From a331db70fe5723aa333ebed2a949a896becf205b Mon Sep 17 00:00:00 2001 From: shwang Date: Fri, 9 Oct 2026 16:14:36 +0800 Subject: [PATCH 1/3] feat: add ACP v1 provider and generated OpenAPI contracts --- docs/guides/acp-provider.md | 85 ++++ package.json | 4 + packages/provider-acp/README.md | 18 + packages/provider-acp/package.json | 28 ++ packages/provider-acp/src/index.ts | 281 +++++++++++ packages/provider-acp/src/process.ts | 80 ++++ packages/provider-acp/tsconfig.json | 10 + pnpm-lock.yaml | 61 +++ scripts/generate-openapi.mjs | 20 + scripts/openapi-schemas.d.mts | 3 + scripts/openapi-schemas.mjs | 21 + spec/openapi.yaml | 436 ++++++++++++------ tests/acp-provider.test.ts | 47 ++ tests/fixtures/acp-agent.mjs | 139 ++++++ tests/integration/acp-provider.integration.ts | 251 ++++++++++ tests/integration/http-server.integration.ts | 52 +++ tests/openapi-parity.test.ts | 141 ++++++ tsconfig.json | 1 + 18 files changed, 1535 insertions(+), 143 deletions(-) create mode 100644 docs/guides/acp-provider.md create mode 100644 packages/provider-acp/README.md create mode 100644 packages/provider-acp/package.json create mode 100644 packages/provider-acp/src/index.ts create mode 100644 packages/provider-acp/src/process.ts create mode 100644 packages/provider-acp/tsconfig.json create mode 100644 scripts/generate-openapi.mjs create mode 100644 scripts/openapi-schemas.d.mts create mode 100644 scripts/openapi-schemas.mjs create mode 100644 tests/acp-provider.test.ts create mode 100644 tests/fixtures/acp-agent.mjs create mode 100644 tests/integration/acp-provider.integration.ts create mode 100644 tests/openapi-parity.test.ts diff --git a/docs/guides/acp-provider.md b/docs/guides/acp-provider.md new file mode 100644 index 0000000..1c10c8c --- /dev/null +++ b/docs/guides/acp-provider.md @@ -0,0 +1,85 @@ +# ACP v1 Provider + +As of 2026-10-09. SDK version: `1.7.0`, stable ACP v1 entry point. + +The adapter connects a trusted ACP executable through the official TypeScript SDK. It does not +use the experimental v2 protocol, implement an agent loop, or require a model credential by itself. + +```ts +import { AcpProvider } from '@harness-runtime/provider-acp' + +const provider = new AcpProvider({ + command: 'path/to/your-acp-agent', + args: ['--acp'], + cwd: 'path/to/isolated-workspace', + env: allowlistedAgentEnvironment(), + initializationTimeoutMs: 10_000, + runTimeoutMs: 300_000, + shutdownGraceMs: 500, + maxMessageBytes: 1_048_576, +}) +``` + +`allowlistedAgentEnvironment()` is application-owned; the default environment is empty. Prefer an +absolute executable path. Arguments are passed directly without a shell. Constructor arguments and +environment are copied, while per-execution `config` must remain empty. Register multiple native +agents with distinct trusted `id` values rather than letting requests select arbitrary executables. + +## Mapping + +| ACP surface | Portable behavior | +| --- | --- | +| Initialization and new session | One fresh native session per execution | +| Assistant text chunks | `output.text.delta`; concatenated assistant text becomes final output | +| Other session updates | Namespaced `provider.event` observations | +| Permission request | `action.required`, awaiting approval, then a correlated response | +| Prompt result | Native stop reason retained under `provider.event` | +| Native cancellation | Hint plus bounded EOF/SIGTERM/SIGKILL teardown; advertised as emulated | +| Client filesystem and terminal | Disabled capabilities and no registered handlers | +| Artifacts, user input, continuity, native extensions | Unsupported by this adapter | + +The [official prompt-turn contract](https://agentclientprotocol.com/protocol/v1/prompt-turn) separates +session updates from prompt completion. A portable success means the native turn and teardown +completed normally, not that the agent fulfilled the business task. Non-text content stays in +native events; the adapter never fetches files or converts tool output into Artifact descriptors. + +## Human Permissions + +The [official permission contract](https://agentclientprotocol.com/protocol/v1/tool-calls) distinguishes +one-time decisions from remembered decisions. This adapter never silently expands an approval: + +- `{ approved: true }` selects an available `allow_once` option, otherwise cancels. +- `{ approved: false }` selects an available `reject_once` option, otherwise cancels. +- An explicit option may be supplied as `value: { optionId: "..." }` or a string ID. Grant options + also require `approved: true`; contradictory, unknown, malformed, or ambiguous choices cancel. +- `allow_always` is honored only when that specific option was explicitly selected and approved. +- Cancellation answers pending requests with a cancelled outcome before process teardown. + +The application supplies business approval policy. The adapter serializes concurrent permissions +but does not automatically approve them. Remembered decisions are owned by the native agent, not a +portable permission store. + +## Security Boundary + +The adapter rejects unrelated-session updates, bounds inbound message size and run duration, +discards stderr, and returns generic request errors instead of forwarding native diagnostics. +Native updates may still contain sensitive tool or file information; restrict access and redact +before exporting those events. These protections do not isolate the native process's filesystem, +network, subprocesses, or descendants. Use a deployment-owned sandbox and least-privilege account. + +The process teardown confirms the direct owned child's exit and transport closure. It does not +certify that every descendant, remote request, or native side effect has stopped. + +## Verification + +```bash +pnpm test:integration +``` + +Credential-free tests use the actual public SDK on both sides of a real subprocess connection. +They cover text normalization, environment isolation, permission choices and cancellation, +concurrent permission requests, forced exit, malformed peers, unrelated sessions, message limits, +and denied client file/terminal access. No private platform or model endpoint is contacted. + +This proves adapter/protocol behavior against a synthetic Agent. Verify each real executable's +authentication, tool policy, native isolation, and model behavior separately before deployment. diff --git a/package.json b/package.json index af21f1a..2a74718 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,15 @@ "format:check": "biome format .", "lint": "biome lint .", "sanitize": "bash scripts/scan-public.sh .", + "spec:generate": "pnpm build && node scripts/generate-openapi.mjs", "test": "pnpm build && vitest run", "test:integration": "pnpm build && vitest run --config vitest.integration.config.ts", "typecheck": "tsc -b && tsc -p tsconfig.tests.json --noEmit" }, "devDependencies": { + "ajv": "8.20.0", + "zod": "4.4.3", + "@agentclientprotocol/sdk": "1.7.0", "@deepseek-ai/dsh-sdk-client": "0.1.0-rc.7", "@biomejs/biome": "2.5.9", "@types/node": "22.20.1", diff --git a/packages/provider-acp/README.md b/packages/provider-acp/README.md new file mode 100644 index 0000000..1fbd501 --- /dev/null +++ b/packages/provider-acp/README.md @@ -0,0 +1,18 @@ +# ACP v1 Provider + +Subprocess adapter built on the public `@agentclientprotocol/sdk` `1.7.0` stable v1 entry point. +It maps assistant text updates and native permission requests into the portable Runtime contract. + +The trusted constructor owns executable, arguments, working directory, environment, timeouts, and +message limits. Execution requests cannot override those values. Each execution owns one process +and a fresh native session; conversation continuity and checkpoint portability are not advertised. + +Permission requests become correlated approval Actions. Approve-once selects `allow_once` only; +`allow_always` requires an explicit option ID and `approved: true`. Invalid or contradictory choices +are cancelled. Concurrent requests are serialized into the Runtime's single pending Action slot. + +Client filesystem and terminal capabilities are disabled and no handlers implement them. Native +agent processes retain their own operating-system privileges, so this is not sandbox isolation. +Cancellation uses a native notification followed by bounded owned-process teardown. + +See the [integration guide](../../docs/guides/acp-provider.md) for usage and verification boundaries. diff --git a/packages/provider-acp/package.json b/packages/provider-acp/package.json new file mode 100644 index 0000000..cd6b670 --- /dev/null +++ b/packages/provider-acp/package.json @@ -0,0 +1,28 @@ +{ + "name": "@harness-runtime/provider-acp", + "version": "0.1.0", + "private": true, + "description": "ACP v1 subprocess adapter for Harness Runtime API.", + "license": "Apache-2.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@agentclientprotocol/sdk": "1.7.0", + "@harness-runtime/core": "workspace:*", + "@harness-runtime/protocol": "workspace:*" + } +} diff --git a/packages/provider-acp/src/index.ts b/packages/provider-acp/src/index.ts new file mode 100644 index 0000000..28f6b96 --- /dev/null +++ b/packages/provider-acp/src/index.ts @@ -0,0 +1,281 @@ +import { resolve } from 'node:path' +import { Readable, Writable } from 'node:stream' +import * as acp from '@agentclientprotocol/sdk' +import type { HarnessProvider, ProviderRunContext } from '@harness-runtime/core' +import { + JsonObjectSchema, + PROTOCOL_VERSION, + type ActionResponse, + type JsonObject, + type ProviderManifest, + ProviderManifestSchema, + StandardCapability, +} from '@harness-runtime/protocol' +import { startProcess, within } from './process.js' + +export interface AcpProviderOptions { + command: string + args?: string[] + cwd?: string + env?: Record + id?: string + initializationTimeoutMs?: number + runTimeoutMs?: number + shutdownGraceMs?: number + maxMessageBytes?: number +} + +export class AcpProvider implements HarnessProvider { + readonly manifest: ProviderManifest + private readonly options: Required + private readonly active = new Map }>() + + constructor(options: AcpProviderOptions) { + if (!options.command) throw new Error('ACP command is required') + this.options = { + command: options.command, + args: [...(options.args ?? [])], + cwd: resolve(options.cwd ?? '.'), + env: { ...(options.env ?? {}) }, + id: options.id ?? 'acp', + initializationTimeoutMs: positive(options.initializationTimeoutMs ?? 10_000), + runTimeoutMs: positive(options.runTimeoutMs ?? 300_000), + shutdownGraceMs: positive(options.shutdownGraceMs ?? 500), + maxMessageBytes: positive(options.maxMessageBytes ?? 1_048_576), + } + this.manifest = ProviderManifestSchema.parse({ + id: this.options.id, + name: 'ACP v1 Agent Adapter', + version: '0.1.0', + protocolVersion: PROTOCOL_VERSION, + topologies: ['subprocess'], + capabilities: { + [StandardCapability.actionApproval]: 'native', + [StandardCapability.actionInput]: 'unsupported', + [StandardCapability.artifacts]: 'unsupported', + [StandardCapability.cancellation]: 'emulated', + [StandardCapability.conversationContinuity]: 'unsupported', + [StandardCapability.eventReplay]: 'emulated', + [StandardCapability.eventStreaming]: 'native', + [StandardCapability.mcp]: 'unsupported', + [StandardCapability.sandbox]: 'unsupported', + [StandardCapability.skills]: 'unsupported', + [StandardCapability.subagents]: 'unsupported', + }, + metadata: { + upstream: 'https://agentclientprotocol.com', + sdkVersion: '1.7.0', + nativeSessions: 'per-execution', + clientFilesystem: false, + clientTerminal: false, + }, + }) + } + + async run(context: ProviderRunContext): Promise<{ finalOutput: string }> { + context.signal.throwIfAborted() + if (Object.keys(context.config).length > 0) { + throw new Error( + 'ACP execution config must be empty; configure the trusted provider constructor', + ) + } + if (this.active.has(context.executionId)) throw new Error('ACP execution is already active') + const owned = startProcess( + this.options.command, + this.options.args, + this.options.cwd, + this.options.env, + this.options.shutdownGraceMs, + ) + let connection: acp.ClientConnection | undefined + let sessionId: string | undefined + let permissionTail: Promise = Promise.resolve() + const pendingPermissions = new Set>() + const output: string[] = [] + let cancellation: Promise | undefined + const cancel = (): Promise => { + cancellation ??= (async () => { + try { + await within( + Promise.allSettled([...pendingPermissions]), + this.options.shutdownGraceMs, + 'ACP permission cancellation timed out', + ) + if (connection && sessionId && !connection.signal.aborted) { + await within( + connection.agent.notify(acp.methods.agent.session.cancel, { sessionId }), + this.options.shutdownGraceMs, + 'ACP cancellation notification timed out', + ) + } + } catch { + // Native cancellation is best effort; owned process exit still requires confirmation. + } + await owned.close() + })() + return cancellation + } + const active = { cancel } + this.active.set(context.executionId, active) + const abort = () => { + void cancel().catch(() => undefined) + } + context.signal.addEventListener('abort', abort, { once: true }) + try { + const app = acp + .client({ name: 'harness-runtime-api' }) + .onNotification(acp.methods.client.session.update, ({ params }) => { + if (!sessionId || params.sessionId !== sessionId) { + throw new Error('ACP agent reported an unrelated session') + } + nativeEvent(context, 'session/update', params) + if ( + params.update.sessionUpdate === 'agent_message_chunk' && + params.update.content.type === 'text' + ) { + output.push(params.update.content.text) + context.emit('output.text.delta', { text: params.update.content.text }) + } + }) + .onRequest(acp.methods.client.session.requestPermission, ({ params }) => { + const permission = permissionTail.then(async () => { + if (context.signal.aborted || !sessionId || params.sessionId !== sessionId) { + return cancelledPermission() + } + try { + const response = await context.requestAction({ + kind: 'approval', + title: params.toolCall.title || 'ACP permission request', + payload: { + toolCallId: params.toolCall.toolCallId, + options: params.options.map(({ optionId, name, kind }) => ({ + optionId, + name, + kind, + })), + }, + }) + return permissionOutcome(params.options, response) + } catch { + return cancelledPermission() + } + }) + permissionTail = permission.then( + () => undefined, + () => undefined, + ) + pendingPermissions.add(permission) + void permission.then( + () => pendingPermissions.delete(permission), + () => pendingPermissions.delete(permission), + ) + return permission + }) + connection = app.connect( + acp.ndJsonStream( + Writable.toWeb(owned.child.stdin), + Readable.toWeb(owned.child.stdout) as ReadableStream, + { maxMessageBytes: this.options.maxMessageBytes }, + ), + ) + const agent = connection.agent + return await within( + (async () => { + const initialized = await within( + agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }), + this.options.initializationTimeoutMs, + 'ACP initialization timed out', + ) + if (initialized.protocolVersion !== acp.PROTOCOL_VERSION) + throw new Error('ACP protocol version mismatch') + context.signal.throwIfAborted() + sessionId = ( + await agent.request(acp.methods.agent.session.new, { + cwd: this.options.cwd, + mcpServers: [], + }) + ).sessionId + context.signal.throwIfAborted() + const result = await agent.request(acp.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: context.input }], + }) + if (result.stopReason === 'cancelled') throw new Error('ACP native turn was cancelled') + nativeEvent(context, 'turn.completed', { stopReason: result.stopReason }) + return { finalOutput: output.join('') } + })(), + this.options.runTimeoutMs, + 'ACP execution timed out', + ) + } catch { + throw new Error('ACP agent request failed') + } finally { + context.signal.removeEventListener('abort', abort) + try { + if (context.signal.aborted && cancellation) await cancellation + else await owned.close() + } finally { + connection?.close() + if (this.active.get(context.executionId) === active) this.active.delete(context.executionId) + } + } + } + + async cancel(executionId: string): Promise { + await this.active.get(executionId)?.cancel() + } +} + +function positive(value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) + throw new Error('ACP limits must be positive safe integers') + return value +} + +function nativeEvent(context: ProviderRunContext, method: string, params: unknown): void { + const parsed = JsonObjectSchema.safeParse(params) + if (!parsed.success) throw new Error('ACP agent emitted invalid JSON') + context.emit('provider.event', { provider: 'acp', method, params: parsed.data }) +} + +function cancelledPermission(): acp.RequestPermissionResponse { + return { outcome: { outcome: 'cancelled' } } +} + +function permissionOutcome( + options: acp.PermissionOption[], + response: ActionResponse, +): acp.RequestPermissionResponse { + if ( + new Set(options.map((option) => option.optionId)).size !== options.length || + options.some((option) => option.optionId === '') + ) + return cancelledPermission() + const selected = + typeof response.value === 'string' + ? response.value + : response.value && typeof response.value === 'object' && !Array.isArray(response.value) + ? (response.value as JsonObject).optionId + : undefined + if (response.value !== undefined && selected === undefined) return cancelledPermission() + const option = + selected === undefined + ? options.find( + (item) => item.kind === (response.approved === true ? 'allow_once' : 'reject_once'), + ) + : options.find((item) => item.optionId === selected) + if ( + !option || + (option.kind.startsWith('allow_') && response.approved !== true) || + (option.kind.startsWith('reject_') && response.approved === true) + ) + return cancelledPermission() + if (response.approved === undefined && selected === undefined) return cancelledPermission() + return { outcome: { outcome: 'selected', optionId: option.optionId } } +} diff --git a/packages/provider-acp/src/process.ts b/packages/provider-acp/src/process.ts new file mode 100644 index 0000000..af67802 --- /dev/null +++ b/packages/provider-acp/src/process.ts @@ -0,0 +1,80 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' + +export interface OwnedProcess { + child: ChildProcessWithoutNullStreams + close(): Promise +} + +export function startProcess( + command: string, + args: string[], + cwd: string, + env: Record, + graceMs: number, +): OwnedProcess { + let child: ChildProcessWithoutNullStreams + try { + child = spawn(command, args, { cwd, env, stdio: 'pipe', shell: false }) + } catch { + throw new Error('ACP process could not start') + } + child.stderr.resume() + child.stdin.on('error', () => undefined) + const exited = new Promise((resolve, reject) => { + child.once('close', () => resolve()) + child.once('error', () => reject(new Error('ACP process could not start'))) + }) + void exited.catch(() => undefined) + let closing: Promise | undefined + const stop = async (): Promise => { + if (child.exitCode !== null || child.signalCode !== null) { + await within(exited, graceMs, 'ACP process closure was not confirmed') + return + } + child.stdin.end() + if (await settledWithin(exited, graceMs)) return + child.kill('SIGTERM') + if (await settledWithin(exited, graceMs)) return + child.kill('SIGKILL') + await within(exited, graceMs, 'ACP process closure was not confirmed') + } + return { + child, + close: () => { + closing ??= stop() + return closing + }, + } +} + +export async function within( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +async function settledWithin(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + promise.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs) + }), + ]) + } finally { + if (timer) clearTimeout(timer) + } +} diff --git a/packages/provider-acp/tsconfig.json b/packages/provider-acp/tsconfig.json new file mode 100644 index 0000000..ae92b94 --- /dev/null +++ b/packages/provider-acp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../protocol" }, { "path": "../core" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e321c3f..8e2439f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@agentclientprotocol/sdk': + specifier: 1.7.0 + version: 1.7.0(zod@4.4.3) '@biomejs/biome': specifier: 2.5.9 version: 2.5.9 @@ -17,6 +20,9 @@ importers: '@types/node': specifier: 22.20.1 version: 22.20.1 + ajv: + specifier: 8.20.0 + version: 8.20.0 tsx: specifier: 4.23.12 version: 4.23.12 @@ -29,6 +35,9 @@ importers: yaml: specifier: 2.9.0 version: 2.9.0 + zod: + specifier: 4.4.3 + version: 4.4.3 packages/conformance: dependencies: @@ -51,6 +60,18 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/provider-acp: + dependencies: + '@agentclientprotocol/sdk': + specifier: 1.7.0 + version: 1.7.0(zod@4.4.3) + '@harness-runtime/core': + specifier: workspace:* + version: link:../core + '@harness-runtime/protocol': + specifier: workspace:* + version: link:../protocol + packages/provider-dsh: dependencies: '@deepseek-ai/dsh-sdk-client': @@ -105,6 +126,11 @@ importers: packages: + '@agentclientprotocol/sdk@1.7.0': + resolution: {integrity: sha512-ZORuxsuEnLjRly7t6Ad7zAsWitjWsyprswyPUHz/yycPb+tTm3DGa8XheaHhRXqX8LS9snuxaYavXSRA50TlHw==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@biomejs/biome@2.5.9': resolution: {integrity: sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g==} engines: {node: '>=14.21.3'} @@ -765,6 +791,9 @@ packages: '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -795,6 +824,12 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -813,6 +848,9 @@ packages: resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} @@ -913,6 +951,10 @@ packages: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rolldown@1.2.4: resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1058,6 +1100,10 @@ packages: snapshots: + '@agentclientprotocol/sdk@1.7.0(zod@4.4.3)': + dependencies: + zod: 4.4.3 + '@biomejs/biome@2.5.9': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.9 @@ -1478,6 +1524,13 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + assertion-error@2.0.1: {} chai@6.2.2: {} @@ -1523,6 +1576,10 @@ snapshots: expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -1532,6 +1589,8 @@ snapshots: hono@4.13.7: {} + json-schema-traverse@1.0.0: {} + lightningcss-android-arm64@1.33.0: optional: true @@ -1601,6 +1660,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + require-from-string@2.0.2: {} + rolldown@1.2.4: dependencies: '@oxc-project/types': 0.144.0 diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs new file mode 100644 index 0000000..5746080 --- /dev/null +++ b/scripts/generate-openapi.mjs @@ -0,0 +1,20 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { parse, stringify } from 'yaml' +import { runtimeSchemas } from './openapi-schemas.mjs' + +const file = new URL('../spec/openapi.yaml', import.meta.url) +const document = parse(await readFile(file, 'utf8')) +document.components.schemas = runtimeSchemas() +document.paths['/v1/conversations'].post.requestBody.content['application/json'].schema = { + $ref: '#/components/schemas/CreateConversationRequest', +} +document.paths['/v1/executions/{id}/events'].parameters.find( + (item) => item.name === 'after', +).schema = { + type: 'integer', + minimum: 0, + maximum: Number.MAX_SAFE_INTEGER, + default: 0, +} +await writeFile(file, stringify(document, { lineWidth: 100 })) +console.log('OpenAPI schemas generated from the portable Zod contract') diff --git a/scripts/openapi-schemas.d.mts b/scripts/openapi-schemas.d.mts new file mode 100644 index 0000000..23652ee --- /dev/null +++ b/scripts/openapi-schemas.d.mts @@ -0,0 +1,3 @@ +import type { SchemaObject } from 'ajv' + +export function runtimeSchemas(): Record diff --git a/scripts/openapi-schemas.mjs b/scripts/openapi-schemas.mjs new file mode 100644 index 0000000..b7e9dba --- /dev/null +++ b/scripts/openapi-schemas.mjs @@ -0,0 +1,21 @@ +import { z } from 'zod' +import * as protocol from '../packages/protocol/dist/index.js' + +export function runtimeSchemas() { + const registry = z.registry() + for (const [name, schema] of Object.entries(protocol)) { + if (name.endsWith('Schema')) registry.add(schema, { id: name.slice(0, -6) }) + } + const generated = z.toJSONSchema(registry, { + io: 'input', + target: 'draft-2020-12', + uri: (id) => `#/components/schemas/${id}`, + }) + return Object.fromEntries( + Object.entries(generated.schemas).map(([name, schema]) => { + // Embedded OpenAPI components share the document root for their local references. + const { $id: _id, $schema: _dialect, ...body } = schema + return [name, body] + }), + ) +} diff --git a/spec/openapi.yaml b/spec/openapi.yaml index ba50456..20f0b65 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -16,7 +16,8 @@ paths: application/json: schema: type: object - required: [status] + required: + - status properties: status: const: ok @@ -40,7 +41,8 @@ paths: application/json: schema: type: object - required: [providers] + required: + - providers properties: providers: type: array @@ -54,10 +56,7 @@ paths: content: application/json: schema: - type: object - properties: - metadata: - $ref: "#/components/schemas/JsonObject" + $ref: "#/components/schemas/CreateConversationRequest" responses: "201": description: Conversation created. @@ -164,6 +163,7 @@ paths: schema: type: integer minimum: 0 + maximum: 9007199254740991 default: 0 get: operationId: listOrStreamEvents @@ -228,248 +228,398 @@ components: schema: $ref: "#/components/schemas/Problem" schemas: - JsonValue: - oneOf: - - type: string - - type: number - - type: boolean - - type: "null" - - type: array - items: - $ref: "#/components/schemas/JsonValue" - - $ref: "#/components/schemas/JsonObject" - JsonObject: - type: object - additionalProperties: - $ref: "#/components/schemas/JsonValue" - SupportLevel: + ActionKind: type: string - enum: [native, emulated, degraded, unsupported] - ProviderManifest: + enum: + - approval + - input + ActionRequest: type: object - required: [id, name, version, protocolVersion, topologies, capabilities] properties: id: type: string - name: + minLength: 1 + executionId: type: string - version: + minLength: 1 + kind: + $ref: "#/components/schemas/ActionKind" + title: type: string - protocolVersion: - const: "2026-09-02" - topologies: - type: array - minItems: 1 - items: - type: string - enum: [embedded, subprocess, remote] - capabilities: - type: object - additionalProperties: - $ref: "#/components/schemas/SupportLevel" + minLength: 1 + description: + type: string + payload: + $ref: "#/components/schemas/JsonObject" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + required: + - id + - executionId + - kind + - title + - payload + - createdAt + ActionResponse: + type: object + properties: + approved: + type: boolean + value: + $ref: "#/components/schemas/JsonValue" metadata: $ref: "#/components/schemas/JsonObject" - RuntimeDescription: + ArtifactList: type: object - required: [name, version, protocolVersion, providers] properties: + artifacts: + type: array + items: + $ref: "#/components/schemas/Artifact" + required: + - artifacts + Artifact: + type: object + properties: + id: + type: string + minLength: 1 name: type: string - version: + minLength: 1 + mediaType: type: string - protocolVersion: - const: "2026-09-02" - providers: - type: array - items: - $ref: "#/components/schemas/ProviderManifest" + minLength: 1 + uri: + type: string + minLength: 1 + metadata: + $ref: "#/components/schemas/JsonObject" + executionId: + type: string + minLength: 1 + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + required: + - id + - name + - mediaType + - metadata + - executionId + - createdAt + CancelExecutionRequest: + type: object + properties: + reason: + type: string + minLength: 1 + maxLength: 500 Conversation: type: object - required: [id, createdAt, metadata] properties: id: type: string + minLength: 1 createdAt: type: string format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ metadata: $ref: "#/components/schemas/JsonObject" - StartExecutionRequest: + required: + - id + - createdAt + - metadata + CreateArtifactInput: type: object - required: [conversationId, providerId, input] properties: - conversationId: + id: type: string - providerId: + minLength: 1 + name: type: string - input: + minLength: 1 + mediaType: type: string - requiredCapabilities: + minLength: 1 + uri: + type: string + minLength: 1 + metadata: + $ref: "#/components/schemas/JsonObject" + required: + - id + - name + - mediaType + CreateConversationRequest: + type: object + properties: + metadata: + $ref: "#/components/schemas/JsonObject" + EventList: + type: object + properties: + events: type: array items: - type: string - config: - $ref: "#/components/schemas/JsonObject" - idempotencyKey: - type: string - maxLength: 200 - CancelExecutionRequest: + $ref: "#/components/schemas/RuntimeEvent" + nextCursor: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - events + - nextCursor + ExecutionError: type: object properties: - reason: + code: type: string minLength: 1 - maxLength: 500 + message: + type: string + minLength: 1 + required: + - code + - message Execution: type: object - required: - - id - - conversationId - - providerId - - state - - input - - requiredCapabilities - - effectiveConfig - - createdAt - - updatedAt properties: id: type: string + minLength: 1 conversationId: type: string + minLength: 1 providerId: type: string + minLength: 1 state: - type: string - enum: - - queued - - starting - - running - - awaiting_input - - awaiting_approval - - cancelling - - succeeded - - failed - - cancelled + $ref: "#/components/schemas/ExecutionState" input: type: string requiredCapabilities: type: array items: type: string + minLength: 1 effectiveConfig: $ref: "#/components/schemas/JsonObject" createdAt: type: string format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ updatedAt: type: string format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ startedAt: type: string format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ endedAt: type: string format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ finalOutput: type: string error: - type: object - required: [code, message] - properties: - code: - type: string - message: - type: string - ActionResponse: + $ref: "#/components/schemas/ExecutionError" + required: + - id + - conversationId + - providerId + - state + - input + - requiredCapabilities + - effectiveConfig + - createdAt + - updatedAt + ExecutionState: + type: string + enum: + - queued + - starting + - running + - awaiting_input + - awaiting_approval + - cancelling + - succeeded + - failed + - cancelled + JsonObject: type: object - properties: - approved: - type: boolean - value: - $ref: "#/components/schemas/JsonValue" - metadata: - $ref: "#/components/schemas/JsonObject" - Artifact: + propertyNames: + type: string + additionalProperties: + $ref: "#/components/schemas/JsonValue" + JsonValue: + anyOf: + - type: string + - type: number + - type: boolean + - type: "null" + - type: array + items: + $ref: "#/components/schemas/JsonValue" + - type: object + propertyNames: + type: string + additionalProperties: + $ref: "#/components/schemas/JsonValue" + Problem: type: object - required: [id, executionId, name, mediaType, createdAt, metadata] properties: - id: + code: type: string minLength: 1 - executionId: + message: type: string minLength: 1 - name: + details: + $ref: "#/components/schemas/JsonObject" + required: + - code + - message + ProviderManifest: + type: object + properties: + id: type: string minLength: 1 - mediaType: + name: type: string minLength: 1 - uri: + version: type: string minLength: 1 - createdAt: + protocolVersion: type: string - format: date-time + const: 2026-09-02 + topologies: + minItems: 1 + type: array + items: + $ref: "#/components/schemas/RuntimeTopology" + capabilities: + type: object + propertyNames: + type: string + minLength: 1 + additionalProperties: + $ref: "#/components/schemas/SupportLevel" metadata: $ref: "#/components/schemas/JsonObject" - ArtifactList: + required: + - id + - name + - version + - protocolVersion + - topologies + - capabilities + RuntimeDescription: type: object - required: [artifacts] properties: - artifacts: + name: + type: string + minLength: 1 + version: + type: string + minLength: 1 + protocolVersion: + type: string + const: 2026-09-02 + providers: type: array items: - $ref: "#/components/schemas/Artifact" + $ref: "#/components/schemas/ProviderManifest" + required: + - name + - version + - protocolVersion + - providers RuntimeEvent: type: object - required: [id, executionId, sequence, type, time, data] properties: id: type: string + minLength: 1 executionId: type: string + minLength: 1 sequence: type: integer - minimum: 1 + exclusiveMinimum: 0 + maximum: 9007199254740991 type: - type: string - enum: - - run.queued - - run.started - - output.text.delta - - output.text.done - - action.required - - action.responded - - artifact.created - - provider.event - - run.completed - - run.failed - - run.cancelled + $ref: "#/components/schemas/StandardEventType" time: type: string format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ data: $ref: "#/components/schemas/JsonObject" - EventList: - type: object - required: [events, nextCursor] - properties: - events: - type: array - items: - $ref: "#/components/schemas/RuntimeEvent" - nextCursor: - type: integer - minimum: 0 - Problem: + required: + - id + - executionId + - sequence + - type + - time + - data + RuntimeTopology: + type: string + enum: + - embedded + - subprocess + - remote + StandardEventType: + type: string + enum: + - run.queued + - run.started + - output.text.delta + - output.text.done + - action.required + - action.responded + - artifact.created + - provider.event + - run.completed + - run.failed + - run.cancelled + StartExecutionRequest: type: object - required: [code, message] properties: - code: + conversationId: type: string - message: + minLength: 1 + providerId: type: string - details: + minLength: 1 + input: + type: string + requiredCapabilities: + type: array + items: + type: string + minLength: 1 + config: $ref: "#/components/schemas/JsonObject" + idempotencyKey: + type: string + minLength: 1 + maxLength: 200 + required: + - conversationId + - providerId + - input + SupportLevel: + type: string + enum: + - native + - emulated + - degraded + - unsupported diff --git a/tests/acp-provider.test.ts b/tests/acp-provider.test.ts new file mode 100644 index 0000000..7704c80 --- /dev/null +++ b/tests/acp-provider.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { InMemoryHarnessRuntime } from '../packages/core/src/index.js' +import { AcpProvider } from '../packages/provider-acp/src/index.js' + +describe('ACP provider configuration and preflight', () => { + it('rejects invalid limits and missing commands', () => { + expect(() => new AcpProvider({ command: '' })).toThrow('ACP command is required') + for (const limit of [0, -1, Number.POSITIVE_INFINITY, 1.5]) { + expect( + () => new AcpProvider({ command: 'synthetic-agent', shutdownGraceMs: limit }), + ).toThrow() + } + }) + + it('rejects unsupported continuity before any subprocess is scheduled', () => { + const provider = new AcpProvider({ command: 'must-not-launch' }) + const runtime = new InMemoryHarnessRuntime([provider]) + expect(() => + runtime.startExecution({ + conversationId: runtime.createConversation().id, + providerId: 'acp', + input: 'synthetic preflight', + requiredCapabilities: ['conversation.continuity'], + }), + ).toThrowError(expect.objectContaining({ code: 'CAPABILITY_UNSUPPORTED' })) + expect(provider.manifest.capabilities['execution.cancel']).toBe('emulated') + expect(provider.manifest.capabilities['extension.sandbox']).toBe('unsupported') + }) + + it('rejects untrusted execution config before spawning a process', async () => { + const runtime = new InMemoryHarnessRuntime([new AcpProvider({ command: 'must-not-launch' })]) + const execution = runtime.startExecution({ + conversationId: runtime.createConversation().id, + providerId: 'acp', + input: 'synthetic request', + config: { command: 'untrusted-command' }, + }) + for await (const _event of runtime.subscribeEvents(execution.id)) { + } + expect(runtime.getExecution(execution.id)).toMatchObject({ + state: 'failed', + error: { + message: 'ACP execution config must be empty; configure the trusted provider constructor', + }, + }) + }) +}) diff --git a/tests/fixtures/acp-agent.mjs b/tests/fixtures/acp-agent.mjs new file mode 100644 index 0000000..47618ee --- /dev/null +++ b/tests/fixtures/acp-agent.mjs @@ -0,0 +1,139 @@ +import { writeFileSync } from 'node:fs' +import { isAbsolute } from 'node:path' +import { Readable, Writable } from 'node:stream' +import * as acp from '@agentclientprotocol/sdk' + +const mode = process.env.FIXTURE_MODE ?? 'success' +const state = { + pid: process.pid, + inheritedParent: process.env.HARNESS_PARENT_SENTINEL !== undefined, + clientFilesystem: false, + clientTerminal: false, + cwdAbsolute: false, + cancellationNotified: false, + permissions: [], + forbiddenErrors: [], + exited: false, +} +const record = () => { + if (process.env.FIXTURE_TRACE_PATH) + writeFileSync(process.env.FIXTURE_TRACE_PATH, JSON.stringify(state)) +} +record() +process.on('exit', () => { + state.exited = true + record() +}) +if (mode === 'stubborn') { + process.on('SIGTERM', () => undefined) + setInterval(() => undefined, 100) +} +let pendingPrompt +const app = acp + .agent({ name: 'synthetic-agent' }) + .onRequest(acp.methods.agent.initialize, ({ params }) => { + state.clientFilesystem = Boolean( + params.clientCapabilities?.fs?.readTextFile || params.clientCapabilities?.fs?.writeTextFile, + ) + state.clientTerminal = params.clientCapabilities?.terminal === true + record() + return { + protocolVersion: mode === 'bad-version' ? 999 : acp.PROTOCOL_VERSION, + agentCapabilities: {}, + authMethods: [], + } + }) + .onRequest(acp.methods.agent.session.new, ({ params }) => { + state.cwdAbsolute = isAbsolute(params.cwd) + record() + return { sessionId: 'fixture-session' } + }) + .onNotification(acp.methods.agent.session.cancel, () => { + state.cancellationNotified = true + record() + if (mode !== 'stubborn') pendingPrompt?.({ stopReason: 'cancelled' }) + }) + .onRequest(acp.methods.agent.session.prompt, async ({ client, params }) => { + const update = (text, sessionId = params.sessionId) => + client.notify(acp.methods.client.session.update, { + sessionId, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, + }) + await update('') + if (mode === 'hang' || mode === 'stubborn') { + return new Promise((resolve) => { + pendingPrompt = resolve + }) + } + if (mode === 'crash') { + process.stderr.write('synthetic-private-diagnostic') + process.exit(2) + } + if (mode === 'foreign-session') { + await update('foreign data', 'foreign-session') + } + if (mode === 'oversized') await update('x'.repeat(4_096)) + if (mode.startsWith('permission')) { + const ask = async (toolCallId) => { + const result = await client.request(acp.methods.client.session.requestPermission, { + sessionId: params.sessionId, + toolCall: { toolCallId, title: 'Synthetic permission' }, + options: + mode === 'permission-always' + ? [{ optionId: 'allow-always', name: 'Allow always', kind: 'allow_always' }] + : [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'allow-always', name: 'Allow always', kind: 'allow_always' }, + { optionId: 'reject-once', name: 'Reject once', kind: 'reject_once' }, + ], + }) + state.permissions.push(result.outcome) + record() + return result + } + const results = + mode === 'permission-parallel' + ? await Promise.all([ask('tool-one'), ask('tool-two')]) + : [await ask('tool-one')] + if (results.some((result) => result.outcome.outcome === 'cancelled')) { + return { stopReason: 'cancelled' } + } + } + if (mode === 'forbidden-client') { + const requests = [ + [ + acp.methods.client.fs.readTextFile, + { sessionId: params.sessionId, path: process.env.FIXTURE_DENIED_TARGET }, + ], + [ + acp.methods.client.fs.writeTextFile, + { + sessionId: params.sessionId, + path: process.env.FIXTURE_DENIED_TARGET, + content: 'mutated', + }, + ], + [ + acp.methods.client.terminal.create, + { sessionId: params.sessionId, command: 'untrusted-command' }, + ], + ] + for (const [method, request] of requests) { + try { + await client.request(method, request) + } catch (error) { + state.forbiddenErrors.push(error.code) + } + } + record() + } + await update('ACP ') + await update('fixture') + return { stopReason: 'end_turn' } + }) +const connection = app.connect( + acp.ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)), +) +void connection.closed.then(() => { + if (mode !== 'stubborn') process.stdin.destroy() +}) diff --git a/tests/integration/acp-provider.integration.ts b/tests/integration/acp-provider.integration.ts new file mode 100644 index 0000000..0945fe0 --- /dev/null +++ b/tests/integration/acp-provider.integration.ts @@ -0,0 +1,251 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it, vi } from 'vitest' +import { InMemoryHarnessRuntime } from '../../packages/core/src/index.js' +import { runProviderConformance } from '../../packages/conformance/src/index.js' +import { AcpProvider } from '../../packages/provider-acp/src/index.js' +import type { ActionResponse } from '../../packages/protocol/src/index.js' + +const fixture = fileURLToPath(new URL('../fixtures/acp-agent.mjs', import.meta.url)) + +interface Trace { + pid: number + inheritedParent: boolean + clientFilesystem: boolean + clientTerminal: boolean + cwdAbsolute: boolean + cancellationNotified: boolean + permissions: Array<{ outcome: string; optionId?: string }> + forbiddenErrors: number[] + exited: boolean +} + +async function setup(mode: string, maxMessageBytes?: number) { + const directory = await mkdtemp(join(tmpdir(), 'harness-acp-')) + const tracePath = join(directory, 'trace.json') + const deniedTarget = join(directory, 'protected.txt') + await writeFile(deniedTarget, 'synthetic protected text') + const provider = new AcpProvider({ + command: process.execPath, + args: [fixture], + env: { FIXTURE_MODE: mode, FIXTURE_TRACE_PATH: tracePath, FIXTURE_DENIED_TARGET: deniedTarget }, + initializationTimeoutMs: 1_000, + runTimeoutMs: 3_000, + shutdownGraceMs: 100, + ...(maxMessageBytes === undefined ? {} : { maxMessageBytes }), + }) + const runtime = new InMemoryHarnessRuntime([provider]) + const execution = runtime.startExecution({ + conversationId: runtime.createConversation().id, + providerId: 'acp', + input: 'synthetic ACP prompt', + }) + return { + provider, + runtime, + execution, + trace: async () => JSON.parse(await readFile(tracePath, 'utf8')) as Trace, + protectedText: () => readFile(deniedTarget, 'utf8'), + cleanup: async () => { + await runtime.cancelExecution(execution.id) + await rm(directory, { recursive: true, force: true }) + }, + } +} + +async function drain(runtime: InMemoryHarnessRuntime, id: string, response?: ActionResponse) { + const events = [] + for await (const event of runtime.subscribeEvents(id)) { + events.push(event) + if (event.type === 'action.required' && response !== undefined) { + runtime.respondAction(id, String(event.data.id), response) + } + } + return events +} + +function assertExited(trace: Trace) { + expect(trace.pid).toBeGreaterThan(0) + expect(() => process.kill(trace.pid, 0)).toThrowError(expect.objectContaining({ code: 'ESRCH' })) +} + +describe('ACP v1 adapter through the official SDK and owned subprocess', () => { + it('normalizes streamed text, disables client capabilities, and isolates environment', async () => { + vi.stubEnv('HARNESS_PARENT_SENTINEL', 'synthetic-parent-value') + const task = await setup('success') + try { + const events = await drain(task.runtime, task.execution.id) + expect(task.runtime.getExecution(task.execution.id)).toMatchObject({ + state: 'succeeded', + finalOutput: 'ACP fixture', + }) + expect( + events + .filter((event) => event.type === 'output.text.delta') + .map((event) => event.data.text), + ).toEqual(['', 'ACP ', 'fixture']) + const trace = await task.trace() + expect(trace).toMatchObject({ + inheritedParent: false, + clientFilesystem: false, + clientTerminal: false, + cwdAbsolute: true, + }) + assertExited(trace) + } finally { + vi.unstubAllEnvs() + await task.cleanup() + } + }) + + it.each<{ response: ActionResponse; outcome: string; optionId?: string; state: string }>([ + { + response: { approved: true }, + outcome: 'selected', + optionId: 'allow-once', + state: 'succeeded', + }, + { + response: { approved: false }, + outcome: 'selected', + optionId: 'reject-once', + state: 'succeeded', + }, + { + response: { approved: true, value: { optionId: 'allow-always' } }, + outcome: 'selected', + optionId: 'allow-always', + state: 'succeeded', + }, + { + response: { approved: false, value: { optionId: 'allow-once' } }, + outcome: 'cancelled', + state: 'failed', + }, + { + response: { approved: true, value: { optionId: 'unknown' } }, + outcome: 'cancelled', + state: 'failed', + }, + { + response: { approved: true, value: { malformed: true } }, + outcome: 'cancelled', + state: 'failed', + }, + ])( + 'maps explicit permission response $response to $outcome', + async ({ response, outcome, optionId, state }) => { + const task = await setup('permission') + try { + const events = await drain(task.runtime, task.execution.id, response) + expect(events.filter((event) => event.type === 'action.required')).toHaveLength(1) + expect(task.runtime.getExecution(task.execution.id).state).toBe(state) + expect((await task.trace()).permissions).toEqual([ + { outcome, ...(optionId === undefined ? {} : { optionId }) }, + ]) + assertExited(await task.trace()) + } finally { + await task.cleanup() + } + }, + ) + + it('does not convert approve-once into an always grant', async () => { + const task = await setup('permission-always') + try { + await drain(task.runtime, task.execution.id, { approved: true }) + expect((await task.trace()).permissions).toEqual([{ outcome: 'cancelled' }]) + expect(task.runtime.getExecution(task.execution.id).state).toBe('failed') + } finally { + await task.cleanup() + } + }) + + it('serializes concurrent native permission requests into correlated Actions', async () => { + const task = await setup('permission-parallel') + try { + const events = await drain(task.runtime, task.execution.id, { approved: true }) + expect(task.runtime.getExecution(task.execution.id).state).toBe('succeeded') + expect(events.filter((event) => event.type === 'action.required')).toHaveLength(2) + expect(events.filter((event) => event.type === 'action.responded')).toHaveLength(2) + expect((await task.trace()).permissions).toHaveLength(2) + } finally { + await task.cleanup() + } + }) + + it.each(['hang', 'stubborn', 'permission'])( + 'cancels %s, reaps the process, and emits one terminal event', + async (mode) => { + const task = await setup(mode) + try { + for await (const event of task.runtime.subscribeEvents(task.execution.id)) { + if (event.type === (mode === 'permission' ? 'action.required' : 'output.text.delta')) + break + } + await task.runtime.cancelExecution(task.execution.id) + const events = task.runtime.listEvents(task.execution.id).events + expect(task.runtime.getExecution(task.execution.id).state).toBe('cancelled') + expect(events.filter((event) => event.type === 'run.cancelled')).toHaveLength(1) + expect(events.at(-1)?.data.providerCleanupError).toBeUndefined() + const trace = await task.trace() + expect(trace.cancellationNotified).toBe(true) + if (mode === 'permission') expect(trace.permissions).toEqual([{ outcome: 'cancelled' }]) + assertExited(trace) + } finally { + await task.cleanup() + } + }, + ) + + it.each(['bad-version', 'crash', 'foreign-session', 'oversized'])( + 'fails %s without leaking diagnostics', + async (mode) => { + const task = await setup(mode, mode === 'oversized' ? 1_024 : undefined) + try { + await drain(task.runtime, task.execution.id) + expect(task.runtime.getExecution(task.execution.id)).toMatchObject({ + state: 'failed', + error: { code: 'PROVIDER_ERROR', message: 'ACP agent request failed' }, + }) + expect( + task.runtime + .listEvents(task.execution.id) + .events.some((event) => event.data.text === 'foreign data'), + ).toBe(false) + assertExited(await task.trace()) + } finally { + await task.cleanup() + } + }, + ) + + it('denies unadvertised filesystem and terminal requests without modifying files', async () => { + const task = await setup('forbidden-client') + try { + await drain(task.runtime, task.execution.id) + expect(task.runtime.getExecution(task.execution.id).state).toBe('succeeded') + expect((await task.trace()).forbiddenErrors).toEqual([-32601, -32601, -32601]) + expect(await task.protectedText()).toBe('synthetic protected text') + } finally { + await task.cleanup() + } + }) + + it('passes capability conformance through the actual ACP transport', async () => { + const task = await setup('permission') + try { + await drain(task.runtime, task.execution.id, { approved: true }) + const report = await runProviderConformance(task.provider, { + requiredCapabilities: ['action.approval'], + actionResponse: { approved: true }, + timeoutMs: 5_000, + }) + expect(report.passed).toBe(true) + } finally { + await task.cleanup() + } + }) +}) diff --git a/tests/integration/http-server.integration.ts b/tests/integration/http-server.integration.ts index fd64c5a..1376a7c 100644 --- a/tests/integration/http-server.integration.ts +++ b/tests/integration/http-server.integration.ts @@ -74,6 +74,58 @@ beforeAll(async () => { afterAll(stopServer) describe('Built reference server over real loopback HTTP', () => { + it.each(['-1', '1.5', 'not-a-number', String(Number.MAX_SAFE_INTEGER + 1)])( + 'rejects invalid cursor %s before opening an SSE stream', + async (cursor) => { + const response = await fetch( + `${baseUrl}/v1/executions/missing/events?after=${encodeURIComponent(cursor)}`, + { + headers: { accept: 'text/event-stream' }, + }, + ) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ code: 'VALIDATION_ERROR' }) + }, + ) + + it('rejects an invalid action response without resolving approval, then rejects duplicate responses', async () => { + const execution = await client.startExecution({ + conversationId: (await client.createConversation()).id, + providerId: 'mock', + input: 'synthetic invalid response', + config: { requireApproval: true }, + }) + try { + let actionId: string | undefined + for await (const event of client.streamEvents(execution.id, { + signal: AbortSignal.timeout(5_000), + })) { + if (event.type === 'action.required') { + actionId = String(event.data.id) + break + } + } + if (!actionId) throw new Error('Expected a synthetic approval') + const invalid = await fetch( + `${baseUrl}/v1/executions/${execution.id}/actions/${actionId}:respond`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ approved: 'yes' }), + }, + ) + expect(invalid.status).toBe(400) + expect((await client.getExecution(execution.id)).state).toBe('awaiting_approval') + await client.respondAction(execution.id, actionId, { approved: true }) + expect((await client.waitForTerminal(execution.id)).state).toBe('succeeded') + await expect( + client.respondAction(execution.id, actionId, { approved: true }), + ).rejects.toMatchObject({ status: 409, problem: { code: 'ACTION_ALREADY_RESOLVED' } }) + } finally { + await client.cancelExecution(execution.id) + } + }) + it('supports idempotency, approval, Artifact listing, and replay on the network', async () => { expect((await client.describeRuntime()).providers[0]?.id).toBe('mock') const request = { diff --git a/tests/openapi-parity.test.ts b/tests/openapi-parity.test.ts new file mode 100644 index 0000000..e26a2c1 --- /dev/null +++ b/tests/openapi-parity.test.ts @@ -0,0 +1,141 @@ +import { readFile } from 'node:fs/promises' +import { Ajv2020 } from 'ajv/dist/2020.js' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { runtimeSchemas } from '../scripts/openapi-schemas.mjs' +import * as protocol from '../packages/protocol/src/index.js' +import { InMemoryHarnessRuntime } from '../packages/core/src/index.js' +import { MockProvider } from '../packages/provider-mock/src/index.js' +import { createApp } from '../packages/server/src/index.js' + +const document = parse(await readFile(new URL('../spec/openapi.yaml', import.meta.url), 'utf8')) +// Format is an annotation in this dialect; generated patterns enforce timestamp input. +const validator = new Ajv2020({ + strict: false, + strictNumbers: true, + validateFormats: false, + allErrors: true, +}) +validator.addSchema({ $id: 'https://runtime.test/openapi', components: document.components }) +const validate = (name: string) => + validator.compile({ $ref: `https://runtime.test/openapi#/components/schemas/${name}` }) + +describe('OpenAPI projection of the executable portable contract', () => { + it('keeps every generated component synchronized with its Zod source', () => { + expect(document.components.schemas).toEqual(runtimeSchemas()) + }) + + it.each([ + { + name: 'StartExecutionRequest', + schema: protocol.StartExecutionRequestSchema, + base: { conversationId: 'conv_fixture', providerId: 'mock', input: 'synthetic' }, + variants: [ + { conversationId: '' }, + { providerId: '' }, + { idempotencyKey: '' }, + { idempotencyKey: 'x'.repeat(201) }, + { requiredCapabilities: [''] }, + { config: { nested: [1, null, true] } }, + ], + }, + { + name: 'ProviderManifest', + schema: protocol.ProviderManifestSchema, + base: { + id: 'fixture', + name: 'Fixture', + version: '1', + protocolVersion: protocol.PROTOCOL_VERSION, + topologies: ['embedded'], + capabilities: {}, + }, + variants: [ + { id: '' }, + { topologies: [] }, + { capabilities: { '': 'native' } }, + { capabilities: { 'event.streaming': 'unknown' } }, + { protocolVersion: 'wrong-version' }, + ], + }, + { + name: 'RuntimeEvent', + schema: protocol.RuntimeEventSchema, + base: { + id: 'exec_fixture:1', + executionId: 'exec_fixture', + sequence: 1, + type: 'run.queued', + time: '2026-10-09T00:00:00.000Z', + data: {}, + }, + variants: [ + { id: '' }, + { sequence: 0 }, + { sequence: 1.5 }, + { sequence: Number.MAX_SAFE_INTEGER + 1 }, + { time: '2026-02-30T00:00:00Z' }, + { time: '2026-10-09T00:00:00+08:00' }, + { time: '2026-10-09T00:00Z' }, + { type: 'unknown' }, + { data: { invalid: Number.POSITIVE_INFINITY } }, + ], + }, + { + name: 'CancelExecutionRequest', + schema: protocol.CancelExecutionRequestSchema, + base: {}, + variants: [ + { reason: '' }, + { reason: 'x'.repeat(501) }, + { reason: 42 }, + { reason: 'synthetic reason' }, + ], + }, + ])( + 'matches independent JSON Schema validation for $name boundary samples', + ({ name, schema, base, variants }) => { + const check = validate(name) + for (const sample of [base, ...variants.map((variant) => ({ ...base, ...variant }))]) { + expect(check(sample)).toBe(schema.safeParse(sample).success) + } + }, + ) + + it('validates actual HTTP response payloads against the published schemas', async () => { + const app = createApp(new InMemoryHarnessRuntime([new MockProvider()])) + const conversation = await (await app.request('/v1/conversations', { method: 'POST' })).json() + const execution = await ( + await app.request('/v1/executions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + conversationId: conversation.id, + providerId: 'mock', + input: 'synthetic parity', + config: { artifactName: 'synthetic.txt' }, + }), + }) + ).json() + const stream = await app.request(`/v1/executions/${execution.id}/events`, { + headers: { accept: 'text/event-stream' }, + }) + const reader = stream.body?.getReader() + if (!reader) throw new Error('Expected an event stream') + try { + while (!(await reader.read()).done) {} + } finally { + reader.releaseLock() + } + for (const [name, path] of [ + ['RuntimeDescription', '/v1/runtime'], + ['Conversation', `/v1/conversations/${conversation.id}`], + ['Execution', `/v1/executions/${execution.id}`], + ['EventList', `/v1/executions/${execution.id}/events`], + ['ArtifactList', `/v1/executions/${execution.id}/artifacts`], + ]) { + const payload = await (await app.request(path as string)).json() + expect(validate(name as string)(payload)).toBe(true) + } + }) +}) diff --git a/tsconfig.json b/tsconfig.json index a3b9c72..4b3067e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ { "path": "./packages/core" }, { "path": "./packages/provider-mock" }, { "path": "./packages/provider-dsh" }, + { "path": "./packages/provider-acp" }, { "path": "./packages/conformance" }, { "path": "./packages/server" }, { "path": "./packages/sdk-typescript" } From 229eeba7ba962606f38183719947afddb5c8ccc2 Mon Sep 17 00:00:00 2001 From: shwang Date: Fri, 9 Oct 2026 16:38:40 +0800 Subject: [PATCH 2/3] fix: enforce ACP peer boundaries without raw diagnostic logging --- docs/guides/acp-provider.md | 3 + packages/provider-acp/package.json | 1 + packages/provider-acp/src/boundary.ts | 90 +++++++++++++++++ packages/provider-acp/src/index.ts | 99 +++++++++++-------- pnpm-lock.yaml | 3 + tests/fixtures/acp-agent.mjs | 25 +++++ tests/integration/acp-provider.integration.ts | 21 +++- 7 files changed, 200 insertions(+), 42 deletions(-) create mode 100644 packages/provider-acp/src/boundary.ts diff --git a/docs/guides/acp-provider.md b/docs/guides/acp-provider.md index 1c10c8c..95a3e36 100644 --- a/docs/guides/acp-provider.md +++ b/docs/guides/acp-provider.md @@ -63,6 +63,9 @@ portable permission store. The adapter rejects unrelated-session updates, bounds inbound message size and run duration, discards stderr, and returns generic request errors instead of forwarding native diagnostics. +Incoming envelopes and registered notification parameters are checked against the SDK's public +JSON Schema before routing, so malformed messages cannot reach its raw diagnostic logger. JSON +syntax errors retain the SDK's parse-error reply/recovery behavior and are not logged with raw input. Native updates may still contain sensitive tool or file information; restrict access and redact before exporting those events. These protections do not isolate the native process's filesystem, network, subprocesses, or descendants. Use a deployment-owned sandbox and least-privilege account. diff --git a/packages/provider-acp/package.json b/packages/provider-acp/package.json index cd6b670..21b1e9f 100644 --- a/packages/provider-acp/package.json +++ b/packages/provider-acp/package.json @@ -21,6 +21,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "ajv": "8.20.0", "@agentclientprotocol/sdk": "1.7.0", "@harness-runtime/core": "workspace:*", "@harness-runtime/protocol": "workspace:*" diff --git a/packages/provider-acp/src/boundary.ts b/packages/provider-acp/src/boundary.ts new file mode 100644 index 0000000..069aebb --- /dev/null +++ b/packages/provider-acp/src/boundary.ts @@ -0,0 +1,90 @@ +import type { AnyMessage, Stream } from '@agentclientprotocol/sdk' +import schema from '@agentclientprotocol/sdk/schema/schema.json' with { type: 'json' } +import { Ajv2020 } from 'ajv/dist/2020.js' + +const validator = new Ajv2020({ strict: false, strictNumbers: true, validateFormats: false }) +validator.addSchema({ ...schema, $id: 'https://runtime.test/acp-schema' }) +const messageValid = validator.compile({ $ref: 'https://runtime.test/acp-schema' }) +const parameterValidators = { + 'session/update': validator.compile({ + $ref: 'https://runtime.test/acp-schema#/$defs/SessionNotification', + }), + 'session/request_permission': validator.compile({ + $ref: 'https://runtime.test/acp-schema#/$defs/RequestPermissionRequest', + }), + '$/cancel_request': validator.compile({ + $ref: 'https://runtime.test/acp-schema#/$defs/CancelRequestNotification', + }), +} + +export function safeStream(stream: Stream, fail: () => void): Stream { + const reader = stream.readable.getReader() + const writer = stream.writable.getWriter() + const outstanding = new Set() + let readerReleased = false + const releaseReader = () => { + if (!readerReleased) { + readerReleased = true + reader.releaseLock() + } + } + return { + writable: new WritableStream({ + async write(message) { + if ('method' in message && 'id' in message && message.id !== null) + outstanding.add(message.id) + await writer.write(message) + }, + async close() { + try { + await writer.close() + } finally { + writer.releaseLock() + } + }, + async abort() { + try { + await writer.abort(new Error('ACP connection closed')) + } finally { + writer.releaseLock() + } + }, + }), + readable: new ReadableStream({ + async pull(controller) { + try { + const item = await reader.read() + if (item.done) { + releaseReader() + controller.close() + return + } + const message = item.value + if (!messageValid(message)) throw new Error('Invalid ACP envelope') + if ('method' in message) { + const check = parameterValidators[message.method as keyof typeof parameterValidators] + if (!('id' in message) && !check) throw new Error('Unsupported ACP notification') + if (check && !check(message.params)) throw new Error('Invalid ACP parameters') + } else if ('id' in message) { + if (message.id === null || !outstanding.delete(message.id)) + throw new Error('Uncorrelated ACP response') + } + controller.enqueue(message) + } catch { + fail() + controller.error(new Error('ACP transport failed')) + await reader.cancel().catch(() => undefined) + releaseReader() + } + }, + async cancel() { + if (readerReleased) return + try { + await reader.cancel() + } finally { + releaseReader() + } + }, + }), + } +} diff --git a/packages/provider-acp/src/index.ts b/packages/provider-acp/src/index.ts index 28f6b96..0ee8e14 100644 --- a/packages/provider-acp/src/index.ts +++ b/packages/provider-acp/src/index.ts @@ -12,6 +12,7 @@ import { StandardCapability, } from '@harness-runtime/protocol' import { startProcess, within } from './process.js' +import { safeStream } from './boundary.js' export interface AcpProviderOptions { command: string @@ -93,6 +94,11 @@ export class AcpProvider implements HarnessProvider { const pendingPermissions = new Set>() const output: string[] = [] let cancellation: Promise | undefined + let rejectViolation: () => void = () => undefined + const violation = new Promise((_resolve, reject) => { + rejectViolation = () => reject(new Error('ACP peer violated the adapter boundary')) + }) + void violation.catch(() => undefined) const cancel = (): Promise => { cancellation ??= (async () => { try { @@ -126,15 +132,20 @@ export class AcpProvider implements HarnessProvider { .client({ name: 'harness-runtime-api' }) .onNotification(acp.methods.client.session.update, ({ params }) => { if (!sessionId || params.sessionId !== sessionId) { - throw new Error('ACP agent reported an unrelated session') + rejectViolation() + return } - nativeEvent(context, 'session/update', params) - if ( - params.update.sessionUpdate === 'agent_message_chunk' && - params.update.content.type === 'text' - ) { - output.push(params.update.content.text) - context.emit('output.text.delta', { text: params.update.content.text }) + try { + nativeEvent(context, 'session/update', params) + if ( + params.update.sessionUpdate === 'agent_message_chunk' && + params.update.content.type === 'text' + ) { + output.push(params.update.content.text) + context.emit('output.text.delta', { text: params.update.content.text }) + } + } catch { + rejectViolation() } }) .onRequest(acp.methods.client.session.requestPermission, ({ params }) => { @@ -172,44 +183,50 @@ export class AcpProvider implements HarnessProvider { return permission }) connection = app.connect( - acp.ndJsonStream( - Writable.toWeb(owned.child.stdin), - Readable.toWeb(owned.child.stdout) as ReadableStream, - { maxMessageBytes: this.options.maxMessageBytes }, + safeStream( + acp.ndJsonStream( + Writable.toWeb(owned.child.stdin), + Readable.toWeb(owned.child.stdout) as ReadableStream, + { maxMessageBytes: this.options.maxMessageBytes }, + ), + rejectViolation, ), ) const agent = connection.agent return await within( - (async () => { - const initialized = await within( - agent.request(acp.methods.agent.initialize, { - protocolVersion: acp.PROTOCOL_VERSION, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - }), - this.options.initializationTimeoutMs, - 'ACP initialization timed out', - ) - if (initialized.protocolVersion !== acp.PROTOCOL_VERSION) - throw new Error('ACP protocol version mismatch') - context.signal.throwIfAborted() - sessionId = ( - await agent.request(acp.methods.agent.session.new, { - cwd: this.options.cwd, - mcpServers: [], + Promise.race([ + (async () => { + const initialized = await within( + agent.request(acp.methods.agent.initialize, { + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }), + this.options.initializationTimeoutMs, + 'ACP initialization timed out', + ) + if (initialized.protocolVersion !== acp.PROTOCOL_VERSION) + throw new Error('ACP protocol version mismatch') + context.signal.throwIfAborted() + sessionId = ( + await agent.request(acp.methods.agent.session.new, { + cwd: this.options.cwd, + mcpServers: [], + }) + ).sessionId + context.signal.throwIfAborted() + const result = await agent.request(acp.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: context.input }], }) - ).sessionId - context.signal.throwIfAborted() - const result = await agent.request(acp.methods.agent.session.prompt, { - sessionId, - prompt: [{ type: 'text', text: context.input }], - }) - if (result.stopReason === 'cancelled') throw new Error('ACP native turn was cancelled') - nativeEvent(context, 'turn.completed', { stopReason: result.stopReason }) - return { finalOutput: output.join('') } - })(), + if (result.stopReason === 'cancelled') throw new Error('ACP native turn was cancelled') + nativeEvent(context, 'turn.completed', { stopReason: result.stopReason }) + return { finalOutput: output.join('') } + })(), + violation, + ]), this.options.runTimeoutMs, 'ACP execution timed out', ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e2439f..70b3e50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@harness-runtime/protocol': specifier: workspace:* version: link:../protocol + ajv: + specifier: 8.20.0 + version: 8.20.0 packages/provider-dsh: dependencies: diff --git a/tests/fixtures/acp-agent.mjs b/tests/fixtures/acp-agent.mjs index 47618ee..5668d66 100644 --- a/tests/fixtures/acp-agent.mjs +++ b/tests/fixtures/acp-agent.mjs @@ -1,6 +1,7 @@ import { writeFileSync } from 'node:fs' import { isAbsolute } from 'node:path' import { Readable, Writable } from 'node:stream' +import { createInterface } from 'node:readline' import * as acp from '@agentclientprotocol/sdk' const mode = process.env.FIXTURE_MODE ?? 'success' @@ -14,12 +15,24 @@ const state = { permissions: [], forbiddenErrors: [], exited: false, + parseErrors: 0, } const record = () => { if (process.env.FIXTURE_TRACE_PATH) writeFileSync(process.env.FIXTURE_TRACE_PATH, JSON.stringify(state)) } record() +const inputObserver = createInterface({ input: process.stdin }) +inputObserver.on('line', (line) => { + try { + if (JSON.parse(line).error?.code === -32700) { + state.parseErrors += 1 + record() + } + } catch { + /* Observe only protocol parse-error replies, not prompt contents. */ + } +}) process.on('exit', () => { state.exited = true record() @@ -60,6 +73,18 @@ const app = acp update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }, }) await update('') + if (mode === 'bad-envelope') + process.stdout.write(`${JSON.stringify({ invalid: 'synthetic-boundary-canary' })}\n`) + if (mode === 'bad-json') process.stdout.write('{synthetic-boundary-canary\n') + if (mode === 'bad-params') { + await client.notify(acp.methods.client.session.update, { + sessionId: params.sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: { canary: 'synthetic-boundary-canary' } }, + }, + }) + } if (mode === 'hang' || mode === 'stubborn') { return new Promise((resolve) => { pendingPrompt = resolve diff --git a/tests/integration/acp-provider.integration.ts b/tests/integration/acp-provider.integration.ts index 0945fe0..685f0b4 100644 --- a/tests/integration/acp-provider.integration.ts +++ b/tests/integration/acp-provider.integration.ts @@ -20,6 +20,7 @@ interface Trace { permissions: Array<{ outcome: string; optionId?: string }> forbiddenErrors: number[] exited: boolean + parseErrors: number } async function setup(mode: string, maxMessageBytes?: number) { @@ -200,9 +201,10 @@ describe('ACP v1 adapter through the official SDK and owned subprocess', () => { }, ) - it.each(['bad-version', 'crash', 'foreign-session', 'oversized'])( + it.each(['bad-version', 'crash', 'foreign-session', 'oversized', 'bad-envelope', 'bad-params'])( 'fails %s without leaking diagnostics', async (mode) => { + const diagnostics = vi.spyOn(console, 'error') const task = await setup(mode, mode === 'oversized' ? 1_024 : undefined) try { await drain(task.runtime, task.execution.id) @@ -216,12 +218,29 @@ describe('ACP v1 adapter through the official SDK and owned subprocess', () => { .events.some((event) => event.data.text === 'foreign data'), ).toBe(false) assertExited(await task.trace()) + expect(diagnostics).not.toHaveBeenCalled() } finally { + diagnostics.mockRestore() await task.cleanup() } }, ) + it('uses the SDK parse-error recovery without logging malformed raw input', async () => { + const diagnostics = vi.spyOn(console, 'error') + const task = await setup('bad-json') + try { + await drain(task.runtime, task.execution.id) + expect(task.runtime.getExecution(task.execution.id).state).toBe('succeeded') + expect((await task.trace()).parseErrors).toBe(1) + expect(diagnostics).not.toHaveBeenCalled() + assertExited(await task.trace()) + } finally { + diagnostics.mockRestore() + await task.cleanup() + } + }) + it('denies unadvertised filesystem and terminal requests without modifying files', async () => { const task = await setup('forbidden-client') try { From 6440399ba0c1db430ab408fc709d78e99ee74ee5 Mon Sep 17 00:00:00 2001 From: shwang Date: Fri, 9 Oct 2026 16:49:10 +0800 Subject: [PATCH 3/3] docs: document ACP delivery and bound native permission queues --- CHANGELOG.md | 9 ++++++ README.md | 3 ++ README.zh-CN.md | 3 ++ SECURITY.md | 6 ++++ docs/README.md | 1 + docs/development/delivery-plan.md | 29 +++++++++++++++---- docs/guides/acp-provider.md | 1 + packages/provider-acp/src/index.ts | 6 ++++ spec/README.md | 6 +++- tests/integration/acp-provider.integration.ts | 13 ++++++++- 10 files changed, 69 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61d506f..312686d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ changes. ## [Unreleased] +### Added + +- ACP v1 subprocess Provider through the official SDK, with correlated human permissions, + one-time versus explicit remembered choices, serialized concurrent requests, and bounded process + teardown. Client filesystem/terminal access is disabled; native isolation remains deployment-owned. +- Generated OpenAPI schema projection and independent contract/HTTP compatibility checks. +- Real ACP transport tests using a synthetic Agent, including scope violations, malformed + messages, diagnostic redaction, process exit, and permission cancellation. + ### Fixed - Provider emissions now reject reserved lifecycle events and malformed portable payloads before diff --git a/README.md b/README.md index 117a42c..38ba9fd 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Harness Runtime API introduces three explicit boundaries: | Artifact descriptors | Available | Validated creation events and list API; bytes stay outside Runtime | | Mock Provider | Available | Deterministic development and conformance provider | | DSH Provider | Experimental | SDK `0.1.0-rc.7`; extensions require an explicit deployment declaration | +| ACP v1 Provider | Experimental | SDK `1.7.0`; permissions, text updates, and bounded subprocess cancellation | | Durable persistence and recovery | Not included | Current reference state is process-local | | Authentication and multi-tenancy | Not included | Must be supplied by a trusted deployment boundary | @@ -165,6 +166,7 @@ idempotency, capability preflight, cursor replay, terminal-state semantics, and | `@harness-runtime/core` | Provider SPI and in-memory reference runtime | | `@harness-runtime/provider-mock` | Deterministic provider for development and tests | | `@harness-runtime/provider-dsh` | Optional DeepSeek Harness subprocess adapter | +| `@harness-runtime/provider-acp` | ACP v1 subprocess adapter with explicit human permission mapping | | `@harness-runtime/server` | HTTP/JSON and SSE reference binding | | `@harness-runtime/sdk-typescript` | Validating TypeScript HTTP/SSE client | | `@harness-runtime/conformance` | Reusable provider lifecycle checks | @@ -184,6 +186,7 @@ Package names are workspace identifiers during the MVP and are not yet published - [Portable and provider-native boundary](docs/explanations/provider-boundary.md) - [Artifact descriptors](docs/guides/artifacts.md) - [DeepSeek Harness adapter](docs/guides/dsh-provider.md) +- [ACP v1 adapter](docs/guides/acp-provider.md) - [Production deployment boundary](docs/guides/production-deployment.md) - [Architecture decisions](spec/README.md) - [Security policy](SECURITY.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index f7f13be..dfbde17 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -46,6 +46,7 @@ Harness Runtime API 建立三个明确边界: | Artifact 描述符 | 已提供 | 创建事件与列表 API;文件内容不进入 Runtime | | Mock Provider | 已提供 | 确定性的开发和一致性测试 Provider | | DSH Provider | 实验性 | SDK `0.1.0-rc.7`;原生扩展需由可信部署显式声明 | +| ACP v1 Provider | 实验性 | SDK `1.7.0`;权限请求、文本更新和有界子进程取消 | | 持久化与重启恢复 | 未提供 | 当前参考状态只存在于进程内 | | 身份认证与多租户 | 未提供 | 必须由可信部署边界提供 | @@ -156,6 +157,7 @@ Adapter 内部可以使用 ACP 或框架 SDK。Portable Runtime 额外定义幂 | `@harness-runtime/core` | Provider SPI 与内存参考 Runtime | | `@harness-runtime/provider-mock` | 用于开发和测试的确定性 Provider | | `@harness-runtime/provider-dsh` | 可选的 DeepSeek Harness 子进程适配器 | +| `@harness-runtime/provider-acp` | ACP v1 子进程适配器,显式映射人工权限选择 | | `@harness-runtime/server` | HTTP/JSON 与 SSE 参考绑定 | | `@harness-runtime/sdk-typescript` | 带响应校验的 TypeScript HTTP/SSE Client | | `@harness-runtime/conformance` | 可复用的 Provider 生命周期一致性检查 | @@ -175,6 +177,7 @@ MVP 阶段这些名称只作为 workspace 标识,尚未发布到包注册中 - [Portable 与 Provider Native 边界](docs/explanations/provider-boundary.md) - [Artifact 描述符](docs/guides/artifacts.md) - [DeepSeek Harness Adapter](docs/guides/dsh-provider.md) +- [ACP v1 Adapter](docs/guides/acp-provider.md) - [生产部署边界](docs/guides/production-deployment.md) - [架构决策](spec/README.md) - [安全策略](SECURITY.md) diff --git a/SECURITY.md b/SECURITY.md index 59a7463..a628dbb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,3 +30,9 @@ The reference implementation does not write execution transcripts to disk or log `pnpm sanitize` uses ripgrep when available and a grep fallback otherwise. Scanner/tool errors fail the check. Matches list file names only; suspected credential values are never printed by the scanner. + +The ACP adapter uses a copied allowlist environment and direct argv launch. Client-side filesystem +and terminal capabilities are disabled; this does not constrain an agent process's own OS access. +Permission grants require explicit approval and remembered grants require an explicit option ID. +Inbound protocol envelopes are validated before SDK routing to avoid raw diagnostic logging. +Apply a deployment-owned sandbox and policies for native events before using real agents. diff --git a/docs/README.md b/docs/README.md index 3ebd319..1815ecb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ Current protocol version: `2026-09-02` | Review state and invariants | [Runtime model](../spec/runtime-model.md) | | Implement Artifact handling | [Artifact descriptors](guides/artifacts.md) | | Configure the DSH adapter | [DeepSeek Harness Provider](guides/dsh-provider.md) | +| Configure an ACP executable | [ACP v1 Provider](guides/acp-provider.md) | | Compare neighboring protocols | [Protocol stack](explanations/protocol-stack.md) | | Understand execution transitions | [Execution lifecycle](explanations/execution-lifecycle.md) | | Understand one complete run | [Execution control loop](explanations/execution-control-loop.md) | diff --git a/docs/development/delivery-plan.md b/docs/development/delivery-plan.md index d95f216..fca4035 100644 --- a/docs/development/delivery-plan.md +++ b/docs/development/delivery-plan.md @@ -10,7 +10,7 @@ delivery does not claim those features exist in the reference server. - [x] DSH compatibility is pinned to an actually tested public SDK, with subprocess integration tests, cancellation, startup failure, and explicit environment isolation. -- [ ] A second public Harness integration proves the provider boundary beyond Mock and DSH. +- [x] A second public Harness integration proves the provider boundary beyond Mock and DSH. - [x] Capability-specific conformance probes cover advertised actions, cancellation, replay, idempotency, and artifacts without assuming model behavior portability. - [x] HTTP and SDK verification uses real loopback connections, including disconnection and @@ -46,15 +46,32 @@ delivery does not claim those features exist in the reference server. ## Remaining Work -1. Implement ACP v1 integration through the public SDK, including session updates, explicit human - permissions, bounded cancellation, and denial of unadvertised client filesystem/terminal access. -2. Add executable OpenAPI/schema constraint parity and stronger negative HTTP verification. -3. Finish source/history/media/artifact privacy review and release packaging/version decisions. -4. Run final local and GitHub checks for the complete delivery, verify fresh-checkout instructions, +1. Finish documentation/visual consistency review for the complete delivery, including failure + while awaiting a human Action. +2. Finish source/history/media/artifact privacy review and release packaging/version decisions. +3. Run final local and GitHub checks for the complete delivery, verify fresh-checkout instructions, then prepare the exact candidate required by the release authorization policy. No completion claim has been made for the unchecked acceptance items. +## ACP and Schema Verification + +- ACP v1 implementation and boundary hardening were developed in + [PR 11](https://github.com/capa-cloud/harness-runtime-api/pull/11). +- [Reviewed provider/schema CI](https://github.com/capa-cloud/harness-runtime-api/actions/runs/37906236553) + passed on Node 22 and 24. The final bounded-permission revision passed local full checks with + 55 unit/contract tests and 35 integration tests. +- ACP tests use the official SDK on both sides of a real synthetic subprocess. They verify human + permission choices, simultaneous requests, cancellation while awaiting approval, forced process + exit, isolated environments, denied client file/terminal requests, unrelated-session updates, + malformed-message handling, and no raw diagnostic logging. +- Pending native permissions are bounded (default: 16); the overflow regression confirms an agent + cannot create an unbounded queue while awaiting a human response. +- OpenAPI now projects 23 named Zod schemas with generated references and constraints. Independent + JSON Schema samples and actual HTTP payload checks passed; native timestamp patterns carry the + input constraints and format is treated as an annotation. +- No real model, private platform, production resource, or user session history was accessed. + ## Verification Boundaries Default tests use synthetic data and local processes only. Native Harness/model integration is diff --git a/docs/guides/acp-provider.md b/docs/guides/acp-provider.md index 95a3e36..661aaf9 100644 --- a/docs/guides/acp-provider.md +++ b/docs/guides/acp-provider.md @@ -17,6 +17,7 @@ const provider = new AcpProvider({ runTimeoutMs: 300_000, shutdownGraceMs: 500, maxMessageBytes: 1_048_576, + maxPendingPermissions: 16, }) ``` diff --git a/packages/provider-acp/src/index.ts b/packages/provider-acp/src/index.ts index 0ee8e14..8b4e132 100644 --- a/packages/provider-acp/src/index.ts +++ b/packages/provider-acp/src/index.ts @@ -24,6 +24,7 @@ export interface AcpProviderOptions { runTimeoutMs?: number shutdownGraceMs?: number maxMessageBytes?: number + maxPendingPermissions?: number } export class AcpProvider implements HarnessProvider { @@ -43,6 +44,7 @@ export class AcpProvider implements HarnessProvider { runTimeoutMs: positive(options.runTimeoutMs ?? 300_000), shutdownGraceMs: positive(options.shutdownGraceMs ?? 500), maxMessageBytes: positive(options.maxMessageBytes ?? 1_048_576), + maxPendingPermissions: positive(options.maxPendingPermissions ?? 16), } this.manifest = ProviderManifestSchema.parse({ id: this.options.id, @@ -149,6 +151,10 @@ export class AcpProvider implements HarnessProvider { } }) .onRequest(acp.methods.client.session.requestPermission, ({ params }) => { + if (pendingPermissions.size >= this.options.maxPendingPermissions) { + rejectViolation() + return Promise.resolve(cancelledPermission()) + } const permission = permissionTail.then(async () => { if (context.signal.aborted || !sessionId || params.sessionId !== sessionId) { return cancelledPermission() diff --git a/spec/README.md b/spec/README.md index 74fd5ff..9c559e5 100644 --- a/spec/README.md +++ b/spec/README.md @@ -9,4 +9,8 @@ | [ADR 0002](decisions/0002-artifact-descriptors.md) | accepted | Portable descriptors with deployment-owned content storage | TypeScript schemas in `packages/protocol/src` are the executable source for payload validation. The -OpenAPI document is the public HTTP projection and is checked for parseability in CI. +OpenAPI document is the public HTTP projection. Its schemas are generated from the portable Zod +schemas with `pnpm spec:generate`; routes and HTTP operation descriptions remain maintained in the +OpenAPI file. CI checks generated component equality, independent JSON Schema boundary samples, +and actual HTTP response compatibility. Timestamp constraints use generated patterns; format is +an annotation under the document's JSON Schema dialect. diff --git a/tests/integration/acp-provider.integration.ts b/tests/integration/acp-provider.integration.ts index 685f0b4..5c8cce6 100644 --- a/tests/integration/acp-provider.integration.ts +++ b/tests/integration/acp-provider.integration.ts @@ -23,7 +23,7 @@ interface Trace { parseErrors: number } -async function setup(mode: string, maxMessageBytes?: number) { +async function setup(mode: string, maxMessageBytes?: number, maxPendingPermissions?: number) { const directory = await mkdtemp(join(tmpdir(), 'harness-acp-')) const tracePath = join(directory, 'trace.json') const deniedTarget = join(directory, 'protected.txt') @@ -36,6 +36,7 @@ async function setup(mode: string, maxMessageBytes?: number) { runTimeoutMs: 3_000, shutdownGraceMs: 100, ...(maxMessageBytes === undefined ? {} : { maxMessageBytes }), + ...(maxPendingPermissions === undefined ? {} : { maxPendingPermissions }), }) const runtime = new InMemoryHarnessRuntime([provider]) const execution = runtime.startExecution({ @@ -73,6 +74,16 @@ function assertExited(trace: Trace) { } describe('ACP v1 adapter through the official SDK and owned subprocess', () => { + it('bounds pending permissions instead of allowing an unbounded approval queue', async () => { + const task = await setup('permission-parallel', undefined, 1) + try { + await drain(task.runtime, task.execution.id) + expect(task.runtime.getExecution(task.execution.id).state).toBe('failed') + assertExited(await task.trace()) + } finally { + await task.cleanup() + } + }) it('normalizes streamed text, disables client capabilities, and isolates environment', async () => { vi.stubEnv('HARNESS_PARENT_SENTINEL', 'synthetic-parent-value') const task = await setup('success')