@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/README.en.md +98 -326
  3. package/README.md +95 -673
  4. package/dist/bin/dingtalk-agent.js +200 -18
  5. package/dist/bin/dingtalk-agent.js.map +1 -1
  6. package/dist/src/agent-audit.js +1014 -89
  7. package/dist/src/agent-audit.js.map +1 -1
  8. package/dist/src/agent-enhance.js +41 -8
  9. package/dist/src/agent-enhance.js.map +1 -1
  10. package/dist/src/agent-platform.js +299 -0
  11. package/dist/src/agent-platform.js.map +1 -0
  12. package/dist/src/config.js +1 -7
  13. package/dist/src/config.js.map +1 -1
  14. package/dist/src/development-workspace.js +31 -5
  15. package/dist/src/development-workspace.js.map +1 -1
  16. package/dist/src/doctor.js +74 -15
  17. package/dist/src/doctor.js.map +1 -1
  18. package/dist/src/host-detect.js +146 -0
  19. package/dist/src/host-detect.js.map +1 -0
  20. package/dist/src/instruction-path.js +270 -0
  21. package/dist/src/instruction-path.js.map +1 -0
  22. package/dist/src/map.js +157 -0
  23. package/dist/src/map.js.map +1 -0
  24. package/dist/src/multica-deploy.js +78 -20
  25. package/dist/src/multica-deploy.js.map +1 -1
  26. package/dist/src/multica-provider.js +1 -1
  27. package/dist/src/multica-provider.js.map +1 -1
  28. package/dist/src/opencode-evals.js +710 -225
  29. package/dist/src/opencode-evals.js.map +1 -1
  30. package/dist/src/opencode-isolation.js +124 -0
  31. package/dist/src/opencode-isolation.js.map +1 -0
  32. package/dist/src/opencode-provider.js +1 -0
  33. package/dist/src/opencode-provider.js.map +1 -1
  34. package/dist/src/opencode-workspace.js +21 -10
  35. package/dist/src/opencode-workspace.js.map +1 -1
  36. package/dist/src/remote-state-evals.js +2 -1
  37. package/dist/src/remote-state-evals.js.map +1 -1
  38. package/dist/src/robot-evals.js +2 -1
  39. package/dist/src/robot-evals.js.map +1 -1
  40. package/dist/src/setup.js +6 -5
  41. package/dist/src/setup.js.map +1 -1
  42. package/dist/src/skill-manager.js +141 -12
  43. package/dist/src/skill-manager.js.map +1 -1
  44. package/dist/src/skills.js +2 -1
  45. package/dist/src/skills.js.map +1 -1
  46. package/dist/src/types.js.map +1 -1
  47. package/dist/src/upgrade.js +23 -27
  48. package/dist/src/upgrade.js.map +1 -1
  49. package/dist/src/version.js +73 -0
  50. package/dist/src/version.js.map +1 -0
  51. package/docs/INSTALLATION.md +3 -3
  52. package/docs/assets/agent-delivery-lifecycle.svg +103 -0
  53. package/docs/schemas/agent-platform.schema.json +13 -0
  54. package/docs/schemas/project.schema.json +3 -0
  55. package/docs/schemas/release-readiness.schema.json +2 -1
  56. package/evals/README.md +17 -0
  57. package/lab/README.md +3 -3
  58. package/lab/agent-eval/catalog.json +5 -5
  59. package/lab/agent-eval/classic-failures.json +9 -9
  60. package/lab/agent-eval/completion-gate-regression.json +6 -6
  61. package/lab/agent-eval/remote-state-workspace/opencode.json +1 -1
  62. package/lab/agent-eval/workspace/AGENTS.md +1 -1
  63. package/lab/project-workspace/fake-multica-provider.mjs +17 -6
  64. package/lab/project-workspace/opencode-provider-suite.json +1 -1
  65. package/lab/robot-eval/suite.json +1 -1
  66. package/lab/robot-eval/workspace/AGENTS.md +1 -1
  67. package/lab/schemas/agent-eval-catalog.schema.json +1 -1
  68. package/package.json +13 -12
  69. package/skills/README.md +23 -0
  70. package/skills/core/dingtalk-agent-compose/SKILL.md +151 -0
  71. package/skills/core/dingtalk-agent-compose/assets/AGENTS.template.md +35 -0
  72. package/skills/core/dingtalk-agent-compose/assets/role-skill.template.md +32 -0
  73. package/skills/core/dingtalk-agent-compose/evals/evals.json +129 -0
  74. package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/references/agent-definition-contract.md +3 -3
  75. package/skills/core/dingtalk-agent-compose/references/host-loading-contract.md +58 -0
  76. package/skills/core/dingtalk-agent-compose/references/hosts/claude-code.md +48 -0
  77. package/skills/core/dingtalk-agent-compose/references/hosts/opencode.md +77 -0
  78. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/SKILL.md +31 -7
  79. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/assets/eval-catalog.template.json +1 -1
  80. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/evals/evals.json +22 -0
  81. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/eval-topology.md +14 -0
  82. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/evidence-contract.md +21 -0
  83. package/skills/core/dingtalk-agent-eval/references/failure-to-case.md +35 -0
  84. package/skills/core/dingtalk-agent-eval/references/interactive-debug-channels.md +93 -0
  85. package/skills/core/dingtalk-agent-eval/references/local-connector-smoke.md +75 -0
  86. package/skills/core/dingtalk-basic-behavior/SKILL.md +87 -0
  87. package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/memory-and-evolution.md +12 -0
  88. package/skills/core/dingtalk-basic-behavior/references/risk-authority-and-privacy.md +62 -0
  89. package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/task-lifecycle.md +15 -3
  90. package/skills/core/dingtalk-basic-behavior/references/truth-and-recovery.md +65 -0
  91. package/skills/platforms/deap/PLATFORM.md +3 -0
  92. package/skills/platforms/deap/README.md +3 -0
  93. package/skills/platforms/multica-dingtalk/PLATFORM.md +40 -0
  94. package/skills/{dingtalk-agent-boot-multica → platforms/multica-dingtalk/dingtalk-agent-boot-multica}/SKILL.md +4 -4
  95. package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/SKILL.md +1 -1
  96. package/skills/platforms/multica-dingtalk/multica-external/SKILL.md +282 -0
  97. package/skills/platforms/multica-dingtalk/multica-external/scripts/bootstrap.sh +78 -0
  98. package/skills/platforms/multica-dingtalk/multica-external/scripts/multica_ext.py +1180 -0
  99. package/skills/dingtalk-agent-compose/SKILL.md +0 -110
  100. package/skills/dingtalk-agent-compose/assets/AGENTS.template.md +0 -26
  101. package/skills/dingtalk-agent-compose/assets/role-skill.template.md +0 -24
  102. package/skills/dingtalk-agent-compose/evals/evals.json +0 -94
  103. package/skills/dingtalk-agent-compose/references/opencode-host-contract.md +0 -65
  104. package/skills/dingtalk-basic-behavior/SKILL.md +0 -146
  105. /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/assets/agent.bindings.dingtalk-doc.template.json +0 -0
  106. /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/assets/agent.bindings.local.template.json +0 -0
  107. /package/skills/{dingtalk-agent-compose/assets → core/dingtalk-agent-compose/assets/hosts/opencode}/opencode.template.json +0 -0
  108. /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/references/storage-routing.md +0 -0
  109. /package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/scenario-taxonomy.md +0 -0
  110. /package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/storage-modes.md +0 -0
  111. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/assets/memory-candidate-proposal.json +0 -0
  112. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/assets/task-checkpoint.json +0 -0
  113. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/action-contract.md +0 -0
  114. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/event-to-behavior.md +0 -0
  115. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/perception-and-gates.md +0 -0
  116. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/runtime-modes.md +0 -0
  117. /package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/references/multica-deployment-contract.md +0 -0
  118. /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, join(packageRoot, 'skills', 'dingtalk-basic-behavior'), 'current Skill');
