@lesliechan721/agent-skill-eval 0.1.0-beta.7 → 0.1.0-beta.9
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/dist/bin.js +149 -39
- package/dist/bin.js.map +1 -1
- package/dist/codex-eval-app-server.d.ts +199 -0
- package/dist/codex-eval-app-server.js +1366 -0
- package/dist/codex-eval-app-server.js.map +1 -0
- package/dist/codex-eval-cases.d.ts +43 -0
- package/dist/codex-eval-cases.js +327 -0
- package/dist/codex-eval-cases.js.map +1 -0
- package/dist/codex-eval-isolation.d.ts +61 -0
- package/dist/codex-eval-isolation.js +318 -0
- package/dist/codex-eval-isolation.js.map +1 -0
- package/dist/codex-eval-process-tree.d.ts +38 -0
- package/dist/codex-eval-process-tree.js +803 -0
- package/dist/codex-eval-process-tree.js.map +1 -0
- package/dist/codex-eval-runtime-config.d.ts +43 -0
- package/dist/codex-eval-runtime-config.js +607 -0
- package/dist/codex-eval-runtime-config.js.map +1 -0
- package/dist/codex-trigger-eval.d.ts +82 -0
- package/dist/codex-trigger-eval.js +789 -0
- package/dist/codex-trigger-eval.js.map +1 -0
- package/dist/codex-workflow.d.ts +188 -0
- package/dist/codex-workflow.js +1790 -0
- package/dist/codex-workflow.js.map +1 -0
- package/dist/credentials.d.ts +5 -0
- package/dist/credentials.js +30 -0
- package/dist/credentials.js.map +1 -0
- package/dist/custom-process.d.ts +58 -0
- package/dist/custom-process.js +409 -0
- package/dist/custom-process.js.map +1 -0
- package/dist/eval-audit.d.ts +38 -0
- package/dist/eval-audit.js +71 -0
- package/dist/eval-audit.js.map +1 -0
- package/dist/eval-file.d.ts +9 -0
- package/dist/eval-file.js +15 -0
- package/dist/eval-file.js.map +1 -0
- package/dist/eval-result.d.ts +64 -0
- package/dist/eval-result.js +144 -0
- package/dist/eval-result.js.map +1 -0
- package/dist/eval-run.d.ts +23 -0
- package/dist/eval-run.js +539 -0
- package/dist/eval-run.js.map +1 -0
- package/dist/eval-spec.d.ts +57 -0
- package/dist/eval-spec.js +120 -0
- package/dist/eval-spec.js.map +1 -0
- package/dist/eval-suite-binding.d.ts +4 -0
- package/dist/eval-suite-binding.js +19 -0
- package/dist/eval-suite-binding.js.map +1 -0
- package/dist/index.d.ts +34 -9
- package/dist/index.js +18 -6
- package/dist/index.js.map +1 -1
- package/dist/json-schema.js +75 -5
- package/dist/json-schema.js.map +1 -1
- package/dist/output-path.js +2 -2
- package/dist/process-tree.d.ts +19 -1
- package/dist/process-tree.js +321 -66
- package/dist/process-tree.js.map +1 -1
- package/dist/process.d.ts +0 -2
- package/dist/process.js +76 -25
- package/dist/process.js.map +1 -1
- package/dist/provider-doctor.d.ts +33 -0
- package/dist/provider-doctor.js +86 -0
- package/dist/provider-doctor.js.map +1 -0
- package/dist/providers.d.ts +82 -0
- package/dist/providers.js +630 -0
- package/dist/providers.js.map +1 -0
- package/dist/run-paths.d.ts +8 -0
- package/dist/run-paths.js +71 -0
- package/dist/run-paths.js.map +1 -0
- package/dist/runtime-skill.d.ts +6 -0
- package/dist/runtime-skill.js +87 -3
- package/dist/runtime-skill.js.map +1 -1
- package/dist/schemas/digests.json +3 -3
- package/dist/schemas/eval-audit.schema.json +64 -0
- package/dist/schemas/eval-result.schema.json +202 -0
- package/dist/schemas/eval-spec.schema.json +109 -0
- package/dist/schemas.d.ts +1 -3
- package/dist/schemas.js +0 -17
- package/dist/schemas.js.map +1 -1
- package/dist/selected-cases.d.ts +30 -0
- package/dist/selected-cases.js +388 -0
- package/dist/selected-cases.js.map +1 -0
- package/dist/types.d.ts +2 -34
- package/package.json +6 -1
- package/dist/bundle.d.ts +0 -93
- package/dist/bundle.js +0 -216
- package/dist/bundle.js.map +0 -1
- package/dist/capabilities.d.ts +0 -2
- package/dist/capabilities.js +0 -38
- package/dist/capabilities.js.map +0 -1
- package/dist/plan.d.ts +0 -79
- package/dist/plan.js +0 -285
- package/dist/plan.js.map +0 -1
- package/dist/run.d.ts +0 -2
- package/dist/run.js +0 -592
- package/dist/run.js.map +0 -1
- package/dist/schemas/aw-skill-eval-execution-plan.schema.json +0 -220
- package/dist/schemas/eval-run-bundle.schema.json +0 -408
- package/dist/schemas/eval-runtime-capabilities.schema.json +0 -85
|
@@ -0,0 +1,1366 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import { assertProcessTreeInspectionAvailable, CODEX_EVAL_PROCESS_TREE_ID_ENV, createChildProcessTreeMonitor, createDirectChildProcessMonitor, processTreeCleanupIsUnconfirmed, releaseChildProcessHandles, scrubProcessTreeId, } from './codex-eval-process-tree.js';
|
|
6
|
+
import { assertCodexEvalCanStartWork, applyCodexEvalProviderEnvironment, createCodexEvalTemporaryRoot, materializeCodexEvalRuntimeHome, registerCodexEvalSignalCleanup, } from './codex-eval-runtime-config.js';
|
|
7
|
+
import { codexEvalConfigOverrides, validateCodexEvalConfigIsolation, validateCodexEvalRpcIsolation, } from './codex-eval-isolation.js';
|
|
8
|
+
import { validateAwSkillEvalConversationCaseDocument, } from './codex-eval-cases.js';
|
|
9
|
+
const DEFAULT_OBSERVED_PROCESS_EVIDENCE = {
|
|
10
|
+
mode: 'observed',
|
|
11
|
+
inspectionAvailable: true,
|
|
12
|
+
userAcknowledged: false,
|
|
13
|
+
descendantCleanupGuaranteed: false,
|
|
14
|
+
limitation: 'Process-tree inspection observes and cleans known descendants but does not guarantee descendant cleanup.',
|
|
15
|
+
};
|
|
16
|
+
async function createEvalTemporaryRoot(prefix, workRoot) {
|
|
17
|
+
if (!workRoot)
|
|
18
|
+
return createCodexEvalTemporaryRoot(prefix);
|
|
19
|
+
await fs.ensureDir(workRoot);
|
|
20
|
+
return createCodexEvalTemporaryRoot(prefix, {
|
|
21
|
+
allocate: () => fs.mkdtemp(path.join(workRoot, prefix)),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export const CODEX_CONVERSATION_NETWORK_LIMITATION = 'Conversation app-server and tool execution network remains enabled because macOS Seatbelt cannot be nested reliably.';
|
|
25
|
+
export const CODEX_CONVERSATION_FINAL_ANSWER_FALLBACK_LIMITATION = 'For final-answer fallback turns, expected_question_count is checked against answers_by_index length because structured questions are not observable.';
|
|
26
|
+
export async function settleAppServerProcessCleanup(options) {
|
|
27
|
+
const diagnostics = await options.processTreeMonitor.terminate();
|
|
28
|
+
const cleanupUnconfirmed = processTreeCleanupIsUnconfirmed(diagnostics);
|
|
29
|
+
const closed = await options.waitForClose(1_000);
|
|
30
|
+
if (!closed)
|
|
31
|
+
releaseChildProcessHandles(options.child);
|
|
32
|
+
if (cleanupUnconfirmed)
|
|
33
|
+
throw new Error(diagnostics.join(' '));
|
|
34
|
+
if (closed)
|
|
35
|
+
return;
|
|
36
|
+
throw new Error(`Could not confirm Codex app-server process-tree termination. ${diagnostics.join(' ')}`.trim());
|
|
37
|
+
}
|
|
38
|
+
const PASSTHROUGH_ENV = new Set([
|
|
39
|
+
'PATH',
|
|
40
|
+
'LANG',
|
|
41
|
+
'LC_ALL',
|
|
42
|
+
'HTTP_PROXY',
|
|
43
|
+
'HTTPS_PROXY',
|
|
44
|
+
'ALL_PROXY',
|
|
45
|
+
'NO_PROXY',
|
|
46
|
+
'SSL_CERT_FILE',
|
|
47
|
+
'SSL_CERT_DIR',
|
|
48
|
+
'NODE_EXTRA_CA_CERTS',
|
|
49
|
+
]);
|
|
50
|
+
const CODEX_EVAL_APP_SERVER_OVERRIDES = [
|
|
51
|
+
'features.default_mode_request_user_input=true',
|
|
52
|
+
'shell_environment_policy.set.GIT_OPTIONAL_LOCKS="0"',
|
|
53
|
+
];
|
|
54
|
+
const DARWIN_SANDBOX_EXEC = '/usr/bin/sandbox-exec';
|
|
55
|
+
class HostReadContainmentError extends Error {
|
|
56
|
+
}
|
|
57
|
+
function isRecord(value) {
|
|
58
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
export class CodexEvalAttemptControl {
|
|
61
|
+
options;
|
|
62
|
+
controller = new AbortController();
|
|
63
|
+
deadlineAt;
|
|
64
|
+
timer;
|
|
65
|
+
removeExternalAbort = () => undefined;
|
|
66
|
+
timedOut = false;
|
|
67
|
+
externallyAborted = false;
|
|
68
|
+
constructor(options) {
|
|
69
|
+
this.options = options;
|
|
70
|
+
if (options.timeoutMs !== undefined) {
|
|
71
|
+
this.deadlineAt = Date.now() + options.timeoutMs;
|
|
72
|
+
this.timer = setTimeout(() => {
|
|
73
|
+
this.timedOut = true;
|
|
74
|
+
this.controller.abort();
|
|
75
|
+
}, options.timeoutMs);
|
|
76
|
+
this.timer.unref();
|
|
77
|
+
}
|
|
78
|
+
const abort = () => {
|
|
79
|
+
this.externallyAborted = true;
|
|
80
|
+
this.controller.abort();
|
|
81
|
+
};
|
|
82
|
+
if (options.signal?.aborted)
|
|
83
|
+
abort();
|
|
84
|
+
else if (options.signal) {
|
|
85
|
+
options.signal.addEventListener('abort', abort, { once: true });
|
|
86
|
+
this.removeExternalAbort = () => options.signal?.removeEventListener('abort', abort);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
get signal() {
|
|
90
|
+
return this.controller.signal;
|
|
91
|
+
}
|
|
92
|
+
get timeoutExpired() {
|
|
93
|
+
if (this.deadlineAt !== undefined && Date.now() >= this.deadlineAt) {
|
|
94
|
+
this.timedOut = true;
|
|
95
|
+
this.controller.abort();
|
|
96
|
+
}
|
|
97
|
+
return this.timedOut;
|
|
98
|
+
}
|
|
99
|
+
throwIfStopped(stage) {
|
|
100
|
+
if (this.deadlineAt !== undefined && Date.now() >= this.deadlineAt) {
|
|
101
|
+
this.timedOut = true;
|
|
102
|
+
this.controller.abort();
|
|
103
|
+
}
|
|
104
|
+
if (!this.controller.signal.aborted)
|
|
105
|
+
return;
|
|
106
|
+
if (this.timedOut) {
|
|
107
|
+
throw new Error(`${this.options.label} timed out after ${this.options.timeoutMs}ms during ${stage}.`);
|
|
108
|
+
}
|
|
109
|
+
if (this.externallyAborted)
|
|
110
|
+
throw new Error(`${this.options.label} aborted during ${stage}.`);
|
|
111
|
+
throw new Error(`${this.options.label} stopped during ${stage}.`);
|
|
112
|
+
}
|
|
113
|
+
remaining(stage) {
|
|
114
|
+
this.throwIfStopped(stage);
|
|
115
|
+
if (this.deadlineAt === undefined) {
|
|
116
|
+
throw new Error(`${this.options.label} has no deadline during ${stage}.`);
|
|
117
|
+
}
|
|
118
|
+
return Math.max(1, this.deadlineAt - Date.now());
|
|
119
|
+
}
|
|
120
|
+
rpcTimeout(stage, capMs = 30_000) {
|
|
121
|
+
this.throwIfStopped(stage);
|
|
122
|
+
if (this.deadlineAt === undefined)
|
|
123
|
+
return capMs;
|
|
124
|
+
return Math.max(1, Math.min(capMs, this.deadlineAt - Date.now()));
|
|
125
|
+
}
|
|
126
|
+
stopError(stage) {
|
|
127
|
+
try {
|
|
128
|
+
this.throwIfStopped(stage);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
return error;
|
|
132
|
+
}
|
|
133
|
+
return new Error(`${this.options.label} stopped during ${stage}.`);
|
|
134
|
+
}
|
|
135
|
+
dispose() {
|
|
136
|
+
if (this.timer)
|
|
137
|
+
clearTimeout(this.timer);
|
|
138
|
+
this.removeExternalAbort();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function comparablePath(value) {
|
|
142
|
+
const normalized = value.replaceAll('\\', '/');
|
|
143
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
144
|
+
}
|
|
145
|
+
function isPathInsideOrEqual(root, candidate) {
|
|
146
|
+
const relative = path.relative(root, candidate);
|
|
147
|
+
return relative === '' || (relative !== '..' &&
|
|
148
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
149
|
+
!path.isAbsolute(relative));
|
|
150
|
+
}
|
|
151
|
+
function containsForbiddenHostRoot(value, forbiddenHostRoots) {
|
|
152
|
+
if (typeof value === 'string') {
|
|
153
|
+
const candidate = comparablePath(value);
|
|
154
|
+
return forbiddenHostRoots.some((root) => candidate.includes(comparablePath(root)));
|
|
155
|
+
}
|
|
156
|
+
if (Array.isArray(value)) {
|
|
157
|
+
return value.some((entry) => containsForbiddenHostRoot(entry, forbiddenHostRoots));
|
|
158
|
+
}
|
|
159
|
+
if (isRecord(value)) {
|
|
160
|
+
return Object.values(value).some((entry) => containsForbiddenHostRoot(entry, forbiddenHostRoots));
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
function commandInputContainsForbiddenHostRoot(value, forbiddenHostRoots) {
|
|
165
|
+
const commandActions = Array.isArray(value.commandActions)
|
|
166
|
+
? value.commandActions.filter(isRecord).map((action) => ({
|
|
167
|
+
command: action.command,
|
|
168
|
+
path: action.path,
|
|
169
|
+
}))
|
|
170
|
+
: [];
|
|
171
|
+
return containsForbiddenHostRoot([
|
|
172
|
+
value.command,
|
|
173
|
+
value.cwd,
|
|
174
|
+
commandActions,
|
|
175
|
+
], forbiddenHostRoots);
|
|
176
|
+
}
|
|
177
|
+
function eventContainsForbiddenHostSourceInput(value, forbiddenHostRoots) {
|
|
178
|
+
if (!isRecord(value))
|
|
179
|
+
return false;
|
|
180
|
+
const params = isRecord(value.params) ? value.params : {};
|
|
181
|
+
if (value.method === 'item/commandExecution/requestApproval') {
|
|
182
|
+
return commandInputContainsForbiddenHostRoot(params, forbiddenHostRoots)
|
|
183
|
+
|| containsForbiddenHostRoot(params.additionalPermissions, forbiddenHostRoots);
|
|
184
|
+
}
|
|
185
|
+
if (value.method === 'item/fileChange/requestApproval') {
|
|
186
|
+
return containsForbiddenHostRoot(params.grantRoot, forbiddenHostRoots);
|
|
187
|
+
}
|
|
188
|
+
const item = isRecord(params.item) ? params.item : null;
|
|
189
|
+
if (item?.type === 'commandExecution') {
|
|
190
|
+
return commandInputContainsForbiddenHostRoot(item, forbiddenHostRoots);
|
|
191
|
+
}
|
|
192
|
+
if (item?.type === 'fileChange' && Array.isArray(item.changes)) {
|
|
193
|
+
const paths = item.changes.filter(isRecord).map((change) => ({
|
|
194
|
+
path: change.path,
|
|
195
|
+
movePath: isRecord(change.kind) ? change.kind.move_path : undefined,
|
|
196
|
+
}));
|
|
197
|
+
return containsForbiddenHostRoot(paths, forbiddenHostRoots);
|
|
198
|
+
}
|
|
199
|
+
if (item?.type === 'imageView') {
|
|
200
|
+
return containsForbiddenHostRoot(item.path, forbiddenHostRoots);
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
function eventTraceContainsForbiddenHostSourceInput(eventTrace, forbiddenHostRoots) {
|
|
205
|
+
return eventTrace.some((event) => eventContainsForbiddenHostSourceInput(event, forbiddenHostRoots));
|
|
206
|
+
}
|
|
207
|
+
async function canonicalForbiddenHostRoots(roots) {
|
|
208
|
+
const values = await Promise.all(roots.map(async (root) => {
|
|
209
|
+
const resolved = path.resolve(root);
|
|
210
|
+
const real = await fs.realpath(resolved).catch(() => resolved);
|
|
211
|
+
return [resolved, real];
|
|
212
|
+
}));
|
|
213
|
+
return [...new Set(values.flat())];
|
|
214
|
+
}
|
|
215
|
+
function seatbeltPath(value) {
|
|
216
|
+
return JSON.stringify(value);
|
|
217
|
+
}
|
|
218
|
+
function hostReadContainmentProfile(options) {
|
|
219
|
+
return [
|
|
220
|
+
'(version 1)',
|
|
221
|
+
'(allow default)',
|
|
222
|
+
'(deny file-write*)',
|
|
223
|
+
`(allow file-write* (literal ${seatbeltPath('/dev/null')}))`,
|
|
224
|
+
...options.writableRoots.map((root) => `(allow file-write* (subpath ${seatbeltPath(root)}))`),
|
|
225
|
+
...options.unwritableRoots.map((root) => `(deny file-write* (subpath ${seatbeltPath(root)}))`),
|
|
226
|
+
...options.forbiddenHostRoots.map((root) => `(deny file-read* (subpath ${seatbeltPath(root)}))`),
|
|
227
|
+
].join('\n');
|
|
228
|
+
}
|
|
229
|
+
async function assertHostReadContainmentAvailable(profile) {
|
|
230
|
+
if (process.platform !== 'darwin') {
|
|
231
|
+
throw new HostReadContainmentError(`Codex conversation provider cannot start: OS-enforced host read containment is unavailable on ${process.platform}.`);
|
|
232
|
+
}
|
|
233
|
+
await new Promise((resolve, reject) => {
|
|
234
|
+
const child = spawn(DARWIN_SANDBOX_EXEC, ['-p', profile, '/usr/bin/true'], {
|
|
235
|
+
shell: false,
|
|
236
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
237
|
+
});
|
|
238
|
+
let stderr = '';
|
|
239
|
+
child.stderr?.on('data', (chunk) => {
|
|
240
|
+
stderr = `${stderr}${String(chunk)}`.slice(-4_000);
|
|
241
|
+
});
|
|
242
|
+
child.once('error', (error) => reject(new HostReadContainmentError(`Codex conversation provider cannot start OS-enforced host read containment: ${error.message}`)));
|
|
243
|
+
child.once('close', (code, signal) => {
|
|
244
|
+
if (code === 0)
|
|
245
|
+
resolve();
|
|
246
|
+
else
|
|
247
|
+
reject(new HostReadContainmentError('Codex conversation provider cannot start OS-enforced host read containment' +
|
|
248
|
+
` (code=${String(code)}, signal=${String(signal)}): ${stderr.trim() || 'no diagnostic'}`));
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
function requestId(value) {
|
|
253
|
+
const rawId = appServerRequestId(value);
|
|
254
|
+
return rawId === undefined ? undefined : String(rawId);
|
|
255
|
+
}
|
|
256
|
+
function appServerRequestId(value) {
|
|
257
|
+
return typeof value === 'string' || typeof value === 'number'
|
|
258
|
+
? value
|
|
259
|
+
: undefined;
|
|
260
|
+
}
|
|
261
|
+
export function inspectAppServerConfigWarning(frame) {
|
|
262
|
+
if (frame.method !== 'configWarning')
|
|
263
|
+
return null;
|
|
264
|
+
const params = isRecord(frame.params) ? frame.params : {};
|
|
265
|
+
return typeof params.summary === 'string' ? params.summary : JSON.stringify(params);
|
|
266
|
+
}
|
|
267
|
+
function buildIsolatedEnv(sourceEnv, root, codexHome, runtime) {
|
|
268
|
+
const env = {};
|
|
269
|
+
for (const name of PASSTHROUGH_ENV) {
|
|
270
|
+
if (sourceEnv[name] !== undefined) {
|
|
271
|
+
env[name] = sourceEnv[name];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const home = path.join(root, 'home');
|
|
275
|
+
const temp = path.join(root, 'tmp');
|
|
276
|
+
return applyCodexEvalProviderEnvironment({
|
|
277
|
+
...env,
|
|
278
|
+
HOME: home,
|
|
279
|
+
USERPROFILE: home,
|
|
280
|
+
CODEX_HOME: codexHome,
|
|
281
|
+
XDG_CACHE_HOME: path.join(root, 'xdg-cache'),
|
|
282
|
+
XDG_CONFIG_HOME: path.join(root, 'xdg-config'),
|
|
283
|
+
XDG_STATE_HOME: path.join(root, 'xdg-state'),
|
|
284
|
+
TMPDIR: temp,
|
|
285
|
+
TEMP: temp,
|
|
286
|
+
TMP: temp,
|
|
287
|
+
NO_COLOR: '1',
|
|
288
|
+
GIT_OPTIONAL_LOCKS: '0',
|
|
289
|
+
}, runtime);
|
|
290
|
+
}
|
|
291
|
+
async function snapshotWorkspace(root) {
|
|
292
|
+
const snapshot = new Map();
|
|
293
|
+
async function visit(current) {
|
|
294
|
+
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
295
|
+
for (const entry of entries) {
|
|
296
|
+
const absolutePath = path.join(current, entry.name);
|
|
297
|
+
const relativePath = path.relative(root, absolutePath).split(path.sep).join('/');
|
|
298
|
+
if (entry.isDirectory()) {
|
|
299
|
+
snapshot.set(`${relativePath}/`, 'directory');
|
|
300
|
+
await visit(absolutePath);
|
|
301
|
+
}
|
|
302
|
+
else if (entry.isFile()) {
|
|
303
|
+
snapshot.set(relativePath, createHash('sha256').update(await fs.readFile(absolutePath)).digest('hex'));
|
|
304
|
+
}
|
|
305
|
+
else if (entry.isSymbolicLink()) {
|
|
306
|
+
snapshot.set(relativePath, `symlink:${await fs.readlink(absolutePath)}`);
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
snapshot.set(relativePath, 'other');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
await visit(root);
|
|
314
|
+
return snapshot;
|
|
315
|
+
}
|
|
316
|
+
function workspacePathMatchesPattern(relativePath, pattern) {
|
|
317
|
+
if (pattern.endsWith('/**')) {
|
|
318
|
+
const prefix = pattern.slice(0, -3);
|
|
319
|
+
return relativePath === prefix || relativePath.startsWith(`${prefix}/`);
|
|
320
|
+
}
|
|
321
|
+
return relativePath === pattern;
|
|
322
|
+
}
|
|
323
|
+
function workspaceChangeIsAllowed(relativePath, patterns) {
|
|
324
|
+
if (patterns.some((pattern) => workspacePathMatchesPattern(relativePath, pattern))) {
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
if (!relativePath.endsWith('/'))
|
|
328
|
+
return false;
|
|
329
|
+
const directoryPath = relativePath.slice(0, -1);
|
|
330
|
+
return patterns.some((pattern) => {
|
|
331
|
+
const allowedPath = pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern;
|
|
332
|
+
return allowedPath.startsWith(`${directoryPath}/`);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
function jsonContains(actual, expected) {
|
|
336
|
+
if (Array.isArray(expected)) {
|
|
337
|
+
return Array.isArray(actual)
|
|
338
|
+
&& actual.length === expected.length
|
|
339
|
+
&& expected.every((entry, index) => jsonContains(actual[index], entry));
|
|
340
|
+
}
|
|
341
|
+
if (isRecord(expected)) {
|
|
342
|
+
return isRecord(actual)
|
|
343
|
+
&& Object.entries(expected).every(([key, value]) => Object.prototype.hasOwnProperty.call(actual, key) && jsonContains(actual[key], value));
|
|
344
|
+
}
|
|
345
|
+
return Object.is(actual, expected);
|
|
346
|
+
}
|
|
347
|
+
async function readWorkspaceArtifactEvidence(workspaceRoot, expectation) {
|
|
348
|
+
const absolutePath = path.join(workspaceRoot, ...expectation.path.split('/'));
|
|
349
|
+
const stat = await fs.lstat(absolutePath).catch((error) => {
|
|
350
|
+
if (error.code === 'ENOENT')
|
|
351
|
+
return null;
|
|
352
|
+
throw error;
|
|
353
|
+
});
|
|
354
|
+
if (!stat) {
|
|
355
|
+
return { path: expectation.path, exists: false, kind: 'absent' };
|
|
356
|
+
}
|
|
357
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
358
|
+
return { path: expectation.path, exists: true, kind: 'other' };
|
|
359
|
+
}
|
|
360
|
+
const content = await fs.readFile(absolutePath);
|
|
361
|
+
const evidence = {
|
|
362
|
+
path: expectation.path,
|
|
363
|
+
exists: true,
|
|
364
|
+
kind: 'file',
|
|
365
|
+
bytes: content.length,
|
|
366
|
+
sha256: createHash('sha256').update(content).digest('hex'),
|
|
367
|
+
};
|
|
368
|
+
if (content.length <= 64 * 1024 && !content.includes(0)) {
|
|
369
|
+
evidence.text = content.toString('utf8');
|
|
370
|
+
}
|
|
371
|
+
return evidence;
|
|
372
|
+
}
|
|
373
|
+
function validateWorkspaceArtifact(expectation, evidence) {
|
|
374
|
+
const diagnostics = [];
|
|
375
|
+
if (evidence.exists !== expectation.exists) {
|
|
376
|
+
diagnostics.push(`Artifact ${expectation.path} expected exists=${expectation.exists} but exists=${evidence.exists}.`);
|
|
377
|
+
return diagnostics;
|
|
378
|
+
}
|
|
379
|
+
if (!expectation.exists)
|
|
380
|
+
return diagnostics;
|
|
381
|
+
if (evidence.kind !== 'file') {
|
|
382
|
+
diagnostics.push(`Artifact ${expectation.path} must be a regular file.`);
|
|
383
|
+
return diagnostics;
|
|
384
|
+
}
|
|
385
|
+
const text = evidence.text;
|
|
386
|
+
if ((expectation.contains.length > 0 || expectation.notContains.length > 0 || expectation.json) && text === undefined) {
|
|
387
|
+
diagnostics.push(`Artifact ${expectation.path} is binary or exceeds the 64 KiB assertion limit.`);
|
|
388
|
+
return diagnostics;
|
|
389
|
+
}
|
|
390
|
+
for (const fragment of expectation.contains) {
|
|
391
|
+
if (!text.includes(fragment)) {
|
|
392
|
+
diagnostics.push(`Artifact ${expectation.path} is missing required text ${JSON.stringify(fragment)}.`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (const fragment of expectation.notContains) {
|
|
396
|
+
if (text.includes(fragment)) {
|
|
397
|
+
diagnostics.push(`Artifact ${expectation.path} contains forbidden text ${JSON.stringify(fragment)}.`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (expectation.json) {
|
|
401
|
+
let parsed;
|
|
402
|
+
try {
|
|
403
|
+
parsed = JSON.parse(text);
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
diagnostics.push(`Artifact ${expectation.path} must contain valid JSON: ${error instanceof Error ? error.message : String(error)}.`);
|
|
407
|
+
return diagnostics;
|
|
408
|
+
}
|
|
409
|
+
if (!jsonContains(parsed, expectation.json)) {
|
|
410
|
+
diagnostics.push(`Artifact ${expectation.path} JSON does not contain the expected object subset.`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return diagnostics;
|
|
414
|
+
}
|
|
415
|
+
async function isolateWorkspaceSkills(options) {
|
|
416
|
+
const skillsRoot = path.join(options.workspaceRoot, '.agents', 'skills');
|
|
417
|
+
const allowed = new Set(options.skillNames);
|
|
418
|
+
const entries = await fs.readdir(skillsRoot).catch((error) => {
|
|
419
|
+
if (error.code === 'ENOENT')
|
|
420
|
+
return [];
|
|
421
|
+
throw error;
|
|
422
|
+
});
|
|
423
|
+
await Promise.all(entries.map(async (entry) => {
|
|
424
|
+
if (allowed.has(entry))
|
|
425
|
+
return;
|
|
426
|
+
await fs.remove(path.join(skillsRoot, entry));
|
|
427
|
+
}));
|
|
428
|
+
const roots = options.skillNames.map((skillName) => path.join(skillsRoot, skillName));
|
|
429
|
+
for (const root of roots) {
|
|
430
|
+
if (!(await fs.pathExists(path.join(root, 'SKILL.md')))) {
|
|
431
|
+
throw new Error(`Materialized eval Skill is missing: ${root}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return roots;
|
|
435
|
+
}
|
|
436
|
+
function diffSnapshots(before, after) {
|
|
437
|
+
const paths = new Set([...before.keys(), ...after.keys()]);
|
|
438
|
+
return [...paths]
|
|
439
|
+
.filter((entry) => before.get(entry) !== after.get(entry))
|
|
440
|
+
.sort((left, right) => left.localeCompare(right));
|
|
441
|
+
}
|
|
442
|
+
export class EvalAppServerSession {
|
|
443
|
+
process;
|
|
444
|
+
processTreeId = randomUUID();
|
|
445
|
+
processTreeMonitor;
|
|
446
|
+
pending = new Map();
|
|
447
|
+
handlers = new Set();
|
|
448
|
+
failureHandlers = new Set();
|
|
449
|
+
stdoutBuffer = '';
|
|
450
|
+
stderrBuffer = '';
|
|
451
|
+
nextId = 1;
|
|
452
|
+
terminalError;
|
|
453
|
+
closing = false;
|
|
454
|
+
closed;
|
|
455
|
+
resolveClosed;
|
|
456
|
+
cleanupPromise;
|
|
457
|
+
unregisterSignalCleanup = () => undefined;
|
|
458
|
+
constructor(options) {
|
|
459
|
+
assertCodexEvalCanStartWork();
|
|
460
|
+
const processManagement = options.processManagement ?? DEFAULT_OBSERVED_PROCESS_EVIDENCE;
|
|
461
|
+
const managed = processManagement.mode === 'managed' || processManagement.mode === 'observed';
|
|
462
|
+
this.closed = new Promise((resolve) => {
|
|
463
|
+
this.resolveClosed = resolve;
|
|
464
|
+
});
|
|
465
|
+
const args = [
|
|
466
|
+
'app-server',
|
|
467
|
+
...(options.isolation === 'strict' ? ['--strict-config'] : []),
|
|
468
|
+
'--listen',
|
|
469
|
+
'stdio://',
|
|
470
|
+
'--config',
|
|
471
|
+
'skills.include_instructions=true',
|
|
472
|
+
'--config',
|
|
473
|
+
`shell_environment_policy.exclude=${JSON.stringify([
|
|
474
|
+
'CODEX_HOME',
|
|
475
|
+
...options.sensitiveEnvironmentNames,
|
|
476
|
+
])}`,
|
|
477
|
+
'--config',
|
|
478
|
+
`model_provider=${JSON.stringify(options.modelProvider)}`,
|
|
479
|
+
];
|
|
480
|
+
for (const override of codexEvalConfigOverrides(options.isolation)) {
|
|
481
|
+
args.push('--config', override);
|
|
482
|
+
}
|
|
483
|
+
for (const override of CODEX_EVAL_APP_SERVER_OVERRIDES) {
|
|
484
|
+
args.push('--config', override);
|
|
485
|
+
}
|
|
486
|
+
for (const override of options.configOverrides ?? []) {
|
|
487
|
+
args.push('--config', override);
|
|
488
|
+
}
|
|
489
|
+
const executable = options.hostReadContainmentProfile
|
|
490
|
+
? DARWIN_SANDBOX_EXEC
|
|
491
|
+
: options.codexBin;
|
|
492
|
+
const executableArgs = options.hostReadContainmentProfile
|
|
493
|
+
? ['-p', options.hostReadContainmentProfile, options.codexBin, ...args]
|
|
494
|
+
: args;
|
|
495
|
+
this.process = spawn(executable, executableArgs, {
|
|
496
|
+
cwd: options.cwd,
|
|
497
|
+
env: managed
|
|
498
|
+
? { ...options.env, [CODEX_EVAL_PROCESS_TREE_ID_ENV]: this.processTreeId }
|
|
499
|
+
: { ...options.env },
|
|
500
|
+
shell: false,
|
|
501
|
+
detached: managed && process.platform !== 'win32',
|
|
502
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
503
|
+
});
|
|
504
|
+
this.processTreeMonitor = managed
|
|
505
|
+
? createChildProcessTreeMonitor(this.process, { treeId: this.processTreeId })
|
|
506
|
+
: createDirectChildProcessMonitor(this.process);
|
|
507
|
+
this.process.stdout.setEncoding?.('utf8');
|
|
508
|
+
this.process.stderr.setEncoding?.('utf8');
|
|
509
|
+
this.process.stdout.on('data', (chunk) => {
|
|
510
|
+
this.handleChunk((typeof chunk === 'string' ? chunk : chunk.toString('utf8'))
|
|
511
|
+
.replaceAll(this.processTreeId, '[process-tree-id]'));
|
|
512
|
+
});
|
|
513
|
+
this.process.stderr.on('data', (chunk) => {
|
|
514
|
+
this.stderrBuffer = `${this.stderrBuffer}${String(chunk)}`
|
|
515
|
+
.replaceAll(this.processTreeId, '[process-tree-id]')
|
|
516
|
+
.slice(-32_000);
|
|
517
|
+
});
|
|
518
|
+
this.process.once('error', (error) => this.fail(error));
|
|
519
|
+
this.process.once('close', (code, signal) => {
|
|
520
|
+
this.resolveClosed?.();
|
|
521
|
+
if (!this.closing) {
|
|
522
|
+
this.fail(new Error(`codex app-server exited early (code=${code}, signal=${signal}).`));
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
this.unregisterSignalCleanup = registerCodexEvalSignalCleanup(() => this.cleanup());
|
|
526
|
+
}
|
|
527
|
+
onFrame(handler) {
|
|
528
|
+
this.handlers.add(handler);
|
|
529
|
+
return () => this.handlers.delete(handler);
|
|
530
|
+
}
|
|
531
|
+
onFailure(handler) {
|
|
532
|
+
this.failureHandlers.add(handler);
|
|
533
|
+
if (this.terminalError) {
|
|
534
|
+
handler(this.terminalError);
|
|
535
|
+
}
|
|
536
|
+
return () => this.failureHandlers.delete(handler);
|
|
537
|
+
}
|
|
538
|
+
async initialize(timeoutMs = 30_000) {
|
|
539
|
+
await this.sendRequest('initialize', {
|
|
540
|
+
clientInfo: {
|
|
541
|
+
name: 'aw-skill-eval-provider',
|
|
542
|
+
title: 'Agent Workflows Plugin Eval',
|
|
543
|
+
version: '1',
|
|
544
|
+
},
|
|
545
|
+
capabilities: {
|
|
546
|
+
experimentalApi: true,
|
|
547
|
+
requestAttestation: false,
|
|
548
|
+
},
|
|
549
|
+
}, timeoutMs);
|
|
550
|
+
await this.write({ method: 'initialized' });
|
|
551
|
+
}
|
|
552
|
+
async sendRequest(method, params, timeoutMs = 30_000) {
|
|
553
|
+
if (this.terminalError) {
|
|
554
|
+
throw this.terminalError;
|
|
555
|
+
}
|
|
556
|
+
const id = String(this.nextId++);
|
|
557
|
+
return new Promise((resolve, reject) => {
|
|
558
|
+
const timer = setTimeout(() => {
|
|
559
|
+
this.pending.delete(id);
|
|
560
|
+
reject(new Error(`Timed out waiting for app-server method ${method}.`));
|
|
561
|
+
}, timeoutMs);
|
|
562
|
+
this.pending.set(id, { method, resolve, reject, timer });
|
|
563
|
+
void this.write({ id, method, params }).catch((error) => {
|
|
564
|
+
clearTimeout(timer);
|
|
565
|
+
this.pending.delete(id);
|
|
566
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
async respond(id, result) {
|
|
571
|
+
await this.write({ id, result });
|
|
572
|
+
}
|
|
573
|
+
async cleanup() {
|
|
574
|
+
this.cleanupPromise ??= this.cleanupProcess();
|
|
575
|
+
return this.cleanupPromise;
|
|
576
|
+
}
|
|
577
|
+
async cleanupProcess() {
|
|
578
|
+
this.closing = true;
|
|
579
|
+
for (const pending of this.pending.values()) {
|
|
580
|
+
clearTimeout(pending.timer);
|
|
581
|
+
pending.reject(new Error('app-server session closed.'));
|
|
582
|
+
}
|
|
583
|
+
this.pending.clear();
|
|
584
|
+
try {
|
|
585
|
+
await settleAppServerProcessCleanup({
|
|
586
|
+
child: this.process,
|
|
587
|
+
processTreeMonitor: this.processTreeMonitor,
|
|
588
|
+
waitForClose: (timeoutMs) => this.waitForClose(timeoutMs),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
finally {
|
|
592
|
+
this.unregisterSignalCleanup();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
get stderr() {
|
|
596
|
+
return this.stderrBuffer.replaceAll(this.processTreeId, '[process-tree-id]');
|
|
597
|
+
}
|
|
598
|
+
scrub(value) {
|
|
599
|
+
return scrubProcessTreeId(value, this.processTreeId);
|
|
600
|
+
}
|
|
601
|
+
async waitForClose(timeoutMs) {
|
|
602
|
+
let timer;
|
|
603
|
+
const result = await Promise.race([
|
|
604
|
+
this.closed.then(() => true),
|
|
605
|
+
new Promise((resolve) => {
|
|
606
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
607
|
+
}),
|
|
608
|
+
]);
|
|
609
|
+
if (timer)
|
|
610
|
+
clearTimeout(timer);
|
|
611
|
+
return result;
|
|
612
|
+
}
|
|
613
|
+
async write(frame) {
|
|
614
|
+
if (!this.process.stdin) {
|
|
615
|
+
throw new Error('codex app-server stdin is unavailable.');
|
|
616
|
+
}
|
|
617
|
+
await new Promise((resolve, reject) => {
|
|
618
|
+
this.process.stdin.write(`${JSON.stringify(frame)}\n`, (error) => {
|
|
619
|
+
if (error) {
|
|
620
|
+
reject(error);
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
resolve();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
handleChunk(chunk) {
|
|
629
|
+
this.stdoutBuffer += chunk;
|
|
630
|
+
let newlineIndex = this.stdoutBuffer.indexOf('\n');
|
|
631
|
+
while (newlineIndex >= 0) {
|
|
632
|
+
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/u, '');
|
|
633
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
|
634
|
+
this.handleLine(line);
|
|
635
|
+
newlineIndex = this.stdoutBuffer.indexOf('\n');
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
handleLine(line) {
|
|
639
|
+
if (!line.trim()) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
let parsed;
|
|
643
|
+
try {
|
|
644
|
+
parsed = JSON.parse(line);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
this.fail(new Error(`Invalid app-server JSONL: ${line.slice(0, 240)}`));
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
if (!isRecord(parsed)) {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const id = requestId(parsed.id);
|
|
654
|
+
if (id && ('result' in parsed || 'error' in parsed)) {
|
|
655
|
+
const pending = this.pending.get(id);
|
|
656
|
+
if (!pending) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
clearTimeout(pending.timer);
|
|
660
|
+
this.pending.delete(id);
|
|
661
|
+
if ('error' in parsed) {
|
|
662
|
+
pending.reject(new Error(`app-server ${pending.method} failed: ${JSON.stringify(parsed.error)}`));
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
pending.resolve(parsed.result);
|
|
666
|
+
}
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
for (const handler of this.handlers) {
|
|
670
|
+
void Promise.resolve(handler(parsed)).catch((error) => this.fail(error instanceof Error ? error : new Error(String(error))));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
fail(error) {
|
|
674
|
+
if (this.terminalError) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
this.terminalError = error;
|
|
678
|
+
for (const pending of this.pending.values()) {
|
|
679
|
+
clearTimeout(pending.timer);
|
|
680
|
+
pending.reject(error);
|
|
681
|
+
}
|
|
682
|
+
this.pending.clear();
|
|
683
|
+
for (const handler of this.failureHandlers) {
|
|
684
|
+
handler(error);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
async function collectStrictIsolationEvidence(options) {
|
|
689
|
+
const [config, requirements] = await Promise.all([
|
|
690
|
+
options.session.sendRequest('config/read', {
|
|
691
|
+
includeLayers: true,
|
|
692
|
+
cwd: options.workspaceRoot,
|
|
693
|
+
}, options.control?.rpcTimeout('config/read') ?? 30_000),
|
|
694
|
+
options.session.sendRequest('configRequirements/read', {}, options.control?.rpcTimeout('configRequirements/read') ?? 30_000),
|
|
695
|
+
]);
|
|
696
|
+
validateCodexEvalConfigIsolation({
|
|
697
|
+
config,
|
|
698
|
+
requirements,
|
|
699
|
+
codexHome: options.codexHome,
|
|
700
|
+
workspaceRoot: options.workspaceRoot,
|
|
701
|
+
});
|
|
702
|
+
const [skills, mcpServers] = await Promise.all([
|
|
703
|
+
options.session.sendRequest('skills/list', {
|
|
704
|
+
cwds: [options.workspaceRoot],
|
|
705
|
+
forceReload: true,
|
|
706
|
+
}, options.control?.rpcTimeout('skills/list') ?? 30_000),
|
|
707
|
+
options.session.sendRequest('mcpServerStatus/list', {
|
|
708
|
+
cursor: null,
|
|
709
|
+
limit: 100,
|
|
710
|
+
detail: 'toolsAndAuthOnly',
|
|
711
|
+
threadId: null,
|
|
712
|
+
}, options.control?.rpcTimeout('mcpServerStatus/list') ?? 30_000),
|
|
713
|
+
]);
|
|
714
|
+
return validateCodexEvalRpcIsolation({
|
|
715
|
+
config,
|
|
716
|
+
requirements,
|
|
717
|
+
skills,
|
|
718
|
+
mcpServers,
|
|
719
|
+
codexHome: options.codexHome,
|
|
720
|
+
workspaceRoot: options.workspaceRoot,
|
|
721
|
+
allowedSkillRoots: options.allowedSkillRoots,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
function normalizeScriptedReply(value, label) {
|
|
725
|
+
if (typeof value === 'string') {
|
|
726
|
+
return {
|
|
727
|
+
expectedQuestionCount: 1,
|
|
728
|
+
answersByIndex: [value],
|
|
729
|
+
unexpectedQuestionAnswer: '我暂时不知道,请先处理已经回答的部分。',
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
if (!isRecord(value)) {
|
|
733
|
+
throw new Error(`${label} must be a string or object.`);
|
|
734
|
+
}
|
|
735
|
+
if (!Array.isArray(value.answers_by_index) || value.answers_by_index.some((entry) => typeof entry !== 'string')) {
|
|
736
|
+
throw new Error(`${label}.answers_by_index must be an array of strings.`);
|
|
737
|
+
}
|
|
738
|
+
const expectedQuestionCount = value.expected_question_count;
|
|
739
|
+
if (expectedQuestionCount !== undefined &&
|
|
740
|
+
(!Number.isInteger(expectedQuestionCount) || expectedQuestionCount < 0)) {
|
|
741
|
+
throw new Error(`${label}.expected_question_count must be a non-negative integer.`);
|
|
742
|
+
}
|
|
743
|
+
const fallback = value.unexpected_question_answer;
|
|
744
|
+
if (typeof fallback !== 'string' || fallback.trim() === '') {
|
|
745
|
+
throw new Error(`${label}.unexpected_question_answer must be a non-empty string.`);
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
...(expectedQuestionCount === undefined
|
|
749
|
+
? {}
|
|
750
|
+
: { expectedQuestionCount: expectedQuestionCount }),
|
|
751
|
+
answersByIndex: value.answers_by_index,
|
|
752
|
+
unexpectedQuestionAnswer: fallback,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
function normalizeConversationCase(value, index) {
|
|
756
|
+
const review = isRecord(value.review) ? value.review : {};
|
|
757
|
+
if (typeof value.id !== 'string' ||
|
|
758
|
+
typeof value.prompt !== 'string' ||
|
|
759
|
+
!Array.isArray(value.scripted_replies) ||
|
|
760
|
+
typeof value.expected_output !== 'string' ||
|
|
761
|
+
!Array.isArray(review.checks) ||
|
|
762
|
+
review.checks.some((entry) => typeof entry !== 'string')) {
|
|
763
|
+
throw new Error(`evals[${index}] has an invalid behavior case shape.`);
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
id: value.id.trim(),
|
|
767
|
+
prompt: value.prompt,
|
|
768
|
+
scriptedReplies: value.scripted_replies.map((entry, replyIndex) => normalizeScriptedReply(entry, `evals[${index}].scripted_replies[${replyIndex}]`)),
|
|
769
|
+
expectedOutput: value.expected_output,
|
|
770
|
+
checks: review.checks,
|
|
771
|
+
workspaceFiles: isRecord(value.workspace_files)
|
|
772
|
+
? Object.fromEntries(Object.entries(value.workspace_files).map(([relativePath, content]) => [
|
|
773
|
+
relativePath,
|
|
774
|
+
content,
|
|
775
|
+
]))
|
|
776
|
+
: {},
|
|
777
|
+
workspaceUnchanged: review.workspace_unchanged === true,
|
|
778
|
+
sandbox: value.sandbox === 'workspace-write' ? 'workspace-write' : 'read-only',
|
|
779
|
+
allowedWorkspaceChanges: Array.isArray(review.allowed_workspace_changes)
|
|
780
|
+
? review.allowed_workspace_changes
|
|
781
|
+
: [],
|
|
782
|
+
artifacts: Array.isArray(review.artifacts)
|
|
783
|
+
? review.artifacts.map((artifact) => {
|
|
784
|
+
const row = artifact;
|
|
785
|
+
return {
|
|
786
|
+
path: row.path,
|
|
787
|
+
exists: row.exists !== false,
|
|
788
|
+
contains: Array.isArray(row.contains) ? row.contains : [],
|
|
789
|
+
notContains: Array.isArray(row.not_contains) ? row.not_contains : [],
|
|
790
|
+
...(isRecord(row.json) ? { json: row.json } : {}),
|
|
791
|
+
};
|
|
792
|
+
})
|
|
793
|
+
: [],
|
|
794
|
+
environmentPathPrepend: isRecord(value.environment) &&
|
|
795
|
+
Array.isArray(value.environment.pathPrepend)
|
|
796
|
+
? value.environment.pathPrepend
|
|
797
|
+
: [],
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
export function normalizeConversationProviderCase(options) {
|
|
801
|
+
const [value] = validateAwSkillEvalConversationCaseDocument({ skill_name: options.expectedSkill, evals: [options.value] }, options.expectedSkill, 'conversation provider case', options.fixtureRoot, options.suitePathPrepend);
|
|
802
|
+
return normalizeConversationCase(value, 0);
|
|
803
|
+
}
|
|
804
|
+
async function assertFixtureTreeSafe(root, target, label) {
|
|
805
|
+
const rootRealPath = await fs.realpath(root);
|
|
806
|
+
const targetRealPath = await fs.realpath(target);
|
|
807
|
+
if (!isPathInsideOrEqual(rootRealPath, targetRealPath)) {
|
|
808
|
+
throw new Error(`${label} must stay inside its Eval fixture root.`);
|
|
809
|
+
}
|
|
810
|
+
const relative = path.relative(root, target);
|
|
811
|
+
let current = root;
|
|
812
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
813
|
+
current = path.join(current, segment);
|
|
814
|
+
const stat = await fs.lstat(current);
|
|
815
|
+
if (stat.isSymbolicLink())
|
|
816
|
+
throw new Error(`${label} must not contain symbolic links.`);
|
|
817
|
+
}
|
|
818
|
+
const stat = await fs.lstat(target);
|
|
819
|
+
if (stat.isFile())
|
|
820
|
+
return;
|
|
821
|
+
if (!stat.isDirectory())
|
|
822
|
+
throw new Error(`${label} must be a regular file or directory.`);
|
|
823
|
+
const entries = await fs.readdir(target);
|
|
824
|
+
for (const entry of entries) {
|
|
825
|
+
await assertFixtureTreeSafe(root, path.join(target, entry), label);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async function makeFixtureExecutable(target) {
|
|
829
|
+
const stat = await fs.lstat(target);
|
|
830
|
+
if (stat.isFile()) {
|
|
831
|
+
await fs.chmod(target, 0o755);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (!stat.isDirectory())
|
|
835
|
+
return;
|
|
836
|
+
for (const entry of await fs.readdir(target)) {
|
|
837
|
+
await makeFixtureExecutable(path.join(target, entry));
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
async function materializeConversationEnvironment(options) {
|
|
841
|
+
const pathEntries = [];
|
|
842
|
+
const materialized = new Map();
|
|
843
|
+
for (const { fixtureRoot, relativePath } of options.fixtures) {
|
|
844
|
+
const source = path.resolve(fixtureRoot, relativePath);
|
|
845
|
+
await assertFixtureTreeSafe(fixtureRoot, source, `Conversation PATH fixture ${JSON.stringify(relativePath)}`);
|
|
846
|
+
const target = path.resolve(options.workspaceRoot, relativePath);
|
|
847
|
+
if (!isPathInsideOrEqual(options.workspaceRoot, target)) {
|
|
848
|
+
throw new Error(`Conversation PATH fixture must stay inside the eval workspace: ${relativePath}`);
|
|
849
|
+
}
|
|
850
|
+
const sourceRealPath = await fs.realpath(source);
|
|
851
|
+
const previousSource = materialized.get(target);
|
|
852
|
+
if (previousSource) {
|
|
853
|
+
if (previousSource !== sourceRealPath) {
|
|
854
|
+
throw new Error(`Conversation PATH fixtures conflict at: ${relativePath}`);
|
|
855
|
+
}
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (await fs.pathExists(target)) {
|
|
859
|
+
throw new Error(`Conversation PATH fixture conflicts with materialized subject: ${relativePath}`);
|
|
860
|
+
}
|
|
861
|
+
await fs.copy(source, target, { dereference: false, errorOnExist: true, overwrite: false });
|
|
862
|
+
await makeFixtureExecutable(target);
|
|
863
|
+
materialized.set(target, sourceRealPath);
|
|
864
|
+
const stat = await fs.lstat(target);
|
|
865
|
+
pathEntries.push(stat.isDirectory() ? target : path.dirname(target));
|
|
866
|
+
}
|
|
867
|
+
return pathEntries;
|
|
868
|
+
}
|
|
869
|
+
async function materializeConversationWorkspaceFiles(workspaceRoot, workspaceFiles) {
|
|
870
|
+
for (const [relativePath, content] of Object.entries(workspaceFiles)) {
|
|
871
|
+
const segments = relativePath.split('/');
|
|
872
|
+
const fileName = segments.pop();
|
|
873
|
+
let parent = workspaceRoot;
|
|
874
|
+
for (const segment of segments) {
|
|
875
|
+
parent = path.join(parent, segment);
|
|
876
|
+
if (await fs.pathExists(parent)) {
|
|
877
|
+
const stat = await fs.lstat(parent);
|
|
878
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
879
|
+
throw new Error(`Conversation workspace file has an unsafe parent path: ${relativePath}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
await fs.mkdir(parent);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const target = path.join(parent, fileName);
|
|
887
|
+
if (await fs.pathExists(target)) {
|
|
888
|
+
throw new Error(`Conversation workspace file conflicts with materialized subject: ${relativePath}`);
|
|
889
|
+
}
|
|
890
|
+
await fs.writeFile(target, content, 'utf8');
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
export function extractAppServerThreadId(result) {
|
|
894
|
+
if (!isRecord(result) || !isRecord(result.thread) || typeof result.thread.id !== 'string') {
|
|
895
|
+
throw new Error('app-server thread/start response is missing thread.id.');
|
|
896
|
+
}
|
|
897
|
+
return result.thread.id;
|
|
898
|
+
}
|
|
899
|
+
function finalAnswerReplyText(scripted) {
|
|
900
|
+
return [
|
|
901
|
+
'Scripted user reply (answers_by_index order):',
|
|
902
|
+
...scripted.answersByIndex.map((answer, index) => `${index + 1}. ${answer}`),
|
|
903
|
+
].join('\n');
|
|
904
|
+
}
|
|
905
|
+
async function runConversationOnce(options) {
|
|
906
|
+
assertCodexEvalCanStartWork();
|
|
907
|
+
const control = new CodexEvalAttemptControl({
|
|
908
|
+
label: 'conversation evaluation',
|
|
909
|
+
...(options.manageTimeout === false ? {} : { timeoutMs: options.timeoutMs }),
|
|
910
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
911
|
+
});
|
|
912
|
+
const temporaryRoot = await createEvalTemporaryRoot('conversation-', options.forbiddenHostRoots.length > 0 ? undefined : options.workRoot);
|
|
913
|
+
const runRoot = await fs.realpath(temporaryRoot.root);
|
|
914
|
+
const workspaceRoot = path.join(runRoot, 'project');
|
|
915
|
+
const codexHome = path.join(runRoot, 'codex-home');
|
|
916
|
+
const diagnostics = [];
|
|
917
|
+
const scriptedReplyMismatches = [];
|
|
918
|
+
const questions = [];
|
|
919
|
+
const fallbackUserReplies = [];
|
|
920
|
+
const finalMessages = [];
|
|
921
|
+
const eventTrace = [];
|
|
922
|
+
let usage = null;
|
|
923
|
+
let isolationEvidence = null;
|
|
924
|
+
let session;
|
|
925
|
+
let removeHandler;
|
|
926
|
+
let removeFailureHandler;
|
|
927
|
+
let removeAbortHandler;
|
|
928
|
+
const startedAt = Date.now();
|
|
929
|
+
try {
|
|
930
|
+
options.onStage?.('workspace-materialization-started');
|
|
931
|
+
const containmentProfile = hostReadContainmentProfile({
|
|
932
|
+
forbiddenHostRoots: options.forbiddenHostRoots,
|
|
933
|
+
writableRoots: [runRoot],
|
|
934
|
+
unwritableRoots: options.evalCase.sandbox === 'read-only' ? [workspaceRoot] : [],
|
|
935
|
+
});
|
|
936
|
+
options.onStage?.('containment-admission-started');
|
|
937
|
+
await assertHostReadContainmentAvailable(containmentProfile);
|
|
938
|
+
options.onStage?.('containment-admitted');
|
|
939
|
+
await fs.copy(options.sourceProjectRoot, workspaceRoot, { dereference: false });
|
|
940
|
+
const environmentPathEntries = await materializeConversationEnvironment({
|
|
941
|
+
workspaceRoot,
|
|
942
|
+
fixtures: options.environmentFixtures,
|
|
943
|
+
});
|
|
944
|
+
await materializeConversationWorkspaceFiles(workspaceRoot, options.evalCase.workspaceFiles);
|
|
945
|
+
let allowedSkillRoots = [];
|
|
946
|
+
if (options.isolation === 'strict') {
|
|
947
|
+
const visibleSkills = options.variant === 'with_skill'
|
|
948
|
+
? options.skillNames
|
|
949
|
+
: options.skillNames.filter((skillName) => skillName !== options.skillName);
|
|
950
|
+
allowedSkillRoots = await isolateWorkspaceSkills({
|
|
951
|
+
workspaceRoot,
|
|
952
|
+
skillNames: visibleSkills,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
else if (options.variant === 'without_skill') {
|
|
956
|
+
await fs.remove(path.join(workspaceRoot, '.agents', 'skills', options.skillName));
|
|
957
|
+
}
|
|
958
|
+
const env = buildIsolatedEnv(options.sourceEnv, runRoot, codexHome, options.codexRuntime);
|
|
959
|
+
env.PATH = [...environmentPathEntries, env.PATH].filter(Boolean).join(path.delimiter);
|
|
960
|
+
await Promise.all([
|
|
961
|
+
fs.ensureDir(env.HOME),
|
|
962
|
+
fs.ensureDir(env.TMPDIR),
|
|
963
|
+
materializeCodexEvalRuntimeHome(options.codexRuntime, codexHome, {
|
|
964
|
+
trustedProject: workspaceRoot,
|
|
965
|
+
}),
|
|
966
|
+
]);
|
|
967
|
+
options.onStage?.('workspace-materialized');
|
|
968
|
+
const before = await snapshotWorkspace(workspaceRoot);
|
|
969
|
+
if (options.processManagement.mode === 'managed' ||
|
|
970
|
+
options.processManagement.mode === 'observed') {
|
|
971
|
+
await assertProcessTreeInspectionAvailable();
|
|
972
|
+
}
|
|
973
|
+
control.throwIfStopped('initialize');
|
|
974
|
+
assertCodexEvalCanStartWork();
|
|
975
|
+
session = new EvalAppServerSession({
|
|
976
|
+
codexBin: options.codexBin,
|
|
977
|
+
cwd: workspaceRoot,
|
|
978
|
+
env,
|
|
979
|
+
modelProvider: options.codexRuntime.modelProvider,
|
|
980
|
+
sensitiveEnvironmentNames: options.codexRuntime.sensitiveEnvironmentNames,
|
|
981
|
+
isolation: options.isolation,
|
|
982
|
+
processManagement: options.processManagement,
|
|
983
|
+
hostReadContainmentProfile: containmentProfile,
|
|
984
|
+
});
|
|
985
|
+
options.onStage?.('session-started');
|
|
986
|
+
let replyStep = 0;
|
|
987
|
+
let turnResolve;
|
|
988
|
+
let turnReject;
|
|
989
|
+
let turnUsedRequestUserInput = false;
|
|
990
|
+
let turnProducedFinalAnswer = false;
|
|
991
|
+
const abort = () => {
|
|
992
|
+
const error = control.stopError('conversation turn wait');
|
|
993
|
+
turnReject?.(error);
|
|
994
|
+
void session?.cleanup().catch((cleanupError) => {
|
|
995
|
+
turnReject?.(cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)));
|
|
996
|
+
});
|
|
997
|
+
};
|
|
998
|
+
if (control.signal.aborted)
|
|
999
|
+
abort();
|
|
1000
|
+
else {
|
|
1001
|
+
control.signal.addEventListener('abort', abort, { once: true });
|
|
1002
|
+
removeAbortHandler = () => control.signal.removeEventListener('abort', abort);
|
|
1003
|
+
}
|
|
1004
|
+
removeFailureHandler = session.onFailure((error) => turnReject?.(error));
|
|
1005
|
+
removeHandler = session.onFrame(async (frame) => {
|
|
1006
|
+
const method = typeof frame.method === 'string' ? frame.method : '';
|
|
1007
|
+
const params = isRecord(frame.params) ? frame.params : {};
|
|
1008
|
+
if (!method.startsWith('remoteControl/') && !method.startsWith('account/')) {
|
|
1009
|
+
eventTrace.push(frame);
|
|
1010
|
+
}
|
|
1011
|
+
const configWarning = inspectAppServerConfigWarning(frame);
|
|
1012
|
+
if (options.isolation === 'strict' && configWarning) {
|
|
1013
|
+
throw new Error(`Strict Codex eval received a config warning: ${configWarning}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (method === 'item/tool/requestUserInput') {
|
|
1016
|
+
turnUsedRequestUserInput = true;
|
|
1017
|
+
const responseId = appServerRequestId(frame.id);
|
|
1018
|
+
const id = requestId(responseId);
|
|
1019
|
+
if (!id || responseId === undefined || !Array.isArray(params.questions)) {
|
|
1020
|
+
throw new Error('requestUserInput frame is missing id or questions.');
|
|
1021
|
+
}
|
|
1022
|
+
const scripted = options.evalCase.scriptedReplies[replyStep];
|
|
1023
|
+
if (!scripted) {
|
|
1024
|
+
scriptedReplyMismatches.push(`Unexpected request_user_input step ${replyStep + 1}.`);
|
|
1025
|
+
}
|
|
1026
|
+
const fallback = scripted?.unexpectedQuestionAnswer ?? '请停止提问并交还控制。';
|
|
1027
|
+
const normalizedQuestions = params.questions.map((value, index) => {
|
|
1028
|
+
const row = isRecord(value) ? value : {};
|
|
1029
|
+
return {
|
|
1030
|
+
id: typeof row.id === 'string' ? row.id : `question-${index}`,
|
|
1031
|
+
header: typeof row.header === 'string' ? row.header : '',
|
|
1032
|
+
question: typeof row.question === 'string' ? row.question : '',
|
|
1033
|
+
options: Array.isArray(row.options)
|
|
1034
|
+
? row.options.filter(isRecord).map((option) => ({
|
|
1035
|
+
label: typeof option.label === 'string' ? option.label : '',
|
|
1036
|
+
description: typeof option.description === 'string' ? option.description : '',
|
|
1037
|
+
}))
|
|
1038
|
+
: null,
|
|
1039
|
+
};
|
|
1040
|
+
});
|
|
1041
|
+
if (scripted?.expectedQuestionCount !== undefined &&
|
|
1042
|
+
scripted.expectedQuestionCount !== normalizedQuestions.length) {
|
|
1043
|
+
scriptedReplyMismatches.push(`request_user_input step ${replyStep + 1} expected ${scripted.expectedQuestionCount} questions but received ${normalizedQuestions.length}.`);
|
|
1044
|
+
}
|
|
1045
|
+
const answers = Object.fromEntries(normalizedQuestions.map((question, index) => [
|
|
1046
|
+
question.id,
|
|
1047
|
+
[scripted?.answersByIndex[index] ?? fallback],
|
|
1048
|
+
]));
|
|
1049
|
+
questions.push({
|
|
1050
|
+
requestId: id,
|
|
1051
|
+
step: replyStep,
|
|
1052
|
+
questions: normalizedQuestions,
|
|
1053
|
+
answers,
|
|
1054
|
+
});
|
|
1055
|
+
replyStep += 1;
|
|
1056
|
+
await session.respond(responseId, {
|
|
1057
|
+
answers: Object.fromEntries(Object.entries(answers).map(([key, value]) => [key, { answers: value }])),
|
|
1058
|
+
});
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
if (method === 'item/completed' && isRecord(params.item)) {
|
|
1062
|
+
const item = params.item;
|
|
1063
|
+
if (item.type === 'agentMessage' &&
|
|
1064
|
+
item.phase === 'final_answer' &&
|
|
1065
|
+
typeof item.text === 'string') {
|
|
1066
|
+
turnProducedFinalAnswer = true;
|
|
1067
|
+
finalMessages.push(item.text);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (method === 'thread/tokenUsage/updated') {
|
|
1071
|
+
usage = params.tokenUsage ?? usage;
|
|
1072
|
+
}
|
|
1073
|
+
if (method === 'turn/completed') {
|
|
1074
|
+
const turn = isRecord(params.turn) ? params.turn : {};
|
|
1075
|
+
turnResolve?.({ complete: turn.status === 'completed' });
|
|
1076
|
+
}
|
|
1077
|
+
if (method === 'error') {
|
|
1078
|
+
turnReject?.(new Error(`app-server error: ${JSON.stringify(params)}`));
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
await session.initialize(control.rpcTimeout('initialize'));
|
|
1082
|
+
control.throwIfStopped('initialize');
|
|
1083
|
+
options.onStage?.('session-initialized');
|
|
1084
|
+
if (options.isolation === 'strict') {
|
|
1085
|
+
isolationEvidence = await collectStrictIsolationEvidence({
|
|
1086
|
+
session,
|
|
1087
|
+
workspaceRoot,
|
|
1088
|
+
codexHome,
|
|
1089
|
+
allowedSkillRoots,
|
|
1090
|
+
control,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
control.throwIfStopped('thread/start');
|
|
1094
|
+
const threadResult = await session.sendRequest('thread/start', {
|
|
1095
|
+
cwd: workspaceRoot,
|
|
1096
|
+
approvalPolicy: 'never',
|
|
1097
|
+
sandbox: options.evalCase.sandbox,
|
|
1098
|
+
ephemeral: false,
|
|
1099
|
+
...(options.model ? { model: options.model } : {}),
|
|
1100
|
+
}, control.rpcTimeout('thread/start'));
|
|
1101
|
+
const threadId = extractAppServerThreadId(threadResult);
|
|
1102
|
+
options.onStage?.('thread-started');
|
|
1103
|
+
let nextTurnText = options.evalCase.prompt;
|
|
1104
|
+
let completed = { complete: false };
|
|
1105
|
+
while (true) {
|
|
1106
|
+
turnUsedRequestUserInput = false;
|
|
1107
|
+
turnProducedFinalAnswer = false;
|
|
1108
|
+
const turnDone = new Promise((resolve, reject) => {
|
|
1109
|
+
turnResolve = resolve;
|
|
1110
|
+
turnReject = reject;
|
|
1111
|
+
});
|
|
1112
|
+
void turnDone.catch(() => undefined);
|
|
1113
|
+
control.throwIfStopped('turn/start');
|
|
1114
|
+
await session.sendRequest('turn/start', {
|
|
1115
|
+
threadId,
|
|
1116
|
+
input: [{ type: 'text', text: nextTurnText, text_elements: [] }],
|
|
1117
|
+
cwd: workspaceRoot,
|
|
1118
|
+
approvalPolicy: 'never',
|
|
1119
|
+
sandboxPolicy: {
|
|
1120
|
+
type: 'externalSandbox',
|
|
1121
|
+
networkAccess: 'enabled',
|
|
1122
|
+
},
|
|
1123
|
+
...(options.model ? { model: options.model } : {}),
|
|
1124
|
+
}, control.rpcTimeout('turn/start'));
|
|
1125
|
+
options.onStage?.('turn-started');
|
|
1126
|
+
completed = await turnDone;
|
|
1127
|
+
control.throwIfStopped('turn wait');
|
|
1128
|
+
options.onStage?.('turn-completed');
|
|
1129
|
+
turnResolve = undefined;
|
|
1130
|
+
turnReject = undefined;
|
|
1131
|
+
const scripted = options.evalCase.scriptedReplies[replyStep];
|
|
1132
|
+
if (!completed.complete ||
|
|
1133
|
+
!scripted ||
|
|
1134
|
+
turnUsedRequestUserInput ||
|
|
1135
|
+
!turnProducedFinalAnswer) {
|
|
1136
|
+
break;
|
|
1137
|
+
}
|
|
1138
|
+
if (scripted.expectedQuestionCount !== undefined &&
|
|
1139
|
+
scripted.expectedQuestionCount !== scripted.answersByIndex.length) {
|
|
1140
|
+
scriptedReplyMismatches.push(`Final-answer fallback step ${replyStep + 1} cannot observe structured questions: expected_question_count=${scripted.expectedQuestionCount}, but answers_by_index contains ${scripted.answersByIndex.length} answer(s).`);
|
|
1141
|
+
}
|
|
1142
|
+
fallbackUserReplies.push({
|
|
1143
|
+
step: replyStep,
|
|
1144
|
+
eventTraceIndex: eventTrace.length,
|
|
1145
|
+
answers: [...scripted.answersByIndex],
|
|
1146
|
+
});
|
|
1147
|
+
replyStep += 1;
|
|
1148
|
+
nextTurnText = finalAnswerReplyText(scripted);
|
|
1149
|
+
}
|
|
1150
|
+
removeHandler();
|
|
1151
|
+
removeHandler = undefined;
|
|
1152
|
+
removeFailureHandler();
|
|
1153
|
+
removeFailureHandler = undefined;
|
|
1154
|
+
await session.cleanup();
|
|
1155
|
+
control.throwIfStopped('cleanup');
|
|
1156
|
+
const after = await snapshotWorkspace(workspaceRoot);
|
|
1157
|
+
const workspaceChanges = diffSnapshots(before, after);
|
|
1158
|
+
const workspaceArtifacts = await Promise.all(options.evalCase.artifacts.map((expectation) => readWorkspaceArtifactEvidence(workspaceRoot, expectation)));
|
|
1159
|
+
const hostSourceAccessAbsent = options.forbiddenHostRoots.length > 0
|
|
1160
|
+
? !eventTraceContainsForbiddenHostSourceInput(eventTrace, options.forbiddenHostRoots)
|
|
1161
|
+
: null;
|
|
1162
|
+
if (hostSourceAccessAbsent === false) {
|
|
1163
|
+
diagnostics.push('Codex eval accessed a forbidden host source root.');
|
|
1164
|
+
}
|
|
1165
|
+
if (replyStep < options.evalCase.scriptedReplies.length) {
|
|
1166
|
+
scriptedReplyMismatches.push(`${options.evalCase.scriptedReplies.length - replyStep} scripted reply step(s) were unused.`);
|
|
1167
|
+
}
|
|
1168
|
+
if (options.evalCase.workspaceUnchanged && workspaceChanges.length > 0) {
|
|
1169
|
+
diagnostics.push(`Workspace changed: ${workspaceChanges.join(', ')}`);
|
|
1170
|
+
}
|
|
1171
|
+
if (options.evalCase.sandbox === 'workspace-write') {
|
|
1172
|
+
const unexpectedChanges = workspaceChanges.filter((relativePath) => !workspaceChangeIsAllowed(relativePath, options.evalCase.allowedWorkspaceChanges));
|
|
1173
|
+
if (unexpectedChanges.length > 0) {
|
|
1174
|
+
diagnostics.push(`Workspace changed outside the allowed paths: ${unexpectedChanges.join(', ')}`);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (options.variant === 'with_skill') {
|
|
1178
|
+
diagnostics.push(...scriptedReplyMismatches);
|
|
1179
|
+
for (const [index, expectation] of options.evalCase.artifacts.entries()) {
|
|
1180
|
+
diagnostics.push(...validateWorkspaceArtifact(expectation, workspaceArtifacts[index]));
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
return session.scrub({
|
|
1184
|
+
variant: options.variant,
|
|
1185
|
+
complete: completed.complete,
|
|
1186
|
+
mechanicalPassed: completed.complete && diagnostics.length === 0,
|
|
1187
|
+
hostSourceAccessAbsent,
|
|
1188
|
+
scriptedReplyMismatches,
|
|
1189
|
+
durationMs: Date.now() - startedAt,
|
|
1190
|
+
questions,
|
|
1191
|
+
fallbackUserReplies,
|
|
1192
|
+
finalMessages,
|
|
1193
|
+
usage,
|
|
1194
|
+
workspaceChanges,
|
|
1195
|
+
workspaceArtifacts,
|
|
1196
|
+
diagnostics,
|
|
1197
|
+
eventTrace,
|
|
1198
|
+
isolation: isolationEvidence,
|
|
1199
|
+
limitations: [
|
|
1200
|
+
CODEX_CONVERSATION_NETWORK_LIMITATION,
|
|
1201
|
+
CODEX_CONVERSATION_FINAL_ANSWER_FALLBACK_LIMITATION,
|
|
1202
|
+
],
|
|
1203
|
+
...(session.stderr.trim() ? { stderr: session.stderr.trim() } : {}),
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
catch (error) {
|
|
1207
|
+
if (error instanceof HostReadContainmentError)
|
|
1208
|
+
throw error;
|
|
1209
|
+
const hostSourceAccessAbsent = options.forbiddenHostRoots.length > 0 &&
|
|
1210
|
+
eventTraceContainsForbiddenHostSourceInput(eventTrace, options.forbiddenHostRoots)
|
|
1211
|
+
? false
|
|
1212
|
+
: null;
|
|
1213
|
+
const evidence = {
|
|
1214
|
+
variant: options.variant,
|
|
1215
|
+
complete: false,
|
|
1216
|
+
mechanicalPassed: false,
|
|
1217
|
+
hostSourceAccessAbsent,
|
|
1218
|
+
scriptedReplyMismatches,
|
|
1219
|
+
durationMs: Date.now() - startedAt,
|
|
1220
|
+
questions,
|
|
1221
|
+
fallbackUserReplies,
|
|
1222
|
+
finalMessages,
|
|
1223
|
+
usage,
|
|
1224
|
+
workspaceChanges: [],
|
|
1225
|
+
workspaceArtifacts: [],
|
|
1226
|
+
diagnostics: [error instanceof Error ? error.stack ?? error.message : String(error)],
|
|
1227
|
+
eventTrace,
|
|
1228
|
+
isolation: isolationEvidence,
|
|
1229
|
+
limitations: [
|
|
1230
|
+
CODEX_CONVERSATION_NETWORK_LIMITATION,
|
|
1231
|
+
CODEX_CONVERSATION_FINAL_ANSWER_FALLBACK_LIMITATION,
|
|
1232
|
+
],
|
|
1233
|
+
...(session?.stderr.trim() ? { stderr: session.stderr.trim() } : {}),
|
|
1234
|
+
};
|
|
1235
|
+
return session ? session.scrub(evidence) : evidence;
|
|
1236
|
+
}
|
|
1237
|
+
finally {
|
|
1238
|
+
removeHandler?.();
|
|
1239
|
+
removeFailureHandler?.();
|
|
1240
|
+
removeAbortHandler?.();
|
|
1241
|
+
try {
|
|
1242
|
+
await session?.cleanup();
|
|
1243
|
+
}
|
|
1244
|
+
catch (error) {
|
|
1245
|
+
throw new Error(`Conversation eval app-server cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1246
|
+
}
|
|
1247
|
+
finally {
|
|
1248
|
+
try {
|
|
1249
|
+
await temporaryRoot.cleanup();
|
|
1250
|
+
}
|
|
1251
|
+
finally {
|
|
1252
|
+
control.dispose();
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
export async function runCodexConversationProviderCase(options) {
|
|
1258
|
+
return runConversationOnce({
|
|
1259
|
+
sourceProjectRoot: options.sourceProjectRoot,
|
|
1260
|
+
evalCase: options.evalCase,
|
|
1261
|
+
variant: options.skillMode === 'without-skill' ? 'without_skill' : 'with_skill',
|
|
1262
|
+
skillName: options.skillName,
|
|
1263
|
+
skillNames: options.skillNames,
|
|
1264
|
+
codexBin: options.codexBin,
|
|
1265
|
+
...(options.model ? { model: options.model } : {}),
|
|
1266
|
+
codexRuntime: options.codexRuntime,
|
|
1267
|
+
timeoutMs: options.timeoutMs,
|
|
1268
|
+
sourceEnv: options.sourceEnv ?? process.env,
|
|
1269
|
+
isolation: options.isolation,
|
|
1270
|
+
processManagement: options.processManagement ?? {
|
|
1271
|
+
mode: 'unmanaged-confirmed',
|
|
1272
|
+
inspectionAvailable: false,
|
|
1273
|
+
userAcknowledged: true,
|
|
1274
|
+
descendantCleanupGuaranteed: false,
|
|
1275
|
+
limitation: 'The AwSkillEval engine owns the provider process tree and timeout.',
|
|
1276
|
+
},
|
|
1277
|
+
forbiddenHostRoots: await canonicalForbiddenHostRoots([options.sourceProjectRoot, ...(options.forbiddenHostRoots ?? [])]),
|
|
1278
|
+
environmentFixtures: options.environmentFixtures,
|
|
1279
|
+
manageTimeout: options.manageTimeout ?? false,
|
|
1280
|
+
...(options.workRoot ? { workRoot: options.workRoot } : {}),
|
|
1281
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1282
|
+
...(options.onStage ? { onStage: options.onStage } : {}),
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
export async function runCodexEvalIsolationProbe(options) {
|
|
1286
|
+
assertCodexEvalCanStartWork();
|
|
1287
|
+
const sourceEnv = options.sourceEnv ?? process.env;
|
|
1288
|
+
const processManagement = options.processManagement ?? DEFAULT_OBSERVED_PROCESS_EVIDENCE;
|
|
1289
|
+
const temporaryRoot = await createEvalTemporaryRoot('isolation-probe-');
|
|
1290
|
+
const workspaceRoot = path.join(temporaryRoot.root, 'project');
|
|
1291
|
+
const codexHome = path.join(temporaryRoot.root, 'codex-home');
|
|
1292
|
+
const control = new CodexEvalAttemptControl({
|
|
1293
|
+
label: 'Codex eval isolation probe',
|
|
1294
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
1295
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
1296
|
+
});
|
|
1297
|
+
let session;
|
|
1298
|
+
let removeAbortHandler;
|
|
1299
|
+
try {
|
|
1300
|
+
await fs.copy(options.subjectRoot, workspaceRoot, { dereference: false });
|
|
1301
|
+
const allowedSkillRoots = await isolateWorkspaceSkills({
|
|
1302
|
+
workspaceRoot,
|
|
1303
|
+
skillNames: options.skillNames ?? [options.skillName],
|
|
1304
|
+
});
|
|
1305
|
+
const env = buildIsolatedEnv(sourceEnv, temporaryRoot.root, codexHome, options.codexRuntime);
|
|
1306
|
+
await Promise.all([
|
|
1307
|
+
fs.ensureDir(env.HOME),
|
|
1308
|
+
fs.ensureDir(env.TMPDIR),
|
|
1309
|
+
materializeCodexEvalRuntimeHome(options.codexRuntime, codexHome, {
|
|
1310
|
+
trustedProject: workspaceRoot,
|
|
1311
|
+
}),
|
|
1312
|
+
]);
|
|
1313
|
+
if (processManagement.mode === 'managed' || processManagement.mode === 'observed') {
|
|
1314
|
+
await assertProcessTreeInspectionAvailable();
|
|
1315
|
+
}
|
|
1316
|
+
control.throwIfStopped('initialize');
|
|
1317
|
+
session = new EvalAppServerSession({
|
|
1318
|
+
codexBin: options.codexBin,
|
|
1319
|
+
cwd: workspaceRoot,
|
|
1320
|
+
env,
|
|
1321
|
+
modelProvider: options.codexRuntime.modelProvider,
|
|
1322
|
+
sensitiveEnvironmentNames: options.codexRuntime.sensitiveEnvironmentNames,
|
|
1323
|
+
isolation: 'strict',
|
|
1324
|
+
processManagement,
|
|
1325
|
+
});
|
|
1326
|
+
const abort = () => {
|
|
1327
|
+
void session?.cleanup().catch(() => undefined);
|
|
1328
|
+
};
|
|
1329
|
+
if (control.signal.aborted)
|
|
1330
|
+
abort();
|
|
1331
|
+
else {
|
|
1332
|
+
control.signal.addEventListener('abort', abort, { once: true });
|
|
1333
|
+
removeAbortHandler = () => control.signal.removeEventListener('abort', abort);
|
|
1334
|
+
}
|
|
1335
|
+
await session.initialize(control.rpcTimeout('initialize'));
|
|
1336
|
+
control.throwIfStopped('initialize');
|
|
1337
|
+
const evidence = await collectStrictIsolationEvidence({
|
|
1338
|
+
session,
|
|
1339
|
+
workspaceRoot,
|
|
1340
|
+
codexHome,
|
|
1341
|
+
allowedSkillRoots,
|
|
1342
|
+
control,
|
|
1343
|
+
});
|
|
1344
|
+
await session.cleanup();
|
|
1345
|
+
control.throwIfStopped('cleanup');
|
|
1346
|
+
return evidence;
|
|
1347
|
+
}
|
|
1348
|
+
finally {
|
|
1349
|
+
removeAbortHandler?.();
|
|
1350
|
+
try {
|
|
1351
|
+
await session?.cleanup();
|
|
1352
|
+
}
|
|
1353
|
+
catch (error) {
|
|
1354
|
+
throw new Error(`Codex eval isolation probe cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1355
|
+
}
|
|
1356
|
+
finally {
|
|
1357
|
+
try {
|
|
1358
|
+
await temporaryRoot.cleanup();
|
|
1359
|
+
}
|
|
1360
|
+
finally {
|
|
1361
|
+
control.dispose();
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
//# sourceMappingURL=codex-eval-app-server.js.map
|