@the-open-engine/zeroshot 6.23.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.
- package/cli/index.js +37 -2
- package/docker/zeroshot-cluster/Dockerfile +7 -0
- package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
- package/lib/agent-cli-provider/adapters/omp.js +37 -11
- package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
- package/lib/agent-cli-provider/omp-release.d.ts +3 -0
- package/lib/agent-cli-provider/omp-release.d.ts.map +1 -1
- package/lib/agent-cli-provider/omp-release.js +20 -1
- package/lib/agent-cli-provider/omp-release.js.map +1 -1
- package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -1
- package/lib/agent-cli-provider/omp-rpc-driver.js +27 -2
- package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -1
- package/lib/agent-cli-provider/omp-rpc-session.js +3 -3
- package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -1
- package/lib/agent-cli-provider/provider-registry.d.ts +20 -8
- package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
- package/lib/agent-cli-provider/provider-registry.js +56 -9
- package/lib/agent-cli-provider/provider-registry.js.map +1 -1
- package/lib/agent-cli-provider/types.d.ts +2 -0
- package/lib/agent-cli-provider/types.d.ts.map +1 -1
- package/lib/agent-cli-provider/types.js.map +1 -1
- package/lib/docker-config.js +122 -5
- package/package.json +2 -2
- package/src/agent/agent-lifecycle.js +78 -3
- package/src/agent/agent-task-executor.js +72 -3
- package/src/agent/provider-session.js +112 -2
- package/src/agent-cli-provider/adapters/omp.ts +41 -11
- package/src/agent-cli-provider/omp-release.ts +26 -0
- package/src/agent-cli-provider/omp-rpc-driver.ts +31 -3
- package/src/agent-cli-provider/omp-rpc-session.ts +3 -3
- package/src/agent-cli-provider/provider-registry.ts +102 -20
- package/src/agent-cli-provider/types.ts +4 -0
- package/src/isolation-manager.js +535 -89
- package/src/omp-blob-root.js +110 -0
- package/src/omp-config-overlay.js +9 -1
- package/src/omp-execution-fingerprint.js +62 -0
- package/src/omp-session-limits.js +17 -0
- package/src/omp-session-partition.js +297 -0
- package/src/omp-session-verifier.js +576 -0
- package/src/orchestrator.js +11 -1
- package/src/preflight.js +15 -2
- package/task-lib/commands/clean.js +23 -0
- package/task-lib/commands/resume.js +42 -0
- package/task-lib/commands/run.js +65 -0
- package/task-lib/omp-session-cleanup.js +160 -0
- package/task-lib/omp-session-ownership-schema.js +262 -0
- package/task-lib/omp-session-ownership.js +332 -0
- package/task-lib/omp-storage-root.js +35 -0
- package/task-lib/rpc-watcher.js +332 -2
- package/task-lib/runner.js +195 -4
- 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
|
+
};
|
package/src/orchestrator.js
CHANGED
|
@@ -1836,12 +1836,18 @@ class Orchestrator {
|
|
|
1836
1836
|
throw new Error('Docker is not available. Install Docker to use --docker mode.');
|
|
1837
1837
|
}
|
|
1838
1838
|
|
|
1839
|
+
// Pre-effect platform probe: fail before any workspace/container side effect when the
|
|
1840
|
+
// Docker engine cannot run the provider's registry-owned platform (e.g. OMP's linux/amd64).
|
|
1841
|
+
const platform = IsolationManager.providerDockerPlatform(providerName);
|
|
1842
|
+
IsolationManager.assertPlatformSupported(platform);
|
|
1843
|
+
|
|
1839
1844
|
const baseImage = options.isolationImage || 'zeroshot-cluster-base';
|
|
1840
1845
|
const image = IsolationManager.imageForProvider(providerName, baseImage);
|
|
1841
1846
|
await IsolationManager.ensureImage(
|
|
1842
1847
|
image,
|
|
1843
1848
|
true,
|
|
1844
|
-
IsolationManager.providerBuildArgs(providerName)
|
|
1849
|
+
IsolationManager.providerBuildArgs(providerName, options.containerHome || '/home/node'),
|
|
1850
|
+
platform
|
|
1845
1851
|
);
|
|
1846
1852
|
isolationImage = image;
|
|
1847
1853
|
|
|
@@ -1856,6 +1862,7 @@ class Orchestrator {
|
|
|
1856
1862
|
mounts: options.mounts,
|
|
1857
1863
|
containerHome: options.containerHome,
|
|
1858
1864
|
provider: providerName,
|
|
1865
|
+
platform,
|
|
1859
1866
|
});
|
|
1860
1867
|
this._log(`[Orchestrator] Container created: ${containerId} (workDir: ${workDir})`);
|
|
1861
1868
|
} else if (options.worktree) {
|
|
@@ -2848,11 +2855,14 @@ class Orchestrator {
|
|
|
2848
2855
|
}
|
|
2849
2856
|
|
|
2850
2857
|
const providerName = this._resolveClusterProvider(cluster.config);
|
|
2858
|
+
const platform = IsolationManager.providerDockerPlatform(providerName);
|
|
2859
|
+
IsolationManager.assertPlatformSupported(platform);
|
|
2851
2860
|
const newContainerId = await cluster.isolation.manager.createContainer(clusterId, {
|
|
2852
2861
|
workDir,
|
|
2853
2862
|
image: cluster.isolation.image,
|
|
2854
2863
|
reuseExistingWorkspace: true,
|
|
2855
2864
|
provider: providerName,
|
|
2865
|
+
platform,
|
|
2856
2866
|
});
|
|
2857
2867
|
|
|
2858
2868
|
this._log(`[Orchestrator] New container created: ${newContainerId}`);
|
package/src/preflight.js
CHANGED
|
@@ -551,7 +551,7 @@ function validateGhRequirement() {
|
|
|
551
551
|
return errors;
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
-
function validateDockerRequirement() {
|
|
554
|
+
function validateDockerRequirement(providerName) {
|
|
555
555
|
const errors = [];
|
|
556
556
|
const docker = checkDocker();
|
|
557
557
|
if (!docker.available) {
|
|
@@ -567,6 +567,19 @@ function validateDockerRequirement() {
|
|
|
567
567
|
]
|
|
568
568
|
)
|
|
569
569
|
);
|
|
570
|
+
return errors;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
const IsolationManager = require('./isolation-manager');
|
|
575
|
+
IsolationManager.assertPlatformSupported(IsolationManager.providerDockerPlatform(providerName));
|
|
576
|
+
} catch (err) {
|
|
577
|
+
errors.push(
|
|
578
|
+
formatError('Docker cannot run required platform', err.message, [
|
|
579
|
+
'Install Buildx and run: docker run --privileged --rm tonistiigi/binfmt --install amd64',
|
|
580
|
+
'Or run Zeroshot on a native amd64 Docker host',
|
|
581
|
+
])
|
|
582
|
+
);
|
|
570
583
|
}
|
|
571
584
|
|
|
572
585
|
return errors;
|
|
@@ -732,7 +745,7 @@ async function runPreflight(options = {}) {
|
|
|
732
745
|
|
|
733
746
|
// 6. Check Docker (if required)
|
|
734
747
|
if (options.requireDocker) {
|
|
735
|
-
errors.push(...validateDockerRequirement());
|
|
748
|
+
errors.push(...validateDockerRequirement(providerName));
|
|
736
749
|
}
|
|
737
750
|
|
|
738
751
|
// 7. Check git repo (if required for worktree isolation)
|
|
@@ -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;
|