switchroom 0.15.45 → 0.16.5

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-command.test.ts +134 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -7,20 +7,17 @@
7
7
  * OAuth account. This module owns that flow end-to-end:
8
8
  *
9
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}.
10
+ * 2. Gateway calls {@link startAccountAuthSession} → launches
11
+ * `claude setup-token` inside a detached tmux session, captures
12
+ * the OAuth authorize URL via `capture-pane`, and tucks pending
13
+ * state into {@link pendingAuthAddFlows}.
15
14
  * 3. Gateway replies to chat with the URL + paste instructions.
16
15
  * 4. Operator opens URL, logs in, copies the browser code, pastes
17
16
  * into chat. Gateway's `pendingReauthFlows`-style intercept
18
17
  * 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)`.
18
+ * 5. Helper submits the code via `send-keys`, then polls only the
19
+ * filesystem + tmux session liveness for success/failureno
20
+ * capture-pane after code submission (the echoed code is a secret).
24
21
  * 6. Scratch dir is wiped on every code path — success, cancel,
25
22
  * paste-failure, TTL timeout, gateway shutdown.
26
23
  *
@@ -50,10 +47,18 @@
50
47
  * the gateway, never forwarded to the agent's bridge. If every account
51
48
  * on the fleet is rate-limited the LLM is unreachable — that's the
52
49
  * whole point of the flow existing.
50
+ *
51
+ * **tmux is required** because `claude setup-token` writes the OAuth URL
52
+ * and reads the browser code directly through `/dev/tty` (not
53
+ * stdout/stderr/stdin). Pipe-based spawning (`stdio: ['pipe','pipe','pipe']`)
54
+ * leaves the URL buffer permanently empty — the process writes to /dev/tty
55
+ * which no pipe captures. A tmux pty gives setup-token the tty it needs;
56
+ * we scrape the URL via `capture-pane` and inject the code via `send-keys`.
57
+ * This requires `SWITCHROOM_TMUX_SUPERVISOR=1` in the agent's environment.
53
58
  */
54
59
 
55
- import { spawn, type ChildProcess } from 'node:child_process'
56
- import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
60
+ import { execFileSync } from 'node:child_process'
61
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
57
62
  import { homedir } from 'node:os'
58
63
  import { join } from 'node:path'
59
64
  import { randomBytes } from 'node:crypto'
@@ -67,6 +72,107 @@ import type {
67
72
  AnthropicAddAccountCredentials,
68
73
  } from '../../src/auth/broker/client.js'
69
74
 
75
+ /* ── Injectable tmux ops (mirrors makeTmuxRunner in src/agents/inject.ts) ─── */
76
+
77
+ /**
78
+ * Injectable tmux operations for the auth-add flow.
79
+ *
80
+ * Co-located here (rather than importing from inject.ts) because:
81
+ * - The gateway must not depend on the CLI src/agents/ subtree at runtime.
82
+ * - The shape is narrower (capture + send + has-session + new-session +
83
+ * kill-session) and carries `-L <socket>` for isolated socket routing.
84
+ * - Unit tests mock this interface; the integration test uses
85
+ * {@link makeAuthAddTmuxOps} against a throwaway socket.
86
+ */
87
+ export interface AuthAddTmuxOps {
88
+ /**
89
+ * `tmux -L <socket> new-session -d -s <session> -e KEY=VAL ... -x 400 -y 50 <cmd>`
90
+ * Returns the tmux new-session stdout (usually empty).
91
+ */
92
+ newSession(socket: string, session: string, env: Record<string, string>, cmd: string): void
93
+ /**
94
+ * `tmux -L <socket> capture-pane -p -t <session> -S -200`
95
+ * Returns pane content or null if the session/pane is gone.
96
+ */
97
+ capture(socket: string, session: string): string | null
98
+ /**
99
+ * Two `send-keys` calls mirroring src/auth/manager.ts:866-867:
100
+ * 1. `send-keys -l -t <session> <text>` (literal, no key-name expansion)
101
+ * 2. `send-keys -t <session> Enter`
102
+ * Throws on tmux error; caller is responsible for not calling this after
103
+ * the session has ended.
104
+ */
105
+ send(socket: string, session: string, text: string): void
106
+ /** `tmux -L <socket> has-session -t <session>` — returns true if alive. */
107
+ hasSession(socket: string, session: string): boolean
108
+ /** `tmux -L <socket> kill-session -t <session>` — best-effort. */
109
+ killSession(socket: string, session: string): void
110
+ }
111
+
112
+ /**
113
+ * Build the real {@link AuthAddTmuxOps} backed by the system `tmux` binary.
114
+ *
115
+ * Pass a custom `tmuxBin` to use an alternate binary path in integration
116
+ * tests. The socket name is always passed via `-L` so every call is routed
117
+ * to the correct tmux server instance.
118
+ */
119
+ export function makeAuthAddTmuxOps(tmuxBin = 'tmux'): AuthAddTmuxOps {
120
+ return {
121
+ newSession(socket, session, env, cmd) {
122
+ const envFlags: string[] = []
123
+ for (const [k, v] of Object.entries(env)) {
124
+ envFlags.push('-e', `${k}=${v}`)
125
+ }
126
+ execFileSync(
127
+ tmuxBin,
128
+ ['-L', socket, 'new-session', '-d', '-s', session, ...envFlags, '-x', '400', '-y', '50', cmd],
129
+ { stdio: ['pipe', 'pipe', 'pipe'] },
130
+ )
131
+ },
132
+ capture(socket, session) {
133
+ try {
134
+ return execFileSync(
135
+ tmuxBin,
136
+ ['-L', socket, 'capture-pane', '-p', '-t', session, '-S', '-200'],
137
+ { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
138
+ )
139
+ } catch {
140
+ return null
141
+ }
142
+ },
143
+ send(socket, session, text) {
144
+ // Mirror src/auth/manager.ts:866-867 exactly:
145
+ // 1. send the literal code (no key-name expansion)
146
+ // 2. send Enter separately
147
+ execFileSync(tmuxBin, ['-L', socket, 'send-keys', '-l', '-t', session, text.trim()], {
148
+ stdio: ['pipe', 'pipe', 'pipe'],
149
+ })
150
+ execFileSync(tmuxBin, ['-L', socket, 'send-keys', '-t', session, 'Enter'], {
151
+ stdio: ['pipe', 'pipe', 'pipe'],
152
+ })
153
+ },
154
+ hasSession(socket, session) {
155
+ try {
156
+ execFileSync(tmuxBin, ['-L', socket, 'has-session', '-t', session], {
157
+ stdio: ['pipe', 'pipe', 'pipe'],
158
+ })
159
+ return true
160
+ } catch {
161
+ return false
162
+ }
163
+ },
164
+ killSession(socket, session) {
165
+ try {
166
+ execFileSync(tmuxBin, ['-L', socket, 'kill-session', '-t', session], {
167
+ stdio: ['pipe', 'pipe', 'pipe'],
168
+ })
169
+ } catch {
170
+ // best-effort
171
+ }
172
+ },
173
+ }
174
+ }
175
+
70
176
  /* ── Pending-state map ────────────────────────────────────────────────── */
71
177
 
72
178
  /**
@@ -81,8 +187,10 @@ import type {
81
187
  export interface PendingAuthAddFlow {
82
188
  label: string
83
189
  scratchDir: string
84
- /** PID of the spawned `claude setup-token` process, for cancel-kill. */
85
- child: ChildProcess
190
+ /** tmux socket name (`switchroom-<agentName>`). */
191
+ tmuxSocket: string
192
+ /** tmux session name (`auth-add-<label>-<hex>`). */
193
+ tmuxSession: string
86
194
  startedAt: number
87
195
  }
88
196
  export const pendingAuthAddFlows = new Map<string, PendingAuthAddFlow>()
@@ -126,168 +234,261 @@ export function cleanScratchDir(scratchDir: string): void {
126
234
  }
127
235
  }
128
236
 
237
+ /* ── Orphan cleanup ───────────────────────────────────────────────────── */
238
+
239
+ /**
240
+ * Filename written into the scratch dir at session start so that a
241
+ * subsequent `startAccountAuthSession` can identify and kill orphaned
242
+ * tmux sessions from crashed/restarted gateways.
243
+ */
244
+ const AUTH_TMUX_SESSION_FILE = '.auth-tmux-session'
245
+
246
+ /**
247
+ * Sweep `~/.switchroom/accounts/.in-progress/` for dirs older than
248
+ * 10 minutes (stale from a prior gateway crash). For each, read the
249
+ * `.auth-tmux-session` file, best-effort kill the tmux session, then
250
+ * remove the dir.
251
+ *
252
+ * Called at the start of every `startAccountAuthSession` invocation.
253
+ */
254
+ function sweepOrphanSessions(
255
+ home: string,
256
+ tmux: AuthAddTmuxOps,
257
+ nowMs: number = Date.now(),
258
+ ): void {
259
+ const inProgressDir = join(home, '.switchroom', 'accounts', '.in-progress')
260
+ if (!existsSync(inProgressDir)) return
261
+ let entries: string[]
262
+ try {
263
+ entries = readdirSync(inProgressDir)
264
+ } catch {
265
+ return
266
+ }
267
+ const tenMinMs = 10 * 60_000
268
+ for (const entry of entries) {
269
+ const dir = join(inProgressDir, entry)
270
+ // Check age via the session file's mtime (proxy for dir creation time).
271
+ const sessionFile = join(dir, AUTH_TMUX_SESSION_FILE)
272
+ if (!existsSync(sessionFile)) continue
273
+ let fileContents: string
274
+ let fileMtime: number
275
+ try {
276
+ const stat = statSync(sessionFile)
277
+ fileMtime = stat.mtimeMs
278
+ fileContents = readFileSync(sessionFile, 'utf8').trim()
279
+ } catch {
280
+ continue
281
+ }
282
+ if (nowMs - fileMtime < tenMinMs) continue
283
+ // Old enough — try to kill the session.
284
+ if (fileContents) {
285
+ const [socket, session] = fileContents.split('\n')
286
+ if (socket && session) {
287
+ tmux.killSession(socket.trim(), session.trim())
288
+ }
289
+ }
290
+ // Remove the dir.
291
+ cleanScratchDir(dir)
292
+ }
293
+ }
294
+
129
295
  /* ── Subprocess lifecycle ─────────────────────────────────────────────── */
130
296
 
131
297
  export interface StartAccountAuthSessionResult {
132
298
  loginUrl: string
133
299
  scratchDir: string
134
- child: ChildProcess
300
+ tmuxSocket: string
301
+ tmuxSession: string
135
302
  }
136
303
 
137
304
  /**
138
- * Spawn `claude setup-token` against a fresh scratch directory and
139
- * resolve once the authorize URL has been parsed from its stdout/stderr.
305
+ * Launch `claude setup-token` inside a detached tmux session and
306
+ * resolve once the authorize URL has been scraped from the pane.
307
+ *
308
+ * Why tmux (not a pipe): `claude setup-token` writes the OAuth URL and
309
+ * reads the browser code directly through `/dev/tty`, bypassing
310
+ * stdout/stderr/stdin entirely. A pipe-spawned process sees an empty
311
+ * buffer forever. A tmux pane provides the tty; `capture-pane` scrapes
312
+ * what setup-token wrote there.
313
+ *
314
+ * **Critical env footgun:** the tmux server's environment is frozen at
315
+ * container boot. Ambient inheritance via `process.env` does NOT flow
316
+ * into a `new-session` call — every variable that must reach the child
317
+ * MUST be passed explicitly via `-e KEY=VAL` flags. This applies to
318
+ * `CLAUDE_CONFIG_DIR`, `BROWSER`, `HOME`, and `PATH`.
319
+ *
320
+ * **Socket:** `switchroom-<SWITCHROOM_AGENT_NAME>` — the same socket
321
+ * the agent's gateway supervisor uses. This requires
322
+ * `SWITCHROOM_TMUX_SUPERVISOR=1` to be set in the gateway's environment.
140
323
  *
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}.
324
+ * Credential file lands at `<scratchDir>/.credentials.json` because
325
+ * `CLAUDE_CONFIG_DIR=scratchDir` is passed explicitly into the session.
148
326
  *
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.
327
+ * Timeout default: 12 seconds to see the URL. `claude setup-token`
328
+ * typically renders the URL within ~3–5s; 12s covers an unloaded VM.
152
329
  *
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.
330
+ * @throws if `SWITCHROOM_TMUX_SUPERVISOR` is not set to '1'.
331
+ * @throws if the URL is not captured within `urlTimeoutMs`.
157
332
  */
158
333
  export async function startAccountAuthSession(
159
334
  label: string,
160
335
  opts: {
161
336
  home?: string
162
337
  urlTimeoutMs?: number
163
- /** Override the binary name (tests). */
338
+ /** Override the tmux binary (integration tests). */
339
+ tmuxBin?: string
340
+ /** Override tmux ops entirely (unit tests). */
341
+ tmuxOps?: AuthAddTmuxOps
342
+ /** Override the agent name (tests). */
343
+ agentName?: string
344
+ /** Override the claude binary name (tests). */
164
345
  claudeBinary?: string
165
346
  } = {},
166
347
  ): Promise<StartAccountAuthSessionResult> {
348
+ if (process.env.SWITCHROOM_TMUX_SUPERVISOR !== '1' && !opts.tmuxOps) {
349
+ throw new Error(
350
+ 'tmux supervisor required for /auth add: SWITCHROOM_TMUX_SUPERVISOR is not set to "1". ' +
351
+ 'Legacy pipe-based setup-token is unsupported (setup-token writes to /dev/tty, not stdout/stderr).',
352
+ )
353
+ }
354
+
167
355
  const home = opts.home ?? homedir()
168
356
  const urlTimeoutMs = opts.urlTimeoutMs ?? 12_000
357
+ const agentName = opts.agentName ?? process.env.SWITCHROOM_AGENT_NAME ?? 'gateway'
358
+ const tmux = opts.tmuxOps ?? makeAuthAddTmuxOps(opts.tmuxBin)
169
359
  const binary = opts.claudeBinary ?? 'claude'
170
360
 
171
361
  const scratchDir = pickScratchDir(label, home)
172
362
  mkdirSync(scratchDir, { recursive: true, mode: 0o700 })
173
363
 
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
- })
364
+ // Sweep orphan sessions BEFORE creating the new one so the dir count
365
+ // stays bounded even across repeated gateway restarts.
366
+ sweepOrphanSessions(home, tmux)
186
367
 
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')
368
+ // Session name reuses the random hex already in the scratchDir basename.
369
+ const hexSuffix = scratchDir.slice(scratchDir.lastIndexOf('-') + 1)
370
+ const tmuxSocket = `switchroom-${agentName}`
371
+ const tmuxSession = `auth-add-${label}-${hexSuffix}`.slice(0, 64)
372
+
373
+ // Write the session coordinates to the scratch dir for orphan cleanup.
374
+ try {
375
+ writeFileSync(join(scratchDir, AUTH_TMUX_SESSION_FILE), `${tmuxSocket}\n${tmuxSession}`, 'utf8')
376
+ } catch {
377
+ // best-effort — orphan cleanup is non-critical
378
+ }
379
+
380
+ // Build the env passed explicitly into the tmux session.
381
+ // CRITICAL: tmux server env is frozen at container boot; ambient
382
+ // inheritance does NOT carry CLAUDE_CONFIG_DIR or BROWSER into the
383
+ // child. Every required variable must appear as a -e flag here.
384
+ const sessionEnv: Record<string, string> = {
385
+ HOME: home,
386
+ PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin',
387
+ CLAUDE_CONFIG_DIR: scratchDir,
388
+ BROWSER: '/bin/true',
389
+ }
390
+ // Forward any extra vars the operator may have set.
391
+ if (process.env.CLAUDE_CONFIG_DIR) sessionEnv['CLAUDE_CONFIG_DIR'] = scratchDir // always override
392
+ if (process.env.XDG_CONFIG_HOME) sessionEnv['XDG_CONFIG_HOME'] = process.env.XDG_CONFIG_HOME
393
+
394
+ try {
395
+ tmux.newSession(tmuxSocket, tmuxSession, sessionEnv, binary + ' setup-token')
396
+ } catch (err) {
397
+ cleanScratchDir(scratchDir)
398
+ throw new Error(`Failed to start tmux session for claude setup-token: ${(err as Error).message}`)
192
399
  }
193
- child.stdout?.on('data', collect)
194
- child.stderr?.on('data', collect)
195
400
 
196
- // Race: URL detection vs timeout vs child exit before URL appeared.
401
+ // Poll capture-pane every 500ms up to the URL timeout.
197
402
  const loginUrl = await new Promise<string>((resolve, reject) => {
198
403
  const deadline = setTimeout(() => {
199
- cleanup()
404
+ clearInterval(ticker)
405
+ tmux.killSession(tmuxSocket, tmuxSession)
406
+ cleanScratchDir(scratchDir)
200
407
  reject(new Error(`claude setup-token did not print an OAuth URL within ${urlTimeoutMs}ms`))
201
408
  }, urlTimeoutMs)
202
409
 
203
- const tick = setInterval(() => {
204
- const url = parseSetupTokenUrl(buffer)
410
+ const ticker = setInterval(() => {
411
+ const pane = tmux.capture(tmuxSocket, tmuxSession)
412
+ if (pane === null) {
413
+ // Session died before URL appeared.
414
+ clearTimeout(deadline)
415
+ clearInterval(ticker)
416
+ cleanScratchDir(scratchDir)
417
+ reject(new Error('claude setup-token exited before printing OAuth URL'))
418
+ return
419
+ }
420
+ const url = parseSetupTokenUrl(pane)
205
421
  if (url) {
206
- cleanup()
422
+ clearTimeout(deadline)
423
+ clearInterval(ticker)
424
+ // Leave the session alive — operator has not yet pasted the code.
207
425
  resolve(url)
208
426
  }
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
427
+ }, 500)
228
428
  })
229
429
 
230
- return { loginUrl, scratchDir, child }
430
+ return { loginUrl, scratchDir, tmuxSocket, tmuxSession }
231
431
  }
232
432
 
233
433
  /**
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.
434
+ * Submit the operator's browser code into the live tmux session and
435
+ * wait for the success-written credentials.json.
236
436
  *
237
- * Returns the `AddAccountCredentials` shape the broker's add-account
238
- * verb expects — same `claudeAiOauth: { accessToken, refreshToken,
239
- * expiresAt, scopes, subscriptionType, rateLimitTier }` envelope.
437
+ * Code is submitted via TWO `send-keys` calls (mirroring
438
+ * `src/auth/manager.ts:866-867`):
439
+ * 1. `send-keys -l -t <session> <code.trim()>` — literal, no key expansion
440
+ * 2. `send-keys -t <session> Enter`
240
441
  *
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.
442
+ * **After code submission, capture-pane is NEVER called again.**
443
+ * The echoed code is a secret OAuth token; scraping the pane post-submit
444
+ * would risk logging it. Success is detected exclusively via:
445
+ * - Filesystem: `<scratchDir>/.credentials.json` appearing.
446
+ * - Session liveness: `has-session` returning false after code submit
447
+ * with no cred file → invalid/expired code (clean error, not 300s hang).
246
448
  *
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`.
449
+ * Code timeout raised to 300s (vs the prior 120s) to give the operator
450
+ * adequate time to complete the browser flow on their phone.
250
451
  */
