switchroom 0.17.5 → 0.17.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +1 -1
- package/dist/auth-broker/index.js +88 -9
- package/dist/cli/notion-write-pretool.mjs +1 -1
- package/dist/cli/switchroom.js +11 -13
- package/dist/host-control/main.js +2 -2
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/auth-snapshot-format.ts +50 -2
- package/telegram-plugin/dist/gateway/gateway.js +36 -39
- package/telegram-plugin/gateway/auth-command.ts +1 -1
- package/telegram-plugin/gateway/gateway.ts +38 -37
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +75 -0
|
@@ -11591,7 +11591,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
11591
11591
|
name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
|
11592
11592
|
message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
|
|
11593
11593
|
}).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
|
|
11594
|
-
account: exports_external.string().min(1).describe("
|
|
11594
|
+
account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
|
|
11595
11595
|
uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
|
|
11596
11596
|
mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
|
|
11597
11597
|
})).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
|
|
@@ -11591,7 +11591,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
11591
11591
|
name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
|
11592
11592
|
message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
|
|
11593
11593
|
}).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
|
|
11594
|
-
account: exports_external.string().min(1).describe("
|
|
11594
|
+
account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
|
|
11595
11595
|
uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
|
|
11596
11596
|
mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
|
|
11597
11597
|
})).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
|
|
@@ -13910,10 +13910,12 @@ class AuthBroker {
|
|
|
13910
13910
|
refreshInFlight = new Set;
|
|
13911
13911
|
consumerLastSeen = {};
|
|
13912
13912
|
capChownWarned = false;
|
|
13913
|
+
yamlActive;
|
|
13913
13914
|
closed = false;
|
|
13914
13915
|
constructor(config, opts = {}) {
|
|
13915
13916
|
this.opts = opts;
|
|
13916
13917
|
this.config = config;
|
|
13918
|
+
this.yamlActive = config.auth?.active;
|
|
13917
13919
|
this.home = opts.home;
|
|
13918
13920
|
this.now = opts.now ?? nowMs;
|
|
13919
13921
|
this.operatorUid = opts.operatorUid;
|
|
@@ -14040,6 +14042,8 @@ class AuthBroker {
|
|
|
14040
14042
|
this.assertConfigConsistent(config);
|
|
14041
14043
|
const prev = this.config;
|
|
14042
14044
|
this.config = config;
|
|
14045
|
+
this.yamlActive = config.auth?.active;
|
|
14046
|
+
this.applyActiveOverride();
|
|
14043
14047
|
const wanted = new Set;
|
|
14044
14048
|
for (const name of Object.keys(config.agents ?? {})) {
|
|
14045
14049
|
wanted.add(this.agentSocketPath(name));
|
|
@@ -14331,7 +14335,9 @@ class AuthBroker {
|
|
|
14331
14335
|
return auth.active ?? null;
|
|
14332
14336
|
if (identity2.kind === "consumer") {
|
|
14333
14337
|
const c = (auth.consumers ?? []).find((x) => x.name === identity2.name);
|
|
14334
|
-
|
|
14338
|
+
if (!c)
|
|
14339
|
+
return null;
|
|
14340
|
+
return c.account ?? auth.active ?? null;
|
|
14335
14341
|
}
|
|
14336
14342
|
const agent = (this.config.agents ?? {})[identity2.name];
|
|
14337
14343
|
const override = agent?.auth?.override;
|
|
@@ -14475,7 +14481,7 @@ class AuthBroker {
|
|
|
14475
14481
|
});
|
|
14476
14482
|
const consumers = (auth.consumers ?? []).map((c) => ({
|
|
14477
14483
|
name: c.name,
|
|
14478
|
-
account: c.account,
|
|
14484
|
+
account: c.account ?? auth.active ?? "",
|
|
14479
14485
|
last_seen_at: this.consumerLastSeen[c.name] ?? null
|
|
14480
14486
|
}));
|
|
14481
14487
|
const active_overage_serving = this.isActiveOverageServing(this.callerAccount(identity2));
|
|
@@ -14594,10 +14600,20 @@ class AuthBroker {
|
|
|
14594
14600
|
continue;
|
|
14595
14601
|
}
|
|
14596
14602
|
this.cacheQuotaSnapshot(label, result);
|
|
14603
|
+
const active = this.config.auth?.active;
|
|
14604
|
+
if (label !== active)
|
|
14605
|
+
continue;
|
|
14606
|
+
const decision = quotaIndicatesExhaustion(result, this.isOverageAllowed(label));
|
|
14607
|
+
if (!decision.exhausted)
|
|
14608
|
+
continue;
|
|
14609
|
+
process.stdout.write(`auth-broker: fleet-quota-probe shows ACTIVE ${label} exhausted — proactive failover
|
|
14610
|
+
`);
|
|
14611
|
+
this.audit({ op: "mark-exhausted", identity: { kind: "operator" }, account: label, accountKind: "claude", ok: true });
|
|
14612
|
+
await this.markExhaustedAndRoll(label, decision.until ?? undefined, { kind: "operator" });
|
|
14597
14613
|
}
|
|
14598
14614
|
}
|
|
14599
14615
|
async consumerQuotaProbeTick() {
|
|
14600
|
-
const accounts = Array.from(new Set((this.config.auth?.consumers ?? []).map((c) => c.account)));
|
|
14616
|
+
const accounts = Array.from(new Set((this.config.auth?.consumers ?? []).map((c) => c.account).filter((a) => typeof a === "string" && a.length > 0)));
|
|
14601
14617
|
for (const label of accounts) {
|
|
14602
14618
|
const creds = readAccountCredentials(label, this.home);
|
|
14603
14619
|
const token = creds?.claudeAiOauth?.accessToken;
|
|
@@ -14654,6 +14670,7 @@ class AuthBroker {
|
|
|
14654
14670
|
auth: { ...this.config.auth ?? {}, active: account }
|
|
14655
14671
|
};
|
|
14656
14672
|
this.config = cfg;
|
|
14673
|
+
this.persistActiveOverride(account);
|
|
14657
14674
|
const fanned = this.fanoutToAffectedAgents(account);
|
|
14658
14675
|
this.fanoutAllConsumers();
|
|
14659
14676
|
this.audit({ op: "set-active", identity: identity2, account, accountKind: "claude", ok: true });
|
|
@@ -14666,6 +14683,11 @@ class AuthBroker {
|
|
|
14666
14683
|
socket.write(encodeError(id, "ACCOUNT_NOT_FOUND", "no active account configured"));
|
|
14667
14684
|
return;
|
|
14668
14685
|
}
|
|
14686
|
+
const { rolled, rolledTo } = await this.markExhaustedAndRoll(account, until, identity2);
|
|
14687
|
+
this.audit({ op: "mark-exhausted", identity: identity2, account, accountKind: "claude", ok: true });
|
|
14688
|
+
socket.write(encodeSuccess(id, { account, rolled, rolledTo }));
|
|
14689
|
+
}
|
|
14690
|
+
async markExhaustedAndRoll(account, until, identity2) {
|
|
14669
14691
|
const now = this.now();
|
|
14670
14692
|
const exhaustedUntil = clampMarkExpiry({
|
|
14671
14693
|
proposedUntil: until ?? now + MARK_EXHAUSTED_DEFAULT_MS,
|
|
@@ -14678,8 +14700,26 @@ class AuthBroker {
|
|
|
14678
14700
|
const rolledTo = await this.nextHealthyAccountLive(account, this.config.auth?.fallback_order ?? []);
|
|
14679
14701
|
const rolled = this.fanoutFailoverTo(account, rolledTo);
|
|
14680
14702
|
this.fanoutToAffectedConsumers(account);
|
|
14681
|
-
|
|
14682
|
-
|
|
14703
|
+
if (rolledTo && this.config.auth?.active === account && this.exhaustionLiveCorroborated(account)) {
|
|
14704
|
+
this.config = {
|
|
14705
|
+
...this.config,
|
|
14706
|
+
auth: { ...this.config.auth ?? {}, active: rolledTo }
|
|
14707
|
+
};
|
|
14708
|
+
this.persistActiveOverride(rolledTo);
|
|
14709
|
+
this.fanoutAllConsumers();
|
|
14710
|
+
this.audit({ op: "auto-promote-active", identity: identity2, account: rolledTo, accountKind: "claude", ok: true });
|
|
14711
|
+
process.stdout.write(`auth-broker: auto-promoted auth.active ${account} → ${rolledTo} ` + `(${account} exhausted until ${new Date(exhaustedUntil).toISOString()}) — persisted
|
|
14712
|
+
`);
|
|
14713
|
+
}
|
|
14714
|
+
return { rolled, rolledTo };
|
|
14715
|
+
}
|
|
14716
|
+
exhaustionLiveCorroborated(account) {
|
|
14717
|
+
const snapshot = this.lastQuotaCache[account];
|
|
14718
|
+
if (!snapshot || !snapshotFresh(snapshot, this.now(), MARK_EXHAUSTED_DEFAULT_MS))
|
|
14719
|
+
return false;
|
|
14720
|
+
if (!snapshotWalled(snapshot))
|
|
14721
|
+
return false;
|
|
14722
|
+
return !overageLiftsWall(snapshot, this.isOverageAllowed(account));
|
|
14683
14723
|
}
|
|
14684
14724
|
opClaimNotification(socket, id, identity2, key, windowMs) {
|
|
14685
14725
|
const now = this.now();
|
|
@@ -15187,12 +15227,15 @@ class AuthBroker {
|
|
|
15187
15227
|
for (const consumer of this.config.auth?.consumers ?? []) {
|
|
15188
15228
|
if (!consumer.mirror_dir)
|
|
15189
15229
|
continue;
|
|
15190
|
-
const
|
|
15230
|
+
const bound = consumer.account ?? this.config.auth?.active;
|
|
15231
|
+
const isPinned = bound === label;
|
|
15191
15232
|
const effective = this.servingAccountForConsumer(consumer.name);
|
|
15192
15233
|
const isEffective = effective === label;
|
|
15193
15234
|
if (!isPinned && !isEffective)
|
|
15194
15235
|
continue;
|
|
15195
|
-
const toMirror = effective ??
|
|
15236
|
+
const toMirror = effective ?? bound;
|
|
15237
|
+
if (toMirror == null)
|
|
15238
|
+
continue;
|
|
15196
15239
|
if (this.mirrorAccountToConsumer(toMirror, consumer)) {
|
|
15197
15240
|
fanned.push(consumer.name);
|
|
15198
15241
|
}
|
|
@@ -15216,7 +15259,7 @@ class AuthBroker {
|
|
|
15216
15259
|
const c = (this.config.auth?.consumers ?? []).find((x) => x.name === name);
|
|
15217
15260
|
if (!c)
|
|
15218
15261
|
return null;
|
|
15219
|
-
return this.accountWithFailover(c.account);
|
|
15262
|
+
return this.accountWithFailover(c.account ?? this.config.auth?.active);
|
|
15220
15263
|
}
|
|
15221
15264
|
mirrorAccountToConsumer(label, consumer) {
|
|
15222
15265
|
const mirrorDir = consumer.mirror_dir;
|
|
@@ -15362,6 +15405,42 @@ class AuthBroker {
|
|
|
15362
15405
|
this.thresholdViolations = this.readJson("threshold-violations.json") ?? {};
|
|
15363
15406
|
this.notificationClaims = this.readJson("notification-claims.json") ?? {};
|
|
15364
15407
|
this.lastQuotaCache = this.readJson("last-quota.json") ?? {};
|
|
15408
|
+
this.applyActiveOverride();
|
|
15409
|
+
}
|
|
15410
|
+
applyActiveOverride() {
|
|
15411
|
+
const ov = this.readJson("active-override.json");
|
|
15412
|
+
if (!ov || typeof ov.active !== "string" || ov.active.length === 0)
|
|
15413
|
+
return;
|
|
15414
|
+
const yamlActive = this.yamlActive ?? null;
|
|
15415
|
+
if ((ov.yaml_active_at_write ?? null) !== yamlActive) {
|
|
15416
|
+
process.stdout.write(`auth-broker: active-override dropped — yaml auth.active changed since the swap ` + `(${ov.yaml_active_at_write ?? "unset"} → ${yamlActive ?? "unset"}); yaml wins
|
|
15417
|
+
`);
|
|
15418
|
+
try {
|
|
15419
|
+
unlinkSync(join4(this.stateDir, "active-override.json"));
|
|
15420
|
+
} catch {}
|
|
15421
|
+
return;
|
|
15422
|
+
}
|
|
15423
|
+
if (!accountExists(ov.active, this.home)) {
|
|
15424
|
+
process.stdout.write(`auth-broker: active-override ignored — account '${ov.active}' not found on disk
|
|
15425
|
+
`);
|
|
15426
|
+
return;
|
|
15427
|
+
}
|
|
15428
|
+
if (ov.active === this.config.auth?.active)
|
|
15429
|
+
return;
|
|
15430
|
+
this.config = {
|
|
15431
|
+
...this.config,
|
|
15432
|
+
auth: { ...this.config.auth ?? {}, active: ov.active }
|
|
15433
|
+
};
|
|
15434
|
+
process.stdout.write(`auth-broker: active-override applied — auth.active ${yamlActive ?? "unset"} → ${ov.active} ` + `(persisted swap survives restarts)
|
|
15435
|
+
`);
|
|
15436
|
+
}
|
|
15437
|
+
persistActiveOverride(active) {
|
|
15438
|
+
const entry = {
|
|
15439
|
+
active,
|
|
15440
|
+
yaml_active_at_write: this.yamlActive ?? null,
|
|
15441
|
+
updated_at: this.now()
|
|
15442
|
+
};
|
|
15443
|
+
atomicWriteJsonSync(join4(this.stateDir, "active-override.json"), entry, 384);
|
|
15365
15444
|
}
|
|
15366
15445
|
readJson(name) {
|
|
15367
15446
|
const p = join4(this.stateDir, name);
|
|
@@ -12339,7 +12339,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
12339
12339
|
name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
|
12340
12340
|
message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
|
|
12341
12341
|
}).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
|
|
12342
|
-
account: exports_external.string().min(1).describe("
|
|
12342
|
+
account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it \u2014 use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
|
|
12343
12343
|
uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
|
|
12344
12344
|
mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here \u2014 in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) \u2014 swallowed when CAP_CHOWN is absent.")
|
|
12345
12345
|
})).optional().describe("Non-agent peers that hold a broker socket (RFC H \u00a74.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -14177,7 +14177,7 @@ var init_schema = __esm(() => {
|
|
|
14177
14177
|
name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
|
14178
14178
|
message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
|
|
14179
14179
|
}).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
|
|
14180
|
-
account: exports_external.string().min(1).describe("
|
|
14180
|
+
account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it \u2014 use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
|
|
14181
14181
|
uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
|
|
14182
14182
|
mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here \u2014 in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) \u2014 swallowed when CAP_CHOWN is absent.")
|
|
14183
14183
|
})).optional().describe("Non-agent peers that hold a broker socket (RFC H \u00a74.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
|
|
@@ -43431,7 +43431,7 @@ function checkHindsightConsumer(config, opts) {
|
|
|
43431
43431
|
return {
|
|
43432
43432
|
name: "hindsight consumer",
|
|
43433
43433
|
status: "warn",
|
|
43434
|
-
detail: `auth.consumers[hindsight] -> ${entry.account} (uid ${entry.uid ?? 0}); ` + `couldn't query auth-broker container (not running / docker unavailable)`,
|
|
43434
|
+
detail: `auth.consumers[hindsight] -> ${entry.account ?? "(follows active)"} (uid ${entry.uid ?? 0}); ` + `couldn't query auth-broker container (not running / docker unavailable)`,
|
|
43435
43435
|
fix: "Check `auth-broker: service health` row above; if the broker is " + "down, `switchroom apply` will bring it back and bind the socket."
|
|
43436
43436
|
};
|
|
43437
43437
|
}
|
|
@@ -43439,14 +43439,14 @@ function checkHindsightConsumer(config, opts) {
|
|
|
43439
43439
|
return {
|
|
43440
43440
|
name: "hindsight consumer",
|
|
43441
43441
|
status: "warn",
|
|
43442
|
-
detail: `auth.consumers[hindsight] -> ${entry.account} (uid ${entry.uid ?? 0}); ` + `auth-broker is running but socket not bound at /run/switchroom/auth-broker/${entry.name}/sock`,
|
|
43442
|
+
detail: `auth.consumers[hindsight] -> ${entry.account ?? "(follows active)"} (uid ${entry.uid ?? 0}); ` + `auth-broker is running but socket not bound at /run/switchroom/auth-broker/${entry.name}/sock`,
|
|
43443
43443
|
fix: "Run `switchroom apply` to refresh compose and rebind per-consumer sockets."
|
|
43444
43444
|
};
|
|
43445
43445
|
}
|
|
43446
43446
|
return {
|
|
43447
43447
|
name: "hindsight consumer",
|
|
43448
43448
|
status: "ok",
|
|
43449
|
-
detail: `auth.consumers[hindsight] -> ${entry.account} (uid ${entry.uid ?? 0})`
|
|
43449
|
+
detail: `auth.consumers[hindsight] -> ${entry.account ?? "(follows active)"} (uid ${entry.uid ?? 0})`
|
|
43450
43450
|
};
|
|
43451
43451
|
}
|
|
43452
43452
|
function probeAuthBrokerSocket(consumerName) {
|
|
@@ -63737,8 +63737,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
63737
63737
|
import { dirname, join } from "node:path";
|
|
63738
63738
|
|
|
63739
63739
|
// src/build-info.ts
|
|
63740
|
-
var VERSION = "0.17.
|
|
63741
|
-
var COMMIT_SHA = "
|
|
63740
|
+
var VERSION = "0.17.6";
|
|
63741
|
+
var COMMIT_SHA = "0cc74c23";
|
|
63742
63742
|
|
|
63743
63743
|
// src/cli/resolve-version.ts
|
|
63744
63744
|
function readPackageVersion() {
|
|
@@ -77748,7 +77748,8 @@ async function ensureHindsightConsumer(configPath, account, uid = HINDSIGHT_DEFA
|
|
|
77748
77748
|
}
|
|
77749
77749
|
const entry = new YAMLMap;
|
|
77750
77750
|
entry.set("name", HINDSIGHT_CONSUMER_NAME);
|
|
77751
|
-
|
|
77751
|
+
if (account !== undefined)
|
|
77752
|
+
entry.set("account", account);
|
|
77752
77753
|
entry.set("uid", uid);
|
|
77753
77754
|
consumersNode.add(entry);
|
|
77754
77755
|
const out = String(doc);
|
|
@@ -82886,14 +82887,11 @@ async function stepMemoryBackend(config, nonInteractive, switchroomConfigPath) {
|
|
|
82886
82887
|
}
|
|
82887
82888
|
}
|
|
82888
82889
|
} catch {}
|
|
82889
|
-
|
|
82890
|
-
if (!activeAccount) {
|
|
82891
|
-
console.log(source_default.yellow(` No auth.active account set \u2014 skipping consumer registration. ` + `Run \`switchroom auth use <label>\` and re-run setup.`));
|
|
82892
|
-
} else {
|
|
82890
|
+
{
|
|
82893
82891
|
try {
|
|
82894
|
-
const result = await ensureHindsightConsumer(switchroomConfigPath
|
|
82892
|
+
const result = await ensureHindsightConsumer(switchroomConfigPath);
|
|
82895
82893
|
if (result.added) {
|
|
82896
|
-
console.log(source_default.green(` ${STEP_DONE} Registered auth.consumers[${HINDSIGHT_CONSUMER_NAME}]
|
|
82894
|
+
console.log(source_default.green(` ${STEP_DONE} Registered auth.consumers[${HINDSIGHT_CONSUMER_NAME}] (follows the fleet active account)`));
|
|
82897
82895
|
} else {
|
|
82898
82896
|
console.log(source_default.gray(` auth.consumers[${HINDSIGHT_CONSUMER_NAME}] already present.`));
|
|
82899
82897
|
}
|
|
@@ -16371,7 +16371,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
16371
16371
|
name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
|
16372
16372
|
message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
|
|
16373
16373
|
}).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
|
|
16374
|
-
account: exports_external.string().min(1).describe("
|
|
16374
|
+
account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
|
|
16375
16375
|
uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
|
|
16376
16376
|
mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
|
|
16377
16377
|
})).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
|
|
@@ -22670,7 +22670,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
|
|
|
22670
22670
|
import { dirname as dirname4, join as join2 } from "node:path";
|
|
22671
22671
|
|
|
22672
22672
|
// src/build-info.ts
|
|
22673
|
-
var VERSION = "0.17.
|
|
22673
|
+
var VERSION = "0.17.6";
|
|
22674
22674
|
|
|
22675
22675
|
// src/cli/resolve-version.ts
|
|
22676
22676
|
function readPackageVersion() {
|
|
@@ -4619,7 +4619,7 @@ var init_schema = __esm(() => {
|
|
|
4619
4619
|
name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
|
4620
4620
|
message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
|
|
4621
4621
|
}).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
|
|
4622
|
-
account: exports_external.string().min(1).describe("
|
|
4622
|
+
account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
|
|
4623
4623
|
uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
|
|
4624
4624
|
mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
|
|
4625
4625
|
})).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
|
|
@@ -4619,7 +4619,7 @@ var init_schema = __esm(() => {
|
|
|
4619
4619
|
name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
|
4620
4620
|
message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
|
|
4621
4621
|
}).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
|
|
4622
|
-
account: exports_external.string().min(1).describe("
|
|
4622
|
+
account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it — use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
|
|
4623
4623
|
uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
|
|
4624
4624
|
mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here — in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) — swallowed when CAP_CHOWN is absent.")
|
|
4625
4625
|
})).optional().describe("Non-agent peers that hold a broker socket (RFC H §4.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.6",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -859,6 +859,14 @@ export interface SnapshotKeyboardOpts {
|
|
|
859
859
|
* keyboard agrees with the card body instead of defaulting to a second
|
|
860
860
|
* `new Date()`. Defaults to wall-clock. */
|
|
861
861
|
now?: Date;
|
|
862
|
+
/**
|
|
863
|
+
* Demo mode (the `/auth demo` / `/usage demo` suffix). Masks the
|
|
864
|
+
* account-email in each switch-button LABEL (the callback_data keeps the
|
|
865
|
+
* real label — the broker needs it to act) and flips the refresh callback
|
|
866
|
+
* to `auth:refresh:demo` so a ↻ tap re-renders the card still masked
|
|
867
|
+
* instead of leaking the real emails mid-screen-recording.
|
|
868
|
+
*/
|
|
869
|
+
demo?: boolean;
|
|
862
870
|
}
|
|
863
871
|
|
|
864
872
|
/**
|
|
@@ -891,14 +899,14 @@ export function buildSnapshotKeyboard(
|
|
|
891
899
|
for (const t of switchTargets) {
|
|
892
900
|
rows.push([
|
|
893
901
|
{
|
|
894
|
-
text: `Switch fleet → ${t.label}`,
|
|
902
|
+
text: `Switch fleet → ${opts.demo ? maskEmail(t.label) : t.label}`,
|
|
895
903
|
callbackData: `auth:use:${t.label}`,
|
|
896
904
|
},
|
|
897
905
|
]);
|
|
898
906
|
}
|
|
899
907
|
|
|
900
908
|
rows.push([
|
|
901
|
-
{ text: '↻ Refresh', callbackData: 'auth:refresh' },
|
|
909
|
+
{ text: '↻ Refresh', callbackData: opts.demo ? 'auth:refresh:demo' : 'auth:refresh' },
|
|
902
910
|
{ text: '/usage', insertText: '/usage' },
|
|
903
911
|
{ text: '+ Add', insertText: '/auth add ' },
|
|
904
912
|
]);
|
|
@@ -919,6 +927,46 @@ function switchPriority(s: AccountSnapshot, now: Date = new Date()): number {
|
|
|
919
927
|
|
|
920
928
|
// ── snapshot assembly helper ─────────────────────────────────────────
|
|
921
929
|
|
|
930
|
+
/**
|
|
931
|
+
* One per-account row of a broker `probe-quota` response. Mirrors the
|
|
932
|
+
* shape `AuthBrokerClient.probeQuota` returns (src/auth/broker/client.ts)
|
|
933
|
+
* without importing across the package boundary.
|
|
934
|
+
*/
|
|
935
|
+
export interface ProbeQuotaResultRow {
|
|
936
|
+
label: string;
|
|
937
|
+
result: QuotaResult;
|
|
938
|
+
/** #2495 Change 2 — how this result was sourced. */
|
|
939
|
+
served?: 'live' | 'cache';
|
|
940
|
+
/** Unix ms the served snapshot was captured (set when served==="cache"). */
|
|
941
|
+
capturedAt?: number;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Zip a broker `probe-quota` response back onto the account list, in input
|
|
946
|
+
* order, and surface cache staleness. Shared by every /auth-surface caller
|
|
947
|
+
* (/auth show, /usage, the ↻ refresh callback) so the "⚠ cached Nm ago"
|
|
948
|
+
* footer logic can't drift between them (#2495 Change 2).
|
|
949
|
+
*
|
|
950
|
+
* Returns `quotas` parallel to `labels` (a missing row degrades to an
|
|
951
|
+
* `ok:false` result, never a hole) and `staleCachedAtMs` — the OLDEST
|
|
952
|
+
* `capturedAt` among cache-served rows, undefined when everything was live.
|
|
953
|
+
*/
|
|
954
|
+
export function zipProbeResults(
|
|
955
|
+
labels: readonly string[],
|
|
956
|
+
results: readonly ProbeQuotaResultRow[],
|
|
957
|
+
): { quotas: QuotaResult[]; staleCachedAtMs?: number } {
|
|
958
|
+
let staleCachedAtMs: number | undefined;
|
|
959
|
+
const quotas = labels.map((label): QuotaResult => {
|
|
960
|
+
const hit = results.find((r) => r.label === label);
|
|
961
|
+
if (!hit) return { ok: false, reason: 'broker returned no result for account' };
|
|
962
|
+
if (hit.served === 'cache' && hit.capturedAt != null) {
|
|
963
|
+
staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt);
|
|
964
|
+
}
|
|
965
|
+
return hit.result;
|
|
966
|
+
});
|
|
967
|
+
return staleCachedAtMs != null ? { quotas, staleCachedAtMs } : { quotas };
|
|
968
|
+
}
|
|
969
|
+
|
|
922
970
|
/**
|
|
923
971
|
* Given the broker's `listState` data + a parallel array of live quota
|
|
924
972
|
* results (same length, same order), return the AccountSnapshot[] the
|
|
@@ -24748,7 +24748,7 @@ var init_schema = __esm(() => {
|
|
|
24748
24748
|
name: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
|
24749
24749
|
message: "Consumer name must be a path-safe slug (letters, digits, underscore, hyphen)"
|
|
24750
24750
|
}).describe("Socket-path identity; binds at /run/switchroom/auth-broker/<name>/sock"),
|
|
24751
|
-
account: exports_external.string().min(1).describe("
|
|
24751
|
+
account: exports_external.string().min(1).optional().describe("Optional pinned account label for this consumer. When set, " + "`get-credentials` serves this account (with automatic failover " + "while it is quota-exhausted) and `mark-exhausted` from this " + "consumer attributes to it \u2014 use a pin for quota isolation. " + "When omitted, the consumer follows the fleet `auth.active` " + "exactly like an agent: same account swaps, same failover."),
|
|
24752
24752
|
uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
|
|
24753
24753
|
mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here \u2014 in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) \u2014 swallowed when CAP_CHOWN is absent.")
|
|
24754
24754
|
})).optional().describe("Non-agent peers that hold a broker socket (RFC H \u00a74.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
|
|
@@ -30892,6 +30892,7 @@ var init_boot_card = __esm(() => {
|
|
|
30892
30892
|
// auth-snapshot-format.ts
|
|
30893
30893
|
var exports_auth_snapshot_format = {};
|
|
30894
30894
|
__export(exports_auth_snapshot_format, {
|
|
30895
|
+
zipProbeResults: () => zipProbeResults,
|
|
30895
30896
|
reviveLastQuota: () => reviveLastQuota,
|
|
30896
30897
|
renderFallbackAnnouncement: () => renderFallbackAnnouncement2,
|
|
30897
30898
|
renderAuthSnapshotFormat2: () => renderAuthSnapshotFormat22,
|
|
@@ -31283,13 +31284,13 @@ function buildSnapshotKeyboard2(snapshots, opts = {}) {
|
|
|
31283
31284
|
for (const t of switchTargets) {
|
|
31284
31285
|
rows.push([
|
|
31285
31286
|
{
|
|
31286
|
-
text: `Switch fleet \u2192 ${t.label}`,
|
|
31287
|
+
text: `Switch fleet \u2192 ${opts.demo ? maskEmail(t.label) : t.label}`,
|
|
31287
31288
|
callbackData: `auth:use:${t.label}`
|
|
31288
31289
|
}
|
|
31289
31290
|
]);
|
|
31290
31291
|
}
|
|
31291
31292
|
rows.push([
|
|
31292
|
-
{ text: "\u21bb Refresh", callbackData: "auth:refresh" },
|
|
31293
|
+
{ text: "\u21bb Refresh", callbackData: opts.demo ? "auth:refresh:demo" : "auth:refresh" },
|
|
31293
31294
|
{ text: "/usage", insertText: "/usage" },
|
|
31294
31295
|
{ text: "+ Add", insertText: "/auth add " }
|
|
31295
31296
|
]);
|
|
@@ -31305,6 +31306,19 @@ function switchPriority2(s, now = new Date) {
|
|
|
31305
31306
|
return 2;
|
|
31306
31307
|
return 3;
|
|
31307
31308
|
}
|
|
31309
|
+
function zipProbeResults(labels, results) {
|
|
31310
|
+
let staleCachedAtMs;
|
|
31311
|
+
const quotas = labels.map((label) => {
|
|
31312
|
+
const hit = results.find((r) => r.label === label);
|
|
31313
|
+
if (!hit)
|
|
31314
|
+
return { ok: false, reason: "broker returned no result for account" };
|
|
31315
|
+
if (hit.served === "cache" && hit.capturedAt != null) {
|
|
31316
|
+
staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt);
|
|
31317
|
+
}
|
|
31318
|
+
return hit.result;
|
|
31319
|
+
});
|
|
31320
|
+
return staleCachedAtMs != null ? { quotas, staleCachedAtMs } : { quotas };
|
|
31321
|
+
}
|
|
31308
31322
|
function buildSnapshotsFromState2(state4, quotas) {
|
|
31309
31323
|
const out = [];
|
|
31310
31324
|
for (let i = 0;i < state4.accounts.length; i++) {
|
|
@@ -42519,13 +42533,13 @@ function buildSnapshotKeyboard(snapshots, opts = {}) {
|
|
|
42519
42533
|
for (const t of switchTargets) {
|
|
42520
42534
|
rows.push([
|
|
42521
42535
|
{
|
|
42522
|
-
text: `Switch fleet \u2192 ${t.label}`,
|
|
42536
|
+
text: `Switch fleet \u2192 ${opts.demo ? maskEmail(t.label) : t.label}`,
|
|
42523
42537
|
callbackData: `auth:use:${t.label}`
|
|
42524
42538
|
}
|
|
42525
42539
|
]);
|
|
42526
42540
|
}
|
|
42527
42541
|
rows.push([
|
|
42528
|
-
{ text: "\u21bb Refresh", callbackData: "auth:refresh" },
|
|
42542
|
+
{ text: "\u21bb Refresh", callbackData: opts.demo ? "auth:refresh:demo" : "auth:refresh" },
|
|
42529
42543
|
{ text: "/usage", insertText: "/usage" },
|
|
42530
42544
|
{ text: "+ Add", insertText: "/auth add " }
|
|
42531
42545
|
]);
|
|
@@ -42721,7 +42735,7 @@ async function handleAuthCommand(parsed, ctx) {
|
|
|
42721
42735
|
let keyboard;
|
|
42722
42736
|
if (liveQuotas && liveQuotas.length === state3.accounts.length) {
|
|
42723
42737
|
const snapshots = buildSnapshotsFromState(state3, liveQuotas);
|
|
42724
|
-
keyboard = buildSnapshotKeyboard(snapshots, { now: new Date });
|
|
42738
|
+
keyboard = buildSnapshotKeyboard(snapshots, { now: new Date, demo: ctx.demo });
|
|
42725
42739
|
}
|
|
42726
42740
|
return {
|
|
42727
42741
|
text: renderShowText(state3, Date.now(), {
|
|
@@ -58590,10 +58604,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
|
|
|
58590
58604
|
}
|
|
58591
58605
|
|
|
58592
58606
|
// ../src/build-info.ts
|
|
58593
|
-
var VERSION = "0.17.
|
|
58594
|
-
var COMMIT_SHA = "
|
|
58595
|
-
var COMMIT_DATE = "2026-07-
|
|
58596
|
-
var LATEST_PR =
|
|
58607
|
+
var VERSION = "0.17.6";
|
|
58608
|
+
var COMMIT_SHA = "0cc74c23";
|
|
58609
|
+
var COMMIT_DATE = "2026-07-05T13:02:44Z";
|
|
58610
|
+
var LATEST_PR = 2854;
|
|
58597
58611
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
58598
58612
|
|
|
58599
58613
|
// gateway/boot-version.ts
|
|
@@ -69315,17 +69329,7 @@ Send \`/auth cancel\` to abort.`, { html: true });
|
|
|
69315
69329
|
liveQuotas: async (accounts) => {
|
|
69316
69330
|
try {
|
|
69317
69331
|
const { results } = await client3.probeQuota(accounts.map((a) => a.label));
|
|
69318
|
-
|
|
69319
|
-
const quotas = accounts.map((a) => {
|
|
69320
|
-
const hit = results.find((r) => r.label === a.label);
|
|
69321
|
-
if (!hit)
|
|
69322
|
-
return { ok: false, reason: "broker returned no result for account" };
|
|
69323
|
-
if (hit.served === "cache" && hit.capturedAt != null) {
|
|
69324
|
-
staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt);
|
|
69325
|
-
}
|
|
69326
|
-
return hit.result;
|
|
69327
|
-
});
|
|
69328
|
-
return { quotas, staleCachedAtMs };
|
|
69332
|
+
return zipProbeResults(accounts.map((a) => a.label), results);
|
|
69329
69333
|
} catch (err) {
|
|
69330
69334
|
const reason = `broker probe-quota failed: ${err?.message ?? String(err)}`;
|
|
69331
69335
|
return { quotas: accounts.map(() => ({ ok: false, reason })) };
|
|
@@ -70467,7 +70471,8 @@ _Tap /auth to see updated quota for the new active account._`), {}), { chat_id:
|
|
|
70467
70471
|
}
|
|
70468
70472
|
return;
|
|
70469
70473
|
}
|
|
70470
|
-
if (data === "auth:refresh") {
|
|
70474
|
+
if (data === "auth:refresh" || data === "auth:refresh:demo") {
|
|
70475
|
+
const refreshDemo = data === "auth:refresh:demo";
|
|
70471
70476
|
const refreshMsg = ctx.callbackQuery?.message;
|
|
70472
70477
|
if (refreshMsg) {
|
|
70473
70478
|
const key = `${refreshMsg.chat.id}:${refreshMsg.message_id}`;
|
|
@@ -70494,20 +70499,19 @@ _Tap /auth to see updated quota for the new active account._`), {}), { chat_id:
|
|
|
70494
70499
|
return;
|
|
70495
70500
|
}
|
|
70496
70501
|
const state4 = await client3.listState();
|
|
70497
|
-
const probeResp = state4.accounts.length > 0 ? await client3.probeQuota(state4.accounts.map((a) => a.label)).catch(() => ({ results: [] })) : { results: [] };
|
|
70498
|
-
const quotas = state4.accounts.map((a) =>
|
|
70499
|
-
const hit = probeResp.results.find((r) => r.label === a.label);
|
|
70500
|
-
return hit?.result ?? { ok: false, reason: "broker returned no result for account" };
|
|
70501
|
-
});
|
|
70502
|
+
const probeResp = state4.accounts.length > 0 ? await client3.probeQuota(state4.accounts.map((a) => a.label), undefined, true).catch(() => ({ results: [] })) : { results: [] };
|
|
70503
|
+
const { quotas, staleCachedAtMs } = zipProbeResults(state4.accounts.map((a) => a.label), probeResp.results);
|
|
70502
70504
|
const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? "UTC";
|
|
70503
70505
|
const { renderAuthSnapshotFormat2: renderAuthSnapshotFormat23, buildSnapshotsFromState: buildSnapshotsFromState4, buildSnapshotKeyboard: buildSnapshotKeyboard3 } = await Promise.resolve().then(() => (init_auth_snapshot_format(), exports_auth_snapshot_format));
|
|
70504
70506
|
const snapshots = buildSnapshotsFromState4(state4, quotas);
|
|
70507
|
+
const renderNow = new Date;
|
|
70505
70508
|
const text2 = renderAuthSnapshotFormat23(snapshots, {
|
|
70506
70509
|
tz,
|
|
70507
|
-
now:
|
|
70508
|
-
|
|
70510
|
+
now: renderNow,
|
|
70511
|
+
demo: refreshDemo,
|
|
70512
|
+
...staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: renderNow.getTime() }
|
|
70509
70513
|
});
|
|
70510
|
-
const kbRows = buildSnapshotKeyboard3(snapshots);
|
|
70514
|
+
const kbRows = buildSnapshotKeyboard3(snapshots, { now: renderNow, demo: refreshDemo });
|
|
70511
70515
|
const inline_keyboard = kbRows.map((row) => row.map((b) => {
|
|
70512
70516
|
if (b.callbackData)
|
|
70513
70517
|
return { text: b.text, callback_data: b.callbackData };
|
|
@@ -70888,14 +70892,7 @@ bot.command("usage", async (ctx) => {
|
|
|
70888
70892
|
const state4 = await client3.listState();
|
|
70889
70893
|
if (state4.accounts.length > 0) {
|
|
70890
70894
|
const probeResp = await client3.probeQuota(state4.accounts.map((a) => a.label)).catch(() => ({ results: [] }));
|
|
70891
|
-
|
|
70892
|
-
const quotas = state4.accounts.map((a) => {
|
|
70893
|
-
const hit = probeResp.results.find((r) => r.label === a.label);
|
|
70894
|
-
if (hit?.served === "cache" && hit.capturedAt != null) {
|
|
70895
|
-
staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt);
|
|
70896
|
-
}
|
|
70897
|
-
return hit?.result ?? { ok: false, reason: "broker returned no result for account" };
|
|
70898
|
-
});
|
|
70895
|
+
const { quotas, staleCachedAtMs } = zipProbeResults(state4.accounts.map((a) => a.label), probeResp.results);
|
|
70899
70896
|
const { renderAuthSnapshotFormat2: renderAuthSnapshotFormat23, buildSnapshotsFromState: buildSnapshotsFromState4, buildSnapshotKeyboard: buildSnapshotKeyboard3 } = await Promise.resolve().then(() => (init_auth_snapshot_format(), exports_auth_snapshot_format));
|
|
70900
70897
|
const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? "UTC";
|
|
70901
70898
|
const snapshots = buildSnapshotsFromState4(state4, quotas);
|
|
@@ -70905,7 +70902,7 @@ bot.command("usage", async (ctx) => {
|
|
|
70905
70902
|
demo,
|
|
70906
70903
|
...staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: Date.now() }
|
|
70907
70904
|
});
|
|
70908
|
-
const kbRows = buildSnapshotKeyboard3(snapshots, { now: new Date });
|
|
70905
|
+
const kbRows = buildSnapshotKeyboard3(snapshots, { now: new Date, demo });
|
|
70909
70906
|
const keyboard = new import_grammy12.InlineKeyboard;
|
|
70910
70907
|
kbRows.forEach((row, ri) => {
|
|
70911
70908
|
if (ri > 0)
|
|
@@ -403,7 +403,7 @@ export async function handleAuthCommand(
|
|
|
403
403
|
let keyboard: AuthCommandReply['keyboard']
|
|
404
404
|
if (liveQuotas && liveQuotas.length === state.accounts.length) {
|
|
405
405
|
const snapshots = buildSnapshotsFromState(state, liveQuotas)
|
|
406
|
-
keyboard = buildSnapshotKeyboard(snapshots, { now: new Date() })
|
|
406
|
+
keyboard = buildSnapshotKeyboard(snapshots, { now: new Date(), demo: ctx.demo })
|
|
407
407
|
}
|
|
408
408
|
return {
|
|
409
409
|
text: renderShowText(state, Date.now(), {
|
|
@@ -574,7 +574,7 @@ import {
|
|
|
574
574
|
QUOTA_WATCH_CLAIM_WINDOW_MS,
|
|
575
575
|
isLiveCorroboration,
|
|
576
576
|
} from '../quota-watch.js'
|
|
577
|
-
import { buildSnapshotsFromState, buildSnapshotsFromCachedState } from '../auth-snapshot-format.js'
|
|
577
|
+
import { buildSnapshotsFromState, buildSnapshotsFromCachedState, zipProbeResults } from '../auth-snapshot-format.js'
|
|
578
578
|
import { maskUsername, maskVaultKey } from '../demo-mask.js'
|
|
579
579
|
import {
|
|
580
580
|
writeTurnActiveMarker,
|
|
@@ -19284,19 +19284,9 @@ bot.command("auth", async ctx => {
|
|
|
19284
19284
|
const { results } = await client.probeQuota(accounts.map((a) => a.label))
|
|
19285
19285
|
// #2495 Change 2 — the broker tags each result `served:"live"|"cache"`
|
|
19286
19286
|
// (TTL hit or failed-probe fallback). When ANY account was served from
|
|
19287
|
-
// cache,
|
|
19288
|
-
// "⚠ cached Nm ago" instead of a false live stamp.
|
|
19289
|
-
|
|
19290
|
-
// Preserve input order (broker also preserves it, but be defensive).
|
|
19291
|
-
const quotas = accounts.map((a) => {
|
|
19292
|
-
const hit = results.find((r) => r.label === a.label)
|
|
19293
|
-
if (!hit) return { ok: false as const, reason: "broker returned no result for account" }
|
|
19294
|
-
if (hit.served === 'cache' && hit.capturedAt != null) {
|
|
19295
|
-
staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt)
|
|
19296
|
-
}
|
|
19297
|
-
return hit.result
|
|
19298
|
-
})
|
|
19299
|
-
return { quotas, staleCachedAtMs }
|
|
19287
|
+
// cache, zipProbeResults surfaces the OLDEST snapshot's capturedAt so
|
|
19288
|
+
// the card stamps "⚠ cached Nm ago" instead of a false live stamp.
|
|
19289
|
+
return zipProbeResults(accounts.map((a) => a.label), results)
|
|
19300
19290
|
} catch (err) {
|
|
19301
19291
|
// Surface a uniform per-account failure so the dashboard renders
|
|
19302
19292
|
// gracefully (label badge stays UNKNOWN) instead of falling back
|
|
@@ -21283,13 +21273,18 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
|
21283
21273
|
}
|
|
21284
21274
|
|
|
21285
21275
|
// auth:refresh — re-render the /auth snapshot in-place with a fresh
|
|
21286
|
-
// live probe. Replaces the message body; keyboard stays.
|
|
21287
|
-
|
|
21276
|
+
// live probe. Replaces the message body; keyboard stays. The `:demo`
|
|
21277
|
+
// variant re-renders with email masking intact (a ↻ tap on an
|
|
21278
|
+
// `/auth demo` / `/usage demo` card must not unmask mid-recording).
|
|
21279
|
+
if (data === 'auth:refresh' || data === 'auth:refresh:demo') {
|
|
21280
|
+
const refreshDemo = data === 'auth:refresh:demo'
|
|
21288
21281
|
// Freshness throttle: each refresh fan-fires N live api.anthropic.com
|
|
21289
|
-
// probes (one per account
|
|
21290
|
-
//
|
|
21291
|
-
//
|
|
21292
|
-
//
|
|
21282
|
+
// probes (one per account — forceLive bypasses the broker's 45s
|
|
21283
|
+
// probe-on-open TTL, because an explicit ↻ tap is the user asking
|
|
21284
|
+
// for live-now data). Without this, a user double-tapping the ↻
|
|
21285
|
+
// button burns through their account's RPM budget on duplicate
|
|
21286
|
+
// work. Cap at one per AUTH_REFRESH_THROTTLE_MS per (chat, message)
|
|
21287
|
+
// pair.
|
|
21293
21288
|
const refreshMsg = ctx.callbackQuery?.message
|
|
21294
21289
|
if (refreshMsg) {
|
|
21295
21290
|
const key = `${refreshMsg.chat.id}:${refreshMsg.message_id}`
|
|
@@ -21315,24 +21310,34 @@ async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
|
|
|
21315
21310
|
}
|
|
21316
21311
|
const state = await client.listState()
|
|
21317
21312
|
// Broker-routed probe (#1336) — see gateway.ts:8910 for diagnosis.
|
|
21313
|
+
// forceLive=true: an explicit ↻ tap must bypass the broker's
|
|
21314
|
+
// probe-on-open TTL — pre-fix, a tap inside the TTL window served
|
|
21315
|
+
// the cached snapshot while stamping "Live · refreshed 0s ago".
|
|
21318
21316
|
const probeResp = state.accounts.length > 0
|
|
21319
|
-
? await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
|
|
21317
|
+
? await client.probeQuota(state.accounts.map((a) => a.label), undefined, true).catch(() => ({ results: [] }))
|
|
21320
21318
|
: { results: [] }
|
|
21321
|
-
|
|
21322
|
-
|
|
21323
|
-
|
|
21324
|
-
}
|
|
21319
|
+
// #2495 Change 2 — even under forceLive a failed upstream probe falls
|
|
21320
|
+
// back to the broker cache (served:"cache"); stamp "⚠ cached Nm ago"
|
|
21321
|
+
// instead of a false live stamp, same as the /auth and /usage paths.
|
|
21322
|
+
const { quotas, staleCachedAtMs } = zipProbeResults(
|
|
21323
|
+
state.accounts.map((a) => a.label),
|
|
21324
|
+
probeResp.results,
|
|
21325
|
+
)
|
|
21325
21326
|
const tz = process.env.SWITCHROOM_TIMEZONE ?? process.env.TZ ?? 'UTC'
|
|
21326
21327
|
const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
|
|
21327
21328
|
'../auth-snapshot-format.js'
|
|
21328
21329
|
)
|
|
21329
21330
|
const snapshots = buildSnapshotsFromState(state, quotas)
|
|
21331
|
+
// Single clock for card body + keyboard so health classification
|
|
21332
|
+
// can't disagree between the two (#2495 folded nit A).
|
|
21333
|
+
const renderNow = new Date()
|
|
21330
21334
|
const text = renderAuthSnapshotFormat2(snapshots, {
|
|
21331
21335
|
tz,
|
|
21332
|
-
now:
|
|
21333
|
-
|
|
21336
|
+
now: renderNow,
|
|
21337
|
+
demo: refreshDemo,
|
|
21338
|
+
...(staleCachedAtMs != null ? { staleCachedAtMs } : { liveProbedAtMs: renderNow.getTime() }),
|
|
21334
21339
|
})
|
|
21335
|
-
const kbRows = buildSnapshotKeyboard(snapshots)
|
|
21340
|
+
const kbRows = buildSnapshotKeyboard(snapshots, { now: renderNow, demo: refreshDemo })
|
|
21336
21341
|
const inline_keyboard = kbRows.map((row) =>
|
|
21337
21342
|
row.map((b) => {
|
|
21338
21343
|
if (b.callbackData) return { text: b.text, callback_data: b.callbackData }
|
|
@@ -21797,14 +21802,10 @@ bot.command('usage', async ctx => {
|
|
|
21797
21802
|
// which we surface as a "⚠ cached Nm ago" footer instead of a false
|
|
21798
21803
|
// live stamp.
|
|
21799
21804
|
const probeResp = await client.probeQuota(state.accounts.map((a) => a.label)).catch(() => ({ results: [] }))
|
|
21800
|
-
|
|
21801
|
-
|
|
21802
|
-
|
|
21803
|
-
|
|
21804
|
-
staleCachedAtMs = staleCachedAtMs == null ? hit.capturedAt : Math.min(staleCachedAtMs, hit.capturedAt)
|
|
21805
|
-
}
|
|
21806
|
-
return hit?.result ?? { ok: false as const, reason: 'broker returned no result for account' }
|
|
21807
|
-
})
|
|
21805
|
+
const { quotas, staleCachedAtMs } = zipProbeResults(
|
|
21806
|
+
state.accounts.map((a) => a.label),
|
|
21807
|
+
probeResp.results,
|
|
21808
|
+
)
|
|
21808
21809
|
const { renderAuthSnapshotFormat2, buildSnapshotsFromState, buildSnapshotKeyboard } = await import(
|
|
21809
21810
|
'../auth-snapshot-format.js'
|
|
21810
21811
|
)
|
|
@@ -21821,7 +21822,7 @@ bot.command('usage', async ctx => {
|
|
|
21821
21822
|
// /auth snapshot does. switchroomReply routes through the rich path
|
|
21822
21823
|
// (replyWithRichMessage), which accepts reply_markup. Build a grammy
|
|
21823
21824
|
// InlineKeyboard so the markup type matches switchroomReply's contract.
|
|
21824
|
-
const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date() })
|
|
21825
|
+
const kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
|
|
21825
21826
|
const keyboard = new InlineKeyboard()
|
|
21826
21827
|
kbRows.forEach((row, ri) => {
|
|
21827
21828
|
if (ri > 0) keyboard.row()
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
renderFallbackAnnouncement,
|
|
19
19
|
buildSnapshotKeyboard,
|
|
20
20
|
buildSnapshotsFromState,
|
|
21
|
+
zipProbeResults,
|
|
21
22
|
buildSnapshotsFromCachedState,
|
|
22
23
|
reviveLastQuota,
|
|
23
24
|
THROTTLING_THRESHOLD_PCT,
|
|
@@ -720,6 +721,80 @@ describe('buildSnapshotKeyboard', () => {
|
|
|
720
721
|
.flat().map((b) => b.text);
|
|
721
722
|
expect(before).not.toContain('Switch fleet → refilled@x');
|
|
722
723
|
});
|
|
724
|
+
|
|
725
|
+
it('demo mode masks the switch-button label but keeps the real label in callback_data', () => {
|
|
726
|
+
__resetDemoMaskCachesForTest();
|
|
727
|
+
const snaps: AccountSnapshot[] = [
|
|
728
|
+
snap({ label: 'ken.real@example.com', isActive: true, quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
729
|
+
snap({ label: 'alt.real@example.com', quota: quota({ fiveHourUtilizationPct: 5 }) }),
|
|
730
|
+
];
|
|
731
|
+
const rows = buildSnapshotKeyboard(snaps, { now: NOW, demo: true });
|
|
732
|
+
const switchBtn = rows.flat().find((b) => b.callbackData?.startsWith('auth:use:'));
|
|
733
|
+
expect(switchBtn).toBeDefined();
|
|
734
|
+
// Label masked — the real email never appears on screen…
|
|
735
|
+
expect(switchBtn!.text).not.toContain('alt.real@example.com');
|
|
736
|
+
// …but the broker still gets the real label to act on.
|
|
737
|
+
expect(switchBtn!.callbackData).toBe('auth:use:alt.real@example.com');
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('demo mode flips the refresh callback to auth:refresh:demo so a ↻ tap stays masked', () => {
|
|
741
|
+
const snaps: AccountSnapshot[] = [
|
|
742
|
+
snap({ label: 'a@x', isActive: true, quota: quota({}) }),
|
|
743
|
+
];
|
|
744
|
+
const demoRows = buildSnapshotKeyboard(snaps, { demo: true }).flat();
|
|
745
|
+
expect(demoRows.find((b) => b.text === '↻ Refresh')?.callbackData).toBe('auth:refresh:demo');
|
|
746
|
+
const plainRows = buildSnapshotKeyboard(snaps).flat();
|
|
747
|
+
expect(plainRows.find((b) => b.text === '↻ Refresh')?.callbackData).toBe('auth:refresh');
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// ── zipProbeResults ──────────────────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
describe('zipProbeResults', () => {
|
|
754
|
+
const okResult = { ok: true as const, data: quota({ fiveHourUtilizationPct: 5 }) };
|
|
755
|
+
|
|
756
|
+
it('returns quotas parallel to labels with no staleCachedAtMs when everything is live', () => {
|
|
757
|
+
const { quotas, staleCachedAtMs } = zipProbeResults(
|
|
758
|
+
['a@x', 'b@x'],
|
|
759
|
+
[
|
|
760
|
+
{ label: 'a@x', result: okResult, served: 'live' },
|
|
761
|
+
{ label: 'b@x', result: okResult, served: 'live' },
|
|
762
|
+
],
|
|
763
|
+
);
|
|
764
|
+
expect(quotas).toHaveLength(2);
|
|
765
|
+
expect(quotas.every((q) => q.ok)).toBe(true);
|
|
766
|
+
expect(staleCachedAtMs).toBeUndefined();
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it('surfaces the OLDEST capturedAt among cache-served rows (#2495 Change 2)', () => {
|
|
770
|
+
const { staleCachedAtMs } = zipProbeResults(
|
|
771
|
+
['a@x', 'b@x', 'c@x'],
|
|
772
|
+
[
|
|
773
|
+
{ label: 'a@x', result: okResult, served: 'live' },
|
|
774
|
+
{ label: 'b@x', result: okResult, served: 'cache', capturedAt: 5_000 },
|
|
775
|
+
{ label: 'c@x', result: okResult, served: 'cache', capturedAt: 2_000 },
|
|
776
|
+
],
|
|
777
|
+
);
|
|
778
|
+
expect(staleCachedAtMs).toBe(2_000);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('degrades a missing per-label row to ok:false, preserving input order', () => {
|
|
782
|
+
const { quotas } = zipProbeResults(
|
|
783
|
+
['a@x', 'gone@x'],
|
|
784
|
+
[{ label: 'a@x', result: okResult }],
|
|
785
|
+
);
|
|
786
|
+
expect(quotas[0]!.ok).toBe(true);
|
|
787
|
+
expect(quotas[1]!.ok).toBe(false);
|
|
788
|
+
expect((quotas[1] as { ok: false; reason: string }).reason).toContain('no result');
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it('ignores served:"cache" rows without capturedAt (no false staleness)', () => {
|
|
792
|
+
const { staleCachedAtMs } = zipProbeResults(
|
|
793
|
+
['a@x'],
|
|
794
|
+
[{ label: 'a@x', result: okResult, served: 'cache' }],
|
|
795
|
+
);
|
|
796
|
+
expect(staleCachedAtMs).toBeUndefined();
|
|
797
|
+
});
|
|
723
798
|
});
|
|
724
799
|
|
|
725
800
|
// ── buildSnapshotsFromState ──────────────────────────────────────────
|