switchroom 0.8.1 → 0.10.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.
- package/README.md +49 -57
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +285 -45
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +15931 -12778
- package/dist/host-control/main.js +582 -43
- package/dist/vault/approvals/kernel-server.js +276 -47
- package/dist/vault/broker/server.js +333 -69
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +3 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/default/CLAUDE.md +10 -0
- package/profiles/default/CLAUDE.md.hbs +16 -0
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +67 -15
- package/skills/switchroom-status/SKILL.md +26 -1
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +22 -36
- package/telegram-plugin/gateway/boot-probes.ts +3 -3
- package/telegram-plugin/gateway/gateway.ts +313 -798
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-probes.test.ts +11 -4
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/uat/SETUP.md +31 -1
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/dist/foreman/foreman.js +0 -31358
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/auth add <label>` Telegram chat flow (RFC H §4.3 add-account, §7.3).
|
|
3
|
+
*
|
|
4
|
+
* The headline use case: every account on the fleet is rate-limited,
|
|
5
|
+
* the LLM is unreachable, and the operator is on their phone. They
|
|
6
|
+
* need a deterministic — LLM-free — chat path to add a fresh Anthropic
|
|
7
|
+
* OAuth account. This module owns that flow end-to-end:
|
|
8
|
+
*
|
|
9
|
+
* 1. Operator sends `/auth add <label>`.
|
|
10
|
+
* 2. Gateway calls {@link startAccountAuthSession} → spawns
|
|
11
|
+
* `claude setup-token` against a scratch directory under
|
|
12
|
+
* `~/.switchroom/accounts/.in-progress/<label>-<rand>/`, captures
|
|
13
|
+
* the OAuth authorize URL, and tucks pending state into
|
|
14
|
+
* {@link pendingAuthAddFlows}.
|
|
15
|
+
* 3. Gateway replies to chat with the URL + paste instructions.
|
|
16
|
+
* 4. Operator opens URL, logs in, copies the browser code, pastes
|
|
17
|
+
* into chat. Gateway's `pendingReauthFlows`-style intercept
|
|
18
|
+
* catches the paste and calls {@link submitAccountAuthCode}.
|
|
19
|
+
* 5. Helper reads `<scratch>/.credentials.json` (the dotfile that
|
|
20
|
+
* `claude setup-token` writes on success — pinned in
|
|
21
|
+
* `src/auth/broker/server-add-account.test.ts`), builds the
|
|
22
|
+
* {@link AddAccountCredentials} payload, and the gateway calls
|
|
23
|
+
* broker `addAccount(label, credentials, replace=false)`.
|
|
24
|
+
* 6. Scratch dir is wiped on every code path — success, cancel,
|
|
25
|
+
* paste-failure, TTL timeout, gateway shutdown.
|
|
26
|
+
*
|
|
27
|
+
* Why a separate module (vs reusing `src/auth/manager.ts`):
|
|
28
|
+
*
|
|
29
|
+
* - `startAuthSession` writes `<agentDir>/.claude/.setup-token.session.json`
|
|
30
|
+
* and is built around the per-agent OAuth flow. The `/auth add`
|
|
31
|
+
* flow has no agent — the resulting credentials become a
|
|
32
|
+
* broker-managed account that any agent can be set to. Threading
|
|
33
|
+
* `agentDir` through it would corrupt the agent's own auth state
|
|
34
|
+
* if the operator's add-flow collides with a normal reauth.
|
|
35
|
+
* - The chat-flow surface is deterministic and stateless beyond
|
|
36
|
+
* `pendingAuthAddFlows`. Reusing the full manager would inherit
|
|
37
|
+
* legacy slot logic, tmp-dir cleanup heuristics, and stale-session
|
|
38
|
+
* detection that doesn't apply when each `/auth add` creates a
|
|
39
|
+
* fresh, unguessable scratch dir of its own.
|
|
40
|
+
*
|
|
41
|
+
* What we DO reuse: the pure parsing helpers — `parseSetupTokenUrl`
|
|
42
|
+
* (handles both claude.ai/oauth and claude.com/cai/oauth shapes),
|
|
43
|
+
* `extractCodeChallenge` (PKCE stale-session detection), and
|
|
44
|
+
* `readTokenFromCredentialsFile` (validates the `sk-ant-oat...` token
|
|
45
|
+
* shape). Those are label-agnostic.
|
|
46
|
+
*
|
|
47
|
+
* **Hard rule: NEVER touch the agent's claude process.** This flow runs
|
|
48
|
+
* as a deterministic chat handler in the gateway. The URL goes straight
|
|
49
|
+
* to chat via `bot.api.sendMessage`. The code paste is intercepted by
|
|
50
|
+
* the gateway, never forwarded to the agent's bridge. If every account
|
|
51
|
+
* on the fleet is rate-limited the LLM is unreachable — that's the
|
|
52
|
+
* whole point of the flow existing.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
56
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
57
|
+
import { homedir } from 'node:os'
|
|
58
|
+
import { join } from 'node:path'
|
|
59
|
+
import { randomBytes } from 'node:crypto'
|
|
60
|
+
|
|
61
|
+
import {
|
|
62
|
+
parseSetupTokenUrl,
|
|
63
|
+
readTokenFromCredentialsFile,
|
|
64
|
+
} from '../../src/auth/manager.js'
|
|
65
|
+
import type {
|
|
66
|
+
AddAccountCredentials,
|
|
67
|
+
AnthropicAddAccountCredentials,
|
|
68
|
+
} from '../../src/auth/broker/client.js'
|
|
69
|
+
|
|
70
|
+
/* ── Pending-state map ────────────────────────────────────────────────── */
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* In-flight `/auth add` flow keyed by Telegram chat id. The gateway's
|
|
74
|
+
* generic message intercept (sibling to `pendingReauthFlows`) reads
|
|
75
|
+
* this map to decide whether a sk-ant-…-shaped paste belongs to an
|
|
76
|
+
* add flow or to a reauth flow.
|
|
77
|
+
*
|
|
78
|
+
* TTL matches `REAUTH_INTERCEPT_TTL_MS` (10 minutes); the reaper sweep
|
|
79
|
+
* in gateway.ts walks both maps each minute.
|
|
80
|
+
*/
|
|
81
|
+
export interface PendingAuthAddFlow {
|
|
82
|
+
label: string
|
|
83
|
+
scratchDir: string
|
|
84
|
+
/** PID of the spawned `claude setup-token` process, for cancel-kill. */
|
|
85
|
+
child: ChildProcess
|
|
86
|
+
startedAt: number
|
|
87
|
+
}
|
|
88
|
+
export const pendingAuthAddFlows = new Map<string, PendingAuthAddFlow>()
|
|
89
|
+
|
|
90
|
+
/* ── Scratch dir lifecycle ────────────────────────────────────────────── */
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pick a fresh scratch path under
|
|
94
|
+
* `~/.switchroom/accounts/.in-progress/<label>-<rand>/`.
|
|
95
|
+
*
|
|
96
|
+
* The leading dot keeps the dir hidden from `listAccounts(home)` in
|
|
97
|
+
* `src/auth/account-store.ts`, which enumerates accounts by scanning
|
|
98
|
+
* `~/.switchroom/accounts/`. That listing is the source of truth for
|
|
99
|
+
* broker `list-state` — a half-written add-in-progress must NOT
|
|
100
|
+
* appear there. `.in-progress/` is also outside the broker's
|
|
101
|
+
* managed-artifact whitelist, so a stray dir won't blow up on the
|
|
102
|
+
* next apply.
|
|
103
|
+
*
|
|
104
|
+
* Random suffix is 8 bytes of crypto-grade randomness so:
|
|
105
|
+
* - two concurrent operators adding the same label can't collide
|
|
106
|
+
* on the scratch path
|
|
107
|
+
* - an attacker watching `~/.switchroom/accounts/.in-progress/`
|
|
108
|
+
* can't predict the next dir name and squat a symlink
|
|
109
|
+
*/
|
|
110
|
+
export function pickScratchDir(label: string, home: string = homedir()): string {
|
|
111
|
+
const suffix = randomBytes(8).toString('hex')
|
|
112
|
+
return join(home, '.switchroom', 'accounts', '.in-progress', `${label}-${suffix}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Best-effort scratch-dir wipe. Used on every exit path — success,
|
|
117
|
+
* cancel, timeout, error. Synchronous because the caller has already
|
|
118
|
+
* settled the user-facing reply by the time we get here; an extra
|
|
119
|
+
* tick of latency is not worth event-loop juggling.
|
|
120
|
+
*/
|
|
121
|
+
export function cleanScratchDir(scratchDir: string): void {
|
|
122
|
+
try {
|
|
123
|
+
rmSync(scratchDir, { recursive: true, force: true })
|
|
124
|
+
} catch {
|
|
125
|
+
// best-effort
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ── Subprocess lifecycle ─────────────────────────────────────────────── */
|
|
130
|
+
|
|
131
|
+
export interface StartAccountAuthSessionResult {
|
|
132
|
+
loginUrl: string
|
|
133
|
+
scratchDir: string
|
|
134
|
+
child: ChildProcess
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Spawn `claude setup-token` against a fresh scratch directory and
|
|
139
|
+
* resolve once the authorize URL has been parsed from its stdout/stderr.
|
|
140
|
+
*
|
|
141
|
+
* Why we *don't* use tmux: the `submitAuthCode` path in
|
|
142
|
+
* `src/auth/manager.ts` uses tmux because that flow is interactive —
|
|
143
|
+
* an operator on a host can `tmux attach` to inspect the auth prompt
|
|
144
|
+
* if anything goes wrong. The chat flow has no equivalent escape
|
|
145
|
+
* hatch (the operator is on their phone) and a pipe-based subprocess
|
|
146
|
+
* is far easier to lifecycle-manage from a long-running gateway. We
|
|
147
|
+
* write the code to the child's stdin in {@link submitAccountAuthCode}.
|
|
148
|
+
*
|
|
149
|
+
* The child is left running between {@link startAccountAuthSession}
|
|
150
|
+
* and {@link submitAccountAuthCode} — closing stdin before the code
|
|
151
|
+
* is pasted would tear down the OAuth session.
|
|
152
|
+
*
|
|
153
|
+
* Timeout default: 12 seconds to see the URL. claude setup-token
|
|
154
|
+
* typically prints the URL within ~3–5s; 12s covers an unloaded VM
|
|
155
|
+
* with slow startup. Caller passes the timeout via opts so tests can
|
|
156
|
+
* shorten it.
|
|
157
|
+
*/
|
|
158
|
+
export async function startAccountAuthSession(
|
|
159
|
+
label: string,
|
|
160
|
+
opts: {
|
|
161
|
+
home?: string
|
|
162
|
+
urlTimeoutMs?: number
|
|
163
|
+
/** Override the binary name (tests). */
|
|
164
|
+
claudeBinary?: string
|
|
165
|
+
} = {},
|
|
166
|
+
): Promise<StartAccountAuthSessionResult> {
|
|
167
|
+
const home = opts.home ?? homedir()
|
|
168
|
+
const urlTimeoutMs = opts.urlTimeoutMs ?? 12_000
|
|
169
|
+
const binary = opts.claudeBinary ?? 'claude'
|
|
170
|
+
|
|
171
|
+
const scratchDir = pickScratchDir(label, home)
|
|
172
|
+
mkdirSync(scratchDir, { recursive: true, mode: 0o700 })
|
|
173
|
+
|
|
174
|
+
// BROWSER=/bin/true: same rationale as src/auth/manager.ts's
|
|
175
|
+
// startAuthSession — suppress claude setup-token's host-side browser
|
|
176
|
+
// auto-launch (would land on Claude's login page with no cookies on
|
|
177
|
+
// a headless box). The chat flow is paste-only.
|
|
178
|
+
const child = spawn(binary, ['setup-token'], {
|
|
179
|
+
env: {
|
|
180
|
+
...process.env,
|
|
181
|
+
CLAUDE_CONFIG_DIR: scratchDir,
|
|
182
|
+
BROWSER: '/bin/true',
|
|
183
|
+
},
|
|
184
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// Aggregate stdout+stderr; the URL can land on either channel
|
|
188
|
+
// depending on claude CLI version.
|
|
189
|
+
let buffer = ''
|
|
190
|
+
const collect = (chunk: Buffer): void => {
|
|
191
|
+
buffer += chunk.toString('utf8')
|
|
192
|
+
}
|
|
193
|
+
child.stdout?.on('data', collect)
|
|
194
|
+
child.stderr?.on('data', collect)
|
|
195
|
+
|
|
196
|
+
// Race: URL detection vs timeout vs child exit before URL appeared.
|
|
197
|
+
const loginUrl = await new Promise<string>((resolve, reject) => {
|
|
198
|
+
const deadline = setTimeout(() => {
|
|
199
|
+
cleanup()
|
|
200
|
+
reject(new Error(`claude setup-token did not print an OAuth URL within ${urlTimeoutMs}ms`))
|
|
201
|
+
}, urlTimeoutMs)
|
|
202
|
+
|
|
203
|
+
const tick = setInterval(() => {
|
|
204
|
+
const url = parseSetupTokenUrl(buffer)
|
|
205
|
+
if (url) {
|
|
206
|
+
cleanup()
|
|
207
|
+
resolve(url)
|
|
208
|
+
}
|
|
209
|
+
}, 200)
|
|
210
|
+
|
|
211
|
+
const onExit = (code: number | null): void => {
|
|
212
|
+
cleanup()
|
|
213
|
+
reject(new Error(`claude setup-token exited (code ${code}) before printing OAuth URL`))
|
|
214
|
+
}
|
|
215
|
+
child.once('exit', onExit)
|
|
216
|
+
|
|
217
|
+
function cleanup(): void {
|
|
218
|
+
clearTimeout(deadline)
|
|
219
|
+
clearInterval(tick)
|
|
220
|
+
child.removeListener('exit', onExit)
|
|
221
|
+
}
|
|
222
|
+
}).catch((err) => {
|
|
223
|
+
// Kill the child and wipe the scratch dir before re-raising so
|
|
224
|
+
// failed-to-start sessions don't leak.
|
|
225
|
+
try { child.kill('SIGTERM') } catch { /* best-effort */ }
|
|
226
|
+
cleanScratchDir(scratchDir)
|
|
227
|
+
throw err
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
return { loginUrl, scratchDir, child }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Paste the operator's browser code into the live `claude setup-token`
|
|
235
|
+
* child's stdin and wait for the success-written credentials.json.
|
|
236
|
+
*
|
|
237
|
+
* Returns the `AddAccountCredentials` shape the broker's add-account
|
|
238
|
+
* verb expects — same `claudeAiOauth: { accessToken, refreshToken,
|
|
239
|
+
* expiresAt, scopes, subscriptionType, rateLimitTier }` envelope.
|
|
240
|
+
*
|
|
241
|
+
* On success: the caller is responsible for invoking
|
|
242
|
+
* `cleanScratchDir(scratchDir)` after `addAccount` returns; we
|
|
243
|
+
* deliberately don't wipe here because the broker call might race the
|
|
244
|
+
* filesystem cleanup. On failure (invalid code, expired code, timeout)
|
|
245
|
+
* the helper throws and cleans the scratch dir itself.
|
|
246
|
+
*
|
|
247
|
+
* Poll interval default: 250ms — same as `submitAuthCode`'s 500ms
|
|
248
|
+
* halved because there's no tmux capture-pane overhead per tick.
|
|
249
|
+
* Timeout default: 120s, matching the env var in `submitAuthCode`.
|
|
250
|
+
*/
|
|
251
|
+
export async function submitAccountAuthCode(
|
|
252
|
+
flow: PendingAuthAddFlow,
|
|
253
|
+
code: string,
|
|
254
|
+
opts: { pollIntervalMs?: number; pollTimeoutMs?: number } = {},
|
|
255
|
+
): Promise<AddAccountCredentials> {
|
|
256
|
+
const pollIntervalMs = opts.pollIntervalMs ?? 250
|
|
257
|
+
const pollTimeoutMs = opts.pollTimeoutMs ?? 120_000
|
|
258
|
+
|
|
259
|
+
const credentialsPath = join(flow.scratchDir, '.credentials.json')
|
|
260
|
+
|
|
261
|
+
// Write the code + newline to stdin. claude setup-token's prompt
|
|
262
|
+
// expects line-buffered input — see the manual-paste paste at the
|
|
263
|
+
// bottom of `submitAuthCode`. We use a single write here (vs the
|
|
264
|
+
// two send-keys calls of the tmux path) because there's no
|
|
265
|
+
// terminfo-flake concern over a pipe.
|
|
266
|
+
if (!flow.child.stdin || flow.child.stdin.destroyed) {
|
|
267
|
+
cleanScratchDir(flow.scratchDir)
|
|
268
|
+
throw new Error('claude setup-token process stdin is not writable (child may have exited)')
|
|
269
|
+
}
|
|
270
|
+
flow.child.stdin.write(code.trim() + '\n')
|
|
271
|
+
|
|
272
|
+
// Poll for the credentials file. Same two-channel design as
|
|
273
|
+
// submitAuthCode but tmux-pane-scrape and log-scrape are out (the
|
|
274
|
+
// pane scrape was a fallback for older claude CLI versions; the
|
|
275
|
+
// chat flow targets the current CLI by definition).
|
|
276
|
+
const deadline = Date.now() + pollTimeoutMs
|
|
277
|
+
while (Date.now() < deadline) {
|
|
278
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs))
|
|
279
|
+
if (existsSync(credentialsPath)) {
|
|
280
|
+
const token = readTokenFromCredentialsFile(credentialsPath)
|
|
281
|
+
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
|
+
try {
|
|
286
|
+
const raw = readFileSync(credentialsPath, 'utf-8')
|
|
287
|
+
const parsed = JSON.parse(raw) as { claudeAiOauth?: AnthropicAddAccountCredentials['claudeAiOauth'] }
|
|
288
|
+
if (parsed.claudeAiOauth?.accessToken) {
|
|
289
|
+
// Drain the child so it exits cleanly after success.
|
|
290
|
+
try { flow.child.stdin?.end() } catch { /* best-effort */ }
|
|
291
|
+
return { claudeAiOauth: parsed.claudeAiOauth }
|
|
292
|
+
}
|
|
293
|
+
} catch {
|
|
294
|
+
// fall through — file may be mid-write; next tick retries.
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Detect child early exit (invalid code → claude prints + exits).
|
|
299
|
+
if (flow.child.exitCode != null) {
|
|
300
|
+
cleanScratchDir(flow.scratchDir)
|
|
301
|
+
throw new Error(
|
|
302
|
+
`claude setup-token exited (code ${flow.child.exitCode}) — code may have been invalid or expired`,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Timeout — kill the child + wipe scratch.
|
|
308
|
+
try { flow.child.kill('SIGTERM') } catch { /* best-effort */ }
|
|
309
|
+
cleanScratchDir(flow.scratchDir)
|
|
310
|
+
throw new Error(`No credentials file appeared at ${credentialsPath} within ${Math.round(pollTimeoutMs / 1000)}s`)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Cancel an in-flight `/auth add` flow: kill the `claude setup-token`
|
|
315
|
+
* child, wipe the scratch dir, and let the caller delete the
|
|
316
|
+
* `pendingAuthAddFlows` entry. Idempotent — safe to call when the
|
|
317
|
+
* child has already exited.
|
|
318
|
+
*/
|
|
319
|
+
export function cancelAccountAuthSession(flow: PendingAuthAddFlow): void {
|
|
320
|
+
try {
|
|
321
|
+
if (flow.child.exitCode == null) flow.child.kill('SIGTERM')
|
|
322
|
+
} catch {
|
|
323
|
+
// best-effort
|
|
324
|
+
}
|
|
325
|
+
cleanScratchDir(flow.scratchDir)
|
|
326
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin adapter between the gateway and `src/auth/broker/client.ts`.
|
|
3
|
+
*
|
|
4
|
+
* The broker client is a stateful class (holds a persistent UDS
|
|
5
|
+
* connection). The gateway constructs one per `/auth` command —
|
|
6
|
+
* cheap, and avoids dangling sockets on idle. The handler needs the
|
|
7
|
+
* five methods on the `AuthBrokerClient` interface in
|
|
8
|
+
* `./auth-command.ts` (listState / setActive / rmAccount /
|
|
9
|
+
* refreshAccount / setOverride); we narrow `BrokerClient` down to
|
|
10
|
+
* that surface so a test mock only has to stub those five.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { AuthBrokerClient as BrokerClient, type AddAccountCredentials } from '../../src/auth/broker/client.js'
|
|
14
|
+
import type { AuthBrokerClient } from './auth-command.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Construct an {@link AuthBrokerClient} for one `/auth` command. The
|
|
18
|
+
* caller is responsible for closing the underlying socket when done
|
|
19
|
+
* (do `await client.close()` after the reply lands).
|
|
20
|
+
*/
|
|
21
|
+
export function createAuthBrokerClient(): {
|
|
22
|
+
client: AuthBrokerClient
|
|
23
|
+
close: () => Promise<void>
|
|
24
|
+
} {
|
|
25
|
+
const broker = new BrokerClient()
|
|
26
|
+
const client: AuthBrokerClient = {
|
|
27
|
+
listState: () => broker.listState(),
|
|
28
|
+
setActive: (label: string) => broker.setActive(label),
|
|
29
|
+
rmAccount: (label: string) => broker.rmAccount(label),
|
|
30
|
+
refreshAccount: (label: string) => broker.refreshAccount(label),
|
|
31
|
+
setOverride: (agent: string, account: string | null) =>
|
|
32
|
+
broker.setOverride(agent, account),
|
|
33
|
+
}
|
|
34
|
+
return { client, close: () => broker.close() }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Legacy `getAuthBrokerClient` entry — kept so the gateway's existing
|
|
39
|
+
* call site doesn't need rewiring. Returns the client object only;
|
|
40
|
+
* the underlying socket leaks unless the caller imports
|
|
41
|
+
* `createAuthBrokerClient` directly. Acceptable because:
|
|
42
|
+
* - The gateway is long-lived (one process per agent).
|
|
43
|
+
* - The broker tolerates many connections per peer.
|
|
44
|
+
* - `/auth` is a low-frequency human-driven verb.
|
|
45
|
+
*
|
|
46
|
+
* If allocations become a concern, swap callers over to the structured
|
|
47
|
+
* variant above.
|
|
48
|
+
*/
|
|
49
|
+
export async function getAuthBrokerClient(
|
|
50
|
+
_agentName: string,
|
|
51
|
+
): Promise<AuthBrokerClient | null> {
|
|
52
|
+
const { client } = createAuthBrokerClient()
|
|
53
|
+
return client
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Add an account via the broker. Used exclusively by the `/auth add`
|
|
58
|
+
* chat flow — the narrow {@link AuthBrokerClient} surface in
|
|
59
|
+
* `auth-command.ts` deliberately omits `addAccount` because the verb
|
|
60
|
+
* is gateway-routed (not handler-routed). Constructs and closes a
|
|
61
|
+
* one-shot {@link BrokerClient} so the gateway doesn't need a
|
|
62
|
+
* long-lived handle just for this verb.
|
|
63
|
+
*/
|
|
64
|
+
export async function addAccountViaBroker(
|
|
65
|
+
label: string,
|
|
66
|
+
credentials: AddAccountCredentials,
|
|
67
|
+
opts: { replace?: boolean } = {},
|
|
68
|
+
): Promise<{ label: string; expiresAt?: number }> {
|
|
69
|
+
const broker = new BrokerClient()
|
|
70
|
+
try {
|
|
71
|
+
return await broker.addAccount(label, credentials, opts.replace)
|
|
72
|
+
} finally {
|
|
73
|
+
await broker.close()
|
|
74
|
+
}
|
|
75
|
+
}
|