251
452
  export async function submitAccountAuthCode(
252
453
  flow: PendingAuthAddFlow,
253
454
  code: string,
254
- opts: { pollIntervalMs?: number; pollTimeoutMs?: number } = {},
455
+ opts: {
456
+ pollIntervalMs?: number
457
+ pollTimeoutMs?: number
458
+ tmuxOps?: AuthAddTmuxOps
459
+ } = {},
255
460
  ): Promise<AddAccountCredentials> {
256
461
  const pollIntervalMs = opts.pollIntervalMs ?? 250
257
- const pollTimeoutMs = opts.pollTimeoutMs ?? 120_000
462
+ const pollTimeoutMs = opts.pollTimeoutMs ?? 300_000
463
+ const tmux = opts.tmuxOps ?? makeAuthAddTmuxOps()
258
464
 
259
465
  const credentialsPath = join(flow.scratchDir, '.credentials.json')
260
466
 
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) {
467
+ // Submit the code via two send-keys calls.
468
+ // After this point, capture-pane is NEVER called (code is a secret).
469
+ try {
470
+ tmux.send(flow.tmuxSocket, flow.tmuxSession, code)
471
+ } catch (err) {
267
472
  cleanScratchDir(flow.scratchDir)
268
- throw new Error('claude setup-token process stdin is not writable (child may have exited)')
473
+ throw new Error(
474
+ `Failed to submit auth code to tmux session: ${(err as Error).message}`,
475
+ )
269
476
  }
270
- flow.child.stdin.write(code.trim() + '\n')
271
477
 
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).
478
+ // Poll filesystem + session liveness only no capture-pane.
276
479
  const deadline = Date.now() + pollTimeoutMs
