switchroom 0.21.17 → 0.21.18

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.
@@ -21598,7 +21598,7 @@ function allocateAgentUid(name) {
21598
21598
  }
21599
21599
 
21600
21600
  // src/build-info.ts
21601
- var VERSION = "0.21.17";
21601
+ var VERSION = "0.21.18";
21602
21602
 
21603
21603
  // src/setup/hindsight-recall-passthrough.ts
21604
21604
  var HINDSIGHT_RECALL_TAG_WEIGHT_SEED = Object.freeze({ sidechain: 0.8 });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.21.17",
4
+ "version": "0.21.18",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -105876,10 +105876,10 @@ function startOutboxSweep(deps) {
105876
105876
  }
105877
105877
 
105878
105878
  // ../src/build-info.ts
105879
- var VERSION2 = "0.21.17";
105880
- var COMMIT_SHA = "ac61272c";
105881
- var COMMIT_DATE = "2026-08-17T16:42:58Z";
105882
- var LATEST_PR = 4768;
105879
+ var VERSION2 = "0.21.18";
105880
+ var COMMIT_SHA = "ae063418";
105881
+ var COMMIT_DATE = "2026-08-18T03:14:53Z";
105882
+ var LATEST_PR = 4772;
105883
105883
  var COMMITS_AHEAD_OF_TAG = 0;
105884
105884
 
105885
105885
  // gateway/boot-version.ts
@@ -82,6 +82,12 @@ else
82
82
  uat/load-env.test.ts
83
83
  uat/uat-driver.test.ts
84
84
  uat/runners/
85
+ # M3 directive-flip UAT — deterministic (no-model, no-network) gate half.
86
+ # Same rationale as uat/runners/: vitest excludes all of uat/**, and these
87
+ # import `vitest` (bun runs them via its vitest-compat shim) — so they run
88
+ # on the BUN side. Directory match (trailing slash) never widens into
89
+ # uat/scenarios/. Fenced by scripts/check-test-runner-coverage.mjs.
90
+ uat/flip/
85
91
  )
86
92
  fi
87
93
 
