switchroom 0.8.1 → 0.11.0

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 (137) hide show
  1. package/README.md +54 -61
  2. package/bin/timezone-hook.sh +9 -7
  3. package/dist/agent-scheduler/index.js +285 -45
  4. package/dist/auth-broker/index.js +13932 -0
  5. package/dist/cli/drive-write-pretool.mjs +5418 -0
  6. package/dist/cli/switchroom.js +8890 -5560
  7. package/dist/host-control/main.js +582 -43
  8. package/dist/vault/approvals/kernel-server.js +276 -47
  9. package/dist/vault/broker/server.js +333 -69
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +6 -4
  16. package/profiles/_base/start.sh.hbs +3 -3
  17. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  18. package/profiles/default/CLAUDE.md +10 -0
  19. package/profiles/default/CLAUDE.md.hbs +16 -0
  20. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  21. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  22. package/skills/buildkite-api/SKILL.md +31 -8
  23. package/skills/buildkite-cli/SKILL.md +27 -9
  24. package/skills/buildkite-migration/SKILL.md +22 -9
  25. package/skills/buildkite-pipelines/SKILL.md +26 -9
  26. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  27. package/skills/buildkite-test-engine/SKILL.md +25 -8
  28. package/skills/docx/SKILL.md +1 -1
  29. package/skills/file-bug/SKILL.md +34 -6
  30. package/skills/humanizer/SKILL.md +15 -0
  31. package/skills/humanizer-calibrate/SKILL.md +7 -1
  32. package/skills/mcp-builder/SKILL.md +1 -1
  33. package/skills/pdf/SKILL.md +1 -1
  34. package/skills/pptx/SKILL.md +1 -1
  35. package/skills/skill-creator/SKILL.md +21 -1
  36. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  41. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  42. package/skills/switchroom-cli/SKILL.md +63 -64
  43. package/skills/switchroom-health/SKILL.md +23 -10
  44. package/skills/switchroom-install/SKILL.md +3 -3
  45. package/skills/switchroom-manage/SKILL.md +26 -19
  46. package/skills/switchroom-runtime/SKILL.md +67 -15
  47. package/skills/switchroom-status/SKILL.md +26 -1
  48. package/skills/telegram-test-harness/SKILL.md +3 -0
  49. package/skills/webapp-testing/SKILL.md +31 -1
  50. package/skills/xlsx/SKILL.md +1 -1
  51. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  52. package/telegram-plugin/admin-commands/index.ts +9 -5
  53. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  54. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  55. package/telegram-plugin/auto-fallback.ts +28 -301
  56. package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
  57. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  58. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  59. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  60. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  61. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  62. package/telegram-plugin/gateway/auth-command.ts +905 -0
  63. package/telegram-plugin/gateway/auth-line.ts +123 -0
  64. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  65. package/telegram-plugin/gateway/boot-card.ts +23 -37
  66. package/telegram-plugin/gateway/boot-probes.ts +9 -12
  67. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  68. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  69. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  70. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  71. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  72. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  73. package/telegram-plugin/gateway/gateway.ts +1156 -938
  74. package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
  75. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  76. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  77. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  78. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  79. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  80. package/telegram-plugin/model-unavailable.ts +28 -12
  81. package/telegram-plugin/permission-title.ts +56 -0
  82. package/telegram-plugin/quota-check.ts +19 -41
  83. package/telegram-plugin/scripts/build.mjs +0 -1
  84. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  85. package/telegram-plugin/silence-poke.ts +153 -1
  86. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  87. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  88. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  89. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  90. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  91. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  92. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  93. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  94. package/telegram-plugin/tests/boot-probes.test.ts +27 -22
  95. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  96. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  97. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  98. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  99. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  100. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  101. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  102. package/telegram-plugin/turn-flush-safety.ts +55 -1
  103. package/telegram-plugin/uat/SETUP.md +35 -1
  104. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  105. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  106. package/telegram-plugin/uat/runners/report.ts +150 -0
  107. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  108. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  109. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  110. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  111. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  112. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  113. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  114. package/telegram-plugin/auth-dashboard.ts +0 -1104
  115. package/telegram-plugin/auth-slot-parser.ts +0 -497
  116. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  117. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  118. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  119. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  120. package/telegram-plugin/foreman/foreman.ts +0 -1165
  121. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  122. package/telegram-plugin/foreman/setup-state.ts +0 -239
  123. package/telegram-plugin/foreman/state.ts +0 -203
  124. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  125. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  126. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  127. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  128. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  129. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  130. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  131. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  132. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  133. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  134. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  135. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  136. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  137. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Re-entry guard + dedup window for fleet-wide auto-fallback.
