diff --git a/README.md b/README.md index 1e14a22..0a1775e 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ export CLAUDECODE_HOME="$ISOLATED_ROOT/claudecode-home" export AMP_CLI_HOME="$ISOLATED_ROOT/ampcli-home" cd "$WORKDIR" npm init -y -npm install /opendevbrowser-0.0.25.tgz +npm install /opendevbrowser-0.0.26.tgz npx --no-install opendevbrowser --help npx --no-install opendevbrowser help ``` diff --git a/docs/AGENTS.md b/docs/AGENTS.md index fd3cf55..e78cdc3 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -64,7 +64,7 @@ When release-gate automation changes: - `docs/RELEASE_RUNBOOK.md` - `docs/EXTENSION_RELEASE_RUNBOOK.md` - `docs/DISTRIBUTION_PLAN.md` -- the current version-scoped release evidence doc (for this release: `docs/RELEASE_0.0.21_EVIDENCE.md`) +- the current version-scoped release evidence doc (for this release: `docs/RELEASE_0.0.26_EVIDENCE.md`) - older ledgers stay historical-only and should receive explicit status clarifications only - automation scripts: - `scripts/audit-zombie-files.mjs` diff --git a/docs/CLI.md b/docs/CLI.md index 52d5732..3a039d5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -61,7 +61,7 @@ export CLAUDECODE_HOME="$ISOLATED_ROOT/claudecode-home" export AMP_CLI_HOME="$ISOLATED_ROOT/ampcli-home" cd "$WORKDIR" npm init -y -npm install /opendevbrowser-0.0.25.tgz +npm install /opendevbrowser-0.0.26.tgz npx --no-install opendevbrowser --help npx --no-install opendevbrowser help ``` @@ -510,7 +510,7 @@ npx opendevbrowser inspiredesign run --brief "Extract a reusable dashboard desig Flags: - `--brief` (required) - `--url` (repeatable inspiration URL input) -- `--capture-mode` (`off|deep`) +- `--capture-mode` (`off|deep`; `off` is ignored when any `--url` is provided) - `--include-prototype-guidance` (`true|false`; bare flag means `true`) - `--mode` (`compact|json|md|context|path`) - `--timeout-ms` @@ -1738,7 +1738,7 @@ npm run test -- tests/providers-performance-gate.test.ts These commands are release guards, not the live release-proof lane. Use the direct-run harness commands above for release evidence. -Release gate source of truth: `docs/RELEASE_RUNBOOK.md` and `docs/RELEASE_0.0.21_EVIDENCE.md`. +Release gate source of truth: `docs/RELEASE_RUNBOOK.md` and `docs/RELEASE_0.0.26_EVIDENCE.md`. Benchmark fixture manifest: `docs/benchmarks/provider-fixtures.md`. --- diff --git a/docs/FIRST_RUN_ONBOARDING.md b/docs/FIRST_RUN_ONBOARDING.md index 1763799..94878c0 100644 --- a/docs/FIRST_RUN_ONBOARDING.md +++ b/docs/FIRST_RUN_ONBOARDING.md @@ -27,7 +27,7 @@ This guide is the shipping checklist for validating OpenDevBrowser as a new user ```bash cd npm pack -# -> opendevbrowser-0.0.25.tgz +# -> opendevbrowser-0.0.26.tgz ``` ## 2) Simulate a brand-new isolated user workspace @@ -44,7 +44,7 @@ export AMP_CLI_HOME="$WORKROOT/amp-home" mkdir -p "$WORKDIR" "$HOME" "$OPENCODE_CONFIG_DIR" "$OPENCODE_CACHE_DIR" "$CODEX_HOME" "$CLAUDECODE_HOME" "$AMP_CLI_HOME" cd "$WORKDIR" npm init -y -npm install /opendevbrowser-0.0.25.tgz +npm install /opendevbrowser-0.0.26.tgz npx --no-install opendevbrowser version --output-format json ``` diff --git a/docs/RELEASE_0.0.26_EVIDENCE.md b/docs/RELEASE_0.0.26_EVIDENCE.md new file mode 100644 index 0000000..ad57f64 --- /dev/null +++ b/docs/RELEASE_0.0.26_EVIDENCE.md @@ -0,0 +1,143 @@ +# v0.0.26 Release Evidence + +Status: active release ledger +Target release date: 2026-04-24 +Last updated: 2026-04-24 + +## Scope + +Tracks the `0.0.26` release cycle for the post-`0.0.25` review fixes, daemon lifecycle hardening, canvas/CDP live proof, annotation timeout-boundary evidence, packaging proof, and public publication evidence. + +## Baseline comparison + +- Reference release: npm `latest` is `0.0.25` before publish. +- Target branch: `main` +- Release-prep branch: `codex/release-0-0-26` +- Target tag: `v0.0.26` +- GitHub release assets expected after release: + - `opendevbrowser-extension.zip` + - `opendevbrowser-extension.zip.sha256` + +## Release summary + +- Recovers from stale cached relay bindings even when the retried daemon call already requires a binding. +- Keeps `/status` probes inside the requested timeout budget even if the daemon stalls after returning headers. +- Rejects stale daemon stop attempts using current daemon fingerprints, including stale OpenCode cached package attempts. +- Reports fingerprint-rejected `serve --stop` and `daemon uninstall` paths explicitly instead of silently ignoring them. +- Keeps live-regression release-gate child process failures truthful: nonzero child exit forces scenario `fail` while preserving child summary status as evidence. +- Aligns package, extension, lockfile, and tarball-reference versions at `0.0.26`. + +## Version authority + +- `package.json`: `0.0.26` +- `package-lock.json`: `0.0.26` +- `extension/package.json`: `0.0.26` +- `extension/manifest.json`: `0.0.26` +- `npm view opendevbrowser version` before publish: `0.0.25` + +## Mandatory release gates + +- [x] `npm run lint` + - Result: passed. +- [x] `npm run typecheck` + - Result: passed. +- [x] `npm run extension:sync` + - Result: passed; extension metadata already at `0.0.26`. +- [x] `npm run build` + - Result: passed. +- [x] `npm run extension:build` + - Result: passed. +- [x] `npm run version:check` + - Result: passed, `Version check passed: 0.0.26`. +- [x] `npm run test:release-gate` + - Result: passed. +- [x] `npm run test` + - Result: passed. + - Test files: `266 passed | 1 skipped (267)`. + - Tests: `3943 passed | 1 skipped (3944)`. + - Coverage: `98.11%` statements, `97.01%` branches, `97.78%` functions, `98.17%` lines. +- [x] `node scripts/audit-zombie-files.mjs` + - Result: passed, `ok=true`, `scanned=1008`, `flagged=[]`. +- [x] `node scripts/docs-drift-check.mjs` + - Result: passed, `ok=true`, version `0.0.26`, counts `77` CLI commands, `70` tools, `59` `/ops`, `35` `/canvas`. +- [x] `node scripts/chrome-store-compliance-check.mjs` + - Result: passed, manifest v3, extension version `0.0.26`, all permission, privacy, and asset checks passed. +- [x] `./skills/opendevbrowser-best-practices/scripts/validate-skill-assets.sh` + - Result: passed, `22` files referenced/present and `10` JSON templates parsed. +- [x] `./skills/opendevbrowser-best-practices/scripts/run-robustness-audit.sh` + - Result: passed for all robustness checks. +- [x] `npx opendevbrowser --help` + - Result: passed, generated help lists `77` commands and `70` tools. +- [x] `npx opendevbrowser help` + - Result: passed, generated help lists `77` commands and `70` tools. +- [x] `node scripts/cli-smoke-test.mjs` + - Result: passed on sequential run. +- [x] `node scripts/cli-onboarding-smoke.mjs` + - Result: passed on sequential run. +- [x] `npm pack --json` + - Result: passed. + - Prepack reran `version:check`, `build`, and `extension:build`. +- [x] `npm run extension:pack` + - Result: passed after final `npm pack` build. + +## Live release proof + +- [x] `node scripts/live-regression-direct.mjs --release-gate --out artifacts/release/v0.0.26/live-regression-direct.json` + - Result: passed with `ok=true` after final packaging rebuild. + - Counts: `pass=6`, `skipped=2`, `fail=0`, `env_limited=0`, `expected_timeout=0`. + - Canvas extension: `pass`. + - Canvas CDP: `pass`. + - Annotation relay: `skipped`, expected manual annotation timeout boundary, not a passing annotation capture proof. + - Annotation direct: `skipped`, expected manual annotation timeout boundary, not a passing annotation capture proof. + +- [ ] `node scripts/provider-direct-runs.mjs --release-gate --out artifacts/release/v0.0.26/provider-direct-runs.json` + - Result: executed, but not a passing strict gate. It exited nonzero by strict release-gate policy because strict mode promotes live `env_limited` rows. + - Counts: `pass=18`, `env_limited=12`, `fail=0`, `skipped=0`, `expected_timeout=0`. + - Verdict: no provider code failures; strict live environment limitations remain truthfully recorded and need explicit release acceptance before publish. + +- [x] `node scripts/provider-direct-runs.mjs --smoke --out artifacts/release/v0.0.26/provider-direct-runs-smoke.json` + - Result: passed with `ok=true`. + - Counts: `pass=8`, `env_limited=3`, `skipped=2`, `fail=0`, `expected_timeout=0`. + +## Artifacts + +- [x] `opendevbrowser-0.0.26.tgz` + - SHA-256: `68790192961d6a0838dcdea46440a4ac7e2ec74acf67382c6d981efd06093117` + - npm pack shasum: `b4b7ad44bb4271f2a4b5ca9e1bcda21cf81d8b09` + - npm pack integrity: `sha512-yKAQNPhY8WZU04Gt2CIk1cHW2ULpBAb+q9z+SVyb0acPGd8yKdF7JdV9y5xpFhTw7Vc8jISRXVGT/9CpJkOaPw==` + - Package size: `2102968` bytes. + - Unpacked size: `10961171` bytes. + - Total files: `982` + +- [x] `opendevbrowser-extension.zip` + - SHA-256: `a49b1b651f62bbb713db3a50d359b6a6284a0c269e6a5cea268c41ac95fad4d6` + - Package size: `174915` bytes. + +## Repo sanity checks + +- [x] `git diff --check` + - Result: passed with no whitespace errors. +- [x] `git status --short` + - Result: expected release-prep source, test, docs, and version-owner changes only. + +## External release workflow evidence + +- [ ] npm publish verification + - Pre-publish `npm view opendevbrowser version`: `0.0.25` + - Local npm auth: `npm whoami` returned `bishopdotun` +- [ ] Registry consumer smoke JSON + - Pending until npm publish. +- [ ] GitHub release URL + - Pending until PR merge or release cut. +- [ ] GitHub release asset verification + - Pending until GitHub release upload. +- [ ] Chrome Web Store upload status + - Blocked in this shell: `CWS_CLIENT_ID`, `CWS_CLIENT_SECRET`, `CWS_REFRESH_TOKEN`, and `CWS_EXTENSION_ID` are not present. +- [ ] Chrome Web Store publish or submit-for-review status + - Blocked until Chrome Web Store credentials are available. + +## Notes + +- `0.0.25` is already published to npm and is the baseline for this follow-up fix release. +- CLI smoke and onboarding smoke share the default relay port. The release evidence uses sequential runs to avoid a false relay-port collision. +- Keep this ledger active until npm publish, GitHub release assets, and Chrome Web Store status are completed or blocked with final evidence. diff --git a/extension/manifest.json b/extension/manifest.json index f844d03..275fc43 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "OpenDevBrowser Relay", - "version": "0.0.25", + "version": "0.0.26", "description": "Optional bridge to reuse existing Chrome tabs with OpenDevBrowser.", "permissions": [ "debugger", diff --git a/extension/package.json b/extension/package.json index 12e6132..15050dd 100644 --- a/extension/package.json +++ b/extension/package.json @@ -1,6 +1,6 @@ { "name": "opendevbrowser-extension", - "version": "0.0.25", + "version": "0.0.26", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index 2a0301d..a6f9689 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opendevbrowser", - "version": "0.0.24", + "version": "0.0.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opendevbrowser", - "version": "0.0.24", + "version": "0.0.26", "license": "MIT", "dependencies": { "@opencode-ai/plugin": "^1.2.25", diff --git a/package.json b/package.json index 4cde3e4..1491056 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opendevbrowser", - "version": "0.0.25", + "version": "0.0.26", "description": "Browser automation runtime with snapshot-refs-actions, browser replay screencasts, public read-only desktop observation, and browser-scoped computer-use orchestration", "type": "module", "main": "dist/index.js", diff --git a/scripts/cli-smoke-test.mjs b/scripts/cli-smoke-test.mjs index 560d9b0..a5237e8 100644 --- a/scripts/cli-smoke-test.mjs +++ b/scripts/cli-smoke-test.mjs @@ -20,7 +20,7 @@ const DAEMON_LOG_DIR_PREFIX = "opendevbrowser-daemon-"; const DAEMON_STDOUT_LOG = "daemon.stdout.log"; const DAEMON_STDERR_LOG = "daemon.stderr.log"; const DEFAULT_BACKGROUND_SHELL = process.env.SHELL || "/bin/sh"; -const BACKGROUND_NODE_COMMAND = "node"; +const BACKGROUND_NODE_COMMAND = JSON.stringify(process.execPath); function isValidPid(value) { return Number.isInteger(value) && value > 0; diff --git a/scripts/live-regression-direct.mjs b/scripts/live-regression-direct.mjs index a128bcb..d2eb6fe 100644 --- a/scripts/live-regression-direct.mjs +++ b/scripts/live-regression-direct.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { spawn } from "node:child_process"; import path from "node:path"; import { CANVAS_LIVE_TIMEOUTS_MS, @@ -13,6 +14,10 @@ import { writeJson } from "./live-direct-utils.mjs"; +const DAEMON_RECOVERY_TIMEOUT_MS = 45_000; +const DAEMON_RECOVERY_POLL_MS = 1_000; +const EXTENSION_RECONNECT_GRACE_MS = 30_000; + function readDaemonStatus() { return runCli(["status", "--daemon"], { allowFailure: true, @@ -20,11 +25,137 @@ function readDaemonStatus() { }); } +function startDetachedDaemon() { + const cliPath = path.join(ROOT, "dist", "cli", "index.js"); + const child = spawn(process.execPath, [cliPath, "serve", "--output-format", "json"], { + cwd: ROOT, + detached: true, + stdio: "ignore" + }); + child.unref(); +} + +export function isCurrentDaemonStatus(status) { + return status?.status === 0 && status.json?.data?.fingerprintCurrent !== false; +} + +export function daemonStatusDetail(status) { + if (status?.status !== 0) { + return status?.detail ?? "daemon_status_unavailable"; + } + return status.json?.data?.fingerprintCurrent === false + ? "daemon_fingerprint_mismatch" + : null; +} + +export function detailSuggestsDaemonLoss(detail) { + return /daemon not running/i.test(String(detail ?? "")); +} + +export function classifyInitialDaemonStep({ + initialDaemonOk, + initialDaemonRecovered, + releaseGate, + detail +}) { + return { + status: initialDaemonOk && !(releaseGate && initialDaemonRecovered) ? "pass" : "fail", + detail: initialDaemonOk + ? (initialDaemonRecovered ? "daemon_recovered_before_run" : null) + : detail + }; +} + +export function classifyDaemonLossStep(step, releaseGate) { + if (!releaseGate || !detailSuggestsDaemonLoss(step.detail)) { + return step; + } + return { + ...step, + status: "fail", + data: { + ...(step.data ?? {}), + releaseGateDaemonLoss: true + } + }; +} + +export function buildScenarioDaemonRecoveryStep(scenario, scenarioDaemonStatus) { + if (scenarioDaemonStatus.currentStatus && !isCurrentDaemonStatus(scenarioDaemonStatus.currentStatus)) { + return { + id: scenario.id, + status: "fail", + detail: daemonStatusDetail(scenarioDaemonStatus.currentStatus), + data: { + currentDaemonStatus: scenarioDaemonStatus.currentStatus.status, + recoveredBeforeScenario: scenarioDaemonStatus.recovered + } + }; + } + if (!scenarioDaemonStatus.recovered) { + return null; + } + return { + id: scenario.id, + status: "fail", + detail: "daemon_recovered_before_scenario", + data: { + recoveredBeforeScenario: true, + initialProbeDetail: scenarioDaemonStatus.initialStatus.detail ?? null + } + }; +} + +export async function recoverDaemonStatus({ + statusReader = readDaemonStatus, + daemonStarter = startDetachedDaemon, + recoverTimeoutMs = DAEMON_RECOVERY_TIMEOUT_MS, + pollMs = DAEMON_RECOVERY_POLL_MS +} = {}) { + let currentStatus = statusReader(); + if (currentStatus.status === 0) { + return currentStatus; + } + + daemonStarter(); + const deadline = Date.now() + recoverTimeoutMs; + while (Date.now() < deadline) { + await sleep(pollMs); + currentStatus = statusReader(); + if (currentStatus.status === 0) { + return currentStatus; + } + } + + return currentStatus; +} + +export async function resolveInitialDaemonStatus({ + statusReader = readDaemonStatus, + recoverStatus = recoverDaemonStatus +} = {}) { + const initialStatus = statusReader(); + if (initialStatus.status === 0) { + return { + initialStatus, + currentStatus: initialStatus, + recovered: false + }; + } + + const currentStatus = await recoverStatus({ statusReader }); + return { + initialStatus, + currentStatus, + recovered: currentStatus.status === 0 + }; +} + export async function waitForExtensionReconnect({ scenario, initialExtensionReady, statusReader = readDaemonStatus, - reconnectGraceMs = 8_000, + reconnectGraceMs = EXTENSION_RECONNECT_GRACE_MS, pollMs = 1_000 }) { let currentDaemonStatus = statusReader(); @@ -106,13 +237,6 @@ export function buildScenarioCases() { { id: "feature.canvas.managed_headless", script: "scripts/canvas-live-workflow.mjs", args: ["--surface", "managed-headless"], timeoutMs: CANVAS_LIVE_TIMEOUTS_MS.managedHeadless }, { id: "feature.canvas.managed_headed", script: "scripts/canvas-live-workflow.mjs", args: ["--surface", "managed-headed"], timeoutMs: CANVAS_LIVE_TIMEOUTS_MS.managedHeaded }, { id: "feature.canvas.extension", script: "scripts/canvas-live-workflow.mjs", args: ["--surface", "extension"], requiresExtension: true, timeoutMs: CANVAS_LIVE_TIMEOUTS_MS.extension }, - { - id: "feature.canvas.cdp", - script: "scripts/canvas-live-workflow.mjs", - args: ["--surface", "cdp"], - requiresExtension: true, - timeoutMs: CANVAS_LIVE_TIMEOUTS_MS.cdp - }, { id: "feature.annotate.relay", script: "scripts/annotate-live-probe.mjs", @@ -128,6 +252,13 @@ export function buildScenarioCases() { supportsReleaseGate: true, timeoutMs: 180_000 }, + { + id: "feature.canvas.cdp", + script: "scripts/canvas-live-workflow.mjs", + args: ["--surface", "cdp"], + requiresExtension: true, + timeoutMs: CANVAS_LIVE_TIMEOUTS_MS.cdp + }, { id: "feature.cli.smoke", script: "scripts/cli-smoke-test.mjs", timeoutMs: 240_000 } ]; } @@ -146,7 +277,7 @@ export function classifyScenarioPreflight({ currentDaemonStatus }) { const relay = currentDaemonStatus.json?.data?.relay ?? null; - const currentDaemonOk = currentDaemonStatus.status === 0; + const currentDaemonOk = isCurrentDaemonStatus(currentDaemonStatus); const currentExtensionReady = relay?.extensionHandshakeComplete === true; if (!scenario.requiresExtension) { @@ -172,13 +303,15 @@ export function classifyScenarioPreflight({ return null; } -function resolveChildStep(scenario, child) { +export function resolveChildStep(scenario, child) { const summary = child.json?.summary ?? null; - const childStatus = summary?.status - ?? child.json?.status - ?? (child.status === 0 && child.json?.ok !== false ? "pass" : "fail"); + const childOk = child.status === 0 && child.json?.ok !== false; + const summaryStatus = summary?.status ?? child.json?.status ?? null; + const childStatus = childOk ? (summaryStatus ?? "pass") : "fail"; const artifactPath = child.json?.artifactPath ?? summary?.artifactPath ?? null; - const detail = summary?.detail ?? child.json?.detail ?? (childStatus === "pass" ? null : child.detail); + const detail = childOk + ? (summary?.detail ?? child.json?.detail ?? (childStatus === "pass" ? null : child.detail)) + : (child.detail ?? summary?.detail ?? child.json?.detail ?? `child exited with status ${child.status}`); return { id: scenario.id, @@ -187,8 +320,8 @@ function resolveChildStep(scenario, child) { data: { artifactPath, childStatus: child.status, - childOk: child.status === 0 && child.json?.ok !== false, - summaryStatus: summary?.status ?? child.json?.status ?? null, + childOk, + summaryStatus, stepCount: Array.isArray(summary?.steps) ? summary.steps.length : null } }; @@ -205,15 +338,29 @@ async function main() { steps: [] }; - const initialDaemonStatus = readDaemonStatus(); + const { + initialStatus: initialDaemonProbe, + currentStatus: initialDaemonStatus, + recovered: initialDaemonRecovered + } = await resolveInitialDaemonStatus(); const initialRelay = initialDaemonStatus.json?.data?.relay ?? null; - const initialDaemonOk = initialDaemonStatus.status === 0; + const initialDaemonOk = isCurrentDaemonStatus(initialDaemonStatus); const initialExtensionReady = initialRelay?.extensionHandshakeComplete === true; + const initialDaemonStep = classifyInitialDaemonStep({ + initialDaemonOk, + initialDaemonRecovered, + releaseGate: options.releaseGate, + detail: daemonStatusDetail(initialDaemonStatus) + }); pushStep(report, { id: "infra.daemon_status", - status: initialDaemonOk ? "pass" : "fail", - detail: initialDaemonOk ? null : initialDaemonStatus.detail, - data: initialDaemonStatus.json?.data ?? null + status: initialDaemonStep.status, + detail: initialDaemonStep.detail, + data: { + ...(initialDaemonStatus.json?.data ?? {}), + recoveredBeforeRun: initialDaemonRecovered, + initialProbeDetail: initialDaemonProbe.status === 0 ? null : initialDaemonProbe.detail + } }, { prefix: "[live-direct]", logProgress: !options.quiet }); if (!initialDaemonOk) { finalizeReport(report, { strictGate: options.releaseGate }); @@ -232,9 +379,21 @@ async function main() { const scriptPath = path.join(ROOT, scenario.script); let step; try { + const scenarioDaemonStatus = await resolveInitialDaemonStatus(); + const recoveryStep = options.releaseGate + ? buildScenarioDaemonRecoveryStep(scenario, scenarioDaemonStatus) + : null; + if (recoveryStep) { + step = recoveryStep; + pushStep(report, step, { prefix: "[live-direct]", logProgress: !options.quiet }); + continue; + } const currentDaemonStatus = await waitForExtensionReconnect({ scenario, - initialExtensionReady + initialExtensionReady, + statusReader: () => scenarioDaemonStatus.currentStatus.status === 0 + ? readDaemonStatus() + : scenarioDaemonStatus.currentStatus }); const preflightStep = classifyScenarioPreflight({ scenario, @@ -270,6 +429,40 @@ async function main() { } ); step = resolveChildStep(scenario, child); + step = classifyDaemonLossStep(step, options.releaseGate); + if (!options.releaseGate && detailSuggestsDaemonLoss(step.detail)) { + const recoveredAfterFailure = await recoverDaemonStatus(); + const retryStatus = await waitForExtensionReconnect({ + scenario, + initialExtensionReady + }); + const retryPreflight = classifyScenarioPreflight({ + scenario, + initialDaemonOk, + initialExtensionReady, + currentDaemonStatus: retryStatus + }); + if (recoveredAfterFailure.status === 0 && !retryPreflight) { + const retryChild = runNode( + [ + scriptPath, + ...buildChildArgs(scenario, options.releaseGate) + ], + { + allowFailure: true, + timeoutMs: scenario.timeoutMs ?? 900_000 + } + ); + const retryStep = resolveChildStep(scenario, retryChild); + step = { + ...retryStep, + data: { + ...retryStep.data, + recoveredDaemonAfterFailure: true + } + }; + } + } } catch (error) { step = { id: scenario.id, diff --git a/scripts/postbuild-dist.mjs b/scripts/postbuild-dist.mjs index 39886c6..27f63fd 100644 --- a/scripts/postbuild-dist.mjs +++ b/scripts/postbuild-dist.mjs @@ -1,7 +1,10 @@ -import { chmodSync, copyFileSync, existsSync, readdirSync, statSync } from "node:fs"; -import { resolve } from "node:path"; +import { createHash } from "node:crypto"; +import { chmodSync, copyFileSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; const distDir = resolve("dist"); +const DAEMON_FINGERPRINT_FILE = "daemon-fingerprint.json"; const canonicalizeGeneratedName = (name) => ( name @@ -10,7 +13,7 @@ const canonicalizeGeneratedName = (name) => ( .replace(/(?<=\.js) \d+(?=\.map$)/, "") ); -const normalizeGeneratedDir = (dir) => { +export const normalizeGeneratedDir = (dir) => { const groups = new Map(); for (const entry of readdirSync(dir)) { @@ -49,20 +52,66 @@ const normalizeGeneratedDir = (dir) => { } }; -normalizeGeneratedDir(distDir); -normalizeGeneratedDir(resolve(distDir, "cli")); +function resolveDaemonFingerprintSources(rootDistDir, currentDir = rootDistDir) { + return readdirSync(currentDir, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry) => { + const entryPath = resolve(currentDir, entry.name); + if (entry.isDirectory()) { + return resolveDaemonFingerprintSources(rootDistDir, entryPath); + } + if (!entry.isFile() || !entry.name.endsWith(".js")) { + return []; + } + return [entryPath]; + }); +} + +function buildDaemonFingerprint(rootDistDir, sourcePaths) { + const hash = createHash("sha256"); + for (const sourcePath of sourcePaths) { + hash.update(relative(rootDistDir, sourcePath)); + hash.update("\n"); + hash.update(readFileSync(sourcePath)); + hash.update("\n"); + } + return hash.digest("hex"); +} + +export function writeDaemonFingerprint(rootDistDir = distDir) { + const sourcePaths = resolveDaemonFingerprintSources(rootDistDir); + if (sourcePaths.length === 0) { + return null; + } + const fingerprint = buildDaemonFingerprint(rootDistDir, sourcePaths); + const targetPath = resolve(rootDistDir, DAEMON_FINGERPRINT_FILE); + writeFileSync(targetPath, `${JSON.stringify({ fingerprint }, null, 2)}\n`, "utf8"); + chmodSync(targetPath, statSync(sourcePaths[0]).mode); + return fingerprint; +} + +export function postbuildDist(rootDistDir = distDir) { + normalizeGeneratedDir(rootDistDir); + normalizeGeneratedDir(resolve(rootDistDir, "cli")); -for (const [sourceName, targetName] of [ - ["index.js", "opendevbrowser.js"], - ["index.js.map", "opendevbrowser.js.map"], - ["index.d.ts", "opendevbrowser.d.ts"], - ["index.d.ts.map", "opendevbrowser.d.ts.map"] -]) { - const sourcePath = resolve(distDir, sourceName); - const targetPath = resolve(distDir, targetName); - if (!existsSync(sourcePath)) { - continue; + for (const [sourceName, targetName] of [ + ["index.js", "opendevbrowser.js"], + ["index.js.map", "opendevbrowser.js.map"], + ["index.d.ts", "opendevbrowser.d.ts"], + ["index.d.ts.map", "opendevbrowser.d.ts.map"] + ]) { + const sourcePath = resolve(rootDistDir, sourceName); + const targetPath = resolve(rootDistDir, targetName); + if (!existsSync(sourcePath)) { + continue; + } + copyFileSync(sourcePath, targetPath); + chmodSync(targetPath, statSync(sourcePath).mode); } - copyFileSync(sourcePath, targetPath); - chmodSync(targetPath, statSync(sourcePath).mode); + + return writeDaemonFingerprint(rootDistDir); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + postbuildDist(); } diff --git a/scripts/sync-extension-version.mjs b/scripts/sync-extension-version.mjs index ee2908c..eba7c19 100644 --- a/scripts/sync-extension-version.mjs +++ b/scripts/sync-extension-version.mjs @@ -28,10 +28,34 @@ function syncVersionFile(filePath, version) { return true; } +function syncPackageLockVersion(filePath, version) { + const json = readJson(filePath); + let changed = false; + if (json.version !== version) { + json.version = version; + changed = true; + } + if (json.packages?.[""]?.version !== version) { + json.packages = { + ...json.packages, + "": { + ...json.packages?.[""], + version + } + }; + changed = true; + } + if (changed) { + writeJson(filePath, json); + } + return changed; +} + export function syncExtensionVersion(repoRoot = rootDir) { const packageJsonPath = join(repoRoot, "package.json"); const manifestPath = join(repoRoot, "extension", "manifest.json"); const extensionPackagePath = join(repoRoot, "extension", "package.json"); + const packageLockPath = join(repoRoot, "package-lock.json"); const pkg = readJson(packageJsonPath); const version = String(pkg.version ?? ""); @@ -46,6 +70,9 @@ export function syncExtensionVersion(repoRoot = rootDir) { if (syncVersionFile(extensionPackagePath, version)) { changedFiles.push("extension/package.json"); } + if (syncPackageLockVersion(packageLockPath, version)) { + changedFiles.push("package-lock.json"); + } return { version, diff --git a/scripts/verify-versions.mjs b/scripts/verify-versions.mjs index 5889ca1..1477ca1 100644 --- a/scripts/verify-versions.mjs +++ b/scripts/verify-versions.mjs @@ -11,43 +11,81 @@ const rootDir = join(fileURLToPath(new URL(".", import.meta.url)), ".."); const pkgPath = join(rootDir, "package.json"); const manifestPath = join(rootDir, "extension", "manifest.json"); const extensionPackagePath = join(rootDir, "extension", "package.json"); +const packageLockPath = join(rootDir, "package-lock.json"); -const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); -const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); -const extensionPackage = JSON.parse(readFileSync(extensionPackagePath, "utf-8")); - -const pkgVersion = String(pkg.version ?? ""); -const manifestVersion = String(manifest.version ?? ""); -const extensionPackageVersion = String(extensionPackage.version ?? ""); - -if (!pkgVersion) { - console.error("package.json version is missing."); - process.exit(1); +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf-8")); } -if (!manifestVersion) { - console.error("extension/manifest.json version is missing."); - process.exit(1); +export function readVersionAlignment(repoRoot = rootDir) { + const pkg = readJson(join(repoRoot, "package.json")); + const manifest = readJson(join(repoRoot, "extension", "manifest.json")); + const extensionPackage = readJson(join(repoRoot, "extension", "package.json")); + const packageLock = readJson(join(repoRoot, "package-lock.json")); + return { + packageJson: String(pkg.version ?? ""), + manifest: String(manifest.version ?? ""), + extensionPackage: String(extensionPackage.version ?? ""), + packageLock: String(packageLock.version ?? ""), + packageLockRoot: String(packageLock.packages?.[""]?.version ?? "") + }; } -if (!extensionPackageVersion) { - console.error("extension/package.json version is missing."); - process.exit(1); -} +export function verifyVersionAlignment(repoRoot = rootDir) { + const versions = readVersionAlignment(repoRoot); + const pkgVersion = versions.packageJson; + if (!pkgVersion) { + throw new Error("package.json version is missing."); + } -if (pkgVersion !== manifestVersion) { - console.error(`Version mismatch: package.json=${pkgVersion} manifest.json=${manifestVersion}`); - process.exit(1); -} + if (!versions.manifest) { + throw new Error("extension/manifest.json version is missing."); + } -if (pkgVersion !== extensionPackageVersion) { - console.error(`Version mismatch: package.json=${pkgVersion} extension/package.json=${extensionPackageVersion}`); - process.exit(1); -} + if (!versions.extensionPackage) { + throw new Error("extension/package.json version is missing."); + } + + if (!versions.packageLock) { + throw new Error("package-lock.json version is missing."); + } + + if (!versions.packageLockRoot) { + throw new Error("package-lock.json root package version is missing."); + } -if (manifestVersion !== extensionPackageVersion) { - console.error(`Version mismatch: extension/manifest.json=${manifestVersion} extension/package.json=${extensionPackageVersion}`); - process.exit(1); + if (pkgVersion !== versions.manifest) { + throw new Error(`Version mismatch: package.json=${pkgVersion} manifest.json=${versions.manifest}`); + } + + if (pkgVersion !== versions.extensionPackage) { + throw new Error(`Version mismatch: package.json=${pkgVersion} extension/package.json=${versions.extensionPackage}`); + } + + if (versions.manifest !== versions.extensionPackage) { + throw new Error(`Version mismatch: extension/manifest.json=${versions.manifest} extension/package.json=${versions.extensionPackage}`); + } + + if (pkgVersion !== versions.packageLock) { + throw new Error(`Version mismatch: package.json=${pkgVersion} package-lock.json=${versions.packageLock}`); + } + + if (pkgVersion !== versions.packageLockRoot) { + throw new Error(`Version mismatch: package.json=${pkgVersion} package-lock.json#packages[\"\"]=${versions.packageLockRoot}`); + } + + return pkgVersion; } -console.log(`Version check passed: ${pkgVersion}`); +const isEntrypoint = process.argv[1] === fileURLToPath(import.meta.url); + +if (isEntrypoint) { + try { + const version = verifyVersionAlignment(); + console.log(`Version check passed: ${version}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exit(1); + } +} diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index 00fcf79..c0d63ab 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -137,5 +137,5 @@ src/cli/ - Run strict live release gates: - `node scripts/provider-direct-runs.mjs --release-gate` - `node scripts/live-regression-direct.mjs --release-gate` -- Follow `docs/RELEASE_RUNBOOK.md` and the current version-scoped release evidence doc (for this cycle: `docs/RELEASE_0.0.21_EVIDENCE.md`) for final sign-off. +- Follow `docs/RELEASE_RUNBOOK.md` and the current version-scoped release evidence doc (for this cycle: `docs/RELEASE_0.0.26_EVIDENCE.md`) for final sign-off. - Keep command/flag/channel inventories synchronized with `docs/CLI.md` and `docs/SURFACE_REFERENCE.md`. diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts index 4caefa2..f5495db 100644 --- a/src/cli/commands/daemon.ts +++ b/src/cli/commands/daemon.ts @@ -1,7 +1,8 @@ import type { ParsedArgs } from "../args"; import { createUsageError, EXIT_DISCONNECTED, EXIT_EXECUTION } from "../errors"; import { fetchDaemonStatusFromMetadata } from "../daemon-status"; -import { readDaemonMetadata } from "../daemon"; +import { DEFAULT_DAEMON_STATUS_FETCH_OPTIONS } from "../daemon-status-policy"; +import { createDaemonStopHeaders, readDaemonMetadata } from "../daemon"; import { fetchWithTimeout } from "../utils/http"; import { getAutostartStatus, @@ -20,6 +21,14 @@ type DaemonResult = { status?: Awaited>; }; +type StopDaemonResult = { + outcome: "stopped" | "not_running" | "fingerprint_rejected" | "failed"; + pid?: number; + port?: number; + status?: number; + error?: string; +}; + const parseDaemonArgs = (rawArgs: string[]): { subcommand: DaemonSubcommand } => { const subcommand = rawArgs[0]; if (subcommand === "install" || subcommand === "uninstall" || subcommand === "status") { @@ -28,20 +37,47 @@ const parseDaemonArgs = (rawArgs: string[]): { subcommand: DaemonSubcommand } => throw createUsageError("Usage: opendevbrowser daemon "); }; -const stopDaemonIfRunning = async (): Promise => { +const stopDaemonIfRunning = async (): Promise => { const metadata = readDaemonMetadata(); if (!metadata) { - return false; + return { outcome: "not_running" }; } try { const response = await fetchWithTimeout(`http://127.0.0.1:${metadata.port}/stop`, { method: "POST", - headers: { Authorization: `Bearer ${metadata.token}` } + headers: createDaemonStopHeaders(metadata.token, "daemon.uninstall") }); - return response.ok; - } catch { + if (response.status === 409) { + return { outcome: "fingerprint_rejected", pid: metadata.pid, port: metadata.port }; + } + return response.ok + ? { outcome: "stopped", pid: metadata.pid, port: metadata.port } + : { outcome: "failed", pid: metadata.pid, port: metadata.port, status: response.status }; + } catch (error) { + return { + outcome: "failed", + pid: metadata.pid, + port: metadata.port, + error: error instanceof Error ? error.message : String(error) + }; + } +}; + +const buildStopFailureMessage = (stop: StopDaemonResult): string => { + const target = stop.port ? `127.0.0.1:${stop.port}` : "recorded daemon"; + const pid = stop.pid ? ` pid=${stop.pid}` : ""; + if (stop.outcome === "fingerprint_rejected") { + return `Daemon autostart removed, but the running daemon at ${target}${pid} rejected the stop request as stale. Run \`opendevbrowser status --daemon\` to inspect it and restart from the current install if needed.`; + } + const reason = stop.error ?? (stop.status ? `HTTP ${stop.status}` : "unknown error"); + return `Daemon autostart removed, but stopping ${target}${pid} failed (${reason}).`; +}; + +const shouldFailUninstallStop = (stop: StopDaemonResult): boolean => { + if (stop.outcome === "stopped" || stop.outcome === "not_running") { return false; } + return true; }; const formatReason = (reason?: ReturnType["reason"]): string => { @@ -132,7 +168,15 @@ export async function runDaemonCommand(args: ParsedArgs) { exitCode: EXIT_EXECUTION }; } - await stopDaemonIfRunning(); + const stop = await stopDaemonIfRunning(); + if (shouldFailUninstallStop(stop)) { + return { + success: false, + message: buildStopFailureMessage(stop), + data: { ...result, stop }, + exitCode: EXIT_EXECUTION + }; + } return { success: true, message: `Daemon autostart removed (${result.platform}).`, @@ -141,7 +185,7 @@ export async function runDaemonCommand(args: ParsedArgs) { } const autostart = getAutostartStatus(); - const daemonStatus = await fetchDaemonStatusFromMetadata(); + const daemonStatus = await fetchDaemonStatusFromMetadata(undefined, DEFAULT_DAEMON_STATUS_FETCH_OPTIONS); const running = Boolean(daemonStatus); const message = buildStatusMessage(autostart, running); const data: DaemonResult = { diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index 057edc5..ce0d615 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -1,7 +1,8 @@ import { spawnSync } from "node:child_process"; import type { ParsedArgs } from "../args"; import { - getCurrentDaemonFingerprint, + createDaemonStopHeaders, + isCurrentDaemonFingerprint, readDaemonMetadata, startDaemon } from "../daemon"; @@ -23,6 +24,11 @@ type DaemonHandle = { stop: () => Promise; }; +type ExistingDaemon = { + token: string; + status: DaemonStatusPayload; +}; + type ServeProcessSnapshot = { pid: number; uid: number | null; @@ -31,7 +37,15 @@ type ServeProcessSnapshot = { let daemonHandle: DaemonHandle | null = null; const PS_MAX_BUFFER = 8 * 1024 * 1024; +const DAEMON_SHUTDOWN_POLL_ATTEMPTS = 10; +const DAEMON_SHUTDOWN_POLL_DELAY_MS = 100; +const DAEMON_SHUTDOWN_STATUS_TIMEOUT_MS = 250; +const DAEMON_STOP_TIMEOUT_MS = 1000; +const MIN_PORT = 1; +const MAX_PORT = 65535; const SERVE_COMMAND_PATTERN = /(?:^|\s)(?:\S*[\\/])?(?:opendevbrowser|dist[\\/]+cli[\\/]+index\.js)(?=\s|$).*?\bserve\b/; +const SERVE_PORT_SPLIT_PATTERN = /(?:^|\s)--port\s+(\d+)(?=\s|$)/; +const SERVE_PORT_EQUALS_PATTERN = /(?:^|\s)--port=(\d+)(?=\s|$)/; const SERVE_STOP_PATTERN = /(?:^|\s)--stop(?:\s|$)/; const CURRENT_UID = typeof process.getuid === "function" ? process.getuid() : null; const CURRENT_EXECUTABLE = process.execPath; @@ -49,7 +63,7 @@ function resolveTokenCandidates( async function resolveExistingDaemon( port: number, tokens: string[] -): Promise<{ token: string; status: DaemonStatusPayload } | null> { +): Promise { for (const token of tokens) { const status = await fetchDaemonStatus(port, token); if (status?.ok) { @@ -63,36 +77,6 @@ function isPositivePid(value: unknown): value is number { return typeof value === "number" && Number.isInteger(value) && value > 0; } -function rememberStalePid(staleDaemonPids: Set, pid: unknown): void { - if (isPositivePid(pid)) { - staleDaemonPids.add(pid); - } -} - -async function stopDaemonOnPort(port: number, token: string): Promise { - try { - const response = await fetchWithTimeout(`http://127.0.0.1:${port}/stop`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` } - }); - return response.ok; - } catch { - return false; - } -} - -async function stopStaleDaemon( - port: number, - daemon: { token: string; status: DaemonStatusPayload }, - staleDaemonPids: Set -): Promise { - rememberStalePid(staleDaemonPids, daemon.status.pid); - const stopped = await stopDaemonOnPort(port, daemon.token); - if (!stopped && isPositivePid(daemon.status.pid)) { - terminateProcess(daemon.status.pid); - } -} - function parseServeArgs(rawArgs: string[]): ServeArgs { const parsed: ServeArgs = { stop: false }; for (let i = 0; i < rawArgs.length; i += 1) { @@ -106,7 +90,7 @@ function parseServeArgs(rawArgs: string[]): ServeArgs { if (!value) { throw createUsageError("Missing value for --port"); } - parsed.port = parseNumberFlag(value, "--port", { min: 1, max: 65535 }); + parsed.port = parseNumberFlag(value, "--port", { min: MIN_PORT, max: MAX_PORT }); i += 1; continue; } @@ -115,7 +99,7 @@ function parseServeArgs(rawArgs: string[]): ServeArgs { if (!value) { throw createUsageError("Missing value for --port"); } - parsed.port = parseNumberFlag(value, "--port", { min: 1, max: 65535 }); + parsed.port = parseNumberFlag(value, "--port", { min: MIN_PORT, max: MAX_PORT }); continue; } if (arg === "--token") { @@ -167,6 +151,16 @@ function parseServeProcessSnapshot(line: string): ServeProcessSnapshot | null { }; } +function parseServeCommandPort(command: string): number | null { + const rawPort = command.match(SERVE_PORT_EQUALS_PATTERN)?.[1] + ?? command.match(SERVE_PORT_SPLIT_PATTERN)?.[1]; + if (!rawPort) { + return null; + } + const port = Number.parseInt(rawPort, 10); + return Number.isInteger(port) && port >= MIN_PORT && port <= MAX_PORT ? port : null; +} + function listServeProcessSnapshots(): ServeProcessSnapshot[] { const result = spawnSync("ps", ["-axww", "-o", "pid=,uid=,command="], { encoding: "utf-8", @@ -194,6 +188,11 @@ function isCurrentExecutableServeProcess(snapshot: ServeProcessSnapshot): boolea return !SERVE_STOP_PATTERN.test(snapshot.command); } +function isRequestedPortServeProcess(snapshot: ServeProcessSnapshot, requestedPort: number): boolean { + return isCurrentExecutableServeProcess(snapshot) + && parseServeCommandPort(snapshot.command) === requestedPort; +} + function terminateProcess(pid: number): boolean { if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid || pid === process.ppid) { return false; @@ -211,9 +210,9 @@ function terminateProcess(pid: number): boolean { return true; } -function cleanupCompetingServeProcesses(keepPid?: number): number[] { +function cleanupCompetingServeProcesses(requestedPort: number, keepPid?: number): number[] { const candidates = listServeProcessSnapshots().filter((snapshot) => { - if (!isCurrentExecutableServeProcess(snapshot)) { + if (!isRequestedPortServeProcess(snapshot, requestedPort)) { return false; } if (snapshot.pid === process.pid || snapshot.pid === process.ppid) { @@ -238,6 +237,96 @@ function cleanupCompetingServeProcesses(keepPid?: number): number[] { return clearedPids; } +function terminateServeProcessByPid(pid?: number): boolean { + if (!isPositivePid(pid)) { + return false; + } + const snapshot = listServeProcessSnapshots().find((item) => item.pid === pid); + return snapshot ? isCurrentExecutableServeProcess(snapshot) && terminateProcess(pid) : false; +} + +function buildStaleStopMessage(metadata: NonNullable>): string { + const pid = isPositivePid(metadata.pid) ? ` pid=${metadata.pid}` : ""; + return `Daemon rejected stale stop request for 127.0.0.1:${metadata.port}${pid}. Run \`opendevbrowser status --daemon\` to inspect the active daemon, then restart from the current install if needed.`; +} + +function buildProtectedMismatchMessage(port: number, status: DaemonStatusPayload): string { + return `Daemon on 127.0.0.1:${port} pid=${status.pid} is protected by a different opendevbrowser build. Run \`opendevbrowser status --daemon\` to inspect it, then restart from the current install.`; +} + +async function waitForDaemonShutdown(port: number, token: string): Promise { + for (let attempt = 0; attempt < DAEMON_SHUTDOWN_POLL_ATTEMPTS; attempt += 1) { + const status = await fetchDaemonStatus(port, token, { timeoutMs: DAEMON_SHUTDOWN_STATUS_TIMEOUT_MS }); + if (!status?.ok) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, DAEMON_SHUTDOWN_POLL_DELAY_MS)); + } + return false; +} + +async function stopMismatchedDaemon(port: number, daemon: ExistingDaemon): Promise { + let response: Response; + try { + response = await fetchWithTimeout(`http://127.0.0.1:${port}/stop`, { + method: "POST", + headers: createDaemonStopHeaders(daemon.token, "serve.upgrade") + }, DAEMON_STOP_TIMEOUT_MS); + } catch (error) { + const status = await fetchDaemonStatus(port, daemon.token, { + timeoutMs: DAEMON_SHUTDOWN_STATUS_TIMEOUT_MS + }); + if (!status?.ok) { + return null; + } + const message = error instanceof Error ? error.message : String(error); + return `Failed to stop mismatched daemon on 127.0.0.1:${port}: ${message}.`; + } + if (response.status === 409) { + return buildProtectedMismatchMessage(port, daemon.status); + } + if (!response.ok) { + return `Failed to stop mismatched daemon on 127.0.0.1:${port}: stop returned ${response.status}.`; + } + if (await waitForDaemonShutdown(port, daemon.token)) { + return null; + } + if (terminateServeProcessByPid(daemon.status.pid)) { + return null; + } + return `Timed out waiting for mismatched daemon on 127.0.0.1:${port} to stop.`; +} + +async function prepareExistingDaemon(port: number, daemon: ExistingDaemon): Promise { + if (isCurrentDaemonFingerprint(daemon.status.fingerprint)) { + return null; + } + return await stopMismatchedDaemon(port, daemon); +} + +function buildAlreadyRunningResult( + port: number, + status: DaemonStatusPayload, + fallbackRelayPort: number, + clearedCount: number +) { + const relayPort = status.relay.port ?? fallbackRelayPort; + const staleNote = clearedCount > 0 ? `\nCleared ${clearedCount} stale daemon process${clearedCount === 1 ? "" : "es"}.` : ""; + return { + success: true, + message: `Daemon already running on 127.0.0.1:${port} (pid=${status.pid}, relay ${relayPort}).${staleNote}`, + data: { + port, + pid: status.pid, + relayPort, + alreadyRunning: true, + staleDaemonsCleared: clearedCount, + relay: status.relay + }, + exitCode: null + }; +} + export async function runServe(args: ParsedArgs) { const serveArgs = parseServeArgs(args.rawArgs); @@ -255,8 +344,11 @@ export async function runServe(args: ParsedArgs) { try { const response = await fetchWithTimeout(`http://127.0.0.1:${metadata.port}/stop`, { method: "POST", - headers: { Authorization: `Bearer ${metadata.token}` } + headers: createDaemonStopHeaders(metadata.token, "serve.stop") }); + if (response.status === 409) { + return { success: false, message: buildStaleStopMessage(metadata), exitCode: EXIT_EXECUTION }; + } if (!response.ok) { throw new Error(`Stop failed (${response.status})`); } @@ -272,35 +364,25 @@ export async function runServe(args: ParsedArgs) { const metadata = readDaemonMetadata(); const metadataToken = metadata?.port === requestedPort ? metadata.token : undefined; const tokenCandidates = resolveTokenCandidates(serveArgs.token, metadataToken, config.daemonToken); - const currentFingerprint = getCurrentDaemonFingerprint(); - const existingDaemon = await resolveExistingDaemon(requestedPort, tokenCandidates); - const staleDaemonPids = new Set(cleanupCompetingServeProcesses(existingDaemon?.status.pid)); + const staleDaemonPids = new Set(); const staleCleared = () => staleDaemonPids.size; - let replacedStaleFingerprint = false; if (existingDaemon) { - const fingerprintMatches = existingDaemon.status.fingerprint === currentFingerprint; - if (fingerprintMatches) { - const relayPort = existingDaemon.status.relay.port ?? config.relayPort; - const clearedCount = staleCleared(); - const staleNote = clearedCount > 0 ? `\nCleared ${clearedCount} stale daemon process${clearedCount === 1 ? "" : "es"}.` : ""; - return { - success: true, - message: `Daemon already running on 127.0.0.1:${requestedPort} (pid=${existingDaemon.status.pid}, relay ${relayPort}).${staleNote}`, - data: { - port: requestedPort, - pid: existingDaemon.status.pid, - relayPort, - alreadyRunning: true, - staleDaemonsCleared: clearedCount, - relay: existingDaemon.status.relay - }, - exitCode: null - }; + const mismatchMessage = await prepareExistingDaemon(requestedPort, existingDaemon); + if (mismatchMessage) { + return { success: false, message: mismatchMessage, exitCode: EXIT_EXECUTION }; + } + if (isCurrentDaemonFingerprint(existingDaemon.status.fingerprint)) { + for (const pid of cleanupCompetingServeProcesses(requestedPort, existingDaemon.status.pid)) { + staleDaemonPids.add(pid); + } + return buildAlreadyRunningResult(requestedPort, existingDaemon.status, config.relayPort, staleCleared()); } - await stopStaleDaemon(requestedPort, existingDaemon, staleDaemonPids); - replacedStaleFingerprint = true; + } + + for (const pid of cleanupCompetingServeProcesses(requestedPort)) { + staleDaemonPids.add(pid); } let nativeStatus = getNativeStatusSnapshot(); @@ -349,34 +431,17 @@ export async function runServe(args: ParsedArgs) { } const runningDaemon = await resolveExistingDaemon(requestedPort, tokenCandidates); if (runningDaemon) { - const fingerprintMatches = runningDaemon.status.fingerprint === currentFingerprint; - if (fingerprintMatches) { - const relayPort = runningDaemon.status.relay.port ?? config.relayPort; - const clearedCount = staleCleared(); - const staleNote = clearedCount > 0 ? `\nCleared ${clearedCount} stale daemon process${clearedCount === 1 ? "" : "es"}.` : ""; - return { - success: true, - message: `Daemon already running on 127.0.0.1:${requestedPort} (pid=${runningDaemon.status.pid}, relay ${relayPort}).${staleNote}`, - data: { - port: requestedPort, - pid: runningDaemon.status.pid, - relayPort, - alreadyRunning: true, - staleDaemonsCleared: clearedCount, - relay: runningDaemon.status.relay - }, - exitCode: null - }; + const mismatchMessage = await prepareExistingDaemon(requestedPort, runningDaemon); + if (mismatchMessage) { + return { success: false, message: mismatchMessage, exitCode: EXIT_EXECUTION }; } - await stopStaleDaemon(requestedPort, runningDaemon, staleDaemonPids); - replacedStaleFingerprint = true; - if (attempt === 0) { - continue; + if (isCurrentDaemonFingerprint(runningDaemon.status.fingerprint)) { + return buildAlreadyRunningResult(requestedPort, runningDaemon.status, config.relayPort, staleCleared()); } } if (attempt === 0) { let clearedNewPid = false; - for (const pid of cleanupCompetingServeProcesses()) { + for (const pid of cleanupCompetingServeProcesses(requestedPort)) { const previousSize = staleDaemonPids.size; staleDaemonPids.add(pid); if (staleDaemonPids.size > previousSize) { @@ -413,10 +478,9 @@ export async function runServe(args: ParsedArgs) { const baseMessage = `Daemon running on 127.0.0.1:${state.port} (relay ${state.relayPort})`; const clearedCount = staleCleared(); const staleNote = clearedCount > 0 ? `\nCleared ${clearedCount} stale daemon process${clearedCount === 1 ? "" : "es"}.` : ""; - const fingerprintNote = replacedStaleFingerprint ? "\nReplaced stale daemon fingerprint." : ""; const message = nativeMessage - ? `${baseMessage}\n${nativeMessage}${fingerprintNote}${staleNote}` - : `${baseMessage}${fingerprintNote}${staleNote}`; + ? `${baseMessage}\n${nativeMessage}${staleNote}` + : `${baseMessage}${staleNote}`; return { success: true, diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 7fd97ec..c8ad4d3 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -1,6 +1,7 @@ import type { ParsedArgs } from "../args"; -import { createUsageError } from "../errors"; +import { createDisconnectedError, createUsageError } from "../errors"; import { fetchDaemonStatusFromMetadata } from "../daemon-status"; +import { DEFAULT_DAEMON_STATUS_FETCH_OPTIONS } from "../daemon-status-policy"; import { runSessionStatus } from "./session/status"; import { assessNativeStatus, getNativeStatusSnapshot } from "./native"; @@ -9,12 +10,6 @@ type StatusArgs = { daemon: boolean; }; -const DAEMON_STATUS_READ_OPTIONS = { - timeoutMs: 5_000, - retryAttempts: 5, - retryDelayMs: 250 -} as const; - const parseStatusArgs = (rawArgs: string[]): StatusArgs => { const parsed: StatusArgs = { daemon: false }; for (let i = 0; i < rawArgs.length; i += 1) { @@ -59,16 +54,20 @@ export async function runStatus(args: ParsedArgs) { }; } - const daemonStatus = await fetchDaemonStatusFromMetadata(undefined, DAEMON_STATUS_READ_OPTIONS); + const daemonStatus = await fetchDaemonStatusFromMetadata(undefined, DEFAULT_DAEMON_STATUS_FETCH_OPTIONS); if (!daemonStatus) { - throw createUsageError("Daemon not running. Start with `opendevbrowser serve`."); + throw createDisconnectedError("Daemon not running. Start with `opendevbrowser serve`."); } const nativeStatus = getNativeStatusSnapshot(); const nativeAssessment = assessNativeStatus(nativeStatus); + const fingerprintLine = daemonStatus.fingerprintCurrent === false + ? "Daemon fingerprint: mismatch with current build" + : "Daemon fingerprint: current"; const baseLines = [ `Daemon OK (pid=${daemonStatus.pid})`, + fingerprintLine, `Relay: port=${daemonStatus.relay.port ?? "n/a"} ext=${daemonStatus.relay.extensionConnected ? "on" : "off"} ` + `handshake=${daemonStatus.relay.extensionHandshakeComplete ? "on" : "off"} ` + `cdp=${daemonStatus.relay.cdpConnected ? "on" : "off"} ` + @@ -84,7 +83,7 @@ export async function runStatus(args: ParsedArgs) { "Legend: ext=extension websocket, handshake=extension handshake, cdp=active /cdp client, annotate=annotation channel, ops=ops clients, canvas=canvas clients, pairing=token required, health=relay status" ]; if (!nativeAssessment.success) { - baseLines.splice(3, 0, `Native detail: ${nativeAssessment.message}`); + baseLines.splice(4, 0, `Native detail: ${nativeAssessment.message}`); } const baseMessage = baseLines.join("\n"); diff --git a/src/cli/daemon-client.ts b/src/cli/daemon-client.ts index 9ac7480..b50ce40 100644 --- a/src/cli/daemon-client.ts +++ b/src/cli/daemon-client.ts @@ -4,6 +4,8 @@ import { join, resolve } from "path"; import { randomUUID } from "crypto"; import { fileURLToPath } from "url"; import { + DAEMON_STOP_DEBUG_ENV, + createDaemonStopHeaders, getCacheRoot, isCurrentDaemonFingerprint, readDaemonMetadata, @@ -101,6 +103,14 @@ type CallOptions = { let cachedClientState: CachedClientState | null | undefined; +const logDaemonStopDebug = (message: string, details?: Record): void => { + if (process.env[DAEMON_STOP_DEBUG_ENV] !== "1") { + return; + } + const suffix = details ? ` ${JSON.stringify(details)}` : ""; + console.error(`[daemon-stop-debug] ${message}${suffix}`); +}; + const getClientStateFilePath = (): string => { const cacheRoot = getCacheRoot(); return join(cacheRoot, CLIENT_ID_FILE); @@ -252,7 +262,7 @@ export class DaemonClient { this.maybeTrackLease(name, params, result); return result; } - if (!options.requireBinding && isBindingRequiredError(error)) { + if (isBindingRequiredError(error)) { if (this.binding) { this.clearBinding(); } @@ -387,7 +397,9 @@ export class DaemonClient { private async callRaw(name: string, params: Record, timeoutMs?: number): Promise { const budget = createTimeoutBudget(timeoutMs); - const connection = await resolveDaemonConnection(budget); + const connection = await resolveDaemonConnection(budget, { + preferConfiguredRecovery: requiresConfiguredRecovery(name) + }); let timedResponse: TimedFetchResponse; try { @@ -541,6 +553,10 @@ type TimeoutBudget = { deadlineMs: number; }; +type ResolveDaemonConnectionOptions = { + preferConfiguredRecovery?: boolean; +}; + type ResolveDaemonRestartCommandOptions = { argv1?: string; execPath?: string; @@ -673,6 +689,10 @@ const sleep = async (delayMs: number): Promise => { await new Promise((resolve) => setTimeout(resolve, delayMs)); }; +const requiresConfiguredRecovery = (name: string): boolean => { + return name === "canvas.execute"; +}; + const getConfiguredDaemonConnection = (): DaemonConnection | null => { const config = loadGlobalConfig(); if (!(config.daemonPort > 0 && config.daemonToken)) { @@ -705,13 +725,14 @@ const persistCurrentConfiguredConnection = async ( ): Promise => { if (staleMetadata && !sameDaemonConnection(staleMetadata.connection, configuredConnection)) { // Once the configured daemon has proven current, stale metadata cleanup must not block the caller. - void stopDaemonConnection(staleMetadata.connection).catch(() => undefined); + void stopDaemonConnection(staleMetadata.connection, null, "persistCurrentConfiguredConnection.staleMetadata").catch(() => undefined); } persistResolvedDaemonStatus(configuredConnection, status); return configuredConnection; }; type DaemonShutdownOutcome = "stopped" | DaemonStatusPayload; +type DaemonStopOutcome = "stopped" | "fingerprint_rejected" | "unreachable"; const resolveConfiguredPreferenceOptions = ( budget: TimeoutBudget | null @@ -744,16 +765,29 @@ const resolveConfiguredPreferenceOptions = ( const stopDaemonConnection = async ( connection: DaemonConnection, - budget: TimeoutBudget | null = null -): Promise => { + budget: TimeoutBudget | null = null, + reason = "unknown" +): Promise => { const stopTimeoutMs = capTimeoutToBudget(DAEMON_RESTART_STATUS_TIMEOUT_MS, budget); + logDaemonStopDebug("client.stop.request", { reason, port: connection.port }); try { - await fetchWithTimeout(`http://127.0.0.1:${connection.port}/stop`, { + const response = await fetchWithTimeout(`http://127.0.0.1:${connection.port}/stop`, { method: "POST", - headers: { Authorization: `Bearer ${connection.token}` } + headers: createDaemonStopHeaders(connection.token, reason) }, stopTimeoutMs); + if (response.status === 409) { + logDaemonStopDebug("client.stop.fingerprintRejected", { reason, port: connection.port }); + return "fingerprint_rejected"; + } + if (!response.ok) { + logDaemonStopDebug("client.stop.rejected", { reason, port: connection.port, status: response.status }); + return "unreachable"; + } + logDaemonStopDebug("client.stop.complete", { reason, port: connection.port }); + return "stopped"; } catch { - // Best effort only. The restart probe below is the source of truth. + logDaemonStopDebug("client.stop.error", { reason, port: connection.port }); + return "unreachable"; } }; @@ -879,7 +913,10 @@ const resolveMetadataConnection = async ( return { connection: metadataConnection, status }; }; -const resolveFreshDaemonConnection = async (budget: TimeoutBudget | null = null): Promise => { +const resolveFreshDaemonConnection = async ( + budget: TimeoutBudget | null = null, + options: ResolveDaemonConnectionOptions = {} +): Promise => { const configuredConnection = getConfiguredDaemonConnection(); if (!configuredConnection) { throw createDisconnectedError("Daemon not running. Start with `opendevbrowser serve`."); @@ -902,9 +939,26 @@ const resolveFreshDaemonConnection = async (budget: TimeoutBudget | null = null) if (currentConfiguredStatus?.ok) { return await persistCurrentConfiguredConnection(configuredConnection, currentConfiguredStatus, staleMetadata); } - if (staleMetadata?.status.ok && isCurrentDaemonFingerprint(staleMetadata.status.fingerprint)) { + if (options.preferConfiguredRecovery && staleMetadata) { + currentConfiguredStatus = await waitForCurrentDaemonStatus( + configuredConnection, + DAEMON_RECOVERY_READY_TIMEOUT_MS, + budget + ); + if (currentConfiguredStatus?.ok) { + return await persistCurrentConfiguredConnection(configuredConnection, currentConfiguredStatus, staleMetadata); + } + if (!configuredStatus?.ok) { + throw createDisconnectedError("Daemon not running. Start with `opendevbrowser serve`."); + } + } + if ( + !options.preferConfiguredRecovery + && staleMetadata?.status.ok + && isCurrentDaemonFingerprint(staleMetadata.status.fingerprint) + ) { if (configuredStatus?.ok) { - void stopDaemonConnection(configuredConnection, budget).catch(() => undefined); + void stopDaemonConnection(configuredConnection, budget, "resolveFreshDaemonConnection.configuredCurrentMetadataPreferred").catch(() => undefined); } return staleMetadata.connection; } @@ -917,14 +971,12 @@ const resolveFreshDaemonConnection = async (budget: TimeoutBudget | null = null) if (currentConfiguredStatus?.ok) { return await persistCurrentConfiguredConnection(configuredConnection, currentConfiguredStatus, staleMetadata); } + throw createDisconnectedError("Daemon not running. Start with `opendevbrowser serve`."); } - const staleConnections: DaemonConnection[] = []; + const staleConnections: Array<{ connection: DaemonConnection; status: DaemonStatusPayload }> = []; if (configuredStatus?.ok) { - staleConnections.push(configuredConnection); - } - if (staleMetadata && !sameDaemonConnection(staleMetadata.connection, configuredConnection)) { - staleConnections.push(staleMetadata.connection); + staleConnections.push({ connection: configuredConnection, status: configuredStatus }); } if (staleConnections.length === 0) { const recoveringStatus = await waitForCurrentDaemonStatus( @@ -939,7 +991,16 @@ const resolveFreshDaemonConnection = async (budget: TimeoutBudget | null = null) throw createDisconnectedError("Daemon not running. Start with `opendevbrowser serve`."); } for (const staleConnection of staleConnections) { - await stopDaemonConnection(staleConnection, budget); + const stopOutcome = await stopDaemonConnection( + staleConnection.connection, + budget, + "resolveFreshDaemonConnection.staleConnections" + ); + if (stopOutcome === "fingerprint_rejected") { + throw createDisconnectedError( + `Daemon on 127.0.0.1:${staleConnection.connection.port} pid=${staleConnection.status.pid} is protected by a different opendevbrowser build. Start with \`opendevbrowser serve\`.` + ); + } } if (configuredStatus?.ok) { const shutdownOutcome = await waitForDaemonShutdown(configuredConnection, DAEMON_RECOVERY_READY_TIMEOUT_MS, budget); @@ -960,7 +1021,10 @@ const resolveFreshDaemonConnection = async (budget: TimeoutBudget | null = null) return configuredConnection; }; -const resolveDaemonConnection = async (budget: TimeoutBudget | null = null): Promise => { +const resolveDaemonConnection = async ( + budget: TimeoutBudget | null = null, + options: ResolveDaemonConnectionOptions = {} +): Promise => { const metadata = readDaemonMetadata(); if (metadata && isCurrentDaemonFingerprint(metadata.fingerprint)) { const metadataConnection = { port: metadata.port, token: metadata.token }; @@ -970,6 +1034,9 @@ const resolveDaemonConnection = async (budget: TimeoutBudget | null = null): Pro } const configuredOptions = resolveConfiguredPreferenceOptions(budget); if (!configuredOptions) { + if (options.preferConfiguredRecovery) { + return await resolveFreshDaemonConnection(budget, options); + } return metadataConnection; } const configuredStatus = await fetchCurrentDaemonStatus(configuredConnection, configuredOptions, budget); @@ -980,9 +1047,12 @@ const resolveDaemonConnection = async (budget: TimeoutBudget | null = null): Pro { connection: metadataConnection } ); } + if (options.preferConfiguredRecovery) { + return await resolveFreshDaemonConnection(budget, options); + } return metadataConnection; } - return await resolveFreshDaemonConnection(budget); + return await resolveFreshDaemonConnection(budget, options); }; const retryWithRefreshedConnection = async ( @@ -990,7 +1060,9 @@ const retryWithRefreshedConnection = async ( params: Record, budget: TimeoutBudget | null ): Promise => { - const connection = await resolveFreshDaemonConnection(budget); + const connection = await resolveFreshDaemonConnection(budget, { + preferConfiguredRecovery: requiresConfiguredRecovery(name) + }); return await openDaemonCommand(connection.port, connection.token, name, params, readRemainingBudgetMs(budget)); }; diff --git a/src/cli/daemon-status-policy.ts b/src/cli/daemon-status-policy.ts new file mode 100644 index 0000000..09cb03d --- /dev/null +++ b/src/cli/daemon-status-policy.ts @@ -0,0 +1,5 @@ +export const DEFAULT_DAEMON_STATUS_FETCH_OPTIONS = { + timeoutMs: 5_000, + retryAttempts: 5, + retryDelayMs: 250 +} as const; diff --git a/src/cli/daemon-status.ts b/src/cli/daemon-status.ts index 062dce3..74a47b2 100644 --- a/src/cli/daemon-status.ts +++ b/src/cli/daemon-status.ts @@ -3,16 +3,19 @@ import type { OpenDevBrowserConfig } from "../config"; import { loadGlobalConfig } from "../config"; import { readDaemonMetadata, + isCurrentDaemonFingerprint, resolveDaemonFingerprint, writeDaemonMetadata, type DaemonState } from "./daemon"; -import { fetchWithTimeout } from "./utils/http"; +import { fetchWithTimeoutContext, readResponseJsonWithTimeout } from "./utils/http"; +import { DEFAULT_DAEMON_STATUS_FETCH_OPTIONS } from "./daemon-status-policy"; export type DaemonStatusPayload = { ok: true; pid: number; fingerprint?: string; + fingerprintCurrent?: boolean; hub: { instanceId: string }; relay: RelayStatus; binding: { @@ -32,6 +35,8 @@ export type DaemonStatusFetchOptions = { type DaemonMetadataSeed = Pick & Partial>; +const DEFAULT_DAEMON_STATUS_TIMEOUT_MS = DEFAULT_DAEMON_STATUS_FETCH_OPTIONS.timeoutMs; + const sleep = async (delayMs: number): Promise => { if (!(Number.isFinite(delayMs) && delayMs > 0)) { return; @@ -51,6 +56,59 @@ const resolveRetryDelayMs = (retryDelayMs?: number): number => { : 0; }; +const resolveStatusTimeoutMs = (timeoutMs?: number): number => { + return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DEFAULT_DAEMON_STATUS_TIMEOUT_MS; +}; + +const withFingerprintCurrent = (status: DaemonStatusPayload): DaemonStatusPayload => ({ + ...status, + fingerprintCurrent: isCurrentDaemonFingerprint(status.fingerprint) +}); + +const readRemainingBudgetMs = (deadlineMs: number): number => { + return Math.max(0, deadlineMs - Date.now()); +}; + +const readSeedTimeoutMs = (remainingBudgetMs: number, remainingSeedCount: number): number => { + if (remainingSeedCount <= 1) { + return remainingBudgetMs; + } + return Math.max(1, Math.floor(remainingBudgetMs / remainingSeedCount)); +}; + +const resolveDaemonStatusSeeds = ( + metadata: DaemonState | null, + config: OpenDevBrowserConfig +): DaemonMetadataSeed[] => { + const seeds: DaemonMetadataSeed[] = []; + const seen = new Set(); + const addSeed = (seed: DaemonMetadataSeed | null): void => { + if (!seed) { + return; + } + const key = `${seed.port}:${seed.token}`; + if (seen.has(key)) { + return; + } + seen.add(key); + seeds.push(seed); + }; + + addSeed( + config.daemonPort > 0 && config.daemonToken + ? { + port: config.daemonPort, + token: config.daemonToken, + relayPort: config.relayPort + } + : null + ); + addSeed(metadata); + return seeds; +}; + export async function fetchDaemonStatus( port: number, token: string, @@ -61,12 +119,21 @@ export async function fetchDaemonStatus( for (let attempt = 1; attempt <= attempts; attempt += 1) { try { - const response = await fetchWithTimeout(`http://127.0.0.1:${port}/status`, { + const timedResponse = await fetchWithTimeoutContext(`http://127.0.0.1:${port}/status`, { method: "GET", headers: { Authorization: `Bearer ${token}` } }, options.timeoutMs); - if (response.ok) { - return await response.json() as DaemonStatusPayload; + try { + if (timedResponse.response.ok) { + const status = await readResponseJsonWithTimeout( + timedResponse.response, + timedResponse.signal, + timedResponse.timeoutMs + ); + return withFingerprintCurrent(status); + } + } finally { + timedResponse.dispose(); } } catch { // retry below when configured @@ -86,35 +153,36 @@ export async function fetchDaemonStatusFromMetadata( const resolvedConfig = config ?? loadGlobalConfig(); const attempts = resolveRetryAttempts(options.retryAttempts); const retryDelayMs = resolveRetryDelayMs(options.retryDelayMs); + const deadlineMs = Date.now() + resolveStatusTimeoutMs(options.timeoutMs); for (let attempt = 1; attempt <= attempts; attempt += 1) { const metadata = readDaemonMetadata(); - if (metadata) { - const status = await fetchDaemonStatus(metadata.port, metadata.token, { timeoutMs: options.timeoutMs }); - if (status?.ok) { - persistDaemonStatusMetadata(metadata, status, resolvedConfig); - return status; + const seeds = resolveDaemonStatusSeeds(metadata, resolvedConfig); + for (let seedIndex = 0; seedIndex < seeds.length; seedIndex += 1) { + const seed = seeds[seedIndex]; + if (!seed) { + continue; } - } - - if (resolvedConfig.daemonPort > 0 && resolvedConfig.daemonToken) { - const status = await fetchDaemonStatus(resolvedConfig.daemonPort, resolvedConfig.daemonToken, { - timeoutMs: options.timeoutMs + const remainingBudgetMs = readRemainingBudgetMs(deadlineMs); + if (remainingBudgetMs <= 0) { + return null; + } + const timeoutMs = readSeedTimeoutMs(remainingBudgetMs, seeds.length - seedIndex); + const status = await fetchDaemonStatus(seed.port, seed.token, { + timeoutMs }); if (status?.ok) { - persistDaemonStatusMetadata({ - port: resolvedConfig.daemonPort, - token: resolvedConfig.daemonToken, - pid: status.pid, - relayPort: status.relay.port ?? resolvedConfig.relayPort, - startedAt: new Date().toISOString() - }, status, resolvedConfig); + persistDaemonStatusMetadata(seed, status, resolvedConfig); return status; } } if (attempt < attempts) { - await sleep(retryDelayMs); + const remainingBudgetMs = readRemainingBudgetMs(deadlineMs); + if (remainingBudgetMs <= 0) { + break; + } + await sleep(Math.min(retryDelayMs, remainingBudgetMs)); } } diff --git a/src/cli/daemon.ts b/src/cli/daemon.ts index 424d393..5f1c1bb 100644 --- a/src/cli/daemon.ts +++ b/src/cli/daemon.ts @@ -2,7 +2,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "http"; import { createHash, timingSafeEqual } from "crypto"; import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync } from "fs"; import { homedir } from "os"; -import { join, resolve } from "path"; +import { basename, dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { generateSecureToken } from "../utils/crypto"; import { createOpenDevBrowserCore } from "../core"; @@ -11,6 +11,11 @@ import { handleDaemonCommand, type DaemonCommandRequest } from "./daemon-command import { clearBinding, getBindingDiagnostics, getHubInstanceId } from "./daemon-state"; const DEFAULT_DAEMON_PORT = 8788; +export const DAEMON_STOP_DEBUG_ENV = "OPDEVBROWSER_DEBUG_DAEMON_STOP"; +const DAEMON_FINGERPRINT_FILE = "daemon-fingerprint.json"; +export const DAEMON_STOP_REASON_HEADER = "x-opendevbrowser-stop-reason"; +export const DAEMON_STOP_CLIENT_PID_HEADER = "x-opendevbrowser-stop-client-pid"; +export const DAEMON_STOP_FINGERPRINT_HEADER = "x-opendevbrowser-stop-fingerprint"; const RECOVERABLE_PLAYWRIGHT_TRANSPORT_ERRORS = [ "Cannot find context with specified id", @@ -107,20 +112,46 @@ function hashFileContents(entryPath: string): string { } } +function resolveDaemonFingerprintDistRoot(modulePath: string): string | null { + let currentDir = dirname(modulePath); + while (true) { + if (basename(currentDir) === "dist") { + return currentDir; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + return null; +} + +function readDaemonFingerprintArtifact(modulePath: string): string | null { + const distRoot = resolveDaemonFingerprintDistRoot(modulePath); + if (distRoot === null) { + return null; + } + try { + const content = readFileSync(join(distRoot, DAEMON_FINGERPRINT_FILE), "utf-8"); + const payload = JSON.parse(content) as { fingerprint?: unknown }; + if (typeof payload.fingerprint === "string" && payload.fingerprint.trim().length > 0) { + return payload.fingerprint.trim(); + } + } catch { + // Fall back to the local module hash below. + } + return null; +} + export function getCurrentDaemonFingerprint(options: ResolveDaemonEntrypointOptions = {}): string { - const entryPath = resolveCurrentDaemonEntrypointPath(options); const modulePath = resolve(fileURLToPath(options.moduleUrl ?? import.meta.url)); + const sharedFingerprint = readDaemonFingerprintArtifact(modulePath); const fingerprintParts = [ DAEMON_FINGERPRINT_VERSION, - process.execPath, - entryPath, - hashFileContents(entryPath) + sharedFingerprint ?? hashFileContents(modulePath) ]; - if (modulePath !== entryPath) { - fingerprintParts.push(modulePath, hashFileContents(modulePath)); - } - return createHash("sha256") .update(fingerprintParts.join("\n")) .digest("hex"); @@ -130,6 +161,18 @@ export function isCurrentDaemonFingerprint(fingerprint?: string | null): boolean return typeof fingerprint === "string" && fingerprint === getCurrentDaemonFingerprint(); } +export function createDaemonStopHeaders(token: string, reason: string): Record { + const headers: Record = { + Authorization: `Bearer ${token}`, + [DAEMON_STOP_FINGERPRINT_HEADER]: getCurrentDaemonFingerprint(), + [DAEMON_STOP_REASON_HEADER]: reason + }; + if (process.env[DAEMON_STOP_DEBUG_ENV] === "1") { + headers[DAEMON_STOP_CLIENT_PID_HEADER] = String(process.pid); + } + return headers; +} + export function resolveDaemonFingerprint(...candidates: Array): string { for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim().length > 0) { @@ -178,6 +221,22 @@ function sendJson(response: ServerResponse, status: number, payload: unknown): v response.end(JSON.stringify(payload)); } +function logDaemonStopDebug(message: string, details?: Record): void { + if (process.env[DAEMON_STOP_DEBUG_ENV] !== "1") { + return; + } + const suffix = details ? ` ${JSON.stringify(details)}` : ""; + console.error(`[daemon-stop-debug] ${message}${suffix}`); +} + +function readSingleHeader(request: IncomingMessage, name: string): string | null { + const value = request.headers[name]; + if (typeof value === "string") { + return value; + } + return null; +} + const isDaemonCommandRequest = (value: Record): value is DaemonCommandRequest => { if (typeof value.name !== "string") { return false; @@ -237,8 +296,20 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<{ state: } if (request.method === "POST" && url.pathname === "/stop") { + const stopFingerprint = readSingleHeader(request, DAEMON_STOP_FINGERPRINT_HEADER); + logDaemonStopDebug("http.stop", { + remoteAddress: request.socket.remoteAddress ?? null, + remotePort: request.socket.remotePort ?? null, + reason: readSingleHeader(request, DAEMON_STOP_REASON_HEADER), + clientPid: readSingleHeader(request, DAEMON_STOP_CLIENT_PID_HEADER), + fingerprintMatches: stopFingerprint === fingerprint + }); + if (stopFingerprint !== fingerprint) { + sendJson(response, 409, { ok: false, error: "Stale daemon stop request." }); + return; + } sendJson(response, 200, { ok: true }); - await stop(); + await stop("http.stop"); return; } @@ -298,7 +369,7 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<{ state: return; } console.error(error); - void stop().finally(() => { + void stop("uncaughtException").finally(() => { process.exitCode = 1; }); }; @@ -308,16 +379,17 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<{ state: return; } console.error(reason); - void stop().finally(() => { + void stop("unhandledRejection").finally(() => { process.exitCode = 1; }); }; - const stop = async () => { + const stop = async (reason = "unknown") => { if (stopping) { return; } stopping = true; + logDaemonStopDebug("stop.begin", { reason }); clearDaemonMetadata(); clearBinding(); process.off("SIGINT", sigintHandler); @@ -328,13 +400,14 @@ export async function startDaemon(options: DaemonOptions = {}): Promise<{ state: await new Promise((resolve) => { server.close(() => resolve()); }); + logDaemonStopDebug("stop.complete", { reason }); }; const sigintHandler = () => { - stop().catch(() => {}); + void stop("SIGINT").catch(() => {}); }; const sigtermHandler = () => { - stop().catch(() => {}); + void stop("SIGTERM").catch(() => {}); }; process.on("SIGINT", sigintHandler); diff --git a/src/cli/utils/http.ts b/src/cli/utils/http.ts index 519d6f6..0b44783 100644 --- a/src/cli/utils/http.ts +++ b/src/cli/utils/http.ts @@ -52,7 +52,10 @@ const createTimedSignal = ( const cancelResponseBody = (response: Response): void => { try { - void response.body?.cancel?.(); + const cancelResult = response.body?.cancel?.(); + if (cancelResult instanceof Promise) { + void cancelResult.catch(() => {}); + } } catch { // Best effort only. } diff --git a/src/index.ts b/src/index.ts index 328cc2f..f9980f4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,20 @@ import type { Plugin } from "@opencode-ai/plugin"; import { createOpenDevBrowserCore } from "./core"; import { ScriptRunner } from "./browser/script-runner"; -import { getCurrentDaemonFingerprint, readDaemonMetadata, startDaemon } from "./cli/daemon"; +import { + createDaemonStopHeaders, + isCurrentDaemonFingerprint, + readDaemonMetadata, + startDaemon +} from "./cli/daemon"; import { DaemonClient } from "./cli/daemon-client"; import { RemoteManager } from "./cli/remote-manager"; import { RemoteCanvasManager } from "./cli/remote-canvas-manager"; import { RemoteDesktopRuntime } from "./cli/remote-desktop-runtime"; import { RemoteRelay } from "./cli/remote-relay"; -import { fetchDaemonStatusFromMetadata } from "./cli/daemon-status"; +import { fetchDaemonStatus, fetchDaemonStatusFromMetadata, type DaemonStatusPayload } from "./cli/daemon-status"; +import { DEFAULT_DAEMON_STATUS_FETCH_OPTIONS } from "./cli/daemon-status-policy"; +import { fetchWithTimeout } from "./cli/utils/http"; import { buildSkillNudgeMessage, clearSkillNudge, @@ -122,6 +129,88 @@ const OpenDevBrowserPlugin: Plugin = async ({ directory, worktree }) => { toolDeps.browserFallbackPort = browserFallbackPort; }; + const readEnsureHubBudgetMs = (deadlineMs: number): number | null => { + const remainingMs = deadlineMs - Date.now(); + return remainingMs > 0 ? remainingMs : null; + }; + + const stopTimeoutMs = (deadlineMs: number): number => { + return Math.max(1, Math.min(500, readEnsureHubBudgetMs(deadlineMs) ?? 1)); + }; + + const resolveHubStopConnection = ( + currentConfig: { daemonPort: number; daemonToken: string }, + status: DaemonStatusPayload + ) => { + const metadata = readDaemonMetadata(); + if (metadata?.pid === status.pid) { + return { port: metadata.port, token: metadata.token }; + } + return { port: currentConfig.daemonPort, token: currentConfig.daemonToken }; + }; + + const isConfiguredHubConnection = ( + currentConfig: { daemonPort: number; daemonToken: string }, + connection: { port: number; token: string } + ): boolean => { + return connection.port === currentConfig.daemonPort && connection.token === currentConfig.daemonToken; + }; + + const waitForHubDaemonShutdown = async ( + connection: { port: number; token: string }, + deadlineMs: number + ): Promise => { + while (readEnsureHubBudgetMs(deadlineMs)) { + const status = await fetchDaemonStatus(connection.port, connection.token, { + timeoutMs: stopTimeoutMs(deadlineMs) + }); + if (!status?.ok) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, Math.min(100, stopTimeoutMs(deadlineMs)))); + } + return false; + }; + + const stopMismatchedHubDaemon = async ( + currentConfig: ReturnType, + status: DaemonStatusPayload, + deadlineMs: number + ): Promise => { + const connection = resolveHubStopConnection(currentConfig, status); + const configuredConnection = isConfiguredHubConnection(currentConfig, connection); + let response: Response; + try { + response = await fetchWithTimeout(`http://127.0.0.1:${connection.port}/stop`, { + method: "POST", + headers: createDaemonStopHeaders(connection.token, "plugin.ensureHub.upgrade") + }, stopTimeoutMs(deadlineMs)); + } catch (error) { + if (!configuredConnection) { + return; + } + throw error instanceof Error ? error : new Error(String(error)); + } + if (response.status === 409) { + if (!configuredConnection) { + return; + } + throw new Error(`Hub daemon on 127.0.0.1:${connection.port} pid=${status.pid} is protected by a different opendevbrowser build.`); + } + if (!response.ok) { + if (!configuredConnection) { + return; + } + throw new Error(`Hub daemon stop failed with status ${response.status}.`); + } + if (!(await waitForHubDaemonShutdown(connection, deadlineMs))) { + if (!configuredConnection) { + return; + } + throw new Error(`Timed out waiting for hub daemon on 127.0.0.1:${connection.port} to stop.`); + } + }; + const ensureHub = async (): Promise => { const currentConfig = configStore.get(); if (!isHubEnabled(currentConfig)) { @@ -134,30 +223,26 @@ const OpenDevBrowserPlugin: Plugin = async ({ directory, worktree }) => { const deadline = Date.now() + 2000; let attempt = 0; let lastError: Error | null = null; - const currentFingerprint = getCurrentDaemonFingerprint(); - while (attempt < 2 && Date.now() < deadline) { attempt += 1; - const status = await fetchDaemonStatusFromMetadata(currentConfig); - if (status?.ok && status.fingerprint === currentFingerprint) { - bindRemote(); - await relay?.refresh?.(); - return; + const statusTimeoutMs = readEnsureHubBudgetMs(deadline); + if (!statusTimeoutMs) { + break; } + const status = await fetchDaemonStatusFromMetadata(currentConfig, { + ...DEFAULT_DAEMON_STATUS_FETCH_OPTIONS, + timeoutMs: statusTimeoutMs + }); if (status?.ok) { - const metadata = readDaemonMetadata(); - const daemonPort = metadata?.port ?? currentConfig.daemonPort; - const daemonToken = metadata?.token ?? currentConfig.daemonToken; - if (daemonPort > 0 && daemonToken) { - try { - await fetch(`http://127.0.0.1:${daemonPort}/stop`, { - method: "POST", - headers: { Authorization: `Bearer ${daemonToken}` } - }); - } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); - } + if (isCurrentDaemonFingerprint(status.fingerprint)) { + bindRemote(); + await relay?.refresh?.(); + return; } + await stopMismatchedHubDaemon(currentConfig, status, deadline); + } + if (!readEnsureHubBudgetMs(deadline)) { + break; } try { const { stop } = await startDaemon({ config: currentConfig, directory, worktree }); @@ -165,6 +250,22 @@ const OpenDevBrowserPlugin: Plugin = async ({ directory, worktree }) => { } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); } + const refreshedTimeoutMs = readEnsureHubBudgetMs(deadline); + if (!refreshedTimeoutMs) { + break; + } + const refreshedStatus = await fetchDaemonStatusFromMetadata(currentConfig, { + ...DEFAULT_DAEMON_STATUS_FETCH_OPTIONS, + timeoutMs: refreshedTimeoutMs + }); + if (refreshedStatus?.ok) { + if (isCurrentDaemonFingerprint(refreshedStatus.fingerprint)) { + bindRemote(); + await relay?.refresh?.(); + return; + } + await stopMismatchedHubDaemon(currentConfig, refreshedStatus, deadline); + } if (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 500)); } @@ -180,7 +281,6 @@ const OpenDevBrowserPlugin: Plugin = async ({ directory, worktree }) => { const hubEnabled = isHubEnabled(config); if (hubEnabled) { - bindRemote(); try { await ensureHub(); } catch (error) { diff --git a/src/providers/inspiredesign-capture.ts b/src/providers/inspiredesign-capture.ts index d896c1c..417693e 100644 --- a/src/providers/inspiredesign-capture.ts +++ b/src/providers/inspiredesign-capture.ts @@ -91,7 +91,14 @@ const SKIPPED_AFTER_TRANSPORT_TIMEOUT_SUFFIX = "transport timeout."; const createRemainingCaptureTimeout = (timeoutMs: number): (() => number) => { const startedAtMs = Date.now(); - return () => Math.max(1, timeoutMs - Math.max(0, Date.now() - startedAtMs)); + let firstRead = true; + return () => { + if (firstRead) { + firstRead = false; + return timeoutMs; + } + return Math.max(1, timeoutMs - Math.max(0, Date.now() - startedAtMs)); + }; }; const clampInspiredesignCaptureTimeout = (timeoutMs?: number): number => { diff --git a/src/providers/workflows.ts b/src/providers/workflows.ts index b26be0f..1d554cb 100644 --- a/src/providers/workflows.ts +++ b/src/providers/workflows.ts @@ -1224,7 +1224,14 @@ const createRemainingTimeoutResolver = (timeoutMs?: number): (() => number | und return () => undefined; } const startedAtMs = Date.now(); - return () => Math.max(1, timeoutMs - Math.max(0, Date.now() - startedAtMs)); + let firstRead = true; + return () => { + if (firstRead) { + firstRead = false; + return timeoutMs; + } + return Math.max(1, timeoutMs - Math.max(0, Date.now() - startedAtMs)); + }; }; type InspiredesignResolvedInput = Omit & { diff --git a/src/tools/index.ts b/src/tools/index.ts index 3760d16..846cb13 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -70,6 +70,7 @@ import { createInspiredesignRunTool } from "./inspiredesign_run"; import { createCanvasTool } from "./canvas"; import { createSkillListTool } from "./skill_list"; import { createSkillLoadTool } from "./skill_load"; +import { failure, serializeError } from "./response"; import onboardingMetadata from "../cli/onboarding-metadata.json"; export type { ToolSurfaceEntry } from "../public-surface/source"; export { TOOL_SURFACE_ENTRIES } from "../public-surface/generated-manifest"; @@ -84,8 +85,9 @@ export function createTools(deps: ToolDeps): Record { execute: async (args, context) => { try { await deps.ensureHub?.(); - } catch { - // Fall through to tool execution; tool-level error handling will surface issues. + } catch (error) { + const serialized = serializeError(error); + return failure(serialized.message, serialized.code ?? "hub_unavailable", serialized.details); } return definition.execute(args, context); } diff --git a/src/tools/status.ts b/src/tools/status.ts index dcfe677..e73d9b3 100644 --- a/src/tools/status.ts +++ b/src/tools/status.ts @@ -6,6 +6,7 @@ import type { ToolDefinition } from "@opencode-ai/plugin"; import type { ToolDeps } from "./deps"; import { failure, ok, serializeError } from "./response"; import { fetchDaemonStatusFromMetadata } from "../cli/daemon-status"; +import { DEFAULT_DAEMON_STATUS_FETCH_OPTIONS } from "../cli/daemon-status-policy"; import { isHubEnabled } from "../utils/hub-enabled"; const z = tool.schema; @@ -66,7 +67,7 @@ export function createStatusTool(deps: ToolDeps): ToolDefinition { let sessionStatus: { mode: string; activeTargetId: string | null; url?: string; title?: string } | null = null; if (hubEnabled) { - const daemonStatus = await fetchDaemonStatusFromMetadata(); + const daemonStatus = await fetchDaemonStatusFromMetadata(config, DEFAULT_DAEMON_STATUS_FETCH_OPTIONS); if (!daemonStatus) { return failure("Daemon not running. Start with `npx opendevbrowser serve`.", "status_failed"); } diff --git a/tests/cli-serve.test.ts b/tests/cli-serve.test.ts index 62e1dc7..ff25b81 100644 --- a/tests/cli-serve.test.ts +++ b/tests/cli-serve.test.ts @@ -6,6 +6,8 @@ const mocks = vi.hoisted(() => ({ startDaemon: vi.fn(), readDaemonMetadata: vi.fn(), getCurrentDaemonFingerprint: vi.fn(), + isCurrentDaemonFingerprint: vi.fn(), + createDaemonStopHeaders: vi.fn(), fetchDaemonStatus: vi.fn(), loadGlobalConfig: vi.fn(), fetchWithTimeout: vi.fn(), @@ -18,7 +20,9 @@ const mocks = vi.hoisted(() => ({ vi.mock("../src/cli/daemon", () => ({ startDaemon: mocks.startDaemon, readDaemonMetadata: mocks.readDaemonMetadata, - getCurrentDaemonFingerprint: mocks.getCurrentDaemonFingerprint + getCurrentDaemonFingerprint: mocks.getCurrentDaemonFingerprint, + isCurrentDaemonFingerprint: mocks.isCurrentDaemonFingerprint, + createDaemonStopHeaders: mocks.createDaemonStopHeaders })); vi.mock("../src/config", () => ({ @@ -80,7 +84,7 @@ const makeConfig = (nativeExtensionId?: string): OpenDevBrowserConfig => ({ }); const CURRENT_UID = typeof process.getuid === "function" ? process.getuid() : 501; -const DEFAULT_SERVE_COMMAND = `${process.execPath} /repo/node_modules/.bin/opendevbrowser serve --output-format json`; +const DEFAULT_SERVE_COMMAND = `${process.execPath} /repo/node_modules/.bin/opendevbrowser serve --port 8788 --output-format json`; const makePsLine = ( pid: number, @@ -97,6 +101,13 @@ describe("serve command", () => { vi.resetAllMocks(); mocks.readDaemonMetadata.mockReturnValue(null); mocks.getCurrentDaemonFingerprint.mockReturnValue("current-fingerprint"); + mocks.isCurrentDaemonFingerprint.mockImplementation((fingerprint: string | null | undefined) => { + return fingerprint === "current-fingerprint"; + }); + mocks.createDaemonStopHeaders.mockImplementation((token: string, reason: string) => ({ + Authorization: `Bearer ${token}`, + "x-test-stop-reason": reason + })); mocks.fetchDaemonStatus.mockResolvedValue(null); mocks.fetchWithTimeout.mockResolvedValue({ ok: true }); mocks.getNativeStatusSnapshot.mockReturnValue({ @@ -116,6 +127,59 @@ describe("serve command", () => { }); }); + it("tags explicit stop requests for debug attribution", async () => { + mocks.readDaemonMetadata.mockReturnValue({ + port: 8788, + token: "daemon-token", + pid: 8080, + relayPort: 8787, + startedAt: new Date().toISOString(), + fingerprint: "current-fingerprint" + }); + + const result = await runServe(makeArgs(["--stop"])); + + expect(result.success).toBe(true); + expect(mocks.createDaemonStopHeaders).toHaveBeenCalledWith("daemon-token", "serve.stop"); + expect(mocks.fetchWithTimeout).toHaveBeenCalledWith( + "http://127.0.0.1:8788/stop", + { + method: "POST", + headers: { + Authorization: "Bearer daemon-token", + "x-test-stop-reason": "serve.stop" + } + } + ); + }); + + it("reports stale fingerprint stop rejections with daemon details", async () => { + mocks.readDaemonMetadata.mockReturnValue({ + port: 8788, + token: "daemon-token", + pid: 8080, + relayPort: 8787, + startedAt: new Date().toISOString(), + fingerprint: "stale-fingerprint" + }); + mocks.fetchWithTimeout.mockResolvedValue({ ok: false, status: 409 }); + mocks.spawnSync.mockReturnValue({ + status: 0, + stdout: makePsLine(8080) + }); + const killSpy = vi.spyOn(process, "kill").mockReturnValue(true); + + const result = await runServe(makeArgs(["--stop"])); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(2); + expect(result.message).toContain("rejected stale stop request"); + expect(result.message).toContain("127.0.0.1:8788 pid=8080"); + expect(result.message).toContain("opendevbrowser status --daemon"); + expect(killSpy).not.toHaveBeenCalled(); + killSpy.mockRestore(); + }); + it("does not attempt native install when host is already installed", async () => { const config = makeConfig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); mocks.loadGlobalConfig.mockReturnValue(config); @@ -183,6 +247,143 @@ describe("serve command", () => { killSpy.mockRestore(); }); + it("stops a responsive daemon with mismatched fingerprint before starting current daemon", async () => { + const config = makeConfig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + mocks.loadGlobalConfig.mockReturnValue(config); + mocks.readDaemonMetadata.mockReturnValue({ + port: 8788, + token: "daemon-token", + pid: 8080, + relayPort: 8787, + startedAt: new Date().toISOString() + }); + mocks.fetchDaemonStatus + .mockResolvedValueOnce({ + ok: true, + pid: 8080, + fingerprint: "stale-fingerprint", + hub: { instanceId: "hub-1" }, + relay: { + extensionConnected: false, + extensionHandshakeComplete: false, + cdpConnected: false, + annotationConnected: false, + opsConnected: false, + pairingRequired: false, + port: 8787, + tokenConfigured: true, + health: { status: "ok", reason: "ready" } + }, + binding: null + }) + .mockResolvedValueOnce(null); + + const result = await runServe(makeArgs([])); + + expect(result.success).toBe(true); + expect(result.message).toBe("Daemon running on 127.0.0.1:8788 (relay 8787)"); + expect(mocks.fetchWithTimeout).toHaveBeenCalledWith( + "http://127.0.0.1:8788/stop", + { + method: "POST", + headers: { + Authorization: "Bearer daemon-token", + "x-test-stop-reason": "serve.upgrade" + } + }, + 1000 + ); + expect(mocks.startDaemon).toHaveBeenCalledWith({ port: undefined, token: undefined, config }); + }); + + it("starts current daemon when mismatched daemon exits before stop", async () => { + const config = makeConfig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + mocks.loadGlobalConfig.mockReturnValue(config); + mocks.readDaemonMetadata.mockReturnValue({ + port: 8788, + token: "daemon-token", + pid: 8080, + relayPort: 8787, + startedAt: new Date().toISOString() + }); + mocks.fetchDaemonStatus + .mockResolvedValueOnce({ + ok: true, + pid: 8080, + fingerprint: "stale-fingerprint", + hub: { instanceId: "hub-1" }, + relay: { + extensionConnected: false, + extensionHandshakeComplete: false, + cdpConnected: false, + annotationConnected: false, + opsConnected: false, + pairingRequired: false, + port: 8787, + tokenConfigured: true, + health: { status: "ok", reason: "ready" } + }, + binding: null + }) + .mockResolvedValueOnce(null); + mocks.fetchWithTimeout.mockRejectedValueOnce(new Error("socket closed")); + + const result = await runServe(makeArgs([])); + + expect(result.success).toBe(true); + expect(result.message).toBe("Daemon running on 127.0.0.1:8788 (relay 8787)"); + expect(mocks.fetchWithTimeout).toHaveBeenCalledWith( + "http://127.0.0.1:8788/stop", + expect.objectContaining({ method: "POST" }), + 1000 + ); + expect(mocks.fetchDaemonStatus).toHaveBeenNthCalledWith(2, 8788, "daemon-token", { + timeoutMs: 250 + }); + expect(mocks.startDaemon).toHaveBeenCalledWith({ port: undefined, token: undefined, config }); + }); + + it("refuses to reuse a fingerprint-protected mismatched daemon", async () => { + const config = makeConfig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + mocks.loadGlobalConfig.mockReturnValue(config); + mocks.readDaemonMetadata.mockReturnValue({ + port: 8788, + token: "daemon-token", + pid: 8080, + relayPort: 8787, + startedAt: new Date().toISOString() + }); + mocks.fetchDaemonStatus.mockResolvedValueOnce({ + ok: true, + pid: 8080, + fingerprint: "stale-fingerprint", + hub: { instanceId: "hub-1" }, + relay: { + extensionConnected: false, + extensionHandshakeComplete: false, + cdpConnected: false, + annotationConnected: false, + opsConnected: false, + pairingRequired: false, + port: 8787, + tokenConfigured: true, + health: { status: "ok", reason: "ready" } + }, + binding: null + }); + mocks.fetchWithTimeout.mockResolvedValueOnce({ ok: false, status: 409 }); + const killSpy = vi.spyOn(process, "kill").mockReturnValue(true); + + const result = await runServe(makeArgs([])); + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(2); + expect(result.message).toContain("protected by a different opendevbrowser build"); + expect(mocks.startDaemon).not.toHaveBeenCalled(); + expect(killSpy).not.toHaveBeenCalled(); + killSpy.mockRestore(); + }); + it("installs using configured nativeExtensionId when host is missing", async () => { const config = makeConfig("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); mocks.loadGlobalConfig.mockReturnValue(config); @@ -418,7 +619,7 @@ describe("serve command", () => { expect(result.message).toContain("opendevbrowser status --daemon"); }); - it("reuses the healthy requested-port daemon while evicting competing same-executable daemons", async () => { + it("reuses the healthy requested-port daemon while evicting only same-port competitors", async () => { const config = makeConfig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); mocks.loadGlobalConfig.mockReturnValue(config); mocks.readDaemonMetadata.mockReturnValue({ @@ -460,19 +661,19 @@ describe("serve command", () => { expect(result.success).toBe(true); expect(result.message).toContain("Daemon already running on 127.0.0.1:8788 (pid=8080, relay 8787)."); - expect(result.message).toContain("Cleared 2 stale daemon processes."); + expect(result.message).toContain("Cleared 1 stale daemon process."); expect(result.data).toMatchObject({ port: 8788, pid: 8080, relayPort: 8787, alreadyRunning: true, - staleDaemonsCleared: 2 + staleDaemonsCleared: 1 }); expect(mocks.startDaemon).not.toHaveBeenCalled(); expect(killSpy).toHaveBeenCalledWith(9999, "SIGTERM"); expect(killSpy).toHaveBeenCalledWith(9999, "SIGKILL"); - expect(killSpy).toHaveBeenCalledWith(2222, "SIGTERM"); - expect(killSpy).toHaveBeenCalledWith(2222, "SIGKILL"); + expect(killSpy).not.toHaveBeenCalledWith(2222, "SIGTERM"); + expect(killSpy).not.toHaveBeenCalledWith(2222, "SIGKILL"); expect(killSpy).not.toHaveBeenCalledWith(8080, "SIGTERM"); killSpy.mockRestore(); }); @@ -520,7 +721,7 @@ describe("serve command", () => { status: 0, stdout: [ makePsLine(8888), - makePsLine(9999, { command: `${process.execPath} /repo/node_modules/.bin/opendevbrowser serve --port 8788 --output-format json` }), + makePsLine(9999, { command: `${process.execPath} /repo/node_modules/.bin/opendevbrowser serve --port=8788 --output-format json` }), makePsLine(2222, { command: `${process.execPath} /repo/node_modules/.bin/opendevbrowser serve --port 9999 --output-format json` }) ].join("") }); @@ -529,14 +730,14 @@ describe("serve command", () => { const result = await runServe(makeArgs([])); expect(result.success).toBe(true); - expect(result.message).toContain("Cleared 3 stale daemon processes."); + expect(result.message).toContain("Cleared 2 stale daemon processes."); expect(mocks.startDaemon).toHaveBeenCalledTimes(1); expect(killSpy).toHaveBeenCalledWith(8888, "SIGTERM"); expect(killSpy).toHaveBeenCalledWith(8888, "SIGKILL"); expect(killSpy).toHaveBeenCalledWith(9999, "SIGTERM"); expect(killSpy).toHaveBeenCalledWith(9999, "SIGKILL"); - expect(killSpy).toHaveBeenCalledWith(2222, "SIGTERM"); - expect(killSpy).toHaveBeenCalledWith(2222, "SIGKILL"); + expect(killSpy).not.toHaveBeenCalledWith(2222, "SIGTERM"); + expect(killSpy).not.toHaveBeenCalledWith(2222, "SIGKILL"); expect(killSpy).not.toHaveBeenCalledWith(7777, "SIGTERM"); expect(killSpy.mock.invocationCallOrder[0]).toBeLessThan(mocks.startDaemon.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER); killSpy.mockRestore(); @@ -607,7 +808,7 @@ describe("serve command", () => { killSpy.mockRestore(); }); - it("replaces a healthy daemon when fingerprint does not match", async () => { + it("stops a healthy daemon when fingerprint does not match", async () => { const config = makeConfig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); mocks.loadGlobalConfig.mockReturnValue(config); mocks.readDaemonMetadata.mockReturnValue({ @@ -618,41 +819,39 @@ describe("serve command", () => { startedAt: new Date().toISOString(), fingerprint: "stale-fingerprint" }); - mocks.fetchDaemonStatus.mockResolvedValue({ - ok: true, - pid: 8080, - fingerprint: "stale-fingerprint", - hub: { instanceId: "hub-1" }, - relay: { - extensionConnected: false, - extensionHandshakeComplete: false, - cdpConnected: false, - annotationConnected: false, - opsConnected: false, - pairingRequired: false, - port: 8787, - tokenConfigured: true, - health: { status: "ok", reason: "ready" } - }, - binding: null - }); + mocks.fetchDaemonStatus + .mockResolvedValueOnce({ + ok: true, + pid: 8080, + fingerprint: "stale-fingerprint", + hub: { instanceId: "hub-1" }, + relay: { + extensionConnected: false, + extensionHandshakeComplete: false, + cdpConnected: false, + annotationConnected: false, + opsConnected: false, + pairingRequired: false, + port: 8787, + tokenConfigured: true, + health: { status: "ok", reason: "ready" } + }, + binding: null + }) + .mockResolvedValueOnce(null); const killSpy = vi.spyOn(process, "kill").mockReturnValue(true); const result = await runServe(makeArgs([])); expect(result.success).toBe(true); - expect(result.message).toContain("Daemon running on 127.0.0.1:8788 (relay 8787)"); - expect(result.message).toContain("Replaced stale daemon fingerprint."); - expect(result.message).toContain("Cleared 1 stale daemon process."); + expect(result.message).toBe("Daemon running on 127.0.0.1:8788 (relay 8787)"); expect(mocks.fetchWithTimeout).toHaveBeenCalledWith( "http://127.0.0.1:8788/stop", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ Authorization: "Bearer daemon-token" }) - }) + expect.objectContaining({ method: "POST" }), + 1000 ); expect(mocks.startDaemon).toHaveBeenCalledTimes(1); - expect(killSpy).not.toHaveBeenCalledWith(8080, "SIGTERM"); + expect(killSpy).not.toHaveBeenCalled(); killSpy.mockRestore(); }); @@ -677,7 +876,7 @@ describe("serve command", () => { killSpy.mockRestore(); }); - it("evicts same-executable daemon processes on other ports before starting a new daemon", async () => { + it("does not evict same-executable daemon processes on other or unknown ports", async () => { const config = makeConfig("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); mocks.loadGlobalConfig.mockReturnValue(config); mocks.readDaemonMetadata.mockReturnValue({ @@ -697,9 +896,14 @@ describe("serve command", () => { }); mocks.spawnSync.mockReturnValue({ status: 0, - stdout: makePsLine(7777, { - command: `${process.execPath} /repo/node_modules/.bin/opendevbrowser serve --output-format json` - }) + stdout: [ + makePsLine(7777, { + command: `${process.execPath} /repo/node_modules/.bin/opendevbrowser serve --port 8788 --output-format json` + }), + makePsLine(8888, { + command: `${process.execPath} /repo/node_modules/.bin/opendevbrowser serve --output-format json` + }) + ].join("") }); mocks.startDaemon.mockResolvedValueOnce({ state: { port: 9999, pid: 1234, relayPort: 8787 }, @@ -711,10 +915,9 @@ describe("serve command", () => { expect(result.success).toBe(true); expect(result.message).toContain("Daemon running on 127.0.0.1:9999 (relay 8787)"); - expect(result.message).toContain("Cleared 1 stale daemon process."); + expect(result.data?.staleDaemonsCleared).toBe(0); expect(mocks.startDaemon).toHaveBeenCalledWith({ port: 9999, token: undefined, config }); - expect(killSpy).toHaveBeenCalledWith(7777, "SIGTERM"); - expect(killSpy).toHaveBeenCalledWith(7777, "SIGKILL"); + expect(killSpy).not.toHaveBeenCalled(); killSpy.mockRestore(); }); }); diff --git a/tests/cli-smoke-test-script.test.ts b/tests/cli-smoke-test-script.test.ts index cb49ba3..bd50f85 100644 --- a/tests/cli-smoke-test-script.test.ts +++ b/tests/cli-smoke-test-script.test.ts @@ -67,6 +67,7 @@ describe("cli-smoke-test startDaemon", () => { await expect(pending).resolves.toMatchObject({ pid: 4321 }); expect(spawnSyncMock).toHaveBeenCalledTimes(4); + expect(spawnSyncMock.mock.calls[0]?.[1]?.[1]).toContain(JSON.stringify(process.execPath)); expect(killSpy).toHaveBeenCalledTimes(2); expect(killSpy.mock.calls.every(([, signal]) => signal === 0)).toBe(true); }); diff --git a/tests/cli-status.test.ts b/tests/cli-status.test.ts index e6bb059..885f238 100644 --- a/tests/cli-status.test.ts +++ b/tests/cli-status.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ParsedArgs } from "../src/cli/args"; +import { CliError, EXIT_DISCONNECTED } from "../src/cli/errors"; const mocks = vi.hoisted(() => ({ fetchDaemonStatusFromMetadata: vi.fn(), @@ -110,4 +111,44 @@ describe("status command", () => { expect(result.message).toContain("opendevbrowser native install bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); expect(result.message).toContain("opendevbrowser serve"); }); + + it("classifies a missing daemon as disconnected", async () => { + mocks.fetchDaemonStatusFromMetadata.mockResolvedValue(null); + mocks.getNativeStatusSnapshot.mockReturnValue(mismatchStatus); + + await expect(runStatus(makeArgs(["--daemon"]))).rejects.toMatchObject({ + message: "Daemon not running. Start with `opendevbrowser serve`.", + exitCode: EXIT_DISCONNECTED + }); + }); + + it("surfaces daemon fingerprint mismatch in status output", async () => { + mocks.fetchDaemonStatusFromMetadata.mockResolvedValue({ + ok: true, + pid: 1234, + fingerprintCurrent: false, + relay: { + port: 8787, + extensionConnected: false, + extensionHandshakeComplete: false, + cdpConnected: false, + annotationConnected: false, + opsConnected: false, + canvasConnected: false, + pairingRequired: false, + health: { reason: "extension_disconnected" } + }, + binding: null + }); + mocks.getNativeStatusSnapshot.mockReturnValue({ + ...mismatchStatus, + mismatch: false + }); + + const result = await runStatus(makeArgs(["--daemon"])); + + expect(result.success).toBe(true); + expect(result.message).toContain("Daemon fingerprint: mismatch with current build"); + expect(result.data).toMatchObject({ fingerprintCurrent: false }); + }); }); diff --git a/tests/daemon-client-retry-timeout.test.ts b/tests/daemon-client-retry-timeout.test.ts index 588fab9..1b6f2c3 100644 --- a/tests/daemon-client-retry-timeout.test.ts +++ b/tests/daemon-client-retry-timeout.test.ts @@ -10,6 +10,10 @@ const mocks = vi.hoisted(() => ({ persistDaemonStatusMetadata: vi.fn(), getCacheRoot: vi.fn(() => "/tmp/odb-daemon-client"), isCurrentDaemonFingerprint: vi.fn(), + createDaemonStopHeaders: vi.fn((token: string) => ({ + Authorization: `Bearer ${token}`, + "x-opendevbrowser-stop-fingerprint": "current-fingerprint" + })), resolveCurrentDaemonEntrypointPath: vi.fn(), loadGlobalConfig: vi.fn(), fetchDaemonStatus: vi.fn(), @@ -24,6 +28,8 @@ vi.mock("../src/cli/utils/http", () => ({ })); vi.mock("../src/cli/daemon", () => ({ + DAEMON_STOP_DEBUG_ENV: "OPDEVBROWSER_DEBUG_DAEMON_STOP", + createDaemonStopHeaders: mocks.createDaemonStopHeaders, readDaemonMetadata: mocks.readDaemonMetadata, getCacheRoot: mocks.getCacheRoot, isCurrentDaemonFingerprint: mocks.isCurrentDaemonFingerprint, @@ -66,6 +72,7 @@ describe("daemon-client retry timeout propagation", () => { mocks.persistDaemonStatusMetadata.mockReset(); mocks.getCacheRoot.mockReset(); mocks.isCurrentDaemonFingerprint.mockReset(); + mocks.createDaemonStopHeaders.mockReset(); mocks.resolveCurrentDaemonEntrypointPath.mockReset(); mocks.loadGlobalConfig.mockReset(); mocks.fetchDaemonStatus.mockReset(); @@ -74,6 +81,10 @@ describe("daemon-client retry timeout propagation", () => { process.execArgv = []; mocks.getCacheRoot.mockReturnValue("/tmp/odb-daemon-client"); + mocks.createDaemonStopHeaders.mockImplementation((token: string) => ({ + Authorization: `Bearer ${token}`, + "x-opendevbrowser-stop-fingerprint": "current-fingerprint" + })); mocks.resolveCurrentDaemonEntrypointPath.mockImplementation((options?: { argv1?: string }) => { const rawEntry = options?.argv1 ?? process.argv[1]; return typeof rawEntry === "string" && rawEntry.trim().length > 0 @@ -167,7 +178,10 @@ describe("daemon-client retry timeout propagation", () => { "http://127.0.0.1:8788/stop", expect.objectContaining({ method: "POST", - headers: { Authorization: "Bearer stale-token" } + headers: { + Authorization: "Bearer stale-token", + "x-opendevbrowser-stop-fingerprint": "current-fingerprint" + } }), 5_000 ); @@ -281,7 +295,10 @@ describe("daemon-client retry timeout propagation", () => { "http://127.0.0.1:8788/stop", expect.objectContaining({ method: "POST", - headers: { Authorization: "Bearer fresh-token" } + headers: { + Authorization: "Bearer fresh-token", + "x-opendevbrowser-stop-fingerprint": "current-fingerprint" + } }), 5_000 ); @@ -371,7 +388,10 @@ describe("daemon-client retry timeout propagation", () => { "http://127.0.0.1:8788/stop", expect.objectContaining({ method: "POST", - headers: { Authorization: "Bearer fresh-token" } + headers: { + Authorization: "Bearer fresh-token", + "x-opendevbrowser-stop-fingerprint": "current-fingerprint" + } }), 5_000 ); @@ -438,7 +458,10 @@ describe("daemon-client retry timeout propagation", () => { "http://127.0.0.1:8788/stop", expect.objectContaining({ method: "POST", - headers: { Authorization: "Bearer fresh-token" } + headers: { + Authorization: "Bearer fresh-token", + "x-opendevbrowser-stop-fingerprint": "current-fingerprint" + } }), 1_000 ); @@ -490,7 +513,10 @@ describe("daemon-client retry timeout propagation", () => { "http://127.0.0.1:8788/stop", expect.objectContaining({ method: "POST", - headers: { Authorization: "Bearer fresh-token" } + headers: { + Authorization: "Bearer fresh-token", + "x-opendevbrowser-stop-fingerprint": "current-fingerprint" + } }), 5_000 ); diff --git a/tests/daemon-client.test.ts b/tests/daemon-client.test.ts index 8ebd80f..a1eab3f 100644 --- a/tests/daemon-client.test.ts +++ b/tests/daemon-client.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createHash } from "crypto"; import { mkdtemp, readFile, rm, mkdir, writeFile } from "fs/promises"; import { tmpdir } from "os"; import { join, normalize } from "path"; @@ -918,6 +919,175 @@ describe("daemon-client error parsing", () => { ]); }); + it("waits for the configured daemon instead of hopping to current metadata for canvas.execute", async () => { + await writeDaemonConfig(tempRoot, 23456, "configured-token"); + vi.useFakeTimers(); + + const currentStatus = { + ok: true as const, + pid: 4242, + fingerprint: getCurrentDaemonFingerprint(), + hub: { instanceId: "hub-current" }, + relay: { + running: true, + url: "ws://127.0.0.1:8787", + port: 8787, + extensionConnected: false, + extensionHandshakeComplete: false, + cdpConnected: false, + annotationConnected: false, + opsConnected: false, + canvasConnected: false, + pairingRequired: false, + instanceId: "relay-current", + epoch: 1, + health: { ok: true, reason: "ok" } + }, + binding: null + }; + + let configuredAttempts = 0; + const statusSpy = vi.spyOn(daemonStatusModule, "fetchDaemonStatus") + .mockImplementation(async (port, _token, options) => { + if (port === 12345) { + expect(options).toEqual(expect.objectContaining({ timeoutMs: expect.any(Number) })); + return currentStatus; + } + if (port === 23456) { + configuredAttempts += 1; + expect(options).toEqual(expect.objectContaining({ timeoutMs: expect.any(Number) })); + return configuredAttempts < 10 ? null : currentStatus; + } + throw new Error(`Unexpected status probe: ${port}`); + }); + + fetchSpy = vi.fn(async (input, options) => { + const url = String(input); + const authorization = String((options?.headers as Record | undefined)?.Authorization ?? ""); + if (url === "http://127.0.0.1:12345/stop") { + expect(authorization).toBe("Bearer test-token"); + return new Response("", { status: 200 }); + } + if (url === "http://127.0.0.1:23456/command") { + expect(authorization).toBe("Bearer configured-token"); + return new Response(JSON.stringify({ ok: true, data: { ok: true, source: "configured" } }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (url === "http://127.0.0.1:12345/command") { + throw new Error("canvas.execute must not hop to metadata"); + } + throw new Error(`Unexpected fetch: ${url}`); + }) as ReturnType; + + (globalThis as unknown as { fetch: typeof fetch }).fetch = fetchSpy as unknown as typeof fetch; + + try { + const client = new DaemonClient({ autoRenew: false }); + const resultPromise = client.call("canvas.execute", { + command: "document.load", + params: { canvasSessionId: "canvas-1", leaseId: "lease-1" } + }); + await vi.advanceTimersByTimeAsync(3_000); + const result = await resultPromise; + + expect(result).toEqual({ ok: true, source: "configured" }); + expect(fetchSpy.mock.calls.map(([input]) => String(input))).toContain("http://127.0.0.1:23456/command"); + expect(fetchSpy.mock.calls.map(([input]) => String(input))).not.toContain("http://127.0.0.1:12345/command"); + } finally { + vi.useRealTimers(); + statusSpy.mockRestore(); + } + }); + + it("rejects canvas.execute when the configured daemon is stale and fingerprint-protected", async () => { + await writeDaemonConfig(tempRoot, 23456, "configured-token"); + vi.useFakeTimers(); + + const currentMetadataStatus = { + ok: true as const, + pid: 9999, + fingerprint: getCurrentDaemonFingerprint(), + hub: { instanceId: "hub-current" }, + relay: { + running: true, + url: "ws://127.0.0.1:8787", + port: 8787, + extensionConnected: false, + extensionHandshakeComplete: false, + cdpConnected: false, + annotationConnected: false, + opsConnected: false, + canvasConnected: false, + pairingRequired: false, + instanceId: "relay-current", + epoch: 1, + health: { ok: true, reason: "ok" } + }, + binding: null + }; + const staleConfiguredStatus = { + ...currentMetadataStatus, + pid: 4242, + fingerprint: "foreign-current-fingerprint", + hub: { instanceId: "hub-foreign" }, + relay: { + ...currentMetadataStatus.relay, + instanceId: "relay-foreign" + } + }; + let configuredChecks = 0; + const statusSpy = vi.spyOn(daemonStatusModule, "fetchDaemonStatus") + .mockImplementation(async (port, _token, options) => { + expect(options).toEqual(expect.objectContaining({ timeoutMs: expect.any(Number) })); + if (port === 12345) { + return currentMetadataStatus; + } + if (port === 23456) { + configuredChecks += 1; + return staleConfiguredStatus; + } + throw new Error(`Unexpected status probe: ${port}`); + }); + + fetchSpy = vi.fn(async (input, options) => { + const url = String(input); + const authorization = String((options?.headers as Record | undefined)?.Authorization ?? ""); + if (url === "http://127.0.0.1:23456/stop") { + expect(authorization).toBe("Bearer configured-token"); + return new Response(JSON.stringify({ ok: false, error: "Stale daemon stop request." }), { + status: 409, + headers: { "Content-Type": "application/json" } + }); + } + if (url === "http://127.0.0.1:12345/command") { + throw new Error("canvas.execute must not hop to metadata"); + } + throw new Error(`Unexpected fetch: ${url}`); + }) as ReturnType; + + (globalThis as unknown as { fetch: typeof fetch }).fetch = fetchSpy as unknown as typeof fetch; + + try { + const client = new DaemonClient({ autoRenew: false }); + const resultPromise = expect(client.call("canvas.execute", { + command: "document.load", + params: { canvasSessionId: "canvas-1", leaseId: "lease-1" } + })).rejects.toThrow("protected by a different opendevbrowser build"); + await vi.advanceTimersByTimeAsync(6_000); + await resultPromise; + + expect(configuredChecks).toBeGreaterThan(3); + expect(fetchSpy.mock.calls.map(([input]) => String(input))).toEqual([ + "http://127.0.0.1:23456/stop" + ]); + } finally { + vi.useRealTimers(); + statusSpy.mockRestore(); + } + }); + it("stops a stale configured daemon while falling back to a current metadata daemon", async () => { await writeDaemonConfig(tempRoot, 23456, "configured-token"); await writeFile(join(tempRoot, "opendevbrowser", "daemon.json"), JSON.stringify({ @@ -1115,6 +1285,13 @@ describe("daemon-client error parsing", () => { fetchSpy = vi.fn(async (input, options) => { const url = String(input); const authorization = String((options?.headers as Record | undefined)?.Authorization ?? ""); + if (url === "http://127.0.0.1:12345/stop") { + expect(authorization).toBe("Bearer test-token"); + return new Response(JSON.stringify({ ok: false, error: "Stale daemon stop request." }), { + status: 409, + headers: { "Content-Type": "application/json" } + }); + } if (url === "http://127.0.0.1:23456/command") { expect(authorization).toBe("Bearer configured-token"); return new Response(JSON.stringify({ ok: true, data: { ok: true, source: "configured" } }), { @@ -1392,6 +1569,62 @@ describe("daemon-client error parsing", () => { } }); + it("rejects a responsive mismatched daemon when fingerprint-gated stop is rejected", async () => { + await rm(join(tempRoot, "opendevbrowser", "daemon.json"), { force: true }); + await writeDaemonConfig(tempRoot, 23456, "configured-token"); + + const staleStatus = { + ok: true as const, + pid: 1111, + fingerprint: "foreign-current-fingerprint", + hub: { instanceId: "hub-foreign" }, + relay: { + running: true, + url: "ws://127.0.0.1:8787", + port: 8787, + extensionConnected: false, + extensionHandshakeComplete: false, + cdpConnected: false, + annotationConnected: false, + opsConnected: false, + canvasConnected: false, + pairingRequired: false, + instanceId: "relay-foreign", + epoch: 1, + health: { ok: true, reason: "ok" } + }, + binding: null + }; + const statusSpy = vi.spyOn(daemonStatusModule, "fetchDaemonStatus") + .mockResolvedValueOnce(staleStatus); + + fetchSpy = vi.fn(async (input, options) => { + const url = String(input); + const authorization = String((options?.headers as Record | undefined)?.Authorization ?? ""); + if (url === "http://127.0.0.1:23456/stop") { + expect(authorization).toBe("Bearer configured-token"); + return new Response(JSON.stringify({ ok: false, error: "Stale daemon stop request." }), { + status: 409, + headers: { "Content-Type": "application/json" } + }); + } + throw new Error(`Unexpected fetch: ${url}`); + }) as ReturnType; + + (globalThis as unknown as { fetch: typeof fetch }).fetch = fetchSpy as unknown as typeof fetch; + + try { + const client = new DaemonClient({ autoRenew: false }); + await expect(client.call("some.command")).rejects.toThrow("protected by a different opendevbrowser build"); + expect(statusSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls.map(([input]) => String(input))).toEqual([ + "http://127.0.0.1:23456/stop" + ]); + } finally { + statusSpy.mockRestore(); + } + }); + it("waits past the old concurrent-restart window for a configured daemon to recover", async () => { await rm(join(tempRoot, "opendevbrowser", "daemon.json"), { force: true }); await writeDaemonConfig(tempRoot, 23456, "configured-token"); @@ -1570,6 +1803,68 @@ describe("daemon-client error parsing", () => { expect(fingerprintB).not.toBe(fingerprintA); }); + it("keeps the fingerprint stable across built tool and cli bundles when a shared dist fingerprint exists", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "odb-daemon-fingerprint-shared-")); + const distDir = join(repoRoot, "dist"); + const cliDir = join(distDir, "cli"); + await mkdir(cliDir, { recursive: true }); + await writeFile(join(cliDir, "index.js"), "export const cli = 'bundle';\n", "utf-8"); + await writeFile(join(distDir, "index.js"), "export const tool = 'bundle';\n", "utf-8"); + await writeFile(join(distDir, "daemon-fingerprint.json"), JSON.stringify({ + fingerprint: "shared-dist-fingerprint" + }), "utf-8"); + + const cliFingerprint = getCurrentDaemonFingerprint({ + moduleUrl: pathToFileURL(join(cliDir, "index.js")).href + }); + const toolFingerprint = getCurrentDaemonFingerprint({ + moduleUrl: pathToFileURL(join(distDir, "index.js")).href + }); + + expect(toolFingerprint).toBe(cliFingerprint); + }); + + it("derives built fingerprints without the local node executable path", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "odb-daemon-fingerprint-execpath-")); + const distDir = join(repoRoot, "dist"); + const cliDir = join(distDir, "cli"); + await mkdir(cliDir, { recursive: true }); + await writeFile(join(cliDir, "index.js"), "export const cli = 'bundle';\n", "utf-8"); + await writeFile(join(distDir, "daemon-fingerprint.json"), JSON.stringify({ + fingerprint: "shared-dist-fingerprint" + }), "utf-8"); + + const expectedFingerprint = createHash("sha256") + .update("v1\nshared-dist-fingerprint") + .digest("hex"); + + expect(getCurrentDaemonFingerprint({ + moduleUrl: pathToFileURL(join(cliDir, "index.js")).href + })).toBe(expectedFingerprint); + }); + + it("ignores a shared dist fingerprint when a source-mode module sits beside the built bundle", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "odb-daemon-fingerprint-source-")); + const srcCliDir = join(repoRoot, "src", "cli"); + const distDir = join(repoRoot, "dist"); + await mkdir(srcCliDir, { recursive: true }); + await mkdir(join(distDir, "cli"), { recursive: true }); + await writeFile(join(srcCliDir, "daemon.ts"), "export const daemon = 'source';\n", "utf-8"); + await writeFile(join(distDir, "cli", "index.js"), "export const cli = 'bundle';\n", "utf-8"); + await writeFile(join(distDir, "daemon-fingerprint.json"), JSON.stringify({ + fingerprint: "shared-dist-fingerprint" + }), "utf-8"); + + const sourceFingerprint = getCurrentDaemonFingerprint({ + moduleUrl: pathToFileURL(join(srcCliDir, "daemon.ts")).href + }); + const distFingerprint = getCurrentDaemonFingerprint({ + moduleUrl: pathToFileURL(join(distDir, "cli", "index.js")).href + }); + + expect(sourceFingerprint).not.toBe(distFingerprint); + }); + it("derives a restart-safe CLI tuple from the built daemon-client module when argv is missing", () => { const repoRoot = join(tmpdir(), "odb-daemon-client-restart"); const entryPath = join(repoRoot, "dist", "cli", "index.js"); diff --git a/tests/daemon-command.test.ts b/tests/daemon-command.test.ts index c91666b..58e9024 100644 --- a/tests/daemon-command.test.ts +++ b/tests/daemon-command.test.ts @@ -20,6 +20,12 @@ const installAutostart = vi.fn(); const uninstallAutostart = vi.fn(); const getAutostartStatus = vi.fn(); const fetchDaemonStatusFromMetadata = vi.fn(async () => null); +const readDaemonMetadata = vi.fn(() => null); +const createDaemonStopHeaders = vi.fn((token: string, reason: string) => ({ + Authorization: `Bearer ${token}`, + "x-test-stop-reason": reason +})); +const fetchWithTimeout = vi.fn(async () => ({ ok: true })); const STABLE_DAEMON_INSTALL_GUIDANCE = "Run opendevbrowser daemon install from a stable installation path."; const isTransientAutostartInstallError = vi.fn((error: unknown) => { @@ -40,11 +46,12 @@ vi.mock("../src/cli/daemon-status", () => ({ })); vi.mock("../src/cli/daemon", () => ({ - readDaemonMetadata: vi.fn(() => null) + createDaemonStopHeaders, + readDaemonMetadata })); vi.mock("../src/cli/utils/http", () => ({ - fetchWithTimeout: vi.fn(async () => ({ ok: true })) + fetchWithTimeout })); const buildArgs = (rawArgs: string[]): ParsedArgs => ({ @@ -74,6 +81,12 @@ describe("daemon command", () => { })); getAutostartStatus.mockReturnValue(makeAutostartStatus()); fetchDaemonStatusFromMetadata.mockResolvedValue(null); + readDaemonMetadata.mockReturnValue(null); + createDaemonStopHeaders.mockImplementation((token: string, reason: string) => ({ + Authorization: `Bearer ${token}`, + "x-test-stop-reason": reason + })); + fetchWithTimeout.mockResolvedValue({ ok: true }); }); it("installs autostart", async () => { @@ -113,6 +126,61 @@ describe("daemon command", () => { expect(uninstallAutostart).toHaveBeenCalledTimes(1); }); + it("tags uninstall stop requests for debug attribution", async () => { + readDaemonMetadata.mockReturnValue({ + port: 8788, + token: "daemon-token", + pid: 8080, + relayPort: 8787, + startedAt: new Date().toISOString(), + fingerprint: "current-fingerprint" + }); + + const { runDaemonCommand } = await import("../src/cli/commands/daemon"); + const result = await runDaemonCommand(buildArgs(["uninstall"])); + + expect(result.success).toBe(true); + expect(createDaemonStopHeaders).toHaveBeenCalledWith("daemon-token", "daemon.uninstall"); + expect(fetchWithTimeout).toHaveBeenCalledWith( + "http://127.0.0.1:8788/stop", + { + method: "POST", + headers: { + Authorization: "Bearer daemon-token", + "x-test-stop-reason": "daemon.uninstall" + } + } + ); + }); + + it("reports stale fingerprint stop rejections during uninstall", async () => { + readDaemonMetadata.mockReturnValue({ + port: 8788, + token: "daemon-token", + pid: 8080, + relayPort: 8787, + startedAt: new Date().toISOString(), + fingerprint: "stale-fingerprint" + }); + fetchWithTimeout.mockResolvedValue({ ok: false, status: 409 }); + + const { runDaemonCommand } = await import("../src/cli/commands/daemon"); + const result = await runDaemonCommand(buildArgs(["uninstall"])); + + expect(result.success).toBe(false); + expect(resolveExitCode(result)).toBe(2); + expect(result.message).toContain("rejected the stop request as stale"); + expect(result.message).toContain("127.0.0.1:8788 pid=8080"); + expect(result.message).toContain("opendevbrowser status --daemon"); + expect(result.data).toMatchObject({ + stop: { + outcome: "fingerprint_rejected", + pid: 8080, + port: 8788 + } + }); + }); + it("returns healthy running status with nested autostart data", async () => { fetchDaemonStatusFromMetadata.mockResolvedValue({ pid: 1234, port: 0 }); @@ -124,6 +192,10 @@ describe("daemon command", () => { expect(result.message).toContain("healthy"); expect((result.data as { autostart: AutostartStatus }).autostart.health).toBe("healthy"); expect((result.data as { status: { pid: number } }).status.pid).toBe(1234); + expect(fetchDaemonStatusFromMetadata).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ timeoutMs: 5_000, retryAttempts: 5, retryDelayMs: 250 }) + ); }); it("keeps status successful when autostart is missing but daemon is running", async () => { diff --git a/tests/daemon-e2e.test.ts b/tests/daemon-e2e.test.ts index 8955ebb..50a33d2 100644 --- a/tests/daemon-e2e.test.ts +++ b/tests/daemon-e2e.test.ts @@ -5,7 +5,7 @@ import { join } from "path"; import http from "http"; import type { AddressInfo } from "net"; import type { OpenDevBrowserConfig } from "../src/config"; -import { startDaemon } from "../src/cli/daemon"; +import { createDaemonStopHeaders, startDaemon } from "../src/cli/daemon"; import { DaemonClient } from "../src/cli/daemon-client"; import { fetchDaemonStatusFromMetadata } from "../src/cli/daemon-status"; @@ -50,6 +50,13 @@ const fetchStatus = async (port: number, token: string): Promise; }; +const postStop = async (port: number, headers: Record): Promise => { + return await fetch(`http://127.0.0.1:${port}/stop`, { + method: "POST", + headers + }); +}; + describe("daemon e2e", () => { let tempRoot = ""; let previousCacheDir: string | undefined; @@ -136,6 +143,40 @@ describe("daemon e2e", () => { })); }); + it("rejects stale stop requests that only know the daemon token", async () => { + daemonPort = await getAvailablePort(); + const { stop } = await startDaemon({ + port: daemonPort, + token, + config: makeConfig(), + directory: tempRoot, + worktree: null + }); + daemonStop = stop; + + const response = await postStop(daemonPort, { Authorization: `Bearer ${token}` }); + + expect(response.status).toBe(409); + const status = await fetchStatus(daemonPort, token); + expect(status.ok).toBe(true); + }); + + it("allows current clients to stop the daemon with a matching fingerprint", async () => { + daemonPort = await getAvailablePort(); + const { stop } = await startDaemon({ + port: daemonPort, + token, + config: makeConfig(), + directory: tempRoot, + worktree: null + }); + daemonStop = stop; + + const response = await postStop(daemonPort, createDaemonStopHeaders(token, "test.current")); + + expect(response.status).toBe(200); + }); + it("recovers daemon status when metadata is missing", async () => { daemonPort = await getAvailablePort(); const configDir = join(tempRoot, "config"); @@ -265,7 +306,8 @@ describe("daemon e2e", () => { }); daemonStop = stop; - await fetchDaemonStatusFromMetadata(); + const config = makeConfig({ daemonPort, daemonToken: token }); + await fetchDaemonStatusFromMetadata(config); const metadataPath = join(tempRoot, "opendevbrowser", "daemon.json"); const first = JSON.parse(await readFile(metadataPath, "utf-8")) as { relayInstanceId?: string }; @@ -281,7 +323,7 @@ describe("daemon e2e", () => { }); daemonStop = stop2; - await fetchDaemonStatusFromMetadata(); + await fetchDaemonStatusFromMetadata(config); const second = JSON.parse(await readFile(metadataPath, "utf-8")) as { relayInstanceId?: string }; expect(first.relayInstanceId).toBeTruthy(); diff --git a/tests/daemon-recovery-regressions.test.ts b/tests/daemon-recovery-regressions.test.ts new file mode 100644 index 0000000..a7975c8 --- /dev/null +++ b/tests/daemon-recovery-regressions.test.ts @@ -0,0 +1,270 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { getCurrentDaemonFingerprint } from "../src/cli/daemon"; +import { DaemonClient, __test__ as daemonClientTest } from "../src/cli/daemon-client"; +import { fetchDaemonStatus, fetchDaemonStatusFromMetadata } from "../src/cli/daemon-status"; +import { readResponseJsonWithTimeout } from "../src/cli/utils/http"; + +const writeDaemonMetadata = async (root: string): Promise => { + const cacheRoot = join(root, "opendevbrowser"); + await mkdir(cacheRoot, { recursive: true }); + await writeFile(join(cacheRoot, "daemon.json"), JSON.stringify({ + port: 12345, + token: "test-token", + pid: 9999, + relayPort: 8787, + startedAt: new Date().toISOString(), + fingerprint: getCurrentDaemonFingerprint() + }), "utf-8"); +}; + +const writeDaemonConfig = async (root: string, port: number, token: string): Promise => { + const configRoot = join(root, "config"); + await mkdir(configRoot, { recursive: true }); + process.env.OPENCODE_CONFIG_DIR = configRoot; + await writeFile(join(configRoot, "opendevbrowser.jsonc"), JSON.stringify({ + daemonPort: port, + daemonToken: token, + relayPort: 0, + relayToken: false + }), "utf-8"); +}; + +const createStalledJsonResponse = (): Response => { + const stalledBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{")); + } + }); + return new Response(stalledBody, { + status: 200, + headers: { "Content-Type": "application/json" } + }); +}; + +const createHealthyStatus = (pid: number, fingerprint: string) => ({ + ok: true as const, + pid, + fingerprint, + hub: { instanceId: `hub-${pid}` }, + relay: { + port: 8788, + tokenSet: true, + extensionConnected: false, + extensionHandshakeComplete: false, + annotationConnected: false, + opsConnected: false, + canvasConnected: false, + cdpConnected: false, + pairingRequired: false, + health: { ok: true, reason: "healthy" } + }, + binding: null +}); + +describe("daemon recovery regressions", () => { + let tempRoot = ""; + let previousCacheDir: string | undefined; + let previousConfigDir: string | undefined; + let fetchSpy: ReturnType> | null = null; + + beforeEach(async () => { + tempRoot = await mkdtemp(join(tmpdir(), "odb-daemon-recovery-")); + previousCacheDir = process.env.OPENCODE_CACHE_DIR; + previousConfigDir = process.env.OPENCODE_CONFIG_DIR; + process.env.OPENCODE_CACHE_DIR = tempRoot; + daemonClientTest.resetCachedClientState(); + await writeDaemonMetadata(tempRoot); + await writeDaemonConfig(tempRoot, 12345, "test-token"); + }); + + afterEach(async () => { + fetchSpy?.mockRestore(); + fetchSpy = null; + vi.useRealTimers(); + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + if (previousCacheDir === undefined) { + delete process.env.OPENCODE_CACHE_DIR; + } else { + process.env.OPENCODE_CACHE_DIR = previousCacheDir; + } + if (previousConfigDir === undefined) { + delete process.env.OPENCODE_CONFIG_DIR; + } else { + process.env.OPENCODE_CONFIG_DIR = previousConfigDir; + } + }); + + it("rebinds after cached binding invalidation on a binding-required call", async () => { + const calls: Array<{ name: string; params: Record }> = []; + let bindCount = 0; + let boundCommandCount = 0; + + fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, options) => { + const body = JSON.parse(String(options?.body ?? "{}")) as { name?: string; params?: Record }; + const name = body.name ?? "unknown"; + const params = body.params ?? {}; + calls.push({ name, params }); + + if (name === "relay.bind") { + bindCount += 1; + return new Response(JSON.stringify({ + ok: true, + data: { + bindingId: `bind-${bindCount}`, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + renewAfterMs: 20_000 + } + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + + if (name === "some.command" && !params.bindingId) { + return new Response(JSON.stringify({ + ok: false, + error: "RELAY_BINDING_REQUIRED: Call relay.bind to acquire the relay binding." + }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + if (name === "some.command" && params.bindingId === "bind-1") { + boundCommandCount += 1; + if (boundCommandCount === 1) { + return new Response(JSON.stringify({ ok: true, data: { ok: true } }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response(JSON.stringify({ + ok: false, + error: "RELAY_BINDING_INVALID: Binding does not match the current owner." + }), { status: 400, headers: { "Content-Type": "application/json" } }); + } + + if (name === "some.command" && params.bindingId === "bind-2") { + return new Response(JSON.stringify({ ok: true, data: { ok: true } }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + + return new Response("Unexpected request", { status: 500 }); + }); + + const firstClient = new DaemonClient({ autoRenew: false }); + await firstClient.call("some.command"); + + const secondClient = new DaemonClient({ autoRenew: false }); + const result = await secondClient.call("some.command", {}, { requireBinding: true }); + + expect(result).toEqual({ ok: true }); + expect(calls.map((entry) => entry.name)).toEqual([ + "some.command", + "relay.bind", + "some.command", + "some.command", + "relay.bind", + "some.command" + ]); + expect(calls[5]?.params.bindingId).toBe("bind-2"); + }); + + it("times out a stalled status body read within the requested budget", async () => { + vi.useFakeTimers(); + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(createStalledJsonResponse()); + + const pendingStatus = fetchDaemonStatus(8788, "token", { timeoutMs: 25 }); + await vi.advanceTimersByTimeAsync(25); + + await expect(pendingStatus).resolves.toBeNull(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("keeps metadata and config status probes inside one shared timeout budget", async () => { + vi.useFakeTimers(); + await writeDaemonConfig(tempRoot, 45678, "config-token"); + fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async () => createStalledJsonResponse()); + + const pendingStatus = fetchDaemonStatusFromMetadata(undefined, { + timeoutMs: 25, + retryAttempts: 2, + retryDelayMs: 10 + }); + await vi.advanceTimersByTimeAsync(25); + + await expect(pendingStatus).resolves.toBeNull(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("http://127.0.0.1:45678/status"); + expect(fetchSpy.mock.calls[1]?.[0]).toBe("http://127.0.0.1:12345/status"); + }); + + it("prefers the configured daemon when metadata points at another reachable daemon", async () => { + await writeDaemonConfig(tempRoot, 45678, "config-token"); + const healthyStatus = createHealthyStatus(4567, getCurrentDaemonFingerprint()); + fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(async (url) => { + if (String(url) === "http://127.0.0.1:45678/status") { + return new Response(JSON.stringify(healthyStatus), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return new Response(JSON.stringify(createHealthyStatus(1234, "metadata-daemon")), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + }); + + const status = await fetchDaemonStatusFromMetadata(undefined, { + timeoutMs: 25, + retryAttempts: 2, + retryDelayMs: 10 + }); + + expect(status).toEqual({ + ...healthyStatus, + fingerprintCurrent: true + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0]?.[0]).toBe("http://127.0.0.1:45678/status"); + }); + + it("marks reachable stale daemon status as not current", async () => { + fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify( + createHealthyStatus(1234, "stale-fingerprint") + ), { + status: 200, + headers: { "Content-Type": "application/json" } + })); + + const status = await fetchDaemonStatus(8788, "token", { timeoutMs: 25 }); + + expect(status?.fingerprintCurrent).toBe(false); + }); + + it("swallows response-body cancel rejections during timeout cleanup", async () => { + const stalledBody = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{")); + } + }); + const response = new Response(stalledBody, { + status: 200, + headers: { "Content-Type": "application/json" } + }); + const responseBody = response.body; + if (!responseBody) { + throw new Error("Expected response body for timeout cleanup test"); + } + const cancelSpy = vi.spyOn(responseBody, "cancel").mockRejectedValue(new Error("stream locked")); + const controller = new AbortController(); + const pendingRead = readResponseJsonWithTimeout>(response, controller.signal, 25); + + controller.abort(); + + await expect(pendingRead).rejects.toThrow("Request timed out after 25ms"); + await Promise.resolve(); + expect(cancelSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/index-hooks.test.ts b/tests/index-hooks.test.ts index c6a8dbc..96b57c4 100644 --- a/tests/index-hooks.test.ts +++ b/tests/index-hooks.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_DAEMON_STATUS_FETCH_OPTIONS } from "../src/cli/daemon-status-policy"; const agentInbox = { registerScope: vi.fn(), @@ -6,9 +7,29 @@ const agentInbox = { acknowledge: vi.fn() }; +const createToolsMock = vi.fn(() => ({})); +const createCoreRuntimeAssembliesMock = vi.fn(() => ({ + providerRuntime: {}, + browserFallbackPort: null +})); +const createAutomationCoordinatorMock = vi.fn(() => ({})); +const isHubEnabledMock = vi.fn(() => false); +const fetchDaemonStatusFromMetadataMock = vi.fn(); +const fetchDaemonStatusMock = vi.fn(); +const fetchWithTimeoutMock = vi.fn(); +const startDaemonMock = vi.fn(); +const readDaemonMetadataMock = vi.fn(); +const isCurrentDaemonFingerprintMock = vi.fn(); +const createDaemonStopHeadersMock = vi.fn(); +const releaseBindingMock = vi.fn().mockResolvedValue(undefined); +const remoteRelayRefreshMock = vi.fn().mockResolvedValue(undefined); + const config = { + daemonPort: 8788, + daemonToken: "daemon-token", relayPort: 8787, relayToken: "token", + snapshot: { maxChars: 20_000 }, skills: { nudge: { enabled: false, keywords: [], maxAgeMs: 60_000 } }, continuity: { enabled: false, filePath: "CONTINUITY.md", nudge: { enabled: false, keywords: [], maxAgeMs: 60_000 } } }; @@ -33,12 +54,24 @@ vi.mock("../src/core", () => ({ }, ensureRelay: vi.fn(async () => undefined), cleanup: vi.fn(), - getExtensionPath: vi.fn(() => null) + getExtensionPath: vi.fn(() => null), + desktopRuntime: {}, + automationCoordinator: {}, + browserFallbackPort: null + })), + createCoreRuntimeAssemblies: createCoreRuntimeAssembliesMock +})); + +vi.mock("../src/config", () => ({ + requireChallengeOrchestrationConfig: vi.fn(() => ({ + mode: "off", + governed: [], + optionalComputerUseBridge: { enabled: false } })) })); vi.mock("../src/tools", () => ({ - createTools: vi.fn(() => ({})) + createTools: createToolsMock })); vi.mock("../src/extension-extractor", () => ({ @@ -46,12 +79,105 @@ vi.mock("../src/extension-extractor", () => ({ })); vi.mock("../src/utils/hub-enabled", () => ({ - isHubEnabled: vi.fn(() => false) + isHubEnabled: isHubEnabledMock +})); + +vi.mock("../src/cli/daemon-status", () => ({ + fetchDaemonStatus: fetchDaemonStatusMock, + fetchDaemonStatusFromMetadata: fetchDaemonStatusFromMetadataMock +})); + +vi.mock("../src/cli/daemon", () => ({ + createDaemonStopHeaders: createDaemonStopHeadersMock, + isCurrentDaemonFingerprint: isCurrentDaemonFingerprintMock, + readDaemonMetadata: readDaemonMetadataMock, + startDaemon: startDaemonMock +})); + +vi.mock("../src/cli/utils/http", () => ({ + fetchWithTimeout: fetchWithTimeoutMock +})); + +vi.mock("../src/cli/remote-manager", () => ({ + RemoteManager: class { + remoteKind = "manager"; + + constructor(_client: unknown) {} + } +})); + +vi.mock("../src/cli/remote-canvas-manager", () => ({ + RemoteCanvasManager: class { + constructor(_client: unknown) {} + } +})); + +vi.mock("../src/cli/remote-desktop-runtime", () => ({ + RemoteDesktopRuntime: class { + constructor(_client: unknown) {} + } +})); + +vi.mock("../src/cli/remote-relay", () => ({ + RemoteRelay: class { + refresh = remoteRelayRefreshMock; + + constructor(_client: unknown) {} + } +})); + +vi.mock("../src/browser/script-runner", () => ({ + ScriptRunner: class { + constructor(_manager: unknown) {} + } +})); + +vi.mock("../src/automation/coordinator", () => ({ + createAutomationCoordinator: createAutomationCoordinatorMock +})); + +vi.mock("../src/cli/daemon-client", () => ({ + DaemonClient: class { + releaseBinding = releaseBindingMock; + } })); describe("plugin inbox hooks", () => { beforeEach(() => { vi.resetModules(); + createToolsMock.mockClear(); + isHubEnabledMock.mockReset(); + isHubEnabledMock.mockReturnValue(false); + fetchDaemonStatusFromMetadataMock.mockReset(); + fetchDaemonStatusFromMetadataMock.mockResolvedValue(null); + fetchDaemonStatusMock.mockReset(); + fetchDaemonStatusMock.mockResolvedValue(null); + fetchWithTimeoutMock.mockReset(); + fetchWithTimeoutMock.mockResolvedValue({ ok: true, status: 200 }); + readDaemonMetadataMock.mockReset(); + readDaemonMetadataMock.mockReturnValue({ + port: 8788, + token: "daemon-token", + pid: 42, + relayPort: 8787, + startedAt: new Date().toISOString(), + fingerprint: "stale-fingerprint" + }); + isCurrentDaemonFingerprintMock.mockReset(); + isCurrentDaemonFingerprintMock.mockImplementation((fingerprint: string | null | undefined) => { + return fingerprint === "current-fingerprint"; + }); + createDaemonStopHeadersMock.mockReset(); + createDaemonStopHeadersMock.mockImplementation((token: string, reason: string) => ({ + Authorization: `Bearer ${token}`, + "x-test-stop-reason": reason + })); + startDaemonMock.mockReset(); + startDaemonMock.mockResolvedValue({ stop: vi.fn().mockResolvedValue(undefined) }); + releaseBindingMock.mockClear(); + remoteRelayRefreshMock.mockClear(); + createCoreRuntimeAssembliesMock.mockClear(); + createAutomationCoordinatorMock.mockClear(); agentInbox.registerScope.mockReset(); agentInbox.buildSystemInjection.mockReset(); agentInbox.acknowledge.mockReset(); @@ -107,4 +233,279 @@ describe("plugin inbox hooks", () => { "[opendevbrowser-agent-inbox]\n{}\n[opendevbrowser-agent-inbox]" ]); }); + + it("uses the shared daemon status fetch policy during ensureHub bootstrap", async () => { + vi.useFakeTimers(); + startDaemonMock.mockRejectedValue(new Error("daemon unavailable")); + + const toolsModule = await import("../src/tools"); + const pluginFactory = (await import("../src/index")).default; + await pluginFactory({ + directory: "/tmp/opendevbrowser", + worktree: "/tmp/opendevbrowser" + } as never); + + const deps = vi.mocked(toolsModule.createTools).mock.calls.at(-1)?.[0] as { + ensureHub?: () => Promise; + }; + + isHubEnabledMock.mockReturnValue(true); + const pending = deps.ensureHub?.(); + const expectation = expect(pending).rejects.toThrow("daemon unavailable"); + await vi.advanceTimersByTimeAsync(1000); + + await expectation; + expect(fetchDaemonStatusFromMetadataMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ relayPort: 8787 }), + { + ...DEFAULT_DAEMON_STATUS_FETCH_OPTIONS, + timeoutMs: 2_000 + } + ); + }); + + it("reuses a daemon that becomes responsive after a non-destructive bootstrap miss", async () => { + vi.useFakeTimers(); + fetchDaemonStatusFromMetadataMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + ok: true, + pid: 43, + fingerprint: "current-fingerprint", + hub: { instanceId: "hub-2" }, + relay: { running: true, port: 8787 }, + binding: null + }); + startDaemonMock.mockRejectedValue(new Error("listen EADDRINUSE: address already in use 127.0.0.1:8788")); + + const toolsModule = await import("../src/tools"); + const pluginFactory = (await import("../src/index")).default; + await pluginFactory({ + directory: "/tmp/opendevbrowser", + worktree: "/tmp/opendevbrowser" + } as never); + + const deps = vi.mocked(toolsModule.createTools).mock.calls.at(-1)?.[0] as { + ensureHub?: () => Promise; + }; + + isHubEnabledMock.mockReturnValue(true); + const pending = deps.ensureHub?.(); + const expectation = expect(pending).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(1_000); + + await expectation; + expect(fetchDaemonStatusFromMetadataMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ relayPort: 8787 }), + { + ...DEFAULT_DAEMON_STATUS_FETCH_OPTIONS, + timeoutMs: 2_000 + } + ); + expect(startDaemonMock).toHaveBeenCalledTimes(1); + expect(remoteRelayRefreshMock).toHaveBeenCalledTimes(1); + }); + + it("stops a responsive mismatched daemon during ensureHub bootstrap", async () => { + fetchDaemonStatusFromMetadataMock + .mockResolvedValueOnce({ + ok: true, + pid: 42, + fingerprint: "stale-fingerprint", + hub: { instanceId: "hub-1" }, + relay: { running: false, port: 8787 }, + binding: null + }) + .mockResolvedValueOnce({ + ok: true, + pid: 43, + fingerprint: "current-fingerprint", + hub: { instanceId: "hub-2" }, + relay: { running: true, port: 8787 }, + binding: null + }); + + const toolsModule = await import("../src/tools"); + const pluginFactory = (await import("../src/index")).default; + await pluginFactory({ + directory: "/tmp/opendevbrowser", + worktree: "/tmp/opendevbrowser" + } as never); + + const deps = vi.mocked(toolsModule.createTools).mock.calls.at(-1)?.[0] as { + ensureHub?: () => Promise; + }; + + isHubEnabledMock.mockReturnValue(true); + await expect(deps.ensureHub?.()).resolves.toBeUndefined(); + expect(fetchWithTimeoutMock).toHaveBeenCalledWith( + "http://127.0.0.1:8788/stop", + { + method: "POST", + headers: { + Authorization: "Bearer daemon-token", + "x-test-stop-reason": "plugin.ensureHub.upgrade" + } + }, + expect.any(Number) + ); + expect(startDaemonMock).toHaveBeenCalledTimes(1); + expect(remoteRelayRefreshMock).toHaveBeenCalledTimes(1); + }); + + it("rejects a refreshed fingerprint-protected mismatch during ensureHub bootstrap", async () => { + fetchDaemonStatusFromMetadataMock + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + ok: true, + pid: 42, + fingerprint: "stale-fingerprint", + hub: { instanceId: "hub-stale" }, + relay: { running: false, port: 8787 }, + binding: null + }); + startDaemonMock.mockRejectedValue(new Error("listen EADDRINUSE: address already in use 127.0.0.1:8788")); + fetchWithTimeoutMock.mockResolvedValueOnce({ ok: false, status: 409 }); + + const toolsModule = await import("../src/tools"); + const pluginFactory = (await import("../src/index")).default; + await pluginFactory({ + directory: "/tmp/opendevbrowser", + worktree: "/tmp/opendevbrowser" + } as never); + + const deps = vi.mocked(toolsModule.createTools).mock.calls.at(-1)?.[0] as { + ensureHub?: () => Promise; + }; + + isHubEnabledMock.mockReturnValue(true); + await expect(deps.ensureHub?.()).rejects.toThrow("protected by a different opendevbrowser build"); + expect(startDaemonMock).toHaveBeenCalledTimes(1); + expect(fetchWithTimeoutMock).toHaveBeenCalledWith( + "http://127.0.0.1:8788/stop", + { + method: "POST", + headers: { + Authorization: "Bearer daemon-token", + "x-test-stop-reason": "plugin.ensureHub.upgrade" + } + }, + expect.any(Number) + ); + expect(remoteRelayRefreshMock).not.toHaveBeenCalled(); + }); + + it("ignores a fingerprint-protected metadata-only daemon during ensureHub bootstrap", async () => { + readDaemonMetadataMock.mockReturnValue({ + port: 12345, + token: "foreign-token", + pid: 99, + relayPort: 8787, + startedAt: new Date().toISOString(), + fingerprint: "foreign-fingerprint" + }); + fetchDaemonStatusFromMetadataMock + .mockResolvedValueOnce({ + ok: true, + pid: 99, + fingerprint: "foreign-fingerprint", + hub: { instanceId: "hub-foreign" }, + relay: { running: false, port: 8787 }, + binding: null + }) + .mockResolvedValueOnce({ + ok: true, + pid: 43, + fingerprint: "current-fingerprint", + hub: { instanceId: "hub-current" }, + relay: { running: true, port: 8787 }, + binding: null + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ ok: false, status: 409 }); + + const toolsModule = await import("../src/tools"); + const pluginFactory = (await import("../src/index")).default; + await pluginFactory({ + directory: "/tmp/opendevbrowser", + worktree: "/tmp/opendevbrowser" + } as never); + + const deps = vi.mocked(toolsModule.createTools).mock.calls.at(-1)?.[0] as { + ensureHub?: () => Promise; + }; + + isHubEnabledMock.mockReturnValue(true); + await expect(deps.ensureHub?.()).resolves.toBeUndefined(); + expect(fetchWithTimeoutMock).toHaveBeenCalledWith( + "http://127.0.0.1:12345/stop", + { + method: "POST", + headers: { + Authorization: "Bearer foreign-token", + "x-test-stop-reason": "plugin.ensureHub.upgrade" + } + }, + expect.any(Number) + ); + expect(startDaemonMock).toHaveBeenCalledTimes(1); + expect(remoteRelayRefreshMock).toHaveBeenCalledTimes(1); + }); + + it("rejects a fingerprint-protected mismatched daemon during ensureHub bootstrap", async () => { + fetchDaemonStatusFromMetadataMock.mockResolvedValue({ + ok: true, + pid: 42, + fingerprint: "stale-fingerprint", + hub: { instanceId: "hub-1" }, + relay: { running: false, port: 8787 }, + binding: null + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ ok: false, status: 409 }); + + const toolsModule = await import("../src/tools"); + const pluginFactory = (await import("../src/index")).default; + await pluginFactory({ + directory: "/tmp/opendevbrowser", + worktree: "/tmp/opendevbrowser" + } as never); + + const deps = vi.mocked(toolsModule.createTools).mock.calls.at(-1)?.[0] as { + ensureHub?: () => Promise; + }; + + isHubEnabledMock.mockReturnValue(true); + await expect(deps.ensureHub?.()).rejects.toThrow("protected by a different opendevbrowser build"); + expect(startDaemonMock).not.toHaveBeenCalled(); + expect(remoteRelayRefreshMock).not.toHaveBeenCalled(); + }); + + it("does not bind remote managers during failed hub-enabled startup", async () => { + isHubEnabledMock.mockReturnValue(true); + fetchDaemonStatusFromMetadataMock.mockResolvedValue({ + ok: true, + pid: 42, + fingerprint: "stale-fingerprint", + hub: { instanceId: "hub-1" }, + relay: { running: false, port: 8787 }, + binding: null + }); + fetchWithTimeoutMock.mockResolvedValueOnce({ ok: false, status: 409 }); + + const toolsModule = await import("../src/tools"); + const pluginFactory = (await import("../src/index")).default; + await pluginFactory({ + directory: "/tmp/opendevbrowser", + worktree: "/tmp/opendevbrowser" + } as never); + + const deps = vi.mocked(toolsModule.createTools).mock.calls.at(-1)?.[0] as { + manager?: { remoteKind?: string }; + }; + + expect(deps.manager?.remoteKind).toBeUndefined(); + expect(remoteRelayRefreshMock).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("Hub daemon unavailable:")); + }); }); diff --git a/tests/live-regression-direct.test.ts b/tests/live-regression-direct.test.ts index 668ede2..d6052fd 100644 --- a/tests/live-regression-direct.test.ts +++ b/tests/live-regression-direct.test.ts @@ -3,8 +3,15 @@ import { CANVAS_LIVE_TIMEOUTS_MS, parseJsonFromStdout } from "../scripts/live-di import { buildChildArgs, buildScenarioCases, + buildScenarioDaemonRecoveryStep, + classifyDaemonLossStep, + classifyInitialDaemonStep, classifyScenarioPreflight, + daemonStatusDetail, + isCurrentDaemonStatus, parseCliOptions, + resolveChildStep, + resolveInitialDaemonStatus, waitForExtensionReconnect } from "../scripts/live-regression-direct.mjs"; @@ -23,9 +30,9 @@ describe("live-regression-direct", () => { "feature.canvas.managed_headless", "feature.canvas.managed_headed", "feature.canvas.extension", - "feature.canvas.cdp", "feature.annotate.relay", "feature.annotate.direct", + "feature.canvas.cdp", "feature.cli.smoke" ]); expect(cdp?.requiresOpsDisconnect).toBeUndefined(); @@ -113,6 +120,176 @@ describe("live-regression-direct", () => { }); }); + it("recovers the daemon before the run starts when the initial probe is down", async () => { + const initialStatus = { + status: 1, + json: null, + detail: "Daemon not running. Start with `opendevbrowser serve`." + }; + const recoveredStatus = { + status: 0, + json: { + data: { + relay: { + extensionHandshakeComplete: true + } + } + } + }; + + const result = await resolveInitialDaemonStatus({ + statusReader: () => initialStatus, + recoverStatus: async ({ statusReader }) => { + expect(statusReader()).toEqual(initialStatus); + return recoveredStatus; + } + }); + + expect(result).toEqual({ + initialStatus, + currentStatus: recoveredStatus, + recovered: true + }); + }); + + it("keeps daemon recovery as a release-gate failure", () => { + expect(classifyInitialDaemonStep({ + initialDaemonOk: true, + initialDaemonRecovered: true, + releaseGate: true, + detail: null + })).toEqual({ + status: "fail", + detail: "daemon_recovered_before_run" + }); + + expect(classifyInitialDaemonStep({ + initialDaemonOk: true, + initialDaemonRecovered: true, + releaseGate: false, + detail: null + })).toEqual({ + status: "pass", + detail: "daemon_recovered_before_run" + }); + }); + + it("classifies reachable stale daemons as not current", () => { + const staleStatus = { + status: 0, + json: { + data: { + fingerprintCurrent: false + } + } + }; + + expect(isCurrentDaemonStatus(staleStatus)).toBe(false); + expect(daemonStatusDetail(staleStatus)).toBe("daemon_fingerprint_mismatch"); + expect(classifyInitialDaemonStep({ + initialDaemonOk: isCurrentDaemonStatus(staleStatus), + initialDaemonRecovered: false, + releaseGate: true, + detail: daemonStatusDetail(staleStatus) + })).toEqual({ + status: "fail", + detail: "daemon_fingerprint_mismatch" + }); + }); + + it("does not retry daemon-loss child failures under release gate", () => { + const step = { + id: "feature.canvas.cdp", + status: "pass", + detail: "Daemon not running. Start with `opendevbrowser serve`.", + data: { artifactPath: "/tmp/odb-canvas-cdp.json" } + }; + + expect(classifyDaemonLossStep(step, true)).toEqual({ + id: "feature.canvas.cdp", + status: "fail", + detail: "Daemon not running. Start with `opendevbrowser serve`.", + data: { + artifactPath: "/tmp/odb-canvas-cdp.json", + releaseGateDaemonLoss: true + } + }); + expect(classifyDaemonLossStep(step, false)).toBe(step); + }); + + it("does not let stale child summaries override failed child exits", () => { + const step = resolveChildStep({ id: "feature.canvas.cdp" }, { + status: 1, + detail: "child exited with status 1", + json: { + summary: { + status: "pass", + artifactPath: "/tmp/stale.json" + } + } + }); + + expect(step).toEqual({ + id: "feature.canvas.cdp", + status: "fail", + detail: "child exited with status 1", + data: { + artifactPath: "/tmp/stale.json", + childStatus: 1, + childOk: false, + summaryStatus: "pass", + stepCount: null + } + }); + }); + + it("marks per-scenario daemon recovery as a release-gate failure", () => { + const result = buildScenarioDaemonRecoveryStep({ + id: "feature.canvas.cdp" + }, { + recovered: true, + initialStatus: { + detail: "Daemon not running. Start with `opendevbrowser serve`." + } + }); + + expect(result).toEqual({ + id: "feature.canvas.cdp", + status: "fail", + detail: "daemon_recovered_before_scenario", + data: { + recoveredBeforeScenario: true, + initialProbeDetail: "Daemon not running. Start with `opendevbrowser serve`." + } + }); + }); + + it("marks per-scenario stale daemon status as a release-gate failure", () => { + const result = buildScenarioDaemonRecoveryStep({ + id: "feature.canvas.managed_headless" + }, { + recovered: false, + currentStatus: { + status: 0, + json: { + data: { + fingerprintCurrent: false + } + } + } + }); + + expect(result).toEqual({ + id: "feature.canvas.managed_headless", + status: "fail", + detail: "daemon_fingerprint_mismatch", + data: { + currentDaemonStatus: 0, + recoveredBeforeScenario: false + } + }); + }); + it("waits through one transient daemon-status failure before classifying extension loss", async () => { const statuses = [ { diff --git a/tests/ops-browser-manager.test.ts b/tests/ops-browser-manager.test.ts index 78a1ac1..b40eedc 100644 --- a/tests/ops-browser-manager.test.ts +++ b/tests/ops-browser-manager.test.ts @@ -2345,16 +2345,17 @@ describe("OpsBrowserManager", () => { managerAny.opsLeases.set("ops-no-target", "lease-keep"); await expect(managerAny.recoverOpsSession("ops-no-target", {})).resolves.toBe(true); - expect(clientRequest).toHaveBeenCalledWith( - "session.connect", - { - sessionId: "ops-no-target", - parallelismPolicy: expect.any(Object) - }, - undefined, - 30000, - "lease-keep" - ); + expect(clientRequest).toHaveBeenCalledTimes(1); + const reconnectCall = clientRequest.mock.calls[0]; + expect(reconnectCall?.[0]).toBe("session.connect"); + expect(reconnectCall?.[1]).toMatchObject({ + sessionId: "ops-no-target", + parallelismPolicy: expect.any(Object) + }); + expect(reconnectCall?.[2]).toBeUndefined(); + expect(reconnectCall?.[3]).toBeGreaterThanOrEqual(29_000); + expect(reconnectCall?.[3]).toBeLessThanOrEqual(30_000); + expect(reconnectCall?.[4]).toBe("lease-keep"); expect(managerAny.opsLeases.get("ops-no-target")).toBe("lease-keep"); expect(managerAny.opsSessionTabs.has("ops-no-target")).toBe(false); expect(managerAny.opsSessionUrls.has("ops-no-target")).toBe(false); diff --git a/tests/postbuild-dist.test.ts b/tests/postbuild-dist.test.ts new file mode 100644 index 0000000..4c3ad22 --- /dev/null +++ b/tests/postbuild-dist.test.ts @@ -0,0 +1,47 @@ +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { getCurrentDaemonFingerprint } from "../src/cli/daemon"; +import { postbuildDist } from "../scripts/postbuild-dist.mjs"; + +describe("postbuild-dist", () => { + const tempDirs = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("writes one shared daemon fingerprint for both built entrypoints", () => { + const repoRoot = mkdtempSync(path.join(os.tmpdir(), "odb-postbuild-dist-")); + const distRoot = path.join(repoRoot, "dist"); + tempDirs.push(repoRoot); + + mkdirSync(path.join(distRoot, "cli"), { recursive: true }); + writeFileSync(path.join(distRoot, "index.js"), "export const tool = 'bundle';\n", "utf8"); + writeFileSync(path.join(distRoot, "cli", "index.js"), "export const cli = 'bundle';\n", "utf8"); + writeFileSync(path.join(distRoot, "chunk-shared.js"), "export const shared = 'one';\n", "utf8"); + writeFileSync(path.join(distRoot, "index.d.ts"), "export {};\n", "utf8"); + writeFileSync(path.join(distRoot, "index.d.ts.map"), "{}\n", "utf8"); + + const fingerprint = postbuildDist(distRoot); + const artifact = JSON.parse(readFileSync(path.join(distRoot, "daemon-fingerprint.json"), "utf8")); + + expect(fingerprint).toBe(artifact.fingerprint); + expect(typeof artifact.fingerprint).toBe("string"); + expect(artifact.fingerprint.length).toBeGreaterThan(0); + expect(getCurrentDaemonFingerprint({ + moduleUrl: pathToFileURL(path.join(distRoot, "cli", "index.js")).href + })).toBe(getCurrentDaemonFingerprint({ + moduleUrl: pathToFileURL(path.join(distRoot, "index.js")).href + })); + + writeFileSync(path.join(distRoot, "chunk-shared.js"), "export const shared = 'two';\n", "utf8"); + const updatedFingerprint = postbuildDist(distRoot); + + expect(updatedFingerprint).not.toBe(fingerprint); + }); +}); diff --git a/tests/sync-extension-version.test.ts b/tests/sync-extension-version.test.ts index 1b68310..4d9e521 100644 --- a/tests/sync-extension-version.test.ts +++ b/tests/sync-extension-version.test.ts @@ -25,16 +25,59 @@ describe("sync-extension-version", () => { writeJson(path.join(repoRoot, "package.json"), { name: "opendevbrowser", version: "0.0.17" }); writeJson(path.join(repoRoot, "extension", "manifest.json"), { version: "0.0.16" }); writeJson(path.join(repoRoot, "extension", "package.json"), { name: "opendevbrowser-extension", version: "0.0.15" }); + writeJson(path.join(repoRoot, "package-lock.json"), { + name: "opendevbrowser", + version: "0.0.14", + lockfileVersion: 3, + packages: { + "": { + name: "opendevbrowser", + version: "0.0.13" + } + } + }); const result = syncExtensionVersion(repoRoot); expect(result.version).toBe("0.0.17"); - expect(result.changedFiles).toEqual(["extension/manifest.json", "extension/package.json"]); + expect(result.changedFiles).toEqual([ + "extension/manifest.json", + "extension/package.json", + "package-lock.json" + ]); const manifest = JSON.parse(readFileSync(path.join(repoRoot, "extension", "manifest.json"), "utf8")); const extensionPackage = JSON.parse(readFileSync(path.join(repoRoot, "extension", "package.json"), "utf8")); + const packageLock = JSON.parse(readFileSync(path.join(repoRoot, "package-lock.json"), "utf8")); expect(manifest.version).toBe("0.0.17"); expect(extensionPackage.version).toBe("0.0.17"); + expect(packageLock.version).toBe("0.0.17"); + expect(packageLock.packages[""].version).toBe("0.0.17"); + }); + + it("repairs a missing root lockfile package version without rewriting aligned top-level metadata", () => { + const repoRoot = mkdtempSync(path.join(os.tmpdir(), "odb-sync-version-")); + tempDirs.push(repoRoot); + + mkdirSync(path.join(repoRoot, "extension"), { recursive: true }); + writeJson(path.join(repoRoot, "package.json"), { name: "opendevbrowser", version: "0.0.17" }); + writeJson(path.join(repoRoot, "extension", "manifest.json"), { version: "0.0.17" }); + writeJson(path.join(repoRoot, "extension", "package.json"), { name: "opendevbrowser-extension", version: "0.0.17" }); + writeJson(path.join(repoRoot, "package-lock.json"), { + name: "opendevbrowser", + version: "0.0.17", + lockfileVersion: 3, + packages: {} + }); + + const result = syncExtensionVersion(repoRoot); + + expect(result.version).toBe("0.0.17"); + expect(result.changedFiles).toEqual(["package-lock.json"]); + + const packageLock = JSON.parse(readFileSync(path.join(repoRoot, "package-lock.json"), "utf8")); + expect(packageLock.version).toBe("0.0.17"); + expect(packageLock.packages[""].version).toBe("0.0.17"); }); }); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 37cf668..6565dff 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -688,16 +688,22 @@ describe("tools", () => { expect(deps.manager.status).toHaveBeenCalled(); }); - it("continues tool execution when ensureHub fails", async () => { + it("fails closed when ensureHub fails", async () => { const deps = createDeps(); const ensureHub = vi.fn().mockRejectedValue(new Error("hub down")); const { createTools } = await import("../src/tools"); const tools = createTools({ ...deps, ensureHub } as never); const statusResult = parse(await tools.opendevbrowser_status.execute({ sessionId: "s1" } as never)); - expect(statusResult.ok).toBe(true); + expect(statusResult).toMatchObject({ + ok: false, + error: { + message: "hub down", + code: "hub_unavailable" + } + }); expect(ensureHub).toHaveBeenCalledTimes(1); - expect(deps.manager.status).toHaveBeenCalled(); + expect(deps.manager.status).not.toHaveBeenCalled(); }); it("does not wrap local skill tools with ensureHub", async () => { @@ -2193,8 +2199,9 @@ describe("tools", () => { }, binding: null }; + const fetchDaemonStatusFromMetadata = vi.fn().mockResolvedValue(daemonStatus); vi.doMock("../src/cli/daemon-status", () => ({ - fetchDaemonStatusFromMetadata: vi.fn().mockResolvedValue(daemonStatus) + fetchDaemonStatusFromMetadata })); const { createTools } = await import("../src/tools"); const tools = createTools(deps as never); @@ -2203,6 +2210,10 @@ describe("tools", () => { expect(result.ok).toBe(true); expect(result.hubEnabled).toBe(true); expect(result.daemon).toEqual(daemonStatus); + expect(fetchDaemonStatusFromMetadata).toHaveBeenCalledWith( + expect.objectContaining({ relayToken: "token", relayPort: 8787 }), + expect.objectContaining({ timeoutMs: 5_000, retryAttempts: 5, retryDelayMs: 250 }) + ); vi.doUnmock("../src/cli/daemon-status"); }); @@ -2244,8 +2255,9 @@ describe("tools", () => { }, binding: null }; + const fetchDaemonStatusFromMetadata = vi.fn().mockResolvedValue(daemonStatus); vi.doMock("../src/cli/daemon-status", () => ({ - fetchDaemonStatusFromMetadata: vi.fn().mockResolvedValue(daemonStatus) + fetchDaemonStatusFromMetadata })); vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, @@ -2258,6 +2270,10 @@ describe("tools", () => { const result = parse(await tools.opendevbrowser_status.execute({ sessionId: "s1" } as never)); expect(result.mode).toBe("managed"); expect(result.updateHint).toContain("Update available"); + expect(fetchDaemonStatusFromMetadata).toHaveBeenCalledWith( + expect.objectContaining({ relayToken: "token", relayPort: 8787, checkForUpdates: true }), + expect.objectContaining({ timeoutMs: 5_000, retryAttempts: 5, retryDelayMs: 250 }) + ); vi.doUnmock("../src/cli/daemon-status"); }); diff --git a/tests/verify-versions.test.ts b/tests/verify-versions.test.ts new file mode 100644 index 0000000..813599e --- /dev/null +++ b/tests/verify-versions.test.ts @@ -0,0 +1,112 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { verifyVersionAlignment } from "../scripts/verify-versions.mjs"; + +function writeJson(filePath, value) { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +describe("verify-versions", () => { + const tempDirs = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("accepts aligned root, extension, and lockfile versions", () => { + const repoRoot = mkdtempSync(path.join(os.tmpdir(), "odb-verify-version-")); + tempDirs.push(repoRoot); + + mkdirSync(path.join(repoRoot, "extension"), { recursive: true }); + writeJson(path.join(repoRoot, "package.json"), { name: "opendevbrowser", version: "0.0.26" }); + writeJson(path.join(repoRoot, "extension", "manifest.json"), { version: "0.0.26" }); + writeJson(path.join(repoRoot, "extension", "package.json"), { name: "opendevbrowser-extension", version: "0.0.26" }); + writeJson(path.join(repoRoot, "package-lock.json"), { + name: "opendevbrowser", + version: "0.0.26", + lockfileVersion: 3, + packages: { + "": { + name: "opendevbrowser", + version: "0.0.26" + } + } + }); + + expect(verifyVersionAlignment(repoRoot)).toBe("0.0.26"); + }); + + it("rejects lockfile drift", () => { + const repoRoot = mkdtempSync(path.join(os.tmpdir(), "odb-verify-version-")); + tempDirs.push(repoRoot); + + mkdirSync(path.join(repoRoot, "extension"), { recursive: true }); + writeJson(path.join(repoRoot, "package.json"), { name: "opendevbrowser", version: "0.0.26" }); + writeJson(path.join(repoRoot, "extension", "manifest.json"), { version: "0.0.26" }); + writeJson(path.join(repoRoot, "extension", "package.json"), { name: "opendevbrowser-extension", version: "0.0.26" }); + writeJson(path.join(repoRoot, "package-lock.json"), { + name: "opendevbrowser", + version: "0.0.25", + lockfileVersion: 3, + packages: { + "": { + name: "opendevbrowser", + version: "0.0.25" + } + } + }); + + expect(() => verifyVersionAlignment(repoRoot)).toThrow( + "Version mismatch: package.json=0.0.26 package-lock.json=0.0.25" + ); + }); + + it("rejects root lockfile package drift when the top-level lockfile version matches", () => { + const repoRoot = mkdtempSync(path.join(os.tmpdir(), "odb-verify-version-")); + tempDirs.push(repoRoot); + + mkdirSync(path.join(repoRoot, "extension"), { recursive: true }); + writeJson(path.join(repoRoot, "package.json"), { name: "opendevbrowser", version: "0.0.26" }); + writeJson(path.join(repoRoot, "extension", "manifest.json"), { version: "0.0.26" }); + writeJson(path.join(repoRoot, "extension", "package.json"), { name: "opendevbrowser-extension", version: "0.0.26" }); + writeJson(path.join(repoRoot, "package-lock.json"), { + name: "opendevbrowser", + version: "0.0.26", + lockfileVersion: 3, + packages: { + "": { + name: "opendevbrowser", + version: "0.0.25" + } + } + }); + + expect(() => verifyVersionAlignment(repoRoot)).toThrow( + "Version mismatch: package.json=0.0.26 package-lock.json#packages[\"\"]=0.0.25" + ); + }); + + it("rejects a missing root lockfile package version", () => { + const repoRoot = mkdtempSync(path.join(os.tmpdir(), "odb-verify-version-")); + tempDirs.push(repoRoot); + + mkdirSync(path.join(repoRoot, "extension"), { recursive: true }); + writeJson(path.join(repoRoot, "package.json"), { name: "opendevbrowser", version: "0.0.26" }); + writeJson(path.join(repoRoot, "extension", "manifest.json"), { version: "0.0.26" }); + writeJson(path.join(repoRoot, "extension", "package.json"), { name: "opendevbrowser-extension", version: "0.0.26" }); + writeJson(path.join(repoRoot, "package-lock.json"), { + name: "opendevbrowser", + version: "0.0.26", + lockfileVersion: 3, + packages: {} + }); + + expect(() => verifyVersionAlignment(repoRoot)).toThrow( + "package-lock.json root package version is missing." + ); + }); +});