switchroom 0.18.12 → 0.18.13

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 (49) hide show
  1. package/dist/agent-scheduler/index.js +8 -0
  2. package/dist/auth-broker/index.js +63 -65
  3. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  4. package/dist/cli/notion-write-pretool.mjs +9 -1
  5. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  6. package/dist/cli/switchroom.js +952 -3126
  7. package/dist/host-control/main.js +216 -2862
  8. package/dist/vault/approvals/kernel-server.js +67 -0
  9. package/dist/vault/broker/server.js +98 -44
  10. package/package.json +1 -1
  11. package/telegram-plugin/dist/bridge/bridge.js +49 -3
  12. package/telegram-plugin/dist/gateway/gateway.js +656 -2326
  13. package/telegram-plugin/dist/server.js +65 -3
  14. package/telegram-plugin/format.ts +19 -0
  15. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  16. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  17. package/telegram-plugin/gateway/gateway.ts +221 -73
  18. package/telegram-plugin/history.ts +51 -0
  19. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  20. package/telegram-plugin/model-unavailable.ts +41 -11
  21. package/telegram-plugin/outbound-field-redact.ts +69 -0
  22. package/telegram-plugin/render/render.ts +32 -14
  23. package/telegram-plugin/scoped-approval.ts +11 -2
  24. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  25. package/telegram-plugin/secret-detect/index.ts +12 -56
  26. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  27. package/telegram-plugin/send-gate.test.ts +25 -6
  28. package/telegram-plugin/send-gate.ts +82 -8
  29. package/telegram-plugin/session-tail.ts +82 -7
  30. package/telegram-plugin/subagent-watcher.ts +71 -16
  31. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  32. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  33. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  34. package/telegram-plugin/tests/history.test.ts +115 -0
  35. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  36. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  37. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  38. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  39. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  40. package/telegram-plugin/tests/render/render.test.ts +88 -0
  41. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  42. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  43. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  44. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  45. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  46. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  47. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  48. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  49. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -17062,6 +17062,48 @@ var init_operator_events = __esm(() => {
17062
17062
  cooldownMap = new Map;
17063
17063
  });
17064
17064
 
17065
+ // quota-check.ts
17066
+ var init_quota_check = () => {};
17067
+
17068
+ // text-voice-scrub.ts
17069
+ var NULL = "\x00", FENCE_PH, INLINE_PH, HTML_CODE_PH, HTML_PRE_PH, URL_PH;
17070
+ var init_text_voice_scrub = __esm(() => {
17071
+ FENCE_PH = `${NULL}VS_FENCE`;
17072
+ INLINE_PH = `${NULL}VS_INLINE`;
17073
+ HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
17074
+ HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
17075
+ URL_PH = `${NULL}VS_URL`;
17076
+ });
17077
+
17078
+ // card-format.ts
17079
+ var init_card_format = __esm(() => {
17080
+ init_text_voice_scrub();
17081
+ });
17082
+
17083
+ // model-unavailable.ts
17084
+ function isTransientUpstreamSignal(text) {
17085
+ if (typeof text !== "string" || text.length === 0)
17086
+ return false;
17087
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17088
+ const lower = sample.toLowerCase();
17089
+ return transientUpstreamSignals.some((s) => lower.includes(s));
17090
+ }
17091
+ var transientUpstreamSignals;
17092
+ var init_model_unavailable = __esm(() => {
17093
+ init_quota_check();
17094
+ init_card_format();
17095
+ transientUpstreamSignals = [
17096
+ "not your usage limit",
17097
+ "not your account",
17098
+ "not your account's",
17099
+ "temporarily limiting requests",
17100
+ "temporarily rate",
17101
+ "server is temporarily",
17102
+ "would exceed your account\u2019s rate limit",
17103
+ "would exceed your account's rate limit"
17104
+ ];
17105
+ });
17106
+
17065
17107
  // tool-label-sidecar.ts
17066
17108
  import { existsSync as existsSync3, readFileSync as readFileSync2, statSync as statSync3 } from "node:fs";
17067
17109
  import { join as join3 } from "node:path";
