@wix/pathgrade 1.0.11 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  ## Quick Start
15
15
 
16
- **Prerequisites**: Node.js 20.11+, Vitest 4+ or Jest 30+, and at least one configured agent runtime. Claude uses the bundled `@anthropic-ai/claude-agent-sdk` binary by default; Codex requires the `codex` CLI; Cursor requires the `cursor-agent` CLI. OpenCode requires the exact v1.18.18 macOS ARM64 or Linux ARM64 executable.
16
+ **Prerequisites**: Node.js 20.11+, Vitest 4+ or Jest 30+, and at least one configured agent runtime. Claude uses the bundled `@anthropic-ai/claude-agent-sdk` binary by default; Codex requires the `codex` CLI; Cursor requires the `cursor-agent` CLI. OpenCode requires the exact Wix-registry v1.18.14 macOS ARM64 or Linux ARM64 executable.
17
17
 
18
18
  ```bash
19
19
  yarn add -D @wix/pathgrade
@@ -41,8 +41,9 @@ By default, Pathgrade tries to reuse the agent CLI's native auth before falling
41
41
  - macOS: reuses `cursor-agent login` OAuth tokens from the login Keychain
42
42
  - surfaces a clear error when neither is available (run `cursor-agent login` or set `CURSOR_API_KEY`)
43
43
  - **OpenCode**
44
- - requires an explicit `ANTHROPIC_API_KEY` in `createAgent({ env })`; ambient credentials and login state are never read
45
- - accepts an optional explicit HTTPS `ANTHROPIC_BASE_URL` ending in `/v1`
44
+ - defaults to `anthropic/claude-sonnet-5` with an explicit or host `ANTHROPIC_API_KEY` and optional HTTPS `/v1` proxy
45
+ - supports `model: 'openai/gpt-5.4'` with an explicit or host `OPENAI_API_KEY`
46
+ - when no OpenAI key or proxy is set, stages the validated OpenAI access record with a disabled refresh sentinel; it never copies the host refresh token or writes credentials back
46
47
 
47
48
  If you set `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, or `CURSOR_API_BASE_URL`, set the matching API key too.
48
49
 
@@ -243,15 +244,13 @@ OpenCode v1 is only for fixtures, prompts, skills, and generated MCP mocks contr
243
244
  ```typescript
244
245
  const agent = await createAgent({
245
246
  agent: 'opencode',
246
- opencodeExecutable: '/absolute/path/to/opencode-v1.18.18',
247
- env: {
248
- ANTHROPIC_API_KEY: process.env.APP_ANTHROPIC_API_KEY!,
249
- ANTHROPIC_BASE_URL: 'https://api.example.com/v1',
250
- },
247
+ opencodeExecutable: '/absolute/path/to/opencode-v1.18.14',
251
248
  });
252
249
  ```
253
250
 
254
- The backend supports only `anthropic/claude-sonnet-5`, generated stdio `mcpMock` servers, and clean hosts without system-managed OpenCode configuration. `PATHGRADE_AGENT=opencode` selects the backend but does not supply the required executable or credentials.
251
+ The backend supports `anthropic/claude-sonnet-5` (default) and `openai/gpt-5.4`, generated stdio `mcpMock` servers, and clean hosts without system-managed OpenCode configuration. `PATHGRADE_AGENT=opencode` selects the backend but does not supply the required executable. Selecting GPT-5.4 opts into the ChatGPT Codex endpoint/account plane. A custom `OPENAI_BASE_URL` always requires a matching key and never receives the local OAuth bearer token. Explicit API keys use only an explicitly paired base URL; they never inherit an ambient host proxy.
252
+
253
+ Pathgrade's default persona, summarization, and plain-judge helpers follow the selected OpenCode provider. GPT-5.4 API-key runs use the OpenAI helper provider and never fall through to Claude CLI. Local OAuth authenticates only the isolated OpenCode process; OAuth-only runs must inject an LLM for helper calls or provide an explicit `OPENAI_API_KEY`. Tool-using judges still require the Anthropic HTTP provider.
255
254
 
256
255
  ### `agent.prompt()` - One shot
257
256
 
@@ -430,8 +429,8 @@ Notes:
430
429
 
431
430
  | Variable | Purpose |
432
431
  |----------|---------|
433
- | `ANTHROPIC_API_KEY` | Claude auth; also required explicitly in `AgentOptions.env` for OpenCode |
434
- | `OPENAI_API_KEY` | Codex auth and the required key when using `OPENAI_BASE_URL` |
432
+ | `ANTHROPIC_API_KEY` | Claude auth; explicit or host API-key auth for OpenCode's default model |
433
+ | `OPENAI_API_KEY` | Codex auth; optional explicit or host API-key auth for OpenCode GPT-5.4, and required with `OPENAI_BASE_URL` |
435
434
  | `CURSOR_API_KEY` | Cursor auth and the required key when using `CURSOR_API_BASE_URL` |
436
435
  | `ANTHROPIC_BASE_URL` | Custom Anthropic-compatible endpoint |
437
436
  | `OPENAI_BASE_URL` | Custom OpenAI-compatible endpoint |
@@ -1,24 +1,60 @@
1
1
  import type { MockMcpServerDescriptor } from '../../core/mcp-mock.types.js';
2
2
  import type { AgentName, AgentOptions } from '../../sdk/types.js';
3
- export declare const OPENCODE_MODEL = "anthropic/claude-sonnet-5";
4
- export declare const OPENCODE_VERSION = "1.18.18";
3
+ export declare const OPENCODE_VERSION = "1.18.14";
4
+ export declare const DEFAULT_OPENCODE_MODEL = "anthropic/claude-sonnet-5";
5
+ export interface OpenCodeModelContract {
6
+ provider: 'anthropic' | 'openai';
7
+ apiKeyEnv: 'ANTHROPIC_API_KEY' | 'OPENAI_API_KEY';
8
+ baseUrlEnv: 'ANTHROPIC_BASE_URL' | 'OPENAI_BASE_URL';
9
+ allowsLocalOAuth: boolean;
10
+ }
11
+ export declare const OPENCODE_MODELS: {
12
+ readonly 'anthropic/claude-sonnet-5': {
13
+ readonly provider: "anthropic";
14
+ readonly apiKeyEnv: "ANTHROPIC_API_KEY";
15
+ readonly baseUrlEnv: "ANTHROPIC_BASE_URL";
16
+ readonly allowsLocalOAuth: false;
17
+ };
18
+ readonly 'openai/gpt-5.4': {
19
+ readonly provider: "openai";
20
+ readonly apiKeyEnv: "OPENAI_API_KEY";
21
+ readonly baseUrlEnv: "OPENAI_BASE_URL";
22
+ readonly allowsLocalOAuth: true;
23
+ };
24
+ };
25
+ export type OpenCodeModel = keyof typeof OPENCODE_MODELS;
26
+ export declare const OPENCODE_DISABLED_REFRESH_TOKEN = "pathgrade-refresh-disabled";
27
+ export interface OpenCodeOAuthRecord {
28
+ type: 'oauth';
29
+ access: string;
30
+ refresh: string;
31
+ expires: number;
32
+ accountId?: string;
33
+ enterpriseUrl?: string;
34
+ }
35
+ export declare function sanitizeOpenCodeOAuthRecord(value: unknown): OpenCodeOAuthRecord | undefined;
36
+ export declare function resolveOpenCodeModel(model?: string): {
37
+ model: OpenCodeModel;
38
+ contract: OpenCodeModelContract;
39
+ };
5
40
  export interface OpenCodeRuntimeLockEntry {
6
41
  version: typeof OPENCODE_VERSION;
7
42
  executableSha256: string;
8
43
  }
