@openparachute/agent 0.2.3-rc.2 → 0.2.3-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/package.json +4 -1
  2. package/src/transports/vault.ts +19 -1
  3. package/web/ui/dist/assets/{index-5KEwEhfi.js → index-0e7eQymr.js} +1 -1
  4. package/web/ui/dist/assets/index-tvKbxee4.css +1 -0
  5. package/web/ui/dist/index.html +2 -2
  6. package/src/_parked/interactive-spawn.test.ts +0 -324
  7. package/src/_parked/interactive-spawn.ts +0 -701
  8. package/src/agent-defs.test.ts +0 -1504
  9. package/src/agent-mcp-config.test.ts +0 -115
  10. package/src/agents.test.ts +0 -360
  11. package/src/auth.test.ts +0 -46
  12. package/src/backends/attached-queue.test.ts +0 -376
  13. package/src/backends/programmatic.test.ts +0 -1715
  14. package/src/backends/registry.test.ts +0 -1494
  15. package/src/backends/stream-json.test.ts +0 -570
  16. package/src/channel-backend-wiring.test.ts +0 -237
  17. package/src/credentials.test.ts +0 -274
  18. package/src/cron.test.ts +0 -342
  19. package/src/daemon-agent-def-api.test.ts +0 -166
  20. package/src/daemon-agent-defs-api.test.ts +0 -953
  21. package/src/daemon-agent-env-api.test.ts +0 -338
  22. package/src/daemon-attached-queue-store.test.ts +0 -65
  23. package/src/daemon-config-api.test.ts +0 -962
  24. package/src/daemon-jobs-api.test.ts +0 -271
  25. package/src/daemon-vault-chat.test.ts +0 -250
  26. package/src/daemon.test.ts +0 -746
  27. package/src/def-vaults.test.ts +0 -136
  28. package/src/delivery-state.test.ts +0 -110
  29. package/src/effective-env.test.ts +0 -114
  30. package/src/grants.test.ts +0 -638
  31. package/src/hub-jwt.test.ts +0 -161
  32. package/src/jobs.test.ts +0 -245
  33. package/src/mcp-http.test.ts +0 -265
  34. package/src/mint-token.test.ts +0 -152
  35. package/src/module-manifest.test.ts +0 -158
  36. package/src/programmatic-wiring.test.ts +0 -838
  37. package/src/registry.test.ts +0 -227
  38. package/src/resolve-port.test.ts +0 -64
  39. package/src/routing.test.ts +0 -184
  40. package/src/runner.test.ts +0 -506
  41. package/src/sandbox/config.test.ts +0 -150
  42. package/src/sandbox/egress.test.ts +0 -113
  43. package/src/sandbox/live-seatbelt.test.ts +0 -277
  44. package/src/sandbox/mounts.test.ts +0 -154
  45. package/src/sandbox/sandbox.test.ts +0 -168
  46. package/src/services-manifest.test.ts +0 -106
  47. package/src/spa-serve.test.ts +0 -116
  48. package/src/spawn-agent-cli.test.ts +0 -172
  49. package/src/spawn-agent.test.ts +0 -1218
  50. package/src/spawn-deps.test.ts +0 -54
  51. package/src/terminal-assets.test.ts +0 -50
  52. package/src/terminal.test.ts +0 -530
  53. package/src/transports/http-ui.test.ts +0 -455
  54. package/src/transports/telegram.test.ts +0 -174
  55. package/src/transports/vault.test.ts +0 -2012
  56. package/src/ui-kit.test.ts +0 -178
  57. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  58. package/web/ui/tsconfig.json +0 -21