@@ -17491,7 +17533,8 @@ function detectErrorInTranscriptLine(line) {
17491
17533
  const status = typeof obj.apiErrorStatus === "number" ? obj.apiErrorStatus : null;
17492
17534
  const errStr = typeof obj.error === "string" ? obj.error : "";
17493
17535
  const text = extractAssistantText(obj);
17494
- const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
17536
+ const kind2 = status === 429 ? isTransientUpstreamSignal(`${text}
17537
+ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
17495
17538
  return {
17496
17539
  kind: kind2,
17497
17540
  raw: obj,
@@ -17585,6 +17628,7 @@ function startSessionTail(config2) {
17585
17628
  const onOperatorEvent = config2.onOperatorEvent;
17586
17629
  log?.(`session-tail: projectsDir=${projectsDir}`);
17587
17630
  const sidecars = new Map;
17631
+ const createSidecar = config2.createSidecar ?? createToolLabelSidecar;
17588
17632
  const stateDirForSidecar = process.env.TELEGRAM_STATE_DIR ?? null;
17589
17633
  function sessionIdForFile(file) {
17590
17634
  if (!file)
@@ -17599,7 +17643,7 @@ function startSessionTail(config2) {
17599
17643
  if (existing)
17600
17644
  return existing;
17601
17645
  try {
17602
- const s = createToolLabelSidecar({ stateDir: stateDirForSidecar, sessionId });
17646
+ const s = createSidecar({ stateDir: stateDirForSidecar, sessionId });
17603
17647
  sidecars.set(sessionId, s);
17604
17648
  s.onLabel((toolUseId, label, toolName) => {
17605
17649
  rawOnEvent({ kind: "tool_label", toolUseId, label, toolName });
@@ -17610,6 +17654,17 @@ function startSessionTail(config2) {
17610
17654
  return null;
17611
17655
  }
17612
17656
  }
17657
+ function stopSidecar(sessionId) {
17658
+ if (!sessionId)
17659
+ return;
17660
+ const s = sidecars.get(sessionId);
17661
+ if (!s)
17662
+ return;
17663
+ try {
17664
+ s.stop();
17665
+ } catch {}
17666
+ sidecars.delete(sessionId);
17667
+ }
17613
17668
  function decorate(ev, sessionId) {
17614
17669
  if (!sessionId)
17615
17670
  return ev;
@@ -17702,6 +17757,11 @@ function startSessionTail(config2) {
17702
17757
  } catch {}
17703
17758
  watcher = null;
17704
17759
  }
17760
+ const rotatedAwaySid = sessionIdForFile(currentFile);
17761
+ const nextSid = sessionIdForFile(file);
17762
+ if (rotatedAwaySid != null && rotatedAwaySid !== nextSid) {
17763
+ stopSidecar(rotatedAwaySid);
17764
+ }
17705
17765
  currentFile = file;
17706
17766
  const prior = fileCursors.get(file);
17707
17767
  if (prior != null) {
@@ -17734,7 +17794,7 @@ function startSessionTail(config2) {
17734
17794
  const multiAgent = isMultiAgentEnabled();
17735
17795
  const CAP_TOOL_USE_THRESHOLD = 30;
17736
17796
  const subTails = new Map;
17737
- const IDLE_FSWATCH_TTL_MS = 5 * 60 * 1000;
17797
+ const IDLE_FSWATCH_TTL_MS = Math.max(1000, config2.subTailIdleReapMs ?? 5 * 60 * 1000);
17738
17798
  function readSub(t) {
17739
17799
  if (stopped)
17740
17800
  return;
@@ -17841,6 +17901,7 @@ function startSessionTail(config2) {
17841
17901
  } catch {}
17842
17902
  t.watcher = null;
17843
17903
  }
17904
+ stopSidecar(sessionIdForFile(t.file));
17844
17905
  subTails.delete(file);
17845
17906
  log?.(`session-tail: reaped idle sub ${t.agentId} (${file})`);
17846
17907
  }
@@ -17925,6 +17986,7 @@ function startSessionTail(config2) {
17925
17986
  var MAX_JSONL_LINE_BYTES, MAX_ERROR_TEXT_CHARS = 500;
17926
17987
  var init_session_tail = __esm(() => {
17927
17988
  init_operator_events();
17989
+ init_model_unavailable();
17928
17990
  init_tool_label_sidecar();
17929
17991
  init_model_label();
17930
17992
  MAX_JSONL_LINE_BYTES = 2 * 1024 * 1024;
@@ -71,6 +71,25 @@ export function codeSpanSafe(s: string): string {
71
71
  return s.replace(/`/g, '`​')
72
72
  }
73
73
 
