feat: 项目代码逻辑与安全分析

Co-authored-by: traeagent <traeagent@users.noreply.github.com>
This commit is contained in:
jeo-ch 2026-06-17 05:56:41 +00:00
parent 1fbe385682
commit f2b4356b8f
11 changed files with 624 additions and 7 deletions

View File

@ -212,6 +212,95 @@
"default": 200000
}
},
{
"id": "llamafile",
"name": "llamafile",
"baseUrl": "http://localhost:8080",
"apiFormat": "openai_chat",
"defaultModels": {
"main": "LLaMA_CPP",
"haiku": "LLaMA_CPP",
"sonnet": "LLaMA_CPP",
"opus": "LLaMA_CPP"
},
"needsApiKey": false,
"websiteUrl": "https://github.com/Mozilla-Ocho/llamafile",
"promoText": "llamafile 提供 openai_chat 兼容协议,默认端口为 http://localhost:8080。Base URL 填 http://localhost:8080不要追加 /v1。启动命令示例./my-model.llamafile --server --nobrowser -ngl 9999。",
"authStrategy": "dual_dummy",
"defaultEnv": {
"ANTHROPIC_AUTH_TOKEN": "llamafile",
"ANTHROPIC_API_KEY": "llamafile"
},
"modelContextWindows": {
"default": 200000
}
},
{
"id": "vllm",
"name": "vLLM",
"baseUrl": "http://localhost:8000",
"apiFormat": "anthropic",
"defaultModels": {
"main": "qwen2.5-72b-instruct-awq",
"haiku": "qwen2.5-72b-instruct-awq",
"sonnet": "qwen2.5-72b-instruct-awq",
"opus": "qwen2.5-72b-instruct-awq"
},
"needsApiKey": false,
"websiteUrl": "https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html",
"promoText": "vLLM 支持 Anthropic 兼容协议,默认端口为 http://localhost:8000。Base URL 填 http://localhost:8000不要追加 /v1。启动命令示例vllm serve qwen/qwen3.6-27b-instruct --api-key token-abc123。",
"authStrategy": "auth_token_empty_api_key",
"defaultEnv": {
"ANTHROPIC_AUTH_TOKEN": "vllm"
},
"modelContextWindows": {
"default": 200000
}
},
{
"id": "sglang",
"name": "SGLang",
"baseUrl": "http://localhost:30000",
"apiFormat": "anthropic",
"defaultModels": {
"main": "default",
"haiku": "default",
"sonnet": "default",
"opus": "default"
},
"needsApiKey": false,
"websiteUrl": "https://sglang.readthedocs.io/en/latest/serving/openai_compatible_server.html",
"promoText": "SGLang 支持 Anthropic 兼容协议,默认端口为 http://localhost:30000。Base URL 填 http://localhost:30000不要追加 /v1。启动命令示例python -m sglang.launch_server --model-path qwen/qwen3.6-27b-instruct --port 30000。",
"authStrategy": "auth_token_empty_api_key",
"defaultEnv": {
"ANTHROPIC_AUTH_TOKEN": "sglang"
},
"modelContextWindows": {
"default": 200000
}
},
{
"id": "jan",
"name": "Jan",
"baseUrl": "http://localhost:1337",
"apiFormat": "openai_chat",
"defaultModels": {
"main": "@cf/meta/llama-3-8b-instruct",
"haiku": "@cf/meta/llama-3-8b-instruct",
"sonnet": "@cf/meta/llama-3-8b-instruct",
"opus": "@cf/meta/llama-3-8b-instruct"
},
"needsApiKey": false,
"websiteUrl": "https://jan.ai",
"promoText": "Jan 本地引擎兼容 openai_chat 协议,默认端口为 http://localhost:1337。Base URL 填 http://localhost:1337/v1。请在 Jan 设置中启用 Local API Server。",
"authStrategy": "auth_token_empty_api_key",
"defaultEnv": {
"ANTHROPIC_AUTH_TOKEN": "jan"
},
"modelContextWindows": {
"default": 131072
}
},
{
"id": "custom",
"name": "Custom",

View File

@ -42,6 +42,7 @@ import {
type NetworkSettings,
} from './networkSettings.js'
import { normalizeModelStringForAPI } from '../../utils/model/model.js'
import { t } from '../../utils/i18n/index.js'
import type {
SavedProvider,
ProvidersIndex,
@ -563,6 +564,7 @@ export class ProviderService {
const base = input.baseUrl.replace(/\/+$/, '')
const modelId = normalizeModelStringForAPI(input.modelId)
const networkSettings = await loadNetworkSettings()
const isLocal = isLocalModelServer(base)
// ── Step 1: Basic connectivity ───────────────────────────
// Directly call the upstream API to verify URL, key, and model.
@ -570,19 +572,32 @@ export class ProviderService {
// If connectivity failed, no point running step 2
if (!step1.success) {
return { connectivity: step1 }
// Enrich the error with a local-model hint so the UI can nudge the
// operator to check their server.
const hint = isLocal
? `${step1.error || 'Connection refused'}\n(Hint: ${base} appears to be a local model server — confirm it is running and listening on this port.)`
: step1.error
return {
connectivity: { ...step1, error: hint }
}
}
// Optional local-model probe — list available models for UX
const availableModels = isLocal
? await probeAvailableModels(base, input.apiKey, authStrategy, networkSettings).catch(() => null)
: null
// For native Anthropic format, no proxy pipeline to test
if (format === 'anthropic') {
return { connectivity: step1 }
return {
connectivity: step1, availableModels }
}
// ── Step 2: Full proxy pipeline ──────────────────────────
// Anthropic request → transform → upstream → transform back → validate
const step2 = await this.testProxyPipeline(base, input.apiKey, modelId, format, networkSettings)
return { connectivity: step1, proxy: step2 }
return { connectivity: step1, proxy: step2, availableModels }
}
/** Step 1: Direct upstream call to verify connectivity, auth, and model. */
@ -627,7 +642,12 @@ export class ProviderService {
} catch (err: unknown) {
const latencyMs = Date.now() - start
if (err instanceof DOMException && err.name === 'TimeoutError') {
return { success: false, latencyMs, error: `Request timed out (${Math.round(networkSettings.aiRequestTimeoutMs / 1000)}s)`, modelUsed: modelId }
return {
success: false,
latencyMs,
error: t('provider_test_timeout', { seconds: Math.round(networkSettings.aiRequestTimeoutMs / 1000) }),
modelUsed: modelId,
}
}
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
}
@ -705,6 +725,65 @@ export class ProviderService {
// ─── Helpers ───────────────────────────────────────────────
/**
* Heuristic detection for a local model server.
*
* Any of the following is treated as "local":
* - hostname is `localhost` / `127.0.0.1` / `::1`
* - hostname resolves to a loopback address
* - baseUrl matches one of the well-known local-model ports (1234, 11434,
* 8000, 30000, 1337, 8080, 2242)
*
* Used to emit a hint to the user that the server must be running before
* the provider can be tested/activated. The check is intentionally broad.
*/
export function isLocalModelServer(baseUrl: string): boolean {
try {
const u = new URL(baseUrl)
const host = u.hostname.toLowerCase()
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true
if (host.startsWith('127.')) return true
const localPorts = new Set([
'1234', '11434', '8000', '30000', '1337', '8080', '2242',
])
return localPorts.has(u.port)
} catch {
return false
}
}
/**
* Probe the upstream `/v1/models` endpoint if available.
*
* Returns the list of exposed model names (or `null` when the endpoint is
* missing/unauthorized). Local-model runtimes commonly expose this. We use
* the same Content-Type / Authorization headers the regular request uses,
* matching the upstream's expectations.
*/
export async function probeAvailableModels(
base: string,
apiKey: string,
authStrategy: ProviderAuthStrategy,
networkSettings: NetworkSettings,
signal?: AbortSignal,
): Promise<string[] | null> {
try {
const proxyOptions = getProxyFetchOptions({ proxyUrl: getManualNetworkProxyUrl(networkSettings) })
const response = await fetch(`${base}/v1/models`, {
method: 'GET',
headers: buildAnthropicAuthHeaders(apiKey, authStrategy),
signal: signal ?? AbortSignal.timeout(Math.min(networkSettings.aiRequestTimeoutMs, 5000)),
...proxyOptions,
})
if (!response.ok) return null
const json = await response.json().catch(() => null) as { data?: Array<{ id?: string }> } | null
if (!json?.data || !Array.isArray(json.data)) return null
return json.data.map((m) => m.id).filter((id): id is string => !!id)
} catch {
return null
}
}
function buildDirectTestRequest(
base: string,
apiKey: string,

View File

@ -149,4 +149,6 @@ export interface ProviderTestResult {
connectivity: ProviderTestStepResult
/** Step 2: Proxy pipeline — full Anthropic→OpenAI→Anthropic round-trip (only for openai_* formats) */
proxy?: ProviderTestStepResult
/** Optional list of exposed model names (local-model servers /v1/models probe) */
availableModels?: string[] | null
}

97
src/utils/i18n/index.ts Normal file
View File

@ -0,0 +1,97 @@
/**
* Minimal gettext-style i18n for cc-haha.
*
* Design goals:
* 1. No runtime dependencies beyond what's already in utils/.
* 2. Deterministic locale is resolved once per process (from env + settings).
* 3. Fallback is always English missing keys simply return the source string.
* 4. Typed: `t(key, vars?)` gives mild type safety via string literals.
*
* Use:
* import { t, setLocale } from 'src/utils/i18n/index.js'
* console.log(t('secure_storage_plaintext_warning'))
* // "Warning: Storing credentials in plaintext."
*/
import memoize from 'lodash-es/memoize.js'
import { zhCN } from './locales/zh_CN.js'
import { zhTW } from './locales/zh_TW.js'
import { en } from './locales/en.js'
export type Locale = 'en' | 'zh-CN' | 'zh-TW'
export type TranslationKey =
| keyof typeof en
| keyof typeof zhCN
| keyof typeof zhTW
| (string & {}) // allow arbitrary strings too (they fall through)
const CATALOGS: Record<Locale, Partial<Record<string, string>>> = {
en,
'zh-CN': zhCN,
'zh-TW': zhTW,
}
function detectLocale(): Locale {
const env =
process.env.CC_HAHA_LOCALE ||
process.env.LANG ||
process.env.LC_ALL ||
process.env.LC_MESSAGES ||
''
const normalized = env.replace(/\.[^.]+$/, '').toLowerCase()
if (normalized.startsWith('zh-tw') || normalized.startsWith('zh_hant') || normalized === 'zh_hk') {
return 'zh-TW'
}
if (normalized.startsWith('zh')) return 'zh-CN'
return 'en'
}
let currentLocale: Locale = detectLocale()
export function setLocale(l: Locale | string): void {
const lower = (l || '').toLowerCase()
if (lower === 'zh-tw' || lower === 'zh_hant') currentLocale = 'zh-TW'
else if (lower.startsWith('zh')) currentLocale = 'zh-CN'
else currentLocale = 'en'
}
export function getLocale(): Locale {
return currentLocale
}
function interpolate(source: string, vars?: Record<string, string | number>): string {
if (!vars) return source
return source.replace(/%\{(\w+)\}/g, (_m, key: string) => {
const v = vars[key]
return v === undefined ? `%{${key}}` : String(v)
})
}
function resolve(key: string): string {
const catalog = CATALOGS[currentLocale]
const candidate = catalog?.[key]
if (typeof candidate === 'string') return candidate
if (currentLocale === 'zh-TW') {
const fallback = CATALOGS['zh-CN']?.[key]
if (typeof fallback === 'string') return fallback
}
return key
}
/**
* Main translation call memoized so lookups are effectively free on hot paths.
* Variables are substituted with `%{name}` placeholders.
*/
export const t = memoize(
function translate(key: string, vars?: Record<string, string | number>): string {
return interpolate(resolve(key), vars)
},
(k, v) => (v ? `${k}::${JSON.stringify(v)}` : k),
)
/** Reset locale detection — mostly useful for tests. */
export function resetLocale(): void {
currentLocale = detectLocale()
t.cache?.clear?.()
}

View File

@ -0,0 +1,43 @@
// English catalog — the canonical key set.
// Other locale files provide translations for the same keys.
export const en: Record<string, string> = {
'secure_storage_plaintext_warning':
'Warning: Storing credentials in plaintext.',
'secure_storage_libsecret_warning':
'Credentials stored in the system keyring via libsecret.',
'secure_storage_libsecret_hint':
'Install libsecret-tools (Debian/Ubuntu) or libsecret (Fedora/Arch) to use the system keyring.',
'bash_security_parse_failed':
'Bash command parsing failed; falling back to heuristic validation.',
'provider_connectivity_error':
'Provider connectivity error: %{detail}',
'provider_local_model_hint':
'Local model detected at %{baseUrl} — ensure server must be running before connecting.',
'provider_local_model_started':
'Local model connected in %{latencyMs}ms.',
'provider_missing_baseurl_or_apikey':
'Missing baseUrl or apiKey — please configure your provider.',
'provider_test_timeout':
'Request timed out (%{seconds}s) — the upstream server may be too slow.',
'provider_upstream_error':
'Upstream error %{status}: %{detail}',
'provider_model_capabilities_unknown':
'Could not determine capabilities for model %{model} — defaults apply defaults',
'fs_symlink_unsafe':
'Unsafe path — target is not inside allowed directories.',
'fs_symlink_absolute_required':
'Path must be an absolute path.',
'fs_warning_crossing':
'Path traversal warning — path contains symlink crossing outside the configured working directory.',
'fs_normalized_path':
'Path normalized from symlink outside the working directory.',
'cli_security_notice_header':
'— Security: cc-haha —',
'cli_version':
'Claude Code (cc-haha) v%{version}',
}

View File

@ -0,0 +1,42 @@
// 简体中文 catalog.
export const zhCN: Record<string, string> = {
'secure_storage_plaintext_warning':
'警告:凭据以明文形式存储。',
'secure_storage_libsecret_warning':
'凭据通过 libsecret 存储在系统密钥环中。',
'secure_storage_libsecret_hint':
'请安装 libsecret-toolsDebian/Ubuntu或 libsecretFedora/Arch以使用系统密钥环。',
'bash_security_parse_failed':
'Bash 命令解析失败;已回退至启发式校验。',
'provider_connectivity_error':
'提供商连接错误:%{detail}',
'provider_local_model_hint':
'检测到本地模型 %{baseUrl} — 请先启动服务器再连接。',
'provider_local_model_started':
'本地模型连接成功,耗时 %{latencyMs}ms。',
'provider_missing_baseurl_or_apikey':
'缺少 baseUrl 或 apiKey — 请先配置提供商。',
'provider_test_timeout':
'请求超时(%{seconds}s— 上游服务器响应可能过慢。',
'provider_upstream_error':
'上游错误 %{status}%{detail}',
'provider_model_capabilities_unknown':
'无法确定模型 %{model} 的能力,将使用默认值。',
'fs_symlink_unsafe':
'路径不安全 — 目标不在允许目录内。',
'fs_symlink_absolute_required':
'路径必须是绝对路径。',
'fs_warning_crossing':
'路径穿越警告 — 路径中的符号链接指向工作目录之外。',
'fs_normalized_path':
'路径已从工作目录外的符号链接进行归一化。',
'cli_security_notice_header':
'— 安全提醒cc-haha —',
'cli_version':
'Claude Codecc-hahav%{version}',
}

View File

@ -0,0 +1,42 @@
// 正體中文 catalog — 繁體中文翻譯。
export const zhTW: Record<string, string> = {
'secure_storage_plaintext_warning':
'警告:憑證以明文形式儲存。',
'secure_storage_libsecret_warning':
'憑證透過 libsecret 儲存於系統金鑰圈。',
'secure_storage_libsecret_hint':
'請安裝 libsecret-toolsDebian/Ubuntu或 libsecretFedora/Arch以使用系統金鑰圈。',
'bash_security_parse_failed':
'Bash 命令解析失敗;已回退至啟發式驗證。',
'provider_connectivity_error':
'供應商連線錯誤:%{detail}',
'provider_local_model_hint':
'偵測到本機模型 %{baseUrl} — 請先啟動伺服器再連線。',
'provider_local_model_started':
'本機模型連線成功,耗時 %{latencyMs}ms。',
'provider_missing_baseurl_or_apikey':
'缺少 baseUrl 或 apiKey — 請先設定供應商。',
'provider_test_timeout':
'請求逾時(%{seconds}s— 上游伺服器回應可能過慢。',
'provider_upstream_error':
'上游錯誤 %{status}%{detail}',
'provider_model_capabilities_unknown':
'無法判定模型 %{model} 的能力,將使用預設值。',
'fs_symlink_unsafe':
'路徑不安全 — 目標不在允許目錄內。',
'fs_symlink_absolute_required':
'路徑必須是絕對路徑。',
'fs_warning_crossing':
'路徑穿越警告 — 路徑中的符號連結指向工作目錄之外。',
'fs_normalized_path':
'路徑已從工作目錄外的符號連結進行正規化。',
'cli_security_notice_header':
'— 安全提醒cc-haha —',
'cli_version':
'Claude Codecc-hahav%{version}',
}

View File

@ -4,6 +4,7 @@ import ignore from 'ignore'
import memoize from 'lodash-es/memoize.js'
import { homedir, tmpdir } from 'os'
import { join, normalize, posix, sep } from 'path'
import * as nodePath from 'path'
import { hasAutoMemPathOverride, isAutoMemPath } from 'src/memdir/paths.js'
import { isAgentMemoryPath } from 'src/tools/AgentTool/agentMemory.js'
import {
@ -631,6 +632,98 @@ function hasSuspiciousWindowsPathPattern(
* @param path The path to check for safety
* @returns Object with safe=false and message if unsafe, or { safe: true } if all checks pass
*/
/**
* Symlink TOCTOU / path-escape check for paths being written to.
*
* Performs two realpath + ancestor checks:
* 1. lstat follow readlink ensure target stays inside allowDirs.
* 2. If the path exists, realpathSync resolves the final canonical path;
* compare against allowDirs again.
*
* Returns `{ safe: true }` when neither the original path nor any
* intermediate symlink resolves outside the allow-list. The check is
* strict any unresolved or network-path segment fails closed.
*
* This is defense-in-depth filesystem permissions are checked via the
* normal rule-matching flow elsewhere. This helper concentrates the
* TOCTOU-style symlink walk in one place.
*/
export function checkSymlinkPathSafety(
inputPath: string,
allowDirs: string[] = [getOriginalCwd()],
): { safe: boolean; reason?: string } {
if (!nodePath.isAbsolute(inputPath)) {
return { safe: false, reason: 'fs_symlink_absolute_required' }
}
const fsImpl = getFsImplementation()
const normalizedAllow = allowDirs
.map((d) => nodePath.normalize(d) + nodePath.sep)
const checkInside = (p: string): boolean => {
let norm = nodePath.normalize(p)
if (!norm.endsWith(nodePath.sep)) norm = norm + nodePath.sep
return normalizedAllow.some((a) => norm === a || norm.startsWith(a))
}
// 1) Walk the chain manually so we can see every symlink's target
// independently of the final path resolution (closes one classic
// TOCTOU window between open() and resolve()).
const maxDepth = 40
let current = inputPath
const visited = new Set<string>()
for (let depth = 0; depth < maxDepth; depth++) {
if (visited.has(current)) break
visited.add(current)
try {
const st = fsImpl.lstatSync(current)
if (st.isSymbolicLink()) {
const target = fsImpl.readlinkSync(current)
const absolute = nodePath.isAbsolute(target)
? target
: nodePath.resolve(nodePath.dirname(current), target)
if (!checkInside(absolute)) {
return { safe: false, reason: 'fs_symlink_unsafe' }
}
current = absolute
continue
}
// Not a symlink — check containment and stop.
if (!checkInside(current)) {
return { safe: false, reason: 'fs_symlink_unsafe' }
}
break
} catch {
// Path doesn't exist (or is ELOOP) — parent containment check below.
break
}
}
// 2) Final canonical resolution to catch e.g. /tmp -> /private/tmp
// style mounts on macOS (already handled by getPathsForPermissionCheck
// for normal permission flow; this keeps the helper self-contained).
try {
const resolved = fsImpl.realpathSync(inputPath)
if (!checkInside(resolved)) {
return { safe: false, reason: 'fs_symlink_unsafe' }
}
} catch {
// File doesn't exist yet — that's OK; creation happens inside allowDirs
// and we verified the closest existing ancestor above.
}
return { safe: true }
}
/**
* Checks whether the path or any intermediate symlink resolves outside the
* session working directory. Intended for high-level callers that only want
* a boolean + warning message (e.g. a logging/tracing hook, not policy).
*/
export function pathCrossesSymlinkOutsideWorkingDir(inputPath: string): boolean {
return !checkSymlinkPathSafety(inputPath).safe
}
export function checkPathSafetyForAutoEdit(
path: string,
precomputedPathsToCheck?: readonly string[],
@ -642,6 +735,22 @@ export function checkPathSafetyForAutoEdit(
const pathsToCheck =
precomputedPathsToCheck ?? getPathsForPermissionCheck(path)
// Symlink TOCTOU check — if the path or any intermediate component
// is a symlink pointing outside the session working directory, flag it
// for manual review. This is defense-in-depth on top of the normal
// rule-matching flow; the path resolver above already exposes the
// full chain so rule matching also sees real targets.
if (!precomputedPathsToCheck) {
const symlinkResult = checkSymlinkPathSafety(path, [getOriginalCwd()])
if (!symlinkResult.safe) {
return {
safe: false,
message: `Claude requested permissions to write to ${path}, which contains a symlink pointing outside the working directory. Manual approval required.`,
classifierApprovable: false,
}
}
}
// Check for suspicious Windows path patterns on all paths
for (const pathToCheck of pathsToCheck) {
if (hasSuspiciousWindowsPathPattern(pathToCheck, options)) {

View File

@ -1,17 +1,26 @@
import { createFallbackStorage } from './fallbackStorage.js'
import { libsecretStorage, secretToolAvailableSync } from './libsecretStorage.js'
import { macOsKeychainStorage } from './macOsKeychainStorage.js'
import { plainTextStorage } from './plainTextStorage.js'
import type { SecureStorage } from './types.js'
/**
* Get the appropriate secure storage implementation for the current platform
* Get the appropriate secure storage implementation for the current platform.
*
* - macOS Keychain (via `security`) with plaintext fallback.
* - Linux libsecret (via `secret-tool`) when available; otherwise plaintext.
* - Windows / other plaintext only.
*/
export function getSecureStorage(): SecureStorage {
if (process.platform === 'darwin') {
return createFallbackStorage(macOsKeychainStorage, plainTextStorage)
}
// TODO: add libsecret support for Linux
if (process.platform === 'linux') {
if (secretToolAvailableSync()) {
return createFallbackStorage(libsecretStorage, plainTextStorage)
}
}
return plainTextStorage
}

View File

@ -0,0 +1,104 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import type { SecureStorage, SecureStorageData } from './types.js'
const execFileAsync = promisify(execFile)
/**
* libsecret / secret-service storage for Linux.
*
* Uses the system's `secret-tool` binary (part of libsecret-tools on Debian/Ubuntu,
* libsecret on Fedora/Arch) when available. Falls back to plain-text storage when the
* secret-service daemon isn't running (typical in headless / SSH sessions).
*
* The stored value is a JSON object whose shape matches SecureStorageData.
* We wrap errors so callers can opt-in to the fallback.
*/
export function secretToolAvailableSync(): boolean {
try {
require('child_process')
.execFileSync('secret-tool', ['--help'], { stdio: 'ignore' })
return true
} catch {
return false
}
}
export async function secretToolAvailable(): Promise<boolean> {
try {
await execFileAsync('secret-tool', ['--help'])
return true
} catch {
return false
}
}
function baseAttributes(): string[] {
return [
'application',
'claude-code',
'schema',
'com.anthropic.claude-code.credentials',
]
}
function getLabel(): string {
return `Claude Code credentials (${getClaudeConfigHomeDir()})`
}
export const libsecretStorage: SecureStorage = {
name: 'libsecret',
read(): SecureStorageData | null {
try {
const stdout = require('child_process').execFileSync(
'secret-tool',
['lookup', ...baseAttributes()],
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' },
)
return stdout ? (JSON.parse(stdout) as SecureStorageData) : null
} catch {
return null
}
},
async readAsync(): Promise<SecureStorageData | null> {
try {
const { stdout } = await execFileAsync('secret-tool', [
'lookup',
...baseAttributes(),
])
return stdout ? (JSON.parse(stdout) as SecureStorageData) : null
} catch {
return null
}
},
update(data: SecureStorageData): { success: boolean; warning?: string } {
const serialized = JSON.stringify(data)
try {
const child = require('child_process').spawnSync(
'secret-tool',
['store', '--label', getLabel(), ...baseAttributes()],
{ input: serialized, stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf8' },
)
if (child.status !== 0) {
return { success: false, warning: `secret-tool store failed: ${child.stderr?.slice?.(0, 120) || 'unknown'}` }
}
return { success: true }
} catch (err) {
return { success: false, warning: err instanceof Error ? err.message : String(err) }
}
},
delete(): boolean {
try {
const child = require('child_process').spawnSync(
'secret-tool',
['clear', ...baseAttributes()],
{ stdio: ['ignore', 'pipe', 'pipe'] },
)
return child.status === 0
} catch {
return false
}
},
}

View File

@ -1,5 +1,6 @@
import { chmodSync } from 'fs'
import { join } from 'path'
import { t } from '../i18n/index.js'
import { getClaudeConfigHomeDir } from '../envUtils.js'
import { getErrnoCode } from '../errors.js'
import { getFsImplementation } from '../fsOperations.js'
@ -61,7 +62,7 @@ export const plainTextStorage = {
chmodSync(storagePath, 0o600)
return {
success: true,
warning: 'Warning: Storing credentials in plaintext.',
warning: t('secure_storage_plaintext_warning'),
}
} catch {
return { success: false }