@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 +9 -0
- package/dist/executors/dag-pi-executor.js +63 -9
- package/dist/executors/shell-executor.js +30 -0
- package/dist/executors/shell-write-guard.js +64 -2
- package/dist/worker/delivery/git-transaction.js +43 -8
- package/dist/worker/observe/paths.js +81 -0
- package/dist/worker/observe/routes.js +127 -19
- package/dist/worker/observe/spec-evidence.js +84 -0
- package/dist/worker/observe/static/api.js +23 -0
- package/dist/worker/observe/static/state.js +26 -0
- package/dist/worker/observe/static/styles.css +10 -0
- package/dist/worker/observe/static/views/dag-inspector.js +173 -6
- package/dist/workflows/dag/init-hybrid.js +104 -0
- package/dist/workflows/dag/node-execution.js +32 -5
- package/dist/workflows/dag/project-governance-context.js +508 -0
- package/dist/workflows/dag/prompt.js +46 -1
- package/dist/workflows/dag/skill-snapshot.js +1 -0
- package/dist/workflows/dag/types.js +10 -0
- package/dist/workflows/dag/validate.js +28 -0
- package/docs/templates/agent-dag.schema.json +15 -0
- package/docs/templates/agent-dag.supervised-implementation.json +1 -0
- package/package.json +1 -1
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 {
|
|
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,
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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);
|