@zhivex-ai/harness 0.7.0 → 0.9.0
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 +45 -0
- package/README.md +31 -7
- package/ROADMAP.md +54 -9
- package/SECURITY.md +7 -1
- package/SUPPORT.md +2 -2
- package/dist/change-envelope.d.ts +271 -0
- package/dist/cli-stream.d.ts +14 -14
- package/dist/cli.d.ts +7 -1
- package/dist/cli.js +1457 -164
- package/dist/config.d.ts +1 -1
- package/dist/execution-environment.d.ts +49 -3
- package/dist/harness.d.ts +16 -1
- package/dist/hostile-repository-demo.js +812 -160
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1367 -188
- package/dist/workspace.d.ts +53 -0
- package/dist/zhx.js +1457 -164
- package/docs/CHANGE_ENVELOPES.md +116 -0
- package/docs/CLI.md +14 -4
- package/docs/DURABLE_OPERATIONS.md +14 -2
- package/docs/EXECUTION_ENVIRONMENTS.md +10 -4
- package/docs/EXTENSIBILITY.md +7 -3
- package/docs/LIVE_CERTIFICATION.md +17 -1
- package/docs/RELEASE.md +4 -4
- package/docs/REPOSITORY_EDITING.md +4 -0
- package/examples/change-envelope-input.json +26 -0
- package/examples/change.patch +6 -0
- package/package.json +14 -8
|
@@ -323,7 +323,7 @@ var BUILTIN_PROVIDER_REGISTRATIONS = Object.freeze([
|
|
|
323
323
|
descriptor: {
|
|
324
324
|
id: "openai",
|
|
325
325
|
name: "OpenAI",
|
|
326
|
-
defaultModel: "gpt-5.
|
|
326
|
+
defaultModel: "gpt-5.6-luna",
|
|
327
327
|
credentialNames: ["OPENAI_API_KEY"],
|
|
328
328
|
capabilities: HARNESS_PROVIDER_CAPABILITIES,
|
|
329
329
|
support: "certified"
|
|
@@ -365,7 +365,7 @@ var HARNESS_CONFIG_SCHEMA_VERSION = 4;
|
|
|
365
365
|
var HARNESS_STORE_BACKENDS = ["sqlite", "file"];
|
|
366
366
|
var HARNESS_EXECUTION_BACKENDS = ["none", "oci"];
|
|
367
367
|
var HARNESS_OCI_RUNTIMES = ["docker", "podman"];
|
|
368
|
-
var HARNESS_EXECUTION_POLICY_VERSION = "2026-08-
|
|
368
|
+
var HARNESS_EXECUTION_POLICY_VERSION = "2026-08-20-v2";
|
|
369
369
|
var DEFAULT_OCI_EXECUTION = {
|
|
370
370
|
runtime: "docker",
|
|
371
371
|
image: "oven/bun:1.3.7-slim",
|
|
@@ -650,8 +650,10 @@ var createProviderModel = (config, env = process.env, registry = DEFAULT_PROVIDE
|
|
|
650
650
|
|
|
651
651
|
// src/execution-environment.ts
|
|
652
652
|
import { createHash as createHash5, randomUUID as randomUUID2 } from "crypto";
|
|
653
|
+
import { constants as fsConstants } from "fs";
|
|
653
654
|
import {
|
|
654
655
|
chmod,
|
|
656
|
+
copyFile,
|
|
655
657
|
lstat as lstat2,
|
|
656
658
|
mkdir as mkdir2,
|
|
657
659
|
readFile as readFile2,
|
|
@@ -808,6 +810,10 @@ var IGNORE_FILES = [".gitignore", ".zhivex-harnessignore"];
|
|
|
808
810
|
var DEFAULT_CHECKS = ["test", "typecheck", "lint", "build"];
|
|
809
811
|
var MAX_FILE_BYTES = 1024 * 1024;
|
|
810
812
|
var MAX_TOOL_OUTPUT = 20000;
|
|
813
|
+
var MAX_READ_BATCH_FILES = 20;
|
|
814
|
+
var MAX_READ_BATCH_BYTES = 2 * MAX_FILE_BYTES;
|
|
815
|
+
var MAX_SEARCH_MANY_QUERIES = 10;
|
|
816
|
+
var MAX_SEARCH_MANY_MATCHES = 500;
|
|
811
817
|
var isSensitiveName = (name) => {
|
|
812
818
|
const normalized = name.toLocaleLowerCase();
|
|
813
819
|
return normalized === ".env" || normalized.startsWith(".env.") && normalized !== ".env.example" || normalized === ".npmrc" || normalized === "id_rsa" || normalized === "id_ed25519" || normalized.endsWith(".key") || normalized.endsWith(".pem") || normalized.endsWith(".p12") || normalized.endsWith(".pfx");
|
|
@@ -829,6 +835,30 @@ var cursorDecode = (value) => {
|
|
|
829
835
|
throw new Error("The pagination cursor is invalid.");
|
|
830
836
|
}
|
|
831
837
|
};
|
|
838
|
+
var firstPathAtLeast = (files, target) => {
|
|
839
|
+
let low = 0;
|
|
840
|
+
let high = files.length;
|
|
841
|
+
while (low < high) {
|
|
842
|
+
const middle = low + Math.floor((high - low) / 2);
|
|
843
|
+
if ((files[middle]?.path ?? "") < target)
|
|
844
|
+
low = middle + 1;
|
|
845
|
+
else
|
|
846
|
+
high = middle;
|
|
847
|
+
}
|
|
848
|
+
return low;
|
|
849
|
+
};
|
|
850
|
+
var firstPathAfter = (files, target) => {
|
|
851
|
+
let low = 0;
|
|
852
|
+
let high = files.length;
|
|
853
|
+
while (low < high) {
|
|
854
|
+
const middle = low + Math.floor((high - low) / 2);
|
|
855
|
+
if ((files[middle]?.path ?? "") <= target)
|
|
856
|
+
low = middle + 1;
|
|
857
|
+
else
|
|
858
|
+
high = middle;
|
|
859
|
+
}
|
|
860
|
+
return low;
|
|
861
|
+
};
|
|
832
862
|
var escapeRegex = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
833
863
|
var globRegex = (pattern) => {
|
|
834
864
|
let source = "";
|
|
@@ -920,6 +950,11 @@ var spawnBounded = async (command, cwd, timeoutMs = 120000) => {
|
|
|
920
950
|
class Workspace {
|
|
921
951
|
root;
|
|
922
952
|
auditEntries = [];
|
|
953
|
+
indexIdentity = randomUUID();
|
|
954
|
+
indexRevision = 0;
|
|
955
|
+
workspaceIndex;
|
|
956
|
+
indexBuild;
|
|
957
|
+
indexMetrics = { builds: 0, reuses: 0, stableFileReads: 0 };
|
|
923
958
|
constructor(root) {
|
|
924
959
|
this.root = root;
|
|
925
960
|
}
|
|
@@ -971,6 +1006,7 @@ class Workspace {
|
|
|
971
1006
|
return { path: candidate, exists: true };
|
|
972
1007
|
}
|
|
973
1008
|
async readStableFile(relativePath, allowBinary = true) {
|
|
1009
|
+
this.indexMetrics.stableFileReads += 1;
|
|
974
1010
|
const safe = await this.safePath(relativePath, { requireFile: true });
|
|
975
1011
|
const before = await lstat(safe.path);
|
|
976
1012
|
if (before.size > MAX_FILE_BYTES)
|
|
@@ -990,14 +1026,40 @@ class Workspace {
|
|
|
990
1026
|
mode: before.mode & 511
|
|
991
1027
|
};
|
|
992
1028
|
}
|
|
993
|
-
|
|
1029
|
+
fingerprint(relativePath, kind, entry, digest2) {
|
|
1030
|
+
return {
|
|
1031
|
+
path: relativePath || ".",
|
|
1032
|
+
kind,
|
|
1033
|
+
dev: entry.dev,
|
|
1034
|
+
ino: entry.ino,
|
|
1035
|
+
size: entry.size,
|
|
1036
|
+
mtimeMs: entry.mtimeMs,
|
|
1037
|
+
ctimeMs: entry.ctimeMs,
|
|
1038
|
+
...digest2 ? { digest: digest2 } : {}
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
sameFingerprint(entry, fingerprint) {
|
|
1042
|
+
return entry.dev === fingerprint.dev && entry.ino === fingerprint.ino && entry.size === fingerprint.size && entry.mtimeMs === fingerprint.mtimeMs && entry.ctimeMs === fingerprint.ctimeMs;
|
|
1043
|
+
}
|
|
1044
|
+
async rulesForDirectory(directory, base, inherited, ignoreFiles) {
|
|
994
1045
|
const rules = [...inherited];
|
|
995
1046
|
for (const filename of IGNORE_FILES) {
|
|
996
1047
|
try {
|
|
997
1048
|
const ignorePath = path3.join(directory, filename);
|
|
998
|
-
const
|
|
999
|
-
if (
|
|
1000
|
-
|
|
1049
|
+
const before = await lstat(ignorePath);
|
|
1050
|
+
if (before.isSymbolicLink() || !before.isFile())
|
|
1051
|
+
continue;
|
|
1052
|
+
if (before.size <= MAX_FILE_BYTES) {
|
|
1053
|
+
const contents = await readFile(ignorePath);
|
|
1054
|
+
const after = await lstat(ignorePath);
|
|
1055
|
+
if (after.isSymbolicLink() || !after.isFile() || !this.sameFingerprint(after, this.fingerprint("", "ignore-file", before))) {
|
|
1056
|
+
throw new Error(`The ignore file changed while the workspace was being indexed: ${wirePath(path3.relative(this.root, ignorePath))}`);
|
|
1057
|
+
}
|
|
1058
|
+
const digest2 = digestBytes(contents);
|
|
1059
|
+
ignoreFiles.push(this.fingerprint(wirePath(path3.relative(this.root, ignorePath)), "ignore-file", after, digest2));
|
|
1060
|
+
rules.push(...parseIgnoreRules(contents.toString("utf8"), base));
|
|
1061
|
+
} else {
|
|
1062
|
+
ignoreFiles.push(this.fingerprint(wirePath(path3.relative(this.root, ignorePath)), "ignore-file", before));
|
|
1001
1063
|
}
|
|
1002
1064
|
} catch (error) {
|
|
1003
1065
|
if (error.code !== "ENOENT")
|
|
@@ -1006,10 +1068,17 @@ class Workspace {
|
|
|
1006
1068
|
}
|
|
1007
1069
|
return rules;
|
|
1008
1070
|
}
|
|
1009
|
-
async collectFiles() {
|
|
1071
|
+
async collectFiles(version) {
|
|
1010
1072
|
const files = [];
|
|
1073
|
+
const directories = [];
|
|
1074
|
+
const ignoreFiles = [];
|
|
1011
1075
|
const walk = async (directory, base, inherited) => {
|
|
1012
|
-
const
|
|
1076
|
+
const directoryEntry = await lstat(directory);
|
|
1077
|
+
if (directoryEntry.isSymbolicLink() || !directoryEntry.isDirectory()) {
|
|
1078
|
+
throw new Error(`A workspace directory changed while it was being indexed: ${base || "."}`);
|
|
1079
|
+
}
|
|
1080
|
+
directories.push(this.fingerprint(base, "directory", directoryEntry));
|
|
1081
|
+
const rules = await this.rulesForDirectory(directory, base, inherited, ignoreFiles);
|
|
1013
1082
|
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1014
1083
|
for (const entry of entries) {
|
|
1015
1084
|
const relative = base ? `${base}/${entry.name}` : entry.name;
|
|
@@ -1019,11 +1088,99 @@ class Workspace {
|
|
|
1019
1088
|
if (entry.isDirectory())
|
|
1020
1089
|
await walk(absolute, relative, rules);
|
|
1021
1090
|
else if (entry.isFile())
|
|
1022
|
-
files.push({ path: relative
|
|
1091
|
+
files.push({ path: relative });
|
|
1023
1092
|
}
|
|
1024
1093
|
};
|
|
1025
1094
|
await walk(this.root, "", []);
|
|
1026
|
-
|
|
1095
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
1096
|
+
return { version, files, directories, ignoreFiles };
|
|
1097
|
+
}
|
|
1098
|
+
async isWorkspaceIndexFresh(index) {
|
|
1099
|
+
try {
|
|
1100
|
+
for (const fingerprint of index.directories) {
|
|
1101
|
+
const absolute = fingerprint.path === "." ? this.root : path3.join(this.root, fingerprint.path);
|
|
1102
|
+
const entry = await lstat(absolute);
|
|
1103
|
+
if (entry.isSymbolicLink() || !entry.isDirectory() || !this.sameFingerprint(entry, fingerprint))
|
|
1104
|
+
return false;
|
|
1105
|
+
}
|
|
1106
|
+
for (const fingerprint of index.ignoreFiles) {
|
|
1107
|
+
const absolute = path3.join(this.root, fingerprint.path);
|
|
1108
|
+
const before = await lstat(absolute);
|
|
1109
|
+
if (before.isSymbolicLink() || !before.isFile() || !this.sameFingerprint(before, fingerprint))
|
|
1110
|
+
return false;
|
|
1111
|
+
if (fingerprint.digest) {
|
|
1112
|
+
const contents = await readFile(absolute);
|
|
1113
|
+
const after = await lstat(absolute);
|
|
1114
|
+
if (after.isSymbolicLink() || !after.isFile() || !this.sameFingerprint(after, fingerprint) || digestBytes(contents) !== fingerprint.digest)
|
|
1115
|
+
return false;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return true;
|
|
1119
|
+
} catch {
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
invalidateWorkspaceIndex() {
|
|
1124
|
+
this.indexRevision += 1;
|
|
1125
|
+
this.workspaceIndex = undefined;
|
|
1126
|
+
}
|
|
1127
|
+
async buildWorkspaceIndex(revision) {
|
|
1128
|
+
const version = `${this.indexIdentity}:${revision}`;
|
|
1129
|
+
for (let attempt = 0;attempt < 3; attempt += 1) {
|
|
1130
|
+
try {
|
|
1131
|
+
const index = await this.collectFiles(version);
|
|
1132
|
+
if (await this.isWorkspaceIndexFresh(index))
|
|
1133
|
+
return index;
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
const code = error.code;
|
|
1136
|
+
const changedDuringIndexing = error instanceof Error && error.message.includes("while the workspace was being indexed");
|
|
1137
|
+
if (attempt === 2 || code !== "ENOENT" && !changedDuringIndexing)
|
|
1138
|
+
throw error;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
throw new Error("The workspace kept changing while its file index was being built.");
|
|
1142
|
+
}
|
|
1143
|
+
async getWorkspaceIndex() {
|
|
1144
|
+
const cached = this.workspaceIndex;
|
|
1145
|
+
if (cached) {
|
|
1146
|
+
if (await this.isWorkspaceIndexFresh(cached)) {
|
|
1147
|
+
this.indexMetrics.reuses += 1;
|
|
1148
|
+
return cached;
|
|
1149
|
+
}
|
|
1150
|
+
this.invalidateWorkspaceIndex();
|
|
1151
|
+
}
|
|
1152
|
+
const revision = this.indexRevision;
|
|
1153
|
+
if (!this.indexBuild || this.indexBuild.revision !== revision) {
|
|
1154
|
+
const promise = this.buildWorkspaceIndex(revision);
|
|
1155
|
+
this.indexBuild = { revision, promise };
|
|
1156
|
+
}
|
|
1157
|
+
const build = this.indexBuild;
|
|
1158
|
+
let index;
|
|
1159
|
+
try {
|
|
1160
|
+
index = await build.promise;
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
if (this.indexBuild === build)
|
|
1163
|
+
this.indexBuild = undefined;
|
|
1164
|
+
throw error;
|
|
1165
|
+
}
|
|
1166
|
+
if (revision !== this.indexRevision)
|
|
1167
|
+
return this.getWorkspaceIndex();
|
|
1168
|
+
if (this.indexBuild === build)
|
|
1169
|
+
this.indexBuild = undefined;
|
|
1170
|
+
if (this.workspaceIndex?.version !== index.version) {
|
|
1171
|
+
this.workspaceIndex = index;
|
|
1172
|
+
this.indexMetrics.builds += 1;
|
|
1173
|
+
}
|
|
1174
|
+
return this.workspaceIndex;
|
|
1175
|
+
}
|
|
1176
|
+
workspaceIndexDiagnostics() {
|
|
1177
|
+
const index = this.workspaceIndex;
|
|
1178
|
+
return {
|
|
1179
|
+
...this.indexMetrics,
|
|
1180
|
+
...index ? { version: index.version } : {},
|
|
1181
|
+
files: index?.files.length ?? 0,
|
|
1182
|
+
directories: index?.directories.length ?? 0
|
|
1183
|
+
};
|
|
1027
1184
|
}
|
|
1028
1185
|
async listFiles(relativePath = ".", options = {}) {
|
|
1029
1186
|
const start = await this.safePath(relativePath);
|
|
@@ -1035,36 +1192,52 @@ class Workspace {
|
|
|
1035
1192
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5000)
|
|
1036
1193
|
throw new Error("limit must be between 1 and 5000.");
|
|
1037
1194
|
let after = "";
|
|
1195
|
+
let cursorIndexVersion;
|
|
1038
1196
|
if (input.cursor) {
|
|
1039
1197
|
const parsed = cursorDecode(input.cursor);
|
|
1040
|
-
if (parsed.v !==
|
|
1198
|
+
if (parsed.v !== 2 || parsed.kind !== "list" || parsed.path !== requestPath || parsed.limit !== limit || typeof parsed.indexVersion !== "string" || typeof parsed.after !== "string") {
|
|
1041
1199
|
throw new Error("The pagination cursor does not match this list request.");
|
|
1042
1200
|
}
|
|
1043
1201
|
after = parsed.after;
|
|
1202
|
+
cursorIndexVersion = parsed.indexVersion;
|
|
1203
|
+
}
|
|
1204
|
+
const index = await this.getWorkspaceIndex();
|
|
1205
|
+
if (cursorIndexVersion && cursorIndexVersion !== index.version) {
|
|
1206
|
+
throw new Error("The pagination cursor is stale because the workspace changed.");
|
|
1044
1207
|
}
|
|
1045
1208
|
const prefix = requestPath === "." ? "" : `${requestPath}/`;
|
|
1046
|
-
|
|
1047
|
-
const selected =
|
|
1209
|
+
let candidateIndex = after ? firstPathAfter(index.files, after) : requestPath === "." ? 0 : firstPathAtLeast(index.files, prefix);
|
|
1210
|
+
const selected = [];
|
|
1211
|
+
while (candidateIndex < index.files.length && selected.length <= limit) {
|
|
1212
|
+
const candidate = index.files[candidateIndex];
|
|
1213
|
+
if (!candidate || requestPath !== "." && !candidate.path.startsWith(prefix))
|
|
1214
|
+
break;
|
|
1215
|
+
selected.push(candidate);
|
|
1216
|
+
candidateIndex += 1;
|
|
1217
|
+
}
|
|
1218
|
+
const truncated = selected.length > limit;
|
|
1219
|
+
if (truncated)
|
|
1220
|
+
selected.pop();
|
|
1048
1221
|
const files = [];
|
|
1049
1222
|
for (const candidate of selected) {
|
|
1050
1223
|
const file = await this.readStableFile(candidate.path);
|
|
1051
1224
|
files.push({ path: file.path, size: file.contents.byteLength, digest: file.digest });
|
|
1052
1225
|
}
|
|
1053
|
-
const truncated = candidates.length > selected.length;
|
|
1054
1226
|
const last = selected.at(-1);
|
|
1055
1227
|
return {
|
|
1056
1228
|
files,
|
|
1057
1229
|
truncated,
|
|
1058
|
-
...truncated && last ? {
|
|
1230
|
+
...truncated && last ? {
|
|
1231
|
+
nextCursor: cursorEncode({ v: 2, kind: "list", path: requestPath, limit, after: last.path, indexVersion: index.version })
|
|
1232
|
+
} : {}
|
|
1059
1233
|
};
|
|
1060
1234
|
}
|
|
1061
|
-
|
|
1235
|
+
renderReadFile(file, startLine = 1, endLine) {
|
|
1062
1236
|
if (!Number.isSafeInteger(startLine) || startLine < 1)
|
|
1063
1237
|
throw new Error("startLine must be a positive integer.");
|
|
1064
1238
|
if (endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine < startLine)) {
|
|
1065
1239
|
throw new Error("endLine must be greater than or equal to startLine.");
|
|
1066
1240
|
}
|
|
1067
|
-
const file = await this.readStableFile(relativePath, false);
|
|
1068
1241
|
const lines = file.contents.toString("utf8").split(/\r?\n/);
|
|
1069
1242
|
const boundedEnd = Math.min(endLine ?? startLine + 399, startLine + 1999, lines.length);
|
|
1070
1243
|
return {
|
|
@@ -1078,6 +1251,37 @@ class Workspace {
|
|
|
1078
1251
|
truncated: boundedEnd < lines.length
|
|
1079
1252
|
};
|
|
1080
1253
|
}
|
|
1254
|
+
async readFile(relativePath, startLine = 1, endLine) {
|
|
1255
|
+
return this.renderReadFile(await this.readStableFile(relativePath, false), startLine, endLine);
|
|
1256
|
+
}
|
|
1257
|
+
async readFiles(requests) {
|
|
1258
|
+
if (!Array.isArray(requests) || requests.length < 1 || requests.length > MAX_READ_BATCH_FILES) {
|
|
1259
|
+
throw new Error(`readFiles requires between 1 and ${MAX_READ_BATCH_FILES} file requests.`);
|
|
1260
|
+
}
|
|
1261
|
+
const ordered = requests.map((request) => ({
|
|
1262
|
+
...request,
|
|
1263
|
+
path: wirePath(path3.relative(this.root, this.lexicalPath(request.path)))
|
|
1264
|
+
})).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));
|
|
1265
|
+
const stableFiles = new Map;
|
|
1266
|
+
let totalBytes = 0;
|
|
1267
|
+
for (const request of ordered) {
|
|
1268
|
+
if (!Number.isSafeInteger(request.startLine ?? 1) || (request.startLine ?? 1) < 1 || request.endLine !== undefined && (!Number.isSafeInteger(request.endLine) || request.endLine < (request.startLine ?? 1))) {
|
|
1269
|
+
throw new Error(`Invalid line range for ${request.path}.`);
|
|
1270
|
+
}
|
|
1271
|
+
if (stableFiles.has(request.path))
|
|
1272
|
+
continue;
|
|
1273
|
+
const file = await this.readStableFile(request.path, false);
|
|
1274
|
+
totalBytes += file.contents.byteLength;
|
|
1275
|
+
if (totalBytes > MAX_READ_BATCH_BYTES) {
|
|
1276
|
+
throw new Error(`readFiles exceeds the aggregate ${MAX_READ_BATCH_BYTES}-byte source limit.`);
|
|
1277
|
+
}
|
|
1278
|
+
stableFiles.set(request.path, file);
|
|
1279
|
+
}
|
|
1280
|
+
return {
|
|
1281
|
+
files: ordered.map((request) => this.renderReadFile(stableFiles.get(request.path), request.startLine ?? 1, request.endLine)),
|
|
1282
|
+
sourceBytes: totalBytes
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1081
1285
|
async inspectFile(relativePath) {
|
|
1082
1286
|
const file = await this.readStableFile(relativePath);
|
|
1083
1287
|
return {
|
|
@@ -1100,21 +1304,29 @@ class Workspace {
|
|
|
1100
1304
|
const caseSensitive = options.caseSensitive ?? false;
|
|
1101
1305
|
let afterPath = "";
|
|
1102
1306
|
let afterLine = 0;
|
|
1307
|
+
let cursorIndexVersion;
|
|
1103
1308
|
if (options.cursor) {
|
|
1104
1309
|
const parsed = cursorDecode(options.cursor);
|
|
1105
|
-
if (parsed.v !==
|
|
1310
|
+
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") {
|
|
1106
1311
|
throw new Error("The pagination cursor does not match this search request.");
|
|
1107
1312
|
}
|
|
1108
1313
|
afterPath = parsed.afterPath;
|
|
1109
1314
|
afterLine = parsed.afterLine;
|
|
1315
|
+
cursorIndexVersion = parsed.indexVersion;
|
|
1316
|
+
}
|
|
1317
|
+
const index = await this.getWorkspaceIndex();
|
|
1318
|
+
if (cursorIndexVersion && cursorIndexVersion !== index.version) {
|
|
1319
|
+
throw new Error("The pagination cursor is stale because the workspace changed.");
|
|
1110
1320
|
}
|
|
1111
1321
|
const prefix = requestPath === "." ? "" : `${requestPath}/`;
|
|
1112
1322
|
const needle = caseSensitive ? query : query.toLocaleLowerCase();
|
|
1113
1323
|
const matches = [];
|
|
1114
1324
|
let hasMore = false;
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1325
|
+
let candidateIndex = afterPath ? firstPathAtLeast(index.files, afterPath) : requestPath === "." ? 0 : firstPathAtLeast(index.files, prefix);
|
|
1326
|
+
for (;candidateIndex < index.files.length; candidateIndex += 1) {
|
|
1327
|
+
const candidate = index.files[candidateIndex];
|
|
1328
|
+
if (!candidate || requestPath !== "." && !candidate.path.startsWith(prefix))
|
|
1329
|
+
break;
|
|
1118
1330
|
let file;
|
|
1119
1331
|
try {
|
|
1120
1332
|
file = await this.readStableFile(candidate.path, false);
|
|
@@ -1122,11 +1334,11 @@ class Workspace {
|
|
|
1122
1334
|
continue;
|
|
1123
1335
|
}
|
|
1124
1336
|
const lines = file.contents.toString("utf8").split(/\r?\n/);
|
|
1125
|
-
for (let
|
|
1126
|
-
const lineNumber =
|
|
1337
|
+
for (let index2 = 0;index2 < lines.length; index2 += 1) {
|
|
1338
|
+
const lineNumber = index2 + 1;
|
|
1127
1339
|
if (candidate.path === afterPath && lineNumber <= afterLine)
|
|
1128
1340
|
continue;
|
|
1129
|
-
const line = lines[
|
|
1341
|
+
const line = lines[index2] ?? "";
|
|
1130
1342
|
if (!(caseSensitive ? line : line.toLocaleLowerCase()).includes(needle))
|
|
1131
1343
|
continue;
|
|
1132
1344
|
if (matches.length >= limit) {
|
|
@@ -1143,10 +1355,88 @@ class Workspace {
|
|
|
1143
1355
|
matches,
|
|
1144
1356
|
truncated: hasMore,
|
|
1145
1357
|
...hasMore && last ? {
|
|
1146
|
-
nextCursor: cursorEncode({
|
|
1358
|
+
nextCursor: cursorEncode({
|
|
1359
|
+
v: 2,
|
|
1360
|
+
kind: "search",
|
|
1361
|
+
path: requestPath,
|
|
1362
|
+
query,
|
|
1363
|
+
caseSensitive,
|
|
1364
|
+
limit,
|
|
1365
|
+
afterPath: last.path,
|
|
1366
|
+
afterLine: last.line,
|
|
1367
|
+
indexVersion: index.version
|
|
1368
|
+
})
|
|
1147
1369
|
} : {}
|
|
1148
1370
|
};
|
|
1149
1371
|
}
|
|
1372
|
+
async searchMany(queries, relativePath = ".", options = {}) {
|
|
1373
|
+
if (!Array.isArray(queries) || queries.length < 1 || queries.length > MAX_SEARCH_MANY_QUERIES) {
|
|
1374
|
+
throw new Error(`searchMany requires between 1 and ${MAX_SEARCH_MANY_QUERIES} queries.`);
|
|
1375
|
+
}
|
|
1376
|
+
const limitPerQuery = options.limitPerQuery ?? 50;
|
|
1377
|
+
if (!Number.isSafeInteger(limitPerQuery) || limitPerQuery < 1 || limitPerQuery > MAX_SEARCH_MANY_MATCHES || limitPerQuery * queries.length > MAX_SEARCH_MANY_MATCHES) {
|
|
1378
|
+
throw new Error(`searchMany allows at most ${MAX_SEARCH_MANY_MATCHES} aggregate matches.`);
|
|
1379
|
+
}
|
|
1380
|
+
const seen = new Set;
|
|
1381
|
+
const states = queries.map((input) => {
|
|
1382
|
+
if (!input.query || input.query.length > 200)
|
|
1383
|
+
throw new Error("Each search query must be between 1 and 200 characters.");
|
|
1384
|
+
const caseSensitive = input.caseSensitive ?? false;
|
|
1385
|
+
const key = `${caseSensitive ? "1" : "0"}\x00${input.query}`;
|
|
1386
|
+
if (seen.has(key))
|
|
1387
|
+
throw new Error("searchMany queries must be unique.");
|
|
1388
|
+
seen.add(key);
|
|
1389
|
+
return {
|
|
1390
|
+
query: input.query,
|
|
1391
|
+
caseSensitive,
|
|
1392
|
+
needle: caseSensitive ? input.query : input.query.toLocaleLowerCase(),
|
|
1393
|
+
matches: [],
|
|
1394
|
+
truncated: false
|
|
1395
|
+
};
|
|
1396
|
+
});
|
|
1397
|
+
const start = await this.safePath(relativePath);
|
|
1398
|
+
if (!(await lstat(start.path)).isDirectory())
|
|
1399
|
+
throw new Error("searchMany requires a directory.");
|
|
1400
|
+
const requestPath = wirePath(path3.relative(this.root, start.path)) || ".";
|
|
1401
|
+
const prefix = requestPath === "." ? "" : `${requestPath}/`;
|
|
1402
|
+
const index = await this.getWorkspaceIndex();
|
|
1403
|
+
let candidateIndex = requestPath === "." ? 0 : firstPathAtLeast(index.files, prefix);
|
|
1404
|
+
for (;candidateIndex < index.files.length; candidateIndex += 1) {
|
|
1405
|
+
const candidate = index.files[candidateIndex];
|
|
1406
|
+
if (!candidate || requestPath !== "." && !candidate.path.startsWith(prefix))
|
|
1407
|
+
break;
|
|
1408
|
+
let file;
|
|
1409
|
+
try {
|
|
1410
|
+
file = await this.readStableFile(candidate.path, false);
|
|
1411
|
+
} catch {
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
const lines = file.contents.toString("utf8").split(/\r?\n/);
|
|
1415
|
+
for (let lineIndex = 0;lineIndex < lines.length; lineIndex += 1) {
|
|
1416
|
+
const line = lines[lineIndex] ?? "";
|
|
1417
|
+
const foldedLine = line.toLocaleLowerCase();
|
|
1418
|
+
for (const state of states) {
|
|
1419
|
+
if (state.truncated || !(state.caseSensitive ? line : foldedLine).includes(state.needle))
|
|
1420
|
+
continue;
|
|
1421
|
+
if (state.matches.length >= limitPerQuery) {
|
|
1422
|
+
state.truncated = true;
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
state.matches.push({
|
|
1426
|
+
path: candidate.path,
|
|
1427
|
+
line: lineIndex + 1,
|
|
1428
|
+
text: truncate(line, 500),
|
|
1429
|
+
digest: file.digest
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
if (states.every((state) => state.truncated))
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
return {
|
|
1437
|
+
results: states.map(({ needle: _needle, ...state }) => state)
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1150
1440
|
audit(entry) {
|
|
1151
1441
|
const record = { id: randomUUID(), timestamp: new Date().toISOString(), ...entry };
|
|
1152
1442
|
this.auditEntries.push(record);
|
|
@@ -1310,6 +1600,7 @@ class Workspace {
|
|
|
1310
1600
|
throw new AggregateError([error, ...rollbackErrors], "Patch failed and rollback was incomplete.");
|
|
1311
1601
|
throw error;
|
|
1312
1602
|
}
|
|
1603
|
+
this.invalidateWorkspaceIndex();
|
|
1313
1604
|
const changes = prepared.map((item) => this.audit({
|
|
1314
1605
|
operation: item.before ? "update" : "create",
|
|
1315
1606
|
path: item.change.path,
|
|
@@ -1370,6 +1661,7 @@ class Workspace {
|
|
|
1370
1661
|
await unlink(destination.path).catch(() => {});
|
|
1371
1662
|
throw error;
|
|
1372
1663
|
}
|
|
1664
|
+
this.invalidateWorkspaceIndex();
|
|
1373
1665
|
const audit = this.audit({ operation: "move", path: parsed.source, destination: parsed.destination, beforeDigest: source.digest, afterDigest: source.digest });
|
|
1374
1666
|
return { source: parsed.source, destination: parsed.destination, digest: source.digest, audit };
|
|
1375
1667
|
}
|
|
@@ -1458,6 +1750,7 @@ class Workspace {
|
|
|
1458
1750
|
await unlink(path3.join(directory, `${quarantineId}.json`)).catch(() => {});
|
|
1459
1751
|
throw error;
|
|
1460
1752
|
}
|
|
1753
|
+
this.invalidateWorkspaceIndex();
|
|
1461
1754
|
const audit = this.audit({ operation: "quarantine", path: parsed.path, beforeDigest: source.digest, quarantineId });
|
|
1462
1755
|
return { quarantineId, path: parsed.path, digest: source.digest, audit };
|
|
1463
1756
|
}
|
|
@@ -1526,6 +1819,7 @@ class Workspace {
|
|
|
1526
1819
|
}
|
|
1527
1820
|
throw rollbackErrors.length ? new AggregateError([error, ...rollbackErrors], "Restore failed and rollback was incomplete.") : error;
|
|
1528
1821
|
}
|
|
1822
|
+
this.invalidateWorkspaceIndex();
|
|
1529
1823
|
const audit = this.audit({ operation: "restore", path: destinationPath, afterDigest: digest2, quarantineId: parsed.quarantineId });
|
|
1530
1824
|
return { quarantineId: parsed.quarantineId, path: destinationPath, digest: digest2, audit };
|
|
1531
1825
|
}
|
|
@@ -1546,7 +1840,11 @@ class Workspace {
|
|
|
1546
1840
|
throw new Error(`package.json does not define the "${check}" script.`);
|
|
1547
1841
|
if (scripts[check] !== expectedScript)
|
|
1548
1842
|
throw new Error(`The "${check}" script changed or does not match expectedScript.`);
|
|
1549
|
-
|
|
1843
|
+
try {
|
|
1844
|
+
return await spawnBounded(["bun", "--no-env-file", "run", check], this.root);
|
|
1845
|
+
} finally {
|
|
1846
|
+
this.invalidateWorkspaceIndex();
|
|
1847
|
+
}
|
|
1550
1848
|
}
|
|
1551
1849
|
async gitDiff() {
|
|
1552
1850
|
const [statusResult, diffResult, stagedResult] = await Promise.all([
|
|
@@ -1571,7 +1869,9 @@ var TERMINAL_STATUSES = new Set([
|
|
|
1571
1869
|
var BUILT_IN_TOOL_NAMES = new Set([
|
|
1572
1870
|
"list_files",
|
|
1573
1871
|
"read_file",
|
|
1872
|
+
"read_files",
|
|
1574
1873
|
"search_files",
|
|
1874
|
+
"search_many",
|
|
1575
1875
|
"propose_edits",
|
|
1576
1876
|
"apply_patch",
|
|
1577
1877
|
"move_file",
|
|
@@ -1695,11 +1995,26 @@ var parseRuntimeVersion = (stdout, runtime) => {
|
|
|
1695
1995
|
var validContainerIds = (stdout) => stdout.split(/\r?\n/).map((value) => value.trim()).filter((value) => /^[a-f0-9]{12,64}$/.test(value));
|
|
1696
1996
|
var validVolumeNames = (stdout) => stdout.split(/\r?\n/).map((value) => value.trim()).filter((value) => /^zhivex-harness-[A-Za-z0-9_.-]+$/.test(value));
|
|
1697
1997
|
var MAX_SYNC_ENTRIES = 1e4;
|
|
1998
|
+
var updateSnapshotMetadata = (target, kind, relative, entry) => {
|
|
1999
|
+
target.update([
|
|
2000
|
+
kind,
|
|
2001
|
+
relative,
|
|
2002
|
+
entry.dev,
|
|
2003
|
+
entry.ino,
|
|
2004
|
+
entry.size,
|
|
2005
|
+
entry.mtimeMs,
|
|
2006
|
+
entry.ctimeMs,
|
|
2007
|
+
entry.mode & 511
|
|
2008
|
+
].join("\x00"));
|
|
2009
|
+
target.update("\x00");
|
|
2010
|
+
};
|
|
1698
2011
|
var validateSynchronizedSnapshot = async (stagedRoot, previousRoot, maxWorkspaceBytes, maxFileWriteBytes) => {
|
|
1699
2012
|
let entries = 0;
|
|
1700
2013
|
let totalBytes = 0;
|
|
2014
|
+
const seal = createHash5("sha256");
|
|
2015
|
+
const metadata = createHash5("sha256");
|
|
1701
2016
|
const visit = async (directory, relativeDirectory = "") => {
|
|
1702
|
-
for (const name of await readdir2(directory)) {
|
|
2017
|
+
for (const name of (await readdir2(directory)).sort()) {
|
|
1703
2018
|
const relative = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
|
1704
2019
|
const absolute = path4.join(directory, name);
|
|
1705
2020
|
if (!isInside2(stagedRoot, absolute) || relative.length > 1024) {
|
|
@@ -1718,22 +2033,32 @@ var validateSynchronizedSnapshot = async (stagedRoot, previousRoot, maxWorkspace
|
|
|
1718
2033
|
throw new Error(`OCI workspace export contains a symbolic link: ${relative}.`);
|
|
1719
2034
|
}
|
|
1720
2035
|
if (entry.isDirectory()) {
|
|
2036
|
+
updateSnapshotMetadata(metadata, "directory", relative, entry);
|
|
2037
|
+
seal.update(`directory\x00${relative}\x00${entry.mode & 511}\x00`);
|
|
1721
2038
|
await visit(absolute, relative);
|
|
1722
2039
|
continue;
|
|
1723
2040
|
}
|
|
1724
2041
|
if (!entry.isFile() || entry.nlink !== 1) {
|
|
1725
2042
|
throw new Error(`OCI workspace export contains a special or linked file: ${relative}.`);
|
|
1726
2043
|
}
|
|
2044
|
+
const contents = await readFile2(absolute);
|
|
2045
|
+
const after = await lstat2(absolute);
|
|
2046
|
+
if (after.isSymbolicLink() || !after.isFile() || after.nlink !== 1 || entry.dev !== after.dev || entry.ino !== after.ino || entry.size !== after.size || entry.mtimeMs !== after.mtimeMs || entry.ctimeMs !== after.ctimeMs || (entry.mode & 511) !== (after.mode & 511)) {
|
|
2047
|
+
throw new Error(`OCI workspace export changed while it was being validated: ${relative}.`);
|
|
2048
|
+
}
|
|
1727
2049
|
totalBytes += entry.size;
|
|
1728
2050
|
if (totalBytes > maxWorkspaceBytes) {
|
|
1729
2051
|
throw new Error(`OCI workspace export exceeds the ${maxWorkspaceBytes}-byte workspace limit.`);
|
|
1730
2052
|
}
|
|
2053
|
+
const contentDigest = digest2(contents);
|
|
2054
|
+
updateSnapshotMetadata(metadata, "file", relative, after);
|
|
2055
|
+
seal.update(`file\x00${relative}\x00${entry.mode & 511}\x00${entry.size}\x00${contentDigest}\x00`);
|
|
1731
2056
|
if (entry.size > maxFileWriteBytes) {
|
|
1732
2057
|
const previous = path4.join(previousRoot, ...relative.split("/"));
|
|
1733
2058
|
let unchanged = false;
|
|
1734
2059
|
try {
|
|
1735
2060
|
const previousEntry = await lstat2(previous);
|
|
1736
|
-
unchanged = previousEntry.isFile() && !previousEntry.isSymbolicLink() && previousEntry.size === entry.size && digest2(await readFile2(previous)) ===
|
|
2061
|
+
unchanged = previousEntry.isFile() && !previousEntry.isSymbolicLink() && previousEntry.size === entry.size && digest2(await readFile2(previous)) === contentDigest;
|
|
1737
2062
|
} catch (error) {
|
|
1738
2063
|
if (error.code !== "ENOENT")
|
|
1739
2064
|
throw error;
|
|
@@ -1745,6 +2070,51 @@ var validateSynchronizedSnapshot = async (stagedRoot, previousRoot, maxWorkspace
|
|
|
1745
2070
|
}
|
|
1746
2071
|
};
|
|
1747
2072
|
await visit(stagedRoot);
|
|
2073
|
+
return {
|
|
2074
|
+
entries,
|
|
2075
|
+
totalBytes,
|
|
2076
|
+
seal: `sha256:${seal.digest("hex")}`,
|
|
2077
|
+
metadataFingerprint: `sha256:${metadata.digest("hex")}`
|
|
2078
|
+
};
|
|
2079
|
+
};
|
|
2080
|
+
var snapshotMetadataFingerprint = async (root, maxWorkspaceBytes) => {
|
|
2081
|
+
let entries = 0;
|
|
2082
|
+
let totalBytes = 0;
|
|
2083
|
+
const metadata = createHash5("sha256");
|
|
2084
|
+
const visit = async (directory, relativeDirectory = "") => {
|
|
2085
|
+
for (const name of (await readdir2(directory)).sort()) {
|
|
2086
|
+
const relative = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
|
2087
|
+
if (relative === "node_modules")
|
|
2088
|
+
continue;
|
|
2089
|
+
const absolute = path4.join(directory, name);
|
|
2090
|
+
if (!isInside2(root, absolute) || relative.length > 1024) {
|
|
2091
|
+
throw new Error(`OCI workspace snapshot contains an invalid path: ${relative}.`);
|
|
2092
|
+
}
|
|
2093
|
+
entries += 1;
|
|
2094
|
+
if (entries > MAX_SYNC_ENTRIES) {
|
|
2095
|
+
throw new Error(`OCI workspace snapshot exceeds the ${MAX_SYNC_ENTRIES}-entry limit.`);
|
|
2096
|
+
}
|
|
2097
|
+
const entry = await lstat2(absolute);
|
|
2098
|
+
if (entry.isSymbolicLink()) {
|
|
2099
|
+
throw new Error(`OCI workspace snapshot contains a symbolic link: ${relative}.`);
|
|
2100
|
+
}
|
|
2101
|
+
if (entry.isDirectory()) {
|
|
2102
|
+
updateSnapshotMetadata(metadata, "directory", relative, entry);
|
|
2103
|
+
await visit(absolute, relative);
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
if (!entry.isFile() || entry.nlink !== 1) {
|
|
2107
|
+
throw new Error(`OCI workspace snapshot contains a special or linked file: ${relative}.`);
|
|
2108
|
+
}
|
|
2109
|
+
totalBytes += entry.size;
|
|
2110
|
+
if (totalBytes > maxWorkspaceBytes) {
|
|
2111
|
+
throw new Error(`OCI workspace snapshot exceeds the ${maxWorkspaceBytes}-byte workspace limit.`);
|
|
2112
|
+
}
|
|
2113
|
+
updateSnapshotMetadata(metadata, "file", relative, entry);
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
await visit(root);
|
|
2117
|
+
return `sha256:${metadata.digest("hex")}`;
|
|
1748
2118
|
};
|
|
1749
2119
|
var replaceSnapshot = async (snapshotRoot, stagedRoot) => {
|
|
1750
2120
|
const backupRoot = `${snapshotRoot}.${randomUUID2()}.previous`;
|
|
@@ -1759,10 +2129,42 @@ var replaceSnapshot = async (snapshotRoot, stagedRoot) => {
|
|
|
1759
2129
|
}
|
|
1760
2130
|
await rm(backupRoot, { recursive: true, force: true });
|
|
1761
2131
|
};
|
|
2132
|
+
var OCI_WORKSPACE_SEAL_SCRIPT = [
|
|
2133
|
+
"import { createHash } from 'node:crypto';",
|
|
2134
|
+
"import { lstat, readFile, readdir, readlink, rm } from 'node:fs/promises';",
|
|
2135
|
+
"import path from 'node:path';",
|
|
2136
|
+
"const root='/workspace';",
|
|
2137
|
+
"const seal=createHash('sha256');",
|
|
2138
|
+
"const modules=path.join(root,'node_modules');",
|
|
2139
|
+
"if(process.env.ZHIVEX_HARNESS_HAS_DEPENDENCIES==='1'){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});}",
|
|
2140
|
+
"const digest=(value)=>`sha256:${createHash('sha256').update(value).digest('hex')}`;",
|
|
2141
|
+
"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);",
|
|
2142
|
+
"const visit=async(directory,base='')=>{",
|
|
2143
|
+
"for(const name of (await readdir(directory)).sort()){",
|
|
2144
|
+
"const relative=base?`${base}/${name}`:name;",
|
|
2145
|
+
"if(relative==='node_modules')continue;",
|
|
2146
|
+
"const absolute=path.join(directory,name);",
|
|
2147
|
+
"const entry=await lstat(absolute);",
|
|
2148
|
+
"if(entry.isSymbolicLink())throw new Error(`symbolic link: ${relative}`);",
|
|
2149
|
+
"if(entry.isDirectory()){seal.update(`directory\\0${relative}\\0${entry.mode&0o777}\\0`);await visit(absolute,relative);continue;}",
|
|
2150
|
+
"if(!entry.isFile()||entry.nlink!==1)throw new Error(`special or linked file: ${relative}`);",
|
|
2151
|
+
"const contents=await readFile(absolute);",
|
|
2152
|
+
"const after=await lstat(absolute);",
|
|
2153
|
+
"if(!same(entry,after))throw new Error(`changed while sealing: ${relative}`);",
|
|
2154
|
+
"seal.update(`file\\0${relative}\\0${entry.mode&0o777}\\0${entry.size}\\0${digest(contents)}\\0`);",
|
|
2155
|
+
"}",
|
|
2156
|
+
"};",
|
|
2157
|
+
"await visit(root);",
|
|
2158
|
+
"for(const name of await readdir('/tmp'))await rm(path.join('/tmp',name),{recursive:true,force:true});",
|
|
2159
|
+
"console.log(`sha256:${seal.digest('hex')}`);"
|
|
2160
|
+
].join("");
|
|
2161
|
+
var OCI_SESSION_CONTROLLER_SCRIPT = "setInterval(()=>{},2147483647);";
|
|
1762
2162
|
|
|
1763
2163
|
class CliOciRuntimeAdapter {
|
|
1764
2164
|
runtime;
|
|
1765
2165
|
hostEnvironment;
|
|
2166
|
+
sessions = new Map;
|
|
2167
|
+
runQueues = new Map;
|
|
1766
2168
|
constructor(runtime) {
|
|
1767
2169
|
this.runtime = runtime;
|
|
1768
2170
|
this.hostEnvironment = Object.fromEntries(["DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST", "HOME", "PATH", "XDG_RUNTIME_DIR"].flatMap((name) => process.env[name] ? [[name, process.env[name]]] : []));
|
|
@@ -1815,10 +2217,44 @@ ${result.stdout}`)) {
|
|
|
1815
2217
|
throw new Error(`${this.runtime} could not remove OCI workspace volume ${name}: ${result.stderr.trim()}`);
|
|
1816
2218
|
}
|
|
1817
2219
|
}
|
|
1818
|
-
|
|
2220
|
+
requestFingerprint(request) {
|
|
2221
|
+
return digest2(JSON.stringify({
|
|
2222
|
+
runId: request.runId,
|
|
2223
|
+
snapshotRoot: request.snapshotRoot,
|
|
2224
|
+
dependencyRoot: request.dependencyRoot ?? null,
|
|
2225
|
+
imageId: request.imageId,
|
|
2226
|
+
limits: request.limits
|
|
2227
|
+
}));
|
|
2228
|
+
}
|
|
2229
|
+
async withRunLock(runId, operation) {
|
|
2230
|
+
const key = runHash(runId);
|
|
2231
|
+
const previous = this.runQueues.get(key) ?? Promise.resolve();
|
|
2232
|
+
let release;
|
|
2233
|
+
const gate = new Promise((resolve) => {
|
|
2234
|
+
release = resolve;
|
|
2235
|
+
});
|
|
2236
|
+
const tail = previous.then(() => gate, () => gate);
|
|
2237
|
+
this.runQueues.set(key, tail);
|
|
2238
|
+
await previous.catch(() => {
|
|
2239
|
+
return;
|
|
2240
|
+
});
|
|
2241
|
+
try {
|
|
2242
|
+
return await operation();
|
|
2243
|
+
} finally {
|
|
2244
|
+
release();
|
|
2245
|
+
if (this.runQueues.get(key) === tail)
|
|
2246
|
+
this.runQueues.delete(key);
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
async destroySession(session) {
|
|
2250
|
+
if (this.sessions.get(session.key) === session)
|
|
2251
|
+
this.sessions.delete(session.key);
|
|
2252
|
+
await this.forceRemove(session.name);
|
|
2253
|
+
await this.removeVolume(session.volumeName);
|
|
2254
|
+
}
|
|
2255
|
+
async createSession(request, requestFingerprint, snapshotSeal, hostFingerprint) {
|
|
1819
2256
|
const resourceName = `zhivex-harness-${runHash(request.runId)}-${randomUUID2().slice(0, 8)}`;
|
|
1820
2257
|
const name = `${resourceName}-job`;
|
|
1821
|
-
const exportName = `${resourceName}-export`;
|
|
1822
2258
|
const volumeName = `${resourceName}-workspace`;
|
|
1823
2259
|
const uid = process.getuid?.() ?? 65532;
|
|
1824
2260
|
const gid = process.getgid?.() ?? 65532;
|
|
@@ -1874,8 +2310,6 @@ ${result.stdout}`)) {
|
|
|
1874
2310
|
"BUN_INSTALL_CACHE_DIR=/tmp/bun-cache",
|
|
1875
2311
|
"--env",
|
|
1876
2312
|
"CI=1",
|
|
1877
|
-
"--volume",
|
|
1878
|
-
`${request.snapshotRoot}:/seed:ro`,
|
|
1879
2313
|
...request.dependencyRoot ? ["--volume", `${request.dependencyRoot}:/dependencies:ro`] : [],
|
|
1880
2314
|
"--workdir",
|
|
1881
2315
|
"/workspace",
|
|
@@ -1883,26 +2317,19 @@ ${result.stdout}`)) {
|
|
|
1883
2317
|
"bun",
|
|
1884
2318
|
request.imageId,
|
|
1885
2319
|
"-e",
|
|
1886
|
-
|
|
2320
|
+
OCI_SESSION_CONTROLLER_SCRIPT
|
|
1887
2321
|
];
|
|
1888
|
-
let
|
|
1889
|
-
const forceRemove = () => {
|
|
1890
|
-
forcedRemoval ??= this.forceRemove(name);
|
|
1891
|
-
};
|
|
2322
|
+
let volumeCreated = false;
|
|
1892
2323
|
try {
|
|
1893
2324
|
await this.cli(volumeArgs);
|
|
2325
|
+
volumeCreated = true;
|
|
1894
2326
|
await this.cli(args);
|
|
1895
2327
|
await this.cli(["start", name]);
|
|
1896
2328
|
await this.cli([
|
|
1897
|
-
"
|
|
1898
|
-
"
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
`${uid}:${gid}`,
|
|
1902
|
-
name,
|
|
1903
|
-
"bun",
|
|
1904
|
-
"-e",
|
|
1905
|
-
"import { cp } from 'node:fs/promises'; await cp('/seed', '/workspace', { recursive: true, force: false, errorOnExist: true })"
|
|
2329
|
+
"cp",
|
|
2330
|
+
"-a",
|
|
2331
|
+
`${request.snapshotRoot}${path4.sep}.`,
|
|
2332
|
+
`${name}:/workspace`
|
|
1906
2333
|
], 60000);
|
|
1907
2334
|
if (request.dependencyRoot) {
|
|
1908
2335
|
await this.cli([
|
|
@@ -1917,88 +2344,183 @@ ${result.stdout}`)) {
|
|
|
1917
2344
|
"import { symlink } from 'node:fs/promises'; await symlink('/dependencies', '/workspace/node_modules', 'dir')"
|
|
1918
2345
|
]);
|
|
1919
2346
|
}
|
|
1920
|
-
|
|
1921
|
-
|
|
2347
|
+
await this.cli(["pause", name]);
|
|
2348
|
+
const session = {
|
|
2349
|
+
key: runHash(request.runId),
|
|
2350
|
+
requestFingerprint,
|
|
2351
|
+
name,
|
|
2352
|
+
volumeName,
|
|
2353
|
+
snapshotSeal,
|
|
2354
|
+
hostFingerprint,
|
|
2355
|
+
paused: true
|
|
2356
|
+
};
|
|
2357
|
+
this.sessions.set(session.key, session);
|
|
2358
|
+
return session;
|
|
2359
|
+
} catch (error) {
|
|
2360
|
+
await this.forceRemove(name);
|
|
2361
|
+
if (volumeCreated)
|
|
2362
|
+
await this.removeVolume(volumeName).catch(() => {
|
|
2363
|
+
return;
|
|
2364
|
+
});
|
|
2365
|
+
throw error;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
async onlyControllerProcessRemains(session) {
|
|
2369
|
+
const result = await this.cli(this.runtime === "podman" ? ["top", session.name, "pid"] : ["top", session.name, "-eo", "pid"]);
|
|
2370
|
+
const processIds = result.stdout.split(/\r?\n/).filter((line) => /^\s*\d+\s*$/.test(line));
|
|
2371
|
+
return processIds.length === 1;
|
|
2372
|
+
}
|
|
2373
|
+
async containerWorkspaceSeal(session, uid, gid, hasDependencies) {
|
|
2374
|
+
if (session.paused) {
|
|
2375
|
+
await this.cli(["unpause", session.name]);
|
|
2376
|
+
session.paused = false;
|
|
2377
|
+
}
|
|
2378
|
+
try {
|
|
2379
|
+
const result = await this.cli([
|
|
1922
2380
|
"exec",
|
|
1923
2381
|
"--workdir",
|
|
1924
2382
|
"/workspace",
|
|
1925
2383
|
"--user",
|
|
1926
2384
|
`${uid}:${gid}`,
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
})
|
|
1936
|
-
|
|
1937
|
-
forceRemove();
|
|
1938
|
-
await forcedRemoval;
|
|
1939
|
-
} else if (result.exitCode === 0) {
|
|
1940
|
-
await this.cli(["pause", name]);
|
|
1941
|
-
const stagedRoot = path4.join(path4.dirname(request.snapshotRoot), `.workspace-sync-${randomUUID2()}`);
|
|
1942
|
-
try {
|
|
1943
|
-
await privateDirectory(stagedRoot);
|
|
1944
|
-
try {
|
|
1945
|
-
await this.cli([
|
|
1946
|
-
"run",
|
|
1947
|
-
"--rm",
|
|
1948
|
-
"--name",
|
|
1949
|
-
exportName,
|
|
1950
|
-
"--label",
|
|
1951
|
-
`${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`,
|
|
1952
|
-
"--label",
|
|
1953
|
-
`com.zhivex.harness.run=${runHash(request.runId)}`,
|
|
1954
|
-
"--network",
|
|
1955
|
-
"none",
|
|
1956
|
-
"--read-only",
|
|
1957
|
-
"--cap-drop",
|
|
1958
|
-
"ALL",
|
|
1959
|
-
"--security-opt",
|
|
1960
|
-
"no-new-privileges",
|
|
1961
|
-
"--pids-limit",
|
|
1962
|
-
"32",
|
|
1963
|
-
"--memory",
|
|
1964
|
-
"256m",
|
|
1965
|
-
"--cpus",
|
|
1966
|
-
"1",
|
|
1967
|
-
"--tmpfs",
|
|
1968
|
-
"/tmp:rw,noexec,nosuid,nodev,size=16m",
|
|
1969
|
-
"--user",
|
|
1970
|
-
`${uid}:${gid}`,
|
|
1971
|
-
"--volume",
|
|
1972
|
-
`${volumeName}:/workspace:ro`,
|
|
1973
|
-
"--volume",
|
|
1974
|
-
`${stagedRoot}:/export:rw`,
|
|
1975
|
-
"--workdir",
|
|
1976
|
-
"/workspace",
|
|
1977
|
-
"--entrypoint",
|
|
1978
|
-
"bun",
|
|
1979
|
-
request.imageId,
|
|
1980
|
-
"-e",
|
|
1981
|
-
"import { cp } from 'node:fs/promises'; await cp('/workspace', '/export', { recursive: true, force: false, errorOnExist: true })"
|
|
1982
|
-
], 60000);
|
|
1983
|
-
} finally {
|
|
1984
|
-
await this.forceRemove(exportName);
|
|
1985
|
-
}
|
|
1986
|
-
await validateSynchronizedSnapshot(stagedRoot, request.snapshotRoot, request.limits.maxWorkspaceBytes, request.limits.maxFileWriteBytes);
|
|
1987
|
-
await replaceSnapshot(request.snapshotRoot, stagedRoot);
|
|
1988
|
-
} finally {
|
|
1989
|
-
await rm(stagedRoot, { recursive: true, force: true });
|
|
1990
|
-
}
|
|
2385
|
+
"--env",
|
|
2386
|
+
`ZHIVEX_HARNESS_HAS_DEPENDENCIES=${hasDependencies ? "1" : "0"}`,
|
|
2387
|
+
session.name,
|
|
2388
|
+
"bun",
|
|
2389
|
+
"-e",
|
|
2390
|
+
OCI_WORKSPACE_SEAL_SCRIPT
|
|
2391
|
+
], 60000, 20000);
|
|
2392
|
+
const seal = result.stdout.trim();
|
|
2393
|
+
if (!/^sha256:[a-f0-9]{64}$/.test(seal)) {
|
|
2394
|
+
throw new Error("OCI workspace seal helper returned an invalid digest.");
|
|
1991
2395
|
}
|
|
1992
|
-
return
|
|
1993
|
-
...result,
|
|
1994
|
-
command: request.command
|
|
1995
|
-
};
|
|
2396
|
+
return seal;
|
|
1996
2397
|
} finally {
|
|
2398
|
+
if (!session.paused) {
|
|
2399
|
+
await this.cli(["pause", session.name]);
|
|
2400
|
+
session.paused = true;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
async runInSession(request, session, sessionReused) {
|
|
2405
|
+
const uid = process.getuid?.() ?? 65532;
|
|
2406
|
+
const gid = process.getgid?.() ?? 65532;
|
|
2407
|
+
if (session.paused) {
|
|
2408
|
+
await this.cli(["unpause", session.name]);
|
|
2409
|
+
session.paused = false;
|
|
2410
|
+
}
|
|
2411
|
+
let forcedRemoval;
|
|
2412
|
+
const forceRemove = () => {
|
|
2413
|
+
forcedRemoval ??= this.forceRemove(session.name);
|
|
2414
|
+
};
|
|
2415
|
+
const result = await spawnBounded2([
|
|
2416
|
+
this.runtime,
|
|
2417
|
+
"exec",
|
|
2418
|
+
"--workdir",
|
|
2419
|
+
"/workspace",
|
|
2420
|
+
"--user",
|
|
2421
|
+
`${uid}:${gid}`,
|
|
2422
|
+
session.name,
|
|
2423
|
+
...request.command
|
|
2424
|
+
], {
|
|
2425
|
+
env: this.hostEnvironment,
|
|
2426
|
+
timeoutMs: request.limits.maxProcessRuntimeMs,
|
|
2427
|
+
maxOutputBytes: request.limits.maxProcessOutputBytes,
|
|
2428
|
+
...request.abortSignal ? { abortSignal: request.abortSignal } : {},
|
|
2429
|
+
onStop: forceRemove
|
|
2430
|
+
});
|
|
2431
|
+
if (result.exitCode !== 0 || result.timedOut || result.cancelled || result.outputLimitExceeded) {
|
|
1997
2432
|
forceRemove();
|
|
1998
2433
|
await forcedRemoval;
|
|
1999
|
-
await this.
|
|
2434
|
+
await this.destroySession(session);
|
|
2435
|
+
return {
|
|
2436
|
+
...result,
|
|
2437
|
+
command: request.command,
|
|
2438
|
+
sessionReused,
|
|
2439
|
+
workspacePublished: false,
|
|
2440
|
+
workspaceExported: false
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
try {
|
|
2444
|
+
if (!await this.onlyControllerProcessRemains(session)) {
|
|
2445
|
+
await this.destroySession(session);
|
|
2446
|
+
return {
|
|
2447
|
+
...result,
|
|
2448
|
+
command: request.command,
|
|
2449
|
+
exitCode: 126,
|
|
2450
|
+
stderr: [
|
|
2451
|
+
result.stderr,
|
|
2452
|
+
"OCI command left background processes running; its workspace changes were discarded."
|
|
2453
|
+
].filter(Boolean).join(`
|
|
2454
|
+
`),
|
|
2455
|
+
sessionReused,
|
|
2456
|
+
workspacePublished: false,
|
|
2457
|
+
workspaceExported: false
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
const workspaceSeal = await this.containerWorkspaceSeal(session, uid, gid, Boolean(request.dependencyRoot));
|
|
2461
|
+
if (workspaceSeal === session.snapshotSeal) {
|
|
2462
|
+
return {
|
|
2463
|
+
...result,
|
|
2464
|
+
command: request.command,
|
|
2465
|
+
sessionReused,
|
|
2466
|
+
workspacePublished: true,
|
|
2467
|
+
workspaceExported: false
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
const stagedRoot = path4.join(path4.dirname(request.snapshotRoot), `.workspace-sync-${randomUUID2()}`);
|
|
2471
|
+
try {
|
|
2472
|
+
await privateDirectory(stagedRoot);
|
|
2473
|
+
await this.cli(["cp", `${session.name}:/workspace/.`, stagedRoot], 60000);
|
|
2474
|
+
const validated = await validateSynchronizedSnapshot(stagedRoot, request.snapshotRoot, request.limits.maxWorkspaceBytes, request.limits.maxFileWriteBytes);
|
|
2475
|
+
await replaceSnapshot(request.snapshotRoot, stagedRoot);
|
|
2476
|
+
session.snapshotSeal = validated.seal;
|
|
2477
|
+
session.hostFingerprint = validated.metadataFingerprint;
|
|
2478
|
+
} finally {
|
|
2479
|
+
await rm(stagedRoot, { recursive: true, force: true });
|
|
2480
|
+
}
|
|
2481
|
+
return {
|
|
2482
|
+
...result,
|
|
2483
|
+
command: request.command,
|
|
2484
|
+
sessionReused,
|
|
2485
|
+
workspacePublished: true,
|
|
2486
|
+
workspaceExported: true
|
|
2487
|
+
};
|
|
2488
|
+
} catch (error) {
|
|
2489
|
+
await this.destroySession(session);
|
|
2490
|
+
throw error;
|
|
2000
2491
|
}
|
|
2001
2492
|
}
|
|
2493
|
+
async runLocked(request) {
|
|
2494
|
+
const key = runHash(request.runId);
|
|
2495
|
+
const requestFingerprint = this.requestFingerprint(request);
|
|
2496
|
+
let session = this.sessions.get(key);
|
|
2497
|
+
let sessionReused = Boolean(session);
|
|
2498
|
+
let validated;
|
|
2499
|
+
if (session) {
|
|
2500
|
+
const hostFingerprint = await snapshotMetadataFingerprint(request.snapshotRoot, request.limits.maxWorkspaceBytes);
|
|
2501
|
+
if (session.requestFingerprint !== requestFingerprint || session.hostFingerprint !== hostFingerprint) {
|
|
2502
|
+
await this.destroySession(session);
|
|
2503
|
+
session = undefined;
|
|
2504
|
+
sessionReused = false;
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
if (!session) {
|
|
2508
|
+
validated = await validateSynchronizedSnapshot(request.snapshotRoot, request.snapshotRoot, request.limits.maxWorkspaceBytes, request.limits.maxFileWriteBytes);
|
|
2509
|
+
session = await this.createSession(request, requestFingerprint, validated.seal, validated.metadataFingerprint);
|
|
2510
|
+
}
|
|
2511
|
+
try {
|
|
2512
|
+
return await this.runInSession(request, session, sessionReused);
|
|
2513
|
+
} catch (error) {
|
|
2514
|
+
if (this.sessions.get(key) === session)
|
|
2515
|
+
await this.destroySession(session).catch(() => {
|
|
2516
|
+
return;
|
|
2517
|
+
});
|
|
2518
|
+
throw error;
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
async run(request) {
|
|
2522
|
+
return this.withRunLock(request.runId, () => this.runLocked(request));
|
|
2523
|
+
}
|
|
2002
2524
|
async removeByFilters(filters) {
|
|
2003
2525
|
const listed = await spawnBounded2([this.runtime, "ps", "-aq", ...filters.flatMap((filter) => ["--filter", filter])], {
|
|
2004
2526
|
env: this.hostEnvironment,
|
|
@@ -2044,15 +2566,23 @@ ${result.stdout}`)) {
|
|
|
2044
2566
|
return removed;
|
|
2045
2567
|
}
|
|
2046
2568
|
async removeRunContainers(runId) {
|
|
2569
|
+
const key = runHash(runId);
|
|
2570
|
+
const session = this.sessions.get(key);
|
|
2571
|
+
let cachedResources = 0;
|
|
2572
|
+
if (session) {
|
|
2573
|
+
await this.destroySession(session);
|
|
2574
|
+
cachedResources = 2;
|
|
2575
|
+
}
|
|
2047
2576
|
const filters = [
|
|
2048
2577
|
`label=${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`,
|
|
2049
|
-
`label=com.zhivex.harness.run=${
|
|
2578
|
+
`label=com.zhivex.harness.run=${key}`
|
|
2050
2579
|
];
|
|
2051
2580
|
const containers = await this.removeByFilters(filters);
|
|
2052
2581
|
const volumes = await this.removeVolumesByFilters(filters);
|
|
2053
|
-
return containers + volumes;
|
|
2582
|
+
return cachedResources + containers + volumes;
|
|
2054
2583
|
}
|
|
2055
2584
|
async cleanupOrphans() {
|
|
2585
|
+
this.sessions.clear();
|
|
2056
2586
|
let removed = 0;
|
|
2057
2587
|
for (const status of ["created", "exited", "dead", "paused"]) {
|
|
2058
2588
|
removed += await this.removeByFilters([
|
|
@@ -2066,34 +2596,81 @@ ${result.stdout}`)) {
|
|
|
2066
2596
|
return removed;
|
|
2067
2597
|
}
|
|
2068
2598
|
}
|
|
2069
|
-
var
|
|
2599
|
+
var SNAPSHOT_INVENTORY_PAGE_SIZE = 5000;
|
|
2600
|
+
var COPY_ON_WRITE_UNSUPPORTED_CODES = new Set(["EINVAL", "ENOSYS", "ENOTSUP", "EOPNOTSUPP"]);
|
|
2601
|
+
var collectSnapshotInventory = async (workspace, metrics) => {
|
|
2070
2602
|
const files = new Map;
|
|
2603
|
+
if (metrics)
|
|
2604
|
+
metrics.inventoryPasses += 1;
|
|
2071
2605
|
let cursor;
|
|
2072
2606
|
do {
|
|
2073
|
-
const page = await workspace.listFiles(".", {
|
|
2607
|
+
const page = await workspace.listFiles(".", {
|
|
2608
|
+
limit: SNAPSHOT_INVENTORY_PAGE_SIZE,
|
|
2609
|
+
...cursor ? { cursor } : {}
|
|
2610
|
+
});
|
|
2611
|
+
if (metrics)
|
|
2612
|
+
metrics.inventoryPages += 1;
|
|
2074
2613
|
for (const file of page.files) {
|
|
2075
2614
|
const absolute = path4.join(workspace.root, ...file.path.split("/"));
|
|
2076
2615
|
if (!isInside2(workspace.root, absolute))
|
|
2077
2616
|
throw new Error(`Snapshot file escaped its workspace: ${file.path}.`);
|
|
2078
|
-
const
|
|
2079
|
-
if (
|
|
2617
|
+
const entry = await lstat2(absolute);
|
|
2618
|
+
if (entry.isSymbolicLink() || !entry.isFile()) {
|
|
2080
2619
|
throw new Error(`Snapshot source is not a regular file: ${file.path}.`);
|
|
2081
|
-
const contents = await readFile2(absolute);
|
|
2082
|
-
const after = await lstat2(absolute);
|
|
2083
|
-
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
2084
|
-
throw new Error(`Snapshot source changed while reading: ${file.path}.`);
|
|
2085
2620
|
}
|
|
2086
|
-
if (
|
|
2087
|
-
throw new Error(`Snapshot
|
|
2088
|
-
|
|
2621
|
+
if (entry.size !== file.size) {
|
|
2622
|
+
throw new Error(`Snapshot source changed while inventorying: ${file.path}.`);
|
|
2623
|
+
}
|
|
2624
|
+
files.set(file.path, {
|
|
2625
|
+
path: file.path,
|
|
2626
|
+
digest: file.digest,
|
|
2627
|
+
mode: entry.mode & 511,
|
|
2628
|
+
bytes: file.size
|
|
2629
|
+
});
|
|
2089
2630
|
}
|
|
2090
2631
|
cursor = page.nextCursor;
|
|
2091
2632
|
} while (cursor);
|
|
2092
2633
|
return files;
|
|
2093
2634
|
};
|
|
2094
|
-
var
|
|
2095
|
-
const
|
|
2096
|
-
|
|
2635
|
+
var readSnapshotFile = async (workspace, expected, metrics) => {
|
|
2636
|
+
const absolute = path4.join(workspace.root, ...expected.path.split("/"));
|
|
2637
|
+
if (!isInside2(workspace.root, absolute)) {
|
|
2638
|
+
throw new Error(`Snapshot file escaped its workspace: ${expected.path}.`);
|
|
2639
|
+
}
|
|
2640
|
+
const before = await lstat2(absolute);
|
|
2641
|
+
if (before.isSymbolicLink() || !before.isFile()) {
|
|
2642
|
+
throw new Error(`Snapshot source is not a regular file: ${expected.path}.`);
|
|
2643
|
+
}
|
|
2644
|
+
const contents = await readFile2(absolute);
|
|
2645
|
+
const after = await lstat2(absolute);
|
|
2646
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || (before.mode & 511) !== (after.mode & 511)) {
|
|
2647
|
+
throw new Error(`Snapshot source changed while reading: ${expected.path}.`);
|
|
2648
|
+
}
|
|
2649
|
+
const actualDigest = digest2(contents);
|
|
2650
|
+
if (actualDigest !== expected.digest || contents.byteLength !== expected.bytes || (before.mode & 511) !== expected.mode) {
|
|
2651
|
+
throw new Error(`Snapshot source changed after inventory: ${expected.path}.`);
|
|
2652
|
+
}
|
|
2653
|
+
if (metrics) {
|
|
2654
|
+
metrics.verifiedContentReads += 1;
|
|
2655
|
+
metrics.verifiedContentBytes += contents.byteLength;
|
|
2656
|
+
}
|
|
2657
|
+
return { ...expected, contents };
|
|
2658
|
+
};
|
|
2659
|
+
var copySnapshotFile = async (source, target, metrics) => {
|
|
2660
|
+
try {
|
|
2661
|
+
await copyFile(source, target, fsConstants.COPYFILE_FICLONE_FORCE);
|
|
2662
|
+
} catch (error) {
|
|
2663
|
+
const code = error.code;
|
|
2664
|
+
if (!code || !COPY_ON_WRITE_UNSUPPORTED_CODES.has(code))
|
|
2665
|
+
throw error;
|
|
2666
|
+
if (metrics)
|
|
2667
|
+
metrics.snapshotCloneFallbacks += 1;
|
|
2668
|
+
await copyFile(source, target);
|
|
2669
|
+
}
|
|
2670
|
+
};
|
|
2671
|
+
var copyWorkspaceSnapshot = async (source, baseRoot, snapshotRoot, maxWorkspaceBytes, metrics) => {
|
|
2672
|
+
const files = await collectSnapshotInventory(source, metrics);
|
|
2673
|
+
const totalBytes = [...files.values()].reduce((total, file) => total + file.bytes, 0);
|
|
2097
2674
|
if (totalBytes > maxWorkspaceBytes) {
|
|
2098
2675
|
throw new Error(`Workspace snapshot exceeds the ${maxWorkspaceBytes}-byte OCI limit.`);
|
|
2099
2676
|
}
|
|
@@ -2101,13 +2678,22 @@ var copyWorkspaceSnapshot = async (source, baseRoot, snapshotRoot, maxWorkspaceB
|
|
|
2101
2678
|
await rm(root, { recursive: true, force: true });
|
|
2102
2679
|
await privateDirectory(root);
|
|
2103
2680
|
}
|
|
2104
|
-
for (const
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2681
|
+
for (const expected of files.values()) {
|
|
2682
|
+
const file = await readSnapshotFile(source, expected, metrics);
|
|
2683
|
+
const baseTarget = path4.join(baseRoot, ...file.path.split("/"));
|
|
2684
|
+
const snapshotTarget = path4.join(snapshotRoot, ...file.path.split("/"));
|
|
2685
|
+
await Promise.all([
|
|
2686
|
+
mkdir2(path4.dirname(baseTarget), { recursive: true, mode: 448 }),
|
|
2687
|
+
mkdir2(path4.dirname(snapshotTarget), { recursive: true, mode: 448 })
|
|
2688
|
+
]);
|
|
2689
|
+
await writeFile(baseTarget, file.contents, { mode: file.mode });
|
|
2690
|
+
await chmod(baseTarget, file.mode);
|
|
2691
|
+
await copySnapshotFile(baseTarget, snapshotTarget, metrics);
|
|
2692
|
+
await chmod(snapshotTarget, file.mode);
|
|
2693
|
+
}
|
|
2694
|
+
if (metrics) {
|
|
2695
|
+
metrics.snapshotFiles += files.size;
|
|
2696
|
+
metrics.snapshotBytes += totalBytes;
|
|
2111
2697
|
}
|
|
2112
2698
|
return { files: files.size, bytes: totalBytes };
|
|
2113
2699
|
};
|
|
@@ -2126,9 +2712,11 @@ var textContent = (file, maxFileWriteBytes) => {
|
|
|
2126
2712
|
}
|
|
2127
2713
|
return file.contents.toString("utf8");
|
|
2128
2714
|
};
|
|
2129
|
-
var createEnvironmentPatch = async (runId, base, current, maxFileWriteBytes) => {
|
|
2130
|
-
const before = await
|
|
2131
|
-
|
|
2715
|
+
var createEnvironmentPatch = async (runId, base, current, maxFileWriteBytes, metrics) => {
|
|
2716
|
+
const [before, after] = await Promise.all([
|
|
2717
|
+
collectSnapshotInventory(base, metrics),
|
|
2718
|
+
collectSnapshotInventory(current, metrics)
|
|
2719
|
+
]);
|
|
2132
2720
|
const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
|
|
2133
2721
|
const entries = [];
|
|
2134
2722
|
let totalBytes = 0;
|
|
@@ -2138,9 +2726,9 @@ var createEnvironmentPatch = async (runId, base, current, maxFileWriteBytes) =>
|
|
|
2138
2726
|
if (oldFile?.digest === newFile?.digest && oldFile?.mode === newFile?.mode)
|
|
2139
2727
|
continue;
|
|
2140
2728
|
const operation = !oldFile ? "create" : !newFile ? "delete" : "update";
|
|
2141
|
-
const beforeContent = oldFile ? textContent(oldFile, maxFileWriteBytes) : undefined;
|
|
2142
|
-
const afterContent = newFile ? textContent(newFile, maxFileWriteBytes) : undefined;
|
|
2143
|
-
const bytes = newFile?.
|
|
2729
|
+
const beforeContent = oldFile ? textContent(await readSnapshotFile(base, oldFile, metrics), maxFileWriteBytes) : undefined;
|
|
2730
|
+
const afterContent = newFile ? textContent(await readSnapshotFile(current, newFile, metrics), maxFileWriteBytes) : undefined;
|
|
2731
|
+
const bytes = newFile?.bytes ?? 0;
|
|
2144
2732
|
totalBytes += bytes;
|
|
2145
2733
|
entries.push({
|
|
2146
2734
|
path: filePath,
|
|
@@ -2180,8 +2768,8 @@ var inspectHostPrecondition = async (workspace, filePath, expectedDigest, expect
|
|
|
2180
2768
|
throw error;
|
|
2181
2769
|
}
|
|
2182
2770
|
};
|
|
2183
|
-
var importPatch = async (runId, expectedPatchId, host, base, current, maxFileWriteBytes) => {
|
|
2184
|
-
const patch = await createEnvironmentPatch(runId, base, current, maxFileWriteBytes);
|
|
2771
|
+
var importPatch = async (runId, expectedPatchId, host, base, current, maxFileWriteBytes, metrics) => {
|
|
2772
|
+
const patch = await createEnvironmentPatch(runId, base, current, maxFileWriteBytes, metrics);
|
|
2185
2773
|
if (patch.patchId !== expectedPatchId) {
|
|
2186
2774
|
throw new Error("Environment patch changed after review; inspect it again before import.");
|
|
2187
2775
|
}
|
|
@@ -2275,7 +2863,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
|
|
|
2275
2863
|
version: `${HARNESS_EXECUTION_POLICY_VERSION}:${image.imageDigest}`,
|
|
2276
2864
|
backend: "container",
|
|
2277
2865
|
assurance: "enforced",
|
|
2278
|
-
isolation: "per-
|
|
2866
|
+
isolation: "per-run",
|
|
2279
2867
|
workspace: {
|
|
2280
2868
|
id: workspaceIdentity,
|
|
2281
2869
|
root: "/workspace",
|
|
@@ -2312,6 +2900,8 @@ var createHarnessOciExecutionEnvironment = async (options) => {
|
|
|
2312
2900
|
maxPids: options.config.maxPids,
|
|
2313
2901
|
maxCpus: options.config.maxCpus,
|
|
2314
2902
|
tmpfsMb: options.config.tmpfsMb,
|
|
2903
|
+
containerLifecycle: "warm-per-acquired-run",
|
|
2904
|
+
failedCommandRecovery: "discard-and-reseed",
|
|
2315
2905
|
patchImportApproval: true,
|
|
2316
2906
|
hostMutationMode: "reviewed-environment-patch"
|
|
2317
2907
|
}
|
|
@@ -2323,6 +2913,19 @@ var createHarnessOciExecutionEnvironment = async (options) => {
|
|
|
2323
2913
|
image,
|
|
2324
2914
|
runtime,
|
|
2325
2915
|
async acquire(request) {
|
|
2916
|
+
const ioMetrics = {
|
|
2917
|
+
inventoryPasses: 0,
|
|
2918
|
+
inventoryPages: 0,
|
|
2919
|
+
verifiedContentReads: 0,
|
|
2920
|
+
verifiedContentBytes: 0,
|
|
2921
|
+
snapshotFiles: 0,
|
|
2922
|
+
snapshotBytes: 0,
|
|
2923
|
+
snapshotCloneFallbacks: 0,
|
|
2924
|
+
containerStarts: 0,
|
|
2925
|
+
containerReuses: 0,
|
|
2926
|
+
workspacePublishes: 0,
|
|
2927
|
+
workspaceExports: 0
|
|
2928
|
+
};
|
|
2326
2929
|
await privateDirectory(environmentRoot);
|
|
2327
2930
|
const directory = path4.join(environmentRoot, runHash(request.runId));
|
|
2328
2931
|
await privateDirectory(directory);
|
|
@@ -2341,7 +2944,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
|
|
|
2341
2944
|
} catch (error) {
|
|
2342
2945
|
if (error.code !== "ENOENT")
|
|
2343
2946
|
throw error;
|
|
2344
|
-
const snapshot = await copyWorkspaceSnapshot(options.workspace, baseRoot, snapshotRoot, options.config.maxWorkspaceBytes);
|
|
2947
|
+
const snapshot = await copyWorkspaceSnapshot(options.workspace, baseRoot, snapshotRoot, options.config.maxWorkspaceBytes, ioMetrics);
|
|
2345
2948
|
const now = new Date().toISOString();
|
|
2346
2949
|
metadata = {
|
|
2347
2950
|
schemaVersion: HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION,
|
|
@@ -2398,6 +3001,14 @@ var createHarnessOciExecutionEnvironment = async (options) => {
|
|
|
2398
3001
|
},
|
|
2399
3002
|
...context?.abortSignal ?? request.abortSignal ? { abortSignal: context?.abortSignal ?? request.abortSignal } : {}
|
|
2400
3003
|
});
|
|
3004
|
+
if (result.sessionReused === false)
|
|
3005
|
+
ioMetrics.containerStarts += 1;
|
|
3006
|
+
if (result.sessionReused === true)
|
|
3007
|
+
ioMetrics.containerReuses += 1;
|
|
3008
|
+
if (result.workspacePublished === true)
|
|
3009
|
+
ioMetrics.workspacePublishes += 1;
|
|
3010
|
+
if (result.workspaceExported === true)
|
|
3011
|
+
ioMetrics.workspaceExports += 1;
|
|
2401
3012
|
const forcedExitCode = result.cancelled ? 130 : result.timedOut ? 124 : result.outputLimitExceeded ? 125 : result.exitCode;
|
|
2402
3013
|
return {
|
|
2403
3014
|
command: result.command,
|
|
@@ -2444,7 +3055,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
|
|
|
2444
3055
|
return await operation();
|
|
2445
3056
|
},
|
|
2446
3057
|
async status() {
|
|
2447
|
-
const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes);
|
|
3058
|
+
const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes, ioMetrics);
|
|
2448
3059
|
return {
|
|
2449
3060
|
schemaVersion: HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION,
|
|
2450
3061
|
kind: "environment-status",
|
|
@@ -2458,7 +3069,8 @@ var createHarnessOciExecutionEnvironment = async (options) => {
|
|
|
2458
3069
|
rootFilesystem: "read-only",
|
|
2459
3070
|
workspace: "ephemeral-snapshot",
|
|
2460
3071
|
patchId: patch.patchId,
|
|
2461
|
-
changedFiles: patch.entries.length
|
|
3072
|
+
changedFiles: patch.entries.length,
|
|
3073
|
+
io: { ...ioMetrics }
|
|
2462
3074
|
};
|
|
2463
3075
|
},
|
|
2464
3076
|
runCommand,
|
|
@@ -2483,7 +3095,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
|
|
|
2483
3095
|
return runCommand("bun", ["--no-env-file", "run", check], context);
|
|
2484
3096
|
},
|
|
2485
3097
|
async inspectPatch() {
|
|
2486
|
-
const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes);
|
|
3098
|
+
const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes, ioMetrics);
|
|
2487
3099
|
const payload = patchPayload(request.runId, patch.entries);
|
|
2488
3100
|
return {
|
|
2489
3101
|
...payload,
|
|
@@ -2492,7 +3104,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
|
|
|
2492
3104
|
};
|
|
2493
3105
|
},
|
|
2494
3106
|
importPatch(host, patchId) {
|
|
2495
|
-
return importPatch(request.runId, patchId, host, base, workspace, options.config.maxFileWriteBytes);
|
|
3107
|
+
return importPatch(request.runId, patchId, host, base, workspace, options.config.maxFileWriteBytes, ioMetrics);
|
|
2496
3108
|
},
|
|
2497
3109
|
async release(result) {
|
|
2498
3110
|
await runtime.removeRunContainers(request.runId);
|
|
@@ -3311,7 +3923,7 @@ var HARNESS_SUBAGENT_PROFILE_DESCRIPTORS = [
|
|
|
3311
3923
|
toolName: "delegate_explorer",
|
|
3312
3924
|
description: "Delegate bounded repository discovery to a read-only explorer.",
|
|
3313
3925
|
instructions: "Inspect the repository with read-only tools. Return evidence with paths and concrete findings. Never propose or perform mutations.",
|
|
3314
|
-
toolNames: ["list_files", "read_file", "search_files", "git_diff"]
|
|
3926
|
+
toolNames: ["list_files", "read_file", "read_files", "search_files", "search_many", "git_diff"]
|
|
3315
3927
|
},
|
|
3316
3928
|
{
|
|
3317
3929
|
id: "implementer",
|
|
@@ -3325,14 +3937,14 @@ var HARNESS_SUBAGENT_PROFILE_DESCRIPTORS = [
|
|
|
3325
3937
|
toolName: "delegate_tester",
|
|
3326
3938
|
description: "Delegate focused verification to a tester with read-only inspection and approved Bun checks.",
|
|
3327
3939
|
instructions: "Verify the delegated behavior with repository evidence and declared Bun checks. Do not edit files. Report exact failures and limits.",
|
|
3328
|
-
toolNames: ["list_files", "read_file", "search_files", "run_check", "git_diff"]
|
|
3940
|
+
toolNames: ["list_files", "read_file", "read_files", "search_files", "search_many", "run_check", "git_diff"]
|
|
3329
3941
|
},
|
|
3330
3942
|
{
|
|
3331
3943
|
id: "reviewer",
|
|
3332
3944
|
toolName: "delegate_reviewer",
|
|
3333
3945
|
description: "Delegate an independent read-only review of correctness, safety, and regressions.",
|
|
3334
3946
|
instructions: "Review the requested scope independently. Inspect concrete code and diffs, prioritize actionable defects, and do not mutate the workspace.",
|
|
3335
|
-
toolNames: ["list_files", "read_file", "search_files", "mutation_audit", "git_diff"]
|
|
3947
|
+
toolNames: ["list_files", "read_file", "read_files", "search_files", "search_many", "mutation_audit", "git_diff"]
|
|
3336
3948
|
}
|
|
3337
3949
|
];
|
|
3338
3950
|
var pickTools = (tools, names) => {
|
|
@@ -3429,7 +4041,7 @@ var createHarnessSubagents = (options) => {
|
|
|
3429
4041
|
|
|
3430
4042
|
// src/harness.ts
|
|
3431
4043
|
var APPROVAL_VERSION = "2026-08-17-v5";
|
|
3432
|
-
var TOOL_CONTRACT_VERSION = "
|
|
4044
|
+
var TOOL_CONTRACT_VERSION = "workspace-batch-v2";
|
|
3433
4045
|
var createHarnessBinding = (config, mcpConfiguration, model, subagentModels, providerTransportFingerprint, executionEnvironment) => ({
|
|
3434
4046
|
schemaVersion: 1,
|
|
3435
4047
|
id: "zhivex-harness",
|
|
@@ -3459,11 +4071,12 @@ var HARNESS_INSTRUCTIONS = `You are Zhivex Harness, a provider-portable coding a
|
|
|
3459
4071
|
|
|
3460
4072
|
Rules:
|
|
3461
4073
|
- Match the user's language.
|
|
3462
|
-
- Inspect the repository before proposing changes. Prefer
|
|
4074
|
+
- Inspect the repository before proposing changes. Prefer search_many and read_files for independent lookups, then use search_files or read_file when pagination or one focused read is clearer.
|
|
3463
4075
|
- Use only workspace-relative paths. Never request or expose secrets.
|
|
3464
4076
|
- Make the smallest coherent change that fully addresses the task.
|
|
3465
4077
|
- For every file edit, first read its digest and call propose_edits. Apply exactly that reviewed proposal with apply_patch.
|
|
3466
4078
|
- apply_patch, move_file, quarantine_file, restore_file, and run_check require explicit approval from the operator.
|
|
4079
|
+
- Calling an approval-gated tool is how you request that approval: call the tool with its complete reviewed arguments, then let the runtime pause before execution. Never replace the tool call with a textual approval request.
|
|
3467
4080
|
- With enforced OCI execution enabled, workspace tools operate on an ephemeral snapshot. Use inspect_environment_patch and obtain a separate approval through apply_environment_patch before changing the host workspace.
|
|
3468
4081
|
- run_environment_command executes one allowlisted argv command without a host shell. Network, privileges, resources, environment variables, and output are bounded by the OCI policy.
|
|
3469
4082
|
- Never overwrite stale content. If an expected digest no longer matches, inspect the file again and create a new proposal.
|
|
@@ -3530,6 +4143,23 @@ var createWorkspaceTools = (workspace, allowedChecks) => ({
|
|
|
3530
4143
|
metadata: readOnlyMetadata,
|
|
3531
4144
|
execute: async ({ path: path8, startLine, endLine }, context) => serializeJsonValue2(await (harnessExecutionSession(context)?.workspace ?? workspace).readFile(path8, startLine, endLine))
|
|
3532
4145
|
}),
|
|
4146
|
+
read_files: tool({
|
|
4147
|
+
name: "read_files",
|
|
4148
|
+
description: "Read up to 20 independent UTF-8 file slices in one bounded call. Duplicate paths are read once and results use deterministic path/range order.",
|
|
4149
|
+
schema: z3.object({
|
|
4150
|
+
files: z3.array(z3.object({
|
|
4151
|
+
path: z3.string().min(1),
|
|
4152
|
+
startLine: z3.number().int().min(1).default(1),
|
|
4153
|
+
endLine: z3.number().int().min(1).optional()
|
|
4154
|
+
})).min(1).max(20)
|
|
4155
|
+
}),
|
|
4156
|
+
metadata: readOnlyMetadata,
|
|
4157
|
+
execute: async ({ files }, context) => serializeJsonValue2(await (harnessExecutionSession(context)?.workspace ?? workspace).readFiles(files.map(({ path: path8, startLine, endLine }) => ({
|
|
4158
|
+
path: path8,
|
|
4159
|
+
startLine,
|
|
4160
|
+
...endLine !== undefined ? { endLine } : {}
|
|
4161
|
+
}))))
|
|
4162
|
+
}),
|
|
3533
4163
|
search_files: tool({
|
|
3534
4164
|
name: "search_files",
|
|
3535
4165
|
description: "Search for a literal string in text files using a stable cursor.",
|
|
@@ -3547,6 +4177,28 @@ var createWorkspaceTools = (workspace, allowedChecks) => ({
|
|
|
3547
4177
|
...cursor ? { cursor } : {}
|
|
3548
4178
|
}))
|
|
3549
4179
|
}),
|
|
4180
|
+
search_many: tool({
|
|
4181
|
+
name: "search_many",
|
|
4182
|
+
description: "Search up to 10 independent literal queries in one workspace pass, with at most 500 aggregate matches.",
|
|
4183
|
+
schema: z3.object({
|
|
4184
|
+
queries: z3.array(z3.object({
|
|
4185
|
+
query: z3.string().min(1).max(200),
|
|
4186
|
+
caseSensitive: z3.boolean().default(false)
|
|
4187
|
+
})).min(1).max(10),
|
|
4188
|
+
path: z3.string().min(1).default("."),
|
|
4189
|
+
limitPerQuery: z3.number().int().min(1).max(500).default(50)
|
|
4190
|
+
}).superRefine((input, context) => {
|
|
4191
|
+
if (input.queries.length * input.limitPerQuery > 500) {
|
|
4192
|
+
context.addIssue({
|
|
4193
|
+
code: "custom",
|
|
4194
|
+
path: ["limitPerQuery"],
|
|
4195
|
+
message: "search_many allows at most 500 aggregate matches."
|
|
4196
|
+
});
|
|
4197
|
+
}
|
|
4198
|
+
}),
|
|
4199
|
+
metadata: readOnlyMetadata,
|
|
4200
|
+
execute: async ({ queries, path: path8, limitPerQuery }, context) => serializeJsonValue2(await (harnessExecutionSession(context)?.workspace ?? workspace).searchMany(queries, path8, { limitPerQuery }))
|
|
4201
|
+
}),
|
|
3550
4202
|
propose_edits: tool({
|
|
3551
4203
|
name: "propose_edits",
|
|
3552
4204
|
description: "Validate a bounded multi-file edit against current SHA-256 digests and return a deterministic proposalId for operator review. This tool does not write files.",
|
|
@@ -3559,7 +4211,7 @@ var createWorkspaceTools = (workspace, allowedChecks) => ({
|
|
|
3559
4211
|
}),
|
|
3560
4212
|
apply_patch: tool({
|
|
3561
4213
|
name: "apply_patch",
|
|
3562
|
-
description: "
|
|
4214
|
+
description: "Request approval to atomically apply one reviewed multi-file proposal; the runtime pauses before execution. Every existing file requires its exact expected digest; expectedDigest=null is create-only. Call this tool instead of asking for approval in text.",
|
|
3563
4215
|
schema: applyEditProposalInputSchema,
|
|
3564
4216
|
...mutationApproval,
|
|
3565
4217
|
execute: async (input, context) => {
|