/** * Tests for the keys CLI subcommands: the set agent-shell guard and the * delete force flag. * * Validates: * - inline set from an agent shell (__CONVERSATION_ID) is refused with a * user-terminal redirect and no store call * - inline set from a skill sandbox (__SKILL_CONTEXT_JSON) is refused * - --generated bypasses the guard and stores via the daemon client * - a plain user-terminal invocation (no markers) stores via the daemon * client * - delete forwards the caller's force choice to the daemon client * - an in-use refusal points at the --force escape hatch */ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { Command } from "commander"; // --------------------------------------------------------------------------- // Mock state // --------------------------------------------------------------------------- interface DeleteResultStub { result: "deleted" | "not-found" | "error"; error?: string; code?: string; } let mockSetSecureKeyViaDaemon = mock(() => Promise.resolve({ ok: true })); let mockDeleteSecureKeyViaDaemon = mock( (): Promise => Promise.resolve({ result: "deleted" }), ); // --------------------------------------------------------------------------- // Mocks — must be declared before importing the module under test // --------------------------------------------------------------------------- mock.module("../../lib/daemon-credential-client.js", () => ({ setSecureKeyViaDaemon: mockSetSecureKeyViaDaemon, deleteSecureKeyViaDaemon: mockDeleteSecureKeyViaDaemon, })); const loggedErrors: string[] = []; mock.module("../../../util/logger.js", () => ({ getLogger: () => ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, }), getCliLogger: () => ({ info: () => {}, warn: () => {}, error: (message: unknown) => { loggedErrors.push(String(message)); }, debug: () => {}, }), })); // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- let savedConversationId: string | undefined; let savedSkillContextJson: string | undefined; beforeEach(() => { mockSetSecureKeyViaDaemon = mock(() => Promise.resolve({ ok: true })); mockDeleteSecureKeyViaDaemon = mock( (): Promise => Promise.resolve({ result: "deleted" }), ); loggedErrors.length = 0; savedConversationId = process.env.__CONVERSATION_ID; savedSkillContextJson = process.env.__SKILL_CONTEXT_JSON; delete process.env.__CONVERSATION_ID; delete process.env.__SKILL_CONTEXT_JSON; process.exitCode = 0; }); afterEach(() => { if (savedConversationId === undefined) { delete process.env.__CONVERSATION_ID; } else { process.env.__CONVERSATION_ID = savedConversationId; } if (savedSkillContextJson === undefined) { delete process.env.__SKILL_CONTEXT_JSON; } else { process.env.__SKILL_CONTEXT_JSON = savedSkillContextJson; } process.exitCode = 0; }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function runKeysCommand(...args: string[]) { mock.module("../../lib/daemon-credential-client.js", () => ({ setSecureKeyViaDaemon: mockSetSecureKeyViaDaemon, deleteSecureKeyViaDaemon: mockDeleteSecureKeyViaDaemon, })); const { registerKeysCommand } = await import("../keys.js"); const stdoutChunks: string[] = []; const stderrChunks: string[] = []; const origStdoutWrite = process.stdout.write.bind(process.stdout); const origStderrWrite = process.stderr.write.bind(process.stderr); process.stdout.write = ((chunk: unknown) => { stdoutChunks.push(typeof chunk === "string" ? chunk : String(chunk)); return true; }) as typeof process.stdout.write; process.stderr.write = ((chunk: unknown) => { stderrChunks.push(typeof chunk === "string" ? chunk : String(chunk)); return true; }) as typeof process.stderr.write; try { const program = new Command(); program.exitOverride(); program.configureOutput({ writeErr: () => {}, writeOut: () => {} }); registerKeysCommand(program); await program.parseAsync(["node", "assistant", ...args]); } finally { process.stdout.write = origStdoutWrite; process.stderr.write = origStderrWrite; } return { stdout: stdoutChunks.join(""), stderr: stderrChunks.join("") }; } // --------------------------------------------------------------------------- // set — agent-shell inline-secret guard // --------------------------------------------------------------------------- describe("assistant keys set (agent-shell guard)", () => { test("refuses inline key when __CONVERSATION_ID is set", async () => { process.env.__CONVERSATION_ID = "conv-123"; const { stderr } = await runKeysCommand( "keys", "set", "acme", "sk-inline-secret", ); expect(mockSetSecureKeyViaDaemon).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(stderr).toContain("own terminal"); expect(stderr).toContain("--generated"); }); test("refuses inline key when __SKILL_CONTEXT_JSON carries a conversationId", async () => { process.env.__SKILL_CONTEXT_JSON = JSON.stringify({ conversationId: "conv-456", }); const { stderr } = await runKeysCommand( "keys", "set", "acme", "sk-inline-secret", ); expect(mockSetSecureKeyViaDaemon).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(stderr).toContain("own terminal"); }); test("--generated bypasses the guard from an agent shell", async () => { process.env.__CONVERSATION_ID = "conv-123"; await runKeysCommand( "keys", "set", "acme", "machine-obtained-value", "--generated", ); expect(mockSetSecureKeyViaDaemon).toHaveBeenCalledTimes(1); const [type, provider, value] = mockSetSecureKeyViaDaemon.mock .calls[0] as unknown as [string, string, string]; expect(type).toBe("api_key"); expect(provider).toBe("acme"); expect(value).toBe("machine-obtained-value"); expect(process.exitCode).toBe(0); }); test("stores inline key from a user terminal (no agent markers)", async () => { await runKeysCommand("keys", "set", "acme", "user-terminal-value"); expect(mockSetSecureKeyViaDaemon).toHaveBeenCalledTimes(1); const [type, provider, value] = mockSetSecureKeyViaDaemon.mock .calls[0] as unknown as [string, string, string]; expect(type).toBe("api_key"); expect(provider).toBe("acme"); expect(value).toBe("user-terminal-value"); expect(process.exitCode).toBe(0); }); }); // --------------------------------------------------------------------------- // delete — in-use guard and --force // --------------------------------------------------------------------------- describe("assistant keys delete (in-use guard)", () => { test("deletes without force by default", async () => { // GIVEN a stored key no connection depends on // WHEN the key is deleted without --force await runKeysCommand("keys", "delete", "acme"); // THEN the daemon is asked for a non-forced delete expect(mockDeleteSecureKeyViaDaemon).toHaveBeenCalledTimes(1); const [type, provider, force] = mockDeleteSecureKeyViaDaemon.mock .calls[0] as unknown as [string, string, boolean]; expect(type).toBe("api_key"); expect(provider).toBe("acme"); expect(force).toBe(false); expect(process.exitCode).toBe(0); }); test("--force carries explicit intent to the daemon", async () => { // GIVEN a caller who accepts breaking dependent connections // WHEN --force is passed await runKeysCommand("keys", "delete", "acme", "--force"); // THEN the daemon receives the force flag const [, , force] = mockDeleteSecureKeyViaDaemon.mock .calls[0] as unknown as [string, string, boolean]; expect(force).toBe(true); }); test("names dependent connections and points at --force when refused", async () => { // GIVEN a key an LLM provider connection resolves its auth through mockDeleteSecureKeyViaDaemon = mock( (): Promise => Promise.resolve({ result: "error", code: "CREDENTIAL_IN_USE", error: 'Credential credential/acme/api_key is in use by connection "acme-router".', }), ); const exitCalls: number[] = []; const origExit = process.exit; process.exit = ((code?: number) => { exitCalls.push(code ?? 0); throw new Error("__exit__"); }) as typeof process.exit; // WHEN the delete is attempted without --force try { await runKeysCommand("keys", "delete", "acme"); } catch (err) { expect((err as Error).message).toBe("__exit__"); } finally { process.exit = origExit; } // THEN the refusal names the connection and suggests the escape hatch expect(exitCalls).toEqual([1]); expect(loggedErrors.join("\n")).toContain('connection "acme-router"'); expect(loggedErrors.join("\n")).toContain("keys delete acme --force"); }); });