@wix/pathgrade 1.0.10 → 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/claude/sdk-message-projector.d.ts +5 -0
- package/dist/agents/claude/sdk-message-projector.js +32 -6
- package/dist/agents/claude/tool-results.d.ts +19 -0
- package/dist/agents/claude/tool-results.js +117 -0
- package/dist/agents/claude.d.ts +4 -0
- package/dist/agents/claude.js +17 -2
- 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 +11 -16
- package/dist/sdk/chat.js +10 -12
- package/dist/sdk/judge-tools.js +23 -2
- package/dist/sdk/managed-session.js +7 -4
- package/dist/sdk/persona.d.ts +1 -0
- package/dist/sdk/persona.js +5 -1
- package/dist/sdk/tool-event-log.d.ts +3 -0
- package/dist/sdk/tool-event-log.js +7 -0
- package/dist/tool-events.d.ts +19 -0
- 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/dist/viewer.html +4 -4
- package/package.json +2 -2
|
@@ -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
|
@@ -9,6 +9,7 @@ import { createManagedSession } from './managed-session.js';
|
|
|
9
9
|
import { createAgentLLM } from '../utils/llm.js';
|
|
10
10
|
import { buildRunSnapshot } from './snapshots.js';
|
|
11
11
|
import { buildModelAgentResultLogEntry } from './agent-result-log.js';
|
|
12
|
+
import { buildToolEventLogEntry } from './tool-event-log.js';
|
|
12
13
|
import { getVisibleAssistantMessage } from './visible-turn.js';
|
|
13
14
|
import { getCurrentCaseContext } from './case-context.js';
|
|
14
15
|
import { createVerboseEmitter } from '../reporters/verbose-emitter.js';
|
|
@@ -140,21 +141,18 @@ class AgentImpl {
|
|
|
140
141
|
this.accumulateTurnUsage(turnResult);
|
|
141
142
|
const response = getVisibleAssistantMessage(turnResult);
|
|
142
143
|
const durationMs = Date.now() - turnStart;
|
|
144
|
+
const turnCompletedAt = timestamp();
|
|
145
|
+
for (const toolEvent of turnResult.toolEvents) {
|
|
146
|
+
this._log.push(buildToolEventLogEntry(toolEvent, turnCompletedAt));
|
|
147
|
+
this.verbose.toolEvent({ action: toolEvent.action, summary: toolEvent.summary });
|
|
148
|
+
}
|
|
143
149
|
this._log.push(buildModelAgentResultLogEntry({
|
|
144
|
-
timestamp:
|
|
150
|
+
timestamp: turnCompletedAt,
|
|
145
151
|
turnNumber,
|
|
146
152
|
durationMs,
|
|
147
153
|
turnResult,
|
|
148
154
|
assistantMessage: response,
|
|
149
155
|
}));
|
|
150
|
-
for (const toolEvent of turnResult.toolEvents) {
|
|
151
|
-
this._log.push({
|
|
152
|
-
type: 'tool_event',
|
|
153
|
-
timestamp: timestamp(),
|
|
154
|
-
tool_event: toolEvent,
|
|
155
|
-
});
|
|
156
|
-
this.verbose.toolEvent({ action: toolEvent.action, summary: toolEvent.summary });
|
|
157
|
-
}
|
|
158
156
|
this._messages.push({ role: 'agent', content: response });
|
|
159
157
|
this.verbose.turnEnd({
|
|
160
158
|
turn: turnNumber,
|
|
@@ -234,11 +232,7 @@ class AgentImpl {
|
|
|
234
232
|
// it undefined, in which case this is a no-op.
|
|
235
233
|
this.accumulateTurnUsage(turnResult);
|
|
236
234
|
for (const toolEvent of turnResult.toolEvents) {
|
|
237
|
-
this._log.push(
|
|
238
|
-
type: 'tool_event',
|
|
239
|
-
timestamp: new Date().toISOString(),
|
|
240
|
-
tool_event: toolEvent,
|
|
241
|
-
});
|
|
235
|
+
this._log.push(buildToolEventLogEntry(toolEvent, new Date().toISOString()));
|
|
242
236
|
}
|
|
243
237
|
// Exit-code failures are no longer thrown here. The runConversation
|
|
244
238
|
// loop projects the partial-turn through `pushModelAgentMessage`
|
|
@@ -257,6 +251,7 @@ class AgentImpl {
|
|
|
257
251
|
...opts.persona,
|
|
258
252
|
llm: opts.persona.llm ?? this.llm,
|
|
259
253
|
conversationWindow: personaWindowConfig,
|
|
254
|
+
defaultSummaryModel: this.modelOpt,
|
|
260
255
|
});
|
|
261
256
|
personaReply = async () => {
|
|
262
257
|
const fakeChatSession = {
|
|
@@ -399,7 +394,7 @@ export async function createAgent(opts) {
|
|
|
399
394
|
const timeoutSetting = opts.timeout ?? 300;
|
|
400
395
|
// Capture runner context now; adapters own installation and restoration.
|
|
401
396
|
const testCtx = opts.debug ? resolveCaseDebugContext() : { name: '', dir: '' };
|
|
402
|
-
const { timeout: _, mcpMock, mcpConfigFile, agent: __, debug: ___,
|
|
397
|
+
const { timeout: _, mcpMock, mcpConfigFile, agent: __, debug: ___, transport: _____, mcpSafety: ______, opencodeExecutable, ...rest } = opts;
|
|
403
398
|
const workspace = await prepareWorkspace({
|
|
404
399
|
...rest,
|
|
405
400
|
agent: agentName,
|
|
@@ -407,7 +402,7 @@ export async function createAgent(opts) {
|
|
|
407
402
|
});
|
|
408
403
|
// Create agent LLM once, using the fully-resolved sandbox env (includes
|
|
409
404
|
// keychain OAuth tokens, API keys, safe host vars).
|
|
410
|
-
const llm = createAgentLLM(agentName, workspace.env);
|
|
405
|
+
const llm = createAgentLLM(agentName, workspace.env, opts.model);
|
|
411
406
|
// Fall back to sandbox dir name if no test name resolved
|
|
412
407
|
const debugName = testCtx.name || path.basename(path.dirname(workspace.path));
|
|
413
408
|
// Default debug dir is next to the eval file, fallback to cwd
|
package/dist/sdk/chat.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { buildModelAgentResultLogEntry } from './agent-result-log.js';
|
|
2
2
|
import { getVisibleAssistantMessage } from './visible-turn.js';
|
|
3
|
+
import { buildToolEventLogEntry } from './tool-event-log.js';
|
|
3
4
|
export class ChatSessionImpl {
|
|
4
5
|
_turn;
|
|
5
6
|
_done = false;
|
|
@@ -51,24 +52,21 @@ export class ChatSessionImpl {
|
|
|
51
52
|
}
|
|
52
53
|
const response = getVisibleAssistantMessage(turnResult);
|
|
53
54
|
const durationMs = Date.now() - turnStart;
|
|
54
|
-
|
|
55
|
-
timestamp: timestamp(),
|
|
56
|
-
turnNumber,
|
|
57
|
-
durationMs,
|
|
58
|
-
turnResult,
|
|
59
|
-
assistantMessage: response,
|
|
60
|
-
}));
|
|
55
|
+
const turnCompletedAt = timestamp();
|
|
61
56
|
for (const toolEvent of turnResult.toolEvents) {
|
|
62
|
-
this.deps.log.push(
|
|
63
|
-
type: 'tool_event',
|
|
64
|
-
timestamp: timestamp(),
|
|
65
|
-
tool_event: toolEvent,
|
|
66
|
-
});
|
|
57
|
+
this.deps.log.push(buildToolEventLogEntry(toolEvent, turnCompletedAt));
|
|
67
58
|
this.deps.verbose?.toolEvent({
|
|
68
59
|
action: toolEvent.action,
|
|
69
60
|
summary: toolEvent.summary,
|
|
70
61
|
});
|
|
71
62
|
}
|
|
63
|
+
this.deps.log.push(buildModelAgentResultLogEntry({
|
|
64
|
+
timestamp: turnCompletedAt,
|
|
65
|
+
turnNumber,
|
|
66
|
+
durationMs,
|
|
67
|
+
turnResult,
|
|
68
|
+
assistantMessage: response,
|
|
69
|
+
}));
|
|
72
70
|
this.deps.messages.push({ role: 'agent', content: response });
|
|
73
71
|
this.deps.verbose?.turnEnd({
|
|
74
72
|
turn: turnNumber,
|