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