9
44
  export declare const OPENCODE_RUNTIME_LOCK: {
10
45
  readonly 'darwin-arm64': {
11
- readonly version: "1.18.18";
12
- readonly executableSha256: "4f5979c2dadb06fbff1335335afaaea274e58f92e79aa43cf2ed98618d555422";
46
+ readonly version: "1.18.14";
47
+ readonly executableSha256: "8b7c4e116c1ac5163c02fa85eee5a13d4d00c8a08e677dac4100f55aa56532fa";
13
48
  };
14
49
  readonly 'linux-arm64': {
15
- readonly version: "1.18.18";
16
- readonly executableSha256: "a63ef0c7271383e48ffe36c156b4146087bbbc97929dec40e8be95ed4f4d76ae";
50
+ readonly version: "1.18.14";
51
+ readonly executableSha256: "79d42436517e485e9444cfc6e92582bf9224a9e36fa77fb3957b343486fec81d";
17
52
  };
18
53
  };
19
54
  export type OpenCodePlatformKey = keyof typeof OPENCODE_RUNTIME_LOCK;
20
55
  export declare function getOpenCodePlatformKey(platform: NodeJS.Platform, arch: string): OpenCodePlatformKey | undefined;
21
56
  export declare function currentOpenCodePlatformKey(): OpenCodePlatformKey | undefined;
57
+ export declare function validateOpenCodeBaseUrl(value: string, variable?: string): void;
22
58
  export declare function validateOpenCodeDeclaration(agent: AgentName, opts: AgentOptions): void;
23
59
  export declare function sanitizeOpenCodeToolName(value: string): string;
24
60
  export declare function collectOpenCodeMcpToolNames(declaration: MockMcpServerDescriptor | MockMcpServerDescriptor[] | undefined): string[];
@@ -1,14 +1,54 @@
1
1
  import * as path from 'node:path';
2
- export const OPENCODE_MODEL = 'anthropic/claude-sonnet-5';
3
- export const OPENCODE_VERSION = '1.18.18';
2
+ export const OPENCODE_VERSION = '1.18.14';
3
+ export const DEFAULT_OPENCODE_MODEL = 'anthropic/claude-sonnet-5';
4
+ export const OPENCODE_MODELS = {
5
+ 'anthropic/claude-sonnet-5': {
6
+ provider: 'anthropic', apiKeyEnv: 'ANTHROPIC_API_KEY',
7
+ baseUrlEnv: 'ANTHROPIC_BASE_URL', allowsLocalOAuth: false,
8
+ },
9
+ 'openai/gpt-5.4': {
10
+ provider: 'openai', apiKeyEnv: 'OPENAI_API_KEY',
11
+ baseUrlEnv: 'OPENAI_BASE_URL', allowsLocalOAuth: true,
12
+ },
13
+ };
14
+ export const OPENCODE_DISABLED_REFRESH_TOKEN = 'pathgrade-refresh-disabled';
15
+ export function sanitizeOpenCodeOAuthRecord(value) {
16
+ if (!value || typeof value !== 'object' || Array.isArray(value))
17
+ return undefined;
18
+ const record = value;
19
+ if (record.type !== 'oauth'
20
+ || typeof record.access !== 'string' || !record.access.trim()
21
+ || typeof record.refresh !== 'string' || !record.refresh.trim()
22
+ || typeof record.expires !== 'number' || !Number.isSafeInteger(record.expires) || record.expires < 0
23
+ || (record.accountId !== undefined && typeof record.accountId !== 'string')
24
+ || (record.enterpriseUrl !== undefined && typeof record.enterpriseUrl !== 'string')) {
25
+ return undefined;
26
+ }
27
+ return {
28
+ type: 'oauth',
29
+ access: record.access,
30
+ refresh: record.refresh,
31
+ expires: record.expires,
32
+ ...(record.accountId !== undefined ? { accountId: record.accountId } : {}),
33
+ ...(record.enterpriseUrl !== undefined ? { enterpriseUrl: record.enterpriseUrl } : {}),
34
+ };
35
+ }
36
+ export function resolveOpenCodeModel(model) {
37
+ const selected = model ?? DEFAULT_OPENCODE_MODEL;
38
+ if (!hasOwn(OPENCODE_MODELS, selected)) {
39
+ throw new Error(`OpenCode supports only models ${Object.keys(OPENCODE_MODELS).join(' and ')}`);
40
+ }
41
+ const normalized = selected;
42
+ return { model: normalized, contract: OPENCODE_MODELS[normalized] };
43
+ }
4
44
  export const OPENCODE_RUNTIME_LOCK = {
5
45
  'darwin-arm64': {
6
46
  version: OPENCODE_VERSION,
7
- executableSha256: '4f5979c2dadb06fbff1335335afaaea274e58f92e79aa43cf2ed98618d555422',
47
+ executableSha256: '8b7c4e116c1ac5163c02fa85eee5a13d4d00c8a08e677dac4100f55aa56532fa',
8
48
  },
9
49
  'linux-arm64': {
10
50
  version: OPENCODE_VERSION,
11
- executableSha256: 'a63ef0c7271383e48ffe36c156b4146087bbbc97929dec40e8be95ed4f4d76ae',
51
+ executableSha256: '79d42436517e485e9444cfc6e92582bf9224a9e36fa77fb3957b343486fec81d',
12
52
  },
13
53
  };
