switchroom 0.18.12 → 0.18.14

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +57 -9
  2. package/dist/auth-broker/index.js +174 -72
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  7. package/dist/cli/notion-write-pretool.mjs +9 -2
  8. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  9. package/dist/cli/switchroom.js +986 -3131
  10. package/dist/host-control/main.js +216 -2863
  11. package/dist/vault/approvals/kernel-server.js +67 -1
  12. package/dist/vault/broker/server.js +98 -45
  13. package/package.json +1 -1
  14. package/profiles/coding/CLAUDE.md.hbs +2 -0
  15. package/profiles/default/CLAUDE.md.hbs +2 -0
  16. package/skills/switchroom-architecture/telegram.md +0 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  18. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  19. package/telegram-plugin/bridge/bridge.ts +2 -0
  20. package/telegram-plugin/dist/bridge/bridge.js +51 -3
  21. package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
  22. package/telegram-plugin/dist/server.js +67 -3
  23. package/telegram-plugin/format.ts +19 -0
  24. package/telegram-plugin/gateway/approval-hold.ts +21 -2
  25. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  26. package/telegram-plugin/gateway/auth-command.ts +14 -0
  27. package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
  28. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  29. package/telegram-plugin/gateway/gateway.ts +445 -83
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  31. package/telegram-plugin/history.ts +106 -6
  32. package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
  33. package/telegram-plugin/model-unavailable.ts +61 -13
  34. package/telegram-plugin/outbound-field-redact.ts +69 -0
  35. package/telegram-plugin/render/render.ts +32 -14
  36. package/telegram-plugin/render/rich-render.ts +40 -32
  37. package/telegram-plugin/scoped-approval.ts +11 -2
  38. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  39. package/telegram-plugin/secret-detect/index.ts +12 -56
  40. package/telegram-plugin/send-gate-degraded.test.ts +131 -0
  41. package/telegram-plugin/send-gate.test.ts +25 -6
  42. package/telegram-plugin/send-gate.ts +82 -8
  43. package/telegram-plugin/session-tail.ts +82 -7
  44. package/telegram-plugin/stream-controller.ts +3 -2
  45. package/telegram-plugin/subagent-watcher.ts +71 -16
  46. package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
  47. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  48. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  49. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  50. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  51. package/telegram-plugin/tests/history.test.ts +272 -0
  52. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  53. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
  54. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  55. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  56. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  57. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  58. package/telegram-plugin/tests/render/render.test.ts +88 -0
  59. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  60. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  61. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  62. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  63. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  64. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  65. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  66. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  67. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  68. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  69. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  70. package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
  71. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  72. package/telegram-plugin/throttle-tier.ts +226 -0
  73. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
  74. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  75. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  76. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,226 @@
