impel-cli 0.20.2 → 0.20.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/README.md +61 -68
- package/RELEASE_NOTES.md +13 -0
- package/package.json +2 -1
- package/src/agents.js +94 -13
- package/src/apps.js +26 -2
- package/src/cli.js +2 -1
- package/src/commands/launch.js +11 -2
- package/src/commands/mcp.js +30 -8
- package/src/commands/remote.js +430 -26
- package/src/directAnswer.js +44 -0
- package/src/nativeAgentTransport.js +95 -7
- package/src/remote/aws.js +38 -0
- package/src/remote/broker.js +296 -0
- package/src/remote/checkpoint.js +248 -0
- package/src/remote/contracts.js +89 -0
- package/src/remote/state.js +13 -1
- package/src/remote/telemetry.js +29 -0
- package/src/remote/transfer.js +14 -6
- package/src/remote/workspace.js +482 -0
- package/src/selfInvocation.js +6 -2
- package/src/verbatimRelay.js +64 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { TextDecoder } from "node:util";
|
|
5
|
+
|
|
6
|
+
import { runBuffer, runCapture } from "./process.js";
|
|
7
|
+
|
|
8
|
+
export const WORKSPACE_SCHEMA_VERSION = 1;
|
|
9
|
+
export const WORKSPACE_CONTENT_TYPE = "application/vnd.impel.workspace.v1+tar";
|
|
10
|
+
const MAX_FILES = 20_000;
|
|
11
|
+
const MAX_FILE_BYTES = 256 * 1024 * 1024;
|
|
12
|
+
const MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024;
|
|
13
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
14
|
+
|
|
15
|
+
function git(root, args, options = {}) {
|
|
16
|
+
return runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", ["-C", root, ...args], options);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function gitBuffer(root, args, options = {}) {
|
|
20
|
+
return runBuffer(process.env.IMPEL_REMOTE_GIT_BIN || "git", ["-C", root, ...args], options).stdout;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sha256Buffer(value) {
|
|
24
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sha256File(filePath) {
|
|
28
|
+
const hash = crypto.createHash("sha256");
|
|
29
|
+
const descriptor = fs.openSync(filePath, "r");
|
|
30
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
31
|
+
try {
|
|
32
|
+
let offset = 0;
|
|
33
|
+
while (true) {
|
|
34
|
+
const read = fs.readSync(descriptor, buffer, 0, buffer.length, offset);
|
|
35
|
+
if (read === 0) break;
|
|
36
|
+
hash.update(buffer.subarray(0, read));
|
|
37
|
+
offset += read;
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
fs.closeSync(descriptor);
|
|
41
|
+
}
|
|
42
|
+
return hash.digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function splitNul(buffer, label) {
|
|
46
|
+
if (buffer.length === 0) return [];
|
|
47
|
+
if (buffer.at(-1) !== 0) throw new Error(`${label} returned a malformed NUL-delimited path list`);
|
|
48
|
+
const paths = [];
|
|
49
|
+
let start = 0;
|
|
50
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
51
|
+
if (buffer[index] !== 0) continue;
|
|
52
|
+
if (index > start) {
|
|
53
|
+
try { paths.push(utf8.decode(buffer.subarray(start, index))); }
|
|
54
|
+
catch { throw new Error(`${label} returned a path that is not valid UTF-8`); }
|
|
55
|
+
}
|
|
56
|
+
start = index + 1;
|
|
57
|
+
}
|
|
58
|
+
return paths;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function safeRelative(relative) {
|
|
62
|
+
const normalized = String(relative).replaceAll("\\", "/");
|
|
63
|
+
if (
|
|
64
|
+
!normalized || normalized.startsWith("/") || /^[A-Za-z]:\//u.test(normalized)
|
|
65
|
+
|| normalized.split("/").some((segment) => !segment || segment === "." || segment === "..")
|
|
66
|
+
|| normalized === ".git" || normalized.startsWith(".git/")
|
|
67
|
+
|| normalized.includes("\0")
|
|
68
|
+
) throw new Error(`unsafe workspace path ${JSON.stringify(relative)}`);
|
|
69
|
+
return normalized;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function effectiveGitConfig(root) {
|
|
73
|
+
const value = (key, fallback) => {
|
|
74
|
+
const result = git(root, ["config", "--get", key], { allowFailure: true });
|
|
75
|
+
return result.status === 0 ? result.stdout.trim().toLowerCase() : fallback;
|
|
76
|
+
};
|
|
77
|
+
const coreAutocrlf = value("core.autocrlf", "false");
|
|
78
|
+
const configuredEol = value("core.eol", "native");
|
|
79
|
+
const coreEol = configuredEol === "native" ? (process.platform === "win32" ? "crlf" : "lf") : configuredEol;
|
|
80
|
+
const coreFileMode = value("core.filemode", "false");
|
|
81
|
+
if (!new Set(["true", "false", "input"]).has(coreAutocrlf)
|
|
82
|
+
|| !new Set(["native", "lf", "crlf"]).has(coreEol)
|
|
83
|
+
|| !new Set(["true", "false"]).has(coreFileMode)) {
|
|
84
|
+
throw new Error("workspace snapshot has unsupported Git worktree settings");
|
|
85
|
+
}
|
|
86
|
+
return { coreAutocrlf, coreEol, coreFileMode: coreFileMode === "true" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function applyGitConfig(root, config) {
|
|
90
|
+
if (!config || !new Set(["true", "false", "input"]).has(config.coreAutocrlf)
|
|
91
|
+
|| !new Set(["native", "lf", "crlf"]).has(config.coreEol)
|
|
92
|
+
|| typeof config.coreFileMode !== "boolean"
|
|
93
|
+
|| Object.keys(config).some((key) => !new Set(["coreAutocrlf", "coreEol", "coreFileMode"]).has(key))) {
|
|
94
|
+
throw new Error("workspace manifest has invalid Git worktree settings");
|
|
95
|
+
}
|
|
96
|
+
git(root, ["config", "--local", "core.autocrlf", config.coreAutocrlf]);
|
|
97
|
+
git(root, ["config", "--local", "core.eol", config.coreEol]);
|
|
98
|
+
git(root, ["config", "--local", "core.filemode", String(config.coreFileMode)]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function safeOrigin(value) {
|
|
102
|
+
const origin = String(value || "").trim();
|
|
103
|
+
if (!origin) return null;
|
|
104
|
+
if (/\s|[?&](?:access_token|token|key|signature)=/iu.test(origin)) return null;
|
|
105
|
+
if (/^[^/@:]+@[^:]+:/u.test(origin)) return origin.startsWith("git@") ? origin : null;
|
|
106
|
+
try {
|
|
107
|
+
const url = new URL(origin);
|
|
108
|
+
if (url.username || url.password || url.search || url.hash) return null;
|
|
109
|
+
if (!new Set(["https:", "ssh:", "git:"]).has(url.protocol)) return null;
|
|
110
|
+
return origin;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function assertRepositorySupported(root, allPaths) {
|
|
117
|
+
if (gitBuffer(root, ["ls-files", "--unmerged", "-z"]).length > 0) {
|
|
118
|
+
throw new Error("workspace snapshot does not support unresolved merge index stages");
|
|
119
|
+
}
|
|
120
|
+
const submodules = gitBuffer(root, ["ls-files", "--stage", "-z"])
|
|
121
|
+
.toString("utf8").split("\0").filter((line) => line.startsWith("160000 "));
|
|
122
|
+
if (submodules.length > 0) {
|
|
123
|
+
throw new Error("workspace snapshot does not support submodules without explicit nested manifests");
|
|
124
|
+
}
|
|
125
|
+
const lfs = git(root, ["lfs", "ls-files", "--name-only"], { allowFailure: true });
|
|
126
|
+
if (lfs.status === 0 && lfs.stdout.trim()) {
|
|
127
|
+
throw new Error("workspace snapshot does not support Git LFS objects");
|
|
128
|
+
}
|
|
129
|
+
if (allPaths.length > 0) {
|
|
130
|
+
const attributeInput = Buffer.from(`${allPaths.join("\0")}\0`, "utf8");
|
|
131
|
+
const attributes = splitNul(gitBuffer(root, ["check-attr", "-z", "--stdin", "filter"], {
|
|
132
|
+
input: attributeInput,
|
|
133
|
+
}), "git check-attr");
|
|
134
|
+
for (let index = 0; index + 2 < attributes.length; index += 3) {
|
|
135
|
+
if (attributes[index + 1] === "filter" && attributes[index + 2] === "lfs") {
|
|
136
|
+
throw new Error(`workspace snapshot does not support Git LFS path ${JSON.stringify(attributes[index])}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const folded = new Map();
|
|
141
|
+
for (const relative of allPaths) {
|
|
142
|
+
const safe = safeRelative(relative);
|
|
143
|
+
const key = safe.normalize("NFC").toLocaleLowerCase("en-US");
|
|
144
|
+
const previous = folded.get(key);
|
|
145
|
+
if (previous && previous !== safe) {
|
|
146
|
+
throw new Error(`workspace snapshot has a destination case collision between ${JSON.stringify(previous)} and ${JSON.stringify(safe)}`);
|
|
147
|
+
}
|
|
148
|
+
folded.set(key, safe);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function inspectFiles(root, relatives) {
|
|
153
|
+
let totalBytes = 0;
|
|
154
|
+
const files = [];
|
|
155
|
+
if (relatives.length > MAX_FILES) throw new Error(`workspace snapshot exceeds ${MAX_FILES} files`);
|
|
156
|
+
for (const relative of relatives) {
|
|
157
|
+
const safe = safeRelative(relative);
|
|
158
|
+
const absolute = path.join(root, ...safe.split("/"));
|
|
159
|
+
const stat = fs.lstatSync(absolute, { throwIfNoEntry: false });
|
|
160
|
+
if (!stat) continue;
|
|
161
|
+
if (stat.isSymbolicLink()) throw new Error(`workspace snapshot refuses symbolic link ${JSON.stringify(safe)}`);
|
|
162
|
+
if (!stat.isFile()) throw new Error(`workspace snapshot refuses special file ${JSON.stringify(safe)}`);
|
|
163
|
+
if (stat.nlink !== 1) throw new Error(`workspace snapshot refuses hard-linked file ${JSON.stringify(safe)}`);
|
|
164
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error(`workspace file ${JSON.stringify(safe)} exceeds ${MAX_FILE_BYTES} bytes`);
|
|
165
|
+
totalBytes += stat.size;
|
|
166
|
+
if (totalBytes > MAX_TOTAL_BYTES) throw new Error(`workspace snapshot exceeds ${MAX_TOTAL_BYTES} bytes`);
|
|
167
|
+
files.push({
|
|
168
|
+
path: safe,
|
|
169
|
+
mode: stat.mode & 0o111 ? "100755" : "100644",
|
|
170
|
+
length: stat.size,
|
|
171
|
+
sha256: sha256File(absolute),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return files;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function tarPathFields(relative) {
|
|
178
|
+
const value = Buffer.from(safeRelative(relative), "utf8");
|
|
179
|
+
if (value.length <= 100) return { name: value, prefix: Buffer.alloc(0) };
|
|
180
|
+
const parts = safeRelative(relative).split("/");
|
|
181
|
+
for (let index = parts.length - 1; index > 0; index -= 1) {
|
|
182
|
+
const prefix = Buffer.from(parts.slice(0, index).join("/"), "utf8");
|
|
183
|
+
const name = Buffer.from(parts.slice(index).join("/"), "utf8");
|
|
184
|
+
if (prefix.length <= 155 && name.length <= 100) return { name, prefix };
|
|
185
|
+
}
|
|
186
|
+
throw new Error(`workspace archive path is too long: ${JSON.stringify(relative)}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function writeField(header, offset, length, value) {
|
|
190
|
+
const bytes = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
|
|
191
|
+
if (bytes.length > length) throw new Error("tar header field overflow");
|
|
192
|
+
bytes.copy(header, offset);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function writeOctal(header, offset, length, value) {
|
|
196
|
+
const encoded = Math.max(0, value).toString(8).padStart(length - 1, "0");
|
|
197
|
+
writeField(header, offset, length, `${encoded}\0`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function tarHeader(relative, size, mode) {
|
|
201
|
+
const header = Buffer.alloc(512);
|
|
202
|
+
const fields = tarPathFields(relative);
|
|
203
|
+
writeField(header, 0, 100, fields.name);
|
|
204
|
+
writeOctal(header, 100, 8, mode & 0o777);
|
|
205
|
+
writeOctal(header, 108, 8, 0);
|
|
206
|
+
writeOctal(header, 116, 8, 0);
|
|
207
|
+
writeOctal(header, 124, 12, size);
|
|
208
|
+
writeOctal(header, 136, 12, 0);
|
|
209
|
+
header.fill(0x20, 148, 156);
|
|
210
|
+
writeField(header, 156, 1, "0");
|
|
211
|
+
writeField(header, 257, 6, "ustar\0");
|
|
212
|
+
writeField(header, 263, 2, "00");
|
|
213
|
+
writeField(header, 345, 155, fields.prefix);
|
|
214
|
+
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
|
215
|
+
writeField(header, 148, 8, `${checksum.toString(8).padStart(6, "0")}\0 `);
|
|
216
|
+
return header;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function createTar(target, entries) {
|
|
220
|
+
const descriptor = fs.openSync(target, "w", 0o600);
|
|
221
|
+
const padding = Buffer.alloc(512);
|
|
222
|
+
try {
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
const stat = fs.statSync(entry.source);
|
|
225
|
+
fs.writeSync(descriptor, tarHeader(entry.path, stat.size, entry.mode ?? stat.mode));
|
|
226
|
+
const input = fs.openSync(entry.source, "r");
|
|
227
|
+
try {
|
|
228
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
229
|
+
let position = 0;
|
|
230
|
+
while (position < stat.size) {
|
|
231
|
+
const read = fs.readSync(input, buffer, 0, Math.min(buffer.length, stat.size - position), position);
|
|
232
|
+
if (read === 0) throw new Error(`workspace file changed during archive: ${entry.path}`);
|
|
233
|
+
fs.writeSync(descriptor, buffer, 0, read);
|
|
234
|
+
position += read;
|
|
235
|
+
}
|
|
236
|
+
} finally { fs.closeSync(input); }
|
|
237
|
+
const remainder = stat.size % 512;
|
|
238
|
+
if (remainder) fs.writeSync(descriptor, padding, 0, 512 - remainder);
|
|
239
|
+
}
|
|
240
|
+
fs.writeSync(descriptor, Buffer.alloc(1024));
|
|
241
|
+
} finally { fs.closeSync(descriptor); }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function writePrivate(target, contents) {
|
|
245
|
+
fs.writeFileSync(target, contents, { mode: 0o600 });
|
|
246
|
+
try { fs.chmodSync(target, 0o600); } catch { /* Best effort on Windows. */ }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function part(filePath) {
|
|
250
|
+
const stat = fs.statSync(filePath);
|
|
251
|
+
return { length: stat.size, sha256: sha256File(filePath) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function snapshotFingerprint(root, allPaths) {
|
|
255
|
+
return inspectFiles(root, allPaths)
|
|
256
|
+
.map((file) => `${file.path}\0${file.mode}\0${file.length}\0${file.sha256}`)
|
|
257
|
+
.join("\n");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function indexMatchedFiles(root, tracked, presentPaths, intent) {
|
|
261
|
+
const changed = new Set(splitNul(gitBuffer(root, ["diff", "--name-only", "--no-ext-diff", "-z"]), "git diff"));
|
|
262
|
+
const present = new Set(presentPaths);
|
|
263
|
+
const intentSet = new Set(intent);
|
|
264
|
+
return tracked.filter((relative) => present.has(relative) && !changed.has(relative) && !intentSet.has(relative));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function refreshIndexStat(root, paths) {
|
|
268
|
+
if (paths.length === 0) return;
|
|
269
|
+
const before = gitBuffer(root, ["ls-files", "--stage", "-z"]);
|
|
270
|
+
for (let offset = 0; offset < paths.length; offset += 64) {
|
|
271
|
+
git(root, ["--literal-pathspecs", "add", "--", ...paths.slice(offset, offset + 64)]);
|
|
272
|
+
}
|
|
273
|
+
const after = gitBuffer(root, ["ls-files", "--stage", "-z"]);
|
|
274
|
+
if (!before.equals(after)) throw new Error("workspace index changed while refreshing restored file metadata");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function createWorkspaceArtifact(repository, artifactRoot) {
|
|
278
|
+
fs.mkdirSync(artifactRoot, { recursive: true, mode: 0o700 });
|
|
279
|
+
const names = {
|
|
280
|
+
bundle: "repository.bundle",
|
|
281
|
+
staged: "staged.patch",
|
|
282
|
+
working: "working.patch",
|
|
283
|
+
untracked: "untracked.tar",
|
|
284
|
+
untrackedPaths: "untracked.paths",
|
|
285
|
+
intent: "intent-to-add.paths",
|
|
286
|
+
manifest: "manifest.json",
|
|
287
|
+
artifact: "workspace.tar",
|
|
288
|
+
};
|
|
289
|
+
const files = Object.fromEntries(Object.entries(names).map(([key, name]) => [key, path.join(artifactRoot, name)]));
|
|
290
|
+
const tracked = splitNul(gitBuffer(repository.root, ["ls-files", "--cached", "-z"]), "git ls-files");
|
|
291
|
+
const untracked = splitNul(gitBuffer(repository.root, ["ls-files", "--others", "--exclude-standard", "-z"]), "git ls-files");
|
|
292
|
+
const allPaths = [...new Set([...tracked, ...untracked])].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
293
|
+
assertRepositorySupported(repository.root, allPaths);
|
|
294
|
+
const statusBefore = gitBuffer(repository.root, ["status", "--porcelain=v2", "-z", "--untracked-files=all"]);
|
|
295
|
+
const fingerprintBefore = snapshotFingerprint(repository.root, allPaths);
|
|
296
|
+
const intent = splitNul(gitBuffer(repository.root, ["diff", "--name-only", "--diff-filter=A", "-z"]), "git diff")
|
|
297
|
+
.filter((relative) => tracked.includes(relative));
|
|
298
|
+
const indexMatchedBefore = indexMatchedFiles(repository.root, tracked, allPaths, intent);
|
|
299
|
+
|
|
300
|
+
const bundleRef = repository.branch ? `refs/heads/${repository.branch}` : "HEAD";
|
|
301
|
+
git(repository.root, ["bundle", "create", files.bundle, bundleRef]);
|
|
302
|
+
writePrivate(files.staged, gitBuffer(repository.root, ["diff", "--cached", "--binary", "--full-index", "--no-ext-diff"]));
|
|
303
|
+
writePrivate(files.working, gitBuffer(repository.root, ["diff", "--binary", "--full-index", "--no-ext-diff"]));
|
|
304
|
+
writePrivate(files.untrackedPaths, untracked.length ? Buffer.from(`${untracked.join("\0")}\0`, "utf8") : Buffer.alloc(0));
|
|
305
|
+
writePrivate(files.intent, intent.length ? Buffer.from(`${intent.join("\0")}\0`, "utf8") : Buffer.alloc(0));
|
|
306
|
+
const fileMetadata = inspectFiles(repository.root, allPaths);
|
|
307
|
+
const metadataByPath = new Map(fileMetadata.map((entry) => [entry.path, entry]));
|
|
308
|
+
const untrackedMetadata = untracked.map((relative) => metadataByPath.get(relative)).filter(Boolean);
|
|
309
|
+
createTar(files.untracked, fileMetadata.map((entry) => ({
|
|
310
|
+
source: path.join(repository.root, ...entry.path.split("/")),
|
|
311
|
+
path: entry.path,
|
|
312
|
+
mode: entry.mode === "100755" ? 0o755 : 0o644,
|
|
313
|
+
})));
|
|
314
|
+
|
|
315
|
+
const statusAfter = gitBuffer(repository.root, ["status", "--porcelain=v2", "-z", "--untracked-files=all"]);
|
|
316
|
+
const fingerprintAfter = snapshotFingerprint(repository.root, allPaths);
|
|
317
|
+
const indexMatchedAfter = indexMatchedFiles(repository.root, tracked, fileMetadata.map((entry) => entry.path), intent);
|
|
318
|
+
if (!statusBefore.equals(statusAfter) || fingerprintBefore !== fingerprintAfter
|
|
319
|
+
|| JSON.stringify(indexMatchedBefore) !== JSON.stringify(indexMatchedAfter)) {
|
|
320
|
+
throw new Error("repository changed while the workspace snapshot was being captured; retry");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const manifestCore = {
|
|
324
|
+
schemaVersion: WORKSPACE_SCHEMA_VERSION,
|
|
325
|
+
contentType: WORKSPACE_CONTENT_TYPE,
|
|
326
|
+
repository: {
|
|
327
|
+
origin: safeOrigin(repository.origin),
|
|
328
|
+
head: repository.head,
|
|
329
|
+
branch: repository.branch,
|
|
330
|
+
detached: !repository.branch,
|
|
331
|
+
subdirectory: repository.relativeProjectPath,
|
|
332
|
+
gitVersion: git(repository.root, ["--version"]).stdout.trim(),
|
|
333
|
+
workingTreeConfig: effectiveGitConfig(repository.root),
|
|
334
|
+
},
|
|
335
|
+
sourceStatus: statusAfter.toString("base64"),
|
|
336
|
+
files: fileMetadata,
|
|
337
|
+
untrackedFiles: untrackedMetadata,
|
|
338
|
+
indexMatchedFiles: indexMatchedAfter,
|
|
339
|
+
intentToAdd: intent,
|
|
340
|
+
parts: {
|
|
341
|
+
bundle: { name: names.bundle, ...part(files.bundle) },
|
|
342
|
+
stagedPatch: { name: names.staged, ...part(files.staged) },
|
|
343
|
+
workingPatch: { name: names.working, ...part(files.working) },
|
|
344
|
+
untrackedArchive: { name: names.untracked, ...part(files.untracked) },
|
|
345
|
+
untrackedPaths: { name: names.untrackedPaths, ...part(files.untrackedPaths) },
|
|
346
|
+
intentToAddPaths: { name: names.intent, ...part(files.intent) },
|
|
347
|
+
},
|
|
348
|
+
closedAt: new Date().toISOString(),
|
|
349
|
+
};
|
|
350
|
+
const rootManifestHash = sha256Buffer(Buffer.from(JSON.stringify(manifestCore), "utf8"));
|
|
351
|
+
const manifest = { ...manifestCore, rootManifestHash };
|
|
352
|
+
writePrivate(files.manifest, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
353
|
+
createTar(files.artifact, [
|
|
354
|
+
{ source: files.manifest, path: names.manifest, mode: 0o600 },
|
|
355
|
+
...Object.values(manifest.parts).map((entry) => ({
|
|
356
|
+
source: path.join(artifactRoot, entry.name), path: entry.name, mode: 0o600,
|
|
357
|
+
})),
|
|
358
|
+
]);
|
|
359
|
+
const artifactStat = fs.statSync(files.artifact);
|
|
360
|
+
return {
|
|
361
|
+
path: files.artifact,
|
|
362
|
+
length: artifactStat.size,
|
|
363
|
+
sha256: sha256File(files.artifact),
|
|
364
|
+
manifestHash: rootManifestHash,
|
|
365
|
+
manifest,
|
|
366
|
+
files,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function parseOctal(buffer) {
|
|
371
|
+
const text = buffer.toString("ascii").replace(/\0.*$/u, "").trim();
|
|
372
|
+
return text ? Number.parseInt(text, 8) : 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function restoreTar(archive, destination, expectedFiles) {
|
|
376
|
+
const descriptor = fs.openSync(archive, "r");
|
|
377
|
+
let offset = 0;
|
|
378
|
+
const restored = [];
|
|
379
|
+
const archiveLength = fs.fstatSync(descriptor).size;
|
|
380
|
+
try {
|
|
381
|
+
while (true) {
|
|
382
|
+
const header = Buffer.alloc(512);
|
|
383
|
+
if (fs.readSync(descriptor, header, 0, 512, offset) !== 512) throw new Error("truncated workspace tar header");
|
|
384
|
+
offset += 512;
|
|
385
|
+
if (header.every((byte) => byte === 0)) {
|
|
386
|
+
const trailing = Buffer.alloc(archiveLength - offset);
|
|
387
|
+
if (trailing.length && fs.readSync(descriptor, trailing, 0, trailing.length, offset) !== trailing.length) {
|
|
388
|
+
throw new Error("truncated workspace tar trailer");
|
|
389
|
+
}
|
|
390
|
+
if (trailing.some((byte) => byte !== 0)) throw new Error("workspace tar contains data after its trailer");
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
const checksumHeader = Buffer.from(header);
|
|
394
|
+
checksumHeader.fill(0x20, 148, 156);
|
|
395
|
+
const expectedChecksum = checksumHeader.reduce((sum, byte) => sum + byte, 0);
|
|
396
|
+
if (parseOctal(header.subarray(148, 156)) !== expectedChecksum) throw new Error("workspace tar header checksum mismatch");
|
|
397
|
+
let name;
|
|
398
|
+
let prefix;
|
|
399
|
+
try {
|
|
400
|
+
name = utf8.decode(header.subarray(0, 100)).replace(/\0.*$/u, "");
|
|
401
|
+
prefix = utf8.decode(header.subarray(345, 500)).replace(/\0.*$/u, "");
|
|
402
|
+
} catch {
|
|
403
|
+
throw new Error("workspace tar contains a path that is not valid UTF-8");
|
|
404
|
+
}
|
|
405
|
+
const relative = safeRelative(prefix ? `${prefix}/${name}` : name);
|
|
406
|
+
if (header[156] !== 0 && header[156] !== 0x30) throw new Error(`workspace tar contains unsupported entry ${relative}`);
|
|
407
|
+
const expected = expectedFiles[restored.length];
|
|
408
|
+
if (!expected || expected.path !== relative) throw new Error(`workspace tar contains unexpected entry ${relative}`);
|
|
409
|
+
const size = parseOctal(header.subarray(124, 136));
|
|
410
|
+
const mode = parseOctal(header.subarray(100, 108));
|
|
411
|
+
const expectedMode = expected.mode === "100755" ? 0o755 : 0o644;
|
|
412
|
+
if (!Number.isSafeInteger(size) || size < 0 || size !== expected.length || (mode & 0o777) !== expectedMode) {
|
|
413
|
+
throw new Error(`workspace tar metadata does not match the manifest for ${relative}`);
|
|
414
|
+
}
|
|
415
|
+
const target = path.resolve(destination, ...relative.split("/"));
|
|
416
|
+
const root = path.resolve(destination);
|
|
417
|
+
if (!target.startsWith(`${root}${path.sep}`)) throw new Error("workspace tar path escaped destination");
|
|
418
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
419
|
+
const prior = fs.lstatSync(target, { throwIfNoEntry: false });
|
|
420
|
+
if (prior && (!prior.isFile() || prior.isSymbolicLink() || prior.nlink !== 1)) {
|
|
421
|
+
throw new Error(`workspace tar refuses to replace unsafe file ${relative}`);
|
|
422
|
+
}
|
|
423
|
+
const output = fs.openSync(target, prior ? "w" : "wx", expectedMode);
|
|
424
|
+
try {
|
|
425
|
+
const opened = fs.fstatSync(output);
|
|
426
|
+
if (!opened.isFile() || opened.nlink !== 1) throw new Error(`workspace tar opened unsafe file ${relative}`);
|
|
427
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
428
|
+
const hash = crypto.createHash("sha256");
|
|
429
|
+
let copied = 0;
|
|
430
|
+
while (copied < size) {
|
|
431
|
+
const read = fs.readSync(descriptor, buffer, 0, Math.min(buffer.length, size - copied), offset + copied);
|
|
432
|
+
if (read === 0) throw new Error("truncated workspace tar entry");
|
|
433
|
+
fs.writeSync(output, buffer, 0, read);
|
|
434
|
+
hash.update(buffer.subarray(0, read));
|
|
435
|
+
copied += read;
|
|
436
|
+
}
|
|
437
|
+
if (hash.digest("hex") !== expected.sha256) throw new Error(`workspace tar hash does not match the manifest for ${relative}`);
|
|
438
|
+
try { fs.fchmodSync(output, expectedMode); } catch {
|
|
439
|
+
if (process.platform !== "win32") throw new Error(`workspace tar could not restore the mode for ${relative}`);
|
|
440
|
+
}
|
|
441
|
+
} finally { fs.closeSync(output); }
|
|
442
|
+
offset += Math.ceil(size / 512) * 512;
|
|
443
|
+
restored.push(relative);
|
|
444
|
+
}
|
|
445
|
+
} finally { fs.closeSync(descriptor); }
|
|
446
|
+
if (restored.length !== expectedFiles.length) throw new Error("workspace tar is missing manifest files");
|
|
447
|
+
return restored;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function restoreWorkspaceArtifact(snapshot, destination) {
|
|
451
|
+
const manifest = snapshot.manifest;
|
|
452
|
+
if (!manifest?.closedAt || manifest.schemaVersion !== WORKSPACE_SCHEMA_VERSION) throw new Error("workspace manifest is not closed");
|
|
453
|
+
const { rootManifestHash, ...core } = manifest;
|
|
454
|
+
if (sha256Buffer(Buffer.from(JSON.stringify(core), "utf8")) !== rootManifestHash) {
|
|
455
|
+
throw new Error("workspace root manifest hash mismatch");
|
|
456
|
+
}
|
|
457
|
+
for (const entry of Object.values(manifest.parts)) {
|
|
458
|
+
const filePath = path.join(path.dirname(snapshot.path), entry.name);
|
|
459
|
+
if (fs.statSync(filePath).size !== entry.length || sha256File(filePath) !== entry.sha256) {
|
|
460
|
+
throw new Error(`workspace part failed verification: ${entry.name}`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", ["clone", "--no-checkout", snapshot.files.bundle, destination]);
|
|
464
|
+
applyGitConfig(destination, manifest.repository.workingTreeConfig);
|
|
465
|
+
git(destination, ["checkout", "--detach", manifest.repository.head]);
|
|
466
|
+
if (manifest.repository.branch) git(destination, ["checkout", "-B", manifest.repository.branch, manifest.repository.head]);
|
|
467
|
+
if (manifest.repository.origin) git(destination, ["remote", "set-url", "origin", manifest.repository.origin]);
|
|
468
|
+
if (manifest.parts.stagedPatch.length > 0) git(destination, ["apply", "--index", "--binary", snapshot.files.staged]);
|
|
469
|
+
if (manifest.parts.workingPatch.length > 0) git(destination, ["apply", "--binary", snapshot.files.working]);
|
|
470
|
+
restoreTar(snapshot.files.untracked, destination, manifest.files);
|
|
471
|
+
for (const relative of manifest.intentToAdd) git(destination, ["add", "-N", "--", relative]);
|
|
472
|
+
refreshIndexStat(destination, manifest.indexMatchedFiles);
|
|
473
|
+
const restoredStatus = gitBuffer(destination, ["status", "--porcelain=v2", "-z", "--untracked-files=all"]);
|
|
474
|
+
if (restoredStatus.toString("base64") !== manifest.sourceStatus) {
|
|
475
|
+
throw new Error("restored workspace Git status does not match the source snapshot");
|
|
476
|
+
}
|
|
477
|
+
const restoredFiles = inspectFiles(destination, manifest.files.map((entry) => entry.path));
|
|
478
|
+
if (JSON.stringify(restoredFiles) !== JSON.stringify(manifest.files)) {
|
|
479
|
+
throw new Error("restored workspace file hashes do not match the source snapshot");
|
|
480
|
+
}
|
|
481
|
+
return { destination, manifest };
|
|
482
|
+
}
|
package/src/selfInvocation.js
CHANGED
|
@@ -98,20 +98,24 @@ export function impelNativeAgentMcpInvocation({
|
|
|
98
98
|
agentId,
|
|
99
99
|
scopeParam,
|
|
100
100
|
policyFingerprint,
|
|
101
|
-
|
|
101
|
+
mode = "durable",
|
|
102
102
|
}, options = {}) {
|
|
103
103
|
for (const [field, value] of Object.entries({ tenantId, agentId, scopeParam, policyFingerprint })) {
|
|
104
104
|
if (typeof value !== "string" || !value.trim()) {
|
|
105
105
|
throw new Error(`${field} is required for the managed native-agent MCP invocation`);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
if (!new Set(["durable", "recovery", "answer"]).has(mode)) {
|
|
109
|
+
throw new Error("native-agent MCP mode must be durable, recovery, or answer");
|
|
110
|
+
}
|
|
108
111
|
return impelMcpInvocation([
|
|
109
112
|
"--target", IMPEL_NATIVE_AGENT_MCP_TARGET,
|
|
110
113
|
"--tenant", tenantId,
|
|
111
114
|
"--agent-id", agentId,
|
|
112
115
|
"--scope-param", scopeParam,
|
|
113
116
|
"--policy-fingerprint", policyFingerprint,
|
|
114
|
-
...(
|
|
117
|
+
...(mode === "recovery" ? ["--recovery-only"] : []),
|
|
118
|
+
...(mode === "answer" ? ["--answer-only"] : []),
|
|
115
119
|
], options);
|
|
116
120
|
}
|
|
117
121
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export const VERBATIM_RELAY_OPT_IN_MARKER = "verbatimRelay enabled";
|
|
2
|
+
|
|
3
|
+
export const VERBATIM_SPAWN_REQUIREMENT = 'fork_turns="none"';
|
|
4
|
+
|
|
5
|
+
export const VERBATIM_FINAL_TEXT_CONSTRAINTS =
|
|
6
|
+
"no preface, rewriting, Markdown changes, or independent synthesis";
|
|
7
|
+
|
|
8
|
+
export function usesVerbatimRelay(agent) {
|
|
9
|
+
return agent?.verbatimRelay === true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function parentVerbatimRelayAppendix() {
|
|
13
|
+
return (
|
|
14
|
+
`When an explicit custom agent's catalog-derived description declares ${VERBATIM_RELAY_OPT_IN_MARKER}, ` +
|
|
15
|
+
`spawn it with ${VERBATIM_SPAWN_REQUIREMENT} and relay its finalText verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}; ` +
|
|
16
|
+
"preserve Sources sections and citations exactly. " +
|
|
17
|
+
"Custom agents without that declaration keep the default delegation behavior."
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function customAgentVerbatimDescriptionLead() {
|
|
22
|
+
return (
|
|
23
|
+
`Explicit custom agent with ${VERBATIM_RELAY_OPT_IN_MARKER}: ` +
|
|
24
|
+
`callers must use ${VERBATIM_SPAWN_REQUIREMENT} and relay its result verbatim`
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function adapterCallerSpawnGuidance(clientLabel) {
|
|
29
|
+
return (
|
|
30
|
+
`Callers must spawn this explicit custom ${clientLabel} agent with ${VERBATIM_SPAWN_REQUIREMENT} ` +
|
|
31
|
+
"and must relay your result verbatim; this is caller guidance and cannot enforce host spawn behavior."
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function adapterVerbatimCompletionGuidance() {
|
|
36
|
+
return (
|
|
37
|
+
`Return successful finalText verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
|
|
38
|
+
"A failure preserves the durable runId, output, and error."
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function adapterFaithfulCompletionGuidance() {
|
|
43
|
+
return (
|
|
44
|
+
"Return successful finalText faithfully as the answer. " +
|
|
45
|
+
"A failure preserves the durable runId, output, and error. " +
|
|
46
|
+
"Never invent or independently synthesize a replacement result."
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function claudeVerbatimCompletionGuidance() {
|
|
51
|
+
return (
|
|
52
|
+
`When it succeeds, return finalText verbatim with ${VERBATIM_FINAL_TEXT_CONSTRAINTS}. ` +
|
|
53
|
+
"When it fails, return the preserved runId, output, and error. " +
|
|
54
|
+
"Never invent or independently synthesize a replacement result."
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function claudeFaithfulCompletionGuidance() {
|
|
59
|
+
return (
|
|
60
|
+
"When it succeeds, return finalText faithfully as the answer. " +
|
|
61
|
+
"When it fails, return the preserved runId, output, and error. " +
|
|
62
|
+
"Never invent or independently synthesize a replacement result."
|
|
63
|
+
);
|
|
64
|
+
}
|