@@ -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
+ });
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Allowlist add/remove helper for the Memory v2 M3 directive-flip UAT.
3
+ *
4
+ * The flip UAT needs a throwaway probe user temporarily on an agent's DM
5
+ * allowlist so the (later) Tier-2 mtcute probe can talk to it, then removed
6
+ * again with zero residue — even if the runner crashes mid-flight. This module
7
+ * is that add/remove half, and NOTHING else: it does not run probes, flip
8
+ * agents, or touch bank state.
9
+ *
10
+ * The file it edits is `~/.switchroom/agents/<agent>/telegram/access.json` —
11
+ * the gateway's DM allowlist (`allowFrom: string[]`, see
12
+ * `telegram-plugin/gateway/access-store.ts`). Two invariants make it
13
+ * crash-safe:
14
+ *
15
+ * 1. WHOLE-OBJECT parse → modify → serialize, written atomically (temp file
16
+ * + `rename`), mode 0600. We never hand-edit a field in place, so a
17
+ * concurrent gateway reader never sees a half-written file and no sibling
18
+ * key (`groups`, `pending`, telegram feature flags) is dropped.
19
+ *
20
+ * 2. A sidecar RECEIPT (`access.json.uat-allowlist-receipt.json`) is written
21
+ * BEFORE `access.json` is mutated on `add`. The receipt records whether
22
+ * the id was already present (`preexisting`) and a +4h `expiresAt`. Revert
23
+ * is driven off the receipt, so:
24
+ * - `remove` reverts iff a receipt exists AND `preexisting` was false
25
+ * (an id the UAT itself added). If the id was already there before the
26
+ * UAT touched it, revert leaves it and just clears the receipt.
27
+ * - `remove --sweep` scans every agent dir and reverts any receipt it
28
+ * finds — the idempotent crash-recovery path. `--sweep --expired`
29
+ * restricts that to receipts past their `expiresAt`.
30
+ *
31
+ * Because the receipt is the source of truth for "did WE add this", the tool
32
+ * is safe to run repeatedly: a second `add` for an id already added is a no-op
33
+ * that leaves the original receipt (and its original `addedAt`) intact, and a
34
+ * `remove`/`sweep` with no receipt does nothing.
35
+ */
36
+
37
+ import {
38
+ mkdirSync,
39
+ readFileSync,
40
+ writeFileSync,
41
+ renameSync,
42
+ existsSync,
43
+ rmSync,
44
+ readdirSync,
45
+ } from "node:fs";
46
+ import { homedir } from "node:os";
47
+ import { join } from "node:path";
48
+
49
+ /** Default allowlist TTL: the probe window before a sweep should reclaim. */
50
+ export const DEFAULT_TTL_MS = 4 * 60 * 60 * 1000; // 4h
51
+
52
+ /** The sidecar receipt written next to access.json. */
53
+ export interface AllowlistReceipt {
54
+ agent: string;
55
+ userId: string;
56
+ /** ISO timestamp the receipt was written (the add moment). */
57
+ addedAt: string;
58
+ /** ISO timestamp after which a `--sweep --expired` will reclaim this id. */
59
+ expiresAt: string;
60
+ /** True when `userId` was ALREADY on the allowlist before the UAT ran — in
61
+ * that case revert must NOT remove it. */
62
+ preexisting: boolean;
63
+ }
64
+
65
+ /** Minimal shape of access.json we touch. All other keys pass through
66
+ * untouched via the parsed object (we serialize the whole thing). */
67
+ interface AccessFile {
68
+ allowFrom?: string[];
69
+ [k: string]: unknown;
70
+ }
71
+
72
+ export interface AllowlistDeps {
73
+ /** Root agents dir. Defaults to `~/.switchroom/agents`. Injected in tests. */
74
+ agentsDir?: string;
75
+ /** Clock seam for deterministic tests. */
76
+ now?: () => number;
77
+ /** TTL override (ms) for the receipt `expiresAt`. */
78
+ ttlMs?: number;
79
+ }
80
+
81
+ export interface AddResult {
82
+ agent: string;
83
+ userId: string;
84
+ /** True when this call actually appended the id. */
85
+ added: boolean;
86
+ /** True when the id was already on the allowlist before this call. */
87
+ preexisting: boolean;
88
+ receiptPath: string;
89
+ }
90
+
91
+ export interface RemoveResult {
92
+ agent: string;
93
+ userId: string | null;
94
+ /** True when the id was removed from the allowlist by this call. */
95
+ reverted: boolean;
96
+ /** Why nothing was reverted, when `reverted` is false. */
97
+ reason?: "no-receipt" | "preexisting" | "not-present";
98
+ receiptCleared: boolean;
99
+ }
100
+
101
+ function resolveAgentsDir(deps: AllowlistDeps): string {
102
+ return deps.agentsDir ?? join(homedir(), ".switchroom", "agents");
103
+ }
104
+
105
+ /** Absolute path to an agent's access.json. */
106
+ export function accessFilePath(agentsDir: string, agent: string): string {
107
+ return join(agentsDir, agent, "telegram", "access.json");
108
+ }
109
+
110
+ /** Absolute path to an agent's UAT allowlist receipt. */
111
+ export function receiptPath(agentsDir: string, agent: string): string {
112
+ return accessFilePath(agentsDir, agent) + ".uat-allowlist-receipt.json";
113
+ }
114
+
115
+ /** Read + parse access.json, or `{}` when absent. Throws on malformed JSON —
116
+ * the UAT must NOT silently clobber a file it can't parse. */
117
+ function readAccess(path: string): AccessFile {
118
+ if (!existsSync(path)) return {};
119
+ const raw = readFileSync(path, "utf8");
120
+ const parsed = JSON.parse(raw) as AccessFile;
121
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
122
+ throw new Error(`access.json at ${path} is not a JSON object`);
123
+ }
124
+ return parsed;
125
+ }
126
+
127
+ /** Atomic whole-object write: temp file + rename, mode 0600, dir 0700. */
128
+ function atomicWriteJson(path: string, value: unknown): void {
129
+ const dir = join(path, "..");
130
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
131
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
132
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
133
+ renameSync(tmp, path);
134
+ }
135
+
136
+ function readReceipt(path: string): AllowlistReceipt | null {
137
+ if (!existsSync(path)) return null;
138
+ try {
139
+ return JSON.parse(readFileSync(path, "utf8")) as AllowlistReceipt;
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Add `userId` to `<agent>`'s DM allowlist, writing the receipt FIRST.
147
+ *
148
+ * Crash-safety ordering: the receipt lands before access.json is mutated, so a
149
+ * crash between the two leaves a receipt whose `preexisting` correctly records
150
+ * the pre-mutation state — a later sweep reverts exactly what it should.
151
+ *
152
+ * Idempotent: if a receipt already exists for this agent (a prior add), this is
153
+ * a no-op that preserves the original receipt (and its `preexisting`/`addedAt`),
154
+ * so a double-add can't overwrite the truth about whether WE added the id.
155
+ */
156
+ export function addToAllowlist(agent: string, userId: string, deps: AllowlistDeps = {}): AddResult {
157
+ const agentsDir = resolveAgentsDir(deps);
158
+ const nowMs = (deps.now ?? Date.now)();
159
+ const ttlMs = deps.ttlMs ?? DEFAULT_TTL_MS;
160
+ const accessPath = accessFilePath(agentsDir, agent);
161
+ const rcptPath = receiptPath(agentsDir, agent);
162
+
163
+ const access = readAccess(accessPath);
164
+ const allowFrom = Array.isArray(access.allowFrom) ? access.allowFrom.map(String) : [];
165
+ const preexisting = allowFrom.includes(userId);
166
+
167
+ const existingReceipt = readReceipt(rcptPath);
168
+ if (existingReceipt) {
169
+ // A prior add already recorded the truth — do not re-mutate or re-stamp.
170
+ return {
171
+ agent,
172
+ userId,
173
+ added: false,
174
+ preexisting: existingReceipt.preexisting,
175
+ receiptPath: rcptPath,
176
+ };
177
+ }
178
+
179
+ // Receipt FIRST, before any mutation — the crash-safety invariant.
180
+ const receipt: AllowlistReceipt = {
181
+ agent,
182
+ userId,
183
+ addedAt: new Date(nowMs).toISOString(),
184
+ expiresAt: new Date(nowMs + ttlMs).toISOString(),
185
+ preexisting,
186
+ };
187
+ atomicWriteJson(rcptPath, receipt);
188
+
189
+ if (preexisting) {
190
+ // Already allowlisted — nothing to add, receipt records preexisting:true.
191
+ return { agent, userId, added: false, preexisting: true, receiptPath: rcptPath };
192
+ }
193
+
194
+ access.allowFrom = [...allowFrom, userId];
195
+ atomicWriteJson(accessPath, access);
196
+ return { agent, userId, added: true, preexisting: false, receiptPath: rcptPath };
197
+ }
198
+
199
+ /**
200
+ * Revert an agent's allowlist add. Only removes the id when a receipt exists
201
+ * AND `preexisting` was false; always clears the receipt when one exists.
202
+ */
203
+ export function removeFromAllowlist(agent: string, deps: AllowlistDeps = {}): RemoveResult {
204
+ const agentsDir = resolveAgentsDir(deps);
205
+ const accessPath = accessFilePath(agentsDir, agent);
206
+ const rcptPath = receiptPath(agentsDir, agent);
207
+
208
+ const receipt = readReceipt(rcptPath);
209
+ if (!receipt) {
210
+ return { agent, userId: null, reverted: false, reason: "no-receipt", receiptCleared: false };
211
+ }
212
+
213
+ // Clear the receipt regardless of the branch below — it is single-use.
214
+ const clearReceipt = () => {
215
+ rmSync(rcptPath, { force: true });
216
+ };
217
+
218
+ if (receipt.preexisting) {
219
+ // The id was there before the UAT — leave the allowlist, drop the receipt.
220
+ clearReceipt();
221
+ return {
222
+ agent,
223
+ userId: receipt.userId,
224
+ reverted: false,
225
+ reason: "preexisting",
226
+ receiptCleared: true,
227
+ };
228
+ }
229
+
230
+ const access = readAccess(accessPath);
231
+ const allowFrom = Array.isArray(access.allowFrom) ? access.allowFrom.map(String) : [];
232
+ if (!allowFrom.includes(receipt.userId)) {
233
+ // Already gone (idempotent re-run) — just clear the receipt.
234
+ clearReceipt();
235
+ return {
236
+ agent,
237
+ userId: receipt.userId,
238
+ reverted: false,
239
+ reason: "not-present",
240
+ receiptCleared: true,
241
+ };
242
+ }
243
+
244
+ access.allowFrom = allowFrom.filter((id) => id !== receipt.userId);
245
+ atomicWriteJson(accessPath, access);
246
+ clearReceipt();
247
+ return { agent, userId: receipt.userId, reverted: true, receiptCleared: true };
248
+ }
249
+
250
+ export interface SweepOptions extends AllowlistDeps {
251
+ /** When true, only revert receipts whose `expiresAt` is in the past. */
252
+ expiredOnly?: boolean;
253
+ }
254
+
255
+ /**
256
+ * Scan every agent dir under `agentsDir` and revert any UAT allowlist receipt.
257
+ * Idempotent — safe to run on a clean fleet (returns an empty result set). With
258
+ * `expiredOnly`, only receipts past their `expiresAt` are reverted.
259
+ */
260
+ export function sweepAllowlist(opts: SweepOptions = {}): RemoveResult[] {
261
+ const agentsDir = resolveAgentsDir(opts);
262
+ const nowMs = (opts.now ?? Date.now)();
263
+ const results: RemoveResult[] = [];
264
+ if (!existsSync(agentsDir)) return results;
265
+
266
+ for (const entry of readdirSync(agentsDir, { withFileTypes: true })) {
267
+ if (!entry.isDirectory()) continue;
268
+ const agent = entry.name;
269
+ const rcptPath = receiptPath(agentsDir, agent);
270
+ const receipt = readReceipt(rcptPath);
271
+ if (!receipt) continue;
272
+ if (opts.expiredOnly) {
273
+ const expiresMs = Date.parse(receipt.expiresAt);
274
+ if (!Number.isNaN(expiresMs) && expiresMs > nowMs) continue; // not yet expired
275
+ }
276
+ results.push(removeFromAllowlist(agent, opts));
277
+ }
278
+ return results;
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // CLI
283
+ // ---------------------------------------------------------------------------
284
+
285
+ function usage(): string {
286
+ return [
287
+ "usage: allowlist <command> [args]",
288
+ "",
289
+ " add <agent> <userId> add userId to <agent>'s DM allowlist (writes receipt first)",
290
+ " remove <agent> revert the receipted add for <agent>",
291
+ " remove --sweep revert every agent's receipt (idempotent)",
292
+ " remove --sweep --expired revert only receipts past their expiresAt",
293
+ "",
294
+ " --agents-dir <path> override the agents root (default ~/.switchroom/agents)",
295
+ ].join("\n");
296
+ }
297
+
298
+ /** Parse argv (sans node/script) and dispatch. Returns process exit code.
299
+ * Exported for direct unit testing of the arg surface. */
300
+ export function runAllowlistCli(argv: string[]): number {
301
+ const args = [...argv];
302
+ let agentsDir: string | undefined;
303
+ const dirIdx = args.indexOf("--agents-dir");
304
+ if (dirIdx !== -1) {
305
+ agentsDir = args[dirIdx + 1];
306
+ args.splice(dirIdx, 2);
307
+ }
308
+ const deps: AllowlistDeps = agentsDir ? { agentsDir } : {};
309
+
310
+ const cmd = args[0];
311
+ if (cmd === "add") {
312
+ const [, agent, userId] = args;
313
+ if (!agent || !userId) {
314
+ process.stderr.write("add requires <agent> <userId>\n" + usage() + "\n");
315
+ return 1;
316
+ }
317
+ const r = addToAllowlist(agent, userId, deps);
318
+ process.stdout.write(JSON.stringify(r) + "\n");
319
+ return 0;
320
+ }
321
+ if (cmd === "remove") {
322
+ if (args.includes("--sweep")) {
323
+ const expiredOnly = args.includes("--expired");
324
+ const rs = sweepAllowlist({ ...deps, expiredOnly });
325
+ process.stdout.write(JSON.stringify(rs) + "\n");
326
+ return 0;
327
+ }
328
+ const agent = args[1];
329
+ if (!agent) {
330
+ process.stderr.write("remove requires <agent> (or --sweep)\n" + usage() + "\n");
331
+ return 1;
332
+ }
333
+ const r = removeFromAllowlist(agent, deps);
334
+ process.stdout.write(JSON.stringify(r) + "\n");
335
+ return 0;
336
+ }
337
+
338
+ process.stderr.write(usage() + "\n");
339
+ return cmd ? 1 : 0;
340
+ }
341
+
342
+ // Direct-exec entry (bun/node run this file). Guarded so imports don't run it.
343
+ if (
344
+ typeof process !== "undefined" &&
345
+ process.argv[1] &&
346
+ (process.argv[1].endsWith("allowlist.ts") || process.argv[1].endsWith("allowlist.js"))
347
+ ) {
348
+ process.exit(runAllowlistCli(process.argv.slice(2)));
349
+ }