74
+ /**
75
+ * Make a URL safe to interpolate as the destination of a `[label](href)`
76
+ * inline link.
77
+ *
78
+ * In GFM / Bot API 10.1 markdown a link destination written as `(...)` is a
79
+ * bare destination whose parentheses must be BALANCED, or every paren must be
80
+ * backslash-escaped. An href containing a literal `)` (Wikipedia
81
+ * `..._(disambiguation)` URLs, tracking params, generated deep links) can
82
+ * either truncate the URL (a lone `)` closing the destination) or, if we
83
+ * escape only `)`, unbalance the parens and leak a literal backslash into the
84
+ * decoded href. The destination honours C-style backslash escapes, so escape
85
+ * `\` first (so we never double-escape a following escape), then BOTH `(` and
86
+ * `)` — the whole URL is preserved balanced and micromark decodes it back to
87
+ * the original href on round-trip. Bot API 10.1 lists `(`/`)` as escapable.
88
+ */
89
+ export function escapeLinkHref(href: string): string {
90
+ return href.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)')
91
+ }
92
+
74
93
  /**
75
94
  * Repair LLM-side JSON escape bungles.
76
95
  *
@@ -268,12 +268,31 @@ export function createBlockedApprovalStore(
268
268
  *
269
269
  * This exists so the feature cannot silently no-op. If the shared bind is
270
270
  * missing (a container predating the volume), or auto-created root-owned, the
271
- * record still lands somewhere the operator and switchroom-web can read
272
- * web mounts all of `~/.switchroom`, so both locations are reachable.
271
+ * record still lands somewhere the operator and switchroom-web can read.
272
+ *
273
+ * **Reachable by mount is not the same as readable, and neither is the same as
274
+ * READ.** That elision is a bug this comment used to paper over: it claimed
275
+ * "web mounts all of ~/.switchroom, so both locations are reachable" — true,
276
+ * and irrelevant, because the reader only ever scanned the shared dir. The
277
+ * fallback was written to a file nobody read (#3109). Two things must hold, and
278
+ * both are load-bearing: if either regresses, the record exists, the agent is
279
+ * held, and the dashboard still prints "No agent is blocked".
280
+ *
281
+ * 1. **Mode.** The record is written 0644 into the 0775 agent dir. Verified
282
+ * live: `docker exec switchroom-web` reads a 0644 file under
283
+ * `~/.switchroom/agents/<agent>/` fine and gets EACCES only on the 0600
284
+ * files beside it. The FILE's mode locks web out, never the directory.
285
+ * 2. **The reader must LOOK here.** It now does —
286
+ * `src/web/blocked-approvals-read.ts` scans the shared dir AND
287
+ * `agents/<agent>/blocked-approval.json` (its `FALLBACK_RECORD_NAME`,
288
+ * which must stay in sync with the filename below).
273
289
  */
274
290
  fallbackDir?: string,
275
291
  ): BlockedApprovalStore {
276
292
  const primary = join(dir, `${agent}.json`)
293
+ // Keep in sync with FALLBACK_RECORD_NAME in src/web/blocked-approvals-read.ts —
294
+ // the reader matches this filename exactly. Pinned by the fallback-contract
295
+ // tests in src/web/blocked-approvals.test.ts.
277
296
  const fallback = fallbackDir != null ? join(fallbackDir, 'blocked-approval.json') : null
278
297
 
279
298
  /** Where the last successful write landed — `read`/`clear` must agree with it. */
@@ -2534,6 +2534,18 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
2534
2534
  // stub so any stale pinned message that fires an `auth:*` tap is
2535
2535
  // silently dismissed instead of crashing the gateway.
2536
2536
  async function handleAuthDashboardCallback(ctx: Context): Promise<void> {
2537
+ // Strict allowFrom gate, identical to every other mutating handler in
2538
+ // this file (handleOperatorEventCallback, the vra:/vrs:/vd:/vg: families).
2539
+ // Its absence was a security hole: `auth:use:<label>` drives
2540
+ // `client.setActive(label)` — a fleet-wide OAuth account swap — so an
2541
+ // ungated handler let any tapper (e.g. a member of an admin forum/
2542
+ // supergroup with an empty group allowFrom) swap the active account.
2543
+ const senderId = String(ctx.from?.id ?? '')
2544
+ const access = loadAccess()
2545
+ if (!access.allowFrom.includes(senderId)) {
2546
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
2547
+ return
2548
+ }
2537
2549
  const data = ctx.callbackQuery?.data ?? ''
2538
2550
  const currentAgent = getMyAgentName()
2539
2551