@tea-agent/loop-agent 0.16.25 → 0.16.26

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 CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  ### 改进
4
4
 
5
+ - Observe 节点检查器“规范证据”页签新增安全文件预览:已绑定任务源和已读取规范文件条目现以可聚焦按钮呈现,点击后在检查器内下钻查看内容(不新增弹窗),提供“返回规范证据”按钮并恢复触发行焦点。新增只读接口 `GET /api/dag-runs/:runId/nodes/:nodeId/spec-evidence/file?source=binding|read&path=...`,服务端重新校验证据成员身份(binding 必须在 `sourceBinding.sources`,read 必须有成功配对 read),拒 `..`/绝对/目录/二进制/仓库外符号链接;正文上限 64KB 并返回截断标志,统一走 Observe 脱敏规则;binding 返回 `sha256`/`currentSha256`/`hashMatch`,read 返回 session event 中实际读取正文与 `readAt`(不以当前工作区文件替代)。
6
+
7
+ - 通用 standard/reviewed/supervised 实现 DAG 现在会在目标仓库存在 `AGENTS.md` 时,按本次 writer 的实际变更解析适用的根级/嵌套指令和仓库内代码规范索引:standard 复用 `verify-pi` 与条件 shell gate,reviewed/supervised 复用现有 `review-pi`/review gate,不新增模型节点;无 `AGENTS.md` 或无适用规范时保持原行为,审查节点只读且不会直接改代码。专用测试、前端和知识类 DAG 不受影响。
5
8
  - GitHub Actions CI 降低私有仓分钟消耗:合并为单 job(避免两次 `npm ci`)、同分支/`PR` concurrency 取消旧 run、Draft PR 跳过完整 CI,且仅忽略 `docs/reports/**`、`docs/progress/**`、`docs/design/archive/**`、`docs/exec-plans/completed/**` 等纯运营文档路径;治理文档与代码门禁仍全量跑。
6
9
  - 明确 **Compatibility / Operator Assist**:openCode 等主会话只编排已发布 `loop-agent` / `agent-worker` CLI 与只读诊断,不得绕过 CLI 直接改业务实现,失败只走 doctor / reconcile / human gate / CLI 重跑。`loop-agent init` 写入的 `AGENTS.md` managed block、包内 skills 与 website 快速开始/治理说明已对齐;架构决策见 `docs/decisions/0005-governed-operator-surface.md`(accepted)。已有目标项目用 `loop-agent init check-update` / `init update --apply-safe` 或 `init reconcile` 刷新 managed block。
7
10
  - 前端测试 DAG 收紧体验:execution preflight 仅硬校验绝对非生产 `baseUrl`;用例 map 缩短为优先用 `playwright-cli` 执行;复盘合并执行证据审查且不依赖 outcome=pass,失败也能出报告。
@@ -39,6 +42,12 @@
39
42
  - Pi SDK 执行长推理或大段结构化输出时不再把高频流式增量事件无界累积到内存;同一响应在多个生命周期事件中重复出现的 Token 用量只统计一次,避免 `Invalid string length` 和成本数据虚高。
40
43
  - 后端测试复合执行节点继续保持 clean environment、失败分类和 fail-closed outcome,并为唯一 JUnit/initial Result、canonical Result、traceability 与 Observe 投影保留结构化运行证据。
41
44
 
45
+ ## [0.16.26] - 2026-07-22
46
+
47
+ ### 修复
48
+
49
+ - Git checkpoint 对 `allowed_paths` 内的 **gitignored 证据文件**(如 dogfood `reports/welcome/final/*.json`)执行 `git add -f` 纳入 checkpoint;敏感路径与范围外 ignored 文件仍 fail-closed。修复 FINAL-VERIFY DAG/report 已绿却因 ignored evidence 记 `record-error`、Task Pool 卡 Running 的 Delivery 阻断。
50
+
42
51
  ## [0.16.25] - 2026-07-22
43
52
 
44
53
  ### 修复
@@ -1,8 +1,9 @@
1
1
  import path from "node:path";
