@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.
@@ -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
@@ -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: 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: ___, model: ____, transport: _____, mcpSafety: ______, opencodeExecutable, ...rest } = opts;
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
- this.deps.log.push(buildModelAgentResultLogEntry({
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,