switchroom 0.19.0 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/agent-scheduler/index.js +29 -1
  2. package/dist/auth-broker/index.js +552 -48
  3. package/dist/cli/autoaccept-poll.js +29 -1
  4. package/dist/cli/drive-write-pretool.mjs +30 -2
  5. package/dist/cli/ms-365-write-pretool.mjs +30 -2
  6. package/dist/cli/switchroom.js +751 -36
  7. package/dist/host-control/main.js +3 -3
  8. package/dist/vault/approvals/kernel-server.js +2 -2
  9. package/dist/vault/broker/server.js +2 -2
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +1 -0
  12. package/skills/switchroom-cli/SKILL.md +25 -0
  13. package/telegram-plugin/auth-snapshot-format.ts +39 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +363 -25
  15. package/telegram-plugin/external-spend.ts +135 -0
  16. package/telegram-plugin/gateway/gateway.ts +83 -67
  17. package/telegram-plugin/gateway/model-command.ts +106 -0
  18. package/telegram-plugin/gateway/narrative-lane.ts +23 -9
  19. package/telegram-plugin/gateway/status-pin-store.ts +64 -4
  20. package/telegram-plugin/gateway/usage-mask.ts +29 -0
  21. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +19 -2
  22. package/telegram-plugin/quota-bar-format.ts +18 -0
  23. package/telegram-plugin/quota-check.ts +17 -2
  24. package/telegram-plugin/tests/activity-card-wiring.test.ts +47 -0
  25. package/telegram-plugin/tests/external-spend.test.ts +168 -0
  26. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +57 -23
  27. package/telegram-plugin/tests/quota-bar-format.test.ts +43 -0
  28. package/telegram-plugin/tests/quota-check.test.ts +57 -0
  29. package/telegram-plugin/tests/status-pin-store.test.ts +198 -0
  30. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +50 -0
  31. package/telegram-plugin/tests/usage-footer-freshness.test.ts +141 -0
  32. package/telegram-plugin/tests/usage-mask.test.ts +35 -0
  33. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +27 -0
  34. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +131 -1
