diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 3f3ae2bcc..dbf3d99f4 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -123,18 +123,12 @@ import lateDiagnosticTemplate from "./prompts/tools/lsp-late-diagnostic.md" with import { AgentLifecycleManager } from "./registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, MAIN_AGENT_ID } from "./registry/agent-registry"; import { - builtinCredentialSecretEntries, - collectEnvSecrets, + buildSecretObfuscator, deobfuscateSessionContext, deobfuscateToolArguments, - getExistingSecretPlaceholderKey, - getSecretPlaceholderKey, - getSecretPlaceholderKeySync, - loadSecrets, obfuscateMessages, obfuscateProviderContext, - SecretObfuscator, - secretEntriesNeedPlaceholderKey, + type SecretObfuscator, } from "./secrets"; import { AgentSession, type InitialRetryFallbackState, type PlanYolo, type Prewalk } from "./session/agent-session"; import { discoverAuthStorage as discoverAuthStorageFromConfig } from "./session/auth-broker-config"; @@ -1377,46 +1371,9 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro // Load and create secret obfuscator early so resumed session state and prompt warnings // reflect actual loaded secrets, not just the setting toggle. - let obfuscator: SecretObfuscator | undefined; - if (settings.get("secrets.enabled")) { - const fileEntries = await logger.time("loadSecrets", loadSecrets, cwd, agentDir); - const envEntries = collectEnvSecrets(); - // Built-in credential-pattern entries come last so user-configured entries - // (plain literals, custom regexes) take precedence in the scan order. - const allEntries = [...envEntries, ...fileEntries, ...builtinCredentialSecretEntries()]; - // Only CONFIGURED entries force startup key creation: a configured - // obfuscate-mode secret — or a default (no custom `replacement`) - // replace-mode regex whose key-derived idempotent fallback marker needs a - // stable key across restarts (see `secretEntryNeedsPlaceholderKey`) — - // mints placeholders as soon as the obfuscator is built. The built-in - // credential-pattern entry matches dynamically, so it resolves the - // persisted key lazily on first match instead of creating the key file - // for every secrets.enabled session; a session whose content never - // contains a credential-shaped token must not require the key, otherwise a - // headless run on an unwritable default config root pays for a feature it - // does not use. - const needsPlaceholderKey = secretEntriesNeedPlaceholderKey([...envEntries, ...fileEntries]); - const explicitAgentDir = options.agentDir; - const placeholderKey = needsPlaceholderKey - ? await getSecretPlaceholderKey(explicitAgentDir) - : await getExistingSecretPlaceholderKey(explicitAgentDir); - if (allEntries.length > 0) { - obfuscator = new SecretObfuscator( - allEntries, - placeholderKey ?? (() => getSecretPlaceholderKeySync(explicitAgentDir)), - ); - } - if (obfuscator?.hasSecrets() !== true && placeholderKey !== undefined) { - // No configured entry produced an active secret (e.g. only ignored short - // plain entries, or no entries at all), but a persisted key exists. Build a - // redaction-only obfuscator so a tool read of the key file does not ship the - // reusable HMAC key to the provider. - obfuscator = new SecretObfuscator( - [{ type: "plain", mode: "replace", content: placeholderKey }], - placeholderKey, - ); - } - } + const obfuscator: SecretObfuscator | undefined = settings.get("secrets.enabled") + ? await buildSecretObfuscator(cwd, agentDir, options.agentDir) + : undefined; const secretsEnabled = obfuscator?.hasSecrets() === true; // An abnormal process exit after a non-terminal message tail is durable diff --git a/packages/coding-agent/src/secrets/index.ts b/packages/coding-agent/src/secrets/index.ts index 7d9429f12..0c3501528 100644 --- a/packages/coding-agent/src/secrets/index.ts +++ b/packages/coding-agent/src/secrets/index.ts @@ -4,7 +4,13 @@ import * as path from "node:path"; import { SENSITIVE_TOKEN_RE } from "@oh-my-pi/pi-ai/providers/transform-messages"; import { getSecretPlaceholderKeyPath, isEnoent, logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; -import { regexHasUnresolvableShortMatchFallback, type SecretEntry, sanitizeSecretFriendlyName } from "./obfuscator"; +import { + regexHasUnresolvableShortMatchFallback, + type SecretEntry, + SecretObfuscator, + sanitizeSecretFriendlyName, + secretEntriesNeedPlaceholderKey, +} from "./obfuscator"; import { compileSecretRegex } from "./regex"; const PLACEHOLDER_KEY_RE = /^[A-Za-z0-9_-]{43}$/; @@ -223,6 +229,51 @@ export function builtinCredentialSecretEntries(): SecretEntry[] { ]; } +/** + * Build the session secret obfuscator from every configured source: secrets.yml + * (project + global), secret-shaped environment variables, and the built-in + * credential patterns. Callers gate on `secrets.enabled`. + * + * Only CONFIGURED entries force startup key creation: a configured + * obfuscate-mode secret — or a default (no custom `replacement`) replace-mode + * regex whose key-derived idempotent fallback marker needs a stable key across + * restarts (see `secretEntryNeedsPlaceholderKey`) — mints placeholders as soon + * as the obfuscator is built. The built-in credential-pattern entry matches + * dynamically, so it resolves the persisted key lazily on first match instead + * of creating the key file for every secrets-enabled session. + * + * When no configured entry produced an active secret but a persisted key + * exists, returns a redaction-only obfuscator so a tool read of the key file + * does not ship the reusable HMAC key to the provider. Returns undefined when + * there is nothing to protect. + * + * `keyDir` is the explicit agent dir override for the placeholder-key file + * (default XDG/agent location when omitted). + */ +export async function buildSecretObfuscator( + cwd: string, + agentDir: string, + keyDir?: string, +): Promise { + const fileEntries = await logger.time("loadSecrets", loadSecrets, cwd, agentDir); + const envEntries = collectEnvSecrets(); + // Built-in credential-pattern entries come last so user-configured entries + // (plain literals, custom regexes) take precedence in the scan order. + const allEntries = [...envEntries, ...fileEntries, ...builtinCredentialSecretEntries()]; + const needsPlaceholderKey = secretEntriesNeedPlaceholderKey([...envEntries, ...fileEntries]); + const placeholderKey = needsPlaceholderKey + ? await getSecretPlaceholderKey(keyDir) + : await getExistingSecretPlaceholderKey(keyDir); + let obfuscator: SecretObfuscator | undefined; + if (allEntries.length > 0) { + obfuscator = new SecretObfuscator(allEntries, placeholderKey ?? (() => getSecretPlaceholderKeySync(keyDir))); + } + if (obfuscator?.hasSecrets() !== true && placeholderKey !== undefined) { + obfuscator = new SecretObfuscator([{ type: "plain", mode: "replace", content: placeholderKey }], placeholderKey); + } + return obfuscator; +} + async function loadSecretsFile(filePath: string): Promise { try { const text = await Bun.file(filePath).text();