From ac397091d8bf3ed84b4e8ce4412545785803662f Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Fri, 7 Aug 2026 13:12:20 +0300 Subject: [PATCH] fix(hooks): harden every native harness contract --- .well-known/mcp/server-card.json | 2 +- CHANGELOG.md | 25 ++- README.md | 36 +++-- cli.js | 4 +- package-lock.json | 4 +- package.json | 2 +- server.json | 4 +- src/daemon-bridge.js | 57 ++++++- src/doctor.js | 54 +++++++ src/hooks/adapter.js | 103 +++++++++++-- src/lifecycle.js | 49 ++++++ src/mcp-stdio.js | 3 +- src/openclaw-plugin/evaluate.js | 67 ++++++++ src/openclaw-plugin/index.js | 33 ++-- src/openclaw-plugin/openclaw.plugin.json | 11 +- src/openclaw-plugin/package.json | 2 +- src/setup/detect.js | 5 +- src/setup/harnesses/claude.js | 40 ++--- src/setup/harnesses/cline.js | 51 ++++--- src/setup/harnesses/codex.js | 37 ++++- src/setup/harnesses/continue.js | 43 ++++-- src/setup/harnesses/copilot.js | 31 +++- src/setup/harnesses/cursor.js | 4 +- src/setup/harnesses/gemini.js | 4 +- src/setup/harnesses/hermes.js | 29 +++- src/setup/harnesses/junie.js | 7 +- src/setup/harnesses/openclaw.js | 40 +++-- src/setup/harnesses/openhands.js | 47 +++--- src/setup/harnesses/picoclaw.js | 6 +- src/setup/harnesses/runtime.js | 14 +- src/setup/index.js | 2 +- src/tour.js | 11 ++ src/version.js | 4 + test/claude-setup.test.js | 8 + test/cli-help.test.js | 35 ++++- test/harness-config-contracts.test.js | 127 ++++++++++++++++ test/hermes-setup.test.js | 9 +- test/hook-adapter.test.js | 186 ++++++++++++++++++++++- test/native-harness-setup.test.js | 49 ++++-- test/openclaw-plugin.test.js | 72 +++++++++ test/release-contract.test.js | 10 +- test/smoke.test.js | 6 +- 42 files changed, 1131 insertions(+), 202 deletions(-) create mode 100644 src/doctor.js create mode 100644 src/lifecycle.js create mode 100644 src/openclaw-plugin/evaluate.js create mode 100644 src/tour.js create mode 100644 src/version.js create mode 100644 test/harness-config-contracts.test.js create mode 100644 test/openclaw-plugin.test.js diff --git a/.well-known/mcp/server-card.json b/.well-known/mcp/server-card.json index 4bb612b..49028fe 100644 --- a/.well-known/mcp/server-card.json +++ b/.well-known/mcp/server-card.json @@ -1,7 +1,7 @@ { "serverInfo": { "name": "pilotprotocol-mcp", - "version": "0.1.0" + "version": "0.2.12" }, "authentication": { "scheme": "local-daemon", diff --git a/CHANGELOG.md b/CHANGELOG.md index ab4664c..285c307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ All notable changes to the `pilotprotocol-mcp` npm adapter are documented here. ## [Unreleased] +## [0.2.12] - 2026-08-07 + +### Fixed +- Claude MCP registration now uses `~/.claude.json`, Cline uses its shared + `~/.cline` configuration, Copilot CLI uses `~/.copilot/mcp-config.json`, + OpenHands uses its post-1.0 `mcp.json`, and Junie uses + `~/.junie/mcp/mcp.json`. +- Continue, Codex, Hermes, OpenClaw, and PicoClaw setup now upgrades owned + entries idempotently, preserves unrelated configuration, enables the native + integration, and removes only obsolete Pilot-owned duplicates. +- Cline's current `tool` payload and its deployed `toolName` compatibility + shape are both normalized. Post-hook failures retain error, success, timing, + and result evidence for hosted tracing. +- Managed pre-hooks enforce a 20-second internal deadline and fail closed + before host-level timeout behavior can silently allow an action. OpenClaw's + native plugin applies the same deadline to tools and outbound messages. +- Version-pinned obsolete Claude heartbeat entries are migrated, while the + compatibility command remains a silent allow for already-running sessions. +- Runtime, registry, server-card, generated-hook, and OpenClaw plugin versions + are now release-contract tested against the npm package version. +- Generated hook and MCP commands now pin `pilotprotocol-mcp@0.2.12`. + ## [0.2.11] - 2026-08-07 ### Fixed @@ -92,7 +114,4 @@ All notable changes to the `pilotprotocol-mcp` npm adapter are documented here. ### Pending (not yet implemented) - Windows runtime bootstrap and per-platform npm binary subpackages. - Streamable HTTP transport (`pilot-mcp serve --http`). -- `pilot-mcp doctor` diagnostic. -- `pilot-mcp tour` first-run demo. - Privileged system-wide daemon service installation (the managed user runtime is installed and started automatically). -- Per-turn heartbeat hooks (Claude Code, OpenHands paths sketched). diff --git a/README.md b/README.md index 1f5ed9c..b206c58 100644 --- a/README.md +++ b/README.md @@ -101,27 +101,25 @@ npx -y pilotprotocol-mcp setup Or per-harness manual: ```bash -# Claude Code +# Claude Code — user MCP lives in ~/.claude.json; hooks live in ~/.claude/settings.json claude mcp add --transport stdio pilot -- npx -y pilotprotocol-mcp # Cursor — add to ~/.cursor/mcp.json {"mcpServers":{"pilot":{"command":"npx","args":["-y", "pilotprotocol-mcp"]}}} -# Cline — add to cline_mcp_settings.json (same JSON) +# Cline — add the same JSON to ~/.cline/data/settings/cline_mcp_settings.json -# Continue.dev — add to .continue/mcpServers/pilot.yaml -name: Pilot -version: 0.0.1 +# Continue.dev — merge into ~/.continue/config.yaml +name: My Continue Config +version: 1.0.0 schema: v1 mcpServers: - - name: pilot + - name: Pilot command: npx args: ["-y", "pilotprotocol-mcp"] -# OpenHands — add to ~/.openhands/config.toml -[mcp.stdio_servers.pilot] -command = "npx" -args = ["-y", "pilotprotocol-mcp"] +# OpenHands — add to ~/.openhands/mcp.json +{"mcpServers":{"pilot":{"command":"npx","args":["-y","pilotprotocol-mcp"]}}} # Hermes — add to ~/.hermes/config.yaml mcp_servers: @@ -135,15 +133,27 @@ command = "npx" args = ["-y", "pilotprotocol-mcp"] # PicoClaw — add to ~/.picoclaw/config.json -{"tools":{"mcp":{"servers":{"pilot":{"command":"npx","args":["-y", "pilotprotocol-mcp"]}}}}} +{"tools":{"mcp":{"enabled":true,"servers":{"pilot":{"enabled":true,"command":"npx","args":["-y", "pilotprotocol-mcp"]}}}}} + +# Copilot CLI — add standard MCP JSON to ~/.copilot/mcp-config.json + +# Junie CLI/IDE — add standard MCP JSON to ~/.junie/mcp/mcp.json + +# OpenClaw — setup installs and enables the Pilot Policy plugin +openclaw plugins inspect pilot-policy --runtime --json ``` -## Privacy +## Privacy and optional management - All overlay traffic flows **P2P over encrypted UDP** (AES-256-GCM, X25519 key exchange, Ed25519 identity). -- No telemetry. No SaaS in the middle. No API key registries. +- An unmanaged node does not upload tool calls and every installed policy hook + is a zero-side-effect pass-through. - Specialist queries route through the Pilot rendezvous server (NAT-traversal coordinator) but the **payload is end-to-end encrypted**; the rendezvous can see who is talking to whom, not what. - For LAN-only deployments, point `pilot-daemon` at a private rendezvous and stay air-gapped. +- When a node is explicitly adopted into Pilot Management, its pre/post action + envelopes—including tool inputs and results—are sent to the hosted federation + control plane for policy evaluation, approvals, and audit. That managed path + is opt-in and fail-closed for pre-action decisions. ## Comparison diff --git a/cli.js b/cli.js index b57c111..8f0fc1a 100755 --- a/cli.js +++ b/cli.js @@ -12,7 +12,6 @@ // pilot-mcp tour → first-run guided demo (one specialist call) // pilot-mcp export-identity → write identity to portable file // pilot-mcp import-identity → load identity from portable file -// pilot-mcp uninstall → reverse setup (remove harness configs, optionally stop daemon) // pilot-mcp → delegate to the platform pilotctl binary // // Critical: bare `pilot-mcp` invocation MUST start the stdio server immediately. @@ -91,7 +90,7 @@ async function main() { } case 'export-identity': case 'import-identity': - case 'uninstall': { + { const { runLifecycle } = await import('./src/lifecycle.js'); await runLifecycle(cmd, args.slice(1)); break; @@ -146,7 +145,6 @@ Usage: pilot-mcp tour Guided first-run demo pilot-mcp export-identity Write identity to portable file pilot-mcp import-identity Load identity from portable file - pilot-mcp uninstall Reverse setup pilot-mcp Delegate to underlying pilotctl Common pilotctl commands (auto-delegated): diff --git a/package-lock.json b/package-lock.json index 19fef4d..eafe08a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pilotprotocol-mcp", - "version": "0.2.11", + "version": "0.2.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pilotprotocol-mcp", - "version": "0.2.11", + "version": "0.2.12", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/package.json b/package.json index efbe5ad..5b45f52 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pilotprotocol-mcp", - "version": "0.2.11", + "version": "0.2.12", "mcpName": "io.github.pilot-protocol/pilot-mcp", "description": "Your agent's overlay network. MCP server exposing 436 Pilot specialist agents + P2P A2A messaging. One install configures every harness on your machine.", "type": "module", diff --git a/server.json b/server.json index 99ee918..e23223a 100644 --- a/server.json +++ b/server.json @@ -7,13 +7,13 @@ "url": "https://github.com/pilot-protocol/pilot-mcp", "source": "github" }, - "version": "0.2.11", + "version": "0.2.12", "websiteUrl": "https://pilotprotocol.network", "packages": [ { "registryType": "npm", "identifier": "pilotprotocol-mcp", - "version": "0.2.11", + "version": "0.2.12", "transport": { "type": "stdio" } } ] diff --git a/src/daemon-bridge.js b/src/daemon-bridge.js index faf026d..00dbfdd 100644 --- a/src/daemon-bridge.js +++ b/src/daemon-bridge.js @@ -71,27 +71,69 @@ export function daemonSocketPath() { export async function execPilotctl(args, opts = {}) { const bin = pilotctlBinaryPath(); + const timeoutMs = positiveNumber(opts.timeoutMs, 'timeoutMs'); + const maxBufferBytes = positiveNumber(opts.maxBufferBytes, 'maxBufferBytes'); return new Promise((resolve, reject) => { const child = spawn(bin, args, { stdio: opts.capture ? ['pipe', 'pipe', 'pipe'] : 'inherit', env: { ...process.env, ...(opts.env ?? {}) }, }); + let settled = false; + let killTimer; + const timer = timeoutMs === null ? null : setTimeout(() => { + if (settled) return; + settled = true; + const error = new Error(`pilotctl timed out after ${timeoutMs}ms`); + error.code = 'ETIMEDOUT'; + child.kill(); + killTimer = setTimeout(() => child.kill('SIGKILL'), 1000); + killTimer.unref?.(); + reject(error); + }, timeoutMs); + timer?.unref?.(); let stdout = ''; let stderr = ''; + let outputBytes = 0; if (opts.capture) { - child.stdout.on('data', (b) => { stdout += b.toString(); }); - child.stderr.on('data', (b) => { stderr += b.toString(); }); + const append = (channel, chunk) => { + if (settled) return; + outputBytes += chunk.byteLength; + if (maxBufferBytes !== null && outputBytes > maxBufferBytes) { + settled = true; + if (timer) clearTimeout(timer); + child.kill(); + reject(new Error(`pilotctl output exceeds ${maxBufferBytes} bytes`)); + return; + } + if (channel === 'stdout') stdout += chunk.toString(); + else stderr += chunk.toString(); + }; + child.stdout.on('data', (chunk) => append('stdout', chunk)); + child.stderr.on('data', (chunk) => append('stderr', chunk)); // A short-lived pilotctl may finish before Node flushes stdin. Linux // reports that normal close as EPIPE; without a listener it becomes an // uncaught process error even though the child's exit status and output // are already authoritative. child.stdin.on('error', (error) => { - if (error?.code !== 'EPIPE') reject(error); + if (error?.code !== 'EPIPE' && !settled) { + settled = true; + if (timer) clearTimeout(timer); + reject(error); + } }); child.stdin.end(opts.input ?? ''); } - child.on('error', reject); + child.on('error', (error) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + reject(error); + }); child.on('close', (code) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); if (opts.capture) { resolve({ code: code ?? 0, stdout, stderr }); } else { @@ -101,6 +143,13 @@ export async function execPilotctl(args, opts = {}) { }); } +function positiveNumber(value, name) { + if (value === undefined || value === null || value === '') return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${name} must be a positive number`); + return Math.floor(parsed); +} + export async function pilotctlJSON(args) { // Wrapper that adds --json and parses the response, with a clear error if // pilotctl is missing or daemon isn't reachable. diff --git a/src/doctor.js b/src/doctor.js new file mode 100644 index 0000000..11b8b76 --- /dev/null +++ b/src/doctor.js @@ -0,0 +1,54 @@ +import process from 'node:process'; +import { existsSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { daemonHealthy, pilotctlBinaryPath } from './daemon-bridge.js'; +import { detectHarnesses } from './setup/detect.js'; +import { VERSION } from './version.js'; + +export async function runDoctor(flags = {}, options = {}) { + const home = options.home ?? homedir(); + const write = options.write ?? ((value) => process.stdout.write(`${value}\n`)); + const report = { + version: VERSION, + runtime: runtimeCheck(), + daemon: { healthy: await daemonHealthy() }, + management: managedCheck(join(home, '.pilot', 'managed', 'enterprise-control.json')), + harnesses: (await detectHarnesses()).map(({ id, name }) => ({ id, name })), + }; + report.ok = report.runtime.ok && (report.management.attached ? report.management.secure : true); + if (flags.json) { + write(JSON.stringify(report)); + } else { + write(`Pilot adapter ${report.version}`); + write(`Runtime: ${report.runtime.ok ? report.runtime.path : report.runtime.error}`); + write(`Daemon: ${report.daemon.healthy ? 'reachable' : 'not reachable'}`); + write(`Management: ${report.management.attached ? (report.management.secure ? 'attached (owner-only)' : report.management.error) : 'not attached (unmanaged pass-through)'}`); + write(`Harnesses detected: ${report.harnesses.map((entry) => entry.id).join(', ') || 'none'}`); + } + if (!report.ok) process.exitCode = 1; + return report; +} + +function runtimeCheck() { + try { + return { ok: true, path: pilotctlBinaryPath() }; + } catch (error) { + return { ok: false, error: error.message }; + } +} + +function managedCheck(path) { + if (!existsSync(path)) return { attached: false, secure: true }; + try { + const stat = statSync(path); + if (!stat.isFile()) return { attached: true, secure: false, error: 'attachment is not a regular file' }; + if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) { + return { attached: true, secure: false, error: 'attachment permissions are not owner-only (0600)' }; + } + return { attached: true, secure: true }; + } catch (error) { + return { attached: true, secure: false, error: error.message }; + } +} diff --git a/src/hooks/adapter.js b/src/hooks/adapter.js index dfed476..b57e127 100644 --- a/src/hooks/adapter.js +++ b/src/hooks/adapter.js @@ -14,6 +14,8 @@ import { join } from 'node:path'; import { execPilotctl } from '../daemon-bridge.js'; const MAX_CONTENT_BYTES = 16 << 20; +const MAX_NATIVE_EVENT_BYTES = MAX_CONTENT_BYTES + (1 << 20); +const DEFAULT_HOOK_TIMEOUT_MS = 20_000; const SUPPORTED_HARNESSES = new Set([ 'claude', 'codex', 'gemini', 'openhands', 'copilot', 'cursor', 'cline', 'hermes', 'openclaw', 'picoclaw', @@ -25,9 +27,7 @@ export async function runHook(flags, io = defaultIO()) { if (!SUPPORTED_HARNESSES.has(harness)) { throw new Error(`unsupported hook harness ${harness || '(missing)'}`); } - const native = await readNativeEvent(io.stdin); - const phase = explicitPhase || eventPhase(native); - if (phase !== 'pre' && phase !== 'post') { + if (explicitPhase && explicitPhase !== 'pre' && explicitPhase !== 'post') { throw new Error('hook phase must be pre or post'); } const configuredPath = String(process.env.PILOT_ENTERPRISE_CONTROL ?? '').trim(); @@ -37,17 +37,25 @@ export async function runHook(flags, io = defaultIO()) { // node must behave exactly as it did before setup: no pilotctl dependency, // no content upload, no local inspection, and no altered host response. if (!controlPath) return { blocked: false, unmanaged: true }; + const native = await readNativeEvent(io.stdin); + const phase = explicitPhase || eventPhase(native); + if (phase !== 'pre' && phase !== 'post') { + throw new Error('hook phase must be pre or post'); + } const request = toPilotHookRequest(harness, phase, native); try { const result = await execPilotctl( ['enterprise', 'hook', phase, '--control', controlPath, '--json'], - { capture: true, input: JSON.stringify(request) }, + { capture: true, input: JSON.stringify(request), timeoutMs: hookTimeoutMs(), maxBufferBytes: 1 << 20 }, ); if (result.code !== 0) { throw new Error(cleanPilotctlError(result.stderr, result.stdout)); } const parsed = JSON.parse(result.stdout); const response = parsed?.data ?? parsed; + if (phase === 'pre' && typeof response?.execute !== 'boolean') { + throw new Error('Pilot control plane returned no authoritative execute decision'); + } if (phase === 'pre' && response.execute === false) { blockNativeHook(harness, formatBlockReason(response), io); return { blocked: true, response }; @@ -73,9 +81,18 @@ export async function runHook(flags, io = defaultIO()) { export function toPilotHookRequest(harness, phase, native) { const toolName = nativeToolName(harness, native); if (!toolName) throw new Error('native hook event is missing tool_name'); - const sessionID = stringValue(native.session_id ?? native.sessionId ?? native.conversation_id ?? 'session'); - const eventName = stringValue(native.hook_event_name ?? native.hookEventName ?? native.event_type ?? native.eventName ?? phase); - const input = parseJSONValue(native.tool_input ?? native.toolInput ?? native.toolArgs ?? native.arguments ?? native.parameters ?? native.preToolUse?.parameters ?? native.params ?? cursorSyntheticInput(native) ?? {}); + const sessionID = stringValue( + native.session_id ?? native.sessionId ?? native.conversation_id ?? native.taskId ?? native.task_id + ?? native.meta?.SessionKey ?? native.meta?.session_key ?? 'session', + ); + const eventName = stringValue( + native.hook_event_name ?? native.hookEventName ?? native.event_type ?? native.eventName ?? native.hookName ?? phase, + ); + const input = parseJSONValue( + native.tool_input ?? native.toolInput ?? native.toolArgs ?? native.arguments ?? native.parameters + ?? native.preToolUse?.parameters ?? native.postToolUse?.parameters ?? native.params + ?? cursorSyntheticInput(native) ?? {}, + ); const mapped = mapToolAction(toolName, input); const stableResume = digest({ harness, sessionID, toolName, action: mapped.action, resource: mapped.resource, input }); const nativeToolUseID = stringValue(native.tool_use_id ?? native.toolUseId ?? native.call_id ?? native.toolCallId ?? native.extra?.tool_call_id); @@ -89,7 +106,7 @@ export function toPilotHookRequest(harness, phase, native) { : { tool_name: toolName, tool_input: input, - tool_response: parseJSONValue(native.tool_response ?? native.toolResponse ?? native.toolResult ?? native.tool_output ?? native.output ?? native.result ?? native.postToolUse?.result ?? native.extra?.result ?? null), + tool_response: nativeToolResponse(native), }; const content = Buffer.from(JSON.stringify(requestContent), 'utf8'); if (content.byteLength > MAX_CONTENT_BYTES) { @@ -192,12 +209,19 @@ function compactAttributes(values) { function postStatus(eventName, native) { const name = String(eventName).toLowerCase(); - if (name.includes('failure') || name.includes('error') || native.success === false || native.error || native.extra?.error) return 'failed'; + if ( + name.includes('failure') || name.includes('error') + || native.success === false || native.postToolUse?.success === false + || native.error || native.error_message || native.errorMessage || native.failure_type + || native.postToolUse?.error || native.tool_response?.error || native.extra?.error + ) return 'failed'; return 'succeeded'; } function eventPhase(native) { - const name = String(native.hook_event_name ?? native.hookEventName ?? native.event_type ?? native.eventName ?? '').toLowerCase(); + const name = String( + native.hook_event_name ?? native.hookEventName ?? native.event_type ?? native.eventName ?? native.hookName ?? '', + ).toLowerCase(); if (name.startsWith('pre') || name.startsWith('before')) return 'pre'; if (name.startsWith('post') || name.startsWith('after')) return 'post'; return ''; @@ -237,7 +261,7 @@ function blockNativeHook(harness, reason, io) { return; } if (harness === 'cursor') { - io.stdout.write(`${JSON.stringify({ continue: true, permission: 'deny', user_message: reason, agent_message: reason })}\n`); + io.stdout.write(`${JSON.stringify({ permission: 'deny', user_message: reason, agent_message: reason })}\n`); return; } io.stderr.write(`${reason}\n`); @@ -246,10 +270,14 @@ function blockNativeHook(harness, reason, io) { function nativeToolName(harness, native) { const explicit = stringValue( - native.tool_name ?? native.toolName ?? native.tool ?? native.preToolUse?.toolName ?? native.postToolUse?.toolName, + native.tool_name ?? native.toolName ?? native.tool + ?? native.preToolUse?.tool ?? native.preToolUse?.toolName + ?? native.postToolUse?.tool ?? native.postToolUse?.toolName, ); if (explicit) return explicit; - const event = stringValue(native.hook_event_name ?? native.hookEventName ?? native.event_type ?? native.eventName).toLowerCase(); + const event = stringValue( + native.hook_event_name ?? native.hookEventName ?? native.event_type ?? native.eventName ?? native.hookName, + ).toLowerCase(); if (harness === 'cursor') { if (event.includes('shell') || native.command !== undefined) return 'shell'; if (event.includes('readfile') || native.file_path !== undefined) return 'read_file'; @@ -265,9 +293,56 @@ function cursorSyntheticInput(native) { return undefined; } +function nativeToolResponse(native) { + const result = firstDefined( + native.tool_response, + native.toolResponse, + native.toolResult, + native.tool_output, + native.output, + native.result, + native.postToolUse?.result, + native.extra?.result, + ); + const failure = compactObject({ + error: firstDefined(native.error, native.error_message, native.errorMessage, native.postToolUse?.error, native.extra?.error), + failure_type: native.failure_type, + is_interrupt: native.is_interrupt, + success: firstDefined(native.success, native.postToolUse?.success), + duration_ms: firstDefined( + native.duration_ms, native.durationMs, native.executionTimeMs, + native.postToolUse?.durationMs, native.postToolUse?.executionTimeMs, native.extra?.duration_ms, + ), + }); + if (Object.keys(failure).length === 0) return parseJSONValue(result ?? null); + if (result !== undefined && result !== null) failure.result = parseJSONValue(result); + return failure; +} + +function firstDefined(...values) { + return values.find((value) => value !== undefined); +} + +function compactObject(values) { + return Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined && value !== null && value !== '')); +} + +function hookTimeoutMs(env = process.env) { + const configured = Number(env.PILOT_HOOK_TIMEOUT_MS ?? DEFAULT_HOOK_TIMEOUT_MS); + if (!Number.isFinite(configured) || configured < 50 || configured > 25_000) { + throw new Error('PILOT_HOOK_TIMEOUT_MS must be between 50 and 25000 milliseconds'); + } + return Math.floor(configured); +} + async function readNativeEvent(stream) { let body = ''; - for await (const chunk of stream) body += chunk.toString(); + let bytes = 0; + for await (const chunk of stream) { + bytes += Buffer.byteLength(chunk); + if (bytes > MAX_NATIVE_EVENT_BYTES) throw new Error(`native hook event exceeds ${MAX_NATIVE_EVENT_BYTES} bytes`); + body += chunk.toString(); + } if (!body.trim()) throw new Error('native hook event JSON is required on stdin'); return JSON.parse(body); } diff --git a/src/lifecycle.js b/src/lifecycle.js new file mode 100644 index 0000000..3ff9b16 --- /dev/null +++ b/src/lifecycle.js @@ -0,0 +1,49 @@ +import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; + +export async function runLifecycle(command, args, options = {}) { + const home = options.home ?? homedir(); + const identity = join(home, '.pilot', 'identity.json'); + const force = args.includes('--force'); + const file = args.find((arg) => !arg.startsWith('--')); + if (command === 'export-identity') { + if (!existsSync(identity)) throw new Error(`Pilot identity not found: ${identity}`); + const destination = resolve(file ?? join(process.cwd(), 'pilot-identity.json')); + if (existsSync(destination) && !force) throw new Error(`destination already exists: ${destination} (pass --force to replace)`); + const value = parseIdentity(readFileSync(identity, 'utf8'), identity); + mkdirSync(dirname(destination), { recursive: true }); + writeFileSync(destination, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + chmodSync(destination, 0o600); + process.stdout.write(`Exported Pilot identity to ${destination}\n`); + return destination; + } + if (command === 'import-identity') { + if (!file) throw new Error('import-identity requires a source file'); + const source = resolve(file); + const stat = lstatSync(source); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('identity source must be a regular, non-symbolic-link file'); + if (existsSync(identity) && !force) throw new Error(`identity already exists: ${identity} (pass --force to replace)`); + const value = parseIdentity(readFileSync(source, 'utf8'), source); + mkdirSync(dirname(identity), { recursive: true }); + writeFileSync(identity, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + chmodSync(identity, 0o600); + process.stdout.write(`Imported Pilot identity into ${identity}\n`); + return identity; + } + throw new Error('adapter uninstall is not yet available; remove Pilot through each harness and core runtime explicitly'); +} + +function parseIdentity(source, path) { + let value; + try { + value = JSON.parse(source); + } catch { + throw new Error(`identity is not valid JSON: ${path}`); + } + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0) { + throw new Error(`identity must be a non-empty JSON object: ${path}`); + } + return value; +} diff --git a/src/mcp-stdio.js b/src/mcp-stdio.js index 4aaebf6..7b668ab 100644 --- a/src/mcp-stdio.js +++ b/src/mcp-stdio.js @@ -17,12 +17,13 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { registerTools } from './tools/index.js'; import { registerResources } from './resources/index.js'; import { registerPrompts } from './prompts/index.js'; +import { VERSION } from './version.js'; export async function runStdio() { const server = new Server( { name: 'pilot-mcp', - version: '0.2.1', + version: VERSION, }, { capabilities: { diff --git a/src/openclaw-plugin/evaluate.js b/src/openclaw-plugin/evaluate.js new file mode 100644 index 0000000..bff11da --- /dev/null +++ b/src/openclaw-plugin/evaluate.js @@ -0,0 +1,67 @@ +import { spawn } from 'node:child_process'; + +const PACKAGE_SPEC = 'pilotprotocol-mcp@0.2.12'; +const DEFAULT_TIMEOUT_MS = 20_000; +const MAX_STDERR_BYTES = 1 << 20; + +export async function evaluate(phase, event, options = {}) { + const spawnProcess = options.spawn ?? spawn; + const timeoutMs = hookTimeoutMs(options.env ?? process.env); + return new Promise((resolve, reject) => { + let settled = false; + let stderr = ''; + let stderrBytes = 0; + const child = spawnProcess('npx', ['-y', PACKAGE_SPEC, 'hook', '--harness', 'openclaw', '--phase', phase], { + stdio: ['pipe', 'pipe', 'pipe'], env: options.env ?? process.env, + }); + const finish = (error, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) reject(error); + else resolve(value); + }; + const timer = setTimeout(() => { + child.kill(); + const forceKill = setTimeout(() => child.kill('SIGKILL'), 1000); + forceKill.unref?.(); + const reason = `Pilot control plane timed out after ${timeoutMs}ms`; + if (phase === 'pre') finish(null, { blocked: true, reason }); + else finish(new Error(reason)); + }, timeoutMs); + timer.unref?.(); + child.stderr.on('data', (chunk) => { + stderrBytes += chunk.byteLength; + if (stderrBytes > MAX_STDERR_BYTES) { + child.kill(); + const error = new Error(`Pilot hook stderr exceeds ${MAX_STDERR_BYTES} bytes`); + if (phase === 'pre') finish(null, { blocked: true, reason: error.message }); + else finish(error); + return; + } + stderr += chunk.toString(); + }); + child.on('error', (error) => { + if (phase === 'pre') finish(null, { blocked: true, reason: `Pilot control plane unavailable: ${error.message}` }); + else finish(error); + }); + child.on('close', (code) => { + if (code === 0) return finish(null, { blocked: false }); + const reason = stderr.trim() || `Pilot hook exited ${code}`; + if (phase === 'pre') return finish(null, { blocked: true, reason }); + return finish(new Error(reason)); + }); + child.stdin.on('error', (error) => { + if (error?.code === 'EPIPE') return; + if (phase === 'pre') finish(null, { blocked: true, reason: `Pilot hook input failed: ${error.message}` }); + else finish(error); + }); + child.stdin.end(JSON.stringify(event)); + }); +} + +function hookTimeoutMs(env) { + const configured = Number(env.PILOT_OPENCLAW_HOOK_TIMEOUT_MS ?? env.PILOT_HOOK_TIMEOUT_MS ?? DEFAULT_TIMEOUT_MS); + if (!Number.isFinite(configured) || configured < 50 || configured > 25_000) return DEFAULT_TIMEOUT_MS; + return Math.floor(configured); +} diff --git a/src/openclaw-plugin/index.js b/src/openclaw-plugin/index.js index 9982438..866b461 100644 --- a/src/openclaw-plugin/index.js +++ b/src/openclaw-plugin/index.js @@ -1,22 +1,5 @@ -import { spawn } from 'node:child_process'; import { definePluginEntry } from 'openclaw/plugin-sdk/core'; - -async function evaluate(phase, event) { - return new Promise((resolve, reject) => { - const child = spawn('npx', ['-y', 'pilotprotocol-mcp', 'hook', '--harness', 'openclaw', '--phase', phase], { - stdio: ['pipe', 'pipe', 'pipe'], env: process.env, - }); - let stderr = ''; - child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - child.on('error', reject); - child.on('close', (code) => { - if (phase === 'pre' && code === 2) return resolve({ blocked: true, reason: stderr.trim() || 'Pilot denied this action.' }); - if (code !== 0) return reject(new Error(stderr.trim() || `Pilot hook exited ${code}`)); - resolve({ blocked: false }); - }); - child.stdin.end(JSON.stringify(event)); - }); -} +import { evaluate } from './evaluate.js'; export default definePluginEntry({ id: 'pilot-policy', @@ -31,11 +14,15 @@ export default definePluginEntry({ return decision.blocked ? { block: true, blockReason: decision.reason } : undefined; }, { priority: 100, timeoutMs: 30000 }); api.on('after_tool_call', async (event, ctx) => { - await evaluate('post', { - hook_event_name: 'after_tool_call', tool_name: event.toolName, tool_input: event.params, - tool_response: event.result, error: event.error, - session_id: ctx.sessionId ?? ctx.sessionKey ?? event.runId, tool_use_id: event.toolCallId, - }); + try { + await evaluate('post', { + hook_event_name: 'after_tool_call', tool_name: event.toolName, tool_input: event.params, + tool_response: event.result, error: event.error, + session_id: ctx.sessionId ?? ctx.sessionKey ?? event.runId, tool_use_id: event.toolCallId, + }); + } catch (error) { + api.logger?.warn?.(`Pilot post-hook evidence failure: ${error.message}`); + } }, { priority: 100, timeoutMs: 30000 }); api.on('message_sending', async (event, ctx) => { const decision = await evaluate('pre', { diff --git a/src/openclaw-plugin/openclaw.plugin.json b/src/openclaw-plugin/openclaw.plugin.json index 8a7dad3..0bdd163 100644 --- a/src/openclaw-plugin/openclaw.plugin.json +++ b/src/openclaw-plugin/openclaw.plugin.json @@ -1,7 +1,14 @@ { "id": "pilot-policy", "name": "Pilot Policy", - "version": "0.2.1", + "version": "0.2.2", "description": "Hosted Pilot pre/post action and outbound-message controls for OpenClaw.", - "configSchema": { "type": "object", "additionalProperties": false, "properties": {} } + "configSchema": { "type": "object", "additionalProperties": false, "properties": {} }, + "mcpServers": { + "pilot": { + "transport": "stdio", + "command": "npx", + "args": ["-y", "pilotprotocol-mcp@0.2.12"] + } + } } diff --git a/src/openclaw-plugin/package.json b/src/openclaw-plugin/package.json index 6ffbc8a..8793755 100644 --- a/src/openclaw-plugin/package.json +++ b/src/openclaw-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@pilotprotocol/openclaw-policy", - "version": "0.2.1", + "version": "0.2.2", "type": "module", "main": "index.js", "openclaw": { "extensions": ["./index.js"] }, diff --git a/src/setup/detect.js b/src/setup/detect.js index fa47f16..cb1d6dd 100644 --- a/src/setup/detect.js +++ b/src/setup/detect.js @@ -27,8 +27,11 @@ const PROBES = [ ]; function clineSettingsPath() { - // Cline lives inside VS Code's per-user storage. Probe both platforms. + // Current Cline CLI/IDE/SDK builds share ~/.cline. Keep legacy VS Code + // storage probes so an older desktop install is still discovered and + // migrated during setup. const candidates = [ + join(HOME, '.cline'), join(HOME, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'), join(HOME, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'), join(HOME, 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'), diff --git a/src/setup/harnesses/claude.js b/src/setup/harnesses/claude.js index 4ac40a4..a5d7ac4 100644 --- a/src/setup/harnesses/claude.js +++ b/src/setup/harnesses/claude.js @@ -7,11 +7,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; -import { execPilotctl } from '../../daemon-bridge.js'; -import { hookCommand, isPilotHookCommand } from './runtime.js'; +import { hookCommand, isPilotHookCommand, pilotMcpServer } from './runtime.js'; const HOME = homedir(); const SETTINGS = join(HOME, '.claude', 'settings.json'); +const MCP_CONFIG = join(HOME, '.claude.json'); export async function configure() { mkdirSync(dirname(SETTINGS), { recursive: true }); @@ -20,24 +20,24 @@ export async function configure() { } async function registerMcp() { - // Try `claude mcp add` first — works in current Claude Code and properly - // updates settings.json with the right schema. - try { - await execPilotctl([], { capture: true }); - // claude mcp add --transport stdio pilot -- npx -y pilotprotocol-mcp - // Not via pilotctl — shell out to claude itself if on PATH. Skipping the - // shell-out skeleton for brevity; the fallback below covers the case - // where `claude` isn't on PATH. - } catch { /* fall through */ } - - // Direct JSON edit fallback. - const current = existsSync(SETTINGS) ? JSON.parse(readFileSync(SETTINGS, 'utf8')) : {}; + // Claude Code stores user-scope MCP servers in ~/.claude.json. Hooks remain + // in ~/.claude/settings.json; putting mcpServers there looks plausible but + // is not loaded by current Claude Code. + const current = existsSync(MCP_CONFIG) ? JSON.parse(readFileSync(MCP_CONFIG, 'utf8')) : {}; current.mcpServers = current.mcpServers ?? {}; - current.mcpServers.pilot = { - command: 'npx', - args: ['-y', 'pilotprotocol-mcp@0.2.11'], - }; - writeFileSync(SETTINGS, JSON.stringify(current, null, 2)); + current.mcpServers.pilot = pilotMcpServer(); + writeFileSync(MCP_CONFIG, JSON.stringify(current, null, 2)); + + // Repair the misplaced entry written by older Pilot releases without + // touching any user-owned MCP entry or setting. + if (existsSync(SETTINGS)) { + const settings = JSON.parse(readFileSync(SETTINGS, 'utf8')); + if (settings.mcpServers?.pilot) { + delete settings.mcpServers.pilot; + if (Object.keys(settings.mcpServers).length === 0) delete settings.mcpServers; + writeFileSync(SETTINGS, JSON.stringify(settings, null, 2)); + } + } } async function installHook() { @@ -68,7 +68,7 @@ export function removeObsoletePromptHook(hooks) { function isObsoletePromptCommand(command) { if (typeof command !== 'string') return false; - return /(?:^|\s)(?:pilot-mcp|pilotprotocol-mcp)\s+heartbeat\s+--claude(?:\s|$)/.test(command); + return /(?:^|\s)(?:pilot-mcp|pilotprotocol-mcp)(?:@[^\s]+)?\s+heartbeat\s+--claude(?:\s|$)/.test(command); } function installToolHook(hooks, event, phase) { diff --git a/src/setup/harnesses/cline.js b/src/setup/harnesses/cline.js index 822d019..2305255 100644 --- a/src/setup/harnesses/cline.js +++ b/src/setup/harnesses/cline.js @@ -2,14 +2,14 @@ // Also drops .clinerules/pilot.md since Cline #5033 (AGENTS.md support) is // still closed unmerged. -import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { chmodSync, existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir, platform } from 'node:os'; -import { hookCommand, isPilotHookCommand } from './runtime.js'; +import { hookCommand, isPilotHookCommand, pilotMcpServer } from './runtime.js'; const HOME = homedir(); -function settingsPath() { +function legacySettingsPath() { switch (platform()) { case 'darwin': return join(HOME, 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'); case 'linux': return join(HOME, '.config', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'); @@ -19,30 +19,45 @@ function settingsPath() { } export async function configure() { - const settings = settingsPath(); - mkdirSync(dirname(settings), { recursive: true }); - const current = existsSync(settings) ? JSON.parse(readFileSync(settings, 'utf8')) : {}; - current.mcpServers = current.mcpServers ?? {}; - current.mcpServers.pilot = { command: 'npx', args: ['-y', 'pilotprotocol-mcp@0.2.11'] }; - writeFileSync(settings, JSON.stringify(current, null, 2)); - installNativeHook('PreToolUse', 'pre'); - installNativeHook('PostToolUse', 'post'); + const canonical = join(HOME, '.cline', 'data', 'settings', 'cline_mcp_settings.json'); + const paths = [canonical, ...(existsSync(legacySettingsPath()) ? [legacySettingsPath()] : [])]; + for (const settings of new Set(paths)) { + mkdirSync(dirname(settings), { recursive: true }); + const current = existsSync(settings) ? JSON.parse(readFileSync(settings, 'utf8')) : {}; + current.mcpServers = current.mcpServers ?? {}; + current.mcpServers.pilot = pilotMcpServer(); + writeFileSync(settings, JSON.stringify(current, null, 2)); + } + installNativeHook('PreToolUse', 'pre', platform()); + installNativeHook('PostToolUse', 'post', platform()); } -function installNativeHook(event, phase) { - const directory = join(HOME, 'Documents', 'Cline', 'Hooks'); - const target = join(directory, event); +export function installNativeHook(event, phase, os = platform()) { + const directory = join(HOME, '.cline', 'hooks'); + const target = join(directory, os === 'win32' ? `${event}.ps1` : event); const marker = hookCommand('cline', phase); + const content = os === 'win32' + ? `& ${marker}\nexit $LASTEXITCODE\n` + : `#!/bin/sh\nexec ${marker}\n`; if (existsSync(target)) { const existing = readFileSync(target, 'utf8'); if (isPilotHookCommand(existing, 'cline', phase)) { - if (!existing.includes(marker)) writeFileSync(target, `#!/bin/sh\nexec ${marker}\n`, { mode: 0o700 }); - chmodSync(target, 0o700); + if (!existing.includes(marker)) writeFileSync(target, content, { mode: 0o700 }); + if (os !== 'win32') chmodSync(target, 0o700); + removeOwnedCompatibilityHook(event, phase, os); return; } throw new Error(`Cline already has a global ${event} hook at ${target}; install Pilot as a workspace hook or compose the scripts explicitly`); } mkdirSync(directory, { recursive: true }); - writeFileSync(target, `#!/bin/sh\nexec ${marker}\n`, { mode: 0o700 }); - chmodSync(target, 0o700); + writeFileSync(target, content, { mode: 0o700 }); + if (os !== 'win32') chmodSync(target, 0o700); + removeOwnedCompatibilityHook(event, phase, os); +} + +function removeOwnedCompatibilityHook(event, phase, os) { + const legacy = join(HOME, 'Documents', 'Cline', 'Hooks', os === 'win32' ? `${event}.ps1` : event); + if (!existsSync(legacy)) return; + const source = readFileSync(legacy, 'utf8'); + if (isPilotHookCommand(source, 'cline', phase)) unlinkSync(legacy); } diff --git a/src/setup/harnesses/codex.js b/src/setup/harnesses/codex.js index 20641e2..4d55d06 100644 --- a/src/setup/harnesses/codex.js +++ b/src/setup/harnesses/codex.js @@ -6,29 +6,50 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; -import { hookCommand, isPilotHookCommand } from './runtime.js'; +import { hookCommand, isPilotHookCommand, PILOT_PACKAGE_SPEC } from './runtime.js'; const HOME = homedir(); const CONFIG = join(HOME, '.codex', 'config.toml'); const HOOKS = join(HOME, '.codex', 'hooks.json'); -const BLOCK = ` -[mcp_servers.pilot] -command = "npx" -args = ["-y", "pilotprotocol-mcp@0.2.11"] -`; +function pilotBlock() { + return `[mcp_servers.pilot]\ncommand = "npx"\nargs = ["-y", "${PILOT_PACKAGE_SPEC}"]\n`; +} export async function configure() { if (existsSync(CONFIG)) { const current = readFileSync(CONFIG, 'utf8'); - if (!current.includes('[mcp_servers.pilot]')) writeFileSync(CONFIG, current + BLOCK); + writeFileSync(CONFIG, upsertPilotMcpBlock(current)); } else { mkdirSync(dirname(CONFIG), { recursive: true }); - writeFileSync(CONFIG, BLOCK.trimStart()); + writeFileSync(CONFIG, pilotBlock()); } installHooks(); } +export function upsertPilotMcpBlock(source) { + const lines = String(source).split(/(?<=\n)/); + const retained = []; + let replaced = false; + let skipping = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '[mcp_servers.pilot]') { + if (!replaced) retained.push(`${retained.length && !retained.at(-1).endsWith('\n\n') ? '\n' : ''}${pilotBlock()}`); + replaced = true; + skipping = true; + continue; + } + if (skipping && trimmed.startsWith('[')) skipping = false; + if (!skipping) retained.push(line); + } + if (!replaced) { + const separator = retained.length && !retained.join('').endsWith('\n\n') ? '\n' : ''; + retained.push(`${separator}${pilotBlock()}`); + } + return retained.join(''); +} + function installHooks() { const current = existsSync(HOOKS) ? JSON.parse(readFileSync(HOOKS, 'utf8')) : {}; current.description = current.description ?? 'Optional local Codex hooks, including Pilot policy enforcement.'; diff --git a/src/setup/harnesses/continue.js b/src/setup/harnesses/continue.js index e56be6e..9c42a8a 100644 --- a/src/setup/harnesses/continue.js +++ b/src/setup/harnesses/continue.js @@ -1,22 +1,39 @@ -// Continue.dev: write .continue/mcpServers/pilot.yaml. +// Continue.dev: merge Pilot into the current global config.yaml agent format. -import { writeFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; +import { parseDocument } from 'yaml'; +import { PILOT_PACKAGE_SPEC, pilotMcpServer } from './runtime.js'; const HOME = homedir(); -const TARGET = join(HOME, '.continue', 'mcpServers', 'pilot.yaml'); - -const CONTENT = `name: Pilot -version: 0.1.0 -schema: v1 -mcpServers: - - name: pilot - command: npx - args: ["-y", "pilotprotocol-mcp@0.2.11"] -`; +const TARGET = join(HOME, '.continue', 'config.yaml'); +const LEGACY_TARGET = join(HOME, '.continue', 'mcpServers', 'pilot.yaml'); export async function configure() { mkdirSync(dirname(TARGET), { recursive: true }); - writeFileSync(TARGET, CONTENT); + const source = existsSync(TARGET) + ? readFileSync(TARGET, 'utf8') + : 'name: Pilot-enabled Continue\nversion: 1.0.0\nschema: v1\nmcpServers: []\n'; + const document = parseDocument(source.trim() ? source : '{}\n'); + if (document.errors.length) throw new Error(`cannot merge Continue YAML: ${document.errors[0].message}`); + const entries = document.getIn(['mcpServers'], true)?.toJSON?.() ?? []; + if (!Array.isArray(entries)) throw new Error('Continue mcpServers must be a sequence'); + const retained = entries.filter((entry) => !isPilotEntry(entry)); + retained.push({ name: 'Pilot', ...pilotMcpServer() }); + document.setIn(['mcpServers'], retained); + writeFileSync(TARGET, String(document)); + + // Releases <=0.2.11 wrote an isolated block file. Remove only a file that + // unmistakably belongs to Pilot so Continue does not load the server twice. + if (existsSync(LEGACY_TARGET)) { + const legacy = readFileSync(LEGACY_TARGET, 'utf8'); + if (/pilotprotocol-mcp(?:@[^\s"']+)?/.test(legacy)) unlinkSync(LEGACY_TARGET); + } +} + +function isPilotEntry(entry) { + return String(entry?.name ?? '').toLowerCase() === 'pilot' + || (Array.isArray(entry?.args) && entry.args.some((arg) => String(arg).startsWith('pilotprotocol-mcp@'))) + || entry?.args?.includes?.(PILOT_PACKAGE_SPEC); } diff --git a/src/setup/harnesses/copilot.js b/src/setup/harnesses/copilot.js index cf77fd0..c668117 100644 --- a/src/setup/harnesses/copilot.js +++ b/src/setup/harnesses/copilot.js @@ -5,7 +5,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir, platform } from 'node:os'; -import { hookCommand, isPilotHookCommand } from './runtime.js'; +import { hookCommand, isPilotHookCommand, pilotMcpServer } from './runtime.js'; const HOME = homedir(); @@ -19,13 +19,32 @@ function vsCodeSettingsPath() { } export async function configure() { + const config = join(HOME, '.copilot', 'mcp-config.json'); + mkdirSync(dirname(config), { recursive: true }); + const current = existsSync(config) ? JSON.parse(readFileSync(config, 'utf8')) : {}; + current.mcpServers = current.mcpServers ?? {}; + current.mcpServers.pilot = pilotMcpServer(); + writeFileSync(config, JSON.stringify(current, null, 2)); + removeObsoleteVSCodeEntry(); + installHooks(); +} + +function removeObsoleteVSCodeEntry() { const settings = vsCodeSettingsPath(); - mkdirSync(dirname(settings), { recursive: true }); - const current = existsSync(settings) ? JSON.parse(readFileSync(settings, 'utf8')) : {}; - current['github.copilot.chat.mcp.servers'] = current['github.copilot.chat.mcp.servers'] ?? {}; - current['github.copilot.chat.mcp.servers'].pilot = { command: 'npx', args: ['-y', 'pilotprotocol-mcp@0.2.11'] }; + if (!existsSync(settings)) return; + let current; + try { + current = JSON.parse(readFileSync(settings, 'utf8')); + } catch { + // VS Code settings may be JSONC. Leaving an inert legacy entry is safer + // than rewriting a commented user file with a lossy parser. + return; + } + const key = 'github.copilot.chat.mcp.servers'; + if (!current[key]?.pilot) return; + delete current[key].pilot; + if (Object.keys(current[key]).length === 0) delete current[key]; writeFileSync(settings, JSON.stringify(current, null, 2)); - installHooks(); } function installHooks() { diff --git a/src/setup/harnesses/cursor.js b/src/setup/harnesses/cursor.js index b3ecf6d..a1e4490 100644 --- a/src/setup/harnesses/cursor.js +++ b/src/setup/harnesses/cursor.js @@ -7,7 +7,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; -import { hookCommand, isPilotHookCommand } from './runtime.js'; +import { hookCommand, isPilotHookCommand, pilotMcpServer } from './runtime.js'; const HOME = homedir(); const MCP_JSON = join(HOME, '.cursor', 'mcp.json'); @@ -17,7 +17,7 @@ export async function configure() { mkdirSync(dirname(MCP_JSON), { recursive: true }); const current = existsSync(MCP_JSON) ? JSON.parse(readFileSync(MCP_JSON, 'utf8')) : {}; current.mcpServers = current.mcpServers ?? {}; - current.mcpServers.pilot = { command: 'npx', args: ['-y', 'pilotprotocol-mcp@0.2.11'] }; + current.mcpServers.pilot = pilotMcpServer(); writeFileSync(MCP_JSON, JSON.stringify(current, null, 2)); const hooks = existsSync(HOOKS_JSON) ? JSON.parse(readFileSync(HOOKS_JSON, 'utf8')) : { version: 1, hooks: {} }; diff --git a/src/setup/harnesses/gemini.js b/src/setup/harnesses/gemini.js index e79d0df..9c07352 100644 --- a/src/setup/harnesses/gemini.js +++ b/src/setup/harnesses/gemini.js @@ -3,7 +3,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; -import { hookCommand, isPilotHookCommand } from './runtime.js'; +import { hookCommand, isPilotHookCommand, pilotMcpServer } from './runtime.js'; const SETTINGS = join(homedir(), '.gemini', 'settings.json'); @@ -11,7 +11,7 @@ export async function configure() { mkdirSync(dirname(SETTINGS), { recursive: true }); const current = existsSync(SETTINGS) ? JSON.parse(readFileSync(SETTINGS, 'utf8')) : {}; current.mcpServers = current.mcpServers ?? {}; - current.mcpServers.pilot = { command: 'npx', args: ['-y', 'pilotprotocol-mcp@0.2.11'] }; + current.mcpServers.pilot = pilotMcpServer(); current.hooksConfig = current.hooksConfig ?? {}; if (current.hooksConfig.enabled === undefined) current.hooksConfig.enabled = true; current.hooks = current.hooks ?? {}; diff --git a/src/setup/harnesses/hermes.js b/src/setup/harnesses/hermes.js index e57852d..f406c91 100644 --- a/src/setup/harnesses/hermes.js +++ b/src/setup/harnesses/hermes.js @@ -8,9 +8,10 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { parseDocument } from 'yaml'; -import { hookCommand, isPilotHookCommand } from './runtime.js'; +import { hookCommand, isPilotHookCommand, pilotMcpServer } from './runtime.js'; const CONFIG = join(homedir(), '.hermes', 'config.yaml'); +const ALLOWLIST = join(homedir(), '.hermes', 'shell-hooks-allowlist.json'); export async function configure() { mkdirSync(dirname(CONFIG), { recursive: true }); @@ -19,13 +20,31 @@ export async function configure() { if (document.errors.length) { throw new Error(`cannot merge Hermes YAML: ${document.errors[0].message}`); } - document.setIn(['mcp_servers', 'pilot'], { - command: 'npx', - args: ['-y', 'pilotprotocol-mcp@0.2.11'], - }); + document.setIn(['mcp_servers', 'pilot'], pilotMcpServer()); installHook(document, 'pre_tool_call', 'pre'); installHook(document, 'post_tool_call', 'post'); writeFileSync(CONFIG, String(document)); + installConsent(); +} + +function installConsent() { + const current = existsSync(ALLOWLIST) ? JSON.parse(readFileSync(ALLOWLIST, 'utf8')) : {}; + const approvals = Array.isArray(current.approvals) ? current.approvals : []; + const desired = [ + { event: 'pre_tool_call', command: hookCommand('hermes', 'pre') }, + { event: 'post_tool_call', command: hookCommand('hermes', 'post') }, + ]; + current.approvals = approvals.filter((approval) => { + if (!approval || typeof approval !== 'object') return true; + const phase = approval.event === 'pre_tool_call' ? 'pre' : approval.event === 'post_tool_call' ? 'post' : ''; + return !phase || !isPilotHookCommand(approval.command, 'hermes', phase); + }); + for (const approval of desired) { + if (!current.approvals.some((entry) => entry?.event === approval.event && entry?.command === approval.command)) { + current.approvals.push(approval); + } + } + writeFileSync(ALLOWLIST, JSON.stringify(current, null, 2)); } function installHook(document, event, phase) { diff --git a/src/setup/harnesses/junie.js b/src/setup/harnesses/junie.js index 083d77b..00d115c 100644 --- a/src/setup/harnesses/junie.js +++ b/src/setup/harnesses/junie.js @@ -6,18 +6,19 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; +import { pilotMcpServer } from './runtime.js'; const HOME = homedir(); -const CLI_CONFIG = join(HOME, '.junie', 'config.json'); +const CLI_CONFIG = join(HOME, '.junie', 'mcp', 'mcp.json'); export async function configure() { if (!existsSync(CLI_CONFIG)) { mkdirSync(dirname(CLI_CONFIG), { recursive: true }); - writeFileSync(CLI_CONFIG, JSON.stringify({ mcpServers: { pilot: { command: 'npx', args: ['-y', 'pilotprotocol-mcp@0.2.11'] } } }, null, 2)); + writeFileSync(CLI_CONFIG, JSON.stringify({ mcpServers: { pilot: pilotMcpServer() } }, null, 2)); return; } const current = JSON.parse(readFileSync(CLI_CONFIG, 'utf8')); current.mcpServers = current.mcpServers ?? {}; - current.mcpServers.pilot = { command: 'npx', args: ['-y', 'pilotprotocol-mcp@0.2.11'] }; + current.mcpServers.pilot = pilotMcpServer(); writeFileSync(CLI_CONFIG, JSON.stringify(current, null, 2)); } diff --git a/src/setup/harnesses/openclaw.js b/src/setup/harnesses/openclaw.js index e2e8e80..79f5e76 100644 --- a/src/setup/harnesses/openclaw.js +++ b/src/setup/harnesses/openclaw.js @@ -1,10 +1,6 @@ -// OpenClaw: defer to the @openclaw/pilot extension if installed; otherwise -// write a minimal MCP server registration into the user's OpenClaw config. -// -// Best path: the openclaw/extensions/pilot channel plugin already exists in -// the OpenClaw monorepo (TypeScript, registers before_prompt_build hook + -// channel plugin + 5 tools). That gives per-turn injection plus native channel -// integration that MCP alone cannot match. +// OpenClaw: install and enable Pilot's native policy plugin. The plugin manifest +// owns its MCP server definition, while runtime hooks enforce tools and outbound +// messages before the host's side effect occurs. import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; @@ -20,14 +16,32 @@ const INSTALLED_PLUGIN = join(HOME, '.pilot', 'integrations', 'openclaw-policy') const execFileAsync = promisify(execFile); export async function configure() { - if (!existsSync(CONFIG)) return; // OpenClaw not configured yet — caller already skipped - const current = JSON.parse(readFileSync(CONFIG, 'utf8')); - current.mcpServers = current.mcpServers ?? {}; - current.mcpServers.pilot = { command: 'npx', args: ['-y', 'pilotprotocol-mcp@0.2.11'] }; - writeFileSync(CONFIG, JSON.stringify(current, null, 2)); + removeObsoleteMcpEntry(); mkdirSync(join(HOME, '.pilot', 'integrations'), { recursive: true }); cpSync(SOURCE_PLUGIN, INSTALLED_PLUGIN, { recursive: true, force: true }); - await execFileAsync('openclaw', ['plugins', 'install', '--link', INSTALLED_PLUGIN], { + await execFileAsync('openclaw', ['plugins', 'install', '--link', '--force', INSTALLED_PLUGIN], { + env: process.env, timeout: 60000, maxBuffer: 1 << 20, + }); + await execFileAsync('openclaw', ['plugins', 'enable', 'pilot-policy'], { + env: process.env, timeout: 60000, maxBuffer: 1 << 20, + }); + await execFileAsync('openclaw', ['plugins', 'inspect', 'pilot-policy', '--json'], { env: process.env, timeout: 60000, maxBuffer: 1 << 20, }); } + +function removeObsoleteMcpEntry() { + if (!existsSync(CONFIG)) return; + const current = JSON.parse(readFileSync(CONFIG, 'utf8')); + if (isPilotMcp(current.mcpServers?.pilot)) { + delete current.mcpServers.pilot; + if (Object.keys(current.mcpServers).length === 0) delete current.mcpServers; + writeFileSync(CONFIG, JSON.stringify(current, null, 2)); + } +} + +function isPilotMcp(server) { + return server?.command === 'npx' + && Array.isArray(server.args) + && server.args.some((arg) => String(arg).startsWith('pilotprotocol-mcp')); +} diff --git a/src/setup/harnesses/openhands.js b/src/setup/harnesses/openhands.js index 20df813..061c4a0 100644 --- a/src/setup/harnesses/openhands.js +++ b/src/setup/harnesses/openhands.js @@ -1,4 +1,4 @@ -// OpenHands: write [mcp.stdio_servers.pilot] into ~/.openhands/config.toml. +// OpenHands: write the current user-level ~/.openhands/mcp.json format. // // OpenHands accepts Claude Code's hooks.json schema, but discovers it from the // repository rather than the user's home directory. `pilot-mcp setup` therefore @@ -10,26 +10,23 @@ import { join, dirname } from 'node:path'; import { homedir } from 'node:os'; import process from 'node:process'; import { hookCommand, isPilotHookCommand } from './runtime.js'; +import { pilotMcpServer } from './runtime.js'; const HOME = homedir(); -const CONFIG = join(HOME, '.openhands', 'config.toml'); - -const MCP_BLOCK = ` -[mcp.stdio_servers.pilot] -command = "npx" -args = ["-y", "pilotprotocol-mcp@0.2.11"] -`; +const MCP_CONFIG = join(HOME, '.openhands', 'mcp.json'); +const LEGACY_CONFIG = join(HOME, '.openhands', 'config.toml'); export async function configure(options = {}) { - // Append the MCP block if not already present. - if (existsSync(CONFIG)) { - const current = readFileSync(CONFIG, 'utf8'); - if (!current.includes('[mcp.stdio_servers.pilot]')) { - writeFileSync(CONFIG, current + MCP_BLOCK); - } - } else { - mkdirSync(dirname(CONFIG), { recursive: true }); - writeFileSync(CONFIG, MCP_BLOCK.trimStart()); + mkdirSync(dirname(MCP_CONFIG), { recursive: true }); + const mcp = existsSync(MCP_CONFIG) ? JSON.parse(readFileSync(MCP_CONFIG, 'utf8')) : {}; + mcp.mcpServers = mcp.mcpServers ?? {}; + mcp.mcpServers.pilot = pilotMcpServer(); + writeFileSync(MCP_CONFIG, JSON.stringify(mcp, null, 2)); + + if (existsSync(LEGACY_CONFIG)) { + const legacy = readFileSync(LEGACY_CONFIG, 'utf8'); + const migrated = removeLegacyPilotMcpBlock(legacy); + if (migrated !== legacy) writeFileSync(LEGACY_CONFIG, migrated); } const workspace = options.cwd ?? process.cwd(); @@ -41,6 +38,22 @@ export async function configure(options = {}) { writeFileSync(hooksPath, JSON.stringify(current, null, 2)); } +export function removeLegacyPilotMcpBlock(source) { + const lines = String(source).split(/(?<=\n)/); + const retained = []; + let skipping = false; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '[mcp.stdio_servers.pilot]') { + skipping = true; + continue; + } + if (skipping && trimmed.startsWith('[')) skipping = false; + if (!skipping) retained.push(line); + } + return retained.join('').replace(/\n{3,}/g, '\n\n'); +} + function installHook(hooks, event, phase) { hooks[event] = hooks[event] ?? []; const command = hookCommand('openhands', phase); diff --git a/src/setup/harnesses/picoclaw.js b/src/setup/harnesses/picoclaw.js index 75f3b6d..e3be917 100644 --- a/src/setup/harnesses/picoclaw.js +++ b/src/setup/harnesses/picoclaw.js @@ -6,6 +6,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { PILOT_PACKAGE_SPEC, pilotMcpServer } from './runtime.js'; const HOME = homedir(); const CONFIG = join(HOME, '.picoclaw', 'config.json'); @@ -15,8 +16,9 @@ export async function configure() { const current = JSON.parse(readFileSync(CONFIG, 'utf8')); current.tools = current.tools ?? {}; current.tools.mcp = current.tools.mcp ?? {}; + current.tools.mcp.enabled = true; current.tools.mcp.servers = current.tools.mcp.servers ?? {}; - current.tools.mcp.servers.pilot = { command: 'npx', args: ['-y', 'pilotprotocol-mcp@0.2.11'] }; + current.tools.mcp.servers.pilot = pilotMcpServer({ enabled: true }); current.hooks = current.hooks ?? {}; current.hooks.enabled = true; current.hooks.defaults = current.hooks.defaults ?? {}; @@ -26,7 +28,7 @@ export async function configure() { enabled: true, priority: 10, transport: 'stdio', - command: ['npx', '-y', 'pilotprotocol-mcp@0.2.11', 'picoclaw-hook'], + command: ['npx', '-y', PILOT_PACKAGE_SPEC, 'picoclaw-hook'], intercept: ['before_tool', 'after_tool'], }; writeFileSync(CONFIG, JSON.stringify(current, null, 2)); diff --git a/src/setup/harnesses/runtime.js b/src/setup/harnesses/runtime.js index e3b1c18..534ec8c 100644 --- a/src/setup/harnesses/runtime.js +++ b/src/setup/harnesses/runtime.js @@ -2,7 +2,19 @@ // Requiring a separate global install makes onboarding appear successful while // the first real tool call fails with "pilot-mcp: command not found". -export const PILOT_RUNNER = 'npx -y pilotprotocol-mcp@0.2.11'; +import { PACKAGE_SPEC, VERSION } from '../../version.js'; + +export const PILOT_PACKAGE_VERSION = VERSION; +export const PILOT_PACKAGE_SPEC = PACKAGE_SPEC; +export const PILOT_RUNNER = `npx -y ${PILOT_PACKAGE_SPEC}`; + +export function pilotMcpServer(extra = {}) { + return { + command: 'npx', + args: ['-y', PILOT_PACKAGE_SPEC], + ...extra, + }; +} export function hookCommand(harness, phase) { return `${PILOT_RUNNER} hook --harness ${harness} --phase ${phase}`; diff --git a/src/setup/index.js b/src/setup/index.js index b0305ec..aa5475a 100644 --- a/src/setup/index.js +++ b/src/setup/index.js @@ -8,7 +8,7 @@ // 5. Install AND load the daemon service (launchd plist / systemd unit). // 6. Start daemon, wait for rendezvous registration, fetch pilot address. // 7. Auto-detect installed harnesses. -// 8. For each detected harness: write MCP config + drop SKILL.md/AGENTS.md heartbeat. +// 8. For each detected harness: write its current MCP config and native policy hooks. // 9. Print summary with pilot address and which harnesses were configured. // // Replaces the current ~16-step new-user journey with one command. diff --git a/src/tour.js b/src/tour.js new file mode 100644 index 0000000..8731382 --- /dev/null +++ b/src/tour.js @@ -0,0 +1,11 @@ +import process from 'node:process'; + +import { search } from './tools/search.js'; + +export async function runTour(options = {}) { + const write = options.write ?? ((value) => process.stdout.write(`${value}\n`)); + const result = await search.handler({ keyword: 'weather', limit: 1 }); + write('Pilot tour: one live specialist-directory query'); + write(JSON.stringify(result, null, 2)); + return result; +} diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..3bb9c7f --- /dev/null +++ b/src/version.js @@ -0,0 +1,4 @@ +// One source of truth for runtime and generated-configuration versioning. +// Release-contract tests keep this synchronized with package/registry metadata. +export const VERSION = '0.2.12'; +export const PACKAGE_SPEC = `pilotprotocol-mcp@${VERSION}`; diff --git a/test/claude-setup.test.js b/test/claude-setup.test.js index c79b98d..3314bd7 100644 --- a/test/claude-setup.test.js +++ b/test/claude-setup.test.js @@ -52,3 +52,11 @@ test('ignores similarly named non-Pilot commands', () => { assert.equal(hooks.UserPromptSubmit.length, 1); }); + +test('removes version-pinned obsolete heartbeat commands', () => { + const hooks = { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'npx -y pilotprotocol-mcp@0.2.5 heartbeat --claude' }] }], + }; + removeObsoletePromptHook(hooks); + assert.equal('UserPromptSubmit' in hooks, false); +}); diff --git a/test/cli-help.test.js b/test/cli-help.test.js index 11d4e08..872c2fc 100644 --- a/test/cli-help.test.js +++ b/test/cli-help.test.js @@ -4,9 +4,10 @@ import { test } from 'node:test'; import assert from 'node:assert'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { readFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; const run = promisify(execFile); const root = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -16,9 +17,41 @@ test('help does not advertise the HTTP transport', async () => { const { stdout } = await run(process.execPath, [cli, '--help']); assert.doesNotMatch(stdout, /pilot-mcp serve/i, 'help lists `serve`, which is not implemented'); assert.doesNotMatch(stdout, /--http|Streamable HTTP/i); + assert.doesNotMatch(stdout, /uninstall/i, 'help advertises adapter uninstall before it is implemented'); assert.match(stdout, /Start stdio MCP server/); }); +test('advertised doctor and tour commands execute against the packaged runtime bridge', async () => { + const home = mkdtempSync(join(tmpdir(), 'pilot-cli-contract-')); + const binary = join(home, 'pilotctl'); + writeFileSync(binary, '#!/bin/sh\nprintf \'%s\\n\' \'{"data":{"items":[{"hostname":"weather.test"}]}}\'\n'); + chmodSync(binary, 0o700); + const env = { ...process.env, HOME: home, PILOTCTL_BIN: binary, PILOT_SOCKET: join(home, 'missing.sock') }; + const doctor = await run(process.execPath, [cli, 'doctor', '--json'], { env }); + const report = JSON.parse(doctor.stdout); + assert.equal(report.runtime.ok, true); + assert.equal(report.management.attached, false); + const tour = await run(process.execPath, [cli, 'tour'], { env }); + assert.match(tour.stdout, /weather\.test/); +}); + +test('identity export and import are real, owner-only lifecycle operations', async () => { + const sourceHome = mkdtempSync(join(tmpdir(), 'pilot-identity-source-')); + const identityPath = join(sourceHome, '.pilot', 'identity.json'); + mkdirSync(dirname(identityPath), { recursive: true }); + writeFileSync(identityPath, '{"node_id":"node-a","private_key":"secret"}\n', { mode: 0o600 }); + const portable = join(sourceHome, 'portable.json'); + await run(process.execPath, [cli, 'export-identity', portable], { env: { ...process.env, HOME: sourceHome } }); + assert.equal(existsSync(portable), true); + assert.equal(readFileSync(portable, 'utf8').includes('node-a'), true); + + const targetHome = mkdtempSync(join(tmpdir(), 'pilot-identity-target-')); + await run(process.execPath, [cli, 'import-identity', portable], { env: { ...process.env, HOME: targetHome } }); + const imported = join(targetHome, '.pilot', 'identity.json'); + assert.deepEqual(JSON.parse(readFileSync(imported, 'utf8')), { node_id: 'node-a', private_key: 'secret' }); + assert.equal(readFileSync(imported).length > 0, true); +}); + test('serve exits non-zero and says the HTTP transport is unavailable', async () => { await assert.rejects( run(process.execPath, [cli, 'serve', '--http']), diff --git a/test/harness-config-contracts.test.js b/test/harness-config-contracts.test.js new file mode 100644 index 0000000..40021b8 --- /dev/null +++ b/test/harness-config-contracts.test.js @@ -0,0 +1,127 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { pathToFileURL } from 'node:url'; +import { parse } from 'yaml'; + +const ROOT = process.cwd(); + +function configureInHome(id, home, options = {}) { + const moduleURL = pathToFileURL(join(ROOT, 'src', 'setup', 'harnesses', `${id}.js`)).href; + const script = `const mod=await import(${JSON.stringify(moduleURL)}); await mod.configure(${JSON.stringify(options)}); await mod.configure(${JSON.stringify(options)});`; + execFileSync(process.execPath, ['--input-type=module', '--eval', script], { + cwd: ROOT, env: { ...process.env, HOME: home }, stdio: 'pipe', + }); +} + +function writeJSON(path, value) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(value, null, 2)); +} + +test('Claude separates user MCP registration from hook settings and migrates stale entries', () => { + const home = mkdtempSync(join(tmpdir(), 'pilot-claude-contract-')); + const settingsPath = join(home, '.claude', 'settings.json'); + writeJSON(settingsPath, { + theme: 'dark', + mcpServers: { + pilot: { command: 'npx', args: ['-y', 'pilotprotocol-mcp@0.2.5'] }, + customer: { command: 'customer-mcp' }, + }, + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'npx -y pilotprotocol-mcp@0.2.5 heartbeat --claude' }] }], + }, + }); + configureInHome('claude', home); + + const mcp = JSON.parse(readFileSync(join(home, '.claude.json'), 'utf8')); + assert.deepEqual(mcp.mcpServers.pilot.args, ['-y', 'pilotprotocol-mcp@0.2.12']); + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')); + assert.equal(settings.theme, 'dark'); + assert.deepEqual(settings.mcpServers, { customer: { command: 'customer-mcp' } }); + assert.equal(settings.hooks.UserPromptSubmit, undefined); + assert.equal(settings.hooks.PreToolUse.length, 1); + assert.equal(settings.hooks.PostToolUse.length, 1); + assert.equal(settings.hooks.PostToolUseFailure.length, 1); +}); + +test('Gemini uses current MCP and BeforeTool/AfterTool user settings idempotently', () => { + const home = mkdtempSync(join(tmpdir(), 'pilot-gemini-contract-')); + const settingsPath = join(home, '.gemini', 'settings.json'); + writeJSON(settingsPath, { mcpServers: { customer: { command: 'customer-mcp' } }, hooks: {} }); + configureInHome('gemini', home); + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')); + assert.deepEqual(settings.mcpServers.pilot.args, ['-y', 'pilotprotocol-mcp@0.2.12']); + assert.equal(settings.mcpServers.customer.command, 'customer-mcp'); + assert.equal(settings.hooksConfig.enabled, true); + assert.equal(settings.hooks.BeforeTool.length, 1); + assert.equal(settings.hooks.AfterTool.length, 1); + assert.equal(settings.hooks.BeforeTool[0].hooks[0].timeout, 30000); +}); + +test('Continue merges Pilot into config.yaml and removes only its obsolete duplicate block', () => { + const home = mkdtempSync(join(tmpdir(), 'pilot-continue-contract-')); + const configPath = join(home, '.continue', 'config.yaml'); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, '# customer config\nname: Customer\nversion: 1.2.3\nschema: v1\nmcpServers:\n - name: Customer\n command: customer-mcp\n'); + const legacyPath = join(home, '.continue', 'mcpServers', 'pilot.yaml'); + mkdirSync(dirname(legacyPath), { recursive: true }); + writeFileSync(legacyPath, 'name: Pilot\nmcpServers:\n - command: npx\n args: ["-y", "pilotprotocol-mcp@0.2.11"]\n'); + configureInHome('continue', home); + const source = readFileSync(configPath, 'utf8'); + const config = parse(source); + assert.match(source, /# customer config/); + assert.equal(config.mcpServers.filter((entry) => entry.name === 'Pilot').length, 1); + assert.deepEqual(config.mcpServers.find((entry) => entry.name === 'Pilot').args, ['-y', 'pilotprotocol-mcp@0.2.12']); + assert.equal(config.mcpServers.find((entry) => entry.name === 'Customer').command, 'customer-mcp'); + assert.equal(existsSync(legacyPath), false); +}); + +test('OpenHands migrates pre-1.0 TOML MCP config and installs project hooks', () => { + const home = mkdtempSync(join(tmpdir(), 'pilot-openhands-contract-')); + const workspace = join(home, 'workspace'); + mkdirSync(workspace, { recursive: true }); + const legacyPath = join(home, '.openhands', 'config.toml'); + mkdirSync(dirname(legacyPath), { recursive: true }); + writeFileSync(legacyPath, '[core]\nmodel = "customer"\n\n[mcp.stdio_servers.pilot]\ncommand = "npx"\nargs = ["-y", "pilotprotocol-mcp@0.2.11"]\n\n[other]\nenabled = true\n'); + writeJSON(join(home, '.openhands', 'mcp.json'), { mcpServers: { customer: { command: 'customer-mcp' } } }); + configureInHome('openhands', home, { cwd: workspace }); + + const mcp = JSON.parse(readFileSync(join(home, '.openhands', 'mcp.json'), 'utf8')); + assert.deepEqual(mcp.mcpServers.pilot.args, ['-y', 'pilotprotocol-mcp@0.2.12']); + assert.equal(mcp.mcpServers.customer.command, 'customer-mcp'); + const legacy = readFileSync(legacyPath, 'utf8'); + assert.doesNotMatch(legacy, /mcp\.stdio_servers\.pilot/); + assert.match(legacy, /\[core\]/); + assert.match(legacy, /\[other\]/); + const hooks = JSON.parse(readFileSync(join(workspace, '.openhands', 'hooks.json'), 'utf8')); + assert.equal(hooks.PreToolUse.length, 1); + assert.equal(hooks.PostToolUse.length, 1); +}); + +test('Codex upgrades its owned TOML table without duplicating user configuration', () => { + const home = mkdtempSync(join(tmpdir(), 'pilot-codex-contract-')); + const configPath = join(home, '.codex', 'config.toml'); + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, 'model = "customer"\n\n[mcp_servers.pilot]\ncommand = "npx"\nargs = ["-y", "pilotprotocol-mcp@0.2.8"]\n\n[mcp_servers.customer]\ncommand = "customer-mcp"\n'); + configureInHome('codex', home); + const config = readFileSync(configPath, 'utf8'); + assert.equal(config.match(/\[mcp_servers\.pilot\]/g)?.length, 1); + assert.match(config, /pilotprotocol-mcp@0\.2\.12/); + assert.match(config, /\[mcp_servers\.customer\]/); + assert.match(config, /model = "customer"/); + const hooks = JSON.parse(readFileSync(join(home, '.codex', 'hooks.json'), 'utf8')); + assert.equal(hooks.hooks.PreToolUse.length, 1); + assert.equal(hooks.hooks.PostToolUse.length, 1); +}); + +test('Junie writes the shared CLI and IDE user MCP location', () => { + const home = mkdtempSync(join(tmpdir(), 'pilot-junie-contract-')); + configureInHome('junie', home); + const config = JSON.parse(readFileSync(join(home, '.junie', 'mcp', 'mcp.json'), 'utf8')); + assert.deepEqual(config.mcpServers.pilot.args, ['-y', 'pilotprotocol-mcp@0.2.12']); + assert.equal(existsSync(join(home, '.junie', 'config.json')), false); +}); diff --git a/test/hermes-setup.test.js b/test/hermes-setup.test.js index c475701..25aae3a 100644 --- a/test/hermes-setup.test.js +++ b/test/hermes-setup.test.js @@ -23,7 +23,12 @@ test('Hermes setup merges native pre/post hooks without replacing existing YAML' assert.equal(result.model, 'gemini/example'); assert.equal(result.hooks.on_session_start[0].command, 'existing-hook'); assert.equal(result.hooks.pre_tool_call.length, 1); - assert.equal(result.hooks.pre_tool_call[0].command, 'npx -y pilotprotocol-mcp@0.2.11 hook --harness hermes --phase pre'); + assert.equal(result.hooks.pre_tool_call[0].command, 'npx -y pilotprotocol-mcp@0.2.12 hook --harness hermes --phase pre'); assert.equal(result.hooks.post_tool_call.length, 1); - assert.deepEqual(result.mcp_servers.pilot.args, ['-y', 'pilotprotocol-mcp@0.2.11']); + assert.deepEqual(result.mcp_servers.pilot.args, ['-y', 'pilotprotocol-mcp@0.2.12']); + const allowlist = JSON.parse(readFileSync(join(home, '.hermes', 'shell-hooks-allowlist.json'), 'utf8')); + assert.deepEqual(allowlist.approvals, [ + { event: 'pre_tool_call', command: 'npx -y pilotprotocol-mcp@0.2.12 hook --harness hermes --phase pre' }, + { event: 'post_tool_call', command: 'npx -y pilotprotocol-mcp@0.2.12 hook --harness hermes --phase post' }, + ]); }); diff --git a/test/hook-adapter.test.js b/test/hook-adapter.test.js index 976f8ef..115af8b 100644 --- a/test/hook-adapter.test.js +++ b/test/hook-adapter.test.js @@ -82,7 +82,7 @@ test('Pilot MCP calls retain their business action instead of collapsing to tool test('Cline, Cursor and PicoClaw native payloads preserve their actual action content', () => { const cline = toPilotHookRequest('cline', 'pre', { hookName: 'PreToolUse', taskId: 'task-1', - preToolUse: { toolName: 'execute_command', parameters: { command: 'curl https://vendor.test' } }, + preToolUse: { tool: 'execute_command', parameters: { command: 'curl https://vendor.test' } }, }); assert.equal(cline.action, 'process.execute'); assert.equal(cline.resource, 'process:curl'); @@ -102,6 +102,152 @@ test('Cline, Cursor and PicoClaw native payloads preserve their actual action co assert.equal(pico.action, 'file.write'); }); +test('all native harness contracts preserve tool identity, session, and complete arguments', () => { + const cases = [ + ['claude', { session_id: 's-claude', hook_event_name: 'PreToolUse', tool_use_id: 'c-1', tool_name: 'Bash', tool_input: { command: 'echo claude' } }], + ['codex', { session_id: 's-codex', hook_event_name: 'PreToolUse', tool_use_id: 'c-2', tool_name: 'Bash', tool_input: { command: 'echo codex' } }], + ['gemini', { session_id: 's-gemini', hook_event_name: 'BeforeTool', tool_name: 'run_shell_command', tool_input: { command: 'echo gemini' } }], + ['openhands', { session_id: 's-openhands', hook_event_name: 'PreToolUse', tool_use_id: 'c-4', tool_name: 'Bash', tool_input: { command: 'echo openhands' } }], + ['copilot', { sessionId: 's-copilot', hookEventName: 'preToolUse', toolCallId: 'c-5', toolName: 'execute_command', toolArgs: { command: 'echo copilot' } }], + ['cursor', { session_id: 's-cursor', hook_event_name: 'preToolUse', tool_use_id: 'c-6', tool_name: 'shell', tool_input: { command: 'echo cursor' } }], + ['cline', { taskId: 's-cline', hookName: 'PreToolUse', preToolUse: { toolName: 'execute_command', parameters: { command: 'echo cline' } } }], + ['hermes', { session_id: 's-hermes', hook_event_name: 'pre_tool_call', tool_name: 'terminal', tool_input: { command: 'echo hermes' }, extra: { tool_call_id: 'c-8' } }], + ['openclaw', { session_id: 's-openclaw', hook_event_name: 'before_tool_call', tool_use_id: 'c-9', tool_name: 'execute_command', tool_input: { command: 'echo openclaw' } }], + ['picoclaw', { session_id: 's-picoclaw', hook_event_name: 'hook.before_tool', tool_use_id: 'c-10', tool: 'execute_command', arguments: { command: 'echo picoclaw' } }], + ]; + for (const [harness, native] of cases) { + const request = toPilotHookRequest(harness, 'pre', native); + const content = JSON.parse(Buffer.from(request.content_base64, 'base64').toString()); + assert.equal(request.adapter_id, `harness.${harness}`); + assert.match(request.attempt_key, new RegExp(`^${harness}:s-${harness}:`)); + assert.equal(request.action, 'process.execute'); + assert.equal(content.tool_input.command, `echo ${harness}`); + assert.ok(content.tool_name); + } +}); + +test('post-hook failure payloads retain host-native error and timing evidence', () => { + const cline = toPilotHookRequest('cline', 'post', { + taskId: 'task-1', hookName: 'PostToolUse', + postToolUse: { + tool: 'execute_command', parameters: { command: 'false' }, result: 'exit 1', + success: false, executionTimeMs: 42, + }, + }); + assert.equal(cline.status, 'failed'); + assert.deepEqual(JSON.parse(Buffer.from(cline.content_base64, 'base64').toString()).tool_response, { + success: false, duration_ms: 42, result: 'exit 1', + }); + + const cursor = toPilotHookRequest('cursor', 'post', { + session_id: 'cursor-1', hook_event_name: 'postToolUseFailure', tool_name: 'shell', + tool_input: { command: 'false' }, error_message: 'exit 1', failure_type: 'nonzero_exit', is_interrupt: false, + }); + assert.equal(cursor.status, 'failed'); + assert.deepEqual(JSON.parse(Buffer.from(cursor.content_base64, 'base64').toString()).tool_response, { + error: 'exit 1', failure_type: 'nonzero_exit', is_interrupt: false, + }); + + const copilot = toPilotHookRequest('copilot', 'post', { + sessionId: 'copilot-1', hookEventName: 'postToolUseFailure', toolName: 'execute_command', + toolArgs: { command: 'false' }, error: 'command failed', + }); + assert.equal(copilot.status, 'failed'); + assert.deepEqual(JSON.parse(Buffer.from(copilot.content_base64, 'base64').toString()).tool_response, { + error: 'command failed', + }); +}); + +test('every managed pre-hook emits the host-specific blocking contract', { concurrency: false }, async () => { + const directory = mkdtempSync(join(tmpdir(), 'pilot-all-hook-denials-')); + const binary = join(directory, 'pilotctl'); + writeFileSync(binary, '#!/bin/sh\nprintf \'%s\\n\' \'{"data":{"execute":false,"outcome":"deny","reasons":["blocked marker"]}}\'\n'); + chmodSync(binary, 0o700); + const originalControl = process.env.PILOT_ENTERPRISE_CONTROL; + const originalPilotctl = process.env.PILOTCTL_BIN; + process.env.PILOT_ENTERPRISE_CONTROL = '/tmp/test-enterprise-control.json'; + process.env.PILOTCTL_BIN = binary; + const cases = [ + ['claude', { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'echo x' } }, 'exit'], + ['codex', { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'echo x' } }, 'exit'], + ['gemini', { hook_event_name: 'BeforeTool', tool_name: 'run_shell_command', tool_input: { command: 'echo x' } }, 'exit'], + ['openhands', { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'echo x' } }, 'exit'], + ['copilot', { hookEventName: 'preToolUse', toolName: 'execute_command', toolArgs: { command: 'echo x' } }, 'exit'], + ['cursor', { hook_event_name: 'preToolUse', tool_name: 'shell', tool_input: { command: 'echo x' } }, 'cursor'], + ['cline', { hookName: 'PreToolUse', preToolUse: { tool: 'execute_command', parameters: { command: 'echo x' } } }, 'cline'], + ['hermes', { hook_event_name: 'pre_tool_call', tool_name: 'terminal', tool_input: { command: 'echo x' } }, 'hermes'], + ['openclaw', { hook_event_name: 'before_tool_call', tool_name: 'execute_command', tool_input: { command: 'echo x' } }, 'exit'], + ['picoclaw', { hook_event_name: 'hook.before_tool', tool: 'execute_command', arguments: { command: 'echo x' } }, 'picoclaw'], + ]; + try { + for (const [harness, native, contract] of cases) { + let stdout = ''; + let stderr = ''; + let exitCode = 0; + const result = await runHook({ harness, phase: 'pre' }, { + stdin: Readable.from([JSON.stringify(native)]), + stdout: { write(value) { stdout += value; } }, + stderr: { write(value) { stderr += value; } }, + setExitCode(value) { exitCode = value; }, + }); + assert.equal(result.blocked, true, harness); + if (contract === 'exit') { + assert.equal(exitCode, 2, harness); + assert.match(stderr, /blocked marker/, harness); + } else { + assert.equal(exitCode, 0, harness); + const output = JSON.parse(stdout); + if (contract === 'cursor') assert.equal(output.permission, 'deny'); + if (contract === 'cline') assert.equal(output.cancel, true); + if (contract === 'hermes') assert.equal(output.decision, 'block'); + if (contract === 'picoclaw') assert.equal(output.action, 'deny_tool'); + } + } + } finally { + if (originalControl === undefined) delete process.env.PILOT_ENTERPRISE_CONTROL; + else process.env.PILOT_ENTERPRISE_CONTROL = originalControl; + if (originalPilotctl === undefined) delete process.env.PILOTCTL_BIN; + else process.env.PILOTCTL_BIN = originalPilotctl; + } +}); + +test('managed pre-hooks fail closed before the host timeout can fail open', { concurrency: false }, async () => { + const directory = mkdtempSync(join(tmpdir(), 'pilot-hook-timeout-')); + const binary = join(directory, 'pilotctl'); + writeFileSync(binary, '#!/usr/bin/env node\nsetTimeout(() => {}, 5000);\n'); + chmodSync(binary, 0o700); + const originals = { + control: process.env.PILOT_ENTERPRISE_CONTROL, + binary: process.env.PILOTCTL_BIN, + timeout: process.env.PILOT_HOOK_TIMEOUT_MS, + }; + process.env.PILOT_ENTERPRISE_CONTROL = '/tmp/test-enterprise-control.json'; + process.env.PILOTCTL_BIN = binary; + process.env.PILOT_HOOK_TIMEOUT_MS = '50'; + let stderr = ''; + let exitCode = 0; + const started = Date.now(); + try { + const result = await runHook({ harness: 'copilot', phase: 'pre' }, { + stdin: Readable.from([JSON.stringify({ toolName: 'execute_command', toolArgs: { command: 'echo x' } })]), + stdout: { write() {} }, + stderr: { write(value) { stderr += value; } }, + setExitCode(value) { exitCode = value; }, + }); + assert.equal(result.blocked, true); + assert.equal(exitCode, 2); + assert.match(stderr, /timed out after 50ms/); + assert.ok(Date.now() - started < 1000); + } finally { + if (originals.control === undefined) delete process.env.PILOT_ENTERPRISE_CONTROL; + else process.env.PILOT_ENTERPRISE_CONTROL = originals.control; + if (originals.binary === undefined) delete process.env.PILOTCTL_BIN; + else process.env.PILOTCTL_BIN = originals.binary; + if (originals.timeout === undefined) delete process.env.PILOT_HOOK_TIMEOUT_MS; + else process.env.PILOT_HOOK_TIMEOUT_MS = originals.timeout; + } +}); + test('approval retries share a stable resume token while attempts stay call-specific', () => { const base = { session_id: 'session-a', hook_event_name: 'PreToolUse', tool_name: 'Bash', @@ -145,7 +291,8 @@ test('Hermes shell-hook events retain one tool-call identity and complete result assert.equal(after.attempt_key, before.attempt_key); assert.equal(after.status, 'succeeded'); assert.deepEqual(JSON.parse(Buffer.from(after.content_base64, 'base64').toString()), { - tool_name: 'terminal', tool_input: { command: 'curl https://example.test' }, tool_response: { status: 200 }, + tool_name: 'terminal', tool_input: { command: 'curl https://example.test' }, + tool_response: { duration_ms: 17, result: { status: 200 } }, }); }); @@ -194,7 +341,11 @@ test('an installed but unattached hook is a zero-side-effect pass-through', { co let exitCode = 0; try { const result = await runHook({ harness: 'claude', phase: 'pre' }, { - stdin: Readable.from(['{}']), + stdin: { + [Symbol.asyncIterator]() { + throw new Error('unmanaged hook must not inspect stdin'); + }, + }, stdout: { write(value) { stdout += value; } }, stderr: { write(value) { stderr += value; } }, setExitCode(value) { exitCode = value; }, @@ -242,3 +393,32 @@ test('an explicitly managed hook fails closed when the control plane is unavaila else process.env.PILOTCTL_BIN = originalPilotctl; } }); + +test('an explicitly managed hook rejects a successful but non-authoritative response', { concurrency: false }, async () => { + const directory = mkdtempSync(join(tmpdir(), 'pilot-malformed-decision-')); + const binary = join(directory, 'pilotctl'); + writeFileSync(binary, '#!/bin/sh\nprintf \'%s\\n\' \'{"data":{}}\'\n'); + chmodSync(binary, 0o700); + const originalControl = process.env.PILOT_ENTERPRISE_CONTROL; + const originalPilotctl = process.env.PILOTCTL_BIN; + process.env.PILOT_ENTERPRISE_CONTROL = '/tmp/test-enterprise-control.json'; + process.env.PILOTCTL_BIN = binary; + let stderr = ''; + let exitCode = 0; + try { + const result = await runHook({ harness: 'gemini', phase: 'pre' }, { + stdin: Readable.from([JSON.stringify({ tool_name: 'run_shell_command', tool_input: { command: 'echo hello' } })]), + stdout: { write() {} }, + stderr: { write(value) { stderr += value; } }, + setExitCode(value) { exitCode = value; }, + }); + assert.equal(result.blocked, true); + assert.equal(exitCode, 2); + assert.match(stderr, /no authoritative execute decision/); + } finally { + if (originalControl === undefined) delete process.env.PILOT_ENTERPRISE_CONTROL; + else process.env.PILOT_ENTERPRISE_CONTROL = originalControl; + if (originalPilotctl === undefined) delete process.env.PILOTCTL_BIN; + else process.env.PILOTCTL_BIN = originalPilotctl; + } +}); diff --git a/test/native-harness-setup.test.js b/test/native-harness-setup.test.js index 477a129..6c43c37 100644 --- a/test/native-harness-setup.test.js +++ b/test/native-harness-setup.test.js @@ -6,10 +6,10 @@ import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { pathToFileURL } from 'node:url'; -function configureInHome(id, home, fixture, env = {}) { +function configureInHome(id, home, fixture, env = {}, options = {}) { fixture?.(home); const moduleURL = pathToFileURL(join(process.cwd(), 'src', 'setup', 'harnesses', `${id}.js`)).href; - execFileSync(process.execPath, ['--input-type=module', '--eval', `const mod=await import(${JSON.stringify(moduleURL)}); await mod.configure(); await mod.configure();`], { + execFileSync(process.execPath, ['--input-type=module', '--eval', `const mod=await import(${JSON.stringify(moduleURL)}); await mod.configure(${JSON.stringify(options)}); await mod.configure(${JSON.stringify(options)});`], { cwd: process.cwd(), env: { ...process.env, HOME: home, ...env }, stdio: 'pipe', }); } @@ -20,27 +20,43 @@ test('Cursor setup installs an idempotent fail-closed native tool boundary', () const hooks = JSON.parse(readFileSync(join(home, '.cursor', 'hooks.json'), 'utf8')); assert.equal(hooks.hooks.preToolUse.length, 1); assert.equal(hooks.hooks.preToolUse[0].failClosed, true); - assert.match(hooks.hooks.preToolUse[0].command, /^npx -y pilotprotocol-mcp@0\.2\.11 /); + assert.match(hooks.hooks.preToolUse[0].command, /^npx -y pilotprotocol-mcp@0\.2\.12 /); assert.match(hooks.hooks.preToolUse[0].command, /--harness cursor --phase pre/); }); -test('Cline setup installs executable global pre/post hook shims without replacing an existing hook', () => { +test('Cline setup installs current global pre/post hook shims without replacing an existing hook', () => { const home = mkdtempSync(join(tmpdir(), 'pilot-cline-home-')); configureInHome('cline', home); - const pre = readFileSync(join(home, 'Documents', 'Cline', 'Hooks', 'PreToolUse'), 'utf8'); - const post = readFileSync(join(home, 'Documents', 'Cline', 'Hooks', 'PostToolUse'), 'utf8'); - assert.match(pre, /exec npx -y pilotprotocol-mcp@0\.2\.11 hook/); + const pre = readFileSync(join(home, '.cline', 'hooks', 'PreToolUse'), 'utf8'); + const post = readFileSync(join(home, '.cline', 'hooks', 'PostToolUse'), 'utf8'); + assert.match(pre, /exec npx -y pilotprotocol-mcp@0\.2\.12 hook/); assert.match(pre, /--harness cline --phase pre/); assert.match(post, /--harness cline --phase post/); + const mcp = JSON.parse(readFileSync(join(home, '.cline', 'data', 'settings', 'cline_mcp_settings.json'), 'utf8')); + assert.deepEqual(mcp.mcpServers.pilot.args, ['-y', 'pilotprotocol-mcp@0.2.12']); const conflictHome = mkdtempSync(join(tmpdir(), 'pilot-cline-conflict-')); - const target = join(conflictHome, 'Documents', 'Cline', 'Hooks', 'PreToolUse'); + const target = join(conflictHome, '.cline', 'hooks', 'PreToolUse'); mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, '#!/bin/sh\nexec existing-hook\n'); assert.throws(() => configureInHome('cline', conflictHome), /already has a global PreToolUse hook/); assert.match(readFileSync(target, 'utf8'), /existing-hook/); }); +test('Cline setup emits the only Windows hook filename and PowerShell contract Cline discovers', () => { + const home = mkdtempSync(join(tmpdir(), 'pilot-cline-windows-')); + configureInHome('cline', home, undefined, {}, { platform: 'win32' }); + // configure() uses the host platform; exercise the exported writer directly + // for the cross-platform artifact that cannot run natively on this host. + const moduleURL = pathToFileURL(join(process.cwd(), 'src', 'setup', 'harnesses', 'cline.js')).href; + execFileSync(process.execPath, ['--input-type=module', '--eval', `const mod=await import(${JSON.stringify(moduleURL)}); mod.installNativeHook('PreToolUse','pre','win32');`], { + cwd: process.cwd(), env: { ...process.env, HOME: home }, stdio: 'pipe', + }); + const source = readFileSync(join(home, '.cline', 'hooks', 'PreToolUse.ps1'), 'utf8'); + assert.match(source, /^& npx -y pilotprotocol-mcp@0\.2\.12 hook --harness cline --phase pre/m); + assert.match(source, /LASTEXITCODE/); +}); + test('Copilot setup writes the documented cross-platform command-hook fields', () => { const home = mkdtempSync(join(tmpdir(), 'pilot-copilot-home-')); configureInHome('copilot', home); @@ -48,8 +64,10 @@ test('Copilot setup writes the documented cross-platform command-hook fields', ( const pre = hooks.hooks.preToolUse[0]; assert.equal(pre.type, 'command'); assert.equal(pre.bash, pre.powershell); - assert.match(pre.bash, /^npx -y pilotprotocol-mcp@0\.2\.11 /); + assert.match(pre.bash, /^npx -y pilotprotocol-mcp@0\.2\.12 /); assert.equal(pre.command, undefined); + const mcp = JSON.parse(readFileSync(join(home, '.copilot', 'mcp-config.json'), 'utf8')); + assert.deepEqual(mcp.mcpServers.pilot.args, ['-y', 'pilotprotocol-mcp@0.2.12']); }); test('PicoClaw setup attaches the native process hook as a fixed argv array', () => { @@ -60,7 +78,9 @@ test('PicoClaw setup attaches the native process hook as a fixed argv array', () writeFileSync(target, '{}'); }); const config = JSON.parse(readFileSync(join(home, '.picoclaw', 'config.json'), 'utf8')); - assert.deepEqual(config.hooks.processes.pilot.command, ['npx', '-y', 'pilotprotocol-mcp@0.2.11', 'picoclaw-hook']); + assert.equal(config.tools.mcp.enabled, true); + assert.equal(config.tools.mcp.servers.pilot.enabled, true); + assert.deepEqual(config.hooks.processes.pilot.command, ['npx', '-y', 'pilotprotocol-mcp@0.2.12', 'picoclaw-hook']); assert.deepEqual(config.hooks.processes.pilot.intercept, ['before_tool', 'after_tool']); }); @@ -70,7 +90,7 @@ test('OpenClaw setup installs the bundled native policy plugin in one pass', () const log = join(home, 'openclaw.args'); mkdirSync(bin, { recursive: true }); const executable = join(bin, 'openclaw'); - writeFileSync(executable, `#!/bin/sh\nprintf '%s\\n' "$@" > ${JSON.stringify(log)}\n`); + writeFileSync(executable, `#!/bin/sh\nprintf '%s\\n' "$@" >> ${JSON.stringify(log)}\nprintf '%s\\n' -- >> ${JSON.stringify(log)}\n`); chmodSync(executable, 0o700); configureInHome('openclaw', home, (dir) => { const target = join(dir, '.openclaw', 'openclaw.json'); @@ -79,5 +99,10 @@ test('OpenClaw setup installs the bundled native policy plugin in one pass', () }, { PATH: `${bin}:${process.env.PATH ?? ''}` }); const installed = join(home, '.pilot', 'integrations', 'openclaw-policy'); assert.equal(existsSync(join(installed, 'openclaw.plugin.json')), true); - assert.match(readFileSync(log, 'utf8'), /plugins\ninstall\n--link/); + const calls = readFileSync(log, 'utf8'); + assert.match(calls, /plugins\ninstall\n--link\n--force/); + assert.match(calls, /plugins\nenable\npilot-policy/); + assert.match(calls, /plugins\ninspect\npilot-policy\n--json/); + const manifest = JSON.parse(readFileSync(join(installed, 'openclaw.plugin.json'), 'utf8')); + assert.deepEqual(manifest.mcpServers.pilot.args, ['-y', 'pilotprotocol-mcp@0.2.12']); }); diff --git a/test/openclaw-plugin.test.js b/test/openclaw-plugin.test.js new file mode 100644 index 0000000..cc44bfa --- /dev/null +++ b/test/openclaw-plugin.test.js @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough, Writable } from 'node:stream'; + +import { evaluate } from '../src/openclaw-plugin/evaluate.js'; + +function fakeSpawn(mode, observation = {}) { + return (command, args) => { + observation.command = command; + observation.args = args; + const child = new EventEmitter(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.stdin = new Writable({ + write(chunk, _encoding, callback) { + observation.input = (observation.input ?? '') + chunk.toString(); + callback(); + }, + }); + child.kill = () => { observation.killed = true; return true; }; + queueMicrotask(() => { + if (mode === 'allow') child.emit('close', 0); + if (mode === 'deny') { + child.stderr.write('semantic policy denied'); + child.emit('close', 2); + } + if (mode === 'error') child.emit('error', new Error('npx unavailable')); + }); + return child; + }; +} + +test('OpenClaw plugin invokes the pinned adapter and preserves the entire event', async () => { + const observation = {}; + const event = { tool_name: 'execute_command', tool_input: { command: 'echo hello' } }; + const result = await evaluate('pre', event, { spawn: fakeSpawn('allow', observation), env: {} }); + assert.deepEqual(result, { blocked: false }); + assert.equal(observation.command, 'npx'); + assert.deepEqual(observation.args, [ + '-y', 'pilotprotocol-mcp@0.2.12', 'hook', '--harness', 'openclaw', '--phase', 'pre', + ]); + assert.deepEqual(JSON.parse(observation.input), event); +}); + +test('OpenClaw plugin converts adapter denial and launch failure into explicit pre-hook blocks', async () => { + const denied = await evaluate('pre', {}, { spawn: fakeSpawn('deny'), env: {} }); + assert.deepEqual(denied, { blocked: true, reason: 'semantic policy denied' }); + const unavailable = await evaluate('pre', {}, { spawn: fakeSpawn('error'), env: {} }); + assert.equal(unavailable.blocked, true); + assert.match(unavailable.reason, /control plane unavailable/); +}); + +test('OpenClaw plugin resolves a fail-closed decision before its host timeout', async () => { + const observation = {}; + const started = Date.now(); + const result = await evaluate('pre', {}, { + spawn: fakeSpawn('hang', observation), + env: { PILOT_OPENCLAW_HOOK_TIMEOUT_MS: '50' }, + }); + assert.equal(result.blocked, true); + assert.match(result.reason, /timed out after 50ms/); + assert.equal(observation.killed, true); + assert.ok(Date.now() - started < 1000); +}); + +test('OpenClaw post-hook failures remain evidence errors and never replay a side effect', async () => { + await assert.rejects( + evaluate('post', {}, { spawn: fakeSpawn('error'), env: {} }), + /npx unavailable/, + ); +}); diff --git a/test/release-contract.test.js b/test/release-contract.test.js index aa3b2e8..9209451 100644 --- a/test/release-contract.test.js +++ b/test/release-contract.test.js @@ -9,9 +9,13 @@ async function readJSON(path) { } test('npm and Official MCP Registry metadata stay version-locked', async () => { - const [pkg, server] = await Promise.all([ + const [pkg, server, card, versionSource, pluginManifest, pluginEvaluator] = await Promise.all([ readJSON('package.json'), readJSON('server.json'), + readJSON('.well-known/mcp/server-card.json'), + readFile(new URL('src/version.js', root), 'utf8'), + readJSON('src/openclaw-plugin/openclaw.plugin.json'), + readFile(new URL('src/openclaw-plugin/evaluate.js', root), 'utf8'), ]); assert.equal(pkg.mcpName, server.name); @@ -20,6 +24,10 @@ test('npm and Official MCP Registry metadata stay version-locked', async () => { assert.equal(server.packages[0].registryType, 'npm'); assert.equal(server.packages[0].identifier, pkg.name); assert.equal(server.packages[0].version, pkg.version); + assert.equal(card.serverInfo.version, pkg.version); + assert.match(versionSource, new RegExp(`VERSION = '${pkg.version.replaceAll('.', '\\.')}';`)); + assert.deepEqual(pluginManifest.mcpServers.pilot.args, ['-y', `${pkg.name}@${pkg.version}`]); + assert.match(pluginEvaluator, new RegExp(`${pkg.name}@${pkg.version.replaceAll('.', '\\.')}`)); }); test('release workflow uses upstream publisher and repository GHCR namespace', async () => { diff --git a/test/smoke.test.js b/test/smoke.test.js index c063f73..b38badd 100644 --- a/test/smoke.test.js +++ b/test/smoke.test.js @@ -26,11 +26,15 @@ test('stdio server answers initialize with serverInfo', async () => { await new Promise((r) => setTimeout(r, 2500)); p.kill(); assert.match(out, /"serverInfo"/, `no serverInfo in output: ${out.slice(0, 200)}`); + const response = JSON.parse(out.trim().split('\n').find((line) => line.includes('"serverInfo"'))); + const pkg = JSON.parse(await (await import('node:fs/promises')).readFile(join(root, 'package.json'), 'utf8')); + assert.equal(response.result.serverInfo.version, pkg.version); }); test('cli --version prints the package version', async () => { const { execFile } = await import('node:child_process'); const { promisify } = await import('node:util'); const { stdout } = await promisify(execFile)(process.execPath, [join(root, 'cli.js'), '--version']); - assert.match(stdout.trim(), /^\d+\.\d+\.\d+$/); + const pkg = JSON.parse(await (await import('node:fs/promises')).readFile(join(root, 'package.json'), 'utf8')); + assert.equal(stdout.trim(), pkg.version); });