refactor(secrets): extracted buildSecretObfuscator from sdk session setup

Moved the obfuscator assembly (secrets.yml + env entries + built-in
credential patterns, placeholder-key minting rules, redaction-only
fallback) from createAgentSessionScoped into the secrets module so other
entrypoints can build the same obfuscator.
This commit is contained in:
can1357
2026-08-07 05:59:58 +02:00
parent 2ad61c7b92
commit 35ab3ece48
2 changed files with 57 additions and 49 deletions
+5 -48
View File
@@ -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
+52 -1
View File
@@ -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<SecretObfuscator | undefined> {
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<SecretEntry[]> {
try {
const text = await Bun.file(filePath).text();