@the-open-engine/zeroshot 6.24.0 → 6.25.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.
Files changed (41) hide show
  1. package/cli/index.js +37 -2
  2. package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
  3. package/lib/agent-cli-provider/adapters/omp.js +37 -11
  4. package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
  5. package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -1
  6. package/lib/agent-cli-provider/omp-rpc-driver.js +27 -2
  7. package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -1
  8. package/lib/agent-cli-provider/omp-rpc-session.js +3 -3
  9. package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -1
  10. package/lib/agent-cli-provider/provider-registry.d.ts +1 -1
  11. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  12. package/lib/agent-cli-provider/provider-registry.js +7 -1
  13. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  14. package/lib/agent-cli-provider/types.d.ts +2 -0
  15. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  16. package/lib/agent-cli-provider/types.js.map +1 -1
  17. package/package.json +1 -1
  18. package/src/agent/agent-lifecycle.js +66 -2
  19. package/src/agent/agent-task-executor.js +72 -3
  20. package/src/agent/provider-session.js +111 -2
  21. package/src/agent-cli-provider/adapters/omp.ts +41 -11
  22. package/src/agent-cli-provider/omp-rpc-driver.ts +31 -3
  23. package/src/agent-cli-provider/omp-rpc-session.ts +3 -3
  24. package/src/agent-cli-provider/provider-registry.ts +7 -1
  25. package/src/agent-cli-provider/types.ts +4 -0
  26. package/src/omp-blob-root.js +110 -0
  27. package/src/omp-config-overlay.js +9 -1
  28. package/src/omp-execution-fingerprint.js +62 -0
  29. package/src/omp-session-limits.js +17 -0
  30. package/src/omp-session-partition.js +297 -0
  31. package/src/omp-session-verifier.js +576 -0
  32. package/task-lib/commands/clean.js +23 -0
  33. package/task-lib/commands/resume.js +42 -0
  34. package/task-lib/commands/run.js +65 -0
  35. package/task-lib/omp-session-cleanup.js +160 -0
  36. package/task-lib/omp-session-ownership-schema.js +262 -0
  37. package/task-lib/omp-session-ownership.js +332 -0
  38. package/task-lib/omp-storage-root.js +35 -0
  39. package/task-lib/rpc-watcher.js +332 -2
  40. package/task-lib/runner.js +195 -4
  41. package/task-lib/store.js +42 -7