1
+ /**
2
+ * throttle-tier.ts — the 429 throttle tier (pure decision + notice rendering).
3
+ *
4
+ * Operator-approved behavior spec: "retry in place under 5 min, else mark +
5
+ * failover, honest reset messaging."
6
+ *
7
+ * A terminal TRANSIENT 429 (a `rate_limit_error` whose wording explicitly
8
+ * negates the account-quota reading — see `transientUpstreamSignals` in
9
+ * model-unavailable.ts) used to take the fully calm path: no broker state, no
10
+ * account attribution, a generic "🚦 Rate limited" card. That is right for a
11
+ * few-second burst but wrong for the minutes-long per-account throttles
12
+ * Anthropic emits with a "resets 8:50am (TZ)" hint: the fleet has no memory
13
+ * that the account is throttled (cron fires walk straight into the same 429)
14
+ * and the operator can't tell a throttle from a wall.
15
+ *
16
+ * This module owns the DECISION and the NOTICE TEXT; the gateway wires the
17
+ * side effects (broker `mark-throttled`, the Telegram send, the delayed
18
+ * retry nudge):
19
+ *
20
+ * - reset parseable and ≤ threshold (default 5 min) → `throttle`: stay on
21
+ * the account, record `throttled_until` broker-side, notify once.
22
+ * - reset parseable and > threshold → `failover`: escalate to the existing
23
+ * mark-exhausted + fleet-failover machinery with the parsed reset as the
24
+ * mark expiry.
25
+ * - reset unparseable → `throttle` with a conservative now+60s wait.
26
+ *
27
+ * Wall wording ("You've hit your limit", out_of_credits) never reaches this
28
+ * module — session-tail classifies those `quota-exhausted` and the gateway's
29
+ * existing failover path handles them unchanged.
30
+ *
31
+ * PURE — no IPC, no bot, no clock except the injected `now`.
32
+ */
33
+
34
+ import { escapeMarkdown } from './card-format.js'
35
+ import { formatResetRelative } from './quota-check.js'
36
+ import { parseResetTime } from './model-unavailable.js'
37
+
38
+ // ─── Account-scoped throttle wording ─────────────────────────────────────────
39
+
40
+ /**
41
+ * ACCOUNT-AFFIRMING subset of `transientUpstreamSignals` (model-unavailable.ts).
42
+ * The throttle tier takes ACCOUNT-level actions — broker `mark-throttled`,
43
+ * per-account notice, retry nudge — so it must key on wording that affirms the
44
+ * account's OWN rate limit ("would exceed your account's rate limit",
45
+ * "not your account…"), NOT the server-side phrasings ("Server is temporarily
46
+ * limiting requests (not your usage limit)", 529 overload wording). A
47
+ * server-wide condition recorded as an account throttle would bench the wrong
48
+ * thing and restart-nudge an agent straight back into the same server-side
49
+ * wall. Server-side transients keep the existing calm rate-limited path
50
+ * (Claude Code's internal retry) untouched.
51
+ */
52
+ export const accountScopedThrottleSignals = [
53
+ "would exceed your account's rate limit",
54
+ 'would exceed your account’s rate limit',
55
+ // Covers both "not your account" and "not your account's" (substring).
56
+ 'not your account',
57
+ ]
58
+
59
+ /** True when `text` carries an EXPLICIT account-affirming throttle marker.
60
+ * Never throws on weird input. */
61
+ export function isAccountScopedThrottle(text: string): boolean {
62
+ if (typeof text !== 'string' || text.length === 0) return false
63
+ const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
64
+ const lower = sample.toLowerCase()
65
+ return accountScopedThrottleSignals.some((s) => lower.includes(s))
66
+ }
67
+
68
+ /**
69
+ * Default retry-in-place ceiling: a transient 429 whose reset is within this
70
+ * window waits on the account instead of failing the fleet over. Override
71
+ * with SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS.
72
+ */
73
+ export const THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT = 5 * 60_000
74
+
75
+ /**
76
+ * Wait applied when a transient 429 carries NO parseable reset. Anthropic's
77
+ * burst throttles clear in seconds; 60s is comfortably past the common case
78
+ * without benching the account for long on a misread.
79
+ */
80
+ export const THROTTLE_DEFAULT_WAIT_MS = 60_000
81
+
82
+ /** Resolve the retry-in-place threshold from env (ms), else the default. */
83
+ export function throttleRetryInPlaceMaxMs(
84
+ env: NodeJS.ProcessEnv = process.env,
85
+ ): number {
86
+ const raw = env.SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS
87
+ if (raw == null || raw === '') return THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT
88
+ const n = Number(raw)
89
+ return Number.isFinite(n) && n > 0 ? n : THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT
90
+ }
91
+
92
+ export type ThrottleTierDecision =
93
+ /** Not a transient per-account 429 — caller falls through to its
94
+ * existing handling (calm card / quota path). */
95
+ | { action: 'none' }
96
+ /** Stay on the account; record throttled_until broker-side; notify once. */
97
+ | { action: 'throttle'; throttledUntilMs: number; resetParsed: boolean }
98
+ /** Reset is beyond the retry-in-place threshold — escalate to the
99
+ * existing mark-exhausted + fleet-failover machinery. */
100
+ | { action: 'failover'; resetAtMs: number }
101
+
102
+ /**
103
+ * THE throttle-tier decision for a terminal `rate-limited` operator event.
104
+ * `detail` is the user-facing error text from the transcript (the source of
105
+ * both the transient-negation wording and the "resets …" prose).
106
+ */
107
+ export function decideThrottleTier(opts: {
108
+ detail: string
109
+ now: number
110
+ /** Retry-in-place ceiling (ms). Callers pass throttleRetryInPlaceMaxMs(). */
111
+ thresholdMs: number
112
+ }): ThrottleTierDecision {
113
+ const { detail, now, thresholdMs } = opts
114
+ // Account-affirming wording ONLY — the wider transient list includes
115
+ // server-side phrasings (529 / "server is temporarily limiting requests")
116
+ // for which an account-scoped throttle would be the wrong action.
117
+ if (!isAccountScopedThrottle(detail)) return { action: 'none' }
118
+ const resetAt = parseResetTime(detail, new Date(now))
119
+ const resetAtMs = resetAt?.getTime()
120
+ if (resetAtMs == null || !Number.isFinite(resetAtMs) || resetAtMs <= now) {
121
+ // No usable reset — wait the conservative default in place.
122
+ return {
123
+ action: 'throttle',
124
+ throttledUntilMs: now + THROTTLE_DEFAULT_WAIT_MS,
125
+ resetParsed: false,
126
+ }
127
+ }
128
+ if (resetAtMs - now <= thresholdMs) {
129
+ return { action: 'throttle', throttledUntilMs: resetAtMs, resetParsed: true }
130
+ }
131
+ return { action: 'failover', resetAtMs }
132
+ }
133
+
134
+ // ─── Per-account notice cooldown ─────────────────────────────────────────────
135
+
136
+ /**
137
+ * Cooldown for the lightweight throttle notice, PER ACCOUNT. A burst of
138
+ * transient 429s (session-tail forwards every terminal error line) must
139
+ * produce ONE notice, not a stream — same shape and rationale as the
140
+ * failure-notice / all-blocked cooldowns in auto-fallback-fleet.ts. Keyed by
141
+ * account (not agent): the throttle is account-scoped, so every agent riding
142
+ * the same throttled account shares one window.
143
+ */
144
+ export const THROTTLE_NOTICE_COOLDOWN_MS = 10 * 60_000
145
+
146
+ export interface ThrottleNoticeState {
147
+ /** account label → unix ms of the last notice sent for it. */
148
+ lastSentAtMsByAccount: Record<string, number>
149
+ }
150
+
151
+ export function evaluateThrottleNotice(
152
+ prev: ThrottleNoticeState,
153
+ account: string,
154
+ now: number,
155
+ cooldownMs: number = THROTTLE_NOTICE_COOLDOWN_MS,
156
+ ): { send: boolean; next: ThrottleNoticeState } {
157
+ const last = prev.lastSentAtMsByAccount[account] ?? 0
158
+ if (now - last >= cooldownMs) {
159
+ return {
160
+ send: true,
161
+ next: {
162
+ lastSentAtMsByAccount: {
163
+ ...prev.lastSentAtMsByAccount,
164
+ [account]: now,
165
+ },
166
+ },
167
+ }
168
+ }
169
+ return { send: false, next: prev }
170
+ }
171
+
172
+ // ─── Notice rendering ────────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * The ONE lightweight operator notice for the throttle path. Deliberately not
176
+ * the ⚠️ model-unavailable card: nothing is exhausted and nothing failed
177
+ * over — the point is to say so, name the account, and name the reset.
178
+ */
179
+ export function renderThrottleNotice(opts: {
180
+ /** Account label from the broker's mark-throttled response; null when the
181
+ * broker was unreachable and the account could not be attributed. */
182
+ account: string | null
183
+ agent: string
184
+ throttledUntilMs: number
185
+ /** True when the reset came from parsed "resets …" prose (vs the 60s default). */
186
+ resetParsed: boolean
187
+ now?: Date
188
+ }): string {
189
+ const now = opts.now ?? new Date()
190
+ // "resets in 3m" — same countdown dialect /usage speaks.
191
+ const resetStr = formatResetRelative(new Date(opts.throttledUntilMs), now)
192
+ const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
193
+ const lines = [
194
+ `🚦 **Rate-limited, staying put** — ${acct} hit a transient rate limit on **${escapeMarkdown(opts.agent)}**.`,
195
+ `This is a short throttle, not a quota wall — ${
196
+ opts.resetParsed ? resetStr : `no reset given, retrying in ~60s`
197
+ }.`,
198
+ `_Staying on ${acct}; no failover needed. The turn retries automatically after the reset._`,
199
+ ]
200
+ return lines.join('\n')
201
+ }
202
+
203
+ /**
204
+ * Notice for the broker's ESCALATION outcome: repeated transient 429s on one
205
+ * account were corroborated by a live probe as a genuine wall, so the broker
206
+ * ran the standard mark-exhausted + roll. The gateway that raised the
207
+ * mark-throttled announces it (the reactive-path doctrine — same reason the
208
+ * plain mark-exhausted path announces gateway-side rather than via
209
+ * `last_fleet_roll`), which also covers PINNED (non-fleet-active) accounts.
210
+ */
211
+ export function renderThrottleEscalationNotice(opts: {
212
+ account: string | null
213
+ agent: string
214
+ /** The account the fleet/agents rolled to; null = every fallback blocked. */
215
+ rolledTo: string | null
216
+ }): string {
217
+ const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
218
+ const head =
219
+ `⛔️ **Rate limit was actually a wall** — repeated 429s on ${acct} ` +
220
+ `(trigger: **${escapeMarkdown(opts.agent)}**) were corroborated by a live quota probe.`
221
+ const tail = opts.rolledTo
222
+ ? `Marked exhausted and rolled to \`${escapeMarkdown(opts.rolledTo)}\`.`
223
+ : `Marked exhausted — no fallback account had quota (all blocked). ` +
224
+ `Use \`/auth add <label>\` to attach another subscription.`
225
+ return `${head}\n${tail}`
226
+ }
@@ -61,13 +61,14 @@ import { richRenderEnabled } from "../../render/rich-render.js";
61
61
  const AGENT = "test-harness";