33
- const previousSkill = options.previousSkill
34
- ? readSkillSource(root, safeInside(root, options.previousSkill, true), 'previous Skill')
35
- : null;
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
- const materialized = materializeConfiguration(sandbox, configuration, probe, command);
137
- const configEvidence = {
138
- configuration: configuration.name,
139
- runNumber,
140
- ...materialized.evidence,
141
- };
142
- writeJson(join(evidenceRoot, 'configurations', configuration.name, `run-${runNumber}.json`), configEvidence);
143
- const probePrompt = '这是加载链路探针,不是业务问题。只依据已强制注入的 Basic Skill 正文末尾注释回答;' +
144
- '若能看到 `dta-load-probe`,严格输出 `dta-load-probe=<值>`;否则严格输出 `dta-load-probe=not-loaded`。' +
145
- '禁止猜测、禁止调用工具、不要补充解释。';
146
- const probeExecution = runOpenCodePrompt(command, sandbox, model, probePrompt, `DTA load probe ${runNumber} ${configuration.name}`, timeoutMs);
147
- const exactProbe = `dta-load-probe=${probe}`;
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
- marker,
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
- results.push(result);
270
- writeExecution(evidenceDirectory, casePrompt, execution, result);
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 passed = probes.length === runs && probes.every((item) => (configuration.expectsLoad ? item.exactLoaded : item.didNotGuess) &&
285
- item.noTools && item.directoryMatched);
286
- return [configuration.name, { passed, passedRuns: probes.filter((item) => (configuration.expectsLoad ? item.exactLoaded : item.didNotGuess) &&
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 = loadGate && primaryResults.length === cases.length * runs &&
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: loadProbes[0]?.openCodeVersion || null, model },
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.expectsLoad ? probe.exactLoaded : probe.didNotGuess) &&
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 path = realpathSync(resolve(directory));
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 (!existsSync(skillPath))
764
- throw new Error(`${label} 缺少 SKILL.md: ${directory}`);
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 (existsSync(snapshotPath)) {
873
+ if (snapshotTreeEntry) {
775
874
  try {
776
- const parsed = JSON.parse(readFileSync(snapshotPath, 'utf8'));
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 = listSnapshotFiles(path);
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 = join(path, file);
806
- if (!existsSync(candidate) || sha256(readFileSync(candidate)) !== expected) {
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 listSnapshotFiles(directory, prefix = '') {
819
- const files = [];
820
- for (const entry of readdirSync(join(directory, prefix), { withFileTypes: true })) {
821
- const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
822
- if (relativePath === 'snapshot.json')
823
- continue;
824
- if (entry.isDirectory())
825
- files.push(...listSnapshotFiles(directory, relativePath));
826
- else if (entry.isFile())
827
- files.push(relativePath);
828
- else
829
- throw new Error(`previous Skill snapshot 不允许特殊文件: ${relativePath}`);
830
- }
831
- return files.sort();
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 sourceBody = readFileSync(join(configuration.skill.path, 'SKILL.md'), 'utf8');
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
- writeFileSync(join(target, 'SKILL.md'), `${sourceBody.trimEnd()}\n\n<!-- dta-load-probe:${probe} -->\n`);
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
- const baselineConfig = resolvedConfig(command, sandbox);
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
- const value = JSON.parse(String(child.stdout || '{}'));
885
- const instructions = Array.isArray(value.instructions) ? value.instructions : [];
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
- export function prepareOpenCodeEvalAgent(workspace, tools = 'none') {
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 config = existsSync(path)
1109
+ const parsed = existsSync(path)
908
1110
  ? JSON.parse(readFileSync(path, 'utf8'))
909
1111
  : { $schema: 'https://opencode.ai/config.json' };
910
- const agents = config.agent && typeof config.agent === 'object' && !Array.isArray(config.agent)
911
- ? config.agent
912
- : {};
913
- agents['dta-eval'] = {
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
- config.agent = agents;
925
- atomicJson(path, config);
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 runOpenCodePrompt(command, cwd, model, prompt, title, timeoutMs, sessionId = '') {
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, '--agent', 'dta-eval', '--title', title,
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
- if (child.error || child.status !== 0)
994
- return { matched: false, directory: '' };
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 rootReal = realpathSync(resolve(root));
1034
- const resolved = resolve(rootReal, path);
1035
- const candidate = mustExist && existsSync(resolved) ? realpathSync(resolved) : resolved;
1036
- const rel = relative(rootReal, candidate);
1037
- if (rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
1038
- throw new Error(`OpenCode Eval 路径必须位于当前目录内: ${path}`);
1039
- }
1040
- if (mustExist && !existsSync(candidate))
1041
- throw new Error(`OpenCode Eval 路径不存在: ${path}`);
1042
- return candidate;
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 extractToolPaths(event) {
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
- if (value.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(value))
1054
- values.push(resolve(value));
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
  }