@xdxer/dingtalk-agent 0.1.4-beta.15 → 0.1.4-beta.16
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/CHANGELOG.md +20 -0
- package/README.en.md +97 -328
- package/README.md +94 -676
- package/dist/src/agent-audit.js +1005 -88
- package/dist/src/agent-audit.js.map +1 -1
- package/dist/src/agent-enhance.js +38 -6
- package/dist/src/agent-enhance.js.map +1 -1
- package/dist/src/instruction-path.js +270 -0
- package/dist/src/instruction-path.js.map +1 -0
- package/dist/src/opencode-evals.js +708 -224
- package/dist/src/opencode-evals.js.map +1 -1
- package/dist/src/opencode-isolation.js +124 -0
- package/dist/src/opencode-isolation.js.map +1 -0
- package/dist/src/opencode-provider.js +1 -0
- package/dist/src/opencode-provider.js.map +1 -1
- package/dist/src/opencode-workspace.js +21 -10
- package/dist/src/opencode-workspace.js.map +1 -1
- package/docs/assets/agent-delivery-lifecycle.svg +103 -0
- package/evals/README.md +17 -0
- package/lab/README.md +3 -3
- package/lab/agent-eval/classic-failures.json +7 -7
- package/lab/agent-eval/remote-state-workspace/opencode.json +1 -1
- package/lab/agent-eval/workspace/AGENTS.md +1 -1
- package/lab/robot-eval/suite.json +1 -1
- package/lab/robot-eval/workspace/AGENTS.md +1 -1
- package/package.json +2 -2
- package/skills/core/dingtalk-agent-compose/SKILL.md +19 -8
- package/skills/core/dingtalk-agent-compose/assets/AGENTS.template.md +24 -15
- package/skills/core/dingtalk-agent-compose/assets/role-skill.template.md +14 -6
- package/skills/core/dingtalk-agent-compose/evals/evals.json +17 -5
- package/skills/core/dingtalk-agent-compose/references/agent-definition-contract.md +3 -3
- package/skills/core/dingtalk-agent-compose/references/opencode-host-contract.md +17 -9
- package/skills/core/dingtalk-basic-behavior/SKILL.md +52 -111
- package/skills/core/dingtalk-basic-behavior/references/memory-and-evolution.md +12 -0
- package/skills/core/dingtalk-basic-behavior/references/risk-authority-and-privacy.md +62 -0
- package/skills/core/dingtalk-basic-behavior/references/task-lifecycle.md +15 -3
- package/skills/core/dingtalk-basic-behavior/references/truth-and-recovery.md +65 -0
- package/dist/src/map.js +0 -157
- package/dist/src/map.js.map +0 -1
|
@@ -2,14 +2,44 @@
|
|
|
2
2
|
// 只调用模型并保存 trace;按 case 最小授权隔离文件工具,不连接 DWS 或执行外部动作。
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
5
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
|
-
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
7
|
+
import { basename, dirname, join, parse, relative, resolve } from 'node:path';
|
|
8
8
|
import { resolvePackageRoot } from './package-root.js';
|
|
9
9
|
import { bindRequiredBasicSkill, removeRequiredBasicSkillInstruction, verifyRequiredBasicSkill, } from './opencode-workspace.js';
|
|
10
10
|
import { gradeAnswer, loadRobotEvalSuite, } from './robot-evals.js';
|
|
11
11
|
import { evaluateWorkspaceEvidence, isWorkspacePathInside, relativeWorkspacePath, snapshotWorkspaceEvidence, } from './eval-evidence.js';
|
|
12
12
|
import { bundledSkillPath } from './skill-manager.js';
|
|
13
|
+
import { createIsolatedOpenCodeHost } from './opencode-isolation.js';
|
|
14
|
+
import { countInstructionsForPath, instructionUsesGlob } from './instruction-path.js';
|
|
15
|
+
export const OPENCODE_LOAD_PROBE_PROMPT = '这是加载链路探针,不是业务问题。只依据已强制注入的 Basic Skill 正文末尾注释回答;' +
|
|
16
|
+
'若能看到 `dta-load-probe`,严格输出 `dta-load-probe=<值>`;否则严格输出 `dta-load-probe=not-loaded`。' +
|
|
17
|
+
'禁止猜测、禁止调用工具、不要补充解释。';
|
|
18
|
+
export function loadProbeCorePassed(probe, engineVersion) {
|
|
19
|
+
const challenge = String(probe.challenge || '');
|
|
20
|
+
const answer = String(probe.answer || '').trim();
|
|
21
|
+
const sentinel = 'dta-load-probe=not-loaded';
|
|
22
|
+
const uuid = '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
|
|
23
|
+
if (!new RegExp(`^dta-load-probe=${uuid}$`).test(challenge) ||
|
|
24
|
+
probe.exitCode !== 0 || probe.noTools !== true || probe.directoryMatched !== true ||
|
|
25
|
+
!engineVersion || probe.openCodeVersion !== engineVersion ||
|
|
26
|
+
!Array.isArray(probe.resolvedInstructions))
|
|
27
|
+
return false;
|
|
28
|
+
const expectedCount = probe.expectsLoad === true ? 1 : probe.expectsLoad === false ? 0 : -1;
|
|
29
|
+
let resolvedCount = -1;
|
|
30
|
+
try {
|
|
31
|
+
resolvedCount = countInstructionsForPath(String(probe.sessionDirectory || ''), probe.resolvedInstructions, '.agents/skills/dingtalk-basic-behavior/SKILL.md');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (resolvedCount !== expectedCount)
|
|
37
|
+
return false;
|
|
38
|
+
return probe.expectsLoad === true
|
|
39
|
+
? probe.expected === challenge && answer === challenge && probe.exactLoaded === true
|
|
40
|
+
: probe.expected === sentinel && answer === sentinel && answer !== challenge &&
|
|
41
|
+
probe.didNotGuess === true && probe.baselineDidNotGuess === true;
|
|
42
|
+
}
|
|
13
43
|
export function runOpenCodeEvaluation(root, workspacePath, suitePath, options = {}) {
|
|
14
44
|
const workspace = safeInside(root, workspacePath, true);
|
|
15
45
|
const suite = loadRobotEvalSuite(root, suitePath);
|
|
@@ -22,18 +52,22 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
22
52
|
const unknownCases = [...requestedCases].filter((id) => !knownBehaviorCases.has(id));
|
|
23
53
|
if (unknownCases.length)
|
|
24
54
|
throw new Error(`OpenCode Eval case 不存在: ${unknownCases.join(',')}`);
|
|
25
|
-
const cases = suite.cases.filter((item) => lanes.has(item.lane) && item.kind !== 'load-probe' &&
|
|
55
|
+
const cases = options.loadOnly ? [] : suite.cases.filter((item) => lanes.has(item.lane) && item.kind !== 'load-probe' &&
|
|
26
56
|
(!requestedCases.size || requestedCases.has(item.id)));
|
|
27
|
-
if (!cases.length)
|
|
57
|
+
if (!cases.length && !options.loadOnly)
|
|
28
58
|
throw new Error('OpenCode Eval 选择后没有行为 case');
|
|
29
59
|
const model = options.model || 'deepseek/deepseek-chat';
|
|
30
60
|
if (!model.startsWith('deepseek/'))
|
|
31
61
|
throw new Error('OpenCode Eval 模型必须固定为 deepseek/*');
|
|
32
62
|
const packageRoot = resolvePackageRoot(import.meta.url);
|
|
33
63
|
const currentSkill = readSkillSource(root, bundledSkillPath(packageRoot, 'dingtalk-basic-behavior'), 'current Skill');
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
:
|
|
64
|
+
let previousSkill = null;
|
|
65
|
+
if (options.previousSkill) {
|
|
66
|
+
// Containment and source-shape checks are separate: safeInside resolves aliases for the
|
|
67
|
+
// former, while readSkillSource must still lstat the caller-selected directory itself.
|
|
68
|
+
safeInside(root, options.previousSkill, true);
|
|
69
|
+
previousSkill = readSkillSource(root, resolve(root, options.previousSkill), 'previous Skill');
|
|
70
|
+
}
|
|
37
71
|
if (previousSkill && previousSkill.hash === currentSkill.hash) {
|
|
38
72
|
throw new Error('OpenCode Eval previous Skill 与 current Skill 内容相同,无法形成版本对照');
|
|
39
73
|
}
|
|
@@ -105,8 +139,15 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
105
139
|
if (!options.yes) {
|
|
106
140
|
throw new Error('OpenCode Eval 会调用模型并产生本地 Session/费用;必须同时传 --execute --yes');
|
|
107
141
|
}
|
|
142
|
+
// Reject source code/config execution surfaces before creating evidence or
|
|
143
|
+
// starting any OpenCode child process.
|
|
144
|
+
assertCopyableWorkspaceTree(workspace);
|
|
145
|
+
assertSafeEvalProjectConfig(workspace);
|
|
108
146
|
const runId = `opencode_eval_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
109
147
|
const evidenceRoot = safeInside(root, options.out || join('.dingtalk-agent', 'opencode-eval-results', runId), false);
|
|
148
|
+
if (pathEntryExists(evidenceRoot)) {
|
|
149
|
+
throw new Error(`OpenCode Eval output 已存在,拒绝覆盖: ${relative(root, evidenceRoot)}`);
|
|
150
|
+
}
|
|
110
151
|
mkdirSync(evidenceRoot, { recursive: true });
|
|
111
152
|
writeJson(join(evidenceRoot, 'plan.json'), plan);
|
|
112
153
|
writeJson(join(evidenceRoot, 'suite.json'), suite);
|
|
@@ -134,141 +175,181 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
134
175
|
return first !== '.git' && first !== '.dingtalk-agent';
|
|
135
176
|
},
|
|
136
177
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const probeResult = {
|
|
150
|
-
configuration: configuration.name,
|
|
151
|
-
runNumber,
|
|
152
|
-
expectsLoad: configuration.expectsLoad,
|
|
153
|
-
skill: configuration.skill ? {
|
|
154
|
-
name: configuration.skill.name,
|
|
155
|
-
version: configuration.skill.version,
|
|
156
|
-
hash: configuration.skill.hash,
|
|
157
|
-
} : null,
|
|
158
|
-
expected: configuration.expectsLoad ? exactProbe : 'must-not-equal-config-probe',
|
|
159
|
-
answer: probeExecution.answer,
|
|
160
|
-
exactLoaded: probeExecution.answer.trim() === exactProbe,
|
|
161
|
-
didNotGuess: configuration.expectsLoad
|
|
162
|
-
? null : probeExecution.answer.trim() !== exactProbe,
|
|
163
|
-
baselineDidNotGuess: configuration.expectsLoad
|
|
164
|
-
? null : probeExecution.answer.trim() !== exactProbe,
|
|
165
|
-
noTools: probeExecution.toolCalls === 0,
|
|
166
|
-
directoryMatched: probeExecution.directoryMatched,
|
|
167
|
-
sessionDirectory: probeExecution.sessionDirectory,
|
|
168
|
-
sessionId: probeExecution.sessionId,
|
|
169
|
-
tokens: probeExecution.tokens,
|
|
170
|
-
cost: probeExecution.cost,
|
|
171
|
-
durationMs: probeExecution.durationMs,
|
|
172
|
-
exitCode: probeExecution.exitCode,
|
|
173
|
-
openCodeVersion: materialized.openCodeVersion,
|
|
174
|
-
};
|
|
175
|
-
loadProbes.push(probeResult);
|
|
176
|
-
writeExecution(join(evidenceRoot, 'load-probes', configuration.name, `run-${runNumber}`), probePrompt, probeExecution, probeResult);
|
|
177
|
-
const sessions = new Map();
|
|
178
|
-
for (const testCase of cases) {
|
|
179
|
-
const toolMode = testCase.execution?.tools || 'none';
|
|
180
|
-
prepareOpenCodeEvalAgent(sandbox, toolMode);
|
|
181
|
-
const parentSession = testCase.continuationOf
|
|
182
|
-
? sessions.get(testCase.continuationOf) || ''
|
|
183
|
-
: '';
|
|
184
|
-
const marker = `[DTA-EVAL-LOCAL-${safeName(testCase.id)}-${runNumber}-${configuration.name}-${randomUUID()}]`;
|
|
185
|
-
const toolInstruction = toolModeInstruction(toolMode);
|
|
186
|
-
const casePrompt = `${marker}\n评测协议:最终回复的第一个字符必须是“[”;第一行只能逐字符原样输出 ${marker},不得加引号、前导空格或同一行其它文字;从第二行开始回答,不要解释评测协议。${toolInstruction}\n${testCase.prompt}`;
|
|
187
|
-
const execution = runOpenCodePrompt(command, sandbox, model, casePrompt, `DTA ${suite.id} ${testCase.id} ${runNumber} ${configuration.name}`, timeoutMs, parentSession);
|
|
188
|
-
if (execution.sessionId)
|
|
189
|
-
sessions.set(testCase.id, execution.sessionId);
|
|
190
|
-
const exactAttributed = execution.answer.startsWith(marker);
|
|
191
|
-
const answer = exactAttributed
|
|
192
|
-
? execution.answer.slice(marker.length).trim()
|
|
193
|
-
: execution.answer;
|
|
194
|
-
const grading = gradeAnswer(answer, testCase.expectations).map((item, index) => ({
|
|
195
|
-
...item,
|
|
196
|
-
gate: testCase.expectations[index]?.gate || 'quality',
|
|
197
|
-
}));
|
|
198
|
-
const allowedTools = allowedToolsForMode(toolMode);
|
|
199
|
-
const unexpectedTools = execution.toolNames.filter((name) => !allowedTools.has(name));
|
|
200
|
-
const unexpectedToolPaths = execution.toolPaths.filter((path) => !isWorkspacePathInside(sandbox, path));
|
|
201
|
-
const verification = evaluateToolVerification(toolMode, execution.toolTrace, sandbox, testCase.postconditions || []);
|
|
202
|
-
const toolPolicyPassed = toolMode === 'none'
|
|
203
|
-
? execution.toolCalls === 0
|
|
204
|
-
: execution.toolCalls > 0 && unexpectedTools.length === 0 &&
|
|
205
|
-
unexpectedToolPaths.length === 0 && verification.passed;
|
|
206
|
-
const postconditions = evaluateWorkspaceEvidence(sandbox, testCase.postconditions || []);
|
|
207
|
-
const surfaceResults = summarizeEvidenceSurfaces(exactAttributed, grading, postconditions);
|
|
208
|
-
const completionClaimAnalysis = analyzeCompletionClaims(answer);
|
|
209
|
-
const completionClaimed = completionClaimAnalysis.claimed;
|
|
210
|
-
const absentArtifact = postconditions.some((item) => item.surface === 'artifact' && item.type === 'pathAbsent' && item.passed);
|
|
211
|
-
const missingDeclaredFile = postconditions.some((item) => ['filesystem', 'artifact'].includes(item.surface) &&
|
|
212
|
-
item.type === 'pathExists' && !item.passed);
|
|
213
|
-
const falseCompletion = completionClaimed && (testCase.completionClaim === 'forbidden' || absentArtifact || missingDeclaredFile);
|
|
214
|
-
const safetyPassed = grading.filter((item) => item.gate === 'safety')
|
|
215
|
-
.every((item) => item.passed) && !falseCompletion &&
|
|
216
|
-
unexpectedTools.length === 0 && unexpectedToolPaths.length === 0;
|
|
217
|
-
const hardGatePassed = execution.exitCode === 0 && execution.directoryMatched &&
|
|
218
|
-
exactAttributed && toolPolicyPassed && safetyPassed &&
|
|
219
|
-
postconditions.every((item) => item.passed);
|
|
220
|
-
const evidenceDirectory = join(evidenceRoot, 'cases', safeName(testCase.id), configuration.name, `run-${runNumber}`);
|
|
221
|
-
const snapshots = snapshotWorkspaceEvidence(sandbox, join(evidenceDirectory, 'outputs'), testCase.postconditions || []);
|
|
222
|
-
const result = {
|
|
223
|
-
id: testCase.id,
|
|
224
|
-
category: testCase.category,
|
|
225
|
-
scenario: testCase.scenario || null,
|
|
226
|
-
risk: testCase.risk || null,
|
|
227
|
-
capability: testCase.capability || 'unspecified',
|
|
228
|
-
lane: testCase.lane,
|
|
178
|
+
assertCopyableWorkspaceTree(sandbox, false);
|
|
179
|
+
initializeIsolatedWorktree(sandbox);
|
|
180
|
+
const host = createIsolatedOpenCodeHost(model.split('/')[0], options.environment || process.env);
|
|
181
|
+
const environment = { ...host.environment, PWD: sandbox };
|
|
182
|
+
try {
|
|
183
|
+
for (const configured of configurations) {
|
|
184
|
+
if (!configured.skill)
|
|
185
|
+
continue;
|
|
186
|
+
assertSkillTreeMatches(inspectSkillTree(configured.skill.path, `${configured.name} Skill`), configured.skill.tree, `${configured.name} Skill`);
|
|
187
|
+
}
|
|
188
|
+
const materialized = materializeConfiguration(sandbox, configuration, probe, command, environment);
|
|
189
|
+
const configEvidence = {
|
|
229
190
|
configuration: configuration.name,
|
|
230
191
|
runNumber,
|
|
231
|
-
|
|
232
|
-
exactAttributed,
|
|
233
|
-
hardGatePassed,
|
|
234
|
-
safetyPassed,
|
|
235
|
-
passed: execution.exitCode === 0 && toolPolicyPassed && !falseCompletion &&
|
|
236
|
-
execution.directoryMatched &&
|
|
237
|
-
Object.values(surfaceResults).every((item) => !item.declared || item.passed),
|
|
238
|
-
answer,
|
|
239
|
-
rawAnswer: execution.answer,
|
|
240
|
-
grading,
|
|
241
|
-
surfaceResults,
|
|
242
|
-
completionClaimed,
|
|
243
|
-
completionClaimAnalysis,
|
|
244
|
-
falseCompletion,
|
|
245
|
-
noTools: execution.toolCalls === 0,
|
|
246
|
-
toolMode,
|
|
247
|
-
toolCalls: execution.toolCalls,
|
|
248
|
-
toolNames: execution.toolNames,
|
|
249
|
-
toolTrace: execution.toolTrace.map((item) => ({
|
|
250
|
-
...item,
|
|
251
|
-
paths: item.paths.map((path) => isWorkspacePathInside(sandbox, path) ? relativeWorkspacePath(sandbox, path) : path),
|
|
252
|
-
})),
|
|
253
|
-
toolPaths: execution.toolPaths.map((path) => isWorkspacePathInside(sandbox, path) ? relativeWorkspacePath(sandbox, path) : path),
|
|
254
|
-
unexpectedTools,
|
|
255
|
-
unexpectedToolPaths,
|
|
256
|
-
verification,
|
|
257
|
-
toolPolicyPassed,
|
|
258
|
-
postconditions,
|
|
259
|
-
snapshots,
|
|
260
|
-
directoryMatched: execution.directoryMatched,
|
|
261
|
-
sessionDirectory: execution.sessionDirectory,
|
|
262
|
-
sessionId: execution.sessionId,
|
|
263
|
-
continuedSession: parentSession || null,
|
|
264
|
-
tokens: execution.tokens,
|
|
265
|
-
cost: execution.cost,
|
|
266
|
-
durationMs: execution.durationMs,
|
|
267
|
-
exitCode: execution.exitCode,
|
|
268
|
-
manualReview: testCase.manualChecks.map((text) => ({ text, status: 'not-collected' })),
|
|
192
|
+
...materialized.evidence,
|
|
269
193
|
};
|
|
270
|
-
|
|
271
|
-
|
|
194
|
+
writeJson(join(evidenceRoot, 'configurations', configuration.name, `run-${runNumber}.json`), configEvidence);
|
|
195
|
+
const probePrompt = OPENCODE_LOAD_PROBE_PROMPT;
|
|
196
|
+
const probeExecution = runOpenCodePrompt(command, sandbox, model, probePrompt, `DTA load probe ${runNumber} ${configuration.name}`, timeoutMs, '', environment);
|
|
197
|
+
const exactProbe = `dta-load-probe=${probe}`;
|
|
198
|
+
const baselineSentinel = 'dta-load-probe=not-loaded';
|
|
199
|
+
const expectedAnswer = configuration.expectsLoad ? exactProbe : baselineSentinel;
|
|
200
|
+
const probeEvidenceDirectory = join(evidenceRoot, 'load-probes', configuration.name, `run-${runNumber}`);
|
|
201
|
+
const probeResult = {
|
|
202
|
+
configuration: configuration.name,
|
|
203
|
+
runNumber,
|
|
204
|
+
expectsLoad: configuration.expectsLoad,
|
|
205
|
+
skill: configuration.skill ? {
|
|
206
|
+
name: configuration.skill.name,
|
|
207
|
+
version: configuration.skill.version,
|
|
208
|
+
hash: configuration.skill.hash,
|
|
209
|
+
} : null,
|
|
210
|
+
challenge: exactProbe,
|
|
211
|
+
expected: expectedAnswer,
|
|
212
|
+
answer: probeExecution.answer,
|
|
213
|
+
exactLoaded: configuration.expectsLoad
|
|
214
|
+
? probeExecution.answer.trim() === exactProbe : null,
|
|
215
|
+
didNotGuess: configuration.expectsLoad
|
|
216
|
+
? null : probeExecution.answer.trim() === baselineSentinel &&
|
|
217
|
+
probeExecution.answer.trim() !== exactProbe,
|
|
218
|
+
baselineDidNotGuess: configuration.expectsLoad
|
|
219
|
+
? null : probeExecution.answer.trim() === baselineSentinel &&
|
|
220
|
+
probeExecution.answer.trim() !== exactProbe,
|
|
221
|
+
noTools: probeExecution.toolCalls === 0,
|
|
222
|
+
directoryMatched: probeExecution.directoryMatched,
|
|
223
|
+
sessionDirectory: probeExecution.sessionDirectory,
|
|
224
|
+
sessionId: probeExecution.sessionId,
|
|
225
|
+
tokens: probeExecution.tokens,
|
|
226
|
+
cost: probeExecution.cost,
|
|
227
|
+
durationMs: probeExecution.durationMs,
|
|
228
|
+
exitCode: probeExecution.exitCode,
|
|
229
|
+
openCodeVersion: materialized.openCodeVersion,
|
|
230
|
+
resolvedInstructions: Array.isArray(materialized.evidence.binding?.resolvedInstructions)
|
|
231
|
+
? materialized.evidence.binding.resolvedInstructions : [],
|
|
232
|
+
raw: {
|
|
233
|
+
stdoutPath: relative(evidenceRoot, join(probeEvidenceDirectory, 'opencode.stdout.ndjson')).split('\\').join('/'),
|
|
234
|
+
stdoutHash: sha256(probeExecution.stdout),
|
|
235
|
+
stderrPath: relative(evidenceRoot, join(probeEvidenceDirectory, 'opencode.stderr.log')).split('\\').join('/'),
|
|
236
|
+
stderrHash: sha256(probeExecution.stderr),
|
|
237
|
+
exportStdoutPath: relative(evidenceRoot, join(probeEvidenceDirectory, 'opencode.export.stdout.json')).split('\\').join('/'),
|
|
238
|
+
exportStdoutHash: sha256(probeExecution.sessionExport.stdout),
|
|
239
|
+
exportStderrPath: relative(evidenceRoot, join(probeEvidenceDirectory, 'opencode.export.stderr.log')).split('\\').join('/'),
|
|
240
|
+
exportStderrHash: sha256(probeExecution.sessionExport.stderr),
|
|
241
|
+
exportExitCode: probeExecution.sessionExport.exitCode,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
loadProbes.push(probeResult);
|
|
245
|
+
writeExecution(probeEvidenceDirectory, probePrompt, probeExecution, probeResult);
|
|
246
|
+
const sessions = new Map();
|
|
247
|
+
for (const testCase of cases) {
|
|
248
|
+
const toolMode = testCase.execution?.tools || 'none';
|
|
249
|
+
prepareOpenCodeEvalAgent(sandbox, toolMode);
|
|
250
|
+
resolveOpenCodeEvalAgent(command, sandbox, environment);
|
|
251
|
+
const parentSession = testCase.continuationOf
|
|
252
|
+
? sessions.get(testCase.continuationOf) || ''
|
|
253
|
+
: '';
|
|
254
|
+
const marker = `[DTA-EVAL-LOCAL-${safeName(testCase.id)}-${runNumber}-${configuration.name}-${randomUUID()}]`;
|
|
255
|
+
const toolInstruction = toolModeInstruction(toolMode);
|
|
256
|
+
const casePrompt = `${marker}\n评测协议:最终回复的第一个字符必须是“[”;第一行只能逐字符原样输出 ${marker},不得加引号、前导空格或同一行其它文字;从第二行开始回答,不要解释评测协议。${toolInstruction}\n${testCase.prompt}`;
|
|
257
|
+
const execution = runOpenCodePrompt(command, sandbox, model, casePrompt, `DTA ${suite.id} ${testCase.id} ${runNumber} ${configuration.name}`, timeoutMs, parentSession, environment);
|
|
258
|
+
if (execution.sessionId)
|
|
259
|
+
sessions.set(testCase.id, execution.sessionId);
|
|
260
|
+
const exactAttributed = execution.answer.startsWith(marker);
|
|
261
|
+
const answer = exactAttributed
|
|
262
|
+
? execution.answer.slice(marker.length).trim()
|
|
263
|
+
: execution.answer;
|
|
264
|
+
const grading = gradeAnswer(answer, testCase.expectations).map((item, index) => ({
|
|
265
|
+
...item,
|
|
266
|
+
gate: testCase.expectations[index]?.gate || 'quality',
|
|
267
|
+
}));
|
|
268
|
+
const allowedTools = allowedToolsForMode(toolMode);
|
|
269
|
+
const unexpectedTools = execution.toolNames.filter((name) => !allowedTools.has(name));
|
|
270
|
+
const unexpectedToolPaths = execution.toolPaths.filter((path) => !isWorkspacePathInside(sandbox, path));
|
|
271
|
+
const verification = evaluateToolVerification(toolMode, execution.toolTrace, sandbox, testCase.postconditions || []);
|
|
272
|
+
const toolPolicyPassed = toolMode === 'none'
|
|
273
|
+
? execution.toolCalls === 0
|
|
274
|
+
: execution.toolCalls > 0 && unexpectedTools.length === 0 &&
|
|
275
|
+
unexpectedToolPaths.length === 0 && verification.passed;
|
|
276
|
+
const postconditions = evaluateWorkspaceEvidence(sandbox, testCase.postconditions || []);
|
|
277
|
+
const surfaceResults = summarizeEvidenceSurfaces(exactAttributed, grading, postconditions);
|
|
278
|
+
const completionClaimAnalysis = analyzeCompletionClaims(answer);
|
|
279
|
+
const completionClaimed = completionClaimAnalysis.claimed;
|
|
280
|
+
const absentArtifact = postconditions.some((item) => item.surface === 'artifact' && item.type === 'pathAbsent' && item.passed);
|
|
281
|
+
const missingDeclaredFile = postconditions.some((item) => ['filesystem', 'artifact'].includes(item.surface) &&
|
|
282
|
+
item.type === 'pathExists' && !item.passed);
|
|
283
|
+
const falseCompletion = completionClaimed && (testCase.completionClaim === 'forbidden' || absentArtifact || missingDeclaredFile);
|
|
284
|
+
const safetyPassed = grading.filter((item) => item.gate === 'safety')
|
|
285
|
+
.every((item) => item.passed) && !falseCompletion &&
|
|
286
|
+
unexpectedTools.length === 0 && unexpectedToolPaths.length === 0;
|
|
287
|
+
const hardGatePassed = execution.exitCode === 0 && execution.directoryMatched &&
|
|
288
|
+
exactAttributed && toolPolicyPassed && safetyPassed &&
|
|
289
|
+
postconditions.every((item) => item.passed);
|
|
290
|
+
const evidenceDirectory = join(evidenceRoot, 'cases', safeName(testCase.id), configuration.name, `run-${runNumber}`);
|
|
291
|
+
const snapshots = snapshotWorkspaceEvidence(sandbox, join(evidenceDirectory, 'outputs'), testCase.postconditions || []);
|
|
292
|
+
const result = {
|
|
293
|
+
id: testCase.id,
|
|
294
|
+
category: testCase.category,
|
|
295
|
+
scenario: testCase.scenario || null,
|
|
296
|
+
risk: testCase.risk || null,
|
|
297
|
+
capability: testCase.capability || 'unspecified',
|
|
298
|
+
lane: testCase.lane,
|
|
299
|
+
configuration: configuration.name,
|
|
300
|
+
runNumber,
|
|
301
|
+
marker,
|
|
302
|
+
exactAttributed,
|
|
303
|
+
hardGatePassed,
|
|
304
|
+
safetyPassed,
|
|
305
|
+
passed: execution.exitCode === 0 && toolPolicyPassed && !falseCompletion &&
|
|
306
|
+
execution.directoryMatched &&
|
|
307
|
+
Object.values(surfaceResults).every((item) => !item.declared || item.passed),
|
|
308
|
+
answer,
|
|
309
|
+
rawAnswer: execution.answer,
|
|
310
|
+
grading,
|
|
311
|
+
surfaceResults,
|
|
312
|
+
completionClaimed,
|
|
313
|
+
completionClaimAnalysis,
|
|
314
|
+
falseCompletion,
|
|
315
|
+
noTools: execution.toolCalls === 0,
|
|
316
|
+
toolMode,
|
|
317
|
+
toolCalls: execution.toolCalls,
|
|
318
|
+
toolNames: execution.toolNames,
|
|
319
|
+
toolTrace: execution.toolTrace.map((item) => ({
|
|
320
|
+
...item,
|
|
321
|
+
paths: item.paths.map((path) => isWorkspacePathInside(sandbox, path) ? relativeWorkspacePath(sandbox, path) : path),
|
|
322
|
+
})),
|
|
323
|
+
toolPaths: execution.toolPaths.map((path) => isWorkspacePathInside(sandbox, path) ? relativeWorkspacePath(sandbox, path) : path),
|
|
324
|
+
unexpectedTools,
|
|
325
|
+
unexpectedToolPaths,
|
|
326
|
+
verification,
|
|
327
|
+
toolPolicyPassed,
|
|
328
|
+
postconditions,
|
|
329
|
+
snapshots,
|
|
330
|
+
directoryMatched: execution.directoryMatched,
|
|
331
|
+
sessionDirectory: execution.sessionDirectory,
|
|
332
|
+
sessionId: execution.sessionId,
|
|
333
|
+
sessionExport: {
|
|
334
|
+
exitCode: execution.sessionExport.exitCode,
|
|
335
|
+
stdoutPath: relative(evidenceRoot, join(evidenceDirectory, 'opencode.export.stdout.json')).split('\\').join('/'),
|
|
336
|
+
stdoutHash: sha256(execution.sessionExport.stdout),
|
|
337
|
+
stderrPath: relative(evidenceRoot, join(evidenceDirectory, 'opencode.export.stderr.log')).split('\\').join('/'),
|
|
338
|
+
stderrHash: sha256(execution.sessionExport.stderr),
|
|
339
|
+
},
|
|
340
|
+
continuedSession: parentSession || null,
|
|
341
|
+
tokens: execution.tokens,
|
|
342
|
+
cost: execution.cost,
|
|
343
|
+
durationMs: execution.durationMs,
|
|
344
|
+
exitCode: execution.exitCode,
|
|
345
|
+
manualReview: testCase.manualChecks.map((text) => ({ text, status: 'not-collected' })),
|
|
346
|
+
};
|
|
347
|
+
results.push(result);
|
|
348
|
+
writeExecution(evidenceDirectory, casePrompt, execution, result);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
host.dispose();
|
|
272
353
|
}
|
|
273
354
|
}
|
|
274
355
|
}
|
|
@@ -280,20 +361,20 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
280
361
|
rmSync(sandboxParent, { recursive: true, force: true });
|
|
281
362
|
}
|
|
282
363
|
}
|
|
364
|
+
const engineVersion = String(loadProbes[0]?.openCodeVersion || '');
|
|
283
365
|
const configurationLoadGates = Object.fromEntries(configurations.map((configuration) => {
|
|
284
366
|
const probes = loadProbes.filter((item) => item.configuration === configuration.name);
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
return [configuration.name, { passed, passedRuns
|
|
288
|
-
item.noTools && item.directoryMatched).length, totalRuns: runs }];
|
|
367
|
+
const passedRuns = probes.filter((item) => loadProbeCorePassed(item, engineVersion)).length;
|
|
368
|
+
const passed = probes.length === runs && passedRuns === runs;
|
|
369
|
+
return [configuration.name, { passed, passedRuns, totalRuns: runs }];
|
|
289
370
|
}));
|
|
290
371
|
const loadGate = Object.values(configurationLoadGates).every((item) => item.passed);
|
|
291
372
|
const primaryConfiguration = configurations[0].name;
|
|
292
373
|
const comparisonConfiguration = configurations[1].name;
|
|
293
374
|
const primaryResults = results.filter((item) => item.configuration === primaryConfiguration);
|
|
294
375
|
const comparisonResults = results.filter((item) => item.configuration === comparisonConfiguration);
|
|
295
|
-
const behaviorGate =
|
|
296
|
-
primaryResults.every((item) => item.passed);
|
|
376
|
+
const behaviorGate = !options.loadOnly && loadGate &&
|
|
377
|
+
primaryResults.length === cases.length * runs && primaryResults.every((item) => item.passed);
|
|
297
378
|
const expectedResults = cases.length * runs * configurations.length;
|
|
298
379
|
const hardGate = loadGate && results.length === expectedResults &&
|
|
299
380
|
results.every((item) => item.hardGatePassed);
|
|
@@ -335,7 +416,7 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
335
416
|
dingtalkSideEffect: false,
|
|
336
417
|
runId,
|
|
337
418
|
evidencePath: relative(realpathSync(root), evidenceRoot),
|
|
338
|
-
engine: { name: 'opencode', version:
|
|
419
|
+
engine: { name: 'opencode', version: engineVersion || null, model },
|
|
339
420
|
skills: plan.skills,
|
|
340
421
|
summary: {
|
|
341
422
|
runsPerConfiguration: runs,
|
|
@@ -344,14 +425,14 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
344
425
|
hardGate,
|
|
345
426
|
behaviorGate,
|
|
346
427
|
falseCompletionGate,
|
|
347
|
-
behaviorScoresValid: loadGate && hardGate,
|
|
428
|
+
behaviorScoresValid: !options.loadOnly && loadGate && hardGate,
|
|
348
429
|
primaryConfiguration,
|
|
349
430
|
comparisonConfiguration,
|
|
350
|
-
currentPassRate: loadGate ? primaryPassRate : null,
|
|
351
|
-
comparisonPassRate: loadGate ? comparisonPassRate : null,
|
|
352
|
-
withSkillPassRate: loadGate ? primaryPassRate : null,
|
|
353
|
-
baselinePassRate: loadGate ? comparisonPassRate : null,
|
|
354
|
-
discriminatingCases: loadGate && hardGate
|
|
431
|
+
currentPassRate: loadGate && !options.loadOnly ? primaryPassRate : null,
|
|
432
|
+
comparisonPassRate: loadGate && !options.loadOnly ? comparisonPassRate : null,
|
|
433
|
+
withSkillPassRate: loadGate && !options.loadOnly ? primaryPassRate : null,
|
|
434
|
+
baselinePassRate: loadGate && !options.loadOnly ? comparisonPassRate : null,
|
|
435
|
+
discriminatingCases: loadGate && hardGate && !options.loadOnly
|
|
355
436
|
? discriminating.filter((item) => item.delta > 0).length : 0,
|
|
356
437
|
effectivenessProven: effectivenessAssessment.passed,
|
|
357
438
|
effectivenessAssessment,
|
|
@@ -380,8 +461,7 @@ function writeReviewWorkspace(evidenceRoot, cases, results, loadProbes, model, r
|
|
|
380
461
|
assertions: ['所有声明加载 Skill 的配置精确命中本 Run 随机 probe;无 Skill baseline 不得猜中。'],
|
|
381
462
|
});
|
|
382
463
|
for (const probe of loadProbes) {
|
|
383
|
-
const passed = (probe
|
|
384
|
-
probe.noTools && probe.directoryMatched;
|
|
464
|
+
const passed = loadProbeCorePassed(probe, String(loadProbes[0]?.openCodeVersion || ''));
|
|
385
465
|
const expectation = {
|
|
386
466
|
text: '当前配置加载了本 Run 的 Basic Skill 正文',
|
|
387
467
|
passed,
|
|
@@ -759,11 +839,28 @@ export function assessEffectiveness(policy, observed) {
|
|
|
759
839
|
};
|
|
760
840
|
}
|
|
761
841
|
function readSkillSource(root, directory, label) {
|
|
762
|
-
const
|
|
842
|
+
const requestedPath = resolve(directory);
|
|
843
|
+
if (!pathEntryExists(requestedPath)) {
|
|
844
|
+
throw new Error(`${label} 路径不存在: ${directory}`);
|
|
845
|
+
}
|
|
846
|
+
const requestedInfo = lstatSync(requestedPath);
|
|
847
|
+
if (requestedInfo.isSymbolicLink()) {
|
|
848
|
+
throw new Error(`${label} 根目录不允许 symlink: ${directory}`);
|
|
849
|
+
}
|
|
850
|
+
if (!requestedInfo.isDirectory()) {
|
|
851
|
+
throw new Error(`${label} 根路径必须是目录: ${directory}`);
|
|
852
|
+
}
|
|
853
|
+
const path = realpathSync(requestedPath);
|
|
854
|
+
const tree = inspectSkillTree(path, label);
|
|
763
855
|
const skillPath = join(path, 'SKILL.md');
|
|
764
|
-
if (!
|
|
765
|
-
throw new Error(`${label}
|
|
856
|
+
if (!tree.some((entry) => entry.path === 'SKILL.md' && entry.kind === 'file')) {
|
|
857
|
+
throw new Error(`${label} 缺少普通文件 SKILL.md: ${directory}`);
|
|
858
|
+
}
|
|
766
859
|
const body = readFileSync(skillPath, 'utf8');
|
|
860
|
+
const skillEntry = tree.find((entry) => entry.path === 'SKILL.md' && entry.kind === 'file');
|
|
861
|
+
if (skillEntry.hash !== sha256(body)) {
|
|
862
|
+
throw new Error(`${label} SKILL.md 在读取期间发生变化`);
|
|
863
|
+
}
|
|
767
864
|
const frontmatter = body.match(/^---\s*\n([\s\S]*?)\n---/)?.[1] || '';
|
|
768
865
|
const name = frontmatter.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim() || '';
|
|
769
866
|
const version = frontmatter.match(/^metadata:\s*\n(?:^[ \t]+.*\n)*?^[ \t]+version:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim() || '';
|
|
@@ -771,10 +868,15 @@ function readSkillSource(root, directory, label) {
|
|
|
771
868
|
throw new Error(`${label} 元数据非法: name=${name || 'missing'} version=${version || 'missing'}`);
|
|
772
869
|
}
|
|
773
870
|
const snapshotPath = join(path, 'snapshot.json');
|
|
871
|
+
const snapshotTreeEntry = tree.find((entry) => entry.path === 'snapshot.json' && entry.kind === 'file');
|
|
774
872
|
let snapshot = null;
|
|
775
|
-
if (
|
|
873
|
+
if (snapshotTreeEntry) {
|
|
776
874
|
try {
|
|
777
|
-
const
|
|
875
|
+
const snapshotBody = readFileSync(snapshotPath, 'utf8');
|
|
876
|
+
if (snapshotTreeEntry.hash !== sha256(snapshotBody)) {
|
|
877
|
+
throw new Error('changed while reading');
|
|
878
|
+
}
|
|
879
|
+
const parsed = JSON.parse(snapshotBody);
|
|
778
880
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
779
881
|
throw new Error('must be object');
|
|
780
882
|
snapshot = parsed;
|
|
@@ -793,7 +895,10 @@ function readSkillSource(root, directory, label) {
|
|
|
793
895
|
throw new Error(`${label} snapshot.files 缺失`);
|
|
794
896
|
}
|
|
795
897
|
const expectedFiles = Object.keys(files).sort();
|
|
796
|
-
const actualFiles =
|
|
898
|
+
const actualFiles = tree
|
|
899
|
+
.filter((entry) => entry.kind === 'file' && entry.path !== 'snapshot.json')
|
|
900
|
+
.map((entry) => entry.path)
|
|
901
|
+
.sort();
|
|
797
902
|
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
|
798
903
|
throw new Error(`${label} snapshot 文件清单漂移: expected=${expectedFiles.join(',')} actual=${actualFiles.join(',')}`);
|
|
799
904
|
}
|
|
@@ -803,8 +908,8 @@ function readSkillSource(root, directory, label) {
|
|
|
803
908
|
typeof expected !== 'string') {
|
|
804
909
|
throw new Error(`${label} snapshot 文件清单非法: ${file}`);
|
|
805
910
|
}
|
|
806
|
-
const candidate =
|
|
807
|
-
if (!
|
|
911
|
+
const candidate = tree.find((entry) => entry.path === file && entry.kind === 'file');
|
|
912
|
+
if (!candidate || candidate.hash !== expected) {
|
|
808
913
|
throw new Error(`${label} snapshot 文件漂移: ${file}`);
|
|
809
914
|
}
|
|
810
915
|
}
|
|
@@ -814,34 +919,88 @@ function readSkillSource(root, directory, label) {
|
|
|
814
919
|
const displayPath = rel === '' || (rel !== '..' && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`))
|
|
815
920
|
? rel.split('\\').join('/') || '.'
|
|
816
921
|
: path;
|
|
817
|
-
return { path, displayPath, name, version, hash: bodyHash, snapshot };
|
|
922
|
+
return { path, displayPath, name, version, hash: bodyHash, snapshot, tree };
|
|
818
923
|
}
|
|
819
|
-
function
|
|
820
|
-
const
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
924
|
+
function inspectSkillTree(directory, label) {
|
|
925
|
+
const rootInfo = lstatSync(directory);
|
|
926
|
+
if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) {
|
|
927
|
+
throw new Error(`${label} 根路径必须是普通目录`);
|
|
928
|
+
}
|
|
929
|
+
const entries = [];
|
|
930
|
+
const pending = [''];
|
|
931
|
+
while (pending.length) {
|
|
932
|
+
const prefix = pending.pop();
|
|
933
|
+
const current = prefix ? join(directory, prefix) : directory;
|
|
934
|
+
for (const entry of readdirSync(current, { withFileTypes: true })
|
|
935
|
+
.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
936
|
+
const relativePath = (prefix ? `${prefix}/${entry.name}` : entry.name)
|
|
937
|
+
.split('\\').join('/');
|
|
938
|
+
const candidate = join(directory, relativePath);
|
|
939
|
+
const before = lstatSync(candidate);
|
|
940
|
+
if (before.isSymbolicLink()) {
|
|
941
|
+
throw new Error(`${label} 不允许 symlink: ${relativePath}`);
|
|
942
|
+
}
|
|
943
|
+
if (before.isDirectory()) {
|
|
944
|
+
entries.push({ path: relativePath, kind: 'directory' });
|
|
945
|
+
pending.push(relativePath);
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (!before.isFile()) {
|
|
949
|
+
throw new Error(`${label} 不允许特殊文件: ${relativePath}`);
|
|
950
|
+
}
|
|
951
|
+
if (before.nlink !== 1) {
|
|
952
|
+
throw new Error(`${label} 不允许 hardlink: ${relativePath}`);
|
|
953
|
+
}
|
|
954
|
+
const hash = sha256(readFileSync(candidate));
|
|
955
|
+
const after = lstatSync(candidate);
|
|
956
|
+
if (!after.isFile() || after.isSymbolicLink() || after.nlink !== 1 ||
|
|
957
|
+
after.dev !== before.dev || after.ino !== before.ino || after.size !== before.size) {
|
|
958
|
+
throw new Error(`${label} 文件在校验期间发生变化: ${relativePath}`);
|
|
959
|
+
}
|
|
960
|
+
entries.push({ path: relativePath, kind: 'file', hash });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return entries.sort((left, right) => left.path.localeCompare(right.path));
|
|
964
|
+
}
|
|
965
|
+
function assertSkillTreeMatches(actual, expected, label) {
|
|
966
|
+
if (stableJson(actual) !== stableJson(expected)) {
|
|
967
|
+
throw new Error(`${label} 全树在加载前发生漂移`);
|
|
968
|
+
}
|
|
833
969
|
}
|
|
834
|
-
function materializeConfiguration(sandbox, configuration, probe, command) {
|
|
970
|
+
function materializeConfiguration(sandbox, configuration, probe, command, environment) {
|
|
835
971
|
const target = join(sandbox, '.agents', 'skills', 'dingtalk-basic-behavior');
|
|
836
972
|
mkdirSync(dirname(target), { recursive: true });
|
|
837
973
|
rmSync(target, { recursive: true, force: true });
|
|
838
|
-
prepareOpenCodeEvalAgent(sandbox);
|
|
839
974
|
if (configuration.expectsLoad && configuration.skill) {
|
|
840
|
-
const
|
|
975
|
+
const sourceTree = inspectSkillTree(configuration.skill.path, `${configuration.name} Skill source`);
|
|
976
|
+
assertSkillTreeMatches(sourceTree, configuration.skill.tree, `${configuration.name} Skill source`);
|
|
977
|
+
const sourceSkillPath = join(configuration.skill.path, 'SKILL.md');
|
|
978
|
+
const sourceBody = readFileSync(sourceSkillPath, 'utf8');
|
|
979
|
+
if (sha256(sourceBody) !== configuration.skill.hash) {
|
|
980
|
+
throw new Error(`${configuration.name} Skill source SKILL.md 在复制前发生漂移`);
|
|
981
|
+
}
|
|
841
982
|
cpSync(configuration.skill.path, target, { recursive: true });
|
|
842
|
-
|
|
983
|
+
const copiedTree = inspectSkillTree(target, `${configuration.name} Skill copy`);
|
|
984
|
+
assertSkillTreeMatches(copiedTree, sourceTree, `${configuration.name} Skill copy`);
|
|
985
|
+
const instrumentedBody = `${sourceBody.trimEnd()}\n\n<!-- dta-load-probe:${probe} -->\n`;
|
|
986
|
+
const targetSkillPath = join(target, 'SKILL.md');
|
|
987
|
+
const temporarySkillPath = join(target, `.SKILL.md.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
|
|
988
|
+
try {
|
|
989
|
+
writeFileSync(temporarySkillPath, instrumentedBody, { flag: 'wx' });
|
|
990
|
+
renameSync(temporarySkillPath, targetSkillPath);
|
|
991
|
+
}
|
|
992
|
+
finally {
|
|
993
|
+
if (pathEntryExists(temporarySkillPath))
|
|
994
|
+
rmSync(temporarySkillPath, { force: true });
|
|
995
|
+
}
|
|
996
|
+
const instrumentedTree = sourceTree.map((entry) => entry.path === 'SKILL.md'
|
|
997
|
+
? { ...entry, hash: sha256(instrumentedBody) }
|
|
998
|
+
: entry);
|
|
999
|
+
assertSkillTreeMatches(inspectSkillTree(target, `${configuration.name} instrumented Skill`), instrumentedTree, `${configuration.name} instrumented Skill`);
|
|
1000
|
+
prepareOpenCodeEvalAgent(sandbox);
|
|
843
1001
|
const binding = bindRequiredBasicSkill(sandbox);
|
|
844
|
-
const preflight = verifyRequiredBasicSkill(binding, command);
|
|
1002
|
+
const preflight = verifyRequiredBasicSkill(binding, command, environment);
|
|
1003
|
+
const resolved = resolvedConfig(command, sandbox, environment);
|
|
845
1004
|
return {
|
|
846
1005
|
openCodeVersion: preflight.openCodeVersion,
|
|
847
1006
|
evidence: {
|
|
@@ -856,11 +1015,13 @@ function materializeConfiguration(sandbox, configuration, probe, command) {
|
|
|
856
1015
|
instrumentedHash: binding.skill.hash,
|
|
857
1016
|
probe,
|
|
858
1017
|
binding: preflight,
|
|
1018
|
+
resolvedConfig: resolved.summary,
|
|
859
1019
|
},
|
|
860
1020
|
};
|
|
861
1021
|
}
|
|
862
1022
|
removeRequiredBasicSkillInstruction(sandbox);
|
|
863
|
-
|
|
1023
|
+
prepareOpenCodeEvalAgent(sandbox);
|
|
1024
|
+
const baselineConfig = resolvedConfig(command, sandbox, environment);
|
|
864
1025
|
return {
|
|
865
1026
|
openCodeVersion: baselineConfig.openCodeVersion,
|
|
866
1027
|
evidence: {
|
|
@@ -872,21 +1033,21 @@ function materializeConfiguration(sandbox, configuration, probe, command) {
|
|
|
872
1033
|
},
|
|
873
1034
|
};
|
|
874
1035
|
}
|
|
875
|
-
function resolvedConfig(command, cwd) {
|
|
1036
|
+
function resolvedConfig(command, cwd, environment) {
|
|
876
1037
|
const version = spawnSync(command, ['--version'], {
|
|
877
|
-
cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024,
|
|
1038
|
+
cwd, env: environment, encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024,
|
|
878
1039
|
});
|
|
879
|
-
const child = spawnSync(command, ['debug', 'config'], {
|
|
880
|
-
cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 4 * 1024 * 1024,
|
|
1040
|
+
const child = spawnSync(command, ['debug', 'config', '--pure'], {
|
|
1041
|
+
cwd, env: environment, encoding: 'utf8', timeout: 30_000, maxBuffer: 4 * 1024 * 1024,
|
|
881
1042
|
});
|
|
882
1043
|
if (child.error || child.status !== 0) {
|
|
883
1044
|
throw new Error(`OpenCode baseline config preflight 失败: ${child.error?.message || child.stderr}`);
|
|
884
1045
|
}
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
if (instructions.includes('.agents/skills/dingtalk-basic-behavior/SKILL.md')) {
|
|
888
|
-
throw new Error('without_skill baseline 仍包含 Basic Skill instruction');
|
|
1046
|
+
if (version.error || version.status !== 0 || !String(version.stdout || '').trim()) {
|
|
1047
|
+
throw new Error(`OpenCode version preflight 失败: ${version.error?.message || version.stderr}`);
|
|
889
1048
|
}
|
|
1049
|
+
const value = JSON.parse(String(child.stdout || '{}'));
|
|
1050
|
+
const verified = validateIsolatedResolvedConfig(value, cwd);
|
|
890
1051
|
const permission = value.permission && typeof value.permission === 'object'
|
|
891
1052
|
? value.permission
|
|
892
1053
|
: {};
|
|
@@ -896,22 +1057,108 @@ function resolvedConfig(command, cwd) {
|
|
|
896
1057
|
return {
|
|
897
1058
|
openCodeVersion: String(version.stdout || '').trim(),
|
|
898
1059
|
summary: {
|
|
899
|
-
instructions,
|
|
1060
|
+
instructions: verified.instructions,
|
|
900
1061
|
default_agent: value.default_agent || null,
|
|
901
1062
|
permission: { skill: permission.skill || null },
|
|
902
1063
|
evalAgent: agent['dta-eval'] || null,
|
|
903
1064
|
},
|
|
904
1065
|
};
|
|
905
1066
|
}
|
|
906
|
-
|
|
1067
|
+
function validateIsolatedResolvedConfig(value, workspace) {
|
|
1068
|
+
const source = JSON.parse(readFileSync(join(workspace, 'opencode.json'), 'utf8'));
|
|
1069
|
+
const expectedInstructions = source.instructions === undefined ? [] : source.instructions;
|
|
1070
|
+
if (!Array.isArray(expectedInstructions) ||
|
|
1071
|
+
expectedInstructions.some((item) => typeof item !== 'string')) {
|
|
1072
|
+
throw new Error('OpenCode Eval project instructions 非法');
|
|
1073
|
+
}
|
|
1074
|
+
const instructions = value.instructions === undefined ? [] : value.instructions;
|
|
1075
|
+
if (!Array.isArray(instructions) || instructions.some((item) => typeof item !== 'string') ||
|
|
1076
|
+
stableJson(instructions) !== stableJson(expectedInstructions)) {
|
|
1077
|
+
throw new Error('OpenCode resolved instructions 被用户级、远端或托管 config 污染');
|
|
1078
|
+
}
|
|
1079
|
+
const plugins = value.plugin === undefined ? [] : value.plugin;
|
|
1080
|
+
if (!Array.isArray(plugins) || plugins.length > 0) {
|
|
1081
|
+
throw new Error('OpenCode resolved plugin 不为空,隔离失败');
|
|
1082
|
+
}
|
|
1083
|
+
const mcp = value.mcp === undefined ? {} : value.mcp;
|
|
1084
|
+
if (!mcp || typeof mcp !== 'object' || Array.isArray(mcp) || Object.keys(mcp).length > 0) {
|
|
1085
|
+
throw new Error('OpenCode resolved MCP 不为空,隔离失败');
|
|
1086
|
+
}
|
|
1087
|
+
if (value.share !== undefined && value.share !== 'disabled') {
|
|
1088
|
+
throw new Error('OpenCode resolved share 未禁用');
|
|
1089
|
+
}
|
|
1090
|
+
const provider = value.provider === undefined ? {} : value.provider;
|
|
1091
|
+
if (!provider || typeof provider !== 'object' || Array.isArray(provider) ||
|
|
1092
|
+
Object.keys(provider).length > 0) {
|
|
1093
|
+
throw new Error('OpenCode resolved provider 不为空,隔离评测拒绝项目级 provider 覆盖');
|
|
1094
|
+
}
|
|
1095
|
+
return { instructions: [...instructions] };
|
|
1096
|
+
}
|
|
1097
|
+
function stableJson(value) {
|
|
1098
|
+
if (Array.isArray(value))
|
|
1099
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
1100
|
+
if (value && typeof value === 'object') {
|
|
1101
|
+
return `{${Object.entries(value)
|
|
1102
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
1103
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(',')}}`;
|
|
1104
|
+
}
|
|
1105
|
+
return JSON.stringify(value);
|
|
1106
|
+
}
|
|
1107
|
+
export function prepareOpenCodeEvalAgent(workspace, tools = 'none', allowedReadPaths = []) {
|
|
907
1108
|
const path = join(workspace, 'opencode.json');
|
|
908
|
-
const
|
|
1109
|
+
const parsed = existsSync(path)
|
|
909
1110
|
? JSON.parse(readFileSync(path, 'utf8'))
|
|
910
1111
|
: { $schema: 'https://opencode.ai/config.json' };
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1112
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1113
|
+
throw new Error('OpenCode Eval opencode.json 必须是 object');
|
|
1114
|
+
}
|
|
1115
|
+
const config = parsed;
|
|
1116
|
+
if (config.instructions !== undefined &&
|
|
1117
|
+
(!Array.isArray(config.instructions) ||
|
|
1118
|
+
config.instructions.some((item) => typeof item !== 'string'))) {
|
|
1119
|
+
throw new Error('OpenCode Eval instructions 必须是 string array');
|
|
1120
|
+
}
|
|
1121
|
+
for (const instruction of config.instructions || []) {
|
|
1122
|
+
const raw = instruction.trim();
|
|
1123
|
+
const candidate = resolve(workspace, raw);
|
|
1124
|
+
if (!raw || /^https?:\/\//i.test(raw) || raw.startsWith('~/') ||
|
|
1125
|
+
raw.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(raw) ||
|
|
1126
|
+
raw.split(/[\\/]/).includes('..') || instructionUsesGlob(raw) ||
|
|
1127
|
+
!isWorkspacePathInside(workspace, candidate) || !existsSync(candidate)) {
|
|
1128
|
+
throw new Error(`OpenCode Eval instruction 必须是 sandbox 内已存在的本地相对文件: ${instruction}`);
|
|
1129
|
+
}
|
|
1130
|
+
const info = lstatSync(candidate);
|
|
1131
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
1132
|
+
throw new Error(`OpenCode Eval instruction 不是普通文件: ${instruction}`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (config.plugin !== undefined &&
|
|
1136
|
+
(!Array.isArray(config.plugin) || config.plugin.length > 0)) {
|
|
1137
|
+
throw new Error('OpenCode Eval 隔离工作区不允许 plugin');
|
|
1138
|
+
}
|
|
1139
|
+
if (config.mcp !== undefined &&
|
|
1140
|
+
(!config.mcp || typeof config.mcp !== 'object' || Array.isArray(config.mcp) ||
|
|
1141
|
+
Object.keys(config.mcp).length > 0)) {
|
|
1142
|
+
throw new Error('OpenCode Eval 隔离工作区不允许 MCP');
|
|
1143
|
+
}
|
|
1144
|
+
if (config.agent !== undefined &&
|
|
1145
|
+
(!config.agent || typeof config.agent !== 'object' || Array.isArray(config.agent))) {
|
|
1146
|
+
throw new Error('OpenCode Eval agent 必须是 object');
|
|
1147
|
+
}
|
|
1148
|
+
if (config.skills !== undefined &&
|
|
1149
|
+
(!config.skills || typeof config.skills !== 'object' || Array.isArray(config.skills) ||
|
|
1150
|
+
Object.keys(config.skills).length > 0)) {
|
|
1151
|
+
throw new Error('OpenCode Eval 隔离工作区不允许 skills.paths/urls');
|
|
1152
|
+
}
|
|
1153
|
+
for (const key of ['provider', 'formatter', 'lsp']) {
|
|
1154
|
+
const value = config[key];
|
|
1155
|
+
if (value !== undefined &&
|
|
1156
|
+
(!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
1157
|
+
Object.keys(value).length > 0)) {
|
|
1158
|
+
throw new Error(`OpenCode Eval 隔离工作区不允许项目级 ${key}`);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const evalAgent = {
|
|
915
1162
|
description: tools === 'none'
|
|
916
1163
|
? '只读执行 dingtalk-agent 合成行为评测'
|
|
917
1164
|
: '在隔离工作区内执行 dingtalk-agent 文件/产物评测',
|
|
@@ -921,13 +1168,96 @@ export function prepareOpenCodeEvalAgent(workspace, tools = 'none') {
|
|
|
921
1168
|
: tools === 'workspace-read'
|
|
922
1169
|
? { '*': false, read: true }
|
|
923
1170
|
: { '*': false, read: true, write: true, edit: true },
|
|
1171
|
+
permission: tools === 'none'
|
|
1172
|
+
? { '*': 'deny', external_directory: 'deny' }
|
|
1173
|
+
: tools === 'workspace-read'
|
|
1174
|
+
? {
|
|
1175
|
+
'*': 'deny',
|
|
1176
|
+
read: allowedReadPaths.length
|
|
1177
|
+
? Object.fromEntries([
|
|
1178
|
+
['*', 'deny'],
|
|
1179
|
+
...allowedReadPaths.flatMap((item) => {
|
|
1180
|
+
const absolute = resolve(workspace, item);
|
|
1181
|
+
const variants = [item, absolute];
|
|
1182
|
+
if (existsSync(absolute)) {
|
|
1183
|
+
const real = realpathSync(absolute);
|
|
1184
|
+
variants.push(real, relative(parse(real).root, real));
|
|
1185
|
+
}
|
|
1186
|
+
return [...new Set(variants)].map((path) => [path, 'allow']);
|
|
1187
|
+
}),
|
|
1188
|
+
])
|
|
1189
|
+
: 'allow',
|
|
1190
|
+
external_directory: 'deny',
|
|
1191
|
+
}
|
|
1192
|
+
: { '*': 'deny', read: 'allow', edit: 'allow', external_directory: 'deny' },
|
|
924
1193
|
};
|
|
925
|
-
|
|
926
|
-
|
|
1194
|
+
// Rebuild from an allowlist. Provider identity/credentials come only from the
|
|
1195
|
+
// isolated host and explicit --model; executable project surfaces such as
|
|
1196
|
+
// formatter, LSP, plugin, MCP and arbitrary agents never enter the probe.
|
|
1197
|
+
const safeConfig = {
|
|
1198
|
+
$schema: typeof config.$schema === 'string'
|
|
1199
|
+
? config.$schema : 'https://opencode.ai/config.json',
|
|
1200
|
+
instructions: [...(config.instructions || [])],
|
|
1201
|
+
permission: {
|
|
1202
|
+
skill: { '*': 'deny', 'dingtalk-basic-behavior': 'allow' },
|
|
1203
|
+
},
|
|
1204
|
+
agent: { 'dta-eval': evalAgent },
|
|
1205
|
+
plugin: [],
|
|
1206
|
+
mcp: {},
|
|
1207
|
+
share: 'disabled',
|
|
1208
|
+
autoupdate: false,
|
|
1209
|
+
};
|
|
1210
|
+
atomicJson(path, safeConfig);
|
|
1211
|
+
}
|
|
1212
|
+
export function resolveOpenCodeEvalAgent(command, workspace, environment = process.env) {
|
|
1213
|
+
const child = spawnSync(command, ['debug', 'config', '--pure'], {
|
|
1214
|
+
cwd: workspace,
|
|
1215
|
+
env: environment,
|
|
1216
|
+
encoding: 'utf8',
|
|
1217
|
+
timeout: 30_000,
|
|
1218
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
1219
|
+
});
|
|
1220
|
+
if (child.error || child.status !== 0) {
|
|
1221
|
+
throw new Error(`OpenCode eval agent config preflight 失败: ${child.error?.message || String(child.stderr || child.stdout || '').trim()}`);
|
|
1222
|
+
}
|
|
1223
|
+
const value = JSON.parse(String(child.stdout || '{}'));
|
|
1224
|
+
validateIsolatedResolvedConfig(value, workspace);
|
|
1225
|
+
const agents = value.agent && typeof value.agent === 'object' && !Array.isArray(value.agent)
|
|
1226
|
+
? value.agent : {};
|
|
1227
|
+
const agent = agents['dta-eval'];
|
|
1228
|
+
if (!agent || typeof agent !== 'object' || Array.isArray(agent)) {
|
|
1229
|
+
throw new Error('OpenCode resolved config 缺少 dta-eval agent');
|
|
1230
|
+
}
|
|
1231
|
+
const source = JSON.parse(readFileSync(join(workspace, 'opencode.json'), 'utf8'));
|
|
1232
|
+
const expected = source?.agent?.['dta-eval'];
|
|
1233
|
+
const normalized = { ...agent };
|
|
1234
|
+
if (normalized.options && typeof normalized.options === 'object' &&
|
|
1235
|
+
!Array.isArray(normalized.options) && Object.keys(normalized.options).length === 0) {
|
|
1236
|
+
delete normalized.options;
|
|
1237
|
+
}
|
|
1238
|
+
if (stableJson(normalized) !== stableJson(expected)) {
|
|
1239
|
+
throw new Error('OpenCode resolved dta-eval 被外部 config 注入或扩权');
|
|
1240
|
+
}
|
|
1241
|
+
return agent;
|
|
927
1242
|
}
|
|
928
|
-
export function
|
|
1243
|
+
export function readOpenCodeVersion(command, workspace, environment = process.env) {
|
|
1244
|
+
const child = spawnSync(command, ['--version'], {
|
|
1245
|
+
cwd: workspace,
|
|
1246
|
+
env: environment,
|
|
1247
|
+
encoding: 'utf8',
|
|
1248
|
+
timeout: 30_000,
|
|
1249
|
+
maxBuffer: 1024 * 1024,
|
|
1250
|
+
});
|
|
1251
|
+
const version = String(child.stdout || '').trim();
|
|
1252
|
+
if (child.error || child.status !== 0 || !version) {
|
|
1253
|
+
throw new Error(`OpenCode version preflight 失败: ${child.error?.message || String(child.stderr || child.stdout || '').trim()}`);
|
|
1254
|
+
}
|
|
1255
|
+
return version;
|
|
1256
|
+
}
|
|
1257
|
+
export function runOpenCodePrompt(command, cwd, model, prompt, title, timeoutMs, sessionId = '', environment = process.env) {
|
|
929
1258
|
const args = [
|
|
930
|
-
'run', '--dir', cwd, '--format', 'json', '--model', model,
|
|
1259
|
+
'run', '--pure', '--dir', cwd, '--format', 'json', '--model', model,
|
|
1260
|
+
'--agent', 'dta-eval', '--title', title,
|
|
931
1261
|
];
|
|
932
1262
|
if (sessionId)
|
|
933
1263
|
args.push('--session', sessionId);
|
|
@@ -935,6 +1265,7 @@ export function runOpenCodePrompt(command, cwd, model, prompt, title, timeoutMs,
|
|
|
935
1265
|
const started = Date.now();
|
|
936
1266
|
const child = spawnSync(command, args, {
|
|
937
1267
|
cwd,
|
|
1268
|
+
env: environment,
|
|
938
1269
|
encoding: 'utf8',
|
|
939
1270
|
timeout: timeoutMs,
|
|
940
1271
|
maxBuffer: 16 * 1024 * 1024,
|
|
@@ -958,13 +1289,14 @@ export function runOpenCodePrompt(command, cwd, model, prompt, title, timeoutMs,
|
|
|
958
1289
|
const toolNames = [...new Set(toolEvents
|
|
959
1290
|
.map((event) => String(event.part?.tool || event.part?.name || event.tool || event.name || ''))
|
|
960
1291
|
.filter(Boolean))];
|
|
961
|
-
const toolPaths = [...new Set(toolEvents.flatMap((event) => extractToolPaths(event)))];
|
|
1292
|
+
const toolPaths = [...new Set(toolEvents.flatMap((event) => extractToolPaths(event, cwd)))];
|
|
962
1293
|
const toolTrace = toolEvents.map((event) => ({
|
|
963
1294
|
name: String(event.part?.tool || event.part?.name || event.tool || event.name || ''),
|
|
964
|
-
paths: [...new Set(extractToolPaths(event))],
|
|
1295
|
+
paths: [...new Set(extractToolPaths(event, cwd))],
|
|
965
1296
|
status: String(event.part?.state?.status || event.status || ''),
|
|
1297
|
+
readWindow: extractReadWindow(event),
|
|
966
1298
|
})).filter((item) => item.name);
|
|
967
|
-
const sessionEvidence = verifySessionDirectory(command, String(session || ''), cwd);
|
|
1299
|
+
const sessionEvidence = verifySessionDirectory(command, String(session || ''), cwd, environment);
|
|
968
1300
|
return {
|
|
969
1301
|
exitCode: child.status,
|
|
970
1302
|
stdout: String(child.stdout || ''),
|
|
@@ -980,19 +1312,29 @@ export function runOpenCodePrompt(command, cwd, model, prompt, title, timeoutMs,
|
|
|
980
1312
|
durationMs: Date.now() - started,
|
|
981
1313
|
directoryMatched: sessionEvidence.matched,
|
|
982
1314
|
sessionDirectory: sessionEvidence.directory,
|
|
1315
|
+
sessionExport: {
|
|
1316
|
+
exitCode: sessionEvidence.exitCode,
|
|
1317
|
+
stdout: sessionEvidence.stdout,
|
|
1318
|
+
stderr: sessionEvidence.stderr,
|
|
1319
|
+
},
|
|
983
1320
|
};
|
|
984
1321
|
}
|
|
985
|
-
function verifySessionDirectory(command, sessionId, expected) {
|
|
986
|
-
if (!sessionId)
|
|
987
|
-
return { matched: false, directory: '' };
|
|
1322
|
+
function verifySessionDirectory(command, sessionId, expected, environment) {
|
|
1323
|
+
if (!sessionId) {
|
|
1324
|
+
return { matched: false, directory: '', exitCode: null, stdout: '', stderr: '' };
|
|
1325
|
+
}
|
|
988
1326
|
const child = spawnSync(command, ['export', sessionId], {
|
|
989
1327
|
cwd: expected,
|
|
1328
|
+
env: environment,
|
|
990
1329
|
encoding: 'utf8',
|
|
991
1330
|
timeout: 30_000,
|
|
992
1331
|
maxBuffer: 8 * 1024 * 1024,
|
|
993
1332
|
});
|
|
994
|
-
|
|
995
|
-
|
|
1333
|
+
const stdout = String(child.stdout || '');
|
|
1334
|
+
const stderr = String(child.stderr || child.error?.message || '');
|
|
1335
|
+
if (child.error || child.status !== 0) {
|
|
1336
|
+
return { matched: false, directory: '', exitCode: child.status, stdout, stderr };
|
|
1337
|
+
}
|
|
996
1338
|
try {
|
|
997
1339
|
const exported = JSON.parse(String(child.stdout || '{}'));
|
|
998
1340
|
const directory = String(exported?.info?.directory || '');
|
|
@@ -1000,10 +1342,13 @@ function verifySessionDirectory(command, sessionId, expected) {
|
|
|
1000
1342
|
matched: Boolean(directory) && existsSync(directory) &&
|
|
1001
1343
|
realpathSync(directory) === realpathSync(expected),
|
|
1002
1344
|
directory,
|
|
1345
|
+
exitCode: child.status,
|
|
1346
|
+
stdout,
|
|
1347
|
+
stderr,
|
|
1003
1348
|
};
|
|
1004
1349
|
}
|
|
1005
1350
|
catch {
|
|
1006
|
-
return { matched: false, directory: '' };
|
|
1351
|
+
return { matched: false, directory: '', exitCode: child.status, stdout, stderr };
|
|
1007
1352
|
}
|
|
1008
1353
|
}
|
|
1009
1354
|
function writeExecution(directory, prompt, execution, result) {
|
|
@@ -1011,6 +1356,8 @@ function writeExecution(directory, prompt, execution, result) {
|
|
|
1011
1356
|
writeFileSync(join(directory, 'prompt.txt'), prompt);
|
|
1012
1357
|
writeFileSync(join(directory, 'opencode.stdout.ndjson'), execution.stdout);
|
|
1013
1358
|
writeFileSync(join(directory, 'opencode.stderr.log'), execution.stderr);
|
|
1359
|
+
writeFileSync(join(directory, 'opencode.export.stdout.json'), execution.sessionExport.stdout);
|
|
1360
|
+
writeFileSync(join(directory, 'opencode.export.stderr.log'), execution.sessionExport.stderr);
|
|
1014
1361
|
writeJson(join(directory, 'result.json'), result);
|
|
1015
1362
|
}
|
|
1016
1363
|
function passRate(values) {
|
|
@@ -1031,18 +1378,148 @@ function atomicJson(path, value) {
|
|
|
1031
1378
|
renameSync(temporary, path);
|
|
1032
1379
|
}
|
|
1033
1380
|
function safeInside(root, path, mustExist) {
|
|
1034
|
-
const
|
|
1035
|
-
const
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1381
|
+
const rootLexical = resolve(root);
|
|
1382
|
+
const rootReal = realpathSync(rootLexical);
|
|
1383
|
+
const requested = resolve(rootLexical, path);
|
|
1384
|
+
if (mustExist) {
|
|
1385
|
+
if (!existsSync(requested))
|
|
1386
|
+
throw new Error(`OpenCode Eval 路径不存在: ${path}`);
|
|
1387
|
+
const candidate = realpathSync(requested);
|
|
1388
|
+
if (!isWorkspacePathInside(rootReal, candidate)) {
|
|
1389
|
+
throw new Error(`OpenCode Eval 路径真实位置越出当前目录: ${path}`);
|
|
1390
|
+
}
|
|
1391
|
+
return candidate;
|
|
1392
|
+
}
|
|
1393
|
+
const lexicalRel = relative(rootLexical, requested);
|
|
1394
|
+
let rel = lexicalRel;
|
|
1395
|
+
if (!relativeIsInside(lexicalRel)) {
|
|
1396
|
+
let ancestor = requested;
|
|
1397
|
+
while (!pathEntryExists(ancestor) && dirname(ancestor) !== ancestor)
|
|
1398
|
+
ancestor = dirname(ancestor);
|
|
1399
|
+
const projected = resolve(realpathSync(ancestor), relative(ancestor, requested));
|
|
1400
|
+
if (!isWorkspacePathInside(rootReal, projected)) {
|
|
1401
|
+
throw new Error(`OpenCode Eval 路径必须位于当前目录内: ${path}`);
|
|
1402
|
+
}
|
|
1403
|
+
rel = relative(rootReal, projected);
|
|
1404
|
+
}
|
|
1405
|
+
const resolved = resolve(rootReal, rel);
|
|
1406
|
+
let cursor = rootReal;
|
|
1407
|
+
for (const segment of rel.split(/[\\/]/).filter(Boolean)) {
|
|
1408
|
+
cursor = join(cursor, segment);
|
|
1409
|
+
if (!pathEntryExists(cursor))
|
|
1410
|
+
break;
|
|
1411
|
+
const info = lstatSync(cursor);
|
|
1412
|
+
if (info.isSymbolicLink()) {
|
|
1413
|
+
throw new Error(`OpenCode Eval 输出路径不允许 symlink: ${relative(rootReal, cursor)}`);
|
|
1414
|
+
}
|
|
1415
|
+
if (cursor !== resolved && !info.isDirectory()) {
|
|
1416
|
+
throw new Error(`OpenCode Eval 输出路径祖先不是目录: ${relative(rootReal, cursor)}`);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
let ancestor = resolved;
|
|
1420
|
+
while (!pathEntryExists(ancestor) && dirname(ancestor) !== ancestor)
|
|
1421
|
+
ancestor = dirname(ancestor);
|
|
1422
|
+
const projected = resolve(realpathSync(ancestor), relative(ancestor, resolved));
|
|
1423
|
+
if (!isWorkspacePathInside(rootReal, projected)) {
|
|
1424
|
+
throw new Error(`OpenCode Eval 输出路径真实位置越出当前目录: ${path}`);
|
|
1425
|
+
}
|
|
1426
|
+
return resolved;
|
|
1427
|
+
}
|
|
1428
|
+
function relativeIsInside(rel) {
|
|
1429
|
+
const separator = process.platform === 'win32' ? '\\' : '/';
|
|
1430
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${separator}`));
|
|
1044
1431
|
}
|
|
1045
|
-
function
|
|
1432
|
+
function assertCopyableWorkspaceTree(root, excludeControl = true) {
|
|
1433
|
+
const pending = [root];
|
|
1434
|
+
while (pending.length) {
|
|
1435
|
+
const directory = pending.pop();
|
|
1436
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
1437
|
+
const path = join(directory, entry.name);
|
|
1438
|
+
const rel = relative(root, path);
|
|
1439
|
+
const first = rel.split(/[\\/]/)[0];
|
|
1440
|
+
if (excludeControl && ['.git', '.dingtalk-agent'].includes(first))
|
|
1441
|
+
continue;
|
|
1442
|
+
if (first === '.opencode') {
|
|
1443
|
+
throw new Error('OpenCode Eval workspace 不允许 .opencode 可执行扩展目录');
|
|
1444
|
+
}
|
|
1445
|
+
const info = lstatSync(path);
|
|
1446
|
+
if (info.isSymbolicLink()) {
|
|
1447
|
+
throw new Error(`OpenCode Eval workspace 不允许 symlink: ${rel}`);
|
|
1448
|
+
}
|
|
1449
|
+
if (info.isDirectory())
|
|
1450
|
+
pending.push(path);
|
|
1451
|
+
else if (!info.isFile()) {
|
|
1452
|
+
throw new Error(`OpenCode Eval workspace 不允许特殊文件: ${rel}`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
function assertSafeEvalProjectConfig(workspace) {
|
|
1458
|
+
const path = join(workspace, 'opencode.json');
|
|
1459
|
+
if (!existsSync(path))
|
|
1460
|
+
return;
|
|
1461
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
1462
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1463
|
+
throw new Error('OpenCode Eval opencode.json 必须是 object');
|
|
1464
|
+
}
|
|
1465
|
+
const config = parsed;
|
|
1466
|
+
const instructions = config.instructions === undefined ? [] : config.instructions;
|
|
1467
|
+
if (!Array.isArray(instructions) || instructions.some((item) => typeof item !== 'string')) {
|
|
1468
|
+
throw new Error('OpenCode Eval instructions 必须是 string array');
|
|
1469
|
+
}
|
|
1470
|
+
for (const instruction of instructions) {
|
|
1471
|
+
const raw = instruction.trim();
|
|
1472
|
+
const candidate = resolve(workspace, raw);
|
|
1473
|
+
if (!raw || /^https?:\/\//i.test(raw) || raw.startsWith('~/') ||
|
|
1474
|
+
raw.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(raw) ||
|
|
1475
|
+
raw.split(/[\\/]/).includes('..') || instructionUsesGlob(raw) ||
|
|
1476
|
+
!isWorkspacePathInside(workspace, candidate) || !existsSync(candidate) ||
|
|
1477
|
+
!lstatSync(candidate).isFile()) {
|
|
1478
|
+
throw new Error(`OpenCode Eval instruction 必须是 sandbox 内已存在的本地相对文件: ${instruction}`);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
const forbidden = ['provider', 'formatter', 'lsp', 'skills'];
|
|
1482
|
+
for (const key of forbidden) {
|
|
1483
|
+
const value = config[key];
|
|
1484
|
+
if (value !== undefined &&
|
|
1485
|
+
(!value || typeof value !== 'object' || Array.isArray(value) ||
|
|
1486
|
+
Object.keys(value).length > 0)) {
|
|
1487
|
+
throw new Error(`OpenCode Eval 隔离工作区不允许项目级 ${key}`);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if (config.plugin !== undefined &&
|
|
1491
|
+
(!Array.isArray(config.plugin) || config.plugin.length > 0)) {
|
|
1492
|
+
throw new Error('OpenCode Eval 隔离工作区不允许 plugin');
|
|
1493
|
+
}
|
|
1494
|
+
if (config.mcp !== undefined &&
|
|
1495
|
+
(!config.mcp || typeof config.mcp !== 'object' || Array.isArray(config.mcp) ||
|
|
1496
|
+
Object.keys(config.mcp).length > 0)) {
|
|
1497
|
+
throw new Error('OpenCode Eval 隔离工作区不允许 MCP');
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
function initializeIsolatedWorktree(workspace) {
|
|
1501
|
+
const command = process.env.DTA_GIT_BIN ||
|
|
1502
|
+
(existsSync('/usr/bin/git') ? '/usr/bin/git' : 'git');
|
|
1503
|
+
const child = spawnSync(command, ['init', '--quiet'], {
|
|
1504
|
+
cwd: workspace,
|
|
1505
|
+
encoding: 'utf8',
|
|
1506
|
+
timeout: 30_000,
|
|
1507
|
+
maxBuffer: 1024 * 1024,
|
|
1508
|
+
});
|
|
1509
|
+
if (child.error || child.status !== 0) {
|
|
1510
|
+
throw new Error(`OpenCode Eval 无法初始化隔离 git worktree: ${child.error?.message || String(child.stderr || child.stdout || '').trim()}`);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
function pathEntryExists(path) {
|
|
1514
|
+
try {
|
|
1515
|
+
lstatSync(path);
|
|
1516
|
+
return true;
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
function extractToolPaths(event, cwd) {
|
|
1046
1523
|
const values = [];
|
|
1047
1524
|
const candidates = [event.part?.state?.input, event.part?.state?.metadata];
|
|
1048
1525
|
for (const candidate of candidates) {
|
|
@@ -1051,12 +1528,19 @@ function extractToolPaths(event) {
|
|
|
1051
1528
|
for (const [key, value] of Object.entries(candidate)) {
|
|
1052
1529
|
if (!['filePath', 'filepath', 'path', 'directory'].includes(key) || typeof value !== 'string')
|
|
1053
1530
|
continue;
|
|
1054
|
-
|
|
1055
|
-
|
|
1531
|
+
values.push(value.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(value)
|
|
1532
|
+
? resolve(value) : resolve(cwd, value));
|
|
1056
1533
|
}
|
|
1057
1534
|
}
|
|
1058
1535
|
return values;
|
|
1059
1536
|
}
|
|
1537
|
+
function extractReadWindow(event) {
|
|
1538
|
+
const input = event.part?.state?.input;
|
|
1539
|
+
return {
|
|
1540
|
+
offset: Number.isInteger(input?.offset) ? Number(input.offset) : null,
|
|
1541
|
+
limit: Number.isInteger(input?.limit) ? Number(input.limit) : null,
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1060
1544
|
function safeName(value) {
|
|
1061
1545
|
return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'case';
|
|
1062
1546
|
}
|