62
62
 
63
63
  // The rich-render wiring (parse -> IR -> renderSafe -> sendRichMessage) is
64
- // gated behind `SWITCHROOM_RICH_RENDER` (default OFF). The expandable /
65
- // collapsible round-trip proof below only runs when BOTH the driver creds AND
66
- // the flag are present — the flag must be set in the gateway process under
67
- // test for the renderer to actually shape the outbound message, so gating the
68
- // scenario on the same flag keeps it honest (no false green when the wiring
69
- // isn't live). When the flag is off, this scenario self-skips green exactly
70
- // like the credential-less case.
64
+ // ON BY DEFAULT, with `SWITCHROOM_RICH_RENDER=0` as the escape hatch. The
65
+ // expandable / collapsible round-trip proof below only runs when the driver
66
+ // creds are present AND the renderer hasn't been killed off — the same
67
+ // setting must hold in the gateway process under test for the renderer to
68
+ // actually shape the outbound message, so gating the scenario on the same
69
+ // check keeps it honest (no false green when the wiring isn't live). When
70
+ // the kill-switch is set, this scenario self-skips green exactly like the
71
+ // credential-less case.
71
72
  const RICH_RENDER_ON = richRenderEnabled();
72
73
 
73
74
  // The driver session is the load-bearing credential. Absent it, spinUp()
