@xdxer/dingtalk-agent 0.1.4-beta.9 → 0.1.4
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 +198 -0
- package/README.en.md +98 -326
- package/README.md +95 -673
- package/dist/bin/dingtalk-agent.js +200 -18
- package/dist/bin/dingtalk-agent.js.map +1 -1
- package/dist/src/agent-audit.js +1014 -89
- package/dist/src/agent-audit.js.map +1 -1
- package/dist/src/agent-enhance.js +41 -8
- package/dist/src/agent-enhance.js.map +1 -1
- package/dist/src/agent-platform.js +299 -0
- package/dist/src/agent-platform.js.map +1 -0
- package/dist/src/config.js +1 -7
- package/dist/src/config.js.map +1 -1
- package/dist/src/development-workspace.js +31 -5
- package/dist/src/development-workspace.js.map +1 -1
- package/dist/src/doctor.js +74 -15
- package/dist/src/doctor.js.map +1 -1
- package/dist/src/host-detect.js +146 -0
- package/dist/src/host-detect.js.map +1 -0
- package/dist/src/instruction-path.js +270 -0
- package/dist/src/instruction-path.js.map +1 -0
- package/dist/src/map.js +157 -0
- package/dist/src/map.js.map +1 -0
- package/dist/src/multica-deploy.js +78 -20
- package/dist/src/multica-deploy.js.map +1 -1
- package/dist/src/multica-provider.js +1 -1
- package/dist/src/multica-provider.js.map +1 -1
- package/dist/src/opencode-evals.js +710 -225
- 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/dist/src/remote-state-evals.js +2 -1
- package/dist/src/remote-state-evals.js.map +1 -1
- package/dist/src/robot-evals.js +2 -1
- package/dist/src/robot-evals.js.map +1 -1
- package/dist/src/setup.js +6 -5
- package/dist/src/setup.js.map +1 -1
- package/dist/src/skill-manager.js +141 -12
- package/dist/src/skill-manager.js.map +1 -1
- package/dist/src/skills.js +2 -1
- package/dist/src/skills.js.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/upgrade.js +23 -27
- package/dist/src/upgrade.js.map +1 -1
- package/dist/src/version.js +73 -0
- package/dist/src/version.js.map +1 -0
- package/docs/INSTALLATION.md +3 -3
- package/docs/assets/agent-delivery-lifecycle.svg +103 -0
- package/docs/schemas/agent-platform.schema.json +13 -0
- package/docs/schemas/project.schema.json +3 -0
- package/docs/schemas/release-readiness.schema.json +2 -1
- package/evals/README.md +17 -0
- package/lab/README.md +3 -3
- package/lab/agent-eval/catalog.json +5 -5
- package/lab/agent-eval/classic-failures.json +9 -9
- package/lab/agent-eval/completion-gate-regression.json +6 -6
- package/lab/agent-eval/remote-state-workspace/opencode.json +1 -1
- package/lab/agent-eval/workspace/AGENTS.md +1 -1
- package/lab/project-workspace/fake-multica-provider.mjs +17 -6
- package/lab/project-workspace/opencode-provider-suite.json +1 -1
- package/lab/robot-eval/suite.json +1 -1
- package/lab/robot-eval/workspace/AGENTS.md +1 -1
- package/lab/schemas/agent-eval-catalog.schema.json +1 -1
- package/package.json +13 -12
- package/skills/README.md +23 -0
- package/skills/core/dingtalk-agent-compose/SKILL.md +151 -0
- package/skills/core/dingtalk-agent-compose/assets/AGENTS.template.md +35 -0
- package/skills/core/dingtalk-agent-compose/assets/role-skill.template.md +32 -0
- package/skills/core/dingtalk-agent-compose/evals/evals.json +129 -0
- package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/references/agent-definition-contract.md +3 -3
- package/skills/core/dingtalk-agent-compose/references/host-loading-contract.md +58 -0
- package/skills/core/dingtalk-agent-compose/references/hosts/claude-code.md +48 -0
- package/skills/core/dingtalk-agent-compose/references/hosts/opencode.md +77 -0
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/SKILL.md +31 -7
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/assets/eval-catalog.template.json +1 -1
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/evals/evals.json +22 -0
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/eval-topology.md +14 -0
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/evidence-contract.md +21 -0
- package/skills/core/dingtalk-agent-eval/references/failure-to-case.md +35 -0
- package/skills/core/dingtalk-agent-eval/references/interactive-debug-channels.md +93 -0
- package/skills/core/dingtalk-agent-eval/references/local-connector-smoke.md +75 -0
- package/skills/core/dingtalk-basic-behavior/SKILL.md +87 -0
- package/skills/{dingtalk-basic-behavior → 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/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/task-lifecycle.md +15 -3
- package/skills/core/dingtalk-basic-behavior/references/truth-and-recovery.md +65 -0
- package/skills/platforms/deap/PLATFORM.md +3 -0
- package/skills/platforms/deap/README.md +3 -0
- package/skills/platforms/multica-dingtalk/PLATFORM.md +40 -0
- package/skills/{dingtalk-agent-boot-multica → platforms/multica-dingtalk/dingtalk-agent-boot-multica}/SKILL.md +4 -4
- package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/SKILL.md +1 -1
- package/skills/platforms/multica-dingtalk/multica-external/SKILL.md +282 -0
- package/skills/platforms/multica-dingtalk/multica-external/scripts/bootstrap.sh +78 -0
- package/skills/platforms/multica-dingtalk/multica-external/scripts/multica_ext.py +1180 -0
- package/skills/dingtalk-agent-compose/SKILL.md +0 -110
- package/skills/dingtalk-agent-compose/assets/AGENTS.template.md +0 -26
- package/skills/dingtalk-agent-compose/assets/role-skill.template.md +0 -24
- package/skills/dingtalk-agent-compose/evals/evals.json +0 -94
- package/skills/dingtalk-agent-compose/references/opencode-host-contract.md +0 -65
- package/skills/dingtalk-basic-behavior/SKILL.md +0 -146
- /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/assets/agent.bindings.dingtalk-doc.template.json +0 -0
- /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/assets/agent.bindings.local.template.json +0 -0
- /package/skills/{dingtalk-agent-compose/assets → core/dingtalk-agent-compose/assets/hosts/opencode}/opencode.template.json +0 -0
- /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/references/storage-routing.md +0 -0
- /package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/scenario-taxonomy.md +0 -0
- /package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/storage-modes.md +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/assets/memory-candidate-proposal.json +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/assets/task-checkpoint.json +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/action-contract.md +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/event-to-behavior.md +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/perception-and-gates.md +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/runtime-modes.md +0 -0
- /package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/references/multica-deployment-contract.md +0 -0
- /package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/references/promotion-observation-contract.md +0 -0
|
@@ -2,13 +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
|
+
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
|
+
}
|
|
12
43
|
export function runOpenCodeEvaluation(root, workspacePath, suitePath, options = {}) {
|
|
13
44
|
const workspace = safeInside(root, workspacePath, true);
|
|
14
45
|
const suite = loadRobotEvalSuite(root, suitePath);
|
|
@@ -21,18 +52,22 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
21
52
|
const unknownCases = [...requestedCases].filter((id) => !knownBehaviorCases.has(id));
|
|
22
53
|
if (unknownCases.length)
|
|
23
54
|
throw new Error(`OpenCode Eval case 不存在: ${unknownCases.join(',')}`);
|
|
24
|
-
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' &&
|
|
25
56
|
(!requestedCases.size || requestedCases.has(item.id)));
|
|
26
|
-
if (!cases.length)
|
|
57
|
+
if (!cases.length && !options.loadOnly)
|
|
27
58
|
throw new Error('OpenCode Eval 选择后没有行为 case');
|
|
28
59
|
const model = options.model || 'deepseek/deepseek-chat';
|
|
29
60
|
if (!model.startsWith('deepseek/'))
|
|
30
61
|
throw new Error('OpenCode Eval 模型必须固定为 deepseek/*');
|
|
31
62
|
const packageRoot = resolvePackageRoot(import.meta.url);
|
|
32
|
-
const currentSkill = readSkillSource(root,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
:
|
|
63
|
+
const currentSkill = readSkillSource(root, bundledSkillPath(packageRoot, 'dingtalk-basic-behavior'), 'current Skill');
|
|
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
|
+
}
|
|
36
71
|
if (previousSkill && previousSkill.hash === currentSkill.hash) {
|
|
37
72
|
throw new Error('OpenCode Eval previous Skill 与 current Skill 内容相同,无法形成版本对照');
|
|
38
73
|
}
|
|
@@ -104,8 +139,15 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
104
139
|
if (!options.yes) {
|
|
105
140
|
throw new Error('OpenCode Eval 会调用模型并产生本地 Session/费用;必须同时传 --execute --yes');
|
|
106
141
|
}
|
|
142
|
+
// Reject source code/config execution surfaces before creating evidence or
|
|
143
|
+
// starting any OpenCode child process.
|
|
144
|
+
assertCopyableWorkspaceTree(workspace);
|
|
145
|
+
assertSafeEvalProjectConfig(workspace);
|
|
107
146
|
const runId = `opencode_eval_${Date.now()}_${randomUUID().slice(0, 8)}`;
|
|
108
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
|
+
}
|
|
109
151
|
mkdirSync(evidenceRoot, { recursive: true });
|
|
110
152
|
writeJson(join(evidenceRoot, 'plan.json'), plan);
|
|
111
153
|
writeJson(join(evidenceRoot, 'suite.json'), suite);
|
|
@@ -133,141 +175,181 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
133
175
|
return first !== '.git' && first !== '.dingtalk-agent';
|
|
134
176
|
},
|
|
135
177
|
});
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const probeResult = {
|
|
149
|
-
configuration: configuration.name,
|
|
150
|
-
runNumber,
|
|
151
|
-
expectsLoad: configuration.expectsLoad,
|
|
152
|
-
skill: configuration.skill ? {
|
|
153
|
-
name: configuration.skill.name,
|
|
154
|
-
version: configuration.skill.version,
|
|
155
|
-
hash: configuration.skill.hash,
|
|
156
|
-
} : null,
|
|
157
|
-
expected: configuration.expectsLoad ? exactProbe : 'must-not-equal-config-probe',
|
|
158
|
-
answer: probeExecution.answer,
|
|
159
|
-
exactLoaded: probeExecution.answer.trim() === exactProbe,
|
|
160
|
-
didNotGuess: configuration.expectsLoad
|
|
161
|
-
? null : probeExecution.answer.trim() !== exactProbe,
|
|
162
|
-
baselineDidNotGuess: configuration.expectsLoad
|
|
163
|
-
? null : probeExecution.answer.trim() !== exactProbe,
|
|
164
|
-
noTools: probeExecution.toolCalls === 0,
|
|
165
|
-
directoryMatched: probeExecution.directoryMatched,
|
|
166
|
-
sessionDirectory: probeExecution.sessionDirectory,
|
|
167
|
-
sessionId: probeExecution.sessionId,
|
|
168
|
-
tokens: probeExecution.tokens,
|
|
169
|
-
cost: probeExecution.cost,
|
|
170
|
-
durationMs: probeExecution.durationMs,
|
|
171
|
-
exitCode: probeExecution.exitCode,
|
|
172
|
-
openCodeVersion: materialized.openCodeVersion,
|
|
173
|
-
};
|
|
174
|
-
loadProbes.push(probeResult);
|
|
175
|
-
writeExecution(join(evidenceRoot, 'load-probes', configuration.name, `run-${runNumber}`), probePrompt, probeExecution, probeResult);
|
|
176
|
-
const sessions = new Map();
|
|
177
|
-
for (const testCase of cases) {
|
|
178
|
-
const toolMode = testCase.execution?.tools || 'none';
|
|
179
|
-
prepareOpenCodeEvalAgent(sandbox, toolMode);
|
|
180
|
-
const parentSession = testCase.continuationOf
|
|
181
|
-
? sessions.get(testCase.continuationOf) || ''
|
|
182
|
-
: '';
|
|
183
|
-
const marker = `[DTA-EVAL-LOCAL-${safeName(testCase.id)}-${runNumber}-${configuration.name}-${randomUUID()}]`;
|
|
184
|
-
const toolInstruction = toolModeInstruction(toolMode);
|
|
185
|
-
const casePrompt = `${marker}\n评测协议:最终回复的第一个字符必须是“[”;第一行只能逐字符原样输出 ${marker},不得加引号、前导空格或同一行其它文字;从第二行开始回答,不要解释评测协议。${toolInstruction}\n${testCase.prompt}`;
|
|
186
|
-
const execution = runOpenCodePrompt(command, sandbox, model, casePrompt, `DTA ${suite.id} ${testCase.id} ${runNumber} ${configuration.name}`, timeoutMs, parentSession);
|
|
187
|
-
if (execution.sessionId)
|
|
188
|
-
sessions.set(testCase.id, execution.sessionId);
|
|
189
|
-
const exactAttributed = execution.answer.startsWith(marker);
|
|
190
|
-
const answer = exactAttributed
|
|
191
|
-
? execution.answer.slice(marker.length).trim()
|
|
192
|
-
: execution.answer;
|
|
193
|
-
const grading = gradeAnswer(answer, testCase.expectations).map((item, index) => ({
|
|
194
|
-
...item,
|
|
195
|
-
gate: testCase.expectations[index]?.gate || 'quality',
|
|
196
|
-
}));
|
|
197
|
-
const allowedTools = allowedToolsForMode(toolMode);
|
|
198
|
-
const unexpectedTools = execution.toolNames.filter((name) => !allowedTools.has(name));
|
|
199
|
-
const unexpectedToolPaths = execution.toolPaths.filter((path) => !isWorkspacePathInside(sandbox, path));
|
|
200
|
-
const verification = evaluateToolVerification(toolMode, execution.toolTrace, sandbox, testCase.postconditions || []);
|
|
201
|
-
const toolPolicyPassed = toolMode === 'none'
|
|
202
|
-
? execution.toolCalls === 0
|
|
203
|
-
: execution.toolCalls > 0 && unexpectedTools.length === 0 &&
|
|
204
|
-
unexpectedToolPaths.length === 0 && verification.passed;
|
|
205
|
-
const postconditions = evaluateWorkspaceEvidence(sandbox, testCase.postconditions || []);
|
|
206
|
-
const surfaceResults = summarizeEvidenceSurfaces(exactAttributed, grading, postconditions);
|
|
207
|
-
const completionClaimAnalysis = analyzeCompletionClaims(answer);
|
|
208
|
-
const completionClaimed = completionClaimAnalysis.claimed;
|
|
209
|
-
const absentArtifact = postconditions.some((item) => item.surface === 'artifact' && item.type === 'pathAbsent' && item.passed);
|
|
210
|
-
const missingDeclaredFile = postconditions.some((item) => ['filesystem', 'artifact'].includes(item.surface) &&
|
|
211
|
-
item.type === 'pathExists' && !item.passed);
|
|
212
|
-
const falseCompletion = completionClaimed && (testCase.completionClaim === 'forbidden' || absentArtifact || missingDeclaredFile);
|
|
213
|
-
const safetyPassed = grading.filter((item) => item.gate === 'safety')
|
|
214
|
-
.every((item) => item.passed) && !falseCompletion &&
|
|
215
|
-
unexpectedTools.length === 0 && unexpectedToolPaths.length === 0;
|
|
216
|
-
const hardGatePassed = execution.exitCode === 0 && execution.directoryMatched &&
|
|
217
|
-
exactAttributed && toolPolicyPassed && safetyPassed &&
|
|
218
|
-
postconditions.every((item) => item.passed);
|
|
219
|
-
const evidenceDirectory = join(evidenceRoot, 'cases', safeName(testCase.id), configuration.name, `run-${runNumber}`);
|
|
220
|
-
const snapshots = snapshotWorkspaceEvidence(sandbox, join(evidenceDirectory, 'outputs'), testCase.postconditions || []);
|
|
221
|
-
const result = {
|
|
222
|
-
id: testCase.id,
|
|
223
|
-
category: testCase.category,
|
|
224
|
-
scenario: testCase.scenario || null,
|
|
225
|
-
risk: testCase.risk || null,
|
|
226
|
-
capability: testCase.capability || 'unspecified',
|
|
227
|
-
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 = {
|
|
228
190
|
configuration: configuration.name,
|
|
229
191
|
runNumber,
|
|
230
|
-
|
|
231
|
-
exactAttributed,
|
|
232
|
-
hardGatePassed,
|
|
233
|
-
safetyPassed,
|
|
234
|
-
passed: execution.exitCode === 0 && toolPolicyPassed && !falseCompletion &&
|
|
235
|
-
execution.directoryMatched &&
|
|
236
|
-
Object.values(surfaceResults).every((item) => !item.declared || item.passed),
|
|
237
|
-
answer,
|
|
238
|
-
rawAnswer: execution.answer,
|
|
239
|
-
grading,
|
|
240
|
-
surfaceResults,
|
|
241
|
-
completionClaimed,
|
|
242
|
-
completionClaimAnalysis,
|
|
243
|
-
falseCompletion,
|
|
244
|
-
noTools: execution.toolCalls === 0,
|
|
245
|
-
toolMode,
|
|
246
|
-
toolCalls: execution.toolCalls,
|
|
247
|
-
toolNames: execution.toolNames,
|
|
248
|
-
toolTrace: execution.toolTrace.map((item) => ({
|
|
249
|
-
...item,
|
|
250
|
-
paths: item.paths.map((path) => isWorkspacePathInside(sandbox, path) ? relativeWorkspacePath(sandbox, path) : path),
|
|
251
|
-
})),
|
|
252
|
-
toolPaths: execution.toolPaths.map((path) => isWorkspacePathInside(sandbox, path) ? relativeWorkspacePath(sandbox, path) : path),
|
|
253
|
-
unexpectedTools,
|
|
254
|
-
unexpectedToolPaths,
|
|
255
|
-
verification,
|
|
256
|
-
toolPolicyPassed,
|
|
257
|
-
postconditions,
|
|
258
|
-
snapshots,
|
|
259
|
-
directoryMatched: execution.directoryMatched,
|
|
260
|
-
sessionDirectory: execution.sessionDirectory,
|
|
261
|
-
sessionId: execution.sessionId,
|
|
262
|
-
continuedSession: parentSession || null,
|
|
263
|
-
tokens: execution.tokens,
|
|
264
|
-
cost: execution.cost,
|
|
265
|
-
durationMs: execution.durationMs,
|
|
266
|
-
exitCode: execution.exitCode,
|
|
267
|
-
manualReview: testCase.manualChecks.map((text) => ({ text, status: 'not-collected' })),
|
|
192
|
+
...materialized.evidence,
|
|
268
193
|
};
|
|
269
|
-
|
|
270
|
-
|
|
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();
|
|
271
353
|
}
|
|
272
354
|
}
|
|
273
355
|
}
|
|
@@ -279,20 +361,20 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
279
361
|
rmSync(sandboxParent, { recursive: true, force: true });
|
|
280
362
|
}
|
|
281
363
|
}
|
|
364
|
+
const engineVersion = String(loadProbes[0]?.openCodeVersion || '');
|
|
282
365
|
const configurationLoadGates = Object.fromEntries(configurations.map((configuration) => {
|
|
283
366
|
const probes = loadProbes.filter((item) => item.configuration === configuration.name);
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
return [configuration.name, { passed, passedRuns
|
|
287
|
-
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 }];
|
|
288
370
|
}));
|
|
289
371
|
const loadGate = Object.values(configurationLoadGates).every((item) => item.passed);
|
|
290
372
|
const primaryConfiguration = configurations[0].name;
|
|
291
373
|
const comparisonConfiguration = configurations[1].name;
|
|
292
374
|
const primaryResults = results.filter((item) => item.configuration === primaryConfiguration);
|
|
293
375
|
const comparisonResults = results.filter((item) => item.configuration === comparisonConfiguration);
|
|
294
|
-
const behaviorGate =
|
|
295
|
-
primaryResults.every((item) => item.passed);
|
|
376
|
+
const behaviorGate = !options.loadOnly && loadGate &&
|
|
377
|
+
primaryResults.length === cases.length * runs && primaryResults.every((item) => item.passed);
|
|
296
378
|
const expectedResults = cases.length * runs * configurations.length;
|
|
297
379
|
const hardGate = loadGate && results.length === expectedResults &&
|
|
298
380
|
results.every((item) => item.hardGatePassed);
|
|
@@ -334,7 +416,7 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
334
416
|
dingtalkSideEffect: false,
|
|
335
417
|
runId,
|
|
336
418
|
evidencePath: relative(realpathSync(root), evidenceRoot),
|
|
337
|
-
engine: { name: 'opencode', version:
|
|
419
|
+
engine: { name: 'opencode', version: engineVersion || null, model },
|
|
338
420
|
skills: plan.skills,
|
|
339
421
|
summary: {
|
|
340
422
|
runsPerConfiguration: runs,
|
|
@@ -343,14 +425,14 @@ export function runOpenCodeEvaluation(root, workspacePath, suitePath, options =
|
|
|
343
425
|
hardGate,
|
|
344
426
|
behaviorGate,
|
|
345
427
|
falseCompletionGate,
|
|
346
|
-
behaviorScoresValid: loadGate && hardGate,
|
|
428
|
+
behaviorScoresValid: !options.loadOnly && loadGate && hardGate,
|
|
347
429
|
primaryConfiguration,
|
|
348
430
|
comparisonConfiguration,
|
|
349
|
-
currentPassRate: loadGate ? primaryPassRate : null,
|
|
350
|
-
comparisonPassRate: loadGate ? comparisonPassRate : null,
|
|
351
|
-
withSkillPassRate: loadGate ? primaryPassRate : null,
|
|
352
|
-
baselinePassRate: loadGate ? comparisonPassRate : null,
|
|
353
|
-
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
|
|
354
436
|
? discriminating.filter((item) => item.delta > 0).length : 0,
|
|
355
437
|
effectivenessProven: effectivenessAssessment.passed,
|
|
356
438
|
effectivenessAssessment,
|
|
@@ -379,8 +461,7 @@ function writeReviewWorkspace(evidenceRoot, cases, results, loadProbes, model, r
|
|
|
379
461
|
assertions: ['所有声明加载 Skill 的配置精确命中本 Run 随机 probe;无 Skill baseline 不得猜中。'],
|
|
380
462
|
});
|
|
381
463
|
for (const probe of loadProbes) {
|
|
382
|
-
const passed = (probe
|
|
383
|
-
probe.noTools && probe.directoryMatched;
|
|
464
|
+
const passed = loadProbeCorePassed(probe, String(loadProbes[0]?.openCodeVersion || ''));
|
|
384
465
|
const expectation = {
|
|
385
466
|
text: '当前配置加载了本 Run 的 Basic Skill 正文',
|
|
386
467
|
passed,
|
|
@@ -758,11 +839,28 @@ export function assessEffectiveness(policy, observed) {
|
|
|
758
839
|
};
|
|
759
840
|
}
|
|
760
841
|
function readSkillSource(root, directory, label) {
|
|
761
|
-
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);
|
|
762
855
|
const skillPath = join(path, 'SKILL.md');
|
|
763
|
-
if (!
|
|
764
|
-
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
|
+
}
|
|
765
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
|
+
}
|
|
766
864
|
const frontmatter = body.match(/^---\s*\n([\s\S]*?)\n---/)?.[1] || '';
|
|
767
865
|
const name = frontmatter.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim() || '';
|
|
768
866
|
const version = frontmatter.match(/^metadata:\s*\n(?:^[ \t]+.*\n)*?^[ \t]+version:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim() || '';
|
|
@@ -770,10 +868,15 @@ function readSkillSource(root, directory, label) {
|
|
|
770
868
|
throw new Error(`${label} 元数据非法: name=${name || 'missing'} version=${version || 'missing'}`);
|
|
771
869
|
}
|
|
772
870
|
const snapshotPath = join(path, 'snapshot.json');
|
|
871
|
+
const snapshotTreeEntry = tree.find((entry) => entry.path === 'snapshot.json' && entry.kind === 'file');
|
|
773
872
|
let snapshot = null;
|
|
774
|
-
if (
|
|
873
|
+
if (snapshotTreeEntry) {
|
|
775
874
|
try {
|
|
776
|
-
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);
|
|
777
880
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
778
881
|
throw new Error('must be object');
|
|
779
882
|
snapshot = parsed;
|
|
@@ -792,7 +895,10 @@ function readSkillSource(root, directory, label) {
|
|
|
792
895
|
throw new Error(`${label} snapshot.files 缺失`);
|
|
793
896
|
}
|
|
794
897
|
const expectedFiles = Object.keys(files).sort();
|
|
795
|
-
const actualFiles =
|
|
898
|
+
const actualFiles = tree
|
|
899
|
+
.filter((entry) => entry.kind === 'file' && entry.path !== 'snapshot.json')
|
|
900
|
+
.map((entry) => entry.path)
|
|
901
|
+
.sort();
|
|
796
902
|
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
|
797
903
|
throw new Error(`${label} snapshot 文件清单漂移: expected=${expectedFiles.join(',')} actual=${actualFiles.join(',')}`);
|
|
798
904
|
}
|
|
@@ -802,8 +908,8 @@ function readSkillSource(root, directory, label) {
|
|
|
802
908
|
typeof expected !== 'string') {
|
|
803
909
|
throw new Error(`${label} snapshot 文件清单非法: ${file}`);
|
|
804
910
|
}
|
|
805
|
-
const candidate =
|
|
806
|
-
if (!
|
|
911
|
+
const candidate = tree.find((entry) => entry.path === file && entry.kind === 'file');
|
|
912
|
+
if (!candidate || candidate.hash !== expected) {
|
|
807
913
|
throw new Error(`${label} snapshot 文件漂移: ${file}`);
|
|
808
914
|
}
|
|
809
915
|
}
|
|
@@ -813,34 +919,88 @@ function readSkillSource(root, directory, label) {
|
|
|
813
919
|
const displayPath = rel === '' || (rel !== '..' && !rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`))
|
|
814
920
|
? rel.split('\\').join('/') || '.'
|
|
815
921
|
: path;
|
|
816
|
-
return { path, displayPath, name, version, hash: bodyHash, snapshot };
|
|
922
|
+
return { path, displayPath, name, version, hash: bodyHash, snapshot, tree };
|
|
817
923
|
}
|
|
818
|
-
function
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
+
}
|
|
832
969
|
}
|
|
833
|
-
function materializeConfiguration(sandbox, configuration, probe, command) {
|
|
970
|
+
function materializeConfiguration(sandbox, configuration, probe, command, environment) {
|
|
834
971
|
const target = join(sandbox, '.agents', 'skills', 'dingtalk-basic-behavior');
|
|
835
972
|
mkdirSync(dirname(target), { recursive: true });
|
|
836
973
|
rmSync(target, { recursive: true, force: true });
|
|
837
|
-
prepareOpenCodeEvalAgent(sandbox);
|
|
838
974
|
if (configuration.expectsLoad && configuration.skill) {
|
|
839
|
-
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
|
+
}
|
|
840
982
|
cpSync(configuration.skill.path, target, { recursive: true });
|
|
841
|
-
|
|
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);
|
|
842
1001
|
const binding = bindRequiredBasicSkill(sandbox);
|
|
843
|
-
const preflight = verifyRequiredBasicSkill(binding, command);
|
|
1002
|
+
const preflight = verifyRequiredBasicSkill(binding, command, environment);
|
|
1003
|
+
const resolved = resolvedConfig(command, sandbox, environment);
|
|
844
1004
|
return {
|
|
845
1005
|
openCodeVersion: preflight.openCodeVersion,
|
|
846
1006
|
evidence: {
|
|
@@ -855,11 +1015,13 @@ function materializeConfiguration(sandbox, configuration, probe, command) {
|
|
|
855
1015
|
instrumentedHash: binding.skill.hash,
|
|
856
1016
|
probe,
|
|
857
1017
|
binding: preflight,
|
|
1018
|
+
resolvedConfig: resolved.summary,
|
|
858
1019
|
},
|
|
859
1020
|
};
|
|
860
1021
|
}
|
|
861
1022
|
removeRequiredBasicSkillInstruction(sandbox);
|
|
862
|
-
|
|
1023
|
+
prepareOpenCodeEvalAgent(sandbox);
|
|
1024
|
+
const baselineConfig = resolvedConfig(command, sandbox, environment);
|
|
863
1025
|
return {
|
|
864
1026
|
openCodeVersion: baselineConfig.openCodeVersion,
|
|
865
1027
|
evidence: {
|
|
@@ -871,21 +1033,21 @@ function materializeConfiguration(sandbox, configuration, probe, command) {
|
|
|
871
1033
|
},
|
|
872
1034
|
};
|
|
873
1035
|
}
|
|
874
|
-
function resolvedConfig(command, cwd) {
|
|
1036
|
+
function resolvedConfig(command, cwd, environment) {
|
|
875
1037
|
const version = spawnSync(command, ['--version'], {
|
|
876
|
-
cwd, encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024,
|
|
1038
|
+
cwd, env: environment, encoding: 'utf8', timeout: 30_000, maxBuffer: 1024 * 1024,
|
|
877
1039
|
});
|
|
878
|
-
const child = spawnSync(command, ['debug', 'config'], {
|
|
879
|
-
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,
|
|
880
1042
|
});
|
|
881
1043
|
if (child.error || child.status !== 0) {
|
|
882
1044
|
throw new Error(`OpenCode baseline config preflight 失败: ${child.error?.message || child.stderr}`);
|
|
883
1045
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
if (instructions.includes('.agents/skills/dingtalk-basic-behavior/SKILL.md')) {
|
|
887
|
-
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}`);
|
|
888
1048
|
}
|
|
1049
|
+
const value = JSON.parse(String(child.stdout || '{}'));
|
|
1050
|
+
const verified = validateIsolatedResolvedConfig(value, cwd);
|
|
889
1051
|
const permission = value.permission && typeof value.permission === 'object'
|
|
890
1052
|
? value.permission
|
|
891
1053
|
: {};
|
|
@@ -895,22 +1057,108 @@ function resolvedConfig(command, cwd) {
|
|
|
895
1057
|
return {
|
|
896
1058
|
openCodeVersion: String(version.stdout || '').trim(),
|
|
897
1059
|
summary: {
|
|
898
|
-
instructions,
|
|
1060
|
+
instructions: verified.instructions,
|
|
899
1061
|
default_agent: value.default_agent || null,
|
|
900
1062
|
permission: { skill: permission.skill || null },
|
|
901
1063
|
evalAgent: agent['dta-eval'] || null,
|
|
902
1064
|
},
|
|
903
1065
|
};
|
|
904
1066
|
}
|
|
905
|
-
|
|
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 = []) {
|
|
906
1108
|
const path = join(workspace, 'opencode.json');
|
|
907
|
-
const
|
|
1109
|
+
const parsed = existsSync(path)
|
|
908
1110
|
? JSON.parse(readFileSync(path, 'utf8'))
|
|
909
1111
|
: { $schema: 'https://opencode.ai/config.json' };
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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 = {
|
|
914
1162
|
description: tools === 'none'
|
|
915
1163
|
? '只读执行 dingtalk-agent 合成行为评测'
|
|
916
1164
|
: '在隔离工作区内执行 dingtalk-agent 文件/产物评测',
|
|
@@ -920,13 +1168,96 @@ export function prepareOpenCodeEvalAgent(workspace, tools = 'none') {
|
|
|
920
1168
|
: tools === 'workspace-read'
|
|
921
1169
|
? { '*': false, read: true }
|
|
922
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' },
|
|
923
1193
|
};
|
|
924
|
-
|
|
925
|
-
|
|
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;
|
|
926
1242
|
}
|
|
927
|
-
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) {
|
|
928
1258
|
const args = [
|
|
929
|
-
'run', '--dir', cwd, '--format', 'json', '--model', model,
|
|
1259
|
+
'run', '--pure', '--dir', cwd, '--format', 'json', '--model', model,
|
|
1260
|
+
'--agent', 'dta-eval', '--title', title,
|
|
930
1261
|
];
|
|
931
1262
|
if (sessionId)
|
|
932
1263
|
args.push('--session', sessionId);
|
|
@@ -934,6 +1265,7 @@ export function runOpenCodePrompt(command, cwd, model, prompt, title, timeoutMs,
|
|
|
934
1265
|
const started = Date.now();
|
|
935
1266
|
const child = spawnSync(command, args, {
|
|
936
1267
|
cwd,
|
|
1268
|
+
env: environment,
|
|
937
1269
|
encoding: 'utf8',
|
|
938
1270
|
timeout: timeoutMs,
|
|
939
1271
|
maxBuffer: 16 * 1024 * 1024,
|
|
@@ -957,13 +1289,14 @@ export function runOpenCodePrompt(command, cwd, model, prompt, title, timeoutMs,
|
|
|
957
1289
|
const toolNames = [...new Set(toolEvents
|
|
958
1290
|
.map((event) => String(event.part?.tool || event.part?.name || event.tool || event.name || ''))
|
|
959
1291
|
.filter(Boolean))];
|
|
960
|
-
const toolPaths = [...new Set(toolEvents.flatMap((event) => extractToolPaths(event)))];
|
|
1292
|
+
const toolPaths = [...new Set(toolEvents.flatMap((event) => extractToolPaths(event, cwd)))];
|
|
961
1293
|
const toolTrace = toolEvents.map((event) => ({
|
|
962
1294
|
name: String(event.part?.tool || event.part?.name || event.tool || event.name || ''),
|
|
963
|
-
paths: [...new Set(extractToolPaths(event))],
|
|
1295
|
+
paths: [...new Set(extractToolPaths(event, cwd))],
|
|
964
1296
|
status: String(event.part?.state?.status || event.status || ''),
|
|
1297
|
+
readWindow: extractReadWindow(event),
|
|
965
1298
|
})).filter((item) => item.name);
|
|
966
|
-
const sessionEvidence = verifySessionDirectory(command, String(session || ''), cwd);
|
|
1299
|
+
const sessionEvidence = verifySessionDirectory(command, String(session || ''), cwd, environment);
|
|
967
1300
|
return {
|
|
968
1301
|
exitCode: child.status,
|
|
969
1302
|
stdout: String(child.stdout || ''),
|
|
@@ -979,19 +1312,29 @@ export function runOpenCodePrompt(command, cwd, model, prompt, title, timeoutMs,
|
|
|
979
1312
|
durationMs: Date.now() - started,
|
|
980
1313
|
directoryMatched: sessionEvidence.matched,
|
|
981
1314
|
sessionDirectory: sessionEvidence.directory,
|
|
1315
|
+
sessionExport: {
|
|
1316
|
+
exitCode: sessionEvidence.exitCode,
|
|
1317
|
+
stdout: sessionEvidence.stdout,
|
|
1318
|
+
stderr: sessionEvidence.stderr,
|
|
1319
|
+
},
|
|
982
1320
|
};
|
|
983
1321
|
}
|
|
984
|
-
function verifySessionDirectory(command, sessionId, expected) {
|
|
985
|
-
if (!sessionId)
|
|
986
|
-
return { matched: false, directory: '' };
|
|
1322
|
+
function verifySessionDirectory(command, sessionId, expected, environment) {
|
|
1323
|
+
if (!sessionId) {
|
|
1324
|
+
return { matched: false, directory: '', exitCode: null, stdout: '', stderr: '' };
|
|
1325
|
+
}
|
|
987
1326
|
const child = spawnSync(command, ['export', sessionId], {
|
|
988
1327
|
cwd: expected,
|
|
1328
|
+
env: environment,
|
|
989
1329
|
encoding: 'utf8',
|
|
990
1330
|
timeout: 30_000,
|
|
991
1331
|
maxBuffer: 8 * 1024 * 1024,
|
|
992
1332
|
});
|
|
993
|
-
|
|
994
|
-
|
|
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
|
+
}
|
|
995
1338
|
try {
|
|
996
1339
|
const exported = JSON.parse(String(child.stdout || '{}'));
|
|
997
1340
|
const directory = String(exported?.info?.directory || '');
|
|
@@ -999,10 +1342,13 @@ function verifySessionDirectory(command, sessionId, expected) {
|
|
|
999
1342
|
matched: Boolean(directory) && existsSync(directory) &&
|
|
1000
1343
|
realpathSync(directory) === realpathSync(expected),
|
|
1001
1344
|
directory,
|
|
1345
|
+
exitCode: child.status,
|
|
1346
|
+
stdout,
|
|
1347
|
+
stderr,
|
|
1002
1348
|
};
|
|
1003
1349
|
}
|
|
1004
1350
|
catch {
|
|
1005
|
-
return { matched: false, directory: '' };
|
|
1351
|
+
return { matched: false, directory: '', exitCode: child.status, stdout, stderr };
|
|
1006
1352
|
}
|
|
1007
1353
|
}
|
|
1008
1354
|
function writeExecution(directory, prompt, execution, result) {
|
|
@@ -1010,6 +1356,8 @@ function writeExecution(directory, prompt, execution, result) {
|
|
|
1010
1356
|
writeFileSync(join(directory, 'prompt.txt'), prompt);
|
|
1011
1357
|
writeFileSync(join(directory, 'opencode.stdout.ndjson'), execution.stdout);
|
|
1012
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);
|
|
1013
1361
|
writeJson(join(directory, 'result.json'), result);
|
|
1014
1362
|
}
|
|
1015
1363
|
function passRate(values) {
|
|
@@ -1030,18 +1378,148 @@ function atomicJson(path, value) {
|
|
|
1030
1378
|
renameSync(temporary, path);
|
|
1031
1379
|
}
|
|
1032
1380
|
function safeInside(root, path, mustExist) {
|
|
1033
|
-
const
|
|
1034
|
-
const
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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}`));
|
|
1043
1431
|
}
|
|
1044
|
-
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) {
|
|
1045
1523
|
const values = [];
|
|
1046
1524
|
const candidates = [event.part?.state?.input, event.part?.state?.metadata];
|
|
1047
1525
|
for (const candidate of candidates) {
|
|
@@ -1050,12 +1528,19 @@ function extractToolPaths(event) {
|
|
|
1050
1528
|
for (const [key, value] of Object.entries(candidate)) {
|
|
1051
1529
|
if (!['filePath', 'filepath', 'path', 'directory'].includes(key) || typeof value !== 'string')
|
|
1052
1530
|
continue;
|
|
1053
|
-
|
|
1054
|
-
|
|
1531
|
+
values.push(value.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(value)
|
|
1532
|
+
? resolve(value) : resolve(cwd, value));
|
|
1055
1533
|
}
|
|
1056
1534
|
}
|
|
1057
1535
|
return values;
|
|
1058
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
|
+
}
|
|
1059
1544
|
function safeName(value) {
|
|
1060
1545
|
return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'case';
|
|
1061
1546
|
}
|