switchroom 0.21.17 → 0.21.19
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/dist/cli/switchroom.js +1884 -1512
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +6 -1
- package/telegram-plugin/connection-drop.ts +83 -0
- package/telegram-plugin/dist/bridge/bridge.js +41 -2
- package/telegram-plugin/dist/gateway/gateway.js +48 -6
- package/telegram-plugin/dist/server.js +46 -3
- package/telegram-plugin/llm-error-present.ts +25 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +6 -0
- package/telegram-plugin/session-tail.ts +38 -1
- package/telegram-plugin/tests/llm-error-present.test.ts +183 -0
- package/telegram-plugin/uat/flip/allowlist.test.ts +229 -0
- package/telegram-plugin/uat/flip/allowlist.ts +349 -0
- package/telegram-plugin/uat/flip/gate.test.ts +153 -0
- package/telegram-plugin/uat/flip/gate.ts +232 -0
- package/telegram-plugin/uat/flip/probe-scoring.test.ts +210 -0
- package/telegram-plugin/uat/flip/probe-scoring.ts +200 -0
- package/telegram-plugin/uat/flip/probe-suite.test.ts +95 -0
- package/telegram-plugin/uat/flip/probe-suite.ts +155 -0
- package/telegram-plugin/uat/flip/probes/kdogg.probes.json +36 -0
- package/telegram-plugin/uat/flip/probes/test-harness.probes.json +15 -0
- package/telegram-plugin/uat/flip/recall-log.test.ts +131 -0
- package/telegram-plugin/uat/flip/recall-log.ts +178 -0
- package/telegram-plugin/uat/flip/report.ts +95 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.test.ts +470 -0
- package/telegram-plugin/uat/flip/tier1-equivalence.ts +697 -0
- package/telegram-plugin/uat/flip/tier2-probe-runner.ts +327 -0
- package/telegram-plugin/uat/runners/scorer.ts +1 -1
- package/vendor/hindsight-memory/scripts/prefetch.py +33 -5
- package/vendor/hindsight-memory/scripts/recall.py +131 -17
- package/vendor/hindsight-memory/scripts/tests/test_prefetch_pipeline.py +97 -6
- package/vendor/hindsight-memory/scripts/tests/test_recall_buffer_join.py +70 -0
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from '../llm-error-present.js'
|
|
28
28
|
import { truncateDetailPreservingRequestId } from '../raw-error-scrub.js'
|
|
29
29
|
import { projectTranscriptLine, detectErrorInTranscriptLine } from '../session-tail.js'
|
|
30
|
+
import { isConnectionDropText } from '../connection-drop.js'
|
|
30
31
|
import { renderOperatorEvent, type OperatorEvent } from '../operator-events.js'
|
|
31
32
|
import { redact } from '../secret-detect/redact.js'
|
|
32
33
|
|
|
@@ -137,6 +138,188 @@ describe('parseLlmError — classification table', () => {
|
|
|
137
138
|
})
|
|
138
139
|
})
|
|
139
140
|
|
|
141
|
+
// ─── Connection-drop classification (PR 0: unify drop classification) ─────────
|
|
142
|
+
//
|
|
143
|
+
// The two classifiers historically disagreed about a mid-stream connection /
|
|
144
|
+
// SSE drop: Path A (`parseLlmError`) mapped it to a transient/network error,
|
|
145
|
+
// while Path B (`detectErrorInTranscriptLine`) let a drop-worded line whose
|
|
146
|
+
// wording `classifyClaudeError` did not recognise fall through to a generic
|
|
147
|
+
// `unknown-*` terminal. These blocks assert the SAME line is now identifiable
|
|
148
|
+
// as a connection drop on BOTH paths — and, critically, that auth / quota /
|
|
149
|
+
// overload / provider-credit walls are NEVER flagged as drops on either path
|
|
150
|
+
// (the greedy-matcher failure mode that would later cause a wrong auto-resume).
|
|
151
|
+
|
|
152
|
+
const OPENROUTER_402_CREDIT =
|
|
153
|
+
'litellm.APIError: OpenrouterException - {"error":{"code":402,"message":"Your account or API key has insufficient credits. Add more credits and retry the request.","metadata":{"provider_name":"openrouter"}}}'
|
|
154
|
+
|
|
155
|
+
/** Build the v2.1.x `isApiErrorMessage` synthetic-assistant transcript line. */
|
|
156
|
+
function apiErrorLine(text: string, opts: { error?: string; status?: number } = {}): string {
|
|
157
|
+
return JSON.stringify({
|
|
158
|
+
type: 'assistant',
|
|
159
|
+
message: { role: 'assistant', model: '<synthetic>', content: [{ type: 'text', text }] },
|
|
160
|
+
isApiErrorMessage: true,
|
|
161
|
+
...(opts.error != null ? { error: opts.error } : { error: '' }),
|
|
162
|
+
...(opts.status != null ? { apiErrorStatus: opts.status } : {}),
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
describe('connection-drop — identifiable on BOTH classification paths', () => {
|
|
167
|
+
// The canonical mid-stream abort shape: isApiErrorMessage + error:"server_error",
|
|
168
|
+
// NO status. Path A sees the worded string; Path B sees the transcript line.
|
|
169
|
+
it('a "Connection closed mid-response" line is a connection-drop on Path A AND Path B', () => {
|
|
170
|
+
const wording = 'API Error: Connection closed mid-response'
|
|
171
|
+
|
|
172
|
+
// Path A
|
|
173
|
+
const a = parseLlmError(wording)
|
|
174
|
+
expect(a.connectionDrop).toBe(true)
|
|
175
|
+
expect(a.kind).toBe('transient')
|
|
176
|
+
expect(a.source).toBe('network')
|
|
177
|
+
|
|
178
|
+
// Path B
|
|
179
|
+
const b = detectErrorInTranscriptLine(apiErrorLine(wording, { error: 'server_error' }))
|
|
180
|
+
expect(b).not.toBeNull()
|
|
181
|
+
expect(b!.connectionDrop).toBe(true)
|
|
182
|
+
expect(b!.kind).toBe('transport-transient')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
// The exact Path-B bug: a drop-worded line whose wording `classifyClaudeError`
|
|
186
|
+
// does NOT recognise (no server_error type, no status) fell through to
|
|
187
|
+
// unknown-5xx. It must STILL be flagged a connection-drop.
|
|
188
|
+
it('a "socket hang up" line unknown to classifyClaudeError is still a connection-drop on both paths', () => {
|
|
189
|
+
const wording = 'API Error: socket hang up'
|
|
190
|
+
|
|
191
|
+
const a = parseLlmError(wording)
|
|
192
|
+
expect(a.connectionDrop).toBe(true)
|
|
193
|
+
|
|
194
|
+
const b = detectErrorInTranscriptLine(apiErrorLine(wording))
|
|
195
|
+
expect(b).not.toBeNull()
|
|
196
|
+
expect(b!.kind).toBe('unknown-5xx') // classifyClaudeError does not recognise the wording…
|
|
197
|
+
expect(b!.connectionDrop).toBe(true) // …yet the drop is identified anyway.
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// The wrapper-error line shape (`type:"error"`, nested error object).
|
|
201
|
+
it('a server_error wrapper line carrying a drop wording is a connection-drop on Path B', () => {
|
|
202
|
+
const line = JSON.stringify({
|
|
203
|
+
type: 'error',
|
|
204
|
+
error: { type: 'server_error', message: 'Connection closed mid-response' },
|
|
205
|
+
})
|
|
206
|
+
const b = detectErrorInTranscriptLine(line)
|
|
207
|
+
expect(b).not.toBeNull()
|
|
208
|
+
expect(b!.kind).toBe('transport-transient')
|
|
209
|
+
expect(b!.connectionDrop).toBe(true)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// EPIPE is a genuine drop wording that `detectModelUnavailable` does not
|
|
213
|
+
// classify as network — Path A lands it on `unknown`, but it must still be a
|
|
214
|
+
// connection-drop (proving the discriminator is NOT tied to source:'network').
|
|
215
|
+
it('EPIPE is a connection-drop on Path A even though its kind stays unknown', () => {
|
|
216
|
+
const a = parseLlmError('write EPIPE: half-dead socket')
|
|
217
|
+
expect(a.kind).toBe('unknown')
|
|
218
|
+
expect(a.connectionDrop).toBe(true)
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
describe('connection-drop — negatives (auth / quota / overload / provider-credit are NEVER drops)', () => {
|
|
223
|
+
it('auth is not a connection-drop on either path', () => {
|
|
224
|
+
const a = parseLlmError('authentication_error: OAuth token expired, please refresh')
|
|
225
|
+
expect(a.kind).toBe('auth')
|
|
226
|
+
expect(a.connectionDrop).toBe(false)
|
|
227
|
+
|
|
228
|
+
const b = detectErrorInTranscriptLine(
|
|
229
|
+
apiErrorLine('authentication_error: OAuth token expired', { error: 'authentication_error', status: 401 }),
|
|
230
|
+
)
|
|
231
|
+
expect(b).not.toBeNull()
|
|
232
|
+
expect(b!.connectionDrop).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('quota_wall is not a connection-drop on either path', () => {
|
|
236
|
+
const a = parseLlmError("You've hit your limit · resets 5pm")
|
|
237
|
+
expect(a.kind).toBe('quota_wall')
|
|
238
|
+
expect(a.connectionDrop).toBe(false)
|
|
239
|
+
|
|
240
|
+
const b = detectErrorInTranscriptLine(
|
|
241
|
+
apiErrorLine("You've hit your limit · resets 5pm", { error: 'rate_limit_error', status: 429 }),
|
|
242
|
+
)
|
|
243
|
+
expect(b).not.toBeNull()
|
|
244
|
+
expect(b!.kind).toBe('quota-exhausted')
|
|
245
|
+
expect(b!.connectionDrop).toBe(false)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('overload_529 is not a connection-drop on either path', () => {
|
|
249
|
+
const a = parseLlmError('Overloaded (overloaded_error) — HTTP 529')
|
|
250
|
+
expect(a.kind).toBe('overload_529')
|
|
251
|
+
expect(a.connectionDrop).toBe(false)
|
|
252
|
+
|
|
253
|
+
const b = detectErrorInTranscriptLine(apiErrorLine('Overloaded', { error: 'overloaded_error' }))
|
|
254
|
+
expect(b).not.toBeNull()
|
|
255
|
+
expect(b!.connectionDrop).toBe(false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('provider_credit is not a connection-drop on either path', () => {
|
|
259
|
+
const a = parseLlmError(OPENROUTER_402_CREDIT)
|
|
260
|
+
expect(a.kind).toBe('provider_credit')
|
|
261
|
+
expect(a.connectionDrop).toBe(false)
|
|
262
|
+
|
|
263
|
+
const b = detectErrorInTranscriptLine(apiErrorLine(OPENROUTER_402_CREDIT, { status: 402 }))
|
|
264
|
+
expect(b).not.toBeNull()
|
|
265
|
+
expect(b!.kind).toBe('provider-credit-exhausted')
|
|
266
|
+
expect(b!.connectionDrop).toBe(false)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// The single highest-risk case: a wrapped auth/quota wall whose OUTER text
|
|
270
|
+
// coincidentally carries a drop wording ("fetch failed") must NOT be flagged a
|
|
271
|
+
// drop — the classification wins over the wording, on both paths.
|
|
272
|
+
it('a wrapped auth error whose outer text says "fetch failed" is NOT a connection-drop', () => {
|
|
273
|
+
const a = parseLlmError('authentication_error: invalid api key (underlying: fetch failed)')
|
|
274
|
+
expect(a.kind).toBe('auth')
|
|
275
|
+
expect(a.connectionDrop).toBe(false)
|
|
276
|
+
|
|
277
|
+
const b = detectErrorInTranscriptLine(
|
|
278
|
+
JSON.stringify({
|
|
279
|
+
type: 'error',
|
|
280
|
+
error: { type: 'authentication_error', message: 'invalid api key; underlying: fetch failed' },
|
|
281
|
+
}),
|
|
282
|
+
)
|
|
283
|
+
expect(b).not.toBeNull()
|
|
284
|
+
expect(b!.kind).toBe('credentials-invalid')
|
|
285
|
+
expect(b!.connectionDrop).toBe(false)
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe('isConnectionDropText — the canonical matcher is conservative', () => {
|
|
290
|
+
it('matches the canonical drop wordings', () => {
|
|
291
|
+
for (const s of [
|
|
292
|
+
'socket hang up',
|
|
293
|
+
'read ECONNRESET',
|
|
294
|
+
'connect ECONNREFUSED 1.2.3.4:443',
|
|
295
|
+
'write EPIPE',
|
|
296
|
+
'fetch failed',
|
|
297
|
+
'network error',
|
|
298
|
+
'Connection closed mid-response',
|
|
299
|
+
'connection lost',
|
|
300
|
+
'Premature close',
|
|
301
|
+
'the stream disconnected unexpectedly',
|
|
302
|
+
]) {
|
|
303
|
+
expect(isConnectionDropText(s)).toBe(true)
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('does NOT over-match "upstream"/"downstream"/"terminated" or auth/quota wording', () => {
|
|
308
|
+
for (const s of [
|
|
309
|
+
'the upstream provider returned a result',
|
|
310
|
+
'downstream consumer finished',
|
|
311
|
+
'the worker terminated cleanly',
|
|
312
|
+
'authentication_error: invalid api key',
|
|
313
|
+
"You've hit your usage limit",
|
|
314
|
+
'overloaded_error',
|
|
315
|
+
'insufficient credits',
|
|
316
|
+
'',
|
|
317
|
+
]) {
|
|
318
|
+
expect(isConnectionDropText(s)).toBe(false)
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
140
323
|
// ─── local-time render ───────────────────────────────────────────────────────
|
|
141
324
|
|
|
142
325
|
describe('renderLlmError / formatResetLocal — local-time rendering', () => {
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit suite for the UAT allowlist add/remove helper. Runs under `bun test`
|
|
3
|
+
* (this tree is vitest-excluded) via the `uat/flip/` entry in
|
|
4
|
+
* telegram-plugin/scripts/bun-test-ci.sh.
|
|
5
|
+
*
|
|
6
|
+
* Everything is hermetic: a tmp `agentsDir` is built per-test and torn down,
|
|
7
|
+
* and the clock is injected so `expiresAt`/`--expired` are deterministic. No
|
|
8
|
+
* real ~/.switchroom is touched.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import {
|
|
23
|
+
addToAllowlist,
|
|
24
|
+
removeFromAllowlist,
|
|
25
|
+
sweepAllowlist,
|
|
26
|
+
runAllowlistCli,
|
|
27
|
+
accessFilePath,
|
|
28
|
+
receiptPath,
|
|
29
|
+
DEFAULT_TTL_MS,
|
|
30
|
+
type AllowlistReceipt,
|
|
31
|
+
} from "./allowlist.js";
|
|
32
|
+
|
|
33
|
+
let agentsDir: string;
|
|
34
|
+
let root: string;
|
|
35
|
+
|
|
36
|
+
function seedAccess(agent: string, obj: unknown): void {
|
|
37
|
+
const p = accessFilePath(agentsDir, agent);
|
|
38
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
39
|
+
writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readAccess(agent: string): Record<string, unknown> {
|
|
43
|
+
return JSON.parse(readFileSync(accessFilePath(agentsDir, agent), "utf8"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readReceipt(agent: string): AllowlistReceipt {
|
|
47
|
+
return JSON.parse(readFileSync(receiptPath(agentsDir, agent), "utf8"));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function mkAgentDir(agent: string): void {
|
|
51
|
+
mkdirSync(join(agentsDir, agent, "telegram"), { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
root = mkdtempSync(join(tmpdir(), "sr-uat-allowlist-"));
|
|
56
|
+
agentsDir = join(root, "agents");
|
|
57
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
rmSync(root, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("addToAllowlist", () => {
|
|
65
|
+
it("appends the id, preserves sibling keys, and writes a receipt first", () => {
|
|
66
|
+
seedAccess("ziggy", {
|
|
67
|
+
dmPolicy: "allowlist",
|
|
68
|
+
allowFrom: ["111"],
|
|
69
|
+
groups: { "-100": { allowFrom: ["222"] } },
|
|
70
|
+
voice_in: true,
|
|
71
|
+
});
|
|
72
|
+
const now = Date.parse("2026-08-18T00:00:00.000Z");
|
|
73
|
+
const r = addToAllowlist("ziggy", "999", { agentsDir, now: () => now });
|
|
74
|
+
|
|
75
|
+
expect(r.added).toBe(true);
|
|
76
|
+
expect(r.preexisting).toBe(false);
|
|
77
|
+
|
|
78
|
+
const access = readAccess("ziggy");
|
|
79
|
+
expect(access.allowFrom).toEqual(["111", "999"]);
|
|
80
|
+
// sibling keys survive the whole-object rewrite.
|
|
81
|
+
expect(access.groups).toEqual({ "-100": { allowFrom: ["222"] } });
|
|
82
|
+
expect(access.voice_in).toBe(true);
|
|
83
|
+
expect(access.dmPolicy).toBe("allowlist");
|
|
84
|
+
|
|
85
|
+
const rcpt = readReceipt("ziggy");
|
|
86
|
+
expect(rcpt).toMatchObject({ agent: "ziggy", userId: "999", preexisting: false });
|
|
87
|
+
expect(rcpt.addedAt).toBe("2026-08-18T00:00:00.000Z");
|
|
88
|
+
expect(Date.parse(rcpt.expiresAt) - now).toBe(DEFAULT_TTL_MS);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("creates access.json when none exists yet", () => {
|
|
92
|
+
mkAgentDir("newbie");
|
|
93
|
+
const r = addToAllowlist("newbie", "42", { agentsDir });
|
|
94
|
+
expect(r.added).toBe(true);
|
|
95
|
+
expect(readAccess("newbie").allowFrom).toEqual(["42"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("is a no-op with preexisting:true when the id is already allowlisted", () => {
|
|
99
|
+
seedAccess("ziggy", { allowFrom: ["999"] });
|
|
100
|
+
const r = addToAllowlist("ziggy", "999", { agentsDir });
|
|
101
|
+
expect(r.added).toBe(false);
|
|
102
|
+
expect(r.preexisting).toBe(true);
|
|
103
|
+
// access.json unchanged; receipt records preexisting so revert won't strip it.
|
|
104
|
+
expect(readAccess("ziggy").allowFrom).toEqual(["999"]);
|
|
105
|
+
expect(readReceipt("ziggy").preexisting).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("a second add is a no-op preserving the original receipt", () => {
|
|
109
|
+
seedAccess("ziggy", { allowFrom: [] });
|
|
110
|
+
const first = addToAllowlist("ziggy", "999", { agentsDir, now: () => 1000 });
|
|
111
|
+
const second = addToAllowlist("ziggy", "999", { agentsDir, now: () => 5000 });
|
|
112
|
+
expect(first.added).toBe(true);
|
|
113
|
+
expect(second.added).toBe(false);
|
|
114
|
+
// original addedAt preserved (not re-stamped to 5000).
|
|
115
|
+
expect(readReceipt("ziggy").addedAt).toBe(new Date(1000).toISOString());
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("removeFromAllowlist", () => {
|
|
120
|
+
it("reverts an id the UAT added and clears the receipt", () => {
|
|
121
|
+
seedAccess("ziggy", { allowFrom: ["111"] });
|
|
122
|
+
addToAllowlist("ziggy", "999", { agentsDir });
|
|
123
|
+
expect(readAccess("ziggy").allowFrom).toEqual(["111", "999"]);
|
|
124
|
+
|
|
125
|
+
const r = removeFromAllowlist("ziggy", { agentsDir });
|
|
126
|
+
expect(r.reverted).toBe(true);
|
|
127
|
+
expect(r.receiptCleared).toBe(true);
|
|
128
|
+
expect(readAccess("ziggy").allowFrom).toEqual(["111"]);
|
|
129
|
+
expect(existsSync(receiptPath(agentsDir, "ziggy"))).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does NOT strip a preexisting id, but clears the receipt", () => {
|
|
133
|
+
seedAccess("ziggy", { allowFrom: ["999"] });
|
|
134
|
+
addToAllowlist("ziggy", "999", { agentsDir }); // preexisting
|
|
135
|
+
const r = removeFromAllowlist("ziggy", { agentsDir });
|
|
136
|
+
expect(r.reverted).toBe(false);
|
|
137
|
+
expect(r.reason).toBe("preexisting");
|
|
138
|
+
expect(r.receiptCleared).toBe(true);
|
|
139
|
+
expect(readAccess("ziggy").allowFrom).toEqual(["999"]);
|
|
140
|
+
expect(existsSync(receiptPath(agentsDir, "ziggy"))).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("no-ops with no-receipt when there is nothing to revert", () => {
|
|
144
|
+
seedAccess("ziggy", { allowFrom: ["111"] });
|
|
145
|
+
const r = removeFromAllowlist("ziggy", { agentsDir });
|
|
146
|
+
expect(r.reverted).toBe(false);
|
|
147
|
+
expect(r.reason).toBe("no-receipt");
|
|
148
|
+
expect(readAccess("ziggy").allowFrom).toEqual(["111"]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("is idempotent — a second remove after revert is a no-op", () => {
|
|
152
|
+
seedAccess("ziggy", { allowFrom: [] });
|
|
153
|
+
addToAllowlist("ziggy", "999", { agentsDir });
|
|
154
|
+
removeFromAllowlist("ziggy", { agentsDir });
|
|
155
|
+
const second = removeFromAllowlist("ziggy", { agentsDir });
|
|
156
|
+
expect(second.reverted).toBe(false);
|
|
157
|
+
expect(second.reason).toBe("no-receipt");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("handles a receipt whose id was already manually removed", () => {
|
|
161
|
+
seedAccess("ziggy", { allowFrom: [] });
|
|
162
|
+
addToAllowlist("ziggy", "999", { agentsDir });
|
|
163
|
+
// someone else stripped the id out of band.
|
|
164
|
+
seedAccess("ziggy", { allowFrom: [] });
|
|
165
|
+
const r = removeFromAllowlist("ziggy", { agentsDir });
|
|
166
|
+
expect(r.reverted).toBe(false);
|
|
167
|
+
expect(r.reason).toBe("not-present");
|
|
168
|
+
expect(r.receiptCleared).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("sweepAllowlist", () => {
|
|
173
|
+
it("reverts every agent's receipt and is idempotent on a clean second pass", () => {
|
|
174
|
+
seedAccess("a", { allowFrom: [] });
|
|
175
|
+
seedAccess("b", { allowFrom: ["7"] });
|
|
176
|
+
addToAllowlist("a", "999", { agentsDir });
|
|
177
|
+
addToAllowlist("b", "888", { agentsDir });
|
|
178
|
+
|
|
179
|
+
const first = sweepAllowlist({ agentsDir });
|
|
180
|
+
expect(first.map((r) => r.agent).sort()).toEqual(["a", "b"]);
|
|
181
|
+
expect(first.every((r) => r.reverted)).toBe(true);
|
|
182
|
+
expect(readAccess("a").allowFrom).toEqual([]);
|
|
183
|
+
expect(readAccess("b").allowFrom).toEqual(["7"]);
|
|
184
|
+
|
|
185
|
+
const second = sweepAllowlist({ agentsDir });
|
|
186
|
+
expect(second).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("with expiredOnly, leaves un-expired receipts alone", () => {
|
|
190
|
+
seedAccess("a", { allowFrom: [] });
|
|
191
|
+
seedAccess("b", { allowFrom: [] });
|
|
192
|
+
const t0 = Date.parse("2026-08-18T00:00:00.000Z");
|
|
193
|
+
addToAllowlist("a", "111", { agentsDir, now: () => t0, ttlMs: 1000 });
|
|
194
|
+
addToAllowlist("b", "222", { agentsDir, now: () => t0, ttlMs: 60 * 60 * 1000 });
|
|
195
|
+
|
|
196
|
+
// 2s later: a's receipt (ttl 1s) is expired, b's (ttl 1h) is not.
|
|
197
|
+
const swept = sweepAllowlist({ agentsDir, expiredOnly: true, now: () => t0 + 2000 });
|
|
198
|
+
expect(swept.map((r) => r.agent)).toEqual(["a"]);
|
|
199
|
+
expect(readAccess("a").allowFrom).toEqual([]);
|
|
200
|
+
// b untouched.
|
|
201
|
+
expect(readAccess("b").allowFrom).toEqual(["222"]);
|
|
202
|
+
expect(existsSync(receiptPath(agentsDir, "b"))).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("returns empty on a missing agents dir", () => {
|
|
206
|
+
expect(sweepAllowlist({ agentsDir: join(root, "nope") })).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("runAllowlistCli", () => {
|
|
211
|
+
it("add then remove --sweep round-trips to zero residue", () => {
|
|
212
|
+
seedAccess("ziggy", { allowFrom: ["1"] });
|
|
213
|
+
expect(runAllowlistCli(["add", "ziggy", "999", "--agents-dir", agentsDir])).toBe(0);
|
|
214
|
+
expect(readAccess("ziggy").allowFrom).toEqual(["1", "999"]);
|
|
215
|
+
|
|
216
|
+
expect(runAllowlistCli(["remove", "--sweep", "--agents-dir", agentsDir])).toBe(0);
|
|
217
|
+
expect(readAccess("ziggy").allowFrom).toEqual(["1"]);
|
|
218
|
+
expect(existsSync(receiptPath(agentsDir, "ziggy"))).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("add without a userId is a usage error (exit 1)", () => {
|
|
222
|
+
expect(runAllowlistCli(["add", "ziggy", "--agents-dir", agentsDir])).toBe(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("unknown command is exit 1; bare invocation is exit 0", () => {
|
|
226
|
+
expect(runAllowlistCli(["frobnicate"])).toBe(1);
|
|
227
|
+
expect(runAllowlistCli([])).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
});
|