@@ -90,13 +90,30 @@ function defaultDeriveName(agentDir: string): string {
90
90
  return leaf;
91
91
  }
92
92
 
93
+ /**
94
+ * Two-tier owner-identity resolution shared by `ownedWorktreeCwds` and
95
+ * `refreshOwnedWorktreeHeartbeats`:
96
+ * Tier 1 — env fast path (`SWITCHROOM_AGENT_NAME`).
97
+ * Tier 2 — durable fallback derived from the agent's OWN directory basename.
98
+ * Returns "" when neither source yields a usable identity — callers MUST treat
99
+ * "" as fail-closed (never guess ownership).
100
+ */
101
+ export function resolveOwnerIdentity(
102
+ self: string | undefined,
103
+ agentDir: string | null | undefined,
104
+ deriveName?: (agentDir: string) => string,
105
+ ): string {
106
+ let resolved: string = self != null ? self : "";
107
+ if (resolved === "" && agentDir != null && agentDir !== "") {
108
+ const derive = deriveName ?? defaultDeriveName;
109
+ resolved = derive(agentDir) || "";
110
+ }
111
+ return resolved;
112
+ }
113
+
93
114
  export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
94
115
  // Tier 1: env fast path. Tier 2: durable agentDir-derived fallback.
95
- let resolved: string = opts.self != null ? opts.self : "";
96
- if (resolved === "" && opts.agentDir != null && opts.agentDir !== "") {
97
- const derive = opts.deriveName ?? defaultDeriveName;
98
- resolved = derive(opts.agentDir) || "";
99
- }
116
+ const resolved = resolveOwnerIdentity(opts.self, opts.agentDir, opts.deriveName);
100
117
 
101
118
  if (resolved === "") {
102
119
  // Both env and durable config unavailable. Keep the historical
@@ -133,3 +150,175 @@ export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
133
150
  return [];
134
151
  }
135
152
  }