@@ -0,0 +1,576 @@
1
+ // Two-phase lazy-file verification for OMP session partitions (issue #866).
2
+ //
3
+ // Partition contract (Zeroshot-owned): a session partition is `<storageRoot>/omp-sessions/<uuid>/`,
4
+ // passed to OMP as `--session-dir`. OMP writes `<fileSafeTimestamp>_<sessionId>.jsonl` directly
5
+ // inside it and, per `packages/coding-agent/src/session/session-manager.ts`
6
+ // (`artifactsDirectoryFor`), keeps that session's artifact tree in the sibling directory whose name
7
+ // is the session file's name minus the `.jsonl` suffix. Zeroshot verifies the whole partition
8
+ // subtree, which is a superset of that pair and therefore also catches anything unexpected.
9
+ //
10
+ // CAS blobs are NOT part of the partition. OMP externalizes large payloads to a *shared*,
11
+ // machine-wide content-addressed store (`blob-store.ts`, rooted at `pi-utils::getBlobsDir()` —
12
+ // `~/.omp/agent/blobs` modulo OMP's config-root/profile/XDG semantics, mirrored in
13
+ // src/omp-blob-root.js) and leaves a *nested* `blob:sha256:<64-lower-hex>` reference string inside
14
+ // the session JSONL records. So verification parses the JSONL, collects canonical nested refs, and
15
+ // checks the referenced blobs at that real shared root. Nothing here ever writes to or deletes
16
+ // from it (see src/omp-session-partition.js, which refuses any path resolving inside it).
17
+ //
18
+ // Every filesystem check below is descriptor-pinned: a path is opened once with O_NOFOLLOW (plus
19
+ // O_DIRECTORY for directories) and every subsequent type/owner/link/size/identity check and every
20
+ // byte read comes from `fstat`/`read` on that same descriptor. There is no lstat -> open -> stat
21
+ // pathname sequence and no re-open of a mutable name after validation, so the substituted-file
22
+ // race (CodeQL js/file-system-race) cannot apply: the object we checked is the object we read.
23
+ // Directory listings additionally re-pin and compare identity around `readdir`, which reports a
24
+ // substitution as a verification failure rather than silently traversing the replacement.
25
+ const { createHash } = require('crypto');
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const { OMP_SESSION_LIMITS } = require('./omp-session-limits');
29
+ const { resolveOmpBlobsDir } = require('./omp-blob-root');
30
+
31
+ const BLOB_REF_PREFIX = 'blob:sha256:';
32
+ const CANONICAL_BLOB_REF_PATTERN = /^blob:sha256:[a-f0-9]{64}$/u;
33
+ const SESSION_FILE_NAME_PATTERN = /^[^/\\]+\.jsonl$/u;
34
+ const NEWLINE = 0x0a;
35
+ const STREAM_CHUNK_BYTES = 1 << 16;
36
+
37
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
38
+ const O_DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
39
+ // O_NONBLOCK is what keeps "reject sockets/devices/FIFOs" from being a liveness hole: opening a
40
+ // FIFO read-only blocks until a writer appears, so a partition containing one would otherwise hang
41
+ // verification forever instead of failing it. With O_NONBLOCK the open returns immediately and the
42
+ // fstat below rejects the non-regular type. No effect on regular files or directories.
43
+ const O_NONBLOCK = fs.constants.O_NONBLOCK ?? 0;
44
+
45
+ class OmpSessionVerificationError extends Error {
46
+ constructor(code, message) {
47
+ super(message);
48
+ this.name = 'OmpSessionVerificationError';
49
+ this.code = code;
50
+ }
51
+ }
52
+
53
+ function fail(code, message) {
54
+ throw new OmpSessionVerificationError(code, message);
55
+ }
56
+
57
+ function identityOf(stat) {
58
+ return { device: String(stat.dev), inode: String(stat.ino) };
59
+ }
60
+
61
+ function sameIdentity(a, b) {
62
+ return Boolean(a) && Boolean(b) && a.device === b.device && a.inode === b.inode;
63
+ }
64
+
65
+ function isSymlink(targetPath) {
66
+ try {
67
+ return fs.lstatSync(targetPath).isSymbolicLink();
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ function assertOwnerHeld(stat, targetPath) {
74
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
75
+ fail('not-owner-held', `${targetPath} is not owned by the current user.`);
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Open `targetPath` without ever following a final symlink and return the descriptor together with
81
+ * the `fstat` that describes *that descriptor* — never a second pathname lookup. Callers must
82
+ * `closeSync(fd)`.
83
+ */
84
+ function openPinned(targetPath, { directory = false, missingCode, notTypeCode }) {
85
+ let fd;
86
+ try {
87
+ fd = fs.openSync(
88
+ targetPath,
89
+ fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK | (directory ? O_DIRECTORY : 0)
90
+ );
91
+ } catch (error) {
92
+ // Diagnostic classification only — the open already failed, so nothing proceeds on the basis
93
+ // of this lstat. It exists because O_NOFOLLOW|O_DIRECTORY reports a symlink-to-directory as
94
+ // ENOTDIR on Linux, and "is a symlink" is a far more actionable message than "is not a
95
+ // directory" for the operator reading a refused resume.
96
+ if (error.code === 'ELOOP' || error.code === 'EMLINK' || isSymlink(targetPath)) {
97
+ fail('symlink-rejected', `${targetPath} is a symlink.`);
98
+ }
99
+ if (error.code === 'ENOTDIR') {
100
+ fail(notTypeCode, `${targetPath} is not a directory.`);
101
+ }
102
+ if (error.code === 'EISDIR') {
103
+ fail(notTypeCode, `${targetPath} is a directory, not a regular file.`);
104
+ }
105
+ fail(missingCode, `${targetPath} could not be opened: ${error.message}`);
106
+ }
107
+ let stat;
108
+ try {
109
+ stat = fs.fstatSync(fd);
110
+ } catch (error) {
111
+ fs.closeSync(fd);
112
+ fail(missingCode, `${targetPath} could not be stat'ed from its descriptor: ${error.message}`);
113
+ }
114
+ // O_DIRECTORY/O_NOFOLLOW already excluded the wrong-type and symlink cases on platforms that
115
+ // implement them; re-assert from the descriptor so a platform lacking the flags still fails
116
+ // closed rather than silently accepting a socket/device/FIFO.
117
+ if (directory ? !stat.isDirectory() : !stat.isFile()) {
118
+ fs.closeSync(fd);
119
+ fail(
120
+ notTypeCode,
121
+ `${targetPath} is not a ${directory ? 'real directory' : 'regular file'} (mode ${stat.mode.toString(8)}).`
122
+ );
123
+ }
124
+ try {
125
+ assertOwnerHeld(stat, targetPath);
126
+ } catch (error) {
127
+ fs.closeSync(fd);
128
+ throw error;
129
+ }
130
+ return { fd, stat };
131
+ }
132
+
133
+ function withPinned(targetPath, options, body) {
134
+ const pinned = openPinned(targetPath, options);
135
+ try {
136
+ return body(pinned);
137
+ } finally {
138
+ fs.closeSync(pinned.fd);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * List a directory whose identity is already pinned, re-pinning afterwards and comparing identity.
144
+ * `readdir` has no descriptor-taking form in Node, so this is the one unavoidable name lookup —
145
+ * bracketing it with the identity comparison turns a substitution into a hard verification failure
146
+ * instead of a silent traversal of the replacement tree.
147
+ */
148
+ function readdirPinned(dirPath, expectedIdentity) {
149
+ let children;
150
+ try {
151
+ children = fs.readdirSync(dirPath, { withFileTypes: true });
152
+ } catch (error) {
153
+ fail('artifact-read-failed', `${dirPath} could not be listed: ${error.message}`);
154
+ }
155
+ withPinned(
156
+ dirPath,
157
+ { directory: true, missingCode: 'partition-missing', notTypeCode: 'not-a-directory' },
158
+ ({ stat }) => {
159
+ if (!sameIdentity(identityOf(stat), expectedIdentity)) {
160
+ fail('identity-substituted', `${dirPath} was substituted while it was being listed.`);
161
+ }
162
+ }
163
+ );
164
+ return children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
165
+ }
166
+
167
+ function assertDirectChildName(name, code = 'invalid-relative-path') {
168
+ if (
169
+ typeof name !== 'string' ||
170
+ name.length === 0 ||
171
+ name.includes('/') ||
172
+ name.includes('\\') ||
173
+ name === '.' ||
174
+ name === '..'
175
+ ) {
176
+ fail(code, `Invalid direct-child name: ${JSON.stringify(name)}`);
177
+ }
178
+ }
179
+
180
+ function assertSessionFileName(name) {
181
+ assertDirectChildName(name, 'invalid-session-file-name');
182
+ if (!SESSION_FILE_NAME_PATTERN.test(name)) {
183
+ fail(
184
+ 'invalid-session-file-name',
185
+ `Session file name must be a direct-child *.jsonl basename: ${JSON.stringify(name)}`
186
+ );
187
+ }
188
+ }
189
+
190
+ /** Read the whole of an already-pinned descriptor in fixed-size chunks, never allocating
191
+ * proportional to the file size, enforcing `maxBytes` against bytes *observed while reading* (not
192
+ * just the descriptor's declared size) and feeding each chunk to `onChunk`. */
193
+ function streamDescriptor(fd, maxBytes, onChunk, overflow) {
194
+ const buffer = Buffer.allocUnsafe(STREAM_CHUNK_BYTES);
195
+ let observed = 0;
196
+ let position = 0;
197
+ for (;;) {
198
+ const read = fs.readSync(fd, buffer, 0, STREAM_CHUNK_BYTES, position);
199
+ if (read === 0) break;
200
+ position += read;
201
+ observed += read;
202
+ if (observed > maxBytes) overflow(observed);
203
+ onChunk(buffer.subarray(0, read));
204
+ }
205
+ return observed;
206
+ }
207
+
208
+ function collectCanonicalBlobRefs(value, sink, limits) {
209
+ if (typeof value === 'string') {
210
+ if (!value.startsWith(BLOB_REF_PREFIX)) return;
211
+ if (!CANONICAL_BLOB_REF_PATTERN.test(value)) {
212
+ // OMP's parseBlobRef only warns and falls back to treating a malformed ref as literal data
213
+ // (blob-store.ts). Zeroshot cannot: a continuation whose externalized payload is
214
+ // unaddressable is not a continuation we can prove, so this fails closed.
215
+ fail('blob-reference-noncanonical', `Non-canonical blob reference ${JSON.stringify(value)}.`);
216
+ }
217
+ sink.add(value);
218
+ if (sink.size > limits.maxBlobReferences) {
219
+ fail('blob-references-exceeded', 'Session exceeds maxBlobReferences.');
220
+ }
221
+ return;
222
+ }
223
+ if (Array.isArray(value)) {
224
+ for (const item of value) collectCanonicalBlobRefs(item, sink, limits);
225
+ return;
226
+ }
227
+ if (value !== null && typeof value === 'object') {
228
+ for (const key of Object.keys(value)) collectCanonicalBlobRefs(value[key], sink, limits);
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Stream the session JSONL from its pinned descriptor: bound bytes/records, hash the raw content,
234
+ * parse each record, verify the session header record, and collect every canonical nested blob
235
+ * reference. A record is buffered only until its terminating newline; the aggregate byte bound
236
+ * already caps how large any single record can get, so this never exceeds the declared session
237
+ * budget.
238
+ */
239
+ function streamSessionJsonl(fd, describePath, limits) {
240
+ const hash = createHash('sha256');
241
+ const blobRefs = new Set();
242
+ let records = 0;
243
+ let header = null;
244
+ let pending = [];
245
+ let pendingBytes = 0;
246
+
247
+ function consumeRecord() {
248
+ records += 1;
249
+ if (records > limits.maxSessionRecords) {
250
+ fail('session-records-exceeded', `${describePath} exceeds maxSessionRecords.`);
251
+ }
252
+ const line = Buffer.concat(pending, pendingBytes).toString('utf8');
253
+ pending = [];
254
+ pendingBytes = 0;
255
+ if (line.trim().length === 0) return;
256
+ let parsed;
257
+ try {
258
+ parsed = JSON.parse(line);
259
+ } catch (error) {
260
+ fail(
261
+ 'session-record-unparseable',
262
+ `${describePath} record ${records} is not valid JSON: ${error.message}`
263
+ );
264
+ }
265
+ if (header === null) header = parsed;
266
+ collectCanonicalBlobRefs(parsed, blobRefs, limits);
267
+ }
268
+
269
+ const bytes = streamDescriptor(
270
+ fd,
271
+ limits.maxSessionBytes,
272
+ (chunk) => {
273
+ let start = 0;
274
+ for (let i = 0; i < chunk.length; i += 1) {
275
+ if (chunk[i] !== NEWLINE) continue;
276
+ pending.push(Buffer.from(chunk.subarray(start, i)));
277
+ pendingBytes += i - start;
278
+ start = i + 1;
279
+ consumeRecord();
280
+ }
281
+ if (start < chunk.length) {
282
+ pending.push(Buffer.from(chunk.subarray(start)));
283
+ pendingBytes += chunk.length - start;
284
+ }
285
+ hash.update(chunk);
286
+ },
287
+ () => fail('session-bytes-exceeded', `${describePath} observed bytes exceed maxSessionBytes.`)
288
+ );
289
+ if (pendingBytes > 0) consumeRecord(); // trailing unterminated record
290
+
291
+ return {
292
+ bytes,
293
+ records,
294
+ digest: `sha256:${hash.digest('hex')}`,
295
+ header,
296
+ blobRefs: [...blobRefs].sort(),
297
+ };
298
+ }
299
+
300
+ /** The session's first record is OMP's session header (`{type:"session", version, id, cwd, ...}` —
301
+ * session-manager.ts `#resetToNewSession`). Its `id` is the authoritative session identity written
302
+ * to disk and its `cwd` is the workspace the session belongs to. */
303
+ function parseSessionHeader(header, describePath) {
304
+ if (!header || typeof header !== 'object' || Array.isArray(header)) {
305
+ fail('session-header-missing', `${describePath} has no session header record.`);
306
+ }
307
+ if (header.type !== 'session') {
308
+ fail(
309
+ 'session-header-invalid',
310
+ `${describePath} first record is type ${JSON.stringify(header.type)}, not "session".`
311
+ );
312
+ }
313
+ if (typeof header.id !== 'string' || header.id.length === 0) {
314
+ fail('session-header-invalid', `${describePath} session header has no id.`);
315
+ }
316
+ return {
317
+ sessionId: header.id,
318
+ cwd: typeof header.cwd === 'string' && header.cwd.length > 0 ? path.resolve(header.cwd) : null,
319
+ version: header.version ?? null,
320
+ };
321
+ }
322
+
323
+ /** Verify one referenced blob at the *shared* OMP CAS root. Unlike partition files a blob may
324
+ * legitimately carry more than one hard link: `blob-store.ts#ensureDisplayPath` hardlinks
325
+ * `<hash>` to a typed `<hash>.<ext>` sidecar for OS image openers. Content is what is
326
+ * authoritative here, and it is checked against the digest the reference names. */
327
+ function verifyBlobReference(ref, blobsDir, limits) {
328
+ const hex = ref.slice(BLOB_REF_PREFIX.length);
329
+ const blobPath = path.join(blobsDir, hex);
330
+ return withPinned(
331
+ blobPath,
332
+ { missingCode: 'blob-missing', notTypeCode: 'blob-not-regular' },
333
+ ({ fd, stat }) => {
334
+ if (stat.size > limits.maxReferencedBlobBytes) {
335
+ fail('blob-bytes-exceeded', `Referenced blob ${ref} exceeds maxReferencedBlobBytes.`);
336
+ }
337
+ const hash = createHash('sha256');
338
+ streamDescriptor(
339
+ fd,
340
+ limits.maxReferencedBlobBytes,
341
+ (chunk) => hash.update(chunk),
342
+ () => fail('blob-bytes-exceeded', `Referenced blob ${ref} exceeds maxReferencedBlobBytes.`)
343
+ );
344
+ if (hash.digest('hex') !== hex) {
345
+ fail('blob-digest-mismatch', `Referenced blob ${ref} content does not match its digest.`);
346
+ }
347
+ return blobPath;
348
+ }
349
+ );
350
+ }
351
+
352
+ function verifyBlobReferences(blobRefs, limits, blobsDirOptions) {
353
+ if (blobRefs.length === 0) return { blobsDir: null, verified: [] };
354
+ if (blobRefs.length > limits.maxBlobReferences) {
355
+ fail('blob-references-exceeded', 'Session exceeds maxBlobReferences.');
356
+ }
357
+ const blobsDir = resolveOmpBlobsDir(blobsDirOptions);
358
+ for (const ref of blobRefs) verifyBlobReference(ref, blobsDir, limits);
359
+ return { blobsDir, verified: blobRefs };
360
+ }
361
+
362
+ /** Walk the partition subtree (everything except the session file itself), descriptor-pinning
363
+ * every entry, and return the manifest entries in a deterministic order. */
364
+ function collectArtifactEntries(partitionPath, partitionIdentity, sessionFileName, limits) {
365
+ const entries = [];
366
+ const aggregate = { count: 0, bytes: 0 };
367
+
368
+ function visit(absDir, dirIdentity, relDir, depth) {
369
+ if (depth > limits.maxArtifactDepth) {
370
+ fail('artifact-depth-exceeded', `Artifact tree exceeds maxArtifactDepth at ${relDir || '.'}.`);
371
+ }
372
+ for (const child of readdirPinned(absDir, dirIdentity)) {
373
+ if (depth === 0 && child.name === sessionFileName) continue;
374
+ assertDirectChildName(child.name);
375
+ const absPath = path.join(absDir, child.name);
376
+ const relPath = relDir ? `${relDir}/${child.name}` : child.name;
377
+ if (Buffer.byteLength(relPath, 'utf8') > limits.maxRelativePathBytes) {
378
+ fail('relative-path-bytes-exceeded', `${relPath} exceeds maxRelativePathBytes.`);
379
+ }
380
+ aggregate.count += 1;
381
+ if (aggregate.count > limits.maxArtifactEntries) {
382
+ fail('artifact-entries-exceeded', 'Artifact tree exceeds maxArtifactEntries.');
383
+ }
384
+
385
+ if (child.isSymbolicLink()) {
386
+ fail('symlink-rejected', `${relPath} is a symlink.`);
387
+ }
388
+ if (child.isDirectory()) {
389
+ const childIdentity = withPinned(
390
+ absPath,
391
+ { directory: true, missingCode: 'artifact-missing', notTypeCode: 'not-a-directory' },
392
+ ({ stat }) => identityOf(stat)
393
+ );
394
+ entries.push({ relPath, type: 'dir', size: 0, contentDigest: '' });
395
+ visit(absPath, childIdentity, relPath, depth + 1);
396
+ continue;
397
+ }
398
+ if (!child.isFile()) {
399
+ fail(
400
+ 'not-a-regular-single-link-file',
401
+ `${relPath} is neither a regular file nor a directory.`
402
+ );
403
+ }
404
+
405
+ const entry = withPinned(
406
+ absPath,
407
+ { missingCode: 'artifact-missing', notTypeCode: 'not-a-regular-single-link-file' },
408
+ ({ fd, stat }) => {
409
+ if (stat.nlink > 1) {
410
+ fail('not-a-regular-single-link-file', `${relPath} has more than one hard link.`);
411
+ }
412
+ if (stat.size > limits.maxArtifactFileBytes) {
413
+ fail('artifact-file-bytes-exceeded', `${relPath} exceeds maxArtifactFileBytes.`);
414
+ }
415
+ const hash = createHash('sha256');
416
+ const observed = streamDescriptor(
417
+ fd,
418
+ limits.maxArtifactFileBytes,
419
+ (chunk) => hash.update(chunk),
420
+ () => fail('artifact-file-bytes-exceeded', `${relPath} exceeds maxArtifactFileBytes.`)
421
+ );
422
+ return {
423
+ relPath,
424
+ type: 'file',
425
+ size: observed,
426
+ contentDigest: `sha256:${hash.digest('hex')}`,
427
+ };
428
+ }
429
+ );
430
+
431
+ aggregate.bytes += entry.size;
432
+ if (aggregate.bytes > limits.maxArtifactAggregateBytes) {
433
+ fail('artifact-aggregate-bytes-exceeded', 'Artifact tree exceeds maxArtifactAggregateBytes.');
434
+ }
435
+ entries.push(entry);
436
+ }
437
+ }
438
+
439
+ visit(partitionPath, partitionIdentity, '', 0);
440
+ return entries;
441
+ }
442
+
443
+ function lengthPrefixed(value) {
444
+ const buf = Buffer.from(String(value), 'utf8');
445
+ const len = Buffer.alloc(4);
446
+ len.writeUInt32BE(buf.length, 0);
447
+ return Buffer.concat([len, buf]);
448
+ }
449
+
450
+ function hashManifestEntry(hash, { relPath, type, size, contentDigest }) {
451
+ hash.update(lengthPrefixed(relPath));
452
+ hash.update(lengthPrefixed(type));
453
+ hash.update(lengthPrefixed(size));
454
+ hash.update(lengthPrefixed(contentDigest || ''));
455
+ }
456
+
457
+ /**
458
+ * Full structural verification of a partition expected to already hold a session.
459
+ * `expectedPartitionIdentity` (when supplied) is compared against the pinned directory before
460
+ * anything inside it is read, so a partition directory swapped since it was recorded fails before
461
+ * its contents can influence the manifest.
462
+ *
463
+ * `limits` and `blobsDirOptions` are seams for tests to prove enforcement and to point at a
464
+ * fixture blob store — NOT configuration. No production caller passes either: the bounds are the
465
+ * pinned OMP_SESSION_LIMITS (a negotiable ceiling would let a hostile partition pick its own
466
+ * verification budget) and the blob root is whatever OMP itself would resolve.
467
+ */
468
+ function verifyPartitionContents(
469
+ partitionPath,
470
+ sessionFileName,
471
+ { expectedPartitionIdentity = null, limits = OMP_SESSION_LIMITS, blobsDirOptions } = {}
472
+ ) {
473
+ assertSessionFileName(sessionFileName);
474
+
475
+ const partitionIdentity = withPinned(
476
+ partitionPath,
477
+ { directory: true, missingCode: 'partition-missing', notTypeCode: 'not-a-directory' },
478
+ ({ stat }) => identityOf(stat)
479
+ );
480
+ if (expectedPartitionIdentity && !sameIdentity(partitionIdentity, expectedPartitionIdentity)) {
481
+ fail(
482
+ 'partition-identity-mismatch',
483
+ `${partitionPath} identity ${partitionIdentity.device}:${partitionIdentity.inode} does not match the recorded owner.`
484
+ );
485
+ }
486
+
487
+ const sessionFilePath = path.join(partitionPath, sessionFileName);
488
+ const session = withPinned(
489
+ sessionFilePath,
490
+ { missingCode: 'session-file-missing', notTypeCode: 'not-a-regular-file' },
491
+ ({ fd, stat }) => {
492
+ if (stat.nlink > 1) {
493
+ fail('hard-link-rejected', `${sessionFilePath} has more than one hard link.`);
494
+ }
495
+ if (stat.size > limits.maxSessionBytes) {
496
+ fail(
497
+ 'session-bytes-exceeded',
498
+ `${sessionFilePath} declared size ${stat.size} exceeds maxSessionBytes.`
499
+ );
500
+ }
501
+ return { identity: identityOf(stat), ...streamSessionJsonl(fd, sessionFilePath, limits) };
502
+ }
503
+ );
504
+
505
+ const header = parseSessionHeader(session.header, sessionFilePath);
506
+ const blobs = verifyBlobReferences(session.blobRefs, limits, blobsDirOptions);
507
+ const artifactEntries = collectArtifactEntries(
508
+ partitionPath,
509
+ partitionIdentity,
510
+ sessionFileName,
511
+ limits
512
+ );
513
+
514
+ const manifestHash = createHash('sha256');
515
+ hashManifestEntry(manifestHash, {
516
+ relPath: sessionFileName,
517
+ type: 'file',
518
+ size: session.bytes,
519
+ contentDigest: session.digest,
520
+ });
521
+ for (const entry of artifactEntries) hashManifestEntry(manifestHash, entry);
522
+
523
+ return {
524
+ partitionPath,
525
+ partitionIdentity,
526
+ sessionFilePath,
527
+ sessionFileName,
528
+ sessionFileIdentity: session.identity,
529
+ sessionBytes: session.bytes,
530
+ sessionRecords: session.records,
531
+ sessionHeader: header,
532
+ blobReferences: blobs.verified,
533
+ blobsDir: blobs.blobsDir,
534
+ artifactManifestDigest: `sha256:${manifestHash.digest('hex')}`,
535
+ };
536
+ }
537
+
538
+ /** Lightweight check at spawn/`ready` for a *fresh* partition: the partition path is a real,
539
+ * owner-held, descriptor-pinned directory whose identity still matches what was recorded (when
540
+ * known). It deliberately does not walk the tree or read the session file — neither exists yet at
541
+ * this point in a fresh session's lifecycle. */
542
+ function checkPartitionPathReady(partitionPath, { expectedPartitionIdentity = null } = {}) {
543
+ const identity = withPinned(
544
+ partitionPath,
545
+ { directory: true, missingCode: 'partition-missing', notTypeCode: 'not-a-directory' },
546
+ ({ stat }) => identityOf(stat)
547
+ );
548
+ if (expectedPartitionIdentity && !sameIdentity(identity, expectedPartitionIdentity)) {
549
+ fail(
550
+ 'partition-identity-mismatch',
551
+ `${partitionPath} identity does not match the recorded owner.`
552
+ );
553
+ }
554
+ return { partitionPath, partitionIdentity: identity };
555
+ }
556
+
557
+ /** Full verification of an existing (resume) partition: before spawn and again before prompt. */
558
+ function verifyExistingOmpPartition(partitionPath, sessionFileName, options = {}) {
559
+ return verifyPartitionContents(partitionPath, sessionFileName, options);
560
+ }
561
+
562
+ /** Full verification after terminal materialization of a fresh session, before its ownership
563
+ * record may be committed as resumable. */
564
+ function verifyFreshMaterialization(partitionPath, sessionFileName, options = {}) {
565
+ return verifyPartitionContents(partitionPath, sessionFileName, options);
566
+ }
567
+
568
+ module.exports = {
569
+ BLOB_REF_PREFIX,
570
+ CANONICAL_BLOB_REF_PATTERN,
571
+ OmpSessionVerificationError,
572
+ checkPartitionPathReady,
573
+ verifyExistingOmpPartition,
574
+ verifyFreshMaterialization,
575
+ verifyPartitionContents,
576
+ };
@@ -2,6 +2,18 @@ import { unlinkSync, existsSync } from 'fs';
2
2
  import chalk from 'chalk';
3
3
  import { loadTasks, saveTasks } from '../store.js';
4
4
  import { createCommandSpecCleanup } from '../command-spec-cleanup.js';
5
+ import { cleanupOmpSessionPartitionForTask } from '../omp-session-cleanup.js';
6
+
7
+ /**
8
+ * Delete a task's OMP session partition directory as part of removing its row. Every ownership
9
+ * state is cleaned here, including `provisional`: the row is going away, so leaving its partition
10
+ * behind would orphan a directory nothing can ever reclaim. The shared OMP CAS blob root is never
11
+ * touched. An unsafe/unresolvable path preserves the owner record — and therefore the whole task
12
+ * row, the same retry-safety contract commandCleanup already uses — with an actionable warning.
13
+ */
14
+ export function cleanUpOmpSessionPartition(task, warn) {
15
+ return cleanupOmpSessionPartitionForTask(task, warn);
16
+ }
5
17
 
6
18
  export function cleanTasks(options = {}) {
7
19
  const tasks = loadTasks();
@@ -36,6 +48,17 @@ export function cleanTasks(options = {}) {
36
48
  console.log(chalk.dim(`Removing ${toRemove.length} task(s)...\n`));
37
49
 
38
50
  for (const task of toRemove) {
51
+ if (
52
+ !cleanUpOmpSessionPartition(task, (message) =>
53
+ console.log(chalk.yellow(`Warning: ${message}`))
54
+ )
55
+ ) {
56
+ cleanupFailed = true;
57
+ console.log(
58
+ chalk.yellow(` Retained: ${task.id} [${task.status}] (OMP partition cleanup pending)`)
59
+ );
60
+ continue;
61
+ }
39
62
  if (task.commandCleanup) {
40
63
  if (task.status === 'running') {
41
64
  cleanupFailed = true;
@@ -2,14 +2,56 @@ import chalk from 'chalk';
2
2
  import { createRequire } from 'module';
3
3
  import { getTask } from '../store.js';
4
4
  import { spawnTask } from '../runner.js';
5
+ import { validateOwnedByTask } from '../omp-session-ownership-schema.js';
5
6
 
6
7
  const require = createRequire(import.meta.url);
7
8
  const { providerSupportsCapability } = require('../../lib/provider-names.js');
8
9
 
10
+ /**
11
+ * Manual standalone resume (`zeroshot task resume <id>`) reuses the *exact* persisted partition
12
+ * under the storage root recorded on the owner row, and asserts the complete committed tuple —
13
+ * including the partition identity, which the cluster path can only learn from the row itself.
14
+ * `state === 'committed'` is required: a provisional or cleanup-required record never durably
15
+ * proved a resumable session, so anything less fails closed to a fresh context.
16
+ */
17
+ function buildOmpResumeTaskOptions(task) {
18
+ const ownership = validateOwnedByTask(task.ompSessionOwnership, task.id);
19
+ if (!ownership) {
20
+ throw new Error(`Task ${task.id} has no valid OMP session ownership record; refusing resume.`);
21
+ }
22
+ if (ownership.state !== 'committed' || !ownership.session || !ownership.partitionIdentity) {
23
+ throw new Error(
24
+ `Task ${task.id} OMP session ownership is '${ownership.state}', not a committed resumable session; refusing resume.`
25
+ );
26
+ }
27
+ return {
28
+ cwd: ownership.canonicalWorkspace,
29
+ provider: task.provider,
30
+ storageRoot: ownership.storageRoot,
31
+ clusterId: ownership.owner.clusterId,
32
+ agentId: ownership.owner.agentId,
33
+ ompResume: {
34
+ priorOwnerTaskId: task.id,
35
+ partitionId: ownership.partitionId,
36
+ sessionFileName: ownership.session.fileName,
37
+ expectedSessionId: ownership.session.sessionId,
38
+ expectedPartitionIdentity: ownership.partitionIdentity,
39
+ expectedSessionFileIdentity: ownership.session.fileIdentity,
40
+ expectedArtifactManifestDigest: ownership.session.artifactManifestDigest,
41
+ expectedExecutionFingerprint: ownership.session.executionFingerprint,
42
+ expectedSelectedProvider: ownership.session.selectedProvider,
43
+ expectedSelectedModel: ownership.session.selectedModel,
44
+ },
45
+ };
46
+ }
47
+
9
48
  export function buildResumeTaskOptions(task) {
10
49
  if (!providerSupportsCapability(task.provider, 'sessionResume')) {
11
50
  throw new Error(`Provider ${task.provider} does not support safe session resume.`);
12
51
  }
52
+ if (task.provider === 'omp') {
53
+ return buildOmpResumeTaskOptions(task);
54
+ }
13
55
  if (
14
56
  task.requestedResumeSessionId &&
15
57
  (task.status !== 'completed' || task.resumeIdentityVerified !== true)