@wrongstack/tools 0.286.0 → 0.289.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/_edit-match.d.ts.map +1 -1
- package/dist/_fetch-guard.d.ts.map +1 -1
- package/dist/_redact-command.d.ts.map +1 -1
- package/dist/_spawn-stream.d.ts.map +1 -1
- package/dist/audit.js +61 -14
- package/dist/audit.js.map +3 -3
- package/dist/bash.d.ts +1 -1
- package/dist/bash.d.ts.map +1 -1
- package/dist/bash.js +97 -19
- package/dist/bash.js.map +3 -3
- package/dist/batch-tool-use.d.ts +1 -1
- package/dist/batch-tool-use.d.ts.map +1 -1
- package/dist/batch-tool-use.js +26 -8
- package/dist/batch-tool-use.js.map +2 -2
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/types.d.ts +1 -1
- package/dist/browser/types.d.ts.map +1 -1
- package/dist/builtin.d.ts +12 -10
- package/dist/builtin.d.ts.map +1 -1
- package/dist/builtin.js +2107 -864
- package/dist/builtin.js.map +4 -4
- package/dist/codebase-index/background-indexer.d.ts.map +1 -1
- package/dist/codebase-index/bm25.d.ts +1 -2
- package/dist/codebase-index/bm25.d.ts.map +1 -1
- package/dist/codebase-index/codebase-index-tool.d.ts.map +1 -1
- package/dist/codebase-index/codebase-search-tool.d.ts.map +1 -1
- package/dist/codebase-index/codebase-stats-tool.d.ts +7 -0
- package/dist/codebase-index/codebase-stats-tool.d.ts.map +1 -1
- package/dist/codebase-index/go-parser.d.ts.map +1 -1
- package/dist/codebase-index/index-service.d.ts +11 -1
- package/dist/codebase-index/index-service.d.ts.map +1 -1
- package/dist/codebase-index/index.d.ts +2 -1
- package/dist/codebase-index/index.d.ts.map +1 -1
- package/dist/codebase-index/index.js +934 -283
- package/dist/codebase-index/index.js.map +4 -4
- package/dist/codebase-index/indexer.d.ts.map +1 -1
- package/dist/codebase-index/py-parser.d.ts.map +1 -1
- package/dist/codebase-index/rs-parser.d.ts.map +1 -1
- package/dist/codebase-index/schema.d.ts +43 -1
- package/dist/codebase-index/schema.d.ts.map +1 -1
- package/dist/codebase-index/ts-parser.d.ts.map +1 -1
- package/dist/codebase-index/worker-protocol.d.ts +1 -0
- package/dist/codebase-index/worker-protocol.d.ts.map +1 -1
- package/dist/codebase-index/worker.js +793 -241
- package/dist/codebase-index/worker.js.map +4 -4
- package/dist/codebase-index/writer.d.ts +27 -1
- package/dist/codebase-index/writer.d.ts.map +1 -1
- package/dist/e2e.js +477 -0
- package/dist/e2e.js.map +7 -0
- package/dist/edit.d.ts.map +1 -1
- package/dist/edit.js +36 -12
- package/dist/edit.js.map +3 -3
- package/dist/exec.d.ts +1 -1
- package/dist/exec.d.ts.map +1 -1
- package/dist/exec.js +60 -5
- package/dist/exec.js.map +3 -3
- package/dist/fetch.js +2 -2
- package/dist/fetch.js.map +2 -2
- package/dist/format.js +61 -14
- package/dist/format.js.map +3 -3
- package/dist/glob.js +2 -2
- package/dist/glob.js.map +1 -1
- package/dist/grep.d.ts.map +1 -1
- package/dist/grep.js +2 -2
- package/dist/grep.js.map +2 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2535 -865
- package/dist/index.js.map +4 -4
- package/dist/install.js +61 -14
- package/dist/install.js.map +3 -3
- package/dist/kanban.d.ts +16 -2
- package/dist/kanban.d.ts.map +1 -1
- package/dist/kanban.js +795 -532
- package/dist/kanban.js.map +4 -4
- package/dist/languages/detect.d.ts.map +1 -1
- package/dist/languages/index.js +61 -14
- package/dist/languages/index.js.map +3 -3
- package/dist/languages/types.d.ts +7 -0
- package/dist/languages/types.d.ts.map +1 -1
- package/dist/lint.js +61 -14
- package/dist/lint.js.map +3 -3
- package/dist/next-steps.d.ts +6 -2
- package/dist/next-steps.d.ts.map +1 -1
- package/dist/next-steps.js +3 -3
- package/dist/next-steps.js.map +2 -2
- package/dist/outdated.js +61 -14
- package/dist/outdated.js.map +3 -3
- package/dist/pack.js +2104 -861
- package/dist/pack.js.map +4 -4
- package/dist/plan.d.ts.map +1 -1
- package/dist/plan.js +158 -4
- package/dist/plan.js.map +4 -4
- package/dist/process-registry.js +12 -5
- package/dist/process-registry.js.map +2 -2
- package/dist/search.js +2 -2
- package/dist/search.js.map +2 -2
- package/dist/session-kanban.d.ts +47 -0
- package/dist/session-kanban.d.ts.map +1 -0
- package/dist/session-kanban.js +626 -0
- package/dist/session-kanban.js.map +7 -0
- package/dist/task.d.ts.map +1 -1
- package/dist/task.js +167 -4
- package/dist/task.js.map +4 -4
- package/dist/test.js +61 -14
- package/dist/test.js.map +3 -3
- package/dist/todo.d.ts.map +1 -1
- package/dist/todo.js +159 -7
- package/dist/todo.js.map +4 -4
- package/dist/tool-icon-map.d.ts.map +1 -1
- package/dist/tool-icons.d.ts.map +1 -1
- package/dist/tool-icons.js +7 -1
- package/dist/tool-icons.js.map +2 -2
- package/dist/tree.js +2 -2
- package/dist/tree.js.map +1 -1
- package/dist/typecheck.js +61 -14
- package/dist/typecheck.js.map +3 -3
- package/package.json +11 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/process-registry.ts", "../src/circuit-breaker.ts", "../src/_redact-command.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * ProcessRegistry \u2014 global singleton that tracks all spawned child processes\n * from `bash` and `exec` tools. Enables:\n *\n * - Listing active processes (for TUI status bar)\n * - Killing individual processes or all processes (for Ctrl+C and /kill)\n * - Detecting runaway processes (hung, looping)\n * - Circuit breaker integration to prevent recursive/repeated failures\n *\n * Thread-safety: Node.js is single-threaded, but async callbacks can fire\n * in any order. All mutations go through synchronized Map methods.\n */\nimport { spawn } from 'node:child_process';\nimport type { ChildProcess } from 'node:child_process';\nimport * as os from 'node:os';\nimport { CircuitBreaker, type CircuitBreakerSnapshot, type CircuitBreakerConfig } from './circuit-breaker.js';\nexport type { CircuitBreakerSnapshot, CircuitBreakerConfig } from './circuit-breaker.js';\n\nexport interface TrackedProcess {\n pid: number;\n name: string;\n /** Display-safe redacted command string \u2014 safe for logs, /ps, crash dumps.\n * Contains [REDACTED] in place of sensitive flag values. */\n command: string;\n startedAt: number;\n sessionId?: string | undefined;\n /** The raw ChildProcess handle. Never call .kill() directly on this \u2014\n * use `kill()` below which handles process groups correctly on POSIX\n * and degrades gracefully on Windows. */\n child: ChildProcess;\n /** True only when this child was spawned as a POSIX process-group/session\n * leader (for example `spawn(..., { detached: true })`) and `pid` is the\n * actual `child.pid`. Negative-PID signaling is host-wide dangerous for\n * values like -1, so tests and manually registered entries must not opt in. */\n processGroupLeader?: boolean | undefined;\n /** True once the process has been kill()ed but not yet exited.\n * We keep it in the registry until 'close' fires so callers can\n * distinguish \"still running\" from \"just exited\". */\n killed: boolean;\n /** If true, kill() and killAll() will refuse to kill this process.\n * Used for infrastructure processes (browser, dev servers, \u2026) that\n * must outlive the agent session. */\n protected: boolean;\n}\n\n// redactCommand (and its sensitive-flag patterns) lives in _redact-command.ts\n// so registry-only consumers (e.g. ps-slash) don't carry its dependencies.\n// Re-exported here to keep this module's historical public API intact.\nexport { redactCommand } from './_redact-command.js';\n\ninterface KillOpts {\n /** SIGKILL instead of SIGTERM. Default: false (SIGTERM first). */\n force?: boolean | undefined;\n /** MS to wait between SIGTERM and SIGKILL on POSIX. Default: 2000. */\n graceMs?: number | undefined;\n}\n\n/**\n * Snapshot of the armed auto kill/reset countdown, or null when nothing is\n * armed. `remainingMs` ticks down in real time; the TUI statusline renders it.\n */\nexport interface BreakerCountdown {\n remainingMs: number;\n totalMs: number;\n}\n\ntype BreakerCountdownListener = (snapshot: BreakerCountdown | null) => void;\n\nexport interface RegistryStats {\n activeCount: number;\n totalCount: number;\n breaker: CircuitBreakerSnapshot;\n}\n\nconst DEFAULT_GRACE_MS = 2000;\nconst WIN32_TASKKILL_TIMEOUT_MS = 5000;\n\ninterface Win32TreeKillOptions {\n /**\n * Upper bound for taskkill itself before the caller's fallback may run.\n * This is deliberately separate from POSIX SIGTERM grace: on Windows the\n * direct-child fallback must not fire while taskkill is still walking the\n * child tree, or it can orphan grandchildren that keep stdio open.\n */\n timeoutMs?: number | undefined;\n onSettled?: (() => void) | undefined;\n}\n\n/**\n * Kill an entire process tree on Windows via `taskkill /T /F`.\n *\n * TerminateProcess (what `child.kill()` maps to) has no process-group\n * semantics, so killing a shell wrapper (`cmd.exe /c \u2026`) orphans its\n * grandchildren (node, vitest forks, dev servers). The orphans inherit the\n * parent's stdio pipe handles and can keep streaming into this process for\n * the rest of the session \u2014 which both prevents the child's 'close' event\n * from ever firing and grows in-memory output buffers without bound.\n *\n * Returns true if taskkill was spawned, false if spawning it failed (caller\n * should fall back to a direct `child.kill()`). Callers that need a direct\n * fallback should pass `onSettled`; it runs after taskkill exits, errors, or\n * exceeds `timeoutMs`, avoiding the race where killing cmd.exe first prevents\n * taskkill from enumerating and killing grandchildren.\n */\nexport function killWin32Tree(pid: number, opts: Win32TreeKillOptions = {}): boolean {\n try {\n const child = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], {\n stdio: 'ignore',\n windowsHide: true,\n });\n let settled = false;\n let timeout: ReturnType<typeof setTimeout> | undefined;\n const settle = () => {\n if (settled) return;\n settled = true;\n if (timeout) clearTimeout(timeout);\n try {\n opts.onSettled?.();\n } catch {\n /* fallback callbacks are best-effort */\n }\n };\n // spawn() reports a failure to launch (e.g. taskkill not on PATH, blocked by\n // security software) via an ASYNC 'error' event \u2014 the surrounding try/catch\n // only traps synchronous throws. Without a listener that event is unhandled\n // and crashes the whole process. Swallow it: this is best-effort tree-kill\n // and the registry still has the direct child.kill() fallback.\n child.on('error', settle);\n child.on('close', settle);\n timeout = setTimeout(() => {\n try {\n child.kill();\n } catch {\n /* already exited */\n }\n settle();\n }, Math.max(1, opts.timeoutMs ?? WIN32_TASKKILL_TIMEOUT_MS));\n timeout.unref?.();\n child.unref();\n return true;\n } catch {\n return false;\n }\n}\n\nexport class ProcessRegistryImpl {\n private readonly processes = new Map<number, TrackedProcess>();\n private readonly breaker: CircuitBreaker;\n\n /**\n * Auto kill/reset config. When the breaker trips and `autoKillResetMs > 0`,\n * a countdown is armed; on expiry all tracked processes are killed and the\n * breaker is reset to closed (forced recovery). Zero means manual recovery\n * only (`/kill reset`).\n */\n private autoKillResetMs = 0;\n private autoKillTimer: ReturnType<typeof setTimeout> | null = null;\n private autoKillArmedAt: number | null = null;\n private breakerCountdownListeners: BreakerCountdownListener[] = [];\n\n constructor(breakerConfig?: CircuitBreakerConfig) {\n this.breaker = new CircuitBreaker(breakerConfig);\n // Arm on trip, cancel on recovery. Listeners are best-effort.\n this.breaker.onTrip = () => this._armAutoKillReset();\n this.breaker.onReset = () => this._cancelAutoKillReset();\n // Protection is OFF by default \u2014 the user opts in via `/settings breaker on`.\n this.breaker.setEnabled(false);\n }\n\n register(info: Omit<TrackedProcess, 'killed' | 'protected'> & { protected?: boolean | undefined }): void {\n this.processes.set(info.pid, { ...info, killed: false, protected: info.protected ?? false });\n }\n\n private _isSafeSignalPid(pid: number): boolean {\n return Number.isInteger(pid) && pid > 1 && pid !== process.pid && pid !== process.ppid;\n }\n\n private _canSignalProcessGroup(p: TrackedProcess): boolean {\n return (\n os.platform() !== 'win32' &&\n p.processGroupLeader === true &&\n this._isSafeSignalPid(p.pid) &&\n typeof p.child.pid === 'number' &&\n p.child.pid === p.pid\n );\n }\n\n private _killChildDirect(p: TrackedProcess, signal: NodeJS.Signals): void {\n try {\n p.child.kill(signal);\n } catch {\n // Process may have already exited, or this may be a persistent entry\n // without a live ChildProcess handle in the current process.\n }\n }\n\n private _killPosix(p: TrackedProcess, signal: NodeJS.Signals): void {\n if (this._canSignalProcessGroup(p)) {\n try {\n process.kill(-p.pid, signal);\n return;\n } catch {\n // Process group may already be gone; fall back to the direct child.\n }\n }\n this._killChildDirect(p, signal);\n }\n\n /** Unregister a process by PID. Called on 'close' / 'exit' events. */\n unregister(pid: number): void {\n this.processes.delete(pid);\n }\n\n /** Get a single process by PID. */\n get(pid: number): TrackedProcess | undefined {\n this._pruneStale(pid);\n return this.processes.get(pid);\n }\n\n /** Get all tracked processes. */\n list(): TrackedProcess[] {\n return Array.from(this.processes.values());\n }\n\n /** Get processes filtered by name (e.g. 'bash', 'exec'). */\n byName(name: string): TrackedProcess[] {\n return this.list().filter((p) => p.name === name);\n }\n\n /** Get processes filtered by session. */\n bySession(sessionId: string): TrackedProcess[] {\n return this.list().filter((p) => p.sessionId === sessionId);\n }\n\n /** Count of active (non-killed) processes. */\n get activeCount(): number {\n let n = 0;\n for (const p of this.processes.values()) {\n if (!p.killed) n++;\n }\n return n;\n }\n\n /**\n * Combined stats for observability \u2014 used by /ps and the TUI status bar.\n */\n stats(): RegistryStats {\n return {\n activeCount: this.activeCount,\n totalCount: this.processes.size,\n breaker: this.breaker.snapshot(),\n };\n }\n\n /**\n * Returns true if the circuit allows a new bash/exec call to proceed.\n * When false, callers MUST NOT spawn a process.\n */\n get canProceed(): boolean {\n return this.breaker.canProceed;\n }\n\n /**\n * Called before spawning a process. Returns true if allowed; false if\n * the circuit breaker is open.\n *\n * @param bypass - If true, skip circuit breaker check (for background processes).\n */\n beforeCall(bypass = false): boolean {\n return this.breaker.beforeCall(bypass);\n }\n\n /**\n * Called after a process finishes. `durationMs` is wall-clock time;\n * `failed` is true for non-zero exit codes.\n *\n * @param bypass - If true, do not update circuit breaker state (for background processes).\n */\n afterCall(durationMs: number, failed: boolean, bypass = false): void {\n this.breaker.afterCall(durationMs, failed, bypass);\n }\n\n /** Force-open the circuit breaker (Ctrl+C, /kill force). */\n forceBreakerOpen(): void {\n this.breaker.forceOpen();\n }\n\n /** Force-reset the circuit breaker to closed (/kill reset). */\n forceBreakerReset(): void {\n this.breaker.forceReset();\n }\n\n /**\n * Configure circuit-breaker protection at runtime. Called from `/settings`\n * (instant, all modes) and on TUI mount (applies persisted config).\n *\n * - `enabled` toggles whether the breaker gates `bash`/`exec`.\n * - `autoKillResetMs` arms the auto kill/reset countdown when the breaker\n * trips (0 = manual recovery only).\n *\n * Re-applies cleanly on every call: cancels a pending countdown when the\n * timeout is cleared or protection disabled, and re-arms if the breaker is\n * currently open under the new settings.\n */\n setBreakerConfig(cfg: { enabled?: boolean | undefined; autoKillResetMs?: number | undefined }): void {\n if (cfg.enabled !== undefined) this.breaker.setEnabled(cfg.enabled);\n if (cfg.autoKillResetMs !== undefined) this.autoKillResetMs = Math.max(0, cfg.autoKillResetMs);\n\n if (this.autoKillResetMs <= 0) {\n this._cancelAutoKillReset();\n return;\n }\n // If protection is active and the breaker is currently tripped, ensure a\n // countdown is armed for the new window (covers a live config change while\n // the breaker is already open).\n if (this.breaker.isEnabled && this.breaker.snapshot().state === 'open') {\n this._armAutoKillReset();\n }\n }\n\n /**\n * Live countdown to the next auto kill/reset, or null when nothing is armed.\n * The TUI polls this on a 1s tick while armed so the statusline decrements.\n */\n getBreakerCountdown(): BreakerCountdown | null {\n if (this.autoKillArmedAt === null || this.autoKillResetMs <= 0) return null;\n const elapsed = Date.now() - this.autoKillArmedAt;\n return { remainingMs: Math.max(0, this.autoKillResetMs - elapsed), totalMs: this.autoKillResetMs };\n }\n\n /**\n * Subscribe to countdown arm/cancel events. Returns an unsubscribe function.\n * Use {@link getBreakerCountdown} for the live ticking value between events.\n */\n onBreakerCountdownChange(listener: BreakerCountdownListener): () => void {\n this.breakerCountdownListeners.push(listener);\n return () => {\n this.breakerCountdownListeners = this.breakerCountdownListeners.filter((l) => l !== listener);\n };\n }\n\n private _emitBreakerCountdown(): void {\n const snap = this.getBreakerCountdown();\n for (const l of this.breakerCountdownListeners) {\n try {\n l(snap);\n } catch {\n /* listener failure must never affect breaker behavior */\n }\n }\n }\n\n /**\n * Arm the auto kill/reset countdown. Idempotent: re-arming resets the window\n * (a fresh trip after a failed half-open probe restarts the clock). No-op\n * when protection is off or no timeout is configured.\n */\n private _armAutoKillReset(): void {\n if (this.autoKillResetMs <= 0 || !this.breaker.isEnabled) return;\n this._clearAutoKillTimer();\n this.autoKillArmedAt = Date.now();\n this.autoKillTimer = setTimeout(() => {\n this.autoKillTimer = null;\n this.autoKillArmedAt = null;\n // Forced recovery: nuke runaway processes and reopen the circuit.\n this.killAll({ force: false });\n this.breaker.forceReset();\n this._emitBreakerCountdown();\n }, this.autoKillResetMs);\n // Don't keep the event loop alive purely for auto-recovery.\n this.autoKillTimer.unref?.();\n this._emitBreakerCountdown();\n }\n\n private _cancelAutoKillReset(): void {\n const wasArmed = this.autoKillArmedAt !== null;\n this._clearAutoKillTimer();\n if (wasArmed) {\n this.autoKillArmedAt = null;\n this._emitBreakerCountdown();\n }\n }\n\n private _clearAutoKillTimer(): void {\n if (this.autoKillTimer !== null) {\n clearTimeout(this.autoKillTimer);\n this.autoKillTimer = null;\n }\n }\n\n /** Kill a single process by PID.\n *\n * On POSIX: sends SIGTERM to the *process group* (-pid) so that\n * runaway grandchild processes (`sleep 9999 & disown`) are also killed.\n * After `graceMs` a SIGKILL is sent if the process hasn't exited.\n *\n * On Windows: `child.kill()` maps to TerminateProcess \u2014 process groups\n * are not meaningfully supported. A second `force=true` call sends\n * SIGKILL (which maps to TerminateProcess again \u2014 the distinction is\n * in the exit code, not the signal).\n *\n * Returns true if the process was found and kill was attempted.\n */\n kill(pid: number, opts: KillOpts = {}): boolean {\n this._pruneStale(pid);\n const p = this.processes.get(pid);\n if (!p) return false;\n if (p.killed) return true; // already kill()ed, don't double-send\n if (p.protected) return false; // protected processes are never kill()ed\n\n const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;\n const isWin = os.platform() === 'win32';\n\n if (isWin) {\n // Windows: no process group semantics. A direct kill terminates only\n // the immediate child \u2014 shell-wrapped commands (cmd.exe /c \u2026) leave\n // grandchildren running that hold the inherited stdio pipes open and\n // keep feeding output into this process indefinitely. Kill the whole\n // tree via taskkill instead, but only for a real, still-running child\n // (exitCode === null); test fakes and already-exited processes take\n // the plain-kill path. The direct kill is deliberately NOT sent\n // immediately alongside taskkill: killing the root first would break\n // taskkill's parent-pid tree enumeration and orphan the grandchildren\n // again \u2014 it runs as a delayed fallback instead.\n const liveRealChild = p.child.exitCode === null && typeof p.child.pid === 'number';\n const directFallback = () => {\n if (p.child.exitCode === null) {\n try {\n p.child.kill('SIGKILL');\n } catch {\n // Process may have already exited.\n }\n }\n };\n if (\n liveRealChild &&\n killWin32Tree(pid, {\n timeoutMs: Math.max(graceMs, WIN32_TASKKILL_TIMEOUT_MS),\n onSettled: directFallback,\n })\n ) {\n // The direct fallback is intentionally chained from taskkill's\n // completion. Killing cmd.exe before taskkill has walked the tree can\n // orphan the real command and leave stdio pipes open forever.\n } else {\n try {\n p.child.kill(force ? 'SIGKILL' : 'SIGTERM');\n } catch {\n // Process may have already exited.\n }\n }\n p.killed = true;\n return true;\n }\n\n // POSIX: kill the process group only when the tracked child is proven to\n // be the group leader. Otherwise use child.kill(); negative PID signaling\n // with untrusted/fake PIDs can target unrelated host processes.\n try {\n if (force) {\n this._killPosix(p, 'SIGKILL');\n } else {\n this._killPosix(p, 'SIGTERM');\n // Schedule SIGKILL as backup.\n const timer = setTimeout(() => {\n // Re-check: process may have exited on its own.\n if (this.processes.has(pid) && !p.child.killed) {\n this._killPosix(p, 'SIGKILL');\n }\n }, graceMs);\n timer.unref?.(); // Don't keep event loop alive.\n }\n } catch {\n // Process may have already exited.\n }\n p.killed = true;\n return true;\n }\n\n /**\n * Kill all tracked processes.\n * Returns the PIDs that were kill()ed.\n */\n killAll(opts: KillOpts = {}): number[] {\n const pids = Array.from(this.processes.keys());\n const killed: number[] = [];\n for (const pid of pids) {\n const p = this.processes.get(pid);\n if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);\n }\n return killed;\n }\n\n /**\n * Kill all processes for a specific session.\n * Returns the PIDs that were kill()ed.\n */\n killSession(sessionId: string, opts: KillOpts = {}): number[] {\n const pids = this.bySession(sessionId).map((p) => p.pid);\n const killed: number[] = [];\n for (const pid of pids) {\n if (this.kill(pid, opts)) killed.push(pid);\n }\n return killed;\n }\n\n /**\n * Check whether a tracked process entry is stale \u2014 the child has exited\n * (exitCode !== null) AND it's been in the registry long enough that the\n * OS may have reused the PID for a new, unrelated process.\n *\n * P3 #24 (before-release.md): on POSIX, PIDs are reused after process\n * exit. If a tracked process exits but its 'close' event hasn't fired yet\n * (or was missed), the registry still holds the entry. A new process\n * gets the same PID, and PID-based lookups (get, kill, shouldBlockKill)\n * may incorrectly protect or target the wrong process.\n *\n * The 60s threshold is conservative \u2014 the OS typically waits much longer\n * before reusing a PID, but we want to clean up before that becomes a risk.\n */\n private _isStaleEntry(entry: TrackedProcess): boolean {\n return entry.child.exitCode !== null && Date.now() - entry.startedAt > 60_000;\n }\n\n /**\n * Remove a stale entry for a specific PID before any PID-based lookup.\n * This prevents PID reuse from causing the registry to act on a dead\n * process that has been replaced by a new one with the same PID.\n */\n private _pruneStale(pid: number): void {\n const entry = this.processes.get(pid);\n if (entry && this._isStaleEntry(entry)) {\n this.processes.delete(pid);\n }\n }\n}\n\n/** Module-level singleton. Initialized on first access. */\nlet _registry: ProcessRegistryImpl | undefined;\n\nexport function getProcessRegistry(): ProcessRegistryImpl {\n if (!_registry) {\n _registry = new ProcessRegistryImpl();\n }\n return _registry;\n}\n\n/** Reset for tests. */\nexport function _resetProcessRegistry(): void {\n _registry = undefined;\n}\n\n// \u2500\u2500 Convenience re-exports \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport type { KillOpts };\n", "/**\n * CircuitBreaker \u2014 prevents runaway bash/exec tool chains by:\n *\n * - Tripping on consecutive failures (models that keep repeating the\n * same failing command, e.g. `npm install` with wrong args in a loop)\n * - Tripping on slow call ratio (too many long-running commands suggest\n * a hung subprocess that the model doesn't know how to kill)\n * - Rate-limiting bursts (rapid succession of commands without reading\n * output suggests the model isn't processing results)\n * - Auto-recovering after a cooldown period so a fixed model can resume\n *\n * The breaker is owned by the ProcessRegistry so any tool that registers\n * a process participates in the same circuit. \"Per-tool\" isolation is\n * intentionally NOT implemented \u2014 the model treats bash/exec as one\n * resource pool; isolating them would let the model route around the\n * breaker by alternating which tool it uses.\n */\n\nexport interface CircuitBreakerConfig {\n /**\n * Consecutive failures before trip. Default: 5.\n * A single success resets this counter to 0.\n */\n maxConsecutiveFailures?: number | undefined;\n /**\n * Slow-call threshold in ms. A call that runs longer than this is\n * counted as \"slow\". Default: 60_000 (1 minute).\n */\n slowCallThresholdMs?: number | undefined;\n /**\n * Max slow calls before trip (within the sliding window). Default: 3.\n */\n maxSlowCalls?: number | undefined;\n /**\n * Sliding window for rate-limit and slow-call counting, in ms.\n * Default: 60_000 (1 minute).\n */\n windowMs?: number | undefined;\n /**\n * Max calls within the sliding window. Default: 30.\n * Burst exceeding this trips the breaker immediately.\n */\n maxCallsPerWindow?: number | undefined;\n /**\n * Cooldown before auto-recovery attempt, in ms. Default: 30_000 (30s).\n * After this the breaker enters \"half-open\" state and allows one call\n * through to test whether the problem is resolved.\n */\n cooldownMs?: number | undefined;\n}\n\ninterface CallRecord {\n at: number;\n /** True if the call threw or returned an is_error result. */\n failed: boolean;\n /** True if elapsed time exceeded slowCallThresholdMs. */\n slow: boolean;\n}\n\ntype BreakerState = 'closed' | 'open' | 'half-open';\n\nconst DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;\nconst DEFAULT_SLOW_CALL_THRESHOLD_MS = 180_000;\n// 3 minutes \u2014 balanced against the 5-minute bash timeout. Commands\n// running <3min are normal; 3-5min are \"slow\" and count toward the\n// breaker. 3 consecutive slow calls trip the circuit.\nconst DEFAULT_MAX_SLOW_CALLS = 3;\nconst DEFAULT_WINDOW_MS = 60_000;\nconst DEFAULT_MAX_CALLS_PER_WINDOW = 30;\nconst DEFAULT_COOLDOWN_MS = 30_000;\n\nexport interface CircuitBreakerSnapshot {\n state: 'closed' | 'open' | 'half-open';\n consecutiveFailures: number;\n slowCallsInWindow: number;\n callsInWindow: number;\n windowMs: number;\n cooldownRemainingMs: number | null;\n lastFailureAt: number | null;\n lastSlowAt: number | null;\n}\n\nexport class CircuitBreaker {\n private readonly maxConsecutiveFailures: number;\n private readonly slowCallThresholdMs: number;\n private readonly maxSlowCalls: number;\n private readonly windowMs: number;\n private readonly maxCallsPerWindow: number;\n private readonly cooldownMs: number;\n\n private state: BreakerState = 'closed';\n private consecutiveFailures = 0;\n private window: CallRecord[] = [];\n private lastFailureAt: number | null = null;\n private lastSlowAt: number | null = null;\n /** Timestamp when the breaker was opened (for cooldown calculation). */\n private openedAt: number | null = null;\n\n /**\n * Master enable flag. When false the breaker is bypassed: `beforeCall`\n * always returns true and `afterCall` records nothing. The class itself\n * defaults to enabled (so the standalone unit tests exercise tripping); the\n * ProcessRegistry flips this off until the user opts in via `/settings`.\n */\n private enabled = true;\n\n /**\n * Fired (best-effort) when the breaker transitions into the `open` state.\n * The registry uses this to arm its auto kill/reset countdown.\n */\n onTrip?: (() => void) | undefined;\n /**\n * Fired (best-effort) when the breaker returns to `closed` after having been\n * open/half-open. The registry uses this to cancel a pending kill/reset.\n */\n onReset?: (() => void) | undefined;\n\n constructor(config: CircuitBreakerConfig = {}) {\n this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;\n this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;\n this.maxSlowCalls = config.maxSlowCalls ?? DEFAULT_MAX_SLOW_CALLS;\n this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;\n this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;\n this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;\n }\n\n /** Toggle the master enable. Disabling resets to a clean `closed` state. */\n setEnabled(enabled: boolean): void {\n if (this.enabled === enabled) return;\n this.enabled = enabled;\n if (!enabled) this._reset();\n }\n\n get isEnabled(): boolean {\n return this.enabled;\n }\n\n /**\n * Returns true if the circuit allows a new call to proceed.\n * When false, callers should abort the tool call and return a\n * circuit-breaker error instead of spawning a process.\n */\n get canProceed(): boolean {\n if (!this.enabled) return true;\n this._checkStateTransition();\n return this.state !== 'open';\n }\n\n /**\n * Snapshot of the current breaker state for observability (`/kill`).\n */\n snapshot(): CircuitBreakerSnapshot {\n this._checkStateTransition();\n const now = Date.now();\n let cooldownRemaining: number | null = null;\n if (this.openedAt !== null && this.state === 'open') {\n const elapsed = now - this.openedAt;\n cooldownRemaining = Math.max(0, this.cooldownMs - elapsed);\n }\n return {\n state: this.state,\n consecutiveFailures: this.consecutiveFailures,\n slowCallsInWindow: this.window.filter((c) => c.slow).length,\n callsInWindow: this.window.length,\n windowMs: this.windowMs,\n cooldownRemainingMs: cooldownRemaining,\n lastFailureAt: this.lastFailureAt,\n lastSlowAt: this.lastSlowAt,\n };\n }\n\n /**\n * Call this BEFORE spawning a bash/exec process.\n * Returns true if the call is allowed; false if the breaker is open.\n * When false, callers MUST NOT spawn a process.\n *\n * @param bypass - If true, skip the circuit breaker check entirely.\n * Use for background/fire-and-forget processes that should\n * not affect breaker state.\n */\n beforeCall(bypass = false): boolean {\n if (bypass || !this.enabled) return true;\n this._checkStateTransition();\n if (this.state === 'open') return false;\n return true;\n }\n\n /**\n * Call this AFTER a bash/exec process finishes (success or failure).\n * `durationMs` is the wall-clock time the process ran.\n * `failed` is true when the process returned a non-zero exit code or\n * threw an exception before spawning.\n *\n * @param bypass - If true, do not update breaker state.\n * Use for background/fire-and-forget processes.\n */\n afterCall(durationMs: number, failed: boolean, bypass = false): void {\n if (bypass || !this.enabled) return;\n\n const now = Date.now();\n\n if (this.state === 'half-open') {\n // First call through after cooldown \u2014 if it failed, go back to open.\n if (failed) {\n this._trip();\n return;\n }\n // Success in half-open \u2192 reset to closed.\n this._reset();\n return;\n }\n\n // Prune old records outside the sliding window.\n this._pruneWindow(now);\n\n const slow = durationMs >= this.slowCallThresholdMs;\n this.window.push({ at: now, failed, slow });\n\n if (failed) {\n this.consecutiveFailures++;\n this.lastFailureAt = now;\n if (this.consecutiveFailures >= this.maxConsecutiveFailures) {\n this._trip();\n }\n return;\n }\n\n // Success: reset consecutive failure counter.\n this.consecutiveFailures = 0;\n\n if (slow) {\n this.lastSlowAt = now;\n const slowCount = this.window.filter((c) => c.slow).length;\n if (slowCount >= this.maxSlowCalls) {\n this._trip();\n }\n }\n\n const callCount = this.window.length;\n if (callCount >= this.maxCallsPerWindow) {\n // Rate limit exceeded. This is a soft trip \u2014 we reset the window\n // and let the next call try immediately (the caller will still see\n // canProceed=false until the window drains naturally).\n this._trip();\n }\n }\n\n /** Force the breaker open. Used by /kill force and Ctrl+C. */\n forceOpen(): void {\n this._trip();\n }\n\n /** Force a reset to closed. Used by tests and /kill reset. */\n forceReset(): void {\n this._reset();\n }\n\n private _trip(): void {\n if (this.state === 'open') return; // already open\n this.state = 'open';\n this.openedAt = Date.now();\n // P3 #23 (before-release.md): clear the window on trip. Old records are\n // irrelevant once tripped \u2014 the breaker starts fresh after cooldown\n // (half-open \u2192 closed resets the counters). Without this the window array\n // holds onto CallRecord entries for its lifetime if no new afterCall()\n // arrives (which is the case when the breaker stays open and no new calls\n // are attempted).\n this.window = [];\n // Best-effort: never let a listener failure corrupt breaker state.\n try {\n this.onTrip?.();\n } catch {\n /* ignored \u2014 observability hook only */\n }\n }\n\n private _reset(): void {\n const wasRecovering = this.state !== 'closed';\n this.state = 'closed';\n this.consecutiveFailures = 0;\n this.window = [];\n this.openedAt = null;\n // Only notify on a real recovery (open/half-open \u2192 closed), not on the\n // initial closed state or an idempotent re-reset.\n if (wasRecovering) {\n try {\n this.onReset?.();\n } catch {\n /* ignored \u2014 observability hook only */\n }\n }\n }\n\n /** Transition from open \u2192 half-open when cooldown elapses. */\n private _checkStateTransition(): void {\n if (this.state !== 'open' || this.openedAt === null) return;\n const elapsed = Date.now() - this.openedAt;\n if (elapsed >= this.cooldownMs) {\n this.state = 'half-open';\n this.openedAt = null;\n }\n }\n\n private _pruneWindow(now: number): void {\n const cutoff = now - this.windowMs;\n this.window = this.window.filter((c) => c.at >= cutoff);\n }\n}", "import { expectDefined } from '@wrongstack/core';\n\n// Sensitive CLI flag patterns that may appear in process command lines.\n// Redacted to [REDACTED] so crash dumps /ps output cannot leak secrets.\n// Split out of process-registry.ts so entries that only need the registry\n// (e.g. ps-slash) don't carry this module's dependencies.\nconst SENSITIVE_FLAG_PATTERNS: RegExp[] = [\n // --flag=value or --flag \"value\" (value captured up to next space or comma)\n /--(?:token|password|passwd|pwd|secret|api[-_]?key|api[-_]?secret|auth|credential|private[-_]?key|access[-_]?key|github[-_]?token|gh[-_]?token|bearer|jwt|oauth|pin|pincode|passphrase|access[-_]?token)(?:[=\\s,][^\\s]*)?/gi,\n // -f \"value\" style short flags\n /(?<!\\w)-t(?:\\s+|\\s*=\\s*)[^\\s,]+/,\n /(?<!\\w)-(?:p|password)(?:\\s+|\\s*=\\s*)[^\\s,]+/gi,\n // env var\u2013style secrets: TOKEN=x, API_KEY=y, etc.\n /(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD)\\s*[=:]\\s*[^\\s,]+/gi,\n // Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits \u2014 but only\n // when preceded by a flag name (e.g. --github-token=EyJ...).\n /--\\w*(?:token|key|secret|password|passwd|auth|credential)\\w*[=\\s,][A-Za-z0-9+/=]{32,}/,\n];\n\n/**\n * Returns a display-safe copy of `cmd` with sensitive flag values replaced by [REDACTED].\n * The original string is unchanged; this is pure and has no side effects.\n */\nexport function redactCommand(cmd: string): string {\n let result = cmd;\n for (const pattern of SENSITIVE_FLAG_PATTERNS) {\n result = result.replace(pattern, (match) => {\n // Preserve the flag name portion; redact only the value part.\n // e.g. \"--token=sekrit_abc\" \u2192 \"--token=[REDACTED]\"\n const eq = match.indexOf('=');\n const sp = match.search(/\\s/);\n const delim = eq !== -1 ? '=' : sp !== -1 ? match[sp] : null;\n if (delim !== null) {\n const flag = match.slice(0, match.indexOf(expectDefined(delim)) + 1);\n return `${flag}[REDACTED]`;\n }\n // Nothing delimitable found; replace the whole token silently.\n // Short flags like -tVALUE are replaced entirely to avoid edge cases.\n const flagEnd = match.match(/^--?[a-zA-Z][a-zA-Z0-9_-]*/)?.[0] ?? match;\n return `${flagEnd}=**redacted**`;\n });\n }\n return result;\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["/**\n * ProcessRegistry \u2014 global singleton that tracks all spawned child processes\n * from `bash` and `exec` tools. Enables:\n *\n * - Listing active processes (for TUI status bar)\n * - Killing individual processes or all processes (for Ctrl+C and /kill)\n * - Detecting runaway processes (hung, looping)\n * - Circuit breaker integration to prevent recursive/repeated failures\n *\n * Thread-safety: Node.js is single-threaded, but async callbacks can fire\n * in any order. All mutations go through synchronized Map methods.\n */\nimport { spawn } from 'node:child_process';\nimport type { ChildProcess } from 'node:child_process';\nimport * as os from 'node:os';\nimport { CircuitBreaker, type CircuitBreakerSnapshot, type CircuitBreakerConfig } from './circuit-breaker.js';\nexport type { CircuitBreakerSnapshot, CircuitBreakerConfig } from './circuit-breaker.js';\n\nexport interface TrackedProcess {\n pid: number;\n name: string;\n /** Display-safe redacted command string \u2014 safe for logs, /ps, crash dumps.\n * Contains [REDACTED] in place of sensitive flag values. */\n command: string;\n startedAt: number;\n sessionId?: string | undefined;\n /** The raw ChildProcess handle. Never call .kill() directly on this \u2014\n * use `kill()` below which handles process groups correctly on POSIX\n * and degrades gracefully on Windows. */\n child: ChildProcess;\n /** True only when this child was spawned as a POSIX process-group/session\n * leader (for example `spawn(..., { detached: true })`) and `pid` is the\n * actual `child.pid`. Negative-PID signaling is host-wide dangerous for\n * values like -1, so tests and manually registered entries must not opt in. */\n processGroupLeader?: boolean | undefined;\n /** True once the process has been kill()ed but not yet exited.\n * We keep it in the registry until 'close' fires so callers can\n * distinguish \"still running\" from \"just exited\". */\n killed: boolean;\n /** If true, kill() and killAll() will refuse to kill this process.\n * Used for infrastructure processes (browser, dev servers, \u2026) that\n * must outlive the agent session. */\n protected: boolean;\n}\n\n// redactCommand (and its sensitive-flag patterns) lives in _redact-command.ts\n// so registry-only consumers (e.g. ps-slash) don't carry its dependencies.\n// Re-exported here to keep this module's historical public API intact.\nexport { redactCommand } from './_redact-command.js';\n\ninterface KillOpts {\n /** SIGKILL instead of SIGTERM. Default: false (SIGTERM first). */\n force?: boolean | undefined;\n /** MS to wait between SIGTERM and SIGKILL on POSIX. Default: 2000. */\n graceMs?: number | undefined;\n}\n\n/**\n * Snapshot of the armed auto kill/reset countdown, or null when nothing is\n * armed. `remainingMs` ticks down in real time; the TUI statusline renders it.\n */\nexport interface BreakerCountdown {\n remainingMs: number;\n totalMs: number;\n}\n\ntype BreakerCountdownListener = (snapshot: BreakerCountdown | null) => void;\n\nexport interface RegistryStats {\n activeCount: number;\n totalCount: number;\n breaker: CircuitBreakerSnapshot;\n}\n\nconst DEFAULT_GRACE_MS = 2000;\nconst WIN32_TASKKILL_TIMEOUT_MS = 5000;\n\ninterface Win32TreeKillOptions {\n /**\n * Upper bound for taskkill itself before the caller's fallback may run.\n * This is deliberately separate from POSIX SIGTERM grace: on Windows the\n * direct-child fallback must not fire while taskkill is still walking the\n * child tree, or it can orphan grandchildren that keep stdio open.\n */\n timeoutMs?: number | undefined;\n onSettled?: (() => void) | undefined;\n}\n\n/**\n * Kill an entire process tree on Windows via `taskkill /T /F`.\n *\n * TerminateProcess (what `child.kill()` maps to) has no process-group\n * semantics, so killing a shell wrapper (`cmd.exe /c \u2026`) orphans its\n * grandchildren (node, vitest forks, dev servers). The orphans inherit the\n * parent's stdio pipe handles and can keep streaming into this process for\n * the rest of the session \u2014 which both prevents the child's 'close' event\n * from ever firing and grows in-memory output buffers without bound.\n *\n * Returns true if taskkill was spawned, false if spawning it failed (caller\n * should fall back to a direct `child.kill()`). Callers that need a direct\n * fallback should pass `onSettled`; it runs after taskkill exits, errors, or\n * exceeds `timeoutMs`, avoiding the race where killing cmd.exe first prevents\n * taskkill from enumerating and killing grandchildren.\n */\nexport function killWin32Tree(pid: number, opts: Win32TreeKillOptions = {}): boolean {\n try {\n const child = spawn('taskkill', ['/pid', String(pid), '/T', '/F'], {\n stdio: 'ignore',\n windowsHide: true,\n });\n let settled = false;\n let timeout: ReturnType<typeof setTimeout> | undefined;\n const settle = () => {\n if (settled) return;\n settled = true;\n if (timeout) clearTimeout(timeout);\n try {\n opts.onSettled?.();\n } catch {\n /* fallback callbacks are best-effort */\n }\n };\n // spawn() reports a failure to launch (e.g. taskkill not on PATH, blocked by\n // security software) via an ASYNC 'error' event \u2014 the surrounding try/catch\n // only traps synchronous throws. Without a listener that event is unhandled\n // and crashes the whole process. Swallow it: this is best-effort tree-kill\n // and the registry still has the direct child.kill() fallback.\n child.on('error', settle);\n child.on('close', settle);\n timeout = setTimeout(() => {\n try {\n child.kill();\n } catch {\n /* already exited */\n }\n settle();\n }, Math.max(1, opts.timeoutMs ?? WIN32_TASKKILL_TIMEOUT_MS));\n timeout.unref?.();\n child.unref();\n return true;\n } catch {\n return false;\n }\n}\n\nexport class ProcessRegistryImpl {\n private readonly processes = new Map<number, TrackedProcess>();\n private readonly breaker: CircuitBreaker;\n\n /**\n * Auto kill/reset config. When the breaker trips and `autoKillResetMs > 0`,\n * a countdown is armed; on expiry all tracked processes are killed and the\n * breaker is reset to closed (forced recovery). Zero means manual recovery\n * only (`/kill reset`).\n */\n private autoKillResetMs = 0;\n private autoKillTimer: ReturnType<typeof setTimeout> | null = null;\n private autoKillArmedAt: number | null = null;\n private breakerCountdownListeners: BreakerCountdownListener[] = [];\n\n constructor(breakerConfig?: CircuitBreakerConfig) {\n this.breaker = new CircuitBreaker(breakerConfig);\n // Arm on trip, cancel on recovery. Listeners are best-effort.\n this.breaker.onTrip = () => this._armAutoKillReset();\n this.breaker.onReset = () => this._cancelAutoKillReset();\n // Protection is OFF by default \u2014 the user opts in via `/settings breaker on`.\n this.breaker.setEnabled(false);\n }\n\n register(info: Omit<TrackedProcess, 'killed' | 'protected'> & { protected?: boolean | undefined }): void {\n this.processes.set(info.pid, { ...info, killed: false, protected: info.protected ?? false });\n }\n\n private _isSafeSignalPid(pid: number): boolean {\n return Number.isInteger(pid) && pid > 1 && pid !== process.pid && pid !== process.ppid;\n }\n\n private _canSignalProcessGroup(p: TrackedProcess): boolean {\n return (\n os.platform() !== 'win32' &&\n p.processGroupLeader === true &&\n this._isSafeSignalPid(p.pid) &&\n typeof p.child.pid === 'number' &&\n p.child.pid === p.pid\n );\n }\n\n private _killChildDirect(p: TrackedProcess, signal: NodeJS.Signals): void {\n try {\n p.child.kill(signal);\n } catch {\n // Process may have already exited, or this may be a persistent entry\n // without a live ChildProcess handle in the current process.\n }\n }\n\n private _killPosix(p: TrackedProcess, signal: NodeJS.Signals): void {\n if (this._canSignalProcessGroup(p)) {\n try {\n process.kill(-p.pid, signal);\n return;\n } catch {\n // Process group may already be gone; fall back to the direct child.\n }\n }\n this._killChildDirect(p, signal);\n }\n\n /** Unregister a process by PID. Called on 'close' / 'exit' events. */\n unregister(pid: number): void {\n this.processes.delete(pid);\n }\n\n /** Get a single process by PID. */\n get(pid: number): TrackedProcess | undefined {\n this._pruneStale(pid);\n return this.processes.get(pid);\n }\n\n /** Get all tracked processes. */\n list(): TrackedProcess[] {\n return Array.from(this.processes.values());\n }\n\n /** Get processes filtered by name (e.g. 'bash', 'exec'). */\n byName(name: string): TrackedProcess[] {\n return this.list().filter((p) => p.name === name);\n }\n\n /** Get processes filtered by session. */\n bySession(sessionId: string): TrackedProcess[] {\n return this.list().filter((p) => p.sessionId === sessionId);\n }\n\n /** Count of active (non-killed) processes. */\n get activeCount(): number {\n let n = 0;\n for (const p of this.processes.values()) {\n if (!p.killed) n++;\n }\n return n;\n }\n\n /**\n * Combined stats for observability \u2014 used by /ps and the TUI status bar.\n */\n stats(): RegistryStats {\n return {\n activeCount: this.activeCount,\n totalCount: this.processes.size,\n breaker: this.breaker.snapshot(),\n };\n }\n\n /**\n * Returns true if the circuit allows a new bash/exec call to proceed.\n * When false, callers MUST NOT spawn a process.\n */\n get canProceed(): boolean {\n return this.breaker.canProceed;\n }\n\n /**\n * Called before spawning a process. Returns true if allowed; false if\n * the circuit breaker is open.\n *\n * @param bypass - If true, skip circuit breaker check (for background processes).\n */\n beforeCall(bypass = false): boolean {\n return this.breaker.beforeCall(bypass);\n }\n\n /**\n * Called after a process finishes. `durationMs` is wall-clock time;\n * `failed` is true for non-zero exit codes.\n *\n * @param bypass - If true, do not update circuit breaker state (for background processes).\n */\n afterCall(durationMs: number, failed: boolean, bypass = false): void {\n this.breaker.afterCall(durationMs, failed, bypass);\n }\n\n /** Force-open the circuit breaker (Ctrl+C, /kill force). */\n forceBreakerOpen(): void {\n this.breaker.forceOpen();\n }\n\n /** Force-reset the circuit breaker to closed (/kill reset). */\n forceBreakerReset(): void {\n this.breaker.forceReset();\n }\n\n /**\n * Configure circuit-breaker protection at runtime. Called from `/settings`\n * (instant, all modes) and on TUI mount (applies persisted config).\n *\n * - `enabled` toggles whether the breaker gates `bash`/`exec`.\n * - `autoKillResetMs` arms the auto kill/reset countdown when the breaker\n * trips (0 = manual recovery only).\n *\n * Re-applies cleanly on every call: cancels a pending countdown when the\n * timeout is cleared or protection disabled, and re-arms if the breaker is\n * currently open under the new settings.\n */\n setBreakerConfig(cfg: { enabled?: boolean | undefined; autoKillResetMs?: number | undefined }): void {\n if (cfg.enabled !== undefined) this.breaker.setEnabled(cfg.enabled);\n if (cfg.autoKillResetMs !== undefined) this.autoKillResetMs = Math.max(0, cfg.autoKillResetMs);\n\n if (this.autoKillResetMs <= 0) {\n this._cancelAutoKillReset();\n return;\n }\n // If protection is active and the breaker is currently tripped, ensure a\n // countdown is armed for the new window (covers a live config change while\n // the breaker is already open).\n if (this.breaker.isEnabled && this.breaker.snapshot().state === 'open') {\n this._armAutoKillReset();\n }\n }\n\n /**\n * Live countdown to the next auto kill/reset, or null when nothing is armed.\n * The TUI polls this on a 1s tick while armed so the statusline decrements.\n */\n getBreakerCountdown(): BreakerCountdown | null {\n if (this.autoKillArmedAt === null || this.autoKillResetMs <= 0) return null;\n const elapsed = Date.now() - this.autoKillArmedAt;\n return { remainingMs: Math.max(0, this.autoKillResetMs - elapsed), totalMs: this.autoKillResetMs };\n }\n\n /**\n * Subscribe to countdown arm/cancel events. Returns an unsubscribe function.\n * Use {@link getBreakerCountdown} for the live ticking value between events.\n */\n onBreakerCountdownChange(listener: BreakerCountdownListener): () => void {\n this.breakerCountdownListeners.push(listener);\n return () => {\n this.breakerCountdownListeners = this.breakerCountdownListeners.filter((l) => l !== listener);\n };\n }\n\n private _emitBreakerCountdown(): void {\n const snap = this.getBreakerCountdown();\n for (const l of this.breakerCountdownListeners) {\n try {\n l(snap);\n } catch {\n /* listener failure must never affect breaker behavior */\n }\n }\n }\n\n /**\n * Arm the auto kill/reset countdown. Idempotent: re-arming resets the window\n * (a fresh trip after a failed half-open probe restarts the clock). No-op\n * when protection is off or no timeout is configured.\n */\n private _armAutoKillReset(): void {\n if (this.autoKillResetMs <= 0 || !this.breaker.isEnabled) return;\n this._clearAutoKillTimer();\n this.autoKillArmedAt = Date.now();\n this.autoKillTimer = setTimeout(() => {\n this.autoKillTimer = null;\n this.autoKillArmedAt = null;\n // Forced recovery: nuke runaway processes and reopen the circuit.\n this.killAll({ force: false });\n this.breaker.forceReset();\n this._emitBreakerCountdown();\n }, this.autoKillResetMs);\n // Don't keep the event loop alive purely for auto-recovery.\n this.autoKillTimer.unref?.();\n this._emitBreakerCountdown();\n }\n\n private _cancelAutoKillReset(): void {\n const wasArmed = this.autoKillArmedAt !== null;\n this._clearAutoKillTimer();\n if (wasArmed) {\n this.autoKillArmedAt = null;\n this._emitBreakerCountdown();\n }\n }\n\n private _clearAutoKillTimer(): void {\n if (this.autoKillTimer !== null) {\n clearTimeout(this.autoKillTimer);\n this.autoKillTimer = null;\n }\n }\n\n /** Kill a single process by PID.\n *\n * On POSIX: sends SIGTERM to the *process group* (-pid) so that\n * runaway grandchild processes (`sleep 9999 & disown`) are also killed.\n * After `graceMs` a SIGKILL is sent if the process hasn't exited.\n *\n * On Windows: `child.kill()` maps to TerminateProcess \u2014 process groups\n * are not meaningfully supported. A second `force=true` call sends\n * SIGKILL (which maps to TerminateProcess again \u2014 the distinction is\n * in the exit code, not the signal).\n *\n * Returns true if the process was found and kill was attempted.\n */\n kill(pid: number, opts: KillOpts = {}): boolean {\n this._pruneStale(pid);\n const p = this.processes.get(pid);\n if (!p) return false;\n if (p.killed) return true; // already kill()ed, don't double-send\n if (p.protected) return false; // protected processes are never kill()ed\n\n const { force = false, graceMs = DEFAULT_GRACE_MS } = opts;\n const isWin = os.platform() === 'win32';\n\n if (isWin) {\n // Windows: no process group semantics. A direct kill terminates only\n // the immediate child \u2014 shell-wrapped commands (cmd.exe /c \u2026) leave\n // grandchildren running that hold the inherited stdio pipes open and\n // keep feeding output into this process indefinitely. Kill the whole\n // tree via taskkill instead, but only for a real, still-running child\n // (exitCode === null); test fakes and already-exited processes take\n // the plain-kill path. The direct kill is deliberately NOT sent\n // immediately alongside taskkill: killing the root first would break\n // taskkill's parent-pid tree enumeration and orphan the grandchildren\n // again \u2014 it runs as a delayed fallback instead.\n const liveRealChild = p.child.exitCode === null && typeof p.child.pid === 'number';\n const directFallback = () => {\n if (p.child.exitCode === null) {\n try {\n p.child.kill('SIGKILL');\n } catch {\n // Process may have already exited.\n }\n }\n };\n if (\n liveRealChild &&\n killWin32Tree(pid, {\n timeoutMs: Math.max(graceMs, WIN32_TASKKILL_TIMEOUT_MS),\n onSettled: directFallback,\n })\n ) {\n // The direct fallback is intentionally chained from taskkill's\n // completion. Killing cmd.exe before taskkill has walked the tree can\n // orphan the real command and leave stdio pipes open forever.\n } else {\n try {\n p.child.kill(force ? 'SIGKILL' : 'SIGTERM');\n } catch {\n // Process may have already exited.\n }\n }\n p.killed = true;\n return true;\n }\n\n // POSIX: kill the process group only when the tracked child is proven to\n // be the group leader. Otherwise use child.kill(); negative PID signaling\n // with untrusted/fake PIDs can target unrelated host processes.\n try {\n if (force) {\n this._killPosix(p, 'SIGKILL');\n } else {\n this._killPosix(p, 'SIGTERM');\n // Schedule SIGKILL as backup.\n const timer = setTimeout(() => {\n // Re-check: process may have exited on its own.\n if (this.processes.has(pid) && !p.child.killed) {\n this._killPosix(p, 'SIGKILL');\n }\n }, graceMs);\n timer.unref?.(); // Don't keep event loop alive.\n }\n } catch {\n // Process may have already exited.\n }\n p.killed = true;\n return true;\n }\n\n /**\n * Kill all tracked processes.\n * Returns the PIDs that were kill()ed.\n */\n killAll(opts: KillOpts = {}): number[] {\n const pids = Array.from(this.processes.keys());\n const killed: number[] = [];\n for (const pid of pids) {\n const p = this.processes.get(pid);\n if (p && !p.protected && this.kill(pid, opts)) killed.push(pid);\n }\n return killed;\n }\n\n /**\n * Kill all processes for a specific session.\n * Returns the PIDs that were kill()ed.\n */\n killSession(sessionId: string, opts: KillOpts = {}): number[] {\n const pids = this.bySession(sessionId).map((p) => p.pid);\n const killed: number[] = [];\n for (const pid of pids) {\n if (this.kill(pid, opts)) killed.push(pid);\n }\n return killed;\n }\n\n /**\n * Check whether a tracked process entry is stale \u2014 the child has exited\n * (exitCode !== null) AND it's been in the registry long enough that the\n * OS may have reused the PID for a new, unrelated process.\n *\n * P3 #24 (before-release.md): on POSIX, PIDs are reused after process\n * exit. If a tracked process exits but its 'close' event hasn't fired yet\n * (or was missed), the registry still holds the entry. A new process\n * gets the same PID, and PID-based lookups (get, kill, shouldBlockKill)\n * may incorrectly protect or target the wrong process.\n *\n * The 60s threshold is conservative \u2014 the OS typically waits much longer\n * before reusing a PID, but we want to clean up before that becomes a risk.\n */\n private _isStaleEntry(entry: TrackedProcess): boolean {\n return entry.child.exitCode !== null && Date.now() - entry.startedAt > 60_000;\n }\n\n /**\n * Remove a stale entry for a specific PID before any PID-based lookup.\n * This prevents PID reuse from causing the registry to act on a dead\n * process that has been replaced by a new one with the same PID.\n */\n private _pruneStale(pid: number): void {\n const entry = this.processes.get(pid);\n if (entry && this._isStaleEntry(entry)) {\n this.processes.delete(pid);\n }\n }\n}\n\n/** Module-level singleton. Initialized on first access. */\nlet _registry: ProcessRegistryImpl | undefined;\n\nexport function getProcessRegistry(): ProcessRegistryImpl {\n if (!_registry) {\n _registry = new ProcessRegistryImpl();\n }\n return _registry;\n}\n\n/** Reset for tests. */\nexport function _resetProcessRegistry(): void {\n _registry = undefined;\n}\n\n// \u2500\u2500 Convenience re-exports \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport type { KillOpts };\n", "/**\n * CircuitBreaker \u2014 prevents runaway bash/exec tool chains by:\n *\n * - Tripping on consecutive failures (models that keep repeating the\n * same failing command, e.g. `npm install` with wrong args in a loop)\n * - Tripping on slow call ratio (too many long-running commands suggest\n * a hung subprocess that the model doesn't know how to kill)\n * - Rate-limiting bursts (rapid succession of commands without reading\n * output suggests the model isn't processing results)\n * - Auto-recovering after a cooldown period so a fixed model can resume\n *\n * The breaker is owned by the ProcessRegistry so any tool that registers\n * a process participates in the same circuit. \"Per-tool\" isolation is\n * intentionally NOT implemented \u2014 the model treats bash/exec as one\n * resource pool; isolating them would let the model route around the\n * breaker by alternating which tool it uses.\n */\n\nexport interface CircuitBreakerConfig {\n /**\n * Consecutive failures before trip. Default: 5.\n * A single success resets this counter to 0.\n */\n maxConsecutiveFailures?: number | undefined;\n /**\n * Slow-call threshold in ms. A call that runs longer than this is\n * counted as \"slow\". Default: 60_000 (1 minute).\n */\n slowCallThresholdMs?: number | undefined;\n /**\n * Max slow calls before trip (within the sliding window). Default: 3.\n */\n maxSlowCalls?: number | undefined;\n /**\n * Sliding window for rate-limit and slow-call counting, in ms.\n * Default: 60_000 (1 minute).\n */\n windowMs?: number | undefined;\n /**\n * Max calls within the sliding window. Default: 30.\n * Burst exceeding this trips the breaker immediately.\n */\n maxCallsPerWindow?: number | undefined;\n /**\n * Cooldown before auto-recovery attempt, in ms. Default: 30_000 (30s).\n * After this the breaker enters \"half-open\" state and allows one call\n * through to test whether the problem is resolved.\n */\n cooldownMs?: number | undefined;\n}\n\ninterface CallRecord {\n at: number;\n /** True if the call threw or returned an is_error result. */\n failed: boolean;\n /** True if elapsed time exceeded slowCallThresholdMs. */\n slow: boolean;\n}\n\ntype BreakerState = 'closed' | 'open' | 'half-open';\n\nconst DEFAULT_MAX_CONSECUTIVE_FAILURES = 5;\nconst DEFAULT_SLOW_CALL_THRESHOLD_MS = 180_000;\n// 3 minutes \u2014 balanced against the 5-minute bash timeout. Commands\n// running <3min are normal; 3-5min are \"slow\" and count toward the\n// breaker. 3 consecutive slow calls trip the circuit.\nconst DEFAULT_MAX_SLOW_CALLS = 3;\nconst DEFAULT_WINDOW_MS = 60_000;\nconst DEFAULT_MAX_CALLS_PER_WINDOW = 30;\nconst DEFAULT_COOLDOWN_MS = 30_000;\n\nexport interface CircuitBreakerSnapshot {\n state: 'closed' | 'open' | 'half-open';\n consecutiveFailures: number;\n slowCallsInWindow: number;\n callsInWindow: number;\n windowMs: number;\n cooldownRemainingMs: number | null;\n lastFailureAt: number | null;\n lastSlowAt: number | null;\n}\n\nexport class CircuitBreaker {\n private readonly maxConsecutiveFailures: number;\n private readonly slowCallThresholdMs: number;\n private readonly maxSlowCalls: number;\n private readonly windowMs: number;\n private readonly maxCallsPerWindow: number;\n private readonly cooldownMs: number;\n\n private state: BreakerState = 'closed';\n private consecutiveFailures = 0;\n private window: CallRecord[] = [];\n private lastFailureAt: number | null = null;\n private lastSlowAt: number | null = null;\n /** Timestamp when the breaker was opened (for cooldown calculation). */\n private openedAt: number | null = null;\n\n /**\n * Master enable flag. When false the breaker is bypassed: `beforeCall`\n * always returns true and `afterCall` records nothing. The class itself\n * defaults to enabled (so the standalone unit tests exercise tripping); the\n * ProcessRegistry flips this off until the user opts in via `/settings`.\n */\n private enabled = true;\n\n /**\n * Fired (best-effort) when the breaker transitions into the `open` state.\n * The registry uses this to arm its auto kill/reset countdown.\n */\n onTrip?: (() => void) | undefined;\n /**\n * Fired (best-effort) when the breaker returns to `closed` after having been\n * open/half-open. The registry uses this to cancel a pending kill/reset.\n */\n onReset?: (() => void) | undefined;\n\n constructor(config: CircuitBreakerConfig = {}) {\n this.maxConsecutiveFailures = config.maxConsecutiveFailures ?? DEFAULT_MAX_CONSECUTIVE_FAILURES;\n this.slowCallThresholdMs = config.slowCallThresholdMs ?? DEFAULT_SLOW_CALL_THRESHOLD_MS;\n this.maxSlowCalls = config.maxSlowCalls ?? DEFAULT_MAX_SLOW_CALLS;\n this.windowMs = config.windowMs ?? DEFAULT_WINDOW_MS;\n this.maxCallsPerWindow = config.maxCallsPerWindow ?? DEFAULT_MAX_CALLS_PER_WINDOW;\n this.cooldownMs = config.cooldownMs ?? DEFAULT_COOLDOWN_MS;\n }\n\n /** Toggle the master enable. Disabling resets to a clean `closed` state. */\n setEnabled(enabled: boolean): void {\n if (this.enabled === enabled) return;\n this.enabled = enabled;\n if (!enabled) this._reset();\n }\n\n get isEnabled(): boolean {\n return this.enabled;\n }\n\n /**\n * Returns true if the circuit allows a new call to proceed.\n * When false, callers should abort the tool call and return a\n * circuit-breaker error instead of spawning a process.\n */\n get canProceed(): boolean {\n if (!this.enabled) return true;\n this._checkStateTransition();\n return this.state !== 'open';\n }\n\n /**\n * Snapshot of the current breaker state for observability (`/kill`).\n */\n snapshot(): CircuitBreakerSnapshot {\n this._checkStateTransition();\n const now = Date.now();\n let cooldownRemaining: number | null = null;\n if (this.openedAt !== null && this.state === 'open') {\n const elapsed = now - this.openedAt;\n cooldownRemaining = Math.max(0, this.cooldownMs - elapsed);\n }\n return {\n state: this.state,\n consecutiveFailures: this.consecutiveFailures,\n slowCallsInWindow: this.window.filter((c) => c.slow).length,\n callsInWindow: this.window.length,\n windowMs: this.windowMs,\n cooldownRemainingMs: cooldownRemaining,\n lastFailureAt: this.lastFailureAt,\n lastSlowAt: this.lastSlowAt,\n };\n }\n\n /**\n * Call this BEFORE spawning a bash/exec process.\n * Returns true if the call is allowed; false if the breaker is open.\n * When false, callers MUST NOT spawn a process.\n *\n * @param bypass - If true, skip the circuit breaker check entirely.\n * Use for background/fire-and-forget processes that should\n * not affect breaker state.\n */\n beforeCall(bypass = false): boolean {\n if (bypass || !this.enabled) return true;\n this._checkStateTransition();\n if (this.state === 'open') return false;\n return true;\n }\n\n /**\n * Call this AFTER a bash/exec process finishes (success or failure).\n * `durationMs` is the wall-clock time the process ran.\n * `failed` is true when the process returned a non-zero exit code or\n * threw an exception before spawning.\n *\n * @param bypass - If true, do not update breaker state.\n * Use for background/fire-and-forget processes.\n */\n afterCall(durationMs: number, failed: boolean, bypass = false): void {\n if (bypass || !this.enabled) return;\n\n const now = Date.now();\n\n if (this.state === 'half-open') {\n // First call through after cooldown \u2014 if it failed, go back to open.\n if (failed) {\n this._trip();\n return;\n }\n // Success in half-open \u2192 reset to closed.\n this._reset();\n return;\n }\n\n // Prune old records outside the sliding window.\n this._pruneWindow(now);\n\n const slow = durationMs >= this.slowCallThresholdMs;\n this.window.push({ at: now, failed, slow });\n\n if (failed) {\n this.consecutiveFailures++;\n this.lastFailureAt = now;\n if (this.consecutiveFailures >= this.maxConsecutiveFailures) {\n this._trip();\n }\n return;\n }\n\n // Success: reset consecutive failure counter.\n this.consecutiveFailures = 0;\n\n if (slow) {\n this.lastSlowAt = now;\n const slowCount = this.window.filter((c) => c.slow).length;\n if (slowCount >= this.maxSlowCalls) {\n this._trip();\n }\n }\n\n const callCount = this.window.length;\n if (callCount >= this.maxCallsPerWindow) {\n // Rate limit exceeded. This is a soft trip \u2014 we reset the window\n // and let the next call try immediately (the caller will still see\n // canProceed=false until the window drains naturally).\n this._trip();\n }\n }\n\n /** Force the breaker open. Used by /kill force and Ctrl+C. */\n forceOpen(): void {\n this._trip();\n }\n\n /** Force a reset to closed. Used by tests and /kill reset. */\n forceReset(): void {\n this._reset();\n }\n\n private _trip(): void {\n if (this.state === 'open') return; // already open\n this.state = 'open';\n this.openedAt = Date.now();\n // P3 #23 (before-release.md): clear the window on trip. Old records are\n // irrelevant once tripped \u2014 the breaker starts fresh after cooldown\n // (half-open \u2192 closed resets the counters). Without this the window array\n // holds onto CallRecord entries for its lifetime if no new afterCall()\n // arrives (which is the case when the breaker stays open and no new calls\n // are attempted).\n this.window = [];\n // Best-effort: never let a listener failure corrupt breaker state.\n try {\n this.onTrip?.();\n } catch {\n /* ignored \u2014 observability hook only */\n }\n }\n\n private _reset(): void {\n const wasRecovering = this.state !== 'closed';\n this.state = 'closed';\n this.consecutiveFailures = 0;\n this.window = [];\n this.openedAt = null;\n // Only notify on a real recovery (open/half-open \u2192 closed), not on the\n // initial closed state or an idempotent re-reset.\n if (wasRecovering) {\n try {\n this.onReset?.();\n } catch {\n /* ignored \u2014 observability hook only */\n }\n }\n }\n\n /** Transition from open \u2192 half-open when cooldown elapses. */\n private _checkStateTransition(): void {\n if (this.state !== 'open' || this.openedAt === null) return;\n const elapsed = Date.now() - this.openedAt;\n if (elapsed >= this.cooldownMs) {\n this.state = 'half-open';\n this.openedAt = null;\n }\n }\n\n private _pruneWindow(now: number): void {\n const cutoff = now - this.windowMs;\n this.window = this.window.filter((c) => c.at >= cutoff);\n }\n}", "import { expectDefined } from '@wrongstack/core';\n\n// Sensitive CLI flag patterns that may appear in process command lines.\n// Redacted to [REDACTED] so crash dumps /ps output cannot leak secrets.\n// Split out of process-registry.ts so entries that only need the registry\n// (e.g. ps-slash) don't carry this module's dependencies.\n//\n// NOTE: @wrongstack/core carries its own copy (observability/redact-command.ts)\n// so the emitProcessStarted telemetry producer can redact command+args\n// centrally. Keep these two copies in sync when updating the patterns.\nconst SENSITIVE_FLAG_PATTERNS: RegExp[] = [\n // --flag=value or --flag \"value\" (value captured up to next space or comma)\n /--(?:token|password|passwd|pwd|secret|api[-_]?key|api[-_]?secret|auth|credential|private[-_]?key|access[-_]?key|github[-_]?token|gh[-_]?token|bearer|jwt|oauth|pin|pincode|passphrase|access[-_]?token)(?:[=\\s,][^\\s]*)?/gi,\n // -t short flag (token): attached (-tVALUE), separated (-t VALUE), or -t=VALUE.\n // (?<![-\\w]) anchors to a token start so we don't match the `-t` inside `--token`.\n // NOTE: synced with @wrongstack/core observability/redact-command.ts.\n /(?<![-\\w])-t(?:[=\\s]+)?[^\\s,-]+/,\n // -p|-password|-a (redis auth) short flags: attached + separated + =value.\n // Same token-start anchor; over-redaction is an accepted tradeoff for a\n // redaction function. Synced with core copy.\n /(?<![-\\w])-(?:password|p|a)(?:[=\\s]+)?[^\\s,-]+/gi,\n // env var\u2013style secrets: TOKEN=x, API_KEY=y, etc.\n /(?:TOKEN|API_KEY|API_SECRET|AUTH_TOKEN|GITHUB_TOKEN|GH_TOKEN|BEARER|JWT|OAUTH|CREDENTIAL|SECRET|PRIVATE_KEY|PASSWORD|PASSWD)\\s*[=:]\\s*[^\\s,]+/gi,\n // Generic high-entropy look: base64 strings >32 chars or hex strings >32 digits \u2014 but only\n // when preceded by a flag name (e.g. --github-token=EyJ...).\n /--\\w*(?:token|key|secret|password|passwd|auth|credential)\\w*[=\\s,][A-Za-z0-9+/=]{32,}/,\n];\n\n/**\n * Returns a display-safe copy of `cmd` with sensitive flag values replaced by [REDACTED].\n * The original string is unchanged; this is pure and has no side effects.\n */\nexport function redactCommand(cmd: string): string {\n let result = cmd;\n for (const pattern of SENSITIVE_FLAG_PATTERNS) {\n result = result.replace(pattern, (match) => {\n // Preserve the flag name portion; redact only the value part.\n // e.g. \"--token=sekrit_abc\" \u2192 \"--token=[REDACTED]\"\n const eq = match.indexOf('=');\n const sp = match.search(/\\s/);\n const delim = eq !== -1 ? '=' : sp !== -1 ? match[sp] : null;\n if (delim !== null) {\n const flag = match.slice(0, match.indexOf(expectDefined(delim)) + 1);\n return `${flag}[REDACTED]`;\n }\n // No delimitable separator found in the match.\n if (match.startsWith('--')) {\n // Long flag with no value attached (e.g. a bare \"--token\" argv token).\n // No secret here \u2014 leave it untouched so downstream pair-scan can still\n // recognize the bare flag. NOTE: keep this synced with\n // @wrongstack/core observability/redact-command.ts.\n return match;\n }\n // Short flag attached form (-pVALUE, -tVALUE, -aVALUE): flag name is the\n // leading -X (2 chars); redact everything after. Don't use a greedy\n // [a-zA-Z0-9_-]* flag-name match \u2014 value chars would be consumed into the\n // flag name and the secret would survive. Synced with core copy.\n return `${match.slice(0, 2)}[REDACTED]`;\n });\n }\n return result;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/search.js
CHANGED
|
@@ -56,7 +56,7 @@ function guardedLookup(hostname, options, callback) {
|
|
|
56
56
|
var pinnedAgent;
|
|
57
57
|
function getPinnedDispatcher() {
|
|
58
58
|
if (!pinnedAgent) {
|
|
59
|
-
pinnedAgent = new Agent({ connect: { lookup: guardedLookup } });
|
|
59
|
+
pinnedAgent = new Agent({ allowH2: false, connect: { lookup: guardedLookup } });
|
|
60
60
|
}
|
|
61
61
|
return pinnedAgent;
|
|
62
62
|
}
|
|
@@ -158,7 +158,7 @@ async function assertNotPrivate(hostname) {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
} catch (err) {
|
|
161
|
-
if (err instanceof
|
|
161
|
+
if (err instanceof ToolValidationError) throw err;
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
}
|
package/dist/search.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/search.ts", "../src/_fetch-guard.ts"],
|
|
4
|
-
"sourcesContent": ["import { expectDefined, FetchError, ToolValidationError } from '@wrongstack/core';\nimport type { Tool, ToolStreamEvent } from '@wrongstack/core';\nimport { guardedFetch } from './_fetch-guard.js';\nimport { toErrorMessage } from '@wrongstack/core/utils';\n\ninterface SearchResult {\n title: string;\n url: string;\n snippet: string;\n score: number;\n}\n\ninterface CacheEntry {\n results: SearchResult[];\n source: string;\n timestamp: number;\n}\n\ninterface SearchInput {\n query: string;\n num_results?: number | undefined;\n source?: 'duckduckgo' | 'google' | 'bing' | undefined;\n skip_cache?: boolean | undefined;\n}\n\ninterface SearchOutput {\n query: string;\n results: { title: string; url: string; snippet: string }[];\n source: string;\n truncated: boolean;\n cached: boolean;\n}\n\nconst DEFAULT_NUM = 10;\nconst MAX_RESULTS = 50;\nconst TIMEOUT_MS = 15_000;\nconst CACHE_TTL_MS = 300_000; // 5 minutes \u2014 matches the former web-search plugin default\nconst CACHE_MAX_ENTRIES = 200;\n\n// Module-level cache shared across calls within a single agent run. Keyed by\n// `<source>:<query>`. This is intentionally process-local (not persisted) \u2014\n// it exists to avoid hammering a search engine when the model rephrases the\n// same query a few turns apart.\nconst cache = new Map<string, CacheEntry>();\n\nexport const searchTool: Tool<SearchInput, SearchOutput> = {\n name: 'search',\n category: 'Search',\n description:\n 'Perform a web search and return results with title, URL, and snippet. Use this when you need up-to-date external information that is not in the local codebase. Results are cached (5 min TTL) and deduplicated by URL.',\n usageHint:\n 'Good for: API documentation, error messages, library usage examples, current best practices.\\n\\n' +\n '- Prefer specific queries over very broad ones.\\n' +\n '- Results go through the guarded fetch system (same protections as the `fetch` tool).\\n' +\n '- Supports duckduckgo (default), google, and bing sources.\\n' +\n '- Set `skip_cache: true` to force a fresh search.\\n' +\n '- This is often better than the model trying to recall outdated knowledge.',\n permission: 'auto',\n mutating: false,\n capabilities: ['net.outbound'],\n icon: 'search',\n timeoutMs: TIMEOUT_MS,\n inputSchema: {\n type: 'object',\n properties: {\n query: { type: 'string', description: 'Search query' },\n num_results: {\n type: 'integer',\n description: 'Number of results (1-50, default 10)',\n minimum: 1,\n maximum: MAX_RESULTS,\n },\n source: {\n type: 'string',\n enum: ['duckduckgo', 'google', 'bing'],\n description: 'Search engine to use (default: duckduckgo)',\n },\n skip_cache: {\n type: 'boolean',\n description: 'Skip the in-memory cache and force a fresh search (default: false)',\n },\n },\n required: ['query'],\n },\n async execute(input, ctx, opts) {\n let final: SearchOutput | undefined;\n const executeStream = searchTool.executeStream;\n if (!executeStream) throw new Error('searchTool: stream execution unavailable');\n for await (const ev of executeStream(input, ctx, opts)) {\n if (ev.type === 'final') final = ev.output;\n }\n if (!final) throw new Error('search: stream ended without final event');\n return final;\n },\n async *executeStream(input, _ctx, opts): AsyncGenerator<ToolStreamEvent<SearchOutput>> {\n if (!input?.query || input.query.trim() === '') {\n throw new ToolValidationError({\n message: 'search: query is required and must be a non-empty string',\n field: 'query',\n });\n }\n\n const num = Math.max(1, Math.min(input.num_results ?? DEFAULT_NUM, MAX_RESULTS));\n const source = input.source ?? 'duckduckgo';\n const skipCache = input.skip_cache ?? false;\n const cacheKey = `${source}:${input.query}`;\n\n // --- Cache hit ---\n if (!skipCache) {\n const entry = cache.get(cacheKey);\n if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) {\n const results = entry.results.map((r) => ({\n title: r.title,\n url: r.url,\n snippet: r.snippet,\n }));\n yield {\n type: 'log',\n text: `Cache hit for \"${input.query}\" (${source})`,\n data: { source, query: input.query, cached: true },\n };\n yield {\n type: 'partial_output',\n text: `${results.length} cached results from ${entry.source}`,\n data: { count: results.length, cached: true, source: entry.source },\n };\n yield {\n type: 'final',\n output: {\n query: input.query,\n results: results.slice(0, num),\n source: entry.source,\n truncated: results.length >= num,\n cached: true,\n },\n };\n return;\n }\n }\n\n yield {\n type: 'log',\n text: `Querying ${source} for \"${input.query}\"\u2026`,\n data: { source, query: input.query, cached: false },\n };\n\n let rawResults: SearchResult[];\n let effectiveSource: SearchOutput['source'] = source;\n switch (source) {\n case 'duckduckgo':\n rawResults = await duckduckgoSearch(input.query, num, opts.signal);\n break;\n case 'google':\n rawResults = await googleSearch(input.query, num, opts.signal);\n break;\n case 'bing':\n rawResults = await bingSearch(input.query, num, opts.signal);\n break;\n default:\n throw new ToolValidationError({\n message: `search: unknown source \"${source}\"`,\n field: 'source',\n });\n }\n\n let ranked = rankSearchResults(rawResults, input.query);\n if (source !== 'duckduckgo' && shouldFallbackToDuckDuckGo(ranked, input.query)) {\n yield {\n type: 'log',\n text: `${source} returned no relevant static results; falling back to duckduckgo`,\n data: { source, fallback: 'duckduckgo', query: input.query },\n };\n rawResults = await duckduckgoSearch(input.query, num, opts.signal);\n ranked = rankSearchResults(rawResults, input.query);\n effectiveSource = 'duckduckgo';\n }\n\n const finalResults = ranked.slice(0, num);\n\n // --- Store in cache ---\n cache.set(cacheKey, { results: ranked, source: effectiveSource, timestamp: Date.now() });\n pruneCacheEntries();\n\n yield {\n type: 'partial_output',\n text: `${finalResults.length} results from ${effectiveSource}`,\n data: { count: finalResults.length, cached: false, source: effectiveSource },\n };\n yield {\n type: 'final',\n output: {\n query: input.query,\n results: finalResults.map((r) => ({\n title: r.title,\n url: r.url,\n snippet: r.snippet,\n })),\n source: effectiveSource,\n truncated: finalResults.length >= num,\n cached: false,\n },\n };\n },\n};\n\n// ---------------------------------------------------------------------------\n// Cache helpers\n// ---------------------------------------------------------------------------\n\n/** Drop stale entries, then evict oldest insertions to cap retained result data. */\nfunction pruneCacheEntries(): void {\n const cutoff = Date.now() - CACHE_TTL_MS * 2;\n for (const [key, entry] of cache.entries()) {\n if (entry.timestamp < cutoff) cache.delete(key);\n }\n while (cache.size > CACHE_MAX_ENTRIES) {\n const oldestKey = cache.keys().next().value;\n if (oldestKey === undefined) break;\n cache.delete(oldestKey);\n }\n}\n\n/** Exposed for tests so they can reset the module-level cache between cases. */\nexport function __clearSearchCache(): void {\n cache.clear();\n}\n\n// ---------------------------------------------------------------------------\n// Ranking\n// ---------------------------------------------------------------------------\n\nfunction rankSearchResults(results: SearchResult[], query: string): SearchResult[] {\n const seenUrls = new Set<string>();\n const deduped: SearchResult[] = [];\n for (const r of results) {\n const noQuery = r.url.split('?')[0] ?? r.url;\n const normalized = noQuery.split('#')[0] ?? r.url;\n if (!seenUrls.has(normalized) && r.url.startsWith('http')) {\n seenUrls.add(normalized);\n deduped.push(r);\n }\n }\n return scoreResults(deduped, query);\n}\n\nfunction scoreResults(results: SearchResult[], query: string): SearchResult[] {\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n return results\n .map((r) => {\n const titleLower = r.title.toLowerCase();\n const snippetLower = r.snippet.toLowerCase();\n let score = r.score;\n for (const term of terms) {\n if (titleLower.includes(term)) score += 2;\n if (snippetLower.includes(term)) score += 1;\n }\n return { ...r, score };\n })\n .sort((a, b) => b.score - a.score);\n}\n\nfunction shouldFallbackToDuckDuckGo(results: SearchResult[], query: string): boolean {\n if (results.length === 0) return true;\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length >= 3);\n if (terms.length === 0) return false;\n return !results.some((r) => {\n const haystack = `${r.title} ${r.url} ${r.snippet}`.toLowerCase();\n return terms.some((term) => haystack.includes(term));\n });\n}\n\n// ---------------------------------------------------------------------------\n// Search engines\n// ---------------------------------------------------------------------------\n\nasync function duckduckgoSearch(\n query: string,\n num: number,\n signal: AbortSignal,\n): Promise<SearchResult[]> {\n const encoded = encodeURIComponent(query);\n const url = `https://lite.duckduckgo.com/lite/?q=${encoded}&kd=-1&kl=wt-wt`;\n\n try {\n const response = await fetchWithTimeout(url, signal, TIMEOUT_MS);\n const html = await response.text();\n return parseDuckDuckGo(html, num);\n } catch (err) {\n console.log(\n JSON.stringify({ level: 'debug', event: 'search_failed', query, error: toErrorMessage(err) }),\n );\n // Return a sentinel result that survives the dedup filter (which drops\n // non-http URLs). Using a placeholder http URL keeps it visible to the\n // caller so they know the search failed rather than silently getting [].\n return [{ title: 'Search unavailable', url: 'https://duckduckgo.com/unavailable', snippet: 'Could not reach DuckDuckGo', score: 0 }];\n }\n}\n\nfunction takeFrom<T>(iter: Iterable<T>, max: number): T[] {\n const out: T[] = [];\n for (const item of iter) {\n if (out.length >= max) break;\n out.push(item);\n }\n return out;\n}\n\nfunction parseDuckDuckGo(html: string, num: number): SearchResult[] {\n const results: SearchResult[] = [];\n const linkRegex = /<a\\b([^>]*\\bclass=([\"'])[^\"']*\\bresult-link\\b[^\"']*\\2[^>]*)>([\\s\\S]*?)<\\/a>/gi;\n const snippetRegex =\n /<([a-z0-9]+)\\b([^>]*\\bclass=([\"'])[^\"']*\\bresult-snippet\\b[^\"']*\\3[^>]*)>([\\s\\S]*?)<\\/\\1>/gi;\n\n const linkMatches = takeFrom(\n [...html.matchAll(linkRegex)]\n .map((m) => {\n const attrs = expectDefined(m[1]);\n const href = getHtmlAttr(attrs, 'href');\n const title = stripTags(expectDefined(m[3]));\n return href && title ? { url: normalizeDuckDuckGoUrl(href), title } : undefined;\n })\n .filter((m): m is { url: string; title: string } => m !== undefined),\n num,\n );\n\n const snippetMatches = takeFrom(\n [...html.matchAll(snippetRegex)]\n .filter((m) => m[4])\n .map((m) => stripTags(expectDefined(m[4]))),\n num,\n );\n\n for (let i = 0; i < linkMatches.length && i < num; i++) {\n const entry = linkMatches[i];\n if (entry) {\n results.push({\n title: entry.title ?? '',\n url: entry.url ?? '',\n snippet: snippetMatches[i] ?? '',\n score: 1,\n });\n }\n }\n\n return results;\n}\n\nfunction getHtmlAttr(attrs: string, name: string): string | undefined {\n const quoted = new RegExp(`\\\\b${name}\\\\s*=\\\\s*([\"'])(.*?)\\\\1`, 'i').exec(attrs);\n if (quoted?.[2]) return decodeHtmlEntities(quoted[2]);\n const unquoted = new RegExp(`\\\\b${name}\\\\s*=\\\\s*([^\\\\s>]+)`, 'i').exec(attrs);\n return unquoted?.[1] ? decodeHtmlEntities(unquoted[1]) : undefined;\n}\n\nfunction normalizeDuckDuckGoUrl(raw: string): string {\n if (raw.startsWith('//')) return `https:${raw}`;\n if (!raw.startsWith('/')) return raw;\n if (!raw.startsWith('/l/')) return raw;\n try {\n const url = new URL(raw, 'https://duckduckgo.com');\n const uddg = url.searchParams.get('uddg');\n return uddg?.startsWith('http') ? uddg : url.toString();\n } catch {\n return raw;\n }\n}\n\nasync function googleSearch(query: string, num: number, signal: AbortSignal): Promise<SearchResult[]> {\n const encoded = encodeURIComponent(query);\n const url = `https://www.google.com/search?q=${encoded}&hl=en`;\n\n const html = await fetchWithTimeout(url, signal, TIMEOUT_MS)\n .then((r) => r.text())\n .catch(() => '');\n\n return parseGoogleResults(html, num);\n}\n\nfunction parseGoogleResults(html: string, num: number): SearchResult[] {\n const results: SearchResult[] = [];\n const titleRegex = /<h3[^>]*class=\"[^\"]*DKV84\"[^>]*>([^<]+)<\\/h3>/gi;\n const urlRegex = /<cite[^>]*>([^<]+)<\\/cite>/gi;\n const snippetRegex = /<span[^>]*class=\"[^\"]*aXCZ0b[^>]*>([^<]+)<\\/span>/gi;\n\n const titles = takeFrom(\n [...html.matchAll(titleRegex)].filter((m) => m[1]).map((m) => stripTags(expectDefined(m[1]))),\n num,\n );\n\n const urls = takeFrom(\n [...html.matchAll(urlRegex)]\n .filter((m) => m[1])\n .map((m) => stripTags(expectDefined(m[1])).replace(/^\\*(https?:\\/\\/[^\\s]+).*$/, '$1'))\n .filter((u) => u.startsWith('http')),\n num,\n );\n\n const snippets = takeFrom(\n [...html.matchAll(snippetRegex)].filter((m) => m[1]).map((m) => stripTags(expectDefined(m[1]))),\n num,\n );\n\n for (let i = 0; i < Math.min(titles.length, num); i++) {\n results.push({\n title: titles[i] ?? '',\n url: urls[i] ?? '',\n snippet: snippets[i] ?? '',\n score: 1,\n });\n }\n\n return results;\n}\n\nasync function bingSearch(query: string, num: number, signal: AbortSignal): Promise<SearchResult[]> {\n const encoded = encodeURIComponent(query);\n const url = `https://www.bing.com/search?q=${encoded}`;\n\n const html = await fetchWithTimeout(url, signal, TIMEOUT_MS)\n .then((r) => r.text())\n .catch(() => '');\n\n return parseBingResults(html, num);\n}\n\nfunction parseBingResults(html: string, num: number): SearchResult[] {\n const results: SearchResult[] = [];\n const blocks = [...html.matchAll(/<li\\b[^>]*class=([\"'])[^\"']*\\bb_algo\\b[^\"']*\\1[^>]*>([\\s\\S]*?)(?=<li\\b[^>]*class=([\"'])[^\"']*\\bb_algo\\b[^\"']*\\3|<\\/ol>)/gi)]\n .map((m) => expectDefined(m[2]));\n const candidates = blocks.length > 0 ? blocks : [html];\n\n const entries = takeFrom(candidates.flatMap((block) => {\n const titleMatch = /<h2[^>]*>\\s*<a\\b([^>]*)>([\\s\\S]*?)<\\/a>\\s*<\\/h2>/i.exec(block);\n if (!titleMatch) return [];\n const href = getHtmlAttr(expectDefined(titleMatch[1]), 'href');\n const title = stripTags(expectDefined(titleMatch[2]));\n if (!href || !title) return [];\n const snippetMatch = /<p\\b[^>]*class=([\"'])[^\"']*\\b(?:b_paractl|b_lineclamp\\d*)\\b[^\"']*\\1[^>]*>([\\s\\S]*?)<\\/p>/i.exec(block)\n ?? /<p\\b[^>]*>([\\s\\S]*?)<\\/p>/i.exec(block);\n const snippet = snippetMatch ? stripTags(expectDefined(snippetMatch.at(-1))) : '';\n return [{ url: normalizeBingUrl(href), title, snippet, score: 1 }];\n }), num);\n\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i];\n if (entry) {\n results.push({\n title: entry.title ?? '',\n url: entry.url ?? '',\n snippet: entry.snippet ?? '',\n score: 1,\n });\n }\n }\n\n return results;\n}\n\nfunction normalizeBingUrl(raw: string): string {\n try {\n const url = new URL(raw);\n if (url.hostname.endsWith('bing.com') && url.pathname.startsWith('/ck/')) {\n const encoded = url.searchParams.get('u');\n const decoded = decodeBingTarget(encoded);\n if (decoded?.startsWith('http')) return decoded;\n }\n } catch {\n // Fall through to the raw URL.\n }\n return raw;\n}\n\nfunction decodeBingTarget(encoded: string | null): string | undefined {\n if (!encoded) return undefined;\n const payload = encoded.startsWith('a1') ? encoded.slice(2) : encoded;\n try {\n const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=');\n return Buffer.from(padded, 'base64').toString('utf8');\n } catch {\n return undefined;\n }\n}\n\n// ---------------------------------------------------------------------------\n// HTTP helper\n// ---------------------------------------------------------------------------\n\nasync function fetchWithTimeout(\n url: string,\n signal: AbortSignal,\n timeoutMs: number,\n): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n const fetchSignal = anySignal(signal, controller.signal);\n try {\n // F-05: route through the SSRF-guarded fetch (private-IP blocking, HTTPS,\n // DNS-pinned dispatcher, per-hop redirect re-validation) instead of a bare\n // `fetch` with `redirect: 'follow'`. Search hosts are fixed/trusted, but\n // this closes the residual \"engine 30x \u2192 internal address\" redirect risk.\n const res = await guardedFetch(url, 5, fetchSignal, {\n 'user-agent':\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n });\n clearTimeout(timer);\n return res;\n } catch (e) {\n clearTimeout(timer);\n if (e instanceof FetchError) {\n throw e;\n }\n throw new FetchError({\n message: `search: failed to fetch ${url}`,\n status: 0,\n context: { url },\n cause: e,\n });\n }\n}\n\nfunction anySignal(...signals: AbortSignal[]): AbortSignal {\n // Native combinator (Node \u2265 20.3; this repo requires \u2265 22). The previous\n // hand-rolled version registered a non-once 'abort' listener on every\n // input signal and never removed it \u2014 the run-level signal outlives each\n // request, so listeners (and their closures) accumulated one per search\n // call for the life of the agent run.\n return AbortSignal.any(signals);\n}\n\nfunction stripTags(html: string): string {\n return decodeHtmlEntities(html.replace(/<[^>]+>/g, '')).trim();\n}\n\nfunction decodeHtmlEntities(text: string): string {\n return text\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\");\n}\n", "import * as dns from 'node:dns/promises';\nimport * as net from 'node:net';\nimport {\n FetchError,\n ToolValidationError,\n isPrivateIPv4,\n isPrivateIPv6,\n} from '@wrongstack/core';\nimport { Agent, fetch as undiciFetch } from 'undici';\n\n/**\n * SSRF guard machinery shared by the `fetch` and `search` tools. Split out of\n * fetch.ts so entries that only need `guardedFetch` (search) don't bundle the\n * whole fetch tool. Everything here is security-critical: the pinned undici\n * dispatcher performs the SINGLE DNS resolution the TCP connection uses, so\n * there is no DNS-rebinding TOCTOU between validation and connect.\n */\n\nconst nativeGlobalFetch = globalThis.fetch;\n\nexport const ALLOW_PRIVATE = process.env['WRONGSTACK_FETCH_ALLOW_PRIVATE'] === '1';\n/* v8 ignore next 8 -- module-load-time opt-in warning; gated on an env var not set during tests. */\nif (ALLOW_PRIVATE && !process.env['CI']) {\n console.warn(\n '[WrongStack] WARNING: WRONGSTACK_FETCH_ALLOW_PRIVATE=1 is active \u2014\\n' +\n ' fetch tool can now access private IPs (10.x, 192.168.x, 169.254.x),\\n' +\n ' cloud metadata endpoints, and plaintext HTTP. Use only on isolated networks.',\n );\n}\n\ntype LookupCallback = (\n err: NodeJS.ErrnoException | null,\n address?: string | Array<{ address: string | undefined; family: number }>,\n family?: number | undefined,\n) => void;\n\n/**\n * DNS lookup used by the undici dispatcher below. It performs the SINGLE name\n * resolution that the TCP connection actually uses, and rejects if any\n * resolved address is private/loopback/link-local. Because the connection\n * reuses exactly this result, there is no DNS-rebinding TOCTOU window between\n * the security check and the connect \u2014 closing the gap the old code documented\n * (validate with one dns.lookup, then let fetch re-resolve independently).\n * TLS still validates the certificate against the hostname (SNI is set by\n * undici from the URL), so pinning the IP does not weaken cert checking.\n */\nexport function guardedLookup(\n hostname: string,\n options: { all?: boolean | undefined; family?: number | undefined },\n callback: LookupCallback,\n): void {\n dns\n .lookup(hostname, { all: true })\n .then((records) => {\n const family = options?.family;\n const byFamily =\n family === 4 || family === 6 ? records.filter((r) => r.family === family) : records;\n const list = byFamily.length > 0 ? byFamily : records;\n if (!ALLOW_PRIVATE) {\n for (const r of list) {\n const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);\n if (bad) {\n callback(\n Object.assign(new Error(`fetch: resolved to private address ${r.address}`), {\n code: 'EAI_FAIL',\n }),\n );\n return;\n }\n }\n }\n if (options?.all) {\n callback(\n null,\n list.map((r) => ({ address: r.address, family: r.family })),\n );\n return;\n }\n const first = list.at(0);\n if (!first) {\n callback(\n Object.assign(new Error(`fetch: no address for ${hostname}`), { code: 'ENOTFOUND' }),\n );\n return;\n }\n callback(null, first.address, first.family);\n })\n .catch((err) => callback(err as NodeJS.ErrnoException));\n}\n\n// Reused across requests; guardedLookup re-validates on every new connection,\n// so connection pooling is safe. Literal-IP targets bypass lookup entirely and\n// are caught by assertNotPrivate's pre-check instead.\n// Destroyed on process exit so long-running processes (eternal autonomy,\n// MCP server mode) don't let the connection pool grow unboundedly.\nlet pinnedAgent: Agent | undefined;\nfunction getPinnedDispatcher(): Agent {\n if (!pinnedAgent) {\n pinnedAgent = new Agent({ connect: { lookup: guardedLookup as never } });\n }\n return pinnedAgent;\n}\n\nfunction dispatcherFetch(): typeof globalThis.fetch {\n // Node's built-in global fetch is backed by its own bundled undici version.\n // Passing an Agent from the workspace's `undici` package to that different\n // dispatcher ABI fails on recent Node with:\n // UND_ERR_INVALID_ARG: invalid onRequestStart method\n // Use the matching package fetch+Agent pair in real runs, but keep honoring\n // test/user fetch shims that replace globalThis.fetch after this module loads.\n return globalThis.fetch === nativeGlobalFetch\n ? (undiciFetch as unknown as typeof globalThis.fetch)\n : globalThis.fetch;\n}\n// Clean up the global dispatcher on exit \u2014 undici Agents maintain connection\n// pools and DNS caches that should be torn down in long-running processes.\n// Guard against duplicate registration (module reload/HMR would otherwise\n// accumulate listeners).\nlet _beforeExitRegistered = false;\nif (!_beforeExitRegistered) {\n _beforeExitRegistered = true;\n /* v8 ignore next 4 -- process 'beforeExit' cleanup; not deterministically triggerable in-test. */\n process.on('beforeExit', () => {\n pinnedAgent?.destroy();\n pinnedAgent = undefined;\n });\n}\n\n/**\n * SSRF-guarded fetch with manual, per-hop-revalidated redirects, exported so\n * other builtin tools (e.g. `search`) get the same protections instead of a\n * weaker `redirect: 'follow'`. Every hop is re-checked against private/loopback\n * ranges and the connection is pinned to the validated IP via the undici\n * dispatcher (no DNS-rebinding TOCTOU). `headers` defaults to the plain `fetch`\n * tool's; callers may override (e.g. a browser User-Agent for search engines).\n */\nexport async function guardedFetch(\n url: string,\n maxRedirects: number,\n signal: AbortSignal,\n headers: Record<string, string> = {\n 'user-agent': 'WrongStack/1.0 (+https://wrongstack.com)',\n accept: 'text/html,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.1',\n },\n): Promise<Response> {\n let redirectCount = 0;\n let currentUrl = url;\n for (;;) {\n // Re-validate every hop. A public host can 302 to 169.254.169.254 (cloud metadata),\n // or DNS can rebind between hops; checking only the initial URL is insufficient.\n const parsed = new URL(currentUrl);\n if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {\n throw new ToolValidationError({\n message: `fetch: redirect to unsupported protocol \"${parsed.protocol}\"`,\n field: 'url',\n });\n }\n if (parsed.protocol === 'http:' && !ALLOW_PRIVATE) {\n throw new ToolValidationError({\n message: 'fetch: redirect to http:// blocked (HTTPS required by default)',\n field: 'url',\n });\n }\n await assertNotPrivate(parsed.hostname);\n\n // The dispatcher pins the connection to the IP guardedLookup validated \u2014\n // no independent re-resolution, so DNS rebinding can't swap in a private\n // address between check and connect. `dispatcher` is a runtime option of\n // Node's undici-backed global fetch but isn't in lib.dom's RequestInit, and\n // our undici Agent's type differs from the @types/node copy \u2014 hence the\n // cast. (Verified: global fetch invokes the Agent's custom lookup.)\n const init = {\n redirect: 'manual' as const,\n signal,\n headers,\n dispatcher: getPinnedDispatcher(),\n };\n const res = await dispatcherFetch()(currentUrl, init as never as RequestInit);\n if (res.status < 300 || res.status > 399) {\n return res;\n }\n redirectCount++;\n if (redirectCount > maxRedirects) {\n throw new FetchError({\n message: `fetch: exceeded ${maxRedirects} redirects`,\n status: res.status,\n context: { url: currentUrl, maxRedirects, redirectCount },\n });\n }\n const location = res.headers.get('location');\n if (!location) {\n throw new FetchError({\n message: 'fetch: redirect status with no location header',\n status: res.status,\n context: { url: currentUrl, redirectCount },\n });\n }\n currentUrl = new URL(location, currentUrl).toString();\n }\n}\n\nexport async function assertNotPrivate(hostname: string): Promise<void> {\n if (ALLOW_PRIVATE) return;\n\n const host =\n hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;\n\n if (host === 'localhost' || host.endsWith('.localhost')) {\n throw new ToolValidationError({\n message: 'fetch: blocked localhost target',\n field: 'url',\n });\n }\n\n const ipVersion = net.isIP(host);\n if (ipVersion === 4) {\n if (isPrivateIPv4(host)) {\n throw new ToolValidationError({\n message: `fetch: blocked private/loopback address \"${host}\"`,\n field: 'url',\n });\n }\n } else if (ipVersion === 6) {\n if (isPrivateIPv6(host)) {\n throw new ToolValidationError({\n message: `fetch: blocked private/loopback address \"${host}\"`,\n field: 'url',\n });\n }\n } else {\n // Hostname \u2014 pre-flight check: resolve and reject if any record is private,\n // so we fail fast with a clear error before opening a socket. The\n // authoritative anti-rebinding control is guardedLookup on the pinned\n // undici dispatcher (see getPinnedDispatcher): it performs the single\n // resolution the connection actually uses, so there is no TOCTOU between\n // this check and the connect. Each redirect target is re-checked too.\n try {\n // Use dns.lookup for async hostname resolution (matches guardedLookup above).\n const records = await dns.lookup(host, { all: true });\n for (const r of records) {\n const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);\n if (bad) {\n throw new ToolValidationError({\n message: `fetch: resolved to private address ${r.address}`,\n field: 'url',\n });\n }\n }\n } catch (err) {\n if (err instanceof Error && err.message.startsWith('fetch:')) throw err;\n // DNS failure \u2014 let fetch handle it\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { expectDefined, FetchError, ToolValidationError } from '@wrongstack/core';\nimport type { Tool, ToolStreamEvent } from '@wrongstack/core';\nimport { guardedFetch } from './_fetch-guard.js';\nimport { toErrorMessage } from '@wrongstack/core/utils';\n\ninterface SearchResult {\n title: string;\n url: string;\n snippet: string;\n score: number;\n}\n\ninterface CacheEntry {\n results: SearchResult[];\n source: string;\n timestamp: number;\n}\n\ninterface SearchInput {\n query: string;\n num_results?: number | undefined;\n source?: 'duckduckgo' | 'google' | 'bing' | undefined;\n skip_cache?: boolean | undefined;\n}\n\ninterface SearchOutput {\n query: string;\n results: { title: string; url: string; snippet: string }[];\n source: string;\n truncated: boolean;\n cached: boolean;\n}\n\nconst DEFAULT_NUM = 10;\nconst MAX_RESULTS = 50;\nconst TIMEOUT_MS = 15_000;\nconst CACHE_TTL_MS = 300_000; // 5 minutes \u2014 matches the former web-search plugin default\nconst CACHE_MAX_ENTRIES = 200;\n\n// Module-level cache shared across calls within a single agent run. Keyed by\n// `<source>:<query>`. This is intentionally process-local (not persisted) \u2014\n// it exists to avoid hammering a search engine when the model rephrases the\n// same query a few turns apart.\nconst cache = new Map<string, CacheEntry>();\n\nexport const searchTool: Tool<SearchInput, SearchOutput> = {\n name: 'search',\n category: 'Search',\n description:\n 'Perform a web search and return results with title, URL, and snippet. Use this when you need up-to-date external information that is not in the local codebase. Results are cached (5 min TTL) and deduplicated by URL.',\n usageHint:\n 'Good for: API documentation, error messages, library usage examples, current best practices.\\n\\n' +\n '- Prefer specific queries over very broad ones.\\n' +\n '- Results go through the guarded fetch system (same protections as the `fetch` tool).\\n' +\n '- Supports duckduckgo (default), google, and bing sources.\\n' +\n '- Set `skip_cache: true` to force a fresh search.\\n' +\n '- This is often better than the model trying to recall outdated knowledge.',\n permission: 'auto',\n mutating: false,\n capabilities: ['net.outbound'],\n icon: 'search',\n timeoutMs: TIMEOUT_MS,\n inputSchema: {\n type: 'object',\n properties: {\n query: { type: 'string', description: 'Search query' },\n num_results: {\n type: 'integer',\n description: 'Number of results (1-50, default 10)',\n minimum: 1,\n maximum: MAX_RESULTS,\n },\n source: {\n type: 'string',\n enum: ['duckduckgo', 'google', 'bing'],\n description: 'Search engine to use (default: duckduckgo)',\n },\n skip_cache: {\n type: 'boolean',\n description: 'Skip the in-memory cache and force a fresh search (default: false)',\n },\n },\n required: ['query'],\n },\n async execute(input, ctx, opts) {\n let final: SearchOutput | undefined;\n const executeStream = searchTool.executeStream;\n if (!executeStream) throw new Error('searchTool: stream execution unavailable');\n for await (const ev of executeStream(input, ctx, opts)) {\n if (ev.type === 'final') final = ev.output;\n }\n if (!final) throw new Error('search: stream ended without final event');\n return final;\n },\n async *executeStream(input, _ctx, opts): AsyncGenerator<ToolStreamEvent<SearchOutput>> {\n if (!input?.query || input.query.trim() === '') {\n throw new ToolValidationError({\n message: 'search: query is required and must be a non-empty string',\n field: 'query',\n });\n }\n\n const num = Math.max(1, Math.min(input.num_results ?? DEFAULT_NUM, MAX_RESULTS));\n const source = input.source ?? 'duckduckgo';\n const skipCache = input.skip_cache ?? false;\n const cacheKey = `${source}:${input.query}`;\n\n // --- Cache hit ---\n if (!skipCache) {\n const entry = cache.get(cacheKey);\n if (entry && Date.now() - entry.timestamp < CACHE_TTL_MS) {\n const results = entry.results.map((r) => ({\n title: r.title,\n url: r.url,\n snippet: r.snippet,\n }));\n yield {\n type: 'log',\n text: `Cache hit for \"${input.query}\" (${source})`,\n data: { source, query: input.query, cached: true },\n };\n yield {\n type: 'partial_output',\n text: `${results.length} cached results from ${entry.source}`,\n data: { count: results.length, cached: true, source: entry.source },\n };\n yield {\n type: 'final',\n output: {\n query: input.query,\n results: results.slice(0, num),\n source: entry.source,\n truncated: results.length >= num,\n cached: true,\n },\n };\n return;\n }\n }\n\n yield {\n type: 'log',\n text: `Querying ${source} for \"${input.query}\"\u2026`,\n data: { source, query: input.query, cached: false },\n };\n\n let rawResults: SearchResult[];\n let effectiveSource: SearchOutput['source'] = source;\n switch (source) {\n case 'duckduckgo':\n rawResults = await duckduckgoSearch(input.query, num, opts.signal);\n break;\n case 'google':\n rawResults = await googleSearch(input.query, num, opts.signal);\n break;\n case 'bing':\n rawResults = await bingSearch(input.query, num, opts.signal);\n break;\n default:\n throw new ToolValidationError({\n message: `search: unknown source \"${source}\"`,\n field: 'source',\n });\n }\n\n let ranked = rankSearchResults(rawResults, input.query);\n if (source !== 'duckduckgo' && shouldFallbackToDuckDuckGo(ranked, input.query)) {\n yield {\n type: 'log',\n text: `${source} returned no relevant static results; falling back to duckduckgo`,\n data: { source, fallback: 'duckduckgo', query: input.query },\n };\n rawResults = await duckduckgoSearch(input.query, num, opts.signal);\n ranked = rankSearchResults(rawResults, input.query);\n effectiveSource = 'duckduckgo';\n }\n\n const finalResults = ranked.slice(0, num);\n\n // --- Store in cache ---\n cache.set(cacheKey, { results: ranked, source: effectiveSource, timestamp: Date.now() });\n pruneCacheEntries();\n\n yield {\n type: 'partial_output',\n text: `${finalResults.length} results from ${effectiveSource}`,\n data: { count: finalResults.length, cached: false, source: effectiveSource },\n };\n yield {\n type: 'final',\n output: {\n query: input.query,\n results: finalResults.map((r) => ({\n title: r.title,\n url: r.url,\n snippet: r.snippet,\n })),\n source: effectiveSource,\n truncated: finalResults.length >= num,\n cached: false,\n },\n };\n },\n};\n\n// ---------------------------------------------------------------------------\n// Cache helpers\n// ---------------------------------------------------------------------------\n\n/** Drop stale entries, then evict oldest insertions to cap retained result data. */\nfunction pruneCacheEntries(): void {\n const cutoff = Date.now() - CACHE_TTL_MS * 2;\n for (const [key, entry] of cache.entries()) {\n if (entry.timestamp < cutoff) cache.delete(key);\n }\n while (cache.size > CACHE_MAX_ENTRIES) {\n const oldestKey = cache.keys().next().value;\n if (oldestKey === undefined) break;\n cache.delete(oldestKey);\n }\n}\n\n/** Exposed for tests so they can reset the module-level cache between cases. */\nexport function __clearSearchCache(): void {\n cache.clear();\n}\n\n// ---------------------------------------------------------------------------\n// Ranking\n// ---------------------------------------------------------------------------\n\nfunction rankSearchResults(results: SearchResult[], query: string): SearchResult[] {\n const seenUrls = new Set<string>();\n const deduped: SearchResult[] = [];\n for (const r of results) {\n const noQuery = r.url.split('?')[0] ?? r.url;\n const normalized = noQuery.split('#')[0] ?? r.url;\n if (!seenUrls.has(normalized) && r.url.startsWith('http')) {\n seenUrls.add(normalized);\n deduped.push(r);\n }\n }\n return scoreResults(deduped, query);\n}\n\nfunction scoreResults(results: SearchResult[], query: string): SearchResult[] {\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n return results\n .map((r) => {\n const titleLower = r.title.toLowerCase();\n const snippetLower = r.snippet.toLowerCase();\n let score = r.score;\n for (const term of terms) {\n if (titleLower.includes(term)) score += 2;\n if (snippetLower.includes(term)) score += 1;\n }\n return { ...r, score };\n })\n .sort((a, b) => b.score - a.score);\n}\n\nfunction shouldFallbackToDuckDuckGo(results: SearchResult[], query: string): boolean {\n if (results.length === 0) return true;\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length >= 3);\n if (terms.length === 0) return false;\n return !results.some((r) => {\n const haystack = `${r.title} ${r.url} ${r.snippet}`.toLowerCase();\n return terms.some((term) => haystack.includes(term));\n });\n}\n\n// ---------------------------------------------------------------------------\n// Search engines\n// ---------------------------------------------------------------------------\n\nasync function duckduckgoSearch(\n query: string,\n num: number,\n signal: AbortSignal,\n): Promise<SearchResult[]> {\n const encoded = encodeURIComponent(query);\n const url = `https://lite.duckduckgo.com/lite/?q=${encoded}&kd=-1&kl=wt-wt`;\n\n try {\n const response = await fetchWithTimeout(url, signal, TIMEOUT_MS);\n const html = await response.text();\n return parseDuckDuckGo(html, num);\n } catch (err) {\n console.log(\n JSON.stringify({ level: 'debug', event: 'search_failed', query, error: toErrorMessage(err) }),\n );\n // Return a sentinel result that survives the dedup filter (which drops\n // non-http URLs). Using a placeholder http URL keeps it visible to the\n // caller so they know the search failed rather than silently getting [].\n return [{ title: 'Search unavailable', url: 'https://duckduckgo.com/unavailable', snippet: 'Could not reach DuckDuckGo', score: 0 }];\n }\n}\n\nfunction takeFrom<T>(iter: Iterable<T>, max: number): T[] {\n const out: T[] = [];\n for (const item of iter) {\n if (out.length >= max) break;\n out.push(item);\n }\n return out;\n}\n\nfunction parseDuckDuckGo(html: string, num: number): SearchResult[] {\n const results: SearchResult[] = [];\n const linkRegex = /<a\\b([^>]*\\bclass=([\"'])[^\"']*\\bresult-link\\b[^\"']*\\2[^>]*)>([\\s\\S]*?)<\\/a>/gi;\n const snippetRegex =\n /<([a-z0-9]+)\\b([^>]*\\bclass=([\"'])[^\"']*\\bresult-snippet\\b[^\"']*\\3[^>]*)>([\\s\\S]*?)<\\/\\1>/gi;\n\n const linkMatches = takeFrom(\n [...html.matchAll(linkRegex)]\n .map((m) => {\n const attrs = expectDefined(m[1]);\n const href = getHtmlAttr(attrs, 'href');\n const title = stripTags(expectDefined(m[3]));\n return href && title ? { url: normalizeDuckDuckGoUrl(href), title } : undefined;\n })\n .filter((m): m is { url: string; title: string } => m !== undefined),\n num,\n );\n\n const snippetMatches = takeFrom(\n [...html.matchAll(snippetRegex)]\n .filter((m) => m[4])\n .map((m) => stripTags(expectDefined(m[4]))),\n num,\n );\n\n for (let i = 0; i < linkMatches.length && i < num; i++) {\n const entry = linkMatches[i];\n if (entry) {\n results.push({\n title: entry.title ?? '',\n url: entry.url ?? '',\n snippet: snippetMatches[i] ?? '',\n score: 1,\n });\n }\n }\n\n return results;\n}\n\nfunction getHtmlAttr(attrs: string, name: string): string | undefined {\n const quoted = new RegExp(`\\\\b${name}\\\\s*=\\\\s*([\"'])(.*?)\\\\1`, 'i').exec(attrs);\n if (quoted?.[2]) return decodeHtmlEntities(quoted[2]);\n const unquoted = new RegExp(`\\\\b${name}\\\\s*=\\\\s*([^\\\\s>]+)`, 'i').exec(attrs);\n return unquoted?.[1] ? decodeHtmlEntities(unquoted[1]) : undefined;\n}\n\nfunction normalizeDuckDuckGoUrl(raw: string): string {\n if (raw.startsWith('//')) return `https:${raw}`;\n if (!raw.startsWith('/')) return raw;\n if (!raw.startsWith('/l/')) return raw;\n try {\n const url = new URL(raw, 'https://duckduckgo.com');\n const uddg = url.searchParams.get('uddg');\n return uddg?.startsWith('http') ? uddg : url.toString();\n } catch {\n return raw;\n }\n}\n\nasync function googleSearch(query: string, num: number, signal: AbortSignal): Promise<SearchResult[]> {\n const encoded = encodeURIComponent(query);\n const url = `https://www.google.com/search?q=${encoded}&hl=en`;\n\n const html = await fetchWithTimeout(url, signal, TIMEOUT_MS)\n .then((r) => r.text())\n .catch(() => '');\n\n return parseGoogleResults(html, num);\n}\n\nfunction parseGoogleResults(html: string, num: number): SearchResult[] {\n const results: SearchResult[] = [];\n const titleRegex = /<h3[^>]*class=\"[^\"]*DKV84\"[^>]*>([^<]+)<\\/h3>/gi;\n const urlRegex = /<cite[^>]*>([^<]+)<\\/cite>/gi;\n const snippetRegex = /<span[^>]*class=\"[^\"]*aXCZ0b[^>]*>([^<]+)<\\/span>/gi;\n\n const titles = takeFrom(\n [...html.matchAll(titleRegex)].filter((m) => m[1]).map((m) => stripTags(expectDefined(m[1]))),\n num,\n );\n\n const urls = takeFrom(\n [...html.matchAll(urlRegex)]\n .filter((m) => m[1])\n .map((m) => stripTags(expectDefined(m[1])).replace(/^\\*(https?:\\/\\/[^\\s]+).*$/, '$1'))\n .filter((u) => u.startsWith('http')),\n num,\n );\n\n const snippets = takeFrom(\n [...html.matchAll(snippetRegex)].filter((m) => m[1]).map((m) => stripTags(expectDefined(m[1]))),\n num,\n );\n\n for (let i = 0; i < Math.min(titles.length, num); i++) {\n results.push({\n title: titles[i] ?? '',\n url: urls[i] ?? '',\n snippet: snippets[i] ?? '',\n score: 1,\n });\n }\n\n return results;\n}\n\nasync function bingSearch(query: string, num: number, signal: AbortSignal): Promise<SearchResult[]> {\n const encoded = encodeURIComponent(query);\n const url = `https://www.bing.com/search?q=${encoded}`;\n\n const html = await fetchWithTimeout(url, signal, TIMEOUT_MS)\n .then((r) => r.text())\n .catch(() => '');\n\n return parseBingResults(html, num);\n}\n\nfunction parseBingResults(html: string, num: number): SearchResult[] {\n const results: SearchResult[] = [];\n const blocks = [...html.matchAll(/<li\\b[^>]*class=([\"'])[^\"']*\\bb_algo\\b[^\"']*\\1[^>]*>([\\s\\S]*?)(?=<li\\b[^>]*class=([\"'])[^\"']*\\bb_algo\\b[^\"']*\\3|<\\/ol>)/gi)]\n .map((m) => expectDefined(m[2]));\n const candidates = blocks.length > 0 ? blocks : [html];\n\n const entries = takeFrom(candidates.flatMap((block) => {\n const titleMatch = /<h2[^>]*>\\s*<a\\b([^>]*)>([\\s\\S]*?)<\\/a>\\s*<\\/h2>/i.exec(block);\n if (!titleMatch) return [];\n const href = getHtmlAttr(expectDefined(titleMatch[1]), 'href');\n const title = stripTags(expectDefined(titleMatch[2]));\n if (!href || !title) return [];\n const snippetMatch = /<p\\b[^>]*class=([\"'])[^\"']*\\b(?:b_paractl|b_lineclamp\\d*)\\b[^\"']*\\1[^>]*>([\\s\\S]*?)<\\/p>/i.exec(block)\n ?? /<p\\b[^>]*>([\\s\\S]*?)<\\/p>/i.exec(block);\n const snippet = snippetMatch ? stripTags(expectDefined(snippetMatch.at(-1))) : '';\n return [{ url: normalizeBingUrl(href), title, snippet, score: 1 }];\n }), num);\n\n for (let i = 0; i < entries.length; i++) {\n const entry = entries[i];\n if (entry) {\n results.push({\n title: entry.title ?? '',\n url: entry.url ?? '',\n snippet: entry.snippet ?? '',\n score: 1,\n });\n }\n }\n\n return results;\n}\n\nfunction normalizeBingUrl(raw: string): string {\n try {\n const url = new URL(raw);\n if (url.hostname.endsWith('bing.com') && url.pathname.startsWith('/ck/')) {\n const encoded = url.searchParams.get('u');\n const decoded = decodeBingTarget(encoded);\n if (decoded?.startsWith('http')) return decoded;\n }\n } catch {\n // Fall through to the raw URL.\n }\n return raw;\n}\n\nfunction decodeBingTarget(encoded: string | null): string | undefined {\n if (!encoded) return undefined;\n const payload = encoded.startsWith('a1') ? encoded.slice(2) : encoded;\n try {\n const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=');\n return Buffer.from(padded, 'base64').toString('utf8');\n } catch {\n return undefined;\n }\n}\n\n// ---------------------------------------------------------------------------\n// HTTP helper\n// ---------------------------------------------------------------------------\n\nasync function fetchWithTimeout(\n url: string,\n signal: AbortSignal,\n timeoutMs: number,\n): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n\n const fetchSignal = anySignal(signal, controller.signal);\n try {\n // F-05: route through the SSRF-guarded fetch (private-IP blocking, HTTPS,\n // DNS-pinned dispatcher, per-hop redirect re-validation) instead of a bare\n // `fetch` with `redirect: 'follow'`. Search hosts are fixed/trusted, but\n // this closes the residual \"engine 30x \u2192 internal address\" redirect risk.\n const res = await guardedFetch(url, 5, fetchSignal, {\n 'user-agent':\n 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n });\n clearTimeout(timer);\n return res;\n } catch (e) {\n clearTimeout(timer);\n if (e instanceof FetchError) {\n throw e;\n }\n throw new FetchError({\n message: `search: failed to fetch ${url}`,\n status: 0,\n context: { url },\n cause: e,\n });\n }\n}\n\nfunction anySignal(...signals: AbortSignal[]): AbortSignal {\n // Native combinator (Node \u2265 20.3; this repo requires \u2265 22). The previous\n // hand-rolled version registered a non-once 'abort' listener on every\n // input signal and never removed it \u2014 the run-level signal outlives each\n // request, so listeners (and their closures) accumulated one per search\n // call for the life of the agent run.\n return AbortSignal.any(signals);\n}\n\nfunction stripTags(html: string): string {\n return decodeHtmlEntities(html.replace(/<[^>]+>/g, '')).trim();\n}\n\nfunction decodeHtmlEntities(text: string): string {\n return text\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\");\n}\n", "import * as dns from 'node:dns/promises';\nimport * as net from 'node:net';\nimport {\n FetchError,\n ToolValidationError,\n isPrivateIPv4,\n isPrivateIPv6,\n} from '@wrongstack/core';\nimport { Agent, fetch as undiciFetch } from 'undici';\n\n/**\n * SSRF guard machinery shared by the `fetch` and `search` tools. Split out of\n * fetch.ts so entries that only need `guardedFetch` (search) don't bundle the\n * whole fetch tool. Everything here is security-critical: the pinned undici\n * dispatcher performs the SINGLE DNS resolution the TCP connection uses, so\n * there is no DNS-rebinding TOCTOU between validation and connect.\n */\n\nconst nativeGlobalFetch = globalThis.fetch;\n\nexport const ALLOW_PRIVATE = process.env['WRONGSTACK_FETCH_ALLOW_PRIVATE'] === '1';\n/* v8 ignore next 8 -- module-load-time opt-in warning; gated on an env var not set during tests. */\nif (ALLOW_PRIVATE && !process.env['CI']) {\n console.warn(\n '[WrongStack] WARNING: WRONGSTACK_FETCH_ALLOW_PRIVATE=1 is active \u2014\\n' +\n ' fetch tool can now access private IPs (10.x, 192.168.x, 169.254.x),\\n' +\n ' cloud metadata endpoints, and plaintext HTTP. Use only on isolated networks.',\n );\n}\n\ntype LookupCallback = (\n err: NodeJS.ErrnoException | null,\n address?: string | Array<{ address: string | undefined; family: number }>,\n family?: number | undefined,\n) => void;\n\n/**\n * DNS lookup used by the undici dispatcher below. It performs the SINGLE name\n * resolution that the TCP connection actually uses, and rejects if any\n * resolved address is private/loopback/link-local. Because the connection\n * reuses exactly this result, there is no DNS-rebinding TOCTOU window between\n * the security check and the connect \u2014 closing the gap the old code documented\n * (validate with one dns.lookup, then let fetch re-resolve independently).\n * TLS still validates the certificate against the hostname (SNI is set by\n * undici from the URL), so pinning the IP does not weaken cert checking.\n */\nexport function guardedLookup(\n hostname: string,\n options: { all?: boolean | undefined; family?: number | undefined },\n callback: LookupCallback,\n): void {\n dns\n .lookup(hostname, { all: true })\n .then((records) => {\n const family = options?.family;\n const byFamily =\n family === 4 || family === 6 ? records.filter((r) => r.family === family) : records;\n const list = byFamily.length > 0 ? byFamily : records;\n if (!ALLOW_PRIVATE) {\n for (const r of list) {\n const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);\n if (bad) {\n callback(\n Object.assign(new Error(`fetch: resolved to private address ${r.address}`), {\n code: 'EAI_FAIL',\n }),\n );\n return;\n }\n }\n }\n if (options?.all) {\n callback(\n null,\n list.map((r) => ({ address: r.address, family: r.family })),\n );\n return;\n }\n const first = list.at(0);\n if (!first) {\n callback(\n Object.assign(new Error(`fetch: no address for ${hostname}`), { code: 'ENOTFOUND' }),\n );\n return;\n }\n callback(null, first.address, first.family);\n })\n .catch((err) => callback(err as NodeJS.ErrnoException));\n}\n\n// Reused across requests; guardedLookup re-validates on every new connection,\n// so connection pooling is safe. Literal-IP targets bypass lookup entirely and\n// are caught by assertNotPrivate's pre-check instead.\n// Destroyed on process exit so long-running processes (eternal autonomy,\n// MCP server mode) don't let the connection pool grow unboundedly.\nlet pinnedAgent: Agent | undefined;\nfunction getPinnedDispatcher(): Agent {\n if (!pinnedAgent) {\n // Undici 8 enables HTTP/2 negotiation by default. Its H2 stream can emit a\n // late, unhandled `error` after fetch already rejected when the peer closes\n // the TLS socket, terminating the whole WrongStack process. The fetch tool\n // does not need multiplexing, so retain the proven HTTP/1.1 transport while\n // preserving the pinned DNS lookup and connection pooling.\n pinnedAgent = new Agent({ allowH2: false, connect: { lookup: guardedLookup as never } });\n }\n return pinnedAgent;\n}\n\nfunction dispatcherFetch(): typeof globalThis.fetch {\n // Node's built-in global fetch is backed by its own bundled undici version.\n // Passing an Agent from the workspace's `undici` package to that different\n // dispatcher ABI fails on recent Node with:\n // UND_ERR_INVALID_ARG: invalid onRequestStart method\n // Use the matching package fetch+Agent pair in real runs, but keep honoring\n // test/user fetch shims that replace globalThis.fetch after this module loads.\n return globalThis.fetch === nativeGlobalFetch\n ? (undiciFetch as unknown as typeof globalThis.fetch)\n : globalThis.fetch;\n}\n// Clean up the global dispatcher on exit \u2014 undici Agents maintain connection\n// pools and DNS caches that should be torn down in long-running processes.\n// Guard against duplicate registration (module reload/HMR would otherwise\n// accumulate listeners).\nlet _beforeExitRegistered = false;\nif (!_beforeExitRegistered) {\n _beforeExitRegistered = true;\n /* v8 ignore next 4 -- process 'beforeExit' cleanup; not deterministically triggerable in-test. */\n process.on('beforeExit', () => {\n pinnedAgent?.destroy();\n pinnedAgent = undefined;\n });\n}\n\n/**\n * SSRF-guarded fetch with manual, per-hop-revalidated redirects, exported so\n * other builtin tools (e.g. `search`) get the same protections instead of a\n * weaker `redirect: 'follow'`. Every hop is re-checked against private/loopback\n * ranges and the connection is pinned to the validated IP via the undici\n * dispatcher (no DNS-rebinding TOCTOU). `headers` defaults to the plain `fetch`\n * tool's; callers may override (e.g. a browser User-Agent for search engines).\n */\nexport async function guardedFetch(\n url: string,\n maxRedirects: number,\n signal: AbortSignal,\n headers: Record<string, string> = {\n 'user-agent': 'WrongStack/1.0 (+https://wrongstack.com)',\n accept: 'text/html,application/json;q=0.9,text/plain;q=0.8,*/*;q=0.1',\n },\n): Promise<Response> {\n let redirectCount = 0;\n let currentUrl = url;\n for (;;) {\n // Re-validate every hop. A public host can 302 to 169.254.169.254 (cloud metadata),\n // or DNS can rebind between hops; checking only the initial URL is insufficient.\n const parsed = new URL(currentUrl);\n if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {\n throw new ToolValidationError({\n message: `fetch: redirect to unsupported protocol \"${parsed.protocol}\"`,\n field: 'url',\n });\n }\n if (parsed.protocol === 'http:' && !ALLOW_PRIVATE) {\n throw new ToolValidationError({\n message: 'fetch: redirect to http:// blocked (HTTPS required by default)',\n field: 'url',\n });\n }\n await assertNotPrivate(parsed.hostname);\n\n // The dispatcher pins the connection to the IP guardedLookup validated \u2014\n // no independent re-resolution, so DNS rebinding can't swap in a private\n // address between check and connect. `dispatcher` is a runtime option of\n // Node's undici-backed global fetch but isn't in lib.dom's RequestInit, and\n // our undici Agent's type differs from the @types/node copy \u2014 hence the\n // cast. (Verified: global fetch invokes the Agent's custom lookup.)\n const init = {\n redirect: 'manual' as const,\n signal,\n headers,\n dispatcher: getPinnedDispatcher(),\n };\n const res = await dispatcherFetch()(currentUrl, init as never as RequestInit);\n if (res.status < 300 || res.status > 399) {\n return res;\n }\n redirectCount++;\n if (redirectCount > maxRedirects) {\n throw new FetchError({\n message: `fetch: exceeded ${maxRedirects} redirects`,\n status: res.status,\n context: { url: currentUrl, maxRedirects, redirectCount },\n });\n }\n const location = res.headers.get('location');\n if (!location) {\n throw new FetchError({\n message: 'fetch: redirect status with no location header',\n status: res.status,\n context: { url: currentUrl, redirectCount },\n });\n }\n currentUrl = new URL(location, currentUrl).toString();\n }\n}\n\nexport async function assertNotPrivate(hostname: string): Promise<void> {\n if (ALLOW_PRIVATE) return;\n\n const host =\n hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;\n\n if (host === 'localhost' || host.endsWith('.localhost')) {\n throw new ToolValidationError({\n message: 'fetch: blocked localhost target',\n field: 'url',\n });\n }\n\n const ipVersion = net.isIP(host);\n if (ipVersion === 4) {\n if (isPrivateIPv4(host)) {\n throw new ToolValidationError({\n message: `fetch: blocked private/loopback address \"${host}\"`,\n field: 'url',\n });\n }\n } else if (ipVersion === 6) {\n if (isPrivateIPv6(host)) {\n throw new ToolValidationError({\n message: `fetch: blocked private/loopback address \"${host}\"`,\n field: 'url',\n });\n }\n } else {\n // Hostname \u2014 pre-flight check: resolve and reject if any record is private,\n // so we fail fast with a clear error before opening a socket. The\n // authoritative anti-rebinding control is guardedLookup on the pinned\n // undici dispatcher (see getPinnedDispatcher): it performs the single\n // resolution the connection actually uses, so there is no TOCTOU between\n // this check and the connect. Each redirect target is re-checked too.\n try {\n // Use dns.lookup for async hostname resolution (matches guardedLookup above).\n const records = await dns.lookup(host, { all: true });\n for (const r of records) {\n const bad = r.family === 4 ? isPrivateIPv4(r.address) : isPrivateIPv6(r.address);\n if (bad) {\n throw new ToolValidationError({\n message: `fetch: resolved to private address ${r.address}`,\n field: 'url',\n });\n }\n }\n } catch (err) {\n if (err instanceof ToolValidationError) throw err;\n // DNS failure \u2014 let fetch handle it\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["FetchError", "ToolValidationError", "ToolValidationError", "FetchError"]
|
|
7
7
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type Context, type PlanFile, type PlanItem, type SerializedTaskGraph, type TaskFile, type TaskItem, type TodoItem } from '@wrongstack/core';
|
|
2
|
+
import { type KanbanBoard, type KanbanColumn, type KanbanTask } from '@wrongstack/kanban';
|
|
3
|
+
/** The canonical workflow shared by WebUI and TUI session boards. */
|
|
4
|
+
export declare const SESSION_KANBAN_COLUMNS: KanbanColumn[];
|
|
5
|
+
/** Create (or migrate) the single Kanban board owned by a session. */
|
|
6
|
+
export declare function ensureSessionKanbanBoard(projectRoot: string | undefined, sessionId: string): Promise<KanbanBoard | null>;
|
|
7
|
+
/** Remove a particular inactive session's system-owned board when it has no cards. */
|
|
8
|
+
export declare function cleanupSessionKanbanBoardIfEmpty(projectRoot: string | undefined, sessionId: string): Promise<string[]>;
|
|
9
|
+
/** Prune stale empty session boards while preserving manual and live boards. */
|
|
10
|
+
export declare function cleanupEmptySessionKanbanBoards(projectRoot: string | undefined, activeSessionId?: string): Promise<string[]>;
|
|
11
|
+
export declare function todoListToSerializedGraph(todos: readonly TodoItem[], sessionId: string): SerializedTaskGraph;
|
|
12
|
+
export declare function taskFileToSerializedGraph(tasks: readonly TaskItem[], sessionId: string): SerializedTaskGraph;
|
|
13
|
+
export declare function planFileToSerializedGraph(items: readonly PlanItem[], sessionId: string): SerializedTaskGraph;
|
|
14
|
+
export declare function projectSessionTodosToKanban(projectRoot: string | undefined, todos: readonly TodoItem[], sessionId: string): Promise<KanbanBoard | null>;
|
|
15
|
+
export declare function projectSessionTasksToKanban(projectRoot: string | undefined, tasks: readonly TaskItem[], sessionId: string): Promise<KanbanBoard | null>;
|
|
16
|
+
export declare function projectSessionPlanToKanban(projectRoot: string | undefined, items: readonly PlanItem[], sessionId: string): Promise<KanbanBoard | null>;
|
|
17
|
+
export declare function mirrorSessionTodosToKanban(projectRoot: string | undefined, todos: readonly TodoItem[], sessionId: string): void;
|
|
18
|
+
export declare function mirrorSessionTasksToKanban(projectRoot: string | undefined, tasks: readonly TaskItem[], sessionId: string): void;
|
|
19
|
+
export declare function mirrorSessionPlanToKanban(projectRoot: string | undefined, items: readonly PlanItem[], sessionId: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* Bind all live session work paths to Kanban. Todo changes are observed from
|
|
22
|
+
* ConversationState; plan/task sidecars are watched so slash commands, WebUI,
|
|
23
|
+
* plugins, and tools all pass through the same board.
|
|
24
|
+
*/
|
|
25
|
+
export declare function attachSessionKanbanMirror(context: Context): () => void;
|
|
26
|
+
/** Fully hydrate a session board before a host announces the session as ready. */
|
|
27
|
+
export declare function hydrateSessionKanban(context: Context): Promise<KanbanBoard | null>;
|
|
28
|
+
export interface SessionKanbanSourceUpdate {
|
|
29
|
+
source: 'todo' | 'task' | 'plan' | null;
|
|
30
|
+
todos?: TodoItem[] | undefined;
|
|
31
|
+
tasks?: TaskFile | undefined;
|
|
32
|
+
plan?: PlanFile | undefined;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Replace the tactical todo list from the current cards on a session-owned board.
|
|
36
|
+
*
|
|
37
|
+
* Existing mirrored todo cards retain their stable source ids. New cards created
|
|
38
|
+
* by a Kanban worker become todos under their card ids, so reassessment can add,
|
|
39
|
+
* split, merge, reprioritize, or remove work without being overwritten by the
|
|
40
|
+
* todo list that happened to exist when the run started.
|
|
41
|
+
*/
|
|
42
|
+
export declare function applySessionKanbanBoardToTodos(context: Context, board: KanbanBoard): TodoItem[];
|
|
43
|
+
/** Reflect a TUI/WebUI Kanban card edit back to its originating work list. */
|
|
44
|
+
export declare function applySessionKanbanTaskToSource(context: Context, task: KanbanTask, options?: {
|
|
45
|
+
remove?: boolean | undefined;
|
|
46
|
+
}): Promise<SessionKanbanSourceUpdate>;
|
|
47
|
+
//# sourceMappingURL=session-kanban.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-kanban.d.ts","sourceRoot":"","sources":["../src/session-kanban.ts"],"names":[],"mappings":"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"}
|