277
480
  while (Date.now() < deadline) {
278
481
  await new Promise((r) => setTimeout(r, pollIntervalMs))
482
+
279
483
  if (existsSync(credentialsPath)) {
280
484
  const token = readTokenFromCredentialsFile(credentialsPath)
281
485
  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
486
  try {
286
487
  const raw = readFileSync(credentialsPath, 'utf-8')
287
488
  const parsed = JSON.parse(raw) as { claudeAiOauth?: AnthropicAddAccountCredentials['claudeAiOauth'] }
288
489
  if (parsed.claudeAiOauth?.accessToken) {
289
- // Drain the child so it exits cleanly after success.
290
- try { flow.child.stdin?.end() } catch { /* best-effort */ }
490
+ // Kill the tmux session cleanly — it's served its purpose.
491
+ tmux.killSession(flow.tmuxSocket, flow.tmuxSession)
291
492
  return { claudeAiOauth: parsed.claudeAiOauth }
292
493
  }
293
494
  } catch {
@@ -295,32 +496,36 @@ export async function submitAccountAuthCode(
295
496
  }
296
497
  }
297
498
  }
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
- )
499
+
500
+ // Detect session death: if session is gone AND no cred file → invalid code.
501
+ if (!tmux.hasSession(flow.tmuxSocket, flow.tmuxSession)) {
502
+ if (!existsSync(credentialsPath)) {
503
+ cleanScratchDir(flow.scratchDir)
504
+ throw new Error(
505
+ 'claude setup-token exited without writing credentials — the code may be invalid or expired',
506
+ )
507
+ }
304
508
  }
