diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2bbbd94..d73618d 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -23,6 +23,13 @@ jobs: with: node-version: 22 + - name: Isolate npm globals from the runner image + shell: bash + run: | + echo "npm_config_prefix=$RUNNER_TEMP/npm-prefix" >> "$GITHUB_ENV" + echo "$RUNNER_TEMP/npm-prefix/bin" >> "$GITHUB_PATH" + mkdir -p "$RUNNER_TEMP/npm-prefix" + # Deliberately npm, not pnpm: this simulates the kit's TARGET environment — # ruflo/agentic-qe installed via `npm i -g`, whose trees the kit heals with # npm (lib/heal.mjs). pnpm-managed globals are a separate follow-up. @@ -32,6 +39,9 @@ jobs: - name: Kit heals a fresh install (sync --no-upgrade) env: HOME: ${{ runner.temp }}/kit-home + USERPROFILE: ${{ runner.temp }}/kit-home + XDG_CONFIG_HOME: ${{ runner.temp }}/kit-home/.config + APPDATA: ${{ runner.temp }}/kit-home/AppData/Roaming run: | mkdir -p "$HOME" node bin/agentic-kit.mjs sync --no-upgrade || true @@ -47,10 +57,73 @@ jobs: - name: Deep proofs against the live packages env: HOME: ${{ runner.temp }}/kit-home + USERPROFILE: ${{ runner.temp }}/kit-home + XDG_CONFIG_HOME: ${{ runner.temp }}/kit-home/.config + APPDATA: ${{ runner.temp }}/kit-home/AppData/Roaming run: | node bin/agentic-kit.mjs x verify security node bin/agentic-kit.mjs x verify learning + clean-mac-setup: + name: clean macOS setup (packed artifact) + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Install the packed kit into a disposable prefix + shell: bash + run: | + echo "npm_config_prefix=$RUNNER_TEMP/npm-prefix" >> "$GITHUB_ENV" + echo "$RUNNER_TEMP/npm-prefix/bin" >> "$GITHUB_PATH" + mkdir -p "$RUNNER_TEMP/npm-prefix" "$RUNNER_TEMP/package" + npm pack --pack-destination "$RUNNER_TEMP/package" + npm install -g "$RUNNER_TEMP"/package/pacphi-agentic-kit-*.tgz + - name: Run real setup in a disposable HOME and project + shell: bash + env: + HOME: ${{ runner.temp }}/clean-home + USERPROFILE: ${{ runner.temp }}/clean-home + XDG_CONFIG_HOME: ${{ runner.temp }}/clean-home/.config + XDG_STATE_HOME: ${{ runner.temp }}/clean-home/.local/state + APPDATA: ${{ runner.temp }}/clean-home/AppData/Roaming + npm_config_cache: ${{ runner.temp }}/npm-cache + RUVNET_BRAIN_KB: ${{ runner.temp }}/brain-kb + AK_PROJECT: ${{ runner.temp }}/clean-project + run: | + mkdir -p "$HOME" "$AK_PROJECT" + git -C "$AK_PROJECT" init + (cd "$AK_PROJECT" && ak setup --yes --no-ruvnet-brain) | tee "$RUNNER_TEMP/setup.log" + node --input-type=module -e ' + import fs from "node:fs"; + import path from "node:path"; + const log = fs.readFileSync(process.env.RUNNER_TEMP + "/setup.log", "utf8"); + const settings = JSON.parse(fs.readFileSync(path.join(process.env.AK_PROJECT, ".claude", "settings.json"), "utf8")); + const expected = ["Bash(npx @claude-flow*)","Bash(npx claude-flow*)","Bash(node .claude/*)","mcp__claude-flow__*","Bash(npx agentic-qe:*)","Bash(npx @anthropics/agentic-qe:*)","mcp__agentic-qe__*"]; + for (const rule of expected) if (!log.includes(rule)) throw new Error(`permission was not disclosed: ${rule}`); + const actual = new Set(settings.permissions?.allow ?? []); + for (const rule of expected) if (!actual.has(rule)) throw new Error(`permission missing after setup: ${rule}`); + if (log.indexOf(expected[0]) > log.indexOf("ruflo init --full")) throw new Error("permission disclosure happened after project mutation"); + ' + - name: Stop disposable daemons + if: always() + shell: bash + env: + HOME: ${{ runner.temp }}/clean-home + USERPROFILE: ${{ runner.temp }}/clean-home + XDG_CONFIG_HOME: ${{ runner.temp }}/clean-home/.config + APPDATA: ${{ runner.temp }}/clean-home/AppData/Roaming + run: ruflo daemon stop --all || true + - name: Upload disposable-run setup evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: clean-mac-setup-evidence + path: | + ${{ runner.temp }}/setup.log + ${{ runner.temp }}/clean-project/.claude/settings.json + links-external: name: links (external) runs-on: ubuntu-latest diff --git a/bin/agentic-kit.mjs b/bin/agentic-kit.mjs index 1f568c4..a38a3cd 100755 --- a/bin/agentic-kit.mjs +++ b/bin/agentic-kit.mjs @@ -151,7 +151,10 @@ async function main() { // `ak usage status` promises a pure offline cache read. The explicit // `refresh` subcommand owns its one named network request; neither form may // silently add unrelated npm probes through the generic drift nudge. - if (!values.json && !values['dry-run'] && !['sync', 'usage'].includes(cmd)) { + // setup and host own complete mutation/reporting flows. Running the generic + // nudge after a declined trust preflight could write version-cache state and + // violate their "before any changes" boundary. + if (!values.json && !values['dry-run'] && !['sync', 'usage', 'setup', 'host'].includes(cmd)) { try { const { driftReport } = await import('../src/lib/versions.mjs'); for (const r of await driftReport()) { diff --git a/docs/HOST-SUPPORT.md b/docs/HOST-SUPPORT.md index d1b1bbe..b656161 100644 --- a/docs/HOST-SUPPORT.md +++ b/docs/HOST-SUPPORT.md @@ -66,8 +66,13 @@ Ruflo, AQE, or Brain parity merely because it can call their MCP tools. OpenCode supervision is a safety boundary around resources, not a sandbox around the repository. An `opencode.json` that pre-approves a tool is inside the user's workspace trust decision; no permission-request event exists for agentic-kit to -deny. Claude and Codex likewise inherit repository-owned hooks, instructions, and -permissions. +deny. Claude and Codex likewise inherit repository-owned hooks, instructions, +and permissions. `ak setup` and new enablements through `ak host pick` disclose +the applicable host-neutral trust manifest +before machine, user, or project mutation: Claude auto-approvals, OpenCode +wildcard approvals and managed extensions, and Codex registrations while its +sandbox/approval policy remains unchanged. See +[Setup trust manifest](SETUP.md#setup-trust-manifest). Official extension references: [Claude hooks](https://code.claude.com/docs/en/hooks), [Claude MCP](https://code.claude.com/docs/en/mcp), diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index dadb334..088e6c8 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -225,7 +225,10 @@ against Node and npm. They are not the supported machine-management contract. The RuvNet Brain is user-level and shared by every project for that user. Ruflo and AQE project memory remains repository-local. Installing the package globally does -not copy project memory into the npm prefix. +not copy project memory into the npm prefix. Dashboard runtime discovery is +user-scoped rather than project-scoped, so supported host controllers running as +the same UID in other repositories may appear and are grouped by their observed +workspace when that identity can be established. ## Multi-user and CI guidance @@ -234,6 +237,11 @@ not copy project memory into the npm prefix. - Prefer one user-writable npm prefix per OS account. - Each user runs `ak setup` under their own account so guidance, credentials, MCP registration, Brain data, and `kit.json` do not land under another user's home. +- Runtime process discovery is scoped to the numeric UID running `ak dashboard`. + Separate OS accounts are outside the normal survey; people sharing one login + also share one UID and are therefore inside the same discovery boundary. This + is least-privilege selection, not an OS sandbox. Never run the dashboard with + `sudo`: it would survey root-owned sessions instead of the invoking user's. - Do not assume one user's global install is available to another user. - Coordinate `ak sync` when several live sessions share the same prefix, because package replacement and daemon stops are prefix/machine-wide for those sessions. @@ -246,6 +254,16 @@ not copy project memory into the npm prefix. - Cache npm and the Brain only when the cache's size and trust model are acceptable. - Avoid `ak sync` self-update in a lockfile-controlled job; use `--no-upgrade`. - Never persist provider credentials in the repository or image layer. +- HOME, XDG, and npm-prefix isolation protects files but does not isolate the + process table. Prefer a private PID namespace. A container using the host PID + namespace can observe same-numeric-UID processes that the container permits it + to inspect. + +The current-UID rule is independent of how `ak` was acquired: local dependency, +global prefix, `npm exec`, tarball, Git checkout, and direct Node execution all +use the UID of the process running the dashboard. A service sees only the +service account's sessions. Windows does not currently provide runtime process +discovery; retained transcript/history sources remain available there. ### Repository onboarding diff --git a/docs/MANAGED-TOOLS.md b/docs/MANAGED-TOOLS.md index e5f5e9c..e92be4c 100644 --- a/docs/MANAGED-TOOLS.md +++ b/docs/MANAGED-TOOLS.md @@ -1,7 +1,7 @@ # Managed tools — the consistency contract Every tool ak manages follows one contract for how it is installed, updated, -version-detected, and displayed. This doc states the contract's four +version-detected, and displayed. This doc states the contract's five invariants, maps every managed tool onto them, and gives the checklist for adding a new tool without breaking them. @@ -14,7 +14,7 @@ design is Proposed in [ADR-0016](adr/0016-capability-driven-integration-adapters Each invariant traces to a live failure it prevents — the appendix records them. -## The four invariants +## The five invariants 1. **Disk-first installed versions.** The "installed" side of every drift check is read from what is actually on disk — never from a cached claim or @@ -46,6 +46,13 @@ them. (`foldBrainDrift()` / the `selfDrift` fold in `src/lib/dashboard-server.mjs`). +5. **Exit status outranks artifact presence.** Every managed operation reports + both an outcome (`ok`, `degraded`, `failed`, or `skipped`) and whether a + usable artifact remains. A failed repair can therefore say that an older + install is still usable, but it cannot render green or advance a release + stamp. A fallback is `degraded`, never an implied native repair. Version + stamps advance only after the installer exits successfully. + ## The tools | Tool | Install / update spec | Update owner | Installed version read from | Drift compared against | status / statusline / dashboard | diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index f99db9b..7c9a6d0 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -291,8 +291,18 @@ Agentic-QE cannot currently configure OpenCode-routed court models. The dashboar seat from an OpenCode process heartbeat. The default dashboard automatically discovers Claude and Codex transcript files, observes -supported controller processes, and reads the Codex state ledger. It does **not** search arbitrary ruflo, -agentic-qe, plugin, or skill stores. Register a structured source +supported controller processes, and reads the Codex state ledger. On macOS and Linux, +process discovery is selected by the real numeric UID running the dashboard. It first reads +only PID/parent/start/command columns, then requests full argv only for Node or known +host-controller candidates from that selection. Separate OS accounts are outside the +intended survey; people sharing one login, a service running under that account, and a +container sharing the host PID namespace remain inside the same numeric-UID boundary. +Do not run the dashboard with `sudo`. Windows runtime process discovery is unsupported, +and missing/restricted `ps`, `lsof`, or `/proc` degrades runtime presence without removing +retained transcript/history evidence. The argv lookup is a second process-table query; a PID +could theoretically be reused between selection and lookup, so current-UID selection is a +least-privilege reduction rather than a hard isolation boundary. It does **not** search arbitrary +ruflo, agentic-qe, plugin, or skill stores. Register a structured source explicitly with repeatable `--live-source 'surface=path'`, where `surface` is exactly `ruflo` or `aqe`. diff --git a/docs/SETUP.md b/docs/SETUP.md index dcff6ee..10b8f25 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -77,6 +77,60 @@ Project setup also reapplies enabled host/provider wiring and seeds the default per-activity routing policy in dual-host mode. With Codex enabled, it repairs both directions of the Claude/Codex–Ruflo bridge. +## Setup trust manifest + +Before making any machine, user, or project change, setup derives one manifest +from the enabled host adapters and prints every applicable approval, registration, +and host-integration change. Interactive setup asks for one confirmation after +this preflight. `--yes` suppresses the question but still prints the manifest, so +automation retains an auditable record; `--dry-run` is the non-mutating way to +inspect the effective manifest for the current flags. A non-interactive setup +with applicable trust changes must pass `--yes`; otherwise it prints the +manifest and exits before mutation. + +The host registry requires every present or future host adapter to declare an +approval posture and its setup-time trust changes. A future host cannot pass +registry validation without that declaration, and setup consumes it without a +host-specific disclosure branch. Enabling a host later with `ak host pick` +uses the same registry-derived preflight for changes that command applies; +already-enabled hosts are not prompted again. + +### Claude Code project auto-approvals + +Project setup discloses the exact Claude Code rules that Ruflo and agentic-qe may +ensure or retain in `.claude/settings.json`: + +| Owner | Auto-approved rule | Effect | +| --- | --- | --- | +| Ruflo | `Bash(npx @claude-flow*)` | Run scoped `@claude-flow` npx commands | +| Ruflo | `Bash(npx claude-flow*)` | Run scoped `claude-flow` npx commands | +| Ruflo | `Bash(node .claude/*)` | Run repository-local `.claude` Node helpers | +| Ruflo | `mcp__claude-flow__*` | Call the project Ruflo MCP tool family | +| agentic-qe | `Bash(npx agentic-qe:*)` | Run scoped agentic-qe npx commands | +| agentic-qe | `Bash(npx @anthropics/agentic-qe:*)` | Run scoped `@anthropics/agentic-qe` npx commands | +| agentic-qe | `mcp__agentic-qe__*` | Call the project agentic-qe MCP tool family | + +The last three rules are omitted with `--no-aqe`. Rules that existed before +setup remain user-owned. After each upstream initializer, agentic-kit compares +the resulting allow-list with the manifest: any newly added, undisclosed rule +is removed and setup fails instead of silently expanding project trust. + +### Codex and OpenCode + +Codex does not need an agentic-kit auto-approve list. The manifest instead names +the project Claude-to-Codex MCP bridge, the user-scope Codex-to-Ruflo MCP +registration, and the AQE Codex integration that project setup will create. +Agentic-kit does not alter Codex's sandbox or approval policy. + +OpenCode's user-scope manifest names all four wildcard tool approvals, the +Ruflo and optional Brain MCP registrations, the lifecycle plugin, and the +managed agent/skill/guidance projection. These are workspace-trust grants, not +an agentic-kit sandbox. + +The separately offered Claude user-scope Ruflo MCP registration retains its own +dedicated prompt because it is optional rather than a deterministic setup +change. `--yes` accepts that prompt as documented. + ## Existing projects For an existing project: @@ -113,9 +167,16 @@ For an empty or newly created directory: as disabled in agentic-kit's machine configuration. - `--codex`, `--opencode`, and `--primary-host` enable and wire the selected hosts before the project phase. -- `--yes` accepts prompts; it does not change the project mutation contract. +- `--yes` accepts prompts; it does not hide the setup trust manifest or change + the project mutation contract. - `--dry-run` prints a high-level plan and changes nothing. +The clean-machine regression runs from the packed release artifact on GitHub's +`macos-latest` runner with disposable `HOME`, XDG directories, npm global +prefix/cache, Brain KB, and project directory. Local regression coverage uses +the same isolation model in a child process. Neither test runs setup against +the developer's existing home or global npm prefix. + See [Upgrading](UPGRADING.md) for the `setup` versus `sync` lifecycle and [Troubleshooting](TROUBLESHOOTING.md) for setup and health-check failures. See [Host support](HOST-SUPPORT.md) before enabling Claude, Codex, or OpenCode. diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index 0ae87c6..04340a5 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -39,9 +39,9 @@ rewritten; rule 3 of the module header, `usage-index.mjs:22-29`): | Claude Code | `~/.claude/projects//.jsonl` | `listClaude` (`usage-index.mjs:684`) — exactly one level of project directories | | Codex CLI | `~/.codex/sessions///
/rollout--.jsonl` | `listCodex` (`usage-index.mjs:705`) — the `yyyy/mm/dd` tree walk | -Roots come from `defaultRoots()` (`usage-index.mjs:676`) and are injectable +Roots come from `defaultRoots()` (`usage-index.mjs:697-701`) and are injectable for tests. A malformed line is skipped, never fatal (`jsonLines`, -`usage-index.mjs:315` — one corrupt line must not cost a whole file). +`usage-index.mjs:328-334` — one corrupt line must not cost a whole file). Host evidence is not inference-provider proof. A Claude transcript may describe Anthropic-, OpenRouter-, or Ollama-served inference. ADR-0016 defines separate @@ -77,7 +77,7 @@ Codex rollout lines carry `type` + `payload`. The parser (`parseCodex`, |---|---| | `session_meta` | Authoritative session id, `cwd`, and `thread_source` (`usage-index.mjs:539-544`) — `"subagent"` marks a thread_spawn replay whose tokens are excluded from aggregation (`usage-index.mjs:609`; `USAGE-SCORECARD-METRICS.md` Appendix A, Bug B) | | `turn_context` | The model id in effect from this point on (`usage-index.mjs:545`) | -| `event_msg` → `token_count` | A **cumulative** usage snapshot; only the last one is kept (`usage-index.mjs:552`) | +| `event_msg` → `token_count` | A **cumulative** usage snapshot; only the last one is kept (`usage-index.mjs:609-611`) | | `event_msg` → `user_message` | A real human prompt — Codex does not route tool output through this event (`usage-index.mjs:584-592`) | | `event_msg` → `agent_message` | A model response (`usage-index.mjs:594-605`) | @@ -182,8 +182,8 @@ transcript content leaves the module, and every step is a gate: 1. **Id grammar before any filesystem access** — `VALID_ID` (`/^[A-Za-z0-9._-]{1,128}$/`, `usage-index.mjs:83`) rejects traversal - shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1260`). -2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1267`), + shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1303-1307`). +2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1313`), consulting the scan cache when present but never requiring it — `readSession` works with no prior `buildIndex`. 3. **Realpath containment** (`usage-index.mjs:1335-1349`) — the resolved file @@ -198,8 +198,8 @@ transcript content leaves the module, and every step is a gate: ### 4.2 Parse and price The file is parsed with `withTurns: true` by the provider's parser -(`usage-index.mjs:1298-1303`), and `meta` is assembled -(`usage-index.mjs:1305-1330`) with the same fields the Sessions view rows +(`usage-index.mjs:1404-1411`), and `meta` is assembled +(`usage-index.mjs:1414-1442`) with the same fields the Sessions view rows carry — `prompts`, `responses`, `exceptions`, `sidechain`, `threadSource`, `models`, `tools`, `skill`/`plugin`, worktree — plus a `cost` priced from the same per-model usage rows `aggregate()` uses (the header used to render a @@ -211,7 +211,7 @@ Every turn body is passed through `maskSecrets` (`usage-index.mjs:196` — the 23 secret shapes) **server-side, before serialization**, then length-capped at `MAX_TURN_CHARS` (40,000, `usage-index.mjs:77`) with the marker appended -(`usage-index.mjs:1404-1414`). Two invariants: +(`usage-index.mjs:1451-1461`). Two invariants: - **Presence is the signal.** `truncated`/`originalChars` are emitted only when the slice fired, so a complete turn cannot be misread as abridged. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index e534acb..ac85537 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -27,6 +27,7 @@ ak sync # apply it | `status` shows `aidefence missing` | ruflo ≥3.28 stopped shipping `@claude-flow/aidefence` but `ruflo security defend` still imports it — injection defense is silently non-functional ([ruvnet/ruflo#2670](https://github.com/ruvnet/ruflo/issues/2670)) | `ak sync` reinstalls it; `ak x verify security` proves defend works (exit 1=threat / 0=clean) | | `status` shows oversized RVF store(s) | A runaway append after a hard exit grew a `.rvf` past the 2 GB cap (seen at ~277 GB once) | `ak sync` quarantines the oversized store; agentic-qe rebuilds it | | Statusline footer (🧠/🛡/🎓 lines) disappeared | `@claude-flow/cli`'s version-stamped helper auto-refresh pristine-copies `statusline.cjs` on the **first ruflo command after an upgrade** — including the statusline render itself | `ak sync` — it now triggers that refresh *first*, then re-injects, so the footer survives; `ak status` flags an armed wipe before it fires | +| Statusline footer is blank or stale with no visible error | Footer probes are intentionally silent during normal rendering | Set `AK_STATUSLINE_DEBUG=1` for one reproduction. Redacted stage/error metadata goes to `$XDG_STATE_HOME/agentic-kit/statusline-debug.log` (default `~/.local/state/agentic-kit/statusline-debug.log`, mode 0600, bounded at 64 KiB); set `AK_STATUSLINE_DEBUG_FILE` to redirect it, then unset debug | | Codex's native status line did not change | Codex reads the user-wide setting when a session starts; an existing TUI may not hot-reload it | Exit and start a new Codex session; inspect ownership with `ak x statusline status` and drift with `ak status` | | The right side of Codex's status line is missing | Codex has one width-constrained native line | Widen the terminal or choose the compact preset with `ak x statusline codex native` | | Want the rich Ruflo/SONA/AQE display inside Codex | Codex currently accepts built-in status-line fields only, not a command-backed renderer | Keep the rich footer in Claude Code; see [Managed Codex status line](CODEX-STATUSLINE.md) for the current boundary | @@ -43,6 +44,9 @@ ak sync # apply it | Suspicious token burn | Background automation vs interactive usage | ask Claude to run the **ruflo-token-audit** skill (deployed by `setup`) | | Observability is empty or has no ruflo/AQE nodes | Live mode tails Claude/Codex records by default, while ruflo/AQE stores are not auto-discovered | open Observability before producing activity; switch to History for retained sessions; register a trusted JSONL file with repeatable `--live-source 'surface=path'`; see [Observability](OBSERVABILITY.md) | | `status` shows `ruvnet-brain … not installed` | The RuvNet Brain (offline KB + `search_ruvnet` MCP) isn't on disk | `ak sync` (or `ak setup`) runs the installer; `npx ruvnet-brain --doctor` health-checks it | +| A heal says `degraded` while the tool is still usable | The native repair failed and a fallback or older artifact remains available; exit status is authoritative | Use the reported repair command/error. The operation will not render green or advance a version stamp until a later repair exits successfully | +| Usage/observability suddenly shows no OpenCode or Codex-ledger data | The SQLite source can be absent, busy, corrupt, or query-incompatible; these are no longer collapsed into an ordinary empty result | Inspect the local-source chips at the top of the dashboard Usage area (or `sourceHealth` in usage-index JSON). A degraded OpenCode scan retains in-window last-good cached sessions; repair the named source before treating zero as observed truth | +| Observability does not show a live host process | Runtime discovery uses the numeric UID running the dashboard and is macOS/Linux-only; `sudo`, a service account, Windows, a private container PID namespace, missing `ps`/`lsof`, or restricted `/proc` changes what is visible | Run `ak dashboard` as the same ordinary OS account as the host CLI. Do not use `sudo`; use retained History on Windows and inspect OS/container process permissions when runtime presence is degraded | | Don't want the RuvNet Brain (the ~2 GB KB download) | It's on by default | `ak setup --no-ruvnet-brain`, or set `ruvnetBrain: false` in `~/.config/agentic-kit/kit.json` | | Don't want the security surface managed | Also on by default | `ak setup --no-security` (persists `security:false`; status shows an info row and sync stops healing it) | | RuvNet Brain KB lives somewhere non-default | The installer + ak honor `$RUVNET_BRAIN_KB` (default `~/.cache/ruvnet-brain/kb`) | export `RUVNET_BRAIN_KB` so detection points at your KB | diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index dc4ff5c..f747b84 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -81,12 +81,13 @@ open in other terminals, here is what can actually reach them, worst first: moment fails once, then npx re-fetches. What does **not** break, by design: running binaries keep executing their old code -(replaced files don't affect a running process's open inodes), and every JSON/TOML config -writer is atomic and backup-first. Settings env keys, `~/.codex/config.toml` edits (MCP -bridge, `[tui]` status line), OpenCode wiring, `.agentic-qe/llm-config.json`, and the -managed guidance blocks are all **read at session start** — a live session simply doesn't -see them until its next launch. The kit's own self-update runs last and applies from the -next `ak` invocation. +(replaced files don't affect a running process's open inodes). Agentic-kit's managed +settings and guidance writers are atomic and fail closed when the one-time backup cannot +be created or validated. Settings env keys, `~/.codex/config.toml` edits (MCP bridge, +`[tui]` status line), OpenCode wiring, `.agentic-qe/llm-config.json`, and the managed +guidance blocks are all **read at session start** — a live session simply doesn't see +them until its next launch. The kit's own self-update runs last and applies from the next +`ak` invocation. > [!TIP] > If other sessions are mid-task: `ak sync --dry-run` first. No `versions` row → the plan diff --git a/docs/USAGE-SCORECARD-METRICS.md b/docs/USAGE-SCORECARD-METRICS.md index 873fd32..5dbe3e9 100644 --- a/docs/USAGE-SCORECARD-METRICS.md +++ b/docs/USAGE-SCORECARD-METRICS.md @@ -72,6 +72,15 @@ Nothing in this transcript pipeline calls a provider API or a billing endpoint; metric is ever a copy of an actual invoice.** That is the whole reason every transcript-derived dollar figure is labelled "API-equivalent." +The built index also exposes `sourceHealth` for the OpenCode SQLite store and +Codex thread ledger. Each source is `ok`, `absent`, `degraded`, or `not-read`, +with a bounded reason such as `busy`, `corrupt`, `query`, or `schema`. A +degraded OpenCode read retains in-window last-good cached sessions rather than +turning an unreadable database into an observed zero. Source health is +diagnostic evidence; it is not added to token or cost totals. The dashboard +renders these states as local-source chips above every Usage view so a degraded, +absent, or deliberately unread source cannot be mistaken for healthy empty data. + The current persisted field named `provider` identifies which host transcript parser produced a session row; it is not sufficient evidence of the inference provider. The Proposed model in [ADR-0016](adr/0016-capability-driven-integration-adapters.md) separates host, provider, @@ -109,8 +118,8 @@ responses = Σ over included sessions of session.responses last activity falls outside the requested window is dropped too (`usage-index.mjs:905`). - `responses` accumulation: Claude increments per assistant message - (`usage-index.mjs:493`); Codex increments per `agent_message` event - (`usage-index.mjs:649`). + (`usage-index.mjs:504-509`); Codex increments per `agent_message` event + (`usage-index.mjs:651-655`). - Totals: `totals.responses += s.responses` per included session (`usage-index.mjs:884`). - Render: `kpi("sessions", fmtNum(t.sessions), fmtNum(t.responses)+" assistant @@ -288,7 +297,7 @@ per row is **gross input minus cached input** — Claude's parser reads `cache_read_input_tokens` and `cache_creation_input_tokens` as separate fields the provider already reports separately (`usage-index.mjs:523-524`); Codex's parser subtracts `cached_input_tokens` from `input_tokens` explicitly -(`usage-index.mjs:645-655`, `input: Math.max(0, gross - cacheRead)`) because +(`usage-index.mjs:666-675`, `input: Math.max(0, gross - cacheRead)`) because Codex's own `input_tokens` field **includes** cached tokens and would double-count them against the separately-reported `cacheRead` figure if left as-is. This is asserted by test: @@ -505,8 +514,8 @@ punchcard[dow + "-" + hour] += 1 per assistant/agent_message response, at its ``` **Source:** incremented once per Claude assistant turn -(`usage-index.mjs:493-496`, keyed by `punchKey(at)`) and once per Codex -`agent_message` (`usage-index.mjs:649-652`), merged into the window-level +(`usage-index.mjs:504-509`, keyed by `punchKey(at)`) and once per Codex +`agent_message` (`usage-index.mjs:651-655`), merged into the window-level `punchcard` object per session (`usage-index.mjs:981`). Cell intensity is linear against the single busiest cell in the window: `v = pcMax ? n/pcMax : 0` (`dashboard/client.mjs`) — this is a @@ -578,7 +587,7 @@ time, someone was genuinely waiting on it — but it is never pushed into `rec.models` and `addUsage()` is never called for it, so it can no longer create a `byModel` row of any kind. It increments a separate `rec.exceptions` counter instead (`usage-index.mjs:470`), rolled up into -`totals.exceptions` (`usage-index.mjs:873-884`) and surfaced per-session +`totals.exceptions` (`usage-index.mjs:995-999`) and surfaced per-session (`usage-index.mjs:920-934`, alongside the existing `sidechain`/`threadSource` flags — inspectable in the Sessions tab, never hidden). When `totals.exceptions > 0`, the panel header shows a small `"· N @@ -888,7 +897,7 @@ Codex ≥0.140 maintains its own SQLite thread ledger (`~/.codex/state_N.sqlite` — the `N` is a migration generation, so `codexStateDb` (`codex-state.mjs:30`) globs and takes the newest). `readCodexState` (`:49`) reads per-thread `thread_source` (`user` vs `subagent`) plus `thread_spawn_edges`, and -`applyCodexLedger` (`usage-index.mjs:1218`) overlays that onto parsed +`applyCodexLedger` (`usage-index.mjs:1264-1275`) overlays that onto parsed sessions: a ledger-identified subagent has its token usage stripped — its rollout replays the parent's entire token history (ccusage/ccusage#950 measured up to 91× inflation) — while the session record stays visible. The diff --git a/docs/adr/0008-guidance-target-scope-split.md b/docs/adr/0008-guidance-target-scope-split.md index b37aeff..bce9f63 100644 --- a/docs/adr/0008-guidance-target-scope-split.md +++ b/docs/adr/0008-guidance-target-scope-split.md @@ -1,7 +1,10 @@ # ADR-0008 — Machine-scoped guidance blocks land in machine files, not a repo's AGENTS.md -- **Status:** Accepted +- **Status:** Implemented - **Date:** 2026-07-24 +- **Updated:** 2026-08-04 +- **Update note:** ADR-0023 made the promised guidance backup fail-closed and the replacement atomic; + an unusable `.bak` now aborts before the machine guidance file changes. - **Deciders:** agentic-kit maintainers ## Context @@ -65,10 +68,11 @@ mechanism that removes the leaked block (from `371da30`) on the next `ak sync` i ### 4. Backup parity and no spurious writes -`~/.codex/AGENTS.md` gets the same one-time `.bak`-before-first-rewrite contract as +`~/.codex/AGENTS.md` gets the same fail-closed one-time `.bak`-before-first-rewrite contract as `~/.claude/CLAUDE.md`. `syncBlocks` writes only when content actually changes, so a single-host codex machine (dual block not wanted, nothing present) yields **no file and no -backup** — the target is discovered but nothing is written. +backup** — the target is discovered but nothing is written. Replacements use a same-directory +temporary file plus atomic rename; a missing backup must be created successfully before replacement. ## Consequences diff --git a/docs/adr/0009-usage-scorecard-local-transcript-analytics.md b/docs/adr/0009-usage-scorecard-local-transcript-analytics.md index 05e050d..4ba12a7 100644 --- a/docs/adr/0009-usage-scorecard-local-transcript-analytics.md +++ b/docs/adr/0009-usage-scorecard-local-transcript-analytics.md @@ -5,7 +5,10 @@ - **Updated:** 2026-08-04 - **Update note:** Added the explicit OpenRouter account-analytics cache boundary for issue #59, aligned Usage with the dashboard's shared three-area navigation, and documented independent - host, inference-provider, provenance, and model facts in session rows. + host, inference-provider, provenance, and model facts in session rows. ADR-0023 subsequently + classified SQLite source failures and made transient OpenCode failures preserve last-good records + with explicit degraded source health instead of becoming observed zero usage; the Usage UI now + renders each local source state rather than leaving that evidence API-only. - **Deciders:** agentic-kit maintainers ## Context diff --git a/docs/adr/0012-observability.md b/docs/adr/0012-observability.md index 94b90d8..d3e4d32 100644 --- a/docs/adr/0012-observability.md +++ b/docs/adr/0012-observability.md @@ -27,6 +27,8 @@ stored locally and a compact, keyboard-accessible restore rail remains visible across responsive layouts. Historical and active-session Review playback exposes 0.5× through 10× speeds. + ADR-0023 restricts runtime discovery to the current UID and reads argv only for selected current-user + host/Node candidates, before the existing path-redacted event boundary. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0005](0005-dashboard-in-page-routing-reveal.md), [ADR-0007](0007-maintainer-admin-local-telemetry.md), diff --git a/docs/adr/0014-dashboard-auth-and-remediation.md b/docs/adr/0014-dashboard-auth-and-remediation.md index ce05a13..e7018b5 100644 --- a/docs/adr/0014-dashboard-auth-and-remediation.md +++ b/docs/adr/0014-dashboard-auth-and-remediation.md @@ -1,7 +1,10 @@ # ADR-0014 — Dashboard auth token, plus a security/quality remediation pass -- **Status:** Accepted +- **Status:** Implemented - **Date:** 2026-07-28 +- **Updated:** 2026-08-04 +- **Update note:** ADR-0023 completed the settings-writer contract: a promised `.bak` is now + fail-closed, validated as a regular non-symlink file, and required before atomic replacement. - **Deciders:** agentic-kit maintainers ## Context diff --git a/docs/adr/0016-capability-driven-integration-adapters.md b/docs/adr/0016-capability-driven-integration-adapters.md index 5b4e35d..8459156 100644 --- a/docs/adr/0016-capability-driven-integration-adapters.md +++ b/docs/adr/0016-capability-driven-integration-adapters.md @@ -3,11 +3,12 @@ - **Status:** Accepted; compatibility clauses superseded by [ADR-0020](0020-ga-stable-surfaces.md) - **Date:** 2026-07-28 -- **Updated:** 2026-07-30 +- **Updated:** 2026-08-04 - **Update note:** Added read-only Codex plugin-hook compatibility facts, runtime-selected Ruflo project-memory store proofs, and the non-correlatable OpenRouter account-analytics boundary; removed the pre-GA compatibility command, - persisted fields, and adapter bootstrap. + persisted fields, and adapter bootstrap. ADR-0023 now requires each host adapter + to declare its setup trust posture and changes for host-neutral preflight. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0003](0003-auto-seed-dual-host-provenance.md), @@ -90,6 +91,7 @@ The conceptual descriptors are: mcp, guidance, statusline, transcripts, usage }, auth: { kind, keyEnv, loginProbe }, + trust: { approvalPolicy, changes: [] }, projections: [], observability: [], defaultProvider @@ -115,7 +117,9 @@ The conceptual descriptors are: Projection and observability descriptors contain metadata plus references to built-in lifecycle or normalizer functions. Registry validation rejects duplicate IDs, unknown capabilities or enum values, unresolved projection/observability references, invalid billing/credential combinations, -and invalid capability implications. In particular, `primary` and `activityRouting` require +and invalid capability implications. A host must explicitly declare whether agentic-kit manages +approval grants or leaves host policy unchanged, plus every setup-time approval, registration, +lifecycle extension, and host integration it can apply. In particular, `primary` and `activityRouting` require `driveSession`; a provider with `pricing: 'zero'` must be local; and a required credential is described by environment-variable name, never by its value. @@ -446,7 +450,9 @@ not write real home/global configuration. ## Consequences New built-in hosts and providers become additive registry entries plus their own adapters and -contract tests. Commands and UI use a common vocabulary and cannot accidentally expose a provider +contract tests. A new host cannot validate without a setup trust declaration, and setup/host-pick +preflight derives its disclosure from that registry rather than adding a host-specific branch. +Commands and UI use a common vocabulary and cannot accidentally expose a provider as a routing host. One provider can be represented behind several hosts, while ownership and teardown remain binding-specific. diff --git a/docs/adr/0017-opencode-host.md b/docs/adr/0017-opencode-host.md index a3daa01..647d6a8 100644 --- a/docs/adr/0017-opencode-host.md +++ b/docs/adr/0017-opencode-host.md @@ -6,7 +6,10 @@ - **Updated:** 2026-08-04 - **Update note:** Clarified that the AQE boundary applies to inference-provider routing, not AQE's upstream OpenCode platform assets, and recorded the implemented OpenCode transcript, - token, observed-cost, and provider-id analytics path. + token, observed-cost, and provider-id analytics path. ADR-0023 adds classified SQLite source + health, preserves last-good OpenCode usage when a present store is temporarily unreadable, + and requires pre-mutation disclosure of OpenCode's wildcard approvals, MCP registrations, + lifecycle plugin, and managed host assets. - **Deciders:** agentic-kit maintainers > **GA amendment:** OpenCode remains opt-in, non-primary, and outside AQE inference-provider diff --git a/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md new file mode 100644 index 0000000..6189def --- /dev/null +++ b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md @@ -0,0 +1,137 @@ +# ADR-0023 — Fail-closed mutations and explicit degraded operation evidence + +- **Status:** Implemented +- **Date:** 2026-08-04 +- **Updated:** 2026-08-04 +- **Update note:** Generalized setup preflight into a required host-adapter trust contract, added + Codex registration/OpenCode approval disclosure, documented current-UID installation-mode + boundaries, and surfaced usage-source health in the dashboard UI. +- **Deciders:** agentic-kit maintainers +- **Related:** [issue #111](https://github.com/pacphi/agentic-kit/issues/111), + [ADR-0008](0008-guidance-target-scope-split.md), + [ADR-0009](0009-usage-scorecard-local-transcript-analytics.md), + [ADR-0012](0012-observability.md), + [ADR-0014](0014-dashboard-auth-and-remediation.md), + [ADR-0016](0016-capability-driven-integration-adapters.md), and + [ADR-0017](0017-opencode-host.md) + +## Context + +Independent clean-Mac testing found seven cases where agentic-kit preserved runtime continuity at +the cost of operator truth or least privilege: + +1. failed RuvNet Brain and optional AQE solver installs could render as successful; +2. the SQLite helper collapsed absence, locks, corruption, bad SQL, and I/O into one fallback; +3. JSON settings backup failures were ignored before replacement; +4. managed-guidance backup failures were ignored before a non-atomic rewrite; +5. the status-line footer intentionally failed to blank but exposed no opt-in diagnostics; +6. runtime discovery read every user's process list before filtering host controllers; and +7. project setup did not disclose the seven upstream Claude Code auto-approve rules before mutation. + +The common defect was not fallback itself. Fallback is often the correct availability policy. The +defect was losing the evidence needed to distinguish healthy, degraded, absent, and failed states, +or mutating user state after a promised safety prerequisite failed. + +## Decision + +### 1. Managed operations carry explicit outcome status + +Managed heals use `ok`, `degraded`, `failed`, or `skipped` status, independently of whether an old +artifact or fallback remains usable. `usable` records that secondary fact. A nonzero Brain installer +exit is failed even when an older KB marker remains; only exit zero records the installed release. +The AQE TypeScript solver fallback is usable but degraded. Setup and sync share one renderer, so a +degraded result is never shown with a green success glyph. + +### 2. SQLite failures remain classified through the helper boundary + +`withDb` returns a discriminated result instead of a caller-supplied fallback: + +```text +{ ok: true, value } +{ ok: false, error: { kind, stage, errcode, code, message } } +``` + +`kind` is `absent`, `busy`, `corrupt`, `query`, `io`, or `close`; `stage` is `open`, `query`, or +`close`. Compatibility helpers may still return a scalar fallback, but they must do so after the +failure has been classified. OpenCode usage preserves current-window last-good records on transient +source failure and reports `sourceHealth.opencode` as degraded; valid empty data remains distinct. +Codex ledger reads retain their JSONL fallback and expose ledger source health. Project-memory status +retains unreadable stores with a reason instead of silently selecting a different store. + +### 3. Promised backups are fail-closed and replacements are atomic + +Settings and managed guidance share one writer. Before replacing an existing file, it either proves +that the one-time `.bak` is a regular non-symlink file or creates it exclusively. Any backup error +aborts before mutation. The replacement uses a same-directory temporary file plus rename, preserves +the original mode, and removes failed temporaries. A recovery promise is part of the write contract, +not best effort. + +### 4. Status-line diagnostics are explicit, bounded, and redacted + +Fail-to-blank remains the default. `AK_STATUSLINE_DEBUG=1` writes stage-level diagnostics to +`$XDG_STATE_HOME/agentic-kit/statusline-debug.log`, falling back to +`~/.local/state/agentic-kit/statusline-debug.log`; `AK_STATUSLINE_DEBUG_FILE` is a test/operator +override. The log is owner-only, capped by resetting at 64 KiB, and contains only timestamp, stable +stage, error name, and error code. It never records messages, stacks, paths, argv, subprocess output, +payloads, or environment values. Diagnostic failures cannot affect rendering. + +### 5. Runtime process discovery follows least privilege + +macOS and Linux discovery uses the real current UID (`ps -U -x`) rather than `ps -a`. The first +survey omits argv. A second query reads argv only for current-user executables that can be a supported +host controller or Node launcher. CWD lookup then receives only filtered controller PIDs. The public +event boundary remains path-redacted as defined by ADR-0012. + +### 6. Every host setup has a pre-mutation trust boundary + +Each host adapter must declare whether agentic-kit manages approval grants or leaves the host's +approval/sandbox policy unchanged, plus every setup-time approval, MCP registration, lifecycle +extension, and host integration it can apply. Setup derives one manifest from those declarations, +prints the applicable changes before machine, user, or project mutation, and asks for one consent; +`--yes` is explicit batch acceptance and still prints the set. A future host cannot validate without +the declaration and needs no command-specific disclosure branch. `ak host pick` applies the same +preflight when it enables a new host, limited to changes that command can actually perform. + +For Claude project setup, `--no-aqe` reduces the four Ruflo and three Agentic-QE auto-approve rules +to four. Setup snapshots pre-existing user rules and verifies upstream output after Ruflo and AQE +init. Any new rule outside the disclosed manifest is removed and setup fails; pre-existing user +rules survive. OpenCode discloses its user-scope wildcard approvals, MCP registrations, lifecycle +plugin, and managed host assets. Codex discloses MCP/AQE registrations while explicitly retaining +its sandbox and approval policy. + +### 7. Usage source degradation is visible in the dashboard + +The Usage API's `sourceHealth` field is rendered as persistent local-source chips across Usage +views. `ok`, `absent`, `degraded`, and `not-read` remain distinct, and bounded reasons such as +`busy`, `corrupt`, `query`, `schema`, or `sandboxed-roots` are visible without entering raw JSON. + +### 8. Clean-machine proof is isolated at every mutable boundary + +Required tests redirect HOME, USERPROFILE, XDG config/state, APPDATA, npm prefix/cache, Brain KB, and +PATH before loading or launching the CLI. The ordinary matrix runs these tests on Linux, macOS, and +Windows. The scheduled/manual nightly additionally packs the current artifact and performs real +setup on `macos-latest` with all global packages and user/project files under `runner.temp`. + +## Consequences + +- A fallback can keep work available without being mislabeled healthy. +- Temporary SQLite locks and corrupt stores no longer become measured zero usage. +- Backup-path problems stop setup/sync instead of destroying the only recoverable pre-write state. +- Status-line failures stay silent for ordinary users and become diagnosable without logging content. +- The normal macOS/Linux survey selects the dashboard process's numeric UID before reading candidate + argv; shared logins and host-PID containers remain same-UID boundaries, not separate users. +- `ak setup` makes each enabled host's trust changes inspectable before acceptance and detects + undisclosed Claude project grants introduced by upstream initializers. +- Some formerly best-effort writes now fail. This is deliberate: when ak promises a backup, mutation + without one is a correctness failure. + +## References + +- Implementation: `src/lib/{file-write,settings,blocks,sqlite,heal,output}.mjs`, + `src/lib/live/process-sessions.mjs`, `src/lib/{usage-index,usage-opencode,codex-state,trust-manifest}.mjs`, + `src/lib/dashboard/{page,client,styles}.mjs`, + `src/commands/{setup,sync}.mjs`, and `src/templates/statusline-footer.cjs`. +- Tests: `tests/kit/{clean-machine-setup,heal-natives,sqlite,settings-config,blocks, + live-process-sessions,setup-command,trust-manifest,usage-index-opencode}.test.mjs`, + `tests/dashboard.test.cjs`, and `tests/statusline-segments.test.cjs`. +- Clean-machine workflow: `.github/workflows/nightly.yml`. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2fe9c15..79886a8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,6 +31,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0020](0020-ga-stable-surfaces.md) | One stable GA surface per capability | Implemented | | [0021](0021-inference-provider-provenance.md) | Inference-provider provenance for live sessions | Accepted | | [0022](0022-metaharness-as-optional-assurance-companion.md) | MetaHarness as an optional assurance companion | Proposed | +| [0023](0023-fail-closed-operations-and-explicit-degradation.md) | Fail-closed mutations and explicit degraded operation evidence | Implemented | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude @@ -114,3 +115,9 @@ routing, and supervised-execution authority. A future internal integration is li capability-probed, read-only projection unless another decision authorizes mutation. The ADR also requires a versioned, sanitized companion result contract before treating `ak run --json` as an interop API. + +**0023** reconciles seven independent clean-machine findings under one operational-truth contract: +managed fallbacks report degradation, SQLite retains classified failure evidence and last-good +usage, promised backups fail closed before atomic replacement, status-line failures gain redacted +opt-in diagnostics, process discovery is current-user and argv-minimized, setup discloses and +verifies its project auto-approve manifest, and clean-machine tests isolate every mutable path. diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 1a1e98a..7529604 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -22,8 +22,11 @@ import * as adb from '../lib/agentdb.mjs'; import { readJson, writeJsonWithBackup } from '../lib/settings.mjs'; import { withDb } from '../lib/sqlite.mjs'; import { findMemoryEntry } from '../lib/project-memory.mjs'; +import { + setupTrustManifest, trustChangesForHost, trustManifestLines, +} from '../lib/trust-manifest.mjs'; import * as paths from '../lib/paths.mjs'; -import { ok, warn, fail, info, heading, bold, dim } from '../lib/output.mjs'; +import { ok, warn, fail, info, heading, bold, dim, reportOutcome } from '../lib/output.mjs'; export const options = { 'dry-run': { type: 'boolean', default: false }, @@ -68,7 +71,8 @@ Options: codex implies --codex and mirrors the routing defaults so codex drives with claude as the alternate. --reconfigure re-run interactive choices, ignoring saved kit.json - --yes accept all prompts (non-interactive) + --yes accept prompts non-interactively; still prints the host-neutral + setup trust manifest before any machine/user/project changes --dry-run print the plan; change nothing Examples: @@ -79,13 +83,50 @@ Examples: ak setup --project --yes force project setup, no prompts`; const ask = async (q, dflt, yes) => { - if (yes || !process.stdin.isTTY) return dflt; + if (yes) return true; + if (!process.stdin.isTTY) return dflt; const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const a = (await rl.question(`${q} [${dflt ? 'Y/n' : 'y/N'}] `)).trim().toLowerCase(); rl.close(); return a === '' ? dflt : a.startsWith('y'); }; +export const PROJECT_PERMISSION_MANIFEST = Object.freeze( + trustChangesForHost('claude', { kind: 'auto-approve' }).map((entry) => ({ + owner: entry.owner, rule: entry.value, effect: entry.effect, + })), +); + +export function projectPermissionManifest(cfg) { + const claude = setupTrustManifest(cfg, { project: true }) + .find((group) => group.hostId === 'claude'); + return (claude?.changes ?? []).filter((entry) => entry.kind === 'auto-approve') + .map((entry) => ({ owner: entry.owner, rule: entry.value, effect: entry.effect })); +} + +export function discloseSetupTrust(cfg, { project = false } = {}) { + const manifest = setupTrustManifest(cfg, { project }); + if (!manifest.length) return manifest; + info('setup trust manifest (evaluated before any machine, user, or project changes):'); + for (const line of trustManifestLines(manifest)) console.log(` ${line}`); + return manifest; +} + +const allowRules = (file) => { + const allow = readJson(file, {})?.permissions?.allow; + return Array.isArray(allow) ? allow.filter((rule) => typeof rule === 'string') : []; +}; + +export function removeUndisclosedPermissions(file, before, authorized) { + const doc = readJson(file, {}) ?? {}; + const allow = Array.isArray(doc.permissions?.allow) ? doc.permissions.allow : []; + const unexpected = allow.filter((rule) => !before.has(rule) && !authorized.has(rule)); + if (!unexpected.length) return []; + doc.permissions.allow = allow.filter((rule) => !unexpected.includes(rule)); + writeJsonWithBackup(file, doc); + return unexpected; +} + export async function run_machine({ flags, pkgRoot, cfg }) { heading('machine setup'); if (flags['dry-run']) { info('dry-run: would ensure packages (incl. ruvnet-brain), deploy skill (blocks + MCP land in the final pass)'); return true; } @@ -120,7 +161,7 @@ export async function run_machine({ flags, pkgRoot, cfg }) { if (await ask('Install the RuvNet Brain (~2 GB offline KB, powers the search_ruvnet MCP)?', true, flags.yes)) { info('installing ruvnet-brain via npx (downloads the KB — may take a while)…'); const r = await heal.installRuvnetBrain(); - (r.ok ? ok : warn)(`ruvnet-brain: ${r.detail}`); + reportOutcome('ruvnet-brain', r); } else warn('ruvnet-brain skipped — install later with `ak sync` (or `ak setup --no-ruvnet-brain` to stop asking)'); } else ok('ruvnet-brain present (refresh to the latest release with `ak sync`)'); } @@ -128,10 +169,10 @@ export async function run_machine({ flags, pkgRoot, cfg }) { // 2. heal natives + the #2670 aidefence gap up front. The aidefence heal is // the security surface — it honors `--no-security` (cfg.security=false), // which was previously write-only: documented, persisted, read by nothing. - ok(`natives: ${(await heal.healNatives()).detail}`); - if (cfg.security !== false) ok(`aidefence: ${(await heal.healAidefence()).detail}`); + reportOutcome('natives', await heal.healNatives()); + if (cfg.security !== false) reportOutcome('aidefence', await heal.healAidefence()); else info('security surface skipped (kit.json security:false — re-enable by removing the key)'); - if (cfg.aqe) info(`aqe solver: ${(await heal.healAqeSolver()).detail}`); + if (cfg.aqe) reportOutcome('aqe solver', await heal.healAqeSolver()); // 3. token-audit skill → ~/.claude/skills const skillSrc = path.join(pkgRoot, 'claude', 'skills', 'ruflo-token-audit'); @@ -205,15 +246,25 @@ export async function run_machine({ flags, pkgRoot, cfg }) { return true; } -export async function run_project({ flags, cfg }) { +export async function run_project({ flags, cfg, trustDisclosed = false }) { const root = process.cwd(); heading(`project setup — ${root}`); + if (!trustDisclosed) discloseSetupTrust(cfg, { project: true }); if (flags['dry-run']) { info('dry-run: would init, sanitize, pin DB path, activate memory/swarm/daemon, verify'); return true; } + const permissionsFile = paths.projectSettings(root); + const permissionsBefore = new Set(allowRules(permissionsFile)); + const authorizedPermissions = new Set(projectPermissionManifest(cfg).map((entry) => entry.rule)); + // 1. ruflo init (--force regenerates; CLAUDE.md backed up upstream, #2208) const init = await runCmd('ruflo', ['init', '--full', '--force'], { cwd: root, timeout: 300_000 }); (init.code === 0 ? ok : fail)('ruflo init --full'); if (init.code !== 0) return false; + const rufloUnexpected = removeUndisclosedPermissions(permissionsFile, permissionsBefore, authorizedPermissions); + if (rufloUnexpected.length) { + fail(`ruflo init introduced undisclosed auto-approve rules; removed: ${rufloUnexpected.join(', ')}`); + return false; + } // 2. statusline heal is DEFERRED to the end of project setup (see step 10): // fixStatusline is a no-op until ruflo/aqe have finished writing @@ -271,11 +322,13 @@ export async function run_project({ flags, cfg }) { if (landed) { // Bound parameters, not interpolation. Delete only this disposable probe // from the store that actually received it. - withDb(landed.file, (db) => { + const cleanup = withDb(landed.file, (db) => { db.prepare('DELETE FROM memory_entries WHERE namespace = ? AND key = ?').run('_setup', probeKey); db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); - }, null, { readonly: false }); - ok(`memory write VERIFIED (store → ${path.basename(landed.file)} row confirmed)`); + return true; + }, { readonly: false }); + if (cleanup.ok) ok(`memory write VERIFIED (store → ${path.basename(landed.file)} row confirmed)`); + else warn(`memory write verified, but probe cleanup ${cleanup.error.kind} — remove ${probeKey} from _setup manually`); } else { fail('memory write verification FAILED — run: ak status / ruflo doctor -c memory'); } @@ -298,6 +351,11 @@ export async function run_project({ flags, cfg }) { const withCodex = !!cfg.integrations?.hosts?.codex && aqeSupportsAgentOverrides(); const aqe = await runCmd('aqe', ['init', '--auto', ...(withCodex ? ['--with-codex'] : [])], { cwd: root, timeout: 300_000 }); (aqe.code === 0 ? ok : warn)(`agentic-qe initialized${withCodex ? ' (+ codex skills)' : ''}`); + const aqeUnexpected = removeUndisclosedPermissions(permissionsFile, permissionsBefore, authorizedPermissions); + if (aqeUnexpected.length) { + fail(`agentic-qe init introduced undisclosed auto-approve rules; removed: ${aqeUnexpected.join(', ')}`); + return false; + } } // 9.5 frontier host/provider wiring — reapply kit.json prefs (no-op at the @@ -352,22 +410,38 @@ ruflo swarm init --topology hierarchical --max-agents 15 --strategy specialized \`\`\` `; -export async function run({ flags, pkgRoot }) { +export async function run({ flags, pkgRoot, confirm = ask }) { const cfg = loadKitConfig(); if (flags['no-aqe']) cfg.aqe = false; if (flags['no-ruvnet-brain']) cfg.ruvnetBrain = false; if (flags['no-security']) cfg.security = false; + const inProject = flags.project + || (fs.existsSync(path.join(process.cwd(), '.git')) && process.cwd() !== paths.home); + const willConfigureProject = inProject && !flags.minimal; + + // Apply host flags to the in-memory config before preflight so the manifest + // describes this invocation, including a newly requested host. Dry-run never + // persists this object; a declined confirmation returns before saveKitConfig. + const hostFlags = applySetupHostFlags(cfg, flags); + const trustManifest = discloseSetupTrust(cfg, { project: willConfigureProject }); + if (trustManifest.length) { + if (!flags['dry-run'] && !(await confirm( + 'Proceed with setup and these trust changes?', false, flags.yes))) { + info('setup cancelled before machine, user, or project changes'); + return 0; + } + } + // --codex / --primary-host: opt codex in BEFORE run_machine's host-install loop // and run_project's dual wiring, so the existing gated/prompted/external-safe // paths install + wire codex. No-op (claude-only) when neither flag is passed. - // Skipped on --dry-run: saveKitConfig below runs unconditionally, so mutating - // cfg here would persist during a dry-run — "change nothing" must hold. + // Dry-run applies these choices in memory for an accurate plan, but never + // persists the resulting config. if (flags['dry-run']) { if (flags.codex || flags['primary-host']) info('dry-run: --codex/--primary-host would enable + install the codex host and wire dual-mode (no changes made)'); if (flags.opencode) info('dry-run: --opencode would enable the opencode host and wire it (no changes made)'); } else { - const hostFlags = applySetupHostFlags(cfg, flags); for (const w of hostFlags.warnings) warn(w); if (hostFlags.changed) { if (flags.codex || flags['primary-host'] === 'codex') { @@ -380,16 +454,14 @@ export async function run({ flags, pkgRoot }) { } if (!(await run_machine({ flags, pkgRoot, cfg }))) return 1; - // "--dry-run: print the plan; change nothing" — an unconditional save CREATED - // ~/.config/agentic-kit/kit.json on a previewed setup. cfg is never mutated - // under --dry-run (the flag branch above skips applySetupHostFlags), so - // skipping the write is a pure no-op beyond not touching the disk. + // "--dry-run: print the plan; change nothing" — an unconditional save once + // CREATED ~/.config/agentic-kit/kit.json on a previewed setup. The effective + // config may be changed in memory above so the plan is truthful; never write + // that preview state. if (!flags['dry-run']) saveKitConfig(cfg); - const inProject = flags.project - || (fs.existsSync(path.join(process.cwd(), '.git')) && process.cwd() !== paths.home); if (inProject && !flags.minimal) { - if (!(await run_project({ flags, cfg }))) return 1; + if (!(await run_project({ flags, cfg, trustDisclosed: true }))) return 1; } else if (!flags.minimal) { info('not inside a project (no .git here) — run `ak setup` from a repo to set one up'); } diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 95eafcb..4983563 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -20,7 +20,7 @@ import { nativesStatus, securityPresent } from '../lib/natives.mjs'; import { readJson } from '../lib/settings.mjs'; import { appendToConfig } from '../lib/health-history.mjs'; import * as paths from '../lib/paths.mjs'; -import { ok, warn, fail, info, bold, dim, withProgress } from '../lib/output.mjs'; +import { ok, warn, fail, info, bold, dim, withProgress, reportOutcome } from '../lib/output.mjs'; import { applyCodexStatusline, projectionFor } from '../lib/codex-statusline.mjs'; export const options = { @@ -62,7 +62,7 @@ export async function run({ flags, pkgRoot }) { const cfg = loadKitConfig(); const subsystems = new Set(plan.map((p) => p.subsystem)); - const report = (name, r) => (r.ok ? ok(`${name}: ${r.detail}`) : fail(`${name}: ${r.detail}`)); + const report = reportOutcome; // Run a managed heal under a live elapsed-time ticker, then print its result. // Keeps every slow tool (npm upgrades, brain KB download, native rebuild) // visibly alive instead of freezing the prompt; fast/local steps clear in <1s. diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index 0fe12c4..f4454c0 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -19,6 +19,9 @@ import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; import { routableHostIds } from '../../lib/adapters/index.mjs'; +import { + newlyEnabledHostTrustManifest, trustManifestLines, +} from '../../lib/trust-manifest.mjs'; import { have } from '../../lib/exec.mjs'; import { ok, warn, fail, info, dim, bold, yellow } from '../../lib/output.mjs'; import { repoRoot } from '../../lib/paths.mjs'; @@ -103,6 +106,10 @@ Options (pick, all optional — omit for interactive): --activity refresh: which activities to re-seed (default: prompt) --yes accept defaults without prompting +Enabling a host prints its host trust manifest before kit.json or host config +is changed. --yes accepts that manifest non-interactively; without --yes, a +non-interactive invocation stops before mutation. + When both claude and codex hosts are enabled (and aqe ≥ 3.13.1), ak seeds a per-activity routing policy from sensible defaults and materializes it into .agentic-qe/llm-config.json (agentOverrides). Override any activity with --route; @@ -399,6 +406,7 @@ async function maybeWriteQeCourtDefaults({ nonInteractive, cwd, enabled, aqeProv async function pick({ flags, cwd, pkgRoot }) { const cfg = loadKitConfig(); + const trustBaseline = structuredClone(cfg); const hosts = await detectHosts(cwd); // Routing eligibility is capability-derived. OpenCode retains its independent // lifecycle wiring even though it is now an execution host; it is never a @@ -551,6 +559,26 @@ async function pick({ flags, cwd, pkgRoot }) { cfg.routing.routes = prunedRoutes.policy; for (const message of prunedRoutes.warnings) warn(message); + const trustManifest = newlyEnabledHostTrustManifest(trustBaseline, enabled); + if (trustManifest.length) { + info('host trust manifest (evaluated before user or project changes):'); + for (const line of trustManifestLines(trustManifest)) console.log(` ${line}`); + if (!flags.yes) { + if (!process.stdin.isTTY) { + fail('host enablement needs trust confirmation; re-run with --yes after reviewing the manifest'); + return 2; + } + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const answer = (await rl.question('Enable these hosts and apply these trust changes? [y/N] ')) + .trim().toLowerCase(); + rl.close(); + if (!answer.startsWith('y')) { + info('host selection cancelled before user or project changes'); + return 0; + } + } + } + // Codex owns two directional MCP bridges. Disable only the marker-owned // bridges, matching OpenCode's receipt-based teardown semantics. let codexRetired = null; diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs index 70b137d..cd16551 100644 --- a/src/lib/adapters/registries.mjs +++ b/src/lib/adapters/registries.mjs @@ -10,6 +10,12 @@ const HOST_CAPABILITIES = Object.freeze([ const PROVIDER_CAPABILITIES = Object.freeze([ 'modelDiscovery', 'runtimeDiscovery', 'quota', 'pricing', 'cacheAccounting', ]); +const TRUST_CHANGE_KINDS = Object.freeze([ + 'auto-approve', 'mcp-registration', 'lifecycle-extension', 'host-integration', +]); +const TRUST_SCOPES = Object.freeze(['project', 'user']); +const TRUST_FEATURES = Object.freeze(['project', 'aqe', 'brain']); +const TRUST_OPERATIONS = Object.freeze(['setup', 'host-pick', 'sync']); function validateCapabilities(value, names, field) { assertRecord(value, field); @@ -24,6 +30,41 @@ function validateCapabilities(value, names, field) { return value; } +function validateHostTrust(value) { + assertRecord(value, 'host.trust'); + assertEnum(value.approvalPolicy, ['managed', 'unchanged'], 'host.trust.approvalPolicy'); + if (!Array.isArray(value.changes)) throw new TypeError('host.trust.changes must be an array'); + const ids = new Set(); + for (const [index, change] of value.changes.entries()) { + const field = `host.trust.changes[${index}]`; + assertRecord(change, field); + assertId(change.id, `${field}.id`); + if (ids.has(change.id)) throw new TypeError('host.trust.changes contains duplicate ids'); + ids.add(change.id); + assertEnum(change.kind, TRUST_CHANGE_KINDS, `${field}.kind`); + assertEnum(change.scope, TRUST_SCOPES, `${field}.scope`); + for (const name of ['owner', 'value', 'effect']) { + if (typeof change[name] !== 'string' || !change[name]) { + throw new TypeError(`${field}.${name} is required`); + } + } + assertStringArray(change.operations, `${field}.operations`, { allowEmpty: false }); + for (const operation of change.operations) { + assertEnum(operation, TRUST_OPERATIONS, `${field}.operations`); + } + if (change.features !== undefined) { + assertStringArray(change.features, `${field}.features`); + for (const feature of change.features) { + assertEnum(feature, TRUST_FEATURES, `${field}.features`); + } + } + if (change.requiresHostEnabled !== undefined && typeof change.requiresHostEnabled !== 'boolean') { + throw new TypeError(`${field}.requiresHostEnabled must be boolean`); + } + } + return value; +} + export function validateProjectionAdapter(value) { assertRecord(value, 'projection'); assertId(value.id, 'projection.id'); @@ -51,6 +92,7 @@ export function validateHostAdapter(value, { assertEnum(value.install.externalInstallPolicy, ['detect-never-overwrite', 'managed', 'unmanaged'], 'host.install.externalInstallPolicy'); validateCapabilities(value.capabilities, HOST_CAPABILITIES, 'host.capabilities'); + validateHostTrust(value.trust); assertId(value.configProjection, 'host.configProjection'); assertStringArray(value.observability, 'host.observability'); if (projections && !projections[value.configProjection]) { @@ -121,6 +163,18 @@ const hostEntries = [ aqeProvider: 'claude-code', envMarkers: ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_SESSION_ID'], enableEnv: 'ENABLE_CLAUDE_CODE', }, + trust: { + approvalPolicy: 'managed', + changes: [ + { id: 'ruflo-npx-package', kind: 'auto-approve', scope: 'project', owner: 'ruflo', value: 'Bash(npx @claude-flow*)', effect: 'run scoped @claude-flow npx commands', operations: ['setup'], features: ['project'], requiresHostEnabled: false }, + { id: 'ruflo-npx-cli', kind: 'auto-approve', scope: 'project', owner: 'ruflo', value: 'Bash(npx claude-flow*)', effect: 'run scoped claude-flow npx commands', operations: ['setup'], features: ['project'], requiresHostEnabled: false }, + { id: 'ruflo-local-helper', kind: 'auto-approve', scope: 'project', owner: 'ruflo', value: 'Bash(node .claude/*)', effect: 'run repository-local .claude Node helpers', operations: ['setup'], features: ['project'], requiresHostEnabled: false }, + { id: 'ruflo-mcp-family', kind: 'auto-approve', scope: 'project', owner: 'ruflo', value: 'mcp__claude-flow__*', effect: 'call the project Ruflo MCP tool family', operations: ['setup'], features: ['project'], requiresHostEnabled: false }, + { id: 'aqe-npx-cli', kind: 'auto-approve', scope: 'project', owner: 'agentic-qe', value: 'Bash(npx agentic-qe:*)', effect: 'run scoped agentic-qe npx commands', operations: ['setup'], features: ['project', 'aqe'], requiresHostEnabled: false }, + { id: 'aqe-npx-package', kind: 'auto-approve', scope: 'project', owner: 'agentic-qe', value: 'Bash(npx @anthropics/agentic-qe:*)', effect: 'run scoped @anthropics/agentic-qe npx commands', operations: ['setup'], features: ['project', 'aqe'], requiresHostEnabled: false }, + { id: 'aqe-mcp-family', kind: 'auto-approve', scope: 'project', owner: 'agentic-qe', value: 'mcp__agentic-qe__*', effect: 'call the project agentic-qe MCP tool family', operations: ['setup'], features: ['project', 'aqe'], requiresHostEnabled: false }, + ], + }, configProjection: 'claude', observability: ['claude-transcripts', 'claude-statusline'], }, { @@ -134,6 +188,14 @@ const hostEntries = [ aqeProvider: 'codex', envMarkers: ['CODEX_SANDBOX', 'CODEX_HOME', 'CODEX_SESSION_ID'], enableEnv: 'ENABLE_CODEX', }, + trust: { + approvalPolicy: 'unchanged', + changes: [ + { id: 'claude-to-codex-mcp', kind: 'mcp-registration', scope: 'project', owner: 'agentic-kit', value: 'codex mcp-server', effect: 'expose Codex to Claude Code as mcp__codex__codex in this project', operations: ['setup', 'host-pick', 'sync'], features: ['project'] }, + { id: 'codex-to-ruflo-mcp', kind: 'mcp-registration', scope: 'user', owner: 'agentic-kit', value: 'ruflo mcp start', effect: 'register the Ruflo MCP server in Codex configuration', operations: ['setup', 'host-pick', 'sync'], features: ['project'] }, + { id: 'aqe-codex-integration', kind: 'host-integration', scope: 'project', owner: 'agentic-qe', value: 'aqe init --with-codex', effect: 'project Agentic-QE Codex skills and configuration', operations: ['setup'], features: ['project', 'aqe'] }, + ], + }, configProjection: 'codex', observability: ['codex-transcripts', 'codex-app-server'], }, { @@ -145,6 +207,19 @@ const hostEntries = [ guidanceFile: 'agents-opencode', configFormat: 'json', statusline: null, aqeProvider: null, envMarkers: [], }, + trust: { + approvalPolicy: 'managed', + changes: [ + { id: 'ruflo-mcp-dash', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'claude-flow_*', effect: 'allow Ruflo MCP tools using dash-separated names', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'ruflo-mcp-underscore', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'claude_flow_*', effect: 'allow Ruflo MCP tools using underscore-separated names', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'brain-mcp-dash', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'ruvnet-brain_*', effect: 'allow RuvNet Brain MCP tools using dash-separated names', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'brain-mcp-underscore', kind: 'auto-approve', scope: 'user', owner: 'agentic-kit', value: 'ruvnet_brain_*', effect: 'allow RuvNet Brain MCP tools using underscore-separated names', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'ruflo-mcp-registration', kind: 'mcp-registration', scope: 'user', owner: 'agentic-kit', value: 'mcp.claude-flow', effect: 'register the Ruflo MCP server in OpenCode configuration', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'brain-mcp-registration', kind: 'mcp-registration', scope: 'user', owner: 'agentic-kit', value: 'mcp.ruvnet-brain', effect: 'register the RuvNet Brain MCP server in OpenCode configuration', operations: ['setup', 'host-pick', 'sync'], features: ['brain'] }, + { id: 'ruflo-lifecycle-plugin', kind: 'lifecycle-extension', scope: 'user', owner: 'agentic-kit', value: 'plugins/ruflo-hooks.js', effect: 'connect OpenCode lifecycle and tool events to Ruflo hooks', operations: ['setup', 'host-pick', 'sync'] }, + { id: 'ruflo-host-assets', kind: 'host-integration', scope: 'user', owner: 'agentic-kit', value: 'agents, skills, and AGENTS.md', effect: 'install the managed Ruflo agent, skill, and guidance projections for OpenCode', operations: ['setup', 'host-pick', 'sync'] }, + ], + }, configProjection: 'opencode', observability: ['opencode-logs'], }, ]; diff --git a/src/lib/blocks.mjs b/src/lib/blocks.mjs index 3ccdaff..3b993be 100644 --- a/src/lib/blocks.mjs +++ b/src/lib/blocks.mjs @@ -18,6 +18,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { claudeDir, claudeMdPath, codexDir, opencodeDir, home } from './paths.mjs'; import { have } from './exec.mjs'; +import { writeFileWithBackup } from './file-write.mjs'; export const BEGIN = (slug) => ``; export const END = (slug) => ``; @@ -326,8 +327,15 @@ export async function reconcileGuidance({ cwd, cfg, pkgRoot, context = {}, dryRu * Returns [{slug, action: 'upserted'|'stripped'|'unchanged'|'missing-template', present}] — * dryRun skips writes but reports the same actions. `context` is forwarded to * every detector (see detect) so `flag`-gated rows can read caller signals such - * as `{ flags: { dualMode: } }`; omitting it preserves prior behavior. */ -export async function syncBlocks(file, rows, resolveTemplate, { dryRun = false, context = {} } = {}) { + * as `{ flags: { dualMode: } }`; omitting it preserves prior behavior. + * @param {string} file + * @param {Array} rows + * @param {(row: any) => string} resolveTemplate + * @param {{dryRun?: boolean, context?: object, fileWriteOptions?: {fsImpl?: typeof fs}}} [options] + */ +export async function syncBlocks(file, rows, resolveTemplate, { + dryRun = false, context = {}, fileWriteOptions, +} = {}) { const results = []; let content = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''; let changed = false; @@ -354,16 +362,7 @@ export async function syncBlocks(file, rows, resolveTemplate, { dryRun = false, } } if (changed && !dryRun) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - // One-time backup before the first rewrite of the user's CLAUDE.md — - // same contract as settings.mjs writeJsonWithBackup (never overwrite an - // existing .bak): this file is the user's global instructions, and every - // other writer in the kit is backup-first. - const bak = `${file}.bak`; - if (fs.existsSync(file) && !fs.existsSync(bak)) { - try { fs.copyFileSync(file, bak); } catch { /* best-effort */ } - } - fs.writeFileSync(file, content); + writeFileWithBackup(file, content, fileWriteOptions); } return results; } diff --git a/src/lib/codex-state.mjs b/src/lib/codex-state.mjs index cc3936b..88e9172 100644 --- a/src/lib/codex-state.mjs +++ b/src/lib/codex-state.mjs @@ -11,9 +11,9 @@ // are subagents is what keeps the totals honest. // // Deliberate limits: -// - READ-ONLY, always. `withDb` opens readonly and swallows every error — -// a locked or half-migrated db yields null, never a crash and never a lock -// held against the codex CLI itself. +// - READ-ONLY, always. `withDb` opens readonly and classifies every error — +// a locked or half-migrated db yields a structured degraded result, never +// a crash and never a lock held against the codex CLI itself. // - The filename suffix (`state_5`) is a MIGRATION GENERATION, not a stable // name. We glob `state_*.sqlite` and take the highest generation rather // than hardcoding today's. @@ -60,8 +60,15 @@ export function codexStateDb(dir = codexDir()) { * @param {{ dir?: string, file?: string }} [opts] test seams */ export function readCodexState(opts = {}) { + const result = readCodexStateResult(opts); + return result.ok ? result.value : null; +} + +/** Structured variant for callers that must distinguish absence, lock, + * corruption, and schema/query failures. */ +export function readCodexStateResult(opts = {}) { const file = opts.file ?? codexStateDb(opts.dir); - if (!file) return null; + if (!file) return { ok: false, error: { kind: 'absent', stage: 'open', code: 'ENOENT', errcode: null, message: 'Codex state database is absent' } }; return withDb(file, (db) => { const cols = new Set(db.prepare('PRAGMA table_info(threads)').all().map((c) => c.name)); // id + thread_source are what the attribution fix rests on; without them @@ -116,5 +123,5 @@ export function readCodexState(opts = {}) { } } catch { /* the edges table is additive detail — attribution works without it */ } return { threads, parents }; - }, null); + }); } diff --git a/src/lib/dashboard/client.mjs b/src/lib/dashboard/client.mjs index eb4de6a..ca324f3 100644 --- a/src/lib/dashboard/client.mjs +++ b/src/lib/dashboard/client.mjs @@ -560,6 +560,20 @@ export const JS = ` return out; } + function renderSourceHealth(health){ + var el=document.getElementById("u-source-health"); + if(!el)return; + var labels={opencode:"OpenCode",codexLedger:"Codex ledger"},chips=[]; + for(var key in (health||{})){ + var item=health[key]||{},status=String(item.status||"not-read"); + var detail=status+(item.reason?" · "+item.reason:""); + chips.push('' + +esc(labels[key]||key)+": "+esc(detail)+""); + } + el.hidden=chips.length===0; + el.innerHTML=chips.length?'local sources'+chips.join(""):""; + } + function loadUsage(force){ if(usageBusy)return Promise.resolve(); usageBusy=true; @@ -1142,9 +1156,11 @@ export const JS = ` function renderUsage(){ if(!USAGE)return; if(USAGE.error){ + renderSourceHealth(null); document.getElementById("u-hero").innerHTML='
'+esc(USAGE.error)+"
"; return; } + renderSourceHealth(USAGE.sourceHealth); renderScore(USAGE); renderFindings(USAGE); renderSessions(USAGE); diff --git a/src/lib/dashboard/page.mjs b/src/lib/dashboard/page.mjs index 5383502..ab65691 100644 --- a/src/lib/dashboard/page.mjs +++ b/src/lib/dashboard/page.mjs @@ -207,6 +207,7 @@ export function renderPage({ name, version }) {

Usage scorecard

Token consumption, API-equivalent cost, efficiency, and trends.

+
diff --git a/src/lib/dashboard/styles.mjs b/src/lib/dashboard/styles.mjs index 0c2f744..8bbd1ab 100644 --- a/src/lib/dashboard/styles.mjs +++ b/src/lib/dashboard/styles.mjs @@ -407,6 +407,19 @@ body.gated .band,body.gated .tabbar,body.gated main{display:none} } .note b{color:var(--ink)} .note .i{color:var(--accent); font-weight:700} +.source-health{ + display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin:-2px 0 16px; + color:var(--ink-2); font-size:11.5px; +} +.source-health[hidden]{display:none} +.source-health .source-label{font-weight:600; color:var(--ink-dim); margin-right:2px} +.source-chip{ + border:1px solid var(--line); border-radius:999px; padding:4px 9px; + background:var(--panel); color:var(--ink-2); +} +.source-chip[data-status="ok"]{border-color:var(--ok); color:var(--ok)} +.source-chip[data-status="degraded"]{border-color:var(--warn); color:var(--warn); background:color-mix(in srgb,var(--warn) 10%,transparent)} +.source-chip[data-status="absent"],.source-chip[data-status="not-read"]{color:var(--ink-dim)} .sh{display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:14px} .sh h2{font-size:15px; font-weight:600; letter-spacing:-.014em; margin:0} .sh .n{color:var(--ink-dim); font-size:11.5px} diff --git a/src/lib/file-write.mjs b/src/lib/file-write.mjs new file mode 100644 index 0000000..3340dfb --- /dev/null +++ b/src/lib/file-write.mjs @@ -0,0 +1,51 @@ +// Fail-closed, backup-first atomic replacement for user-owned text files. +// A promised recovery copy is part of the write contract: if it cannot be +// created or is not a regular file, the target is left byte-identical. +import fs from 'node:fs'; +import path from 'node:path'; + +let sequence = 0; + +function backupState(file, fsImpl) { + try { + const stat = fsImpl.lstatSync(file); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`refusing unusable backup path: ${file}`); + } + return 'present'; + } catch (error) { + if (error?.code === 'ENOENT') return 'absent'; + throw error; + } +} + +/** + * Replace `file` atomically after preserving its first pre-managed state. + * Existing regular `.bak` files are never overwritten; symlinks and other + * non-regular backup paths fail closed. + */ +export function writeFileWithBackup(file, content, { fsImpl = fs } = {}) { + const dir = path.dirname(file); + fsImpl.mkdirSync(dir, { recursive: true }); + const existed = fsImpl.existsSync(file); + let mode = 0o600; + if (existed) { + mode = fsImpl.statSync(file).mode & 0o777; + const backup = `${file}.bak`; + if (backupState(backup, fsImpl) === 'absent') { + fsImpl.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL); + } + } + + const tmp = `${file}.${process.pid}.${sequence++}.tmp`; + let renamed = false; + try { + fsImpl.writeFileSync(tmp, content, { mode }); + fsImpl.renameSync(tmp, file); + renamed = true; + } finally { + if (!renamed) { + try { fsImpl.rmSync(tmp, { force: true }); } catch { /* cleanup must not mask the write error */ } + } + } +} diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index 929aac2..d75f95c 100644 --- a/src/lib/heal.mjs +++ b/src/lib/heal.mjs @@ -1,5 +1,7 @@ // Heal actions — the mutations `sync` applies. Each returns -// {ok, detail} and is idempotent. Ports of: ruflo-patch-native, +// {ok, status, usable, detail} and is idempotent. `status` is one of +// ok|degraded|failed|skipped; callers must not infer subsystem health from +// mere on-disk presence after a failed operation. Ports of: ruflo-patch-native, // _ruflo_ensure_aidefence, _ruflo_aqe_ensure_native, _ruflo_aqe_ensure_ruvector_native, // the package-upgrade step (with the npm >=11.17 allow-scripts handling verified // on the 2026-07-14 upgrade), and the RVF quarantine. @@ -132,12 +134,18 @@ export async function healAidefence() { } /** Optional native sublinear solver for agentic-qe (best-effort). */ -export async function healAqeSolver() { - if (!fs.existsSync(aqeRoot())) return { ok: true, detail: 'agentic-qe not installed' }; +export async function healAqeSolver({ runner = run } = {}) { + if (!fs.existsSync(aqeRoot())) { + return { ok: true, status: 'skipped', usable: false, detail: 'agentic-qe not installed' }; + } const probe = path.join(aqeRoot(), 'node_modules', '@ruvector', 'solver-node', 'package.json'); - if (fs.existsSync(probe)) return { ok: true, detail: 'already present' }; - const r = await npmInstallInto(aqeRoot(), '@ruvector/solver-node'); - return { ok: true, detail: r.code === 0 ? 'installed' : 'unavailable (TS fallback is fine <50K nodes)' }; + if (fs.existsSync(probe)) return { ok: true, status: 'ok', usable: true, detail: 'already present' }; + const r = await npmInstallInto(aqeRoot(), '@ruvector/solver-node', runner); + if (r.code === 0) return { ok: true, status: 'ok', usable: true, detail: 'installed' }; + return { + ok: true, status: 'degraded', usable: true, + detail: `native solver unavailable; TypeScript fallback active (<50K nodes) (${failTail(r)})`, + }; } /** Quarantine oversized (runaway-append) RVF stores in a project — the one RVF @@ -175,27 +183,36 @@ export async function selfUpdate(version) { * already present — pass force:true to bypass that skip (used when a drift * check saw a newer release). Runs `--no-stack --no-enhance`: ak already * manages ruflo/RuVector and owns the CLAUDE.md grounding block. */ -export async function installRuvnetBrain({ force = false } = {}) { +export async function installRuvnetBrain({ + force = false, runner = run, latestVersion = rbLatest, + present = rbPresent, recordRelease = rbRecord, +} = {}) { // Resolve the release tag FIRST and pin the installer to it (--version v), // so the bundle that lands on disk is exactly the release ak stamps — the old // install-then-stamp order left a window where a release published mid-install // made the stamp disagree with disk. Offline (tag null): the installer's own // latest logic applies and the stamp is best-effort afterwards, as before. - const tag = await rbLatest(); + const tag = await latestVersion(); const args = ['-y', INSTALL_SPEC, ...INSTALL_ARGS, ...(tag ? ['--version', `v${tag}`] : []), ...(force ? ['--force'] : [])]; - const r = await run('npx', args, { timeout: 900_000 }); + const r = await runner('npx', args, { timeout: 900_000 }); if (r.code === 0) { // Stamp the release-tag namespace so drift converges — the plugin's own // semver never tracks the KB release, so we can't use it. - const stamped = tag ?? await rbLatest(); - if (stamped) rbRecord(stamped); - return { ok: true, detail: stamped ? `installed release v${stamped}` : 'installed (release tag unknown)' }; + const stamped = tag ?? await latestVersion(); + if (stamped) recordRelease(stamped); + return { + ok: true, status: 'ok', usable: true, + detail: stamped ? `installed release v${stamped}` : 'installed (release tag unknown)', + }; } - // A non-zero exit can still leave a usable install (post-verify smoke test may - // fail offline); report the tail but reflect actual presence. - return { ok: rbPresent(), detail: (r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200) }; + // Presence after a non-zero exit can be a stale or partial prior install. It + // is useful evidence for `usable`, never proof that this install succeeded. + return { + ok: false, status: 'failed', usable: present(), + detail: (r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200), + }; } /** Disable the brain installer's nightly self-update LaunchAgent (macOS-only — @@ -226,20 +243,25 @@ export async function disableRuvnetBrainNightly({ runner = run } = {}) { * the shared cognitive store never skews on the core version — a core skew is * the corruption risk this heal exists to prevent. Idempotent: a no-op when * already present and coherent. */ -export async function healAgentdb() { - const c = adbCoherence(); +export async function healAgentdb({ + runner = run, coherence = adbCoherence, present = adbPresent, +} = {}) { + const c = coherence(); // Already present and coherent (identical or prerelease-only diff) → nothing. if (c.present && c.ok && c.skew !== 'core') { return { ok: true, detail: `present ${c.global}${c.skew === 'prerelease' ? ` (bundled ${c.bundled}; prerelease diff ok)` : ' (coherent with ruflo)'}` }; } // Pin to ruflo's bundled version; fall back to latest only when unknown. const spec = c.target ? `${ADB_PKG}@${c.target}` : `${ADB_PKG}@latest`; - const r = await run('npm', ['install', '-g', `--allow-scripts=${ALLOW_SCRIPTS}`, spec], { timeout: 600_000 }); + const r = await runner('npm', ['install', '-g', `--allow-scripts=${ALLOW_SCRIPTS}`, spec], { timeout: 600_000 }); if (r.code !== 0) { - return { ok: adbPresent(), detail: (r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200) }; + return { + ok: false, status: 'failed', usable: present(), + detail: (r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200), + }; } const verb = !c.present ? 'installed' : 'repaired coherence →'; - return { ok: true, detail: `${verb} ${c.target ?? 'latest'} (matches ruflo's bundled agentdb)` }; + return { ok: true, status: 'ok', usable: true, detail: `${verb} ${c.target ?? 'latest'} (matches ruflo's bundled agentdb)` }; } /** Stop all ruflo daemons before an upgrade (3.27+; best-effort). */ diff --git a/src/lib/live/index.mjs b/src/lib/live/index.mjs index 46b62c9..766339f 100644 --- a/src/lib/live/index.mjs +++ b/src/lib/live/index.mjs @@ -17,7 +17,7 @@ export { safeProjectKey, safeProjectLabel, stableProjectKey, } from './project-label.mjs'; export { - hostFromCommand, listActiveHostSessions, parseLsofCwds, parseProcessList, + hostFromCommand, listActiveHostSessions, parseLsofCwds, parseProcessHeaders, parseProcessList, } from './process-sessions.mjs'; export { inspectGitWorkspace, parseGitNumstat, workspaceFromSource, diff --git a/src/lib/live/process-sessions.mjs b/src/lib/live/process-sessions.mjs index 7d0ccc5..2f29992 100644 --- a/src/lib/live/process-sessions.mjs +++ b/src/lib/live/process-sessions.mjs @@ -46,6 +46,30 @@ export function parseProcessList(output) { return rows; } +/** Parse the privacy-minimized first survey, which deliberately omits argv. */ +export function parseProcessHeaders(output) { + const rows = []; + const pattern = /^\s*(\d+)\s+(\d+)\s+([A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(\S+)\s*$/; + for (const line of String(output ?? '').split('\n')) { + const match = pattern.exec(line); + if (!match) continue; + rows.push({ + pid: Number(match[1]), ppid: Number(match[2]), startedAt: match[3], + executable: match[4], command: '', + }); + } + return rows; +} + +function parseArgsByPid(output) { + const commands = new Map(); + for (const line of String(output ?? '').split('\n')) { + const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(line); + if (match) commands.set(Number(match[1]), match[2]); + } + return commands; +} + function rootControllers(rows) { const byPid = new Map(rows.map((row) => [row.pid, row])); const candidates = new Map(); @@ -114,6 +138,7 @@ async function darwinCwds(pids, run) { * }>, * cwdByPid?: Map * inspectWorkspace?: typeof inspectGitWorkspace + * uid?: number * }} [options] */ export async function listActiveHostSessions({ @@ -122,6 +147,7 @@ export async function listActiveHostSessions({ processRows, cwdByPid, inspectWorkspace = (cwd) => inspectGitWorkspace(cwd, { execFileImpl }), + uid = process.getuid?.(), } = {}) { if (platform === 'win32') { throw Object.assign(new Error('runtime process survey is unsupported on Windows'), { @@ -130,18 +156,38 @@ export async function listActiveHostSessions({ } let rows = processRows; if (!rows) { + if (!Number.isInteger(uid) || uid < 0) { + throw Object.assign(new Error('runtime process survey cannot determine the current user'), { + code: 'ERR_RUNTIME_PROCESS_SURVEY', + }); + } try { const result = await execFileImpl('ps', [ - '-axo', 'pid=,ppid=,lstart=,comm=,args=', + '-U', String(uid), '-x', '-o', 'pid=,ppid=,lstart=,comm=', ], { encoding: 'utf8', timeout: 3000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } }); const output = typeof result === 'string' ? result : result.stdout; - rows = parseProcessList(output); + rows = parseProcessHeaders(output); if (String(output ?? '').trim() && !rows.length) { throw Object.assign(new Error('runtime process output was not understood'), { code: 'ERR_RUNTIME_PROCESS_FORMAT', }); } + // argv can contain sensitive prompts/tokens. Fetch it only for executables + // that can actually be a supported controller or Node launcher. + const possible = rows.filter((row) => { + const name = executableName(row.executable); + return HOST_NAMES.has(name) || name === 'node' || name === 'nodejs'; + }); + if (possible.length) { + const argsResult = await execFileImpl('ps', [ + '-p', possible.map((row) => row.pid).join(','), '-o', 'pid=,args=', + ], { encoding: 'utf8', timeout: 3000, maxBuffer: 1024 * 1024, + env: { ...process.env, LC_ALL: 'C' } }); + const commands = parseArgsByPid(typeof argsResult === 'string' ? argsResult : argsResult.stdout); + rows = rows.map((row) => commands.has(row.pid) + ? { ...row, command: commands.get(row.pid) } : row); + } } catch { throw Object.assign(new Error('runtime process survey failed'), { code: 'ERR_RUNTIME_PROCESS_SURVEY', diff --git a/src/lib/opencode.mjs b/src/lib/opencode.mjs index b3ede3d..5a70fa2 100644 --- a/src/lib/opencode.mjs +++ b/src/lib/opencode.mjs @@ -37,6 +37,7 @@ import { have } from './exec.mjs'; import { readJson, writeJsonWithBackup } from './settings.mjs'; import { registry, syncBlocks, blocksForTarget, retiredForTarget, guidanceTargets } from './blocks.mjs'; import { CURRENT_INTEGRATIONS_VERSION } from './adapters/config.mjs'; +import { autoApproveValues } from './trust-manifest.mjs'; import * as paths from './paths.mjs'; const opencodeOwnership = (cfg) => cfg?.integrations?.ownership?.opencode ?? {}; @@ -65,7 +66,7 @@ export const RUFLO_MCP_ENV = { /** Permission patterns ak pre-approves (opencode surfaces MCP tools as * `_`; cover both separator spellings defensively). */ -export const PERMISSION_KEYS = ['claude-flow_*', 'claude_flow_*', 'ruvnet-brain_*', 'ruvnet_brain_*']; +export const PERMISSION_KEYS = autoApproveValues('opencode'); /** The brain's stable-spine shim (same registration codex carries). */ export const brainShimPath = () => path.join(paths.home, '.claude', 'ruvnet-brain', 'mcp', 'server.mjs'); diff --git a/src/lib/output.mjs b/src/lib/output.mjs index 1b2d43b..e13c968 100644 --- a/src/lib/output.mjs +++ b/src/lib/output.mjs @@ -17,6 +17,18 @@ export const fail = (msg) => console.log(`${red('✗')} ${msg}`); export const info = (msg) => console.log(`${dim('ℹ')} ${msg}`); export const heading = (msg) => console.log(`\n${bold(msg)}`); +/** Render a managed-operation result without collapsing degraded/skipped work + * into a green success. Legacy `{ok, detail}` results remain supported. */ +export function reportOutcome(name, result) { + const status = result?.status ?? (result?.ok ? 'ok' : 'failed'); + const message = `${name}: ${result?.detail ?? 'no detail'}`; + if (status === 'ok') ok(message); + else if (status === 'degraded') warn(message); + else if (status === 'skipped') info(message); + else fail(message); + return status; +} + /** Status glyph for dashboard rows. */ export const glyph = (level) => level === 'ok' ? green('✓') : level === 'warn' ? yellow('⚠') : level === 'fail' ? red('✗') : dim('·'); diff --git a/src/lib/project-memory.mjs b/src/lib/project-memory.mjs index 72baeaa..87f7b9f 100644 --- a/src/lib/project-memory.mjs +++ b/src/lib/project-memory.mjs @@ -17,13 +17,16 @@ function inspectStore(file, kind) { if (!fs.existsSync(file)) { return { kind, file, present: false, readable: false, entries: null, activityAt: null }; } - const observed = withDb(file, (db) => { + const result = withDb(file, (db) => { const columns = db.prepare('PRAGMA table_info(memory_entries)').all().map((column) => column.name); if (!columns.length) return { readable: false, entries: null }; const where = columns.includes('status') ? " WHERE status = 'active' OR status IS NULL" : ''; const entries = db.prepare(`SELECT COUNT(*) AS n FROM memory_entries${where}`).get()?.n ?? 0; return { readable: true, entries: Number(entries) }; - }, { readable: false, entries: null }); + }); + const observed = result.ok + ? result.value + : { readable: false, entries: null, reason: result.error.kind }; return { kind, file, present: true, ...observed, activityAt: activityAt(file) }; } @@ -37,12 +40,13 @@ export function projectMemoryStatus(root) { } export function memoryEntryExists(file, namespace, key) { - return withDb(file, (db) => { + const result = withDb(file, (db) => { const row = db.prepare( 'SELECT 1 AS found FROM memory_entries WHERE namespace = ? AND key = ? LIMIT 1', ).get(namespace, key); return row?.found === 1; - }, false); + }); + return result.ok ? result.value : false; } export function findMemoryEntry(root, namespace, key) { diff --git a/src/lib/settings.mjs b/src/lib/settings.mjs index d2ed838..26c4051 100644 --- a/src/lib/settings.mjs +++ b/src/lib/settings.mjs @@ -2,7 +2,7 @@ // (one .bak per calling site, never overwritten within a run), always // merge-not-clobber, trailing newline preserved. import fs from 'node:fs'; -import path from 'node:path'; +import { writeFileWithBackup } from './file-write.mjs'; export function readJson(file, fallback = null) { try { @@ -12,19 +12,11 @@ export function readJson(file, fallback = null) { } } -export function writeJsonWithBackup(file, data) { - fs.mkdirSync(path.dirname(file), { recursive: true }); - if (fs.existsSync(file)) { - const bak = `${file}.bak`; - try { if (!fs.existsSync(bak)) fs.copyFileSync(file, bak); } catch { /* best-effort */ } - } - // write-tmp-then-rename: rename(2) is atomic within a filesystem, so a - // reader (including Claude Code itself, on every startup) always sees - // either the complete old file or the complete new one — never a - // truncated one from an interrupt (Ctrl-C, OOM kill) landing mid-write. - const tmp = `${file}.${process.pid}.tmp`; - fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n'); - fs.renameSync(tmp, file); +export function writeJsonWithBackup(file, data, options) { + // Serialize before touching the filesystem: circular/unsupported values fail + // without even creating a backup or temporary file. + const content = JSON.stringify(data, null, 2) + '\n'; + writeFileWithBackup(file, content, options); } /** Add deny rules (deduped, sorted); returns count actually added. */ diff --git a/src/lib/sqlite.mjs b/src/lib/sqlite.mjs index aa77a68..6676665 100644 --- a/src/lib/sqlite.mjs +++ b/src/lib/sqlite.mjs @@ -2,23 +2,53 @@ // `sqlite3` binary call from the shell kit (memory verification, WAL // checkpoint, statusline QE metrics). import { DatabaseSync } from 'node:sqlite'; +import fs from 'node:fs'; -/** Run fn against a readonly connection; returns fn's result or fallback on - * any error (missing file, locked, missing table). */ -export function withDb(file, fn, fallback = null, { readonly = true } = {}) { +function classify(error, stage) { + const errcode = Number(error?.errcode); + const code = typeof error?.code === 'string' ? error.code : null; + const message = String(error?.message ?? 'SQLite operation failed').replace(/\s+/g, ' ').slice(0, 200); + let kind = stage === 'close' ? 'close' : 'io'; + if (code === 'ENOENT') kind = 'absent'; + else if (errcode === 5 || errcode === 6 || /\b(?:busy|locked)\b/i.test(message)) kind = 'busy'; + else if (errcode === 11 || errcode === 26 || /malformed|not a database/i.test(message)) kind = 'corrupt'; + else if (stage === 'query' && (errcode === 1 || /^SQLITE_ERROR$/.test(code ?? ''))) kind = 'query'; + return { kind, stage, errcode: Number.isFinite(errcode) ? errcode : null, code, message }; +} + +/** Run `fn` against a SQLite connection without erasing why it failed. + * Returns `{ok:true,value}` or `{ok:false,error:{kind,stage,...}}`. */ +export function withDb(file, fn, { readonly = true, Database = DatabaseSync } = {}) { + if (readonly && !fs.existsSync(file)) { + return { ok: false, error: { kind: 'absent', stage: 'open', errcode: null, code: 'ENOENT', message: 'database file is absent' } }; + } let db; + let result; try { - db = new DatabaseSync(file, { readOnly: readonly }); - return fn(db); - } catch { - return fallback; + db = new Database(file, { readOnly: readonly }); + try { result = { ok: true, value: fn(db) }; } + catch (error) { result = { ok: false, error: classify(error, 'query') }; } + } catch (error) { + result = { ok: false, error: classify(error, 'open') }; } finally { - try { db?.close(); } catch { /* ignore */ } + if (db) { + try { db.close(); } + catch (error) { + if (result?.ok) result = { ok: false, error: classify(error, 'close') }; + } + } } + return result; } -export const scalar = (file, sql, fallback = null) => - withDb(file, (db) => Object.values(db.prepare(sql).get() ?? {})[0] ?? fallback, fallback); +export const scalar = (file, sql, fallback = null) => { + const result = withDb(file, (db) => Object.values(db.prepare(sql).get() ?? {})[0] ?? fallback); + return result.ok ? result.value : fallback; +}; -export const checkpoint = (file) => - withDb(file, (db) => { db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); return true; }, false, { readonly: false }); +export const checkpoint = (file) => { + const result = withDb(file, (db) => { + db.exec('PRAGMA wal_checkpoint(TRUNCATE);'); return true; + }, { readonly: false }); + return result.ok; +}; diff --git a/src/lib/trust-manifest.mjs b/src/lib/trust-manifest.mjs new file mode 100644 index 0000000..813da9f --- /dev/null +++ b/src/lib/trust-manifest.mjs @@ -0,0 +1,88 @@ +// Host-neutral setup trust manifest. Host adapters declare every setup-time +// trust change in the registry; setup only filters/renders that declaration. +// This keeps a future host from gaining a silent permission/config path. +import { HOST_REGISTRY } from './adapters/index.mjs'; + +const enabledSet = (cfg) => new Set(Object.entries(cfg?.integrations?.hosts ?? {}) + .filter(([, enabled]) => enabled) + .map(([id]) => id)); + +function featuresMatch(change, context) { + return (change.features ?? []).every((feature) => context[feature] === true); +} + +/** Return applicable trust changes grouped by host. `hosts` is injectable so + * registry construction tests can prove a newly-added host needs no setup + * command branch to participate. */ +export function trustManifestForOperation(cfg, { + project = false, + hosts = HOST_REGISTRY, + operation = 'setup', +} = {}) { + const context = { + project, + aqe: cfg?.aqe !== false, + brain: cfg?.ruvnetBrain !== false, + }; + const enabled = enabledSet(cfg); + return hosts.map((host) => ({ + hostId: host.id, + label: host.label, + approvalPolicy: host.trust.approvalPolicy, + changes: host.trust.changes.filter((change) => ( + (change.requiresHostEnabled === false || enabled.has(host.id)) + && change.operations.includes(operation) + && featuresMatch(change, context) + )), + })).filter((group) => group.changes.length > 0); +} + +export function setupTrustManifest(cfg, options = {}) { + return trustManifestForOperation(cfg, { ...options, operation: 'setup' }); +} + +export function newlyEnabledHostTrustManifest(cfg, enabledHostIds, { + project = true, + hosts = HOST_REGISTRY, + operation = 'host-pick', +} = {}) { + const previous = enabledSet(cfg); + const desired = new Set(enabledHostIds); + const newlyEnabled = hosts.filter((host) => desired.has(host.id) && !previous.has(host.id)); + if (!newlyEnabled.length) return []; + const nextCfg = { + ...cfg, + integrations: { + ...(cfg?.integrations ?? {}), + hosts: Object.fromEntries([...desired].map((id) => [id, true])), + }, + }; + return trustManifestForOperation(nextCfg, { project, hosts: newlyEnabled, operation }); +} + +/** @param {string} hostId + * @param {{ kind?: string, hosts?: readonly any[] }} [options] */ +export function trustChangesForHost(hostId, { kind, hosts = HOST_REGISTRY } = {}) { + const host = hosts.find((entry) => entry.id === hostId); + if (!host) return []; + return host.trust.changes.filter((change) => !kind || change.kind === kind); +} + +export function autoApproveValues(hostId, options) { + return trustChangesForHost(hostId, { ...options, kind: 'auto-approve' }) + .map((change) => change.value); +} + +export function trustManifestLines(manifest) { + return manifest.flatMap((group) => { + const posture = group.approvalPolicy === 'unchanged' + ? 'approval/sandbox policy unchanged' + : 'approval policy receives the listed grants'; + return [ + `${group.label} — ${posture}`, + ...group.changes.map((change) => ( + ` • [${change.scope}] ${change.kind}: ${change.value} — ${change.owner}: ${change.effect}` + )), + ]; + }); +} diff --git a/src/lib/usage-index.mjs b/src/lib/usage-index.mjs index d3540ad..7098ad2 100644 --- a/src/lib/usage-index.mjs +++ b/src/lib/usage-index.mjs @@ -31,8 +31,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { configDir, claudeDir, codexDir } from './paths.mjs'; -import { readCodexState } from './codex-state.mjs'; -import { defaultOpencodeDbPath, listSessions as listOpencodeSessions, parseSession as parseOpencodeSession, sessionExists as opencodeSessionExists } from './usage-opencode.mjs'; +import { readCodexStateResult } from './codex-state.mjs'; +import { + defaultOpencodeDbPath, listSessionsResult as listOpencodeSessionsResult, + parseSession as parseOpencodeSession, sessionExistsResult as opencodeSessionExistsResult, +} from './usage-opencode.mjs'; /** Bump to invalidate every cached entry wholesale. * v2: cached records carry `active` sub-intervals for the idle-gap split. @@ -1082,7 +1085,7 @@ let _memo = null; /** Identity of a scan: two calls sharing it must produce the same aggregate. */ function scanKey(o = {}) { const r = o.roots || {}; - return JSON.stringify([Number(o.days) || 14, !!o.force, r.claude || '', r.codex || '', o.cachePath || '']); + return JSON.stringify([Number(o.days) || 14, !!o.force, r.claude || '', r.codex || '', r.opencode || '', o.cachePath || '']); } /** Drop process-level state (single-flight promises, read memo, lazy deps). */ @@ -1142,12 +1145,21 @@ async function scan(o = {}) { // the REAL store is the wrong one — only default-root scans (or an explicit // roots.opencode path) read it. const ocDb = o.roots === undefined ? defaultOpencodeDbPath() : (roots?.opencode ?? null); + let opencodeHealth = { status: 'absent', reason: null }; if (ocDb && fs.existsSync(ocDb)) { - for (const e of listOpencodeSessions({ dbFile: ocDb, cutoffMs: cutoff })) { - candidates.push({ - file: `opencode://${e.id}`, provider: 'opencode', id: e.id, dbFile: ocDb, - stat: { mtimeMs: e.mtimeMs, size: e.size }, - }); + const listed = listOpencodeSessionsResult({ dbFile: ocDb, cutoffMs: cutoff }); + if (listed.ok) { + opencodeHealth = { status: 'ok', reason: null }; + for (const e of listed.value) { + candidates.push({ + file: `opencode://${e.id}`, provider: 'opencode', id: e.id, dbFile: ocDb, + stat: { mtimeMs: e.mtimeMs, size: e.size }, + }); + } + } else { + opencodeHealth = listed.error.kind === 'absent' + ? { status: 'absent', reason: 'absent' } + : { status: 'degraded', reason: listed.error.kind }; } } @@ -1189,8 +1201,16 @@ async function scan(o = {}) { // opencode pseudo-keys are not files: existence means "row still in the store". if (file.startsWith('opencode://')) { const dbFile = e.dbFile ?? ocDb; - if (dbFile && opencodeSessionExists({ dbFile, id: file.slice('opencode://'.length) })) { + const exists = opencodeHealth.status === 'degraded' + ? null + : (dbFile ? opencodeSessionExistsResult({ dbFile, id: file.slice('opencode://'.length) }) : null); + if (opencodeHealth.status === 'degraded' || (exists?.ok && exists.value)) { + entries[file] = { ...e, dbFile }; + if (lastActivity == null || lastActivity >= cutoff) records.push(e.session); + } else if (exists && !exists.ok && exists.error.kind !== 'absent') { + opencodeHealth = { status: 'degraded', reason: exists.error.kind }; entries[file] = { ...e, dbFile }; + if (lastActivity == null || lastActivity >= cutoff) records.push(e.session); } } else if (statSafe(file)) entries[file] = e; } @@ -1206,10 +1226,26 @@ async function scan(o = {}) { // Overridden roots (tests, sandboxes) imply the REAL ~/.codex ledger is the // wrong ledger for these records — reading it would break test hermeticity // and mis-attribute fixture sessions. Only default roots read the real db. - const ledger = o.codexState !== undefined - ? o.codexState - : (roots?.codex ? null : readCodexState()); - return aggregate(applyCodexLedger(records, ledger), { days, now, cutoff, deps }); + let ledger; + let codexLedgerHealth; + if (o.codexState !== undefined) { + ledger = o.codexState; + codexLedgerHealth = { status: ledger ? 'ok' : 'absent', reason: null }; + } else if (roots?.codex) { + ledger = null; + codexLedgerHealth = { status: 'not-read', reason: 'sandboxed-roots' }; + } else { + const observed = readCodexStateResult(); + ledger = observed.ok ? observed.value : null; + codexLedgerHealth = observed.ok + ? (observed.value + ? { status: 'ok', reason: null } + : { status: 'degraded', reason: 'schema' }) + : { status: observed.error.kind === 'absent' ? 'absent' : 'degraded', reason: observed.error.kind }; + } + const result = aggregate(applyCodexLedger(records, ledger), { days, now, cutoff, deps }); + result.sourceHealth = { opencode: opencodeHealth, codexLedger: codexLedgerHealth }; + return result; } /** @@ -1324,7 +1360,9 @@ export async function readSession(id, o = {}) { // opencode sessions live in the SQLite store, not a JSONL file — resolve // them before the file-locating path (pseudo-key opencode://). const ocDb = o.roots === undefined ? defaultOpencodeDbPath() : (o.roots?.opencode ?? null); - if (ocDb && fs.existsSync(ocDb) && opencodeSessionExists({ dbFile: ocDb, id })) { + const ocExists = ocDb && fs.existsSync(ocDb) + ? opencodeSessionExistsResult({ dbFile: ocDb, id }) : null; + if (ocExists?.ok && ocExists.value) { const parsed = parseOpencodeSession({ dbFile: ocDb, id, withTurns: true }); if (parsed) { const rec = parsed.session; diff --git a/src/lib/usage-opencode.mjs b/src/lib/usage-opencode.mjs index a0805b4..5dec48f 100644 --- a/src/lib/usage-opencode.mjs +++ b/src/lib/usage-opencode.mjs @@ -77,6 +77,11 @@ const parseJson = (raw) => { try { return JSON.parse(raw); } catch { return null * cache key — a warm refresh re-parses only sessions that gained messages. * @param {{ dbFile: string, cutoffMs?: number }} opts */ export function listSessions({ dbFile, cutoffMs = 0 }) { + const result = listSessionsResult({ dbFile, cutoffMs }); + return result.ok ? result.value : []; +} + +export function listSessionsResult({ dbFile, cutoffMs = 0 }) { return withDb(dbFile, (db) => db.prepare(` SELECT s.id AS id, COALESCE(MAX(m.time_created), s.time_created) AS mtime, COUNT(m.id) AS messages @@ -84,12 +89,17 @@ export function listSessions({ dbFile, cutoffMs = 0 }) { GROUP BY s.id HAVING mtime >= ? ORDER BY mtime DESC - `).all(cutoffMs).map((r) => ({ id: r.id, mtimeMs: num(r.mtime), size: num(r.messages) })), []); + `).all(cutoffMs).map((r) => ({ id: r.id, mtimeMs: num(r.mtime), size: num(r.messages) }))); } /** Carry-forward existence probe (a session can be deleted between scans). */ export function sessionExists({ dbFile, id }) { - return withDb(dbFile, (db) => !!db.prepare('SELECT 1 FROM session WHERE id = ?').get(id), false); + const result = sessionExistsResult({ dbFile, id }); + return result.ok ? result.value : false; +} + +export function sessionExistsResult({ dbFile, id }) { + return withDb(dbFile, (db) => !!db.prepare('SELECT 1 FROM session WHERE id = ?').get(id)); } /** Project label from the session's working directory: basename, with the @@ -111,7 +121,7 @@ function projectFromDirectory(directory) { * transcript-view turn rows alongside the record. * @param {{ dbFile: string, id: string, withTurns?: boolean }} opts */ export function parseSession({ dbFile, id, withTurns = false }) { - return withDb(dbFile, (db) => { + const result = withDb(dbFile, (db) => { const srow = db.prepare('SELECT * FROM session WHERE id = ?').get(id); if (!srow) return null; const msgRows = db.prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC').all(id); @@ -218,5 +228,6 @@ export function parseSession({ dbFile, id, withTurns = false }) { rec.active = activeIntervals(rec.stamps); delete rec.stamps; return { session: rec, turns }; - }, null); + }); + return result.ok ? result.value : null; } diff --git a/src/templates/statusline-footer.cjs b/src/templates/statusline-footer.cjs index 3c81e3e..71bc507 100644 --- a/src/templates/statusline-footer.cjs +++ b/src/templates/statusline-footer.cjs @@ -1,11 +1,27 @@ /* ruflo-seg:BEGIN */ +function rufloStatuslineDebug(stage, error){ + if (!process || !process.env || process.env.AK_STATUSLINE_DEBUG !== "1") return; + if (error && error.code === "ENOENT") return; // optional source genuinely absent + try { + var fs = require("fs"), path = require("path"), os = require("os"); + var root = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"); + var file = process.env.AK_STATUSLINE_DEBUG_FILE || path.join(root, "agentic-kit", "statusline-debug.log"); + var safeStage = String(stage || "unknown").replace(/[^a-z0-9._-]/gi, "_").slice(0, 64); + var safeName = String(error && error.name || "Error").replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40) || "Error"; + var safeCode = String(error && (error.code || error.errcode) || "unknown").replace(/[^A-Za-z0-9_-]/g, "").slice(0, 40) || "unknown"; + var line = new Date().toISOString() + " stage=" + safeStage + " name=" + safeName + " code=" + safeCode + "\n"; + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + var size = 0; try { size = fs.statSync(file).size; } catch(_missing){} + if (size >= 65536) fs.writeFileSync(file, line, { mode: 0o600 }); + else fs.appendFileSync(file, line, { mode: 0o600 }); + try { fs.chmodSync(file, 0o600); } catch(_mode){} + } catch(_debugFailure) { /* diagnostics must never break the renderer */ } +} function rufloActivationSegments(cwd){ try { var fs = require("fs"), path = require("path"), cp = require("child_process"); var RED = "\x1b[1;31m"; // alarm-only segments (aidefence OFF) — matches ruflo's own brightRed var DIM = "", G = "", Y = "", C = "", R = ""; - // execFileSync (no shell) — db path / sql are passed as argv, never interpolated into a command line. - function q(db, sql){ try { return cp.execFileSync("sqlite3", [db, sql], {stdio:["ignore","pipe","ignore"], timeout:1500}).toString().trim(); } catch(e){ return ""; } } // ── quota tee (ADR-0010): Claude Code pushes plan utilization into every // statusline invocation (rate_limits: five_hour/seven_day used_percentage + // reset epochs — code.claude.com/docs/en/statusline.md). This is the ONLY @@ -22,7 +38,7 @@ function rufloActivationSegments(cwd){ var _qdir = path.join(process.env.XDG_CONFIG_HOME || path.join(require("os").homedir(), ".config"), "agentic-kit"); var _qf = path.join(_qdir, "claude-rate-limits.json"); var _qold = 0; - try { _qold = fs.statSync(_qf).mtimeMs; } catch(e){} + try { _qold = fs.statSync(_qf).mtimeMs; } catch(e){ rufloStatuslineDebug("quota-cache-stat", e); } if (Date.now() - _qold > 60000) { fs.mkdirSync(_qdir, { recursive: true }); var _qtmp = _qf + "." + process.pid + ".tmp"; @@ -38,7 +54,7 @@ function rufloActivationSegments(cwd){ } } } - } catch(e){} + } catch(e){ rufloStatuslineDebug("quota-tee", e); } function bar(n, max){ n = Math.max(0, Math.min(max, n)); return "[" + "●".repeat(n) + "○".repeat(max - n) + "]"; } // ── self-learning (SONA): own line with a volume bar (patterns/traj/HNSW) plus a // LIVE micro-LoRA adaptation field (Δ‖W‖, appended further below). The Δ‖W‖ tracker @@ -58,7 +74,7 @@ function rufloActivationSegments(cwd){ learn = C + "🧠 SONA" + R + " " + DIM + bar(dots, 5) + R + " " + parts.join(DIM + " · " + R); } } - } catch(e){} + } catch(e){ rufloStatuslineDebug("sona-stats", e); } // ── micro-LoRA LIVE adaptation: Δ‖W‖ + n ── // Shows the model ACTUALLY ADAPTING FROM YOUR WORK, live. ruflo's own micro-LoRA is // per-process scratch ("resets per process", intelligence.js) — every hook reinits it @@ -80,7 +96,7 @@ function rufloActivationSegments(cwd){ var nd = path.join(cwd, ".claude-flow", "neural"); var pPath = path.join(nd, "patterns.json"), sPath = path.join(nd, "lora-live.json"); if (fs.existsSync(pPath)) { - var st = null; try { st = JSON.parse(fs.readFileSync(sPath, "utf8")); } catch(e){} + var st = null; try { st = JSON.parse(fs.readFileSync(sPath, "utf8")); } catch(e){ rufloStatuslineDebug("lora-state-read", e); } var nowS = Math.floor(Date.now() / 1000); var pMtimeMs = fs.statSync(pPath).mtimeMs; // ms precision: same-second writes still detected var TTL = Number(process.env.RUFLO_LORA_TTL_S || 60); @@ -89,7 +105,7 @@ function rufloActivationSegments(cwd){ // getStdinData() is the host statusline's cached single-read of that JSON; guard the // call so the segment still works on a template that lacks it, or run standalone. var sid = ""; - try { if (typeof getStdinData === "function") { var _sd = getStdinData(); sid = (_sd && (_sd.session_id || _sd.sessionId)) || ""; } } catch(e){} + try { if (typeof getStdinData === "function") { var _sd = getStdinData(); sid = (_sd && (_sd.session_id || _sd.sessionId)) || ""; } } catch(e){ rufloStatuslineDebug("lora-session-input", e); } // Refresh when: no state yet, the session changed (reset the +session baseline even // with no new patterns), or patterns changed and the TTL has elapsed. var sidChanged = !!(sid && st && (st.sessionId || "") !== sid); @@ -101,7 +117,7 @@ function rufloActivationSegments(cwd){ var sj = path.join(path.dirname(process.execPath), "..", "lib", "node_modules", "ruflo", "node_modules", "@ruvector", "ruvllm", "dist", "cjs", "sona.js"); if (fs.existsSync(sj)) SC = require(sj).SonaCoordinator; - } catch(e){} + } catch(e){ rufloStatuslineDebug("lora-module-load", e); } if (SC) { var pats = JSON.parse(fs.readFileSync(pPath, "utf8")); // Seed Math.random so the first-ever loraA init is deterministic; restore after ctor. @@ -111,7 +127,7 @@ function rufloActivationSegments(cwd){ Math.random = orig; var applied = new Set((st && st.appliedIds) || []); var n = (st && st.n) || 0; - if (st && st.loraA) { try { coord.microLora.setWeights({ loraA: st.loraA, loraB: st.loraB, scaling: st.scaling }); } catch(e){} } + if (st && st.loraA) { try { coord.microLora.setWeights({ loraA: st.loraA, loraB: st.loraB, scaling: st.scaling }); } catch(e){ rufloStatuslineDebug("lora-weight-restore", e); } } var prevSid = st ? (st.sessionId || "") : ""; var newSession; if (sid) { @@ -134,7 +150,7 @@ function rufloActivationSegments(cwd){ var rec = { loraA: w.loraA, loraB: w.loraB, scaling: w.scaling, appliedIds: Array.from(applied), n: n, deltaNorm: nm, sessionBase: sessionBase, sessionTs: sessionTs, sessionId: sid, pms: pMtimeMs, ts: nowS }; - try { var tmp = sPath + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(rec)); fs.renameSync(tmp, sPath); } catch(e){} + try { var tmp = sPath + ".tmp"; fs.writeFileSync(tmp, JSON.stringify(rec)); fs.renameSync(tmp, sPath); } catch(e){ rufloStatuslineDebug("lora-state-write", e); } st = rec; } } @@ -150,7 +166,7 @@ function rufloActivationSegments(cwd){ else { learn = C + "🧠 Δ LoRA" + R + " " + dseg; } } } - } catch(e){} + } catch(e){ rufloStatuslineDebug("lora-segment", e); } // ── route Q-learner (📈 RL): live agent-routing metrics, fs-only, honesty-gated ── // F3 (ruvnet/ruflo#2239) is fixed in ruflo 3.10.11 (FNV-1a lossless fold) — the // state encoder no longer collapses keyword-distinct tasks, so |Q| is a @@ -190,7 +206,7 @@ function rufloActivationSegments(cwd){ } } } - } catch(e){} + } catch(e){ rufloStatuslineDebug("route-learning", e); } // ── proof verdict (self-improvement eval): ALARM-ONLY, fs-only ── // Sources the most recent ruflo-improvement-eval run (.claude-flow/improvement.json): // a pre-registered causal test (one-sided permutation p + Cohen's d + above-chance) @@ -219,7 +235,7 @@ function rufloActivationSegments(cwd){ proof = Y + "◷ proof FAIL" + R + (pp.length ? " " + DIM + pp.join(" · ") + R : ""); } } - } catch(e){} + } catch(e){ rufloStatuslineDebug("proof-verdict", e); } // ── AI defense (AIMDS) — ALARM-ONLY: renders only when it is MISSING ──────── // Was a permanent green "🛡 aidefence on". Two reasons it inverted: // 1. Issue #8's rule, already law for the proof segment below: the expected state @@ -255,7 +271,7 @@ function rufloActivationSegments(cwd){ if (rufloAidefenceState(rufloFindRufloRoot()) === "off") { sec = RED + "⚠ aidefence OFF" + R + DIM + " — no prompt-injection defense · ak sync restores it" + R; } - } catch(e){} + } catch(e){ rufloStatuslineDebug("aidefence-state", e); } // ── daemon visibility (⚙): GLOBAL count of running ruflo daemons, so no daemon // is ever invisible (token-burn incident lesson). Machine-global, not per-project, // so it is cached in tmpdir and shared across every project's statusline — one @@ -271,13 +287,13 @@ function rufloActivationSegments(cwd){ var dCache = path.join(os.tmpdir(), "ruflo-daemon-count.json"); var dTtl = Number(process.env.RUFLO_DAEMON_STATUSLINE_TTL_MS || 30000); var dCount = null; - try { var dc = JSON.parse(fs.readFileSync(dCache, "utf8")); if (dc && typeof dc.n === "number" && dTtl > 0 && (Date.now() - dc.ts) < dTtl) dCount = dc.n; } catch(e){} + try { var dc = JSON.parse(fs.readFileSync(dCache, "utf8")); if (dc && typeof dc.n === "number" && dTtl > 0 && (Date.now() - dc.ts) < dTtl) dCount = dc.n; } catch(e){ rufloStatuslineDebug("daemon-cache-read", e); } if (dCount === null) { try { var pg = cp.execFileSync("pgrep", ["-f", "cli.js daemon start"], {stdio:["ignore","pipe","ignore"], timeout:1500}).toString().trim(); dCount = pg ? pg.split("\n").filter(Boolean).length : 0; } catch(e){ dCount = 0; } // pgrep exits 1 (=> throws) when nothing matches - try { fs.writeFileSync(dCache, JSON.stringify({ts: Date.now(), n: dCount})); } catch(e){} + try { fs.writeFileSync(dCache, JSON.stringify({ts: Date.now(), n: dCount})); } catch(e){ rufloStatuslineDebug("daemon-cache-write", e); } } if (dCount > 0) { var dCol = dCount >= 4 ? Y : DIM; @@ -285,7 +301,7 @@ function rufloActivationSegments(cwd){ + (dCount >= 4 ? DIM + " — ruflo-daemon-gc to inspect" + R : ""); } } - } catch(e){} + } catch(e){ rufloStatuslineDebug("daemon-segment", e); } // ── RuvNet Brain (🧿): offline rUv-stack knowledge base — honesty-gated, fs-only ── // The brain is NOT an npm package — `npx github:stuinfla/ruvnet-brain` drops a // ~2GB offline knowledge base at ~/.cache/ruvnet-brain/kb (honors RUVNET_BRAIN_KB) @@ -322,12 +338,12 @@ function rufloActivationSegments(cwd){ var srcJ = JSON.parse(fs.readFileSync(path.join(kbDir, "SOURCE.json"), "utf8")); var rawTag = String(srcJ.releaseTag || ""); if (/^[A-Za-z0-9._-]{1,32}$/.test(rawTag)) relTag = rawTag; - } catch(e){} + } catch(e){ rufloStatuslineDebug("brain-source-stamp", e); } if (!relTag) try { var kitCfg = path.join(os2.homedir(), ".config", "agentic-kit", "kit.json"); var kj = JSON.parse(fs.readFileSync(kitCfg, "utf8")); if (kj && kj.versionCheck && kj.versionCheck.ruvnetBrain) relTag = kj.versionCheck.ruvnetBrain.installedRelease; - } catch(e){} + } catch(e){ rufloStatuslineDebug("brain-kit-stamp", e); } if (relTag) { bver = " V" + String(relTag).replace(/^v/, ""); } else { @@ -336,7 +352,7 @@ function rufloActivationSegments(cwd){ var bv = JSON.parse(fs.readFileSync(bpkg, "utf8")).version; if (bv) bver = " V" + String(bv).replace(/^v/, ""); } - } catch(e){} + } catch(e){ rufloStatuslineDebug("brain-version", e); } // KB size — TTL-cached shallow sum of top-level files, keyed on kbDir so an // env-overridden path (or a moved KB) never serves a stale foreign size. var bBytes = null; @@ -346,16 +362,16 @@ function rufloActivationSegments(cwd){ try { var bc = JSON.parse(fs.readFileSync(bCache, "utf8")); if (bc && bc.dir === kbDir && typeof bc.bytes === "number" && bTtl > 0 && (Date.now() - bc.ts) < bTtl) bBytes = bc.bytes; - } catch(e){} + } catch(e){ rufloStatuslineDebug("brain-size-cache-read", e); } if (bBytes === null) { var sum = 0; fs.readdirSync(kbDir).forEach(function(f){ - try { var s = fs.statSync(path.join(kbDir, f)); if (s.isFile()) sum += s.size; } catch(e){} + try { var s = fs.statSync(path.join(kbDir, f)); if (s.isFile()) sum += s.size; } catch(e){ rufloStatuslineDebug("brain-size-entry", e); } }); bBytes = sum; - try { fs.writeFileSync(bCache, JSON.stringify({ts: Date.now(), dir: kbDir, bytes: sum})); } catch(e){} + try { fs.writeFileSync(bCache, JSON.stringify({ts: Date.now(), dir: kbDir, bytes: sum})); } catch(e){ rufloStatuslineDebug("brain-size-cache-write", e); } } - } catch(e){} + } catch(e){ rufloStatuslineDebug("brain-size", e); } var bp = []; if (bBytes && bBytes > 0) { var bkb = Math.round(bBytes / 1024); @@ -363,7 +379,7 @@ function rufloActivationSegments(cwd){ } brain = C + "🧿 RuvNet Brain" + bver + R + " " + (bp.length ? bp.join(DIM + " · " + R) : G + "✓" + R); } - } catch(e){} + } catch(e){ rufloStatuslineDebug("brain-segment", e); } // ── agentic-qe — TTL-cached; one sqlite3 spawn only on a cache miss (issue #3) ── var qe = ""; try { @@ -376,7 +392,7 @@ function rufloActivationSegments(cwd){ try { var cc = JSON.parse(fs.readFileSync(cacheFile, "utf8")); if (cc && typeof cc.line === "string" && ttl > 0 && (Date.now() - cc.ts) < ttl) cachedLine = cc.line; - } catch(e){} + } catch(e){ rufloStatuslineDebug("qe-cache-read", e); } if (cachedLine !== null) { qe = cachedLine; // hit: zero sqlite3 spawns } else { @@ -391,7 +407,7 @@ function rufloActivationSegments(cwd){ + "SELECT 'traj',COUNT(*) FROM qe_trajectories;\n"; var raw = ""; try { raw = cp.execFileSync("sqlite3", [db], {input: sql, stdio:["pipe","pipe","ignore"], timeout:1500}).toString(); } - catch(e){ raw = (e && e.stdout) ? e.stdout.toString() : ""; } + catch(e){ rufloStatuslineDebug("qe-sqlite-query", e); raw = (e && e.stdout) ? e.stdout.toString() : ""; } var pat = 0, qtj = 0, qv = 0; raw.split("\n").forEach(function(ln){ var i = ln.indexOf("|"); if (i < 0) return; @@ -402,7 +418,7 @@ function rufloActivationSegments(cwd){ if (pat > 0) qp.push("🎓 " + pat + " patterns"); if (qtj > 0) qp.push("🧭 " + qtj + " traj"); if (qv > 0) qp.push("🧬 " + qv + " vec" + G + "⚡" + R); - try { var kb = Math.round(fs.statSync(db).size / 1024); qp.push("💾 " + (kb >= 1024 ? (kb/1024).toFixed(1) + "MB" : kb + "KB")); } catch(e){} + try { var kb = Math.round(fs.statSync(db).size / 1024); qp.push("💾 " + (kb >= 1024 ? (kb/1024).toFixed(1) + "MB" : kb + "KB")); } catch(e){ rufloStatuslineDebug("qe-db-stat", e); } // Installed agentic-qe version — shown next to the label, mirroring "RuFlo V" // in ruflo's native header. Prefer the global install (matches the aidefence // probe above); fall back to a project-local node_modules copy. @@ -412,12 +428,12 @@ function rufloActivationSegments(cwd){ if (!fs.existsSync(qpkg)) qpkg = path.join(cwd, "node_modules", "agentic-qe", "package.json"); var qv2 = JSON.parse(fs.readFileSync(qpkg, "utf8")).version; if (qv2) qver = " V" + qv2; - } catch(e){} + } catch(e){ rufloStatuslineDebug("qe-version", e); } qe = Y + "🎓 Agentic QE" + qver + R + " " + (qp.length ? qp.join(DIM + " · " + R) : "on"); - try { fs.mkdirSync(cacheDir, {recursive:true}); fs.writeFileSync(cacheFile, JSON.stringify({ts: Date.now(), line: qe})); } catch(e){} + try { fs.mkdirSync(cacheDir, {recursive:true}); fs.writeFileSync(cacheFile, JSON.stringify({ts: Date.now(), line: qe})); } catch(e){ rufloStatuslineDebug("qe-cache-write", e); } } } - } catch(e){} + } catch(e){ rufloStatuslineDebug("qe-segment", e); } // ── assemble: one ruflo feature per line (SONA, 📈 RL, ◷ proof FAIL alarm, // ⚠ aidefence OFF alarm), then a divider, then the agentic-qe line. The two alarms // are silent in the healthy case, so a well-configured machine shows only the live @@ -437,7 +453,7 @@ function rufloActivationSegments(cwd){ if (qe) out.push(qe); if (!out.length) return ""; return "\n" + out.join("\n"); - } catch(e){ return ""; } + } catch(e){ rufloStatuslineDebug("renderer", e); return ""; } } // ── AI-defense probe (companion to the alarm-only segment above) ───────────── // Locates the global ruflo install WITHOUT spawning npm (this runs on every render). @@ -461,7 +477,7 @@ function rufloFindRufloRoot(){ if (fs.existsSync(path.join(cands[ci], "package.json"))) return cands[ci]; } return ""; - } catch(e){ return ""; } + } catch(e){ rufloStatuslineDebug("ruflo-root-probe", e); return ""; } } // ── real CLI bins (companion to the ruflo-bin wrapper) ────────────────────── // Upstream's resolveCliBinCandidates looks for `ruflo/bin/cli.js`, but the ruflo @@ -484,8 +500,8 @@ function rufloRealCliBins(cwd){ out.push(path.join(roots[i], "bin", "ruflo.js")); out.push(path.join(roots[i], "node_modules", "@claude-flow", "cli", "bin", "cli.js")); } - return out.filter(function(p){ try { return fs.existsSync(p); } catch(e){ return false; } }); - } catch(e){ return []; } + return out.filter(function(p){ try { return fs.existsSync(p); } catch(e){ rufloStatuslineDebug("ruflo-bin-entry", e); return false; } }); + } catch(e){ rufloStatuslineDebug("ruflo-bin-probe", e); return []; } } // Three states, not two — the distinction IS the fail-safe. "off" is asserted only on // positive evidence: a real ruflo install that does not contain aidefence. Anything we @@ -499,7 +515,7 @@ function rufloAidefenceState(rufloRoot){ if (!rufloRoot || !fs.existsSync(path.join(rufloRoot, "package.json"))) return "unknown"; var ad = path.join(rufloRoot, "node_modules", "@claude-flow", "aidefence", "package.json"); return fs.existsSync(ad) ? "on" : "off"; - } catch(e){ return "unknown"; } + } catch(e){ rufloStatuslineDebug("aidefence-probe", e); return "unknown"; } } // ── security overlay: replaces ruflo's FABRICATED CVE counter with the real scan ── // Upstream (@claude-flow/cli dist/src/funnel/local-signals.js, getSecurityStatus) does: @@ -536,11 +552,11 @@ function rufloLocalSecurity(cwd, upstream){ // Prefer the scan's own timestamp; fall back to mtime so a hand-written or // older-format scan file still orders correctly instead of sorting to epoch 0. var t = Date.parse(j && j.timestamp); - if (!t) { try { t = fs.statSync(path.join(dir, f)).mtimeMs; } catch(e){ t = 0; } } + if (!t) { try { t = fs.statSync(path.join(dir, f)).mtimeMs; } catch(e){ rufloStatuslineDebug("security-scan-stat", e); t = 0; } } if (!newest || t > newest.t) newest = { t: t, j: j }; - } catch(e){} // unreadable/!JSON scan file: ignore, never let it break the render + } catch(e){ rufloStatuslineDebug("security-scan-file", e); } // unreadable/!JSON scan file: ignore, never let it break the render }); - } catch(e){} // no directory => never scanned + } catch(e){ rufloStatuslineDebug("security-scan-directory", e); } // no directory => never scanned if (!newest) return { status: "PENDING", cvesFixed: 0, totalCves: 0 }; var s = newest.j.summary || {}; var n = typeof s.total === "number" ? s.total @@ -551,7 +567,7 @@ function rufloLocalSecurity(cwd, upstream){ return { status: "STALE", cvesFixed: 0, totalCves: 0 }; } return { status: "CLEAN", cvesFixed: 0, totalCves: 0 }; - } catch(e){ return upstream; } + } catch(e){ rufloStatuslineDebug("security-overlay", e); return upstream; } } // ── insight-row companion to rufloLocalSecurity ────────────────────────────── // The fabricated count reaches the render through a SECOND, independent path: the @@ -577,6 +593,6 @@ function rufloHonestInsight(promo, sec){ return { text: "⚠ " + n + " security issue" + (n === 1 ? "" : "s") + " found — see .claude/security-scans", kind: "insight" }; } return null; // CLEAN: say nothing. The slot falls blank rather than nagging about a lie. - } catch(e){ return promo; } + } catch(e){ rufloStatuslineDebug("security-insight", e); return promo; } } /* ruflo-seg:END */ diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs index 4312e14..172cff3 100644 --- a/tests/dashboard.test.cjs +++ b/tests/dashboard.test.cjs @@ -425,6 +425,10 @@ async function main() { // ── the routes, over real HTTP, with a spying usage module ── const AGG = { generatedAt: '2026-07-25T00:00:00.000Z', windowDays: 14, pricesAsOf: '2026-07-01', + sourceHealth: { + opencode: { status: 'degraded', reason: 'busy' }, + codexLedger: { status: 'ok', reason: null }, + }, totals: { sessions: 2, responses: 9, input: 100, output: 200, cacheRead: 900, cacheWrite: 50, tokens: 1250, cost: 12.5, spanMinutes: 90, engagedSeconds: 3600 }, byDay: { '2026-07-24': { tokens: 1000, cost: 10, sessions: 1 } }, byModel: { 'claude-opus-5': { cost: 12.5, tokens: 1250, responses: 9 } }, @@ -517,6 +521,8 @@ async function main() { 'provider analytics must not alter transcript totals'); assert(j.projectTree && j.projectTree.length === 1, 'projectTree must survive'); assert(Array.isArray(j.insights) && j.insights.length === 1, 'insights must survive'); + assert(j.sourceHealth.opencode.status === 'degraded' && j.sourceHealth.opencode.reason === 'busy', + 'source-health evidence must survive the dashboard route'); assert(spy.calls.readIndex.some((o) => o && o.days === 7), 'days must reach readIndex, got ' + JSON.stringify(spy.calls.readIndex)); }); @@ -643,6 +649,11 @@ async function main() { contains(r.body, 'data-view="' + v + '"'); } contains(r.body, 'id="u-openrouter"'); + contains(r.body, 'id="u-source-health"'); + contains(r.body, 'function renderSourceHealth'); + contains(r.body, 'data-status="'); + contains(r.body, 'OpenCode'); + contains(r.body, 'Codex ledger'); contains(r.body, 'provider account analytics'); contains(r.body, 'never merged into transcript totals'); contains(r.body, 'OpenRouter credits'); diff --git a/tests/kit/blocks.test.mjs b/tests/kit/blocks.test.mjs index 9fb00cf..7d6eeeb 100644 --- a/tests/kit/blocks.test.mjs +++ b/tests/kit/blocks.test.mjs @@ -155,3 +155,17 @@ test('syncBlocks takes a one-time .bak before first rewriting the file', async ( assert.equal(fs.readFileSync(`${file}.bak`, 'utf8'), 'original\n', 'pre-rewrite content preserved'); fs.rmSync(tmp, { recursive: true, force: true }); }); + +test('syncBlocks fails closed when its promised backup is unusable', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-bak-fail-')); + const file = path.join(tmp, 'CLAUDE.md'); + fs.writeFileSync(file, 'original\n'); + fs.writeFileSync(path.join(tmp, 't.md'), 'body\n'); + fs.mkdirSync(`${file}.bak`); + const rows = [{ slug: 'b1', template: 't.md', position: 'append', detector: { type: 'always' } }]; + + await assert.rejects(syncBlocks(file, rows, () => path.join(tmp, 't.md')), /unusable backup path/); + assert.equal(fs.readFileSync(file, 'utf8'), 'original\n'); + assert.deepEqual(fs.readdirSync(tmp).filter((name) => name.endsWith('.tmp')), []); + fs.rmSync(tmp, { recursive: true, force: true }); +}); diff --git a/tests/kit/clean-machine-setup.test.mjs b/tests/kit/clean-machine-setup.test.mjs new file mode 100644 index 0000000..3511f30 --- /dev/null +++ b/tests/kit/clean-machine-setup.test.mjs @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { snapshot } from './helpers/home-sandbox.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const BIN = path.join(ROOT, 'bin', 'agentic-kit.mjs'); + +test('clean-machine setup preview is hermetic and discloses every auto-approve rule', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-clean-machine-')); + const home = path.join(root, 'home'); + const project = path.join(root, 'project'); + const prefix = path.join(root, 'npm-prefix'); + const noBin = path.join(root, 'no-such-bin'); + fs.mkdirSync(path.join(home, '.config'), { recursive: true }); + fs.mkdirSync(path.join(project, '.git'), { recursive: true }); + const beforeHome = snapshot(home); + const beforeProject = snapshot(project); + const env = { + ...process.env, + HOME: home, USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, '.config'), + XDG_STATE_HOME: path.join(home, '.local', 'state'), + APPDATA: path.join(home, 'AppData', 'Roaming'), + npm_config_prefix: prefix, + npm_config_cache: path.join(root, 'npm-cache'), + RUVNET_BRAIN_KB: path.join(root, 'brain-kb'), + PATH: noBin, + NO_COLOR: '1', + }; + const run = spawnSync(process.execPath, [BIN, 'setup', '--project', '--dry-run', '--yes'], { + cwd: project, env, encoding: 'utf8', timeout: 30_000, + }); + assert.equal(run.status, 0, run.stderr || run.stdout); + const output = `${run.stdout}\n${run.stderr}`; + for (const rule of [ + 'Bash(npx @claude-flow*)', 'Bash(npx claude-flow*)', 'Bash(node .claude/*)', + 'mcp__claude-flow__*', 'Bash(npx agentic-qe:*)', + 'Bash(npx @anthropics/agentic-qe:*)', 'mcp__agentic-qe__*', + ]) assert.ok(output.includes(rule), `missing disclosed rule: ${rule}`); + assert.deepEqual(snapshot(home), beforeHome, 'preview must not mutate its disposable HOME'); + assert.deepEqual(snapshot(project), beforeProject, 'preview must not mutate its disposable project'); + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('clean-machine noninteractive trust requires --yes and declines without mutation', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-clean-trust-')); + const home = path.join(root, 'home'); + const project = path.join(root, 'project'); + fs.mkdirSync(path.join(home, '.config'), { recursive: true }); + fs.mkdirSync(path.join(project, '.git'), { recursive: true }); + const beforeHome = snapshot(home); + const beforeProject = snapshot(project); + const env = { + ...process.env, + HOME: home, USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, '.config'), + XDG_STATE_HOME: path.join(home, '.local', 'state'), + APPDATA: path.join(home, 'AppData', 'Roaming'), + npm_config_prefix: path.join(root, 'npm-prefix'), + npm_config_cache: path.join(root, 'npm-cache'), + RUVNET_BRAIN_KB: path.join(root, 'brain-kb'), + PATH: path.join(root, 'no-such-bin'), + NO_COLOR: '1', + }; + const run = spawnSync(process.execPath, [BIN, 'setup', '--project'], { + cwd: project, env, encoding: 'utf8', timeout: 30_000, + }); + assert.equal(run.status, 0, run.stderr || run.stdout); + const output = `${run.stdout}\n${run.stderr}`; + assert.match(output, /setup trust manifest/); + assert.match(output, /setup cancelled before machine, user, or project changes/); + assert.deepEqual(snapshot(home), beforeHome, 'declined setup trust must not mutate HOME'); + assert.deepEqual(snapshot(project), beforeProject, 'declined setup trust must not mutate project'); + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/tests/kit/heal-natives.test.mjs b/tests/kit/heal-natives.test.mjs index 62b15ce..6a3939d 100644 --- a/tests/kit/heal-natives.test.mjs +++ b/tests/kit/heal-natives.test.mjs @@ -13,7 +13,9 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { ensureNativeBsq3, healNatives } from '../../src/lib/heal.mjs'; +import { + ensureNativeBsq3, healAqeSolver, healNatives, installRuvnetBrain, +} from '../../src/lib/heal.mjs'; import { bsq3IsNative } from '../../src/lib/natives.mjs'; import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; @@ -310,3 +312,47 @@ test('ladder succeeds on npm-12 (run install blocked, approve-scripts then rebui assert.match(r.how, /rebuilt/); cleanup(); }); + +test('brain installer exit failure is failed even when an old or partial KB is present', async () => { + let stamped = false; + const r = await installRuvnetBrain({ + runner: async () => ({ code: 1, stdout: '', stderr: 'installer failed after partial copy\n' }), + latestVersion: async () => '4.0.12', + present: () => true, + recordRelease: () => { stamped = true; }, + }); + + assert.equal(r.ok, false); + assert.equal(r.status, 'failed'); + assert.equal(r.usable, true, 'old presence is retained as usability evidence only'); + assert.equal(stamped, false, 'failed installers never stamp a release'); +}); + +test('brain installer stamps only a zero-exit installation', async () => { + let stamped = null; + const r = await installRuvnetBrain({ + runner: async () => ({ code: 0, stdout: '', stderr: '' }), + latestVersion: async () => '4.0.12', + present: () => false, + recordRelease: (version) => { stamped = version; }, + }); + assert.equal(r.status, 'ok'); + assert.equal(stamped, '4.0.12'); +}); + +test('AQE native solver failure is an explicit degraded fallback', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-solver-')); + fs.mkdirSync(path.join(root, 'agentic-qe'), { recursive: true }); + _setGlobalRootForTest(root); + try { + const r = await healAqeSolver({ + runner: async () => ({ code: 1, stdout: '', stderr: 'native package unavailable\n' }), + }); + assert.equal(r.ok, true, 'the TypeScript fallback remains usable'); + assert.equal(r.status, 'degraded'); + assert.equal(r.usable, true); + } finally { + _setGlobalRootForTest(null); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/kit/helpers/integration-builders.mjs b/tests/kit/helpers/integration-builders.mjs index ceb9d44..e386426 100644 --- a/tests/kit/helpers/integration-builders.mjs +++ b/tests/kit/helpers/integration-builders.mjs @@ -7,12 +7,14 @@ export function validHost(overrides = {}) { canDriveSession: true, canBePrimary: true, canRouteActivities: true, + commandStatusline: false, transcripts: true, usage: true, nativeMcpConfig: true, nativeGuidance: true, }, auth: { apiKeyEnv: ['TEST_HOST_API_KEY'], keyOverridesLogin: false }, + trust: { approvalPolicy: 'unchanged', changes: [] }, configProjection: 'test-projection', observability: ['test-transcripts'], ...overrides, diff --git a/tests/kit/host-cli-migration.test.mjs b/tests/kit/host-cli-migration.test.mjs index 3460c34..6a252bf 100644 --- a/tests/kit/host-cli-migration.test.mjs +++ b/tests/kit/host-cli-migration.test.mjs @@ -70,6 +70,22 @@ test('ak host preserves pick option parsing without performing an interactive ru assert.match(result.stdout, /--provider /); }); +test('non-interactive host enablement requires --yes after printing trust and mutates nothing', () => { + const sb = sandbox(); + const configFile = path.join(sb.env.XDG_CONFIG_HOME, 'agentic-kit', 'kit.json'); + const result = ak(sb, 'host', 'pick', '--host', 'claude,opencode'); + const text = output(result); + assert.equal(result.status, 2, text); + assert.match(text, /host trust manifest/); + assert.match(text, /OpenCode — approval policy receives the listed grants/); + assert.match(text, /\[user\] auto-approve: claude-flow_\*/); + assert.match(text, /re-run with --yes after reviewing the manifest/); + assert.equal(fs.existsSync(configFile), false, + 'declined non-interactive trust must not create kit.json'); + assert.equal(fs.existsSync(path.join(sb.env.XDG_CONFIG_HOME, 'opencode')), false, + 'declined non-interactive trust must not create OpenCode config'); +}); + for (const [name, body, detail] of [ ['malformed JSON', '{ invalid json', /Unexpected token|Expected property name/], ['non-object integrations envelope', JSON.stringify({ integrations: [] }), diff --git a/tests/kit/live-process-sessions.test.mjs b/tests/kit/live-process-sessions.test.mjs index b8bfb26..bdb2298 100644 --- a/tests/kit/live-process-sessions.test.mjs +++ b/tests/kit/live-process-sessions.test.mjs @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - hostFromCommand, listActiveHostSessions, parseLsofCwds, parseProcessList, + hostFromCommand, listActiveHostSessions, parseLsofCwds, parseProcessHeaders, parseProcessList, } from '../../src/lib/live/index.mjs'; test('host process detection recognizes controllers and rejects helpers', () => { @@ -84,3 +84,34 @@ test('nonempty unparseable process output degrades instead of becoming a healthy execFileImpl: async () => ({ stdout: 'localized or malformed process output' }), }), (error) => error.code === 'ERR_RUNTIME_PROCESS_SURVEY'); }); + +test('runtime discovery scopes ps to the current UID and reads argv only for host candidates', async () => { + const calls = []; + const startedAt = 'Mon Aug 3 12:00:00 2026'; + const execFileImpl = async (command, args) => { + calls.push({ command, args }); + if (args.includes('pid=,ppid=,lstart=,comm=')) { + return { stdout: [ + `100 1 ${startedAt} node`, + `101 1 ${startedAt} ssh`, + `102 1 ${startedAt} claude`, + ].join('\n') }; + } + if (args.includes('pid=,args=')) { + return { stdout: '100 node /opt/bin/codex\n102 claude\n' }; + } + throw new Error(`unexpected command: ${command} ${args.join(' ')}`); + }; + const rows = parseProcessHeaders(`100 1 ${startedAt} node\n`); + assert.equal(rows[0].command, '', 'the first-stage parser retains no argv'); + + const sessions = await listActiveHostSessions({ + platform: 'linux', uid: 501, execFileImpl, + cwdByPid: new Map([[100, '/repos/a'], [102, '/repos/b']]), + inspectWorkspace: async () => null, + }); + assert.deepEqual(sessions.map((row) => row.host), ['codex', 'claude']); + assert.deepEqual(calls[0].args.slice(0, 4), ['-U', '501', '-x', '-o']); + assert.equal(calls[0].args.includes('-a'), false, 'never surveys all users'); + assert.equal(calls[1].args[1], '100,102', 'ssh/non-host PID never reaches the argv survey'); +}); diff --git a/tests/kit/output-progress.test.mjs b/tests/kit/output-progress.test.mjs index 604189e..f13fda4 100644 --- a/tests/kit/output-progress.test.mjs +++ b/tests/kit/output-progress.test.mjs @@ -7,7 +7,7 @@ // never touches the real stdout, so results don't depend on how tests are run. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { withProgress } from '../../src/lib/output.mjs'; +import { reportOutcome, withProgress } from '../../src/lib/output.mjs'; const sink = () => { const writes = []; @@ -84,3 +84,16 @@ test('labels are interpolated verbatim — including real callers with metachars await withProgress('providers (api)', async () => 'v', { tty: true, out }); assert.ok(out.writes[0].includes('providers (api)')); }); + +test('managed outcomes render degraded and failed states without green success', () => { + const lines = []; + const original = console.log; + console.log = (line) => lines.push(String(line)); + try { + assert.equal(reportOutcome('solver', { ok: true, status: 'degraded', detail: 'fallback' }), 'degraded'); + assert.equal(reportOutcome('brain', { ok: false, status: 'failed', detail: 'exit 1' }), 'failed'); + } finally { console.log = original; } + assert.match(lines[0], /⚠.*solver: fallback/); + assert.match(lines[1], /✗.*brain: exit 1/); + assert.doesNotMatch(lines.join('\n'), /✓/); +}); diff --git a/tests/kit/settings-config.test.mjs b/tests/kit/settings-config.test.mjs index df487ea..27ce0fc 100644 --- a/tests/kit/settings-config.test.mjs +++ b/tests/kit/settings-config.test.mjs @@ -45,6 +45,18 @@ test('a failure while serializing never touches the existing file (atomic swap, fs.rmSync(tmp, { recursive: true, force: true }); }); +test('an unusable backup path fails closed before settings are replaced', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-set-bak-fail-')); + const f = tmpFile(tmp, 'settings.json'); + fs.writeFileSync(f, '{"safe":true}\n'); + fs.mkdirSync(`${f}.bak`); + + assert.throws(() => writeJsonWithBackup(f, { safe: false }), /unusable backup path/); + assert.equal(fs.readFileSync(f, 'utf8'), '{"safe":true}\n'); + assert.deepEqual(fs.readdirSync(tmp).filter((name) => name.endsWith('.tmp')), []); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + test('addDenyRules dedupes, sorts, and reports only net-new rules', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'kit-deny-')); const f = tmpFile(tmp, 'settings.json'); diff --git a/tests/kit/setup-command.test.mjs b/tests/kit/setup-command.test.mjs index b324fec..a1172ee 100644 --- a/tests/kit/setup-command.test.mjs +++ b/tests/kit/setup-command.test.mjs @@ -56,12 +56,54 @@ test('run_project --dry-run announces the plan and touches neither home nor proj setup.run_project({ flags: FLAGS({ 'dry-run': true }), cfg: loadKitConfig() })); assert.equal(result, true); assert.match(out, /dry-run: would init, sanitize, pin DB path/); + for (const entry of setup.PROJECT_PERMISSION_MANIFEST) assert.ok(out.includes(entry.rule)); } finally { process.chdir(cwd); } assertUnchanged(beforeHome, HOME, 'run_project --dry-run must not touch HOME'); assertUnchanged(beforeProject, project, 'run_project --dry-run must not touch the project'); rmrf(project); }); +test('project permission manifest omits AQE grants when AQE is disabled', () => { + assert.equal(setup.projectPermissionManifest({ aqe: true }).length, 7); + assert.deepEqual(setup.projectPermissionManifest({ aqe: false }).map((entry) => entry.owner), + ['ruflo', 'ruflo', 'ruflo', 'ruflo']); +}); + +test('permission verification removes only newly introduced undisclosed grants', () => { + const project = sandboxProject('ak-setup-permissions'); + const file = path.join(project, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ permissions: { allow: [ + 'Bash(user-owned:*)', 'mcp__claude-flow__*', 'Bash(unexpected:*)', + ] } }, null, 2)); + const removed = setup.removeUndisclosedPermissions( + file, new Set(['Bash(user-owned:*)']), new Set(['mcp__claude-flow__*']), + ); + assert.deepEqual(removed, ['Bash(unexpected:*)']); + const allow = JSON.parse(fs.readFileSync(file, 'utf8')).permissions.allow; + assert.deepEqual(allow, ['Bash(user-owned:*)', 'mcp__claude-flow__*']); + rmrf(project); +}); + +test('declining the permission preflight stops before machine or project mutation', async () => { + seedHome(); + const project = sandboxProject('ak-setup-decline'); + const beforeHome = snapshot(HOME); + const beforeProject = snapshot(project); + const cwd = process.cwd(); + process.chdir(project); + try { + const { result, out } = await captureLog(() => setup.run({ + flags: FLAGS(), pkgRoot: PKG_ROOT, confirm: async () => false, + })); + assert.equal(result, 0); + assert.match(out, /setup cancelled before machine, user, or project changes/); + } finally { process.chdir(cwd); } + assertUnchanged(beforeHome, HOME, 'declined permission preflight must not touch HOME'); + assertUnchanged(beforeProject, project, 'declined permission preflight must not touch the project'); + rmrf(project); +}); + // Regression: `run()` saved kit.json unconditionally after run_machine, so // `ak setup --dry-run` CREATED ~/.config/agentic-kit/kit.json on a machine that // had never been set up — a filesystem change from a command that promises none. @@ -96,6 +138,21 @@ test('--dry-run reports what --codex/--primary-host WOULD do without enabling th assertUnchanged(before, HOME, '`ak setup --codex --dry-run` must not touch the filesystem'); }); +test('--codex project dry-run discloses registrations while preserving Codex policy', async () => { + seedHome(); + const project = sandboxProject('ak-setup-codex-trust'); + const cwd = process.cwd(); + process.chdir(project); + try { + const { result, out } = await captureLog(() => + setup.run({ flags: FLAGS({ 'dry-run': true, codex: true }), pkgRoot: PKG_ROOT })); + assert.equal(result, 0); + assert.match(out, /OpenAI Codex — approval\/sandbox policy unchanged/); + assert.match(out, /\[project\] mcp-registration: codex mcp-server/); + assert.match(out, /\[user\] mcp-registration: ruflo mcp start/); + } finally { process.chdir(cwd); rmrf(project); } +}); + test('--minimal skips project setup even inside a git repo', async () => { seedHome(); const project = sandboxProject('ak-setup-min'); @@ -223,12 +280,28 @@ test('ak setup --opencode --dry-run writes nothing anywhere (kit.json, opencode const { result, out } = await captureLog(() => setup.run({ flags: FLAGS({ 'dry-run': true, minimal: true, opencode: true }), pkgRoot: PKG_ROOT })); assert.equal(result, 0); + assert.match(out, /OpenCode — approval policy receives the listed grants/); + for (const pattern of ['claude-flow_*', 'claude_flow_*', 'ruvnet-brain_*', 'ruvnet_brain_*']) { + assert.ok(out.includes(`[user] auto-approve: ${pattern}`), `missing disclosure for ${pattern}`); + } assert.match(out, /dry-run: --opencode would enable the opencode host/); assert.equal(loadKitConfig().integrations.hosts.opencode, false, 'a previewed --opencode must not persist'); assert.ok(!fs.existsSync(ocHome()), 'no opencode config home fabricated by a dry run'); assertUnchanged(before, HOME, '`ak setup --opencode --dry-run` must not touch the filesystem'); }); +test('declining OpenCode trust stops a minimal setup before user-scope mutation', async () => { + seedHome(); + const before = snapshot(HOME); + const { result, out } = await captureLog(() => setup.run({ + flags: FLAGS({ minimal: true, opencode: true }), pkgRoot: PKG_ROOT, + confirm: async () => false, + })); + assert.equal(result, 0); + assert.match(out, /setup cancelled before machine, user, or project changes/); + assertUnchanged(before, HOME, 'declined OpenCode trust must not touch HOME'); +}); + test('ak setup --opencode fails honestly and deploys nothing when JSONC is refused', async () => { seedHome(); paths._setGlobalRootForTest(fakeGlobalRoot(HOME, { ruflo: '9.9.9', 'agentic-qe': '9.9.9' })); diff --git a/tests/kit/sqlite.test.mjs b/tests/kit/sqlite.test.mjs new file mode 100644 index 0000000..1c1f623 --- /dev/null +++ b/tests/kit/sqlite.test.mjs @@ -0,0 +1,59 @@ +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 { DatabaseSync } from 'node:sqlite'; +import { withDb } from '../../src/lib/sqlite.mjs'; + +test('withDb distinguishes an absent database from an empty result', () => { + const file = path.join(os.tmpdir(), `ak-sqlite-absent-${process.pid}-${Date.now()}.db`); + const result = withDb(file, () => []); + assert.equal(result.ok, false); + assert.equal(result.error.kind, 'absent'); + assert.equal(result.error.stage, 'open'); +}); + +test('withDb classifies corrupt input instead of returning the caller fallback', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-sqlite-corrupt-')); + const file = path.join(dir, 'store.db'); + fs.writeFileSync(file, 'not a sqlite database'); + const result = withDb(file, (db) => db.prepare('SELECT 1').get()); + assert.equal(result.ok, false); + assert.equal(result.error.kind, 'corrupt'); + assert.equal(result.error.stage, 'query'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('withDb classifies SQL/schema errors separately from a valid empty query', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-sqlite-query-')); + const file = path.join(dir, 'store.db'); + const seed = new DatabaseSync(file); + seed.exec('CREATE TABLE item (id INTEGER PRIMARY KEY);'); + seed.close(); + + const empty = withDb(file, (db) => db.prepare('SELECT * FROM item').all()); + assert.deepEqual(empty, { ok: true, value: [] }); + const bad = withDb(file, (db) => db.prepare('SELECT * FROM missing_table').all()); + assert.equal(bad.ok, false); + assert.equal(bad.error.kind, 'query'); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test('withDb classifies busy and close failures through its injectable driver seam', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-sqlite-driver-')); + const file = path.join(dir, 'store.db'); + fs.writeFileSync(file, 'fixture'); + class BusyDatabase { + constructor() { throw Object.assign(new Error('database is locked'), { errcode: 5 }); } + } + class CloseFailureDatabase { + close() { throw Object.assign(new Error('close failed'), { code: 'EIO' }); } + } + const busy = withDb(file, () => null, { Database: BusyDatabase }); + assert.equal(busy.error.kind, 'busy'); + const close = withDb(file, () => 1, { Database: CloseFailureDatabase }); + assert.equal(close.error.kind, 'close'); + assert.equal(close.error.stage, 'close'); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/tests/kit/trust-manifest.test.mjs b/tests/kit/trust-manifest.test.mjs new file mode 100644 index 0000000..410c346 --- /dev/null +++ b/tests/kit/trust-manifest.test.mjs @@ -0,0 +1,69 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { HOST_REGISTRY, validateHostAdapter } from '../../src/lib/adapters/index.mjs'; +import { + setupTrustManifest, trustManifestForOperation, newlyEnabledHostTrustManifest, + autoApproveValues, +} from '../../src/lib/trust-manifest.mjs'; +import { validHost } from './helpers/integration-builders.mjs'; + +test('every host adapter must declare an explicit setup trust posture', () => { + assert.throws(() => validateHostAdapter(validHost({ trust: undefined })), + /host\.trust must be an object/); + assert.throws(() => validateHostAdapter(validHost({ trust: { + approvalPolicy: 'managed', + changes: [{ + id: 'bad-change', kind: 'auto-approve', scope: 'user', owner: 'test', + value: 'test_*', effect: 'test effect', operations: ['setup'], + features: ['unknown-feature'], + }], + } })), /host\.trust\.changes\[0\]\.features must be one of/); +}); + +test('host-pick preflight includes only newly enabled hosts and its own operations', () => { + const cfg = { + integrations: { hosts: { claude: true, codex: false, opencode: false } }, + aqe: true, ruvnetBrain: true, + }; + const first = newlyEnabledHostTrustManifest(cfg, ['claude', 'codex', 'opencode']); + assert.deepEqual(first.map((group) => group.hostId), ['codex', 'opencode']); + assert.equal(first.find((group) => group.hostId === 'codex').changes + .some((change) => change.value === 'aqe init --with-codex'), false, + 'host pick must not disclose a setup-only AQE action it does not run'); + assert.equal(newlyEnabledHostTrustManifest(cfg, ['claude']).length, 0, + 'an already accepted host must not prompt again'); +}); + +test('built-in hosts distinguish managed approval from unchanged host policy', () => { + const byId = Object.fromEntries(HOST_REGISTRY.map((host) => [host.id, host])); + assert.equal(byId.claude.trust.approvalPolicy, 'managed'); + assert.equal(byId.opencode.trust.approvalPolicy, 'managed'); + assert.equal(byId.codex.trust.approvalPolicy, 'unchanged'); + assert.deepEqual(autoApproveValues('opencode'), [ + 'claude-flow_*', 'claude_flow_*', 'ruvnet-brain_*', 'ruvnet_brain_*', + ]); +}); + +test('a future enabled host joins setup disclosure without a setup command branch', () => { + const future = { + id: 'grok', label: 'Grok CLI', + trust: { + approvalPolicy: 'unchanged', + changes: [{ + id: 'grok-ruflo-mcp', kind: 'mcp-registration', scope: 'user', + owner: 'agentic-kit', value: 'ruflo mcp start', + effect: 'register Ruflo in Grok', operations: ['setup', 'host-pick'], + }], + }, + }; + const manifest = setupTrustManifest({ + integrations: { hosts: { grok: true } }, aqe: true, ruvnetBrain: true, + }, { hosts: [future] }); + assert.equal(manifest.length, 1); + assert.equal(manifest[0].hostId, 'grok'); + assert.equal(manifest[0].changes[0].value, 'ruflo mcp start'); + const picked = trustManifestForOperation({ + integrations: { hosts: { grok: true } }, aqe: true, ruvnetBrain: true, + }, { hosts: [future], operation: 'host-pick' }); + assert.equal(picked[0].hostId, 'grok'); +}); diff --git a/tests/kit/usage-index-opencode.test.mjs b/tests/kit/usage-index-opencode.test.mjs index 129e2c9..c1fc2d8 100644 --- a/tests/kit/usage-index-opencode.test.mjs +++ b/tests/kit/usage-index-opencode.test.mjs @@ -152,6 +152,26 @@ test('the incremental cache: a warm scan reuses unchanged sessions and picks up rm(sb.dir); }); +test('a corrupt OpenCode store preserves last-good usage and surfaces degraded source health', async () => { + const at = NOW - DAY; + const sb = sandbox({ + sessions: [{ id: 'ses_last_good', directory: '/x', title: 'last good', timeCreated: at }], + messages: [userMsg('u1', 'ses_last_good', at), assistantMsg('a1', 'ses_last_good', at + 1000, { cost: 0.4 })], + }); + const first = await buildIndex(opts(sb)); + assert.equal(first.sourceHealth.opencode.status, 'ok'); + assert.equal(first.sessions.find((x) => x.id === 'ses_last_good').cost, 0.4); + + fs.rmSync(sb.dbFile); + fs.writeFileSync(sb.dbFile, 'not a sqlite database'); + _resetForTest(); + const degraded = await buildIndex(opts(sb)); + assert.deepEqual(degraded.sourceHealth.opencode, { status: 'degraded', reason: 'corrupt' }); + assert.equal(degraded.sessions.find((x) => x.id === 'ses_last_good').cost, 0.4, + 'a transient source failure must not become an observed zero'); + rm(sb.dir); +}); + test('overridden roots WITHOUT an opencode key never read any opencode store (hermeticity)', async () => { const at = NOW - DAY; const sb = sandbox({ diff --git a/tests/statusline-segments.test.cjs b/tests/statusline-segments.test.cjs index f670397..7f62eb0 100644 --- a/tests/statusline-segments.test.cjs +++ b/tests/statusline-segments.test.cjs @@ -40,7 +40,7 @@ try { // The security overlay ships in the same block (it must: the strip regex in // statusline.mjs is non-global, so a second ruflo-seg block would leak on re-injection). -let rufloLocalSecurity, rufloHonestInsight, rufloAidefenceState; +let rufloLocalSecurity, rufloHonestInsight, rufloAidefenceState, rufloStatuslineDebug; try { // eslint-disable-next-line no-eval rufloLocalSecurity = eval('(function(){' + block + '\nreturn rufloLocalSecurity;})()'); @@ -48,6 +48,8 @@ try { rufloHonestInsight = eval('(function(){' + block + '\nreturn rufloHonestInsight;})()'); // eslint-disable-next-line no-eval rufloAidefenceState = eval('(function(){' + block + '\nreturn rufloAidefenceState;})()'); + // eslint-disable-next-line no-eval + rufloStatuslineDebug = eval('(function(){' + block + '\nreturn rufloStatuslineDebug;})()'); } catch (e) { console.error('FATAL: could not extract security overlay fns:', e.message); process.exit(2); @@ -174,6 +176,46 @@ test('proof segment absent on malformed improvement.json (no crash)', () => { absent(out, 'proof'); }); +test('statusline debug is opt-in, redacted, bounded to a stable stage, and mode 0600', () => { + const dir = mkFixture({ + '.claude-flow/improvement.json': '{ this contains SECRET_VALUE and is not json', + }); + const log = path.join(dir, 'statusline-debug.log'); + const before = process.env.AK_STATUSLINE_DEBUG; + const beforeFile = process.env.AK_STATUSLINE_DEBUG_FILE; + try { + delete process.env.AK_STATUSLINE_DEBUG; + process.env.AK_STATUSLINE_DEBUG_FILE = log; + rufloActivationSegments(dir); + assert(!fs.existsSync(log), 'debug-off must not write a diagnostic log'); + + process.env.AK_STATUSLINE_DEBUG = '1'; + rufloActivationSegments(dir); + const diagnostic = fs.readFileSync(log, 'utf8'); + contains(diagnostic, 'stage=proof-verdict'); + contains(diagnostic, 'name=SyntaxError'); + absent(diagnostic, 'SECRET_VALUE'); + if (process.platform !== 'win32') assert((fs.statSync(log).mode & 0o777) === 0o600, 'debug log must be owner-only'); + } finally { + if (before === undefined) delete process.env.AK_STATUSLINE_DEBUG; else process.env.AK_STATUSLINE_DEBUG = before; + if (beforeFile === undefined) delete process.env.AK_STATUSLINE_DEBUG_FILE; else process.env.AK_STATUSLINE_DEBUG_FILE = beforeFile; + } +}); + +test('an unwritable statusline debug sink never changes fail-to-blank rendering', () => { + const dir = mkFixture({}); + const before = process.env.AK_STATUSLINE_DEBUG; + const beforeFile = process.env.AK_STATUSLINE_DEBUG_FILE; + try { + process.env.AK_STATUSLINE_DEBUG = '1'; + process.env.AK_STATUSLINE_DEBUG_FILE = dir; // appendFileSync on a directory fails + rufloStatuslineDebug('fixture', new Error('private payload')); + } finally { + if (before === undefined) delete process.env.AK_STATUSLINE_DEBUG; else process.env.AK_STATUSLINE_DEBUG = before; + if (beforeFile === undefined) delete process.env.AK_STATUSLINE_DEBUG_FILE; else process.env.AK_STATUSLINE_DEBUG_FILE = beforeFile; + } +}); + test('proof segment absent on invalid verdict value', () => { const out = strip(rufloActivationSegments(mkFixture({ '.claude-flow/improvement.json': { verdict: 'MAYBE', deltaPP: 1 }, @@ -479,7 +521,7 @@ test('unresolvable ruflo → silent (a probe miss must never fail loud and wrong // Test-quality Finding 5: bump deliberately when adding/removing a test — // see admin-model.test.cjs's identical guard for the full rationale. -const EXPECTED = 43; +const EXPECTED = 45; if (passed + failed !== EXPECTED) { console.error(`\nPLAN MISMATCH: expected ${EXPECTED} tests, ran ${passed + failed}`); process.exit(1);