diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 287b29d..fe9a726 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,12 +29,29 @@ jobs: - run: npm test - run: npm run pack + windows-kimi: + runs-on: windows-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node: [22, 26] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run test:kimi + - run: npm run pack + # A release is a tag vX.Y.Z a maintainer pushes on main; nothing on main names a version. The tag's version is # stamped into every packed manifest, and the release branch, which the hosts install from (Claude Code, WorkBuddy, # Grok and Qoder through their marketplaces, Codex at --ref, Antigravity, OpenCode and DeepSeek Harness from a # clone), moves one commit on, with the tag re-pointed there. A push made with this workflow's token starts no run. publish: - needs: test + needs: [test, windows-kimi] if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest timeout-minutes: 10 @@ -50,6 +67,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 + - run: npm ci - run: node scripts/pack.mjs all dist "--version=$GITHUB_REF_NAME" - run: scripts/publish-tag.sh "$GITHUB_REF_NAME" release env: diff --git a/README.md b/README.md index 27f5f99..8bbdd0d 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ The plugin is the access layer: shared logic lives in `lib/`, and each host is a | DeepSeek Harness (dsh) | Supported | `hosts/dsh`, an in-process cordis plugin; installs `dist/dsh` | | Grok CLI 1.0.70+ | Supported | `hosts/grok`, command hooks; the strategy reaches the model at the turn's first tool result; installs `dist/grok` | | Qoder CLI (`qodercli`) 1.1.79+ | Supported | `hosts/qoder`, command hooks; installs `dist/qoder` | +| Kimi Code CLI 2.4.1 and Desktop 1.2.1 | Direct install; Windows native checked | `hosts/kimi`, native hooks and isolated ACP distillation; installs `dist/kimi` | | Muse Code | Not yet | No reviewed adapter yet | | OpenAI Dots, Claude Duet, Grok Bot, Meta Muse | Not yet | Cloud agents without hooks; need Evolver's remote MCP and routines | @@ -135,6 +136,16 @@ injections, undelivered reports), so nothing is lost on the way over. prompt box (`evolver-status`, with or without its `/`) and answered as a blocked prompt; `/evolver-evolve` is a Grok command. Each release tag stamps its version into the packed folder; `grok plugin update evolver-plugin` fetches it. - **Cursor:** add this repository as a plugin marketplace and install `evolver-plugin`; it installs the repo itself. +- **Kimi Code on Windows:** close Kimi, run `node scripts/install-kimi.mjs install` from a checkout, then start a new CLI or Desktop session. Keep the Kimi CLI installed for draft creation. + `--home PATH` selects the profile root; use the same absolute `KIMI_CODE_HOME` when starting Kimi. Close Kimi before + install/update/remove. The installer stages `dist/kimi`, registers its native `kimi.plugin.json` bundle and + preserves account config, other plugins, preferences and drafts. A release bundle keeps its bytes and version; + `--version X.Y.Z` explicitly builds current sources. `remove` unregisters the plugin while retaining managed bytes. + V1 targets the main agent in local CLI and Desktop sessions on Windows with an API/BYOK provider. Commands run from the terminal: + `node /plugins/managed/evolver-plugin/bin/command.mjs kimi evolver-status`. + Other OAuth regions, web/VS Code, subagents and parallel prompts have separate acceptance gates. This local installer + does not establish support in the external Evolver CLI installer or a Marketplace. + [The Kimi adapter guide](hosts/kimi/dist/README.md) explains evidence, route identity and isolation. - **Antigravity:** `agy plugin uninstall evolver-antigravity-plugin`, then from the `release` branch, `git clone --depth 1 --branch release https://github.com/EvoMap/evolver-agent-plugin.git` and `agy plugin install evolver-agent-plugin/dist/antigravity`. @@ -203,6 +214,11 @@ npm run pack claude --plugin-dir dist/claude-code ``` +Packing follows each entry's module imports. The shared TOML reader vendors only the pinned +`smol-toml@1.9.1` ESM closure and its BSD-3-Clause license under `lib/vendor/`, rewriting its static import to a +relative file. Packed hosts load without an npm install; optional MCP SDK and engine packages are not copied into +those bundles. `npm ci` is required before packing source so the pinned dependency is available. + ## License MIT © EvoMap. diff --git a/bin/worker.mjs b/bin/worker.mjs index 45d75bc..61ca760 100644 --- a/bin/worker.mjs +++ b/bin/worker.mjs @@ -5,4 +5,4 @@ import { runWorker } from '../lib/channels/worker/runner.js'; const [agent, ...tasks] = process.argv.slice(2); -process.exit((await runWorker(agent, tasks)) ? 0 : 1); +process.exitCode = (await runWorker(agent, tasks)) ? 0 : 1; diff --git a/docs/architecture.md b/docs/architecture.md index fbeb64c..d21fb34 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -276,6 +276,40 @@ scripts/ pack.mjs, publish-tag.sh a resumed session continues under the same id, and a repeated task id is one the Hub drops as a duplicate. - **Subagents** get no recall; their checks and edits count toward the main turn. +## Indexed native sessions and isolated ACP completion + +Kimi Code's profile uses wire protocol 1.5 for CLI 2.4.1 and Desktop 1.2.1 on Windows. +Both local clients use the same Kimi profile and managed plugin. It declares the native workspace/session +layout; `runtime/native-facts` owns bounded filesystem reads and refuses linked, replaced, malformed or partial +records. The driver opens a logical turn before the host admits its native turn. `app/native-turn` binds the two +using the captured byte/file boundary, then closes only the matched main-user terminal. `Stop` is an observer +hint. A detached native monitor catches terminal writes after the last hook; recovery reads those records before +orphan abort reporting. Turn number and owner guards prevent a late observer from closing a newer pending turn. +Background checks remain unknown until matching native task metadata settles them. Other hosts retain their +existing transcript/tool-result adapters; Kimi subagents and concurrent prompts are outside this v1 contract. + +The optional ModelRunner `capture` port records the selected alias, provider, upstream model, effort and a +credential/config binding before recall returns. The `acp` runner revalidates that binding before creating one +private session and sending one prompt. It uses a private HOME and a selected-only TOML config; the credential is +passed in an environment variable. The private agent has no tools/subagents, project hooks, user plugins or MCP +servers. ACP success, the native/provider terminal, exact route and native process closure must agree. A separate +IPC guardian handles cancellation and owner death before removing owned scratch. On Windows the system +PowerShell helper assigns the native process to a non-inheritable kill-on-close Job Object at creation, with +only its stdio handles inherited. The helper terminates remaining job members and proves the job is empty +after native exit or stop. It reports proof separately from native output. Scratch with no proof is kept; +the job does not contain processes created by an external broker outside the descendant tree. + +An unavailable/changed route is a typed preflight block, preserving the candidate without spending a model +attempt or expiring it as an abandoned draft. Ordinary model/protocol failures keep the existing retry policy. +The `kimi.com` managed route reads the current access token and device identity from the exact native profile. +It binds both to the snapshot, preserves the native provider family separately from its transport, and passes +only the access token in the private child's environment. It does not manage login or refresh. +Draft publication still uses the shared explicit command and Proxy contract. Other regions, clients and native +acceptance on each target OS are separate gates, not inferred from fixture tests. + +Packing vendors only the pinned `smol-toml` ESM closure and its BSD license, rewriting that runtime import to a +relative path. Installed bundles need no npm install for TOML; source packing and release CI run `npm ci` first. + ## Claude Code engine behaviour found while testing - A `hooks.json` that lists `modules` still runs its command `hooks`. @@ -299,6 +333,7 @@ scripts/ pack.mjs, publish-tag.sh ```bash npm ci npm test +npm run test:kimi npx -p typescript tsc -p . npm run validate ``` @@ -309,6 +344,11 @@ update. `npm run validate` packs `dist/claude-code` and runs `claude plugin vali version: bump `version` in a host's manifest with every change meant to reach its users, or the host reports the old version as the latest. +Release CI runs the full suite on Linux and separate Kimi packed-hook and shared runtime tests on Windows, +each on Node 22 and 26. All three jobs must pass before publication. Host tests use disposable profiles and local +service fixtures; they require no developer account, Hub token or globally installed native CLI. Windows suites +run up to four test files at once; their multi-process contention tests retain their own parallel workers. + ## Open questions - Whether `@evomap/evolver-core`'s terms allow redistributing a plugin that imports it. diff --git a/hosts/README.md b/hosts/README.md index e462c87..aefe77a 100644 --- a/hosts/README.md +++ b/hosts/README.md @@ -48,6 +48,23 @@ From each host's reviewed standalone plugin; bold is where it differs from the d ## Adding a host +Kimi Code's local adapter targets CLI 2.4.1 and Desktop 1.2.1 on Windows for the main agent in local sessions, using API/BYOK or an existing `kimi.com` +managed access token. Its native +root manifest is `kimi.plugin.json`; `scripts/install-kimi.mjs` stages a versioned package under the selected +`KIMI_CODE_HOME` and changes only its owned native registration. Native config, credentials and Evolver state +(`KIMI_CODE_HOME/evolver`) stay outside the package; unregistering retains managed bytes. Other client types, +subagents, parallel prompts and other OAuth regions require separate native acceptance. + +Kimi's `UserPromptSubmit` returns `{ message }` to the model. Tool hooks observe native evidence and never promise +inline failed-check context. Stop is a hint rather than a terminal verdict: wire/task records reconcile lifecycle +and verification, with dead-owner recovery when a session has no end event. Session ids include the native-home +scope. Commands use `node /bin/command.mjs kimi ` from a terminal, without prompt interception. +Distillation captures provider/model/account identity from the native session and uses a private ACP profile with +no user/project hooks, plugins, MCP or skills; missing or changed route identity keeps the candidate for retry. +Windows packed hooks have passed with a loopback fixture provider/Proxy, including native turn-zero evidence, +process ownership and normal cleanup. macOS packed acceptance and real-account acceptance are separate gates; see +[`kimi/dist/README.md`](kimi/dist/README.md). + 1. `hosts//profile.js`: `export default defineProfile({ name, label, channel, events, evidence, … })` with only the points that differ; readers that need more than a line go in sibling files. 2. `hosts//dist/`: the host's hook registration calling `node bin/hook.mjs ` (or its MCP diff --git a/hosts/kimi/dist/README.md b/hosts/kimi/dist/README.md new file mode 100644 index 0000000..e71614b --- /dev/null +++ b/hosts/kimi/dist/README.md @@ -0,0 +1,92 @@ +# Evolver for Kimi Code + +The same package integrates Kimi Code CLI 2.4.1 and Desktop 1.2.1 on Windows. +Keep the Kimi Code CLI installed for isolated ACP draft creation. Node.js 22.13+ +must be available as `node` on the hook process PATH. + +The integration covers the main agent in local sessions with a resolved +API/BYOK provider. The adapter can also resolve the managed Kimi Code account on `kimi.com`. The +managed route uses an existing, fresh access token and the device identity from +the same native profile. Kimi handles sign-in and refresh. Other OAuth regions, +VS Code/web, remote sessions, parallel prompts and subagents require +separate acceptance. + +The prompt hook adds recalled context. Tool hooks observe evidence; they do not add +failed-check recall mid-turn. Stop is a lifecycle hint, not proof that a turn has +completed. Native wire/task records supply terminal and verification evidence. + +## Local installation + +From the repository checkout or an extracted release containing this installer: + +```text +node scripts/install-kimi.mjs install +node scripts/install-kimi.mjs status +node scripts/install-kimi.mjs update +node scripts/install-kimi.mjs remove +``` + +`--home PATH` selects the native Kimi profile root; otherwise the installer uses +`KIMI_CODE_HOME`, then `~/.kimi-code`. Set `KIMI_CODE_HOME` to the same root when +starting Kimi, using an absolute path. Relative roots have not passed acceptance; +native plugin hooks run from the package root rather than the startup directory. +Close Kimi before changing its local plugin registrations, then start +a new session to load the package. Do not install a second copy of Evolver under a +different plugin id or retain a manual duplicate hook registration. + +The managed package lives at `/plugins/managed/evolver-plugin`. The +installer preserves other plugin registrations, native config, credentials and +Evolver state. Removal unregisters this package while retaining its managed bytes, +matching native local-plugin removal; it does not delete preferences or drafts. +The default install/update preserves a bundled `dist/kimi` release and its version. +An explicit `--version X.Y.Z` packs the checkout at that version instead. +Packing source requires `npm ci` in that checkout; installing an existing release +bundle does not download dependencies or change the native account configuration. +The native manifest/registry contract is pinned to Kimi Code 2.4.1 and should be +retested when upgrading Kimi. This direct installation says nothing about +Marketplace eligibility or support by an external Evolver CLI installer. + +## Terminal commands + +Run from the project directory, using the installed package's absolute entry path. +In Desktop you can run these commands in its terminal. Use `evolver-distill` to +create a draft while the Desktop session remains open. + +```text +node /plugins/managed/evolver-plugin/bin/command.mjs kimi evolver-status +node /plugins/managed/evolver-plugin/bin/command.mjs kimi evolver-drafts +node /plugins/managed/evolver-plugin/bin/command.mjs kimi evolver-distill +``` + +The shared command catalog provides search, login, draft publication/discard and +preferences too. Commands run directly in the terminal; chat command interception +is not part of this host's v1 contract. Publishing a draft remains an explicit user +operation. Kimi's provider account and Evolver's Proxy/Hub account are separate. + +Private distillation profiles exclude user/project hooks, plugins, MCP servers and +skills. The selected credential goes to the native process through its environment. +For `kimi.com` OAuth, the private profile contains the selected provider, model and +device ID; it has no OAuth token file or refresh credential. Expired or changed +credentials keep the candidate blocked. A new verified turn can capture a fresh +binding after Kimi has renewed its access token. + +On Windows, ACP completion uses a Job Object with kill-on-close. It starts the +native CLI inside the job and waits for the job to empty before deleting scratch, +including when a descendant outlives the native root. This needs Windows 10 or +later and the system Windows PowerShell to compile the bundled Win32 helper. +When proof of an empty job is lost, scratch is retained. The job contains ordinary +process descendants; it is not a sandbox for processes started by an external broker. + +## Validation scope + +`npm run test:kimi` covers source fixtures, packed hooks with a loopback fake Proxy, +installer rollback and route/isolation checks. CI runs those source tests on Windows +Node 22/26 and the full suite on Linux Node 22/26. They use no real credentials or +model, Proxy or Hub service. The Windows native packed main-CLI flow passed with a +loopback dummy provider/Proxy: recalled context reached the real model request, +native edits/checks produced one candidate/reuse report, the owner matched Kimi and +workers exited. Loopback results do not establish real provider entitlement. +Windows Desktop packed acceptance used the native app with a loopback provider and +Proxy. It verified Recipe injection, a passing Bash check, and one private draft +through the installed CLI's isolated ACP completion. macOS, real Desktop provider +acceptance, and other clients are separate gates. diff --git a/hosts/kimi/dist/kimi.plugin.json b/hosts/kimi/dist/kimi.plugin.json new file mode 100644 index 0000000..7797d76 --- /dev/null +++ b/hosts/kimi/dist/kimi.plugin.json @@ -0,0 +1,21 @@ +{ + "name": "evolver-plugin", + "version": "1.0.0", + "description": "Evolver recall, evidence-backed reuse reports and verified-turn drafts for Kimi Code.", + "repository": "https://github.com/EvoMap/evolver-agent-plugin", + "license": "MIT", + "hooks": [ + { "event": "SessionStart", "command": "node bin/hook.mjs kimi SessionStart", "timeout": 3 }, + { "event": "UserPromptSubmit", "command": "node bin/hook.mjs kimi UserPromptSubmit", "timeout": 10 }, + { "event": "TurnStarted", "command": "node bin/hook.mjs kimi TurnStarted", "timeout": 3 }, + { "event": "PreToolUse", "command": "node bin/hook.mjs kimi PreToolUse", "timeout": 3 }, + { "event": "PostToolUse", "command": "node bin/hook.mjs kimi PostToolUse", "timeout": 3 }, + { "event": "PostToolUseFailure", "command": "node bin/hook.mjs kimi PostToolUseFailure", "timeout": 3 }, + { "event": "Stop", "command": "node bin/hook.mjs kimi Stop", "timeout": 3 }, + { "event": "StopFailure", "command": "node bin/hook.mjs kimi StopFailure", "timeout": 3 }, + { "event": "Interrupt", "command": "node bin/hook.mjs kimi Interrupt", "timeout": 3 }, + { "event": "SessionEnd", "command": "node bin/hook.mjs kimi SessionEnd", "timeout": 3 }, + { "event": "TaskStarted", "command": "node bin/hook.mjs kimi TaskStarted", "timeout": 3 }, + { "event": "Notification", "command": "node bin/hook.mjs kimi Notification", "timeout": 3 } + ] +} diff --git a/hosts/kimi/dist/pack.json b/hosts/kimi/dist/pack.json new file mode 100644 index 0000000..6872801 --- /dev/null +++ b/hosts/kimi/dist/pack.json @@ -0,0 +1,8 @@ +{ + "entries": ["bin/hook.mjs", "bin/worker.mjs", "bin/command.mjs"], + "root": { + "kimi.plugin.json": "hosts/kimi/dist/kimi.plugin.json", + "README.md": "hosts/kimi/dist/README.md", + "LICENSE": "LICENSE" + } +} diff --git a/hosts/kimi/evidence.js b/hosts/kimi/evidence.js new file mode 100644 index 0000000..55320bd --- /dev/null +++ b/hosts/kimi/evidence.js @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { isAbsolute, resolve } from 'node:path'; + +const factsOf = context => context?.nativeFacts?.status === 'ok' && context.nativeFacts.schemaSupported ? context.nativeFacts : null; + +export function editedFilesOf(payload, context = {}) { + const facts = factsOf(context); + if (payload.hook_event_name !== 'PreToolUse' || !['Write', 'Edit'].includes(payload.tool_name) || facts?.toolTurnId == null) return []; + const file = payload.tool_input?.path; + if (typeof file !== 'string' || !file || file.includes('\0')) return []; + return isAbsolute(file) ? [file] : typeof payload.cwd === 'string' && isAbsolute(payload.cwd) ? [resolve(payload.cwd, file)] : []; +} + +export const kimiEvidence = Object.freeze({ + // Kimi truncates hook tool_output. Only correlated native wire/task results can settle evidence. + toolNote: () => null, + editedFiles: editedFilesOf, + turnStart: (payload, context = {}) => context.nativeFacts?.mark ?? null, + turnRecord: (payload, context = {}) => factsOf(context)?.record ?? null, +}); diff --git a/hosts/kimi/model.js b/hosts/kimi/model.js new file mode 100644 index 0000000..2c4bf86 --- /dev/null +++ b/hosts/kimi/model.js @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { kimiHomeOf } from './paths.js'; + +const text = value => typeof value === 'string' && value.trim() ? value : null; +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const own = (value, key) => object(value) && Object.hasOwn(value, key) ? value[key] : null; +const only = (value, keys) => Object.keys(value).every(key => keys.includes(key)); +const strings = value => Array.isArray(value) && value.every(item => typeof item === 'string' && item.length <= 100); +const providerKeys = ['type', 'base_url', 'api_key', 'api_key_env', 'oauth']; +const modelKeys = ['provider', 'model', 'protocol', 'display_name', 'max_context_size', 'max_output_size', 'capabilities', 'adaptive_thinking', 'support_efforts', 'default_effort']; +const supportsThinking = model => model.adaptive_thinking === true || model.capabilities.includes('thinking') || model.capabilities.includes('always_thinking'); +const protocols = ['openai', 'openai_responses', 'anthropic']; +const managedBase = 'https://api.kimi.com/coding/v1'; +const managedAuth = 'https://auth.kimi.com'; +const matches = (value, expected) => value === undefined || text(value)?.trim().replace(/\/+$/, '') === expected; + +function managedAccess(provider, providerId, endpoint, env) { + const ref = provider.oauth; + if (provider.type !== 'kimi' || providerId !== 'managed:kimi-code' || endpoint !== managedBase + || text(provider.api_key) || text(provider.api_key_env) || !object(ref) || !only(ref, ['storage', 'key', 'oauth_host']) + || ref.storage !== 'file' || ref.key !== 'oauth/kimi-code' || !matches(ref.oauth_host, managedAuth) + || !matches(env.KIMI_CODE_BASE_URL, managedBase) || !matches(env.KIMI_CODE_OAUTH_HOST, managedAuth) || !matches(env.KIMI_OAUTH_HOST, managedAuth)) return null; + const home = kimiHomeOf(env); + return { kind: 'oauth-access', tokenFile: join(home, 'credentials', 'kimi-code.json'), deviceIdFile: join(home, 'device_id') }; +} + +/** Keep the selected account and transport; never fall back to another credential or endpoint. */ +export function selectRoute(config, hint, env = {}) { + const alias = text(hint?.alias); + const model = own(config?.models, alias); + const providerId = text(model?.provider); + const provider = own(config?.providers, providerId); + if (!alias || !object(model) || !object(provider) || !only(model, modelKeys) || !only(provider, providerKeys)) return null; + if ([provider.api_key, provider.api_key_env].some(value => value !== undefined && typeof value !== 'string')) return null; + const protocol = model.protocol ?? (provider.type === 'kimi' ? 'openai' : provider.type); + if (!text(model.model) || !protocols.includes(protocol) || (provider.type !== 'kimi' && protocol !== provider.type)) return null; + if (!Number.isSafeInteger(model.max_context_size) || model.max_context_size < 1 || !strings(model.capabilities)) return null; + if (model.max_output_size !== undefined && (!Number.isSafeInteger(model.max_output_size) || model.max_output_size < 1)) return null; + if (model.adaptive_thinking !== undefined && typeof model.adaptive_thinking !== 'boolean') return null; + if (model.support_efforts !== undefined && !strings(model.support_efforts)) return null; + let endpoint; + try { + const url = new URL(provider.base_url); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) return null; + endpoint = url.href.replace(/\/$/, ''); + } catch { return null; } + let credential; + if (provider.oauth !== undefined) { + credential = managedAccess(provider, providerId, endpoint, env); + if (!credential) return null; + } else { + const key = text(provider.api_key), keyEnv = text(provider.api_key_env); + if (Boolean(key) === Boolean(keyEnv) || (keyEnv && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(keyEnv))) return null; + const value = key ?? text(env[keyEnv]); + if (!value || /[\x00-\x1f\x7f]/.test(value)) return null; + credential = { kind: 'api-key', ...(keyEnv ? { env: keyEnv } : { value }) }; + } + const thinking = supportsThinking(model); + const effort = text(hint?.effort) ?? (thinking ? null : 'off'); + // Native models without named levels expose the binary thinking toggle. + const enabledEfforts = model.support_efforts?.length ? model.support_efforts : ['on']; + if (effort !== 'off' && (!thinking || !enabledEfforts.includes(effort))) return null; + const route = { alias, providerId, model: model.model, protocol, endpoint, effort, ...(provider.type === 'kimi' && { providerType: 'kimi' }) }; + const { display_name, ...privateModel } = model; + return { route, credential, model: privateModel }; +} + +export function prepareRoute(selected, { home, credentialEnv }) { + const { route, model } = selected; + const privateModel = { ...model }; + delete privateModel.default_effort; + if (route.effort !== 'off') privateModel.default_effort = route.effort; + const config = { + default_model: route.alias, telemetry: false, auto_session_title: false, + builtin_product_skills: false, merge_all_available_skills: false, + providers: { [route.providerId]: { type: route.providerType ?? route.protocol, base_url: route.endpoint, api_key_env: credentialEnv } }, + models: { [route.alias]: privateModel }, + loop_control: { max_steps_per_turn: 1, max_attempts_per_step: 1, reserved_context_size: 1024 }, + task: { keep_alive_on_exit: false }, + }; + return { + files: { + 'kimi-home/config.toml': { format: 'toml', value: config }, + 'kimi-home/agents/agent.md': { format: 'text', value: '---\nname: agent\ndescription: Isolated Evolver distiller\noverride: true\ntools: []\nsubagents: []\n---\nYou are a text-only distiller. Return only the requested structured response.\n' }, + ...(selected.deviceId && { 'kimi-home/device_id': { format: 'text', value: selected.deviceId } }), + }, + env: { KIMI_CODE_HOME: join(home, 'kimi-home'), KIMI_CODE_NO_AUTO_UPDATE: '1', KIMI_DISABLE_TELEMETRY: '1', KIMI_DISABLE_CRON: '1' }, + }; +} + +/** ACP's end_turn alone is insufficient: cross-check the owned private session's native evidence. */ +export function completionFacts(records = []) { + const main = records.filter(record => record.agentId === 'main'); + const prompts = new Set(main.filter(record => record.type === 'turn.prompt').map(record => `${record.turnId}:${record.promptId}`)); + const ended = main.filter(record => record.type === 'turn.ended'); + const terminal = ended.at(-1); + const requests = main.filter(record => record.type === 'llm.request'); + const request = requests.at(-1); + const routes = new Set(requests.map(record => JSON.stringify([record.modelAlias, record.model, record.provider, record.thinkingEffort]))); + const steps = main.filter(record => record.type === 'context.append_loop_event' && record.event?.type === 'step.end' && String(record.event.turnId) === String(terminal?.turnId)); + const step = steps.at(-1)?.event; + const snapshots = records.filter(record => record.type === 'llm.tools_snapshot'); + const usage = step?.usage; + const validUsage = usage && ['inputOther', 'inputCacheRead', 'inputCacheCreation', 'output'].every(key => Number.isFinite(usage[key]) && usage[key] >= 0); + const error = records.find(record => record.type === 'metadata')?.protocol_version !== '1.5' + || prompts.size !== 1 || routes.size !== 1 || !terminal || Boolean(terminal.error) + || main.some(record => record.type === 'turn.prompt' && (String(record.turnId) !== String(terminal.turnId) || record.origin?.kind !== 'user')) + || snapshots.some(record => record.agentId !== 'main' || !Array.isArray(record.tools)) + || records.some(record => record.type === 'llm.request' && record.agentId !== 'main'); + return { + reason: terminal?.reason ?? null, error, + providerFinish: step?.finishReason === 'end_turn' ? step.providerFinishReason ?? null : null, + model: request?.model ?? null, alias: request?.modelAlias ?? null, protocol: request?.provider ?? null, + thinkingEffort: request?.thinkingEffort ?? null, + toolSnapshots: snapshots.map(record => Array.isArray(record.tools) ? record.tools.length : -1), + toolCalls: records.filter(record => record.type === 'context.append_loop_event' && record.event?.type === 'tool.call').length, + ...(validUsage && { usage: { inputTokens: usage.inputOther + usage.inputCacheRead + usage.inputCacheCreation, outputTokens: usage.output } }), + }; +} + +export const headlessKimi = Object.freeze({ + kind: 'acp', + command: { names: ['kimi', 'kimi.exe'], dirs: env => [join(kimiHomeOf(env), 'bin'), join(env.HOME || env.USERPROFILE || homedir(), '.local', 'bin')] }, + args: () => ['acp'], + config: { source: env => join(kimiHomeOf(env), 'config.toml'), select: selectRoute }, + prepare: prepareRoute, + sessionConfig: (snapshot, selected) => supportsThinking(selected.model) ? [{ id: 'thinking', value: snapshot.effort }] : [], + facts: { root: home => join(home, 'kimi-home'), decode: completionFacts }, +}); diff --git a/hosts/kimi/paths.js b/hosts/kimi/paths.js new file mode 100644 index 0000000..7af6e87 --- /dev/null +++ b/hosts/kimi/paths.js @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +export const kimiHomeOf = (env = {}) => resolve(env.KIMI_CODE_HOME || join(env.HOME || env.USERPROFILE || homedir(), '.kimi-code')); +export const stateDirOf = (env = {}) => join(kimiHomeOf(env), 'evolver'); +export const nativeSessionIdOf = payload => typeof payload.session_id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(payload.session_id) ? payload.session_id : null; +export const projectDirOf = payload => typeof payload.cwd === 'string' && payload.cwd ? payload.cwd : null; +export const sessionIdOf = (payload, context = {}) => { + const id = nativeSessionIdOf(payload); + return id && typeof context.nativeScope === 'string' && /^[a-f0-9]{64}$/.test(context.nativeScope) ? JSON.stringify([context.nativeScope, id]) : null; +}; diff --git a/hosts/kimi/payload.js b/hosts/kimi/payload.js new file mode 100644 index 0000000..d79241f --- /dev/null +++ b/hosts/kimi/payload.js @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { nativeTurnIdOf } from './transcript.js'; +import { projectDirOf, sessionIdOf } from './paths.js'; + +const text = value => typeof value === 'string' ? value : ''; +const toolEvents = new Set(['PreToolUse', 'PostToolUse', 'PostToolUseFailure']); +const localClients = new Set(['kimi_code_cli', 'kimi_code_desktop']); + +export const kimiPayload = Object.freeze({ + sessionId: sessionIdOf, + projectDir: projectDirOf, + prompt: payload => Array.isArray(payload.prompt) ? payload.prompt.filter(part => part?.type === 'text').map(part => text(part.text)).join('\n').trim() : '', + turnId: (payload, context = {}) => { + if (payload.hook_event_name === 'UserPromptSubmit') return null; + if (toolEvents.has(payload.hook_event_name)) return nativeTurnIdOf(context.nativeFacts?.toolTurnId); + return nativeTurnIdOf(payload.turn_id) ?? nativeTurnIdOf(context.nativeFacts?.bind?.turnId); + }, + isIgnored: payload => payload.is_steer === true || Boolean(payload.agent_id && payload.agent_id !== 'main') || (payload.origin_kind !== undefined && payload.origin_kind !== 'user') || !localClients.has(payload.client_type), + // SessionStart.model is a global default, not the selected native profile binding. + model: (payload, context = {}) => context.nativeFacts?.modelHint ?? null, +}); diff --git a/hosts/kimi/profile.js b/hosts/kimi/profile.js new file mode 100644 index 0000000..9fcecc4 --- /dev/null +++ b/hosts/kimi/profile.js @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { join } from 'node:path'; + +import { defineProfile } from '../../lib/agent/profile.js'; +import { EVENTS } from '../../lib/agent/events.js'; +import { kimiEvidence } from './evidence.js'; +import { headlessKimi } from './model.js'; +import { kimiPayload } from './payload.js'; +import { kimiHomeOf, nativeSessionIdOf, projectDirOf, stateDirOf } from './paths.js'; +import { decodeNativeFacts, taskIdsOf } from './transcript.js'; + +const promptReply = (message, event) => message && event === 'UserPromptSubmit' ? { message } : {}; +const reasons = { completed: 'answer', failed: 'error', cancelled: 'aborted', interrupted: 'aborted' }; +const terminalOf = context => context?.nativeFacts?.status === 'ok' && context.nativeFacts.schemaSupported ? context.nativeFacts.terminal : null; + +export default defineProfile({ + name: 'kimi', label: 'Kimi Code', + events: { + SessionStart: EVENTS.SESSION_START, UserPromptSubmit: EVENTS.PROMPT, + PreToolUse: EVENTS.BEFORE_EDIT, PostToolUse: EVENTS.TOOL_RESULT, PostToolUseFailure: EVENTS.TOOL_RESULT, + TurnStarted: EVENTS.OBSERVE, Stop: EVENTS.OBSERVE, StopFailure: EVENTS.OBSERVE, Interrupt: EVENTS.OBSERVE, + TaskStarted: EVENTS.OBSERVE, Notification: EVENTS.OBSERVE, SessionEnd: EVENTS.SESSION_END, + }, + payload: kimiPayload, + native: { + root: kimiHomeOf, workspaceIndex: 'workspaces.json', sessionId: nativeSessionIdOf, projectDir: projectDirOf, agent: 'main', + tasksRoot: (env, sessionDir) => join(sessionDir, 'agents', 'main', 'tasks'), + taskIds: taskIdsOf, decode: decodeNativeFacts, + }, + evidence: kimiEvidence, + reply: { context: promptReply, notice: promptReply, toolResultContext: false }, + commands: { entry: 'cli-only', parse: () => null }, + turnEnd: { + reason: (payload, context) => reasons[terminalOf(context)?.reason] ?? 'unknown', + answer: (payload, context) => terminalOf(context)?.answer ?? '', + unclosed: { settleMs: 0, as: 'error' }, + }, + budgets: { prompt: 6_000, sessionEnd: 2_000 }, + owner: { ancestor: /^(?:kimi|kimi[- ]code)(?:\.exe)?$/i }, + model: headlessKimi, + stateDir: stateDirOf, + settingsFile: env => join(stateDirOf(env), 'kimi-settings.json'), +}); diff --git a/hosts/kimi/transcript.js b/hosts/kimi/transcript.js new file mode 100644 index 0000000..8996c36 --- /dev/null +++ b/hosts/kimi/transcript.js @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { resolve, isAbsolute } from 'node:path'; + +import { turnRecordOf } from '../../lib/turn/record.js'; + +const text = value => typeof value === 'string' ? value : ''; +export const nativeTurnIdOf = value => Number.isSafeInteger(value) && value >= 0 ? String(value) : typeof value === 'string' && /^(?:0|[1-9]\d*)$/.test(value) ? value : null; +const main = record => record.agentId === 'main'; +const promptOf = record => record.type === 'turn.prompt' && main(record) && record.origin?.kind === 'user' && nativeTurnIdOf(record.turnId) !== null && text(record.promptId); +const editTools = new Set(['Write', 'Edit']); +const taskId = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : null; +const validResult = result => result && typeof result === 'object' && typeof result.output === 'string' && Number.isFinite(result.durationMs) && result.durationMs >= 0 && (result.isError === undefined || typeof result.isError === 'boolean'); +const pathOf = (input, cwd) => text(input?.path) && (isAbsolute(input.path) ? input.path : text(cwd) && isAbsolute(cwd) ? resolve(cwd, input.path) : null); + +/** Only discovers fixed task IDs; caller chooses a bounded file under the known session root. */ +export function taskIdsOf(records = [], payload = {}) { + const ids = [taskId(payload.task_id)]; + for (const record of records.filter(main)) { + const event = record.type === 'context.append_loop_event' ? record.event : null; + if (event?.type === 'tool.call' && event.name === 'TaskOutput') ids.push(taskId(event.args?.task_id)); + if (event?.type === 'tool.result') ids.push(taskId(/^task_id:\s*([A-Za-z0-9_-]+)(?:\r?\n|$)/.exec(text(event.result?.output))?.[1])); + } + return [...new Set(ids.filter(Boolean))]; +} + +function callsOf(records, turnId) { + const calls = new Map(); + for (const record of records.filter(main)) { + const call = record.type === 'context.append_loop_event' ? record.event : null; + if (call?.type !== 'tool.call' || nativeTurnIdOf(call.turnId) !== turnId || !text(call.uuid) || !text(call.toolCallId)) continue; + if (!calls.has(call.uuid)) calls.set(call.uuid, call); + } + return [...calls.values()]; +} + +function resultOf(records, call) { + const results = records.filter(main).filter(record => record.type === 'context.append_loop_event' && record.event?.type === 'tool.result' && record.event.parentUuid === call.uuid && record.event.toolCallId === call.toolCallId).map(record => record.event.result); + const distinct = new Map(results.map(result => [JSON.stringify(result), result])); + return distinct.size === 1 && validResult([...distinct.values()][0]) ? [...distinct.values()][0] : null; +} + +function notesOf(records, tasks, turnId, cwd) { + return callsOf(records, turnId).flatMap(call => { + const result = resultOf(records, call); + const input = call.args && typeof call.args === 'object' ? call.args : {}; + if (call.name === 'Bash' && text(input.command)) { + const expectedTask = taskId(/^task_id:\s*([A-Za-z0-9_-]+)(?:\r?\n|$)/.exec(text(result?.output))?.[1]); + const matching = tasks.filter(task => expectedTask && task?.kind === 'process' && task.parentToolCallId === call.toolCallId && task.command === input.command && task.taskId === expectedTask); + const task = matching.length === 1 ? matching[0] : null; + if (task) { + const exit = ['completed', 'failed'].includes(task.status) && Number.isInteger(task.exitCode) ? task.status === 'failed' && task.exitCode === 0 ? 1 : task.exitCode : null; + return [{ kind: 'shell', command: input.command, exit, output: '', ...(exit === null && { running: true }), callId: call.toolCallId }]; + } + // Recognising a background wrapper can only downgrade evidence; its text can never prove success. + const pending = input.run_in_background === true || /^task_id:/m.test(text(result?.output)); + return [{ kind: 'shell', command: input.command, exit: !pending && result ? result.isError === true ? 1 : 0 : null, output: result?.output ?? '', ...(pending && { running: true }), callId: call.toolCallId }]; + } + const file = pathOf(input, call.display?.cwd || cwd); + return editTools.has(call.name) && file && result && result.isError !== true ? [{ kind: 'edit', paths: [file], callId: call.toolCallId }] : []; + }); +} + +function answerOf(records, turnId) { + const step = records.filter(main).findLast(record => record.type === 'context.append_loop_event' && record.event?.type === 'step.end' && nativeTurnIdOf(record.event.turnId) === turnId)?.event; + if (!step || step.finishReason !== 'end_turn' || step.providerFinishReason !== 'completed') return ''; + const parts = new Map(); + for (const record of records.filter(main)) { + const part = record.type === 'context.append_loop_event' ? record.event : null; + if (part?.type === 'content.part' && nativeTurnIdOf(part.turnId) === turnId && part.stepUuid === step.uuid && part.part?.type === 'text' && text(part.uuid) && !parts.has(part.uuid)) parts.set(part.uuid, text(part.part.text)); + } + return [...parts.values()].join('').trim(); +} + +function conflictingIdentity(records) { + const seen = new Map(); + for (const record of records.filter(main)) { + const event = record.type === 'context.append_loop_event' ? record.event : null; + if (event?.type !== 'tool.call') continue; + for (const key of [`uuid:${event.uuid}`, `call:${event.toolCallId}`]) { + const value = JSON.stringify(event); + if (seen.has(key) && seen.get(key) !== value) return true; + seen.set(key, value); + } + } + return false; +} + +/** Decode only the main agent's supported native wire; native turn zero is a real identity. */ +export function decodeNativeFacts(records = [], tasks = [], payload = {}, context = {}) { + const schema = records.find(record => record.type === 'metadata')?.protocol_version ?? context.schemaVersion; + const schemaSupported = schema === '1.5'; + const binding = records.findLast(record => record.type === 'profile.bind' && main(record)); + const modelHint = binding && text(binding.modelAlias) ? { alias: binding.modelAlias, effort: text(binding.thinkingEffort) || null } : context.modelHint ?? null; + const empty = { status: schemaSupported ? 'ok' : 'unsupported', schemaSupported, schemaVersion: schemaSupported ? schema : null, bind: null, terminal: null, modelHint, record: null, notes: [], toolTurnId: null }; + if (!schemaSupported) return empty; + const wanted = nativeTurnIdOf(context.turnId) ?? nativeTurnIdOf(payload.turn_id); + const prompts = records.filter(promptOf).filter(record => wanted !== null ? nativeTurnIdOf(record.turnId) === wanted : context.after ? record.$offset >= context.after.offset : true); + const unique = new Map(prompts.map(record => [`${nativeTurnIdOf(record.turnId)}:${record.promptId}`, record])); + if (unique.size !== 1 || (payload.hook_event_name === 'UserPromptSubmit' && wanted === null && !context.after)) return empty; + const prompt = [...unique.values()][0]; + const turnId = nativeTurnIdOf(prompt.turnId); + const start = records.indexOf(prompt); + const turnBinding = records.slice(0, start + 1).findLast(record => record.type === 'profile.bind' && main(record)); + const boundModelHint = turnBinding && text(turnBinding.modelAlias) ? { alias: turnBinding.modelAlias, effort: text(turnBinding.thinkingEffort) || null } : context.modelHint ?? null; + const next = records.findIndex((record, index) => index > start && promptOf(record) && nativeTurnIdOf(record.turnId) !== turnId); + const turn = records.slice(start, next === -1 ? undefined : next); + const ended = turn.filter(main).findLast(item => item.type === 'turn.ended' && nativeTurnIdOf(item.turnId) === turnId); + if (conflictingIdentity(turn) || (ended?.reason === 'completed' && ended.error)) return { ...empty, status: 'partial' }; + const notes = notesOf(turn, tasks, turnId, payload.cwd ?? context.projectDir); + const record = turnRecordOf(notes); + record.answer = answerOf(turn, turnId); + const requests = turn.filter(main).filter(item => item.type === 'llm.request'); + const request = requests.at(-1); + const routes = new Set(requests.map(item => JSON.stringify([item.modelAlias, item.model, item.provider, item.thinkingEffort]))); + const terminalHint = routes.size === 1 && request && text(request.modelAlias) ? { alias: request.modelAlias, effort: text(request.thinkingEffort) || null, model: text(request.model) || null, protocol: text(request.provider) || null } : null; + const terminal = ended && ['completed', 'failed', 'cancelled', 'interrupted'].includes(ended.reason) ? { turnId, reason: ended.reason, answer: record.answer, modelHint: terminalHint } : null; + const hookCalls = records.filter(main).filter(item => item.type === 'context.append_loop_event' && item.event?.type === 'tool.call' && item.event.toolCallId === payload.tool_call_id); + const hookTurns = [...new Set(hookCalls.map(item => nativeTurnIdOf(item.event.turnId)).filter(id => id !== null))]; + const toolTurnId = hookTurns.length === 1 ? hookTurns[0] : payload.hook_event_name === 'PreToolUse' && nativeTurnIdOf(context.turnId) !== null ? turnId : null; + return { ...empty, modelHint: boundModelHint, bind: { turnId, promptId: prompt.promptId, origin: 'user', mark: prompt.$offset ?? null }, terminal, record, notes, toolTurnId }; +} diff --git a/lib/agent/driver.js b/lib/agent/driver.js index 8ebe3e8..66e008b 100644 --- a/lib/agent/driver.js +++ b/lib/agent/driver.js @@ -6,6 +6,7 @@ import { commandNamed, runCommand } from '../app/commands/index.js'; import { distillSession, postAgentGene } from '../app/distill-session.js'; import { adoptStrategy, lastTurnOf, promptSubmitted, takeWaitingStrategy } from '../app/prompt.js'; import { sessionEnded } from '../app/session-end.js'; +import { reconcileNativeTurn, recoverNativeTurns } from '../app/native-turn.js'; import { isNewOwner, sessionStarted } from '../app/session-start.js'; import { recallWhenStuck } from '../app/stuck-recall.js'; import { beforeEdit, toolFinished } from '../app/tool-result.js'; @@ -44,7 +45,8 @@ async function commandAnswer(profile, ports, at, typed, options = {}) { const HANDLERS = { async [EVENTS.SESSION_START]({ profile, ports, hostEvent, payload, at }) { - const notice = await sessionStarted(ports, { sessionId: at.sessionId, projectDir: at.projectDir }); + if (ports.native) await recoverNativeTurns(ports); + const notice = await sessionStarted(ports, { ...at, trackOwner: profile.sessionStart === 'owner-change' }); return notice ? profile.reply.notice(notice, hostEvent, payload) : profile.reply.empty(hostEvent, payload); }, @@ -68,7 +70,21 @@ const HANDLERS = { const keepLate = typeof profile.reply.late === 'function'; const hostTurn = profile.payload.hostTurn(payload, context); const holdForToolResult = profile.reply.strategyAt === STRATEGY_AT.TOOL_RESULT; - const { injection, late } = await promptSubmitted(ports, { sessionId, prompt, turnId, turnStart, hostTurn, isCommand: Boolean(typed), waitMs: waitLeft(), keepLate, holdForToolResult }); + let nativeModel = null; + if (ports.native) { + try { nativeModel = await ports.model?.capture?.(context.nativeFacts?.modelHint); } catch { /* A missing binding keeps the candidate unavailable. */ } + } + const { turn, injection, late } = await promptSubmitted(ports, { sessionId, prompt, turnId, turnStart, hostTurn, isCommand: Boolean(typed), waitMs: waitLeft(), keepLate, holdForToolResult }); + if (ports.native) { + const attached = await ports.sessions.update(sessionId, stored => { + if (!stored.open || stored.turn !== turn || stored.pid !== ports.owner.current()) return undefined; + return { ...stored, native: { turn, mark: context.nativeFacts?.status === 'ok' ? context.nativeFacts.mark : null, + turnId: null, model: nativeModel, modelHint: context.nativeFacts?.modelHint ?? null, + schemaVersion: context.nativeFacts?.schemaVersion ?? null, + payload: { session_id: payload.session_id, cwd: projectDir } } }; + }); + if (attached) ports.scheduler.schedule([`native:${JSON.stringify({ ...at, turn })}`]); + } const answer = profile.reply.context(joined(notice, await checkpointOf(), injection), hostEvent, payload); return late ? profile.reply.late(answer, late, hostEvent, payload) : answer; }, @@ -90,6 +106,10 @@ const HANDLERS = { return profile.reply.empty(hostEvent, payload); }, + async [EVENTS.OBSERVE]({ profile, hostEvent, payload }) { + return profile.reply.empty(hostEvent, payload); + }, + async [EVENTS.TURN_END]({ profile, ports, hostEvent, payload, context, at }) { const reason = profile.turnEnd.reason(payload, context); if (reason === 'unknown') return profile.reply.empty(hostEvent, payload); @@ -144,7 +164,7 @@ function geneOutcome(ports, at, { gene, writtenFor }) { export function createDriver({ profile, ports, transcripts, env = process.env, lifetime, startedAt = Date.now() }) { const contextOf = (payload, hostEvent) => { const file = profile.payload.transcriptPath(payload); - return { env, hostEvent, transcriptSize: () => transcripts.size(file), transcriptSince: (offset) => transcripts.readSince(file, offset) }; + return { env, hostEvent, nativeScope: ports.native?.scope, transcriptSize: () => transcripts.size(file), transcriptSince: (offset) => transcripts.readSince(file, offset) }; }; return { /** @@ -169,6 +189,14 @@ export function createDriver({ profile, ports, transcripts, env = process.env, l const sessionId = profile.payload.sessionId(payload, context); if (!sessionId) return profile.reply.empty(hostEvent, payload); const at = { sessionId, projectDir: profile.payload.projectDir(payload, context) }; + if (ports.native) { + const reconciled = await reconcileNativeTurn(ports, { ...at, payload }); + context.nativeFacts = reconciled?.facts; + if (event === EVENTS.PROMPT) { + const session = await ports.sessions.read(sessionId), cursor = session.nativeCursor; + context.nativeFacts = await ports.native.read(payload, { after: cursor?.mark, modelHint: cursor?.modelHint, schemaVersion: cursor?.schemaVersion }); + } + } const waitLeft = (budgetOf = 'prompt') => waitBudgetOf(profile.budgets, budgetOf, { lifetime, startedAt }); return await HANDLERS[event]({ profile, ports, hostEvent, payload, context, at, waitLeft, startedAt, options }); } catch { diff --git a/lib/agent/events.js b/lib/agent/events.js index 9454fdc..d3a9b05 100644 --- a/lib/agent/events.js +++ b/lib/agent/events.js @@ -11,6 +11,7 @@ export const EVENTS = Object.freeze({ SESSION_END: 'sessionEnd', COMMAND: 'command', AGENT_GENE: 'agentGene', + OBSERVE: 'observe', }); export const CHANNELS = Object.freeze({ HOOK: 'hook', PLUGIN: 'plugin', MCP: 'mcp', RELAY: 'relay' }); diff --git a/lib/agent/profile.js b/lib/agent/profile.js index e743ab2..8290d64 100644 --- a/lib/agent/profile.js +++ b/lib/agent/profile.js @@ -43,7 +43,7 @@ const NESTED = ['payload', 'reply', 'turnEnd', 'commands', 'evidence', 'budgets' const REPLY_FUNCTIONS = ['context', 'notice', 'empty', 'command', 'turnEnd']; const OPTIONAL_REPLY_FUNCTIONS = ['late']; const PAYLOAD_FUNCTIONS = ['sessionId', 'turnId', 'prompt', 'projectDir', 'isIgnored', 'model', 'transcriptPath', 'gene', 'writtenFor', 'hostTurn']; -const MODEL_KINDS = ['cli', 'sdk', 'in-session']; +const MODEL_KINDS = ['cli', 'sdk', 'in-session', 'acp']; // Where a command's answer becomes part of what the model reads, a strategy it found is a reuse to report. const MODEL_SEES_ANSWER = new Set([COMMAND_ENTRIES.INLINE_SCRIPT, COMMAND_ENTRIES.PROMPT, COMMAND_ENTRIES.TOOL]); @@ -82,6 +82,8 @@ function problemsOf(profile) { if (Object.values(profile.budgets).some((budget) => !Number.isFinite(budget))) problems.push('budgets must be milliseconds'); if (!isFunction(profile.stateDir)) problems.push('stateDir must be a function of the environment'); if (!Array.isArray(profile.legacy)) problems.push('legacy must be a list of leftovers to adopt'); + if (profile.settingsFile !== undefined && !isFunction(profile.settingsFile)) problems.push('settingsFile must be a function of the environment'); + if (profile.native !== undefined && (!['root', 'sessionId', 'projectDir', 'decode', 'taskIds', 'tasksRoot'].every(key => isFunction(profile.native?.[key])) || !/^[a-zA-Z0-9_.-]+$/.test(profile.native?.workspaceIndex ?? ''))) problems.push('native requires indexed-session readers and a fixed workspaceIndex'); return problems; } diff --git a/lib/app/commands/distill.js b/lib/app/commands/distill.js index 089fcd3..d0fe4e5 100644 --- a/lib/app/commands/distill.js +++ b/lib/app/commands/distill.js @@ -2,15 +2,16 @@ // Copyright (c) 2026 EvoMap import { distillSession } from '../distill-session.js'; -import { failure, shown, success } from './result.js'; +import { failure, success } from './result.js'; export function distillText(outcome) { switch (outcome.status) { case 'nothing': return failure('Nothing to distill yet: no turn of this session finished verified.'); case 'busy': return failure('Another process is distilling this session right now.'); + case 'blocked': return failure(`Distillation is blocked (${outcome.reason}); the verified candidate is kept. Restore its selected model configuration and retry /evolver-distill.`); case 'declined': return success(`No Gene: the turn holds no method worth reusing (${outcome.reason}).`); - case 'drafted': return success(`Distilled "${outcome.draft.title}" into a draft. Publish it with /evolver-drafts publish 1, or list drafts with /evolver-drafts.`); - case 'stored': return success(shown(outcome.data ?? 'distilled')); + case 'drafted': return success(`Distilled "${outcome.draft.title}" into a draft. Review it with /evolver-drafts.`); + case 'stored': return success('The Proxy reported that it stored the result and did not return a draft for review.'); case 'skipped': return failure(`The Proxy kept nothing: ${outcome.reason}.`); case 'unparsed': return failure('The Gene must be one JSON object with at least a "summary".'); case 'dropped': return failure('A newer turn replaced the one this Gene was written for; nothing was sent.'); diff --git a/lib/app/commands/status.js b/lib/app/commands/status.js index 7a7ef86..43a0032 100644 --- a/lib/app/commands/status.js +++ b/lib/app/commands/status.js @@ -5,14 +5,15 @@ import { engineNoticeOf } from '../../engine/notice.js'; import { SIGN_IN, signInOf } from '../../hub-login.js'; import { MIN_EVOLVER_VERSION } from '../../engine/version.js'; import { proxyStatusText } from '../../proxy-status.js'; -import { success } from './result.js'; +import { failure, success } from './result.js'; function candidateLine(pending, { sessionId, anySession }) { if (anySession) { const waiting = Object.keys(pending).length; - return waiting === 0 ? 'No distillation candidate is waiting.' : `${waiting} verified turn(s) waiting to be distilled; /evolver-distill takes the newest.`; + const blocked = Object.values(pending).filter(candidate => candidate.blocked).length; + return waiting === 0 ? 'No distillation candidate is waiting.' : `${waiting} verified turn(s) waiting to be distilled${blocked ? ` (${blocked} blocked by model configuration)` : ''}; /evolver-distill takes the newest.`; } - return pending[sessionId] ? 'This session has a verified turn waiting to be distilled.' : 'No distillation candidate in this session.'; + return pending[sessionId] ? `This session has a verified turn waiting to be distilled.${pending[sessionId].blocked ? ` Blocked: ${pending[sessionId].blocked.reason}.` : ''}` : 'No distillation candidate in this session.'; } function engineLine(outcome, label) { @@ -35,7 +36,16 @@ function signInLine(state) { export default { name: 'evolver-status', description: 'Evolver health: the Proxy, engine setup, Hub sign-in, distillation candidate and drafts.', - async run({ identity, proxy, engine, candidates, drafts, signIn, clock }, { sessionId, anySession }) { + async run({ identity, proxy, engine, candidates, drafts, signIn, clock, settings }, { sessionId, anySession, args = '' }) { + const preferences = () => `Recall: ${settings.recall ? 'on' : 'off'}; automatic distillation: ${settings.autoDistill ? 'on' : 'off'}.`; + if (args.trim()) { + const match = /^(recall|auto-distill)\s+(on|off)$/.exec(args.trim()); + if (!match) return failure('Usage: evolver-status [recall|auto-distill on|off].'); + if (!settings?.configure) return failure('This host configures recall and automatic distillation through its existing settings.'); + await settings.configure({ [match[1] === 'recall' ? 'recall' : 'autoDistill']: match[2] === 'on' }); + return success(preferences()); + } + const answered = await proxy.request('GET', '/proxy/status', undefined, { timeoutMs: 3_000 }); const waiting = (await drafts.read()).length; return success([ diff --git a/lib/app/distill-session.js b/lib/app/distill-session.js index c2407af..8cb9988 100644 --- a/lib/app/distill-session.js +++ b/lib/app/distill-session.js @@ -4,16 +4,16 @@ import { withDraft } from '../distill.js'; import { candidateSessionOf, claimedLeftovers, claimedOwn, isCurrentCandidate, settledCandidate, withoutExpiredLeftovers } from '../distill/candidate.js'; import { argumentsOf, declineOf, distillBody, distillPrompt } from '../distill/prompt.js'; -import { distillOutcomeOf, isSettled, needsAgentModel } from '../distill/result.js'; +import { blockedModelReason, distillOutcomeOf, isSettled, needsAgentModel } from '../distill/result.js'; import { taskSessionId } from './turn-reports.js'; export const DISTILL_TIMEOUT_MS = 120_000; const WATCH_MS = 1_000; -async function claim({ candidates, owner }, claimOf) { +async function claim({ candidates, owner, native }, claimOf) { let claimed = null; await candidates.update((pending) => { - const result = claimOf(pending, { pid: owner.self(), isGone: owner.isGone }); + const result = claimOf(pending, { pid: owner.self(), isGone: owner.isGone, requireCompletedChecks: Boolean(native) }); claimed = result.claimed; return result.next; }); @@ -21,7 +21,7 @@ async function claim({ candidates, owner }, claimOf) { } const settle = ({ candidates }, { sessionId, candidate }, outcome, countsFailure) => - candidates.update((pending) => settledCandidate(pending, sessionId, candidate, { done: isSettled(outcome), counted: countsFailure })); + candidates.update((pending) => settledCandidate(pending, sessionId, candidate, { done: isSettled(outcome), counted: countsFailure, blocked: outcome?.status === 'blocked' ? outcome.reason : null })); async function post(ports, { sessionId, candidate }, args, usage, signal) { const body = distillBody({ platform: ports.identity.agent, sessionId: taskSessionId(ports.identity, sessionId), candidate, args, usage }); @@ -42,7 +42,8 @@ function watchCandidate({ candidates }, { sessionId, candidate }) { } async function distillClaimed(ports, claimed, focus) { - if (!ports.model || !claimed.candidate.model) return { status: 'no_model' }; + if (!ports.model) return { status: 'no_model' }; + if (!claimed.candidate.model) return ports.model.capture ? { status: 'blocked', reason: 'route_unresolved' } : { status: 'no_model' }; const watch = watchCandidate(ports, claimed); const stopping = ports.scheduler?.signal; const signal = AbortSignal.any([AbortSignal.timeout(DISTILL_TIMEOUT_MS), watch.signal, ...(stopping ? [stopping] : [])]); @@ -56,6 +57,8 @@ async function distillClaimed(ports, claimed, focus) { return await post(ports, claimed, args, reply.usage, signal); } catch (error) { if (watch.signal.aborted) return { status: 'dropped' }; + const blocked = blockedModelReason(error); + if (blocked) return { status: 'blocked', reason: blocked }; return stopping?.aborted ? { status: 'stopped' } : { status: 'model_failed', error: String(error?.message ?? error) }; } finally { watch.stop(); @@ -65,7 +68,7 @@ async function distillClaimed(ports, claimed, focus) { // A run its process stopped for shutdown only gives its claim back: the next start tries the candidate again. async function run(ports, claimed, { focus = '', countModelFailures = true } = {}) { const outcome = await distillClaimed(ports, claimed, focus); - const counts = outcome.status !== 'stopped' && (countModelFailures || !needsAgentModel(outcome)); + const counts = !['stopped', 'blocked'].includes(outcome.status) && (countModelFailures || !needsAgentModel(outcome)); if (outcome.status !== 'dropped') await settle(ports, claimed, outcome, counts); return outcome; } diff --git a/lib/app/native-turn.js b/lib/app/native-turn.js new file mode 100644 index 0000000..eaf5e58 --- /dev/null +++ b/lib/app/native-turn.js @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { hasUnknownChecks } from '../checks.js'; +import { skipReason } from '../distill/candidate.js'; +import { isCorrected, isSettling, sessionOf } from '../session/state.js'; +import { turnEnded } from './turn-end.js'; + +const reasons = { completed: 'answer', failed: 'error', cancelled: 'aborted', interrupted: 'aborted' }; +const same = (left, right) => JSON.stringify(left) === JSON.stringify(right); +const turnIdOf = value => typeof value === 'string' && value ? value : null; +const cursorOf = facts => ({ mark: facts.mark, schemaVersion: facts.schemaVersion, modelHint: facts.modelHint }); + +async function activateCandidate({ candidates, sessions }, sessionId, turn) { + const expected = (await candidates.read())[sessionId]; + if (expected?.turn !== turn || expected.finalizationPending !== true || hasUnknownChecks(expected)) return; + const session = sessionOf(await sessions.read(sessionId)); + const committed = session.native?.turn === turn && session.native.settled && !session.native.pending && session.native.candidateMade; + if (isCorrected(session, turn) || !(committed || session.turn > turn)) return; + await candidates.update(pending => { + const candidate = pending[sessionId]; + if (candidate?.turn !== turn || candidate.at !== expected.at || candidate.finalizationPending !== true || hasUnknownChecks(candidate)) return undefined; + const { finalizationPending, ...ready } = candidate; + return { ...pending, [sessionId]: { ...ready, ...(session.native?.turn === turn && session.native.sessionEnded && { closed: true }) } }; + }); +} + +async function readFacts(ports, payload, native, cursor) { + if (!ports.native?.read || !payload) return null; + try { + return await ports.native.read(payload, { + after: native?.mark ?? cursor?.mark, + turnId: native?.turnId ?? undefined, + modelHint: native?.modelHint ?? cursor?.modelHint, + schemaVersion: native?.schemaVersion ?? cursor?.schemaVersion, + }); + } catch { return { status: 'unreadable' }; } +} + +function capturedModel(native, terminal) { + const captured = native.model, actual = terminal.modelHint; + if (!captured || !actual || ['alias', 'model', 'protocol'].some(key => !actual[key] || captured[key] !== actual[key]) || (captured.effort ?? null) !== (actual.effort ?? null)) return null; + // A changed current config cannot replace the verified turn's captured account. Keep that binding for a retry + // after restoration; the runner checks it again before any native model request. + return captured; +} + +/** Reconcile only a previously opened logical turn with its matched native main-user identity and terminal. */ +export async function reconcileNativeTurn(ports, { sessionId, projectDir, payload, turn } = {}, suppliedFacts) { + const stored = sessionOf(await ports.sessions.read(sessionId)); + const native = stored.native; + const facts = suppliedFacts ?? await readFacts(ports, payload ?? native?.payload, native, stored.nativeCursor); + const result = { facts, native: native ?? null, ended: null, waiting: Boolean(native && stored.turn === native.turn && (!native.settled || native.pending)) }; + if (!native || stored.turn !== native.turn || (turn !== undefined && turn !== native.turn)) return { ...result, waiting: false }; + if (facts?.status !== 'ok' || !facts.schemaSupported || facts.bind?.origin !== 'user') return result; + const id = turnIdOf(facts.bind.turnId); + if (!id || !Number.isSafeInteger(facts.bind.mark) || facts.bind.mark < native.mark?.offset + || !facts.mark?.fileId || facts.mark.fileId !== native.mark?.fileId + || (native.turnId != null && native.turnId !== id) + || (native.promptId && native.promptId !== facts.bind.promptId)) return result; + + let bound = null; + await ports.sessions.update(sessionId, currentStored => { + const current = sessionOf(currentStored), state = current.native; + if (current.turn !== stored.turn || current.pid !== stored.pid || state?.turn !== native.turn || !same(state.payload, native.payload) + || state.mark?.fileId !== native.mark.fileId || (state.turnId != null && state.turnId !== id) + || (state.promptId && state.promptId !== facts.bind.promptId)) return undefined; + bound = { ...current, ...(!current.open && !current.prompt && state.sessionEnded && { prompt: state.prompt || '' }), turnId: id, turnIds: [...current.turnIds.filter(known => known !== id), id].slice(-20), native: { + ...state, turnId: id, promptId: facts.bind.promptId, + schemaVersion: facts.schemaVersion, modelHint: facts.modelHint ?? state.modelHint, + } }; + return bound; + }); + if (!bound) return { ...result, waiting: false }; + result.native = bound.native; + const terminal = facts.terminal; + if (!terminal || terminal.turnId !== id || !reasons[terminal.reason] || !facts.record) return result; + if (bound.native.settled && !bound.native.pending) { + await activateCandidate(ports, sessionId, stored.turn); + return { ...result, waiting: false }; + } + const claimant = ports.owner.self(); + let claimed = null; + await ports.sessions.update(sessionId, currentStored => { + const current = sessionOf(currentStored), state = current.native; + if (!Number.isSafeInteger(claimant) || claimant <= 0 || current.turn !== stored.turn || current.pid !== stored.pid + || state?.turnId !== id || state.promptId !== facts.bind.promptId || (state.settled && !state.pending) + || (!current.open && isSettling(current, ports.clock.now())) + || (state.finalizingPid && !ports.owner.isGone(state.finalizingPid))) return undefined; + claimed = { ...current, native: { ...state, settled: false, finalizingPid: claimant } }; + return claimed; + }); + if (!claimed) return result; + try { + const model = capturedModel(claimed.native, terminal); + const pending = terminal.reason === 'completed' && hasUnknownChecks(facts.record); + const ended = await turnEnded(ports, { + sessionId, projectDir: projectDir ?? native.payload?.cwd, turnId: id, + expectedTurn: stored.turn, expectedPid: stored.pid, + allowNewCandidate: !pending && !claimed.native.candidateMade, + finalizationPending: true, + reason: reasons[terminal.reason], answer: terminal.answer, model, + recordOf: () => facts.record, + }); + const eligible = !pending && skipReason(reasons[terminal.reason], { ...facts.record, answer: String(terminal.answer || facts.record.answer || '').trim() }) === null; + await ports.sessions.update(sessionId, currentStored => { + const current = sessionOf(currentStored); + if (current.turn !== stored.turn || current.pid !== stored.pid || current.native?.turnId !== id || current.native.finalizingPid !== claimant || current.open || isSettling(current, ports.clock.now())) return undefined; + return { ...current, native: { ...current.native, settled: true, pending, candidateMade: Boolean(current.native.candidateMade || eligible) }, nativeCursor: cursorOf(facts) }; + }); + await activateCandidate(ports, sessionId, stored.turn); + return { facts, native: claimed.native, ended, waiting: pending }; + } finally { + await ports.sessions.update(sessionId, currentStored => { + const current = sessionOf(currentStored); + if (current.turn !== stored.turn || current.pid !== stored.pid || current.native?.turnId !== id || current.native.finalizingPid !== claimant) return undefined; + return { ...current, native: { ...current.native, finalizingPid: null } }; + }); + } +} + +/** Only inspect sessions already in the plugin's state; the native reader never discovers arbitrary history. */ +export async function recoverNativeTurns(ports) { + if (!ports.native?.read) return []; + const pending = await ports.candidates.read(); + const known = []; + await ports.sessions.updateEach((stored, sessionId) => { + if ((stored?.native && stored.turn === stored.native.turn) || pending[sessionId]?.finalizationPending) known.push({ sessionId, projectDir: stored.native?.payload?.cwd, turn: stored.turn }); + }); + const results = []; + for (const at of known) { + results.push(await reconcileNativeTurn(ports, at)); + if (pending[at.sessionId]?.finalizationPending) await activateCandidate(ports, at.sessionId, pending[at.sessionId].turn); + } + return results; +} diff --git a/lib/app/ports.js b/lib/app/ports.js index 9d9658e..e03926f 100644 --- a/lib/app/ports.js +++ b/lib/app/ports.js @@ -48,6 +48,11 @@ * * @typedef {object} ModelRunner The agent's model, run outside the conversation; absent when it has none. * @property {(model: any, prompt: string, signal: AbortSignal) => Promise<{ answer: string, usage?: object }>} complete + * @property {(hint: object|null) => object} [capture] A no-secret route/account binding before the turn requests a model. + * + * @typedef {object} NativeFacts Read-only, bounded host records; an unsuccessful read supplies no verdict. + * @property {string} scope Pseudonymous profile identity; filesystem paths never become Hub task IDs. + * @property {(payload: object, trusted?: object) => object} read * * @typedef {object} OwnerProbe The process a session belongs to, this process, and whether a recorded one has exited. * @property {() => number|null} current @@ -106,6 +111,7 @@ * @property {RetentionStore} retention * @property {Proxy} proxy * @property {ModelRunner|null} model + * @property {NativeFacts|null} [native] * @property {OwnerProbe} owner * @property {Scheduler} scheduler * @property {Clock} clock diff --git a/lib/app/prompt.js b/lib/app/prompt.js index c49da4d..d331b88 100644 --- a/lib/app/prompt.js +++ b/lib/app/prompt.js @@ -4,8 +4,10 @@ import { withoutCandidate } from '../distill/candidate.js'; import { promptTextOf, recallStrategy, taskTextOf } from '../recall.js'; import { correctionReportsOf, followUpLabelOf } from '../session/follow-ups.js'; -import { correctedSession, joinedWaiting, listedIdsOf, openedTurn, sessionOf, withInjection, withShown, withWaiting } from '../session/state.js'; +import { correctedSession, joinedWaiting, listedIdsOf, openedTurn, sessionOf, unreportedOf, withInjection, withShown, withWaiting } from '../session/state.js'; +import { reportedReuseOf } from '../reuse.js'; import { TASKS } from './ports.js'; +import { turnReportsOf } from './turn-reports.js'; export const CLOSING = 'Apply them where they fit.'; // A Recipe still pending this close to the hook's deadline gives way to the single strategy, which needs the rest to be booked. @@ -17,13 +19,17 @@ const LATE_RECIPE_WAIT_MS = 6_000; * session's candidate first, then the last turn's reuses are reported again under `:correction`. The session is * marked corrected before the drop, so a re-judge of that turn writing meanwhile sees the mark and takes its write back. */ -async function labelFollowUp({ sessions, candidates, reports, scheduler }, sessionId, prompt) { +async function labelFollowUp({ sessions, candidates, reports, scheduler, identity }, sessionId, prompt) { const label = followUpLabelOf(prompt); + let feedbackSession; if (label === 'correction') { - await sessions.update(sessionId, (stored) => correctedSession(sessionOf(stored))); + feedbackSession = await sessions.update(sessionId, (stored) => correctedSession(sessionOf(stored))); await candidates.update((pending) => withoutCandidate(pending, sessionId)); } - const queued = correctionReportsOf(sessionOf(await sessions.read(sessionId)).labelable, label, sessionId); + const session = sessionOf(feedbackSession ?? await sessions.read(sessionId)); + const due = unreportedOf(session, sessionId, session.turn).filter(asset => asset.turn === session.turn); + const labelable = session.labelable.length ? session.labelable : turnReportsOf(identity, due, { reason: 'unknown', checks: [] }).map(({ report }) => reportedReuseOf(report)); + const queued = correctionReportsOf(labelable, label, sessionId); if (queued.length === 0) return; await reports.enqueue(queued); scheduler.schedule([TASKS.DRAIN]); diff --git a/lib/app/session-start.js b/lib/app/session-start.js index f431060..947b9ff 100644 --- a/lib/app/session-start.js +++ b/lib/app/session-start.js @@ -34,8 +34,8 @@ async function queueOrphanReports({ identity, sessions, reports, owner, clock }) * starts engine setup, delivery and leftover distillation in the background, and answers the one-off engine * notice: the only text besides a recalled strategy that reaches the model. */ -export async function sessionStarted(ports, { sessionId, projectDir }) { - await ports.sessions.update(sessionId, (stored) => ({ ...sessionOf(stored), startedUnder: String(ports.owner.current()) })); +export async function sessionStarted(ports, { sessionId, projectDir, trackOwner = true }) { + if (trackOwner) await ports.sessions.update(sessionId, (stored) => ({ ...sessionOf(stored), startedUnder: String(ports.owner.current()) })); await queueRetentionReports(ports, sessionId, projectDir); await queueOrphanReports(ports); const setup = (await ports.engine.needsSetup()) ? [TASKS.ENGINE] : []; diff --git a/lib/app/turn-end.js b/lib/app/turn-end.js index b1ef410..ff4625b 100644 --- a/lib/app/turn-end.js +++ b/lib/app/turn-end.js @@ -13,11 +13,17 @@ const SETTLE_POLL_MS = 100; // correction drops a candidate that exists. const SETTLING_WAIT_MS = 2_000; -async function closeTurn({ sessions, clock }, sessionId, turnId) { +const isExpected = (session, { expectedTurn, expectedPid }) => + (expectedTurn === undefined || session.turn === expectedTurn) && (expectedPid === undefined || session.pid === expectedPid); + +async function closeTurn({ sessions, clock }, sessionId, turnId, expected) { let opened = null; await sessions.update(sessionId, (stored) => { - const result = closedTurn(sessionOf(stored), turnId, clock.now()); - opened = result.opened; + const session = sessionOf(stored); + if (!isExpected(session, expected)) return undefined; + const now = clock.now(); + const result = closedTurn(session, turnId, now); + opened = result.opened && { ...result.opened, settling: now }; return result.next; }); return opened; @@ -26,37 +32,48 @@ async function closeTurn({ sessions, clock }, sessionId, turnId) { // Reports are queued before their injections are marked reported, so a hook killed in between leaves an injection // reported twice, which the outbox keys away, rather than never. async function reportTurn({ identity, sessions, reports, clock }, sessionId, opened, { reason, checks, source }) { - const due = unreportedOf(sessionOf(await sessions.read(sessionId)), sessionId, opened.turn); + const due = unreportedOf(sessionOf(await sessions.read(sessionId)), sessionId, opened.turn).filter(asset => asset.turn === opened.turn); const queued = turnReportsOf(identity, due, { reason, checks, source }); await reports.enqueue(queued); + let corrected = false; await sessions.update(sessionId, (stored) => { const session = markedReported(sessionOf(stored), due, clock.now()) ?? sessionOf(stored); - const labelable = opened.isCommand ? session.labelable : queued.map(({ report }) => reportedReuseOf(report)); + corrected = isCorrected(session, opened.turn); + const labelable = opened.isCommand || session.turn !== opened.turn || corrected ? session.labelable : queued.map(({ report }) => reportedReuseOf(report)); return { ...session, labelable }; }); return { queued, strategies: due.map((asset) => asset.strategyText) }; } -async function rememberCandidate({ candidates, owner, clock }, sessionId, opened, { reason, record, model, written = withCandidate }) { - if (opened.isCommand || skipReason(reason, record)) return false; - const candidate = candidateOf({ prompt: opened.prompt, record, model, turn: opened.turn, pid: owner.current(), now: clock.now() }); - await candidates.update((pending) => written(pending, sessionId, candidate)); - return true; +async function rememberCandidate({ candidates, sessions, clock }, sessionId, opened, { reason, record, model, written = withCandidate, allowNewCandidate = true, finalizationPending = false }) { + if (opened.isCommand || skipReason(reason, record) || isCorrected(sessionOf(await sessions.read(sessionId)), opened.turn)) return false; + const candidate = candidateOf({ prompt: opened.prompt, record, model, turn: opened.turn, pid: opened.pid, now: clock.now(), finalizationPending }); + let added = false; + await candidates.update((pending) => { + if (!allowNewCandidate && pending[sessionId]?.turn !== opened.turn) return undefined; + const next = written(pending, sessionId, candidate); added = next !== undefined; return next; + }); + return added; } +const settleTurn = (ports, sessionId, opened) => ports.sessions.update(sessionId, stored => { + const current = sessionOf(stored); + return current.turn === opened.turn && current.pid === opened.pid && current.settling === opened.settling ? settledSession(current) : undefined; +}); + // A turn read from the host's own record can run again after its end without a new prompt (a background command // finished); its reports went out and stay, but its candidate is judged again on the whole turn: kept when it still // ended verified, else dropped. An agent's report is its record, so a second one is a retry and changes nothing. // The re-judge marks the session settling while it writes, like a first turn end, so a correction that arrives // meanwhile waits for it and then drops what it wrote; one already past that wait has marked the session corrected // before dropping, so the re-judge, checking the mark after its write, takes the write back. -async function rejudgeCandidate(ports, sessionId, turnId, { reason, answer, model, recordOf }) { +async function rejudgeCandidate(ports, sessionId, turnId, { reason, answer, model, recordOf, allowNewCandidate, finalizationPending, ...expected }) { let session = null; await ports.sessions.update(sessionId, (stored) => { const current = sessionOf(stored); - if (current.open || current.isCommand || current.turnId !== turnId) return undefined; - session = current; - return { ...current, settling: ports.clock.now() }; + if (current.open || current.isCommand || current.turnId !== turnId || isSettling(current, ports.clock.now()) || !isExpected(current, expected)) return undefined; + session = { ...current, settling: ports.clock.now() }; + return session; }); if (!session) return; try { @@ -64,10 +81,10 @@ async function rejudgeCandidate(ports, sessionId, turnId, { reason, answer, mode const finished = { ...record, answer: String(answer || record.answer || '').trim() }; const drop = () => ports.candidates.update((pending) => withoutTurnCandidate(pending, sessionId, session.turn)); if (skipReason(reason, finished)) await drop(); - else await rememberCandidate(ports, sessionId, session, { reason, record: finished, model, written: withRejudgedCandidate }); + else await rememberCandidate(ports, sessionId, session, { reason, record: finished, model, written: withRejudgedCandidate, allowNewCandidate, finalizationPending }); if (isCorrected(sessionOf(await ports.sessions.read(sessionId)), session.turn)) await drop(); } finally { - await ports.sessions.update(sessionId, (stored) => settledSession(sessionOf(stored))); + await settleTurn(ports, sessionId, session); } } @@ -78,10 +95,11 @@ async function rejudgeCandidate(ports, sessionId, turnId, { reason, answer, mode * session's retention check, and a verified turn becomes the session's distillation candidate (`isCandidate`); * `strategies` holds the text of each strategy it reported. Null when no such turn was open. */ -export async function turnEnded(ports, { sessionId, turnId, reason, answer = '', model = null, projectDir, recordOf = null, source = OBSERVED }) { - const opened = await closeTurn(ports, sessionId, turnId); +export async function turnEnded(ports, { sessionId, turnId, reason, answer = '', model = null, projectDir, recordOf = null, source = OBSERVED, expectedTurn, expectedPid, allowNewCandidate = true, finalizationPending = false }) { + const expected = { expectedTurn, expectedPid }; + const opened = await closeTurn(ports, sessionId, turnId, expected); if (!opened) { - if (turnId && recordOf && source === OBSERVED) await rejudgeCandidate(ports, sessionId, turnId, { reason, answer, model, recordOf }); + if (turnId && recordOf && source === OBSERVED) await rejudgeCandidate(ports, sessionId, turnId, { reason, answer, model, recordOf, allowNewCandidate, finalizationPending, ...expected }); return null; } const notes = await ports.notes.take(sessionId); @@ -94,8 +112,12 @@ export async function turnEnded(ports, { sessionId, turnId, reason, answer = '', await ports.retention.remember({ sessionId, projectDir, reuses, editedBefore: edits.before }); await notes.commit(); await edits.commit(); - const isCandidate = await rememberCandidate(ports, sessionId, opened, { reason, record: finished, model }); - await ports.sessions.update(sessionId, (stored) => settledSession(sessionOf(stored))); + let isCandidate = await rememberCandidate(ports, sessionId, opened, { reason, record: finished, model, allowNewCandidate, finalizationPending }); + if (isCorrected(sessionOf(await ports.sessions.read(sessionId)), opened.turn)) { + await ports.candidates.update(pending => withoutTurnCandidate(pending, sessionId, opened.turn)); + isCandidate = false; + } + await settleTurn(ports, sessionId, opened); if (queued.length > 0) ports.scheduler.schedule([TASKS.DRAIN]); return { turn: opened.turn, record: finished, reports: queued.map(({ report }) => report), strategies, isCandidate }; } diff --git a/lib/channels/hook/runner.js b/lib/channels/hook/runner.js index a591080..0494bdf 100644 --- a/lib/channels/hook/runner.js +++ b/lib/channels/hook/runner.js @@ -6,6 +6,7 @@ import { createDriver } from '../../agent/driver.js'; import { profileNamed } from '../../agent/registry.js'; import { EPHEMERAL, environmentFor } from '../../runtime/environment.js'; import { isHeadlessRun } from '../../runtime/headless.js'; +import { exitAfterHookReply } from '../../runtime/process/spawn.js'; import { transcripts } from '../../runtime/transcripts.js'; import { readPayload } from './stdin.js'; @@ -13,9 +14,9 @@ import { readPayload } from './stdin.js'; * One command-hook invocation: the host's payload on stdin, the agent's answer as JSON on stdout, exit 0. Inside * this plugin's own headless distillation the hook answers the agent's empty reply and does nothing else. */ -export async function runHookProcess(agent, hostEvent, { stdin = process.stdin, stdout = process.stdout, env = process.env, exit = process.exit, profileOf = profileNamed } = {}) { +export async function runHookProcess(agent, hostEvent, { stdin = process.stdin, stdout = process.stdout, env = process.env, exit = exitAfterHookReply, profileOf = profileNamed } = {}) { const startedAt = Date.now(); - const write = (answer) => stdout.write(JSON.stringify(answer ?? {}), () => exit(0)); + const write = (answer) => stdout.write(JSON.stringify(answer ?? {}), () => { stdin.unref?.(); exit(0); }); const profile = await profileOf(agent).catch(() => null); if (!profile) return write({}); const payload = await readPayload(stdin); diff --git a/lib/channels/worker/runner.js b/lib/channels/worker/runner.js index 98be1b7..82b1606 100644 --- a/lib/channels/worker/runner.js +++ b/lib/channels/worker/runner.js @@ -2,12 +2,25 @@ // Copyright (c) 2026 EvoMap import { runBackgroundTasks } from '../../app/background.js'; +import { join } from 'node:path'; +import { turnEnded } from '../../app/turn-end.js'; +import { reconcileNativeTurn } from '../../app/native-turn.js'; import { profileNamed } from '../../agent/registry.js'; import { EPHEMERAL, environmentFor } from '../../runtime/environment.js'; +import { monitorNativeTurn } from '../../runtime/native-monitor.js'; /** The detached worker a hook process starts for slow work: runs `tasks` for `agent`; true when none threw. */ export async function runWorker(agent, tasks, { env = process.env } = {}) { const profile = await profileNamed(agent).catch(() => null); if (!profile) return false; - return environmentFor({ profile, env, lifetime: EPHEMERAL, runBackground: runBackgroundTasks }).runTasks(tasks); + const { ports, runTasks } = environmentFor({ profile, env, lifetime: EPHEMERAL, runBackground: runBackgroundTasks }); + let clean = true; + for (const task of tasks) { + if (task.startsWith('native:') && ports.native) { + let at; + try { at = JSON.parse(task.slice(7)); } catch { return false; } + await monitorNativeTurn(ports, at, input => reconcileNativeTurn(ports, input), closed => turnEnded(ports, closed), { dir: join(profile.stateDir(env), 'native-monitors') }); + } else clean = await runTasks([task]) && clean; + } + return clean; } diff --git a/lib/checks.js b/lib/checks.js index 58d9a95..fdf9a51 100644 --- a/lib/checks.js +++ b/lib/checks.js @@ -24,6 +24,8 @@ export function verdictOf(check) { return 'unknown'; } +export const hasUnknownChecks = (record) => record?.checks?.some((check) => check.failed === null) ?? false; + export function checkExitOf(check) { const verdict = verdictOf(check); if (verdict === 'unknown') return null; diff --git a/lib/distill/candidate.js b/lib/distill/candidate.js index c7cc757..18cd5c6 100644 --- a/lib/distill/candidate.js +++ b/lib/distill/candidate.js @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -import { verdictOf } from '../checks.js'; +import { hasUnknownChecks, verdictOf } from '../checks.js'; import { withoutSecrets } from '../redact.js'; // Distillation runs once a session, at its end, outside the conversation: each turn that finished verified only @@ -35,7 +35,7 @@ export function skipReason(reason, record) { } // Keys and tokens are stripped before anything is written down or sent to a model or the Proxy. -export function candidateOf({ prompt, record, model, turn, pid, now }) { +export function candidateOf({ prompt, record, model, turn, pid, now, finalizationPending = false }) { return { prompt: withoutSecrets(String(prompt ?? '')).slice(0, PROMPT_MAX_CHARS), commands: record.commands.slice(-MAX_COMMANDS).map((command) => withoutSecrets(command).slice(0, COMMAND_MAX_CHARS)), @@ -47,10 +47,11 @@ export function candidateOf({ prompt, record, model, turn, pid, now }) { pid: pid ?? null, attempts: 0, at: now, + ...(finalizationPending && { finalizationPending: true }), }; } -export const withCandidate = (pending, sessionId, candidate) => ({ ...pending, [sessionId]: candidate }); +export const withCandidate = (pending, sessionId, candidate) => pending[sessionId]?.turn > candidate.turn ? undefined : ({ ...pending, [sessionId]: candidate }); /** * The session's candidate judged again on its whole turn: a candidate already written for that turn keeps its stamp, @@ -88,25 +89,25 @@ export const isCurrentCandidate = (pending, sessionId, candidate) => pending[ses const isHeld = (candidate, isGone) => Boolean(candidate.claimedBy) && !isGone(candidate.claimedBy); /** The session's own candidate claimed for `pid`; none when there is none or a live process distills it. */ -export function claimedOwn(pending, sessionId, { pid, isGone }) { +export function claimedOwn(pending, sessionId, { pid, isGone, requireCompletedChecks }) { const candidate = pending[sessionId]; - if (!candidate || isHeld(candidate, isGone)) return { next: undefined, claimed: null }; + if (!candidate || candidate.finalizationPending || (requireCompletedChecks && hasUnknownChecks(candidate)) || isHeld(candidate, isGone)) return { next: undefined, claimed: null }; const claimed = { ...candidate, claimedBy: pid }; return { next: { ...pending, [sessionId]: claimed }, claimed: { sessionId, candidate: claimed } }; } // Over means ended, or killed with its owner; an idle session whose process still runs keeps its candidate. function isLeftover(candidate, isGone) { - const isOver = Boolean(candidate?.closed) || isGone(candidate?.pid); + const isOver = !candidate?.finalizationPending && (Boolean(candidate?.closed) || isGone(candidate?.pid)); return isOver && (!candidate?.claimedBy || isGone(candidate.claimedBy)); } /** The candidates of sessions that are over, claimed for `pid` at once, so two processes cannot distill one twice. */ -export function claimedLeftovers(pending, { pid, isGone }) { +export function claimedLeftovers(pending, { pid, isGone, requireCompletedChecks }) { const next = { ...pending }; const claimed = []; for (const [sessionId, candidate] of Object.entries(pending)) { - if (!isLeftover(candidate, isGone) || candidate.claimedBy === pid) continue; + if (!isLeftover(candidate, isGone) || (requireCompletedChecks && hasUnknownChecks(candidate)) || candidate.claimedBy === pid) continue; next[sessionId] = { ...candidate, claimedBy: pid }; claimed.push({ sessionId, candidate: next[sessionId] }); } @@ -115,7 +116,7 @@ export function claimedLeftovers(pending, { pid, isGone }) { /** The table without leftovers older than UNDISTILLED_LEFTOVER_TTL_MS; undefined when none is. */ export function withoutExpiredLeftovers(pending, { now, isGone }) { - const isExpired = (candidate) => isLeftover(candidate, isGone) && now - Number(candidate?.at) > UNDISTILLED_LEFTOVER_TTL_MS; + const isExpired = (candidate) => !candidate?.blocked && isLeftover(candidate, isGone) && now - Number(candidate?.at) > UNDISTILLED_LEFTOVER_TTL_MS; const kept = Object.entries(pending).filter(([, candidate]) => !isExpired(candidate)); return kept.length < Object.keys(pending).length ? Object.fromEntries(kept) : undefined; } @@ -124,10 +125,10 @@ export function withoutExpiredLeftovers(pending, { now, isGone }) { * Removes a candidate once `done`, or counts a failed try and gives up after MAX_ATTEMPTS; a try that is not * `counted` only releases the claim. A newer candidate written meanwhile is left alone. */ -export function settledCandidate(pending, sessionId, candidate, { done, counted }) { +export function settledCandidate(pending, sessionId, candidate, { done, counted, blocked = null }) { const current = pending[sessionId]; if (!current || current.at !== candidate.at) return undefined; const attempts = (current.attempts ?? 0) + (counted ? 1 : 0); if (done || attempts >= MAX_ATTEMPTS) return without(pending, sessionId); - return { ...pending, [sessionId]: { ...current, attempts, claimedBy: null } }; + return { ...pending, [sessionId]: { ...current, attempts, claimedBy: null, blocked: blocked ? { reason: blocked } : null } }; } diff --git a/lib/distill/prompt.js b/lib/distill/prompt.js index 31e9086..72f4eea 100644 --- a/lib/distill/prompt.js +++ b/lib/distill/prompt.js @@ -83,7 +83,7 @@ export function distillBody({ platform, sessionId, candidate, args, usage }) { final_answer: candidate.answer, ...args, execution: { trace: candidate.checks.map((check) => ({ command: check.command, exit: checkExitOf(check) })).filter(({ exit }) => exit !== null) }, - persist: true, + persist: false, publish: false, ...(usage ? { distill_usage: usage } : {}), }; diff --git a/lib/distill/result.js b/lib/distill/result.js index 406cfac..0396cee 100644 --- a/lib/distill/result.js +++ b/lib/distill/result.js @@ -5,11 +5,14 @@ import { draftOf, skipReasonOf } from '../distill.js'; const SETTLED = new Set(['stored', 'drafted', 'skipped', 'declined']); const MODEL_FAILURES = new Set(['no_model', 'model_failed', 'unparsed']); +const BLOCKED_REASONS = new Set(['route_unresolved', 'route_changed', 'credential_missing', 'auth_unsupported', 'unsafe_runtime']); + +/** Only the runtime's pre-request error contract can block a candidate without consuming an attempt. */ +export const blockedModelReason = error => error?.code === 'MODEL_UNAVAILABLE' && BLOCKED_REASONS.has(error.reason) ? error.reason : null; /** - * What the Proxy did with a Gene. Without a host verifier (only an isolated Linux Proxy has one) it stores - * nothing and answers the Gene and Capsule as a draft, which the person later publishes or discards. A Gene its - * quality gate refused is settled too: sending it again gives the same answer. + * Interpret the Proxy's response to a private draft request. A stored response is a separate + * outcome and cannot be reviewed as a local draft. A quality-gate rejection is also settled. */ export function distillOutcomeOf(result, { title, sessionId }, now) { if (!result?.ok) return { status: 'failed', error: String(result?.error) }; diff --git a/lib/recall.js b/lib/recall.js index 3da6e0f..a9c838d 100644 --- a/lib/recall.js +++ b/lib/recall.js @@ -142,8 +142,12 @@ const numbered = (steps) => steps.map((step, index) => `${index + 1}. ${step}`); function readableStepsOf(data) { return new Map(recalledAssets(data).map((asset) => { - const name = readableNameOf(asset); - const lines = strategySteps(asset); + const isCapsule = (asset.type ?? asset.asset_type) === 'Capsule'; + const name = isCapsule ? 'Capsule evidence' : readableNameOf(asset); + const summary = isCapsule ? promptTextOf(asset.summary) : ''; + const lines = isCapsule + ? (summary ? [headOf(`Observed case: ${summary.replace(/\s+/g, ' ')}`, STEP_MAX_TOKENS)] : []) + : strategySteps(asset); return [asset.asset_id, { name, lines, strategyText: [headingOf(name), ...numbered(lines)].join('\n') }]; })); } diff --git a/lib/runtime/acp-model.js b/lib/runtime/acp-model.js new file mode 100644 index 0000000..41c8de6 --- /dev/null +++ b/lib/runtime/acp-model.js @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { fork } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { stringify } from 'smol-toml'; + +import { markHeadlessWorkdir } from './headless.js'; +import { ModelUnavailable, sameSnapshot, selectedConfig } from './native-config.js'; +import { CHILD_FILE, guardianFile, OWNER_FILE } from './process/acp-child.js'; +import { nodeExecutable } from './process/node.js'; +import { executableOf } from './process/which.js'; + +const CREDENTIAL_ENV = 'EVOLVER_MODEL_API_KEY'; +const OUTPUT_MAX = 16 * 1024 * 1024; +const pidGone = pid => { + if (!Number.isSafeInteger(pid) || pid <= 0) return true; + try { process.kill(pid, 0); return false; } catch (error) { return error?.code === 'ESRCH'; } +}; + +function failure(code = 'ACP_MODEL_FAILED') { + return Object.assign(new Error(code === 'ABORT_ERR' ? 'Model completion was cancelled' : 'Native model completion did not finish safely'), { code }); +} + +async function within(promise, ms) { + let timer; + try { return await Promise.race([promise, new Promise(done => { timer = setTimeout(done, ms); })]); } + finally { clearTimeout(timer); } +} + +function beneath(home, path, allowSelf = false) { + const rel = relative(resolve(home), resolve(path)); + if ((!rel && !allowSelf) || rel.startsWith('..') || isAbsolute(rel)) throw new ModelUnavailable('unsafe_runtime'); + return resolve(path); +} + +function filesPrepared(declaration, selected, home, secret) { + let prepared; + try { prepared = declaration.prepare(selected, { home, credentialEnv: CREDENTIAL_ENV }); } + catch { throw new ModelUnavailable('unsafe_runtime'); } + const files = Object.entries(prepared?.files ?? {}).map(([path, entry]) => { + const target = beneath(home, join(home, path)); + if (isAbsolute(path)) throw new ModelUnavailable('unsafe_runtime'); + const text = entry?.format === 'toml' ? stringify(entry.value) : entry?.format === 'text' ? entry.value : null; + if (typeof text !== 'string' || Buffer.byteLength(text) > 1024 * 1024 || text.includes(secret)) throw new ModelUnavailable('unsafe_runtime'); + return { target, text }; + }); + if (!files.length || files.length > 16) throw new ModelUnavailable('unsafe_runtime'); + for (const { target, text } of files) { + mkdirSync(dirname(target), { recursive: true, mode: 0o700 }); + writeFileSync(target, text, { mode: 0o600, flag: 'wx' }); + } + return prepared.env ?? {}; +} + +function privateEnv(original, extra, home, secret) { + const env = {}; + for (const key of ['PATH', 'Path', 'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', 'NUMBER_OF_PROCESSORS', 'PROCESSOR_ARCHITECTURE', 'RUNNER_TRACKING_ID', 'ProgramW6432', 'ProgramFiles']) { + if (original[key] !== undefined) env[key] = original[key]; + } + for (const [key, value] of Object.entries(extra)) { + if (typeof value !== 'string' || value.includes(secret) || /(?:KEY|TOKEN|AUTH|SECRET)/i.test(key)) throw new ModelUnavailable('unsafe_runtime'); + if (/(?:^|_)HOME$/.test(key)) beneath(home, value, true); + env[key] = value; + } + Object.assign(env, { + HOME: home, USERPROFILE: home, APPDATA: join(home, 'AppData', 'Roaming'), LOCALAPPDATA: join(home, 'AppData', 'Local'), + TEMP: join(home, 'tmp'), TMP: join(home, 'tmp'), TMPDIR: join(home, 'tmp'), + EVOLVER_HEADLESS: '1', CI: '1', NO_COLOR: '1', [CREDENTIAL_ENV]: secret, + }); + for (const key of ['APPDATA', 'LOCALAPPDATA', 'TEMP']) mkdirSync(env[key], { recursive: true, mode: 0o700 }); + return env; +} + +function cleanOrphans(base, origin) { + const uid = process.getuid?.(); + let seen = 0; + for (const name of readdirSync(base)) { + if (!name.startsWith('evolver-model-') || ++seen > 256) continue; + const home = join(base, name), marker = join(home, OWNER_FILE); + try { + const stat = lstatSync(home), mark = lstatSync(marker); + if (!stat.isDirectory() || stat.isSymbolicLink() || !mark.isFile() || mark.isSymbolicLink() || mark.size > 2048) continue; + if (uid !== undefined && (stat.uid !== uid || mark.uid !== uid)) continue; + const owner = JSON.parse(readFileSync(marker, 'utf8')); + if (owner.origin !== origin || !/^[a-f0-9]{32}$/.test(owner.nonce ?? '') || !Number.isSafeInteger(owner.ownerPid) || owner.ownerPid <= 0) continue; + if (!pidGone(owner.ownerPid)) continue; + const childFile = join(home, CHILD_FILE); + let child; + try { + const childStat = lstatSync(childFile); + if (!childStat.isFile() || childStat.isSymbolicLink() || childStat.size > 2048) throw new ModelUnavailable('unsafe_runtime'); + if (uid !== undefined && childStat.uid !== uid) continue; + child = JSON.parse(readFileSync(childFile, 'utf8')); + if (child.nonce !== owner.nonce || ![child.guardianPid, child.nativePid].every(pid => Number.isSafeInteger(pid) && pid > 0)) throw new ModelUnavailable('unsafe_runtime'); + } catch { throw new ModelUnavailable('unsafe_runtime'); } + // Missing receipt can mean the guardian is still starting; it cannot prove an empty process tree. + // An old PID may now belong to somebody else. Recovery never signals one without a live child handle. + const pids = [child.guardianPid, child.nativePid]; + if (process.platform === 'win32') { + if (child.jobEmpty !== true || !Number.isSafeInteger(child.helperPid) || child.helperPid <= 0) throw new ModelUnavailable('unsafe_runtime'); + pids.push(child.helperPid); + } + if (!pids.every(pidGone)) throw new ModelUnavailable('unsafe_runtime'); + if (dirname(realpathSync(home)) !== realpathSync(base)) throw new ModelUnavailable('unsafe_runtime'); + rmSync(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } catch (error) { if (error?.code === 'MODEL_UNAVAILABLE') throw error; } + } +} + +function nativeFacts(declaration, home) { + const root = beneath(home, declaration.facts.root(home), true); + let bytes = 0, files = 0; + const records = []; + const visit = dir => { + const stat = lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink() || !realpathSync(dir).startsWith(realpathSync(home))) throw failure(); + for (const name of readdirSync(dir)) { + const file = join(dir, name), item = lstatSync(file); + if (item.isSymbolicLink()) throw failure(); + if (item.isDirectory()) visit(file); + else if (item.isFile() && name === 'wire.jsonl') { + if (++files > 16 || (bytes += item.size) > OUTPUT_MAX) throw failure(); + const text = readFileSync(file, 'utf8'); + if (!text.endsWith('\n')) throw failure(); + for (const line of text.split('\n')) if (line.trim()) records.push(JSON.parse(line)); + } + } + }; + visit(root); + return declaration.facts.decode(records); +} + +function validateFacts(facts, route) { + if (!facts || facts.reason !== 'completed' || facts.error || facts.providerFinish !== 'completed' + || facts.model !== route.model || facts.alias !== route.alias || facts.protocol !== route.protocol + || (route.effort !== undefined && facts.thinkingEffort !== route.effort) + || !Array.isArray(facts.toolSnapshots) || !facts.toolSnapshots.length || facts.toolSnapshots.some(count => count !== 0) + || facts.toolCalls !== 0) throw failure(); +} + +function conversation(child, signal) { + const pending = new Map(), messages = []; + let next = 0, buffer = '', bytes = 0, error, guardianResult, nativeResult, outputComplete = false, cleaned = false, stopped, promptFinished = false; + const phases = []; + const decoder = new TextDecoder('utf-8', { fatal: true }); + const nativeClosed = new Promise(done => { stopped = done; }); + const nativeReady = Promise.withResolvers(); + const finishOutput = () => { + if (nativeResult && bytes === nativeResult.outputBytes && !outputComplete) { + outputComplete = true; + try { buffer += decoder.decode(); } catch { fail(failure('ACP_PROTOCOL_ERROR')); } + if (buffer.trim()) fail(failure('ACP_PROTOCOL_ERROR')); + if (pending.size) fail(error ?? failure()); + stopped(); + } + }; + const fail = cause => { + error ??= cause; + nativeReady.resolve(false); + for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); } + pending.clear(); + }; + child.stdin.on('error', () => {}); + child.stderr.on('data', () => {}); + child.stdout.on('data', chunk => { + bytes += chunk.length; + try { buffer += decoder.decode(chunk, { stream: true }); } catch { fail(failure('ACP_PROTOCOL_ERROR')); } + if (bytes > OUTPUT_MAX) { fail(failure('ACP_OUTPUT_LIMIT')); return; } + for (;;) { + const end = buffer.indexOf('\n'); + if (end < 0) break; + const line = buffer.slice(0, end); buffer = buffer.slice(end + 1); + if (!line.trim()) continue; + let message; + try { message = JSON.parse(line); } catch { fail(failure('ACP_PROTOCOL_ERROR')); continue; } + if (message?.jsonrpc !== '2.0' || typeof message !== 'object') { fail(failure('ACP_PROTOCOL_ERROR')); continue; } + if (message.method !== undefined) { + if (message.id !== undefined || message.method !== 'session/update') { fail(failure('ACP_UNEXPECTED_REQUEST')); continue; } + if (/^tool_call/.test(message.params?.update?.sessionUpdate ?? '')) { fail(failure('ACP_UNEXPECTED_REQUEST')); continue; } + if (promptFinished && message.params?.update?.sessionUpdate === 'agent_message_chunk') { fail(failure('ACP_PROTOCOL_ERROR')); continue; } + messages.push(message.params); + } else if (Number.isSafeInteger(message.id) && pending.has(message.id)) { + const entry = pending.get(message.id); pending.delete(message.id); clearTimeout(entry.timer); + if (entry.method === 'session/prompt') promptFinished = true; + if (message.error) entry.reject(failure()); else entry.resolve(message.result); + } else fail(failure('ACP_PROTOCOL_ERROR')); + } + finishOutput(); + }); + child.on('message', message => { + if (message?.type === 'phase' && /^[a-z0-9_-]{1,32}$/.test(message.value) && phases.length < 12) phases.push(message.value); + if (message?.type === 'spawned') nativeReady.resolve(true); + if (message?.type === 'closed') { nativeReady.resolve(false); nativeResult = message; child.stdin.end(); finishOutput(); } + if (message?.type === 'cleanup') cleaned = message.cleaned === true; + }); + const closed = new Promise(done => { + child.on('error', () => { fail(failure()); }); + child.on('close', (code, signalName) => { + guardianResult = { code, signal: signalName }; + if (buffer.trim()) fail(failure('ACP_PROTOCOL_ERROR')); + if (pending.size) fail(error ?? failure()); + stopped(); + nativeReady.resolve(false); + done(guardianResult); + }); + }); + const abort = () => { fail(failure('ABORT_ERR')); if (child.connected) child.send({ type: 'stop' }); child.stdin.end(); }; + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + const request = (method, params, ms) => new Promise((resolve, reject) => { + if (error) { reject(error); return; } + const id = ++next, timer = setTimeout(() => { pending.delete(id); reject(failure('ACP_TIMEOUT')); }, ms); + pending.set(id, { method, resolve, reject, timer }); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); + }); + return { + request, closed, messages, + async waitForNative() { + if (await within(nativeReady.promise, 20_000)) return; + throw error ?? failure('ACP_STARTUP_FAILED'); + }, + cleanupFailure() { + const cause = failure('ACP_CLEANUP_FAILED'); + if (process.platform === 'win32') cause.message += ' (Windows job: ' + (phases.join(', ') || 'guardian-unconfirmed') + ')'; + return cause; + }, + get error() { return error; }, get nativeResult() { return nativeResult; }, get guardianResult() { return guardianResult; }, + async dispose(sessionId) { + signal?.removeEventListener('abort', abort); + if (!error && !guardianResult && sessionId) try { await request('session/close', { sessionId }, 2000); } catch {} + child.stdin.end(); + await within(nativeClosed, 1500); + if (!nativeResult && !guardianResult) { if (child.connected) child.send({ type: 'stop' }); await within(nativeClosed, 4000); } + if (!nativeResult && !guardianResult) { + if (process.platform === 'win32') { + // The helper's job closes independently when it loses the guardian. + try { child.kill('SIGKILL'); } catch {} + } else { + // The guardian owns the native process group. Let it finish cleanup after losing IPC. + if (child.connected) child.disconnect(); + child.unref(); + child.stdout.destroy(); + child.stderr.destroy(); + } + await within(closed, 500); + } + for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(failure()); } + pending.clear(); + return Boolean(outputComplete && nativeResult?.treeClosed); + }, + async release() { + if (child.connected) child.send({ type: 'release' }); + await within(closed, 2000); + return Boolean(guardianResult && cleaned); + }, + }; +} + +export function acpModelRunnerOf(declaration, env) { + return { + capture: hint => selectedConfig(declaration, hint, env).snapshot, + complete: async (snapshot, prompt, signal) => { + if (signal?.aborted) throw failure('ABORT_ERR'); + const selected = selectedConfig(declaration, snapshot, env); + if (!sameSnapshot(selected.snapshot, snapshot)) throw new ModelUnavailable('route_changed'); + const found = typeof declaration.command === 'function' + ? { file: declaration.command(env), viaNode: false } + : executableOf({ ...declaration.command, dirs: declaration.command?.dirs?.(env) ?? [] }, { env }); + if (!found?.file || /\.(?:cmd|bat)$/i.test(found.file)) throw new ModelUnavailable('unsafe_runtime'); + const command = found.viaNode ? nodeExecutable() : found.file; + const leading = found.viaNode ? [found.file] : []; + const base = tmpdir(); cleanOrphans(base, selected.origin); + const home = mkdtempSync(join(base, 'evolver-model-')), nonce = randomBytes(16).toString('hex'); + let sessionId, channel, answer, facts, callStarted = false, safeToRemove = true, thrown; + try { + markHeadlessWorkdir(home); + writeFileSync(join(home, OWNER_FILE), JSON.stringify({ nonce, origin: selected.origin, ownerPid: process.pid }), { mode: 0o600, flag: 'wx' }); + const extra = filesPrepared(declaration, selected.selected, home, selected.secret); + const runEnv = privateEnv(env, extra, home, selected.secret); + const cwd = join(home, 'workspace'); mkdirSync(cwd, { mode: 0o700 }); + const args = [...leading, ...declaration.args(snapshot, { home })]; + if (args.some(arg => typeof arg !== 'string' || arg.includes(selected.secret))) throw new ModelUnavailable('unsafe_runtime'); + const spec = { command, args, home, cwd, base, nonce, ownerPid: process.pid }; + // A separate process group survives host termination long enough to reap its owned native child. + const child = fork(guardianFile, [JSON.stringify(spec)], { execPath: nodeExecutable(), execArgv: [], env: runEnv, detached: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }); + safeToRemove = false; + const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 90_000); + const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + channel = conversation(child, combined); + try { + // The helper can compile before launching the client. The protocol clock starts after launch. + if (process.platform === 'win32') await channel.waitForNative(); + const init = await channel.request('initialize', { protocolVersion: 1, clientInfo: { name: 'evolver', version: '1' }, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } }, 10_000); + if (init?.protocolVersion !== 1) throw failure('ACP_PROTOCOL_ERROR'); + const session = await channel.request('session/new', { cwd, mcpServers: [] }, 10_000); + sessionId = session?.sessionId; + if (typeof sessionId !== 'string' || !sessionId) throw failure('ACP_PROTOCOL_ERROR'); + const options = [{ id: 'model', value: snapshot.alias }, ...(declaration.sessionConfig?.(snapshot, selected.selected) ?? [])]; + if (options.length > 8 || new Set(options.map(option => option.id)).size !== options.length) throw failure('ACP_PROTOCOL_ERROR'); + for (const option of options) { + if (typeof option.id !== 'string' || typeof option.value !== 'string' + || !session.configOptions?.some(offered => offered.id === option.id && offered.options?.some(value => value.value === option.value))) throw failure('ACP_PROTOCOL_ERROR'); + await channel.request('session/set_config_option', { sessionId, configId: option.id, value: option.value }, 10_000); + } + callStarted = true; + const result = await channel.request('session/prompt', { sessionId, prompt: [{ type: 'text', text: prompt }] }, 75_000); + if (result?.stopReason !== 'end_turn') throw failure(); + const chunks = []; + for (const event of channel.messages) { + if (event?.sessionId !== sessionId) throw failure('ACP_PROTOCOL_ERROR'); + const update = event.update; + if (update?.sessionUpdate === 'agent_message_chunk') { + if (update.content?.type !== 'text' || typeof update.content.text !== 'string') throw failure('ACP_PROTOCOL_ERROR'); + chunks.push(update.content.text); + } + } + answer = chunks.join(''); + if (!answer.trim()) throw failure(); + } finally { clearTimeout(timer); } + } catch (error) { thrown = error; } + finally { + if (channel) { + safeToRemove = await channel.dispose(sessionId); + if (!safeToRemove) thrown = channel.cleanupFailure(); + else if (!thrown && (channel.error || channel.nativeResult?.code !== 0 || channel.nativeResult?.failure)) thrown = channel.error ?? failure(); + else if (!thrown && channel.messages.some(event => event?.sessionId !== sessionId)) thrown = failure('ACP_PROTOCOL_ERROR'); + } + if (safeToRemove) { + try { if (!thrown) { facts = nativeFacts(declaration, home); validateFacts(facts, selected.route); } } + catch { thrown = failure(); } + } + if (channel) { + if (!await channel.release()) thrown = channel.cleanupFailure(); + else if (!thrown && channel.guardianResult?.code !== 0) thrown = failure(); + } else if (safeToRemove) { + try { rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } + catch { thrown = failure('ACP_CLEANUP_FAILED'); } + } + } + if (thrown) { + if (!callStarted && thrown.code !== 'MODEL_UNAVAILABLE' && thrown.code !== 'ABORT_ERR' && thrown.code !== 'ACP_CLEANUP_FAILED') throw new ModelUnavailable('unsafe_runtime'); + throw thrown; + } + return { answer, ...(facts?.usage ? { usage: facts.usage } : {}) }; + }, + }; +} diff --git a/lib/runtime/environment.js b/lib/runtime/environment.js index f4892ba..cf3ce59 100644 --- a/lib/runtime/environment.js +++ b/lib/runtime/environment.js @@ -8,14 +8,21 @@ import { hubSignInOf, signInFilesOf } from './hub-login.js'; import { detachedScheduler } from './lifetime/ephemeral.js'; import { inProcessScheduler } from './lifetime/resident.js'; import { modelRunnerOf } from './model-executor.js'; +import { nativeFactsOf } from './native-facts.js'; import { ownerProbeOf } from './process/owner.js'; import { proxyFetchOf, runningProxy } from './proxy.js'; +import { settingsOf } from './settings.js'; import { fileStoresOf } from './store/file-stores.js'; export const EPHEMERAL = 'ephemeral'; export const RESIDENT = 'resident'; -const clock = Object.freeze({ now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }); +const clock = Object.freeze({ now: () => Date.now(), sleep: (ms, signal) => new Promise(resolve => { + const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); }; + const timer = setTimeout(done, ms); + signal?.addEventListener('abort', done, { once: true }); + if (signal?.aborted) done(); +}) }); /** * Builds the ports for one agent in one execution environment: the only place execution differences are decided. @@ -26,7 +33,7 @@ const clock = Object.freeze({ now: () => Date.now(), sleep: (ms) => new Promise( export function environmentFor({ profile, env = process.env, lifetime, runBackground, overrides = {} }) { const stores = fileStoresOf({ dir: profile.stateDir(env), agent: profile.name }); stores.adoptLegacy(profile.legacy); - const request = proxyFetchOf(env); + const request = proxyFetchOf(env, { closeConnection: lifetime === EPHEMERAL }); const ports = { identity: { agent: profile.name, label: profile.label }, sessions: stores.sessions, @@ -39,13 +46,14 @@ export function environmentFor({ profile, env = process.env, lifetime, runBackgr retention: stores.retention, proxy: { request }, model: modelRunnerOf(profile.model, env), + native: nativeFactsOf(profile.native, env), owner: ownerProbeOf(profile.owner), clock, engine: enginePortOf(stores, { runningProxy: () => runningProxy({ env, proxyFetch: request }) }), ledger: { record: (entry) => recordReuseLocally(entry, { enginePrefix: stores.folder('engine') }) }, cli: evolverCliOf({ enginePrefix: stores.folder('engine') }), signIn: hubSignInOf({ files: signInFilesOf(env), enginePrefix: stores.folder('engine'), env }), - settings: { recall: env.EVOLVER_RECALL !== 'off', autoDistill: env.EVOLVER_AUTO_DISTILL !== 'off' }, + settings: settingsOf(env, profile.settingsFile?.(env)), ...overrides, }; const runTasks = async (tasks) => { diff --git a/lib/runtime/headless.js b/lib/runtime/headless.js index fd8c97a..6834f9b 100644 --- a/lib/runtime/headless.js +++ b/lib/runtime/headless.js @@ -1,10 +1,29 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 EvoMap -// A headless model run loads the agent's plugins too; this marker in its environment turns this plugin's hooks -// off there, so a distillation never recalls into its own prompt or starts more workers. +import { lstatSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; + export const HEADLESS_MARKER = 'EVOLVER_HEADLESS'; +const WORKDIR_MARKER = '.evolver-headless'; +const CONTENT = `${HEADLESS_MARKER}=1\n`; + +/** The private scratch marker survives hosts which remove inherited environment variables from their hooks. */ +export function markHeadlessWorkdir(dir) { + writeFileSync(join(dir, WORKDIR_MARKER), CONTENT, { mode: 0o600, flag: 'wx' }); +} -export const isHeadlessRun = (env = process.env) => env[HEADLESS_MARKER] === '1'; +export function isHeadlessRun(env = process.env, cwd = process.cwd()) { + if (env[HEADLESS_MARKER] === '1') return true; + if (typeof cwd !== 'string' || !basename(cwd).startsWith('evolver-model-')) return false; + const normalized = value => process.platform === 'win32' ? resolve(value).toLowerCase() : resolve(value); + if (normalized(dirname(cwd)) !== normalized(tmpdir())) return false; + try { + const file = join(cwd, WORKDIR_MARKER); + const stat = lstatSync(file); + return stat.isFile() && !stat.isSymbolicLink() && stat.size === Buffer.byteLength(CONTENT) && readFileSync(file, 'utf8') === CONTENT; + } catch { return false; } +} export const headlessEnv = (env = process.env) => ({ ...env, [HEADLESS_MARKER]: '1' }); diff --git a/lib/runtime/model-executor.js b/lib/runtime/model-executor.js index fad14a2..2f28d00 100644 --- a/lib/runtime/model-executor.js +++ b/lib/runtime/model-executor.js @@ -8,6 +8,7 @@ import { join } from 'node:path'; import { nodeExecutable } from './process/node.js'; import { runProcess } from './process/spawn.js'; import { executableOf } from './process/which.js'; +import { acpModelRunnerOf } from './acp-model.js'; const ERROR_MAX_CHARS = 300; const PREFLIGHT_TIMEOUT_MS = 10_000; @@ -37,6 +38,7 @@ function exitFailureOf(declaration, command, result) { * timeout or cancel. */ export function modelRunnerOf(declaration, env = process.env) { + if (declaration?.kind === 'acp') return acpModelRunnerOf(declaration, env); if (declaration?.kind === 'sdk') return typeof declaration.complete === 'function' ? { complete: declaration.complete } : null; if (declaration?.kind !== 'cli') return null; const found = typeof declaration.command === 'function' diff --git a/lib/runtime/native-config.js b/lib/runtime/native-config.js new file mode 100644 index 0000000..b379848 --- /dev/null +++ b/lib/runtime/native-config.js @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { createHash } from 'node:crypto'; +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; +import { parse } from 'smol-toml'; + +const CONFIG_MAX_BYTES = 1024 * 1024; +const CREDENTIAL_MAX_BYTES = 16 * 1024; +const MIN_ACCESS_LIFETIME_SECONDS = 120; +const REASONS = new Set(['route_unresolved', 'route_changed', 'credential_missing', 'auth_unsupported', 'unsafe_runtime']); + +/** A preflight failure, before a model request: callers retain the candidate without spending a model attempt. */ +export class ModelUnavailable extends Error { + constructor(reason) { + const safe = REASONS.has(reason) ? reason : 'unsafe_runtime'; + super(`Model completion is unavailable (${safe})`); + this.code = 'MODEL_UNAVAILABLE'; + this.reason = safe; + } +} + +function boundedBytes(file, maximum, reason) { + if (typeof file !== 'string' || !isAbsolute(file)) throw new ModelUnavailable(reason); + let fd; + try { + const leaf = lstatSync(file); + if (!leaf.isFile() || leaf.isSymbolicLink() || leaf.size > maximum) throw new Error(); + fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > maximum || stat.dev !== leaf.dev || stat.ino !== leaf.ino) throw new Error(); + const bytes = Buffer.alloc(maximum + 1); + let length = 0; + for (;;) { + const read = readSync(fd, bytes, length, bytes.length - length, null); + if (!read) break; + length += read; + if (length > maximum) throw new Error(); + } + return bytes.subarray(0, length); + } catch { throw new ModelUnavailable(reason); } + finally { if (fd !== undefined) closeSync(fd); } +} + +function routeOf(value) { + if (!value || typeof value !== 'object') throw new ModelUnavailable('route_unresolved'); + const route = {}; + for (const key of ['alias', 'providerId', 'model', 'protocol', 'endpoint']) { + const item = value[key]; + if (typeof item !== 'string' || !item || item.length > 2048 || /[\0\r\n]/.test(item)) throw new ModelUnavailable('route_unresolved'); + route[key] = item; + } + if (!['openai', 'anthropic', 'openai_responses'].includes(route.protocol)) throw new ModelUnavailable('route_unresolved'); + if (value.providerType !== undefined) { + if (typeof value.providerType !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/.test(value.providerType)) throw new ModelUnavailable('route_unresolved'); + route.providerType = value.providerType; + } + let endpoint; + try { endpoint = new URL(route.endpoint); } catch { throw new ModelUnavailable('route_unresolved'); } + if ((endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && ['127.0.0.1', '[::1]', 'localhost'].includes(endpoint.hostname))) + || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) throw new ModelUnavailable('route_unresolved'); + route.endpoint = endpoint.href; + if (value.effort !== undefined && value.effort !== null) { + if (typeof value.effort !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(value.effort)) throw new ModelUnavailable('route_unresolved'); + route.effort = value.effort; + } + return route; +} + +function credentialOf(credential, env) { + if (credential?.kind === 'oauth-access') { + try { + const { access_token: secret, token_type: type, expires_at: expires } = JSON.parse(boundedBytes(credential.tokenFile, CREDENTIAL_MAX_BYTES, 'credential_missing').toString('utf8')); + const deviceId = boundedBytes(credential.deviceIdFile, 256, 'credential_missing').toString('utf8').trim(); + if (typeof secret !== 'string' || !/^[!-~]{1,8192}$/.test(secret) || typeof type !== 'string' || type.toLowerCase() !== 'bearer' + || !Number.isSafeInteger(expires) || expires - Date.now() / 1000 < MIN_ACCESS_LIFETIME_SECONDS || !/^[A-Za-z0-9_-]{1,128}$/.test(deviceId)) throw new Error(); + return { secret, deviceId }; + } catch { throw new ModelUnavailable('credential_missing'); } + } + if (credential?.kind !== 'api-key') throw new ModelUnavailable('auth_unsupported'); + if ((credential.value !== undefined) === (credential.env !== undefined)) throw new ModelUnavailable('credential_missing'); + if (credential.env !== undefined && (typeof credential.env !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(credential.env))) throw new ModelUnavailable('credential_missing'); + const value = credential.env === undefined ? credential.value : env[credential.env]; + const secret = typeof value === 'string' ? value.trim() : ''; + if (!secret || /[\0\r\n]/.test(secret)) throw new ModelUnavailable('credential_missing'); + return { secret }; +} + +/** Read one bounded snapshot; raw config and credential remain local to this invocation. */ +export function selectedConfig(declaration, hint, env) { + let bytes, selected, source; + try { + source = declaration.config.source(env); + bytes = boundedBytes(source, CONFIG_MAX_BYTES, 'route_unresolved'); + selected = declaration.config.select(parse(bytes.toString('utf8')), hint, env); + } catch (error) { + if (error?.code === 'MODEL_UNAVAILABLE') throw new ModelUnavailable(error.reason); + throw new ModelUnavailable('route_unresolved'); + } + if (selected?.unavailable) throw new ModelUnavailable(selected.unavailable); + const route = routeOf(selected?.route); + const { secret, deviceId } = credentialOf(selected?.credential, env); + const configRevision = createHash('sha256').update(bytes).digest('hex'); + const hash = createHash('sha256').update(configRevision).update('\0').update(JSON.stringify(route)).update('\0').update(secret); + if (deviceId) hash.update('\0').update(deviceId); + const binding = hash.digest('hex'); + const origin = createHash('sha256').update(resolve(source)).digest('hex'); + return { selected: deviceId ? { ...selected, deviceId } : selected, route, secret, origin, snapshot: { ...route, configRevision, binding } }; +} + +export function sameSnapshot(actual, expected) { + return expected && typeof expected === 'object' && JSON.stringify(actual) === JSON.stringify(expected); +} diff --git a/lib/runtime/native-facts.js b/lib/runtime/native-facts.js new file mode 100644 index 0000000..b036ba9 --- /dev/null +++ b/lib/runtime/native-facts.js @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { closeSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +const MAX_INDEX = 1024 * 1024; +const MAX_BATCH = 4 * 1024 * 1024; +const ID = /^[A-Za-z0-9_-]{1,128}$/; + +function confined(root, file) { + const candidate = resolve(file), rel = relative(root, candidate); + if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw new Error('unsafe'); + let current = root; + for (const part of rel.split(sep)) { + current = join(current, part); + if (lstatSync(current).isSymbolicLink()) throw new Error('unsafe'); + } + const actual = realpathSync(candidate), back = relative(root, actual); + if (!back || back.startsWith('..') || isAbsolute(back) || !statSync(actual).isFile()) throw new Error('unsafe'); + return actual; +} + +function jsonOf(root, file, maximum) { + const safe = confined(root, file); + const before = statSync(safe), fd = openSync(safe, 'r'); + try { + const stat = fstatSync(fd); + if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino) throw new Error('rotated'); + if (stat.size > maximum) throw new Error('oversize'); + const buffer = Buffer.alloc(maximum + 1); + let size = 0, read; + do { read = readSync(fd, buffer, size, buffer.length - size, null); size += read; } while (read && size < buffer.length); + if (size > maximum) throw new Error('oversize'); + return JSON.parse(buffer.subarray(0, size).toString('utf8')); + } finally { closeSync(fd); } +} + +function recordsOf(root, file, after) { + const safe = confined(root, file), stat = statSync(safe), fileId = `${stat.dev}:${stat.ino}`; + const offset = after?.offset ?? 0; + if (!Number.isSafeInteger(offset) || offset < 0 || offset > stat.size || (after?.fileId && after.fileId !== fileId)) throw new Error('rotated'); + const length = Math.min(MAX_BATCH, stat.size - offset), buffer = Buffer.alloc(length); + let fd; + let size = 0; + try { + fd = openSync(safe, 'r'); + const opened = fstatSync(fd); + if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino) throw new Error('rotated'); + while (size < length) { + const read = readSync(fd, buffer, size, length - size, offset + size); + if (!read) break; + size += read; + } + } + finally { if (fd !== undefined) closeSync(fd); } + const records = []; let start = 0; + const content = buffer.subarray(0, size); + for (let end = content.indexOf(10); end >= 0; end = content.indexOf(10, start)) { + const line = buffer.subarray(start, end).toString('utf8'); + if (line.trim()) { + const value = JSON.parse(line); + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid'); + Object.defineProperty(value, '$offset', { value: offset + start }); + records.push(value); + } + start = end + 1; + } + return { records, mark: { offset: offset + start, fileId }, incompleteTail: start < size, limited: stat.size - offset > MAX_BATCH || (size === MAX_BATCH && start < size) }; +} + +/** Read-only native facts over a declared indexed-session layout. A failed read is never an empty valid turn. */ +export function nativeFactsOf(declaration, env = process.env) { + if (!declaration) return null; + let scopedRoot = resolve(declaration.root(env)); + try { scopedRoot = realpathSync(scopedRoot); } catch { /* A fresh native profile may not exist yet. */ } + const scope = createHash('sha256').update(scopedRoot).digest('hex'); + return { scope, read(payload, context = {}) { + try { + const root = realpathSync(declaration.root(env)); + const sessionId = declaration.sessionId(payload), projectDir = realpathSync(declaration.projectDir(payload)); + if (!ID.test(sessionId) || !ID.test(declaration.agent ?? 'main')) return { status: 'unsupported' }; + const index = jsonOf(root, join(root, declaration.workspaceIndex), MAX_INDEX); + if (index?.version !== 1 || !index.workspaces || typeof index.workspaces !== 'object' || Array.isArray(index.workspaces)) return { status: 'unsupported' }; + const workspaces = Object.entries(index.workspaces).filter(([id, workspace]) => { + if (!ID.test(id) || typeof workspace?.root !== 'string') return false; + try { return realpathSync(workspace.root) === projectDir; } catch { return false; } + }); + if (workspaces.length !== 1) return { status: 'missing' }; + const sessionDir = join(root, 'sessions', workspaces[0][0], sessionId); + const wire = join(sessionDir, 'agents', declaration.agent ?? 'main', 'wire.jsonl'); + const batch = recordsOf(root, wire, context.after); + const ids = declaration.taskIds?.(batch.records, payload) ?? []; + if (!Array.isArray(ids) || ids.length > 64 || ids.some(id => !ID.test(id))) return { status: 'partial', mark: batch.mark }; + const tasks = [], taskRoot = declaration.tasksRoot?.(env, sessionDir); + let budget = MAX_BATCH; + for (const id of new Set(ids)) { + if (!taskRoot) break; + try { + const file = confined(root, join(taskRoot, `${id}.json`)), size = statSync(file).size; + if (size > MAX_INDEX || size > budget) return { status: 'partial', mark: batch.mark }; + budget -= size; + const task = jsonOf(root, file, size); + if (task?.taskId !== id) return { status: 'partial', mark: batch.mark }; + tasks.push(task); + } catch (error) { if (error?.code !== 'ENOENT') return { status: 'partial', mark: batch.mark }; } + } + const decoded = declaration.decode(batch.records, tasks, payload, { ...context, projectDir }); + return { ...decoded, records: batch.records, tasks, mark: batch.mark, incompleteTail: batch.incompleteTail, limited: batch.limited, + status: batch.limited || batch.incompleteTail ? 'partial' : decoded.status ?? 'ok' }; + } catch (error) { + return { status: error?.code === 'ENOENT' ? 'missing' : error?.message === 'oversize' || error?.message === 'rotated' ? 'partial' : 'unreadable' }; + } + } }; +} diff --git a/lib/runtime/native-monitor.js b/lib/runtime/native-monitor.js new file mode 100644 index 0000000..18c637a --- /dev/null +++ b/lib/runtime/native-monitor.js @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; + +import { withLock } from './store/file-lock.js'; + +const POLL_MS = 500; +const MISSING_MS = 30_000; +const MAX_WATCH_MS = 24 * 60 * 60 * 1000; + +/** Hook completion can precede the native terminal write. One owned monitor reconciles that logical turn. */ +export async function monitorNativeTurn(ports, at, reconcile, abort, { dir, now = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) }) { + if (!ports.native || !at || typeof at.sessionId !== 'string' || !at.sessionId || at.sessionId.length > 2048 + || !Number.isSafeInteger(at.turn) || at.turn < 1 || typeof at.projectDir !== 'string') return false; + const key = createHash('sha256').update(JSON.stringify([at.sessionId, at.turn])).digest('hex'); + return withLock(join(dir, key), async lock => { + const start = now(); + while (now() - start < MAX_WATCH_MS) { + const session = await ports.sessions.read(at.sessionId); + if (session.turn !== at.turn || session.native?.turn !== at.turn) return; + // Native terminal wins over owner death; orphan recovery must not turn a completed check into an abort. + const result = await reconcile(at); + if (result?.facts?.limited || ['unreadable', 'unsupported'].includes(result?.facts?.status)) return; + // An end before native admission cannot become a running turn; do not retain a detached observer for it. + if (session.native.sessionEnded && !result?.native?.turnId) return; + if (ports.owner.isGone(session.pid)) { + if (!result?.ended) await abort({ ...at, expectedTurn: session.turn, expectedPid: session.pid, reason: 'aborted' }); + return; + } + if (result?.ended && !result.waiting) return; + if (!session.open && !result?.waiting) return; + if (!result?.native?.turnId && now() - start >= MISSING_MS) return; + lock.refresh(); + await sleep(POLL_MS); + } + }); +} diff --git a/lib/runtime/process/acp-child.js b/lib/runtime/process/acp-child.js new file mode 100644 index 0000000..16bee17 --- /dev/null +++ b/lib/runtime/process/acp-child.js @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { spawn } from 'node:child_process'; +import { lstatSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { writeState } from '../store/file-lock.js'; +import { startWindowsJob } from './windows-job.js'; + +export const guardianFile = fileURLToPath(import.meta.url); +export const OWNER_FILE = '.evolver-model-owner.json'; +export const CHILD_FILE = '.evolver-owned-child.json'; + +const pause = ms => new Promise(done => setTimeout(done, ms)); +const bounded = async (promise, ms) => { + let timer; + try { return await Promise.race([promise, new Promise(done => { timer = setTimeout(done, ms); })]); } + finally { clearTimeout(timer); } +}; +const pidGone = pid => { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { process.kill(pid, 0); return false; } catch (error) { return error?.code === 'ESRCH'; } +}; +const groupGone = pid => { + try { process.kill(-pid, 0); return false; } catch (error) { return error?.code === 'ESRCH'; } +}; + +function ownedHome(home, base, nonce) { + const stat = lstatSync(home); + if (!stat.isDirectory() || stat.isSymbolicLink() || dirname(realpathSync(home)) !== realpathSync(base) || !basename(home).startsWith('evolver-model-')) throw new Error('Unowned scratch'); + const marker = join(home, OWNER_FILE); + const mark = lstatSync(marker); + if (!mark.isFile() || mark.isSymbolicLink() || mark.size > 2048 || JSON.parse(readFileSync(marker, 'utf8')).nonce !== nonce) throw new Error('Unowned scratch marker'); +} + +async function killNative(child) { + if (!child?.pid || child.pid === process.pid || child.pid === process.ppid) return false; + try { process.kill(-child.pid, 'SIGTERM'); } catch {} + await pause(100); + if (!groupGone(child.pid)) try { process.kill(-child.pid, 'SIGKILL'); } catch {} + return groupGone(child.pid); +} + +/** Separate control pipe: losing the model worker ends the native process rather than orphaning it. */ +async function guard() { + let spec; + try { + spec = JSON.parse(process.argv[2]); + ownedHome(spec.home, spec.base, spec.nonce); + if (typeof spec.command !== 'string' || !Array.isArray(spec.args) || spec.args.some(arg => typeof arg !== 'string')) throw new Error(); + } catch { process.exitCode = 1; process.disconnect?.(); return; } + + let child, job, receipt, result, parentLost = false, failure, timer, stopping = false, release; + const released = new Promise(done => { release = done; }); + const notify = value => { if (process.connected) try { process.send(value, () => {}); } catch {} }; + const phase = value => notify({ type: 'phase', value }); + const remember = nativePid => { + try { + if (!nativePid) throw new Error(); + receipt = { nonce: spec.nonce, guardianPid: process.pid, nativePid, ...(job && { helperPid: child.pid, jobEmpty: false }) }; + writeFileSync(join(spec.home, CHILD_FILE), JSON.stringify(receipt), { mode: 0o600, flag: 'wx' }); + notify({ type: 'spawned', pid: nativePid }); + } catch { failure = 'ownership_failed'; void terminate(); } + }; + const stopped = new Promise(done => { + if (process.platform === 'win32') { phase('guardian-started'); job = startWindowsJob(spec, remember, phase); child = job.child; } + else child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: process.env, detached: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + child.on('error', () => { failure = 'spawn_failed'; }); + child.on('close', (code, signal) => { result = { code, signal }; done(); }); + }); + child.stdin.on('error', () => {}); + process.stdout.on('error', () => {}); + let bytes = 0, outputBytes = 0; + const count = chunk => { + bytes += chunk.length; + if (bytes > 16 * 1024 * 1024 && !failure) { failure = 'output_limit'; void terminate(); } + }; + child.stdout.on('data', chunk => { count(chunk); if (!failure) { outputBytes += chunk.length; process.stdout.write(chunk); } }); + if (!job) { child.stderr.on('data', count); remember(child.pid); } + + async function terminate() { + if (stopping) return; + stopping = true; + if (job) { + try { job.stop(); } catch { child.kill('SIGKILL'); } + await bounded(stopped, 3000); + // Closing the owned helper handle kills its job, but does not prove the job has drained. + if (!result) child.kill('SIGKILL'); + } else { + await killNative(child); + await bounded(stopped, 3000); + } + } + const endInput = () => { + if (result) return; + child.stdin.end(); + timer ??= setTimeout(() => { failure ??= 'shutdown_timeout'; void terminate(); }, parentLost ? 250 : 5000); + }; + process.stdin.on('data', chunk => { if (!child.stdin.destroyed) child.stdin.write(chunk); }); + process.stdin.on('end', endInput); + process.on('message', message => { + if (message?.type === 'stop') { failure ??= 'cancelled'; void terminate(); } + if (message?.type === 'release') release(); + }); + process.on('disconnect', () => { parentLost = true; release(); clearTimeout(timer); timer = null; endInput(); }); + process.on('SIGTERM', () => { failure ??= 'cancelled'; void terminate(); }); + const ownerWatch = setInterval(() => { + if (!pidGone(spec.ownerPid)) return; + parentLost = true; release(); endInput(); void terminate(); + }, 100); + + await stopped; + clearTimeout(timer); + if (!job && !groupGone(child.pid)) { + failure ??= 'leftover_process'; + await killNative(child); + } + const proof = job?.proof(result.code, result.signal); + const treeClosed = job ? Boolean(proof) : groupGone(child.pid); + if (proof) { + result = { code: proof.code, signal: null }; + failure ??= proof.failure; + if (receipt) try { writeState(join(spec.home, CHILD_FILE), { ...receipt, jobEmpty: true }); } catch { /* The live proof still permits cleanup. */ } + } else if (job) failure ??= 'job_unproven'; + notify({ type: 'closed', ...result, treeClosed, failure, outputBytes }); + // The worker reads closed native facts before release. Losing its IPC pipe releases cleanup too. + if (parentLost || pidGone(spec.ownerPid)) release(); + await released; + clearInterval(ownerWatch); + let cleaned = false; + if (treeClosed) { + try { + ownedHome(spec.home, spec.base, spec.nonce); + rmSync(spec.home, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + cleaned = true; + } catch { /* The ownership receipt remains for a later bounded recovery. */ } + } + if (job) phase(cleaned ? 'scratch-removed' : 'scratch-retained'); + notify({ type: 'cleanup', cleaned }); + process.exitCode = failure || !cleaned ? 1 : 0; + if (process.connected) process.disconnect(); +} + +if (process.argv[1] && resolve(process.argv[1]) === guardianFile) { + guard().catch(() => { process.exitCode = 1; if (process.connected) process.disconnect(); }); +} diff --git a/lib/runtime/process/owner.js b/lib/runtime/process/owner.js index 8d39208..85fff64 100644 --- a/lib/runtime/process/owner.js +++ b/lib/runtime/process/owner.js @@ -5,9 +5,35 @@ import { spawnSync } from 'node:child_process'; import { readlinkSync } from 'node:fs'; import path from 'node:path'; -const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'cmd.exe', 'powershell', 'pwsh']); -const PS_TIMEOUT_MS = 1000; +import { windowsAncestorsOf } from './windows.js'; + +const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'cmd', 'powershell', 'pwsh']); +export const PROCESS_START_TIMEOUT_MS = 1_000; +// POSIX ps reports start times to the nearest second. +export const PROCESS_START_TOLERANCE_MS = 2_000; const MAX_ANCESTORS = 16; +const ownStartTime = Math.round(Date.now() - process.uptime() * 1000); + +/** A bounded process start-time probe for lock recovery. The hot path needs no subprocess. */ +export function processStartTime(pid, timeout = PROCESS_START_TIMEOUT_MS) { + if (pid === process.pid) return ownStartTime; + if (!Number.isSafeInteger(pid) || pid <= 0 || timeout <= 0) return null; + const windows = process.platform === 'win32'; + const systemRoot = process.env.SystemRoot ?? 'C:\\Windows'; + if (windows && !path.isAbsolute(systemRoot)) return null; + const executable = windows ? path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : '/bin/ps'; + const args = windows + ? ['-NoProfile', '-NonInteractive', '-Command', `([DateTimeOffset] (Get-Process -Id ${pid} -ErrorAction Stop).StartTime).ToUnixTimeMilliseconds()`] + : ['-p', String(pid), '-o', 'lstart=']; + const env = windows ? process.env : { ...process.env, LC_ALL: 'C', TZ: 'UTC' }; + const result = spawnSync(executable, args, { + env, encoding: 'utf8', windowsHide: true, timeout, killSignal: 'SIGKILL', + }); + const started = result.status === 0 ? result.stdout.trim() : ''; + if (!started) return null; + const time = windows ? Number(started) : Date.parse(started + ' GMT'); + return Number.isSafeInteger(time) && time > 0 ? time : null; +} /** * Whether the process that wrote a state entry has exited. Signal 0 only probes: ESRCH means no such process; @@ -35,7 +61,7 @@ function executablePathOf(pid, command) { } export function processInfo(pid) { - const probe = spawnSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', timeout: PS_TIMEOUT_MS }); + const probe = spawnSync('ps', ['-o', 'ppid=,comm=', '-p', String(pid)], { encoding: 'utf8', timeout: PROCESS_START_TIMEOUT_MS }); const match = /^\s*(\d+)\s+(.+?)\s*$/.exec(probe.stdout ?? ''); if (!match) return null; const command = match[2].replace(/^-/, ''); @@ -43,9 +69,14 @@ export function processInfo(pid) { } function parentPid(probe) { - const parent = probe(process.ppid); - if (!parent) return null; - return SHELLS.has(parent.name) ? parent.ppid : process.ppid; + let pid = process.ppid; + for (let depth = 0; depth < MAX_ANCESTORS && pid > 1; depth += 1) { + const parent = probe(pid); + if (!parent) return null; + if (!SHELLS.has(parent.name.toLowerCase().replace(/\.exe$/, ''))) return pid; + pid = parent.ppid; + } + return null; } function ancestorPid(isOwner, probe) { @@ -64,17 +95,20 @@ function ancestorPid(isOwner, probe) { * (the process that started this one with no shell between, such as a host's relay), 'parent' (the host that ran this * hook; a shell in between is skipped), `{ ancestor: /name/ }` (the nearest ancestor so named) or * `{ ancestorPath: /path/ }` (the nearest whose executable's path matches: a host running as `node`). The last three - * need a process table; where none can be read, Windows included, they are null and such entries never read as + * need a process table; where none can be read they are null and such entries never read as * orphaned. */ -export function ownerProbeOf(declaration, { probe = processInfo, platform = process.platform } = {}) { +export function ownerProbeOf(declaration, { probe, platform = process.platform } = {}) { + let windows; + const readProcess = probe ?? (platform === 'win32' + ? (pid) => (windows ??= windowsAncestorsOf(process.ppid)).get(pid) ?? null + : processInfo); const locate = () => { if (declaration === 'self') return process.pid; if (declaration === 'spawner') return process.ppid; - if (platform === 'win32') return null; - if (declaration === 'parent') return parentPid(probe); - if (declaration?.ancestor instanceof RegExp) return ancestorPid((info) => declaration.ancestor.test(info.name), probe); - if (declaration?.ancestorPath instanceof RegExp) return ancestorPid((info) => declaration.ancestorPath.test(info.path ?? info.name), probe); + if (declaration === 'parent') return parentPid(readProcess); + if (declaration?.ancestor instanceof RegExp) return ancestorPid((info) => declaration.ancestor.test(info.name), readProcess); + if (declaration?.ancestorPath instanceof RegExp) return ancestorPid((info) => declaration.ancestorPath.test(info.path ?? info.name), readProcess); return null; }; let current; diff --git a/lib/runtime/process/spawn.js b/lib/runtime/process/spawn.js index 9512f10..feade5d 100644 --- a/lib/runtime/process/spawn.js +++ b/lib/runtime/process/spawn.js @@ -14,6 +14,20 @@ import { headlessEnv } from '../headless.js'; const KILL_GRACE_MS = 5_000; const OUTPUT_MAX_BYTES = 16 * 1024 * 1024; const IS_WINDOWS = process.platform === 'win32'; +const WINDOWS_ROOT = process.env.SystemRoot || process.env.WINDIR || ''; +const SYSTEM32 = IS_WINDOWS && path.isAbsolute(WINDOWS_ROOT) ? path.join(WINDOWS_ROOT, 'System32') : null; + +function windowsSystemDirectory() { + // A stripped host environment still has the Windows loader mapped from the system directory. + const loader = process.report.getReport().sharedObjects.find((file) => + path.isAbsolute(file) && /[\\/](?:system32|syswow64)[\\/]ntdll\.dll$/i.test(file)); + return loader ? path.dirname(loader) : null; +} + +const TASKKILL_DIR = IS_WINDOWS ? SYSTEM32 ?? windowsSystemDirectory() : null; + +// Let detached spawn handles finish closing rather than force shutdown on Windows. +export const exitAfterHookReply = IS_WINDOWS ? code => { process.exitCode = code; } : process.exit; // npm shims (`evolver`, `npx`, `npm`) only run through cmd.exe, which reads the line once while the shim re-reads its // arguments, so each argument is quoted for the C runtime and its cmd metacharacters are caret-escaped twice. @@ -24,8 +38,14 @@ export function cmdArgument(arg) { return quoted.replace(CMD_METACHARACTERS, '^$1').replace(CMD_METACHARACTERS, '^$1'); } +const cmdLine = (command, args) => [String(command).replace(CMD_METACHARACTERS, '^$1'), ...args.map(cmdArgument)].join(' '); + function taskkill(pid) { - execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {}); + if (!TASKKILL_DIR) return; + const root = path.dirname(TASKKILL_DIR); + // taskkill itself needs the system root, even when launched by its absolute path. + const env = SYSTEM32 ? process.env : { ...process.env, SystemRoot: root, WINDIR: root }; + execFile(path.join(TASKKILL_DIR, 'taskkill.exe'), ['/pid', String(pid), '/T', '/F'], { env, windowsHide: true }, () => {}); } function signalGroup(pid, name) { @@ -50,6 +70,9 @@ export function endTree(pid, { platform = process.platform, killWindowsTree = ta export function runProcess(command, args = [], { input, env = process.env, cwd = os.homedir(), timeoutMs, signal, shim = false, headless = false } = {}) { return new Promise((resolve) => { const viaShell = IS_WINDOWS && shim; + if ((viaShell && !SYSTEM32) || (IS_WINDOWS && !TASKKILL_DIR && (timeoutMs || signal))) { + return resolve({ code: null, stdout: '', stderr: '', error: Object.assign(new Error('Windows system directory is unavailable; no command was started'), { code: 'ENOENT' }) }); + } let stdout = ''; let stderr = ''; let settled = false; @@ -60,8 +83,8 @@ export function runProcess(command, args = [], { input, env = process.env, cwd = signal?.removeEventListener('abort', stop); resolve({ stdout, stderr, ...result }); }; - const child = spawn(command, viaShell ? args.map(cmdArgument) : args, { - cwd, env: headless ? headlessEnv(env) : env, shell: viaShell, detached: !IS_WINDOWS, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], + const child = spawn(viaShell ? cmdLine(command, args) : command, viaShell ? [] : args, { + cwd, env: headless ? headlessEnv(env) : env, shell: viaShell ? path.join(SYSTEM32, 'cmd.exe') : false, detached: !IS_WINDOWS, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], }); const stop = () => child.pid && endTree(child.pid); const timer = timeoutMs ? setTimeout(() => { stop(); finish({ code: null, error: Object.assign(new Error(`timed out after ${timeoutMs} ms`), { code: 'ETIMEDOUT' }) }); }, timeoutMs) : null; @@ -84,8 +107,9 @@ export function startDetached(command, args, { env = process.env, cwd = os.homed child.unref(); } -function isOnWindowsPath(name) { - return new Promise((resolve) => execFile('where', [name], { windowsHide: true }, (error) => resolve(!error))); +function isOnWindowsPath(name, env = process.env) { + if (!SYSTEM32) return Promise.resolve(false); + return new Promise((resolve) => execFile(path.join(SYSTEM32, 'where.exe'), [name], { env, windowsHide: true }, (error) => resolve(!error))); } function spawnedOrMissing(child) { @@ -102,12 +126,12 @@ function spawnedOrMissing(child) { */ export async function startLogged(command, args, { logFile, env = process.env, cwd = os.homedir(), shim = false }) { const viaShell = IS_WINDOWS && shim; - if (viaShell && !(await isOnWindowsPath(command))) return { missing: true }; + if (viaShell && !(await isOnWindowsPath(command, env))) return { missing: true }; fs.mkdirSync(path.dirname(logFile), { recursive: true, mode: 0o700 }); const log = fs.openSync(logFile, 'w', 0o600); try { - const child = spawn(command, viaShell ? args.map(cmdArgument) : args, { - cwd, env, shell: viaShell, detached: true, windowsHide: true, stdio: ['ignore', log, log], + const child = spawn(viaShell ? cmdLine(command, args) : command, viaShell ? [] : args, { + cwd, env, shell: viaShell ? path.join(SYSTEM32, 'cmd.exe') : false, detached: true, windowsHide: true, stdio: ['ignore', log, log], }); const started = spawnedOrMissing(child); child.on('error', () => {}); diff --git a/lib/runtime/process/which.js b/lib/runtime/process/which.js index 067e518..d38bc67 100644 --- a/lib/runtime/process/which.js +++ b/lib/runtime/process/which.js @@ -22,13 +22,14 @@ function realPathOf(file) { */ export function executableOf(declaration, { env = process.env, platform = process.platform, exists = existsSync, realPath = realPathOf } = {}) { const isWindows = platform === 'win32'; + const paths = isWindows ? path.win32 : path.posix; const viaNode = isWindows && Boolean(declaration.nodeScriptOnWindows); - const pathDirs = viaNode ? [] : String(env.PATH ?? '').split(path.delimiter).filter(Boolean); + const pathDirs = viaNode ? [] : String(env.PATH ?? '').split(paths.delimiter).filter(Boolean); const extensions = !isWindows || viaNode ? [''] : ['.exe', ...(declaration.windowsShims ? ['.cmd'] : [])]; for (const name of declaration.names) { for (const dir of [...pathDirs, ...(declaration.dirs ?? [])]) { for (const extension of extensions) { - const file = path.join(dir, `${name}${extension}`); + const file = paths.join(dir, `${name}${extension}`); if (exists(file) && (!declaration.accepts || declaration.accepts(name, realPath(file)))) return { file, viaNode }; } } diff --git a/lib/runtime/process/windows-job.js b/lib/runtime/process/windows-job.js new file mode 100644 index 0000000..2404030 --- /dev/null +++ b/lib/runtime/process/windows-job.js @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { spawn } from 'node:child_process'; +import { existsSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join } from 'node:path'; + +// Compile the small Win32 bridge inside owned scratch. +const SCRIPT = String.raw`param([string] $SpecPath) +$ErrorActionPreference = 'Stop' +$key = [Environment]::GetEnvironmentVariable('EVOLVER_MODEL_API_KEY', 'Process') +[Environment]::SetEnvironmentVariable('EVOLVER_MODEL_API_KEY', $null, 'Process') +[Console]::Error.WriteLine('P bootstrap') +try { + $spec = Get-Content -LiteralPath $SpecPath -Raw -Encoding UTF8 | ConvertFrom-Json + [Console]::Error.WriteLine('P compiling') + Add-Type -Language CSharp -TypeDefinition @' +using System; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using Microsoft.Win32.SafeHandles; + +public sealed class EvolverJob { + [StructLayout(LayoutKind.Sequential)] struct Security { public int Size; public IntPtr Descriptor; public int Inherit; } + [StructLayout(LayoutKind.Sequential)] struct Startup { + public int Size; + public IntPtr Reserved, Desktop, Title; + public int X, Y, Width, Height, XChars, YChars, Fill, Flags; + public short Show, ReservedSize; + public IntPtr ReservedBytes, Input, Output, Error; + } + [StructLayout(LayoutKind.Sequential)] struct StartupEx { public Startup Startup; public IntPtr Attributes; } + [StructLayout(LayoutKind.Sequential)] struct ProcessInfo { public IntPtr Process, Thread; public uint Pid, Tid; } + [StructLayout(LayoutKind.Sequential)] struct BasicLimits { + public long ProcessTime, JobTime; + public uint Flags; + public UIntPtr MinWorkingSet, MaxWorkingSet; + public uint ActiveLimit; + public UIntPtr Affinity; + public uint Priority, Scheduling; + } + [StructLayout(LayoutKind.Sequential)] struct Limits { + public BasicLimits Basic; + public ulong Reads, Writes, Others, ReadBytes, WriteBytes, OtherBytes; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + [StructLayout(LayoutKind.Sequential)] struct Accounting { + public long UserTime, KernelTime, PeriodUserTime, PeriodKernelTime; + public uint Faults, Total, Active, Terminated; + } + + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CreatePipe(out IntPtr read, out IntPtr write, ref Security security, uint size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern IntPtr CreateJobObject(IntPtr security, string name); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool SetInformationJobObject(IntPtr job, int kind, ref Limits limits, int size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool QueryInformationJobObject(IntPtr job, int kind, out Accounting info, int size, IntPtr returned); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, uint flags, ref IntPtr size); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, UIntPtr attribute, IntPtr value, IntPtr size, IntPtr oldValue, IntPtr returned); + [DllImport("kernel32.dll")] static extern void DeleteProcThreadAttributeList(IntPtr list); + [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] static extern bool CreateProcess(string application, StringBuilder command, IntPtr processSecurity, IntPtr threadSecurity, bool inherit, uint flags, IntPtr environment, string cwd, ref StartupEx startup, out ProcessInfo info); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError=true)] static extern uint WaitForSingleObject(IntPtr handle, uint ms); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetExitCodeProcess(IntPtr process, out uint code); + + static void Check(bool ok) { if (!ok) throw new Win32Exception(); } + static void Close(IntPtr handle) { if (handle != IntPtr.Zero) CloseHandle(handle); } + static void Proof(string line) { Console.Error.WriteLine(line); Console.Error.Flush(); } + + sealed class Pipe : IDisposable { + public IntPtr Child; + public FileStream Parent; + public Pipe(bool input) { + var security = new Security { Size = Marshal.SizeOf(typeof(Security)), Inherit = 1 }; + IntPtr read, write; + Check(CreatePipe(out read, out write, ref security, 0)); + Child = input ? read : write; + var parent = new SafeFileHandle(input ? write : read, true); + try { + Check(SetHandleInformation(parent.DangerousGetHandle(), 1, 0)); + Parent = new FileStream(parent, input ? FileAccess.Write : FileAccess.Read); + } catch { parent.Dispose(); CloseChild(); throw; } + } + public void CloseChild() { Close(Child); Child = IntPtr.Zero; } + public void Dispose() { CloseChild(); Parent.Dispose(); } + } + + // Quote for CommandLineToArgvW/CRT parsing, not cmd.exe or PowerShell. + static string Quote(string value) { + var result = new StringBuilder("\""); + int slashes = 0; + foreach (char c in value) { + if (c == '\\') { slashes++; continue; } + result.Append('\\', c == '"' ? 2 * slashes + 1 : slashes).Append(c); + slashes = 0; + } + return result.Append('\\', 2 * slashes).Append('"').ToString(); + } + + static ProcessInfo Launch(IntPtr job, string command, string[] args, string cwd, Pipe input, Pipe output, Pipe error) { + var size = IntPtr.Zero; + InitializeProcThreadAttributeList(IntPtr.Zero, 2, 0, ref size); + Check(size != IntPtr.Zero); + var attributes = Marshal.AllocHGlobal(size); + var values = Marshal.AllocHGlobal(IntPtr.Size * 4); + bool initialized = false; + try { + Check(InitializeProcThreadAttributeList(attributes, 2, 0, ref size)); + initialized = true; + Marshal.WriteIntPtr(values, 0, input.Child); + Marshal.WriteIntPtr(values, IntPtr.Size, output.Child); + Marshal.WriteIntPtr(values, IntPtr.Size * 2, error.Child); + Marshal.WriteIntPtr(values, IntPtr.Size * 3, job); + Check(UpdateProcThreadAttribute(attributes, 0, (UIntPtr)0x20002u, values, (IntPtr)(IntPtr.Size * 3), IntPtr.Zero, IntPtr.Zero)); + // JOB_LIST assigns the job atomically at creation, even if this launcher dies before ResumeThread. + Check(UpdateProcThreadAttribute(attributes, 0, (UIntPtr)0x2000du, IntPtr.Add(values, IntPtr.Size * 3), (IntPtr)IntPtr.Size, IntPtr.Zero, IntPtr.Zero)); + var startup = new StartupEx { Startup = new Startup { Size = Marshal.SizeOf(typeof(StartupEx)), Flags = 0x100, Input = input.Child, Output = output.Child, Error = error.Child }, Attributes = attributes }; + var line = new StringBuilder(Quote(command)); + foreach (string arg in args ?? new string[0]) line.Append(' ').Append(Quote(arg)); + ProcessInfo process; + // EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW | CREATE_SUSPENDED + Check(CreateProcess(command, line, IntPtr.Zero, IntPtr.Zero, true, 0x08080004, IntPtr.Zero, cwd, ref startup, out process)); + return process; + } finally { + if (initialized) DeleteProcThreadAttributeList(attributes); + Marshal.FreeHGlobal(values); + Marshal.FreeHGlobal(attributes); + input.CloseChild(); output.CloseChild(); error.CloseChild(); + } + } + + static uint Active(IntPtr job) { + Accounting accounting; + Check(QueryInformationJobObject(job, 1, out accounting, Marshal.SizeOf(typeof(Accounting)), IntPtr.Zero)); + return accounting.Active; + } + + static Thread Background(ThreadStart action) { + var worker = new Thread(action) { IsBackground = true }; + worker.Start(); + return worker; + } + + readonly BlockingCollection input = new BlockingCollection(4); + int stop, eof; + long bytes; + + void Ingress() { + try { + var reader = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false, true), false); + for (string line = reader.ReadLine(); line != null; line = reader.ReadLine()) { + if (!input.TryAdd(Encoding.UTF8.GetBytes(line + "\n"))) { Interlocked.CompareExchange(ref stop, 2, 0); break; } + } + } catch { Interlocked.CompareExchange(ref stop, 2, 0); } + finally { input.CompleteAdding(); Volatile.Write(ref eof, 1); } + } + + void Feed(Stream destination) { + try { + using (destination) foreach (var line in input.GetConsumingEnumerable()) { destination.Write(line, 0, line.Length); destination.Flush(); } + } catch (IOException) { /* The native process may close stdin before it exits. */ } + catch (ObjectDisposedException) { /* Job exit closes our input handle too. */ } + } + + void Drain(Stream source, Stream destination) { + try { + var buffer = new byte[8192]; + for (int size = source.Read(buffer, 0, buffer.Length); size > 0; size = source.Read(buffer, 0, buffer.Length)) { + if (Interlocked.Add(ref bytes, size) > 16 * 1024 * 1024) { Interlocked.CompareExchange(ref stop, 1, 0); break; } + if (destination != null) { destination.Write(buffer, 0, size); destination.Flush(); } + } + } catch { Interlocked.CompareExchange(ref stop, 2, 0); } + } + + public int Run(string command, string[] args, string cwd, string stopFile, string key) { + IntPtr job = IntPtr.Zero; + ProcessInfo native = new ProcessInfo(); + try { + if (File.Exists(stopFile)) { Proof("D 0 cancelled"); return 0; } + job = CreateJobObject(IntPtr.Zero, null); + Check(job != IntPtr.Zero); + var limits = new Limits { Basic = new BasicLimits { Flags = 0x2000 } }; // KILL_ON_JOB_CLOSE, no breakaway. + Check(SetInformationJobObject(job, 9, ref limits, Marshal.SizeOf(typeof(Limits)))); + using (var inputPipe = new Pipe(true)) + using (var outputPipe = new Pipe(false)) + using (var errorPipe = new Pipe(false)) { + Environment.SetEnvironmentVariable("EVOLVER_MODEL_API_KEY", key); + try { native = Launch(job, command, args, cwd, inputPipe, outputPipe, errorPipe); } + finally { Environment.SetEnvironmentVariable("EVOLVER_MODEL_API_KEY", null); } + Proof("S " + native.Pid); + var output = Background(() => Drain(outputPipe.Parent, Console.OpenStandardOutput())); + var error = Background(() => Drain(errorPipe.Parent, null)); + Background(Ingress); + Background(() => Feed(inputPipe.Parent)); + string failure = "ok"; + uint code = 0; + if (!File.Exists(stopFile)) { + Check(ResumeThread(native.Thread) != uint.MaxValue); + Stopwatch eofTime = null; + for (;;) { + var status = WaitForSingleObject(native.Process, 25); + if (status == 0) { Check(GetExitCodeProcess(native.Process, out code)); break; } + Check(status == 258); + if (File.Exists(stopFile)) { failure = "cancelled"; break; } + var stopped = Volatile.Read(ref stop); + if (stopped != 0) { failure = stopped == 1 ? "output_limit" : "bridge_failed"; break; } + if (Volatile.Read(ref eof) != 0) { + if (eofTime == null) eofTime = Stopwatch.StartNew(); + if (eofTime.ElapsedMilliseconds >= 5000) { failure = "shutdown_timeout"; break; } + } + } + } else failure = "cancelled"; + + // Native root exit is not a job exit; descendants can still hold pipe handles. + if (Active(job) != 0 && !TerminateJobObject(job, 1)) Check(Active(job) == 0); + var closing = Stopwatch.StartNew(); + while (Active(job) != 0) { if (closing.ElapsedMilliseconds >= 1000) return 1; Thread.Sleep(10); } + if (!output.Join(1000) || !error.Join(1000)) return 1; + if (failure == "ok" && Volatile.Read(ref stop) != 0) failure = stop == 1 ? "output_limit" : "bridge_failed"; + Proof("D " + code + " " + failure); + return 0; + } + } catch (Win32Exception error) { + if (native.Process == IntPtr.Zero) { Proof("D 0 spawn_failed"); return 0; } + Proof("E " + error.NativeErrorCode); + return 1; + } finally { Close(job); Close(native.Thread); Close(native.Process); } + } +} +'@ +} catch { + [Console]::Error.WriteLine('D 0 spawn_failed') + exit 0 +} +try { + [Console]::Error.WriteLine('P compiled') + $job = [EvolverJob]::new() + exit $job.Run([string] $spec.command, [string[]] $spec.args, [string] $spec.cwd, [string] $spec.stopFile, $key) +} catch { + [Console]::Error.WriteLine('E bridge_failed') + exit 1 +} +`; + +export function windowsJobExecutable(env = process.env) { + // PowerShell 7 compiles in-process. Windows PowerShell launches a separate compiler that + // can stall in a restricted runner. Never look up a launcher on the inherited PATH. + const programFiles = env.ProgramW6432 || env.ProgramFiles; + if (programFiles && isAbsolute(programFiles)) { + const modern = join(programFiles, 'PowerShell', '7', 'pwsh.exe'); + if (existsSync(modern)) return modern; + } + const root = env.SystemRoot || env.WINDIR; + if (!root || !isAbsolute(root)) throw new Error('Windows job launcher needs an absolute system path'); + return join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); +} + +/** The helper's stderr is control only; native stderr is drained and counted inside the job. */ +export function startWindowsJob(spec, onNative, onPhase) { + if (!isAbsolute(spec.command)) throw new Error('Windows job launcher needs an absolute executable path'); + const script = join(spec.home, '.evolver-job.ps1'), config = join(spec.home, '.evolver-job.json'), stopFile = join(spec.home, '.evolver-job-stop'); + const data = JSON.stringify({ command: spec.command, args: spec.args, cwd: spec.cwd, stopFile }); + if (process.env.EVOLVER_MODEL_API_KEY && data.includes(process.env.EVOLVER_MODEL_API_KEY)) throw new Error('Unsafe Windows job configuration'); + writeFileSync(script, SCRIPT, { flag: 'wx', mode: 0o600 }); + writeFileSync(config, data, { flag: 'wx', mode: 0o600 }); + const child = spawn(windowsJobExecutable(), ['-NoProfile', '-NoLogo', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, '-SpecPath', config], { cwd: spec.cwd, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let buffer = '', invalid = false, nativePid, result; + onPhase('helper-starting'); + child.stderr.on('data', chunk => { + if (invalid) return; + buffer += chunk.toString('ascii'); + if (buffer.length > 2048) { invalid = true; onPhase('invalid-control'); return; } + for (let end; (end = buffer.indexOf('\n')) !== -1;) { + const line = buffer.slice(0, end).trim(); buffer = buffer.slice(end + 1); + const started = /^S ([1-9]\d{0,9})$/.exec(line); + const done = /^D (\d{1,10}) (ok|cancelled|output_limit|shutdown_timeout|bridge_failed|spawn_failed)$/.exec(line); + const bridgeError = /^E (bridge_failed|\d{1,10})$/.exec(line); + if (line === 'P bootstrap' || line === 'P compiling' || line === 'P compiled') onPhase(line.slice(2)); + else if (started && !nativePid && !result && Number(started[1]) <= 0xffffffff) { nativePid = Number(started[1]); onPhase('native-started'); onNative(nativePid); } + else if (done && !result && (nativePid || ['cancelled', 'spawn_failed'].includes(done[2])) && Number(done[1]) <= 0xffffffff) { + result = { code: Number(done[1]), failure: done[2] === 'ok' ? undefined : done[2] }; + onPhase('closed-' + done[2]); + } else { + invalid = true; + onPhase(bridgeError ? 'bridge-' + bridgeError[1] : 'invalid-control'); + return; + } + } + }); + return { + child, + stop() { + try { writeFileSync(stopFile, '', { flag: 'wx', mode: 0o600 }); } + catch (error) { if (error?.code !== 'EEXIST') throw error; } + }, + proof(code, signal) { return !invalid && !buffer && code === 0 && signal === null ? result : undefined; }, + }; +} diff --git a/lib/runtime/process/windows.js b/lib/runtime/process/windows.js new file mode 100644 index 0000000..2e7e066 --- /dev/null +++ b/lib/runtime/process/windows.js @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { spawnSync } from 'node:child_process'; +import { win32 } from 'node:path'; + +/** A bounded ancestor snapshot, without command lines or credentials; an unavailable process table stays unknown. */ +export function windowsAncestorsOf(pid, { run = spawnSync, env = process.env } = {}) { + const empty = () => new Map(); + const root = env.SystemRoot || env.WINDIR || ''; + if (!Number.isSafeInteger(pid) || pid <= 0 || !win32.isAbsolute(root)) return empty(); + // One CIM snapshot avoids a new WMI round trip for each shell or ancestor; only the bounded chain leaves PowerShell. + const script = `$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false); $evolverTable=@{}; Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,ExecutablePath | ForEach-Object {$evolverTable[[int]$_.ProcessId]=$_}; $evolverPid=${pid}; $evolverRows=@(); for($evolverDepth=0;$evolverDepth -lt 16 -and $evolverPid -gt 0;$evolverDepth++){ $p=$evolverTable[$evolverPid]; if($null -eq $p){break}; $evolverRows += [pscustomobject]@{pid=[int]$p.ProcessId;ppid=[int]$p.ParentProcessId;name=[string]$p.Name;path=[string]$p.ExecutablePath}; if($p.ParentProcessId -eq $evolverPid){break}; $evolverPid=[int]$p.ParentProcessId }; ConvertTo-Json -InputObject @($evolverRows) -Compress`; + try { + const result = run(win32.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], { encoding: 'utf8', timeout: 2500, maxBuffer: 128 * 1024, windowsHide: true }); + if (result.error || result.status !== 0) return empty(); + const rows = JSON.parse(result.stdout); + if (!Array.isArray(rows) || rows.length > 16 || rows.some(row => !Number.isSafeInteger(row.pid) || row.pid <= 0 || !Number.isSafeInteger(row.ppid) || row.ppid < 0 || typeof row.name !== 'string' || typeof row.path !== 'string')) return empty(); + return new Map(rows.map(({ pid: key, ...info }) => [key, info])); + } catch { return empty(); } +} diff --git a/lib/runtime/proxy.js b/lib/runtime/proxy.js index 53489bc..87fe9c5 100644 --- a/lib/runtime/proxy.js +++ b/lib/runtime/proxy.js @@ -18,10 +18,10 @@ function settingsText(env) { } /** `proxyFetch(method, path, body, { timeoutMs, signal })` against the loopback Proxy, answering a ProxyResult; never throws. */ -export function proxyFetchOf(env = process.env) { +export function proxyFetchOf(env = process.env, { closeConnection = false } = {}) { return async (method, path, body, { timeoutMs = PROXY_TIMEOUT_MS, signal } = {}) => { const { url: base, token } = proxySettingsFrom(settingsText(env), env.EVOMAP_PROXY_PORT || DEFAULT_PROXY_PORT); - const headers = { ...(body !== undefined && { 'Content-Type': 'application/json' }), ...(token && { Authorization: `Bearer ${token}` }) }; + const headers = { ...(body !== undefined && { 'Content-Type': 'application/json' }), ...(token && { Authorization: `Bearer ${token}` }), ...(closeConnection && { Connection: 'close' }) }; const timeout = AbortSignal.timeout(timeoutMs); try { const response = await fetch(base + path, { diff --git a/lib/runtime/settings.js b/lib/runtime/settings.js new file mode 100644 index 0000000..cf5a5c0 --- /dev/null +++ b/lib/runtime/settings.js @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import { lstatSync, readFileSync } from 'node:fs'; + +import { withLock, writeState } from './store/file-lock.js'; + +const KEYS = ['recall', 'autoDistill']; +function checked(value) { + if (!value || Array.isArray(value) || typeof value !== 'object' || Object.entries(value).some(([key, flag]) => !KEYS.includes(key) || typeof flag !== 'boolean')) throw new Error('Plugin settings must contain only boolean recall and autoDistill fields.'); + return value; +} + +function readSaved(file) { + try { + const stat = lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error('Invalid settings file'); + return checked(JSON.parse(readFileSync(file, 'utf8'))); + } catch (error) { if (error.code === 'ENOENT') return {}; throw error; } +} + +const INVALID = 'Plugin settings are invalid or unreadable; recall and automatic distillation are paused. Repair the settings file before changing preferences.'; + +/** Persistent host preferences survive native hook environments which strip inherited variables. */ +export function settingsOf(env, file) { + const effective = saved => ({ recall: env.EVOLVER_RECALL === undefined ? saved.recall ?? true : env.EVOLVER_RECALL !== 'off', autoDistill: env.EVOLVER_AUTO_DISTILL === undefined ? saved.autoDistill ?? true : env.EVOLVER_AUTO_DISTILL !== 'off' }); + if (!file) return effective({}); + let saved = {}, error; + try { saved = readSaved(file); } catch { error = INVALID; } + const settings = { ...(error ? { recall: false, autoDistill: false } : effective(saved)), file, error }; + settings.configure = async patch => { + if (error) throw new Error(error); + checked(patch); + const written = await withLock(`${file}.lock`, async () => { + let current; + try { current = readSaved(file); } catch { throw new Error(INVALID); } + const next = { ...current, ...patch }; + writeState(file, next); + Object.assign(settings, effective(next)); + }); + if (!written) throw new Error('Another preference update is running; retry the command.'); + }; + return settings; +} diff --git a/lib/runtime/store/admission.js b/lib/runtime/store/admission.js new file mode 100644 index 0000000..b416d0b --- /dev/null +++ b/lib/runtime/store/admission.js @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { ownerIsGone, processStartTime, PROCESS_START_TIMEOUT_MS, PROCESS_START_TOLERANCE_MS } from '../process/owner.js'; + +export const LOCK_WAIT_MS = 2_000; +export const LOCK_RETRY_MS = 20; +export const LOCK_ACCESS_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES']); +const ADMISSION_IDENTITY_CHECK_MS = 10_000; +const ABANDONED_PREFIX = 'abandoned-'; +const abandonedAdmissions = new Map(); + +export function pause(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function reclaimAdmission(admission, deadline, starts) { + try { + const info = fs.lstatSync(admission); + if (!info.isDirectory()) return false; + const entries = fs.readdirSync(admission); + if (entries.length > 0) { + const marker = entries.find((name) => name.startsWith(ABANDONED_PREFIX)); + const ownerName = marker ? marker.slice(ABANDONED_PREFIX.length) : entries[0]; + if (entries.some((name) => name !== ownerName && name !== marker)) return false; + const owner = /^owner-(\d+)-[a-f0-9]+(?:-([a-f0-9]{24}|[0-9]{13}))?$/.exec(ownerName); + if (!owner) return false; + const pid = Number(owner[1]); + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + const abandonedHere = abandonedAdmissions.get(admission) === ownerName; + const markerPath = marker && path.join(admission, marker); + if (markerPath && !fs.lstatSync(markerPath).isFile()) return false; + if (!markerPath && !abandonedHere && !ownerIsGone(pid)) { + const birth = Number(owner[2]); + const remaining = deadline - Date.now(); + if (owner[2]?.length !== 13 || !Number.isSafeInteger(birth) || Date.now() - info.mtimeMs < ADMISSION_IDENTITY_CHECK_MS || remaining <= 0) return false; + if (!starts.has(pid)) starts.set(pid, processStartTime(pid, Math.min(remaining, PROCESS_START_TIMEOUT_MS))); + const actualBirth = starts.get(pid); + // Only a clearly newer process proves reuse. ps timestamps round to whole seconds. + if (!actualBirth || actualBirth <= birth + PROCESS_START_TOLERANCE_MS) return false; + } + if (entries.includes(ownerName)) fs.unlinkSync(path.join(admission, ownerName)); + if (markerPath) fs.unlinkSync(markerPath); + if (abandonedHere) abandonedAdmissions.delete(admission); + } + // Only the inspected unique owner is unlinked. A fresh guard is already nonempty, so rmdir cannot + // remove it even if another reclaimer replaced the directory after our read. + fs.rmdirSync(admission); + return true; + } catch { + return false; + } +} + +function enterAdmission(lockDir, deadline, starts) { + const admission = `${lockDir}.admission`; + const owner = `owner-${process.pid}-${crypto.randomBytes(12).toString('hex')}-${processStartTime(process.pid)}`; + const staging = `${admission}.${owner}.claim`; + fs.mkdirSync(path.dirname(admission), { recursive: true, mode: 0o700 }); + fs.mkdirSync(staging, { mode: 0o700 }); + let published = false; + try { + fs.writeFileSync(path.join(staging, owner), '', { mode: 0o600, flag: 'wx' }); + // Publishing a prepopulated directory never exposes an empty fresh guard to a stale reclaimer. + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + fs.renameSync(staging, admission); + published = true; + abandonedAdmissions.delete(admission); + return { admission, owner }; + } catch { + if (attempt > 0 || !reclaimAdmission(admission, deadline, starts)) return null; + } + } + return null; + } finally { + if (!published) fs.rmSync(staging, { recursive: true, force: true }); + } +} + +export function retryLockAccess(access) { + for (let attempt = 0; ; attempt += 1) { + try { + return access(); + } catch (error) { + if (attempt === 2 || !LOCK_ACCESS_ERRORS.has(error?.code)) throw error; + pause(LOCK_RETRY_MS); + } + } +} + +function releaseAdmission({ admission, owner }) { + try { + retryLockAccess(() => fs.unlinkSync(path.join(admission, owner))); + } catch (cause) { + if (cause?.code !== 'ENOENT') { + throw Object.assign(new Error(`Could not release admission ${admission}`, { cause }), { code: 'ELOCKED' }); + } + } + // The guard is released when its unique owner is gone. An empty directory can be reclaimed. + try { retryLockAccess(() => fs.rmdirSync(admission)); } catch {} +} + +function abandonAdmission({ admission, owner }) { + // A separate marker can be written even when Windows denies access to the owner file. + try { + retryLockAccess(() => fs.writeFileSync(path.join(admission, ABANDONED_PREFIX + owner), '', { mode: 0o600, flag: 'wx' })); + } catch { + abandonedAdmissions.set(admission, owner); + } +} + +// Every directory mutation uses this short, nonblocking admission. No work or await belongs inside it. +export function admitted(lockDir, mutate, rollback, deadline = Date.now() + LOCK_WAIT_MS, starts = new Map()) { + const admission = enterAdmission(lockDir, deadline, starts); + if (!admission) return null; + let result; + try { + result = mutate(); + } catch (error) { + try { releaseAdmission(admission); } catch { abandonAdmission(admission); } + throw error; + } + try { + releaseAdmission(admission); + } catch (error) { + // Publish only after rollback, so another process cannot enter while it runs. + try { rollback?.(); } catch (rollbackError) { error.rollbackError = rollbackError; } + abandonAdmission(admission); + if (rollback) throw error; + } + return result; +} diff --git a/lib/runtime/store/file-lock.js b/lib/runtime/store/file-lock.js index f730098..be3bea7 100644 --- a/lib/runtime/store/file-lock.js +++ b/lib/runtime/store/file-lock.js @@ -5,19 +5,25 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { ownerIsGone } from '../process/owner.js'; +import { ownerIsGone, processStartTime, PROCESS_START_TIMEOUT_MS, PROCESS_START_TOLERANCE_MS } from '../process/owner.js'; +import { admitted, LOCK_ACCESS_ERRORS, LOCK_WAIT_MS, LOCK_RETRY_MS, pause, retryLockAccess } from './admission.js'; -const LOCK_WAIT_MS = 2_000; -const LOCK_RETRY_MS = 20; const WORK_LOCK_STALE_MS = 2 * 60 * 1000; +const WORK_OWNER_PROBE_MS = process.platform === 'win32' ? 5_000 : PROCESS_START_TIMEOUT_MS; +const WORK_OWNER_WITH_START = /^\d+:[a-f0-9]+:([1-9]\d{12})$/; const CLAIM_STALE_MS = 60 * 1000; +const abandonedWork = new Map(); +const abandonedState = new Map(); export function readState(target, fallback) { try { const parsed = JSON.parse(fs.readFileSync(target, 'utf8')); - return parsed && typeof parsed === 'object' && Array.isArray(parsed) === Array.isArray(fallback) ? parsed : fallback; - } catch { - return fallback; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) !== Array.isArray(fallback)) throw new Error(`Invalid state shape in ${target}; repair it before retrying.`); + return parsed; + } catch (error) { + if (error?.code === 'ENOENT') return fallback; + if (error instanceof SyntaxError) throw new Error(`Invalid JSON in ${target}; repair it before retrying.`); + throw error; } } @@ -25,12 +31,19 @@ export function writeState(target, value) { fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); const temporary = `${target}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`; fs.writeFileSync(temporary, JSON.stringify(value), { mode: 0o600, flag: 'wx' }); + const deadline = Date.now() + LOCK_WAIT_MS; try { - fs.renameSync(temporary, target); - } catch (error) { - if (!['EEXIST', 'EPERM'].includes(error?.code)) throw error; - fs.rmSync(target, { force: true }); - fs.renameSync(temporary, target); + for (;;) { + try { + fs.renameSync(temporary, target); + return; + } catch (error) { + if (!['EEXIST', 'EACCES', 'EPERM', 'EBUSY'].includes(error?.code) || Date.now() >= deadline) throw error; + pause(LOCK_RETRY_MS); + } + } + } finally { + removeState(temporary); } } @@ -41,74 +54,81 @@ export function removeState(target) { } } -// Atomics.wait is the only sleep that holds a synchronous read-modify-write together. -function pause(ms) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function contentOf(file) { +function stateOwnerOf(file) { try { - return fs.readFileSync(file, 'utf8'); - } catch { - return null; + return retryLockAccess(() => fs.readFileSync(file, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') return null; + throw error; } } const ownerPidOf = (token) => Number(token.split(':')[0]); -// Another process may reclaim the same dead lock and a third take a fresh one before this removal happens, so the -// lock is first moved aside, and only what was inspected as dead is deleted; anything else is put back. -function reclaim(lockPath, deadToken) { - const aside = `${lockPath}.${crypto.randomBytes(6).toString('hex')}.reclaimed`; - try { - fs.renameSync(lockPath, aside); - } catch { - return; - } - if (contentOf(aside) !== deadToken) { - try { - fs.linkSync(aside, lockPath); - } catch { - } - } - removeState(aside); -} - function acquire(lockPath, deadline) { const token = `${process.pid}:${crypto.randomBytes(8).toString('hex')}`; - for (;;) { + const starts = new Map(); + while (Date.now() < deadline) { + let acquired = false; try { - fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 }); - fs.writeFileSync(lockPath, token, { mode: 0o600, flag: 'wx' }); - return token; + const result = admitted(lockPath, () => { + try { + fs.writeFileSync(lockPath, token, { mode: 0o600, flag: 'wx' }); + acquired = true; + return token; + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + // Admission serializes inspection and removal with every other acquisition. + const held = stateOwnerOf(lockPath); + if (held !== null && (abandonedState.get(lockPath) === held || ownerIsGone(ownerPidOf(held)))) { + fs.unlinkSync(lockPath); + abandonedState.delete(lockPath); + } + return null; + } + }, () => { + if (!acquired) return; + abandonedState.set(lockPath, token); + if (stateOwnerOf(lockPath) === token) retryLockAccess(() => fs.unlinkSync(lockPath)); + abandonedState.delete(lockPath); + }, deadline, starts); + if (result) { + abandonedState.delete(lockPath); + return result; + } } catch (error) { - if (error?.code !== 'EEXIST') return null; - const held = contentOf(lockPath); - if (held !== null && ownerIsGone(ownerPidOf(held))) reclaim(lockPath, held); - else if (Date.now() >= deadline) return null; - else pause(LOCK_RETRY_MS); + if (!LOCK_ACCESS_ERRORS.has(error?.code)) throw error; } + if (Date.now() < deadline) pause(LOCK_RETRY_MS); } + return null; } function release(lockPath, token) { - if (contentOf(lockPath) === token) removeState(lockPath); + try { + if (stateOwnerOf(lockPath) === token) retryLockAccess(() => fs.unlinkSync(lockPath)); + } catch { + // The state write is already settled. Keep the exact token for the next admitted acquire. + abandonedState.set(lockPath, token); + } } /** * Applies `mutate` to a state file under an exclusive lock, so hook processes sharing it cannot overwrite each - * other's entries. A dead owner's lock is taken over; one that cannot be had within LOCK_WAIT_MS is given up on - * and the update runs unguarded, which never blocks a session. `mutate` returns undefined to leave the file. + * other's entries. A dead owner's lock is taken over; failed admission never permits an unlocked write. + * `mutate` returns undefined to leave the file. */ export function updateState(target, fallback, mutate) { const lockPath = `${target}.lock`; - const token = acquire(lockPath, Date.now() + LOCK_WAIT_MS); + let token, cause; + try { token = acquire(lockPath, Date.now() + LOCK_WAIT_MS); } catch (error) { cause = error; } + if (!token) throw Object.assign(new Error('State is busy or unavailable; no update was made', { cause }), { code: 'ELOCKED' }); try { const next = mutate(readState(target, fallback)); if (next !== undefined) writeState(target, next); return next; } finally { - if (token) release(lockPath, token); + release(lockPath, token); } } @@ -180,38 +200,107 @@ function isStale(lockDir, now) { } } +function workOwnerOf(lockDir) { + try { + return retryLockAccess(() => fs.readFileSync(path.join(lockDir, 'owner'), 'utf8')); + } catch (cause) { + if (cause?.code === 'ENOENT') return null; + throw Object.assign(new Error(`Could not read lock owner ${lockDir}`, { cause }), { code: 'ELOCKED' }); + } +} + function ownsLock(lockDir, token) { - return contentOf(path.join(lockDir, 'owner')) === token; + return workOwnerOf(lockDir) === token; } -function tryLock(lockDir, now) { - fs.mkdirSync(path.dirname(lockDir), { recursive: true, mode: 0o700 }); - if (isStale(lockDir, now)) fs.rmSync(lockDir, { recursive: true, force: true }); - try { - fs.mkdirSync(lockDir); - } catch { - return null; - } - const token = crypto.randomBytes(12).toString('hex'); - fs.writeFileSync(path.join(lockDir, 'owner'), token); - return { - refresh: () => { - if (ownsLock(lockDir, token)) fs.utimesSync(lockDir, new Date(), new Date()); - }, - release: () => { +async function releaseWorkLock(lockDir, token) { + const deadline = Date.now() + LOCK_WAIT_MS; + const starts = new Map(); + for (;;) { + if (admitted(lockDir, () => { if (ownsLock(lockDir, token)) fs.rmSync(lockDir, { recursive: true, force: true }); - }, - }; + return true; + }, null, deadline, starts)) return; + if (Date.now() >= deadline) throw Object.assign(new Error(`Could not release lock ${lockDir}`), { code: 'ELOCKED' }); + await new Promise((resolve) => setTimeout(resolve, LOCK_RETRY_MS)); + } } -/** Runs slow `work` while holding a lock directory no other process gets meanwhile; false when it is held. */ +function tryLock(lockDir, now) { + let token, suspect, reusedToken; + const take = () => admitted(lockDir, () => { + const stale = isStale(lockDir, now); + if (abandonedWork.has(lockDir) || stale) { + let held; + try { held = workOwnerOf(lockDir); } catch { return null; } + if (held !== abandonedWork.get(lockDir)) { + if (!stale) return null; + if (held !== reusedToken && /^\d+:/.test(held ?? '')) { + const pid = ownerPidOf(held); + if (!ownerIsGone(pid)) { + const birth = WORK_OWNER_WITH_START.exec(held)?.[1]; + if (birth) suspect = { token: held, pid, birth: Number(birth) }; + return null; + } + } + } + fs.rmSync(lockDir, { recursive: true, force: true }); + abandonedWork.delete(lockDir); + } + try { + fs.mkdirSync(lockDir); + } catch { + return null; + } + token = `${process.pid}:${crypto.randomBytes(12).toString('hex')}:${processStartTime(process.pid)}`; + fs.writeFileSync(path.join(lockDir, 'owner'), token); + return { + refresh: () => { + try { + return admitted(lockDir, () => { + if (ownsLock(lockDir, token)) fs.utimesSync(lockDir, new Date(), new Date()); + }); + } catch { + // Timer-driven refresh is best effort; a live pid stays protected and final release still surfaces errors. + return null; + } + }, + release: async () => { + try { await releaseWorkLock(lockDir, token); } + catch (error) { abandonedWork.set(lockDir, token); throw error; } + }, + }; + }, () => { + if (!token) return; + abandonedWork.set(lockDir, token); + if (ownsLock(lockDir, token)) fs.rmSync(lockDir, { recursive: true, force: true }); + abandonedWork.delete(lockDir); + }); + const lock = take(); + if (lock || !suspect) return lock; + // A foreign lookup can be slow. Release admission, then recheck staleness and the exact token. + const actualBirth = processStartTime(suspect.pid, WORK_OWNER_PROBE_MS); + if (!actualBirth || actualBirth <= suspect.birth + PROCESS_START_TOLERANCE_MS) return null; + reusedToken = suspect.token; + return take(); +} + +/** Runs slow `work` under one owner's lock; dead or legacy directories recover after their lease expires. */ export async function withLock(lockDir, work, now = Date.now()) { const lock = tryLock(lockDir, now); if (!lock) return false; + let workFailed = false; try { await work(lock); return true; + } catch (error) { + workFailed = true; + throw error; } finally { - lock.release(); + try { + await lock.release(); + } catch (error) { + if (!workFailed) throw error; + } } } diff --git a/lib/session/state.js b/lib/session/state.js index 3ec618f..7599c5b 100644 --- a/lib/session/state.js +++ b/lib/session/state.js @@ -162,5 +162,9 @@ export const isEarlierTurn = (session, turnId) => Boolean(turnId) && turnId !== /** What an ended session keeps: its turn counter, owner and ledger, so a resume neither repeats a task id nor re-injects. */ export function endedSession(session) { - return { ...EMPTY_SESSION, turn: session.turn, pid: session.pid, injected: session.injected, turnIds: session.turnIds, shown: session.shown, task: session.task }; + return { + ...EMPTY_SESSION, turn: session.turn, pid: session.pid, injected: session.injected, turnIds: session.turnIds, shown: session.shown, task: session.task, correctedTurn: session.correctedTurn, + ...(session.native && { native: { ...session.native, prompt: session.prompt || session.native.prompt || '', sessionEnded: true } }), + ...(session.nativeCursor && { nativeCursor: session.nativeCursor }), + }; } diff --git a/lib/turn/record.js b/lib/turn/record.js index bd69b76..846b862 100644 --- a/lib/turn/record.js +++ b/lib/turn/record.js @@ -14,19 +14,19 @@ const CHECK_COMMAND_MAX_CHARS = 200; * An exit code decides a check when the host gives one; the output is read too, since a failing run piped * through `tail` still exits zero. Without an exit code or a supported passing summary, `failed: null` keeps * the check's result unknown. A command still running has no verdict: a later note with its `callId` - * settles it, and one still pending when the turn ends counts as not run. `label` names a check to the person, and + * settles it in call order; one still pending keeps an unknown verdict. `label` names a check to the person, and * `lines` counts what an edit changed and `identifiers` the names it introduced, where the host says. */ export function turnRecordOf(notes) { const record = { checks: [], commands: [], paths: [], edits: [], identifiers: [], changedLines: 0, answer: '' }; - const pending = new Set(); + const calls = new Map(); for (const note of notes ?? []) { if (note.kind === 'answer') { record.answer = String(note.text ?? ''); continue; } - const isCompletion = Boolean(note.callId) && pending.has(note.callId); - if (isCompletion) pending.delete(note.callId); + const known = note.callId ? calls.get(note.callId) : undefined; + const isCompletion = known !== undefined; if (typeof note.command === 'string' && note.command && !isCompletion) record.commands.push(note.command); record.paths.push(...(note.paths ?? []).filter((file) => typeof file === 'string' && file)); if (note.kind === 'edit') { @@ -35,11 +35,14 @@ export function turnRecordOf(notes) { record.changedLines += Number(note.lines) || 0; } if (note.kind !== 'shell' || !isVerificationCommand(note.command)) continue; - if (note.running) { - if (note.callId) pending.add(note.callId); - continue; + const check = { command: note.command.slice(0, CHECK_COMMAND_MAX_CHARS), failed: note.running ? null : checkFailed(note), ...(note.label && { label: note.label }) }; + if (isCompletion) { + // Repeated pending observations cannot undo a terminal result. + if (!note.running) record.checks[known] = check; + } else { + if (note.callId) calls.set(note.callId, record.checks.length); + record.checks.push(check); } - record.checks.push({ command: note.command.slice(0, CHECK_COMMAND_MAX_CHARS), failed: checkFailed(note), ...(note.label && { label: note.label }) }); } return record; } diff --git a/lib/verification.js b/lib/verification.js index ecd59cf..cdeb0e4 100644 --- a/lib/verification.js +++ b/lib/verification.js @@ -7,15 +7,15 @@ import { detectSignals } from './signals.js'; // verdict an automatic reuse report carries is read off the final test, build, // lint or type-check command, never off how the turn ended. const VERIFICATION_COMMANDS = [ - /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|type-check|check)\b/, - /\bnode\s+--test\b/, - /\b(?:jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|ruff|mypy)\b/, - /\bgo\s+(?:test|build|vet)\b/, - /\bcargo\s+(?:test|build|check|clippy)\b/, - /\bdotnet\s+(?:test|build)\b/, - /\b(?:mvn|gradle|gradlew)\b/, - /\bmake\b/, - /\bclaude\s+plugin\s+(?:validate|test)\b/, + /^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|type-check|check)\b/, + /^node\s+--test\b/, + /^(?:jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|ruff|mypy)(?:\s|$)/, + /^go\s+(?:test|build|vet)\b/, + /^cargo\s+(?:test|build|check|clippy)\b/, + /^dotnet\s+(?:test|build)\b/, + /^(?:mvn|gradle|gradlew)(?:\s|$)/, + /^make(?:\s|$)/, + /^claude\s+plugin\s+(?:validate|test)\b/, ]; const FAILURE_SIGNALS = new Set(['test_failure', 'log_error']); @@ -31,8 +31,23 @@ const HEREDOC = /<<-?\s*(['"]?)(\w+)\1([^\n]*)\n[\s\S]*?\n\s*\2[ \t]*(?=\n|$)/g; const withoutHeredocBodies = (command) => command.replace(HEREDOC, '<<$2$3'); +// Keep separators inside strings, backticks and comments out of the command scan. A name in an argument is no +// check. This recognises direct commands and known wrappers; it does not evaluate arbitrary shell control flow. +const SHELL_DATA = /\\[\s\S]|`[^`]*(?:`|$)|'(?:''|[^'])*(?:'|$)|"(?:\\[\s\S]|`[\s\S]|[^"\\`])*(?:"|$)|#[^\r\n]*/g; +const ENVIRONMENT = /^(?:env\s+)?(?:[A-Za-z_]\w*=\S*\s+)*/; +const NPX = /^npx\s+(?:(?:-y|--yes|--no-install)\s+|(?:-p|--package)(?:=\S+|\s+\S+)\s+)*(?:--\s+)?/; +const withoutExecutablePath = (command) => command.replace(/^\S+/, (token) => token.split(/[\\/]/).at(-1)); + export function isVerificationCommand(command) { - return typeof command === 'string' && VERIFICATION_COMMANDS.some((pattern) => pattern.test(withoutHeredocBodies(command))); + if (typeof command !== 'string') return false; + const shell = withoutHeredocBodies(command).replace(SHELL_DATA, '__data__'); + if (shell.includes('$(')) return false; // Do not mistake separators in a nested substitution for command positions. + const commands = shell.split(/[;&|\r\n]+/); + return commands.some((part) => { + const executable = withoutExecutablePath(part.trim().replace(ENVIRONMENT, '')); + const check = withoutExecutablePath(executable.replace(NPX, '')); + return VERIFICATION_COMMANDS.some((pattern) => pattern.test(check)); + }); } /** @param {{ isError?: boolean, text?: string }} result */ diff --git a/package-lock.json b/package-lock.json index e268d2f..4c66e66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.32.1", + "smol-toml": "1.9.1", "zod": "4.6.7" }, "devDependencies": { @@ -1490,6 +1491,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/smol-toml": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.1.tgz", + "integrity": "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 8677431..2f5d33b 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "license": "MIT", "scripts": { "test": "node --test --test-timeout=120000 \"test/**/*.test.js\"", + "test:kimi": "node --test --test-concurrency=4 --test-timeout=120000 \"test/*.test.js\" \"test/agent/*.test.js\" \"test/app/*.test.js\" \"test/runtime/*.test.js\" \"test/hosts/kimi/*.test.js\" test/channels/hook.test.js test/channels/script.test.js test/channels/mcp.test.js", "validate": "node scripts/pack.mjs claude-code dist/claude-code && claude plugin validate dist/claude-code", "dev:sync": "bash scripts/sync-dev-mod.sh", "pack": "node scripts/pack.mjs all dist" @@ -19,6 +20,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.32.1", + "smol-toml": "1.9.1", "zod": "4.6.7" } } diff --git a/scripts/install-kimi.mjs b/scripts/install-kimi.mjs new file mode 100644 index 0000000..102242a --- /dev/null +++ b/scripts/install-kimi.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap + +import fs from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +import { updateState } from '../lib/runtime/store/file-lock.js'; +import { kimiHomeOf } from '../hosts/kimi/paths.js'; +import { pack } from './pack.mjs'; + +const BUNDLE = fileURLToPath(new URL('../dist/kimi', import.meta.url)); +const NAME = 'evolver-plugin'; +const validVersion = value => typeof value === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(value) + && !value.split('-').slice(1).join('-').split('.').some(part => /^0\d+$/.test(part)); + +function ordinaryDirectory(path) { + if (!fs.existsSync(path)) return; + const stat = fs.lstatSync(path); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Refusing a non-directory or linked plugin path: ${path}`); +} + +function validRegistry(value) { + if (value?.version !== 1 || !Array.isArray(value.plugins) + || value.plugins.some(entry => !entry || typeof entry !== 'object' || typeof entry.id !== 'string' || typeof entry.root !== 'string') + || new Set(value.plugins.map(entry => entry.id)).size !== value.plugins.length) throw new Error('Invalid Kimi plugin registry; repair it before retrying.'); + return value; +} + +function registryOf(file) { + let value; + try { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Refusing a non-file or linked plugin registry.'); + value = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (error) { + if (error?.code === 'ENOENT') return { version: 1, plugins: [] }; + throw error; + } + return validRegistry(value); +} + +function packageOf(dir) { + if (!fs.existsSync(dir)) return null; + ordinaryDirectory(dir); + const marker = join(dir, '.evolver-packed'), file = join(dir, 'kimi.plugin.json'); + for (const path of [marker, file]) { + if (!fs.existsSync(path) || !fs.lstatSync(path).isFile() || fs.lstatSync(path).isSymbolicLink()) throw new Error(`Refusing an unmanaged Kimi plugin: ${dir}`); + } + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); + if (fs.readFileSync(marker, 'utf8').trim() !== 'kimi' || manifest.name !== NAME || !validVersion(manifest.version) + || !Array.isArray(manifest.hooks) || manifest.hooks.some(hook => typeof hook.event !== 'string' || typeof hook.command !== 'string')) throw new Error(`Invalid Kimi package identity, version or hooks: ${dir}`); + return manifest; +} + +function ownEntry(registry, target) { + const own = registry.plugins.find(entry => entry.id === NAME); + if (own && resolve(own.root) !== target) throw new Error('Refusing to replace an Evolver registration owned by another path.'); + return own; +} + +function underInstallLock(plugins, work) { + let result; + // No state value is written: this target-specific guard must stay held through registry failure and rollback. + updateState(join(plugins, '.evolver-plugin-install'), {}, () => { result = work(); }); + return result; +} + +// Before activation, both packages are still in staging. After activation, "package" has been moved away. +function recoverPackage(root, target) { + let interrupted; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || !/^\.evolver-install-[a-z0-9]{6}$/i.test(entry.name)) continue; + const stage = join(root, entry.name); + ordinaryDirectory(stage); + const backup = join(stage, 'previous'), prepared = join(stage, 'package'); + if (!fs.existsSync(backup) || !fs.existsSync(prepared)) continue; + const previous = packageOf(backup); + if (!previous || !packageOf(prepared)) continue; + if (interrupted) throw new Error('Multiple interrupted Kimi updates found. Restore the intended backup before retrying.'); + interrupted = { stage, backup, prepared, previous }; + } + if (!interrupted) return null; + fs.renameSync(interrupted.backup, target); + fs.rmSync(interrupted.prepared, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (fs.readdirSync(interrupted.stage).length === 0) fs.rmdirSync(interrupted.stage); + return interrupted.previous; +} + +function installationOf(root, target, registryFile, recover = false) { + const own = ownEntry(registryOf(registryFile), target); + const previous = packageOf(target) ?? (recover && own ? recoverPackage(root, target) : null); + return { own, previous }; +} + +/** Installs only the owned managed package and native registry entry; account/config/state stay outside them. */ +export function installKimi({ operation = 'install', home = kimiHomeOf(process.env), dryRun = false, version } = {}) { + if (!['install', 'update', 'status', 'remove'].includes(operation)) throw new Error('Expected install, update, status or remove.'); + if (version !== undefined && (!validVersion(version) || !['install', 'update'].includes(operation))) throw new Error('Version must be X.Y.Z or X.Y.Z-prerelease for install/update.'); + const root = resolve(home), plugins = join(root, 'plugins'), managed = join(plugins, 'managed'), target = join(managed, 'evolver-plugin'); + for (const dir of [root, plugins, managed, target]) ordinaryDirectory(dir); + const registryFile = join(plugins, 'installed.json'); + if (operation === 'remove' && !dryRun) return underInstallLock(plugins, () => { + const packageRetained = Boolean(installationOf(root, target, registryFile, true).previous); + updateState(registryFile, { version: 1, plugins: [] }, current => { + validRegistry(current); + if (!ownEntry(current, target)) return undefined; + return { ...current, plugins: current.plugins.filter(entry => entry.id !== NAME) }; + }); + // Native removal unregisters the plugin and retains its managed bytes. + return { installed: false, registered: false, path: target, packageRetained }; + }); + let { own, previous } = installationOf(root, target, registryFile); + if (!dryRun && own && !previous) { + ({ own, previous } = underInstallLock(plugins, () => installationOf(root, target, registryFile, true))); + } + const registered = Boolean(own), installed = registered && Boolean(previous); + if (operation === 'status') return { installed, registered, path: target, ...(previous ? { version: previous.version } : {}), ...(own ? { enabled: own.enabled !== false } : {}) }; + if (operation === 'update' && !installed) throw new Error('Kimi plugin is not installed; use install first.'); + if (dryRun) return { operation, path: target, dryRun: true }; + fs.mkdirSync(managed, { recursive: true, mode: 0o700 }); + const stage = fs.mkdtempSync(join(root, '.evolver-install-')), prepared = join(stage, 'package'), backup = join(stage, 'previous'); + let moved = false, activated = false, committed = false, next; + try { + if (version === undefined && fs.existsSync(BUNDLE)) { + packageOf(BUNDLE); + fs.cpSync(BUNDLE, prepared, { recursive: true }); + } else pack('kimi', prepared, { version }); + next = packageOf(prepared); + underInstallLock(plugins, () => { + try { + updateState(registryFile, { version: 1, plugins: [] }, current => { + validRegistry(current); + const currentOwn = ownEntry(current, target); + const currentPackage = packageOf(target) ?? (currentOwn ? recoverPackage(root, target) : null); + if (operation === 'update' && (!currentOwn || !currentPackage)) throw new Error('Kimi plugin changed before activation; retry after checking status.'); + if (currentPackage) { fs.renameSync(target, backup); moved = true; } + fs.renameSync(prepared, target); activated = true; + const entry = { ...currentOwn, id: NAME, root: target, source: 'local-path', enabled: currentOwn?.enabled ?? true, installedAt: currentOwn?.installedAt ?? new Date().toISOString() }; + return { ...current, plugins: [...current.plugins.filter(item => item.id !== NAME), entry] }; + }); + committed = true; + } catch (error) { + try { + if (activated) fs.rmSync(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (moved) { fs.renameSync(backup, target); moved = false; } + } catch { throw new Error(`Activation and rollback failed. The previous package is retained at ${backup}.`); } + throw error; + } + }); + return { installed: true, registered: true, operation, path: target, version: next.version, next: 'Start a new Kimi Code session to load the managed plugin.' }; + } finally { + if (!moved || committed) fs.rmSync(stage, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const { values, positionals } = parseArgs({ options: { home: { type: 'string' }, 'dry-run': { type: 'boolean' }, version: { type: 'string' }, help: { type: 'boolean' } }, allowPositionals: true }); + const usage = 'Usage: node scripts/install-kimi.mjs [install|update|status|remove] [--home PATH] [--dry-run] [--version X.Y.Z]'; + if (positionals.length > 1) throw new Error(usage); + if (values.help) console.log(usage); + else console.log(JSON.stringify(installKimi({ operation: positionals[0], home: values.home, dryRun: values['dry-run'], version: values.version }), null, 2)); + } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/scripts/pack.mjs b/scripts/pack.mjs index 22eb85c..b9ba231 100644 --- a/scripts/pack.mjs +++ b/scripts/pack.mjs @@ -20,7 +20,21 @@ const ROOT = fileURLToPath(new URL('../', import.meta.url)); const MODULE_PACKAGE = { private: true, type: 'module' }; // Written into every packed folder, so a later pack knows the folder is its own to replace. const PACKED_MARK = '.evolver-packed'; +// These bytes are needed by the shared runtime, including npm hosts whose package manager omits node_modules. +// Keep the allowlist exact: optional SDKs and engine packages are still not part of a host bundle. +const VENDORS = { 'smol-toml': { version: '1.9.1', license: 'BSD-3-Clause', entry: 'dist/index.js', root: 'lib/vendor/smol-toml' } }; +const vendorOf = file => Object.entries(VENDORS).find(([, vendor]) => file.startsWith(`${vendor.root}/`)); +const sourceOf = file => { + const vendor = vendorOf(file); + return path.join(ROOT, vendor ? `node_modules/${vendor[0]}/${file.slice(vendor[1].root.length + 1)}` : file); +}; +function vendorManifest(name, vendor) { + const manifest = JSON.parse(readFileSync(path.join(ROOT, 'node_modules', name, 'package.json'), 'utf8')); + if (manifest.name !== name || manifest.version !== vendor.version || manifest.license !== vendor.license + || manifest.type !== 'module' || manifest.exports?.import !== `./${vendor.entry}` || Object.keys(manifest.dependencies ?? {}).length) throw new Error(`Expected the pinned ${name}@${vendor.version} ESM package; run npm ci before packing.`); + return { name, version: manifest.version, type: manifest.type, license: manifest.license }; +} /** The host's readers beside its profile, which the registry imports by name rather than by a static import. */ function profileFilesOf(host) { @@ -29,8 +43,8 @@ function profileFilesOf(host) { } /** - * Every repo file the entries load, followed through relative imports. A static package import is refused; a dynamic - * one is the code's own fallback for a package that may be missing, so it is left out. + * Every file the entries load, followed through relative imports, at its path inside the bundle. Static packages + * are refused except the exact vendored ESM allowlist; dynamic package fallbacks remain outside the bundle. */ export function closureOf(entries) { const seen = new Set(); @@ -39,10 +53,20 @@ export function closureOf(entries) { const file = path.posix.normalize(queue.pop()); if (seen.has(file)) continue; seen.add(file); - for (const { specifier, isDynamic } of importsOf(readFileSync(path.join(ROOT, file), 'utf8'))) { + for (const { specifier, isDynamic } of importsOf(readFileSync(sourceOf(file), 'utf8'))) { if (specifier.startsWith('node:') || (isDynamic && !specifier.startsWith('.'))) continue; - if (!specifier.startsWith('.')) throw new Error(`${file} imports the package ${specifier}, which a packed host does not ship`); - queue.push(path.posix.join(path.posix.dirname(file), specifier)); + if (!specifier.startsWith('.')) { + const vendor = VENDORS[specifier]; + if (!vendor) throw new Error(`${file} imports the package ${specifier}, which a packed host does not ship`); + vendorManifest(specifier, vendor); + queue.push(`${vendor.root}/${vendor.entry}`); + seen.add(`${vendor.root}/package.json`); seen.add(`${vendor.root}/LICENSE`); + } else { + const dependency = path.posix.normalize(path.posix.join(path.posix.dirname(file), specifier)); + const vendor = vendorOf(file); + if (dependency.startsWith('../') || path.posix.isAbsolute(dependency) || (vendor && !dependency.startsWith(`${vendor[1].root}/`))) throw new Error(`Import escapes its package: ${file} -> ${specifier}`); + queue.push(dependency); + } } } return [...seen].sort(); @@ -58,7 +82,24 @@ function copied(from, to) { } const VERSION_FIELD = /("version"\s*:\s*")[^"]*(")/g; -const isManifest = (target) => /(?:^|\/)(?:plugin|marketplace|package)\.json$/.test(target); +const isManifest = (target) => /(?:^|\/)(?:kimi\.plugin|plugin|marketplace|package)\.json$/.test(target); + +function copyModule(file, out) { + const vendor = vendorOf(file), target = path.join(out, file); + if (vendor && file === `${vendor[1].root}/package.json`) { + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, `${JSON.stringify(vendorManifest(...vendor), null, 2)}\n`); + return; + } + copied(sourceOf(file), target); + if (!/\.m?js$/.test(file)) return; + const source = readFileSync(target, 'utf8'); + const rewritten = source.replace(/(\bfrom\s*|\bimport\s*)(['"])smol-toml\2/g, (_, prefix) => { + const relative = path.posix.relative(path.posix.dirname(file), `${VENDORS['smol-toml'].root}/${VENDORS['smol-toml'].entry}`); + return `${prefix}${JSON.stringify(relative.startsWith('.') ? relative : `./${relative}`)}`; + }); + if (source !== rewritten) writeFileSync(target, rewritten); +} // A release names its version once, in its tag: every manifest the host reads its version from gets it here. function stampVersion(out, targets, version) { @@ -96,7 +137,7 @@ export function pack(host, out, { version = null } = {}) { const files = closureOf([...spec.entries, ...profileFilesOf(host)]); assertReplaceable(out); rmSync(out, { recursive: true, force: true }); - for (const file of files) copied(path.join(ROOT, file), path.join(out, file)); + for (const file of files) copyModule(file, out); for (const [target, source] of Object.entries(spec.root ?? {})) copied(path.join(ROOT, source), path.join(out, target)); if (!existsSync(path.join(out, 'package.json'))) writeFileSync(path.join(out, 'package.json'), `${JSON.stringify(MODULE_PACKAGE, null, 2)}\n`); for (const [target, fields] of Object.entries(spec.patch ?? {})) patchJson(path.join(out, target), fields); diff --git a/test/agent/native-driver.test.js b/test/agent/native-driver.test.js new file mode 100644 index 0000000..25760a6 --- /dev/null +++ b/test/agent/native-driver.test.js @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import profile from '../../hosts/kimi/profile.js'; +import { createDriver } from '../../lib/agent/driver.js'; +import { nativeFactsOf } from '../../lib/runtime/native-facts.js'; +import { memoryPorts, PROMPT } from '../app/memory-ports.js'; + +const route = { alias: 'selected', providerId: 'account-b', model: 'upstream-model', protocol: 'openai', endpoint: 'https://example.test/v1', effort: 'off', binding: 'key-binding', configRevision: 'revision' }; + +function fixture(t) { + const home = mkdtempSync(join(tmpdir(), 'evolver-native-driver-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + const project = join(home, 'project'); mkdirSync(project); + writeFileSync(join(home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { workspace: { root: project } } })); + const agent = join(home, 'sessions/workspace/session/agents/main'); mkdirSync(agent, { recursive: true }); + const wire = join(agent, 'wire.jsonl'); + const add = (...records) => appendFileSync(wire, records.map(record => JSON.stringify(record)).join('\n') + '\n'); + add({ type: 'metadata', protocol_version: '1.5' }, { type: 'profile.bind', agentId: 'main', modelAlias: 'selected', thinkingEffort: 'off' }); + const made = memoryPorts(), env = { KIMI_CODE_HOME: home }; + const captured = []; + made.ports.model = { capture: hint => { captured.push(hint); return { ...route }; } }; + made.ports.native = nativeFactsOf(profile.native, env); + const driver = createDriver({ profile, ports: made.ports, env, lifetime: 'ephemeral', transcripts: { size: () => 0, readSince: () => '' } }); + const payload = { session_id: 'session', cwd: project, client_type: 'kimi_code_cli' }; + const call = (event, extra = {}) => driver.handle(event, { ...payload, hook_event_name: event, ...extra }); + const id = profile.payload.sessionId(payload, { env, nativeScope: made.ports.native.scope }); + const loop = event => ({ type: 'context.append_loop_event', agentId: 'main', event }); + return { ...made, env, id, call, add, loop, project, agent, captured }; +} + +test('native driver captures the route and waits for both the terminal and an earlier background check', async t => { + const f = fixture(t); + const reply = await f.call('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }); + assert.match(reply.message, /Evolution Memory/); + assert.deepEqual(f.captured[0], { alias: 'selected', effort: 'off' }); + assert.deepEqual(f.sessions.get(f.id).native.model, route); + assert.ok(f.scheduled.some(task => task.startsWith('native:'))); + f.add({ type: 'turn.prompt', agentId: 'main', turnId: 0, promptId: 'prompt-a', origin: { kind: 'user' } }); + await f.call('TurnStarted', { turn_id: 0, origin_kind: 'user' }); + assert.equal(f.sessions.get(f.id).turnId, '0'); + await f.call('PreToolUse', { tool_name: 'Edit', tool_call_id: 'edit', tool_input: { path: 'a.js' } }); + f.add( + f.loop({ type: 'tool.call', uuid: 'edit-call', turnId: 0, toolCallId: 'edit', name: 'Edit', args: { path: 'a.js' } }), + f.loop({ type: 'tool.result', parentUuid: 'edit-call', toolCallId: 'edit', result: { output: '', durationMs: 2 } }), + f.loop({ type: 'tool.call', uuid: 'lint-call', turnId: 0, toolCallId: 'lint', name: 'Bash', args: { command: 'npm run lint', run_in_background: true } }), + f.loop({ type: 'tool.result', parentUuid: 'lint-call', toolCallId: 'lint', result: { output: 'task_id: lint-task\nstatus: running', durationMs: 2 } }), + f.loop({ type: 'tool.call', uuid: 'check-call', turnId: 0, toolCallId: 'check', name: 'Bash', args: { command: 'npm test' } }), + f.loop({ type: 'tool.result', parentUuid: 'check-call', toolCallId: 'check', result: { output: '', durationMs: 2 } }), + { type: 'llm.request', agentId: 'main', modelAlias: 'selected', thinkingEffort: 'off', model: 'upstream-model', provider: 'openai' }, + f.loop({ type: 'content.part', uuid: 'answer', turnId: 0, stepUuid: 'final', part: { type: 'text', text: 'Fixed retries.' } }), + f.loop({ type: 'step.end', uuid: 'final', turnId: 0, finishReason: 'end_turn', providerFinishReason: 'completed' }), + ); + assert.deepEqual(await f.call('Stop'), {}); + assert.equal(f.sessions.get(f.id).open, true); + assert.equal(f.queued.length, 0); + f.add({ type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }); + await f.call('Notification'); + assert.equal(f.sessions.get(f.id).open, false); + assert.equal(f.sessions.get(f.id).native.pending, true); + assert.deepEqual(await f.ports.candidates.read(), {}); + + const tasks = join(f.agent, 'tasks'); + mkdirSync(tasks); + writeFileSync(join(tasks, 'lint-task.json'), JSON.stringify({ taskId: 'lint-task', kind: 'process', parentToolCallId: 'lint', command: 'npm run lint', status: 'completed', exitCode: 0 })); + await f.call('Notification'); + assert.equal(f.sessions.get(f.id).native.pending, false); + const candidate = (await f.ports.candidates.read())[f.id]; + assert.deepEqual(candidate.model, route); + assert.deepEqual(candidate.checks, [{ command: 'npm run lint', failed: false }, { command: 'npm test', failed: false }]); + assert.ok(f.retained[0].editedBefore.has(join(f.project, 'a.js'))); + assert.equal(f.queued.length, 1); + assert.equal(f.queued[0].report.task_id.includes(f.env.KIMI_CODE_HOME), false, 'native profile paths stay out of Hub task identifiers'); + await f.call('Stop'); + assert.equal(f.queued.length, 1); + await f.call('UserPromptSubmit', { prompt: [{ type: 'text', text: 'Now update docs.' }] }); + await f.call('Stop', { turn_id: 0 }); + assert.equal(f.sessions.get(f.id).open, true); + assert.equal(f.sessions.get(f.id).turn, 2); +}); diff --git a/test/app/distill-session.test.js b/test/app/distill-session.test.js index 1828cdc..e9c4d78 100644 --- a/test/app/distill-session.test.js +++ b/test/app/distill-session.test.js @@ -44,11 +44,52 @@ test('a session-end distillation the Proxy answers as a draft is kept as a draft assert.equal((await ports.candidates.read()).s1, undefined); assert.equal((await ports.drafts.read())[0].title, GENE.title); const { body } = requests.find((request) => request.path === '/conversation/distill'); - assert.deepEqual([body.platform, body.trigger, body.session_id, body.persist, body.publish], ['testagent', 'session_end', 'testagent:s1', true, false]); + assert.deepEqual([body.platform, body.trigger, body.session_id, body.persist, body.publish], ['testagent', 'session_end', 'testagent:s1', false, false]); assert.deepEqual(body.execution, { trace: [{ command: 'npm test', exit: 0 }] }); assert.deepEqual(body.distill_usage, { inputTokens: 10, outputTokens: 5 }); }); +test('manual distill distinguishes a reviewable draft from an unexpected Proxy storage report', async () => { + const older = { id: 'older-draft', title: 'An older draft', createdAt: 1_000_000 }; + for (const answer of [DRAFTED, { status: 'stored', details: 'raw Proxy detail' }]) { + const { ports, requests } = await withCandidate({ model: modelAnswering(GENE), distillAnswer: answer }); + await ports.drafts.update(() => [older]); + const result = await runCommand(ports, { sessionId: 's1', name: 'evolver-distill' }); + assert.equal(result.ok, true); + const drafts = await ports.drafts.read(); + assert.deepEqual(drafts.map(draft => draft.id), answer === DRAFTED ? [older.id, DRAFTED.distill_id] : [older.id]); + if (answer === DRAFTED) { + assert.match(result.text, /Review it with \/evolver-drafts/); + assert.doesNotMatch(result.text, /publish 1/); + } else { + assert.match(result.text, /Proxy reported that it stored the result/); + assert.doesNotMatch(result.text, /raw Proxy detail|evolver-drafts/); + } + assert.equal(requests.some(request => request.path === '/asset/submit'), false); + } +}); + +test('a runtime blocked before a model request keeps its candidate and attempts until the route is available', async () => { + const blocked = Object.assign(new Error('no safe selected route'), { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); + const { ports, requests, advance } = await withCandidate({ model: { complete: async () => { throw blocked; } } }); + await sessionEnded(ports, { sessionId: 's1', projectDir: '/proj' }); + for (let i = 0; i < 5; i++) { + const [outcome] = await distillLeftovers(ports); + assert.deepEqual(outcome, { status: 'blocked', reason: 'route_changed' }); + } + const candidate = (await ports.candidates.read()).s1; + assert.equal(candidate.attempts, 0); + assert.equal(candidate.claimedBy, null); + assert.equal(candidate.blocked.reason, 'route_changed'); + assert.equal(requests.some(request => request.path === '/conversation/distill'), false); + advance(UNDISTILLED_LEFTOVER_TTL_MS + 1); + ports.settings = { autoDistill: false }; + await runBackgroundTasks(ports, [TASKS.DISTILL]); + assert.ok((await ports.candidates.read()).s1); + ports.model = modelAnswering(GENE); + assert.equal((await distillSession(ports, { sessionId: 's1' })).status, 'drafted'); +}); + test('a live session keeps its candidate; only an ended one is a leftover', async () => { const { ports } = await withCandidate({ model: modelAnswering(GENE) }); assert.deepEqual(await distillLeftovers(ports), []); @@ -138,13 +179,15 @@ test('a failing headless model keeps the candidate, and three failed tries give }); test('with no headless model, distilling now hands the agent the prompt, and its Gene becomes a draft', async () => { - const { ports } = await withCandidate({ distillAnswer: { ...DRAFTED, publishable: true, publish_receipt: { receiptId: 'proxy-issued-receipt' } } }); + const { ports, requests } = await withCandidate({ distillAnswer: { ...DRAFTED, publishable: true, publish_receipt: { receiptId: 'proxy-issued-receipt' } } }); const asked = await distillSession(ports, { sessionId: 's1' }); assert.equal(asked.status, 'no_model'); assert.match(asked.agentGene.prompt, /Turn it into one reusable Gene/); assert.ok((await ports.candidates.read()).s1); const posted = await postAgentGene(ports, { sessionId: 's1', text: JSON.stringify(GENE), writtenFor: asked.agentGene.writtenFor }); assert.equal(posted.status, 'drafted'); + const { persist, publish } = requests.find(request => request.path === '/conversation/distill').body; + assert.deepEqual({ persist, publish }, { persist: false, publish: false }); const listed = await runCommand(ports, { sessionId: 's1', name: 'evolver-drafts' }); assert.match(listed.text, /Stabilise Redis retries/); const published = await runCommand(ports, { sessionId: 's1', name: 'evolver-drafts', args: 'publish 1' }); diff --git a/test/app/native-distill.test.js b/test/app/native-distill.test.js new file mode 100644 index 0000000..e2dde02 --- /dev/null +++ b/test/app/native-distill.test.js @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { distillSession } from '../../lib/app/distill-session.js'; +import { promptSubmitted } from '../../lib/app/prompt.js'; +import { toolFinished } from '../../lib/app/tool-result.js'; +import { turnEnded } from '../../lib/app/turn-end.js'; +import { modelRunnerOf } from '../../lib/runtime/model-executor.js'; +import { declarationFor } from '../fixtures/acp/declaration.mjs'; +import { memoryPorts, PROMPT } from './memory-ports.js'; + +const KEY = 'fixture-private-api-key-NOT-A-REAL-CREDENTIAL'; +const GENE = { title: 'Bounded retry validation', summary: 'An isolated native completion extracts the verified retry method.', strategy: ['Bound retries.', 'Validate the final check.'], validation: ['npm test passes'], signals: ['retry'], preconditions: ['A reproducible retry failure'] }; +const DRAFT = { ok: true, status: 'draft', publishable: false, distill_id: 'd'.repeat(16), quality: { ok: true, score: 6, threshold: 5 }, signals: ['retry'], gene: { type: 'Gene', asset_id: `sha256:${'a'.repeat(64)}`, summary: GENE.summary }, capsule: { type: 'Capsule', asset_id: `sha256:${'b'.repeat(64)}`, summary: GENE.summary } }; + +async function fixture(t) { + const dir = mkdtempSync(join(tmpdir(), 'evolver-native-distill-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const config = join(dir, 'config.toml'), receipt = join(dir, 'receipt.json'); + writeFileSync(config, '[provider]\nendpoint="https://example.invalid/v1"\nkey_env="FIXTURE_ACCOUNT_KEY"\n'); + const declaration = declarationFor(config, receipt); + const prepare = declaration.prepare; + declaration.prepare = (selected, context) => { + const prepared = prepare(selected, context), entry = prepared.files['fixture.json']; + entry.value = JSON.stringify({ ...JSON.parse(entry.value), answer: JSON.stringify(GENE) }); + return prepared; + }; + const env = { ...process.env, FIXTURE_ACCOUNT_KEY: KEY }; + const runner = modelRunnerOf(declaration, env); + const snapshot = runner.capture({ alias: 'route-a', effort: 'off' }); + const made = memoryPorts({ model: runner, distillAnswer: DRAFT }); + await promptSubmitted(made.ports, { sessionId: 'session', prompt: PROMPT, waitMs: 500 }); + await toolFinished(made.ports, { sessionId: 'session', note: { kind: 'edit', paths: ['retry.js'] } }); + await toolFinished(made.ports, { sessionId: 'session', note: { kind: 'shell', command: 'npm test', exit: 0 } }); + await turnEnded(made.ports, { sessionId: 'session', reason: 'answer', answer: 'Fixed retry policy.', model: snapshot }); + return { ...made, dir, receipt, env, snapshot }; +} + +test('verified candidate flows through real ACP transport and validation into a private Proxy draft', async t => { + const made = await fixture(t); + const result = await distillSession(made.ports, { sessionId: 'session' }); + assert.equal(result.status, 'drafted', () => JSON.stringify(result)); + assert.equal((await made.ports.drafts.read())[0].title, GENE.title); + assert.deepEqual(await made.ports.candidates.read(), {}); + const posts = made.requests.filter(request => request.path === '/conversation/distill'); + assert.equal(posts.length, 1); + assert.deepEqual(posts[0].body.strategy, GENE.strategy); + assert.equal(posts[0].body.publish, false); + assert.equal(posts[0].body.persist, false); + assert.equal(JSON.stringify(posts).includes(KEY), false); + assert.equal(JSON.stringify(posts).includes(made.snapshot.binding), false); + const receipt = JSON.parse(readFileSync(made.receipt, 'utf8')); + assert.equal(existsSync(receipt.home), false); + assert.throws(() => process.kill(receipt.pid, 0)); +}); + +test('unavailable account preflight keeps the candidate without a model request and restoration retries its original binding', async t => { + const made = await fixture(t); + made.env.FIXTURE_ACCOUNT_KEY = 'different-fixture-account'; + assert.deepEqual(await distillSession(made.ports, { sessionId: 'session' }), { status: 'blocked', reason: 'route_changed' }); + const candidate = (await made.ports.candidates.read()).session; + assert.equal(candidate.attempts, 0); + assert.deepEqual(candidate.model, made.snapshot); + assert.equal(candidate.claimedBy, null); + assert.equal(existsSync(made.receipt), false, 'no native process launched on preflight failure'); + assert.equal(made.requests.some(request => request.path === '/conversation/distill'), false); + assert.deepEqual(await made.ports.drafts.read(), []); + made.env.FIXTURE_ACCOUNT_KEY = KEY; + assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'drafted'); + assert.equal(made.requests.filter(request => request.path === '/conversation/distill').length, 1); + assert.equal((await made.ports.drafts.read()).length, 1); + assert.deepEqual(await made.ports.candidates.read(), {}); +}); diff --git a/test/app/native-turn.test.js b/test/app/native-turn.test.js new file mode 100644 index 0000000..dc03a0d --- /dev/null +++ b/test/app/native-turn.test.js @@ -0,0 +1,353 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { reconcileNativeTurn, recoverNativeTurns } from '../../lib/app/native-turn.js'; +import { promptSubmitted } from '../../lib/app/prompt.js'; +import { sessionStarted } from '../../lib/app/session-start.js'; +import { sessionEnded } from '../../lib/app/session-end.js'; +import { distillLeftovers, distillSession, dropExpiredLeftovers } from '../../lib/app/distill-session.js'; +import { candidateOf, UNDISTILLED_LEFTOVER_TTL_MS } from '../../lib/distill/candidate.js'; +import { memoryPorts, PROMPT } from './memory-ports.js'; + +const route = { alias: 'route-b', providerId: 'account-b', model: 'upstream-b', protocol: 'openai', endpoint: 'https://api.example/v1', effort: 'off', binding: 'captured-account', configRevision: 'same-config' }; +const modelHint = { alias: 'route-b', effort: 'off' }; +const payload = { session_id: 'native-session', cwd: '/workspace' }; +const at = { sessionId: 'session', projectDir: '/workspace', payload, turn: 1 }; +const record = () => ({ commands: ['npm test'], paths: ['/workspace/file.js'], edits: ['/workspace/file.js'], checks: [{ command: 'npm test', failed: false }], answer: 'Fixed retries.' }); +const facts = (terminal = true) => ({ status: 'ok', schemaSupported: true, schemaVersion: '1.5', mark: { offset: 500, fileId: 'wire' }, modelHint, + bind: { turnId: '0', promptId: 'native-prompt', origin: 'user', mark: 120 }, + terminal: terminal ? { turnId: '0', reason: 'completed', answer: 'Fixed retries.', modelHint: { ...modelHint, model: 'upstream-b', protocol: 'openai' } } : null, + record: record() }); + +async function pending(options = {}) { + const made = memoryPorts(options); + await promptSubmitted(made.ports, { sessionId: 'session', prompt: PROMPT, waitMs: 500 }); + await made.ports.sessions.update('session', stored => ({ ...stored, native: { turn: 1, payload, mark: { offset: 100, fileId: 'wire' }, turnId: null, model: route, modelHint, schemaVersion: '1.5' } })); + made.ports.model = { capture: () => ({ ...route }) }; + made.ports.native = { read: async () => facts() }; + return made; +} + +test('native prompt binds turn zero before terminal; Stop hints cannot close it early', async () => { + const made = await pending(); + const observed = await reconcileNativeTurn(made.ports, at, facts(false)); + assert.equal(observed.waiting, true); + assert.equal(made.sessions.get('session').turnId, '0'); + assert.equal(made.sessions.get('session').open, true); + assert.deepEqual(made.queued, []); + const ended = await reconcileNativeTurn(made.ports, at, facts()); + assert.equal(ended.ended.isCandidate, true); + assert.equal(made.sessions.get('session').open, false); + assert.deepEqual((await made.ports.candidates.read()).session.model, route); + assert.equal(made.queued.length, 1); + await reconcileNativeTurn(made.ports, at, facts()); + assert.equal(made.queued.length, 1); + assert.deepEqual(made.sessions.get('session').nativeCursor.mark, { offset: 500, fileId: 'wire' }); +}); + +test('read errors, partial wire and a native prompt before the pending mark cannot settle a turn', async () => { + for (const bad of [{ ...facts(), status: 'partial' }, { ...facts(), bind: { ...facts().bind, mark: 50 } }, { ...facts(), mark: { offset: 500, fileId: 'rotated' } }]) { + const made = await pending(); + await reconcileNativeTurn(made.ports, at, bad); + assert.equal(made.sessions.get('session').open, true); + assert.deepEqual(made.queued, []); + } + const made = await pending(); + made.ports.native.read = async () => { throw new Error('unreadable'); }; + const result = await reconcileNativeTurn(made.ports, at); + assert.equal(result.facts.status, 'unreadable'); + assert.equal(made.sessions.get('session').open, true); +}); + +test('late background verification only rejudges its original still-last turn', async () => { + const made = await pending(); + const running = facts(); running.record.checks[0].failed = null; + assert.equal((await reconcileNativeTurn(made.ports, at, running)).waiting, true); + assert.deepEqual(await made.ports.candidates.read(), {}); + await reconcileNativeTurn(made.ports, at, facts()); + assert.equal((await made.ports.candidates.read()).session.turn, 1); + assert.equal(made.queued.length, 1); + await promptSubmitted(made.ports, { sessionId: 'session', prompt: 'Continue a different task.', waitMs: 500 }); + await reconcileNativeTurn(made.ports, at, facts()); + assert.equal(made.sessions.get('session').open, true); + assert.equal(made.sessions.get('session').turn, 2); + assert.equal(made.queued.length, 1); +}); + +test('temporary account drift preserves the captured binding for retry while an actual native route mismatch stays unresolved', async () => { + const changed = await pending(); + changed.ports.model.capture = () => ({ ...route, binding: 'other-account' }); + await reconcileNativeTurn(changed.ports, at, facts()); + assert.deepEqual((await changed.ports.candidates.read()).session.model, route); + const wrong = await pending(); + const observed = facts(); observed.terminal.modelHint.model = 'wrong-model'; + await reconcileNativeTurn(wrong.ports, at, observed); + assert.equal((await wrong.ports.candidates.read()).session.model, null); +}); + +test('an unresolved native model binding remains blocked without consuming attempts or expiring the verified candidate', async () => { + for (const missingCapture of [true, false]) { + const made = await pending({ gone: new Set([4242]) }); + const observed = facts(); + if (missingCapture) await made.ports.sessions.update('session', stored => ({ ...stored, native: { ...stored.native, model: null } })); + else observed.terminal.modelHint.model = 'unmatched-native-model'; + made.ports.model.complete = () => assert.fail('an unresolved account must not request a model'); + await reconcileNativeTurn(made.ports, at, observed); + for (let count = 0; count < 3; count++) { + assert.deepEqual(await distillLeftovers(made.ports), [{ status: 'blocked', reason: 'route_unresolved' }]); + } + const candidate = (await made.ports.candidates.read()).session; + assert.equal(candidate.attempts, 0); + assert.equal(candidate.claimedBy, null); + assert.deepEqual(candidate.blocked, { reason: 'route_unresolved' }); + made.advance(UNDISTILLED_LEFTOVER_TTL_MS + 1); + await dropExpiredLeftovers(made.ports); + assert.ok((await made.ports.candidates.read()).session); + assert.equal(made.requests.some(request => request.path === '/conversation/distill'), false); + } +}); + +test('a known dead owner is reconciled from terminal wire before orphan abort reporting', async () => { + const made = await pending({ gone: new Set([4242]) }); + made.ports.owner.current = () => 9999; + await recoverNativeTurns(made.ports); + await sessionStarted(made.ports, { sessionId: 'new-session', projectDir: '/workspace' }); + assert.equal(made.queued.length, 1); + assert.equal(made.queued[0].report.outcome, 'success'); + assert.equal((await made.ports.candidates.read()).session.pid, 4242); +}); + +test('a prompt or owner change during a native read cannot close a newer unbound turn', async () => { + for (const change of [ + made => promptSubmitted(made.ports, { sessionId: 'session', prompt: 'New task.', waitMs: 500 }), + made => made.ports.sessions.update('session', stored => ({ ...stored, pid: 9999 })), + ]) { + const made = await pending(); + made.ports.native.read = async () => { await change(made); return facts(); }; + await reconcileNativeTurn(made.ports, at); + assert.equal(made.sessions.get('session').open, true); + assert.deepEqual(await made.ports.candidates.read(), {}); + assert.deepEqual(made.queued, []); + } +}); + +test('a duplicate terminal cannot recreate an explicitly discarded candidate', async () => { + const made = await pending(); + await reconcileNativeTurn(made.ports, at, facts()); + await made.ports.candidates.update(() => ({})); + await reconcileNativeTurn(made.ports, at, facts()); + assert.deepEqual(await made.ports.candidates.read(), {}); +}); + +test('session end preserves a pending native turn and its original prompt for late task completion', async () => { + const made = await pending(); + const running = facts(); running.record.checks[0].failed = null; + await reconcileNativeTurn(made.ports, at, running); + await sessionEnded(made.ports, at); + assert.equal(made.sessions.get('session').native.pending, true); + await reconcileNativeTurn(made.ports, at, facts()); + const candidate = (await made.ports.candidates.read()).session; + assert.equal(candidate.prompt, PROMPT); + assert.equal(candidate.closed, true); + assert.equal(made.queued.length, 1); +}); + +test('a native finalization claim covers the candidate-consumed window before the settled marker is written', async () => { + const made = await pending({ distillAnswer: { status: 'stored' } }); + let completions = 0; + made.ports.model.complete = async () => { completions++; return { answer: JSON.stringify({ summary: 'Reusable retry method.', strategy: ['Validate retries.'] }) }; }; + const entered = Promise.withResolvers(), release = Promise.withResolvers(), update = made.ports.sessions.update; + let held = false; + made.ports.sessions.update = async (id, mutate) => { + const next = await update(id, mutate); + if (!held && next && !next.open && next.settling === null && next.native?.turnId === '0' && !next.native.settled) { + held = true; entered.resolve(); await release.promise; + } + return next; + }; + const first = reconcileNativeTurn(made.ports, at, facts()); + await entered.promise; + assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'busy'); + await reconcileNativeTurn(made.ports, at, facts()); + assert.equal(completions, 0); + release.resolve(); await first; + assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'stored'); + await reconcileNativeTurn(made.ports, at, facts()); + assert.deepEqual(await made.ports.candidates.read(), {}); + assert.equal(completions, 1); +}); + +test('native candidates survive failed finalization writes without being consumed or recreated', async () => { + for (const stage of ['marker', 'activation']) { + const made = await pending({ distillAnswer: { status: 'stored' }, gone: new Set([4242]) }); + let completions = 0, failed = false; + made.ports.model.complete = async () => { completions++; return { answer: JSON.stringify({ summary: 'Reusable retry method.', strategy: ['Validate retries.'] }) }; }; + const sessionUpdate = made.ports.sessions.update, candidateUpdate = made.ports.candidates.update; + if (stage === 'marker') { + made.ports.sessions.update = (id, mutate) => sessionUpdate(id, stored => { + const next = mutate(stored); + if (!failed && next?.native?.settled && !stored.native?.settled) { + failed = true; throw Object.assign(new Error('session replacement failed'), { code: 'EACCES' }); + } + return next; + }); + } else { + made.ports.candidates.update = mutate => candidateUpdate(pending => { + const next = mutate(pending); + if (!failed && pending.session?.finalizationPending === true && next?.session && next.session.finalizationPending !== true) { + failed = true; throw Object.assign(new Error('candidate activation failed'), { code: 'EACCES' }); + } + return next; + }); + } + await assert.rejects(reconcileNativeTurn(made.ports, at, facts()), { code: 'EACCES' }); + assert.equal((await made.ports.candidates.read()).session.finalizationPending, true); + assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'busy'); + assert.deepEqual(await distillLeftovers(made.ports), []); + made.advance(UNDISTILLED_LEFTOVER_TTL_MS + 1); + await dropExpiredLeftovers(made.ports); + assert.ok((await made.ports.candidates.read()).session, 'unfinished persistence must retain its candidate past expiry'); + assert.equal(completions, 0); + made.ports.sessions.update = sessionUpdate; made.ports.candidates.update = candidateUpdate; + await recoverNativeTurns(made.ports); + assert.notEqual((await made.ports.candidates.read()).session.finalizationPending, true); + assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'stored'); + await reconcileNativeTurn(made.ports, at, facts()); + assert.deepEqual(await made.ports.candidates.read(), {}); + assert.equal(completions, 1); + } +}); + +test('a later prompt cannot strand an older pending candidate when activation needs recovery', async () => { + const made = await pending({ distillAnswer: { status: 'stored' } }); + let completions = 0, held = false, failed = false; + made.ports.model.complete = async () => { completions++; return { answer: JSON.stringify({ summary: 'Reusable retry method.', strategy: ['Validate retries.'] }) }; }; + const entered = Promise.withResolvers(), release = Promise.withResolvers(); + const sessionUpdate = made.ports.sessions.update, candidateUpdate = made.ports.candidates.update; + made.ports.sessions.update = async (id, mutate) => { + const next = await sessionUpdate(id, mutate); + if (!held && next && !next.open && next.settling === null && next.native?.turnId === '0' && !next.native.settled) { + held = true; entered.resolve(); await release.promise; + } + return next; + }; + made.ports.candidates.update = mutate => candidateUpdate(pending => { + const next = mutate(pending); + if (!failed && pending.session?.finalizationPending === true && next?.session && next.session.finalizationPending !== true) { + failed = true; throw Object.assign(new Error('activation replacement failed'), { code: 'EACCES' }); + } + return next; + }); + const first = reconcileNativeTurn(made.ports, at, facts()); + const rejected = assert.rejects(first, { code: 'EACCES' }); + await entered.promise; + await promptSubmitted(made.ports, { sessionId: 'session', prompt: 'Now update unrelated documentation.', waitMs: 500 }); + await made.ports.sessions.update('session', stored => ({ ...stored, native: { turn: 2, payload, mark: facts().mark, model: route, modelHint, schemaVersion: '1.5' } })); + release.resolve(); await rejected; + assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'busy'); + await recoverNativeTurns(made.ports); + assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'stored'); + await reconcileNativeTurn(made.ports, at, facts()); + assert.deepEqual(await made.ports.candidates.read(), {}); + assert.equal(completions, 1); + assert.equal(made.sessions.get('session').open, true); + assert.equal(made.sessions.get('session').turn, 2); +}); + +test('session end crossing first finalization closes the eventual candidate while its owner is still alive', async () => { + const made = await pending({ distillAnswer: { status: 'stored' } }); + made.ports.model.complete = async () => ({ answer: JSON.stringify({ summary: 'Reusable retry method.', strategy: ['Validate retries.'] }) }); + const entered = Promise.withResolvers(), release = Promise.withResolvers(), enqueue = made.ports.reports.enqueue; + let held = false; + made.ports.reports.enqueue = async entries => { + if (!held) { held = true; entered.resolve(); await release.promise; } + return enqueue(entries); + }; + const first = reconcileNativeTurn(made.ports, at, facts()); + await entered.promise; + await sessionEnded(made.ports, at); + release.resolve(); await first; + assert.equal(made.ports.owner.isGone(4242), false); + assert.equal((await made.ports.candidates.read()).session.closed, true); + assert.deepEqual((await distillLeftovers(made.ports)).map(outcome => outcome.status), ['stored']); +}); + +test('native claims refuse a live holder without TTL takeover and recover a dead stale holder', async () => { + const gone = new Set(), made = await pending({ gone }); + await made.ports.sessions.update('session', stored => ({ ...stored, open: false, turnId: '0', settling: made.ports.clock.now(), native: { ...stored.native, turnId: '0', promptId: 'native-prompt', finalizingPid: 9999 } })); + made.advance(60_000); + await reconcileNativeTurn(made.ports, at, facts()); + assert.deepEqual(await made.ports.candidates.read(), {}); + gone.add(9999); + await reconcileNativeTurn(made.ports, at, facts()); + assert.equal((await made.ports.candidates.read()).session.turn, 1); + assert.equal(made.sessions.get('session').native.finalizingPid, null); +}); + +test('failed and cancelled native turns stop polling pending checks that cannot become candidates', async () => { + for (const reason of ['failed', 'cancelled', 'interrupted']) { + const made = await pending(); + const terminal = facts(); terminal.terminal.reason = reason; terminal.record.checks[0].failed = null; + assert.equal((await reconcileNativeTurn(made.ports, at, terminal)).waiting, false); + assert.equal(made.sessions.get('session').native.pending, false); + assert.deepEqual(await made.ports.candidates.read(), {}); + } +}); + +test('a pending background check blocks native distillation until dead-owner recovery sees its result', async () => { + const made = await pending({ distillAnswer: { status: 'stored' }, gone: new Set([4242]) }); + let completions = 0; + made.ports.model.complete = async () => { completions++; return { answer: JSON.stringify({ summary: 'Reusable retry method.', strategy: ['Validate retries.'] }) }; }; + const observed = facts(); + observed.record.checks.unshift({ command: 'npm run lint', failed: null }); + made.ports.native.read = async () => observed; + const first = await reconcileNativeTurn(made.ports, at, observed); + assert.equal(first.waiting, true); + assert.equal(first.ended.isCandidate, false); + assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'nothing'); + assert.equal((await recoverNativeTurns(made.ports))[0].waiting, true); + assert.deepEqual(await distillLeftovers(made.ports), []); + assert.equal(completions, 0); + assert.equal(made.requests.some(({ path }) => path === '/conversation/distill'), false); + assert.deepEqual(await made.ports.candidates.read(), {}); + + observed.record.checks[0].failed = false; + assert.equal((await recoverNativeTurns(made.ports))[0].waiting, false); + assert.deepEqual((await distillLeftovers(made.ports)).map(({ status }) => status), ['stored']); + await recoverNativeTurns(made.ports); + assert.deepEqual(await distillLeftovers(made.ports), []); + assert.deepEqual(await made.ports.candidates.read(), {}); + assert.equal(completions, 1); + assert.equal(made.requests.filter(({ path }) => path === '/conversation/distill').length, 1); +}); + +for (const [stage, turn] of [['provisional', 1], ['provisional', 2], ['activated', 1]]) { + test(`recovery cannot distill a previously ${stage} candidate with a pending check (current turn ${turn})`, async () => { + const made = await pending({ gone: new Set([4242]) }); + const observed = facts(); + observed.record.checks.unshift({ command: 'npm run lint', failed: null }); + const candidate = candidateOf({ prompt: PROMPT, record: observed.record, model: route, turn: 1, pid: 4242, + now: made.ports.clock.now(), finalizationPending: stage === 'provisional' }); + await made.ports.candidates.update(() => ({ session: candidate })); + await made.ports.sessions.update('session', stored => ({ ...stored, turn, open: false, + native: { ...stored.native, turn, settled: true, pending: turn === 1, candidateMade: turn === 1 } })); + made.ports.native.read = async () => ({ status: 'unreadable' }); + made.ports.model.complete = () => assert.fail('a pending check must not request a model'); + + assert.equal((await distillSession(made.ports, { sessionId: 'session' })).status, 'busy'); + assert.deepEqual(await distillLeftovers(made.ports), []); + await recoverNativeTurns(made.ports); + assert.deepEqual((await made.ports.candidates.read()).session, candidate); + assert.deepEqual(await distillLeftovers(made.ports), []); + assert.equal(made.requests.some(({ path }) => path === '/conversation/distill'), false); + + if (turn === 1) { + observed.record.checks[0].failed = false; + made.ports.native.read = async () => observed; + await recoverNativeTurns(made.ports); + const ready = (await made.ports.candidates.read()).session; + assert.deepEqual(ready.checks, observed.record.checks); + assert.equal(ready.finalizationPending, undefined); + } + }); +} diff --git a/test/app/turn-end-races.test.js b/test/app/turn-end-races.test.js new file mode 100644 index 0000000..0eecc06 --- /dev/null +++ b/test/app/turn-end-races.test.js @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { promptSubmitted } from '../../lib/app/prompt.js'; +import { sessionEnded } from '../../lib/app/session-end.js'; +import { toolFinished } from '../../lib/app/tool-result.js'; +import { closeUnfinishedTurn, turnEnded } from '../../lib/app/turn-end.js'; +import { ASSET, PROMPT, memoryPorts } from './memory-ports.js'; + +const record = { commands: ['npm test'], checks: [{ command: 'npm test', failed: false }], paths: ['retry.js'], edits: ['retry.js'], answer: 'Fixed retries.' }; +const finish = ports => turnEnded(ports, { sessionId: 's', turnId: 'first', reason: 'answer', answer: record.answer, recordOf: () => record }); + +async function heldFirst(options = {}) { + const made = memoryPorts(options); + await promptSubmitted(made.ports, { sessionId: 's', turnId: 'first', prompt: PROMPT, waitMs: 500 }); + const entered = Promise.withResolvers(), release = Promise.withResolvers(), enqueue = made.ports.reports.enqueue; + let held = false; + made.ports.reports.enqueue = async entries => { + if (!held) { held = true; entered.resolve(); await release.promise; } + return enqueue(entries); + }; + const closing = finish(made.ports); + await entered.promise; + return { ...made, release, closing }; +} + +test('a correction past the settle budget invalidates a delayed first finalizer and receives its late reuse correction', async () => { + for (const endSession of [false, true]) { + const made = await heldFirst(); + await closeUnfinishedTurn(made.ports, { sessionId: 's' }); + await promptSubmitted(made.ports, { sessionId: 's', turnId: 'second', prompt: 'Still broken, that is incorrect. Fix it.', waitMs: 500 }); + if (endSession) await sessionEnded(made.ports, { sessionId: 's' }); + made.release.resolve(); await made.closing; + assert.deepEqual(await made.ports.candidates.read(), {}); + assert.equal(made.sessions.get('s').correctedTurn, 1); + assert.deepEqual(made.sessions.get('s').labelable, []); + const corrections = made.queued.filter(entry => entry.report.task_id === 'testagent:s:1:correction'); + assert.equal(corrections.length, 1); + assert.equal(corrections[0].report.outcome, 'failed'); + } +}); + +test('a delayed verified candidate survives an ordinary follow-up without changing that newer turn state', async () => { + const made = await heldFirst(); + await closeUnfinishedTurn(made.ports, { sessionId: 's' }); + await promptSubmitted(made.ports, { sessionId: 's', turnId: 'second', prompt: 'Now update documentation.', waitMs: 500 }); + const newerSettling = made.ports.clock.now() + 123; + await made.ports.sessions.update('s', stored => ({ ...stored, settling: newerSettling })); + made.release.resolve(); await made.closing; + assert.equal((await made.ports.candidates.read()).s.turn, 1); + assert.equal(made.sessions.get('s').settling, newerSettling); + assert.deepEqual(made.sessions.get('s').labelable, []); +}); + +test('an older finalizer cannot overwrite a newer verified candidate or its reuse labels', async () => { + const secondAsset = { ...ASSET, asset_id: `sha256:${'b'.repeat(64)}`, signals_match: ['queue'] }; + const made = await heldFirst({ assets: [ASSET, secondAsset] }); + await closeUnfinishedTurn(made.ports, { sessionId: 's' }); + await promptSubmitted(made.ports, { sessionId: 's', turnId: 'second', prompt: 'Fix queue delivery.', waitMs: 500 }); + await toolFinished(made.ports, { sessionId: 's', turnId: 'second', note: { kind: 'edit', paths: ['queue.js'] } }); + await toolFinished(made.ports, { sessionId: 's', turnId: 'second', note: { kind: 'shell', command: 'npm test', exit: 0 } }); + await turnEnded(made.ports, { sessionId: 's', turnId: 'second', reason: 'answer', answer: 'Fixed queue.' }); + const candidate = (await made.ports.candidates.read()).s, labelable = made.sessions.get('s').labelable; + made.release.resolve(); const old = await made.closing; + assert.equal(old.isCandidate, false); + assert.deepEqual((await made.ports.candidates.read()).s, candidate); + assert.deepEqual(made.sessions.get('s').labelable, labelable); +}); + +test('a repeated terminal cannot rejudge while the first terminal is still writing', async () => { + const made = await heldFirst(); + assert.equal(await finish(made.ports), null); + assert.deepEqual(await made.ports.candidates.read(), {}); + made.release.resolve(); await made.closing; + assert.equal((await made.ports.candidates.read()).s.turn, 1); +}); + +test('a correction of a newer turn does not retroactively label an older delayed terminal', async () => { + const secondAsset = { ...ASSET, asset_id: `sha256:${'b'.repeat(64)}`, signals_match: ['queue'] }; + const made = await heldFirst({ assets: [ASSET, secondAsset] }); + await closeUnfinishedTurn(made.ports, { sessionId: 's' }); + await promptSubmitted(made.ports, { sessionId: 's', turnId: 'second', prompt: 'Fix queue delivery.', waitMs: 500 }); + await turnEnded(made.ports, { sessionId: 's', turnId: 'second', reason: 'answer', answer: 'Fixed queue.', recordOf: () => record }); + await promptSubmitted(made.ports, { sessionId: 's', turnId: 'third', prompt: 'Still broken, that is incorrect. Fix it.', waitMs: 500 }); + made.release.resolve(); await made.closing; + assert.equal(made.queued.some(entry => entry.report.task_id === 'testagent:s:1:correction'), false); + assert.equal(made.queued.filter(entry => entry.report.task_id === 'testagent:s:2:correction').length, 1); +}); + +test('a rejudge forbidden from creating a candidate cannot race a same-turn candidate removal', async () => { + const made = memoryPorts(); + await promptSubmitted(made.ports, { sessionId: 's', turnId: 'first', prompt: PROMPT, waitMs: 500 }); + await finish(made.ports); + const update = made.ports.candidates.update; + let removed = false; + made.ports.candidates.update = async mutate => { + if (!removed) { removed = true; await update(() => ({})); } + return update(mutate); + }; + await turnEnded(made.ports, { sessionId: 's', turnId: 'first', reason: 'answer', answer: record.answer, recordOf: () => record, allowNewCandidate: false }); + assert.deepEqual(await made.ports.candidates.read(), {}); +}); diff --git a/test/app/turn-lifecycle.test.js b/test/app/turn-lifecycle.test.js index fdad62f..ec3e0bb 100644 --- a/test/app/turn-lifecycle.test.js +++ b/test/app/turn-lifecycle.test.js @@ -88,11 +88,11 @@ test('an aborted turn reports its injection as failed, with no check', async () assert.equal(report.evidence.aborted, true); }); -test('a check still running at turn end counts as not run; a later note with its call id settles it', async () => { +test('a check still running at turn end stays unknown; a later note with its call id settles it', async () => { const { ports } = memoryPorts(); await promptSubmitted(ports, { sessionId: 's1', prompt: PROMPT, ...WAIT }); await toolFinished(ports, { sessionId: 's1', note: { kind: 'shell', command: 'npm test', running: true, callId: 'c1' } }); - assert.deepEqual((await end(ports, 's1')).record.checks, []); + assert.deepEqual((await end(ports, 's1')).record.checks, [{ command: 'npm test', failed: null }]); await promptSubmitted(ports, { sessionId: 's1', prompt: PROMPT, ...WAIT }); await toolFinished(ports, { sessionId: 's1', note: { kind: 'shell', command: 'npm test', running: true, callId: 'c2' } }); await toolFinished(ports, { sessionId: 's1', note: { kind: 'shell', command: 'npm test', exit: 1, callId: 'c2' } }); @@ -101,6 +101,18 @@ test('a check still running at turn end counts as not run; a later note with its assert.deepEqual(record.commands, ['npm test']); }); +test('a later pending check blocks an earlier pass, even when an older command finishes late', async () => { + const { ports } = memoryPorts(); + await verifiedTurn(ports, 's1'); + await toolFinished(ports, { sessionId: 's1', note: { kind: 'shell', command: 'npm run lint', running: true, callId: 'old' } }); + await toolFinished(ports, { sessionId: 's1', note: { kind: 'shell', command: 'npm test', running: true, callId: 'new' } }); + await toolFinished(ports, { sessionId: 's1', note: { kind: 'shell', command: 'npm run lint', exit: 0, callId: 'old' } }); + const ended = await end(ports, 's1'); + assert.deepEqual(ended.record.checks.map(check => check.failed), [false, false, null]); + assert.equal((await ports.candidates.read()).s1, undefined); + assert.equal(ended.reports[0].evidence.tier, 'none'); +}); + test('an agent-reported turn counts as exposure only', async () => { const { ports } = memoryPorts(); await promptSubmitted(ports, { sessionId: 's1', prompt: PROMPT, ...WAIT }); @@ -267,6 +279,17 @@ test('a turn judged again keeps its candidate\'s stamp and claim; an agent\'s re assert.deepEqual((await ports.candidates.read()).agent, first); }); +test('a late native completion cannot close a newer logical turn whose native identity is not bound yet', async () => { + const { ports } = memoryPorts(); + await promptSubmitted(ports, { sessionId: 's1', prompt: PROMPT, turnId: '0', ...WAIT }); + const first = await ports.sessions.read('s1'); + await end(ports, 's1', { turnId: '0' }); + await promptSubmitted(ports, { sessionId: 's1', prompt: 'now the next task', ...WAIT }); + await end(ports, 's1', { turnId: '0', expectedTurn: first.turn, expectedPid: first.pid }); + assert.equal((await ports.sessions.read('s1')).open, true); + assert.equal((await ports.sessions.read('s1')).turn, 2); +}); + test('a correction that slips in while a re-judge reads its record still drops the candidate', async () => { const { ports } = memoryPorts(); const verified = { checks: [{ command: 'npm test', failed: false }], commands: ['npm test'], paths: ['/proj/a.js'], edits: ['/proj/a.js'], answer: 'Fixed.' }; diff --git a/test/channels/mcp.test.js b/test/channels/mcp.test.js index 9253eb0..a45212c 100644 --- a/test/channels/mcp.test.js +++ b/test/channels/mcp.test.js @@ -9,7 +9,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { PROMPT } from '../app/memory-ports.js'; import { installFakeEvolver } from '../helpers/fake-evolver.js'; -import { fakeProxy } from '../helpers/fake-proxy.js'; +import { closeProxyWhenSettled, fakeProxy } from '../helpers/fake-proxy.js'; const SERVER = fileURLToPath(new URL('../../bin/mcp.mjs', import.meta.url)); const GENE = { title: 'Stabilise Redis retries', summary: 'Flaky Redis-backed tests stop failing once retries are unbounded and the client is shared.', strategy: ['Set maxRetriesPerRequest to null', 'Share one client', 'Close it in teardown'] }; @@ -22,7 +22,7 @@ before(async () => { capsule: { type: 'Capsule', asset_id: 'sha256:' + 'c'.repeat(64), summary: GENE.summary }, } }); }); -after(() => proxy.close()); +after(() => closeProxyWhenSettled(proxy)); async function connected(t, env = {}) { const client = new Client({ name: 'evolver-test-client', version: '1.0.0' }); @@ -104,10 +104,10 @@ test('evolver_login answers the link and code, and tells the agent to show them const fakeDir = join(isolated.env.HOME, 'fake-evolver'); const fake = installFakeEvolver(join(isolated.env.HOME, '.evolver', 'state', 'mcp-generic-engine'), fakeDir); const client = await connected(t, { ...isolated.env, ...fake.env }); - t.after(() => { + t.after(async () => { const starts = readdirSync(fakeDir).includes('starts') ? readFileSync(join(fakeDir, 'starts'), 'utf8').trim().split('\n') : []; for (const line of starts) try { process.kill(Number(line.split(' ')[0]), 'SIGKILL'); } catch {} - isolated.close(); + await closeProxyWhenSettled(isolated); }); const { tools } = await client.listTools(); assert.match(tools.find((tool) => tool.name === 'evolver_login').description, /Show the link and code to the person verbatim; never open or approve it yourself\.$/); diff --git a/test/channels/script.test.js b/test/channels/script.test.js index f176e80..6539e49 100644 --- a/test/channels/script.test.js +++ b/test/channels/script.test.js @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { join } from 'node:path'; import { Readable } from 'node:stream'; import { after, before, test } from 'node:test'; @@ -16,7 +17,7 @@ const profile = defineProfile({ commands: { entry: 'inline-script', sessionOf: (env) => env.TEST_SESSION_ID ?? null, - scriptAnswer: (result, script) => `${result.text}\n[${script.session_id} script ${script.script.endsWith('bin/command.mjs')} nonce ${script.nonce.length}]`, + scriptAnswer: (result, script) => `${result.text}\n[${script.session_id} script ${script.script.endsWith(join('bin', 'command.mjs'))} nonce ${script.nonce.length}]`, }, }); diff --git a/test/fixtures/acp/declaration.mjs b/test/fixtures/acp/declaration.mjs new file mode 100644 index 0000000..fb4fce5 --- /dev/null +++ b/test/fixtures/acp/declaration.mjs @@ -0,0 +1,22 @@ +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function declarationFor(config, receipt, mode = '') { + return { + kind: 'acp', command: () => process.execPath, + args: () => [fileURLToPath(new URL('./fake-agent.mjs', import.meta.url))], + config: { + source: () => config, + select: (data, hint) => ({ + route: { alias: hint.alias, providerId: 'fixture', model: 'fixture-model', protocol: 'openai', endpoint: data.provider.endpoint, ...(hint.effort ? { effort: hint.effort } : {}) }, + credential: data.provider.key_env ? { kind: 'api-key', env: data.provider.key_env } : { kind: 'api-key', value: data.provider.key }, + }), + }, + sessionConfig: snapshot => snapshot.effort ? [{ id: 'thinking', value: snapshot.effort }] : [], + prepare: (selected, { credentialEnv }) => ({ + files: { 'fixture.json': { format: 'text', value: JSON.stringify({ ...selected.route, credentialEnv, mode }) } }, + env: { FIXTURE_RECEIPT: receipt }, + }), + facts: { root: home => join(home, 'records'), decode: records => records.at(-1) }, + }; +} diff --git a/test/fixtures/acp/fake-agent.mjs b/test/fixtures/acp/fake-agent.mjs new file mode 100644 index 0000000..643d09a --- /dev/null +++ b/test/fixtures/acp/fake-agent.mjs @@ -0,0 +1,81 @@ +import { appendFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { spawn } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; + +import { writeState } from '../../../lib/runtime/store/file-lock.js'; + +const home = process.env.HOME; +const config = JSON.parse(readFileSync(join(home, 'fixture.json'), 'utf8')); +const session = 'fixture-session'; +let thinkingEffort = 'high'; +const send = value => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n'); +const records = join(home, 'records'); +mkdirSync(records, { recursive: true }); +const receipt = { + pid: process.pid, home, argv: process.argv.slice(2), + hasPrivateCredential: process.env.EVOLVER_MODEL_API_KEY === 'fixture-private-api-key-NOT-A-REAL-CREDENTIAL', + hasAmbientCredential: process.env.AMBIENT_API_KEY !== undefined, + configContainsCredential: JSON.stringify(config).includes('fixture-private-api-key-NOT-A-REAL-CREDENTIAL'), +}; +const saveReceipt = () => { + const file = process.env.FIXTURE_RECEIPT; + if (!file) return; + writeState(file, receipt); +}; +saveReceipt(); + +if (config.mode === 'startup-hang') setInterval(() => {}, 1000); +else createInterface({ input: process.stdin }).on('line', async line => { + const request = JSON.parse(line); + if (request.method === 'initialize') send({ id: request.id, result: { protocolVersion: 1 } }); + else if (request.method === 'session/new') send({ id: request.id, result: { sessionId: session, configOptions: [{ id: 'model', options: [{ value: config.alias }] }, { id: 'thinking', options: [{ value: 'off' }, { value: 'high' }] }] } }); + else if (request.method === 'session/set_config_option') { + if (request.params.configId === 'thinking' && config.mode !== 'ignore-effort') thinkingEffort = request.params.value; + send({ id: request.id, result: {} }); + } + else if (request.method === 'session/prompt') { + receipt.promptStarted = true; + if (config.mode === 'exit-descendant') { + process.stderr.write('S 42\nD 0 ok\n'); // Native output is never a trusted job proof. + receipt.descendantNonce = randomBytes(16).toString('hex'); + receipt.descendantPipe = '\\\\.\\pipe\\evolver-acp-fixture-' + receipt.descendantNonce; + const source = "const net=require('net');const server=net.createServer(socket=>socket.once('data',data=>{if(data.toString()===process.argv[2])socket.end('stopped',()=>{server.close();process.exit(0);});}));server.listen(process.argv[1],()=>{process.send('ready');process.disconnect();});"; + // Detached avoids libuv's per-parent job; it does not request Win32 job breakaway. + const descendant = spawn(process.execPath, ['-e', source, receipt.descendantPipe, receipt.descendantNonce], { cwd: dirname(process.execPath), stdio: ['ignore', 'ignore', 'ignore', 'ipc'], detached: true, windowsHide: true }); + receipt.descendantPid = descendant.pid; + await new Promise((resolve, reject) => { descendant.once('message', resolve); descendant.once('error', reject); descendant.once('exit', () => reject(new Error('Fixture descendant exited before readiness'))); }); + receipt.descendantReady = true; + descendant.unref(); + } + if (config.mode === 'hang') { + const descendant = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true, stdio: 'ignore' }); + receipt.descendantPid = descendant.pid; + try { saveReceipt(); } catch (error) { descendant.kill('SIGKILL'); throw error; } + setInterval(() => {}, 1000); return; + } + saveReceipt(); + if (config.mode === 'output-limit') { process.stdout.write('x'.repeat(17 * 1024 * 1024)); return; } + if (config.mode === 'combined-output-limit') process.stderr.write('x'.repeat(9 * 1024 * 1024)); + if (config.mode === 'client-request') { send({ id: 99, method: 'session/request_permission', params: { sessionId: session } }); return; } + if (config.mode === 'tool-update') { send({ method: 'session/update', params: { sessionId: session, update: { sessionUpdate: 'tool_call', toolCallId: 'unavailable' } } }); return; } + const proof = { reason: 'completed', providerFinish: 'completed', model: config.model, alias: config.alias, protocol: config.protocol, thinkingEffort, toolSnapshots: [0], toolCalls: 0 }; + if (config.mode === 'native-error') proof.reason = 'failed'; + if (config.mode === 'missing-provider-finish') delete proof.providerFinish; + if (config.mode === 'wrong-model') proof.model = 'another-model'; + if (config.mode === 'tools-loaded') proof.toolSnapshots = [0, 1]; + if (config.mode === 'native-tool-call') proof.toolCalls = 1; + if (config.mode !== 'missing-proof') appendFileSync(join(records, 'wire.jsonl'), JSON.stringify(proof) + (config.mode === 'truncated-proof' ? '' : '\n')); + const answer = config.mode === 'combined-output-limit' ? 'x'.repeat(8 * 1024 * 1024) : config.answer ?? ('answer: ' + request.params.prompt[0].text); + send({ method: 'session/update', params: { sessionId: config.mode === 'wrong-session' ? 'another-session' : session, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: answer } } } }); + send({ id: request.id, result: { stopReason: config.mode === 'length-cutoff' ? 'max_tokens' : 'end_turn' } }); + if (config.mode === 'duplicate-response') send({ id: request.id, result: { stopReason: 'end_turn' } }); + if (config.mode === 'nonzero-exit') process.exitCode = 17; + } else if (request.method === 'session/close') { + send({ id: request.id, result: {} }); + if (config.mode === 'trailing-frame') process.stdout.write('{'); + if (config.mode === 'late-session') send({ method: 'session/update', params: { sessionId: 'unowned-session', update: { sessionUpdate: 'current_mode_update', currentModeId: 'default' } } }); + if (config.mode === 'late-chunk') send({ method: 'session/update', params: { sessionId: session, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'late untrusted answer' } } } }); + } +}); diff --git a/test/fixtures/acp/owner.mjs b/test/fixtures/acp/owner.mjs new file mode 100644 index 0000000..a98af18 --- /dev/null +++ b/test/fixtures/acp/owner.mjs @@ -0,0 +1,17 @@ +import childProcess from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import { modelRunnerOf } from '../../../lib/runtime/model-executor.js'; +import { declarationFor } from './declaration.mjs'; + +if (process.argv[5] === 'kill-guardian') { + const fork = childProcess.fork; + childProcess.fork = (...args) => { + const guardian = fork(...args); + process.once('message', message => { if (message === 'kill-guardian') guardian.kill('SIGKILL'); }); + return guardian; + }; + syncBuiltinESMExports(); +} +const runner = modelRunnerOf(declarationFor(process.argv[2], process.argv[3], process.argv[4] ?? 'hang'), process.env); +try { await runner.complete(runner.capture({ alias: 'route-a' }), 'disposable fixture'); } +catch { process.exitCode = 1; } diff --git a/test/fixtures/acp/poison-preload.mjs b/test/fixtures/acp/poison-preload.mjs new file mode 100644 index 0000000..c9ff20c --- /dev/null +++ b/test/fixtures/acp/poison-preload.mjs @@ -0,0 +1 @@ +if (process.env.EVOLVER_MODEL_API_KEY) throw new Error('Untrusted host preload inherited the private model environment'); diff --git a/test/helpers/fake-proxy.js b/test/helpers/fake-proxy.js index fce36a9..09ef2a3 100644 --- a/test/helpers/fake-proxy.js +++ b/test/helpers/fake-proxy.js @@ -47,3 +47,14 @@ export async function fakeProxy({ assets = [ASSET], recipes = [], stepAssets = [ }, }; } + +/** A detached hook worker can still finish a state write after the Proxy received its last request. */ +export async function closeProxyWhenSettled(proxy) { + for (let attempt = 0; ; attempt++) { + try { return proxy.close(); } + catch (error) { + if (attempt === 19 || !['ENOTEMPTY', 'EPERM', 'EBUSY'].includes(error.code)) throw error; + await new Promise(resolve => setTimeout(resolve, 100)); + } + } +} diff --git a/test/helpers/lock-contenders.js b/test/helpers/lock-contenders.js new file mode 100644 index 0000000..1e412a7 --- /dev/null +++ b/test/helpers/lock-contenders.js @@ -0,0 +1,39 @@ +import { spawn } from 'node:child_process'; +import { rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export async function until(check) { + const deadline = Date.now() + 10_000; + while (!check()) { + if (Date.now() >= deadline) throw new Error('timed out waiting for a lock contender'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +export function lockContenders(t, dir, script, args, cleanupMarkers) { + const children = []; + const results = () => Promise.all(children); + t.after(async () => { + try { + for (const name of cleanupMarkers) writeFileSync(join(dir, name), ''); + } finally { + await results(); + rmSync(dir, { recursive: true, force: true }); + } + }); + return { + run(role) { + const child = spawn(process.execPath, ['--input-type=module', '-e', script, ...args, role], { windowsHide: true }); + let stderr = ''; + const watchdog = setTimeout(() => child.kill('SIGKILL'), 15_000); + child.stderr.on('data', (data) => { stderr += data; }); + child.on('error', (error) => { stderr += String(error); }); + children.push(new Promise((resolve) => child.on('close', (code) => { + clearTimeout(watchdog); + resolve({ code, stderr }); + }))); + return child; + }, + results, + }; +} diff --git a/test/hosts/antigravity/hooks.test.js b/test/hosts/antigravity/hooks.test.js index 707f28f..b1a6550 100644 --- a/test/hosts/antigravity/hooks.test.js +++ b/test/hosts/antigravity/hooks.test.js @@ -223,7 +223,7 @@ test('the candidate of a session whose Antigravity process is gone is distilled await s.invoke(); const [body] = await waitFor(() => proxy.requests.filter((request) => request.path === '/conversation/distill').map((request) => request.body), 5_000); - assert.deepEqual([body.platform, body.trigger, body.user_prompt, body.persist, body.publish], ['antigravity', 'session_end', 'add a retry to the uploader', true, false]); + assert.deepEqual([body.platform, body.trigger, body.user_prompt, body.persist, body.publish], ['antigravity', 'session_end', 'add a retry to the uploader', false, false]); assert.deepEqual(body.strategy, ['Measure.', 'Back off.']); assert.equal(body.distill_usage.inputTokens, 900); const [call] = readFileSync(join(home, 'fake-agy.log'), 'utf8').trim().split('\n').map((line) => JSON.parse(line)); diff --git a/test/hosts/antigravity/readers.test.js b/test/hosts/antigravity/readers.test.js index 49c1d99..916feb9 100644 --- a/test/hosts/antigravity/readers.test.js +++ b/test/hosts/antigravity/readers.test.js @@ -35,7 +35,8 @@ test('a turn record pairs each call with its result in step order: commands, che { command: 'node --test', failed: true }, { command: 'npm run lint', failed: false }, { command: 'node --test 2>&1 | tail -5', failed: false }, - ], 'the build moved to the background has no verdict'); + { command: 'npm run build', failed: null }, + ], 'the build moved to the background keeps an unknown verdict'); assert.deepEqual(record.paths, ['/workspace/project/src/index.js', '/workspace/project/src/lru.js', '/workspace/project/src/index.js']); assert.deepEqual(record.edits, ['/workspace/project/src/lru.js'], 'the failed replacement edited nothing'); assert.equal(record.answer, 'LRU 缓存写好了,node --test 通过。'); @@ -49,7 +50,7 @@ test('an exit code decides a check; without one an ERROR step fails it and the o assert.deepEqual(checksOf(turn(call(1, 'npm test'), row({ step_index: 2, source: 'MODEL', type: 'RUN_COMMAND', status: 'ERROR' }))), [{ command: 'npm test', failed: true }]); assert.deepEqual(checksOf(turn(call(1, 'npm test'), row({ step_index: 2, source: 'MODEL', type: 'RUN_COMMAND', content: 'done' }))), [{ command: 'npm test', failed: null }]); assert.deepEqual(checksOf(turn(call(1, 'npm test | tail -3'), row({ step_index: 2, source: 'MODEL', type: 'RUN_COMMAND', content: 'The command exited with code 0.\nOutput:\nAssertionError: expected 1 to equal 2' }))), [{ command: 'npm test | tail -3', failed: true }]); - assert.deepEqual(checksOf(turn(call(1, 'npm test'))), [], 'a call no result answered was not run'); + assert.deepEqual(checksOf(turn(call(1, 'npm test'))), [{ command: 'npm test', failed: null }], 'a call without a result remains an unknown check'); }); test('a turn ends at the next prompt, and a compaction that dropped its prompt still finds its steps', () => { diff --git a/test/hosts/claude-code/module.test.js b/test/hosts/claude-code/module.test.js index de36449..54d085d 100644 --- a/test/hosts/claude-code/module.test.js +++ b/test/hosts/claude-code/module.test.js @@ -176,7 +176,7 @@ test('the relay\'s distillation runs on the session\'s own model through the mod assert.equal(cc.seen.completions[0].model, captured.session.model); assert.match(cc.seen.completions[0].prompt, /npm test/); const { body } = cc.proxy.requests.find((request) => request.path === '/conversation/distill'); - assert.deepEqual([body.platform, body.persist, body.publish], ['claude-code', true, false]); + assert.deepEqual([body.platform, body.persist, body.publish], ['claude-code', false, false]); }); test('commands answer as Claude Code shows them: evolve is sent as a prompt, others answer text, foreign ones pass on', async (t) => { diff --git a/test/hosts/codex/readers.test.js b/test/hosts/codex/readers.test.js index e72f057..266b375 100644 --- a/test/hosts/codex/readers.test.js +++ b/test/hosts/codex/readers.test.js @@ -95,7 +95,7 @@ test('an exit-code header decides, a timeout fails, and a run still going waits assert.deepEqual(checksOf(bash(RESPONSES_0144['bash.timedOut'])), [{ command: 'npm test', failed: true }]); const running = bash(RESPONSES_0144['bash.stillRunning']); assert.deepEqual(running, { kind: 'shell', command: 'npm test', running: true, callId: 'call_suite' }); - assert.deepEqual(turnRecordOf([running]).checks, [], 'a run with no verdict counts as not run'); + assert.deepEqual(turnRecordOf([running]).checks, [{ command: 'npm test', failed: null }], 'a pending check keeps its unknown verdict'); const completed = turnRecordOf([running, bash(RESPONSES_0144['bash.finishedFailing'])]); assert.deepEqual(completed.checks, [{ command: 'npm test', failed: true }]); assert.deepEqual(completed.commands, ['npm test'], 'its completion is the same command'); diff --git a/test/hosts/grok/readers.test.js b/test/hosts/grok/readers.test.js index 5f15b45..29811d6 100644 --- a/test/hosts/grok/readers.test.js +++ b/test/hosts/grok/readers.test.js @@ -6,6 +6,7 @@ import { test } from 'node:test'; import { promptCommandOf } from '../../../hosts/grok/commands.js'; import grok from '../../../hosts/grok/profile.js'; import { turnRecordOf } from '../../../lib/turn/record.js'; +import { skipReason } from '../../../lib/distill/candidate.js'; // Payloads grok 1.0.70 sent to a plugin's hooks on a fake model (paths renamed): one verified turn, a run of other // tools, a turn cut at --max-turns, a turn whose API call failed, and a typed command. @@ -52,7 +53,7 @@ test('a shell result carries its exit code; failure then pass reads as a fixed c test('a call grok moves to the background has no verdict until its task\'s output settles it under the same call id', () => { const started = noteOf('PostToolUse.run_terminal_command.background'); assert.deepEqual(started, { kind: 'shell', command: 'sleep 2; npm test', running: true, callId: 'call_3' }); - assert.deepEqual(turnRecordOf([started]).checks, [], 'a check still running is no pass'); + assert.deepEqual(turnRecordOf([started]).checks, [{ command: 'sleep 2; npm test', failed: null }], 'a check still running keeps its place with no verdict'); const output = noteOf('PostToolUse.get_command_or_subagent_output'); assert.deepEqual(output, { kind: 'shell', command: 'sleep 2; npm test', exit: 1, output: '\n> test\n> sh check.sh\n\n1 failing\n', callId: 'call_3' }); const record = turnRecordOf([noteOf('PostToolUse.run_terminal_command.backgroundForTaskOutput'), output]); @@ -67,7 +68,17 @@ test('a background task killed before it finished reports no exit code and settl const cancelled = noteOf('PostToolUse.get_command_or_subagent_output.cancelled'); assert.deepEqual(cancelled, { kind: 'shell', command: 'sleep 30; npm test', running: true, callId: 'call_4' }); const started = { kind: 'shell', command: 'sleep 30; npm test', running: true, callId: 'call_4' }; - assert.deepEqual(turnRecordOf([started, cancelled]).checks, [], 'a run that never finished is no pass'); + assert.deepEqual(turnRecordOf([started, cancelled]).checks, [{ command: 'sleep 30; npm test', failed: null }], 'a run that never finished stays unknown'); +}); + +test('a later background check blocks an earlier pass until its own native task result settles it', () => { + const passed = { ...noteOf('PostToolUse.run_terminal_command.passed'), command: 'npm test', callId: 'earlier-check' }; + const running = noteOf('PostToolUse.run_terminal_command.background'); + const record = turnRecordOf([{ kind: 'edit', paths: ['README.md'] }, passed, running, { kind: 'answer', text: 'Done.' }]); + assert.deepEqual(record.checks.map(check => check.failed), [false, null]); + assert.equal(skipReason('answer', record), 'unverified'); + const failed = turnRecordOf([{ kind: 'edit', paths: ['README.md'] }, passed, running, noteOf('PostToolUse.get_command_or_subagent_output'), { kind: 'answer', text: 'Done.' }]); + assert.equal(skipReason('answer', failed), 'failed_check'); }); test('a prompt of many reminders followed by the person\'s text is read in linear time and stays the person\'s', () => { diff --git a/test/hosts/kimi/dist.test.js b/test/hosts/kimi/dist.test.js new file mode 100644 index 0000000..d309b23 --- /dev/null +++ b/test/hosts/kimi/dist.test.js @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { pack } from '../../../scripts/pack.mjs'; + +const EVENTS = ['SessionStart', 'UserPromptSubmit', 'TurnStarted', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'Stop', 'StopFailure', 'Interrupt', 'SessionEnd', 'TaskStarted', 'Notification']; + +test('the Kimi bundle carries a native flat hook manifest and self-contained channel entries', t => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-kimi-pack-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + pack('kimi', dir, { version: '9.8.7' }); + const manifest = JSON.parse(readFileSync(join(dir, 'kimi.plugin.json'), 'utf8')); + assert.equal(manifest.name, 'evolver-plugin'); + assert.equal(manifest.version, '9.8.7'); + assert.deepEqual(manifest.hooks.map(hook => hook.event), EVENTS); + for (const hook of manifest.hooks) { + assert.equal(hook.command, `node bin/hook.mjs kimi ${hook.event}`); + assert.equal(hook.timeout, hook.event === 'UserPromptSubmit' ? 10 : 3); + } + for (const entry of ['bin/hook.mjs', 'bin/worker.mjs', 'bin/command.mjs', 'hosts/kimi/profile.js', 'lib/runtime/process/windows-job.js']) assert.ok(existsSync(join(dir, entry)), entry); + for (const extra of ['.claude-plugin', '.mcp.json', '.git', 'test', 'config.toml', 'credentials']) assert.equal(existsSync(join(dir, extra)), false, extra); + assert.equal(readFileSync(join(dir, '.evolver-packed'), 'utf8').trim(), 'kimi'); + const system = Object.fromEntries(['SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP'].filter(key => process.env[key]).map(key => [key, process.env[key]])); + const child = spawnSync(process.execPath, [join(dir, 'bin/hook.mjs'), 'kimi', 'UserPromptSubmit'], { + cwd: dir, input: '{"session_id":"smoke","prompt":"ignored"}', encoding: 'utf8', + env: { ...system, HOME: dir, USERPROFILE: dir, KIMI_CODE_HOME: join(dir, 'private-native'), EVOLVER_HEADLESS: '1' }, + }); + assert.equal(child.status, 0, child.stderr); + assert.deepEqual(JSON.parse(child.stdout), {}); + assert.equal(existsSync(join(dir, 'private-native')), false, 'recursive completion creates no native profile or state'); +}); diff --git a/test/hosts/kimi/evidence.test.js b/test/hosts/kimi/evidence.test.js new file mode 100644 index 0000000..73cc569 --- /dev/null +++ b/test/hosts/kimi/evidence.test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; + +import kimi from '../../../hosts/kimi/profile.js'; +import { decodeNativeFacts } from '../../../hosts/kimi/transcript.js'; +import { skipReason } from '../../../lib/distill/candidate.js'; + +const meta = { type: 'metadata', protocol_version: '1.5' }; +const prompt = { type: 'turn.prompt', agentId: 'main', turnId: 0, promptId: 'p', origin: { kind: 'user' } }; +const loop = event => ({ type: 'context.append_loop_event', agentId: 'main', event }); +const call = (uuid, name, args) => loop({ type: 'tool.call', uuid, turnId: '0', toolCallId: uuid, name, args }); +const result = (uuid, value) => loop({ type: 'tool.result', parentUuid: uuid, toolCallId: uuid, result: value }); + +test('pre-edit paths require the bound main native turn and successful results alone count as edits', () => { + const cwd = resolve('fixture-project'); + const payload = { hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { path: 'file.js' }, cwd }; + const context = { nativeFacts: { status: 'ok', schemaSupported: true, toolTurnId: '0' } }; + assert.deepEqual(kimi.evidence.editedFiles(payload, context), [join(cwd, 'file.js')]); + assert.deepEqual(kimi.evidence.editedFiles(payload), []); + const records = [meta, prompt, call('failed-write', 'Write', { path: 'bad.js' }), result('failed-write', { output: 'denied', durationMs: 1, isError: true }), call('edited', 'Edit', { path: 'file.js' }), result('edited', { output: '', durationMs: 1 })]; + const facts = decodeNativeFacts(records, [], { cwd }, { turnId: '0' }); + assert.deepEqual(facts.record.edits, [join(cwd, 'file.js')]); +}); + +test('a later uncompleted Bash check prevents a candidate even when an earlier check passed', () => { + const records = [meta, prompt, call('pass', 'Bash', { command: 'npm test' }), result('pass', { output: '', durationMs: 1 }), call('pending', 'Bash', { command: 'npm test' })]; + const record = decodeNativeFacts(records, [], {}, { turnId: '0' }).record; + record.answer = 'Done.'; record.edits = ['file.js']; + assert.deepEqual(record.checks.map(check => check.failed), [false, null]); + assert.equal(skipReason('answer', record), 'unverified'); +}); + +test('a background task record must match both the native call and its returned task ID', () => { + const records = [meta, prompt, call('background', 'Bash', { command: 'npm test', run_in_background: true }), result('background', { output: 'task_id: expected-task\nstatus: running', durationMs: 1 })]; + const task = { taskId: 'different-task', kind: 'process', parentToolCallId: 'background', command: 'npm test', status: 'completed', exitCode: 0 }; + const record = decodeNativeFacts(records, [task], {}, { turnId: '0' }).record; + assert.deepEqual(record.checks, [{ command: 'npm test', failed: null }]); +}); diff --git a/test/hosts/kimi/install.test.js b/test/hosts/kimi/install.test.js new file mode 100644 index 0000000..473ec86 --- /dev/null +++ b/test/hosts/kimi/install.test.js @@ -0,0 +1,304 @@ +import assert from 'node:assert/strict'; +import fs, { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync, spawn } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { dirname, join, sep } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { installKimi } from '../../../scripts/install-kimi.mjs'; +import { pack } from '../../../scripts/pack.mjs'; + +function scratch(t) { + const home = mkdtempSync(join(tmpdir(), 'evolver-kimi-install-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + return home; +} + +test('status does not create registration or change native account configuration', t => { + const home = scratch(t); + writeFileSync(join(home, 'config.toml'), 'default_model = "personal"\n'); + mkdirSync(join(home, 'credentials')); + writeFileSync(join(home, 'credentials', 'personal.json'), '{"fixture":"keep"}'); + assert.deepEqual(installKimi({ operation: 'status', home }), { + installed: false, registered: false, path: join(home, 'plugins', 'managed', 'evolver-plugin'), + }); + assert.equal(existsSync(join(home, 'plugins')), false); + assert.equal(readFileSync(join(home, 'config.toml'), 'utf8'), 'default_model = "personal"\n'); + assert.equal(readFileSync(join(home, 'credentials', 'personal.json'), 'utf8'), '{"fixture":"keep"}'); +}); + +test('the terminal installer honors KIMI_CODE_HOME without creating a native profile', t => { + const home = scratch(t), selected = join(home, 'selected profile'); + const script = fileURLToPath(new URL('../../../scripts/install-kimi.mjs', import.meta.url)); + const result = JSON.parse(execFileSync(process.execPath, [script, 'status'], { + cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home, KIMI_CODE_HOME: selected }, encoding: 'utf8', + })); + assert.equal(result.path, join(selected, 'plugins', 'managed', 'evolver-plugin')); + assert.equal(result.installed, false); + assert.equal(existsSync(selected), false); + assert.throws(() => execFileSync(process.execPath, [script, 'status', '--version', '1.2.3'], { cwd: home, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }), /Version/); + assert.equal(existsSync(join(home, '.kimi-code')), false); +}); + +test('install and update register the managed package while preserving native data and other plugins', t => { + const home = scratch(t), plugins = join(home, 'plugins'); + mkdirSync(plugins); + const other = { id: 'another-plugin', root: '/another/local/plugin', source: 'local-path', enabled: false, custom: 'keep' }; + writeFileSync(join(plugins, 'installed.json'), JSON.stringify({ version: 1, plugins: [other], extra: 'keep' })); + writeFileSync(join(home, 'config.toml'), 'default_model = "personal"\n'); + mkdirSync(join(home, 'evolver')); + writeFileSync(join(home, 'evolver', 'kimi-distill-drafts.json'), '[{"title":"unpublished"}]'); + writeFileSync(join(home, 'evolver', 'kimi-settings.json'), '{"recall":false}'); + const installed = installKimi({ home, version: '1.2.3' }); + assert.equal(installed.installed, true); + assert.equal(installed.version, '1.2.3'); + let registry = JSON.parse(readFileSync(join(plugins, 'installed.json'), 'utf8')); + assert.deepEqual(registry.plugins[0], other); + assert.equal(registry.extra, 'keep'); + assert.deepEqual(registry.plugins[1], { + id: 'evolver-plugin', root: installed.path, source: 'local-path', enabled: true, installedAt: registry.plugins[1].installedAt, + }); + assert.ok(Number.isFinite(Date.parse(registry.plugins[1].installedAt))); + registry.plugins[1].enabled = false; + writeFileSync(join(plugins, 'installed.json'), JSON.stringify(registry)); + const updated = installKimi({ operation: 'update', home, version: '2.0.0' }); + assert.equal(updated.version, '2.0.0'); + registry = JSON.parse(readFileSync(join(plugins, 'installed.json'), 'utf8')); + assert.equal(registry.plugins.length, 2); + assert.equal(registry.plugins[1].enabled, false, 'update retains a user-disabled plugin'); + assert.equal(readFileSync(join(home, 'config.toml'), 'utf8'), 'default_model = "personal"\n'); + assert.equal(readFileSync(join(home, 'evolver', 'kimi-distill-drafts.json'), 'utf8'), '[{"title":"unpublished"}]'); + assert.equal(readFileSync(join(home, 'evolver', 'kimi-settings.json'), 'utf8'), '{"recall":false}'); + const removed = installKimi({ operation: 'remove', home }); + assert.equal(removed.installed, false); + assert.equal(removed.packageRetained, true); + assert.deepEqual(JSON.parse(readFileSync(join(plugins, 'installed.json'), 'utf8')), { version: 1, plugins: [other], extra: 'keep' }); + assert.ok(existsSync(join(installed.path, 'kimi.plugin.json')), 'native removal retains managed bytes'); + assert.equal(readFileSync(join(home, 'evolver', 'kimi-distill-drafts.json'), 'utf8'), '[{"title":"unpublished"}]'); +}); + +test('remove sees a registration committed before it acquires the installer lock', t => { + const home = scratch(t); + installKimi({ home, version: '1.2.3' }); + const plugins = join(home, 'plugins'); + const file = join(plugins, 'installed.json'); + const guard = join(plugins, '.evolver-plugin-install.lock'); + const installed = readFileSync(file, 'utf8'); + writeFileSync(file, '{"version":1,"plugins":[]}'); + const read = fs.readFileSync; + let committed = false; + t.mock.method(fs, 'readFileSync', (path, ...args) => { + const snapshot = read(path, ...args); + if (path !== file || committed) return snapshot; + committed = true; + writeFileSync(file, installed); + return existsSync(guard) ? read(path, ...args) : snapshot; + }); + const removed = installKimi({ home, operation: 'remove' }); + assert.ok(committed); + assert.equal(removed.packageRetained, true); + assert.deepEqual(JSON.parse(read(file, 'utf8')).plugins, []); +}); + +test('invalid or unreadable registration never becomes an empty registry', t => { + const home = scratch(t), plugins = join(home, 'plugins'), file = join(plugins, 'installed.json'); + mkdirSync(plugins); + for (const text of ['{', 'null', '[]', '{"version":2,"plugins":[]}', '{"version":1,"plugins":[{}]}', '{"version":1,"plugins":[{"id":"same","root":"/a"},{"id":"same","root":"/b"}]}']) { + writeFileSync(file, text); + for (const operation of ['install', 'status', 'remove']) assert.throws(() => installKimi({ operation, home })); + assert.equal(readFileSync(file, 'utf8'), text); + } + const text = '{"version":1,"plugins":[]}'; writeFileSync(file, text); + const read = fs.readFileSync; + t.mock.method(fs, 'readFileSync', (path, ...args) => { + if (path === file) throw Object.assign(new Error('fixture read denied'), { code: 'EACCES' }); + return read(path, ...args); + }); + assert.throws(() => installKimi({ home }), /read denied/); + assert.equal(read(file, 'utf8'), text); + assert.equal(existsSync(join(plugins, 'managed')), false); +}); + +test('unmanaged bytes, foreign registrations and missing updates fail without mutation', t => { + const home = scratch(t), target = join(home, 'plugins', 'managed', 'evolver-plugin'); + assert.throws(() => installKimi({ operation: 'update', home }), /not installed/); + assert.throws(() => installKimi({ home, version: '01.2.3' }), /Version/); + assert.deepEqual(installKimi({ home, dryRun: true }), { operation: 'install', path: target, dryRun: true }); + assert.equal(existsSync(join(home, 'plugins')), false); + mkdirSync(target, { recursive: true }); writeFileSync(join(target, 'personal-file'), 'keep'); + assert.throws(() => installKimi({ home }), /unmanaged/); + assert.equal(readFileSync(join(target, 'personal-file'), 'utf8'), 'keep'); + rmSync(target, { recursive: true }); + const registry = join(home, 'plugins', 'installed.json'), text = JSON.stringify({ version: 1, plugins: [{ id: 'evolver-plugin', root: join(home, 'foreign') }] }); + writeFileSync(registry, text); + assert.throws(() => installKimi({ home }), /another path/); + assert.throws(() => installKimi({ home, operation: 'remove' }), /another path/); + assert.equal(readFileSync(registry, 'utf8'), text); +}); + +for (const failure of ['activation', 'registry']) test(`a ${failure} failure restores the package and leaves the registry intact`, t => { + const home = scratch(t), installed = installKimi({ home, version: '1.2.3' }); + const registry = join(home, 'plugins', 'installed.json'), before = readFileSync(registry, 'utf8'); + writeFileSync(join(installed.path, 'keep-on-failure'), 'previous package'); + const rename = fs.renameSync; + t.mock.method(fs, 'renameSync', (from, to) => { + if ((failure === 'activation' && String(from).endsWith(`${sep}package`) && to === installed.path) + || (failure === 'registry' && to === registry)) throw new Error(`fixture ${failure} failure`); + return rename(from, to); + }); + assert.throws(() => installKimi({ home, operation: 'update', version: '2.0.0' }), new RegExp(`${failure} failure`)); + assert.equal(readFileSync(join(installed.path, 'keep-on-failure'), 'utf8'), 'previous package'); + assert.equal(readFileSync(registry, 'utf8'), before); + assert.equal(readdirSync(home).some(name => name.startsWith('.evolver-install-')), false); + assert.equal(readdirSync(join(home, 'plugins')).some(name => /\.tmp$|\.lock/.test(name)), false); +}); + +test('a failed rollback retains the old package for explicit recovery', t => { + const home = scratch(t), installed = installKimi({ home, version: '1.2.3' }); + writeFileSync(join(installed.path, 'keep-on-failure'), 'previous package'); + const rename = fs.renameSync; + t.mock.method(fs, 'renameSync', (from, to) => { + if (to === installed.path) throw new Error('fixture activation/rollback failure'); + return rename(from, to); + }); + assert.throws(() => installKimi({ home, operation: 'update', version: '2.0.0' }), /previous package is retained at/); + const stages = readdirSync(home).filter(name => name.startsWith('.evolver-install-')); + assert.equal(stages.length, 1); + assert.equal(readFileSync(join(home, stages[0], 'previous', 'keep-on-failure'), 'utf8'), 'previous package'); + assert.equal(JSON.parse(readFileSync(join(home, 'plugins', 'installed.json'), 'utf8')).plugins[0].root, installed.path); +}); + +test('a later installer run recovers an update interrupted after the backup rename', t => { + const home = scratch(t), installed = installKimi({ home, version: '1.2.3' }); + const registry = join(home, 'plugins', 'installed.json'); + const oldRegistry = JSON.parse(readFileSync(registry, 'utf8')); + oldRegistry.plugins[0].enabled = false; + writeFileSync(registry, JSON.stringify(oldRegistry)); + writeFileSync(join(installed.path, 'keep-on-failure'), 'previous package'); + const module = new URL('../../../scripts/install-kimi.mjs', import.meta.url).href; + const interrupt = `import fs from 'node:fs'; +import { installKimi } from ${JSON.stringify(module)}; +const [home, target] = process.argv.slice(1); +const rename = fs.renameSync; +fs.renameSync = (from, to) => { + rename(from, to); + if (from === target) process.exit(73); +}; +installKimi({ home, operation: 'update', version: '2.0.0' }); +`; + const crashAfterBackup = () => { + assert.throws(() => execFileSync(process.execPath, ['--input-type=module', '-e', interrupt, home, installed.path], { + timeout: 15_000, stdio: ['ignore', 'pipe', 'pipe'], + }), { status: 73 }); + assert.equal(existsSync(installed.path), false); + assert.deepEqual(JSON.parse(readFileSync(registry, 'utf8')), oldRegistry); + }; + crashAfterBackup(); + const stage = readdirSync(home).find(name => name.startsWith('.evolver-install-')); + const duplicate = mkdtempSync(join(home, '.evolver-install-')); + cpSync(join(home, stage), duplicate, { recursive: true }); + assert.throws(() => installKimi({ home, operation: 'status' }), /Multiple interrupted Kimi updates/); + assert.equal(existsSync(installed.path), false); + assert.equal(readFileSync(join(duplicate, 'previous', 'keep-on-failure'), 'utf8'), 'previous package'); + rmSync(duplicate, { recursive: true, force: true }); + + assert.equal(installKimi({ home, operation: 'status' }).version, '1.2.3'); + assert.equal(readFileSync(join(installed.path, 'keep-on-failure'), 'utf8'), 'previous package'); + assert.deepEqual(JSON.parse(readFileSync(registry, 'utf8')), oldRegistry); + assert.equal(readdirSync(home).some(name => name.startsWith('.evolver-install-')), false); + + crashAfterBackup(); + const rename = fs.renameSync; + t.mock.method(fs, 'renameSync', (from, to) => { + if (String(from).endsWith(`${sep}package`) && to === installed.path) throw new Error('fixture activation failure'); + return rename(from, to); + }); + assert.throws(() => installKimi({ home, operation: 'update', version: '3.0.0' }), /fixture activation failure/); + t.mock.restoreAll(); + assert.equal(readFileSync(join(installed.path, 'keep-on-failure'), 'utf8'), 'previous package'); + assert.equal(installKimi({ home, operation: 'update', version: '3.0.0' }).version, '3.0.0'); + assert.deepEqual(JSON.parse(readFileSync(registry, 'utf8')), oldRegistry); + assert.equal(readdirSync(home).some(name => name.startsWith('.evolver-install-')), false); +}); + +test('a concurrent installer cannot replace a package while a failed registry write is rolling back', async t => { + const home = scratch(t), installed = installKimi({ home, version: '1.2.3' }); + const registry = join(home, 'plugins', 'installed.json'), ready = join(home, 'rollback-ready'), release = join(home, 'allow-rollback'); + const module = new URL('../../../scripts/install-kimi.mjs', import.meta.url).href; + writeFileSync(join(installed.path, 'keep-on-failure'), 'original package'); + const writer = join(home, 'writer.mjs'); + writeFileSync(writer, `import fs from 'node:fs'; +import { installKimi } from ${JSON.stringify(module)}; +const [home, target, registry, ready, release] = process.argv.slice(2); +const rename = fs.renameSync, remove = fs.rmSync; +fs.renameSync = (from, to) => { if (to === registry) throw new Error('fixture registry failure'); return rename(from, to); }; +fs.rmSync = (file, options) => { + if (file === target) { + fs.writeFileSync(ready, 'ready'); + const deadline = Date.now() + 10000; + while (!fs.existsSync(release)) { + if (Date.now() >= deadline) throw new Error('fixture rollback barrier expired'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20); + } + } + return remove(file, options); +}; +try { installKimi({ home, operation: 'update', version: '2.0.0' }); console.log(JSON.stringify({ unexpected: 'success' })); } +catch (error) { console.log(JSON.stringify({ error: error.message, code: error.code })); } +`); + const launch = (args) => { + const child = spawn(process.execPath, args, { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + t.after(() => { if (child.exitCode === null) child.kill(); }); + let stdout = '', stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); child.stderr.on('data', chunk => { stderr += chunk; }); + return new Promise((resolve, reject) => { child.once('error', reject); child.once('close', code => code === 0 ? resolve(JSON.parse(stdout)) : reject(new Error(stderr))); }); + }; + const first = launch([writer, home, installed.path, registry, ready, release]); + const deadline = Date.now() + 10_000; + while (!existsSync(ready)) { + if (Date.now() >= deadline) throw new Error('fixture writer did not reach rollback'); + await new Promise(resolve => setTimeout(resolve, 20)); + } + const competing = join(home, 'competing.mjs'); + writeFileSync(competing, `import { installKimi } from ${JSON.stringify(module)}; +try { console.log(JSON.stringify(installKimi({ home: process.argv[2], operation: 'update', version: '3.0.0' }))); } +catch (error) { console.log(JSON.stringify({ error: error.message, code: error.code })); } +`); + let other; + try { other = await launch([competing, home]); } + finally { writeFileSync(release, 'release'); } + const failed = await first; + assert.equal(other.code, 'ELOCKED', 'activation stays excluded until the first installer finishes rollback'); + assert.match(failed.error, /fixture registry failure/); + assert.equal(installKimi({ home, operation: 'status' }).version, '1.2.3'); + assert.equal(readFileSync(join(installed.path, 'keep-on-failure'), 'utf8'), 'original package'); + assert.equal(installKimi({ home, operation: 'update', version: '3.0.0' }).version, '3.0.0'); + assert.equal(readdirSync(home).some(name => name.startsWith('.evolver-install-')), false); +}); + +test('the released bundle keeps its bytes/version by default; --version explicitly packs the checkout', t => { + const root = scratch(t), checkout = join(root, 'checkout'), home = join(root, 'native'); + const repository = fileURLToPath(new URL('../../../', import.meta.url)); + for (const file of ['bin', 'lib', 'scripts', 'hosts/kimi', 'LICENSE', 'package.json', 'node_modules/smol-toml']) { + mkdirSync(dirname(join(checkout, file)), { recursive: true }); + cpSync(join(repository, file), join(checkout, file), { recursive: true }); + } + const bundle = join(checkout, 'dist', 'kimi'); + pack('kimi', bundle, { version: '9.8.7' }); + writeFileSync(join(bundle, 'README.md'), 'The released guide, not the source checkout.\n'); + const run = (operation, ...args) => JSON.parse(execFileSync(process.execPath, [join(checkout, 'scripts', 'install-kimi.mjs'), operation, '--home', home, ...args], { cwd: checkout, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })); + const installed = run('install'); + assert.equal(run('status').version, '9.8.7'); + assert.equal(readFileSync(join(installed.path, 'README.md'), 'utf8'), 'The released guide, not the source checkout.\n'); + run('update', '--version', '10.0.0'); + assert.equal(run('status').version, '10.0.0'); + assert.equal(readFileSync(join(installed.path, 'README.md'), 'utf8'), readFileSync(join(checkout, 'hosts', 'kimi', 'dist', 'README.md'), 'utf8')); + const manifestFile = join(bundle, 'kimi.plugin.json'), released = JSON.parse(readFileSync(manifestFile, 'utf8')); + for (const invalid of [{ name: 'foreign' }, { version: 'not-a-version' }]) { + writeFileSync(manifestFile, JSON.stringify({ ...released, ...invalid })); + assert.throws(() => run('update'), /Invalid Kimi package/); + assert.equal(run('status').version, '10.0.0'); + } + assert.equal(readdirSync(home).some(name => name.startsWith('.evolver-install-')), false); +}); diff --git a/test/hosts/kimi/managed-oauth.test.js b/test/hosts/kimi/managed-oauth.test.js new file mode 100644 index 0000000..86d3337 --- /dev/null +++ b/test/hosts/kimi/managed-oauth.test.js @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { headlessKimi } from '../../../hosts/kimi/model.js'; +import { modelRunnerOf } from '../../../lib/runtime/model-executor.js'; + +const ACCESS = 'fixture-coding-access-only'; +const REFRESH = 'fixture-refresh-never-copied'; +const DEVICE = '01234567-89ab-4def-8123-456789abcdef'; +const HINT = { alias: 'kimi-code/k3', effort: 'high' }; + +function fixture(t) { + const profile = mkdtempSync(join(tmpdir(), 'evolver-kimi-oauth-')); + t.after(() => rmSync(profile, { recursive: true, force: true })); + const config = join(profile, 'config.toml'), device = join(profile, 'device_id'); + mkdirSync(join(profile, 'credentials')); + const tokenFile = join(profile, 'credentials', 'kimi-code.json'); + writeFileSync(config, `default_model = "kimi-code/k3" +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +api_key = "" +[providers."managed:kimi-code".oauth] +storage = "file" +key = "oauth/kimi-code" +[models."kimi-code/k3"] +provider = "managed:kimi-code" +model = "k3" +display_name = "Kimi K3" +max_context_size = 65536 +capabilities = ["thinking", "always_thinking", "tool_use"] +support_efforts = ["low", "high", "max"] +default_effort = "high" +`); + const wire = { access_token: ACCESS, refresh_token: REFRESH, token_type: 'Bearer', expires_at: Math.floor(Date.now() / 1000) + 3600 }; + const setToken = (overrides = {}) => writeFileSync(tokenFile, JSON.stringify({ ...wire, ...overrides })); + setToken(); + writeFileSync(device, DEVICE); + const starts = []; + const env = { HOME: profile, USERPROFILE: profile, KIMI_CODE_HOME: profile }; + const runner = modelRunnerOf({ ...headlessKimi, command: () => { starts.push(true); return null; } }, env); + return { config, device, tokenFile, setToken, starts, runner }; +} + +test('a current managed Kimi credential binds the exact route without exposing bearer, refresh or device', t => { + const f = fixture(t), original = readFileSync(f.tokenFile); + const model = f.runner.capture(HINT); + assert.deepEqual([model.providerId, model.providerType, model.protocol, model.model, model.effort], + ['managed:kimi-code', 'kimi', 'openai', 'k3', 'high']); + for (const privateValue of [ACCESS, REFRESH, DEVICE]) assert.equal(JSON.stringify(model).includes(privateValue), false); + assert.deepEqual(readFileSync(f.tokenFile), original); + assert.deepEqual(f.starts, []); +}); + +test('rotated or expired access credentials and changed device identity block before native launch', async t => { + const f = fixture(t), model = f.runner.capture(HINT); + f.setToken({ refresh_token: 'different-unused-refresh' }); + assert.deepEqual(f.runner.capture(HINT), model, 'an unused refresh token is never bound'); + f.setToken({ access_token: 'different-access-token' }); + await assert.rejects(f.runner.complete(model, 'fixture'), { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); + f.setToken(); + writeFileSync(f.device, '11234567-89ab-4def-8123-456789abcdef'); + await assert.rejects(f.runner.complete(model, 'fixture'), { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); + writeFileSync(f.device, DEVICE); + f.setToken({ expires_at: Math.floor(Date.now() / 1000) + 30 }); + await assert.rejects(f.runner.complete(model, 'fixture'), { code: 'MODEL_UNAVAILABLE', reason: 'credential_missing' }); + assert.deepEqual(f.starts, []); +}); diff --git a/test/hosts/kimi/model.test.js b/test/hosts/kimi/model.test.js new file mode 100644 index 0000000..5752af9 --- /dev/null +++ b/test/hosts/kimi/model.test.js @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { join, resolve } from 'node:path'; +import { test } from 'node:test'; + +import { headlessKimi } from '../../../hosts/kimi/model.js'; + +const config = () => ({ + default_model: 'route-a', + providers: { + 'account-a': { type: 'openai', base_url: 'https://api-a.example/v1', api_key_env: 'ACCOUNT_A' }, + 'account-b': { type: 'openai', base_url: 'https://api-b.example/v1', api_key_env: 'ACCOUNT_B' }, + }, + models: { + 'route-a': { provider: 'account-a', model: 'same-model', max_context_size: 32768, capabilities: ['tool_use'] }, + 'route-b': { provider: 'account-b', model: 'same-model', max_context_size: 32768, capabilities: ['tool_use', 'thinking'], support_efforts: ['low', 'high'], default_effort: 'low' }, + }, +}); + +const managed = () => ({ + providers: { + 'managed:kimi-code': { type: 'kimi', base_url: 'https://api.kimi.com/coding/v1', api_key: '', oauth: { storage: 'file', key: 'oauth/kimi-code' } }, + }, + models: { + 'kimi-code/k3': { provider: 'managed:kimi-code', model: 'k3', max_context_size: 1048576, max_output_size: 256, display_name: 'K3', capabilities: ['thinking', 'always_thinking', 'tool_use'], support_efforts: ['low', 'high', 'max'], default_effort: 'max' }, + }, +}); + +test('official managed Kimi chooses the exact CN access-only slot and keeps the native provider family', () => { + const sourceHome = resolve('source-kimi-home'), home = resolve('private-completion-home'); + const selected = headlessKimi.config.select(managed(), { alias: 'kimi-code/k3', effort: 'low' }, { KIMI_CODE_HOME: sourceHome }); + assert.deepEqual(selected?.route, { alias: 'kimi-code/k3', providerId: 'managed:kimi-code', providerType: 'kimi', model: 'k3', protocol: 'openai', endpoint: 'https://api.kimi.com/coding/v1', effort: 'low' }); + assert.deepEqual(selected.credential, { kind: 'oauth-access', tokenFile: join(sourceHome, 'credentials', 'kimi-code.json'), deviceIdFile: join(sourceHome, 'device_id') }); + const deviceId = '00000000-0000-4000-8000-000000000000'; + const prepared = headlessKimi.prepare({ ...selected, deviceId }, { home, credentialEnv: 'EVOLVER_MODEL_API_KEY' }); + const cfg = prepared.files['kimi-home/config.toml'].value; + assert.deepEqual(cfg.providers, { 'managed:kimi-code': { type: 'kimi', base_url: 'https://api.kimi.com/coding/v1', api_key_env: 'EVOLVER_MODEL_API_KEY' } }); + assert.deepEqual(prepared.files['kimi-home/device_id'], { format: 'text', value: deviceId }); + assert.equal(cfg.models['kimi-code/k3'].default_effort, 'low'); + assert.equal(cfg.models['kimi-code/k3'].max_output_size, 256); + assert.equal(JSON.stringify(prepared).includes('credentials'), false); +}); + +test('managed Kimi rejects other endpoints, credential slots, auth overrides and mixed credentials', () => { + const hint = { alias: 'kimi-code/k3', effort: 'high' }; + for (const [change, env] of [ + [p => { p.base_url = 'https://api.kimi.ai/coding/v1'; }, {}], + [p => { p.oauth.key = 'oauth/../../elsewhere'; }, {}], + [p => { p.oauth.storage = 'keyring'; }, {}], + [p => { p.oauth.oauth_host = 'https://auth.kimi.ai'; }, {}], + [p => { p.api_key_env = 'OTHER_ACCOUNT'; }, { OTHER_ACCOUNT: 'fixture-other-account' }], + [p => { p.api_key = 123; }, {}], + [() => {}, { KIMI_CODE_BASE_URL: 'https://api.kimi.ai/coding/v1' }], + [() => {}, { KIMI_OAUTH_HOST: 'https://auth.kimi.ai' }], + ]) { + const cfg = managed(); + change(cfg.providers['managed:kimi-code']); + assert.equal(headlessKimi.config.select(cfg, hint, env), null); + } +}); + +test('a Kimi API-key provider preserves its family and explicit native wire protocol', () => { + const cfg = managed(); + cfg.providers['managed:kimi-code'] = { type: 'kimi', base_url: 'https://api.moonshot.ai/v1', api_key_env: 'FIXTURE_KEY' }; + cfg.models['kimi-code/k3'].protocol = 'openai_responses'; + const selected = headlessKimi.config.select(cfg, { alias: 'kimi-code/k3', effort: 'high' }, { FIXTURE_KEY: 'fixture-not-a-real-key' }); + assert.equal(selected.route.protocol, 'openai_responses'); + assert.equal(selected.route.providerType, 'kimi'); + assert.deepEqual(selected.credential, { kind: 'api-key', env: 'FIXTURE_KEY' }); + const cfgPrivate = headlessKimi.prepare(selected, { home: 'scratch', credentialEnv: 'PRIVATE_KEY_ENV' }).files['kimi-home/config.toml'].value; + assert.equal(cfgPrivate.providers['managed:kimi-code'].type, 'kimi'); + assert.equal(cfgPrivate.models['kimi-code/k3'].protocol, 'openai_responses'); +}); + +test('exact native alias selects its provider account, upstream model and active thinking effort', () => { + const selected = headlessKimi.config.select(config(), { alias: 'route-b', effort: 'high' }, { ACCOUNT_A: 'fixture-a', ACCOUNT_B: 'fixture-b' }); + assert.deepEqual(selected.route, { alias: 'route-b', providerId: 'account-b', model: 'same-model', protocol: 'openai', endpoint: 'https://api-b.example/v1', effort: 'high' }); + assert.deepEqual(selected.credential, { kind: 'api-key', env: 'ACCOUNT_B' }); + assert.equal(headlessKimi.config.select(config(), null, { ACCOUNT_A: 'fixture-a' }), null); + assert.equal(headlessKimi.config.select(config(), { alias: 'missing' }, { ACCOUNT_A: 'fixture-a' }), null); +}); + +test('private completion files contain only the selected route and an environment credential reference', () => { + const selected = headlessKimi.config.select(config(), { alias: 'route-b', effort: 'high' }, { ACCOUNT_B: 'fixture-b' }); + const prepared = headlessKimi.prepare(selected, { home: join('scratch', 'home'), credentialEnv: 'EVOLVER_MODEL_API_KEY' }); + const cfg = prepared.files['kimi-home/config.toml'].value; + assert.deepEqual(Object.keys(cfg.providers), ['account-b']); + assert.deepEqual(Object.keys(cfg.models), ['route-b']); + assert.equal(cfg.providers['account-b'].api_key_env, 'EVOLVER_MODEL_API_KEY'); + assert.equal(JSON.stringify(prepared).includes('fixture-b'), false); + assert.equal(cfg.default_model, 'route-b'); + assert.equal(cfg.models['route-b'].default_effort, 'high'); + assert.equal(cfg.telemetry, false); + assert.equal(cfg.builtin_product_skills, false); + assert.match(prepared.files['kimi-home/agents/agent.md'].value, /override: true\ntools: \[\]\nsubagents: \[\]/); + assert.equal(prepared.env.KIMI_CODE_HOME, join('scratch', 'home', 'kimi-home')); +}); + +test('thinking off does not inherit a model default and requires explicit native session selection', () => { + const cfg = config(); cfg.models['route-b'].default_effort = 'high'; + const selected = headlessKimi.config.select(cfg, { alias: 'route-b', effort: 'off' }, { ACCOUNT_B: 'fixture-b' }); + const prepared = headlessKimi.prepare(selected, { home: 'scratch', credentialEnv: 'EVOLVER_MODEL_API_KEY' }); + assert.equal(prepared.files['kimi-home/config.toml'].value.models['route-b'].default_effort, undefined); + assert.deepEqual(headlessKimi.sessionConfig(selected.route, selected), [{ id: 'thinking', value: 'off' }]); + const simple = headlessKimi.config.select(cfg, { alias: 'route-a', effort: 'off' }, { ACCOUNT_A: 'fixture-a' }); + assert.deepEqual(headlessKimi.sessionConfig(simple.route, simple), []); + assert.equal(headlessKimi.config.select(cfg, { alias: 'route-a', effort: 'high' }, { ACCOUNT_A: 'fixture-a' }), null); +}); + +test('adaptive Responses model preserves the observed thinking toggle without declared levels', () => { + const cfg = config(); + cfg.providers['account-b'].type = 'openai_responses'; + const model = cfg.models['route-b']; + delete model.support_efforts; + delete model.default_effort; + model.adaptive_thinking = true; + const env = { ACCOUNT_A: 'fixture-a', ACCOUNT_B: 'fixture-b' }; + for (const effort of ['on', 'off']) { + const selected = headlessKimi.config.select(cfg, { alias: 'route-b', effort }, env); + assert.equal(selected?.route.protocol, 'openai_responses'); + assert.equal(selected.route.effort, effort); + assert.deepEqual(selected.credential, { kind: 'api-key', env: 'ACCOUNT_B' }); + const prepared = headlessKimi.prepare(selected, { home: 'scratch', credentialEnv: 'EVOLVER_MODEL_API_KEY' }); + assert.equal(prepared.files['kimi-home/config.toml'].value.models['route-b'].adaptive_thinking, true); + assert.deepEqual(headlessKimi.sessionConfig(selected.route, selected), [{ id: 'thinking', value: effort }]); + } + for (const effort of [undefined, 'high']) { + assert.equal(headlessKimi.config.select(cfg, { alias: 'route-b', effort }, env), null); + } + model.adaptive_thinking = 'true'; + assert.equal(headlessKimi.config.select(cfg, { alias: 'route-b', effort: 'off' }, env), null); +}); + +test('unsafe or unresolved configuration cannot switch to another credential route', () => { + const env = { ACCOUNT_B: 'fixture-b' }, hint = { alias: 'route-b', effort: 'high' }; + for (const provider of [ + { type: 'kimi', base_url: 'https://api.example/v1', oauth: 'account-b' }, + { type: 'openai', base_url: 'https://user:secret@api.example/v1', api_key_env: 'ACCOUNT_B' }, + { type: 'openai', base_url: 'https://api.example/v1?key=secret', api_key_env: 'ACCOUNT_B' }, + { type: 'openai', base_url: 'https://api.example/v1', api_key_env: 'ACCOUNT_B', headers: { Authorization: 'fixture' } }, + { type: 'openai', base_url: 'https://api.example/v1', api_key_env: 'ACCOUNT_B', api_key: 'other-fixture' }, + ]) { + const cfg = config(); cfg.providers['account-b'] = provider; + assert.equal(headlessKimi.config.select(cfg, hint, env), null); + } + assert.equal(headlessKimi.config.select(config(), hint, {}), null); + assert.equal(headlessKimi.config.select(config(), { alias: 'route-b' }, env), null); +}); + +test('private completion requires native provider-finish, exact route and zero tool evidence', () => { + const records = [ + { type: 'metadata', protocol_version: '1.5' }, + { type: 'turn.prompt', agentId: 'main', turnId: 0, promptId: 'p', origin: { kind: 'user' } }, + { type: 'llm.tools_snapshot', agentId: 'main', tools: [] }, + { type: 'llm.request', agentId: 'main', modelAlias: 'route-b', model: 'same-model', provider: 'openai', thinkingEffort: 'high', turnStep: '0.1' }, + { type: 'context.append_loop_event', agentId: 'main', event: { type: 'step.end', uuid: 'last', turnId: '0', finishReason: 'end_turn', providerFinishReason: 'completed', usage: { inputOther: 8, inputCacheRead: 2, inputCacheCreation: 1, output: 4 } } }, + { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }, + ]; + const facts = headlessKimi.facts.decode(records); + assert.deepEqual(facts, { reason: 'completed', error: false, providerFinish: 'completed', model: 'same-model', alias: 'route-b', protocol: 'openai', thinkingEffort: 'high', toolSnapshots: [0], toolCalls: 0, usage: { inputTokens: 11, outputTokens: 4 } }); + const missingFinish = structuredClone(records); delete missingFinish[4].event.providerFinishReason; + assert.equal(headlessKimi.facts.decode(missingFinish).providerFinish, null); + const secondPrompt = [...records, { ...records[1], turnId: 1, promptId: 'another' }]; + assert.equal(headlessKimi.facts.decode(secondPrompt).error, true); + assert.equal(headlessKimi.facts.decode(records.slice(1)).error, true); +}); diff --git a/test/hosts/kimi/packed-hooks.test.js b/test/hosts/kimi/packed-hooks.test.js new file mode 100644 index 0000000..2d88ee0 --- /dev/null +++ b/test/hosts/kimi/packed-hooks.test.js @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, join } from 'node:path'; +import { test } from 'node:test'; + +import { endTree } from '../../../lib/runtime/process/spawn.js'; +import { pack } from '../../../scripts/pack.mjs'; +import { PROMPT } from '../../app/memory-ports.js'; +import { closeProxyWhenSettled, fakeProxy } from '../../helpers/fake-proxy.js'; + +async function run(command, args, { env, cwd, input = '', shell = false, timeoutMs }) { + const child = spawn(command, args, { env, cwd, shell, detached: process.platform !== 'win32', windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = '', stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); child.stderr.on('data', chunk => { stderr += chunk; }); + child.stdin.on('error', () => {}); child.stdin.end(input); + return new Promise((resolve, reject) => { + const timer = timeoutMs ? setTimeout(() => { + if (child.pid) endTree(child.pid); + reject(new Error(command + ' exceeded its native hook budget')); + }, timeoutMs) : undefined; + child.once('error', error => { clearTimeout(timer); reject(error); }); + child.once('close', code => { clearTimeout(timer); resolve({ code, stdout, stderr }); }); + }); +} + +async function until(check) { + const deadline = Date.now() + 20_000; + for (;;) { + const result = check(); if (result) return result; + if (Date.now() >= deadline) throw new Error('Packed Kimi hook work did not settle'); + await new Promise(resolve => setTimeout(resolve, 50)); + } +} + +async function fixture(t) { + const proxy = await fakeProxy(); + let teardown = async () => {}; + const plugin = mkdtempSync(join(tmpdir(), "evolver Kimi user's gói ")); + t.after(async () => { + try { await teardown(); } + finally { + rmSync(plugin, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + await closeProxyWhenSettled(proxy); + } + }); + pack('kimi', plugin); + const home = join(proxy.env.HOME, 'native'), project = join(proxy.env.HOME, "project user's tiếng Việt"); + const agent = join(home, 'sessions', 'workspace_fixture', 'session-fixture', 'agents', 'main'), state = join(home, 'evolver'); + mkdirSync(agent, { recursive: true }); mkdirSync(project); + writeFileSync(join(home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { workspace_fixture: { root: project, name: 'fixture', created_at: 1, last_opened_at: 1 } }, deleted_workspace_ids: [] })); + const config = 'default_model = "route-a"\n[providers.account-a]\ntype = "openai"\nbase_url = "https://fixture.invalid/v1"\napi_key = "fixture-not-a-real-key"\n[models.route-a]\nprovider = "account-a"\nmodel = "fixture-model"\nmax_context_size = 32768\ncapabilities = ["tool_use"]\n'; + writeFileSync(join(home, 'config.toml'), config); + const wire = join(agent, 'wire.jsonl'); + const append = (...records) => appendFileSync(wire, records.map(record => JSON.stringify(record) + '\n').join('')); + append({ type: 'metadata', protocol_version: '1.5' }, { type: 'profile.bind', agentId: 'main', modelAlias: 'route-a', thinkingEffort: 'off' }); + const system = Object.fromEntries(['SystemRoot', 'WINDIR', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP'].filter(key => process.env[key]).map(key => [key, process.env[key]])); + const env = { ...system, HOME: proxy.env.HOME, USERPROFILE: proxy.env.HOME, KIMI_CODE_HOME: home, EVOMAP_PROXY_PORT: proxy.env.EVOMAP_PROXY_PORT, EVOLVER_AUTO_DISTILL: 'off', PATH: [dirname(process.execPath), process.env.PATH].join(delimiter) }; + const manifest = JSON.parse(readFileSync(join(plugin, 'kimi.plugin.json'), 'utf8')); + const base = { session_id: 'session-fixture', cwd: project, client_type: 'kimi_code_cli', is_steer: false }; + const hook = async (event, extra = {}, overrideEnv = {}) => { + const handler = manifest.hooks.find(item => item.event === event); assert.ok(handler); + const started = Date.now(); + // Kimi executes each plugin hook with the plugin root as cwd, while payload.cwd names the user's project. + const result = await run(handler.command, [], { env: { ...env, ...overrideEnv }, cwd: plugin, shell: true, timeoutMs: handler.timeout * 1000, input: JSON.stringify({ ...base, hook_event_name: event, ...extra }) }); + assert.equal(result.code, 0, result.stderr); + assert.ok(Date.now() - started < handler.timeout * 1000, `${event} exceeded its native hook budget`); + return JSON.parse(result.stdout); + }; + const command = (...args) => run(process.execPath, [join(plugin, 'bin', 'command.mjs'), 'kimi', ...args], { env, cwd: project }); + const read = (name, fallback = {}) => existsSync(join(state, name)) ? JSON.parse(readFileSync(join(state, name), 'utf8')) : fallback; + teardown = async () => { + append({ type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'cancelled' }); + await hook('SessionEnd'); + // Delivery acknowledgement precedes lock release; owned detached workers use this HOME as their cwd. + await until(() => !existsSync(join(state, 'kimi-outbox.lock')) + && (!existsSync(join(state, 'native-monitors')) || readdirSync(join(state, 'native-monitors')).length === 0)); + }; + return { proxy, plugin, home, project, state, wire, append, hook, command, read, config }; +} + +test('packed Desktop prompt hooks return Kimi message context, ignore unsupported clients and retain terminal preferences', async t => { + const f = await fixture(t); + assert.deepEqual(await f.hook('SessionStart'), {}); + const ignored = f.proxy.requests.length; + assert.deepEqual(await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }], client_type: 'kimi_code_vscode' }), {}); + assert.deepEqual(await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }], is_steer: true }), {}); + assert.deepEqual(await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }, { EVOLVER_HEADLESS: '1' }), {}); + assert.equal(f.proxy.requests.length, ignored); + const answer = await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }], client_type: 'kimi_code_desktop' }); + assert.match(answer.message, /maxRetriesPerRequest/); + assert.equal(answer.decision, undefined, 'v1 never intercepts or blocks the native prompt'); + assert.equal(answer.hookSpecificOutput, undefined, 'Kimi consumes its own message response schema'); + f.append({ type: 'turn.prompt', agentId: 'main', turnId: 0, promptId: 'first-prompt', origin: { kind: 'user' }, input: [{ type: 'text', text: PROMPT }] }); + await f.hook('TurnStarted', { turn_id: 0, prompt_id: 'first-prompt' }); + f.append({ type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'cancelled' }); + await f.hook('Notification'); + const off = await f.command('evolver-status', 'recall', 'off'); + assert.equal(off.code, 0, off.stderr); assert.match(off.stdout, /off/); + assert.equal(f.read('kimi-settings.json').recall, false); + const recalls = f.proxy.requests.filter(item => item.path === '/asset/fetch').length; + assert.deepEqual(await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] }), {}); + assert.equal(f.proxy.requests.filter(item => item.path === '/asset/fetch').length, recalls); + f.append({ type: 'turn.prompt', agentId: 'main', turnId: 1, promptId: 'second-prompt', origin: { kind: 'user' }, input: [{ type: 'text', text: PROMPT }] }); + await f.hook('TurnStarted', { turn_id: 1, prompt_id: 'second-prompt' }); + f.append({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'cancelled' }); + await f.hook('Notification'); + assert.equal(readFileSync(join(f.home, 'config.toml'), 'utf8'), f.config); + assert.equal(f.proxy.requests.some(item => item.path === '/asset/submit'), false); +}); + +test('packed observers wait for native terminal evidence before confirming turn zero or queuing reuse', async t => { + const f = await fixture(t); + await f.hook('SessionStart'); + assert.match((await f.hook('UserPromptSubmit', { prompt: [{ type: 'text', text: PROMPT }] })).message, /maxRetriesPerRequest/); + f.append({ type: 'turn.prompt', agentId: 'main', turnId: 0, promptId: 'prompt-zero', origin: { kind: 'user' }, input: [{ type: 'text', text: PROMPT }] }); + await f.hook('TurnStarted', { turn_id: 0, prompt_id: 'prompt-zero' }); + const file = join(f.project, 'fixture.js'); writeFileSync(file, 'export const retries = 1;\n'); + const edit = { tool_call_id: 'edit-call', tool_name: 'Edit', tool_input: { path: file } }; + await f.hook('PreToolUse', edit); + writeFileSync(file, 'export const retries = null;\n'); + f.append( + { type: 'context.append_loop_event', agentId: 'main', event: { type: 'tool.call', uuid: 'edit-uuid', toolCallId: 'edit-call', turnId: '0', name: 'Edit', args: { path: file } } }, + { type: 'context.append_loop_event', agentId: 'main', event: { type: 'tool.result', parentUuid: 'edit-uuid', toolCallId: 'edit-call', result: { output: 'Applied edit', durationMs: 1, isError: false } } }, + { type: 'context.append_loop_event', agentId: 'main', event: { type: 'tool.call', uuid: 'check-uuid', toolCallId: 'check-call', turnId: '0', name: 'Bash', args: { command: 'node --test' } } }, + { type: 'context.append_loop_event', agentId: 'main', event: { type: 'tool.result', parentUuid: 'check-uuid', toolCallId: 'check-call', result: { output: '# pass 1\n# fail 0', durationMs: 1, isError: false } } }, + ); + assert.deepEqual(await f.hook('PostToolUse', { tool_call_id: 'check-call', tool_name: 'Bash', tool_input: { command: 'node --test' }, tool_output: 'truncated observer text' }), {}); + assert.deepEqual(await f.hook('Stop'), {}); + assert.deepEqual(f.read('kimi-distill-pending.json'), {}); + assert.equal(f.proxy.requests.some(item => item.path === '/asset/reuse-result'), false); + f.append( + { type: 'llm.request', agentId: 'main', modelAlias: 'route-a', model: 'fixture-model', provider: 'openai', thinkingEffort: 'off' }, + { type: 'context.append_loop_event', agentId: 'main', event: { type: 'content.part', uuid: 'answer-part', stepUuid: 'final-step', turnId: '0', part: { type: 'text', text: 'The repair passes the check.' } } }, + { type: 'context.append_loop_event', agentId: 'main', event: { type: 'step.end', uuid: 'final-step', turnId: '0', finishReason: 'end_turn', providerFinishReason: 'completed' } }, + { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }, + ); + await f.hook('Notification'); + const candidate = await until(() => Object.values(f.read('kimi-distill-pending.json'))[0]); + assert.equal(candidate.turn, 1, 'native turn zero is the first internal turn'); assert.equal(candidate.model.alias, 'route-a'); + assert.equal(candidate.model.model, 'fixture-model'); + assert.equal(JSON.stringify(candidate).includes('fixture-not-a-real-key'), false); + await until(() => f.proxy.requests.find(item => item.path === '/asset/reuse-result')); + await f.hook('Notification'); await f.hook('SessionEnd'); + assert.equal(Object.keys(f.read('kimi-distill-pending.json')).length, 1, 'manual distillation stays available with automatic completion off'); + assert.equal(f.proxy.requests.filter(item => item.path === '/asset/reuse-result').length, 1); + assert.equal(f.proxy.requests.some(item => item.path === '/conversation/distill' || item.path === '/asset/submit'), false); +}); diff --git a/test/hosts/kimi/profile.test.js b/test/hosts/kimi/profile.test.js new file mode 100644 index 0000000..3fd15f0 --- /dev/null +++ b/test/hosts/kimi/profile.test.js @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import kimi from '../../../hosts/kimi/profile.js'; +import { EVENTS } from '../../../lib/agent/events.js'; +import { ownerProbeOf } from '../../../lib/runtime/process/owner.js'; + +const payload = { session_id: 'session-fixture', cwd: join('D:', 'fixture'), client_type: 'kimi_code_cli', hook_event_name: 'UserPromptSubmit', prompt: [{ type: 'text', text: ' Fix retry. ' }, { type: 'image', image_url: 'untrusted' }, { type: 'text', text: 'Run tests.' }], is_steer: false }; + +test('Windows Desktop hooks use the Kimi session contract and require a Kimi ancestor', () => { + const desktop = { ...payload, client_type: 'kimi_code_desktop' }; + assert.equal(kimi.payload.isIgnored(desktop), false); + assert.equal(kimi.payload.isIgnored({ ...desktop, is_steer: true }), true); + assert.equal(kimi.payload.isIgnored({ ...desktop, client_type: 'kimi_code_vscode' }), true); + const owner = (name) => ownerProbeOf(kimi.owner, { + probe: (pid) => pid === process.ppid ? { name: 'cmd.exe', ppid: 4242 } + : pid === 4242 ? { name, ppid: 1 } : null, + }).current(); + assert.equal(owner('Kimi Code.exe'), 4242); + assert.equal(owner('kimi.exe'), 4242); + assert.equal(owner('Other Kimi Code.exe'), null); +}); + +test('Kimi uses its native prompt message contract and observer events cannot inject context', () => { + assert.equal(kimi.payload.prompt(payload), 'Fix retry. \nRun tests.'); + assert.deepEqual(kimi.reply.context('Recall text', 'UserPromptSubmit'), { message: 'Recall text' }); + assert.deepEqual(kimi.reply.context('Ignored', 'PostToolUse'), {}); + assert.deepEqual(kimi.reply.notice('Later notice', 'SessionStart'), {}); + assert.equal(kimi.reply.toolResultContext, false); + assert.equal(kimi.commands.entry, 'cli-only'); + for (const event of ['TurnStarted', 'Stop', 'StopFailure', 'Interrupt', 'TaskStarted', 'Notification']) assert.equal(kimi.events[event], EVENTS.OBSERVE); +}); + +test('native profile scope and turn zero survive identity normalization; steer and foreign agents are ignored', () => { + const home = join('fixture', 'profile-a'); + const a = kimi.payload.sessionId(payload, { env: { KIMI_CODE_HOME: home }, nativeScope: 'a'.repeat(64) }); + const b = kimi.payload.sessionId(payload, { env: { KIMI_CODE_HOME: join('fixture', 'profile-b') }, nativeScope: 'b'.repeat(64) }); + assert.notEqual(a, b); + assert.equal(a.includes(home), false); + assert.equal(kimi.payload.sessionId(payload, { env: { KIMI_CODE_HOME: home } }), null); + assert.equal(kimi.payload.sessionId(payload, { nativeScope: home }), null); + assert.equal(kimi.payload.sessionId({ ...payload, session_id: 'x'.repeat(129) }, { nativeScope: 'a'.repeat(64) }), null); + assert.equal(kimi.payload.turnId({ ...payload, hook_event_name: 'TurnStarted', turn_id: 0 }), '0'); + assert.equal(kimi.payload.turnId({ ...payload, turn_id: 9 }), null); + assert.equal(kimi.payload.turnId({ ...payload, hook_event_name: 'PostToolUse' }, { nativeFacts: { toolTurnId: '0' } }), '0'); + assert.equal(kimi.payload.isIgnored(payload), false); + for (const changed of [{ is_steer: true }, { agent_id: 'helper' }, { origin_kind: 'cron' }, { client_type: 'unknown' }]) assert.equal(kimi.payload.isIgnored({ ...payload, ...changed }), true); + assert.equal(kimi.payload.sessionId({ session_id: '../outside' }), null); +}); + +test('global default model and truncated hook output are never candidate authority', () => { + const facts = { status: 'ok', schemaSupported: true, bind: { turnId: '0' }, modelHint: { alias: 'route-b', effort: 'off' }, terminal: { turnId: '0', reason: 'failed', answer: '' }, record: { checks: [{ command: 'npm test', failed: true }] }, toolTurnId: '0' }; + assert.equal(kimi.payload.model({ ...payload, model: 'default-a' }), null); + assert.deepEqual(kimi.payload.model(payload, { nativeFacts: facts }), facts.modelHint); + assert.equal(kimi.evidence.toolNote({ ...payload, tool_output: 'tests passed' }), null); + assert.equal(kimi.turnEnd.reason(payload), 'unknown'); + assert.equal(kimi.turnEnd.reason(payload, { nativeFacts: facts }), 'error'); + assert.deepEqual(kimi.evidence.turnRecord(payload, { nativeFacts: facts }), facts.record); + assert.equal(kimi.evidence.turnRecord(payload, { nativeFacts: { ...facts, status: 'partial' } }), null); +}); diff --git a/test/hosts/kimi/readers.test.js b/test/hosts/kimi/readers.test.js new file mode 100644 index 0000000..f8ead58 --- /dev/null +++ b/test/hosts/kimi/readers.test.js @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { decodeNativeFacts, taskIdsOf } from '../../../hosts/kimi/transcript.js'; + +const meta = { type: 'metadata', protocol_version: '1.5' }; +const profile = { type: 'profile.bind', agentId: 'main', modelAlias: 'custom/model-b', thinkingEffort: 'off' }; +const prompt = (id, promptId = `prompt-${id}`) => ({ type: 'turn.prompt', agentId: 'main', turnId: id, promptId, origin: { kind: 'user' }, input: [{ type: 'text', text: 'same prompt' }] }); +const event = value => ({ type: 'context.append_loop_event', agentId: 'main', event: value }); + +test('Kimi native turn zero and the active binding are distinct from a global default model', () => { + const p = { ...prompt(0), $offset: 128 }; + const facts = decodeNativeFacts([meta, profile, p], [], { hook_event_name: 'TurnStarted', turn_id: 0 }); + assert.equal(facts.schemaSupported, true); + assert.deepEqual(facts.bind, { turnId: '0', promptId: 'prompt-0', origin: 'user', mark: 128 }); + assert.deepEqual(facts.modelHint, { alias: 'custom/model-b', effort: 'off' }); + assert.equal(facts.terminal, null); +}); + +test('a matched native terminal settles a silent Bash check and only the final model text is its answer', () => { + const records = [meta, profile, prompt(0), + { type: 'llm.request', agentId: 'main', modelAlias: 'custom/model-b', model: 'upstream-b', provider: 'openai', turnStep: '0.1' }, + event({ type: 'tool.call', uuid: 'call-uuid', turnId: '0', toolCallId: 'call-test', name: 'Bash', args: { command: 'npm test' } }), + event({ type: 'tool.result', parentUuid: 'call-uuid', toolCallId: 'call-test', result: { output: '', durationMs: 1 } }), + event({ type: 'content.part', uuid: 'part-answer', turnId: '0', stepUuid: 'final-step', part: { type: 'text', text: 'Fixed the retry; tests pass.' } }), + event({ type: 'step.end', uuid: 'final-step', turnId: '0', finishReason: 'end_turn', providerFinishReason: 'completed' }), + { type: 'agent.message.appended', message: { message: { role: 'assistant', content: [{ type: 'text', text: 'unrelated hook notice' }] } } }, + { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed' }, + ]; + const facts = decodeNativeFacts(records, [], { hook_event_name: 'Stop' }, { turnId: '0' }); + assert.deepEqual(facts.record.checks, [{ command: 'npm test', failed: false }]); + assert.equal(facts.record.answer, 'Fixed the retry; tests pass.'); + assert.deepEqual(facts.terminal, { turnId: '0', reason: 'completed', answer: 'Fixed the retry; tests pass.', modelHint: { alias: 'custom/model-b', effort: null, model: 'upstream-b', protocol: 'openai' } }); +}); + +test('a background Bash is settled by its task metadata, never by successful TaskOutput retrieval', () => { + const records = [meta, profile, prompt(0), + event({ type: 'tool.call', uuid: 'bg-uuid', turnId: '0', toolCallId: 'bg-call', name: 'Bash', args: { command: 'npm test', run_in_background: true } }), + event({ type: 'tool.result', parentUuid: 'bg-uuid', toolCallId: 'bg-call', result: { output: 'task_id: bash-task\nstatus: running', durationMs: 1 } }), + event({ type: 'tool.call', uuid: 'output-uuid', turnId: '0', toolCallId: 'output-call', name: 'TaskOutput', args: { task_id: 'bash-task' } }), + event({ type: 'tool.result', parentUuid: 'output-uuid', toolCallId: 'output-call', result: { output: 'retrieval_status: success\nstatus: failed', durationMs: 1 } }), + ]; + const task = { taskId: 'bash-task', kind: 'process', parentToolCallId: 'bg-call', command: 'npm test', status: 'failed', exitCode: 7 }; + assert.deepEqual(decodeNativeFacts(records, [task], {}, { turnId: '0' }).record.checks, [{ command: 'npm test', failed: true }]); + assert.deepEqual(decodeNativeFacts(records, [{ ...task, parentToolCallId: 'another-turn' }], {}, { turnId: '0' }).record.checks, [{ command: 'npm test', failed: null }]); +}); + +test('late observer events cannot guess the latest of two identical prompts', () => { + const records = [meta, profile, { ...prompt(0), $offset: 100 }, { ...prompt(1), $offset: 200 }]; + assert.equal(decodeNativeFacts(records, [], { hook_event_name: 'Stop' }).bind, null); + assert.equal(decodeNativeFacts(records, [], { hook_event_name: 'Stop' }, { after: { offset: 100 } }).bind, null); + assert.equal(decodeNativeFacts(records, [], {}, { after: { offset: 200 } }).bind.turnId, '1'); + assert.equal(decodeNativeFacts(records, [], {}, { turnId: '0' }).bind.turnId, '0'); + assert.equal(decodeNativeFacts(records, [], { hook_event_name: 'UserPromptSubmit' }).bind, null); +}); + +test('unsupported schemas and foreign agents do not supply evidence', () => { + assert.equal(decodeNativeFacts([{ ...meta, protocol_version: '9.0' }, prompt(0)], [], { turn_id: 0 }).status, 'unsupported'); + assert.equal(decodeNativeFacts([meta, { ...prompt(0), agentId: 'helper' }], [], { turn_id: 0 }).bind, null); + const facts = decodeNativeFacts([prompt(0)], [], { turn_id: 0 }, { schemaVersion: '1.5', modelHint: { alias: 'cached', effort: 'off' } }); + assert.deepEqual(facts.modelHint, { alias: 'cached', effort: 'off' }); + assert.equal(facts.bind.turnId, '0'); +}); + +test('fixed task discovery rejects paths and does not inspect arbitrary output paths', () => { + const records = [event({ type: 'tool.call', name: 'TaskOutput', args: { task_id: 'bash-task' } }), event({ type: 'tool.result', result: { output: 'task_id: bash-task\nstatus: running' } }), event({ type: 'tool.call', name: 'TaskOutput', args: { task_id: '../outside' } })]; + assert.deepEqual(taskIdsOf(records, { task_id: 'other-task' }), ['other-task', 'bash-task']); +}); + +test('duplicate native events do not duplicate checks and mismatched results remain unknown', () => { + const call = event({ type: 'tool.call', uuid: 'id', turnId: '0', toolCallId: 'test', name: 'Bash', args: { command: 'npm test' } }); + const result = event({ type: 'tool.result', parentUuid: 'id', toolCallId: 'test', result: { output: '', durationMs: 1 } }); + assert.deepEqual(decodeNativeFacts([meta, prompt(0), call, call, result, result], [], {}, { turnId: '0' }).record.checks, [{ command: 'npm test', failed: false }]); + assert.deepEqual(decodeNativeFacts([meta, prompt(0), call, { ...result, event: { ...result.event, parentUuid: 'another-turn' } }], [], {}, { turnId: '0' }).record.checks, [{ command: 'npm test', failed: null }]); +}); + +test('conflicting call identities and completed terminals containing an error cannot verify a turn', () => { + const call = event({ type: 'tool.call', uuid: 'id', turnId: '0', toolCallId: 'test', name: 'Bash', args: { command: 'npm test' } }); + const result = event({ type: 'tool.result', parentUuid: 'id', toolCallId: 'test', result: { output: '', durationMs: 1 } }); + const facts = decodeNativeFacts([meta, prompt(0), call, { ...call, event: { ...call.event, args: { command: 'npm run build' } } }, result, { type: 'turn.ended', agentId: 'main', turnId: 0, reason: 'completed', error: { code: 'provider.api_error' } }], [], {}, { turnId: '0' }); + assert.equal(facts.status, 'partial'); + assert.equal(facts.terminal, null); + assert.equal(facts.record, null); +}); + +test('a later profile binding and a truncated continuation cannot become an earlier turn final answer', () => { + const records = [meta, profile, prompt(0), + event({ type: 'content.part', uuid: 'earlier', turnId: '0', stepUuid: 'first-step', part: { type: 'text', text: 'Earlier answer' } }), + event({ type: 'step.end', uuid: 'first-step', turnId: '0', finishReason: 'end_turn', providerFinishReason: 'completed' }), + event({ type: 'step.end', uuid: 'last-step', turnId: '0', finishReason: 'max_tokens', providerFinishReason: 'truncated' }), + { ...profile, modelAlias: 'later-route' }, prompt(1), + ]; + const facts = decodeNativeFacts(records, [], {}, { turnId: '0' }); + assert.deepEqual(facts.modelHint, { alias: 'custom/model-b', effort: 'off' }); + assert.equal(facts.record.answer, ''); +}); diff --git a/test/hosts/opencode/profile.test.js b/test/hosts/opencode/profile.test.js index c61a144..4e471ab 100644 --- a/test/hosts/opencode/profile.test.js +++ b/test/hosts/opencode/profile.test.js @@ -6,6 +6,7 @@ import profile from '../../../hosts/opencode/profile.js'; import { pluginEnv } from '../../../hosts/opencode/dist/options.js'; import { createTurnTracker } from '../../../hosts/opencode/dist/turns.js'; import { turnRecordOf } from '../../../lib/turn/record.js'; +import { skipReason } from '../../../lib/distill/candidate.js'; const FIXTURE = JSON.parse(readFileSync(new URL('../../fixtures/opencode/payloads.json', import.meta.url), 'utf8')); const { bash, args, assistant, errors, sessions } = FIXTURE; @@ -31,12 +32,23 @@ test('a bash check passes or fails on its exit code, and on failure output a `| }); test('a check the person interrupted gives no verdict, while one its timeout killed failed', () => { - assert.deepEqual(checksOf(bash.interrupted), []); + assert.deepEqual(checksOf(bash.interrupted), [{ command: 'npm test', failed: null }]); assert.deepEqual(checksOf(bash.timedOut), [{ command: 'npm test', failed: true }]); const record = turnRecordOf([note({ tool: 'bash', args: { command: 'npm test' }, result: bash.interrupted })]); assert.deepEqual(record.commands, ['npm test']); }); +test('an interrupted check after a pass remains last and prevents a candidate', () => { + const record = turnRecordOf([ + { kind: 'edit', paths: ['src/retry.js'] }, + note({ tool: 'bash', args: { command: 'npm test' }, result: bash.passed }), + note({ tool: 'bash', args: { command: 'npm test' }, result: bash.interrupted }), + { kind: 'answer', text: 'Done.' }, + ]); + assert.deepEqual(record.checks.map(check => check.failed), [false, null]); + assert.equal(skipReason('answer', record), 'unverified'); +}); + test('a command that is no check keeps its exit code but not its output', () => { assert.deepEqual(note({ tool: 'bash', args: { command: 'ls' }, result: bash.listing }), { kind: 'shell', command: 'ls', exit: 0 }); }); diff --git a/test/pack-vendor.test.js b/test/pack-vendor.test.js new file mode 100644 index 0000000..fffa458 --- /dev/null +++ b/test/pack-vendor.test.js @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { test } from 'node:test'; + +import { pack } from '../scripts/pack.mjs'; + +function filesOf(root, dir = root) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const path = join(dir, entry.name); + return entry.isDirectory() ? filesOf(root, path) : [relative(root, path)]; + }).sort(); +} + +test('packing deterministically vendors only the pinned TOML ESM closure and its license', t => { + const root = mkdtempSync(join(tmpdir(), 'evolver-pack-vendor-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const left = join(root, 'left'), right = join(root, 'right'); + const files = pack('kimi', left, { version: '9.8.7' }); + pack('kimi', right, { version: '9.8.7' }); + const vendor = join(left, 'lib', 'vendor', 'smol-toml'); + assert.deepEqual(JSON.parse(readFileSync(join(vendor, 'package.json'), 'utf8')), { + name: 'smol-toml', version: '1.9.1', type: 'module', license: 'BSD-3-Clause', + }); + assert.equal(readFileSync(join(vendor, 'LICENSE'), 'utf8'), readFileSync(new URL('../node_modules/smol-toml/LICENSE', import.meta.url), 'utf8')); + assert.ok(files.includes('lib/vendor/smol-toml/dist/index.js')); + assert.ok(files.every(file => !file.startsWith('node_modules/'))); + assert.equal(existsSync(join(left, 'node_modules')), false); + assert.equal(existsSync(join(vendor, 'dist', 'index.cjs')), false); + assert.equal(existsSync(join(vendor, 'README.md')), false); + const config = readFileSync(join(left, 'lib', 'runtime', 'native-config.js'), 'utf8'); + assert.match(config, /from ["']\.\.\/vendor\/smol-toml\/dist\/index\.js["']/); + assert.doesNotMatch(config, /from ["']smol-toml["']/); + const snapshot = dir => filesOf(dir).map(file => [file, createHash('sha256').update(readFileSync(join(dir, file))).digest('hex')]); + assert.deepEqual(snapshot(left), snapshot(right)); +}); diff --git a/test/packed-layout.test.js b/test/packed-layout.test.js index d0b9bb7..58eaa40 100644 --- a/test/packed-layout.test.js +++ b/test/packed-layout.test.js @@ -6,7 +6,8 @@ import { test } from 'node:test'; import { pack, packedHosts, packSpecOf } from '../scripts/pack.mjs'; -// What `evolver plugin install` checks in a packed folder (evolver-cli pluginBundle.ts and pluginInspection.ts): +// Folder checks used by installers (evolver-cli pluginBundle.ts/pluginInspection.ts for its hosts, +// and scripts/install-kimi.mjs for Kimi; this does not imply external CLI support for Kimi): // the folder names its host in .evolver-packed, its manifest names this plugin, its version and this repository, // and its entry files sit where the host and the installer look for them. const CONTRACT = { @@ -14,6 +15,7 @@ const CONTRACT = { codex: { manifest: '.codex-plugin/plugin.json', name: 'evolver-plugin', entries: ['hooks/hooks.json', 'bin/hook.mjs'] }, grok: { manifest: '.grok-plugin/plugin.json', name: 'evolver-plugin', entries: ['hooks/hooks.json', 'bin/hook.mjs'] }, qoder: { manifest: '.qoder-plugin/plugin.json', name: 'evolver-plugin', entries: ['hooks/hooks.json', 'bin/hook.mjs'] }, + kimi: { manifest: 'kimi.plugin.json', name: 'evolver-plugin', entries: ['bin/hook.mjs', 'bin/worker.mjs', 'bin/command.mjs'] }, antigravity: { manifest: 'plugin.json', name: 'evolver-plugin', entries: ['hooks.json', 'bin/hook.mjs'] }, opencode: { manifest: 'package.json', name: '@evomap/evolver-opencode', entries: ['server.mjs', 'tui.mjs'], npm: true }, dsh: { manifest: 'package.json', name: '@evomap/dsh-evolver', entries: ['plugin.mjs'], npm: true }, @@ -23,12 +25,12 @@ const REPOSITORY = /^(?:git\+)?https:\/\/github\.com\/EvoMap\/evolver-agent-plug const repositoryOf = (manifest) => (typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url); const isShipped = (files, path) => files.some((entry) => path === entry || path.startsWith(entry.endsWith('/') ? entry : `${entry}/`)); -test('every packed host is in the installer contract, and every host in it is packed', () => { +test('every packed host is in a local installer contract, and every host in it is packed', () => { assert.deepEqual(packedHosts().filter((host) => host !== 'workbuddy').sort(), Object.keys(CONTRACT).sort()); }); for (const [host, contract] of Object.entries(CONTRACT)) { - test(`${host}: the packed folder passes the checks evolver plugin install makes`, (t) => { + test(`${host}: the packed folder passes its installer layout checks`, (t) => { const out = mkdtempSync(join(tmpdir(), `evolver-layout-${host}-`)); t.after(() => rmSync(out, { recursive: true, force: true })); const folder = join(out, host); diff --git a/test/recipe.test.js b/test/recipe.test.js index bd468c4..18e8909 100644 --- a/test/recipe.test.js +++ b/test/recipe.test.js @@ -168,3 +168,20 @@ test('a Recipe whose every step cannot keep even its opening line is passed over assert.deepEqual(match.ids, ['sha256:gene']); assert.deepEqual(traces[0], { outcome: 'passed_over', kind: 'recipe', id: 'rcp_long', reason: 'too_long' }); }); + +test('a mixed Recipe preserves Capsule evidence in order without exposing its execution trace', async () => { + const capsule = { + type: 'Capsule', asset_id: 'sha256:retry', gene: 'sha256:linked', + summary: 'A flaky upload recovered after bounded retries.', + content: 'Raw case content to keep out of the prompt.', + execution_trace: ['Raw trace to keep out of the prompt.'], + }; + const { proxyFetch } = proxy({ stepAssets: [capsule, STEP_ASSETS[1]] }); + const match = await recalled(proxyFetch); + assert.deepEqual(match.ids, ['sha256:retry', 'sha256:alert']); + assert.match(match.text, /Step 1[^\n]*\n1\. Observed case: A flaky upload recovered after bounded retries\.[\s\S]*Step 2 — Alert/); + assert.doesNotMatch(match.text, /Raw case content|Raw trace/); + assert.match(match.stepTexts['sha256:retry'], /Observed case:/); + const incomplete = proxy({ stepAssets: [{ ...capsule, summary: '' }, STEP_ASSETS[1]] }); + assert.deepEqual((await recalled(incomplete.proxyFetch)).ids, ['sha256:gene']); +}); diff --git a/test/release.test.js b/test/release.test.js index 472cd42..605c708 100644 --- a/test/release.test.js +++ b/test/release.test.js @@ -11,7 +11,7 @@ import { pack, packedHosts, packSpecOf } from '../scripts/pack.mjs'; const PUBLISH = fileURLToPath(new URL('../scripts/publish-tag.sh', import.meta.url)); const workflow = readFileSync(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8'); const posix = { skip: process.platform === 'win32' && 'bash script' }; -const MANIFEST = /(?:^|\/)(?:plugin|marketplace|package)\.json$/; +const MANIFEST = /(?:^|\/)(?:kimi\.plugin|plugin|marketplace|package)\.json$/; function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'evolver-release-')); @@ -32,9 +32,14 @@ test('packing with a version stamps it into every manifest a packed host reads', test('only a pushed vX.Y.Z tag publishes, after the tests: pack at its version, publish, then the GitHub Release', () => { assert.match(workflow, /tags: \['v\*'\]/); - assert.match(workflow, /needs: test\n\s+if: startsWith\(github\.ref, 'refs\/tags\/v'\)/); + assert.match(workflow, /needs: \[test, windows-kimi\]\n\s+if: startsWith\(github\.ref, 'refs\/tags\/v'\)/); + const kimi = workflow.slice(workflow.indexOf(' windows-kimi:'), workflow.indexOf(' publish:')); + assert.match(kimi, /runs-on: windows-latest/); + assert.match(kimi, /node: \[22, 26\]/); + assert.match(kimi, /run: npm run test:kimi/); + assert.match(kimi, /run: npm run pack/); const publish = workflow.slice(workflow.indexOf(' publish:')); - const steps = ['--version=$GITHUB_REF_NAME', 'publish-tag.sh "$GITHUB_REF_NAME" release', 'gh release create'].map((step) => publish.indexOf(step)); + const steps = ['run: npm ci', '--version=$GITHUB_REF_NAME', 'publish-tag.sh "$GITHUB_REF_NAME" release', 'gh release create'].map((step) => publish.indexOf(step)); assert.ok(steps.every((at, index) => at > 0 && (index === 0 || at > steps[index - 1])), steps.join(',')); }); diff --git a/test/runtime/acp-model.test.js b/test/runtime/acp-model.test.js new file mode 100644 index 0000000..81d6c4f --- /dev/null +++ b/test/runtime/acp-model.test.js @@ -0,0 +1,388 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createConnection } from 'node:net'; + +import { modelRunnerOf } from '../../lib/runtime/model-executor.js'; +import { declarationFor } from '../fixtures/acp/declaration.mjs'; +import { CHILD_FILE, OWNER_FILE } from '../../lib/runtime/process/acp-child.js'; +import { windowsJobExecutable } from '../../lib/runtime/process/windows-job.js'; + +const SECRET = 'fixture-private-api-key-NOT-A-REAL-CREDENTIAL'; + +function fixture(t, mode = '') { + const dir = mkdtempSync(join(tmpdir(), 'evolver-acp-test-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const config = join(dir, 'config.toml'); + writeFileSync(config, `[provider]\nendpoint = "https://example.invalid/v1"\nkey = "${SECRET}"\n`); + const declaration = declarationFor(config, join(dir, 'receipt.json'), mode); + const env = { ...process.env, AMBIENT_API_KEY: 'fixture-ambient-key-must-not-be-inherited' }; + return { dir, config, declaration, env, runner: modelRunnerOf(declaration, env) }; +} + +const pause = ms => new Promise(done => setTimeout(done, ms)); +async function until(predicate, ms = 10_000) { + const deadline = Date.now() + ms; + while (!predicate()) { + assert.ok(Date.now() < deadline, 'fixture condition reached its deadline'); + await pause(25); + } +} + +function nativeClosed(dir) { + const receipt = JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')); + assert.equal(existsSync(receipt.home), false, 'owned scratch has been removed'); + assert.throws(() => process.kill(receipt.pid, 0), 'owned native process has exited'); + if (receipt.descendantPid) assert.throws(() => process.kill(receipt.descendantPid, 0), 'owned native descendant has exited'); +} + +function promptStarted(dir) { + try { return JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')).promptStarted === true; } catch { return false; } +} + +test('an ACP route snapshot binds the selected configuration and credential without retaining the key', async t => { + const { runner, config } = fixture(t); + assert.ok(runner, 'the declaration supplies the model port'); + const selected = await runner.capture({ alias: 'route-a' }); + assert.equal(selected.alias, 'route-a'); + assert.equal(selected.model, 'fixture-model'); + assert.equal(selected.providerId, 'fixture'); + assert.equal(selected.protocol, 'openai'); + assert.match(selected.configRevision, /^[a-f0-9]{64}$/); + assert.match(selected.binding, /^[a-f0-9]{64}$/); + assert.equal(JSON.stringify(selected).includes(SECRET), false); + writeFileSync(config, readFileSync(config, 'utf8').replace(SECRET, 'different-fixture-key')); + const changed = await runner.capture({ alias: 'route-a' }); + assert.notEqual(changed.binding, selected.binding); +}); + +test('an ACP completion selects the captured route, uses a private credential environment and closes before cleanup', async t => { + const { runner, dir, declaration } = fixture(t); + const args = declaration.args(), extras = ['', 'path with spaces', 'trailing slash\\', 'embedded "quote"', 'đường dẫn']; + declaration.args = () => [...args, ...extras]; + const snapshot = await runner.capture({ alias: 'route-a' }); + const answer = await runner.complete(snapshot, 'the private candidate digest', AbortSignal.timeout(10_000)); + assert.equal(answer.answer, 'answer: the private candidate digest'); + const receipt = JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')); + assert.equal(receipt.hasPrivateCredential, true); + assert.equal(receipt.hasAmbientCredential, false); + assert.equal(receipt.configContainsCredential, false); + assert.deepEqual(receipt.argv, extras, 'native arguments survive shell-free quoting'); + assert.equal(receipt.argv.includes('the private candidate digest'), false); + assert.equal(existsSync(receipt.home), false, 'scratch is deleted after native close'); + assert.throws(() => process.kill(receipt.pid, 0), 'the owned native process has closed'); +}); + +test('a native root exiting normally cannot leave its stdio-ignored descendant alive', { skip: process.platform !== 'win32' }, async t => { + const { runner, dir } = fixture(t, 'exit-descendant'); + const file = join(dir, 'receipt.json'); + try { + const result = await runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', AbortSignal.timeout(10_000)); + assert.equal(result.answer, 'answer: candidate'); + const receipt = JSON.parse(readFileSync(file, 'utf8')); + assert.equal(receipt.descendantReady, true); + assert.throws(() => process.kill(receipt.descendantPid, 0), 'the descendant must be gone even after its parent exited first'); + assert.equal(existsSync(receipt.home), false); + } finally { + const receipt = existsSync(file) && JSON.parse(readFileSync(file, 'utf8')); + if (receipt?.descendantReady) { + // The nonce endpoint belongs to this fixture; cleanup never signals a possibly reused PID. + await new Promise(done => { + const child = createConnection(receipt.descendantPipe); + child.setTimeout(1000, () => child.destroy()); + child.once('connect', () => child.end(receipt.descendantNonce)); + child.on('error', () => {}); + child.once('close', done); + child.resume(); + }); + } + } +}); + +test('an ACP completion rejects an incomplete trailing frame before accepting native success', async t => { + const { runner, dir } = fixture(t, 'trailing-frame'); + const snapshot = await runner.capture({ alias: 'route-a' }); + await assert.rejects(runner.complete(snapshot, 'candidate', AbortSignal.timeout(10_000))); + const receipt = JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')); + assert.equal(existsSync(receipt.home), false); +}); + +test('an ACP completion rejects a late update belonging to another session', async t => { + const { runner } = fixture(t, 'late-session'); + const snapshot = await runner.capture({ alias: 'route-a' }); + await assert.rejects(runner.complete(snapshot, 'candidate', AbortSignal.timeout(10_000))); +}); + +test('an ACP completion rejects answer chunks sent after the prompt response', async t => { + const { runner } = fixture(t, 'late-chunk'); + await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', AbortSignal.timeout(10_000))); +}); + +test('a changed configuration or account binding blocks before any native request', async t => { + const { runner, dir, config, env } = fixture(t); + const original = readFileSync(config, 'utf8'); + const snapshot = runner.capture({ alias: 'route-a' }); + writeFileSync(config, original.replace('example.invalid', 'other.invalid')); + await assert.rejects(runner.complete(snapshot, 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); + assert.equal(existsSync(join(dir, 'receipt.json')), false); + writeFileSync(config, '[provider]\nendpoint="https://example.invalid/v1"\nkey_env="FIXTURE_ACCOUNT_KEY"\n'); + env.FIXTURE_ACCOUNT_KEY = SECRET; + const envSnapshot = runner.capture({ alias: 'route-a' }); + env.FIXTURE_ACCOUNT_KEY = 'a-different-fixture-account'; + await assert.rejects(runner.complete(envSnapshot, 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'route_changed' }); + assert.equal(existsSync(join(dir, 'receipt.json')), false); + env.FIXTURE_ACCOUNT_KEY = SECRET; + assert.equal((await runner.complete(envSnapshot, 'candidate', AbortSignal.timeout(10_000))).answer, 'answer: candidate'); + nativeClosed(dir); +}); + +test('invalid native config yields a typed sanitized preflight error', t => { + const { runner, config, dir } = fixture(t); + writeFileSync(config, `[provider]\nkey = "${SECRET}"\ninvalid = [\n`); + assert.throws(() => runner.capture({ alias: 'route-a' }), error => { + assert.equal(error.code, 'MODEL_UNAVAILABLE'); + assert.equal(error.message.includes(SECRET), false); + return true; + }); + writeFileSync(config, '#'.repeat(1024 * 1024 + 1)); + assert.throws(() => runner.capture({ alias: 'route-a' }), { code: 'MODEL_UNAVAILABLE' }); + assert.equal(existsSync(join(dir, 'receipt.json')), false); +}); + +test('orphan recovery cannot delete scratch without a child receipt proving both owned processes have exited', async t => { + const { runner, config, dir } = fixture(t); + const orphan = mkdtempSync(join(tmpdir(), 'evolver-model-')); + const live = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore', windowsHide: true }); + t.after(async () => { + const stopped = new Promise(done => live.once('close', done)); + live.kill('SIGKILL'); await stopped; + rmSync(orphan, { recursive: true, force: true }); + }); + const ownerPid = 2147483647; + assert.throws(() => process.kill(ownerPid, 0)); + writeFileSync(join(orphan, OWNER_FILE), JSON.stringify({ nonce: 'a'.repeat(32), ownerPid, origin: createHash('sha256').update(config).digest('hex') })); + await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'unsafe_runtime' }); + assert.equal(existsSync(orphan), true, 'missing receipt is not proof that no native process exists'); + assert.doesNotThrow(() => process.kill(live.pid, 0)); + assert.equal(existsSync(join(dir, 'receipt.json')), false, 'a fresh native process was not started'); + for (const content of ['{incomplete', JSON.stringify({ nonce: 'a'.repeat(32) }), JSON.stringify({ nonce: 'b'.repeat(32), guardianPid: ownerPid, nativePid: ownerPid })]) { + writeFileSync(join(orphan, CHILD_FILE), content); + await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'unsafe_runtime' }); + assert.equal(existsSync(orphan), true); + } +}); + +test('orphan recovery ignores a matching scratch marker with a nonpositive owner PID', async t => { + const { runner, config } = fixture(t); + const orphan = mkdtempSync(join(tmpdir(), 'evolver-model-')); + t.after(() => rmSync(orphan, { recursive: true, force: true })); + const origin = createHash('sha256').update(config).digest('hex'); + writeFileSync(join(orphan, OWNER_FILE), JSON.stringify({ nonce: 'd'.repeat(32), ownerPid: 0, origin })); + assert.equal((await runner.complete(runner.capture({ alias: 'route-a' }), 'candidate')).answer, 'answer: candidate'); + assert.equal(existsSync(orphan), true); +}); + +test('orphan recovery removes only a matching receipt with proof all owned processes are gone', async t => { + const { runner, config, dir } = fixture(t); + const orphan = mkdtempSync(join(tmpdir(), 'evolver-model-')); + t.after(() => rmSync(orphan, { recursive: true, force: true })); + const dead = 2147483647, nonce = 'c'.repeat(32); + assert.throws(() => process.kill(dead, 0)); + writeFileSync(join(orphan, OWNER_FILE), JSON.stringify({ nonce, ownerPid: dead, origin: createHash('sha256').update(config).digest('hex') })); + const receipt = { nonce, guardianPid: dead, nativePid: dead }; + if (process.platform === 'win32') { + writeFileSync(join(orphan, CHILD_FILE), JSON.stringify(receipt)); + await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate'), { code: 'MODEL_UNAVAILABLE', reason: 'unsafe_runtime' }); + assert.equal(existsSync(orphan), true, 'dead root PIDs alone cannot prove a Windows job is empty'); + Object.assign(receipt, { helperPid: dead, jobEmpty: true }); + } + writeFileSync(join(orphan, CHILD_FILE), JSON.stringify(receipt)); + assert.equal((await runner.complete(runner.capture({ alias: 'route-a' }), 'candidate')).answer, 'answer: candidate'); + assert.equal(existsSync(orphan), false); + nativeClosed(dir); +}); + +test('cancelling a pending completion waits for its owned native process before removing scratch', async t => { + const { runner, dir } = fixture(t, 'hang'); + const controller = new AbortController(); + const result = runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', controller.signal); + const rejection = assert.rejects(result, { code: 'ABORT_ERR' }); + await until(() => promptStarted(dir)); + controller.abort(); + await rejection; + nativeClosed(dir); +}); + +test('a stalled POSIX guardian retains ownership until it can stop the native group', { skip: process.platform === 'win32' }, async (t) => { + const { runner, dir } = fixture(t, 'hang'); + const controller = new AbortController(); + const completion = runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', controller.signal); + const rejection = assert.rejects(completion, { code: 'ACP_CLEANUP_FAILED' }); + await until(() => promptStarted(dir)); + const receipt = JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')); + const child = JSON.parse(readFileSync(join(receipt.home, CHILD_FILE), 'utf8')); + assert.equal(child.nativePid, receipt.pid); + process.kill(child.guardianPid, 'SIGSTOP'); + try { + controller.abort(); + await rejection; + assert.doesNotThrow(() => process.kill(child.guardianPid, 0)); + } finally { + try { process.kill(child.guardianPid, 'SIGCONT'); } catch {} + } + await until(() => !existsSync(receipt.home)); + nativeClosed(dir); +}); + +test('the native guardian cleans its process and marked scratch when its worker dies', async t => { + const { config, dir } = fixture(t); + const owner = spawn(process.execPath, [fileURLToPath(new URL('../fixtures/acp/owner.mjs', import.meta.url)), config, join(dir, 'receipt.json')], { windowsHide: true, stdio: 'ignore' }); + t.after(() => { if (owner.exitCode === null && owner.signalCode === null) owner.kill('SIGKILL'); }); + await until(() => existsSync(join(dir, 'receipt.json'))); + const receipt = JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')); + const closed = new Promise(done => owner.once('close', done)); + owner.kill('SIGKILL'); + await closed; + await until(() => !existsSync(receipt.home)); + nativeClosed(dir); +}); + +async function activeFixtureJob(dir, ownerPid) { + await until(() => promptStarted(dir)); + const receipt = JSON.parse(readFileSync(join(dir, 'receipt.json'), 'utf8')); + const home = realpathSync(receipt.home); + assert.equal(dirname(home), realpathSync(tmpdir())); + assert.match(basename(home), /^evolver-model-/); + let child; + await until(() => { + try { child = JSON.parse(readFileSync(join(home, CHILD_FILE), 'utf8')); return child.nativePid === receipt.pid && child.helperPid > 0; } + catch { return false; } + }); + const marker = JSON.parse(readFileSync(join(home, OWNER_FILE), 'utf8')); + assert.equal(marker.ownerPid, ownerPid); + assert.equal(marker.nonce, child.nonce); + assert.equal(child.jobEmpty, false); + const pids = [child.guardianPid, child.helperPid, child.nativePid, receipt.descendantPid]; + assert.ok(pids.every(pid => Number.isSafeInteger(pid) && pid > 0)); + pids.forEach(pid => assert.doesNotThrow(() => process.kill(pid, 0))); + return { home, child, pids }; +} + +async function assertUnprovenFixtureJobStopped({ home, pids }) { + await until(() => pids.every(pid => { + try { process.kill(pid, 0); return false; } catch (error) { return error?.code === 'ESRCH'; } + })); + assert.equal(existsSync(home), true, 'scratch stays when no live job-empty proof was delivered'); + assert.equal(JSON.parse(readFileSync(join(home, CHILD_FILE), 'utf8')).jobEmpty, false); + rmSync(home, { recursive: true }); // Only this fixture's now-dead, nonce-owned tree. +} + +test('an abrupt Windows guardian death kills the whole job but retains scratch without a job-empty proof', { skip: process.platform !== 'win32' }, async t => { + const { config, dir } = fixture(t); + const owner = spawn(process.execPath, [fileURLToPath(new URL('../fixtures/acp/owner.mjs', import.meta.url)), config, join(dir, 'receipt.json'), 'hang', 'kill-guardian'], { windowsHide: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }); + const closed = new Promise(done => owner.once('close', done)); + t.after(() => { if (owner.exitCode === null && owner.signalCode === null) owner.kill('SIGKILL'); }); + const job = await activeFixtureJob(dir, owner.pid); + owner.send('kill-guardian'); + assert.equal(await closed, 1); + await assertUnprovenFixtureJobStopped(job); +}); + +test('hard-killing the exact Windows job keeper reaps native processes through kill-on-close', { skip: process.platform !== 'win32' }, async t => { + const { runner, dir } = fixture(t, 'hang'); + const controller = new AbortController(); + const completion = runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', controller.signal); + const rejected = assert.rejects(completion, { code: 'ACP_CLEANUP_FAILED' }); + try { + const job = await activeFixtureJob(dir, process.pid); + const system = process.env.SystemRoot || process.env.WINDIR; + const powershell = windowsJobExecutable(); + // Hold the process handle while proving image, parent, start time and its unique fixture script. + execFileSync(powershell, ['-NoProfile', '-NoLogo', '-NonInteractive', '-Command', String.raw` +$ErrorActionPreference = 'Stop' +$keeper = [Diagnostics.Process]::GetProcessById([int] $env:EVOLVER_TEST_KEEPER_PID) +try { + $null = $keeper.Handle + $row = Get-CimInstance Win32_Process -Filter ('ProcessId = ' + $keeper.Id) + if ($null -eq $row -or $row.ParentProcessId -ne [int] $env:EVOLVER_TEST_GUARDIAN_PID -or + $row.ExecutablePath -ine $env:EVOLVER_TEST_KEEPER_IMAGE -or + $keeper.MainModule.FileName -ine $env:EVOLVER_TEST_KEEPER_IMAGE -or + [Math]::Abs(($keeper.StartTime.ToUniversalTime() - $row.CreationDate.ToUniversalTime()).TotalMilliseconds) -gt 1 -or + $row.CommandLine.IndexOf($env:EVOLVER_TEST_KEEPER_SCRIPT, [StringComparison]::OrdinalIgnoreCase) -lt 0) { + throw 'Fixture keeper identity mismatch' + } + $keeper.Kill() + if (!$keeper.WaitForExit(2000)) { throw 'Fixture keeper did not exit' } +} finally { $keeper.Dispose() } +`], { windowsHide: true, timeout: 5000, stdio: 'pipe', env: { + SystemRoot: system, WINDIR: system, EVOLVER_TEST_KEEPER_PID: String(job.child.helperPid), EVOLVER_TEST_GUARDIAN_PID: String(job.child.guardianPid), + EVOLVER_TEST_KEEPER_IMAGE: powershell, EVOLVER_TEST_KEEPER_SCRIPT: join(job.home, '.evolver-job.ps1'), + } }); + await rejected; + await assertUnprovenFixtureJobStopped(job); + } finally { + controller.abort(); + await completion.catch(() => {}); + await rejected.catch(() => {}); + } +}); + +test('the captured thinking choice must match native evidence even when the native default is high', async t => { + const { runner, dir } = fixture(t, 'ignore-effort'); + await assert.rejects(runner.complete(runner.capture({ alias: 'route-a', effort: 'off' }), 'candidate', AbortSignal.timeout(10_000))); + nativeClosed(dir); +}); + +test('ACP selects thinking off explicitly instead of inheriting the native high default', async t => { + const { runner, dir } = fixture(t); + assert.equal((await runner.complete(runner.capture({ alias: 'route-a', effort: 'off' }), 'candidate', AbortSignal.timeout(10_000))).answer, 'answer: candidate'); + nativeClosed(dir); +}); + +test('ACP rejects failed, unverified, truncated or unsafe native completions and cleans the process', async t => { + for (const mode of ['native-error', 'missing-provider-finish', 'wrong-model', 'tools-loaded', 'native-tool-call', 'missing-proof', 'truncated-proof', 'wrong-session', 'length-cutoff', 'duplicate-response', 'nonzero-exit', 'client-request', 'tool-update', 'output-limit', 'combined-output-limit']) { + await t.test(mode, async t => { + const { runner, dir } = fixture(t, mode); + await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', AbortSignal.timeout(10_000))); + nativeClosed(dir); + }); + } +}); + +test('a request deadline cancels the native completion and its inherited descendant', async t => { + const { runner, dir } = fixture(t, 'hang'); + const controller = new AbortController(); + const rejection = assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', controller.signal), { code: 'ABORT_ERR' }); + await until(() => promptStarted(dir)); + const deadline = setTimeout(() => controller.abort(), 500); + try { await rejection; } finally { clearTimeout(deadline); } + nativeClosed(dir); +}); + +test('a 500 ms startup abort sends no native prompt and leaves no scratch', async t => { + const { declaration, env, dir } = fixture(t, 'startup-hang'); + const prepare = declaration.prepare; + let home; + declaration.prepare = (selected, context) => { home = context.home; return prepare(selected, context); }; + const runner = modelRunnerOf(declaration, env); + await assert.rejects(runner.complete(runner.capture({ alias: 'route-a' }), 'candidate', AbortSignal.timeout(500)), { code: 'ABORT_ERR' }); + assert.ok(home); + assert.equal(existsSync(home), false); + const file = join(dir, 'receipt.json'); + if (existsSync(file)) { + assert.notEqual(JSON.parse(readFileSync(file, 'utf8')).promptStarted, true); + nativeClosed(dir); + } +}); + +test('a host Node preload cannot run inside the private credential environment', async t => { + const { config, dir } = fixture(t); + const owner = spawn(process.execPath, ['--import', new URL('../fixtures/acp/poison-preload.mjs', import.meta.url).href, fileURLToPath(new URL('../fixtures/acp/owner.mjs', import.meta.url)), config, join(dir, 'receipt.json'), ''], { windowsHide: true, stdio: 'ignore' }); + assert.equal(await new Promise(done => owner.once('close', done)), 0); + nativeClosed(dir); +}); diff --git a/test/runtime/acp-receipt.test.js b/test/runtime/acp-receipt.test.js new file mode 100644 index 0000000..ea28528 --- /dev/null +++ b/test/runtime/acp-receipt.test.js @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { pathToFileURL } from 'node:url'; + +import { modelRunnerOf } from '../../lib/runtime/model-executor.js'; +import { declarationFor } from '../fixtures/acp/declaration.mjs'; + +test('a transient receipt replacement refusal does not crash the native fixture before its prompt completes', async t => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-acp-receipt-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const config = join(dir, 'config.toml'), receipt = join(dir, 'receipt.json'), preload = join(dir, 'replace-busy.mjs'); + writeFileSync(config, '[provider]\nendpoint="https://example.invalid/v1"\nkey="fixture-private-api-key-NOT-A-REAL-CREDENTIAL"\n'); + writeFileSync(preload, `import fs from 'node:fs'; +import { syncBuiltinESMExports } from 'node:module'; +const rename = fs.renameSync; +let receipts = 0; +fs.renameSync = (from, to) => { + if (to === process.env.FIXTURE_RECEIPT && ++receipts === 2) { + throw Object.assign(new Error('fixture temporary replacement refusal'), { code: 'EPERM' }); + } + return rename(from, to); +}; +syncBuiltinESMExports(); +`); + const declaration = declarationFor(config, receipt), args = declaration.args; + declaration.args = () => ['--import', pathToFileURL(preload).href, ...args()]; + const runner = modelRunnerOf(declaration, process.env); + const result = await runner.complete(runner.capture({ alias: 'route-a' }), 'receipt regression', AbortSignal.timeout(10_000)); + assert.equal(result.answer, 'answer: receipt regression'); + const native = JSON.parse(readFileSync(receipt, 'utf8')); + assert.equal(native.promptStarted, true); + assert.equal(existsSync(native.home), false); + assert.throws(() => process.kill(native.pid, 0)); +}); diff --git a/test/runtime/file-lock.test.js b/test/runtime/file-lock.test.js new file mode 100644 index 0000000..0e40c47 --- /dev/null +++ b/test/runtime/file-lock.test.js @@ -0,0 +1,621 @@ +import assert from 'node:assert/strict'; +import childProcess, { spawnSync } from 'node:child_process'; +import { syncBuiltinESMExports } from 'node:module'; +import fs from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; + +import { processStartTime } from '../../lib/runtime/process/owner.js'; +import { withLock } from '../../lib/runtime/store/file-lock.js'; +import { lockContenders, until } from '../helpers/lock-contenders.js'; + +const RUNTIME = new URL('../../lib/runtime/store/file-lock.js', import.meta.url).href; +const STALE = new Date(0); + +function scratch(t, token = null) { + const dir = mkdtempSync(join(tmpdir(), 'evolver-work-lock-')); + const lock = join(dir, 'work.lock'); + if (token !== null) { + mkdirSync(lock); + writeFileSync(join(lock, 'owner'), token); + } + t.after(() => rmSync(dir, { recursive: true, force: true })); + return { dir, lock }; +} + +function holdAdmission(lock, pid = process.pid) { + const admission = `${lock}.admission`; + const owner = join(admission, `owner-${pid}-deadbeef`); + mkdirSync(admission); + writeFileSync(owner, 'fixture owner'); + return { admission, owner, release: () => { unlinkSync(owner); rmdirSync(admission); } }; +} + +test('taking an uncontended lock does not launch a process lookup', async (t) => { + const { lock } = scratch(t); + let launches = 0; + const spawn = childProcess.spawnSync; + childProcess.spawnSync = (...args) => { launches += 1; return spawn(...args); }; + syncBuiltinESMExports(); + try { + assert.equal(await withLock(lock, () => {}), true); + } finally { + childProcess.spawnSync = spawn; + syncBuiltinESMExports(); + } + assert.equal(launches, 0); +}); + +test('stale legacy and dead-owner directories recover, but a fresh legacy lock stays held', async (t) => { + const { lock } = scratch(t, 'legacy token'); + let entered = 0; + assert.equal(await withLock(lock, async () => { entered += 1; }), false); + utimesSync(lock, STALE, STALE); + assert.equal(await withLock(lock, async () => { entered += 1; }), true); + const exited = spawnSync(process.execPath, ['-e', '']); + assert.equal(exited.status, 0); + mkdirSync(lock); + writeFileSync(join(lock, 'owner'), `${exited.pid}:deadbeef`); + holdAdmission(lock, exited.pid); + utimesSync(lock, STALE, STALE); + assert.equal(await withLock(lock, async () => { entered += 1; }), true); + assert.equal(entered, 2); + assert.equal(existsSync(lock), false); + assert.equal(existsSync(`${lock}.admission`), false); +}); + +test('an old mtime never evicts a live owner, which can refresh and release its lock', async (t) => { + const { dir, lock } = scratch(t); + assert.equal(await withLock(lock, async (held) => { + utimesSync(lock, STALE, STALE); + let entered = false; + assert.equal(await withLock(lock, async () => { entered = true; }), false); + assert.equal(entered, false); + held.refresh(); + assert.ok(statSync(lock).mtimeMs > STALE.getTime()); + }), true); + assert.equal(existsSync(lock), false); + assert.deepEqual(readdirSync(dir), []); +}); + +test('only a stale worker lock with proof of PID reuse can displace a live PID', async (t) => { + const birth = processStartTime(process.pid); + const prefix = `${process.pid}:deadbeef`; + for (const token of [prefix, `${prefix}:${birth}`, `${prefix}:${birth - 1_000}`, `${prefix}:invalid`]) { + const { lock } = scratch(t, token); + utimesSync(lock, STALE, STALE); + assert.equal(await withLock(lock, () => assert.fail('owner identity is not disproved')), false); + assert.equal(readFileSync(join(lock, 'owner'), 'utf8'), token); + } + + const { dir, lock } = scratch(t, `${prefix}:${birth - 10_000}`); + assert.equal(await withLock(lock, () => assert.fail('the lease is still fresh')), false); + utimesSync(lock, STALE, STALE); + assert.equal(await withLock(lock, () => { + assert.match(readFileSync(join(lock, 'owner'), 'utf8'), new RegExp(`^${process.pid}:[a-f0-9]+:${birth}$`)); + }), true); + assert.deepEqual(readdirSync(dir), []); +}); + +test('a foreign PID probe leaves admission free and cannot remove a different worker token', async (t) => { + const pid = process.ppid; + const birth = Date.now(); + const old = `${pid}:aaaa:${birth - 10_000}`; + const replacement = `${pid}:bbbb:${birth - 10_000}`; + const { lock } = scratch(t, old); + utimesSync(lock, STALE, STALE); + const spawn = childProcess.spawnSync; + let probes = 0; + childProcess.spawnSync = () => { + probes += 1; + assert.equal(existsSync(`${lock}.admission`), false); + writeFileSync(join(lock, 'owner'), replacement); + const stdout = process.platform === 'win32' ? String(birth) : new Date(birth).toUTCString().replace(/ GMT$/, ''); + return { status: 0, stdout }; + }; + syncBuiltinESMExports(); + try { + assert.equal(await withLock(lock, () => assert.fail('the observed token was replaced')), false); + } finally { + childProcess.spawnSync = spawn; + syncBuiltinESMExports(); + } + assert.equal(probes, 1); + assert.equal(readFileSync(join(lock, 'owner'), 'utf8'), replacement); +}); + +test('a transient owner read error never turns a stale live lock into a legacy lock', async (t) => { + for (const code of ['EBUSY', 'EPERM', 'EACCES', 'EIO']) { + const { dir, lock } = scratch(t); + assert.equal(await withLock(lock, async () => { + const owner = join(lock, 'owner'); + const token = readFileSync(owner, 'utf8'); + utimesSync(lock, STALE, STALE); + const read = fs.readFileSync; + let refused = false; + let entered = false; + fs.readFileSync = (target, ...args) => { + if (target === owner && !refused) { + refused = true; + throw Object.assign(new Error('transient owner read refusal'), { code }); + } + return read(target, ...args); + }; + try { + assert.equal(await withLock(lock, async () => { entered = true; }), false, code); + } finally { + fs.readFileSync = read; + } + assert.equal(refused, true); + assert.equal(entered, false, code); + assert.equal(readFileSync(owner, 'utf8'), token, 'the live owner stays intact'); + }), true); + assert.deepEqual(readdirSync(dir), [], 'the original owner still releases normally'); + } +}); + +test('a stale directory left before its owner was written is recoverable', async (t) => { + const { dir, lock } = scratch(t); + mkdirSync(lock); + utimesSync(lock, STALE, STALE); + let entered = false; + assert.equal(await withLock(lock, async () => { entered = true; }), true); + assert.equal(entered, true); + assert.deepEqual(readdirSync(dir), []); +}); + +test('release retries a transient owner read failure and really removes its lock', async (t) => { + const { dir, lock } = scratch(t); + const owner = join(lock, 'owner'); + const read = fs.readFileSync; + let refused = false; + try { + assert.equal(await withLock(lock, async () => { + fs.readFileSync = (target, ...args) => { + if (target === owner && !refused) { + refused = true; + throw Object.assign(new Error('transient read refusal'), { code: 'EBUSY' }); + } + return read(target, ...args); + }; + }), true); + } finally { + fs.readFileSync = read; + } + assert.equal(refused, true); + assert.deepEqual(readdirSync(dir), []); +}); + +test('release refuses an unreadable owner explicitly without claiming cleanup', async (t) => { + for (const code of ['EBUSY', 'EPERM', 'EIO']) { + const { lock } = scratch(t); + const owner = join(lock, 'owner'); + const read = fs.readFileSync; + let token; + try { + await assert.rejects(withLock(lock, async () => { + token = read(owner, 'utf8'); + fs.readFileSync = (target, ...args) => { + if (target === owner) throw Object.assign(new Error('persistent read refusal'), { code }); + return read(target, ...args); + }; + }), { code: 'ELOCKED' }); + } finally { + fs.readFileSync = read; + } + assert.equal(readFileSync(owner, 'utf8'), token, code); + assert.equal(await withLock(lock, () => {}), true, 'known abandoned work is recoverable after access returns'); + } +}); + +test('a stale foreign admission is probed once within the release deadline', async (t) => { + const { lock } = scratch(t); + const spawn = childProcess.spawnSync; + const timeouts = []; + try { + await assert.rejects(withLock(lock, () => { + const guard = holdAdmission(lock, process.ppid); + unlinkSync(guard.owner); + writeFileSync(join(guard.admission, `owner-${process.ppid}-a1-${Date.now() - 30_000}`), ''); + utimesSync(guard.admission, STALE, STALE); + childProcess.spawnSync = (_command, _args, { timeout }) => { + timeouts.push(timeout); + return { status: null, stdout: '' }; + }; + syncBuiltinESMExports(); + }), { code: 'ELOCKED' }); + } finally { + childProcess.spawnSync = spawn; + syncBuiltinESMExports(); + } + assert.equal(timeouts.length, 1); + assert.ok(timeouts[0] > 0 && timeouts[0] <= 1_000); + assert.equal(existsSync(lock), true, 'an inconclusive identity probe does not reclaim a live guard'); +}); + +test('a release failure preserves the original work error', async (t) => { + const { lock } = scratch(t); + const read = fs.readFileSync; + const workError = new Error('work failed'); + try { + await assert.rejects(withLock(lock, async () => { + fs.readFileSync = (file, ...args) => { + if (String(file) === join(lock, 'owner')) throw Object.assign(new Error('unreadable owner'), { code: 'EIO' }); + return read(file, ...args); + }; + throw workError; + }), (error) => error === workError); + } finally { + fs.readFileSync = read; + } +}); + +test('refresh leaves an unreadable live lock untouched without crashing its timer caller', async (t) => { + const { dir, lock } = scratch(t); + assert.equal(await withLock(lock, async (held) => { + const owner = join(lock, 'owner'); + utimesSync(lock, STALE, STALE); + const read = fs.readFileSync; + fs.readFileSync = (target, ...args) => { + if (target === owner) throw Object.assign(new Error('unreadable owner'), { code: 'EPERM' }); + return read(target, ...args); + }; + try { + assert.doesNotThrow(() => held.refresh()); + assert.equal(statSync(lock).mtimeMs, STALE.getTime()); + } finally { + fs.readFileSync = read; + } + }), true); + assert.deepEqual(readdirSync(dir), []); +}); + +test('refresh tolerates file access refusals and the next refresh and release still work', async (t) => { + for (const method of ['utimesSync', 'mkdirSync', 'readFileSync']) { + const { dir, lock } = scratch(t); + assert.equal(await withLock(lock, async (held) => { + utimesSync(lock, STALE, STALE); + const access = fs[method]; + let refused = false; + fs[method] = (target, ...args) => { + const matches = method === 'mkdirSync' ? String(target).startsWith(`${lock}.admission.`) + : target === (method === 'readFileSync' ? join(lock, 'owner') : lock); + if (matches && !refused) { + refused = true; + throw Object.assign(new Error('transient access refusal'), { code: 'EPERM' }); + } + return access(target, ...args); + }; + try { + assert.doesNotThrow(() => held.refresh(), method); + if (method !== 'readFileSync') assert.equal(statSync(lock).mtimeMs, STALE.getTime()); + } finally { + fs[method] = access; + } + assert.equal(refused, true, method); + held.refresh(); + assert.ok(statSync(lock).mtimeMs > STALE.getTime()); + }), true); + assert.deepEqual(readdirSync(dir), []); + } +}); + +test('release does not delete a missing or different owner', async (t) => { + for (const replacement of [null, `${process.pid}:another-owner`]) { + const { lock } = scratch(t); + assert.equal(await withLock(lock, async () => { + if (replacement === null) unlinkSync(join(lock, 'owner')); + else writeFileSync(join(lock, 'owner'), replacement); + }), true); + assert.equal(existsSync(lock), true); + assert.equal(existsSync(join(lock, 'owner')), replacement !== null); + if (replacement !== null) assert.equal(readFileSync(join(lock, 'owner'), 'utf8'), replacement); + } +}); + +test('release waits asynchronously for transient admission contention', async (t) => { + const { lock } = scratch(t); + let guard; + let finished = false; + let refreshedAt; + const running = withLock(lock, async (held) => { + guard = holdAdmission(lock); + utimesSync(lock, STALE, STALE); + held.refresh(); + refreshedAt = statSync(lock).mtimeMs; + }).then(() => { finished = true; }); + try { + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(refreshedAt, STALE.getTime(), 'refresh also respects admission'); + assert.equal(finished, false); + assert.equal(existsSync(lock), true); + } finally { + guard.release(); + await running; + } + assert.equal(existsSync(lock), false); +}); + +test('release refuses bounded admission contention without deleting the held directory', async (t) => { + const { lock } = scratch(t); + let guard; + const started = Date.now(); + await assert.rejects(withLock(lock, async () => { + guard = holdAdmission(lock); + }), { code: 'ELOCKED' }); + assert.ok(Date.now() - started < 5_000, 'cleanup cannot wait forever'); + assert.equal(existsSync(lock), true); + assert.equal(readFileSync(guard.owner, 'utf8'), 'fixture owner'); +}); + +test('an admission cleanup failure is surfaced instead of silently orphaning a live guard', async (t) => { + const { lock } = scratch(t); + const admission = `${lock}.admission`; + const unlink = fs.unlinkSync; + fs.unlinkSync = (target) => { + if (dirname(target) === admission) throw Object.assign(new Error('refused by fixture'), { code: 'EPERM' }); + return unlink(target); + }; + let entered = false; + try { + await assert.rejects(withLock(lock, async () => { entered = true; }), { code: 'ELOCKED' }); + } finally { + fs.unlinkSync = unlink; + } + assert.equal(entered, false); + assert.equal(existsSync(lock), false, 'the failed acquire must not leave a work lock'); + assert.ok(readdirSync(admission).some((name) => name.startsWith(`owner-${process.pid}-`))); + assert.equal(await withLock(lock, async () => { entered = true; }), true); + assert.equal(entered, true); +}); + +test('another process reclaims a failed admission release while the original owner is alive', async (t) => { + const { dir, lock } = scratch(t); + const admission = `${lock}.admission`; + const unlink = fs.unlinkSync; + try { + assert.equal(await withLock(lock, () => { + fs.unlinkSync = (target) => { + if (dirname(target) === admission) throw Object.assign(new Error('fixture cleanup refusal'), { code: 'EPERM' }); + return unlink(target); + }; + }), true); + } finally { + fs.unlinkSync = unlink; + } + assert.equal(existsSync(lock), false); + assert.ok(readdirSync(admission).some((name) => name.startsWith(`owner-${process.pid}-`))); + + const other = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import assert from 'node:assert/strict'; + const [runtime, lock, owner] = process.argv.slice(1); + const { withLock } = await import(runtime); + process.kill(Number(owner), 0); + assert.equal(await withLock(lock, () => {}), true, 'the stranded admission can be reclaimed'); + `, RUNTIME, lock, String(process.pid)], { encoding: 'utf8', timeout: 10_000, windowsHide: true }); + assert.ifError(other.error); + assert.equal(other.status, 0, other.stderr); + assert.deepEqual(readdirSync(dir), []); +}); + +test('admission recovery resumes after the abandoned owner has already been removed', async (t) => { + const { dir, lock } = scratch(t); + const guard = holdAdmission(lock); + const marker = join(guard.admission, `abandoned-owner-${process.pid}-abcd`); + writeFileSync(marker, ''); + assert.equal(await withLock(lock, () => assert.fail('a different owner is still active')), false); + assert.equal(existsSync(guard.owner), true); + + unlinkSync(guard.owner); + assert.equal(await withLock(lock, () => {}), true); + assert.deepEqual(readdirSync(dir), []); +}); + +test('a transient admission cleanup refusal retries and leaves later work unblocked', async (t) => { + const { dir, lock } = scratch(t); + const unlink = fs.unlinkSync; + let refused = false; + fs.unlinkSync = (target) => { + if (dirname(target) === `${lock}.admission` && !refused) { + refused = true; + throw Object.assign(new Error('temporarily refused by fixture'), { code: 'EPERM' }); + } + return unlink(target); + }; + let entered = 0; + try { + assert.equal(await withLock(lock, async () => { entered += 1; }), true); + } finally { + fs.unlinkSync = unlink; + } + assert.equal(refused, true); + assert.equal(entered, 1); + assert.deepEqual(readdirSync(dir), []); + assert.equal(await withLock(lock, async () => { entered += 1; }), true); + assert.equal(entered, 2); + assert.deepEqual(readdirSync(dir), []); +}); + +test('an old admission stays held by the same process, but a reused PID can be recovered', async (t) => { + const { lock } = scratch(t); + const admission = `${lock}.admission`; + const unlink = fs.unlinkSync; + fs.unlinkSync = (target) => { + if (dirname(target) === admission) throw Object.assign(new Error('refused by fixture'), { code: 'EPERM' }); + return unlink(target); + }; + try { + await assert.rejects(withLock(lock, async () => assert.fail()), { code: 'ELOCKED' }); + } finally { + fs.unlinkSync = unlink; + } + const original = readdirSync(admission).find((name) => name.startsWith(`owner-${process.pid}-`)); + const birth = original.split('-')[3]; + assert.match(birth ?? '', /^\d{13}$/); + const replaceOwner = (name) => { + for (const name of readdirSync(admission)) unlinkSync(join(admission, name)); + writeFileSync(join(admission, name), ''); + utimesSync(admission, STALE, STALE); + }; + replaceOwner(`owner-${process.pid}-a1-${birth}`); + assert.equal(await withLock(lock, async () => assert.fail('same process still owns the guard')), false); + const differentBirth = Number(birth) - 10_000; + replaceOwner(`owner-${process.pid}-a2-${differentBirth}`); + assert.equal(await withLock(lock, async () => {}), true); +}); + +test('busy and unrecognised admission guards stay intact and leave no staging directories', async (t) => { + const { dir, lock } = scratch(t); + const guard = holdAdmission(lock); + assert.equal(await withLock(lock, async () => assert.fail('a live guard is held')), false); + assert.deepEqual(readdirSync(dir), ['work.lock.admission']); + writeFileSync(join(guard.admission, 'unexpected'), 'keep'); + assert.equal(await withLock(lock, async () => assert.fail('multiple owners are unknown')), false); + assert.equal(readdirSync(guard.admission).length, 2); + unlinkSync(guard.owner); + assert.equal(await withLock(lock, async () => assert.fail('an unknown owner is held')), false); + assert.equal(readFileSync(join(guard.admission, 'unexpected'), 'utf8'), 'keep'); + assert.deepEqual(readdirSync(dir), ['work.lock.admission']); +}); + +test('two contenders cannot replace the fresh lock while another reclaims a stale directory', async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-work-lock-')); + const lock = join(dir, 'work.lock'); + mkdirSync(lock); + writeFileSync(join(lock, 'owner'), 'legacy token'); + utimesSync(lock, STALE, STALE); + const marker = (name) => join(dir, name); + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + import { withLock } from ${JSON.stringify(RUNTIME)}; + const [dir, lock, role] = process.argv.slice(1); + const marker = (name) => path.join(dir, name); + const wait = async (name) => { + const deadline = Date.now() + 10000; + while (!fs.existsSync(marker(name))) { + if (Date.now() > deadline) throw new Error('timed out waiting for ' + name); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }; + if (role === 'A') { + const stat = fs.statSync; + let paused = false; + fs.statSync = (file, ...args) => { + const result = stat(file, ...args); + if (file === lock && !paused) { + paused = true; + fs.writeFileSync(marker('stale-read'), ''); + const deadline = Date.now() + 10000; + while (!fs.existsSync(marker('resume'))) { + if (Date.now() > deadline) throw new Error('timed out waiting to resume'); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } + } + return result; + }; + } + const result = await withLock(lock, async () => { + fs.writeFileSync(marker(role + '-entered'), ''); + await wait('release'); + }); + fs.writeFileSync(marker(role + '-result'), JSON.stringify(result)); + `; + const { run, results } = lockContenders(t, dir, script, [dir, lock], ['resume', 'release']); + run('A'); + await until(() => existsSync(marker('stale-read'))); + run('B'); + run('C'); + await until(() => ['B', 'C'].every((role) => existsSync(marker(role + '-entered')) || existsSync(marker(role + '-result')))); + writeFileSync(marker('resume'), ''); + await until(() => existsSync(marker('A-entered'))); + const entered = ['A', 'B', 'C'].filter((role) => existsSync(marker(role + '-entered'))); + writeFileSync(marker('release'), ''); + const outcomes = await results(); + assert.ok(outcomes.every(({ code }) => code === 0), JSON.stringify(outcomes)); + assert.deepEqual(entered, ['A'], 'only the process reclaiming the inspected stale directory may enter'); + assert.deepEqual(['A', 'B', 'C'].map((role) => JSON.parse(readFileSync(marker(role + '-result'), 'utf8'))), [true, false, false]); +}); + +test('a delayed dead-admission reclaimer cannot unlink the fresh owner another contender published', async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-admission-race-')); + const lock = join(dir, 'work.lock'); + mkdirSync(lock); + writeFileSync(join(lock, 'owner'), 'legacy token'); + utimesSync(lock, STALE, STALE); + const exited = spawnSync(process.execPath, ['-e', '']); + assert.equal(exited.status, 0); + holdAdmission(lock, exited.pid); + const marker = (name) => join(dir, name); + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + import { withLock } from ${JSON.stringify(RUNTIME)}; + const [dir, lock, role] = process.argv.slice(1); + const marker = (name) => path.join(dir, name); + const waitSync = (name) => { + const deadline = Date.now() + 10000; + while (!fs.existsSync(marker(name))) { + if (Date.now() > deadline) throw new Error('timed out waiting for ' + name); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } + }; + if (role === 'A') { + const list = fs.readdirSync; + let paused = false; + fs.readdirSync = (file, ...args) => { + const result = list(file, ...args); + if (file === lock + '.admission' && !paused) { + paused = true; + fs.writeFileSync(marker('dead-inspected'), ''); + waitSync('A-resume'); + } + return result; + }; + } + if (role === 'B') { + const rename = fs.renameSync; + let paused = false; + fs.renameSync = (source, target) => { + const result = rename(source, target); + if (target === lock + '.admission' && !paused) { + paused = true; + fs.writeFileSync(marker('fresh-held'), ''); + waitSync('B-resume'); + } + return result; + }; + } + const result = await withLock(lock, async () => { + fs.writeFileSync(marker(role + '-entered'), ''); + const deadline = Date.now() + 10000; + while (!fs.existsSync(marker('release'))) { + if (Date.now() > deadline) throw new Error('timed out waiting to release'); + await new Promise((resolve) => setTimeout(resolve, 10)); + } + }); + fs.writeFileSync(marker(role + '-result'), JSON.stringify(result)); + `; + const { run, results } = lockContenders(t, dir, script, [dir, lock], ['A-resume', 'B-resume', 'release']); + run('A'); + await until(() => existsSync(marker('dead-inspected'))); + const fresh = run('B'); + await until(() => existsSync(marker('fresh-held'))); + run('C'); + await until(() => existsSync(marker('C-result')) || existsSync(marker('C-entered'))); + assert.equal(existsSync(marker('C-entered')), false, 'publishing a fresh guard never exposes an empty directory'); + writeFileSync(marker('A-resume'), ''); + await until(() => existsSync(marker('A-result')) || existsSync(marker('A-entered'))); + assert.equal(existsSync(marker('A-entered')), false, 'the delayed reclaimer cannot displace the fresh guard'); + assert.equal(readdirSync(`${lock}.admission`).length, 1); + assert.match(readdirSync(`${lock}.admission`)[0], new RegExp(`^owner-${fresh.pid}-`)); + writeFileSync(marker('B-resume'), ''); + await until(() => existsSync(marker('B-entered'))); + assert.deepEqual(['A', 'B', 'C'].filter((role) => existsSync(marker(role + '-entered'))), ['B']); + writeFileSync(marker('release'), ''); + const outcomes = await results(); + assert.ok(outcomes.every(({ code }) => code === 0), JSON.stringify(outcomes)); + assert.deepEqual(['A', 'B', 'C'].map((role) => JSON.parse(readFileSync(marker(role + '-result'), 'utf8'))), [false, true, false]); +}); diff --git a/test/runtime/native-facts.test.js b/test/runtime/native-facts.test.js new file mode 100644 index 0000000..9f30f1c --- /dev/null +++ b/test/runtime/native-facts.test.js @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync, renameSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { nativeFactsOf } from '../../lib/runtime/native-facts.js'; + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), 'evolver-native-facts-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const project = join(root, 'project'); mkdirSync(project); + const home = join(root, 'home'); mkdirSync(home); + writeFileSync(join(home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { workspace: { root: project } } })); + const agent = join(home, 'sessions/workspace/session/agents/main'); mkdirSync(agent, { recursive: true }); + const wire = join(agent, 'wire.jsonl'); + const declaration = { root: () => home, workspaceIndex: 'workspaces.json', sessionId: p => p.session_id, + projectDir: p => p.cwd, agent: 'main', decode: (records, tasks, payload, context) => ({ records, tasks, context }) }; + return { home, project, wire, declaration, payload: { session_id: 'session', cwd: project } }; +} + +test('native facts read complete JSONL records under the selected workspace and leave an incomplete tail for the next read', t => { + const f = fixture(t); + const first = JSON.stringify({ type: 'metadata', protocol_version: '1.5' }) + '\n'; + writeFileSync(f.wire, first + '{"type":"turn.prompt"'); + const port = nativeFactsOf(f.declaration, {}); + const read = port.read(f.payload); + assert.equal(read.status, 'partial'); + assert.equal(read.records.length, 1); + assert.equal(read.records[0].$offset, 0); + assert.equal(read.mark.offset, Buffer.byteLength(first)); + writeFileSync(f.wire, first + '{"type":"turn.prompt","turnId":0}\n'); + const next = port.read(f.payload, { after: read.mark, schemaVersion: '1.5' }); + assert.equal(next.records[0].type, 'turn.prompt'); + assert.equal(next.records[0].$offset, Buffer.byteLength(first)); + assert.equal(next.context.schemaVersion, '1.5'); +}); + +test('native decoder rejection and mismatched task filename identities are never promoted to valid facts', t => { + const f = fixture(t); + writeFileSync(f.wire, '{}\n'); + f.declaration.decode = () => ({ status: 'unsupported' }); + assert.equal(nativeFactsOf(f.declaration).read(f.payload).status, 'unsupported'); + f.declaration.taskIds = () => ['task']; + f.declaration.tasksRoot = () => join(f.wire, '..', 'tasks'); + mkdirSync(f.declaration.tasksRoot(), { recursive: true }); + writeFileSync(join(f.declaration.tasksRoot(), 'task.json'), JSON.stringify({ taskId: 'wrong' })); + assert.equal(nativeFactsOf(f.declaration).read(f.payload).status, 'partial'); +}); + +test('missing, invalid, oversized and replaced native files cannot become an empty valid record', t => { + const f = fixture(t), port = nativeFactsOf(f.declaration, {}); + assert.equal(port.read(f.payload).status, 'missing'); + writeFileSync(f.wire, '{invalid}\n'); + assert.equal(port.read(f.payload).status, 'unreadable'); + writeFileSync(f.wire, '{}\n'); + const mark = port.read(f.payload).mark; + renameSync(f.wire, `${f.wire}.old`); + writeFileSync(f.wire, '{}\n'); + assert.equal(port.read(f.payload, { after: mark }).status, 'partial'); + writeFileSync(f.wire, 'x'.repeat(4 * 1024 * 1024)); + assert.equal(port.read(f.payload).status, 'partial'); + writeFileSync(join(f.home, 'workspaces.json'), ' '.repeat(1024 * 1024 + 1)); + assert.equal(port.read(f.payload).status, 'partial'); +}); + +test('native facts refuse path traversal, duplicate workspace mappings and linked files outside home', t => { + const f = fixture(t), port = nativeFactsOf(f.declaration, {}); + writeFileSync(f.wire, '{}\n'); + assert.equal(port.read({ ...f.payload, session_id: '../session' }).status, 'unsupported'); + writeFileSync(join(f.home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { a: { root: f.project }, b: { root: f.project } } })); + assert.equal(port.read(f.payload).status, 'missing'); + const foreign = join(f.project, 'foreign'); mkdirSync(foreign); + writeFileSync(join(foreign, 'wire.jsonl'), '{}\n'); + const agent = join(f.wire, '..'); + rmSync(agent, { recursive: true }); + symlinkSync(foreign, agent, process.platform === 'win32' ? 'junction' : 'dir'); + writeFileSync(join(f.home, 'workspaces.json'), JSON.stringify({ version: 1, workspaces: { workspace: { root: f.project } } })); + assert.equal(port.read(f.payload).status, 'unreadable'); +}); diff --git a/test/runtime/native-monitor.test.js b/test/runtime/native-monitor.test.js new file mode 100644 index 0000000..9b610d4 --- /dev/null +++ b/test/runtime/native-monitor.test.js @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { monitorNativeTurn } from '../../lib/runtime/native-monitor.js'; + +function fixture(t) { + const dir = mkdtempSync(join(tmpdir(), 'evolver-native-monitor-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const at = { sessionId: 's', turn: 1, projectDir: dir }; + const session = { open: true, turn: 1, native: { turn: 1 }, pid: 123 }; + const ports = { native: {}, sessions: { read: async () => session }, owner: { isGone: () => false } }; + return { at, session, ports, dir }; +} + +test('a native monitor waits for terminal and pending check records, then exits without touching a later turn', async t => { + const f = fixture(t); + let count = 0; + await monitorNativeTurn(f.ports, f.at, async () => ({ ended: ++count > 1, waiting: count < 3, native: { turnId: '0' } }), + () => assert.fail('not aborted'), { dir: f.dir, sleep: async () => {} }); + assert.equal(count, 3); + f.session.turn = 2; + await monitorNativeTurn(f.ports, f.at, () => assert.fail('stale'), () => assert.fail('stale'), { dir: f.dir }); +}); + +test('owner death checks terminal first and only aborts the matching logical turn; missing identity has a bounded watch', async t => { + const f = fixture(t); + f.ports.owner.isGone = () => true; + const events = []; + await monitorNativeTurn(f.ports, f.at, async () => { events.push('terminal'); return { ended: true }; }, + () => assert.fail('completed is not aborted'), { dir: f.dir }); + await monitorNativeTurn(f.ports, f.at, async () => { events.push('missing'); return { ended: false }; }, + async closed => { events.push(closed); }, { dir: f.dir }); + assert.deepEqual(events, ['terminal', 'missing', { ...f.at, expectedTurn: 1, expectedPid: 123, reason: 'aborted' }]); + f.ports.owner.isGone = () => false; + let now = 0; + await monitorNativeTurn(f.ports, f.at, async () => ({ waiting: true }), () => assert.fail('no verdict'), + { dir: f.dir, now: () => now, sleep: async ms => { now += ms; } }); + assert.equal(now, 30_000); +}); + +test('a permanently oversized native turn stops its monitor without fabricating evidence', async t => { + const f = fixture(t); + let reads = 0; + await monitorNativeTurn(f.ports, f.at, async () => { reads++; return { facts: { status: 'partial', limited: true }, waiting: true, native: { turnId: '0' } }; }, + () => assert.fail('no terminal verdict'), { dir: f.dir, sleep: () => assert.fail('permanent limit is not polled') }); + assert.equal(reads, 1); + for (const status of ['unreadable', 'unsupported']) { + await monitorNativeTurn(f.ports, f.at, async () => ({ facts: { status }, waiting: true, native: { turnId: '0' } }), + () => assert.fail('no terminal verdict'), { dir: f.dir, sleep: () => assert.fail('invalid native history is not polled') }); + } +}); + +test('session end before native admission performs one final read then stops the unbound monitor', async t => { + const f = fixture(t); + Object.assign(f.session, { open: false, native: { turn: 1, turnId: null, sessionEnded: true } }); + let reads = 0, slept = 0, now = 0; + await monitorNativeTurn(f.ports, f.at, async () => { reads++; return { waiting: true, native: f.session.native }; }, + () => assert.fail('no native evidence is no new verdict'), { dir: f.dir, now: () => now, sleep: async ms => { slept++; now += ms; } }); + assert.equal(reads, 1); + assert.equal(slept, 0); +}); diff --git a/test/runtime/process.test.js b/test/runtime/process.test.js index 32c1fc7..1c2181b 100644 --- a/test/runtime/process.test.js +++ b/test/runtime/process.test.js @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; import { test } from 'node:test'; +import { promisify } from 'node:util'; import { sessionEndWaitOf, waitBudgetOf } from '../../lib/runtime/budget.js'; import { modelRunnerOf } from '../../lib/runtime/model-executor.js'; @@ -21,6 +23,36 @@ test('a run past its timeout is killed and says so; a missing command reads as E assert.equal((await runProcess('evolver-no-such-command-xyz', [])).error.code, 'ENOENT'); }); +test('Windows timeout ends the process tree without system directory environment variables', { skip: process.platform !== 'win32' }, async () => { + const runtime = new URL('../../lib/runtime/process/spawn.js', import.meta.url).href; + const child = ` + const { spawn } = require('node:child_process'); + const descendant = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 5000)'], { stdio: 'ignore', windowsHide: true }); + console.log(JSON.stringify([process.pid, descendant.pid])); + setTimeout(() => {}, 5000); + `; + const fixture = ` + import { setTimeout as delay } from 'node:timers/promises'; + const env = { ...process.env }; + for (const key of Object.keys(process.env)) { + if (/^(systemroot|windir|comspec)$/i.test(key)) delete process.env[key]; + } + const { runProcess } = await import(${JSON.stringify(runtime)}); + const result = await runProcess(process.execPath, ['--input-type=commonjs', '-e', ${JSON.stringify(child)}], { env, timeoutMs: 1500 }); + if (!result.stdout) throw new Error(result.stderr || result.error?.message || 'Fixture process did not start'); + const pids = JSON.parse(result.stdout); + const alive = pid => { + try { process.kill(pid, 0); return true; } + catch (error) { if (error.code === 'ESRCH') return false; throw error; } + }; + const deadline = Date.now() + 2000; + while (pids.some(alive) && Date.now() < deadline) await delay(50); + console.log(JSON.stringify({ code: result.error?.code, alive: pids.map(alive) })); + `; + const { stdout } = await promisify(execFile)(node, ['--input-type=module', '-e', fixture], { timeout: 12000, windowsHide: true }); + assert.deepEqual(JSON.parse(stdout), { code: 'ETIMEDOUT', alive: [false, false] }); +}); + test('a cancelled run is ended and reported cancelled', async () => { const controller = new AbortController(); setTimeout(() => controller.abort(), 100); @@ -48,7 +80,7 @@ test('the owner is this process, the parent past a shell, or the nearest named a assert.equal(ownerProbeOf('self').current(), process.pid); assert.equal(ownerProbeOf('parent', { probe, platform: 'darwin' }).current(), 50); assert.equal(ownerProbeOf({ ancestor: /^codex/ }, { probe, platform: 'darwin' }).current(), 40); - assert.equal(ownerProbeOf('parent', { probe, platform: 'win32' }).current(), null); + assert.equal(ownerProbeOf('parent', { probe, platform: 'win32' }).current(), 50); }); test('a hook waits what is left of its budget, never less than a floor', () => { @@ -132,16 +164,17 @@ test('a CLI is looked up name by name, on PATH then in the host\'s folders, and assert.equal(executableOf(cursor, options), null, 'an unrelated agent on PATH is never run'); const workbuddy = { names: ['codebuddy'], dirs: ['/apps/wb'], nodeScriptOnWindows: true }; assert.deepEqual(executableOf(workbuddy, options), { file: '/apps/wb/codebuddy', viaNode: false }); - assert.deepEqual(executableOf(workbuddy, { ...options, platform: 'win32', env: { PATH: '/usr/bin' } }), { file: '/apps/wb/codebuddy', viaNode: true }); + const windowsScript = 'C:\\apps\\wb\\codebuddy'; present.add(windowsScript); + assert.deepEqual(executableOf({ ...workbuddy, dirs: ['C:\\apps\\wb'] }, { ...options, platform: 'win32', env: { PATH: 'C:\\unrelated;C:\\usr\\bin' } }), { file: windowsScript, viaNode: true }); present.add('/usr/bin/tool.cmd'); assert.equal(executableOf({ names: ['tool'] }, { ...options, platform: 'win32' }), null, 'a .cmd shim needs a shell'); }); test('on Windows an npm .cmd shim is found only for a CLI that allows one, after an .exe of the same name', async () => { const { executableOf } = await import('../../lib/runtime/process/which.js'); - const present = new Set(['/npm/codex.cmd', '/tools/codex.exe']); + const present = new Set(['C:\\npm\\codex.cmd', 'C:\\tools\\codex.exe']); const options = (PATH) => ({ env: { PATH }, platform: 'win32', exists: (file) => present.has(file), realPath: (file) => file }); - assert.deepEqual(executableOf({ names: ['codex'], windowsShims: true }, options('/npm')), { file: '/npm/codex.cmd', viaNode: false }); - assert.equal(executableOf({ names: ['codex'] }, options('/npm')), null); - assert.deepEqual(executableOf({ names: ['codex'], windowsShims: true }, options('/tools')), { file: '/tools/codex.exe', viaNode: false }); + assert.deepEqual(executableOf({ names: ['codex'], windowsShims: true }, options('C:\\unrelated;C:\\npm')), { file: 'C:\\npm\\codex.cmd', viaNode: false }); + assert.equal(executableOf({ names: ['codex'] }, options('C:\\npm')), null); + assert.deepEqual(executableOf({ names: ['codex'], windowsShims: true }, options('C:\\tools')), { file: 'C:\\tools\\codex.exe', viaNode: false }); }); diff --git a/test/runtime/settings.test.js b/test/runtime/settings.test.js new file mode 100644 index 0000000..1280a5e --- /dev/null +++ b/test/runtime/settings.test.js @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { settingsOf } from '../../lib/runtime/settings.js'; + +test('preferences survive a fresh hook with stripped environment and keep explicit environment overrides', async () => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-preferences-')); + try { + const file = join(dir, 'settings.json'); + await settingsOf({}, file).configure({ recall: false }); + await settingsOf({}, file).configure({ autoDistill: false }); + assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')), { recall: false, autoDistill: false }); + assert.equal(settingsOf({}, file).recall, false); + assert.equal(settingsOf({}, file).autoDistill, false); + assert.equal(settingsOf({ EVOLVER_RECALL: 'on' }, file).recall, true); + assert.equal(settingsOf({ EVOLVER_AUTO_DISTILL: 'off' }, file).autoDistill, false); + assert.deepEqual(settingsOf({ EVOLVER_RECALL: 'off' }), { recall: false, autoDistill: true }); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('corrupt, oversized or wrong-schema settings pause automatic activity and are not silently overwritten', async () => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-bad-preferences-')); + try { + const file = join(dir, 'settings.json'); + for (const text of ['not JSON', '{"recall":"false"}', '{"unknown":true}', '[]', ' '.repeat(4097)]) { + writeFileSync(file, text); + const settings = settingsOf({ EVOLVER_RECALL: 'on' }, file); + assert.equal(settings.recall, false); + assert.equal(settings.autoDistill, false); + await assert.rejects(settings.configure({ recall: true }), /invalid or unreadable/); + assert.equal(readFileSync(file, 'utf8'), text); + } + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('a held preference lock refuses an update instead of writing without exclusivity', async () => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-locked-preferences-')); + try { + const file = join(dir, 'settings.json'); + writeFileSync(file, '{"autoDistill":false}'); + mkdirSync(`${file}.lock`); + await assert.rejects(settingsOf({}, file).configure({ recall: false }), /Another preference update/); + assert.equal(readFileSync(file, 'utf8'), '{"autoDistill":false}'); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/test/runtime/state-lock.test.js b/test/runtime/state-lock.test.js new file mode 100644 index 0000000..24bf8da --- /dev/null +++ b/test/runtime/state-lock.test.js @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; + +import { updateState } from '../../lib/runtime/store/file-lock.js'; +import { lockContenders, until } from '../helpers/lock-contenders.js'; + +const RUNTIME = new URL('../../lib/runtime/store/file-lock.js', import.meta.url).href; +const BUSY = { code: 'ELOCKED', message: 'State is busy or unavailable; no update was made' }; + +function scratch(t) { + const dir = mkdtempSync(join(tmpdir(), 'evolver-state-lock-')); + const target = join(dir, 'state.json'); + writeFileSync(target, '{"count":0}'); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return { dir, target, lock: `${target}.lock` }; +} + +test('a failed admission release after acquiring a state token lets the next update proceed', (t) => { + const { target, lock } = scratch(t); + const unlink = fs.unlinkSync; + fs.unlinkSync = (file) => { + if (typeof file === 'string' && dirname(file) === `${lock}.admission`) { + throw Object.assign(new Error('refused by fixture'), { code: 'EPERM' }); + } + return unlink(file); + }; + try { + assert.throws(() => updateState(target, {}, () => assert.fail('mutator must not run')), BUSY); + } finally { + fs.unlinkSync = unlink; + } + assert.equal(existsSync(lock), false); + assert.deepEqual(updateState(target, {}, (state) => ({ count: state.count + 1 })), { count: 1 }); +}); + +test('a failed acquire rollback keeps its own token recoverable', (t) => { + const { target, lock } = scratch(t); + const unlink = fs.unlinkSync; + fs.unlinkSync = (file) => { + if (String(file) === lock || dirname(String(file)) === `${lock}.admission`) { + throw Object.assign(new Error('refused by fixture'), { code: 'EPERM' }); + } + return unlink(file); + }; + try { + assert.throws(() => updateState(target, {}, () => assert.fail('mutator must not run')), BUSY); + } finally { + fs.unlinkSync = unlink; + } + assert.equal(existsSync(lock), true); + assert.deepEqual(updateState(target, {}, (state) => ({ count: state.count + 1 })), { count: 1 }); + assert.equal(existsSync(lock), false); +}); + +test('a settled state write stays successful and its stranded token can be reclaimed', (t) => { + const { target, lock } = scratch(t); + const unlink = fs.unlinkSync; + try { + assert.deepEqual(updateState(target, {}, (state) => { + fs.unlinkSync = (file) => { + if (String(file) === lock) throw Object.assign(new Error('refused by fixture'), { code: 'EPERM' }); + return unlink(file); + }; + return { count: state.count + 1 }; + }), { count: 1 }); + } finally { + fs.unlinkSync = unlink; + } + assert.equal(existsSync(lock), true); + assert.deepEqual(updateState(target, {}, (state) => ({ count: state.count + 1 })), { count: 2 }); + assert.equal(existsSync(lock), false); +}); + +test('a held state lock never runs an unguarded mutator after its wait expires', (t) => { + const { target, lock } = scratch(t); + const held = `${process.pid}:alive`; + writeFileSync(lock, held); + let called = false; + const started = Date.now(); + assert.throws(() => updateState(target, {}, () => { called = true; return { count: 1 }; }), BUSY); + assert.ok(Date.now() - started < 5_000); + assert.equal(called, false); + assert.equal(readFileSync(target, 'utf8'), '{"count":0}'); + assert.equal(readFileSync(lock, 'utf8'), held); +}); + +test('a legacy dead state owner is recovered, and file permission errors stop before mutation', (t) => { + const { target, lock } = scratch(t); + const exited = spawnSync(process.execPath, ['-e', '']); + assert.equal(exited.status, 0); + writeFileSync(lock, `${exited.pid}:deadbeef`); + assert.deepEqual(updateState(target, {}, (stored) => ({ count: stored.count + 1 })), { count: 1 }); + assert.equal(existsSync(lock), false); + const write = fs.writeFileSync; + fs.writeFileSync = (file, ...args) => { + if (file === lock) throw Object.assign(new Error('refused by fixture'), { code: 'EROFS' }); + return write(file, ...args); + }; + let called = false; + try { + assert.throws(() => updateState(target, {}, () => { called = true; return {}; }), BUSY); + } finally { + fs.writeFileSync = write; + } + assert.equal(called, false); + assert.deepEqual(JSON.parse(readFileSync(target, 'utf8')), { count: 1 }); + assert.deepEqual(updateState(target, {}, (stored) => ({ count: stored.count + 1 })), { count: 2 }); +}); + +test('a transient sharing denial retries outside admission and runs the mutator only once', (t) => { + const { target, lock } = scratch(t); + const write = fs.writeFileSync; + let attempts = 0; + fs.writeFileSync = (file, ...args) => { + if (file === lock && ++attempts === 1) throw Object.assign(new Error('temporarily refused by fixture'), { code: 'EPERM' }); + return write(file, ...args); + }; + let called = 0; + try { + assert.deepEqual(updateState(target, {}, (stored) => { called += 1; return { count: stored.count + 1 }; }), { count: 1 }); + } finally { + fs.writeFileSync = write; + } + assert.equal(attempts, 2); + assert.equal(called, 1); + assert.equal(existsSync(lock), false); +}); + +test('an admission setup refusal leaves state unchanged and never calls the mutator', (t) => { + const { target, lock } = scratch(t); + const mkdir = fs.mkdirSync; + fs.mkdirSync = (file, ...args) => { + if (String(file).startsWith(`${lock}.admission.`)) throw Object.assign(new Error('read-only fixture'), { code: 'EROFS' }); + return mkdir(file, ...args); + }; + let called = false; + try { + assert.throws(() => updateState(target, {}, () => { called = true; return {}; }), BUSY); + } finally { + fs.mkdirSync = mkdir; + } + assert.equal(called, false); + assert.equal(readFileSync(target, 'utf8'), '{"count":0}'); + assert.deepEqual(updateState(target, {}, (stored) => ({ count: stored.count + 1 })), { count: 1 }); +}); + +test('a refused dead-owner reclaim obeys the same bounded wait without calling the mutator', async (t) => { + const { target, lock } = scratch(t); + const exited = spawnSync(process.execPath, ['-e', '']); + assert.equal(exited.status, 0); + const held = `${exited.pid}:deadbeef`; + writeFileSync(lock, held); + const script = ` + import fs from 'node:fs'; + import { updateState } from ${JSON.stringify(RUNTIME)}; + const [target, lock] = process.argv.slice(1); + const unlink = fs.unlinkSync; + let reclaims = 0; + fs.unlinkSync = (file) => { + if (String(file) === lock) { + reclaims += 1; + throw Object.assign(new Error('refused by fixture'), { code: 'EPERM' }); + } + return unlink(file); + }; + let called = false; + try { updateState(target, {}, () => { called = true; return {}; }); } + catch (error) { process.stdout.write(JSON.stringify({ code: error.code, called, reclaims })); } + `; + const child = spawn(process.execPath, ['--input-type=module', '-e', script, target, lock]); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => { stdout += data; }); + child.stderr.on('data', (data) => { stderr += data; }); + const watchdog = setTimeout(() => child.kill('SIGKILL'), 5_000); + const code = await new Promise((resolve) => child.on('close', resolve)); + clearTimeout(watchdog); + assert.equal(code, 0, stderr || 'a failed reclaim must not spin forever'); + const result = JSON.parse(stdout); + assert.equal(result.code, 'ELOCKED'); + assert.equal(result.called, false); + assert.ok(result.reclaims > 0); + assert.equal(readFileSync(target, 'utf8'), '{"count":0}'); + assert.equal(readFileSync(lock, 'utf8'), held); + assert.deepEqual(updateState(target, {}, (stored) => ({ count: stored.count + 1 })), { count: 1 }); +}); + +test('three state updaters preserve every increment while recovering one dead file lock', async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'evolver-state-lock-race-')); + const target = join(dir, 'state.json'); + const lock = `${target}.lock`; + writeFileSync(target, '{"count":0}'); + const exited = spawnSync(process.execPath, ['-e', '']); + assert.equal(exited.status, 0); + const dead = `${exited.pid}:deadbeef`; + writeFileSync(lock, dead); + const marker = (name) => join(dir, name); + const script = ` + import fs from 'node:fs'; + import path from 'node:path'; + import { updateState } from ${JSON.stringify(RUNTIME)}; + const [dir, target, lock, role] = process.argv.slice(1); + const marker = (name) => path.join(dir, name); + const wait = (name) => { + const deadline = Date.now() + 10000; + while (!fs.existsSync(marker(name))) { + if (Date.now() > deadline) throw new Error('timed out waiting for ' + name); + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10); + } + }; + // Start all contenders before orchestrating the lock interleaving. + updateState(marker(role + '-startup.json'), {}, () => undefined); + fs.writeFileSync(marker(role + '-ready'), ''); + wait(role + '-start'); + const read = fs.readFileSync; + let paused = false; + fs.readFileSync = (file, ...args) => { + const result = read(file, ...args); + if (role === 'A' && file === lock && !paused) { + paused = true; + fs.writeFileSync(marker('A-read-dead'), ''); + wait('A-resume'); + } + return result; + }; + const rename = fs.renameSync; + fs.renameSync = (source, destination) => { + try { return rename(source, destination); } + catch (error) { + if (destination === lock + '.admission') fs.writeFileSync(marker(role + '-guard-busy'), ''); + throw error; + } + }; + const write = fs.writeFileSync; + fs.writeFileSync = (file, ...args) => { + if (file === lock) write(marker(role + '-attempted'), ''); + return write(file, ...args); + }; + updateState(target, {}, (stored) => { + try { fs.writeFileSync(marker('active'), role, { flag: 'wx' }); } + catch { fs.writeFileSync(marker('overlap'), ''); } + fs.writeFileSync(marker(role + '-entered'), ''); + wait('release'); + if (fs.readFileSync(marker('active'), 'utf8') === role) fs.unlinkSync(marker('active')); + return { count: stored.count + 1 }; + }); + `; + const { run, results } = lockContenders(t, dir, script, [dir, target, lock], ['A-start', 'B-start', 'C-start', 'A-resume', 'release']); + for (const role of ['A', 'B', 'C']) run(role); + await until(() => ['A', 'B', 'C'].every((role) => existsSync(marker(role + '-ready')))); + writeFileSync(marker('A-start'), ''); + await until(() => existsSync(marker('A-read-dead'))); + writeFileSync(marker('B-start'), ''); + await until(() => existsSync(marker('B-entered')) || existsSync(marker('B-guard-busy'))); + assert.equal(existsSync(marker('B-entered')), false, 'the competing process waits for admission'); + writeFileSync(marker('A-resume'), ''); + await until(() => ['A', 'B'].some((role) => existsSync(marker(role + '-entered')))); + writeFileSync(marker('C-start'), ''); + await until(() => existsSync(marker('C-attempted')) || existsSync(marker('C-guard-busy'))); + writeFileSync(marker('release'), ''); + const outcomes = await results(); + assert.ok(outcomes.every(({ code }) => code === 0), JSON.stringify(outcomes)); + assert.equal(existsSync(marker('overlap')), false, 'state mutators never overlap during dead-owner recovery'); + assert.deepEqual(JSON.parse(readFileSync(target, 'utf8')), { count: 3 }); +});