@the-open-engine/zeroshot 6.24.0 → 6.25.1
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 +135 -72
- 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-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 +1 -1
- package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
- package/lib/agent-cli-provider/provider-registry.js +7 -1
- 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/package.json +1 -1
- package/src/agent/agent-lifecycle.js +66 -2
- package/src/agent/agent-task-executor.js +72 -3
- package/src/agent/provider-session.js +125 -2
- package/src/agent-cli-provider/adapters/omp.ts +41 -11
- 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 +7 -1
- package/src/agent-cli-provider/types.ts +4 -0
- 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 +41 -0
- package/src/omp-session-partition.js +424 -0
- package/src/omp-session-verifier.js +740 -0
- package/task-lib/commands/clean.js +92 -35
- package/task-lib/commands/kill.js +21 -0
- package/task-lib/commands/resume.js +62 -0
- package/task-lib/commands/run.js +80 -0
- package/task-lib/omp-session-cleanup.js +197 -0
- package/task-lib/omp-session-ownership-schema.js +268 -0
- package/task-lib/omp-session-ownership.js +367 -0
- package/task-lib/omp-storage-root.js +35 -0
- package/task-lib/rpc-watcher.js +368 -2
- package/task-lib/runner.js +243 -5
- package/task-lib/store.js +106 -76
|
@@ -0,0 +1,740 @@
|
|
|
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 the enumeration, which reports
|
|
24
|
+
// a substitution as a verification failure rather than silently traversing the replacement.
|
|
25
|
+
//
|
|
26
|
+
// Names are handled as RAW BYTES wherever the platform has raw bytes to give. A POSIX filename is
|
|
27
|
+
// an opaque byte string, not text: two distinct files can carry names that are both invalid UTF-8
|
|
28
|
+
// and that Node's string decoding collapses to the same run of U+FFFD replacement characters.
|
|
29
|
+
// Hashing the re-encoded string would then give two different trees the same manifest digest — a
|
|
30
|
+
// collision an attacker picks the filenames for. So directory entries are enumerated with
|
|
31
|
+
// `encoding: 'buffer'`, every relative path is assembled, bounded, separator-checked, sorted, and
|
|
32
|
+
// length-prefix-hashed as bytes, and child paths are opened from those same bytes rather than from
|
|
33
|
+
// a string round trip. Windows has no such thing: the OS gives UTF-16 names, Node's fs rejects
|
|
34
|
+
// Buffer paths there, and the existing string behavior is already lossless — so that platform keeps
|
|
35
|
+
// it, with the same manifest layout.
|
|
36
|
+
//
|
|
37
|
+
// Memory is bounded by the pinned OMP_SESSION_LIMITS rather than by the input: file bytes are
|
|
38
|
+
// streamed and hashed in fixed chunks, one JSONL record is capped at MAX_SESSION_RECORD_BYTES, the
|
|
39
|
+
// blob-reference walk is iterative (a hostile record cannot exhaust the call stack), and directory
|
|
40
|
+
// enumeration stops as soon as it has read more names than the entry budget could allow.
|
|
41
|
+
const { createHash } = require('crypto');
|
|
42
|
+
const fs = require('fs');
|
|
43
|
+
const path = require('path');
|
|
44
|
+
const { OMP_SESSION_LIMITS, MAX_SESSION_RECORD_BYTES } = require('./omp-session-limits');
|
|
45
|
+
const { resolveOmpBlobsDir } = require('./omp-blob-root');
|
|
46
|
+
|
|
47
|
+
const BLOB_REF_PREFIX = 'blob:sha256:';
|
|
48
|
+
const CANONICAL_BLOB_REF_PATTERN = /^blob:sha256:[a-f0-9]{64}$/u;
|
|
49
|
+
const SESSION_FILE_NAME_PATTERN = /^[^/\\]+\.jsonl$/u;
|
|
50
|
+
const NEWLINE = 0x0a;
|
|
51
|
+
const STREAM_CHUNK_BYTES = 1 << 16;
|
|
52
|
+
|
|
53
|
+
// Raw-byte names are a POSIX property. On Windows, fs does not accept Buffer paths at all.
|
|
54
|
+
const RAW_NAME_BYTES_SUPPORTED = process.platform !== 'win32';
|
|
55
|
+
const FORWARD_SLASH = 0x2f;
|
|
56
|
+
const BACKSLASH = 0x5c;
|
|
57
|
+
const NUL = 0x00;
|
|
58
|
+
const DOT = 0x2e;
|
|
59
|
+
// The manifest's relative-path separator is always '/', on every platform, so a manifest computed
|
|
60
|
+
// for the same tree is the same manifest everywhere.
|
|
61
|
+
const MANIFEST_SEPARATOR = Buffer.from('/', 'utf8');
|
|
62
|
+
const PATH_SEPARATOR_BYTES = Buffer.from(path.sep, 'utf8');
|
|
63
|
+
|
|
64
|
+
/** A filesystem path in the form this platform's fs takes losslessly: raw bytes on POSIX, the
|
|
65
|
+
* original string on Windows. */
|
|
66
|
+
function toPathRef(pathString) {
|
|
67
|
+
return RAW_NAME_BYTES_SUPPORTED ? Buffer.from(pathString, 'utf8') : pathString;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A directory entry's name as raw bytes (already bytes on POSIX; UTF-8 of the OS's UTF-16 name on
|
|
71
|
+
* Windows, which is the lossless encoding of that name). */
|
|
72
|
+
function nameToBytes(name) {
|
|
73
|
+
return Buffer.isBuffer(name) ? name : Buffer.from(name, 'utf8');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Extend a pinned directory path with one child name, without a string round trip on POSIX. */
|
|
77
|
+
function joinChildRef(dirRef, nameBytes) {
|
|
78
|
+
if (!RAW_NAME_BYTES_SUPPORTED) return path.join(dirRef, nameBytes.toString('utf8'));
|
|
79
|
+
return Buffer.concat([dirRef, PATH_SEPARATOR_BYTES, nameBytes]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A printable rendering for error messages. Names that are not valid UTF-8 cannot be shown as text
|
|
84
|
+
* without lying about their bytes, so those carry the exact hex alongside — an operator reading a
|
|
85
|
+
* refused resume needs to be able to identify the actual file.
|
|
86
|
+
*/
|
|
87
|
+
function describeRef(ref) {
|
|
88
|
+
if (!Buffer.isBuffer(ref)) return String(ref);
|
|
89
|
+
const text = ref.toString('utf8');
|
|
90
|
+
return Buffer.from(text, 'utf8').equals(ref)
|
|
91
|
+
? text
|
|
92
|
+
: `${text} (raw bytes ${ref.toString('hex')})`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
96
|
+
const O_DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
|
|
97
|
+
// O_NONBLOCK is what keeps "reject sockets/devices/FIFOs" from being a liveness hole: opening a
|
|
98
|
+
// FIFO read-only blocks until a writer appears, so a partition containing one would otherwise hang
|
|
99
|
+
// verification forever instead of failing it. With O_NONBLOCK the open returns immediately and the
|
|
100
|
+
// fstat below rejects the non-regular type. No effect on regular files or directories.
|
|
101
|
+
const O_NONBLOCK = fs.constants.O_NONBLOCK ?? 0;
|
|
102
|
+
|
|
103
|
+
class OmpSessionVerificationError extends Error {
|
|
104
|
+
constructor(code, message) {
|
|
105
|
+
super(message);
|
|
106
|
+
this.name = 'OmpSessionVerificationError';
|
|
107
|
+
this.code = code;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fail(code, message) {
|
|
112
|
+
throw new OmpSessionVerificationError(code, message);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function identityOf(stat) {
|
|
116
|
+
return { device: String(stat.dev), inode: String(stat.ino) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sameIdentity(a, b) {
|
|
120
|
+
return Boolean(a) && Boolean(b) && a.device === b.device && a.inode === b.inode;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isSymlink(targetPath) {
|
|
124
|
+
try {
|
|
125
|
+
return fs.lstatSync(targetPath).isSymbolicLink();
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertOwnerHeld(stat, targetPath) {
|
|
132
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
|
133
|
+
fail('not-owner-held', `${describeRef(targetPath)} is not owned by the current user.`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Open `targetPath` without ever following a final symlink and return the descriptor together with
|
|
139
|
+
* the `fstat` that describes *that descriptor* — never a second pathname lookup. Callers must
|
|
140
|
+
* `closeSync(fd)`.
|
|
141
|
+
*/
|
|
142
|
+
function openPinned(targetPath, { directory = false, missingCode, notTypeCode }) {
|
|
143
|
+
let fd;
|
|
144
|
+
try {
|
|
145
|
+
fd = fs.openSync(
|
|
146
|
+
targetPath,
|
|
147
|
+
fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK | (directory ? O_DIRECTORY : 0)
|
|
148
|
+
);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// Diagnostic classification only — the open already failed, so nothing proceeds on the basis
|
|
151
|
+
// of this lstat. It exists because O_NOFOLLOW|O_DIRECTORY reports a symlink-to-directory as
|
|
152
|
+
// ENOTDIR on Linux, and "is a symlink" is a far more actionable message than "is not a
|
|
153
|
+
// directory" for the operator reading a refused resume.
|
|
154
|
+
if (error.code === 'ELOOP' || error.code === 'EMLINK' || isSymlink(targetPath)) {
|
|
155
|
+
fail('symlink-rejected', `${describeRef(targetPath)} is a symlink.`);
|
|
156
|
+
}
|
|
157
|
+
if (error.code === 'ENOTDIR') {
|
|
158
|
+
fail(notTypeCode, `${describeRef(targetPath)} is not a directory.`);
|
|
159
|
+
}
|
|
160
|
+
if (error.code === 'EISDIR') {
|
|
161
|
+
fail(notTypeCode, `${describeRef(targetPath)} is a directory, not a regular file.`);
|
|
162
|
+
}
|
|
163
|
+
fail(missingCode, `${describeRef(targetPath)} could not be opened: ${error.message}`);
|
|
164
|
+
}
|
|
165
|
+
let stat;
|
|
166
|
+
try {
|
|
167
|
+
stat = fs.fstatSync(fd);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
fs.closeSync(fd);
|
|
170
|
+
fail(
|
|
171
|
+
missingCode,
|
|
172
|
+
`${describeRef(targetPath)} could not be stat'ed from its descriptor: ${error.message}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
// O_DIRECTORY/O_NOFOLLOW already excluded the wrong-type and symlink cases on platforms that
|
|
176
|
+
// implement them; re-assert from the descriptor so a platform lacking the flags still fails
|
|
177
|
+
// closed rather than silently accepting a socket/device/FIFO.
|
|
178
|
+
if (directory ? !stat.isDirectory() : !stat.isFile()) {
|
|
179
|
+
fs.closeSync(fd);
|
|
180
|
+
fail(
|
|
181
|
+
notTypeCode,
|
|
182
|
+
`${describeRef(targetPath)} is not a ${directory ? 'real directory' : 'regular file'} (mode ${stat.mode.toString(8)}).`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
assertOwnerHeld(stat, targetPath);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
fs.closeSync(fd);
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
return { fd, stat };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function withPinned(targetPath, options, body) {
|
|
195
|
+
const pinned = openPinned(targetPath, options);
|
|
196
|
+
try {
|
|
197
|
+
return body(pinned);
|
|
198
|
+
} finally {
|
|
199
|
+
fs.closeSync(pinned.fd);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Enumerate a directory whose identity is already pinned, re-pinning afterwards and comparing
|
|
205
|
+
* identity. Directory enumeration has no descriptor-taking form in Node, so this is the one
|
|
206
|
+
* unavoidable name lookup — bracketing it with the identity comparison turns a substitution into a
|
|
207
|
+
* hard verification failure instead of a silent traversal of the replacement tree.
|
|
208
|
+
*
|
|
209
|
+
* Names come back as raw bytes (`encoding: 'buffer'`) wherever the platform has them, and the sort
|
|
210
|
+
* is a byte-wise comparison, so the traversal order is a property of the bytes on disk rather than
|
|
211
|
+
* of how they happen to decode.
|
|
212
|
+
*
|
|
213
|
+
* `maxNames` is the largest number of entries this level could legitimately hold given the entry
|
|
214
|
+
* budget already consumed. Reading is abandoned the moment that is exceeded, so a directory with
|
|
215
|
+
* ten million entries costs the budget, not the directory: `opendir` streams, unlike `readdir`,
|
|
216
|
+
* which would materialize every name before any bound could be applied.
|
|
217
|
+
*/
|
|
218
|
+
function readdirPinned(dirRef, expectedIdentity, maxNames) {
|
|
219
|
+
const children = [];
|
|
220
|
+
let dir;
|
|
221
|
+
try {
|
|
222
|
+
dir = fs.opendirSync(dirRef, RAW_NAME_BYTES_SUPPORTED ? { encoding: 'buffer' } : {});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
fail('artifact-read-failed', `${describeRef(dirRef)} could not be listed: ${error.message}`);
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
for (;;) {
|
|
228
|
+
const entry = dir.readSync();
|
|
229
|
+
if (entry === null || entry === undefined) break;
|
|
230
|
+
if (children.length >= maxNames) {
|
|
231
|
+
fail('artifact-entries-exceeded', 'Artifact tree exceeds maxArtifactEntries.');
|
|
232
|
+
}
|
|
233
|
+
children.push({
|
|
234
|
+
name: nameToBytes(entry.name),
|
|
235
|
+
directory: entry.isDirectory(),
|
|
236
|
+
file: entry.isFile(),
|
|
237
|
+
symlink: entry.isSymbolicLink(),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
try {
|
|
242
|
+
dir.closeSync();
|
|
243
|
+
} catch {
|
|
244
|
+
// The enumeration result is what matters; a close failure cannot invalidate it.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
withPinned(
|
|
248
|
+
dirRef,
|
|
249
|
+
{ directory: true, missingCode: 'partition-missing', notTypeCode: 'not-a-directory' },
|
|
250
|
+
({ stat }) => {
|
|
251
|
+
if (!sameIdentity(identityOf(stat), expectedIdentity)) {
|
|
252
|
+
fail(
|
|
253
|
+
'identity-substituted',
|
|
254
|
+
`${describeRef(dirRef)} was substituted while it was being listed.`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
);
|
|
259
|
+
return children.sort((a, b) => Buffer.compare(a.name, b.name));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Validate a direct-child name by *byte*, not by decoded text: a path separator is a byte, and a
|
|
264
|
+
* name that decodes to something harmless can still carry one. `.`/`..` are compared as bytes for
|
|
265
|
+
* the same reason.
|
|
266
|
+
*/
|
|
267
|
+
function assertDirectChildNameBytes(nameBytes, code = 'invalid-relative-path') {
|
|
268
|
+
const invalid =
|
|
269
|
+
nameBytes.length === 0 ||
|
|
270
|
+
nameBytes.includes(FORWARD_SLASH) ||
|
|
271
|
+
nameBytes.includes(BACKSLASH) ||
|
|
272
|
+
nameBytes.includes(NUL) ||
|
|
273
|
+
(nameBytes.length <= 2 && nameBytes.every((byte) => byte === DOT));
|
|
274
|
+
if (invalid) {
|
|
275
|
+
fail(code, `Invalid direct-child name: ${describeRef(nameBytes)}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function assertSessionFileName(name) {
|
|
280
|
+
if (typeof name !== 'string') {
|
|
281
|
+
fail('invalid-session-file-name', `Session file name must be a string: ${String(name)}`);
|
|
282
|
+
}
|
|
283
|
+
assertDirectChildNameBytes(Buffer.from(name, 'utf8'), 'invalid-session-file-name');
|
|
284
|
+
if (!SESSION_FILE_NAME_PATTERN.test(name)) {
|
|
285
|
+
fail(
|
|
286
|
+
'invalid-session-file-name',
|
|
287
|
+
`Session file name must be a direct-child *.jsonl basename: ${JSON.stringify(name)}`
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Read the whole of an already-pinned descriptor in fixed-size chunks, never allocating
|
|
293
|
+
* proportional to the file size, enforcing `maxBytes` against bytes *observed while reading* (not
|
|
294
|
+
* just the descriptor's declared size) and feeding each chunk to `onChunk`. */
|
|
295
|
+
function streamDescriptor(fd, maxBytes, onChunk, overflow) {
|
|
296
|
+
const buffer = Buffer.allocUnsafe(STREAM_CHUNK_BYTES);
|
|
297
|
+
let observed = 0;
|
|
298
|
+
let position = 0;
|
|
299
|
+
for (;;) {
|
|
300
|
+
const read = fs.readSync(fd, buffer, 0, STREAM_CHUNK_BYTES, position);
|
|
301
|
+
if (read === 0) break;
|
|
302
|
+
position += read;
|
|
303
|
+
observed += read;
|
|
304
|
+
if (observed > maxBytes) overflow(observed);
|
|
305
|
+
onChunk(buffer.subarray(0, read));
|
|
306
|
+
}
|
|
307
|
+
return observed;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Walk one parsed record for nested canonical blob references.
|
|
312
|
+
*
|
|
313
|
+
* Iterative on purpose. A JSONL record is attacker-controlled, and a recursive walk over a deeply
|
|
314
|
+
* nested value would exhaust the call stack — a RangeError thrown from outside any handler rather
|
|
315
|
+
* than a verification failure. An explicit stack is bounded by the parsed value, which is in turn
|
|
316
|
+
* bounded by MAX_SESSION_RECORD_BYTES. (`JSON.parse` itself refuses over-deep input with a
|
|
317
|
+
* SyntaxError/RangeError, which the caller already converts into a record-unparseable failure.)
|
|
318
|
+
*/
|
|
319
|
+
function collectCanonicalBlobRefs(root, sink, limits) {
|
|
320
|
+
const stack = [root];
|
|
321
|
+
while (stack.length > 0) {
|
|
322
|
+
const value = stack.pop();
|
|
323
|
+
if (typeof value === 'string') {
|
|
324
|
+
if (!value.startsWith(BLOB_REF_PREFIX)) continue;
|
|
325
|
+
if (!CANONICAL_BLOB_REF_PATTERN.test(value)) {
|
|
326
|
+
// OMP's parseBlobRef only warns and falls back to treating a malformed ref as literal data
|
|
327
|
+
// (blob-store.ts). Zeroshot cannot: a continuation whose externalized payload is
|
|
328
|
+
// unaddressable is not a continuation we can prove, so this fails closed.
|
|
329
|
+
fail(
|
|
330
|
+
'blob-reference-noncanonical',
|
|
331
|
+
`Non-canonical blob reference ${JSON.stringify(value)}.`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
sink.add(value);
|
|
335
|
+
if (sink.size > limits.maxBlobReferences) {
|
|
336
|
+
fail('blob-references-exceeded', 'Session exceeds maxBlobReferences.');
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (Array.isArray(value)) {
|
|
341
|
+
for (const item of value) stack.push(item);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (value !== null && typeof value === 'object') {
|
|
345
|
+
for (const key of Object.keys(value)) stack.push(value[key]);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Stream the session JSONL from its pinned descriptor: bound bytes/records, hash the raw content,
|
|
352
|
+
* parse each record, verify the session header record, and collect every canonical nested blob
|
|
353
|
+
* reference.
|
|
354
|
+
*
|
|
355
|
+
* A record is buffered only until its terminating newline, and never past
|
|
356
|
+
* MAX_SESSION_RECORD_BYTES. That second bound is what makes this safe against a hostile transcript:
|
|
357
|
+
* `maxSessionBytes` bounds the file, but a 256 MiB file containing no newline is a *single* record,
|
|
358
|
+
* and buffering it would cost the raw bytes plus a UTF-16 string plus a parsed value. The record
|
|
359
|
+
* bound is checked before each append, i.e. before the copy it would authorize, so an over-long
|
|
360
|
+
* record is refused rather than accumulated. See src/omp-session-limits.js for the derivation.
|
|
361
|
+
*/
|
|
362
|
+
function streamSessionJsonl(fd, describePath, limits) {
|
|
363
|
+
const hash = createHash('sha256');
|
|
364
|
+
const blobRefs = new Set();
|
|
365
|
+
let records = 0;
|
|
366
|
+
let header = null;
|
|
367
|
+
let pending = [];
|
|
368
|
+
let pendingBytes = 0;
|
|
369
|
+
|
|
370
|
+
function appendPending(slice) {
|
|
371
|
+
if (pendingBytes + slice.length > MAX_SESSION_RECORD_BYTES) {
|
|
372
|
+
fail(
|
|
373
|
+
'session-record-bytes-exceeded',
|
|
374
|
+
`${describePath} record ${records + 1} exceeds the ${MAX_SESSION_RECORD_BYTES}-byte per-record bound.`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
pending.push(Buffer.from(slice));
|
|
378
|
+
pendingBytes += slice.length;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function consumeRecord() {
|
|
382
|
+
records += 1;
|
|
383
|
+
if (records > limits.maxSessionRecords) {
|
|
384
|
+
fail('session-records-exceeded', `${describePath} exceeds maxSessionRecords.`);
|
|
385
|
+
}
|
|
386
|
+
// One buffer is the common case (a record that arrived inside a single read); concatenating
|
|
387
|
+
// only when it spanned reads keeps the extra copy off the normal path.
|
|
388
|
+
const raw = pending.length === 1 ? pending[0] : Buffer.concat(pending, pendingBytes);
|
|
389
|
+
pending = [];
|
|
390
|
+
pendingBytes = 0;
|
|
391
|
+
const line = raw.toString('utf8');
|
|
392
|
+
if (line.trim().length === 0) return;
|
|
393
|
+
let parsed;
|
|
394
|
+
try {
|
|
395
|
+
parsed = JSON.parse(line);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
fail(
|
|
398
|
+
'session-record-unparseable',
|
|
399
|
+
`${describePath} record ${records} is not valid JSON: ${error.message}`
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if (header === null) header = parsed;
|
|
403
|
+
collectCanonicalBlobRefs(parsed, blobRefs, limits);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const bytes = streamDescriptor(
|
|
407
|
+
fd,
|
|
408
|
+
limits.maxSessionBytes,
|
|
409
|
+
(chunk) => {
|
|
410
|
+
let start = 0;
|
|
411
|
+
for (let i = 0; i < chunk.length; i += 1) {
|
|
412
|
+
if (chunk[i] !== NEWLINE) continue;
|
|
413
|
+
appendPending(chunk.subarray(start, i));
|
|
414
|
+
start = i + 1;
|
|
415
|
+
consumeRecord();
|
|
416
|
+
}
|
|
417
|
+
if (start < chunk.length) appendPending(chunk.subarray(start));
|
|
418
|
+
hash.update(chunk);
|
|
419
|
+
},
|
|
420
|
+
() => fail('session-bytes-exceeded', `${describePath} observed bytes exceed maxSessionBytes.`)
|
|
421
|
+
);
|
|
422
|
+
if (pendingBytes > 0) consumeRecord(); // trailing unterminated record
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
bytes,
|
|
426
|
+
records,
|
|
427
|
+
digest: `sha256:${hash.digest('hex')}`,
|
|
428
|
+
header,
|
|
429
|
+
blobRefs: [...blobRefs].sort(),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The session's first record is OMP's session header (`{type:"session", version, id, cwd, ...}` —
|
|
434
|
+
* session-manager.ts `#resetToNewSession`). Its `id` is the authoritative session identity written
|
|
435
|
+
* to disk and its `cwd` is the workspace the session belongs to. */
|
|
436
|
+
function parseSessionHeader(header, describePath) {
|
|
437
|
+
if (!header || typeof header !== 'object' || Array.isArray(header)) {
|
|
438
|
+
fail('session-header-missing', `${describePath} has no session header record.`);
|
|
439
|
+
}
|
|
440
|
+
if (header.type !== 'session') {
|
|
441
|
+
fail(
|
|
442
|
+
'session-header-invalid',
|
|
443
|
+
`${describePath} first record is type ${JSON.stringify(header.type)}, not "session".`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (typeof header.id !== 'string' || header.id.length === 0) {
|
|
447
|
+
fail('session-header-invalid', `${describePath} session header has no id.`);
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
sessionId: header.id,
|
|
451
|
+
cwd: typeof header.cwd === 'string' && header.cwd.length > 0 ? path.resolve(header.cwd) : null,
|
|
452
|
+
version: header.version ?? null,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Verify one referenced blob at the *shared* OMP CAS root. Unlike partition files a blob may
|
|
457
|
+
* legitimately carry more than one hard link: `blob-store.ts#ensureDisplayPath` hardlinks
|
|
458
|
+
* `<hash>` to a typed `<hash>.<ext>` sidecar for OS image openers. Content is what is
|
|
459
|
+
* authoritative here, and it is checked against the digest the reference names. */
|
|
460
|
+
function verifyBlobReference(ref, blobsDir, limits) {
|
|
461
|
+
const hex = ref.slice(BLOB_REF_PREFIX.length);
|
|
462
|
+
const blobPath = path.join(blobsDir, hex);
|
|
463
|
+
return withPinned(
|
|
464
|
+
blobPath,
|
|
465
|
+
{ missingCode: 'blob-missing', notTypeCode: 'blob-not-regular' },
|
|
466
|
+
({ fd, stat }) => {
|
|
467
|
+
if (stat.size > limits.maxReferencedBlobBytes) {
|
|
468
|
+
fail('blob-bytes-exceeded', `Referenced blob ${ref} exceeds maxReferencedBlobBytes.`);
|
|
469
|
+
}
|
|
470
|
+
const hash = createHash('sha256');
|
|
471
|
+
streamDescriptor(
|
|
472
|
+
fd,
|
|
473
|
+
limits.maxReferencedBlobBytes,
|
|
474
|
+
(chunk) => hash.update(chunk),
|
|
475
|
+
() => fail('blob-bytes-exceeded', `Referenced blob ${ref} exceeds maxReferencedBlobBytes.`)
|
|
476
|
+
);
|
|
477
|
+
if (hash.digest('hex') !== hex) {
|
|
478
|
+
fail('blob-digest-mismatch', `Referenced blob ${ref} content does not match its digest.`);
|
|
479
|
+
}
|
|
480
|
+
return blobPath;
|
|
481
|
+
}
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function verifyBlobReferences(blobRefs, limits, blobsDirOptions) {
|
|
486
|
+
if (blobRefs.length === 0) return { blobsDir: null, verified: [] };
|
|
487
|
+
if (blobRefs.length > limits.maxBlobReferences) {
|
|
488
|
+
fail('blob-references-exceeded', 'Session exceeds maxBlobReferences.');
|
|
489
|
+
}
|
|
490
|
+
const blobsDir = resolveOmpBlobsDir(blobsDirOptions);
|
|
491
|
+
for (const ref of blobRefs) verifyBlobReference(ref, blobsDir, limits);
|
|
492
|
+
return { blobsDir, verified: blobRefs };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** Walk the partition subtree (everything except the session file itself), descriptor-pinning
|
|
496
|
+
* every entry, and return the manifest entries in a deterministic order. Relative paths are raw
|
|
497
|
+
* bytes throughout — assembled from the bytes the directory reported, bounded by their own byte
|
|
498
|
+
* length, and never re-encoded from a decoded string. */
|
|
499
|
+
function collectArtifactEntries(partitionRef, partitionIdentity, sessionFileName, limits) {
|
|
500
|
+
const entries = [];
|
|
501
|
+
const aggregate = { count: 0, bytes: 0 };
|
|
502
|
+
const sessionFileNameBytes = Buffer.from(sessionFileName, 'utf8');
|
|
503
|
+
|
|
504
|
+
function visit(absDirRef, dirIdentity, relDir, depth) {
|
|
505
|
+
if (depth > limits.maxArtifactDepth) {
|
|
506
|
+
fail(
|
|
507
|
+
'artifact-depth-exceeded',
|
|
508
|
+
`Artifact tree exceeds maxArtifactDepth at ${relDir ? describeRef(relDir) : '.'}.`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
// The most names this level could hold without the entry budget already being blown. The
|
|
512
|
+
// session file is skipped rather than counted, so depth 0 may hold exactly one more.
|
|
513
|
+
const maxNames = limits.maxArtifactEntries - aggregate.count + (depth === 0 ? 1 : 0);
|
|
514
|
+
for (const child of readdirPinned(absDirRef, dirIdentity, maxNames)) {
|
|
515
|
+
if (depth === 0 && child.name.equals(sessionFileNameBytes)) continue;
|
|
516
|
+
assertDirectChildNameBytes(child.name);
|
|
517
|
+
const absPath = joinChildRef(absDirRef, child.name);
|
|
518
|
+
const relPath = relDir ? Buffer.concat([relDir, MANIFEST_SEPARATOR, child.name]) : child.name;
|
|
519
|
+
if (relPath.length > limits.maxRelativePathBytes) {
|
|
520
|
+
fail(
|
|
521
|
+
'relative-path-bytes-exceeded',
|
|
522
|
+
`${describeRef(relPath)} exceeds maxRelativePathBytes.`
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
aggregate.count += 1;
|
|
526
|
+
if (aggregate.count > limits.maxArtifactEntries) {
|
|
527
|
+
fail('artifact-entries-exceeded', 'Artifact tree exceeds maxArtifactEntries.');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (child.symlink) {
|
|
531
|
+
fail('symlink-rejected', `${describeRef(relPath)} is a symlink.`);
|
|
532
|
+
}
|
|
533
|
+
if (child.directory) {
|
|
534
|
+
const childIdentity = withPinned(
|
|
535
|
+
absPath,
|
|
536
|
+
{ directory: true, missingCode: 'artifact-missing', notTypeCode: 'not-a-directory' },
|
|
537
|
+
({ stat }) => identityOf(stat)
|
|
538
|
+
);
|
|
539
|
+
entries.push({ relPath, type: 'dir', size: 0, contentDigest: '' });
|
|
540
|
+
visit(absPath, childIdentity, relPath, depth + 1);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (!child.file) {
|
|
544
|
+
fail(
|
|
545
|
+
'not-a-regular-single-link-file',
|
|
546
|
+
`${describeRef(relPath)} is neither a regular file nor a directory.`
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const entry = withPinned(
|
|
551
|
+
absPath,
|
|
552
|
+
{ missingCode: 'artifact-missing', notTypeCode: 'not-a-regular-single-link-file' },
|
|
553
|
+
({ fd, stat }) => {
|
|
554
|
+
if (stat.nlink > 1) {
|
|
555
|
+
fail(
|
|
556
|
+
'not-a-regular-single-link-file',
|
|
557
|
+
`${describeRef(relPath)} has more than one hard link.`
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
if (stat.size > limits.maxArtifactFileBytes) {
|
|
561
|
+
fail(
|
|
562
|
+
'artifact-file-bytes-exceeded',
|
|
563
|
+
`${describeRef(relPath)} exceeds maxArtifactFileBytes.`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
const hash = createHash('sha256');
|
|
567
|
+
const observed = streamDescriptor(
|
|
568
|
+
fd,
|
|
569
|
+
limits.maxArtifactFileBytes,
|
|
570
|
+
(chunk) => hash.update(chunk),
|
|
571
|
+
() =>
|
|
572
|
+
fail(
|
|
573
|
+
'artifact-file-bytes-exceeded',
|
|
574
|
+
`${describeRef(relPath)} exceeds maxArtifactFileBytes.`
|
|
575
|
+
)
|
|
576
|
+
);
|
|
577
|
+
return {
|
|
578
|
+
relPath,
|
|
579
|
+
type: 'file',
|
|
580
|
+
size: observed,
|
|
581
|
+
contentDigest: `sha256:${hash.digest('hex')}`,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
aggregate.bytes += entry.size;
|
|
587
|
+
if (aggregate.bytes > limits.maxArtifactAggregateBytes) {
|
|
588
|
+
fail(
|
|
589
|
+
'artifact-aggregate-bytes-exceeded',
|
|
590
|
+
'Artifact tree exceeds maxArtifactAggregateBytes.'
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
entries.push(entry);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
visit(partitionRef, partitionIdentity, null, 0);
|
|
598
|
+
return entries;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** `<uint32 big-endian byte length><bytes>`. Length-prefixing is what makes the manifest
|
|
602
|
+
* unambiguous: without it `a/b` + `c` and `a` + `b/c` would hash identically. */
|
|
603
|
+
function lengthPrefixed(value) {
|
|
604
|
+
const buf = Buffer.isBuffer(value) ? value : Buffer.from(String(value), 'utf8');
|
|
605
|
+
const len = Buffer.alloc(4);
|
|
606
|
+
len.writeUInt32BE(buf.length, 0);
|
|
607
|
+
return Buffer.concat([len, buf]);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function hashManifestEntry(hash, { relPath, type, size, contentDigest }) {
|
|
611
|
+
// relPath is hashed as the raw bytes the filesystem reported. Two files whose names are both
|
|
612
|
+
// invalid UTF-8 decode to the same replacement-character string, so hashing the decoded form
|
|
613
|
+
// would give distinct trees identical manifests.
|
|
614
|
+
hash.update(lengthPrefixed(relPath));
|
|
615
|
+
hash.update(lengthPrefixed(type));
|
|
616
|
+
hash.update(lengthPrefixed(size));
|
|
617
|
+
hash.update(lengthPrefixed(contentDigest || ''));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Full structural verification of a partition expected to already hold a session.
|
|
622
|
+
* `expectedPartitionIdentity` (when supplied) is compared against the pinned directory before
|
|
623
|
+
* anything inside it is read, so a partition directory swapped since it was recorded fails before
|
|
624
|
+
* its contents can influence the manifest.
|
|
625
|
+
*
|
|
626
|
+
* `limits` and `blobsDirOptions` are seams for tests to prove enforcement and to point at a
|
|
627
|
+
* fixture blob store — NOT configuration. No production caller passes either: the bounds are the
|
|
628
|
+
* pinned OMP_SESSION_LIMITS (a negotiable ceiling would let a hostile partition pick its own
|
|
629
|
+
* verification budget) and the blob root is whatever OMP itself would resolve.
|
|
630
|
+
*/
|
|
631
|
+
function verifyPartitionContents(
|
|
632
|
+
partitionPath,
|
|
633
|
+
sessionFileName,
|
|
634
|
+
{ expectedPartitionIdentity = null, limits = OMP_SESSION_LIMITS, blobsDirOptions } = {}
|
|
635
|
+
) {
|
|
636
|
+
assertSessionFileName(sessionFileName);
|
|
637
|
+
|
|
638
|
+
const partitionRef = toPathRef(partitionPath);
|
|
639
|
+
const partitionIdentity = withPinned(
|
|
640
|
+
partitionRef,
|
|
641
|
+
{ directory: true, missingCode: 'partition-missing', notTypeCode: 'not-a-directory' },
|
|
642
|
+
({ stat }) => identityOf(stat)
|
|
643
|
+
);
|
|
644
|
+
if (expectedPartitionIdentity && !sameIdentity(partitionIdentity, expectedPartitionIdentity)) {
|
|
645
|
+
fail(
|
|
646
|
+
'partition-identity-mismatch',
|
|
647
|
+
`${partitionPath} identity ${partitionIdentity.device}:${partitionIdentity.inode} does not match the recorded owner.`
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const sessionFilePath = path.join(partitionPath, sessionFileName);
|
|
652
|
+
const session = withPinned(
|
|
653
|
+
joinChildRef(partitionRef, Buffer.from(sessionFileName, 'utf8')),
|
|
654
|
+
{ missingCode: 'session-file-missing', notTypeCode: 'not-a-regular-file' },
|
|
655
|
+
({ fd, stat }) => {
|
|
656
|
+
if (stat.nlink > 1) {
|
|
657
|
+
fail('hard-link-rejected', `${sessionFilePath} has more than one hard link.`);
|
|
658
|
+
}
|
|
659
|
+
if (stat.size > limits.maxSessionBytes) {
|
|
660
|
+
fail(
|
|
661
|
+
'session-bytes-exceeded',
|
|
662
|
+
`${sessionFilePath} declared size ${stat.size} exceeds maxSessionBytes.`
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
return { identity: identityOf(stat), ...streamSessionJsonl(fd, sessionFilePath, limits) };
|
|
666
|
+
}
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
const header = parseSessionHeader(session.header, sessionFilePath);
|
|
670
|
+
const blobs = verifyBlobReferences(session.blobRefs, limits, blobsDirOptions);
|
|
671
|
+
const artifactEntries = collectArtifactEntries(
|
|
672
|
+
partitionRef,
|
|
673
|
+
partitionIdentity,
|
|
674
|
+
sessionFileName,
|
|
675
|
+
limits
|
|
676
|
+
);
|
|
677
|
+
|
|
678
|
+
const manifestHash = createHash('sha256');
|
|
679
|
+
hashManifestEntry(manifestHash, {
|
|
680
|
+
relPath: sessionFileName,
|
|
681
|
+
type: 'file',
|
|
682
|
+
size: session.bytes,
|
|
683
|
+
contentDigest: session.digest,
|
|
684
|
+
});
|
|
685
|
+
for (const entry of artifactEntries) hashManifestEntry(manifestHash, entry);
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
partitionPath,
|
|
689
|
+
partitionIdentity,
|
|
690
|
+
sessionFilePath,
|
|
691
|
+
sessionFileName,
|
|
692
|
+
sessionFileIdentity: session.identity,
|
|
693
|
+
sessionBytes: session.bytes,
|
|
694
|
+
sessionRecords: session.records,
|
|
695
|
+
sessionHeader: header,
|
|
696
|
+
blobReferences: blobs.verified,
|
|
697
|
+
blobsDir: blobs.blobsDir,
|
|
698
|
+
artifactManifestDigest: `sha256:${manifestHash.digest('hex')}`,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Lightweight check at spawn/`ready` for a *fresh* partition: the partition path is a real,
|
|
703
|
+
* owner-held, descriptor-pinned directory whose identity still matches what was recorded (when
|
|
704
|
+
* known). It deliberately does not walk the tree or read the session file — neither exists yet at
|
|
705
|
+
* this point in a fresh session's lifecycle. */
|
|
706
|
+
function checkPartitionPathReady(partitionPath, { expectedPartitionIdentity = null } = {}) {
|
|
707
|
+
const identity = withPinned(
|
|
708
|
+
toPathRef(partitionPath),
|
|
709
|
+
{ directory: true, missingCode: 'partition-missing', notTypeCode: 'not-a-directory' },
|
|
710
|
+
({ stat }) => identityOf(stat)
|
|
711
|
+
);
|
|
712
|
+
if (expectedPartitionIdentity && !sameIdentity(identity, expectedPartitionIdentity)) {
|
|
713
|
+
fail(
|
|
714
|
+
'partition-identity-mismatch',
|
|
715
|
+
`${partitionPath} identity does not match the recorded owner.`
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
return { partitionPath, partitionIdentity: identity };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Full verification of an existing (resume) partition: before spawn and again before prompt. */
|
|
722
|
+
function verifyExistingOmpPartition(partitionPath, sessionFileName, options = {}) {
|
|
723
|
+
return verifyPartitionContents(partitionPath, sessionFileName, options);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Full verification after terminal materialization of a fresh session, before its ownership
|
|
727
|
+
* record may be committed as resumable. */
|
|
728
|
+
function verifyFreshMaterialization(partitionPath, sessionFileName, options = {}) {
|
|
729
|
+
return verifyPartitionContents(partitionPath, sessionFileName, options);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
module.exports = {
|
|
733
|
+
BLOB_REF_PREFIX,
|
|
734
|
+
CANONICAL_BLOB_REF_PATTERN,
|
|
735
|
+
OmpSessionVerificationError,
|
|
736
|
+
checkPartitionPathReady,
|
|
737
|
+
verifyExistingOmpPartition,
|
|
738
|
+
verifyFreshMaterialization,
|
|
739
|
+
verifyPartitionContents,
|
|
740
|
+
};
|