@@ -7012,14 +7012,27 @@ var require_dist = __commonJS((exports) => {
7012
7012
 
7013
7013
  // src/util/atomic.ts
7014
7014
  import { randomBytes } from "node:crypto";
7015
- import { closeSync, constants, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
7016
- function atomicWriteFileSync(destPath, contents, mode = 384) {
7015
+ import { closeSync, constants, fchmodSync, fchownSync, fsyncSync, openSync, renameSync, rmSync, writeSync } from "node:fs";
7016
+ function atomicWriteFileSync(destPath, contents, modeOrOpts = 384) {
7017
+ const opts = typeof modeOrOpts === "number" ? { mode: modeOrOpts } : modeOrOpts;
7018
+ const mode = opts.mode ?? 384;
7017
7019
  const tmp = `${destPath}.tmp-${process.pid}-${randomBytes(4).toString("hex")}`;
7018
7020
  const buf = typeof contents === "string" ? Buffer.from(contents, "utf-8") : contents;
7019
7021
  let fd = null;
7020
7022
  try {
7021
7023
  fd = openSync(tmp, TMP_OPEN_FLAGS, mode);
7022
7024
  writeSync(fd, buf, 0, buf.length, 0);
7025
+ fchmodSync(fd, mode);
7026
+ if (opts.uid !== undefined) {
7027
+ try {
7028
+ fchownSync(fd, opts.uid, opts.gid ?? opts.uid);
7029
+ } catch (chownErr) {
7030
+ if (opts.onChownError)
7031
+ opts.onChownError(chownErr);
7032
+ else
7033
+ throw chownErr;
7034
+ }
7035
+ }
7023
7036
  fsyncSync(fd);
7024
7037
  closeSync(fd);
7025
7038
  fd = null;
@@ -18221,9 +18234,10 @@ import {
18221
18234
  chmodSync,
18222
18235
  chownSync as chownSync2,
18223
18236
  existsSync as existsSync8,
18224
- lstatSync,
18237
+ lstatSync as lstatSync2,
18225
18238
  mkdirSync as mkdirSync5,
18226
18239
  readFileSync as readFileSync7,
18240
+ realpathSync as realpathSync3,
18227
18241
  renameSync as renameSync3,
18228
18242
  rmSync as rmSync5,
18229
18243
  statSync as statSync6,
@@ -18233,7 +18247,7 @@ import {
18233
18247
  import { closeSync as closeSync2, openSync as openSync2, writeSync as writeSync2 } from "node:fs";
18234
18248
  import * as constants2 from "node:constants";
18235
18249
  import { createHash as createHash2 } from "node:crypto";
18236
- import { dirname as dirname3, join as join8, resolve as resolve9 } from "node:path";
18250
+ import { basename as basename2, dirname as dirname3, join as join8, resolve as resolve9 } from "node:path";
18237
18251
 
18238
18252
  // src/agents/scaffold.ts
18239
18253
  import { join as join4, resolve as resolve5 } from "node:path";
@@ -18343,7 +18357,7 @@ var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.
18343
18357
  var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
18344
18358
 
18345
18359
  // src/memory/hindsight.ts
18346
- var DEFAULT_RETAIN_MISSION = "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise.";
18360
+ var DEFAULT_RETAIN_MISSION = "Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) — only retain the outcome once a task " + "actually completes or a decision is made.";
18347
18361
  var PROFILE_MEMORY_DEFAULTS = {
18348
18362
  "health-coach": {
18349
18363
  disposition: { skepticism: 2, literalism: 2, empathy: 5 },
@@ -19198,6 +19212,187 @@ function bestPremiumHeadroomAccount(ledger, candidates, now) {
19198
19212
  // src/auth/broker/server.ts
19199
19213
  init_atomic();
19200
19214
 
19215
+ // src/litellm/external-spend.ts
19216
+ var EXTERNAL_SPEND_TOP_N = 3;
19217
+ var DEFAULT_LITELLM_BASE = "http://127.0.0.1:4010";
19218
+ var EXTERNAL_SPEND_CACHE_TTL_MS = 90000;
19219
+ var EXTERNAL_SPEND_FETCH_TIMEOUT_MS = 5000;
19220
+ var LITELLM_MASTER_KEY_STATE_BASENAME = "litellm-master-key";
19221
+ var BARE_EXTERNAL_NEEDLES = [
19222
+ "gpt-oss",
19223
+ "grok",
19224
+ "gemini",
19225
+ "deepseek",
19226
+ "kimi",
19227
+ "glm-",
19228
+ "qwen",
19229
+ "llama"
19230
+ ];
19231
+ function isExternalModel(model) {
19232
+ const m = model.trim().toLowerCase();
19233
+ if (!m)
19234
+ return false;
19235
+ if (m.startsWith("claude"))
19236
+ return false;
19237
+ if (m.includes("anthropic/claude"))
19238
+ return false;
19239
+ if (m.startsWith("openrouter/"))
19240
+ return true;
19241
+ if (m.startsWith("sr-"))
19242
+ return true;
19243
+ for (const needle of BARE_EXTERNAL_NEEDLES) {
19244
+ if (m.includes(needle))
19245
+ return true;
19246
+ }
19247
+ return false;
19248
+ }
19249
+ function shortModelLabel(model) {
19250
+ let m = model.trim();
19251
+ if (m.toLowerCase().startsWith("openrouter/")) {
19252
+ m = m.slice("openrouter/".length);
19253
+ }
19254
+ const parts = m.split("/").filter(Boolean);
19255
+ if (parts.length >= 2) {
19256
+ m = parts[parts.length - 1];
19257
+ }
19258
+ if (m.toLowerCase().startsWith("sr-")) {
19259
+ m = m.slice(3);
19260
+ }
19261
+ return m || model.trim();
19262
+ }
19263
+ function utcDateString(d) {
19264
+ return d.toISOString().slice(0, 10);
19265
+ }
19266
+ function addUtcDays(dateStr, days) {
19267
+ const [y, m, d] = dateStr.split("-").map(Number);
19268
+ const dt = new Date(Date.UTC(y, m - 1, d));
19269
+ dt.setUTCDate(dt.getUTCDate() + days);
19270
+ return utcDateString(dt);
19271
+ }
19272
+ function rowDay(row) {
19273
+ const st = row.startTime;
19274
+ if (!st || typeof st !== "string")
19275
+ return null;
19276
+ return st.length >= 10 ? st.slice(0, 10) : null;
19277
+ }
19278
+ function externalModelsFromRow(row) {
19279
+ const out = {};
19280
+ const models = row.models ?? {};
19281
+ for (const [name, raw] of Object.entries(models)) {
19282
+ if (!isExternalModel(name))
19283
+ continue;
19284
+ const n = typeof raw === "number" ? raw : Number(raw);
19285
+ if (!Number.isFinite(n) || n === 0)
19286
+ continue;
19287
+ out[name] = (out[name] ?? 0) + n;
19288
+ }
19289
+ return out;
19290
+ }
19291
+ function summarizeExternalSpend(days, now = new Date) {
19292
+ const today = utcDateString(now);
19293
+ const start7 = addUtcDays(today, -6);
19294
+ let day24hUsd = 0;
19295
+ let day7dUsd = 0;
19296
+ const byModel = {};
19297
+ for (const row of days) {
19298
+ const day = rowDay(row);
19299
+ if (!day)
19300
+ continue;
19301
+ if (day < start7 || day > today)
19302
+ continue;
19303
+ const ext = externalModelsFromRow(row);
19304
+ let rowSum = 0;
19305
+ for (const [name, usd] of Object.entries(ext)) {
19306
+ rowSum += usd;
19307
+ byModel[name] = (byModel[name] ?? 0) + usd;
19308
+ }
19309
+ day7dUsd += rowSum;
19310
+ if (day === today)
19311
+ day24hUsd += rowSum;
19312
+ }
19313
+ const top = Object.entries(byModel).filter(([, usd]) => usd > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, EXTERNAL_SPEND_TOP_N).map(([name, usd]) => ({ label: shortModelLabel(name), usd }));
19314
+ return { day24hUsd, day7dUsd, top };
19315
+ }
19316
+ function normalizeSpendLogRows(body) {
19317
+ if (Array.isArray(body))
19318
+ return body;
19319
+ if (body && typeof body === "object") {
19320
+ const data = body.data;
19321
+ if (Array.isArray(data))
19322
+ return data;
19323
+ }
19324
+ return [];
19325
+ }
19326
+ async function fetchAndSummarizeExternalSpend(opts) {
19327
+ const now = opts.now ?? new Date;
19328
+ const base = (opts.baseUrl ?? DEFAULT_LITELLM_BASE).replace(/\/+$/, "");
19329
+ const today = utcDateString(now);
19330
+ const start = addUtcDays(today, -6);
19331
+ const end = addUtcDays(today, 1);
19332
+ const url = `${base}/spend/logs?start_date=${encodeURIComponent(start)}` + `&end_date=${encodeURIComponent(end)}`;
19333
+ const fetchImpl = opts.fetchImpl ?? fetch;
19334
+ const timeoutMs = opts.timeoutMs ?? EXTERNAL_SPEND_FETCH_TIMEOUT_MS;
19335
+ const ac = new AbortController;
19336
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
19337
+ try {
19338
+ const res = await fetchImpl(url, {
19339
+ method: "GET",
19340
+ headers: {
19341
+ Authorization: `Bearer ${opts.adminKey}`,
19342
+ Accept: "application/json"
19343
+ },
19344
+ signal: ac.signal
19345
+ });
19346
+ if (!res.ok)
19347
+ return null;
19348
+ let body;
19349
+ try {
19350
+ body = await res.json();
19351
+ } catch {
19352
+ return null;
19353
+ }
19354
+ return summarizeExternalSpend(normalizeSpendLogRows(body), now);
19355
+ } catch {
19356
+ return null;
19357
+ } finally {
19358
+ clearTimeout(timer);
19359
+ }
19360
+ }
19361
+
19362
+ // src/auth/broker/mirror-write.ts
19363
+ init_atomic();
19364
+ import { lstatSync, realpathSync as realpathSync2 } from "node:fs";
19365
+ function safeMirrorWrite(input) {
19366
+ let realParent;
19367
+ try {
19368
+ realParent = realpathSync2(input.parentDir);
19369
+ } catch (err) {
19370
+ input.logErr(`mirror parent realpath failed: ${err.message}`);
19371
+ return false;
19372
+ }
19373
+ if (realParent !== input.expectedRealParent) {
19374
+ input.refuse("parentDir", "realpath-mismatch");
19375
+ return false;
19376
+ }
19377
+ try {
19378
+ atomicWriteFileSync(input.targetPath, input.content, {
19379
+ mode: input.mode ?? 384,
19380
+ uid: input.uid,
19381
+ gid: input.gid ?? input.uid,
19382
+ onChownError: input.onChownError
19383
+ });
19384
+ return true;
19385
+ } catch (err) {
19386
+ const e = err;
19387
+ if (e.code === "ELOOP" || e.code === "EEXIST") {
19388
+ input.refuse("targetPath", `open-${e.code}`);
19389
+ return false;
19390
+ }
19391
+ input.logErr(`mirror write failed: ${e.message}`);
19392
+ return false;
19393
+ }
19394
+ }
19395
+
19201
19396
  // src/auth/account-store.ts
19202
19397
  init_atomic();
19203
19398
  import {
@@ -19998,14 +20193,20 @@ class ProviderRegistry {
19998
20193
  }
19999
20194
 
20000
20195
  // src/auth/broker/peercred.ts
20001
- var AUTH_BROKER_SOCKET_PATH_RE = /^\/run\/switchroom\/auth-broker\/([a-zA-Z0-9][a-zA-Z0-9_-]*)\/sock$/;
20196
+ var AUTH_BROKER_ROOT = "/run/switchroom/auth-broker";
20197
+ function authBrokerSocketRe(socketRoot) {
20198
+ const escaped = socketRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20199
+ return new RegExp(`^${escaped}/([a-zA-Z0-9][a-zA-Z0-9_-]*)/sock$`);
20200
+ }
20201
+ var AUTH_BROKER_SOCKET_PATH_RE = authBrokerSocketRe(AUTH_BROKER_ROOT);
20002
20202
  var AUTH_BROKER_MAX_NAME_LEN = 63;
20003
20203
  var RESERVED_NAMES = new Set(["operator"]);
20004
- function socketPathToName(socketPath) {
20005
- return matchSocketName(socketPath, [AUTH_BROKER_SOCKET_PATH_RE], AUTH_BROKER_MAX_NAME_LEN);
20204
+ function socketPathToName(socketPath, socketRoot) {
20205
+ const pattern = socketRoot === undefined || socketRoot === AUTH_BROKER_ROOT ? AUTH_BROKER_SOCKET_PATH_RE : authBrokerSocketRe(socketRoot);
20206
+ return matchSocketName(socketPath, [pattern], AUTH_BROKER_MAX_NAME_LEN);
20006
20207
  }
20007
- function classify(socketPath, config) {
20008
- const name = socketPathToName(socketPath);
20208
+ function classify(socketPath, config, socketRoot) {
20209
+ const name = socketPathToName(socketPath, socketRoot);
20009
20210
  if (!name)
20010
20211
  return null;
20011
20212
  if (name === "operator")
@@ -20169,6 +20370,12 @@ var ClaimNotificationRequestSchema = exports_external.object({
20169
20370
  key: exports_external.string().min(1).max(512),
20170
20371
  windowMs: exports_external.number().int().positive().max(86400000)
20171
20372
  });
20373
+ var GetExternalSpendRequestSchema = exports_external.object({
20374
+ v: exports_external.literal(PROTOCOL_VERSION),
20375
+ op: exports_external.literal("get-external-spend"),
20376
+ id: exports_external.string().min(1),
20377
+ forceLive: exports_external.boolean().optional()
20378
+ });
20172
20379
  var RequestSchema2 = exports_external.discriminatedUnion("op", [
20173
20380
  GetCredentialsRequestSchema,
20174
20381
  ListStateRequestSchema,
@@ -20182,7 +20389,8 @@ var RequestSchema2 = exports_external.discriminatedUnion("op", [
20182
20389
  ListGoogleAccountsRequestSchema,
20183
20390
  ListMicrosoftAccountsRequestSchema,
20184
20391
  ProbeQuotaRequestSchema,
20185
- ClaimNotificationRequestSchema
20392
+ ClaimNotificationRequestSchema,
20393
+ GetExternalSpendRequestSchema
20186
20394
  ]);
20187
20395
  var GetCredentialsDataSchema = exports_external.object({
20188
20396
  account: exports_external.string(),
@@ -20249,6 +20457,18 @@ var SetOverrideDataSchema = exports_external.object({
20249
20457
  var ClaimNotificationDataSchema = exports_external.object({
20250
20458
  granted: exports_external.boolean()
20251
20459
  });
20460
+ var GetExternalSpendDataSchema = exports_external.object({
20461
+ available: exports_external.boolean(),
20462
+ day24hUsd: exports_external.number().optional(),
20463
+ day7dUsd: exports_external.number().optional(),
20464
+ top: exports_external.array(exports_external.object({
20465
+ label: exports_external.string(),
20466
+ usd: exports_external.number()
20467
+ })).optional(),
20468
+ capturedAtMs: exports_external.number().int().nonnegative().optional(),
20469
+ served: exports_external.enum(["live", "cache"]).optional(),
20470
+ reason: exports_external.string().optional()
20471
+ });
20252
20472
  var GoogleAccountStateSchema = exports_external.object({
20253
20473
  account: exports_external.string(),
20254
20474
  expiresAt: exports_external.number(),
@@ -20329,13 +20549,14 @@ function encodeError(id, code, message) {
20329
20549
  }
20330
20550
 
20331
20551
  // src/auth/broker/server.ts
20332
- var AUTH_BROKER_ROOT = "/run/switchroom/auth-broker";
20552
+ var AUTH_BROKER_ROOT2 = "/run/switchroom/auth-broker";
20333
20553
  var REFRESH_TICK_INTERVAL_MS = 60 * 1000;
20334
20554
  var MARK_EXHAUSTED_DEFAULT_MS = 5 * 60 * 60 * 1000;
20335
20555
  var MARK_THROTTLED_MAX_MS = 30 * 60 * 1000;
20336
20556
  var THROTTLE_ESCALATION_HITS = 3;
20337
20557
  var THROTTLE_ESCALATION_WINDOW_MS = 10 * 60 * 1000;
20338
20558
  var MARK_THROTTLED_MIN_INTERVAL_MS = 5 * 1000;
20559
+ var MARK_CORROBORATION_PROBE_DELAY_MS = 90 * 1000;
20339
20560
  var AUDIT_ROTATE_BYTES = 10 * 1024 * 1024;
20340
20561
  var AUDIT_KEEP = 5;
20341
20562
  var AUDIT_LINE_MAX = 4000;
@@ -20412,6 +20633,8 @@ class AuthBroker {
20412
20633
  refreshTimer = null;
20413
20634
  consumerProbeTimer = null;
20414
20635
  fleetProbeTimer = null;
20636
+ markCorroborationTimers = new Set;
20637
+ markCorroborationDelayMs = MARK_CORROBORATION_PROBE_DELAY_MS;
20415
20638
  fetchQuotaImpl;
20416
20639
  stateDir;
20417
20640
  socketRoot;
@@ -20422,6 +20645,8 @@ class AuthBroker {
20422
20645
  providers;
20423
20646
  quota = {};
20424
20647
  lastQuotaCache = {};
20648
+ externalSpendCache = null;
20649
+ externalSpendInFlight = null;
20425
20650
  probeInFlight = new Map;
20426
20651
  shaIndex = {};
20427
20652
  thresholdViolations = {};
@@ -20446,8 +20671,9 @@ class AuthBroker {
20446
20671
  this.operatorUid = opts.operatorUid;
20447
20672
  this.fetcher = opts.fetcher;
20448
20673
  this.fetchQuotaImpl = opts._testFetchQuota ?? fetchQuota;
20674
+ this.markCorroborationDelayMs = opts._testCorroborationDelayMs ?? MARK_CORROBORATION_PROBE_DELAY_MS;
20449
20675
  this.stateDir = opts.stateDir ?? resolve9(this.homeRoot(), ".switchroom", "state", "auth-broker");
20450
- this.socketRoot = opts.socketRoot ?? AUTH_BROKER_ROOT;
20676
+ this.socketRoot = opts.socketRoot ?? AUTH_BROKER_ROOT2;
20451
20677
  this.providers = new ProviderRegistry;
20452
20678
  this.providers.register(new AnthropicProvider);
20453
20679
  const googleClientId = config.google_workspace?.google_client_id;
@@ -20547,10 +20773,18 @@ class AuthBroker {
20547
20773
  clearInterval(this.fleetProbeTimer);
20548
20774
  this.fleetProbeTimer = null;
20549
20775
  }
20776
+ for (const timer of this.markCorroborationTimers)
20777
+ clearTimeout(timer);
20778
+ this.markCorroborationTimers.clear();
20550
20779
  for (const [sock, lis] of this.listeners) {
20551
20780
  try {
20552
20781
  lis.server.close();
20553
20782
  } catch {}
20783
+ for (const s of lis.sockets) {
20784
+ try {
20785
+ s.destroy();
20786
+ } catch {}
20787
+ }
20554
20788
  try {
20555
20789
  if (existsSync8(sock))
20556
20790
  unlinkSync(sock);
@@ -20579,7 +20813,12 @@ class AuthBroker {
20579
20813
  if (this.operatorUid !== undefined) {
20580
20814
  wanted.add(this.operatorSocketPath());
20581
20815
  }
20816
+ const prevShape = configToShape(prev);
20817
+ const nextShape = configToShape(config);
20582
20818
  for (const [sock, lis] of [...this.listeners]) {
20819
+ const before = classify(lis.socketPath, prevShape, this.socketRoot);
20820
+ const after = classify(lis.socketPath, nextShape, this.socketRoot);
20821
+ const identityChanged = JSON.stringify(before ?? null) !== JSON.stringify(after ?? null);
20583
20822
  if (!wanted.has(sock)) {
20584
20823
  try {
20585
20824
  lis.server.close();
@@ -20588,7 +20827,18 @@ class AuthBroker {
20588
20827
  if (existsSync8(sock))
20589
20828
  unlinkSync(sock);
20590
20829
  } catch {}
20830
+ for (const s of lis.sockets) {
20831
+ try {
20832
+ s.destroy();
20833
+ } catch {}
20834
+ }
20591
20835
  this.listeners.delete(sock);
20836
+ } else if (identityChanged) {
20837
+ for (const s of lis.sockets) {
20838
+ try {
20839
+ s.destroy();
20840
+ } catch {}
20841
+ }
20592
20842
  }
20593
20843
  }
20594
20844
  for (const name of Object.keys(config.agents ?? {})) {
@@ -20676,12 +20926,24 @@ class AuthBroker {
20676
20926
  try {
20677
20927
  chownSync2(dir, targetUid, targetUid);
20678
20928
  } catch {}
20679
- this.listeners.set(sockPath, { server, identity, socketPath: sockPath });
20929
+ this.listeners.set(sockPath, {
20930
+ server,
20931
+ identity,
20932
+ socketPath: sockPath,
20933
+ sockets: new Set
20934
+ });
20680
20935
  resolveP();
20681
20936
  });
20682
20937
  });
20683
20938
  }
20684
20939
  handleConnection(socket, sockPath, boundIdentity) {
20940
+ const listener = this.listeners.get(sockPath);
20941
+ if (listener) {
20942
+ listener.sockets.add(socket);
20943
+ socket.on("close", () => {
20944
+ listener.sockets.delete(socket);
20945
+ });
20946
+ }
20685
20947
  let buf = "";
20686
20948
  socket.on("data", (chunk) => {
20687
20949
  buf += chunk.toString("utf-8");
@@ -20720,7 +20982,17 @@ class AuthBroker {
20720
20982
  socket.write(encodeError(reqId, "INVALID_ARGS", err.message));
20721
20983
  return;
20722
20984
  }
20723
- const identity = classify(sockPath, configToShape(this.config)) ?? boundIdentity;
20985
+ const identity = classify(sockPath, configToShape(this.config), this.socketRoot);
20986
+ if (!identity) {
20987
+ this.audit({
20988
+ op: "stale-identity-rejected",
20989
+ identity: boundIdentity,
20990
+ ok: false,
20991
+ error: "FORBIDDEN"
20992
+ });
20993
+ socket.end(encodeError(reqId, "FORBIDDEN", "identity no longer configured"));
20994
+ return;
20995
+ }
20724
20996
  try {
20725
20997
  switch (req.op) {
20726
20998
  case "get-credentials": {
@@ -20840,6 +21112,9 @@ class AuthBroker {
20840
21112
  case "claim-notification":
20841
21113
  this.opClaimNotification(socket, reqId, identity, req.key, req.windowMs);
20842
21114
  break;
21115
+ case "get-external-spend":
21116
+ await this.opGetExternalSpend(socket, reqId, identity, req.forceLive);
21117
+ break;
20843
21118
  }
20844
21119
  } catch (err) {
20845
21120
  socket.write(encodeError(reqId, "INTERNAL", err.message));
@@ -21612,6 +21887,9 @@ class AuthBroker {
21612
21887
  });
21613
21888
  this.quota[account] = { ...this.quota[account], exhausted_until: exhaustedUntil, marked_at: now };
21614
21889
  this.persistQuota();
21890
+ if (exhaustedUntil > now + MARK_EXHAUSTED_DEFAULT_MS) {
21891
+ this.scheduleMarkCorroboration(account, now, identity);
21892
+ }
21615
21893
  const rolledTo = await this.nextHealthyAccountLive(account, this.config.auth?.fallback_order ?? []);
21616
21894
  const rolled = this.fanoutFailoverTo(account, rolledTo);
21617
21895
  this.fanoutToAffectedConsumers(account);
@@ -21628,6 +21906,67 @@ class AuthBroker {
21628
21906
  }
21629
21907
  return { rolled, rolledTo };
21630
21908
  }
21909
+ scheduleMarkCorroboration(account, markedAt, identity) {
21910
+ if (this.markCorroborationDelayMs <= 0)
21911
+ return;
21912
+ const timer = setTimeout(() => {
21913
+ this.markCorroborationTimers.delete(timer);
21914
+ this.corroborateExhaustionMark(account, markedAt, identity).catch((err) => {
21915
+ this.logErr(`mark-corroboration ${account}: ${err.message}`);
21916
+ });
21917
+ }, this.markCorroborationDelayMs);
21918
+ timer.unref?.();
21919
+ this.markCorroborationTimers.add(timer);
21920
+ }
21921
+ async corroborateExhaustionMark(account, markedAt, identity) {
21922
+ const entry = this.quota[account];
21923
+ if (!entry || entry.marked_at !== markedAt) {
21924
+ return { clamped: false, reason: "mark-superseded" };
21925
+ }
21926
+ const until = entry.exhausted_until;
21927
+ const now = this.now();
21928
+ if (until == null || until <= now + MARK_EXHAUSTED_DEFAULT_MS) {
21929
+ return { clamped: false, reason: "not-long" };
21930
+ }
21931
+ const creds = readAccountCredentials(account, this.home);
21932
+ const token = creds?.claudeAiOauth?.accessToken;
21933
+ if (!token)
21934
+ return { clamped: false, reason: "no-token" };
21935
+ let result;
21936
+ try {
21937
+ result = await this.fetchQuotaImpl({ accessToken: token });
21938
+ } catch (err) {
21939
+ this.logErr(`mark-corroboration probe ${account}: ${err.message}`);
21940
+ return { clamped: false, reason: "probe-failed" };
21941
+ }
21942
+ this.cacheQuotaSnapshot(account, result);
21943
+ const snapshot = this.lastQuotaCache[account];
21944
+ const clampedUntil = clampMarkExpiry({
21945
+ proposedUntil: until,
21946
+ now,
21947
+ shortMs: MARK_EXHAUSTED_DEFAULT_MS,
21948
+ snapshot
21949
+ });
21950
+ if (clampedUntil >= until) {
21951
+ return { clamped: false, reason: "not-contradicted" };
21952
+ }
21953
+ const live = this.quota[account];
21954
+ if (!live || live.marked_at !== markedAt || (live.exhausted_until ?? 0) <= clampedUntil) {
21955
+ return { clamped: false, reason: "mark-superseded" };
21956
+ }
21957
+ this.quota[account] = { ...live, exhausted_until: clampedUntil };
21958
+ this.persistQuota();
21959
+ this.audit({
21960
+ op: "mark-corroboration-clamp",
21961
+ identity,
21962
+ account,
21963
+ accountKind: "claude",
21964
+ ok: true
21965
+ });
21966
+ process.stdout.write(`auth-broker: mark-corroboration probe CONTRADICTS ${account} weekly wall — ` + `clamped exhausted_until ${new Date(until).toISOString()} → ` + `${new Date(clampedUntil).toISOString()}
21967
+ `);
21968
+ return { clamped: true, reason: "contradicted", until: clampedUntil };
21969
+ }
21631
21970
  exhaustionLiveCorroborated(account) {
21632
21971
  const snapshot = this.lastQuotaCache[account];
21633
21972
  if (!snapshot || !snapshotFresh(snapshot, this.now(), MARK_EXHAUSTED_DEFAULT_MS))
@@ -21636,6 +21975,105 @@ class AuthBroker {
21636
21975
  return false;
21637
21976
  return !overageLiftsWall(snapshot, this.isOverageAllowed(account));
21638
21977
  }
21978
+ async opGetExternalSpend(socket, id, _identity, forceLive) {
21979
+ const nowMs2 = this.now();
21980
+ const ttl = EXTERNAL_SPEND_CACHE_TTL_MS;
21981
+ const fresh = this.externalSpendCache != null && nowMs2 - this.externalSpendCache.capturedAtMs < ttl;
21982
+ if (forceLive || !fresh) {
21983
+ try {
21984
+ await this.refreshExternalSpend(forceLive === true);
21985
+ } catch (err) {
21986
+ this.logErr(`external-spend refresh failed: ${err?.message ?? err}`);
21987
+ }
21988
+ }
21989
+ if (!this.externalSpendCache) {
21990
+ socket.write(encodeSuccess(id, {
21991
+ available: false,
21992
+ reason: this.resolveLitellmMasterKey() ? "litellm_unreachable" : "master_key_unavailable"
21993
+ }));
21994
+ return;
21995
+ }
21996
+ const recently = nowMs2 - this.externalSpendCache.capturedAtMs < 5000;
21997
+ const outServed = recently && (forceLive || !fresh) ? "live" : "cache";
21998
+ const s = this.externalSpendCache.summary;
21999
+ socket.write(encodeSuccess(id, {
22000
+ available: true,
22001
+ day24hUsd: s.day24hUsd,
22002
+ day7dUsd: s.day7dUsd,
22003
+ top: s.top,
22004
+ capturedAtMs: this.externalSpendCache.capturedAtMs,
22005
+ served: outServed
22006
+ }));
22007
+ }
22008
+ resolveLitellmMasterKey() {
22009
+ if (this.opts._testLitellmMasterKey !== undefined) {
22010
+ const v = this.opts._testLitellmMasterKey;
22011
+ return v && v.trim() ? v.trim() : null;
22012
+ }
22013
+ const envKey = (process.env.SWITCHROOM_LITELLM_ADMIN_KEY ?? process.env.LITELLM_MASTER_KEY ?? "").trim();
22014
+ if (envKey)
22015
+ return envKey;
22016
+ try {
22017
+ const path = join8(this.stateDir, LITELLM_MASTER_KEY_STATE_BASENAME);
22018
+ if (!existsSync8(path))
22019
+ return null;
22020
+ const raw = readFileSync7(path, "utf-8").trim();
22021
+ return raw.length > 0 ? raw : null;
22022
+ } catch {
22023
+ return null;
22024
+ }
22025
+ }
22026
+ resolveLitellmBaseUrl() {
22027
+ const fromCfg = this.config.litellm?.base_url;
22028
+ const raw = (process.env.SWITCHROOM_LITELLM_BASE ?? fromCfg ?? DEFAULT_LITELLM_BASE).trim();
22029
+ return raw.replace(/\/+$/, "") || DEFAULT_LITELLM_BASE;
22030
+ }
22031
+ async refreshExternalSpend(forceLive) {
22032
+ while (this.externalSpendInFlight) {
22033
+ await this.externalSpendInFlight;
22034
+ if (!forceLive)
22035
+ return;
22036
+ const justNow = this.now();
22037
+ if (this.externalSpendCache && justNow - this.externalSpendCache.capturedAtMs < 5000) {
22038
+ return;
22039
+ }
22040
+ }
22041
+ const run = (async () => {
22042
+ const nowMs2 = this.now();
22043
+ if (!forceLive && this.externalSpendCache && nowMs2 - this.externalSpendCache.capturedAtMs < EXTERNAL_SPEND_CACHE_TTL_MS) {
22044
+ return;
22045
+ }
22046
+ const key = this.resolveLitellmMasterKey();
22047
+ if (!key)
22048
+ return;
22049
+ const baseUrl = this.resolveLitellmBaseUrl();
22050
+ const now = new Date(nowMs2);
22051
+ let summary = null;
22052
+ if (this.opts._testFetchExternalSpend) {
22053
+ summary = await this.opts._testFetchExternalSpend({
22054
+ adminKey: key,
22055
+ baseUrl,
22056
+ now,
22057
+ forceLive
22058
+ });
22059
+ } else {
22060
+ summary = await fetchAndSummarizeExternalSpend({
22061
+ adminKey: key,
22062
+ baseUrl,
22063
+ now,
22064
+ timeoutMs: EXTERNAL_SPEND_FETCH_TIMEOUT_MS
22065
+ });
22066
+ }
22067
+ if (!summary)
22068
+ return;
22069
+ this.externalSpendCache = { summary, capturedAtMs: this.now() };
22070
+ this.persistExternalSpendCache();
22071
+ })();
22072
+ this.externalSpendInFlight = run.finally(() => {
22073
+ this.externalSpendInFlight = null;
22074
+ });
22075
+ await this.externalSpendInFlight;
22076
+ }
21639
22077
  opClaimNotification(socket, id, identity, key, windowMs) {
21640
22078
  const now = this.now();
21641
22079
  const prev = this.notificationClaims[key];
@@ -22201,25 +22639,68 @@ class AuthBroker {
22201
22639
  const mirrorDir = consumer.mirror_dir;
22202
22640
  if (!mirrorDir)
22203
22641
  return false;
22204
- const targetPath = join8(mirrorDir, ".credentials.json");
22205
22642
  const credsPath = accountCredentialsPath(label, this.home);
22206
22643
  if (!existsSync8(credsPath))
22207
22644
  return false;
22208
22645
  const mirrorContent = enrichMirrorContent(readFileSync7(credsPath, "utf-8"));
22209
- try {
22210
- mkdirSync5(mirrorDir, { recursive: true, mode: 448 });
22211
- atomicWriteFileSync(targetPath, mirrorContent, 384);
22646
+ const safe = this.resolveConsumerMirrorPathsSafe(consumer.name, mirrorDir);
22647
+ if (!safe)
22648
+ return false;
22649
+ return safeMirrorWrite({
22650
+ parentDir: safe.mirrorDir,
22651
+ expectedRealParent: safe.expectedRealParent,
22652
+ targetPath: safe.targetPath,
22653
+ content: mirrorContent,
22654
+ uid: consumer.uid ?? 0,
22655
+ onChownError: (err) => this.warnCapChownMissing(err),
22656
+ refuse: (component, reason) => this.auditMirrorRefused({ kind: "consumer", name: consumer.name }, component, reason),
22657
+ logErr: (msg) => this.logErr(`consumer-mirror ${consumer.name} <- ${label}: ${msg}`)
22658
+ });
22659
+ }
22660
+ resolveConsumerMirrorPathsSafe(consumerName, mirrorDir) {
22661
+ const identity = { kind: "consumer", name: consumerName };
22662
+ const refuse = (component, reason) => {
22663
+ this.auditMirrorRefused(identity, component, reason);
22664
+ return null;
22665
+ };
22666
+ const parent = dirname3(mirrorDir);
22667
+ const mirrorStat = this.lstatOrNull(mirrorDir);
22668
+ if (mirrorStat) {
22669
+ if (mirrorStat.isSymbolicLink())
22670
+ return refuse("mirror_dir", "is-a-symlink");
22671
+ if (!mirrorStat.isDirectory())
22672
+ return refuse("mirror_dir", "is-not-a-directory");
22673
+ } else {
22674
+ const parentStat = this.lstatOrNull(parent);
22675
+ if (!parentStat)
22676
+ return refuse("mirror_dir-parent", "does-not-exist");
22677
+ if (parentStat.isSymbolicLink())
22678
+ return refuse("mirror_dir-parent", "is-a-symlink");
22679
+ if (!parentStat.isDirectory())
22680
+ return refuse("mirror_dir-parent", "is-not-a-directory");
22212
22681
  try {
22213
- const uid = consumer.uid ?? 0;
22214
- chownSync2(targetPath, uid, uid);
22682
+ mkdirSync5(mirrorDir, { recursive: false, mode: 448 });
22215
22683
  } catch (err) {
22216
- this.warnCapChownMissing(err);
22684
+ this.logErr(`consumer-mirror ${consumerName}: mkdir ${mirrorDir}: ${err.message}`);
22685
+ return null;
22217
22686
  }
22218
- return true;
22687
+ }
22688
+ const targetPath = join8(mirrorDir, ".credentials.json");
22689
+ const targetStat = this.lstatOrNull(targetPath);
22690
+ if (targetStat) {
22691
+ if (targetStat.isSymbolicLink())
22692
+ return refuse(".credentials.json", "is-a-symlink");
22693
+ if (!targetStat.isFile())
22694
+ return refuse(".credentials.json", "is-not-a-regular-file");
22695
+ }
22696
+ let expectedRealParent;
22697
+ try {
22698
+ expectedRealParent = join8(realpathSync3(parent), basename2(mirrorDir));
22219
22699
  } catch (err) {
22220
- this.logErr(`consumer-mirror ${consumer.name} <- ${label}: ${err.message}`);
22221
- return false;
22700
+ this.logErr(`consumer-mirror ${consumerName}: realpath ${parent}: ${err.message}`);
22701
+ return null;
22222
22702
  }
22703
+ return { mirrorDir, targetPath, expectedRealParent };
22223
22704
  }
22224
22705
  fanoutFailoverTo(label, next) {
22225
22706
  const auth = this.config.auth ?? {};
@@ -22274,13 +22755,7 @@ class AuthBroker {
22274
22755
  const claudeDir = join8(agentDir, ".claude");
22275
22756
  const targetPath = join8(claudeDir, ".credentials.json");
22276
22757
  const refuse = (component, reason) => {
22277
- this.logErr(`fanout ${agentName}: REFUSING mirror ${component} ${reason} ` + `(symlink-guard #1393; agent-owned tree may be attacker-poisoned)`);
22278
- this.audit({
22279
- op: "mirror-symlink-refused",
22280
- identity: { kind: "agent", name: agentName, admin: false },
22281
- ok: false,
22282
- error: `${component}:${reason}`
22283
- });
22758
+ this.auditMirrorRefused({ kind: "agent", name: agentName, admin: false }, component, reason);
22284
22759
  return null;
22285
22760
  };
22286
22761
  const agentsStat = this.lstatOrNull(agentsDir);
@@ -22311,15 +22786,26 @@ class AuthBroker {
22311
22786
  if (!targetStat.isFile())
22312
22787
  return refuse(".credentials.json", "is-not-a-regular-file");
22313
22788
  }
22314
- return { agentDir, claudeDir, targetPath };
22789
+ const expectedRealParent = join8(realpathSync3(agentsDir), agentName, ".claude");
22790
+ return { agentDir, claudeDir, targetPath, expectedRealParent };
22315
22791
  }
22316
22792
  lstatOrNull(path) {
22317
22793
  try {
22318
- return lstatSync(path);
22794
+ return lstatSync2(path);
22319
22795
  } catch {
22320
22796
  return null;
22321
22797
  }
22322
22798
  }
22799
+ auditMirrorRefused(identity, component, reason) {
22800
+ const who = identity.kind === "operator" ? "operator" : identity.name;
22801
+ this.logErr(`mirror ${who}: REFUSING mirror — ${component} ${reason} ` + `(symlink-guard #1393; peer-owned tree may be attacker-poisoned)`);
22802
+ this.audit({
22803
+ op: "mirror-symlink-refused",
22804
+ identity,
22805
+ ok: false,
22806
+ error: `${component}:${reason}`
22807
+ });
22808
+ }
22323
22809
  mirrorAccountToAgent(label, agentName) {
22324
22810
  const credsPath = accountCredentialsPath(label, this.home);
22325
22811
  if (!existsSync8(credsPath))
@@ -22328,21 +22814,18 @@ class AuthBroker {
22328
22814
  const safe = this.resolveMirrorPathsSafe(agentName);
22329
22815
  if (!safe)
22330
22816
  return false;
22331
- const { claudeDir, targetPath } = safe;
22817
+ const { claudeDir, targetPath, expectedRealParent } = safe;
22332
22818
  mkdirSync5(claudeDir, { recursive: true, mode: 448 });
22333
- try {
22334
- atomicWriteFileSync(targetPath, mirrorContent, 384);
22335
- try {
22336
- const uid = allocateAgentUid(agentName);
22337
- chownSync2(targetPath, uid, uid);
22338
- } catch (err) {
22339
- this.warnCapChownMissing(err);
22340
- }
22341
- return true;
22342
- } catch (err) {
22343
- this.logErr(`fanout ${agentName} <- ${label}: ${err.message}`);
22344
- return false;
22345
- }
22819
+ return safeMirrorWrite({
22820
+ parentDir: claudeDir,
22821
+ expectedRealParent,
22822
+ targetPath,
22823
+ content: mirrorContent,
22824
+ uid: allocateAgentUid(agentName),
22825
+ onChownError: (err) => this.warnCapChownMissing(err),
22826
+ refuse: (component, reason) => this.auditMirrorRefused({ kind: "agent", name: agentName, admin: false }, component, reason),
22827
+ logErr: (msg) => this.logErr(`fanout ${agentName} <- ${label}: ${msg}`)
22828
+ });
22346
22829
  }
22347
22830
  loadStateFromDisk() {
22348
22831
  this.quota = this.readJson("quota.json") ?? {};
@@ -22354,6 +22837,22 @@ class AuthBroker {
22354
22837
  this.lastTierQuotaCache = this.readJson("last-tier-quota.json") ?? {};
22355
22838
  this.premiumTierAllWalled = this.readJson("premium-tier-all-walled.json") ?? null;
22356
22839
  this.usageLedger = this.readJson("usage-ledger.json") ?? {};
22840
+ {
22841
+ const es = this.readJson("external-spend.json");
22842
+ if (es && es.summary && typeof es.capturedAtMs === "number" && Number.isFinite(es.summary.day24hUsd) && Number.isFinite(es.summary.day7dUsd) && Array.isArray(es.summary.top) && es.summary.top.every((t) => t && typeof t.label === "string" && typeof t.usd === "number" && Number.isFinite(t.usd))) {
22843
+ this.externalSpendCache = {
22844
+ summary: {
22845
+ day24hUsd: es.summary.day24hUsd,
22846
+ day7dUsd: es.summary.day7dUsd,
22847
+ top: es.summary.top.map((t) => ({
22848
+ label: t.label,
22849
+ usd: t.usd
22850
+ }))
22851
+ },
22852
+ capturedAtMs: es.capturedAtMs
22853
+ };
22854
+ }
22855
+ }
22357
22856
  this.applyActiveOverride();
22358
22857
  }
22359
22858
  applyActiveOverride() {
@@ -22407,6 +22906,11 @@ class AuthBroker {
22407
22906
  persistLastQuotaCache() {
22408
22907
  atomicWriteJsonSync(join8(this.stateDir, "last-quota.json"), this.lastQuotaCache, 384);
22409
22908
  }
22909
+ persistExternalSpendCache() {
22910
+ if (!this.externalSpendCache)
22911
+ return;
22912
+ atomicWriteJsonSync(join8(this.stateDir, "external-spend.json"), this.externalSpendCache, 384);
22913
+ }
22410
22914
  persistLastTierQuotaCache() {
22411
22915
  atomicWriteJsonSync(join8(this.stateDir, "last-tier-quota.json"), this.lastTierQuotaCache, 384);
22412
22916
  }