/** * Tests for the credentials set CLI subcommand's agent-shell guard. * * Validates: * - inline set from an agent shell (__CONVERSATION_ID) is refused with a * redirect to `credentials prompt` and no IPC call * - inline set from a skill sandbox (__SKILL_CONTEXT_JSON) is refused * - --generated bypasses the guard and stores via credentials_set * - a plain user-terminal invocation (no markers) stores via credentials_set */ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { Command } from "commander"; // --------------------------------------------------------------------------- // Mock state // --------------------------------------------------------------------------- let mockIpcCallFn = mock(() => Promise.resolve({ ok: true, result: {} })); // --------------------------------------------------------------------------- // Mocks — must be declared before importing the module under test // --------------------------------------------------------------------------- mock.module("../../../ipc/cli-client.js", () => ({ cliIpcCall: mockIpcCallFn, exitFromIpcResult: mock((r: { error?: string }) => { process.stderr.write((r.error ?? "Unknown error") + "\n"); process.exitCode = 10; }), })); mock.module("../../../util/logger.js", () => ({ getLogger: () => ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, }), getCliLogger: () => ({ info: () => {}, warn: () => {}, error: () => {}, debug: () => {}, }), })); // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- let savedConversationId: string | undefined; let savedSkillContextJson: string | undefined; beforeEach(() => { mockIpcCallFn = mock(() => Promise.resolve({ ok: true, result: { credentialId: "cred-123", service: "acme", field: "api_key" }, }), ); 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 runCredentialsCommand(...args: string[]) { mock.module("../../../ipc/cli-client.js", () => ({ cliIpcCall: mockIpcCallFn, exitFromIpcResult: mock((r: { error?: string }) => { process.stderr.write((r.error ?? "Unknown error") + "\n"); process.exitCode = 10; }), })); const { registerCredentialsCommand } = await import("../credentials.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: () => {} }); registerCredentialsCommand(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 credentials set (agent-shell guard)", () => { test("refuses inline value when __CONVERSATION_ID is set", async () => { process.env.__CONVERSATION_ID = "conv-123"; const { stderr } = await runCredentialsCommand( "credentials", "set", "sk-inline-secret", "--service", "acme", "--field", "api_key", ); expect(mockIpcCallFn).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(stderr).toContain("credentials prompt"); expect(stderr).toContain("--generated"); }); test("refuses inline value when __SKILL_CONTEXT_JSON carries a conversationId", async () => { process.env.__SKILL_CONTEXT_JSON = JSON.stringify({ conversationId: "conv-456", }); const { stderr } = await runCredentialsCommand( "credentials", "set", "sk-inline-secret", "--service", "acme", "--field", "api_key", ); expect(mockIpcCallFn).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(stderr).toContain("credentials prompt"); }); test("--generated bypasses the guard from an agent shell", async () => { process.env.__CONVERSATION_ID = "conv-123"; await runCredentialsCommand( "credentials", "set", "machine-generated-value", "--service", "acme", "--field", "webhook_secret", "--generated", ); expect(mockIpcCallFn).toHaveBeenCalledTimes(1); const [method, payload] = mockIpcCallFn.mock.calls[0] as unknown as [ string, { body: Record }, ]; expect(method).toBe("credentials_set"); expect(payload.body.service).toBe("acme"); expect(payload.body.field).toBe("webhook_secret"); expect(payload.body.value).toBe("machine-generated-value"); expect(process.exitCode).toBe(0); }); test("stores inline value from a user terminal (no agent markers)", async () => { await runCredentialsCommand( "credentials", "set", "user-terminal-value", "--service", "acme", "--field", "api_key", ); expect(mockIpcCallFn).toHaveBeenCalledTimes(1); const [method, payload] = mockIpcCallFn.mock.calls[0] as unknown as [ string, { body: Record }, ]; expect(method).toBe("credentials_set"); expect(payload.body.value).toBe("user-terminal-value"); expect(process.exitCode).toBe(0); }); });