diff --git a/CHANGELOG.md b/CHANGELOG.md index f32f4c9..4dc9724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ 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. + +### Changed + +- Apply the `on-success` Docker cleanup policy uniformly: adapters keep the container unless the run completed with a successful result. +- **Breaking for custom Claude-backed adapters:** the runner no longer injects `settingSources: []`. Add `defaultOptions: { settingSources: [] }` to custom adapters to preserve SDK-settings isolation. The built-in Claude adapter keeps this default. +- Reject judge `queryOptions` for non-Claude adapters without `runStructured()` instead of forwarding provider-native options to the legacy Claude judge. Use a separate Claude runner for customized Claude judging. +- Drop cumulative output from pi progress messages, coalesce adjacent queued updates for the same tool call, and omit heartbeats from judge transcripts. + +### Fixed + +- Scrub exact environment values of at least eight characters whose names match recognized sensitive fragments from pi messages and errors, Docker bridge execution errors, and judge inputs. Pi message telemetry receives scrubbed output; arbitrary names, short or encoded values, and task prompt telemetry are not covered by this heuristic. +- Share sensitive-name fragments with key-based redaction, including wallet SRPs, mnemonics, and passphrases; preserve system error fields and aggregate causes when scrubbing. +- Preserve explicitly mapped pi Docker working directories instead of replacing them with the container default. +- Preserve abort identity when Docker client close wins the event race so cancelled startup still attempts container removal. +- Keep scrubbed DOMExceptions as real DOMExceptions so telemetry span finalization cannot throw on abort errors. + ## [0.3.1] ### Fixed diff --git a/README.md b/README.md index 7fa9218..dbabd6c 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. @@ -79,7 +79,86 @@ 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 + +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. + +`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. + +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 remains unchanged. Custom adapters without `runStructured()` can still use the legacy Claude judge with default options, but supplying `queryOptions` now throws rather than forwarding provider-native options to Claude. Use a separate Claude runner to customize that fallback, or implement `runStructured()` for provider-owned judging. Custom adapters can also implement `getStructuredDefaults()` and `getRunMetadata()`. + +Migration for custom Claude-backed adapters: the runner no longer injects `settingSources: []`. Add `defaultOptions: { settingSources: [] }` to your adapter to preserve SDK-settings isolation. Without it, the SDK may load user/project settings and hooks. Adapter defaults are merged under runner defaults and per-run options; the built-in Claude adapter retains its isolated 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 @@ -203,6 +282,12 @@ agent-runner (root session span) When `redact: true` is set on telemetry config, prompts and tool I/O are replaced with `[REDACTED]` in spans. Sensitive keys (`password`, `secret`, `srp`, `mnemonic`, `privatekey`, `token`, `apikey`, etc.) are recursively redacted from tool inputs regardless of the redact flag. +#### Automatic credential scrubbing + +Pi messages and errors, Docker bridge execution errors, and judge inputs scrub exact environment values of at least eight characters when their variable names contain a known sensitive fragment. These include the sensitive-key list above plus `key`, `pass`, `auth`, `bearer`, and `cookie`, covering names such as `AI_CLI_SRP`, `SEED_PHRASE`, and `MNEMONIC`. Pi output scrubbing occurs before message callbacks, result collection, and message telemetry. + +This is a name-based heuristic, not a guarantee that all secrets are removed. Short values, unrecognized variable names, transformed/encoded values, and secrets unavailable to the scrubbing process are not covered. The telemetry redactor below remains useful for application-specific values and span inputs, including task prompts. Key-based redaction hides values under sensitive object keys; automatic scrubbing removes recognized environment values even inside free-form output. + #### Value-level redaction For full-fidelity traces that still strip secret values, provide a `redactor` function. It receives each string leaf of span input/output (the prompt, generation input/output, tool inputs, tool results, and the final output) and returns a scrubbed string. Structure is preserved: for tool inputs the redactor is applied recursively to string leaves only, so the surrounding command and argument shape stay intact. @@ -277,7 +362,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 @@ -347,7 +432,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/eslint.config.mjs b/eslint.config.mjs index e4f731e..cc2a46d 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/adapters/pi/*.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.test.ts b/src/adapters/claude-adapter.test.ts index f6ff38c..58fabe1 100644 --- a/src/adapters/claude-adapter.test.ts +++ b/src/adapters/claude-adapter.test.ts @@ -679,6 +679,22 @@ describe('createClaudeAdapter', () => { expect(adapter.capabilities).toStrictEqual({ sandboxes: ['docker'] }); }); + it('defaults to isolated settingSources', () => { + const adapter = 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([])); @@ -855,7 +871,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 +888,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 a3a2627..dc63c6f 100644 --- a/src/adapters/claude-adapter.ts +++ b/src/adapters/claude-adapter.ts @@ -7,6 +7,7 @@ import { normalizeDockerSandboxConfig, prepareDockerSandboxRequest, } from '../sandbox/docker/options.js'; +import { shouldCloseSandbox } from '../sandbox/docker/utils.js'; import type { AgentMessage, DockerSandboxConfig, @@ -35,7 +36,20 @@ 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. + * + * @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. * @@ -114,6 +128,7 @@ async function* runWithSandbox( let bridgeError: unknown; let bridgeFailed = false; let bridgeCompleted = false; + let resultSucceeded = false; let closeError: unknown; let closeFailed = false; try { @@ -124,16 +139,24 @@ 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; bridgeFailed = true; } finally { - const consumerAborted = !bridgeCompleted && !bridgeFailed; if ( - consumerAborted || - shouldCloseContainer(normalized.cleanup, bridgeCompleted) + shouldCloseSandbox(normalized.cleanup, { + completed: bridgeCompleted, + failed: bridgeFailed, + succeeded: resultSucceeded, + aborted: false, + }) ) { try { await handle.close(); @@ -153,36 +176,3 @@ async function* runWithSandbox( throw closeError; } } - -/** - * 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. - * - * This function ONLY governs the non-abort cases. When the consumer - * aborts iteration early (`break`, `iterator.return()`, or an - * exception thrown inside the consumer's `for await` body), the - * caller closes the container unconditionally regardless of the - * cleanup policy, because the in-container process may still be - * running. - * - * @param cleanup - Cleanup policy from the normalized sandbox config. - * @param succeeded - Whether the bridge completed naturally without - * throwing and without being abandoned mid-stream. - * @returns Whether to call `handle.close()`. - */ -function shouldCloseContainer( - cleanup: 'always' | 'on-success' | 'never', - succeeded: boolean, -): boolean { - switch (cleanup) { - case 'always': - return true; - case 'on-success': - return succeeded; - case 'never': - return false; - default: - return true; - } -} diff --git a/src/adapters/pi/pi-adapter.test.ts b/src/adapters/pi/pi-adapter.test.ts new file mode 100644 index 0000000..bad805b --- /dev/null +++ b/src/adapters/pi/pi-adapter.test.ts @@ -0,0 +1,350 @@ +/* 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', () => ({ + 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 inherits the container 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, + ).toBeUndefined(); + }); + it('preserves mapped package cwd when a container workdir is set', async () => { + const result = await setup().runAgent({ + prompt: 'hi', + options: { cwd: '/repo/packages/a' }, + sandbox: { + type: 'docker', + workspace: { hostPath: '/repo' }, + workdir: '/workspace', + }, + }); + expect(result.error).toBeUndefined(); + expect(mocks.bridge.mock.calls.at(-1)?.[0].request.options.cwd).toBe( + '/workspace/packages/a', + ); + }); + it('inherits an explicit workdir with no workspace or cwd', async () => { + expect((await setup().runAgent({ prompt: 'hi' })).error).toBeUndefined(); + expect(mocks.create.mock.calls.at(-1)?.[0].workdir).toBe('/task'); + expect( + mocks.bridge.mock.calls.at(-1)?.[0].request.options.cwd, + ).toBeUndefined(); + }); + 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' } }, + config: { env: { LITELLM_API_KEY: 'key' }, workdir: '/task' }, + }); + 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/pi-adapter.ts b/src/adapters/pi/pi-adapter.ts new file mode 100644 index 0000000..ccbfbd1 --- /dev/null +++ b/src/adapters/pi/pi-adapter.ts @@ -0,0 +1,281 @@ +import { + createCredentialScrubber, + scrubCredentials, +} from '../../credential-redactor.js'; +import { runDockerBridge } 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 { shouldCloseSandbox } from '../../sandbox/docker/utils.js'; +import type { AgentMessage, ProviderAdapter, RunConfig } from '../../types.js'; +import { PI_BRIDGE_RUNTIME } from './pi-bridge-runtime.js'; +import { + runPiSession, + validatePiOptions, + PI_FORWARD_ENV, +} from './pi-runtime.js'; +import type { PiStructuredOutput } from './pi-runtime.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, + }); + 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 ( + shouldCloseSandbox(normalized.cleanup, { + completed, + failed, + succeeded, + aborted: config.signal?.aborted ?? false, + }) + ) { + 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' && + 'input' in call, + ) && + 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/pi-bridge-runtime.ts b/src/adapters/pi/pi-bridge-runtime.ts new file mode 100644 index 0000000..272df2a --- /dev/null +++ b/src/adapters/pi/pi-bridge-runtime.ts @@ -0,0 +1,25 @@ +import type { DockerBridgeRuntimeDescriptor } from '../../sandbox/docker/bridge.js'; +import { PI_SDK_VERSION } from './pi-runtime.js'; + +/** + * 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', + hostRoot: 'dist', + files: [ + 'adapters/pi/pi-runtime.mjs', + 'credential-redactor.mjs', + 'message-parser.mjs', + ], + /** + * Returns the architecture-locked Pi version. + * + * @returns The locked Pi SDK version. + */ + resolveVersion: () => PI_SDK_VERSION, + minNodeVersion: '22.19.0', + exactVersion: PI_SDK_VERSION, +}; diff --git a/src/adapters/pi/pi-runtime-lifecycle.test.ts b/src/adapters/pi/pi-runtime-lifecycle.test.ts new file mode 100644 index 0000000..d994d2e --- /dev/null +++ b/src/adapters/pi/pi-runtime-lifecycle.test.ts @@ -0,0 +1,406 @@ +/* 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 type { AgentMessage } from '../../types.js'; +import { assertPiNodeVersion, runPiSession } from './pi-runtime.js'; +import type { PiSessionFactory } from './pi-runtime.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('coalesces queued heartbeats by call id without retaining output or arguments', async () => { + const fake = fakeSession((emit) => { + for (const toolCallId of ['first', 'second']) { + emit({ + type: 'tool_execution_start', + toolCallId, + toolName: 'bash', + args: {}, + }); + for (let index = 0; index < 10001; index += 1) { + emit({ + type: 'tool_execution_update', + toolCallId, + toolName: 'bash', + args: { command: 'large command' }, + partialResult: { + content: [{ type: 'text', text: 'x'.repeat(50 * 1024) }], + }, + }); + } + if (toolCallId === 'first') { + emit(assistant('stop', 'between calls')); + } + } + emit({ + type: 'tool_execution_end', + toolCallId: 'second', + toolName: 'bash', + result: { content: [{ type: 'text', text: 'final output' }] }, + isError: false, + }); + emit(assistant()); + }); + const messages = await collect( + runPiSession('hi', { model: 'x' }, undefined, undefined, fake.factory), + ); + const progress = messages.filter( + (message) => message.type === 'tool_progress', + ); + expect(progress).toHaveLength(2); + expect(progress.every((message) => message.raw === undefined)).toBe(true); + expect(messages.map((message) => message.type)).toStrictEqual([ + 'init', + 'tool_progress', + 'generation', + 'tool_progress', + 'tool_result', + 'generation', + 'result', + ]); + expect( + messages.find((message) => message.type === 'tool_result'), + ).toMatchObject({ content: 'final output' }); + expect(fake.abort).not.toHaveBeenCalled(); + }); + it('does not coalesce adjacent progress from different calls with the same tool name', async () => { + const fake = fakeSession((emit) => { + for (const toolCallId of ['first', 'second']) { + emit({ + type: 'tool_execution_start', + toolCallId, + toolName: 'bash', + args: {}, + }); + emit({ + type: 'tool_execution_update', + toolCallId, + toolName: 'bash', + args: {}, + partialResult: {}, + }); + } + emit(assistant()); + }); + const messages = await collect( + runPiSession('hi', { model: 'x' }, undefined, undefined, fake.factory), + ); + expect( + messages.filter((message) => message.type === 'tool_progress'), + ).toHaveLength(2); + }); + 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/adapters/pi/pi-runtime-setup.test.ts b/src/adapters/pi/pi-runtime-setup.test.ts new file mode 100644 index 0000000..8f9c40c --- /dev/null +++ b/src/adapters/pi/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/adapters/pi/pi-runtime.test.ts b/src/adapters/pi/pi-runtime.test.ts new file mode 100644 index 0000000..0c38187 --- /dev/null +++ b/src/adapters/pi/pi-runtime.test.ts @@ -0,0 +1,355 @@ +import { createServer } from 'node:http'; +import type { Server } from 'node:http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createAgentRunner } from '../../runner.js'; +import type { AgentMessage } from '../../types.js'; +import { createPiAdapter } from './pi-adapter.js'; +import { + assertPiNodeVersion, + runPiSession, + validatePiOptions, +} from './pi-runtime.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-key', + 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-key'); + }); + 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-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-key'); + }); + + 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-key')); + 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-key' }; + 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-key'); + }); + + 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/adapters/pi/pi-runtime.ts b/src/adapters/pi/pi-runtime.ts new file mode 100644 index 0000000..2382f8a --- /dev/null +++ b/src/adapters/pi/pi-runtime.ts @@ -0,0 +1,653 @@ +import type { + AgentSession, + AgentSessionEvent, +} from '@earendil-works/pi-coding-agent'; +import path from 'node:path'; + +import { + createCredentialScrubber, + scrubCredentials, +} from '../../credential-redactor.js'; +import type { AgentMessage, AgentResultMessage } from '../../types.js'; +import type { PiQueryOptions, PiToolName } from './pi-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 queuedProgressId: string | undefined; + 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. + * @param toolCallId - Identity for coalescing adjacent progress updates. + */ + const push = (message: AgentMessage, toolCallId?: string): void => { + if ( + message.type === 'tool_progress' && + queue.at(-1)?.type === 'tool_progress' && + queuedProgressId === toolCallId + ) { + queue[queue.length - 1] = scrubCredentials(message, scrub); + } else if (queue.length >= 10000) { + taskError ??= new Error('Pi event queue exceeded 10000 messages.'); + cancel(); + } else { + queue.push(scrubCredentials(message, scrub)); + queuedProgressId = toolCallId; + } + 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) { + // Updates contain cumulative output snapshots. Only the terminal + // tool_result retains output; progress is a lightweight heartbeat. + push( + { + type: 'tool_progress', + toolName: event.toolName, + elapsedSeconds: (Date.now() - start) / 1000, + }, + event.toolCallId, + ); + } + } 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/adapters/pi/pi-types.ts b/src/adapters/pi/pi-types.ts new file mode 100644 index 0000000..2ddeb0c --- /dev/null +++ b/src/adapters/pi/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..2096030 --- /dev/null +++ b/src/credential-redactor.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { + createCredentialScrubber, + scrubCredentials, +} from './credential-redactor.js'; +import { DockerSandboxError } from './errors.js'; +import { SENSITIVE_KEYS } from './message-parser.js'; + +describe('credential redaction', () => { + it('redacts credential values of at least eight characters and keeps shorter values', () => { + const scrub = createCredentialScrubber({ + LITELLM_API_KEY: 'sk-secret-value', + TOKENIZERS_PARALLELISM: 'false', + PASSWORD_MIN_LENGTH: '8', + TOKEN: 'short', + OTHER: 'visible', + EMPTY_KEY: '', + }); + expect(scrub('sk-secret-value false 8 short visible')).toBe( + '[REDACTED] false 8 short visible', + ); + expect(createCredentialScrubber({})('unchanged')).toBe('unchanged'); + }); + + it.each([...SENSITIVE_KEYS, 'pass', 'auth', 'bearer', 'cookie'])( + 'scrubs environment values whose names contain %s', + (fragment) => { + const scrub = createCredentialScrubber({ + [`AI_CLI_${fragment.toUpperCase()}`]: 'wallet-secret-value', + }); + expect(scrub('output wallet-secret-value')).toBe('output [REDACTED]'); + }, + ); + it('escapes regex syntax in credential values', () => { + const scrub = createCredentialScrubber({ API_KEY: 'sk-a.b+c(d)' }); + expect(scrub('sk-a.b+c(d) sk-axbcccd')).toBe('[REDACTED] sk-axbcccd'); + }); + it('preserves system error fields while scrubbing extra properties and cycles', () => { + const error = Object.assign(new Error('connection refused'), { + code: 'ECONNREFUSED', + errno: -61, + syscall: 'connect', + address: '127.0.0.1', + port: 4000, + requestUrl: 'https://proxy.test/secret-key', + }); + Object.assign(error, { self: error }); + const scrubbed = scrubCredentials( + error, + createCredentialScrubber({ API_KEY: 'secret-key' }), + ); + expect(scrubbed).toMatchObject({ + code: 'ECONNREFUSED', + errno: -61, + syscall: 'connect', + address: '127.0.0.1', + port: 4000, + requestUrl: 'https://proxy.test/[REDACTED]', + self: scrubbed, + }); + expect(error.requestUrl).toContain('secret-key'); + }); + it('preserves aggregate errors and shared references', () => { + const cause = new Error('secret-key'); + const error = new AggregateError([cause], 'secret-key aggregate', { + cause, + }); + error.errors.push(error); + const scrubbed = scrubCredentials( + error, + createCredentialScrubber({ API_KEY: 'secret-key' }), + ); + expect(scrubbed).toBeInstanceOf(AggregateError); + expect(scrubbed.message).toBe('[REDACTED] aggregate'); + expect(scrubbed.errors[0]).toBe(scrubbed.cause); + expect(scrubbed.errors[0].message).toBe('[REDACTED]'); + expect(scrubbed.errors[1]).toBe(scrubbed); + }); + 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 }); + cause.cause = error; + const scrubbed = scrubCredentials( + error, + 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-key'); + expect((scrubbed.cause as Error).message).toBe('[REDACTED] cause'); + expect((scrubbed.cause as Error).cause).toBe(scrubbed); + expect(error.message).toBe('sk-test-key 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: 'secret-key' }); + const error = scrubCredentials( + new DOMException('secret-key', 'AbortError'), + scrub, + ); + expect(error.name).toBe('AbortError'); + expect(error.message).toBe('[REDACTED]'); + 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 new file mode 100644 index 0000000..f6745c7 --- /dev/null +++ b/src/credential-redactor.ts @@ -0,0 +1,132 @@ +import { SENSITIVE_KEYS } from './message-parser.js'; + +const CREDENTIAL_KEYS = [ + ...SENSITIVE_KEYS, + 'key', + 'pass', + 'auth', + 'bearer', + 'cookie', +]; + +/** + * 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. + */ +export function createCredentialScrubber( + env: Record = process.env, +): (text: string) => string { + const values = [ + ...new Set( + Object.entries(env) + .filter( + ([key, value]) => + CREDENTIAL_KEYS.some((fragment) => + key.toLowerCase().includes(fragment), + ) && + value !== undefined && + value.length >= MIN_CREDENTIAL_LENGTH, + ) + .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 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)); + 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); + } + if (value instanceof AggregateError) { + Object.defineProperty(result, 'errors', { + value: scrubCredentials(value.errors, scrub, seen), + configurable: true, + writable: true, + }); + } + for (const [key, entry] of Object.entries(value)) { + if ( + ['message', 'name', 'stack', 'cause'].includes(key) || + (key === 'errors' && value instanceof AggregateError) + ) { + continue; + } + Object.defineProperty(result, key, { + value: scrubCredentials(entry, scrub, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + 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..8a9b549 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,16 @@ +export { createPiAdapter } from './adapters/pi/pi-adapter.js'; +export { createClaudeAdapter } from './adapters/claude-adapter.js'; +export type { + PiQueryOptions, + PiToolName, + PiModelCost, +} from './adapters/pi/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.test.ts b/src/judge/executor.test.ts index c0d042d..508d2e4 100644 --- a/src/judge/executor.test.ts +++ b/src/judge/executor.test.ts @@ -176,7 +176,7 @@ describe('executeJudge', () => { }); }); - it('formats all message types in the transcript prompt', async () => { + it('formats evidence but omits progress heartbeats from the transcript prompt', async () => { const judgeOutput = JSON.stringify({ quality: 7, accuracy: 4, @@ -204,7 +204,8 @@ describe('executeJudge', () => { expect(prompt).toContain('file.txt'); expect(prompt).toContain('[ERROR] not found'); expect(prompt).toContain('status'); - expect(prompt).toContain('Bash (3s)'); + expect(prompt).not.toContain('Bash (3s)'); + expect(prompt).not.toContain('tool_progress'); expect(prompt).toContain('Ran ls command'); expect(prompt).toContain('rate_limited'); expect(prompt).toContain('success:'); diff --git a/src/judge/executor.ts b/src/judge/executor.ts index b74de05..ff898bc 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, + ), }); } @@ -210,6 +277,7 @@ export async function executeJudge( */ function formatTranscript(runResult: AgentRunResult): string { return runResult.messages + .filter((message) => message.type !== 'tool_progress') .map( (message, index) => `[${String(index)}] ${message.type}: ${summarizeMessage(message)}`, @@ -245,8 +313,6 @@ function summarizeMessage(message: AgentMessage): string { : `error: ${escapeXml(message.error ?? 'unknown')}`; case 'system': return message.subtype; - case 'tool_progress': - return `${message.toolName} (${String(message.elapsedSeconds)}s)`; case 'tool_use_summary': return escapeXml(message.summary); case 'rate_limit': 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/message-parser.ts b/src/message-parser.ts index 5a9890c..288df40 100644 --- a/src/message-parser.ts +++ b/src/message-parser.ts @@ -1,4 +1,5 @@ -const SENSITIVE_KEYS = [ +/** Shared sensitive-name fragments for key and environment-value redaction. */ +export const SENSITIVE_KEYS = [ 'password', 'passphrase', 'secret', diff --git a/src/runner.test.ts b/src/runner.test.ts index e8f4661..0aa1c05 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({ @@ -553,6 +571,36 @@ describe('createAgentRunner', () => { scoringMocks.postScores.mockResolvedValue(undefined); }); + it('rejects provider-native options before the legacy Claude fallback', async () => { + const adapter: ProviderAdapter<{ nativeOption: string }, string> = { + name: 'custom', + async *run() { + yield resultMessage; + }, + }; + const runner = createAgentRunner({ adapter }); + await expect( + runner.judge(judgeRunResult, { + ...judgeConfig, + queryOptions: { nativeOption: 'not-Claude' }, + }), + ).rejects.toThrow('must implement runStructured()'); + expect(judgeMocks.executeJudge).not.toHaveBeenCalled(); + }); + it('continues to accept Claude judge options on the Claude adapter', async () => { + const runner = createAgentRunner(); + await runner.judge(judgeRunResult, { + ...judgeConfig, + queryOptions: { model: 'claude-test' }, + }); + expect(judgeMocks.executeJudge).toHaveBeenCalledWith( + judgeRunResult, + expect.objectContaining({ queryOptions: { model: 'claude-test' } }), + undefined, + undefined, + undefined, + ); + }); it('returns the judge evaluation result', async () => { const { adapter } = createMockAdapter(); const runner = createAgentRunner({ adapter }); @@ -565,6 +613,7 @@ describe('createAgentRunner', () => { judgeConfig, undefined, undefined, + undefined, ); }); @@ -580,6 +629,7 @@ describe('createAgentRunner', () => { judgeConfig, context, undefined, + undefined, ); }); diff --git a/src/runner.ts b/src/runner.ts index 7c12a50..bb224eb 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -1,6 +1,7 @@ import { createClaudeAdapter } from './adapters/claude-adapter.js'; import { AgentRunnerError, + JudgeError, MessageHandlerError, SandboxConfigurationError, } from './errors.js'; @@ -22,20 +23,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 +72,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 +172,34 @@ 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, ...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,35 @@ export function createAgentRunner(config: AgentRunnerConfig = {}): AgentRunner { */ judge: async ( runResult: AgentRunResult, - judgeConfig: JudgeConfig, + judgeConfig: JudgeConfig, context?: JudgeContext, options?: JudgeOptions, ): Promise => { + if ( + adapter.runStructured === undefined && + adapter.name !== 'claude' && + judgeConfig.queryOptions !== undefined + ) { + throw new JudgeError( + `Adapter \`${adapter.name}\` must implement runStructured() to accept judge queryOptions. Use a separate Claude runner for Claude judge options.`, + ); + } + 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..1c99754 --- /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('../../adapters/pi/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..ee57da1 --- /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-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-secret failure'); + }, + ); + expect(result.code).toBe(1); + expect(JSON.stringify(result.frames)).not.toContain('short-secret'); + } 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..0e8ae34 --- /dev/null +++ b/src/sandbox/container/pi-bridge.ts @@ -0,0 +1,120 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { runPiSession } from '../../adapters/pi/pi-runtime.js'; +import type { PiStructuredOutput } from '../../adapters/pi/pi-runtime.js'; +import { + createCredentialScrubber, + scrubCredentials, +} from '../../credential-redactor.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..2ecfc66 100644 --- a/src/sandbox/docker/bridge.test.ts +++ b/src/sandbox/docker/bridge.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; +import { PI_BRIDGE_RUNTIME } from '../../adapters/pi/pi-bridge-runtime.js'; +import { PI_SDK_VERSION } from '../../adapters/pi/pi-runtime.js'; import { DockerSandboxError, DockerSandboxProtocolError, @@ -11,14 +13,16 @@ import { } from './bridge-protocol.js'; import { BRIDGE_SDK_PACKAGE_NAME, + CLAUDE_BRIDGE_RUNTIME, DEFAULT_REMOTE_BRIDGE_DIR, DEFAULT_REMOTE_BRIDGE_FILE, MAX_BRIDGE_LINE_LENGTH, MAX_BRIDGE_QUEUE_SIZE, bootstrapDockerClaudeBridge, - resolveBridgeSdkVersion, + bootstrapDockerBridge, resolveDefaultBridgeHostPath, runDockerClaudeBridge, + runDockerBridge, } from './bridge.js'; import type { DockerCommandResult, @@ -166,59 +170,97 @@ describe('resolveDefaultBridgeHostPath', () => { const resolved = resolveDefaultBridgeHostPath(); expect(resolved).toMatch(/[/\\]container[/\\]claude-bridge\.mjs$/u); }); + + it('resolves the Claude SDK version from the host installation', () => { + expect(CLAUDE_BRIDGE_RUNTIME.resolveVersion()).toMatch(/^\d+\./u); + }); }); -describe('resolveBridgeSdkVersion', () => { - it('returns the explicit override when provided', () => { - const config = makeConfig({ sdkVersion: '1.2.3' }); - expect( - resolveBridgeSdkVersion({ - config, - /** - * - */ - readHostSdkVersion: () => '9.9.9', +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_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', }), - ).toBe('1.2.3'); - }); + ).rejects.toThrow(/requires Node\.js >=22\.19\.0/u); - it('falls back to the host-installed SDK version', () => { - const config = makeConfig(); - expect( - resolveBridgeSdkVersion({ - config, - /** - * - */ - readHostSdkVersion: () => '0.2.138', + 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', }), - ).toBe('0.2.138'); + ).rejects.toThrow(/requires .*@0\.83\.0/u); }); - 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('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_SDK_VERSION}`, + ); }); - it('throws when neither an override nor the host SDK can be located', () => { - expect(() => - resolveBridgeSdkVersion({ + 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(), - /** - * - */ - readHostSdkVersion: () => undefined, + commandRunner: runner, }), - ).toThrow(/Could not determine .* version/u); + ).rejects.toThrow( + /Failed to copy runtime file adapters\/pi\/pi-runtime\.mjs/u, + ); }); -}); -describe('bootstrapDockerClaudeBridge', () => { it('executes preflight, mkdir, cp, package.json, and npm install in order', async () => { const { runner, calls } = makeStubRunner(); @@ -559,6 +601,68 @@ 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(4); + }, + ); + 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', + hostRoot: 'dist', + 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}`, @@ -593,6 +697,58 @@ describe('runDockerClaudeBridge', () => { ]); } + it.each(['stderr', 'remote error', 'remote stack', 'rejection'])( + 'scrubs configured credentials from Claude bridge %s', + async (source) => { + const secret = 'wallet-credential-value'; + const config = makeConfig(); + config.env = { AI_CLI_SRP: secret }; + const lines = source.startsWith('remote') + ? [ + serializeBridgeEvent({ + version: BRIDGE_PROTOCOL_VERSION, + type: 'error', + error: { + name: `RemoteError ${secret}`, + message: `failed ${secret}`, + ...(source === 'remote stack' + ? { stack: `stack ${secret}` } + : {}), + }, + }), + ] + : []; + const { runner } = + source === 'rejection' + ? makeStubRunner([ + () => { + throw new Error(`failed ${secret}`, { + cause: new Error(secret), + }); + }, + ]) + : makeStreamingRunner(lines, 1, `stderr ${secret}`); + const error = await iterate( + runDockerClaudeBridge({ + sandbox: makeHandle('ct-secrets'), + config, + commandRunner: runner, + request: { prompt: 'hi', options: {} }, + preparedBridge, + }), + [], + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('[REDACTED]'); + let current = error as Error | undefined; + while (current !== undefined) { + expect(current.message).not.toContain(secret); + expect(current.name).not.toContain(secret); + expect(current.stack).not.toContain(secret); + current = current.cause as Error | undefined; + } + }, + ); it('yields raw SDK messages in order and completes on done', async () => { const lines = [ serializeBridgeEvent({ diff --git a/src/sandbox/docker/bridge.ts b/src/sandbox/docker/bridge.ts index b807d5a..c2d8df4 100644 --- a/src/sandbox/docker/bridge.ts +++ b/src/sandbox/docker/bridge.ts @@ -3,6 +3,10 @@ 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 +61,39 @@ export const MAX_BRIDGE_QUEUE_SIZE = 10_000; */ export const BRIDGE_SDK_PACKAGE_NAME = '@anthropic-ai/claude-agent-sdk'; +/** 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; + /** + * 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. */ + 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, + hostRoot: 'container', + /** Resolves the installed Claude SDK version. */ + resolveVersion: readHostSdkVersion, +}; + /** * Resolves the on-disk path of the compiled container bridge `.mjs` * file shipped with this package. The build emits @@ -69,62 +106,32 @@ export const BRIDGE_SDK_PACKAGE_NAME = '@anthropic-ai/claude-agent-sdk'; * @returns Absolute path to the compiled bridge script. */ export function resolveDefaultBridgeHostPath(): string { - const here = fileURLToPath(import.meta.url); - return path.resolve( - path.dirname(here), - '..', - 'container', - DEFAULT_REMOTE_BRIDGE_FILE, - ); + return resolveBridgeHostPath(CLAUDE_BRIDGE_RUNTIME); } /** - * Input for {@link resolveBridgeSdkVersion}. + * 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 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; -}; +export function resolveBridgeHostPath( + runtime: DockerBridgeRuntimeDescriptor, +): string { + return path.join(resolveHostRoot(runtime.hostRoot), runtime.remoteBridgeFile); +} /** - * Resolves the SDK version the bridge should install inside the - * container. + * Resolves the host directory a runtime's bridge files ship from. * - * Resolution order: - * 1. Explicit `sandbox.bridge.sdkVersion` if provided. - * 2. Host-installed `@anthropic-ai/claude-agent-sdk/package.json`. - * - * 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'] : ['..', '..']), ); } @@ -192,12 +199,21 @@ 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; }; +/** 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 +245,54 @@ 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. + * + * @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 { 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, + ); + const remoteDirectories = new Set([ + path.posix.dirname(remoteBridgePath), + ...(input.runtime.files ?? []).map((file) => + path.posix.dirname(`${remoteBridgeDir}/${file}`), + ), + ]); await runDockerExec( runner, sandbox.containerName, - ['sh', '-lc', `mkdir -p ${shellEscape(remoteBridgeDir)}`], + [ + 'sh', + '-lc', + `mkdir -p ${[...remoteDirectories].map(shellEscape).join(' ')}`, + ], `prepare bridge directory (${remoteBridgeDir})`, ); @@ -260,14 +309,41 @@ export async function bootstrapDockerClaudeBridge( ); } - const sdkVersion = resolveBridgeSdkVersion({ - config: input.config, - ...(input.readHostSdkVersion === undefined - ? {} - : { readHostSdkVersion: input.readHostSdkVersion }), - }); + for (const file of input.runtime.files ?? []) { + 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 pkgJson = buildBridgePackageJson(sdkVersion); + 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(input.runtime.packageName, sdkVersion); try { await runner.run( 'docker', @@ -302,12 +378,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 +436,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 +464,18 @@ 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. + * + * @param input - Run parameters, including the runtime descriptor. + * @returns An `AsyncIterable` over raw SDK messages. + */ +export function runDockerBridge( + input: RunDockerBridgeInput, ): AsyncIterable { /** * AsyncIterator factory wired into the returned AsyncIterable. @@ -401,11 +497,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 +534,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 +603,19 @@ async function* createBridgeIterator( // bridge to finish. 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(); + 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 +628,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 +638,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 +655,7 @@ async function* createBridgeIterator( let doneSeen = false; try { while (true) { + input.signal?.throwIfAborted(); if (parseError !== null) { const errorToThrow: DockerSandboxProtocolError = parseError; throw errorToThrow; @@ -544,12 +667,16 @@ async function* createBridgeIterator( continue; } 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 named = scrubCredentials(event.error.name || 'Error'); + 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 +714,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); } } @@ -628,19 +756,29 @@ 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, 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, @@ -661,6 +799,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, @@ -668,15 +807,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 +830,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 +845,7 @@ function buildBridgePackageJson(sdkVersion: string): string { private: true, type: 'module', dependencies: { - [BRIDGE_SDK_PACKAGE_NAME]: sdkVersion, + [packageName]: sdkVersion, }, }, null, @@ -709,6 +853,29 @@ function buildBridgePackageJson(sdkVersion: string): string { )}\n`; } +/** + * 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) { + 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/command-runner-abort.test.ts b/src/sandbox/docker/command-runner-abort.test.ts new file mode 100644 index 0000000..e77074b --- /dev/null +++ b/src/sandbox/docker/command-runner-abort.test.ts @@ -0,0 +1,41 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; + +import { createDefaultDockerCommandRunner } from './command-runner.js'; + +const mocks = vi.hoisted(() => ({ spawn: vi.fn() })); +vi.mock('node:child_process', () => ({ spawn: mocks.spawn })); + +describe('Docker command cancellation event order', () => { + it.each(['error-first', 'close-first'])( + 'preserves AbortError when %s', + async (order) => { + const child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + stdout: new PassThrough(), + stderr: new PassThrough(), + }); + mocks.spawn.mockReturnValue(child); + const controller = new AbortController(); + const pending = createDefaultDockerCommandRunner().run( + 'docker', + ['run'], + { signal: controller.signal }, + ); + controller.abort(); + const abort = new DOMException('aborted', 'AbortError'); + // eslint-disable-next-line vitest/no-conditional-in-test -- Exercise both process event orders. + if (order === 'error-first') { + child.emit('error', abort); + child.emit('close', null, 'SIGTERM'); + } else { + child.emit('close', null, 'SIGTERM'); + child.emit('error', abort); + } + await expect(pending).rejects.toMatchObject({ + cause: { name: 'AbortError' }, + }); + }, + ); +}); diff --git a/src/sandbox/docker/command-runner.test.ts b/src/sandbox/docker/command-runner.test.ts index 635b98c..2dded04 100644 --- a/src/sandbox/docker/command-runner.test.ts +++ b/src/sandbox/docker/command-runner.test.ts @@ -230,12 +230,7 @@ describe('createDefaultDockerCommandRunner', () => { ); expect(error).toBeInstanceOf(DockerSandboxError); - // Either the `error` event (AbortError) or the `close` signal - // branch can win the race; both produce a sandbox error pointing - // back at the failed spawn or signal termination. - expect((error as Error).message).toMatch( - /Failed to spawn host command|terminated by signal/u, - ); + expect(error).toMatchObject({ cause: { name: 'AbortError' } }); }); it('rejects immediately when the signal is already aborted', async () => { diff --git a/src/sandbox/docker/command-runner.ts b/src/sandbox/docker/command-runner.ts index fa95145..d54f2be 100644 --- a/src/sandbox/docker/command-runner.ts +++ b/src/sandbox/docker/command-runner.ts @@ -333,6 +333,21 @@ async function runWithSpawn( const stderr = stderrAccumulator.getResult(); + if (options?.signal?.aborted) { + // Preserve cancellation identity even if close arrives before the + // spawn AbortError event. Container startup cleanup relies on it. + settle(() => { + reject( + buildSpawnError( + command, + argsArray, + new DOMException('Host command aborted.', 'AbortError'), + ), + ); + }); + return; + } + if (signal !== null || code === null) { // The signal branch covers both `signal !== null` and the rare // case where `code` is null without a signal (process was diff --git a/src/sandbox/docker/lifecycle.test.ts b/src/sandbox/docker/lifecycle.test.ts index 0dcf3a0..6ae6aca 100644 --- a/src/sandbox/docker/lifecycle.test.ts +++ b/src/sandbox/docker/lifecycle.test.ts @@ -664,6 +664,48 @@ describe('createDockerSandbox', () => { }); describe('start failure', () => { + it.each(['always', 'on-success', 'never'] as const)( + 'removes a possibly created container after aborted startup under %s', + async (cleanup) => { + const abort = new DOMException('cancelled', 'AbortError'); + const cause = new DockerSandboxError('spawn aborted', { cause: abort }); + const { runner, calls } = makeStubRunner([ + () => { + throw cause; + }, + () => { + throw new Error('secondary removal failure'); + }, + ]); + const pending = createDockerSandbox(makeConfig({ cleanup }), { + commandRunner: runner, + containerName: 'cancelled-start', + }); + await expect(pending).rejects.toMatchObject({ cause }); + expect(calls.at(-1)?.args).toStrictEqual([ + 'rm', + '-f', + 'cancelled-start', + ]); + expect(getActiveContainersForTesting()).not.toContain( + 'cancelled-start', + ); + }, + ); + it('also removes containers for an unwrapped startup AbortError', async () => { + const { runner, calls } = makeStubRunner([ + () => { + throw new DOMException('cancelled', 'AbortError'); + }, + ]); + await expect( + createDockerSandbox(makeConfig(), { + commandRunner: runner, + containerName: 'direct-abort', + }), + ).rejects.toThrow('cancelled'); + expect(calls.at(-1)?.args).toStrictEqual(['rm', '-f', 'direct-abort']); + }); it('wraps docker run errors in a DockerSandboxError and does not register the container', async () => { const { runner } = makeStubRunner([ () => { 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/sandbox/docker/utils.test.ts b/src/sandbox/docker/utils.test.ts new file mode 100644 index 0000000..7929312 --- /dev/null +++ b/src/sandbox/docker/utils.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldCloseSandbox } from './utils.js'; + +describe('shouldCloseSandbox', () => { + it.each([ + { + completed: true, + failed: false, + succeeded: true, + aborted: false, + expected: [true, true, false], + }, + { + completed: true, + failed: false, + succeeded: false, + aborted: false, + expected: [true, false, false], + }, + { + completed: false, + failed: true, + succeeded: true, + aborted: false, + expected: [true, false, false], + }, + { + completed: false, + failed: true, + succeeded: false, + aborted: false, + expected: [true, false, false], + }, + { + completed: false, + failed: false, + succeeded: false, + aborted: false, + expected: [true, true, true], + }, + { + completed: false, + failed: true, + succeeded: false, + aborted: true, + expected: [true, true, true], + }, + { + completed: true, + failed: false, + succeeded: true, + aborted: true, + expected: [true, true, true], + }, + ])( + 'applies all retention policies to $completed/$failed/$succeeded/$aborted', + (outcome) => { + expect( + (['always', 'on-success', 'never'] as const).map((policy) => + shouldCloseSandbox(policy, outcome), + ), + ).toStrictEqual(outcome.expected); + }, + ); +}); diff --git a/src/sandbox/docker/utils.ts b/src/sandbox/docker/utils.ts index d064a8f..d3fce99 100644 --- a/src/sandbox/docker/utils.ts +++ b/src/sandbox/docker/utils.ts @@ -1,5 +1,37 @@ import { DockerSandboxError } from '../../errors.js'; +/** + * Removes abandoned or cancelled runs regardless of retention policy. + * + * @param cleanup - Configured retention policy. + * @param outcome - Bridge completion, failure, result, and cancellation state. + * @param outcome.completed - Whether iteration completed naturally. + * @param outcome.failed - Whether execution threw. + * @param outcome.succeeded - Whether the agent reported success. + * @param outcome.aborted - Whether caller cancellation was requested. + * @returns Whether to remove the sandbox. + */ +export function shouldCloseSandbox( + cleanup: 'always' | 'on-success' | 'never', + outcome: { + /** Bridge iteration completed naturally. */ + completed: boolean; + /** Execution threw an error. */ + failed: boolean; + /** Agent reported a successful result. */ + succeeded: boolean; + /** Caller requested cancellation. */ + aborted: boolean; + }, +): boolean { + return ( + outcome.aborted || + (!outcome.completed && !outcome.failed) || + cleanup === 'always' || + (cleanup === 'on-success' && outcome.completed && outcome.succeeded) + ); +} + /** * Maximum number of characters included in a stderr excerpt inside * error messages. Longer output is truncated with an ellipsis. 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 33669a2..7825dc9 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,14 +103,19 @@ 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 = { 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. * @@ -109,6 +128,7 @@ const createMockAdapter = ( yield message; } }, + ...(getRunMetadata === undefined ? {} : { getRunMetadata }), }; return { runCalls, adapter }; @@ -123,6 +143,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 +223,240 @@ 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', + defaultOptions: { settingSources: [] }, + /** + * 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.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/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..b9b78b8 --- /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/pi-adapter.js'; +import type { PiQueryOptions } from './adapters/pi/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..35acb98 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 merged under per-run options. Omitted supplies nothing. */ + 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"