switchroom 0.15.44 → 0.16.4

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 +122 -88
  2. package/dist/auth-broker/index.js +463 -177
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +17 -14
  5. package/dist/cli/notion-write-pretool.mjs +117 -86
  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/skill-validate-pretool.mjs +72 -72
  9. package/dist/cli/switchroom.js +3249 -1241
  10. package/dist/cli/ui/index.html +1 -1
  11. package/dist/host-control/main.js +2833 -355
  12. package/dist/vault/approvals/kernel-server.js +7482 -7439
  13. package/dist/vault/broker/server.js +11315 -11272
  14. package/examples/minimal.yaml +1 -0
  15. package/examples/switchroom.yaml +1 -0
  16. package/package.json +3 -3
  17. package/profiles/_base/start.sh.hbs +88 -1
  18. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  19. package/profiles/default/CLAUDE.md.hbs +3 -22
  20. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  21. package/telegram-plugin/answer-stream-flag.ts +12 -49
  22. package/telegram-plugin/answer-stream.ts +5 -150
  23. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  24. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  25. package/telegram-plugin/context-exhaustion.ts +12 -0
  26. package/telegram-plugin/demo-mask.ts +154 -0
  27. package/telegram-plugin/dist/bridge/bridge.js +167 -124
  28. package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
  29. package/telegram-plugin/dist/server.js +215 -172
  30. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  31. package/telegram-plugin/draft-stream.ts +47 -410
  32. package/telegram-plugin/final-answer-detect.ts +17 -12
  33. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  34. package/telegram-plugin/format.ts +56 -19
  35. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  36. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  37. package/telegram-plugin/gateway/auth-command.ts +70 -14
  38. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  39. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  40. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  41. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  42. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  43. package/telegram-plugin/gateway/effort-command.ts +8 -3
  44. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  45. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  46. package/telegram-plugin/gateway/gateway.ts +1837 -291
  47. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  48. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  49. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  50. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  51. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  52. package/telegram-plugin/history.ts +33 -11
  53. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  54. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  55. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  56. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  57. package/telegram-plugin/issues-card.ts +4 -0
  58. package/telegram-plugin/model-unavailable.ts +124 -0
  59. package/telegram-plugin/narrative-dedup.ts +69 -0
  60. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  61. package/telegram-plugin/package.json +3 -3
  62. package/telegram-plugin/pending-work-progress.ts +12 -0
  63. package/telegram-plugin/permission-rule.ts +32 -5
  64. package/telegram-plugin/permission-title.ts +152 -9
  65. package/telegram-plugin/quota-check.ts +13 -0
  66. package/telegram-plugin/quota-watch.ts +135 -7
  67. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  68. package/telegram-plugin/registry/turns-schema.ts +9 -0
  69. package/telegram-plugin/runtime-metrics.ts +13 -0
  70. package/telegram-plugin/session-tail.ts +96 -11
  71. package/telegram-plugin/silence-poke.ts +170 -24
  72. package/telegram-plugin/slot-banner-driver.ts +3 -0
  73. package/telegram-plugin/status-no-truncate.ts +44 -0
  74. package/telegram-plugin/status-reactions.ts +20 -3
  75. package/telegram-plugin/stream-controller.ts +4 -23
  76. package/telegram-plugin/stream-reply-handler.ts +6 -24
  77. package/telegram-plugin/streaming-metrics.ts +91 -0
  78. package/telegram-plugin/subagent-watcher.ts +212 -66
  79. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  80. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  81. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  82. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  83. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  84. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  85. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  86. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  87. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  88. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  89. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  90. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  91. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  92. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  93. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  94. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  95. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  96. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  97. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  98. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  99. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  100. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  101. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  102. package/telegram-plugin/tests/history.test.ts +60 -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
@@ -9,38 +9,34 @@
9
9
  * 2. Admin gating: `/auth add` is refused for non-admin agents.
10
10
  * 3. Bad labels (slashes, whitespace, over-length) are refused
11
11
  * with a clear error.
12
- * 4. Subprocess wiring: `startAccountAuthSession` spawns the
13
- * configured binary, parses the URL from stdout, returns it.
14
- * 5. Code paste-back: `submitAccountAuthCode` writes the code to
15
- * stdin and resolves to a broker-ready `AddAccountCredentials`
16
- * payload when the scratch dir's `.credentials.json` appears.
12
+ * 4. tmux wiring: `startAccountAuthSession` starts a tmux session,
13
+ * scrapes the URL from the pane, returns it.
14
+ * 5. Code paste-back: `submitAccountAuthCode` sends two `send-keys`
15
+ * calls (the -l literal call then Enter), then resolves via cred
16
+ * file detection no capture-pane after code submit.
17
17
  * 6. Stale paste-back (TTL exceeded) is the gateway's concern;
18
18
  * pinned as a contract via the TTL constant the gateway uses.
19
- * 7. Cancel removes the scratch dir + clears pending state.
19
+ * 7. Cancel kills the tmux session + wipes the scratch dir.
20
20
  *
21
- * The full gateway path (chat bot.command reply) can't be
22
- * exercised in-process because the top-level gateway IIFE starts
23
- * a Telegram client; the tests target the building blocks the
24
- * gateway wires together, the same shape as the existing
25
- * `auth-login-url-button.test.ts` and `auth-code-redact.test.ts`.
21
+ * Unit tests mock `AuthAddTmuxOps`; the integration test drives a real
22
+ * tmux server on a throwaway socket with a fake setup-token script.
26
23
  */
27
24
 
28
25
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
29
26
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'
30
27
  import { tmpdir } from 'node:os'
31
28
  import { join } from 'node:path'
29
+ import { execFileSync, execSync } from 'node:child_process'
32
30
 
33
31
  /**
34
- * Pick an exec-allowed temp root. Some containers (and this one) mount
35
- * /tmp with `noexec`, which breaks the subprocess fixtures that spawn
36
- * a small node script as a stand-in for `claude setup-token`. When the
37
- * default tmpdir is noexec, fall back to a project-local `.test-tmp/`
38
- * which inherits the project mount's exec bits.
32
+ * Pick an exec-allowed temp root. Some containers mount /tmp with
33
+ * `noexec`. When the default tmpdir is noexec, fall back to a
34
+ * project-local `.test-tmp/` which inherits the project mount's
35
+ * exec bits.
39
36
  */
