@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 +10 -11
- package/dist/agents/opencode/contract.d.ts +42 -6
- package/dist/agents/opencode/contract.js +59 -16
- package/dist/agents/opencode/runtime-policy.d.ts +18 -0
- package/dist/agents/opencode/runtime-policy.js +156 -0
- package/dist/agents/opencode.js +40 -55
- package/dist/providers/credentials.d.ts +13 -1
- package/dist/providers/credentials.js +91 -8
- package/dist/providers/sandbox.d.ts +2 -0
- package/dist/providers/workspace.js +19 -2
- package/dist/sdk/agent.js +3 -2
- package/dist/sdk/managed-session.js +1 -0
- package/dist/sdk/persona.d.ts +1 -0
- package/dist/sdk/persona.js +5 -1
- package/dist/types.d.ts +2 -0
- package/dist/utils/llm-providers/anthropic.js +15 -11
- package/dist/utils/llm-providers/openai.js +13 -4
- package/dist/utils/llm.d.ts +2 -2
- package/dist/utils/llm.js +9 -4
- package/package.json +2 -2
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.
|
|
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
|
-
-
|
|
45
|
-
-
|
|
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.
|
|
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
|
|
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;
|
|
434
|
-
| `OPENAI_API_KEY` | Codex auth
|
|
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
|
|
4
|
-
export declare const
|
|
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.
|
|
12
|
-
readonly executableSha256: "
|
|
46
|
+
readonly version: "1.18.14";
|
|
47
|
+
readonly executableSha256: "8b7c4e116c1ac5163c02fa85eee5a13d4d00c8a08e677dac4100f55aa56532fa";
|
|
13
48
|
};
|
|
14
49
|
readonly 'linux-arm64': {
|
|
15
|
-
readonly version: "1.18.
|
|
16
|
-
readonly executableSha256: "
|
|
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
|
|
3
|
-
export const
|
|
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: '
|
|
47
|
+
executableSha256: '8b7c4e116c1ac5163c02fa85eee5a13d4d00c8a08e677dac4100f55aa56532fa',
|
|
8
48
|
},
|
|
9
49
|
'linux-arm64': {
|
|
10
50
|
version: OPENCODE_VERSION,
|
|
11
|
-
executableSha256: '
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
54
|
-
throw new Error(
|
|
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,
|
|
57
|
-
if (!env.
|
|
58
|
-
throw new Error(
|
|
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
|
+
}
|
package/dist/agents/opencode.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
476
|
+
...this.runtimePolicy.environment(),
|
|
492
477
|
OPENCODE_CONFIG_CONTENT: JSON.stringify({
|
|
493
478
|
share: 'disabled',
|
|
494
|
-
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) =>
|
|
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
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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: ___,
|
|
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;
|
package/dist/sdk/persona.d.ts
CHANGED
package/dist/sdk/persona.js
CHANGED
|
@@ -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:
|
|
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
|
|
3
|
-
|| env
|
|
4
|
-
||
|
|
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
|
|
9
|
-
|| env
|
|
10
|
-
||
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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',
|
package/dist/utils/llm.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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.
|
|
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": "
|
|
135
|
+
"falconPackageHash": "48e95ba80576ede589f0a5e47d60b9c94fd53c1ef435d8ca9af35392"
|
|
136
136
|
}
|