/** * Integration tests for the Vault environment variable routes (Agent-level * agent_state/.vault.toml): GET masks values (plaintext * is never sent), PUT is owner-only, whole-table replace semantics (omitting * value keeps the original, an absent key is deleted, a new key must supply a * value), 400 on key/shape validation, 404 for a nonexistent Agent, vaults of * different Agents are independent of each other, and PUT invalidates the Agent's * cached Session runtimes (the next Task re-resumes and sees the new values). */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { userText } from "@prismshadow/penguin-core"; import type { ProjectCreateResponse, VaultResponse } from "../src/api/types.js"; import type { RuntimeSession } from "../src/runtime/session-manager.js"; import { apiClient, createTestApp, provisionUser, waitFor } from "./helpers.js"; import type { TestApp } from "./helpers.js"; /** Minimal fake runtime Session: one assistant reply, no approvals (keeps the loader-count test free of LLM calls). */ function fakeRuntimeSession(sessionId: string): RuntimeSession { return { sessionId, toolPermission: () => "rw", generateTitle: async () => ({ title: null, usage: null }), compactability: () => "ok" as const, steer: () => false, skipReconnectWait: () => false, async *run() {}, async *compact() {}, }; } describe("vault api", () => { let t: TestApp; let owner: ReturnType; let member: ReturnType; let outsider: ReturnType; let projectId: string; let vaultPath: string; /** Loader call count: how many times the manager (re)built a runtime from the index. */ let loads: number; beforeEach(async () => { loads = 0; t = await createTestApp({ loader: { load: async (row) => { loads++; return fakeRuntimeSession(row.sessionId); }, }, }); const a = await provisionUser(t.app, "owner_a"); const b = await provisionUser(t.app, "member_b"); const c = await provisionUser(t.app, "outsider_c"); owner = apiClient(t.app, a.cookie); member = apiClient(t.app, b.cookie); outsider = apiClient(t.app, c.cookie); const created = (await ( await owner.post("/api/projects", { projectId: "owner_a-vault", name: "vault project" }) ).json()) as ProjectCreateResponse; projectId = created.project.projectId; vaultPath = `/api/projects/${projectId}/agents/default_agent/vault`; const add = await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" }); expect(add.status).toBe(201); }); afterEach(async () => { await t.cleanup(); }); it("GET masks values and never sends plaintext; members can read, outsiders 404; an empty vault returns an empty table", async () => { // Not configured yet: an empty table. const empty = (await (await owner.get(vaultPath)).json()) as VaultResponse; expect(empty.entries).toEqual([]); const put = await owner.put(vaultPath, { entries: [ { key: "OPENAI_API_KEY", value: "sk-vault-secret-123456" }, { key: "SHORT", value: "sk-11-chars" }, ], }); expect(put.status).toBe(200); // The PUT response likewise contains only masked values. expect(JSON.stringify(await put.json())).not.toContain("sk-vault-secret-123456"); const res = await member.get(vaultPath); expect(res.status).toBe(200); const body = (await res.json()) as VaultResponse; expect(body.entries).toEqual([ { key: "OPENAI_API_KEY", valueMasked: "sk-v…3456" }, { key: "SHORT", valueMasked: "***" }, // ≤12 chars are masked entirely (first4…last4 would expose over half) ]); expect(JSON.stringify(body)).not.toContain("sk-vault-secret-123456"); expect(JSON.stringify(body)).not.toContain("sk-11-chars"); expect((await outsider.get(vaultPath)).status).toBe(404); }); it("PUT is owner-only: member 403, outsider 404", async () => { expect((await member.put(vaultPath, { entries: [{ key: "K", value: "v" }] })).status).toBe(403); expect((await outsider.put(vaultPath, { entries: [{ key: "K", value: "v" }] })).status).toBe( 404, ); }); it("whole-table replace: omitting value keeps the original, absent keys are deleted, a new key without value 400", async () => { await owner.put(vaultPath, { entries: [ { key: "KEEP_ME", value: "keep-secret-000111" }, { key: "DROP_ME", value: "drop-secret" }, ], }); // KEEP_ME only sends back the key name (keeping the original value); DROP_ME is absent (deleted). const second = (await ( await owner.put(vaultPath, { entries: [{ key: "KEEP_ME" }] }) ).json()) as VaultResponse; expect(second.entries).toEqual([{ key: "KEEP_ME", valueMasked: "keep…0111" }]); // A new key without value → 400 (there's no original value to keep). const bad = await owner.put(vaultPath, { entries: [{ key: "KEEP_ME" }, { key: "BRAND_NEW" }], }); expect(bad.status).toBe(400); // Clear the whole table. const cleared = (await (await owner.put(vaultPath, { entries: [] })).json()) as VaultResponse; expect(cleared.entries).toEqual([]); }); it("vault updates leave models/credential and other config untouched", async () => { await owner.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "custom", modelId: "m-1" }, models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-model-key-999888" }], }); await owner.put(vaultPath, { entries: [{ key: "K1", value: "v1-secret" }] }); const models = await owner.get(`/api/projects/${projectId}/models`); const modelsBody = (await models.json()) as { models: { credential?: unknown }[] }; expect(modelsBody.models).toHaveLength(1); expect(modelsBody.models[0]!.credential).toBeTruthy(); }); it("PUT invalidates the Agent's cached Session runtimes: the next Task re-resumes and picks up the new vault", async () => { t.deps.sessionsRepo.insert({ sessionId: "vault-sess-1", projectId, agentId: "default_agent", modelId: "m1", provider: "custom", workspace: t.root, approvalMode: "allow-all", title: null, createdAt: new Date().toISOString(), }); const idle = () => t.deps.manager.statusOf("vault-sess-1") === "idle"; // First Task builds the runtime (load #1); the second reuses the active-table entry. await t.deps.manager.startTask("vault-sess-1", [userText("a")]); await waitFor(idle); await t.deps.manager.startTask("vault-sess-1", [userText("b")]); await waitFor(idle); expect(loads).toBe(1); // Vault update via the API: the cached runtime is stale, so the next Task re-resumes // (the loader re-reads .vault.toml — the new value reaches the next Task's environment). const put = await owner.put(vaultPath, { entries: [{ key: "NEW_KEY", value: "v-secret-1" }] }); expect(put.status).toBe(200); await t.deps.manager.startTask("vault-sess-1", [userText("c")]); await waitFor(idle); expect(loads).toBe(2); // Reads don't invalidate: the rebuilt runtime is reused. expect((await owner.get(vaultPath)).status).toBe(200); await t.deps.manager.startTask("vault-sess-1", [userText("d")]); await waitFor(idle); expect(loads).toBe(2); }); it("Agent-level isolation: different Agents' vaults are independent; a nonexistent Agent 404", async () => { await owner.put(vaultPath, { entries: [{ key: "ONLY_DEFAULT", value: "v-default-1" }] }); // Another Agent in the same Project: empty table, and writes don't affect each other. expect( (await owner.post(`/api/projects/${projectId}/agents`, { agentId: "other_agent" })).status, ).toBe(201); const otherPath = `/api/projects/${projectId}/agents/other_agent/vault`; const empty = (await (await owner.get(otherPath)).json()) as VaultResponse; expect(empty.entries).toEqual([]); await owner.put(otherPath, { entries: [{ key: "ONLY_OTHER", value: "v-other-1" }] }); const def = (await (await owner.get(vaultPath)).json()) as VaultResponse; expect(def.entries.map((e) => e.key)).toEqual(["ONLY_DEFAULT"]); // Agent doesn't exist (including traversal-style ids blocked by requireValidId) → 404. expect((await owner.get(`/api/projects/${projectId}/agents/no-such-agent/vault`)).status).toBe( 404, ); expect( ( await owner.put(`/api/projects/${projectId}/agents/no-such-agent/vault`, { entries: [{ key: "K", value: "v" }], }) ).status, ).toBe(404); }); it("key name and request body shape validation 400", async () => { const cases: unknown[] = [ { entries: [{ key: "1BAD", value: "v" }] }, // starts with a digit { entries: [{ key: "BAD-DASH", value: "v" }] }, // hyphen { entries: [{ key: "BAD KEY", value: "v" }] }, // space { entries: [{ key: "OK_KEY", value: "" }] }, // empty value { entries: [ { key: "DUP", value: "a" }, { key: "DUP", value: "b" }, ], }, // duplicate key { entries: [{ key: "BIG", value: "x".repeat(8193) }] }, // value too long (>8192, guards against exec E2BIG) { entries: "nope" }, // entries is not an array { entries: [42] }, // entry is not an object ]; for (const body of cases) { expect((await owner.put(vaultPath, body)).status).toBe(400); } }); });