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
package/README.md
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
# Switchroom
|
|
6
6
|
|
|
7
|
-
[](https://github.com/switchroom/switchroom/actions/workflows/ci-tests-core.yml)
|
|
8
|
+
[](https://github.com/switchroom/switchroom/actions/workflows/ci-tests-plugin.yml)
|
|
9
|
+
[](https://github.com/switchroom/switchroom/actions/workflows/docker-e2e.yml)
|
|
10
|
+
[](https://github.com/switchroom/switchroom/actions/workflows/ci-evals.yml)
|
|
11
|
+
[](https://github.com/switchroom/switchroom/actions/workflows/ci-evals.yml)
|
|
11
12
|
|
|
12
13
|
**A switchboard for your Pro or Max.** Your Claude subscription, as a fleet of always-on specialist agents you talk to from Telegram. Opinionated UX, done properly.
|
|
13
14
|
|
|
@@ -11286,7 +11286,7 @@ var QuotaConfigSchema = exports_external.object({
|
|
|
11286
11286
|
monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
|
|
11287
11287
|
});
|
|
11288
11288
|
var HostControlConfigSchema = exports_external.object({
|
|
11289
|
-
enabled: exports_external.boolean().
|
|
11289
|
+
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
|
|
11290
11290
|
});
|
|
11291
11291
|
var SwitchroomConfigSchema = exports_external.object({
|
|
11292
11292
|
switchroom: exports_external.object({
|
|
@@ -11312,7 +11312,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
11312
11312
|
drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
|
|
11313
11313
|
google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
|
|
11314
11314
|
quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
|
|
11315
|
-
host_control: HostControlConfigSchema.
|
|
11315
|
+
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
|
|
11316
11316
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
11317
11317
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
11318
11318
|
}).transform((v) => v.trim().toLowerCase()), exports_external.object({
|
|
@@ -11286,7 +11286,7 @@ var QuotaConfigSchema = exports_external.object({
|
|
|
11286
11286
|
monthly_budget_usd: exports_external.number().positive().optional().describe("Monthly USD spend budget. If unset, the greeting shows raw usage only.")
|
|
11287
11287
|
});
|
|
11288
11288
|
var HostControlConfigSchema = exports_external.object({
|
|
11289
|
-
enabled: exports_external.boolean().
|
|
11289
|
+
enabled: exports_external.boolean().default(true).describe("Whether the host-control daemon is in use. Default: true (since " + "RFC C Phase 2 default-flip — the gateway's /restart, /new, /reset, " + "and /update apply slash-commands all dispatch through hostd, and " + "without it those verbs fail on docker-mode installs because the " + "agent container has no docker binary/socket). " + "When true, the compose generator emits per-agent bind mounts " + "at `~/.switchroom/hostd/<name>/sock` for every admin-flagged " + "agent. Install the daemon with `switchroom hostd install` — " + "it runs as a docker container in its own compose project " + "(`switchroom-hostd`), separate from the agent fleet's compose " + "project so `up -d --remove-orphans` cycles of the fleet " + "can't recreate the daemon mid-RPC. See RFC C §5.1. " + "Set enabled: false only on legacy systemd-mode installs that " + "still rely on the in-container `spawnSwitchroomDetached` " + "shellout (removal is tracked as RFC C Phase 3).")
|
|
11290
11290
|
});
|
|
11291
11291
|
var SwitchroomConfigSchema = exports_external.object({
|
|
11292
11292
|
switchroom: exports_external.object({
|
|
@@ -11312,7 +11312,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
11312
11312
|
drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key — use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
|
|
11313
11313
|
google_workspace: GoogleWorkspaceConfigSchema.describe("RFC G canonical key. Top-level Google Workspace configuration — " + "OAuth client credentials, approver allowlist, and tier knob (`core` " + "| `extended` | `complete`, default `core`). Mutually exclusive with " + "`drive:` at the top level (loader fails fast if both are set)."),
|
|
11314
11314
|
quota: QuotaConfigSchema.optional().describe("Optional weekly/monthly USD spend budgets rendered in the session " + "greeting. Usage is read from ccusage at runtime; no network calls."),
|
|
11315
|
-
host_control: HostControlConfigSchema.
|
|
11315
|
+
host_control: HostControlConfigSchema.default({}).describe("Host-control daemon configuration. Defaults to enabled=true since " + "RFC C Phase 2 (docs/rfcs/host-control-daemon.md). Omit the block " + "to accept defaults; set `enabled: false` only on legacy systemd-" + "mode installs (removal tracked as RFC C Phase 3)."),
|
|
11316
11316
|
google_accounts: exports_external.record(exports_external.string().regex(/^[^@\s:]+@[^@\s:]+\.[^@\s:]+$/, {
|
|
11317
11317
|
message: "Account key must be a Google account email like 'alice@example.com' (colons not allowed)"
|
|
11318
11318
|
}).transform((v) => v.trim().toLowerCase()), exports_external.object({
|
|
@@ -11947,6 +11947,93 @@ function allocateAgentUid(name) {
|
|
|
11947
11947
|
}
|
|
11948
11948
|
var BIND_MOUNT_EXACT_SOURCE_DENY = new Set(["/var/run/docker.sock"]);
|
|
11949
11949
|
|
|
11950
|
+
// src/auth/quota.ts
|
|
11951
|
+
var OAUTH_BETA = "oauth-2025-04-20";
|
|
11952
|
+
var DEFAULT_USER_AGENT = "claude-cli/1.0.0 (external, cli)";
|
|
11953
|
+
var DEFAULT_PROBE_MODEL = "claude-haiku-4-5-20251001";
|
|
11954
|
+
function parseFloatHeader(headers, name) {
|
|
11955
|
+
const v = headers.get(name);
|
|
11956
|
+
if (v == null || v.trim().length === 0)
|
|
11957
|
+
return null;
|
|
11958
|
+
const n = Number(v);
|
|
11959
|
+
return Number.isFinite(n) ? n : null;
|
|
11960
|
+
}
|
|
11961
|
+
function parseEpochHeader(headers, name) {
|
|
11962
|
+
const v = headers.get(name);
|
|
11963
|
+
if (v == null)
|
|
11964
|
+
return null;
|
|
11965
|
+
const n = Number(v);
|
|
11966
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
11967
|
+
return null;
|
|
11968
|
+
return new Date(n * 1000);
|
|
11969
|
+
}
|
|
11970
|
+
function parseQuotaHeaders(headers) {
|
|
11971
|
+
const fiveHour = parseFloatHeader(headers, "anthropic-ratelimit-unified-5h-utilization");
|
|
11972
|
+
const sevenDay = parseFloatHeader(headers, "anthropic-ratelimit-unified-7d-utilization");
|
|
11973
|
+
if (fiveHour == null && sevenDay == null) {
|
|
11974
|
+
return {
|
|
11975
|
+
ok: false,
|
|
11976
|
+
reason: "no unified rate-limit headers in response (API token, not OAuth?)"
|
|
11977
|
+
};
|
|
11978
|
+
}
|
|
11979
|
+
return {
|
|
11980
|
+
ok: true,
|
|
11981
|
+
data: {
|
|
11982
|
+
fiveHourUtilizationPct: (fiveHour ?? 0) * 100,
|
|
11983
|
+
sevenDayUtilizationPct: (sevenDay ?? 0) * 100,
|
|
11984
|
+
fiveHourResetAt: parseEpochHeader(headers, "anthropic-ratelimit-unified-5h-reset"),
|
|
11985
|
+
sevenDayResetAt: parseEpochHeader(headers, "anthropic-ratelimit-unified-7d-reset"),
|
|
11986
|
+
representativeClaim: headers.get("anthropic-ratelimit-unified-representative-claim"),
|
|
11987
|
+
overageStatus: headers.get("anthropic-ratelimit-unified-overage-status"),
|
|
11988
|
+
overageDisabledReason: headers.get("anthropic-ratelimit-unified-overage-disabled-reason")
|
|
11989
|
+
}
|
|
11990
|
+
};
|
|
11991
|
+
}
|
|
11992
|
+
async function fetchQuota(opts) {
|
|
11993
|
+
const token = opts.accessToken?.trim();
|
|
11994
|
+
if (!token || token.length === 0) {
|
|
11995
|
+
return { ok: false, reason: "fetchQuota requires a non-empty accessToken" };
|
|
11996
|
+
}
|
|
11997
|
+
const controller = new AbortController;
|
|
11998
|
+
const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1e4);
|
|
11999
|
+
const fetchFn = opts.fetchImpl ?? fetch;
|
|
12000
|
+
let resp;
|
|
12001
|
+
try {
|
|
12002
|
+
resp = await fetchFn("https://api.anthropic.com/v1/messages", {
|
|
12003
|
+
method: "POST",
|
|
12004
|
+
headers: {
|
|
12005
|
+
"anthropic-version": "2023-06-01",
|
|
12006
|
+
"anthropic-beta": OAUTH_BETA,
|
|
12007
|
+
authorization: `Bearer ${token}`,
|
|
12008
|
+
"x-app": "cli",
|
|
12009
|
+
"user-agent": DEFAULT_USER_AGENT,
|
|
12010
|
+
"content-type": "application/json"
|
|
12011
|
+
},
|
|
12012
|
+
body: JSON.stringify({
|
|
12013
|
+
model: opts.model ?? DEFAULT_PROBE_MODEL,
|
|
12014
|
+
max_tokens: 1,
|
|
12015
|
+
messages: [{ role: "user", content: "hi" }]
|
|
12016
|
+
}),
|
|
12017
|
+
signal: controller.signal
|
|
12018
|
+
});
|
|
12019
|
+
} catch (err) {
|
|
12020
|
+
const msg = err?.message ?? String(err);
|
|
12021
|
+
clearTimeout(timeout);
|
|
12022
|
+
if (msg.includes("aborted")) {
|
|
12023
|
+
return { ok: false, reason: `quota probe timed out after ${opts.timeoutMs ?? 1e4}ms` };
|
|
12024
|
+
}
|
|
12025
|
+
return { ok: false, reason: `quota probe network error: ${msg}` };
|
|
12026
|
+
}
|
|
12027
|
+
clearTimeout(timeout);
|
|
12028
|
+
const parsed = parseQuotaHeaders(resp.headers);
|
|
12029
|
+
if (parsed.ok)
|
|
12030
|
+
return parsed;
|
|
12031
|
+
if (!resp.ok) {
|
|
12032
|
+
return { ok: false, reason: `HTTP ${resp.status} from Anthropic (${parsed.reason})` };
|
|
12033
|
+
}
|
|
12034
|
+
return parsed;
|
|
12035
|
+
}
|
|
12036
|
+
|
|
11950
12037
|
// src/util/atomic.ts
|
|
11951
12038
|
import { randomBytes } from "node:crypto";
|
|
11952
12039
|
import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
@@ -12634,6 +12721,13 @@ var ListGoogleAccountsRequestSchema = exports_external.object({
|
|
|
12634
12721
|
op: exports_external.literal("list-google-accounts"),
|
|
12635
12722
|
id: exports_external.string().min(1)
|
|
12636
12723
|
});
|
|
12724
|
+
var ProbeQuotaRequestSchema = exports_external.object({
|
|
12725
|
+
v: exports_external.literal(PROTOCOL_VERSION),
|
|
12726
|
+
op: exports_external.literal("probe-quota"),
|
|
12727
|
+
id: exports_external.string().min(1),
|
|
12728
|
+
accounts: exports_external.array(exports_external.string().min(1)).min(1).max(32),
|
|
12729
|
+
timeoutMs: exports_external.number().int().positive().max(60000).optional()
|
|
12730
|
+
});
|
|
12637
12731
|
var RequestSchema = exports_external.discriminatedUnion("op", [
|
|
12638
12732
|
GetCredentialsRequestSchema,
|
|
12639
12733
|
ListStateRequestSchema,
|
|
@@ -12643,7 +12737,8 @@ var RequestSchema = exports_external.discriminatedUnion("op", [
|
|
|
12643
12737
|
AddAccountRequestSchema,
|
|
12644
12738
|
RmAccountRequestSchema,
|
|
12645
12739
|
SetOverrideRequestSchema,
|
|
12646
|
-
ListGoogleAccountsRequestSchema
|
|
12740
|
+
ListGoogleAccountsRequestSchema,
|
|
12741
|
+
ProbeQuotaRequestSchema
|
|
12647
12742
|
]);
|
|
12648
12743
|
var GetCredentialsDataSchema = exports_external.object({
|
|
12649
12744
|
account: exports_external.string(),
|
|
@@ -13167,6 +13262,9 @@ class AuthBroker {
|
|
|
13167
13262
|
case "list-google-accounts":
|
|
13168
13263
|
await this.opListGoogleAccounts(socket, reqId, identity2);
|
|
13169
13264
|
break;
|
|
13265
|
+
case "probe-quota":
|
|
13266
|
+
await this.opProbeQuota(socket, reqId, identity2, req.accounts, req.timeoutMs);
|
|
13267
|
+
break;
|
|
13170
13268
|
}
|
|
13171
13269
|
} catch (err) {
|
|
13172
13270
|
socket.write(encodeError(reqId, "INTERNAL", err.message));
|
|
@@ -13265,6 +13363,30 @@ class AuthBroker {
|
|
|
13265
13363
|
this.audit({ op: "list-google-accounts", identity: identity2, ok: true });
|
|
13266
13364
|
socket.write(encodeSuccess(id, { accounts }));
|
|
13267
13365
|
}
|
|
13366
|
+
async opProbeQuota(socket, id, identity2, accounts, timeoutMs) {
|
|
13367
|
+
const results = await Promise.all(accounts.map(async (label) => {
|
|
13368
|
+
const creds = readAccountCredentials(label, this.home);
|
|
13369
|
+
const token = creds?.claudeAiOauth?.accessToken;
|
|
13370
|
+
if (!token) {
|
|
13371
|
+
const result2 = {
|
|
13372
|
+
ok: false,
|
|
13373
|
+
reason: "no credentials for account in broker store"
|
|
13374
|
+
};
|
|
13375
|
+
this.audit({ op: "probe-quota", identity: identity2, account: label, ok: false, error: "missing-credentials" });
|
|
13376
|
+
return { label, result: result2 };
|
|
13377
|
+
}
|
|
13378
|
+
const result = await fetchQuota({ accessToken: token, timeoutMs });
|
|
13379
|
+
this.audit({
|
|
13380
|
+
op: "probe-quota",
|
|
13381
|
+
identity: identity2,
|
|
13382
|
+
account: label,
|
|
13383
|
+
ok: result.ok,
|
|
13384
|
+
error: result.ok ? undefined : result.reason
|
|
13385
|
+
});
|
|
13386
|
+
return { label, result };
|
|
13387
|
+
}));
|
|
13388
|
+
socket.write(encodeSuccess(id, { results }));
|
|
13389
|
+
}
|
|
13268
13390
|
async opSetActive(socket, id, identity2, account) {
|
|
13269
13391
|
if (!this.isAdmin(identity2)) {
|
|
13270
13392
|
this.audit({ op: "set-active", identity: identity2, account, ok: false, error: "FORBIDDEN" });
|