305
509
  }
306
510
 
307
- // Timeout — kill the child + wipe scratch.
308
- try { flow.child.kill('SIGTERM') } catch { /* best-effort */ }
511
+ // Timeout — kill session + wipe scratch.
512
+ tmux.killSession(flow.tmuxSocket, flow.tmuxSession)
309
513
  cleanScratchDir(flow.scratchDir)
310
- throw new Error(`No credentials file appeared at ${credentialsPath} within ${Math.round(pollTimeoutMs / 1000)}s`)
514
+ throw new Error(
515
+ `No credentials file appeared at ${credentialsPath} within ${Math.round(pollTimeoutMs / 1000)}s`,
516
+ )
311
517
  }
312
518
 
313
519
  /**
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.
520
+ * Cancel an in-flight `/auth add` flow: kill the tmux session, wipe
521
+ * the scratch dir, and let the caller delete the `pendingAuthAddFlows`
522
+ * entry. Idempotent — safe to call when the session has already exited.
318
523
  */
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
- }
524
+ export function cancelAccountAuthSession(
525
+ flow: PendingAuthAddFlow,
526
+ tmuxOps?: AuthAddTmuxOps,
527
+ ): void {
528
+ const tmux = tmuxOps ?? makeAuthAddTmuxOps()
529
+ tmux.killSession(flow.tmuxSocket, flow.tmuxSession)
325
530
  cleanScratchDir(flow.scratchDir)
326
531
  }
@@ -32,8 +32,8 @@ export function createAuthBrokerClient(): {
32
32
  refreshAccount: (label: string) => broker.refreshAccount(label),
33
33
  setOverride: (agent: string, account: string | null) =>
34
34
  broker.setOverride(agent, account),
35
- probeQuota: (accounts: readonly string[], timeoutMs?: number) =>
36
- broker.probeQuota(accounts, timeoutMs),
35
+ probeQuota: (accounts: readonly string[], timeoutMs?: number, forceLive?: boolean) =>
36
+ broker.probeQuota(accounts, timeoutMs, forceLive),
37
37
  claimNotification: (key: string, windowMs: number) =>
38
38
  broker.claimNotification(key, windowMs),
39
39
  }