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.
- package/dist/agent-scheduler/index.js +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3249 -1241
- package/dist/cli/ui/index.html +1 -1
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7482 -7439
- package/dist/vault/broker/server.js +11315 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +3 -22
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- 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.
|
|
13
|
-
*
|
|
14
|
-
* 5. Code paste-back: `submitAccountAuthCode`
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
19
|
+
* 7. Cancel kills the tmux session + wipes the scratch dir.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
-
*
|
|
94
|
-
* -
|
|
95
|
-
*
|
|
96
|
-
* -
|
|
97
|
-
*
|
|
98
|
-
* -
|
|
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
|
-
*
|
|
101
|
-
*
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
164
|
-
expect(p).toEqual({ kind: 'add', label: '
|
|
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
|
|
169
|
-
expect(p).toEqual({ kind: 'add', label: '
|
|
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
|
-
'
|
|
220
|
-
'
|
|
221
|
-
'
|
|
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.
|
|
287
|
+
/* ── 3. SWITCHROOM_TMUX_SUPERVISOR guard ──────────────────────────────── */
|
|
283
288
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
309
|
-
const
|
|
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('
|
|
380
|
+
await startAccountAuthSession('timeout-case', {
|
|
315
381
|
home: workspace,
|
|
316
|
-
|
|
317
|
-
urlTimeoutMs:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
/* ──
|
|
419
|
+
/* ── 5. Unit: submitAccountAuthCode with mock tmuxOps ────────────────── */
|
|
336
420
|
|
|
337
|
-
describe('submitAccountAuthCode', () => {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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('
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
urlTimeoutMs:
|
|
472
|
+
tmuxOps: mock,
|
|
473
|
+
urlTimeoutMs: 3_000,
|
|
371
474
|
})
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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, '
|
|
381
|
-
pollIntervalMs:
|
|
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(
|
|
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
|
|
393
|
-
const
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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:
|
|
409
|
-
pollTimeoutMs:
|
|
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(
|
|
562
|
+
expect(existsSync(scratchDir)).toBe(false)
|
|
417
563
|
})
|
|
418
564
|
})
|
|
419
565
|
|
|
420
|
-
/* ──
|
|
566
|
+
/* ── 6. Unit: cancelAccountAuthSession with mock tmuxOps ──────────────── */
|
|
421
567
|
|
|
422
|
-
describe('cancelAccountAuthSession', () => {
|
|
423
|
-
it('kills the
|
|
424
|
-
const
|
|
425
|
-
const
|
|
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
|
|
433
|
-
|
|
574
|
+
scratchDir,
|
|
575
|
+
tmuxSocket: 'switchroom-test',
|
|
576
|
+
tmuxSession: 'auth-add-cancel-test-abc',
|
|
434
577
|
startedAt: Date.now(),
|
|
435
578
|
}
|
|
436
|
-
expect(existsSync(
|
|
437
|
-
cancelAccountAuthSession(flow)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
expect(existsSync(
|
|
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
|
|
445
|
-
const
|
|
446
|
-
const
|
|
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
|
|
454
|
-
|
|
591
|
+
scratchDir,
|
|
592
|
+
tmuxSocket: 'switchroom-test',
|
|
593
|
+
tmuxSession: 'auth-add-idempotent-xyz',
|
|
455
594
|
startedAt: Date.now(),
|
|
456
595
|
}
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
/* ──
|
|
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('
|
|
470
|
-
expect(p.startsWith(join(workspace, '.switchroom', 'accounts', '.in-progress', '
|
|
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
|
-
/* ──
|
|
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
|
-
/* ──
|
|
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
|
+
}
|