@openparachute/agent 0.2.2 → 0.2.3-rc.3
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.
- package/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/transports/vault.ts +40 -22
- package/web/ui/dist/assets/index-5KEwEhfi.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- 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
|
-
});
|
package/src/credentials.test.ts
DELETED
|
@@ -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
|
-
});
|