153
+
154
+ /**
155
+ * Default minimum interval (ms) between heartbeat writes for the same
156
+ * worktree record. The gateway invokes the refresh on every ~1s rescan tick;
157
+ * writing every tick would be needless churn. Refreshing at most every 2 min
158
+ * keeps every live claim's heartbeat FAR fresher than the reaper's 10-min
159
+ * `STALE_THRESHOLD_MS`, so a live claim never reads as stale, while a dead
160
+ * gateway (no ticks) lets the heartbeat age out and become reap-eligible.
161
+ */
162
+ export const DEFAULT_HEARTBEAT_REFRESH_INTERVAL_MS = 2 * 60_000;
163
+
164
+ /** Minimal record shape needed to refresh a worktree heartbeat. */
165
+ export interface WorktreeHeartbeatRecord {
166
+ id: string;
167
+ ownerAgent?: string;
168
+ /** ISO 8601 timestamp of the record's current heartbeat (for throttling). */
169
+ heartbeatAt?: string;
170
+ }
171
+
172
+ export interface RefreshOwnedHeartbeatsOptions {
173
+ /** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
174
+ self: string | undefined;
175
+ /** Durable, non-env identity fallback: the agent's OWN directory. */
176
+ agentDir?: string | null;
177
+ /** Host-global registry read (`listRecords` from src/worktree/registry). */
178
+ listRecords: () => WorktreeHeartbeatRecord[];
179
+ /** Advance one record's heartbeat (`touchHeartbeat` from the registry). */
180
+ touchHeartbeat: (id: string) => void;
181
+ /**
182
+ * Skip a touch while the record's heartbeat is younger than this (ms).
183
+ * Defaults to `DEFAULT_HEARTBEAT_REFRESH_INTERVAL_MS`. Set to 0 to always
184
+ * touch (used by tests).
185
+ */
186
+ minRefreshIntervalMs?: number;
187
+ /** `Date.now` override for tests. */
188
+ now?: () => number;
189
+ /** Injectable name derivation (defaults to `path.basename`). */
190
+ deriveName?: (agentDir: string) => string;
191
+ /** Best-effort error sink. */
192
+ log?: (msg: string) => void;
193
+ }
194
+
195
+ /**
196
+ * Refresh the heartbeat of every worktree THIS agent owns.
197
+ *
198
+ * This is the production driver that keeps `touchHeartbeat` (previously dead —
199
+ * F1/H3) alive: the gateway calls it on the same rescan tick that re-derives
200
+ * the watched worktree cwds, so a claim held by a LIVE agent has its heartbeat
201
+ * advanced continuously and never trips the reaper's staleness gate. When the
202
+ * owning gateway dies, the ticks stop, the heartbeat ages past
203
+ * `STALE_THRESHOLD_MS`, and the (now fail-safe) reaper can reclaim the truly
204
+ * abandoned claim.
205
+ *
206
+ * Fail-CLOSED, mirroring `ownedWorktreeCwds`:
207
+ * - Unresolved identity ⇒ touch nothing (never guess ownership).
208
+ * - Ownerless records (`ownerAgent` undefined) are NEVER matched — we must
209
+ * not advance another agent's / an unattributable claim's heartbeat.
210
+ * - A registry read failure ⇒ touch nothing.
211
+ * - A per-record touch failure is swallowed (logged) — one bad record must
212
+ * not abort the rest, and this runs on the hot watch loop.
213
+ *
214
+ * @returns the number of heartbeats actually advanced this call.
215
+ */
216
+ export function refreshOwnedWorktreeHeartbeats(
217
+ opts: RefreshOwnedHeartbeatsOptions,
218
+ ): number {
219
+ const identity = resolveOwnerIdentity(opts.self, opts.agentDir, opts.deriveName);
220
+ if (identity === "") return 0; // fail-closed: never guess ownership
221
+
222
+ const nowMs = (opts.now ?? Date.now)();
223
+ const minInterval =
224
+ opts.minRefreshIntervalMs ?? DEFAULT_HEARTBEAT_REFRESH_INTERVAL_MS;
225
+
226
+ let records: WorktreeHeartbeatRecord[];
227
+ try {
228
+ records = opts.listRecords();
229
+ } catch {
230
+ return 0;
231
+ }
232
+
233
+ let touched = 0;
234
+ for (const r of records) {
235
+ if (r.ownerAgent !== identity) continue; // ownerless never matched
236
+ // Throttle: skip if the heartbeat is still comfortably fresh.
237
+ if (minInterval > 0 && r.heartbeatAt != null) {
238
+ const age = nowMs - new Date(r.heartbeatAt).getTime();
239
+ if (Number.isFinite(age) && age >= 0 && age < minInterval) continue;
240
+ }
241
+ try {
242
+ opts.touchHeartbeat(r.id);
243
+ touched++;
244
+ } catch (err) {
245
+ opts.log?.(
246
+ `worktree heartbeat refresh failed for ${r.id}: ${(err as Error).message}`,
247
+ );
248
+ }
249
+ }
250
+ return touched;
251
+ }
252
+
253
+ /** A registry record carrying everything both watch operations need. */
254
+ export type WorktreeWatchRecord = WorktreeOwnershipRecord & WorktreeHeartbeatRecord;
255
+
256
+ export interface WorktreeWatchProviderOptions {
257
+ /** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
258
+ self: string | undefined;
259
+ /** Durable, non-env identity fallback: the agent's OWN directory. */
260
+ agentDir?: string | null;
261
+ /** Host-global registry read (`listRecords` from src/worktree/registry). */
262
+ listRecords: () => WorktreeWatchRecord[];
263
+ /** Advance one record's heartbeat (`touchHeartbeat` from the registry). */
264
+ touchHeartbeat: (id: string) => void;
265
+ /** Injectable realpath for the cwd derivation (defaults to fs.realpathSync). */
266
+ realpath?: (p: string) => string;
267
+ /** Injectable name derivation from `agentDir` (defaults to path.basename). */
268
+ deriveName?: (agentDir: string) => string;
269
+ /** Heartbeat throttle window (ms); see refreshOwnedWorktreeHeartbeats. */
270
+ minRefreshIntervalMs?: number;
271
+ /** `Date.now` override for tests. */
272
+ now?: () => number;
273
+ /** Best-effort log sink shared by both operations. */
274
+ log?: (msg: string) => void;
275
+ }
276
+
277
+ /**
278
+ * Build the `extraWatchCwdsProvider` closure the gateway installs on the
279
+ * subagent watcher.
280
+ *
281
+ * The provider is invoked on every ~1s rescan tick and does TWO things on that
282
+ * single tick:
283
+ * 1. advances the heartbeat of every worktree THIS agent owns
284
+ * (`refreshOwnedWorktreeHeartbeats` — the production driver that keeps
285
+ * `touchHeartbeat` alive; without it every claim reads "stale" 10 min
286
+ * after creation and the reaper's staleness guarantee collapses), AND
287
+ * 2. returns the set of owned worktree cwds for the watcher to also watch
288
+ * (`ownedWorktreeCwds`).
289
+ *
290
+ * It is extracted from the gateway (rather than inlined) SO THAT the wiring —
291
+ * specifically that the provider actually DRIVES heartbeats, not merely returns
292
+ * cwds — is under direct unit test. Deleting the heartbeat refresh here is
293
+ * caught by the provider's behaviour test (it would return cwds but stop
294
+ * advancing heartbeats), which the pre-extraction inline closure could not
295
+ * assert against.
296
+ *
297
+ * Both operations use the SAME two-tier identity (env fast path + agentDir
298
+ * fallback), so a single `agentDir` kill-switch on the caller governs both.
299
+ * Fully best-effort: neither operation throws out of the returned closure.
300
+ */
301
+ export function makeWorktreeWatchProvider(
302
+ opts: WorktreeWatchProviderOptions,
303
+ ): () => string[] {
304
+ return () => {
305
+ refreshOwnedWorktreeHeartbeats({
306
+ self: opts.self,
307
+ agentDir: opts.agentDir,
308
+ listRecords: opts.listRecords,
309
+ touchHeartbeat: opts.touchHeartbeat,
310
+ minRefreshIntervalMs: opts.minRefreshIntervalMs,
311
+ now: opts.now,
312
+ deriveName: opts.deriveName,
313
+ log: opts.log,
314
+ });
315
+ return ownedWorktreeCwds({
316
+ self: opts.self,
317
+ agentDir: opts.agentDir,
318
+ listRecords: opts.listRecords,
319
+ realpath: opts.realpath,
320
+ deriveName: opts.deriveName,
321
+ log: opts.log,
322
+ });
323
+ };
324
+ }
@@ -1,95 +0,0 @@
1
- /**
2
- * Secretlint wrapper — adapts `@secretlint/core` + the recommend preset into
3
- * our `Detection` shape so it can merge with the vendored pattern engine.
4
- *
5
- * Secretlint is async (it loads rules, applies preset config, walks the
6
- * source). This module exposes `detectViaSecretlint(text)` returning a
7
- * Promise. The synchronous `detectSecrets()` path in `index.ts` stays the
8
- * fast default; callers that want the full engine use `detectSecretsAsync()`
9
- * which fans out both and merges.
10
- *
11
- * Slug derivation: Secretlint rules don't give us a clean LHS (KEY=value),
12
- * so we derive from the rule id (e.g. `@secretlint/secretlint-rule-slack`
13
- * becomes the slug `@secretlint-rule-slack_YYYYMMDD` via `deriveSlug`'s
14
- * rule_id + date fallback path).
15
- *
16
- * Confidence tier: Secretlint is a curated engine with checksum-validated
17
- * rules for most providers, so every hit is `high`.
18
- */
19
-
20
- import { lintSource } from '@secretlint/core'
21
- import { creator as presetRecommendCreator } from '@secretlint/secretlint-rule-preset-recommend'
22
- import type { Detection } from './index.js'
23
- import { deriveSlug } from './slug.js'
24
- import { isSuppressed } from './suppressor.js'
25
-
26
- /**
27
- * Map a single Secretlint rule id to a short rule slug used as the
28
- * `Detection.rule_id`. The full `@secretlint/secretlint-rule-foo` names are
29
- * long; we strip the scope/prefix to keep rule_ids readable in logs.
30
- */
31
- function normalizeRuleId(secretlintRuleId: string): string {
32
- // "@secretlint/secretlint-rule-slack" → "secretlint_slack"
33
- // "secretlint-rule-custom-thing" → "secretlint_custom_thing"
34
- const stripped = secretlintRuleId
35
- .replace(/^@secretlint\//, '')
36
- .replace(/^secretlint-rule-/, '')
37
- .replace(/[^A-Za-z0-9]+/g, '_')
38
- .replace(/_+/g, '_')
39
- .replace(/^_+|_+$/g, '')
40
- return `secretlint_${stripped || 'unknown'}`
41
- }
42
-
43
- export async function detectViaSecretlint(text: string): Promise<Detection[]> {
44
- if (!text || text.length === 0) return []
45
-
46
- let result
47
- try {
48
- result = await lintSource({
49
- source: {
50
- content: text,
51
- filePath: 'message.txt',
52
- ext: '.txt',
53
- contentType: 'text',
54
- },
55
- options: {
56
- config: {
57
- rules: [
58
- {
59
- id: '@secretlint/secretlint-rule-preset-recommend',
60
- rule: presetRecommendCreator,
61
- },
62
- ],
63
- },
64
- noPhysicFilePath: true,
65
- },
66
- })
67
- } catch {
68
- // Fail-open: Secretlint crashes must never break the detector path.
69
- return []
70
- }
71
-
72
- const existing = new Set<string>()
73
- const out: Detection[] = []
74
- for (const msg of result.messages) {
75
- const [start, end] = msg.range
76
- if (typeof start !== 'number' || typeof end !== 'number' || end <= start) continue
77
- const matched_text = text.slice(start, end)
78
- if (!matched_text) continue
79
- const rule_id = normalizeRuleId(msg.ruleId)
80
- const suggested_slug = deriveSlug({ rule_id }, existing)
81
- existing.add(suggested_slug)
82
- out.push({
83
- rule_id,
84
- matched_text,
85
- start,
86
- end,
87
- confidence: 'high',
88
- suppressed: isSuppressed(text, start, end),
89
- suggested_slug,
90
- })
91
- }
92
- return out
93
- }
94
-
95
- export { normalizeRuleId as __normalizeRuleId }
@@ -1,105 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import {
3
- detectViaSecretlint,
4
- detectSecretsAsync,
5
- } from '../secret-detect/index.js'
6
-
7
- // All three fixtures are assembled at runtime so the source file never
8
- // contains a contiguous token pattern. Matches the corresponding
9
- // secretlint regex when evaluated; evades GitHub Push Protection's
10
- // static-text scan. See CLAUDE.md "Secrets in tests".
11
- const SLACK_FIXTURE = ['xoxb', '0000000000', '0000000000000', 'FIXTURE0NOTAREALTOKEN000'].join('-')
12
- const GITHUB_FIXTURE = 'ghp' + '_' + '16C7e42F292c6912E7710c838347Ae178B4a'
13
- const NPM_FIXTURE = 'npm' + '_' + 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789'
14
-
15
- describe('secretlint-source.detectViaSecretlint', () => {
16
- it('catches a realistic-looking Slack bot token', async () => {
17
- const text = 'Slack: ' + SLACK_FIXTURE
18
- const hits = await detectViaSecretlint(text)
19
- expect(hits.length).toBeGreaterThan(0)
20
- const slack = hits.find((h) => h.rule_id.includes('slack'))
21
- expect(slack).toBeDefined()
22
- expect(slack!.confidence).toBe('high')
23
- expect(slack!.suppressed).toBe(false)
24
- // rule_id normalized from @secretlint/secretlint-rule-slack → secretlint_slack
25
- expect(slack!.rule_id).toMatch(/^secretlint_/)
26
- expect(slack!.rule_id).toContain('slack')
27
- // Matched text should be the actual token bytes.
28
- expect(slack!.matched_text.startsWith('xoxb-')).toBe(true)
29
- // Slug derived from rule_id + date (rule_id fallback path).
30
- expect(slack!.suggested_slug).toMatch(/^secretlint_slack_\d{8}/)
31
- })
32
-
33
- it('catches a GitHub personal access token', async () => {
34
- const text = 'token=' + GITHUB_FIXTURE + ' rest of message'
35
- const hits = await detectViaSecretlint(text)
36
- const gh = hits.find((h) => h.rule_id.includes('github'))
37
- expect(gh).toBeDefined()
38
- expect(gh!.confidence).toBe('high')
39
- expect(gh!.matched_text.startsWith('ghp_')).toBe(true)
40
- expect(gh!.rule_id).toContain('github')
41
- })
42
-
43
- it('catches an NPM access token', async () => {
44
- const text = 'NPM_TOKEN=' + NPM_FIXTURE
45
- const hits = await detectViaSecretlint(text)
46
- const npm = hits.find((h) => h.rule_id.includes('npm'))
47
- expect(npm).toBeDefined()
48
- expect(npm!.confidence).toBe('high')
49
- expect(npm!.matched_text.startsWith('npm_')).toBe(true)
50
- })
51
-
52
- it('returns empty for empty input', async () => {
53
- expect(await detectViaSecretlint('')).toEqual([])
54
- })
55
-
56
- it('returns empty for text with no secrets', async () => {
57
- expect(await detectViaSecretlint('hello how are you today')).toEqual([])
58
- })
59
-
60
- it('marks nearby test/mock markers as suppressed', async () => {
61
- const text = 'test example: ' + SLACK_FIXTURE
62
- const hits = await detectViaSecretlint(text)
63
- const slack = hits.find((h) => h.rule_id.includes('slack'))
64
- expect(slack).toBeDefined()
65
- expect(slack!.suppressed).toBe(true)
66
- })
67
- })
68
-
69
- describe('detectSecretsAsync merge', () => {
70
- it('merges Secretlint hits with vendored pattern hits, deduped by range', async () => {
71
- // Slack token that matches both the vendored anchored pattern AND Secretlint.
72
- const text = 'a ' + SLACK_FIXTURE + ' end'
73
- const hits = await detectSecretsAsync(text)
74
- // One entry for the Slack token — not two. Vendored wins on ties.
75
- const slackHits = hits.filter(
76
- (h) => h.matched_text.startsWith('xoxb-'),
77
- )
78
- expect(slackHits).toHaveLength(1)
79
- // Vendored rule id wins on exact-range ties (listed first in merge).
80
- expect(slackHits[0]!.rule_id).toBe('slack_token')
81
- })
82
-
83
- it('detects a Shopify token via the async (Secretlint-augmented) path', async () => {
84
- // Shopify is now ALSO a vendored PROVIDER_PATTERN (shopify_shared_secret),
85
- // so on this span the merge prefers the vendored high hit over the
86
- // Secretlint one — both are valid Shopify classifications. Secretlint
87
- // remains the fallback for the long tail of providers we don't vendor;
88
- // this asserts the async path still detects + classifies the token.
89
- const text = 'SHOPIFY=shpss_1234567890abcdef1234567890abcdef and go'
90
- const hits = await detectSecretsAsync(text)
91
- const shopify = hits.find((h) => h.matched_text.startsWith('shpss_'))
92
- expect(shopify).toBeDefined()
93
- expect(shopify!.rule_id).toMatch(/shopify/)
94
- expect(shopify!.confidence).toBe('high')
95
- })
96
-
97
- it('produces unique slugs across the merged detection list', async () => {
98
- const text =
99
- 'tok1=' + GITHUB_FIXTURE +
100
- ' and tok2=' + SLACK_FIXTURE
101
- const hits = await detectSecretsAsync(text)
102
- const slugs = hits.map((h) => h.suggested_slug)
103
- expect(new Set(slugs).size).toBe(slugs.length)
104
- })
105
- })