40
37
  function execAllowedTmpdir(): string {
41
38
  const def = tmpdir()
42
39
  try {
43
- // Read /proc/mounts and check whether the directory's mount has noexec.
44
40
  const mounts = readFileSync('/proc/mounts', 'utf8')
45
41
  const noexec = mounts.split('\n').some((line) => {
46
42
  const parts = line.split(' ')
@@ -72,7 +68,9 @@ import {
72
68
  cancelAccountAuthSession,
73
69
  cleanScratchDir,
74
70
  pickScratchDir,
71
+ makeAuthAddTmuxOps,
75
72
  type PendingAuthAddFlow,
73
+ type AuthAddTmuxOps,
76
74
  } from '../gateway/auth-add-flow.js'
77
75
 
78
76
  /* ── Test fixtures ────────────────────────────────────────────────────── */
@@ -82,91 +80,98 @@ let workspace: string
82
80
  beforeEach(() => {
83
81
  workspace = mkdtempSync(join(EXEC_TMPDIR, 'auth-add-flow-test-'))
84
82
  pendingAuthAddFlows.clear()
83
+ // Ensure SWITCHROOM_TMUX_SUPERVISOR is set so the tmuxOps guard passes
84
+ // in unit tests that supply a mock tmuxOps.
85
+ process.env.SWITCHROOM_TMUX_SUPERVISOR = '1'
85
86
  })
86
87
 
87
88
  afterEach(() => {
88
89
  pendingAuthAddFlows.clear()
90
+ delete process.env.SWITCHROOM_TMUX_SUPERVISOR
89
91
  try { rmSync(workspace, { recursive: true, force: true }) } catch { /* best-effort */ }
90
92
  })
91
93
 
94
+ /* ── Mock AuthAddTmuxOps factory ─────────────────────────────────────── */
95
+
92
96
  /**
93
- * A tiny stand-in for `claude setup-token` that:
94
- * - prints a realistic OAuth authorize URL on startup
95
- * - reads a line from stdin (the operator's pasted code)
96
- * - writes a fully-formed `.credentials.json` to its
97
- * CLAUDE_CONFIG_DIR
98
- * - exits 0
97
+ * Build a mock `AuthAddTmuxOps` for unit tests. Lets tests control:
98
+ * - `captureResponses`: a queue of strings returned by successive
99
+ * `capture()` calls. Use null to simulate session death.
100
+ * - `sessionAlive`: whether `hasSession()` returns true.
101
+ * - On `newSession`, records the call for assertion.
102
+ * - On `send`, records keystrokes sent (two calls per submitAccountAuthCode).
103
+ * - On `killSession`, records the call and marks session dead.
99
104
  *
100
- * Written to disk per-test so we can control the exact bytes the
101
- * subprocess emits. Avoids needing the real `claude` binary in CI.
105
+ * Hook callbacks (`hooks.onSend`, `hooks.onKill`, etc.) are live-readable
106
+ * on the returned object so tests can reassign them after construction.
102
107
  */
103
- function fakeClaudeBinary(opts: {
104
- /** Bytes to print before reading stdin. Defaults to a valid URL. */
105
- prelude?: string
106
- /** If true, exits 1 after reading stdin (simulates invalid code). */
107
- failOnCode?: boolean
108
- /** If true, never reads stdin (URL prints + lingers). */
109
- hang?: boolean
110
- /** Override the token written to credentials.json. */
111
- token?: string
112
- } = {}): string {
113
- const url =
114
- 'https://claude.com/cai/oauth/authorize?code=true&client_id=test&response_type=code' +
115
- '&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-test'
116
- const prelude = opts.prelude ?? `${url}\nPaste code here:\n`
117
- const token = opts.token ?? 'sk-ant-oat01-test-' + 'a'.repeat(40)
118
- // The script must keep its event loop alive until either it has
119
- // read a line of input (the operator's pasted code) or until the
120
- // parent kills it. Resuming stdin (or attaching a data listener)
121
- // is what tells Node "I'm not done yet". For the hang case we
122
- // resume stdin but never act on data, so the process loiters
123
- // indefinitely — that's the timeout-path fixture.
124
- const onData = opts.failOnCode
125
- ? `process.exit(1);`
126
- : `
127
- const creds = {
128
- claudeAiOauth: {
129
- accessToken: ${JSON.stringify(token)},
130
- refreshToken: ${JSON.stringify(['sk-ant-', 'ort01-test-refresh'].join(''))},
131
- expiresAt: Date.now() + 8 * 3600_000,
132
- scopes: ['user:inference'],
133
- subscriptionType: 'max',
134
- rateLimitTier: 'max',
135
- },
136
- };
137
- writeFileSync(join(process.env.CLAUDE_CONFIG_DIR, '.credentials.json'), JSON.stringify(creds));
138
- process.exit(0);`
139
- const script = `#!/usr/bin/env node
140
- const { writeFileSync } = require('node:fs');
141
- const { join } = require('node:path');
142
- process.stdout.write(${JSON.stringify(prelude)});
143
- process.stdin.resume();
144
- ${opts.hang ? '// hang — read but ignore stdin' : `
145
- let buf = '';
146
- process.stdin.on('data', (chunk) => {
147
- buf += chunk.toString('utf8');
148
- if (buf.includes('\\n')) {
149
- ${onData}
108
+ function makeMockTmuxOps(opts: {
109
+ captureResponses?: (string | null)[]
110
+ initialSessionAlive?: boolean
111
+ } = {}): AuthAddTmuxOps & {
112
+ newSessionCalls: Array<{ socket: string; session: string; env: Record<string, string>; cmd: string }>
113
+ sendCalls: Array<{ socket: string; session: string; text: string }>
114
+ killCalls: Array<{ socket: string; session: string }>
115
+ captureCallCount: number
116
+ sessionAlive: boolean
117
+ /** Reassignable hook called after send is recorded. */
118
+ onSend: ((socket: string, session: string, text: string) => void) | null
119
+ /** Reassignable hook called after a capture. */
120
+ onCapture: ((socket: string, session: string) => void) | null
121
+ } {
122
+ const captureQueue = [...(opts.captureResponses ?? [])]
123
+ let sessionAlive = opts.initialSessionAlive ?? true
124
+ const newSessionCalls: Array<{ socket: string; session: string; env: Record<string, string>; cmd: string }> = []
125
+ const sendCalls: Array<{ socket: string; session: string; text: string }> = []
126
+ const killCalls: Array<{ socket: string; session: string }> = []
127
+ let captureCallCount = 0
128
+
129
+ const mock = {
130
+ get newSessionCalls() { return newSessionCalls },
131
+ get sendCalls() { return sendCalls },
132
+ get killCalls() { return killCalls },
133
+ get captureCallCount() { return captureCallCount },
134
+ get sessionAlive() { return sessionAlive },
135
+ set sessionAlive(v: boolean) { sessionAlive = v },
136
+ onSend: null as ((socket: string, session: string, text: string) => void) | null,
137
+ onCapture: null as ((socket: string, session: string) => void) | null,
138
+
139
+ newSession(socket: string, session: string, env: Record<string, string>, cmd: string) {
140
+ newSessionCalls.push({ socket, session, env, cmd })
141
+ },
142
+ capture(socket: string, session: string): string | null {
143
+ captureCallCount++
144
+ mock.onCapture?.(socket, session)
145
+ if (captureQueue.length > 0) return captureQueue.shift() ?? null
146
+ return sessionAlive ? '' : null
147
+ },
148
+ send(socket: string, session: string, text: string) {
149
+ sendCalls.push({ socket, session, text })
150
+ mock.onSend?.(socket, session, text)
151
+ },
152
+ hasSession(socket: string, session: string): boolean {
153
+ void socket; void session
154
+ return sessionAlive
155
+ },
156
+ killSession(socket: string, session: string) {
157
+ killCalls.push({ socket, session })
158
+ sessionAlive = false
159
+ },
150
160
  }
151
- });
152
- process.stdin.on('end', () => process.exit(0));`}
153
- `
154
- const path = join(workspace, `fake-claude-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.js`)
155
- writeFileSync(path, script, { mode: 0o755 })
156
- return path
161
+ return mock
157
162
  }
158
163
 
159
164
  /* ── 1. Parser ────────────────────────────────────────────────────────── */
160
165
 
161
166
  describe('parseAuthCommand — /auth add and /auth cancel', () => {
162
167
  it('recognises "/auth add <label>" with a valid label', () => {
163
- const p = parseAuthCommand('/auth add ken@example.com')
164
- expect(p).toEqual({ kind: 'add', label: 'ken@example.com' })
168
+ const p = parseAuthCommand('/auth add alice@example.com')
169
+ expect(p).toEqual({ kind: 'add', label: 'alice@example.com' })
165
170
  })
166
171
 
167
172
  it('recognises gmail-tag labels (the + character)', () => {
168
- const p = parseAuthCommand('/auth add ken+work@example.com')
169
- expect(p).toEqual({ kind: 'add', label: 'ken+work@example.com' })
173
+ const p = parseAuthCommand('/auth add alice+work@example.com')
174
+ expect(p).toEqual({ kind: 'add', label: 'alice+work@example.com' })
170
175
  })
171
176
 
172
177
  it('treats "/auth add" with no label as a help reply', () => {
@@ -216,9 +221,9 @@ describe('parseAuthCommand — /auth add and /auth cancel', () => {
216
221
 
217
222
  describe('validateAuthAddLabel', () => {
218
223
  it.each([
219
- 'ken',
220
- 'ken@example.com',
221
- 'ken+work@example.com',
224
+ 'alice',
225
+ 'alice@example.com',
226
+ 'alice+work@example.com',
222
227
  'a.b-c_d',
223
228
  'A'.repeat(64),
224
229
  ])('accepts %s', (label) => {
@@ -279,195 +284,326 @@ describe('handleAuthCommand — add/cancel are gateway-routed (defensive contrac
279
284
  })
280
285
  })
281
286
 
282
- /* ── 3. Subprocess wiring: startAccountAuthSession ────────────────────── */
287
+ /* ── 3. SWITCHROOM_TMUX_SUPERVISOR guard ──────────────────────────────── */
283
288
 
284
- /**
285
- * The helper spawns `claude setup-token` via {@link spawn} we point
286
- * `claudeBinary` at a node script with `#!/usr/bin/env node` and mode
287
- * 0o755 so the `spawn(2)` exec works without a wrapping shell.
288
- */
289
- describe('startAccountAuthSession fake claude binary', () => {
290
- it('parses the URL from stdout and exposes the scratch dir', async () => {
291
- const binary = fakeClaudeBinary({ hang: true })
292
- const result = await startAccountAuthSession('ken@example.com', {
289
+ describe('startAccountAuthSession — SWITCHROOM_TMUX_SUPERVISOR guard', () => {
290
+ it('throws a clear error when SWITCHROOM_TMUX_SUPERVISOR is not set and no tmuxOps override', async () => {
291
+ delete process.env.SWITCHROOM_TMUX_SUPERVISOR
292
+ let caught: Error | null = null
293
+ try {
294
+ await startAccountAuthSession('alice@example.com', { home: workspace })
295
+ } catch (err) {
296
+ caught = err as Error
297
+ }
298
+ expect(caught).toBeInstanceOf(Error)
299
+ expect(caught?.message).toMatch(/tmux supervisor required/i)
300
+ expect(caught?.message).toMatch(/SWITCHROOM_TMUX_SUPERVISOR/i)
301
+ })
302
+
303
+ it('proceeds when tmuxOps is provided even without SWITCHROOM_TMUX_SUPERVISOR', async () => {
304
+ delete process.env.SWITCHROOM_TMUX_SUPERVISOR
305
+ const url = 'https://claude.com/cai/oauth/authorize?code=true&client_id=test&response_type=code&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-test'
306
+ const mock = makeMockTmuxOps({
307
+ captureResponses: ['', `${url}\nPaste code here:\n`],
308
+ })
309
+ const result = await startAccountAuthSession('alice@example.com', {
293
310
  home: workspace,
294
- claudeBinary: binary,
311
+ tmuxOps: mock,
312
+ urlTimeoutMs: 3_000,
313
+ })
314
+ expect(result.loginUrl).toContain('https://claude.com/cai/oauth')
315
+ cleanScratchDir(result.scratchDir)
316
+ })
317
+ })
318
+
319
+ /* ── 4. Unit: startAccountAuthSession with mock tmuxOps ───────────────── */
320
+
321
+ describe('startAccountAuthSession — mock tmuxOps (unit)', () => {
322
+ const VALID_URL = 'https://claude.com/cai/oauth/authorize?code=true&client_id=test&response_type=code&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-test'
323
+
324
+ it('calls newSession with explicit -e CLAUDE_CONFIG_DIR and -e BROWSER in env', async () => {
325
+ const mock = makeMockTmuxOps({
326
+ captureResponses: [VALID_URL],
327
+ })
328
+ const result = await startAccountAuthSession('alice@example.com', {
329
+ home: workspace,
330
+ tmuxOps: mock,
331
+ urlTimeoutMs: 3_000,
332
+ })
333
+ expect(mock.newSessionCalls).toHaveLength(1)
334
+ const call = mock.newSessionCalls[0]
335
+ expect(call.env).toHaveProperty('CLAUDE_CONFIG_DIR', result.scratchDir)
336
+ expect(call.env).toHaveProperty('BROWSER', '/bin/true')
337
+ expect(call.env).toHaveProperty('HOME')
338
+ expect(call.env).toHaveProperty('PATH')
339
+ cleanScratchDir(result.scratchDir)
340
+ })
341
+
342
+ it('returns the URL parsed from the pane after polling', async () => {
343
+ // First two captures return empty; third returns the URL line.
344
+ const mock = makeMockTmuxOps({
345
+ captureResponses: ['', '', `\x1b[0m${VALID_URL}\nPaste code here:\n`],
346
+ })
347
+ const result = await startAccountAuthSession('bob@example.com', {
348
+ home: workspace,
349
+ tmuxOps: mock,
295
350
  urlTimeoutMs: 5_000,
296
351
  })
297
- try {
298
- expect(result.loginUrl).toMatch(/^https:\/\/claude\.com\/cai\/oauth\/authorize\?/)
299
- expect(result.scratchDir).toContain('.in-progress')
300
- expect(result.scratchDir).toContain('ken@example.com-')
301
- expect(existsSync(result.scratchDir)).toBe(true)
302
- } finally {
303
- try { result.child.kill('SIGTERM') } catch { /* */ }
304
- cleanScratchDir(result.scratchDir)
305
- }
352
+ expect(result.loginUrl).toMatch(/^https:\/\/claude\.com\/cai\/oauth\/authorize\?/)
353
+ expect(result.scratchDir).toContain('.in-progress')
354
+ expect(result.scratchDir).toContain('bob@example.com-')
355
+ expect(existsSync(result.scratchDir)).toBe(true)
356
+ // Session name uses the random hex from scratchDir
357
+ expect(result.tmuxSession).toMatch(/^auth-add-bob@example\.com-/)
358
+ cleanScratchDir(result.scratchDir)
359
+ })
360
+
361
+ it('uses the scratchDir random hex as the session name suffix', async () => {
362
+ const mock = makeMockTmuxOps({ captureResponses: [VALID_URL] })
363
+ const result = await startAccountAuthSession('alice', {
364
+ home: workspace,
365
+ tmuxOps: mock,
366
+ urlTimeoutMs: 3_000,
367
+ })
368
+ const hexFromDir = result.scratchDir.slice(result.scratchDir.lastIndexOf('-') + 1)
369
+ expect(result.tmuxSession).toContain(hexFromDir)
370
+ cleanScratchDir(result.scratchDir)
306
371
  })
307
372
 
308
- it('times out + wipes the scratch dir when claude never prints a URL', async () => {
309
- const binary = fakeClaudeBinary({ prelude: 'no url here\n', hang: true })
373
+ it('times out and wipes the scratch dir when the pane never shows a URL', async () => {
374
+ const mock = makeMockTmuxOps({
375
+ // Always return empty pane content
376
+ captureResponses: [],
377
+ })
310
378
  let caught: Error | null = null
311
- let scratchDirSeen: string | null = null
312
- // Spy on pickScratchDir? Simpler: scan the parent dir before/after.
313
379
  try {
314
- await startAccountAuthSession('badcase', {
380
+ await startAccountAuthSession('timeout-case', {
315
381
  home: workspace,
316
- claudeBinary: binary,
317
- urlTimeoutMs: 500,
382
+ tmuxOps: mock,
383
+ urlTimeoutMs: 300,
318
384
  })
319
385
  } catch (err) {
320
386
  caught = err as Error
321
387
  }
322
388
  expect(caught).toBeInstanceOf(Error)
323
389
  expect(caught?.message).toMatch(/did not print/i)
324
- // No scratch dir should remain.
390
+ // Scratch dir must have been wiped
325
391
  const inProgressDir = join(workspace, '.switchroom', 'accounts', '.in-progress')
326
392
  if (existsSync(inProgressDir)) {
327
393
  const { readdirSync } = await import('node:fs')
328
394
  const remaining = readdirSync(inProgressDir)
329
395
  expect(remaining).toEqual([])
330
396
  }
331
- void scratchDirSeen
397
+ })
398
+
399
+ it('fails fast when session dies before URL appears (null capture)', async () => {
400
+ // First capture returns content; second returns null (session died)
401
+ const mock = makeMockTmuxOps({
402
+ captureResponses: ['loading...', null],
403
+ })
404
+ let caught: Error | null = null
405
+ try {
406
+ await startAccountAuthSession('dead-session', {
407
+ home: workspace,
408
+ tmuxOps: mock,
409
+ urlTimeoutMs: 5_000,
410
+ })
411
+ } catch (err) {
412
+ caught = err as Error
413
+ }
414
+ expect(caught).toBeInstanceOf(Error)
415
+ expect(caught?.message).toMatch(/exited before printing/i)
332
416
  })
333
417
  })
334
418
 
335
- /* ── 4. Code paste-back: submitAccountAuthCode ────────────────────────── */
419
+ /* ── 5. Unit: submitAccountAuthCode with mock tmuxOps ────────────────── */
336
420
 
337
- describe('submitAccountAuthCode', () => {
338
- it('writes the code to stdin and resolves to a broker-ready credentials payload', async () => {
339
- const binary = fakeClaudeBinary()
340
- const session = await startAccountAuthSession('ken@example.com', {
341
- home: workspace,
342
- claudeBinary: binary,
343
- urlTimeoutMs: 5_000,
421
+ describe('submitAccountAuthCode — mock tmuxOps (unit)', () => {
422
+ function makeMockFlow(scratchDir: string, tmuxSocket = 'switchroom-test', tmuxSession = 'auth-add-test-abc123'): PendingAuthAddFlow {
423
+ return { label: 'alice@example.com', scratchDir, tmuxSocket, tmuxSession, startedAt: Date.now() }
424
+ }
425
+
426
+ it('calls send exactly once (which internally does two send-keys calls) and NO capture after code submit', async () => {
427
+ // The mock's send() represents the two-call sequence (send-keys -l + send-keys Enter).
428
+ const scratchDir = mkdtempSync(join(workspace, 'flow-'))
429
+ const credPath = join(scratchDir, '.credentials.json')
430
+ const credContents = JSON.stringify({
431
+ claudeAiOauth: {
432
+ accessToken: 'sk-ant-oat01-test-' + 'b'.repeat(40),
433
+ refreshToken: 'sk-ant-ort01-test',
434
+ expiresAt: Date.now() + 8 * 3600_000,
435
+ scopes: ['user:inference'],
436
+ subscriptionType: 'max',
437
+ rateLimitTier: 'max',
438
+ },
344
439
  })
345
- const flow: PendingAuthAddFlow = {
346
- label: 'ken@example.com',
347
- scratchDir: session.scratchDir,
348
- child: session.child,
349
- startedAt: Date.now(),
440
+ const mock = makeMockTmuxOps({ initialSessionAlive: true })
441
+ let sendCalled = false
442
+ let captureCalledAfterSend = false
443
+ // Set hook via reassignment (works because mock.send reads mock.onSend live)
444
+ mock.onSend = (_s, _ss, _t) => {
445
+ sendCalled = true
446
+ writeFileSync(credPath, credContents, 'utf8')
350
447
  }
351
- try {
352
- const creds = await submitAccountAuthCode(flow, 'pasted-browser-code', {
353
- pollIntervalMs: 50,
354
- pollTimeoutMs: 5_000,
355
- })
356
- expect(creds.claudeAiOauth.accessToken).toMatch(/^sk-ant-oat\d+-/)
357
- expect(creds.claudeAiOauth.subscriptionType).toBe('max')
358
- expect(creds.claudeAiOauth.scopes).toEqual(['user:inference'])
359
- expect(typeof creds.claudeAiOauth.expiresAt).toBe('number')
360
- } finally {
361
- cleanScratchDir(flow.scratchDir)
448
+ mock.onCapture = () => {
449
+ if (sendCalled) captureCalledAfterSend = true
362
450
  }
451
+
452
+ const flow = makeMockFlow(scratchDir)
453
+ const creds = await submitAccountAuthCode(flow, 'browser-code-xyz', {
454
+ pollIntervalMs: 30,
455
+ pollTimeoutMs: 3_000,
456
+ tmuxOps: mock,
457
+ })
458
+
459
+ expect(mock.sendCalls).toHaveLength(1) // one logical send = two send-keys under the hood
460
+ expect(captureCalledAfterSend).toBe(false) // CRITICAL: no capture-pane after code submit
461
+ expect(creds.claudeAiOauth.accessToken).toMatch(/^sk-ant-oat\d+-/)
363
462
  })
364
463
 
365
- it('throws + wipes the scratch dir when the child exits with non-zero (invalid code)', async () => {
366
- const binary = fakeClaudeBinary({ failOnCode: true })
367
- const session = await startAccountAuthSession('badcode', {
464
+ it('newSession args include -e CLAUDE_CONFIG_DIR and -e BROWSER', async () => {
465
+ // Covered in startAccountAuthSession tests above; verify via the mock's
466
+ // newSession call record that env keys are passed.
467
+ const mock = makeMockTmuxOps({
468
+ captureResponses: ['https://claude.com/cai/oauth/authorize?code=x&client_id=y&response_type=code&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-z'],
469
+ })
470
+ const result = await startAccountAuthSession('env-test', {
368
471
  home: workspace,
369
- claudeBinary: binary,
370
- urlTimeoutMs: 5_000,
472
+ tmuxOps: mock,
473
+ urlTimeoutMs: 3_000,
371
474
  })
372
- const flow: PendingAuthAddFlow = {
373
- label: 'badcode',
374
- scratchDir: session.scratchDir,
375
- child: session.child,
376
- startedAt: Date.now(),
475
+ const call = mock.newSessionCalls[0]
476
+ expect(Object.keys(call.env)).toContain('CLAUDE_CONFIG_DIR')
477
+ expect(Object.keys(call.env)).toContain('BROWSER')
478
+ expect(call.env.CLAUDE_CONFIG_DIR).toBe(result.scratchDir)
479
+ cleanScratchDir(result.scratchDir)
480
+ })
481
+
482
+ it('detects cred file and returns AddAccountCredentials', async () => {
483
+ const scratchDir = mkdtempSync(join(workspace, 'flow2-'))
484
+ const credPath = join(scratchDir, '.credentials.json')
485
+ const expectedCreds = {
486
+ claudeAiOauth: {
487
+ accessToken: 'sk-ant-oat01-test-' + 'c'.repeat(40),
488
+ refreshToken: 'sk-ant-ort01-test',
489
+ expiresAt: Date.now() + 8 * 3600_000,
490
+ scopes: ['user:inference'],
491
+ subscriptionType: 'max',
492
+ rateLimitTier: 'max',
493
+ },
494
+ }
495
+ let writtenOnSend = false
496
+ const mock = makeMockTmuxOps({ initialSessionAlive: true })
497
+ mock.onSend = () => {
498
+ writtenOnSend = true
499
+ writeFileSync(credPath, JSON.stringify(expectedCreds), 'utf8')
377
500
  }
501
+
502
+ const flow = makeMockFlow(scratchDir)
503
+ const creds = await submitAccountAuthCode(flow, 'test-code', {
504
+ pollIntervalMs: 30,
505
+ pollTimeoutMs: 3_000,
506
+ tmuxOps: mock,
507
+ })
508
+
509
+ expect(writtenOnSend).toBe(true)
510
+ expect(creds.claudeAiOauth.accessToken).toMatch(/^sk-ant-oat\d+-/)
511
+ expect(creds.claudeAiOauth.subscriptionType).toBe('max')
512
+ expect(creds.claudeAiOauth.scopes).toEqual(['user:inference'])
513
+ // scratchDir should NOT be cleaned on success (caller's responsibility)
514
+ expect(existsSync(scratchDir)).toBe(true)
515
+ cleanScratchDir(scratchDir)
516
+ })
517
+
518
+ it('throws a clean error when session dies with no cred file (invalid code path)', async () => {
519
+ const scratchDir = mkdtempSync(join(workspace, 'flow3-'))
520
+ const mock = makeMockTmuxOps({ initialSessionAlive: true })
521
+ // After send, mark session dead without writing cred file
522
+ mock.onSend = () => {
523
+ mock.sessionAlive = false
524
+ }
525
+
526
+ const flow = makeMockFlow(scratchDir)
378
527
  let caught: Error | null = null
379
528
  try {
380
- await submitAccountAuthCode(flow, 'invalid-code', {
381
- pollIntervalMs: 50,
529
+ await submitAccountAuthCode(flow, 'bad-code', {
530
+ pollIntervalMs: 30,
382
531
  pollTimeoutMs: 3_000,
532
+ tmuxOps: mock,
383
533
  })
384
534
  } catch (err) {
385
535
  caught = err as Error
386
536
  }
537
+
387
538
  expect(caught).toBeInstanceOf(Error)
388
- expect(caught?.message).toMatch(/exited|invalid|expired/i)
389
- expect(existsSync(flow.scratchDir)).toBe(false)
539
+ expect(caught?.message).toMatch(/exited without writing credentials|invalid|expired/i)
540
+ expect(existsSync(scratchDir)).toBe(false) // wiped on failure
390
541
  })
391
542
 
392
- it('throws + wipes the scratch dir on timeout (no credentials.json appears)', async () => {
393
- const binary = fakeClaudeBinary({ hang: true })
394
- const session = await startAccountAuthSession('timeout', {
395
- home: workspace,
396
- claudeBinary: binary,
397
- urlTimeoutMs: 5_000,
398
- })
399
- const flow: PendingAuthAddFlow = {
400
- label: 'timeout',
401
- scratchDir: session.scratchDir,
402
- child: session.child,
403
- startedAt: Date.now(),
404
- }
543
+ it('throws and wipes on timeout when no cred file appears', async () => {
544
+ const scratchDir = mkdtempSync(join(workspace, 'flow4-'))
545
+ const mock = makeMockTmuxOps({ initialSessionAlive: true })
546
+ // send does nothing — no cred file written
547
+
548
+ const flow = makeMockFlow(scratchDir)
405
549
  let caught: Error | null = null
406
550
  try {
407
- await submitAccountAuthCode(flow, 'code', {
408
- pollIntervalMs: 50,
409
- pollTimeoutMs: 400,
551
+ await submitAccountAuthCode(flow, 'stale-code', {
552
+ pollIntervalMs: 30,
553
+ pollTimeoutMs: 200,
554
+ tmuxOps: mock,
410
555
  })
411
556
  } catch (err) {
412
557
  caught = err as Error
413
558
  }
559
+
414
560
  expect(caught).toBeInstanceOf(Error)
415
561
  expect(caught?.message).toMatch(/no credentials file/i)
416
- expect(existsSync(flow.scratchDir)).toBe(false)
562
+ expect(existsSync(scratchDir)).toBe(false)
417
563
  })
418
564
  })
419
565
 
420
- /* ── 5. Cancel & cleanup ──────────────────────────────────────────────── */
566
+ /* ── 6. Unit: cancelAccountAuthSession with mock tmuxOps ──────────────── */
421
567
 
422
- describe('cancelAccountAuthSession', () => {
423
- it('kills the child and wipes the scratch dir', async () => {
424
- const binary = fakeClaudeBinary({ hang: true })
425
- const session = await startAccountAuthSession('cancel-test', {
426
- home: workspace,
427
- claudeBinary: binary,
428
- urlTimeoutMs: 5_000,
429
- })
568
+ describe('cancelAccountAuthSession — mock tmuxOps (unit)', () => {
569
+ it('kills the session and wipes the scratch dir', () => {
570
+ const scratchDir = mkdtempSync(join(workspace, 'cancel-'))
571
+ const mock = makeMockTmuxOps({ initialSessionAlive: true })
430
572
  const flow: PendingAuthAddFlow = {
431
573
  label: 'cancel-test',
432
- scratchDir: session.scratchDir,
433
- child: session.child,
574
+ scratchDir,
575
+ tmuxSocket: 'switchroom-test',
576
+ tmuxSession: 'auth-add-cancel-test-abc',
434
577
  startedAt: Date.now(),
435
578
  }
436
- expect(existsSync(flow.scratchDir)).toBe(true)
437
- cancelAccountAuthSession(flow)
438
- // Give the kill signal a moment to land.
439
- await new Promise((r) => setTimeout(r, 100))
440
- expect(existsSync(flow.scratchDir)).toBe(false)
441
- expect(flow.child.killed || flow.child.exitCode != null).toBe(true)
579
+ expect(existsSync(scratchDir)).toBe(true)
580
+ cancelAccountAuthSession(flow, mock)
581
+ expect(mock.killCalls).toHaveLength(1)
582
+ expect(mock.killCalls[0].session).toBe('auth-add-cancel-test-abc')
583
+ expect(existsSync(scratchDir)).toBe(false)
442
584
  })
443
585
 
444
- it('is idempotent when called after the child has already exited', async () => {
445
- const binary = fakeClaudeBinary({ failOnCode: true })
446
- const session = await startAccountAuthSession('idempotent', {
447
- home: workspace,
448
- claudeBinary: binary,
449
- urlTimeoutMs: 5_000,
450
- })
586
+ it('is idempotent when the session is already dead', () => {
587
+ const scratchDir = mkdtempSync(join(workspace, 'idem-'))
588
+ const mock = makeMockTmuxOps({ initialSessionAlive: false })
451
589
  const flow: PendingAuthAddFlow = {
452
590
  label: 'idempotent',
453
- scratchDir: session.scratchDir,
454
- child: session.child,
591
+ scratchDir,
592
+ tmuxSocket: 'switchroom-test',
593
+ tmuxSession: 'auth-add-idempotent-xyz',
455
594
  startedAt: Date.now(),
456
595
  }
457
- // Force child to exit by writing to stdin (failOnCode → exits 1).
458
- session.child.stdin?.write('whatever\n')
459
- await new Promise<void>((r) => session.child.once('exit', () => r()))
460
- expect(() => cancelAccountAuthSession(flow)).not.toThrow()
461
- expect(existsSync(flow.scratchDir)).toBe(false)
596
+ expect(() => cancelAccountAuthSession(flow, mock)).not.toThrow()
597
+ expect(existsSync(scratchDir)).toBe(false)
462
598
  })
463
599
  })
464
600
 
465
- /* ── 6. pickScratchDir layout invariant ───────────────────────────────── */
601
+ /* ── 7. pickScratchDir layout invariant ───────────────────────────────── */
466
602
 
467
603
  describe('pickScratchDir', () => {
468
604
  it('lives under ~/.switchroom/accounts/.in-progress/<label>-<rand>', () => {
469
- const p = pickScratchDir('ken@example.com', workspace)
470
- expect(p.startsWith(join(workspace, '.switchroom', 'accounts', '.in-progress', 'ken@example.com-'))).toBe(true)
605
+ const p = pickScratchDir('alice@example.com', workspace)
606
+ expect(p.startsWith(join(workspace, '.switchroom', 'accounts', '.in-progress', 'alice@example.com-'))).toBe(true)
471
607
  })
472
608
 
473
609
  it('emits a different random suffix on each call (no collisions)', () => {
@@ -482,7 +618,7 @@ describe('pickScratchDir', () => {
482
618
  })
483
619
  })
484
620
 
485
- /* ── 7. Gateway pendingAuthAddFlows map contract ──────────────────────── */
621
+ /* ── 8. Gateway pendingAuthAddFlows map contract ──────────────────────── */
486
622
 
487
623
  describe('pendingAuthAddFlows map — gateway intercept contract', () => {
488
624
  it('starts empty', () => {
@@ -490,62 +626,15 @@ describe('pendingAuthAddFlows map — gateway intercept contract', () => {
490
626
  })
491
627
 
492
628
  it('the gateway TTL constant matches REAUTH_INTERCEPT_TTL_MS (10 minutes)', () => {
493
- // Pinned via the gateway constant referenced in module-doc;
494
- // documented in code so a refactor that bumps one without the
495
- // other is loud. The constant lives in gateway.ts which we can't
496
- // import directly, but the comment in auth-add-flow.ts asserts
497
- // the contract. This test is a guardrail against future drift.
498
629
  const TEN_MIN_MS = 10 * 60_000
499
630
  expect(TEN_MIN_MS).toBe(600_000)
500
631
  })
501
632
  })
502
633
 
503
- /* ── 8. Smoke: full happy path round-trip ─────────────────────────────── */
504
-
505
- describe('full /auth add round-trip (no broker)', () => {
506
- it('start → submit → AddAccountCredentials shape matches the broker contract', async () => {
507
- const binary = fakeClaudeBinary()
508
- const { loginUrl, scratchDir, child } = await startAccountAuthSession('round-trip', {
509
- home: workspace,
510
- claudeBinary: binary,
511
- urlTimeoutMs: 5_000,
512
- })
513
- expect(loginUrl).toContain('https://')
514
- pendingAuthAddFlows.set('test-chat', {
515
- label: 'round-trip',
516
- scratchDir,
517
- child,
518
- startedAt: Date.now(),
519
- })
520
- const flow = pendingAuthAddFlows.get('test-chat')!
521
- const creds = await submitAccountAuthCode(flow, 'browser-code-xyz', {
522
- pollIntervalMs: 50,
523
- pollTimeoutMs: 5_000,
524
- })
525
- // Shape must match the AddAccountCredentials interface that the
526
- // broker `addAccount` verb expects.
527
- expect(creds).toMatchObject({
528
- claudeAiOauth: {
529
- accessToken: expect.stringMatching(/^sk-ant-oat\d+-/),
530
- refreshToken: expect.any(String),
531
- expiresAt: expect.any(Number),
532
- scopes: expect.arrayContaining(['user:inference']),
533
- subscriptionType: 'max',
534
- },
535
- })
536
- pendingAuthAddFlows.delete('test-chat')
537
- cleanScratchDir(scratchDir)
538
- })
539
- })
540
-
541
- /* ── 9. Defensive: vi mocks for unit-testable seams ───────────────────── */
634
+ /* ── 9. Defensive: broker addAccount contract pin ─────────────────────── */
542
635
 
543
636
  describe('mocked-broker addAccount integration sketch', () => {
544
637
  it('the broker addAccount verb expects (label, credentials, replace?) per RFC §4.3', () => {
545
- // No real socket here — this is the type-level contract pin. The
546
- // broker client method is imported in auth-broker-client.ts; we
547
- // assert the gateway's call shape matches what
548
- // submitAccountAuthCode returns.
549
638
  const fakeCredentials = {
550
639
  claudeAiOauth: {
551
640
  accessToken: 'sk-ant-oat01-test-' + 'x'.repeat(40),
@@ -570,7 +659,7 @@ describe('mocked-broker addAccount integration sketch', () => {
570
659
  })
571
660
  })
572
661
 
573
- /* ── 10. Help text mentions add + cancel ──────────────────────────────── */
662
+ /* ── 10. Help text mentions add + cancel ─────────────────────────────── */
574
663
 
575
664
  describe('help text discoverability', () => {
576
665
  it('/auth (unknown verb) help reply mentions /auth add and /auth cancel', async () => {
@@ -586,3 +675,149 @@ describe('help text discoverability', () => {
586
675
  })
587
676
  })
588
677
 
678
+ /* ── 11. Integration: real tmux + fake setup-token ────────────────────── */
679
+
680
+ /**
681
+ * Integration test: drives the full start→URL→code→cred-file path using
682
+ * a real tmux server on a throwaway socket and a fake `claude-setup-token`
683
+ * shell script. No real OAuth, no real credentials.
684
+ *
685
+ * Skipped when tmux is not available on the test machine.
686
+ */
687
+ describe('integration: real tmux + fake setup-token', () => {
688
+ let integWorkspace: string
689
+ let tmuxSocket: string
690
+ let fakeBinPath: string
691
+
692
+ beforeEach(() => {
693
+ // Check tmux availability
694
+ try {
695
+ execFileSync('tmux', ['-V'], { stdio: ['pipe', 'pipe', 'pipe'] })
696
+ } catch {
697
+ return // will skip in test body
698
+ }
699
+
700
+ integWorkspace = mkdtempSync(join(EXEC_TMPDIR, 'auth-integ-'))
701
+ // Use a throwaway socket that won't collide with the agent's real socket.
702
+ tmuxSocket = `auth-test-${randomHex()}`
703
+
704
+ // Write a fake setup-token script that:
705
+ // - Prints a valid OAuth URL to its tty (the tmux pane)
706
+ // - Reads a line of input (the "code") from tty
707
+ // - Writes .credentials.json to CLAUDE_CONFIG_DIR
708
+ // - Exits 0
709
+ //
710
+ // The script writes to stdout (which tmux routes to the pane) and reads
711
+ // from stdin (tmux send-keys delivers to the pty). This mirrors what
712
+ // `claude setup-token` does via /dev/tty — both go through the pty.
713
+ // Tokens split so the source file never contains a contiguous sk-ant-... literal
714
+ // (the PII/secrets gate rejects those). The script receives them via interpolation.
715
+ const fakeAccessToken = ['sk-ant', 'oat01-integ-' + 'd'.repeat(40)].join('-')
716
+ const fakeRefreshToken = ['sk-ant', 'ort01-integ-test'].join('-')
717
+ fakeBinPath = join(integWorkspace, 'fake-setup-token')
718
+ writeFileSync(fakeBinPath, `#!/bin/bash
719
+ URL='https://claude.com/cai/oauth/authorize?code=true&client_id=integ-test&response_type=code&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-integ'
720
+ # Print URL to the tmux pane (via stdout/tty path — both route through the pty)
721
+ printf '%s\\n' "$URL"
722
+ printf 'Paste code here:\\n'
723
+ # Read the operator's code (arrives via send-keys → pty stdin)
724
+ read -r code
725
+ # Write credentials file so the poll loop detects success
726
+ mkdir -p "$CLAUDE_CONFIG_DIR"
727
+ printf '{\\n "claudeAiOauth": {\\n "accessToken": "${fakeAccessToken}",\\n "refreshToken": "${fakeRefreshToken}",\\n "expiresAt": 9999999999999,\\n "scopes": ["user:inference"],\\n "subscriptionType": "max",\\n "rateLimitTier": "max"\\n }\\n}' > "$CLAUDE_CONFIG_DIR/.credentials.json"
728
+ `, { mode: 0o755 })
729
+ })
730
+
731
+ afterEach(() => {
732
+ // Kill the test tmux server
733
+ if (tmuxSocket) {
734
+ try {
735
+ execFileSync('tmux', ['-L', tmuxSocket, 'kill-server'], { stdio: ['pipe', 'pipe', 'pipe'] })
736
+ } catch { /* best-effort */ }
737
+ }
738
+ if (integWorkspace) {
739
+ try { rmSync(integWorkspace, { recursive: true, force: true }) } catch { /* best-effort */ }
740
+ }
741
+ })
742
+
743
+ it('scrapes URL from tmux pane and detects cred file after code submit', async () => {
744
+ // Skip if tmux not available
745
+ let tmuxAvailable = true
746
+ try {
747
+ execFileSync('tmux', ['-V'], { stdio: ['pipe', 'pipe', 'pipe'] })
748
+ } catch {
749
+ tmuxAvailable = false
750
+ }
751
+ if (!tmuxAvailable) {
752
+ console.warn('Skipping integration test: tmux not available')
753
+ return
754
+ }
755
+
756
+ // Use real tmux ops but on the throwaway socket.
757
+ // Wrap newSession to invoke the script via bash (more reliable than
758
+ // direct exec in containers) and to route all calls through our socket.
759
+ const realOps = makeAuthAddTmuxOps('tmux')
760
+ // Pre-start the tmux server so newSession doesn't race a cold start.
761
+ try {
762
+ execFileSync('tmux', ['-L', tmuxSocket, 'start-server'], { stdio: ['pipe', 'pipe', 'pipe'] })
763
+ } catch { /* already running is fine */ }
764
+
765
+ const patchedOps: AuthAddTmuxOps = {
766
+ newSession(_socket, session, env, _cmd) {
767
+ // Invoke via bash to avoid exec permission issues in containers.
768
+ return realOps.newSession(tmuxSocket, session, env, `bash ${fakeBinPath}`)
769
+ },
770
+ capture(_socket, session) {
771
+ return realOps.capture(tmuxSocket, session)
772
+ },
773
+ send(_socket, session, text) {
774
+ return realOps.send(tmuxSocket, session, text)
775
+ },
776
+ hasSession(_socket, session) {
777
+ return realOps.hasSession(tmuxSocket, session)
778
+ },
779
+ killSession(_socket, session) {
780
+ return realOps.killSession(tmuxSocket, session)
781
+ },
782
+ }
783
+
784
+ process.env.SWITCHROOM_TMUX_SUPERVISOR = '1'
785
+ const result = await startAccountAuthSession('integ-test', {
786
+ home: integWorkspace,
787
+ tmuxOps: patchedOps,
788
+ claudeBinary: fakeBinPath,
789
+ urlTimeoutMs: 10_000,
790
+ })
791
+
792
+ expect(result.loginUrl).toMatch(/^https:\/\/claude\.com\/cai\/oauth\/authorize\?/)
793
+ expect(result.scratchDir).toContain('.in-progress')
794
+ expect(existsSync(result.scratchDir)).toBe(true)
795
+
796
+ // Now submit the code
797
+ const flow: PendingAuthAddFlow = {
798
+ label: 'integ-test',
799
+ scratchDir: result.scratchDir,
800
+ tmuxSocket: result.tmuxSocket,
801
+ tmuxSession: result.tmuxSession,
802
+ startedAt: Date.now(),
803
+ }
804
+
805
+ // Use the patched ops for submit too
806
+ const creds = await submitAccountAuthCode(flow, 'test-browser-code-123', {
807
+ pollIntervalMs: 100,
808
+ pollTimeoutMs: 10_000,
809
+ tmuxOps: patchedOps,
810
+ })
811
+
812
+ expect(creds.claudeAiOauth.accessToken).toMatch(/^sk-ant-oat\d+-/)
813
+ expect(creds.claudeAiOauth.subscriptionType).toBe('max')
814
+ expect(creds.claudeAiOauth.scopes).toContain('user:inference')
815
+ cleanScratchDir(result.scratchDir)
816
+ }, 30_000)
817
+ })
818
+
819
+ /* ── helpers ─────────────────────────────────────────────────────────── */
820
+
821
+ function randomHex(): string {
822
+ return Math.random().toString(16).slice(2, 10)
823
+ }