3
+ *
4
+ * Lifted out of gateway.ts so the dedup state is constructable per-test
5
+ * (gateway.ts module state was hard to reach from vitest — every test
6
+ * shared the same in-flight Promise + last-fired timestamp).
7
+ *
8
+ * Contract (the "honesty contract" from PR #1317 review):
9
+ *
10
+ * `wouldFire()` is the SYNCHRONOUS read the model-unavailable card
11
+ * uses to decide whether to advertise "Auto-failover in progress".
12
+ * It MUST agree with the dispatcher's actual behavior — otherwise
13
+ * the card lies (claims a swap is coming when the dispatcher will
14
+ * dedup-drop or bail).
15
+ *
16
+ * Three reasons `wouldFire()` returns false:
17
+ * 1. A swap is already in flight (collapse concurrent fires).
18
+ * 2. The post-trigger dedup window is still active (the user
19
+ * already saw a swap announcement; another one would oscillate).
20
+ * 3. The broker is unreachable — the dispatcher would just bail
21
+ * with `reason=no-broker-client`, leaving the card to lie.
22
+ * Optional: only checked when `brokerReachable` is supplied.
23
+ *
24
+ * `markFired()` is called ONLY on actual swaps (kind: 'switched').
25
+ * No-ops (no broker, no eligible target, idempotent skip) DO NOT
26
+ * arm the suppression window — otherwise a transient hiccup blocks
27
+ * the next 30s of legitimate fires.
28
+ */
29
+
30
+ export interface FleetFallbackGateOptions {
31
+ /** Suppression window in ms after a successful swap. */
32
+ dedupMs: number;
33
+ /** Time source (overridable in tests). */
34
+ nowFn?: () => number;
35
+ /**
36
+ * Synchronous probe of broker reachability. Optional. Returning false
37
+ * makes `wouldFire()` return false so the card stays honest about a
38
+ * fire that would otherwise bail in the dispatcher.
39
+ *
40
+ * Synchronous on purpose: `wouldFire()` runs on the card-render path
41
+ * and must not block. A connection-cached flag (e.g. a UDS reachability
42
+ * check populated by a background heartbeat) fits this shape.
43
+ */
44
+ brokerReachable?: () => boolean;
45
+ }
46
+
47
+ export interface FleetFallbackGate {
48
+ /** True iff a fresh fire would actually invoke the dispatcher. */
49
+ wouldFire(): boolean;
50
+ /** Run a fire-and-forget action under the gate. Collapses concurrent
51
+ * callers to the same in-flight Promise. The action's resolved value
52
+ * controls whether the dedup window arms (true = arm, false = skip).
53
+ * Caller-thrown errors are swallowed (logged via `onError`). */
54
+ fire(action: () => Promise<boolean>, onError?: (err: unknown) => void): Promise<void>;
55
+ /** Test seam — reset to fresh state. Production code should not call this. */
56
+ reset(): void;
57
+ /** Test/debug — current internal state. */
58
+ inspect(): { inFlight: boolean; lastFiredAtMs: number };
59
+ }
60
+
61
+ export function createFleetFallbackGate(opts: FleetFallbackGateOptions): FleetFallbackGate {
62
+ const nowFn = opts.nowFn ?? (() => Date.now());
63
+ let inFlight: Promise<void> | null = null;
64
+ // -Infinity = never fired. Concrete number = wall-clock ms of the
65
+ // last actual swap. Sentinel matters in tests (fake clocks at t=0
66
+ // would otherwise look like "just fired" and falsely arm dedup).
67
+ let lastFiredAtMs = Number.NEGATIVE_INFINITY;
68
+
69
+ function wouldFire(): boolean {
70
+ if (inFlight) return false;
71
+ if (nowFn() - lastFiredAtMs < opts.dedupMs) return false;
72
+ if (opts.brokerReachable && !opts.brokerReachable()) return false;
73
+ return true;
74
+ }
75
+
76
+ function fire(action: () => Promise<boolean>, onError?: (err: unknown) => void): Promise<void> {
77
+ if (inFlight) return inFlight;
78
+ if (nowFn() - lastFiredAtMs < opts.dedupMs) return Promise.resolve();
79
+ if (opts.brokerReachable && !opts.brokerReachable()) return Promise.resolve();
80
+
81
+ inFlight = (async () => {
82
+ try {
83
+ const didSwap = await action();
84
+ if (didSwap) lastFiredAtMs = nowFn();
85
+ } catch (err) {
86
+ if (onError) onError(err);
87
+ } finally {
88
+ inFlight = null;
89
+ }
90
+ })();
91
+ return inFlight;
92
+ }
93
+
94
+ return {
95
+ wouldFire,
96
+ fire,
97
+ reset() {
98
+ inFlight = null;
99
+ lastFiredAtMs = Number.NEGATIVE_INFINITY;
100
+ },
101
+ inspect() {
102
+ return { inFlight: inFlight !== null, lastFiredAtMs };
103
+ },
104
+ };
105
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tests for `buildGrantedKeyboard` — the post-tap inline-keyboard
3
+ * surfaced on a granted approval card (RFC E §4.3 — granted-card
4
+ * confirmations gain the [ 📖 Open in Drive ] deep-link button).
5
+ *
6
+ * Scope-driven and pure, so the test runs without mocking grammy's
7
+ * Context or the approval kernel. The full handler in
8
+ * `approval-callback.ts` glues this onto the consumed-scope payload
9
+ * the kernel returns; the routing decision lives entirely in this
10
+ * builder.
11
+ */
12
+
13
+ import { describe, expect, it } from "vitest";
14
+ import { InlineKeyboard } from "grammy";
15
+ import { buildGrantedKeyboard } from "./approval-callback.js";
16
+
17
+ /**
18
+ * Helper — pull the `[{text, url}]` rows out of a grammy InlineKeyboard
19
+ * so we can assert without poking into its internal shape too hard.
20
+ */
21
+ function rows(kb: InlineKeyboard): Array<Array<{ text: string; url?: string }>> {
22
+ return kb.inline_keyboard.map((row) =>
23
+ row.map((btn) => ({
24
+ text: btn.text,
25
+ ...("url" in btn ? { url: btn.url } : {}),
26
+ })),
27
+ );
28
+ }
29
+
30
+ describe("buildGrantedKeyboard — Drive scopes", () => {
31
+ it("emits Open-in-Drive for a single-doc grant", () => {
32
+ const kb = buildGrantedKeyboard("doc:gdrive:D1");
33
+ expect(kb).toBeDefined();
34
+ expect(rows(kb!)).toEqual([
35
+ [
36
+ {
37
+ text: "📖 Open in Drive",
38
+ url: "https://drive.google.com/file/d/D1/view",
39
+ },
40
+ ],
41
+ ]);
42
+ });
43
+
44
+ it("emits Open-in-Drive for a folder grant (canonical folder URL)", () => {
45
+ const kb = buildGrantedKeyboard("doc:gdrive:folder/F1/**");
46
+ expect(kb).toBeDefined();
47
+ expect(rows(kb!)).toEqual([
48
+ [
49
+ {
50
+ text: "📖 Open in Drive",
51
+ url: "https://drive.google.com/drive/folders/F1",
52
+ },
53
+ ],
54
+ ]);
55
+ });
56
+
57
+ it("emits Open-in-Drive for write-namespace grants on a single doc", () => {
58
+ const kb = buildGrantedKeyboard("doc:gdrive:write:D1");
59
+ expect(kb).toBeDefined();
60
+ expect(rows(kb!)).toEqual([
61
+ [
62
+ {
63
+ text: "📖 Open in Drive",
64
+ url: "https://drive.google.com/file/d/D1/view",
65
+ },
66
+ ],
67
+ ]);
68
+ });
69
+
70
+ it("emits Open-in-Drive for suggest-namespace folder grants", () => {
71
+ const kb = buildGrantedKeyboard("doc:gdrive:suggest:folder/F1/**");
72
+ expect(kb).toBeDefined();
73
+ expect(rows(kb!)).toEqual([
74
+ [
75
+ {
76
+ text: "📖 Open in Drive",
77
+ url: "https://drive.google.com/drive/folders/F1",
78
+ },
79
+ ],
80
+ ]);
81
+ });
82
+ });
83
+
84
+ describe("buildGrantedKeyboard — no button cases", () => {
85
+ it("returns undefined for the whole-Drive grant (no specific artifact)", () => {
86
+ expect(buildGrantedKeyboard("doc:gdrive:**")).toBeUndefined();
87
+ expect(buildGrantedKeyboard("doc:gdrive:suggest:**")).toBeUndefined();
88
+ expect(buildGrantedKeyboard("doc:gdrive:write:**")).toBeUndefined();
89
+ });
90
+
91
+ it("returns undefined for non-Drive scopes (secrets, system, vault)", () => {
92
+ expect(buildGrantedKeyboard("secret:OPENAI_API_KEY")).toBeUndefined();
93
+ expect(buildGrantedKeyboard("system:reconnect:gdrive")).toBeUndefined();
94
+ expect(buildGrantedKeyboard("vault:read:gdrive:klanker:refresh_token")).toBeUndefined();
95
+ });
96
+
97
+ it("returns undefined for unparseable Drive scopes (defense in depth)", () => {
98
+ // A folder id containing a slash slips past prefix matching but is
99
+ // rejected by parseDriveScope's id-charset check — the granted-card
100
+ // edit MUST NOT render a URL button derived from such a string.
101
+ expect(buildGrantedKeyboard("doc:gdrive:folder/abc/def/**")).toBeUndefined();
102
+ expect(buildGrantedKeyboard("doc:gdrive:write:abc?evil=1")).toBeUndefined();
103
+ });
104
+ });
@@ -21,13 +21,31 @@
21
21
  * approval_lookup (RFC §10) to discover the outcome and proceed.
22
22
  */
23
23
 
24
- import type { Context } from "grammy";
24
+ import { type Context, InlineKeyboard } from "grammy";
25
25
  import { parseApprovalCallback, ttlMsFromToken } from "./approval-card.js";
26
26
  import {
27
27
  approvalConsume,
28
28
  approvalRecord,
29
29
  } from "../../src/vault/approvals/client.js";
30
30
  import type { ApprovalDecisionMode } from "../../src/vault/approvals/schema.js";
31
+ import { scopeToOpenInDriveButton } from "../../src/drive/deep-links.js";
32
+
33
+ /**
34
+ * Build the post-tap keyboard for a granted decision. Today this is
35
+ * just the `[ 📖 Open in Drive ]` button when the granted scope names
36
+ * a specific Drive doc or folder (RFC E §4.3 — granted-card
37
+ * confirmations gain the deep-link). Returns `undefined` when no
38
+ * post-tap keyboard applies, which the gateway translates into
39
+ * `reply_markup: undefined` to strip the original action buttons.
40
+ *
41
+ * Pure / scope-driven — no kernel I/O — so it stays unit-testable
42
+ * without mocking grammy's Context.
43
+ */
44
+ export function buildGrantedKeyboard(scope: string): InlineKeyboard | undefined {
45
+ const btn = scopeToOpenInDriveButton(scope);
46
+ if (btn === null) return undefined;
47
+ return new InlineKeyboard().url(btn.text, btn.url);
48
+ }
31
49
 
32
50
  export async function handleApprovalCallback(
33
51
  ctx: Context,
@@ -109,7 +127,10 @@ export async function handleApprovalCallback(
109
127
  return;
110
128
  }
111
129
 
112
- // Edit the original card to its post-tap state and drop the keyboard.
130
+ // Edit the original card to its post-tap state. Drop the original
131
+ // action keyboard either way; on a successful grant for a Drive
132
+ // scope, surface `[ 📖 Open in Drive ]` so the user can jump
133
+ // straight from "agent has access" to the doc (RFC E §4.3).
113
134
  const icon = granted ? "✅" : "🚫";
114
135
  const newBody =
115
136
  `${icon} ${displayMode}` +
@@ -117,8 +138,15 @@ export async function handleApprovalCallback(
117
138
  ? ` · /approvals revoke <code>${decision_id}</code>`
118
139
  : "");
119
140
 
141
+ const postTapKeyboard = granted && consumed.scope
142
+ ? buildGrantedKeyboard(consumed.scope)
143
+ : undefined;
144
+
120
145
  try {
121
- await ctx.editMessageText(newBody, { parse_mode: "HTML", reply_markup: undefined });
146
+ await ctx.editMessageText(newBody, {
147
+ parse_mode: "HTML",
148
+ reply_markup: postTapKeyboard,
149
+ });
122
150
  } catch {
123
151
  // Best-effort: card may have been edited or deleted under us.
124
152
  }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * `/auth add <label>` Telegram chat flow (RFC H §4.3 add-account, §7.3).
3
+ *
4
+ * The headline use case: every account on the fleet is rate-limited,
5
+ * the LLM is unreachable, and the operator is on their phone. They
6
+ * need a deterministic — LLM-free — chat path to add a fresh Anthropic
7
+ * OAuth account. This module owns that flow end-to-end:
8
+ *
9
+ * 1. Operator sends `/auth add <label>`.
10
+ * 2. Gateway calls {@link startAccountAuthSession} → spawns
11
+ * `claude setup-token` against a scratch directory under
12
+ * `~/.switchroom/accounts/.in-progress/<label>-<rand>/`, captures
13
+ * the OAuth authorize URL, and tucks pending state into
14
+ * {@link pendingAuthAddFlows}.
15
+ * 3. Gateway replies to chat with the URL + paste instructions.
16
+ * 4. Operator opens URL, logs in, copies the browser code, pastes
17
+ * into chat. Gateway's `pendingReauthFlows`-style intercept
18
+ * catches the paste and calls {@link submitAccountAuthCode}.
19
+ * 5. Helper reads `<scratch>/.credentials.json` (the dotfile that
20
+ * `claude setup-token` writes on success — pinned in
21
+ * `src/auth/broker/server-add-account.test.ts`), builds the
22
+ * {@link AddAccountCredentials} payload, and the gateway calls
23
+ * broker `addAccount(label, credentials, replace=false)`.
24
+ * 6. Scratch dir is wiped on every code path — success, cancel,
25
+ * paste-failure, TTL timeout, gateway shutdown.
26
+ *
27
+ * Why a separate module (vs reusing `src/auth/manager.ts`):
28
+ *
29
+ * - `startAuthSession` writes `<agentDir>/.claude/.setup-token.session.json`
30
+ * and is built around the per-agent OAuth flow. The `/auth add`
31
+ * flow has no agent — the resulting credentials become a
32
+ * broker-managed account that any agent can be set to. Threading
33
+ * `agentDir` through it would corrupt the agent's own auth state
34
+ * if the operator's add-flow collides with a normal reauth.
35
+ * - The chat-flow surface is deterministic and stateless beyond
36
+ * `pendingAuthAddFlows`. Reusing the full manager would inherit
37
+ * legacy slot logic, tmp-dir cleanup heuristics, and stale-session
38
+ * detection that doesn't apply when each `/auth add` creates a
39
+ * fresh, unguessable scratch dir of its own.
40
+ *
41
+ * What we DO reuse: the pure parsing helpers — `parseSetupTokenUrl`
42
+ * (handles both claude.ai/oauth and claude.com/cai/oauth shapes),
43
+ * `extractCodeChallenge` (PKCE stale-session detection), and
44
+ * `readTokenFromCredentialsFile` (validates the `sk-ant-oat...` token
45
+ * shape). Those are label-agnostic.
46
+ *
47
+ * **Hard rule: NEVER touch the agent's claude process.** This flow runs
48
+ * as a deterministic chat handler in the gateway. The URL goes straight
49
+ * to chat via `bot.api.sendMessage`. The code paste is intercepted by
50
+ * the gateway, never forwarded to the agent's bridge. If every account
51
+ * on the fleet is rate-limited the LLM is unreachable — that's the
52
+ * whole point of the flow existing.
53
+ */
54
+
55
+ import { spawn, type ChildProcess } from 'node:child_process'
56
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
57
+ import { homedir } from 'node:os'
58
+ import { join } from 'node:path'
59
+ import { randomBytes } from 'node:crypto'
60
+
61
+ import {
62
+ parseSetupTokenUrl,
63
+ readTokenFromCredentialsFile,
64
+ } from '../../src/auth/manager.js'
65
+ import type {
66
+ AddAccountCredentials,
67
+ AnthropicAddAccountCredentials,
68
+ } from '../../src/auth/broker/client.js'
69
+
70
+ /* ── Pending-state map ────────────────────────────────────────────────── */
71
+
72
+ /**
73
+ * In-flight `/auth add` flow keyed by Telegram chat id. The gateway's
74
+ * generic message intercept (sibling to `pendingReauthFlows`) reads
75
+ * this map to decide whether a sk-ant-…-shaped paste belongs to an
76
+ * add flow or to a reauth flow.
77
+ *
78
+ * TTL matches `REAUTH_INTERCEPT_TTL_MS` (10 minutes); the reaper sweep
79
+ * in gateway.ts walks both maps each minute.
80
+ */
81
+ export interface PendingAuthAddFlow {
82
+ label: string
83
+ scratchDir: string
84
+ /** PID of the spawned `claude setup-token` process, for cancel-kill. */
85
+ child: ChildProcess
86
+ startedAt: number
87
+ }
88
+ export const pendingAuthAddFlows = new Map<string, PendingAuthAddFlow>()
89
+
90
+ /* ── Scratch dir lifecycle ────────────────────────────────────────────── */
91
+
92
+ /**
93
+ * Pick a fresh scratch path under
94
+ * `~/.switchroom/accounts/.in-progress/<label>-<rand>/`.
95
+ *
96
+ * The leading dot keeps the dir hidden from `listAccounts(home)` in
97
+ * `src/auth/account-store.ts`, which enumerates accounts by scanning
98
+ * `~/.switchroom/accounts/`. That listing is the source of truth for
99
+ * broker `list-state` — a half-written add-in-progress must NOT
100
+ * appear there. `.in-progress/` is also outside the broker's
101
+ * managed-artifact whitelist, so a stray dir won't blow up on the
102
+ * next apply.
103
+ *
104
+ * Random suffix is 8 bytes of crypto-grade randomness so:
105
+ * - two concurrent operators adding the same label can't collide
106
+ * on the scratch path
107
+ * - an attacker watching `~/.switchroom/accounts/.in-progress/`
108
+ * can't predict the next dir name and squat a symlink
109
+ */
110
+ export function pickScratchDir(label: string, home: string = homedir()): string {
111
+ const suffix = randomBytes(8).toString('hex')
112
+ return join(home, '.switchroom', 'accounts', '.in-progress', `${label}-${suffix}`)
113
+ }
114
+
115
+ /**
116
+ * Best-effort scratch-dir wipe. Used on every exit path — success,
117
+ * cancel, timeout, error. Synchronous because the caller has already
118
+ * settled the user-facing reply by the time we get here; an extra
119
+ * tick of latency is not worth event-loop juggling.
120
+ */
121
+ export function cleanScratchDir(scratchDir: string): void {
122
+ try {
123
+ rmSync(scratchDir, { recursive: true, force: true })
124
+ } catch {
125
+ // best-effort
126
+ }
127
+ }
128
+
129
+ /* ── Subprocess lifecycle ─────────────────────────────────────────────── */
130
+
131
+ export interface StartAccountAuthSessionResult {
132
+ loginUrl: string
133
+ scratchDir: string
134
+ child: ChildProcess
135
+ }
136
+
137
+ /**
138
+ * Spawn `claude setup-token` against a fresh scratch directory and
139
+ * resolve once the authorize URL has been parsed from its stdout/stderr.
140
+ *
141
+ * Why we *don't* use tmux: the `submitAuthCode` path in
142
+ * `src/auth/manager.ts` uses tmux because that flow is interactive —
143
+ * an operator on a host can `tmux attach` to inspect the auth prompt
144
+ * if anything goes wrong. The chat flow has no equivalent escape
145
+ * hatch (the operator is on their phone) and a pipe-based subprocess
146
+ * is far easier to lifecycle-manage from a long-running gateway. We
147
+ * write the code to the child's stdin in {@link submitAccountAuthCode}.
148
+ *
149
+ * The child is left running between {@link startAccountAuthSession}
150
+ * and {@link submitAccountAuthCode} — closing stdin before the code
151
+ * is pasted would tear down the OAuth session.
152
+ *
153
+ * Timeout default: 12 seconds to see the URL. claude setup-token
154
+ * typically prints the URL within ~3–5s; 12s covers an unloaded VM
155
+ * with slow startup. Caller passes the timeout via opts so tests can
156
+ * shorten it.
157
+ */
158
+ export async function startAccountAuthSession(
159
+ label: string,
160
+ opts: {
161
+ home?: string
162
+ urlTimeoutMs?: number
163
+ /** Override the binary name (tests). */
164
+ claudeBinary?: string
165
+ } = {},
166
+ ): Promise<StartAccountAuthSessionResult> {
167
+ const home = opts.home ?? homedir()
168
+ const urlTimeoutMs = opts.urlTimeoutMs ?? 12_000
169
+ const binary = opts.claudeBinary ?? 'claude'
170
+
171
+ const scratchDir = pickScratchDir(label, home)
172
+ mkdirSync(scratchDir, { recursive: true, mode: 0o700 })
173
+
174
+ // BROWSER=/bin/true: same rationale as src/auth/manager.ts's
175
+ // startAuthSession — suppress claude setup-token's host-side browser
176
+ // auto-launch (would land on Claude's login page with no cookies on
177
+ // a headless box). The chat flow is paste-only.
178
+ const child = spawn(binary, ['setup-token'], {
179
+ env: {
180
+ ...process.env,
181
+ CLAUDE_CONFIG_DIR: scratchDir,
182
+ BROWSER: '/bin/true',
183
+ },
184
+ stdio: ['pipe', 'pipe', 'pipe'],
185
+ })
186
+
187
+ // Aggregate stdout+stderr; the URL can land on either channel
188
+ // depending on claude CLI version.
189
+ let buffer = ''
190
+ const collect = (chunk: Buffer): void => {
191
+ buffer += chunk.toString('utf8')
192
+ }
193
+ child.stdout?.on('data', collect)
194
+ child.stderr?.on('data', collect)
195
+
196
+ // Race: URL detection vs timeout vs child exit before URL appeared.
197
+ const loginUrl = await new Promise<string>((resolve, reject) => {
198
+ const deadline = setTimeout(() => {
199
+ cleanup()
200
+ reject(new Error(`claude setup-token did not print an OAuth URL within ${urlTimeoutMs}ms`))
201
+ }, urlTimeoutMs)
202
+
203
+ const tick = setInterval(() => {
204
+ const url = parseSetupTokenUrl(buffer)
205
+ if (url) {
206
+ cleanup()
207
+ resolve(url)
208
+ }
209
+ }, 200)
210
+
211
+ const onExit = (code: number | null): void => {
212
+ cleanup()
213
+ reject(new Error(`claude setup-token exited (code ${code}) before printing OAuth URL`))
214
+ }
215
+ child.once('exit', onExit)
216
+
217
+ function cleanup(): void {
218
+ clearTimeout(deadline)
219
+ clearInterval(tick)
220
+ child.removeListener('exit', onExit)
221
+ }
222
+ }).catch((err) => {
223
+ // Kill the child and wipe the scratch dir before re-raising so
224
+ // failed-to-start sessions don't leak.
225
+ try { child.kill('SIGTERM') } catch { /* best-effort */ }
226
+ cleanScratchDir(scratchDir)
227
+ throw err
228
+ })
229
+
230
+ return { loginUrl, scratchDir, child }
231
+ }
232
+
233
+ /**
234
+ * Paste the operator's browser code into the live `claude setup-token`
235
+ * child's stdin and wait for the success-written credentials.json.
236
+ *
237
+ * Returns the `AddAccountCredentials` shape the broker's add-account
238
+ * verb expects — same `claudeAiOauth: { accessToken, refreshToken,
239
+ * expiresAt, scopes, subscriptionType, rateLimitTier }` envelope.
240
+ *
241
+ * On success: the caller is responsible for invoking
242
+ * `cleanScratchDir(scratchDir)` after `addAccount` returns; we
243
+ * deliberately don't wipe here because the broker call might race the
244
+ * filesystem cleanup. On failure (invalid code, expired code, timeout)
245
+ * the helper throws and cleans the scratch dir itself.
246
+ *
247
+ * Poll interval default: 250ms — same as `submitAuthCode`'s 500ms
248
+ * halved because there's no tmux capture-pane overhead per tick.
249
+ * Timeout default: 120s, matching the env var in `submitAuthCode`.
250
+ */
251
+ export async function submitAccountAuthCode(
252
+ flow: PendingAuthAddFlow,
253
+ code: string,
254
+ opts: { pollIntervalMs?: number; pollTimeoutMs?: number } = {},
255
+ ): Promise<AddAccountCredentials> {
256
+ const pollIntervalMs = opts.pollIntervalMs ?? 250
257
+ const pollTimeoutMs = opts.pollTimeoutMs ?? 120_000
258
+
259
+ const credentialsPath = join(flow.scratchDir, '.credentials.json')
260
+
261
+ // Write the code + newline to stdin. claude setup-token's prompt
262
+ // expects line-buffered input — see the manual-paste paste at the
263
+ // bottom of `submitAuthCode`. We use a single write here (vs the
264
+ // two send-keys calls of the tmux path) because there's no
265
+ // terminfo-flake concern over a pipe.
266
+ if (!flow.child.stdin || flow.child.stdin.destroyed) {
267
+ cleanScratchDir(flow.scratchDir)
268
+ throw new Error('claude setup-token process stdin is not writable (child may have exited)')
269
+ }
270
+ flow.child.stdin.write(code.trim() + '\n')
271
+
272
+ // Poll for the credentials file. Same two-channel design as
273
+ // submitAuthCode but tmux-pane-scrape and log-scrape are out (the
274
+ // pane scrape was a fallback for older claude CLI versions; the
275
+ // chat flow targets the current CLI by definition).
276
+ const deadline = Date.now() + pollTimeoutMs
277
+ while (Date.now() < deadline) {
278
+ await new Promise((r) => setTimeout(r, pollIntervalMs))
279
+ if (existsSync(credentialsPath)) {
280
+ const token = readTokenFromCredentialsFile(credentialsPath)
281
+ if (token) {
282
+ // Parse the full credentials envelope to forward to the
283
+ // broker. readTokenFromCredentialsFile already validated the
284
+ // accessToken regex, so the JSON is well-formed.
285
+ try {
286
+ const raw = readFileSync(credentialsPath, 'utf-8')
287
+ const parsed = JSON.parse(raw) as { claudeAiOauth?: AnthropicAddAccountCredentials['claudeAiOauth'] }
288
+ if (parsed.claudeAiOauth?.accessToken) {
289
+ // Drain the child so it exits cleanly after success.
290
+ try { flow.child.stdin?.end() } catch { /* best-effort */ }
291
+ return { claudeAiOauth: parsed.claudeAiOauth }
292
+ }
293
+ } catch {
294
+ // fall through — file may be mid-write; next tick retries.
295
+ }
296
+ }
297
+ }
298
+ // Detect child early exit (invalid code → claude prints + exits).
299
+ if (flow.child.exitCode != null) {
300
+ cleanScratchDir(flow.scratchDir)
301
+ throw new Error(
302
+ `claude setup-token exited (code ${flow.child.exitCode}) — code may have been invalid or expired`,
303
+ )
304
+ }
305
+ }
306
+
307
+ // Timeout — kill the child + wipe scratch.
308
+ try { flow.child.kill('SIGTERM') } catch { /* best-effort */ }
309
+ cleanScratchDir(flow.scratchDir)
310
+ throw new Error(`No credentials file appeared at ${credentialsPath} within ${Math.round(pollTimeoutMs / 1000)}s`)
311
+ }
312
+
313
+ /**
314
+ * Cancel an in-flight `/auth add` flow: kill the `claude setup-token`
315
+ * child, wipe the scratch dir, and let the caller delete the
316
+ * `pendingAuthAddFlows` entry. Idempotent — safe to call when the
317
+ * child has already exited.
318
+ */
319
+ export function cancelAccountAuthSession(flow: PendingAuthAddFlow): void {
320
+ try {
321
+ if (flow.child.exitCode == null) flow.child.kill('SIGTERM')
322
+ } catch {
323
+ // best-effort
324
+ }
325
+ cleanScratchDir(flow.scratchDir)
326
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Thin adapter between the gateway and `src/auth/broker/client.ts`.
3
+ *
4
+ * The broker client is a stateful class (holds a persistent UDS
5
+ * connection). The gateway constructs one per `/auth` command —
6
+ * cheap, and avoids dangling sockets on idle. The handler needs the
7
+ * five methods on the `AuthBrokerClient` interface in
8
+ * `./auth-command.ts` (listState / setActive / rmAccount /
9
+ * refreshAccount / setOverride); we narrow `BrokerClient` down to
10
+ * that surface so a test mock only has to stub those five.
11
+ */
12
+
13
+ import { AuthBrokerClient as BrokerClient, type AddAccountCredentials } from '../../src/auth/broker/client.js'
14
+ import type { AuthBrokerClient } from './auth-command.js'
15
+
16
+ /**
17
+ * Construct an {@link AuthBrokerClient} for one `/auth` command. The
18
+ * caller is responsible for closing the underlying socket when done
19
+ * (do `await client.close()` after the reply lands).
20
+ */
21
+ export function createAuthBrokerClient(): {
22
+ client: AuthBrokerClient
23
+ close: () => Promise<void>
24
+ } {
25
+ const broker = new BrokerClient()
26
+ const client: AuthBrokerClient = {
27
+ listState: () => broker.listState(),
28
+ setActive: (label: string) => broker.setActive(label),
29
+ rmAccount: (label: string) => broker.rmAccount(label),
30
+ refreshAccount: (label: string) => broker.refreshAccount(label),
31
+ setOverride: (agent: string, account: string | null) =>
32
+ broker.setOverride(agent, account),
33
+ }
34
+ return { client, close: () => broker.close() }
35
+ }
36
+
37
+ /**
38
+ * Legacy `getAuthBrokerClient` entry — kept so the gateway's existing
39
+ * call site doesn't need rewiring. Returns the client object only;
40
+ * the underlying socket leaks unless the caller imports
41
+ * `createAuthBrokerClient` directly. Acceptable because:
42
+ * - The gateway is long-lived (one process per agent).
43
+ * - The broker tolerates many connections per peer.
44
+ * - `/auth` is a low-frequency human-driven verb.
45
+ *
46
+ * If allocations become a concern, swap callers over to the structured
47
+ * variant above.
48
+ */
49
+ export async function getAuthBrokerClient(
50
+ _agentName: string,
51
+ ): Promise<AuthBrokerClient | null> {
52
+ const { client } = createAuthBrokerClient()
53
+ return client
54
+ }
55
+
56
+ /**
57
+ * Add an account via the broker. Used exclusively by the `/auth add`
58
+ * chat flow — the narrow {@link AuthBrokerClient} surface in
59
+ * `auth-command.ts` deliberately omits `addAccount` because the verb
60
+ * is gateway-routed (not handler-routed). Constructs and closes a
61
+ * one-shot {@link BrokerClient} so the gateway doesn't need a
62
+ * long-lived handle just for this verb.
63
+ */
64
+ export async function addAccountViaBroker(
65
+ label: string,
66
+ credentials: AddAccountCredentials,
67
+ opts: { replace?: boolean } = {},
68
+ ): Promise<{ label: string; expiresAt?: number }> {
69
+ const broker = new BrokerClient()
70
+ try {
71
+ return await broker.addAccount(label, credentials, opts.replace)
72
+ } finally {
73
+ await broker.close()
74
+ }
75
+ }