switchroom 0.8.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -61
- 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/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +8890 -5560
- 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/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +9 -5
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- 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 +905 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +23 -37
- package/telegram-plugin/gateway/boot-probes.ts +9 -12
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +1156 -938
- package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/model-unavailable.ts +28 -12
- 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/silence-poke.ts +153 -1
- 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-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +27 -22
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +35 -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/auto-fallback-dispatcher.ts +0 -68
- 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/auto-fallback-dispatcher.e2e.test.ts +0 -183
- 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,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-entry guard + dedup window for fleet-wide auto-fallback.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of gateway.ts so the dedup state is constructable per-test
|
|
5
|
+
* (gateway.ts module state was hard to reach from vitest — every test
|
|
6
|
+
* shared the same in-flight Promise + last-fired timestamp).
|
|
7
|
+
*
|
|
8
|
+
* Contract (the "honesty contract" from PR #1317 review):
|
|
9
|
+
*
|
|
10
|
+
* `wouldFire()` is the SYNCHRONOUS read the model-unavailable card
|
|
11
|
+
* uses to decide whether to advertise "Auto-failover in progress".
|
|
12
|
+
* It MUST agree with the dispatcher's actual behavior — otherwise
|
|
13
|
+
* the card lies (claims a swap is coming when the dispatcher will
|
|
14
|
+
* dedup-drop or bail).
|
|
15
|
+
*
|
|
16
|
+
* Three reasons `wouldFire()` returns false:
|
|
17
|
+
* 1. A swap is already in flight (collapse concurrent fires).
|
|
18
|
+
* 2. The post-trigger dedup window is still active (the user
|
|
19
|
+
* already saw a swap announcement; another one would oscillate).
|
|
20
|
+
* 3. The broker is unreachable — the dispatcher would just bail
|
|
21
|
+
* with `reason=no-broker-client`, leaving the card to lie.
|
|
22
|
+
* Optional: only checked when `brokerReachable` is supplied.
|
|
23
|
+
*
|
|
24
|
+
* `markFired()` is called ONLY on actual swaps (kind: 'switched').
|
|
25
|
+
* No-ops (no broker, no eligible target, idempotent skip) DO NOT
|
|
26
|
+
* arm the suppression window — otherwise a transient hiccup blocks
|
|
27
|
+
* the next 30s of legitimate fires.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface FleetFallbackGateOptions {
|
|
31
|
+
/** Suppression window in ms after a successful swap. */
|
|
32
|
+
dedupMs: number;
|
|
33
|
+
/** Time source (overridable in tests). */
|
|
34
|
+
nowFn?: () => number;
|
|
35
|
+
/**
|
|
36
|
+
* Synchronous probe of broker reachability. Optional. Returning false
|
|
37
|
+
* makes `wouldFire()` return false so the card stays honest about a
|
|
38
|
+
* fire that would otherwise bail in the dispatcher.
|
|
39
|
+
*
|
|
40
|
+
* Synchronous on purpose: `wouldFire()` runs on the card-render path
|
|
41
|
+
* and must not block. A connection-cached flag (e.g. a UDS reachability
|
|
42
|
+
* check populated by a background heartbeat) fits this shape.
|
|
43
|
+
*/
|
|
44
|
+
brokerReachable?: () => boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FleetFallbackGate {
|
|
48
|
+
/** True iff a fresh fire would actually invoke the dispatcher. */
|
|
49
|
+
wouldFire(): boolean;
|
|
50
|
+
/** Run a fire-and-forget action under the gate. Collapses concurrent
|
|
51
|
+
* callers to the same in-flight Promise. The action's resolved value
|
|
52
|
+
* controls whether the dedup window arms (true = arm, false = skip).
|
|
53
|
+
* Caller-thrown errors are swallowed (logged via `onError`). */
|
|
54
|
+
fire(action: () => Promise<boolean>, onError?: (err: unknown) => void): Promise<void>;
|
|
55
|
+
/** Test seam — reset to fresh state. Production code should not call this. */
|
|
56
|
+
reset(): void;
|
|
57
|
+
/** Test/debug — current internal state. */
|
|
58
|
+
inspect(): { inFlight: boolean; lastFiredAtMs: number };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createFleetFallbackGate(opts: FleetFallbackGateOptions): FleetFallbackGate {
|
|
62
|
+
const nowFn = opts.nowFn ?? (() => Date.now());
|
|
63
|
+
let inFlight: Promise<void> | null = null;
|
|
64
|
+
// -Infinity = never fired. Concrete number = wall-clock ms of the
|
|
65
|
+
// last actual swap. Sentinel matters in tests (fake clocks at t=0
|
|
66
|
+
// would otherwise look like "just fired" and falsely arm dedup).
|
|
67
|
+
let lastFiredAtMs = Number.NEGATIVE_INFINITY;
|
|
68
|
+
|
|
69
|
+
function wouldFire(): boolean {
|
|
70
|
+
if (inFlight) return false;
|
|
71
|
+
if (nowFn() - lastFiredAtMs < opts.dedupMs) return false;
|
|
72
|
+
if (opts.brokerReachable && !opts.brokerReachable()) return false;
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fire(action: () => Promise<boolean>, onError?: (err: unknown) => void): Promise<void> {
|
|
77
|
+
if (inFlight) return inFlight;
|
|
78
|
+
if (nowFn() - lastFiredAtMs < opts.dedupMs) return Promise.resolve();
|
|
79
|
+
if (opts.brokerReachable && !opts.brokerReachable()) return Promise.resolve();
|
|
80
|
+
|
|
81
|
+
inFlight = (async () => {
|
|
82
|
+
try {
|
|
83
|
+
const didSwap = await action();
|
|
84
|
+
if (didSwap) lastFiredAtMs = nowFn();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (onError) onError(err);
|
|
87
|
+
} finally {
|
|
88
|
+
inFlight = null;
|
|
89
|
+
}
|
|
90
|
+
})();
|
|
91
|
+
return inFlight;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
wouldFire,
|
|
96
|
+
fire,
|
|
97
|
+
reset() {
|
|
98
|
+
inFlight = null;
|
|
99
|
+
lastFiredAtMs = Number.NEGATIVE_INFINITY;
|
|
100
|
+
},
|
|
101
|
+
inspect() {
|
|
102
|
+
return { inFlight: inFlight !== null, lastFiredAtMs };
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `buildGrantedKeyboard` — the post-tap inline-keyboard
|
|
3
|
+
* surfaced on a granted approval card (RFC E §4.3 — granted-card
|
|
4
|
+
* confirmations gain the [ 📖 Open in Drive ] deep-link button).
|
|
5
|
+
*
|
|
6
|
+
* Scope-driven and pure, so the test runs without mocking grammy's
|
|
7
|
+
* Context or the approval kernel. The full handler in
|
|
8
|
+
* `approval-callback.ts` glues this onto the consumed-scope payload
|
|
9
|
+
* the kernel returns; the routing decision lives entirely in this
|
|
10
|
+
* builder.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
import { InlineKeyboard } from "grammy";
|
|
15
|
+
import { buildGrantedKeyboard } from "./approval-callback.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Helper — pull the `[{text, url}]` rows out of a grammy InlineKeyboard
|
|
19
|
+
* so we can assert without poking into its internal shape too hard.
|
|
20
|
+
*/
|
|
21
|
+
function rows(kb: InlineKeyboard): Array<Array<{ text: string; url?: string }>> {
|
|
22
|
+
return kb.inline_keyboard.map((row) =>
|
|
23
|
+
row.map((btn) => ({
|
|
24
|
+
text: btn.text,
|
|
25
|
+
...("url" in btn ? { url: btn.url } : {}),
|
|
26
|
+
})),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("buildGrantedKeyboard — Drive scopes", () => {
|
|
31
|
+
it("emits Open-in-Drive for a single-doc grant", () => {
|
|
32
|
+
const kb = buildGrantedKeyboard("doc:gdrive:D1");
|
|
33
|
+
expect(kb).toBeDefined();
|
|
34
|
+
expect(rows(kb!)).toEqual([
|
|
35
|
+
[
|
|
36
|
+
{
|
|
37
|
+
text: "📖 Open in Drive",
|
|
38
|
+
url: "https://drive.google.com/file/d/D1/view",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("emits Open-in-Drive for a folder grant (canonical folder URL)", () => {
|
|
45
|
+
const kb = buildGrantedKeyboard("doc:gdrive:folder/F1/**");
|
|
46
|
+
expect(kb).toBeDefined();
|
|
47
|
+
expect(rows(kb!)).toEqual([
|
|
48
|
+
[
|
|
49
|
+
{
|
|
50
|
+
text: "📖 Open in Drive",
|
|
51
|
+
url: "https://drive.google.com/drive/folders/F1",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("emits Open-in-Drive for write-namespace grants on a single doc", () => {
|
|
58
|
+
const kb = buildGrantedKeyboard("doc:gdrive:write:D1");
|
|
59
|
+
expect(kb).toBeDefined();
|
|
60
|
+
expect(rows(kb!)).toEqual([
|
|
61
|
+
[
|
|
62
|
+
{
|
|
63
|
+
text: "📖 Open in Drive",
|
|
64
|
+
url: "https://drive.google.com/file/d/D1/view",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("emits Open-in-Drive for suggest-namespace folder grants", () => {
|
|
71
|
+
const kb = buildGrantedKeyboard("doc:gdrive:suggest:folder/F1/**");
|
|
72
|
+
expect(kb).toBeDefined();
|
|
73
|
+
expect(rows(kb!)).toEqual([
|
|
74
|
+
[
|
|
75
|
+
{
|
|
76
|
+
text: "📖 Open in Drive",
|
|
77
|
+
url: "https://drive.google.com/drive/folders/F1",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("buildGrantedKeyboard — no button cases", () => {
|
|
85
|
+
it("returns undefined for the whole-Drive grant (no specific artifact)", () => {
|
|
86
|
+
expect(buildGrantedKeyboard("doc:gdrive:**")).toBeUndefined();
|
|
87
|
+
expect(buildGrantedKeyboard("doc:gdrive:suggest:**")).toBeUndefined();
|
|
88
|
+
expect(buildGrantedKeyboard("doc:gdrive:write:**")).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns undefined for non-Drive scopes (secrets, system, vault)", () => {
|
|
92
|
+
expect(buildGrantedKeyboard("secret:OPENAI_API_KEY")).toBeUndefined();
|
|
93
|
+
expect(buildGrantedKeyboard("system:reconnect:gdrive")).toBeUndefined();
|
|
94
|
+
expect(buildGrantedKeyboard("vault:read:gdrive:klanker:refresh_token")).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns undefined for unparseable Drive scopes (defense in depth)", () => {
|
|
98
|
+
// A folder id containing a slash slips past prefix matching but is
|
|
99
|
+
// rejected by parseDriveScope's id-charset check — the granted-card
|
|
100
|
+
// edit MUST NOT render a URL button derived from such a string.
|
|
101
|
+
expect(buildGrantedKeyboard("doc:gdrive:folder/abc/def/**")).toBeUndefined();
|
|
102
|
+
expect(buildGrantedKeyboard("doc:gdrive:write:abc?evil=1")).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -21,13 +21,31 @@
|
|
|
21
21
|
* approval_lookup (RFC §10) to discover the outcome and proceed.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import type
|
|
24
|
+
import { type Context, InlineKeyboard } from "grammy";
|
|
25
25
|
import { parseApprovalCallback, ttlMsFromToken } from "./approval-card.js";
|
|
26
26
|
import {
|
|
27
27
|
approvalConsume,
|
|
28
28
|
approvalRecord,
|
|
29
29
|
} from "../../src/vault/approvals/client.js";
|
|
30
30
|
import type { ApprovalDecisionMode } from "../../src/vault/approvals/schema.js";
|
|
31
|
+
import { scopeToOpenInDriveButton } from "../../src/drive/deep-links.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the post-tap keyboard for a granted decision. Today this is
|
|
35
|
+
* just the `[ 📖 Open in Drive ]` button when the granted scope names
|
|
36
|
+
* a specific Drive doc or folder (RFC E §4.3 — granted-card
|
|
37
|
+
* confirmations gain the deep-link). Returns `undefined` when no
|
|
38
|
+
* post-tap keyboard applies, which the gateway translates into
|
|
39
|
+
* `reply_markup: undefined` to strip the original action buttons.
|
|
40
|
+
*
|
|
41
|
+
* Pure / scope-driven — no kernel I/O — so it stays unit-testable
|
|
42
|
+
* without mocking grammy's Context.
|
|
43
|
+
*/
|
|
44
|
+
export function buildGrantedKeyboard(scope: string): InlineKeyboard | undefined {
|
|
45
|
+
const btn = scopeToOpenInDriveButton(scope);
|
|
46
|
+
if (btn === null) return undefined;
|
|
47
|
+
return new InlineKeyboard().url(btn.text, btn.url);
|
|
48
|
+
}
|
|
31
49
|
|
|
32
50
|
export async function handleApprovalCallback(
|
|
33
51
|
ctx: Context,
|
|
@@ -109,7 +127,10 @@ export async function handleApprovalCallback(
|
|
|
109
127
|
return;
|
|
110
128
|
}
|
|
111
129
|
|
|
112
|
-
// Edit the original card to its post-tap state
|
|
130
|
+
// Edit the original card to its post-tap state. Drop the original
|
|
131
|
+
// action keyboard either way; on a successful grant for a Drive
|
|
132
|
+
// scope, surface `[ 📖 Open in Drive ]` so the user can jump
|
|
133
|
+
// straight from "agent has access" to the doc (RFC E §4.3).
|
|
113
134
|
const icon = granted ? "✅" : "🚫";
|
|
114
135
|
const newBody =
|
|
115
136
|
`${icon} ${displayMode}` +
|
|
@@ -117,8 +138,15 @@ export async function handleApprovalCallback(
|
|
|
117
138
|
? ` · /approvals revoke <code>${decision_id}</code>`
|
|
118
139
|
: "");
|
|
119
140
|
|
|
141
|
+
const postTapKeyboard = granted && consumed.scope
|
|
142
|
+
? buildGrantedKeyboard(consumed.scope)
|
|
143
|
+
: undefined;
|
|
144
|
+
|
|
120
145
|
try {
|
|
121
|
-
await ctx.editMessageText(newBody, {
|
|
146
|
+
await ctx.editMessageText(newBody, {
|
|
147
|
+
parse_mode: "HTML",
|
|
148
|
+
reply_markup: postTapKeyboard,
|
|
149
|
+
});
|
|
122
150
|
} catch {
|
|
123
151
|
// Best-effort: card may have been edited or deleted under us.
|
|
124
152
|
}
|
|
@@ -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
|
+
}
|