diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5dfc6d8..fb59a044 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: # The smoke test proves local CLI behavior, not npm reachability. # Seed a fresh empty drift cache so an offline Windows runner does # not pay four sequential 20s `npm view` timeouts inside status. - node -e 'const fs=require("node:fs"),p=require("node:path"),base=process.platform==="win32"?process.env.APPDATA:process.env.XDG_CONFIG_HOME,dir=p.join(base,"agentic-kit"),last=Date.now();fs.mkdirSync(dir,{recursive:true});fs.writeFileSync(p.join(dir,"kit.json"),JSON.stringify({versionCheck:{last,seen:{},self:{last,best:null}}}))' + node -e 'const fs=require("node:fs"),p=require("node:path"),base=process.platform==="win32"?process.env.APPDATA:process.env.XDG_CONFIG_HOME,dir=p.join(base,"agentic-kit"),last=Date.now();fs.mkdirSync(dir,{recursive:true});fs.writeFileSync(p.join(dir,"kit.json"),JSON.stringify({versionCheck:{last,seen:{},self:{last,best:null,lastTags:["latest","next"]}}}))' node bin/agentic-kit.mjs --version node bin/agentic-kit.mjs --help --all > /dev/null # status must emit valid JSON and exit deterministically even on a diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index d18c1ba6..b73b87c9 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -194,7 +194,9 @@ async function main() { } catch (err) { if (!String(err?.code ?? '').startsWith('ERR_PARSE_ARGS_')) throw err; if (cmd === 'telemetry') { - console.error('Telemetry failed: invalid command options.'); + const error = 'Telemetry failed: invalid command options.'; + console.error(error); + console.log(JSON.stringify({ error, exitCode: 2 })); return 2; } // Under --json a rejected option still answers with one JSON object (the diff --git a/docs/adr/0055-aqe-embedding-lifecycle.md b/docs/adr/0055-aqe-embedding-lifecycle.md index 0f5bacd4..2475c1df 100644 --- a/docs/adr/0055-aqe-embedding-lifecycle.md +++ b/docs/adr/0055-aqe-embedding-lifecycle.md @@ -14,6 +14,7 @@ - **Updated:** 2026-09-27 — the recognizer accepts every plain npx spelling of AQE's server (optional `-y`/`--yes`; unversioned, `@latest` or an exact version), audit item 5 choice A - **Updated:** 2026-09-27 — a passing embedding check reads "embedder verified"; status, `ak x verify aqe` and setup say AQE's pattern index binding stays unverified (agentic-qe#754) and corpus compatibility stays separate - **Updated:** 2026-09-27 — the busy rule's removal condition is agentic-qe#574 fixed in a released agentic-qe that is the kit floor; agentic-qe#719 (carried by 3.14.4) is only a partial fix +- **Updated:** 2026-09-29 — the later approved N-1 criterion supersedes that floor condition: released AQE 3.14.4 passed native macOS and Linux live-owner probes without `FsyncFailed`, so ak removed the exact 3.14.3 `FsyncFailed`-as-busy exception. Any `FsyncFailed`/`0x0303` now fails, including the old sequence. Ordinary `LockHeld` SQLite fallback remains busy with owner health and RVF integrity unknown. AQE 3.14.4 is the verified baseline for this decision, not a universal minimum; native Windows AQE conformance was not run - **Updated:** 2026-09-27 — beside these projections, `ak sync` and `ak setup` pin AQE to the project root (absolute `AQE_PROJECT_ROOT`, `AQE_MEMORY_PATH`, `AQE_STORAGE_PATH`) in `.claude/settings.local.json`, the recognized `.mcp.json` entry and both AQE tables of the project `.codex/config.toml`, under receipts from the same owned-env engine; a file git tracks is not pinned, and AQE's own relative `AQE_MEMORY_PATH` is taken back even after an AQE re-init. Stray AQE stores are merged and archived by `ak x aqe-store merge`. See [ADR-0062](0062-aqe-project-store-integrity.md) (remediation Branch 5, B5-D1 to B5-D5, B5-M5) - **Updated:** 2026-09-28 — live-check evidence storage relocated from `/agentic-kit/live-checks/.json` to the shared `/agentic-kit/evidence/live-check/.json` layout; this ADR's own live-check BEHAVIOR (TTL, statuses, remembered-check display) is unchanged, only where the evidence file lives. `ak x aqe-embedding verify` (distinct from `ak x verify`'s `aqe-embedding` row) does not persist evidence either way — it is a one-shot, unpersisted synthetic-backend proof. See [ADR-0063](0063-evidence-store-and-refresh-vocabulary.md) (remediation program, branch 6a task 2) - **Updated:** 2026-09-28 — `ak x verify` is retired; its live checks (this ADR's own quick @@ -21,9 +22,9 @@ full `aqe` proof runs with `--only aqe`. A live-check evidence row now carries the source id `status-refresh-live`, labelled "ak status --refresh=live"; a row recorded before this rename under the retired `verify`/`status-live` source ids still reads back, labelled "an earlier live - check" — the label never names a retired command. Evidence ids are unchanged: `memory-routes` - still records under the `memory` id, and the full `aqe` proof still records only its embedding - request under `aqe-embedding` (remediation program, branch 6b; see + check" — the label never names a retired command. `memory-routes` records its CLI round trip + under `memory` and its routing observation under `memory-routes`; the full `aqe` proof still + records only its embedding request under `aqe-embedding` (remediation program, branch 6b; see [ADR-0063](0063-evidence-store-and-refresh-vocabulary.md)) - **Related:** [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md), [September repair](https://github.com/pacphi/agentic-kit/blob/main/docs/archive/2026-09-09-audit-aqe-integration-repair.md) diff --git a/docs/adr/0062-aqe-project-store-integrity.md b/docs/adr/0062-aqe-project-store-integrity.md index 2790861d..dcd5b6d5 100644 --- a/docs/adr/0062-aqe-project-store-integrity.md +++ b/docs/adr/0062-aqe-project-store-integrity.md @@ -6,6 +6,7 @@ re-init value taken back, clean release; holder checks that time out refuse; nested repositories are not strays; stores fingerprinted at copy time; root checked before backup; applying receipt; starter patterns the root holds keep their usage +- **Updated:** 2026-09-29 — released AQE 3.14.4 passed native macOS and Linux live-owner conformance with `LockHeld` and no `FsyncFailed`; ak retired only the old exact `FsyncFailed`-as-busy exception in AQE startup classification. This is a verified baseline for that rule, not a universal AQE minimum. The 3.14.4 minimum below applies only to store merge. Native Windows AQE conformance remains unverified - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0016](0016-capability-driven-integration-adapters.md) (project memory status and stray stores), [ADR-0055](0055-aqe-embedding-lifecycle.md) (the AQE embedding projections this diff --git a/docs/adr/0063-evidence-store-and-refresh-vocabulary.md b/docs/adr/0063-evidence-store-and-refresh-vocabulary.md index f7001912..8a0e1752 100644 --- a/docs/adr/0063-evidence-store-and-refresh-vocabulary.md +++ b/docs/adr/0063-evidence-store-and-refresh-vocabulary.md @@ -1,7 +1,7 @@ # ADR-0063 — One evidence store and the refresh vocabulary - **Status:** Accepted -- **Updated:** 2026-09-29 — Branch 6c delivered the dashboard refresh operation and retired GET-started scans +- **Updated:** 2026-09-29 — Branch 6c delivered the dashboard refresh operation and retired GET-started scans; 2026-09-29 V4 A3: self-version retry attempts and last freshness are scoped to checked channels - **Earlier update:** 2026-09-28 — Branch 6b delivered the CLI refresh vocabulary - **Date:** 2026-09-28 - **Deciders:** agentic-kit maintainers @@ -430,9 +430,9 @@ this branch" section and "Delivered in 6c" below. `learning`, `harvest`, `aqe`, `memory-routes` — slow, `--only`-only) now live in `src/lib/live-checks.mjs` and run as `--refresh=live`'s `live` stage. `memory` is the quick store/retrieve/purge round trip; `memory-routes` additionally observes whether the CLI and MCP - see each other's writes, and its result is remembered under the `memory` evidence id, not its - own. A live-check evidence row now carries the source id `status-refresh-live`, labelled "ak - status --refresh=live"; a row recorded before this branch under the retired `verify` or + see each other's writes. Its CLI result is remembered under `memory` and its routing result + under `memory-routes`. A live-check evidence row carries the source id + `status-refresh-live`, labelled "ak status --refresh=live"; a row recorded before this branch under the retired `verify` or `status-live` source ids still reads back, labelled "an earlier live check" — the label never names a retired command (`live-check-evidence.mjs`'s `SOURCE_LABEL`). - **The Codex quota presence gate.** `/api/limits` asks `codex app-server` for its @@ -539,25 +539,7 @@ shared evidence envelope. ## Known limitations (recorded, not fixed, by this branch) -1. **Resolved in Branch 6b: the failed-lookup rule is now the same in all four version-drift - functions.** This item originally recorded that `ruvector.mjs`/`ruvnet-brain.mjs`'s `drift()` - could silently drop a known update on a failed forced fetch, unlike `versions.mjs`'s - `driftReport()`/`selfDrift()`. Branch 6b fixed both (`fix(versions): a failed lookup keeps the - cached version and waits one TTL window before retrying`, and its follow-ups), so all four now - share one rule: on a total lookup failure, the cached `latest`/`best`/`installedRelease` value - is kept — never overwritten with `null` — and the TTL stamp (`last`) is restamped, so the next - unforced call waits one more TTL window before retrying (`force` bypasses this and retries - immediately). `observedAt` records the real time a value was last actually observed, not the - time of a failed retry: `ruvector.mjs`'s `drift()` keeps `observedAt: cached.observedAt ?? - cached.last` on failure (`:70`); `ruvnet-brain.mjs`'s `drift()` does the same - (`:288`, `recordedRelease()`); `versions.mjs`'s `driftReport()`'s `lookUpLatest()` restamps - `observedAt` from the prior `last` only for packages that were never individually observed - (`:82`); its `selfDrift()`'s `selfRecord()` restamps on a *total* failure, including one with no - cached candidate at all — only a partial answer (something answered live but did not win), or a - total failure whose cached candidate is unusable (a `next` candidate on a stable install), saves - nothing (`:172-176`). None of the four applies this rule under `record: false` (`ak sync - --dry-run`, ADR-0063's own `record` parameter) or a cache-only read (`cacheOnly: true`, `ak - sync --skip `): both skip the network and the write entirely, by design. +1. **Resolved in Branch 6b, refined in V4 A3: failed version lookups retain recorded evidence without claiming a new observation.** A total failed lookup of managed packages, Brain, or ruvector keeps its cached candidate and restamps `last` for one retry per configured TTL; `observedAt` remains the time that candidate was actually seen. The kit follows that rule when its cached candidate is usable. A partial answer that leaves the kit's cached candidate winning, or a stable install whose cached `next` candidate is unusable, keeps `last`, `observedAt`, and `best` unchanged and separately records `versionCheck.self.attempt` with its time and exact channel tags. Successful and total-failure kit lookups record `lastTags`, the channel scope of `last`; changing from stable to prerelease therefore probes an untried `next` channel even within the prior TTL. Legacy records without `lastTags` are reused for the single `latest` channel or where a `next` winner proves it was checked; a legacy `latest` winner cannot suppress an untried `next`. Malformed or future attempt metadata cannot suppress retries. `force` bypasses freshness. `record: false` permits a lookup without saving its result or attempt, while `cacheOnly: true` performs neither a lookup nor a write. 2. **`globalRoot()`'s `record`-persistence structural fragility** — see "The `npm-global-root` exception" above. 3. **Task 9's dashboard timeout bounds async hangs only** — see "The dashboard poll's two cost diff --git a/docs/archive/2026-09-28-plan-follow-ups-v2.md b/docs/archive/2026-09-28-plan-follow-ups-v2.md new file mode 100644 index 00000000..fc1c8a5e --- /dev/null +++ b/docs/archive/2026-09-28-plan-follow-ups-v2.md @@ -0,0 +1,55 @@ +# Follow ups v2: V4 branch plan + +## Status at archival + +**Implementation and local verification complete (2026-09-29).** All eight local +gates passed at `2915265402b758ddcd73d0dd663db9308637f3b2`: 6,159 unit tests passed +with seven skips and no failures, 514 browser assertions plus 15 UI tests passed, +and typecheck, lint, complexity, Markdown, build and offline links passed. +Measured coverage was 94.00% lines, 83.36% branches and 93.27% functions. +Independent whole-branch review found no actionable findings; its additional +focused run passed 131 tests with two Windows-only skips. + +B1 merged in PR #273, V3 dashboard changes in #276, C3 trace in #277 and C4 watch +in #278. This branch includes green `develop@989c5e56`, the reviewed exact runner +identity follow-up `f80bc55e`, temporary C1 job removal `257e6940`, A3 ADR amendment +`44dc4e9` and C6 evidence alignment `29152654`. B13 required no product fix after +the approved conditional check. B6's extra Codex hook fix line remains deferred +pending a Ruflo-supported answer to #3419. + +Native macOS/Linux AQE live-lock conformance passed on the named released +artifacts; native Windows AQE was not run. Native Windows Ruflo 3.48.0 memory +visibility was observed with its native bridge disabled. Final-head feature PR +CI, including the corrected Windows identity fixtures, and squash integration +remain pending at capture. This archive does not claim main merge, release, +installation or operational cleanup. + +The [remediation program V4](../plans/2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. + +| Row | Source or artifact mapping | Focused proof and prerequisite | +| --- | --- | --- | +| A1 | `bin/agentic-kit.mjs`; `src/commands/usage.mjs`, `models.mjs`, `audit.mjs`, `heal.mjs`, `telemetry.mjs`, `x/host.mjs` | `tests/kit/cli-json-honesty.test.mjs`, `usage-cli.test.mjs`, `models-command.test.mjs`, `telemetry-cli.test.mjs`, `status-command.test.mjs`; include unknown models verb and status positional | +| A2 | `src/commands/x/host.mjs`; `bin/agentic-kit.mjs` | `tests/kit/host-dry-run.test.mjs`, `host-cli-migration.test.mjs`; pick refusal, off, reset-routes under `--dry-run --json` | +| A3 | `src/lib/versions.mjs`; `docs/adr/0063-evidence-store-and-refresh-vocabulary.md` | Accepted in `175677a6`; `versionCheck.self.attempt` and `lastTags` scope offline retries. ADR-0063 item 1 is amended; local gates passed at `29152654`, with final PR CI pending | +| A4 | `src/commands/status.mjs`; `src/lib/refresh.mjs` | `tests/kit/refresh.test.mjs`, `status-version-drift-refresh.test.mjs`; injected `refreshStages` plus `service` builds no collector | +| B1 | `src/lib/paths.mjs`; `src/lib/footprint/index.mjs`, `storage.mjs`, `consumers.mjs`, `storage-reclaim-detectors.mjs`, `install.mjs`; `src/lib/host-readiness-local.mjs`, `live/process-sessions.mjs`, `hook-audit/providers/opencode.mjs`, `usage-opencode.mjs`; `src/commands/uninstall.mjs` | `tests/kit/xdg-relative.test.mjs` and specified regressions; exact-head CI gate passed before edit; preserve nullable OpenCode fallback | +| B2 | `src/commands/x/daemon-gc.mjs`, `src/commands/x/host.mjs`, `src/commands/setup.mjs` | New `tests/kit/daemon-gc-rerecord.test.mjs`, `setup-host-rerecord.test.mjs`, `host-pick-rerecord.test.mjs`; Branch 9 Task 8 plus deferred host pick; compare `sync-host-repair.test.mjs` | +| B3 | `src/lib/ruflo-memory.mjs`, `paths.mjs` | `tests/kit/ruflo-memory-location.test.mjs`, `project-memory-status.test.mjs`; compose both unsuitable reasons and make `inside()` exclude equality | +| B4 | `src/commands/status/sections/project-memory.mjs`; `src/lib/live-check-evidence.mjs`, `live-checks.mjs` | **Accepted:** distinct `memory-routes` evidence binds installed CLI version and platform; generic `memory` cannot lower the row. Focused evidence, runner, status, and routing tests cover pass, upgrade, failure, timeout, and read-only render. | +| B5 | `src/lib/project-memory.mjs`; `src/commands/status/sections/user-memory.mjs`, `codex-mcp.mjs`; #757 registry entry | **Accepted:** bounded ordinary dot-folder discovery, read-only AQE home data row, and an AQE-owned init hint. `tests/kit/project-memory.test.mjs`, `project-memory-status.test.mjs`, `ruflo-memory-location.test.mjs`, `status-command.test.mjs` cover the three units. No real store was merged or moved. | +| B6 | `src/commands/status/sections/ruflo-components.mjs` | **Accepted:** applied-but-unverified keeps its state and meaning in the message and gives one restart/recheck instruction in its manual fix. Rendered-row and neighboring-state tests cover the contract. The Codex-hooks fix line remains conditional on a Ruflo-supported answer to #3419 and a pre-PR recheck. | +| B7 | `src/lib/ruflo-daemon-config.mjs`; `src/commands/sync.mjs`, `sync/plan-versions.mjs` | `tests/kit/sync-daemon-repair.test.mjs`, `sync-dry-run-preview.test.mjs`, `sync-skip-versions.test.mjs`; F6 hidden YAML keys and F7 versions-only preview parity | +| B8 | `src/lib/maintenance/discovery/orchestrator.mjs`, `history.mjs` | `tests/kit/maintenance-discovery-orchestrator.test.mjs`, `maintenance-recovery.test.mjs`; restart after pause shows paused history | +| B9 | `src/lib/exec.mjs`, `execution/process-tree.mjs` | `tests/kit/process-tree.test.mjs`; abort kills descendants; Windows CI required | +| B10 | `src/lib/maintenance/discovery/partitions.mjs`; inventory `src/lib/live/jsonl-tailer.mjs`, `live/transcript-streams.mjs`, `telemetry/store.mjs`, `maintenance/management/service-store.mjs` for additional persisted IDs | `tests/kit/file-identity-bigint.test.mjs`; distinguish IDs above `2^53`; enumerate the exact sites before edit | +| B11 | `src/lib/live-checks.mjs` | `tests/kit/live-checks.test.mjs`; skipped deja-vu check says skipped and check-created temp folders are cleaned | +| B12 | `src/commands/setup.mjs`; `src/lib/memory-probe-cleanup.mjs` unchanged | **Fixed:** `tests/kit/setup-memory-probe.test.mjs`; Ruflo 3.48.0 seeded reproduction created an unused native side file, and a disposable candidate run confirmed a private mirror leaves no canonical side file or probe row | +| B13 | `src/commands/sync.mjs`; `src/lib/aqe-project-pin.mjs` | Conditional check found the AQE pin converged across all four targets; no B13 product fix was made | +| C1 | `.github/workflows/ci.yml`; `src/lib/aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | **Accepted; temporary CI job removed:** native macOS and Linux live-owner probes on released AQE 3.14.4 omitted `FsyncFailed`; the exact exception is retired. Ordinary `LockHeld` remains busy, while any `FsyncFailed` fails. The temporary CI job was removed in `257e6940` after its evidence was reviewed. #240 closure waits for the final main PR. No native Windows AQE conformance is claimed. | +| C2 | `docs/host-support.md`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/ruflo-support-window.test.mjs` plus link check; verify AQE 3.14.4 #528/#532/#535 and Ruflo #2356/#420 first | +| C3 | `.github/workflows/nightly.yml`; `scripts/trace-ort.mjs` | Merged #277; native macOS trace run 36567908852. Exact approved comment posted and body verified at [Ruflo #2885](https://github.com/ruvnet/ruflo/issues/2885#issuecomment-5891510508). The post-heal trace is noncausal; the learning step remains nonblocking even though its outer command exited 1. | +| C4 | `scripts/upstream-watch/classify.mjs`, `fetch.mjs`, `ledger.mjs`, `dispatch.mjs`, `render.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | Merged #278 as `989c5e56`; develop CI 36578593054 passed all 13 jobs. M10 remained declined. | +| C5 | `src/lib/aqe-guidance.mjs`; `src/commands/setup.mjs`; ignored `.superpowers/sdd/2026-09-28-follow-ups-v2/c5-issue-draft.md` | Approved exact AQE repeated-init issue posted as [#778](https://github.com/proffesor-for-testing/agentic-qe/issues/778); the 3.14.4 disposable repro does not establish 3.14.5 behavior | +| C6 | `docs/host-support.md`; `src/lib/ruflo-support-window.mjs`, `aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | 2026-09-29 13:42 UTC registry: Ruflo 3.48.0, AQE 3.14.5, Codex 0.159.0. Narrow disposable Ruflo one-file scan and integrity-verified native Codex read-only App Server initialize passed; AQE 3.14.4/3.14.5 live-lock proof passed on macOS/Linux. No provider turn or native Windows AQE proof. Local V4 gates passed at `29152654`; final PR CI pending | + +B1 used disposable homes, guarded focused tests, and the ignored B1 report at `.superpowers/sdd/2026-09-28-follow-ups-v2/b1-report.md`. No shared manifests, lockfiles, ADR index, or decision log change belongs to this plan update. The controller owns integration and the whole-branch gate. diff --git a/docs/archive/README.md b/docs/archive/README.md index 559d1038..6e65c9bb 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -68,6 +68,7 @@ reconfirmed by this metadata audit. The per-file inventory and limitations are r | File | Original location | What it was | Why it's historical | |---|---|---|---| +| [2026-09-28-plan-follow-ups-v2.md](2026-09-28-plan-follow-ups-v2.md) | `docs/plans/2026-09-28-follow-ups-v2.md` | V4 product, CLI, memory, process-lifecycle and upstream integration follow-ups. | All eight local gates and independent whole-branch review passed at `29152654`; final-head PR CI and squash integration were pending at archival. Conditional Ruflo #3419 guidance remains deferred; native Windows AQE was not tested. | | [2026-09-29-plan-upstream-watch-followups.md](2026-09-29-plan-upstream-watch-followups.md) | `docs/plans/2026-09-29-upstream-watch-followups.md` | V4 C4 execution plan for bounded PR polling, deterministic retry failures and twelve deferred watcher minors. | Implementation and independent review complete; all eight local gates passed at `b75c1e3f`. Final PR CI and merge were pending at archival. Current contract: [Upstream watch](../upstream-watch.md). | | [2026-06-upstream-findings-f1-f6.md](2026-06-upstream-findings-f1-f6.md) | `docs/upstream/ruflo-self-improvement-findings.md` | The F1–F6 findings series: proofs/refutations of ruflo's self-improvement claims (Q-learning persistence, state-encoder collapse, SONA learn→inference wiring, native-training misreporting), with filed upstream issues. | Every finding is now fixed upstream: F2 in 3.10.6 ([#2222](https://github.com/ruvnet/ruflo/issues/2222)), F2b in 3.10.7, F3 in 3.10.11 ([#2239](https://github.com/ruvnet/ruflo/issues/2239)), F4 in `@ruvector/ruvllm` 2.5.6 ([RuVector#519](https://github.com/ruvnet/RuVector/issues/519)), F6 in 3.18.1/3.19.0 + ruvllm 2.5.7 ([#2549](https://github.com/ruvnet/ruflo/issues/2549), closed 2026-07-03). | | [2026-06-token-consumption-incident.md](2026-06-token-consumption-incident.md) | `docs/usage/token-consumption-findings-and-mitigation-2026-06.md` | Root-cause report for the June 2026 token-burn incident: six immortal auto-started daemons consumed ~8.1B tokens over 7 days via headless worker sessions. Produced the opt-in daemon policy, TTL reaper, ⚙ statusline alarm, and `ruflo-token-audit`. | The root cause was fixed upstream in ruflo 3.27/3.28 ([#2661](https://github.com/ruvnet/ruflo/issues/2661)): AI workers are opt-in, launches are governed by a machine-wide budget with telemetry, one supervisor daemon per repo, native daemon TTL. The kit's daemon policy flipped back to default-on (local-only workers) on that baseline; the reapers and token-audit remain as an independent check. | diff --git a/docs/host-support.md b/docs/host-support.md index 6bbfb259..fc445551 100644 --- a/docs/host-support.md +++ b/docs/host-support.md @@ -26,6 +26,24 @@ Claude Code `2.1.222`, Codex CLI `0.146.0`, and OpenCode `1.18.x`. Host and upstream behavior changes quickly; open issues below are a risk snapshot, not a promise that an issue remains open forever. +**2026-09-29 support-window addendum.** Registry metadata at 13:42 UTC listed +Ruflo 3.48.0, Agentic QE 3.14.5, and Codex CLI 0.159.0. In a network-denied, +disposable scan, the installed Ruflo 3.48.0 `security secrets --action scan +--path ` reported one synthetic file scanned and exited 0 without +changing the target. The npm-integrity-verified native Codex 0.159.0 binary +accepted `-s read-only -a never app-server`; an `initialize` request answered +successfully without a provider turn. These are narrow command checks, not +end-to-end host conformance. + +Released AQE 3.14.4 passed disposable live-owner lock checks on macOS and Linux: +the status command and shipped adapter reported `LockHeld` without +`FsyncFailed`, while the holder and storage bytes remained intact. The same +check passed on AQE 3.14.5 on macOS and Linux. Native Windows AQE was not run. +Ruflo 3.48.0 showed CLI-to-MCP and MCP-to-CLI memory visibility on native +Windows with one `memory.db`; the reported backend was sql.js + HNSW with its +native bridge disabled. The earlier Linux result was asymmetric, so these +observations do not establish one cross-platform native-backend guarantee. + The stock OpenCode gateway acceptance test currently covers the stable compatibility window **`>=1.18.18 <1.19.0`**. This is a tested release-line window, not a claim that every future OpenCode release is compatible and not a target for `ak sync` to @@ -118,16 +136,21 @@ Official extension references: [Claude hooks](https://code.claude.com/docs/en/ho | Upgrade convergence | `ak sync` heals managed assets | `ak sync` heals Ruflo/AQE access and retires owned legacy MCP | `ak sync` regenerates the embedded catalogue and repairs exact-receipted plugins/config | | Teardown | Managed blocks and registrations | Receipt-based managed teardown | Value- and hash-receipt teardown; user-owned values survive | -Ruflo MCP access and Ruflo-backed inference are different contracts. In -particular, Ruflo's [`agent_execute` provider-key behavior](https://github.com/ruvnet/ruflo/issues/2356) -can still require a separate provider credential even when invoked from Codex. +Ruflo MCP access and Ruflo-backed inference are different contracts. +In Ruflo 3.48.0's shipped `agent_execute` path, execution uses a separately +configured inference provider; its no-provider branch still returns an error +instead of delegating to the MCP host +([ruflo #2356](https://github.com/ruvnet/ruflo/issues/2356)). This is a source +check, not a credentialed runtime probe from Codex. `ak run` avoids that conflation by executing the selected host directly and using Ruflo for tools, memory, routing context, and orchestration assets. The dated upstream risk inventory includes: -- force initialization can overwrite unrelated `.mcp.json` content - ([ruflo #420](https://github.com/ruvnet/ruflo/issues/420)); +- force initialization still writes a generated `.mcp.json` over the existing + file in Ruflo 3.48.0's shipped source, without merging unrelated servers + ([ruflo #420](https://github.com/ruvnet/ruflo/issues/420)); this was not + exercised on a real project; - generated Claude and Codex instructions can diverge ([#2638](https://github.com/ruvnet/ruflo/issues/2638)); - init and plugin installation can duplicate assets or hooks @@ -139,6 +162,12 @@ The dated upstream risk inventory includes: - hierarchical AgentDB writes can report success without durable persistence ([#2887](https://github.com/ruvnet/ruflo/issues/2887)). +The additional Codex hook-environment fix line remains conditional. At the +2026-09-29 check, [Ruflo #3419](https://github.com/ruvnet/ruflo/issues/3419) +was open with only agentic-kit's Codex source-analysis comment, not a +maintainer-supported answer or a live hook observation. Version tags alone do +not close that evidence gap. + ## Agentic QE support | AQE capability | Claude Code | Codex | OpenCode | @@ -171,12 +200,18 @@ Current AQE includes a subscription-backed `codex` provider. Agentic-kit accepts `ak host pick --aqe-provider codex`, admits Codex fallback rungs, enables Codex providers referenced by `agentOverrides`, and projects Codex activity routes. -The dated AQE risk inventory includes its -[MCP entrypoint double-spawn](https://github.com/proffesor-for-testing/agentic-qe/issues/528), -[multi-platform initialization behavior](https://github.com/proffesor-for-testing/agentic-qe/issues/532), -[MCP tool correctness gaps](https://github.com/proffesor-for-testing/agentic-qe/issues/535), +AQE 3.14.4 adopted fixes for the +[MCP entrypoint double-spawn](https://github.com/proffesor-for-testing/agentic-qe/issues/528) +and [exclusive platform initialization](https://github.com/proffesor-for-testing/agentic-qe/issues/532) +(`aqe init --no-claude`). Both upstream issues remain open; versions below +3.14.4 retain those gaps. The remaining dated AQE risk inventory includes +[GOAP `maxSteps` and world-state, test-generation quality, and coherence recommendation-text gaps](https://github.com/proffesor-for-testing/agentic-qe/issues/535) +(the 3.14.4 recheck did not exercise `goap_execute`), [RVF recovery loop](https://github.com/proffesor-for-testing/agentic-qe/issues/574), and [local-embedding audit findings](https://github.com/proffesor-for-testing/agentic-qe/issues/615). +The newer 3.14.5 registry version does not establish that the repeated-init +settings rewrite reported in [AQE #778](https://github.com/proffesor-for-testing/agentic-qe/issues/778) +has been fixed; the disposable reproduction used 3.14.4. The Codex QE-Court investigation in [agentic-kit #108](https://github.com/pacphi/agentic-kit/issues/108) is a diff --git a/docs/maintenance.md b/docs/maintenance.md index 441b072c..f77d26d7 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -772,13 +772,14 @@ See the [dated top-50 coverage list](https://github.com/pacphi/agentic-kit/blob/ Activity presents scan history as a table grouped by the browser’s local calendar date, newest first. Each date has a chevron toggle to expand or collapse its source rows; groups -start collapsed and retain their state while the dashboard stays open. Source rows show completion time and timezone, source, status, and entry count. +start collapsed and retain their state while the dashboard stays open. Source rows show a completion time when available, plus source, status, and entry count. Discovery focuses on source configuration and current coverage; historical scans appear only in Activity. Groups represent dates, not inferred shared scan runs. Missing dates remain explicitly unknown. Version measurements, update checks, and snooze deadlines also use local date/time formatting. -Scan history retains the latest 10 completed records per source and environment, within the -90-day retention limit. Each new record replaces the oldest retained record for that source. +Scan history retains the latest 10 records per source and environment, within the +90-day retention limit. A pause records a continuation boundary with a recorded time, +not a scan completion time. Each new record replaces the oldest retained record for that source. Activity places recovery, work in progress, receipts, dispositions, and recipe changes in a responsive card grid above the full-width scan history. Narrow screens use a single column. diff --git a/docs/plans/2026-09-28-follow-ups-v2.md b/docs/plans/2026-09-28-follow-ups-v2.md deleted file mode 100644 index 7c1bc897..00000000 --- a/docs/plans/2026-09-28-follow-ups-v2.md +++ /dev/null @@ -1,35 +0,0 @@ -# Follow ups v2: V4 branch plan - -## Status - -**Active.** Branch `fix/follow-ups-v2`; exact base `e2f9dcae0554ff63921df618a819fd5e6afe80d2` (develop bootstrap #272). B1 is complete in `fb54f02b`; other rows remain unimplemented. The controller reviews and assigns later rows. One test-first unit commit per row. - -The [remediation program V4](2026-09-28-remediation-program-v2.md#v4-fixfollow-ups-v2-every-small-product-cli-and-upstream-item) defines scope. The [archived Branch 9 plan](../archive/2026-09-28-superpowers-plan-branch-9-follow-ups.md) supplies task details. Paths below name current source seams and focused test targets. After an explicit directory prefix, subsequent bare filenames in the same cell use that directory. A new test named below is a proposed file. Later implementers must verify dependencies before editing. - -| Row | Source or artifact mapping | Focused proof and prerequisite | -| --- | --- | --- | -| A1 | `bin/agentic-kit.mjs`; `src/commands/usage.mjs`, `models.mjs`, `audit.mjs`, `heal.mjs`, `telemetry.mjs`, `x/host.mjs` | `tests/kit/cli-json-honesty.test.mjs`, `usage-cli.test.mjs`, `models-command.test.mjs`, `telemetry-cli.test.mjs`, `status-command.test.mjs`; include unknown models verb and status positional | -| A2 | `src/commands/x/host.mjs`; `bin/agentic-kit.mjs` | `tests/kit/host-dry-run.test.mjs`, `host-cli-migration.test.mjs`; pick refusal, off, reset-routes under `--dry-run --json` | -| A3 | `src/lib/versions.mjs`; `docs/adr/0063-evidence-store-and-refresh-vocabulary.md` | `tests/kit/version-lookup-record.test.mjs`, `drift-freshness.test.mjs`; offline tried-at TTL and ADR wording | -| A4 | `src/commands/status.mjs`; `src/lib/refresh.mjs` | `tests/kit/refresh.test.mjs`, `status-version-drift-refresh.test.mjs`; injected `refreshStages` plus `service` builds no collector | -| B1 | `src/lib/paths.mjs`; `src/lib/footprint/index.mjs`, `storage.mjs`, `consumers.mjs`, `storage-reclaim-detectors.mjs`, `install.mjs`; `src/lib/host-readiness-local.mjs`, `live/process-sessions.mjs`, `hook-audit/providers/opencode.mjs`, `usage-opencode.mjs`; `src/commands/uninstall.mjs` | `tests/kit/xdg-relative.test.mjs` and specified regressions; exact-head CI gate passed before edit; preserve nullable OpenCode fallback | -| B2 | `src/commands/x/daemon-gc.mjs`, `src/commands/x/host.mjs`, `src/commands/setup.mjs` | New `tests/kit/daemon-gc-rerecord.test.mjs`, `setup-host-rerecord.test.mjs`, `host-pick-rerecord.test.mjs`; Branch 9 Task 8 plus deferred host pick; compare `sync-host-repair.test.mjs` | -| B3 | `src/lib/ruflo-memory.mjs`, `paths.mjs` | `tests/kit/ruflo-memory-location.test.mjs`, `project-memory-status.test.mjs`; compose both unsuitable reasons and make `inside()` exclude equality | -| B4 | `src/commands/status/sections/project-memory.mjs`; `src/lib/ruflo-memory-contract.mjs`, `live-check-evidence.mjs` | D-4 **B approved**: `tests/kit/project-memory-status.test.mjs`, `live-check-evidence.test.mjs`, `verify-memory-routes.test.mjs`; info only after successful installed-version `memory-routes` evidence, warn on upgrade or failure | -| B5 | `src/lib/project-memory.mjs`, `aqe-readiness.mjs`; `src/commands/status/sections/project-memory.mjs`, `aqe.mjs` | `tests/kit/project-memory.test.mjs`, `aqe-readiness.test.mjs`, `project-memory-status.test.mjs`; dot-folder scan, real `~/.agentic-qe`, and agentic-qe#757 hint | -| B6 | `src/commands/status/sections/ruflo-components.mjs`; `src/lib/ruflo-components/states.mjs` | `tests/kit/ruflo-components-status.test.mjs`; applied-but-unverified row; hooks fix line requires #3419 answer first | -| B7 | `src/lib/ruflo-daemon-config.mjs`; `src/commands/sync.mjs`, `sync/plan-versions.mjs` | `tests/kit/sync-daemon-repair.test.mjs`, `sync-dry-run-preview.test.mjs`, `sync-skip-versions.test.mjs`; F6 hidden YAML keys and F7 versions-only preview parity | -| B8 | `src/lib/maintenance/discovery/orchestrator.mjs`, `history.mjs` | `tests/kit/maintenance-discovery-orchestrator.test.mjs`, `maintenance-recovery.test.mjs`; restart after pause shows paused history | -| B9 | `src/lib/exec.mjs`, `execution/process-tree.mjs` | `tests/kit/process-tree.test.mjs`; abort kills descendants; Windows CI required | -| B10 | `src/lib/maintenance/discovery/partitions.mjs`; inventory `src/lib/live/jsonl-tailer.mjs`, `live/transcript-streams.mjs`, `telemetry/store.mjs`, `maintenance/management/service-store.mjs` for additional persisted IDs | `tests/kit/file-identity-bigint.test.mjs`; distinguish IDs above `2^53`; enumerate the exact sites before edit | -| B11 | `src/lib/live-checks.mjs` | `tests/kit/live-checks.test.mjs`; skipped deja-vu check says skipped and check-created temp folders are cleaned | -| B12 | `src/commands/setup.mjs`; `src/lib/memory-probe-cleanup.mjs` | `tests/kit/setup-memory-probe.test.mjs`; disposable real Ruflo reproduction first, fix only if unused `agentdb-memory.db` appears | -| B13 | `src/commands/sync.mjs`; `src/lib/aqe-project-pin.mjs` | `tests/kit/sync-command.test.mjs`, `aqe-project-pin.test.mjs`; only if program §2 step 2 shows sync omitted the AQE pin | -| C1 | `.github/workflows/ci.yml`; `src/lib/aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/live/ruflo-memory-routing.test.mjs`, `tests/kit/aqe-readiness.test.mjs`; disposable macOS and temporary Linux/Windows CI busy-rule evidence; remove temporary job before merge; #240 action follows result | -| C2 | `docs/host-support.md`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/ruflo-support-window.test.mjs` plus link check; verify AQE 3.14.4 #528/#532/#535 and Ruflo #2356/#420 first | -| C3 | `.github/workflows/nightly.yml`; vidaunited's `trace-ort.mjs` hook (obtain and verify its exact script path before adding) | `tests/kit/upstream-watch-workflow.test.mjs` plus macOS artifact receipt; exact upstream #2885 post text requires user approval | -| C4 | `scripts/upstream-watch/classify.mjs`, `fetch.mjs`, `ledger.mjs`, `dispatch.mjs`, `render.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/upstream-watch-script.test.mjs`, `upstream-watch-record.test.mjs`, `upstream-watch-dispatch.test.mjs`, `upstream-watch-registry.test.mjs`; use ignored `reports/n5-253-deferred-minors.md` §2 for M7/M8/minors 1–12; M10 declined | -| C5 | `src/lib/aqe-guidance.mjs`; `src/commands/setup.mjs`; ignored `.superpowers/sdd/2026-09-28-follow-ups-v2/c5-issue-draft.md` | D-6 **A approved**: controller's isolated AQE init reproduction is evidence handoff; draft issue with command/version/expected/actual, then obtain approval of exact posting text. B0-16 draft only if D-16 B | -| C6 | `docs/host-support.md`; `src/lib/ruflo-support-window.mjs`, `aqe-readiness.mjs`; `src/lib/hook-audit/agentic-dependency-constraints.json` | `tests/kit/ruflo-support-window.test.mjs`, `aqe-readiness.test.mjs`; pre-PR live checks against newest supported Ruflo `security secrets --path`, Codex read-only app-server flags, and AQE 3.14.x | - -B1 used disposable homes, guarded focused tests, and the ignored B1 report at `.superpowers/sdd/2026-09-28-follow-ups-v2/b1-report.md`. No shared manifests, lockfiles, ADR index, or decision log change belongs to this plan update. The controller owns integration and the whole-branch gate. diff --git a/docs/plans/2026-09-28-remediation-program-v2.md b/docs/plans/2026-09-28-remediation-program-v2.md index 3da32f49..4a6b526d 100644 --- a/docs/plans/2026-09-28-remediation-program-v2.md +++ b/docs/plans/2026-09-28-remediation-program-v2.md @@ -10,6 +10,11 @@ Windows timing evidence. #251 is done, and the alpha.60 release commit is on `ma Publication and global installation were not verified in the execution-plan baseline. The historical decision batch and schedule below remain as scope/evidence references; the approved execution section governs wherever their authority or timing differs. +At the 2026-09-29 checkpoint, V3 #276, V5 #274, V4 B1 #273, V4 C3 #277, and +V4 C4 #278 had merged to `develop@989c5e56`. Remaining V4 implementation +units were accepted on an isolated branch; its final gates, whole-branch +review, PR CI and integration remained open. V6 and V7 still have work. The +final `develop` → `main` PR has not been opened. ### Approved execution and precedence @@ -308,7 +313,7 @@ One unit commit per line, test-first. The source text is the Branch 9 plan where 1. A relative `XDG_*` value is ignored (Branch 9 Task 6; B6a-6, B2-6). 2. Re-record seams for `x/daemon-gc.mjs` and `setup.mjs` (Task 8), plus the one for `x/host.mjs` `pick` (deferred 13a) (B6a-3). -3. `rufloMemoryLocation` names both reasons when the root and the folder are both unsuitable (deferred item 11). `inside()` stops treating equality as "inside", so a `TMPDIR` set to a tool folder is read correctly (B9-12, B0-15). +3. `rufloMemoryLocation` names both reasons when the root and the folder are both unsuitable (deferred item 11). `inside()` stops treating equality as "inside", so a `TMPDIR` set to a tool folder is read correctly (B9-12, B0-15). Implemented in V4 B3; isolated-branch review pending (`.superpowers/sdd/2026-09-28-follow-ups-v2/b3-report.md`). 4. N4, as D-4 decides (B9-3). 5. The stray scan walks dot folders below the root (B5-9, D-7). The real `~/.agentic-qe` home store is listed (B5-11). The `codex-mcp` hint stops suggesting AQE's broken Codex platform setup (agentic-qe#757) (B5-10). 6. `ruflo-components`: the applied-but-unverified row stops repeating the restart instruction (B0-21). The rows reading "partial — missing: Codex hooks" get a fix line once ruvnet/ruflo#3419 answers; if it is still unanswered at the pre-PR check, this part waits (LQ-2). diff --git a/docs/plans/2026-09-28-remediation-v2-develop-execution.md b/docs/plans/2026-09-28-remediation-v2-develop-execution.md index 567dec18..bed7dd78 100644 --- a/docs/plans/2026-09-28-remediation-v2-develop-execution.md +++ b/docs/plans/2026-09-28-remediation-v2-develop-execution.md @@ -12,6 +12,16 @@ unit commits, feature PRs into `develop`, and conditional squash integration; th `develop` → `main` PR remains open for human review. Releases, installation, real-data operations and cleanup remain separately gated. Baseline inspected: `main@94890a00`. +**2026-09-29 execution update:** Bootstrap, V3 #276, V5 #274, V4 B1 #273, +V4 C3 #277, and V4 C4 #278 have merged to `develop@989c5e56`; their relevant +CI receipts passed. Remaining V4 implementation units are accepted on the +isolated branch, and its temporary C1 CI job has been removed. V4's ADR/C6 +alignment is underway. The final V4 full gates, whole-branch review, feature +PR CI including Windows, and integration are still pending. V6 and V7 remain +separate work. The final `develop` → `main` PR and operational gates have not +occurred. Historical baseline rows below describe planning-time state, not +current completion. + **Goal:** Complete all remaining v2 remediation through feature PRs into `develop`, then open one aggregate `develop` → `main` PR for human review. @@ -68,9 +78,10 @@ Appendix A/B row and its referenced v1 plans, rulings and evidence. The original program's "not yet started" status and main-only flow are stale. Reconcile them in the bootstrap PR, retaining historical evidence rather than replaying completed work. -ADR-0063 is **Accepted**, updated 2026-09-28, with CLI delivery recorded; V3 completes its -dashboard changes and V4 its offline retry limitation. ADR-0048 is **Accepted**, updated -2026-09-28, with human evaluation gates outstanding; V3 records their approved v5 deferral. +ADR-0063 is **Accepted**, updated 2026-09-29, with CLI and V3 dashboard delivery +recorded; V4 A3 refines its offline retry limitation on the feature branch. +ADR-0048 is **Accepted**, updated 2026-09-28, with human evaluation gates +outstanding; V3 records their approved v5 deferral. ADR-0060 is **Proposed**, updated 2026-09-27, with discovery partly implemented; V6 implements its approved remaining scope and records acceptance and the actual delivered subset. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 576d9f40..58d06d53 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -151,15 +151,18 @@ up to six minutes each: ak status --refresh=live --only learning # trains a cycle in an isolated dir; asserts patterns persist to disk ak status --refresh=live --only aqe # agentic-qe genuinely on ruvector (no FsyncFailed) ak status --refresh=live --only harvest # Ruflo's learning-write path (post-task + distill) in an isolated store -ak status --refresh=live --only memory-routes # CLI/MCP routing observation, remembered as the memory check +ak status --refresh=live --only memory-routes # CLI round trip remembered as memory; routing observation remembered separately ak status --refresh=live --only learning,harvest,aqe,memory-routes,security,deja-vu,providers,mcp,aqe-embedding ``` If `ak status --refresh=live --only aqe` warns that RVF is held by another live process, another AQE process (usually the AQE MCP server in an open Claude Code session) owns the store. -That is contention, not a storage failure, even though agentic-qe 3.14.3 also prints -`FsyncFailed` in this case ([#240](https://github.com/pacphi/agentic-kit/issues/240)). -A `FsyncFailed` without the live-owner lines still fails verification. +Released agentic-qe 3.14.4 passed native macOS and Linux live-owner probes with `LockHeld` +and no `FsyncFailed` ([#240](https://github.com/pacphi/agentic-kit/issues/240)). +The old 3.14.3 sequence also printed `FsyncFailed`; ak now treats any `FsyncFailed` or +`0x0303` as an RVF failure, even alongside live-owner lines. An ordinary live lock stays +busy with SQLite fallback observed; owner health and RVF integrity remain unverified. +Native Windows AQE live-lock conformance has not been run. ## Known upstream gaps (not fixable by sync) diff --git a/scripts/run-roots.mjs b/scripts/run-roots.mjs index a077cec5..ff4621c2 100644 --- a/scripts/run-roots.mjs +++ b/scripts/run-roots.mjs @@ -46,20 +46,20 @@ export function readOwner(root) { let record = null; try { const file = path.join(root, OWNER_FILE); - const before = fs.lstatSync(file); - if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 - || before.size > MAX_OWNER_BYTES || (currentUid() !== null && before.uid !== currentUid())) { + const before = fs.lstatSync(file, { bigint: true }); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n + || before.size > BigInt(MAX_OWNER_BYTES) || (currentUid() !== null && before.uid !== BigInt(currentUid()))) { throw Error('unsafe owner file'); } // Bitwise flags treat an unavailable platform constant as zero. fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); - const opened = fs.fstatSync(fd); - if (!opened.isFile() || !sameIdentity(before, opened) || opened.size > MAX_OWNER_BYTES) { + const opened = fs.fstatSync(fd, { bigint: true }); + if (!opened.isFile() || !sameIdentity(before, opened) || opened.size > BigInt(MAX_OWNER_BYTES)) { throw Error('owner file changed at open'); } const bytes = Buffer.alloc(MAX_OWNER_BYTES + 1); const count = fs.readSync(fd, bytes, 0, bytes.length, 0); - if (count > MAX_OWNER_BYTES || !sameIdentity(opened, fs.lstatSync(file))) throw Error('owner file changed at read'); + if (count > MAX_OWNER_BYTES || !sameIdentity(opened, fs.lstatSync(file, { bigint: true }))) throw Error('owner file changed at read'); const parsed = JSON.parse(bytes.subarray(0, count).toString('utf8')); if (validRecord(parsed, root)) record = parsed; } catch { /* Unknown metadata never grants ownership. */ } @@ -184,7 +184,8 @@ export function defaultProbes(_platform = process.platform) { return { listOnly: true, alive: () => null, startedAfter: () => null, completeExit: () => null }; } -function sameIdentity(a, b) { return a.dev === b.dev && a.ino === b.ino && a.ctimeMs === b.ctimeMs; } +// Keep inode/device IDs and change times exact; Number stats can alias distinct files. +function sameIdentity(a, b) { return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs; } /** Revalidate after injected probes; recursive rm can still fail partway through. * The fixture seam is not an installed platform containment implementation. @@ -208,13 +209,13 @@ export function collectAbandonedRoots({ tmpdir, selfRoot, homedir, uid = current const safe = removableRunRoot(root, options); if (!safe.ok) { keep(root, safe.reason); continue; } try { - const identity = fs.lstatSync(root); + const identity = fs.lstatSync(root, { bigint: true }); const owner = readOwner(root); if (!owner) { keep(root, 'owner changed during inspection'); continue; } const proof = proveAbandoned(root, owner, probes); if (!proof.abandoned) { keep(root, proof.reason); continue; } const boundary = removableRunRoot(root, options); - if (!boundary.ok || !sameIdentity(identity, fs.lstatSync(root)) + if (!boundary.ok || !sameIdentity(identity, fs.lstatSync(root, { bigint: true })) || JSON.stringify(owner) !== JSON.stringify(readOwner(root))) { keep(root, 'root or owner changed before removal'); continue; } diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 166a5324..d0587a8d 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -63,13 +63,13 @@ export function runGuarded(commands, { const owner = ownerRecord(); try { writeOwner(tempRoot, owner); } catch (error) { log(`could not record run owner; kept run root ${tempRoot}: ${error.message}`); return 2; } - const identity = fs.lstatSync(tempRoot); + const identity = fs.lstatSync(tempRoot, { bigint: true }); const removeOwnRoot = () => { const safe = removableRunRoot(tempRoot, { tmpdir, homedir, requireOwner: false }); if (!safe.ok) { log(`kept own run root ${tempRoot}: ${safe.reason}`); return false; } try { - const current = fs.lstatSync(tempRoot); - if (current.dev !== identity.dev || current.ino !== identity.ino || current.birthtimeMs !== identity.birthtimeMs) { + const current = fs.lstatSync(tempRoot, { bigint: true }); + if (current.dev !== identity.dev || current.ino !== identity.ino || current.birthtimeNs !== identity.birthtimeNs) { log(`kept own run root ${tempRoot}: directory identity changed`); return false; } fs.rmSync(tempRoot, { recursive: true, force: true, maxRetries: 3 }); diff --git a/src/commands/audit.mjs b/src/commands/audit.mjs index 90a1141f..69535fcb 100644 --- a/src/commands/audit.mjs +++ b/src/commands/audit.mjs @@ -9,6 +9,7 @@ import { collectContextEvidence } from '../lib/context-audit-sources.mjs'; import { loadKitConfig } from '../lib/config.mjs'; import { projectCensus, projectsInScope } from '../lib/project-census.mjs'; import { installedVersion } from '../lib/versions.mjs'; +import { reportFailure } from '../lib/output.mjs'; export const options = { json: { type: 'boolean', default: false }, @@ -152,8 +153,11 @@ export async function run({ contextCollectorFn = collectContextAudit, }) { if (positionals.length !== 1 || !['hooks', 'context'].includes(positionals[0])) { - console.error('ak audit requires the hooks or context subcommand'); - console.log(help); + const error = 'ak audit requires the hooks or context subcommand'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => { + console.error(error); + console.log(help); + } }); return 2; } if (positionals[0] === 'context') { @@ -161,7 +165,8 @@ export async function run({ try { report = await contextCollectorFn({ flags, pkgRoot, loadConfigFn }); } catch (error) { - console.error(`context audit failed: ${error.message}`); + const message = `context audit failed: ${error.message}`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => console.error(message) }); return 2; } if (flags.json) console.log(JSON.stringify(report, null, 2)); @@ -172,7 +177,8 @@ export async function run({ try { report = collectHookAudit({ flags, detectVersionFn, loadConfigFn }); } catch (error) { - console.error(`hook audit failed: ${error.message}`); + const message = `hook audit failed: ${error.message}`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => console.error(message) }); return 2; } if (flags.json) { diff --git a/src/commands/heal.mjs b/src/commands/heal.mjs index cd68085e..8b30d965 100644 --- a/src/commands/heal.mjs +++ b/src/commands/heal.mjs @@ -1,4 +1,5 @@ import path from 'node:path'; +import { reportFailure } from '../lib/output.mjs'; import { collectHookAudit } from './audit.mjs'; import { @@ -99,25 +100,28 @@ function validateMode(flags) { if (flags.apply && !flags.yes) throw new TypeError('--apply requires --yes'); } +function usageError(flags, message, showHelp = false) { + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => { + console.error(message); + if (showHelp) console.log(help); + } }); + return 2; +} + export async function run({ flags, positionals, detectVersionFn, loadConfigFn }) { if (positionals.length !== 1 || positionals[0] !== 'hooks') { - console.error('ak heal requires the hooks subcommand'); - console.log(help); - return 2; + return usageError(flags, 'ak heal requires the hooks subcommand', true); } try { validateMode(flags); } catch (error) { - console.error(`hook healing refused: ${error.message}`); - return 2; + return usageError(flags, `hook healing refused: ${error.message}`); } const transactionsRoot = transactionRoot(flags); if (flags.undo && flags.recover) { - console.error('hook healing refused: --undo and --recover are mutually exclusive'); - return 2; + return usageError(flags, 'hook healing refused: --undo and --recover are mutually exclusive'); } if (flags.undo || flags.recover) { if ((flags.action?.length ?? 0) || flags['plan-digest']) { - console.error('hook healing refused: rollback/recovery cannot be combined with plan action flags'); - return 2; + return usageError(flags, 'hook healing refused: rollback/recovery cannot be combined with plan action flags'); } const result = flags.recover ? (flags.apply @@ -145,8 +149,7 @@ export async function run({ flags, positionals, detectVersionFn, loadConfigFn }) return audit.summary.invalidSources || audit.summary.configurationIssues ? 1 : 0; } if (!flags.action?.length || !flags['plan-digest']) { - console.error('hook healing refused: apply requires --action and --plan-digest from a preview'); - return 2; + return usageError(flags, 'hook healing refused: apply requires --action and --plan-digest from a preview'); } if (unfinishedTransactions.length) { throw new Error(`unfinished hook transaction(s) require --recover first: ${unfinishedTransactions.map((item) => item.id).join(', ')}`); @@ -160,7 +163,6 @@ export async function run({ flags, positionals, detectVersionFn, loadConfigFn }) }); return printResult(result, flags.json); } catch (error) { - console.error(`hook healing failed: ${error.message}`); - return 2; + return usageError(flags, `hook healing failed: ${error.message}`); } } diff --git a/src/commands/models.mjs b/src/commands/models.mjs index 89026435..b9562567 100644 --- a/src/commands/models.mjs +++ b/src/commands/models.mjs @@ -1,4 +1,4 @@ -import { heading, info, ok, warn, dim } from '../lib/output.mjs'; +import { heading, info, ok, warn, dim, reportFailure } from '../lib/output.mjs'; import { loadKitConfig } from '../lib/config.mjs'; import { aqeRouterFile } from '../lib/providers.mjs'; import { readJson } from '../lib/settings.mjs'; @@ -129,10 +129,6 @@ async function runRefresh(ctx) { function runStatus(ctx) { const { flags, cacheFile, store, latest } = ctx; - if (flags.host && !ALL_OWNERS.includes(flags.host)) { - warn(`unsupported model host: ${flags.host}`); - return 2; - } const snapshot = visibleSnapshot(latest, flags.host); const since = flags.since ? Date.parse(flags.since) : null; const history = store.snapshots.filter((entry) => entry.scope.fingerprint === latest.scope.fingerprint @@ -174,7 +170,7 @@ function runDiff(ctx) { function runExplain(ctx) { const { positionals, flags, latest } = ctx; const selector = positionals[1] ?? flags.to; - if (!selector) { warn('usage: ak models explain HOST:MODEL'); return 2; } + if (!selector) { modelUsageError(flags, 'usage: ak models explain HOST:MODEL'); return 2; } const result = explainModel(latest, selector); if (flags.json) printJson(result); else if (!result.found) warn(`Model not found: ${selector}`); @@ -193,7 +189,7 @@ function runPlan(ctx) { const { flags, positionals, latest } = ctx; const activity = flags.activity; const to = flags.to ?? positionals[1]; - if (!activity || !to) { warn('usage: ak models plan --activity ACTIVITY [--from HOST:MODEL] --to HOST:MODEL'); return 2; } + if (!activity || !to) { modelUsageError(flags, 'usage: ak models plan --activity ACTIVITY [--from HOST:MODEL] --to HOST:MODEL'); return 2; } const result = planModelChange(latest, { activity, from: flags.from, to }); if (flags.json) printJson(result); else { @@ -211,9 +207,34 @@ function runPlan(ctx) { // dispatched by name once that store/latest snapshot is in hand (below). const READ_ACTIONS = { status: runStatus, diff: runDiff, explain: runExplain, plan: runPlan }; +function modelUsageError(flags, message) { + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); +} + /** @param {{flags: Record, positionals: string[], deps?: Record}} input */ export async function run({ flags, positionals, deps = {} }) { const action = positionals[0] ?? 'status'; + if (action !== 'refresh' && !Object.hasOwn(READ_ACTIONS, action)) { + modelUsageError(flags, 'usage: ak models status|refresh|diff|explain|plan'); + return 2; + } + const maxPositionals = action === 'diff' ? 3 : action === 'explain' || action === 'plan' ? 2 : 1; + if (positionals.length > maxPositionals) { + modelUsageError(flags, `unexpected argument '${positionals[maxPositionals]}'`); + return 2; + } + if (action === 'explain' && !positionals[1] && !flags.to) { + modelUsageError(flags, 'usage: ak models explain HOST:MODEL'); + return 2; + } + if (action === 'plan' && (!flags.activity || !(flags.to ?? positionals[1]))) { + modelUsageError(flags, 'usage: ak models plan --activity ACTIVITY [--from HOST:MODEL] --to HOST:MODEL'); + return 2; + } + if (action === 'status' && flags.host && !ALL_OWNERS.includes(flags.host)) { + modelUsageError(flags, `unsupported model host: ${flags.host}`); + return 2; + } const cacheFile = deps.cacheFile ?? modelInventoryPath(); const readStore = deps.readStore ?? readModelStore; const append = deps.append ?? appendModelSnapshot; @@ -229,6 +250,5 @@ export async function run({ flags, positionals, deps = {} }) { if (!latest) return noSnapshot(flags, cacheFile); const handler = READ_ACTIONS[action]; - if (!handler) { warn('usage: ak models status|refresh|diff|explain|plan'); return 2; } return handler({ ...ctx, store, latest }); } diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 772294d5..fc649db6 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -5,6 +5,7 @@ // Project scope (when run inside a git repo / --project): the port of // ruflo-setup-project — init, sanitize, pin, activate, verify, daemon. import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline/promises'; import { run as runCmd, have } from '../lib/exec.mjs'; @@ -381,21 +382,24 @@ function deployTokenAuditSkill(pkgRoot) { * left alone. Shares HOSTS/hostInstallState/installHost with `ak sync`'s * and `ak host pick`'s own host-install loops; the interactive confirmation * here (vs. their unconditional install) is this command's own UX. */ -async function installEnabledAbsentHosts(cfg, flags) { +export async function installEnabledAbsentHosts(cfg, flags, lifecycle = {}) { + const { installState, install, collectFacts } = { + installState: hostInstallState, install: installHost, collectFacts: collectIntegrationFacts, ...lifecycle, + }; let installed = false; for (const h of HOSTS) { if (!cfg.integrations?.hosts?.[h.id]) continue; - const st = await hostInstallState(h); + const st = await installState(h); if (st.method === 'absent') { if (await ask(`${h.id} CLI not found — install ${h.pkg} globally?`, true, flags.yes)) { - const r = await installHost(h.id); + const r = await install(h.id); (r.ok ? ok : warn)(`${h.id}: ${r.detail}`); if (r.ok) { installed = true; // hostInstallState() above already recorded the pre-install // 'absent' evidence; re-probe now so a subsequent `ak status` // doesn't read that stale row back. - await hostInstallState(h, { refresh: true, record: true, source: 'setup' }); + await installState(h, { refresh: true, record: true, source: 'setup' }); } } else warn(`${h.id} not installed — enable/install later with: ak host pick`); } else { @@ -404,7 +408,7 @@ async function installEnabledAbsentHosts(cfg, flags) { } // host-setup covers every host in one call; refresh it once after the // loop, not per host, once anything actually changed. - if (installed) await collectIntegrationFacts({ cfg, refresh: true, record: true, source: 'setup' }); + if (installed) await collectFacts({ cfg, refresh: true, record: true, source: 'setup' }); } /** Step 6b: host lifecycle wiring — connected MCPs, compact lazy gateway, @@ -467,7 +471,7 @@ async function printUndetectedHostHints(cfg) { } } -export async function run_machine({ flags, pkgRoot, cfg }) { +export async function run_machine({ flags, pkgRoot, cfg, deps = { hostLifecycle: undefined } }) { heading('machine setup'); if (flags['dry-run']) { info('dry-run: would ensure packages (incl. agent-browser and ruvnet-brain), deploy skill (blocks + MCP land in the final pass)'); return true; } @@ -479,7 +483,7 @@ export async function run_machine({ flags, pkgRoot, cfg }) { // key on `codex` being on PATH / dual-mode enablement). Running them here // warned + drifted on genuinely bare machines. deployTokenAuditSkill(pkgRoot); - await installEnabledAbsentHosts(cfg, flags); + await installEnabledAbsentHosts(cfg, flags, deps.hostLifecycle); if (!(await applyMachineHostLifecycles(cfg, pkgRoot))) return false; if (cfg.codexContext && cfg.integrations?.hosts?.codex) { try { await manageCodexContext(cfg, { persist: saveKitConfig }); } @@ -602,25 +606,52 @@ export async function startProjectDaemon(root, { } else warn('daemon failed to start — try: ruflo daemon start'); } -/** Step 7: write-verification (store → actual on-disk row, then clean up). - * The CLI mirrors the write into agentdb-memory.db under the memory root - * (CLAUDE_FLOW_MEMORY_PATH, else a config persistPath, else /.swarm). - * The probe pins that root beside the pinned memory.db, so both copies land - * in the stores cleanup checks even when the project's root is redirected; - * this is the user's real corpus, so nothing of the probe may be left behind. */ +/** Step 7: verify a real primary write. Ruflo also writes a native mirror + * under CLAUDE_FLOW_MEMORY_PATH; keep that disposable mirror in a private + * directory so setup cannot create a spare project store. */ export async function verifyProjectMemoryWrite(root, env, { runner = runCmd } = {}) { const probeKey = `_setup/verify-${process.pid}-${Date.now()}`; - const probeEnv = { ...env, CLAUDE_FLOW_MEMORY_PATH: path.dirname(env?.CLAUDE_FLOW_DB_PATH ?? paths.projectMemoryDb(root)) }; - const stored = (await runner('ruflo', ['memory', 'store', '-k', probeKey, '--value', 'setup-verify', '-n', '_setup'], { cwd: root, env: probeEnv })).code === 0; - const landed = stored ? findMemoryEntry(root, '_setup', probeKey) : null; - if (!landed) { + let mirrorDir; + let stored = false; + let landed = null; + let cleanup; + let runnerError = false; + let mirrorCleanupError = false; + try { + mirrorDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-setup-memory-probe-')); + const probeEnv = { + ...env, + CLAUDE_FLOW_DB_PATH: env?.CLAUDE_FLOW_DB_PATH ?? paths.projectMemoryDb(root), + CLAUDE_FLOW_MEMORY_PATH: mirrorDir, + RUFLO_DAEMON_AUTOSTART: '0', + }; + stored = (await runner('ruflo', ['memory', 'store', '-k', probeKey, '--value', 'setup-verify', '-n', '_setup'], { cwd: root, env: probeEnv })).code === 0; + } catch { + runnerError = true; + } finally { + // A failed command may still have written its primary row. Keep the + // existing refusal behavior for unreadable or busy project stores. + if (mirrorDir) { + try { + landed = findMemoryEntry(root, '_setup', probeKey); + cleanup = removeMemoryProbe(root, '_setup', probeKey); + } catch { + runnerError = true; + } finally { + try { fs.rmSync(mirrorDir, { recursive: true, maxRetries: 3 }); } + catch { mirrorCleanupError = true; } + } + } + } + if (cleanup?.failed.length) { + warn(`memory probe cleanup failed in ${cleanup.failed.map((f) => `${path.basename(f.file)} (${f.kind})`).join(', ')} — remove ${probeKey} from _setup manually`); + } + if (mirrorCleanupError) warn(`memory probe temporary mirror cleanup failed at ${mirrorDir} — inspect it manually`); + if (!stored || !landed || runnerError || cleanup?.failed.length || mirrorCleanupError) { fail('memory write verification FAILED — run: ak status / ruflo doctor -c memory'); return; } - const cleanup = removeMemoryProbe(root, '_setup', probeKey); - if (cleanup.failed.length) { - warn(`memory write verified, but probe cleanup failed in ${cleanup.failed.map((f) => `${path.basename(f.file)} (${f.kind})`).join(', ')} — remove ${probeKey} from _setup manually`); - } else ok(`memory write VERIFIED (store → ${path.basename(landed.file)} row confirmed)`); + ok(`memory write VERIFIED (store → ${path.basename(landed.file)} row confirmed)`); } function reportProjectGuidance(result) { @@ -926,6 +957,7 @@ async function applySetupCodexRepairs(flags, repairPlan, cwd, repairTopology) { export async function run({ flags, pkgRoot, confirm = ask, dejaVuLifecycle = DEFAULT_DEJA_VU_LIFECYCLE, + deps = { hostLifecycle: undefined }, ...runtimeOverrides }) { const runtime = { ...DEFAULT_SETUP_RUNTIME, ...runtimeOverrides }; @@ -966,7 +998,7 @@ export async function run({ flags, cfg, hostFlags, companionPreflight, dejaVuFlagsResult, }); - if (!(await runtime.machineSetup({ flags, pkgRoot, cfg }))) return 1; + if (!(await runtime.machineSetup({ flags, pkgRoot, cfg, deps }))) return 1; if (!flags['dry-run'] && cfg.aqe !== false) { // Persist the selected choice even on failure, so retry/sync has an exact plan. saveKitConfig(cfg); diff --git a/src/commands/status.mjs b/src/commands/status.mjs index 0020ec5f..f7147d31 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -89,7 +89,7 @@ Slow proofs run only when named with --only, up to six minutes each: aqe storage, embedding configuration and provenance, and the browser payload memory-routes the memory round trip, plus whether CLI and MCP see each - other's writes (remembered as the memory check) + other's writes (CLI result remembered as memory; routing separately) A named check runs even when it would not apply; its result is remembered only when it applies. learning and harvest are never remembered. @@ -281,9 +281,9 @@ function strayArgumentError(positionals) { export async function run({ flags, positionals = [], pkgRoot, deps = {} }) { const request = refreshRequestFromFlags(flags); if ('error' in request) return usageError(flags, request.error); - if (!request.strength) return report(flags, { rows: await collect({ pkgRoot, refresh: false }) }); const stray = strayArgumentError(positionals); if (stray) return usageError(flags, stray); + if (!request.strength) return report(flags, { rows: await collect({ pkgRoot, refresh: false }) }); return runRefreshed({ flags, pkgRoot, request, deps }); } diff --git a/src/commands/status/sections/codex-mcp.mjs b/src/commands/status/sections/codex-mcp.mjs index 620db669..2e46c1c1 100644 --- a/src/commands/status/sections/codex-mcp.mjs +++ b/src/commands/status/sections/codex-mcp.mjs @@ -87,7 +87,7 @@ function topologyRows(cwd, cfg) { if (!topology.agenticQeRegistrations.length) { // Agentic-QE owns its Codex registration (ADR-0033); sync never writes it. rows.push(row('codex-mcp', 'warn', 'agentic-qe MCP is not concretely registered in Codex', - 'run: aqe platform setup codex --overwrite --with-ruflo', { repair: 'manual' })); + 'run: aqe init --auto --with-codex --codex-guidance compact in this project, then recheck; AQE 3.14.4 may still omit Codex assets (agentic-qe#755)', { repair: 'manual' })); } else { rows.push(row('codex-mcp', 'ok', 'agentic-qe MCP concretely registered in Codex')); } diff --git a/src/commands/status/sections/daemons.mjs b/src/commands/status/sections/daemons.mjs index 152eb9c5..e156cd14 100644 --- a/src/commands/status/sections/daemons.mjs +++ b/src/commands/status/sections/daemons.mjs @@ -87,6 +87,15 @@ const flatKeys = (entries, pick) => entries.map((e) => `"${e.key}": ${JSON.strin export function heldRow(held) { const file = DAEMON_CONFIG_RELATIVE.split(path.sep).join('/'); const want = flatKeys(held.entries, (e) => e.want); + if (held.reason === 'yaml-shadow') return row('daemons', 'warn', + `${file} is not ak-managed: creating it would hide existing .claude-flow/config.yaml or config.yml daemon values`, + `review the YAML daemon values and set ${want} in the active config yourself, ${RESTART}`, { repair: 'manual' }); + if (held.reason === 'higher-priority-json') return row('daemons', 'warn', + `${file} is not ak-managed: Ruflo reads claude-flow.config.json first`, + `review claude-flow.config.json and set ${want} there yourself, ${RESTART}`, { repair: 'manual' }); + if (held.reason === 'explicit-config') return row('daemons', 'warn', + `${file} is not ak-managed: Ruflo currently reads CLAUDE_FLOW_CONFIG before YAML`, + `review the CLAUDE_FLOW_CONFIG file and set ${want} there yourself, ${RESTART}`, { repair: 'manual' }); return held.invalid ? row('daemons', 'warn', `${file} is not ak-managed: it is unreadable or not a JSON object, so ak leaves it untouched`, `fix ${file} so it is a JSON object holding ${want} (flat keys), ${RESTART}`, { repair: 'manual' }) @@ -96,16 +105,16 @@ export function heldRow(held) { /** The Ruflo repository around `cwd`, kit.json, and the keys its config.json * keeps from ak (read once for the deferral and drift rows). */ -function rufloContext(cwd, { loadConfig, rufloVersion, platform }) { +function rufloContext(cwd, { loadConfig, rufloVersion, platform, env }) { const root = rufloDaemonProjectRoot(cwd); if (!root) return { root: null, cfg: null, held: null }; const cfg = loadConfig(); - return { root, cfg, held: daemonConfigHeld(root, { cfg, rufloVersion, platform }) }; + return { root, cfg, held: daemonConfigHeld(root, { cfg, rufloVersion, platform, env }) }; } -function driftRows({ root, cfg, held }, { rufloVersion, platform }) { +function driftRows({ root, cfg, held }, { rufloVersion, platform, env }) { if (!root) return []; - const parts = daemonDrift(root, { cfg, rufloVersion, platform }); + const parts = daemonDrift(root, { cfg, rufloVersion, platform, env }); return [held && heldRow(held), parts && row('daemons', 'warn', `ak-managed daemon settings differ from what Ruflo ${rufloVersion ?? '(version unknown)'} ` + `needs: ${parts.join('; ')}`, "sync applies ak's Ruflo daemon settings")].filter(Boolean); } @@ -134,10 +143,10 @@ export default { rows.push(row('daemons', 'ok', daemons.length ? `${daemons.length} running (one per active project is expected)` : 'none running')); } - const ruflo = rufloContext(cwd, { loadConfig, rufloVersion, platform }); + const ruflo = rufloContext(cwd, { loadConfig, rufloVersion, platform, env }); const deferral = deferralRow(root, { now, platform, ruflo }); if (deferral) rows.push(deferral); - rows.push(...driftRows(ruflo, { rufloVersion, platform })); + rows.push(...driftRows(ruflo, { rufloVersion, platform, env })); } catch (e) { rows.push(row('daemons', 'warn', `daemon check unavailable: ${e.message}`)); } diff --git a/src/commands/status/sections/project-memory.mjs b/src/commands/status/sections/project-memory.mjs index a76c2f57..e9055539 100644 --- a/src/commands/status/sections/project-memory.mjs +++ b/src/commands/status/sections/project-memory.mjs @@ -26,7 +26,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { projectDaemonAlive } from '../../../lib/daemons.mjs'; -import { formatLiveCheckAge as ago } from '../../../lib/live-check-evidence.mjs'; +import { formatLiveCheckAge as ago, liveCheckInputsKey, readLiveCheck } from '../../../lib/live-check-evidence.mjs'; import { memoryMaintenanceStatus } from '../../../lib/memory-maintenance.mjs'; import { findStrayMemoryStores, projectMemoryStatus } from '../../../lib/project-memory.mjs'; import { findProbeRows } from '../../../lib/memory-probe-cleanup.mjs'; @@ -206,7 +206,19 @@ export default { ? storeMessage(store) : `${path.basename(store.file)} store is unreadable (${store.file}); existing-corpus access unverified`)); } - if (memory.secondary) rows.push(row('memory', 'warn', twoStoreMessage(rufloVersion, platform))); + if (memory.secondary) { + const routing = readLiveCheck('memory-routes', { + inputsKey: liveCheckInputsKey('memory-routes', { routingVersion: rufloVersion, platform }), now, + }); + const observed = typeof rufloVersion === 'string' && rufloVersion.length > 0 && + routing?.status === 'passed' && !routing.invalidated; + const message = observed + ? twoStoreMessage(rufloVersion, platform).replace('MCP routing needs separate verification.', 'Isolated CLI/MCP routing was observed.') + : twoStoreMessage(rufloVersion, platform); + rows.push(row('memory', observed ? 'info' : 'warn', observed + ? `${message}; isolated CLI/MCP routing observed (${ago(routing.ageMs)}) for this installed CLI version and platform; existing-corpus access unverified` + : message)); + } const orphaned = orphanedStoreRow(root, memory); if (orphaned) rows.push(orphaned); rows.push(...maintenanceRows(root, memory, now)); diff --git a/src/commands/status/sections/ruflo-components.mjs b/src/commands/status/sections/ruflo-components.mjs index 9bbdebad..ee5270eb 100644 --- a/src/commands/status/sections/ruflo-components.mjs +++ b/src/commands/status/sections/ruflo-components.mjs @@ -29,7 +29,10 @@ export function rufloComponentRows(snapshot) { const rows = [row('ruflo-components', snapshot.summary.active === snapshot.summary.total ? 'ok' : 'info', `ruflo components: ${snapshot.summary.active} of ${snapshot.summary.total} active (ruflo ${snapshot.rufloVersion ?? 'not installed'})`)]; for (const c of snapshot.components) { - const text = `${c.label} — ${c.state.label}: ${c.state.meaning}${c.state.action ? ` ${c.state.action}` : ''}`; + // The applied-unverified action is carried by its manual fix below; repeating it + // in the message renders the same host restart instruction twice. + const action = c.state.id === 'applied-unverified' ? '' : c.state.action; + const text = `${c.label} — ${c.state.label}: ${c.state.meaning}${action ? ` ${action}` : ''}`; rows.push({ ...(FIXABLE.has(c.state.id) ? row('ruflo-components', LEVEL(c.state.id), text, `sync applies ${c.label} (${c.state.action || 'reconcile'})`) diff --git a/src/commands/status/sections/user-memory.mjs b/src/commands/status/sections/user-memory.mjs index 2759b8e3..d2306d6c 100644 --- a/src/commands/status/sections/user-memory.mjs +++ b/src/commands/status/sections/user-memory.mjs @@ -5,6 +5,8 @@ // that store when it exists, and the stray stores such sessions left before: // `~/.swarm` and `~/.codex/.chatgpt-projects/*/.swarm`. Everything here is // information only: ak never moves, merges or deletes a store. +import fs from 'node:fs'; +import path from 'node:path'; import * as paths from '../../../lib/paths.mjs'; import { findUserStrayStores, memoryDirStatus } from '../../../lib/project-memory.mjs'; import { homeRelative } from '../../../lib/ruflo-memory.mjs'; @@ -30,6 +32,23 @@ function strayRow(found, home, userDir) { + (found.complete ? '' : '; only the first 500 Codex project folders were checked')); } +function homeAqeRow(home) { + const dir = path.join(home, '.agentic-qe'); + let folder; + try { folder = fs.lstatSync(dir); } catch (e) { if (e.code === 'ENOENT') return null; throw e; } + if (!folder.isDirectory()) return row('aqe', 'info', `AQE home path ${dir} is not a directory; contents unverified`); + const db = path.join(dir, 'memory.db'); + let file; + try { file = fs.lstatSync(db); } catch (e) { if (e.code !== 'ENOENT') throw e; } + if (!file?.isFile()) return row('aqe', 'info', `AQE home directory ${dir}: memory.db absent; contents and runtime health unverified`); + let walBytes = 0; + try { + const wal = fs.lstatSync(`${db}-wal`); + if (wal.isFile()) walBytes = wal.size; + } catch (e) { if (e.code !== 'ENOENT') throw e; } + return row('aqe', 'info', `AQE home directory ${dir}: memory.db present (${formatBytes(file.size + walBytes)} with WAL); contents and runtime health unverified`); +} + export default { id: 'user-memory', /** @param {{ home?: string, env?: NodeJS.ProcessEnv, cfg?: any }} [ctx] */ @@ -47,6 +66,8 @@ export default { const found = findUserStrayStores({ home, codexHome: env.CODEX_HOME || undefined }); const stray = strayRow(found, home, dir); if (stray) rows.push(stray); + const aqe = homeAqeRow(home); + if (aqe) rows.push(aqe); } catch (e) { rows.push(row('memory', 'warn', `user-level memory check unavailable: ${e.message}`)); } diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 3e6ef56c..48ca68ba 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -22,7 +22,7 @@ import { hostsWithLifecycle, lifecycleAdapterFor, lifecycleExecutionEnabled, det import { companionLifecycleFor } from '../lib/adapters/companion-lifecycle-registry.mjs'; import { renderApplyReport } from '../lib/adapters/lifecycle-render.mjs'; import { listDaemons, staleDaemons, reap } from '../lib/daemons.mjs'; -import { applyRufloDaemon } from '../lib/ruflo-daemon-config.mjs'; +import { applyRufloDaemon, rufloDaemonProjectRoot } from '../lib/ruflo-daemon-config.mjs'; import { cleanupProbeRows } from '../lib/memory-probe-cleanup.mjs'; import { rufloMemoryLocation } from '../lib/ruflo-memory.mjs'; import { installedRoutingVersion } from '../lib/ruflo-memory-contract.mjs'; @@ -484,16 +484,20 @@ export const SYNC_STEPS = [ // that are actually still alive, not the ones just killed. if (reaped.some((r) => r.killed)) await list({ cwd: ctx.cwd, refresh: true, record: true, source: 'sync' }); // Read the version now: the versions step may have just upgraded Ruflo. - const applied = await applyRufloDaemon(ctx.cwd, { - cfg: ctx.cfg, rufloVersion: installedRoutingVersion() ?? installedVersion('ruflo'), + const applied = await (ctx.daemonApply ?? applyRufloDaemon)(ctx.cwd, { + cfg: ctx.cfg, rufloVersion: (ctx.daemonVersion ?? (() => installedRoutingVersion() ?? installedVersion('ruflo')))(), }); if (!applied) return; - saveKitConfig(ctx.cfg); + (ctx.saveConfig ?? saveKitConfig)(ctx.cfg); const { config, autostart } = applied.result; if (applied.result.changed) ok(`ruflo daemon settings: config ${config}, start-on-use ${autostart}`); const { held } = applied.result; if (held) { - warn(`.claude-flow/config.json is not ak-managed here (${held.invalid ? 'unreadable or not a JSON object' : 'a key holds your own value'}); ` + const reason = held.reason === 'yaml-shadow' ? 'creating JSON would hide existing YAML daemon values' + : held.reason === 'higher-priority-json' ? 'Ruflo reads root claude-flow.config.json first' + : held.reason === 'explicit-config' ? 'Ruflo reads CLAUDE_FLOW_CONFIG before YAML' + : held.invalid ? 'unreadable or not a JSON object' : 'a key holds your own value'; + warn(`.claude-flow/config.json is not ak-managed here (${reason}); ` + `left as is, and the daemon is not restarted for ${held.entries.map((e) => e.key).join(', ')}`); } if (applied.restarted) ok('ruflo daemon restarted so it reads its settings'); @@ -1123,6 +1127,18 @@ async function converge({ // (not-applied/drifted/blocked) don't need an upgrade and stay in the plan. .filter((r) => !(flags['no-upgrade'] && r.subsystem === 'ruflo-components' && r.state === 'needs-ruflo')); + // The daemon step also runs for a versions item, even when the collector + // reports no current daemon drift. Its settings are decided after upgrades + // from the installed Ruflo version, so the preview can promise only this + // recheck, not exact keys or a restart. + if (!skip.has('versions') && candidates.some((r) => r.subsystem === 'versions') + && !candidates.some((r) => r.subsystem === 'daemons') + && rufloDaemonProjectRoot(cwd)) { + candidates.push(row('daemons', 'info', + 'package changes may change the daemon settings needed by the installed Ruflo version', + 'recheck daemon settings against the installed Ruflo version; write receipted keys or restart a running daemon only if needed')); + } + const cfg = loadKitConfig(); if (cfg.aqe !== false && cfg.aqeEmbedding && cfg.aqeEmbedding.mode !== 'unmanaged') { candidates.push(row('aqe-embedding', 'info', 'selected semantic backend requires live verification', diff --git a/src/commands/telemetry.mjs b/src/commands/telemetry.mjs index 77d3c503..142eaa66 100644 --- a/src/commands/telemetry.mjs +++ b/src/commands/telemetry.mjs @@ -98,7 +98,9 @@ export async function run({ flags, positionals, pkgRoot, deps = {} }) { return 0; } catch { // Source failures and hostile input must not echo filenames or parser details. - console.error('Telemetry failed: check command options, schema/digest, compatible snapshots, private identity, and readable input/new output files.'); + const error = 'Telemetry failed: check command options, schema/digest, compatible snapshots, private identity, and readable input/new output files.'; + console.error(error); + console.log(JSON.stringify({ error, exitCode: 2 })); return 2; } } diff --git a/src/commands/usage.mjs b/src/commands/usage.mjs index 2d786207..e2c578f7 100644 --- a/src/commands/usage.mjs +++ b/src/commands/usage.mjs @@ -2,7 +2,7 @@ // offline text scorecard (`score`) rendered from the SAME local-transcript // aggregate `ak dashboard`'s Usage tab reads — no cost/token/percentile // arithmetic is redone here; see the score section below for the boundary. -import { heading, info, ok, warn, dim } from '../lib/output.mjs'; +import { heading, info, ok, warn, dim, reportFailure } from '../lib/output.mjs'; import { stripUnsafeChars } from '../lib/text-safety.mjs'; import { readIndex } from '../lib/usage-index.mjs'; import { @@ -321,7 +321,8 @@ function scoreProjection(agg, windowDays) { async function runScore({ flags, deps }) { const windowDays = parseScoreWindow(flags.window ?? '14'); if (windowDays == null) { - warn(`ak usage score: --window must be 7, 14, or 30 (got ${JSON.stringify(flags.window)})`); + const message = `ak usage score: --window must be 7, 14, or 30 (got ${JSON.stringify(flags.window)})`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); return 2; } const readAgg = deps.readIndex ?? readIndex; @@ -953,7 +954,8 @@ function printDeepPass(deep) { async function runPrompts({ flags, deps }) { const win = parsePromptWindow(flags.window); if (win == null) { - warn(`ak usage prompts: --window must be 7, 14, 30, or all (got ${JSON.stringify(flags.window)})`); + const message = `ak usage prompts: --window must be 7, 14, 30, or all (got ${JSON.stringify(flags.window)})`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); return 2; } const readAgg = deps.readIndex ?? readIndex; @@ -1087,6 +1089,12 @@ export async function run({ flags, positionals, deps = {} }) { const provider = positionals[1]; const cacheFile = deps.cacheFile ?? openRouterActivityFile(); + if (['score', 'prompts'].includes(action) && positionals.length > 1) { + const message = `unexpected argument '${positionals[1]}'`; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); + return 2; + } + if (action === 'status' && provider === undefined) { return runOpenRouterStatus({ flags, cacheFile, read: deps.read ?? readOpenRouterActivity }); } @@ -1096,6 +1104,7 @@ export async function run({ flags, positionals, deps = {} }) { return runOpenRouterRefresh({ flags, cacheFile, refresh: deps.refresh ?? refreshOpenRouterActivity }); } - warn('usage: ak usage status | ak usage refresh openrouter | ak usage score | ak usage prompts'); + const message = 'usage: ak usage status | ak usage refresh openrouter | ak usage score | ak usage prompts'; + reportFailure({ json: flags.json, payload: { error: message, exitCode: 2 }, human: () => warn(message) }); return 2; } diff --git a/src/commands/x/aqe-embedding.mjs b/src/commands/x/aqe-embedding.mjs index 25601712..fad9d06e 100644 --- a/src/commands/x/aqe-embedding.mjs +++ b/src/commands/x/aqe-embedding.mjs @@ -3,6 +3,7 @@ import { embeddingIntentFromFlags, embeddingSetupDisclosure } from '../../lib/aq import { prepareAqeEmbedding, AQE_EMBEDDING_COACHING } from '../../lib/aqe-embedding-lifecycle.mjs'; import { inspectAqeEmbeddingProjections, reconcileAqeEmbeddingProjections } from '../../lib/aqe-embedding-projection.mjs'; import { reconcileOpencodeAqeEmbedding } from '../../lib/opencode-core.mjs'; +import { reportFailure } from '../../lib/output.mjs'; export const options = { 'aqe-embedding-mode': { type: 'string' }, 'aqe-embedding-endpoint': { type: 'string' }, @@ -43,11 +44,19 @@ export async function run({ flags = {}, positionals = [], reconcileOpenCode = reconcileOpencodeAqeEmbedding, }) { const action = positionals[0] ?? 'status'; - if (!['status', 'configure', 'prepare', 'verify'].includes(action) || positionals.length > 1) return 2; + if (!['status', 'configure', 'prepare', 'verify'].includes(action) || positionals.length > 1) { + const error = 'usage: ak x aqe-embedding [status|configure|prepare|verify]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => console.error(error) }); + return 2; + } const cfg = load(); if (action === 'configure') { try { cfg.aqeEmbedding = embeddingIntentFromFlags(cfg, flags); } - catch { console.error('Invalid embedding selection; run ak x aqe-embedding --help.'); return 2; } + catch { + const error = 'Invalid embedding selection; run ak x aqe-embedding --help.'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => console.error(error) }); + return 2; + } } const emit = value => console.log(flags.json ? JSON.stringify(value) : value.detail); const openCodeReady = (dryRun) => { diff --git a/src/commands/x/aqe-store.mjs b/src/commands/x/aqe-store.mjs index fb26b5b7..9d4e5158 100644 --- a/src/commands/x/aqe-store.mjs +++ b/src/commands/x/aqe-store.mjs @@ -3,7 +3,7 @@ // while any AQE writer is open. The work is in src/lib/aqe-store-merge.mjs. import { mergeAqeStores, restoreSteps } from '../../lib/aqe-store-merge.mjs'; import { repoRoot } from '../../lib/paths.mjs'; -import { ok, warn, fail, info } from '../../lib/output.mjs'; +import { ok, warn, fail, info, reportFailure } from '../../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, @@ -139,7 +139,8 @@ function printInterrupted(result) { export async function run({ flags = {}, positionals = [], cwd = process.cwd(), merge = mergeAqeStores }) { const action = positionals[0] ?? 'status'; if (!['status', 'merge'].includes(action) || positionals.length > 1) { - fail('usage: ak x aqe-store [status|merge] [--yes] [--dry-run] [--json]'); + const error = 'usage: ak x aqe-store [status|merge] [--yes] [--dry-run] [--json]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); return 2; } const root = repoRoot(cwd); diff --git a/src/commands/x/codex-context.mjs b/src/commands/x/codex-context.mjs index 3502beca..16866127 100644 --- a/src/commands/x/codex-context.mjs +++ b/src/commands/x/codex-context.mjs @@ -1,6 +1,6 @@ import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { inspectCodexContext, manageCodexContext, releaseCodexContext } from '../../lib/codex-context.mjs'; -import { info, ok, warn } from '../../lib/output.mjs'; +import { info, ok, warn, reportFailure } from '../../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, json: { type: 'boolean', default: false } }; export const help = `ak x codex-context — manage Codex's native per-model context capacities @@ -21,7 +21,11 @@ Examples: export async function run({ flags, positionals, contextOptions = {} }) { const choice = positionals[0] ?? 'status'; - if (!['status', 'max', 'off'].includes(choice) || positionals.length > 1) { warn(help); return 2; } + if (!['status', 'max', 'off'].includes(choice) || positionals.length > 1) { + const error = 'usage: ak x codex-context [status|max|off] [--dry-run] [--json]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(help) }); + return 2; + } const cfg = loadKitConfig(); const status = inspectCodexContext(cfg, contextOptions); if (choice === 'status' || flags['dry-run']) { diff --git a/src/commands/x/daemon-gc.mjs b/src/commands/x/daemon-gc.mjs index d6d6947d..8a1e691a 100644 --- a/src/commands/x/daemon-gc.mjs +++ b/src/commands/x/daemon-gc.mjs @@ -4,7 +4,7 @@ import { listDaemons, staleDaemons, reap, listMcpTransports, orphanedMcpTransports, reapMcpTransports, } from '../../lib/daemons.mjs'; -import { ok, warn, dim } from '../../lib/output.mjs'; +import { ok, warn, dim, reportFailure } from '../../lib/output.mjs'; export const options = { kill: { type: 'boolean', default: false }, @@ -32,10 +32,19 @@ Examples: ak x daemon-gc --kill reap stale background daemons ak x daemon-gc --mcp --kill also reap same-user PPID-1 MCP orphans`; -export async function run({ flags }) { - const daemons = await listDaemons(); +export async function run({ flags, positionals = [], deps = { daemonLifecycle: undefined, mcpLifecycle: undefined } }) { + if (positionals.length) { + const error = `unexpected argument '${positionals[0]}'`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); + return 2; + } + const { list, reap: reapFn } = { list: listDaemons, reap, ...deps.daemonLifecycle }; + const { list: listMcp, reap: reapMcp } = { + list: listMcpTransports, reap: reapMcpTransports, ...deps.mcpLifecycle, + }; + const daemons = await list(); const stale = staleDaemons(daemons); - const mcpTransports = await listMcpTransports(); + const mcpTransports = await listMcp(); const mcpOrphans = orphanedMcpTransports(mcpTransports); if (flags.json) { console.log(JSON.stringify({ @@ -47,14 +56,14 @@ export async function run({ flags }) { return 0; } if (stale.length && flags.kill) { - const reaped = reap(stale); + const reaped = reapFn(stale); for (const r of reaped) { if (r.killed) ok(`stopped stale daemon pid=${r.pid} ${dim(r.workspace ?? '')}`); else warn(`could not stop pid=${r.pid} (already exited?)`); } // Refresh daemon-sweep evidence so a later read sees the daemons that are // actually still alive, not the pre-reap list. - if (reaped.some((r) => r.killed)) await listDaemons({ refresh: true, record: true, source: 'daemon-gc' }); + if (reaped.some((r) => r.killed)) await list({ refresh: true, record: true, source: 'daemon-gc' }); } else if (stale.length) { for (const d of stale) { warn(`stale daemon pid=${d.pid} ${dim(d.workspace ?? '(unknown workspace)')} ${dim(d.workspaceExists ? `age ${d.ageSecs}s > TTL` : 'workspace gone')}`); @@ -63,7 +72,7 @@ export async function run({ flags }) { } if (flags.mcp && flags.kill) { - for (const result of reapMcpTransports(mcpOrphans)) { + for (const result of reapMcp(mcpOrphans)) { if (result.killed) ok(`stopped orphaned Ruflo MCP pid=${result.pid}`); else warn(`could not stop MCP pid=${result.pid} (identity or orphan proof changed)`); } diff --git a/src/commands/x/harvest.mjs b/src/commands/x/harvest.mjs index 86f2dcbc..7b690dc6 100644 --- a/src/commands/x/harvest.mjs +++ b/src/commands/x/harvest.mjs @@ -7,7 +7,7 @@ // memory root. It NEVER starts a daemon and NEVER backgrounds anything. import { loadKitConfig } from '../../lib/config.mjs'; import { planHarvest, runHarvest } from '../../lib/harvest.mjs'; -import { ok, fail, warn, info, dim, heading } from '../../lib/output.mjs'; +import { ok, fail, warn, info, dim, heading, reportFailure } from '../../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, @@ -45,7 +45,12 @@ Examples: ak x harvest record the outcome (only when opted in) ak x harvest --distill record, then distill the project store`; -export async function run({ flags }) { +export async function run({ flags, positionals = [] }) { + if (positionals.length) { + const error = `unexpected argument '${positionals[0]}'`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); + return 2; + } const cwd = process.cwd(); const cfg = loadKitConfig(); const enabled = cfg.harvest === true; diff --git a/src/commands/x/host-adapters-grants.mjs b/src/commands/x/host-adapters-grants.mjs index 8e3dbe8f..12c2ddd3 100644 --- a/src/commands/x/host-adapters-grants.mjs +++ b/src/commands/x/host-adapters-grants.mjs @@ -19,7 +19,7 @@ import { import { bootstrapHostAdapters } from '../../lib/adapters/admission.mjs'; import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { applyAqeRouter } from '../../lib/providers.mjs'; -import { ok, warn, fail, info, bold } from '../../lib/output.mjs'; +import { ok, warn, fail, info, bold, reportFailure } from '../../lib/output.mjs'; import { findEntry, loadAndHash, stripControl, hookCommandsFor, } from './host-adapters.mjs'; @@ -396,12 +396,16 @@ async function reconcileRevokedAqeProvider({ } export async function revokeGrant({ - name, capability, grantsFile, cfg, env, cwd = process.cwd(), + name, capability, grantsFile, cfg, env, cwd = process.cwd(), flags = /** @type {{json?: boolean}} */ ({}), saveConfig = saveKitConfig, bootstrapAdapters = bootstrapHostAdapters, applyRouter = applyAqeRouter, }) { - if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke-grant [capability]'); return 2; } + if (typeof name !== 'string' || !name) { + const error = 'usage: ak host adapters revoke-grant [capability]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); + return 2; + } const safeName = stripControl(name); if (typeof capability === 'string' && capability) { diff --git a/src/commands/x/host-adapters.mjs b/src/commands/x/host-adapters.mjs index 0264c2fe..9d2091db 100644 --- a/src/commands/x/host-adapters.mjs +++ b/src/commands/x/host-adapters.mjs @@ -25,7 +25,7 @@ import { HOST_REGISTRY } from '../../lib/adapters/registries.mjs'; import * as consentStore from '../../lib/adapters/consent.mjs'; import { runTieredConformance as defaultRunTieredConformance } from '../../lib/adapters/conformance.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; -import { ok, warn, fail, info, dim, bold } from '../../lib/output.mjs'; +import { ok, warn, fail, info, dim, bold, reportFailure } from '../../lib/output.mjs'; import { grant as grantCap, gate as gateTier, status as statusReport, revokeGrant, } from './host-adapters-grants.mjs'; @@ -298,8 +298,12 @@ async function trust({ name, cfg, consent, reader, ask, isTTY, yes, expectHash } return 0; } -function revoke({ name, consent }) { - if (typeof name !== 'string' || !name) { fail('usage: ak host adapters revoke '); return 2; } +function revoke({ name, consent, flags = /** @type {{json?: boolean}} */ ({}) }) { + if (typeof name !== 'string' || !name) { + const error = 'usage: ak host adapters revoke '; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); + return 2; + } const existed = consent.revokeConsent(name); if (existed) { ok(`revoked consent for '${name}'`); return 0; } info(`no recorded consent for '${name}'`); @@ -437,9 +441,9 @@ async function conformance({ // standing consent or grant record, or it silently reactivates the next time // the flag is turned back on. const FAIL_SAFE_HANDLERS = { - revoke: (ctx) => revoke({ name: ctx.name, consent: ctx.consent }), + revoke: (ctx) => revoke({ name: ctx.name, consent: ctx.consent, flags: ctx.flags }), 'revoke-grant': (ctx) => revokeGrant({ - name: ctx.name, capability: ctx.positionals[2], grantsFile: ctx.grantsFile, cfg: ctx.cfg, env: ctx.env, cwd: ctx.cwd, + name: ctx.name, capability: ctx.positionals[2], grantsFile: ctx.grantsFile, cfg: ctx.cfg, env: ctx.env, cwd: ctx.cwd, flags: ctx.flags, ...(ctx.saveConfig ? { saveConfig: ctx.saveConfig } : {}), ...(ctx.bootstrapAdapters ? { bootstrapAdapters: ctx.bootstrapAdapters } : {}), ...(ctx.applyRouter ? { applyRouter: ctx.applyRouter } : {}), @@ -505,7 +509,8 @@ export async function run({ if (failSafe) return failSafe(ctx); if (!flagEnabled(env)) { - fail(`experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`); + const error = `experimental host-adapter surface is disabled — set ${FLAG_ENV_VAR}=1`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); return 2; } @@ -514,6 +519,7 @@ export async function run({ const gated = GATED_HANDLERS[sub]; if (gated) return gated(ctx); - fail(`unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance|grant|bless|gate|status|revoke-grant)`); + const error = `unknown host adapters subcommand: ${sub} (list|trust|revoke|conformance|grant|bless|gate|status|revoke-grant)`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); return 2; } diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 73231e87..8f4ec772 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -32,7 +32,7 @@ import { hostManagement, hostEnableCommand, HOST_MANAGEMENT_LABELS, NOT_PARTICIPATING, } from '../../lib/host-management.mjs'; import { - ok, warn, fail, info, dim, bold, yellow, humanOutputToStderr, + ok, warn, fail, info, dim, bold, yellow, humanOutputToStderr, reportFailure, } from '../../lib/output.mjs'; import { repoRoot } from '../../lib/paths.mjs'; import { writeJsonWithBackup } from '../../lib/settings.mjs'; @@ -181,13 +181,19 @@ export const parseFallback = (str) => str.split(';').map((s) => s.trim()).filter return { provider: provider.trim().toLowerCase(), models: models.split(',').map((m) => m.trim()).filter(Boolean) }; }); -export async function run({ flags, positionals, pkgRoot }) { +export async function run({ flags, positionals, pkgRoot, deps = { hostLifecycle: undefined } }) { const sub = positionals[0] ?? 'status'; const cwd = process.cwd(); + if (['status', 'off', 'pick', 'reset-routes', 'align'].includes(sub) && positionals.length > 1) { + const error = `unexpected argument '${positionals[1]}'`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); + return 2; + } + if (sub === 'status') return status({ flags, cwd }); if (sub === 'off') return off({ cwd, pkgRoot, flags }); - if (sub === 'pick') return pick({ flags, cwd, pkgRoot }); + if (sub === 'pick') return pick({ flags, cwd, pkgRoot, deps }); if (sub === 'reset-routes') return resetRoutes({ flags, cwd }); if (sub === 'align') return (await import('./host-align.mjs')).run({ flags }); if (sub === 'adapters') { @@ -196,12 +202,17 @@ export async function run({ flags, positionals, pkgRoot }) { // read-only preview to give --dry-run, so it is refused outright // instead of silently behaving like a real run: a flag we declare is a // flag we honor, or refuse. - if (flags['dry-run']) { fail('ak host adapters has no preview; run it without --dry-run'); return 2; } + if (flags['dry-run']) { + const error = 'ak host adapters has no preview; run it without --dry-run'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); + return 2; + } return (await import('./host-adapters.mjs')).run({ flags, positionals: positionals.slice(1) }); } if (sub === 'check-connection') return (await import('./host-connection.mjs')).run({ flags, positionals: positionals.slice(1) }); - fail(`unknown host subcommand: ${sub} (status|pick|reset-routes|off|check-connection|adapters|align)`); + const error = `unknown host subcommand: ${sub} (status|pick|reset-routes|off|check-connection|adapters|align)`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => fail(error) }); return 2; } @@ -322,7 +333,7 @@ const STATUS_SECTIONS = [ async function status({ flags, cwd }) { const cfg = loadKitConfig(); - const facts = await collectIntegrationFacts({ cwd, cfg }); + const facts = await collectIntegrationFacts({ cwd, cfg, record: !flags['dry-run'] }); const hosts = facts.hosts; const providers = facts.providers; const { scope } = settingsTarget(cwd); @@ -378,27 +389,38 @@ async function resetRoutes({ flags, cwd }) { const cfg = loadKitConfig(); const policy = cfg.routing?.routes ?? {}; const diverged = divergedRoutes(policy); - if (!diverged.length) { ok('no seeded routes diverge from the current defaults'); return 0; } - - console.log(bold('seeded routes that diverge from current defaults')); - for (const d of diverged) { - const head = d.modelDiverged ? `${d.model} → ${d.defaultModel}` : d.model; - console.log(` ${d.activity.padEnd(18)} ${d.host.padEnd(7)} ${head}`); - for (const e of d.escalation) console.log(` ${dim('escalation:')} ${e.model} → ${e.defaultModel}`); - // The trade, not just the ids: choosing on a price axis while paying on a - // turns axis is the misreading this whole surface exists to prevent. - if (d.modelDiverged && d.currentNote) console.log(dim(` now: ${d.model} — ${d.currentNote}`)); - if (d.modelDiverged && d.defaultNote) console.log(dim(` default: ${d.defaultModel} — ${d.defaultNote}`)); - for (const e of d.escalation) { - const note = modelNote(e.defaultModel); - if (note) console.log(dim(` default: ${e.defaultModel} — ${note}`)); + const jsonDryRun = flags['dry-run'] && flags.json; + const previewJson = (activities) => console.log(JSON.stringify({ dryRun: true, activities }, null, 2)); + if (!diverged.length) { + if (jsonDryRun) previewJson([]); + else ok('no seeded routes diverge from the current defaults'); + return 0; + } + + if (!jsonDryRun) { + console.log(bold('seeded routes that diverge from current defaults')); + for (const d of diverged) { + const head = d.modelDiverged ? `${d.model} → ${d.defaultModel}` : d.model; + console.log(` ${d.activity.padEnd(18)} ${d.host.padEnd(7)} ${head}`); + for (const e of d.escalation) console.log(` ${dim('escalation:')} ${e.model} → ${e.defaultModel}`); + // The trade, not just the ids: choosing on a price axis while paying on a + // turns axis is the misreading this whole surface exists to prevent. + if (d.modelDiverged && d.currentNote) console.log(dim(` now: ${d.model} — ${d.currentNote}`)); + if (d.modelDiverged && d.defaultNote) console.log(dim(` default: ${d.defaultModel} — ${d.defaultNote}`)); + for (const e of d.escalation) { + const note = modelNote(e.defaultModel); + if (note) console.log(dim(` default: ${e.defaultModel} — ${note}`)); + } } } let picked; if (flags.activity !== undefined) { const want = flags.activity.split(',').map((s) => s.trim()).filter(Boolean); - for (const a of want.filter((a) => !ACTIVITIES.includes(a))) warn(`unknown activity '${a}' — ignored`); + for (const a of want.filter((a) => !ACTIVITIES.includes(a))) { + if (jsonDryRun) await humanOutputToStderr(() => warn(`unknown activity '${a}' — ignored`)); + else warn(`unknown activity '${a}' — ignored`); + } picked = want.filter((a) => diverged.some((d) => d.activity === a)); } else if (flags.yes || flags['dry-run']) { // --dry-run never prompts: with nothing else naming a subset, preview the @@ -413,11 +435,18 @@ async function resetRoutes({ flags, cwd }) { ? diverged.map((d) => d.activity) : ans.split(',').map((s) => s.trim()).filter((a) => diverged.some((d) => d.activity === a)); } - if (!picked.length) { info('no routes reset — routes left as they are'); return 0; } + if (!picked.length) { + if (jsonDryRun) previewJson([]); + else info('no routes reset — routes left as they are'); + return 0; + } if (flags['dry-run']) { - info(`would reset ${picked.length} route(s) to the current defaults: ${picked.join(', ')}`); - info('dry run — nothing changed'); + if (jsonDryRun) previewJson(picked); + else { + info(`would reset ${picked.length} route(s) to the current defaults: ${picked.join(', ')}`); + info('dry run — nothing changed'); + } return 0; } @@ -457,11 +486,30 @@ function printOffDryRunSummary(cfg, opts) { if (codexOwned) console.log(' would remove ak-managed Codex MCP wiring'); } -async function off({ cwd, pkgRoot, flags = {} }) { +async function off({ cwd, pkgRoot, flags = /** @type {{ json?: boolean, 'dry-run'?: boolean }} */ ({}) }) { const cfg = loadKitConfig(); if (flags['dry-run']) { - printOffDryRunSummary(cfg, { pkgRoot }); - info('dry run — nothing changed'); + if (flags.json) { + await humanOutputToStderr(() => { + printOffDryRunSummary(cfg, { pkgRoot }); + info('dry run — nothing changed'); + }); + console.log(JSON.stringify({ + dryRun: true, + wouldDisable: HOSTS.filter((h) => cfg.integrations.hosts[h.id]).map((h) => h.id), + primaryHost: DEFAULT_PRIMARY_HOST, + wouldClear: ['aqe provider/fallback', 'ruflo providers', 'activity routing'], + wouldStripManagedProviderEnv: true, + wouldRestoreOrRemoveManagedAqeConfig: true, + wouldReconcileOpencodeGuidance: !!pkgRoot, + wouldTeardownOpencode: !!(cfg.integrations.hosts.opencode || cfg.integrations?.ownership?.opencode), + wouldRemoveManagedCodexMcp: cfg.integrations?.ownership?.codex?.mcp === 'ak' + || cfg.integrations?.ownership?.codex?.reverseMcp === 'ak', + }, null, 2)); + } else { + printOffDryRunSummary(cfg, { pkgRoot }); + info('dry run — nothing changed'); + } return 0; } const codexMcpManaged = cfg.integrations?.ownership?.codex?.mcp === 'ak'; @@ -715,8 +763,9 @@ async function resolvePickDecision(cfg, { const known = new Set([...MANAGED_HOSTS, ...EFFECTIVE_ROUTING]); const unknown = enabled.filter((h) => !known.has(h)); if (unknown.length) { - fail(`unknown host(s): ${unknown.join(', ')} (valid: ${[...known].join(', ')}) — nothing changed`); - return { code: 2 }; + const error = `unknown host(s): ${unknown.join(', ')} (valid: ${[...known].join(', ')}) — nothing changed`; + fail(error); + return { code: 2, error }; } // The routing set needs at least one primary-capable member; OpenCode remains // routable but cannot satisfy that primary-host invariant on its own. @@ -869,25 +918,28 @@ async function retireCodexOnDisable(cfg, cwd, { codexMcpManaged, rufloCodexManag /** Install any enabled host that is entirely absent (external installs * untouched). Unlike setup's install loop, pick never prompts first — the * user already confirmed the trust manifest for this exact enable. */ -async function installPickAbsentHosts(cfg, cwd) { +export async function installPickAbsentHosts(cfg, cwd, lifecycle = {}) { + const { installState, install, collectFacts } = { + installState: hostInstallState, install: installHost, collectFacts: collectIntegrationFacts, ...lifecycle, + }; let installed = false; for (const h of HOSTS) { if (!cfg.integrations.hosts[h.id]) continue; - if ((await hostInstallState(h)).method !== 'absent') continue; + if ((await installState(h)).method !== 'absent') continue; info(`${h.id} not installed — installing ${h.pkg}…`); - const r = await installHost(h.id); + const r = await install(h.id); (r.ok ? ok : warn)(`${h.id}: ${r.detail}`); if (r.ok) { installed = true; // hostInstallState() above already recorded the pre-install 'absent' // evidence; re-probe now so a subsequent `ak status` doesn't read that // stale row back. - await hostInstallState(h, { refresh: true, record: true, source: 'host-pick' }); + await installState(h, { refresh: true, record: true, source: 'host-pick' }); } } // host-setup covers every host in one call; refresh it once after the // loop, not per host, once anything actually changed. - if (installed) await collectIntegrationFacts({ cwd, cfg, refresh: true, record: true, source: 'host-pick' }); + if (installed) await collectFacts({ cwd, cfg, refresh: true, record: true, source: 'host-pick' }); } /** opencode enable half: apply the same owner-module stack setup/sync use — @@ -1058,7 +1110,7 @@ async function resolvePickIntentAndPreview({ aqeProviderTypes, aqeChainProviderTypes, }); - if (decision.code !== undefined) return { code: decision.code }; + if (decision.code !== undefined) return decision; const { enabled, routing, primaryHost, aqeProvider, seed, } = decision; @@ -1095,7 +1147,7 @@ async function resolvePickIntentAndPreview({ }; } -export async function pick({ flags, cwd, pkgRoot, migrateRoutes = migrateRetiredRoutesInConfig }) { +export async function pick({ flags, cwd, pkgRoot, deps = { hostLifecycle: undefined }, migrateRoutes = migrateRetiredRoutesInConfig }) { const aqeProviderTypes = aqeSelectableProviderTypes(); const aqeChainProviderTypes = aqeSelectableChainProviderTypes(); const cfg = loadKitConfig(); @@ -1133,6 +1185,7 @@ export async function pick({ flags, cwd, pkgRoot, migrateRoutes = migrateRetired const outcome = jsonDryRun ? await humanOutputToStderr(resolve) : await resolve(); if (outcome.code !== undefined) { if (outcome.json) console.log(JSON.stringify(outcome.json, null, 2)); + else if (jsonDryRun) console.log(JSON.stringify({ error: outcome.error ?? 'host pick refused', exitCode: outcome.code }, null, 2)); return outcome.code; } const { @@ -1145,7 +1198,7 @@ export async function pick({ flags, cwd, pkgRoot, migrateRoutes = migrateRetired } saveKitConfig(cfg); - await installPickAbsentHosts(cfg, cwd); + await installPickAbsentHosts(cfg, cwd, deps.hostLifecycle); const { incompleteTeardown } = await applyPickOpencodeLifecycle(cfg, { pkgRoot, cwd, prevOpencode }); const { router } = await applyPickProviderStack(cfg, cwd, { diff --git a/src/commands/x/reference.mjs b/src/commands/x/reference.mjs index 8c42cd83..d37b40ff 100644 --- a/src/commands/x/reference.mjs +++ b/src/commands/x/reference.mjs @@ -1,7 +1,7 @@ // x reference — inspect (diff) or reconcile (sync) every managed host-guidance target. import { reconcileGuidance } from '../../lib/blocks.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; -import { ok, warn, dim } from '../../lib/output.mjs'; +import { ok, warn, dim, reportFailure } from '../../lib/output.mjs'; export const options = { json: { type: 'boolean', default: false } }; @@ -20,6 +20,11 @@ Examples: export async function run({ flags, positionals, pkgRoot }) { const sub = positionals[0] ?? 'diff'; + if (!['diff', 'sync'].includes(sub) || positionals.length > 1) { + const error = 'usage: ak x reference [diff|sync] [--json]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); + return 2; + } const cfg = loadKitConfig(); const dryRun = sub !== 'sync'; const res = await reconcileGuidance({ diff --git a/src/commands/x/skills.mjs b/src/commands/x/skills.mjs index 08b62044..5dc7f6ac 100644 --- a/src/commands/x/skills.mjs +++ b/src/commands/x/skills.mjs @@ -3,7 +3,7 @@ import path from 'node:path'; import { collectCatalog } from '../../lib/footprint/catalog.mjs'; import { buildSkillMaintenancePlan } from '../../lib/skill-maintenance-plan.mjs'; import { loadKitConfig } from '../../lib/config.mjs'; -import { heading, info, warn, dim } from '../../lib/output.mjs'; +import { heading, info, warn, dim, reportFailure } from '../../lib/output.mjs'; export const options = { project: { type: 'string' }, @@ -27,7 +27,8 @@ Examples: export async function run({ flags, positionals }) { if ((positionals[0] ?? 'plan') !== 'plan' || positionals.length > 1) { - warn('usage: ak x skills plan [--project PATH] [--json]'); + const error = 'usage: ak x skills plan [--project PATH] [--json]'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); return 2; } const project = path.resolve(flags.project ?? process.cwd()); diff --git a/src/commands/x/statusline.mjs b/src/commands/x/statusline.mjs index 13465758..92567c16 100644 --- a/src/commands/x/statusline.mjs +++ b/src/commands/x/statusline.mjs @@ -3,7 +3,7 @@ import { PRESETS, applyCodexStatusline, inspectCodexStatusline, projectionFor, removeCodexStatusline, statuslineDrift, } from '../../lib/codex-statusline.mjs'; -import { ok, info, warn } from '../../lib/output.mjs'; +import { ok, info, warn, reportFailure } from '../../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, @@ -33,7 +33,7 @@ Examples: export async function run({ flags, positionals }) { const cfg = loadKitConfig(); const [target = 'status', choice] = positionals; - if (target === 'status') { + if (target === 'status' && positionals.length <= 1) { const current = inspectCodexStatusline(); const drift = statuslineDrift(cfg); const result = { ownership: cfg.statusline?.codex ?? null, current, drifted: drift.drifted }; @@ -45,8 +45,9 @@ export async function run({ flags, positionals }) { } return 0; } - if (target !== 'codex' || !choice) { - warn('usage: ak x statusline status | codex native | codex extended | codex off'); + if (target !== 'codex' || !choice || positionals.length !== 2) { + const error = 'usage: ak x statusline status | codex native | codex extended | codex off'; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); return 2; } if (choice === 'off') { @@ -63,7 +64,11 @@ export async function run({ flags, positionals }) { ok(`codex status-line management disabled${result.changed ? '; unchanged managed keys removed' : '; user-modified keys preserved'}`); return 0; } - if (!PRESETS[choice]) { warn(`unknown preset '${choice}' (expected native, extended, or off)`); return 2; } + if (!PRESETS[choice]) { + const error = `unknown preset '${choice}' (expected native, extended, or off)`; + reportFailure({ json: flags.json, payload: { error, exitCode: 2 }, human: () => warn(error) }); + return 2; + } if (flags['dry-run']) { info(`[dry-run] apply Codex ${choice} preset at user scope`); return 0; } // Persist ownership first. If the TOML merge then fails, sync retains enough // intent to report/retry it; the inverse ordering could mutate config.toml diff --git a/src/lib/aqe-readiness.mjs b/src/lib/aqe-readiness.mjs index 6c8c204f..91f819c4 100644 --- a/src/lib/aqe-readiness.mjs +++ b/src/lib/aqe-readiness.mjs @@ -24,33 +24,13 @@ export function aqeEmbeddingConfiguration({ packageRoot = aqeRoot(), env = proce } catch { return { status: 'missing-backend', backend: null }; } } -// TEMPORARY (remove once fixed upstream; tracked by pacphi/agentic-kit#240): on a live -// RVF lock, agentic-qe 3.14.3 logs the busy warning, then falls through to a create -// attempt that fails with FsyncFailed; store and lock are untouched (agentic-qe#574). -// 3.14.4 carries agentic-qe#719, a partial fix (it rethrows LockHeld); whether 3.14.4 -// still emits this sequence is unverified, so the rule stays for it. Only that exact -// sequence is contention. -// The middle line is emitted solely by AQE's live-owner quarantine refusal, so a bare -// FsyncFailed, or a lock warning plus FsyncFailed without it, still fails below. The rule -// has no version gate. Remove it and its test when a released agentic-qe fixes -// agentic-qe#574 and that release is the kit floor; #719 alone does not remove it. -const LIVE_OWNER_CONTENTION = [ - /is locked by a live process/, - /is unusable but its lock is held by a live process/, - /FsyncFailed|0x0303/, -]; - export function classifyAqeStartup(result) { if (result.code !== 0) return { status: 'failed', reason: 'AQE command failed' }; const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; - const contention = LIVE_OWNER_CONTENTION.every(line => line.test(output)); - if (!contention && /FsyncFailed|0x0303/.test(output)) return { status: 'failed', reason: 'RVF backend failed' }; + if (/FsyncFailed|0x0303/.test(output)) return { status: 'failed', reason: 'RVF backend failed' }; if (/prewarm failed|Transformer initialization previously failed|Embedding model failed|semantic embedding unavailable/i.test(output)) { return { status: 'degraded', reason: 'Embedding initialization failed' }; } - if (contention) { - return { status: 'busy', reason: 'RVF is held by another live process; its FsyncFailed came from an AQE create attempt during contention (agentic-qe#574), not a storage error; SQLite fallback observed; owner health and RVF integrity unverified' }; - } if (/locked by a live process|lock is held by a live process/.test(output)) { return { status: 'busy', reason: 'RVF is held by another live process; SQLite fallback observed; owner health and RVF integrity unverified' }; } diff --git a/src/lib/exec.mjs b/src/lib/exec.mjs index 6726a0f5..9a30aa6a 100644 --- a/src/lib/exec.mjs +++ b/src/lib/exec.mjs @@ -1,5 +1,5 @@ // Subprocess helpers. Rule (binding, from the plan): NOTHING goes through a -// shell string — execFile with argv arrays only, shell ALWAYS false. +// shell string — spawn with argv arrays only, shell ALWAYS false. // // npm/npx/claude/deja/ruflo/aqe/claude-flow are .cmd shims on Windows, and // Windows' CreateProcess cannot launch a .cmd directly — that historically @@ -7,26 +7,23 @@ // command line to cmd.exe as ONE string (CVE-class: any arg with `&`/`|`/`^` // breaks out into a second command). The actual fix is resolving the shim to // its real file on PATH. Native .com/.exe files run directly. A .cmd shim is -// never passed to execFile: Node does not execute batch files without a shell. -// Instead, its sibling .ps1 shim runs through Windows PowerShell's `-File` -// interface, preserving every caller argument as a separate argv element. -import { execFile, spawn } from 'node:child_process'; +// never passed to spawn: Node does not execute batch files without a shell. +// An exact recognized npm wrapper pair launches its declared public bin with +// Node directly, preserving interactive stdio and literal argv. Other wrappers +// keep their sibling .ps1 through Windows PowerShell's `-File` interface. +import { spawn } from 'node:child_process'; import { AsyncLocalStorage } from 'node:async_hooks'; -import { promisify } from 'node:util'; import fs from 'node:fs'; import path from 'node:path'; import { isWindows } from './paths.mjs'; +import { npmShimInvocation, windowsEnvValue, mergeWindowsEnv } from './windows-npm-shim.mjs'; -const pexecFile = promisify(execFile); const MAX_EXEC_BUFFER = 16 * 1024 * 1024; // A caller that bounds work it does not own (a live check under `ak status // --refresh=live` running the full live-check suite) scopes an AbortSignal // here; every run() inside the scope that passes no signal of its own uses it, -// so a timed-out check's direct child processes stop and its own cleanup still -// runs. The abort signals only that direct child: a process it started keeps -// running (on Windows a .cmd shim's child is PowerShell, so the ruflo or aqe -// node process behind it survives the abort). +// so a timed-out check's entire owned process tree stops and cleanup still runs. const abortScope = new AsyncLocalStorage(); /** Run `fn` with `signal` as the default abort signal for run() calls in it. @@ -41,18 +38,20 @@ const CMD_SHIMS = new Set([ /** Build a shell-free invocation for `cmd`, trying Windows' shim extensions in * PATHEXT order. A native executable is launched directly; a .cmd shim is - * accepted only when its sibling .ps1 and system PowerShell both exist. + * mapped to its own public Node bin only for an exact recognized npm wrapper + * pair; other .cmd wrappers require a sibling .ps1 and system PowerShell. * Falls back to the bare name with resolved:false when no safe target exists. * Exported: the execution adapters spawn these CLIs directly (subprocess.mjs * for claude/codex, opencode.mjs for the serve child, x/ruflo-mcp.mjs for the * Ruflo MCP launcher) and must share the same * resolution `run()`/`have()` use, or readiness passes but launch ENOENTs on - * Windows (swarm review, #88). */ -export function resolveShim(cmd, args = [], { windows = isWindows, env = process.env } = {}) { + * Windows (swarm review, #88). `npmBin:false` retains the PowerShell boundary + * for native diagnostics. */ +export function resolveShim(cmd, args = [], { windows = isWindows, env = process.env, npmBin = true } = {}) { const direct = { command: cmd, args: [...args], resolved: !windows }; if (!windows) return direct; - const systemRoot = env.SystemRoot || env.WINDIR; + const systemRoot = windowsEnvValue(env, 'SystemRoot') || windowsEnvValue(env, 'WINDIR'); const powershell = systemRoot ? path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : null; @@ -64,7 +63,10 @@ export function resolveShim(cmd, args = [], { windows = isWindows, env = process if (ext === '.com' || ext === '.exe' || !ext) { return { command: candidate, args: [...args], resolved: true }; } - if (ext !== '.cmd' || !powershell) return null; + if (ext !== '.cmd') return null; + const npm = npmBin ? npmShimInvocation(candidate, args, env) : null; + if (npm) return npm; + if (!powershell) return null; const script = `${candidate.slice(0, -ext.length)}.ps1`; try { if (!fs.statSync(script).isFile() || !fs.statSync(powershell).isFile()) return null; @@ -80,15 +82,21 @@ export function resolveShim(cmd, args = [], { windows = isWindows, env = process }; if (path.isAbsolute(cmd)) return invocationFor(cmd) ?? direct; - const exts = (env.PATHEXT || '.COM;.EXE;.BAT;.CMD') + const exts = (windowsEnvValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD') .split(';') .map((ext) => ext.trim()) .filter(Boolean); - for (const dir of (env.PATH || env.Path || '').split(path.delimiter)) { + for (const dir of (windowsEnvValue(env, 'PATH') || '').split(path.delimiter)) { if (!dir) continue; for (const ext of exts) { - const invocation = invocationFor(path.join(dir, cmd + ext.toLowerCase())); + const candidate = path.join(dir, cmd + ext.toLowerCase()); + const invocation = invocationFor(candidate); if (invocation) return invocation; + // An earlier custom/unusable .cmd still selects this installation. Do + // not silently switch to a later package because its shim is recognized. + if (ext.toLowerCase() === '.cmd') { + try { if (fs.statSync(candidate).isFile()) return direct; } catch { /* absent */ } + } } } return direct; @@ -142,13 +150,18 @@ export function killProcessTree(child, { platform = process.platform, spawnFn = /** Accumulate one child stream, capped at `maxBuffer` — `execFile` applies its * own cap internally, so the spawn-based path below has to reimplement it. */ function captureStream(stream, encoding, maxBuffer, onOverflow) { - const state = { text: '', overflowed: false }; - stream?.setEncoding?.(encoding); + const chunks = []; + const state = { + bytes: 0, + overflowed: false, + get text() { return Buffer.concat(chunks).toString(encoding); }, + }; stream?.on('data', (chunk) => { if (state.overflowed) return; - state.text += chunk; - if (state.text.length > maxBuffer) { - state.text = state.text.slice(0, maxBuffer); + const remaining = maxBuffer - state.bytes; + chunks.push(chunk.subarray(0, remaining)); + state.bytes += chunk.length; + if (state.bytes > maxBuffer) { state.overflowed = true; onOverflow(); } @@ -156,63 +169,121 @@ function captureStream(stream, encoding, maxBuffer, onOverflow) { return state; } -/** The stdin-feeding, process-group variant of `run()`, used whenever a caller - * passes `opts.input`. Two reasons it exists, both from the security review: - * the payload stays out of argv (SEC-7 — `ps -ww` shows argv to the same user - * here, and `/proc//cmdline` shows it to ANY local user on Linux), and - * the child leads its own process group so the timeout reaps its subprocesses - * (SEC-8). - * - * WHY `spawn` AND NOT `execFile`: execFile forwards only a fixed whitelist of - * options through to spawn, and `detached` is not on it — passing it there is - * silently ignored, and the child stays in the PARENT's process group, where - * `process.kill(-pid)` fails ESRCH and the grandchild survives. Measured, not - * assumed. The timeout is likewise managed here rather than handed to the - * child process API, whose own `timeout` signals only the direct child. */ -function runWithInput(command, args, execOpts, { windows, input }) { +/** `spawn` is required for both paths: execFile silently drops `detached`, so + * its AbortSignal and timeout can only stop the direct child. Input stays on + * stdin and never enters argv. The Windows taskkill completion is awaited + * before returning, even if the direct child closes first. */ +function runOwned(command, args, execOpts, { windows, input }) { const { timeout, encoding, maxBuffer, cwd, env, signal } = execOpts; + if (signal?.aborted) return Promise.resolve({ code: 1, stdout: '', stderr: 'The operation was aborted' }); return new Promise((resolve) => { let child; try { - child = spawn(command, args, { cwd, env, signal, shell: false, detached: !windows }); + child = spawn(command, args, { cwd, env, shell: false, detached: !windows }); } catch (err) { resolve(failureResult(err)); return; } let failure = null; - const abort = (reason) => { failure ??= reason; killGroup(child); }; + let closed = false; + let stopping = Promise.resolve(); + const closePipes = () => { + child.stdout?.destroy(); + child.stderr?.destroy(); + child.stdin?.destroy(); + }; + const incomplete = (reason) => { + failure = `${reason}; incomplete process-tree cleanup`; + closePipes(); + }; + const abort = (reason) => { + if (closed) return; + if (failure) return; + failure = reason; + if (!windows) { killGroup(child); return; } + // A reaped root PID may already name a different process. The owned + // descendant may still hold our pipes, but cannot be safely found by PID. + if (child.exitCode !== null || child.signalCode !== null) { + incomplete(reason); + return; + } + if (!Number.isInteger(child.pid) || child.pid < 1) { + incomplete(reason); + return; + } + stopping = new Promise((done) => { + let killer; + const fallback = (detail) => { + incomplete(`${reason} (${detail})`); + if (child.exitCode === null && child.signalCode === null) { + try { child.kill('SIGKILL'); } catch { /* exited */ } + } + done(); + }; + try { + killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { + stdio: 'ignore', shell: false, + }); + } catch { fallback('taskkill could not start'); return; } + const finish = (code, detail) => { + clearTimeout(deadline); + killer.removeListener('error', onError); + killer.removeListener('close', onClose); + if (code === 0) done(); + else fallback(detail); + }; + const onError = () => finish(null, 'taskkill could not start'); + const onClose = (code) => finish(code, `taskkill exited ${code}`); + const deadline = setTimeout(() => { + try { killer.kill('SIGKILL'); } catch { /* already exited */ } + killer.unref?.(); + finish(null, 'taskkill exceeded cleanup deadline'); + }, 1_000); + killer.once('error', onError); + killer.once('close', onClose); + }); + }; + const onSignal = () => abort('The operation was aborted'); + signal?.addEventListener('abort', onSignal, { once: true }); + if (signal?.aborted) onSignal(); const out = captureStream(child.stdout, encoding, maxBuffer, () => abort('stdout maxBuffer length exceeded')); const errOut = captureStream(child.stderr, encoding, maxBuffer, () => abort('stderr maxBuffer length exceeded')); - const timer = setTimeout(() => abort(`timed out after ${timeout}ms`), timeout); - timer.unref?.(); + const timer = timeout > 0 ? setTimeout(() => abort(`timed out after ${timeout}ms`), timeout) : null; + timer?.unref?.(); child.on('error', (err) => { clearTimeout(timer); - resolve(failureResult(err, out.text, errOut.text)); + signal?.removeEventListener('abort', onSignal); + stopping.then(() => resolve(failureResult(err, out.text, errOut.text))); }); - child.on('close', (code) => { + child.on('close', (code, exitSignal) => { + closed = true; clearTimeout(timer); + signal?.removeEventListener('abort', onSignal); const exitCode = typeof code === 'number' ? code : 1; - resolve({ + stopping.then(() => resolve({ code: failure ? exitCode || 1 : exitCode, stdout: out.text, - stderr: failure ? errOut.text || failure : errOut.text, - }); + stderr: failure ? [errOut.text, failure].filter(Boolean).join('\n') : errOut.text, + ...(exitSignal ? { signal: exitSignal } : {}), + })); }); // A child that exits without reading its input makes this write fail with // EPIPE. That is the child's own non-zero exit to report, not a crash here. child.stdin?.on('error', () => {}); - child.stdin?.end(input); + if (typeof input === 'string') child.stdin?.end(input); + else child.stdin?.end(); }); } /** Run a command; never throws. Returns {code, stdout, stderr}. * `opts.input` (a string) is delivered on the child's stdin instead of argv; - * see `runWithInput` for the two guarantees that carries. */ + * `runOwned` keeps that payload out of the process table. */ export async function run(cmd, args = [], opts = {}) { try { - const env = opts.env ? { ...process.env, ...opts.env } : process.env; const windows = opts.windows ?? isWindows; + const env = opts.env + ? (windows ? mergeWindowsEnv(process.env, opts.env) : { ...process.env, ...opts.env }) : process.env; const invocation = CMD_SHIMS.has(cmd) ? resolveShim(cmd, args, { windows, env }) : { command: cmd, args }; @@ -229,13 +300,9 @@ export async function run(cmd, args = [], opts = {}) { env, shell: false, }; - if (typeof opts.input === 'string') { - return await runWithInput(invocation.command, invocation.args, execOpts, { - windows, input: opts.input, - }); - } - const { stdout, stderr } = await pexecFile(invocation.command, invocation.args, execOpts); - return { code: 0, stdout, stderr }; + return await runOwned(invocation.command, invocation.args, execOpts, { + windows, input: opts.input, + }); } catch (err) { return failureResult(err); } diff --git a/src/lib/file-identity.mjs b/src/lib/file-identity.mjs new file mode 100644 index 00000000..b2f764c3 --- /dev/null +++ b/src/lib/file-identity.mjs @@ -0,0 +1,18 @@ +// Persisted file IDs are decimal strings. A legacy Number is comparable only +// when it was a safe integer; an unsafe Number has already lost information. +export function fileId(value) { + if (typeof value === 'bigint') return value >= 0n ? value.toString() : null; + if (typeof value === 'number') return Number.isSafeInteger(value) && value >= 0 ? String(value) : null; + if (typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value)) return value; + return null; +} + +export function sameFileId(left, right) { + const a = fileId(left); + return a !== null && a === fileId(right); +} + +export function statMtimeMs(stat) { + if (typeof stat.mtimeNs !== 'bigint') return stat.mtimeMs; + return Number(stat.mtimeNs / 1_000_000n) + Number(stat.mtimeNs % 1_000_000n) / 1_000_000; +} diff --git a/src/lib/hook-audit/agentic-dependency-constraints.json b/src/lib/hook-audit/agentic-dependency-constraints.json index ff0af7e5..bac4266e 100644 --- a/src/lib/hook-audit/agentic-dependency-constraints.json +++ b/src/lib/hook-audit/agentic-dependency-constraints.json @@ -501,14 +501,15 @@ "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, "mapping": "mapped", "kitImpact": { "refs": ["lane C: live-lock busy rule", "pacphi/agentic-kit#240"], "files": ["docs/host-support.md"] }, - "adjustment": "agentic-qe 3.14.4 reports the real LockHeld instead of FsyncFailed after a live lock (Windows confirmation 2026-09-28). Re-run the live-owner fixture on macOS and Linux against 3.14.4; if no FsyncFailed follows the live-lock warning, remove the temporary busy rule in src/lib/aqe-readiness.mjs and close pacphi/agentic-kit#240.", - "status": "watching", + "adjustment": "Released agentic-qe 3.14.4 passed disposable live-owner conformance on macOS and Linux: status and the shipped adapter emitted LockHeld without FsyncFailed, and RVF/lock bytes were unchanged. ak retired the exact FsyncFailed-as-busy exception; old 3.14.3 output now fails closed. Ordinary live-lock SQLite fallback remains busy. This is a verified artifact baseline for this rule, not a universal AQE minimum or native Windows conformance. Upstream issue state remains separate from this kit retirement.", + "status": "retired", "constraintIds": [], "history": [ { "date": "2026-09-26", "event": "commented" }, { "date": "2026-09-26", "event": "registered" }, { "date": "2026-09-27", "event": "commented", "note": "#719 fixed FsyncFailed; a small fall-through remains (issuecomment-5863219357)" }, - { "date": "2026-09-28", "event": "commented", "note": "thanked fedor-drmanovic for the Windows 3.14.4 results; ak re-runs its live-owner fixture on macOS and Linux next (issuecomment-5878041570)" } + { "date": "2026-09-28", "event": "commented", "note": "thanked fedor-drmanovic for the Windows 3.14.4 results; ak re-runs its live-owner fixture on macOS and Linux next (issuecomment-5878041570)" }, + { "date": "2026-09-29", "event": "retired", "note": "ak retired its exact FsyncFailed-as-busy exception after released 3.14.4 passed native macOS and Linux live-owner probes; ordinary LockHeld fallback remains busy; no native Windows AQE proof or upstream closure claimed" } ] }, { @@ -749,7 +750,7 @@ { "date": "2026-09-26", "event": "registered" }, { "date": "2026-09-27", "event": "retired", "note": "merged and released in 3.14.4; context only: agentic-qe#574, tracked by pacphi/agentic-kit#240, drives removing the busy rule" } ], - "note": "Context only: a partial fix for agentic-qe#574 (rethrows LockHeld). Its release alone does not prove a live lock no longer surfaces FsyncFailed, so agentic-qe#574 drives removing the busy rule and closing pacphi/agentic-kit#240." + "note": "Context only: a partial fix for agentic-qe#574 (rethrows LockHeld), released in 3.14.4. Native macOS and Linux conformance against that artifact later showed no FsyncFailed under a live lock; ak retired the exact exception on 2026-09-29. Native Windows AQE conformance and pacphi/agentic-kit#240 closure remain separate." }, { "id": "proffesor-for-testing/agentic-qe#734", @@ -1128,7 +1129,8 @@ { "date": "2026-09-26", "event": "commented", "note": "tested the single-threaded ONNX Runtime mitigation on Apple Silicon: inconclusive, the abort does not reproduce locally (issuecomment-5850382245)" }, { "date": "2026-09-27", "event": "commented", "note": "hosted probe run 36333572972 on Ruflo 3.46.1: 10/10 aborts by default and 10/10 with single-threaded ONNX Runtime sessions; mitigation disproven (issuecomment-5857781254)" }, { "date": "2026-09-27", "event": "commented", "note": "3.47.0 status, transformers 3.x / ONNX Runtime 1.21 lead, fix approaches; 11/11 nightly aborts in the continue-on-error step (issuecomment-5863246672)" }, - { "date": "2026-09-28", "event": "commented", "note": "thanked vidaunited for the trace hook; 12 of 12 macOS nightly aborts on 3.47.0, with a new WASM backend line before the abort (issuecomment-5878044230)" } + { "date": "2026-09-28", "event": "commented", "note": "thanked vidaunited for the trace hook; 12 of 12 macOS nightly aborts on 3.47.0, with a new WASM backend line before the abort (issuecomment-5878044230)" }, + { "date": "2026-09-29", "event": "commented", "note": "posted the approved source-bound native trace on #2885 (issuecomment-5891510508); the failure occurred after heal, the outer command exited 1, and its learning step remained nonblocking for the job; the trace does not establish a cause or released fix" } ] }, { @@ -1524,7 +1526,8 @@ "history": [ { "date": "2026-09-24", "event": "filed" }, { "date": "2026-09-26", "event": "registered" }, - { "date": "2026-09-27", "event": "commented", "note": "answered from the Codex CLI source (issuecomment-5863289657)" } + { "date": "2026-09-27", "event": "commented", "note": "answered from the Codex CLI source (issuecomment-5863289657)" }, + { "date": "2026-09-29", "event": "reviewed", "note": "still open with only ak's source-analysis comment; no maintainer-supported guidance or live hook proof, so the conditional Codex fix line remains deferred" } ] }, { @@ -2024,15 +2027,16 @@ "dependency": null, "doneWhen": { "state": "closed-completed", "release": null }, "mapping": "mapped", - "kitImpact": { "refs": ["decision 7", "lane C: live-lock busy rule"], "files": ["src/lib/aqe-readiness.mjs"] }, - "adjustment": "Close #240 when a released agentic-qe no longer falls through to create after a live RVF lock (agentic-qe#574) and ak removes the temporary busy rule. agentic-qe#719 (partial fix: rethrows LockHeld) was released in 3.14.4 on 2026-09-27; whether that release alone clears FsyncFailed under a live lock is not yet verified.", + "kitImpact": { "refs": ["decision 7", "lane C: live-lock busy rule"], "files": ["docs/troubleshooting.md"] }, + "adjustment": "After the final main PR merges, the controller can close #240 with the released 3.14.4 native macOS/Linux live-owner evidence and ak's exact exception retirement for agentic-qe#574. The old 3.14.3 FsyncFailed sequence now fails closed; ordinary LockHeld fallback remains busy. No native Windows AQE conformance or upstream issue closure is claimed.", "status": "watching", "constraintIds": [], "tracks": ["proffesor-for-testing/agentic-qe#574", "proffesor-for-testing/agentic-qe#719"], "history": [ { "date": "2026-09-26", "event": "filed" }, { "date": "2026-09-26", "event": "registered" }, - { "date": "2026-09-27", "event": "commented", "note": "moved the upstream part of this tracker to the watch registry; agentic-qe 3.14.4 carries PR 719, not yet verified to stop FsyncFailed under a live lock (issuecomment-5858567140)" } + { "date": "2026-09-27", "event": "commented", "note": "moved the upstream part of this tracker to the watch registry; agentic-qe 3.14.4 carries PR 719, not yet verified to stop FsyncFailed under a live lock (issuecomment-5858567140)" }, + { "date": "2026-09-29", "event": "reviewed", "note": "released 3.14.4 passed native macOS and Linux live-owner conformance and ak retired the exact exception; issue closure waits for final main PR" } ] }, { @@ -2129,10 +2133,10 @@ "kind": "issue", "title": "`agentic-qe mcp` double-spawns the server (stdio:'inherit') and drops stdin → premature shutdown", "dependency": "agentic-qe", - "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, + "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": "3.14.4" } }, "mapping": "mapped", "kitImpact": { "refs": [], "files": ["docs/host-support.md"] }, - "adjustment": "Once a released agentic-qe mcp stops double-spawning the server, drop the host-support.md risk link.", + "adjustment": "The 3.14.4 MCP entry runs in-process; host-support.md records adoption from that version. Keep watching the still-open upstream issue until its closed-completed condition is met.", "status": "watching", "constraintIds": [], "history": [ @@ -2147,10 +2151,10 @@ "kind": "issue", "title": "init: platform installs are additive, not exclusive — always installs Claude Code surface; need per-platform/only mode", "dependency": "agentic-qe", - "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, + "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": "3.14.4" } }, "mapping": "mapped", "kitImpact": { "refs": [], "files": ["docs/host-support.md"] }, - "adjustment": "Once a released agentic-qe init offers an exclusive per-platform mode, drop the host-support.md risk link.", + "adjustment": "The 3.14.4 aqe init --no-claude option supports exclusive platform initialization; host-support.md records adoption from that version. Keep watching the still-open upstream issue until its closed-completed condition is met.", "status": "watching", "constraintIds": [], "history": [ @@ -2168,7 +2172,7 @@ "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, "mapping": "mapped", "kitImpact": { "refs": [], "files": ["docs/host-support.md"] }, - "adjustment": "Once a released agentic-qe fixes the listed MCP tool bugs, drop the host-support.md risk link.", + "adjustment": "Keep the host-support.md risk link limited to the remaining 3.14.4 GOAP maxSteps/world-state, test-generation quality, and coherence recommendation-text gaps. memory_delete and cross-phase stats were fixed; goap_execute was not re-exercised.", "status": "watching", "constraintIds": [], "history": [ @@ -2702,8 +2706,8 @@ "dependency": "agentic-qe", "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, "mapping": "mapped", - "kitImpact": {"refs": ["ak runs aqe init --with-codex, not platform setup", "the codex-mcp status row's manual fix names aqe platform setup codex (src/commands/status/sections/codex-mcp.mjs)"], "files": []}, - "adjustment": "none: ak does not run platform setup; once fixed, the codex-mcp status row's manual fix (aqe platform setup codex --overwrite --with-ruflo) works as written.", + "kitImpact": {"refs": ["ak runs aqe init --with-codex, not platform setup", "the codex-mcp status row formerly named the broken platform setup command (src/commands/status/sections/codex-mcp.mjs)"], "files": []}, + "adjustment": "The codex-mcp status row now points to AQE's supported aqe init --auto --with-codex --codex-guidance compact flow and notes the separate 3.14.4 Codex asset gap (agentic-qe#755). ak does not run platform setup or write AQE's Codex registration.", "status": "fixed-unreleased", "constraintIds": [], "history": [ @@ -2749,6 +2753,24 @@ { "date": "2026-09-27", "event": "registered" }, { "date": "2026-09-28", "event": "closed", "note": "completed by PR 766; no release contains it yet" } ] + }, + { + "id": "proffesor-for-testing/agentic-qe#778", + "url": "https://github.com/proffesor-for-testing/agentic-qe/issues/778", + "relation": "filed", + "kind": "issue", + "title": "fix: repeated aqe init changes generated settings on an unchanged project", + "dependency": "agentic-qe", + "doneWhen": { "state": "closed-completed", "release": { "channel": "npm", "name": "agentic-qe", "minVersion": null } }, + "mapping": "mapped", + "kitImpact": { "refs": ["ak setup invokes aqe init and can inherit its repeat-run settings churn"], "files": [] }, + "adjustment": "AQE 3.14.4 repeated init rewrites .claude/settings.json, changes domain and learning defaults on the second run, and creates a backup; CLAUDE.md and AGENTS.md hashes and mtimes stayed unchanged. Keep setup convergence claims bounded until a released fix is verified; 3.14.5 behavior has not been established.", + "status": "watching", + "constraintIds": [], + "history": [ + { "date": "2026-09-29", "event": "filed", "note": "approved exact draft posted; remote title and body match receipt" }, + { "date": "2026-09-29", "event": "registered", "note": "3.14.4 disposable project repro: settings changed on all three runs; guidance hashes and mtimes did not" } + ] } ] } diff --git a/src/lib/hook-remediation/engine.mjs b/src/lib/hook-remediation/engine.mjs index 767b449e..2bbfa475 100644 --- a/src/lib/hook-remediation/engine.mjs +++ b/src/lib/hook-remediation/engine.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { sameFileId } from '../file-identity.mjs'; import { assertHookHealingPlanIntegrity, buildHookHealingPlan, @@ -27,8 +28,8 @@ function sameOwnerAndParent(snapshot, expected) { && snapshot.gid === (expected.gid ?? snapshot.gid) && snapshot.specialMode === (expected.specialMode ?? 0) && snapshot.parent.realPath === (expected.parent?.realPath ?? snapshot.parent.realPath) - && snapshot.parent.dev === (expected.parent?.dev ?? snapshot.parent.dev) - && snapshot.parent.ino === (expected.parent?.ino ?? snapshot.parent.ino); + && sameFileId(snapshot.parent.dev, expected.parent?.dev ?? snapshot.parent.dev) + && sameFileId(snapshot.parent.ino, expected.parent?.ino ?? snapshot.parent.ino); } function preflight(plan, actionIds, expectedPlanDigest, options) { diff --git a/src/lib/hook-remediation/fs-port.mjs b/src/lib/hook-remediation/fs-port.mjs index 90a6ad9f..42a9ae5c 100644 --- a/src/lib/hook-remediation/fs-port.mjs +++ b/src/lib/hook-remediation/fs-port.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { randomBytes } from 'node:crypto'; import { MAX_AUDIT_SOURCE_BYTES, sha256 } from '../hook-audit/common.mjs'; +import { fileId, sameFileId, statMtimeMs } from '../file-identity.mjs'; export const MAX_HOOK_TARGET_BYTES = MAX_AUDIT_SOURCE_BYTES; @@ -44,29 +45,27 @@ export function inspectHookTarget(file, containmentRoot, { const realFile = fsImpl.realpathSync(file); if (!contained(realRoot, realFile, platform)) throw new Error('target escapes its containment root'); descriptor = fsImpl.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); - const opened = fsImpl.fstatSync(descriptor); + const opened = fsImpl.fstatSync(descriptor, { bigint: true }); if (!opened.isFile() || opened.size > maxBytes) throw new Error('opened target is not a bounded regular file'); - // BigInt identity: Windows file IDs can exceed 2^53; `opened` stays Number for the image. - const openedId = fsImpl.fstatSync(descriptor, { bigint: true }); - if (openedId.dev !== stat.dev || openedId.ino !== stat.ino) { + if (opened.dev !== stat.dev || opened.ino !== stat.ino) { throw new Error('target identity changed between inspection and open'); } const reopened = fsImpl.realpathSync(file); if (reopened !== realFile || !contained(realRoot, reopened, platform)) { throw new Error('target path changed between inspection and open'); } - const bytes = readDescriptor(fsImpl, descriptor, opened.size); + const bytes = readDescriptor(fsImpl, descriptor, Number(opened.size)); const parent = path.dirname(realFile); - const parentStat = fsImpl.statSync(parent); + const parentStat = fsImpl.statSync(parent, { bigint: true }); return { file: path.resolve(file), containmentRoot: path.resolve(containmentRoot), realFile, realRoot, bytes, sha256: sha256(bytes), size: bytes.length, - mode: platform === 'win32' ? null : opened.mode & 0o777, - modeSupported: platform !== 'win32', mtimeMs: opened.mtimeMs, - uid: typeof opened.uid === 'number' ? opened.uid : null, - gid: typeof opened.gid === 'number' ? opened.gid : null, - specialMode: platform === 'win32' ? 0 : opened.mode & 0o7000, - parent: { realPath: parent, dev: parentStat.dev, ino: parentStat.ino }, + mode: platform === 'win32' ? null : Number(opened.mode & 0o777n), + modeSupported: platform !== 'win32', mtimeMs: statMtimeMs(opened), + uid: typeof opened.uid === 'bigint' ? Number(opened.uid) : null, + gid: typeof opened.gid === 'bigint' ? Number(opened.gid) : null, + specialMode: platform === 'win32' ? 0 : Number(opened.mode & 0o7000n), + parent: { realPath: parent, dev: fileId(parentStat.dev), ino: fileId(parentStat.ino) }, }; } finally { if (descriptor !== undefined) fsImpl.closeSync(descriptor); @@ -134,9 +133,10 @@ export function atomicReplaceHookTarget(snapshot, bytes, desiredMode = snapshot. || current.specialMode !== snapshot.specialMode) { throw new Error(`target changed immediately before replacement: ${snapshot.file}`); } - const currentParent = fsImpl.statSync(path.dirname(current.realFile)); + const currentParent = fsImpl.statSync(path.dirname(current.realFile), { bigint: true }); if (current.parent.realPath !== snapshot.parent.realPath - || currentParent.dev !== snapshot.parent.dev || currentParent.ino !== snapshot.parent.ino) { + || !sameFileId(currentParent.dev, snapshot.parent.dev) + || !sameFileId(currentParent.ino, snapshot.parent.ino)) { throw new Error(`target parent changed immediately before replacement: ${snapshot.file}`); } const suffix = randomBytes(12).toString('hex'); diff --git a/src/lib/hook-remediation/store.mjs b/src/lib/hook-remediation/store.mjs index 32a91cf7..d4214eb4 100644 --- a/src/lib/hook-remediation/store.mjs +++ b/src/lib/hook-remediation/store.mjs @@ -3,6 +3,7 @@ import path from 'node:path'; import { randomBytes } from 'node:crypto'; import { MAX_AUDIT_SOURCE_BYTES, sha256, stableJson } from '../hook-audit/common.mjs'; +import { fileId } from '../file-identity.mjs'; export const HOOK_HEAL_RECEIPT_SCHEMA = 'hook-heal-receipt/v1'; const RECEIPT_ID = /^tx-[0-9TZ.-]+-[a-f0-9]{16}$/; @@ -202,7 +203,7 @@ function validImage(image, { preimage = false } = {}) { && (image.gid === null || Number.isInteger(image.gid)) && Number.isInteger(image.specialMode) && image.specialMode >= 0 && image.specialMode <= 0o7000 && path.isAbsolute(image.parent?.realPath ?? '') - && Number.isInteger(image.parent?.dev) && Number.isInteger(image.parent?.ino) + && fileId(image.parent?.dev) !== null && fileId(image.parent?.ino) !== null )); } diff --git a/src/lib/host-alignment.mjs b/src/lib/host-alignment.mjs index ab9867c9..b1757f5c 100644 --- a/src/lib/host-alignment.mjs +++ b/src/lib/host-alignment.mjs @@ -4,6 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; +import { fileId } from './file-identity.mjs'; import { writeFileWithBackup } from './file-write.mjs'; import { inspectCodexTomlStructure, isTomlTableLine } from './codex-toml-safety.mjs'; import { enabledPluginRefs } from './codex-plugins.mjs'; @@ -58,13 +59,13 @@ function uniqueJson(source) { } function readSource(file) { - const stat = fs.lstatSync(file); + const stat = fs.lstatSync(file, { bigint: true }); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 2 * 1024 * 1024) throw new Error('configuration is not a bounded regular file'); const bytes = fs.readFileSync(file); const source = bytes.toString('utf8'); if (!bytes.equals(Buffer.from(source))) throw new Error('configuration is not UTF-8'); - return { file, source, digest: hash(bytes), mode: stat.mode & 0o777, - identity: { real: fs.realpathSync(file), device: stat.dev, inode: stat.ino, mode: stat.mode } }; + return { file, source, digest: hash(bytes), mode: Number(stat.mode & 0o777n), + identity: { real: fs.realpathSync(file), device: fileId(stat.dev), inode: fileId(stat.ino), mode: Number(stat.mode) } }; } function safeJsonTransport(entry) { diff --git a/src/lib/host-health-evidence.mjs b/src/lib/host-health-evidence.mjs index 600b17ed..2d2cd983 100644 --- a/src/lib/host-health-evidence.mjs +++ b/src/lib/host-health-evidence.mjs @@ -4,6 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { createHmac, randomBytes } from 'node:crypto'; import { hostHealthInputPaths } from './paths.mjs'; +import { fileId, statMtimeMs } from './file-identity.mjs'; export function createHostHealthSnapshot({ secret = randomBytes(32), env = process.env, inputPaths = hostHealthInputPaths } = {}) { return ({ cwd, cfg }) => { @@ -35,9 +36,9 @@ export function createHostHealthSnapshot({ secret = randomBytes(32), env = proce for (const dir of (env.PATH ?? '').split(path.delimiter).slice(0, 256)) { const file = path.resolve(dir, host + (process.platform === 'win32' ? '.cmd' : '')); try { - const st = fs.statSync(file); + const st = fs.statSync(file, { bigint: true }); if (!st.isFile()) continue; - hash.update(JSON.stringify([host, fs.realpathSync(file), st.size, st.mtimeMs, st.ino])); + hash.update(JSON.stringify([host, fs.realpathSync(file), Number(st.size), statMtimeMs(st), fileId(st.dev), fileId(st.ino)])); break; } catch { /* next PATH entry */ } } diff --git a/src/lib/live-check-evidence.mjs b/src/lib/live-check-evidence.mjs index 3ec1448f..c3143f03 100644 --- a/src/lib/live-check-evidence.mjs +++ b/src/lib/live-check-evidence.mjs @@ -30,8 +30,10 @@ import { warn } from './output.mjs'; // The checks whose results are remembered: the quick, free live checks. One // list, owned by the refresh vocabulary (a constants-only import). import { LIVE_CHECK_IDS } from './refresh.mjs'; +import { installedRoutingVersion } from './ruflo-memory-contract.mjs'; export { LIVE_CHECK_IDS }; +export const RECORDED_CHECK_IDS = Object.freeze([...LIVE_CHECK_IDS, 'memory-routes']); const STATUSES = new Set(['passed', 'failed', 'inconclusive']); /** The sources a record is written with, and the ones it may still be read with. */ const WRITE_SOURCES = new Set(['sync', 'status-refresh-live']); @@ -45,7 +47,7 @@ const REASON_MAX = 200; export const liveCheckDir = () => path.join(paths.evidenceDir(), 'live-check'); function assertKnownId(id) { - if (!LIVE_CHECK_IDS.includes(id)) throw new TypeError(`unknown live check id: ${String(id).slice(0, 40)}`); + if (!RECORDED_CHECK_IDS.includes(id)) throw new TypeError(`unknown live check id: ${String(id).slice(0, 40)}`); } /** Printable, single-line, bounded. Stored reasons come from the kit's own @@ -141,18 +143,22 @@ const INPUTS = { security: () => ({ ruflo: rufloVersion() }), 'deja-vu': ({ cfg }) => ({ dejaVu: cfg.integrations?.tools?.dejaVu ?? null }), memory: () => ({ ruflo: rufloVersion() }), + 'memory-routes': ({ routingVersion, platform }) => ({ + routingVersion: routingVersion === undefined ? installedRoutingVersion() : routingVersion, + platform: platform ?? process.platform, + }), }; /** * The inputs key a check ran against. Writers and readers MUST both call this * so the same configuration yields the same key. Never throws. * @param {string} id - * @param {{cfg?:any,env?:NodeJS.ProcessEnv,cwd?:string}} [facts] + * @param {{cfg?:any,env?:NodeJS.ProcessEnv,cwd?:string,routingVersion?:string|null,platform?:string}} [facts] */ -export function liveCheckInputsKey(id, { cfg = {}, env = process.env, cwd = process.cwd() } = {}) { +export function liveCheckInputsKey(id, { cfg = {}, env = process.env, cwd = process.cwd(), routingVersion, platform } = {}) { assertKnownId(id); let parts; - try { parts = INPUTS[id]({ cfg: cfg ?? {}, env, cwd }); } catch { parts = { unavailable: true }; } + try { parts = INPUTS[id]({ cfg: cfg ?? {}, env, cwd, routingVersion, platform }); } catch { parts = { unavailable: true }; } return digest({ id, ...parts }); } @@ -181,12 +187,12 @@ export function embeddingProbeOutcome(live) { * when it could not be remembered. Returns whether it was recorded. * @param {string} id * @param {{status:string,reason?:string|null}|null} outcome - * @param {{source:string,cfg?:any,env?:NodeJS.ProcessEnv,cwd?:string,now?:number}} context + * @param {{source:string,cfg?:any,env?:NodeJS.ProcessEnv,cwd?:string,now?:number,inputsKey?:string}} context */ -export function rememberLiveCheck(id, outcome, { source, cfg, env, cwd, now } = /** @type {any} */ ({})) { +export function rememberLiveCheck(id, outcome, { source, cfg, env, cwd, now, inputsKey } = /** @type {any} */ ({})) { if (!outcome) return false; const recorded = recordLiveCheck({ id, status: outcome.status, reason: outcome.reason ?? null, source, - inputsKey: liveCheckInputsKey(id, { cfg, env, cwd }) }, { now }); + inputsKey: inputsKey ?? liveCheckInputsKey(id, { cfg, env, cwd }) }, { now }); if (!recorded) warn(`${id}: could not remember this live check result; ak status will not show it`); return recorded; } diff --git a/src/lib/live-checks.mjs b/src/lib/live-checks.mjs index 2aaab40c..9553008a 100644 --- a/src/lib/live-checks.mjs +++ b/src/lib/live-checks.mjs @@ -31,7 +31,7 @@ import { runHarvest } from './harvest.mjs'; import { runLifecycle } from './adapters/lifecycle.mjs'; import { companionLifecycleFor } from './adapters/companion-lifecycle-registry.mjs'; import { ok, warn, fail, info, heading, captureOutput } from './output.mjs'; -import { rememberLiveCheck, embeddingProbeOutcome } from './live-check-evidence.mjs'; +import { rememberLiveCheck, liveCheckInputsKey, embeddingProbeOutcome } from './live-check-evidence.mjs'; import { LIVE_CHECK_IDS, SLOW_PROOF_IDS } from './refresh.mjs'; export { LIVE_CHECK_IDS, SLOW_PROOF_IDS }; @@ -107,9 +107,11 @@ export async function observeProjectMemoryRoutes(tmp, env, namespace, deps) { try { const observation = await probeProjectMemoryRoutes(tmp, env, namespace, deps); for (const { level, message } of describeMemoryRoutes(observation)) (level === 'ok' ? ok : warn)(message); + return observation; } catch (e) { // An observation problem must never turn a working CLI proof into a failure. warn(`cross-interface routing not observed: ${e.message}`); + return null; } } @@ -132,11 +134,12 @@ async function purgeProofNamespace(tmp, env, namespace, key, runner = runCmd) { /** The memory round trip in an isolated folder under `tmpRoot`, removed * whatever happens. `observeRoutes: false` keeps the quick `memory` check to * the CLI round trip: the route observation (the `memory-routes` proof) - * starts a real MCP server and can only add warnings, which a live-check - * record does not carry. - * @param {{ observeRoutes?: boolean, tmpRoot?: string, runner?: typeof runCmd, haveCmd?: typeof have }} [options] */ + * starts a real MCP server. Its observation has separate evidence, while + * the CLI round-trip evidence remains under `memory`. + * @param {{ observeRoutes?: boolean, routeVerdict?: boolean, onCliOutcome?: (outcome:{status:string,reason:null})=>void, + * tmpRoot?: string, runner?: typeof runCmd, haveCmd?: typeof have }} [options] */ export async function verifyMemory({ - observeRoutes = true, tmpRoot = os.tmpdir(), runner = runCmd, haveCmd = have, + observeRoutes = true, routeVerdict = false, onCliOutcome, tmpRoot = os.tmpdir(), runner = runCmd, haveCmd = have, } = {}) { heading('memory — store, retrieve, locate the on-disk row, purge, and observe CLI/MCP routing in an isolated dir'); if (!(await haveCmd('ruflo'))) { fail('ruflo CLI not installed — cannot prove project memory'); return false; } @@ -182,17 +185,29 @@ export async function verifyMemory({ purged = await purgeProofNamespace(tmp, env, namespace, key, runner); if (!purged) { fail('isolated namespace purge did not remove the proof row'); return false; } ok('isolated proof namespace purged'); - if (observeRoutes) await observeProjectMemoryRoutes(tmp, env, namespace); + onCliOutcome?.({ status: 'passed', reason: null }); + if (observeRoutes) { + const route = /** @type {{status?:string,cliToMcp?:string,mcpToCli?:string}|null} */ + (await observeProjectMemoryRoutes(tmp, env, namespace)); + if (routeVerdict) return route?.status === 'observed' && + ['visible', 'not-visible'].includes(route.cliToMcp) && + ['visible', 'not-visible'].includes(route.mcpToCli) + ? { status: 'passed', reason: null } + : { status: 'inconclusive', reason: 'cross-interface routing not observed completely' }; + } return true; } catch (e) { fail(`memory proof error: ${e.message}`); return false; } finally { - if (stored && !purged) { - await runner('ruflo', ['memory', 'purge', '--namespace', namespace, '--force'], - { cwd: tmp, env, timeout: 120_000 }); + try { + if (stored && !purged) { + await runner('ruflo', ['memory', 'purge', '--namespace', namespace, '--force'], + { cwd: tmp, env, timeout: 120_000 }); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); } - fs.rmSync(tmp, { recursive: true, force: true }); } } @@ -451,10 +466,10 @@ async function verifyProjectProviders(root, cfg, { runner, haveCmd }) { * bridge derives agentdb-memory.db from (CLAUDE_FLOW_MEMORY_PATH), and * AGENTDB_PATH. An inherited value of any of them would otherwise receive the * proof rows. Nothing is seeded: the proof is Ruflo's own verbs succeeding. */ -export async function verifyHarvest({ runner = runCmd, haveCmd = have } = {}) { +export async function verifyHarvest({ tmpRoot = os.tmpdir(), runner = runCmd, haveCmd = have } = {}) { heading('harvest — record an outcome and distill, in an isolated store'); if (!(await haveCmd('ruflo'))) { fail('ruflo CLI not installed — cannot prove the harvest write path'); return false; } - const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'agentic-kit-harvest-'))); + const tmp = fs.realpathSync(fs.mkdtempSync(path.join(tmpRoot, 'agentic-kit-harvest-'))); const swarm = path.join(tmp, '.swarm'); // Pinned explicitly: a temporary folder inside a Git checkout would make the // derived project root the enclosing repository. @@ -577,7 +592,7 @@ export async function verifyDejaVu({ const enabled = cfg?.integrations?.tools?.dejaVu?.enabled === true; if (!dejaVuProofApplies(cfg)) { warn('deja-vu disabled and unowned — skipped'); - return true; + return { status: 'skipped', reason: 'disabled and unowned' }; } if (!adapter) { fail('deja-vu lifecycle adapter unavailable'); @@ -637,9 +652,10 @@ const CHECKS = Object.freeze([ // The full AQE proof remembers only its live embedding request, itself, and // only for a backend the kit manages. { ...slow('aqe'), run: ({ cfg, cwd, onEvidence }) => verifyAqe({ cfg, cwd, onEvidence }) }, - // The memory round trip plus the CLI/MCP route observation; its verdict is - // the memory check's. - { ...slow('memory-routes', 'memory'), run: () => verifyMemory({ observeRoutes: true }) }, + // The memory round trip plus the CLI/MCP route observation has distinct + // evidence; a CLI-only pass cannot establish routing. + { ...slow('memory-routes', 'memory-routes'), run: ({ onCliOutcome }) => + verifyMemory({ observeRoutes: true, routeVerdict: true, onCliOutcome }) }, ].map((check) => Object.freeze(check))); /** @@ -673,7 +689,9 @@ const duration = (ms) => (ms < 1000 ? `${ms} ms` : `${Math.round(ms / 1000)} s`) async function runOneLiveCheck(check, ctx, { timeoutMs, graceMs }) { const controller = new AbortController(); const started = Date.now(); - const work = captureOutput(() => withAbortSignal(controller.signal, () => check.run(ctx))) + let cliOutcome = null; + const work = captureOutput(() => withAbortSignal(controller.signal, + () => check.run({ ...ctx, onCliOutcome: (outcome) => { cliOutcome = outcome; } }))) .then(({ result, entries }) => ({ outcome: checkOutcome(result, entries, check.id), entries }), () => ({ outcome: { status: 'inconclusive', reason: 'the check could not run' }, entries: [] })); const deadline = sleep(timeoutMs); @@ -687,7 +705,8 @@ async function runOneLiveCheck(check, ctx, { timeoutMs, graceMs }) { settled = { outcome: { status: 'inconclusive', reason: `no result within ${duration(timeoutMs)}` }, entries: late?.entries ?? [] }; } const { outcome, entries } = settled; - return { id: check.id, status: outcome.status, reason: outcome.reason ?? null, elapsedMs: Date.now() - started, entries }; + return { id: check.id, status: outcome.status, reason: outcome.reason ?? null, + elapsedMs: Date.now() - started, entries, cliOutcome }; } /** @@ -706,15 +725,28 @@ export async function runLiveChecks({ cfg = loadKitConfig(), cwd = process.cwd(), only = [], checks = liveChecksFor(cfg, only), timeoutMs, graceMs = LIVE_CHECK_GRACE_MS, source = 'status-refresh-live', } = {}) { - const remember = (id, outcome) => rememberLiveCheck(id, outcome, { source, cfg, cwd }); + const remember = (id, outcome, inputsKey) => rememberLiveCheck(id, outcome, { source, cfg, cwd, inputsKey }); const ctx = { cfg, cwd, onEvidence: remember }; + // Capture the installed implementation before any proof starts. A package + // upgrade while the slow check runs cannot turn an old observation into a + // pass for the newly installed CLI. + const routeKeys = checks.map((check) => check.id === 'memory-routes' + ? liveCheckInputsKey('memory-routes', { cfg, cwd }) : null); const results = await Promise.all(checks.map(async (check) => ({ ...(await runOneLiveCheck(check, ctx, { timeoutMs: timeoutMs ?? check.timeoutMs ?? QUICK_TIMEOUT_MS, graceMs })), applies: check.applies?.(cfg ?? {}) ?? true, }))); checks.forEach((check, i) => { const evidenceId = check.evidenceId === undefined ? check.id : check.evidenceId; - if (evidenceId && results[i].applies) remember(evidenceId, results[i]); + if (!results[i].applies || results[i].status === 'skipped') return; + if (check.id === 'memory-routes') { + remember('memory', results[i].cliOutcome ?? results[i]); + if (routeKeys[i] !== liveCheckInputsKey('memory-routes', { cfg, cwd })) { + results[i].status = 'inconclusive'; + results[i].reason = 'installed routing implementation changed during the check'; + } + remember('memory-routes', results[i], routeKeys[i]); + } else if (evidenceId) remember(evidenceId, results[i]); }); - return results; + return results.map(({ cliOutcome: _cliOutcome, ...result }) => result); } diff --git a/src/lib/live/jsonl-tailer.mjs b/src/lib/live/jsonl-tailer.mjs index 8a3c3663..610abc44 100644 --- a/src/lib/live/jsonl-tailer.mjs +++ b/src/lib/live/jsonl-tailer.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import { StringDecoder } from 'node:string_decoder'; +import { fileId } from '../file-identity.mjs'; const limit = (value, ceiling) => Number.isSafeInteger(value) && value > 0 ? Math.min(value, ceiling) : ceiling; @@ -70,7 +71,7 @@ export class JsonlTailer { reconcile() { if (!this.canRead(this.#file)) return; let stat; - try { stat = fs.statSync(this.#file); } catch (error) { + try { stat = fs.statSync(this.#file, { bigint: true }); } catch (error) { if (error.code === 'ENOENT') this.#absent(); else this.#unreadable(error); return; @@ -82,31 +83,32 @@ export class JsonlTailer { })); return; } - const identity = `${stat.dev}:${stat.ino}`; + const identity = `${fileId(stat.dev)}:${fileId(stat.ino)}`; + const size = Number(stat.size); if (this.#identity == null) { this.#identity = identity; // A file that appeared after tailing began holds only new records, so // neither a resume offset nor startAtEnd may skip any of it. if (this.#absentSeen) this.#offset = 0; - else if (this.startOffset != null) this.#offset = Math.min(this.startOffset, stat.size); - else if (this.startAtEnd) this.#offset = stat.size; - } else if (this.#identity !== identity || stat.size < this.#offset) { + else if (this.startOffset != null) this.#offset = Math.min(this.startOffset, size); + else if (this.startAtEnd) this.#offset = size; + } else if (this.#identity !== identity || size < this.#offset) { this.#identity = identity; this.#resetPosition(); } - if (stat.size <= this.#offset) { + if (size <= this.#offset) { // Nothing new to read. Prove readability anyway when it is not yet known // (or was lost), so a mode-000 file cannot pass as healthy by staying // the same size. if (this.#presence !== 'readable') this.#probeReadable(); - this.#coverage(stat.size); + this.#coverage(size); return; } try { const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0); const fd = fs.openSync(this.#file, flags); try { - let remaining = Math.min(stat.size - this.#offset, this.maxReadBytes); + let remaining = Math.min(size - this.#offset, this.maxReadBytes); const bytes = Buffer.alloc(Math.min(remaining, this.maxChunkBytes)); while (remaining > 0) { const count = fs.readSync(fd, bytes, 0, Math.min(bytes.length, remaining), this.#offset); @@ -118,7 +120,7 @@ export class JsonlTailer { } finally { fs.closeSync(fd); } this.#presence = 'readable'; } catch (error) { this.#unreadable(error); } - this.#coverage(stat.size); + this.#coverage(size); } #resetPosition() { diff --git a/src/lib/live/transcript-streams.mjs b/src/lib/live/transcript-streams.mjs index 32dee5a4..32a41201 100644 --- a/src/lib/live/transcript-streams.mjs +++ b/src/lib/live/transcript-streams.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileId } from '../file-identity.mjs'; import { JsonlTailer } from './jsonl-tailer.mjs'; import { LiveReplayStream } from './replay-stream.mjs'; import { @@ -155,12 +156,13 @@ class TranscriptStream { this.#host = host; this.#sessionId = sessionId; this.#options = options; - const epoch = fs.statSync(file).ino; + const stat = fs.statSync(file, { bigint: true }); + const epoch = fileId(stat.ino); this.#stream = new LiveReplayStream({ capacity: options.replayCapacity, prefix: `tx-${host}-${sessionId}-${epoch}`, }); - const offset = fs.statSync(file).size; + const offset = Number(stat.size); const history = tailLines(file, offset, options.maxHistoryBytes, options.maxHistoryRecords); const candidates = []; for (const raw of history.lines) { diff --git a/src/lib/maintenance/discovery/history.mjs b/src/lib/maintenance/discovery/history.mjs index 0d2c0691..0b6f5aff 100644 --- a/src/lib/maintenance/discovery/history.mjs +++ b/src/lib/maintenance/discovery/history.mjs @@ -1,9 +1,9 @@ // ADR-0048 scan history — bounded, retained scan summaries // (docs/maintenance.md "Retention"). -// This store holds ONLY terminal scan summaries. It structurally cannot reach +// This store holds terminal scan summaries and paused continuation boundaries. It cannot reach // receipts, dispositions, or recipe acceptance records — those live in other // agents' stores — so `clearHistory` cannot violate MNT-PRV-008 by scope -// alone; the `isProtected` guard below is defense in depth for a summary that +// alone; the `hasOpenContinuation` guard below is defense in depth for a summary that // still names an open continuation. import fs from 'node:fs'; import path from 'node:path'; @@ -30,14 +30,14 @@ function pruneSummaries(summaries, retention, now) { const cutoff = now - retention.maxAgeDays * 86_400_000; const bySource = new Map(); for (const summary of summaries - .filter((entry) => Date.parse(entry.completedAt) >= cutoff) - .sort((a, b) => Date.parse(a.completedAt) - Date.parse(b.completedAt))) { + .filter((entry) => Date.parse(entry.recordedAt ?? entry.completedAt) >= cutoff) + .sort((a, b) => Date.parse(a.recordedAt ?? a.completedAt) - Date.parse(b.recordedAt ?? b.completedAt))) { const key = JSON.stringify([summary.environmentId, summary.sourceId]); const list = bySource.get(key) ?? []; list.push(summary); bySource.set(key, list.slice(-retention.maxSummaries)); } - return [...bySource.values()].flat().sort((a, b) => Date.parse(a.completedAt) - Date.parse(b.completedAt)); + return [...bySource.values()].flat().sort((a, b) => Date.parse(a.recordedAt ?? a.completedAt) - Date.parse(b.recordedAt ?? b.completedAt)); } /** @@ -47,7 +47,8 @@ function pruneSummaries(summaries, retention, now) { */ export function createScanHistoryStore(dir, { fsImpl = fs, now = Date.now, retention = SCAN_HISTORY_RETENTION, - hasOpenContinuation = (/** @type {string} */ _scanId) => false, + hasOpenContinuation = (scanId) => typeof scanId === 'string' && /^[A-Za-z0-9_-]{1,80}$/u.test(scanId) + && fsImpl.existsSync(path.join(dir, 'checkpoints', `${scanId}.json`)), } = {}) { const file = path.join(dir, 'scan-history.json'); let effectiveRetention = clampRetention(retention); @@ -86,7 +87,7 @@ export function createScanHistoryStore(dir, { return environmentId ? summaries.filter((entry) => entry.environmentId === environmentId) : summaries; } - /** Remove only terminal, non-protected summaries; refuses (never throws) to + /** Remove only non-protected summaries; refuses (never throws) to * touch a summary whose scanId still names an open continuation. * @param {{environmentId?: string}} [options] */ function clearHistory({ environmentId } = {}) { diff --git a/src/lib/maintenance/discovery/orchestrator.mjs b/src/lib/maintenance/discovery/orchestrator.mjs index 38aa917d..4ad5ce95 100644 --- a/src/lib/maintenance/discovery/orchestrator.mjs +++ b/src/lib/maintenance/discovery/orchestrator.mjs @@ -118,11 +118,18 @@ function toCoverageRecord(record) { }; } -function toSummary(record, now) { +function toSummary(record, now, state = record.scanState) { + const recordedAt = new Date(now()).toISOString(); return { scanId: record.scanId, sourceId: record.sourceId, environmentId: record.environmentId, - state: record.scanState, startedAt: record.createdAt, completedAt: new Date(now()).toISOString(), + state, startedAt: record.createdAt, + completedAt: state === 'paused' ? null : recordedAt, + ...(state === 'paused' ? { recordedAt } : {}), visited: record.visited, limitingReason: record.limitingReason, ceiling: record.ceiling ?? null, + ...(state === 'paused' ? { + completedPartitions: record.completedPartitions.length, + pendingPartitions: record.pendingPartitions.length, + } : {}), }; } @@ -180,7 +187,8 @@ function remainingEntryBudget(ceilingEntries, visited) { * remove: (scanId: string) => void, list: () => string[] }} CheckpointStore * @typedef {{ recordSummary: (summary: object) => any, * list: (options?: {environmentId?: string}) => Array<{sourceId: string, environmentId: string, - * state: string, completedAt: string, visited?: number, limitingReason?: string|null, + * state: string, completedAt: string|null, recordedAt?: string, visited?: number, + * completedPartitions?: number, pendingPartitions?: number, limitingReason?: string|null, * ceiling?: string|null}> }} HistoryStore * @typedef {{ write: (entry: object) => boolean, * current: () => Array<{sourceId: string, environmentId: string}> }} LastGoodStore @@ -446,6 +454,11 @@ export function createScanOrchestrator({ error.code = 'SOURCE_NOT_PAUSABLE'; throw error; } + if (!historyStore) throw new Error('scan history unavailable; cannot persist pause'); + // Commit the continuation before claiming that pause is durable. If + // either store rejects the write, leave the live state unchanged. + persistCheckpoint(record); + historyStore.recordSummary(toSummary(record, now, 'paused')); transition(record, 'paused'); return coverageFor(toCoverageRecord(record)); } @@ -486,8 +499,8 @@ export function createScanOrchestrator({ /** A configured source with no live record — never started, paused, or * stopped in this process (e.g. right after a restart, when `records` is - * empty again) — first restores its latest terminal history summary when - * that summary is itself a failure, or a stop at a limit other than the + * empty again) — first restores its latest history summary when + * that summary is a pause, a failure, or a stop at a limit other than the * user's own request (audit finding M1b): a real failure must read the * same on the Discovery panel and the Inventory banner both before and * after a restart, since both read this same `coverage()`. A user stop is @@ -501,11 +514,13 @@ export function createScanOrchestrator({ const record = records.get(source.sourceId); if (record) return coverageFor(toCoverageRecord(record)); const summary = summaryBySource?.get(`${source.environmentId}:${source.sourceId}`); - if (summary && (summary.state === 'failed' || (summary.state === 'stopped' && summary.limitingReason !== 'stopped-by-user'))) { + if (summary && (summary.state === 'paused' || summary.state === 'failed' || (summary.state === 'stopped' && summary.limitingReason !== 'stopped-by-user'))) { return coverageFor({ ...toCoverageRecord(initRecord(source)), scanState: summary.state, visited: summary.visited ?? 0, + completedPartitions: summary.completedPartitions ?? 0, + pendingPartitions: summary.pendingPartitions ?? 0, limitingReason: summary.limitingReason ?? null, ceiling: summary.ceiling ?? null, }); @@ -516,10 +531,10 @@ export function createScanOrchestrator({ return coverageFor(toCoverageRecord(initRecord(source))); } - /** Index the newest terminal summary per `(environmentId, sourceId)` from + /** Index the newest summary per `(environmentId, sourceId)` from * the history store, read once per `coverage()` call rather than once per * source (M1b). `historyStore.list()` is already ascending by - * `completedAt` with ties in recording order, so `>=` (not `>`) keeps the + * record timestamp with ties in recording order, so `>=` (not `>`) keeps the * most-recently-recorded summary on a same-millisecond tie — a later * successful scan must win over an earlier failure even when both * complete within the same clock tick. */ @@ -528,7 +543,7 @@ export function createScanOrchestrator({ for (const summary of historyStore?.list() ?? []) { const key = `${summary.environmentId}:${summary.sourceId}`; const existing = bySource.get(key); - if (!existing || Date.parse(summary.completedAt) >= Date.parse(existing.completedAt)) bySource.set(key, summary); + if (!existing || Date.parse(summary.recordedAt ?? summary.completedAt) >= Date.parse(existing.recordedAt ?? existing.completedAt)) bySource.set(key, summary); } return bySource; } diff --git a/src/lib/maintenance/discovery/partitions.mjs b/src/lib/maintenance/discovery/partitions.mjs index d2b249a3..6066d605 100644 --- a/src/lib/maintenance/discovery/partitions.mjs +++ b/src/lib/maintenance/discovery/partitions.mjs @@ -5,14 +5,15 @@ // partition is executed as one or more `observeWalkForest` calls. import fs from 'node:fs'; import path from 'node:path'; +import { fileId, sameFileId, statMtimeMs } from '../../file-identity.mjs'; const DEFAULT_MAX_FANOUT = 64; function stampFor(target, fsImpl) { try { - const stat = fsImpl.lstatSync(target); + const stat = fsImpl.lstatSync(target, { bigint: true }); return { - target, mtimeMs: stat.mtimeMs, ino: Number(stat.ino) || null, size: stat.isFile() ? stat.size : null, + target, mtimeMs: statMtimeMs(stat), ino: fileId(stat.ino), size: stat.isFile() ? Number(stat.size) : null, }; } catch (error) { return { target, mtimeMs: null, ino: null, size: null, reason: error?.code ?? 'io' }; @@ -110,6 +111,6 @@ export function mergeWalkResults(results) { export function partitionDrifted(partition, { fsImpl = fs } = {}) { return partition.sourceStamps.some((recorded) => { const current = stampFor(recorded.target, fsImpl); - return current.mtimeMs !== recorded.mtimeMs || current.ino !== recorded.ino || current.size !== recorded.size; + return current.mtimeMs !== recorded.mtimeMs || !sameFileId(current.ino, recorded.ino) || current.size !== recorded.size; }); } diff --git a/src/lib/mcp-probe.mjs b/src/lib/mcp-probe.mjs index dcdc4416..d7302370 100644 --- a/src/lib/mcp-probe.mjs +++ b/src/lib/mcp-probe.mjs @@ -2,6 +2,7 @@ // repository content, environment values, or stderr are returned in receipts. import { spawn } from 'node:child_process'; import { resolveShim, killProcessTree } from './exec.mjs'; +import { mergeWindowsEnv } from './windows-npm-shim.mjs'; /** @param {{command:string,args?:string[],cwd?:string,env?:NodeJS.ProcessEnv,timeoutMs?:number}} options */ export function probeMcp({ command, args = [], cwd, env = {}, timeoutMs = 30_000 }) { @@ -12,7 +13,7 @@ export function probeMcp({ command, args = [], cwd, env = {}, timeoutMs = 30_000 } return new Promise((resolve) => { const started = performance.now(); - const mergedEnv = { ...process.env, ...env }; + const mergedEnv = process.platform === 'win32' ? mergeWindowsEnv(process.env, env) : { ...process.env, ...env }; const invocation = resolveShim(command, args, { env: mergedEnv }); const child = spawn(invocation.command, invocation.args, { cwd, env: mergedEnv, shell: false, detached: process.platform !== 'win32', diff --git a/src/lib/mcp-tool-call.mjs b/src/lib/mcp-tool-call.mjs index 7b83678d..26154fb9 100644 --- a/src/lib/mcp-tool-call.mjs +++ b/src/lib/mcp-tool-call.mjs @@ -14,6 +14,7 @@ // shim's node process). import { spawn } from 'node:child_process'; import { resolveShim, killProcessTree } from './exec.mjs'; +import { mergeWindowsEnv } from './windows-npm-shim.mjs'; const MAX_OUTPUT_BYTES = 2 * 1024 * 1024; /** How long the killed server may take to exit before the call reports @@ -44,7 +45,7 @@ export async function callMcpTools({ }) { validate({ command, args, calls, timeoutMs, exitGraceMs }); return new Promise((resolve) => { - const merged = { ...process.env, ...env }; + const merged = process.platform === 'win32' ? mergeWindowsEnv(process.env, env) : { ...process.env, ...env }; const invocation = resolveShim(command, args, { env: merged }); const child = spawn(invocation.command, invocation.args, { cwd, env: merged, shell: false, detached: process.platform !== 'win32', diff --git a/src/lib/project-memory.mjs b/src/lib/project-memory.mjs index e42dbe3b..07856e85 100644 --- a/src/lib/project-memory.mjs +++ b/src/lib/project-memory.mjs @@ -143,24 +143,34 @@ export function removeMemoryProbe(root, namespace, key) { // aqe a .agentic-qe/ folder below the project root (AQE resolves a // relative AQE_MEMORY_PATH against the folder it runs in) // Bounded: at most `maxDirs` folders listed and `maxDepth` levels deep; dot -// folders (other checkouts under .claude/worktrees, .git) and node_modules are -// never walked, only a root dot folder's own markers are checked. A folder that +// tool homes (including other checkouts under .claude/worktrees), .git and +// node_modules are never walked. Ordinary dot folders are walked. A folder that // holds `.git` (nested repository, submodule, worktree inside the checkout) is // another repository: neither it nor anything below it is searched; it is // listed in `nestedRepositories`. const RUFLO_STORE_FILES = Object.freeze(['memory.db', 'agentdb-memory.db']); const ROOT_STRAYS = Object.freeze([['agentdb.db', 'agentdb-cli'], ['agentdb.rvf', 'agentdb-rvf'], ['ruvector.db', 'ruvector']]); +const SCAN_EXCLUDED_DIRS = new Set(['.git', '.swarm', '.agentic-qe', '.claude', '.codex', '.claude-flow', '.agents', '.harness', 'node_modules']); -const isDirectory = (file) => { try { return fs.lstatSync(file).isDirectory(); } catch { return false; } }; const isFile = (file) => { try { return fs.lstatSync(file).isFile(); } catch { return false; } }; const storeBytes = (file) => (fileBytes(file) ?? 0) + (fileBytes(`${file}-wal`) ?? 0); export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = {}) { - if (!isDirectory(root)) return { strays: [], complete: true, visited: 0, nestedRepositories: [] }; + let rootStat; + try { rootStat = fs.lstatSync(root); } catch (e) { + return { strays: [], complete: e.code === 'ENOENT', visited: 0, nestedRepositories: [] }; + } + if (!rootStat.isDirectory()) return { strays: [], complete: true, visited: 0, nestedRepositories: [] }; const found = new Map(); const nestedRepositories = []; let visited = 0; let complete = true; + const stat = (file) => { + try { return fs.lstatSync(file); } catch (e) { + if (e.code !== 'ENOENT') complete = false; + return null; + } + }; const add = (kind, file, sizeBytes) => { const relative = path.relative(root, file).split(path.sep).join('/'); found.set(relative, { kind, path: relative, file, sizeBytes }); @@ -170,14 +180,14 @@ export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = { visited += 1; try { return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); - } catch { return []; } + } catch { complete = false; return []; } }; const checkMarkers = (dir, { ruflo = true } = {}) => { - if (isDirectory(path.join(dir, '.agentic-qe'))) add('aqe', path.join(dir, '.agentic-qe'), null); + if (stat(path.join(dir, '.agentic-qe'))?.isDirectory()) add('aqe', path.join(dir, '.agentic-qe'), null); if (!ruflo) return; for (const name of RUFLO_STORE_FILES) { const file = path.join(dir, '.swarm', name); - if (isFile(file)) add('ruflo', file, storeBytes(file)); + if (stat(file)?.isFile()) add('ruflo', file, storeBytes(file)); } }; const walkSwarm = (dir, depth) => { @@ -192,7 +202,7 @@ export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = { // a worktree inside the checkout) is another repository: its stores are its // own, and ak's pin makes its `.agentic-qe` that repository's store (review M3). const otherRepository = (full) => { - try { fs.lstatSync(path.join(full, '.git')); } catch { return false; } + if (!stat(path.join(full, '.git'))) return false; nestedRepositories.push(path.relative(root, full).split(path.sep).join('/')); return true; }; @@ -200,25 +210,26 @@ export function findStrayMemoryStores(root, { maxDepth = 4, maxDirs = 2000 } = { const entries = list(dir); for (const entry of entries ?? []) { if (!entry.isDirectory()) continue; + if (entry.name === 'node_modules') continue; const full = path.join(dir, entry.name); if (entry.name !== '.git' && otherRepository(full)) continue; - if (entry.name.startsWith('.')) { - // .swarm's own subtree is walked separately; only its AQE marker here. - if (depth === 0 && entry.name !== '.git') checkMarkers(full, { ruflo: entry.name !== '.swarm' }); - continue; - } - if (entry.name === 'node_modules') continue; - checkMarkers(full); + if (entry.name !== '.git') checkMarkers(full, { ruflo: entry.name !== '.swarm' }); + if (SCAN_EXCLUDED_DIRS.has(entry.name)) continue; if (depth + 1 < maxDepth) walk(full, depth + 1); + else { + // A marker at the depth boundary is visible, but descendants are not. + const children = list(full); + if (children?.some((child) => child.isDirectory() && !SCAN_EXCLUDED_DIRS.has(child.name))) complete = false; + } } }; for (const [name, kind] of ROOT_STRAYS) { const file = path.join(root, name); - if (isFile(file)) add(kind, file, storeBytes(file)); + if (stat(file)?.isFile()) add(kind, file, storeBytes(file)); } // .swarm first: it is small, and the most likely home of a stray Ruflo store. - if (isDirectory(path.join(root, '.swarm'))) walkSwarm(path.join(root, '.swarm'), 0); + if (stat(path.join(root, '.swarm'))?.isDirectory()) walkSwarm(path.join(root, '.swarm'), 0); walk(root, 0); const strays = [...found.values()].sort((a, b) => a.path.localeCompare(b.path)); return { strays, complete, visited, nestedRepositories: nestedRepositories.sort() }; diff --git a/src/lib/ruflo-daemon-config.mjs b/src/lib/ruflo-daemon-config.mjs index f517d557..81048959 100644 --- a/src/lib/ruflo-daemon-config.mjs +++ b/src/lib/ruflo-daemon-config.mjs @@ -100,10 +100,14 @@ function readDaemonConfig(file) { } } +const pathPresent = (candidate) => { + try { fs.lstatSync(candidate); return true; } catch (error) { return error?.code !== 'ENOENT'; } +}; + /** A desired key ak leaves to the user: the file is unreadable (`invalid`), * or it holds the user's own value `have` instead of `want`. * @typedef {{ key: string, want: number, have?: unknown }} HeldKey - * @typedef {{ invalid: boolean, entries: HeldKey[] } | null} HeldConfig */ + * @typedef {{ invalid: boolean, entries: HeldKey[], reason?: 'yaml-shadow'|'higher-priority-json'|'explicit-config' } | null} HeldConfig */ /** Plan the config.json edit: which owned keys to drop, which to set. */ function planConfig(current, owned, desired) { @@ -127,26 +131,64 @@ function planConfig(current, owned, desired) { return { next, nextOwned, removed, written, conflicts }; } -function reconcileConfig(root, receipt, desired, dryRun) { +/** @returns {{status: string, changed: boolean, activeChanged: boolean, held: HeldConfig}} */ +function reconcileConfig(root, receipt, desired, dryRun, env) { const file = path.join(root, DAEMON_CONFIG_RELATIVE); + // Never follow a user-controlled .claude-flow link (or special file) when + // reading, creating, replacing or removing config.json. + try { + const dir = fs.lstatSync(path.dirname(file)); + if (!dir.isDirectory() || dir.isSymbolicLink()) { + const entries = Object.entries(desired).map(([key, want]) => ({ key, want })); + return { status: 'user-managed', changed: false, activeChanged: false, held: entries.length ? { invalid: true, entries } : null }; + } + } catch (error) { + if (error?.code !== 'ENOENT') { + const entries = Object.entries(desired).map(([key, want]) => ({ key, want })); + return { status: 'user-managed', changed: false, activeChanged: false, held: entries.length ? { invalid: true, entries } : null }; + } + } + const higherPriority = pathPresent(path.join(root, 'claude-flow.config.json')); + // ConfigFileManager.findConfig checks the explicit path only after both + // JSON candidates. existsSync resolves a relative env path from process.cwd, + // exactly as Ruflo does; it does not resolve it against the project root. + const explicitConfig = !higherPriority && typeof env?.CLAUDE_FLOW_CONFIG === 'string' + && fs.existsSync(env.CLAUDE_FLOW_CONFIG); const owned = receipt.configKeys ?? {}; const current = readDaemonConfig(file); if (current.state === 'invalid') { const entries = Object.entries(desired).map(([key, want]) => ({ key, want })); - return { status: 'user-managed', changed: false, held: entries.length ? { invalid: true, entries } : null }; + return { status: 'user-managed', changed: false, activeChanged: false, held: entries.length ? { invalid: true, entries } : null }; } if (current.state === 'absent') { receipt.configKeys = {}; receipt.configCreated = false; - if (!Object.keys(desired).length) return { status: 'absent', changed: false, held: null }; + if (!Object.keys(desired).length) return { status: 'absent', changed: false, activeChanged: false, held: null }; + // Ruflo 3.48.0 chooses root JSON, then .claude-flow/config.json, then + // config.yaml/yml. Creating our JSON over YAML would hide all user YAML + // daemon values; under root JSON this file would have no effect at all. + const yaml = ['config.yaml', 'config.yml'].some((name) => pathPresent(path.join(root, '.claude-flow', name))); + if (higherPriority || explicitConfig || yaml) return { + status: 'user-managed', changed: false, activeChanged: false, + held: { + invalid: false, reason: higherPriority ? 'higher-priority-json' : explicitConfig ? 'explicit-config' : 'yaml-shadow', + entries: Object.entries(desired).map(([key, want]) => ({ key, want })), + }, + }; if (!dryRun) { writePrivateFileAtomic(file, `${JSON.stringify(desired, null, 2)}\n`); receipt.configKeys = { ...desired }; receipt.configCreated = true; } - return { status: 'written', changed: true, held: null }; + return { status: 'written', changed: true, activeChanged: true, held: null }; } - const plan = planConfig(current.value, owned, desired); + // A root JSON file wins even over existing .claude-flow/config.json. Keep + // receipted still-needed keys and clean obsolete ones, but add no new keys + // to a file the daemon does not read. + const effectiveDesired = higherPriority + ? Object.fromEntries(Object.entries(desired).filter(([key]) => Object.hasOwn(owned, key))) + : desired; + const plan = planConfig(current.value, owned, effectiveDesired); const changed = plan.written || plan.removed; const empty = Object.keys(plan.next).length === 0; if (!dryRun) { @@ -156,10 +198,13 @@ function reconcileConfig(root, receipt, desired, dryRun) { receipt.configCreated ??= false; if (changed && empty && receipt.configCreated === true) receipt.configCreated = false; } - const held = plan.conflicts.length ? { invalid: false, entries: plan.conflicts } : null; - if (plan.written) return { status: 'written', changed, held }; - if (plan.removed) return { status: 'removed', changed, held }; - return { status: held ? 'user-managed' : 'converged', changed: false, held }; + /** @type {HeldConfig} */ + const held = higherPriority && Object.keys(desired).length + ? { invalid: false, reason: 'higher-priority-json', entries: Object.entries(desired).map(([key, want]) => ({ key, want })) } + : plan.conflicts.length ? { invalid: false, entries: plan.conflicts } : null; + if (plan.written) return { status: 'written', changed, activeChanged: !higherPriority, held }; + if (plan.removed) return { status: 'removed', changed, activeChanged: !higherPriority, held }; + return { status: held ? 'user-managed' : 'converged', changed: false, activeChanged: false, held }; } function reconcileAutostart(root, receipt, wanted, dryRun) { @@ -196,25 +241,27 @@ const hasOwnership = (receipt) => Object.keys(receipt.configKeys ?? {}).length > * Converge one project's managed daemon settings. * @param {string} root the Ruflo project root * @param {{rufloVersion?: (string|null), platform?: string, receipts: Record, - * autoStart?: boolean, dryRun?: boolean, desired?: Record, runner?: unknown}} options + * autoStart?: boolean, dryRun?: boolean, desired?: Record, runner?: unknown, + * env?: NodeJS.ProcessEnv}} options * `autoStart` is kit.json rufloDaemon.autoStart !== false; `runner` is accepted and never used. - * @returns {{config: string, autostart: string, changed: boolean, held: HeldConfig}} + * @returns {{config: string, autostart: string, changed: boolean, configActiveChanged: boolean, held: HeldConfig}} * `held` names the desired keys ak cannot manage here (null when none). */ export function reconcileRufloDaemon(root, { rufloVersion = null, platform = process.platform, receipts, autoStart = true, dryRun = false, - desired = desiredDaemonKeys({ rufloVersion, platform }), + desired = desiredDaemonKeys({ rufloVersion, platform }), env = process.env, } = /** @type {any} */ ({})) { const key = path.resolve(root); const receipt = structuredClone(receipts[key] ?? {}); - const config = reconcileConfig(root, receipt, desired, dryRun); + const config = reconcileConfig(root, receipt, desired, dryRun, env); const autostart = reconcileAutostart(root, receipt, autoStart, dryRun); if (!dryRun) { if (hasOwnership(receipt)) receipts[key] = receipt; else delete receipts[key]; } return { - config: config.status, autostart: autostart.status, changed: config.changed || autostart.changed, held: config.held, + config: config.status, autostart: autostart.status, changed: config.changed || autostart.changed, + configActiveChanged: config.activeChanged, held: config.held, }; } @@ -254,16 +301,17 @@ export function daemonIntent(cfg) { * is left for Ruflo's start-on-use. Returns null outside a Ruflo project. * @param {string} cwd * @param {{cfg: any, rufloVersion?: (string|null), platform?: string, runner?: typeof run, - * alive?: (root: string) => boolean, dryRun?: boolean}} options + * alive?: (root: string) => boolean, dryRun?: boolean, env?: NodeJS.ProcessEnv}} options */ export async function applyRufloDaemon(cwd, { cfg, rufloVersion = null, platform = process.platform, runner = run, alive = projectDaemonAlive, dryRun = false, + env = process.env, }) { const root = rufloDaemonProjectRoot(cwd); if (!root) return null; const intent = daemonIntent(cfg); - const result = reconcileRufloDaemon(root, { rufloVersion, platform, receipts: intent.receipts, autoStart: intent.autoStart, dryRun }); - const configChanged = result.config === 'written' || result.config === 'removed'; + const result = reconcileRufloDaemon(root, { rufloVersion, platform, receipts: intent.receipts, autoStart: intent.autoStart, dryRun, env }); + const configChanged = result.configActiveChanged; // A config.json that keeps the floor from ak (unreadable, or the user's own // value) changed nothing a restart would pick up. const floorHeld = result.held?.entries.some((e) => e.key === MEMORY_FLOOR_KEY) ?? false; @@ -279,17 +327,17 @@ export async function applyRufloDaemon(cwd, { /** Status: the desired keys a user-managed config.json keeps from ak (a dry run). * @returns {HeldConfig} */ -export function daemonConfigHeld(root, { cfg, rufloVersion = null, platform = process.platform }) { +export function daemonConfigHeld(root, { cfg, rufloVersion = null, platform = process.platform, env = process.env }) { const intent = daemonIntent(structuredClone(cfg ?? {})); const receipts = structuredClone(intent.receipts); - return reconcileRufloDaemon(root, { rufloVersion, platform, receipts, autoStart: intent.autoStart, dryRun: true }).held; + return reconcileRufloDaemon(root, { rufloVersion, platform, receipts, autoStart: intent.autoStart, dryRun: true, env }).held; } /** Status: what a sync would change here, as phrases, or null when converged. */ -export function daemonDrift(root, { cfg, rufloVersion = null, platform = process.platform }) { +export function daemonDrift(root, { cfg, rufloVersion = null, platform = process.platform, env = process.env }) { const intent = daemonIntent(structuredClone(cfg ?? {})); const receipts = structuredClone(intent.receipts); - const r = reconcileRufloDaemon(root, { rufloVersion, platform, receipts, autoStart: intent.autoStart, dryRun: true }); + const r = reconcileRufloDaemon(root, { rufloVersion, platform, receipts, autoStart: intent.autoStart, dryRun: true, env }); if (!r.changed) return null; const parts = []; const desired = desiredDaemonKeys({ rufloVersion, platform }); diff --git a/src/lib/ruflo-memory.mjs b/src/lib/ruflo-memory.mjs index 358bdc9f..b7aa541e 100644 --- a/src/lib/ruflo-memory.mjs +++ b/src/lib/ruflo-memory.mjs @@ -36,9 +36,10 @@ import { componentById } from './ruflo-components/catalogue.mjs'; // are the same folder, as the filesystem treats them. const realOr = (p, file) => { try { return fs.realpathSync(file); } catch { return p.resolve(file); } }; const same = (p, a, b) => p.relative(a, b) === ''; +// Strict containment: equality cannot make a tool root a deeper temp boundary. const inside = (p, child, parent) => { const rel = p.relative(parent, child); - return rel === '' || (!rel.startsWith('..') && !p.isAbsolute(rel)); + return rel !== '' && !rel.startsWith('..') && !p.isAbsolute(rel); }; /** `~/…` for a folder under the home folder, else the absolute path. */ export function homeRelative(file, home = paths.home, p = path) { @@ -57,7 +58,8 @@ function unsuitableReason(dir, { home, env, platform, p }) { if (temps.some((temp) => same(p, dir, temp))) return 'a temporary folder'; const tool = paths.toolInternalDirs({ home, env, platform, p }).find((folder) => { const real = realOr(p, folder); - return inside(p, dir, real) && !temps.some((temp) => inside(p, temp, real) && inside(p, dir, temp)); + return (same(p, dir, real) || inside(p, dir, real)) + && !temps.some((temp) => inside(p, temp, real) && inside(p, dir, temp)); }); return tool ? `inside ${homeRelative(tool, home, p)}, a tool's own folder` : null; } @@ -75,14 +77,20 @@ export function rufloMemoryLocation(cwd = process.cwd(), { home = paths.home, env = process.env, platform = process.platform, p = path, } = {}) { const options = { home, env, platform, p }; - let reason = null; - for (const [kind, candidate] of [['project', paths.repoRoot(cwd, p)], ['folder', cwd]]) { - if (!candidate) continue; - const root = realOr(p, candidate); - const why = unsuitableReason(root, options); - if (!why) return { kind, root, dir: p.join(root, '.swarm'), db: paths.projectMemoryDb(root, p), reason: null }; - reason ??= why; + const repository = paths.repoRoot(cwd, p); + const projectRoot = repository && realOr(p, repository); + const projectReason = projectRoot && unsuitableReason(projectRoot, options); + if (projectRoot && !projectReason) { + return { kind: 'project', root: projectRoot, dir: p.join(projectRoot, '.swarm'), db: paths.projectMemoryDb(projectRoot, p), reason: null }; } + const folderRoot = realOr(p, cwd); + const folderReason = unsuitableReason(folderRoot, options); + if (!folderReason) { + return { kind: 'folder', root: folderRoot, dir: p.join(folderRoot, '.swarm'), db: paths.projectMemoryDb(folderRoot, p), reason: null }; + } + const reason = projectReason && !same(p, projectRoot, folderRoot) && projectReason !== folderReason + ? `${folderReason}, in a repository whose root is ${projectReason}` + : folderReason; const dir = paths.userMemoryDir(home, p); return { kind: 'user', root: dir, dir, db: p.join(dir, 'memory.db'), reason }; } diff --git a/src/lib/versions.mjs b/src/lib/versions.mjs index 7e541a9a..8617c98b 100644 --- a/src/lib/versions.mjs +++ b/src/lib/versions.mjs @@ -159,27 +159,26 @@ async function fetchSelfCandidate(tags, cachedBest, fetchLatest) { return { best, observed, answered }; } -/** The self record to save after a lookup, or null to save nothing. A live - * winner is an observation. When every lookup failed, the record stays and - * `last` is restamped, so the next lookup waits one TTL window; `observedAt` - * keeps when the recorded best was seen (a record written before it existed - * takes the previous `last`). A partial answer that leaves a cached candidate - * winning renews nothing. Neither does a failure when the recorded best is - * one this install cannot use (a `next` candidate on a stable install): such - * a record is never fresh, so a restamp would only rewrite kit.json on every - * call, and dropping the candidate would make an empty record look fresh and - * stop the lookup for a TTL window once the registry is back. */ -function selfRecord(cached, usable, { best, observed, answered }, now = Date.now()) { - if (observed) return { last: now, best, observedAt: now }; - if (answered || (cached?.best && !usable)) return null; - return { ...cached, last: now, observedAt: cached?.observedAt ?? cached?.last }; +/** `observedAt` describes the winning candidate; `last` and `lastTags` scope + * the latest completed check. `attempt` throttles a lookup that could not + * update that check. Neither stamp makes a cached winner newly observed. */ +function selfRecord(cached, usable, { best, observed, answered }, tags, now = Date.now()) { + if (observed) return { last: now, best, observedAt: now, lastTags: tags }; + if (answered || (cached?.best && !usable)) { + return { ...cached, attempt: { at: now, tags } }; + } + const { attempt: _priorAttempt, ...prior } = cached ?? {}; + return { ...prior, last: now, observedAt: cached?.observedAt ?? cached?.last, lastTags: tags }; } +const sameTags = (recorded, tags) => Array.isArray(recorded) && recorded.length === tags.length + && tags.every((tag, index) => recorded[index] === tag); + /** Look the kit up on its channels; with `record`, save what selfRecord keeps. * Returns the winning candidate. */ async function lookUpSelf(cfg, cached, { tags, cachedBest, fetchLatest, record }) { const candidate = await fetchSelfCandidate(tags, cachedBest, fetchLatest); - const entry = record ? selfRecord(cached, cachedBest, candidate) : null; + const entry = record ? selfRecord(cached, cachedBest, candidate, tags) : null; if (entry) { cfg.versionCheck = { ...cfg.versionCheck, self: entry }; try { saveKitConfig(cfg); } catch { /* read-only envs: next call re-fetches */ } @@ -191,8 +190,10 @@ async function lookUpSelf(cfg, cached, { tags, cachedBest, fetchLatest, record } * (pkgRoot). Prerelease installs also consult the `next` dist-tag — * prereleases publish there, so `latest` alone would never see them; the * higher of latest/next wins. Cached in kit.json alongside versionCheck. - * Failed lookups preserve eligible cached evidence (see selfRecord); `force` - * retries within the TTL. cacheOnly=true reports the recorded best with no + * Failed lookups preserve eligible cached evidence (see selfRecord); an + * `attempt` stamp limits partial/unusable retries to once per tag set and TTL. + * `lastTags` scopes completed checks. `force` retries within the TTL. + * cacheOnly=true reports the recorded best with no * network and no write (`ak sync --skip self`); record=false reports what the * lookup found without saving it (ADR-0063). * @param {{ pkgRoot?: string, force?: boolean, cacheOnly?: boolean, record?: boolean, @@ -208,8 +209,19 @@ export async function selfDrift({ pkgRoot, force = false, cacheOnly = false, rec const tags = installed?.includes('-') ? ['latest', 'next'] : ['latest']; const cachedBest = cached?.best && tags.includes(cached.best.tag) && isValidSemver(cached.best.version) ? cached.best : null; - const fresh = !force && cached?.last && Date.now() - cached.last < ttlMs - && (!cached.best || cachedBest); + const now = Date.now(); + const attempt = cached?.attempt; + const attemptFresh = Number.isSafeInteger(attempt?.at) && attempt.at > 0 && attempt.at <= now + && sameTags(attempt.tags, tags) + && now - attempt.at < ttlMs; + // Older records have no scope: a `next` winner proves both tags were tried; + // otherwise only the single latest channel is safe to reuse. + const lastScope = cached?.lastTags === undefined + ? (tags.length === 1 || cached?.best?.tag === 'next') + : sameTags(cached.lastTags, tags); + const lastFresh = Number.isSafeInteger(cached?.last) && cached.last > 0 && cached.last <= now + && now - cached.last < ttlMs && lastScope && (!cached.best || cachedBest); + const fresh = !force && (lastFresh || attemptFresh); const best = fresh || cacheOnly ? cachedBest : await lookUpSelf(cfg, cached, { tags, cachedBest, fetchLatest, record }); return { pkg: KIT_PKG, diff --git a/src/lib/windows-npm-shim.mjs b/src/lib/windows-npm-shim.mjs new file mode 100644 index 00000000..433ee81c --- /dev/null +++ b/src/lib/windows-npm-shim.mjs @@ -0,0 +1,83 @@ +// Recognize, never interpret, npm cmd-shim 8's plain `env node` wrapper pair. +// Fingerprints normalize only CRLF and the package entry path. Any flags, +// environment assignments, comments or custom behavior keep the PS fallback. +// Fixtures were emitted by cmd-shim 8.0.0; the ps1 hash matches the native +// Ruflo 3.48.0 CI artifact. No package manager or package code runs here. +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +const CMD_TEMPLATE = '46268d032014c41f0112ffc0f52a9b297a809c289587e984e5919c65f29dad79'; +const PS_TEMPLATE = '11a7c411407320ddc34a9ae9633d6372b1867994ff723693be5be16148dd45a8'; + +export function windowsEnvValue(env, key) { + return env[Object.keys(env).sort().find((name) => name.toUpperCase() === key.toUpperCase())]; +} + +/** Windows treats environment names case-insensitively. Remove a replaced + * spelling before applying the override, so Node's sorted env selection and + * the resolver agree about the caller's PATH and runtime. */ +export function mergeWindowsEnv(base, overrides) { + const merged = { ...base }; + for (const [key, value] of Object.entries(overrides)) { + for (const old of Object.keys(merged)) if (old.toUpperCase() === key.toUpperCase()) delete merged[old]; + merged[key] = value; + } + return merged; +} + +function readBounded(file) { + const stat = fs.statSync(file); + if (!stat.isFile() || stat.size > 65536) throw Error('not a bounded shim or manifest'); + return fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n'); +} +const fingerprint = (source, target) => createHash('sha256').update(source.replaceAll(target, '')).digest('hex'); +const isFile = (file) => { try { return fs.statSync(file).isFile(); } catch { return false; } }; +function inside(root, file) { + const relative = path.relative(root, file); + return relative && !relative.split(path.sep).includes('..') && !path.isAbsolute(relative); +} +function plainNodeShebang(file) { + const fd = fs.openSync(file, 'r'); + try { + const data = Buffer.alloc(128); + const size = fs.readSync(fd, data, 0, data.length, 0); + return /^#!\/usr\/bin\/env node\r?\n/.test(data.subarray(0, size).toString('utf8')); + } finally { fs.closeSync(fd); } +} + +/** Map ONLY the PATH-selected, unchanged npm wrapper pair to its own manifest's + * public bin. Never consult a global-root guess or bypass an internal bundle. + * Return null when ownership, containment, template or runtime is uncertain. */ +export function npmShimInvocation(candidate, args, env) { + try { + const cmd = readBounded(candidate); + const ps = readBounded(`${candidate.slice(0, -4)}.ps1`); + const target = ps.match(/"\$basedir\/(node_modules\/[A-Za-z0-9@_./-]+)"/)?.[1]; + if (!target || target.split('/').some((part) => !part || part === '.' || part === '..')) return null; + if (fingerprint(ps, target) !== PS_TEMPLATE + || fingerprint(cmd, target.replaceAll('/', '\\')) !== CMD_TEMPLATE) return null; + const parts = target.split('/'); + const packageName = parts[1].startsWith('@') ? `${parts[1]}/${parts[2]}` : parts[1]; + const base = path.resolve(path.dirname(candidate)); + const packageRoot = path.join(base, 'node_modules', ...packageName.split('/')); + const pkg = JSON.parse(readBounded(path.join(packageRoot, 'package.json'))); + if (pkg.name !== packageName) return null; + const name = path.basename(candidate).slice(0, -4); + const declared = typeof pkg.bin === 'string' + ? (packageName.split('/').at(-1) === name ? pkg.bin : null) : pkg.bin?.[name]; + if (typeof declared !== 'string' || !declared || path.win32.isAbsolute(declared) + || path.isAbsolute(declared) || declared.split(/[\\/]/).includes('..')) return null; + const entry = path.resolve(base, ...parts); + if (path.resolve(packageRoot, declared) !== entry || !isFile(entry)) return null; + const realPackage = fs.realpathSync(packageRoot); + if (!inside(fs.realpathSync(base), realPackage) + || !inside(realPackage, fs.realpathSync(entry)) || !plainNodeShebang(entry)) return null; + // npm chooses adjacent node.exe first, then node.exe on the caller's PATH. + // Do not substitute agentic-kit's current runtime or a different npm tree. + const adjacent = path.join(base, 'node.exe'); + const node = isFile(adjacent) ? adjacent : (windowsEnvValue(env, 'PATH') || '') + .split(path.delimiter).filter(Boolean).map((dir) => path.resolve(dir, 'node.exe')).find(isFile); + return node ? { command: node, args: [entry, ...args], resolved: true } : null; + } catch { return null; } +} diff --git a/tests/fixtures/npm-windows-shim/license.txt b/tests/fixtures/npm-windows-shim/license.txt new file mode 100644 index 00000000..20a47625 --- /dev/null +++ b/tests/fixtures/npm-windows-shim/license.txt @@ -0,0 +1,15 @@ +The ISC License + +Copyright (c) npm, Inc. and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/tests/fixtures/npm-windows-shim/ruflo.cmd b/tests/fixtures/npm-windows-shim/ruflo.cmd new file mode 100644 index 00000000..ea9a8598 --- /dev/null +++ b/tests/fixtures/npm-windows-shim/ruflo.cmd @@ -0,0 +1,17 @@ +@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\node.exe" ( + SET "_prog=%dp0%\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\ruflo\bin\ruflo.js" %* diff --git a/tests/fixtures/npm-windows-shim/ruflo.ps1 b/tests/fixtures/npm-windows-shim/ruflo.ps1 new file mode 100644 index 00000000..7de4de00 --- /dev/null +++ b/tests/fixtures/npm-windows-shim/ruflo.ps1 @@ -0,0 +1,28 @@ +#!/usr/bin/env pwsh +$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent + +$exe="" +if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) { + # Fix case when both the Windows and Linux builds of Node + # are installed in the same directory + $exe=".exe" +} +$ret=0 +if (Test-Path "$basedir/node$exe") { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "$basedir/node$exe" "$basedir/node_modules/ruflo/bin/ruflo.js" $args + } else { + & "$basedir/node$exe" "$basedir/node_modules/ruflo/bin/ruflo.js" $args + } + $ret=$LASTEXITCODE +} else { + # Support pipeline input + if ($MyInvocation.ExpectingInput) { + $input | & "node$exe" "$basedir/node_modules/ruflo/bin/ruflo.js" $args + } else { + & "node$exe" "$basedir/node_modules/ruflo/bin/ruflo.js" $args + } + $ret=$LASTEXITCODE +} +exit $ret diff --git a/tests/kit/aqe-live-lock-process.test.mjs b/tests/kit/aqe-live-lock-process.test.mjs new file mode 100644 index 00000000..467f34b8 --- /dev/null +++ b/tests/kit/aqe-live-lock-process.test.mjs @@ -0,0 +1,138 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, existsSync, rmSync, mkdirSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createProcessScope } from '../live/aqe-live-lock-process.mjs'; +import { spawnEnv, envValue } from './helpers/home-sandbox.mjs'; +import { ownerRecord, writeOwner, readOwner, prepareRunRootHolds, + inspectRunRootHolds } from '../../scripts/run-roots.mjs'; + +function sandbox(root) { + const home = path.join(root, 'home'); + mkdirSync(home); + return spawnEnv(home); +} + +test('abort closes a call-owned child before its temporary root is removed', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-abort-proof-')); + const controller = new AbortController(); + const scope = createProcessScope(controller.signal); + let closed = false; + try { + const marker = path.join(root, 'child-ready'); + const run = scope.launch(process.execPath, ['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'ready');setInterval(() => {}, 1000)`], { cwd: root, env: sandbox(root) }); + assert.ok(run.child.pid > 0); + const deadline = Date.now() + 2000; + while (!existsSync(marker) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(existsSync(marker), 'the child must be running before cancellation'); + controller.abort(); + await scope.closeAll(); + closed = true; + assert.equal(run.closed, true); + assert.throws(() => scope.launch(process.execPath, [], { env: {} }), /cannot launch/); + assert.ok(existsSync(root), 'root must remain until closure is established'); + } finally { + if (!closed) await scope.closeAll(); + if (closed) rmSync(root, { recursive: true, force: true }); + } + assert.equal(existsSync(root), false); +}); + +test('spawn failure is retained without an unhandled rejection', async () => { + const scope = createProcessScope(new AbortController().signal); + const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-spawn-failure-')); + const run = scope.launch(path.join(root, 'ak-missing-executable'), [], { env: sandbox(root) }); + await assert.rejects(scope.wait(run, 1000), /ENOENT/); + await scope.closeAll(); + rmSync(root, { recursive: true, force: true }); +}); + +test('requires explicit sandbox env and passes it to the child', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-env-proof-')); + const scope = createProcessScope(new AbortController().signal); + try { + assert.throws(() => scope.launch(process.execPath, [], {}), /explicit sandbox env/); + assert.throws(() => scope.launch(process.execPath, [], { env: {} }), /requires sandbox home/); + const env = sandbox(root); + const run = scope.launch(process.execPath, + ['-e', 'console.log(JSON.stringify({home:process.env.HOME,tmp:process.env.TMPDIR,state:process.env.XDG_STATE_HOME}))'], + { cwd: root, env }); + const result = await scope.wait(run, 2000); + assert.equal(result.code, 0); + const observed = JSON.parse(result.stdout.trim()); + assert.equal(observed.home, env.HOME); + assert.equal(observed.tmp, env.TMPDIR); + assert.equal(observed.state, env.XDG_STATE_HOME); + } finally { + await scope.closeAll(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('Windows bootstrap validation accepts preserved mixed-case names without adding duplicates', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-windows-env-')); + const home = path.join(root, 'home'); + mkdirSync(home); + const env = spawnEnv(home, {}, { platform: 'win32', env: { + SYSTEMROOT: process.env.SystemRoot ?? process.env.SYSTEMROOT ?? 'C:\\Windows', + COMSPEC: process.env.ComSpec ?? process.env.COMSPEC ?? 'C:\\Windows\\System32\\cmd.exe', + PaThExT: process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD', + Path: process.env.PATH ?? '', + } }); + assert.equal(env.SystemRoot, undefined); + assert.equal(env.ComSpec, undefined); + assert.equal(envValue(env, 'SystemRoot', 'win32'), env.SYSTEMROOT); + const scope = createProcessScope(new AbortController().signal, { platform: 'win32' }); + try { + assert.throws(() => scope.launch(process.execPath, [], { env: { ...env, COMSPEC: '' } }), + /requires Windows process env/); + const run = scope.launch(process.execPath, ['-e', 'console.log("bootstrapped")'], { env }); + const result = await scope.wait(run, 2000); + assert.equal(result.code, 0); + assert.match(result.stdout, /bootstrapped/); + assert.equal(Object.keys(env).filter((key) => key.toUpperCase() === 'SYSTEMROOT').length, 1); + assert.equal(Object.keys(env).filter((key) => key.toUpperCase() === 'COMSPEC').length, 1); + } finally { + await scope.closeAll(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('a guarded scope holds the enclosing run root until owned children close', async () => { + const base = mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-hold-base-')); + const root = mkdtempSync(path.join(base, 'ak-suite-')); + const owner = ownerRecord(); + writeOwner(root, owner); + prepareRunRootHolds(root, owner.runId); + const beforeRoot = process.env.AK_SUITE_ROOT; + const beforeId = process.env.AK_SUITE_RUN_ID; + process.env.AK_SUITE_ROOT = root; + process.env.AK_SUITE_RUN_ID = owner.runId; + let scope; + try { + scope = createProcessScope(new AbortController().signal, { closeLimitMs: 25 }); + assert.equal(inspectRunRootHolds(root, readOwner(root).runId).unresolved, true); + const run = scope.launch(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], + { env: sandbox(base) }); + assert.equal(run.closed, false); + const realKill = run.child.kill.bind(run.child); + run.child.kill = () => false; + try { + await assert.rejects(scope.closeAll(), /did not close/); + assert.equal(inspectRunRootHolds(root, owner.runId).unresolved, true); + } finally { + run.child.kill = realKill; + } + await scope.closeAll(); + assert.equal(run.closed, true); + assert.equal(inspectRunRootHolds(root, owner.runId).unresolved, false); + } finally { + if (scope) await scope.closeAll(); + if (beforeRoot === undefined) delete process.env.AK_SUITE_ROOT; + else process.env.AK_SUITE_ROOT = beforeRoot; + if (beforeId === undefined) delete process.env.AK_SUITE_RUN_ID; + else process.env.AK_SUITE_RUN_ID = beforeId; + rmSync(base, { recursive: true, force: true }); + } +}); diff --git a/tests/kit/aqe-verification.test.mjs b/tests/kit/aqe-verification.test.mjs index 1bad2acd..60e24c5b 100644 --- a/tests/kit/aqe-verification.test.mjs +++ b/tests/kit/aqe-verification.test.mjs @@ -6,39 +6,40 @@ import { aqeVerificationPassed } from '../../src/lib/aqe-verification.mjs'; test('a live lock does not hide an independent storage error', () => { assert.equal(classifyAqeStartup({ code: 0, stderr: 'locked by a live process; FsyncFailed' }).status, 'failed'); }); -// TEMPORARY (remove with the rule in classifyAqeStartup, pacphi/agentic-kit#240): the -// exact stderr agentic-qe 3.14.3 emits when a healthy patterns.rvf is held by a live -// owner (captured from a fixture; store and lock bytes were unchanged). The FsyncFailed -// comes from a create attempt AQE should not make (agentic-qe#574). Remove this test -// when a released agentic-qe fixes agentic-qe#574 and that release is the kit's floor; -// agentic-qe#719 (in 3.14.4) is a partial fix and does not remove it. -const LIVE_OWNER_CONTENTION = [ +// AQE 3.14.3 emitted this exact sequence under a live lock. The released 3.14.4 +// artifact omits FsyncFailed in macOS and Linux conformance probes; old output fails closed. +const OLD_LIVE_OWNER_CONTENTION = [ '[RVF] /p/.agentic-qe/patterns.rvf is locked by a live process (pid 70149) — not breaking the lock; degrading to SQLite for this run.', '[RVF] /p/.agentic-qe/patterns.rvf is unusable but its lock is held by a live process — leaving it alone and degrading to SQLite for this run.', '[RVF] Shared adapter init failed: RVF error 0x0303: FsyncFailed', ].join('\n'); -test('live-owner lock contention reads as busy, not a storage failure (agentic-qe#574)', () => { - const startup = classifyAqeStartup({ code: 0, stdout: '', stderr: LIVE_OWNER_CONTENTION }); - assert.equal(startup.status, 'busy'); - assert.match(startup.reason, /another live process/); - assert.match(startup.reason, /integrity unverified/); - assert.equal(aqeVerificationPassed(startup, { status: 'passed', corpus: { status: 'healthy' } }), true); +test('old live-owner FsyncFailed sequence fails closed and blocks verification', () => { + const startup = classifyAqeStartup({ code: 0, stdout: '', stderr: OLD_LIVE_OWNER_CONTENTION }); + assert.deepEqual(startup, { status: 'failed', reason: 'RVF backend failed' }); + assert.equal(aqeVerificationPassed(startup, { status: 'passed', corpus: { status: 'healthy' } }), false); }); -test('the contention rule needs all three lines; a partial match still fails', () => { - const [locked, unusable, fsync] = LIVE_OWNER_CONTENTION.split('\n'); +test('FsyncFailed fails with or without partial live-lock lines', () => { + const [locked, unusable, fsync] = OLD_LIVE_OWNER_CONTENTION.split('\n'); for (const stderr of [fsync, `${locked}\n${fsync}`, `${unusable}\n${fsync}`]) { assert.equal(classifyAqeStartup({ code: 0, stderr }).status, 'failed', stderr); } - assert.equal(classifyAqeStartup({ code: 1, stderr: LIVE_OWNER_CONTENTION }).status, 'failed'); + assert.equal(classifyAqeStartup({ code: 1, stderr: OLD_LIVE_OWNER_CONTENTION }).status, 'failed'); }); -test('live-owner contention does not hide failed embedding initialization', () => { - const stderr = `${LIVE_OWNER_CONTENTION}\nReasoningBank prewarm failed`; - assert.equal(classifyAqeStartup({ code: 0, stderr }).status, 'degraded'); +test('FsyncFailed takes precedence over failed embedding initialization', () => { + const stderr = `${OLD_LIVE_OWNER_CONTENTION}\nReasoningBank prewarm failed`; + assert.equal(classifyAqeStartup({ code: 0, stderr }).status, 'failed'); }); test('a live lock does not hide failed embedding initialization', () => { assert.equal(classifyAqeStartup({ code: 0, stderr: 'locked by a live process; prewarm failed' }).status, 'degraded'); }); +test('ordinary live lock remains busy with owner health and RVF integrity unknown', () => { + const startup = classifyAqeStartup({ code: 0, stderr: '[RVF] locked by a live process; 0x0300: LockHeld; degrading to SQLite' }); + assert.equal(startup.status, 'busy'); + assert.match(startup.reason, /SQLite fallback observed/); + assert.match(startup.reason, /owner health and RVF integrity unverified/); + assert.equal(aqeVerificationPassed(startup, { status: 'passed', corpus: { status: 'healthy' } }), true); +}); test('busy RVF permits qualified semantic proof when corpus is verified', () => { assert.equal(aqeVerificationPassed({ status: 'busy' }, { status: 'passed', corpus: { status: 'healthy' } }), true); }); diff --git a/tests/kit/cli-json-honesty.test.mjs b/tests/kit/cli-json-honesty.test.mjs index 1a4a0a34..f82b79bf 100644 --- a/tests/kit/cli-json-honesty.test.mjs +++ b/tests/kit/cli-json-honesty.test.mjs @@ -23,9 +23,9 @@ const BIN = path.join(PKG_ROOT, 'bin', 'agentic-kit.mjs'); const KIT_JSON = path.join(HOME, '.config', 'agentic-kit', 'kit.json'); /** Run `ak …args` in the sandbox. */ -function ak(args) { +function ak(args, envOverrides = {}) { return spawnSync(process.execPath, [BIN, ...args], { - cwd: PROJECT, env: spawnEnv(HOME), encoding: 'utf8', timeout: 120_000, + cwd: PROJECT, env: { ...spawnEnv(HOME), ...envOverrides }, encoding: 'utf8', timeout: 120_000, }); } @@ -124,6 +124,72 @@ for (const [args, message] of USAGE_ERRORS) { }); } +const COMMAND_USAGE_ERRORS = [ + [['usage', 'bogus', '--json'], /usage: ak usage/], + [['usage', 'score', '--window', '99', '--json'], /--window must be/], + [['usage', 'prompts', '--window', '99', '--json'], /--window must be/], + [['usage', 'score', 'extra', '--json'], /unexpected argument 'extra'/], + [['usage', 'prompts', 'extra', '--json'], /unexpected argument 'extra'/], + [['models', 'bogus', '--json'], /usage: ak models/], + [['models', 'explain', '--json'], /usage: ak models explain/], + [['models', 'plan', '--json'], /usage: ak models plan/], + [['models', 'status', 'extra', '--json'], /unexpected argument/], + [['models', 'status', '--host', 'bogus', '--json'], /unsupported model host/], + [['audit', 'bogus', '--json'], /requires the hooks or context subcommand/], + [['heal', 'bogus', '--json'], /requires the hooks subcommand/], + [['heal', 'hooks', '--yes', '--json'], /--yes requires --apply/], + [['x', 'aqe-store', 'bogus', '--json'], /usage: ak x aqe-store/], + [['x', 'aqe-embedding', 'bogus', '--json'], /aqe-embedding/], + [['x', 'codex-context', 'bogus', '--json'], /codex-context/], + [['x', 'skills', 'bogus', '--json'], /usage: ak x skills/], + [['x', 'reference', 'bogus', '--json'], /reference/], + [['x', 'statusline', 'bogus', '--json'], /usage: ak x statusline/], + [['x', 'daemon-gc', 'bogus', '--json'], /unexpected argument/], + [['x', 'harvest', 'bogus', '--json'], /unexpected argument/], + [['host', 'bogus', '--json'], /unknown host subcommand/], + [['host', 'status', 'extra', '--json'], /unexpected argument/], + [['host', 'adapters', '--dry-run', '--json'], /has no preview/], + [['host', 'adapters', 'unknown', '--json'], /experimental host-adapter surface is disabled/], +]; + +for (const [args, message] of COMMAND_USAGE_ERRORS) { + test(`ak ${args.join(' ')} reports one command-level JSON usage error`, () => { + const child = ak(args); + const out = oneJson(child); + assert.equal(child.status, 2, child.stderr); + assert.deepEqual(Object.keys(out), ['error', 'exitCode']); + assert.equal(out.exitCode, 2); + assert.match(out.error, message); + assert.match(child.stderr, message); + }); +} + +for (const enabled of ['0', '1']) { + for (const verb of ['revoke', 'revoke-grant']) { + test(`ak host adapters ${verb} --json without a name is JSON with feature flag ${enabled}`, () => { + const child = ak(['host', 'adapters', verb, '--json'], { AK_EXPERIMENTAL_HOST_ADAPTERS: enabled }); + const out = oneJson(child); + assert.equal(child.status, 2, child.stderr); + assert.deepEqual(Object.keys(out), ['error', 'exitCode']); + assert.equal(out.exitCode, 2); + assert.match(out.error, new RegExp(`usage: ak host adapters ${verb} `)); + assert.match(child.stderr, new RegExp(`usage: ak host adapters ${verb} `)); + }); + } +} + +test('models rejects an unknown verb even when no snapshot exists', () => { + const child = ak(['models', 'bogus', '--json']); + assert.equal(child.status, 2, child.stderr); + assert.match(oneJson(child).error, /usage: ak models/); +}); + +test('plain status rejects a stray positional with exit 2', () => { + const child = ak(['status', 'stray']); + assert.equal(child.status, 2, child.stderr); + assert.match(child.stdout, /unexpected argument 'stray'/); +}); + test('without --json a command-level usage error still prints on stdout', () => { const child = ak(['status', '--refresh=bogus']); assert.equal(child.status, 2); diff --git a/tests/kit/daemon-gc-rerecord.test.mjs b/tests/kit/daemon-gc-rerecord.test.mjs new file mode 100644 index 00000000..5adb05e5 --- /dev/null +++ b/tests/kit/daemon-gc-rerecord.test.mjs @@ -0,0 +1,62 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { sandboxHome, rmrf } from './helpers/home-sandbox.mjs'; + +const home = sandboxHome('ak-daemon-gc-rerecord'); +after(() => rmrf(home)); +const trapDir = path.join(home, 'no-such-bin'); +const processProbeMarker = path.join(home, 'process-probe-reached'); +fs.mkdirSync(trapDir, { recursive: true }); +for (const command of ['ps', 'powershell']) { + const trap = path.join(trapDir, command); + fs.writeFileSync(trap, `#!/bin/sh\n: > '${processProbeMarker}'\nexit 99\n`, { mode: 0o755 }); +} +const { run } = await import('../../src/commands/x/daemon-gc.mjs'); + +for (const [name, kill, killed, expected] of [ + ['successful reap', true, true, 2], + ['failed reap', true, false, 1], + ['list only', false, true, 1], +]) { + test(`${name} re-records only after a successful kill`, async () => { + const calls = []; + let reapCalls = 0; + const daemon = { pid: 4242, workspace: '/missing-ak-workspace', workspaceExists: false, ageSecs: 1 }; + const result = await run({ + flags: { kill, mcp: false, quiet: true }, + deps: { + daemonLifecycle: { + list: async opts => { calls.push(opts); return [daemon]; }, + reap: () => { reapCalls++; return [{ ...daemon, killed }]; }, + }, + mcpLifecycle: { list: async () => [], reap: () => { throw new Error('MCP reap forbidden'); } }, + }, + }); + assert.equal(result, 0); + assert.equal(reapCalls, kill ? 1 : 0); + assert.equal(calls.length, expected); + if (expected === 2) assert.deepEqual(calls[1], { refresh: true, record: true, source: 'daemon-gc' }); + assert.equal(fs.existsSync(processProbeMarker), false, 'real process discovery was reached'); + }); +} + +test('JSON listing observes MCP transports without reaping or re-recording', async () => { + const daemonCalls = []; + let mcpCalls = 0; + const oldLog = console.log; + console.log = () => {}; + try { + assert.equal(await run({ + flags: { json: true, kill: true, mcp: false }, + deps: { + daemonLifecycle: { list: async opts => { daemonCalls.push(opts); return []; }, reap: () => { throw new Error('reap forbidden'); } }, + mcpLifecycle: { list: async () => { mcpCalls++; return []; }, reap: () => { throw new Error('MCP reap forbidden'); } }, + }, + }), 0); + } finally { console.log = oldLog; } + assert.deepEqual(daemonCalls, [undefined]); + assert.equal(mcpCalls, 1); + assert.equal(fs.existsSync(processProbeMarker), false, 'real process discovery was reached'); +}); diff --git a/tests/kit/deja-vu-teardown-verify.test.mjs b/tests/kit/deja-vu-teardown-verify.test.mjs index 8f5041cf..c36e0ca2 100644 --- a/tests/kit/deja-vu-teardown-verify.test.mjs +++ b/tests/kit/deja-vu-teardown-verify.test.mjs @@ -80,7 +80,7 @@ test('deja-vu verify cleanly skips disabled, unowned integration without probing const { result, out } = await captureLog(() => verify.verifyDejaVu({ cfg: cfg({ enabled: false }), adapter, })); - assert.equal(result, true); + assert.deepEqual(result, { status: 'skipped', reason: 'disabled and unowned' }); assert.deepEqual(calls, []); assert.match(out, /disabled and unowned — skipped/); }); diff --git a/tests/kit/exec.test.mjs b/tests/kit/exec.test.mjs index 0526bcda..770ff176 100644 --- a/tests/kit/exec.test.mjs +++ b/tests/kit/exec.test.mjs @@ -3,7 +3,259 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { run, have, resolveShim } from '../../src/lib/exec.mjs'; +import { run, have, resolveShim, withAbortSignal } from '../../src/lib/exec.mjs'; + +const isAlive = (pid) => { + try { process.kill(pid, 0); return true; } catch { return false; } +}; +async function waitUntil(predicate) { + for (let n = 0; n < 60; n += 1) { + if (predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return predicate(); +} + +for (const [name, options] of [ + ['no input with explicit signal', { input: undefined, inherited: false }], + ['input with explicit signal', { input: '', inherited: false }], + ['no input with inherited signal', { input: undefined, inherited: true }], + ['input with inherited signal', { input: '', inherited: true }], +]) { + test(`run() abort reaps owned grandchild: ${name}`, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-abort-tree-')); + const pidFile = path.join(dir, 'pids.json'); + const controller = new AbortController(); + const code = `const {spawn}=require('node:child_process'); + const gc=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); + require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); + setInterval(()=>{},1000);`; + let pids = []; + let pending; + try { + const launch = () => run(process.execPath, ['-e', code], { + input: options.input, timeout: 5_000, + ...(options.inherited ? {} : { signal: controller.signal }), + }); + pending = options.inherited ? withAbortSignal(controller.signal, launch) : launch(); + assert.equal(await waitUntil(() => fs.existsSync(pidFile)), true, 'owned children started'); + pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); + assert.equal(isAlive(pids[1]), true, 'grandchild alive before abort'); + controller.abort(); + const result = await pending; + assert.notEqual(result.code, 0); + assert.equal(await waitUntil(() => !isAlive(pids[1])), true, + 'abort must terminate the grandchild, not only its parent'); + } finally { + controller.abort(); + for (const pid of pids) { + if (isAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ } + } + } + await pending; + assert.equal(await waitUntil(() => pids.every((pid) => !isAlive(pid))), true, 'fixture children exited'); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} + +test('run() does not spawn for a pre-aborted signal, including inherited abort', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-preabort-')); + const marker = path.join(dir, 'spawned'); + const aborted = new AbortController(); + aborted.abort(); + const args = ['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)},'yes')`]; + try { + for (const input of [undefined, '']) { + const explicit = await run(process.execPath, args, { signal: aborted.signal, input }); + const inherited = await withAbortSignal(aborted.signal, + () => run(process.execPath, args, { input })); + assert.notEqual(explicit.code, 0); + assert.notEqual(inherited.code, 0); + assert.equal(fs.existsSync(marker), false); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('an explicit live signal takes precedence over an aborted inherited signal', async () => { + const inherited = new AbortController(); + inherited.abort(); + const explicit = new AbortController(); + for (const input of [undefined, '']) { + const result = await withAbortSignal(inherited.signal, () => run( + process.execPath, ['-e', 'process.stdout.write("ok")'], + { signal: explicit.signal, input }, + )); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, 'ok'); + } +}); + +for (const stop of ['abort', 'timeout']) { + test(`Windows ${stop} after direct-child exit reports incomplete tree cleanup`, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-exited-root-')); + const pidFile = path.join(dir, 'pids.json'); + const exitFile = path.join(dir, 'parent-exit'); + const readyFile = path.join(dir, 'descendant-ready'); + const controller = new AbortController(); + // libuv's Windows Job Object kills non-detached children when their + // parent exits. unref() alone only releases the event-loop reference. + // Deliberately escape that job while retaining the real output handles. + const descendant = `require('node:fs').writeFileSync(${JSON.stringify(readyFile)},'ready'); + setTimeout(()=>{},10000);`; + const code = `const {spawn}=require('node:child_process'); + const fs=require('node:fs'); + const gc=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{ + detached:process.platform==='win32',stdio:['ignore','inherit','inherit']}); + gc.unref(); + fs.writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid])); + const ready=setInterval(()=>{ + if(fs.existsSync(${JSON.stringify(readyFile)})) clearInterval(ready); + },10); + setTimeout(()=>process.exit(2),4000).unref(); + process.on('exit',()=>fs.writeFileSync(${JSON.stringify(exitFile)},'yes'));`; + let pids = []; + let pending; + try { + const started = Date.now(); + pending = run(process.execPath, ['-e', code], { + windows: true, signal: controller.signal, timeout: stop === 'timeout' ? 1_200 : 5_000, + }); + assert.equal(await waitUntil(() => fs.existsSync(pidFile)), true); + pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')); + assert.equal(await waitUntil(() => fs.existsSync(readyFile)), true, 'descendant initialized'); + assert.equal(await waitUntil(() => fs.existsSync(exitFile)), true, 'direct child exited'); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(isAlive(pids[1]), true, 'descendant still owns the output pipe'); + const stoppedAt = Date.now(); + if (stop === 'abort') controller.abort(); + const result = await pending; + assert.notEqual(result.code, 0, 'an incomplete run cannot report success'); + assert.match(result.stderr, /incomplete.*tree cleanup/i); + assert.ok(Date.now() - (stop === 'abort' ? stoppedAt : started) + < (stop === 'abort' ? 1_400 : 2_600), 'return is bounded by abort or timeout'); + } finally { + controller.abort(); + for (const pid of pids) { + if (isAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ } + } + } + await pending; + assert.equal(await waitUntil(() => pids.every((pid) => !isAlive(pid))), true, 'fixture children exited'); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +} + +test('Windows taskkill stall has a bounded cleanup wait and reports uncertainty', { + skip: process.platform === 'win32', +}, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-taskkill-stall-')); + const killer = path.join(dir, 'taskkill.exe'); + const oldPath = process.env.PATH; + fs.writeFileSync(killer, `#!${process.execPath}\nsetTimeout(() => process.exit(1), 1500);\n`, { mode: 0o755 }); + process.env.PATH = `${dir}${path.delimiter}${oldPath}`; + try { + const started = Date.now(); + const result = await run(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + windows: true, timeout: 100, + }); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /incomplete.*tree cleanup/i); + assert.ok(Date.now() - started < 1400, 'does not wait for the stalled taskkill process'); + } finally { + process.env.PATH = oldPath; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('run() enforces maxBuffer in UTF-8 bytes', async () => { + const result = await run(process.execPath, ['-e', 'process.stdout.write("ééé")'], { + maxBuffer: 4, + }); + assert.notEqual(result.code, 0); + assert.match(result.stderr, /maxBuffer/i); +}); + +function recordShimChildren(reported, observedShimPid, cleanupPids) { + // The reported parent is assertion evidence, never kill authority. + cleanupPids.push(reported[0], reported[1], observedShimPid); + assert.equal(reported[2], observedShimPid, 'Node is a child of the observed PowerShell shim'); +} + +test('a mismatched reported parent never becomes a fixture cleanup kill target', () => { + const cleanupPids = []; + const killTargets = []; + const unexpectedParent = 990003; + // Synthetic PIDs and an injected recording function: no real process signal. + const kill = (pid) => { killTargets.push(pid); }; + try { + assert.throws(() => recordShimChildren([990001, 990002, unexpectedParent], 990004, cleanupPids), + /Node is a child of the observed PowerShell shim/); + } finally { + for (const pid of cleanupPids) kill(pid); + } + assert.equal(killTargets.includes(unexpectedParent), false, 'unowned reported parent must never be signalled'); + assert.deepEqual(killTargets, [990001, 990002, 990004]); +}); + +test('Windows abort reaps the Node child behind a PowerShell shim and its grandchild', { + skip: process.platform !== 'win32', +}, async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-abort-shim-')); + const pidFile = path.join(dir, 'pids.json'); + const shimEntry = path.join(dir, 'powershell-pid'); + const controller = new AbortController(); + const quotedNode = process.execPath.replaceAll("'", "''"); + const pids = []; + let pending; + let outcome; + try { + fs.writeFileSync(path.join(dir, 'codex.cmd'), '@echo off\r\n'); + const code = `const {spawn}=require('node:child_process'); + const gc=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'}); + require('node:fs').writeFileSync(${JSON.stringify(pidFile)},JSON.stringify([process.pid,gc.pid,process.ppid])); + setInterval(()=>{},1000);`; + // npm shims forward a script filename; PowerShell 5.1 reserializes + // native arguments, so multiline node -e source is not that interface. + const script = path.join(dir, 'fixture.cjs'); + fs.writeFileSync(script, code); + fs.writeFileSync(path.join(dir, 'codex.ps1'), + `[System.IO.File]::WriteAllText('${shimEntry.replaceAll("'", "''")}',[string]$PID)\n` + + `& '${quotedNode}' '${script.replaceAll("'", "''")}' $args\nexit $LASTEXITCODE\n`); + pending = run('codex', [], { + // PowerShell checks PATHEXT even for the absolute Node.exe path. + // Excluding .EXE changes native execution into document activation. + env: { PATH: dir, PATHEXT: '.COM;.EXE;.BAT;.CMD' }, signal: controller.signal, timeout: 10_000, + }); + pending.then((result) => { outcome = result; }); + assert.equal(await waitUntil(() => fs.existsSync(pidFile) || outcome), true, 'PowerShell launch settled or ready'); + assert.equal(fs.existsSync(shimEntry), true, `PowerShell entered owned shim: ${JSON.stringify(outcome)}`); + assert.equal(fs.existsSync(pidFile), true, `PowerShell launched Node: ${JSON.stringify(outcome)}`); + const reported = JSON.parse(fs.readFileSync(pidFile, 'utf8')); + recordShimChildren(reported, Number(fs.readFileSync(shimEntry, 'utf8')), pids); + assert.equal(isAlive(pids[1]), true); + controller.abort(); + const result = await pending; + assert.notEqual(result.code, 0); + assert.equal(await waitUntil(() => pids.every((pid) => !isAlive(pid))), true, + 'taskkill must remove the Node process and its grandchild'); + } finally { + controller.abort(); + for (const pid of pids) { + if (isAlive(pid)) { + try { process.kill(pid, 'SIGKILL'); } catch { /* already exited */ } + } + } + await pending; + assert.equal(await waitUntil(() => pids.every((pid) => !isAlive(pid))), true, 'fixture children exited'); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); // code-quality Finding 2: exec.mjs used to set shell:true for a fixed set of // Windows .cmd shims (npm/npx/claude/ruflo/aqe/claude-flow), which handed diff --git a/tests/kit/helpers/home-sandbox.mjs b/tests/kit/helpers/home-sandbox.mjs index 65f8bad0..038c6dfd 100644 --- a/tests/kit/helpers/home-sandbox.mjs +++ b/tests/kit/helpers/home-sandbox.mjs @@ -287,7 +287,9 @@ export function offlineKitConfig(extra = {}) { ttlHours: 24, last: Date.now(), seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' }, - self: { last: Date.now(), best: { version: '0.0.1', tag: 'latest' } }, + // This fixture runs against the prerelease kit, whose self check uses + // both channels. A legacy latest-only record must retry under A3. + self: { last: Date.now(), best: { version: '0.0.1', tag: 'latest' }, lastTags: ['latest', 'next'] }, }, ...extra, }; diff --git a/tests/kit/host-dry-run.test.mjs b/tests/kit/host-dry-run.test.mjs index a7db8ed2..68fec135 100644 --- a/tests/kit/host-dry-run.test.mjs +++ b/tests/kit/host-dry-run.test.mjs @@ -59,6 +59,23 @@ function ak(sb, ...args) { const readKit = (home) => fs.readFileSync(path.join(home, '.config', 'agentic-kit', 'kit.json'), 'utf8'); +for (const args of [ + ['host', 'status'], ['host'], ['x', 'host'], +]) { + test(`ak ${args.join(' ')} --dry-run --json reports status without recording evidence`, (t) => { + const sb = sandbox(t); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + const r = ak(sb, ...args, '--dry-run', '--json'); + assert.equal(r.status, 0, r.all); + const out = JSON.parse(r.stdout); + assert.deepEqual(Object.keys(out), ['scope', 'config', 'hosts', 'providers']); + assert.ok(out.hosts.claude); + assertUnchanged(beforeHome, sb.home, 'status preview must not write host evidence or config'); + assertUnchanged(beforeProject, sb.project, 'status preview must not write project files'); + }); +} + test('ak host pick --dry-run previews and writes nothing', (t) => { const sb = sandbox(t); const beforeHome = snapshot(sb.home); @@ -119,6 +136,74 @@ test('ak host pick --dry-run --json carries previewOfCurrent, true only with no assert.equal(explicitJson.previewOfCurrent, false); }); +test('host pick refusal and x host alias dry-run emit one JSON object without writes', (t) => { + const sb = sandbox(t); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + for (const command of ['host', 'x']) { + const args = command === 'x' ? ['x', 'host'] : ['host']; + const r = ak(sb, ...args, 'pick', '--host', 'claude,opencdoe', '--dry-run', '--json'); + assert.equal(r.status, 2, r.all); + const out = JSON.parse(r.stdout); + assert.deepEqual(Object.keys(out), ['error', 'exitCode']); + assert.equal(out.exitCode, 2); + assert.match(out.error, /unknown host\(s\): opencdoe/); + assert.match(r.stderr, /unknown host\(s\): opencdoe/); + } + assertUnchanged(beforeHome, sb.home, 'refused picks must not touch HOME'); + assertUnchanged(beforeProject, sb.project, 'refused picks must not touch the project'); +}); + +test('host off dry-run JSON previews teardown and preserves configuration', (t) => { + const sb = sandbox(t, divergedConfig()); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + const r = ak(sb, 'host', 'off', '--dry-run', '--json'); + assert.equal(r.status, 0, r.all); + const out = JSON.parse(r.stdout); + assert.equal(out.dryRun, true); + assert.deepEqual(out.wouldDisable, ['claude', 'codex']); + assert.equal(out.primaryHost, 'claude'); + assert.deepEqual(out.wouldClear, ['aqe provider/fallback', 'ruflo providers', 'activity routing']); + assert.equal(out.wouldStripManagedProviderEnv, true); + assert.equal(out.wouldRestoreOrRemoveManagedAqeConfig, true); + assert.equal(out.wouldReconcileOpencodeGuidance, true); + assert.equal(out.wouldTeardownOpencode, false); + assert.equal(out.wouldRemoveManagedCodexMcp, false); + assert.match(r.stderr, /dry run/i); + assertUnchanged(beforeHome, sb.home, 'off preview must not touch HOME'); + assertUnchanged(beforeProject, sb.project, 'off preview must not touch the project'); +}); + +test('host reset-routes dry-run JSON reports selected and empty routes without writes', (t) => { + const sb = sandbox(t, divergedConfig()); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + const selected = ak(sb, 'host', 'reset-routes', '--activity', 'architecture', '--dry-run', '--json'); + assert.equal(selected.status, 0, selected.all); + assert.deepEqual(JSON.parse(selected.stdout), { dryRun: true, activities: ['architecture'] }); + const empty = ak(sb, 'host', 'reset-routes', '--activity', 'design', '--dry-run', '--json'); + assert.equal(empty.status, 0, empty.all); + assert.deepEqual(JSON.parse(empty.stdout), { dryRun: true, activities: [] }); + const invalid = ak(sb, 'host', 'reset-routes', '--activity', 'not-an-activity', '--dry-run', '--json'); + assert.equal(invalid.status, 0, invalid.all); + assert.deepEqual(JSON.parse(invalid.stdout), { dryRun: true, activities: [] }); + assert.match(invalid.stderr, /unknown activity 'not-an-activity'/); + assertUnchanged(beforeHome, sb.home, 'route previews must not touch HOME'); + assertUnchanged(beforeProject, sb.project, 'route previews must not touch the project'); +}); + +test('host reset-routes dry-run JSON reports no divergence as an empty preview', (t) => { + const sb = sandbox(t); + const beforeHome = snapshot(sb.home); + const beforeProject = snapshot(sb.project); + const r = ak(sb, 'host', 'reset-routes', '--dry-run', '--json'); + assert.equal(r.status, 0, r.all); + assert.deepEqual(JSON.parse(r.stdout), { dryRun: true, activities: [] }); + assertUnchanged(beforeHome, sb.home, 'no-op route preview must not touch HOME'); + assertUnchanged(beforeProject, sb.project, 'no-op route preview must not touch the project'); +}); + test('ak host off --dry-run previews and writes nothing', (t) => { const sb = sandbox(t, divergedConfig()); const beforeHome = snapshot(sb.home); diff --git a/tests/kit/host-pick-rerecord.test.mjs b/tests/kit/host-pick-rerecord.test.mjs new file mode 100644 index 00000000..72887e89 --- /dev/null +++ b/tests/kit/host-pick-rerecord.test.mjs @@ -0,0 +1,57 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { sandboxHome, rmrf, writeKitConfig, offlineKitConfig } from './helpers/home-sandbox.mjs'; + +const home = sandboxHome('ak-host-pick-rerecord'); +after(() => rmrf(home)); +const host = await import('../../src/commands/x/host.mjs'); +const cfg = { integrations: { hosts: { claude: false, codex: true, opencode: false } } }; +const cwd = '/disposable-project'; + +for (const [name, initial, ok, expected] of [ + ['successful install', 'absent', true, 2], + ['failed install', 'absent', false, 1], + ['present host', 'external', true, 1], +]) { + test(`host pick ${name} re-records only after success`, async () => { + const states = []; + const facts = []; + const installs = []; + await host.installPickAbsentHosts(cfg, cwd, { + installState: async (_host, opts) => { states.push(opts); return { method: states.length === 1 ? initial : 'npm', version: '1.0.0' }; }, + install: async id => { installs.push(id); return { ok, detail: ok ? 'installed' : 'failed' }; }, + collectFacts: async opts => { facts.push(opts); }, + }); + assert.equal(states.length, expected); + assert.deepEqual(installs, initial === 'absent' ? ['codex'] : []); + if (expected === 2) { + assert.deepEqual(states[1], { refresh: true, record: true, source: 'host-pick' }); + assert.deepEqual(facts, [{ cwd, cfg, refresh: true, record: true, source: 'host-pick' }]); + } else assert.deepEqual(facts, []); + }); +} + +test('disabled hosts do not probe, install, or record evidence', async () => { + await host.installPickAbsentHosts({ integrations: { hosts: {} } }, cwd, { + installState: async () => { throw new Error('disabled host probed'); }, + install: async () => { throw new Error('disabled host installed'); }, + collectFacts: async () => { throw new Error('disabled host recorded'); }, + }); +}); + +test('host command dispatch passes the lifecycle to pick before installation', async () => { + writeKitConfig(home, offlineKitConfig()); + const sentinel = new Error('injected host lifecycle reached'); + let calls = 0; + await assert.rejects(host.run({ + flags: { host: 'codex', yes: true, 'aqe-provider': 'none' }, + positionals: ['pick'], + pkgRoot: process.cwd(), + deps: { hostLifecycle: { + installState: async () => { calls++; throw sentinel; }, + install: async () => { throw new Error('installer reached'); }, + collectFacts: async () => { throw new Error('collector reached'); }, + } }, + }), error => error === sentinel); + assert.equal(calls, 1); +}); diff --git a/tests/kit/live-check-evidence.test.mjs b/tests/kit/live-check-evidence.test.mjs index f23f385b..7b010b60 100644 --- a/tests/kit/live-check-evidence.test.mjs +++ b/tests/kit/live-check-evidence.test.mjs @@ -7,6 +7,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { sandboxHome, assertSandboxed, snapshot, assertUnchanged, captureLog, rmrf, sandboxProject, writeKitConfig, offlineKitConfig, fakeGlobalRoot, @@ -18,9 +19,11 @@ const paths = await import('../../src/lib/paths.mjs'); const evidence = await import('../../src/lib/live-check-evidence.mjs'); const { writeEvidence } = await import('../../src/lib/evidence.mjs'); const aqeSection = (await import('../../src/commands/status/sections/aqe.mjs')).default; +const projectMemorySection = (await import('../../src/commands/status/sections/project-memory.mjs')).default; const { SYNC_STEPS } = await import('../../src/commands/sync.mjs'); assertSandboxed(paths, HOME); -paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9' })); +const GLOBAL_ROOT = fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9' }); +paths._setGlobalRootForTest(GLOBAL_ROOT); const PROJECT = sandboxProject('ak-live-evidence'); const NOW = Date.parse('2026-09-26T12:00:00Z'); @@ -33,6 +36,70 @@ test('the store lives under the kit state directory, one file per check', () => assert.deepEqual([...evidence.LIVE_CHECK_IDS].sort(), ['aqe-embedding', 'deja-vu', 'mcp', 'memory', 'providers', 'security']); assert.equal(evidence.LIVE_CHECK_TTL_MS, 24 * 3600_000); + assert.deepEqual(evidence.RECORDED_CHECK_IDS, [...evidence.LIVE_CHECK_IDS, 'memory-routes']); +}); + +test('routing evidence is separate from the generic memory round trip and bound to CLI version and platform', () => { + reset(); + const key = (routingVersion, platform) => evidence.liveCheckInputsKey('memory-routes', { routingVersion, platform }); + assert.notEqual(key('3.45.0', 'darwin'), key('3.45.1', 'darwin')); + assert.notEqual(key('3.45.0', 'darwin'), key('3.45.0', 'linux')); + evidence.recordLiveCheck({ id: 'memory', status: 'passed', source: 'status-refresh-live', inputsKey: 'generic' }, { now: NOW }); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: key('3.45.0', 'darwin'), now: NOW }), null); + evidence.recordLiveCheck({ id: 'memory-routes', status: 'passed', source: 'status-refresh-live', + inputsKey: key('3.45.0', 'darwin') }, { now: NOW }); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: key('3.45.1', 'darwin'), now: NOW }).invalidated, true); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: key('3.45.0', 'linux'), now: NOW }).invalidated, true); +}); + +test('the routing key follows the installed CLI even when the wrapper version stays fixed', (t) => { + const root = fs.mkdtempSync(path.join(HOME, 'route-version-')); + t.after(() => { paths._setGlobalRootForTest(GLOBAL_ROOT); rmrf(root); }); + const wrapper = path.join(root, 'ruflo'); + const cli = path.join(wrapper, 'node_modules', '@claude-flow', 'cli'); + fs.mkdirSync(cli, { recursive: true }); + fs.writeFileSync(path.join(wrapper, 'package.json'), JSON.stringify({ version: '3.45.0' })); + const setCli = (version) => fs.writeFileSync(path.join(cli, 'package.json'), JSON.stringify({ version })); + paths._setGlobalRootForTest(root); + setCli('3.45.0'); + const before = evidence.liveCheckInputsKey('memory-routes', { platform: 'darwin' }); + setCli('3.45.1'); + assert.notEqual(evidence.liveCheckInputsKey('memory-routes', { platform: 'darwin' }), before); +}); + +test('two-store row lowers only for applicable routing proof and warns after failure or upgrade', async (t) => { + reset(); + const cwd = sandboxProject('ak-route-row'); + t.after(() => rmrf(cwd)); + const dir = path.join(cwd, '.swarm'); + fs.mkdirSync(dir); + for (const name of ['memory.db', 'agentdb-memory.db']) { + const db = new DatabaseSync(path.join(dir, name)); + db.exec('CREATE TABLE memory_entries (status TEXT); INSERT INTO memory_entries VALUES (NULL)'); + db.close(); + } + const row = async (routingVersion = '3.45.0', platform = 'darwin', now = NOW) => + (await projectMemorySection.collect({ cwd, rufloVersion: routingVersion, platform, now })) + .find((item) => /two project memory stores/.test(item.message)); + const key = evidence.liveCheckInputsKey('memory-routes', { routingVersion: '3.45.0', platform: 'darwin' }); + assert.equal((await row()).level, 'warn'); + evidence.recordLiveCheck({ id: 'memory', status: 'passed', source: 'status-refresh-live', inputsKey: 'generic' }, { now: NOW }); + assert.equal((await row()).level, 'warn'); + evidence.recordLiveCheck({ id: 'memory-routes', status: 'passed', source: 'status-refresh-live', inputsKey: key }, { now: NOW }); + const beforeRead = snapshot(HOME); + assert.equal((await row()).level, 'info'); + assert.match((await row()).message, /existing-corpus access unverified/); + assertUnchanged(beforeRead, HOME, 'ordinary project-memory status reads neither probe nor write'); + assert.equal((await row('3.45.1')).level, 'warn'); + assert.equal((await row('3.45.0', 'linux')).level, 'warn'); + assert.equal((await row(null)).level, 'warn'); + assert.equal((await row('3.45.0', 'darwin', NOW + 2 * evidence.LIVE_CHECK_TTL_MS)).level, 'info'); + evidence.recordLiveCheck({ id: 'memory-routes', status: 'inconclusive', source: 'status-refresh-live', inputsKey: key }, { now: NOW + 1000 }); + assert.equal((await row('3.45.0', 'darwin', NOW + 2000)).level, 'warn'); + evidence.recordLiveCheck({ id: 'memory', status: 'passed', source: 'status-refresh-live', inputsKey: 'generic' }, { now: NOW + 3000 }); + assert.equal((await row('3.45.0', 'darwin', NOW + 4000)).level, 'warn'); + fs.writeFileSync(path.join(evidence.liveCheckDir(), 'memory-routes.json'), '{corrupt'); + assert.equal((await row()).level, 'warn'); }); test('a recorded result reads back with its source and age', () => { diff --git a/tests/kit/live-checks.test.mjs b/tests/kit/live-checks.test.mjs index 75a2b1e0..ad5db1a5 100644 --- a/tests/kit/live-checks.test.mjs +++ b/tests/kit/live-checks.test.mjs @@ -12,6 +12,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { sandboxHome, assertSandboxed, snapshot, assertUnchanged, captureLog, rmrf, @@ -22,13 +23,15 @@ const HOME = sandboxHome('ak-live-checks'); const paths = await import('../../src/lib/paths.mjs'); const live = await import('../../src/lib/live-checks.mjs'); const evidence = await import('../../src/lib/live-check-evidence.mjs'); +const projectMemorySection = (await import('../../src/commands/status/sections/project-memory.mjs')).default; const { refreshRequestFromFlags, cliRefreshStages } = await import('../../src/lib/refresh.mjs'); const status = await import('../../src/commands/status.mjs'); const { loadKitConfig } = await import('../../src/lib/config.mjs'); assertSandboxed(paths, HOME); const PROJECT = sandboxProject('ak-live-checks'); -paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9' })); +const GLOBAL_ROOT = fakeGlobalRoot(HOME, { ruflo: '9.9.9' }); +paths._setGlobalRootForTest(GLOBAL_ROOT); const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const seedHome = (cfg = offlineKitConfig()) => { @@ -93,7 +96,7 @@ test('each check carries its timeout and the evidence id its result is remembere assert.equal(entry('learning').evidenceId, null); assert.equal(entry('harvest').evidenceId, null); assert.equal(entry('aqe').evidenceId, null, 'the aqe proof remembers only its embedding request, itself'); - assert.equal(entry('memory-routes').evidenceId, 'memory'); + assert.equal(entry('memory-routes').evidenceId, 'memory-routes'); }); test('without --only the quick checks that apply run; mcp runs whenever Codex is enabled', async () => { @@ -219,9 +222,11 @@ test('a named check that does not apply says on its line that its result is not assert.match(out, /^⚠ {2}mcp failed \(20 ms\) — effective Codex MCP inventory unavailable; not remembered: this check does not apply to your setup$/m); assert.match(out, /^✓ security passed \(30 ms\)$/m, 'a check that applies says nothing more'); const skipped = await runStatus({ refresh: 'live', only: ['deja-vu'] }, [ - { id: 'deja-vu', status: 'passed', reason: null, elapsedMs: 2, entries: [], applies: false }, + { id: 'deja-vu', status: 'skipped', reason: 'disabled and unowned', elapsedMs: 2, entries: [], applies: false }, ]); - assert.match(skipped.out, /^✓ deja-vu passed \(2 ms\) — not remembered: this check does not apply to your setup$/m); + assert.equal(skipped.code, 1, 'a named skipped proof did not pass'); + assert.match(skipped.out, /Running live checks \(\d+ ms\): 1 skipped/); + assert.match(skipped.out, /^⚠ {2}deja-vu skipped \(2 ms\) — disabled and unowned; not remembered: this check does not apply to your setup$/m); }); test('one line per check; with --only each check\'s own lines are indented under it', async () => { @@ -585,25 +590,203 @@ test('a failed check is remembered for status with its first failure as the reas assert.equal(got.reason, '@claude-flow/security missing'); }); -test('the memory-routes proof is remembered as the memory check; learning and harvest are not remembered', async () => { +test('the memory-routes proof is remembered separately; learning and harvest are not remembered', async () => { seedHome(); rmrf(evidence.liveCheckDir()); await runOnly(['memory-routes', 'learning', 'harvest']); - const got = evidence.readLiveCheck('memory', {}); + const got = evidence.readLiveCheck('memory-routes', {}); assert.equal(got.status, 'failed'); assert.equal(got.source, 'status-refresh-live'); - assert.deepEqual(fs.readdirSync(evidence.liveCheckDir()), ['memory.json']); + assert.equal(evidence.readLiveCheck('memory', {}).status, 'failed'); + assert.deepEqual(fs.readdirSync(evidence.liveCheckDir()), ['memory-routes.json', 'memory.json']); +}); + +test('a failed or timed-out routing run replaces a previous pass; a later generic memory pass cannot revive it', async () => { + seedHome(); + rmrf(evidence.liveCheckDir()); + const cfg = offlineKitConfig(); + const route = (run, timeoutMs = 50) => ({ id: 'memory-routes', evidenceId: 'memory-routes', + timeoutMs, applies: () => true, run }); + const generic = { id: 'memory', evidenceId: 'memory', timeoutMs: 50, applies: () => true, + run: async () => true }; + await live.runLiveChecks({ cfg, cwd: PROJECT, checks: [route(async () => ({ status: 'passed' }))] }); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'passed'); + await live.runLiveChecks({ cfg, cwd: PROJECT, checks: [route(async () => false)] }); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'failed'); + await live.runLiveChecks({ cfg, cwd: PROJECT, checks: [route(() => new Promise(() => {}), 10)], graceMs: 1 }); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); + await live.runLiveChecks({ cfg, cwd: PROJECT, checks: [generic] }); + assert.equal(evidence.readLiveCheck('memory').status, 'passed'); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); +}); + +test('a route timeout after the CLI proof keeps the generic memory pass', async () => { + seedHome(); + rmrf(evidence.liveCheckDir()); + const check = { id: 'memory-routes', evidenceId: 'memory-routes', timeoutMs: 10, applies: () => true, + run: ({ onCliOutcome }) => { + onCliOutcome({ status: 'passed', reason: null }); + return new Promise(() => {}); + } }; + const [result] = await live.runLiveChecks({ cfg: offlineKitConfig(), cwd: PROJECT, checks: [check], graceMs: 1 }); + assert.equal(result.status, 'inconclusive'); + assert.equal(evidence.readLiveCheck('memory').status, 'passed'); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); +}); + +test('a CLI upgrade during the route check cannot attribute the old observation to the new version', async (t) => { + seedHome(); + rmrf(evidence.liveCheckDir()); + const root = fs.mkdtempSync(path.join(HOME, 'routing-upgrade-')); + const cli = path.join(root, 'ruflo', 'node_modules', '@claude-flow', 'cli'); + fs.mkdirSync(cli, { recursive: true }); + const setVersion = (version) => fs.writeFileSync(path.join(cli, 'package.json'), JSON.stringify({ version })); + t.after(() => { paths._setGlobalRootForTest(GLOBAL_ROOT); rmrf(root); }); + paths._setGlobalRootForTest(root); + setVersion('3.45.0'); + const oldKey = evidence.liveCheckInputsKey('memory-routes'); + const check = { id: 'memory-routes', evidenceId: 'memory-routes', timeoutMs: 50, applies: () => true, + run: async ({ onCliOutcome }) => { + onCliOutcome({ status: 'passed', reason: null }); + setVersion('3.45.1'); + return { status: 'passed', reason: null }; + } }; + const [result] = await live.runLiveChecks({ cfg: offlineKitConfig(), cwd: PROJECT, checks: [check] }); + const currentKey = evidence.liveCheckInputsKey('memory-routes'); + assert.notEqual(currentKey, oldKey); + assert.equal(result.status, 'inconclusive'); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: oldKey }).status, 'inconclusive'); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: oldKey }).invalidated, false); + assert.equal(evidence.readLiveCheck('memory-routes', { inputsKey: currentKey }).invalidated, true); + assert.equal(evidence.readLiveCheck('memory').status, 'passed'); + + const project = sandboxProject('ak-route-upgrade-row'); + t.after(() => rmrf(project)); + const swarm = path.join(project, '.swarm'); + fs.mkdirSync(swarm); + for (const name of ['memory.db', 'agentdb-memory.db']) { + const db = new DatabaseSync(path.join(swarm, name)); + db.exec('CREATE TABLE memory_entries (status TEXT); INSERT INTO memory_entries VALUES (NULL)'); + db.close(); + } + const row = async (version) => (await projectMemorySection.collect({ cwd: project, + rufloVersion: version })).find((item) => /two project memory stores/.test(item.message)); + assert.equal((await row('3.45.0')).level, 'warn'); + assert.equal((await row('3.45.1')).level, 'warn'); }); test('a skipped deja-vu proof is not remembered as a pass', async () => { seedHome(); rmrf(evidence.liveCheckDir()); const [r] = await runOnly(['deja-vu']); - assert.equal(r.status, 'passed'); + assert.equal(r.status, 'skipped'); + assert.equal(r.reason, 'disabled and unowned'); assert.match(texts(r), /deja-vu disabled and unowned — skipped/); assert.equal(evidence.readLiveCheck('deja-vu', {}), null); }); +test('a skipped applicable check never writes conformance evidence', async () => { + seedHome(); + rmrf(evidence.liveCheckDir()); + const [result] = await live.runLiveChecks({ cfg: offlineKitConfig(), cwd: PROJECT, + checks: [{ id: 'deja-vu', evidenceId: 'deja-vu', applies: () => true, + run: async () => ({ status: 'skipped', reason: 'not installed' }) }] }); + assert.equal(result.status, 'skipped'); + assert.equal(evidence.readLiveCheck('deja-vu', {}), null); +}); + +test('learning removes only its call-owned folder after success, missing artifacts, thrown runner, and abort', async (t) => { + const tmpRoot = privateRoot(t); + const unrelated = path.join(tmpRoot, 'unrelated'); + fs.mkdirSync(unrelated); + const cases = [ + async (_cmd, _args, { cwd }) => { + const neural = path.join(cwd, '.claude-flow', 'neural'); + fs.mkdirSync(neural, { recursive: true }); + fs.writeFileSync(path.join(neural, 'stats.json'), '{"patternsLearned":1}'); + fs.writeFileSync(path.join(neural, 'patterns.json'), '[{"id":"one"}]'); + return { code: 0, stdout: '', stderr: '' }; + }, + async () => ({ code: 0, stdout: '', stderr: '' }), + async () => { throw new Error('runner failed'); }, + async () => { throw new DOMException('aborted', 'AbortError'); }, + ]; + for (const [i, runner] of cases.entries()) { + const { result } = await captureLog(() => live.verifyLearning({ tmpRoot, runner })); + assert.equal(result, i === 0, `case ${i}`); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated'], `case ${i} left a call-owned folder`); + } +}); + +test('memory removes its call-owned folder even when final purge throws', async (t) => { + const tmpRoot = privateRoot(t); + fs.mkdirSync(path.join(tmpRoot, 'unrelated')); + const runner = async (_cmd, args) => { + if (args[1] === 'init') return { code: 0, stdout: '', stderr: '' }; + if (args[1] === 'store') return { code: 1, stdout: '', stderr: 'store failed' }; + throw new Error('purge failed'); + }; + await assert.rejects(captureLog(() => live.verifyMemory({ tmpRoot, runner, haveCmd: async () => true })), /purge failed/); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated']); +}); + +test('memory removes only its call-owned folder when init throws or is aborted', async (t) => { + const tmpRoot = privateRoot(t); + fs.mkdirSync(path.join(tmpRoot, 'unrelated')); + for (const error of [new Error('runner failed'), new DOMException('aborted', 'AbortError')]) { + const { result } = await captureLog(() => live.verifyMemory({ tmpRoot, + runner: async () => { throw error; }, haveCmd: async () => true })); + assert.equal(result, false); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated']); + } +}); + +test('memory removes its call-owned folder after a successful mocked CLI round trip', async (t) => { + const tmpRoot = privateRoot(t); + fs.mkdirSync(path.join(tmpRoot, 'unrelated')); + let db; + let storedValue; + const runner = async (_cmd, args, { cwd }) => { + if (args[1] === 'init') { + fs.mkdirSync(path.join(cwd, '.swarm')); + db = new DatabaseSync(path.join(cwd, '.swarm', 'memory.db')); + db.exec('CREATE TABLE memory_entries (namespace TEXT, key TEXT)'); + } else if (args[1] === 'store') { + db.prepare('INSERT INTO memory_entries VALUES (?, ?)').run(args[args.indexOf('-n') + 1], args[args.indexOf('-k') + 1]); + storedValue = args[args.indexOf('--value') + 1]; + } else if (args[1] === 'retrieve') { + return { code: 0, stdout: storedValue, stderr: '' }; + } else if (args[1] === 'purge') { + db.exec('DELETE FROM memory_entries'); + db.close(); + } + return { code: 0, stdout: '', stderr: '' }; + }; + const { result } = await captureLog(() => live.verifyMemory({ + tmpRoot, runner, haveCmd: async () => true, observeRoutes: false, + })); + assert.equal(result, true); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated']); +}); + +test('harvest removes only its call-owned folder on success, nonzero result, thrown runner, and abort', async (t) => { + const tmpRoot = privateRoot(t); + fs.mkdirSync(path.join(tmpRoot, 'unrelated')); + for (const mode of ['success', 'nonzero', 'throw', 'abort']) { + const dirs = []; + const runner = async (_cmd, _args, { cwd }) => { + dirs.push(cwd); + if (mode === 'throw') throw new Error('runner failed'); + if (mode === 'abort') throw new DOMException('aborted', 'AbortError'); + return { code: mode === 'nonzero' ? 1 : 0, stdout: '', stderr: '' }; + }; + const { result } = await captureLog(() => live.verifyHarvest({ tmpRoot, runner, haveCmd: async () => true })); + assert.equal(result, mode === 'success', mode); + assert.ok(dirs.length >= 1 && dirs.every((dir) => path.dirname(dir) === tmpRoot), mode); + assert.deepEqual(fs.readdirSync(tmpRoot), ['unrelated'], mode); + } +}); + test('an aqe proof stopped before the embedding request remembers no embedding result', async () => { seedHome(); rmrf(evidence.liveCheckDir()); diff --git a/tests/kit/maintenance-discovery-orchestrator.test.mjs b/tests/kit/maintenance-discovery-orchestrator.test.mjs index 1d72f60c..c9d92ba2 100644 --- a/tests/kit/maintenance-discovery-orchestrator.test.mjs +++ b/tests/kit/maintenance-discovery-orchestrator.test.mjs @@ -145,6 +145,99 @@ test('pause and resume: a paused source keeps its checkpoint and completes once assert.equal(published.scanState, 'published'); }); +test('a paused scan survives restart without publishing partial evidence, then resumes', async (t) => { + const dir = fixture(t); + const root = fixture(t); + buildLargeTree(root, { dirs: 5, filesPerDir: 4 }); + const first = control(root, dir); + const [checkpointed] = await first.orchestrator.start({ sourceIds: [SOURCE.sourceId], maxSlices: 1 }); + assert.equal(checkpointed.scanState, 'checkpointed'); + const paused = first.orchestrator.pause({ sourceId: SOURCE.sourceId }); + assert.equal(paused.state, 'paused'); + assert.ok(paused.visited > 0); + const [summary] = first.historyStore.list(); + assert.equal(summary.state, 'paused'); + assert.equal(summary.visited, paused.visited); + assert.equal(summary.completedPartitions, paused.completedPartitions); + assert.equal(summary.pendingPartitions, paused.pendingPartitions); + assert.equal(summary.completedAt, null); + assert.ok(summary.recordedAt); + assert.equal(first.lastGoodStore.current().length, 0); + + const restarted = control(root, dir); + const [row] = restarted.orchestrator.coverage(); + assert.equal(row.state, 'paused'); + assert.equal(row.visited, paused.visited); + assert.equal(row.completedPartitions, paused.completedPartitions); + assert.equal(row.pendingPartitions, paused.pendingPartitions); + assert.equal(row.lastCompletedAt, null); + assert.equal(restarted.checkpointStore.list().length, 1); + const [published] = await restarted.orchestrator.resume({ sourceIds: [SOURCE.sourceId] }); + assert.equal(published.scanState, 'published'); + assert.equal(restarted.orchestrator.coverage()[0].state, 'complete'); + assert.equal(restarted.checkpointStore.list().length, 0); + assert.equal(restarted.lastGoodStore.current().length, 1); + assert.equal(control(root, dir).orchestrator.coverage()[0].state, 'complete'); +}); + +test('a confirmed stop after pause prevents old paused state from returning', async (t) => { + const dir = fixture(t); + const root = fixture(t); + buildLargeTree(root); + const first = control(root, dir); + await first.orchestrator.start({ sourceIds: [SOURCE.sourceId], maxSlices: 1 }); + first.orchestrator.pause({ sourceId: SOURCE.sourceId }); + first.orchestrator.stop({ sourceId: SOURCE.sourceId, confirmed: true }); + assert.equal(first.checkpointStore.list().length, 0); + assert.equal(control(root, dir).orchestrator.coverage()[0].state, 'not-scanned'); +}); + +test('pause does not claim durable state when its summary write fails', async (t) => { + const dir = fixture(t); + const root = fixture(t); + buildLargeTree(root); + const first = control(root, dir); + await first.orchestrator.start({ sourceIds: [SOURCE.sourceId], maxSlices: 1 }); + const historyStore = { ...first.historyStore, recordSummary: () => { throw new Error('history unavailable'); } }; + const second = control(root, dir, { historyStore }); + assert.throws(() => second.orchestrator.pause({ sourceId: SOURCE.sourceId }), /history unavailable/); + assert.equal(second.orchestrator.coverage()[0].state, 'scanning'); + assert.equal(second.historyStore.list().length, 0); + assert.equal(second.checkpointStore.list().length, 1); +}); + +test('pause refuses when no history store can record the boundary', async (t) => { + const dir = fixture(t); + const root = fixture(t); + buildLargeTree(root); + const first = control(root, dir); + await first.orchestrator.start({ sourceIds: [SOURCE.sourceId], maxSlices: 1 }); + const restarted = control(root, dir, { historyStore: null }); + assert.throws(() => restarted.orchestrator.pause({ sourceId: SOURCE.sourceId }), /history unavailable/); + assert.equal(restarted.orchestrator.coverage()[0].state, 'scanning'); +}); + +test('paused history restores only its matching environment and loses to a later failure at the same millisecond', (t) => { + const dir = fixture(t); + const root = fixture(t); + const fixed = Date.parse('2026-09-08T12:00:00.000Z'); + const first = control(root, dir, { now: () => fixed }); + first.historyStore.recordSummary({ scanId: 'one', sourceId: SOURCE.sourceId, + environmentId: 'other', state: 'paused', completedAt: null, + recordedAt: new Date(fixed).toISOString(), visited: 7 }); + assert.equal(control(root, dir).orchestrator.coverage()[0].state, 'not-scanned'); + first.historyStore.recordSummary({ scanId: 'two', sourceId: SOURCE.sourceId, + environmentId: SOURCE.environmentId, state: 'paused', completedAt: null, + recordedAt: new Date(fixed).toISOString(), visited: 8 }); + assert.equal(control(root, dir).orchestrator.coverage()[0].state, 'paused'); + first.historyStore.recordSummary({ scanId: 'two', sourceId: SOURCE.sourceId, + environmentId: SOURCE.environmentId, state: 'failed', completedAt: new Date(fixed).toISOString(), + visited: 9, limitingReason: 'io-failure' }); + const [row] = control(root, dir).orchestrator.coverage(); + assert.equal(row.state, 'failed'); + assert.equal(row.visited, 9); +}); + test('MNT-DSC-018: stop previews affected work before confirming, then removes the active scan and retains history', async (t) => { const controlDir = fixture(t); const root = fixture(t); @@ -662,3 +755,18 @@ test('scan history rolls over independently at ten records per source and surviv [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); } }); + +test('clearHistory keeps a paused summary while its continuation is open', (t) => { + const dir = fixture(t); + const checkpoints = createCheckpointStore(path.join(dir, 'checkpoints'), { fsImpl: fs }); + const history = createScanHistoryStore(dir, { fsImpl: fs }); + const scanId = 'paused-scan'; + checkpoints.write({ scanId, sourceId: SOURCE.sourceId, environmentId: SOURCE.environmentId, + scanEpoch: 1, completedPartitions: [], pendingPartitions: [], workCounts: { visited: 1 }, + sourceStamps: [], createdAt: new Date().toISOString() }); + history.recordSummary({ scanId, sourceId: SOURCE.sourceId, environmentId: SOURCE.environmentId, + state: 'paused', completedAt: null, recordedAt: new Date().toISOString(), visited: 1 }); + assert.deepEqual(history.clearHistory(), { removed: 0, kept: 1 }); + checkpoints.remove(scanId); + assert.deepEqual(history.clearHistory(), { removed: 1, kept: 0 }); +}); diff --git a/tests/kit/maintenance-management-service.test.mjs b/tests/kit/maintenance-management-service.test.mjs index 0a9da99f..562e49b5 100644 --- a/tests/kit/maintenance-management-service.test.mjs +++ b/tests/kit/maintenance-management-service.test.mjs @@ -696,6 +696,35 @@ test('scan lifecycle: pauseScan and resumeScan accept a bare sourceId (symmetric } }); +test('paused collection root survives a service restart in Discovery and the Inventory partial banner', async (t) => { + const controlRoot = fixtureRoot(t); + const root = fixtureRoot(t); + fs.writeFileSync(path.join(root, 'root-file.txt'), 'x'); + for (let i = 0; i < 5; i += 1) { + const child = path.join(root, `project-${i}`); + fs.mkdirSync(path.join(child, '.git'), { recursive: true }); + fs.writeFileSync(path.join(child, 'file.txt'), 'x'); + } + const sourceId = opaqueId('src', { kind: 'collection-root', root }, INSTALLATION_KEY); + const discovery = { collectionRoots: [{ root, sourceId, maxDepth: null, includeNetwork: false }] }; + const first = buildHarness(t, { controlRoot, discovery }); + await first.service.startScan({ sourceId, maxSlices: 1 }); + const paused = first.service.pauseScan({ sourceId }); + const pausedRow = paused.coverage.find((entry) => entry.sourceId === sourceId); + assert.equal(pausedRow.state, 'paused'); + assert.ok(pausedRow.visited > 0); + + const restarted = buildHarness(t, { controlRoot, discovery }); + const row = restarted.service.discovery().coverage.find((entry) => entry.sourceId === sourceId); + assert.equal(row.state, 'paused'); + assert.equal(row.visited, pausedRow.visited); + assert.equal(row.label, 'Collection root'); + await restarted.service.refreshInventory(); + const page = restarted.service.inventory({}); + assert.equal(page.partialSources.total, 5, 'the paused root joins four unscanned automatic roots'); + assert.ok(page.partialSources.entries.some((entry) => entry.sourceId === sourceId && entry.state === 'paused')); +}); + test('DSC: setAutomaticSource, addExclusion, and removeExclusion round-trip through kit.json', async (t) => { const h = buildHarness(t); await h.service.setAutomaticSource({ sourceId: 'ollama', enabled: false }); diff --git a/tests/kit/maintenance-refresh-injection.test.mjs b/tests/kit/maintenance-refresh-injection.test.mjs new file mode 100644 index 00000000..03a363fe --- /dev/null +++ b/tests/kit/maintenance-refresh-injection.test.mjs @@ -0,0 +1,78 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { spawnEnv } from './helpers/home-sandbox.mjs'; +import { tempDir } from './helpers/temp-dir.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +// The Maintenance service statically imports footprint/index.mjs, so blocking +// that module's import would reject a valid injected run. Instrument its real +// constructor instead; the management facade is lazy and must never import. +const guard = ` +export async function resolve(specifier, context, nextResolve) { + if (specifier.endsWith('/maintenance/management/service.mjs')) { + throw new Error('real management facade imported'); + } + return nextResolve(specifier, context); +} +export async function load(url, context, nextLoad) { + const loaded = await nextLoad(url, context); + if (!url.endsWith('/footprint/index.mjs')) return loaded; + const needle = ' const collect = {'; + const source = String(loaded.source); + if (source.split(needle).length !== 2) throw new Error('collector constructor guard no longer matches'); + return { ...loaded, source: source.replace(needle, + " throw new Error('real footprint collector constructed');\\n" + needle) }; +} +`; + +const child = ` +import assert from 'node:assert/strict'; +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; +register('data:text/javascript,' + encodeURIComponent(process.env.AK_REFRESH_GUARD)); +const { run } = await import(pathToFileURL(process.env.AK_MAINTAIN_MODULE).href); +const calls = []; +const refreshStages = { + maintenance: async () => { calls.push('maintenance'); return { ok: true }; }, + inventory: async () => { calls.push('inventory'); return { ok: true }; }, + local: async () => { calls.push('local'); return { ok: true }; }, +}; +const service = { async report() { calls.push('report'); return { mode: 'read-only' }; } }; +const lines = []; +const originalLog = console.log; +console.log = (...args) => lines.push(args.join(' ')); +let code; +try { + code = await run({ flags: { json: true, refresh: '' }, positionals: ['report'], + deps: { refreshStages, service } }); +} finally { + console.log = originalLog; +} +assert.equal(code, 0, lines.join('\\n')); +assert.deepEqual(calls, ['maintenance', 'inventory', 'local', 'report']); +const result = JSON.parse(lines.join('\\n')); +assert.equal(result.mode, 'read-only'); +assert.equal(result.refresh.ok, true); +assert.deepEqual(result.refresh.stages.map(({ id }) => id), ['maintenance', 'inventory', 'local']); +originalLog('injected refresh used no real constructors'); +`; + +test('injected maintain refresh constructs no real collector or management facade', (t) => { + const home = tempDir('ak-maintain-injected-home', t); + const project = tempDir('ak-maintain-injected-project', t); + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', child], { + cwd: project, + env: spawnEnv(home, { + AK_MAINTAIN_MODULE: path.join(ROOT, 'src/commands/maintain.mjs'), + AK_REFRESH_GUARD: guard, + }), + encoding: 'utf8', + }); + assert.ifError(result.error); + assert.equal(result.status, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`); + assert.match(result.stdout, /injected refresh used no real constructors/); +}); diff --git a/tests/kit/models-command.test.mjs b/tests/kit/models-command.test.mjs index b4774fd4..fc815f6f 100644 --- a/tests/kit/models-command.test.mjs +++ b/tests/kit/models-command.test.mjs @@ -40,6 +40,15 @@ test('models status is a cache-only read', async () => { assert.equal(JSON.parse(result.output).inventory.snapshotId, 'models:test'); }); +test('models rejects an unknown verb with a populated snapshot store', async () => { + const result = await capture(() => run({ + flags: { json: true }, positionals: ['bogus'], + deps: { loadConfig: () => cfg, readStore: () => store }, + })); + assert.equal(result.code, 2); + assert.match(result.output, /usage: ak models status\|refresh\|diff\|explain\|plan/); +}); + test('models status host filter rejects unknown owners and narrows evidence', async () => { const invalid = await capture(() => run({ flags: { json: true, host: 'unknown' }, positionals: ['status'], diff --git a/tests/kit/persisted-file-identity.test.mjs b/tests/kit/persisted-file-identity.test.mjs new file mode 100644 index 00000000..087f80f8 --- /dev/null +++ b/tests/kit/persisted-file-identity.test.mjs @@ -0,0 +1,229 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { tempDir } from './helpers/temp-dir.mjs'; +import { planPartitions, partitionDrifted } from '../../src/lib/maintenance/discovery/partitions.mjs'; +import { inspectHookTarget, atomicReplaceHookTarget } from '../../src/lib/hook-remediation/fs-port.mjs'; +import { JsonlTailer } from '../../src/lib/live/jsonl-tailer.mjs'; +import { createHostHealthSnapshot } from '../../src/lib/host-health-evidence.mjs'; +import { HOOK_HEAL_RECEIPT_SCHEMA, readHookReceipt, sealHookReceipt } from '../../src/lib/hook-remediation/store.mjs'; +import { inspectHostAlignment } from '../../src/lib/host-alignment.mjs'; +import { TranscriptStreams } from '../../src/lib/live/transcript-streams.mjs'; + +const A = 9007199254740992n; +const B = 9007199254740993n; + +test('Discovery stamp survives JSON with adjacent 64-bit inodes and fractional mtime', (t) => { + const root = tempDir('ak-partition-id', t); + const lstatSync = fs.lstatSync; + let ino = A; + const fsImpl = { ...fs, lstatSync(target, options) { + const stat = lstatSync(target, options); + stat.ino = options?.bigint ? ino : Number(ino); + return stat; + } }; + const stamp = planPartitions(root, { fsImpl }).partitions[0].sourceStamps[0]; + assert.equal(stamp.ino, '9007199254740992'); + assert.equal(typeof stamp.mtimeMs, 'number'); + assert.equal(stamp.mtimeMs, lstatSync(root).mtimeMs); + const restored = JSON.parse(JSON.stringify(stamp)); + assert.equal(partitionDrifted({ sourceStamps: [{ ...restored, ino: Number(A) }] }, { fsImpl }), true, + 'unsafe legacy number cannot authorize a match'); + ino = B; + assert.equal(partitionDrifted({ sourceStamps: [restored] }, { fsImpl }), true); + for (const malformed of ['-1', '01', -1]) { + assert.equal(partitionDrifted({ sourceStamps: [{ ...restored, ino: malformed }] }, { fsImpl }), true); + } + ino = 42n; + const safeStamp = planPartitions(root, { fsImpl }).partitions[0].sourceStamps[0]; + assert.equal(partitionDrifted({ sourceStamps: [{ ...safeStamp, ino: 42 }] }, { fsImpl }), false); +}); + +test('hook target parent identity survives JSON and refuses adjacent replacement', (t) => { + const root = tempDir('ak-hook-parent-id', t); + const file = path.join(root, 'hook.json'); + fs.writeFileSync(file, '{}'); + const statSync = fs.statSync; + let ino = A; + const fsImpl = { ...fs, statSync(target, options) { + const stat = statSync(target, options); + if (target === root) stat.ino = options?.bigint ? ino : Number(ino); + return stat; + } }; + // Exercise the parent guard on every OS before any mutation is allowed. + // This injected branch is not evidence of native Windows atomic replacement. + fsImpl.openSync = (target, flags) => { + assert.equal(flags, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0)); + return fs.openSync(target, flags); + }; + fsImpl.renameSync = () => assert.fail('parent drift must refuse before rename'); + const options = { fsImpl, platform: 'linux' }; + const snapshot = inspectHookTarget(file, root, options); + assert.equal(snapshot.parent.ino, '9007199254740992'); + const savedParent = JSON.parse(JSON.stringify(snapshot.parent)); + ino = B; + assert.throws(() => atomicReplaceHookTarget({ ...snapshot, parent: savedParent }, Buffer.from('[]'), undefined, options), + /target parent changed/); + assert.equal(fs.readFileSync(file, 'utf8'), '{}'); +}); + +test('Windows hook mutation refuses before accessing the filesystem', (t) => { + const root = tempDir('ak-hook-windows-refusal', t); + const file = path.join(root, 'hook.json'); + fs.writeFileSync(file, '{}'); + const snapshot = inspectHookTarget(file, root, { platform: 'win32' }); + const fsImpl = { lstatSync: () => assert.fail('unsupported mutation must refuse before inspection') }; + assert.throws(() => atomicReplaceHookTarget(snapshot, Buffer.from('[]'), undefined, { fsImpl, platform: 'win32' }), + /hook mutation is unsupported on Windows until replace-existing atomicity is proven/); + assert.equal(fs.readFileSync(file, 'utf8'), '{}'); + assert.deepEqual(fs.readdirSync(root), ['hook.json']); +}); + +test('hook snapshot gets identity and fractional mtime from one descriptor stat', (t) => { + const root = tempDir('ak-hook-one-stat', t); + const file = path.join(root, 'hook.json'); + fs.writeFileSync(file, '{}'); + const fstatSync = fs.fstatSync; + let calls = 0; + const fsImpl = { ...fs, fstatSync(fd, options) { + calls++; + assert.equal(options?.bigint, true); + const stat = fstatSync(fd, options); + stat.ino = A; + return stat; + }, lstatSync(target, options) { + const stat = fs.lstatSync(target, options); + if (target === file) stat.ino = A; + return stat; + } }; + const snapshot = inspectHookTarget(file, root, { fsImpl }); + assert.equal(calls, 1); + assert.equal(snapshot.mtimeMs, fs.statSync(file).mtimeMs); +}); + +test('JSONL replacement with a colliding Number inode resets offset and emits new records', (t) => { + const root = tempDir('ak-tailer-id', t); + const file = path.join(root, 'events.jsonl'); + fs.writeFileSync(file, '{"a":1}\n'); + const statSync = fs.statSync; + let ino = A; + t.mock.method(fs, 'statSync', (target, options) => { + const stat = statSync(target, options); + if (target === file) stat.ino = options?.bigint ? ino : Number(ino); + return stat; + }); + const records = []; + const tailer = new JsonlTailer(file, { onRecord: record => records.push(record) }); + tailer.reconcile(); + fs.writeFileSync(file, '{"b":2}\n'); + ino = B; + tailer.reconcile(); + assert.deepEqual(records, [{ a: 1 }, { b: 2 }]); +}); + +test('host health evidence changes when adjacent 64-bit launcher inode changes', (t) => { + const root = tempDir('ak-health-id', t); + const launcher = path.join(root, process.platform === 'win32' ? 'codex.cmd' : 'codex'); + fs.writeFileSync(launcher, 'launcher'); + const observedIds = []; + const statSync = fs.statSync; + let ino = A; + t.mock.method(fs, 'statSync', (target, options) => { + const stat = statSync(target, options); + if (target === launcher) { + assert.equal(options?.bigint, true); + stat.ino = ino; + observedIds.push(ino); + } + return stat; + }); + const snapshot = createHostHealthSnapshot({ env: { PATH: root, PATHEXT: '.CMD' }, inputPaths: () => [] }); + const before = snapshot({ cwd: root, cfg: {} }).key; + assert.deepEqual(observedIds, [A], 'the fixture must be the launcher fingerprinted by this platform'); + ino = B; + assert.notEqual(snapshot({ cwd: root, cfg: {} }).key, before); + assert.deepEqual(observedIds, [A, B]); +}); + +test('hook receipt accepts exact parent IDs but refuses unsafe legacy Numbers', (t) => { + const root = tempDir('ak-receipt-id', t); + fs.chmodSync(root, 0o700); + const id = 'tx-2026-09-29T00-00-00.000Z-0123456789abcdef'; + const dir = path.join(root, id); + fs.mkdirSync(dir, { mode: 0o700 }); + const file = path.join(dir, 'receipt.json'); + const digest = 'a'.repeat(64); + const receipt = { + schemaVersion: HOOK_HEAL_RECEIPT_SCHEMA, id, createdAt: new Date().toISOString(), + status: 'prepared', planDigest: digest, auditId: 'audit', + authorization: { mechanism: 'explicit-action-selection', actionIds: ['one'], trustMutationAuthorized: false }, + actions: [{ + id: 'one', host: 'codex', hostVersion: '1', recipeId: 'recipe', profileId: 'profile', + target: path.join(root, 'hook'), containmentRoot: root, classification: 'safe-automatic', state: 'prepared', + preimage: { sha256: digest, size: 2, mode: 0o600, modeSupported: true, + uid: null, gid: null, specialMode: 0, parent: { realPath: root, dev: '1', ino: '9007199254740993' } }, + postimage: { sha256: digest, size: 2, mode: 0o600, modeSupported: true }, + backup: { relative: path.join('backups', '0000.bin'), sha256: digest, size: 2 }, + }], + }; + // Seed sealed on-disk inputs for reader validation. Durable receipt writes + // have a separate contract and require native directory fsync support. + const seedReceipt = () => fs.writeFileSync(file, JSON.stringify(sealHookReceipt(receipt))); + for (const ino of [String(A), String(B)]) { + receipt.actions[0].preimage.parent.ino = ino; + seedReceipt(); + assert.equal(readHookReceipt(root, id).receipt.actions[0].preimage.parent.ino, ino); + } + receipt.actions[0].preimage.parent.ino = Number(B); + seedReceipt(); + assert.throws(() => readHookReceipt(root, id), /receipt action image is invalid/); + for (const malformed of ['-1', '01', -1]) { + receipt.actions[0].preimage.parent.ino = malformed; + seedReceipt(); + assert.throws(() => readHookReceipt(root, id), /receipt action image is invalid/); + } + receipt.actions[0].preimage.parent.ino = 42; + seedReceipt(); + assert.equal(readHookReceipt(root, id).receipt.actions[0].preimage.parent.ino, 42); +}); + +test('host alignment snapshot serializes adjacent file IDs distinctly', (t) => { + const root = tempDir('ak-align-id', t); + const file = path.join(root, '.mcp.json'); + fs.writeFileSync(file, '{}'); + const lstatSync = fs.lstatSync; + let ino = A; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = lstatSync(target, options); + if (target === file) stat.ino = options?.bigint ? ino : Number(ino); + return stat; + }); + const options = { home: root, codexHome: path.join(root, '.codex'), projectRoots: [root] }; + const before = inspectHostAlignment(options); + assert.equal(before.snapshots.find(s => s.file === file).identity.inode, '9007199254740992'); + ino = B; + assert.notEqual(inspectHostAlignment(options).digest, before.digest); +}); + +test('transcript replay cursor distinguishes adjacent 64-bit file epochs', (t) => { + const root = tempDir('ak-transcript-id', t); + const file = path.join(root, 'session-1.jsonl'); + fs.writeFileSync(file, ''); + const statSync = fs.statSync; + let ino = A; + t.mock.method(fs, 'statSync', (target, options) => { + const stat = statSync(target, options); + if (target === file) stat.ino = options?.bigint ? ino : Number(ino); + return stat; + }); + const first = new TranscriptStreams({ roots: { claude: root }, mask: value => value }); + const before = first.open('claude', 'session-1').snapshot().cursor; + first.close(); + ino = B; + const second = new TranscriptStreams({ roots: { claude: root }, mask: value => value }); + const after = second.open('claude', 'session-1').snapshot().cursor; + second.close(); + assert.notEqual(before, after); +}); diff --git a/tests/kit/project-memory.test.mjs b/tests/kit/project-memory.test.mjs index 7579dbba..30b1cb80 100644 --- a/tests/kit/project-memory.test.mjs +++ b/tests/kit/project-memory.test.mjs @@ -274,7 +274,7 @@ test('the stray search is bounded and says when it stopped early', (t) => { assert.equal(capped.complete, false); assert.equal(capped.visited, 3); const deep = findStrayMemoryStores(root); - assert.equal(deep.complete, true); + assert.equal(deep.complete, false, 'a depth cutoff leaves descendants unchecked'); assert.deepEqual(deep.strays, [], 'folders deeper than the depth bound are not searched'); assert.deepEqual(findStrayMemoryStores(path.join(root, 'missing')), { strays: [], complete: true, visited: 0, nestedRepositories: [] }); }); @@ -300,3 +300,63 @@ test('the stray search stops at a nested repository or an in-checkout worktree: assert.deepEqual(strays.map((stray) => `${stray.kind} ${stray.path}`), ['aqe docs/.agentic-qe']); assert.deepEqual(nestedRepositories, ['.tools', 'packages/api', 'wt/feature']); }); + +test('the stray search finds AQE below ordinary dot folders without entering tool homes or linked folders', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-dot-')); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-outside-')); + t.after(() => { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(outside, { recursive: true, force: true }); }); + fs.mkdirSync(path.join(root, '.superpowers', 'sdd', 'program', 'reports', '.agentic-qe'), { recursive: true }); + fs.mkdirSync(path.join(root, '.notes', 'drafts', '.agentic-qe'), { recursive: true }); + for (const dir of ['.git', '.claude', '.codex', '.agentic-qe', '.swarm', 'node_modules']) { + fs.mkdirSync(path.join(root, dir, 'nested', '.agentic-qe'), { recursive: true }); + } + fs.mkdirSync(path.join(outside, '.agentic-qe')); + fs.symlinkSync(outside, path.join(root, '.notes', 'linked')); + fs.symlinkSync(root, path.join(root, '.notes', 'loop')); + fs.mkdirSync(path.join(root, '.other-repo', '.agentic-qe'), { recursive: true }); + fs.writeFileSync(path.join(root, '.other-repo', '.git'), 'gitdir: elsewhere\n'); + + const result = findStrayMemoryStores(root); + assert.equal(result.complete, true); + assert.deepEqual(result.strays.map((stray) => stray.path), [ + '.notes/drafts/.agentic-qe', + '.superpowers/sdd/program/reports/.agentic-qe', + ]); + assert.deepEqual(result.nestedRepositories, ['.other-repo']); +}); + +test('depth-limited dot subtrees do not claim complete stray coverage', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-limit-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, '.notes', 'a', 'b', 'c', 'd', '.agentic-qe'), { recursive: true }); + const result = findStrayMemoryStores(root); + assert.equal(result.complete, false); + assert.deepEqual(result.strays, []); +}); + +test('dependency root markers are excluded before stray inspection', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-dependency-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'node_modules', '.agentic-qe'), { recursive: true }); + touch(root, 'node_modules/.swarm/memory.db'); + touch(root, 'node_modules/.swarm/agentdb-memory.db'); + const result = findStrayMemoryStores(root); + assert.equal(result.complete, true); + assert.deepEqual(result.strays, []); +}); + +test('a listed dot subtree with denied marker metadata reports incomplete coverage', (t) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-stray-metadata-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, '.notes', '.agentic-qe'), { recursive: true }); + const denied = path.join(root, '.notes', '.agentic-qe'); + const original = fs.lstatSync; + fs.lstatSync = (file, ...args) => { + if (file === denied) throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + return original(file, ...args); + }; + let result; + try { result = findStrayMemoryStores(root); } finally { fs.lstatSync = original; } + assert.equal(result.complete, false); + assert.deepEqual(result.strays, []); +}); diff --git a/tests/kit/ruflo-components-status.test.mjs b/tests/kit/ruflo-components-status.test.mjs index 8e8be73b..15f468ae 100644 --- a/tests/kit/ruflo-components-status.test.mjs +++ b/tests/kit/ruflo-components-status.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { rufloComponentRows, formatComponentResults, componentResultReport, RESTART_REMINDER } from '../../src/commands/status/sections/ruflo-components.mjs'; +import { describeState } from '../../src/lib/ruflo-components/states.mjs'; import { rufloComponentsTrustGroup, trustManifestLines } from '../../src/lib/trust-manifest.mjs'; const view = (id, label, stateId, stateLabel, meaning, action = '') => ({ id, label, state: { id: stateId, label: stateLabel, meaning, action } }); @@ -23,6 +24,45 @@ test('rows lead with a summary and always carry the meaning', () => { assert.equal(rows[3].fix, null); }); +test('applied-unverified gives one restart instruction across message and manual fix', () => { + const [summary, unverified] = rufloComponentRows({ + rufloVersion: '3.44.0', summary: { active: 0, total: 1 }, components: [ + { id: 'minilmPicker', label: 'MiniLM agent picker', state: describeState('applied-unverified') }, + ], + }); + assert.equal(summary.level, 'info'); + assert.equal(unverified.state, 'applied-unverified'); + assert.equal(unverified.level, 'warn'); + assert.equal(unverified.repair, 'manual'); + assert.match(unverified.message, /MiniLM agent picker — applied, not verified: Set, but not yet confirmed/); + assert.match(unverified.fix, /restart Claude Code, Codex and OpenCode, then run ak status --refresh/i); + assert.equal(`${unverified.message} ${unverified.fix}`.match(/restart Claude Code, Codex and OpenCode/gi)?.length, 1); +}); + +test('neighboring component rows retain action, repair, and convergence contracts', () => { + const states = ['not-applied', 'drifted', 'blocked', 'active']; + const rows = rufloComponentRows({ + rufloVersion: '3.44.0', summary: { active: 1, total: 4 }, + components: states.map((id) => ({ id, label: `${id} component`, state: describeState(id) })), + }).slice(1); + for (const id of ['not-applied', 'drifted']) { + const rendered = rows.find((r) => r.state === id); + assert.equal(rendered.level, 'warn'); + assert.equal(rendered.repair, 'sync'); + assert.match(rendered.message, /Run ak sync/); + assert.match(rendered.fix, /sync applies/); + } + const blocked = rows.find((r) => r.state === 'blocked'); + assert.equal(blocked.level, 'fail'); + assert.equal(blocked.repair, 'sync'); + assert.match(blocked.message, /Follow the reason shown, then run ak sync/); + assert.match(blocked.fix, /sync applies/); + const active = rows.find((r) => r.state === 'active'); + assert.equal(active.level, 'ok'); + assert.equal(active.repair, null); + assert.equal(active.fix, null); +}); + test('setup results table lists state and meaning per component', () => { const lines = formatComponentResults(snapshot); assert.ok(lines.some((l) => /Learning profile.*user-managed.*leaves it alone/.test(l))); diff --git a/tests/kit/ruflo-daemon-config.test.mjs b/tests/kit/ruflo-daemon-config.test.mjs index 8c4ac413..90467e09 100644 --- a/tests/kit/ruflo-daemon-config.test.mjs +++ b/tests/kit/ruflo-daemon-config.test.mjs @@ -120,6 +120,174 @@ test('a malformed, non-object or symlinked config.json is user-managed and untou assert.equal(fs.readFileSync(target, 'utf8'), '{}'); }); +test('YAML markers hold JSON creation in apply and preview without hiding user daemon values', (t) => { + for (const extension of ['yaml', 'yml']) { + const root = tmpProject(t); + fs.mkdirSync(path.join(root, '.claude-flow')); + const yaml = path.join(root, '.claude-flow', `config.${extension}`); + fs.writeFileSync(yaml, 'daemon:\n maxConcurrent: 7\n'); + writeSettings(root, { claudeFlow: { daemon: { autoStart: false } } }); + const receipts = {}; + const preview = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts, dryRun: true }); + assert.equal(preview.config, 'user-managed'); + assert.equal(preview.held?.reason, 'yaml-shadow'); + assert.equal(preview.autostart, 'enabled'); + assert.deepEqual(receipts, {}); + const applied = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + assert.deepEqual({ config: applied.config, held: applied.held, changed: applied.changed }, + { config: preview.config, held: preview.held, changed: preview.changed }); + assert.equal(fs.existsSync(configFile(root)), false); + assert.equal(fs.readFileSync(yaml, 'utf8'), 'daemon:\n maxConcurrent: 7\n'); + assert.equal(autoStart(root), true); + } +}); + +test('a root JSON config holds ineffective creation of lower-priority daemon JSON', (t) => { + const root = tmpProject(t); + fs.writeFileSync(path.join(root, 'claude-flow.config.json'), '{"daemon.maxConcurrent":7}'); + const result = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts: {} }); + assert.equal(result.held?.reason, 'higher-priority-json'); + assert.equal(fs.existsSync(configFile(root)), false); +}); + +test('a symlinked .claude-flow directory cannot redirect daemon JSON edits outside the project', (t) => { + const root = tmpProject(t); + const target = tmpProject(t); + fs.symlinkSync(target, path.join(root, '.claude-flow')); + const receipts = {}; + const result = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + assert.equal(result.config, 'user-managed'); + assert.equal(result.held?.invalid, true); + assert.equal(fs.existsSync(path.join(target, 'config.json')), false); + assert.deepEqual(receipts, {}); + fs.writeFileSync(path.join(target, 'config.json'), '{}'); + const second = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + assert.equal(second.config, 'user-managed'); + assert.equal(fs.readFileSync(path.join(target, 'config.json'), 'utf8'), '{}'); +}); + +test('root JSON becoming active holds new keys but permits receipted obsolete-key cleanup', (t) => { + const root = tmpProject(t); + const receipts = {}; + reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + fs.writeFileSync(path.join(root, 'claude-flow.config.json'), '{"daemon.maxConcurrent":7}'); + const result = reconcileRufloDaemon(root, { rufloVersion: '3.46.1', platform: 'darwin', receipts }); + assert.equal(result.held?.reason, 'higher-priority-json'); + assert.deepEqual(readConfig(root), { 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); + assert.deepEqual(receipts[path.resolve(root)].configKeys, + { 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); +}); + +test('cleanup of inactive receipted JSON under root JSON does not restart a live daemon', async (t) => { + const root = rufloRepo(t); + const cfg = { rufloDaemon: { receipts: {} } }; + reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts: cfg.rufloDaemon.receipts }); + fs.writeFileSync(path.join(root, 'claude-flow.config.json'), '{"daemon.maxConcurrent":7}'); + const before = JSON.stringify(cfg.rufloDaemon.receipts); + const preview = reconcileRufloDaemon(root, { + rufloVersion: '3.46.1', platform: 'linux', receipts: cfg.rufloDaemon.receipts, dryRun: true, + }); + assert.equal(preview.config, 'removed'); + assert.equal(JSON.stringify(cfg.rufloDaemon.receipts), before); + const { calls, runner } = recorder(); + const applied = await applyRufloDaemon(root, { + cfg, rufloVersion: '3.46.1', platform: 'linux', runner, alive: () => true, + }); + assert.equal(applied.result.config, 'removed'); + assert.equal(applied.restarted, false); + assert.deepEqual(calls, []); + assert.equal(fs.existsSync(configFile(root)), false); + assert.deepEqual(cfg.rufloDaemon.receipts, {}); +}); + +test('an existing explicit config holds creation of daemon JSON in preview and apply', (t) => { + const root = tmpProject(t); + const external = tmpProject(t); + const custom = path.join(external, 'custom-config.json'); + fs.writeFileSync(custom, '{"daemon.maxConcurrent":7}'); + fs.mkdirSync(path.join(root, '.claude-flow')); + fs.writeFileSync(path.join(root, '.claude-flow', 'config.yaml'), 'daemon:\n maxConcurrent: 9\n'); + const receipts = {}; + const env = { CLAUDE_FLOW_CONFIG: custom }; + const before = fs.readFileSync(custom, 'utf8'); + const preview = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts, env, dryRun: true }); + assert.equal(preview.config, 'user-managed'); + assert.equal(preview.held?.reason, 'explicit-config'); + assert.deepEqual(receipts, {}); + const applied = reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts, env }); + assert.deepEqual(applied, preview); + assert.equal(fs.existsSync(configFile(root)), false); + assert.equal(fs.readFileSync(custom, 'utf8'), before); +}); + +test('relative explicit config resolves from process cwd, and absent path does not hold JSON creation', (t) => { + const root = tmpProject(t); + const external = tmpProject(t); + const originalCwd = process.cwd(); + fs.writeFileSync(path.join(external, 'custom-config.json'), '{"daemon.maxConcurrent":7}'); + try { + process.chdir(external); + const held = reconcileRufloDaemon(root, { + rufloVersion: '3.45.0', platform: 'darwin', receipts: {}, + env: { CLAUDE_FLOW_CONFIG: './custom-config.json' }, dryRun: true, + }); + assert.equal(held.held?.reason, 'explicit-config'); + const writable = reconcileRufloDaemon(root, { + rufloVersion: '3.45.0', platform: 'darwin', receipts: {}, + env: { CLAUDE_FLOW_CONFIG: './missing-config.json' }, dryRun: true, + }); + assert.equal(writable.config, 'written'); + } finally { process.chdir(originalCwd); } +}); + +test('an existing daemon JSON takes precedence over CLAUDE_FLOW_CONFIG', async (t) => { + const root = rufloRepo(t); + const external = tmpProject(t); + const custom = path.join(external, 'custom-config.json'); + fs.writeFileSync(custom, '{"daemon.maxConcurrent":7}'); + fs.writeFileSync(configFile(root), '{}'); + const { calls, runner } = recorder(); + const result = await applyRufloDaemon(root, { + cfg: { rufloDaemon: { receipts: {} } }, rufloVersion: '3.46.1', platform: 'darwin', + env: { CLAUDE_FLOW_CONFIG: custom }, runner, alive: () => true, + }); + assert.equal(result.result.config, 'written'); + assert.equal(result.restarted, true); + assert.deepEqual(readConfig(root), { 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); + assert.equal(fs.readFileSync(custom, 'utf8'), '{"daemon.maxConcurrent":7}'); + assert.deepEqual(calls, [['ruflo', 'daemon', 'stop', root], ['ruflo', 'daemon', 'start', root]]); +}); + +test('an existing JSON keeps its ownership rules beside YAML, and release exposes YAML again', (t) => { + const root = tmpProject(t); + const receipts = {}; + reconcileRufloDaemon(root, { rufloVersion: '3.45.0', platform: 'darwin', receipts }); + const yaml = path.join(root, '.claude-flow', 'config.yml'); + fs.writeFileSync(yaml, 'daemon:\n maxConcurrent: 7\n'); + const preview = reconcileRufloDaemon(root, { rufloVersion: '3.46.1', platform: 'linux', receipts, dryRun: true }); + assert.equal(preview.config, 'removed'); + assert.equal(fs.existsSync(configFile(root)), true); + assert.equal(reconcileRufloDaemon(root, { rufloVersion: '3.46.1', platform: 'linux', receipts }).config, 'removed'); + assert.equal(fs.existsSync(configFile(root)), false); + assert.equal(fs.readFileSync(yaml, 'utf8'), 'daemon:\n maxConcurrent: 7\n'); + assert.deepEqual(receipts, {}); +}); + +test('a YAML hold stops repeated low-memory restarts while leaving the YAML and receipts alone', async (t) => { + const root = rufloRepo(t); + fs.writeFileSync(path.join(root, '.claude-flow', 'config.yaml'), 'daemon:\n maxConcurrent: 7\n'); + fs.mkdirSync(path.join(root, '.claude-flow', 'logs')); + fs.writeFileSync(path.join(root, '.claude-flow', 'logs', 'daemon.log'), + `[${new Date().toISOString()}] [INFO] Worker consolidate deferred: Memory too low: 3.9% free\n`); + const { calls, runner } = recorder(); + const cfg = { rufloDaemon: { receipts: {} } }; + const result = await applyRufloDaemon(root, { cfg, rufloVersion: '3.46.1', platform: 'darwin', runner, alive: () => true }); + assert.equal(result.restarted, false); + assert.deepEqual(calls, []); + assert.equal(fs.existsSync(configFile(root)), false); + assert.deepEqual(cfg.rufloDaemon.receipts, {}); +}); + test('the memory pin still wins and .swarm stays the memory root', (t) => { const root = tmpProject(t); fs.writeFileSync(path.join(root, 'claude-flow.config.json'), JSON.stringify({ memory: { persistPath: '.swarm' } })); diff --git a/tests/kit/ruflo-memory-location.test.mjs b/tests/kit/ruflo-memory-location.test.mjs index 212b8d43..32fa0c54 100644 --- a/tests/kit/ruflo-memory-location.test.mjs +++ b/tests/kit/ruflo-memory-location.test.mjs @@ -84,6 +84,30 @@ test('a disposable folder below a temporary root inside a tool folder keeps its assert.equal(launch.env.CLAUDE_FLOW_MEMORY_PATH, undefined); }); +test('a temporary root equal to a tool folder does not exempt its descendants', (t) => { + const home = sandbox(t); + const cache = mkdir(path.join(home, '.cache')); + const child = mkdir(path.join(cache, 'project')); + const at = (cwd) => rufloMemoryLocation(cwd, { home, env: { TMPDIR: cache } }); + assert.equal(at(cache).kind, 'user', 'the tool folder itself remains unsuitable'); + const location = at(child); + assert.equal(location.kind, 'user'); + assert.match(location.reason, /inside ~\/\.cache, a tool's own folder/); + assert.equal(location.db, path.join(userStore(home), 'memory.db')); +}); + +test('Windows same-path temp and tool roots are not deeper disposable boundaries', () => { + const home = 'C:\\Users\\Me'; + const local = `${home}\\AppData\\Local`; + const options = { home, platform: 'win32', p: path.win32, env: { LOCALAPPDATA: local, TMPDIR: local.toLowerCase() } }; + const tool = rufloMemoryLocation(local, options); + const child = rufloMemoryLocation(`${local}\\project`, options); + assert.equal(tool.kind, 'user'); + assert.equal(child.kind, 'user'); + assert.match(child.reason, /tool's own folder/); + assert.equal(child.db, `${home}\\.claude-flow\\memory\\memory.db`); +}); + test('the filesystem root, the home folder and a temporary root use the one user-level store', (t) => { const home = sandbox(t); for (const [cwd, reason] of [ @@ -167,6 +191,25 @@ test('a Git repository at the home folder does not pull a plain subfolder into ~ assert.equal(rufloMemoryLocation(home, { home }).kind, 'user'); }); +test('user-level location explains both an unsuitable folder and its unsuitable repository root', (t) => { + const home = sandbox(t); + fs.mkdirSync(path.join(home, '.git')); + const codex = mkdir(path.join(home, '.codex', 'sessions')); + const location = rufloMemoryLocation(codex, { home }); + assert.deepEqual([location.kind, location.root, location.dir, location.db], + ['user', userStore(home), userStore(home), path.join(userStore(home), 'memory.db')]); + assert.equal(location.reason, "inside ~/.codex, a tool's own folder, in a repository whose root is the home folder"); +}); + +test('location does not repeat a reason when root and folder share one tool area', (t) => { + const home = sandbox(t); + const root = mkdir(path.join(home, '.codex', 'workspace')); + fs.mkdirSync(path.join(root, '.git')); + const child = mkdir(path.join(root, 'src')); + assert.equal(rufloMemoryLocation(child, { home }).reason, "inside ~/.codex, a tool's own folder"); + assert.equal(rufloMemoryLocation(root, { home }).reason, "inside ~/.codex, a tool's own folder"); +}); + test('the launcher pins both memory variables to the user-level store and starts Ruflo inside it', (t) => { const home = sandbox(t); const launch = rufloMcpLaunch(home, { CLAUDE_FLOW_DB_PATH: '/.swarm/memory.db', KEEP: 'yes' }, { cfg, rufloVersion: '3.45.0', home }); @@ -215,6 +258,27 @@ test('status reports the user-level store and stray stores outside projects, for assert.match(strays[0].message, /leaves them in place/); }); +test('user status reports AQE home data separately without calling an empty folder healthy', async (t) => { + const home = sandbox(t); + const aqeDir = path.join(home, '.agentic-qe'); + fs.mkdirSync(aqeDir); + let rows = await userMemory.collect({ home, env: {} }); + let aqe = rows.find((r) => r.message.includes(aqeDir)); + assert.ok(aqe); + assert.equal(aqe.level, 'info'); + assert.equal(aqe.fix, null); + assert.match(aqe.message, /AQE.*memory\.db absent.*unverified/); + assert.doesNotMatch(aqe.message, /Ruflo|healthy|merge|move/i); + + fs.writeFileSync(path.join(aqeDir, 'memory.db'), 'placeholder'); + fs.writeFileSync(path.join(aqeDir, 'memory.db-wal'), 'wal'); + rows = await userMemory.collect({ home, env: {} }); + aqe = rows.find((r) => r.message.includes(aqeDir)); + assert.match(aqe.message, /AQE.*memory\.db present.*14 B.*unverified/); + assert.doesNotMatch(aqe.message, /Ruflo|healthy|merge|move/i); + assert.equal(rows.filter((r) => /stray Ruflo/.test(r.message)).length, 0); +}); + test('Claude mode from the home folder pins both memory variables to the user-level store', (t) => { const home = sandbox(t); const launch = rufloMcpLaunch(home, { RUFLO_INTELLIGENCE_MODE: 'fast' }, { cfg, rufloVersion: '3.46.1', home, host: 'claude' }); diff --git a/tests/kit/ruflo-windows-diagnostic-process.test.mjs b/tests/kit/ruflo-windows-diagnostic-process.test.mjs new file mode 100644 index 00000000..d902dcd2 --- /dev/null +++ b/tests/kit/ruflo-windows-diagnostic-process.test.mjs @@ -0,0 +1,191 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { spawn } from 'node:child_process'; +import { createDiagnosticScope } from '../live/ruflo-windows-diagnostic-process.mjs'; + +function fakeChild({ closes = true, code = 0 } = {}) { + const child = new EventEmitter(); + Object.assign(child, { pid: 123, exitCode: null, signalCode: null, + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + unreferenced: false, killed: false }); + child.unref = () => { child.unreferenced = true; }; + child.kill = () => { + child.killed = true; + if (closes) queueMicrotask(() => { child.exitCode = code; child.emit('close', code, null); }); + }; + return child; +} + +function scopeFor(spawnFn) { + let released = false; + const scope = createDiagnosticScope({ spawnFn, platform: 'win32', + killTimeoutMs: 20, closeTimeoutMs: 20, + acquireHold: () => ({}), releaseHold: () => { released = true; } }); + return { scope, released: () => released }; +} + +test('every diagnostic role passes the exact environment, excluding parent-only credentials', async () => { + process.env.AK_DIAGNOSTIC_PARENT_ONLY = 'synthetic-do-not-inherit'; + const env = { PATH: 'fixture-only' }; + const seen = []; + let active; + const { scope } = scopeFor((command, _args, options) => { + seen.push({ command, env: options.env }); + const child = fakeChild(); + if (command === 'taskkill.exe') queueMicrotask(() => { active.kill(); child.kill(); }); + else active = child; + return child; + }); + try { + for (const command of ['version', 'legacy', 'initialize']) { + const r = await scope.launch({ command, args: [] }, { env, timeoutMs: 10 }); + assert.equal(r.cleanupComplete, true); + } + assert.deepEqual(seen.map((x) => x.command), ['version', 'taskkill.exe', 'legacy', 'taskkill.exe', 'initialize', 'taskkill.exe']); + for (const entry of seen) { + assert.strictEqual(entry.env, env); + assert.equal(entry.env.AK_DIAGNOSTIC_PARENT_ONLY, undefined); + } + assert.equal(scope.release(), true); + } finally { delete process.env.AK_DIAGNOSTIC_PARENT_ONLY; } +}); + +for (const failure of ['nonzero', 'spawn-error', 'stall']) { + test(`taskkill ${failure} always disposes handles and retains uncertainty`, async () => { + const children = []; + const { scope, released } = scopeFor((command) => { + const child = fakeChild({ closes: false }); children.push(child); + if (command === 'taskkill.exe' && failure !== 'stall') queueMicrotask(() => { + if (failure === 'spawn-error') child.emit('error', new Error('ENOENT')); + child.exitCode = 1; child.emit('close', 1, null); + }); + return child; + }); + const started = Date.now(); + const r = await scope.launch({ command: 'initialize', args: [] }, { env: {}, timeoutMs: 10 }); + assert.equal(r.cleanupComplete, false); + assert.ok(Date.now() - started < 1000); + assert.equal(children[0].killed, true, 'direct-child fallback attempted'); + assert.equal(children[0].unreferenced, true); + assert.equal(children[0].stdout.destroyed, true); + assert.equal(children[0].stderr.destroyed, true); + assert.equal(children[0].stdin.destroyed, true); + assert.equal(scope.release(), false); + assert.equal(released(), false); + if (failure === 'stall') assert.equal(children[1].unreferenced, true); + }); +} + +for (const command of ['version', 'legacy']) { + test(`${command} cleanup failure cannot be cleared by a later successful launch`, async () => { + const { scope, released } = scopeFor((cmd) => { + const child = fakeChild(); + if (cmd === 'later' || cmd === 'taskkill.exe') queueMicrotask(() => { + child.exitCode = cmd === 'taskkill.exe' ? 1 : 0; child.emit('close', child.exitCode, null); + }); + return child; + }); + assert.equal((await scope.launch({ command, args: [] }, { env: {}, timeoutMs: 10 })).cleanupComplete, false); + assert.equal((await scope.launch({ command: 'later', args: [] }, { env: {}, timeoutMs: 10 })).cleanupComplete, true); + assert.equal(scope.release(), false); + assert.equal(released(), false); + }); +} + +test('real version, legacy, initialize and taskkill children cannot see a parent-only sentinel', async () => { + const key = 'AK_DIAGNOSTIC_PARENT_ONLY'; + const previous = process.env[key]; + process.env[key] = 'synthetic-do-not-inherit'; + const env = { AK_DIAGNOSTIC_ALLOWED: 'yes' }; + const observed = []; + const scope = createDiagnosticScope({ + platform: 'win32', acquireHold: () => ({}), releaseHold: () => {}, + killTimeoutMs: 1000, closeTimeoutMs: 1000, + spawnFn: (command, args, options) => { + const payload = 'process.stdout.write(JSON.stringify({allowed:process.env.AK_DIAGNOSTIC_ALLOWED,sentinel:process.env.AK_DIAGNOSTIC_PARENT_ONLY}));'; + const code = command === 'taskkill.exe' + ? `${payload}process.kill(${Number(args[1])},'SIGKILL');` + : `${payload}${command === 'initialize' ? 'setInterval(()=>{},1000);' : ''}`; + const child = spawn(process.execPath, ['-e', code], { ...options, env: options.env }); + let output = ''; + child.stdout.on('data', (chunk) => { output += chunk; }); + child.on('close', () => observed.push({ command, ...JSON.parse(output) })); + return child; + }, + }); + try { + for (const command of ['version', 'legacy', 'initialize']) { + const result = await scope.launch({ command, args: [] }, { env, timeoutMs: 500 }); + assert.equal(result.cleanupComplete, true); + } + assert.deepEqual(observed.map((x) => x.command).sort(), ['initialize', 'legacy', 'taskkill.exe', 'version']); + for (const result of observed) { + assert.equal(result.allowed, 'yes'); + assert.equal(result.sentinel, undefined); + } + assert.equal(scope.release(), true); + } finally { + if (previous === undefined) delete process.env[key]; else process.env[key] = previous; + } +}); + +test('EOF response waits for natural pipe closure without racing taskkill', async () => { + let child; + const commands = []; + const { scope, released } = scopeFor((command) => { + commands.push(command); + child = fakeChild({ closes: false }); + queueMicrotask(() => child.stdout.write('initialized')); + setTimeout(() => { child.exitCode = 0; }, 5); + setTimeout(() => child.emit('close', 0, null), 60); + return child; + }); + const result = await scope.launch({ command: 'eof', args: [] }, { + env: {}, timeoutMs: 200, endInput: true, until: (text) => text === 'initialized', + }); + assert.equal(result.matched, true); + assert.equal(result.code, 0); + assert.equal(result.cleanupComplete, true, 'requires observed close, not just exit code'); + assert.equal(result.timedOut, false); + assert.equal(child.killed, false); + assert.deepEqual(commands, ['eof']); + assert.equal(scope.release(), true); + assert.equal(released(), true); +}); + +test('EOF match plus exit code zero without pipe closure retains uncertainty', async () => { + const commands = []; + let child; + const { scope, released } = scopeFor((command) => { + commands.push(command); + child = fakeChild({ closes: false }); + queueMicrotask(() => { child.stdout.write('initialized'); child.exitCode = 0; }); + return child; + }); + const result = await scope.launch({ command: 'eof', args: [] }, { + env: {}, timeoutMs: 30, endInput: true, until: (text) => text === 'initialized', + }); + assert.equal(result.matched, true); + assert.equal(result.timedOut, true); + assert.equal(result.cleanupComplete, false); + assert.deepEqual(commands, ['eof'], 'never target a reaped PID'); + assert.equal(child.unreferenced, true); + assert.equal(child.stdout.destroyed, true); + assert.equal(scope.release(), false); + assert.equal(released(), false); +}); + +test('a real EOF responder exits naturally after its reply before cleanup is accepted', async () => { + const scope = createDiagnosticScope({ acquireHold: () => ({}), releaseHold: () => {} }); + const code = "process.stdin.resume();process.stdin.on('end',()=>{process.stdout.write('initialized');setTimeout(()=>process.exit(0),80)});"; + const result = await scope.launch({ command: process.execPath, args: ['-e', code] }, { + env: {}, timeoutMs: 2000, endInput: true, until: (text) => text === 'initialized', + }); + assert.equal(result.matched, true); + assert.equal(result.code, 0); + assert.equal(result.signal, null, 'no forced termination after the reply'); + assert.equal(result.cleanupComplete, true); + assert.equal(scope.release(), true); +}); diff --git a/tests/kit/run-root-identities.test.mjs b/tests/kit/run-root-identities.test.mjs new file mode 100644 index 00000000..4b79d17b --- /dev/null +++ b/tests/kit/run-root-identities.test.mjs @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { tempDir } from './helpers/temp-dir.mjs'; +import { spawnEnv } from './helpers/home-sandbox.mjs'; +import { ownerRecord, writeOwner, readOwner, collectAbandonedRoots, OWNER_FILE } from '../../scripts/run-roots.mjs'; +import { runGuarded } from '../../scripts/run-tests.mjs'; + +const first = 2n ** 53n; +const second = first + 1n; +assert.equal(Number(first), Number(second), 'fixture must collide through Number'); + +// Model the OS exposing exact BigInt fields or lossy legacy Number fields. +function identity(stat, options, field, value) { + const exact = options?.bigint === true; + const key = !exact && field.endsWith('Ns') ? field.replace(/Ns$/, 'Ms') : field; + stat[key] = exact ? value : Number(value) / (field.endsWith('Ns') ? 1e6 : 1); + return stat; +} +function fixture(t) { + const parent = fs.realpathSync(tempDir('ak-exact-root', t)); + const root = fs.mkdtempSync(path.join(parent, 'ak-suite-')); + const home = path.join(parent, 'home'); + fs.mkdirSync(home); + writeOwner(root, ownerRecord()); + return { parent, root, home }; +} + +for (const field of ['dev', 'ino', 'ctimeNs']) { + for (const phase of ['open', 'read']) { + test(`owner ${field} collision at ${phase} refuses swapped file`, (t) => { + const { root } = fixture(t); + const file = path.join(root, OWNER_FILE); + const originalLstat = fs.lstatSync; + const originalFstat = fs.fstatSync; + let reads = 0; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = originalLstat(target, options); + return target === file ? identity(stat, options, field, ++reads === 1 ? first : second) : stat; + }); + t.mock.method(fs, 'fstatSync', (fd, options) => + identity(originalFstat(fd, options), options, field, phase === 'open' ? second : first)); + assert.equal(readOwner(root), null); + }); + } + + test(`sibling ${field} collision during injected proof retains root`, (t) => { + const { parent, root, home } = fixture(t); + const original = fs.lstatSync; + let swapped = false; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = original(target, options); + return target === root ? identity(stat, options, field, swapped ? second : first) : stat; + }); + let removals = 0; + const result = collectAbandonedRoots({ tmpdir: parent, homedir: home, log: () => {}, + probes: { alive: () => false, startedAfter: () => false, completeExit: () => { swapped = true; return true; } }, + remove: () => { removals++; }, + }); + assert.equal(removals, 0); + assert.deepEqual(result.removed, []); + assert.match(result.kept[0].reason, /changed/); + assert.ok(fs.existsSync(root)); + }); +} + +for (const field of ['dev', 'ino', 'birthtimeNs']) { + test(`call-owned ${field} collision retains replacement untouched`, (t) => { + const { parent, home } = fixture(t); + const tmpdir = path.join(parent, 'tmp'); + const repoRoot = path.join(home, 'repo'); + fs.mkdirSync(tmpdir); + fs.mkdirSync(repoRoot); + const env = spawnEnv(home); + t.mock.method(os, 'tmpdir', () => tmpdir); + const original = fs.lstatSync; + let swapped = false; + let ownRoot; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = original(target, options); + if (!stat || path.dirname(String(target)) !== tmpdir || !/^ak-suite-[A-Za-z0-9]{6}$/.test(path.basename(String(target)))) return stat; + ownRoot = target; + for (const key of ['dev', 'ino', 'birthtimeNs']) identity(stat, options, key, first); + return identity(stat, options, field, swapped ? second : first); + }); + const messages = []; + const code = runGuarded([], { env, homedir: home, repoRoot, log: (message) => { + messages.push(message); + if (message.startsWith('real-state tripwire:')) { + // Preserve metadata so the ownership/hold checks alone cannot catch this swap. + fs.cpSync(ownRoot, `${ownRoot}-saved`, { recursive: true }); + fs.rmSync(ownRoot, { recursive: true }); + fs.renameSync(`${ownRoot}-saved`, ownRoot); + swapped = true; + } + } }); + assert.equal(code, 4); + assert.match(messages.join('\n'), /directory identity changed/); + assert.ok(fs.existsSync(path.join(ownRoot, OWNER_FILE))); + assert.ok(fs.existsSync(path.join(ownRoot, '.ak-suite-holds'))); + }); +} + +test('unchanged exact owner identity accepts large IDs and preserves numeric attribution', (t) => { + const { root } = fixture(t); + const file = path.join(root, OWNER_FILE); + const lstat = fs.lstatSync; + const fstat = fs.fstatSync; + const patch = (stat, options) => { + for (const field of ['dev', 'ino', 'ctimeNs']) identity(stat, options, field, second); + return stat; + }; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = lstat(target, options); + return target === file ? patch(stat, options) : stat; + }); + t.mock.method(fs, 'fstatSync', (fd, options) => patch(fstat(fd, options), options)); + const owner = readOwner(root); + assert.equal(owner.pid, process.pid); + assert.equal(owner.uid, process.getuid?.() ?? null); + assert.equal(typeof owner.startedAt, 'number'); +}); + +test('exact owner stats reject foreign filesystem UID before opening', (t) => { + if (!process.getuid) return; // Windows has no UID boundary to validate. + const { root } = fixture(t); + const file = path.join(root, OWNER_FILE); + const lstat = fs.lstatSync; + t.mock.method(fs, 'lstatSync', (target, options) => { + const stat = lstat(target, options); + if (target === file) stat.uid = options?.bigint ? BigInt(process.getuid()) + 1n : process.getuid() + 1; + return stat; + }); + const open = t.mock.method(fs, 'openSync', () => { throw Error('must not open foreign owner'); }); + assert.equal(readOwner(root), null); + assert.equal(open.mock.callCount(), 0); +}); diff --git a/tests/kit/run-tests-runner.test.mjs b/tests/kit/run-tests-runner.test.mjs index 6f7b9a6d..4d7cc9d9 100644 --- a/tests/kit/run-tests-runner.test.mjs +++ b/tests/kit/run-tests-runner.test.mjs @@ -445,7 +445,10 @@ function cleanupProbe(home) { if (own(p)) { ownStats++; if (mode === 'refusal' && ownStats >= 3) stat.isDirectory = () => false; - if (mode === 'identity' && ownStats >= 4) stat.birthtimeMs += 1; + if (mode === 'identity' && ownStats >= 4) { + if (typeof stat.birthtimeNs === 'bigint') stat.birthtimeNs += 1n; + else stat.birthtimeMs += 1; + } } return stat; }; diff --git a/tests/kit/setup-host-rerecord.test.mjs b/tests/kit/setup-host-rerecord.test.mjs new file mode 100644 index 00000000..80cb1110 --- /dev/null +++ b/tests/kit/setup-host-rerecord.test.mjs @@ -0,0 +1,106 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { sandboxHome, rmrf } from './helpers/home-sandbox.mjs'; +import { isolateProject, REPO_ROOT } from './helpers/project-isolation.mjs'; + +const home = sandboxHome('ak-setup-host-rerecord'); +after(() => rmrf(home)); +isolateProject('ak-setup-host-rerecord'); +const setup = await import('../../src/commands/setup.mjs'); +const cfg = { integrations: { hosts: { claude: false, codex: true, opencode: false } } }; + +for (const [name, initial, ok, expected] of [ + ['successful install', 'absent', true, 2], + ['failed install', 'absent', false, 1], + ['present host', 'npm', true, 1], +]) { + test(`${name} records fresh host facts only when installation succeeds`, async () => { + const stateCalls = []; + const facts = []; + const installs = []; + await setup.installEnabledAbsentHosts(cfg, { yes: true }, { + installState: async (_host, opts) => { stateCalls.push(opts); return { method: stateCalls.length === 1 ? initial : 'npm', version: '1.0.0' }; }, + install: async id => { installs.push(id); return { ok, detail: ok ? 'installed' : 'failed' }; }, + collectFacts: async opts => { facts.push(opts); }, + }); + assert.equal(stateCalls.length, expected); + assert.deepEqual(installs, initial === 'absent' ? ['codex'] : []); + if (expected === 2) { + assert.deepEqual(stateCalls[1], { refresh: true, record: true, source: 'setup' }); + assert.deepEqual(facts, [{ cfg, refresh: true, record: true, source: 'setup' }]); + } else assert.deepEqual(facts, []); + }); +} + +test('disabled hosts do not probe, install, or record evidence', async () => { + await setup.installEnabledAbsentHosts({ integrations: { hosts: {} } }, { yes: true }, { + installState: async () => { throw new Error('disabled host probed'); }, + install: async () => { throw new Error('disabled host installed'); }, + collectFacts: async () => { throw new Error('disabled host recorded'); }, + }); +}); + +test('setup run passes its host lifecycle through the machine setup boundary', async () => { + const lifecycle = { installState: async () => { throw new Error('sentinel'); } }; + let received; + await setup.run({ + flags: { 'dry-run': true, minimal: true, 'no-aqe': true, 'no-ruvnet-brain': true, 'no-agent-browser': true, yes: true }, + pkgRoot: REPO_ROOT, + deps: { hostLifecycle: lifecycle }, + machineSetup: async args => { received = args.deps?.hostLifecycle; return false; }, + }); + assert.equal(received, lifecycle); +}); + +test('real run_machine passes injected lifecycle to the host installation loop', async () => { + const stubs = new Map([ + ['../lib/versions.mjs', "export const installedVersion = () => '3.48.0'; export const cmpVersions = () => 0;"], + ['../lib/heal.mjs', "export const healNatives = async () => ({ ok: true, detail: 'stub' }); export const healAidefence = async () => ({ ok: true, detail: 'stub' });"], + ['../lib/exec.mjs', "export const have = async () => false; export const run = async () => { throw Error('real command reached'); };"], + ['../lib/providers.mjs', ` + export const HOSTS = [{ id: 'codex', pkg: '@openai/codex' }]; + export const hostInstallState = async () => { throw Error('default host probe reached'); }; + export const installHost = async () => { throw Error('default installer reached'); }; + export const collectIntegrationFacts = async () => { throw Error('default facts collector reached'); }; + export const migrateRetiredRoutesInConfig = () => {}; + export const printActivityRoutingTable = () => {}; + export const convergeProviderStack = () => {}; + export const applySetupHostFlags = () => {}; + export const guidanceContext = () => {}; + export const reportRetiredRouteChanges = () => {}; + `], + ['../lib/adapters/lifecycle-registry.mjs', ` + export const hostsWithLifecycle = () => []; + export const lifecycleAdapterFor = () => { throw Error('host lifecycle reached'); }; + export const lifecycleExecutionEnabled = () => false; + export const detectionBinFor = () => { throw Error('host lifecycle reached'); }; + `], + ['../lib/ruflo-components/apply.mjs', "export const reconcileRufloComponents = async () => { throw Error('components reached'); };"], + ['./status/sections/ruflo-components.mjs', "export const componentResultReport = () => [];"], + ]); + registerHooks({ + resolve(specifier, context, nextResolve) { + if (context.parentURL?.includes('/src/commands/setup.mjs?b2-machine') && stubs.has(specifier)) { + return { url: `data:text/javascript,${encodeURIComponent(stubs.get(specifier))}`, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + }); + const isolatedSetup = await import('../../src/commands/setup.mjs?b2-machine'); + const sentinel = new Error('injected host lifecycle reached'); + const machineCfg = { + agentBrowser: false, aqe: false, ruvnetBrain: false, security: false, + integrations: { hosts: { codex: true } }, + }; + let calls = 0; + await assert.rejects(isolatedSetup.run_machine({ + flags: { yes: true }, cfg: machineCfg, pkgRoot: home, + deps: { hostLifecycle: { + installState: async () => { calls++; throw sentinel; }, + install: async () => { throw Error('injected installer reached'); }, + collectFacts: async () => { throw Error('injected facts reached'); }, + } }, + }), error => error === sentinel); + assert.equal(calls, 1); +}); diff --git a/tests/kit/setup-memory-probe.test.mjs b/tests/kit/setup-memory-probe.test.mjs index d477d976..e9842afd 100644 --- a/tests/kit/setup-memory-probe.test.mjs +++ b/tests/kit/setup-memory-probe.test.mjs @@ -10,6 +10,7 @@ import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { verifyProjectMemoryWrite } from '../../src/commands/setup.mjs'; import { captureLog } from './helpers/home-sandbox.mjs'; +import { tempDir } from './helpers/temp-dir.mjs'; function store(file) { fs.mkdirSync(path.dirname(file), { recursive: true }); @@ -109,3 +110,114 @@ test('the setup probe leaves nothing in a redirected memory root', async (t) => assert.deepEqual(keys(path.join(redirected, 'agentdb-memory.db')), ['user-row'], 'the redirected MCP store kept a setup probe'); for (const name of ['memory.db', 'agentdb-memory.db']) assert.deepEqual(keys(path.join(root, '.swarm', name)), ['user-row']); }); + +// Ruflo 3.48 writes its native mirror under CLAUDE_FLOW_MEMORY_PATH even when +// CLAUDE_FLOW_DB_PATH points elsewhere. The fake follows those two routes. +function routedProject(t) { + const root = tempDir('ak-setup-route', t); + const primary = path.join(root, '.swarm', 'memory.db'); + const canonical = path.join(root, '.swarm', 'agentdb-memory.db'); + const redirected = path.join(root, 'data', 'memory', 'agentdb-memory.db'); + for (const file of [primary, canonical, redirected]) { + const db = store(file); + db.prepare("INSERT INTO memory_entries VALUES ('real', 'user-row', 'active')").run(); + db.close(); + } + const mirrors = []; + const runner = async (_cmd, args, { env }) => { + const key = args[args.indexOf('-k') + 1]; + assert.equal(env.RUFLO_DAEMON_AUTOSTART, '0'); + assert.equal(env.CLAUDE_FLOW_DB_PATH, primary); + const mirror = path.join(env.CLAUDE_FLOW_MEMORY_PATH, 'agentdb-memory.db'); + mirrors.push(mirror); + for (const file of [env.CLAUDE_FLOW_DB_PATH, mirror]) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + db.exec('CREATE TABLE IF NOT EXISTS memory_entries (namespace TEXT, key TEXT, status TEXT)'); + db.prepare("INSERT INTO memory_entries VALUES ('_setup', ?, 'active')").run(key); + db.close(); + } + return { code: 0, stdout: '', stderr: '' }; + }; + return { root, primary, canonical, redirected, mirrors, runner }; +} + +test('setup verifies the primary while removing its private mirror and preserving native corpora', async (t) => { + const p = routedProject(t); + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, { CLAUDE_FLOW_DB_PATH: p.primary, + CLAUDE_FLOW_MEMORY_PATH: path.dirname(p.redirected) }, { runner: p.runner })); + assert.match(out, /memory write VERIFIED/); + assert.deepEqual(keys(p.primary), ['user-row']); + assert.deepEqual(keys(p.canonical), ['user-row']); + assert.deepEqual(keys(p.redirected), ['user-row']); + assert.equal(p.mirrors.length, 1); + assert.equal(fs.existsSync(path.dirname(p.mirrors[0])), false); +}); + +test('setup pins the primary when caller env is absent and removes the mirror on a failed store', async (t) => { + const p = routedProject(t); + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, undefined, { + runner: async (cmd, args, options) => { + await p.runner(cmd, args, options); + return { code: 1, stdout: '', stderr: 'failed after write' }; + }, + })); + assert.match(out, /memory write verification FAILED/); + assert.doesNotMatch(out, /memory write VERIFIED/); + assert.deepEqual(keys(p.canonical), ['user-row']); + assert.deepEqual(keys(p.redirected), ['user-row']); + assert.equal(fs.existsSync(path.dirname(p.mirrors[0])), false); +}); + +test('setup removes its private mirror after a runner throws', async (t) => { + const p = routedProject(t); + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, {}, { + runner: async (cmd, args, options) => { + await p.runner(cmd, args, options); + throw new Error('runner failed'); + }, + })); + assert.match(out, /memory write verification FAILED/); + assert.equal(fs.existsSync(path.dirname(p.mirrors[0])), false); + assert.deepEqual(keys(p.canonical), ['user-row']); +}); + +test('setup reports a missing primary row and removes the private mirror', async (t) => { + const p = routedProject(t); + let mirror; + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, {}, { + runner: async (_cmd, _args, { env }) => { + mirror = env.CLAUDE_FLOW_MEMORY_PATH; + const db = store(path.join(mirror, 'agentdb-memory.db')); + db.close(); + return { code: 0, stdout: '', stderr: '' }; + }, + })); + assert.match(out, /memory write verification FAILED/); + assert.doesNotMatch(out, /memory write VERIFIED/); + assert.equal(fs.existsSync(mirror), false); + assert.deepEqual(keys(p.primary), ['user-row']); +}); + +test('setup does not claim complete verification when private mirror removal fails', { skip: process.platform === 'win32' }, async (t) => { + const p = routedProject(t); + let mirror; + t.after(() => { + if (mirror && fs.existsSync(mirror)) { + fs.chmodSync(mirror, 0o700); + fs.rmSync(mirror, { recursive: true, force: true }); + } + }); + const { out } = await captureLog(() => verifyProjectMemoryWrite(p.root, {}, { + runner: async (cmd, args, options) => { + await p.runner(cmd, args, options); + mirror = options.env.CLAUDE_FLOW_MEMORY_PATH; + fs.chmodSync(mirror, 0o000); + return { code: 0, stdout: '', stderr: '' }; + }, + })); + assert.match(out, /temporary mirror cleanup failed/); + assert.match(out, /memory write verification FAILED/); + assert.doesNotMatch(out, /memory write VERIFIED/); + assert.deepEqual(keys(p.primary), ['user-row']); +}); diff --git a/tests/kit/status-command.test.mjs b/tests/kit/status-command.test.mjs index 27744242..92ca6191 100644 --- a/tests/kit/status-command.test.mjs +++ b/tests/kit/status-command.test.mjs @@ -658,6 +658,7 @@ test('Codex MCP topology fails recursive self-registration and reports missing A 'args = ["x", "ruflo-mcp"]', ].join('\n')); try { + const codexConfigBefore = fs.readFileSync(path.join(PROJECT, '.codex', 'config.toml'), 'utf8'); const rows = rowsFor(await collect(), 'codex-mcp'); assert.equal(rows.find((r) => /recursive codex/.test(r.message))?.level, 'fail'); assert.equal(rows.find((r) => /agentic-qe MCP is not concretely/.test(r.message))?.level, 'warn'); @@ -666,7 +667,12 @@ test('Codex MCP topology fails recursive self-registration and reports missing A // can prove (codexMcpRepairPlan); Agentic-QE owns its own Codex registration. assert.equal(rows.find((r) => /recursive codex/.test(r.message))?.repair, 'sync'); assert.equal(rows.find((r) => /duplicate Ruflo/.test(r.message))?.repair, 'sync'); - assert.equal(rows.find((r) => /agentic-qe MCP is not concretely/.test(r.message))?.repair, 'manual'); + const aqe = rows.find((r) => /agentic-qe MCP is not concretely/.test(r.message)); + assert.equal(aqe?.repair, 'manual'); + assert.match(aqe.fix, /aqe init --auto --with-codex --codex-guidance compact/); + assert.doesNotMatch(aqe.fix, /platform setup|codex mcp-server/); + assert.equal(fs.readFileSync(path.join(PROJECT, '.codex', 'config.toml'), 'utf8'), codexConfigBefore, + 'status only reports the AQE-owned initialization flow'); } finally { rmrf(path.join(PROJECT, '.codex')); } @@ -716,7 +722,7 @@ test('a user-owned deprecated codex mcp-server entry is a manual removal', async }); test('Codex MCP topology does not ask an aqe:false machine to register agentic-qe in Codex', async () => { - // #237 N1: with AQE opted out, `aqe platform setup codex` is advice for a + // #237 N1: with AQE opted out, Codex initialization advice is for a // tool the user declined; the topology rows must honor kit.json like the // aqe section does. seedHome(offlineKitConfig({ @@ -970,7 +976,7 @@ test('--refresh prints one line per finished stage, then the status table', asyn assert.match(r.out, /ak status\n.*versions +fake versions row/); }); -test('plain status runs no refresh stage', async () => { +test('plain status rejects a stray positional before any refresh stage', async () => { seedHome(); let calls = 0; const refreshStages = new Proxy({}, { get: () => async () => { calls += 1; return { ok: true }; } }); @@ -981,7 +987,8 @@ test('plain status runs no refresh stage', async () => { r = await captureLog(() => status.run({ flags: {}, positionals: ['extra'], pkgRoot: PKG_ROOT, deps: { refreshStages } })); } finally { process.chdir(cwd); } assert.equal(calls, 0); - assert.notEqual(r.result, 2, 'plain status still ignores a positional, as it always has'); + assert.equal(r.result, 2); + assert.match(r.out, /unexpected argument 'extra'/); }); test('a refresh with a stray argument is a usage error that names the one-token spelling', async () => { diff --git a/tests/kit/status-version-drift-refresh.test.mjs b/tests/kit/status-version-drift-refresh.test.mjs index 0f9bc8f7..88dd9dc3 100644 --- a/tests/kit/status-version-drift-refresh.test.mjs +++ b/tests/kit/status-version-drift-refresh.test.mjs @@ -151,7 +151,7 @@ function seedSelfHome({ ageMs = 0 } = {}) { ttlHours: 24, last: Date.now(), seen: { ruflo: '9.9.9', 'agentic-qe': '9.9.9' }, - self: { last: Date.now() - ageMs, best: { version: '0.0.1', tag: 'latest' } }, + self: { last: Date.now() - ageMs, best: { version: '0.0.1', tag: 'latest' }, lastTags: ['latest', 'next'] }, }, }); writeKitConfig(HOME, cfg); diff --git a/tests/kit/sync-daemon-repair.test.mjs b/tests/kit/sync-daemon-repair.test.mjs index 616841da..79c9aa82 100644 --- a/tests/kit/sync-daemon-repair.test.mjs +++ b/tests/kit/sync-daemon-repair.test.mjs @@ -16,6 +16,7 @@ import { sandboxHome, rmrf } from './helpers/home-sandbox.mjs'; const SANDBOX_HOME = sandboxHome('ak-sync-daemon-repair'); after(() => rmrf(SANDBOX_HOME)); const sync = await import('../../src/commands/sync.mjs'); +const { applyRufloDaemon } = await import('../../src/lib/ruflo-daemon-config.mjs'); const daemonsStep = sync.SYNC_STEPS.find((s) => s.id === 'daemons'); @@ -75,3 +76,49 @@ test('a reap attempt that fails to kill anything is not re-recorded either', asy assert.equal(listCalls.length, 1, 'a failed reap (pid already gone/reused) leaves the process list unchanged; no re-record is needed'); } finally { rmrf(cwd); } }); + +test('daemon step uses injected version, apply and save seams after its sweep', async () => { + const cwd = freshCwd(); + const calls = []; + try { + await daemonsStep.run({ + cwd, cfg: {}, + daemonLifecycle: { list: async () => [], reap: () => [] }, + daemonVersion: () => { calls.push('version'); return '3.48.0'; }, + daemonApply: async (_cwd, options) => { + calls.push(['apply', options.rufloVersion]); + return { result: { config: 'converged', autostart: 'converged', changed: false, held: null }, restarted: false }; + }, + saveConfig: () => calls.push('save'), + }); + assert.deepEqual(calls, ['version', ['apply', '3.48.0'], 'save']); + } finally { rmrf(cwd); } +}); + +test('versions-triggered daemon step applies only fixture settings with no external processes', async () => { + const cwd = freshCwd(); + const calls = []; + try { + fs.mkdirSync(path.join(cwd, '.git')); + fs.mkdirSync(path.join(cwd, '.claude-flow')); + fs.mkdirSync(path.join(cwd, '.swarm')); + fs.writeFileSync(path.join(cwd, '.swarm', 'memory.db'), ''); + const cfg = { rufloDaemon: { receipts: {} } }; + assert.ok(sync.activeSteps(new Set(['versions']), {}, cfg).includes('daemons')); + await daemonsStep.run({ + cwd, cfg, + daemonLifecycle: { list: async () => { calls.push('list'); return []; }, reap: () => [] }, + daemonVersion: () => '3.45.0', + daemonApply: (root, options) => applyRufloDaemon(root, { + ...options, platform: 'darwin', alive: () => { calls.push('alive'); return true; }, + runner: async (_tool, args) => { calls.push(args.join(' ')); return { code: 0 }; }, + }), + saveConfig: () => calls.push('save'), + }); + assert.deepEqual(calls, ['list', 'alive', 'daemon stop', 'daemon start', 'save']); + assert.deepEqual(JSON.parse(fs.readFileSync(path.join(cwd, '.claude-flow', 'config.json'), 'utf8')), + { 'daemon.idleSecs': 0, 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); + assert.deepEqual(cfg.rufloDaemon.receipts[path.resolve(cwd)].configKeys, + { 'daemon.idleSecs': 0, 'daemon.resourceThresholds.minFreeMemoryPercent': 0 }); + } finally { rmrf(cwd); } +}); diff --git a/tests/kit/sync-dry-run-preview.test.mjs b/tests/kit/sync-dry-run-preview.test.mjs index f3def05e..19c1b9ec 100644 --- a/tests/kit/sync-dry-run-preview.test.mjs +++ b/tests/kit/sync-dry-run-preview.test.mjs @@ -136,6 +136,44 @@ const DRY = (over = {}) => ({ 'dry-run': true, 'no-upgrade': false, yes: false, const failedDates = async () => ({ code: 1, stdout: '', stderr: 'offline' }); const NOT_CHECKED = /versions not checked online \(offline or timed out\); this plan uses the versions ak recorded/; +test('versions-only dry run previews conditional daemon convergence in a Ruflo project without writes', async () => { + seed(); + const marker = path.join(PROJECT, '.claude-flow', 'config.yaml'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, 'daemon:\n maxConcurrent: 7\n'); + const beforeHome = snapshot(HOME); + const beforeProject = snapshot(PROJECT); + try { + const { result, out } = await syncLines({ + flags: DRY(), fetchLatest: async () => null, releaseDatesRunner: failedDates, + collectFn: async () => [{ subsystem: 'versions', level: 'warn', message: 'ruflo update available', fix: 'sync upgrades', repair: 'sync' }], + }); + assert.equal(result, 0, out); + assert.match(out, /\[daemons\].*installed Ruflo version/); + assert.doesNotMatch(out, /daemon\.idleSecs.*0/, 'the target version is not yet known'); + assertUnchanged(beforeHome, HOME); + assertUnchanged(beforeProject, PROJECT); + } finally { fs.rmSync(marker); } +}); + +test('versions-triggered daemon preview honors skip daemons and skip versions', async () => { + seed(); + const marker = path.join(PROJECT, '.claude-flow', 'config.yaml'); + fs.mkdirSync(path.dirname(marker), { recursive: true }); + fs.writeFileSync(marker, 'daemon:\n maxConcurrent: 7\n'); + const collectFn = async () => [{ subsystem: 'versions', level: 'warn', message: 'update available', fix: 'sync upgrades', repair: 'sync' }]; + try { + const daemonSkipped = await syncLines({ + flags: DRY({ skip: ['daemons'] }), fetchLatest: async () => null, releaseDatesRunner: failedDates, collectFn, + }); + assert.match(daemonSkipped.out, /skipped by request: \[daemons\]/); + const versionSkipped = await syncLines({ + flags: DRY({ skip: ['versions'] }), fetchLatest: async () => null, releaseDatesRunner: failedDates, collectFn, + }); + assert.doesNotMatch(versionSkipped.out, /\[daemons\]/); + } finally { fs.rmSync(marker); } +}); + test('a dry run looks the versions up online and plans the upgrade a fresh cache does not know about, recording nothing', () => { const root = seed({ age: HOUR }); const env = spawnEnv(HOME); diff --git a/tests/kit/sync-self-freshness.test.mjs b/tests/kit/sync-self-freshness.test.mjs index 6d726340..2bfce5d2 100644 --- a/tests/kit/sync-self-freshness.test.mjs +++ b/tests/kit/sync-self-freshness.test.mjs @@ -113,6 +113,103 @@ test('a failed next lookup retains its cached candidate without claiming a fresh assert.equal(loadKitConfig().versionCheck.self.last, 1); }); +test('partial self answers retry once per TTL without renewing the cached observation', async t => { + seed(); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 100, observedAt: 80, best: { version: '4.0.0-alpha.50', tag: 'next' } }; + writeKitConfig(home, cfg); + let now = 200_000_000; + t.mock.method(Date, 'now', () => now); + const tags = []; + const fetchLatest = async (_pkg, tag) => { tags.push(tag); return tag === 'latest' ? '4.0.0-alpha.0' : null; }; + const first = await selfDrift({ pkgRoot, fetchLatest }); + assert.deepEqual([first.latest, first.tag], ['4.0.0-alpha.50', 'next']); + assert.deepEqual(loadKitConfig().versionCheck.self, { + last: 100, observedAt: 80, best: { version: '4.0.0-alpha.50', tag: 'next' }, + attempt: { at: now, tags: ['latest', 'next'] }, + }); + const afterFirst = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + now += 1000; + assert.equal((await selfDrift({ pkgRoot, fetchLatest })).latest, first.latest); + assert.deepEqual(tags, ['latest', 'next']); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), afterFirst); + await selfDrift({ pkgRoot, force: true, fetchLatest }); + assert.deepEqual(tags, ['latest', 'next', 'latest', 'next']); + now += 24 * 3600_000; + await selfDrift({ pkgRoot, fetchLatest }); + assert.deepEqual(tags, ['latest', 'next', 'latest', 'next', 'latest', 'next']); + assert.equal(loadKitConfig().versionCheck.self.observedAt, 80); + now += 1000; + const recovered = await selfDrift({ pkgRoot, force: true, fetchLatest: async (_pkg, tag) => + tag === 'next' ? '4.0.0-alpha.51' : null }); + assert.equal(recovered.latest, '4.0.0-alpha.51'); + assert.deepEqual(loadKitConfig().versionCheck.self, + { last: now, observedAt: now, best: { version: '4.0.0-alpha.51', tag: 'next' }, lastTags: ['latest', 'next'] }); +}); + +test('a stable lookup cannot make an untried prerelease channel fresh', async t => { + seed('4.0.0'); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 100, observedAt: 80, best: { version: '4.0.0', tag: 'latest' } }; + writeKitConfig(home, cfg); + let now = 200_000_000; + t.mock.method(Date, 'now', () => now); + const stableTags = []; + await selfDrift({ pkgRoot, fetchLatest: async (_pkg, tag) => { + stableTags.push(tag); return null; + } }); + assert.deepEqual(stableTags, ['latest']); + const stable = loadKitConfig().versionCheck.self; + assert.deepEqual(stable, { + last: now, observedAt: 80, best: { version: '4.0.0', tag: 'latest' }, lastTags: ['latest'], + }); + fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version: '4.0.0-alpha.1' })); + now += 1000; + const prereleaseTags = []; + const offline = async (_pkg, tag) => { prereleaseTags.push(tag); return null; }; + const first = await selfDrift({ pkgRoot, fetchLatest: offline }); + assert.deepEqual(prereleaseTags, ['latest', 'next']); + assert.deepEqual([first.latest, first.tag], ['4.0.0', 'latest']); + const afterFirst = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + await selfDrift({ pkgRoot, fetchLatest: offline }); + assert.deepEqual(prereleaseTags, ['latest', 'next']); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), afterFirst); + assert.equal(loadKitConfig().versionCheck.self.observedAt, 80); +}); + +test('a legacy latest-only record does not suppress an untried next channel', async t => { + seed('4.0.0-alpha.1'); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 200_000_000, observedAt: 100, best: { version: '4.0.0', tag: 'latest' } }; + writeKitConfig(home, cfg); + t.mock.method(Date, 'now', () => 200_001_000); + const tags = []; + await selfDrift({ pkgRoot, fetchLatest: async (_pkg, tag) => { tags.push(tag); return null; } }); + assert.deepEqual(tags, ['latest', 'next']); + assert.equal(loadKitConfig().versionCheck.self.observedAt, 100); +}); + +test('a successful stable observation also leaves next untried after a channel switch', async t => { + seed('4.0.0'); + const cfg = loadKitConfig(); + cfg.versionCheck.self.last = 100; + writeKitConfig(home, cfg); + let now = 200_000_000; + t.mock.method(Date, 'now', () => now); + await selfDrift({ pkgRoot, fetchLatest: async () => '4.0.1' }); + assert.deepEqual(loadKitConfig().versionCheck.self.lastTags, ['latest']); + assert.equal(loadKitConfig().versionCheck.self.observedAt, now); + fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version: '4.0.0-alpha.1' })); + now += 1000; + const tags = []; + const result = await selfDrift({ pkgRoot, fetchLatest: async (_pkg, tag) => { + tags.push(tag); return tag === 'next' ? '4.1.0-alpha.1' : null; + } }); + assert.deepEqual(tags, ['latest', 'next']); + assert.deepEqual([result.latest, result.tag], ['4.1.0-alpha.1', 'next']); + assert.deepEqual(loadKitConfig().versionCheck.self.lastTags, ['latest', 'next']); +}); + test('stable installs reject cached next-channel candidates when latest is unavailable', async () => { seed('4.0.0'); const cfg = loadKitConfig(); @@ -145,6 +242,68 @@ test('a stable install whose record holds only a next-channel candidate is not r assert.deepEqual(loadKitConfig().versionCheck.self.best, { version: '5.0.0-alpha.1', tag: 'next' }); }); +test('offline self attempts are scoped to tags and do not persist in read-only modes', async t => { + seed('4.0.0'); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { last: 100, observedAt: 80, best: { version: '5.0.0-alpha.1', tag: 'next' } }; + writeKitConfig(home, cfg); + let now = 200_000_000; + t.mock.method(Date, 'now', () => now); + const tags = []; + const fetchLatest = async (_pkg, tag) => { tags.push(tag); return null; }; + assert.equal((await selfDrift({ pkgRoot, fetchLatest })).latest, null); + assert.deepEqual(loadKitConfig().versionCheck.self, { + last: 100, observedAt: 80, best: { version: '5.0.0-alpha.1', tag: 'next' }, + attempt: { at: now, tags: ['latest'] }, + }); + const afterFirst = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + await selfDrift({ pkgRoot, fetchLatest }); + assert.deepEqual(tags, ['latest']); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), afterFirst); + await selfDrift({ pkgRoot, force: true, fetchLatest }); + assert.deepEqual(tags, ['latest', 'latest']); + fs.writeFileSync(path.join(pkgRoot, 'package.json'), JSON.stringify({ name: KIT_PKG, version: '4.0.0-alpha.1' })); + await selfDrift({ pkgRoot, fetchLatest }); + assert.deepEqual(tags, ['latest', 'latest', 'latest', 'next'], 'the untried channel is probed'); + const beforeReadOnly = fs.readFileSync(paths.kitConfigPath(), 'utf8'); + await selfDrift({ pkgRoot, force: true, record: false, fetchLatest }); + assert.deepEqual(tags.slice(-2), ['latest', 'next']); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), beforeReadOnly); + await selfDrift({ pkgRoot, force: true, cacheOnly: true, fetchLatest }); + assert.equal(fs.readFileSync(paths.kitConfigPath(), 'utf8'), beforeReadOnly); + assert.equal(tags.length, 6); + now += 24 * 3600_000; + await selfDrift({ pkgRoot, fetchLatest }); + assert.equal(tags.length, 8); +}); + +test('malformed self attempt stamps cannot suppress an offline retry', async t => { + seed('4.0.0'); + const cfg = loadKitConfig(); + cfg.versionCheck.self = { + last: 100, best: { version: '5.0.0-alpha.1', tag: 'next' }, + attempt: { at: Number.MAX_SAFE_INTEGER, tags: ['latest'] }, + }; + writeKitConfig(home, cfg); + t.mock.method(Date, 'now', () => 200_000_000); + let calls = 0; + await selfDrift({ pkgRoot, fetchLatest: async () => { calls += 1; return null; } }); + assert.equal(calls, 1); + assert.deepEqual(loadKitConfig().versionCheck.self.attempt, { at: 200_000_000, tags: ['latest'] }); + for (const attempt of [ + { at: '200000000', tags: ['latest'] }, + { at: 200_000_000.5, tags: ['latest'] }, + { at: 200_000_000, tags: ['next'] }, + { at: 200_000_000, tags: 'latest' }, + ]) { + const next = loadKitConfig(); + next.versionCheck.self.attempt = attempt; + writeKitConfig(home, next); + await selfDrift({ pkgRoot, fetchLatest: async () => { calls += 1; return null; } }); + } + assert.equal(calls, 5); +}); + test('successful registry observations supersede cached versions even after a channel rollback', async () => { seed(); const cfg = loadKitConfig(); diff --git a/tests/kit/telemetry-cli.test.mjs b/tests/kit/telemetry-cli.test.mjs index f7f2ad7b..a91132f2 100644 --- a/tests/kit/telemetry-cli.test.mjs +++ b/tests/kit/telemetry-cli.test.mjs @@ -66,6 +66,15 @@ test('should_keepErrorsGeneric_when_malformedFilesContainSecrets', t => { const result = cli(['validate', file]); assert.equal(result.status, 2); assert.doesNotMatch(result.stdout + result.stderr, /SECRET/); + assert.deepEqual(Object.keys(JSON.parse(result.stdout)), ['error', 'exitCode']); + assert.equal(JSON.parse(result.stdout).exitCode, 2); +}); +test('telemetry parser rejection remains private and machine-readable', () => { + const result = cli(['schema', '--private-path=/secret/SECRET']); + assert.equal(result.status, 2); + assert.deepEqual(Object.keys(JSON.parse(result.stdout)), ['error', 'exitCode']); + assert.equal(JSON.parse(result.stdout).exitCode, 2); + assert.doesNotMatch(result.stdout + result.stderr, /SECRET|\/secret/); }); test('should_degradeSourcesIndependently_when_usageReaderFails', async () => { const { collectSnapshot } = await import('../../src/lib/telemetry/collect.mjs'); diff --git a/tests/kit/upstream-watch-registry.test.mjs b/tests/kit/upstream-watch-registry.test.mjs index 7b1d43b9..18eca394 100644 --- a/tests/kit/upstream-watch-registry.test.mjs +++ b/tests/kit/upstream-watch-registry.test.mjs @@ -193,11 +193,10 @@ test('the tracking issues carry their whole upstream remainder', () => { const t240 = entry(doc, 'pacphi/agentic-kit#240'); assert.deepEqual([...t240.tracks].sort(), ['proffesor-for-testing/agentic-qe#574', 'proffesor-for-testing/agentic-qe#719']); assert.match(t240.adjustment, /agentic-qe#574/); - assert.ok(t240.kitImpact.files.includes('src/lib/aqe-readiness.mjs')); + assert.ok(t240.kitImpact.files.includes('docs/troubleshooting.md')); }); -// agentic-qe#719 is a partial fix for #574: releasing it alone must not dispatch removing the busy rule. -test('the partial fix agentic-qe#719 is context only; agentic-qe#574 drives the dispatch', () => { +test('the #574 exception retirement records released conformance without closing #240', () => { const doc = document(); const partial = entry(doc, 'proffesor-for-testing/agentic-qe#719'); assert.equal(partial.mapping, 'unmapped'); @@ -206,7 +205,13 @@ test('the partial fix agentic-qe#719 is context only; agentic-qe#574 drives the assert.match(partial.note, /agentic-qe#574/); const driver = entry(doc, 'proffesor-for-testing/agentic-qe#574'); assert.equal(driver.mapping, 'mapped'); - assert.match(driver.adjustment, /busy rule/); + assert.equal(driver.status, 'retired'); + assert.match(driver.adjustment, /macOS and Linux/); + assert.match(driver.adjustment, /not a universal AQE minimum/); + assert.ok(driver.history.some((item) => item.event === 'retired' && item.date === '2026-09-29')); + const tracker = entry(doc, 'pacphi/agentic-kit#240'); + assert.equal(tracker.status, 'watching'); + assert.match(tracker.adjustment, /final main PR/); }); test('stale threads are mapped to what ak carries, or retired with a reason', () => { diff --git a/tests/kit/verify-memory-routes.test.mjs b/tests/kit/verify-memory-routes.test.mjs index b1e87d23..cf530a51 100644 --- a/tests/kit/verify-memory-routes.test.mjs +++ b/tests/kit/verify-memory-routes.test.mjs @@ -241,8 +241,11 @@ test('the quick live memory check proves the CLI round trip without starting an assert.ok(!calls.includes('mcp start'), 'the live check stays quick: routing is observed only by the memory-routes proof'); }); -test('--only memory-routes runs the memory proof with the route observation and remembers it as memory', posix, async () => { +test('--only memory-routes runs the memory proof with the route observation and remembers it separately', posix, async () => { const cfg = offlineKitConfig(); + const evidence = await import('../../src/lib/live-check-evidence.mjs'); + evidence.recordLiveCheck({ id: 'memory', status: 'failed', source: 'status-refresh-live', + inputsKey: evidence.liveCheckInputsKey('memory') }); const { results, calls } = await withFakeRuflo('aligned', async () => ({ results: await verify.runLiveChecks({ cfg, cwd: PROJECT, only: ['memory-routes'] }), })); @@ -250,4 +253,21 @@ test('--only memory-routes runs the memory proof with the route observation and assert.equal(results[0].status, 'passed', JSON.stringify(results[0])); assert.ok(calls.includes('mcp start'), 'the named proof observes CLI↔MCP routing'); assert.ok(results[0].entries.some((e) => /see each other's writes/.test(e.text)), JSON.stringify(results[0].entries)); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'passed'); + assert.equal(evidence.readLiveCheck('memory').status, 'passed', + 'the successful CLI round trip refreshes the generic memory row'); +}); + +test('an unavailable route observation is inconclusive even after a successful CLI round trip', posix, async () => { + const cfg = offlineKitConfig(); + const evidence = await import('../../src/lib/live-check-evidence.mjs'); + evidence.recordLiveCheck({ id: 'memory', status: 'failed', source: 'status-refresh-live', + inputsKey: evidence.liveCheckInputsKey('memory') }); + const { results } = await withFakeRuflo('mcp-down', async () => ({ + results: await verify.runLiveChecks({ cfg, cwd: PROJECT, only: ['memory-routes'] }), + })); + assert.equal(results[0].status, 'inconclusive'); + assert.equal(evidence.readLiveCheck('memory-routes').status, 'inconclusive'); + assert.equal(evidence.readLiveCheck('memory').status, 'passed', + 'MCP unavailability does not undo the successful CLI proof'); }); diff --git a/tests/kit/windows-npm-shim.test.mjs b/tests/kit/windows-npm-shim.test.mjs new file mode 100644 index 00000000..fc965df1 --- /dev/null +++ b/tests/kit/windows-npm-shim.test.mjs @@ -0,0 +1,151 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { resolveShim, run } from '../../src/lib/exec.mjs'; +import { callMcpTools } from '../../src/lib/mcp-tool-call.mjs'; +import { spawnEnv } from './helpers/home-sandbox.mjs'; + +const templates = Object.fromEntries(['cmd', 'ps1'].map((ext) => [ext, + fs.readFileSync(new URL(`../fixtures/npm-windows-shim/ruflo.${ext}`, import.meta.url), 'utf8')])); +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-npm-shim-')); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const entry = path.join(root, 'node_modules', 'ruflo', 'bin', 'ruflo.js'); + const manifest = path.join(root, 'node_modules', 'ruflo', 'package.json'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, '#!/usr/bin/env node\n'); + fs.writeFileSync(manifest, JSON.stringify({ name: 'ruflo', bin: { ruflo: 'bin/ruflo.js' } })); + for (const ext of ['cmd', 'ps1']) fs.writeFileSync(path.join(root, `ruflo.${ext}`), templates[ext]); + const powershell = path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + fs.mkdirSync(path.dirname(powershell), { recursive: true }); fs.writeFileSync(powershell, 'fixture'); + const node = path.join(root, 'node.exe'); fs.writeFileSync(node, 'fixture'); + const env = { PATH: root, PATHEXT: '.EXE;.CMD', SystemRoot: root }; + return { root, entry, manifest, node, powershell, env }; +} + +test('recognized npm shim maps to its declared public bin with literal argv and adjacent Node', (t) => { + const f = fixture(t); + const args = ['mcp', 'start', 'a & b', 'quoted " argument', '$(ignored)', '', '\n']; + assert.deepEqual(resolveShim('ruflo', args, { windows: true, env: f.env }), { + command: f.node, args: [f.entry, ...args], resolved: true, + }); + assert.deepEqual(resolveShim(path.join(f.root, 'ruflo.cmd'), args, { windows: true, env: f.env }), { + command: f.node, args: [f.entry, ...args], resolved: true, + }); + assert.equal(resolveShim('ruflo', args, { windows: true, env: f.env, npmBin: false }).command, f.powershell); + for (const ext of ['cmd', 'ps1']) { + fs.writeFileSync(path.join(f.root, `ruflo.${ext}`), templates[ext].replaceAll('\r\n', '\n').replaceAll('\n', '\r\n')); + } + assert.equal(resolveShim('ruflo', args, { windows: true, env: f.env }).command, f.node, 'CRLF templates remain recognized'); +}); + +test('PATH-selected installation and case-insensitive Node lookup beat current runtime/global guesses', (t) => { + const f = fixture(t); const other = fixture(t); + fs.rmSync(f.node); + const env = { pAtH: `${f.root}${path.delimiter}${other.root}`, pAtHeXt: '.CMD', sYsTeMrOoT: f.root }; + assert.deepEqual(resolveShim('ruflo', ['mcp', 'start'], { windows: true, env }), { + command: other.node, args: [f.entry, 'mcp', 'start'], resolved: true, + }); +}); + +for (const mutation of ['cmd', 'ps1', 'manifest', 'foreign-package', 'undeclared-bin', 'traversal', 'shebang', 'missing-bin', 'missing-node']) { + test(`${mutation} cannot silently bypass a custom or unverified wrapper`, (t) => { + const f = fixture(t); + if (mutation === 'cmd' || mutation === 'ps1') fs.appendFileSync(path.join(f.root, `ruflo.${mutation}`), '\ncustom-behavior\n'); + if (mutation === 'manifest') fs.writeFileSync(f.manifest, 'broken json'); + if (mutation === 'foreign-package') fs.writeFileSync(f.manifest, JSON.stringify({ name: 'other', bin: { ruflo: 'bin/ruflo.js' } })); + if (mutation === 'undeclared-bin') fs.writeFileSync(f.manifest, JSON.stringify({ name: 'ruflo', bin: { other: 'bin/ruflo.js' } })); + if (mutation === 'traversal') fs.writeFileSync(f.manifest, JSON.stringify({ name: 'ruflo', bin: { ruflo: '../other.js' } })); + if (mutation === 'shebang') fs.writeFileSync(f.entry, '#!/usr/bin/env node --require injected\n'); + if (mutation === 'missing-bin') fs.rmSync(f.entry); + if (mutation === 'missing-node') fs.rmSync(f.node); + assert.equal(resolveShim('ruflo', [], { windows: true, env: f.env }).command, f.powershell); + }); +} + +test('custom wrapper first on PATH and native executable precedence remain authoritative', (t) => { + const first = fixture(t); const second = fixture(t); + fs.appendFileSync(path.join(first.root, 'ruflo.ps1'), '\n# user customization\n'); + const env = { ...first.env, PATH: `${first.root}${path.delimiter}${second.root}` }; + assert.equal(resolveShim('ruflo', [], { windows: true, env }).command, first.powershell); + fs.writeFileSync(path.join(first.root, 'ruflo.exe'), 'native'); + assert.deepEqual(resolveShim('ruflo', ['literal'], { windows: true, env }), { + command: path.join(first.root, 'ruflo.exe'), args: ['literal'], resolved: true, + }); +}); + +test('bin symlink escaping its package cannot authorize bypass', { skip: process.platform === 'win32' }, (t) => { + const f = fixture(t); + const outside = path.join(f.root, 'outside.js'); fs.writeFileSync(outside, '#!/usr/bin/env node\n'); + fs.rmSync(f.entry); fs.symlinkSync(outside, f.entry); + assert.equal(resolveShim('ruflo', [], { windows: true, env: f.env }).command, f.powershell); +}); + +test('recognized public entry point answers MCP initialize without stdin EOF', { skip: process.platform === 'win32' }, async (t) => { + const f = fixture(t); + fs.rmSync(f.node); fs.symlinkSync(process.execPath, f.node); + fs.writeFileSync(f.entry, `#!/usr/bin/env node +require('node:readline').createInterface({input:process.stdin}).on('line', line => { + const r=JSON.parse(line); + if(r.method==='initialize') process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result:{protocolVersion:'2024-11-05'}})+'\\n'); +}); +`); + const spec = resolveShim('ruflo', ['mcp', 'start'], { windows: true, env: f.env }); + assert.equal(spec.command, f.node); + const result = await callMcpTools({ ...spec, cwd: f.root, env: spawnEnv(f.root), calls: [], timeoutMs: 1000 }); + assert.equal(result.status, 'ok'); +}); + +test('scoped package aliases and string bin declarations require manifest agreement', (t) => { + const f = fixture(t); + fs.writeFileSync(f.manifest, JSON.stringify({ name: 'ruflo', bin: './bin/ruflo.js' })); + assert.equal(resolveShim('ruflo', [], { windows: true, env: f.env }).command, f.node); + const target = 'node_modules/@scope/tool/bin/cli.js'; + const entry = path.join(f.root, ...target.split('/')); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(entry, '#!/usr/bin/env node\n'); + const manifest = path.join(f.root, 'node_modules', '@scope', 'tool', 'package.json'); + fs.writeFileSync(manifest, JSON.stringify({ name: '@scope/tool', bin: { ruflo: 'bin/cli.js' } })); + for (const ext of ['cmd', 'ps1']) { + const oldTarget = ext === 'cmd' ? 'node_modules\\ruflo\\bin\\ruflo.js' : 'node_modules/ruflo/bin/ruflo.js'; + fs.writeFileSync(path.join(f.root, `ruflo.${ext}`), templates[ext].replaceAll(oldTarget, ext === 'cmd' ? target.replaceAll('/', '\\') : target)); + } + assert.deepEqual(resolveShim('ruflo', [], { windows: true, env: f.env }), { + command: f.node, args: [entry], resolved: true, + }); + fs.writeFileSync(manifest, JSON.stringify({ name: '@scope/tool', bin: 'bin/cli.js' })); + assert.equal(resolveShim('ruflo', [], { windows: true, env: f.env }).command, f.powershell); +}); + +test('run honors a differently cased PATH override without duplicate environment keys', { skip: process.platform === 'win32' }, async (t) => { + const f = fixture(t); + fs.rmSync(f.node); fs.symlinkSync(process.execPath, f.node); + fs.writeFileSync(f.entry, `#!/usr/bin/env node +process.stdout.write(JSON.stringify({argv:process.argv.slice(2),paths:Object.keys(process.env).filter(k=>k.toUpperCase()==='PATH')})); +`); + const args = ['mcp', 'quoted " value', 'a & b', '']; + const result = await run('ruflo', args, { windows: true, env: { + pAtH: f.root, pAtHeXt: '.CMD', sYsTeMrOoT: f.root, + } }); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { argv: args, paths: ['pAtH'] }); +}); + +test('an earlier cmd with no safe sibling does not fall through to another installation', (t) => { + const first = fixture(t); const second = fixture(t); + fs.rmSync(path.join(first.root, 'ruflo.ps1')); + const env = { ...first.env, PATH: `${first.root}${path.delimiter}${second.root}` }; + assert.deepEqual(resolveShim('ruflo', ['mcp'], { windows: true, env }), { + command: 'ruflo', args: ['mcp'], resolved: false, + }); +}); + +test('package symlink escaping the selected installation cannot authorize bypass', { skip: process.platform === 'win32' }, (t) => { + const first = fixture(t); const second = fixture(t); + const pkg = path.join(first.root, 'node_modules', 'ruflo'); + fs.rmSync(pkg, { recursive: true }); + fs.symlinkSync(path.join(second.root, 'node_modules', 'ruflo'), pkg); + assert.equal(resolveShim('ruflo', [], { windows: true, env: first.env }).command, first.powershell); +}); diff --git a/tests/live/aqe-live-lock-conformance.test.mjs b/tests/live/aqe-live-lock-conformance.test.mjs new file mode 100644 index 00000000..bf5e6f2b --- /dev/null +++ b/tests/live/aqe-live-lock-conformance.test.mjs @@ -0,0 +1,150 @@ +// Opt-in native contract for agentic-qe#574. No installed package is patched. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createProcessScope } from './aqe-live-lock-process.mjs'; + +const required = process.env.AK_AQE_LOCK_LIVE === '1'; +const digest = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function installedRoot() { + if (process.env.AK_AQE_PACKAGE_ROOT) { + assert.ok(path.isAbsolute(process.env.AK_AQE_PACKAGE_ROOT), 'package root must be absolute'); + return fs.realpathSync(process.env.AK_AQE_PACKAGE_ROOT); + } + for (const dir of (process.env.PATH ?? '').split(path.delimiter)) { + const bin = path.join(dir, 'aqe'); + if (dir && fs.existsSync(bin)) { + const root = path.resolve(path.dirname(fs.realpathSync(bin)), '../..'); + if (fs.existsSync(path.join(root, 'package.json'))) return root; + } + } + throw new Error('AQE missing: set AK_AQE_PACKAGE_ROOT to absolute installed package root'); +} + +function childEnv(root, project) { + const home = path.join(root, 'home'); + const tmp = path.join(root, 'tmp'); + fs.mkdirSync(home); fs.mkdirSync(tmp); + return { + PATH: process.env.PATH ?? '', + ...(process.platform === 'win32' ? { + SystemRoot: process.env.SystemRoot ?? 'C:\\Windows', + ComSpec: process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe', + PATHEXT: process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD', + } : {}), + HOME: home, USERPROFILE: home, TMPDIR: tmp, TMP: tmp, TEMP: tmp, + LANG: 'en_US.UTF-8', CI: '1', NO_COLOR: '1', + XDG_CONFIG_HOME: path.join(home, 'config'), XDG_STATE_HOME: path.join(home, 'state'), + XDG_DATA_HOME: path.join(home, 'data'), XDG_CACHE_HOME: path.join(home, 'cache'), + APPDATA: path.join(home, 'appdata'), LOCALAPPDATA: path.join(home, 'localappdata'), + CODEX_HOME: path.join(home, 'codex'), CLAUDE_CONFIG_DIR: path.join(home, 'claude'), + HERMES_HOME: path.join(home, 'hermes'), npm_config_prefix: path.join(home, 'npm-prefix'), + npm_config_cache: path.join(home, 'npm-cache'), + MISE_DATA_DIR: path.join(home, 'mise-data'), MISE_CONFIG_DIR: path.join(home, 'mise-config'), + MISE_CACHE_DIR: path.join(home, 'mise-cache'), AQE_PROJECT_ROOT: project, + AQE_MEMORY_PATH: path.join(project, '.agentic-qe', 'memory.db'), + AQE_STORAGE_PATH: path.join(project, '.agentic-qe'), + CLAUDE_FLOW_MEMORY_PATH: path.join(project, '.swarm'), + CLAUDE_FLOW_DB_PATH: path.join(project, '.swarm', 'memory.db'), + RUFLO_DAEMON_AUTOSTART: '0', + }; +} + +async function bounded(scope, file, args, options, limit) { + const run = scope.launch(process.execPath, [file, ...args], options); + return scope.wait(run, limit); +} + +function snapshot(store) { + return Object.fromEntries(fs.readdirSync(store).filter((n) => n.startsWith('patterns.rvf')) + .sort().map((name) => { + const file = path.join(store, name); + return [name, { sha256: digest(file), bytes: fs.statSync(file).size }]; + })); +} + +function check(label, result, owner, before, store) { + const output = result.stdout + result.stderr; + assert.equal(result.timedOut, false, `${label} timed out`); + assert.equal(result.code, 0, `${label} exited ${result.code}: ${output.slice(-1200)}`); + assert.equal(owner.closed, false, `native holder closed during ${label}`); + assert.equal(owner.child.exitCode, null, `native holder exited during ${label}`); + assert.equal(owner.child.signalCode, null, `native holder signaled during ${label}`); + assert.ifError(owner.error); + assert.match(output, /is locked by a live process/, `${label} missed live-lock warning`); + assert.match(output, /LockHeld|0x0300/, `${label} missed LockHeld fallback`); + assert.doesNotMatch(output, /FsyncFailed|0x0303/, `${label} emitted FsyncFailed`); + assert.deepEqual(snapshot(store), before, `${label} changed RVF bytes`); + assert.ok(!fs.readdirSync(store).some((n) => n.includes('.corrupt-')), `${label} quarantined RVF`); +} + +test('installed AQE degrades under a live native RVF lock without changing the store', + { skip: !required && 'set AK_AQE_LOCK_LIVE=1 for native proof', timeout: 420_000 }, async (t) => { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ak-aqe-live-lock-'))); + const scope = createProcessScope(t.signal); + t.after(async () => { + try { + await scope.closeAll(); + } catch (error) { + throw new Error(`owned child closure unverified; retained ${root}`, { cause: error }); + } + fs.rmSync(root, { recursive: true, force: true, maxRetries: 3 }); + }); + const project = path.join(root, 'project'); fs.mkdirSync(project); + fs.writeFileSync(path.join(project, 'package.json'), '{"name":"aqe-live-lock-probe","version":"1.0.0","type":"module"}\n'); + const env = childEnv(root, project); + const packageRoot = installedRoot(); + const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')); + assert.equal(pkg.name, 'agentic-qe'); + if (process.env.AK_AQE_EXPECTED_VERSION) assert.equal(pkg.version, process.env.AK_AQE_EXPECTED_VERSION); + const entry = path.join(packageRoot, 'dist', 'cli', 'bundle.js'); + const adapter = path.join(packageRoot, 'dist', 'integrations', 'ruvector', 'shared-rvf-adapter.js'); + assert.ok(fs.existsSync(entry) && fs.existsSync(adapter), 'installed AQE CLI and adapter required'); + const sourceHashes = { entry: digest(entry), adapter: digest(adapter) }; + const options = { cwd: project, env }; + const init = await bounded(scope, entry, ['init', '--minimal', '--auto'], options, 150_000); + assert.equal(init.timedOut, false); + assert.equal(init.code, 0, `aqe init failed: ${(init.stdout + init.stderr).slice(-1200)}`); + const store = path.join(project, '.agentic-qe'); + const ready = path.join(root, 'ready'); + const holderFile = path.join(root, 'holder.mjs'); + const moduleUrl = pathToFileURL(adapter).href; + fs.writeFileSync(holderFile, `import { createRequire } from 'node:module'; import fs from 'node:fs';\nglobalThis.require=createRequire(${JSON.stringify(adapter)});\nconst {getSharedRvfAdapter}=await import(${JSON.stringify(moduleUrl)});\nglobalThis.hold=getSharedRvfAdapter(${JSON.stringify(store)},384);\nif(!globalThis.hold)process.exit(2);\nfs.writeFileSync(${JSON.stringify(ready)},String(process.pid));\nconst timer=setInterval(()=>{if(!globalThis.hold)process.exit(3)},1000);\nprocess.on('SIGTERM',()=>{clearInterval(timer);globalThis.hold.close();process.exit(0)});\n`); + const owner = scope.launch(process.execPath, [holderFile], options); + try { + const deadline = Date.now() + 30_000; + while (!fs.existsSync(ready) && !owner.closed && !owner.error && owner.child.exitCode === null + && owner.child.signalCode === null && Date.now() < deadline) await pause(50); + assert.ifError(owner.error); + assert.ok(fs.existsSync(ready), 'holder did not signal ready'); + assert.equal(Number(fs.readFileSync(ready, 'utf8')), owner.child.pid, 'holder PID mismatch'); + assert.equal(owner.closed, false, 'holder closed after ready'); + assert.equal(owner.child.exitCode, null, 'holder exited after ready'); + assert.equal(owner.child.signalCode, null, 'holder signaled after ready'); + const before = snapshot(store); + assert.ok(before['patterns.rvf'] && before['patterns.rvf.lock'], 'native RVF and lock required'); + const status = await bounded(scope, entry, ['status'], options, 150_000); + check('aqe status', status, owner, before, store); + const challengerFile = path.join(root, 'challenger.mjs'); + fs.writeFileSync(challengerFile, `import {createRequire} from 'node:module';\nglobalThis.require=createRequire(${JSON.stringify(adapter)});\nconst {getSharedRvfAdapter}=await import(${JSON.stringify(moduleUrl)});\nconst adapter=getSharedRvfAdapter(${JSON.stringify(store)},384);\nconsole.log(JSON.stringify({fallback:adapter===null}));\nif(adapter){adapter.close();process.exitCode=2}\n`); + const challenger = await bounded(scope, challengerFile, [], options, 30_000); + check('shipped adapter', challenger, owner, before, store); + assert.match(challenger.stdout, /"fallback":true/, 'adapter did not fall back'); + console.log(JSON.stringify({ aqeVersion: pkg.version, platform: process.platform, + node: process.version, sourceHashes, ownerPid: owner.child.pid, + status: { exit: status.code, liveLock: true, lockHeld: true, fsyncFailed: false }, + adapter: { exit: challenger.code, fallback: true, liveLock: true, lockHeld: true, fsyncFailed: false }, + before, after: snapshot(store) })); + } finally { + const closed = await scope.stop(owner, 10_000); + if (!t.signal.aborted) { + assert.equal(closed.code, 0, `holder failed to close: ${closed.stderr.slice(-1000)}`); + } + } + }); diff --git a/tests/live/aqe-live-lock-process.mjs b/tests/live/aqe-live-lock-process.mjs new file mode 100644 index 00000000..433ab8a3 --- /dev/null +++ b/tests/live/aqe-live-lock-process.mjs @@ -0,0 +1,107 @@ +import { spawn } from 'node:child_process'; +import { acquireRunRootHold, releaseRunRootHold } from '../../scripts/run-roots.mjs'; +import { envValue } from '../kit/helpers/home-sandbox.mjs'; + +const CLOSE_LIMIT_MS = 10_000; + +function closedWithin(run, ms) { + if (run.closed) return Promise.resolve(run.result); + let timer; + return Promise.race([ + run.done, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`call-owned child ${run.child.pid ?? 'unspawned'} did not close`)), ms); + }), + ]).finally(() => clearTimeout(timer)); +} + +export function createProcessScope(signal, { closeLimitMs = CLOSE_LIMIT_MS, platform = process.platform } = {}) { + // Register uncertainty with the enclosing guarded runner before any child starts. + const hold = acquireRunRootHold(); + const runs = new Set(); + let closed = false; + let closing = false; + const killLive = () => { + for (const run of runs) if (!run.closed) run.child.kill('SIGKILL'); + }; + signal.addEventListener('abort', killLive, { once: true }); + + function launch(command, args, options) { + if (closing || signal.aborted) throw Error('cannot launch after process scope closing or aborted'); + if (!options?.env || typeof options.env !== 'object' || Array.isArray(options.env)) { + throw Error('call-owned child requires an explicit sandbox env'); + } + const env = options.env; + const missing = (key) => { + const value = envValue(env, key, platform); + return typeof value !== 'string' || !value; + }; + const required = ['HOME', 'USERPROFILE', 'TMPDIR', 'TEMP', 'TMP', 'XDG_CONFIG_HOME', + 'XDG_STATE_HOME', 'APPDATA', 'LOCALAPPDATA']; + if (required.some(missing)) { + throw Error('call-owned child requires sandbox home, temp, and state env'); + } + if (platform === 'win32' && ['SystemRoot', 'ComSpec', 'PATHEXT'].some(missing)) { + throw Error('call-owned child requires Windows process env'); + } + const child = spawn(command, args, { ...options, env, stdio: ['ignore', 'pipe', 'pipe'] }); + const run = { child, closed: false, error: null, stdout: '', stderr: '', done: null, result: null }; + runs.add(run); + child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); + child.stdout.on('data', (s) => { run.stdout += s; }); + child.stderr.on('data', (s) => { run.stderr += s; }); + // Resolve on close even after spawn error. The caller can inspect error immediately + // while polling readiness, and no delayed rejection can go unhandled. + run.done = new Promise((resolve) => { + child.once('error', (error) => { run.error = error; }); + child.once('close', (code, childSignal) => { + run.closed = true; + run.result = { code, signal: childSignal, stdout: run.stdout, stderr: run.stderr }; + resolve(run.result); + }); + }); + if (signal.aborted) child.kill('SIGKILL'); + return run; + } + + async function wait(run, limit) { + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + if (!run.closed) run.child.kill('SIGKILL'); + }, limit); + try { + const result = await closedWithin(run, limit + CLOSE_LIMIT_MS); + if (run.error) throw run.error; + return { ...result, timedOut }; + } finally { clearTimeout(timer); } + } + + async function stop(run, grace = CLOSE_LIMIT_MS) { + if (!run.closed) run.child.kill('SIGTERM'); + let timer; + try { + await Promise.race([ + run.done, + new Promise((resolve) => { timer = setTimeout(resolve, grace); }), + ]); + } finally { + clearTimeout(timer); + if (!run.closed) run.child.kill('SIGKILL'); + } + return closedWithin(run, CLOSE_LIMIT_MS); + } + + async function closeAll() { + if (closed) return; + closing = true; + killLive(); + // A failed close keeps the caller's temporary root intact for diagnosis. + await Promise.all([...runs].map((run) => closedWithin(run, closeLimitMs))); + releaseRunRootHold(hold); + closed = true; + signal.removeEventListener('abort', killLive); + } + + return { launch, wait, stop, closeAll }; +} diff --git a/tests/live/ruflo-windows-diagnostic-process.mjs b/tests/live/ruflo-windows-diagnostic-process.mjs new file mode 100644 index 00000000..5ec1b20a --- /dev/null +++ b/tests/live/ruflo-windows-diagnostic-process.mjs @@ -0,0 +1,103 @@ +// Test-only exact-environment launcher. Every attempt contributes independently +// to the scope's cleanup receipt; uncertainty is sticky until handoff. +import { spawn } from 'node:child_process'; +import { acquireRunRootHold, releaseRunRootHold } from '../../scripts/run-roots.mjs'; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +export function createDiagnosticScope({ spawnFn = spawn, platform = process.platform, + killTimeoutMs = 3000, closeTimeoutMs = 5000, + acquireHold = acquireRunRootHold, releaseHold = releaseRunRootHold } = {}) { + const hold = acquireHold(); + const receipts = []; + let released = false; + + async function launch(spec, { cwd, env, timeoutMs, input = '', endInput = true, + until = () => false, tree = true }) { + if (released) throw Error('diagnostic scope already released'); + if (!env || typeof env !== 'object') throw Error('explicit diagnostic environment required'); + const result = { code: null, signal: null, stdout: '', stderr: '', error: null, + timedOut: false, overflow: false, matched: false, cleanupComplete: false, elapsedMs: 0 }; + receipts.push(result); + let child; + let closed = false; + let bytes = 0; + const started = Date.now(); + const live = () => child?.pid && child.exitCode === null && child.signalCode === null; + const capture = (key, chunk) => { + bytes += chunk.length; + result.overflow ||= bytes > 65536; + result[key] = (result[key] + chunk.toString('utf8')).slice(0, 16384); + }; + const waitClose = async () => { + const deadline = Date.now() + closeTimeoutMs; + while (!closed && Date.now() < deadline) await delay(10); + }; + try { + child = spawnFn(spec.command, spec.args, { + cwd, env, shell: false, stdio: ['pipe', 'pipe', 'pipe'], + detached: tree && platform !== 'win32', + }); + child.on('error', (e) => { result.error = e.message; }); + child.once('close', (code, signal) => { + closed = true; result.code = code; result.signal = signal; + }); + child.stdout.on('data', (chunk) => capture('stdout', chunk)); + child.stderr.on('data', (chunk) => capture('stderr', chunk)); + child.stdin.on('error', (e) => { result.error = e.message; }); + child.stdin.write(input); + if (endInput) child.stdin.end(); + while (!closed && !result.error && !result.overflow && Date.now() - started < timeoutMs) { + result.matched = until(result.stdout); + // EOF comparisons must observe natural closure. Stopping on the + // first response can race a server already exiting after stdin end. + if (result.matched && !endInput) break; + await delay(10); + } + result.matched ||= until(result.stdout); + result.timedOut = !closed && !(result.matched && !endInput) && !result.error && !result.overflow; + } catch (error) { + result.error = error.message; + } finally { + // Never throw before cleanup. Failed tree termination is uncertainty even + // if the direct-child fallback subsequently closes its own pipes. + let treeStopped = closed || !child; + if (child && !closed) { + if (live()) { + if (tree && platform === 'win32') { + const killed = await launch({ command: 'taskkill.exe', args: ['/PID', String(child.pid), '/T', '/F'] }, + { cwd, env, timeoutMs: killTimeoutMs, tree: false }); + treeStopped = killed.cleanupComplete && killed.code === 0 && !killed.timedOut && !killed.error; + } else { + try { + if (tree) process.kill(-child.pid, 'SIGKILL'); + else child.kill('SIGKILL'); + treeStopped = true; + } catch { treeStopped = false; } + } + if (!treeStopped && live()) { + try { child.kill('SIGKILL'); } catch { /* preserve uncertainty */ } + } + } + await waitClose(); + } + result.cleanupComplete = treeStopped && (closed || !child); + if (child && !closed) { + // Close local handles and release event-loop references without claiming + // the process tree exited. The scope hold remains active. + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + child.unref(); + } + result.elapsedMs = Date.now() - started; + } + return result; + } + + function release() { + if (receipts.some((receipt) => !receipt.cleanupComplete)) return false; + if (!released) releaseHold(hold); + released = true; + return true; + } + return { launch, release, receipts }; +} diff --git a/tests/live/ruflo-windows-transport.test.mjs b/tests/live/ruflo-windows-transport.test.mjs new file mode 100644 index 00000000..d4c91e08 --- /dev/null +++ b/tests/live/ruflo-windows-transport.test.mjs @@ -0,0 +1,95 @@ +// Opt-in diagnosis only. A backend response never substitutes for public routing +// proof. Run alongside (not instead of) ruflo-memory-routing.test.mjs. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { spawnEnv, envValue } from '../kit/helpers/home-sandbox.mjs'; +import { resolveShim } from '../../src/lib/exec.mjs'; +import { createDiagnosticScope } from './ruflo-windows-diagnostic-process.mjs'; + +const sha = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const initialized = (stdout) => stdout.split('\n').some((line) => { + try { const r = JSON.parse(line); return r.id === 1 && Boolean(r.result?.protocolVersion); } + catch { return false; } +}); +const input = `${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { + protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'ak-native-diagnostic', version: '1' }, +} })}\n`; + +test('diagnose installed Ruflo Windows public shim versus installed bin transport', { + skip: process.env.AK_RUFLO_WINDOWS_DIAGNOSTIC !== '1', timeout: 180000, +}, async () => { + assert.equal(process.platform, 'win32', 'diagnostic requires native Windows'); + const packageRoot = process.env.AK_RUFLO_PACKAGE_ROOT; + assert.ok(packageRoot && path.isAbsolute(packageRoot), 'explicit installed Ruflo package root required'); + const packageFile = path.join(packageRoot, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8')); + assert.equal(pkg.name, 'ruflo'); + const bin = path.resolve(packageRoot, typeof pkg.bin === 'string' ? pkg.bin : pkg.bin.ruflo); + assert.ok(bin.startsWith(`${fs.realpathSync(packageRoot)}${path.sep}`), 'bin belongs to installed package'); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-ruflo-win-diagnostic-')); + const scope = createDiagnosticScope(); + let clean = false; + try { + const bootstrap = {}; + for (const key of ['PATH', 'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT']) { + const value = envValue(process.env, key); + if (value) bootstrap[key] = value; + } + const env = spawnEnv(root, { + CLAUDE_FLOW_DB_PATH: path.join(root, '.swarm', 'memory.db'), + CLAUDE_FLOW_MEMORY_PATH: path.join(root, '.swarm'), RUFLO_DAEMON_AUTOSTART: '0', + }, { env: bootstrap }); + fs.writeFileSync(path.join(root, 'claude-flow.config.json'), JSON.stringify({ daemon: { autostart: false } })); + const invocation = resolveShim('ruflo', ['mcp', 'start'], { env }); + assert.equal(invocation.resolved, true, 'installed public shim must resolve'); + const powershell = resolveShim('ruflo', ['mcp', 'start'], { env, npmBin: false }); + const scriptIndex = powershell.args.indexOf('-File'); + assert.ok(scriptIndex >= 0, 'receipt requires the native PowerShell transport'); + const shim = powershell.args[scriptIndex + 1]; + const cmdShim = shim.slice(0, -4) + '.cmd'; + const version = await scope.launch(resolveShim('ruflo', ['--version'], { env }), + { cwd: root, env, timeoutMs: 10000 }); + console.log(JSON.stringify({ diagnostic: 'inputs', node: process.version, uv: process.versions.uv, + platform: process.platform, packageVersion: pkg.version, version, + packageSha: sha(packageFile), bin, binSha: sha(bin), invocation, powershell, + cmdSha: sha(cmdShim), cmdSource: fs.readFileSync(cmdShim, 'utf8').slice(0, 8192), + shimSha: sha(shim), shimSource: fs.readFileSync(shim, 'utf8').slice(0, 8192), + sourceSha: sha(new URL(import.meta.url)), + launcherSha: sha(new URL('./ruflo-windows-diagnostic-process.mjs', import.meta.url)) })); + assert.equal(version.cleanupComplete, true, 'version launch cleanup incomplete'); + // Capture the original fixture's native argument forwarding without + // creating any descendants: this isolates quoting from tree ownership. + const legacyShim = path.join(root, 'legacy-fixture.ps1'); + const marker = path.join(root, 'legacy-marker'); + fs.writeFileSync(legacyShim, `& '${process.execPath.replaceAll("'", "''")}' -e $args[0]\nexit $LASTEXITCODE\n`); + const legacyCode = `const {spawn}=require('node:child_process'); + require('node:fs').writeFileSync(${JSON.stringify(marker)},JSON.stringify([process.pid]));`; + const legacy = await scope.launch({ command: powershell.command, args: [ + ...powershell.args.slice(0, scriptIndex + 1), legacyShim, legacyCode, + ] }, { cwd: root, env, timeoutMs: 5000 }); + console.log(JSON.stringify({ diagnostic: 'legacy-multiline-node-e', + marker: fs.existsSync(marker), ...legacy })); + assert.equal(legacy.cleanupComplete, true, 'legacy launch cleanup incomplete'); + for (const [transport, spec, eof] of [ + ['public-open-stdin', invocation, false], + ['public-eof', invocation, true], + ['powershell-open-stdin', powershell, false], + ['powershell-eof', powershell, true], + ['installed-bin-open-stdin', { command: process.execPath, args: [bin, 'mcp', 'start'] }, false], + ]) { + const observation = await scope.launch(spec, { cwd: root, env, input, endInput: eof, + timeoutMs: 15000, until: initialized }); + console.log(JSON.stringify({ diagnostic: transport, initialized: observation.matched, endInput: eof, ...observation })); + assert.equal(observation.cleanupComplete, true, `${transport} cleanup incomplete`); + } + clean = scope.release(); + assert.equal(clean, true, 'every diagnostic launch must prove cleanup before root removal'); + } finally { + if (clean) fs.rmSync(root, { recursive: true, force: true }); + else console.error(`diagnostic root retained: ${root}`); + } +});