@tea-agent/loop-agent 0.26.2 → 0.26.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/dist/executors/dag-pi-executor.js +182 -10
- package/dist/executors/pi-executor.js +233 -147
- package/dist/executors/pi-sdk-executor.js +140 -81
- package/dist/executors/pi-writer-tool-policy.js +266 -0
- package/dist/executors/shell-executor.js +355 -52
- package/dist/executors/shell-write-guard.js +145 -12
- package/dist/governance/document-index-closure.js +164 -0
- package/dist/worker/observe/node-input.js +8 -11
- package/dist/workflows/dag/convergence/controller.js +100 -3
- package/dist/workflows/dag/frontend-repair.js +29 -29
- package/dist/workflows/dag/frontend-verification-trace.js +12 -2
- package/dist/workflows/dag/governance-profile.js +1 -1
- package/dist/workflows/dag/init-hybrid.js +70 -16
- package/dist/workflows/dag/repair-artifact.js +100 -4
- package/dist/workflows/dag/rerun-plan.js +14 -7
- package/dist/workflows/dag/types.js +7 -1
- package/dist/workflows/dag/workspace-checkpoint.js +42 -0
- package/docs/templates/init-managed-agents.md +3 -3
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +13 -6
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { access as fsAccess, lstat, mkdir as fsMkdir, readFile as fsReadFile, realpath, writeFile as fsWriteFile, } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
4
|
+
export class PiWriterPathDeniedError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
targetPath;
|
|
7
|
+
constructor(code, targetPath, detail) {
|
|
8
|
+
super(detail
|
|
9
|
+
? `pi writer path denied (${code}): ${targetPath}: ${detail}`
|
|
10
|
+
: `pi writer path denied (${code}): ${targetPath}`);
|
|
11
|
+
this.name = "PiWriterPathDeniedError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.targetPath = targetPath;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const HARD_FORBIDDEN_PREFIXES = [
|
|
17
|
+
".harness/tasks/",
|
|
18
|
+
".harness/dag-runs/",
|
|
19
|
+
];
|
|
20
|
+
function normalizeRepoRelative(value) {
|
|
21
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
22
|
+
}
|
|
23
|
+
function matchesAnyPattern(filePath, patterns) {
|
|
24
|
+
return patterns.some((pattern) => pathMatchesPattern(filePath, pattern));
|
|
25
|
+
}
|
|
26
|
+
function isPathInside(root, target) {
|
|
27
|
+
const normalizedRoot = path.resolve(root);
|
|
28
|
+
const normalizedTarget = path.resolve(target);
|
|
29
|
+
const rootWithSep = normalizedRoot.endsWith(path.sep)
|
|
30
|
+
? normalizedRoot
|
|
31
|
+
: `${normalizedRoot}${path.sep}`;
|
|
32
|
+
return (normalizedTarget === normalizedRoot ||
|
|
33
|
+
normalizedTarget.startsWith(rootWithSep));
|
|
34
|
+
}
|
|
35
|
+
async function resolveRealIfExists(absPath) {
|
|
36
|
+
try {
|
|
37
|
+
return await realpath(absPath);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the effective mutation destination from the nearest existing
|
|
45
|
+
* ancestor's real path plus the unresolved suffix. This catches in-repo
|
|
46
|
+
* symlinks that redirect an authorized lexical path into a forbidden area.
|
|
47
|
+
*/
|
|
48
|
+
async function resolveEffectiveMutationTarget(repoRoot, absoluteTarget) {
|
|
49
|
+
const realRoot = (await resolveRealIfExists(repoRoot)) ?? path.resolve(repoRoot);
|
|
50
|
+
let current = path.resolve(absoluteTarget);
|
|
51
|
+
const suffix = [];
|
|
52
|
+
while (true) {
|
|
53
|
+
try {
|
|
54
|
+
const st = await lstat(current);
|
|
55
|
+
const real = await resolveRealIfExists(current);
|
|
56
|
+
if (!real) {
|
|
57
|
+
throw new PiWriterPathDeniedError(st.isSymbolicLink() ? "symlink-escape" : "ancestor-escape", absoluteTarget, `cannot resolve existing ancestor ${current}`);
|
|
58
|
+
}
|
|
59
|
+
const effectiveTarget = path.resolve(real, ...suffix);
|
|
60
|
+
if (!isPathInside(realRoot, effectiveTarget)) {
|
|
61
|
+
throw new PiWriterPathDeniedError(st.isSymbolicLink() ? "symlink-escape" : "ancestor-escape", absoluteTarget, `effective mutation target ${effectiveTarget} escapes repo`);
|
|
62
|
+
}
|
|
63
|
+
return { realRoot, absoluteTarget: effectiveTarget };
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error instanceof PiWriterPathDeniedError)
|
|
67
|
+
throw error;
|
|
68
|
+
const code = error.code;
|
|
69
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
70
|
+
throw new PiWriterPathDeniedError("ancestor-escape", absoluteTarget, `cannot inspect ancestor ${current}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const parent = path.dirname(current);
|
|
74
|
+
if (parent === current) {
|
|
75
|
+
throw new PiWriterPathDeniedError("outside-repo", absoluteTarget, "no existing repository ancestor found");
|
|
76
|
+
}
|
|
77
|
+
suffix.unshift(path.basename(current));
|
|
78
|
+
current = parent;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function isPathAtOrBelowDirectory(candidatePath, directoryPath) {
|
|
82
|
+
const candidate = normalizeRepoRelative(candidatePath);
|
|
83
|
+
const directory = normalizeRepoRelative(directoryPath);
|
|
84
|
+
return (candidate.length > 0 &&
|
|
85
|
+
(directory.length === 0 ||
|
|
86
|
+
candidate === directory ||
|
|
87
|
+
candidate.startsWith(`${directory}/`)));
|
|
88
|
+
}
|
|
89
|
+
function isHardForbidden(relPath) {
|
|
90
|
+
const normalized = normalizeRepoRelative(relPath);
|
|
91
|
+
if (normalized === ".harness/tasks" ||
|
|
92
|
+
normalized === ".harness/dag-runs" ||
|
|
93
|
+
normalized.startsWith(".harness/tasks/") ||
|
|
94
|
+
normalized.startsWith(".harness/dag-runs/")) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return HARD_FORBIDDEN_PREFIXES.some((prefix) => matchesAnyPattern(normalized, [`${prefix}**`, prefix.slice(0, -1)]));
|
|
98
|
+
}
|
|
99
|
+
function repoRelativePath(root, absoluteTarget, raw, allowRoot) {
|
|
100
|
+
const relative = normalizeRepoRelative(path.relative(root, absoluteTarget));
|
|
101
|
+
if ((!allowRoot && !relative) ||
|
|
102
|
+
path.isAbsolute(relative) ||
|
|
103
|
+
relative.startsWith("..")) {
|
|
104
|
+
throw new PiWriterPathDeniedError("outside-repo", raw);
|
|
105
|
+
}
|
|
106
|
+
return relative;
|
|
107
|
+
}
|
|
108
|
+
function assertWritableRelative(relative, ctx) {
|
|
109
|
+
if (isHardForbidden(relative)) {
|
|
110
|
+
throw new PiWriterPathDeniedError("hard-forbidden", relative, "writes under .harness/tasks/** or .harness/dag-runs/** are never allowed for Pi writers");
|
|
111
|
+
}
|
|
112
|
+
const forbidden = ctx.forbiddenPaths ?? [];
|
|
113
|
+
if (forbidden.length > 0 && matchesAnyPattern(relative, forbidden)) {
|
|
114
|
+
throw new PiWriterPathDeniedError("forbidden", relative);
|
|
115
|
+
}
|
|
116
|
+
const writeSet = ctx.writeSet ?? [];
|
|
117
|
+
if ((ctx.writePolicy ?? "exclusive") === "exclusive" &&
|
|
118
|
+
(writeSet.length === 0 || !matchesAnyPattern(relative, writeSet))) {
|
|
119
|
+
throw new PiWriterPathDeniedError("not-in-write-set", relative);
|
|
120
|
+
}
|
|
121
|
+
const allowedPaths = ctx.allowedPaths ?? [];
|
|
122
|
+
if (allowedPaths.length === 0 || !matchesAnyPattern(relative, allowedPaths)) {
|
|
123
|
+
throw new PiWriterPathDeniedError("not-in-allowed-paths", relative);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function assertMkdirRelative(relative, ctx) {
|
|
127
|
+
if (relative && isHardForbidden(relative)) {
|
|
128
|
+
throw new PiWriterPathDeniedError("hard-forbidden", relative);
|
|
129
|
+
}
|
|
130
|
+
const forbidden = ctx.forbiddenPaths ?? [];
|
|
131
|
+
if (relative &&
|
|
132
|
+
forbidden.length > 0 &&
|
|
133
|
+
matchesAnyPattern(relative, forbidden)) {
|
|
134
|
+
throw new PiWriterPathDeniedError("forbidden", relative);
|
|
135
|
+
}
|
|
136
|
+
const writeSetCandidates = (ctx.writeSet ?? [])
|
|
137
|
+
.map(normalizeRepoRelative)
|
|
138
|
+
.filter((candidate) => isPathAtOrBelowDirectory(candidate, relative) ||
|
|
139
|
+
(relative.length > 0 && pathMatchesPattern(relative, candidate)));
|
|
140
|
+
if (writeSetCandidates.length === 0) {
|
|
141
|
+
throw new PiWriterPathDeniedError("not-in-write-set", relative || ".");
|
|
142
|
+
}
|
|
143
|
+
const allowedPaths = ctx.allowedPaths ?? [];
|
|
144
|
+
const allowedCandidates = writeSetCandidates.filter((candidate) => matchesAnyPattern(candidate, allowedPaths));
|
|
145
|
+
if (allowedPaths.length === 0 || allowedCandidates.length === 0) {
|
|
146
|
+
throw new PiWriterPathDeniedError("not-in-allowed-paths", relative || ".");
|
|
147
|
+
}
|
|
148
|
+
const nonHardForbiddenCandidates = allowedCandidates.filter((candidate) => !isHardForbidden(candidate));
|
|
149
|
+
if (nonHardForbiddenCandidates.length === 0) {
|
|
150
|
+
throw new PiWriterPathDeniedError("hard-forbidden", relative || ".");
|
|
151
|
+
}
|
|
152
|
+
if (forbidden.length > 0 &&
|
|
153
|
+
nonHardForbiddenCandidates.every((candidate) => matchesAnyPattern(candidate, forbidden))) {
|
|
154
|
+
throw new PiWriterPathDeniedError("forbidden", relative || ".");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Assert a mutation target is writable under the DAG writer policy.
|
|
159
|
+
* Returns the normalized lexical repo-relative path on success.
|
|
160
|
+
*/
|
|
161
|
+
export async function assertWritablePath(targetPath, ctx) {
|
|
162
|
+
const raw = String(targetPath ?? "").trim();
|
|
163
|
+
if (!raw) {
|
|
164
|
+
throw new PiWriterPathDeniedError("empty-path", targetPath ?? "");
|
|
165
|
+
}
|
|
166
|
+
const repoRoot = path.resolve(ctx.repoRoot);
|
|
167
|
+
const absoluteTarget = path.isAbsolute(raw)
|
|
168
|
+
? path.resolve(raw)
|
|
169
|
+
: path.resolve(repoRoot, raw);
|
|
170
|
+
if (!isPathInside(repoRoot, absoluteTarget)) {
|
|
171
|
+
throw new PiWriterPathDeniedError("outside-repo", raw, "path resolves outside repository root");
|
|
172
|
+
}
|
|
173
|
+
const effective = await resolveEffectiveMutationTarget(repoRoot, absoluteTarget);
|
|
174
|
+
const lexicalRelative = repoRelativePath(repoRoot, absoluteTarget, raw, false);
|
|
175
|
+
const effectiveRelative = repoRelativePath(effective.realRoot, effective.absoluteTarget, raw, false);
|
|
176
|
+
assertWritableRelative(lexicalRelative, ctx);
|
|
177
|
+
assertWritableRelative(effectiveRelative, ctx);
|
|
178
|
+
return lexicalRelative;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Intermediate directory creation is authorized only when both the lexical
|
|
182
|
+
* directory and its effective real destination are ancestors of a target that
|
|
183
|
+
* satisfies the complete writer boundary.
|
|
184
|
+
*/
|
|
185
|
+
export async function assertMkdirPath(dirPath, ctx) {
|
|
186
|
+
const raw = String(dirPath ?? "").trim();
|
|
187
|
+
if (!raw) {
|
|
188
|
+
throw new PiWriterPathDeniedError("empty-path", dirPath ?? "");
|
|
189
|
+
}
|
|
190
|
+
const repoRoot = path.resolve(ctx.repoRoot);
|
|
191
|
+
const absoluteTarget = path.isAbsolute(raw)
|
|
192
|
+
? path.resolve(raw)
|
|
193
|
+
: path.resolve(repoRoot, raw);
|
|
194
|
+
if (!isPathInside(repoRoot, absoluteTarget)) {
|
|
195
|
+
throw new PiWriterPathDeniedError("outside-repo", raw);
|
|
196
|
+
}
|
|
197
|
+
const effective = await resolveEffectiveMutationTarget(repoRoot, absoluteTarget);
|
|
198
|
+
const lexicalRelative = repoRelativePath(repoRoot, absoluteTarget, raw, true);
|
|
199
|
+
const effectiveRelative = repoRelativePath(effective.realRoot, effective.absoluteTarget, raw, true);
|
|
200
|
+
assertMkdirRelative(lexicalRelative, ctx);
|
|
201
|
+
assertMkdirRelative(effectiveRelative, ctx);
|
|
202
|
+
return lexicalRelative;
|
|
203
|
+
}
|
|
204
|
+
/** Build FS operations that enforce writer policy before mutations. */
|
|
205
|
+
export function createPiWriterFsOperations(ctx) {
|
|
206
|
+
return {
|
|
207
|
+
readFile: (absolutePath) => fsReadFile(absolutePath),
|
|
208
|
+
access: async (absolutePath) => {
|
|
209
|
+
await fsAccess(absolutePath);
|
|
210
|
+
},
|
|
211
|
+
writeFile: async (absolutePath, content) => {
|
|
212
|
+
await assertWritablePath(absolutePath, ctx);
|
|
213
|
+
await fsWriteFile(absolutePath, content, "utf-8");
|
|
214
|
+
},
|
|
215
|
+
mkdir: async (dir) => {
|
|
216
|
+
await assertMkdirPath(dir, ctx);
|
|
217
|
+
await fsMkdir(dir, { recursive: true });
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Create same-name custom edit/write tool definitions that override built-ins
|
|
223
|
+
* via Pi SDK customTools registration. Fails closed when SDK factories are missing.
|
|
224
|
+
*/
|
|
225
|
+
export async function createPiWriterCustomTools(ctx) {
|
|
226
|
+
const sdk = (await import("@earendil-works/pi-coding-agent"));
|
|
227
|
+
const createEditToolDefinition = sdk.createEditToolDefinition;
|
|
228
|
+
const createWriteToolDefinition = sdk.createWriteToolDefinition;
|
|
229
|
+
if (typeof createEditToolDefinition !== "function" ||
|
|
230
|
+
typeof createWriteToolDefinition !== "function") {
|
|
231
|
+
throw new Error("pi writer tool policy unavailable: createEditToolDefinition/createWriteToolDefinition missing from SDK");
|
|
232
|
+
}
|
|
233
|
+
const ops = createPiWriterFsOperations(ctx);
|
|
234
|
+
const cwd = path.resolve(ctx.repoRoot);
|
|
235
|
+
const edit = createEditToolDefinition(cwd, {
|
|
236
|
+
operations: {
|
|
237
|
+
readFile: ops.readFile,
|
|
238
|
+
writeFile: ops.writeFile,
|
|
239
|
+
access: ops.access,
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const write = createWriteToolDefinition(cwd, {
|
|
243
|
+
operations: {
|
|
244
|
+
writeFile: ops.writeFile,
|
|
245
|
+
mkdir: ops.mkdir,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
if (edit?.name !== "edit" || write?.name !== "write") {
|
|
249
|
+
throw new Error("pi writer tool policy unavailable: SDK tool definitions must be named edit/write");
|
|
250
|
+
}
|
|
251
|
+
return [edit, write];
|
|
252
|
+
}
|
|
253
|
+
/** Build a policy context from a DAG task-like write boundary. */
|
|
254
|
+
export function buildPiWriterToolPolicyContext(input) {
|
|
255
|
+
const repoRoot = String(input.repoRoot ?? "").trim();
|
|
256
|
+
if (!repoRoot) {
|
|
257
|
+
throw new Error("pi writer tool policy requires non-empty repoRoot");
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
repoRoot: path.resolve(repoRoot),
|
|
261
|
+
allowedPaths: input.allowedPaths ?? [],
|
|
262
|
+
writeSet: input.writeSet ?? [],
|
|
263
|
+
forbiddenPaths: input.forbiddenPaths ?? [],
|
|
264
|
+
writePolicy: input.writePolicy ?? "exclusive",
|
|
265
|
+
};
|
|
266
|
+
}
|