2
- import { writeTextArtifactFile } from "../infrastructure/harness/artifact-store.js";
2
+ import { createHash } from "node:crypto";
3
+ import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
3
4
  import { executePiStep, } from "./pi-executor.js";
4
5
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
5
- import { readGitStatusPorcelain, runPostRunWriteGuard, } from "./shell-write-guard.js";
6
+ import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
6
7
  export const DAG_PI_READONLY_TOOLS = ["read", "grep", "find", "ls"];
7
8
  export const DAG_PI_WRITE_TOOLS = [
8
9
  "read",
@@ -240,12 +241,15 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
240
241
  };
241
242
  }
242
243
  let beforeStatus;
244
+ let beforePathFingerprints;
243
245
  if (isWriteTask) {
244
246
  try {
245
247
  beforeStatus = await readGitStatusPorcelain(input.cwd);
248
+ beforePathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, snapshotGitStatusPorcelain(beforeStatus));
246
249
  }
247
250
  catch {
248
251
  beforeStatus = undefined;
252
+ beforePathFingerprints = undefined;
249
253
  }
250
254
  }
251
255
  const result = await piStepFn({
@@ -278,15 +282,19 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
278
282
  }
279
283
  let writeGuardOk = true;
280
284
  let writeGuardViolations = [];
285
+ let changeManifestAfterStatus;
286
+ let changeManifestChangedFiles;
281
287
  if (beforeStatus !== undefined) {
282
288
  try {
283
- const guard = await runPostRunWriteGuard({
284
- rootCwd: input.cwd,
285
- beforeStatus,
286
- writePolicy: input.task.writePolicy,
287
- writeSet: input.task.writeSet,
288
- allowedPaths: input.task.allowedPaths,
289
- forbiddenPaths: input.task.forbiddenPaths,
289
+ const afterStatus = await readGitStatusPorcelain(input.cwd);
290
+ const afterSnapshot = snapshotGitStatusPorcelain(afterStatus);
291
+ const afterPathFingerprints = await snapshotGitStatusPathFingerprints(input.cwd, afterSnapshot);
292
+ const changedFiles = pathsChangedDuringRun(snapshotGitStatusPorcelain(beforeStatus), afterSnapshot, beforePathFingerprints, afterPathFingerprints);
293
+ changeManifestAfterStatus = afterStatus;
294
+ changeManifestChangedFiles = changedFiles;
295
+ const guard = validateShellWriteGuardFromDiff({
296
+ changedFiles,
297
+ task: input.task,
290
298
  concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
291
299
  });
292
300
  writeGuardOk = guard.ok;
@@ -300,6 +308,16 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep) {
300
308
  }
301
309
  }
302
310
  if (writeGuardOk) {
311
+ if (beforeStatus !== undefined && changeManifestChangedFiles !== undefined) {
312
+ await persistWriterChangeManifest({
313
+ runDir: meta.runDir,
314
+ nodeId: input.task.id,
315
+ writerNodeId: input.task.id,
316
+ changedFiles: changeManifestChangedFiles,
317
+ beforeStatus,
318
+ afterStatus: changeManifestAfterStatus ?? "",
319
+ });
320
+ }
303
321
  return mapped;
304
322
  }
305
323
  const stderrParts = [mapped.stderr];
@@ -369,3 +387,39 @@ function normalizeProtocolLine(line) {
369
387
  const emphasized = trimmed.match(/^(\*{1,3})\s*(.*?)\s*\1$/);
370
388
  return (emphasized?.[2] ?? trimmed).trim();
371
389
  }
390
+ /**
391
+ * Validate the writer's observed diff against its declared write boundary.
392
+ * Inlined mirror of runPostRunWriteGuard that reuses the already-computed diff
393
+ * so the change manifest and the guard share one source of truth.
394
+ */
395
+ function validateShellWriteGuardFromDiff(input) {
396
+ return validateShellWriteGuard({
397
+ changedPaths: input.changedFiles,
398
+ writePolicy: input.task.writePolicy,
399
+ writeSet: input.task.writeSet,
400
+ allowedPaths: input.task.allowedPaths,
401
+ forbiddenPaths: input.task.forbiddenPaths,
402
+ concurrentSiblingWriteSets: input.concurrentSiblingWriteSets,
403
+ allowSiblingWriteSetAttribution: input.task.writePolicy === "exclusive" &&
404
+ (input.concurrentSiblingWriteSets?.length ?? 0) > 0,
405
+ });
406
+ }
407
+ /**
408
+ * Persist the writer's actual changeset as a run-owned artifact so the
409
+ * project governance context resolver can attribute exactly this run's
410
+ * writes (not final `git status`, which may include pre-existing changes).
411
+ */
412
+ async function persistWriterChangeManifest(input) {
413
+ const manifest = {
414
+ schemaVersion: 1,
415
+ writerNodeId: input.writerNodeId,
416
+ changedFiles: input.changedFiles,
417
+ beforeStatusSha256: createHash("sha256")
418
+ .update(input.beforeStatus)
419
+ .digest("hex"),
420
+ afterStatusSha256: createHash("sha256")
421
+ .update(input.afterStatus)
422
+ .digest("hex"),
423
+ };
424
+ await writeDagNodeJsonArtifact(input.runDir, input.nodeId, "change-manifest.json", manifest);
425
+ }
@@ -19,6 +19,8 @@ import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBack
19
19
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
20
20
  import { pathsChangedDuringRun, readGitStatusPorcelain, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
21
21
  import { buildShellProcessEnv } from "./shell-verification.js";
22
+ import { readRunState } from "../workflows/dag/run-store.js";
23
+ import { readProjectGovernanceContext } from "../workflows/dag/project-governance-context.js";
22
24
  const DEFAULT_SHELL_TIMEOUT_MS = 300_000;
23
25
  const SUMMARY_STDOUT_MAX = 4_000;
24
26
  const SUMMARY_STDERR_MAX = 2_000;
@@ -733,6 +735,34 @@ export async function executeDagShellNode(input, meta) {
733
735
  };
734
736
  }
735
737
  }
738
+ if (shell?.projectGovernanceGate) {
739
+ const started = Date.now();
740
+ try {
741
+ const state = await readRunState(meta.runDir);
742
+ if (!state.projectGovernanceContextRef) {
743
+ throw new Error("project governance gate requires projectGovernanceContextRef in run state");
744
+ }
745
+ const context = await readProjectGovernanceContext(meta.runDir, state.projectGovernanceContextRef, { expectedRunId: meta.runId });
746
+ if (!context.applicable) {
747
+ return {
748
+ ok: true,
749
+ stdout: "Project governance gate: not applicable for this writer changeset.",
750
+ stderr: "",
751
+ failureCategory: "success",
752
+ durationMs: Date.now() - started,
753
+ };
754
+ }
755
+ }
756
+ catch (error) {
757
+ return {
758
+ ok: false,
759
+ stdout: "",
760
+ stderr: error instanceof Error ? error.message : String(error),
761
+ failureCategory: "invalid-output",
762
+ durationMs: Date.now() - started,
763
+ };
764
+ }
765
+ }
736
766
  const commands = shell ? resolveShellCommands(shell) : [];
737
767
  if (!shell || commands.length === 0) {
738
768
  throw new Error(`shell task ${input.task.id} requires shell.preset, shell.verdictGate, and/or non-empty shell.commands`);
@@ -1,5 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { createReadStream } from "node:fs";
4
+ import { lstat, readlink } from "node:fs/promises";
5
+ import path from "node:path";
2
6
  import { pathMatchesPattern } from "../shared/git-progress.js";
7
+ async function sha256File(filePath) {
8
+ const hash = createHash("sha256");
9
+ for await (const chunk of createReadStream(filePath)) {
10
+ hash.update(chunk);
11
+ }
12
+ return hash.digest("hex");
13
+ }
3
14
  export function snapshotGitStatusPorcelain(porcelain) {
4
15
  const snapshot = new Map();
5
16
  for (const line of porcelain.split("\n")) {
@@ -14,11 +25,15 @@ export function snapshotGitStatusPorcelain(porcelain) {
14
25
  }
15
26
  return snapshot;
16
27
  }
17
- export function pathsChangedDuringRun(before, after) {
28
+ export function pathsChangedDuringRun(before, after, beforeFingerprints, afterFingerprints) {
18
29
  const changed = new Set();
19
30
  for (const [filePath, afterCode] of after) {
20
31
  const beforeCode = before.get(filePath);
21
- if (beforeCode === undefined || beforeCode !== afterCode) {
32
+ if (beforeCode === undefined ||
33
+ beforeCode !== afterCode ||
34
+ (beforeFingerprints !== undefined &&
35
+ afterFingerprints !== undefined &&
36
+ beforeFingerprints.get(filePath) !== afterFingerprints.get(filePath))) {
22
37
  changed.add(filePath);
23
38
  }
24
39
  }
@@ -29,6 +44,53 @@ export function pathsChangedDuringRun(before, after) {
29
44
  }
30
45
  return Array.from(changed).sort();
31
46
  }
47
+ /**
48
+ * Capture content-aware fingerprints for every path already present in a
49
+ * porcelain snapshot. Status codes alone cannot detect a writer editing a file
50
+ * that was `M` or `??` before the node and remains so afterwards.
51
+ *
52
+ * Symlinks are hashed by link target without following them, keeping the
53
+ * snapshot repository-contained. Missing paths and non-files receive stable
54
+ * sentinels so deletions and type changes remain observable.
55
+ */
56
+ export async function snapshotGitStatusPathFingerprints(cwd, status) {
57
+ const root = path.resolve(cwd);
58
+ const entries = await Promise.all([...status.keys()].map(async (filePath) => {
59
+ const candidate = path.resolve(root, filePath);
60
+ const relative = path.relative(root, candidate);
61
+ if (relative.startsWith("..") ||
62
+ path.isAbsolute(relative)) {
63
+ return [filePath, "outside-repository"];
64
+ }
65
+ try {
66
+ const info = await lstat(candidate);
67
+ if (info.isSymbolicLink()) {
68
+ const target = await readlink(candidate);
69
+ return [
70
+ filePath,
71
+ `symlink:${createHash("sha256").update(target).digest("hex")}`,
72
+ ];
73
+ }
74
+ if (info.isFile()) {
75
+ return [
76
+ filePath,
77
+ `file:${await sha256File(candidate)}`,
78
+ ];
79
+ }
80
+ return [
81
+ filePath,
82
+ `other:${info.mode}:${info.size}:${info.mtimeMs}`,
83
+ ];
84
+ }
85
+ catch (error) {
86
+ const code = error && typeof error === "object" && "code" in error
87
+ ? String(error.code)
88
+ : "unknown";
89
+ return [filePath, `unreadable:${code}`];
90
+ }
91
+ }));
92
+ return new Map(entries);
93
+ }
32
94
  const DAG_RUNS_PREFIX = ".harness/dag-runs/";
33
95
  /**
34
96
  * Ephemeral interpreter/tool caches that writers may create while drafting tests.
@@ -122,22 +122,51 @@ export async function finalizeGitTask(transaction, outcome) {
122
122
  const changes = await readChanges(transaction.repoRoot);
123
123
  const newIgnored = currentIgnored.map((entry) => entry.path).filter((entry) => !ignoredBaselineByPath.has(entry));
124
124
  if (outcome.status === "succeeded") {
125
- if (changes.length === 0)
125
+ const allowedPaths = expandAllowedPathsForWorkflow(resolveWorkflow(outcome.taskSpec).workflow, outcome.taskSpec.constraints.allowed_paths);
126
+ // Evidence under allowed_paths may be gitignored for local convenience (e.g.
127
+ // reports/welcome/** smoke JSON) but must still checkpoint for Task Pool Done.
128
+ // Sensitive / out-of-scope ignored files remain fail-closed; ephemeral tool
129
+ // caches neither block nor force-add.
130
+ const forceAddIgnored = [];
131
+ const blockedIgnored = [];
132
+ for (const entry of newIgnored) {
133
+ if (isEphemeralToolCachePath(entry))
134
+ continue;
135
+ if (isSensitivePath(entry)) {
136
+ blockedIgnored.push(entry);
137
+ continue;
138
+ }
139
+ if (allowedPaths.some((glob) => matchesGlob(entry, glob))) {
140
+ forceAddIgnored.push(entry);
141
+ }
142
+ else {
143
+ blockedIgnored.push(entry);
144
+ }
145
+ }
146
+ if (blockedIgnored.length > 0) {
147
+ throw new Error(`task created ignored files outside the Git checkpoint: ${blockedIgnored.join(", ")}`);
148
+ }
149
+ const checkpointFiles = [...changes, ...forceAddIgnored];
150
+ if (checkpointFiles.length === 0) {
126
151
  throw new Error(`successful task produced no checkpointable changes: ${outcome.taskSpec.id}`);
127
- if (newIgnored.length > 0)
128
- throw new Error(`task created ignored files outside the Git checkpoint: ${newIgnored.join(", ")}`);
129
- auditChangedPaths(changes, outcome.taskSpec);
130
- await git(transaction.repoRoot, ["add", "--", ...changes]);
152
+ }
153
+ auditChangedPaths(checkpointFiles, outcome.taskSpec);
154
+ if (changes.length > 0) {
155
+ await git(transaction.repoRoot, ["add", "--", ...changes]);
156
+ }
157
+ if (forceAddIgnored.length > 0) {
158
+ await git(transaction.repoRoot, ["add", "-f", "--", ...forceAddIgnored]);
159
+ }
131
160
  const message = commitMessage(outcome.taskSpec, outcome.workerRunId);
132
161
  await git(transaction.repoRoot, ["commit", "-m", message]);
133
162
  const commit = await git(transaction.repoRoot, ["rev-parse", "HEAD"]);
134
- const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: changes, createdAt: (outcome.now ?? new Date()).toISOString() };
163
+ const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: checkpointFiles, createdAt: (outcome.now ?? new Date()).toISOString() };
135
164
  const previousCheckpoint = current.lastCheckpoint;
136
165
  try {
137
166
  await assertTransactionPosition(transaction.repoRoot, { ...current, lastCheckpoint: commit });
138
167
  await assertCheckpointMetadata(transaction.repoRoot, commit, outcome.taskSpec, outcome.workerRunId);
139
168
  const afterCommitIgnored = await readIgnoredBaseline(transaction.repoRoot);
140
- assertIgnoredBaselineUnchanged(current.ignoredBaseline, afterCommitIgnored);
169
+ assertIgnoredBaselineUnchanged(current.ignoredBaseline.filter((entry) => !isEphemeralToolCachePath(entry.path)), afterCommitIgnored.filter((entry) => !isEphemeralToolCachePath(entry.path)));
141
170
  }
142
171
  catch (error) {
143
172
  const branch = await git(transaction.repoRoot, ["branch", "--show-current"]).catch(() => "");
@@ -162,7 +191,13 @@ export async function finalizeGitTask(transaction, outcome) {
162
191
  }
163
192
  transaction.record = current;
164
193
  await assertClean(transaction.repoRoot);
165
- return { status: "checkpointed", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: changes };
194
+ return {
195
+ status: "checkpointed",
196
+ taskId: outcome.taskSpec.id,
197
+ workerRunId: outcome.workerRunId,
198
+ commit,
199
+ changedFiles: checkpointFiles,
200
+ };
166
201
  }
167
202
  const artifactDir = path.join(path.dirname(transaction.recordPath), "failures", outcome.workerRunId);
168
203
  await captureFailureArtifacts(transaction.repoRoot, artifactDir, changes, newIgnored, outcome);
@@ -1,4 +1,5 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
+ import { open, stat } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  const ALLOWED_ARTIFACT_EXTENSIONS = new Set([
4
5
  ".csv",
@@ -91,3 +92,83 @@ function isPathInside(root, target) {
91
92
  const normalizedTarget = path.normalize(target);
92
93
  return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
93
94
  }
95
+ /**
96
+ * Safe repo-relative file resolver for spec-evidence previews (binding sources).
97
+ *
98
+ * Enforces AC-003: rejects `..`, absolute paths (POSIX + Windows drive),
99
+ * directory, binary content, and symlink escape via realpath re-check.
100
+ *
101
+ * Unlike resolveArtifactPath (which only allows .harness artifact roots), this
102
+ * allows any repo-relative text file that was already validated as an evidence
103
+ * member by the caller. Returns { absPath, size } on success, throws otherwise.
104
+ */
105
+ export async function resolveRepoFilePreview(repoRoot, relPath) {
106
+ if (!relPath || typeof relPath !== "string") {
107
+ throw new Error("Invalid path");
108
+ }
109
+ if (path.isAbsolute(relPath)) {
110
+ throw new Error("Absolute paths are not allowed");
111
+ }
112
+ // Windows drive letters (e.g. C:\) or POSIX absolute already covered, plus
113
+ // explicit backslash-drive form used in tests.
114
+ if (/^[A-Za-z]:[\\/]/.test(relPath)) {
115
+ throw new Error("Absolute paths are not allowed");
116
+ }
117
+ // Reject any path segment that is ".." (handles ../, nested .., ./..).
118
+ const preSegments = relPath.split(/[\\/]/).filter(Boolean);
119
+ if (preSegments.includes("..")) {
120
+ throw new Error("Path traversal is not allowed");
121
+ }
122
+ if (!isAllowedArtifactTextPath(relPath)) {
123
+ throw new Error("Binary or disallowed file type");
124
+ }
125
+ const resolvedRepoRoot = path.resolve(repoRoot);
126
+ let realRepoRoot;
127
+ try {
128
+ realRepoRoot = realpathSync(resolvedRepoRoot);
129
+ }
130
+ catch {
131
+ realRepoRoot = resolvedRepoRoot;
132
+ }
133
+ const candidate = path.resolve(realRepoRoot, relPath);
134
+ const relative = path.relative(realRepoRoot, candidate);
135
+ if (relative === "" ||
136
+ relative === ".." ||
137
+ relative.startsWith(`..${path.sep}`) ||
138
+ path.isAbsolute(relative)) {
139
+ throw new Error("Path escapes repo root");
140
+ }
141
+ if (!existsSync(candidate)) {
142
+ throw new Error("File not found");
143
+ }
144
+ let realCandidate;
145
+ try {
146
+ realCandidate = realpathSync(candidate);
147
+ }
148
+ catch {
149
+ throw new Error("File not found");
150
+ }
151
+ if (!isPathInside(realRepoRoot, realCandidate)) {
152
+ throw new Error("Symlink escapes repo root");
153
+ }
154
+ const fileStat = await stat(realCandidate);
155
+ if (!fileStat.isFile()) {
156
+ throw new Error("Path is not a regular file");
157
+ }
158
+ const sampleLength = Math.min(fileStat.size, 8 * 1024);
159
+ if (sampleLength > 0) {
160
+ const sample = Buffer.alloc(sampleLength);
161
+ const handle = await open(realCandidate, "r");
162
+ let bytesRead = 0;
163
+ try {
164
+ ({ bytesRead } = await handle.read(sample, 0, sampleLength, 0));
165
+ }
166
+ finally {
167
+ await handle.close();
168
+ }
169
+ if (sample.subarray(0, bytesRead).includes(0)) {
170
+ throw new Error("Binary file content is not allowed");
171
+ }
172
+ }
173
+ return { absPath: realCandidate, size: fileStat.size };
174
+ }
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { open as openFile, readFile, stat } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
@@ -8,8 +9,8 @@ import { clampEventHistoryLimit, listBatchEventHistory, listPoolEventHistory, }
8
9
  import { buildGlobalSnapshot, clampTaskRunHistoryLimit, listTaskRunHistory, resolveLegacyTask, } from "../observability/read-model.js";
9
10
  import { dagSourceBindingSchema } from "../../workflows/dag/types.js";
10
11
  import { getTaskPoolRoot } from "../pool/run-store.js";
11
- import { isAllowedArtifactTextPath, resolveArtifactPath, toRepoRelativeArtifactPath, } from "./paths.js";
12
- import { extractSpecEvidence, } from "./spec-evidence.js";
12
+ import { isAllowedArtifactTextPath, resolveArtifactPath, resolveRepoFilePreview, toRepoRelativeArtifactPath, } from "./paths.js";
13
+ import { extractSpecEvidence, extractSpecReadContent, } from "./spec-evidence.js";
13
14
  const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
14
15
  export function createObserveSnapshotCache() {
15
16
  return { expiresAt: 0 };
@@ -76,6 +77,11 @@ const ROUTES = [
76
77
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence$/,
77
78
  handler: handleDagNodeSpecEvidence,
78
79
  },
80
+ {
81
+ method: "GET",
82
+ pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence\/file$/,
83
+ handler: handleDagNodeSpecEvidenceFile,
84
+ },
79
85
  {
80
86
  method: "GET",
81
87
  pattern: /^\/api\/dag-runs\/([^/]+)$/,
@@ -706,6 +712,109 @@ function sendJson(res, status, body) {
706
712
  });
707
713
  res.end(payload);
708
714
  }
715
+ async function loadDagRunSourceBinding(repoRoot, dagRunId) {
716
+ const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
717
+ for (const lifecycle of ["active", "completed", "paused"]) {
718
+ const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
719
+ try {
720
+ const runRaw = await readFile(runJsonPath, "utf-8");
721
+ const runSpec = JSON.parse(runRaw);
722
+ const parsed = dagSourceBindingSchema.safeParse(runSpec.sourceBinding);
723
+ if (parsed.success)
724
+ return parsed.data;
725
+ return undefined;
726
+ }
727
+ catch {
728
+ // run.json may not exist; continue to next lifecycle
729
+ }
730
+ }
731
+ return undefined;
732
+ }
733
+ /**
734
+ * Read-only spec-evidence file preview (AC-001..AC-005). Re-validates evidence
735
+ * membership server-side: binding paths must be in sourceBinding.sources, read
736
+ * paths must have a successful paired read in session events. Enforces AC-003
737
+ * path safety and AC-004 truncation + redaction.
738
+ */
739
+ async function handleDagNodeSpecEvidenceFile(_req, res, match, ctx) {
740
+ const dagRunId = match.params.id;
741
+ const nodeId = match.params.sub;
742
+ if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
743
+ sendJson(res, 400, { error: "Invalid dag run or node identifier" });
744
+ return;
745
+ }
746
+ const source = match.query.get("source");
747
+ const rawPath = match.query.get("path");
748
+ if (source !== "binding" && source !== "read") {
749
+ sendJson(res, 400, { error: "Invalid source" });
750
+ return;
751
+ }
752
+ if (!rawPath) {
753
+ sendJson(res, 400, { error: "path is required" });
754
+ return;
755
+ }
756
+ const maxBytes = ARTIFACT_PREVIEW_MAX_BYTES;
757
+ if (source === "binding") {
758
+ const sourceBinding = await loadDagRunSourceBinding(ctx.repoRoot, dagRunId);
759
+ const member = sourceBinding?.sources?.find((s) => s.path === rawPath);
760
+ if (!sourceBinding || !member) {
761
+ sendJson(res, 404, { error: "Path is not a bound source" });
762
+ return;
763
+ }
764
+ let resolved;
765
+ try {
766
+ resolved = await resolveRepoFilePreview(ctx.repoRoot, rawPath);
767
+ }
768
+ catch {
769
+ sendJson(res, 400, { error: "Invalid or unsafe path" });
770
+ return;
771
+ }
772
+ let raw;
773
+ try {
774
+ raw = await readFile(resolved.absPath, "utf-8");
775
+ }
776
+ catch {
777
+ sendJson(res, 404, { error: "File not found" });
778
+ return;
779
+ }
780
+ const redacted = redactSecrets(raw);
781
+ const content = truncateUtf8Preview(redacted, maxBytes);
782
+ const truncated = content !== redacted;
783
+ const currentSha256 = createHash("sha256")
784
+ .update(raw, "utf-8")
785
+ .digest("hex");
786
+ sendJson(res, 200, {
787
+ source: "binding",
788
+ path: rawPath,
789
+ sha256: member.sha256,
790
+ currentSha256,
791
+ hashMatch: currentSha256 === member.sha256,
792
+ content,
793
+ truncated,
794
+ contentBytes: Buffer.byteLength(content, "utf-8"),
795
+ maxBytes,
796
+ });
797
+ return;
798
+ }
799
+ // source === "read"
800
+ const record = await extractSpecReadContent(ctx.repoRoot, dagRunId, nodeId, rawPath);
801
+ if (!record) {
802
+ sendJson(res, 404, { error: "Path was not successfully read" });
803
+ return;
804
+ }
805
+ const redacted = redactSecrets(record.content);
806
+ const content = truncateUtf8Preview(redacted, maxBytes);
807
+ const truncated = content !== redacted;
808
+ sendJson(res, 200, {
809
+ source: "read",
810
+ path: rawPath,
811
+ content,
812
+ truncated,
813
+ contentBytes: Buffer.byteLength(content, "utf-8"),
814
+ maxBytes,
815
+ readAt: record.timestamp ?? null,
816
+ });
817
+ }
709
818
  async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
710
819
  const dagRunId = match.params.id;
711
820
  const nodeId = match.params.sub;
@@ -715,24 +824,23 @@ async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
715
824
  }
716
825
  // Extract skill injection info from the DAG run spec (run.json)
717
826
  const skillInjection = { skills: [], references: [] };
718
- let sourceBinding;
719
- const dagRunsRoot = path.resolve(ctx.repoRoot, ".harness", "dag-runs");
720
- for (const lifecycle of ["active", "completed", "paused"]) {
721
- const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
722
- try {
723
- const runRaw = await readFile(runJsonPath, "utf-8");
724
- const runSpec = JSON.parse(runRaw);
725
- const parsedSourceBinding = dagSourceBindingSchema.safeParse(runSpec.sourceBinding);
726
- if (parsedSourceBinding.success)
727
- sourceBinding = parsedSourceBinding.data;
728
- const task = runSpec.tasks?.find((t) => t.id === nodeId);
729
- if (task?.skills && Array.isArray(task.skills)) {
730
- skillInjection.skills = task.skills;
827
+ const sourceBinding = await loadDagRunSourceBinding(ctx.repoRoot, dagRunId);
828
+ {
829
+ const dagRunsRoot = path.resolve(ctx.repoRoot, ".harness", "dag-runs");
830
+ for (const lifecycle of ["active", "completed", "paused"]) {
831
+ const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
832
+ try {
833
+ const runRaw = await readFile(runJsonPath, "utf-8");
834
+ const runSpec = JSON.parse(runRaw);
835
+ const task = runSpec.tasks?.find((t) => t.id === nodeId);
836
+ if (task?.skills && Array.isArray(task.skills)) {
837
+ skillInjection.skills = task.skills;
838
+ }
839
+ break;
840
+ }
841
+ catch {
842
+ // run.json may not exist; continue to next lifecycle
731
843
  }
732
- break;
733
- }
734
- catch {
735
- // run.json may not exist; continue to next lifecycle
736
844
  }
737
845
  }
738
846
  const evidence = await extractSpecEvidence(ctx.repoRoot, dagRunId, nodeId);