switchroom 0.10.0 → 0.11.1
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 +5 -4
- package/dist/agent-scheduler/index.js +2 -2
- package/dist/auth-broker/index.js +125 -3
- package/dist/cli/drive-write-pretool.mjs +5436 -0
- package/dist/cli/switchroom.js +231 -29
- package/dist/host-control/main.js +2 -2
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +2 -0
- 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 +4314 -2143
- 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-broker-client.ts +2 -0
- package/telegram-plugin/gateway/auth-command.ts +131 -10
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +1 -1
- package/telegram-plugin/gateway/boot-probes.ts +6 -9
- 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 +903 -173
- package/telegram-plugin/gateway/hostd-dispatch.ts +137 -2
- 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/model-unavailable.ts +28 -12
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -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 +16 -18
- 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/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 +16 -12
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/hostd-dispatch.test.ts +0 -129
|
@@ -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
|
}
|
|
@@ -30,6 +30,8 @@ export function createAuthBrokerClient(): {
|
|
|
30
30
|
refreshAccount: (label: string) => broker.refreshAccount(label),
|
|
31
31
|
setOverride: (agent: string, account: string | null) =>
|
|
32
32
|
broker.setOverride(agent, account),
|
|
33
|
+
probeQuota: (accounts: readonly string[], timeoutMs?: number) =>
|
|
34
|
+
broker.probeQuota(accounts, timeoutMs),
|
|
33
35
|
}
|
|
34
36
|
return { client, close: () => broker.close() }
|
|
35
37
|
}
|
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import type { ListStateData, AccountState } from './auth-line.js'
|
|
32
|
+
import {
|
|
33
|
+
buildSnapshotsFromState,
|
|
34
|
+
renderAuthSnapshotFormat2,
|
|
35
|
+
buildSnapshotKeyboard,
|
|
36
|
+
} from '../auth-snapshot-format.js'
|
|
32
37
|
|
|
33
38
|
// ─── Parser ────────────────────────────────────────────────────────────────
|
|
34
39
|
|
|
@@ -215,6 +220,16 @@ export interface AuthBrokerClient {
|
|
|
215
220
|
agent: string,
|
|
216
221
|
account: string | null,
|
|
217
222
|
): Promise<{ agent: string; account: string | null }>
|
|
223
|
+
/**
|
|
224
|
+
* Live Anthropic quota probe via the broker (#1336). The broker
|
|
225
|
+
* uses its stored accessTokens to hit `/v1/messages` server-side
|
|
226
|
+
* and returns parsed rate-limit headers. Tokens never reach the
|
|
227
|
+
* caller. Per-label results are returned in input order.
|
|
228
|
+
*/
|
|
229
|
+
probeQuota(
|
|
230
|
+
accounts: readonly string[],
|
|
231
|
+
timeoutMs?: number,
|
|
232
|
+
): Promise<{ results: Array<{ label: string; result: import('../quota-check.js').QuotaResult }> }>
|
|
218
233
|
}
|
|
219
234
|
|
|
220
235
|
export interface AuthCommandContext {
|
|
@@ -236,12 +251,43 @@ export interface AuthCommandContext {
|
|
|
236
251
|
* that never reach the destructive branches can skip wiring it.
|
|
237
252
|
*/
|
|
238
253
|
chatId?: string
|
|
254
|
+
/**
|
|
255
|
+
* Optional Format 2 enricher — when supplied, the `show`/`list`
|
|
256
|
+
* paths probe live quota for every account (in parallel) so the
|
|
257
|
+
* snapshot renders the new health-grouped shape with real-time
|
|
258
|
+
* percentages and reset countdowns. When omitted the legacy
|
|
259
|
+
* ASCII table renders, which keeps tests + broker-only callers
|
|
260
|
+
* working without spinning up the Anthropic API path.
|
|
261
|
+
*
|
|
262
|
+
* Returns a parallel array (same length, same order as
|
|
263
|
+
* `state.accounts`) of QuotaResult — the gateway passes
|
|
264
|
+
* `accounts.map(a => fetchAccountQuota(a.label, {force: true}))`.
|
|
265
|
+
*/
|
|
266
|
+
liveQuotas?: (
|
|
267
|
+
accounts: AccountState[],
|
|
268
|
+
) => Promise<import('../quota-check.js').QuotaResult[]>
|
|
269
|
+
/** Operator timezone forwarded to the Format 2 renderer. */
|
|
270
|
+
tz?: string
|
|
239
271
|
}
|
|
240
272
|
|
|
241
273
|
export interface AuthCommandReply {
|
|
242
274
|
text: string
|
|
243
275
|
/** True when the reply contains HTML markup. */
|
|
244
276
|
html: boolean
|
|
277
|
+
/**
|
|
278
|
+
* Optional inline keyboard (rows of buttons). Format 2 attaches a
|
|
279
|
+
* smart-keyboard here for the fleet snapshot — switch buttons for
|
|
280
|
+
* healthy non-active accounts, plus refresh/usage/+add. Caller
|
|
281
|
+
* translates to grammy's `reply_markup` shape. Empty/missing means
|
|
282
|
+
* no keyboard.
|
|
283
|
+
*/
|
|
284
|
+
keyboard?: Array<
|
|
285
|
+
Array<{
|
|
286
|
+
text: string
|
|
287
|
+
callbackData?: string
|
|
288
|
+
insertText?: string
|
|
289
|
+
}>
|
|
290
|
+
>
|
|
245
291
|
}
|
|
246
292
|
|
|
247
293
|
/**
|
|
@@ -282,7 +328,35 @@ export async function handleAuthCommand(
|
|
|
282
328
|
) {
|
|
283
329
|
try {
|
|
284
330
|
const state = await ctx.client.listState()
|
|
285
|
-
|
|
331
|
+
let liveQuotas: import('../quota-check.js').QuotaResult[] | undefined
|
|
332
|
+
let liveProbedAtMs: number | undefined
|
|
333
|
+
if (ctx.liveQuotas && state.accounts.length > 0) {
|
|
334
|
+
try {
|
|
335
|
+
liveQuotas = await ctx.liveQuotas(state.accounts)
|
|
336
|
+
liveProbedAtMs = Date.now()
|
|
337
|
+
} catch {
|
|
338
|
+
// Live probe failed — fall back to legacy table silently.
|
|
339
|
+
liveQuotas = undefined
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Build the smart keyboard only when we have live quota data —
|
|
343
|
+
// without it we can't classify health and the buttons could
|
|
344
|
+
// tempt the user into a blocked account. When omitted, the
|
|
345
|
+
// text still renders (legacy table); just no keyboard.
|
|
346
|
+
let keyboard: AuthCommandReply['keyboard']
|
|
347
|
+
if (liveQuotas && liveQuotas.length === state.accounts.length) {
|
|
348
|
+
const snapshots = buildSnapshotsFromState(state, liveQuotas)
|
|
349
|
+
keyboard = buildSnapshotKeyboard(snapshots)
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
text: renderShowText(state, Date.now(), {
|
|
353
|
+
liveQuotas,
|
|
354
|
+
tz: ctx.tz,
|
|
355
|
+
liveProbedAtMs,
|
|
356
|
+
}),
|
|
357
|
+
html: true,
|
|
358
|
+
keyboard,
|
|
359
|
+
}
|
|
286
360
|
} catch (err) {
|
|
287
361
|
return {
|
|
288
362
|
text: `<b>/auth show failed:</b> ${escapeHtml((err as Error)?.message ?? String(err))}`,
|
|
@@ -609,17 +683,64 @@ export function pickRotateTarget(state: ListStateData, now: number = Date.now())
|
|
|
609
683
|
* Telegram (HTML, monospace blocks). Three sections, each suppressed
|
|
610
684
|
* when empty.
|
|
611
685
|
*/
|
|
612
|
-
export
|
|
686
|
+
export interface RenderShowOpts {
|
|
687
|
+
/** Optional live quota probes, parallel to state.accounts. When
|
|
688
|
+
* present, the Accounts section uses Format 2 (health-grouped,
|
|
689
|
+
* causal-runway). When absent (legacy callers, broker-only render),
|
|
690
|
+
* falls back to the original ASCII table. */
|
|
691
|
+
liveQuotas?: import('../quota-check.js').QuotaResult[]
|
|
692
|
+
/** Operator timezone for absolute reset times in Format 2. */
|
|
693
|
+
tz?: string
|
|
694
|
+
/** Wall-clock ms when the live probes returned, used for "refreshed
|
|
695
|
+
* Ns ago" footer. Omit to suppress that footer line. */
|
|
696
|
+
liveProbedAtMs?: number
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Render the fleet snapshot. Two shapes coexist transparently:
|
|
701
|
+
*
|
|
702
|
+
* 1. Format 2 (preferred) — when `opts.liveQuotas` is supplied:
|
|
703
|
+
* health-grouped per-account view (🟢 HEALTHY / 🟡 THROTTLING /
|
|
704
|
+
* 🔴 BLOCKED), live percent + reset times, recommendation
|
|
705
|
+
* footer. See `auth-snapshot-format.ts → renderAuthSnapshotFormat2`.
|
|
706
|
+
*
|
|
707
|
+
* 2. Legacy ASCII table — when no live data is available
|
|
708
|
+
* (broker-only path, tests, or the live probe failed). Same
|
|
709
|
+
* visual shape RFC §4.6 originally specified; preserved so the
|
|
710
|
+
* broker can still answer `/auth show` with no Anthropic-API
|
|
711
|
+
* round-trip.
|
|
712
|
+
*
|
|
713
|
+
* The Agents and Consumers tables render identically under both
|
|
714
|
+
* shapes — those tables don't depend on quota state.
|
|
715
|
+
*/
|
|
716
|
+
export function renderShowText(
|
|
717
|
+
state: ListStateData,
|
|
718
|
+
now: number = Date.now(),
|
|
719
|
+
opts: RenderShowOpts = {},
|
|
720
|
+
): string {
|
|
613
721
|
const lines: string[] = []
|
|
614
|
-
lines.push('<b>Auth — fleet snapshot</b>')
|
|
615
722
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
lines.push(
|
|
622
|
-
|
|
723
|
+
if (state.accounts.length > 0 && opts.liveQuotas && opts.liveQuotas.length === state.accounts.length) {
|
|
724
|
+
// Format 2 path. Build snapshots, render the new shape inline at
|
|
725
|
+
// the top of the message — replaces the legacy "Auth — fleet
|
|
726
|
+
// snapshot" header + Accounts table.
|
|
727
|
+
const snapshots = buildSnapshotsFromState(state, opts.liveQuotas)
|
|
728
|
+
lines.push(
|
|
729
|
+
renderAuthSnapshotFormat2(snapshots, {
|
|
730
|
+
tz: opts.tz,
|
|
731
|
+
now: new Date(now),
|
|
732
|
+
liveProbedAtMs: opts.liveProbedAtMs,
|
|
733
|
+
}),
|
|
734
|
+
)
|
|
735
|
+
} else {
|
|
736
|
+
lines.push('<b>Auth — fleet snapshot</b>')
|
|
737
|
+
if (state.accounts.length > 0) {
|
|
738
|
+
lines.push('')
|
|
739
|
+
lines.push('<b>Accounts</b>')
|
|
740
|
+
lines.push('<pre>')
|
|
741
|
+
lines.push(formatAccountsTable(state, now))
|
|
742
|
+
lines.push('</pre>')
|
|
743
|
+
}
|
|
623
744
|
}
|
|
624
745
|
|
|
625
746
|
// Agents table
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapt the RFC H broker `auth show --json` / `auth list --json` payload
|
|
3
|
+
* (a `ListStateData`) into the per-agent `AuthSummary` shape the
|
|
4
|
+
* /status panel renders via `formatAuthLine`.
|
|
5
|
+
*
|
|
6
|
+
* Pre-RFC-H, gateway shelled out to `switchroom auth status --json`
|
|
7
|
+
* which already returned per-agent records in `AuthSummary` shape.
|
|
8
|
+
* That verb was retired; this adapter does the per-agent projection
|
|
9
|
+
* over the new fleet-broker payload.
|
|
10
|
+
*
|
|
11
|
+
* Pure & dependency-free so it can be unit-tested without a grammy
|
|
12
|
+
* Context or live broker.
|
|
13
|
+
*/
|
|
14
|
+
import type { AuthSummary } from '../welcome-text.js'
|
|
15
|
+
|
|
16
|
+
/** Mirrors `ListStateData` in src/auth/broker/client.ts — duplicated as
|
|
17
|
+
* a structural type so this adapter stays in the telegram-plugin
|
|
18
|
+
* workspace without importing across the src/ boundary. */
|
|
19
|
+
export interface BrokerStateView {
|
|
20
|
+
active: string
|
|
21
|
+
fallback_order: string[]
|
|
22
|
+
accounts: Array<{
|
|
23
|
+
label: string
|
|
24
|
+
expiresAt?: number
|
|
25
|
+
exhausted: boolean
|
|
26
|
+
}>
|
|
27
|
+
agents: Array<{
|
|
28
|
+
name: string
|
|
29
|
+
account: string
|
|
30
|
+
override: string | null
|
|
31
|
+
}>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Subset of `.claude.json` we need for billingType — duplicated for
|
|
35
|
+
* the same reason as BrokerStateView. */
|
|
36
|
+
export interface ClaudeJsonView {
|
|
37
|
+
oauthAccount?: {
|
|
38
|
+
billingType?: string
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatExpiresInRelative(expiresAt: number | undefined, now: number = Date.now()): string | null {
|
|
43
|
+
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) return null
|
|
44
|
+
const delta = expiresAt - now
|
|
45
|
+
if (delta <= 0) return 'expired'
|
|
46
|
+
const days = Math.floor(delta / 86_400_000)
|
|
47
|
+
if (days >= 1) return `in ${days} day${days === 1 ? '' : 's'}`
|
|
48
|
+
const hours = Math.floor(delta / 3_600_000)
|
|
49
|
+
if (hours >= 1) return `in ${hours} hour${hours === 1 ? '' : 's'}`
|
|
50
|
+
const minutes = Math.max(1, Math.floor(delta / 60_000))
|
|
51
|
+
return `in ${minutes} minute${minutes === 1 ? '' : 's'}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function mapBillingTypeToPlan(billingType: string | undefined): string | null {
|
|
55
|
+
if (!billingType) return null
|
|
56
|
+
const t = billingType.toLowerCase()
|
|
57
|
+
if (t.includes('max')) return 'Max'
|
|
58
|
+
if (t.includes('pro')) return 'Pro'
|
|
59
|
+
return billingType
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the per-agent AuthSummary from broker state.
|
|
64
|
+
*
|
|
65
|
+
* - `authenticated` = the agent is bound to an account that the broker
|
|
66
|
+
* knows about. Quota exhaustion is NOT counted as unauthenticated —
|
|
67
|
+
* the agent still has valid credentials, it just can't make calls
|
|
68
|
+
* until the broker rotates (which is a separate signal).
|
|
69
|
+
* - `auth_source` surfaces the bound account label (e.g. the email).
|
|
70
|
+
* Under RFC H all auth flows through the broker, so the source is
|
|
71
|
+
* "which account is currently mirrored to this agent", not the
|
|
72
|
+
* transport.
|
|
73
|
+
* - `subscription_type` is read from the agent's `.claude.json`
|
|
74
|
+
* because the broker doesn't track plan tier.
|
|
75
|
+
* - `expires_in` is computed from the bound account's `expiresAt`.
|
|
76
|
+
*/
|
|
77
|
+
export function buildAuthSummaryFromBroker(
|
|
78
|
+
state: BrokerStateView | null | undefined,
|
|
79
|
+
agentName: string,
|
|
80
|
+
claudeJson: ClaudeJsonView | null | undefined,
|
|
81
|
+
now: number = Date.now(),
|
|
82
|
+
): AuthSummary | null {
|
|
83
|
+
if (!state) return null
|
|
84
|
+
const binding = state.agents.find((a) => a.name === agentName)
|
|
85
|
+
if (!binding) {
|
|
86
|
+
return {
|
|
87
|
+
authenticated: false,
|
|
88
|
+
subscription_type: mapBillingTypeToPlan(claudeJson?.oauthAccount?.billingType),
|
|
89
|
+
expires_in: null,
|
|
90
|
+
auth_source: null,
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const account = state.accounts.find((a) => a.label === binding.account)
|
|
94
|
+
const authenticated = account !== undefined
|
|
95
|
+
return {
|
|
96
|
+
authenticated,
|
|
97
|
+
subscription_type: mapBillingTypeToPlan(claudeJson?.oauthAccount?.billingType),
|
|
98
|
+
expires_in: account ? formatExpiresInRelative(account.expiresAt, now) : null,
|
|
99
|
+
auth_source: binding.account,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -499,7 +499,7 @@ export async function runAllProbes(opts: RunProbesOpts): Promise<ProbeMap> {
|
|
|
499
499
|
const slug = opts.agentSlug ?? opts.agentName
|
|
500
500
|
|
|
501
501
|
await Promise.allSettled([
|
|
502
|
-
probeAccount(opts.agentDir
|
|
502
|
+
probeAccount(opts.agentDir).then(r => { probes.account = r }),
|
|
503
503
|
probeAgentProcess(slug, { execFileImpl: opts.probeExecFileImpl, tmuxSupervisor: opts.tmuxSupervisor, dockerMode: opts.dockerMode }).then(r => { probes.agent = r }),
|
|
504
504
|
probeGateway(opts.gatewayInfo).then(r => { probes.gateway = r }),
|
|
505
505
|
probeQuota(claudeDir, opts.agentDir, opts.fetchImpl).then(r => { probes.quota = r }),
|
|
@@ -121,16 +121,10 @@ const TOKEN_EXPIRING_SOON_DAYS = 7
|
|
|
121
121
|
*/
|
|
122
122
|
export async function probeAccount(
|
|
123
123
|
agentDir: string,
|
|
124
|
-
opts: { agentName?: string } = {},
|
|
125
124
|
): Promise<ProbeResult> {
|
|
126
125
|
return withTimeout('Account', (async (): Promise<ProbeResult> => {
|
|
127
126
|
const claudeDir = join(agentDir, '.claude')
|
|
128
127
|
const claudeJsonPath = join(claudeDir, '.claude.json')
|
|
129
|
-
// Fall back to the literal placeholder only when no agentName is plumbed
|
|
130
|
-
// through — the renderer's <code> escape will keep that safe in Telegram
|
|
131
|
-
// HTML, but real call sites should always pass the name so users can
|
|
132
|
-
// tap-to-copy a working command.
|
|
133
|
-
const agentRef = opts.agentName ?? '<agent>'
|
|
134
128
|
let cfg: ClaudeJson = {}
|
|
135
129
|
try {
|
|
136
130
|
const raw = readFileSync(claudeJsonPath, 'utf8')
|
|
@@ -145,7 +139,10 @@ export async function probeAccount(
|
|
|
145
139
|
status: 'degraded',
|
|
146
140
|
label: 'Account',
|
|
147
141
|
detail: 'not signed in',
|
|
148
|
-
|
|
142
|
+
// RFC H: auth is fleet-wide. Recovery is `auth add` + `auth use` —
|
|
143
|
+
// the broker then fans the active label out to every agent. There
|
|
144
|
+
// is no per-agent `auth login` verb anymore.
|
|
145
|
+
nextStep: 'Run `switchroom auth add <label> --from-oauth` then `switchroom auth use <label>` to authenticate the fleet',
|
|
149
146
|
}
|
|
150
147
|
}
|
|
151
148
|
|
|
@@ -176,9 +173,9 @@ export async function probeAccount(
|
|
|
176
173
|
}
|
|
177
174
|
|
|
178
175
|
const nextStep = status === 'fail'
|
|
179
|
-
?
|
|
176
|
+
? 'OAuth token expired — broker should auto-refresh; force with `switchroom auth refresh` or `switchroom auth add <label> --from-oauth --replace` if the refresh token is also bad'
|
|
180
177
|
: status === 'degraded'
|
|
181
|
-
?
|
|
178
|
+
? 'Token expiring soon — broker auto-refreshes < 60min before expiry; force now with `switchroom auth refresh`'
|
|
182
179
|
: undefined
|
|
183
180
|
return {
|
|
184
181
|
status,
|