From a324ef51adacf8ba7fb65a6cf074aa847c89eb49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Mon, 7 Sep 2026 17:24:08 +0100 Subject: [PATCH 1/4] feat: add optional pi agent SDK support --- CHANGELOG.md | 9 + README.md | 79 +- eslint.config.mjs | 8 +- package.json | 11 + src/adapters/claude-adapter.ts | 10 + src/adapters/pi-adapter.test.ts | 329 +++++ src/adapters/pi-adapter.ts | 288 +++++ src/adapters/pi-types.ts | 45 + src/credential-redactor.test.ts | 55 + src/credential-redactor.ts | 85 ++ src/index.ts | 13 + src/judge/executor.ts | 167 ++- src/judge/types.ts | 26 +- src/pi-runtime-lifecycle.test.ts | 325 +++++ src/pi-runtime-setup.test.ts | 148 +++ src/pi-runtime.test.ts | 352 ++++++ src/pi-runtime.ts | 640 ++++++++++ src/runner.test.ts | 2 + src/runner.ts | 157 ++- src/sandbox/container/pi-bridge-main.test.ts | 53 + src/sandbox/container/pi-bridge.test.ts | 157 +++ src/sandbox/container/pi-bridge.ts | 120 ++ src/sandbox/docker/bridge.test.ts | 125 ++ src/sandbox/docker/bridge.ts | 245 +++- src/sandbox/docker/lifecycle.ts | 9 + src/sandbox/docker/pi-integration.test.ts | 87 ++ src/telemetry/controller.test.ts | 251 ++++ src/telemetry/message-handler.ts | 29 +- src/types.test-d.ts | 154 +++ src/types.ts | 95 +- vitest.config.mts | 7 +- yarn.lock | 1195 +++++++++++++++++- 32 files changed, 5060 insertions(+), 216 deletions(-) create mode 100644 src/adapters/pi-adapter.test.ts create mode 100644 src/adapters/pi-adapter.ts create mode 100644 src/adapters/pi-types.ts create mode 100644 src/credential-redactor.test.ts create mode 100644 src/credential-redactor.ts create mode 100644 src/pi-runtime-lifecycle.test.ts create mode 100644 src/pi-runtime-setup.test.ts create mode 100644 src/pi-runtime.test.ts create mode 100644 src/pi-runtime.ts create mode 100644 src/sandbox/container/pi-bridge-main.test.ts create mode 100644 src/sandbox/container/pi-bridge.test.ts create mode 100644 src/sandbox/container/pi-bridge.ts create mode 100644 src/sandbox/docker/pi-integration.test.ts create mode 100644 src/types.test-d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f32f4c9..60e64b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Optional pi Agent SDK adapter for isolated LiteLLM runs, Docker execution, structured judging, cancellation, and turn limits. +- Provider-specific runner option types with backwards-compatible Claude defaults. + +### Fixed + +- Scrub configured credential values from pi messages, errors, telemetry, and judge inputs, including short credentials and nested error causes. + ## [0.3.1] ### Fixed diff --git a/README.md b/README.md index 7fa9218..4e5e82f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @metamask/agent-runner -Reusable TypeScript runner for `@anthropic-ai/claude-agent-sdk` with optional Langfuse/OpenTelemetry lifecycle support. +Reusable TypeScript runner for the Claude Agent SDK and optional pi Agent SDK, with Langfuse/OpenTelemetry lifecycle support and Docker execution. This package wraps the Claude Agent SDK `query()` behind a provider adapter, normalizes the streamed message types into a discriminated union, collects result metadata, and exposes `flush()` / `shutdown()` so short-lived CI and eval processes do not lose telemetry spans. @@ -81,6 +81,81 @@ console.log(result.sessionId, result.totalCostUsd, result.durationMs); By default the runner passes `settingSources: []` to the Claude SDK for isolated settings. Callers can override that in `defaultOptions` or per-run `options` when they intentionally want SDK settings loaded from other sources. +## Pi usage + +Pi is an optional peer dependency. Claude-only installations keep their existing Node requirements and do not need pi installed. Pi execution requires Node.js 22.19.0 or newer and the supported SDK version: + +```sh +npm install @metamask/agent-runner @earendil-works/pi-coding-agent@0.83.0 +export LITELLM_BASE_URL=http://localhost:4000 +export LITELLM_API_KEY=your-key +``` + +The endpoint can include `/v1`; the adapter does not append it twice. Pi uses the OpenAI Chat Completions protocol through LiteLLM. Native pi providers and stored OAuth credentials are not loaded. + +```ts +import { createAgentRunner, createPiAdapter } from '@metamask/agent-runner'; + +const runner = createAgentRunner({ + adapter: createPiAdapter(), + defaultOptions: { + model: 'your-litellm-model-alias', + tools: ['read', 'bash', 'edit', 'write'], + maxTurns: 10, + }, + sandbox: { type: 'docker', image: 'node:22-bookworm' }, +}); + +const result = await runner.runAgent({ + prompt: 'Run the tests and fix failures.', + signal: AbortSignal.timeout(120_000), +}); + +const verdict = await runner.judge(result, { + rubric: 'Evaluate correctness.', + scoreFields: [{ name: 'correctness', min: 0, max: 10 }], +}); +``` + +The adapter determines the runner's option and prompt types. Existing Claude calls remain unchanged. Pi accepts string prompts and these package-owned options: + +| Option | Behavior | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `model` | Required LiteLLM model alias. Judging inherits it from runner defaults. | +| `cwd` | Absolute working directory, defaulting to `process.cwd()`. Docker maps it into the mounted workspace. | +| `systemPrompt` | Replacement system prompt. | +| `tools` | Exact built-in allowlist: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`. Defaults to the first four; `[]` disables all tools. | +| `maxTurns` | Positive integer limiting model turns, including their tool execution. Unlimited for task runs when omitted. | +| `contextWindow` | Declared model context capacity, default 128000 tokens. | +| `maxTokens` | Maximum output tokens per generation, default 8192. This is not a whole-run limit. | +| `reasoning` | Whether the model supports reasoning, default `false`. | +| `input` | Declared model modalities, default `['text']`. This adapter currently accepts text prompts only. | +| `cost` | Optional USD-per-million-token prices: `input`, `output`, `cacheRead`, `cacheWrite`. | + +Unknown options, command-scoped tool selectors, and Claude permission options are rejected before execution. They are never silently ignored. Direct pi tools execute with the host process's permissions; use Docker when isolation is required. + +Every pi run uses a fresh in-memory session. Settings, extensions, skills, context files, and prompt templates are not discovered. Automatic retries and compaction are disabled. Persistence, resume, custom tools, and interactive steering are not supported. + +Pi emits the same normalized messages and telemetry as Claude. Reaching `maxTurns` produces `result.success: false`; cancellation or a throwing `onMessage` callback returns a partial result and stops active work. Cost is omitted when pricing for consumed tokens is unknown. Explicit zero prices report zero. + +### Pi sandbox and judge behavior + +Docker installs pi 0.83.0 and copies the shared pi execution modules into the container. `bridge.sdkVersion`, if supplied, must match. With `bridge.install: false`, that version must already be resolvable from the bridge directory. The Node version check applies inside the container, so a Node 20 host can still launch a supported pi container. + +Pi's default forwarded environment is `LITELLM_BASE_URL`, `LITELLM_API_KEY`, `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. An explicit `forwardEnv` list or `false` overrides it. Credentials are passed through the existing Docker environment-file mechanism, not serialized into query options. The existing Docker security caveats still apply: enabled tools can read credentials inside the container. + +Pi judging uses only a terminating `submit_judgment` tool and rejects caller tool customization. It inherits model settings, but not task tools or task turn limits. Its default limit is five turns. It uses the runner's sandbox; `judge(..., context, { sandbox: false, signal })` can override sandboxing and cancel the judge. A sandboxed judge starts its own container rather than reusing the task container. + +Claude judging and the legacy Claude fallback for custom adapters without `runStructured()` remain unchanged. To judge a transcript with another provider, use a separate runner. Custom adapters can implement `runStructured()`, `getStructuredDefaults()`, and `getRunMetadata()`. Set `defaultOptions: {}` on non-Claude adapters to opt out of the legacy `settingSources: []` default. + +To run the local-protocol Docker smoke test after building: + +```sh +RUN_DOCKER_TESTS=1 yarn vitest run src/sandbox/docker/pi-integration.test.ts --coverage.enabled=false +``` + +It requires a working Docker daemon and `host.docker.internal` connectivity, but no external model credentials. + ## Telemetry usage ```ts @@ -277,7 +352,7 @@ The run loop catches all errors and returns them in the result rather than throw ## Docker sandbox -The Claude adapter can execute agent runs inside a Docker container instead of +The Claude and pi adapters can execute agent runs inside a Docker container instead of the host process. This isolates filesystem writes, environment variables, and spawned subprocesses (including the Claude Agent SDK's own tools) from the host. The sandbox is opt-in: when no `sandbox` is configured the adapter runs diff --git a/eslint.config.mjs b/eslint.config.mjs index e4f731e..1e30fd0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -50,7 +50,13 @@ const config = createConfig([ // sandbox modules all run host-side and need the nodejs eslint extends so // they can call into the platform without per-line disables. { - files: ['src/adapters/claude-adapter.ts', 'src/sandbox/**/*.ts'], + files: [ + 'src/adapters/*-adapter.ts', + 'src/pi-runtime.ts', + 'src/credential-redactor.ts', + 'src/judge/executor.ts', + 'src/sandbox/**/*.ts', + ], ignores: ['**/*.test.ts'], extends: nodejs, rules: { diff --git a/package.json b/package.json index 1b05768..ac03e3d 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", + "@earendil-works/pi-coding-agent": "0.83.0", "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.0.0", "@metamask/auto-changelog": "^6.1.0", @@ -91,6 +92,14 @@ "vite": "^8.0.8", "vitest": "^4.1.4" }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "0.83.0" + }, + "peerDependenciesMeta": { + "@earendil-works/pi-coding-agent": { + "optional": true + } + }, "resolutions": { "fflate": "0.8.2" }, @@ -100,6 +109,8 @@ "packageManager": "yarn@4.16.0+sha256.ba05224324578801b9cc98170d64aa50b9a36733b440fb0942306da3fbbdc7d1", "lavamoat": { "allowScripts": { + "@earendil-works/pi-coding-agent>@earendil-works/pi-ai>@google/genai": false, + "@earendil-works/pi-coding-agent>@earendil-works/pi-ai>@google/genai>protobufjs": false, "@lavamoat/preinstall-always-fail": false, "@langfuse/otel>@opentelemetry/exporter-trace-otlp-http>@opentelemetry/otlp-transformer>protobufjs": false, "@opentelemetry/sdk-node>@opentelemetry/exporter-logs-otlp-grpc>@grpc/grpc-js>@grpc/proto-loader>protobufjs": false, diff --git a/src/adapters/claude-adapter.ts b/src/adapters/claude-adapter.ts index a3a2627..592c247 100644 --- a/src/adapters/claude-adapter.ts +++ b/src/adapters/claude-adapter.ts @@ -36,6 +36,16 @@ export function createClaudeAdapter(): ProviderAdapter { return { name: 'claude', capabilities: { sandboxes: ['docker'] }, + /** + * Describes the Claude run for telemetry. + * + * @param options - Claude options. + * @returns Model and turn limit. + */ + getRunMetadata: (options) => ({ + model: options.model ?? 'unknown', + maxTurns: options.maxTurns ?? 0, + }), /** * Runs the Claude query and yields translated agent messages. * diff --git a/src/adapters/pi-adapter.test.ts b/src/adapters/pi-adapter.test.ts new file mode 100644 index 0000000..b3b2016 --- /dev/null +++ b/src/adapters/pi-adapter.test.ts @@ -0,0 +1,329 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test doubles infer their contracts from the runtime. */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAgentRunner } from '../runner.js'; +import { createPiAdapter } from './pi-adapter.js'; + +const mocks = vi.hoisted(() => ({ + close: vi.fn(), + unregisterCleanup: vi.fn(), + create: vi.fn(), + bridge: vi.fn(), + direct: vi.fn(), + command: vi.fn(), +})); +vi.mock('../pi-runtime.js', async (original) => ({ + ...(await original()), + runPiSession: mocks.direct, +})); +vi.mock('../sandbox/docker/command-runner.js', () => ({ + createDefaultDockerCommandRunner: () => ({ run: mocks.command }), +})); +vi.mock('../sandbox/docker/lifecycle.js', () => ({ + createDockerSandbox: mocks.create, +})); +vi.mock('../sandbox/docker/bridge.js', () => ({ + PI_BRIDGE_RUNTIME: { id: 'pi' }, + runDockerBridge: mocks.bridge, +})); + +function setup( + messages: unknown[] = [{ type: 'result', success: true }], +): import('../types.js').AgentRunner< + import('./pi-types.js').PiQueryOptions, + string +> { + mocks.create.mockResolvedValue({ + containerName: 'pi-test', + close: mocks.close, + unregisterCleanup: mocks.unregisterCleanup, + }); + mocks.bridge.mockImplementation(async function* () { + yield* messages; + }); + return createAgentRunner({ + adapter: createPiAdapter(), + defaultOptions: { model: 'test' }, + sandbox: { type: 'docker', workspace: false, workdir: '/task' }, + }); +} +describe('pi adapter', () => { + afterEach(() => { + vi.resetAllMocks(); + vi.unstubAllEnvs(); + }); + + describe('pi adapter sandbox routing', () => { + it('requires exactly one terminal result', async () => { + expect( + (await setup([]).runAgent({ prompt: 'hi' })).error?.message, + ).toContain('without a result'); + expect( + ( + await setup([ + { type: 'result', success: true }, + { type: 'result', success: true }, + ]).runAgent({ prompt: 'hi' }) + ).error?.message, + ).toContain('after the result'); + }); + it('exposes provider metadata without Claude defaults', () => { + const adapter = createPiAdapter(); + expect(adapter.defaultOptions).toStrictEqual({}); + expect(adapter.getRunMetadata?.({})).toStrictEqual({ + model: 'unknown', + maxTurns: 0, + }); + expect( + adapter.getRunMetadata?.({ model: 'x', maxTurns: 3 }), + ).toStrictEqual({ model: 'x', maxTurns: 3 }); + }); + it.each([ + null, + {}, + { type: 'init' }, + { type: 'generation' }, + { type: 'tool_result' }, + { type: 'tool_progress' }, + { type: 'result' }, + { type: 'result', success: true, result: 3 }, + { type: 'result', success: true, error: 3 }, + { type: 'result', success: true, costUsd: -1 }, + { + type: 'generation', + model: 'x', + text: '', + toolCalls: [null], + usage: {}, + }, + { type: 'generation', model: 'x', text: '', toolCalls: [{}], usage: {} }, + { + type: 'generation', + model: 'x', + text: '', + toolCalls: [{ id: 'x' }], + usage: {}, + }, + { + type: 'generation', + model: 'x', + text: '', + toolCalls: [], + usage: { inputTokens: 0 }, + }, + { + type: 'generation', + model: 'x', + text: '', + toolCalls: [], + usage: { inputTokens: 0, outputTokens: 0 }, + }, + ])('rejects malformed normalized payload %j', async (message) => { + expect( + (await setup([message]).runAgent({ prompt: 'hi' })).error?.message, + ).toBe('Invalid pi bridge message.'); + }); + it('accepts each emitted message variant and maps the workspace cwd', async () => { + const messages = [ + { + type: 'generation', + model: 'x', + text: 'text', + toolCalls: [{ id: 'id', name: 'bash', input: {} }], + usage: { inputTokens: 0, outputTokens: 0 }, + stopReason: 'stop', + }, + { + type: 'tool_result', + toolUseId: 'id', + content: 'output', + isError: false, + }, + { type: 'tool_progress', toolName: 'bash', elapsedSeconds: 1 }, + { + type: 'result', + success: true, + costUsd: 0, + error: '', + result: 'done', + }, + ]; + const result = await setup(messages).runAgent({ + prompt: 'hi', + sandbox: { type: 'docker', workspace: { hostPath: process.cwd() } }, + }); + expect(result.messages).toStrictEqual(messages); + const runner = createAgentRunner({ + adapter: createPiAdapter(), + defaultOptions: { model: 'x' }, + sandbox: { type: 'docker' }, + }); + await runner.runAgent({ prompt: 'hi' }); + expect(mocks.bridge.mock.calls.at(-1)?.[0].request.options.cwd).toBe( + '/workspace', + ); + }); + it('does not mask provider failures with cleanup failures', async () => { + const runner = setup(); + mocks.close.mockRejectedValue(new Error('cleanup')); + const completed = await runner.runAgent({ prompt: 'hi' }); + expect(completed.error?.message).toBe('cleanup'); + mocks.bridge.mockImplementation(() => ({ + [Symbol.asyncIterator]: () => ({ + next: async () => { + throw new Error('provider'); + }, + }), + })); + expect((await runner.runAgent({ prompt: 'hi' })).error?.message).toBe( + 'provider', + ); + }); + it('normalizes non-error failures and obeys successful cleanup policies', async () => { + const runner = setup(); + mocks.create.mockRejectedValue('failure'); + expect((await runner.runAgent({ prompt: 'hi' })).error?.message).toBe( + 'failure', + ); + await setup().runAgent({ + prompt: 'hi', + sandbox: { type: 'docker', cleanup: 'on-success' }, + }); + expect(mocks.close).toHaveBeenCalledOnce(); + await setup().runAgent({ + prompt: 'hi', + sandbox: { type: 'docker', cleanup: 'never' }, + }); + expect(mocks.unregisterCleanup).toHaveBeenCalledOnce(); + }); + it('forwards setup cancellation while leaving removal executable', async () => { + const runner = setup(); + const controller = new AbortController(); + const inner = new AbortController(); + mocks.create.mockImplementation(async (_config, { commandRunner }) => { + await commandRunner.run('docker', ['exec']); + await commandRunner.run('docker', ['exec'], { signal: inner.signal }); + controller.abort(); + await commandRunner.run('docker', ['rm', '-f']); + return { + close: mocks.close, + unregisterCleanup: mocks.unregisterCleanup, + }; + }); + await runner.runAgent({ prompt: 'hi', signal: controller.signal }); + expect(mocks.command.mock.calls[0]?.[2].signal).toBe(controller.signal); + expect(mocks.command.mock.calls[1]?.[2].signal.aborted).toBe(true); + expect(mocks.command.mock.calls[2]?.[2].signal).toBeUndefined(); + }); + it('runs in Docker with pi env defaults and container cwd, never on the host', async () => { + vi.stubEnv('LITELLM_API_KEY', 'key'); + vi.stubEnv('ANTHROPIC_API_KEY', 'other'); + const result = await setup().runAgent({ prompt: 'hi' }); + expect(result.error).toBeUndefined(); + expect(mocks.direct).not.toHaveBeenCalled(); + expect(mocks.bridge.mock.calls[0]?.[0]).toMatchObject({ + request: { prompt: 'hi', options: { model: 'test', cwd: '/task' } }, + config: { env: { LITELLM_API_KEY: 'key' } }, + }); + expect(mocks.bridge.mock.calls[0]?.[0].config.env).not.toHaveProperty( + 'ANTHROPIC_API_KEY', + ); + expect(mocks.close).toHaveBeenCalledOnce(); + }); + it('runs the judge in Docker with inherited model and a five-turn limit', async () => { + const runner = setup([ + { + type: 'result', + success: true, + result: JSON.stringify({ score: 8, reasoning: 'good' }), + }, + ]); + const verdict = await runner.judge( + { + messages: [], + durationMs: 0, + isPartial: false, + metadata: { startedAt: '', endedAt: '', messageCount: 0 }, + }, + { rubric: 'Judge.', scoreFields: [{ name: 'score', min: 0, max: 10 }] }, + ); + expect(verdict.scores.score).toBe(8); + expect(mocks.bridge.mock.calls[0]?.[0].request.options).toMatchObject({ + model: 'test', + maxTurns: 5, + structured: { systemPrompt: 'Judge.' }, + }); + expect( + mocks.bridge.mock.calls[0]?.[0].request.options, + ).not.toHaveProperty('tools'); + }); + it.each(['never', 'on-success'] as const)( + 'removes the container on callback failure regardless of %s cleanup', + async (cleanup) => { + const result = await setup([{ type: 'init', sessionId: 'x' }]).runAgent( + { + prompt: 'hi', + sandbox: { type: 'docker', cleanup }, + onMessage: () => { + throw new Error('stop'); + }, + }, + ); + expect(result.isPartial).toBe(true); + expect(mocks.close).toHaveBeenCalledOnce(); + }, + ); + it('keeps unsuccessful completed runs for on-success cleanup', async () => { + await setup([ + { type: 'result', success: false, error: 'maxTurns' }, + ]).runAgent({ + prompt: 'hi', + sandbox: { type: 'docker', cleanup: 'on-success' }, + }); + expect(mocks.close).not.toHaveBeenCalled(); + expect(mocks.unregisterCleanup).toHaveBeenCalledOnce(); + }); + it('rejects malformed messages and scrubs sandbox credentials in errors', async () => { + const result = await setup([{ type: 'bogus' }]).runAgent({ + prompt: 'hi', + }); + expect(result.error?.message).toContain('Invalid pi bridge'); + expect(mocks.close).toHaveBeenCalledOnce(); + const runner = setup(); + // eslint-disable-next-line require-yield -- Models a provider that throws before emitting a message. + mocks.bridge.mockImplementation(async function* () { + throw new Error('sandbox-key leaked'); + }); + const failed = await runner.runAgent({ + prompt: 'hi', + sandbox: { type: 'docker', env: { LITELLM_API_KEY: 'sandbox-key' } }, + }); + expect(failed.error?.message).toBe('[REDACTED] leaked'); + }); + it('rejects unsupported options before creating a container', async () => { + const result = await setup().runAgent({ + prompt: 'hi', + options: { permissionMode: 'bypassPermissions' } as any, + }); + expect(result.error?.message).toContain('Unsupported Pi option'); + expect(mocks.create).not.toHaveBeenCalled(); + }); + it('passes caller cancellation to the bridge and always removes an aborted container', async () => { + const runner = setup(); + const controller = new AbortController(); + // eslint-disable-next-line require-yield -- Models a provider that throws before emitting a message. + mocks.bridge.mockImplementation(async function* () { + controller.abort(); + throw new Error('aborted'); + }); + const result = await runner.runAgent({ + prompt: 'hi', + signal: controller.signal, + sandbox: { type: 'docker', cleanup: 'never' }, + }); + expect(result.isPartial).toBe(true); + expect(mocks.bridge.mock.calls[0]?.[0].signal).toBe(controller.signal); + expect(mocks.close).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/adapters/pi-adapter.ts b/src/adapters/pi-adapter.ts new file mode 100644 index 0000000..bb7103d --- /dev/null +++ b/src/adapters/pi-adapter.ts @@ -0,0 +1,288 @@ +import { + createCredentialScrubber, + scrubCredentials, +} from '../credential-redactor.js'; +import { + runPiSession, + validatePiOptions, + PI_FORWARD_ENV, +} from '../pi-runtime.js'; +import type { PiStructuredOutput } from '../pi-runtime.js'; +import { + runDockerBridge, + PI_BRIDGE_RUNTIME, +} from '../sandbox/docker/bridge.js'; +import { createDefaultDockerCommandRunner } from '../sandbox/docker/command-runner.js'; +import { createDockerSandbox } from '../sandbox/docker/lifecycle.js'; +import { + normalizeDockerSandboxConfig, + prepareDockerSandboxRequest, +} from '../sandbox/docker/options.js'; +import type { AgentMessage, ProviderAdapter, RunConfig } from '../types.js'; +import type { PiQueryOptions } from './pi-types.js'; + +/** + * Creates an isolated LiteLLM pi adapter. The SDK is loaded only for direct runs. + * + * @returns The pi provider adapter. + */ +export function createPiAdapter(): ProviderAdapter { + return { + name: 'pi', + defaultOptions: {}, + capabilities: { sandboxes: ['docker'] }, + /** + * Describes the run for telemetry. + * + * @param options - Pi run options. + * @returns Model and turn limit. + */ + getRunMetadata: (options) => ({ + model: options.model ?? 'unknown', + maxTurns: options.maxTurns ?? 0, + }), + /** + * Keeps model settings without inheriting task execution policy. + * + * @param options0 - Runner defaults. + * @param options0.tools - Task tools, excluded from judging. + * @param options0.maxTurns - Task limit, excluded from judging. + * @returns Safe judge defaults. + */ + getStructuredDefaults: ({ + tools: _tools, + maxTurns: _maxTurns, + ...options + }) => options, + run: runPi, + /** + * Runs a bounded judge with its own tool contract. + * + * @param config - Structured run request. + * @returns Normalized judge messages. + */ + runStructured: (config) => + runPi( + { ...config, options: { maxTurns: 5, ...config.options } }, + { schema: config.schema, systemPrompt: config.systemPrompt }, + ), + }; +} + +/** + * Runs pi directly or with the shared Docker lifecycle. + * + * @param config - Runner request. + * @param structured - Optional judge contract. + * @yields Normalized messages. + */ +async function* runPi( + config: RunConfig, + structured?: PiStructuredOutput, +): AsyncGenerator { + const scrub = createCredentialScrubber({ + ...process.env, + ...config.sandbox?.env, + }); + try { + config.signal?.throwIfAborted(); + const options = validatePiOptions(config.options, structured !== undefined); + if (config.sandbox === undefined) { + // The runtime validates again at the container entry point too. Keep the + // caller's options so structured runs can reject an explicitly set tools. + yield* runPiSession( + config.prompt, + config.options, + structured, + config.signal, + ); + return; + } + const normalized = normalizeDockerSandboxConfig( + { + ...config.sandbox, + forwardEnv: config.sandbox.forwardEnv ?? PI_FORWARD_ENV, + }, + { hostCwd: options.cwd ?? process.cwd() }, + ); + const prepared = prepareDockerSandboxRequest({ + prompt: config.prompt, + options: { ...options }, + sandbox: normalized, + }); + // Respect an explicit workdir; otherwise use the mapped workspace cwd. + if (normalized.workdir !== undefined) { + prepared.options.cwd = normalized.workdir; + } else if ( + prepared.options.cwd === undefined && + normalized.workspace !== false + ) { + prepared.options.cwd = normalized.workspace.containerPath; + } + if (structured !== undefined) { + delete prepared.options.tools; + prepared.options.structured = structured; + } + const baseRunner = createDefaultDockerCommandRunner(); + const commandRunner: typeof baseRunner = { + // Cancellation applies to setup/install too. Container removal must still + // run after cancellation, so it never receives the aborted signal. + /** + * Applies cancellation without preventing cleanup. + * + * @param command - Executable. + * @param args - Command arguments. + * @param commandOptions - Process options. + * @returns Process outcome. + */ + run: async (command, args, commandOptions) => + baseRunner.run(command, args, { + ...commandOptions, + ...(config.signal === undefined || args[0] === 'rm' + ? {} + : { + signal: + commandOptions?.signal === undefined + ? config.signal + : // Pi execution requires Node >=22.19; Claude never uses this path. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + AbortSignal.any([config.signal, commandOptions.signal]), + }), + }), + }; + const handle = await createDockerSandbox(normalized, { commandRunner }); + let completed = false; + let failed = false; + let succeeded = false; + let resultSeen = false; + try { + config.signal?.throwIfAborted(); + for await (const raw of runDockerBridge({ + runtime: PI_BRIDGE_RUNTIME, + sandbox: handle, + config: normalized, + commandRunner, + request: prepared, + ...(config.signal === undefined ? {} : { signal: config.signal }), + })) { + if (resultSeen) { + throw new Error('Pi bridge emitted messages after the result.'); + } + const message = validatePiBridgeMessage(raw); + if (message.type === 'result') { + resultSeen = true; + succeeded = message.success; + } + yield scrubCredentials(message, scrub); + } + if (!resultSeen) { + throw new Error('Pi bridge completed without a result.'); + } + completed = true; + } catch (cause) { + failed = true; + throw cause; + } finally { + if ( + (!completed && !failed) || + config.signal?.aborted || + normalized.cleanup === 'always' || + (normalized.cleanup === 'on-success' && succeeded && completed) + ) { + try { + await handle.close(); + } catch (cause) { + if (completed && !failed) { + // eslint-disable-next-line no-unsafe-finally -- No primary error or abandoned iterator to mask. + throw cause; + } + } + } else { + handle.unregisterCleanup(); + } + } + } catch (cause) { + throw scrubCredentials( + cause instanceof Error ? cause : new Error(String(cause)), + scrub, + ); + } +} + +/** + * Validates normalized messages arriving from the container. + * + * @param raw - Parsed bridge payload. + * @returns A supported normalized message. + */ +function validatePiBridgeMessage(raw: unknown): AgentMessage { + if (raw === null || typeof raw !== 'object' || !('type' in raw)) { + throw new Error('Invalid pi bridge message.'); + } + const message = raw as Record; + switch (message.type) { + case 'init': + if (typeof message.sessionId === 'string') { + return message as AgentMessage; + } + break; + case 'generation': + if ( + typeof message.model === 'string' && + typeof message.text === 'string' && + Array.isArray(message.toolCalls) && + message.toolCalls.every( + (call: unknown) => + call !== null && + typeof call === 'object' && + 'id' in call && + typeof call.id === 'string' && + 'name' in call && + typeof call.name === 'string', + ) && + message.usage !== null && + typeof message.usage === 'object' && + 'inputTokens' in message.usage && + typeof message.usage.inputTokens === 'number' && + 'outputTokens' in message.usage && + typeof message.usage.outputTokens === 'number' && + (message.stopReason === null || typeof message.stopReason === 'string') + ) { + return message as AgentMessage; + } + break; + case 'tool_result': + if ( + typeof message.toolUseId === 'string' && + typeof message.content === 'string' && + typeof message.isError === 'boolean' + ) { + return message as AgentMessage; + } + break; + case 'tool_progress': + if ( + typeof message.toolName === 'string' && + typeof message.elapsedSeconds === 'number' + ) { + return message as AgentMessage; + } + break; + case 'result': + if ( + typeof message.success === 'boolean' && + (message.result === undefined || typeof message.result === 'string') && + (message.error === undefined || typeof message.error === 'string') && + (message.costUsd === undefined || + (typeof message.costUsd === 'number' && + Number.isFinite(message.costUsd) && + message.costUsd >= 0)) + ) { + return message as AgentMessage; + } + break; + default: + break; + } + throw new Error('Invalid pi bridge message.'); +} diff --git a/src/adapters/pi-types.ts b/src/adapters/pi-types.ts new file mode 100644 index 0000000..2ddeb0c --- /dev/null +++ b/src/adapters/pi-types.ts @@ -0,0 +1,45 @@ +/** Built-in tools allowed in an isolated pi run. */ +export type PiToolName = + | 'read' + | 'bash' + | 'edit' + | 'write' + | 'grep' + | 'find' + | 'ls'; + +/** USD per million tokens. Missing prices remain unknown, not free. */ +export type PiModelCost = { + /** Input token price. */ + input?: number; + /** Output token price. */ + output?: number; + /** Cached input price. */ + cacheRead?: number; + /** Cache creation price. */ + cacheWrite?: number; +}; + +/** Serializable options for isolated LiteLLM pi runs. */ +export type PiQueryOptions = { + /** LiteLLM model alias. Required, including for judging. */ + model: string; + /** Absolute working directory. Defaults to process.cwd(). */ + cwd?: string; + /** Replacement system prompt. */ + systemPrompt?: string; + /** Exact allowlist. Defaults to read, bash, edit, write; [] disables tools. */ + tools?: readonly PiToolName[]; + /** Maximum model turns, including tool execution. Unlimited when omitted. */ + maxTurns?: number; + /** Model context window. Defaults to 128000. */ + contextWindow?: number; + /** Maximum output tokens per generation. Defaults to 8192. */ + maxTokens?: number; + /** Whether the model supports reasoning. Defaults to false. */ + reasoning?: boolean; + /** Model input capabilities. Prompts in this adapter are text-only. */ + input?: readonly ('text' | 'image')[]; + /** Explicit pricing used to calculate reported cost. */ + cost?: PiModelCost; +}; diff --git a/src/credential-redactor.test.ts b/src/credential-redactor.test.ts new file mode 100644 index 0000000..57426fe --- /dev/null +++ b/src/credential-redactor.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; + +import { + createCredentialScrubber, + scrubCredentials, +} from './credential-redactor.js'; +import { DockerSandboxError } from './errors.js'; + +describe('credential redaction', () => { + it('redacts every nonempty credential, including short and regex-like values', () => { + const scrub = createCredentialScrubber({ + LITELLM_API_KEY: 'a+b', + TOKEN: 'xy', + OTHER: 'visible', + EMPTY_KEY: '', + }); + expect(scrub('a+b xy visible')).toBe('[REDACTED] [REDACTED] visible'); + expect(createCredentialScrubber({})('unchanged')).toBe('unchanged'); + }); + it('copies errors, stacks, nested causes, and cycles without losing error identity', () => { + const cause = new Error('sk-test cause'); + const error = new DockerSandboxError('sk-test failure', { cause }); + cause.cause = error; + const scrubbed = scrubCredentials( + error, + createCredentialScrubber({ API_KEY: 'sk-test' }), + ); + expect(scrubbed).toBeInstanceOf(DockerSandboxError); + expect(scrubbed).not.toBe(error); + expect(scrubbed.message).toBe('[REDACTED] failure'); + expect(scrubbed.stack).not.toContain('sk-test'); + expect((scrubbed.cause as Error).message).toBe('[REDACTED] cause'); + expect((scrubbed.cause as Error).cause).toBe(scrubbed); + expect(error.message).toBe('sk-test failure'); + }); + it('preserves stackless errors and repeated references', () => { + const error = new Error('failure'); + delete error.stack; + const scrubbed = scrubCredentials([error, error], (text) => text); + expect(scrubbed[0]).toBe(scrubbed[1]); + expect(scrubbed[0]?.message).toBe('failure'); + }); + it('supports DOMException abort reasons and structured payloads', () => { + const scrub = createCredentialScrubber({ API_KEY: 'key' }); + const error = scrubCredentials( + new DOMException('key', 'AbortError'), + scrub, + ); + expect(error.name).toBe('AbortError'); + expect(error.message).toBe('[REDACTED]'); + expect(scrubCredentials({ raw: ['key', null, 1] }, scrub)).toStrictEqual({ + raw: ['[REDACTED]', null, 1], + }); + }); +}); diff --git a/src/credential-redactor.ts b/src/credential-redactor.ts new file mode 100644 index 0000000..eda0869 --- /dev/null +++ b/src/credential-redactor.ts @@ -0,0 +1,85 @@ +/** + * Collects configured credential values without imposing a minimum length. + * + * @param env - Credential-bearing environment. + * @returns A value-level string scrubber. + */ +export function createCredentialScrubber( + env: Record = process.env, +): (text: string) => string { + const values = [ + ...new Set( + Object.entries(env) + .filter( + ([key, value]) => + /key|token|secret|password|authorization/iu.test(key) && + Boolean(value), + ) + .map(([, value]) => value as string), + ), + ].sort((a, b) => b.length - a.length); + if (values.length === 0) { + return (text) => text; + } + const pattern = new RegExp( + values + .map((value) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')) + .join('|'), + 'gu', + ); + return (text) => text.replace(pattern, '[REDACTED]'); +} + +/** + * Copies errors and JSON-like values while scrubbing every string, including causes. + * + * @param value - Value leaving an execution boundary. + * @param scrub - Credential or caller-provided scrubber. + * @param seen - Already copied objects, including cyclic error causes. + * @returns A scrubbed copy with the original error name preserved. + */ +export function scrubCredentials( + value: Value, + scrub: (text: string) => string, + seen = new WeakMap(), +): Value { + if (typeof value === 'string') { + return scrub(value) as Value; + } + if (value === null || typeof value !== 'object') { + return value; + } + if (seen.has(value)) { + return seen.get(value) as Value; + } + if (value instanceof Error) { + const result = new Error(scrub(value.message)); + Object.setPrototypeOf(result, Object.getPrototypeOf(value)); + seen.set(value, result); + Object.defineProperty(result, 'name', { + value: scrub(value.name), + configurable: true, + writable: true, + }); + if (value.stack !== undefined) { + result.stack = scrub(value.stack); + } + if (value.cause !== undefined) { + result.cause = scrubCredentials(value.cause, scrub, seen); + } + return result as Value; + } + const result: Record | unknown[] = Array.isArray(value) + ? [] + : {}; + seen.set(value, result); + for (const [key, entry] of Object.entries(value)) { + Object.defineProperty(result, key, { + value: scrubCredentials(entry, scrub, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + return result as Value; +} diff --git a/src/index.ts b/src/index.ts index fd9565e..b0f745e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,16 @@ +export { createPiAdapter } from './adapters/pi-adapter.js'; +export { createClaudeAdapter } from './adapters/claude-adapter.js'; +export type { + PiQueryOptions, + PiToolName, + PiModelCost, +} from './adapters/pi-types.js'; +export type { + ProviderAdapter, + RunConfig, + RunStructuredConfig, + ClaudeQueryOptions, +} from './types.js'; export { formatMessage } from './formatter.js'; export { createAgentRunner } from './runner.js'; export { diff --git a/src/judge/executor.ts b/src/judge/executor.ts index b74de05..033f998 100644 --- a/src/judge/executor.ts +++ b/src/judge/executor.ts @@ -1,11 +1,17 @@ import { query } from '@anthropic-ai/claude-agent-sdk'; +import { + createCredentialScrubber, + scrubCredentials, +} from '../credential-redactor.js'; import { JudgeError } from '../errors.js'; import { redactSensitive } from '../message-parser.js'; import type { AgentMessage, AgentRunResult, RunnerMessageHandler, + RunStructuredConfig, + ClaudeQueryOptions, } from '../types.js'; import type { JudgeConfig, @@ -83,7 +89,9 @@ function translateRawResultMessage(raw: Record): AgentMessage { * @throws {JudgeError} If scoreFields is empty, contains duplicates, * uses reserved names, or has invalid ranges. */ -function validateJudgeConfig(config: JudgeConfig): void { +function validateJudgeConfig( + config: JudgeConfig, +): void { if (config.scoreFields.length === 0) { throw new JudgeError('scoreFields must not be empty'); } @@ -120,78 +128,137 @@ function validateJudgeConfig(config: JudgeConfig): void { * @param config - Judge configuration including rubric and score schema. * @param context - Optional context such as task prompt and outcome status. * @param onMessage - Optional callback invoked for each raw SDK message. + * @param structured - Optional adapter-owned judging; omitted uses legacy Claude judging. + * @param structured.run - Structured execution method. + * @param structured.defaults - Safe inherited options. + * @param structured.sandbox - Resolved judge sandbox. + * @param structured.signal - Caller cancellation. * @returns The judge evaluation result with scores and reasoning. */ -export async function executeJudge( +export async function executeJudge< + TOptions extends object = ClaudeQueryOptions, +>( runResult: AgentRunResult, - config: JudgeConfig, + config: JudgeConfig, context?: JudgeContext, onMessage?: RunnerMessageHandler, + structured?: { + /** Adapter-owned structured execution. */ + run: (config: RunStructuredConfig) => AsyncIterable; + /** Safe defaults inherited from the runner. */ + defaults: Partial; + /** Resolved execution sandbox. */ + sandbox: RunStructuredConfig['sandbox']; + /** Caller cancellation. */ + signal: AbortSignal | undefined; + }, ): Promise { validateJudgeConfig(config); - const transcript = formatTranscript(runResult); - const userPrompt = buildJudgePrompt(transcript, context); + const scrub = createCredentialScrubber({ + ...process.env, + ...structured?.sandbox?.env, + }); + const transcript = formatTranscript(scrubCredentials(runResult, scrub)); + const userPrompt = buildJudgePrompt( + transcript, + scrubCredentials(context, scrub), + ); const outputSchema = buildOutputSchema(config.scoreFields); let resultText: string | undefined; try { - for await (const message of query({ - prompt: userPrompt, - options: { - tools: [], - maxTurns: 5, - settingSources: [], - ...config.queryOptions, - systemPrompt: config.rubric, - outputFormat: { type: 'json_schema', schema: outputSchema }, - persistSession: false, - }, - })) { - const sdkMessage = message as Record; - - if (onMessage) { - try { - const translated = - sdkMessage.type === 'result' - ? translateRawResultMessage(sdkMessage) - : (sdkMessage as AgentMessage); - await onMessage(translated); - } catch (handlerError) { - const cause = - handlerError instanceof Error - ? handlerError - : new Error(String(handlerError)); - throw new JudgeError('Judge onMessage callback failed', { cause }); + // eslint-disable-next-line no-negated-condition -- Keep the optional provider path before the unchanged legacy implementation. + if (structured !== undefined) { + for await (const message of structured.run({ + prompt: userPrompt, + systemPrompt: scrub(config.rubric), + schema: outputSchema, + options: { ...structured.defaults, ...config.queryOptions }, + ...(structured.sandbox === undefined + ? {} + : { sandbox: structured.sandbox }), + ...(structured.signal === undefined + ? {} + : { signal: structured.signal }), + })) { + if (onMessage) { + try { + await onMessage(message); + } catch (cause) { + throw new JudgeError('Judge onMessage callback failed', { cause }); + } + } + if (message.type === 'result') { + if (!message.success) { + throw new JudgeError( + `Judge agent terminated unsuccessfully: ${message.error ?? 'unknown'}`, + ); + } + resultText = message.result; } } + } else { + for await (const message of query({ + prompt: userPrompt, + options: { + tools: [], + maxTurns: 5, + settingSources: [], + ...(config.queryOptions as Partial), + systemPrompt: scrub(config.rubric), + outputFormat: { type: 'json_schema', schema: outputSchema }, + persistSession: false, + }, + })) { + const sdkMessage = message as Record; - if (sdkMessage.type === 'result') { - if (sdkMessage.subtype === 'success') { - // With outputFormat (structured output), the SDK returns the - // parsed object in `structured_output` instead of a JSON string - // in `result`. Fall back to `result` for non-structured runs. - if ( - sdkMessage.structured_output !== null && - sdkMessage.structured_output !== undefined - ) { - resultText = JSON.stringify(sdkMessage.structured_output); - } else if (typeof sdkMessage.result === 'string') { - resultText = sdkMessage.result; + if (onMessage) { + try { + const translated = + sdkMessage.type === 'result' + ? translateRawResultMessage(sdkMessage) + : (sdkMessage as AgentMessage); + await onMessage(translated); + } catch (handlerError) { + const cause = + handlerError instanceof Error + ? handlerError + : new Error(String(handlerError)); + throw new JudgeError('Judge onMessage callback failed', { cause }); + } + } + + if (sdkMessage.type === 'result') { + if (sdkMessage.subtype === 'success') { + // With outputFormat (structured output), the SDK returns the + // parsed object in `structured_output` instead of a JSON string + // in `result`. Fall back to `result` for non-structured runs. + if ( + sdkMessage.structured_output !== null && + sdkMessage.structured_output !== undefined + ) { + resultText = JSON.stringify(sdkMessage.structured_output); + } else if (typeof sdkMessage.result === 'string') { + resultText = sdkMessage.result; + } + } else { + throw new JudgeError( + `Judge agent terminated with status: ${String(sdkMessage.subtype)}`, + ); } - } else { - throw new JudgeError( - `Judge agent terminated with status: ${String(sdkMessage.subtype)}`, - ); } } } } catch (error) { if (error instanceof JudgeError) { - throw error; + throw scrubCredentials(error, scrub); } throw new JudgeError('Judge agent execution failed', { - cause: error instanceof Error ? error : new Error(String(error)), + cause: scrubCredentials( + error instanceof Error ? error : new Error(String(error)), + scrub, + ), }); } diff --git a/src/judge/types.ts b/src/judge/types.ts index 35cbaed..c3601b8 100644 --- a/src/judge/types.ts +++ b/src/judge/types.ts @@ -1,4 +1,8 @@ -import type { ClaudeQueryOptions, RunnerMessageHandler } from '../types.js'; +import type { + SandboxConfig, + ClaudeQueryOptions, + RunnerMessageHandler, +} from '../types.js'; /** * Defines a single scoring dimension for LLM judge evaluation. @@ -15,18 +19,20 @@ export type JudgeScoreField = { /** * Configuration for running an LLM-as-a-judge evaluation. */ -export type JudgeConfig = { +export type JudgeConfig = { /** System prompt / evaluation rubric for the judge. */ rubric: string; /** Score dimensions the judge should return. Used for validation and clamping. */ scoreFields: JudgeScoreField[]; /** - * Query options forwarded to the SDK call. + * Query options forwarded to the SDK call, merged over any runner defaults + * the adapter deems safe to inherit for a structured run (e.g. the Pi + * adapter inherits `model` from `defaultOptions` but never `tools`). * - * Defaults applied when not set: `model` (`claude-sonnet-4-20250514`), - * `tools` (`[]`), `maxTurns` (`5`), `settingSources` (`[]`). + * Claude adapter defaults applied when not set: `tools` (`[]`), + * `maxTurns` (`5`), and `settingSources` (`[]`). */ - queryOptions?: Partial; + queryOptions?: Partial>; }; /** @@ -55,6 +61,10 @@ export type JudgeResult = { * Options for the runner `judge()` method. */ export type JudgeOptions = { + /** Override the runner sandbox for structured judging. */ + sandbox?: SandboxConfig | false; + /** Cancel structured judging. */ + signal?: AbortSignal; /** * When `true`, automatically posts judge scores to the telemetry backend * after evaluation. Requires telemetry to be enabled and a trace ID on @@ -62,8 +72,8 @@ export type JudgeOptions = { */ postScores?: boolean; /** - * Callback invoked for each raw SDK message streamed during the judge - * agent run. Mirrors the `onMessage` callback on `AgentRunOptions`. + * Callback invoked for each normalized adapter message streamed during the + * judge run. Mirrors the `onMessage` callback on `AgentRunOptions`. * If the callback throws, the judge run terminates early with a * `JudgeError`. */ diff --git a/src/pi-runtime-lifecycle.test.ts b/src/pi-runtime-lifecycle.test.ts new file mode 100644 index 0000000..79b2b35 --- /dev/null +++ b/src/pi-runtime-lifecycle.test.ts @@ -0,0 +1,325 @@ +/* eslint-disable @typescript-eslint/prefer-promise-reject-errors, @typescript-eslint/only-throw-error -- Verify normalization of non-Error SDK failures. */ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test doubles infer their contracts from the runtime. */ +import type { + AgentSession, + AgentSessionEvent, +} from '@earendil-works/pi-coding-agent'; +import { describe, expect, it, vi } from 'vitest'; + +import { assertPiNodeVersion, runPiSession } from './pi-runtime.js'; +import type { PiSessionFactory } from './pi-runtime.js'; +import type { AgentMessage } from './types.js'; + +function fakeSession( + run: (emit: (event: AgentSessionEvent) => void) => Promise | void, +) { + let listener: (event: AgentSessionEvent) => void = () => undefined; + const abort = vi.fn(); + const unsubscribe = vi.fn(); + const dispose = vi.fn(); + const session = { + sessionId: 'test', + agent: { abort }, + getActiveToolNames: () => [], + subscribe: (callback: typeof listener) => { + listener = callback; + return unsubscribe; + }, + prompt: async () => { + await run((event) => listener(event)); + }, + dispose, + } as unknown as AgentSession; + const factory: PiSessionFactory = async () => session; + return { factory, abort, unsubscribe, dispose }; +} +async function collect( + stream: AsyncIterable, +): Promise { + const messages = []; + for await (const message of stream) { + messages.push(message); + } + return messages; +} +function assistant( + stopReason = 'stop', + text = 'hello', + usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, +) { + return { + type: 'message_end', + message: { + role: 'assistant', + model: 'test', + content: [{ type: 'text', text }], + usage, + stopReason, + }, + } as AgentSessionEvent; +} + +describe('pi lifecycle', () => { + it('requires a supported Node version only for pi execution', () => { + expect(() => assertPiNodeVersion('20.20.0')).toThrow('Node.js >=22.19.0'); + expect(() => assertPiNodeVersion('22.19.0')).not.toThrow(); + expect(() => assertPiNodeVersion('24.0.0')).not.toThrow(); + }); + it('rejects non-string prompts before session creation', async () => { + const fake = fakeSession(() => undefined); + await expect( + collect( + runPiSession( + {} as string, + { model: 'x' }, + undefined, + undefined, + fake.factory, + ), + ), + ).rejects.toThrow('string prompt'); + }); + it('preserves the primary failure when dispose also fails', async () => { + const failed = fakeSession(() => { + throw new Error('primary'); + }); + failed.dispose.mockImplementation(() => { + throw new Error('dispose'); + }); + await expect( + collect( + runPiSession( + 'hi', + { model: 'x' }, + undefined, + undefined, + failed.factory, + ), + ), + ).rejects.toThrow('primary'); + const completed = fakeSession((emit) => emit(assistant())); + completed.dispose.mockImplementation(() => { + throw new Error('dispose'); + }); + await expect( + collect( + runPiSession( + 'hi', + { model: 'x' }, + undefined, + undefined, + completed.factory, + ), + ), + ).rejects.toThrow('dispose'); + }); + it('does not emit success when prompt rejects after an assistant message', async () => { + const fake = fakeSession((emit) => { + emit(assistant()); + throw new Error('prompt failure'); + }); + const messages: AgentMessage[] = []; + await expect( + (async () => { + for await (const message of runPiSession( + 'hi', + { model: 'x' }, + undefined, + undefined, + fake.factory, + )) { + messages.push(message); + } + })(), + ).rejects.toThrow('prompt failure'); + expect(messages.some((message) => message.type === 'result')).toBe(false); + expect(fake.abort).toHaveBeenCalledOnce(); + expect(fake.unsubscribe).toHaveBeenCalledOnce(); + expect(fake.dispose).toHaveBeenCalledOnce(); + }); + it('normalizes non-Error failures and empty runs', async () => { + const failed = fakeSession(async () => Promise.reject('plain failure')); + await expect( + collect( + runPiSession( + 'hi', + { model: 'x' }, + undefined, + undefined, + failed.factory, + ), + ), + ).rejects.toThrow('plain failure'); + const empty = fakeSession(() => undefined); + expect( + ( + await collect( + runPiSession( + 'hi', + { model: 'x' }, + undefined, + undefined, + empty.factory, + ), + ) + ).at(-1), + ).toMatchObject({ + success: false, + turns: 0, + error: 'Pi produced no assistant response.', + }); + }); + it('tracks real tool elapsed time and ignores unmatched progress', async () => { + let now = 0; + const clock = vi.spyOn(Date, 'now').mockImplementation(() => now); + const fake = fakeSession((emit) => { + emit({ + type: 'tool_execution_update', + toolCallId: 'missing', + toolName: 'bash', + args: {}, + partialResult: {}, + }); + emit({ + type: 'tool_execution_start', + toolCallId: 'id', + toolName: 'bash', + args: {}, + }); + now = 2500; + emit({ + type: 'tool_execution_update', + toolCallId: 'id', + toolName: 'bash', + args: {}, + partialResult: {}, + }); + emit({ + type: 'tool_execution_end', + toolCallId: 'id', + toolName: 'bash', + result: { + content: [{ type: 'image' }, { type: 'text', text: 'done' }], + }, + isError: false, + }); + emit({ + type: 'tool_execution_end', + toolCallId: 'other', + toolName: 'bash', + result: null, + isError: true, + }); + emit(assistant()); + }); + try { + const messages = await collect( + runPiSession('hi', { model: 'x' }, undefined, undefined, fake.factory), + ); + expect( + messages.filter((message) => message.type === 'tool_progress'), + ).toStrictEqual([expect.objectContaining({ elapsedSeconds: 2.5 })]); + expect( + messages.filter((message) => message.type === 'tool_result'), + ).toMatchObject([{ content: 'done' }, { content: '', isError: true }]); + } finally { + clock.mockRestore(); + } + }); + it('bounds queued messages and aborts an overflowing producer', async () => { + const fake = fakeSession((emit) => { + for (let index = 0; index < 10002; index += 1) { + emit(assistant()); + } + }); + await expect( + collect( + runPiSession('hi', { model: 'x' }, undefined, undefined, fake.factory), + ), + ).rejects.toThrow('queue exceeded'); + expect(fake.abort).toHaveBeenCalled(); + expect(fake.dispose).toHaveBeenCalledOnce(); + }); + it('normalizes non-Error setup and disposal failures', async () => { + const factory: PiSessionFactory = async () => + Promise.reject('setup failed'); + await expect( + collect( + runPiSession('hi', { model: 'x' }, undefined, undefined, factory), + ), + ).rejects.toThrow('setup failed'); + const fake = fakeSession((emit) => emit(assistant())); + fake.dispose.mockImplementation(() => { + throw 'dispose failed'; + }); + await expect( + collect( + runPiSession('hi', { model: 'x' }, undefined, undefined, fake.factory), + ), + ).rejects.toThrow('dispose failed'); + }); + it('cleans up cancellation during session creation', async () => { + const controller = new AbortController(); + const fake = fakeSession(() => undefined); + const factory: PiSessionFactory = async (...args) => { + controller.abort(); + return fake.factory(...args); + }; + await expect( + collect( + runPiSession( + 'hi', + { model: 'x' }, + undefined, + controller.signal, + factory, + ), + ), + ).rejects.toThrow(/abort/iu); + expect(fake.dispose).toHaveBeenCalledOnce(); + }); + it.each(['length', 'pending', 'error', 'aborted'])( + 'reports unsuccessful %s stop reasons', + async (reason) => { + const fake = fakeSession((emit) => emit(assistant(reason))); + expect( + ( + await collect( + runPiSession( + 'hi', + { model: 'x' }, + undefined, + undefined, + fake.factory, + ), + ) + ).at(-1), + ).toMatchObject({ success: false }); + }, + ); + it('omits malformed and overflowing cost totals', async () => { + const fake = fakeSession((emit) => + emit( + assistant('stop', '', { + input: Infinity, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }), + ), + ); + expect( + ( + await collect( + runPiSession( + 'hi', + { model: 'x', cost: { input: 1, output: 1 } }, + undefined, + undefined, + fake.factory, + ), + ) + ).at(-1), + ).not.toHaveProperty('costUsd'); + }); +}); diff --git a/src/pi-runtime-setup.test.ts b/src/pi-runtime-setup.test.ts new file mode 100644 index 0000000..8f9c40c --- /dev/null +++ b/src/pi-runtime-setup.test.ts @@ -0,0 +1,148 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type -- Test doubles infer their contracts from the runtime. */ +import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runPiSession } from './pi-runtime.js'; + +const mocks = vi.hoisted(() => ({ + createRuntime: vi.fn(), + createSession: vi.fn(), + loader: vi.fn(), + model: vi.fn(), + version: '0.83.0', +})); +vi.mock('@earendil-works/pi-coding-agent', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention -- Match the SDK export. + get VERSION() { + return mocks.version; + }, + ModelRuntime: { create: mocks.createRuntime }, + SettingsManager: { inMemory: vi.fn(() => ({})) }, + SessionManager: { inMemory: vi.fn(() => ({})) }, + DefaultResourceLoader: class { + constructor(options: object) { + mocks.loader(options); + } + + async reload() { + return undefined; + } + }, + createAgentSession: mocks.createSession, +})); + +async function consume( + options: Parameters[1] = { model: 'x' }, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Drain the stream. + for await (const _message of runPiSession('hi', options)) { + /* Drain runtime. */ + } +} +function setup(): void { + vi.stubEnv('LITELLM_BASE_URL', 'http://localhost:4000'); + vi.stubEnv('LITELLM_API_KEY', 'sk-test'); + mocks.model.mockReturnValue({ id: 'x' }); + mocks.createRuntime.mockResolvedValue({ + registerProvider: vi.fn(), + getModel: mocks.model, + }); + let listener: (event: AgentSessionEvent) => void = () => undefined; + mocks.createSession.mockResolvedValue({ + session: { + sessionId: 'x', + agent: { abort: vi.fn() }, + getActiveToolNames: () => [], + dispose: vi.fn(), + subscribe: (callback: typeof listener) => { + listener = callback; + return () => undefined; + }, + prompt: async () => { + listener({ + type: 'message_end', + message: { + role: 'assistant', + model: 'x', + content: [], + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + stopReason: 'stop', + }, + } as unknown as AgentSessionEvent); + }, + }, + }); +} + +describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( + 'pi SDK setup contract', + () => { + afterEach(() => { + vi.resetAllMocks(); + vi.unstubAllEnvs(); + mocks.version = '0.83.0'; + }); + it('disables discovery and persisted credentials, while passing declared model settings', async () => { + setup(); + await consume({ + model: 'x', + cwd: '/tmp', + systemPrompt: 'custom', + contextWindow: 1000, + maxTokens: 100, + reasoning: true, + input: ['image'], + cost: { input: 0, output: 0 }, + }); + const config = mocks.createRuntime.mock.calls[0]?.[0]; + expect(config).toMatchObject({ + modelsPath: null, + allowModelNetwork: false, + }); + expect(await config.credentials.read('litellm')).toBeUndefined(); + expect(await config.credentials.list()).toStrictEqual([]); + await expect(config.credentials.modify()).rejects.toThrow( + 'persistence is disabled', + ); + await expect(config.credentials.delete()).rejects.toThrow( + 'persistence is disabled', + ); + expect(mocks.loader).toHaveBeenCalledWith( + expect.objectContaining({ + noExtensions: true, + noSkills: true, + noContextFiles: true, + noPromptTemplates: true, + systemPrompt: 'custom', + }), + ); + expect(mocks.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: '/tmp', + tools: ['read', 'bash', 'edit', 'write'], + }), + ); + }); + it('rejects a mismatched SDK version', async () => { + setup(); + mocks.version = '0.84.0'; + await expect(consume()).rejects.toThrow('requires SDK 0.83.0'); + }); + it('rejects an unresolved model before creating a session', async () => { + setup(); + mocks.model.mockReturnValue(undefined); + await expect(consume()).rejects.toThrow('could not resolve'); + expect(mocks.createSession).not.toHaveBeenCalled(); + }); + it.each([ + 'ftp://localhost', + 'http://user:pass@localhost', + 'http://localhost?key=x', + 'http://localhost#fragment', + ])('rejects unsafe endpoint %s', async (url) => { + setup(); + vi.stubEnv('LITELLM_BASE_URL', url); + await expect(consume()).rejects.toThrow('HTTP(S) URL'); + }); + }, +); diff --git a/src/pi-runtime.test.ts b/src/pi-runtime.test.ts new file mode 100644 index 0000000..44bd882 --- /dev/null +++ b/src/pi-runtime.test.ts @@ -0,0 +1,352 @@ +import { createServer } from 'node:http'; +import type { Server } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createPiAdapter } from './adapters/pi-adapter.js'; +import { + assertPiNodeVersion, + runPiSession, + validatePiOptions, +} from './pi-runtime.js'; +import { createAgentRunner } from './runner.js'; +import type { AgentMessage } from './types.js'; + +type Reply = { + text?: string; + tool?: string; + args?: object; + stop?: string; + status?: number; +}; +let server: Server; +let requests: Record[]; +let reply: Reply; +let hanging = false; + +// The SDK itself requires Node >=22.19; Claude compatibility is tested on Node 20. +describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( + 'pi integration', + () => { + beforeEach(async () => { + requests = []; + reply = { text: 'hello' }; + hanging = false; + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Local fixture handler; failures are test failures. + server = createServer(async (req, res) => { + let body = ''; + for await (const chunk of req) { + body += String(chunk); + } + requests.push(JSON.parse(body)); + if (hanging) { + return; + } + if (reply.status) { + res.writeHead(reply.status, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + error: { message: 'bad sk-test', type: 'invalid_request_error' }, + }), + ); + return; + } + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + const delta = reply.tool + ? { + tool_calls: [ + { + index: 0, + id: `call-${requests.length}`, + type: 'function', + function: { + name: reply.tool, + arguments: JSON.stringify(reply.args ?? {}), + }, + }, + ], + } + : { content: reply.text ?? '' }; + res.end( + `data: ${JSON.stringify({ id: 'response', object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason: reply.stop ?? (reply.tool ? 'tool_calls' : 'stop') }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } })}\n\ndata: [DONE]\n\n`, + ); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve), + ); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Missing server address'); + } + vi.stubEnv('LITELLM_BASE_URL', `http://127.0.0.1:${address.port}`); + vi.stubEnv('LITELLM_API_KEY', 'sk-test'); + }); + afterEach(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + vi.unstubAllEnvs(); + }); + + describe('pi runtime with the real SDK and local LiteLLM protocol', () => { + it('normalizes finalized text and usage without inventing prices', async () => { + const runner = createAgentRunner({ + adapter: createPiAdapter(), + defaultOptions: { model: 'test', tools: [] }, + }); + const result = await runner.runAgent({ prompt: 'hi' }); + expect(result.error).toBeUndefined(); + expect(result.resultMessage).toMatchObject({ + success: true, + result: 'hello', + turns: 1, + }); + expect(result.totalCostUsd).toBeUndefined(); + expect(result.messages[1]).toMatchObject({ + type: 'generation', + usage: { inputTokens: 3, outputTokens: 2 }, + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.tools).toBeUndefined(); + }); + + it.each([ + { input: 2, output: 4 }, + { input: 0, output: 0 }, + ])('reports explicitly configured prices %j', async (cost) => { + const result = await createAgentRunner({ + adapter: createPiAdapter(), + }).runAgent({ + prompt: 'hi', + options: { model: 'test', tools: [], cost }, + }); + expect(result.totalCostUsd).toBe( + (3 * cost.input + 2 * cost.output) / 1e6, + ); + }); + + it('does not report a partial price as a complete total', async () => { + const result = await createAgentRunner({ + adapter: createPiAdapter(), + }).runAgent({ + prompt: 'hi', + options: { model: 'test', tools: [], cost: { input: 1 } }, + }); + expect(result.totalCostUsd).toBeUndefined(); + }); + + it('stops tool loops at maxTurns, without another HTTP request', async () => { + reply = { tool: 'bash', args: { command: 'printf done' } }; + const result = await createAgentRunner({ + adapter: createPiAdapter(), + }).runAgent({ + prompt: 'hi', + options: { model: 'test', tools: ['bash'], maxTurns: 1 }, + }); + expect(result.error).toBeUndefined(); + expect(result.resultMessage).toMatchObject({ + success: false, + turns: 1, + error: 'Pi reached maxTurns (1).', + }); + expect(result.messages).toContainEqual( + expect.objectContaining({ type: 'tool_result', content: 'done' }), + ); + expect(requests).toHaveLength(1); + }); + + it('judges with only submit_judgment and inherits the model', async () => { + reply = { + tool: 'submit_judgment', + args: { correctness: 7, reasoning: 'Looks right.' }, + }; + const runner = createAgentRunner({ + adapter: createPiAdapter(), + defaultOptions: { model: 'test', tools: ['bash'] }, + }); + const verdict = await runner.judge( + { + messages: [], + durationMs: 0, + isPartial: false, + metadata: { startedAt: '', endedAt: '', messageCount: 0 }, + }, + { + rubric: 'Judge correctness.', + scoreFields: [{ name: 'correctness', min: 0, max: 10 }], + }, + { taskPrompt: 'sk-test', status: 'sk-test' }, + ); + expect(verdict.scores.correctness).toBe(7); + expect(requests).toHaveLength(1); + expect( + requests[0]?.tools.map((tool: any) => tool.function.name), + ).toStrictEqual(['submit_judgment']); + expect(JSON.stringify(requests[0])).not.toContain('sk-test'); + }); + + it('cleans up when a judge message callback fails', async () => { + const runner = createAgentRunner({ + adapter: createPiAdapter(), + defaultOptions: { model: 'test' }, + }); + await expect( + runner.judge( + { + messages: [], + durationMs: 0, + isPartial: false, + metadata: { startedAt: '', endedAt: '', messageCount: 0 }, + }, + { + rubric: 'Judge.', + scoreFields: [{ name: 'score', min: 0, max: 10 }], + }, + undefined, + { + onMessage: () => { + throw new Error('callback'); + }, + }, + ), + ).rejects.toThrow('callback failed'); + expect(requests).toHaveLength(0); + }); + + it('rejects a judge that never submits the structured result', async () => { + const runner = createAgentRunner({ + adapter: createPiAdapter(), + defaultOptions: { model: 'test' }, + }); + await expect( + runner.judge( + { + messages: [], + durationMs: 0, + isPartial: false, + metadata: { startedAt: '', endedAt: '', messageCount: 0 }, + }, + { + rubric: 'Judge.', + scoreFields: [{ name: 'score', min: 0, max: 10 }], + }, + ), + ).rejects.toThrow('did not submit'); + }); + + it('cancels a stalled provider request and returns a partial result', async () => { + hanging = true; + const controller = new AbortController(); + const resultPromise = createAgentRunner({ + adapter: createPiAdapter(), + }).runAgent({ + prompt: 'hi', + options: { model: 'test' }, + signal: controller.signal, + }); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + controller.abort(new Error('cancel sk-test')); + const result = await resultPromise; + expect(result.isPartial).toBe(true); + expect(result.error?.message).toBe('cancel [REDACTED]'); + expect(result.resultMessage).toBeUndefined(); + }); + + it('does not start a prompt if the init callback fails', async () => { + const result = await createAgentRunner({ + adapter: createPiAdapter(), + }).runAgent({ + prompt: 'hi', + options: { model: 'test' }, + onMessage: () => { + throw new Error('callback failed'); + }, + }); + expect(result.isPartial).toBe(true); + expect(requests).toHaveLength(0); + }); + + it('returns an unsuccessful result for truncation and scrubs provider errors', async () => { + reply = { stop: 'length', text: 'partial sk-test' }; + const runner = createAgentRunner({ + adapter: createPiAdapter(), + defaultOptions: { model: 'test', tools: [] }, + }); + const truncated = await runner.runAgent({ prompt: 'hi' }); + expect(truncated.resultMessage).toMatchObject({ + success: false, + result: 'partial [REDACTED]', + }); + reply = { status: 400 }; + const failed = await runner.runAgent({ prompt: 'hi' }); + expect(failed.resultMessage).toMatchObject({ success: false }); + expect(JSON.stringify(failed)).not.toContain('sk-test'); + }); + + it('accepts a base URL that already ends in /v1', async () => { + // eslint-disable-next-line n/no-process-env -- Local fixture endpoint. + vi.stubEnv('LITELLM_BASE_URL', `${process.env.LITELLM_BASE_URL}/v1/`); + const messages: AgentMessage[] = []; + for await (const message of runPiSession('hi', { + model: 'test', + tools: [], + })) { + messages.push(message); + } + expect(messages.at(-1)).toMatchObject({ success: true }); + }); + }); + + describe('pi validation', () => { + it.each(['20.19.0', '22.18.0', '0.0.0'])( + 'rejects unsupported Node %s', + (version) => { + expect(() => assertPiNodeVersion(version)).toThrow('Node.js'); + }, + ); + it.each(['22.19.0', '24.0.0'])('accepts Node %s', (version) => { + expect(() => assertPiNodeVersion(version)).not.toThrow(/Pi/u); + }); + it.each([ + null, + {}, + { model: '' }, + { model: 'x', permissionMode: 'bypassPermissions' }, + { model: 'x', maxTurns: 0 }, + { model: 'x', maxTokens: Infinity }, + { model: 'x', tools: ['bash(rm:*)'] }, + { model: 'x', tools: 'bash' }, + { model: 'x', cwd: 'relative' }, + { model: 'x', cost: { input: -1 } }, + { model: 'x', cost: [] }, + { model: 'x', input: ['invalid'] }, + Object.assign(new Date(), { model: 'x' }), + { model: 'x', cost: { unknown: 1 } }, + { model: 'x', reasoning: 'yes' }, + { model: 'x', input: [] }, + { model: 'x', systemPrompt: 2 }, + ])('fails closed on invalid options %j', (options) => { + expect(() => validatePiOptions(options as any)).toThrow(/Pi/u); + }); + it('rejects judge tool customization', () => { + expect(() => + validatePiOptions({ model: 'x', tools: [] }, true), + ).toThrow('tool customization'); + }); + it('rejects missing credentials and pre-aborted runs without HTTP requests', async () => { + vi.stubEnv('LITELLM_API_KEY', ''); + const runner = createAgentRunner({ adapter: createPiAdapter() }); + const missing = await runner.runAgent({ + prompt: 'hi', + options: { model: 'x' }, + }); + expect(missing.error?.message).toContain('LITELLM_API_KEY'); + const aborted = await runner.runAgent({ + prompt: 'hi', + options: { model: 'x' }, + signal: AbortSignal.abort(), + }); + expect(aborted.error?.name).toBe('AbortError'); + expect(requests).toHaveLength(0); + }); + }); + }, +); diff --git a/src/pi-runtime.ts b/src/pi-runtime.ts new file mode 100644 index 0000000..95d90f7 --- /dev/null +++ b/src/pi-runtime.ts @@ -0,0 +1,640 @@ +import type { + AgentSession, + AgentSessionEvent, +} from '@earendil-works/pi-coding-agent'; +import path from 'node:path'; + +import type { PiQueryOptions, PiToolName } from './adapters/pi-types.js'; +import { + createCredentialScrubber, + scrubCredentials, +} from './credential-redactor.js'; +import type { AgentMessage, AgentResultMessage } from './types.js'; + +/** Supported pi SDK version, also installed in Docker. */ +export const PI_SDK_VERSION = '0.83.0'; +/** Environment forwarded to pi containers unless explicitly overridden. */ +export const PI_FORWARD_ENV = [ + 'LITELLM_BASE_URL', + 'LITELLM_API_KEY', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', +]; +const TOOLS: readonly PiToolName[] = [ + 'read', + 'bash', + 'edit', + 'write', + 'grep', + 'find', + 'ls', +]; +const OPTION_KEYS = new Set([ + 'model', + 'cwd', + 'systemPrompt', + 'tools', + 'maxTurns', + 'contextWindow', + 'maxTokens', + 'reasoning', + 'input', + 'cost', +]); + +/** Internal structured-output contract. Never supplied through pi query options. */ +export type PiStructuredOutput = { + /** Judge response schema. */ + schema: Record; + /** Judge rubric. */ + systemPrompt: string; +}; + +/** + * Checks the pi-specific Node floor without affecting Claude consumers. + * + * @param version - Node version. + */ +export function assertPiNodeVersion(version = process.versions.node): void { + const [major = 0, minor = 0] = version + .replace(/^v/u, '') + .split('.') + .map(Number); + if (major < 22 || (major === 22 && minor < 19)) { + throw new Error( + 'Pi requires Node.js >=22.19.0. Claude-only runs still support Node 20.', + ); + } +} + +/** + * Validates the complete pi option allowlist before starting any SDK or container. + * + * @param value - Caller options. + * @param structured - Whether the judge owns tool selection. + * @returns Validated options with defaults. + */ +export function validatePiOptions( + value: Partial, + structured = false, +): PiQueryOptions { + if (!isRecord(value)) { + throw new Error('Pi options must be a plain object.'); + } + for (const key of Object.keys(value)) { + if (!OPTION_KEYS.has(key)) { + throw new Error( + `Unsupported Pi option \`${key}\`; Claude permission policies cannot be applied to pi.`, + ); + } + } + if (typeof value.model !== 'string' || !value.model.trim()) { + throw new Error('Pi requires a non-empty options.model.'); + } + if ( + value.cwd !== undefined && + (typeof value.cwd !== 'string' || !path.isAbsolute(value.cwd)) + ) { + throw new Error('Pi options.cwd must be an absolute path.'); + } + if ( + value.systemPrompt !== undefined && + typeof value.systemPrompt !== 'string' + ) { + throw new Error('Pi options.systemPrompt must be a string.'); + } + for (const key of ['maxTurns', 'contextWindow', 'maxTokens'] as const) { + if ( + value[key] !== undefined && + (!Number.isSafeInteger(value[key]) || value[key] <= 0) + ) { + throw new Error(`Pi options.${key} must be a positive integer.`); + } + } + if (value.reasoning !== undefined && typeof value.reasoning !== 'boolean') { + throw new Error('Pi options.reasoning must be a boolean.'); + } + if ( + value.input !== undefined && + (!Array.isArray(value.input) || + !value.input.length || + value.input.some((entry) => entry !== 'text' && entry !== 'image')) + ) { + throw new Error('Pi options.input must contain text or image.'); + } + if (value.cost !== undefined) { + if (!isRecord(value.cost)) { + throw new Error('Pi options.cost must be a plain object.'); + } + for (const [key, price] of Object.entries(value.cost)) { + if ( + !['input', 'output', 'cacheRead', 'cacheWrite'].includes(key) || + typeof price !== 'number' || + !Number.isFinite(price) || + price < 0 + ) { + throw new Error( + 'Pi pricing must contain only finite nonnegative token prices.', + ); + } + } + } + if (structured && 'tools' in value) { + throw new Error('Pi judging does not accept caller tool customization.'); + } + if ( + value.tools !== undefined && + (!Array.isArray(value.tools) || + value.tools.some((tool) => !TOOLS.includes(tool))) + ) { + throw new Error('Pi tools must be an exact built-in tool allowlist.'); + } + return { + ...value, + model: value.model, + tools: structured ? [] : [...(value.tools ?? TOOLS.slice(0, 4))], + }; +} + +/** + * Checks a plain JSON-like record. + * + * @param value - Candidate record. + * @returns Whether the value has a plain object prototype. + */ +function isRecord(value: unknown): value is Record { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * Builds one isolated session using the supported SDK. + * + * @param options - Validated pi options. + * @param structured - Optional structured-output contract. + * @param capture - Receives the structured response. + * @returns A fresh, in-memory session. + */ +async function createSession( + options: PiQueryOptions, + structured: PiStructuredOutput | undefined, + capture: (value: unknown) => void, +): Promise { + assertPiNodeVersion(); + if (!process.env.LITELLM_BASE_URL || !process.env.LITELLM_API_KEY) { + throw new Error( + 'Pi requires LITELLM_BASE_URL and LITELLM_API_KEY in the environment.', + ); + } + const url = new URL(process.env.LITELLM_BASE_URL); + if ( + !['http:', 'https:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error( + 'LITELLM_BASE_URL must be an HTTP(S) URL without credentials, query, or fragment.', + ); + } + url.pathname = `${url.pathname.replace(/\/+$/u, '').replace(/\/v1$/u, '')}/v1`; + let pi: typeof import('@earendil-works/pi-coding-agent'); + try { + pi = await import('@earendil-works/pi-coding-agent'); + } catch (cause) { + throw new Error( + `Could not load pi. Install @earendil-works/pi-coding-agent@${PI_SDK_VERSION} and use Node >=22.19.0.`, + { cause }, + ); + } + if (pi.VERSION !== PI_SDK_VERSION) { + throw new Error(`Pi requires SDK ${PI_SDK_VERSION}; found ${pi.VERSION}.`); + } + // Credentials come only from the environment. No persistence or login flow + // exists in this adapter, so reject writes instead of implementing a store. + const modelRuntime = await pi.ModelRuntime.create({ + modelsPath: null, + credentials: { + /** + * Returns no stored credentials. + * + * @returns No persisted credential. + */ + read: async () => undefined, + /** + * Lists no stored credentials. + * + * @returns An empty credential list. + */ + list: async () => [], + /** Rejects credential writes. */ + modify: async () => { + throw new Error('Pi credential persistence is disabled.'); + }, + /** Rejects credential deletion. */ + delete: async () => { + throw new Error('Pi credential persistence is disabled.'); + }, + }, + allowModelNetwork: false, + }); + modelRuntime.registerProvider('litellm', { + name: 'LiteLLM', + baseUrl: url.href.replace(/\/$/u, ''), + apiKey: '$LITELLM_API_KEY', + api: 'openai-completions', + authHeader: true, + models: [ + { + id: options.model, + name: options.model, + reasoning: options.reasoning ?? false, + input: [...(options.input ?? ['text'])], + contextWindow: options.contextWindow ?? 128000, + maxTokens: options.maxTokens ?? 8192, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + ...options.cost, + }, + }, + ], + }); + const model = modelRuntime.getModel('litellm', options.model); + if (model === undefined) { + throw new Error('Pi could not resolve the configured LiteLLM model.'); + } + const cwd = options.cwd ?? process.cwd(); + const settingsManager = pi.SettingsManager.inMemory({ + retry: { enabled: false }, + compaction: { enabled: false }, + }); + const loader = new pi.DefaultResourceLoader({ + cwd, + agentDir: path.join(cwd, '.agent-runner-pi'), + settingsManager, + noExtensions: true, + noSkills: true, + noContextFiles: true, + noThemes: true, + noPromptTemplates: true, + ...((structured?.systemPrompt ?? options.systemPrompt) === undefined + ? {} + : { + systemPrompt: (structured?.systemPrompt ?? + options.systemPrompt) as string, + }), + }); + await loader.reload(); + const customTools = + structured === undefined + ? [] + : [ + pi.defineTool({ + name: 'submit_judgment', + label: 'Submit judgment', + description: 'Submit the final judgment using this tool and stop.', + parameters: structured.schema, + constrainedSampling: { type: 'json_schema', strict: 'require' }, + /** + * Captures the schema-validated judgment and terminates the tool batch. + * + * @param _id - Tool call identifier. + * @param params - Validated judgment fields. + * @returns A terminating tool result. + */ + execute: async (_id, params) => { + capture(params); + return { + content: [ + { type: 'text' as const, text: 'Judgment submitted.' }, + ], + details: {}, + terminate: true, + }; + }, + }), + ]; + const { session } = await pi.createAgentSession({ + cwd, + agentDir: path.join(cwd, '.agent-runner-pi'), + modelRuntime, + model, + resourceLoader: loader, + settingsManager, + sessionManager: pi.SessionManager.inMemory(cwd), + tools: + structured === undefined + ? [...(options.tools ?? [])] + : ['submit_judgment'], + customTools, + }); + return session; +} + +/** + * Extracts text without trusting tool-specific result details. + * + * @param result - Tool result from the SDK. + * @returns Text content of the result. + */ +function toolResultText(result: unknown): string { + if (!isRecord(result) || !Array.isArray(result.content)) { + return ''; + } + return result.content + .filter( + ( + block: unknown, + ): block is { + /** Text block discriminant. */ + type: 'text'; + /** Tool output text. */ + text: string; + } => + isRecord(block) && + block.type === 'text' && + typeof block.text === 'string', + ) + .map((block) => block.text) + .join('\n'); +} + +/** Session factory seam used to test lifecycle without network requests. */ +export type PiSessionFactory = typeof createSession; + +/** + * Runs the same pi lifecycle on the host and in Docker. + * + * @param prompt - Text prompt. + * @param input - Package-owned pi options. + * @param structured - Optional locked judgment contract. + * @param signal - Caller cancellation. + * @param factory - Test session factory. + * @yields Normalized messages, with credentials scrubbed before leaving the runtime. + */ +export async function* runPiSession( + prompt: string, + input: Partial, + structured?: PiStructuredOutput, + signal?: AbortSignal, + factory: PiSessionFactory = createSession, +): AsyncGenerator { + const scrub = createCredentialScrubber(); + let session: AgentSession | undefined; + let unsubscribe: (() => void) | undefined; + let task: Promise | undefined; + let completed = false; + let structuredResult: unknown; + let limitReached = false; + let turns = 0; + let lastText: string | undefined; + let failure: string | undefined; + let cost = 0; + let costKnown = true; + const started = Date.now(); + const toolStarts = new Map(); + const queue: AgentMessage[] = []; + let wake: (() => void) | undefined; + let settled = false; + let taskError: Error | undefined; + /** Wakes the waiting message consumer. */ + const notify = (): void => { + wake?.(); + wake = undefined; + }; + /** Stops the active model request and tools. */ + const cancel = (): void => { + session?.agent.abort(); + notify(); + }; + try { + signal?.throwIfAborted(); + if (typeof prompt !== 'string') { + throw new Error('Pi requires a string prompt.'); + } + const options = validatePiOptions(input, structured !== undefined); + costKnown = options.cost !== undefined; + session = await factory( + options, + scrubCredentials(structured, scrub), + (value) => { + structuredResult = value; + }, + ); + signal?.addEventListener('abort', cancel, { once: true }); + signal?.throwIfAborted(); + const activeSession = session; + /** + * Buffers a scrubbed message, aborting on overflow. + * + * @param message - Normalized runtime message. + */ + const push = (message: AgentMessage): void => { + if (queue.length >= 10000) { + taskError ??= new Error('Pi event queue exceeded 10000 messages.'); + cancel(); + } else { + queue.push(scrubCredentials(message, scrub)); + } + notify(); + }; + unsubscribe = session.subscribe((event: AgentSessionEvent) => { + if (event.type === 'message_end' && event.message.role === 'assistant') { + const { message } = event; + // Pi can emit a synthetic aborted response after the last tool batch. + // It is not another model generation. + if (limitReached) { + return; + } + turns += 1; + lastText = message.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join(''); + if ( + ['error', 'aborted', 'length', 'pending'].includes(message.stopReason) + ) { + failure = + message.errorMessage ?? + `Pi generation stopped with ${message.stopReason}.`; + } + const { usage } = message; + for (const key of [ + 'input', + 'output', + 'cacheRead', + 'cacheWrite', + ] as const) { + const tokens = usage[key]; + const price = options.cost?.[key]; + if ( + !Number.isFinite(tokens) || + tokens < 0 || + (tokens > 0 && price === undefined) + ) { + costKnown = false; + } else { + cost += (tokens * (price ?? 0)) / 1e6; + } + } + push({ + type: 'generation', + model: message.model, + text: lastText, + toolCalls: message.content + .filter((block) => block.type === 'toolCall') + .map((block) => ({ + id: block.id, + name: block.name, + input: block.arguments, + })), + usage: { + inputTokens: usage.input, + outputTokens: usage.output, + cacheReadTokens: usage.cacheRead, + cacheCreationTokens: usage.cacheWrite, + }, + stopReason: message.stopReason, + raw: event, + }); + } else if (event.type === 'tool_execution_start') { + toolStarts.set(event.toolCallId, Date.now()); + } else if (event.type === 'tool_execution_update') { + const start = toolStarts.get(event.toolCallId); + if (start !== undefined) { + push({ + type: 'tool_progress', + toolName: event.toolName, + elapsedSeconds: (Date.now() - start) / 1000, + raw: event, + }); + } + } else if (event.type === 'tool_execution_end') { + toolStarts.delete(event.toolCallId); + push({ + type: 'tool_result', + toolUseId: event.toolCallId, + content: toolResultText(event.result), + isError: event.isError, + raw: event, + }); + } else if ( + event.type === 'turn_end' && + options.maxTurns !== undefined && + turns >= options.maxTurns && + event.message.role === 'assistant' && + event.message.content.some((block) => block.type === 'toolCall') && + structuredResult === undefined + ) { + limitReached = true; + activeSession.agent.abort(); + } + }); + yield scrubCredentials( + { + type: 'init' as const, + sessionId: session.sessionId, + model: `litellm/${options.model}`, + tools: session.getActiveToolNames(), + }, + scrub, + ); + signal?.throwIfAborted(); + task = session + .prompt(structured === undefined ? prompt : scrub(prompt), { + expandPromptTemplates: false, + }) + .then( + () => { + settled = true; + notify(); + return undefined; + }, + (cause: unknown) => { + taskError ??= + cause instanceof Error ? cause : new Error(String(cause)); + settled = true; + notify(); + return undefined; + }, + ); + // eslint-disable-next-line no-unmodified-loop-condition -- The prompt promise settles asynchronously. + while (!settled || queue.length) { + signal?.throwIfAborted(); + if (queue.length) { + yield queue.shift() as AgentMessage; + } else { + // eslint-disable-next-line no-loop-func -- A one-shot wake slot, not a captured loop index. + await new Promise((resolve) => { + wake = resolve; + }); + } + } + if (taskError !== undefined) { + throw taskError; + } + signal?.throwIfAborted(); + if (limitReached) { + failure = `Pi reached maxTurns (${String(options.maxTurns)}).`; + } + if (turns === 0) { + failure ??= 'Pi produced no assistant response.'; + } + if (structured !== undefined && structuredResult === undefined) { + failure ??= 'Pi did not submit a judgment.'; + } + const result: AgentResultMessage = { + type: 'result', + success: failure === undefined, + turns, + durationMs: Date.now() - started, + ...(lastText === undefined ? {} : { result: lastText }), + ...(structuredResult === undefined + ? {} + : { result: JSON.stringify(structuredResult) }), + ...(costKnown && Number.isFinite(cost) ? { costUsd: cost } : {}), + ...(failure === undefined ? {} : { error: failure }), + }; + completed = true; + yield scrubCredentials(result, scrub); + } catch (cause) { + throw scrubCredentials( + cause instanceof Error ? cause : new Error(String(cause)), + scrub, + ); + } finally { + signal?.removeEventListener('abort', cancel); + if (session !== undefined) { + if (!completed) { + session.agent.abort(); + } + try { + await task; + } finally { + try { + unsubscribe?.(); + } finally { + try { + session.dispose(); + } catch (cause) { + if (completed) { + // eslint-disable-next-line no-unsafe-finally -- Only surface cleanup failure after natural completion. + throw scrubCredentials( + cause instanceof Error ? cause : new Error(String(cause)), + scrub, + ); + } + } + } + } + } + } +} diff --git a/src/runner.test.ts b/src/runner.test.ts index e8f4661..3139070 100644 --- a/src/runner.test.ts +++ b/src/runner.test.ts @@ -565,6 +565,7 @@ describe('createAgentRunner', () => { judgeConfig, undefined, undefined, + undefined, ); }); @@ -580,6 +581,7 @@ describe('createAgentRunner', () => { judgeConfig, context, undefined, + undefined, ); }); diff --git a/src/runner.ts b/src/runner.ts index 7c12a50..a6c823c 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -22,20 +22,13 @@ import type { AgentRunResult, AgentRunner, AgentRunnerConfig, + ClaudeQueryInput, ClaudeQueryOptions, ProviderAdapter, + ProviderRunMetadata, RunConfig, } from './types.js'; -/** - * Returns default query options with isolated settings. - * - * @returns Default query options. - */ -function defaultQueryOptions(): Partial { - return { settingSources: [] }; -} - /** * Assembles an agent run result from collected messages and timing data. * @@ -78,15 +71,95 @@ function createResult( }; } +/** + * Converts a provider prompt into best-effort telemetry text. + * + * @param prompt - Provider-specific prompt value. + * @returns A safe prompt representation for telemetry. + */ +function getTelemetryPrompt(prompt: unknown): string { + if (typeof prompt === 'string') { + return prompt; + } + + try { + const serialized = JSON.stringify(prompt); + if (serialized !== undefined) { + return serialized; + } + } catch { + // Fall through to a guarded string conversion. + } + + try { + return String(prompt); + } catch { + return '[unserializable prompt]'; + } +} + +/** + * Reads adapter-owned telemetry metadata without affecting agent execution. + * + * @param adapter - Active provider adapter. + * @param options - Merged provider-specific run options. + * @returns Provider metadata with neutral harness fallbacks. + */ +function getTelemetryRunMetadata( + adapter: ProviderAdapter, + options: Partial, +): Required { + try { + const metadata = adapter.getRunMetadata?.(options); + return { + model: metadata?.model ?? 'unknown', + maxTurns: metadata?.maxTurns ?? 0, + }; + } catch { + return { model: 'unknown', maxTurns: 0 }; + } +} + /** * Creates an agent runner with optional telemetry support. * * @param config - Runner configuration including telemetry and default options. * @returns The agent runner instance. */ -export function createAgentRunner(config: AgentRunnerConfig = {}): AgentRunner { +export function createAgentRunner( + config: AgentRunnerConfig & { + /** Custom provider adapter defining the runner's option and prompt types. */ + adapter: ProviderAdapter; + }, +): AgentRunner; +/** + * Creates the default Claude agent runner. + * + * @param config - Claude runner configuration with an optional Claude adapter. + * @returns A runner typed to Claude query options and prompts. + */ +export function createAgentRunner( + config?: AgentRunnerConfig, +): AgentRunner; +/** + * Implements the public Claude and custom-provider overloads. + * + * @param config - Runner configuration. + * @returns The configured agent runner. + */ +export function createAgentRunner< + TOptions extends object = ClaudeQueryOptions, + TPrompt = ClaudeQueryInput['prompt'], +>( + config: AgentRunnerConfig = {}, +): AgentRunner { const telemetry = createTelemetryController(config.telemetry); - const adapter: ProviderAdapter = config.adapter ?? createClaudeAdapter(); + // Public overloads only permit an omitted adapter for the Claude option and + // prompt types. This cast unifies that default with the generic implementation. + const adapter = (config.adapter ?? createClaudeAdapter()) as ProviderAdapter< + TOptions, + TPrompt + >; return { enabled: telemetry.enabled, @@ -98,33 +171,35 @@ export function createAgentRunner(config: AgentRunnerConfig = {}): AgentRunner { * @param runOptions - Options for this agent run. * @returns The agent run result with collected messages and metadata. */ - runAgent: async (runOptions: AgentRunOptions): Promise => { + runAgent: async ( + runOptions: AgentRunOptions, + ): Promise => { const options = { - ...defaultQueryOptions(), + ...(adapter.defaultOptions ?? + ({ settingSources: [] } as unknown as Partial)), ...config.defaultOptions, ...runOptions.options, }; const messages: AgentMessage[] = []; const startedAtMs = Date.now(); - const promptText = - typeof runOptions.prompt === 'string' - ? runOptions.prompt - : JSON.stringify(runOptions.prompt); - const handler = telemetry.enabled - ? createMessageHandler({ - prompt: promptText, - model: (options.model as string) ?? 'unknown', - maxTurns: (options.maxTurns as number) ?? 0, - redact: telemetry.redact, - redactor: telemetry.redactor, - userId: runOptions.telemetry?.userId ?? 'unknown', - initialSessionId: runOptions.telemetry?.sessionId, - traceName: runOptions.telemetry?.traceName, - traceMetadata: runOptions.telemetry?.metadata, - traceTags: runOptions.telemetry?.tags, - traceVersion: runOptions.telemetry?.version, - }) - : undefined; + let handler; + if (telemetry.enabled) { + const promptText = getTelemetryPrompt(runOptions.prompt); + const runMetadata = getTelemetryRunMetadata(adapter, options); + handler = createMessageHandler({ + prompt: promptText, + model: runMetadata.model, + maxTurns: runMetadata.maxTurns, + redact: telemetry.redact, + redactor: telemetry.redactor, + userId: runOptions.telemetry?.userId ?? 'unknown', + initialSessionId: runOptions.telemetry?.sessionId, + traceName: runOptions.telemetry?.traceName, + traceMetadata: runOptions.telemetry?.metadata, + traceTags: runOptions.telemetry?.tags, + traceVersion: runOptions.telemetry?.version, + }); + } let runError: Error | undefined; @@ -143,10 +218,13 @@ export function createAgentRunner(config: AgentRunnerConfig = {}): AgentRunner { } } - const runConfig: RunConfig = + const runConfig: RunConfig = sandbox === undefined ? { prompt: runOptions.prompt, options } : { prompt: runOptions.prompt, options, sandbox }; + if (runOptions.signal !== undefined) { + runConfig.signal = runOptions.signal; + } for await (const message of adapter.run(runConfig)) { messages.push(message); handler?.handleMessage(message); @@ -207,15 +285,26 @@ export function createAgentRunner(config: AgentRunnerConfig = {}): AgentRunner { */ judge: async ( runResult: AgentRunResult, - judgeConfig: JudgeConfig, + judgeConfig: JudgeConfig, context?: JudgeContext, options?: JudgeOptions, ): Promise => { + const structuredDefaults = adapter.getStructuredDefaults + ? adapter.getStructuredDefaults(config.defaultOptions ?? {}) + : {}; const result = await executeJudge( runResult, judgeConfig, context, options?.onMessage, + adapter.runStructured === undefined + ? undefined + : { + run: adapter.runStructured, + defaults: structuredDefaults, + sandbox: resolveSandboxConfig(config.sandbox, options?.sandbox), + signal: options?.signal, + }, ); if (options?.postScores && telemetry.enabled && runResult.traceId) { diff --git a/src/sandbox/container/pi-bridge-main.test.ts b/src/sandbox/container/pi-bridge-main.test.ts new file mode 100644 index 0000000..282efd5 --- /dev/null +++ b/src/sandbox/container/pi-bridge-main.test.ts @@ -0,0 +1,53 @@ +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ run: vi.fn() })); +vi.mock('../../pi-runtime.js', () => ({ runPiSession: mocks.run })); + +describe('pi standalone entry point', () => { + const oldCode = process.exitCode; + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + process.exitCode = oldCode; + }); + + it.each([false, true])( + 'settles the process exit code when stdout fails: %s', + async (writeFails) => { + vi.resetModules(); + const { Readable, Writable } = await import('node:stream'); + const stdout = new Writable({ + write(_chunk, _encoding, callback) { + callback(writeFails ? new Error('broken pipe') : undefined); + }, + }); + stdout.on('error', () => undefined); + const fakeProcess = { + ...process, + argv: [ + 'node', + fileURLToPath(new URL('./pi-bridge.ts', import.meta.url)), + ], + stdin: Readable.from([ + JSON.stringify({ + version: 1, + type: 'run', + prompt: 'hi', + options: {}, + }), + ]), + stdout, + exitCode: undefined as number | undefined, + }; + vi.stubGlobal('process', fakeProcess); + mocks.run.mockImplementation(async function* () { + yield { type: 'result', success: true }; + }); + await import('./pi-bridge.js'); + await vi.waitFor(() => + expect(fakeProcess.exitCode).toBe(writeFails ? 1 : 0), + ); + }, + ); +}); diff --git a/src/sandbox/container/pi-bridge.test.ts b/src/sandbox/container/pi-bridge.test.ts new file mode 100644 index 0000000..54598da --- /dev/null +++ b/src/sandbox/container/pi-bridge.test.ts @@ -0,0 +1,157 @@ +import { Readable, Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentMessage } from '../../types.js'; +import { runPiBridge } from './pi-bridge.js'; + +async function invoke( + request: unknown, + run?: Parameters[2], +) { + let output = ''; + const stdout = new Writable({ + write(chunk, _encoding, callback) { + output += String(chunk); + callback(); + }, + }); + const code = await runPiBridge( + Readable.from([ + typeof request === 'string' ? request : JSON.stringify(request), + ]), + stdout, + run, + ); + return { + code, + frames: output + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + }; +} + +describe('pi container bridge', () => { + it('bounds input and output frame sizes', async () => { + const oversized = await invoke('x'.repeat(10 * 1024 * 1024 + 1)); + expect(oversized.frames[0].error.message).toContain('request exceeds'); + const result = await invoke( + { version: 1, type: 'run', prompt: 'hi', options: {} }, + async function* (): AsyncGenerator { + yield { + type: 'result', + success: true, + result: 'x'.repeat(10 * 1024 * 1024), + }; + }, + ); + expect(result.frames[0].error.message).toContain('frame exceeds'); + }); + it('uses the real runtime by default and reports validation errors', async () => { + const result = await invoke({ + version: 1, + type: 'run', + prompt: 'hi', + options: {}, + }); + expect(result.frames[0].error.message).toContain('options.model'); + }); + it('normalizes non-Error failures', async () => { + const run: Parameters[2] = () => + ({ + [Symbol.asyncIterator]: () => ({ + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Test non-Error normalization. + next: async () => Promise.reject('non-error'), + }), + }) as AsyncGenerator; + const result = await invoke( + { version: 1, type: 'run', prompt: 'hi', options: {} }, + run, + ); + expect(result.frames[0].error.message).toBe('non-error'); + }); + it('streams the shared runtime messages and terminates with done', async () => { + const run = vi.fn(async function* (): AsyncGenerator { + yield { type: 'init', sessionId: 'session' }; + yield { type: 'result', success: true }; + }); + const structured = { schema: { type: 'object' }, systemPrompt: 'Judge.' }; + const result = await invoke( + { + version: 1, + type: 'run', + prompt: 'hi', + options: { model: 'test', structured }, + }, + run, + ); + expect(result.code).toBe(0); + expect(result.frames.map((frame) => frame.type)).toStrictEqual([ + 'message', + 'message', + 'done', + ]); + expect(run).toHaveBeenCalledWith('hi', { model: 'test' }, structured); + }); + it.each([ + 'not json', + 'null', + {}, + { version: 2 }, + { version: 1, type: 'run', prompt: 'hi', options: [] }, + { version: 1, type: 'run', prompt: 'hi', options: { structured: {} } }, + ])('rejects invalid input %j', async (request) => { + const run = vi.fn(); + const result = await invoke(request, run); + expect(result.code).toBe(1); + expect(result.frames[0].type).toBe('error'); + expect(run).not.toHaveBeenCalled(); + }); + it('scrubs thrown errors before writing protocol frames', async () => { + vi.stubEnv('LITELLM_API_KEY', 'short'); + try { + const result = await invoke( + { version: 1, type: 'run', prompt: 'hi', options: {} }, + // eslint-disable-next-line require-yield -- Failure before the first message. + async function* () { + throw new Error('short failure'); + }, + ); + expect(result.code).toBe(1); + expect(JSON.stringify(result.frames)).not.toContain('short'); + } finally { + vi.unstubAllEnvs(); + } + }); + it('closes the runtime iterator when stdout fails', async () => { + let closed = false; + const stdout = new Writable({ + write(_chunk, _encoding, callback) { + callback(new Error('broken pipe')); + }, + }); + stdout.on('error', () => undefined); + const run = async function* (): AsyncGenerator { + try { + yield { type: 'init', sessionId: 'x' }; + } finally { + closed = true; + } + }; + await expect( + runPiBridge( + Readable.from([ + JSON.stringify({ + version: 1, + type: 'run', + prompt: 'hi', + options: {}, + }), + ]), + stdout, + run, + ), + ).rejects.toThrow('broken pipe'); + expect(closed).toBe(true); + }); +}); diff --git a/src/sandbox/container/pi-bridge.ts b/src/sandbox/container/pi-bridge.ts new file mode 100644 index 0000000..42ea7a7 --- /dev/null +++ b/src/sandbox/container/pi-bridge.ts @@ -0,0 +1,120 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + createCredentialScrubber, + scrubCredentials, +} from '../../credential-redactor.js'; +import { runPiSession } from '../../pi-runtime.js'; +import type { PiStructuredOutput } from '../../pi-runtime.js'; + +/** + * Runs the container protocol using the same pi implementation as direct runs. + * + * @param stdin - Single JSON request stream. + * @param stdout - JSONL response stream. + * @param run - Shared runtime, injectable for protocol tests. + * @returns Process exit code. + */ +export async function runPiBridge( + stdin: NodeJS.ReadableStream, + stdout: NodeJS.WritableStream, + run = runPiSession, +): Promise { + const scrub = createCredentialScrubber(); + /** + * Writes one bounded frame and waits for stream completion. + * + * @param event - Protocol frame. + */ + const write = async (event: object): Promise => { + const line = `${JSON.stringify(event)}\n`; + if (Buffer.byteLength(line) > 10 * 1024 * 1024) { + throw new Error('Pi bridge frame exceeds 10 MiB.'); + } + await new Promise((resolve, reject) => { + stdout.write(line, (error?: Error | null) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); + }; + try { + stdin.setEncoding('utf8'); + let text = ''; + for await (const chunk of stdin) { + text += String(chunk); + if (Buffer.byteLength(text) > 10 * 1024 * 1024) { + throw new Error('Pi bridge request exceeds 10 MiB.'); + } + } + const request = JSON.parse(text) as Record; + if ( + request?.version !== 1 || + request.type !== 'run' || + typeof request.prompt !== 'string' || + request.options === null || + typeof request.options !== 'object' || + Array.isArray(request.options) + ) { + throw new Error('Invalid pi bridge request.'); + } + const { structured, ...options } = request.options as Record< + string, + unknown + >; + if ( + structured !== undefined && + (structured === null || + typeof structured !== 'object' || + !('schema' in structured) || + structured.schema === null || + typeof structured.schema !== 'object' || + !('systemPrompt' in structured) || + typeof structured.systemPrompt !== 'string') + ) { + throw new Error('Invalid pi structured-output contract.'); + } + for await (const message of run( + request.prompt, + options, + structured as PiStructuredOutput | undefined, + )) { + await write({ version: 1, type: 'message', message }); + } + await write({ version: 1, type: 'done' }); + return 0; + } catch (cause) { + const error = scrubCredentials( + cause instanceof Error ? cause : new Error(String(cause)), + scrub, + ); + try { + await write({ + version: 1, + type: 'error', + error: { name: error.name, message: error.message, stack: error.stack }, + }); + } catch { + throw error; + } + return 1; + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + runPiBridge(process.stdin, process.stdout) + .then((code) => { + process.exitCode = code; + return undefined; + }) + .catch(() => { + process.exitCode = 1; + }); +} diff --git a/src/sandbox/docker/bridge.test.ts b/src/sandbox/docker/bridge.test.ts index 0e6ed4b..937635d 100644 --- a/src/sandbox/docker/bridge.test.ts +++ b/src/sandbox/docker/bridge.test.ts @@ -11,14 +11,18 @@ import { } from './bridge-protocol.js'; import { BRIDGE_SDK_PACKAGE_NAME, + PI_BRIDGE_RUNTIME, + PI_BRIDGE_SDK_VERSION, DEFAULT_REMOTE_BRIDGE_DIR, DEFAULT_REMOTE_BRIDGE_FILE, MAX_BRIDGE_LINE_LENGTH, MAX_BRIDGE_QUEUE_SIZE, bootstrapDockerClaudeBridge, + bootstrapDockerBridge, resolveBridgeSdkVersion, resolveDefaultBridgeHostPath, runDockerClaudeBridge, + runDockerBridge, } from './bridge.js'; import type { DockerCommandResult, @@ -219,6 +223,66 @@ describe('resolveBridgeSdkVersion', () => { }); describe('bootstrapDockerClaudeBridge', () => { + it('bootstraps the locked Pi descriptor and enforces its version and minimum Node', async () => { + const { runner, calls } = makeStubRunner([ + () => ({ stdout: 'v22.19.0\n', stderr: '', exitCode: 0 }), + ]); + const result = await bootstrapDockerBridge({ + runtime: PI_BRIDGE_RUNTIME, + sandbox: makeHandle('ct-pi'), + config: makeConfig(), + commandRunner: runner, + bridgeHostPath: '/host/pi-bridge.mjs', + }); + expect(result.remoteBridgePath).toMatch(/pi-bridge\.mjs$/u); + expect(calls.at(-1)?.args).toContain( + `@earendil-works/pi-coding-agent@${PI_BRIDGE_SDK_VERSION}`, + ); + + await expect( + bootstrapDockerBridge({ + runtime: PI_BRIDGE_RUNTIME, + sandbox: makeHandle('ct-old'), + config: makeConfig(), + commandRunner: makeStubRunner([ + () => ({ stdout: 'v22.18.0\n', stderr: '', exitCode: 0 }), + ]).runner, + bridgeHostPath: '/host/pi-bridge.mjs', + }), + ).rejects.toThrow(/requires Node\.js >=22\.19\.0/u); + + await expect( + bootstrapDockerBridge({ + runtime: PI_BRIDGE_RUNTIME, + sandbox: makeHandle('ct-drift'), + config: makeConfig({ sdkVersion: '0.82.0' }), + commandRunner: makeStubRunner([ + () => ({ stdout: 'v24.0.0\n', stderr: '', exitCode: 0 }), + ]).runner, + bridgeHostPath: '/host/pi-bridge.mjs', + }), + ).rejects.toThrow(/requires .*@0\.83\.0/u); + }); + + it('defaults Pi bootstrap to the built Pi bridge host path', async () => { + const { runner, calls } = makeStubRunner([ + () => ({ stdout: 'v22.19.0\n', stderr: '', exitCode: 0 }), + ]); + await bootstrapDockerBridge({ + runtime: PI_BRIDGE_RUNTIME, + sandbox: makeHandle('ct-pi-default'), + config: makeConfig(), + commandRunner: runner, + }); + const copy = calls.find( + (call) => call.command === 'docker' && call.args[0] === 'cp', + ); + expect(copy?.args[1]).toMatch(/\/container\/pi-bridge\.mjs$/u); + expect(copy?.args[2]).toMatch(/pi-bridge\.mjs$/u); + expect(calls.at(-1)?.args).toContain( + `@earendil-works/pi-coding-agent@${PI_BRIDGE_SDK_VERSION}`, + ); + }); it('executes preflight, mkdir, cp, package.json, and npm install in order', async () => { const { runner, calls } = makeStubRunner(); @@ -559,6 +623,67 @@ describe('bootstrapDockerClaudeBridge', () => { }); }); +describe('pi Docker cancellation and bootstrap', () => { + it.each(['v24.0.0', 'v22.19.0'])( + 'preflights pi Node version %s', + async (version) => { + const { runner, calls } = makeStubRunner([ + () => ({ stdout: version, stderr: '', exitCode: 0 }), + ]); + const promise = bootstrapDockerBridge({ + runtime: PI_BRIDGE_RUNTIME, + sandbox: makeHandle('pi'), + config: makeConfig(), + commandRunner: runner, + }); + await promise; + expect(calls.filter((call) => call.args[0] === 'cp')).toHaveLength(3); + }, + ); + it('rejects a runtime whose SDK version cannot be resolved', async () => { + const { runner } = makeStubRunner(); + await expect( + bootstrapDockerBridge({ + runtime: { + id: 'missing', + packageName: 'missing', + remoteBridgeFile: 'bridge.mjs', + resolveVersion: () => undefined, + }, + sandbox: makeHandle('pi'), + config: makeConfig(), + commandRunner: runner, + }), + ).rejects.toThrow('Could not determine'); + }); + it('cancels a pending docker exec through the caller signal', async () => { + const controller = new AbortController(); + const { runner } = makeStubRunner([ + async (_command, _args, _options) => { + controller.abort(new Error('cancelled')); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + ]); + const error = await iterate( + runDockerBridge({ + runtime: PI_BRIDGE_RUNTIME, + sandbox: makeHandle('pi'), + config: makeConfig(), + commandRunner: runner, + request: { prompt: 'hi', options: {} }, + signal: controller.signal, + preparedBridge: { + remoteBridgePath: '/bridge', + nodeCommand: 'node', + remoteBridgeDir: '/tmp', + }, + }), + [], + ); + expect((error as Error).message).toBe('cancelled'); + }); +}); + describe('runDockerClaudeBridge', () => { const preparedBridge = { remoteBridgePath: `${DEFAULT_REMOTE_BRIDGE_DIR}/${DEFAULT_REMOTE_BRIDGE_FILE}`, diff --git a/src/sandbox/docker/bridge.ts b/src/sandbox/docker/bridge.ts index b807d5a..757fd19 100644 --- a/src/sandbox/docker/bridge.ts +++ b/src/sandbox/docker/bridge.ts @@ -1,8 +1,13 @@ +/* eslint-disable jsdoc/require-param, jsdoc/require-returns */ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { + createCredentialScrubber, + scrubCredentials as scrubValue, +} from '../../credential-redactor.js'; import { DockerSandboxError, DockerSandboxProtocolError, @@ -57,6 +62,48 @@ export const MAX_BRIDGE_QUEUE_SIZE = 10_000; */ export const BRIDGE_SDK_PACKAGE_NAME = '@anthropic-ai/claude-agent-sdk'; +/** Exact Pi runtime version locked by the package architecture. */ +export const PI_BRIDGE_SDK_VERSION = '0.83.0'; + +/** Runtime-specific data consumed by the generic Docker bootstrap. */ +export type DockerBridgeRuntimeDescriptor = { + /** Stable runtime identifier used in diagnostics. */ + id: string; + /** NPM package installed beside the bridge. */ + packageName: string; + /** Default bridge filename copied into the container. */ + remoteBridgeFile: string; + /** Resolves the exact install version when no explicit override is supplied. */ + resolveVersion: () => string | undefined; + /** Optional minimum Node.js version required by the runtime. */ + minNodeVersion?: string; + /** Optional version lock that explicit overrides must match. */ + exactVersion?: string; + /** Additional compiled files copied with their package-relative paths. */ + files?: readonly string[]; +}; + +/** Claude runtime descriptor retained behind compatibility wrappers. */ +export const CLAUDE_BRIDGE_RUNTIME: DockerBridgeRuntimeDescriptor = { + id: 'claude', + packageName: BRIDGE_SDK_PACKAGE_NAME, + remoteBridgeFile: DEFAULT_REMOTE_BRIDGE_FILE, + /** Resolves the installed Claude SDK version. */ + resolveVersion: readHostSdkVersion, +}; + +/** Pi runtime descriptor for the standalone Pi bridge. */ +export const PI_BRIDGE_RUNTIME: DockerBridgeRuntimeDescriptor = { + id: 'pi', + packageName: '@earendil-works/pi-coding-agent', + remoteBridgeFile: 'sandbox/container/pi-bridge.mjs', + files: ['pi-runtime.mjs', 'credential-redactor.mjs'], + /** Returns the architecture-locked Pi version. */ + resolveVersion: () => PI_BRIDGE_SDK_VERSION, + minNodeVersion: '22.19.0', + exactVersion: PI_BRIDGE_SDK_VERSION, +}; + /** * Resolves the on-disk path of the compiled container bridge `.mjs` * file shipped with this package. The build emits @@ -69,12 +116,18 @@ export const BRIDGE_SDK_PACKAGE_NAME = '@anthropic-ai/claude-agent-sdk'; * @returns Absolute path to the compiled bridge script. */ export function resolveDefaultBridgeHostPath(): string { + return resolveBridgeHostPath(CLAUDE_BRIDGE_RUNTIME); +} + +/** Resolves the built bridge path for a runtime descriptor. */ +export function resolveBridgeHostPath( + runtime: DockerBridgeRuntimeDescriptor, +): string { const here = fileURLToPath(import.meta.url); return path.resolve( path.dirname(here), - '..', - 'container', - DEFAULT_REMOTE_BRIDGE_FILE, + ...(runtime.files === undefined ? ['..', 'container'] : ['..', '..']), + runtime.remoteBridgeFile, ); } @@ -198,6 +251,14 @@ export type BootstrapDockerClaudeBridgeInput = { readHostSdkVersion?: () => string | undefined; }; +/** Input for the generic runtime-aware bridge bootstrap. */ +export type BootstrapDockerBridgeInput = BootstrapDockerClaudeBridgeInput & { + /** Cancellation checked during bootstrap. */ + signal?: AbortSignal; + /** Runtime being bootstrapped. */ + runtime: DockerBridgeRuntimeDescriptor; +}; + /** * Result of {@link bootstrapDockerClaudeBridge}. */ @@ -229,21 +290,43 @@ export type BootstrapDockerClaudeBridgeResult = { */ export async function bootstrapDockerClaudeBridge( input: BootstrapDockerClaudeBridgeInput, +): Promise { + return await bootstrapDockerBridge({ + ...input, + runtime: CLAUDE_BRIDGE_RUNTIME, + }); +} + +/** Bootstraps an arbitrary descriptor-defined SDK bridge. */ +export async function bootstrapDockerBridge( + input: BootstrapDockerBridgeInput, ): Promise { const { sandbox } = input; const runner = input.commandRunner; const bridgeConfig = input.config.bridge; - const bridgeHostPath = input.bridgeHostPath ?? resolveDefaultBridgeHostPath(); + const bridgeHostPath = + input.bridgeHostPath ?? resolveBridgeHostPath(input.runtime); const remoteBridgeDir = input.remoteBridgeDir ?? DEFAULT_REMOTE_BRIDGE_DIR; - const remoteBridgeFile = input.remoteBridgeFile ?? DEFAULT_REMOTE_BRIDGE_FILE; + const remoteBridgeFile = + input.remoteBridgeFile ?? input.runtime.remoteBridgeFile; const remoteBridgePath = `${remoteBridgeDir}/${remoteBridgeFile}`; + input.signal?.throwIfAborted(); - await preflightNodeAndNpm(runner, sandbox.containerName, bridgeConfig); + await preflightNodeAndNpm( + runner, + sandbox.containerName, + bridgeConfig, + input.runtime, + ); await runDockerExec( runner, sandbox.containerName, - ['sh', '-lc', `mkdir -p ${shellEscape(remoteBridgeDir)}`], + [ + 'sh', + '-lc', + `mkdir -p ${shellEscape(path.posix.dirname(remoteBridgePath))}`, + ], `prepare bridge directory (${remoteBridgeDir})`, ); @@ -260,14 +343,39 @@ export async function bootstrapDockerClaudeBridge( ); } - const sdkVersion = resolveBridgeSdkVersion({ - config: input.config, - ...(input.readHostSdkVersion === undefined - ? {} - : { readHostSdkVersion: input.readHostSdkVersion }), - }); + for (const file of input.runtime.files ?? []) { + await runner.run('docker', [ + 'cp', + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + file, + ), + `${sandbox.containerName}:${remoteBridgeDir}/${file}`, + ]); + } + + const explicitVersion = input.config.bridge.sdkVersion; + const sdkVersion = + explicitVersion ?? + input.readHostSdkVersion?.() ?? + input.runtime.resolveVersion(); + if (sdkVersion === undefined) { + throw new DockerSandboxError( + `Could not determine ${input.runtime.packageName} version for the ${input.runtime.id} bridge.`, + ); + } + if ( + input.runtime.exactVersion !== undefined && + sdkVersion !== input.runtime.exactVersion + ) { + throw new DockerSandboxError( + `${input.runtime.id} bridge requires ${input.runtime.packageName}@${input.runtime.exactVersion}; received ${sdkVersion}.`, + ); + } - const pkgJson = buildBridgePackageJson(sdkVersion); + const pkgJson = buildBridgePackageJson(input.runtime.packageName, sdkVersion); try { await runner.run( 'docker', @@ -302,12 +410,12 @@ export async function bootstrapDockerClaudeBridge( '--no-audit', '--no-fund', '--ignore-scripts', - `${BRIDGE_SDK_PACKAGE_NAME}@${sdkVersion}`, + `${input.runtime.packageName}@${sdkVersion}`, ]); } catch (cause) { throw wrapDockerSandboxError( `Failed to install bridge runtime in container \`${sandbox.containerName}\` ` + - `(npm install ${BRIDGE_SDK_PACKAGE_NAME}@${sdkVersion})`, + `(npm install ${input.runtime.packageName}@${sdkVersion})`, cause, ); } @@ -360,6 +468,14 @@ export type RunDockerClaudeBridgeInput = { preparedBridge?: BootstrapDockerClaudeBridgeResult; }; +/** Generic bridge-run input with a runtime descriptor. */ +export type RunDockerBridgeInput = RunDockerClaudeBridgeInput & { + /** Cancels the running docker exec. */ + signal?: AbortSignal; + /** Runtime being invoked. */ + runtime: DockerBridgeRuntimeDescriptor; +}; + /** * Bootstraps the bridge (when not already prepared), invokes it via * `docker exec`, and exposes the SDK message stream as an @@ -380,6 +496,13 @@ export type RunDockerClaudeBridgeInput = { */ export function runDockerClaudeBridge( input: RunDockerClaudeBridgeInput, +): AsyncIterable { + return runDockerBridge({ ...input, runtime: CLAUDE_BRIDGE_RUNTIME }); +} + +/** Runs a descriptor-defined bridge using the unchanged protocol-v1 stream. */ +export function runDockerBridge( + input: RunDockerBridgeInput, ): AsyncIterable { /** * AsyncIterator factory wired into the returned AsyncIterable. @@ -401,11 +524,13 @@ export function runDockerClaudeBridge( * @yields Each raw SDK message reported by the in-container bridge. */ async function* createBridgeIterator( - input: RunDockerClaudeBridgeInput, + input: RunDockerBridgeInput, ): AsyncGenerator { const prepared = input.preparedBridge ?? - (await bootstrapDockerClaudeBridge({ + (await bootstrapDockerBridge({ + runtime: input.runtime, + ...(input.signal === undefined ? {} : { signal: input.signal }), sandbox: input.sandbox, config: input.config, commandRunner: input.commandRunner, @@ -436,6 +561,13 @@ async function* createBridgeIterator( let runError: Error | null = null; let runCompleted = false; const wakerHolder: WakerHolder = { current: null }; + // The container bridge runs untrusted, model-controlled tools, so any error + // text or stderr it returns may echo a forwarded provider credential. Scrub + // those values before they surface in a host-side error, log, or telemetry. + const scrubCredentials = createCredentialScrubber({ + ...process.env, + ...input.config.env, + }); /** * Wakes the consumer loop if it is currently awaiting more input. @@ -498,6 +630,18 @@ async function* createBridgeIterator( // bridge to finish. const abortController = new AbortController(); + /** + * + */ + const cancel = (): void => { + abortController.abort(); + wake(); + }; + input.signal?.addEventListener('abort', cancel, { once: true }); + if (input.signal?.aborted) { + cancel(); + } + const runPromise = input.commandRunner .run('docker', execArgs, { stdin: serializeBridgeRequest(request), @@ -510,7 +654,9 @@ async function* createBridgeIterator( (result) => { if (result.exitCode !== 0) { runError = new DockerSandboxError( - `Docker bridge for container \`${containerName}\` exited with code ${result.exitCode}.${formatStderrExcerpt(result.stderr)}`, + scrubCredentials( + `Docker bridge for container \`${containerName}\` exited with code ${result.exitCode}.${formatStderrExcerpt(result.stderr)}`, + ), ); } return undefined; @@ -518,9 +664,11 @@ async function* createBridgeIterator( (cause: unknown) => { runError = cause instanceof Error - ? cause + ? scrubValue(cause, scrubCredentials) : new DockerSandboxError( - `Docker bridge for container \`${containerName}\` failed: ${String(cause)}`, + scrubCredentials( + `Docker bridge for container \`${containerName}\` failed: ${String(cause)}`, + ), ); return undefined; }, @@ -533,6 +681,7 @@ async function* createBridgeIterator( let doneSeen = false; try { while (true) { + input.signal?.throwIfAborted(); if (parseError !== null) { const errorToThrow: DockerSandboxProtocolError = parseError; throw errorToThrow; @@ -545,11 +694,15 @@ async function* createBridgeIterator( } if (event.type === 'error') { const named = event.error.name || 'Error'; - const message = `Docker bridge for container \`${containerName}\` reported an error: ${named}: ${event.error.message}`; + const message = scrubCredentials( + `Docker bridge for container \`${containerName}\` reported an error: ${named}: ${event.error.message}`, + ); if (event.error.stack !== undefined) { - const remoteCause = new Error(event.error.message); + const remoteCause = new Error( + scrubCredentials(event.error.message), + ); remoteCause.name = named; - remoteCause.stack = event.error.stack; + remoteCause.stack = scrubCredentials(event.error.stack); throw new DockerSandboxError(message, { cause: remoteCause }); } throw new DockerSandboxError(message); @@ -587,6 +740,7 @@ async function* createBridgeIterator( // failure that arrived before the abort has already been surfaced // (or replaced by the original termination cause). await runPromise; + input.signal?.removeEventListener('abort', cancel); } } @@ -633,14 +787,23 @@ async function preflightNodeAndNpm( runner: DockerCommandRunner, containerName: string, bridgeConfig: NormalizedDockerSandboxConfig['bridge'], + runtime: DockerBridgeRuntimeDescriptor, ): Promise { - await preflightBinary( + const nodeVersion = await preflightBinary( runner, containerName, bridgeConfig.nodeCommand, 'Node.js', - 'Install Node.js in the sandbox image (or set `sandbox.bridge.nodeCommand` to its path) before running the Claude bridge.', + `Install Node.js in the sandbox image (or set \`sandbox.bridge.nodeCommand\` to its path) before running the ${runtime.id} bridge.`, ); + if ( + runtime.minNodeVersion !== undefined && + !isVersionAtLeast(nodeVersion, runtime.minNodeVersion) + ) { + throw new DockerSandboxError( + `${runtime.id} bridge requires Node.js >=${runtime.minNodeVersion}; container reported ${nodeVersion.trim()}.`, + ); + } if (bridgeConfig.install) { await preflightBinary( runner, @@ -668,15 +831,16 @@ async function preflightBinary( command: string, friendlyName: string, guidance: string, -): Promise { +): Promise { try { - await runner.run('docker', [ + const result = await runner.run('docker', [ 'exec', containerName, 'sh', '-lc', `${shellEscape(command)} --version`, ]); + return result.stdout; } catch (cause) { const reason = cause instanceof Error ? cause.message : String(cause); throw new DockerSandboxError( @@ -690,10 +854,14 @@ async function preflightBinary( * Builds the minimal `package.json` document written into the bridge * directory inside the container. * + * @param packageName - Runtime package name. * @param sdkVersion - SDK version that `npm install` will pin. * @returns A JSON string ready to be piped over stdin. */ -function buildBridgePackageJson(sdkVersion: string): string { +function buildBridgePackageJson( + packageName: string, + sdkVersion: string, +): string { return `${JSON.stringify( { name: 'metamask-agent-runner-bridge', @@ -701,7 +869,7 @@ function buildBridgePackageJson(sdkVersion: string): string { private: true, type: 'module', dependencies: { - [BRIDGE_SDK_PACKAGE_NAME]: sdkVersion, + [packageName]: sdkVersion, }, }, null, @@ -709,6 +877,23 @@ function buildBridgePackageJson(sdkVersion: string): string { )}\n`; } +/** Compares dotted Node versions without a semver runtime dependency. */ +function isVersionAtLeast(actualOutput: string, minimum: string): boolean { + const match = /v?(\d+)\.(\d+)\.(\d+)/u.exec(actualOutput.trim()); + if (!match) { + return false; + } + const actual = match.slice(1).map(Number); + const required = minimum.split('.').map(Number); + for (let index = 0; index < 3; index += 1) { + const difference = (actual[index] ?? 0) - (required[index] ?? 0); + if (difference !== 0) { + return difference > 0; + } + } + return true; +} + /** * Runs `docker exec ` and wraps any failure with context about * which bridge step failed. diff --git a/src/sandbox/docker/lifecycle.ts b/src/sandbox/docker/lifecycle.ts index c4b3bef..903920f 100644 --- a/src/sandbox/docker/lifecycle.ts +++ b/src/sandbox/docker/lifecycle.ts @@ -150,6 +150,15 @@ export async function createDockerSandbox( validateMountPaths(config); await runner.run('docker', runArgs); } catch (cause) { + // A cancelled docker client may have created the container before its + // response arrived. Remove it before unregistering even on start failure. + if ( + cause instanceof Error && + (cause.name === 'AbortError' || + (cause.cause instanceof Error && cause.cause.name === 'AbortError')) + ) { + await bestEffortRemove(runner, containerName); + } if (config.cleanup !== 'never') { unregisterActiveContainer(containerName); } diff --git a/src/sandbox/docker/pi-integration.test.ts b/src/sandbox/docker/pi-integration.test.ts new file mode 100644 index 0000000..2e20e49 --- /dev/null +++ b/src/sandbox/docker/pi-integration.test.ts @@ -0,0 +1,87 @@ +import { createServer } from 'node:http'; +import type { Server } from 'node:http'; +import { afterEach, describe, expect, it } from 'vitest'; + +// The host must be reachable from Docker as host.docker.internal. On Linux, +// provide that hostname in the daemon's network or run the daemon locally with +// an appropriate host-gateway mapping. Build dist before enabling these tests. +describe.skipIf(process.env.RUN_DOCKER_TESTS !== '1')( + 'pi real Docker integration', + () => { + let server: Server | undefined; + afterEach(async () => { + server?.closeAllConnections(); + await new Promise((resolve) => + server ? server.close(() => resolve()) : resolve(), + ); + }); + + it('runs and judges through the standalone bridge with no external model service', async () => { + // Load the built package only when the opt-in test runs. + const { createAgentRunner, createPiAdapter } = (await import( + new URL('../../../dist/index.mjs', import.meta.url).href + )) as typeof import('../../index.js'); + let judge = false; + server = createServer((req, res) => { + req.resume(); + req.on('end', () => { + const delta = judge + ? { + tool_calls: [ + { + index: 0, + id: 'call', + type: 'function', + function: { + name: 'submit_judgment', + arguments: JSON.stringify({ + score: 8, + reasoning: 'good', + }), + }, + }, + ], + } + : { content: 'Docker works' }; + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.end( + `data: ${JSON.stringify({ id: 'x', object: 'chat.completion.chunk', choices: [{ index: 0, delta, finish_reason: judge ? 'tool_calls' : 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 } })}\n\ndata: [DONE]\n\n`, + ); + }); + }); + await new Promise((resolve) => + server?.listen(0, '0.0.0.0', resolve), + ); + const address = server.address(); + const { port } = address as import('node:net').AddressInfo; + const runner = createAgentRunner({ + adapter: createPiAdapter(), + defaultOptions: { model: 'test', tools: [] }, + sandbox: { + type: 'docker', + image: 'node:22-bookworm', + workspace: false, + workdir: '/tmp', + forwardEnv: false, + env: { + LITELLM_BASE_URL: `http://host.docker.internal:${port}`, + LITELLM_API_KEY: 'sk-local', + }, + }, + }); + const result = await runner.runAgent({ prompt: 'hi' }); + expect(result.error).toBeUndefined(); + expect(result.resultMessage).toMatchObject({ + success: true, + result: 'Docker works', + turns: 1, + }); + judge = true; + const verdict = await runner.judge(result, { + rubric: 'Judge.', + scoreFields: [{ name: 'score', min: 0, max: 10 }], + }); + expect(verdict.scores.score).toBe(8); + }, 120000); + }, +); diff --git a/src/telemetry/controller.test.ts b/src/telemetry/controller.test.ts index 33669a2..2998873 100644 --- a/src/telemetry/controller.test.ts +++ b/src/telemetry/controller.test.ts @@ -11,6 +11,7 @@ const telemetryMocks = vi.hoisted(() => ({ ), langfuseSpanProcessorConfigs: [] as object[], nodeSdkConfigs: [] as object[], + messageHandlerConfigs: [] as object[], })); vi.mock('@langfuse/otel', () => ({ @@ -64,6 +65,19 @@ vi.mock('../telemetry/tracing.js', () => ({ flushTracing: vi.fn(async () => undefined), })); +vi.mock('../telemetry/message-handler.js', () => ({ + createMessageHandler: vi.fn((config: object) => { + telemetryMocks.messageHandlerConfigs.push(config); + return { + handleMessage: vi.fn(), + recordError: vi.fn(), + finalizePendingTools: vi.fn(), + finalizeSessionSpan: vi.fn(), + getState: vi.fn(() => ({ traceId: undefined })), + }; + }), +})); + /** Mock adapter with captured run calls for test assertions. */ type MockAdapter = { /** Captured adapter run call arguments. */ @@ -89,10 +103,12 @@ const resultMessage: AgentMessage = { * Creates a mock adapter for telemetry testing. * * @param messages - Messages emitted by the adapter. + * @param getRunMetadata - Optional provider metadata hook. * @returns The mock adapter and recorded run calls. */ const createMockAdapter = ( messages: AgentMessage[] = [initMessage, resultMessage], + getRunMetadata?: ProviderAdapter['getRunMetadata'], ): MockAdapter => { const runCalls: unknown[] = []; const adapter: ProviderAdapter = { @@ -109,6 +125,7 @@ const createMockAdapter = ( yield message; } }, + ...(getRunMetadata === undefined ? {} : { getRunMetadata }), }; return { runCalls, adapter }; @@ -123,6 +140,7 @@ describe('telemetry lifecycle', () => { vi.clearAllMocks(); telemetryMocks.langfuseSpanProcessorConfigs.length = 0; telemetryMocks.nodeSdkConfigs.length = 0; + telemetryMocks.messageHandlerConfigs.length = 0; /* eslint-disable n/no-process-env */ process.env.LANGFUSE_PUBLIC_KEY = 'pk-lf-test'; process.env.LANGFUSE_SECRET_KEY = 'sk-lf-test'; @@ -202,6 +220,239 @@ describe('telemetry lifecycle', () => { await runner.shutdown(); }); + it('does not prepare adapter metadata when telemetry is disabled', async () => { + const { createAgentRunner } = await import('../runner.js'); + const getRunMetadata = vi.fn(() => ({ model: 'unused' })); + const { adapter } = createMockAdapter(undefined, getRunMetadata); + const runner = createAgentRunner({ adapter }); + + const result = await runner.runAgent({ prompt: 'plain run' }); + + expect(result.isPartial).toBe(false); + expect(getRunMetadata).not.toHaveBeenCalled(); + expect(telemetryMocks.messageHandlerConfigs).toStrictEqual([]); + }); + + it('passes non-JSON prompts to the adapter when telemetry is disabled', async () => { + const { createAgentRunner } = await import('../runner.js'); + const runCalls: unknown[] = []; + const adapter: ProviderAdapter = { + name: 'bigint-prompt', + /** + * Captures the generic prompt without serializing it. + * + * @param config - Generic provider run configuration. + * @yields The normalized result message. + */ + async *run(config): AsyncGenerator { + runCalls.push(config); + yield resultMessage; + }, + }; + const runner = createAgentRunner({ adapter }); + const prompt = { value: 1n }; + + const result = await runner.runAgent({ prompt }); + + expect(result.isPartial).toBe(false); + expect(runCalls).toStrictEqual([ + { prompt, options: { settingSources: [] } }, + ]); + }); + + it('uses adapter-owned run metadata for telemetry', async () => { + const { createAgentRunner } = await import('../runner.js'); + const { adapter } = createMockAdapter(undefined, () => ({ + model: 'provider-model', + maxTurns: 6, + })); + const getRunMetadata = vi.spyOn(adapter, 'getRunMetadata'); + const runner = createAgentRunner({ + adapter, + telemetry: { mode: 'enabled' }, + }); + + await runner.runAgent({ prompt: 'instrumented run' }); + + expect(getRunMetadata).toHaveBeenCalledWith({ settingSources: [] }); + expect(telemetryMocks.messageHandlerConfigs).toContainEqual( + expect.objectContaining({ model: 'provider-model', maxTurns: 6 }), + ); + + await runner.shutdown(); + }); + + it('uses neutral telemetry fallbacks when adapter metadata is unavailable', async () => { + const { createAgentRunner } = await import('../runner.js'); + const { adapter } = createMockAdapter(); + const runner = createAgentRunner({ + adapter, + telemetry: { mode: 'enabled' }, + }); + + await runner.runAgent({ prompt: 'instrumented run' }); + + expect(telemetryMocks.messageHandlerConfigs).toContainEqual( + expect.objectContaining({ model: 'unknown', maxTurns: 0 }), + ); + + await runner.shutdown(); + }); + + it('continues when adapter metadata extraction throws', async () => { + const { createAgentRunner } = await import('../runner.js'); + const getRunMetadata = vi.fn(() => { + throw new Error('metadata failure'); + }); + const { adapter, runCalls } = createMockAdapter(undefined, getRunMetadata); + const runner = createAgentRunner({ + adapter, + telemetry: { mode: 'enabled' }, + }); + + const result = await runner.runAgent({ prompt: 'instrumented run' }); + + expect(result.isPartial).toBe(false); + expect(runCalls).toHaveLength(1); + expect(telemetryMocks.messageHandlerConfigs).toContainEqual( + expect.objectContaining({ model: 'unknown', maxTurns: 0 }), + ); + + await runner.shutdown(); + }); + + it('uses a safe telemetry prompt for non-JSON values', async () => { + const { createAgentRunner } = await import('../runner.js'); + const adapter: ProviderAdapter = { + name: 'bigint-prompt', + /** + * Emits a successful result for the generic prompt. + * + * @yields The normalized result message. + */ + async *run(): AsyncGenerator { + yield resultMessage; + }, + }; + const runner = createAgentRunner({ + adapter, + telemetry: { mode: 'enabled' }, + }); + + const result = await runner.runAgent({ prompt: { value: 1n } }); + + expect(result.isPartial).toBe(false); + expect(telemetryMocks.messageHandlerConfigs).toContainEqual( + expect.objectContaining({ prompt: '[object Object]' }), + ); + + await runner.shutdown(); + }); + + it('serializes object prompts for telemetry when possible', async () => { + const { createAgentRunner } = await import('../runner.js'); + const adapter: ProviderAdapter = { + name: 'object-prompt', + /** + * Emits a successful result for the generic prompt. + * + * @yields The normalized result message. + */ + async *run(): AsyncGenerator { + yield resultMessage; + }, + }; + const runner = createAgentRunner({ + adapter, + telemetry: { mode: 'enabled' }, + }); + + const result = await runner.runAgent({ prompt: { task: 'inspect' } }); + + expect(result.isPartial).toBe(false); + expect(telemetryMocks.messageHandlerConfigs).toContainEqual( + expect.objectContaining({ prompt: '{"task":"inspect"}' }), + ); + + await runner.shutdown(); + }); + + it('uses a neutral telemetry prompt when serialization and string conversion throw', async () => { + const { createAgentRunner } = await import('../runner.js'); + const adapter: ProviderAdapter = { + name: 'throwing-prompt', + /** + * Emits a successful result for the generic prompt. + * + * @yields The normalized result message. + */ + async *run(): AsyncGenerator { + yield resultMessage; + }, + }; + const runner = createAgentRunner({ + adapter, + telemetry: { mode: 'enabled' }, + }); + const prompt = { + /** Throws during JSON serialization. */ + toJSON(): never { + throw new Error('cannot serialize'); + }, + /** Throws during fallback string conversion. */ + toString(): never { + throw new Error('cannot stringify'); + }, + }; + + const result = await runner.runAgent({ prompt }); + + expect(result.isPartial).toBe(false); + expect(telemetryMocks.messageHandlerConfigs).toContainEqual( + expect.objectContaining({ prompt: '[unserializable prompt]' }), + ); + + await runner.shutdown(); + }); + + it('falls back only missing adapter metadata fields', async () => { + const { createAgentRunner } = await import('../runner.js'); + const { adapter } = createMockAdapter(undefined, () => ({ maxTurns: 4 })); + vi.spyOn(adapter, 'getRunMetadata'); + const runner = createAgentRunner({ + adapter, + telemetry: { mode: 'enabled' }, + }); + + await runner.runAgent({ prompt: 'instrumented run' }); + + expect(telemetryMocks.messageHandlerConfigs).toContainEqual( + expect.objectContaining({ model: 'unknown', maxTurns: 4 }), + ); + + await runner.shutdown(); + }); + + it('preserves an adapter model while defaulting missing max turns', async () => { + const { createAgentRunner } = await import('../runner.js'); + const { adapter } = createMockAdapter(undefined, () => ({ + model: 'provider-model', + })); + vi.spyOn(adapter, 'getRunMetadata'); + const runner = createAgentRunner({ + adapter, + telemetry: { mode: 'enabled' }, + }); + + await runner.runAgent({ prompt: 'instrumented run' }); + + expect(telemetryMocks.messageHandlerConfigs).toContainEqual( + expect.objectContaining({ model: 'provider-model', maxTurns: 0 }), + ); + + await runner.shutdown(); + }); + it('returns partial result when agent run fails under telemetry', async () => { const { createAgentRunner } = await import('../runner.js'); const adapterError = new Error('Adapter connection failed'); diff --git a/src/telemetry/message-handler.ts b/src/telemetry/message-handler.ts index de907ff..61eca3b 100644 --- a/src/telemetry/message-handler.ts +++ b/src/telemetry/message-handler.ts @@ -1,3 +1,7 @@ +import { + createCredentialScrubber, + scrubCredentials, +} from '../credential-redactor.js'; import { redactSensitive } from '../message-parser.js'; import type { AgentMessage, TelemetryRedactor, ToolCall } from '../types.js'; import { @@ -161,18 +165,19 @@ export type MessageHandler = { export function createMessageHandler( config: MessageHandlerConfig, ): MessageHandler { + const scrubCredential = createCredentialScrubber(); /** - * Applies the configured value-level redactor to a string, if any. + * Applies credential redaction followed by the caller's redactor. * - * @param value - The string leaf to scrub. - * @returns The scrubbed string, or the original when no redactor is set - * or the value is undefined. + * @param value - String leaf to scrub. + * @returns Scrubbed text, or undefined unchanged. */ function scrubText(value: Value): Value { - if (config.redactor && typeof value === 'string') { - return config.redactor(value) as Value; + if (typeof value !== 'string') { + return value; } - return value; + const scrubbed = scrubCredential(value); + return (config.redactor ? config.redactor(scrubbed) : scrubbed) as Value; } /** @@ -183,12 +188,8 @@ export function createMessageHandler( * @returns The value with string leaves scrubbed. */ function scrubValueLeaves(value: unknown): unknown { - if (!config.redactor) { - return value; - } - if (typeof value === 'string') { - return config.redactor(value); + return scrubText(value); } if (Array.isArray(value)) { @@ -447,7 +448,9 @@ export function createMessageHandler( * @param error - The run error. */ recordError: (error: Error): void => { - state.runError = error; + state.runError = scrubCredentials(error, (text) => + config.redact ? '[REDACTED]' : scrubText(text), + ); }, finalizePendingTools, finalizeSessionSpan, diff --git a/src/types.test-d.ts b/src/types.test-d.ts new file mode 100644 index 0000000..dfdd9d4 --- /dev/null +++ b/src/types.test-d.ts @@ -0,0 +1,154 @@ +import { expectTypeOf } from 'vitest'; + +import { createClaudeAdapter } from './adapters/claude-adapter.js'; +import { createPiAdapter } from './adapters/pi-adapter.js'; +import type { PiQueryOptions } from './adapters/pi-types.js'; +import { createAgentRunner } from './runner.js'; +import type { + AgentMessage, + AgentRunOptions, + AgentRunResult, + AgentRunner, + AgentRunnerConfig, + ClaudeQueryInput, + ClaudeQueryOptions, + JudgeConfig, + ProviderAdapter, +} from './types.js'; + +const claudeRunner = createAgentRunner({ defaultOptions: { maxTurns: 1 } }); +expectTypeOf(claudeRunner.runAgent) + .parameter(0) + .toEqualTypeOf>(); + +const claudeRunPromise = claudeRunner.runAgent({ + prompt: 'use a later Claude option', + options: { model: 'claude-sonnet' }, +}); +expectTypeOf(claudeRunPromise).toEqualTypeOf>(); + +const annotatedClaudeConfig: AgentRunnerConfig = { + defaultOptions: { maxTurns: 2 }, +}; +const annotatedClaudeRunner = createAgentRunner(annotatedClaudeConfig); +expectTypeOf(annotatedClaudeRunner).toEqualTypeOf(); + +const annotatedClaudeAdapterConfig: AgentRunnerConfig = { + adapter: createClaudeAdapter(), + defaultOptions: { maxTurns: 2 }, +}; +const annotatedClaudeAdapterRunner = createAgentRunner( + annotatedClaudeAdapterConfig, +); +expectTypeOf(annotatedClaudeAdapterRunner).toEqualTypeOf(); + +/** Provider-specific options used to verify custom adapter inference. */ +type CustomOptions = { + /** Sampling temperature supported by the custom provider. */ + temperature?: number; +}; + +const customAdapter: ProviderAdapter = { + name: 'custom', + /** + * Creates the custom adapter test stream. + * + * @yields The normalized result message. + */ + async *run(): AsyncGenerator { + yield { type: 'result', success: true }; + }, +}; + +const customRunner = createAgentRunner({ + adapter: customAdapter, + defaultOptions: { temperature: 0.2 }, +}); + +expectTypeOf(customRunner.runAgent) + .parameter(0) + .toEqualTypeOf>(); +/** Claude-only options that must not match the custom runner. */ +type ClaudeOnlyRunOptions = { + /** Prompt accepted by the default Claude runner. */ + prompt: string; + /** Claude-specific query options. */ + options?: ClaudeMaxTurnsOption; +}; +/** Claude option intentionally unsupported by the custom provider. */ +type ClaudeMaxTurnsOption = { + /** Maximum Claude agent turns. */ + maxTurns?: number; +}; +expectTypeOf(customRunner.runAgent) + .parameter(0) + .not.toMatchTypeOf(); +expectTypeOf().not.toMatchTypeOf(); + +/** Factory configuration inferred for explicit custom provider generics. */ +type CustomFactoryConfig = Parameters< + typeof createAgentRunner +>[0]; +/** Expected factory configuration requiring the custom adapter. */ +type ExpectedCustomFactoryConfig = AgentRunnerConfig & { + /** Adapter required when explicit custom generics are supplied. */ + adapter: ProviderAdapter; +}; +expectTypeOf().toEqualTypeOf(); + +const customRunPromise = customRunner.runAgent({ + prompt: 'custom prompt', + options: { temperature: 0.7 }, +}); +expectTypeOf(customRunPromise).toEqualTypeOf>(); + +/** Options deliberately overlapping Claude's model field. */ +type OverlappingCustomOptions = { + /** Provider model identifier. */ + model?: string; +}; +const overlappingAdapter: ProviderAdapter< + OverlappingCustomOptions, + ClaudeQueryInput['prompt'] +> = { + name: 'overlapping-custom', + /** + * Creates the overlapping adapter test stream. + * + * @yields The normalized result message. + */ + async *run(): AsyncGenerator { + yield { type: 'result', success: true }; + }, +}; +const overlappingRunner = createAgentRunner({ adapter: overlappingAdapter }); +expectTypeOf(overlappingRunner.runAgent) + .parameter(0) + .toEqualTypeOf< + AgentRunOptions + >(); +expectTypeOf(overlappingRunner.runAgent) + .parameter(0) + .not.toMatchTypeOf(); +expectTypeOf[1]>().toEqualTypeOf< + JudgeConfig +>(); +expectTypeOf< + NonNullable['queryOptions']> +>().toEqualTypeOf>(); + +const piAdapter = createPiAdapter(); +expectTypeOf(piAdapter).toEqualTypeOf< + ProviderAdapter +>(); +const piRunner = createAgentRunner({ adapter: piAdapter }); +expectTypeOf(piRunner.runAgent) + .parameter(0) + .toEqualTypeOf>(); + +/** Claude-only field intentionally absent from Pi's package-owned options. */ +type ClaudePolicyOption = { + /** Claude permission mode unsupported by Pi. */ + permissionMode?: 'bypassPermissions'; +}; +expectTypeOf().not.toMatchTypeOf(); diff --git a/src/types.ts b/src/types.ts index a59f407..8491ed6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -214,11 +214,16 @@ export type ClaudeQueryOptions = NonNullable; /** * Configuration passed to a provider adapter's run method. */ -export type RunConfig = { +export type RunConfig< + TOptions extends object = ClaudeQueryOptions, + TPrompt = ClaudeQueryInput['prompt'], +> = { /** The prompt to send to the agent. */ - prompt: ClaudeQueryInput['prompt']; + prompt: TPrompt; /** Query options forwarded to the provider. */ - options: Partial; + options: Partial; + /** Cancels active work. Supported by the built-in pi adapter. */ + signal?: AbortSignal; /** * Resolved sandbox configuration for this run. Adapters that do not * declare sandbox support via {@link ProviderAdapter.capabilities} @@ -230,6 +235,25 @@ export type RunConfig = { sandbox?: SandboxConfig; }; +/** + * Configuration passed to a provider adapter's structured-output run method. + */ +export type RunStructuredConfig = + { + /** The prompt to send to the agent. */ + prompt: string; + /** System prompt controlling structured evaluation behavior. */ + systemPrompt: string; + /** JSON schema the provider must enforce for the final result. */ + schema: Record; + /** Provider-specific options for the structured run. */ + options: Partial; + /** Resolved sandbox for structured execution. */ + sandbox?: SandboxConfig; + /** Cancels structured execution. */ + signal?: AbortSignal; + }; + /** * Optional capability descriptor advertised by a provider adapter. * @@ -250,15 +274,45 @@ export type ProviderAdapterCapabilities = { sandboxes?: readonly SandboxConfig['type'][]; }; +/** + * Provider-owned metadata used to describe a run in telemetry. + */ +export type ProviderRunMetadata = { + /** Model requested for the run, when known before initialization. */ + model?: string; + /** Maximum number of turns configured for the run, when applicable. */ + maxTurns?: number; +}; + /** * Abstraction over an LLM agent provider, decoupling the runner from * any specific SDK. */ -export type ProviderAdapter = { +export type ProviderAdapter< + TOptions extends object = ClaudeQueryOptions, + TPrompt = ClaudeQueryInput['prompt'], +> = { /** Identifier for the provider (e.g. 'claude'). */ name: string; + /** Provider defaults. Omitted retains legacy isolated Claude settings. */ + defaultOptions?: Partial; /** Executes an agent run and yields normalized messages. */ - run: (config: RunConfig) => AsyncIterable; + run: (config: RunConfig) => AsyncIterable; + /** Executes a structured-output run when supported by the provider. */ + runStructured?: ( + config: RunStructuredConfig, + ) => AsyncIterable; + /** + * Projects runner default options onto the subset safe to inherit for a + * structured-output run, dropping agent-execution policy fields (e.g. + * `tools`). Omitted means no defaults are inherited for structured runs. + * + * @param defaults - The runner-level default options. + * @returns The subset of defaults safe to merge under structured overrides. + */ + getStructuredDefaults?: (defaults: Partial) => Partial; + /** Extracts provider-specific run metadata without exposing option keys to the runner. */ + getRunMetadata?: (options: Partial) => ProviderRunMetadata; /** * Optional capability descriptor advertised by the adapter. Used by the * runner to validate that requested features (e.g. sandboxes) are @@ -344,13 +398,16 @@ export type TelemetryLifecycle = { /** * Top-level configuration for creating an agent runner. */ -export type AgentRunnerConfig = { +export type AgentRunnerConfig< + TOptions extends object = ClaudeQueryOptions, + TPrompt = ClaudeQueryInput['prompt'], +> = { /** Default query options applied to every run. */ - defaultOptions?: Partial; + defaultOptions?: Partial>; /** Telemetry configuration for Langfuse/OTel integration. */ telemetry?: TelemetryConfig; /** Provider adapter override; defaults to the Claude adapter. */ - adapter?: ProviderAdapter; + adapter?: ProviderAdapter; /** * Default sandbox configuration applied to every run. Set to `false` * to explicitly disable sandboxing at the runner level; individual runs @@ -382,13 +439,18 @@ export type AgentRunTelemetryAttributes = { /** * Options for a single agent run invocation. */ -export type AgentRunOptions = { +export type AgentRunOptions< + TOptions extends object = ClaudeQueryOptions, + TPrompt = ClaudeQueryInput['prompt'], +> = { /** The prompt to send to the agent. */ - prompt: ClaudeQueryInput['prompt']; + prompt: NoInfer; /** Per-run query options merged over runner defaults. */ - options?: Partial; + options?: Partial>; /** Callback invoked for each streamed message. */ onMessage?: RunnerMessageHandler; + /** Cancels active work. Supported by the built-in pi adapter. */ + signal?: AbortSignal; /** Per-run Langfuse trace attributes. */ telemetry?: AgentRunTelemetryAttributes; /** @@ -436,16 +498,21 @@ export type AgentRunResult = { * The main agent runner interface combining telemetry lifecycle * with agent execution, judging, and score posting. */ -export type AgentRunner = TelemetryLifecycle & { +export type AgentRunner< + TOptions extends object = ClaudeQueryOptions, + TPrompt = ClaudeQueryInput['prompt'], +> = TelemetryLifecycle & { /** Executes a single agent run with the given options. */ - runAgent: (options: AgentRunOptions) => Promise; + runAgent: ( + options: AgentRunOptions, + ) => Promise; /** * Runs an LLM-as-a-judge evaluation on a completed agent run. * Scores are only posted to Langfuse when `options.postScores` is `true`. */ judge: ( runResult: AgentRunResult, - judgeConfig: JudgeConfig, + judgeConfig: JudgeConfig>, context?: JudgeContext, options?: JudgeOptions, ) => Promise; diff --git a/vitest.config.mts b/vitest.config.mts index e08e6a6..9c0b93c 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -5,7 +5,9 @@ export default defineConfig({ watch: false, include: ['src/**/*.test.ts'], coverage: { - enabled: true, + // Pi's SDK integration tests require Node >=22.19. Measure the full + // coverage contract on supported pi runtimes; still run Claude tests on 20. + enabled: Number(process.versions.node.split('.')[0]) >= 22, provider: 'istanbul', include: [ 'src/**/*.ts', @@ -16,7 +18,8 @@ export default defineConfig({ ], exclude: ['src/**/*.test-d.ts'], thresholds: { - autoUpdate: true, + // Keep test runs from modifying the working tree when coverage improves. + autoUpdate: false, branches: 95.3, functions: 98.31, lines: 98.18, diff --git a/yarn.lock b/yarn.lock index 4dc76af..287648e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -105,6 +105,22 @@ __metadata: languageName: node linkType: hard +"@anthropic-ai/sdk@npm:0.91.1": + version: 0.91.1 + resolution: "@anthropic-ai/sdk@npm:0.91.1" + dependencies: + json-schema-to-ts: "npm:^3.1.1" + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bin: + anthropic-ai-sdk: bin/cli + checksum: 10/3ac357233f237e038af32a0a2d820e3531e35dc2e3b39864639fc42570bad6d1036630b49b6a75e9961ef31a9bcd338a3f262fbde60e4d13014683c40265756a + languageName: node + linkType: hard + "@arethetypeswrong/cli@npm:^0.15.3": version: 0.15.4 resolution: "@arethetypeswrong/cli@npm:0.15.4" @@ -136,6 +152,345 @@ __metadata: languageName: node linkType: hard +"@aws-crypto/sha256-browser@npm:5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-browser@npm:5.2.0" + dependencies: + "@aws-crypto/sha256-js": "npm:^5.2.0" + "@aws-crypto/supports-web-crypto": "npm:^5.2.0" + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + "@aws-sdk/util-locate-window": "npm:^3.0.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10/2b1b701ca6caa876333b4eb2b96e5187d71ebb51ebf8e2d632690dbcdedeff038202d23adcc97e023437ed42bb1963b7b463e343687edf0635fd4b98b2edad1a + languageName: node + linkType: hard + +"@aws-crypto/sha256-js@npm:5.2.0, @aws-crypto/sha256-js@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/sha256-js@npm:5.2.0" + dependencies: + "@aws-crypto/util": "npm:^5.2.0" + "@aws-sdk/types": "npm:^3.222.0" + tslib: "npm:^2.6.2" + checksum: 10/f46aace7b873c615be4e787ab0efd0148ef7de48f9f12c7d043e05c52e52b75bb0bf6dbcb9b2852d940d7724fab7b6d5ff1469160a3dd024efe7a68b5f70df8c + languageName: node + linkType: hard + +"@aws-crypto/supports-web-crypto@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/supports-web-crypto@npm:5.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/6ed0c7e17f4f6663d057630805c45edb35d5693380c24ab52d4c453ece303c6c8a6ade9ee93c97dda77d9f6cae376ffbb44467057161c513dffa3422250edaf5 + languageName: node + linkType: hard + +"@aws-crypto/util@npm:^5.2.0": + version: 5.2.0 + resolution: "@aws-crypto/util@npm:5.2.0" + dependencies: + "@aws-sdk/types": "npm:^3.222.0" + "@smithy/util-utf8": "npm:^2.0.0" + tslib: "npm:^2.6.2" + checksum: 10/f80a174c404e1ad4364741c942f440e75f834c08278fa754349fe23a6edc679d480ea9ced5820774aee58091ed270067022d8059ecf1a7ef452d58134ac7e9e1 + languageName: node + linkType: hard + +"@aws-sdk/client-bedrock-runtime@npm:3.1048.0": + version: 3.1048.0 + resolution: "@aws-sdk/client-bedrock-runtime@npm:3.1048.0" + dependencies: + "@aws-crypto/sha256-browser": "npm:5.2.0" + "@aws-crypto/sha256-js": "npm:5.2.0" + "@aws-sdk/core": "npm:^3.974.11" + "@aws-sdk/credential-provider-node": "npm:^3.972.42" + "@aws-sdk/eventstream-handler-node": "npm:^3.972.16" + "@aws-sdk/middleware-eventstream": "npm:^3.972.12" + "@aws-sdk/middleware-websocket": "npm:^3.972.19" + "@aws-sdk/token-providers": "npm:3.1048.0" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/core": "npm:^3.24.2" + "@smithy/fetch-http-handler": "npm:^5.4.2" + "@smithy/node-http-handler": "npm:^4.7.2" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/3ff5c594b58770138847146e0b1eb523b6a14db99b9872da8c1c48ae3ca57e3494755ac053f3a9942c8d66bd7fa22099bd6fc7b3561ce691e034a8ac2cd6d314 + languageName: node + linkType: hard + +"@aws-sdk/core@npm:^3.974.11, @aws-sdk/core@npm:^3.977.9": + version: 3.977.9 + resolution: "@aws-sdk/core@npm:3.977.9" + dependencies: + "@aws-sdk/types": "npm:^3.974.5" + "@aws-sdk/xml-builder": "npm:^3.972.40" + "@aws/lambda-invoke-store": "npm:^0.3.0" + "@smithy/core": "npm:^3.33.3" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.17.2" + bowser: "npm:^2.11.0" + tslib: "npm:^2.6.2" + checksum: 10/5dbc5f744354a48caaedee348a57a8dca7451f812bfc6727391b9e1ea9da158b4d5911266f30ebb6d9f05ffbae1516a94099d51805edf5c589cf0d27d9096838 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-env@npm:^3.972.70": + version: 3.972.70 + resolution: "@aws-sdk/credential-provider-env@npm:3.972.70" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/092dacb39ecddf65f27f726b507f58a4b017acc5207d0edf852b48a40dc67dfd64a312bc56c010b9da97df491adf0356ff10ac21fac57f76d6255479febd3259 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-http@npm:^3.972.72": + version: 3.972.72 + resolution: "@aws-sdk/credential-provider-http@npm:3.972.72" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/node-http-handler": "npm:^4.11.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/c98436c86c23924a4f6027f5982ebd338b218afb0387261a65cd6633526c8ea04996831881fdad1d207123beb64d94485716fea71691907896ae49fa288877f4 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-ini@npm:^3.973.15": + version: 3.973.15 + resolution: "@aws-sdk/credential-provider-ini@npm:3.973.15" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/credential-provider-env": "npm:^3.972.70" + "@aws-sdk/credential-provider-http": "npm:^3.972.72" + "@aws-sdk/credential-provider-login": "npm:^3.972.77" + "@aws-sdk/credential-provider-process": "npm:^3.972.70" + "@aws-sdk/credential-provider-sso": "npm:^3.973.14" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.76" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/f6617aaeaae6afed7fe57f3db473ae4f8bda4cc0eadea12610a14e2efcb24e4ee21887c7742d7d43229e685417640e686e2b21e54fcb60aece6bad588d28cab8 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-login@npm:^3.972.77": + version: 3.972.77 + resolution: "@aws-sdk/credential-provider-login@npm:3.972.77" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/3b0f6cc3f591ad8084c72620279c603c6e925a0c3835b268a46e746f6b9c8b9672ad5d18384eb35741d76d972dfafebb8d451b14b1e31b9fe17247bf2153b95a + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-node@npm:^3.972.42": + version: 3.972.82 + resolution: "@aws-sdk/credential-provider-node@npm:3.972.82" + dependencies: + "@aws-sdk/credential-provider-env": "npm:^3.972.70" + "@aws-sdk/credential-provider-http": "npm:^3.972.72" + "@aws-sdk/credential-provider-ini": "npm:^3.973.15" + "@aws-sdk/credential-provider-process": "npm:^3.972.70" + "@aws-sdk/credential-provider-sso": "npm:^3.973.14" + "@aws-sdk/credential-provider-web-identity": "npm:^3.972.76" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/credential-provider-imds": "npm:^4.4.16" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/14075a6bed1a3b0a05b9764b4024e485ac918378e470e7ea9ce12303f3fd5b60142c923d2b0b6ca247c66f63e28e217216734ceb5fe120bf91ae144253030b78 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-process@npm:^3.972.70": + version: 3.972.70 + resolution: "@aws-sdk/credential-provider-process@npm:3.972.70" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/5b193841c272c73c9f4f505d7fd7609faf4adb7579d35841e03681def9900ef7eacef7d9fc546b70147866338eb90440a519b444b70179f26cf1fd122f28df29 + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-sso@npm:^3.973.14": + version: 3.973.14 + resolution: "@aws-sdk/credential-provider-sso@npm:3.973.14" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/token-providers": "npm:3.1116.0" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/03de14a7405810be412ec81762269547cab9583bf6332a61f3421be065aa988614295a1aafcb28132928d50b6fbbd824e65bd96eb8ef798a97e550c33ea846dd + languageName: node + linkType: hard + +"@aws-sdk/credential-provider-web-identity@npm:^3.972.76": + version: 3.972.76 + resolution: "@aws-sdk/credential-provider-web-identity@npm:3.972.76" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/de65c95191e0e0e3e26828f52a9f81371b7f73266f712b184365e68410847dda9578b211bba9c427d7a6c3e61e8029c8ba9b8412be52f2e5dbac99edffda81c1 + languageName: node + linkType: hard + +"@aws-sdk/eventstream-handler-node@npm:^3.972.16": + version: 3.972.34 + resolution: "@aws-sdk/eventstream-handler-node@npm:3.972.34" + dependencies: + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/62ad811b2b992085acacdf3ae786908a24cf807c171ae7395f5b4b8b82728c7436438b586bf8dc934784e48cc053f2870122832a546e67ce65e2af159f99597e + languageName: node + linkType: hard + +"@aws-sdk/middleware-eventstream@npm:^3.972.12": + version: 3.972.29 + resolution: "@aws-sdk/middleware-eventstream@npm:3.972.29" + dependencies: + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/d4a38fff63f11c7f685293a558793a82a5dbc93b31165a8495408006942f20f4f717ec87b3c4229e7410418c4ba9e5b5f2b4614dc9f279bbd9a957510714008b + languageName: node + linkType: hard + +"@aws-sdk/middleware-websocket@npm:^3.972.19": + version: 3.972.52 + resolution: "@aws-sdk/middleware-websocket@npm:3.972.52" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/4f82dbe930357dea9fe3d1079efe20e330159ed96091e2e377cfba2b92b4ede17ca885ad6f4b91aa260720cc26177465e4ce28b167a707fa1777bd076dfd624a + languageName: node + linkType: hard + +"@aws-sdk/nested-clients@npm:^3.997.44, @aws-sdk/nested-clients@npm:^3.997.9": + version: 3.997.44 + resolution: "@aws-sdk/nested-clients@npm:3.997.44" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/signature-v4-multi-region": "npm:^3.996.46" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/fetch-http-handler": "npm:^5.7.2" + "@smithy/node-http-handler": "npm:^4.11.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/33a488102d9d7038cd4b600417a94e6828322e7b90ade93fe9425c66a77ceea67412a72e49b0e71e7ed6716147de53a327c630602aa62f0928519738015fa80a + languageName: node + linkType: hard + +"@aws-sdk/signature-v4-multi-region@npm:^3.996.46": + version: 3.996.46 + resolution: "@aws-sdk/signature-v4-multi-region@npm:3.996.46" + dependencies: + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/signature-v4": "npm:^5.6.12" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/2f32c081210ba41f24f0adee3fbcdf8486d91b12c023cb6414e77dadd66737e472016c142154696fa97afd1bcdc3b1a0d3c3f9695ee2b0369ec1867d110fb65f + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.1048.0": + version: 3.1048.0 + resolution: "@aws-sdk/token-providers@npm:3.1048.0" + dependencies: + "@aws-sdk/core": "npm:^3.974.11" + "@aws-sdk/nested-clients": "npm:^3.997.9" + "@aws-sdk/types": "npm:^3.973.8" + "@smithy/core": "npm:^3.24.2" + "@smithy/types": "npm:^4.14.1" + tslib: "npm:^2.6.2" + checksum: 10/088e81b12eb5d5db42a9f45159e943541e20020fad3bc8fdf53a390de686737edd32a6a6f78eea51cc5d25b680ac7df0e641c95b2b25eac65b3e4575a6a4eb86 + languageName: node + linkType: hard + +"@aws-sdk/token-providers@npm:3.1116.0": + version: 3.1116.0 + resolution: "@aws-sdk/token-providers@npm:3.1116.0" + dependencies: + "@aws-sdk/core": "npm:^3.977.9" + "@aws-sdk/nested-clients": "npm:^3.997.44" + "@aws-sdk/types": "npm:^3.974.5" + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/187a26cb63bde1cb69ac8f475066f2b6bcfa1c0574e5dd4470f714939f07b878f9b849443f253012117514bf1a86c3ef41795d70fb05e7c7d89a449584bdcde0 + languageName: node + linkType: hard + +"@aws-sdk/types@npm:^3.222.0, @aws-sdk/types@npm:^3.973.8, @aws-sdk/types@npm:^3.974.5": + version: 3.974.5 + resolution: "@aws-sdk/types@npm:3.974.5" + dependencies: + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/df23a788b562adc18c5833f07e5591a57428b74893e0603cba5866de9a4f7c3f3878a678a2509267a71be65265f9f5983276ab1c182bfbb98ba586c005d50fb4 + languageName: node + linkType: hard + +"@aws-sdk/util-locate-window@npm:^3.0.0": + version: 3.965.10 + resolution: "@aws-sdk/util-locate-window@npm:3.965.10" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/b55b43b4192fb1764db498a32186c64293ff85677c76847f758f8c4cfb9a5d4790eeba56da47beff85dac8c3edd45b3a49eeb497b48ac516485c20e975d95191 + languageName: node + linkType: hard + +"@aws-sdk/xml-builder@npm:^3.972.40": + version: 3.972.40 + resolution: "@aws-sdk/xml-builder@npm:3.972.40" + dependencies: + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/d23f0c0bc289045960982181d6979d0535fca6516a57e79ad3845bd6a5166d38a3ca4e755fc77c9fc674c55ec8f68eac2d4ccbda289d846a3734c572a08d6dc3 + languageName: node + linkType: hard + +"@aws/lambda-invoke-store@npm:^0.3.0": + version: 0.3.0 + resolution: "@aws/lambda-invoke-store@npm:0.3.0" + checksum: 10/4a6c7af16477dd330d4dcd2269f89370be68295b54ae1e90ef8c2175efa4df6735f9a3f914ab7efa21ba7db5477031fe8488853adcd268eeb6cb8e34ad06b206 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" @@ -275,6 +630,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.18.3": + version: 7.29.7 + resolution: "@babel/runtime@npm:7.29.7" + checksum: 10/9883b4951787779fd382b121f22f92966d85f19434841f65fb00b2dfec232107e139683f47c6f252891826ad8ee18317b46c3a0e4819116a9885f47b46d7126a + languageName: node + linkType: hard + "@babel/template@npm:^7.28.6": version: 7.28.6 resolution: "@babel/template@npm:7.28.6" @@ -327,6 +689,82 @@ __metadata: languageName: node linkType: hard +"@earendil-works/pi-agent-core@npm:^0.83.0": + version: 0.83.0 + resolution: "@earendil-works/pi-agent-core@npm:0.83.0" + dependencies: + "@earendil-works/pi-ai": "npm:^0.83.0" + diff: "npm:8.0.4" + ignore: "npm:7.0.5" + typebox: "npm:1.3.7" + yaml: "npm:2.9.0" + checksum: 10/f2ea141fcfe8bd63ac6e6d1f2bebc99ea3ec89f79a2586f754c0bde8ca112da6ac32f200f69e14148037d6c59d5efd888b0297bf9a0419b3c74ca2f4670a7e32 + languageName: node + linkType: hard + +"@earendil-works/pi-ai@npm:^0.83.0": + version: 0.83.0 + resolution: "@earendil-works/pi-ai@npm:0.83.0" + dependencies: + "@anthropic-ai/sdk": "npm:0.91.1" + "@aws-sdk/client-bedrock-runtime": "npm:3.1048.0" + "@google/genai": "npm:1.52.0" + "@mistralai/mistralai": "npm:2.2.6" + "@opentelemetry/api": "npm:1.9.0" + "@smithy/node-http-handler": "npm:4.7.3" + http-proxy-agent: "npm:7.0.2" + https-proxy-agent: "npm:7.0.6" + openai: "npm:6.26.0" + partial-json: "npm:0.1.7" + typebox: "npm:1.3.7" + bin: + pi-ai: dist/cli.js + checksum: 10/c3e92248c55a29331c2d042596b14ba12f7180ba48f8ea200887940a81bc938c2304081578a58ad3fc308eb188a8d457ebc156558c41a57b7bd5eb57501498d8 + languageName: node + linkType: hard + +"@earendil-works/pi-coding-agent@npm:0.83.0": + version: 0.83.0 + resolution: "@earendil-works/pi-coding-agent@npm:0.83.0" + dependencies: + "@earendil-works/pi-agent-core": "npm:^0.83.0" + "@earendil-works/pi-ai": "npm:^0.83.0" + "@earendil-works/pi-tui": "npm:^0.83.0" + "@mariozechner/clipboard": "npm:0.3.9" + "@silvia-odwyer/photon-node": "npm:0.3.4" + chalk: "npm:5.6.2" + cross-spawn: "npm:7.0.6" + diff: "npm:8.0.4" + glob: "npm:13.0.6" + highlight.js: "npm:10.7.3" + hosted-git-info: "npm:9.0.3" + ignore: "npm:7.0.5" + jiti: "npm:2.7.0" + minimatch: "npm:10.2.5" + proper-lockfile: "npm:4.1.2" + semver: "npm:7.8.0" + typebox: "npm:1.3.7" + undici: "npm:8.5.0" + yaml: "npm:2.9.0" + dependenciesMeta: + "@mariozechner/clipboard": + optional: true + bin: + pi: dist/cli.js + checksum: 10/147e4153527e91067eb4c83572a7bd1bc0869a2ff001e3dcd422ee2f784861e83614f21ab0d9810f3be180d240ca17b778d811652f4ea3ad1f739aeb14385ca1 + languageName: node + linkType: hard + +"@earendil-works/pi-tui@npm:^0.83.0": + version: 0.83.0 + resolution: "@earendil-works/pi-tui@npm:0.83.0" + dependencies: + get-east-asian-width: "npm:1.6.0" + marked: "npm:18.0.5" + checksum: 10/c3f62b696b513decf2e4d28a8ebfcc00a3ba5b4cf530ff29937df47bcaeff9eb98927a9388b8f573537d10150b3d70ec53044ba6dff2846860d9a2b554dd78ef + languageName: node + linkType: hard + "@emnapi/core@npm:1.10.0, @emnapi/core@npm:^1.4.3": version: 1.10.0 resolution: "@emnapi/core@npm:1.10.0" @@ -463,6 +901,23 @@ __metadata: languageName: node linkType: hard +"@google/genai@npm:1.52.0": + version: 1.52.0 + resolution: "@google/genai@npm:1.52.0" + dependencies: + google-auth-library: "npm:^10.3.0" + p-retry: "npm:^4.6.2" + protobufjs: "npm:^7.5.4" + ws: "npm:^8.18.0" + peerDependencies: + "@modelcontextprotocol/sdk": ^1.25.2 + peerDependenciesMeta: + "@modelcontextprotocol/sdk": + optional: true + checksum: 10/4815bb7198910e20bc1976cb9c649d31c0d90b9352c5a433fc60a8906c74cd4022768daf75a7bdf86daa73beb9d0a6e426efbc2e5d6ed02df5c21ea63d335eae + languageName: node + linkType: hard + "@grpc/grpc-js@npm:^1.14.3": version: 1.14.3 resolution: "@grpc/grpc-js@npm:1.14.3" @@ -686,12 +1141,122 @@ __metadata: languageName: node linkType: hard +"@mariozechner/clipboard-darwin-arm64@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-darwin-arm64@npm:0.3.9" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@mariozechner/clipboard-darwin-universal@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-darwin-universal@npm:0.3.9" + conditions: os=darwin + languageName: node + linkType: hard + +"@mariozechner/clipboard-darwin-x64@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-darwin-x64@npm:0.3.9" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@mariozechner/clipboard-linux-arm64-gnu@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-linux-arm64-gnu@npm:0.3.9" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@mariozechner/clipboard-linux-arm64-musl@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-linux-arm64-musl@npm:0.3.9" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@mariozechner/clipboard-linux-riscv64-gnu@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-linux-riscv64-gnu@npm:0.3.9" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@mariozechner/clipboard-linux-x64-gnu@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-linux-x64-gnu@npm:0.3.9" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@mariozechner/clipboard-linux-x64-musl@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-linux-x64-musl@npm:0.3.9" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@mariozechner/clipboard-win32-arm64-msvc@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-win32-arm64-msvc@npm:0.3.9" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@mariozechner/clipboard-win32-x64-msvc@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard-win32-x64-msvc@npm:0.3.9" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@mariozechner/clipboard@npm:0.3.9": + version: 0.3.9 + resolution: "@mariozechner/clipboard@npm:0.3.9" + dependencies: + "@mariozechner/clipboard-darwin-arm64": "npm:0.3.9" + "@mariozechner/clipboard-darwin-universal": "npm:0.3.9" + "@mariozechner/clipboard-darwin-x64": "npm:0.3.9" + "@mariozechner/clipboard-linux-arm64-gnu": "npm:0.3.9" + "@mariozechner/clipboard-linux-arm64-musl": "npm:0.3.9" + "@mariozechner/clipboard-linux-riscv64-gnu": "npm:0.3.9" + "@mariozechner/clipboard-linux-x64-gnu": "npm:0.3.9" + "@mariozechner/clipboard-linux-x64-musl": "npm:0.3.9" + "@mariozechner/clipboard-win32-arm64-msvc": "npm:0.3.9" + "@mariozechner/clipboard-win32-x64-msvc": "npm:0.3.9" + dependenciesMeta: + "@mariozechner/clipboard-darwin-arm64": + optional: true + "@mariozechner/clipboard-darwin-universal": + optional: true + "@mariozechner/clipboard-darwin-x64": + optional: true + "@mariozechner/clipboard-linux-arm64-gnu": + optional: true + "@mariozechner/clipboard-linux-arm64-musl": + optional: true + "@mariozechner/clipboard-linux-riscv64-gnu": + optional: true + "@mariozechner/clipboard-linux-x64-gnu": + optional: true + "@mariozechner/clipboard-linux-x64-musl": + optional: true + "@mariozechner/clipboard-win32-arm64-msvc": + optional: true + "@mariozechner/clipboard-win32-x64-msvc": + optional: true + checksum: 10/9a40e81126654c86e2a084bee33f8e0d8d7c6edf598d010272b8688cb27e1f2b7292da507bcc8121db5ef10d855ada8f545635953e9e33a4c54ce6ed964e6edf + languageName: node + linkType: hard + "@metamask/agent-runner@workspace:.": version: 0.0.0-use.local resolution: "@metamask/agent-runner@workspace:." dependencies: "@anthropic-ai/claude-agent-sdk": "npm:^0.3.220" "@arethetypeswrong/cli": "npm:^0.15.3" + "@earendil-works/pi-coding-agent": "npm:0.83.0" "@langfuse/client": "npm:^5.3.0" "@langfuse/otel": "npm:^5.3.0" "@langfuse/tracing": "npm:^5.3.0" @@ -726,6 +1291,11 @@ __metadata: typescript-eslint: "npm:^8.48.1" vite: "npm:^8.0.8" vitest: "npm:^4.1.4" + peerDependencies: + "@earendil-works/pi-coding-agent": 0.83.0 + peerDependenciesMeta: + "@earendil-works/pi-coding-agent": + optional: true languageName: unknown linkType: soft @@ -815,6 +1385,23 @@ __metadata: languageName: node linkType: hard +"@mistralai/mistralai@npm:2.2.6": + version: 2.2.6 + resolution: "@mistralai/mistralai@npm:2.2.6" + dependencies: + "@opentelemetry/semantic-conventions": "npm:^1.40.0" + ws: "npm:^8.18.0" + zod: "npm:^3.25.0 || ^4.0.0" + zod-to-json-schema: "npm:^3.25.0" + peerDependencies: + "@opentelemetry/api": ^1.9.0 + peerDependenciesMeta: + "@opentelemetry/api": + optional: true + checksum: 10/ac0de2684da09f7830c0fd738e8b62c60e71e454056c204eb7dc6dbbb155ed1cbd53ab6a56e469b41d652fb040306ac2e33a3d462440d3a4389949d88757aa04 + languageName: node + linkType: hard + "@napi-rs/wasm-runtime@npm:^0.2.11": version: 0.2.12 resolution: "@napi-rs/wasm-runtime@npm:0.2.12" @@ -1040,6 +1627,13 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/api@npm:1.9.0": + version: 1.9.0 + resolution: "@opentelemetry/api@npm:1.9.0" + checksum: 10/a607f0eef971893c4f2ee2a4c2069aade6ec3e84e2a1f5c2aac19f65c5d9eeea41aa72db917c1029faafdd71789a1a040bdc18f40d63690e22ccae5d7070f194 + languageName: node + linkType: hard + "@opentelemetry/api@npm:^1.3.0, @opentelemetry/api@npm:^1.9.1": version: 1.9.1 resolution: "@opentelemetry/api@npm:1.9.1" @@ -1428,10 +2022,10 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:^1.29.0": - version: 1.40.0 - resolution: "@opentelemetry/semantic-conventions@npm:1.40.0" - checksum: 10/edb58894590e42e631006a9f5741955fad248e3589aa334a5e59080c535ead44ee9f376c444ef2be094d1e6c1a2e596538c1df0a31a04508551e91b1a5d5c93c +"@opentelemetry/semantic-conventions@npm:^1.29.0, @opentelemetry/semantic-conventions@npm:^1.40.0": + version: 1.43.0 + resolution: "@opentelemetry/semantic-conventions@npm:1.43.0" + checksum: 10/44c0e667cbfbecd98911cd6a90dc5f873de262da82d6684f467a87d529098b04fb2b9705bbd3524ecdedf13c62914d9ccf0ddcc4104dbba5062faf68f361aef4 languageName: node linkType: hard @@ -1610,20 +2204,19 @@ __metadata: languageName: node linkType: hard -"@protobufjs/eventemitter@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/eventemitter@npm:1.1.0" - checksum: 10/03af3e99f17ad421283d054c88a06a30a615922a817741b43ca1b13e7c6b37820a37f6eba9980fb5150c54dba6e26cb6f7b64a6f7d8afa83596fafb3afa218c3 +"@protobufjs/eventemitter@npm:^1.1.0, @protobufjs/eventemitter@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/eventemitter@npm:1.1.1" + checksum: 10/a54dc1aff4475ffad4fdf3235c71a553f5e40e3b4cf6a2e217151895a61cb4eb0be20d63791db22441ca25e594671f1021977133f9939540750231ff7d8e9dd6 languageName: node linkType: hard -"@protobufjs/fetch@npm:^1.1.0": - version: 1.1.0 - resolution: "@protobufjs/fetch@npm:1.1.0" +"@protobufjs/fetch@npm:^1.1.0, @protobufjs/fetch@npm:^1.1.1": + version: 1.1.1 + resolution: "@protobufjs/fetch@npm:1.1.1" dependencies: "@protobufjs/aspromise": "npm:^1.1.1" - "@protobufjs/inquire": "npm:^1.1.0" - checksum: 10/67ae40572ad536e4ef94269199f252c024b66e3059850906bdaee161ca1d75c73d04d35cd56f147a8a5a079f5808e342b99e61942c1dae15604ff0600b09a958 + checksum: 10/427cf2da8c69b494b0df3b2fb1f43c97f0f71ca2c8ef8232dac7e44f2527ad0cc9cecb243eda14a918e86018bfa6d54d92252240d2b37ed205b13adb5506fa1d languageName: node linkType: hard @@ -1634,7 +2227,7 @@ __metadata: languageName: node linkType: hard -"@protobufjs/inquire@npm:^1.1.0, @protobufjs/inquire@npm:^1.1.1": +"@protobufjs/inquire@npm:^1.1.0": version: 1.1.1 resolution: "@protobufjs/inquire@npm:1.1.1" checksum: 10/504740e8ac348f70b33bcf6a20c83d5b9679901654c1a96b18c0491ec2f2f7ac580e74019b6d1bce16113bfb9746bc6e7dfd4e12a717deed699675b7f230ce9e @@ -1848,6 +2441,13 @@ __metadata: languageName: node linkType: hard +"@silvia-odwyer/photon-node@npm:0.3.4": + version: 0.3.4 + resolution: "@silvia-odwyer/photon-node@npm:0.3.4" + checksum: 10/9353e3b2ed8e89f3410bdfe390c12e269867440ceb9a6f5c8c765732efd86bf299fbb7fae68c8959241878705b0f8516016c98cd83e69770bfc05f531dc6edb7 + languageName: node + linkType: hard + "@sindresorhus/is@npm:^4.6.0": version: 4.6.0 resolution: "@sindresorhus/is@npm:4.6.0" @@ -1855,6 +2455,109 @@ __metadata: languageName: node linkType: hard +"@smithy/core@npm:^3.24.2, @smithy/core@npm:^3.24.3, @smithy/core@npm:^3.33.2, @smithy/core@npm:^3.33.3": + version: 3.33.3 + resolution: "@smithy/core@npm:3.33.3" + dependencies: + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/76d67070bada0a32bfe44f2c07e0c871675990d3ff7f7574c749df036ae3b4bc63a374513637b0b16fc153dbf01d66af565b875dced77cfbd45e975a4f4d0059 + languageName: node + linkType: hard + +"@smithy/credential-provider-imds@npm:^4.4.16": + version: 4.5.2 + resolution: "@smithy/credential-provider-imds@npm:4.5.2" + dependencies: + "@smithy/core": "npm:^3.33.2" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/5db260066aa97e2a7eca5258ff1c1dd1234cd2ed28e3b7ec36cbcf578007878853fa787cb48df2fd070fb0440a8c537d628344b1fd4ec589aaa2fa154d150b3e + languageName: node + linkType: hard + +"@smithy/fetch-http-handler@npm:^5.4.2, @smithy/fetch-http-handler@npm:^5.7.2": + version: 5.8.0 + resolution: "@smithy/fetch-http-handler@npm:5.8.0" + dependencies: + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.18.0" + tslib: "npm:^2.6.2" + checksum: 10/9f5374a6d2db6a1314393a05f9500efc0b73a0ef9d491a9e64aab6fdc8e0ce39214a5ec0b6af3bf5c948e452c7f91f91b0cda13d3325399276811392b3ac36b7 + languageName: node + linkType: hard + +"@smithy/is-array-buffer@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/is-array-buffer@npm:2.2.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/d366743ecc7a9fc3bad21dbb3950d213c12bdd4aeb62b1265bf6cbe38309df547664ef3e51ab732e704485194f15e89d361943b0bfbe3fe1a4b3178b942913cc + languageName: node + linkType: hard + +"@smithy/node-http-handler@npm:4.7.3": + version: 4.7.3 + resolution: "@smithy/node-http-handler@npm:4.7.3" + dependencies: + "@smithy/core": "npm:^3.24.3" + "@smithy/types": "npm:^4.14.2" + tslib: "npm:^2.6.2" + checksum: 10/2050c04aefbc90095bd111ce12a77572da1391d6e10e9feca010c5f151e652152dce25e330b047380d539643ba823eb0c504659b1f152282ca42f4d9dfa220c4 + languageName: node + linkType: hard + +"@smithy/node-http-handler@npm:^4.11.3, @smithy/node-http-handler@npm:^4.7.2": + version: 4.12.1 + resolution: "@smithy/node-http-handler@npm:4.12.1" + dependencies: + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.18.0" + tslib: "npm:^2.6.2" + checksum: 10/291ab9053afeefc7f6524c18dd5ade4aab42af7fddba7d5c087c23c23d4d686fc9f6674c5c28aa1af708d93db9fc2be18999cd64bbbbed8b88f2004a0aa37bbf + languageName: node + linkType: hard + +"@smithy/signature-v4@npm:^5.6.12": + version: 5.7.3 + resolution: "@smithy/signature-v4@npm:5.7.3" + dependencies: + "@smithy/core": "npm:^3.33.3" + "@smithy/types": "npm:^4.17.2" + tslib: "npm:^2.6.2" + checksum: 10/8f18091aedd508ac746570b1eb811fd30147112dc359909d8400d155f759bdda16a8f1b03ddfdb641190e18f8fac8915be151b2d68e200c58b1c6821800502f8 + languageName: node + linkType: hard + +"@smithy/types@npm:^4.14.1, @smithy/types@npm:^4.14.2, @smithy/types@npm:^4.17.2, @smithy/types@npm:^4.18.0": + version: 4.18.0 + resolution: "@smithy/types@npm:4.18.0" + dependencies: + tslib: "npm:^2.6.2" + checksum: 10/18719c5ae8eef71ef10d79e8628b528a9f0aede5fb036c9a798f676b7b4a35efd10fc0d3b7e1f8921746c66c4ae2a8277570f99a0545fbc4eae6c994997f83cd + languageName: node + linkType: hard + +"@smithy/util-buffer-from@npm:^2.2.0": + version: 2.2.0 + resolution: "@smithy/util-buffer-from@npm:2.2.0" + dependencies: + "@smithy/is-array-buffer": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10/53253e4e351df3c4b7907dca48a0a6ceae783e98a8e73526820b122b3047a53fd127c19f4d8301f68d852011d821da519da783de57e0b22eed57c4df5b90d089 + languageName: node + linkType: hard + +"@smithy/util-utf8@npm:^2.0.0": + version: 2.3.0 + resolution: "@smithy/util-utf8@npm:2.3.0" + dependencies: + "@smithy/util-buffer-from": "npm:^2.2.0" + tslib: "npm:^2.6.2" + checksum: 10/c766ead8dac6bc6169f4cac1cc47ef7bd86928d06255148f9528228002f669c8cc49f78dc2b9ba5d7e214d40315024a9e32c5c9130b33e20f0fe4532acd0dff5 + languageName: node + linkType: hard + "@standard-schema/spec@npm:^1.1.0": version: 1.1.0 resolution: "@standard-schema/spec@npm:1.1.0" @@ -2004,6 +2707,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.0": + version: 0.12.0 + resolution: "@types/retry@npm:0.12.0" + checksum: 10/bbd0b88f4b3eba7b7acfc55ed09c65ef6f2e1bcb4ec9b4dca82c66566934351534317d294a770a7cc6c0468d5573c5350abab6e37c65f8ef254443e1b028e44d + languageName: node + linkType: hard + "@types/unist@npm:*, @types/unist@npm:^3.0.0": version: 3.0.3 resolution: "@types/unist@npm:3.0.3" @@ -2512,6 +3222,13 @@ __metadata: languageName: node linkType: hard +"agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 10/79bef167247789f955aaba113bae74bf64aa1e1acca4b1d6bb444bdf91d82c3e07e9451ef6a6e2e35e8f71a6f97ce33e3d855a5328eb9fad1bc3cc4cfd031ed8 + languageName: node + linkType: hard + "ajv@npm:^6.14.0": version: 6.15.0 resolution: "ajv@npm:6.15.0" @@ -2635,6 +3352,13 @@ __metadata: languageName: node linkType: hard +"base64-js@npm:^1.3.0": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10/669632eb3745404c2f822a18fc3a0122d2f9a7a13f7fb8b5823ee19d1d2ff9ee5b52c53367176ea4ad093c332fd5ab4bd0ebae5a8e27917a4105a4cfc86b1005 + languageName: node + linkType: hard + "baseline-browser-mapping@npm:^2.10.12": version: 2.10.28 resolution: "baseline-browser-mapping@npm:2.10.28" @@ -2651,6 +3375,13 @@ __metadata: languageName: node linkType: hard +"bignumber.js@npm:^9.0.0": + version: 9.3.1 + resolution: "bignumber.js@npm:9.3.1" + checksum: 10/1be0372bf0d6d29d0a49b9e6a9cefbd54dad9918232ad21fcd4ec39030260773abf0c76af960c6b3b98d3115a3a71e61c6a111812d1395040a039cfa178e0245 + languageName: node + linkType: hard + "bin-links@npm:4.0.4": version: 4.0.4 resolution: "bin-links@npm:4.0.4" @@ -2663,6 +3394,13 @@ __metadata: languageName: node linkType: hard +"bowser@npm:^2.11.0": + version: 2.14.1 + resolution: "bowser@npm:2.14.1" + checksum: 10/a002f0795ef360314c75552b94daa42f74473f38b34255cfa959779e875806ef8e41b24ec63a533717798c8ef70bb991aef3037a2bb5dd32e8f507b39a509163 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.14 resolution: "brace-expansion@npm:1.1.14" @@ -2715,6 +3453,13 @@ __metadata: languageName: node linkType: hard +"buffer-equal-constant-time@npm:^1.0.1": + version: 1.0.1 + resolution: "buffer-equal-constant-time@npm:1.0.1" + checksum: 10/80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab + languageName: node + linkType: hard + "callsite@npm:^1.0.0": version: 1.0.0 resolution: "callsite@npm:1.0.0" @@ -2757,6 +3502,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:5.6.2, chalk@npm:^5.3.0, chalk@npm:^5.4.1": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 + languageName: node + linkType: hard + "chalk@npm:^4.0.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -2767,13 +3519,6 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.3.0, chalk@npm:^5.4.1": - version: 5.6.2 - resolution: "chalk@npm:5.6.2" - checksum: 10/1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 - languageName: node - linkType: hard - "char-regex@npm:^1.0.2": version: 1.0.2 resolution: "char-regex@npm:1.0.2" @@ -2952,7 +3697,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:7.0.6, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -2963,7 +3708,14 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": +"data-uri-to-buffer@npm:^4.0.0": + version: 4.0.1 + resolution: "data-uri-to-buffer@npm:4.0.1" + checksum: 10/0d0790b67ffec5302f204c2ccca4494f70b4e2d940fea3d36b09f0bb2b8539c2e86690429eb1f1dc4bcc9e4df0644193073e63d9ee48ac9fce79ec1506e4aa4c + languageName: node + linkType: hard + +"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -3059,6 +3811,13 @@ __metadata: languageName: node linkType: hard +"diff@npm:8.0.4": + version: 8.0.4 + resolution: "diff@npm:8.0.4" + checksum: 10/b4036ceda0d1e10683a2313079ed52c5e6b09553ae29da87bce81d98714d9725dbf3c0f6f7c3b1f16eec049fe17087e38ee329e732580fa62f6ec1c2487b2435 + languageName: node + linkType: hard + "diff@npm:^4.0.1": version: 4.0.4 resolution: "diff@npm:4.0.4" @@ -3073,6 +3832,15 @@ __metadata: languageName: node linkType: hard +"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11": + version: 1.0.11 + resolution: "ecdsa-sig-formatter@npm:1.0.11" + dependencies: + safe-buffer: "npm:^5.0.1" + checksum: 10/878e1aab8a42773320bc04c6de420bee21aebd71810e40b1799880a8a1c4594bcd6adc3d4213a0fb8147d4c3f529d8f9a618d7f59ad5a9a41b142058aceda23f + languageName: node + linkType: hard + "electron-to-chromium@npm:^1.5.328": version: 1.5.352 resolution: "electron-to-chromium@npm:1.5.352" @@ -3536,6 +4304,13 @@ __metadata: languageName: node linkType: hard +"extend@npm:^3.0.2": + version: 3.0.2 + resolution: "extend@npm:3.0.2" + checksum: 10/59e89e2dc798ec0f54b36d82f32a27d5f6472c53974f61ca098db5d4648430b725387b53449a34df38fd0392045434426b012f302b3cc049a6500ccf82877e4e + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -3576,6 +4351,16 @@ __metadata: languageName: node linkType: hard +"fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": + version: 3.2.0 + resolution: "fetch-blob@npm:3.2.0" + dependencies: + node-domexception: "npm:^1.0.0" + web-streams-polyfill: "npm:^3.0.3" + checksum: 10/5264ecceb5fdc19eb51d1d0359921f12730941e333019e673e71eb73921146dceabcb0b8f534582be4497312d656508a439ad0f5edeec2b29ab2e10c72a1f86b + languageName: node + linkType: hard + "fflate@npm:0.8.2": version: 0.8.2 resolution: "fflate@npm:0.8.2" @@ -3640,6 +4425,15 @@ __metadata: languageName: node linkType: hard +"formdata-polyfill@npm:^4.0.10": + version: 4.0.10 + resolution: "formdata-polyfill@npm:4.0.10" + dependencies: + fetch-blob: "npm:^3.1.2" + checksum: 10/9b5001d2edef3c9449ac3f48bd4f8cc92e7d0f2e7c1a5c8ba555ad4e77535cc5cf621fabe49e97f304067037282dd9093b9160a3cb533e46420b446c4e6bc06f + languageName: node + linkType: hard + "fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" @@ -3666,6 +4460,28 @@ __metadata: languageName: node linkType: hard +"gaxios@npm:^7.0.0, gaxios@npm:^7.1.4": + version: 7.3.1 + resolution: "gaxios@npm:7.3.1" + dependencies: + extend: "npm:^3.0.2" + https-proxy-agent: "npm:^7.0.1" + node-fetch: "npm:^3.3.2" + checksum: 10/8b00343b2f3ffcfaa7833893f0ca4f31e0fdbc0fb82ad718d8dd0e124817f3aeda965ad7e35c25ed3a472bed2f5b2597a61f1371efd4e6d3bec43a8451cd4e6d + languageName: node + linkType: hard + +"gcp-metadata@npm:8.1.2": + version: 8.1.2 + resolution: "gcp-metadata@npm:8.1.2" + dependencies: + gaxios: "npm:^7.0.0" + google-logging-utils: "npm:^1.0.0" + json-bigint: "npm:^1.0.0" + checksum: 10/b3a4674067692991d1b72ddb5ff8cc24d08756fac2cf9ba4b49d92d0062724eca111ba58656fac54343bae8f0a29c8d264fb655ca2d6570e156fbdc338c787d9 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -3680,6 +4496,13 @@ __metadata: languageName: node linkType: hard +"get-east-asian-width@npm:1.6.0": + version: 1.6.0 + resolution: "get-east-asian-width@npm:1.6.0" + checksum: 10/3e5370b5df1f0020db711d8a3f9ee2cbfc9c7542daa99a699e9d7b9acf66e7868b89084741565a45d30d80afedf6e1218e0fb8bef7a583924a449c2816777380 + languageName: node + linkType: hard + "get-stream@npm:^6.0.0": version: 6.0.1 resolution: "get-stream@npm:6.0.1" @@ -3705,7 +4528,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^13.0.0": +"glob@npm:13.0.6, glob@npm:^13.0.0": version: 13.0.6 resolution: "glob@npm:13.0.6" dependencies: @@ -3761,6 +4584,34 @@ __metadata: languageName: node linkType: hard +"google-auth-library@npm:^10.3.0": + version: 10.9.1 + resolution: "google-auth-library@npm:10.9.1" + dependencies: + base64-js: "npm:^1.3.0" + ecdsa-sig-formatter: "npm:^1.0.11" + gaxios: "npm:^7.1.4" + gcp-metadata: "npm:8.1.2" + google-logging-utils: "npm:1.1.3" + jws: "npm:^4.0.0" + checksum: 10/73ff32cb0d8ab74ae9ebd1d81b69731f0a462dba2df7ebf5027b9dd99e2bb2162fa01edb4a2c5f143b9b89657ddb6122dd6002e4231878de4ace7f2b3803a089 + languageName: node + linkType: hard + +"google-logging-utils@npm:1.1.3": + version: 1.1.3 + resolution: "google-logging-utils@npm:1.1.3" + checksum: 10/5a6c090399545e0f1f2c92fbda316479dc5d573b2f4b54f0deb570dc31d8b254537894fd4e7c275ce7d352482e40d5857fa4b960c1b3d869584b5216dc2076e2 + languageName: node + linkType: hard + +"google-logging-utils@npm:^1.0.0": + version: 1.2.0 + resolution: "google-logging-utils@npm:1.2.0" + checksum: 10/68d0dd8295088a53d62aa53aa45a32fa00fb5a31bbe7d51c1bb311e2dee9dc007b67b87723a0a01bc6653b4f431d688b0f6f5b2164a38d86e8f5cbf9fa5dc949 + languageName: node + linkType: hard + "graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -3812,7 +4663,7 @@ __metadata: languageName: node linkType: hard -"highlight.js@npm:^10.7.1": +"highlight.js@npm:10.7.3, highlight.js@npm:^10.7.1": version: 10.7.3 resolution: "highlight.js@npm:10.7.3" checksum: 10/db8d10a541936b058e221dbde77869664b2b45bca75d660aa98065be2cd29f3924755fbc7348213f17fd931aefb6e6597448ba6fe82afba6d8313747a91983ee @@ -3828,7 +4679,7 @@ __metadata: languageName: node linkType: hard -"hosted-git-info@npm:^9.0.0": +"hosted-git-info@npm:9.0.3, hosted-git-info@npm:^9.0.0": version: 9.0.3 resolution: "hosted-git-info@npm:9.0.3" dependencies: @@ -3851,6 +4702,26 @@ __metadata: languageName: node linkType: hard +"http-proxy-agent@npm:7.0.2": + version: 7.0.2 + resolution: "http-proxy-agent@npm:7.0.2" + dependencies: + agent-base: "npm:^7.1.0" + debug: "npm:^4.3.4" + checksum: 10/d062acfa0cb82beeb558f1043c6ba770ea892b5fb7b28654dbc70ea2aeea55226dd34c02a294f6c1ca179a5aa483c4ea641846821b182edbd9cc5d89b54c6848 + languageName: node + linkType: hard + +"https-proxy-agent@npm:7.0.6, https-proxy-agent@npm:^7.0.1": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" + dependencies: + agent-base: "npm:^7.1.2" + debug: "npm:4" + checksum: 10/784b628cbd55b25542a9d85033bdfd03d4eda630fb8b3c9477959367f3be95dc476ed2ecbb9836c359c7c698027fc7b45723a302324433590f45d6c1706e8c13 + languageName: node + linkType: hard + "human-signals@npm:^2.1.0": version: 2.1.0 resolution: "human-signals@npm:2.1.0" @@ -3858,6 +4729,13 @@ __metadata: languageName: node linkType: hard +"ignore@npm:7.0.5, ignore@npm:^7.0.5": + version: 7.0.5 + resolution: "ignore@npm:7.0.5" + checksum: 10/f134b96a4de0af419196f52c529d5c6120c4456ff8a6b5a14ceaaa399f883e15d58d2ce651c9b69b9388491d4669dda47285d307e827de9304a53a1824801bc6 + languageName: node + linkType: hard + "ignore@npm:^5.2.0, ignore@npm:^5.2.4, ignore@npm:^5.3.2": version: 5.3.2 resolution: "ignore@npm:5.3.2" @@ -3865,13 +4743,6 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^7.0.5": - version: 7.0.5 - resolution: "ignore@npm:7.0.5" - checksum: 10/f134b96a4de0af419196f52c529d5c6120c4456ff8a6b5a14ceaaa399f883e15d58d2ce651c9b69b9388491d4669dda47285d307e827de9304a53a1824801bc6 - languageName: node - linkType: hard - "import-fresh@npm:^3.2.1": version: 3.3.1 resolution: "import-fresh@npm:3.3.1" @@ -4026,6 +4897,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:2.7.0": + version: 2.7.0 + resolution: "jiti@npm:2.7.0" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10/6d75a8dbd61dbee031aa0937fabb748ff8ddf370b971958cc704f5cf26b4c5bdc9dcd0563059b2627a2bd41d946fa0bc64f912fdc8981ca7945a9d63c74ad0f9 + languageName: node + linkType: hard + "js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -4072,6 +4952,15 @@ __metadata: languageName: node linkType: hard +"json-bigint@npm:^1.0.0": + version: 1.0.0 + resolution: "json-bigint@npm:1.0.0" + dependencies: + bignumber.js: "npm:^9.0.0" + checksum: 10/cd3973b88e5706f8f89d2a9c9431f206ef385bd5c584db1b258891a5e6642507c32316b82745239088c697f5ddfe967351e1731f5789ba7855aed56ad5f70e1f + languageName: node + linkType: hard + "json-buffer@npm:3.0.1": version: 3.0.1 resolution: "json-buffer@npm:3.0.1" @@ -4093,6 +4982,16 @@ __metadata: languageName: node linkType: hard +"json-schema-to-ts@npm:^3.1.1": + version: 3.1.1 + resolution: "json-schema-to-ts@npm:3.1.1" + dependencies: + "@babel/runtime": "npm:^7.18.3" + ts-algebra: "npm:^2.0.0" + checksum: 10/9fd0490279d36ff8b4604cc10632df05e4e5f5ee1d0a77841c927623fc1e636c47eb4ace488018c4e9a2e7e5dde5520d0870e06dfc84b930d9c98c6eacd0f041 + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -4116,6 +5015,27 @@ __metadata: languageName: node linkType: hard +"jwa@npm:^2.0.1": + version: 2.0.1 + resolution: "jwa@npm:2.0.1" + dependencies: + buffer-equal-constant-time: "npm:^1.0.1" + ecdsa-sig-formatter: "npm:1.0.11" + safe-buffer: "npm:^5.0.1" + checksum: 10/b04312a1de85f912b96aa3a7211717b8336945fab5b4f7cbc7800f4c80934060c0a3111576fad8d76e41ad62887d6da4b21fd4c47e45c174197f8be7dc0c1694 + languageName: node + linkType: hard + +"jws@npm:^4.0.0": + version: 4.0.1 + resolution: "jws@npm:4.0.1" + dependencies: + jwa: "npm:^2.0.1" + safe-buffer: "npm:^5.0.1" + checksum: 10/75d7b157489fa9a72023712c58a7a7706c7e2b10eec27fabd3bb9cae0c9e492251ab72527d20a8a5f5726196f0508c320c643fddff7076657f6bca16d0ceeeeb + languageName: node + linkType: hard + "keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -4301,7 +5221,7 @@ __metadata: languageName: node linkType: hard -"long@npm:^5.0.0": +"long@npm:^5.0.0, long@npm:^5.3.2": version: 5.3.2 resolution: "long@npm:5.3.2" checksum: 10/b6b55ddae56fcce2864d37119d6b02fe28f6dd6d9e44fd22705f86a9254b9321bd69e9ffe35263b4846d54aba197c64882adcb8c543f2383c1e41284b321ea64 @@ -4400,6 +5320,15 @@ __metadata: languageName: node linkType: hard +"marked@npm:18.0.5": + version: 18.0.5 + resolution: "marked@npm:18.0.5" + bin: + marked: bin/marked.js + checksum: 10/61f1c64304a7e2615ac096bd7b6e5da453561a57393a57f85f8ddef07a64eaeadf4d57c4c6e47d9179d116d36326c159d15993490686fc17434a39bdab9ddb8a + languageName: node + linkType: hard + "marked@npm:^9.1.2": version: 9.1.6 resolution: "marked@npm:9.1.6" @@ -4499,7 +5428,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.2.2, minimatch@npm:^9.0.3 || ^10.1.2": +"minimatch@npm:10.2.5, minimatch@npm:^10.2.2, minimatch@npm:^9.0.3 || ^10.1.2": version: 10.2.5 resolution: "minimatch@npm:10.2.5" dependencies: @@ -4623,6 +5552,13 @@ __metadata: languageName: node linkType: hard +"node-domexception@npm:^1.0.0": + version: 1.0.0 + resolution: "node-domexception@npm:1.0.0" + checksum: 10/e332522f242348c511640c25a6fc7da4f30e09e580c70c6b13cb0be83c78c3e71c8d4665af2527e869fc96848924a4316ae7ec9014c091e2156f41739d4fa233 + languageName: node + linkType: hard + "node-emoji@npm:^2.2.0": version: 2.2.0 resolution: "node-emoji@npm:2.2.0" @@ -4635,6 +5571,17 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:^3.3.2": + version: 3.3.2 + resolution: "node-fetch@npm:3.3.2" + dependencies: + data-uri-to-buffer: "npm:^4.0.0" + fetch-blob: "npm:^3.1.4" + formdata-polyfill: "npm:^4.0.10" + checksum: 10/24207ca8c81231c7c59151840e3fded461d67a31cf3e3b3968e12201a42f89ce4a0b5fb7079b1fa0a4655957b1ca9257553200f03a9f668b45ebad265ca5593d + languageName: node + linkType: hard + "node-gyp@npm:^12.1.0, node-gyp@npm:latest": version: 12.3.0 resolution: "node-gyp@npm:12.3.0" @@ -4772,6 +5719,23 @@ __metadata: languageName: node linkType: hard +"openai@npm:6.26.0": + version: 6.26.0 + resolution: "openai@npm:6.26.0" + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + bin: + openai: bin/cli + checksum: 10/33dc7beb65916331985ba811b7a30fab974f47ceed6b4ef1bf537d35a977c9cc1d8471154456fd0ab5efd1656e8881f447756cebf88def568973fbf5d08ca392 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -4873,6 +5837,16 @@ __metadata: languageName: node linkType: hard +"p-retry@npm:^4.6.2": + version: 4.6.2 + resolution: "p-retry@npm:4.6.2" + dependencies: + "@types/retry": "npm:0.12.0" + retry: "npm:^0.13.1" + checksum: 10/45c270bfddaffb4a895cea16cb760dcc72bdecb6cb45fef1971fa6ea2e91ddeafddefe01e444ac73e33b1b3d5d29fb0dd18a7effb294262437221ddc03ce0f2e + languageName: node + linkType: hard + "parent-module@npm:^1.0.0": version: 1.0.1 resolution: "parent-module@npm:1.0.1" @@ -4940,6 +5914,13 @@ __metadata: languageName: node linkType: hard +"partial-json@npm:0.1.7": + version: 0.1.7 + resolution: "partial-json@npm:0.1.7" + checksum: 10/6fb8d305638e4747fccfc0649b8a8de99314bfafcc4e3136a3a27c0632857c40b62e6e6bb5f17af00472387393f1d8a58a7865242ca20382af31b593a7bb1f89 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -5049,6 +6030,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: "npm:^4.2.4" + retry: "npm:^0.12.0" + signal-exit: "npm:^3.0.2" + checksum: 10/000a4875f543f591872b36ca94531af8a6463ddb0174f41c0b004d19e231d7445268b422ff1ea595e43d238655c702250cd3d27f408e7b9d97b56f1533ba26bf + languageName: node + linkType: hard + "property-information@npm:^7.0.0": version: 7.1.0 resolution: "property-information@npm:7.1.0" @@ -5076,23 +6068,22 @@ __metadata: languageName: node linkType: hard -"protobufjs@npm:^7.5.5": - version: 7.5.6 - resolution: "protobufjs@npm:7.5.6" +"protobufjs@npm:^7.5.4, protobufjs@npm:^7.5.5": + version: 7.6.6 + resolution: "protobufjs@npm:7.6.6" dependencies: "@protobufjs/aspromise": "npm:^1.1.2" "@protobufjs/base64": "npm:^1.1.2" "@protobufjs/codegen": "npm:^2.0.5" - "@protobufjs/eventemitter": "npm:^1.1.0" - "@protobufjs/fetch": "npm:^1.1.0" + "@protobufjs/eventemitter": "npm:^1.1.1" + "@protobufjs/fetch": "npm:^1.1.1" "@protobufjs/float": "npm:^1.0.2" - "@protobufjs/inquire": "npm:^1.1.1" "@protobufjs/path": "npm:^1.1.2" "@protobufjs/pool": "npm:^1.1.0" "@protobufjs/utf8": "npm:^1.1.1" "@types/node": "npm:>=13.7.0" - long: "npm:^5.0.0" - checksum: 10/13d7ab5b5d588b704476a600506cda9f647e2f657faf5a18e8866ce5b04800026a508a4958673e4d5674090bbc528717b01128f8e226055d56e90745e3ee6ada + long: "npm:^5.3.2" + checksum: 10/a894f38c793a0d741f77a7fbf8595141618e8c1cb3433637d2d0309ea9baca59bd9c44ec95f46ee839d063de5d5ae1bc78ef8d474359866af52ee4ceef7fec4f languageName: node linkType: hard @@ -5261,6 +6252,20 @@ __metadata: languageName: node linkType: hard +"retry@npm:^0.12.0": + version: 0.12.0 + resolution: "retry@npm:0.12.0" + checksum: 10/1f914879f97e7ee931ad05fe3afa629bd55270fc6cf1c1e589b6a99fab96d15daad0fa1a52a00c729ec0078045fe3e399bd4fd0c93bcc906957bdc17f89cb8e6 + languageName: node + linkType: hard + +"retry@npm:^0.13.1": + version: 0.13.1 + resolution: "retry@npm:0.13.1" + checksum: 10/6125ec2e06d6e47e9201539c887defba4e47f63471db304c59e4b82fc63c8e89ca06a77e9d34939a9a42a76f00774b2f46c0d4a4cbb3e287268bd018ed69426d + languageName: node + linkType: hard + "rolldown@npm:1.0.0-rc.18": version: 1.0.0-rc.18 resolution: "rolldown@npm:1.0.0-rc.18" @@ -5319,6 +6324,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:^5.0.1": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 + languageName: node + linkType: hard + "semver-compare@npm:^1.0.0": version: 1.0.0 resolution: "semver-compare@npm:1.0.0" @@ -5326,21 +6338,21 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.3.1": - version: 6.3.1 - resolution: "semver@npm:6.3.1" +"semver@npm:7.8.0, semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3": + version: 7.8.0 + resolution: "semver@npm:7.8.0" bin: semver: bin/semver.js - checksum: 10/1ef3a85bd02a760c6ef76a45b8c1ce18226de40831e02a00bad78485390b98b6ccaa31046245fc63bba4a47a6a592b6c7eedc65cc47126e60489f9cc1ce3ed7e + checksum: 10/039a8f68a581c03c1ac17c990316da57a79a93af9b109b712739c50cd4d464079f7e3fee31c008b472e390c7ba48a11ed2b86e91d8602bf06059d4a266db1426 languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2, semver@npm:^7.7.3": - version: 7.8.0 - resolution: "semver@npm:7.8.0" +"semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" bin: semver: bin/semver.js - checksum: 10/039a8f68a581c03c1ac17c990316da57a79a93af9b109b712739c50cd4d464079f7e3fee31c008b472e390c7ba48a11ed2b86e91d8602bf06059d4a266db1426 + checksum: 10/1ef3a85bd02a760c6ef76a45b8c1ce18226de40831e02a00bad78485390b98b6ccaa31046245fc63bba4a47a6a592b6c7eedc65cc47126e60489f9cc1ce3ed7e languageName: node linkType: hard @@ -5383,7 +6395,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.3": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -5650,6 +6662,13 @@ __metadata: languageName: node linkType: hard +"ts-algebra@npm:^2.0.0": + version: 2.0.0 + resolution: "ts-algebra@npm:2.0.0" + checksum: 10/b970eef64ca9594a77337e03b9c1732c1b7a0d2c4d316638b654e921a47b40c4cc42f41821445e9e54408d5dfdf4ecca27ffa59554373033b9c92dee8b52066d + languageName: node + linkType: hard + "ts-api-utils@npm:^2.5.0": version: 2.5.0 resolution: "ts-api-utils@npm:2.5.0" @@ -5715,7 +6734,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.4.0": +"tslib@npm:^2.4.0, tslib@npm:^2.6.2": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 @@ -5738,6 +6757,13 @@ __metadata: languageName: node linkType: hard +"typebox@npm:1.3.7": + version: 1.3.7 + resolution: "typebox@npm:1.3.7" + checksum: 10/ec9e810fac8efcec21da608bfd34f657b631c8674ccb4b6395581153ba9ebcd7e0c6e6bb92b7175c939f4e2fc71c72e032615514c7f3296e24a7fc488a518011 + languageName: node + linkType: hard + "typedoc@npm:^0.26.11": version: 0.26.11 resolution: "typedoc@npm:0.26.11" @@ -5831,6 +6857,13 @@ __metadata: languageName: node linkType: hard +"undici@npm:8.5.0": + version: 8.5.0 + resolution: "undici@npm:8.5.0" + checksum: 10/e95913777afd47d76babfe62547bb4e9a94b93f44b50c84b4e0b6fa41c00364e31ff4f433a955b1592c3162f43e8757e2e0feae6eea195187730594523393568 + languageName: node + linkType: hard + "undici@npm:^6.25.0": version: 6.25.0 resolution: "undici@npm:6.25.0" @@ -6156,6 +7189,13 @@ __metadata: languageName: node linkType: hard +"web-streams-polyfill@npm:^3.0.3": + version: 3.3.3 + resolution: "web-streams-polyfill@npm:3.3.3" + checksum: 10/8e7e13501b3834094a50abe7c0b6456155a55d7571312b89570012ef47ec2a46d766934768c50aabad10a9c30dd764a407623e8bfcc74fcb58495c29130edea9 + languageName: node + linkType: hard + "which@npm:^1.2.14": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -6236,6 +7276,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.18.0": + version: 8.21.3 + resolution: "ws@npm:8.21.3" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/8856922c26fd2d106931c16310d9a0bc2d6d37ed8771352bfa0d2a36df0ab5cb1f6528c6db8213f3333cf4ce93925e56f13604df0454cb0d4289fef922bebcdd + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -6257,6 +7312,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:2.9.0, yaml@npm:^2.0.0, yaml@npm:^2.5.1": + version: 2.9.0 + resolution: "yaml@npm:2.9.0" + bin: + yaml: bin.mjs + checksum: 10/9a95e8e08651c3d292ab6a5befeb5f57b76801caa097c75bb45c9a70ce19c1b11f57e87a6ef84a579ea070ed2c2c8ac541c88c0ae684d544d5f42c7e77d11b7b + languageName: node + linkType: hard + "yaml@npm:^1.10.0": version: 1.10.3 resolution: "yaml@npm:1.10.3" @@ -6264,15 +7328,6 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.0.0, yaml@npm:^2.5.1": - version: 2.8.4 - resolution: "yaml@npm:2.8.4" - bin: - yaml: bin.mjs - checksum: 10/1ccf5d9692c6521141515c2df24c30dd3f633fdb96b548ccc744094893cc58868ad0b3220e040b914a6308c52d258dc2d228a0330343e544a13c871569b92305 - languageName: node - linkType: hard - "yargs-parser@npm:^20.2.2": version: 20.2.9 resolution: "yargs-parser@npm:20.2.9" @@ -6331,6 +7386,22 @@ __metadata: languageName: node linkType: hard +"zod-to-json-schema@npm:^3.25.0": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 10/7035328654113f1a0b8e4c2d34a06f918c93650ef8a50d4fb30ad8f22e47d5762c163af9c82494756b34776bae3c41c26cfc6945105b0eee7dceb528cc07e665 + languageName: node + linkType: hard + +"zod@npm:^3.25.0 || ^4.0.0": + version: 4.5.4 + resolution: "zod@npm:4.5.4" + checksum: 10/b2fd4aaf358359650a1f27f303a462f0f49b4e5582f517ad8894ed566fdceb9da391b59babffe3c70574c8064174883735657eb28e12db5c7021455a631866c4 + languageName: node + linkType: hard + "zwitch@npm:^2.0.4": version: 2.0.4 resolution: "zwitch@npm:2.0.4" From 4411ad0ebcf9b148496cdcb726e4bdef6b7792d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Mon, 7 Sep 2026 18:18:04 +0100 Subject: [PATCH 2/4] fix: address PR #28 review findings - Rebuild scrubbed DOMExceptions so OTel span finalization cannot throw on abort - Scrub only credential values of at least eight characters - Own the settingSources default in the Claude adapter, not the runner - Collapse the Pi SDK version to one constant and drop dead resolveBridgeSdkVersion - Add an explicit hostRoot to bridge runtime descriptors - Unify on-success cleanup: keep the container unless the run completed successfully - Wrap runtime file copies in Docker error handling; write missing JSDoc --- CHANGELOG.md | 8 +- README.md | 8 +- src/adapters/claude-adapter.test.ts | 29 ++++- src/adapters/claude-adapter.ts | 21 +++- src/credential-redactor.test.ts | 41 +++++-- src/credential-redactor.ts | 17 ++- src/pi-runtime.test.ts | 17 +-- src/runner.test.ts | 18 +++ src/runner.ts | 3 +- src/sandbox/container/pi-bridge.test.ts | 6 +- src/sandbox/docker/bridge.test.ts | 81 +++++--------- src/sandbox/docker/bridge.ts | 142 ++++++++++++------------ src/sandbox/types.ts | 5 +- src/telemetry/controller.test.ts | 4 + src/telemetry/message-handler.test.ts | 11 ++ src/types.ts | 2 +- 16 files changed, 247 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e64b7..581e075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional pi Agent SDK adapter for isolated LiteLLM runs, Docker execution, structured judging, cancellation, and turn limits. - Provider-specific runner option types with backwards-compatible Claude defaults. +### Changed + +- Apply the `on-success` Docker cleanup policy uniformly: adapters keep the container unless the run completed with a successful result. +- Take provider option defaults from `adapter.defaultOptions` only; the Claude adapter owns the isolated `settingSources: []` default. + ### Fixed -- Scrub configured credential values from pi messages, errors, telemetry, and judge inputs, including short credentials and nested error causes. +- Scrub configured credential values of at least eight characters from pi messages, errors, telemetry, and judge inputs, including nested error causes. +- Keep scrubbed DOMExceptions as real DOMExceptions so telemetry span finalization cannot throw on abort errors. ## [0.3.1] diff --git a/README.md b/README.md index 4e5e82f..c51ad7d 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ const result = await runner.runAgent({ console.log(result.sessionId, result.totalCostUsd, result.durationMs); ``` -By default the runner passes `settingSources: []` to the Claude SDK for isolated settings. Callers can override that in `defaultOptions` or per-run `options` when they intentionally want SDK settings loaded from other sources. +By default the Claude adapter passes `settingSources: []` to the SDK for isolated settings. Callers can override that in `defaultOptions` or per-run `options` when they intentionally want SDK settings loaded from other sources. ## Pi usage @@ -140,13 +140,13 @@ Pi emits the same normalized messages and telemetry as Claude. Reaching `maxTurn ### Pi sandbox and judge behavior -Docker installs pi 0.83.0 and copies the shared pi execution modules into the container. `bridge.sdkVersion`, if supplied, must match. With `bridge.install: false`, that version must already be resolvable from the bridge directory. The Node version check applies inside the container, so a Node 20 host can still launch a supported pi container. +Docker installs pi 0.83.0 and copies the shared pi execution modules into the container. `bridge.sdkVersion`, if supplied, must match. With `bridge.install: false`, the runner performs no version check, so make sure the bridge directory already contains a compatible install. The Node version check applies inside the container, so a Node 20 host can still launch a supported pi container. Pi's default forwarded environment is `LITELLM_BASE_URL`, `LITELLM_API_KEY`, `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`. An explicit `forwardEnv` list or `false` overrides it. Credentials are passed through the existing Docker environment-file mechanism, not serialized into query options. The existing Docker security caveats still apply: enabled tools can read credentials inside the container. Pi judging uses only a terminating `submit_judgment` tool and rejects caller tool customization. It inherits model settings, but not task tools or task turn limits. Its default limit is five turns. It uses the runner's sandbox; `judge(..., context, { sandbox: false, signal })` can override sandboxing and cancel the judge. A sandboxed judge starts its own container rather than reusing the task container. -Claude judging and the legacy Claude fallback for custom adapters without `runStructured()` remain unchanged. To judge a transcript with another provider, use a separate runner. Custom adapters can implement `runStructured()`, `getStructuredDefaults()`, and `getRunMetadata()`. Set `defaultOptions: {}` on non-Claude adapters to opt out of the legacy `settingSources: []` default. +Claude judging and the legacy Claude fallback for custom adapters without `runStructured()` remain unchanged. To judge a transcript with another provider, use a separate runner. Custom adapters can implement `runStructured()`, `getStructuredDefaults()`, and `getRunMetadata()`. Adapter `defaultOptions` are merged under per-run options; adapters that supply none change nothing. To run the local-protocol Docker smoke test after building: @@ -422,7 +422,7 @@ const result = await runner.runAgent({ | `shmSize` | `string` | Size of `/dev/shm` (e.g. `512m`, `2g`). | | `unsafeDockerArgs` | `string[]` | Extra raw arguments forwarded to `docker run`. Not validated. | | `setupCommands` | `string[]` | Shell commands executed inside the container before the agent starts. Useful for installing extra dependencies or seeding state. | -| `cleanup` | `'always' \| 'on-success' \| 'never'` | When to remove the container. Defaults to `'always'`. `'on-success'` keeps the container on failure for inspection. | +| `cleanup` | `'always' \| 'on-success' \| 'never'` | When to remove the container. Defaults to `'always'`. `'on-success'` keeps the container unless the run completed with a successful result. | | `bridge` | `DockerSandboxBridgeConfig` | Bridge runtime options: `install`, `nodeCommand`, `npmCommand`, `sdkVersion`. Defaults install the host's installed Claude Agent SDK version on the fly. | `SandboxConfig` is a discriminated union on `type`; today only `'docker'` diff --git a/src/adapters/claude-adapter.test.ts b/src/adapters/claude-adapter.test.ts index f6ff38c..d4d181f 100644 --- a/src/adapters/claude-adapter.test.ts +++ b/src/adapters/claude-adapter.test.ts @@ -679,6 +679,11 @@ describe('createClaudeAdapter', () => { expect(adapter.capabilities).toStrictEqual({ sandboxes: ['docker'] }); }); + it('defaults to isolated settingSources', () => { + const adapter = createClaudeAdapter(); + expect(adapter.defaultOptions).toStrictEqual({ settingSources: [] }); + }); + it('runs in-process and bypasses the sandbox machinery when no sandbox is set', async () => { claudeMocks.query.mockReturnValueOnce(yieldMessages([])); @@ -855,7 +860,9 @@ describe('createClaudeAdapter', () => { it('closes the container on bridge success when cleanup is `on-success`', async () => { const handle = makeHandle(); sandboxMocks.createDockerSandbox.mockResolvedValueOnce(handle); - sandboxMocks.runDockerClaudeBridge.mockReturnValueOnce(makeBridge([])); + sandboxMocks.runDockerClaudeBridge.mockReturnValueOnce( + makeBridge([{ type: 'result', subtype: 'success' }]), + ); const adapter = createClaudeAdapter(); await drain( @@ -870,6 +877,26 @@ describe('createClaudeAdapter', () => { expect(handle.unregisterCleanup).not.toHaveBeenCalled(); }); + it('keeps the container when the result failed under cleanup `on-success`', async () => { + const handle = makeHandle(); + sandboxMocks.createDockerSandbox.mockResolvedValueOnce(handle); + sandboxMocks.runDockerClaudeBridge.mockReturnValueOnce( + makeBridge([{ type: 'result', subtype: 'error_max_turns' }]), + ); + + const adapter = createClaudeAdapter(); + await drain( + adapter.run({ + prompt: 'sandboxed', + options: {}, + sandbox: { ...baseSandbox, cleanup: 'on-success' }, + }), + ); + + expect(handle.close).not.toHaveBeenCalled(); + expect(handle.unregisterCleanup).toHaveBeenCalledTimes(1); + }); + it('re-throws close errors when the bridge run succeeds but cleanup fails', async () => { const handle = makeHandle(); handle.close.mockRejectedValueOnce(new Error('close boom')); diff --git a/src/adapters/claude-adapter.ts b/src/adapters/claude-adapter.ts index 592c247..89b2043 100644 --- a/src/adapters/claude-adapter.ts +++ b/src/adapters/claude-adapter.ts @@ -35,6 +35,9 @@ import { translateClaudeSdkMessages } from './claude-message-translator.js'; export function createClaudeAdapter(): ProviderAdapter { return { name: 'claude', + // Isolated settings by default so the SDK does not read user or project + // configuration files unless a caller opts in via run options. + defaultOptions: { settingSources: [] }, capabilities: { sandboxes: ['docker'] }, /** * Describes the Claude run for telemetry. @@ -124,6 +127,7 @@ async function* runWithSandbox( let bridgeError: unknown; let bridgeFailed = false; let bridgeCompleted = false; + let resultSucceeded = false; let closeError: unknown; let closeFailed = false; try { @@ -134,7 +138,12 @@ async function* runWithSandbox( request: { prompt: prepared.prompt, options: prepared.options }, }); - yield* translateClaudeSdkMessages(bridgeMessages); + for await (const message of translateClaudeSdkMessages(bridgeMessages)) { + if (message.type === 'result') { + resultSucceeded = message.success; + } + yield message; + } bridgeCompleted = true; } catch (cause) { bridgeError = cause; @@ -143,7 +152,7 @@ async function* runWithSandbox( const consumerAborted = !bridgeCompleted && !bridgeFailed; if ( consumerAborted || - shouldCloseContainer(normalized.cleanup, bridgeCompleted) + shouldCloseContainer(normalized.cleanup, bridgeCompleted, resultSucceeded) ) { try { await handle.close(); @@ -167,7 +176,7 @@ async function* runWithSandbox( /** * Decides whether the sandbox container should be removed at the end * of a run based on the cleanup policy and whether the bridge run - * completed naturally. + * completed naturally with a successful result. * * This function ONLY governs the non-abort cases. When the consumer * aborts iteration early (`break`, `iterator.return()`, or an @@ -177,19 +186,21 @@ async function* runWithSandbox( * running. * * @param cleanup - Cleanup policy from the normalized sandbox config. - * @param succeeded - Whether the bridge completed naturally without + * @param completed - Whether the bridge completed naturally without * throwing and without being abandoned mid-stream. + * @param succeeded - Whether the run produced a successful result. * @returns Whether to call `handle.close()`. */ function shouldCloseContainer( cleanup: 'always' | 'on-success' | 'never', + completed: boolean, succeeded: boolean, ): boolean { switch (cleanup) { case 'always': return true; case 'on-success': - return succeeded; + return completed && succeeded; case 'never': return false; default: diff --git a/src/credential-redactor.test.ts b/src/credential-redactor.test.ts index 57426fe..8f9546a 100644 --- a/src/credential-redactor.test.ts +++ b/src/credential-redactor.test.ts @@ -7,31 +7,35 @@ import { import { DockerSandboxError } from './errors.js'; describe('credential redaction', () => { - it('redacts every nonempty credential, including short and regex-like values', () => { + it('redacts credential values of at least eight characters and keeps shorter values', () => { const scrub = createCredentialScrubber({ - LITELLM_API_KEY: 'a+b', - TOKEN: 'xy', + LITELLM_API_KEY: 'sk-secret-value', + TOKENIZERS_PARALLELISM: 'false', + PASSWORD_MIN_LENGTH: '8', + TOKEN: 'short', OTHER: 'visible', EMPTY_KEY: '', }); - expect(scrub('a+b xy visible')).toBe('[REDACTED] [REDACTED] visible'); + expect(scrub('sk-secret-value false 8 short visible')).toBe( + '[REDACTED] false 8 short visible', + ); expect(createCredentialScrubber({})('unchanged')).toBe('unchanged'); }); it('copies errors, stacks, nested causes, and cycles without losing error identity', () => { - const cause = new Error('sk-test cause'); - const error = new DockerSandboxError('sk-test failure', { cause }); + const cause = new Error('sk-test-key cause'); + const error = new DockerSandboxError('sk-test-key failure', { cause }); cause.cause = error; const scrubbed = scrubCredentials( error, - createCredentialScrubber({ API_KEY: 'sk-test' }), + createCredentialScrubber({ API_KEY: 'sk-test-key' }), ); expect(scrubbed).toBeInstanceOf(DockerSandboxError); expect(scrubbed).not.toBe(error); expect(scrubbed.message).toBe('[REDACTED] failure'); - expect(scrubbed.stack).not.toContain('sk-test'); + expect(scrubbed.stack).not.toContain('sk-test-key'); expect((scrubbed.cause as Error).message).toBe('[REDACTED] cause'); expect((scrubbed.cause as Error).cause).toBe(scrubbed); - expect(error.message).toBe('sk-test failure'); + expect(error.message).toBe('sk-test-key failure'); }); it('preserves stackless errors and repeated references', () => { const error = new Error('failure'); @@ -41,15 +45,28 @@ describe('credential redaction', () => { expect(scrubbed[0]?.message).toBe('failure'); }); it('supports DOMException abort reasons and structured payloads', () => { - const scrub = createCredentialScrubber({ API_KEY: 'key' }); + const scrub = createCredentialScrubber({ API_KEY: 'secret-key' }); const error = scrubCredentials( - new DOMException('key', 'AbortError'), + new DOMException('secret-key', 'AbortError'), scrub, ); expect(error.name).toBe('AbortError'); expect(error.message).toBe('[REDACTED]'); - expect(scrubCredentials({ raw: ['key', null, 1] }, scrub)).toStrictEqual({ + expect( + scrubCredentials({ raw: ['secret-key', null, 1] }, scrub), + ).toStrictEqual({ raw: ['[REDACTED]', null, 1], }); }); + it('keeps DOMExceptions as real DOMExceptions so prototype getters still work', () => { + const scrubbed = scrubCredentials( + new DOMException('secret-key abort', 'AbortError'), + createCredentialScrubber({ API_KEY: 'secret-key' }), + ); + expect(scrubbed).toBeInstanceOf(DOMException); + expect(scrubbed.message).toBe('[REDACTED] abort'); + // OTel's `recordException` reads `exception.code`; a prototype-swapped + // copy throws `TypeError` here instead. + expect(() => scrubbed.code).not.toThrow(); + }); }); diff --git a/src/credential-redactor.ts b/src/credential-redactor.ts index eda0869..e6a4303 100644 --- a/src/credential-redactor.ts +++ b/src/credential-redactor.ts @@ -1,5 +1,11 @@ /** - * Collects configured credential values without imposing a minimum length. + * Minimum credential value length eligible for scrubbing. Shorter values + * (flags like `true`, single digits) are too generic to redact safely. + */ +const MIN_CREDENTIAL_LENGTH = 8; + +/** + * Collects configured credential values of at least eight characters. * * @param env - Credential-bearing environment. * @returns A value-level string scrubber. @@ -13,7 +19,8 @@ export function createCredentialScrubber( .filter( ([key, value]) => /key|token|secret|password|authorization/iu.test(key) && - Boolean(value), + value !== undefined && + value.length >= MIN_CREDENTIAL_LENGTH, ) .map(([, value]) => value as string), ), @@ -52,6 +59,12 @@ export function scrubCredentials( if (seen.has(value)) { return seen.get(value) as Value; } + if (value instanceof DOMException) { + // Build a real DOMException. Copying an Error and swapping the prototype + // leaves the internal slots unset, so DOMException getters (`code`, + // `message`) throw and crash consumers such as OTel's `recordException`. + return new DOMException(scrub(value.message), value.name) as Value; + } if (value instanceof Error) { const result = new Error(scrub(value.message)); Object.setPrototypeOf(result, Object.getPrototypeOf(value)); diff --git a/src/pi-runtime.test.ts b/src/pi-runtime.test.ts index 44bd882..5ee02e2 100644 --- a/src/pi-runtime.test.ts +++ b/src/pi-runtime.test.ts @@ -45,7 +45,10 @@ describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( res.writeHead(reply.status, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ - error: { message: 'bad sk-test', type: 'invalid_request_error' }, + error: { + message: 'bad sk-test-key', + type: 'invalid_request_error', + }, }), ); return; @@ -78,7 +81,7 @@ describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( throw new Error('Missing server address'); } vi.stubEnv('LITELLM_BASE_URL', `http://127.0.0.1:${address.port}`); - vi.stubEnv('LITELLM_API_KEY', 'sk-test'); + vi.stubEnv('LITELLM_API_KEY', 'sk-test-key'); }); afterEach(async () => { server.closeAllConnections(); @@ -173,14 +176,14 @@ describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( rubric: 'Judge correctness.', scoreFields: [{ name: 'correctness', min: 0, max: 10 }], }, - { taskPrompt: 'sk-test', status: 'sk-test' }, + { taskPrompt: 'sk-test-key', status: 'sk-test-key' }, ); expect(verdict.scores.correctness).toBe(7); expect(requests).toHaveLength(1); expect( requests[0]?.tools.map((tool: any) => tool.function.name), ).toStrictEqual(['submit_judgment']); - expect(JSON.stringify(requests[0])).not.toContain('sk-test'); + expect(JSON.stringify(requests[0])).not.toContain('sk-test-key'); }); it('cleans up when a judge message callback fails', async () => { @@ -243,7 +246,7 @@ describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( signal: controller.signal, }); await vi.waitFor(() => expect(requests).toHaveLength(1)); - controller.abort(new Error('cancel sk-test')); + controller.abort(new Error('cancel sk-test-key')); const result = await resultPromise; expect(result.isPartial).toBe(true); expect(result.error?.message).toBe('cancel [REDACTED]'); @@ -265,7 +268,7 @@ describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( }); it('returns an unsuccessful result for truncation and scrubs provider errors', async () => { - reply = { stop: 'length', text: 'partial sk-test' }; + reply = { stop: 'length', text: 'partial sk-test-key' }; const runner = createAgentRunner({ adapter: createPiAdapter(), defaultOptions: { model: 'test', tools: [] }, @@ -278,7 +281,7 @@ describe.skipIf(Number(process.versions.node.split('.')[0]) < 22)( reply = { status: 400 }; const failed = await runner.runAgent({ prompt: 'hi' }); expect(failed.resultMessage).toMatchObject({ success: false }); - expect(JSON.stringify(failed)).not.toContain('sk-test'); + expect(JSON.stringify(failed)).not.toContain('sk-test-key'); }); it('accepts a base URL that already ends in /v1', async () => { diff --git a/src/runner.test.ts b/src/runner.test.ts index 3139070..33cee09 100644 --- a/src/runner.test.ts +++ b/src/runner.test.ts @@ -72,6 +72,9 @@ const createMockAdapter = ( const runCalls: unknown[] = []; const adapter: ProviderAdapter = { name: 'mock', + // Mirror the Claude adapter's isolated-settings default so option-merging + // tests exercise the same shape a real adapter supplies. + defaultOptions: { settingSources: [] }, /** * Mock run generator. * @@ -104,6 +107,21 @@ describe('createAgentRunner', () => { ]); }); + it('passes empty options when the adapter defines no defaults', async () => { + const { adapter, runCalls } = createMockAdapter(); + delete adapter.defaultOptions; + const runner = createAgentRunner({ adapter }); + + await runner.runAgent({ prompt: 'test prompt' }); + + expect(runCalls).toStrictEqual([ + { + prompt: 'test prompt', + options: {}, + }, + ]); + }); + it('lets caller override default settings', async () => { const { adapter, runCalls } = createMockAdapter(); const runner = createAgentRunner({ diff --git a/src/runner.ts b/src/runner.ts index a6c823c..43b96b5 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -175,8 +175,7 @@ export function createAgentRunner< runOptions: AgentRunOptions, ): Promise => { const options = { - ...(adapter.defaultOptions ?? - ({ settingSources: [] } as unknown as Partial)), + ...adapter.defaultOptions, ...config.defaultOptions, ...runOptions.options, }; diff --git a/src/sandbox/container/pi-bridge.test.ts b/src/sandbox/container/pi-bridge.test.ts index 54598da..ee57da1 100644 --- a/src/sandbox/container/pi-bridge.test.ts +++ b/src/sandbox/container/pi-bridge.test.ts @@ -108,17 +108,17 @@ describe('pi container bridge', () => { expect(run).not.toHaveBeenCalled(); }); it('scrubs thrown errors before writing protocol frames', async () => { - vi.stubEnv('LITELLM_API_KEY', 'short'); + vi.stubEnv('LITELLM_API_KEY', 'short-secret'); try { const result = await invoke( { version: 1, type: 'run', prompt: 'hi', options: {} }, // eslint-disable-next-line require-yield -- Failure before the first message. async function* () { - throw new Error('short failure'); + throw new Error('short-secret failure'); }, ); expect(result.code).toBe(1); - expect(JSON.stringify(result.frames)).not.toContain('short'); + expect(JSON.stringify(result.frames)).not.toContain('short-secret'); } finally { vi.unstubAllEnvs(); } diff --git a/src/sandbox/docker/bridge.test.ts b/src/sandbox/docker/bridge.test.ts index 937635d..fa321b2 100644 --- a/src/sandbox/docker/bridge.test.ts +++ b/src/sandbox/docker/bridge.test.ts @@ -4,6 +4,7 @@ import { DockerSandboxError, DockerSandboxProtocolError, } from '../../errors.js'; +import { PI_SDK_VERSION } from '../../pi-runtime.js'; import { BRIDGE_PROTOCOL_VERSION, parseBridgeEvent, @@ -11,15 +12,14 @@ import { } from './bridge-protocol.js'; import { BRIDGE_SDK_PACKAGE_NAME, + CLAUDE_BRIDGE_RUNTIME, PI_BRIDGE_RUNTIME, - PI_BRIDGE_SDK_VERSION, DEFAULT_REMOTE_BRIDGE_DIR, DEFAULT_REMOTE_BRIDGE_FILE, MAX_BRIDGE_LINE_LENGTH, MAX_BRIDGE_QUEUE_SIZE, bootstrapDockerClaudeBridge, bootstrapDockerBridge, - resolveBridgeSdkVersion, resolveDefaultBridgeHostPath, runDockerClaudeBridge, runDockerBridge, @@ -170,55 +170,9 @@ describe('resolveDefaultBridgeHostPath', () => { const resolved = resolveDefaultBridgeHostPath(); expect(resolved).toMatch(/[/\\]container[/\\]claude-bridge\.mjs$/u); }); -}); - -describe('resolveBridgeSdkVersion', () => { - it('returns the explicit override when provided', () => { - const config = makeConfig({ sdkVersion: '1.2.3' }); - expect( - resolveBridgeSdkVersion({ - config, - /** - * - */ - readHostSdkVersion: () => '9.9.9', - }), - ).toBe('1.2.3'); - }); - - it('falls back to the host-installed SDK version', () => { - const config = makeConfig(); - expect( - resolveBridgeSdkVersion({ - config, - /** - * - */ - readHostSdkVersion: () => '0.2.138', - }), - ).toBe('0.2.138'); - }); - it('uses the default host SDK reader when no override is supplied', () => { - // Without the optional override, the function falls back to - // reading the host package.json via createRequire. We do not - // assert a specific version (it tracks the installed dep), only - // that the lookup succeeds in this repo. - const result = resolveBridgeSdkVersion({ config: makeConfig() }); - expect(typeof result).toBe('string'); - expect(result.length).toBeGreaterThan(0); - }); - - it('throws when neither an override nor the host SDK can be located', () => { - expect(() => - resolveBridgeSdkVersion({ - config: makeConfig(), - /** - * - */ - readHostSdkVersion: () => undefined, - }), - ).toThrow(/Could not determine .* version/u); + it('resolves the Claude SDK version from the host installation', () => { + expect(CLAUDE_BRIDGE_RUNTIME.resolveVersion()).toMatch(/^\d+\./u); }); }); @@ -236,7 +190,7 @@ describe('bootstrapDockerClaudeBridge', () => { }); expect(result.remoteBridgePath).toMatch(/pi-bridge\.mjs$/u); expect(calls.at(-1)?.args).toContain( - `@earendil-works/pi-coding-agent@${PI_BRIDGE_SDK_VERSION}`, + `@earendil-works/pi-coding-agent@${PI_SDK_VERSION}`, ); await expect( @@ -280,9 +234,31 @@ describe('bootstrapDockerClaudeBridge', () => { expect(copy?.args[1]).toMatch(/\/container\/pi-bridge\.mjs$/u); expect(copy?.args[2]).toMatch(/pi-bridge\.mjs$/u); expect(calls.at(-1)?.args).toContain( - `@earendil-works/pi-coding-agent@${PI_BRIDGE_SDK_VERSION}`, + `@earendil-works/pi-coding-agent@${PI_SDK_VERSION}`, ); }); + + it('wraps runtime file copy failures in a DockerSandboxError', async () => { + const { runner } = makeStubRunner([ + () => ({ stdout: 'v22.19.0\n', stderr: '', exitCode: 0 }), + () => ({ stdout: '10.0.0\n', stderr: '', exitCode: 0 }), + () => ({ stdout: '', stderr: '', exitCode: 0 }), + () => ({ stdout: '', stderr: '', exitCode: 0 }), + () => { + throw new Error('cp boom'); + }, + ]); + + await expect( + bootstrapDockerBridge({ + runtime: PI_BRIDGE_RUNTIME, + sandbox: makeHandle('ct-pi-file-copy'), + config: makeConfig(), + commandRunner: runner, + }), + ).rejects.toThrow(/Failed to copy runtime file pi-runtime\.mjs/u); + }); + it('executes preflight, mkdir, cp, package.json, and npm install in order', async () => { const { runner, calls } = makeStubRunner(); @@ -648,6 +624,7 @@ describe('pi Docker cancellation and bootstrap', () => { id: 'missing', packageName: 'missing', remoteBridgeFile: 'bridge.mjs', + hostRoot: 'dist', resolveVersion: () => undefined, }, sandbox: makeHandle('pi'), diff --git a/src/sandbox/docker/bridge.ts b/src/sandbox/docker/bridge.ts index 757fd19..e82aab9 100644 --- a/src/sandbox/docker/bridge.ts +++ b/src/sandbox/docker/bridge.ts @@ -1,4 +1,3 @@ -/* eslint-disable jsdoc/require-param, jsdoc/require-returns */ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; @@ -12,6 +11,7 @@ import { DockerSandboxError, DockerSandboxProtocolError, } from '../../errors.js'; +import { PI_SDK_VERSION } from '../../pi-runtime.js'; import { BRIDGE_PROTOCOL_VERSION, parseBridgeEvent, @@ -62,9 +62,6 @@ export const MAX_BRIDGE_QUEUE_SIZE = 10_000; */ export const BRIDGE_SDK_PACKAGE_NAME = '@anthropic-ai/claude-agent-sdk'; -/** Exact Pi runtime version locked by the package architecture. */ -export const PI_BRIDGE_SDK_VERSION = '0.83.0'; - /** Runtime-specific data consumed by the generic Docker bootstrap. */ export type DockerBridgeRuntimeDescriptor = { /** Stable runtime identifier used in diagnostics. */ @@ -73,6 +70,11 @@ export type DockerBridgeRuntimeDescriptor = { packageName: string; /** Default bridge filename copied into the container. */ remoteBridgeFile: string; + /** + * Where `remoteBridgeFile` (and `files`) resolve from, relative to this + * module: `container` for `dist/sandbox/container`, `dist` for the build root. + */ + hostRoot: 'container' | 'dist'; /** Resolves the exact install version when no explicit override is supplied. */ resolveVersion: () => string | undefined; /** Optional minimum Node.js version required by the runtime. */ @@ -88,6 +90,7 @@ export const CLAUDE_BRIDGE_RUNTIME: DockerBridgeRuntimeDescriptor = { id: 'claude', packageName: BRIDGE_SDK_PACKAGE_NAME, remoteBridgeFile: DEFAULT_REMOTE_BRIDGE_FILE, + hostRoot: 'container', /** Resolves the installed Claude SDK version. */ resolveVersion: readHostSdkVersion, }; @@ -97,11 +100,16 @@ export const PI_BRIDGE_RUNTIME: DockerBridgeRuntimeDescriptor = { id: 'pi', packageName: '@earendil-works/pi-coding-agent', remoteBridgeFile: 'sandbox/container/pi-bridge.mjs', + hostRoot: 'dist', files: ['pi-runtime.mjs', 'credential-redactor.mjs'], - /** Returns the architecture-locked Pi version. */ - resolveVersion: () => PI_BRIDGE_SDK_VERSION, + /** + * Returns the architecture-locked Pi version. + * + * @returns The locked Pi SDK version. + */ + resolveVersion: () => PI_SDK_VERSION, minNodeVersion: '22.19.0', - exactVersion: PI_BRIDGE_SDK_VERSION, + exactVersion: PI_SDK_VERSION, }; /** @@ -119,65 +127,29 @@ export function resolveDefaultBridgeHostPath(): string { return resolveBridgeHostPath(CLAUDE_BRIDGE_RUNTIME); } -/** Resolves the built bridge path for a runtime descriptor. */ +/** + * Resolves the built bridge path for a runtime descriptor. + * + * @param runtime - Runtime descriptor naming the bridge file. + * @returns Absolute path to the compiled bridge script. + */ export function resolveBridgeHostPath( runtime: DockerBridgeRuntimeDescriptor, ): string { - const here = fileURLToPath(import.meta.url); - return path.resolve( - path.dirname(here), - ...(runtime.files === undefined ? ['..', 'container'] : ['..', '..']), - runtime.remoteBridgeFile, - ); + return path.join(resolveHostRoot(runtime.hostRoot), runtime.remoteBridgeFile); } /** - * Input for {@link resolveBridgeSdkVersion}. - */ -export type ResolveBridgeSdkVersionInput = { - /** Normalized bridge configuration. */ - config: NormalizedDockerSandboxConfig; - /** - * Test seam returning the host-installed SDK version when present. - * Production code falls back to {@link readHostSdkVersion}. - */ - readHostSdkVersion?: () => string | undefined; -}; - -/** - * Resolves the SDK version the bridge should install inside the - * container. - * - * Resolution order: - * 1. Explicit `sandbox.bridge.sdkVersion` if provided. - * 2. Host-installed `@anthropic-ai/claude-agent-sdk/package.json`. + * Resolves the host directory a runtime's bridge files ship from. * - * The host `node_modules` tree is never copied into the container — - * only the version string is borrowed to pin the in-container - * `npm install`. - * - * @param input - Normalized config and an optional reader override. - * @returns The exact version string suitable for `npm install`. - * @throws {DockerSandboxError} When no version can be resolved. + * @param hostRoot - Root selector from the runtime descriptor. + * @returns Absolute path to the directory holding the built files. */ -export function resolveBridgeSdkVersion( - input: ResolveBridgeSdkVersionInput, -): string { - const explicit = input.config.bridge.sdkVersion; - if (explicit !== undefined) { - return explicit; - } - - const reader = input.readHostSdkVersion ?? readHostSdkVersion; - const detected = reader(); - if (detected !== undefined) { - return detected; - } - - throw new DockerSandboxError( - `Could not determine ${BRIDGE_SDK_PACKAGE_NAME} version: the host does not ` + - 'have it installed and `sandbox.bridge.sdkVersion` is not set. ' + - 'Install the SDK on the host or pin a version via `sandbox.bridge.sdkVersion`.', +function resolveHostRoot(hostRoot: 'container' | 'dist'): string { + const here = path.dirname(fileURLToPath(import.meta.url)); + return path.resolve( + here, + ...(hostRoot === 'container' ? ['..', 'container'] : ['..', '..']), ); } @@ -245,8 +217,9 @@ export type BootstrapDockerClaudeBridgeInput = { */ remoteBridgeFile?: string; /** - * Test seam returning the host-installed SDK version. Forwarded to - * {@link resolveBridgeSdkVersion}. + * Test seam returning the host-installed SDK version. When it returns + * `undefined`, bootstrap falls back to the runtime descriptor's + * `resolveVersion()`. */ readHostSdkVersion?: () => string | undefined; }; @@ -297,7 +270,12 @@ export async function bootstrapDockerClaudeBridge( }); } -/** Bootstraps an arbitrary descriptor-defined SDK bridge. */ +/** + * Bootstraps an arbitrary descriptor-defined SDK bridge. + * + * @param input - Inputs and overrides, including the runtime descriptor. + * @returns The resolved remote bridge path and node command. + */ export async function bootstrapDockerBridge( input: BootstrapDockerBridgeInput, ): Promise { @@ -344,16 +322,18 @@ export async function bootstrapDockerBridge( } for (const file of input.runtime.files ?? []) { - await runner.run('docker', [ - 'cp', - path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', - '..', - file, - ), - `${sandbox.containerName}:${remoteBridgeDir}/${file}`, - ]); + try { + await runner.run('docker', [ + 'cp', + path.join(resolveHostRoot(input.runtime.hostRoot), file), + `${sandbox.containerName}:${remoteBridgeDir}/${file}`, + ]); + } catch (cause) { + throw wrapDockerSandboxError( + `Failed to copy runtime file ${file} into container \`${sandbox.containerName}\` at ${remoteBridgeDir}/${file}`, + cause, + ); + } } const explicitVersion = input.config.bridge.sdkVersion; @@ -500,7 +480,12 @@ export function runDockerClaudeBridge( return runDockerBridge({ ...input, runtime: CLAUDE_BRIDGE_RUNTIME }); } -/** Runs a descriptor-defined bridge using the unchanged protocol-v1 stream. */ +/** + * Runs a descriptor-defined bridge using the unchanged protocol-v1 stream. + * + * @param input - Run parameters, including the runtime descriptor. + * @returns An `AsyncIterable` over raw SDK messages. + */ export function runDockerBridge( input: RunDockerBridgeInput, ): AsyncIterable { @@ -631,7 +616,8 @@ async function* createBridgeIterator( const abortController = new AbortController(); /** - * + * Cancels the in-container docker exec and wakes the consumer when + * the caller's signal fires. */ const cancel = (): void => { abortController.abort(); @@ -782,6 +768,7 @@ async function waitForBridgeEvent(holder: WakerHolder): Promise { * @param containerName - Container to probe. * @param bridgeConfig - Normalized bridge config (for the node/npm * command overrides). + * @param runtime - Runtime descriptor providing the Node floor and id. */ async function preflightNodeAndNpm( runner: DockerCommandRunner, @@ -824,6 +811,7 @@ async function preflightNodeAndNpm( * @param command - Binary path or PATH-resolved name. * @param friendlyName - Display name used in the error message. * @param guidance - Remediation hint included in the error message. + * @returns The captured `--version` stdout. */ async function preflightBinary( runner: DockerCommandRunner, @@ -877,7 +865,13 @@ function buildBridgePackageJson( )}\n`; } -/** Compares dotted Node versions without a semver runtime dependency. */ +/** + * Compares dotted Node versions without a semver runtime dependency. + * + * @param actualOutput - Raw `node --version` output. + * @param minimum - Dotted minimum version (e.g. `22.19.0`). + * @returns Whether the actual version satisfies the minimum. + */ function isVersionAtLeast(actualOutput: string, minimum: string): boolean { const match = /v?(\d+)\.(\d+)\.(\d+)/u.exec(actualOutput.trim()); if (!match) { diff --git a/src/sandbox/types.ts b/src/sandbox/types.ts index 90b25ba..559971d 100644 --- a/src/sandbox/types.ts +++ b/src/sandbox/types.ts @@ -97,8 +97,9 @@ export type DockerSandboxBridgeConfig = { * When the runner should remove the sandbox container after a run. * * - `always` Remove the container regardless of outcome. - * - `on-success` Keep the container when the run errors so it can be - * inspected. Kept containers retain their environment variables, which + * - `on-success` Keep the container unless the run completed with a + * successful result, so failed or interrupted runs can be inspected. + * Kept containers retain their environment variables, which * may include forwarded secrets. See the security note on * {@link DockerSandboxConfig.envFile}. * - `never` Always keep the container; the caller is responsible for diff --git a/src/telemetry/controller.test.ts b/src/telemetry/controller.test.ts index 2998873..7825dc9 100644 --- a/src/telemetry/controller.test.ts +++ b/src/telemetry/controller.test.ts @@ -113,6 +113,9 @@ const createMockAdapter = ( const runCalls: unknown[] = []; const adapter: ProviderAdapter = { name: 'mock', + // Mirror the Claude adapter's isolated-settings default so option-merging + // assertions exercise the shape a real adapter supplies. + defaultOptions: { settingSources: [] }, /** * Mock adapter run generator. * @@ -238,6 +241,7 @@ describe('telemetry lifecycle', () => { const runCalls: unknown[] = []; const adapter: ProviderAdapter = { name: 'bigint-prompt', + defaultOptions: { settingSources: [] }, /** * Captures the generic prompt without serializing it. * diff --git a/src/telemetry/message-handler.test.ts b/src/telemetry/message-handler.test.ts index 431fcc2..47004ca 100644 --- a/src/telemetry/message-handler.test.ts +++ b/src/telemetry/message-handler.test.ts @@ -780,6 +780,17 @@ describe('createMessageHandler', () => { expect(sessionSpan?.end).toHaveBeenCalledOnce(); }); + it('recordError keeps DOMExceptions finalizable and clears the session span', () => { + const handler = createHandler(); + handler.handleMessage({ type: 'init', sessionId: 'session-1' }); + + handler.recordError(new DOMException('aborted', 'AbortError')); + handler.finalizePendingTools(); + + expect(() => handler.finalizeSessionSpan()).not.toThrow(); + expect(handler.getState().sessionSpan).toBeUndefined(); + }); + it('recordError redacts error output when redact is enabled', () => { const handler = createHandler({ redact: true }); handler.handleMessage({ type: 'init', sessionId: 'session-1' }); diff --git a/src/types.ts b/src/types.ts index 8491ed6..35acb98 100644 --- a/src/types.ts +++ b/src/types.ts @@ -294,7 +294,7 @@ export type ProviderAdapter< > = { /** Identifier for the provider (e.g. 'claude'). */ name: string; - /** Provider defaults. Omitted retains legacy isolated Claude settings. */ + /** Provider defaults merged under per-run options. Omitted supplies nothing. */ defaultOptions?: Partial; /** Executes an agent run and yields normalized messages. */ run: (config: RunConfig) => AsyncIterable; From 45a955be5b6bb993e0d09a544e7748cfdf3b5d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Mon, 7 Sep 2026 21:28:02 +0100 Subject: [PATCH 3/4] fix: make coverage deterministic and harden bridge message validation - Cover the credential scrubber sort comparator with an explicit multi-value test: it previously only ran when the ambient environment contained two or more credential-like variables, which is true on developer machines but not on CI runners, dropping functions coverage to 97.97% and failing the 98.31% threshold. - Cover the Claude adapter getRunMetadata used for telemetry. - Require toolCall input presence when validating pi bridge messages so consumers never see ToolCall objects without their required field. - Document that runAgent/judge signal cancellation is pi-only. --- README.md | 2 ++ src/adapters/claude-adapter.test.ts | 11 +++++++++++ src/adapters/pi-adapter.ts | 3 ++- src/credential-redactor.test.ts | 10 ++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c51ad7d..a9e705d 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ Every pi run uses a fresh in-memory session. Settings, extensions, skills, conte Pi emits the same normalized messages and telemetry as Claude. Reaching `maxTurns` produces `result.success: false`; cancellation or a throwing `onMessage` callback returns a partial result and stops active work. Cost is omitted when pricing for consumed tokens is unknown. Explicit zero prices report zero. +`runAgent({ signal })` and `judge(..., { signal })` cancel only pi runs. The Claude adapter does not observe `signal` yet, so passing one there is a silent no-op. + ### Pi sandbox and judge behavior Docker installs pi 0.83.0 and copies the shared pi execution modules into the container. `bridge.sdkVersion`, if supplied, must match. With `bridge.install: false`, the runner performs no version check, so make sure the bridge directory already contains a compatible install. The Node version check applies inside the container, so a Node 20 host can still launch a supported pi container. diff --git a/src/adapters/claude-adapter.test.ts b/src/adapters/claude-adapter.test.ts index d4d181f..58fabe1 100644 --- a/src/adapters/claude-adapter.test.ts +++ b/src/adapters/claude-adapter.test.ts @@ -684,6 +684,17 @@ describe('createClaudeAdapter', () => { expect(adapter.defaultOptions).toStrictEqual({ settingSources: [] }); }); + it('describes runs for telemetry metadata', () => { + const adapter = createClaudeAdapter(); + expect(adapter.getRunMetadata?.({})).toStrictEqual({ + model: 'unknown', + maxTurns: 0, + }); + expect( + adapter.getRunMetadata?.({ model: 'claude-x', maxTurns: 3 }), + ).toStrictEqual({ model: 'claude-x', maxTurns: 3 }); + }); + it('runs in-process and bypasses the sandbox machinery when no sandbox is set', async () => { claudeMocks.query.mockReturnValueOnce(yieldMessages([])); diff --git a/src/adapters/pi-adapter.ts b/src/adapters/pi-adapter.ts index bb7103d..ff3f1b9 100644 --- a/src/adapters/pi-adapter.ts +++ b/src/adapters/pi-adapter.ts @@ -238,7 +238,8 @@ function validatePiBridgeMessage(raw: unknown): AgentMessage { 'id' in call && typeof call.id === 'string' && 'name' in call && - typeof call.name === 'string', + typeof call.name === 'string' && + 'input' in call, ) && message.usage !== null && typeof message.usage === 'object' && diff --git a/src/credential-redactor.test.ts b/src/credential-redactor.test.ts index 8f9546a..965eebf 100644 --- a/src/credential-redactor.test.ts +++ b/src/credential-redactor.test.ts @@ -21,6 +21,16 @@ describe('credential redaction', () => { ); expect(createCredentialScrubber({})('unchanged')).toBe('unchanged'); }); + + it('scrubs multiple credential values, longest first', () => { + const scrub = createCredentialScrubber({ + LITELLM_API_KEY: 'sk-two-short-key', + OTHER_API_KEY: 'sk-two-longer-credential-value', + }); + expect(scrub('sk-two-longer-credential-value sk-two-short-key')).toBe( + '[REDACTED] [REDACTED]', + ); + }); it('copies errors, stacks, nested causes, and cycles without losing error identity', () => { const cause = new Error('sk-test-key cause'); const error = new DockerSandboxError('sk-test-key failure', { cause }); From ca31f8eac50023d48046362f79ce9c4b6dfb7746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Mon, 7 Sep 2026 21:40:59 +0100 Subject: [PATCH 4/4] chore: re-trigger Socket scan after dependency triage