@@ -1,237 +0,0 @@
1
- /**
2
- * Tests for the ATTACHED-backend daemon wiring (design 2026-06-18-attached-backend.md,
3
- * phases 1-2) — the two load-bearing seams:
4
- *
5
- * 1. The DAEMON ROUTING FORK (`contextFor`): an inbound for a `backend:attached`
6
- * agent must NOT enqueue to the ProgrammaticAgentRegistry (no `claude -p`) and
7
- * lands as a durable queue note; an inbound for a programmatic agent still
8
- * enqueues as before (no regression).
9
- * 2. The CHANNEL MCP SURFACE (`dispatchChannelTool`): pending / next-message /
10
- * reply / release dispatch to the AttachedQueueRegistry; the outbound from `reply`
11
- * goes through the channel transport's reply (the `#agent/message/outbound` path —
12
- * loop-safe).
13
- *
14
- * Fakes throughout — no real vault / hub / tmux.
15
- */
16
-
17
- import { describe, test, expect } from "bun:test";
18
- import { contextFor } from "./daemon.ts";
19
- import { ClientRegistry } from "./routing.ts";
20
- import { DeliveryState } from "./delivery-state.ts";
21
- import {
22
- ProgrammaticAgentRegistry,
23
- type WriteOutbound,
24
- } from "./backends/registry.ts";
25
- import {
26
- AttachedQueueRegistry,
27
- type AttachedQueueStore,
28
- } from "./backends/attached-queue.ts";
29
- import { dispatchChannelTool } from "./mcp-http.ts";
30
- import { SCOPE_READ, SCOPE_WRITE } from "./auth.ts";
31
- import type { AgentBackend, AgentHandle, AgentStatus, DeliverResult } from "./backends/types.ts";
32
- import type { AgentSpec } from "./sandbox/types.ts";
33
- import type { InboundQueueNote, InboundStatus } from "./transports/vault.ts";
34
-
35
- // --- fakes -----------------------------------------------------------------
36
-
37
- /** A programmatic backend whose `deliver` (the `claude -p` turn) records calls. */
38
- class FakeProgrammaticBackend implements AgentBackend {
39
- readonly kind = "programmatic";
40
- readonly delivered: string[] = [];
41
- async start(spec: AgentSpec): Promise<AgentHandle> {
42
- return { backend: this.kind, channel: spec.channels[0] as string, name: spec.name, spec };
43
- }
44
- async deliver(_handle: AgentHandle, message: string): Promise<DeliverResult> {
45
- this.delivered.push(message);
46
- return { ok: true, reply: `auto:${message}` };
47
- }
48
- async stop(): Promise<void> {}
49
- async status(): Promise<AgentStatus> {
50
- return { live: true };
51
- }
52
- }
53
-
54
- function noopWriteOutbound(): WriteOutbound {
55
- return async () => {};
56
- }
57
-
58
- /** A fake attached-queue store: in-memory notes + recorded outbound. */
59
- class FakeStore implements AttachedQueueStore {
60
- readonly notes = new Map<string, InboundQueueNote>();
61
- readonly outbound: Array<{ text: string; inReplyTo?: string }> = [];
62
- add(n: InboundQueueNote): void {
63
- this.notes.set(n.id, n);
64
- }
65
- async listInboundQueue(): Promise<InboundQueueNote[]> {
66
- return [...this.notes.values()].map((n) => ({ ...n })).sort((a, b) => (a.ts < b.ts ? -1 : 1));
67
- }
68
- async setInboundStatus(id: string, status: InboundStatus, claimedAt?: string | null): Promise<void> {
69
- const n = this.notes.get(id)!;
70
- n.status = status;
71
- if (claimedAt === null) delete n.claimedAt;
72
- else if (claimedAt !== undefined) n.claimedAt = claimedAt;
73
- }
74
- async reply(args: { text: string; inReplyTo?: string }): Promise<{ sent: string[] }> {
75
- this.outbound.push({ text: args.text, ...(args.inReplyTo ? { inReplyTo: args.inReplyTo } : {}) });
76
- return { sent: ["out-1"] };
77
- }
78
- }
79
-
80
- const channelSpec = (name: string, systemPrompt?: string): AgentSpec => ({
81
- name,
82
- channels: [name],
83
- backend: "attached",
84
- ...(systemPrompt ? { systemPrompt } : {}),
85
- });
86
-
87
- // --- 1. the daemon routing fork --------------------------------------------
88
-
89
- describe("daemon routing fork (contextFor) — attached vs programmatic", () => {
90
- test("an attached-backend inbound does NOT enqueue to the programmatic worker", async () => {
91
- const backend = new FakeProgrammaticBackend();
92
- const programmatic = new ProgrammaticAgentRegistry({ backend, writeOutbound: noopWriteOutbound() });
93
- const attachedQueue = new AttachedQueueRegistry();
94
- const store = new FakeStore();
95
- // Register an ATTACHED agent for "laptop" (its queue store is the fake).
96
- attachedQueue.register(channelSpec("laptop"), store);
97
-
98
- const registry = new ClientRegistry();
99
- const ctx = contextFor(registry, "laptop", new DeliveryState(), programmatic, attachedQueue);
100
- // Simulate the vault trigger delivering an inbound for the channel.
101
- ctx.emit({
102
- channel: "laptop",
103
- content: "handle this when you're around",
104
- meta: { note_id: "note-1", ts: "2026-06-18T10:00:00Z" },
105
- source: "vault",
106
- });
107
-
108
- // THE ASSERTION: the programmatic worker was NOT invoked (no `claude -p` turn).
109
- expect(backend.delivered).toEqual([]);
110
- // And the durable note is the queue item — it's pending (the trigger creates it as
111
- // status:pending; emit doesn't mutate it). The note already lives in the vault; the
112
- // channel registry reads it on the next pull.
113
- store.add({ id: "note-1", text: "handle this when you're around", sender: "operator", ts: "2026-06-18T10:00:00Z", status: "pending" });
114
- const view = await attachedQueue.pending("laptop");
115
- expect(view.count).toBe(1);
116
- });
117
-
118
- test("a programmatic inbound STILL enqueues to the worker (no regression)", async () => {
119
- const backend = new FakeProgrammaticBackend();
120
- const programmatic = new ProgrammaticAgentRegistry({ backend, writeOutbound: noopWriteOutbound() });
121
- await programmatic.register({ name: "eng", channels: ["eng"], backend: "programmatic" });
122
- const attachedQueue = new AttachedQueueRegistry(); // no attached agent for "eng".
123
-
124
- const registry = new ClientRegistry();
125
- const ctx = contextFor(registry, "eng", new DeliveryState(), programmatic, attachedQueue);
126
- ctx.emit({ channel: "eng", content: "do a task", meta: { note_id: "n-eng" }, source: "vault" });
127
-
128
- // The serial worker drains async — wait for the turn to run.
129
- for (let i = 0; i < 200 && backend.delivered.length === 0; i++) {
130
- await new Promise<void>((r) => setTimeout(r, 1));
131
- }
132
- expect(backend.delivered).toEqual(["do a task"]); // the programmatic path is untouched.
133
- });
134
-
135
- test("the channel fork is checked FIRST — a name in BOTH registries routes to channel", async () => {
136
- // Defense-in-depth: even if a programmatic agent somehow shares the channel, the
137
- // channel check short-circuits (the fork is explicit + first), so no `claude -p`.
138
- const backend = new FakeProgrammaticBackend();
139
- const programmatic = new ProgrammaticAgentRegistry({ backend, writeOutbound: noopWriteOutbound() });
140
- await programmatic.register({ name: "dual", channels: ["dual"], backend: "programmatic" });
141
- const attachedQueue = new AttachedQueueRegistry();
142
- attachedQueue.register(channelSpec("dual"), new FakeStore());
143
-
144
- const ctx = contextFor(new ClientRegistry(), "dual", new DeliveryState(), programmatic, attachedQueue);
145
- ctx.emit({ channel: "dual", content: "x", meta: {}, source: "vault" });
146
- await new Promise<void>((r) => setTimeout(r, 10));
147
- expect(backend.delivered).toEqual([]); // channel won the fork.
148
- });
149
- });
150
-
151
- // --- 2. the channel MCP surface --------------------------------------------
152
-
153
- const RW = [SCOPE_READ, SCOPE_WRITE];
154
-
155
- function parse(result: { content: Array<{ type: "text"; text: string }> }): unknown {
156
- return JSON.parse(result.content[0]!.text);
157
- }
158
-
159
- describe("channel MCP surface (dispatchChannelTool)", () => {
160
- test("pending → { count, items }", async () => {
161
- const reg = new AttachedQueueRegistry();
162
- const store = new FakeStore();
163
- store.add({ id: "a", text: "hi", sender: "operator", ts: "2026-06-18T10:00:00Z", status: "pending" });
164
- reg.register(channelSpec("laptop"), store);
165
-
166
- const r = await dispatchChannelTool("laptop", reg, RW, "pending", {});
167
- expect(r.isError).toBeUndefined();
168
- expect(parse(r)).toEqual({ count: 1, items: [{ id: "a", preview: "hi" }] });
169
- });
170
-
171
- test("next-message claims + returns id/text/inReplyTo/systemPrompt", async () => {
172
- const reg = new AttachedQueueRegistry();
173
- const store = new FakeStore();
174
- store.add({ id: "a", text: "the question", sender: "operator", ts: "2026-06-18T10:00:00Z", status: "pending" });
175
- reg.register(channelSpec("laptop", "You are laptop."), store);
176
-
177
- const r = await dispatchChannelTool("laptop", reg, RW, "next-message", {});
178
- const claimed = parse(r) as { id: string; text: string; inReplyTo: string; systemPrompt: string };
179
- expect(claimed.id).toBe("a");
180
- expect(claimed.text).toBe("the question");
181
- expect(claimed.inReplyTo).toBe("a");
182
- expect(claimed.systemPrompt).toBe("You are laptop.");
183
- expect(store.notes.get("a")!.status).toBe("in-flight");
184
- });
185
-
186
- test("next-message returns a null-message sentinel when the queue is empty", async () => {
187
- const reg = new AttachedQueueRegistry();
188
- reg.register(channelSpec("laptop"), new FakeStore());
189
- const r = await dispatchChannelTool("laptop", reg, RW, "next-message", {});
190
- expect(parse(r)).toEqual({ message: null, note: "no pending messages" });
191
- });
192
-
193
- test("reply writes the outbound (loop-safe path) + marks handled", async () => {
194
- const reg = new AttachedQueueRegistry();
195
- const store = new FakeStore();
196
- store.add({ id: "a", text: "q", sender: "operator", ts: "2026-06-18T10:00:00Z", status: "in-flight", claimedAt: "2026-06-18T10:01:00Z" });
197
- reg.register(channelSpec("laptop"), store);
198
-
199
- const r = await dispatchChannelTool("laptop", reg, RW, "reply", { inReplyTo: "a", text: "the answer" });
200
- expect(r.isError).toBeUndefined();
201
- // The outbound went through the channel transport's reply seam — which writes a
202
- // `#agent/message/outbound` note (loop-safe; the inbound trigger never fires on it).
203
- expect(store.outbound).toEqual([{ text: "the answer", inReplyTo: "a" }]);
204
- expect(store.notes.get("a")!.status).toBe("handled");
205
- });
206
-
207
- test("release un-claims back to pending", async () => {
208
- const reg = new AttachedQueueRegistry();
209
- const store = new FakeStore();
210
- store.add({ id: "a", text: "q", sender: "operator", ts: "t", status: "in-flight", claimedAt: "2026-06-18T10:00:00Z" });
211
- reg.register(channelSpec("laptop"), store);
212
-
213
- const r = await dispatchChannelTool("laptop", reg, RW, "release", { id: "a" });
214
- expect(r.isError).toBeUndefined();
215
- expect(store.notes.get("a")!.status).toBe("pending");
216
- });
217
-
218
- test("write tools require agent:write; a read-only token is refused", async () => {
219
- const reg = new AttachedQueueRegistry();
220
- reg.register(channelSpec("laptop"), new FakeStore());
221
- for (const tool of ["next-message", "reply", "release"]) {
222
- const r = await dispatchChannelTool("laptop", reg, [SCOPE_READ], tool, { id: "a", text: "x" });
223
- expect(r.isError).toBe(true);
224
- expect(r.content[0]!.text).toContain(SCOPE_WRITE);
225
- }
226
- // pending is read-only — works with a read token.
227
- const ok = await dispatchChannelTool("laptop", reg, [SCOPE_READ], "pending", {});
228
- expect(ok.isError).toBeUndefined();
229
- });
230
-
231
- test("a non-attached channel gates cleanly (tool error, not a crash)", async () => {
232
- const reg = new AttachedQueueRegistry(); // nothing registered.
233
- const r = await dispatchChannelTool("nope", reg, RW, "pending", {});
234
- expect(r.isError).toBe(true);
235
- expect(r.content[0]!.text).toContain("no attached-backend agent");
236
- });
237
- });
@@ -1,274 +0,0 @@
1
- /**
2
- * Per-channel Claude OAuth credential store (design §6).
3
- *
4
- * Covers: store/retrieve round-trip, 0600 on the secret file, redaction (the
5
- * raw token never appears in the inspection helper / serialized output), and
6
- * default-vs-override resolution (override wins, falls back to default, errors
7
- * when neither). All hermetic under a throwaway state dir.
8
- */
9
- import { describe, test, expect, beforeEach, afterEach } from "bun:test";
10
- import { mkdtempSync, rmSync, existsSync, readFileSync, statSync } from "fs";
11
- import { join } from "path";
12
- import { tmpdir } from "os";
13
- import {
14
- setDefaultClaudeCredential,
15
- setChannelClaudeCredential,
16
- removeChannelClaudeCredential,
17
- resolveClaudeCredential,
18
- describeClaudeCredentials,
19
- readCredentialsFile,
20
- credentialsFilePath,
21
- CredentialNotConfiguredError,
22
- setChannelEnvVar,
23
- removeChannelEnvVar,
24
- resolveChannelEnv,
25
- describeChannelEnv,
26
- DenylistedEnvError,
27
- DENYLISTED_ENV,
28
- } from "./credentials.ts";
29
-
30
- const DEFAULT_TOKEN = "oat_DEFAULT-OPERATOR-TOKEN-SECRET";
31
- const OVERRIDE_TOKEN = "oat_PER-CHANNEL-OVERRIDE-SECRET";
32
-
33
- let dir: string;
34
- beforeEach(() => {
35
- dir = mkdtempSync(join(tmpdir(), "channel-creds-"));
36
- });
37
- afterEach(() => {
38
- rmSync(dir, { recursive: true, force: true });
39
- });
40
-
41
- describe("store / retrieve round-trip", () => {
42
- test("default token: set then resolve returns it", () => {
43
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
44
- expect(resolveClaudeCredential("any-channel", dir)).toBe(DEFAULT_TOKEN);
45
- });
46
-
47
- test("per-channel override: set then resolve returns it for that channel", () => {
48
- setChannelClaudeCredential("aaron-dev", OVERRIDE_TOKEN, dir);
49
- expect(resolveClaudeCredential("aaron-dev", dir)).toBe(OVERRIDE_TOKEN);
50
- });
51
-
52
- test("setting one slice preserves the other (read-modify-write)", () => {
53
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
54
- setChannelClaudeCredential("aaron-dev", OVERRIDE_TOKEN, dir);
55
- setChannelClaudeCredential("ops", "oat_OPS", dir);
56
- const file = readCredentialsFile(dir);
57
- expect(file.claude!.default).toBe(DEFAULT_TOKEN);
58
- expect(file.claude!.channels!["aaron-dev"]).toBe(OVERRIDE_TOKEN);
59
- expect(file.claude!.channels!["ops"]).toBe("oat_OPS");
60
- });
61
-
62
- test("empty token is rejected (never persists a blank credential)", () => {
63
- expect(() => setDefaultClaudeCredential("", dir)).toThrow(/non-empty token/);
64
- expect(() => setChannelClaudeCredential("c", "", dir)).toThrow(/non-empty token/);
65
- expect(existsSync(credentialsFilePath(dir))).toBe(false);
66
- });
67
- });
68
-
69
- describe("0600 on the secret file", () => {
70
- test("the credentials file is written 0600 (holds a secret)", () => {
71
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
72
- const file = credentialsFilePath(dir);
73
- expect(existsSync(file)).toBe(true);
74
- expect(statSync(file).mode & 0o777).toBe(0o600);
75
- });
76
-
77
- test("a subsequent write keeps it 0600 (chmod is unconditional)", () => {
78
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
79
- // Loosen perms behind the store's back, then write again → must re-tighten.
80
- const fs = require("fs") as typeof import("fs");
81
- fs.chmodSync(credentialsFilePath(dir), 0o644);
82
- setChannelClaudeCredential("c", OVERRIDE_TOKEN, dir);
83
- expect(statSync(credentialsFilePath(dir)).mode & 0o777).toBe(0o600);
84
- });
85
- });
86
-
87
- describe("redaction — the raw token never leaks via the inspection helper", () => {
88
- test("describeClaudeCredentials reports presence + channel names, NOT the token", () => {
89
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
90
- setChannelClaudeCredential("aaron-dev", OVERRIDE_TOKEN, dir);
91
- setChannelClaudeCredential("ops", "oat_OPS", dir);
92
- const desc = describeClaudeCredentials(dir);
93
- expect(desc.defaultSet).toBe(true);
94
- expect(desc.channels).toEqual(["aaron-dev", "ops"]); // sorted, names only
95
- const serialized = JSON.stringify(desc);
96
- expect(serialized).not.toContain(DEFAULT_TOKEN);
97
- expect(serialized).not.toContain(OVERRIDE_TOKEN);
98
- expect(serialized).not.toContain("oat_OPS");
99
- });
100
-
101
- test("describe on an empty store: defaultSet false, no channels", () => {
102
- const desc = describeClaudeCredentials(dir);
103
- expect(desc).toEqual({ defaultSet: false, channels: [] });
104
- });
105
- });
106
-
107
- describe("default-vs-override resolution", () => {
108
- test("override WINS over the default for its channel", () => {
109
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
110
- setChannelClaudeCredential("aaron-dev", OVERRIDE_TOKEN, dir);
111
- expect(resolveClaudeCredential("aaron-dev", dir)).toBe(OVERRIDE_TOKEN);
112
- // A different channel with no override falls back to the default.
113
- expect(resolveClaudeCredential("other", dir)).toBe(DEFAULT_TOKEN);
114
- });
115
-
116
- test("falls back to the default when the channel has no override", () => {
117
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
118
- expect(resolveClaudeCredential("never-configured", dir)).toBe(DEFAULT_TOKEN);
119
- });
120
-
121
- test("ERRORS when neither an override nor a default is set", () => {
122
- expect(() => resolveClaudeCredential("ghost", dir)).toThrow(CredentialNotConfiguredError);
123
- expect(() => resolveClaudeCredential("ghost", dir)).toThrow(/no Claude credential for channel "ghost"/);
124
- });
125
-
126
- test("removing an override falls back to the default; removing a missing one is a no-op", () => {
127
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
128
- setChannelClaudeCredential("aaron-dev", OVERRIDE_TOKEN, dir);
129
- expect(removeChannelClaudeCredential("aaron-dev", dir)).toBe(true);
130
- expect(resolveClaudeCredential("aaron-dev", dir)).toBe(DEFAULT_TOKEN); // back to default
131
- expect(removeChannelClaudeCredential("aaron-dev", dir)).toBe(false); // already gone
132
- // The default is untouched by an override removal.
133
- expect(readCredentialsFile(dir).claude!.default).toBe(DEFAULT_TOKEN);
134
- });
135
-
136
- test("resolution is read dynamically — a rotate takes effect on the next resolve", () => {
137
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
138
- expect(resolveClaudeCredential("c", dir)).toBe(DEFAULT_TOKEN);
139
- setDefaultClaudeCredential("oat_ROTATED", dir);
140
- expect(resolveClaudeCredential("c", dir)).toBe("oat_ROTATED");
141
- });
142
- });
143
-
144
- // ===========================================================================
145
- // Generic per-channel env store (GH_TOKEN / CLOUDFLARE_API_TOKEN / …)
146
- // ===========================================================================
147
- const GH = "ghp_GITHUB-TOKEN-SECRET";
148
- const CF = "cf_CLOUDFLARE-TOKEN-SECRET";
149
-
150
- describe("env store — set / resolve / channel-over-default merge", () => {
151
- test("default var: set with null channel, resolves for any channel", () => {
152
- setChannelEnvVar(null, "GH_TOKEN", GH, dir);
153
- expect(resolveChannelEnv("anything", dir)).toEqual({ GH_TOKEN: GH });
154
- // An empty-string channel also targets the default layer.
155
- setChannelEnvVar("", "CF_TOKEN", CF, dir);
156
- expect(resolveChannelEnv("anything", dir)).toEqual({ GH_TOKEN: GH, CF_TOKEN: CF });
157
- });
158
-
159
- test("per-channel override WINS over the default for that channel; others see only the default", () => {
160
- setChannelEnvVar(null, "GH_TOKEN", "ghp_DEFAULT", dir);
161
- setChannelEnvVar("aaron-dev", "GH_TOKEN", "ghp_AARON", dir);
162
- setChannelEnvVar("aaron-dev", "CLOUDFLARE_API_TOKEN", CF, dir);
163
- // channel layer wins on GH_TOKEN, plus its own CF token, plus inherits nothing extra.
164
- expect(resolveChannelEnv("aaron-dev", dir)).toEqual({ GH_TOKEN: "ghp_AARON", CLOUDFLARE_API_TOKEN: CF });
165
- // a different channel falls back to the default only.
166
- expect(resolveChannelEnv("other", dir)).toEqual({ GH_TOKEN: "ghp_DEFAULT" });
167
- });
168
-
169
- test("resolves to {} when nothing is configured (env injection is optional)", () => {
170
- expect(resolveChannelEnv("ghost", dir)).toEqual({});
171
- });
172
-
173
- test("setting an env var preserves the Claude slice (independent namespaces)", () => {
174
- setDefaultClaudeCredential(DEFAULT_TOKEN, dir);
175
- setChannelEnvVar(null, "GH_TOKEN", GH, dir);
176
- const file = readCredentialsFile(dir);
177
- expect(file.claude!.default).toBe(DEFAULT_TOKEN); // untouched
178
- expect(file.env!.default!.GH_TOKEN).toBe(GH);
179
- });
180
-
181
- test("read dynamically — a value change takes effect on the next resolve", () => {
182
- setChannelEnvVar("c", "GH_TOKEN", "ghp_OLD", dir);
183
- expect(resolveChannelEnv("c", dir).GH_TOKEN).toBe("ghp_OLD");
184
- setChannelEnvVar("c", "GH_TOKEN", "ghp_NEW", dir);
185
- expect(resolveChannelEnv("c", dir).GH_TOKEN).toBe("ghp_NEW");
186
- });
187
-
188
- test("the env store file is written 0600 (holds secrets)", () => {
189
- setChannelEnvVar(null, "GH_TOKEN", GH, dir);
190
- expect(statSync(credentialsFilePath(dir)).mode & 0o777).toBe(0o600);
191
- });
192
- });
193
-
194
- describe("env store — remove", () => {
195
- test("remove a default var; remove a missing one is a no-op (false)", () => {
196
- setChannelEnvVar(null, "GH_TOKEN", GH, dir);
197
- setChannelEnvVar(null, "CF_TOKEN", CF, dir);
198
- expect(removeChannelEnvVar(null, "GH_TOKEN", dir)).toBe(true);
199
- expect(resolveChannelEnv("any", dir)).toEqual({ CF_TOKEN: CF });
200
- expect(removeChannelEnvVar(null, "GH_TOKEN", dir)).toBe(false); // already gone
201
- });
202
-
203
- test("remove a channel override; the default for that name re-emerges", () => {
204
- setChannelEnvVar(null, "GH_TOKEN", "ghp_DEFAULT", dir);
205
- setChannelEnvVar("aaron-dev", "GH_TOKEN", "ghp_AARON", dir);
206
- expect(removeChannelEnvVar("aaron-dev", "GH_TOKEN", dir)).toBe(true);
207
- expect(resolveChannelEnv("aaron-dev", dir)).toEqual({ GH_TOKEN: "ghp_DEFAULT" }); // back to default
208
- });
209
-
210
- test("removing the last var of a channel prunes the empty channel map", () => {
211
- setChannelEnvVar("c", "GH_TOKEN", GH, dir);
212
- removeChannelEnvVar("c", "GH_TOKEN", dir);
213
- const file = readCredentialsFile(dir);
214
- // The channel (and the now-empty channels map) is pruned, not left as {}.
215
- expect(file.env?.channels).toBeUndefined();
216
- });
217
- });
218
-
219
- describe("env store — redaction (describeChannelEnv returns NAMES only)", () => {
220
- test("describe reports names per layer, never the values", () => {
221
- setChannelEnvVar(null, "GH_TOKEN", GH, dir);
222
- setChannelEnvVar("aaron-dev", "CLOUDFLARE_API_TOKEN", CF, dir);
223
- setChannelEnvVar("aaron-dev", "GH_TOKEN", "ghp_AARON", dir);
224
- const desc = describeChannelEnv(dir);
225
- expect(desc.default).toEqual(["GH_TOKEN"]);
226
- expect(desc.channels["aaron-dev"]).toEqual(["CLOUDFLARE_API_TOKEN", "GH_TOKEN"]); // sorted
227
- const serialized = JSON.stringify(desc);
228
- expect(serialized).not.toContain(GH);
229
- expect(serialized).not.toContain(CF);
230
- expect(serialized).not.toContain("ghp_AARON");
231
- });
232
-
233
- test("describe on an empty store: no default, no channels", () => {
234
- expect(describeChannelEnv(dir)).toEqual({ default: [], channels: {} });
235
- });
236
- });
237
-
238
- describe("env store — denylist (the Claude-auth trio is never settable)", () => {
239
- test("the denylist is exactly the Claude-auth vars", () => {
240
- expect([...DENYLISTED_ENV].sort()).toEqual(
241
- ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"].sort(),
242
- );
243
- });
244
-
245
- test("setter REJECTS each denylisted name (default + channel), nothing persisted", () => {
246
- for (const name of DENYLISTED_ENV) {
247
- expect(() => setChannelEnvVar(null, name, "x", dir)).toThrow(DenylistedEnvError);
248
- expect(() => setChannelEnvVar("c", name, "x", dir)).toThrow(DenylistedEnvError);
249
- }
250
- expect(existsSync(credentialsFilePath(dir))).toBe(false);
251
- });
252
-
253
- test("setter rejects a malformed name + an empty value", () => {
254
- expect(() => setChannelEnvVar(null, "9BAD", "x", dir)).toThrow(/invalid/);
255
- expect(() => setChannelEnvVar(null, "has space", "x", dir)).toThrow(/invalid/);
256
- expect(() => setChannelEnvVar(null, "GH_TOKEN", "", dir)).toThrow(/non-empty/);
257
- expect(existsSync(credentialsFilePath(dir))).toBe(false);
258
- });
259
-
260
- test("resolve defensively STRIPS a denylisted key planted by a hand-edited file", () => {
261
- // Plant a denylisted key directly on disk (bypassing the setter), then prove
262
- // resolveChannelEnv never returns it — the injection defense's first line.
263
- setChannelEnvVar(null, "GH_TOKEN", GH, dir);
264
- const fs = require("fs") as typeof import("fs");
265
- const file = JSON.parse(fs.readFileSync(credentialsFilePath(dir), "utf8")) as {
266
- env: { default: Record<string, string> };
267
- };
268
- file.env.default.ANTHROPIC_API_KEY = "sk-ant-SMUGGLED";
269
- fs.writeFileSync(credentialsFilePath(dir), JSON.stringify(file));
270
- const resolved = resolveChannelEnv("any", dir);
271
- expect(resolved.GH_TOKEN).toBe(GH);
272
- expect(resolved.ANTHROPIC_API_KEY).toBeUndefined();
273
- });
274
- });