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.
Files changed (105) hide show
  1. package/README.md +49 -57
  2. package/bin/timezone-hook.sh +9 -7
  3. package/dist/agent-scheduler/index.js +285 -45
  4. package/dist/auth-broker/index.js +13932 -0
  5. package/dist/cli/switchroom.js +15931 -12778
  6. package/dist/host-control/main.js +582 -43
  7. package/dist/vault/approvals/kernel-server.js +276 -47
  8. package/dist/vault/broker/server.js +333 -69
  9. package/examples/minimal.yaml +63 -0
  10. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  11. package/examples/personal-google-workspace-mcp/README.md +194 -0
  12. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  13. package/examples/switchroom.yaml +220 -0
  14. package/package.json +6 -4
  15. package/profiles/_base/start.sh.hbs +3 -3
  16. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  17. package/profiles/default/CLAUDE.md +10 -0
  18. package/profiles/default/CLAUDE.md.hbs +16 -0
  19. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  20. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  21. package/skills/buildkite-api/SKILL.md +31 -8
  22. package/skills/buildkite-cli/SKILL.md +27 -9
  23. package/skills/buildkite-migration/SKILL.md +22 -9
  24. package/skills/buildkite-pipelines/SKILL.md +26 -9
  25. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  26. package/skills/buildkite-test-engine/SKILL.md +25 -8
  27. package/skills/docx/SKILL.md +1 -1
  28. package/skills/file-bug/SKILL.md +34 -6
  29. package/skills/humanizer/SKILL.md +15 -0
  30. package/skills/humanizer-calibrate/SKILL.md +7 -1
  31. package/skills/mcp-builder/SKILL.md +1 -1
  32. package/skills/pdf/SKILL.md +1 -1
  33. package/skills/pptx/SKILL.md +1 -1
  34. package/skills/skill-creator/SKILL.md +21 -1
  35. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  36. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  41. package/skills/switchroom-cli/SKILL.md +63 -64
  42. package/skills/switchroom-health/SKILL.md +23 -10
  43. package/skills/switchroom-install/SKILL.md +3 -3
  44. package/skills/switchroom-manage/SKILL.md +26 -19
  45. package/skills/switchroom-runtime/SKILL.md +67 -15
  46. package/skills/switchroom-status/SKILL.md +26 -1
  47. package/skills/telegram-test-harness/SKILL.md +3 -0
  48. package/skills/webapp-testing/SKILL.md +31 -1
  49. package/skills/xlsx/SKILL.md +1 -1
  50. package/telegram-plugin/admin-commands/index.ts +7 -5
  51. package/telegram-plugin/dist/gateway/gateway.js +13042 -12844
  52. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  53. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  54. package/telegram-plugin/gateway/auth-command.ts +794 -0
  55. package/telegram-plugin/gateway/auth-line.ts +123 -0
  56. package/telegram-plugin/gateway/boot-card.ts +22 -36
  57. package/telegram-plugin/gateway/boot-probes.ts +3 -3
  58. package/telegram-plugin/gateway/gateway.ts +313 -798
  59. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  60. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  61. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  62. package/telegram-plugin/permission-title.ts +56 -0
  63. package/telegram-plugin/quota-check.ts +19 -41
  64. package/telegram-plugin/scripts/build.mjs +0 -1
  65. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  66. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  67. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  68. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  69. package/telegram-plugin/tests/boot-probes.test.ts +11 -4
  70. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  71. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  72. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  73. package/telegram-plugin/uat/SETUP.md +31 -1
  74. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  75. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  76. package/telegram-plugin/uat/runners/report.ts +150 -0
  77. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  78. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  79. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  80. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  81. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  82. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  83. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  84. package/telegram-plugin/auth-dashboard.ts +0 -1104
  85. package/telegram-plugin/auth-slot-parser.ts +0 -497
  86. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  87. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  88. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  89. package/telegram-plugin/foreman/foreman.ts +0 -1165
  90. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  91. package/telegram-plugin/foreman/setup-state.ts +0 -239
  92. package/telegram-plugin/foreman/state.ts +0 -203
  93. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  94. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  95. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  96. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  97. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  98. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  99. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  100. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  101. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  102. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  103. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  104. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  105. 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
+ }