14
54
  export function getOpenCodePlatformKey(platform, arch) {
@@ -21,16 +61,17 @@ export function currentOpenCodePlatformKey() {
21
61
  function hasOwn(record, key) {
22
62
  return Object.prototype.hasOwnProperty.call(record, key);
23
63
  }
24
- function assertOpenCodeBaseUrl(value) {
64
+ export function validateOpenCodeBaseUrl(value, variable = 'ANTHROPIC_BASE_URL') {
65
+ const error = `OpenCode ${variable} must be an absolute HTTPS API root ending in /v1`;
25
66
  let url;
26
67
  try {
27
68
  url = new URL(value);
28
69
  }
29
70
  catch {
30
- throw new Error('OpenCode ANTHROPIC_BASE_URL must be an absolute HTTPS API root ending in /v1');
71
+ throw new Error(error);
31
72
  }
32
73
  if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !url.pathname.endsWith('/v1')) {
33
- throw new Error('OpenCode ANTHROPIC_BASE_URL must be an absolute HTTPS API root ending in /v1');
74
+ throw new Error(error);
34
75
  }
35
76
  }
36
77
  export function validateOpenCodeDeclaration(agent, opts) {
@@ -46,18 +87,20 @@ export function validateOpenCodeDeclaration(agent, opts) {
46
87
  if (!opts.opencodeExecutable || !path.isAbsolute(opts.opencodeExecutable)) {
47
88
  throw new Error('OpenCode requires an absolute opencodeExecutable path');
48
89
  }
49
- if (opts.model !== undefined && opts.model !== OPENCODE_MODEL) {
50
- throw new Error(`OpenCode v1 supports only model ${OPENCODE_MODEL}`);
51
- }
90
+ const { contract } = resolveOpenCodeModel(opts.model);
52
91
  const env = opts.env ?? {};
53
- if (!hasOwn(env, 'ANTHROPIC_API_KEY') || !env.ANTHROPIC_API_KEY?.trim()) {
54
- throw new Error('OpenCode requires a nonempty own env.ANTHROPIC_API_KEY');
92
+ if (hasOwn(env, contract.apiKeyEnv) && !env[contract.apiKeyEnv]?.trim()) {
93
+ throw new Error(`OpenCode env.${contract.apiKeyEnv} must be nonempty when provided`);
55
94
  }
56
- if (hasOwn(env, 'ANTHROPIC_BASE_URL')) {
57
- if (!env.ANTHROPIC_BASE_URL) {
58
- throw new Error('OpenCode ANTHROPIC_BASE_URL must be a nonempty absolute HTTPS API root ending in /v1');
95
+ if (hasOwn(env, contract.baseUrlEnv)) {
96
+ if (!env[contract.baseUrlEnv]) {
97
+ throw new Error(`OpenCode ${contract.baseUrlEnv} must be a nonempty absolute HTTPS API root ending in /v1`);
98
+ }
99
+ validateOpenCodeBaseUrl(env[contract.baseUrlEnv], contract.baseUrlEnv);
100
+ if (!hasOwn(env, contract.apiKeyEnv)) {
101
+ throw new Error(`OpenCode env.${contract.baseUrlEnv} requires an explicit env.${contract.apiKeyEnv}; ` +
102
+ 'host credentials are never sent to caller-provided endpoints.');
59
103
  }
60
- assertOpenCodeBaseUrl(env.ANTHROPIC_BASE_URL);
61
104
  }
62
105
  if (opts.copyFromHome !== undefined)
63
106
  throw new Error('OpenCode does not support copyFromHome');
@@ -0,0 +1,18 @@
1
+ import { type OpenCodeModel } from './contract.js';
2
+ export declare const OPENCODE_PERMISSION: string;
3
+ export declare class OpenCodeRuntimePolicy {
4
+ readonly model: OpenCodeModel;
5
+ readonly oauth: boolean;
6
+ private readonly authPath;
7
+ private readonly expectedAuthDigest;
8
+ private readonly expectedAuthFingerprint;
9
+ private authWatcher;
10
+ private authMutationObserved;
11
+ private constructor();
12
+ static create(runtimeEnv: Record<string, string>, requestedModel?: string): Promise<OpenCodeRuntimePolicy>;
13
+ environment(): Record<string, string>;
14
+ beforeTurn(remainingMs: number, now?: number): Promise<void>;
15
+ afterTurn(): Promise<void>;
16
+ private startAuthMonitor;
17
+ private readUnchanged;
18
+ }
@@ -0,0 +1,156 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants, watch } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import { OPENCODE_DISABLED_REFRESH_TOKEN, resolveOpenCodeModel, sanitizeOpenCodeOAuthRecord, } from './contract.js';
6
+ const OAUTH_SKEW_MS = 5 * 60 * 1_000;
7
+ const FIXED_ENV = {
8
+ OPENCODE_CLIENT: 'pathgrade',
9
+ OPENCODE_DISABLE_AUTOUPDATE: '1',
10
+ OPENCODE_DISABLE_PRUNE: '1',
11
+ OPENCODE_DISABLE_MODELS_FETCH: '1',
12
+ OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
13
+ OPENCODE_DISABLE_LSP_DOWNLOAD: '1',
14
+ OPENCODE_DISABLE_SHARE: '1',
15
+ OPENCODE_PURE: '1',
16
+ };
17
+ export const OPENCODE_PERMISSION = JSON.stringify({
18
+ read: 'allow', edit: 'allow', glob: 'allow', grep: 'allow', list: 'allow',
19
+ bash: 'allow', todowrite: 'allow', lsp: 'allow', skill: 'allow',
20
+ task: 'deny', question: 'deny', plan_enter: 'deny', plan_exit: 'deny',
21
+ external_directory: 'deny', webfetch: 'deny', websearch: 'deny',
22
+ });
23
+ function digest(value) {
24
+ return createHash('sha256').update(value).digest('hex');
25
+ }
26
+ function oauthExpiry(raw) {
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(raw);
30
+ }
31
+ catch {
32
+ throw new Error('Staged OpenCode OpenAI login is invalid; refresh it locally and start a new agent.');
33
+ }
34
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
35
+ || Object.keys(parsed).length !== 1 || !('openai' in parsed)) {
36
+ throw new Error('Staged OpenCode OpenAI login is invalid; refresh it locally and start a new agent.');
37
+ }
38
+ const record = sanitizeOpenCodeOAuthRecord(parsed.openai);
39
+ if (!record || record.refresh !== OPENCODE_DISABLED_REFRESH_TOKEN) {
40
+ throw new Error('Staged OpenCode OpenAI login is invalid; refresh it locally and start a new agent.');
41
+ }
42
+ return record.expires;
43
+ }
44
+ async function readRegularMode600(filename) {
45
+ const handle = await open(filename, constants.O_RDONLY | constants.O_NOFOLLOW);
46
+ try {
47
+ const stat = await handle.stat({ bigint: true });
48
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 511n) !== 384n) {
49
+ throw new Error('mode');
50
+ }
51
+ return {
52
+ raw: await handle.readFile('utf8'),
53
+ fingerprint: [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':'),
54
+ };
55
+ }
56
+ finally {
57
+ await handle.close();
58
+ }
59
+ }
60
+ function poisonedAuthError() {
61
+ return new Error('OpenCode changed its staged OpenAI login; this session is poisoned. ' +
62
+ 'The host login was not updated; refresh it locally and start a new agent.');
63
+ }
64
+ export class OpenCodeRuntimePolicy {
65
+ model;
66
+ oauth;
67
+ authPath;
68
+ expectedAuthDigest;
69
+ expectedAuthFingerprint;
70
+ authWatcher;
71
+ authMutationObserved = false;
72
+ constructor(model, authPath, expectedAuthDigest, expectedAuthFingerprint) {
73
+ this.model = model;
74
+ this.authPath = authPath;
75
+ this.expectedAuthDigest = expectedAuthDigest;
76
+ this.expectedAuthFingerprint = expectedAuthFingerprint;
77
+ this.oauth = expectedAuthDigest !== undefined;
78
+ }
79
+ static async create(runtimeEnv, requestedModel) {
80
+ const { model, contract } = resolveOpenCodeModel(requestedModel);
81
+ const home = runtimeEnv.HOME;
82
+ if (!home)
83
+ throw new Error('OpenCode requires a managed HOME');
84
+ const authPath = path.join(home, '.local', 'share', 'opencode', 'auth.json');
85
+ const hasApiKey = Boolean(runtimeEnv[contract.apiKeyEnv]?.trim());
86
+ if (!contract.allowsLocalOAuth || hasApiKey) {
87
+ return new OpenCodeRuntimePolicy(model, authPath);
88
+ }
89
+ let snapshot;
90
+ try {
91
+ snapshot = await readRegularMode600(authPath);
92
+ }
93
+ catch {
94
+ throw new Error('OpenCode OpenAI OAuth staging is missing or not mode 0600.');
95
+ }
96
+ oauthExpiry(snapshot.raw);
97
+ return new OpenCodeRuntimePolicy(model, authPath, digest(snapshot.raw), snapshot.fingerprint);
98
+ }
99
+ environment() {
100
+ return this.oauth
101
+ ? { ...FIXED_ENV }
102
+ : { ...FIXED_ENV, OPENCODE_DISABLE_DEFAULT_PLUGINS: '1' };
103
+ }
104
+ async beforeTurn(remainingMs, now = Date.now()) {
105
+ if (!this.oauth)
106
+ return;
107
+ const raw = await this.readUnchanged();
108
+ const expires = oauthExpiry(raw);
109
+ if (expires <= now + remainingMs + OAUTH_SKEW_MS) {
110
+ throw new Error('Local OpenCode OpenAI login will expire before this turn can finish. ' +
111
+ 'Refresh it with `opencode auth login` locally and start a new agent.');
112
+ }
113
+ this.startAuthMonitor();
114
+ }
115
+ async afterTurn() {
116
+ if (!this.oauth)
117
+ return;
118
+ try {
119
+ await this.readUnchanged();
120
+ await new Promise((resolve) => setImmediate(resolve));
121
+ if (this.authMutationObserved)
122
+ throw poisonedAuthError();
123
+ }
124
+ finally {
125
+ this.authWatcher?.close();
126
+ this.authWatcher = undefined;
127
+ }
128
+ }
129
+ startAuthMonitor() {
130
+ if (this.authWatcher)
131
+ throw new Error('OpenCode auth monitor is already active');
132
+ this.authMutationObserved = false;
133
+ const expectedName = path.basename(this.authPath);
134
+ this.authWatcher = watch(path.dirname(this.authPath), { persistent: false }, (_event, filename) => {
135
+ if (filename === null || filename.toString() === expectedName)
136
+ this.authMutationObserved = true;
137
+ });
138
+ this.authWatcher.on('error', () => {
139
+ this.authMutationObserved = true;
140
+ });
141
+ }
142
+ async readUnchanged() {
143
+ let snapshot;
144
+ try {
145
+ snapshot = await readRegularMode600(this.authPath);
146
+ }
147
+ catch {
148
+ throw poisonedAuthError();
149
+ }
150
+ if (digest(snapshot.raw) !== this.expectedAuthDigest
151
+ || snapshot.fingerprint !== this.expectedAuthFingerprint) {
152
+ throw poisonedAuthError();
153
+ }
154
+ return snapshot.raw;
155
+ }
156
+ }
@@ -7,38 +7,11 @@ import fs from 'fs-extra';
7
7
  import { BaseAgent, getRuntimeEnv, getWorkspacePath, } from '../types.js';
8
8
  import { buildSummary, enrichSkillEvents } from '../tool-events.js';
9
9
  import { readStagedMcpServers } from '../providers/mcp-config.js';
10
- import { currentOpenCodePlatformKey, OPENCODE_MODEL, OPENCODE_RUNTIME_LOCK, } from './opencode/contract.js';
10
+ import { removeSandboxRoot } from '../providers/sandbox-lifecycle.js';
11
+ import { currentOpenCodePlatformKey, OPENCODE_RUNTIME_LOCK, } from './opencode/contract.js';
12
+ import { OpenCodeRuntimePolicy, OPENCODE_PERMISSION } from './opencode/runtime-policy.js';
11
13
  import { killOpenCodeProcessGroup, registerOpenCodeProcessGroup, unregisterOpenCodeProcessGroup, } from './opencode/process-groups.js';
12
14
  const OUTPUT_CAP_BYTES = 16 * 1024 * 1024;
13
- const FIXED_OPENCODE_ENV = {
14
- OPENCODE_CLIENT: 'pathgrade',
15
- OPENCODE_DISABLE_AUTOUPDATE: '1',
16
- OPENCODE_DISABLE_PRUNE: '1',
17
- OPENCODE_DISABLE_MODELS_FETCH: '1',
18
- OPENCODE_DISABLE_DEFAULT_PLUGINS: '1',
19
- OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
20
- OPENCODE_DISABLE_LSP_DOWNLOAD: '1',
21
- OPENCODE_DISABLE_SHARE: '1',
22
- OPENCODE_PURE: '1',
23
- };
24
- const OPENCODE_PERMISSION = JSON.stringify({
25
- read: 'allow',
26
- edit: 'allow',
27
- glob: 'allow',
28
- grep: 'allow',
29
- list: 'allow',
30
- bash: 'allow',
31
- todowrite: 'allow',
32
- lsp: 'allow',
33
- skill: 'allow',
34
- task: 'deny',
35
- question: 'deny',
36
- plan_enter: 'deny',
37
- plan_exit: 'deny',
38
- external_directory: 'deny',
39
- webfetch: 'deny',
40
- websearch: 'deny',
41
- });
42
15
  const NATIVE_TOOL_ACTIONS = {
43
16
  bash: 'run_shell',
44
17
  read: 'read_file',
@@ -170,12 +143,9 @@ function sanitizedProviderError(event) {
170
143
  const data = error.data && typeof error.data === 'object' && !Array.isArray(error.data)
171
144
  ? error.data
172
145
  : {};
173
- const message = typeof data.message === 'string' && data.message.trim()
174
- ? data.message.trim().slice(0, 1_000)
175
- : 'OpenCode provider error';
176
146
  const status = typeof data.statusCode === 'number' ? ` status=${data.statusCode}` : '';
177
147
  const retryable = typeof data.isRetryable === 'boolean' ? ` retryable=${data.isRetryable}` : '';
178
- return new Error(`${message}${status}${retryable}`);
148
+ return new Error(`OpenCode provider error${status}${retryable}`);
179
149
  }
180
150
  export function parseOpenCodeOutput(stdout, processResult, mcpToolNames) {
181
151
  if (processResult.overflow)
@@ -375,7 +345,10 @@ class OpenCodeSession {
375
345
  mcpConfigPath;
376
346
  mcpToolNames;
377
347
  getAbortSignal;
348
+ getRemainingMs;
349
+ requestedModel;
378
350
  xdgDirs;
351
+ runtimePolicy;
379
352
  resolvedExecutable;
380
353
  preflightDone = false;
381
354
  running = false;
@@ -391,6 +364,8 @@ class OpenCodeSession {
391
364
  this.mcpConfigPath = options.mcpConfigPath;
392
365
  this.mcpToolNames = new Set(options.opencodeMcpToolNames ?? []);
393
366
  this.getAbortSignal = options.getAbortSignal ?? (() => options.abortSignal);
367
+ this.getRemainingMs = options.getRemainingMs ?? (() => 0);
368
+ this.requestedModel = options.model;
394
369
  const home = this.runtimeEnv.HOME;
395
370
  if (!home)
396
371
  throw new Error('OpenCode requires a managed HOME');
@@ -432,23 +407,27 @@ class OpenCodeSession {
432
407
  try {
433
408
  await this.ensurePreflight();
434
409
  await assertNoProjectConfig(this.workspacePath);
435
- const mcp = await projectMcpConfig(this.workspacePath, this.mcpConfigPath);
436
- const env = this.buildEnvironment(mcp);
437
- const args = [
438
- 'run', '--format', 'json', '--thinking', '--dir', this.workspacePath,
439
- '--model', OPENCODE_MODEL, '--agent', 'build',
440
- ...(this.sessionId ? ['--session', this.sessionId] : []),
441
- ];
442
- const turnSignal = this.getAbortSignal();
443
- const signal = turnSignal
444
- ? AbortSignal.any([turnSignal, this.disposeController.signal])
445
- : this.disposeController.signal;
446
- const processResult = await spawnOpenCode(this.resolvedExecutable, args, {
447
- cwd: this.workspacePath,
448
- env,
449
- stdin: message,
450
- signal,
451
- });
410
+ await this.runtimePolicy.beforeTurn(this.getRemainingMs());
411
+ let processResult;
412
+ try {
413
+ const mcp = await projectMcpConfig(this.workspacePath, this.mcpConfigPath);
414
+ const env = this.buildEnvironment(mcp);
415
+ const args = [
416
+ 'run', '--format', 'json', '--thinking', '--dir', this.workspacePath,
417
+ '--model', this.runtimePolicy.model, '--agent', 'build',
418
+ ...(this.sessionId ? ['--session', this.sessionId] : []),
419
+ ];
420
+ const turnSignal = this.getAbortSignal();
421
+ const signal = turnSignal
422
+ ? AbortSignal.any([turnSignal, this.disposeController.signal])
423
+ : this.disposeController.signal;
424
+ processResult = await spawnOpenCode(this.resolvedExecutable, args, {
425
+ cwd: this.workspacePath, env, stdin: message, signal,
426
+ });
427
+ }
428
+ finally {
429
+ await this.runtimePolicy.afterTurn();
430
+ }
452
431
  const parsed = parseOpenCodeOutput(processResult.stdout, processResult, this.mcpToolNames);
453
432
  if (this.sessionId && parsed.sessionId !== this.sessionId) {
454
433
  throw new Error('OpenCode protocol error: resumed session ID changed');
@@ -458,7 +437,12 @@ class OpenCodeSession {
458
437
  }
459
438
  catch (error) {
460
439
  this.failed = true;
461
- await this.cleanupState();
440
+ try {
441
+ await this.cleanupState();
442
+ }
443
+ catch (cleanupError) {
444
+ throw new AggregateError([error, cleanupError], 'OpenCode turn failed and isolated state cleanup also failed');
445
+ }
462
446
  throw error;
463
447
  }
464
448
  }
@@ -477,6 +461,7 @@ class OpenCodeSession {
477
461
  await assertCleanManagedOpenCodeHost();
478
462
  await assertNoProjectConfig(this.workspacePath);
479
463
  await Promise.all(this.xdgDirs.map((directory) => fs.ensureDir(directory)));
464
+ this.runtimePolicy = await OpenCodeRuntimePolicy.create(this.runtimeEnv, this.requestedModel);
480
465
  this.resolvedExecutable = resolved;
481
466
  this.preflightDone = true;
482
467
  }
@@ -488,10 +473,10 @@ class OpenCodeSession {
488
473
  XDG_CONFIG_HOME: config,
489
474
  XDG_STATE_HOME: state,
490
475
  XDG_CACHE_HOME: cache,
491
- ...FIXED_OPENCODE_ENV,
476
+ ...this.runtimePolicy.environment(),
492
477
  OPENCODE_CONFIG_CONTENT: JSON.stringify({
493
478
  share: 'disabled',
494
- model: OPENCODE_MODEL,
479
+ model: this.runtimePolicy.model,
495
480
  skills: { paths: ['.agents/skills'], urls: [] },
496
481
  mcp,
497
482
  }),
@@ -499,7 +484,7 @@ class OpenCodeSession {
499
484
  };
500
485
  }
501
486
  async cleanupState() {
502
- await Promise.all(this.xdgDirs.map((directory) => fs.remove(directory)));
487
+ await Promise.all(this.xdgDirs.map((directory) => removeSandboxRoot(directory)));
503
488
  }
504
489
  async dispose() {
505
490
  if (this.disposed)
@@ -16,6 +16,13 @@ export interface CredentialPorts {
16
16
  keychainEntryExists(service: string, account: string): Promise<boolean>;
17
17
  /** Check if a path exists on the host filesystem. */
18
18
  fileExists(absolutePath: string): Promise<boolean>;
19
+ /** Read a sensitive host text file without exposing it to logs. */
20
+ readTextFile(absolutePath: string): Promise<string>;
21
+ }
22
+ export interface SensitiveHomeFile {
23
+ relativePath: string;
24
+ content: string;
25
+ mode: number;
19
26
  }
20
27
  export interface CredentialResult {
21
28
  /** Env vars to merge into sandbox env. */
@@ -30,7 +37,12 @@ export interface CredentialResult {
30
37
  * host (e.g. macOS `Library/Keychains`). Optional; defaults to none.
31
38
  */
32
39
  linkFromHome?: string[];
40
+ /** Filtered sensitive files to create inside the isolated HOME. */
41
+ sensitiveHomeFiles?: SensitiveHomeFile[];
33
42
  }
34
43
  /** Default ports using real process.env, Keychain, and filesystem. */
35
44
  export declare function defaultPorts(): CredentialPorts;
36
- export declare function resolveCredentials(agent: AgentName, userEnv: Record<string, string>, ports?: CredentialPorts): Promise<CredentialResult>;
45
+ export interface CredentialContext {
46
+ model?: string;
47
+ }
48
+ export declare function resolveCredentials(agent: AgentName, userEnv: Record<string, string>, ports?: CredentialPorts, context?: CredentialContext): Promise<CredentialResult>;
@@ -16,6 +16,7 @@ import { execSync, execFileSync } from 'child_process';
16
16
  import * as os from 'os';
17
17
  import * as path from 'path';
18
18
  import fs from 'fs-extra';
19
+ import { OPENCODE_DISABLED_REFRESH_TOKEN, resolveOpenCodeModel, sanitizeOpenCodeOAuthRecord, validateOpenCodeBaseUrl, } from '../agents/opencode/contract.js';
19
20
  const EMPTY = { env: {}, setupCommands: [], copyFromHome: [] };
20
21
  /** Default ports using real process.env, Keychain, and filesystem. */
21
22
  export function defaultPorts() {
@@ -49,9 +50,12 @@ export function defaultPorts() {
49
50
  async fileExists(absolutePath) {
50
51
  return fs.pathExists(absolutePath);
51
52
  },
53
+ async readTextFile(absolutePath) {
54
+ return fs.readFile(absolutePath, 'utf8');
55
+ },
52
56
  };
53
57
  }
54
- export async function resolveCredentials(agent, userEnv, ports) {
58
+ export async function resolveCredentials(agent, userEnv, ports, context = {}) {
55
59
  const p = ports ?? defaultPorts();
56
60
  switch (agent) {
57
61
  case 'claude':
@@ -61,20 +65,99 @@ export async function resolveCredentials(agent, userEnv, ports) {
61
65
  case 'cursor':
62
66
  return resolveCursor(userEnv, p);
63
67
  case 'opencode':
64
- return resolveOpenCode(userEnv);
68
+ return resolveOpenCode(userEnv, p, resolveOpenCodeModel(context.model).model);
65
69
  default:
66
70
  return EMPTY;
67
71
  }
68
72
  }
69
- function resolveOpenCode(userEnv) {
73
+ async function resolveOpenCode(userEnv, ports, model) {
74
+ const { contract } = resolveOpenCodeModel(model);
70
75
  const env = {};
71
- if (userEnv.ANTHROPIC_API_KEY) {
72
- env.ANTHROPIC_API_KEY = userEnv.ANTHROPIC_API_KEY;
76
+ const hasUserKey = Object.prototype.hasOwnProperty.call(userEnv, contract.apiKeyEnv);
77
+ const userKey = hasUserKey ? userEnv[contract.apiKeyEnv] : undefined;
78
+ if (hasUserKey && !userKey?.trim()) {
79
+ throw new Error(`OpenCode env.${contract.apiKeyEnv} must be nonempty when provided`);
73
80
  }
74
- if (userEnv.ANTHROPIC_BASE_URL) {
75
- env.ANTHROPIC_BASE_URL = userEnv.ANTHROPIC_BASE_URL;
81
+ const hasUserBaseUrl = Object.prototype.hasOwnProperty.call(userEnv, contract.baseUrlEnv);
82
+ let baseUrl;
83
+ if (hasUserBaseUrl) {
84
+ baseUrl = userEnv[contract.baseUrlEnv];
85
+ if (!baseUrl) {
86
+ throw new Error(`OpenCode ${contract.baseUrlEnv} must be a nonempty absolute HTTPS API root ending in /v1`);
87
+ }
88
+ validateOpenCodeBaseUrl(baseUrl, contract.baseUrlEnv);
89
+ if (!hasUserKey) {
90
+ throw new Error(`OpenCode env.${contract.baseUrlEnv} requires an explicit env.${contract.apiKeyEnv}; ` +
91
+ 'host credentials are never sent to caller-provided endpoints.');
92
+ }
76
93
  }
77
- return { env, setupCommands: [], copyFromHome: [] };
94
+ const hostKey = hasUserKey ? undefined : ports.hostEnv(contract.apiKeyEnv);
95
+ const apiKey = userKey || hostKey;
96
+ if (!hasUserKey && hostKey?.trim())
97
+ env[contract.apiKeyEnv] = hostKey;
98
+ if (!hasUserBaseUrl && !hasUserKey) {
99
+ const hostBaseUrl = ports.hostEnv(contract.baseUrlEnv);
100
+ if (hostBaseUrl) {
101
+ validateOpenCodeBaseUrl(hostBaseUrl, contract.baseUrlEnv);
102
+ env[contract.baseUrlEnv] = hostBaseUrl;
103
+ baseUrl = hostBaseUrl;
104
+ }
105
+ }
106
+ if (apiKey?.trim())
107
+ return { env, setupCommands: [], copyFromHome: [] };
108
+ if (baseUrl) {
109
+ throw new Error(`OpenCode ${contract.baseUrlEnv} is set but ${contract.apiKeyEnv} is missing; ` +
110
+ 'local OAuth is never sent to a custom endpoint.');
111
+ }
112
+ if (!contract.allowsLocalOAuth) {
113
+ throw new Error(`OpenCode authentication requires ${contract.apiKeyEnv}. ` +
114
+ 'Provide it in env or set it in your host environment.');
115
+ }
116
+ const hostXdgDataHome = ports.hostEnv('XDG_DATA_HOME')?.trim();
117
+ if (hostXdgDataHome && !path.isAbsolute(hostXdgDataHome)) {
118
+ throw new Error('OpenCode host XDG_DATA_HOME must be absolute when provided.');
119
+ }
120
+ const xdgDataHome = hostXdgDataHome || path.join(ports.homedir, '.local', 'share');
121
+ const authPath = path.join(xdgDataHome, 'opencode', 'auth.json');
122
+ let raw;
123
+ try {
124
+ raw = await ports.readTextFile(authPath);
125
+ }
126
+ catch {
127
+ throw new Error(`OpenCode authentication requires ${contract.apiKeyEnv} or a local OpenCode OpenAI login. ` +
128
+ 'Run `opencode auth login` locally and try again.');
129
+ }
130
+ const filtered = filterOpenCodeOAuthRecord(raw);
131
+ return filtered;
132
+ }
133
+ function filterOpenCodeOAuthRecord(raw) {
134
+ let store;
135
+ try {
136
+ store = JSON.parse(raw);
137
+ }
138
+ catch {
139
+ throw new Error('Local OpenCode OpenAI login is invalid; run `opencode auth login` locally and try again.');
140
+ }
141
+ if (!store || typeof store !== 'object' || Array.isArray(store)) {
142
+ throw new Error('Local OpenCode OpenAI login is invalid; run `opencode auth login` locally and try again.');
143
+ }
144
+ const openai = store.openai;
145
+ if (!openai || typeof openai !== 'object' || Array.isArray(openai)) {
146
+ throw new Error('Local OpenCode OpenAI login is missing; run `opencode auth login` locally and try again.');
147
+ }
148
+ const record = sanitizeOpenCodeOAuthRecord(openai);
149
+ if (!record) {
150
+ throw new Error('Local OpenCode OpenAI login is invalid; run `opencode auth login` locally and try again.');
151
+ }
152
+ const sanitized = { ...record, refresh: OPENCODE_DISABLED_REFRESH_TOKEN };
153
+ return {
154
+ env: {}, setupCommands: [], copyFromHome: [],
155
+ sensitiveHomeFiles: [{
156
+ relativePath: path.join('.local', 'share', 'opencode', 'auth.json'),
157
+ content: JSON.stringify({ openai: sanitized }),
158
+ mode: 0o600,
159
+ }],
160
+ };
78
161
  }
79
162
  async function resolveClaude(userEnv, ports) {
80
163
  // User explicitly provided API key — trust it, nothing to add
@@ -1,5 +1,7 @@
1
1
  export interface SandboxConfig {
2
2
  agent: import('../sdk/types.js').AgentName;
3
+ /** Internal provider/model context; ignored by sandbox creation itself. */
4
+ model?: string;
3
5
  workspace?: string;
4
6
  skillDir?: string;
5
7
  copyFromHome?: string[];
@@ -18,6 +18,17 @@ async function copyPathsFromHostHome(pathsToCopy, sandboxHomePath) {
18
18
  await fs.copy(srcPath, destPath, { filter: isPortableCopyEntry });
19
19
  }
20
20
  }
21
+ async function stageSensitiveHomeFiles(files, sandboxHomePath) {
22
+ for (const file of files) {
23
+ if (path.isAbsolute(file.relativePath) || file.relativePath.split(path.sep).includes('..')) {
24
+ throw new Error('Sensitive credential staging path must stay inside the sandbox HOME');
25
+ }
26
+ const destination = path.join(sandboxHomePath, file.relativePath);
27
+ await fs.ensureDir(path.dirname(destination));
28
+ await fs.writeFile(destination, file.content, { encoding: 'utf8', mode: file.mode, flag: 'wx' });
29
+ await fs.chmod(destination, file.mode);
30
+ }
31
+ }
21
32
  export async function linkPathsFromHostHome(pathsToLink, sandboxHomePath) {
22
33
  const realHome = os.homedir();
23
34
  for (const relPath of pathsToLink) {
@@ -36,10 +47,11 @@ export async function prepareWorkspace(spec) {
36
47
  try {
37
48
  // Resolve credentials: pass user's original env (not sandboxEnv) so
38
49
  // the resolver can distinguish explicit user intent from auto-resolved values.
39
- const creds = await resolveCredentials(spec.agent, spec.env ?? {});
50
+ const creds = await resolveCredentials(spec.agent, spec.env ?? {}, undefined, { model: spec.model });
40
51
  Object.assign(sandboxEnv, creds.env);
41
52
  await copyPathsFromHostHome(creds.copyFromHome, homePath);
42
53
  await linkPathsFromHostHome(creds.linkFromHome ?? [], homePath);
54
+ await stageSensitiveHomeFiles(creds.sensitiveHomeFiles ?? [], homePath);
43
55
  const { mcpConfigPath } = await stageMcpConfig(workspacePath, mcp);
44
56
  let disposed = false;
45
57
  return {
@@ -57,7 +69,12 @@ export async function prepareWorkspace(spec) {
57
69
  };
58
70
  }
59
71
  catch (error) {
60
- await fs.remove(rootDir).catch(() => { });
72
+ try {
73
+ await removeSandboxRoot(rootDir);
74
+ }
75
+ catch (cleanupError) {
76
+ throw new AggregateError([error, cleanupError], 'Workspace setup failed and the sandbox could not be removed');
77
+ }
61
78
  throw error;
62
79
  }
63
80
  }
package/dist/sdk/agent.js CHANGED
@@ -251,6 +251,7 @@ class AgentImpl {
251
251
  ...opts.persona,
252
252
  llm: opts.persona.llm ?? this.llm,
253
253
  conversationWindow: personaWindowConfig,
254
+ defaultSummaryModel: this.modelOpt,
254
255
  });
255
256
  personaReply = async () => {
256
257
  const fakeChatSession = {
@@ -393,7 +394,7 @@ export async function createAgent(opts) {
393
394
  const timeoutSetting = opts.timeout ?? 300;
394
395
  // Capture runner context now; adapters own installation and restoration.
395
396
  const testCtx = opts.debug ? resolveCaseDebugContext() : { name: '', dir: '' };
396
- const { timeout: _, mcpMock, mcpConfigFile, agent: __, debug: ___, model: ____, transport: _____, mcpSafety: ______, opencodeExecutable, ...rest } = opts;
397
+ const { timeout: _, mcpMock, mcpConfigFile, agent: __, debug: ___, transport: _____, mcpSafety: ______, opencodeExecutable, ...rest } = opts;
397
398
  const workspace = await prepareWorkspace({
398
399
  ...rest,
399
400
  agent: agentName,
@@ -401,7 +402,7 @@ export async function createAgent(opts) {
401
402
  });
402
403
  // Create agent LLM once, using the fully-resolved sandbox env (includes
403
404
  // keychain OAuth tokens, API keys, safe host vars).
404
- const llm = createAgentLLM(agentName, workspace.env);
405
+ const llm = createAgentLLM(agentName, workspace.env, opts.model);
405
406
  // Fall back to sandbox dir name if no test name resolved
406
407
  const debugName = testCtx.name || path.basename(path.dirname(workspace.path));
407
408
  // Default debug dir is next to the eval file, fallback to cwd
@@ -28,6 +28,7 @@ export function createManagedSession(deps) {
28
28
  ...(deps.opencodeExecutable !== undefined ? { opencodeExecutable: deps.opencodeExecutable } : {}),
29
29
  ...(deps.opencodeMcpToolNames !== undefined ? { opencodeMcpToolNames: deps.opencodeMcpToolNames } : {}),
30
30
  getAbortSignal: () => currentSignal,
31
+ getRemainingMs: () => Math.max(0, deadlineMs - Date.now()),
31
32
  };
32
33
  let session = null;
33
34
  let setupDone = false;
@@ -2,4 +2,5 @@ import type { LLMPort } from '../utils/llm-types.js';
2
2
  import type { Persona, PersonaConfig } from './types.js';
3
3
  export declare function createPersona(config: PersonaConfig & {
4
4
  llm: LLMPort;
5
+ defaultSummaryModel?: string;
5
6
  }): Persona;
@@ -1,8 +1,12 @@
1
1
  import { createConversationWindow } from './conversation-window.js';
2
2
  export function createPersona(config) {
3
3
  const llm = config.llm;
4
+ const configuredWindowModel = config.conversationWindow === false
5
+ ? undefined
6
+ : config.conversationWindow?.model;
7
+ const summaryModel = config.model ?? configuredWindowModel ?? config.defaultSummaryModel;
4
8
  const window = config.conversationWindow !== false
5
- ? createConversationWindow({ ...config.conversationWindow, model: config.model, llm })
9
+ ? createConversationWindow({ ...config.conversationWindow, model: summaryModel, llm })
6
10
  : null;
7
11
  return {
8
12
  async reply(chat) {
package/dist/types.d.ts CHANGED
@@ -379,6 +379,8 @@ export interface AgentSessionOptions {
379
379
  abortSignal?: AbortSignal;
380
380
  /** Supplies the current timeout/cancellation signal for reused sessions. */
381
381
  getAbortSignal?: () => AbortSignal | undefined;
382
+ /** Supplies the remaining managed-session deadline for credential validity checks. */
383
+ getRemainingMs?: () => number;
382
384
  /** Absolute pinned runtime path for the OpenCode adapter. */
383
385
  opencodeExecutable?: string;
384
386
  /** Exact generated MCP tool names accepted by the OpenCode event normalizer. */
@@ -1,16 +1,20 @@
1
1
  function getApiKey(env) {
2
- return env?.ANTHROPIC_API_KEY
3
- || env?.APP_ANTHROPIC_API_KEY
4
- || process.env.ANTHROPIC_API_KEY
5
- || process.env.APP_ANTHROPIC_API_KEY;
2
+ return env === undefined
3
+ ? process.env.ANTHROPIC_API_KEY || process.env.APP_ANTHROPIC_API_KEY
4
+ : env.ANTHROPIC_API_KEY || env.APP_ANTHROPIC_API_KEY;
6
5
  }
7
6
  function resolveBaseUrl(env) {
8
- return env?.ANTHROPIC_BASE_URL
9
- || env?.APP_ANTHROPIC_BASE_URL
10
- || process.env.ANTHROPIC_BASE_URL
11
- || process.env.APP_ANTHROPIC_BASE_URL
7
+ return (env === undefined
8
+ ? process.env.ANTHROPIC_BASE_URL || process.env.APP_ANTHROPIC_BASE_URL
9
+ : env.ANTHROPIC_BASE_URL || env.APP_ANTHROPIC_BASE_URL)
12
10
  || 'https://api.anthropic.com';
13
11
  }
12
+ function normalizeModel(model) {
13
+ const normalized = model.trim();
14
+ return normalized.toLowerCase().startsWith('anthropic/')
15
+ ? normalized.slice('anthropic/'.length)
16
+ : normalized;
17
+ }
14
18
  function resolveMessagesUrl(env) {
15
19
  const baseUrl = resolveBaseUrl(env).replace(/\/+$/, '');
16
20
  const apiRoot = baseUrl.endsWith('/v1') ? baseUrl : `${baseUrl}/v1`;
@@ -79,14 +83,14 @@ export const anthropicProvider = {
79
83
  return !!getApiKey(env);
80
84
  },
81
85
  supportsModel(model) {
82
- return model.trim().toLowerCase().startsWith('claude');
86
+ return normalizeModel(model).toLowerCase().startsWith('claude');
83
87
  },
84
88
  async call(prompt, opts) {
85
89
  const apiKey = getApiKey(opts.env);
86
90
  if (!apiKey) {
87
91
  throw new Error('No ANTHROPIC_API_KEY available');
88
92
  }
89
- const model = opts.model || DEFAULT_ANTHROPIC_MODEL;
93
+ const model = normalizeModel(opts.model || DEFAULT_ANTHROPIC_MODEL);
90
94
  const modelConfig = getAnthropicModelConfig(model);
91
95
  assertRawAnthropicModel(model, modelConfig);
92
96
  const temperature = resolveTemperature(model, modelConfig, opts.temperature);
@@ -120,7 +124,7 @@ export const anthropicProvider = {
120
124
  if (!apiKey) {
121
125
  throw new Error('No ANTHROPIC_API_KEY available');
122
126
  }
123
- const model = opts.model || DEFAULT_ANTHROPIC_MODEL;
127
+ const model = normalizeModel(opts.model || DEFAULT_ANTHROPIC_MODEL);
124
128
  const modelConfig = getAnthropicModelConfig(model);
125
129
  assertRawAnthropicModel(model, modelConfig);
126
130
  const temperature = resolveTemperature(model, modelConfig, opts.temperature);
@@ -1,5 +1,11 @@
1
1
  function getApiKey(env) {
2
- return env?.OPENAI_API_KEY || process.env.OPENAI_API_KEY;
2
+ return env === undefined ? process.env.OPENAI_API_KEY : env.OPENAI_API_KEY;
3
+ }
4
+ function normalizeModel(model) {
5
+ const normalized = model.trim();
6
+ return normalized.toLowerCase().startsWith('openai/')
7
+ ? normalized.slice('openai/'.length)
8
+ : normalized;
3
9
  }
4
10
  export const openaiProvider = {
5
11
  name: 'openai',
@@ -7,7 +13,7 @@ export const openaiProvider = {
7
13
  return !!getApiKey(env);
8
14
  },
9
15
  supportsModel(model) {
10
- const normalized = model.trim().toLowerCase();
16
+ const normalized = normalizeModel(model).toLowerCase();
11
17
  return (normalized.startsWith('gpt-')
12
18
  || normalized.startsWith('chatgpt-')
13
19
  || normalized.startsWith('o1')
@@ -19,9 +25,12 @@ export const openaiProvider = {
19
25
  if (!apiKey) {
20
26
  throw new Error('No OPENAI_API_KEY available');
21
27
  }
22
- const model = opts.model || 'gpt-4o';
28
+ const model = normalizeModel(opts.model || 'gpt-4o');
23
29
  const temperature = opts.temperature ?? 0;
24
- const baseUrl = (opts.env?.OPENAI_BASE_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').replace(/\/$/, '');
30
+ const configuredBaseUrl = opts.env === undefined
31
+ ? process.env.OPENAI_BASE_URL
32
+ : opts.env.OPENAI_BASE_URL;
33
+ const baseUrl = (configuredBaseUrl || 'https://api.openai.com/v1').replace(/\/$/, '');
25
34
  try {
26
35
  const response = await fetch(`${baseUrl}/chat/completions`, {
27
36
  method: 'POST',
@@ -25,7 +25,7 @@ export declare function createLLMClient(opts: CreateLLMClientOptions): LLMPort;
25
25
  export declare function createLLMClient(providers: LLMProviderAdapter[], agentName?: string): LLMPort;
26
26
  export declare function callLLM(prompt: string, opts?: LLMCallOptions): Promise<LLMCallResult>;
27
27
  /**
28
- * Create an LLM client scoped to the providers that match the given agent.
28
+ * Create an LLM client scoped to the providers that match the given agent and model.
29
29
  *
30
30
  * - claude → CLI + Anthropic API (no OpenAI fallthrough)
31
31
  * - codex → OpenAI API
@@ -33,4 +33,4 @@ export declare function callLLM(prompt: string, opts?: LLMCallOptions): Promise<
33
33
  * If `agentEnv` is provided, it is merged into every LLM call so that
34
34
  * the agent's env propagates to persona/judge/summarization calls.
35
35
  */
36
- export declare function createAgentLLM(agentName: string, agentEnv?: Record<string, string>): LLMPort;
36
+ export declare function createAgentLLM(agentName: string, agentEnv?: Record<string, string>, agentModel?: string): LLMPort;
package/dist/utils/llm.js CHANGED
@@ -17,6 +17,10 @@ function inferProviderFromModel(model) {
17
17
  const normalized = model?.trim().toLowerCase();
18
18
  if (!normalized)
19
19
  return undefined;
20
+ if (normalized.startsWith('anthropic/'))
21
+ return 'anthropic';
22
+ if (normalized.startsWith('openai/'))
23
+ return 'openai';
20
24
  if (normalized.startsWith('claude'))
21
25
  return 'anthropic';
22
26
  if (normalized.startsWith('gpt-')
@@ -178,7 +182,7 @@ export async function callLLM(prompt, opts = {}) {
178
182
  return defaultClient.call(prompt, opts);
179
183
  }
180
184
  /**
181
- * Create an LLM client scoped to the providers that match the given agent.
185
+ * Create an LLM client scoped to the providers that match the given agent and model.
182
186
  *
183
187
  * - claude → CLI + Anthropic API (no OpenAI fallthrough)
184
188
  * - codex → OpenAI API
@@ -186,17 +190,18 @@ export async function callLLM(prompt, opts = {}) {
186
190
  * If `agentEnv` is provided, it is merged into every LLM call so that
187
191
  * the agent's env propagates to persona/judge/summarization calls.
188
192
  */
189
- export function createAgentLLM(agentName, agentEnv) {
193
+ export function createAgentLLM(agentName, agentEnv, agentModel) {
190
194
  // Tool-using judges need a provider that implements callWithTools.
191
195
  // For claude, anthropicProvider is added as a tool-use-capable fallback
192
196
  // alongside the CLI. The CLI still wins for plain call() when available.
193
197
  // Cursor inherits the Claude chain by design (judge consistency across
194
198
  // harnesses — see PRD §"LLM-backend routing"). Cursor evals therefore
195
199
  // depend on Claude CLI or ANTHROPIC_API_KEY being available at judge time.
196
- const baseAdapters = agentName === 'codex'
200
+ const openCodeUsesOpenAI = agentName === 'opencode' && agentModel === 'openai/gpt-5.4';
201
+ const baseAdapters = agentName === 'codex' || openCodeUsesOpenAI
197
202
  ? [openaiProvider]
198
203
  : [cliProvider, anthropicProvider];
199
- const adapters = agentEnv && Object.keys(agentEnv).length > 0
204
+ const adapters = agentEnv !== undefined
200
205
  ? baseAdapters.map((a) => ({
201
206
  ...a,
202
207
  isAvailable: (env) => a.isAvailable({ ...agentEnv, ...env }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/pathgrade",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "packageManager": "yarn@4.12.0",
5
5
  "description": "Evaluate whether AI agents discover and use your skills correctly",
6
6
  "exports": {
@@ -132,5 +132,5 @@
132
132
  "typescript": "^5.9.3",
133
133
  "zod": "4.3.6"
134
134
  },
135
- "falconPackageHash": "0bfcf787a0b4fe0c48683c6aa662dd0e3c9679800fdcc9811414571d"
135
+ "falconPackageHash": "48e95ba80576ede589f0a5e47d60b9c94fd53c1ef435d8ca9af35392"
136
136
  }