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.
- package/dist/agent-scheduler/index.js +8 -0
- package/dist/auth-broker/index.js +63 -65
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -1
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +952 -3126
- package/dist/host-control/main.js +216 -2862
- package/dist/vault/approvals/kernel-server.js +67 -0
- package/dist/vault/broker/server.js +98 -44
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +49 -3
- package/telegram-plugin/dist/gateway/gateway.js +656 -2326
- package/telegram-plugin/dist/server.js +65 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/gateway.ts +221 -73
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -50,11 +50,13 @@
|
|
|
50
50
|
*
|
|
51
51
|
* SAFETY / ROLLOUT
|
|
52
52
|
* ----------------
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* ON BY DEFAULT in every install — an escape hatch, not an opt-in feature.
|
|
54
|
+
* `SWITCHROOM_TELEGRAM_SEND_GATE=0` (or `false`/`off`/`no`) is the safety valve
|
|
55
|
+
* that disables it without a rebuild, following the repo's default-on kill-
|
|
56
|
+
* switch convention (`midTurnFloorEnabled`, `SWITCHROOM_RATE_LIMIT_OVERAGE=0`).
|
|
57
|
+
* When disabled, `gate()` is a pure passthrough to the wrapped call — zero
|
|
58
|
+
* behaviour change. Priority-class shedding + degraded mode and observability +
|
|
59
|
+
* operator alert build on the counters exposed here.
|
|
58
60
|
*
|
|
59
61
|
* DETERMINISM / TESTABILITY
|
|
60
62
|
* -------------------------
|
|
@@ -356,6 +358,26 @@ interface PendingEdit {
|
|
|
356
358
|
promise: Promise<unknown>
|
|
357
359
|
resolve: (v: unknown) => void
|
|
358
360
|
reject: (e: unknown) => void
|
|
361
|
+
/**
|
|
362
|
+
* Effective priority of the CURRENTLY-queued edit for this message. Set when
|
|
363
|
+
* the pending edit is created and UPGRADED (never downgraded) on coalesce, so
|
|
364
|
+
* that a `critical` edit coalescing onto a non-critical driver still gets the
|
|
365
|
+
* critical fail-fast treatment. The driver reads THIS (not the driver-start
|
|
366
|
+
* opts) to decide fail-fast vs unbounded admit (F2, review 2026-07-12).
|
|
367
|
+
*/
|
|
368
|
+
priorityClass: PriorityClass
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Total order over priority classes: cosmetic < useful < critical. */
|
|
372
|
+
const PRIORITY_RANK: Record<PriorityClass, number> = {
|
|
373
|
+
cosmetic: 0,
|
|
374
|
+
useful: 1,
|
|
375
|
+
critical: 2,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Return the HIGHER-priority of two classes (upgrade-only; never downgrades). */
|
|
379
|
+
function maxPriority(a: PriorityClass, b: PriorityClass): PriorityClass {
|
|
380
|
+
return PRIORITY_RANK[b] > PRIORITY_RANK[a] ? b : a
|
|
359
381
|
}
|
|
360
382
|
|
|
361
383
|
interface MessageEditState {
|
|
@@ -806,7 +828,37 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
806
828
|
continue
|
|
807
829
|
}
|
|
808
830
|
|
|
809
|
-
|
|
831
|
+
// A `critical` edit must NEVER block unbounded (part3-design §3). Mirror
|
|
832
|
+
// the non-edit critical path in `gate`: admit via the priority-aware loop
|
|
833
|
+
// so a flood-wait ban longer than the fail-fast ceiling rejects with a
|
|
834
|
+
// structured FLOOD_WAIT_ACTIVE — and a window that EXTENDS past the
|
|
835
|
+
// ceiling while we wait out a short window converts to fail-fast too (M2,
|
|
836
|
+
// review PR #3106) — instead of the unbounded `admit` below, which would
|
|
837
|
+
// re-introduce the multi-hour reply wedge the send-gate exists to
|
|
838
|
+
// eliminate (F4, review 2026-07-11). Non-critical edits keep PR 1's
|
|
839
|
+
// unbounded coalescing admit unchanged.
|
|
840
|
+
//
|
|
841
|
+
// Read the CURRENT pending edit's class (`p.priorityClass`), NOT the
|
|
842
|
+
// driver-start `opts`: a `critical` edit that coalesced onto a driver
|
|
843
|
+
// started by a non-critical edit upgraded `p.priorityClass`, and MUST
|
|
844
|
+
// fail-fast here rather than ride the non-critical unbounded admit for
|
|
845
|
+
// the whole ban (F2, review 2026-07-12).
|
|
846
|
+
if (p.priorityClass === 'critical') {
|
|
847
|
+
const outcome = await admitPriority(bucketsFor(opts), 'critical')
|
|
848
|
+
if (outcome.result === 'failfast') {
|
|
849
|
+
counters.failedFast++
|
|
850
|
+
const retryAfterSec = Math.ceil((outcome.untilTs - clock.now()) / 1000)
|
|
851
|
+
openScopedWindowsForOpts(opts, outcome.untilTs)
|
|
852
|
+
// Reject THIS edit's own promise (fail fast) and loop: a distinct
|
|
853
|
+
// newer edit that arrived during the wait owns a fresh state.pending
|
|
854
|
+
// and is handled on the next iteration.
|
|
855
|
+
p.reject(makeFloodWaitActiveError(retryAfterSec, outcome.untilTs, null))
|
|
856
|
+
continue
|
|
857
|
+
}
|
|
858
|
+
// outcome.result === 'ok' → admitPriority already consumed the buckets.
|
|
859
|
+
} else {
|
|
860
|
+
await admit(bucketsFor(opts))
|
|
861
|
+
}
|
|
810
862
|
// Reserve the send-start time BEFORE awaiting the network so the floor
|
|
811
863
|
// is measured from send start (matches the per-message serialization).
|
|
812
864
|
state.lastSentMs = clock.now()
|
|
@@ -874,6 +926,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
874
926
|
// a coalesced revert so nothing needless hits the API. All callers share the
|
|
875
927
|
// single pending promise, which resolves with the coalesced send's result.
|
|
876
928
|
if (state.pending) {
|
|
929
|
+
// Upgrade (never downgrade) the queued edit's effective priority. A
|
|
930
|
+
// `critical` edit coalescing onto a `useful`/`cosmetic` pending edit must
|
|
931
|
+
// ride the driver's critical fail-fast path — otherwise the critical work
|
|
932
|
+
// rides the non-critical unbounded admit and blocks for a whole flood ban
|
|
933
|
+
// (F2, review 2026-07-12). We upgrade even when the hash is unchanged (a
|
|
934
|
+
// no-op payload from a critical caller still deserves fail-fast, not an
|
|
935
|
+
// unbounded block); a lower-priority coalesce leaves the class intact.
|
|
936
|
+
state.pending.priorityClass = maxPriority(
|
|
937
|
+
state.pending.priorityClass,
|
|
938
|
+
opts.priorityClass ?? 'useful',
|
|
939
|
+
)
|
|
877
940
|
if (state.pending.hash !== hash) {
|
|
878
941
|
counters.coalesced++
|
|
879
942
|
state.pending.hash = hash
|
|
@@ -905,6 +968,7 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
905
968
|
promise,
|
|
906
969
|
resolve,
|
|
907
970
|
reject,
|
|
971
|
+
priorityClass: opts.priorityClass ?? 'useful',
|
|
908
972
|
}
|
|
909
973
|
state.pending = pending
|
|
910
974
|
if (!state.running) void drive(state, opts)
|
|
@@ -976,7 +1040,17 @@ export function createSendGate(config: SendGateConfig): SendGate {
|
|
|
976
1040
|
return { gate, openFloodWindow, stats }
|
|
977
1041
|
}
|
|
978
1042
|
|
|
979
|
-
/**
|
|
1043
|
+
/**
|
|
1044
|
+
* The send gate is an ESCAPE HATCH, not an opt-in feature: it is ON BY DEFAULT
|
|
1045
|
+
* in every install and can be disabled as a safety valve. Mirrors the repo's
|
|
1046
|
+
* default-on kill-switch convention (`midTurnFloorEnabled`, `PIN_STATUS_WHILE_
|
|
1047
|
+
* WORKING`, `SWITCHROOM_RATE_LIMIT_OVERAGE`): enabled unless
|
|
1048
|
+
* `SWITCHROOM_TELEGRAM_SEND_GATE` is explicitly set to a falsey/off value
|
|
1049
|
+
* (`0`/`false`/`off`/`no`, case-insensitive, trimmed). Unset → enabled.
|
|
1050
|
+
*/
|
|
980
1051
|
export function sendGateEnabledFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
981
|
-
|
|
1052
|
+
const v = env.SWITCHROOM_TELEGRAM_SEND_GATE
|
|
1053
|
+
if (v == null) return true
|
|
1054
|
+
const t = v.trim().toLowerCase()
|
|
1055
|
+
return !(t === '0' || t === 'false' || t === 'off' || t === 'no')
|
|
982
1056
|
}
|
|
@@ -41,7 +41,8 @@ function isMultiAgentEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
|
41
41
|
return env.PROGRESS_CARD_MULTI_AGENT !== '0'
|
|
42
42
|
}
|
|
43
43
|
import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
|
|
44
|
-
import {
|
|
44
|
+
import { isTransientUpstreamSignal } from './model-unavailable.js'
|
|
45
|
+
import { createToolLabelSidecar, type ToolLabelSidecar, type SidecarOptions } from './tool-label-sidecar.js'
|
|
45
46
|
import { isModelSentinel } from './model-label.js'
|
|
46
47
|
|
|
47
48
|
/** Match Claude Code's cli.js VX() function. */
|
|
@@ -668,13 +669,26 @@ export function detectErrorInTranscriptLine(
|
|
|
668
669
|
typeof obj.apiErrorStatus === 'number' ? obj.apiErrorStatus : null
|
|
669
670
|
const errStr = typeof obj.error === 'string' ? obj.error : ''
|
|
670
671
|
const text = extractAssistantText(obj)
|
|
671
|
-
// A 429 in this shape is a subscription usage-limit
|
|
672
|
-
// a reset time) — classify it quota-exhausted so the operator
|
|
673
|
-
// resolves to an auto-fallback-eligible kind
|
|
672
|
+
// A 429 in this shape is USUALLY a subscription usage-limit wall (it
|
|
673
|
+
// carries a reset time) — classify it quota-exhausted so the operator
|
|
674
|
+
// event resolves to an auto-fallback-eligible kind that always shows the
|
|
675
|
+
// "model unavailable" card. BUT Anthropic also emits a 429 for a TRANSIENT
|
|
676
|
+
// per-account burst / RPM throttle whose wording explicitly negates the
|
|
677
|
+
// account-quota reading ("This request would exceed your account's rate
|
|
678
|
+
// limit … not your usage limit"). That is a self-healing few-second throttle
|
|
679
|
+
// Claude Code retries internally — blanket-labeling it quota-exhausted fired
|
|
680
|
+
// a false scary card on the fleet (carrie incident, 2026-07-12). So a 429 is
|
|
681
|
+
// only quota-exhausted when it LACKS an explicit transient-burst marker;
|
|
682
|
+
// with one, classify it rate-limited so it takes the calm path (no card, no
|
|
683
|
+
// failover). Keyed on the explicit transient NEGATION (canonical list in
|
|
684
|
+
// model-unavailable.ts) — an ambiguous 429 that merely says "limit" stays
|
|
685
|
+
// quota-exhausted, biasing toward surfacing a real wall. Other statuses fall
|
|
674
686
|
// through to the shared classifier.
|
|
675
687
|
const kind: OperatorEventKind =
|
|
676
688
|
status === 429
|
|
677
|
-
?
|
|
689
|
+
? isTransientUpstreamSignal(`${text}\n${errStr}`)
|
|
690
|
+
? 'rate-limited'
|
|
691
|
+
: 'quota-exhausted'
|
|
678
692
|
: classifyClaudeError({ type: errStr, status, message: text })
|
|
679
693
|
// An `isApiErrorMessage` line is Claude surfacing the failure to the
|
|
680
694
|
// user — terminal by construction (Claude writes this shape only
|
|
@@ -781,6 +795,13 @@ export interface SessionTailConfig {
|
|
|
781
795
|
claudeHome?: string
|
|
782
796
|
/** How often to re-scan for a new active session file (ms). Default 500. */
|
|
783
797
|
rescanIntervalMs?: number
|
|
798
|
+
/**
|
|
799
|
+
* Idle window before an inactive sub-agent FSWatcher (and its PreToolUse
|
|
800
|
+
* sidecar) is reaped, in ms. Defaults to 5 minutes — well past the 99th-
|
|
801
|
+
* percentile sub-agent completion time. Exposed only so tests can drive the
|
|
802
|
+
* reap deterministically without a 5-minute wall-clock wait.
|
|
803
|
+
*/
|
|
804
|
+
subTailIdleReapMs?: number
|
|
784
805
|
/** Optional logger. */
|
|
785
806
|
log?: (msg: string) => void
|
|
786
807
|
/** Called for each parsed event. */
|
|
@@ -791,6 +812,18 @@ export interface SessionTailConfig {
|
|
|
791
812
|
* TODO(Phase 4b): wire this to the gateway's emitOperatorEvent pipeline.
|
|
792
813
|
*/
|
|
793
814
|
onOperatorEvent?: (event: TailOperatorEvent) => void
|
|
815
|
+
/**
|
|
816
|
+
* PreToolUse sidecar factory. Defaults to the real `createToolLabelSidecar`;
|
|
817
|
+
* production never sets this. It exists as a dependency-injection seam so the
|
|
818
|
+
* M1 FD-leak reap test can drive a fake sidecar per session WITHOUT
|
|
819
|
+
* `vi.mock`-ing the shared `tool-label-sidecar` module: bun's `vi.mock` is
|
|
820
|
+
* process-global (not file-scoped like vitest), and the CI bun-test shard
|
|
821
|
+
* runs the whole `tests/` dir in ONE process, so a module-mock here would
|
|
822
|
+
* leak into the real `tool-label-sidecar.test.ts` suite and break it. This
|
|
823
|
+
* mirrors the repo's bun-safe injection precedent (`vault-write-posture`'s
|
|
824
|
+
* optional `deps` param).
|
|
825
|
+
*/
|
|
826
|
+
createSidecar?: (opts: SidecarOptions) => ToolLabelSidecar
|
|
794
827
|
}
|
|
795
828
|
|
|
796
829
|
export interface SessionTailHandle {
|
|
@@ -887,6 +920,7 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
887
920
|
// $TELEGRAM_STATE_DIR/tool-labels-<session_id>.jsonl. Each sub-agent
|
|
888
921
|
// has its OWN sessionId (its jsonl filename stem), so we key by that.
|
|
889
922
|
const sidecars = new Map<string, ToolLabelSidecar>()
|
|
923
|
+
const createSidecar = config.createSidecar ?? createToolLabelSidecar
|
|
890
924
|
const stateDirForSidecar = process.env.TELEGRAM_STATE_DIR ?? null
|
|
891
925
|
function sessionIdForFile(file: string | null): string | null {
|
|
892
926
|
if (!file) return null
|
|
@@ -898,7 +932,7 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
898
932
|
const existing = sidecars.get(sessionId)
|
|
899
933
|
if (existing) return existing
|
|
900
934
|
try {
|
|
901
|
-
const s =
|
|
935
|
+
const s = createSidecar({ stateDir: stateDirForSidecar, sessionId })
|
|
902
936
|
sidecars.set(sessionId, s)
|
|
903
937
|
// Real-time draft-mirror source: emit a `tool_label` event the moment
|
|
904
938
|
// the hook writes a label (flush-independent), so the gateway can
|
|
@@ -913,6 +947,22 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
913
947
|
return null
|
|
914
948
|
}
|
|
915
949
|
}
|
|
950
|
+
/**
|
|
951
|
+
* M1 FD-leak fix: stop and forget the PreToolUse sidecar for a session that
|
|
952
|
+
* has ended (a rotated-away parent session, or a reaped sub-agent). Each
|
|
953
|
+
* sidecar holds its own stat-poll timer (and, on real fs, a file handle);
|
|
954
|
+
* pre-fix they were only reaped in `stop()`, so every session rotation
|
|
955
|
+
* (`/clear`, compaction → new sessionId) and every finished sub-agent leaked
|
|
956
|
+
* one for the gateway's life. Idempotent — a no-op when the key is absent.
|
|
957
|
+
*/
|
|
958
|
+
function stopSidecar(sessionId: string | null): void {
|
|
959
|
+
if (!sessionId) return
|
|
960
|
+
const s = sidecars.get(sessionId)
|
|
961
|
+
if (!s) return
|
|
962
|
+
try { s.stop() } catch { /* ignore */ }
|
|
963
|
+
sidecars.delete(sessionId)
|
|
964
|
+
}
|
|
965
|
+
|
|
916
966
|
function decorate(ev: SessionEvent, sessionId: string | null): SessionEvent {
|
|
917
967
|
if (!sessionId) return ev
|
|
918
968
|
if (ev.kind !== 'tool_use' && ev.kind !== 'sub_agent_tool_use') return ev
|
|
@@ -1024,6 +1074,19 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1024
1074
|
try { watcher.close() } catch { /* ignore */ }
|
|
1025
1075
|
watcher = null
|
|
1026
1076
|
}
|
|
1077
|
+
// M1 FD-leak fix: we are rotating the PARENT tail off `currentFile`; its
|
|
1078
|
+
// PreToolUse sidecar is no longer needed (its watcher just closed). Reap it
|
|
1079
|
+
// so `/clear`- and compaction-driven session rotations don't accumulate one
|
|
1080
|
+
// idle sidecar poll-timer per rotation. Parent session ids are the JSONL
|
|
1081
|
+
// stem (`<uuid>`); sub-agent sidecars are keyed by `agent-<id>` stems and
|
|
1082
|
+
// owned by their sub-tail (reaped in `reapIdleSubTails`), so this never
|
|
1083
|
+
// stops a sidecar a live sub-tail still depends on. A later re-attach to
|
|
1084
|
+
// this same file transparently recreates the sidecar via `ensureSidecar`.
|
|
1085
|
+
const rotatedAwaySid = sessionIdForFile(currentFile)
|
|
1086
|
+
const nextSid = sessionIdForFile(file)
|
|
1087
|
+
if (rotatedAwaySid != null && rotatedAwaySid !== nextSid) {
|
|
1088
|
+
stopSidecar(rotatedAwaySid)
|
|
1089
|
+
}
|
|
1027
1090
|
currentFile = file
|
|
1028
1091
|
const prior = fileCursors.get(file)
|
|
1029
1092
|
if (prior != null) {
|
|
@@ -1113,7 +1176,12 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1113
1176
|
* very-long task (rescanSubagents picks the file back up on the
|
|
1114
1177
|
* next tick if it grows).
|
|
1115
1178
|
*/
|
|
1116
|
-
|
|
1179
|
+
// Floor-clamp the reap window: a 0 / negative override would make
|
|
1180
|
+
// `reapIdleSubTails` treat every live sub-tail as instantly idle
|
|
1181
|
+
// (cutoff = Date.now() - 0 ≥ lastActivityAt), reaping every live
|
|
1182
|
+
// sidecar on the first tick. Only tests set this today, but the clamp
|
|
1183
|
+
// makes the footgun unreachable — the smallest sane window is 1s.
|
|
1184
|
+
const IDLE_FSWATCH_TTL_MS = Math.max(1000, config.subTailIdleReapMs ?? 5 * 60 * 1000)
|
|
1117
1185
|
|
|
1118
1186
|
function readSub(t: SubTail): void {
|
|
1119
1187
|
if (stopped) return
|
|
@@ -1254,6 +1322,13 @@ export function startSessionTail(config: SessionTailConfig): SessionTailHandle {
|
|
|
1254
1322
|
try { t.watcher.close() } catch { /* ignore */ }
|
|
1255
1323
|
t.watcher = null
|
|
1256
1324
|
}
|
|
1325
|
+
// M1 FD-leak fix: reap the sub-agent's PreToolUse sidecar alongside its
|
|
1326
|
+
// file watcher. Sub-agent sidecars are keyed by the sub file's stem
|
|
1327
|
+
// (`agent-<id>`), created lazily by `decorate` while reading the sub
|
|
1328
|
+
// JSONL. Pre-fix `reapIdleSubTails` closed the sub-tail watcher but left
|
|
1329
|
+
// the sidecar (and its poll timer) alive until `stop()`, so a long-lived
|
|
1330
|
+
// agent leaked one sidecar per finished sub-agent.
|
|
1331
|
+
stopSidecar(sessionIdForFile(t.file))
|
|
1257
1332
|
subTails.delete(file)
|
|
1258
1333
|
log?.(`session-tail: reaped idle sub ${t.agentId} (${file})`)
|
|
1259
1334
|
}
|
|
@@ -1779,21 +1779,39 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1779
1779
|
maybySendStateTransition(agentId)
|
|
1780
1780
|
}
|
|
1781
1781
|
|
|
1782
|
-
// Set up FSWatcher
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1782
|
+
// Set up FSWatcher.
|
|
1783
|
+
//
|
|
1784
|
+
// FD-leak fix (H2): only open an inotify watch when it can do real work.
|
|
1785
|
+
// A historical entry that is done-at-boot (already `scheduleTerminalCleanup`d
|
|
1786
|
+
// just above) or a stale `running` entry that will never be promoted (no
|
|
1787
|
+
// `bootPromotionPending`) has NO live transition left to observe —
|
|
1788
|
+
// `checkStalls` skips every historical entry, so such an entry never
|
|
1789
|
+
// reaches a terminal transition and `scheduleTerminalCleanup` never runs
|
|
1790
|
+
// for it. Opening an `fs.watch` for one leaks its inotify FD for the whole
|
|
1791
|
+
// gateway lifetime (the dozens-to-hundreds of dead prior-session
|
|
1792
|
+
// `running` JSONLs a 24/7 agent accumulates). The poll loop still reads
|
|
1793
|
+
// any registered running entry defensively, so dropping the watcher here
|
|
1794
|
+
// costs nothing but the leaked FD. Open the watch only for a live worker
|
|
1795
|
+
// or a historical one still awaiting boot-promotion growth confirmation.
|
|
1796
|
+
const needsWatcher = !entry.historical || entry.bootPromotionPending != null
|
|
1797
|
+
if (needsWatcher) {
|
|
1798
|
+
try {
|
|
1799
|
+
tail.watcher = fs.watch(filePath, () => {
|
|
1800
|
+
if (stopped) return
|
|
1801
|
+
const entry = registry.get(agentId)
|
|
1802
|
+
const t = tails.get(agentId)
|
|
1803
|
+
if (!entry || !t) return
|
|
1804
|
+
checkBootPromotionGrowth(entry, t, nowFn())
|
|
1805
|
+
readSubTail(entry, t, nowFn(), (desc) => {
|
|
1806
|
+
log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
|
|
1807
|
+
}, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
|
|
1808
|
+
maybySendStateTransition(agentId)
|
|
1809
|
+
})
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
log?.(`subagent-watcher: fs.watch failed for ${agentId}: ${(err as Error).message}`)
|
|
1812
|
+
}
|
|
1813
|
+
} else {
|
|
1814
|
+
log?.(`subagent-watcher: ${agentId} historical/terminal at registration — not opening an FSWatcher (no live transition to observe)`)
|
|
1797
1815
|
}
|
|
1798
1816
|
}
|
|
1799
1817
|
|
|
@@ -1855,7 +1873,17 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1855
1873
|
}
|
|
1856
1874
|
if (n >= pending.deadlineAt) {
|
|
1857
1875
|
entry.bootPromotionPending = undefined
|
|
1858
|
-
|
|
1876
|
+
// FD-leak fix (H2): the boot-promotion window elapsed with no growth,
|
|
1877
|
+
// so this entry is now a permanent historical/orphan that `checkStalls`
|
|
1878
|
+
// skips — it will never reach terminal cleanup. Release the FSWatcher we
|
|
1879
|
+
// opened purely for growth-confirmation now; the poll loop remains the
|
|
1880
|
+
// fallback reader if the file ever resumes (rescan re-registers on a
|
|
1881
|
+
// fresh transition).
|
|
1882
|
+
if (tail.watcher) {
|
|
1883
|
+
try { tail.watcher.close() } catch { /* ignore */ }
|
|
1884
|
+
tail.watcher = null
|
|
1885
|
+
}
|
|
1886
|
+
log?.(`subagent-watcher: ${entry.agentId} never observed post-boot JSONL growth within the window — leaving historical/orphan (not promoting; avoids synthesising a stale 'completed' handback from a worker killed before this restart); released growth-confirmation FSWatcher`)
|
|
1859
1887
|
}
|
|
1860
1888
|
}
|
|
1861
1889
|
|
|
@@ -2386,8 +2414,35 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2386
2414
|
* We walk: <agentDir>/.claude/projects/ → each project dir → each session dir
|
|
2387
2415
|
* → subagents/ → agent-*.jsonl
|
|
2388
2416
|
*/
|
|
2417
|
+
/**
|
|
2418
|
+
* FD-leak fix (H1): close and forget any directory FSWatcher whose watched
|
|
2419
|
+
* directory no longer exists on disk. Every new Claude session mints a fresh
|
|
2420
|
+
* `<sessionId>/subagents/` dir (and `workflows/wf_<id>` sub-dirs); when Claude
|
|
2421
|
+
* Code reaps a prior session's directory, the scan loop below simply skips
|
|
2422
|
+
* the vanished path (`existsSync` guard) WITHOUT closing the watcher it had
|
|
2423
|
+
* opened for it — Node only frees the inotify FD on `.close()`, so each dead
|
|
2424
|
+
* session leaked one dir watcher for the whole gateway lifetime. Sweeping on
|
|
2425
|
+
* every rescan releases them deterministically the tick after the dir goes
|
|
2426
|
+
* away, and also covers a slug that transitions to "foreign" (skipped by the
|
|
2427
|
+
* allow-list filter below). Deleting the current key while iterating a Map's
|
|
2428
|
+
* entries() is safe in JS.
|
|
2429
|
+
*/
|
|
2430
|
+
function pruneVanishedDirWatchers(): void {
|
|
2431
|
+
for (const [dirPath, w] of dirWatchers) {
|
|
2432
|
+
if (!fs.existsSync(dirPath)) {
|
|
2433
|
+
try { w.close() } catch { /* ignore */ }
|
|
2434
|
+
dirWatchers.delete(dirPath)
|
|
2435
|
+
log?.(`subagent-watcher: released dir watcher for vanished ${dirPath}`)
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2389
2440
|
function rescanSubagentDirs(): void {
|
|
2390
2441
|
if (stopped) return
|
|
2442
|
+
// Release watchers for directories reaped since the last tick BEFORE the
|
|
2443
|
+
// early-returns below, so a projectsRoot that vanishes entirely still frees
|
|
2444
|
+
// every child dir watcher.
|
|
2445
|
+
pruneVanishedDirWatchers()
|
|
2391
2446
|
const claudeHome = join(agentDir, '.claude')
|
|
2392
2447
|
const projectsRoot = join(claudeHome, 'projects')
|
|
2393
2448
|
if (!fs.existsSync(projectsRoot)) return
|
|
@@ -261,14 +261,27 @@ describe('gateway wiring — the leash', () => {
|
|
|
261
261
|
'utf8',
|
|
262
262
|
)
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* One anchor + one span for the TTL sweep, shared by every pin below.
|
|
266
|
+
*
|
|
267
|
+
* They drifted apart before: one pin still anchored on the inline `for` loop the
|
|
268
|
+
* shared-sweep extraction had deleted, so it silently matched nothing. A single
|
|
269
|
+
* definition means a future move breaks every pin at once — loudly — instead of
|
|
270
|
+
* quietly disarming one of them.
|
|
271
|
+
*
|
|
272
|
+
* The span must cover the whole `onExpire` body (~4.2k chars today).
|
|
273
|
+
*/
|
|
274
|
+
const SWEEP_ANCHOR = 'sweepPermissionTtl({'
|
|
275
|
+
const SWEEP_SPAN = 6000
|
|
276
|
+
|
|
264
277
|
it('the TTL sweep routes through the SHARED shouldExpirePermission()', () => {
|
|
265
278
|
// This pin is no longer the safety net — the behavioural tests above are, and
|
|
266
279
|
// they now drive the same `shouldExpirePermission` the gateway does, so deleting
|
|
267
280
|
// the leash turns them RED. This only guards the WIRING: that the sweep can't
|
|
268
281
|
// regrow a private inline check that drifts from what the test exercises.
|
|
269
|
-
const sweepStart = GATEWAY_SRC.indexOf(
|
|
282
|
+
const sweepStart = GATEWAY_SRC.indexOf(SWEEP_ANCHOR)
|
|
270
283
|
expect(sweepStart).toBeGreaterThan(-1)
|
|
271
|
-
const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart +
|
|
284
|
+
const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + SWEEP_SPAN)
|
|
272
285
|
expect(GATEWAY_SRC).toContain('sweepPermissionTtl({')
|
|
273
286
|
// …and must NOT carry its own copy of the leash or the raw TTL comparison.
|
|
274
287
|
// …and the gateway must not regrow a private inline leash check.
|
|
@@ -288,9 +301,27 @@ describe('gateway wiring — the leash', () => {
|
|
|
288
301
|
|
|
289
302
|
it('nothing in the permission path auto-approves', () => {
|
|
290
303
|
// Never, under any circumstance. A held approval that "times out" into an
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
304
|
+
// ALLOW would be the worst possible reading of Ken's call — the other half of
|
|
305
|
+
// no-self-escalation.
|
|
306
|
+
//
|
|
307
|
+
// This pin was DEAD. It anchored on `for (const [k, v] of pendingPermissions) {`,
|
|
308
|
+
// the inline loop that the shared-sweep extraction deleted. `indexOf` returned
|
|
309
|
+
// -1, `slice(-1, 2499)` returned the EMPTY STRING, and `not.toContain` passes on
|
|
310
|
+
// "" — so the sweep could be changed to dispatch `behavior: 'allow'` and all 13
|
|
311
|
+
// tests stayed green. Exactly the failure class this file exists to prevent,
|
|
312
|
+
// inside the file that exists to prevent it.
|
|
313
|
+
//
|
|
314
|
+
// Two rules now, and they apply to every grep pin here:
|
|
315
|
+
// 1. ASSERT THE ANCHOR RESOLVES. A silent -1 is what turns a pin into a lie.
|
|
316
|
+
// 2. Assert something POSITIVE inside the window, so a window that is too
|
|
317
|
+
// small (or empty) fails loudly instead of vacuously passing a `not`.
|
|
318
|
+
const sweepStart = GATEWAY_SRC.indexOf(SWEEP_ANCHOR)
|
|
319
|
+
expect(sweepStart).toBeGreaterThan(-1)
|
|
320
|
+
const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + SWEEP_SPAN)
|
|
321
|
+
|
|
322
|
+
// POSITIVE: proves the window actually covers the verdict dispatch.
|
|
323
|
+
expect(sweep).toContain("behavior: 'deny',")
|
|
324
|
+
// …and the sweep may only ever deny. Never allow.
|
|
294
325
|
expect(sweep).not.toContain("behavior: 'allow'")
|
|
295
326
|
})
|
|
296
327
|
})
|
|
@@ -41,6 +41,43 @@ import { createSweepableStore } from '../gateway/pending-state-stores.js'
|
|
|
41
41
|
import { StagingMap } from '../secret-detect/staging.js'
|
|
42
42
|
import { InlineKeyboard } from 'grammy'
|
|
43
43
|
|
|
44
|
+
// Mock the auth-broker client so the `auth:use:` swap path is observable
|
|
45
|
+
// (spy on setActive) without a live UDS broker. Only handleAuthDashboardCallback
|
|
46
|
+
// touches getAuthBrokerClient, so this is inert for every other family here.
|
|
47
|
+
//
|
|
48
|
+
// The spy is created *inside* the factory and re-exported as a test-only
|
|
49
|
+
// handle (`__setActiveSpy`) rather than closed over from a `vi.hoisted`
|
|
50
|
+
// binding: bun's vitest-compat layer implements `vi.mock`/`vi.fn` but NOT
|
|
51
|
+
// `vi.hoisted`, so a hoisted-closure mock throws `vi.hoisted is not a
|
|
52
|
+
// function` under `bun test` (CI's bun-test-run shard runs this whole dir).
|
|
53
|
+
// A single `setActive` instance is closed over and returned by every
|
|
54
|
+
// `getAuthBrokerClient()` call, so the handler and the tests observe the
|
|
55
|
+
// same spy.
|
|
56
|
+
vi.mock('../gateway/auth-broker-client.js', () => {
|
|
57
|
+
const setActive = vi.fn(async () => ({ active: 'acct-b', fanned: ['a1'] }))
|
|
58
|
+
return {
|
|
59
|
+
getAuthBrokerClient: vi.fn(async () => ({ setActive })),
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
import { getAuthBrokerClient } from '../gateway/auth-broker-client.js'
|
|
63
|
+
// The mocked getAuthBrokerClient closes over one shared `setActive` spy and
|
|
64
|
+
// returns it on every call, so resolving the client here yields the SAME spy
|
|
65
|
+
// the handler observes. A getter defers the read to test-run time (after the
|
|
66
|
+
// mock is live under both runners); a beforeEach caches the resolved spy.
|
|
67
|
+
let resolvedSetActiveSpy: ReturnType<typeof vi.fn> | undefined
|
|
68
|
+
const brokerMock = {
|
|
69
|
+
get setActive(): ReturnType<typeof vi.fn> {
|
|
70
|
+
if (!resolvedSetActiveSpy) throw new Error('setActive spy not resolved yet')
|
|
71
|
+
return resolvedSetActiveSpy
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
beforeEach(async () => {
|
|
75
|
+
const client = (await getAuthBrokerClient('test-agent')) as {
|
|
76
|
+
setActive: ReturnType<typeof vi.fn>
|
|
77
|
+
}
|
|
78
|
+
resolvedSetActiveSpy = client.setActive
|
|
79
|
+
})
|
|
80
|
+
|
|
44
81
|
// ── Fakes ────────────────────────────────────────────────────────────────
|
|
45
82
|
|
|
46
83
|
interface FakeCtxOpts {
|
|
@@ -568,6 +605,34 @@ describe('handleAuthDashboardCallback', () => {
|
|
|
568
605
|
show_alert: false,
|
|
569
606
|
})
|
|
570
607
|
})
|
|
608
|
+
|
|
609
|
+
// Security gate (found in an adversarial security review): the auth: family
|
|
610
|
+
// is a mutating callback family (auth:use:<label> → broker.setActive, a
|
|
611
|
+
// fleet-wide OAuth account swap) and MUST enforce the same allowFrom gate as
|
|
612
|
+
// every sibling family. Without the gate, any tapper on an admin forum/
|
|
613
|
+
// supergroup with an empty group allowFrom could swap the active account.
|
|
614
|
+
it('rejects an auth:use swap from a sender not on allowFrom (never reaches setActive)', async () => {
|
|
615
|
+
const { deps } = makeDeps() // allowFrom = ['111','222']
|
|
616
|
+
const h = createCallbackQueryHandlers(deps)
|
|
617
|
+
brokerMock.setActive.mockClear()
|
|
618
|
+
const { ctx, raw } = makeCtx({ senderId: '999', data: 'auth:use:acct-b' })
|
|
619
|
+
await h.handleAuthDashboardCallback(ctx)
|
|
620
|
+
expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
|
|
621
|
+
// The load-bearing assertion: the swap must not fire for an unauthorized tap.
|
|
622
|
+
expect(brokerMock.setActive).not.toHaveBeenCalled()
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('lets an allowFrom sender through to the setActive swap path', async () => {
|
|
626
|
+
const { deps } = makeDeps()
|
|
627
|
+
const h = createCallbackQueryHandlers(deps)
|
|
628
|
+
brokerMock.setActive.mockClear()
|
|
629
|
+
const { ctx, raw } = makeCtx({ senderId: '111', data: 'auth:use:acct-b' })
|
|
630
|
+
await h.handleAuthDashboardCallback(ctx)
|
|
631
|
+
expect(brokerMock.setActive).toHaveBeenCalledWith('acct-b')
|
|
632
|
+
expect(raw.answerCallbackQuery).toHaveBeenCalledWith(
|
|
633
|
+
expect.objectContaining({ text: expect.stringContaining('Switched fleet → acct-b') }),
|
|
634
|
+
)
|
|
635
|
+
})
|
|
571
636
|
})
|
|
572
637
|
|
|
573
638
|
// ── vg:* — grant wizard + management ────────────────────────────────────
|
|
@@ -77,6 +77,63 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
77
77
|
expect(truncIdx).toBeGreaterThan(redactIdx) // mask BEFORE the slice
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
it('ask_user: redacts question + option labels at entry, BEFORE the send', () => {
|
|
81
|
+
// F1 — ask_user sent the question + button labels unredacted. The scrub
|
|
82
|
+
// must run at the top of executeAskUser (via redactAskUserFields), before
|
|
83
|
+
// the keyboard is built and the question is sent to Telegram.
|
|
84
|
+
const start = src.indexOf('async function executeAskUser(')
|
|
85
|
+
const redactIdx = src.indexOf('redactAskUserFields(args.question, args.options', start)
|
|
86
|
+
const keyboardIdx = src.indexOf('const keyboard = new InlineKeyboard()', start)
|
|
87
|
+
const sendIdx = src.indexOf('sendRichMessage(args.chatId, richMessage(args.question)', start)
|
|
88
|
+
expect(start).toBeGreaterThan(0)
|
|
89
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
90
|
+
expect(keyboardIdx).toBeGreaterThan(redactIdx) // mask BEFORE labels become buttons
|
|
91
|
+
expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the question is sent
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('send_checklist: redacts title + task text BEFORE rawSendChecklist', () => {
|
|
95
|
+
// F2 — checklist title + task strings were sent unredacted.
|
|
96
|
+
const start = src.indexOf('async function executeSendChecklist(')
|
|
97
|
+
const redactIdx = src.indexOf('redactChecklistFields(', start)
|
|
98
|
+
const sendIdx = src.indexOf('rawSendChecklist({', start)
|
|
99
|
+
expect(start).toBeGreaterThan(0)
|
|
100
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
101
|
+
expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the send
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('update_checklist: redacts title + task text BEFORE rawEditMessageChecklist', () => {
|
|
105
|
+
// F2 sibling — update_checklist shares the identical leak class.
|
|
106
|
+
const start = src.indexOf('async function executeUpdateChecklist(')
|
|
107
|
+
const redactIdx = src.indexOf('redactChecklistFields(', start)
|
|
108
|
+
const editIdx = src.indexOf('rawEditMessageChecklist({', start)
|
|
109
|
+
expect(start).toBeGreaterThan(0)
|
|
110
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
111
|
+
expect(editIdx).toBeGreaterThan(redactIdx) // mask BEFORE the edit
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('pty draft-preview: redacts at the gateway boundary BEFORE the stream', () => {
|
|
115
|
+
// F3 — the PTY-tail partial (assistant reply text extracted from the TUI)
|
|
116
|
+
// forwards straight to the draft-preview stream. Mask at the top of
|
|
117
|
+
// handlePtyPartial, before it hands off to handlePtyPartialPure.
|
|
118
|
+
const start = src.indexOf('function handlePtyPartial(text: string): void {')
|
|
119
|
+
const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_preview')`, start)
|
|
120
|
+
const pureIdx = src.indexOf('handlePtyPartialPure(text, state', start)
|
|
121
|
+
expect(start).toBeGreaterThan(0)
|
|
122
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
123
|
+
expect(pureIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream push
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('pty activity: redacts at the gateway boundary BEFORE the stream', () => {
|
|
127
|
+
// F3 — the (currently-unwired) PTY-activity lane is masked too so the fix
|
|
128
|
+
// is durable if it is ever re-armed.
|
|
129
|
+
const start = src.indexOf('function handlePtyActivity(text: string): void {')
|
|
130
|
+
const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_activity')`, start)
|
|
131
|
+
const streamIdx = src.indexOf('handleStreamReply(', start)
|
|
132
|
+
expect(start).toBeGreaterThan(0)
|
|
133
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
134
|
+
expect(streamIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream
|
|
135
|
+
})
|
|
136
|
+
|
|
80
137
|
it('does not log the secret value when a mask fires', () => {
|
|
81
138
|
const idx = src.indexOf('function redactOutboundText(')
|
|
82
139
|
const body = src.slice(idx, idx + 400)
|