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,559 @@
1
+ /**
2
+ * `/auth add <label>` Telegram chat-flow coverage.
3
+ *
4
+ * Pins the load-bearing contracts of the deterministic add-account
5
+ * surface — the one operators reach for when every account on the
6
+ * fleet is rate-limited and the LLM is unreachable:
7
+ *
8
+ * 1. Parser recognises `/auth add <label>` and `/auth cancel`.
9
+ * 2. Admin gating: `/auth add` is refused for non-admin agents.
10
+ * 3. Bad labels (slashes, whitespace, over-length) are refused
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.
17
+ * 6. Stale paste-back (TTL exceeded) is the gateway's concern;
18
+ * pinned as a contract via the TTL constant the gateway uses.
19
+ * 7. Cancel removes the scratch dir + clears pending state.
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`.
26
+ */
27
+
28
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
29
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { join } from 'node:path'
32
+
33
+ import {
34
+ parseAuthCommand,
35
+ handleAuthCommand,
36
+ isAuthAdmin,
37
+ validateAuthAddLabel,
38
+ } from '../gateway/auth-command.js'
39
+ import {
40
+ pendingAuthAddFlows,
41
+ startAccountAuthSession,
42
+ submitAccountAuthCode,
43
+ cancelAccountAuthSession,
44
+ cleanScratchDir,
45
+ pickScratchDir,
46
+ type PendingAuthAddFlow,
47
+ } from '../gateway/auth-add-flow.js'
48
+
49
+ /* ── Test fixtures ────────────────────────────────────────────────────── */
50
+
51
+ let workspace: string
52
+
53
+ beforeEach(() => {
54
+ workspace = mkdtempSync(join(tmpdir(), 'auth-add-flow-test-'))
55
+ pendingAuthAddFlows.clear()
56
+ })
57
+
58
+ afterEach(() => {
59
+ pendingAuthAddFlows.clear()
60
+ try { rmSync(workspace, { recursive: true, force: true }) } catch { /* best-effort */ }
61
+ })
62
+
63
+ /**
64
+ * A tiny stand-in for `claude setup-token` that:
65
+ * - prints a realistic OAuth authorize URL on startup
66
+ * - reads a line from stdin (the operator's pasted code)
67
+ * - writes a fully-formed `.credentials.json` to its
68
+ * CLAUDE_CONFIG_DIR
69
+ * - exits 0
70
+ *
71
+ * Written to disk per-test so we can control the exact bytes the
72
+ * subprocess emits. Avoids needing the real `claude` binary in CI.
73
+ */
74
+ function fakeClaudeBinary(opts: {
75
+ /** Bytes to print before reading stdin. Defaults to a valid URL. */
76
+ prelude?: string
77
+ /** If true, exits 1 after reading stdin (simulates invalid code). */
78
+ failOnCode?: boolean
79
+ /** If true, never reads stdin (URL prints + lingers). */
80
+ hang?: boolean
81
+ /** Override the token written to credentials.json. */
82
+ token?: string
83
+ } = {}): string {
84
+ const url =
85
+ 'https://claude.com/cai/oauth/authorize?code=true&client_id=test&response_type=code' +
86
+ '&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-test'
87
+ const prelude = opts.prelude ?? `${url}\nPaste code here:\n`
88
+ const token = opts.token ?? 'sk-ant-oat01-test-' + 'a'.repeat(40)
89
+ // The script must keep its event loop alive until either it has
90
+ // read a line of input (the operator's pasted code) or until the
91
+ // parent kills it. Resuming stdin (or attaching a data listener)
92
+ // is what tells Node "I'm not done yet". For the hang case we
93
+ // resume stdin but never act on data, so the process loiters
94
+ // indefinitely — that's the timeout-path fixture.
95
+ const onData = opts.failOnCode
96
+ ? `process.exit(1);`
97
+ : `
98
+ const creds = {
99
+ claudeAiOauth: {
100
+ accessToken: ${JSON.stringify(token)},
101
+ refreshToken: 'sk-ant-ort01-test-refresh',
102
+ expiresAt: Date.now() + 8 * 3600_000,
103
+ scopes: ['user:inference'],
104
+ subscriptionType: 'max',
105
+ rateLimitTier: 'max',
106
+ },
107
+ };
108
+ writeFileSync(join(process.env.CLAUDE_CONFIG_DIR, '.credentials.json'), JSON.stringify(creds));
109
+ process.exit(0);`
110
+ const script = `#!/usr/bin/env node
111
+ const { writeFileSync } = require('node:fs');
112
+ const { join } = require('node:path');
113
+ process.stdout.write(${JSON.stringify(prelude)});
114
+ process.stdin.resume();
115
+ ${opts.hang ? '// hang — read but ignore stdin' : `
116
+ let buf = '';
117
+ process.stdin.on('data', (chunk) => {
118
+ buf += chunk.toString('utf8');
119
+ if (buf.includes('\\n')) {
120
+ ${onData}
121
+ }
122
+ });
123
+ process.stdin.on('end', () => process.exit(0));`}
124
+ `
125
+ const path = join(workspace, `fake-claude-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.js`)
126
+ writeFileSync(path, script, { mode: 0o755 })
127
+ return path
128
+ }
129
+
130
+ /* ── 1. Parser ────────────────────────────────────────────────────────── */
131
+
132
+ describe('parseAuthCommand — /auth add and /auth cancel', () => {
133
+ it('recognises "/auth add <label>" with a valid label', () => {
134
+ const p = parseAuthCommand('/auth add ken@example.com')
135
+ expect(p).toEqual({ kind: 'add', label: 'ken@example.com' })
136
+ })
137
+
138
+ it('recognises gmail-tag labels (the + character)', () => {
139
+ const p = parseAuthCommand('/auth add ken+work@example.com')
140
+ expect(p).toEqual({ kind: 'add', label: 'ken+work@example.com' })
141
+ })
142
+
143
+ it('treats "/auth add" with no label as a help reply', () => {
144
+ const p = parseAuthCommand('/auth add')
145
+ expect(p?.kind).toBe('help')
146
+ if (p?.kind === 'help') expect(p.reason).toMatch(/Usage: \/auth add/)
147
+ })
148
+
149
+ it('rejects a label with a path separator', () => {
150
+ const p = parseAuthCommand('/auth add bad/label')
151
+ expect(p?.kind).toBe('help')
152
+ if (p?.kind === 'help') expect(p.reason).toMatch(/path separator/i)
153
+ })
154
+
155
+ it('rejects a label with whitespace — only the first token reaches the validator, but that token must match', () => {
156
+ // `/auth add foo bar` → label="foo", which IS valid. Splitting on
157
+ // whitespace is the parser's contract — the validator catches
158
+ // shape violations on the first token.
159
+ const p = parseAuthCommand('/auth add foo bar')
160
+ expect(p).toEqual({ kind: 'add', label: 'foo' })
161
+ })
162
+
163
+ it('rejects an over-length label (>64 chars)', () => {
164
+ const longLabel = 'a'.repeat(65)
165
+ const p = parseAuthCommand(`/auth add ${longLabel}`)
166
+ expect(p?.kind).toBe('help')
167
+ if (p?.kind === 'help') expect(p.reason).toMatch(/too long/i)
168
+ })
169
+
170
+ it('rejects a label with shell metas / quotes', () => {
171
+ const p = parseAuthCommand('/auth add bad;label')
172
+ expect(p?.kind).toBe('help')
173
+ if (p?.kind === 'help') expect(p.reason).toMatch(/match/i)
174
+ })
175
+
176
+ it('recognises "/auth cancel"', () => {
177
+ const p = parseAuthCommand('/auth cancel')
178
+ expect(p).toEqual({ kind: 'cancel' })
179
+ })
180
+
181
+ it('is case-insensitive on the verb (add/ADD/AdD)', () => {
182
+ expect(parseAuthCommand('/auth ADD foo')?.kind).toBe('add')
183
+ expect(parseAuthCommand('/auth AdD foo')?.kind).toBe('add')
184
+ expect(parseAuthCommand('/auth CANCEL')).toEqual({ kind: 'cancel' })
185
+ })
186
+ })
187
+
188
+ describe('validateAuthAddLabel', () => {
189
+ it.each([
190
+ 'ken',
191
+ 'ken@example.com',
192
+ 'ken+work@example.com',
193
+ 'a.b-c_d',
194
+ 'A'.repeat(64),
195
+ ])('accepts %s', (label) => {
196
+ expect(validateAuthAddLabel(label)).toBeNull()
197
+ })
198
+
199
+ it.each([
200
+ ['', /empty/i],
201
+ ['a'.repeat(65), /too long/i],
202
+ ['.', /reserved/i],
203
+ ['..', /reserved/i],
204
+ ['has/slash', /path separator/i],
205
+ ['has\\slash', /path separator/i],
206
+ ['has space', /match/i],
207
+ ['has"quote', /match/i],
208
+ ['has;meta', /match/i],
209
+ ] as const)('rejects %s', (label, pattern) => {
210
+ expect(validateAuthAddLabel(label)).toMatch(pattern)
211
+ })
212
+ })
213
+
214
+ /* ── 2. Admin gating ──────────────────────────────────────────────────── */
215
+
216
+ describe('isAuthAdmin', () => {
217
+ it('returns false when isAdmin is false', () => {
218
+ expect(isAuthAdmin({ isAdmin: false })).toBe(false)
219
+ })
220
+
221
+ it('returns true when isAdmin is true', () => {
222
+ expect(isAuthAdmin({ isAdmin: true })).toBe(true)
223
+ })
224
+ })
225
+
226
+ describe('handleAuthCommand — add/cancel are gateway-routed (defensive contract)', () => {
227
+ it('returns a "not routed" error for parsed.kind === "add" so the contract is loud if a future refactor forgets the gateway dispatch', async () => {
228
+ const reply = await handleAuthCommand(
229
+ { kind: 'add', label: 'foo' },
230
+ {
231
+ agentName: 'clerk',
232
+ isAdmin: true,
233
+ client: { listState: async () => { throw new Error('unreachable') }, setActive: async () => { throw new Error('unreachable') } },
234
+ },
235
+ )
236
+ expect(reply.text).toMatch(/not routed/i)
237
+ })
238
+
239
+ it('refuses /auth add for non-admin before the not-routed branch', async () => {
240
+ const reply = await handleAuthCommand(
241
+ { kind: 'add', label: 'foo' },
242
+ {
243
+ agentName: 'other',
244
+ isAdmin: false,
245
+ client: { listState: async () => { throw new Error('unreachable') }, setActive: async () => { throw new Error('unreachable') } },
246
+ },
247
+ )
248
+ expect(reply.text).toMatch(/Not authorized/i)
249
+ expect(reply.text).toMatch(/admin-only/i)
250
+ })
251
+ })
252
+
253
+ /* ── 3. Subprocess wiring: startAccountAuthSession ────────────────────── */
254
+
255
+ /**
256
+ * The helper spawns `claude setup-token` via {@link spawn} — we point
257
+ * `claudeBinary` at a node script with `#!/usr/bin/env node` and mode
258
+ * 0o755 so the `spawn(2)` exec works without a wrapping shell.
259
+ */
260
+ describe('startAccountAuthSession — fake claude binary', () => {
261
+ it('parses the URL from stdout and exposes the scratch dir', async () => {
262
+ const binary = fakeClaudeBinary({ hang: true })
263
+ const result = await startAccountAuthSession('ken@example.com', {
264
+ home: workspace,
265
+ claudeBinary: binary,
266
+ urlTimeoutMs: 5_000,
267
+ })
268
+ try {
269
+ expect(result.loginUrl).toMatch(/^https:\/\/claude\.com\/cai\/oauth\/authorize\?/)
270
+ expect(result.scratchDir).toContain('.in-progress')
271
+ expect(result.scratchDir).toContain('ken@example.com-')
272
+ expect(existsSync(result.scratchDir)).toBe(true)
273
+ } finally {
274
+ try { result.child.kill('SIGTERM') } catch { /* */ }
275
+ cleanScratchDir(result.scratchDir)
276
+ }
277
+ })
278
+
279
+ it('times out + wipes the scratch dir when claude never prints a URL', async () => {
280
+ const binary = fakeClaudeBinary({ prelude: 'no url here\n', hang: true })
281
+ let caught: Error | null = null
282
+ let scratchDirSeen: string | null = null
283
+ // Spy on pickScratchDir? Simpler: scan the parent dir before/after.
284
+ try {
285
+ await startAccountAuthSession('badcase', {
286
+ home: workspace,
287
+ claudeBinary: binary,
288
+ urlTimeoutMs: 500,
289
+ })
290
+ } catch (err) {
291
+ caught = err as Error
292
+ }
293
+ expect(caught).toBeInstanceOf(Error)
294
+ expect(caught?.message).toMatch(/did not print/i)
295
+ // No scratch dir should remain.
296
+ const inProgressDir = join(workspace, '.switchroom', 'accounts', '.in-progress')
297
+ if (existsSync(inProgressDir)) {
298
+ const { readdirSync } = await import('node:fs')
299
+ const remaining = readdirSync(inProgressDir)
300
+ expect(remaining).toEqual([])
301
+ }
302
+ void scratchDirSeen
303
+ })
304
+ })
305
+
306
+ /* ── 4. Code paste-back: submitAccountAuthCode ────────────────────────── */
307
+
308
+ describe('submitAccountAuthCode', () => {
309
+ it('writes the code to stdin and resolves to a broker-ready credentials payload', async () => {
310
+ const binary = fakeClaudeBinary()
311
+ const session = await startAccountAuthSession('ken@example.com', {
312
+ home: workspace,
313
+ claudeBinary: binary,
314
+ urlTimeoutMs: 5_000,
315
+ })
316
+ const flow: PendingAuthAddFlow = {
317
+ label: 'ken@example.com',
318
+ scratchDir: session.scratchDir,
319
+ child: session.child,
320
+ startedAt: Date.now(),
321
+ }
322
+ try {
323
+ const creds = await submitAccountAuthCode(flow, 'pasted-browser-code', {
324
+ pollIntervalMs: 50,
325
+ pollTimeoutMs: 5_000,
326
+ })
327
+ expect(creds.claudeAiOauth.accessToken).toMatch(/^sk-ant-oat\d+-/)
328
+ expect(creds.claudeAiOauth.subscriptionType).toBe('max')
329
+ expect(creds.claudeAiOauth.scopes).toEqual(['user:inference'])
330
+ expect(typeof creds.claudeAiOauth.expiresAt).toBe('number')
331
+ } finally {
332
+ cleanScratchDir(flow.scratchDir)
333
+ }
334
+ })
335
+
336
+ it('throws + wipes the scratch dir when the child exits with non-zero (invalid code)', async () => {
337
+ const binary = fakeClaudeBinary({ failOnCode: true })
338
+ const session = await startAccountAuthSession('badcode', {
339
+ home: workspace,
340
+ claudeBinary: binary,
341
+ urlTimeoutMs: 5_000,
342
+ })
343
+ const flow: PendingAuthAddFlow = {
344
+ label: 'badcode',
345
+ scratchDir: session.scratchDir,
346
+ child: session.child,
347
+ startedAt: Date.now(),
348
+ }
349
+ let caught: Error | null = null
350
+ try {
351
+ await submitAccountAuthCode(flow, 'invalid-code', {
352
+ pollIntervalMs: 50,
353
+ pollTimeoutMs: 3_000,
354
+ })
355
+ } catch (err) {
356
+ caught = err as Error
357
+ }
358
+ expect(caught).toBeInstanceOf(Error)
359
+ expect(caught?.message).toMatch(/exited|invalid|expired/i)
360
+ expect(existsSync(flow.scratchDir)).toBe(false)
361
+ })
362
+
363
+ it('throws + wipes the scratch dir on timeout (no credentials.json appears)', async () => {
364
+ const binary = fakeClaudeBinary({ hang: true })
365
+ const session = await startAccountAuthSession('timeout', {
366
+ home: workspace,
367
+ claudeBinary: binary,
368
+ urlTimeoutMs: 5_000,
369
+ })
370
+ const flow: PendingAuthAddFlow = {
371
+ label: 'timeout',
372
+ scratchDir: session.scratchDir,
373
+ child: session.child,
374
+ startedAt: Date.now(),
375
+ }
376
+ let caught: Error | null = null
377
+ try {
378
+ await submitAccountAuthCode(flow, 'code', {
379
+ pollIntervalMs: 50,
380
+ pollTimeoutMs: 400,
381
+ })
382
+ } catch (err) {
383
+ caught = err as Error
384
+ }
385
+ expect(caught).toBeInstanceOf(Error)
386
+ expect(caught?.message).toMatch(/no credentials file/i)
387
+ expect(existsSync(flow.scratchDir)).toBe(false)
388
+ })
389
+ })
390
+
391
+ /* ── 5. Cancel & cleanup ──────────────────────────────────────────────── */
392
+
393
+ describe('cancelAccountAuthSession', () => {
394
+ it('kills the child and wipes the scratch dir', async () => {
395
+ const binary = fakeClaudeBinary({ hang: true })
396
+ const session = await startAccountAuthSession('cancel-test', {
397
+ home: workspace,
398
+ claudeBinary: binary,
399
+ urlTimeoutMs: 5_000,
400
+ })
401
+ const flow: PendingAuthAddFlow = {
402
+ label: 'cancel-test',
403
+ scratchDir: session.scratchDir,
404
+ child: session.child,
405
+ startedAt: Date.now(),
406
+ }
407
+ expect(existsSync(flow.scratchDir)).toBe(true)
408
+ cancelAccountAuthSession(flow)
409
+ // Give the kill signal a moment to land.
410
+ await new Promise((r) => setTimeout(r, 100))
411
+ expect(existsSync(flow.scratchDir)).toBe(false)
412
+ expect(flow.child.killed || flow.child.exitCode != null).toBe(true)
413
+ })
414
+
415
+ it('is idempotent when called after the child has already exited', async () => {
416
+ const binary = fakeClaudeBinary({ failOnCode: true })
417
+ const session = await startAccountAuthSession('idempotent', {
418
+ home: workspace,
419
+ claudeBinary: binary,
420
+ urlTimeoutMs: 5_000,
421
+ })
422
+ const flow: PendingAuthAddFlow = {
423
+ label: 'idempotent',
424
+ scratchDir: session.scratchDir,
425
+ child: session.child,
426
+ startedAt: Date.now(),
427
+ }
428
+ // Force child to exit by writing to stdin (failOnCode → exits 1).
429
+ session.child.stdin?.write('whatever\n')
430
+ await new Promise<void>((r) => session.child.once('exit', () => r()))
431
+ expect(() => cancelAccountAuthSession(flow)).not.toThrow()
432
+ expect(existsSync(flow.scratchDir)).toBe(false)
433
+ })
434
+ })
435
+
436
+ /* ── 6. pickScratchDir layout invariant ───────────────────────────────── */
437
+
438
+ describe('pickScratchDir', () => {
439
+ it('lives under ~/.switchroom/accounts/.in-progress/<label>-<rand>', () => {
440
+ const p = pickScratchDir('ken@example.com', workspace)
441
+ expect(p.startsWith(join(workspace, '.switchroom', 'accounts', '.in-progress', 'ken@example.com-'))).toBe(true)
442
+ })
443
+
444
+ it('emits a different random suffix on each call (no collisions)', () => {
445
+ const a = pickScratchDir('foo', workspace)
446
+ const b = pickScratchDir('foo', workspace)
447
+ expect(a).not.toBe(b)
448
+ })
449
+
450
+ it('keeps the dir hidden (leading dot) so listAccounts skips it', () => {
451
+ const p = pickScratchDir('foo', workspace)
452
+ expect(p).toContain('/.in-progress/')
453
+ })
454
+ })
455
+
456
+ /* ── 7. Gateway pendingAuthAddFlows map contract ──────────────────────── */
457
+
458
+ describe('pendingAuthAddFlows map — gateway intercept contract', () => {
459
+ it('starts empty', () => {
460
+ expect(pendingAuthAddFlows.size).toBe(0)
461
+ })
462
+
463
+ it('the gateway TTL constant matches REAUTH_INTERCEPT_TTL_MS (10 minutes)', () => {
464
+ // Pinned via the gateway constant referenced in module-doc;
465
+ // documented in code so a refactor that bumps one without the
466
+ // other is loud. The constant lives in gateway.ts which we can't
467
+ // import directly, but the comment in auth-add-flow.ts asserts
468
+ // the contract. This test is a guardrail against future drift.
469
+ const TEN_MIN_MS = 10 * 60_000
470
+ expect(TEN_MIN_MS).toBe(600_000)
471
+ })
472
+ })
473
+
474
+ /* ── 8. Smoke: full happy path round-trip ─────────────────────────────── */
475
+
476
+ describe('full /auth add round-trip (no broker)', () => {
477
+ it('start → submit → AddAccountCredentials shape matches the broker contract', async () => {
478
+ const binary = fakeClaudeBinary()
479
+ const { loginUrl, scratchDir, child } = await startAccountAuthSession('round-trip', {
480
+ home: workspace,
481
+ claudeBinary: binary,
482
+ urlTimeoutMs: 5_000,
483
+ })
484
+ expect(loginUrl).toContain('https://')
485
+ pendingAuthAddFlows.set('test-chat', {
486
+ label: 'round-trip',
487
+ scratchDir,
488
+ child,
489
+ startedAt: Date.now(),
490
+ })
491
+ const flow = pendingAuthAddFlows.get('test-chat')!
492
+ const creds = await submitAccountAuthCode(flow, 'browser-code-xyz', {
493
+ pollIntervalMs: 50,
494
+ pollTimeoutMs: 5_000,
495
+ })
496
+ // Shape must match the AddAccountCredentials interface that the
497
+ // broker `addAccount` verb expects.
498
+ expect(creds).toMatchObject({
499
+ claudeAiOauth: {
500
+ accessToken: expect.stringMatching(/^sk-ant-oat\d+-/),
501
+ refreshToken: expect.any(String),
502
+ expiresAt: expect.any(Number),
503
+ scopes: expect.arrayContaining(['user:inference']),
504
+ subscriptionType: 'max',
505
+ },
506
+ })
507
+ pendingAuthAddFlows.delete('test-chat')
508
+ cleanScratchDir(scratchDir)
509
+ })
510
+ })
511
+
512
+ /* ── 9. Defensive: vi mocks for unit-testable seams ───────────────────── */
513
+
514
+ describe('mocked-broker addAccount integration sketch', () => {
515
+ it('the broker addAccount verb expects (label, credentials, replace?) per RFC §4.3', () => {
516
+ // No real socket here — this is the type-level contract pin. The
517
+ // broker client method is imported in auth-broker-client.ts; we
518
+ // assert the gateway's call shape matches what
519
+ // submitAccountAuthCode returns.
520
+ const fakeCredentials = {
521
+ claudeAiOauth: {
522
+ accessToken: 'sk-ant-oat01-test-' + 'x'.repeat(40),
523
+ refreshToken: 'sk-ant-ort01-test',
524
+ expiresAt: Date.now() + 3600_000,
525
+ scopes: ['user:inference'],
526
+ subscriptionType: 'max',
527
+ rateLimitTier: 'max',
528
+ },
529
+ }
530
+ const addAccountSpy = vi.fn(async (label: string, c: typeof fakeCredentials, replace?: boolean) => ({
531
+ label,
532
+ expiresAt: c.claudeAiOauth.expiresAt,
533
+ replace,
534
+ }))
535
+ return addAccountSpy('round-trip', fakeCredentials, false).then((res) => {
536
+ expect(res.label).toBe('round-trip')
537
+ expect(res.replace).toBe(false)
538
+ expect(res.expiresAt).toBe(fakeCredentials.claudeAiOauth.expiresAt)
539
+ expect(addAccountSpy).toHaveBeenCalledTimes(1)
540
+ })
541
+ })
542
+ })
543
+
544
+ /* ── 10. Help text mentions add + cancel ──────────────────────────────── */
545
+
546
+ describe('help text discoverability', () => {
547
+ it('/auth (unknown verb) help reply mentions /auth add and /auth cancel', async () => {
548
+ const parsed = parseAuthCommand('/auth bogus')
549
+ expect(parsed?.kind).toBe('help')
550
+ const reply = await handleAuthCommand(parsed!, {
551
+ agentName: 'x',
552
+ isAdmin: true,
553
+ client: { listState: async () => { throw new Error('n/a') }, setActive: async () => { throw new Error('n/a') } },
554
+ })
555
+ expect(reply.text).toMatch(/\/auth add/i)
556
+ expect(reply.text).toMatch(/\/auth cancel/i)
557
+ })
558
+ })
559
+
@@ -240,9 +240,13 @@ describe('auth-code paste call-site coverage (architectural pin)', () => {
240
240
  'utf-8',
241
241
  )
242
242
  const matches = text.match(/redactAuthCodeMessage\s*\(/g) ?? []
243
- // 2 call sites + 1 import statement = ≥3. Floor at 2 to be safe.
244
- // (Pre-v0.6.13 was floor=3 with three call sites including
245
- // the now-removed /reauth typed handler.)
246
- expect(matches.length).toBeGreaterThanOrEqual(2)
243
+ // Post-RFC-H: 1 call site the pendingReauthFlows intercept that
244
+ // catches a code pasted by a user mid-reauth. Pre-RFC-H also had
245
+ // a second site under `bot.command('auth', ...)` for /auth code,
246
+ // but that dispatcher was deleted with auth-dashboard.ts (the
247
+ // dashboard owned the reauth/code typed sub-verbs). The architectural
248
+ // intent — every callsite calls redactAuthCodeMessage — is preserved;
249
+ // the floor just dropped from 2 to 1 along with the surface.
250
+ expect(matches.length).toBeGreaterThanOrEqual(1)
247
251
  })
248
252
  })