@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/dist/zhx.js CHANGED
@@ -4,9 +4,10 @@
4
4
  // src/cli.ts
5
5
  import { createInterface } from "readline/promises";
6
6
  import { randomUUID as randomUUID5 } from "crypto";
7
- import { constants as fsConstants } from "fs";
7
+ import { constants as fsConstants2 } from "fs";
8
8
  import { access, lstat as lstat7, readFile as readFile4, stat as stat4 } from "fs/promises";
9
9
  import path9 from "path";
10
+ import { z as z5 } from "zod";
10
11
  import {
11
12
  getAgentBudgetStatus as getAgentBudgetStatus3
12
13
  } from "@zhivex-ai/agents";
@@ -329,7 +330,7 @@ var BUILTIN_PROVIDER_REGISTRATIONS = Object.freeze([
329
330
  descriptor: {
330
331
  id: "openai",
331
332
  name: "OpenAI",
332
- defaultModel: "gpt-5.4",
333
+ defaultModel: "gpt-5.6-luna",
333
334
  credentialNames: ["OPENAI_API_KEY"],
334
335
  capabilities: HARNESS_PROVIDER_CAPABILITIES,
335
336
  support: "certified"
@@ -371,7 +372,7 @@ var HARNESS_CONFIG_SCHEMA_VERSION = 4;
371
372
  var HARNESS_STORE_BACKENDS = ["sqlite", "file"];
372
373
  var HARNESS_EXECUTION_BACKENDS = ["none", "oci"];
373
374
  var HARNESS_OCI_RUNTIMES = ["docker", "podman"];
374
- var HARNESS_EXECUTION_POLICY_VERSION = "2026-08-17-v1";
375
+ var HARNESS_EXECUTION_POLICY_VERSION = "2026-08-20-v2";
375
376
  var DEFAULT_OCI_EXECUTION = {
376
377
  runtime: "docker",
377
378
  image: "oven/bun:1.3.7-slim",
@@ -869,6 +870,10 @@ var IGNORE_FILES = [".gitignore", ".zhivex-harnessignore"];
869
870
  var DEFAULT_CHECKS = ["test", "typecheck", "lint", "build"];
870
871
  var MAX_FILE_BYTES = 1024 * 1024;
871
872
  var MAX_TOOL_OUTPUT = 20000;
873
+ var MAX_READ_BATCH_FILES = 20;
874
+ var MAX_READ_BATCH_BYTES = 2 * MAX_FILE_BYTES;
875
+ var MAX_SEARCH_MANY_QUERIES = 10;
876
+ var MAX_SEARCH_MANY_MATCHES = 500;
872
877
  var isSensitiveName = (name) => {
873
878
  const normalized = name.toLocaleLowerCase();
874
879
  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");
@@ -890,6 +895,30 @@ var cursorDecode = (value) => {
890
895
  throw new Error("The pagination cursor is invalid.");
891
896
  }
892
897
  };
898
+ var firstPathAtLeast = (files, target) => {
899
+ let low = 0;
900
+ let high = files.length;
901
+ while (low < high) {
902
+ const middle = low + Math.floor((high - low) / 2);
903
+ if ((files[middle]?.path ?? "") < target)
904
+ low = middle + 1;
905
+ else
906
+ high = middle;
907
+ }
908
+ return low;
909
+ };
910
+ var firstPathAfter = (files, target) => {
911
+ let low = 0;
912
+ let high = files.length;
913
+ while (low < high) {
914
+ const middle = low + Math.floor((high - low) / 2);
915
+ if ((files[middle]?.path ?? "") <= target)
916
+ low = middle + 1;
917
+ else
918
+ high = middle;
919
+ }
920
+ return low;
921
+ };
893
922
  var escapeRegex = (value) => value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
894
923
  var globRegex = (pattern) => {
895
924
  let source = "";
@@ -981,6 +1010,11 @@ var spawnBounded = async (command, cwd, timeoutMs = 120000) => {
981
1010
  class Workspace {
982
1011
  root;
983
1012
  auditEntries = [];
1013
+ indexIdentity = randomUUID();
1014
+ indexRevision = 0;
1015
+ workspaceIndex;
1016
+ indexBuild;
1017
+ indexMetrics = { builds: 0, reuses: 0, stableFileReads: 0 };
984
1018
  constructor(root) {
985
1019
  this.root = root;
986
1020
  }
@@ -1032,6 +1066,7 @@ class Workspace {
1032
1066
  return { path: candidate, exists: true };
1033
1067
  }
1034
1068
  async readStableFile(relativePath, allowBinary = true) {
1069
+ this.indexMetrics.stableFileReads += 1;
1035
1070
  const safe = await this.safePath(relativePath, { requireFile: true });
1036
1071
  const before = await lstat(safe.path);
1037
1072
  if (before.size > MAX_FILE_BYTES)
@@ -1051,14 +1086,40 @@ class Workspace {
1051
1086
  mode: before.mode & 511
1052
1087
  };
1053
1088
  }
1054
- async rulesForDirectory(directory, base, inherited) {
1089
+ fingerprint(relativePath, kind, entry, digest2) {
1090
+ return {
1091
+ path: relativePath || ".",
1092
+ kind,
1093
+ dev: entry.dev,
1094
+ ino: entry.ino,
1095
+ size: entry.size,
1096
+ mtimeMs: entry.mtimeMs,
1097
+ ctimeMs: entry.ctimeMs,
1098
+ ...digest2 ? { digest: digest2 } : {}
1099
+ };
1100
+ }
1101
+ sameFingerprint(entry, fingerprint) {
1102
+ return entry.dev === fingerprint.dev && entry.ino === fingerprint.ino && entry.size === fingerprint.size && entry.mtimeMs === fingerprint.mtimeMs && entry.ctimeMs === fingerprint.ctimeMs;
1103
+ }
1104
+ async rulesForDirectory(directory, base, inherited, ignoreFiles) {
1055
1105
  const rules = [...inherited];
1056
1106
  for (const filename of IGNORE_FILES) {
1057
1107
  try {
1058
1108
  const ignorePath = path3.join(directory, filename);
1059
- const entry = await lstat(ignorePath);
1060
- if (entry.isFile() && entry.size <= MAX_FILE_BYTES) {
1061
- rules.push(...parseIgnoreRules(await readFile(ignorePath, "utf8"), base));
1109
+ const before = await lstat(ignorePath);
1110
+ if (before.isSymbolicLink() || !before.isFile())
1111
+ continue;
1112
+ if (before.size <= MAX_FILE_BYTES) {
1113
+ const contents = await readFile(ignorePath);
1114
+ const after = await lstat(ignorePath);
1115
+ if (after.isSymbolicLink() || !after.isFile() || !this.sameFingerprint(after, this.fingerprint("", "ignore-file", before))) {
1116
+ throw new Error(`The ignore file changed while the workspace was being indexed: ${wirePath(path3.relative(this.root, ignorePath))}`);
1117
+ }
1118
+ const digest2 = digestBytes(contents);
1119
+ ignoreFiles.push(this.fingerprint(wirePath(path3.relative(this.root, ignorePath)), "ignore-file", after, digest2));
1120
+ rules.push(...parseIgnoreRules(contents.toString("utf8"), base));
1121
+ } else {
1122
+ ignoreFiles.push(this.fingerprint(wirePath(path3.relative(this.root, ignorePath)), "ignore-file", before));
1062
1123
  }
1063
1124
  } catch (error) {
1064
1125
  if (error.code !== "ENOENT")
@@ -1067,10 +1128,17 @@ class Workspace {
1067
1128
  }
1068
1129
  return rules;
1069
1130
  }
1070
- async collectFiles() {
1131
+ async collectFiles(version) {
1071
1132
  const files = [];
1133
+ const directories = [];
1134
+ const ignoreFiles = [];
1072
1135
  const walk = async (directory, base, inherited) => {
1073
- const rules = await this.rulesForDirectory(directory, base, inherited);
1136
+ const directoryEntry = await lstat(directory);
1137
+ if (directoryEntry.isSymbolicLink() || !directoryEntry.isDirectory()) {
1138
+ throw new Error(`A workspace directory changed while it was being indexed: ${base || "."}`);
1139
+ }
1140
+ directories.push(this.fingerprint(base, "directory", directoryEntry));
1141
+ const rules = await this.rulesForDirectory(directory, base, inherited, ignoreFiles);
1074
1142
  const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
1075
1143
  for (const entry of entries) {
1076
1144
  const relative = base ? `${base}/${entry.name}` : entry.name;
@@ -1080,11 +1148,99 @@ class Workspace {
1080
1148
  if (entry.isDirectory())
1081
1149
  await walk(absolute, relative, rules);
1082
1150
  else if (entry.isFile())
1083
- files.push({ path: relative, size: (await lstat(absolute)).size });
1151
+ files.push({ path: relative });
1084
1152
  }
1085
1153
  };
1086
1154
  await walk(this.root, "", []);
1087
- return files;
1155
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
1156
+ return { version, files, directories, ignoreFiles };
1157
+ }
1158
+ async isWorkspaceIndexFresh(index) {
1159
+ try {
1160
+ for (const fingerprint of index.directories) {
1161
+ const absolute = fingerprint.path === "." ? this.root : path3.join(this.root, fingerprint.path);
1162
+ const entry = await lstat(absolute);
1163
+ if (entry.isSymbolicLink() || !entry.isDirectory() || !this.sameFingerprint(entry, fingerprint))
1164
+ return false;
1165
+ }
1166
+ for (const fingerprint of index.ignoreFiles) {
1167
+ const absolute = path3.join(this.root, fingerprint.path);
1168
+ const before = await lstat(absolute);
1169
+ if (before.isSymbolicLink() || !before.isFile() || !this.sameFingerprint(before, fingerprint))
1170
+ return false;
1171
+ if (fingerprint.digest) {
1172
+ const contents = await readFile(absolute);
1173
+ const after = await lstat(absolute);
1174
+ if (after.isSymbolicLink() || !after.isFile() || !this.sameFingerprint(after, fingerprint) || digestBytes(contents) !== fingerprint.digest)
1175
+ return false;
1176
+ }
1177
+ }
1178
+ return true;
1179
+ } catch {
1180
+ return false;
1181
+ }
1182
+ }
1183
+ invalidateWorkspaceIndex() {
1184
+ this.indexRevision += 1;
1185
+ this.workspaceIndex = undefined;
1186
+ }
1187
+ async buildWorkspaceIndex(revision) {
1188
+ const version = `${this.indexIdentity}:${revision}`;
1189
+ for (let attempt = 0;attempt < 3; attempt += 1) {
1190
+ try {
1191
+ const index = await this.collectFiles(version);
1192
+ if (await this.isWorkspaceIndexFresh(index))
1193
+ return index;
1194
+ } catch (error) {
1195
+ const code = error.code;
1196
+ const changedDuringIndexing = error instanceof Error && error.message.includes("while the workspace was being indexed");
1197
+ if (attempt === 2 || code !== "ENOENT" && !changedDuringIndexing)
1198
+ throw error;
1199
+ }
1200
+ }
1201
+ throw new Error("The workspace kept changing while its file index was being built.");
1202
+ }
1203
+ async getWorkspaceIndex() {
1204
+ const cached = this.workspaceIndex;
1205
+ if (cached) {
1206
+ if (await this.isWorkspaceIndexFresh(cached)) {
1207
+ this.indexMetrics.reuses += 1;
1208
+ return cached;
1209
+ }
1210
+ this.invalidateWorkspaceIndex();
1211
+ }
1212
+ const revision = this.indexRevision;
1213
+ if (!this.indexBuild || this.indexBuild.revision !== revision) {
1214
+ const promise = this.buildWorkspaceIndex(revision);
1215
+ this.indexBuild = { revision, promise };
1216
+ }
1217
+ const build = this.indexBuild;
1218
+ let index;
1219
+ try {
1220
+ index = await build.promise;
1221
+ } catch (error) {
1222
+ if (this.indexBuild === build)
1223
+ this.indexBuild = undefined;
1224
+ throw error;
1225
+ }
1226
+ if (revision !== this.indexRevision)
1227
+ return this.getWorkspaceIndex();
1228
+ if (this.indexBuild === build)
1229
+ this.indexBuild = undefined;
1230
+ if (this.workspaceIndex?.version !== index.version) {
1231
+ this.workspaceIndex = index;
1232
+ this.indexMetrics.builds += 1;
1233
+ }
1234
+ return this.workspaceIndex;
1235
+ }
1236
+ workspaceIndexDiagnostics() {
1237
+ const index = this.workspaceIndex;
1238
+ return {
1239
+ ...this.indexMetrics,
1240
+ ...index ? { version: index.version } : {},
1241
+ files: index?.files.length ?? 0,
1242
+ directories: index?.directories.length ?? 0
1243
+ };
1088
1244
  }
1089
1245
  async listFiles(relativePath = ".", options = {}) {
1090
1246
  const start = await this.safePath(relativePath);
@@ -1096,36 +1252,52 @@ class Workspace {
1096
1252
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5000)
1097
1253
  throw new Error("limit must be between 1 and 5000.");
1098
1254
  let after = "";
1255
+ let cursorIndexVersion;
1099
1256
  if (input.cursor) {
1100
1257
  const parsed = cursorDecode(input.cursor);
1101
- if (parsed.v !== 1 || parsed.kind !== "list" || parsed.path !== requestPath || parsed.limit !== limit || typeof parsed.after !== "string") {
1258
+ if (parsed.v !== 2 || parsed.kind !== "list" || parsed.path !== requestPath || parsed.limit !== limit || typeof parsed.indexVersion !== "string" || typeof parsed.after !== "string") {
1102
1259
  throw new Error("The pagination cursor does not match this list request.");
1103
1260
  }
1104
1261
  after = parsed.after;
1262
+ cursorIndexVersion = parsed.indexVersion;
1263
+ }
1264
+ const index = await this.getWorkspaceIndex();
1265
+ if (cursorIndexVersion && cursorIndexVersion !== index.version) {
1266
+ throw new Error("The pagination cursor is stale because the workspace changed.");
1105
1267
  }
1106
1268
  const prefix = requestPath === "." ? "" : `${requestPath}/`;
1107
- const candidates = (await this.collectFiles()).filter((file) => (requestPath === "." || file.path.startsWith(prefix)) && file.path > after);
1108
- const selected = candidates.slice(0, limit);
1269
+ let candidateIndex = after ? firstPathAfter(index.files, after) : requestPath === "." ? 0 : firstPathAtLeast(index.files, prefix);
1270
+ const selected = [];
1271
+ while (candidateIndex < index.files.length && selected.length <= limit) {
1272
+ const candidate = index.files[candidateIndex];
1273
+ if (!candidate || requestPath !== "." && !candidate.path.startsWith(prefix))
1274
+ break;
1275
+ selected.push(candidate);
1276
+ candidateIndex += 1;
1277
+ }
1278
+ const truncated = selected.length > limit;
1279
+ if (truncated)
1280
+ selected.pop();
1109
1281
  const files = [];
1110
1282
  for (const candidate of selected) {
1111
1283
  const file = await this.readStableFile(candidate.path);
1112
1284
  files.push({ path: file.path, size: file.contents.byteLength, digest: file.digest });
1113
1285
  }
1114
- const truncated = candidates.length > selected.length;
1115
1286
  const last = selected.at(-1);
1116
1287
  return {
1117
1288
  files,
1118
1289
  truncated,
1119
- ...truncated && last ? { nextCursor: cursorEncode({ v: 1, kind: "list", path: requestPath, limit, after: last.path }) } : {}
1290
+ ...truncated && last ? {
1291
+ nextCursor: cursorEncode({ v: 2, kind: "list", path: requestPath, limit, after: last.path, indexVersion: index.version })
1292
+ } : {}
1120
1293
  };
1121
1294
  }
1122
- async readFile(relativePath, startLine = 1, endLine) {
1295
+ renderReadFile(file, startLine = 1, endLine) {
1123
1296
  if (!Number.isSafeInteger(startLine) || startLine < 1)
1124
1297
  throw new Error("startLine must be a positive integer.");
1125
1298
  if (endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine < startLine)) {
1126
1299
  throw new Error("endLine must be greater than or equal to startLine.");
1127
1300
  }
1128
- const file = await this.readStableFile(relativePath, false);
1129
1301
  const lines = file.contents.toString("utf8").split(/\r?\n/);
1130
1302
  const boundedEnd = Math.min(endLine ?? startLine + 399, startLine + 1999, lines.length);
1131
1303
  return {
@@ -1139,6 +1311,37 @@ class Workspace {
1139
1311
  truncated: boundedEnd < lines.length
1140
1312
  };
1141
1313
  }
1314
+ async readFile(relativePath, startLine = 1, endLine) {
1315
+ return this.renderReadFile(await this.readStableFile(relativePath, false), startLine, endLine);
1316
+ }
1317
+ async readFiles(requests) {
1318
+ if (!Array.isArray(requests) || requests.length < 1 || requests.length > MAX_READ_BATCH_FILES) {
1319
+ throw new Error(`readFiles requires between 1 and ${MAX_READ_BATCH_FILES} file requests.`);
1320
+ }
1321
+ const ordered = requests.map((request) => ({
1322
+ ...request,
1323
+ path: wirePath(path3.relative(this.root, this.lexicalPath(request.path)))
1324
+ })).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));
1325
+ const stableFiles = new Map;
1326
+ let totalBytes = 0;
1327
+ for (const request of ordered) {
1328
+ if (!Number.isSafeInteger(request.startLine ?? 1) || (request.startLine ?? 1) < 1 || request.endLine !== undefined && (!Number.isSafeInteger(request.endLine) || request.endLine < (request.startLine ?? 1))) {
1329
+ throw new Error(`Invalid line range for ${request.path}.`);
1330
+ }
1331
+ if (stableFiles.has(request.path))
1332
+ continue;
1333
+ const file = await this.readStableFile(request.path, false);
1334
+ totalBytes += file.contents.byteLength;
1335
+ if (totalBytes > MAX_READ_BATCH_BYTES) {
1336
+ throw new Error(`readFiles exceeds the aggregate ${MAX_READ_BATCH_BYTES}-byte source limit.`);
1337
+ }
1338
+ stableFiles.set(request.path, file);
1339
+ }
1340
+ return {
1341
+ files: ordered.map((request) => this.renderReadFile(stableFiles.get(request.path), request.startLine ?? 1, request.endLine)),
1342
+ sourceBytes: totalBytes
1343
+ };
1344
+ }
1142
1345
  async inspectFile(relativePath) {
1143
1346
  const file = await this.readStableFile(relativePath);
1144
1347
  return {
@@ -1161,21 +1364,29 @@ class Workspace {
1161
1364
  const caseSensitive = options.caseSensitive ?? false;
1162
1365
  let afterPath = "";
1163
1366
  let afterLine = 0;
1367
+ let cursorIndexVersion;
1164
1368
  if (options.cursor) {
1165
1369
  const parsed = cursorDecode(options.cursor);
1166
- if (parsed.v !== 1 || parsed.kind !== "search" || parsed.path !== requestPath || parsed.query !== query || parsed.caseSensitive !== caseSensitive || parsed.limit !== limit || typeof parsed.afterPath !== "string" || !Number.isSafeInteger(parsed.afterLine)) {
1370
+ 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") {
1167
1371
  throw new Error("The pagination cursor does not match this search request.");
1168
1372
  }
1169
1373
  afterPath = parsed.afterPath;
1170
1374
  afterLine = parsed.afterLine;
1375
+ cursorIndexVersion = parsed.indexVersion;
1376
+ }
1377
+ const index = await this.getWorkspaceIndex();
1378
+ if (cursorIndexVersion && cursorIndexVersion !== index.version) {
1379
+ throw new Error("The pagination cursor is stale because the workspace changed.");
1171
1380
  }
1172
1381
  const prefix = requestPath === "." ? "" : `${requestPath}/`;
1173
1382
  const needle = caseSensitive ? query : query.toLocaleLowerCase();
1174
1383
  const matches = [];
1175
1384
  let hasMore = false;
1176
- for (const candidate of await this.collectFiles()) {
1177
- if (requestPath !== "." && !candidate.path.startsWith(prefix) || candidate.size > MAX_FILE_BYTES || candidate.path < afterPath)
1178
- continue;
1385
+ let candidateIndex = afterPath ? firstPathAtLeast(index.files, afterPath) : requestPath === "." ? 0 : firstPathAtLeast(index.files, prefix);
1386
+ for (;candidateIndex < index.files.length; candidateIndex += 1) {
1387
+ const candidate = index.files[candidateIndex];
1388
+ if (!candidate || requestPath !== "." && !candidate.path.startsWith(prefix))
1389
+ break;
1179
1390
  let file;
1180
1391
  try {
1181
1392
  file = await this.readStableFile(candidate.path, false);
@@ -1183,11 +1394,11 @@ class Workspace {
1183
1394
  continue;
1184
1395
  }
1185
1396
  const lines = file.contents.toString("utf8").split(/\r?\n/);
1186
- for (let index = 0;index < lines.length; index += 1) {
1187
- const lineNumber = index + 1;
1397
+ for (let index2 = 0;index2 < lines.length; index2 += 1) {
1398
+ const lineNumber = index2 + 1;
1188
1399
  if (candidate.path === afterPath && lineNumber <= afterLine)
1189
1400
  continue;
1190
- const line = lines[index] ?? "";
1401
+ const line = lines[index2] ?? "";
1191
1402
  if (!(caseSensitive ? line : line.toLocaleLowerCase()).includes(needle))
1192
1403
  continue;
1193
1404
  if (matches.length >= limit) {
@@ -1204,10 +1415,88 @@ class Workspace {
1204
1415
  matches,
1205
1416
  truncated: hasMore,
1206
1417
  ...hasMore && last ? {
1207
- nextCursor: cursorEncode({ v: 1, kind: "search", path: requestPath, query, caseSensitive, limit, afterPath: last.path, afterLine: last.line })
1418
+ nextCursor: cursorEncode({
1419
+ v: 2,
1420
+ kind: "search",
1421
+ path: requestPath,
1422
+ query,
1423
+ caseSensitive,
1424
+ limit,
1425
+ afterPath: last.path,
1426
+ afterLine: last.line,
1427
+ indexVersion: index.version
1428
+ })
1208
1429
  } : {}
1209
1430
  };
1210
1431
  }
1432
+ async searchMany(queries, relativePath = ".", options = {}) {
1433
+ if (!Array.isArray(queries) || queries.length < 1 || queries.length > MAX_SEARCH_MANY_QUERIES) {
1434
+ throw new Error(`searchMany requires between 1 and ${MAX_SEARCH_MANY_QUERIES} queries.`);
1435
+ }
1436
+ const limitPerQuery = options.limitPerQuery ?? 50;
1437
+ if (!Number.isSafeInteger(limitPerQuery) || limitPerQuery < 1 || limitPerQuery > MAX_SEARCH_MANY_MATCHES || limitPerQuery * queries.length > MAX_SEARCH_MANY_MATCHES) {
1438
+ throw new Error(`searchMany allows at most ${MAX_SEARCH_MANY_MATCHES} aggregate matches.`);
1439
+ }
1440
+ const seen = new Set;
1441
+ const states = queries.map((input) => {
1442
+ if (!input.query || input.query.length > 200)
1443
+ throw new Error("Each search query must be between 1 and 200 characters.");
1444
+ const caseSensitive = input.caseSensitive ?? false;
1445
+ const key = `${caseSensitive ? "1" : "0"}\x00${input.query}`;
1446
+ if (seen.has(key))
1447
+ throw new Error("searchMany queries must be unique.");
1448
+ seen.add(key);
1449
+ return {
1450
+ query: input.query,
1451
+ caseSensitive,
1452
+ needle: caseSensitive ? input.query : input.query.toLocaleLowerCase(),
1453
+ matches: [],
1454
+ truncated: false
1455
+ };
1456
+ });
1457
+ const start = await this.safePath(relativePath);
1458
+ if (!(await lstat(start.path)).isDirectory())
1459
+ throw new Error("searchMany requires a directory.");
1460
+ const requestPath = wirePath(path3.relative(this.root, start.path)) || ".";
1461
+ const prefix = requestPath === "." ? "" : `${requestPath}/`;
1462
+ const index = await this.getWorkspaceIndex();
1463
+ let candidateIndex = requestPath === "." ? 0 : firstPathAtLeast(index.files, prefix);
1464
+ for (;candidateIndex < index.files.length; candidateIndex += 1) {
1465
+ const candidate = index.files[candidateIndex];
1466
+ if (!candidate || requestPath !== "." && !candidate.path.startsWith(prefix))
1467
+ break;
1468
+ let file;
1469
+ try {
1470
+ file = await this.readStableFile(candidate.path, false);
1471
+ } catch {
1472
+ continue;
1473
+ }
1474
+ const lines = file.contents.toString("utf8").split(/\r?\n/);
1475
+ for (let lineIndex = 0;lineIndex < lines.length; lineIndex += 1) {
1476
+ const line = lines[lineIndex] ?? "";
1477
+ const foldedLine = line.toLocaleLowerCase();
1478
+ for (const state of states) {
1479
+ if (state.truncated || !(state.caseSensitive ? line : foldedLine).includes(state.needle))
1480
+ continue;
1481
+ if (state.matches.length >= limitPerQuery) {
1482
+ state.truncated = true;
1483
+ continue;
1484
+ }
1485
+ state.matches.push({
1486
+ path: candidate.path,
1487
+ line: lineIndex + 1,
1488
+ text: truncate(line, 500),
1489
+ digest: file.digest
1490
+ });
1491
+ }
1492
+ }
1493
+ if (states.every((state) => state.truncated))
1494
+ break;
1495
+ }
1496
+ return {
1497
+ results: states.map(({ needle: _needle, ...state }) => state)
1498
+ };
1499
+ }
1211
1500
  audit(entry) {
1212
1501
  const record = { id: randomUUID(), timestamp: new Date().toISOString(), ...entry };
1213
1502
  this.auditEntries.push(record);
@@ -1371,6 +1660,7 @@ class Workspace {
1371
1660
  throw new AggregateError([error, ...rollbackErrors], "Patch failed and rollback was incomplete.");
1372
1661
  throw error;
1373
1662
  }
1663
+ this.invalidateWorkspaceIndex();
1374
1664
  const changes = prepared.map((item) => this.audit({
1375
1665
  operation: item.before ? "update" : "create",
1376
1666
  path: item.change.path,
@@ -1431,6 +1721,7 @@ class Workspace {
1431
1721
  await unlink(destination.path).catch(() => {});
1432
1722
  throw error;
1433
1723
  }
1724
+ this.invalidateWorkspaceIndex();
1434
1725
  const audit = this.audit({ operation: "move", path: parsed.source, destination: parsed.destination, beforeDigest: source.digest, afterDigest: source.digest });
1435
1726
  return { source: parsed.source, destination: parsed.destination, digest: source.digest, audit };
1436
1727
  }
@@ -1519,6 +1810,7 @@ class Workspace {
1519
1810
  await unlink(path3.join(directory, `${quarantineId}.json`)).catch(() => {});
1520
1811
  throw error;
1521
1812
  }
1813
+ this.invalidateWorkspaceIndex();
1522
1814
  const audit = this.audit({ operation: "quarantine", path: parsed.path, beforeDigest: source.digest, quarantineId });
1523
1815
  return { quarantineId, path: parsed.path, digest: source.digest, audit };
1524
1816
  }
@@ -1587,6 +1879,7 @@ class Workspace {
1587
1879
  }
1588
1880
  throw rollbackErrors.length ? new AggregateError([error, ...rollbackErrors], "Restore failed and rollback was incomplete.") : error;
1589
1881
  }
1882
+ this.invalidateWorkspaceIndex();
1590
1883
  const audit = this.audit({ operation: "restore", path: destinationPath, afterDigest: digest2, quarantineId: parsed.quarantineId });
1591
1884
  return { quarantineId: parsed.quarantineId, path: destinationPath, digest: digest2, audit };
1592
1885
  }
@@ -1607,7 +1900,11 @@ class Workspace {
1607
1900
  throw new Error(`package.json does not define the "${check}" script.`);
1608
1901
  if (scripts[check] !== expectedScript)
1609
1902
  throw new Error(`The "${check}" script changed or does not match expectedScript.`);
1610
- return spawnBounded(["bun", "--no-env-file", "run", check], this.root);
1903
+ try {
1904
+ return await spawnBounded(["bun", "--no-env-file", "run", check], this.root);
1905
+ } finally {
1906
+ this.invalidateWorkspaceIndex();
1907
+ }
1611
1908
  }
1612
1909
  async gitDiff() {
1613
1910
  const [statusResult, diffResult, stagedResult] = await Promise.all([
@@ -2389,7 +2686,7 @@ var HARNESS_SUBAGENT_PROFILE_DESCRIPTORS = [
2389
2686
  toolName: "delegate_explorer",
2390
2687
  description: "Delegate bounded repository discovery to a read-only explorer.",
2391
2688
  instructions: "Inspect the repository with read-only tools. Return evidence with paths and concrete findings. Never propose or perform mutations.",
2392
- toolNames: ["list_files", "read_file", "search_files", "git_diff"]
2689
+ toolNames: ["list_files", "read_file", "read_files", "search_files", "search_many", "git_diff"]
2393
2690
  },
2394
2691
  {
2395
2692
  id: "implementer",
@@ -2403,14 +2700,14 @@ var HARNESS_SUBAGENT_PROFILE_DESCRIPTORS = [
2403
2700
  toolName: "delegate_tester",
2404
2701
  description: "Delegate focused verification to a tester with read-only inspection and approved Bun checks.",
2405
2702
  instructions: "Verify the delegated behavior with repository evidence and declared Bun checks. Do not edit files. Report exact failures and limits.",
2406
- toolNames: ["list_files", "read_file", "search_files", "run_check", "git_diff"]
2703
+ toolNames: ["list_files", "read_file", "read_files", "search_files", "search_many", "run_check", "git_diff"]
2407
2704
  },
2408
2705
  {
2409
2706
  id: "reviewer",
2410
2707
  toolName: "delegate_reviewer",
2411
2708
  description: "Delegate an independent read-only review of correctness, safety, and regressions.",
2412
2709
  instructions: "Review the requested scope independently. Inspect concrete code and diffs, prioritize actionable defects, and do not mutate the workspace.",
2413
- toolNames: ["list_files", "read_file", "search_files", "mutation_audit", "git_diff"]
2710
+ toolNames: ["list_files", "read_file", "read_files", "search_files", "search_many", "mutation_audit", "git_diff"]
2414
2711
  }
2415
2712
  ];
2416
2713
  var pickTools = (tools, names) => {
@@ -2548,8 +2845,10 @@ var runHarnessReviewGroup = async (runtime, input, profiles = ["explorer", "revi
2548
2845
 
2549
2846
  // src/execution-environment.ts
2550
2847
  import { createHash as createHash6, randomUUID as randomUUID3 } from "crypto";
2848
+ import { constants as fsConstants } from "fs";
2551
2849
  import {
2552
2850
  chmod as chmod2,
2851
+ copyFile,
2553
2852
  lstat as lstat5,
2554
2853
  mkdir as mkdir3,
2555
2854
  readFile as readFile3,
@@ -2573,7 +2872,9 @@ var TERMINAL_STATUSES = new Set([
2573
2872
  var BUILT_IN_TOOL_NAMES = new Set([
2574
2873
  "list_files",
2575
2874
  "read_file",
2875
+ "read_files",
2576
2876
  "search_files",
2877
+ "search_many",
2577
2878
  "propose_edits",
2578
2879
  "apply_patch",
2579
2880
  "move_file",
@@ -2698,11 +2999,26 @@ var parseRuntimeVersion = (stdout, runtime) => {
2698
2999
  var validContainerIds = (stdout) => stdout.split(/\r?\n/).map((value) => value.trim()).filter((value) => /^[a-f0-9]{12,64}$/.test(value));
2699
3000
  var validVolumeNames = (stdout) => stdout.split(/\r?\n/).map((value) => value.trim()).filter((value) => /^zhivex-harness-[A-Za-z0-9_.-]+$/.test(value));
2700
3001
  var MAX_SYNC_ENTRIES = 1e4;
3002
+ var updateSnapshotMetadata = (target, kind, relative, entry) => {
3003
+ target.update([
3004
+ kind,
3005
+ relative,
3006
+ entry.dev,
3007
+ entry.ino,
3008
+ entry.size,
3009
+ entry.mtimeMs,
3010
+ entry.ctimeMs,
3011
+ entry.mode & 511
3012
+ ].join("\x00"));
3013
+ target.update("\x00");
3014
+ };
2701
3015
  var validateSynchronizedSnapshot = async (stagedRoot, previousRoot, maxWorkspaceBytes, maxFileWriteBytes) => {
2702
3016
  let entries = 0;
2703
3017
  let totalBytes = 0;
3018
+ const seal = createHash6("sha256");
3019
+ const metadata = createHash6("sha256");
2704
3020
  const visit = async (directory, relativeDirectory = "") => {
2705
- for (const name of await readdir2(directory)) {
3021
+ for (const name of (await readdir2(directory)).sort()) {
2706
3022
  const relative = relativeDirectory ? `${relativeDirectory}/${name}` : name;
2707
3023
  const absolute = path7.join(directory, name);
2708
3024
  if (!isInside3(stagedRoot, absolute) || relative.length > 1024) {
@@ -2721,22 +3037,32 @@ var validateSynchronizedSnapshot = async (stagedRoot, previousRoot, maxWorkspace
2721
3037
  throw new Error(`OCI workspace export contains a symbolic link: ${relative}.`);
2722
3038
  }
2723
3039
  if (entry.isDirectory()) {
3040
+ updateSnapshotMetadata(metadata, "directory", relative, entry);
3041
+ seal.update(`directory\x00${relative}\x00${entry.mode & 511}\x00`);
2724
3042
  await visit(absolute, relative);
2725
3043
  continue;
2726
3044
  }
2727
3045
  if (!entry.isFile() || entry.nlink !== 1) {
2728
3046
  throw new Error(`OCI workspace export contains a special or linked file: ${relative}.`);
2729
3047
  }
3048
+ const contents = await readFile3(absolute);
3049
+ const after = await lstat5(absolute);
3050
+ 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)) {
3051
+ throw new Error(`OCI workspace export changed while it was being validated: ${relative}.`);
3052
+ }
2730
3053
  totalBytes += entry.size;
2731
3054
  if (totalBytes > maxWorkspaceBytes) {
2732
3055
  throw new Error(`OCI workspace export exceeds the ${maxWorkspaceBytes}-byte workspace limit.`);
2733
3056
  }
3057
+ const contentDigest = digest2(contents);
3058
+ updateSnapshotMetadata(metadata, "file", relative, after);
3059
+ seal.update(`file\x00${relative}\x00${entry.mode & 511}\x00${entry.size}\x00${contentDigest}\x00`);
2734
3060
  if (entry.size > maxFileWriteBytes) {
2735
3061
  const previous = path7.join(previousRoot, ...relative.split("/"));
2736
3062
  let unchanged = false;
2737
3063
  try {
2738
3064
  const previousEntry = await lstat5(previous);
2739
- unchanged = previousEntry.isFile() && !previousEntry.isSymbolicLink() && previousEntry.size === entry.size && digest2(await readFile3(previous)) === digest2(await readFile3(absolute));
3065
+ unchanged = previousEntry.isFile() && !previousEntry.isSymbolicLink() && previousEntry.size === entry.size && digest2(await readFile3(previous)) === contentDigest;
2740
3066
  } catch (error) {
2741
3067
  if (error.code !== "ENOENT")
2742
3068
  throw error;
@@ -2748,6 +3074,51 @@ var validateSynchronizedSnapshot = async (stagedRoot, previousRoot, maxWorkspace
2748
3074
  }
2749
3075
  };
2750
3076
  await visit(stagedRoot);
3077
+ return {
3078
+ entries,
3079
+ totalBytes,
3080
+ seal: `sha256:${seal.digest("hex")}`,
3081
+ metadataFingerprint: `sha256:${metadata.digest("hex")}`
3082
+ };
3083
+ };
3084
+ var snapshotMetadataFingerprint = async (root, maxWorkspaceBytes) => {
3085
+ let entries = 0;
3086
+ let totalBytes = 0;
3087
+ const metadata = createHash6("sha256");
3088
+ const visit = async (directory, relativeDirectory = "") => {
3089
+ for (const name of (await readdir2(directory)).sort()) {
3090
+ const relative = relativeDirectory ? `${relativeDirectory}/${name}` : name;
3091
+ if (relative === "node_modules")
3092
+ continue;
3093
+ const absolute = path7.join(directory, name);
3094
+ if (!isInside3(root, absolute) || relative.length > 1024) {
3095
+ throw new Error(`OCI workspace snapshot contains an invalid path: ${relative}.`);
3096
+ }
3097
+ entries += 1;
3098
+ if (entries > MAX_SYNC_ENTRIES) {
3099
+ throw new Error(`OCI workspace snapshot exceeds the ${MAX_SYNC_ENTRIES}-entry limit.`);
3100
+ }
3101
+ const entry = await lstat5(absolute);
3102
+ if (entry.isSymbolicLink()) {
3103
+ throw new Error(`OCI workspace snapshot contains a symbolic link: ${relative}.`);
3104
+ }
3105
+ if (entry.isDirectory()) {
3106
+ updateSnapshotMetadata(metadata, "directory", relative, entry);
3107
+ await visit(absolute, relative);
3108
+ continue;
3109
+ }
3110
+ if (!entry.isFile() || entry.nlink !== 1) {
3111
+ throw new Error(`OCI workspace snapshot contains a special or linked file: ${relative}.`);
3112
+ }
3113
+ totalBytes += entry.size;
3114
+ if (totalBytes > maxWorkspaceBytes) {
3115
+ throw new Error(`OCI workspace snapshot exceeds the ${maxWorkspaceBytes}-byte workspace limit.`);
3116
+ }
3117
+ updateSnapshotMetadata(metadata, "file", relative, entry);
3118
+ }
3119
+ };
3120
+ await visit(root);
3121
+ return `sha256:${metadata.digest("hex")}`;
2751
3122
  };
2752
3123
  var replaceSnapshot = async (snapshotRoot, stagedRoot) => {
2753
3124
  const backupRoot = `${snapshotRoot}.${randomUUID3()}.previous`;
@@ -2762,10 +3133,42 @@ var replaceSnapshot = async (snapshotRoot, stagedRoot) => {
2762
3133
  }
2763
3134
  await rm(backupRoot, { recursive: true, force: true });
2764
3135
  };
3136
+ var OCI_WORKSPACE_SEAL_SCRIPT = [
3137
+ "import { createHash } from 'node:crypto';",
3138
+ "import { lstat, readFile, readdir, readlink, rm } from 'node:fs/promises';",
3139
+ "import path from 'node:path';",
3140
+ "const root='/workspace';",
3141
+ "const seal=createHash('sha256');",
3142
+ "const modules=path.join(root,'node_modules');",
3143
+ "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});}",
3144
+ "const digest=(value)=>`sha256:${createHash('sha256').update(value).digest('hex')}`;",
3145
+ "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);",
3146
+ "const visit=async(directory,base='')=>{",
3147
+ "for(const name of (await readdir(directory)).sort()){",
3148
+ "const relative=base?`${base}/${name}`:name;",
3149
+ "if(relative==='node_modules')continue;",
3150
+ "const absolute=path.join(directory,name);",
3151
+ "const entry=await lstat(absolute);",
3152
+ "if(entry.isSymbolicLink())throw new Error(`symbolic link: ${relative}`);",
3153
+ "if(entry.isDirectory()){seal.update(`directory\\0${relative}\\0${entry.mode&0o777}\\0`);await visit(absolute,relative);continue;}",
3154
+ "if(!entry.isFile()||entry.nlink!==1)throw new Error(`special or linked file: ${relative}`);",
3155
+ "const contents=await readFile(absolute);",
3156
+ "const after=await lstat(absolute);",
3157
+ "if(!same(entry,after))throw new Error(`changed while sealing: ${relative}`);",
3158
+ "seal.update(`file\\0${relative}\\0${entry.mode&0o777}\\0${entry.size}\\0${digest(contents)}\\0`);",
3159
+ "}",
3160
+ "};",
3161
+ "await visit(root);",
3162
+ "for(const name of await readdir('/tmp'))await rm(path.join('/tmp',name),{recursive:true,force:true});",
3163
+ "console.log(`sha256:${seal.digest('hex')}`);"
3164
+ ].join("");
3165
+ var OCI_SESSION_CONTROLLER_SCRIPT = "setInterval(()=>{},2147483647);";
2765
3166
 
2766
3167
  class CliOciRuntimeAdapter {
2767
3168
  runtime;
2768
3169
  hostEnvironment;
3170
+ sessions = new Map;
3171
+ runQueues = new Map;
2769
3172
  constructor(runtime) {
2770
3173
  this.runtime = runtime;
2771
3174
  this.hostEnvironment = Object.fromEntries(["DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_HOST", "HOME", "PATH", "XDG_RUNTIME_DIR"].flatMap((name) => process.env[name] ? [[name, process.env[name]]] : []));
@@ -2818,10 +3221,44 @@ ${result.stdout}`)) {
2818
3221
  throw new Error(`${this.runtime} could not remove OCI workspace volume ${name}: ${result.stderr.trim()}`);
2819
3222
  }
2820
3223
  }
2821
- async run(request) {
3224
+ requestFingerprint(request) {
3225
+ return digest2(JSON.stringify({
3226
+ runId: request.runId,
3227
+ snapshotRoot: request.snapshotRoot,
3228
+ dependencyRoot: request.dependencyRoot ?? null,
3229
+ imageId: request.imageId,
3230
+ limits: request.limits
3231
+ }));
3232
+ }
3233
+ async withRunLock(runId, operation) {
3234
+ const key = runHash(runId);
3235
+ const previous = this.runQueues.get(key) ?? Promise.resolve();
3236
+ let release;
3237
+ const gate = new Promise((resolve) => {
3238
+ release = resolve;
3239
+ });
3240
+ const tail = previous.then(() => gate, () => gate);
3241
+ this.runQueues.set(key, tail);
3242
+ await previous.catch(() => {
3243
+ return;
3244
+ });
3245
+ try {
3246
+ return await operation();
3247
+ } finally {
3248
+ release();
3249
+ if (this.runQueues.get(key) === tail)
3250
+ this.runQueues.delete(key);
3251
+ }
3252
+ }
3253
+ async destroySession(session) {
3254
+ if (this.sessions.get(session.key) === session)
3255
+ this.sessions.delete(session.key);
3256
+ await this.forceRemove(session.name);
3257
+ await this.removeVolume(session.volumeName);
3258
+ }
3259
+ async createSession(request, requestFingerprint, snapshotSeal, hostFingerprint) {
2822
3260
  const resourceName = `zhivex-harness-${runHash(request.runId)}-${randomUUID3().slice(0, 8)}`;
2823
3261
  const name = `${resourceName}-job`;
2824
- const exportName = `${resourceName}-export`;
2825
3262
  const volumeName = `${resourceName}-workspace`;
2826
3263
  const uid = process.getuid?.() ?? 65532;
2827
3264
  const gid = process.getgid?.() ?? 65532;
@@ -2877,8 +3314,6 @@ ${result.stdout}`)) {
2877
3314
  "BUN_INSTALL_CACHE_DIR=/tmp/bun-cache",
2878
3315
  "--env",
2879
3316
  "CI=1",
2880
- "--volume",
2881
- `${request.snapshotRoot}:/seed:ro`,
2882
3317
  ...request.dependencyRoot ? ["--volume", `${request.dependencyRoot}:/dependencies:ro`] : [],
2883
3318
  "--workdir",
2884
3319
  "/workspace",
@@ -2886,26 +3321,19 @@ ${result.stdout}`)) {
2886
3321
  "bun",
2887
3322
  request.imageId,
2888
3323
  "-e",
2889
- `await Bun.sleep(${request.limits.maxProcessRuntimeMs + 60000})`
3324
+ OCI_SESSION_CONTROLLER_SCRIPT
2890
3325
  ];
2891
- let forcedRemoval;
2892
- const forceRemove = () => {
2893
- forcedRemoval ??= this.forceRemove(name);
2894
- };
3326
+ let volumeCreated = false;
2895
3327
  try {
2896
3328
  await this.cli(volumeArgs);
3329
+ volumeCreated = true;
2897
3330
  await this.cli(args);
2898
3331
  await this.cli(["start", name]);
2899
3332
  await this.cli([
2900
- "exec",
2901
- "--workdir",
2902
- "/workspace",
2903
- "--user",
2904
- `${uid}:${gid}`,
2905
- name,
2906
- "bun",
2907
- "-e",
2908
- "import { cp } from 'node:fs/promises'; await cp('/seed', '/workspace', { recursive: true, force: false, errorOnExist: true })"
3333
+ "cp",
3334
+ "-a",
3335
+ `${request.snapshotRoot}${path7.sep}.`,
3336
+ `${name}:/workspace`
2909
3337
  ], 60000);
2910
3338
  if (request.dependencyRoot) {
2911
3339
  await this.cli([
@@ -2920,88 +3348,183 @@ ${result.stdout}`)) {
2920
3348
  "import { symlink } from 'node:fs/promises'; await symlink('/dependencies', '/workspace/node_modules', 'dir')"
2921
3349
  ]);
2922
3350
  }
2923
- const result = await spawnBounded2([
2924
- this.runtime,
3351
+ await this.cli(["pause", name]);
3352
+ const session = {
3353
+ key: runHash(request.runId),
3354
+ requestFingerprint,
3355
+ name,
3356
+ volumeName,
3357
+ snapshotSeal,
3358
+ hostFingerprint,
3359
+ paused: true
3360
+ };
3361
+ this.sessions.set(session.key, session);
3362
+ return session;
3363
+ } catch (error) {
3364
+ await this.forceRemove(name);
3365
+ if (volumeCreated)
3366
+ await this.removeVolume(volumeName).catch(() => {
3367
+ return;
3368
+ });
3369
+ throw error;
3370
+ }
3371
+ }
3372
+ async onlyControllerProcessRemains(session) {
3373
+ const result = await this.cli(this.runtime === "podman" ? ["top", session.name, "pid"] : ["top", session.name, "-eo", "pid"]);
3374
+ const processIds = result.stdout.split(/\r?\n/).filter((line) => /^\s*\d+\s*$/.test(line));
3375
+ return processIds.length === 1;
3376
+ }
3377
+ async containerWorkspaceSeal(session, uid, gid, hasDependencies) {
3378
+ if (session.paused) {
3379
+ await this.cli(["unpause", session.name]);
3380
+ session.paused = false;
3381
+ }
3382
+ try {
3383
+ const result = await this.cli([
2925
3384
  "exec",
2926
3385
  "--workdir",
2927
3386
  "/workspace",
2928
3387
  "--user",
2929
3388
  `${uid}:${gid}`,
2930
- name,
2931
- ...request.command
2932
- ], {
2933
- env: this.hostEnvironment,
2934
- timeoutMs: request.limits.maxProcessRuntimeMs,
2935
- maxOutputBytes: request.limits.maxProcessOutputBytes,
2936
- ...request.abortSignal ? { abortSignal: request.abortSignal } : {},
2937
- onStop: forceRemove
2938
- });
2939
- if (result.timedOut || result.cancelled || result.outputLimitExceeded) {
2940
- forceRemove();
2941
- await forcedRemoval;
2942
- } else if (result.exitCode === 0) {
2943
- await this.cli(["pause", name]);
2944
- const stagedRoot = path7.join(path7.dirname(request.snapshotRoot), `.workspace-sync-${randomUUID3()}`);
2945
- try {
2946
- await privateDirectory(stagedRoot);
2947
- try {
2948
- await this.cli([
2949
- "run",
2950
- "--rm",
2951
- "--name",
2952
- exportName,
2953
- "--label",
2954
- `${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`,
2955
- "--label",
2956
- `com.zhivex.harness.run=${runHash(request.runId)}`,
2957
- "--network",
2958
- "none",
2959
- "--read-only",
2960
- "--cap-drop",
2961
- "ALL",
2962
- "--security-opt",
2963
- "no-new-privileges",
2964
- "--pids-limit",
2965
- "32",
2966
- "--memory",
2967
- "256m",
2968
- "--cpus",
2969
- "1",
2970
- "--tmpfs",
2971
- "/tmp:rw,noexec,nosuid,nodev,size=16m",
2972
- "--user",
2973
- `${uid}:${gid}`,
2974
- "--volume",
2975
- `${volumeName}:/workspace:ro`,
2976
- "--volume",
2977
- `${stagedRoot}:/export:rw`,
2978
- "--workdir",
2979
- "/workspace",
2980
- "--entrypoint",
2981
- "bun",
2982
- request.imageId,
2983
- "-e",
2984
- "import { cp } from 'node:fs/promises'; await cp('/workspace', '/export', { recursive: true, force: false, errorOnExist: true })"
2985
- ], 60000);
2986
- } finally {
2987
- await this.forceRemove(exportName);
2988
- }
2989
- await validateSynchronizedSnapshot(stagedRoot, request.snapshotRoot, request.limits.maxWorkspaceBytes, request.limits.maxFileWriteBytes);
2990
- await replaceSnapshot(request.snapshotRoot, stagedRoot);
2991
- } finally {
2992
- await rm(stagedRoot, { recursive: true, force: true });
2993
- }
3389
+ "--env",
3390
+ `ZHIVEX_HARNESS_HAS_DEPENDENCIES=${hasDependencies ? "1" : "0"}`,
3391
+ session.name,
3392
+ "bun",
3393
+ "-e",
3394
+ OCI_WORKSPACE_SEAL_SCRIPT
3395
+ ], 60000, 20000);
3396
+ const seal = result.stdout.trim();
3397
+ if (!/^sha256:[a-f0-9]{64}$/.test(seal)) {
3398
+ throw new Error("OCI workspace seal helper returned an invalid digest.");
2994
3399
  }
2995
- return {
2996
- ...result,
2997
- command: request.command
2998
- };
3400
+ return seal;
2999
3401
  } finally {
3402
+ if (!session.paused) {
3403
+ await this.cli(["pause", session.name]);
3404
+ session.paused = true;
3405
+ }
3406
+ }
3407
+ }
3408
+ async runInSession(request, session, sessionReused) {
3409
+ const uid = process.getuid?.() ?? 65532;
3410
+ const gid = process.getgid?.() ?? 65532;
3411
+ if (session.paused) {
3412
+ await this.cli(["unpause", session.name]);
3413
+ session.paused = false;
3414
+ }
3415
+ let forcedRemoval;
3416
+ const forceRemove = () => {
3417
+ forcedRemoval ??= this.forceRemove(session.name);
3418
+ };
3419
+ const result = await spawnBounded2([
3420
+ this.runtime,
3421
+ "exec",
3422
+ "--workdir",
3423
+ "/workspace",
3424
+ "--user",
3425
+ `${uid}:${gid}`,
3426
+ session.name,
3427
+ ...request.command
3428
+ ], {
3429
+ env: this.hostEnvironment,
3430
+ timeoutMs: request.limits.maxProcessRuntimeMs,
3431
+ maxOutputBytes: request.limits.maxProcessOutputBytes,
3432
+ ...request.abortSignal ? { abortSignal: request.abortSignal } : {},
3433
+ onStop: forceRemove
3434
+ });
3435
+ if (result.exitCode !== 0 || result.timedOut || result.cancelled || result.outputLimitExceeded) {
3000
3436
  forceRemove();
3001
3437
  await forcedRemoval;
3002
- await this.removeVolume(volumeName);
3438
+ await this.destroySession(session);
3439
+ return {
3440
+ ...result,
3441
+ command: request.command,
3442
+ sessionReused,
3443
+ workspacePublished: false,
3444
+ workspaceExported: false
3445
+ };
3446
+ }
3447
+ try {
3448
+ if (!await this.onlyControllerProcessRemains(session)) {
3449
+ await this.destroySession(session);
3450
+ return {
3451
+ ...result,
3452
+ command: request.command,
3453
+ exitCode: 126,
3454
+ stderr: [
3455
+ result.stderr,
3456
+ "OCI command left background processes running; its workspace changes were discarded."
3457
+ ].filter(Boolean).join(`
3458
+ `),
3459
+ sessionReused,
3460
+ workspacePublished: false,
3461
+ workspaceExported: false
3462
+ };
3463
+ }
3464
+ const workspaceSeal = await this.containerWorkspaceSeal(session, uid, gid, Boolean(request.dependencyRoot));
3465
+ if (workspaceSeal === session.snapshotSeal) {
3466
+ return {
3467
+ ...result,
3468
+ command: request.command,
3469
+ sessionReused,
3470
+ workspacePublished: true,
3471
+ workspaceExported: false
3472
+ };
3473
+ }
3474
+ const stagedRoot = path7.join(path7.dirname(request.snapshotRoot), `.workspace-sync-${randomUUID3()}`);
3475
+ try {
3476
+ await privateDirectory(stagedRoot);
3477
+ await this.cli(["cp", `${session.name}:/workspace/.`, stagedRoot], 60000);
3478
+ const validated = await validateSynchronizedSnapshot(stagedRoot, request.snapshotRoot, request.limits.maxWorkspaceBytes, request.limits.maxFileWriteBytes);
3479
+ await replaceSnapshot(request.snapshotRoot, stagedRoot);
3480
+ session.snapshotSeal = validated.seal;
3481
+ session.hostFingerprint = validated.metadataFingerprint;
3482
+ } finally {
3483
+ await rm(stagedRoot, { recursive: true, force: true });
3484
+ }
3485
+ return {
3486
+ ...result,
3487
+ command: request.command,
3488
+ sessionReused,
3489
+ workspacePublished: true,
3490
+ workspaceExported: true
3491
+ };
3492
+ } catch (error) {
3493
+ await this.destroySession(session);
3494
+ throw error;
3003
3495
  }
3004
3496
  }
3497
+ async runLocked(request) {
3498
+ const key = runHash(request.runId);
3499
+ const requestFingerprint = this.requestFingerprint(request);
3500
+ let session = this.sessions.get(key);
3501
+ let sessionReused = Boolean(session);
3502
+ let validated;
3503
+ if (session) {
3504
+ const hostFingerprint = await snapshotMetadataFingerprint(request.snapshotRoot, request.limits.maxWorkspaceBytes);
3505
+ if (session.requestFingerprint !== requestFingerprint || session.hostFingerprint !== hostFingerprint) {
3506
+ await this.destroySession(session);
3507
+ session = undefined;
3508
+ sessionReused = false;
3509
+ }
3510
+ }
3511
+ if (!session) {
3512
+ validated = await validateSynchronizedSnapshot(request.snapshotRoot, request.snapshotRoot, request.limits.maxWorkspaceBytes, request.limits.maxFileWriteBytes);
3513
+ session = await this.createSession(request, requestFingerprint, validated.seal, validated.metadataFingerprint);
3514
+ }
3515
+ try {
3516
+ return await this.runInSession(request, session, sessionReused);
3517
+ } catch (error) {
3518
+ if (this.sessions.get(key) === session)
3519
+ await this.destroySession(session).catch(() => {
3520
+ return;
3521
+ });
3522
+ throw error;
3523
+ }
3524
+ }
3525
+ async run(request) {
3526
+ return this.withRunLock(request.runId, () => this.runLocked(request));
3527
+ }
3005
3528
  async removeByFilters(filters) {
3006
3529
  const listed = await spawnBounded2([this.runtime, "ps", "-aq", ...filters.flatMap((filter) => ["--filter", filter])], {
3007
3530
  env: this.hostEnvironment,
@@ -3047,15 +3570,23 @@ ${result.stdout}`)) {
3047
3570
  return removed;
3048
3571
  }
3049
3572
  async removeRunContainers(runId) {
3573
+ const key = runHash(runId);
3574
+ const session = this.sessions.get(key);
3575
+ let cachedResources = 0;
3576
+ if (session) {
3577
+ await this.destroySession(session);
3578
+ cachedResources = 2;
3579
+ }
3050
3580
  const filters = [
3051
3581
  `label=${HARNESS_OCI_LABEL}=${HARNESS_OCI_LABEL_VALUE}`,
3052
- `label=com.zhivex.harness.run=${runHash(runId)}`
3582
+ `label=com.zhivex.harness.run=${key}`
3053
3583
  ];
3054
3584
  const containers = await this.removeByFilters(filters);
3055
3585
  const volumes = await this.removeVolumesByFilters(filters);
3056
- return containers + volumes;
3586
+ return cachedResources + containers + volumes;
3057
3587
  }
3058
3588
  async cleanupOrphans() {
3589
+ this.sessions.clear();
3059
3590
  let removed = 0;
3060
3591
  for (const status of ["created", "exited", "dead", "paused"]) {
3061
3592
  removed += await this.removeByFilters([
@@ -3069,34 +3600,81 @@ ${result.stdout}`)) {
3069
3600
  return removed;
3070
3601
  }
3071
3602
  }
3072
- var collectSnapshotFiles = async (workspace) => {
3603
+ var SNAPSHOT_INVENTORY_PAGE_SIZE = 5000;
3604
+ var COPY_ON_WRITE_UNSUPPORTED_CODES = new Set(["EINVAL", "ENOSYS", "ENOTSUP", "EOPNOTSUPP"]);
3605
+ var collectSnapshotInventory = async (workspace, metrics) => {
3073
3606
  const files = new Map;
3607
+ if (metrics)
3608
+ metrics.inventoryPasses += 1;
3074
3609
  let cursor;
3075
3610
  do {
3076
- const page = await workspace.listFiles(".", { limit: 500, ...cursor ? { cursor } : {} });
3611
+ const page = await workspace.listFiles(".", {
3612
+ limit: SNAPSHOT_INVENTORY_PAGE_SIZE,
3613
+ ...cursor ? { cursor } : {}
3614
+ });
3615
+ if (metrics)
3616
+ metrics.inventoryPages += 1;
3077
3617
  for (const file of page.files) {
3078
3618
  const absolute = path7.join(workspace.root, ...file.path.split("/"));
3079
3619
  if (!isInside3(workspace.root, absolute))
3080
3620
  throw new Error(`Snapshot file escaped its workspace: ${file.path}.`);
3081
- const before = await lstat5(absolute);
3082
- if (before.isSymbolicLink() || !before.isFile())
3621
+ const entry = await lstat5(absolute);
3622
+ if (entry.isSymbolicLink() || !entry.isFile()) {
3083
3623
  throw new Error(`Snapshot source is not a regular file: ${file.path}.`);
3084
- const contents = await readFile3(absolute);
3085
- const after = await lstat5(absolute);
3086
- if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
3087
- throw new Error(`Snapshot source changed while reading: ${file.path}.`);
3088
3624
  }
3089
- if (digest2(contents) !== file.digest)
3090
- throw new Error(`Snapshot digest changed while reading: ${file.path}.`);
3091
- files.set(file.path, { path: file.path, digest: file.digest, contents, mode: before.mode & 511 });
3625
+ if (entry.size !== file.size) {
3626
+ throw new Error(`Snapshot source changed while inventorying: ${file.path}.`);
3627
+ }
3628
+ files.set(file.path, {
3629
+ path: file.path,
3630
+ digest: file.digest,
3631
+ mode: entry.mode & 511,
3632
+ bytes: file.size
3633
+ });
3092
3634
  }
3093
3635
  cursor = page.nextCursor;
3094
3636
  } while (cursor);
3095
3637
  return files;
3096
3638
  };
3097
- var copyWorkspaceSnapshot = async (source, baseRoot, snapshotRoot, maxWorkspaceBytes) => {
3098
- const files = await collectSnapshotFiles(source);
3099
- const totalBytes = [...files.values()].reduce((total, file) => total + file.contents.byteLength, 0);
3639
+ var readSnapshotFile = async (workspace, expected, metrics) => {
3640
+ const absolute = path7.join(workspace.root, ...expected.path.split("/"));
3641
+ if (!isInside3(workspace.root, absolute)) {
3642
+ throw new Error(`Snapshot file escaped its workspace: ${expected.path}.`);
3643
+ }
3644
+ const before = await lstat5(absolute);
3645
+ if (before.isSymbolicLink() || !before.isFile()) {
3646
+ throw new Error(`Snapshot source is not a regular file: ${expected.path}.`);
3647
+ }
3648
+ const contents = await readFile3(absolute);
3649
+ const after = await lstat5(absolute);
3650
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || (before.mode & 511) !== (after.mode & 511)) {
3651
+ throw new Error(`Snapshot source changed while reading: ${expected.path}.`);
3652
+ }
3653
+ const actualDigest = digest2(contents);
3654
+ if (actualDigest !== expected.digest || contents.byteLength !== expected.bytes || (before.mode & 511) !== expected.mode) {
3655
+ throw new Error(`Snapshot source changed after inventory: ${expected.path}.`);
3656
+ }
3657
+ if (metrics) {
3658
+ metrics.verifiedContentReads += 1;
3659
+ metrics.verifiedContentBytes += contents.byteLength;
3660
+ }
3661
+ return { ...expected, contents };
3662
+ };
3663
+ var copySnapshotFile = async (source, target, metrics) => {
3664
+ try {
3665
+ await copyFile(source, target, fsConstants.COPYFILE_FICLONE_FORCE);
3666
+ } catch (error) {
3667
+ const code = error.code;
3668
+ if (!code || !COPY_ON_WRITE_UNSUPPORTED_CODES.has(code))
3669
+ throw error;
3670
+ if (metrics)
3671
+ metrics.snapshotCloneFallbacks += 1;
3672
+ await copyFile(source, target);
3673
+ }
3674
+ };
3675
+ var copyWorkspaceSnapshot = async (source, baseRoot, snapshotRoot, maxWorkspaceBytes, metrics) => {
3676
+ const files = await collectSnapshotInventory(source, metrics);
3677
+ const totalBytes = [...files.values()].reduce((total, file) => total + file.bytes, 0);
3100
3678
  if (totalBytes > maxWorkspaceBytes) {
3101
3679
  throw new Error(`Workspace snapshot exceeds the ${maxWorkspaceBytes}-byte OCI limit.`);
3102
3680
  }
@@ -3104,13 +3682,22 @@ var copyWorkspaceSnapshot = async (source, baseRoot, snapshotRoot, maxWorkspaceB
3104
3682
  await rm(root, { recursive: true, force: true });
3105
3683
  await privateDirectory(root);
3106
3684
  }
3107
- for (const file of files.values()) {
3108
- for (const root of [baseRoot, snapshotRoot]) {
3109
- const target = path7.join(root, ...file.path.split("/"));
3110
- await mkdir3(path7.dirname(target), { recursive: true, mode: 448 });
3111
- await writeFile(target, file.contents, { mode: file.mode });
3112
- await chmod2(target, file.mode);
3113
- }
3685
+ for (const expected of files.values()) {
3686
+ const file = await readSnapshotFile(source, expected, metrics);
3687
+ const baseTarget = path7.join(baseRoot, ...file.path.split("/"));
3688
+ const snapshotTarget = path7.join(snapshotRoot, ...file.path.split("/"));
3689
+ await Promise.all([
3690
+ mkdir3(path7.dirname(baseTarget), { recursive: true, mode: 448 }),
3691
+ mkdir3(path7.dirname(snapshotTarget), { recursive: true, mode: 448 })
3692
+ ]);
3693
+ await writeFile(baseTarget, file.contents, { mode: file.mode });
3694
+ await chmod2(baseTarget, file.mode);
3695
+ await copySnapshotFile(baseTarget, snapshotTarget, metrics);
3696
+ await chmod2(snapshotTarget, file.mode);
3697
+ }
3698
+ if (metrics) {
3699
+ metrics.snapshotFiles += files.size;
3700
+ metrics.snapshotBytes += totalBytes;
3114
3701
  }
3115
3702
  return { files: files.size, bytes: totalBytes };
3116
3703
  };
@@ -3129,9 +3716,11 @@ var textContent = (file, maxFileWriteBytes) => {
3129
3716
  }
3130
3717
  return file.contents.toString("utf8");
3131
3718
  };
3132
- var createEnvironmentPatch = async (runId, base, current, maxFileWriteBytes) => {
3133
- const before = await collectSnapshotFiles(base);
3134
- const after = await collectSnapshotFiles(current);
3719
+ var createEnvironmentPatch = async (runId, base, current, maxFileWriteBytes, metrics) => {
3720
+ const [before, after] = await Promise.all([
3721
+ collectSnapshotInventory(base, metrics),
3722
+ collectSnapshotInventory(current, metrics)
3723
+ ]);
3135
3724
  const paths = [...new Set([...before.keys(), ...after.keys()])].sort();
3136
3725
  const entries = [];
3137
3726
  let totalBytes = 0;
@@ -3141,9 +3730,9 @@ var createEnvironmentPatch = async (runId, base, current, maxFileWriteBytes) =>
3141
3730
  if (oldFile?.digest === newFile?.digest && oldFile?.mode === newFile?.mode)
3142
3731
  continue;
3143
3732
  const operation = !oldFile ? "create" : !newFile ? "delete" : "update";
3144
- const beforeContent = oldFile ? textContent(oldFile, maxFileWriteBytes) : undefined;
3145
- const afterContent = newFile ? textContent(newFile, maxFileWriteBytes) : undefined;
3146
- const bytes = newFile?.contents.byteLength ?? 0;
3733
+ const beforeContent = oldFile ? textContent(await readSnapshotFile(base, oldFile, metrics), maxFileWriteBytes) : undefined;
3734
+ const afterContent = newFile ? textContent(await readSnapshotFile(current, newFile, metrics), maxFileWriteBytes) : undefined;
3735
+ const bytes = newFile?.bytes ?? 0;
3147
3736
  totalBytes += bytes;
3148
3737
  entries.push({
3149
3738
  path: filePath,
@@ -3183,8 +3772,8 @@ var inspectHostPrecondition = async (workspace, filePath, expectedDigest, expect
3183
3772
  throw error;
3184
3773
  }
3185
3774
  };
3186
- var importPatch = async (runId, expectedPatchId, host, base, current, maxFileWriteBytes) => {
3187
- const patch = await createEnvironmentPatch(runId, base, current, maxFileWriteBytes);
3775
+ var importPatch = async (runId, expectedPatchId, host, base, current, maxFileWriteBytes, metrics) => {
3776
+ const patch = await createEnvironmentPatch(runId, base, current, maxFileWriteBytes, metrics);
3188
3777
  if (patch.patchId !== expectedPatchId) {
3189
3778
  throw new Error("Environment patch changed after review; inspect it again before import.");
3190
3779
  }
@@ -3278,7 +3867,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
3278
3867
  version: `${HARNESS_EXECUTION_POLICY_VERSION}:${image.imageDigest}`,
3279
3868
  backend: "container",
3280
3869
  assurance: "enforced",
3281
- isolation: "per-tool-call",
3870
+ isolation: "per-run",
3282
3871
  workspace: {
3283
3872
  id: workspaceIdentity,
3284
3873
  root: "/workspace",
@@ -3315,6 +3904,8 @@ var createHarnessOciExecutionEnvironment = async (options) => {
3315
3904
  maxPids: options.config.maxPids,
3316
3905
  maxCpus: options.config.maxCpus,
3317
3906
  tmpfsMb: options.config.tmpfsMb,
3907
+ containerLifecycle: "warm-per-acquired-run",
3908
+ failedCommandRecovery: "discard-and-reseed",
3318
3909
  patchImportApproval: true,
3319
3910
  hostMutationMode: "reviewed-environment-patch"
3320
3911
  }
@@ -3326,6 +3917,19 @@ var createHarnessOciExecutionEnvironment = async (options) => {
3326
3917
  image,
3327
3918
  runtime,
3328
3919
  async acquire(request) {
3920
+ const ioMetrics = {
3921
+ inventoryPasses: 0,
3922
+ inventoryPages: 0,
3923
+ verifiedContentReads: 0,
3924
+ verifiedContentBytes: 0,
3925
+ snapshotFiles: 0,
3926
+ snapshotBytes: 0,
3927
+ snapshotCloneFallbacks: 0,
3928
+ containerStarts: 0,
3929
+ containerReuses: 0,
3930
+ workspacePublishes: 0,
3931
+ workspaceExports: 0
3932
+ };
3329
3933
  await privateDirectory(environmentRoot);
3330
3934
  const directory = path7.join(environmentRoot, runHash(request.runId));
3331
3935
  await privateDirectory(directory);
@@ -3344,7 +3948,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
3344
3948
  } catch (error) {
3345
3949
  if (error.code !== "ENOENT")
3346
3950
  throw error;
3347
- const snapshot = await copyWorkspaceSnapshot(options.workspace, baseRoot, snapshotRoot, options.config.maxWorkspaceBytes);
3951
+ const snapshot = await copyWorkspaceSnapshot(options.workspace, baseRoot, snapshotRoot, options.config.maxWorkspaceBytes, ioMetrics);
3348
3952
  const now = new Date().toISOString();
3349
3953
  metadata = {
3350
3954
  schemaVersion: HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION,
@@ -3401,6 +4005,14 @@ var createHarnessOciExecutionEnvironment = async (options) => {
3401
4005
  },
3402
4006
  ...context?.abortSignal ?? request.abortSignal ? { abortSignal: context?.abortSignal ?? request.abortSignal } : {}
3403
4007
  });
4008
+ if (result.sessionReused === false)
4009
+ ioMetrics.containerStarts += 1;
4010
+ if (result.sessionReused === true)
4011
+ ioMetrics.containerReuses += 1;
4012
+ if (result.workspacePublished === true)
4013
+ ioMetrics.workspacePublishes += 1;
4014
+ if (result.workspaceExported === true)
4015
+ ioMetrics.workspaceExports += 1;
3404
4016
  const forcedExitCode = result.cancelled ? 130 : result.timedOut ? 124 : result.outputLimitExceeded ? 125 : result.exitCode;
3405
4017
  return {
3406
4018
  command: result.command,
@@ -3447,7 +4059,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
3447
4059
  return await operation();
3448
4060
  },
3449
4061
  async status() {
3450
- const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes);
4062
+ const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes, ioMetrics);
3451
4063
  return {
3452
4064
  schemaVersion: HARNESS_EXECUTION_ARTIFACT_SCHEMA_VERSION,
3453
4065
  kind: "environment-status",
@@ -3461,7 +4073,8 @@ var createHarnessOciExecutionEnvironment = async (options) => {
3461
4073
  rootFilesystem: "read-only",
3462
4074
  workspace: "ephemeral-snapshot",
3463
4075
  patchId: patch.patchId,
3464
- changedFiles: patch.entries.length
4076
+ changedFiles: patch.entries.length,
4077
+ io: { ...ioMetrics }
3465
4078
  };
3466
4079
  },
3467
4080
  runCommand,
@@ -3486,7 +4099,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
3486
4099
  return runCommand("bun", ["--no-env-file", "run", check], context);
3487
4100
  },
3488
4101
  async inspectPatch() {
3489
- const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes);
4102
+ const patch = await createEnvironmentPatch(request.runId, base, workspace, options.config.maxFileWriteBytes, ioMetrics);
3490
4103
  const payload = patchPayload(request.runId, patch.entries);
3491
4104
  return {
3492
4105
  ...payload,
@@ -3495,7 +4108,7 @@ var createHarnessOciExecutionEnvironment = async (options) => {
3495
4108
  };
3496
4109
  },
3497
4110
  importPatch(host, patchId) {
3498
- return importPatch(request.runId, patchId, host, base, workspace, options.config.maxFileWriteBytes);
4111
+ return importPatch(request.runId, patchId, host, base, workspace, options.config.maxFileWriteBytes, ioMetrics);
3499
4112
  },
3500
4113
  async release(result) {
3501
4114
  await runtime.removeRunContainers(request.runId);
@@ -3559,7 +4172,7 @@ var describeOciCommand = (command, args) => commandDisplay([command, ...args]);
3559
4172
 
3560
4173
  // src/harness.ts
3561
4174
  var APPROVAL_VERSION = "2026-08-17-v5";
3562
- var TOOL_CONTRACT_VERSION = "enforced-execution-v1";
4175
+ var TOOL_CONTRACT_VERSION = "workspace-batch-v2";
3563
4176
  var createHarnessBinding = (config, mcpConfiguration, model, subagentModels, providerTransportFingerprint, executionEnvironment) => ({
3564
4177
  schemaVersion: 1,
3565
4178
  id: "zhivex-harness",
@@ -3589,11 +4202,12 @@ var HARNESS_INSTRUCTIONS = `You are Zhivex Harness, a provider-portable coding a
3589
4202
 
3590
4203
  Rules:
3591
4204
  - Match the user's language.
3592
- - Inspect the repository before proposing changes. Prefer search_files and read_file over assumptions.
4205
+ - 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.
3593
4206
  - Use only workspace-relative paths. Never request or expose secrets.
3594
4207
  - Make the smallest coherent change that fully addresses the task.
3595
4208
  - For every file edit, first read its digest and call propose_edits. Apply exactly that reviewed proposal with apply_patch.
3596
4209
  - apply_patch, move_file, quarantine_file, restore_file, and run_check require explicit approval from the operator.
4210
+ - 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.
3597
4211
  - 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.
3598
4212
  - 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.
3599
4213
  - Never overwrite stale content. If an expected digest no longer matches, inspect the file again and create a new proposal.
@@ -3660,6 +4274,23 @@ var createWorkspaceTools = (workspace, allowedChecks) => ({
3660
4274
  metadata: readOnlyMetadata,
3661
4275
  execute: async ({ path: path8, startLine, endLine }, context) => serializeJsonValue2(await (harnessExecutionSession(context)?.workspace ?? workspace).readFile(path8, startLine, endLine))
3662
4276
  }),
4277
+ read_files: tool({
4278
+ name: "read_files",
4279
+ 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.",
4280
+ schema: z3.object({
4281
+ files: z3.array(z3.object({
4282
+ path: z3.string().min(1),
4283
+ startLine: z3.number().int().min(1).default(1),
4284
+ endLine: z3.number().int().min(1).optional()
4285
+ })).min(1).max(20)
4286
+ }),
4287
+ metadata: readOnlyMetadata,
4288
+ execute: async ({ files }, context) => serializeJsonValue2(await (harnessExecutionSession(context)?.workspace ?? workspace).readFiles(files.map(({ path: path8, startLine, endLine }) => ({
4289
+ path: path8,
4290
+ startLine,
4291
+ ...endLine !== undefined ? { endLine } : {}
4292
+ }))))
4293
+ }),
3663
4294
  search_files: tool({
3664
4295
  name: "search_files",
3665
4296
  description: "Search for a literal string in text files using a stable cursor.",
@@ -3677,6 +4308,28 @@ var createWorkspaceTools = (workspace, allowedChecks) => ({
3677
4308
  ...cursor ? { cursor } : {}
3678
4309
  }))
3679
4310
  }),
4311
+ search_many: tool({
4312
+ name: "search_many",
4313
+ description: "Search up to 10 independent literal queries in one workspace pass, with at most 500 aggregate matches.",
4314
+ schema: z3.object({
4315
+ queries: z3.array(z3.object({
4316
+ query: z3.string().min(1).max(200),
4317
+ caseSensitive: z3.boolean().default(false)
4318
+ })).min(1).max(10),
4319
+ path: z3.string().min(1).default("."),
4320
+ limitPerQuery: z3.number().int().min(1).max(500).default(50)
4321
+ }).superRefine((input, context) => {
4322
+ if (input.queries.length * input.limitPerQuery > 500) {
4323
+ context.addIssue({
4324
+ code: "custom",
4325
+ path: ["limitPerQuery"],
4326
+ message: "search_many allows at most 500 aggregate matches."
4327
+ });
4328
+ }
4329
+ }),
4330
+ metadata: readOnlyMetadata,
4331
+ execute: async ({ queries, path: path8, limitPerQuery }, context) => serializeJsonValue2(await (harnessExecutionSession(context)?.workspace ?? workspace).searchMany(queries, path8, { limitPerQuery }))
4332
+ }),
3680
4333
  propose_edits: tool({
3681
4334
  name: "propose_edits",
3682
4335
  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.",
@@ -3689,7 +4342,7 @@ var createWorkspaceTools = (workspace, allowedChecks) => ({
3689
4342
  }),
3690
4343
  apply_patch: tool({
3691
4344
  name: "apply_patch",
3692
- description: "Atomically apply one reviewed multi-file proposal. Every existing file requires its exact expected digest; expectedDigest=null is create-only.",
4345
+ 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.",
3693
4346
  schema: applyEditProposalInputSchema,
3694
4347
  ...mutationApproval,
3695
4348
  execute: async (input, context) => {
@@ -4900,6 +5553,512 @@ var openCliSessionStore = async (options) => {
4900
5553
  };
4901
5554
  var openSessionStore = openCliSessionStore;
4902
5555
 
5556
+ // src/change-envelope.ts
5557
+ import { createHash as createHash9 } from "crypto";
5558
+ import { z as z4 } from "zod";
5559
+ var CHANGE_ENVELOPE_SCHEMA_VERSION = 1;
5560
+ var CHANGE_ENVELOPE_DIGEST_ALGORITHM = "sha256";
5561
+ var MAX_CHANGE_ENVELOPE_CHECKS = 100;
5562
+ var MAX_CHANGE_ENVELOPE_APPROVALS = 100;
5563
+ var MAX_CHANGE_ENVELOPE_APPROVAL_SCOPES = 32;
5564
+ var MAX_CHANGE_ENVELOPE_ATTESTATIONS = 32;
5565
+ var timestampSchema = z4.iso.datetime({ precision: 3 });
5566
+ var boundedIdentifierSchema = z4.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/, "Identifier must contain only bounded non-secret token characters.");
5567
+ var approvalScopeSchema = z4.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._:/*-]*$/, "Approval scope must contain only bounded token characters.");
5568
+ var attestationFormatSchema = z4.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9.+_-]*$/);
5569
+ var mediaTypeSchema = z4.string().min(3).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/);
5570
+ var addDuplicateIssues = (values, key, context, path9) => {
5571
+ const seen = new Set;
5572
+ for (let index = 0;index < values.length; index += 1) {
5573
+ const value = values[index];
5574
+ if (value === undefined)
5575
+ continue;
5576
+ const identifier2 = key(value);
5577
+ if (seen.has(identifier2)) {
5578
+ context.addIssue({
5579
+ code: "custom",
5580
+ path: [index, path9],
5581
+ message: `Duplicate ${path9}: ${identifier2}.`
5582
+ });
5583
+ }
5584
+ seen.add(identifier2);
5585
+ }
5586
+ };
5587
+ var addCanonicalOrderIssue = (values, key, context, path9) => {
5588
+ for (let index = 1;index < values.length; index += 1) {
5589
+ const previous = values[index - 1];
5590
+ const current = values[index];
5591
+ if (previous !== undefined && current !== undefined && key(previous) >= key(current)) {
5592
+ context.addIssue({
5593
+ code: "custom",
5594
+ path: [index, path9],
5595
+ message: `${path9} entries must be unique and in canonical ascending order.`
5596
+ });
5597
+ }
5598
+ }
5599
+ };
5600
+ var approvalScopesInputSchema = z4.array(approvalScopeSchema).min(1).max(MAX_CHANGE_ENVELOPE_APPROVAL_SCOPES).superRefine((scopes, context) => addDuplicateIssues(scopes, (scope) => scope, context, "scope"));
5601
+ var canonicalApprovalScopesSchema = z4.array(approvalScopeSchema).min(1).max(MAX_CHANGE_ENVELOPE_APPROVAL_SCOPES).superRefine((scopes, context) => addCanonicalOrderIssue(scopes, (scope) => scope, context, "scope"));
5602
+ var changeEnvelopeCheckSchema = z4.strictObject({
5603
+ checkId: boundedIdentifierSchema,
5604
+ status: z4.enum(["passed", "failed", "skipped"]),
5605
+ redacted: z4.literal(true),
5606
+ startedAt: timestampSchema,
5607
+ completedAt: timestampSchema,
5608
+ durationMs: z4.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
5609
+ exitCode: z4.number().int().min(0).max(255).optional(),
5610
+ redactedOutputDigest: fileDigestSchema.optional(),
5611
+ runnerFingerprint: fileDigestSchema.optional()
5612
+ }).superRefine((check, context) => {
5613
+ const startedAt = Date.parse(check.startedAt);
5614
+ const completedAt = Date.parse(check.completedAt);
5615
+ if (completedAt < startedAt) {
5616
+ context.addIssue({
5617
+ code: "custom",
5618
+ path: ["completedAt"],
5619
+ message: "Check completion cannot precede its start."
5620
+ });
5621
+ }
5622
+ if (check.status === "passed" && check.exitCode !== undefined && check.exitCode !== 0) {
5623
+ context.addIssue({
5624
+ code: "custom",
5625
+ path: ["exitCode"],
5626
+ message: "A passed check cannot have a non-zero exit code."
5627
+ });
5628
+ }
5629
+ if (check.status === "failed" && check.exitCode === 0) {
5630
+ context.addIssue({
5631
+ code: "custom",
5632
+ path: ["exitCode"],
5633
+ message: "A failed check cannot have a zero exit code."
5634
+ });
5635
+ }
5636
+ if (check.status === "skipped" && check.exitCode !== undefined) {
5637
+ context.addIssue({
5638
+ code: "custom",
5639
+ path: ["exitCode"],
5640
+ message: "A skipped check cannot have an exit code."
5641
+ });
5642
+ }
5643
+ });
5644
+ var checkInputListSchema = z4.array(changeEnvelopeCheckSchema).min(1).max(MAX_CHANGE_ENVELOPE_CHECKS).superRefine((checks, context) => addDuplicateIssues(checks, (check) => check.checkId, context, "checkId"));
5645
+ var canonicalCheckListSchema = z4.array(changeEnvelopeCheckSchema).min(1).max(MAX_CHANGE_ENVELOPE_CHECKS).superRefine((checks, context) => addCanonicalOrderIssue(checks, (check) => check.checkId, context, "checkId"));
5646
+ var changeEnvelopeApprovalSchema = z4.strictObject({
5647
+ kind: z4.literal("approval-reference"),
5648
+ approvalId: boundedIdentifierSchema,
5649
+ approvalDigest: fileDigestSchema,
5650
+ approverDigest: fileDigestSchema,
5651
+ scopes: canonicalApprovalScopesSchema,
5652
+ approvedAt: timestampSchema,
5653
+ expiresAt: timestampSchema.optional()
5654
+ }).superRefine((approval, context) => {
5655
+ if (approval.expiresAt && Date.parse(approval.expiresAt) <= Date.parse(approval.approvedAt)) {
5656
+ context.addIssue({
5657
+ code: "custom",
5658
+ path: ["expiresAt"],
5659
+ message: "Approval expiry must be later than approval time."
5660
+ });
5661
+ }
5662
+ });
5663
+ var changeEnvelopeApprovalInputSchema = z4.strictObject({
5664
+ kind: z4.literal("approval-reference"),
5665
+ approvalId: boundedIdentifierSchema,
5666
+ approvalDigest: fileDigestSchema,
5667
+ approverDigest: fileDigestSchema,
5668
+ scopes: approvalScopesInputSchema,
5669
+ approvedAt: timestampSchema,
5670
+ expiresAt: timestampSchema.optional()
5671
+ }).superRefine((approval, context) => {
5672
+ if (approval.expiresAt && Date.parse(approval.expiresAt) <= Date.parse(approval.approvedAt)) {
5673
+ context.addIssue({
5674
+ code: "custom",
5675
+ path: ["expiresAt"],
5676
+ message: "Approval expiry must be later than approval time."
5677
+ });
5678
+ }
5679
+ });
5680
+ var approvalInputListSchema = z4.array(changeEnvelopeApprovalInputSchema).min(1).max(MAX_CHANGE_ENVELOPE_APPROVALS).superRefine((approvals, context) => addDuplicateIssues(approvals, (approval) => approval.approvalId, context, "approvalId"));
5681
+ var canonicalApprovalListSchema = z4.array(changeEnvelopeApprovalSchema).min(1).max(MAX_CHANGE_ENVELOPE_APPROVALS).superRefine((approvals, context) => addCanonicalOrderIssue(approvals, (approval) => approval.approvalId, context, "approvalId"));
5682
+ var externalAttestationReferenceSchema = z4.strictObject({
5683
+ kind: z4.literal("external-attestation-reference"),
5684
+ attestationId: boundedIdentifierSchema,
5685
+ format: attestationFormatSchema,
5686
+ mediaType: mediaTypeSchema,
5687
+ bundleDigest: fileDigestSchema
5688
+ });
5689
+ var externalAttestationInputListSchema = z4.array(externalAttestationReferenceSchema).min(1).max(MAX_CHANGE_ENVELOPE_ATTESTATIONS).superRefine((attestations, context) => addDuplicateIssues(attestations, (attestation) => attestation.attestationId, context, "attestationId"));
5690
+ var canonicalExternalAttestationListSchema = z4.array(externalAttestationReferenceSchema).min(1).max(MAX_CHANGE_ENVELOPE_ATTESTATIONS).superRefine((attestations, context) => addCanonicalOrderIssue(attestations, (attestation) => attestation.attestationId, context, "attestationId"));
5691
+ var changeEnvelopeBaseSchema = z4.strictObject({
5692
+ workspaceDigest: fileDigestSchema,
5693
+ treeDigest: fileDigestSchema
5694
+ });
5695
+ var changeEnvelopePatchSchema = z4.strictObject({
5696
+ patchId: boundedIdentifierSchema,
5697
+ patchDigest: fileDigestSchema
5698
+ });
5699
+ var changeEnvelopeFingerprintsSchema = z4.strictObject({
5700
+ harness: fileDigestSchema,
5701
+ policy: fileDigestSchema,
5702
+ environment: fileDigestSchema
5703
+ });
5704
+ var changeEnvelopeEvidenceSchema = z4.strictObject({
5705
+ schemaVersion: z4.literal(CHANGE_ENVELOPE_SCHEMA_VERSION),
5706
+ kind: z4.literal("check-evidence-summary"),
5707
+ digestAlgorithm: z4.literal(CHANGE_ENVELOPE_DIGEST_ALGORITHM),
5708
+ evidenceDigest: fileDigestSchema,
5709
+ checks: canonicalCheckListSchema
5710
+ });
5711
+ var changeEnvelopeInputSchema = z4.strictObject({
5712
+ createdAt: timestampSchema,
5713
+ expiresAt: timestampSchema.optional(),
5714
+ base: changeEnvelopeBaseSchema,
5715
+ patch: changeEnvelopePatchSchema,
5716
+ fingerprints: changeEnvelopeFingerprintsSchema,
5717
+ checks: checkInputListSchema,
5718
+ approvals: approvalInputListSchema.optional(),
5719
+ externalAttestations: externalAttestationInputListSchema.optional()
5720
+ });
5721
+ var changeEnvelopeSchema = z4.strictObject({
5722
+ schemaVersion: z4.literal(CHANGE_ENVELOPE_SCHEMA_VERSION),
5723
+ kind: z4.literal("change-envelope"),
5724
+ digestAlgorithm: z4.literal(CHANGE_ENVELOPE_DIGEST_ALGORITHM),
5725
+ envelopeId: fileDigestSchema,
5726
+ createdAt: timestampSchema,
5727
+ expiresAt: timestampSchema.optional(),
5728
+ base: changeEnvelopeBaseSchema,
5729
+ patch: changeEnvelopePatchSchema,
5730
+ fingerprints: changeEnvelopeFingerprintsSchema,
5731
+ evidence: changeEnvelopeEvidenceSchema,
5732
+ approvals: canonicalApprovalListSchema.optional(),
5733
+ externalAttestations: canonicalExternalAttestationListSchema.optional()
5734
+ }).superRefine((envelope, context) => {
5735
+ if (envelope.expiresAt && Date.parse(envelope.expiresAt) <= Date.parse(envelope.createdAt)) {
5736
+ context.addIssue({
5737
+ code: "custom",
5738
+ path: ["expiresAt"],
5739
+ message: "Envelope expiry must be later than creation time."
5740
+ });
5741
+ }
5742
+ for (let index = 0;index < envelope.evidence.checks.length; index += 1) {
5743
+ const check = envelope.evidence.checks[index];
5744
+ if (check && Date.parse(check.completedAt) > Date.parse(envelope.createdAt)) {
5745
+ context.addIssue({
5746
+ code: "custom",
5747
+ path: ["evidence", "checks", index, "completedAt"],
5748
+ message: "Check completion cannot be later than envelope creation time."
5749
+ });
5750
+ }
5751
+ }
5752
+ for (let index = 0;index < (envelope.approvals?.length ?? 0); index += 1) {
5753
+ const approval = envelope.approvals?.[index];
5754
+ if (approval && Date.parse(approval.approvedAt) > Date.parse(envelope.createdAt)) {
5755
+ context.addIssue({
5756
+ code: "custom",
5757
+ path: ["approvals", index, "approvedAt"],
5758
+ message: "Approval time cannot be later than envelope creation time."
5759
+ });
5760
+ }
5761
+ }
5762
+ });
5763
+ var canonicalJson = (value) => {
5764
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
5765
+ return JSON.stringify(value);
5766
+ }
5767
+ if (typeof value === "number") {
5768
+ if (!Number.isFinite(value))
5769
+ throw new TypeError("Canonical JSON cannot encode a non-finite number.");
5770
+ return JSON.stringify(Object.is(value, -0) ? 0 : value);
5771
+ }
5772
+ if (Array.isArray(value)) {
5773
+ return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
5774
+ }
5775
+ if (typeof value === "object") {
5776
+ const record = value;
5777
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
5778
+ }
5779
+ throw new TypeError(`Canonical JSON cannot encode ${typeof value}.`);
5780
+ };
5781
+ var digestCanonical = (value) => `sha256:${createHash9("sha256").update(canonicalJson(value)).digest("hex")}`;
5782
+ var digestChangeEnvelopeArtifact = (value) => `sha256:${createHash9("sha256").update(value).digest("hex")}`;
5783
+ var evidencePayload = (checks) => ({
5784
+ schemaVersion: CHANGE_ENVELOPE_SCHEMA_VERSION,
5785
+ kind: "check-evidence-summary",
5786
+ digestAlgorithm: CHANGE_ENVELOPE_DIGEST_ALGORITHM,
5787
+ checks: [...checks]
5788
+ });
5789
+ var envelopePayload = (envelope) => {
5790
+ const { envelopeId: _envelopeId, ...payload } = envelope;
5791
+ return payload;
5792
+ };
5793
+ var sortBy = (values, key) => [...values].sort((left, right) => {
5794
+ const leftKey = key(left);
5795
+ const rightKey = key(right);
5796
+ return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
5797
+ });
5798
+ var createChangeEnvelope = (input) => {
5799
+ const parsed = changeEnvelopeInputSchema.parse(input);
5800
+ const checks = sortBy(parsed.checks, (check) => check.checkId);
5801
+ const evidenceWithoutDigest = evidencePayload(checks);
5802
+ const evidence = {
5803
+ ...evidenceWithoutDigest,
5804
+ evidenceDigest: digestCanonical(evidenceWithoutDigest)
5805
+ };
5806
+ const approvals = parsed.approvals ? sortBy(parsed.approvals, (approval) => approval.approvalId).map((approval) => ({
5807
+ ...approval,
5808
+ scopes: [...approval.scopes].sort()
5809
+ })) : undefined;
5810
+ const externalAttestations = parsed.externalAttestations ? sortBy(parsed.externalAttestations, (attestation) => attestation.attestationId) : undefined;
5811
+ const payload = {
5812
+ schemaVersion: CHANGE_ENVELOPE_SCHEMA_VERSION,
5813
+ kind: "change-envelope",
5814
+ digestAlgorithm: CHANGE_ENVELOPE_DIGEST_ALGORITHM,
5815
+ createdAt: parsed.createdAt,
5816
+ ...parsed.expiresAt ? { expiresAt: parsed.expiresAt } : {},
5817
+ base: parsed.base,
5818
+ patch: parsed.patch,
5819
+ fingerprints: parsed.fingerprints,
5820
+ evidence,
5821
+ ...approvals ? { approvals } : {},
5822
+ ...externalAttestations ? { externalAttestations } : {}
5823
+ };
5824
+ return changeEnvelopeSchema.parse({
5825
+ ...payload,
5826
+ envelopeId: digestCanonical(payload)
5827
+ });
5828
+ };
5829
+ var canonicalizeChangeEnvelope = (input) => canonicalJson(changeEnvelopeSchema.parse(input));
5830
+ var computeChangeEnvelopeDigest = (input) => {
5831
+ const parsed = changeEnvelopeSchema.parse(input);
5832
+ return digestCanonical(envelopePayload(parsed));
5833
+ };
5834
+ var computeChangeEnvelopeEvidenceDigest = (input) => {
5835
+ const parsed = changeEnvelopeEvidenceSchema.parse(input);
5836
+ return digestCanonical(evidencePayload(parsed.checks));
5837
+ };
5838
+ var changeEnvelopePreconditionsSchema = z4.strictObject({
5839
+ envelopeId: fileDigestSchema.optional(),
5840
+ baseWorkspaceDigest: fileDigestSchema.optional(),
5841
+ baseTreeDigest: fileDigestSchema.optional(),
5842
+ patchId: boundedIdentifierSchema.optional(),
5843
+ patchDigest: fileDigestSchema.optional(),
5844
+ harnessFingerprint: fileDigestSchema.optional(),
5845
+ policyFingerprint: fileDigestSchema.optional(),
5846
+ environmentFingerprint: fileDigestSchema.optional(),
5847
+ evidenceDigest: fileDigestSchema.optional(),
5848
+ requiredPassedChecks: z4.array(boundedIdentifierSchema).max(MAX_CHANGE_ENVELOPE_CHECKS).optional(),
5849
+ requiredApprovalDigests: z4.array(fileDigestSchema).max(MAX_CHANGE_ENVELOPE_APPROVALS).optional(),
5850
+ requiredApprovalScopes: z4.array(approvalScopeSchema).max(MAX_CHANGE_ENVELOPE_APPROVAL_SCOPES).optional(),
5851
+ requiredAttestationDigests: z4.array(fileDigestSchema).max(MAX_CHANGE_ENVELOPE_ATTESTATIONS).optional()
5852
+ }).superRefine((preconditions, context) => {
5853
+ if (preconditions.requiredPassedChecks) {
5854
+ addDuplicateIssues(preconditions.requiredPassedChecks, (checkId) => checkId, context, "requiredPassedCheck");
5855
+ }
5856
+ if (preconditions.requiredApprovalDigests) {
5857
+ addDuplicateIssues(preconditions.requiredApprovalDigests, (digest3) => digest3, context, "requiredApprovalDigest");
5858
+ }
5859
+ if (preconditions.requiredApprovalScopes) {
5860
+ addDuplicateIssues(preconditions.requiredApprovalScopes, (scope) => scope, context, "requiredApprovalScope");
5861
+ }
5862
+ if (preconditions.requiredAttestationDigests) {
5863
+ addDuplicateIssues(preconditions.requiredAttestationDigests, (digest3) => digest3, context, "requiredAttestationDigest");
5864
+ }
5865
+ });
5866
+ var verificationOptionsSchema = z4.strictObject({
5867
+ now: z4.union([z4.date(), z4.number().int(), timestampSchema]).optional(),
5868
+ preconditions: changeEnvelopePreconditionsSchema.optional()
5869
+ });
5870
+ var verificationTime = (value) => {
5871
+ const date = value === undefined ? new Date : value instanceof Date ? value : new Date(value);
5872
+ if (!Number.isFinite(date.getTime()))
5873
+ throw new TypeError("Verification time must be a valid date.");
5874
+ return date;
5875
+ };
5876
+ var verifyChangeEnvelope = (input, options = {}) => {
5877
+ const parsedOptions = verificationOptionsSchema.parse(options);
5878
+ const now = verificationTime(parsedOptions.now);
5879
+ const checkedAt = now.toISOString();
5880
+ const parsedEnvelope = changeEnvelopeSchema.safeParse(input);
5881
+ if (!parsedEnvelope.success) {
5882
+ return {
5883
+ valid: false,
5884
+ verificationScope: "integrity-expiration-and-preconditions-only",
5885
+ issues: parsedEnvelope.error.issues.map((issue) => ({
5886
+ code: "invalid-schema",
5887
+ path: issue.path.map((segment) => typeof segment === "symbol" ? segment.description ?? "symbol" : segment),
5888
+ message: issue.message
5889
+ })),
5890
+ integrity: { envelopeDigestValid: false, evidenceDigestValid: false },
5891
+ expiration: { checkedAt, envelopeNotYetValid: false, envelopeExpired: false, expiredApprovalIds: [] },
5892
+ checks: {
5893
+ recorded: 0,
5894
+ passed: [],
5895
+ failed: [],
5896
+ skipped: [],
5897
+ requiredPassed: [...parsedOptions.preconditions?.requiredPassedChecks ?? []].sort(),
5898
+ missingPassed: [...parsedOptions.preconditions?.requiredPassedChecks ?? []].sort()
5899
+ },
5900
+ approvals: {
5901
+ recorded: 0,
5902
+ requiredScopes: [...parsedOptions.preconditions?.requiredApprovalScopes ?? []].sort(),
5903
+ missingScopes: [...parsedOptions.preconditions?.requiredApprovalScopes ?? []].sort(),
5904
+ authenticity: "not-verified"
5905
+ },
5906
+ externalAttestations: {
5907
+ referenced: 0,
5908
+ requiredDigests: [...parsedOptions.preconditions?.requiredAttestationDigests ?? []].sort(),
5909
+ missingDigests: [...parsedOptions.preconditions?.requiredAttestationDigests ?? []].sort(),
5910
+ authenticity: "not-verified"
5911
+ }
5912
+ };
5913
+ }
5914
+ const envelope = parsedEnvelope.data;
5915
+ const issues = [];
5916
+ const computedEvidenceDigest = digestCanonical(evidencePayload(envelope.evidence.checks));
5917
+ const evidenceDigestValid = computedEvidenceDigest === envelope.evidence.evidenceDigest;
5918
+ if (!evidenceDigestValid) {
5919
+ issues.push({
5920
+ code: "evidence-digest-mismatch",
5921
+ path: ["evidence", "evidenceDigest"],
5922
+ message: "Evidence digest does not match the canonical redacted check summary.",
5923
+ expected: computedEvidenceDigest,
5924
+ actual: envelope.evidence.evidenceDigest
5925
+ });
5926
+ }
5927
+ const computedEnvelopeDigest = digestCanonical(envelopePayload(envelope));
5928
+ const envelopeDigestValid = computedEnvelopeDigest === envelope.envelopeId;
5929
+ if (!envelopeDigestValid) {
5930
+ issues.push({
5931
+ code: "envelope-digest-mismatch",
5932
+ path: ["envelopeId"],
5933
+ message: "Envelope digest does not match its canonical payload.",
5934
+ expected: computedEnvelopeDigest,
5935
+ actual: envelope.envelopeId
5936
+ });
5937
+ }
5938
+ const envelopeNotYetValid = now.getTime() < Date.parse(envelope.createdAt);
5939
+ if (envelopeNotYetValid) {
5940
+ issues.push({
5941
+ code: "envelope-not-yet-valid",
5942
+ path: ["createdAt"],
5943
+ message: "Change envelope creation time is in the future."
5944
+ });
5945
+ }
5946
+ const envelopeExpired = envelope.expiresAt !== undefined && now.getTime() >= Date.parse(envelope.expiresAt);
5947
+ if (envelopeExpired) {
5948
+ issues.push({
5949
+ code: "envelope-expired",
5950
+ path: ["expiresAt"],
5951
+ message: "Change envelope has expired."
5952
+ });
5953
+ }
5954
+ const expiredApprovalIds = (envelope.approvals ?? []).filter((approval) => approval.expiresAt !== undefined && now.getTime() >= Date.parse(approval.expiresAt)).map((approval) => approval.approvalId);
5955
+ for (const approvalId of expiredApprovalIds) {
5956
+ issues.push({
5957
+ code: "approval-expired",
5958
+ path: ["approvals", envelope.approvals?.findIndex((approval) => approval.approvalId === approvalId) ?? 0, "expiresAt"],
5959
+ message: `Recorded approval has expired: ${approvalId}.`
5960
+ });
5961
+ }
5962
+ const preconditions = parsedOptions.preconditions;
5963
+ const comparePrecondition = (field, expected, actual, path9) => {
5964
+ if (expected !== undefined && expected !== actual) {
5965
+ issues.push({
5966
+ code: "precondition-mismatch",
5967
+ path: path9,
5968
+ message: `Change envelope precondition does not match: ${field}.`,
5969
+ expected,
5970
+ actual
5971
+ });
5972
+ }
5973
+ };
5974
+ comparePrecondition("envelopeId", preconditions?.envelopeId, envelope.envelopeId, ["envelopeId"]);
5975
+ comparePrecondition("baseWorkspaceDigest", preconditions?.baseWorkspaceDigest, envelope.base.workspaceDigest, ["base", "workspaceDigest"]);
5976
+ comparePrecondition("baseTreeDigest", preconditions?.baseTreeDigest, envelope.base.treeDigest, ["base", "treeDigest"]);
5977
+ comparePrecondition("patchId", preconditions?.patchId, envelope.patch.patchId, ["patch", "patchId"]);
5978
+ comparePrecondition("patchDigest", preconditions?.patchDigest, envelope.patch.patchDigest, ["patch", "patchDigest"]);
5979
+ comparePrecondition("harnessFingerprint", preconditions?.harnessFingerprint, envelope.fingerprints.harness, ["fingerprints", "harness"]);
5980
+ comparePrecondition("policyFingerprint", preconditions?.policyFingerprint, envelope.fingerprints.policy, ["fingerprints", "policy"]);
5981
+ comparePrecondition("environmentFingerprint", preconditions?.environmentFingerprint, envelope.fingerprints.environment, ["fingerprints", "environment"]);
5982
+ comparePrecondition("evidenceDigest", preconditions?.evidenceDigest, envelope.evidence.evidenceDigest, ["evidence", "evidenceDigest"]);
5983
+ const passedChecks = envelope.evidence.checks.filter((check) => check.status === "passed").map((check) => check.checkId);
5984
+ const failedChecks = envelope.evidence.checks.filter((check) => check.status === "failed").map((check) => check.checkId);
5985
+ const skippedChecks = envelope.evidence.checks.filter((check) => check.status === "skipped").map((check) => check.checkId);
5986
+ const passedCheckSet = new Set(passedChecks);
5987
+ const requiredPassed = [...preconditions?.requiredPassedChecks ?? []].sort();
5988
+ const missingPassed = requiredPassed.filter((checkId) => !passedCheckSet.has(checkId));
5989
+ for (const checkId of missingPassed) {
5990
+ issues.push({
5991
+ code: "missing-passed-check",
5992
+ path: ["evidence", "checks"],
5993
+ message: `Required passed check is not recorded: ${checkId}.`,
5994
+ expected: checkId
5995
+ });
5996
+ }
5997
+ const expiredApprovals = new Set(expiredApprovalIds);
5998
+ const activeApprovals = (envelope.approvals ?? []).filter((approval) => !expiredApprovals.has(approval.approvalId));
5999
+ const approvalDigests = new Set(activeApprovals.map((approval) => approval.approvalDigest));
6000
+ for (const requiredDigest of preconditions?.requiredApprovalDigests ?? []) {
6001
+ if (!approvalDigests.has(requiredDigest)) {
6002
+ issues.push({
6003
+ code: "missing-approval-digest",
6004
+ path: ["approvals"],
6005
+ message: `Required active approval digest is not recorded: ${requiredDigest}.`,
6006
+ expected: requiredDigest
6007
+ });
6008
+ }
6009
+ }
6010
+ const approvedScopes = new Set(activeApprovals.flatMap((approval) => approval.scopes));
6011
+ const requiredScopes = [...preconditions?.requiredApprovalScopes ?? []].sort();
6012
+ const missingScopes = requiredScopes.filter((scope) => !approvedScopes.has(scope));
6013
+ for (const scope of missingScopes) {
6014
+ issues.push({
6015
+ code: "missing-approval-scope",
6016
+ path: ["approvals"],
6017
+ message: `Required active approval scope is not recorded: ${scope}.`,
6018
+ expected: scope
6019
+ });
6020
+ }
6021
+ const attestationDigests = new Set((envelope.externalAttestations ?? []).map((attestation) => attestation.bundleDigest));
6022
+ const requiredAttestationDigests = [...preconditions?.requiredAttestationDigests ?? []].sort();
6023
+ const missingAttestationDigests = requiredAttestationDigests.filter((digest3) => !attestationDigests.has(digest3));
6024
+ for (const requiredDigest of missingAttestationDigests) {
6025
+ issues.push({
6026
+ code: "missing-attestation-digest",
6027
+ path: ["externalAttestations"],
6028
+ message: `Required external attestation digest is not referenced: ${requiredDigest}.`,
6029
+ expected: requiredDigest
6030
+ });
6031
+ }
6032
+ return {
6033
+ valid: issues.length === 0,
6034
+ verificationScope: "integrity-expiration-and-preconditions-only",
6035
+ envelope,
6036
+ issues,
6037
+ integrity: { envelopeDigestValid, evidenceDigestValid },
6038
+ expiration: { checkedAt, envelopeNotYetValid, envelopeExpired, expiredApprovalIds },
6039
+ checks: {
6040
+ recorded: envelope.evidence.checks.length,
6041
+ passed: passedChecks,
6042
+ failed: failedChecks,
6043
+ skipped: skippedChecks,
6044
+ requiredPassed,
6045
+ missingPassed
6046
+ },
6047
+ approvals: {
6048
+ recorded: envelope.approvals?.length ?? 0,
6049
+ requiredScopes,
6050
+ missingScopes,
6051
+ authenticity: "not-verified"
6052
+ },
6053
+ externalAttestations: {
6054
+ referenced: envelope.externalAttestations?.length ?? 0,
6055
+ requiredDigests: requiredAttestationDigests,
6056
+ missingDigests: missingAttestationDigests,
6057
+ authenticity: "not-verified"
6058
+ }
6059
+ };
6060
+ };
6061
+
4903
6062
  // src/cli.ts
4904
6063
  var HARNESS_RESUME_METADATA_KEY = "zhivexHarnessResume";
4905
6064
  var HARNESS_RESUME_METADATA_SCHEMA_VERSION = 1;
@@ -4909,9 +6068,11 @@ var CLI_EXIT_CODES = {
4909
6068
  usageError: 2,
4910
6069
  doctorFailed: 3
4911
6070
  };
4912
- var COMMANDS = new Set(["run", "review", "chat", "providers", "doctor", "resume", "runs", "sessions", "help", "version"]);
6071
+ var COMMANDS = new Set(["run", "review", "chat", "providers", "doctor", "resume", "runs", "sessions", "changes", "help", "version"]);
4913
6072
  var RUNS_COMMANDS = new Set(["list", "inspect", "cancel", "cleanup", "export"]);
4914
6073
  var SESSIONS_COMMANDS = new Set(["list", "inspect", "rename", "fork", "archive"]);
6074
+ var CHANGES_COMMANDS = new Set(["create", "verify"]);
6075
+ var CLI_TIMESTAMP_SCHEMA = z5.iso.datetime({ precision: 3 });
4915
6076
  var RUN_STATUSES = new Set([
4916
6077
  "queued",
4917
6078
  "running",
@@ -5131,6 +6292,23 @@ var parseCliArgs = (argv) => {
5131
6292
  options.mcpConfigPath = optionValue(argv, index, argument);
5132
6293
  index += 1;
5133
6294
  break;
6295
+ case "--patch":
6296
+ options.patchPath = optionValue(argv, index, argument);
6297
+ index += 1;
6298
+ break;
6299
+ case "--preconditions":
6300
+ options.preconditionsPath = optionValue(argv, index, argument);
6301
+ index += 1;
6302
+ break;
6303
+ case "--now": {
6304
+ const value = optionValue(argv, index, argument);
6305
+ if (!CLI_TIMESTAMP_SCHEMA.safeParse(value).success) {
6306
+ throw new CliUsageError("--now must be a millisecond-precision ISO-8601 UTC timestamp.");
6307
+ }
6308
+ options.verificationTime = value;
6309
+ index += 1;
6310
+ break;
6311
+ }
5134
6312
  case "--execution":
5135
6313
  options.executionBackend = optionValue(argv, index, argument);
5136
6314
  index += 1;
@@ -5423,6 +6601,9 @@ var parseCliArgs = (argv) => {
5423
6601
  if (options.jsonl && options.command !== "run" && options.command !== "resume") {
5424
6602
  throw new CliUsageError("--jsonl is supported by run and resume.");
5425
6603
  }
6604
+ if (options.command !== "changes" && (options.patchPath || options.preconditionsPath || options.verificationTime)) {
6605
+ throw new CliUsageError("--patch, --preconditions, and --now are supported only by changes.");
6606
+ }
5426
6607
  if (options.command === "resume") {
5427
6608
  const runId = positional.shift();
5428
6609
  if (runId) {
@@ -5472,6 +6653,26 @@ var parseCliArgs = (argv) => {
5472
6653
  if (positional.length > 0) {
5473
6654
  throw new CliUsageError(`sessions ${sessionsCommand} received unexpected positional arguments.`);
5474
6655
  }
6656
+ } else if (options.command === "changes") {
6657
+ const changesCommand = positional.shift();
6658
+ if (!changesCommand || !CHANGES_COMMANDS.has(changesCommand)) {
6659
+ throw new CliUsageError("changes requires one of: create, verify.");
6660
+ }
6661
+ options.changesCommand = changesCommand;
6662
+ const artifactPath = positional.shift();
6663
+ if (!artifactPath) {
6664
+ throw new CliUsageError(`changes ${changesCommand} requires an input JSON file.`);
6665
+ }
6666
+ options.artifactPath = artifactPath;
6667
+ if (!options.patchPath) {
6668
+ throw new CliUsageError(`changes ${changesCommand} requires --patch <file>.`);
6669
+ }
6670
+ if (changesCommand === "create" && (options.preconditionsPath || options.verificationTime)) {
6671
+ throw new CliUsageError("--preconditions and --now are only supported by changes verify.");
6672
+ }
6673
+ if (positional.length > 0) {
6674
+ throw new CliUsageError(`changes ${changesCommand} received unexpected positional arguments.`);
6675
+ }
5475
6676
  } else if (options.command === "run" || options.command === "review") {
5476
6677
  const prompt = positional.join(" ").trim();
5477
6678
  if (prompt) {
@@ -5494,6 +6695,8 @@ Usage:
5494
6695
  zhx resume [options] <runId> --approve|--deny
5495
6696
  zhx runs list [--status <status>] [--limit <n>] [--json]
5496
6697
  zhx sessions list|inspect|rename|fork|archive
6698
+ zhx changes create <input.json> --patch <artifact>
6699
+ zhx changes verify <envelope.json> --patch <artifact> [--preconditions <file>]
5497
6700
 
5498
6701
  The long command zhivex-harness remains supported for compatibility.
5499
6702
 
@@ -5506,6 +6709,9 @@ Options:
5506
6709
  --workspace <path> Target workspace (default: cwd)
5507
6710
  --state-dir <path> Durable run-state directory
5508
6711
  --mcp-config <path> Declarative governed MCP JSON configuration
6712
+ --patch <path> Exact patch/artifact bytes bound to a change envelope
6713
+ --preconditions <path> Verification preconditions JSON for changes verify
6714
+ --now <ISO-8601 UTC> Explicit millisecond verification time (default: current time)
5509
6715
  --execution <none|oci> Enforced execution backend (default: none)
5510
6716
  --oci-runtime <docker|podman> Local OCI runtime (default: docker)
5511
6717
  --oci-image <reference> Preloaded immutable-capable OCI image
@@ -6304,7 +7510,7 @@ var inspectWorkspace = async (workspace) => {
6304
7510
  if (!workspaceStat.isDirectory()) {
6305
7511
  return diagnostic("workspace", "fail", "Workspace is not a directory.", { path: workspace });
6306
7512
  }
6307
- await access(workspace, fsConstants.R_OK);
7513
+ await access(workspace, fsConstants2.R_OK);
6308
7514
  return diagnostic("workspace", "pass", "Workspace exists and is readable.", { path: workspace });
6309
7515
  } catch (error) {
6310
7516
  const code = error.code ?? "UNKNOWN";
@@ -6438,7 +7644,7 @@ var inspectStateDirectory = async (workspace, stateDirectory) => {
6438
7644
  writable: false
6439
7645
  });
6440
7646
  }
6441
- await access(existing.path, fsConstants.R_OK | fsConstants.W_OK);
7647
+ await access(existing.path, fsConstants2.R_OK | fsConstants2.W_OK);
6442
7648
  return diagnostic("state-directory", "pass", "State directory is safe and writable.", {
6443
7649
  path: stateDirectory,
6444
7650
  exists: existing.path === stateDirectory,
@@ -6840,6 +8046,90 @@ var updateIndexedSessionRun = async (config, runId, status) => {
6840
8046
  }
6841
8047
  };
6842
8048
  var cliExitCodeForError = (error) => error instanceof CliUsageError ? CLI_EXIT_CODES.usageError : CLI_EXIT_CODES.runtimeError;
8049
+ var MAX_CHANGE_ENVELOPE_JSON_BYTES = 4 * 1024 * 1024;
8050
+ var MAX_CHANGE_ARTIFACT_BYTES = 64 * 1024 * 1024;
8051
+ var readStableCliArtifact = async (filePath, maxBytes) => {
8052
+ const absolute = path9.resolve(process.cwd(), filePath);
8053
+ try {
8054
+ const before = await lstat7(absolute);
8055
+ if (before.isSymbolicLink() || !before.isFile()) {
8056
+ throw new CliUsageError(`Change artifact must be a regular non-symlink file: ${filePath}`);
8057
+ }
8058
+ if (before.size > maxBytes) {
8059
+ throw new CliUsageError(`Change artifact exceeds the ${maxBytes}-byte limit: ${filePath}`);
8060
+ }
8061
+ const contents = await readFile4(absolute);
8062
+ const after = await lstat7(absolute);
8063
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
8064
+ throw new CliUsageError(`Change artifact changed while it was being read: ${filePath}`);
8065
+ }
8066
+ return contents;
8067
+ } catch (error) {
8068
+ if (error instanceof CliUsageError)
8069
+ throw error;
8070
+ const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
8071
+ if (typeof code === "string") {
8072
+ throw new CliUsageError(`Change artifact cannot be read (${code}): ${filePath}`);
8073
+ }
8074
+ throw error;
8075
+ }
8076
+ };
8077
+ var readCliJsonArtifact = async (filePath) => {
8078
+ const contents = await readStableCliArtifact(filePath, MAX_CHANGE_ENVELOPE_JSON_BYTES);
8079
+ try {
8080
+ return JSON.parse(contents.toString("utf8"));
8081
+ } catch {
8082
+ throw new CliUsageError(`Change artifact is not valid JSON: ${filePath}`);
8083
+ }
8084
+ };
8085
+ var objectValue = (value, label) => {
8086
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
8087
+ throw new CliUsageError(`${label} must be a JSON object.`);
8088
+ }
8089
+ return value;
8090
+ };
8091
+ var manageChanges = async (options) => {
8092
+ if (!options.changesCommand || !options.artifactPath || !options.patchPath) {
8093
+ throw new CliUsageError("Incomplete changes command.");
8094
+ }
8095
+ const patchBytes = await readStableCliArtifact(options.patchPath, MAX_CHANGE_ARTIFACT_BYTES);
8096
+ const patchDigest = digestChangeEnvelopeArtifact(patchBytes);
8097
+ if (options.changesCommand === "create") {
8098
+ const input = objectValue(await readCliJsonArtifact(options.artifactPath), "Change envelope input");
8099
+ const patchInput = objectValue(input.patch, "Change envelope patch");
8100
+ if (patchInput.patchDigest !== undefined && patchInput.patchDigest !== patchDigest) {
8101
+ throw new CliUsageError("The declared patch.patchDigest does not match the exact --patch bytes.");
8102
+ }
8103
+ const envelope2 = createChangeEnvelope({
8104
+ ...input,
8105
+ patch: { ...patchInput, patchDigest }
8106
+ });
8107
+ process.stdout.write(`${canonicalizeChangeEnvelope(envelope2)}
8108
+ `);
8109
+ return;
8110
+ }
8111
+ const envelope = await readCliJsonArtifact(options.artifactPath);
8112
+ const suppliedPreconditions = options.preconditionsPath ? objectValue(await readCliJsonArtifact(options.preconditionsPath), "Change envelope preconditions") : {};
8113
+ if (suppliedPreconditions.patchDigest !== undefined && suppliedPreconditions.patchDigest !== patchDigest) {
8114
+ throw new CliUsageError("The precondition patchDigest does not match the exact --patch bytes.");
8115
+ }
8116
+ const preconditions = changeEnvelopePreconditionsSchema.parse({
8117
+ ...suppliedPreconditions,
8118
+ patchDigest
8119
+ });
8120
+ const result = verifyChangeEnvelope(envelope, {
8121
+ ...options.verificationTime ? { now: options.verificationTime } : {},
8122
+ preconditions
8123
+ });
8124
+ process.stdout.write(`${JSON.stringify({
8125
+ schemaVersion: 1,
8126
+ kind: "change-envelope-verification",
8127
+ ...result
8128
+ })}
8129
+ `);
8130
+ if (!result.valid)
8131
+ process.exitCode = CLI_EXIT_CODES.runtimeError;
8132
+ };
6843
8133
  var main = async (argv = process.argv.slice(2)) => {
6844
8134
  const options = parseCliArgs(argv);
6845
8135
  switch (options.command) {
@@ -6869,6 +8159,9 @@ var main = async (argv = process.argv.slice(2)) => {
6869
8159
  case "sessions":
6870
8160
  await manageSessions(options);
6871
8161
  return;
8162
+ case "changes":
8163
+ await manageChanges(options);
8164
+ return;
6872
8165
  case "run":
6873
8166
  if (options.implicitCommand && !options.prompt && process.stdin.isTTY && process.stdout.isTTY) {
6874
8167
  await chat(options);