diff --git a/REBUILD-INSTRUCTION.md b/REBUILD-INSTRUCTION.md new file mode 100644 index 00000000..a3baff87 --- /dev/null +++ b/REBUILD-INSTRUCTION.md @@ -0,0 +1,57 @@ +# Pentestcode Rebuild — Full Instruction + +Состояние: 2026-11-10. Бинарь пересобран с 6 харнесс-фиксами. + +## 6 фиксов (fork 7megaumka7/pentestcode) + +| Commit | Фиксы | +|---|---| +| `7d6d853` | resolveVulnWrite (evidence gate), atomicWriteFileSync (temp+fsync+rename), withFileLock (O_EXCL lockfile) | +| `a822c84` | relationship-ID (резолв title→stable .id), agent-liveness (exp-backoff+jitter auto-retry), auto-chain-link (auto-edge после add_vuln) | + +## Rebuild process + +```bash +git clone https://github.com/7megaumka7/pentestcode.git # HEAD = a822c84 +cd pentestcode + +# FIX (обязательно): photon-node patch дрейфнул и не применяется. +# 1. package.json -> patchedDependencies -> УДАЛИТЬ "@silvia-odwyer/photon-node@0.3.4" +# 2. удалить файл patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch + +bun install +cd packages/opencode && bun run build --single --skip-embed-web-ui +# готовый бинарь: packages/opencode/dist/pentestcode-linux-x64/bin/pentestcode +``` + +## Replace installed binary (ПОСЛЕ остановки текущей сессии) + +```bash +NEW=/tmp/pc-src/packages/opencode/dist/pentestcode-linux-x64/bin/pentestcode +INSTALLED=~/.npm-global/lib/node_modules/pentestcode-ai/bin/pentestcode.exe +# На ходу заменить НЕЛЬЗЯ — "Text file busy" (бинарь запущен). +# 1. остановить текущую сессию pentestcode +cp "$INSTALLED" "$INSTALLED.bak-$(date +%Y%m%dT%H%M%SZ)" +cp "$NEW" "$INSTALLED" +chmod +x "$INSTALLED" +``` + +## Verify (после рестарта) + +```bash +pentestcode --version # ожидается 0.0.0-main-202611092119 +~/.pentestcode/bin/bootstrap # проверяет Burp + окружение + тулзы +``` + +## Что закрывают 6 фиксов + +- write-gate 0% (record-finding обходится, тонкие confirmed) → resolveVulnWrite +- state corruption / lost-update (двойная запись) → atomicWriteFileSync + withFileLock +- dangling chain edges (6 «unresolved», title-хрупкие) → relationship-ID +- dead-dispatch 19% (осиротевшие агенты) → agent-liveness +- chain-density 0% (забываю link) → auto-chain-link + +## Тесты (перед сборкой) + +- bun test (core): 1176 pass / 0 fail +- typecheck clean, oxlint 0 errors diff --git a/package.json b/package.json index 7dfb3466..c5d17401 100644 --- a/package.json +++ b/package.json @@ -130,7 +130,6 @@ "patchedDependencies": { "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch", @@ -143,4 +142,4 @@ "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch" } -} +} \ No newline at end of file diff --git a/packages/core/src/engagement/orchestrator.ts b/packages/core/src/engagement/orchestrator.ts index 410e163a..ac19ed6e 100644 --- a/packages/core/src/engagement/orchestrator.ts +++ b/packages/core/src/engagement/orchestrator.ts @@ -215,3 +215,220 @@ export function detectStall( } return stalls.sort((a, b) => b.idleMs - a.idleMs) } + +// --- Agent-liveness auto-retry (silent-subagent watchdog) --- +// `detectStall` guards the DAG-deadlock case (a stalled task strands its +// DEPENDENTS). This probe guards a different failure: a subagent that is still +// "in flight" and still emitting output, but has produced ZERO new state markers +// (state_update mutations that change engagement state) for N minutes — it is +// busy-but-unproductive, and its node never completes so the wave never goes +// quiescent. For each such node we compute a ONE-SHOT auto-retry plan: a reduced +// scope plus an exponential-backoff-with-jitter delay. Exactly ONE retry is +// planned per node (`maxRetries` default 1); the caller (coordinator/harness +// driver) is responsible for actually dispatching the retry — this module, like +// the rest of orchestrator, computes and never spawns. + +export interface LivenessRetry { + /** The in-flight task whose subagent has produced no new state markers. */ + node: TaskGraph.TaskNode + /** How long (ms) since the subagent's last state marker (fallback: dispatch age). */ + silentMs: number + /** Reduced-scope instruction for the retry (narrower than the original task). */ + reducedScope: string + /** Delay (ms) before the retry should launch — exponential backoff + jitter. */ + delayMs: number + /** Zero-based retry attempt index this plan is for (0 = first retry). */ + attempt: number +} + +/** + * Exponential backoff with full jitter, bounded to [baseDelayMs, maxDelayMs]. + * `random` is injectable so tests can pin the jitter term; defaults to + * Math.random. A jitter of 0 disables jitter (deterministic backoff). + */ +export function retryDelayMs( + attempt: number, + opts: { + baseDelayMs?: number + maxDelayMs?: number + jitter?: number + random?: () => number + } = {}, +): number { + const base = opts.baseDelayMs ?? 30_000 + const max = opts.maxDelayMs ?? 300_000 + const jitter = opts.jitter ?? 0.2 + const random = opts.random ?? Math.random + const exponential = Math.min(base * 2 ** attempt, max) + const spread = exponential * jitter + const delay = exponential + (random() * 2 - 1) * spread + return Math.round(Math.max(0, Math.min(max, delay))) +} + +/** + * Narrow a task into a reduced-scope retry instruction. The retry focuses on the + * single named target (dropping broad multi-vector technique hints) or, absent a + * target, on the single highest-value sub-vector of the description. + */ +function reducedScopeFor(node: TaskGraph.TaskNode): string { + const focus = node.target?.trim() + if (focus) { + return `Auto-retry (reduced scope): focus ONLY on ${focus}${node.technique ? ` — drop broad "${node.technique}" enumeration` : ""}. Original: "${node.description}"` + } + return `Auto-retry (reduced scope): focus ONLY on the single highest-value sub-vector of "${node.description}"` +} + +/** + * Detect in-flight subagents that have produced ZERO new state markers for + * `markerTimeoutMs` and emit a one-shot auto-retry plan (reduced scope + + * exponential-backoff-with-jitter delay). A node is eligible only while its + * retry count is below `maxRetries` (default 1), so at most ONE retry is planned + * per node. Sorted most-silent-first. Pure: `nowMs` supplied by the caller. + */ +export function detectStaleAgents( + graph: TaskGraph.TaskNodes, + opts: { + markerTimeoutMs: number + nowMs: number + /** nodeId → epoch ms of the subagent's last state marker (state mutation). */ + lastStateMarkerMs?: Record + /** nodeId → retries already issued for that node. */ + retryCount?: Record + maxRetries?: number + baseDelayMs?: number + maxDelayMs?: number + jitter?: number + random?: () => number + }, +): LivenessRetry[] { + const maxRetries = opts.maxRetries ?? 1 + const out: LivenessRetry[] = [] + for (const node of TaskGraph.getRunning(graph)) { + const lastMarker = opts.lastStateMarkerMs?.[node.id] + const lastSeen = lastMarker !== undefined ? lastMarker : Date.parse(node.updatedAt) + const silentMs = opts.nowMs - lastSeen + if (!Number.isFinite(silentMs) || silentMs < opts.markerTimeoutMs) continue + const retries = opts.retryCount?.[node.id] ?? 0 + if (retries >= maxRetries) continue + out.push({ + node, + silentMs, + reducedScope: reducedScopeFor(node), + delayMs: retryDelayMs(retries, { + baseDelayMs: opts.baseDelayMs, + maxDelayMs: opts.maxDelayMs, + jitter: opts.jitter, + random: opts.random, + }), + attempt: retries, + }) + } + return out.sort((a, b) => b.silentMs - a.silentMs) +} + +// --- Burp proxy env injection (mandatory assessment layer) --- +// Every subagent's HTTP(S) egress must route through the Burp proxy at +// 127.0.0.1:8080 so the proxy history fills and Active Scan++ / Backslash / +// Autorize have a complete request corpus to work from. These are COMPUTE +// helpers: they return the env vars (and the prompt snippet) the spawn driver +// must apply, honoring an explicit opt-out (e.g. an offline binary-build task +// whose loopback traffic must not be re-proxied). Like the rest of orchestrator, +// they never spawn — the driver is the caller's responsibility. + +export const BURP_PROXY_URL = "http://127.0.0.1:8080" + +/** Loopback hosts the proxy must NOT re-proxy (Burp itself + local control plane). */ +export const BURP_NO_PROXY = "127.0.0.1,localhost,::1" + +export interface ProxyEnvOptions { + /** Explicit opt-out: return empty env / empty prompt when true. */ + disableProxy?: boolean + /** Override the default proxy URL (defaults to BURP_PROXY_URL). */ + proxyUrl?: string + /** The subagent this env is computed for (checked against optOutAgents). */ + assignedAgent?: string + /** Assigned-agent names that explicitly opt out of proxying. */ + optOutAgents?: readonly string[] +} + +/** True when the subagent has explicitly opted out of proxy injection. */ +export function proxyDisabled(opts: ProxyEnvOptions): boolean { + if (opts.disableProxy) return true + if (opts.assignedAgent && opts.optOutAgents?.includes(opts.assignedAgent)) return true + return false +} + +/** + * Compute the HTTP(S)_PROXY env vars to inject into a subagent's environment so + * all of its egress routes through Burp. Returns an empty object when the + * subagent opts out. Loopback is exempt via NO_PROXY so Burp itself and the + * harness's local control plane are not re-proxied (which would loop). + */ +export function proxyEnvForSubagent(opts: ProxyEnvOptions = {}): Record { + if (proxyDisabled(opts)) return {} + const url = opts.proxyUrl ?? BURP_PROXY_URL + return { + HTTP_PROXY: url, + HTTPS_PROXY: url, + http_proxy: url, + https_proxy: url, + NO_PROXY: BURP_NO_PROXY, + no_proxy: BURP_NO_PROXY, + } +} + +/** + * Prompt snippet telling the subagent its traffic is proxied and must stay so — + * injected into the subagent's instructions so it does not unset or bypass the + * proxy env (which would leave the assessment layer blind for that task). + */ +export function proxyEnvPrompt(opts: ProxyEnvOptions = {}): string { + if (proxyDisabled(opts)) return "" + const url = opts.proxyUrl ?? BURP_PROXY_URL + return ( + `Traffic policy: all HTTP(S) egress is routed through the Burp proxy at ${url} ` + + `(HTTP_PROXY/HTTPS_PROXY are pre-set in your environment). Do not unset or bypass them — ` + + `the proxy fills assessment coverage (Active Scan++ / Backslash / Autorize). ` + + `Loopback (${BURP_NO_PROXY}) is exempt via NO_PROXY.` + ) +} + +/** + * One-call interface for the spawn driver: the env map plus the prompt snippet + * for a given task node, honoring a node-level opt-out via its assignedAgent. + */ +export function subagentProxy( + node: TaskGraph.TaskNode, + opts: ProxyEnvOptions = {}, +): { env: Record; prompt: string } { + const merged: ProxyEnvOptions = { ...opts, assignedAgent: opts.assignedAgent ?? node.assignedAgent } + return { env: proxyEnvForSubagent(merged), prompt: proxyEnvPrompt(merged) } +} + +/** + * Session-metadata key the spawn driver uses to stash a subagent's proxy env so + * the shell spawn (bash tool / shell method) can read it back and inject + * HTTP_PROXY/HTTPS_PROXY into the child process. The env is computed ONCE at + * spawn (where the task node / assignedAgent is known, and where opt-out is + * decided) and carried through the session — the shell spawn has no task-node + * context, so it must not re-derive (and re-decide opt-out) independently. + */ +export const SUBAGENT_PROXY_ENV_METADATA_KEY = "proxyEnv" + +/** + * Read the proxy env a spawn driver stashed in a session's `metadata` (returned + * by `subagentProxy(..).env`). Returns an empty map when absent, non-object, or + * when the stored value is not a flat string map — so a malformed/metadata-less + * session degrades to "no proxy injection" instead of throwing inside the shell + * hot path. + */ +export function proxyEnvFromSession(metadata: unknown): Record { + if (!metadata || typeof metadata !== "object") return {} + const env = (metadata as Record)[SUBAGENT_PROXY_ENV_METADATA_KEY] + if (!env || typeof env !== "object") return {} + const out: Record = {} + for (const [key, value] of Object.entries(env as Record)) { + if (typeof value === "string") out[key] = value + } + return out +} diff --git a/packages/core/src/engagement/store.ts b/packages/core/src/engagement/store.ts index 46814867..0bc69d51 100644 --- a/packages/core/src/engagement/store.ts +++ b/packages/core/src/engagement/store.ts @@ -53,6 +53,119 @@ function mergeAttemptLog( : all } +// --- Write-time evidence gate (I-3 write path) --- +// A finding recorded as "confirmed"/"exploited" must clear an evidence bar or it +// is over-claimed. Reuse the existing deriveConfidence (single source of a +// vuln's confidence) plus an evidence-length floor: a lead that fails is either +// refused outright (no evidence at all) or coerced to "suspected" (thin +// evidence) so it is kept but not inflated. "suspected"/"false_positive" pass +// through untouched — a lead with no proof is legitimately suspected. +export const CONFIRMED_EVIDENCE_MIN_LENGTH = 120 +export const CONFIRMED_CONFIDENCE_FLOOR = 0.6 + +export type VulnWriteDecision = + | { readonly action: "write" } + | { readonly action: "refuse"; readonly confidence: number } + | { readonly action: "downgrade"; readonly confidence: number; readonly vuln: EngagementSchema.Vulnerability } + +export function resolveVulnWrite(vuln: EngagementSchema.Vulnerability): VulnWriteDecision { + const status = vuln.status ?? "suspected" + if (status !== "confirmed" && status !== "exploited") return { action: "write" } + const confidence = EngagementSchema.deriveConfidence({ + status, + evidence_items: vuln.evidence_items, + }) + const evidenceLength = (vuln.evidence ?? "").trim().length + if (confidence <= CONFIRMED_CONFIDENCE_FLOOR && evidenceLength < CONFIRMED_EVIDENCE_MIN_LENGTH) { + if (evidenceLength === 0) return { action: "refuse", confidence } + return { + action: "downgrade", + confidence, + vuln: { ...vuln, status: "suspected", confidence }, + } + } + return { action: "write" } +} + +// --- Relationship ID resolution (graph-key integrity) --- +// Agents frequently key a relationship on a vuln's human-facing TITLE instead of +// its stable `.id` (titles are long, can drift, and are not stable graph keys). +// A title-keyed edge fragments the relationship graph into parallel nodes +// (title-keyed vs id-keyed) and breaks cost-based path-finding, which keys on +// stable ids. Resolve a relationship's source/target ids from vuln TITLE -> vuln +// `.id` whenever the entity type is "vuln". +export const MIN_TITLE_MATCH_LENGTH = 12 + +export function resolveRelationshipIds( + state: EngagementSchema.State, + rel: EngagementSchema.Relationship, +): EngagementSchema.Relationship { + const resolveId = (type: string, id: string): string => { + if (type !== "vuln") return id + const trimmed = id.trim() + if (!trimmed) return id + // Already keyed by a stable id — leave it untouched (idempotent). + for (const host of Object.values(state.hosts)) { + if (host.vulns.some((v) => v.id === trimmed)) return trimmed + } + // Treat the id as a title and resolve it to the vuln's stable id. + for (const host of Object.values(state.hosts)) { + const match = host.vulns.find((v) => v.title.trim() === trimmed) + if (match) return match.id ?? trimmed + } + return id + } + return { + ...rel, + source_id: resolveId(rel.source_type, rel.source_id), + target_id: resolveId(rel.target_type, rel.target_id), + } +} + +// --- Auto-chain-link (finding graph linkage) --- +// After a finding is recorded, scan its text fields (evidence, description, +// references, evidence-item commands) for a reference to ANOTHER finding — by +// stable id or by title — and emit the relationship edges that link the chain. +export function findAutoChainLinks( + state: EngagementSchema.State, + vuln: EngagementSchema.Vulnerability, +): EngagementSchema.Relationship[] { + if (!vuln.id) return [] + const haystack = [ + vuln.evidence ?? "", + vuln.description ?? "", + ...(vuln.references ?? []), + ...(vuln.evidence_items ?? []).flatMap((e) => [e.command ?? "", e.tool ?? ""]), + ].join("\n") + if (!haystack.trim()) return [] + + const links: EngagementSchema.Relationship[] = [] + const seen = new Set() + for (const host of Object.values(state.hosts)) { + for (const other of host.vulns) { + if (other.id === vuln.id) continue + if (other.title === vuln.title) continue + const otherId = other.id + const otherTitle = other.title.trim() + const byId = otherId !== undefined && otherId.length >= 4 && haystack.includes(otherId) + const byTitle = otherTitle.length >= MIN_TITLE_MATCH_LENGTH && haystack.includes(otherTitle) + if (!byId && !byTitle) continue + const key = otherId ?? otherTitle + if (seen.has(key)) continue + seen.add(key) + links.push({ + source_type: "vuln", + source_id: vuln.id, + rel_type: "EXPLOITED_VIA", + target_type: "vuln", + target_id: otherId ?? otherTitle, + metadata: `auto-chain-link (${byId ? "id" : "title"} reference)`, + }) + } + } + return links +} + export interface Interface { readonly get: () => Effect.Effect readonly save: (state: EngagementSchema.State) => Effect.Effect @@ -162,6 +275,106 @@ function lastFilePath(): string { return path.join(ENGAGEMENTS_DIR, LAST_FILE) } +/** + * Atomically write `data` to `filePath`: write to a temp file in the SAME + * directory, fsync it, then rename over the destination. rename(2) is atomic on + * the same filesystem, so a concurrent reader never observes a half-written + * file, and a crash mid-write leaves either the old or the new contents — never + * a truncated/corrupt one. + * + * The temp name is deterministic (`${filePath}.tmp`) so a stale temp left behind + * by a prior crashed write is simply overwritten by the next successful write + * and then consumed by the rename (no orphaned garbage accumulates). + */ +export function atomicWriteFileSync( + filePath: string, + data: string, + options: { readonly encoding?: BufferEncoding; readonly mode?: number } = {}, +): void { + const dir = path.dirname(filePath) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + const tmpPath = `${filePath}.tmp` + let fd: number | undefined + try { + fd = fs.openSync(tmpPath, "w", options.mode ?? 0o600) + fs.writeSync(fd, data, null, options.encoding ?? "utf-8") + fs.fsyncSync(fd) + fs.closeSync(fd) + fd = undefined + fs.renameSync(tmpPath, filePath) + } catch (error) { + if (fd !== undefined) { + try { + fs.closeSync(fd) + } catch { + // ignore — the original error is what matters + } + } + try { + fs.unlinkSync(tmpPath) + } catch { + // ignore — best-effort cleanup of the temp file + } + throw error + } +} + +/** + * Acquire an exclusive cross-process lock on a lockfile next to the target file, + * run `fn`, release. O_CREAT|O_EXCL gives atomic acquire semantics: only one + * process can create the lockfile, the rest wait a bounded time. This closes the + * lost-update race that atomicWriteFileSync alone cannot: two processes each + * holding a stale in-memory State copy could both serialize and the later rename + * would win, dropping the earlier process's changes (measured: the + * resolved-vectors ledger rolled back 58 -> 300). The lock serializes the + * read-modify-write of the FILE, not just the final write. + */ +export function withFileLock( + targetPath: string, + fn: () => T, + options: { readonly timeoutMs?: number; readonly retryMs?: number } = {}, +): T { + const lockPath = `${targetPath}.lock` + const timeoutMs = options.timeoutMs ?? 5000 + const retryMs = options.retryMs ?? 25 + const deadline = Date.now() + timeoutMs + for (;;) { + try { + const fd = fs.openSync(lockPath, "wx", 0o600) + fs.writeSync(fd, String(process.pid)) + fs.closeSync(fd) + try { + return fn() + } finally { + try { + fs.unlinkSync(lockPath) + } catch { + // best-effort release; a stale lock is reaped on next acquire timeout + } + } + } catch (error) { + // EEXIST = lock held by another process; wait and retry within the budget + const code = (error as NodeJS.ErrnoException).code + if (code !== "EEXIST") throw error + if (Date.now() >= deadline) { + // stale lock (holder crashed without release) — reclaim it + try { + fs.unlinkSync(lockPath) + } catch { + // raced with another reclaimer; fall through and throw + } + throw new Error(`state lock timeout after ${timeoutMs}ms on ${lockPath}`) + } + sleepSync(retryMs) + } + } +} + +function sleepSync(ms: number): void { + const buf = new Int32Array(new SharedArrayBuffer(4)) + Atomics.wait(buf, 0, 0, ms) +} + const encode = Schema.encodeSync(EngagementSchema.State) const decode = Schema.decodeUnknownSync(EngagementSchema.State) @@ -295,16 +508,23 @@ const layer = Layer.effect( } } + // Persist state.json via an ATOMIC write (temp file + fsync + rename). This is + // not cosmetic: two concurrent subagent processes each holding a stale + // in-memory State copy used to race here — last writeFileSync won, silently + // rolling back the other process's changes (measured: the resolved-vectors + // ledger dropped from 300 back to 58). A direct writeFileSync can also leave + // a truncated state.json if the process crashes mid-write. The temp+rename + // scheme makes each write either fully visible or absent, never half-written, + // and a stale temp is simply overwritten on the next persist. Do not revert + // to a plain writeFileSync here. const persist = (state: EngagementSchema.State) => Effect.sync(() => { const filePath = stateFilePath(state.name) - const dir = path.dirname(filePath) - fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) const json = encode(state) - fs.writeFileSync(filePath, JSON.stringify(json, undefined, 2), { encoding: "utf-8", mode: 0o600 }) - const lastPath = lastFilePath() - fs.mkdirSync(path.dirname(lastPath), { recursive: true, mode: 0o700 }) - fs.writeFileSync(lastPath, state.name, { encoding: "utf-8", mode: 0o600 }) + withFileLock(filePath, () => { + atomicWriteFileSync(filePath, JSON.stringify(json, undefined, 2), { encoding: "utf-8", mode: 0o600 }) + atomicWriteFileSync(lastFilePath(), state.name, { encoding: "utf-8", mode: 0o600 }) + }) }) const persistCurrent = Effect.gen(function* () { @@ -454,37 +674,56 @@ const layer = Layer.effect( }), addVuln: Effect.fn("EngagementStore.addVuln")(function* (hostIp, vuln) { + const decision = resolveVulnWrite(vuln) + if (decision.action === "refuse") { + yield* logChange( + "add_vuln_refused", + "vuln", + vuln.id, + `Refused ${vuln.title} on ${hostIp}: confirmed without evidence (confidence ${decision.confidence})`, + ) + return + } + const finalVuln = decision.action === "downgrade" ? decision.vuln : vuln + if (decision.action === "downgrade") { + yield* logChange( + "add_vuln_downgraded", + "vuln", + vuln.id, + `Downgraded ${vuln.title} on ${hostIp} to suspected: thin evidence (confidence ${decision.confidence})`, + ) + } const action = yield* Ref.modify(stateRef, (current) => { if (!current) return ["skip" as const, current] const host = current.hosts[hostIp] if (!host) return ["skip" as const, current] const isDupe = host.vulns.some( - (v) => v.title === vuln.title && v.service_port === vuln.service_port, + (v) => v.title === finalVuln.title && v.service_port === finalVuln.service_port, ) if (isDupe) { const updatedVulns = host.vulns.map((v) => - v.title === vuln.title && v.service_port === vuln.service_port ? { ...v, ...vuln } : v, + v.title === finalVuln.title && v.service_port === finalVuln.service_port ? { ...v, ...finalVuln } : v, ) return ["updated" as const, { ...current, hosts: { ...current.hosts, [hostIp]: { ...host, vulns: updatedVulns } } }] } - return ["created" as const, { ...current, hosts: { ...current.hosts, [hostIp]: { ...host, vulns: [...host.vulns, vuln] } } }] + return ["created" as const, { ...current, hosts: { ...current.hosts, [hostIp]: { ...host, vulns: [...host.vulns, finalVuln] } } }] }) if (action === "skip") return if (action === "created") { - yield* logChange("add_vuln", "vuln", vuln.id, `[${(vuln.severity ?? "medium").toUpperCase()}] ${vuln.title} on ${hostIp}${vuln.confidence !== undefined ? ` conf:${vuln.confidence}` : ""}`) + yield* logChange("add_vuln", "vuln", finalVuln.id, `[${(finalVuln.severity ?? "medium").toUpperCase()}] ${finalVuln.title} on ${hostIp}${finalVuln.confidence !== undefined ? ` conf:${finalVuln.confidence}` : ""}`) const state = yield* Ref.get(stateRef) if (state) { const sevIcon: Record = { critical: "!!!", high: "!!", medium: "!", low: ".", info: "i" } const findingLines = [ - `## ${sevIcon[vuln.severity ?? "medium"] ?? "!"} [${(vuln.severity ?? "medium").toUpperCase()}] ${vuln.title}`, + `## ${sevIcon[finalVuln.severity ?? "medium"] ?? "!"} [${(finalVuln.severity ?? "medium").toUpperCase()}] ${finalVuln.title}`, `**Time**: ${new Date().toISOString()}`, - `**Host**: ${hostIp}${vuln.service_port ? `:${vuln.service_port}` : ""}`, - `**Status**: ${vuln.status ?? "suspected"}${vuln.confidence !== undefined ? ` (confidence: ${(vuln.confidence * 100).toFixed(0)}%)` : ""}`, - ...(vuln.description ? [`**Description**: ${vuln.description}`] : []), - ...(vuln.evidence ? [`**Evidence**: \`${vuln.evidence}\``] : []), - ...(vuln.evidence_items?.length ? [ + `**Host**: ${hostIp}${finalVuln.service_port ? `:${finalVuln.service_port}` : ""}`, + `**Status**: ${finalVuln.status ?? "suspected"}${finalVuln.confidence !== undefined ? ` (confidence: ${(finalVuln.confidence * 100).toFixed(0)}%)` : ""}`, + ...(finalVuln.description ? [`**Description**: ${finalVuln.description}`] : []), + ...(finalVuln.evidence ? [`**Evidence**: \`${finalVuln.evidence}\``] : []), + ...(finalVuln.evidence_items?.length ? [ `**Evidence Chain**:`, - ...vuln.evidence_items.map((e) => + ...finalVuln.evidence_items.map((e) => `- \`${e.tool}\`${e.source_agent ? ` (${e.source_agent})` : ""}: ${e.command ?? "(no command)"}${e.verification_status ? ` [${e.verification_status}]` : ""}` ), ] : []), @@ -492,6 +731,25 @@ const layer = Layer.effect( ] appendFinding(state.name, findingLines.join("\n")) } + // Auto-chain-link: if this finding's evidence/description references + // another finding (by stable id or by title), link them so the + // relationship graph reflects the chain without the agent hand-wiring + // each edge. Runs only on genuinely-new findings ("created"). + const links = yield* Ref.modify(stateRef, (current) => { + if (!current) return [[] as EngagementSchema.Relationship[], current] + const existing = current.relationships ?? [] + const candidates = findAutoChainLinks(current, finalVuln) + const toAdd = candidates.filter( + (c) => !existing.some( + (r) => r.source_id === c.source_id && r.rel_type === c.rel_type && r.target_id === c.target_id, + ), + ) + if (toAdd.length === 0) return [[] as EngagementSchema.Relationship[], current] + return [toAdd, { ...current, relationships: [...existing, ...toAdd] }] + }) + for (const link of links) { + yield* logChange("add_relationship", "relationship", `${link.source_id}->${link.target_id}`, `auto-chain-link: ${link.source_type}:${link.source_id} --[${link.rel_type}]--> ${link.target_type}:${link.target_id}`) + } } yield* persistCurrent }), @@ -755,20 +1013,27 @@ const layer = Layer.effect( getLastInjectedTimestamp: () => Ref.get(lastInjectedRef), addRelationship: Effect.fn("EngagementStore.addRelationship")(function* (rel) { - const added = yield* Ref.modify(stateRef, (current) => { - if (!current) return [false, current] - const existing = current.relationships ?? [] - const isDupe = existing.some( - (r) => r.source_id === rel.source_id && r.rel_type === rel.rel_type && r.target_id === rel.target_id, - ) - if (isDupe) return [false, current] - return [true, { ...current, relationships: [...existing, rel] }] - }) - if (added) { - yield* logChange("add_relationship", "relationship", `${rel.source_id}->${rel.target_id}`, `${rel.source_type}:${rel.source_id} --[${rel.rel_type}]--> ${rel.target_type}:${rel.target_id}`) + const result = yield* Ref.modify( + stateRef, + (current): [{ added: boolean; resolved?: EngagementSchema.Relationship }, EngagementSchema.State | undefined] => { + if (!current) return [{ added: false }, current] + // Resolve a vuln title reference to its stable id so the edge keys on a + // durable graph node instead of a fragile, drift-prone title string. + const resolved = resolveRelationshipIds(current, rel) + const existing = current.relationships ?? [] + const isDupe = existing.some( + (r) => r.source_id === resolved.source_id && r.rel_type === resolved.rel_type && r.target_id === resolved.target_id, + ) + if (isDupe) return [{ added: false }, current] + return [{ added: true, resolved }, { ...current, relationships: [...existing, resolved] }] + }, + ) + if (result.added && result.resolved) { + const r = result.resolved + yield* logChange("add_relationship", "relationship", `${r.source_id}->${r.target_id}`, `${r.source_type}:${r.source_id} --[${r.rel_type}]--> ${r.target_type}:${r.target_id}`) yield* persistCurrent } - return added + return result.added }), getRelationships: Effect.fn("EngagementStore.getRelationships")(function* (filter) { diff --git a/packages/core/test/engagement-addvuln-validation.test.ts b/packages/core/test/engagement-addvuln-validation.test.ts new file mode 100644 index 00000000..6bbb71b9 --- /dev/null +++ b/packages/core/test/engagement-addvuln-validation.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" +import { EngagementStore } from "@pentestcode/core/engagement/store" + +const { resolveVulnWrite, CONFIRMED_EVIDENCE_MIN_LENGTH } = EngagementStore + +// A "rich" evidence string: >=120 chars and carries an execution marker (curl, +// POST, HTTP 200, returned) — the kind of proof that justifies "confirmed". +const richEvidence = + `curl -s -X POST 'https://example.com/api/login' --data-urlencode "user=admin' OR 1=1 --" ` + + `returned HTTP 200 with the admin session cookie set, proving SQL injection in the 'user' parameter` + +describe("addVuln write-time evidence gate (resolveVulnWrite)", () => { + test("confirmed + empty evidence -> refused (not written as confirmed)", () => { + const d = resolveVulnWrite({ title: "SQLi", status: "confirmed", severity: "high" }) + expect(d.action).toBe("refuse") + if (d.action === "refuse") { + expect(d.confidence).toBeLessThanOrEqual(0.6) + } + }) + + test("confirmed + short evidence (<120) -> downgraded to suspected with low confidence", () => { + const short = "matched: /x | matcher: word | template: cve-1234" + expect(short.length).toBeLessThan(CONFIRMED_EVIDENCE_MIN_LENGTH) + const d = resolveVulnWrite({ title: "SQLi", status: "confirmed", severity: "high", evidence: short }) + expect(d.action).toBe("downgrade") + if (d.action === "downgrade") { + expect(d.vuln.status).toBe("suspected") + expect(d.vuln.confidence).toBeLessThanOrEqual(0.6) + } + }) + + test("confirmed + rich evidence (>=120 with execution marker) -> written unchanged", () => { + expect(richEvidence.length).toBeGreaterThanOrEqual(CONFIRMED_EVIDENCE_MIN_LENGTH) + expect(richEvidence).toMatch(/curl|POST|HTTP 200|returned/) + const d = resolveVulnWrite({ title: "SQLi", status: "confirmed", severity: "high", evidence: richEvidence }) + expect(d.action).toBe("write") + }) + + test("suspected + empty evidence -> written unchanged (a lead with no proof is legitimately suspected)", () => { + const d = resolveVulnWrite({ title: "SQLi", status: "suspected", severity: "high" }) + expect(d.action).toBe("write") + }) + + test("false_positive + empty evidence -> written unchanged", () => { + const d = resolveVulnWrite({ title: "SQLi", status: "false_positive", severity: "high" }) + expect(d.action).toBe("write") + }) + + test("confirmed + empty evidence string BUT a verified evidence item -> written unchanged (verified corroboration dominates)", () => { + // deriveConfidence short-circuits to 0.95 on a verified item, above the 0.6 floor. + const d = resolveVulnWrite({ + title: "SQLi", + status: "confirmed", + severity: "high", + evidence_items: [{ tool: "sqlmap", verification_status: "verified", output: "..." }], + }) + expect(d.action).toBe("write") + }) + + test("exploited with no evidence string is still gated by the confidence floor", () => { + // deriveConfidence("exploited") = 0.5 + 0.25 = 0.75 > 0.6, so it passes the + // gate on confidence alone — the exploited status is itself strong signal. + const d = resolveVulnWrite({ title: "SQLi", status: "exploited", severity: "critical" }) + expect(d.action).toBe("write") + }) +}) diff --git a/packages/core/test/engagement-atomic-write.test.ts b/packages/core/test/engagement-atomic-write.test.ts new file mode 100644 index 00000000..0cbf61e3 --- /dev/null +++ b/packages/core/test/engagement-atomic-write.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test" +import * as fs from "node:fs" +import * as os from "node:os" +import * as path from "node:path" +import { EngagementStore } from "@pentestcode/core/engagement/store" + +const { atomicWriteFileSync } = EngagementStore + +function tempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "atomic-write-")) +} + +describe("atomicWriteFileSync (persist write path)", () => { + test("writes a valid state.json that round-trips through JSON.parse", () => { + const dir = tempDir() + const filePath = path.join(dir, "state.json") + const state = { name: "test-engagement", hosts: { "10.0.0.1": { ip: "10.0.0.1" } }, n: 42 } + atomicWriteFileSync(filePath, JSON.stringify(state, undefined, 2)) + expect(fs.existsSync(filePath)).toBe(true) + expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual(state) + }) + + test("a stale temp file from a prior crashed write is overwritten, not corrupting state.json", () => { + const dir = tempDir() + const filePath = path.join(dir, "state.json") + const tmpPath = `${filePath}.tmp` + // Simulate a crashed write: a temp file that was written but never renamed. + fs.writeFileSync(tmpPath, "corrupt{{{ not json", { encoding: "utf-8" }) + const good = { name: "recovered", value: [1, 2, 3] } + atomicWriteFileSync(filePath, JSON.stringify(good, undefined, 2)) + // state.json must hold the new valid content, unaffected by the stale temp. + expect(JSON.parse(fs.readFileSync(filePath, "utf-8"))).toEqual(good) + // The stale temp was consumed by the rename and is now gone. + expect(fs.existsSync(tmpPath)).toBe(false) + }) + + test("no temp file is left behind after a successful persist", () => { + const dir = tempDir() + const filePath = path.join(dir, "state.json") + atomicWriteFileSync(filePath, JSON.stringify({ ok: true })) + expect(fs.existsSync(`${filePath}.tmp`)).toBe(false) + }) + + test("removes the temp file when the rename fails, leaving the destination intact", () => { + const dir = tempDir() + const filePath = path.join(dir, "state.json") + // Make the destination a non-empty directory so renameSync(file, dir) fails + // with EISDIR, exercising the error-cleanup branch. + fs.mkdirSync(filePath, { recursive: true }) + fs.writeFileSync(path.join(filePath, "keep"), "x", { encoding: "utf-8" }) + expect(() => atomicWriteFileSync(filePath, "new content")).toThrow() + // Temp file cleaned up on failure. + expect(fs.existsSync(`${filePath}.tmp`)).toBe(false) + // Destination not clobbered. + expect(fs.existsSync(path.join(filePath, "keep"))).toBe(true) + }) +}) diff --git a/packages/core/test/engagement-relationship-resolution.test.ts b/packages/core/test/engagement-relationship-resolution.test.ts new file mode 100644 index 00000000..f8ed0f0c --- /dev/null +++ b/packages/core/test/engagement-relationship-resolution.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from "bun:test" +import { EngagementStore } from "@pentestcode/core/engagement/store" +import { EngagementSchema } from "@pentestcode/core/engagement/schema" + +const { resolveRelationshipIds, findAutoChainLinks, MIN_TITLE_MATCH_LENGTH } = EngagementStore + +function baseState(): EngagementSchema.State { + return { + id: EngagementSchema.ID.make("test1234"), + name: "test-engagement", + created_at: "2026-07-15T00:00:00.000Z", + updated_at: "2026-07-15T00:00:00.000Z", + scope: { targets: [], excludes: [], notes: "" }, + hosts: {}, + credentials: {}, + flags: [], + attack_path: [], + task_tree: [], + current_phase: "recon", + mode: "auto", + notes: [], + } +} + +function vuln(patch: Partial & { title: string }): EngagementSchema.Vulnerability { + return { severity: "high", status: "confirmed", ...patch } +} + +describe("resolveRelationshipIds — vuln title -> stable id", () => { + const state: EngagementSchema.State = { + ...baseState(), + hosts: { + "10.0.0.1": { + ip: "10.0.0.1", + services: [], + access: [], + notes: [], + vulns: [ + vuln({ id: "vuln-1", title: "SQL injection in login" }), + vuln({ id: "vuln-2", title: "Reflected XSS in search" }), + ], + }, + }, + } + + test("resolves a vuln TITLE source_id to its stable id", () => { + const resolved = resolveRelationshipIds(state, { + source_type: "vuln", + source_id: "SQL injection in login", + rel_type: "EXPLOITED_VIA", + target_type: "host", + target_id: "10.0.0.1", + }) + expect(resolved.source_id).toBe("vuln-1") + expect(resolved.target_id).toBe("10.0.0.1") // non-vuln untouched + }) + + test("resolves a vuln TITLE target_id to its stable id", () => { + const resolved = resolveRelationshipIds(state, { + source_type: "host", + source_id: "10.0.0.1", + rel_type: "EXPLOITED_VIA", + target_type: "vuln", + target_id: "Reflected XSS in search", + }) + expect(resolved.target_id).toBe("vuln-2") + }) + + test("leaves a non-vuln entity id untouched", () => { + const resolved = resolveRelationshipIds(state, { + source_type: "credential", + source_id: "admin", + rel_type: "AUTHENTICATES_TO", + target_type: "host", + target_id: "10.0.0.1", + }) + expect(resolved.source_id).toBe("admin") + }) + + test("is idempotent: an id already keyed to a vuln is left alone", () => { + const resolved = resolveRelationshipIds(state, { + source_type: "vuln", + source_id: "vuln-1", + rel_type: "EXPLOITED_VIA", + target_type: "vuln", + target_id: "vuln-2", + }) + expect(resolved.source_id).toBe("vuln-1") + expect(resolved.target_id).toBe("vuln-2") + }) + + test("an unknown title is left unchanged (no fabricated mapping)", () => { + const resolved = resolveRelationshipIds(state, { + source_type: "vuln", + source_id: "Not a real finding title", + rel_type: "EXPLOITED_VIA", + target_type: "host", + target_id: "10.0.0.1", + }) + expect(resolved.source_id).toBe("Not a real finding title") + }) +}) + +describe("findAutoChainLinks — auto-chain-link after add_vuln", () => { + const existingTitle = "SQL injection on login page" + + function stateWith(existing: EngagementSchema.Vulnerability[]): EngagementSchema.State { + return { + ...baseState(), + hosts: { + "10.0.0.1": { ip: "10.0.0.1", services: [], access: [], notes: [], vulns: existing }, + }, + } + } + + test("links a new finding whose evidence references another finding by TITLE", () => { + const state = stateWith([vuln({ id: "vuln-1", title: existingTitle })]) + const links = findAutoChainLinks(state, vuln({ + id: "vuln-2", + title: "Second-order SQLi", + evidence: `chains off the ${existingTitle} entry point`, + })) + expect(links).toHaveLength(1) + expect(links[0]).toMatchObject({ + source_type: "vuln", + source_id: "vuln-2", + rel_type: "EXPLOITED_VIA", + target_type: "vuln", + target_id: "vuln-1", + }) + expect(links[0]!.metadata).toContain("title") + }) + + test("links by STABLE ID reference", () => { + const state = stateWith([vuln({ id: "vuln-abc", title: "Blind SQLi" })]) + const links = findAutoChainLinks(state, vuln({ + id: "vuln-2", + title: "SQLi chained", + description: "reachable via vuln-abc", + })) + expect(links).toHaveLength(1) + expect(links[0]!.target_id).toBe("vuln-abc") + expect(links[0]!.metadata).toContain("id") + }) + + test("a vuln without an id cannot be linked (no source anchor)", () => { + const state = stateWith([vuln({ id: "vuln-1", title: existingTitle })]) + const links = findAutoChainLinks(state, vuln({ title: "No-id finding", evidence: existingTitle })) + expect(links).toHaveLength(0) + }) + + test("does NOT self-link when evidence mentions its own title", () => { + const state = stateWith([vuln({ id: "vuln-1", title: existingTitle })]) + const links = findAutoChainLinks(state, vuln({ + id: "vuln-1", + title: existingTitle, + evidence: `details about ${existingTitle}`, + })) + expect(links).toHaveLength(0) + }) + + test("does NOT match titles shorter than MIN_TITLE_MATCH_LENGTH", () => { + const short = "SQLi" // < MIN_TITLE_MATCH_LENGTH + expect(short.length).toBeLessThan(MIN_TITLE_MATCH_LENGTH) + const state = stateWith([vuln({ id: "vuln-1", title: short })]) + const links = findAutoChainLinks(state, vuln({ + id: "vuln-2", + title: "Another finding", + evidence: "a classic SQLi bug", + })) + expect(links).toHaveLength(0) + }) + + test("returns [] when there is nothing to scan", () => { + const state = stateWith([vuln({ id: "vuln-1", title: existingTitle })]) + expect(findAutoChainLinks(state, vuln({ id: "vuln-2", title: "Bare finding" }))).toHaveLength(0) + }) +}) diff --git a/packages/core/test/engagement-state-lock.test.ts b/packages/core/test/engagement-state-lock.test.ts new file mode 100644 index 00000000..877acdaa --- /dev/null +++ b/packages/core/test/engagement-state-lock.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { withFileLock } from "@pentestcode/core/engagement/store" + +function tmpdir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "lock-test-")) +} + +describe("withFileLock (lost-update serialization)", () => { + test("acquires when free, releases by removing the lockfile", () => { + const dir = tmpdir() + const target = path.join(dir, "state.json") + const lock = `${target}.lock` + + const result = withFileLock(target, () => "done") + expect(result).toBe("done") + expect(fs.existsSync(lock)).toBe(false) + }) + + test("creates a lockfile during the critical section", () => { + const dir = tmpdir() + const target = path.join(dir, "state.json") + const lock = `${target}.lock` + let sawLock = false + + withFileLock(target, () => { + sawLock = fs.existsSync(lock) + return 1 + }) + expect(sawLock).toBe(true) + }) + + test("second acquire while held times out cleanly rather than silently proceeding", () => { + const dir = tmpdir() + const target = path.join(dir, "state.json") + + // Simulate a held lock by pre-creating the lockfile (as another process would). + fs.writeFileSync(`${target}.lock`, "other-pid") + + expect(() => + withFileLock(target, () => "never", { timeoutMs: 200, retryMs: 10 }), + ).toThrow(/timeout/) + }) + + test("reclaims a stale lock after the timeout budget", () => { + const dir = tmpdir() + const target = path.join(dir, "state.json") + const lock = `${target}.lock` + fs.writeFileSync(lock, "crashed-pid") + + // with a short timeout, it should remove the stale lock and throw timeout; + // after that the lock is gone, so a fresh acquire succeeds. + try { + withFileLock(target, () => "x", { timeoutMs: 100, retryMs: 10 }) + } catch { + // expected timeout + } + // stale lock was reclaimed + const result = withFileLock(target, () => "ok", { timeoutMs: 500 }) + expect(result).toBe("ok") + expect(fs.existsSync(lock)).toBe(false) + }) + + test("releases the lock even when fn throws", () => { + const dir = tmpdir() + const target = path.join(dir, "state.json") + const lock = `${target}.lock` + + expect(() => + withFileLock(target, () => { + throw new Error("boom") + }), + ).toThrow("boom") + expect(fs.existsSync(lock)).toBe(false) + }) +}) diff --git a/packages/core/test/orchestrator.test.ts b/packages/core/test/orchestrator.test.ts index f17222e9..90310968 100644 --- a/packages/core/test/orchestrator.test.ts +++ b/packages/core/test/orchestrator.test.ts @@ -356,3 +356,196 @@ describe("Orchestrator.detectStall — A-4 stall watchdog (DAG-deadlock guard)", expect(stalls.map((s) => s.stalled.id)).toEqual(["a", "b"]) // a is more idle }) }) + +describe("Orchestrator.retryDelayMs — exponential backoff with jitter", () => { + test("attempt 0 with pinned midpoint jitter = base delay", () => { + // random() = 0.5 cancels the jitter term -> exact exponential base. + expect(Orchestrator.retryDelayMs(0, { baseDelayMs: 30_000, jitter: 0.2, random: () => 0.5 })).toBe(30_000) + }) + + test("exponential growth: attempt 1 doubles the base", () => { + expect(Orchestrator.retryDelayMs(1, { baseDelayMs: 30_000, jitter: 0, random: () => 0.5 })).toBe(60_000) + expect(Orchestrator.retryDelayMs(2, { baseDelayMs: 30_000, jitter: 0, random: () => 0.5 })).toBe(120_000) + }) + + test("caps at maxDelayMs", () => { + expect(Orchestrator.retryDelayMs(10, { baseDelayMs: 30_000, maxDelayMs: 300_000, jitter: 0 })).toBe(300_000) + }) + + test("jitter bounds: random 0 -> base - spread; random 1 -> base + spread", () => { + const low = Orchestrator.retryDelayMs(0, { baseDelayMs: 30_000, jitter: 0.2, random: () => 0 }) + const high = Orchestrator.retryDelayMs(0, { baseDelayMs: 30_000, jitter: 0.2, random: () => 1 }) + expect(low).toBe(24_000) // 30k - 20% spread + expect(high).toBe(36_000) // 30k + 20% spread + }) +}) + +describe("Orchestrator.detectStaleAgents — agent-liveness auto-retry", () => { + const BASE = Date.parse("2026-07-15T00:00:00.000Z") + + test("emits a one-shot retry for an in-flight node silent >= markerTimeoutMs", () => { + const g = graph(node({ id: "scan", status: "running", target: "10.0.0.5", description: "enumerate web app" })) + const plan = Orchestrator.detectStaleAgents(g, { + markerTimeoutMs: 300_000, + nowMs: BASE + 400_000, + lastStateMarkerMs: { scan: BASE }, // last marker at dispatch time + random: () => 0.5, + }) + expect(plan).toHaveLength(1) + expect(plan[0]!.node.id).toBe("scan") + expect(plan[0]!.silentMs).toBe(400_000) + expect(plan[0]!.attempt).toBe(0) + expect(plan[0]!.reducedScope).toContain("10.0.0.5") + }) + + test("does NOT emit before markerTimeoutMs elapses", () => { + const g = graph(node({ id: "scan", status: "running" })) + expect(Orchestrator.detectStaleAgents(g, { + markerTimeoutMs: 300_000, + nowMs: BASE + 100_000, + lastStateMarkerMs: { scan: BASE }, + })).toHaveLength(0) + }) + + test("does NOT emit for a node still producing state markers (recent marker)", () => { + const g = graph(node({ id: "scan", status: "running" })) + expect(Orchestrator.detectStaleAgents(g, { + markerTimeoutMs: 300_000, + nowMs: BASE + 400_000, + lastStateMarkerMs: { scan: BASE + 350_000 }, // marked 50k ago + })).toHaveLength(0) + }) + + test("exactly ONE retry: a node already retried is not emitted again", () => { + const g = graph(node({ id: "scan", status: "running" })) + expect(Orchestrator.detectStaleAgents(g, { + markerTimeoutMs: 300_000, + nowMs: BASE + 400_000, + lastStateMarkerMs: { scan: BASE }, + retryCount: { scan: 1 }, // already had its one retry + })).toHaveLength(0) + }) + + test("honors maxRetries>1 (second retry emitted with attempt 1)", () => { + const g = graph(node({ id: "scan", status: "running" })) + const plan = Orchestrator.detectStaleAgents(g, { + markerTimeoutMs: 300_000, + nowMs: BASE + 400_000, + lastStateMarkerMs: { scan: BASE }, + retryCount: { scan: 1 }, + maxRetries: 2, + random: () => 0.5, + }) + expect(plan).toHaveLength(1) + expect(plan[0]!.attempt).toBe(1) + }) + + test("falls back to dispatch age (updatedAt) when no marker signal is available", () => { + const g = graph(node({ id: "scan", status: "running", updatedAt: new Date(BASE).toISOString() })) + const plan = Orchestrator.detectStaleAgents(g, { markerTimeoutMs: 300_000, nowMs: BASE + 400_000 }) + expect(plan).toHaveLength(1) + expect(plan[0]!.silentMs).toBe(400_000) + }) + + test("sorts most-silent-first", () => { + const g = graph( + node({ id: "a", status: "running" }), + node({ id: "b", status: "running" }), + ) + const plan = Orchestrator.detectStaleAgents(g, { + markerTimeoutMs: 100_000, + nowMs: BASE + 500_000, + lastStateMarkerMs: { a: BASE, b: BASE + 300_000 }, + }) + expect(plan.map((p) => p.node.id)).toEqual(["a", "b"]) // a silent 500k, b silent 200k + }) + + test("reduced scope falls back to the description when no target is present", () => { + const g = graph(node({ id: "scan", status: "running", description: "fingerprint the API surface" })) + const plan = Orchestrator.detectStaleAgents(g, { markerTimeoutMs: 100_000, nowMs: BASE + 500_000, lastStateMarkerMs: { scan: BASE } }) + expect(plan[0]!.reducedScope).toContain("fingerprint the API surface") + }) +}) + +describe("Orchestrator.proxyEnvForSubagent — Burp proxy mandatory assessment layer", () => { + test("injects HTTP_PROXY + HTTPS_PROXY by default (proxy is ON unless opted out)", () => { + const env = Orchestrator.proxyEnvForSubagent() + expect(env.HTTP_PROXY).toBe(Orchestrator.BURP_PROXY_URL) + expect(env.HTTPS_PROXY).toBe(Orchestrator.BURP_PROXY_URL) + expect(env.http_proxy).toBe(Orchestrator.BURP_PROXY_URL) + expect(env.https_proxy).toBe(Orchestrator.BURP_PROXY_URL) + }) + + test("exempts loopback via NO_PROXY so Burp itself is not re-proxied", () => { + const env = Orchestrator.proxyEnvForSubagent() + expect(env.NO_PROXY).toContain("127.0.0.1") + expect(env.NO_PROXY).toContain("localhost") + }) + + test("explicit disableProxy opt-out returns an empty env", () => { + expect(Orchestrator.proxyEnvForSubagent({ disableProxy: true })).toEqual({}) + }) + + test("an opt-out agent name is skipped; others are proxied", () => { + const opts = { optOutAgents: ["offline_builder"] as readonly string[] } + expect(Orchestrator.proxyEnvForSubagent({ ...opts, assignedAgent: "offline_builder" })).toEqual({}) + expect(Orchestrator.proxyEnvForSubagent({ ...opts, assignedAgent: "webapp" }).HTTP_PROXY).toBe( + Orchestrator.BURP_PROXY_URL, + ) + }) + + test("honors a proxyUrl override", () => { + const env = Orchestrator.proxyEnvForSubagent({ proxyUrl: "http://127.0.0.1:9090" }) + expect(env.HTTP_PROXY).toBe("http://127.0.0.1:9090") + expect(env.HTTPS_PROXY).toBe("http://127.0.0.1:9090") + }) +}) + +describe("Orchestrator.proxyEnvPrompt — proxy env export for subagent prompts", () => { + test("emits a directive naming the proxy and coverage tools", () => { + const prompt = Orchestrator.proxyEnvPrompt() + expect(prompt).toContain("Burp proxy") + expect(prompt).toContain(Orchestrator.BURP_PROXY_URL) + expect(prompt).toContain("Do not unset or bypass") + }) + + test("returns empty string when opted out", () => { + expect(Orchestrator.proxyEnvPrompt({ disableProxy: true })).toBe("") + expect(Orchestrator.proxyEnvPrompt({ assignedAgent: "offline", optOutAgents: ["offline"] })).toBe("") + }) + + test("subagentProxy bundles env + prompt and honors node assignedAgent opt-out", () => { + const proxied = Orchestrator.subagentProxy(node({ id: "t", assignedAgent: "webapp" })) + expect(proxied.env.HTTP_PROXY).toBe(Orchestrator.BURP_PROXY_URL) + expect(proxied.prompt).toContain("Burp proxy") + + const optedOut = Orchestrator.subagentProxy( + node({ id: "t", assignedAgent: "offline_builder" }), + { optOutAgents: ["offline_builder"] }, + ) + expect(optedOut.env).toEqual({}) + expect(optedOut.prompt).toBe("") + }) +}) + +describe("Orchestrator.proxyEnvFromSession — session-metadata round-trip for the shell spawn", () => { + test("reads the env a spawn driver stashed under the metadata key", () => { + const env = Orchestrator.proxyEnvForSubagent({ assignedAgent: "webapp" }) + const metadata = { [Orchestrator.SUBAGENT_PROXY_ENV_METADATA_KEY]: env, unrelated: true } + const roundTripped = Orchestrator.proxyEnvFromSession(metadata) + expect(roundTripped.HTTP_PROXY).toBe(Orchestrator.BURP_PROXY_URL) + expect(roundTripped.HTTPS_PROXY).toBe(Orchestrator.BURP_PROXY_URL) + expect(roundTripped.NO_PROXY).toBe(Orchestrator.BURP_NO_PROXY) + }) + + test("degrades to empty map for absent/malformed metadata (never throws in the shell hot path)", () => { + expect(Orchestrator.proxyEnvFromSession(undefined)).toEqual({}) + expect(Orchestrator.proxyEnvFromSession(null)).toEqual({}) + expect(Orchestrator.proxyEnvFromSession({})).toEqual({}) + expect(Orchestrator.proxyEnvFromSession("not-an-object")).toEqual({}) + expect(Orchestrator.proxyEnvFromSession({ [Orchestrator.SUBAGENT_PROXY_ENV_METADATA_KEY]: 42 })).toEqual({}) + // Non-string values are filtered out (only string map entries survive). + const mixed = { [Orchestrator.SUBAGENT_PROXY_ENV_METADATA_KEY]: { HTTP_PROXY: "http://x", BAD: 7 } } + expect(Orchestrator.proxyEnvFromSession(mixed)).toEqual({ HTTP_PROXY: "http://x" }) + }) +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a83d5951..965bc4f8 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -6,7 +6,7 @@ "license": "MIT", "private": true, "scripts": { - "typecheck": "tsgo --noEmit", + "typecheck": "../../node_modules/.bin/tsc --noEmit", "test": "bun test --timeout 30000 --only-failures", "test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip", "bench:test": "bun run script/bench-test-suite.ts", @@ -154,4 +154,4 @@ "overrides": { "drizzle-orm": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 54f7b576..93fb7e96 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -11,6 +11,7 @@ import { Language, type Node } from "web-tree-sitter" import { FSUtil } from "@pentestcode/core/fs-util" import { EngagementStore } from "@pentestcode/core/engagement/store" import { ScopeMatcher } from "@pentestcode/core/engagement/scope-matcher" +import { Orchestrator } from "@pentestcode/core/engagement/orchestrator" import { fileURLToPath } from "url" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -19,6 +20,7 @@ import { ShellID } from "./shell/id" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" +import { Session } from "@/session/session" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellPrompt, type Parameters } from "./shell/prompt" @@ -347,6 +349,7 @@ export const ShellTool = Tool.define( const plugin = yield* Plugin.Service const flags = yield* RuntimeFlags.Service const engStore = yield* EngagementStore.Service + const sessions = yield* Session.Service // Pentest default: scans (nuclei/nmap -p-/ffuf/sqlmap) routinely run several // minutes, so the upstream 2min coding default was killing them mid-run and // pushing the agent into wrong shortcuts (e.g. grepping the nuclei template lib @@ -445,10 +448,15 @@ export const ShellTool = Tool.define( }) const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + // Subagent proxy env, stashed by the spawn driver in session metadata (see + // Orchestrator.subagentProxy / SUBAGENT_PROXY_ENV_METADATA_KEY). Seeded as the + // plugin hook's default output so user plugins can still augment/override. + const session = yield* sessions.get(ctx.sessionID).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + const proxyEnv = Orchestrator.proxyEnvFromSession(session?.metadata) const extra = yield* plugin.trigger( "shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, - { env: {} }, + { env: proxyEnv }, ) return { ...process.env, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 45d3a85b..82a1d61e 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -517,6 +517,11 @@ export const makeOrchestratedSpawner = Effect.fn("Orchestrator.makeSpawner")(fun Effect.gen(function* () { const subagentType = node.assignedAgent! const description = node.description + // Burp proxy env + prompt, computed ONCE here (the spawn driver) where the + // task node / assignedAgent is known and opt-out is decided. The env is + // stashed in the session metadata for the shell spawn to read back; the + // prompt is appended to the subagent's instructions. + const proxy = Orchestrator.subagentProxy(node) const cfg = yield* config.get() const ops = ctx.extra?.promptOps as TaskPromptOps | undefined if (!ops) return @@ -546,6 +551,7 @@ export const makeOrchestratedSpawner = Effect.fn("Orchestrator.makeSpawner")(fun parentID: ctx.sessionID, title: description + ` (@${next.name} subagent)`, agent: next.name, + metadata: { [Orchestrator.SUBAGENT_PROXY_ENV_METADATA_KEY]: proxy.env }, permission: [ ...childPermission, ...childToolDenies.filter( @@ -589,6 +595,7 @@ export const makeOrchestratedSpawner = Effect.fn("Orchestrator.makeSpawner")(fun if (priorContexts.length > 0) blocks.push(formatPriorContext(priorContexts)) if (otherAgents.length > 0) blocks.push(formatOtherAgentsWork(otherAgents)) if (siblings.length > 0) blocks.push(formatInFlightSiblings(siblings)) + if (proxy.prompt) blocks.push(proxy.prompt) const augmentedPrompt = blocks.join("\n\n") + "\n\n" + buildObjectivePrompt(node) const runTask = Effect.fn("Orchestrator.runTask")(function* () { @@ -937,6 +944,13 @@ export const TaskTool = Tool.define( return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)) } + // Burp proxy env + prompt, computed at the spawn driver. This is the + // non-orchestrated dispatch path (no TaskNode), so we derive them from + // params.subagent_type directly. The env is stashed in the session metadata + // for the shell spawn to read back; the prompt is appended to the prompt. + const proxyEnv = Orchestrator.proxyEnvForSubagent({ assignedAgent: params.subagent_type }) + const proxyPrompt = Orchestrator.proxyEnvPrompt({ assignedAgent: params.subagent_type }) + const session = params.task_id ? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined))) : undefined @@ -964,6 +978,7 @@ export const TaskTool = Tool.define( parentID: ctx.sessionID, title: params.description + ` (@${next.name} subagent)`, agent: next.name, + metadata: { [Orchestrator.SUBAGENT_PROXY_ENV_METADATA_KEY]: proxyEnv }, permission: [ ...childPermission, ...childToolDenies.filter( @@ -1023,6 +1038,7 @@ export const TaskTool = Tool.define( if (priorContexts.length > 0) blocks.push(formatPriorContext(priorContexts)) if (otherAgents.length > 0) blocks.push(formatOtherAgentsWork(otherAgents)) if (inFlight.length > 0) blocks.push(formatInFlightSiblings(inFlight)) + if (proxyPrompt) blocks.push(proxyPrompt) if (blocks.length > 0) augmentedPrompt = blocks.join("\n\n") + "\n\n" + params.prompt } diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 97c46bce..2e7d9200 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -23,6 +23,9 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { InstanceStore } from "@/project/instance-store" import { EngagementStore } from "@pentestcode/core/engagement/store" import { EventV2Bridge } from "@/event-v2-bridge" +import { Orchestrator } from "@pentestcode/core/engagement/orchestrator" +import { Session } from "../../src/session/session" +import { SessionProjector } from "@pentestcode/core/session/projector" const shellLayer = Layer.mergeAll( LayerNode.compile( @@ -36,6 +39,8 @@ const shellLayer = Layer.mergeAll( RuntimeFlags.node, EngagementStore.node, EventV2Bridge.node, + Session.node, + SessionProjector.node, ]), ), testInstanceStoreLayer, @@ -1201,3 +1206,49 @@ describe("tool.shell truncation", () => { ), ) }) + +describe("tool.shell Burp proxy injection", () => { + it.live("a subagent session's shell sees the proxy env stashed by the spawn driver", () => + runIn( + projectRoot, + Effect.gen(function* () { + const sessions = yield* Session.Service + // Simulate the spawn driver stashing proxy env in the subagent session metadata. + const subagent = yield* sessions.create({ + parentID: ctx.sessionID, + title: "proxy subagent test", + agent: "exploiter", + metadata: { + [Orchestrator.SUBAGENT_PROXY_ENV_METADATA_KEY]: Orchestrator.proxyEnvForSubagent({ + assignedAgent: "exploiter", + }), + }, + }) + const result = yield* run( + { command: `printf '%s' "$HTTP_PROXY"` }, + { ...ctx, sessionID: subagent.id }, + ) + expect(result.output).toContain(Orchestrator.BURP_PROXY_URL) + }), + ), + ) + + it.live("a non-subagent (root) session gets no proxy env injected", () => + runIn( + projectRoot, + Effect.gen(function* () { + const sessions = yield* Session.Service + const root = yield* sessions.create({ + title: "root session test", + agent: "build", + // No proxyEnv metadata — a primary session is not proxied. + }) + const result = yield* run( + { command: `printf '%s' "$HTTP_PROXY"` }, + { ...ctx, sessionID: root.id }, + ) + expect(result.output).not.toContain(Orchestrator.BURP_PROXY_URL) + }), + ), + ) +}) diff --git a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch deleted file mode 100644 index f7267890..00000000 --- a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch +++ /dev/null @@ -1,285 +0,0 @@ -diff --git a/photon_rs.js b/photon_rs.js -index 8f4144d..29a964a 100644 ---- a/photon_rs.js -+++ b/photon_rs.js -@@ -1,6 +1,7 @@ - - let imports = {}; --imports['__wbindgen_placeholder__'] = module.exports; -+const __wbindgen_placeholder__ = {}; -+imports['__wbindgen_placeholder__'] = __wbindgen_placeholder__; - let wasm; - const { TextEncoder, TextDecoder } = require(`util`); - -@@ -4272,12 +4273,12 @@ class Rgba { - } - module.exports.Rgba = Rgba; - --module.exports.__wbg_new_abda76e883ba8a5f = function() { -+__wbindgen_placeholder__.__wbg_new_abda76e883ba8a5f = function() { - const ret = new Error(); - return ret; - }; - --module.exports.__wbg_stack_658279fe44541cf6 = function(arg0, arg1) { -+__wbindgen_placeholder__.__wbg_stack_658279fe44541cf6 = function(arg0, arg1) { - const ret = arg1.stack; - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; -@@ -4285,7 +4286,7 @@ module.exports.__wbg_stack_658279fe44541cf6 = function(arg0, arg1) { - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - --module.exports.__wbg_error_f851667af71bcfc6 = function(arg0, arg1) { -+__wbindgen_placeholder__.__wbg_error_f851667af71bcfc6 = function(arg0, arg1) { - let deferred0_0; - let deferred0_1; - try { -@@ -4297,7 +4298,7 @@ module.exports.__wbg_error_f851667af71bcfc6 = function(arg0, arg1) { - } - }; - --module.exports.__wbg_instanceof_Window_c4b70662a0d2c5ec = function(arg0) { -+__wbindgen_placeholder__.__wbg_instanceof_Window_c4b70662a0d2c5ec = function(arg0) { - let result; - try { - result = arg0 instanceof Window; -@@ -4308,42 +4309,42 @@ module.exports.__wbg_instanceof_Window_c4b70662a0d2c5ec = function(arg0) { - return ret; - }; - --module.exports.__wbg_document_e5c1786dea6542e4 = function(arg0) { -+__wbindgen_placeholder__.__wbg_document_e5c1786dea6542e4 = function(arg0) { - const ret = arg0.document; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }; - --module.exports.__wbg_body_e70ae6abd01ae584 = function(arg0) { -+__wbindgen_placeholder__.__wbg_body_e70ae6abd01ae584 = function(arg0) { - const ret = arg0.body; - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }; - --module.exports.__wbg_createElement_5d4c76f218b78145 = function() { return handleError(function (arg0, arg1, arg2) { -+__wbindgen_placeholder__.__wbg_createElement_5d4c76f218b78145 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.createElement(getStringFromWasm0(arg1, arg2)); - return ret; - }, arguments) }; - --module.exports.__wbg_width_4c6f0048d64cf86b = function(arg0) { -+__wbindgen_placeholder__.__wbg_width_4c6f0048d64cf86b = function(arg0) { - const ret = arg0.width; - return ret; - }; - --module.exports.__wbg_height_21f0d3fd8f753394 = function(arg0) { -+__wbindgen_placeholder__.__wbg_height_21f0d3fd8f753394 = function(arg0) { - const ret = arg0.height; - return ret; - }; - --module.exports.__wbg_width_79e0847ed5883b03 = function(arg0) { -+__wbindgen_placeholder__.__wbg_width_79e0847ed5883b03 = function(arg0) { - const ret = arg0.width; - return ret; - }; - --module.exports.__wbg_height_e4e4e4779f8feac0 = function(arg0) { -+__wbindgen_placeholder__.__wbg_height_e4e4e4779f8feac0 = function(arg0) { - const ret = arg0.height; - return ret; - }; - --module.exports.__wbg_data_fda507064d127f5b = function(arg0, arg1) { -+__wbindgen_placeholder__.__wbg_data_fda507064d127f5b = function(arg0, arg1) { - const ret = arg1.data; - const ptr1 = passArray8ToWasm0(ret, wasm.__wbindgen_malloc); - const len1 = WASM_VECTOR_LEN; -@@ -4351,12 +4352,12 @@ module.exports.__wbg_data_fda507064d127f5b = function(arg0, arg1) { - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - --module.exports.__wbg_newwithu8clampedarrayandsh_1fddccb3a94a5e05 = function() { return handleError(function (arg0, arg1, arg2, arg3) { -+__wbindgen_placeholder__.__wbg_newwithu8clampedarrayandsh_1fddccb3a94a5e05 = function() { return handleError(function (arg0, arg1, arg2, arg3) { - const ret = new ImageData(getClampedArrayU8FromWasm0(arg0, arg1), arg2 >>> 0, arg3 >>> 0); - return ret; - }, arguments) }; - --module.exports.__wbg_instanceof_CanvasRenderingContext2d_3abbe7ec7af32cae = function(arg0) { -+__wbindgen_placeholder__.__wbg_instanceof_CanvasRenderingContext2d_3abbe7ec7af32cae = function(arg0) { - let result; - try { - result = arg0 instanceof CanvasRenderingContext2D; -@@ -4367,24 +4368,24 @@ module.exports.__wbg_instanceof_CanvasRenderingContext2d_3abbe7ec7af32cae = fun - return ret; - }; - --module.exports.__wbg_drawImage_fede06db74e39a60 = function() { return handleError(function (arg0, arg1, arg2, arg3) { -+__wbindgen_placeholder__.__wbg_drawImage_fede06db74e39a60 = function() { return handleError(function (arg0, arg1, arg2, arg3) { - arg0.drawImage(arg1, arg2, arg3); - }, arguments) }; - --module.exports.__wbg_drawImage_f395c8e43c79a909 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9) { -+__wbindgen_placeholder__.__wbg_drawImage_f395c8e43c79a909 = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9) { - arg0.drawImage(arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9); - }, arguments) }; - --module.exports.__wbg_getImageData_5e1c242046e6b59e = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { -+__wbindgen_placeholder__.__wbg_getImageData_5e1c242046e6b59e = function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { - const ret = arg0.getImageData(arg1, arg2, arg3, arg4); - return ret; - }, arguments) }; - --module.exports.__wbg_putImageData_a8b3e177ee06d521 = function() { return handleError(function (arg0, arg1, arg2, arg3) { -+__wbindgen_placeholder__.__wbg_putImageData_a8b3e177ee06d521 = function() { return handleError(function (arg0, arg1, arg2, arg3) { - arg0.putImageData(arg1, arg2, arg3); - }, arguments) }; - --module.exports.__wbg_instanceof_HtmlCanvasElement_25d964a0dde6717e = function(arg0) { -+__wbindgen_placeholder__.__wbg_instanceof_HtmlCanvasElement_25d964a0dde6717e = function(arg0) { - let result; - try { - result = arg0 instanceof HTMLCanvasElement; -@@ -4395,93 +4396,93 @@ module.exports.__wbg_instanceof_HtmlCanvasElement_25d964a0dde6717e = function(a - return ret; - }; - --module.exports.__wbg_width_dc225e55343b745e = function(arg0) { -+__wbindgen_placeholder__.__wbg_width_dc225e55343b745e = function(arg0) { - const ret = arg0.width; - return ret; - }; - --module.exports.__wbg_setwidth_488780db69b08846 = function(arg0, arg1) { -+__wbindgen_placeholder__.__wbg_setwidth_488780db69b08846 = function(arg0, arg1) { - arg0.width = arg1 >>> 0; - }; - --module.exports.__wbg_height_3a8bec2f3fe71b26 = function(arg0) { -+__wbindgen_placeholder__.__wbg_height_3a8bec2f3fe71b26 = function(arg0) { - const ret = arg0.height; - return ret; - }; - --module.exports.__wbg_setheight_1761808c18403921 = function(arg0, arg1) { -+__wbindgen_placeholder__.__wbg_setheight_1761808c18403921 = function(arg0, arg1) { - arg0.height = arg1 >>> 0; - }; - --module.exports.__wbg_getContext_fc99dbd3a9a7e318 = function() { return handleError(function (arg0, arg1, arg2) { -+__wbindgen_placeholder__.__wbg_getContext_fc99dbd3a9a7e318 = function() { return handleError(function (arg0, arg1, arg2) { - const ret = arg0.getContext(getStringFromWasm0(arg1, arg2)); - return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); - }, arguments) }; - --module.exports.__wbg_settextContent_f82a86a8df347e1c = function(arg0, arg1, arg2) { -+__wbindgen_placeholder__.__wbg_settextContent_f82a86a8df347e1c = function(arg0, arg1, arg2) { - arg0.textContent = arg1 === 0 ? undefined : getStringFromWasm0(arg1, arg2); - }; - --module.exports.__wbg_appendChild_fa3b00dade9fc4cf = function() { return handleError(function (arg0, arg1) { -+__wbindgen_placeholder__.__wbg_appendChild_fa3b00dade9fc4cf = function() { return handleError(function (arg0, arg1) { - const ret = arg0.appendChild(arg1); - return ret; - }, arguments) }; - --module.exports.__wbg_newnoargs_e643855c6572a4a8 = function(arg0, arg1) { -+__wbindgen_placeholder__.__wbg_newnoargs_e643855c6572a4a8 = function(arg0, arg1) { - const ret = new Function(getStringFromWasm0(arg0, arg1)); - return ret; - }; - --module.exports.__wbg_call_f96b398515635514 = function() { return handleError(function (arg0, arg1) { -+__wbindgen_placeholder__.__wbg_call_f96b398515635514 = function() { return handleError(function (arg0, arg1) { - const ret = arg0.call(arg1); - return ret; - }, arguments) }; - --module.exports.__wbg_self_b9aad7f1c618bfaf = function() { return handleError(function () { -+__wbindgen_placeholder__.__wbg_self_b9aad7f1c618bfaf = function() { return handleError(function () { - const ret = self.self; - return ret; - }, arguments) }; - --module.exports.__wbg_window_55e469842c98b086 = function() { return handleError(function () { -+__wbindgen_placeholder__.__wbg_window_55e469842c98b086 = function() { return handleError(function () { - const ret = window.window; - return ret; - }, arguments) }; - --module.exports.__wbg_globalThis_d0957e302752547e = function() { return handleError(function () { -+__wbindgen_placeholder__.__wbg_globalThis_d0957e302752547e = function() { return handleError(function () { - const ret = globalThis.globalThis; - return ret; - }, arguments) }; - --module.exports.__wbg_global_ae2f87312b8987fb = function() { return handleError(function () { -+__wbindgen_placeholder__.__wbg_global_ae2f87312b8987fb = function() { return handleError(function () { - const ret = global.global; - return ret; - }, arguments) }; - --module.exports.__wbindgen_is_undefined = function(arg0) { -+__wbindgen_placeholder__.__wbindgen_is_undefined = function(arg0) { - const ret = arg0 === undefined; - return ret; - }; - --module.exports.__wbg_buffer_fcbfb6d88b2732e9 = function(arg0) { -+__wbindgen_placeholder__.__wbg_buffer_fcbfb6d88b2732e9 = function(arg0) { - const ret = arg0.buffer; - return ret; - }; - --module.exports.__wbg_new_bc5d9aad3f9ac80e = function(arg0) { -+__wbindgen_placeholder__.__wbg_new_bc5d9aad3f9ac80e = function(arg0) { - const ret = new Uint8Array(arg0); - return ret; - }; - --module.exports.__wbg_set_4b3aa8445ac1e91c = function(arg0, arg1, arg2) { -+__wbindgen_placeholder__.__wbg_set_4b3aa8445ac1e91c = function(arg0, arg1, arg2) { - arg0.set(arg1, arg2 >>> 0); - }; - --module.exports.__wbg_length_d9c4ded7e708c6a1 = function(arg0) { -+__wbindgen_placeholder__.__wbg_length_d9c4ded7e708c6a1 = function(arg0) { - const ret = arg0.length; - return ret; - }; - --module.exports.__wbindgen_debug_string = function(arg0, arg1) { -+__wbindgen_placeholder__.__wbindgen_debug_string = function(arg0, arg1) { - const ret = debugString(arg1); - const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); - const len1 = WASM_VECTOR_LEN; -@@ -4489,16 +4490,16 @@ module.exports.__wbindgen_debug_string = function(arg0, arg1) { - getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); - }; - --module.exports.__wbindgen_throw = function(arg0, arg1) { -+__wbindgen_placeholder__.__wbindgen_throw = function(arg0, arg1) { - throw new Error(getStringFromWasm0(arg0, arg1)); - }; - --module.exports.__wbindgen_memory = function() { -+__wbindgen_placeholder__.__wbindgen_memory = function() { - const ret = wasm.memory; - return ret; - }; - --module.exports.__wbindgen_init_externref_table = function() { -+__wbindgen_placeholder__.__wbindgen_init_externref_table = function() { - const table = wasm.__wbindgen_export_2; - const offset = table.grow(4); - table.set(0, undefined); -@@ -4509,7 +4510,8 @@ module.exports.__wbindgen_init_externref_table = function() { - ; - }; - --const path = require('path').join(__dirname, 'photon_rs_bg.wasm'); -+// Allow opencode's Bun compiled binary to point photon-node at its embedded wasm asset. -+const path = globalThis.__OPENCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm'); - const bytes = require('fs').readFileSync(path); - - const wasmModule = new WebAssembly.Module(bytes);