@zhivex-ai/harness 1.2.0 → 1.3.0-rc.7
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 +64 -0
- package/README.md +9 -10
- package/contracts/engine-api.json +2921 -0
- package/contracts/public-api.json +267 -1
- package/contracts/stable-api-signatures.json +43 -43
- package/dist/acp-cli.js +137 -15002
- package/dist/approvals/approval-history.d.ts +22 -0
- package/dist/approvals/explicit-review.d.ts +24 -0
- package/dist/approvals/host-policy-identity.d.ts +3 -0
- package/dist/cli/arguments.d.ts +6 -2
- package/dist/cli/checkpoints.d.ts +230 -0
- package/dist/cli/cli-options.d.ts +2 -41
- package/dist/cli/cli-profiles.d.ts +4 -2
- package/dist/cli/cli-stream.d.ts +2 -167
- package/dist/cli/governance-report.d.ts +5 -0
- package/dist/cli/policy-decisions.d.ts +9 -0
- package/dist/cli/policy.d.ts +5 -0
- package/dist/cli/presentation.d.ts +3 -1
- package/dist/cli/run-document.d.ts +2 -91
- package/dist/cli/terminal/terminal-continuation.d.ts +2 -3
- package/dist/cli/terminal/terminal-ui.d.ts +4 -0
- package/dist/{cli-entry-c2f58zj2.js → cli-entry-ae7x44d4.js} +14969 -10731
- package/dist/{cli-entry-427869g9.js → cli-entry-d5788aay.js} +388 -47
- package/dist/cli-entry-ev76ezcd.js +656 -0
- package/dist/{cli-3jmy4h5h.js → cli-t7hrhayq.js} +918 -2223
- package/dist/cli.js +3 -5
- package/dist/client/acp-mcp-admission.d.ts +32 -0
- package/dist/client/acp-mcp-host.d.ts +19 -0
- package/dist/client/acp-mcp-session.d.ts +14 -0
- package/dist/client/acp-stdio.d.ts +2 -0
- package/dist/client/acp.d.ts +4 -1
- package/dist/client/adapter.d.ts +13 -1
- package/dist/client/continuation.d.ts +3 -0
- package/dist/client/json-contracts.d.ts +107 -4
- package/dist/client/local-service.d.ts +2 -0
- package/dist/client/protocol.d.ts +127 -1
- package/dist/client/run-document.d.ts +91 -0
- package/dist/client/service-events.d.ts +4 -0
- package/dist/client/stream.d.ts +167 -0
- package/dist/compat/cli-options.d.ts +53 -0
- package/dist/compat/code-support.d.ts +22 -0
- package/dist/compat/code-support.js +74 -0
- package/dist/{config-1k9t4jhh.js → config-c002f06f.js} +1 -1
- package/dist/config-j3jga5wh.js +61 -0
- package/dist/context/adaptive-compaction.d.ts +1 -0
- package/dist/context/compaction.d.ts +1 -1
- package/dist/context/request-measurements.d.ts +17 -0
- package/dist/context/request-projection.d.ts +10 -0
- package/dist/context/task-memory.d.ts +1 -0
- package/dist/engine/acp.d.ts +6 -0
- package/dist/engine/acp.js +205 -0
- package/dist/engine/client.d.ts +6 -0
- package/dist/engine/client.js +28 -0
- package/dist/engine/desktop-providers-v1.d.ts +4 -0
- package/dist/engine/desktop-providers-v1.js +18 -0
- package/dist/engine/desktop-state-v1.d.ts +10 -0
- package/dist/engine/desktop-state-v1.js +168 -0
- package/dist/engine/index.d.ts +73 -0
- package/dist/engine/index.js +977 -0
- package/dist/engine/mcp-stdio-v1.d.ts +11 -0
- package/dist/engine/mcp-stdio-v1.js +108 -0
- package/dist/engine/models.d.ts +3 -0
- package/dist/engine/models.js +894 -0
- package/dist/engine/protocol.d.ts +7 -0
- package/dist/engine/protocol.js +182 -0
- package/dist/engine/service.d.ts +5 -0
- package/dist/engine/service.js +28 -0
- package/dist/execution/acceptance-snapshot.d.ts +2 -0
- package/dist/execution/execution-environment.d.ts +10 -0
- package/dist/execution/mcp-oci-channel.d.ts +11 -0
- package/dist/execution/mcp-oci-server.d.ts +38 -0
- package/dist/execution/mcp-oci-workspace.d.ts +23 -0
- package/dist/execution/mcp-resource-recovery.d.ts +19 -0
- package/dist/execution/mcp-workspace-snapshot.d.ts +20 -0
- package/dist/hostile-repository-demo.js +3368 -1304
- package/dist/index-1bx83sw4.js +4 -0
- package/dist/index-2gz7jv7g.js +549 -0
- package/dist/{cli-entry-b1mfvmxe.js → index-2jrdcj6b.js} +72 -115
- package/dist/index-3ah1f369.js +356 -0
- package/dist/index-42gp2hy8.js +1555 -0
- package/dist/index-4jw3tvy8.js +301 -0
- package/dist/index-8705zxpr.js +145 -0
- package/dist/index-8vm2m34s.js +2080 -0
- package/dist/index-8zzhggxv.js +477 -0
- package/dist/index-a97bezbz.js +1073 -0
- package/dist/index-gq9xagx2.js +1343 -0
- package/dist/index-h0mqx2ed.js +1373 -0
- package/dist/index-jgrx804e.js +5589 -0
- package/dist/index-qfpz5y2v.js +174 -0
- package/dist/index-t60rva31.js +1004 -0
- package/dist/index-wg1sd3at.js +80 -0
- package/dist/index-xqxeypt5.js +888 -0
- package/dist/index-yb239ghk.js +961 -0
- package/dist/index-z0qxmkm2.js +3389 -0
- package/dist/index-z4e8d0ph.js +1768 -0
- package/dist/index.js +251 -18649
- package/dist/integrations/mcp-host-session.d.ts +26 -0
- package/dist/integrations/mcp-stdio-admission.d.ts +54 -0
- package/dist/integrations/mcp-stdio-client.d.ts +44 -0
- package/dist/integrations/mcp-stdio-framing.d.ts +18 -0
- package/dist/integrations/mcp-stdio-launch.d.ts +20 -0
- package/dist/integrations/mcp-stdio-session.d.ts +7 -0
- package/dist/integrations/mcp-stdio-tools.d.ts +12 -0
- package/dist/internal/desktop/catalog.d.ts +2 -0
- package/dist/internal/desktop/persistence.d.ts +2 -3
- package/dist/internal/desktop/providers.d.ts +1 -0
- package/dist/models/catalog.d.ts +2 -0
- package/dist/persistence/governance-context.d.ts +126 -0
- package/dist/persistence/governance-report.d.ts +299 -0
- package/dist/persistence/host-database-backup.d.ts +28 -0
- package/dist/persistence/mcp-execution-journal.d.ts +19 -0
- package/dist/persistence/mcp-reconciliation.d.ts +33 -0
- package/dist/persistence/mcp-resource-journal.d.ts +54 -0
- package/dist/persistence/workspace-checkpoints.d.ts +138 -0
- package/dist/providers/anthropic-continuation.d.ts +5 -0
- package/dist/providers/providers.d.ts +1 -1
- package/dist/providers/vertex-auth.d.ts +27 -0
- package/dist/runtime/delegation-contracts.d.ts +9 -0
- package/dist/runtime/delegation-correction.d.ts +9 -0
- package/dist/runtime/delegation-result.d.ts +73 -0
- package/dist/runtime/error-diagnostics.d.ts +86 -0
- package/dist/runtime/harness.d.ts +11 -1
- package/dist/runtime/policy-decisions.d.ts +66 -0
- package/dist/runtime/policy-inspection.d.ts +52 -0
- package/dist/runtime/provider-diagnostics.d.ts +1 -0
- package/dist/runtime/runtime-instructions.d.ts +8 -0
- package/dist/runtime/task-acceptance-checks.d.ts +103 -0
- package/dist/runtime/task-acceptance-delivery.d.ts +107 -0
- package/dist/runtime/task-acceptance-host.d.ts +20 -0
- package/dist/runtime/task-acceptance-record.d.ts +107 -0
- package/dist/runtime/task-acceptance-validation.d.ts +25 -0
- package/dist/runtime/task-acceptance.d.ts +47 -0
- package/dist/runtime/time-to-safe-fix.d.ts +83 -0
- package/dist/runtime/tool-policy-file.d.ts +23 -0
- package/dist/runtime/tool-policy-paths.d.ts +5 -0
- package/dist/runtime/tool-policy.d.ts +6 -0
- package/dist/service-cli.js +19 -14504
- package/dist/{service-client-cli-yx56n0hc.js → service-client-cli-kns4mf57.js} +307 -248
- package/dist/verify-historical-migrations.js +3314 -1251
- package/dist/workspace/path-policy.d.ts +4 -0
- package/dist/zhx.js +3 -5
- package/docs/ACP.md +14 -2
- package/docs/ANTHROPIC.md +33 -0
- package/docs/CLI.md +132 -0
- package/docs/CLIENT_PROTOCOL.md +45 -0
- package/docs/CODE_RELEASE.md +93 -0
- package/docs/ENGINE_API.md +319 -0
- package/docs/EXTENSIBILITY.md +46 -3
- package/docs/FIRST_USE.md +6 -6
- package/docs/LOCAL_SERVICE.md +13 -0
- package/docs/MCP_STDIO.md +20 -0
- package/docs/README.md +6 -0
- package/docs/STABILITY.md +9 -1
- package/docs/SUPPORT_MATRIX.md +1 -1
- package/docs/TOOL_POLICY.md +81 -0
- package/docs/VERTEX.md +50 -0
- package/docs/adr/0002-isolated-mcp-stdio.md +135 -0
- package/docs/fixtures/mcp-stdio-contract-v1.json +189 -0
- package/docs/support-matrix.json +2 -0
- package/evaluations/representative-assembly-matrix.json +64 -1
- package/evaluations/task-acceptance-routes.json +7 -0
- package/examples/README.md +1 -1
- package/package.json +74 -12
- package/dist/cli-entry-64pdp3k5.js +0 -17
|
@@ -0,0 +1,3389 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FileSizeLimitError,
|
|
3
|
+
MAX_EDIT_CHANGES,
|
|
4
|
+
MAX_EDIT_PROPOSAL_BYTES,
|
|
5
|
+
SqliteDatabase,
|
|
6
|
+
UnsafeFileTypeError,
|
|
7
|
+
assertTaskAcceptanceImport,
|
|
8
|
+
confirmTaskAcceptanceImport,
|
|
9
|
+
createEditProposal,
|
|
10
|
+
fileDigestSchema,
|
|
11
|
+
moveFileInputSchema,
|
|
12
|
+
mutationAuditEntrySchema,
|
|
13
|
+
quarantineFileInputSchema,
|
|
14
|
+
readRegularFileNoFollow,
|
|
15
|
+
resolvePackageCheckCommand,
|
|
16
|
+
restoreFileInputSchema,
|
|
17
|
+
runPortableProcess,
|
|
18
|
+
statRegularFileNoFollow,
|
|
19
|
+
taskAcceptanceChecks,
|
|
20
|
+
validateEditProposal,
|
|
21
|
+
workspaceFilePathSchema
|
|
22
|
+
} from "./index-yb239ghk.js";
|
|
23
|
+
import {
|
|
24
|
+
HARNESS_EXECUTION_POLICY_VERSION,
|
|
25
|
+
HarnessError,
|
|
26
|
+
HarnessExecutionError,
|
|
27
|
+
HarnessStateConflictError,
|
|
28
|
+
HarnessWorkspaceError
|
|
29
|
+
} from "./index-gq9xagx2.js";
|
|
30
|
+
|
|
31
|
+
// src/workspace/workspace-mutation-lock.ts
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
34
|
+
import { lstat, mkdir, open, realpath } from "node:fs/promises";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
37
|
+
var ownership = new AsyncLocalStorage;
|
|
38
|
+
var withWorkspaceMutation = async (root, operation) => {
|
|
39
|
+
root = await realpath(root);
|
|
40
|
+
const inherited = ownership.getStore();
|
|
41
|
+
if (inherited?.get(root)?.active)
|
|
42
|
+
return operation();
|
|
43
|
+
const uid = process.getuid?.();
|
|
44
|
+
if (uid === undefined || !["darwin", "linux"].includes(process.platform)) {
|
|
45
|
+
throw new HarnessWorkspaceError("Workspace mutation locks require Linux or macOS user identity.");
|
|
46
|
+
}
|
|
47
|
+
const directory = path.join(process.platform === "darwin" ? "/private/tmp" : "/tmp", `zhivex-harness-mutation-locks-${uid}`);
|
|
48
|
+
await mkdir(directory, { mode: 448 }).catch((error) => {
|
|
49
|
+
if (error.code !== "EEXIST")
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
const entry = await lstat(directory);
|
|
53
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.uid !== uid || (entry.mode & 63) !== 0 || await realpath(directory) !== directory) {
|
|
54
|
+
throw new HarnessWorkspaceError("Workspace mutation lock requires a real state directory without symbolic links.");
|
|
55
|
+
}
|
|
56
|
+
const filename = path.join(directory, `${createHash("sha256").update(root).digest("hex")}.sqlite`);
|
|
57
|
+
try {
|
|
58
|
+
const created = await open(filename, "wx", 384);
|
|
59
|
+
await created.close();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error.code !== "EEXIST")
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
const before = await statRegularFileNoFollow(filename, { label: "Workspace mutation lock", requireSingleLink: true });
|
|
65
|
+
if (before.uid !== uid || (before.mode & 63) !== 0)
|
|
66
|
+
throw new HarnessWorkspaceError("Workspace mutation lock must be owner-only.");
|
|
67
|
+
const database = new SqliteDatabase(filename, { create: false });
|
|
68
|
+
let acquired = false;
|
|
69
|
+
const token = { active: true };
|
|
70
|
+
try {
|
|
71
|
+
const after = await statRegularFileNoFollow(filename, { label: "Workspace mutation lock", requireSingleLink: true });
|
|
72
|
+
if (before.dev !== after.dev || before.ino !== after.ino) {
|
|
73
|
+
throw new HarnessWorkspaceError("Workspace mutation lock changed while opening.");
|
|
74
|
+
}
|
|
75
|
+
database.exec("PRAGMA busy_timeout = 0");
|
|
76
|
+
const deadline = performance.now() + 15000;
|
|
77
|
+
for (;; ) {
|
|
78
|
+
try {
|
|
79
|
+
database.exec("BEGIN IMMEDIATE");
|
|
80
|
+
acquired = true;
|
|
81
|
+
break;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const message = error instanceof Error ? error.message : "";
|
|
84
|
+
if (!/database (?:is )?(?:locked|busy)/i.test(message))
|
|
85
|
+
throw error;
|
|
86
|
+
if (performance.now() >= deadline)
|
|
87
|
+
throw new HarnessStateConflictError("Workspace mutation is owned by another writer.");
|
|
88
|
+
await delay(25);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return await ownership.run(new Map([...inherited ?? [], [root, token]]), operation);
|
|
92
|
+
} finally {
|
|
93
|
+
token.active = false;
|
|
94
|
+
try {
|
|
95
|
+
if (acquired)
|
|
96
|
+
database.exec("ROLLBACK");
|
|
97
|
+
} finally {
|
|
98
|
+
database.close(false);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// src/workspace/replacement-edits.ts
|
|
104
|
+
import { z } from "zod";
|
|
105
|
+
var replacementEditSchema = z.strictObject({
|
|
106
|
+
path: workspaceFilePathSchema,
|
|
107
|
+
expectedDigest: fileDigestSchema,
|
|
108
|
+
oldText: z.string().min(1).max(64000),
|
|
109
|
+
newText: z.string().max(64000)
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// src/workspace/bounded-reads.ts
|
|
113
|
+
async function* boundedBatches(items, operation, concurrency = 8) {
|
|
114
|
+
if (!Number.isSafeInteger(concurrency) || concurrency < 1)
|
|
115
|
+
throw new Error("Invalid concurrency.");
|
|
116
|
+
for (let offset = 0;offset < items.length; offset += concurrency) {
|
|
117
|
+
const results = await Promise.allSettled(items.slice(offset, offset + concurrency).map(async (item) => operation(item)));
|
|
118
|
+
const failure = results.find((result) => result.status !== "fulfilled");
|
|
119
|
+
if (failure?.status === "rejected")
|
|
120
|
+
throw failure.reason;
|
|
121
|
+
yield results.map((result) => result.value);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/workspace/workspace.ts
|
|
126
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
127
|
+
import { link, lstat as lstat2, mkdir as mkdir2, open as open2, readdir, realpath as realpath2, rename, stat, unlink } from "node:fs/promises";
|
|
128
|
+
import path2 from "node:path";
|
|
129
|
+
|
|
130
|
+
// src/workspace/path-policy.ts
|
|
131
|
+
var HARD_IGNORES = new Set([".git", ".next", ".turbo", ".zhivex-harness", "coverage", "dist", "node_modules"]);
|
|
132
|
+
var isSensitiveName = (name) => {
|
|
133
|
+
const normalized = name.toLocaleLowerCase();
|
|
134
|
+
return normalized === ".env" || normalized.startsWith(".env.") && normalized !== ".env.example" || normalized === ".npmrc" || normalized === "id_rsa" || normalized === "id_ed25519" || [".key", ".pem", ".p12", ".pfx"].some((extension) => normalized.endsWith(extension));
|
|
135
|
+
};
|
|
136
|
+
var isHardIgnored = (relativePath) => relativePath.split("/").some((segment) => HARD_IGNORES.has(segment.toLocaleLowerCase()) || isSensitiveName(segment));
|
|
137
|
+
|
|
138
|
+
// src/workspace/workspace.ts
|
|
139
|
+
var IGNORE_FILES = [".gitignore", ".zhivex-harnessignore"];
|
|
140
|
+
var DEFAULT_CHECKS = ["test", "typecheck", "lint", "build"];
|
|
141
|
+
var MAX_FILE_BYTES = 1024 * 1024;
|
|
142
|
+
var MAX_TOOL_OUTPUT = 20000;
|
|
143
|
+
var MAX_READ_BATCH_FILES = 20;
|
|
144
|
+
var MAX_READ_BATCH_BYTES = 2 * MAX_FILE_BYTES;
|
|
145
|
+
var MAX_SEARCH_MANY_QUERIES = 10;
|
|
146
|
+
var MAX_SEARCH_MANY_MATCHES = 500;
|
|
147
|
+
var MAX_SEARCH_CHARACTERS = 28000;
|
|
148
|
+
var searchCoverage = () => ({
|
|
149
|
+
inspectedFiles: 0,
|
|
150
|
+
skippedFiles: 0,
|
|
151
|
+
incomplete: false,
|
|
152
|
+
skippedReasons: { tooLarge: 0, unsafe: 0, unreadable: 0 }
|
|
153
|
+
});
|
|
154
|
+
var skipSearchFile = (coverage, error) => {
|
|
155
|
+
coverage.skippedFiles++;
|
|
156
|
+
coverage.incomplete = true;
|
|
157
|
+
coverage.skippedReasons[error instanceof FileSizeLimitError ? "tooLarge" : error instanceof UnsafeFileTypeError ? "unsafe" : "unreadable"]++;
|
|
158
|
+
};
|
|
159
|
+
var MAX_GIT_DISCOVERY_OUTPUT = 1e6;
|
|
160
|
+
var MAX_GIT_DIFF_PATHS = 2048;
|
|
161
|
+
var MAX_GIT_DIFF_ARGV_CHARACTERS = 128000;
|
|
162
|
+
var GIT_SAFE_PREFIX = ["git", "-c", "core.fsmonitor=false"];
|
|
163
|
+
var isInside = (root, candidate) => {
|
|
164
|
+
const relative = path2.relative(root, candidate);
|
|
165
|
+
return relative === "" || !relative.startsWith(`..${path2.sep}`) && relative !== ".." && !path2.isAbsolute(relative);
|
|
166
|
+
};
|
|
167
|
+
var wirePath = (value) => value.split(path2.sep).join("/");
|
|
168
|
+
var digestBytes = (value) => `sha256:${createHash2("sha256").update(value).digest("hex")}`;
|
|
169
|
+
var truncate = (value, maxLength = MAX_TOOL_OUTPUT) => value.length <= maxLength ? value : `${value.slice(0, maxLength)}
|
|
170
|
+
… output truncated (${value.length - maxLength} characters omitted)`;
|
|
171
|
+
var cursorEncode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
172
|
+
var cursorDecode = (value) => {
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
175
|
+
} catch {
|
|
176
|
+
throw new HarnessWorkspaceError("The pagination cursor is invalid.");
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
var firstPathAtLeast = (files, target) => {
|
|
180
|
+
let low = 0;
|
|
181
|
+
let high = files.length;
|
|
182
|
+
while (low < high) {
|
|
183
|
+
const middle = low + Math.floor((high - low) / 2);
|
|
184
|
+
if ((files[middle]?.path ?? "") < target)
|
|
185
|
+
low = middle + 1;
|
|
186
|
+
else
|
|
187
|
+
high = middle;
|
|
188
|
+
}
|
|
189
|
+
return low;
|
|
190
|
+
};
|
|
191
|
+
var firstPathAfter = (files, target) => {
|
|
192
|
+
let low = 0;
|
|
193
|
+
let high = files.length;
|
|
194
|
+
while (low < high) {
|
|
195
|
+
const middle = low + Math.floor((high - low) / 2);
|
|
196
|
+
if ((files[middle]?.path ?? "") <= target)
|
|
197
|
+
low = middle + 1;
|
|
198
|
+
else
|
|
199
|
+
high = middle;
|
|
200
|
+
}
|
|
201
|
+
return low;
|
|
202
|
+
};
|
|
203
|
+
var escapeRegex = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
204
|
+
var globRegex = (pattern) => {
|
|
205
|
+
let source = "";
|
|
206
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
207
|
+
const character = pattern[index] ?? "";
|
|
208
|
+
if (character === "*") {
|
|
209
|
+
if (pattern[index + 1] === "*") {
|
|
210
|
+
index += 1;
|
|
211
|
+
if (pattern[index + 1] === "/") {
|
|
212
|
+
index += 1;
|
|
213
|
+
source += "(?:.*/)?";
|
|
214
|
+
} else {
|
|
215
|
+
source += ".*";
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
source += "[^/]*";
|
|
219
|
+
}
|
|
220
|
+
} else if (character === "?") {
|
|
221
|
+
source += "[^/]";
|
|
222
|
+
} else {
|
|
223
|
+
source += escapeRegex(character);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return source;
|
|
227
|
+
};
|
|
228
|
+
var parseIgnoreRules = (contents, base) => {
|
|
229
|
+
const rules = [];
|
|
230
|
+
for (const rawLine of contents.split(/\r?\n/)) {
|
|
231
|
+
let line = rawLine.trimEnd();
|
|
232
|
+
if (!line || line.startsWith("#"))
|
|
233
|
+
continue;
|
|
234
|
+
if (line.startsWith("\\#") || line.startsWith("\\!"))
|
|
235
|
+
line = line.slice(1);
|
|
236
|
+
const negated = line.startsWith("!");
|
|
237
|
+
if (negated)
|
|
238
|
+
line = line.slice(1);
|
|
239
|
+
if (!line)
|
|
240
|
+
continue;
|
|
241
|
+
const directoryOnly = line.endsWith("/");
|
|
242
|
+
if (directoryOnly)
|
|
243
|
+
line = line.slice(0, -1);
|
|
244
|
+
const anchored = line.startsWith("/");
|
|
245
|
+
if (anchored)
|
|
246
|
+
line = line.slice(1);
|
|
247
|
+
if (!line)
|
|
248
|
+
continue;
|
|
249
|
+
const source = globRegex(line);
|
|
250
|
+
rules.push({
|
|
251
|
+
base,
|
|
252
|
+
negated,
|
|
253
|
+
directoryOnly,
|
|
254
|
+
regex: anchored || line.includes("/") ? new RegExp(`^${source}$`) : new RegExp(`(?:^|/)${source}$`)
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
return rules;
|
|
258
|
+
};
|
|
259
|
+
var isIgnoredByRules = (relativePath, isDirectory, rules) => {
|
|
260
|
+
let ignored = false;
|
|
261
|
+
for (const rule of rules) {
|
|
262
|
+
if (rule.directoryOnly && !isDirectory)
|
|
263
|
+
continue;
|
|
264
|
+
const local = rule.base ? relativePath === rule.base ? "" : relativePath.startsWith(`${rule.base}/`) ? relativePath.slice(rule.base.length + 1) : undefined : relativePath;
|
|
265
|
+
if (local && rule.regex.test(local))
|
|
266
|
+
ignored = !rule.negated;
|
|
267
|
+
}
|
|
268
|
+
return ignored;
|
|
269
|
+
};
|
|
270
|
+
var spawnBounded = async (command, cwd, timeoutMs = 120000) => {
|
|
271
|
+
return await runPortableProcess(command, { cwd, timeoutMs });
|
|
272
|
+
};
|
|
273
|
+
var gitCommand = (...args) => [...GIT_SAFE_PREFIX, ...args];
|
|
274
|
+
var gitDiscovery = async (command, cwd) => await runPortableProcess(command, {
|
|
275
|
+
cwd,
|
|
276
|
+
timeoutMs: 15000,
|
|
277
|
+
maxOutputCharacters: MAX_GIT_DISCOVERY_OUTPUT,
|
|
278
|
+
stderr: "ignore"
|
|
279
|
+
});
|
|
280
|
+
var gitDiscoveryFailure = (result, label) => ({
|
|
281
|
+
command: result.command,
|
|
282
|
+
exitCode: result.exitCode,
|
|
283
|
+
stdout: "",
|
|
284
|
+
stderr: result.timedOut ? `${label} timed out without exposing repository-controlled output.` : `${label} failed without exposing repository-controlled output.`,
|
|
285
|
+
timedOut: result.timedOut
|
|
286
|
+
});
|
|
287
|
+
var nulFields = (result, label) => {
|
|
288
|
+
if (result.stdout === "")
|
|
289
|
+
return [];
|
|
290
|
+
if (!result.stdout.endsWith("\x00")) {
|
|
291
|
+
throw new HarnessWorkspaceError(`${label} exceeded its safe discovery bound or returned malformed output.`);
|
|
292
|
+
}
|
|
293
|
+
return result.stdout.slice(0, -1).split("\x00");
|
|
294
|
+
};
|
|
295
|
+
var safeGitPath = (candidate) => {
|
|
296
|
+
const normalized = wirePath(candidate);
|
|
297
|
+
return normalized !== "" && normalized !== "." && !path2.isAbsolute(candidate) && !normalized.startsWith("../") && !isHardIgnored(normalized);
|
|
298
|
+
};
|
|
299
|
+
var renderGitPath = (candidate) => /[\0\r\n\t]/.test(candidate) ? JSON.stringify(candidate) : candidate;
|
|
300
|
+
var filteredGitStatus = (result) => {
|
|
301
|
+
if (result.exitCode !== 0 || result.timedOut)
|
|
302
|
+
return gitDiscoveryFailure(result, "Git status");
|
|
303
|
+
const fields = nulFields(result, "Git status");
|
|
304
|
+
const lines = [];
|
|
305
|
+
for (let index = 0;index < fields.length; index += 1) {
|
|
306
|
+
const field = fields[index] ?? "";
|
|
307
|
+
if (field.length < 4 || field[2] !== " ") {
|
|
308
|
+
throw new HarnessWorkspaceError("Git status returned malformed porcelain output.");
|
|
309
|
+
}
|
|
310
|
+
const status = field.slice(0, 2);
|
|
311
|
+
const target = field.slice(3);
|
|
312
|
+
const renamed = status.includes("R") || status.includes("C");
|
|
313
|
+
const source = renamed ? fields[index += 1] : undefined;
|
|
314
|
+
if (!safeGitPath(target) || source !== undefined && !safeGitPath(source))
|
|
315
|
+
continue;
|
|
316
|
+
lines.push(source === undefined ? `${status} ${renderGitPath(target)}` : `${status} ${renderGitPath(source)} -> ${renderGitPath(target)}`);
|
|
317
|
+
}
|
|
318
|
+
return { ...result, stdout: lines.length === 0 ? "" : `${lines.join(`
|
|
319
|
+
`)}
|
|
320
|
+
`, stderr: "" };
|
|
321
|
+
};
|
|
322
|
+
var filteredGitDiffPaths = (result, label) => {
|
|
323
|
+
if (result.exitCode !== 0 || result.timedOut)
|
|
324
|
+
return gitDiscoveryFailure(result, label);
|
|
325
|
+
const fields = nulFields(result, label);
|
|
326
|
+
const paths = [];
|
|
327
|
+
for (let index = 0;index < fields.length; ) {
|
|
328
|
+
const status = fields[index++] ?? "";
|
|
329
|
+
if (!/^[ACDMRTUXB][0-9]*$/.test(status)) {
|
|
330
|
+
throw new HarnessWorkspaceError(`${label} returned malformed name-status output.`);
|
|
331
|
+
}
|
|
332
|
+
const count = status.startsWith("R") || status.startsWith("C") ? 2 : 1;
|
|
333
|
+
const changePaths = fields.slice(index, index + count);
|
|
334
|
+
if (changePaths.length !== count) {
|
|
335
|
+
throw new HarnessWorkspaceError(`${label} returned incomplete name-status output.`);
|
|
336
|
+
}
|
|
337
|
+
index += count;
|
|
338
|
+
if (changePaths.every(safeGitPath))
|
|
339
|
+
paths.push(...changePaths);
|
|
340
|
+
}
|
|
341
|
+
return [...new Set(paths)];
|
|
342
|
+
};
|
|
343
|
+
var emptyGitDiff = (command) => ({
|
|
344
|
+
command,
|
|
345
|
+
exitCode: 0,
|
|
346
|
+
stdout: "",
|
|
347
|
+
stderr: "",
|
|
348
|
+
timedOut: false
|
|
349
|
+
});
|
|
350
|
+
var runFilteredGitDiff = async (discovery, cwd, cached) => {
|
|
351
|
+
const label = cached ? "Git staged diff discovery" : "Git diff discovery";
|
|
352
|
+
const paths = filteredGitDiffPaths(discovery, label);
|
|
353
|
+
if (!Array.isArray(paths))
|
|
354
|
+
return paths;
|
|
355
|
+
const command = gitCommand("diff", ...cached ? ["--cached"] : [], "--no-ext-diff", "--no-textconv", "--ignore-submodules=all", "--src-prefix=a/", "--dst-prefix=b/", "--", ...paths);
|
|
356
|
+
if (paths.length === 0)
|
|
357
|
+
return emptyGitDiff(command);
|
|
358
|
+
if (paths.length > MAX_GIT_DIFF_PATHS || command.reduce((total, value) => total + value.length + 1, 0) > MAX_GIT_DIFF_ARGV_CHARACTERS) {
|
|
359
|
+
return {
|
|
360
|
+
...emptyGitDiff(command.slice(0, command.length - paths.length)),
|
|
361
|
+
exitCode: 1,
|
|
362
|
+
stderr: `${label} found too many changed paths to render safely.`
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return await spawnBounded(command, cwd, 15000);
|
|
366
|
+
};
|
|
367
|
+
var workspaceIndexFreshness = new WeakMap;
|
|
368
|
+
|
|
369
|
+
class Workspace {
|
|
370
|
+
auditPath;
|
|
371
|
+
root;
|
|
372
|
+
auditEntries = [];
|
|
373
|
+
indexIdentity = randomUUID();
|
|
374
|
+
indexRevision = 0;
|
|
375
|
+
workspaceIndex;
|
|
376
|
+
indexBuild;
|
|
377
|
+
indexMetrics = { builds: 0, reuses: 0, stableFileReads: 0 };
|
|
378
|
+
constructor(root, auditPath) {
|
|
379
|
+
this.auditPath = auditPath;
|
|
380
|
+
this.root = root;
|
|
381
|
+
}
|
|
382
|
+
static async open(root, options = {}) {
|
|
383
|
+
try {
|
|
384
|
+
const resolved = await realpath2(path2.resolve(root));
|
|
385
|
+
if (!(await stat(resolved)).isDirectory()) {
|
|
386
|
+
throw new HarnessWorkspaceError(`The workspace is not a directory: ${root}`);
|
|
387
|
+
}
|
|
388
|
+
const workspace = new Workspace(resolved, options.auditPath);
|
|
389
|
+
if (options.auditPath) {
|
|
390
|
+
try {
|
|
391
|
+
const data = await readRegularFileNoFollow(options.auditPath, { label: "Mutation audit", maxBytes: 8 * 1024 * 1024 });
|
|
392
|
+
const entries = JSON.parse(data.contents.toString("utf8"));
|
|
393
|
+
if (!Array.isArray(entries) || entries.length > 1e4)
|
|
394
|
+
throw new Error("Invalid mutation audit.");
|
|
395
|
+
workspace.auditEntries.push(...entries.map((entry) => mutationAuditEntrySchema.parse(entry)));
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (error.code !== "ENOENT")
|
|
398
|
+
throw error;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return workspace;
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof HarnessWorkspaceError)
|
|
404
|
+
throw error;
|
|
405
|
+
throw new HarnessWorkspaceError(`The workspace could not be opened safely: ${root}`, { cause: error });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
lexicalPath(relativePath) {
|
|
409
|
+
if (!relativePath || relativePath.includes("\x00"))
|
|
410
|
+
throw new HarnessWorkspaceError("The path must be a valid relative path.");
|
|
411
|
+
const candidate = path2.resolve(this.root, relativePath);
|
|
412
|
+
if (!isInside(this.root, candidate))
|
|
413
|
+
throw new HarnessWorkspaceError(`The path escapes the workspace: ${relativePath}`);
|
|
414
|
+
const relative = wirePath(path2.relative(this.root, candidate));
|
|
415
|
+
if (relative && isHardIgnored(relative)) {
|
|
416
|
+
const blocked = relative.split("/").find((segment) => HARD_IGNORES.has(segment.toLocaleLowerCase()) || isSensitiveName(segment));
|
|
417
|
+
throw new HarnessWorkspaceError(`The path is protected by the harness policy: ${blocked}`);
|
|
418
|
+
}
|
|
419
|
+
return candidate;
|
|
420
|
+
}
|
|
421
|
+
async safePath(relativePath, options = {}) {
|
|
422
|
+
const candidate = this.lexicalPath(relativePath);
|
|
423
|
+
if (candidate === this.root && options.requireFile)
|
|
424
|
+
throw new HarnessWorkspaceError("The workspace root is not a regular file.");
|
|
425
|
+
const segments = path2.relative(this.root, candidate).split(path2.sep).filter(Boolean);
|
|
426
|
+
let current = this.root;
|
|
427
|
+
for (let index = 0;index < segments.length; index += 1) {
|
|
428
|
+
current = path2.join(current, segments[index]);
|
|
429
|
+
const leaf = index === segments.length - 1;
|
|
430
|
+
if (leaf && options.skipLeafValidation)
|
|
431
|
+
continue;
|
|
432
|
+
let entry;
|
|
433
|
+
try {
|
|
434
|
+
entry = await lstat2(current);
|
|
435
|
+
} catch (error) {
|
|
436
|
+
if (options.allowMissing && error.code === "ENOENT") {
|
|
437
|
+
return { path: candidate, exists: false };
|
|
438
|
+
}
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
if (entry.isSymbolicLink()) {
|
|
442
|
+
throw new HarnessWorkspaceError(`The path resolves outside the workspace or through a symbolic link: ${relativePath}`);
|
|
443
|
+
}
|
|
444
|
+
if (!leaf && !entry.isDirectory())
|
|
445
|
+
throw new HarnessWorkspaceError(`A path ancestor is not a directory: ${relativePath}`);
|
|
446
|
+
if (leaf && options.requireFile && !entry.isFile())
|
|
447
|
+
throw new HarnessWorkspaceError("The path does not point to a regular file.");
|
|
448
|
+
}
|
|
449
|
+
return { path: candidate, exists: true };
|
|
450
|
+
}
|
|
451
|
+
async readStableFile(relativePath, allowBinary = true) {
|
|
452
|
+
this.indexMetrics.stableFileReads += 1;
|
|
453
|
+
const safe = await this.safePath(relativePath, { skipLeafValidation: true });
|
|
454
|
+
const file = await readRegularFileNoFollow(safe.path, {
|
|
455
|
+
label: "The file",
|
|
456
|
+
maxBytes: MAX_FILE_BYTES
|
|
457
|
+
});
|
|
458
|
+
const { contents } = file;
|
|
459
|
+
if (!allowBinary && contents.includes(0))
|
|
460
|
+
throw new HarnessWorkspaceError("The file appears to be binary and cannot be read as text.");
|
|
461
|
+
return {
|
|
462
|
+
path: wirePath(path2.relative(this.root, safe.path)),
|
|
463
|
+
absolutePath: safe.path,
|
|
464
|
+
contents,
|
|
465
|
+
digest: digestBytes(contents),
|
|
466
|
+
mode: file.stat.mode & 511
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
fingerprint(relativePath, kind, entry, digest) {
|
|
470
|
+
return {
|
|
471
|
+
path: relativePath || ".",
|
|
472
|
+
kind,
|
|
473
|
+
dev: entry.dev,
|
|
474
|
+
ino: entry.ino,
|
|
475
|
+
size: entry.size,
|
|
476
|
+
mtimeMs: entry.mtimeMs,
|
|
477
|
+
ctimeMs: entry.ctimeMs,
|
|
478
|
+
...digest ? { digest } : {}
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
sameFingerprint(entry, fingerprint) {
|
|
482
|
+
return entry.dev === fingerprint.dev && entry.ino === fingerprint.ino && entry.size === fingerprint.size && entry.mtimeMs === fingerprint.mtimeMs && entry.ctimeMs === fingerprint.ctimeMs;
|
|
483
|
+
}
|
|
484
|
+
async rulesForDirectory(directory, base, inherited, ignoreFiles) {
|
|
485
|
+
const rules = [...inherited];
|
|
486
|
+
for (const filename of IGNORE_FILES) {
|
|
487
|
+
try {
|
|
488
|
+
const ignorePath = path2.join(directory, filename);
|
|
489
|
+
const inspected = await statRegularFileNoFollow(ignorePath, { label: "The ignore file" });
|
|
490
|
+
if (inspected.size > MAX_FILE_BYTES) {
|
|
491
|
+
ignoreFiles.push(this.fingerprint(wirePath(path2.relative(this.root, ignorePath)), "ignore-file", inspected));
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const file = await readRegularFileNoFollow(ignorePath, {
|
|
495
|
+
label: "The ignore file",
|
|
496
|
+
maxBytes: MAX_FILE_BYTES
|
|
497
|
+
});
|
|
498
|
+
const digest = digestBytes(file.contents);
|
|
499
|
+
ignoreFiles.push(this.fingerprint(wirePath(path2.relative(this.root, ignorePath)), "ignore-file", file.stat, digest));
|
|
500
|
+
rules.push(...parseIgnoreRules(file.contents.toString("utf8"), base));
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (error.code !== "ENOENT" && !(error instanceof UnsafeFileTypeError))
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return rules;
|
|
507
|
+
}
|
|
508
|
+
async collectFiles(version) {
|
|
509
|
+
const files = [];
|
|
510
|
+
const directories = [];
|
|
511
|
+
const ignoreFiles = [];
|
|
512
|
+
let pending = [
|
|
513
|
+
{ directory: this.root, base: "", inherited: [] }
|
|
514
|
+
];
|
|
515
|
+
while (pending.length) {
|
|
516
|
+
const next = [];
|
|
517
|
+
for await (const batch of boundedBatches(pending, async ({ directory, base, inherited }) => {
|
|
518
|
+
const directoryEntry = await lstat2(directory);
|
|
519
|
+
if (directoryEntry.isSymbolicLink() || !directoryEntry.isDirectory()) {
|
|
520
|
+
throw new HarnessWorkspaceError(`A workspace directory changed while it was being indexed: ${base || "."}`);
|
|
521
|
+
}
|
|
522
|
+
directories.push(this.fingerprint(base, "directory", directoryEntry));
|
|
523
|
+
const rules = await this.rulesForDirectory(directory, base, inherited, ignoreFiles);
|
|
524
|
+
const children = [];
|
|
525
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
526
|
+
const relative = base ? `${base}/${entry.name}` : entry.name;
|
|
527
|
+
if (isHardIgnored(relative) || entry.isSymbolicLink() || isIgnoredByRules(relative, entry.isDirectory(), rules))
|
|
528
|
+
continue;
|
|
529
|
+
if (entry.isDirectory())
|
|
530
|
+
children.push({ directory: path2.join(directory, entry.name), base: relative, inherited: rules });
|
|
531
|
+
else if (entry.isFile())
|
|
532
|
+
files.push({ path: relative });
|
|
533
|
+
}
|
|
534
|
+
return children;
|
|
535
|
+
}))
|
|
536
|
+
for (const children of batch)
|
|
537
|
+
next.push(...children);
|
|
538
|
+
pending = next;
|
|
539
|
+
}
|
|
540
|
+
directories.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
541
|
+
ignoreFiles.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
542
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
543
|
+
return { version, files, directories, ignoreFiles };
|
|
544
|
+
}
|
|
545
|
+
async isWorkspaceIndexFresh(index) {
|
|
546
|
+
try {
|
|
547
|
+
for await (const batch of boundedBatches(index.directories, async (fingerprint) => {
|
|
548
|
+
const absolute = fingerprint.path === "." ? this.root : path2.join(this.root, fingerprint.path);
|
|
549
|
+
const entry = await lstat2(absolute);
|
|
550
|
+
return !entry.isSymbolicLink() && entry.isDirectory() && this.sameFingerprint(entry, fingerprint);
|
|
551
|
+
}))
|
|
552
|
+
if (batch.some((fresh) => !fresh))
|
|
553
|
+
return false;
|
|
554
|
+
for await (const batch of boundedBatches(index.ignoreFiles, async (fingerprint) => {
|
|
555
|
+
const absolute = path2.join(this.root, fingerprint.path);
|
|
556
|
+
if (fingerprint.digest) {
|
|
557
|
+
const file = await readRegularFileNoFollow(absolute, {
|
|
558
|
+
label: "The ignore file",
|
|
559
|
+
maxBytes: MAX_FILE_BYTES
|
|
560
|
+
});
|
|
561
|
+
return this.sameFingerprint(file.stat, fingerprint) && digestBytes(file.contents) === fingerprint.digest;
|
|
562
|
+
}
|
|
563
|
+
const inspected = await statRegularFileNoFollow(absolute, { label: "The ignore file" });
|
|
564
|
+
return this.sameFingerprint(inspected, fingerprint);
|
|
565
|
+
}))
|
|
566
|
+
if (batch.some((fresh) => !fresh))
|
|
567
|
+
return false;
|
|
568
|
+
return true;
|
|
569
|
+
} catch {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
invalidateWorkspaceIndex() {
|
|
574
|
+
this.indexRevision += 1;
|
|
575
|
+
this.workspaceIndex = undefined;
|
|
576
|
+
}
|
|
577
|
+
async buildWorkspaceIndex(revision) {
|
|
578
|
+
const version = `${this.indexIdentity}:${revision}`;
|
|
579
|
+
for (let attempt = 0;attempt < 3; attempt += 1) {
|
|
580
|
+
try {
|
|
581
|
+
const index = await this.collectFiles(version);
|
|
582
|
+
if (await this.isWorkspaceIndexFresh(index))
|
|
583
|
+
return index;
|
|
584
|
+
} catch (error) {
|
|
585
|
+
const code = error.code;
|
|
586
|
+
const changedDuringIndexing = error instanceof Error && error.message.includes("while the workspace was being indexed");
|
|
587
|
+
if (attempt === 2 || code !== "ENOENT" && !changedDuringIndexing)
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
throw new HarnessWorkspaceError("The workspace kept changing while its file index was being built.");
|
|
592
|
+
}
|
|
593
|
+
async getWorkspaceIndex() {
|
|
594
|
+
const cached = this.workspaceIndex;
|
|
595
|
+
if (cached) {
|
|
596
|
+
let freshness = workspaceIndexFreshness.get(this);
|
|
597
|
+
if (freshness?.index !== cached) {
|
|
598
|
+
const promise = this.isWorkspaceIndexFresh(cached).finally(() => {
|
|
599
|
+
if (workspaceIndexFreshness.get(this)?.promise === promise)
|
|
600
|
+
workspaceIndexFreshness.delete(this);
|
|
601
|
+
});
|
|
602
|
+
freshness = { index: cached, promise };
|
|
603
|
+
workspaceIndexFreshness.set(this, freshness);
|
|
604
|
+
}
|
|
605
|
+
const fresh = await freshness.promise;
|
|
606
|
+
if (this.workspaceIndex !== cached)
|
|
607
|
+
return this.getWorkspaceIndex();
|
|
608
|
+
if (fresh) {
|
|
609
|
+
this.indexMetrics.reuses += 1;
|
|
610
|
+
return cached;
|
|
611
|
+
}
|
|
612
|
+
this.invalidateWorkspaceIndex();
|
|
613
|
+
}
|
|
614
|
+
const revision = this.indexRevision;
|
|
615
|
+
if (!this.indexBuild || this.indexBuild.revision !== revision) {
|
|
616
|
+
const promise = this.buildWorkspaceIndex(revision);
|
|
617
|
+
this.indexBuild = { revision, promise };
|
|
618
|
+
}
|
|
619
|
+
const build = this.indexBuild;
|
|
620
|
+
let index;
|
|
621
|
+
try {
|
|
622
|
+
index = await build.promise;
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (this.indexBuild === build)
|
|
625
|
+
this.indexBuild = undefined;
|
|
626
|
+
throw error;
|
|
627
|
+
}
|
|
628
|
+
if (revision !== this.indexRevision)
|
|
629
|
+
return this.getWorkspaceIndex();
|
|
630
|
+
if (this.indexBuild === build)
|
|
631
|
+
this.indexBuild = undefined;
|
|
632
|
+
if (this.workspaceIndex?.version !== index.version) {
|
|
633
|
+
this.workspaceIndex = index;
|
|
634
|
+
this.indexMetrics.builds += 1;
|
|
635
|
+
}
|
|
636
|
+
return this.workspaceIndex;
|
|
637
|
+
}
|
|
638
|
+
workspaceIndexDiagnostics() {
|
|
639
|
+
const index = this.workspaceIndex;
|
|
640
|
+
return {
|
|
641
|
+
...this.indexMetrics,
|
|
642
|
+
...index ? { version: index.version } : {},
|
|
643
|
+
files: index?.files.length ?? 0,
|
|
644
|
+
directories: index?.directories.length ?? 0
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
async listFiles(relativePath = ".", options = {}) {
|
|
648
|
+
const start = await this.safePath(relativePath);
|
|
649
|
+
if (!(await lstat2(start.path)).isDirectory())
|
|
650
|
+
throw new HarnessWorkspaceError("list_files requires a directory.");
|
|
651
|
+
const requestPath = wirePath(path2.relative(this.root, start.path)) || ".";
|
|
652
|
+
const input = typeof options === "number" ? { limit: options } : options;
|
|
653
|
+
const limit = input.limit ?? 200;
|
|
654
|
+
const includeDigests = input.includeDigests ?? true;
|
|
655
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5000)
|
|
656
|
+
throw new HarnessWorkspaceError("limit must be between 1 and 5000.");
|
|
657
|
+
let after = "";
|
|
658
|
+
let cursorIndexVersion;
|
|
659
|
+
if (input.cursor) {
|
|
660
|
+
const parsed = cursorDecode(input.cursor);
|
|
661
|
+
if (parsed.v !== 2 || parsed.kind !== "list" || parsed.path !== requestPath || parsed.limit !== limit || parsed.includeDigests !== includeDigests || typeof parsed.indexVersion !== "string" || typeof parsed.after !== "string") {
|
|
662
|
+
throw new HarnessWorkspaceError("The pagination cursor does not match this list request.");
|
|
663
|
+
}
|
|
664
|
+
after = parsed.after;
|
|
665
|
+
cursorIndexVersion = parsed.indexVersion;
|
|
666
|
+
}
|
|
667
|
+
const index = await this.getWorkspaceIndex();
|
|
668
|
+
if (cursorIndexVersion && cursorIndexVersion !== index.version) {
|
|
669
|
+
throw new HarnessWorkspaceError("The pagination cursor is stale because the workspace changed.");
|
|
670
|
+
}
|
|
671
|
+
const prefix = requestPath === "." ? "" : `${requestPath}/`;
|
|
672
|
+
let candidateIndex = after ? firstPathAfter(index.files, after) : requestPath === "." ? 0 : firstPathAtLeast(index.files, prefix);
|
|
673
|
+
const selected = [];
|
|
674
|
+
while (candidateIndex < index.files.length && selected.length <= limit) {
|
|
675
|
+
const candidate = index.files[candidateIndex];
|
|
676
|
+
if (!candidate || requestPath !== "." && !candidate.path.startsWith(prefix))
|
|
677
|
+
break;
|
|
678
|
+
selected.push(candidate);
|
|
679
|
+
candidateIndex += 1;
|
|
680
|
+
}
|
|
681
|
+
const truncated = selected.length > limit;
|
|
682
|
+
if (truncated)
|
|
683
|
+
selected.pop();
|
|
684
|
+
const files = [];
|
|
685
|
+
if (includeDigests) {
|
|
686
|
+
for await (const batch of boundedBatches(selected, async (candidate) => {
|
|
687
|
+
const file = await this.readStableFile(candidate.path);
|
|
688
|
+
return { path: file.path, size: file.contents.byteLength, digest: file.digest };
|
|
689
|
+
}))
|
|
690
|
+
files.push(...batch);
|
|
691
|
+
} else {
|
|
692
|
+
files.push(...selected.map(({ path: candidatePath }) => ({ path: candidatePath })));
|
|
693
|
+
}
|
|
694
|
+
const last = selected.at(-1);
|
|
695
|
+
return {
|
|
696
|
+
files,
|
|
697
|
+
truncated,
|
|
698
|
+
...truncated && last ? {
|
|
699
|
+
nextCursor: cursorEncode({
|
|
700
|
+
v: 2,
|
|
701
|
+
kind: "list",
|
|
702
|
+
path: requestPath,
|
|
703
|
+
limit,
|
|
704
|
+
includeDigests,
|
|
705
|
+
after: last.path,
|
|
706
|
+
indexVersion: index.version
|
|
707
|
+
})
|
|
708
|
+
} : {}
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
renderReadFile(file, startLine = 1, endLine, maxCharacters = 16000) {
|
|
712
|
+
if (!Number.isSafeInteger(startLine) || startLine < 1)
|
|
713
|
+
throw new HarnessWorkspaceError("startLine must be a positive integer.");
|
|
714
|
+
if (endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine < startLine)) {
|
|
715
|
+
throw new HarnessWorkspaceError("endLine must be greater than or equal to startLine.");
|
|
716
|
+
}
|
|
717
|
+
const lines = file.contents.toString("utf8").split(/\r?\n/);
|
|
718
|
+
const boundedEnd = Math.min(endLine ?? startLine + 119, startLine + 1999, lines.length);
|
|
719
|
+
const rendered = [];
|
|
720
|
+
let characters = 0;
|
|
721
|
+
let clippedLine = false;
|
|
722
|
+
for (let index = startLine - 1;index < boundedEnd; index++) {
|
|
723
|
+
const line = `${index + 1}: ${lines[index]}`;
|
|
724
|
+
const remaining = maxCharacters - characters - (rendered.length ? 1 : 0);
|
|
725
|
+
if (line.length > remaining && rendered.length)
|
|
726
|
+
break;
|
|
727
|
+
rendered.push(line.slice(0, remaining));
|
|
728
|
+
characters += Math.min(line.length, remaining) + (rendered.length > 1 ? 1 : 0);
|
|
729
|
+
if (line.length > remaining) {
|
|
730
|
+
clippedLine = true;
|
|
731
|
+
break;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const returnedEnd = rendered.length ? startLine + rendered.length - 1 : boundedEnd;
|
|
735
|
+
return {
|
|
736
|
+
path: file.path,
|
|
737
|
+
digest: file.digest,
|
|
738
|
+
startLine,
|
|
739
|
+
endLine: returnedEnd,
|
|
740
|
+
totalLines: lines.length,
|
|
741
|
+
content: rendered.join(`
|
|
742
|
+
`),
|
|
743
|
+
clippedLine,
|
|
744
|
+
truncated: clippedLine || returnedEnd < lines.length
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
async readFile(relativePath, startLine = 1, endLine) {
|
|
748
|
+
return this.renderReadFile(await this.readStableFile(relativePath, false), startLine, endLine);
|
|
749
|
+
}
|
|
750
|
+
async readFiles(requests) {
|
|
751
|
+
if (!Array.isArray(requests) || requests.length < 1 || requests.length > MAX_READ_BATCH_FILES) {
|
|
752
|
+
throw new HarnessWorkspaceError(`readFiles requires between 1 and ${MAX_READ_BATCH_FILES} file requests.`);
|
|
753
|
+
}
|
|
754
|
+
const ordered = requests.map((request) => ({
|
|
755
|
+
...request,
|
|
756
|
+
path: wirePath(path2.relative(this.root, this.lexicalPath(request.path)))
|
|
757
|
+
})).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0) || (a.startLine ?? 1) - (b.startLine ?? 1) || (a.endLine ?? 0) - (b.endLine ?? 0));
|
|
758
|
+
for (const request of ordered) {
|
|
759
|
+
if (!Number.isSafeInteger(request.startLine ?? 1) || (request.startLine ?? 1) < 1 || request.endLine !== undefined && (!Number.isSafeInteger(request.endLine) || request.endLine < (request.startLine ?? 1))) {
|
|
760
|
+
throw new HarnessWorkspaceError(`Invalid line range for ${request.path}.`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const stableFiles = new Map;
|
|
764
|
+
let totalBytes = 0;
|
|
765
|
+
for await (const batch of boundedBatches([...new Set(ordered.map((request) => request.path))], (filePath) => this.readStableFile(filePath, false), 4)) {
|
|
766
|
+
for (const file of batch) {
|
|
767
|
+
totalBytes += file.contents.byteLength;
|
|
768
|
+
if (totalBytes > MAX_READ_BATCH_BYTES) {
|
|
769
|
+
throw new HarnessWorkspaceError(`readFiles exceeds the aggregate ${MAX_READ_BATCH_BYTES}-byte source limit.`);
|
|
770
|
+
}
|
|
771
|
+
stableFiles.set(file.path, file);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return {
|
|
775
|
+
files: ordered.map((request) => this.renderReadFile(stableFiles.get(request.path), request.startLine ?? 1, request.endLine, Math.min(16000, Math.floor(32000 / ordered.length)))),
|
|
776
|
+
sourceBytes: totalBytes
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
async inspectFile(relativePath) {
|
|
780
|
+
const file = await this.readStableFile(relativePath);
|
|
781
|
+
return {
|
|
782
|
+
path: file.path,
|
|
783
|
+
digest: file.digest,
|
|
784
|
+
mode: file.mode,
|
|
785
|
+
bytes: file.contents.byteLength
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
async searchFiles(query, relativePath = ".", options = {}) {
|
|
789
|
+
if (!query || query.length > 200)
|
|
790
|
+
throw new HarnessWorkspaceError("The search query must be between 1 and 200 characters.");
|
|
791
|
+
const start = await this.safePath(relativePath);
|
|
792
|
+
const isDirectory = (await lstat2(start.path)).isDirectory();
|
|
793
|
+
const requestPath = wirePath(path2.relative(this.root, start.path)) || ".";
|
|
794
|
+
const limit = options.limit ?? options.maxMatches ?? 100;
|
|
795
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500)
|
|
796
|
+
throw new HarnessWorkspaceError("limit must be between 1 and 500.");
|
|
797
|
+
const caseSensitive = options.caseSensitive ?? false;
|
|
798
|
+
let afterPath = "";
|
|
799
|
+
let afterLine = 0;
|
|
800
|
+
let cursorIndexVersion;
|
|
801
|
+
if (options.cursor) {
|
|
802
|
+
const parsed = cursorDecode(options.cursor);
|
|
803
|
+
if (parsed.v !== 2 || parsed.kind !== "search" || parsed.path !== requestPath || parsed.query !== query || parsed.caseSensitive !== caseSensitive || parsed.limit !== limit || typeof parsed.afterPath !== "string" || !Number.isSafeInteger(parsed.afterLine) || typeof parsed.indexVersion !== "string") {
|
|
804
|
+
throw new HarnessWorkspaceError("The pagination cursor does not match this search request.");
|
|
805
|
+
}
|
|
806
|
+
afterPath = parsed.afterPath;
|
|
807
|
+
afterLine = parsed.afterLine;
|
|
808
|
+
cursorIndexVersion = parsed.indexVersion;
|
|
809
|
+
}
|
|
810
|
+
const directFile = isDirectory ? undefined : await this.readStableFile(relativePath, false);
|
|
811
|
+
const index = isDirectory ? await this.getWorkspaceIndex() : { version: directFile.digest, files: [{ path: requestPath }] };
|
|
812
|
+
if (cursorIndexVersion && cursorIndexVersion !== index.version) {
|
|
813
|
+
throw new HarnessWorkspaceError("The pagination cursor is stale because the workspace changed.");
|
|
814
|
+
}
|
|
815
|
+
const prefix = requestPath === "." ? "" : `${requestPath}/`;
|
|
816
|
+
const needle = caseSensitive ? query : query.toLocaleLowerCase();
|
|
817
|
+
const matches = [];
|
|
818
|
+
let hasMore = false;
|
|
819
|
+
let characters = 0;
|
|
820
|
+
const coverage = searchCoverage();
|
|
821
|
+
const candidateIndex = afterPath ? firstPathAtLeast(index.files, afterPath) : requestPath === "." ? 0 : firstPathAtLeast(index.files, prefix);
|
|
822
|
+
for await (const batch of boundedBatches(isDirectory ? index.files.slice(candidateIndex, requestPath === "." ? undefined : firstPathAtLeast(index.files, `${requestPath}0`)) : [{ path: requestPath }], async (candidate) => {
|
|
823
|
+
try {
|
|
824
|
+
const file = directFile ?? await this.readStableFile(candidate.path, false);
|
|
825
|
+
coverage.inspectedFiles++;
|
|
826
|
+
return file;
|
|
827
|
+
} catch (error) {
|
|
828
|
+
skipSearchFile(coverage, error);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
})) {
|
|
832
|
+
for (const file of batch) {
|
|
833
|
+
if (!file)
|
|
834
|
+
continue;
|
|
835
|
+
const candidate = file;
|
|
836
|
+
const lines = file.contents.toString("utf8").split(/\r?\n/);
|
|
837
|
+
for (let index2 = 0;index2 < lines.length; index2 += 1) {
|
|
838
|
+
const lineNumber = index2 + 1;
|
|
839
|
+
if (candidate.path === afterPath && lineNumber <= afterLine)
|
|
840
|
+
continue;
|
|
841
|
+
const line = lines[index2] ?? "";
|
|
842
|
+
if (!(caseSensitive ? line : line.toLocaleLowerCase()).includes(needle))
|
|
843
|
+
continue;
|
|
844
|
+
const match = { path: candidate.path, line: lineNumber, text: truncate(line, 500), digest: file.digest };
|
|
845
|
+
const size = JSON.stringify(match).length + 1;
|
|
846
|
+
if (matches.length >= limit || characters + size > MAX_SEARCH_CHARACTERS) {
|
|
847
|
+
hasMore = true;
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
matches.push(match);
|
|
851
|
+
characters += size;
|
|
852
|
+
}
|
|
853
|
+
if (hasMore)
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
if (hasMore)
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
const render = () => {
|
|
860
|
+
const last = matches.at(-1);
|
|
861
|
+
return {
|
|
862
|
+
matches,
|
|
863
|
+
coverage: { ...coverage, incomplete: coverage.incomplete || hasMore },
|
|
864
|
+
truncated: hasMore,
|
|
865
|
+
...hasMore && last ? {
|
|
866
|
+
nextCursor: cursorEncode({
|
|
867
|
+
v: 2,
|
|
868
|
+
kind: "search",
|
|
869
|
+
path: requestPath,
|
|
870
|
+
query,
|
|
871
|
+
caseSensitive,
|
|
872
|
+
limit,
|
|
873
|
+
afterPath: last.path,
|
|
874
|
+
afterLine: last.line,
|
|
875
|
+
indexVersion: index.version
|
|
876
|
+
})
|
|
877
|
+
} : {}
|
|
878
|
+
};
|
|
879
|
+
};
|
|
880
|
+
while (JSON.stringify(render()).length > 32000) {
|
|
881
|
+
if (!matches.length)
|
|
882
|
+
throw new HarnessWorkspaceError("Search metadata exceeds the output limit; narrow the request.");
|
|
883
|
+
matches.pop();
|
|
884
|
+
hasMore = true;
|
|
885
|
+
}
|
|
886
|
+
return render();
|
|
887
|
+
}
|
|
888
|
+
async searchMany(queries, relativePath = ".", options = {}) {
|
|
889
|
+
if (!Array.isArray(queries) || queries.length < 1 || queries.length > MAX_SEARCH_MANY_QUERIES) {
|
|
890
|
+
throw new HarnessWorkspaceError(`searchMany requires between 1 and ${MAX_SEARCH_MANY_QUERIES} queries.`);
|
|
891
|
+
}
|
|
892
|
+
const limitPerQuery = options.limitPerQuery ?? 50;
|
|
893
|
+
if (!Number.isSafeInteger(limitPerQuery) || limitPerQuery < 1 || limitPerQuery > MAX_SEARCH_MANY_MATCHES || limitPerQuery * queries.length > MAX_SEARCH_MANY_MATCHES) {
|
|
894
|
+
throw new HarnessWorkspaceError(`searchMany allows at most ${MAX_SEARCH_MANY_MATCHES} aggregate matches.`);
|
|
895
|
+
}
|
|
896
|
+
const coverage = searchCoverage();
|
|
897
|
+
let characters = 0;
|
|
898
|
+
let outputFull = false;
|
|
899
|
+
const seen = new Set;
|
|
900
|
+
const states = queries.map((input) => {
|
|
901
|
+
if (!input.query || input.query.length > 200)
|
|
902
|
+
throw new HarnessWorkspaceError("Each search query must be between 1 and 200 characters.");
|
|
903
|
+
const caseSensitive = input.caseSensitive ?? false;
|
|
904
|
+
const key = `${caseSensitive ? "1" : "0"}\x00${input.query}`;
|
|
905
|
+
if (seen.has(key))
|
|
906
|
+
throw new HarnessWorkspaceError("searchMany queries must be unique.");
|
|
907
|
+
seen.add(key);
|
|
908
|
+
return {
|
|
909
|
+
query: input.query,
|
|
910
|
+
caseSensitive,
|
|
911
|
+
needle: caseSensitive ? input.query : input.query.toLocaleLowerCase(),
|
|
912
|
+
matches: [],
|
|
913
|
+
truncated: false
|
|
914
|
+
};
|
|
915
|
+
});
|
|
916
|
+
const start = await this.safePath(relativePath);
|
|
917
|
+
const isDirectory = (await lstat2(start.path)).isDirectory();
|
|
918
|
+
const requestPath = wirePath(path2.relative(this.root, start.path)) || ".";
|
|
919
|
+
const prefix = requestPath === "." ? "" : `${requestPath}/`;
|
|
920
|
+
const index = isDirectory ? await this.getWorkspaceIndex() : undefined;
|
|
921
|
+
const candidateIndex = requestPath === "." ? 0 : firstPathAtLeast(index?.files ?? [], prefix);
|
|
922
|
+
const candidates = index ? index.files.slice(candidateIndex, requestPath === "." ? undefined : firstPathAtLeast(index.files, `${requestPath}0`)) : [{ path: requestPath }];
|
|
923
|
+
for await (const batch of boundedBatches(candidates, async (candidate) => {
|
|
924
|
+
try {
|
|
925
|
+
const file = await this.readStableFile(candidate.path, false);
|
|
926
|
+
coverage.inspectedFiles++;
|
|
927
|
+
return file;
|
|
928
|
+
} catch (error) {
|
|
929
|
+
if (!isDirectory)
|
|
930
|
+
throw error;
|
|
931
|
+
skipSearchFile(coverage, error);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
})) {
|
|
935
|
+
for (const file of batch) {
|
|
936
|
+
if (!file)
|
|
937
|
+
continue;
|
|
938
|
+
const candidate = file;
|
|
939
|
+
const lines = file.contents.toString("utf8").split(/\r?\n/);
|
|
940
|
+
for (let lineIndex = 0;lineIndex < lines.length; lineIndex += 1) {
|
|
941
|
+
const line = lines[lineIndex] ?? "";
|
|
942
|
+
const foldedLine = line.toLocaleLowerCase();
|
|
943
|
+
for (const state of states) {
|
|
944
|
+
if (state.truncated || !(state.caseSensitive ? line : foldedLine).includes(state.needle))
|
|
945
|
+
continue;
|
|
946
|
+
if (state.matches.length >= limitPerQuery) {
|
|
947
|
+
state.truncated = true;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
const match = {
|
|
951
|
+
path: candidate.path,
|
|
952
|
+
line: lineIndex + 1,
|
|
953
|
+
text: truncate(line, 500),
|
|
954
|
+
digest: file.digest
|
|
955
|
+
};
|
|
956
|
+
const size = JSON.stringify(match).length + 1;
|
|
957
|
+
if (characters + size > MAX_SEARCH_CHARACTERS) {
|
|
958
|
+
outputFull = true;
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
state.matches.push(match);
|
|
962
|
+
characters += size;
|
|
963
|
+
}
|
|
964
|
+
if (outputFull)
|
|
965
|
+
break;
|
|
966
|
+
}
|
|
967
|
+
if (outputFull || states.every((state) => state.truncated))
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
if (outputFull || states.every((state) => state.truncated))
|
|
971
|
+
break;
|
|
972
|
+
}
|
|
973
|
+
const render = () => ({
|
|
974
|
+
coverage: { ...coverage, incomplete: coverage.incomplete || outputFull || states.some((state) => state.truncated) },
|
|
975
|
+
outputTruncated: outputFull,
|
|
976
|
+
continuation: "For a truncated query, use search_files with that query and its nextCursor, or narrow to the returned file/line locations.",
|
|
977
|
+
results: states.map(({ needle: _needle, ...state }) => {
|
|
978
|
+
const last = state.matches.at(-1);
|
|
979
|
+
const truncated = state.truncated || outputFull;
|
|
980
|
+
return { ...state, truncated, ...truncated && last ? { nextCursor: cursorEncode({
|
|
981
|
+
v: 2,
|
|
982
|
+
kind: "search",
|
|
983
|
+
path: requestPath,
|
|
984
|
+
query: state.query,
|
|
985
|
+
caseSensitive: state.caseSensitive,
|
|
986
|
+
limit: limitPerQuery,
|
|
987
|
+
afterPath: last.path,
|
|
988
|
+
afterLine: last.line,
|
|
989
|
+
indexVersion: index?.version ?? last.digest
|
|
990
|
+
}) } : {} };
|
|
991
|
+
})
|
|
992
|
+
});
|
|
993
|
+
while (JSON.stringify(render()).length > 32000) {
|
|
994
|
+
const largest = states.filter((state) => state.matches.length).sort((a, b) => JSON.stringify(b.matches).length - JSON.stringify(a.matches).length)[0];
|
|
995
|
+
if (!largest)
|
|
996
|
+
throw new HarnessWorkspaceError("Search metadata exceeds the output limit; narrow the request.");
|
|
997
|
+
largest.matches.pop();
|
|
998
|
+
largest.truncated = true;
|
|
999
|
+
outputFull = true;
|
|
1000
|
+
}
|
|
1001
|
+
return render();
|
|
1002
|
+
}
|
|
1003
|
+
checkAuditCapacity(count = 1) {
|
|
1004
|
+
if (this.auditPath && this.auditEntries.length + count > 1e4)
|
|
1005
|
+
throw new HarnessWorkspaceError("Mutation audit capacity exceeded; retain evidence and start a new run.");
|
|
1006
|
+
}
|
|
1007
|
+
async audit(entry) {
|
|
1008
|
+
const record = { id: randomUUID(), timestamp: new Date().toISOString(), ...entry };
|
|
1009
|
+
this.auditEntries.push(record);
|
|
1010
|
+
if (this.auditPath) {
|
|
1011
|
+
const temporary = `${this.auditPath}.${randomUUID()}.tmp`;
|
|
1012
|
+
const handle = await open2(temporary, "wx", 384);
|
|
1013
|
+
try {
|
|
1014
|
+
await handle.writeFile(JSON.stringify(this.auditEntries));
|
|
1015
|
+
await handle.sync();
|
|
1016
|
+
} finally {
|
|
1017
|
+
await handle.close();
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
await rename(temporary, this.auditPath);
|
|
1021
|
+
} finally {
|
|
1022
|
+
await unlink(temporary).catch(() => {});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
return record;
|
|
1026
|
+
}
|
|
1027
|
+
mutationAudit() {
|
|
1028
|
+
return this.auditEntries.map((entry) => ({ ...entry }));
|
|
1029
|
+
}
|
|
1030
|
+
async secureWorkspaceDirectory(directory, mode = 493) {
|
|
1031
|
+
const resolvedInput = path2.resolve(directory);
|
|
1032
|
+
if (!isInside(this.root, resolvedInput)) {
|
|
1033
|
+
throw new HarnessWorkspaceError("A mutation directory escapes the workspace.");
|
|
1034
|
+
}
|
|
1035
|
+
const segments = path2.relative(this.root, resolvedInput).split(path2.sep).filter(Boolean);
|
|
1036
|
+
let current = this.root;
|
|
1037
|
+
for (const segment of segments) {
|
|
1038
|
+
current = path2.join(current, segment);
|
|
1039
|
+
try {
|
|
1040
|
+
const entry = await lstat2(current);
|
|
1041
|
+
if (entry.isSymbolicLink())
|
|
1042
|
+
throw new HarnessWorkspaceError("A mutation directory must not contain a symbolic link.");
|
|
1043
|
+
if (!entry.isDirectory())
|
|
1044
|
+
throw new HarnessWorkspaceError("A mutation directory contains a non-directory entry.");
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
if (error.code !== "ENOENT")
|
|
1047
|
+
throw error;
|
|
1048
|
+
try {
|
|
1049
|
+
await mkdir2(current, { mode });
|
|
1050
|
+
} catch (mkdirError) {
|
|
1051
|
+
if (mkdirError.code !== "EEXIST")
|
|
1052
|
+
throw mkdirError;
|
|
1053
|
+
const raced = await lstat2(current);
|
|
1054
|
+
if (raced.isSymbolicLink() || !raced.isDirectory()) {
|
|
1055
|
+
throw new HarnessWorkspaceError("A mutation directory changed during creation.");
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const canonical = await realpath2(current);
|
|
1061
|
+
if (!isInside(this.root, canonical))
|
|
1062
|
+
throw new HarnessWorkspaceError("A mutation directory resolves outside the workspace.");
|
|
1063
|
+
return canonical;
|
|
1064
|
+
}
|
|
1065
|
+
async stageFile(target, contents, mode) {
|
|
1066
|
+
const directory = await this.secureWorkspaceDirectory(path2.dirname(target));
|
|
1067
|
+
const temporary = path2.join(directory, `.${path2.basename(target)}.zhivex-${randomUUID()}.tmp`);
|
|
1068
|
+
const handle = await open2(temporary, "wx", mode);
|
|
1069
|
+
try {
|
|
1070
|
+
await handle.writeFile(contents);
|
|
1071
|
+
await handle.chmod(mode);
|
|
1072
|
+
await handle.sync();
|
|
1073
|
+
} catch (error) {
|
|
1074
|
+
await handle.close();
|
|
1075
|
+
await unlink(temporary).catch(() => {});
|
|
1076
|
+
throw error;
|
|
1077
|
+
}
|
|
1078
|
+
await handle.close();
|
|
1079
|
+
return temporary;
|
|
1080
|
+
}
|
|
1081
|
+
async previewPatch(input) {
|
|
1082
|
+
const proposal = validateEditProposal(input);
|
|
1083
|
+
const files = [];
|
|
1084
|
+
let bytes = 0;
|
|
1085
|
+
for (const change of proposal.changes) {
|
|
1086
|
+
const safe = await this.safePath(change.path, { allowMissing: true });
|
|
1087
|
+
let before = null;
|
|
1088
|
+
if (change.expectedDigest === null) {
|
|
1089
|
+
if (safe.exists)
|
|
1090
|
+
throw new HarnessWorkspaceError(`The patch target already exists: ${change.path}.`);
|
|
1091
|
+
} else {
|
|
1092
|
+
const file = await this.readStableFile(change.path, false);
|
|
1093
|
+
if (file.digest !== change.expectedDigest)
|
|
1094
|
+
throw new HarnessWorkspaceError(`Stale patch rejected for ${change.path}.`);
|
|
1095
|
+
try {
|
|
1096
|
+
before = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(file.contents);
|
|
1097
|
+
} catch {
|
|
1098
|
+
throw new HarnessWorkspaceError("Preview requires valid UTF-8 text.");
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
bytes += Buffer.byteLength(before ?? "") + Buffer.byteLength(change.content);
|
|
1102
|
+
if (bytes > 4 * 1024 * 1024)
|
|
1103
|
+
throw new HarnessWorkspaceError("Preview exceeds the complete-content byte limit.");
|
|
1104
|
+
files.push({ path: change.path, expectedDigest: change.expectedDigest, before, after: change.content, afterDigest: digestBytes(Buffer.from(change.content)) });
|
|
1105
|
+
}
|
|
1106
|
+
return { proposalId: proposal.proposalId, files };
|
|
1107
|
+
}
|
|
1108
|
+
async previewReplacement(input) {
|
|
1109
|
+
const changes = await this.replacementChanges(input);
|
|
1110
|
+
return this.previewPatch({ proposalId: createEditProposal({ changes }).proposalId, changes });
|
|
1111
|
+
}
|
|
1112
|
+
async replacementChanges(input) {
|
|
1113
|
+
const edit = replacementEditSchema.parse(input);
|
|
1114
|
+
const file = await this.readStableFile(edit.path, false);
|
|
1115
|
+
if (file.digest !== edit.expectedDigest)
|
|
1116
|
+
throw new HarnessWorkspaceError(`Stale patch rejected for ${edit.path}.`);
|
|
1117
|
+
let text;
|
|
1118
|
+
try {
|
|
1119
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(file.contents);
|
|
1120
|
+
} catch {
|
|
1121
|
+
throw new HarnessWorkspaceError("Replacement requires valid UTF-8 text.");
|
|
1122
|
+
}
|
|
1123
|
+
const position = text.indexOf(edit.oldText);
|
|
1124
|
+
if (position < 0 || text.indexOf(edit.oldText, position + 1) >= 0) {
|
|
1125
|
+
throw new HarnessWorkspaceError("Replacement requires exactly one literal match; include more surrounding context.");
|
|
1126
|
+
}
|
|
1127
|
+
return [{ path: edit.path, expectedDigest: edit.expectedDigest, content: text.slice(0, position) + edit.newText + text.slice(position + edit.oldText.length) }];
|
|
1128
|
+
}
|
|
1129
|
+
async applyReplacement(input) {
|
|
1130
|
+
const changes = await this.replacementChanges(input);
|
|
1131
|
+
return this.applyPatch({ proposalId: createEditProposal({ changes }).proposalId, changes });
|
|
1132
|
+
}
|
|
1133
|
+
async applyPatch(input) {
|
|
1134
|
+
return this.applyPatchWithModes(input, new Map);
|
|
1135
|
+
}
|
|
1136
|
+
async applyPatchWithModes(input, modes, assertActive) {
|
|
1137
|
+
return withWorkspaceMutation(this.root, () => this.applyPatchWithModesLocked(input, modes, assertActive));
|
|
1138
|
+
}
|
|
1139
|
+
async applyPatchWithModesLocked(input, modes, assertActive) {
|
|
1140
|
+
const proposal = validateEditProposal(input);
|
|
1141
|
+
this.checkAuditCapacity(proposal.changes.length);
|
|
1142
|
+
const targetPaths = new Set(proposal.changes.map((change) => change.path));
|
|
1143
|
+
for (const [targetPath, binding] of modes) {
|
|
1144
|
+
if (!targetPaths.has(targetPath)) {
|
|
1145
|
+
throw new HarnessWorkspaceError(`Patch mode binding has no matching content target: ${targetPath}.`);
|
|
1146
|
+
}
|
|
1147
|
+
for (const [label, mode] of [["before", binding.beforeMode], ["after", binding.afterMode]]) {
|
|
1148
|
+
if (mode !== undefined && (!Number.isSafeInteger(mode) || mode < 0 || mode > 511)) {
|
|
1149
|
+
throw new HarnessWorkspaceError(`Patch ${label} mode must be an integer between 0 and 0777: ${targetPath}.`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
const ordered = [...proposal.changes].sort((a, b) => a.path < b.path ? -1 : 1);
|
|
1154
|
+
for (let index = 1;index < ordered.length; index += 1) {
|
|
1155
|
+
const previous = ordered[index - 1];
|
|
1156
|
+
const current = ordered[index];
|
|
1157
|
+
if (previous && current && current.path.startsWith(`${previous.path}/`)) {
|
|
1158
|
+
throw new HarnessWorkspaceError(`Patch targets conflict as ancestor and descendant: ${previous.path}, ${current.path}.`);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
const prepared = [];
|
|
1162
|
+
try {
|
|
1163
|
+
for (const change of proposal.changes) {
|
|
1164
|
+
const safe = await this.safePath(change.path, { allowMissing: true });
|
|
1165
|
+
const modeBinding = modes.get(change.path);
|
|
1166
|
+
let before;
|
|
1167
|
+
if (change.expectedDigest === null) {
|
|
1168
|
+
if (safe.exists)
|
|
1169
|
+
throw new HarnessWorkspaceError(`The patch target already exists: ${change.path}.`);
|
|
1170
|
+
if (modeBinding?.beforeMode !== undefined) {
|
|
1171
|
+
throw new HarnessWorkspaceError(`Create-only patch mode binding cannot declare a before mode: ${change.path}.`);
|
|
1172
|
+
}
|
|
1173
|
+
} else {
|
|
1174
|
+
if (!safe.exists)
|
|
1175
|
+
throw new HarnessWorkspaceError(`The patch target no longer exists: ${change.path}.`);
|
|
1176
|
+
before = await this.readStableFile(change.path);
|
|
1177
|
+
if (before.digest !== change.expectedDigest) {
|
|
1178
|
+
throw new HarnessWorkspaceError(`Stale patch rejected for ${change.path}: expected ${change.expectedDigest}, found ${before.digest}.`);
|
|
1179
|
+
}
|
|
1180
|
+
if (modeBinding?.beforeMode !== undefined && before.mode !== modeBinding.beforeMode) {
|
|
1181
|
+
throw new HarnessWorkspaceError(`Stale patch mode rejected for ${change.path}: expected ${modeBinding.beforeMode.toString(8)}, found ${before.mode.toString(8)}.`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
const temporary = await this.stageFile(safe.path, change.content, modeBinding?.afterMode ?? before?.mode ?? 420);
|
|
1185
|
+
prepared.push({
|
|
1186
|
+
change,
|
|
1187
|
+
target: safe.path,
|
|
1188
|
+
...before ? { before } : {},
|
|
1189
|
+
...modeBinding ? { modeBinding } : {},
|
|
1190
|
+
temporary
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
} catch (error) {
|
|
1194
|
+
await Promise.all(prepared.map((item) => unlink(item.temporary).catch(() => {})));
|
|
1195
|
+
throw error;
|
|
1196
|
+
}
|
|
1197
|
+
const committed = [];
|
|
1198
|
+
try {
|
|
1199
|
+
for (const item of prepared) {
|
|
1200
|
+
await this.safePath(item.change.path, { allowMissing: item.change.expectedDigest === null });
|
|
1201
|
+
if (item.before) {
|
|
1202
|
+
const current = await this.readStableFile(item.change.path);
|
|
1203
|
+
if (current.digest !== item.change.expectedDigest)
|
|
1204
|
+
throw new HarnessWorkspaceError(`Stale patch rejected for ${item.change.path}.`);
|
|
1205
|
+
if (item.modeBinding?.beforeMode !== undefined && current.mode !== item.modeBinding.beforeMode) {
|
|
1206
|
+
throw new HarnessWorkspaceError(`Stale patch mode rejected for ${item.change.path}.`);
|
|
1207
|
+
}
|
|
1208
|
+
await assertActive?.();
|
|
1209
|
+
await rename(item.temporary, item.target);
|
|
1210
|
+
} else {
|
|
1211
|
+
try {
|
|
1212
|
+
await assertActive?.();
|
|
1213
|
+
await link(item.temporary, item.target);
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
if (error.code === "EEXIST") {
|
|
1216
|
+
throw new HarnessWorkspaceError(`The patch target was created concurrently: ${item.change.path}.`);
|
|
1217
|
+
}
|
|
1218
|
+
throw error;
|
|
1219
|
+
}
|
|
1220
|
+
await unlink(item.temporary);
|
|
1221
|
+
}
|
|
1222
|
+
committed.push(item);
|
|
1223
|
+
await assertActive?.();
|
|
1224
|
+
}
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
const rollbackErrors = [];
|
|
1227
|
+
for (const item of [...committed].reverse()) {
|
|
1228
|
+
try {
|
|
1229
|
+
if (item.before) {
|
|
1230
|
+
const rollback = await this.stageFile(item.target, item.before.contents, item.before.mode);
|
|
1231
|
+
await rename(rollback, item.target);
|
|
1232
|
+
} else {
|
|
1233
|
+
await unlink(item.target);
|
|
1234
|
+
}
|
|
1235
|
+
} catch (rollbackError) {
|
|
1236
|
+
rollbackErrors.push(rollbackError);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
await Promise.all(prepared.map((item) => unlink(item.temporary).catch(() => {})));
|
|
1240
|
+
if (rollbackErrors.length)
|
|
1241
|
+
throw new AggregateError([error, ...rollbackErrors], "Patch failed and rollback was incomplete.");
|
|
1242
|
+
throw error;
|
|
1243
|
+
}
|
|
1244
|
+
this.invalidateWorkspaceIndex();
|
|
1245
|
+
const changes = [];
|
|
1246
|
+
for (const item of prepared)
|
|
1247
|
+
changes.push(await this.audit({
|
|
1248
|
+
operation: item.before ? "update" : "create",
|
|
1249
|
+
path: item.change.path,
|
|
1250
|
+
...item.before ? { beforeDigest: item.before.digest } : {},
|
|
1251
|
+
afterDigest: digestBytes(item.change.content)
|
|
1252
|
+
}));
|
|
1253
|
+
return { proposalId: proposal.proposalId, changes };
|
|
1254
|
+
}
|
|
1255
|
+
async writeFile(relativePath, content, overwrite = false) {
|
|
1256
|
+
const safe = await this.safePath(relativePath, { allowMissing: true });
|
|
1257
|
+
if (safe.exists && !overwrite)
|
|
1258
|
+
throw new HarnessWorkspaceError("The file already exists; use applyPatch with its expected digest.");
|
|
1259
|
+
const before = safe.exists ? await this.readStableFile(relativePath) : undefined;
|
|
1260
|
+
const changes = [{ path: relativePath, expectedDigest: before?.digest ?? null, content }];
|
|
1261
|
+
const proposal = createEditProposal({ changes });
|
|
1262
|
+
await this.applyPatch({ proposalId: proposal.proposalId, changes });
|
|
1263
|
+
return { path: relativePath, bytes: Buffer.byteLength(content), overwritten: Boolean(before) };
|
|
1264
|
+
}
|
|
1265
|
+
async replaceInFile(relativePath, oldText, newText) {
|
|
1266
|
+
if (!oldText)
|
|
1267
|
+
throw new HarnessWorkspaceError("oldText cannot be empty.");
|
|
1268
|
+
const before = await this.readStableFile(relativePath, false);
|
|
1269
|
+
const contents = before.contents.toString("utf8");
|
|
1270
|
+
const occurrences = contents.split(oldText).length - 1;
|
|
1271
|
+
if (occurrences !== 1)
|
|
1272
|
+
throw new HarnessWorkspaceError(`oldText must occur exactly once; found ${occurrences} occurrences.`);
|
|
1273
|
+
const updated = contents.replace(oldText, newText);
|
|
1274
|
+
const changes = [{ path: relativePath, expectedDigest: before.digest, content: updated }];
|
|
1275
|
+
const proposal = createEditProposal({ changes });
|
|
1276
|
+
await this.applyPatch({ proposalId: proposal.proposalId, changes });
|
|
1277
|
+
return { path: relativePath, beforeBytes: before.contents.byteLength, afterBytes: Buffer.byteLength(updated) };
|
|
1278
|
+
}
|
|
1279
|
+
async moveFile(input) {
|
|
1280
|
+
return withWorkspaceMutation(this.root, () => this.moveFileLocked(input));
|
|
1281
|
+
}
|
|
1282
|
+
async moveFileLocked(input) {
|
|
1283
|
+
const parsed = moveFileInputSchema.parse(input);
|
|
1284
|
+
this.checkAuditCapacity();
|
|
1285
|
+
const source = await this.readStableFile(parsed.source);
|
|
1286
|
+
if (source.digest !== parsed.expectedDigest)
|
|
1287
|
+
throw new HarnessWorkspaceError(`Stale move rejected for ${parsed.source}.`);
|
|
1288
|
+
const destination = await this.safePath(parsed.destination, { allowMissing: true });
|
|
1289
|
+
if (destination.exists)
|
|
1290
|
+
throw new HarnessWorkspaceError(`Move destination already exists: ${parsed.destination}.`);
|
|
1291
|
+
await this.secureWorkspaceDirectory(path2.dirname(destination.path));
|
|
1292
|
+
const destinationRecheck = await this.safePath(parsed.destination, { allowMissing: true });
|
|
1293
|
+
if (destinationRecheck.exists)
|
|
1294
|
+
throw new HarnessWorkspaceError(`Move destination already exists: ${parsed.destination}.`);
|
|
1295
|
+
if ((await this.readStableFile(parsed.source)).digest !== parsed.expectedDigest)
|
|
1296
|
+
throw new HarnessWorkspaceError(`Stale move rejected for ${parsed.source}.`);
|
|
1297
|
+
let linked = false;
|
|
1298
|
+
try {
|
|
1299
|
+
await link(source.absolutePath, destination.path);
|
|
1300
|
+
linked = true;
|
|
1301
|
+
const linkedFile = await this.readStableFile(parsed.destination);
|
|
1302
|
+
if (linkedFile.digest !== source.digest) {
|
|
1303
|
+
throw new HarnessWorkspaceError(`Move destination changed before commit: ${parsed.destination}.`);
|
|
1304
|
+
}
|
|
1305
|
+
await unlink(source.absolutePath);
|
|
1306
|
+
} catch (error) {
|
|
1307
|
+
if (linked)
|
|
1308
|
+
await unlink(destination.path).catch(() => {});
|
|
1309
|
+
throw error;
|
|
1310
|
+
}
|
|
1311
|
+
this.invalidateWorkspaceIndex();
|
|
1312
|
+
const audit = await this.audit({ operation: "move", path: parsed.source, destination: parsed.destination, beforeDigest: source.digest, afterDigest: source.digest });
|
|
1313
|
+
return { source: parsed.source, destination: parsed.destination, digest: source.digest, audit };
|
|
1314
|
+
}
|
|
1315
|
+
async secureQuarantineDirectory(create) {
|
|
1316
|
+
const segments = [".zhivex-harness", "quarantine"];
|
|
1317
|
+
let current = this.root;
|
|
1318
|
+
for (const segment of segments) {
|
|
1319
|
+
current = path2.join(current, segment);
|
|
1320
|
+
try {
|
|
1321
|
+
const entry = await lstat2(current);
|
|
1322
|
+
if (entry.isSymbolicLink()) {
|
|
1323
|
+
throw new HarnessWorkspaceError("Harness quarantine must not resolve through a symbolic link.");
|
|
1324
|
+
}
|
|
1325
|
+
if (!entry.isDirectory()) {
|
|
1326
|
+
throw new HarnessWorkspaceError("Harness quarantine path contains a non-directory entry.");
|
|
1327
|
+
}
|
|
1328
|
+
} catch (error) {
|
|
1329
|
+
if (error.code !== "ENOENT" || !create)
|
|
1330
|
+
throw error;
|
|
1331
|
+
try {
|
|
1332
|
+
await mkdir2(current, { mode: 448 });
|
|
1333
|
+
} catch (mkdirError) {
|
|
1334
|
+
if (mkdirError.code !== "EEXIST")
|
|
1335
|
+
throw mkdirError;
|
|
1336
|
+
const racedEntry = await lstat2(current);
|
|
1337
|
+
if (racedEntry.isSymbolicLink() || !racedEntry.isDirectory()) {
|
|
1338
|
+
throw new HarnessWorkspaceError("Harness quarantine path changed during creation.");
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
const resolved = await realpath2(current);
|
|
1344
|
+
if (!isInside(this.root, resolved))
|
|
1345
|
+
throw new HarnessWorkspaceError("Harness quarantine resolves outside the workspace.");
|
|
1346
|
+
return resolved;
|
|
1347
|
+
}
|
|
1348
|
+
async writeManifest(manifest) {
|
|
1349
|
+
const directory = await this.secureQuarantineDirectory(true);
|
|
1350
|
+
const target = path2.join(directory, `${manifest.quarantineId}.json`);
|
|
1351
|
+
const temporary = await this.stageFile(target, `${JSON.stringify(manifest, null, 2)}
|
|
1352
|
+
`, 384);
|
|
1353
|
+
await rename(temporary, target);
|
|
1354
|
+
}
|
|
1355
|
+
async readInternalRegularFile(target, label, maxBytes) {
|
|
1356
|
+
return await readRegularFileNoFollow(target, { label, maxBytes });
|
|
1357
|
+
}
|
|
1358
|
+
async quarantineFile(input, assertActive) {
|
|
1359
|
+
return withWorkspaceMutation(this.root, () => this.quarantineFileLocked(input, assertActive));
|
|
1360
|
+
}
|
|
1361
|
+
async quarantineFileLocked(input, assertActive) {
|
|
1362
|
+
const parsed = quarantineFileInputSchema.parse(input);
|
|
1363
|
+
this.checkAuditCapacity();
|
|
1364
|
+
const source = await this.readStableFile(parsed.path);
|
|
1365
|
+
if (source.digest !== parsed.expectedDigest)
|
|
1366
|
+
throw new HarnessWorkspaceError(`Stale quarantine rejected for ${parsed.path}.`);
|
|
1367
|
+
const quarantineId = `${Date.now()}-${randomUUID()}`;
|
|
1368
|
+
const directory = await this.secureQuarantineDirectory(true);
|
|
1369
|
+
const dataPath = path2.join(directory, `${quarantineId}.data`);
|
|
1370
|
+
if ((await this.readStableFile(parsed.path)).digest !== parsed.expectedDigest)
|
|
1371
|
+
throw new HarnessWorkspaceError(`Stale quarantine rejected for ${parsed.path}.`);
|
|
1372
|
+
await link(source.absolutePath, dataPath);
|
|
1373
|
+
if (digestBytes((await this.readInternalRegularFile(dataPath, "Quarantine payload", MAX_FILE_BYTES)).contents) !== source.digest) {
|
|
1374
|
+
await unlink(dataPath).catch(() => {});
|
|
1375
|
+
throw new HarnessWorkspaceError(`Quarantine payload changed before commit: ${parsed.path}.`);
|
|
1376
|
+
}
|
|
1377
|
+
const manifest = {
|
|
1378
|
+
schemaVersion: 1,
|
|
1379
|
+
quarantineId,
|
|
1380
|
+
originalPath: parsed.path,
|
|
1381
|
+
digest: source.digest,
|
|
1382
|
+
mode: source.mode,
|
|
1383
|
+
status: "quarantined",
|
|
1384
|
+
createdAt: new Date().toISOString()
|
|
1385
|
+
};
|
|
1386
|
+
try {
|
|
1387
|
+
await this.writeManifest(manifest);
|
|
1388
|
+
await assertActive?.();
|
|
1389
|
+
await unlink(source.absolutePath);
|
|
1390
|
+
} catch (error) {
|
|
1391
|
+
await unlink(dataPath).catch(() => {});
|
|
1392
|
+
await unlink(path2.join(directory, `${quarantineId}.json`)).catch(() => {});
|
|
1393
|
+
throw error;
|
|
1394
|
+
}
|
|
1395
|
+
this.invalidateWorkspaceIndex();
|
|
1396
|
+
const audit = await this.audit({ operation: "quarantine", path: parsed.path, beforeDigest: source.digest, quarantineId });
|
|
1397
|
+
return { quarantineId, path: parsed.path, digest: source.digest, audit };
|
|
1398
|
+
}
|
|
1399
|
+
async restoreQuarantined(input) {
|
|
1400
|
+
return withWorkspaceMutation(this.root, () => this.restoreQuarantinedLocked(input));
|
|
1401
|
+
}
|
|
1402
|
+
async restoreQuarantinedLocked(input) {
|
|
1403
|
+
const parsed = restoreFileInputSchema.parse(input);
|
|
1404
|
+
this.checkAuditCapacity();
|
|
1405
|
+
const directory = await this.secureQuarantineDirectory(false);
|
|
1406
|
+
let manifest;
|
|
1407
|
+
try {
|
|
1408
|
+
const manifestFile = await this.readInternalRegularFile(path2.join(directory, `${parsed.quarantineId}.json`), "Quarantine manifest", 64 * 1024);
|
|
1409
|
+
manifest = JSON.parse(manifestFile.contents.toString("utf8"));
|
|
1410
|
+
} catch {
|
|
1411
|
+
throw new HarnessWorkspaceError(`Quarantine entry was not found: ${parsed.quarantineId}.`);
|
|
1412
|
+
}
|
|
1413
|
+
if (manifest.schemaVersion !== 1 || manifest.quarantineId !== parsed.quarantineId || manifest.status !== "quarantined") {
|
|
1414
|
+
throw new HarnessWorkspaceError(`Quarantine entry is not restorable: ${parsed.quarantineId}.`);
|
|
1415
|
+
}
|
|
1416
|
+
const dataPath = path2.join(directory, `${parsed.quarantineId}.data`);
|
|
1417
|
+
const dataFile = await this.readInternalRegularFile(dataPath, "Quarantine payload", MAX_FILE_BYTES);
|
|
1418
|
+
const digest = digestBytes(dataFile.contents);
|
|
1419
|
+
if (digest !== manifest.digest || parsed.expectedDigest && parsed.expectedDigest !== digest) {
|
|
1420
|
+
throw new HarnessWorkspaceError(`Quarantine digest mismatch: ${parsed.quarantineId}.`);
|
|
1421
|
+
}
|
|
1422
|
+
const destinationPath = parsed.destination ?? manifest.originalPath;
|
|
1423
|
+
const destination = await this.safePath(destinationPath, { allowMissing: true });
|
|
1424
|
+
if (destination.exists)
|
|
1425
|
+
throw new HarnessWorkspaceError(`Restore destination already exists: ${destinationPath}.`);
|
|
1426
|
+
await this.secureWorkspaceDirectory(path2.dirname(destination.path));
|
|
1427
|
+
const destinationRecheck = await this.safePath(destinationPath, { allowMissing: true });
|
|
1428
|
+
if (destinationRecheck.exists)
|
|
1429
|
+
throw new HarnessWorkspaceError(`Restore destination already exists: ${destinationPath}.`);
|
|
1430
|
+
const dataRecheck = await lstat2(dataPath);
|
|
1431
|
+
if (dataRecheck.isSymbolicLink() || !dataRecheck.isFile() || dataRecheck.dev !== dataFile.stat.dev || dataRecheck.ino !== dataFile.stat.ino || dataRecheck.size !== dataFile.stat.size || dataRecheck.mtimeMs !== dataFile.stat.mtimeMs) {
|
|
1432
|
+
throw new HarnessWorkspaceError("Quarantine payload changed before restore commit.");
|
|
1433
|
+
}
|
|
1434
|
+
await link(dataPath, destination.path);
|
|
1435
|
+
const linkedDestination = await this.readInternalRegularFile(destination.path, "Restore destination", MAX_FILE_BYTES);
|
|
1436
|
+
if (linkedDestination.stat.dev !== dataFile.stat.dev || linkedDestination.stat.ino !== dataFile.stat.ino || digestBytes(linkedDestination.contents) !== digest) {
|
|
1437
|
+
await unlink(destination.path).catch(() => {});
|
|
1438
|
+
throw new HarnessWorkspaceError("Restore destination did not match the quarantined payload.");
|
|
1439
|
+
}
|
|
1440
|
+
const restoredManifest = {
|
|
1441
|
+
...manifest,
|
|
1442
|
+
status: "restored",
|
|
1443
|
+
restoredAt: new Date().toISOString(),
|
|
1444
|
+
restoredPath: destinationPath
|
|
1445
|
+
};
|
|
1446
|
+
try {
|
|
1447
|
+
await this.writeManifest(restoredManifest);
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
await unlink(destination.path).catch(() => {});
|
|
1450
|
+
throw error;
|
|
1451
|
+
}
|
|
1452
|
+
try {
|
|
1453
|
+
await unlink(dataPath);
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
const rollbackErrors = [];
|
|
1456
|
+
try {
|
|
1457
|
+
await this.writeManifest(manifest);
|
|
1458
|
+
} catch (rollbackError) {
|
|
1459
|
+
rollbackErrors.push(rollbackError);
|
|
1460
|
+
}
|
|
1461
|
+
try {
|
|
1462
|
+
await unlink(destination.path);
|
|
1463
|
+
} catch (rollbackError) {
|
|
1464
|
+
rollbackErrors.push(rollbackError);
|
|
1465
|
+
}
|
|
1466
|
+
throw rollbackErrors.length ? new AggregateError([error, ...rollbackErrors], "Restore failed and rollback was incomplete.") : error;
|
|
1467
|
+
}
|
|
1468
|
+
this.invalidateWorkspaceIndex();
|
|
1469
|
+
const audit = await this.audit({ operation: "restore", path: destinationPath, afterDigest: digest, quarantineId: parsed.quarantineId });
|
|
1470
|
+
return { quarantineId: parsed.quarantineId, path: destinationPath, digest, audit };
|
|
1471
|
+
}
|
|
1472
|
+
async runCheck(check, expectedScript, allowedChecks = DEFAULT_CHECKS) {
|
|
1473
|
+
let manifest;
|
|
1474
|
+
try {
|
|
1475
|
+
manifest = JSON.parse((await this.readStableFile("package.json", false)).contents.toString("utf8"));
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
if (error.code === "ENOENT")
|
|
1478
|
+
throw new HarnessWorkspaceError("The workspace does not contain a package.json file.");
|
|
1479
|
+
throw error;
|
|
1480
|
+
}
|
|
1481
|
+
const resolved = await resolvePackageCheckCommand(this.root, manifest, check, expectedScript, allowedChecks);
|
|
1482
|
+
try {
|
|
1483
|
+
return await spawnBounded(resolved.command, this.root);
|
|
1484
|
+
} finally {
|
|
1485
|
+
this.invalidateWorkspaceIndex();
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
async gitDiff() {
|
|
1489
|
+
const [statusDiscovery, diffDiscovery, stagedDiscovery] = await Promise.all([
|
|
1490
|
+
gitDiscovery(gitCommand("status", "--short", "--untracked-files=all", "--ignore-submodules=all", "-z"), this.root),
|
|
1491
|
+
gitDiscovery(gitCommand("diff", "--name-status", "-z", "--no-ext-diff", "--no-textconv", "--ignore-submodules=all"), this.root),
|
|
1492
|
+
gitDiscovery(gitCommand("diff", "--cached", "--name-status", "-z", "--no-ext-diff", "--no-textconv", "--ignore-submodules=all"), this.root)
|
|
1493
|
+
]);
|
|
1494
|
+
const statusResult = filteredGitStatus(statusDiscovery);
|
|
1495
|
+
const [diffResult, stagedResult] = await Promise.all([
|
|
1496
|
+
runFilteredGitDiff(diffDiscovery, this.root, false),
|
|
1497
|
+
runFilteredGitDiff(stagedDiscovery, this.root, true)
|
|
1498
|
+
]);
|
|
1499
|
+
return { status: statusResult, diff: diffResult, staged: stagedResult };
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// src/execution/patch-diagnostics.ts
|
|
1504
|
+
class EnvironmentPatchDriftError extends Error {
|
|
1505
|
+
diagnosticCode;
|
|
1506
|
+
constructor(expected, current, lastInspected) {
|
|
1507
|
+
super("Environment patch changed after review; inspect it again before import.");
|
|
1508
|
+
this.diagnosticCode = typeof lastInspected !== "string" || !/^sha256:[a-f0-9]{64}$/.test(lastInspected) ? "OCI_PATCH_REVIEW_UNAVAILABLE" : expected === lastInspected ? "OCI_PATCH_SNAPSHOT_CHANGED" : current === lastInspected ? "OCI_PATCH_ID_MISMATCH" : "OCI_PATCH_REVIEW_UNAVAILABLE";
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// src/execution/acceptance-snapshot.ts
|
|
1513
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
1514
|
+
import { lstat as lstat3, readdir as readdir2 } from "node:fs/promises";
|
|
1515
|
+
import path3 from "node:path";
|
|
1516
|
+
async function acceptanceSnapshotDigest(root, maxBytes) {
|
|
1517
|
+
const rows = [];
|
|
1518
|
+
let bytes = 0;
|
|
1519
|
+
async function visit(relative) {
|
|
1520
|
+
const names = await readdir2(path3.join(root, relative));
|
|
1521
|
+
names.sort();
|
|
1522
|
+
for (const name of names) {
|
|
1523
|
+
const item = relative ? relative + "/" + name : name;
|
|
1524
|
+
if (rows.length >= 20000)
|
|
1525
|
+
throw new Error("TASK_ACCEPTANCE_SNAPSHOT_CAPACITY");
|
|
1526
|
+
const absolute = path3.join(root, item), entry = await lstat3(absolute);
|
|
1527
|
+
if (entry.isSymbolicLink())
|
|
1528
|
+
throw new Error("TASK_ACCEPTANCE_SNAPSHOT_UNSAFE");
|
|
1529
|
+
if (entry.isDirectory()) {
|
|
1530
|
+
rows.push([item, "directory", entry.mode & 511, null]);
|
|
1531
|
+
await visit(item);
|
|
1532
|
+
} else if (entry.isFile() && entry.nlink === 1) {
|
|
1533
|
+
const file = await readRegularFileNoFollow(absolute, { label: "Acceptance snapshot file", maxBytes: maxBytes - bytes, requireSingleLink: true });
|
|
1534
|
+
bytes += file.contents.byteLength;
|
|
1535
|
+
rows.push([item, "file", file.stat.mode & 511, createHash3("sha256").update(file.contents).digest("hex")]);
|
|
1536
|
+
} else
|
|
1537
|
+
throw new Error("TASK_ACCEPTANCE_SNAPSHOT_UNSAFE");
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
await visit("");
|
|
1541
|
+
return "sha256:" + createHash3("sha256").update(JSON.stringify(rows)).digest("hex");
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// src/execution/oci-observability.ts
|
|
1545
|
+
import { channel } from "node:diagnostics_channel";
|
|
1546
|
+
var diagnostics = channel("zhivex.harness.oci.phase");
|
|
1547
|
+
var sequence = 0;
|
|
1548
|
+
async function observeOciPhase(operation, action) {
|
|
1549
|
+
if (!diagnostics.hasSubscribers)
|
|
1550
|
+
return action();
|
|
1551
|
+
const id = ++sequence;
|
|
1552
|
+
diagnostics.publish({ id, operation, outcome: "running" });
|
|
1553
|
+
try {
|
|
1554
|
+
const result = await action();
|
|
1555
|
+
const timedOut = !!result && typeof result === "object" && "timedOut" in result && result.timedOut === true;
|
|
1556
|
+
diagnostics.publish({ id, operation, outcome: timedOut ? "failed" : "completed", timedOut });
|
|
1557
|
+
return result;
|
|
1558
|
+
} catch (error) {
|
|
1559
|
+
diagnostics.publish({ id, operation, outcome: "failed" });
|
|
1560
|
+
throw error;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// src/execution/execution-environment.ts
|
|
1565
|
+
import { createHash as createHash4, randomUUID as randomUUID2 } from "node:crypto";
|
|
1566
|
+
import { spawn } from "node:child_process";
|
|
1567
|
+
import { constants as fsConstants } from "node:fs";
|
|
1568
|
+
import {
|
|
1569
|
+
chmod,
|
|
1570
|
+
copyFile,
|
|
1571
|
+
lstat as lstat4,
|
|
1572
|
+
mkdir as mkdir3,
|
|
1573
|
+
readdir as readdir3,
|
|
1574
|
+
realpath as realpath3,
|
|
1575
|
+
rename as rename2,
|
|
1576
|
+
rm,
|
|
1577
|
+
writeFile
|
|
1578
|
+
} from "node:fs/promises";
|
|
1579
|
+
import path4 from "node:path";
|
|
1580
|
+
import { createAgentExecutionEnvironmentBinding } from "@zhivex-ai/agents/beta";
|
|
1581
|
+
var HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION = 1;
|
|
1582
|
+
var HARNESS_OCI_LABEL = "com.zhivex.harness.execution";
|
|
1583
|
+
var HARNESS_OCI_LABEL_VALUE = "v1";
|
|
1584
|
+
var HARNESS_OCI_OWNER_PID_LABEL = "com.zhivex.harness.owner-pid";
|
|
1585
|
+
var TERMINAL_STATUSES = new Set([
|
|
1586
|
+
"completed",
|
|
1587
|
+
"failed",
|
|
1588
|
+
"cancelled",
|
|
1589
|
+
"timed_out"
|
|
1590
|
+
]);
|
|
1591
|
+
var EXECUTION_ARTIFACT_DIRECTORY_PATTERN = /^[a-f0-9]{24}$/;
|
|
1592
|
+
var STAGED_EXECUTION_ARTIFACT_DIRECTORY_PATTERN = /^\.cleanup-([a-f0-9]{24})-[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/;
|
|
1593
|
+
var BUILT_IN_TOOL_NAMES = new Set([
|
|
1594
|
+
"__harness_result_feedback",
|
|
1595
|
+
"read_dependency",
|
|
1596
|
+
"read_task",
|
|
1597
|
+
"repair_plan",
|
|
1598
|
+
"list_files",
|
|
1599
|
+
"read_file",
|
|
1600
|
+
"read_files",
|
|
1601
|
+
"search_files",
|
|
1602
|
+
"search_many",
|
|
1603
|
+
"load_skill",
|
|
1604
|
+
"propose_edits",
|
|
1605
|
+
"apply_patch",
|
|
1606
|
+
"apply_reviewed_edits",
|
|
1607
|
+
"apply_reviewed_replacement",
|
|
1608
|
+
"move_file",
|
|
1609
|
+
"quarantine_file",
|
|
1610
|
+
"restore_file",
|
|
1611
|
+
"run_check",
|
|
1612
|
+
"mutation_audit",
|
|
1613
|
+
"git_diff",
|
|
1614
|
+
"run_environment_command",
|
|
1615
|
+
"run_environment_batch",
|
|
1616
|
+
"run_environment_shell",
|
|
1617
|
+
"inspect_environment_patch",
|
|
1618
|
+
"apply_environment_patch",
|
|
1619
|
+
"verify_and_apply_environment_patch",
|
|
1620
|
+
"verify_and_apply_reviewed_edits",
|
|
1621
|
+
"environment_status"
|
|
1622
|
+
]);
|
|
1623
|
+
var digest = (value) => `sha256:${createHash4("sha256").update(value).digest("hex")}`;
|
|
1624
|
+
var executionScopeKey = (scope) => {
|
|
1625
|
+
if (scope) {
|
|
1626
|
+
for (const value of [scope.tenantId, scope.userId, scope.namespace]) {
|
|
1627
|
+
if (value !== undefined && (typeof value !== "string" || !value || value.includes("\x00"))) {
|
|
1628
|
+
throw new Error("Invalid OCI execution scope.");
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (!scope.tenantId)
|
|
1632
|
+
throw new Error("OCI execution scope requires tenantId.");
|
|
1633
|
+
}
|
|
1634
|
+
return JSON.stringify([scope?.tenantId ?? null, scope?.userId ?? null, scope?.namespace ?? null]);
|
|
1635
|
+
};
|
|
1636
|
+
var executionIdentity = (workspace, stateDirectory, runId, scopeKey) => digest(JSON.stringify(["scoped-oci-v2", workspace, stateDirectory, scopeKey, runId]));
|
|
1637
|
+
var runHash = (runId) => createHash4("sha256").update(runId).digest("hex").slice(0, 24);
|
|
1638
|
+
var isInside2 = (root, candidate) => {
|
|
1639
|
+
const relative = path4.relative(root, candidate);
|
|
1640
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${path4.sep}`) && !path4.isAbsolute(relative);
|
|
1641
|
+
};
|
|
1642
|
+
var privateDirectory = async (directory) => {
|
|
1643
|
+
await mkdir3(directory, { recursive: true, mode: 448 });
|
|
1644
|
+
const entry = await lstat4(directory);
|
|
1645
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
1646
|
+
throw new Error(`Execution artifact directory must be a real directory: ${directory}.`);
|
|
1647
|
+
}
|
|
1648
|
+
await chmod(directory, 448);
|
|
1649
|
+
};
|
|
1650
|
+
var atomicJson = async (target, value) => {
|
|
1651
|
+
const temporary = `${target}.${randomUUID2()}.tmp`;
|
|
1652
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}
|
|
1653
|
+
`, { encoding: "utf8", mode: 384 });
|
|
1654
|
+
await rename2(temporary, target);
|
|
1655
|
+
};
|
|
1656
|
+
var commandDisplay = (command) => command.map((value) => /^[A-Za-z0-9_./:=+-]+$/.test(value) ? value : JSON.stringify(value)).join(" ");
|
|
1657
|
+
var elapsedMilliseconds = (startedAt) => Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
1658
|
+
var spawnBounded2 = async (command, options) => {
|
|
1659
|
+
const child = spawn(command[0], command.slice(1), {
|
|
1660
|
+
...options.cwd ? { cwd: options.cwd } : {},
|
|
1661
|
+
env: options.env ?? {},
|
|
1662
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
1663
|
+
});
|
|
1664
|
+
if (options.input !== undefined) {
|
|
1665
|
+
child.stdin?.on("error", () => {
|
|
1666
|
+
return;
|
|
1667
|
+
});
|
|
1668
|
+
child.stdin?.end(options.input);
|
|
1669
|
+
}
|
|
1670
|
+
let timedOut = false;
|
|
1671
|
+
let cancelled = false;
|
|
1672
|
+
let outputLimitExceeded = false;
|
|
1673
|
+
let stopped = false;
|
|
1674
|
+
let retainedOutputBytes = 0;
|
|
1675
|
+
let totalOutputBytes = 0;
|
|
1676
|
+
const stop = (reason) => {
|
|
1677
|
+
if (!stopped) {
|
|
1678
|
+
stopped = true;
|
|
1679
|
+
options.onStop?.(reason);
|
|
1680
|
+
child.kill("SIGKILL");
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
const timeout = setTimeout(() => {
|
|
1684
|
+
timedOut = true;
|
|
1685
|
+
stop("timeout");
|
|
1686
|
+
}, options.timeoutMs);
|
|
1687
|
+
const onAbort = () => {
|
|
1688
|
+
cancelled = true;
|
|
1689
|
+
stop("cancelled");
|
|
1690
|
+
};
|
|
1691
|
+
if (options.abortSignal?.aborted) {
|
|
1692
|
+
onAbort();
|
|
1693
|
+
} else {
|
|
1694
|
+
options.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
1695
|
+
}
|
|
1696
|
+
const consume = async (stream) => {
|
|
1697
|
+
const chunks = [];
|
|
1698
|
+
let omittedBytes = 0;
|
|
1699
|
+
for await (const value of stream) {
|
|
1700
|
+
const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
|
|
1701
|
+
totalOutputBytes += chunk.byteLength;
|
|
1702
|
+
if (retainedOutputBytes < options.maxOutputBytes) {
|
|
1703
|
+
const remaining = options.maxOutputBytes - retainedOutputBytes;
|
|
1704
|
+
const kept = chunk.byteLength <= remaining ? chunk : chunk.subarray(0, remaining);
|
|
1705
|
+
chunks.push(kept);
|
|
1706
|
+
retainedOutputBytes += kept.byteLength;
|
|
1707
|
+
omittedBytes += chunk.byteLength - kept.byteLength;
|
|
1708
|
+
} else {
|
|
1709
|
+
omittedBytes += chunk.byteLength;
|
|
1710
|
+
}
|
|
1711
|
+
if (totalOutputBytes > options.maxOutputBytes) {
|
|
1712
|
+
outputLimitExceeded = true;
|
|
1713
|
+
stop("output-limit");
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
const text = new TextDecoder().decode(Buffer.concat(chunks));
|
|
1717
|
+
return omittedBytes > 0 ? `${text}
|
|
1718
|
+
… output limit exceeded (${omittedBytes} bytes omitted)` : text;
|
|
1719
|
+
};
|
|
1720
|
+
const exited = new Promise((resolve, reject) => {
|
|
1721
|
+
child.once("error", reject);
|
|
1722
|
+
child.once("exit", (exitCode) => resolve(exitCode ?? 137));
|
|
1723
|
+
});
|
|
1724
|
+
try {
|
|
1725
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1726
|
+
consume(child.stdout),
|
|
1727
|
+
consume(child.stderr),
|
|
1728
|
+
exited
|
|
1729
|
+
]);
|
|
1730
|
+
return {
|
|
1731
|
+
command,
|
|
1732
|
+
exitCode,
|
|
1733
|
+
stdout,
|
|
1734
|
+
stderr,
|
|
1735
|
+
timedOut,
|
|
1736
|
+
cancelled,
|
|
1737
|
+
outputLimitExceeded
|
|
1738
|
+
};
|
|
1739
|
+
} finally {
|
|
1740
|
+
clearTimeout(timeout);
|
|
1741
|
+
options.abortSignal?.removeEventListener("abort", onAbort);
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1744
|
+
var parseRuntimeVersion = (stdout, runtime) => {
|
|
1745
|
+
const version = stdout.trim();
|
|
1746
|
+
if (!version)
|
|
1747
|
+
throw new Error(`${runtime} did not report a server version.`);
|
|
1748
|
+
return version;
|
|
1749
|
+
};
|
|
1750
|
+
var validContainerIds = (stdout) => stdout.split(/\r?\n/).map((value) => value.trim()).filter((value) => /^[a-f0-9]{12,64}$/.test(value));
|
|
1751
|
+
var validVolumeNames = (stdout) => stdout.split(/\r?\n/).map((value) => value.trim()).filter((value) => /^zhivex-harness-[A-Za-z0-9_.-]+$/.test(value));
|
|
1752
|
+
var hostProcessIsAlive = (pid) => {
|
|
1753
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
1754
|
+
return false;
|
|
1755
|
+
try {
|
|
1756
|
+
process.kill(pid, 0);
|
|
1757
|
+
return true;
|
|
1758
|
+
} catch (error) {
|
|
1759
|
+
return error.code === "EPERM";
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
var MAX_SYNC_ENTRIES = 1e4;
|
|
1763
|
+
var updateSnapshotMetadata = (target, kind, relative, entry) => {
|
|
1764
|
+
target.update([
|
|
1765
|
+
kind,
|
|
1766
|
+
relative,
|
|
1767
|
+
entry.dev,
|
|
1768
|
+
entry.ino,
|
|
1769
|
+
entry.size,
|
|
1770
|
+
entry.mtimeMs,
|
|
1771
|
+
entry.ctimeMs,
|
|
1772
|
+
entry.mode & 511
|
|
1773
|
+
].join("\x00"));
|
|
1774
|
+
target.update("\x00");
|
|
1775
|
+
};
|
|
1776
|
+
var validateSynchronizedSnapshot = async (stagedRoot, previousRoot, maxWorkspaceBytes, maxFileWriteBytes) => {
|
|
1777
|
+
let entries = 0;
|
|
1778
|
+
let totalBytes = 0;
|
|
1779
|
+
const seal = createHash4("sha256");
|
|
1780
|
+
const metadata = createHash4("sha256");
|
|
1781
|
+
const visit = async (directory, relativeDirectory = "") => {
|
|
1782
|
+
const children = (await readdir3(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
1783
|
+
for (const child of children) {
|
|
1784
|
+
const name = child.name;
|
|
1785
|
+
const relative = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
|
1786
|
+
const absolute = path4.join(directory, name);
|
|
1787
|
+
if (!isInside2(stagedRoot, absolute) || relative.length > 1024) {
|
|
1788
|
+
throw new Error(`OCI workspace export contains an invalid path: ${relative}.`);
|
|
1789
|
+
}
|
|
1790
|
+
entries += 1;
|
|
1791
|
+
if (entries > MAX_SYNC_ENTRIES) {
|
|
1792
|
+
throw new Error(`OCI workspace export exceeds the ${MAX_SYNC_ENTRIES}-entry limit.`);
|
|
1793
|
+
}
|
|
1794
|
+
if (relative === "node_modules") {
|
|
1795
|
+
await rm(absolute, { recursive: true, force: true });
|
|
1796
|
+
continue;
|
|
1797
|
+
}
|
|
1798
|
+
if (child.isSymbolicLink()) {
|
|
1799
|
+
throw new Error(`OCI workspace export contains a symbolic link: ${relative}.`);
|
|
1800
|
+
}
|
|
1801
|
+
if (child.isDirectory()) {
|
|
1802
|
+
const directoryEntry = await lstat4(absolute);
|
|
1803
|
+
if (directoryEntry.isSymbolicLink() || !directoryEntry.isDirectory()) {
|
|
1804
|
+
throw new Error(`OCI workspace export directory changed while it was being validated: ${relative}.`);
|
|
1805
|
+
}
|
|
1806
|
+
updateSnapshotMetadata(metadata, "directory", relative, directoryEntry);
|
|
1807
|
+
seal.update(`directory\x00${relative}\x00${directoryEntry.mode & 511}\x00`);
|
|
1808
|
+
await visit(absolute, relative);
|
|
1809
|
+
continue;
|
|
1810
|
+
}
|
|
1811
|
+
if (!child.isFile()) {
|
|
1812
|
+
throw new Error(`OCI workspace export contains a special or linked file: ${relative}.`);
|
|
1813
|
+
}
|
|
1814
|
+
let file;
|
|
1815
|
+
try {
|
|
1816
|
+
file = await readRegularFileNoFollow(absolute, {
|
|
1817
|
+
label: `OCI workspace export file ${relative}`,
|
|
1818
|
+
maxBytes: maxWorkspaceBytes - totalBytes,
|
|
1819
|
+
requireSingleLink: true
|
|
1820
|
+
});
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
if (error instanceof FileSizeLimitError) {
|
|
1823
|
+
throw new Error(`OCI workspace export exceeds the ${maxWorkspaceBytes}-byte workspace limit.`);
|
|
1824
|
+
}
|
|
1825
|
+
throw error;
|
|
1826
|
+
}
|
|
1827
|
+
const contents = file.contents;
|
|
1828
|
+
const entry = file.stat;
|
|
1829
|
+
totalBytes += entry.size;
|
|
1830
|
+
if (totalBytes > maxWorkspaceBytes) {
|
|
1831
|
+
throw new Error(`OCI workspace export exceeds the ${maxWorkspaceBytes}-byte workspace limit.`);
|
|
1832
|
+
}
|
|
1833
|
+
const contentDigest = digest(contents);
|
|
1834
|
+
updateSnapshotMetadata(metadata, "file", relative, entry);
|
|
1835
|
+
seal.update(`file\x00${relative}\x00${entry.mode & 511}\x00${entry.size}\x00${contentDigest}\x00`);
|
|
1836
|
+
if (entry.size > maxFileWriteBytes) {
|
|
1837
|
+
const previous = path4.join(previousRoot, ...relative.split("/"));
|
|
1838
|
+
let unchanged = false;
|
|
1839
|
+
try {
|
|
1840
|
+
const previousFile = await readRegularFileNoFollow(previous, {
|
|
1841
|
+
label: `Previous OCI workspace file ${relative}`,
|
|
1842
|
+
maxBytes: entry.size
|
|
1843
|
+
});
|
|
1844
|
+
unchanged = previousFile.stat.size === entry.size && digest(previousFile.contents) === contentDigest;
|
|
1845
|
+
} catch (error) {
|
|
1846
|
+
if (error.code !== "ENOENT")
|
|
1847
|
+
throw error;
|
|
1848
|
+
}
|
|
1849
|
+
if (!unchanged) {
|
|
1850
|
+
throw new Error(`OCI command wrote a file above the ${maxFileWriteBytes}-byte limit: ${relative}.`);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
};
|
|
1855
|
+
await visit(stagedRoot);
|
|
1856
|
+
return {
|
|
1857
|
+
entries,
|
|
1858
|
+
totalBytes,
|
|
1859
|
+
seal: `sha256:${seal.digest("hex")}`,
|
|
1860
|
+
metadataFingerprint: `sha256:${metadata.digest("hex")}`
|
|
1861
|
+
};
|
|
1862
|
+
};
|
|
1863
|
+
var snapshotMetadataFingerprint = async (root, maxWorkspaceBytes) => {
|
|
1864
|
+
let entries = 0;
|
|
1865
|
+
let totalBytes = 0;
|
|
1866
|
+
const metadata = createHash4("sha256");
|
|
1867
|
+
const visit = async (directory, relativeDirectory = "") => {
|
|
1868
|
+
for (const name of (await readdir3(directory)).sort()) {
|
|
1869
|
+
const relative = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
|
1870
|
+
if (relative === "node_modules")
|
|
1871
|
+
continue;
|
|
1872
|
+
const absolute = path4.join(directory, name);
|
|
1873
|
+
if (!isInside2(root, absolute) || relative.length > 1024) {
|
|
1874
|
+
throw new Error(`OCI workspace snapshot contains an invalid path: ${relative}.`);
|
|
1875
|
+
}
|
|
1876
|
+
entries += 1;
|
|
1877
|
+
if (entries > MAX_SYNC_ENTRIES) {
|
|
1878
|
+
throw new Error(`OCI workspace snapshot exceeds the ${MAX_SYNC_ENTRIES}-entry limit.`);
|
|
1879
|
+
}
|
|
1880
|
+
const entry = await lstat4(absolute);
|
|
1881
|
+
if (entry.isSymbolicLink()) {
|
|
1882
|
+
throw new Error(`OCI workspace snapshot contains a symbolic link: ${relative}.`);
|
|
1883
|
+
}
|
|
1884
|
+
if (entry.isDirectory()) {
|
|
1885
|
+
updateSnapshotMetadata(metadata, "directory", relative, entry);
|
|
1886
|
+
await visit(absolute, relative);
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
if (!entry.isFile() || entry.nlink !== 1) {
|
|
1890
|
+
throw new Error(`OCI workspace snapshot contains a special or linked file: ${relative}.`);
|
|
1891
|
+
}
|
|
1892
|
+
totalBytes += entry.size;
|
|
1893
|
+
if (totalBytes > maxWorkspaceBytes) {
|
|
1894
|
+
throw new Error(`OCI workspace snapshot exceeds the ${maxWorkspaceBytes}-byte workspace limit.`);
|
|
1895
|
+
}
|
|
1896
|
+
updateSnapshotMetadata(metadata, "file", relative, entry);
|
|
1897
|
+
}
|
|
1898
|
+
};
|
|
1899
|
+
await visit(root);
|
|
1900
|
+
return `sha256:${metadata.digest("hex")}`;
|
|
1901
|
+
};
|
|
1902
|
+
var replaceSnapshot = async (snapshotRoot, stagedRoot) => {
|
|
1903
|
+
const backupRoot = `${snapshotRoot}.${randomUUID2()}.previous`;
|
|
1904
|
+
await rename2(snapshotRoot, backupRoot);
|
|
1905
|
+
try {
|
|
1906
|
+
await rename2(stagedRoot, snapshotRoot);
|
|
1907
|
+
} catch (error) {
|
|
1908
|
+
await rename2(backupRoot, snapshotRoot).catch(() => {
|
|
1909
|
+
return;
|
|
1910
|
+
});
|
|
1911
|
+
throw error;
|
|
1912
|
+
}
|
|
1913
|
+
await rm(backupRoot, { recursive: true, force: true });
|
|
1914
|
+
};
|
|
1915
|
+
var OCI_BACKGROUND_PROCESS_MARKER = "ZHIVEX_HARNESS_BACKGROUND_PROCESS";
|
|
1916
|
+
var OCI_COMMAND_TIMEOUT_MARKER = "ZHIVEX_HARNESS_COMMAND_TIMEOUT";
|
|
1917
|
+
var OCI_ATTESTATION_TIMEOUT_MS = 60000;
|
|
1918
|
+
var OCI_ATTESTATION_PROTOCOL_BYTES = 160;
|
|
1919
|
+
var OCI_ATTESTATION_PATTERN = /\nZHIVEX_HARNESS_ATTESTATION:(sha256:[a-f0-9]{64})\n$/;
|
|
1920
|
+
var OCI_COMMAND_RUNNER_SCRIPT = [
|
|
1921
|
+
"import { createHash } from 'node:crypto';",
|
|
1922
|
+
"import { spawn } from 'node:child_process';",
|
|
1923
|
+
"import { readFileSync } from 'node:fs';",
|
|
1924
|
+
"import { lstat, readFile, readdir, readlink, rm } from 'node:fs/promises';",
|
|
1925
|
+
"import path from 'node:path';",
|
|
1926
|
+
"const request=JSON.parse(readFileSync(0,'utf8'));",
|
|
1927
|
+
"if(!Array.isArray(request.commands)||request.commands.length===0||!request.commands.every((command)=>Array.isArray(command)&&command.length>0&&command.every((value)=>typeof value==='string')))throw new Error('invalid OCI command batch');",
|
|
1928
|
+
"if(!Number.isSafeInteger(request.maxProcessRuntimeMs)||request.maxProcessRuntimeMs<1)throw new Error('invalid OCI command timeout');",
|
|
1929
|
+
"const deadline=Date.now()+request.maxProcessRuntimeMs;",
|
|
1930
|
+
"const run=async(command,timeoutMs)=>await new Promise((resolve,reject)=>{",
|
|
1931
|
+
"const child=spawn(command[0],command.slice(1),{stdio:['ignore','inherit','inherit'],detached:true});",
|
|
1932
|
+
"let settled=false;let timedOut=false;",
|
|
1933
|
+
"const finish=(value,error)=>{if(settled)return;settled=true;clearTimeout(timer);error?reject(error):resolve(value);};",
|
|
1934
|
+
"const timer=setTimeout(()=>{timedOut=true;try{process.kill(-child.pid,'SIGKILL');}catch{}},timeoutMs);",
|
|
1935
|
+
"child.once('error',(error)=>finish(undefined,error));",
|
|
1936
|
+
"child.once('exit',(code)=>finish({exitCode:timedOut?124:(code??137),timedOut}));",
|
|
1937
|
+
"});",
|
|
1938
|
+
"for(const command of request.commands){const remaining=deadline-Date.now();if(remaining<=0){console.error('ZHIVEX_HARNESS_COMMAND_TIMEOUT');process.exit(124);}const result=await run(command,remaining);if(result.timedOut){console.error('ZHIVEX_HARNESS_COMMAND_TIMEOUT');process.exit(124);}if(result.exitCode!==0)process.exit(result.exitCode);}",
|
|
1939
|
+
"const unexpectedProcesses=async()=>(await readdir('/proc')).filter((name)=>/^\\d+$/.test(name)).map(Number).filter((pid)=>pid!==1&&pid!==process.pid);",
|
|
1940
|
+
"if((await unexpectedProcesses()).length>0){console.error('ZHIVEX_HARNESS_BACKGROUND_PROCESS');process.exit(126);}",
|
|
1941
|
+
"const root='/workspace';",
|
|
1942
|
+
"const seal=createHash('sha256');",
|
|
1943
|
+
"const modules=path.join(root,'node_modules');",
|
|
1944
|
+
"try{",
|
|
1945
|
+
"if(request.hasDependencies===true){const entry=await lstat(modules);if(!entry.isSymbolicLink()||(await readlink(modules))!=='/dependencies')throw new Error('read-only dependency mount was replaced');}else{await rm(modules,{recursive:true,force:true});}",
|
|
1946
|
+
"const digest=(value)=>`sha256:${createHash('sha256').update(value).digest('hex')}`;",
|
|
1947
|
+
"const same=(before,after)=>before.dev===after.dev&&before.ino===after.ino&&before.size===after.size&&before.mtimeMs===after.mtimeMs&&before.ctimeMs===after.ctimeMs&&(before.mode&0o777)===(after.mode&0o777);",
|
|
1948
|
+
"const visit=async(directory,base='')=>{",
|
|
1949
|
+
"for(const name of (await readdir(directory)).sort()){",
|
|
1950
|
+
"const relative=base?`${base}/${name}`:name;",
|
|
1951
|
+
"if(relative==='node_modules')continue;",
|
|
1952
|
+
"const absolute=path.join(directory,name);",
|
|
1953
|
+
"const entry=await lstat(absolute);",
|
|
1954
|
+
"if(entry.isSymbolicLink())throw new Error(`symbolic link: ${relative}`);",
|
|
1955
|
+
"if(entry.isDirectory()){seal.update(`directory\\0${relative}\\0${entry.mode&0o777}\\0`);await visit(absolute,relative);continue;}",
|
|
1956
|
+
"if(!entry.isFile()||entry.nlink!==1)throw new Error(`special or linked file: ${relative}`);",
|
|
1957
|
+
"const contents=await readFile(absolute);",
|
|
1958
|
+
"const after=await lstat(absolute);",
|
|
1959
|
+
"if(!same(entry,after))throw new Error(`changed while sealing: ${relative}`);",
|
|
1960
|
+
"seal.update(`file\\0${relative}\\0${entry.mode&0o777}\\0${entry.size}\\0${digest(contents)}\\0`);",
|
|
1961
|
+
"}",
|
|
1962
|
+
"};",
|
|
1963
|
+
"await visit(root);",
|
|
1964
|
+
"if((await unexpectedProcesses()).length>0){console.error('ZHIVEX_HARNESS_BACKGROUND_PROCESS');process.exit(126);}",
|
|
1965
|
+
"for(const name of await readdir('/tmp'))await rm(path.join('/tmp',name),{recursive:true,force:true});",
|
|
1966
|
+
"process.stdout.write(`\\nZHIVEX_HARNESS_ATTESTATION:sha256:${seal.digest('hex')}\\n`);",
|
|
1967
|
+
"}catch(error){console.error(`ZHIVEX_HARNESS_ATTESTATION_FAILURE:${error instanceof Error?error.message:String(error)}`);process.exit(127);}"
|
|
1968
|
+
].join("");
|
|
1969
|
+
var OCI_SESSION_CONTROLLER_SCRIPT = "setInterval(()=>{},2147483647);";
|
|
1970
|
+
|
|
1971
|
+
class CliOciRuntimeAdapter {
|
|
1972
|
+
runtime;
|
|
1973
|
+
hostEnvironment;
|
|
1974
|
+
sessions = new Map;
|
|
1975
|
+
runQueues = new Map;
|
|
1976
|
+
constructor(runtime) {
|
|
1977
|
+
this.runtime = runtime;
|
|
1978
|
+
this.hostEnvironment = Object.fromEntries(["DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST", "HOME", "PATH", "XDG_RUNTIME_DIR"].flatMap((name) => process.env[name] ? [[name, process.env[name]]] : []));
|
|
1979
|
+
}
|
|
1980
|
+
async cli(args, timeoutMs = 30000, maxOutputBytes = 1024 * 1024) {
|
|
1981
|
+
const result = await spawnBounded2([this.runtime, ...args], {
|
|
1982
|
+
env: this.hostEnvironment,
|
|
1983
|
+
timeoutMs,
|
|
1984
|
+
maxOutputBytes
|
|
1985
|
+
});
|
|
1986
|
+
if (result.exitCode !== 0 || result.timedOut || result.outputLimitExceeded) {
|
|
1987
|
+
const detail = (result.stderr || result.stdout).trim();
|
|
1988
|
+
throw new Error(`${this.runtime} ${args[0] ?? "command"} failed${detail ? `: ${detail}` : "."}`);
|
|
1989
|
+
}
|
|
1990
|
+
return result;
|
|
1991
|
+
}
|
|
1992
|
+
async inspectImage(image) {
|
|
1993
|
+
return observeOciPhase("oci_inspect", () => this.inspectImageUnobserved(image));
|
|
1994
|
+
}
|
|
1995
|
+
async inspectImageUnobserved(image) {
|
|
1996
|
+
const version = await this.cli(["version", "--format", "{{.Server.Version}}"]);
|
|
1997
|
+
const inspected = await this.cli(["image", "inspect", image, "--format", "{{json .}}"]);
|
|
1998
|
+
const document = JSON.parse(inspected.stdout);
|
|
1999
|
+
if (typeof document.Id !== "string" || !/^sha256:[a-f0-9]{64}$/.test(document.Id)) {
|
|
2000
|
+
throw new Error(`${this.runtime} returned an invalid immutable image id for ${image}.`);
|
|
2001
|
+
}
|
|
2002
|
+
const repoDigest = Array.isArray(document.RepoDigests) ? document.RepoDigests.find((value) => typeof value === "string" && /@sha256:[a-f0-9]{64}$/.test(value)) : undefined;
|
|
2003
|
+
return {
|
|
2004
|
+
runtime: this.runtime,
|
|
2005
|
+
runtimeVersion: parseRuntimeVersion(version.stdout, this.runtime),
|
|
2006
|
+
imageReference: image,
|
|
2007
|
+
imageId: document.Id,
|
|
2008
|
+
imageDigest: repoDigest?.slice(repoDigest.indexOf("@") + 1) ?? document.Id
|
|
2009
|
+
};
|
|
2010
|
+
}
|
|
2011
|
+
async forceRemove(name) {
|
|
2012
|
+
await spawnBounded2([this.runtime, "rm", "-f", name], {
|
|
2013
|
+
env: this.hostEnvironment,
|
|
2014
|
+
timeoutMs: 30000,
|
|
2015
|
+
maxOutputBytes: 20000
|
|
2016
|
+
}).catch(() => {
|
|
2017
|
+
return;
|
|
2018
|
+
});
|
|
2019
|
+
}
|
|
2020
|
+
async removeVolume(name) {
|
|
2021
|
+
const result = await spawnBounded2([this.runtime, "volume", "rm", name], {
|
|
2022
|
+
env: this.hostEnvironment,
|
|
2023
|
+
timeoutMs: 30000,
|
|
2024
|
+
maxOutputBytes: 20000
|
|
2025
|
+
});
|
|
2026
|
+
if (result.exitCode !== 0 && !/no such volume|not found/i.test(`${result.stderr}
|
|
2027
|
+
${result.stdout}`)) {
|
|
2028
|
+
throw new Error(`${this.runtime} could not remove OCI workspace volume ${name}: ${result.stderr.trim()}`);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
requestFingerprint(request) {
|
|
2032
|
+
return digest(JSON.stringify({
|
|
2033
|
+
runId: request.runId,
|
|
2034
|
+
snapshotRoot: request.snapshotRoot,
|
|
2035
|
+
dependencyRoot: request.dependencyRoot ?? null,
|
|
2036
|
+
imageId: request.imageId,
|
|
2037
|
+
limits: request.limits
|
|
2038
|
+
}));
|
|
2039
|
+
}
|
|
2040
|
+
async withRunLock(runId, operation) {
|
|
2041
|
+
const key = runHash(runId);
|
|
2042
|
+
const previous = this.runQueues.get(key) ?? Promise.resolve();
|
|
2043
|
+
let release;
|
|
2044
|
+
const gate = new Promise((resolve) => {
|
|
2045
|
+
release = resolve;
|
|
2046
|
+
});
|
|
2047
|
+
const tail = previous.then(() => gate, () => gate);
|
|
2048
|
+
this.runQueues.set(key, tail);
|
|
2049
|
+
await previous.catch(() => {
|
|
2050
|
+
return;
|
|
2051
|
+
});
|
|
2052
|
+
try {
|
|
2053
|
+
return await operation();
|
|
2054
|
+
} finally {
|
|
2055
|
+
release();
|
|
2056
|
+
if (this.runQueues.get(key) === tail)
|
|
2057
|
+
this.runQueues.delete(key);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
async destroySession(...args) {
|
|
2061
|
+
return observeOciPhase("oci_cleanup", () => this.destroySessionUnobserved(...args));
|
|
2062
|
+
}
|
|
2063
|
+
async destroySessionUnobserved(session) {
|
|
2064
|
+
if (this.sessions.get(session.key) === session)
|
|
2065
|
+
this.sessions.delete(session.key);
|
|
2066
|
+
await this.forceRemove(session.name);
|
|
2067
|
+
await this.removeVolume(session.volumeName);
|
|
2068
|
+
}
|
|
2069
|
+
async createSession(...args) {
|
|
2070
|
+
return observeOciPhase("oci_create", () => this.createSessionUnobserved(...args));
|
|
2071
|
+
}
|
|
2072
|
+
async createSessionUnobserved(request, requestFingerprint, snapshotSeal, hostFingerprint) {
|
|
2073
|
+
const resourceName = `zhivex-harness-${runHash(request.runId)}-${randomUUID2().slice(0, 8)}`;
|
|
2074
|
+
const name = `${resourceName}-job`;
|
|
2075
|
+
const volumeName = `${resourceName}-workspace`;
|
|
2076
|
+
const uid = process.getuid?.() ?? 65532;
|
|
2077
|
+
const gid = process.getgid?.() ?? 65532;
|
|
2078
|
+
const volumeArgs = [
|
|
2079
|
+
"volume",
|
|
2080
|
+
"create",
|
|
2081
|
+
"--driver",
|
|
2082
|
+
"local",
|
|
2083
|
+
"--opt",
|
|
2084
|
+
"type=tmpfs",
|
|
2085
|
+
"--opt",
|
|
2086
|
+
"device=tmpfs",
|
|
2087
|
+
"--opt",
|
|
2088
|
+
`o=size=${request.limits.maxWorkspaceBytes},uid=${uid},gid=${gid},mode=0700,noexec,nosuid,nodev`,
|
|
2089
|
+
"--label",
|
|
2090
|
+
`${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`,
|
|
2091
|
+
"--label",
|
|
2092
|
+
`com.zhivex.harness.run=${runHash(request.runId)}`,
|
|
2093
|
+
"--label",
|
|
2094
|
+
`${HARNESS_OCI_OWNER_PID_LABEL}=${process.pid}`,
|
|
2095
|
+
volumeName
|
|
2096
|
+
];
|
|
2097
|
+
const args = [
|
|
2098
|
+
"create",
|
|
2099
|
+
"--name",
|
|
2100
|
+
name,
|
|
2101
|
+
"--label",
|
|
2102
|
+
`${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`,
|
|
2103
|
+
"--label",
|
|
2104
|
+
`com.zhivex.harness.run=${runHash(request.runId)}`,
|
|
2105
|
+
"--network",
|
|
2106
|
+
"none",
|
|
2107
|
+
"--read-only",
|
|
2108
|
+
"--cap-drop",
|
|
2109
|
+
"ALL",
|
|
2110
|
+
"--security-opt",
|
|
2111
|
+
"no-new-privileges",
|
|
2112
|
+
"--pids-limit",
|
|
2113
|
+
String(request.limits.maxPids),
|
|
2114
|
+
"--memory",
|
|
2115
|
+
`${request.limits.maxMemoryMb}m`,
|
|
2116
|
+
"--cpus",
|
|
2117
|
+
String(request.limits.maxCpus),
|
|
2118
|
+
"--tmpfs",
|
|
2119
|
+
`/tmp:rw,noexec,nosuid,nodev,size=${request.limits.tmpfsMb}m`,
|
|
2120
|
+
"--volume",
|
|
2121
|
+
`${volumeName}:/workspace`,
|
|
2122
|
+
"--user",
|
|
2123
|
+
`${uid}:${gid}`,
|
|
2124
|
+
"--env",
|
|
2125
|
+
"HOME=/tmp",
|
|
2126
|
+
"--env",
|
|
2127
|
+
"TMPDIR=/tmp",
|
|
2128
|
+
"--env",
|
|
2129
|
+
"NPM_CONFIG_CACHE=/tmp/npm-cache",
|
|
2130
|
+
"--env",
|
|
2131
|
+
"CI=1",
|
|
2132
|
+
...request.dependencyRoot ? ["--volume", `${request.dependencyRoot}:/dependencies:ro`] : [],
|
|
2133
|
+
"--workdir",
|
|
2134
|
+
"/workspace",
|
|
2135
|
+
"--entrypoint",
|
|
2136
|
+
"node",
|
|
2137
|
+
request.imageId,
|
|
2138
|
+
"--input-type=module",
|
|
2139
|
+
"-e",
|
|
2140
|
+
OCI_SESSION_CONTROLLER_SCRIPT
|
|
2141
|
+
];
|
|
2142
|
+
let volumeCreated = false;
|
|
2143
|
+
try {
|
|
2144
|
+
await this.cli(volumeArgs);
|
|
2145
|
+
volumeCreated = true;
|
|
2146
|
+
await this.cli(args);
|
|
2147
|
+
await this.cli(["start", name]);
|
|
2148
|
+
await this.cli([
|
|
2149
|
+
"cp",
|
|
2150
|
+
"-a",
|
|
2151
|
+
`${request.snapshotRoot}${path4.sep}.`,
|
|
2152
|
+
`${name}:/workspace`
|
|
2153
|
+
], 60000);
|
|
2154
|
+
if (request.dependencyRoot) {
|
|
2155
|
+
await this.cli([
|
|
2156
|
+
"exec",
|
|
2157
|
+
"--workdir",
|
|
2158
|
+
"/workspace",
|
|
2159
|
+
"--user",
|
|
2160
|
+
`${uid}:${gid}`,
|
|
2161
|
+
name,
|
|
2162
|
+
"node",
|
|
2163
|
+
"--input-type=module",
|
|
2164
|
+
"-e",
|
|
2165
|
+
"import { symlink } from 'node:fs/promises'; await symlink('/dependencies', '/workspace/node_modules', 'dir')"
|
|
2166
|
+
]);
|
|
2167
|
+
}
|
|
2168
|
+
const session = {
|
|
2169
|
+
key: runHash(request.runId),
|
|
2170
|
+
requestFingerprint,
|
|
2171
|
+
name,
|
|
2172
|
+
volumeName,
|
|
2173
|
+
snapshotSeal,
|
|
2174
|
+
hostFingerprint
|
|
2175
|
+
};
|
|
2176
|
+
this.sessions.set(session.key, session);
|
|
2177
|
+
return session;
|
|
2178
|
+
} catch (error) {
|
|
2179
|
+
await this.forceRemove(name);
|
|
2180
|
+
if (volumeCreated)
|
|
2181
|
+
await this.removeVolume(volumeName).catch(() => {
|
|
2182
|
+
return;
|
|
2183
|
+
});
|
|
2184
|
+
throw error;
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
async runInSession(...args) {
|
|
2188
|
+
return observeOciPhase("oci_execute", () => this.runInSessionUnobserved(...args));
|
|
2189
|
+
}
|
|
2190
|
+
async runInSessionUnobserved(request, session, sessionReused) {
|
|
2191
|
+
const totalStartedAt = process.hrtime.bigint();
|
|
2192
|
+
const uid = process.getuid?.() ?? 65532;
|
|
2193
|
+
const gid = process.getgid?.() ?? 65532;
|
|
2194
|
+
let forcedRemoval;
|
|
2195
|
+
const forceRemove = () => {
|
|
2196
|
+
forcedRemoval ??= this.forceRemove(session.name);
|
|
2197
|
+
};
|
|
2198
|
+
const commandStartedAt = process.hrtime.bigint();
|
|
2199
|
+
const result = await spawnBounded2([
|
|
2200
|
+
this.runtime,
|
|
2201
|
+
"exec",
|
|
2202
|
+
"-i",
|
|
2203
|
+
"--workdir",
|
|
2204
|
+
"/workspace",
|
|
2205
|
+
"--user",
|
|
2206
|
+
`${uid}:${gid}`,
|
|
2207
|
+
session.name,
|
|
2208
|
+
"node",
|
|
2209
|
+
"--input-type=module",
|
|
2210
|
+
"-e",
|
|
2211
|
+
OCI_COMMAND_RUNNER_SCRIPT
|
|
2212
|
+
], {
|
|
2213
|
+
env: this.hostEnvironment,
|
|
2214
|
+
timeoutMs: request.limits.maxProcessRuntimeMs + OCI_ATTESTATION_TIMEOUT_MS,
|
|
2215
|
+
maxOutputBytes: request.limits.maxProcessOutputBytes + OCI_ATTESTATION_PROTOCOL_BYTES,
|
|
2216
|
+
input: JSON.stringify({
|
|
2217
|
+
commands: request.commands,
|
|
2218
|
+
maxProcessRuntimeMs: request.limits.maxProcessRuntimeMs,
|
|
2219
|
+
hasDependencies: Boolean(request.dependencyRoot)
|
|
2220
|
+
}),
|
|
2221
|
+
...request.abortSignal ? { abortSignal: request.abortSignal } : {},
|
|
2222
|
+
onStop: forceRemove
|
|
2223
|
+
});
|
|
2224
|
+
const commandAndAttestationMs = elapsedMilliseconds(commandStartedAt);
|
|
2225
|
+
const attestationMatch = result.exitCode === 0 ? result.stdout.match(OCI_ATTESTATION_PATTERN) : null;
|
|
2226
|
+
const commandResult = attestationMatch ? { ...result, stdout: result.stdout.slice(0, -attestationMatch[0].length) } : result;
|
|
2227
|
+
if (Buffer.byteLength(commandResult.stdout) + Buffer.byteLength(commandResult.stderr) > request.limits.maxProcessOutputBytes) {
|
|
2228
|
+
commandResult.outputLimitExceeded = true;
|
|
2229
|
+
}
|
|
2230
|
+
const command = request.commands.length === 1 ? request.commands[0] : ["<oci-batch>", String(request.commands.length)];
|
|
2231
|
+
const backgroundProcess = commandResult.exitCode === 126 && commandResult.stderr.includes(OCI_BACKGROUND_PROCESS_MARKER);
|
|
2232
|
+
const commandTimedOut = commandResult.exitCode === 124 && commandResult.stderr.includes(OCI_COMMAND_TIMEOUT_MARKER);
|
|
2233
|
+
const attestationFailure = commandResult.exitCode === 127 ? commandResult.stderr.match(/ZHIVEX_HARNESS_ATTESTATION_FAILURE:([^\r\n]*)/) : null;
|
|
2234
|
+
if (commandResult.exitCode !== 0 || commandResult.timedOut || commandResult.cancelled || commandResult.outputLimitExceeded) {
|
|
2235
|
+
forceRemove();
|
|
2236
|
+
await forcedRemoval;
|
|
2237
|
+
await this.destroySession(session);
|
|
2238
|
+
if (attestationFailure) {
|
|
2239
|
+
throw new Error(`OCI workspace attestation failed: ${attestationFailure[1] ?? "unknown failure"}.`);
|
|
2240
|
+
}
|
|
2241
|
+
return {
|
|
2242
|
+
...commandResult,
|
|
2243
|
+
command,
|
|
2244
|
+
commands: request.commands,
|
|
2245
|
+
...commandTimedOut ? { timedOut: true, exitCode: 124 } : {},
|
|
2246
|
+
...backgroundProcess ? {
|
|
2247
|
+
exitCode: 126,
|
|
2248
|
+
stderr: [
|
|
2249
|
+
commandResult.stderr.replace(OCI_BACKGROUND_PROCESS_MARKER, "").trim(),
|
|
2250
|
+
"OCI command left background processes running; its workspace changes were discarded."
|
|
2251
|
+
].filter(Boolean).join(`
|
|
2252
|
+
`)
|
|
2253
|
+
} : {},
|
|
2254
|
+
sessionReused,
|
|
2255
|
+
workspacePublished: false,
|
|
2256
|
+
workspaceExported: false,
|
|
2257
|
+
phaseLatencies: {
|
|
2258
|
+
commandAndAttestationMs,
|
|
2259
|
+
totalMs: elapsedMilliseconds(totalStartedAt)
|
|
2260
|
+
}
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
try {
|
|
2264
|
+
if (!attestationMatch) {
|
|
2265
|
+
throw new Error("OCI command attestation returned an invalid workspace seal.");
|
|
2266
|
+
}
|
|
2267
|
+
const workspaceSeal = attestationMatch[1];
|
|
2268
|
+
if (workspaceSeal === session.snapshotSeal) {
|
|
2269
|
+
return {
|
|
2270
|
+
...commandResult,
|
|
2271
|
+
command,
|
|
2272
|
+
commands: request.commands,
|
|
2273
|
+
sessionReused,
|
|
2274
|
+
workspacePublished: true,
|
|
2275
|
+
workspaceExported: false,
|
|
2276
|
+
phaseLatencies: {
|
|
2277
|
+
commandAndAttestationMs,
|
|
2278
|
+
totalMs: elapsedMilliseconds(totalStartedAt)
|
|
2279
|
+
}
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
const workspaceExportStartedAt = process.hrtime.bigint();
|
|
2283
|
+
const stagedRoot = path4.join(path4.dirname(request.snapshotRoot), `.workspace-sync-${randomUUID2()}`);
|
|
2284
|
+
try {
|
|
2285
|
+
await privateDirectory(stagedRoot);
|
|
2286
|
+
await observeOciPhase("oci_export", () => this.cli(["cp", `${session.name}:/workspace/.`, stagedRoot], 60000));
|
|
2287
|
+
const validated = await validateSynchronizedSnapshot(stagedRoot, request.snapshotRoot, request.limits.maxWorkspaceBytes, request.limits.maxFileWriteBytes);
|
|
2288
|
+
await replaceSnapshot(request.snapshotRoot, stagedRoot);
|
|
2289
|
+
session.snapshotSeal = validated.seal;
|
|
2290
|
+
session.hostFingerprint = validated.metadataFingerprint;
|
|
2291
|
+
} finally {
|
|
2292
|
+
await rm(stagedRoot, { recursive: true, force: true });
|
|
2293
|
+
}
|
|
2294
|
+
return {
|
|
2295
|
+
...commandResult,
|
|
2296
|
+
command,
|
|
2297
|
+
commands: request.commands,
|
|
2298
|
+
sessionReused,
|
|
2299
|
+
workspacePublished: true,
|
|
2300
|
+
workspaceExported: true,
|
|
2301
|
+
phaseLatencies: {
|
|
2302
|
+
commandAndAttestationMs,
|
|
2303
|
+
workspaceExportMs: elapsedMilliseconds(workspaceExportStartedAt),
|
|
2304
|
+
totalMs: elapsedMilliseconds(totalStartedAt)
|
|
2305
|
+
}
|
|
2306
|
+
};
|
|
2307
|
+
} catch (error) {
|
|
2308
|
+
await this.destroySession(session);
|
|
2309
|
+
throw error;
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
async runBatchLocked(request) {
|
|
2313
|
+
if (request.commands.length === 0 || request.commands.some((command) => command.length === 0)) {
|
|
2314
|
+
throw new Error("OCI command batch must contain at least one non-empty argv command.");
|
|
2315
|
+
}
|
|
2316
|
+
const totalStartedAt = process.hrtime.bigint();
|
|
2317
|
+
const key = runHash(request.runId);
|
|
2318
|
+
const requestFingerprint = this.requestFingerprint(request);
|
|
2319
|
+
let session = this.sessions.get(key);
|
|
2320
|
+
let sessionReused = Boolean(session);
|
|
2321
|
+
let validated;
|
|
2322
|
+
let hostSynchronizationMs;
|
|
2323
|
+
let sessionCreationMs;
|
|
2324
|
+
if (session) {
|
|
2325
|
+
const synchronizationStartedAt = process.hrtime.bigint();
|
|
2326
|
+
const hostFingerprint = await snapshotMetadataFingerprint(request.snapshotRoot, request.limits.maxWorkspaceBytes);
|
|
2327
|
+
if (session.requestFingerprint !== requestFingerprint || session.hostFingerprint !== hostFingerprint) {
|
|
2328
|
+
await this.destroySession(session);
|
|
2329
|
+
session = undefined;
|
|
2330
|
+
sessionReused = false;
|
|
2331
|
+
}
|
|
2332
|
+
hostSynchronizationMs = elapsedMilliseconds(synchronizationStartedAt);
|
|
2333
|
+
}
|
|
2334
|
+
if (!session) {
|
|
2335
|
+
const creationStartedAt = process.hrtime.bigint();
|
|
2336
|
+
validated = await validateSynchronizedSnapshot(request.snapshotRoot, request.snapshotRoot, request.limits.maxWorkspaceBytes, request.limits.maxFileWriteBytes);
|
|
2337
|
+
session = await this.createSession(request, requestFingerprint, validated.seal, validated.metadataFingerprint);
|
|
2338
|
+
sessionCreationMs = elapsedMilliseconds(creationStartedAt);
|
|
2339
|
+
}
|
|
2340
|
+
try {
|
|
2341
|
+
const result = await this.runInSession(request, session, sessionReused);
|
|
2342
|
+
return {
|
|
2343
|
+
...result,
|
|
2344
|
+
phaseLatencies: {
|
|
2345
|
+
...result.phaseLatencies,
|
|
2346
|
+
...hostSynchronizationMs !== undefined ? { hostSynchronizationMs } : {},
|
|
2347
|
+
...sessionCreationMs !== undefined ? { sessionCreationMs } : {},
|
|
2348
|
+
totalMs: elapsedMilliseconds(totalStartedAt)
|
|
2349
|
+
}
|
|
2350
|
+
};
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
if (this.sessions.get(key) === session)
|
|
2353
|
+
await this.destroySession(session).catch(() => {
|
|
2354
|
+
return;
|
|
2355
|
+
});
|
|
2356
|
+
throw error;
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
async run(request) {
|
|
2360
|
+
const { command, ...shared } = request;
|
|
2361
|
+
const result = await this.runBatch({ ...shared, commands: [command] });
|
|
2362
|
+
return { ...result, command };
|
|
2363
|
+
}
|
|
2364
|
+
async runBatch(request) {
|
|
2365
|
+
return this.withRunLock(request.runId, () => this.runBatchLocked(request));
|
|
2366
|
+
}
|
|
2367
|
+
async removeByFilters(filters) {
|
|
2368
|
+
const listed = await spawnBounded2([this.runtime, "ps", "-aq", ...filters.flatMap((filter) => ["--filter", filter])], {
|
|
2369
|
+
env: this.hostEnvironment,
|
|
2370
|
+
timeoutMs: 30000,
|
|
2371
|
+
maxOutputBytes: 1024 * 1024
|
|
2372
|
+
});
|
|
2373
|
+
if (listed.exitCode !== 0)
|
|
2374
|
+
return 0;
|
|
2375
|
+
const ids = validContainerIds(listed.stdout);
|
|
2376
|
+
if (ids.length === 0)
|
|
2377
|
+
return 0;
|
|
2378
|
+
const removed = await spawnBounded2([this.runtime, "rm", "-f", ...ids], {
|
|
2379
|
+
env: this.hostEnvironment,
|
|
2380
|
+
timeoutMs: 60000,
|
|
2381
|
+
maxOutputBytes: 1024 * 1024
|
|
2382
|
+
});
|
|
2383
|
+
if (removed.exitCode !== 0) {
|
|
2384
|
+
throw new Error(`${this.runtime} could not remove harness containers: ${removed.stderr.trim()}`);
|
|
2385
|
+
}
|
|
2386
|
+
return ids.length;
|
|
2387
|
+
}
|
|
2388
|
+
async removeVolumesByFilters(filters) {
|
|
2389
|
+
const listed = await spawnBounded2([
|
|
2390
|
+
this.runtime,
|
|
2391
|
+
"volume",
|
|
2392
|
+
"ls",
|
|
2393
|
+
"-q",
|
|
2394
|
+
...filters.flatMap((filter) => ["--filter", filter])
|
|
2395
|
+
], {
|
|
2396
|
+
env: this.hostEnvironment,
|
|
2397
|
+
timeoutMs: 30000,
|
|
2398
|
+
maxOutputBytes: 1024 * 1024
|
|
2399
|
+
});
|
|
2400
|
+
if (listed.exitCode !== 0)
|
|
2401
|
+
return 0;
|
|
2402
|
+
let removed = 0;
|
|
2403
|
+
for (const name of validVolumeNames(listed.stdout)) {
|
|
2404
|
+
try {
|
|
2405
|
+
await this.removeVolume(name);
|
|
2406
|
+
removed += 1;
|
|
2407
|
+
} catch {}
|
|
2408
|
+
}
|
|
2409
|
+
return removed;
|
|
2410
|
+
}
|
|
2411
|
+
async removeRunningOrphans() {
|
|
2412
|
+
const listed = await spawnBounded2([
|
|
2413
|
+
this.runtime,
|
|
2414
|
+
"ps",
|
|
2415
|
+
"-q",
|
|
2416
|
+
"--filter",
|
|
2417
|
+
`label=${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`,
|
|
2418
|
+
"--filter",
|
|
2419
|
+
"status=running"
|
|
2420
|
+
], {
|
|
2421
|
+
env: this.hostEnvironment,
|
|
2422
|
+
timeoutMs: 30000,
|
|
2423
|
+
maxOutputBytes: 1024 * 1024
|
|
2424
|
+
});
|
|
2425
|
+
if (listed.exitCode !== 0)
|
|
2426
|
+
return 0;
|
|
2427
|
+
let removed = 0;
|
|
2428
|
+
for (const id of validContainerIds(listed.stdout)) {
|
|
2429
|
+
const inspected = await this.cli([
|
|
2430
|
+
"inspect",
|
|
2431
|
+
id,
|
|
2432
|
+
"--format",
|
|
2433
|
+
"{{json .Config.Labels}}"
|
|
2434
|
+
], 30000, 20000);
|
|
2435
|
+
const labels = JSON.parse(inspected.stdout);
|
|
2436
|
+
const ownerPid = Number(labels[HARNESS_OCI_OWNER_PID_LABEL]);
|
|
2437
|
+
if (hostProcessIsAlive(ownerPid))
|
|
2438
|
+
continue;
|
|
2439
|
+
await this.forceRemove(id);
|
|
2440
|
+
removed += 1;
|
|
2441
|
+
}
|
|
2442
|
+
return removed;
|
|
2443
|
+
}
|
|
2444
|
+
async removeRunContainers(runId) {
|
|
2445
|
+
const key = runHash(runId);
|
|
2446
|
+
const session = this.sessions.get(key);
|
|
2447
|
+
let cachedResources = 0;
|
|
2448
|
+
if (session) {
|
|
2449
|
+
await this.destroySession(session);
|
|
2450
|
+
cachedResources = 2;
|
|
2451
|
+
}
|
|
2452
|
+
const filters = [
|
|
2453
|
+
`label=${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`,
|
|
2454
|
+
`label=com.zhivex.harness.run=${key}`
|
|
2455
|
+
];
|
|
2456
|
+
const containers = await this.removeByFilters(filters);
|
|
2457
|
+
const volumes = await this.removeVolumesByFilters(filters);
|
|
2458
|
+
return cachedResources + containers + volumes;
|
|
2459
|
+
}
|
|
2460
|
+
async cleanupOrphans() {
|
|
2461
|
+
let removed = 0;
|
|
2462
|
+
for (const session of [...this.sessions.values()]) {
|
|
2463
|
+
await this.destroySession(session);
|
|
2464
|
+
removed += 2;
|
|
2465
|
+
}
|
|
2466
|
+
this.sessions.clear();
|
|
2467
|
+
for (const status of ["created", "exited", "dead", "paused"]) {
|
|
2468
|
+
removed += await this.removeByFilters([
|
|
2469
|
+
`label=${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`,
|
|
2470
|
+
`status=${status}`
|
|
2471
|
+
]);
|
|
2472
|
+
}
|
|
2473
|
+
removed += await this.removeRunningOrphans();
|
|
2474
|
+
removed += await this.removeVolumesByFilters([
|
|
2475
|
+
`label=${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`
|
|
2476
|
+
]);
|
|
2477
|
+
return removed;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
var SNAPSHOT_INVENTORY_PAGE_SIZE = 5000;
|
|
2481
|
+
var COPY_ON_WRITE_UNSUPPORTED_CODES = new Set(["EINVAL", "ENOSYS", "ENOTSUP", "EOPNOTSUPP"]);
|
|
2482
|
+
var collectSnapshotInventory = async (workspace, maxWorkspaceBytes, metrics, maxEntries = Number.MAX_SAFE_INTEGER) => {
|
|
2483
|
+
const files = new Map;
|
|
2484
|
+
if (metrics)
|
|
2485
|
+
metrics.inventoryPasses += 1;
|
|
2486
|
+
let totalBytes = 0;
|
|
2487
|
+
let cursor;
|
|
2488
|
+
do {
|
|
2489
|
+
const page = await workspace.listFiles(".", {
|
|
2490
|
+
limit: SNAPSHOT_INVENTORY_PAGE_SIZE,
|
|
2491
|
+
includeDigests: false,
|
|
2492
|
+
...cursor ? { cursor } : {}
|
|
2493
|
+
});
|
|
2494
|
+
if (metrics)
|
|
2495
|
+
metrics.inventoryPages += 1;
|
|
2496
|
+
for await (const candidates of boundedBatches(page.files, async (file) => {
|
|
2497
|
+
const absolute = path4.join(workspace.root, ...file.path.split("/"));
|
|
2498
|
+
if (!isInside2(workspace.root, absolute))
|
|
2499
|
+
throw new Error(`Snapshot file escaped its workspace: ${file.path}.`);
|
|
2500
|
+
if (await realpath3(absolute) !== absolute)
|
|
2501
|
+
throw new Error("Snapshot inventory path traverses a symbolic link.");
|
|
2502
|
+
const entry = await statRegularFileNoFollow(absolute, { label: "Snapshot inventory file" });
|
|
2503
|
+
return { file, absolute, entry };
|
|
2504
|
+
}, 4)) {
|
|
2505
|
+
for (const { entry } of candidates) {
|
|
2506
|
+
if (entry.size > maxWorkspaceBytes - totalBytes)
|
|
2507
|
+
throw new FileSizeLimitError("Snapshot inventory file", maxWorkspaceBytes);
|
|
2508
|
+
totalBytes += entry.size;
|
|
2509
|
+
}
|
|
2510
|
+
for await (const batch of boundedBatches(candidates, async ({ file, absolute, entry }) => {
|
|
2511
|
+
const stable = await readRegularFileNoFollow(absolute, {
|
|
2512
|
+
label: "Snapshot inventory file",
|
|
2513
|
+
maxBytes: entry.size
|
|
2514
|
+
});
|
|
2515
|
+
if (stable.stat.dev !== entry.dev || stable.stat.ino !== entry.ino || stable.stat.size !== entry.size || stable.stat.mtimeMs !== entry.mtimeMs || stable.stat.ctimeMs !== entry.ctimeMs) {
|
|
2516
|
+
throw new Error(`Snapshot inventory file changed while being read: ${file.path}.`);
|
|
2517
|
+
}
|
|
2518
|
+
return { path: file.path, digest: digest(stable.contents), mode: stable.stat.mode & 511, bytes: stable.contents.byteLength };
|
|
2519
|
+
}, 4))
|
|
2520
|
+
for (const file of batch) {
|
|
2521
|
+
if (files.size >= maxEntries)
|
|
2522
|
+
throw new Error("Workspace snapshot entry limit exceeded.");
|
|
2523
|
+
files.set(file.path, file);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
cursor = page.nextCursor;
|
|
2527
|
+
} while (cursor);
|
|
2528
|
+
return files;
|
|
2529
|
+
};
|
|
2530
|
+
var readSnapshotFile = async (workspace, expected, metrics, requireSingleLink = false) => {
|
|
2531
|
+
const absolute = path4.join(workspace.root, ...expected.path.split("/"));
|
|
2532
|
+
if (!isInside2(workspace.root, absolute)) {
|
|
2533
|
+
throw new Error(`Snapshot file escaped its workspace: ${expected.path}.`);
|
|
2534
|
+
}
|
|
2535
|
+
if (await realpath3(absolute) !== absolute)
|
|
2536
|
+
throw new Error("Snapshot source path traverses a symbolic link.");
|
|
2537
|
+
const file = await readRegularFileNoFollow(absolute, {
|
|
2538
|
+
label: `Snapshot source ${expected.path}`,
|
|
2539
|
+
maxBytes: expected.bytes,
|
|
2540
|
+
requireSingleLink
|
|
2541
|
+
});
|
|
2542
|
+
const { contents } = file;
|
|
2543
|
+
const entry = file.stat;
|
|
2544
|
+
const actualDigest = digest(contents);
|
|
2545
|
+
if (actualDigest !== expected.digest || contents.byteLength !== expected.bytes || (entry.mode & 511) !== expected.mode) {
|
|
2546
|
+
throw new Error(`Snapshot source changed after inventory: ${expected.path}.`);
|
|
2547
|
+
}
|
|
2548
|
+
if (metrics) {
|
|
2549
|
+
metrics.verifiedContentReads += 1;
|
|
2550
|
+
metrics.verifiedContentBytes += contents.byteLength;
|
|
2551
|
+
}
|
|
2552
|
+
return { ...expected, contents };
|
|
2553
|
+
};
|
|
2554
|
+
var copySnapshotFile = async (source, target, metrics) => {
|
|
2555
|
+
try {
|
|
2556
|
+
await copyFile(source, target, fsConstants.COPYFILE_FICLONE_FORCE);
|
|
2557
|
+
} catch (error) {
|
|
2558
|
+
const code = error.code;
|
|
2559
|
+
if (!code || !COPY_ON_WRITE_UNSUPPORTED_CODES.has(code))
|
|
2560
|
+
throw error;
|
|
2561
|
+
if (metrics)
|
|
2562
|
+
metrics.snapshotCloneFallbacks += 1;
|
|
2563
|
+
await copyFile(source, target);
|
|
2564
|
+
}
|
|
2565
|
+
};
|
|
2566
|
+
var copyWorkspaceSnapshot = async (source, baseRoot, snapshotRoot, maxWorkspaceBytes, metrics) => {
|
|
2567
|
+
const files = await collectSnapshotInventory(source, maxWorkspaceBytes, metrics);
|
|
2568
|
+
const totalBytes = [...files.values()].reduce((total, file) => total + file.bytes, 0);
|
|
2569
|
+
if (totalBytes > maxWorkspaceBytes) {
|
|
2570
|
+
throw new Error(`Workspace snapshot exceeds the ${maxWorkspaceBytes}-byte OCI limit.`);
|
|
2571
|
+
}
|
|
2572
|
+
for (const root of [baseRoot, snapshotRoot]) {
|
|
2573
|
+
await rm(root, { recursive: true, force: true });
|
|
2574
|
+
await privateDirectory(root);
|
|
2575
|
+
}
|
|
2576
|
+
for await (const _batch of boundedBatches([...files.values()], async (expected) => {
|
|
2577
|
+
const file = await readSnapshotFile(source, expected, metrics);
|
|
2578
|
+
const baseTarget = path4.join(baseRoot, ...file.path.split("/"));
|
|
2579
|
+
const snapshotTarget = path4.join(snapshotRoot, ...file.path.split("/"));
|
|
2580
|
+
await Promise.all([
|
|
2581
|
+
mkdir3(path4.dirname(baseTarget), { recursive: true, mode: 448 }),
|
|
2582
|
+
mkdir3(path4.dirname(snapshotTarget), { recursive: true, mode: 448 })
|
|
2583
|
+
]);
|
|
2584
|
+
await writeFile(baseTarget, file.contents, { mode: file.mode });
|
|
2585
|
+
await chmod(baseTarget, file.mode);
|
|
2586
|
+
await copySnapshotFile(baseTarget, snapshotTarget, metrics);
|
|
2587
|
+
await chmod(snapshotTarget, file.mode);
|
|
2588
|
+
}, 4)) {}
|
|
2589
|
+
if (metrics) {
|
|
2590
|
+
metrics.snapshotFiles += files.size;
|
|
2591
|
+
metrics.snapshotBytes += totalBytes;
|
|
2592
|
+
}
|
|
2593
|
+
return { files: files.size, bytes: totalBytes };
|
|
2594
|
+
};
|
|
2595
|
+
var deliveryPaths = (value) => {
|
|
2596
|
+
if (value === undefined)
|
|
2597
|
+
return [];
|
|
2598
|
+
if (!Array.isArray(value) || value.length > MAX_SYNC_ENTRIES || value.some((item) => typeof item !== "string" || !item || item.length > 8192 || item.includes("\\") || item.includes("\x00") || path4.posix.isAbsolute(item) || item.split("/").some((segment) => !segment || segment === "." || segment === "..")) || Buffer.byteLength(JSON.stringify(value)) > 512 * 1024) {
|
|
2599
|
+
throw new Error("Invalid or oversized OCI delivery path history.");
|
|
2600
|
+
}
|
|
2601
|
+
return [...new Set(value)];
|
|
2602
|
+
};
|
|
2603
|
+
var patchPayload = (runId, entries) => ({
|
|
2604
|
+
schemaVersion: HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION,
|
|
2605
|
+
kind: "environment-patch",
|
|
2606
|
+
runId,
|
|
2607
|
+
entries: entries.map(({ beforeContent: _before, afterContent: _after, ...entry }) => entry)
|
|
2608
|
+
});
|
|
2609
|
+
var textContent = (file, maxFileWriteBytes) => {
|
|
2610
|
+
if (file.contents.byteLength > maxFileWriteBytes) {
|
|
2611
|
+
throw new Error(`Environment patch file exceeds the ${maxFileWriteBytes}-byte import limit: ${file.path}.`);
|
|
2612
|
+
}
|
|
2613
|
+
if (file.contents.includes(0)) {
|
|
2614
|
+
throw new Error(`Environment patch contains a binary file that cannot be imported: ${file.path}.`);
|
|
2615
|
+
}
|
|
2616
|
+
try {
|
|
2617
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(file.contents);
|
|
2618
|
+
} catch {
|
|
2619
|
+
throw new Error("Environment patch requires valid UTF-8 text.");
|
|
2620
|
+
}
|
|
2621
|
+
};
|
|
2622
|
+
var createEnvironmentPatch = async (runId, base, current, maxFileWriteBytes, maxWorkspaceBytes, metrics, identity) => {
|
|
2623
|
+
const [before, after] = await Promise.all([
|
|
2624
|
+
collectSnapshotInventory(base, maxWorkspaceBytes, metrics),
|
|
2625
|
+
collectSnapshotInventory(current, maxWorkspaceBytes, metrics)
|
|
2626
|
+
]);
|
|
2627
|
+
const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
|
|
2628
|
+
const entries = [];
|
|
2629
|
+
let totalBytes = 0;
|
|
2630
|
+
for (const filePath of paths) {
|
|
2631
|
+
const oldFile = before.get(filePath);
|
|
2632
|
+
const newFile = after.get(filePath);
|
|
2633
|
+
if (oldFile?.digest === newFile?.digest && oldFile?.mode === newFile?.mode)
|
|
2634
|
+
continue;
|
|
2635
|
+
const operation = !oldFile ? "create" : !newFile ? "delete" : "update";
|
|
2636
|
+
const beforeContent = oldFile ? textContent(await readSnapshotFile(base, oldFile, metrics), maxFileWriteBytes) : undefined;
|
|
2637
|
+
const afterContent = newFile ? textContent(await readSnapshotFile(current, newFile, metrics), maxFileWriteBytes) : undefined;
|
|
2638
|
+
const bytes = newFile?.bytes ?? 0;
|
|
2639
|
+
totalBytes += bytes;
|
|
2640
|
+
entries.push({
|
|
2641
|
+
path: filePath,
|
|
2642
|
+
operation,
|
|
2643
|
+
...oldFile ? { beforeDigest: oldFile.digest } : {},
|
|
2644
|
+
...oldFile ? { beforeMode: oldFile.mode } : {},
|
|
2645
|
+
...beforeContent !== undefined ? { beforeContent } : {},
|
|
2646
|
+
...newFile ? { afterDigest: newFile.digest } : {},
|
|
2647
|
+
...newFile ? { afterMode: newFile.mode } : {},
|
|
2648
|
+
...afterContent !== undefined ? { afterContent } : {},
|
|
2649
|
+
bytes
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
if (entries.length > MAX_EDIT_CHANGES) {
|
|
2653
|
+
throw new Error(`Environment patch exceeds the ${MAX_EDIT_CHANGES}-file import limit.`);
|
|
2654
|
+
}
|
|
2655
|
+
if (totalBytes > MAX_EDIT_PROPOSAL_BYTES) {
|
|
2656
|
+
throw new Error(`Environment patch exceeds the ${MAX_EDIT_PROPOSAL_BYTES}-byte import limit.`);
|
|
2657
|
+
}
|
|
2658
|
+
const payload = patchPayload(runId, entries);
|
|
2659
|
+
return { entries, totalBytes, patchId: digest(JSON.stringify({ ...payload, executionIdentity: identity ?? null })) };
|
|
2660
|
+
};
|
|
2661
|
+
var inspectHostPrecondition = async (workspace, filePath, expectedDigest, expectedMode) => {
|
|
2662
|
+
try {
|
|
2663
|
+
const current = await workspace.inspectFile(filePath);
|
|
2664
|
+
if (!expectedDigest)
|
|
2665
|
+
throw new Error(`Host patch target already exists: ${filePath}.`);
|
|
2666
|
+
if (current.digest !== expectedDigest) {
|
|
2667
|
+
throw new Error(`Host patch target changed after the environment snapshot: ${filePath}.`);
|
|
2668
|
+
}
|
|
2669
|
+
if (expectedMode !== undefined && current.mode !== expectedMode) {
|
|
2670
|
+
throw new Error(`Host patch target mode changed after the environment snapshot: ${filePath}.`);
|
|
2671
|
+
}
|
|
2672
|
+
} catch (error) {
|
|
2673
|
+
if (error.code === "ENOENT" && !expectedDigest)
|
|
2674
|
+
return;
|
|
2675
|
+
throw error;
|
|
2676
|
+
}
|
|
2677
|
+
};
|
|
2678
|
+
var importPatch = async (runId, expectedPatchId, host, base, current, maxFileWriteBytes, maxWorkspaceBytes, metrics, assertActive, identity, lastInspectedPatchId) => withWorkspaceMutation(host.root, async () => {
|
|
2679
|
+
await assertActive?.();
|
|
2680
|
+
const patch = await createEnvironmentPatch(runId, base, current, maxFileWriteBytes, maxWorkspaceBytes, metrics, identity);
|
|
2681
|
+
if (patch.patchId !== expectedPatchId) {
|
|
2682
|
+
throw new EnvironmentPatchDriftError(expectedPatchId, patch.patchId, lastInspectedPatchId);
|
|
2683
|
+
}
|
|
2684
|
+
if (patch.entries.length === 0)
|
|
2685
|
+
throw new Error("Environment patch contains no changes.");
|
|
2686
|
+
const acceptanceBinding = taskAcceptanceChecks() ? {
|
|
2687
|
+
runId,
|
|
2688
|
+
patchId: patch.patchId,
|
|
2689
|
+
executionIdentity: identity ?? runId,
|
|
2690
|
+
snapshotDigest: await acceptanceSnapshotDigest(current.root, maxWorkspaceBytes)
|
|
2691
|
+
} : undefined;
|
|
2692
|
+
assertTaskAcceptanceImport({ workspace: host.root, runId, patchId: patch.patchId, entries: patch.entries, ...acceptanceBinding });
|
|
2693
|
+
for (const entry of patch.entries) {
|
|
2694
|
+
await inspectHostPrecondition(host, entry.path, entry.beforeDigest, entry.beforeMode);
|
|
2695
|
+
}
|
|
2696
|
+
const writes = patch.entries.filter((entry) => entry.operation !== "delete");
|
|
2697
|
+
const deletes = patch.entries.filter((entry) => entry.operation === "delete");
|
|
2698
|
+
const audits = [];
|
|
2699
|
+
if (writes.length) {
|
|
2700
|
+
const changes = writes.map((entry) => ({
|
|
2701
|
+
path: entry.path,
|
|
2702
|
+
expectedDigest: entry.beforeDigest ?? null,
|
|
2703
|
+
content: entry.afterContent
|
|
2704
|
+
}));
|
|
2705
|
+
const proposal = createEditProposal({ changes });
|
|
2706
|
+
const modes = new Map(writes.map((entry) => [entry.path, {
|
|
2707
|
+
...entry.beforeMode !== undefined ? { beforeMode: entry.beforeMode } : {},
|
|
2708
|
+
afterMode: entry.afterMode
|
|
2709
|
+
}]));
|
|
2710
|
+
const result = await host.applyPatchWithModes({ proposalId: proposal.proposalId, changes }, modes, assertActive);
|
|
2711
|
+
audits.push(...result.changes);
|
|
2712
|
+
}
|
|
2713
|
+
const quarantined = [];
|
|
2714
|
+
try {
|
|
2715
|
+
for (const entry of deletes) {
|
|
2716
|
+
const result = await host.quarantineFile({ path: entry.path, expectedDigest: entry.beforeDigest }, assertActive);
|
|
2717
|
+
quarantined.push(result);
|
|
2718
|
+
audits.push(result.audit);
|
|
2719
|
+
}
|
|
2720
|
+
await assertActive?.();
|
|
2721
|
+
} catch (error) {
|
|
2722
|
+
const rollbackErrors = [];
|
|
2723
|
+
for (const item of [...quarantined].reverse()) {
|
|
2724
|
+
try {
|
|
2725
|
+
await host.restoreQuarantined({ quarantineId: item.quarantineId, expectedDigest: item.digest });
|
|
2726
|
+
} catch (rollbackError) {
|
|
2727
|
+
rollbackErrors.push(rollbackError);
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
const updatedWrites = writes.filter((entry) => entry.operation === "update");
|
|
2731
|
+
if (updatedWrites.length) {
|
|
2732
|
+
try {
|
|
2733
|
+
const changes = updatedWrites.map((entry) => ({
|
|
2734
|
+
path: entry.path,
|
|
2735
|
+
expectedDigest: entry.afterDigest,
|
|
2736
|
+
content: entry.beforeContent
|
|
2737
|
+
}));
|
|
2738
|
+
const proposal = createEditProposal({ changes });
|
|
2739
|
+
const modes = new Map(updatedWrites.map((entry) => [entry.path, {
|
|
2740
|
+
beforeMode: entry.afterMode,
|
|
2741
|
+
afterMode: entry.beforeMode
|
|
2742
|
+
}]));
|
|
2743
|
+
await host.applyPatchWithModes({ proposalId: proposal.proposalId, changes }, modes);
|
|
2744
|
+
} catch (rollbackError) {
|
|
2745
|
+
rollbackErrors.push(rollbackError);
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
for (const entry of writes.filter((candidate) => candidate.operation === "create").reverse()) {
|
|
2749
|
+
try {
|
|
2750
|
+
await host.quarantineFile({ path: entry.path, expectedDigest: entry.afterDigest });
|
|
2751
|
+
} catch (rollbackError) {
|
|
2752
|
+
rollbackErrors.push(rollbackError);
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
if (rollbackErrors.length) {
|
|
2756
|
+
throw new AggregateError([error, ...rollbackErrors], "Environment patch deletion failed and rollback was incomplete.");
|
|
2757
|
+
}
|
|
2758
|
+
throw error;
|
|
2759
|
+
}
|
|
2760
|
+
if (acceptanceBinding)
|
|
2761
|
+
confirmTaskAcceptanceImport(acceptanceBinding, () => withWorkspaceMutation(host.root, async () => {
|
|
2762
|
+
if (await acceptanceSnapshotDigest(current.root, maxWorkspaceBytes) !== acceptanceBinding.snapshotDigest)
|
|
2763
|
+
return false;
|
|
2764
|
+
for (const entry of patch.entries) {
|
|
2765
|
+
try {
|
|
2766
|
+
const delivered = await host.inspectFile(entry.path);
|
|
2767
|
+
if (entry.operation === "delete" || delivered.digest !== entry.afterDigest || delivered.mode !== entry.afterMode)
|
|
2768
|
+
return false;
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
if (entry.operation !== "delete" || error.code !== "ENOENT")
|
|
2771
|
+
throw error;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
return true;
|
|
2775
|
+
}));
|
|
2776
|
+
return {
|
|
2777
|
+
schemaVersion: HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION,
|
|
2778
|
+
kind: "environment-patch-import",
|
|
2779
|
+
patchId: patch.patchId,
|
|
2780
|
+
runId,
|
|
2781
|
+
changes: audits
|
|
2782
|
+
};
|
|
2783
|
+
});
|
|
2784
|
+
var harnessExecutionSession = (context) => {
|
|
2785
|
+
const candidate = context?.executionEnvironment;
|
|
2786
|
+
return candidate?.kind === "zhivex-oci" ? candidate : undefined;
|
|
2787
|
+
};
|
|
2788
|
+
var createHarnessOciExecutionEnvironmentUnsafe = async (options) => {
|
|
2789
|
+
const runtime = options.runtime ?? new CliOciRuntimeAdapter(options.config.runtime);
|
|
2790
|
+
const image = await runtime.inspectImage(options.config.image);
|
|
2791
|
+
const workspaceIdentity = digest(options.workspace.root);
|
|
2792
|
+
const manifest = {
|
|
2793
|
+
schemaVersion: 1,
|
|
2794
|
+
id: `zhivex-harness-${options.config.runtime}-oci`,
|
|
2795
|
+
version: `${HARNESS_EXECUTION_POLICY_VERSION}:${image.imageDigest}`,
|
|
2796
|
+
backend: "container",
|
|
2797
|
+
assurance: "enforced",
|
|
2798
|
+
isolation: "per-run",
|
|
2799
|
+
workspace: {
|
|
2800
|
+
id: workspaceIdentity,
|
|
2801
|
+
root: "/workspace",
|
|
2802
|
+
cwd: "/workspace",
|
|
2803
|
+
access: "read-write",
|
|
2804
|
+
followSymlinks: false,
|
|
2805
|
+
readablePaths: ["/workspace"],
|
|
2806
|
+
writablePaths: ["/workspace", "/tmp"]
|
|
2807
|
+
},
|
|
2808
|
+
permissions: {
|
|
2809
|
+
undeclaredTools: "deny",
|
|
2810
|
+
filesystem: "read-write",
|
|
2811
|
+
network: { mode: "deny", allowedDomains: [], allowedPorts: [], allowPrivateNetworks: false },
|
|
2812
|
+
process: {
|
|
2813
|
+
shell: options.config.shellMode === "ask" ? "allow" : "allowlist",
|
|
2814
|
+
allowedCommands: [...options.config.allowedCommands]
|
|
2815
|
+
},
|
|
2816
|
+
environment: { inheritedVariables: [] }
|
|
2817
|
+
},
|
|
2818
|
+
limits: {
|
|
2819
|
+
maxProcessRuntimeMs: options.config.maxProcessRuntimeMs,
|
|
2820
|
+
maxProcessOutputBytes: options.config.maxProcessOutputBytes,
|
|
2821
|
+
maxConcurrentProcesses: 1,
|
|
2822
|
+
maxMemoryMb: options.config.maxMemoryMb,
|
|
2823
|
+
maxWorkspaceBytes: options.config.maxWorkspaceBytes,
|
|
2824
|
+
maxFileWriteBytes: options.config.maxFileWriteBytes,
|
|
2825
|
+
maxNetworkRequests: 0,
|
|
2826
|
+
maxNetworkBytes: 0
|
|
2827
|
+
},
|
|
2828
|
+
metadata: {
|
|
2829
|
+
policyVersion: options.config.policyVersion,
|
|
2830
|
+
runtime: image.runtime,
|
|
2831
|
+
runtimeVersion: image.runtimeVersion,
|
|
2832
|
+
imageReference: image.imageReference,
|
|
2833
|
+
imageId: image.imageId,
|
|
2834
|
+
imageDigest: image.imageDigest,
|
|
2835
|
+
maxPids: options.config.maxPids,
|
|
2836
|
+
maxCpus: options.config.maxCpus,
|
|
2837
|
+
tmpfsMb: options.config.tmpfsMb,
|
|
2838
|
+
containerLifecycle: "warm-per-acquired-run",
|
|
2839
|
+
failedCommandRecovery: "discard-and-reseed",
|
|
2840
|
+
patchImportApproval: true,
|
|
2841
|
+
hostMutationMode: "reviewed-environment-patch",
|
|
2842
|
+
shellMode: options.config.shellMode
|
|
2843
|
+
}
|
|
2844
|
+
};
|
|
2845
|
+
const binding = createAgentExecutionEnvironmentBinding(manifest);
|
|
2846
|
+
const environmentRoot = path4.join(options.stateDirectory, "environments");
|
|
2847
|
+
const reviewSnapshot = async (request, allowAbsent = false) => {
|
|
2848
|
+
let stateDirectory;
|
|
2849
|
+
try {
|
|
2850
|
+
stateDirectory = await realpath3(options.stateDirectory);
|
|
2851
|
+
} catch (error) {
|
|
2852
|
+
if (allowAbsent && error.code === "ENOENT")
|
|
2853
|
+
return;
|
|
2854
|
+
throw error;
|
|
2855
|
+
}
|
|
2856
|
+
const scopeKey = executionScopeKey(request.scope);
|
|
2857
|
+
const identity = executionIdentity(options.workspace.root, stateDirectory, request.runId, scopeKey);
|
|
2858
|
+
const root = path4.join(stateDirectory, "environments");
|
|
2859
|
+
const directory = path4.join(root, runHash(identity));
|
|
2860
|
+
for (const candidate of [root, directory, path4.join(directory, "base"), path4.join(directory, "workspace")]) {
|
|
2861
|
+
let entry;
|
|
2862
|
+
try {
|
|
2863
|
+
entry = await lstat4(candidate);
|
|
2864
|
+
} catch (error) {
|
|
2865
|
+
if (allowAbsent && (candidate === root || candidate === directory) && error.code === "ENOENT")
|
|
2866
|
+
return;
|
|
2867
|
+
throw error;
|
|
2868
|
+
}
|
|
2869
|
+
if (entry.isSymbolicLink() || !entry.isDirectory() || await realpath3(candidate) !== candidate) {
|
|
2870
|
+
throw new Error("Unsafe execution review directory.");
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
const file = await readRegularFileNoFollow(path4.join(directory, "environment.json"), {
|
|
2874
|
+
label: "Execution metadata",
|
|
2875
|
+
maxBytes: 1024 * 1024
|
|
2876
|
+
});
|
|
2877
|
+
const metadata = JSON.parse(file.contents.toString("utf8"));
|
|
2878
|
+
if (metadata.schemaVersion !== HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION || metadata.runId !== request.runId || metadata.scopeKey !== scopeKey || metadata.stateDirectory !== stateDirectory || metadata.executionIdentity !== identity || metadata.hostWorkspace !== options.workspace.root || metadata.binding.fingerprint !== binding.fingerprint || metadata.binding.workspaceId !== binding.workspaceId) {
|
|
2879
|
+
throw Object.assign(new Error("Execution review binding changed."), { diagnosticCode: "OCI_EXECUTION_BINDING_CHANGED" });
|
|
2880
|
+
}
|
|
2881
|
+
const base = await Workspace.open(path4.join(directory, "base"));
|
|
2882
|
+
const current = await Workspace.open(path4.join(directory, "workspace"));
|
|
2883
|
+
const patch = await createEnvironmentPatch(request.runId, base, current, options.config.maxFileWriteBytes, options.config.maxWorkspaceBytes, undefined, identity);
|
|
2884
|
+
deliveryPaths(metadata.deliveryPaths);
|
|
2885
|
+
return { metadata, patch, current };
|
|
2886
|
+
};
|
|
2887
|
+
return {
|
|
2888
|
+
manifest,
|
|
2889
|
+
image,
|
|
2890
|
+
runtime,
|
|
2891
|
+
async pendingDelivery(request) {
|
|
2892
|
+
const reviewed = await reviewSnapshot(request, true);
|
|
2893
|
+
if (!reviewed)
|
|
2894
|
+
return false;
|
|
2895
|
+
const paths = new Set([...reviewed.patch.entries.map((entry) => entry.path), ...deliveryPaths(reviewed.metadata.deliveryPaths)]);
|
|
2896
|
+
const inspect = async (workspace, filePath) => {
|
|
2897
|
+
try {
|
|
2898
|
+
return await workspace.inspectFile(filePath);
|
|
2899
|
+
} catch (error) {
|
|
2900
|
+
if (error.code === "ENOENT")
|
|
2901
|
+
return;
|
|
2902
|
+
throw error;
|
|
2903
|
+
}
|
|
2904
|
+
};
|
|
2905
|
+
for (const filePath of paths) {
|
|
2906
|
+
const [current, host] = await Promise.all([
|
|
2907
|
+
inspect(reviewed.current, filePath),
|
|
2908
|
+
inspect(options.workspace, filePath)
|
|
2909
|
+
]);
|
|
2910
|
+
if (current?.digest !== host?.digest || current?.mode !== host?.mode)
|
|
2911
|
+
return true;
|
|
2912
|
+
}
|
|
2913
|
+
return false;
|
|
2914
|
+
},
|
|
2915
|
+
async previewPatch(request, expectedPatchId) {
|
|
2916
|
+
const { metadata, patch } = await reviewSnapshot(request);
|
|
2917
|
+
if (patch.patchId !== expectedPatchId)
|
|
2918
|
+
throw new EnvironmentPatchDriftError(expectedPatchId, patch.patchId, metadata.lastInspectedPatchId);
|
|
2919
|
+
for (const entry of patch.entries)
|
|
2920
|
+
await inspectHostPrecondition(options.workspace, entry.path, entry.beforeDigest, entry.beforeMode);
|
|
2921
|
+
return { patchId: patch.patchId, entries: patch.entries };
|
|
2922
|
+
},
|
|
2923
|
+
async acquire(request) {
|
|
2924
|
+
const ioMetrics = {
|
|
2925
|
+
inventoryPasses: 0,
|
|
2926
|
+
inventoryPages: 0,
|
|
2927
|
+
verifiedContentReads: 0,
|
|
2928
|
+
verifiedContentBytes: 0,
|
|
2929
|
+
snapshotFiles: 0,
|
|
2930
|
+
snapshotBytes: 0,
|
|
2931
|
+
snapshotCloneFallbacks: 0,
|
|
2932
|
+
containerStarts: 0,
|
|
2933
|
+
containerReuses: 0,
|
|
2934
|
+
workspacePublishes: 0,
|
|
2935
|
+
workspaceExports: 0
|
|
2936
|
+
};
|
|
2937
|
+
await privateDirectory(environmentRoot);
|
|
2938
|
+
const stateDirectory = await realpath3(options.stateDirectory);
|
|
2939
|
+
const scopeKey = executionScopeKey(request.scope);
|
|
2940
|
+
const identity = executionIdentity(options.workspace.root, stateDirectory, request.runId, scopeKey);
|
|
2941
|
+
const directory = path4.join(environmentRoot, runHash(identity));
|
|
2942
|
+
await privateDirectory(directory);
|
|
2943
|
+
const metadataPath = path4.join(directory, "environment.json");
|
|
2944
|
+
const baseRoot = path4.join(directory, "base");
|
|
2945
|
+
const snapshotRoot = path4.join(directory, "workspace");
|
|
2946
|
+
let metadata;
|
|
2947
|
+
try {
|
|
2948
|
+
const metadataFile = await readRegularFileNoFollow(metadataPath, {
|
|
2949
|
+
label: "Execution metadata",
|
|
2950
|
+
maxBytes: 1024 * 1024
|
|
2951
|
+
});
|
|
2952
|
+
metadata = JSON.parse(metadataFile.contents.toString("utf8"));
|
|
2953
|
+
if (metadata.schemaVersion !== HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION || metadata.runId !== request.runId || metadata.scopeKey !== scopeKey || metadata.stateDirectory !== stateDirectory || metadata.executionIdentity !== identity || metadata.hostWorkspace !== options.workspace.root || metadata.binding.fingerprint !== binding.fingerprint || metadata.binding.workspaceId !== binding.workspaceId) {
|
|
2954
|
+
throw Object.assign(new Error("Execution artifact binding changed."), { diagnosticCode: "OCI_EXECUTION_BINDING_CHANGED" });
|
|
2955
|
+
}
|
|
2956
|
+
} catch (error) {
|
|
2957
|
+
if (error.code !== "ENOENT")
|
|
2958
|
+
throw error;
|
|
2959
|
+
try {
|
|
2960
|
+
await lstat4(path4.join(environmentRoot, runHash(request.runId)));
|
|
2961
|
+
throw new Error("Unscoped legacy OCI artifact requires its original runtime; start a new run.");
|
|
2962
|
+
} catch (legacyError) {
|
|
2963
|
+
if (legacyError.code !== "ENOENT")
|
|
2964
|
+
throw legacyError;
|
|
2965
|
+
}
|
|
2966
|
+
const snapshot = await copyWorkspaceSnapshot(options.workspace, baseRoot, snapshotRoot, options.config.maxWorkspaceBytes, ioMetrics);
|
|
2967
|
+
const now = new Date().toISOString();
|
|
2968
|
+
metadata = {
|
|
2969
|
+
schemaVersion: HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION,
|
|
2970
|
+
runId: request.runId,
|
|
2971
|
+
hostWorkspace: options.workspace.root,
|
|
2972
|
+
stateDirectory,
|
|
2973
|
+
scopeKey,
|
|
2974
|
+
executionIdentity: identity,
|
|
2975
|
+
binding,
|
|
2976
|
+
image,
|
|
2977
|
+
createdAt: now,
|
|
2978
|
+
acquiredAt: now,
|
|
2979
|
+
snapshot
|
|
2980
|
+
};
|
|
2981
|
+
}
|
|
2982
|
+
deliveryPaths(metadata.deliveryPaths);
|
|
2983
|
+
metadata.acquiredAt = new Date().toISOString();
|
|
2984
|
+
delete metadata.releasedAt;
|
|
2985
|
+
delete metadata.status;
|
|
2986
|
+
await atomicJson(metadataPath, metadata);
|
|
2987
|
+
const workspace = await Workspace.open(snapshotRoot, { auditPath: path4.join(directory, "mutation-audit.json") });
|
|
2988
|
+
const base = await Workspace.open(baseRoot);
|
|
2989
|
+
const dependencyPath = path4.join(options.workspace.root, "node_modules");
|
|
2990
|
+
let dependencyRoot;
|
|
2991
|
+
try {
|
|
2992
|
+
const entry = await lstat4(dependencyPath);
|
|
2993
|
+
if (entry.isSymbolicLink() || !entry.isDirectory()) {
|
|
2994
|
+
throw new Error("node_modules must be a real directory before it can be mounted read-only.");
|
|
2995
|
+
}
|
|
2996
|
+
dependencyRoot = await realpath3(dependencyPath);
|
|
2997
|
+
} catch (error) {
|
|
2998
|
+
if (error.code !== "ENOENT") {
|
|
2999
|
+
throw error;
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
const validateCommand = (command, args) => {
|
|
3003
|
+
if (!options.config.allowedCommands.includes(command)) {
|
|
3004
|
+
throw new Error(`The OCI command "${command}" is not in the explicit allowlist.`);
|
|
3005
|
+
}
|
|
3006
|
+
if (args.length > 256 || args.some((value) => value.length > 8192 || value.includes("\x00"))) {
|
|
3007
|
+
throw new Error("OCI command arguments exceed the bounded argument contract.");
|
|
3008
|
+
}
|
|
3009
|
+
return [command, ...args];
|
|
3010
|
+
};
|
|
3011
|
+
const sharedRunRequest = (context) => ({
|
|
3012
|
+
runId: identity,
|
|
3013
|
+
snapshotRoot,
|
|
3014
|
+
...dependencyRoot ? { dependencyRoot } : {},
|
|
3015
|
+
imageId: image.imageId,
|
|
3016
|
+
limits: {
|
|
3017
|
+
maxProcessRuntimeMs: options.config.maxProcessRuntimeMs,
|
|
3018
|
+
maxProcessOutputBytes: options.config.maxProcessOutputBytes,
|
|
3019
|
+
maxMemoryMb: options.config.maxMemoryMb,
|
|
3020
|
+
maxPids: options.config.maxPids,
|
|
3021
|
+
maxCpus: options.config.maxCpus,
|
|
3022
|
+
maxWorkspaceBytes: options.config.maxWorkspaceBytes,
|
|
3023
|
+
maxFileWriteBytes: options.config.maxFileWriteBytes,
|
|
3024
|
+
tmpfsMb: options.config.tmpfsMb
|
|
3025
|
+
},
|
|
3026
|
+
...context?.abortSignal ?? request.abortSignal ? { abortSignal: context?.abortSignal ?? request.abortSignal } : {}
|
|
3027
|
+
});
|
|
3028
|
+
const normalizeResult = (result) => {
|
|
3029
|
+
if (result.sessionReused === false)
|
|
3030
|
+
ioMetrics.containerStarts += 1;
|
|
3031
|
+
if (result.sessionReused === true)
|
|
3032
|
+
ioMetrics.containerReuses += 1;
|
|
3033
|
+
if (result.workspacePublished === true)
|
|
3034
|
+
ioMetrics.workspacePublishes += 1;
|
|
3035
|
+
if (result.workspaceExported === true)
|
|
3036
|
+
ioMetrics.workspaceExports += 1;
|
|
3037
|
+
const forcedExitCode = result.cancelled ? 130 : result.timedOut ? 124 : result.outputLimitExceeded ? 125 : result.exitCode;
|
|
3038
|
+
return {
|
|
3039
|
+
command: result.command,
|
|
3040
|
+
exitCode: forcedExitCode,
|
|
3041
|
+
stdout: result.stdout,
|
|
3042
|
+
stderr: [
|
|
3043
|
+
result.stderr,
|
|
3044
|
+
...result.cancelled ? ["OCI command cancelled."] : [],
|
|
3045
|
+
...result.timedOut ? ["OCI command timed out."] : [],
|
|
3046
|
+
...result.outputLimitExceeded ? ["OCI command exceeded the output limit."] : []
|
|
3047
|
+
].filter(Boolean).join(`
|
|
3048
|
+
`),
|
|
3049
|
+
timedOut: result.timedOut,
|
|
3050
|
+
...result.phaseLatencies ? { phaseLatencies: result.phaseLatencies } : {},
|
|
3051
|
+
...result.sessionReused !== undefined ? { sessionReused: result.sessionReused } : {},
|
|
3052
|
+
...result.workspacePublished !== undefined ? { workspacePublished: result.workspacePublished } : {},
|
|
3053
|
+
...result.workspaceExported !== undefined ? { workspaceExported: result.workspaceExported } : {}
|
|
3054
|
+
};
|
|
3055
|
+
};
|
|
3056
|
+
const runCommand = async (command, args, context) => {
|
|
3057
|
+
const argv = validateCommand(command, args);
|
|
3058
|
+
const checks = taskAcceptanceChecks(), matching = checks?.matching(command, args) ?? [];
|
|
3059
|
+
checks?.begin(matching.map((check) => check.id));
|
|
3060
|
+
const binding2 = async () => ({
|
|
3061
|
+
runId: request.runId,
|
|
3062
|
+
executionIdentity: identity,
|
|
3063
|
+
patchId: (await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes, options.config.maxWorkspaceBytes, ioMetrics, identity)).patchId,
|
|
3064
|
+
snapshotDigest: await acceptanceSnapshotDigest(workspace.root, options.config.maxWorkspaceBytes)
|
|
3065
|
+
});
|
|
3066
|
+
for (const check of matching)
|
|
3067
|
+
if (check.kind === "package-script") {
|
|
3068
|
+
const file = await readRegularFileNoFollow(path4.join(workspace.root, "package.json"), { label: "Acceptance package manifest", maxBytes: 256 * 1024, requireSingleLink: true });
|
|
3069
|
+
const manifestDocument = JSON.parse(file.contents.toString("utf8"));
|
|
3070
|
+
const resolved = await resolvePackageCheckCommand(workspace.root, manifestDocument, check.script, check.expectedScript, [check.script]);
|
|
3071
|
+
if (JSON.stringify(resolved.command) !== JSON.stringify(argv))
|
|
3072
|
+
throw new Error("TASK_ACCEPTANCE_CHECK_CHANGED");
|
|
3073
|
+
}
|
|
3074
|
+
const before = matching.length ? await binding2() : undefined;
|
|
3075
|
+
const startedAt = Date.now();
|
|
3076
|
+
const result = normalizeResult(await runtime.run({ ...sharedRunRequest(context), command: argv }));
|
|
3077
|
+
if (before && checks) {
|
|
3078
|
+
const after = await binding2();
|
|
3079
|
+
for (const check of matching)
|
|
3080
|
+
checks.record(check.id, before, after, result, context?.toolCall ? { id: context.toolCall.id, name: context.toolCall.name, startedAt } : undefined);
|
|
3081
|
+
}
|
|
3082
|
+
return result;
|
|
3083
|
+
};
|
|
3084
|
+
const runCommandBatch = async (commands, context) => {
|
|
3085
|
+
if (commands.length === 0 || commands.length > 32) {
|
|
3086
|
+
throw new Error("OCI command batch must contain between 1 and 32 commands.");
|
|
3087
|
+
}
|
|
3088
|
+
if (commands.reduce((total, item) => total + item.args.length, 0) > 256) {
|
|
3089
|
+
throw new Error("OCI command batch exceeds the bounded argument-count contract.");
|
|
3090
|
+
}
|
|
3091
|
+
const argvCommands = commands.map(({ command, args }) => validateCommand(command, args));
|
|
3092
|
+
if (runtime.runBatch && !taskAcceptanceChecks()) {
|
|
3093
|
+
return normalizeResult(await runtime.runBatch({
|
|
3094
|
+
...sharedRunRequest(context),
|
|
3095
|
+
commands: argvCommands
|
|
3096
|
+
}));
|
|
3097
|
+
}
|
|
3098
|
+
const results = [];
|
|
3099
|
+
for (const argv of argvCommands) {
|
|
3100
|
+
const result = await runCommand(argv[0], argv.slice(1), context);
|
|
3101
|
+
results.push(result);
|
|
3102
|
+
if (result.exitCode !== 0)
|
|
3103
|
+
break;
|
|
3104
|
+
}
|
|
3105
|
+
return {
|
|
3106
|
+
command: ["<oci-batch>", String(commands.length)],
|
|
3107
|
+
exitCode: results.at(-1)?.exitCode ?? 1,
|
|
3108
|
+
stdout: results.map((result) => result.stdout).filter(Boolean).join(""),
|
|
3109
|
+
stderr: results.map((result) => result.stderr).filter(Boolean).join(`
|
|
3110
|
+
`),
|
|
3111
|
+
timedOut: results.some((result) => result.timedOut)
|
|
3112
|
+
};
|
|
3113
|
+
};
|
|
3114
|
+
const runShell = async (script, context) => {
|
|
3115
|
+
if (options.config.shellMode !== "ask") {
|
|
3116
|
+
throw new Error("OCI shell execution is denied by policy.");
|
|
3117
|
+
}
|
|
3118
|
+
if (!script || script.length > 16384 || script.includes("\x00")) {
|
|
3119
|
+
throw new Error("OCI shell script must contain between 1 and 16384 characters without NUL bytes.");
|
|
3120
|
+
}
|
|
3121
|
+
return normalizeResult(await runtime.run({
|
|
3122
|
+
...sharedRunRequest(context),
|
|
3123
|
+
command: ["sh", "-lc", script, "zhivex-harness"]
|
|
3124
|
+
}));
|
|
3125
|
+
};
|
|
3126
|
+
const session = {
|
|
3127
|
+
kind: "zhivex-oci",
|
|
3128
|
+
runId: request.runId,
|
|
3129
|
+
manifest,
|
|
3130
|
+
binding,
|
|
3131
|
+
workspace,
|
|
3132
|
+
async authorize(authorization) {
|
|
3133
|
+
const name = authorization.tool.name;
|
|
3134
|
+
if (!BUILT_IN_TOOL_NAMES.has(name) && !name.startsWith("delegate_")) {
|
|
3135
|
+
return { decision: "deny", reason: `Tool "${name}" is undeclared by the enforced OCI policy.` };
|
|
3136
|
+
}
|
|
3137
|
+
if (name === "run_environment_command") {
|
|
3138
|
+
const input = authorization.input;
|
|
3139
|
+
if (typeof input.command !== "string" || !options.config.allowedCommands.includes(input.command)) {
|
|
3140
|
+
return { decision: "deny", reason: "The requested OCI executable is not allowlisted." };
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
if (name === "run_environment_shell") {
|
|
3144
|
+
const input = authorization.input;
|
|
3145
|
+
if (options.config.shellMode !== "ask" || typeof input.script !== "string" || input.script.length < 1 || input.script.length > 16384 || input.script.includes("\x00")) {
|
|
3146
|
+
return { decision: "deny", reason: "OCI shell execution is denied or the script is invalid." };
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
if (name === "verify_and_apply_environment_patch" || name === "verify_and_apply_reviewed_edits") {
|
|
3150
|
+
const input = authorization.input;
|
|
3151
|
+
const validBoundInput = name === "verify_and_apply_environment_patch" ? typeof input.patchId === "string" : Array.isArray(input.changes) && input.changes.length > 0;
|
|
3152
|
+
if (!validBoundInput || typeof input.command !== "string" || !options.config.allowedCommands.includes(input.command) || !Array.isArray(input.args)) {
|
|
3153
|
+
return { decision: "deny", reason: "The verified OCI transaction is invalid or contains a non-allowlisted executable." };
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
if (name === "run_environment_batch") {
|
|
3157
|
+
const input = authorization.input;
|
|
3158
|
+
if (!Array.isArray(input.commands) || input.commands.length < 1 || input.commands.length > 32 || input.commands.some((item) => {
|
|
3159
|
+
const candidate = item;
|
|
3160
|
+
return typeof candidate.command !== "string" || !options.config.allowedCommands.includes(candidate.command) || !Array.isArray(candidate.args);
|
|
3161
|
+
})) {
|
|
3162
|
+
return { decision: "deny", reason: "The requested OCI command batch is invalid or contains a non-allowlisted executable." };
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
return {
|
|
3166
|
+
decision: "allow",
|
|
3167
|
+
metadata: {
|
|
3168
|
+
environmentId: binding.environmentId,
|
|
3169
|
+
imageDigest: image.imageDigest,
|
|
3170
|
+
network: "deny",
|
|
3171
|
+
phase: authorization.phase
|
|
3172
|
+
}
|
|
3173
|
+
};
|
|
3174
|
+
},
|
|
3175
|
+
async execute(_authorization, operation) {
|
|
3176
|
+
return await operation();
|
|
3177
|
+
},
|
|
3178
|
+
async status() {
|
|
3179
|
+
const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes, options.config.maxWorkspaceBytes, ioMetrics, identity);
|
|
3180
|
+
return {
|
|
3181
|
+
schemaVersion: HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION,
|
|
3182
|
+
kind: "environment-status",
|
|
3183
|
+
runId: request.runId,
|
|
3184
|
+
binding,
|
|
3185
|
+
runtime: image.runtime,
|
|
3186
|
+
runtimeVersion: image.runtimeVersion,
|
|
3187
|
+
imageReference: image.imageReference,
|
|
3188
|
+
imageDigest: image.imageDigest,
|
|
3189
|
+
network: "deny",
|
|
3190
|
+
rootFilesystem: "read-only",
|
|
3191
|
+
workspace: "ephemeral-snapshot",
|
|
3192
|
+
patchId: patch.patchId,
|
|
3193
|
+
changedFiles: patch.entries.length,
|
|
3194
|
+
io: { ...ioMetrics }
|
|
3195
|
+
};
|
|
3196
|
+
},
|
|
3197
|
+
runCommand,
|
|
3198
|
+
runCommandBatch,
|
|
3199
|
+
runShell,
|
|
3200
|
+
async runCheck(check, expectedScript, allowedChecks, context) {
|
|
3201
|
+
let manifestDocument;
|
|
3202
|
+
try {
|
|
3203
|
+
const manifestFile = await readRegularFileNoFollow(path4.join(snapshotRoot, "package.json"), {
|
|
3204
|
+
label: "Snapshot package.json",
|
|
3205
|
+
maxBytes: 1024 * 1024
|
|
3206
|
+
});
|
|
3207
|
+
manifestDocument = JSON.parse(manifestFile.contents.toString("utf8"));
|
|
3208
|
+
} catch (error) {
|
|
3209
|
+
if (error.code === "ENOENT") {
|
|
3210
|
+
throw new Error("The environment snapshot does not contain package.json.");
|
|
3211
|
+
}
|
|
3212
|
+
throw error;
|
|
3213
|
+
}
|
|
3214
|
+
const resolved = await resolvePackageCheckCommand(snapshotRoot, manifestDocument, check, expectedScript, allowedChecks);
|
|
3215
|
+
return runCommand(resolved.command[0], resolved.command.slice(1), context);
|
|
3216
|
+
},
|
|
3217
|
+
async inspectPatch() {
|
|
3218
|
+
const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes, options.config.maxWorkspaceBytes, ioMetrics, identity);
|
|
3219
|
+
metadata.lastInspectedPatchId = patch.patchId;
|
|
3220
|
+
await atomicJson(metadataPath, metadata);
|
|
3221
|
+
const payload = patchPayload(request.runId, patch.entries);
|
|
3222
|
+
return {
|
|
3223
|
+
...payload,
|
|
3224
|
+
patchId: patch.patchId,
|
|
3225
|
+
totalBytes: patch.totalBytes
|
|
3226
|
+
};
|
|
3227
|
+
},
|
|
3228
|
+
async importPatch(host, patchId, assertActive) {
|
|
3229
|
+
await assertActive?.();
|
|
3230
|
+
const intended = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes, options.config.maxWorkspaceBytes, ioMetrics, identity);
|
|
3231
|
+
if (intended.patchId !== patchId) {
|
|
3232
|
+
throw new EnvironmentPatchDriftError(patchId, intended.patchId, metadata.lastInspectedPatchId);
|
|
3233
|
+
}
|
|
3234
|
+
metadata.deliveryPaths = deliveryPaths([...new Set([
|
|
3235
|
+
...deliveryPaths(metadata.deliveryPaths),
|
|
3236
|
+
...intended.entries.map((entry) => entry.path)
|
|
3237
|
+
])]);
|
|
3238
|
+
await atomicJson(metadataPath, metadata);
|
|
3239
|
+
return importPatch(request.runId, patchId, host, base, workspace, options.config.maxFileWriteBytes, options.config.maxWorkspaceBytes, ioMetrics, assertActive, identity, metadata.lastInspectedPatchId);
|
|
3240
|
+
},
|
|
3241
|
+
async release(result) {
|
|
3242
|
+
await runtime.removeRunContainers(identity);
|
|
3243
|
+
metadata.releasedAt = new Date().toISOString();
|
|
3244
|
+
metadata.status = result.status;
|
|
3245
|
+
await atomicJson(metadataPath, metadata);
|
|
3246
|
+
}
|
|
3247
|
+
};
|
|
3248
|
+
return session;
|
|
3249
|
+
}
|
|
3250
|
+
};
|
|
3251
|
+
};
|
|
3252
|
+
var executionBoundaryError = (error, message) => {
|
|
3253
|
+
if (error instanceof HarnessError || error instanceof Error && error.name === "ToolExecutionSuspendedError") {
|
|
3254
|
+
throw error;
|
|
3255
|
+
}
|
|
3256
|
+
throw new HarnessExecutionError(error instanceof Error ? error.message : message, {
|
|
3257
|
+
cause: error,
|
|
3258
|
+
retryable: true
|
|
3259
|
+
});
|
|
3260
|
+
};
|
|
3261
|
+
var typedExecutionSession = (session) => new Proxy(session, {
|
|
3262
|
+
get(target, property, receiver) {
|
|
3263
|
+
const value = Reflect.get(target, property, receiver);
|
|
3264
|
+
if (typeof value !== "function")
|
|
3265
|
+
return value;
|
|
3266
|
+
return async (...args) => {
|
|
3267
|
+
try {
|
|
3268
|
+
return await Reflect.apply(value, target, args);
|
|
3269
|
+
} catch (error) {
|
|
3270
|
+
return executionBoundaryError(error, "Enforced execution session failed.");
|
|
3271
|
+
}
|
|
3272
|
+
};
|
|
3273
|
+
}
|
|
3274
|
+
});
|
|
3275
|
+
var createHarnessOciExecutionEnvironment = async (options) => {
|
|
3276
|
+
try {
|
|
3277
|
+
const environment = await createHarnessOciExecutionEnvironmentUnsafe(options);
|
|
3278
|
+
return {
|
|
3279
|
+
...environment,
|
|
3280
|
+
async pendingDelivery(request) {
|
|
3281
|
+
try {
|
|
3282
|
+
return await environment.pendingDelivery(request);
|
|
3283
|
+
} catch (error) {
|
|
3284
|
+
return executionBoundaryError(error, "Execution delivery inspection failed.");
|
|
3285
|
+
}
|
|
3286
|
+
},
|
|
3287
|
+
async previewPatch(request, patchId) {
|
|
3288
|
+
try {
|
|
3289
|
+
return await environment.previewPatch(request, patchId);
|
|
3290
|
+
} catch (error) {
|
|
3291
|
+
return executionBoundaryError(error, "Execution patch review failed.");
|
|
3292
|
+
}
|
|
3293
|
+
},
|
|
3294
|
+
async acquire(request) {
|
|
3295
|
+
try {
|
|
3296
|
+
return typedExecutionSession(await environment.acquire(request));
|
|
3297
|
+
} catch (error) {
|
|
3298
|
+
return executionBoundaryError(error, "Enforced execution environment acquisition failed.");
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
};
|
|
3302
|
+
} catch (error) {
|
|
3303
|
+
return executionBoundaryError(error, "Enforced execution environment could not be created.");
|
|
3304
|
+
}
|
|
3305
|
+
};
|
|
3306
|
+
var cleanupHarnessExecutionArtifacts = async (stateDirectory, before, selection) => {
|
|
3307
|
+
const root = path4.join(stateDirectory, "environments");
|
|
3308
|
+
const result = { scanned: 0, deleted: 0, skipped: 0 };
|
|
3309
|
+
let entries;
|
|
3310
|
+
try {
|
|
3311
|
+
entries = await readdir3(root, { withFileTypes: true });
|
|
3312
|
+
} catch (error) {
|
|
3313
|
+
if (error.code === "ENOENT")
|
|
3314
|
+
return result;
|
|
3315
|
+
throw error;
|
|
3316
|
+
}
|
|
3317
|
+
for (const entry of entries) {
|
|
3318
|
+
const stagedMatch = STAGED_EXECUTION_ARTIFACT_DIRECTORY_PATTERN.exec(entry.name);
|
|
3319
|
+
const artifactName = EXECUTION_ARTIFACT_DIRECTORY_PATTERN.test(entry.name) ? entry.name : stagedMatch?.[1];
|
|
3320
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !artifactName) {
|
|
3321
|
+
result.skipped += 1;
|
|
3322
|
+
continue;
|
|
3323
|
+
}
|
|
3324
|
+
result.scanned += 1;
|
|
3325
|
+
const directory = path4.join(root, entry.name);
|
|
3326
|
+
try {
|
|
3327
|
+
const resolved = await realpath3(directory);
|
|
3328
|
+
if (!isInside2(root, resolved))
|
|
3329
|
+
throw new Error("unsafe execution artifact directory");
|
|
3330
|
+
const directoryEntry = await lstat4(directory);
|
|
3331
|
+
if (directoryEntry.isSymbolicLink() || !directoryEntry.isDirectory()) {
|
|
3332
|
+
throw new Error("unsafe execution artifact directory");
|
|
3333
|
+
}
|
|
3334
|
+
const metadataPath = path4.join(directory, "environment.json");
|
|
3335
|
+
const metadataFile = await readRegularFileNoFollow(metadataPath, {
|
|
3336
|
+
label: "Execution cleanup metadata",
|
|
3337
|
+
maxBytes: 1024 * 1024
|
|
3338
|
+
});
|
|
3339
|
+
const metadata = JSON.parse(metadataFile.contents.toString("utf8"));
|
|
3340
|
+
if (selection && (metadata.hostWorkspace !== await realpath3(selection.workspace) || metadata.scopeKey !== executionScopeKey(selection.scope))) {
|
|
3341
|
+
result.skipped += 1;
|
|
3342
|
+
continue;
|
|
3343
|
+
}
|
|
3344
|
+
if (metadata.executionIdentity && (metadata.stateDirectory !== await realpath3(stateDirectory) || metadata.executionIdentity !== executionIdentity(metadata.hostWorkspace, metadata.stateDirectory, metadata.runId, metadata.scopeKey) || runHash(metadata.executionIdentity) !== artifactName)) {
|
|
3345
|
+
result.skipped += 1;
|
|
3346
|
+
continue;
|
|
3347
|
+
}
|
|
3348
|
+
const releasedAt = metadata.releasedAt ? Date.parse(metadata.releasedAt) : Number.NaN;
|
|
3349
|
+
if (!metadata.status || !TERMINAL_STATUSES.has(metadata.status) || !Number.isFinite(releasedAt) || releasedAt >= before) {
|
|
3350
|
+
result.skipped += 1;
|
|
3351
|
+
continue;
|
|
3352
|
+
}
|
|
3353
|
+
const stagedDirectory = path4.join(root, `.cleanup-${artifactName}-${randomUUID2()}`);
|
|
3354
|
+
let staged = false;
|
|
3355
|
+
try {
|
|
3356
|
+
await rename2(directory, stagedDirectory);
|
|
3357
|
+
staged = true;
|
|
3358
|
+
const movedEntry = await lstat4(stagedDirectory);
|
|
3359
|
+
if (movedEntry.isSymbolicLink() || !movedEntry.isDirectory() || movedEntry.dev !== directoryEntry.dev || movedEntry.ino !== directoryEntry.ino) {
|
|
3360
|
+
throw new Error("execution artifact directory changed before cleanup");
|
|
3361
|
+
}
|
|
3362
|
+
const recheckedMetadata = await readRegularFileNoFollow(path4.join(stagedDirectory, "environment.json"), { label: "Execution cleanup metadata", maxBytes: 1024 * 1024 });
|
|
3363
|
+
if (!recheckedMetadata.contents.equals(metadataFile.contents)) {
|
|
3364
|
+
throw new Error("execution artifact metadata changed before cleanup");
|
|
3365
|
+
}
|
|
3366
|
+
await rm(stagedDirectory, { recursive: true, force: true });
|
|
3367
|
+
staged = false;
|
|
3368
|
+
result.deleted += 1;
|
|
3369
|
+
} catch (error) {
|
|
3370
|
+
if (staged)
|
|
3371
|
+
await rename2(stagedDirectory, directory).catch(() => {
|
|
3372
|
+
return;
|
|
3373
|
+
});
|
|
3374
|
+
throw error;
|
|
3375
|
+
}
|
|
3376
|
+
} catch {
|
|
3377
|
+
result.skipped += 1;
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
return result;
|
|
3381
|
+
};
|
|
3382
|
+
var executionFingerprintInput = (execution, environment) => execution.backend === "none" ? { backend: "none" } : {
|
|
3383
|
+
...execution,
|
|
3384
|
+
image: environment?.image,
|
|
3385
|
+
binding: environment ? createAgentExecutionEnvironmentBinding(environment.manifest) : undefined
|
|
3386
|
+
};
|
|
3387
|
+
var describeOciCommand = (command, args) => commandDisplay([command, ...args]);
|
|
3388
|
+
|
|
3389
|
+
export { withWorkspaceMutation, EnvironmentPatchDriftError, boundedBatches, replacementEditSchema, isHardIgnored, Workspace, HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION, HARNESS_OCI_LABEL, HARNESS_OCI_LABEL_VALUE, CliOciRuntimeAdapter, collectSnapshotInventory, readSnapshotFile, harnessExecutionSession, createHarnessOciExecutionEnvironment, cleanupHarnessExecutionArtifacts, executionFingerprintInput, describeOciCommand };
|