diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc731f98d9..6a0c1c60bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -181,6 +181,10 @@ jobs: if: runner.os == 'Linux' uses: t1m0thyj/unlock-keyring@v1 + - name: Install Secret Service CLI + if: runner.os == 'Linux' + run: sudo apt-get install -qq libsecret-tools + - name: Run tests (extended timeout) timeout-minutes: 15 # Unit tests must pass for fork PRs (no secrets). Keep API-dependent tests diff --git a/build.js b/build.js index 2734f4dc10..aba92899ba 100644 --- a/build.js +++ b/build.js @@ -125,12 +125,6 @@ if (content.startsWith("#!")) { content = content.slice(content.indexOf("\n") + 1); } -// Patch secrets requirement back in for node build -content = content.replace( - `(()=>{throw new Error("Cannot require module "+"bun");})().secrets`, - `globalThis.Bun.secrets`, -); - const withShebang = `#!/usr/bin/env node ${content}`; await Bun.write(outputPath, withShebang); diff --git a/scripts/isolated-unit-tests.json b/scripts/isolated-unit-tests.json index 2395c2edaa..8e63daa004 100644 --- a/scripts/isolated-unit-tests.json +++ b/scripts/isolated-unit-tests.json @@ -97,6 +97,16 @@ "timeoutMs": 15000, "reason": "Uses a top-level Bun module mock for the backend client." }, + { + "path": "src/utils/secret-backends.test.ts", + "timeoutMs": 30000, + "reason": "Mutates the secrets runtime override and service name while exercising the real OS credential store." + }, + { + "path": "src/utils/secrets.test.ts", + "timeoutMs": 30000, + "reason": "Mutates the process-global secrets service name while exercising the real OS credential store." + }, { "path": "src/websocket/listen-client-concurrency.test.ts", "timeoutMs": 30000, diff --git a/src/test-utils/test-process-env.test.ts b/src/test-utils/test-process-env.test.ts index 9a62738d07..e81a38363f 100644 --- a/src/test-utils/test-process-env.test.ts +++ b/src/test-utils/test-process-env.test.ts @@ -43,6 +43,7 @@ describe("test process env helpers", () => { expect(env.LETTA_MEMORY_DIR).toBeUndefined(); expect(env.MEMORY_DIR).toBeUndefined(); expect(env.LETTA_DISABLE_SESSION_PERSIST).toBe("1"); + expect(env.LETTA_SKIP_KEYCHAIN_CHECK).toBe("1"); expect(env.DISABLE_AUTOUPDATER).toBe("1"); }); diff --git a/src/test-utils/test-process-env.ts b/src/test-utils/test-process-env.ts index 3b0bffacfd..30abeb01e5 100644 --- a/src/test-utils/test-process-env.ts +++ b/src/test-utils/test-process-env.ts @@ -9,6 +9,7 @@ export function createIsolatedCliTestEnv( Object.assign(env, { LETTA_DISABLE_SESSION_PERSIST: "1", + LETTA_SKIP_KEYCHAIN_CHECK: "1", DISABLE_AUTOUPDATER: "1", }); diff --git a/src/utils/secret-backends.test.ts b/src/utils/secret-backends.test.ts new file mode 100644 index 0000000000..e494c4c7b5 --- /dev/null +++ b/src/utils/secret-backends.test.ts @@ -0,0 +1,735 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { Buffer } from "node:buffer"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { __getBunMacKeychainHelperScriptForTests } from "@/utils/secret-backends"; +import { + __getDefaultServiceNameForTests, + __getExplicitNodeSecretBackendForTests, + __getSelectedSecretBackendKindForTests, + __getWindowsCredentialScriptForTests, + __resetSecretWarningStateForTests, + __setSecretRuntimeOverrideForTests, + deleteSecretValue, + getSecretValue, + isKeychainAvailable, + setSecretValue, + setServiceName, +} from "@/utils/secrets"; + +type BunSecretFixture = { + get: (options: { service: string; name: string }) => Promise; + set: (options: { + service: string; + name: string; + value: string; + allowUnrestrictedAccess?: boolean; + }) => Promise; + delete: (options: { service: string; name: string }) => Promise; +}; + +const DEFAULT_SERVICE_NAME = __getDefaultServiceNameForTests(); +const posixTest = process.platform === "win32" ? test.skip : test; +const macosTest = process.platform === "darwin" ? test : test.skip; +const tempDirs: string[] = []; +const bunSecretsForInterop = ( + globalThis as typeof globalThis & { Bun?: { secrets?: BunSecretFixture } } +).Bun?.secrets; +const explicitNodeBackendForInterop = __getExplicitNodeSecretBackendForTests(); +const INTEROP_SERVICE_NAME = `letta-code-interop-${process.platform}-${randomUUID()}`; +const INTEROP_PROBE_NAME = `probe-${randomUUID()}`; +const interopAvailable = await computeInteropAvailable(); + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function writeExecutable(path: string, content: string): void { + writeFileSync(path, content); + chmodSync(path, 0o755); +} + +function fakeBunSecrets( + overrides: Partial = {}, +): BunSecretFixture { + return { + get: overrides.get ?? (async () => null), + set: overrides.set ?? (async () => {}), + delete: overrides.delete ?? (async () => false), + }; +} + +async function computeInteropAvailable(): Promise { + if (!bunSecretsForInterop || !explicitNodeBackendForInterop) { + return false; + } + + try { + await bunSecretsForInterop.get({ + service: INTEROP_SERVICE_NAME, + name: INTEROP_PROBE_NAME, + }); + await explicitNodeBackendForInterop.get({ + service: INTEROP_SERVICE_NAME, + name: INTEROP_PROBE_NAME, + }); + return true; + } catch { + return false; + } +} + +afterEach(() => { + __setSecretRuntimeOverrideForTests(null); + __resetSecretWarningStateForTests(); + setServiceName(DEFAULT_SERVICE_NAME); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("Secret backend selection", () => { + test("selects Bun secrets before platform fallbacks", () => { + __setSecretRuntimeOverrideForTests({ + platform: "linux", + bunSecrets: fakeBunSecrets(), + }); + + expect(__getSelectedSecretBackendKindForTests()).toBe("bun"); + }); + + test("makes Bun-written macOS entries available to headless runtimes", async () => { + const set = mock(async () => {}); + __setSecretRuntimeOverrideForTests({ + platform: "darwin", + bunSecrets: fakeBunSecrets({ set }), + }); + + setServiceName(`letta-code-bun-access-${randomUUID()}`); + await setSecretValue("api-key", "credential-value"); + + expect(set).toHaveBeenCalledWith({ + service: expect.any(String), + name: "api-key", + value: "credential-value", + allowUnrestrictedAccess: true, + }); + }); + + test("normalizes empty values to deletion across runtimes", async () => { + const set = mock(async () => {}); + const remove = mock(async () => true); + __setSecretRuntimeOverrideForTests({ + platform: "linux", + bunSecrets: fakeBunSecrets({ set, delete: remove }), + }); + + setServiceName(`letta-code-empty-${randomUUID()}`); + await setSecretValue("api-key", ""); + + expect(set).not.toHaveBeenCalled(); + expect(remove).toHaveBeenCalledWith({ + service: expect.any(String), + name: "api-key", + }); + }); + + test("selects explicit Node backends by platform when Bun is absent", () => { + __setSecretRuntimeOverrideForTests({ + platform: "darwin", + bunSecrets: null, + }); + expect(__getSelectedSecretBackendKindForTests()).toBe("macos-keyring"); + + __setSecretRuntimeOverrideForTests({ platform: "win32", bunSecrets: null }); + expect(__getSelectedSecretBackendKindForTests()).toBe( + "windows-credential-manager", + ); + + __setSecretRuntimeOverrideForTests({ platform: "linux", bunSecrets: null }); + expect(__getSelectedSecretBackendKindForTests()).toBe( + "linux-secret-service", + ); + + __setSecretRuntimeOverrideForTests({ + platform: "freebsd", + bunSecrets: null, + }); + expect(__getSelectedSecretBackendKindForTests()).toBe(null); + }); + + test("checks Bun backend availability without reading a credential", async () => { + const get = mock(async () => { + throw new Error("availability must not read a credential"); + }); + __setSecretRuntimeOverrideForTests({ + platform: "darwin", + bunSecrets: fakeBunSecrets({ get }), + }); + + expect(await isKeychainAvailable()).toBe(true); + expect(get).not.toHaveBeenCalled(); + }); + + test("honors LETTA_SKIP_KEYCHAIN_CHECK without probing", async () => { + const originalSkip = process.env.LETTA_SKIP_KEYCHAIN_CHECK; + const get = mock(async () => null); + __setSecretRuntimeOverrideForTests({ + platform: "darwin", + bunSecrets: fakeBunSecrets({ get }), + }); + + try { + process.env.LETTA_SKIP_KEYCHAIN_CHECK = "1"; + expect(await isKeychainAvailable()).toBe(false); + expect(get).not.toHaveBeenCalled(); + } finally { + if (originalSkip === undefined) { + delete process.env.LETTA_SKIP_KEYCHAIN_CHECK; + } else { + process.env.LETTA_SKIP_KEYCHAIN_CHECK = originalSkip; + } + } + }); + + test("preserves Bun duplicate-item replacement", async () => { + const operations: string[] = []; + let setCalls = 0; + setServiceName(`letta-code-duplicate-test-${randomUUID()}`); + __setSecretRuntimeOverrideForTests({ + platform: "darwin", + bunSecrets: fakeBunSecrets({ + set: async (options) => { + operations.push(`set:${options.name}`); + setCalls += 1; + if (setCalls === 1) { + throw new Error("already exists in the keychain (code: -25299)"); + } + }, + delete: async (options) => { + operations.push(`delete:${options.name}`); + return true; + }, + }), + }); + + await setSecretValue("duplicate-name", "credential-value"); + + expect(operations).toEqual([ + "set:duplicate-name", + "delete:duplicate-name", + "set:duplicate-name", + ]); + }); +}); + +describe("Secret backend command protocols", () => { + test("keeps Bun Keychain reads non-mutating", () => { + const script = __getBunMacKeychainHelperScriptForTests(); + const readBranchStart = script.indexOf("// Reads must stay non-mutating."); + + expect(readBranchStart).toBeGreaterThan(-1); + const readBranch = script.slice(readBranchStart); + expect(readBranch).toContain("await Bun.secrets.get(locator)"); + expect(readBranch).not.toContain("Bun.secrets.set"); + expect(readBranch).not.toContain("Bun.secrets.delete"); + }); + + posixTest( + "uses macOS security status and stdin without leaking values to argv", + async () => { + const dir = makeTempDir("letta-macos-security-"); + const securityPath = join(dir, "security"); + const argvLog = join(dir, "argv.log"); + const stdinLog = join(dir, "stdin.log"); + writeExecutable( + securityPath, + `#!/bin/sh +printf '%s\\n' "$@" > "$MACOS_ARGV_LOG" +case "$1" in + add-generic-password) + cat > "$MACOS_STDIN_LOG" + exit 0 + ;; + find-generic-password) + case "$MACOS_TEST_MODE" in + missing) echo 'item not found' >&2; exit 44 ;; + denied) echo 'interaction not allowed' >&2; exit 51 ;; + *) printf 'stored-value\\n'; exit 0 ;; + esac + ;; + delete-generic-password) + case "$MACOS_TEST_MODE" in + missing) exit 44 ;; + denied) echo 'interaction not allowed' >&2; exit 51 ;; + *) exit 0 ;; + esac + ;; +esac +exit 2 +`, + ); + __setSecretRuntimeOverrideForTests({ + platform: "darwin", + bunSecrets: null, + bunExecutablePath: null, + macSecurityPath: securityPath, + env: { + MACOS_ARGV_LOG: argvLog, + MACOS_STDIN_LOG: stdinLog, + }, + }); + const backend = __getExplicitNodeSecretBackendForTests("darwin"); + expect(backend).not.toBeNull(); + + await backend?.set({ + service: "letta-code-test", + name: "api-key", + value: "credential-value", + }); + const argvLogValue = readFileSync(argvLog, "utf8"); + expect(argvLogValue).toContain("add-generic-password"); + expect(argvLogValue).toContain("-A"); + expect(argvLogValue.trim().endsWith("-w")).toBe(true); + expect(argvLogValue).not.toContain("credential-value"); + expect(readFileSync(stdinLog, "utf8")).toBe( + "credential-value\ncredential-value\n", + ); + + expect( + await backend?.get({ service: "letta-code-test", name: "api-key" }), + ).toBe("stored-value"); + + __setSecretRuntimeOverrideForTests({ + platform: "darwin", + bunSecrets: null, + bunExecutablePath: null, + macSecurityPath: securityPath, + env: { + MACOS_ARGV_LOG: argvLog, + MACOS_STDIN_LOG: stdinLog, + MACOS_TEST_MODE: "missing", + }, + }); + expect( + await backend?.get({ service: "letta-code-test", name: "missing" }), + ).toBeNull(); + expect( + await backend?.delete({ service: "letta-code-test", name: "missing" }), + ).toBe(false); + + __setSecretRuntimeOverrideForTests({ + platform: "darwin", + bunSecrets: null, + bunExecutablePath: null, + macSecurityPath: securityPath, + env: { + MACOS_ARGV_LOG: argvLog, + MACOS_STDIN_LOG: stdinLog, + MACOS_TEST_MODE: "denied", + }, + }); + await expect( + backend?.get({ service: "letta-code-test", name: "api-key" }), + ).rejects.toThrow("interaction not allowed"); + await expect( + backend?.delete({ service: "letta-code-test", name: "api-key" }), + ).rejects.toThrow("interaction not allowed"); + await expect( + backend?.set({ + service: "letta-code-test", + name: "api-key", + value: "line-one\nline-two", + }), + ).rejects.toThrow("does not accept line breaks"); + }, + ); + + posixTest( + "runs Bun macOS migration outside project config scope", + async () => { + const projectDir = makeTempDir("letta-bun-project-"); + const bunDir = makeTempDir("letta-bun-bin-"); + const bunPath = join(bunDir, "bun"); + const cwdLog = join(projectDir, "cwd.log"); + const envLog = join(projectDir, "env.log"); + writeFileSync( + join(projectDir, "bunfig.toml"), + 'preload = ["./bad.ts"]\n', + ); + writeExecutable( + bunPath, + `#!/bin/sh +pwd > "$BUN_CWD_LOG" +printf '%s|%s|%s\\n' "$BUN_CONFIG" "$BUN_CONFIG_PATH" "$BUN_OPTIONS" > "$BUN_ENV_LOG" +printf '{"ok":true,"valueBase64":null}\\n' +`, + ); + __setSecretRuntimeOverrideForTests({ + platform: "darwin", + bunSecrets: null, + bunExecutablePath: bunPath, + macSecurityPath: null, + env: { + BUN_CWD_LOG: cwdLog, + BUN_ENV_LOG: envLog, + BUN_CONFIG: join(projectDir, "bunfig.toml"), + BUN_CONFIG_PATH: join(projectDir, "bunfig.toml"), + BUN_OPTIONS: "--preload ./bad.ts", + }, + }); + const backend = __getExplicitNodeSecretBackendForTests("darwin"); + expect(backend).not.toBeNull(); + + await backend?.get({ service: "letta-code-test", name: "api-key" }); + + const helperCwd = readFileSync(cwdLog, "utf8").trim(); + expect(helperCwd).toContain("letta-bun-keychain-"); + expect(helperCwd).not.toBe(projectDir); + expect(readFileSync(envLog, "utf8").trim()).toBe("||"); + }, + ); + + posixTest( + "uses secret-tool attrs and stdin without leaking values to argv", + async () => { + const dir = makeTempDir("letta-secret-tool-"); + const logPath = join(dir, "secret-tool.log"); + const service = `letta-code-linux-protocol-${randomUUID()}`; + writeExecutable( + join(dir, "secret-tool"), + `#!/bin/sh +printf '%s\n' "$*" >> "$SECRET_TOOL_LOG" +if [ "$1" = "store" ]; then + IFS= read -r stdin || true + printf 'stdin-bytes:%s\n' "\${#stdin}" >> "$SECRET_TOOL_LOG" + case "$*" in + *credential-value*) exit 64 ;; + esac + exit 0 +fi +if [ "$1" = "lookup" ]; then + if [ "$5" = "missing-name" ]; then exit 1; fi + printf '%s' 'lookup-value' + exit 0 +fi +if [ "$1" = "clear" ]; then + exit 0 +fi +exit 2 +`, + ); + setServiceName(service); + __setSecretRuntimeOverrideForTests({ + platform: "linux", + bunSecrets: null, + env: { + PATH: dir, + DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/letta-fake-bus", + SECRET_TOOL_LOG: logPath, + }, + }); + + await setSecretValue("secret-name", "credential-value"); + expect(await getSecretValue("secret-name", "test secret")).toBe( + "lookup-value", + ); + expect(await deleteSecretValue("secret-name")).toBe(true); + expect(await getSecretValue("missing-name", "missing secret")).toBe(null); + + const log = readFileSync(logPath, "utf8"); + expect(log).toContain( + `store --label ${service}/secret-name service ${service} account secret-name xdg:schema com.oven-sh.bun.Secret`, + ); + expect(log).toContain( + `lookup service ${service} account secret-name xdg:schema com.oven-sh.bun.Secret`, + ); + expect(log).toContain( + `clear service ${service} account secret-name xdg:schema com.oven-sh.bun.Secret`, + ); + expect(log).toContain("stdin-bytes:16"); + expect(log).not.toContain("credential-value"); + }, + ); + + posixTest("treats headless Linux Secret Service as unavailable", async () => { + const dir = makeTempDir("letta-secret-tool-headless-"); + writeExecutable(join(dir, "secret-tool"), "#!/bin/sh\nexit 0\n"); + __setSecretRuntimeOverrideForTests({ + platform: "linux", + bunSecrets: null, + env: { PATH: dir, DBUS_SESSION_BUS_ADDRESS: "" }, + }); + + expect(await isKeychainAvailable()).toBe(false); + }); + + posixTest( + "sends Windows credential payloads on stdin, not argv", + async () => { + const dir = makeTempDir("letta-powershell-"); + const logPath = join(dir, "powershell.log"); + const stdinPath = join(dir, "powershell.stdin.json"); + const powershellPath = join(dir, "powershell.exe"); + writeExecutable( + powershellPath, + `#!/bin/sh +printf '%s\n' "$*" > "$POWERSHELL_LOG" +cat > "$POWERSHELL_STDIN" +printf '{"ok":true}\n' +`, + ); + setServiceName(`letta-code-windows-protocol-${randomUUID()}`); + __setSecretRuntimeOverrideForTests({ + platform: "win32", + bunSecrets: null, + powerShellPath: powershellPath, + env: { POWERSHELL_LOG: logPath, POWERSHELL_STDIN: stdinPath }, + }); + + await setSecretValue("win-name", "credential-value"); + + const argvLog = readFileSync(logPath, "utf8"); + const stdinJson = readFileSync(stdinPath, "utf8"); + expect(argvLog).toContain("-EncodedCommand"); + expect(argvLog).not.toContain("credential-value"); + expect(stdinJson).not.toContain("credential-value"); + expect(stdinJson).toContain( + Buffer.from("credential-value", "utf8").toString("base64"), + ); + }, + ); + + test("uses Windows enterprise-persist generic credentials", () => { + const script = __getWindowsCredentialScriptForTests(); + expect(script).toContain("CRED_TYPE_GENERIC = 1"); + expect(script).toContain("CRED_PERSIST_ENTERPRISE = 3"); + expect(script).toContain('return "$service/$name"'); + expect(script).not.toContain("CRED_PERSIST_LOCAL_MACHINE"); + }); +}); + +describe("Bun.secrets and explicit Node backend interoperability", () => { + test("requires a working cross-runtime backend in platform CI", () => { + if (!process.env.CI) return; + expect(interopAvailable).toBe(true); + }); + + test.skipIf(!interopAvailable)( + "reads and deletes the same OS entries both ways", + async () => { + if (!bunSecretsForInterop || !explicitNodeBackendForInterop) return; + + const bunToNodeName = `bun-to-node-${randomUUID()}`; + const nodeToBunName = `node-to-bun-${randomUUID()}`; + const bunToNodeValue = `test-secret-${randomUUID()}`; + const nodeToBunValue = `test-secret-${randomUUID()}`; + + try { + await Promise.allSettled([ + bunSecretsForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: bunToNodeName, + }), + bunSecretsForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: nodeToBunName, + }), + explicitNodeBackendForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: bunToNodeName, + }), + explicitNodeBackendForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: nodeToBunName, + }), + ]); + + await bunSecretsForInterop.set({ + service: INTEROP_SERVICE_NAME, + name: bunToNodeName, + value: bunToNodeValue, + }); + expect( + await explicitNodeBackendForInterop.get({ + service: INTEROP_SERVICE_NAME, + name: bunToNodeName, + }), + ).toBe(bunToNodeValue); + expect( + await explicitNodeBackendForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: bunToNodeName, + }), + ).toBe(true); + expect( + await bunSecretsForInterop.get({ + service: INTEROP_SERVICE_NAME, + name: bunToNodeName, + }), + ).toBe(null); + + await explicitNodeBackendForInterop.set({ + service: INTEROP_SERVICE_NAME, + name: nodeToBunName, + value: nodeToBunValue, + }); + expect( + await bunSecretsForInterop.get({ + service: INTEROP_SERVICE_NAME, + name: nodeToBunName, + }), + ).toBe(nodeToBunValue); + expect( + await bunSecretsForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: nodeToBunName, + }), + ).toBe(true); + expect( + await explicitNodeBackendForInterop.get({ + service: INTEROP_SERVICE_NAME, + name: nodeToBunName, + }), + ).toBe(null); + } finally { + await Promise.allSettled([ + bunSecretsForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: bunToNodeName, + }), + bunSecretsForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: nodeToBunName, + }), + explicitNodeBackendForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: bunToNodeName, + }), + explicitNodeBackendForInterop.delete({ + service: INTEROP_SERVICE_NAME, + name: nodeToBunName, + }), + ]); + } + }, + 30_000, + ); + + macosTest( + "preserves legacy Bun entries across a real Node process boundary", + async () => { + if (!bunSecretsForInterop) return; + const outputDir = makeTempDir("letta-node-secret-backend-"); + const build = await Bun.build({ + entrypoints: [join(process.cwd(), "src/utils/secret-backends.ts")], + outdir: outputDir, + target: "node", + format: "esm", + }); + expect(build.success).toBe(true); + + const moduleUrl = pathToFileURL( + join(outputDir, "secret-backends.js"), + ).href; + const service = `letta-code-process-interop-${randomUUID()}`; + const bunName = `from-bun-${randomUUID()}`; + const nodeName = `from-node-${randomUUID()}`; + const bunValue = `bun-value-${randomUUID()}`; + const nodeValue = `node-value-${randomUUID()}`; + + try { + // No allowUnrestrictedAccess flag: this models entries written before + // the runtime-independent backend shipped. + await bunSecretsForInterop.set({ + service, + name: bunName, + value: bunValue, + }); + + const node = spawnSync("node", ["--input-type=module"], { + input: ` +const { createExplicitNodeSecretBackend } = await import(process.env.MODULE_URL); +const backend = createExplicitNodeSecretBackend("darwin"); +if (!backend) throw new Error("missing macOS backend"); +const value = await backend.get({ service: process.env.SERVICE, name: process.env.BUN_NAME }); +if (value !== process.env.BUN_VALUE) throw new Error("Bun-to-Node value mismatch"); +await backend.set({ service: process.env.SERVICE, name: process.env.NODE_NAME, value: process.env.NODE_VALUE }); +`, + env: { + ...process.env, + MODULE_URL: moduleUrl, + SERVICE: service, + BUN_NAME: bunName, + NODE_NAME: nodeName, + BUN_VALUE: bunValue, + NODE_VALUE: nodeValue, + }, + encoding: "utf8", + timeout: 30_000, + }); + expect(node.status, `${node.stdout}\n${node.stderr}`).toBe(0); + + const restartedNode = spawnSync("node", ["--input-type=module"], { + input: ` +const { createExplicitNodeSecretBackend } = await import(process.env.MODULE_URL); +const backend = createExplicitNodeSecretBackend("darwin"); +if (!backend) throw new Error("missing macOS backend"); +const bunValue = await backend.get({ service: process.env.SERVICE, name: process.env.BUN_NAME }); +const nodeValue = await backend.get({ service: process.env.SERVICE, name: process.env.NODE_NAME }); +if (bunValue !== process.env.BUN_VALUE) throw new Error("legacy value missing after restart"); +if (nodeValue !== process.env.NODE_VALUE) throw new Error("Node value missing after restart"); +if (!(await backend.delete({ service: process.env.SERVICE, name: process.env.BUN_NAME }))) { + throw new Error("Node failed to delete the legacy Bun entry after restart"); +} +if (!(await backend.delete({ service: process.env.SERVICE, name: process.env.NODE_NAME }))) { + throw new Error("Node failed to delete its entry after restart"); +} +`, + env: { + ...process.env, + MODULE_URL: moduleUrl, + SERVICE: service, + BUN_NAME: bunName, + NODE_NAME: nodeName, + BUN_VALUE: bunValue, + NODE_VALUE: nodeValue, + }, + encoding: "utf8", + timeout: 30_000, + }); + expect( + restartedNode.status, + `${restartedNode.stdout}\n${restartedNode.stderr}`, + ).toBe(0); + + expect( + await bunSecretsForInterop.get({ service, name: bunName }), + ).toBeNull(); + expect( + await bunSecretsForInterop.get({ service, name: nodeName }), + ).toBeNull(); + } finally { + await Promise.allSettled([ + bunSecretsForInterop.delete({ service, name: bunName }), + bunSecretsForInterop.delete({ service, name: nodeName }), + explicitNodeBackendForInterop?.delete({ service, name: bunName }), + explicitNodeBackendForInterop?.delete({ service, name: nodeName }), + ]); + } + }, + 60_000, + ); +}); diff --git a/src/utils/secret-backends.ts b/src/utils/secret-backends.ts new file mode 100644 index 0000000000..892dbf3e32 --- /dev/null +++ b/src/utils/secret-backends.ts @@ -0,0 +1,989 @@ +import { Buffer } from "node:buffer"; +import { spawn } from "node:child_process"; +import { + accessSync, + constants, + existsSync, + mkdtempSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; + +export interface SecretLocator { + service: string; + name: string; +} + +export interface SecretSetOptions extends SecretLocator { + value: string; +} + +export type SecretBackendKind = + | "bun" + | "macos-keyring" + | "windows-credential-manager" + | "linux-secret-service"; + +export interface SecretBackend { + kind: SecretBackendKind; + get(options: SecretLocator): Promise; + set(options: SecretSetOptions): Promise; + delete(options: SecretLocator): Promise; + isAvailable(): Promise; +} + +export interface BunSecretsLike { + get(options: SecretLocator): Promise | string | null; + set( + options: SecretSetOptions & { allowUnrestrictedAccess?: boolean }, + ): Promise | void; + delete(options: SecretLocator): Promise | boolean; +} + +type SecretRuntimeOverride = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + bunSecrets?: BunSecretsLike | null; + bunExecutablePath?: string | null; + macSecurityPath?: string | null; + powerShellPath?: string | null; +}; + +type SecretRuntime = { + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + bunSecrets: BunSecretsLike | null; + bunExecutablePath?: string | null; + macSecurityPath?: string | null; + powerShellPath?: string | null; +}; + +const SECRET_COMMAND_TIMEOUT_MS = 10_000; +const SECRET_COMMAND_MAX_OUTPUT_BYTES = 64 * 1024; +const WINDOWS_CREDENTIAL_MAX_BLOB_BYTES = 2_560; +const BUN_LINUX_SECRET_SCHEMA = "com.oven-sh.bun.Secret"; +const MACOS_SECURITY_PATH = "/usr/bin/security"; +const MACOS_ITEM_NOT_FOUND_EXIT_CODE = 44; +const BUN_PROJECT_ENV_KEYS = [ + "BUN_CONFIG", + "BUN_CONFIG_PATH", + "BUN_OPTIONS", + "BUN_RUNTIME_TRANSPILER_CACHE_PATH", +] as const; + +const BUN_MACOS_KEYCHAIN_HELPER_SCRIPT = ` +const locator = { + service: process.env.LETTA_SECRET_MIGRATION_SERVICE, + name: process.env.LETTA_SECRET_MIGRATION_NAME, +}; + +const errorMessage = (error) => + error instanceof Error ? error.message : String(error); + +const isDuplicateItemError = (error) => { + const message = errorMessage(error).toLowerCase(); + return message.includes("already exists") || message.includes("-25299"); +}; + +const setUnrestricted = async (value) => { + try { + await Bun.secrets.set({ + ...locator, + value, + allowUnrestrictedAccess: true, + }); + return; + } catch (error) { + if (!isDuplicateItemError(error)) throw error; + } + + const previousValue = await Bun.secrets.get(locator); + await Bun.secrets.delete(locator); + try { + await Bun.secrets.set({ + ...locator, + value, + allowUnrestrictedAccess: true, + }); + } catch (retryError) { + if (previousValue !== null) { + try { + await Bun.secrets.set({ ...locator, value: previousValue }); + } catch (restoreError) { + throw new Error( + "Failed to replace Keychain item: " + errorMessage(retryError) + + "; restoring the previous item also failed: " + errorMessage(restoreError), + ); + } + } + throw retryError; + } +}; + +try { + if (process.env.LETTA_SECRET_MIGRATION_OPERATION === "set") { + const request = JSON.parse(await Bun.stdin.text()); + await setUnrestricted( + Buffer.from(request.valueBase64, "base64").toString("utf8"), + ); + process.stdout.write(JSON.stringify({ ok: true })); + } else if (process.env.LETTA_SECRET_MIGRATION_OPERATION === "delete") { + const deleted = await Bun.secrets.delete(locator); + process.stdout.write(JSON.stringify({ ok: true, deleted })); + } else { + // Reads must stay non-mutating. Legacy restricted entries remain owned by + // Bun, so Node delegates access to Bun instead of recreating them on every + // startup. Rewriting here causes errSecDuplicateItem (-25299) and creates + // a delete/recreate race between concurrent listeners. + const value = await Bun.secrets.get(locator); + process.stdout.write(JSON.stringify({ + ok: true, + valueBase64: value === null ? null : Buffer.from(value, "utf8").toString("base64"), + })); + } +} catch (error) { + process.stdout.write(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : String(error), + })); + process.exitCode = 1; +} +`; + +const WINDOWS_CREDENTIAL_SCRIPT = ` +$ErrorActionPreference = 'Stop' + +$inputJson = [Console]::In.ReadToEnd() +$request = $inputJson | ConvertFrom-Json + +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; + +public static class LettaCredentialNative { + public const int CRED_TYPE_GENERIC = 1; + public const int CRED_PERSIST_ENTERPRISE = 3; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct CREDENTIAL { + public UInt32 Flags; + public UInt32 Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public UInt32 CredentialBlobSize; + public IntPtr CredentialBlob; + public UInt32 Persist; + public UInt32 AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredRead(string target, uint type, int reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredWrite([In] ref CREDENTIAL userCredential, uint flags); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredDelete(string target, uint type, uint flags); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern void CredFree(IntPtr buffer); +} +"@ + +function Write-LettaJson($value) { + $value | ConvertTo-Json -Compress -Depth 4 +} + +function Get-LettaCredentialTarget([string]$service, [string]$name) { + return "$service/$name" +} + +function Read-LettaCredentialBase64([string]$target) { + $credentialPtr = [IntPtr]::Zero + $ok = [LettaCredentialNative]::CredRead($target, [uint32][LettaCredentialNative]::CRED_TYPE_GENERIC, 0, [ref]$credentialPtr) + if (-not $ok) { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + if ($code -eq 1168) { + return $null + } + throw "CredRead failed with Windows error code $code" + } + + try { + $credential = [Runtime.InteropServices.Marshal]::PtrToStructure($credentialPtr, [type][LettaCredentialNative+CREDENTIAL]) + $bytes = New-Object byte[] $credential.CredentialBlobSize + if ($bytes.Length -gt 0) { + [Runtime.InteropServices.Marshal]::Copy($credential.CredentialBlob, $bytes, 0, $bytes.Length) + } + return [Convert]::ToBase64String($bytes) + } finally { + if ($credentialPtr -ne [IntPtr]::Zero) { + [LettaCredentialNative]::CredFree($credentialPtr) + } + } +} + +function Get-LettaCredential([string]$target) { + Write-LettaJson @{ ok = $true; valueBase64 = Read-LettaCredentialBase64 $target } +} + +function Set-LettaCredential([string]$target, [string]$name, [string]$valueBase64) { + $bytes = [Convert]::FromBase64String($valueBase64) + $blob = [IntPtr]::Zero + if ($bytes.Length -gt 0) { + $blob = [Runtime.InteropServices.Marshal]::AllocCoTaskMem($bytes.Length) + [Runtime.InteropServices.Marshal]::Copy($bytes, 0, $blob, $bytes.Length) + } + + try { + $credential = New-Object LettaCredentialNative+CREDENTIAL + $credential.Flags = 0 + $credential.Type = [uint32][LettaCredentialNative]::CRED_TYPE_GENERIC + $credential.TargetName = $target + $credential.CredentialBlobSize = [uint32]$bytes.Length + $credential.CredentialBlob = $blob + $credential.Persist = [uint32][LettaCredentialNative]::CRED_PERSIST_ENTERPRISE + $credential.AttributeCount = 0 + $credential.Attributes = [IntPtr]::Zero + $credential.TargetAlias = $null + $credential.UserName = $name + + $ok = [LettaCredentialNative]::CredWrite([ref]$credential, 0) + if (-not $ok) { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + throw "CredWrite failed with Windows error code $code" + } + + Write-LettaJson @{ ok = $true } + } finally { + if ($blob -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::FreeCoTaskMem($blob) + } + } +} + +function Remove-LettaCredential([string]$target) { + $ok = [LettaCredentialNative]::CredDelete($target, [uint32][LettaCredentialNative]::CRED_TYPE_GENERIC, 0) + if (-not $ok) { + $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error() + if ($code -eq 1168) { + Write-LettaJson @{ ok = $true; deleted = $false } + return + } + throw "CredDelete failed with Windows error code $code" + } + + Write-LettaJson @{ ok = $true; deleted = $true } +} + +try { + $target = Get-LettaCredentialTarget ([string]$request.service) ([string]$request.name) + switch ([string]$request.operation) { + 'get' { Get-LettaCredential $target } + 'set' { Set-LettaCredential $target ([string]$request.name) ([string]$request.valueBase64) } + 'delete' { Remove-LettaCredential $target } + default { throw "Unknown credential operation: $($request.operation)" } + } +} catch { + Write-LettaJson @{ ok = $false; error = $_.Exception.Message } + exit 1 +} +`; + +const WINDOWS_CREDENTIAL_ENCODED_COMMAND = Buffer.from( + WINDOWS_CREDENTIAL_SCRIPT, + "utf16le", +).toString("base64"); + +interface SecretCommandResult { + code: number | null; + stdout: Buffer; + stderr: Buffer; +} + +interface WindowsCredentialRequest { + operation: "get" | "set" | "delete"; + service: string; + name: string; + valueBase64?: string; +} + +interface WindowsCredentialResponse { + ok?: boolean; + valueBase64?: string | null; + deleted?: boolean; + error?: string; +} + +interface BunMacMigrationResponse { + ok?: boolean; + valueBase64?: string | null; + deleted?: boolean; + error?: string; +} + +let runtimeOverrideForTests: SecretRuntimeOverride | null = null; + +export function __setSecretRuntimeOverrideForTests( + override: SecretRuntimeOverride | null, +): void { + runtimeOverrideForTests = override; +} + +export function __getWindowsCredentialScriptForTests(): string { + return WINDOWS_CREDENTIAL_SCRIPT; +} + +function getRuntimeBunSecrets(): BunSecretsLike | null { + const runtime = globalThis as typeof globalThis & { + Bun?: { secrets?: BunSecretsLike }; + }; + return runtime.Bun?.secrets ?? null; +} + +function getRuntime(): SecretRuntime { + const override = runtimeOverrideForTests; + const env = { ...process.env, ...(override?.env ?? {}) }; + const bunSecrets = + override && Object.hasOwn(override, "bunSecrets") + ? (override.bunSecrets ?? null) + : getRuntimeBunSecrets(); + + return { + platform: override?.platform ?? process.platform, + env, + bunSecrets, + bunExecutablePath: override?.bunExecutablePath, + macSecurityPath: override?.macSecurityPath, + powerShellPath: override?.powerShellPath, + }; +} + +function getEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined { + const value = env[name]; + return typeof value === "string" ? value : undefined; +} + +function appendOutputChunk( + chunks: Buffer[], + chunk: Buffer | string, + currentBytes: number, +): { chunks: Buffer[]; bytes: number } { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(buffer); + return { chunks, bytes: currentBytes + buffer.byteLength }; +} + +function runSecretCommand( + command: string, + args: string[], + options: { + env: NodeJS.ProcessEnv; + cwd?: string; + input?: string; + timeoutMs?: number; + windowsHide?: boolean; + }, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let stdoutBytes = 0; + let stderrBytes = 0; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: options.windowsHide, + }); + + const timeout = setTimeout(() => { + fail(new Error("Secret storage command timed out")); + }, options.timeoutMs ?? SECRET_COMMAND_TIMEOUT_MS); + + function fail(error: Error): void { + if (settled) return; + settled = true; + clearTimeout(timeout); + child.kill(); + reject(error); + } + + child.stdout.on("data", (chunk: Buffer | string) => { + const next = appendOutputChunk(stdoutChunks, chunk, stdoutBytes); + stdoutBytes = next.bytes; + if (stdoutBytes > SECRET_COMMAND_MAX_OUTPUT_BYTES) { + fail(new Error("Secret storage command stdout exceeded output limit")); + } + }); + + child.stderr.on("data", (chunk: Buffer | string) => { + const next = appendOutputChunk(stderrChunks, chunk, stderrBytes); + stderrBytes = next.bytes; + if (stderrBytes > SECRET_COMMAND_MAX_OUTPUT_BYTES) { + fail(new Error("Secret storage command stderr exceeded output limit")); + } + }); + + child.on("error", (error) => { + fail(error); + }); + + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve({ + code, + stdout: Buffer.concat(stdoutChunks), + stderr: Buffer.concat(stderrChunks), + }); + }); + + child.stdin.on("error", () => { + // The close path reports the command failure; some tools close stdin early. + }); + child.stdin.end(options.input ?? ""); + }); +} + +function findExecutableOnPath( + executable: string, + env: NodeJS.ProcessEnv, +): string | null { + const pathValue = getEnvValue(env, "PATH") ?? getEnvValue(env, "Path") ?? ""; + for (const directory of pathValue.split(delimiter)) { + if (!directory) continue; + const candidate = join(directory, executable); + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + if (existsSync(candidate)) { + return candidate; + } + } + } + return null; +} + +function getPowerShellPath(runtime: SecretRuntime): string | null { + if (runtime.powerShellPath !== undefined) { + return runtime.powerShellPath; + } + + const systemRoot = getEnvValue(runtime.env, "SystemRoot") || "C:\\Windows"; + const bundledPath = join( + systemRoot, + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ); + if (existsSync(bundledPath)) { + return bundledPath; + } + + return ( + findExecutableOnPath("powershell.exe", runtime.env) ?? + findExecutableOnPath("pwsh.exe", runtime.env) + ); +} + +function commandError( + commandName: string, + operation: string, + result: SecretCommandResult, +): Error { + const stderr = result.stderr.toString("utf8").trim(); + const detail = stderr || `exit code ${result.code ?? "unknown"}`; + return new Error(`${commandName} ${operation} failed: ${detail}`); +} + +function createBunSecretBackend( + bunSecrets: BunSecretsLike, + platform: NodeJS.Platform, +): SecretBackend { + return { + kind: "bun", + get: async (options) => bunSecrets.get(options), + set: async (options) => { + await bunSecrets.set({ + ...options, + // Letta listeners and global installs can legitimately switch between + // Bun and Node. macOS otherwise restricts the item to the writer + // executable, making the same Keychain entry unreadable headlessly. + ...(platform === "darwin" ? { allowUnrestrictedAccess: true } : {}), + }); + }, + delete: async (options) => bunSecrets.delete(options), + isAvailable: async () => true, + }; +} + +function getMacSecurityPath(runtime: SecretRuntime): string | null { + if (runtime.macSecurityPath !== undefined) { + return runtime.macSecurityPath; + } + try { + accessSync(MACOS_SECURITY_PATH, constants.X_OK); + return MACOS_SECURITY_PATH; + } catch { + return null; + } +} + +function getBunExecutablePath(runtime: SecretRuntime): string | null { + if (runtime.bunExecutablePath !== undefined) { + return runtime.bunExecutablePath; + } + return findExecutableOnPath("bun", runtime.env); +} + +function getBunSecretCommandEnv( + env: NodeJS.ProcessEnv, + operation: "get" | "set" | "delete", + locator: SecretLocator, +): NodeJS.ProcessEnv { + const commandEnv: NodeJS.ProcessEnv = { + ...env, + LETTA_SECRET_MIGRATION_OPERATION: operation, + LETTA_SECRET_MIGRATION_SERVICE: locator.service, + LETTA_SECRET_MIGRATION_NAME: locator.name, + }; + for (const key of BUN_PROJECT_ENV_KEYS) { + delete commandEnv[key]; + } + return commandEnv; +} + +async function runBunMacKeychainOperation( + operation: "get" | "set" | "delete", + locator: SecretLocator, + value?: string, +): Promise { + const runtime = getRuntime(); + const bunPath = getBunExecutablePath(runtime); + if (!bunPath) return null; + + const commandCwd = mkdtempSync(join(tmpdir(), "letta-bun-keychain-")); + let result: SecretCommandResult; + try { + result = await runSecretCommand( + bunPath, + ["-e", BUN_MACOS_KEYCHAIN_HELPER_SCRIPT], + { + cwd: commandCwd, + env: getBunSecretCommandEnv(runtime.env, operation, locator), + input: + operation === "set" + ? JSON.stringify({ + valueBase64: Buffer.from(value ?? "", "utf8").toString( + "base64", + ), + }) + : undefined, + }, + ); + } finally { + rmSync(commandCwd, { recursive: true, force: true }); + } + + let response: BunMacMigrationResponse; + try { + const output = result.stdout.toString("utf8").trim(); + if (!output) throw new Error("empty output"); + response = JSON.parse(output) as BunMacMigrationResponse; + } catch { + throw commandError("Bun Keychain helper", operation, result); + } + + if (result.code !== 0 || response.ok === false) { + throw new Error( + response.error || + result.stderr.toString("utf8").trim() || + `Bun Keychain helper ${operation} failed`, + ); + } + return response; +} + +async function runMacSecurityCommand( + args: string[], + input?: string, +): Promise { + const runtime = getRuntime(); + if (runtime.platform !== "darwin") { + throw new Error("macOS Keychain is only available on macOS"); + } + + const securityPath = getMacSecurityPath(runtime); + if (!securityPath) { + throw new Error("macOS security CLI is unavailable"); + } + + return runSecretCommand(securityPath, args, { + env: runtime.env, + input, + }); +} + +function macSecurityResultIsMissing(result: SecretCommandResult): boolean { + return result.code === MACOS_ITEM_NOT_FOUND_EXIT_CODE; +} + +function decodeMacSecurityPassword(stdout: Buffer): string { + const value = stdout.toString("utf8"); + // `security ... -w` appends one LF after the raw password. Remove only + // that byte so a password that itself ends in CR/LF remains intact. + return value.endsWith("\n") ? value.slice(0, -1) : value; +} + +function createMacKeyringBackend(): SecretBackend { + const backend: SecretBackend = { + kind: "macos-keyring", + get: async ({ service, name }) => { + const locator = { service, name }; + const bunResult = await runBunMacKeychainOperation("get", locator); + if (bunResult) { + return bunResult.valueBase64 == null + ? null + : Buffer.from(bunResult.valueBase64, "base64").toString("utf8"); + } + + const result = await runMacSecurityCommand([ + "find-generic-password", + "-s", + service, + "-a", + name, + "-w", + ]); + if (result.code === 0) return decodeMacSecurityPassword(result.stdout); + if (macSecurityResultIsMissing(result)) return null; + throw commandError("macOS Keychain", "get", result); + }, + set: async ({ service, name, value }) => { + const locator = { service, name }; + const bunResult = await runBunMacKeychainOperation("set", locator, value); + if (bunResult) return; + + if (value.includes("\n")) { + throw new Error( + "macOS cross-runtime Keychain storage does not accept line breaks", + ); + } + const result = await runMacSecurityCommand( + [ + "add-generic-password", + "-a", + name, + "-s", + service, + "-A", + "-U", + // Keep -w last so `security` reads and confirms the password from + // stdin instead of exposing it in the process argument list. + "-w", + ], + `${value}\n${value}\n`, + ); + if (result.code !== 0) { + throw commandError("macOS Keychain", "set", result); + } + }, + delete: async ({ service, name }) => { + const locator = { service, name }; + const bunResult = await runBunMacKeychainOperation("delete", locator); + if (bunResult) { + return bunResult.deleted === true; + } + + const result = await runMacSecurityCommand([ + "delete-generic-password", + "-s", + service, + "-a", + name, + ]); + if (result.code === 0) return true; + if (macSecurityResultIsMissing(result)) return false; + throw commandError("macOS Keychain", "delete", result); + }, + isAvailable: async () => { + const runtime = getRuntime(); + return Boolean( + getBunExecutablePath(runtime) || getMacSecurityPath(runtime), + ); + }, + }; + return backend; +} + +async function runWindowsCredentialCommand( + request: WindowsCredentialRequest, +): Promise { + const runtime = getRuntime(); + if (runtime.platform !== "win32") { + throw new Error("Windows Credential Manager is only available on Windows"); + } + + const powershellPath = getPowerShellPath(runtime); + if (!powershellPath) { + throw new Error("PowerShell is unavailable"); + } + + const result = await runSecretCommand( + powershellPath, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + WINDOWS_CREDENTIAL_ENCODED_COMMAND, + ], + { + env: runtime.env, + input: JSON.stringify(request), + windowsHide: true, + }, + ); + + let response: WindowsCredentialResponse; + try { + const output = result.stdout.toString("utf8").trim(); + if (!output) { + throw new Error("empty output"); + } + response = JSON.parse(output) as WindowsCredentialResponse; + } catch { + throw commandError("Windows Credential Manager", request.operation, result); + } + + if (result.code === 0 && response.ok !== false) { + return response; + } + + throw new Error( + response.error || + result.stderr.toString("utf8").trim() || + `Windows Credential Manager ${request.operation} failed`, + ); +} + +async function getWindowsCredential( + service: string, + name: string, +): Promise { + const response = await runWindowsCredentialCommand({ + operation: "get", + service, + name, + }); + if (response.valueBase64 === null || response.valueBase64 === undefined) { + return null; + } + return Buffer.from(response.valueBase64, "base64").toString("utf8"); +} + +async function setWindowsCredential( + service: string, + name: string, + value: string, +): Promise { + const valueBuffer = Buffer.from(value, "utf8"); + if (valueBuffer.byteLength > WINDOWS_CREDENTIAL_MAX_BLOB_BYTES) { + throw new Error( + `Windows Credential Manager value is too large (${valueBuffer.byteLength} bytes; max ${WINDOWS_CREDENTIAL_MAX_BLOB_BYTES} bytes)`, + ); + } + + await runWindowsCredentialCommand({ + operation: "set", + service, + name, + valueBase64: valueBuffer.toString("base64"), + }); +} + +async function deleteWindowsCredential( + service: string, + name: string, +): Promise { + const response = await runWindowsCredentialCommand({ + operation: "delete", + service, + name, + }); + return response.deleted === true; +} + +function createWindowsCredentialBackend(): SecretBackend { + return { + kind: "windows-credential-manager", + get: ({ service, name }) => getWindowsCredential(service, name), + set: ({ service, name, value }) => + setWindowsCredential(service, name, value), + delete: ({ service, name }) => deleteWindowsCredential(service, name), + isAvailable: async () => Boolean(getPowerShellPath(getRuntime())), + }; +} + +function getSecretToolPath(runtime: SecretRuntime): string | null { + return findExecutableOnPath("secret-tool", runtime.env); +} + +function hasLinuxSecretServiceSession(runtime: SecretRuntime): boolean { + return Boolean(getEnvValue(runtime.env, "DBUS_SESSION_BUS_ADDRESS")?.trim()); +} + +function linuxSecretServiceUnavailableError(): Error { + return new Error( + "Linux Secret Service is unavailable; DBUS_SESSION_BUS_ADDRESS and secret-tool are required", + ); +} + +async function runSecretTool( + args: string[], + input?: string, +): Promise { + const runtime = getRuntime(); + if (!hasLinuxSecretServiceSession(runtime)) { + throw linuxSecretServiceUnavailableError(); + } + + const secretToolPath = getSecretToolPath(runtime); + if (!secretToolPath) { + throw linuxSecretServiceUnavailableError(); + } + + return runSecretCommand(secretToolPath, args, { + env: runtime.env, + input, + }); +} + +async function lookupLinuxSecret( + service: string, + name: string, +): Promise<{ found: true; value: string } | { found: false }> { + const result = await runSecretTool([ + "lookup", + "service", + service, + "account", + name, + "xdg:schema", + BUN_LINUX_SECRET_SCHEMA, + ]); + + if (result.code === 0) { + return { found: true, value: result.stdout.toString("utf8") }; + } + + if ( + result.code === 1 && + result.stdout.byteLength === 0 && + result.stderr.toString("utf8").trim() === "" + ) { + return { found: false }; + } + + throw commandError("secret-tool", "lookup", result); +} + +function createLinuxSecretServiceBackend(): SecretBackend { + return { + kind: "linux-secret-service", + get: async ({ service, name }) => { + const result = await lookupLinuxSecret(service, name); + return result.found ? result.value : null; + }, + set: async ({ service, name, value }) => { + const result = await runSecretTool( + [ + "store", + "--label", + `${service}/${name}`, + "service", + service, + "account", + name, + "xdg:schema", + BUN_LINUX_SECRET_SCHEMA, + ], + value, + ); + if (result.code !== 0) { + throw commandError("secret-tool", "store", result); + } + }, + delete: async ({ service, name }) => { + const existing = await lookupLinuxSecret(service, name); + if (!existing.found) return false; + + const result = await runSecretTool([ + "clear", + "service", + service, + "account", + name, + "xdg:schema", + BUN_LINUX_SECRET_SCHEMA, + ]); + if (result.code === 0) return true; + if ( + result.code === 1 && + result.stdout.byteLength === 0 && + result.stderr.toString("utf8").trim() === "" + ) { + return false; + } + throw commandError("secret-tool", "clear", result); + }, + isAvailable: async () => { + const runtime = getRuntime(); + return Boolean( + hasLinuxSecretServiceSession(runtime) && getSecretToolPath(runtime), + ); + }, + }; +} + +export function createExplicitNodeSecretBackend( + platform: NodeJS.Platform = getRuntime().platform, +): SecretBackend | null { + switch (platform) { + case "darwin": + return createMacKeyringBackend(); + case "win32": + return createWindowsCredentialBackend(); + case "linux": + return createLinuxSecretServiceBackend(); + default: + return null; + } +} + +export function getSecretBackend(): SecretBackend | null { + const runtime = getRuntime(); + if (runtime.bunSecrets) { + return createBunSecretBackend(runtime.bunSecrets, runtime.platform); + } + return createExplicitNodeSecretBackend(runtime.platform); +} + +export function __getSelectedSecretBackendKindForTests(): SecretBackendKind | null { + return getSecretBackend()?.kind ?? null; +} + +export function __getBunMacKeychainHelperScriptForTests(): string { + return BUN_MACOS_KEYCHAIN_HELPER_SCRIPT; +} diff --git a/src/utils/secrets.test.ts b/src/utils/secrets.test.ts index a9e86c194a..17c2963d42 100644 --- a/src/utils/secrets.test.ts +++ b/src/utils/secrets.test.ts @@ -1,8 +1,18 @@ // src/tests/keychain.test.ts // Tests for secrets utility functions -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + mock, + test, +} from "bun:test"; +import { randomUUID } from "node:crypto"; +import { + __getDefaultServiceNameForTests, __resetSecretWarningStateForTests, __setSecretGetOverrideForTests, deleteApiKey, @@ -16,14 +26,20 @@ import { setApiKey, setRefreshToken, setSecureTokens, + setServiceName, } from "@/utils/secrets"; +const DEFAULT_SERVICE_NAME = __getDefaultServiceNameForTests(); +const TEST_SERVICE_NAME = `letta-code-test-${randomUUID()}`; + +setServiceName(TEST_SERVICE_NAME); const keychainAvailablePrecompute = await isKeychainAvailable(); describe("Secrets utilities", () => { const originalConsoleWarn = console.warn; beforeEach(async () => { + setServiceName(TEST_SERVICE_NAME); __resetSecretWarningStateForTests(); __setSecretGetOverrideForTests(null); console.warn = originalConsoleWarn; @@ -39,6 +55,11 @@ describe("Secrets utilities", () => { if (keychainAvailablePrecompute) { await deleteSecureTokens(); } + setServiceName(DEFAULT_SERVICE_NAME); + }); + + afterAll(() => { + setServiceName(DEFAULT_SERVICE_NAME); }); test("isKeychainAvailable works", async () => { @@ -46,6 +67,20 @@ describe("Secrets utilities", () => { expect(typeof available).toBe("boolean"); }); + test("never targets the live credential service during tests", async () => { + let observedService: string | undefined; + setServiceName(DEFAULT_SERVICE_NAME); + __setSecretGetOverrideForTests(async ({ service }) => { + observedService = service; + return null; + }); + + await getApiKey(); + + expect(observedService).toStartWith("letta-code-test-"); + expect(observedService).not.toBe(DEFAULT_SERVICE_NAME); + }); + test.skipIf(!keychainAvailablePrecompute)( "can store and retrieve API key", async () => { @@ -136,7 +171,7 @@ describe("Secrets utilities", () => { test.skipIf(!keychainAvailablePrecompute)( "returns null for non-existent tokens", async () => { - // Ensure no tokens exist + // Ensure no tokens exist in the unique test service, never production. await deleteSecureTokens(); const apiKey = await getApiKey(); @@ -153,14 +188,14 @@ describe("Secrets utilities", () => { test.skipIf(!keychainAvailablePrecompute)( "handles partial token storage", async () => { - // Store only API key + // Store only API key. await setSecureTokens({ apiKey: "sk-only-api-key" }); let tokens = await getSecureTokens(); expect(tokens.apiKey).toBe("sk-only-api-key"); expect(tokens.refreshToken).toBeUndefined(); - // Clean up and store only refresh token + // Clean up and store only refresh token. await deleteSecureTokens(); await setSecureTokens({ refreshToken: "rt-only-refresh-token" }); @@ -171,13 +206,13 @@ describe("Secrets utilities", () => { ); test("gracefully handles secrets unavailability", async () => { - // This test should work even if secrets are not available + // This test should work even if secrets are not available. if (await isKeychainAvailable()) { - // If secrets are available, this is a basic functionality test + // If secrets are available, this is a basic functionality test. const tokens = await getSecureTokens(); expect(typeof tokens).toBe("object"); } else { - // If secrets are not available, functions should return null or throw appropriately + // If secrets are not available, functions should return null or throw appropriately. const tokens = await getSecureTokens(); expect(tokens.apiKey).toBeUndefined(); expect(tokens.refreshToken).toBeUndefined(); @@ -188,12 +223,12 @@ describe("Secrets utilities", () => { const refreshToken = await getRefreshToken(); expect(refreshToken).toBe(null); - // Set operations should throw when secrets unavailable (handled by settings manager) + // Set operations should throw when secrets unavailable (handled by settings manager). await expect(setSecureTokens({ apiKey: "test" })).rejects.toThrow(); await expect(setApiKey("test")).rejects.toThrow(); await expect(setRefreshToken("test")).rejects.toThrow(); - // Delete operations should not throw (no-op when secrets unavailable) + // Delete operations should not throw (no-op when secrets unavailable). await expect(deleteSecureTokens()).resolves.toBeUndefined(); await expect(deleteApiKey()).resolves.toBeUndefined(); await expect(deleteRefreshToken()).resolves.toBeUndefined(); diff --git a/src/utils/secrets.ts b/src/utils/secrets.ts index 95aba150ac..d74ea7c7bc 100644 --- a/src/utils/secrets.ts +++ b/src/utils/secrets.ts @@ -1,22 +1,25 @@ /// // src/utils/secrets.ts -// Secure storage utilities for tokens using Bun's secrets API with Node.js fallback +// Secure storage utilities for tokens and local agent secrets. Consumers stay on +// this boundary; runtime-specific OS storage lives behind SecretBackend. import { debugWarn } from "./debug.js"; - -let secrets: typeof Bun.secrets; -let secretsAvailable = false; - -// Try to import Bun's secrets API, fallback if unavailable -try { - secrets = require("bun").secrets; - secretsAvailable = true; -} catch { - // Running in Node.js or Bun secrets unavailable - secretsAvailable = false; -} - -let SERVICE_NAME = "letta-code"; +import { + createExplicitNodeSecretBackend, + getSecretBackend, + __getSelectedSecretBackendKindForTests as getSelectedSecretBackendKindForTests, + __getWindowsCredentialScriptForTests as getWindowsCredentialScriptForTests, + type SecretBackend, + type SecretBackendKind, + __setSecretRuntimeOverrideForTests as setSecretRuntimeOverrideForTests, +} from "./secret-backends.js"; + +const DEFAULT_SERVICE_NAME = "letta-code"; +const TEST_DEFAULT_SERVICE_NAME = `${DEFAULT_SERVICE_NAME}-test-${process.pid}`; +let SERVICE_NAME = + process.env.NODE_ENV === "test" + ? TEST_DEFAULT_SERVICE_NAME + : DEFAULT_SERVICE_NAME; const API_KEY_NAME = "letta-api-key"; const REFRESH_TOKEN_NAME = "letta-refresh-token"; @@ -37,12 +40,26 @@ function isDuplicateKeychainItemError(error: unknown): boolean { ); } -export async function getSecretValue( +function getBackendOrThrow(): SecretBackend { + const backend = getSecretBackend(); + if (!backend) { + throw new Error("Secrets API unavailable"); + } + return backend; +} + +type SecretReadResult = { + failed: boolean; + value: string | null; +}; + +async function readSecretValue( name: string, label: string, -): Promise { - if (!secretsAvailable && !secretGetOverrideForTests) { - return null; +): Promise { + const backend = getSecretBackend(); + if (!backend && !secretGetOverrideForTests) { + return { failed: false, value: null }; } try { @@ -52,9 +69,9 @@ export async function getSecretValue( }; const value = secretGetOverrideForTests ? await secretGetOverrideForTests(options) - : await secrets.get(options); + : await backend?.get(options); warnedSecretReadFailures.delete(name); - return value; + return { failed: false, value: value ?? null }; } catch (error) { const message = `Failed to retrieve ${label} from secrets: ${error}`; if (!warnedSecretReadFailures.has(name)) { @@ -63,34 +80,51 @@ export async function getSecretValue( } else { debugWarn("secrets", message); } - return null; + return { failed: true, value: null }; } } +export async function getSecretValue( + name: string, + label: string, +): Promise { + return (await readSecretValue(name, label)).value; +} + export async function setSecretValue( name: string, value: string, ): Promise { - if (!secretsAvailable) { - throw new Error("Secrets API unavailable"); + const backend = getBackendOrThrow(); + + // Bun.secrets treats an empty value as deletion on every platform. Keep the + // explicit Node backends behaviorally identical instead of storing an empty + // credential that only one runtime can observe. + if (value === "") { + await backend.delete({ + service: SERVICE_NAME, + name, + }); + return; } try { - await secrets.set({ + await backend.set({ service: SERVICE_NAME, name, value, }); return; } catch (error) { - if (!isDuplicateKeychainItemError(error)) { + if (backend.kind !== "bun" || !isDuplicateKeychainItemError(error)) { throw error; } } - // Replace existing keychain item and retry once. + // Preserve Bun.secrets duplicate-item replacement behavior for existing + // macOS entries: delete the exact shared entry and retry once. try { - await secrets.delete({ + await backend.delete({ service: SERVICE_NAME, name, }); @@ -98,7 +132,7 @@ export async function setSecretValue( // Ignore delete errors and retry set below. } - await secrets.set({ + await backend.set({ service: SERVICE_NAME, name, value, @@ -106,12 +140,13 @@ export async function setSecretValue( } export async function deleteSecretValue(name: string): Promise { - if (!secretsAvailable) { + const backend = getSecretBackend(); + if (!backend) { return false; } try { - return await secrets.delete({ + return await backend.delete({ service: SERVICE_NAME, name, }); @@ -125,27 +160,32 @@ export async function deleteSecretValue(name: string): Promise { * Override the keychain service name (useful for tests to avoid touching real credentials) */ export function setServiceName(name: string): void { - SERVICE_NAME = name; + // A mis-isolated Bun test must never fall back to the live credential + // namespace. Test files still use unique names for correctness; this guard + // prevents cleanup races from deleting a developer's real credentials. + SERVICE_NAME = + process.env.NODE_ENV === "test" && name === DEFAULT_SERVICE_NAME + ? TEST_DEFAULT_SERVICE_NAME + : name; } -// Note: When secrets API is unavailable (Node.js), tokens will be managed -// by the settings manager which falls back to storing in the settings file -// This provides persistence across restarts +// Note: On platforms without an OS secret backend, tokens are managed by the +// settings manager fallback so authentication still persists across restarts. export interface SecureTokens { apiKey?: string; refreshToken?: string; } +export interface SecureTokensReadResult { + failed: boolean; + tokens: SecureTokens; +} + /** * Store API key in system secrets */ export async function setApiKey(apiKey: string): Promise { - if (!secretsAvailable) { - // When secrets unavailable, let the settings manager handle fallback - throw new Error("Secrets API unavailable"); - } - await setSecretValue(API_KEY_NAME, apiKey); } @@ -160,11 +200,6 @@ export async function getApiKey(): Promise { * Store refresh token in system secrets */ export async function setRefreshToken(refreshToken: string): Promise { - if (!secretsAvailable) { - // When secrets unavailable, let the settings manager handle fallback - throw new Error("Secrets API unavailable"); - } - await setSecretValue(REFRESH_TOKEN_NAME, refreshToken); } @@ -179,18 +214,21 @@ export async function getRefreshToken(): Promise { * Get both tokens from secrets */ export async function getSecureTokens(): Promise { - const [apiKey, refreshToken] = await Promise.allSettled([ - getApiKey(), - getRefreshToken(), + return (await getSecureTokensWithStatus()).tokens; +} + +export async function getSecureTokensWithStatus(): Promise { + const [apiKey, refreshToken] = await Promise.all([ + readSecretValue(API_KEY_NAME, "API key"), + readSecretValue(REFRESH_TOKEN_NAME, "refresh token"), ]); return { - apiKey: - apiKey.status === "fulfilled" ? apiKey.value || undefined : undefined, - refreshToken: - refreshToken.status === "fulfilled" - ? refreshToken.value || undefined - : undefined, + failed: apiKey.failed || refreshToken.failed, + tokens: { + apiKey: apiKey.value || undefined, + refreshToken: refreshToken.value || undefined, + }, }; } @@ -217,40 +255,14 @@ export async function setSecureTokens(tokens: SecureTokens): Promise { * Remove API key from system secrets */ export async function deleteApiKey(): Promise { - if (secretsAvailable) { - try { - await secrets.delete({ - service: SERVICE_NAME, - name: API_KEY_NAME, - }); - return; - } catch (error) { - console.warn(`Failed to delete API key from secrets: ${error}`); - } - } - - // When secrets unavailable, deletion is handled by settings manager - // No action needed here + await deleteSecretValue(API_KEY_NAME); } /** * Remove refresh token from system secrets */ export async function deleteRefreshToken(): Promise { - if (secretsAvailable) { - try { - await secrets.delete({ - service: SERVICE_NAME, - name: REFRESH_TOKEN_NAME, - }); - return; - } catch (error) { - console.warn(`Failed to delete refresh token from secrets: ${error}`); - } - } - - // When secrets unavailable, deletion is handled by settings manager - // No action needed here + await deleteSecretValue(REFRESH_TOKEN_NAME); } /** @@ -261,35 +273,23 @@ export async function deleteSecureTokens(): Promise { } /** - * Check if secrets API is available - * Set LETTA_SKIP_KEYCHAIN_CHECK=1 to skip the check (useful in CI/test environments) + * Check if OS secure storage is available. + * Set LETTA_SKIP_KEYCHAIN_CHECK=1 to skip the check (useful in CI/test environments). */ export async function isKeychainAvailable(): Promise { - // Skip keychain check in test/CI environments to avoid error dialogs if (process.env.LETTA_SKIP_KEYCHAIN_CHECK === "1") { return false; } - // Headless Linux environments frequently lack a session bus, so avoid - // probing the keychain when Secret Service cannot work. - if ( - process.platform === "linux" && - !process.env.DBUS_SESSION_BUS_ADDRESS?.trim() - ) { - return false; - } - - if (!secretsAvailable) { + const backend = getSecretBackend(); + if (!backend) { return false; } try { - // Non-mutating probe: if this call succeeds (even with null), keychain is usable. - await secrets.get({ - service: SERVICE_NAME, - name: API_KEY_NAME, - }); - return true; + // Availability is structural. Reading a live credential just to probe the + // backend creates unnecessary Keychain access and can trigger GUI prompts. + return await backend.isAvailable(); } catch { return false; } @@ -306,3 +306,27 @@ export function __setSecretGetOverrideForTests( ): void { secretGetOverrideForTests = override; } + +export function __getDefaultServiceNameForTests(): string { + return DEFAULT_SERVICE_NAME; +} + +export function __setSecretRuntimeOverrideForTests( + override: Parameters[0], +): void { + setSecretRuntimeOverrideForTests(override); +} + +export function __getSelectedSecretBackendKindForTests(): SecretBackendKind | null { + return getSelectedSecretBackendKindForTests(); +} + +export function __getWindowsCredentialScriptForTests(): string { + return getWindowsCredentialScriptForTests(); +} + +export function __getExplicitNodeSecretBackendForTests( + platform: NodeJS.Platform = process.platform, +): SecretBackend | null { + return createExplicitNodeSecretBackend(platform); +}