@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.
Files changed (51) hide show
  1. package/cli/index.js +37 -2
  2. package/docker/zeroshot-cluster/Dockerfile +7 -0
  3. package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
  4. package/lib/agent-cli-provider/adapters/omp.js +37 -11
  5. package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
  6. package/lib/agent-cli-provider/omp-release.d.ts +3 -0
  7. package/lib/agent-cli-provider/omp-release.d.ts.map +1 -1
  8. package/lib/agent-cli-provider/omp-release.js +20 -1
  9. package/lib/agent-cli-provider/omp-release.js.map +1 -1
  10. package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -1
  11. package/lib/agent-cli-provider/omp-rpc-driver.js +27 -2
  12. package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -1
  13. package/lib/agent-cli-provider/omp-rpc-session.js +3 -3
  14. package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -1
  15. package/lib/agent-cli-provider/provider-registry.d.ts +20 -8
  16. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  17. package/lib/agent-cli-provider/provider-registry.js +56 -9
  18. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  19. package/lib/agent-cli-provider/types.d.ts +2 -0
  20. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  21. package/lib/agent-cli-provider/types.js.map +1 -1
  22. package/lib/docker-config.js +122 -5
  23. package/package.json +2 -2
  24. package/src/agent/agent-lifecycle.js +78 -3
  25. package/src/agent/agent-task-executor.js +72 -3
  26. package/src/agent/provider-session.js +112 -2
  27. package/src/agent-cli-provider/adapters/omp.ts +41 -11
  28. package/src/agent-cli-provider/omp-release.ts +26 -0
  29. package/src/agent-cli-provider/omp-rpc-driver.ts +31 -3
  30. package/src/agent-cli-provider/omp-rpc-session.ts +3 -3
  31. package/src/agent-cli-provider/provider-registry.ts +102 -20
  32. package/src/agent-cli-provider/types.ts +4 -0
  33. package/src/isolation-manager.js +535 -89
  34. package/src/omp-blob-root.js +110 -0
  35. package/src/omp-config-overlay.js +9 -1
  36. package/src/omp-execution-fingerprint.js +62 -0
  37. package/src/omp-session-limits.js +17 -0
  38. package/src/omp-session-partition.js +297 -0
  39. package/src/omp-session-verifier.js +576 -0
  40. package/src/orchestrator.js +11 -1
  41. package/src/preflight.js +15 -2
  42. package/task-lib/commands/clean.js +23 -0
  43. package/task-lib/commands/resume.js +42 -0
  44. package/task-lib/commands/run.js +65 -0
  45. package/task-lib/omp-session-cleanup.js +160 -0
  46. package/task-lib/omp-session-ownership-schema.js +262 -0
  47. package/task-lib/omp-session-ownership.js +332 -0
  48. package/task-lib/omp-storage-root.js +35 -0
  49. package/task-lib/rpc-watcher.js +332 -2
  50. package/task-lib/runner.js +195 -4
  51. package/task-lib/store.js +42 -7
@@ -0,0 +1,110 @@
1
+ // Resolution of OMP's *shared* content-addressed blob store root, mirrored from the tagged
2
+ // v17.2.1 source (`packages/utils/src/dirs.ts`: `getBlobsDir()` / `DirResolver`) rather than
3
+ // invented by Zeroshot.
4
+ //
5
+ // Why this exists: OMP externalizes large payloads (images, provider data URLs) out of the session
6
+ // JSONL into `<blobsDir>/<sha256-hex>` and leaves a nested `blob:sha256:<hex>` reference string
7
+ // inside the JSONL record (`packages/coding-agent/src/session/blob-store.ts`). The store is shared
8
+ // by every session on the machine and lives at `~/.omp/agent/blobs` by default — nowhere near
9
+ // Zeroshot's per-task session partition. A resumed partition whose referenced blobs are missing is
10
+ // an invalid continuation, so verification has to resolve them at this real root; and because the
11
+ // root is shared, Zeroshot cleanup must never delete anything under it.
12
+ //
13
+ // Resolution order, exactly as `DirResolver`'s constructor computes it:
14
+ // profile = normalize(OMP_PROFILE ?? PI_PROFILE) // OMP_PROFILE wins; '' selects default
15
+ // configRoot = ~/${PI_CONFIG_DIR || '.omp'}[/profiles/<profile>]
16
+ // defaultAgent = <configRoot>/agent
17
+ // agentDir = profile ? defaultAgent : (resolve(PI_CODING_AGENT_DIR) || defaultAgent)
18
+ // dataBase = (linux|darwin) && agentDir === defaultAgent && $XDG_DATA_HOME/omp[/profiles/<p>]
19
+ // exists ? that : agentDir // XDG flattens the agent/ prefix
20
+ // blobsDir = <dataBase>/blobs
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+
25
+ const APP_NAME = 'omp';
26
+ const CONFIG_DIR_NAME = '.omp';
27
+ // dirs.ts PROFILE_NAME_RE / WINDOWS_RESERVED_BASENAME_RE.
28
+ const PROFILE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
29
+ const WINDOWS_RESERVED_BASENAME_PATTERN = /^(?:CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?$/iu;
30
+
31
+ /** dirs.ts normalizeProfileName, but total: an invalid name resolves to the default profile here
32
+ * instead of throwing. A resume against a profile OMP itself would reject cannot succeed anyway —
33
+ * the verifier will simply not find the referenced blobs and fail the continuation closed. */
34
+ function normalizeProfileName(profile) {
35
+ const normalized = typeof profile === 'string' ? profile.trim() : '';
36
+ if (!normalized || normalized === 'default') return undefined;
37
+ if (
38
+ normalized === '.' ||
39
+ normalized === '..' ||
40
+ normalized.endsWith('.') ||
41
+ !PROFILE_NAME_PATTERN.test(normalized) ||
42
+ WINDOWS_RESERVED_BASENAME_PATTERN.test(normalized)
43
+ ) {
44
+ return undefined;
45
+ }
46
+ return normalized;
47
+ }
48
+
49
+ function activeProfile(env) {
50
+ return normalizeProfileName(
51
+ env.OMP_PROFILE !== undefined ? env.OMP_PROFILE : env.PI_PROFILE
52
+ );
53
+ }
54
+
55
+ function directoryExists(candidate) {
56
+ try {
57
+ return fs.statSync(candidate).isDirectory();
58
+ } catch {
59
+ return false;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Absolute path of the shared OMP blob store for the current environment.
65
+ * `env`/`homedir`/`platform` are injectable for tests only; production callers pass nothing.
66
+ */
67
+ function resolveOmpBlobsDir({
68
+ env = process.env,
69
+ homedir = os.homedir(),
70
+ platform = process.platform,
71
+ } = {}) {
72
+ const profile = activeProfile(env);
73
+ const configDirName = env.PI_CONFIG_DIR || CONFIG_DIR_NAME;
74
+ const baseConfigRoot = path.join(homedir, configDirName);
75
+ const configRoot = profile ? path.join(baseConfigRoot, 'profiles', profile) : baseConfigRoot;
76
+
77
+ const defaultAgentDir = path.join(configRoot, 'agent');
78
+ // A named profile pins the agent dir to the profile root; PI_CODING_AGENT_DIR applies only in
79
+ // default mode (dirs.ts: `const agentDirOverride = profile ? undefined : options.agentDirOverride`).
80
+ const agentDirOverride = profile ? undefined : env.PI_CODING_AGENT_DIR;
81
+ const agentDir = agentDirOverride ? path.resolve(agentDirOverride) : defaultAgentDir;
82
+
83
+ let dataBase = agentDir;
84
+ if ((platform === 'linux' || platform === 'darwin') && agentDir === defaultAgentDir) {
85
+ const xdgDataHome = env.XDG_DATA_HOME;
86
+ if (xdgDataHome) {
87
+ const appRoot = path.join(xdgDataHome, APP_NAME);
88
+ const candidate = profile ? path.join(appRoot, 'profiles', profile) : appRoot;
89
+ if (directoryExists(candidate)) dataBase = candidate;
90
+ }
91
+ }
92
+
93
+ return path.join(dataBase, 'blobs');
94
+ }
95
+
96
+ /** True when `candidate` is the shared blob root or anything inside it. Cleanup uses this as a
97
+ * hard stop: a Zeroshot partition must never resolve into OMP's shared, cross-session CAS. */
98
+ function isInsideOmpBlobsDir(candidate, options = {}) {
99
+ const blobsDir = resolveOmpBlobsDir(options);
100
+ const resolved = path.resolve(candidate);
101
+ return resolved === blobsDir || resolved.startsWith(blobsDir + path.sep);
102
+ }
103
+
104
+ module.exports = {
105
+ APP_NAME,
106
+ CONFIG_DIR_NAME,
107
+ isInsideOmpBlobsDir,
108
+ normalizeProfileName,
109
+ resolveOmpBlobsDir,
110
+ };
@@ -1,7 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const os = require('os');
3
3
  const path = require('path');
4
- const { randomUUID } = require('crypto');
4
+ const { createHash, randomUUID } = require('crypto');
5
5
 
6
6
  const OVERLAY_PREFIX = 'zeroshot-omp-config-';
7
7
  const OMP_CONFIG_OVERLAY_DIR_PATTERN = /^zeroshot-omp-config-[A-Za-z0-9_-]+$/u;
@@ -62,6 +62,13 @@ bash:
62
62
  thresholdMs: 60000
63
63
  `;
64
64
 
65
+ // Identity of the overlay *content*, not of any one temp file. A resumed session was produced
66
+ // under whatever workflow-altering defaults this body pinned; if the body changes (a Zeroshot
67
+ // upgrade retunes task.*/memory/advisor/async behaviour), continuing an old transcript under the
68
+ // new rules is execution drift, so this digest is part of the OMP execution fingerprint recorded
69
+ // with every resumable session (src/omp-execution-fingerprint.js).
70
+ const OMP_CONFIG_OVERLAY_DIGEST = `sha256:${createHash('sha256').update(OVERLAY_BODY, 'utf8').digest('hex')}`;
71
+
65
72
  function createOmpConfigOverlay() {
66
73
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), OVERLAY_PREFIX), { mode: 0o700 });
67
74
  try {
@@ -89,6 +96,7 @@ function isCanonicalOmpConfigOverlayDirectory(overlayDir) {
89
96
  }
90
97
 
91
98
  module.exports = {
99
+ OMP_CONFIG_OVERLAY_DIGEST,
92
100
  OMP_CONFIG_OVERLAY_DIR_PATTERN,
93
101
  OMP_CONFIG_OVERLAY_FILE_PATTERN,
94
102
  createOmpConfigOverlay,
@@ -0,0 +1,62 @@
1
+ // The `executionFingerprint` recorded with every resumable OMP session (issue #866).
2
+ //
3
+ // A session transcript is only safely continuable under the same execution contract that produced
4
+ // it. This digest binds that contract: the pinned OMP release, the Zeroshot config overlay's
5
+ // content, the requested Zeroshot selectors (`--model`, `--thinking`, `--approval-mode`), and the
6
+ // concrete provider/model/thinking level OMP actually reported for the turn. Any of those drifting
7
+ // between the recording turn and a resume attempt — a Zeroshot upgrade that retunes the overlay, a
8
+ // changed level mapping, an alias resolving to a different concrete model, a different thinking
9
+ // level — makes the fingerprints differ, and the continuation is refused before the prompt.
10
+ const { createHash } = require('crypto');
11
+ const { OMP_CONFIG_OVERLAY_DIGEST } = require('./omp-config-overlay');
12
+
13
+ /** Value of `--flag <value>` in an argv array, or '' when the flag is absent. */
14
+ function flagValue(args, flag) {
15
+ if (!Array.isArray(args)) return '';
16
+ const index = args.indexOf(flag);
17
+ if (index < 0 || index + 1 >= args.length) return '';
18
+ const value = args[index + 1];
19
+ return typeof value === 'string' ? value : '';
20
+ }
21
+
22
+ /** The Zeroshot-requested half of the contract, readable from the command spec alone. */
23
+ function requestedExecutionSelectors(commandSpec) {
24
+ const args = commandSpec?.args;
25
+ return {
26
+ modelSelector: flagValue(args, '--model'),
27
+ thinkingSelector: flagValue(args, '--thinking'),
28
+ approvalMode: flagValue(args, '--approval-mode'),
29
+ };
30
+ }
31
+
32
+ /**
33
+ * @param {object} params
34
+ * @param {string} params.expectedVersion pinned OMP release (OMP_SUPPORTED_VERSION)
35
+ * @param {object} params.commandSpec the spec OMP was actually spawned with
36
+ * @param {object} params.evidence OMP's reported session evidence (selectedProvider/Model, thinkingLevel)
37
+ * @param {string} [params.configOverlayDigest] injectable for tests only
38
+ * @returns {string} `sha256:<64-lower-hex>`
39
+ */
40
+ function computeOmpExecutionFingerprint({
41
+ expectedVersion,
42
+ commandSpec,
43
+ evidence,
44
+ configOverlayDigest = OMP_CONFIG_OVERLAY_DIGEST,
45
+ }) {
46
+ const fields = {
47
+ ompSupportedVersion: String(expectedVersion ?? ''),
48
+ configOverlayDigest: String(configOverlayDigest ?? ''),
49
+ ...requestedExecutionSelectors(commandSpec),
50
+ observedProvider: String(evidence?.selectedProvider ?? ''),
51
+ observedModel: String(evidence?.selectedModel ?? ''),
52
+ observedThinkingLevel: String(evidence?.thinkingLevel ?? ''),
53
+ };
54
+ const stable = {};
55
+ for (const key of Object.keys(fields).sort()) stable[key] = fields[key];
56
+ return `sha256:${createHash('sha256').update(JSON.stringify(stable), 'utf8').digest('hex')}`;
57
+ }
58
+
59
+ module.exports = {
60
+ computeOmpExecutionFingerprint,
61
+ requestedExecutionSelectors,
62
+ };
@@ -0,0 +1,17 @@
1
+ // Non-configurable bounds for OMP session partition verification (src/omp-session-verifier.js).
2
+ // Every value is pinned exactly as specified by issue #866; do not derive these from settings or
3
+ // environment — a configurable ceiling here would let a compromised/misbehaving OMP process (or a
4
+ // hostile resumed partition) negotiate its own verification budget.
5
+ const OMP_SESSION_LIMITS = Object.freeze({
6
+ maxSessionBytes: 268435456,
7
+ maxSessionRecords: 1000000,
8
+ maxArtifactEntries: 4096,
9
+ maxArtifactDepth: 16,
10
+ maxRelativePathBytes: 4096,
11
+ maxArtifactFileBytes: 268435456,
12
+ maxArtifactAggregateBytes: 536870912,
13
+ maxBlobReferences: 4096,
14
+ maxReferencedBlobBytes: 67108864,
15
+ });
16
+
17
+ module.exports = { OMP_SESSION_LIMITS };
@@ -0,0 +1,297 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { randomUUID } = require('crypto');
4
+ const { isInsideOmpBlobsDir } = require('./omp-blob-root');
5
+
6
+ // Every OMP session partition lives under <storageRoot>/omp-sessions/<uuid>/. storageRoot is the
7
+ // owning cluster's storageDir for cluster-agent tasks or the standalone TASKS_DIR otherwise (see
8
+ // task-lib/omp-storage-root.js) — never derived from prompt text or cwd. The shared OMP CAS blob
9
+ // store is *not* under here at all (it is machine-wide, at pi-utils::getBlobsDir(); see
10
+ // src/omp-blob-root.js), which is what makes per-task partition deletion safe.
11
+ const OMP_SESSIONS_SUBDIR = 'omp-sessions';
12
+ const PARTITION_ID_PATTERN =
13
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
14
+ const DELETING_PREFIX = '.zeroshot-deleting-';
15
+
16
+ const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
17
+ const O_DIRECTORY = fs.constants.O_DIRECTORY ?? 0;
18
+ // See src/omp-session-verifier.js: O_NONBLOCK stops a FIFO planted at one of these paths from
19
+ // blocking the open forever; the fstat that follows still rejects the wrong type.
20
+ const O_NONBLOCK = fs.constants.O_NONBLOCK ?? 0;
21
+
22
+ function ompSessionsRoot(storageRoot) {
23
+ return path.join(path.resolve(storageRoot), OMP_SESSIONS_SUBDIR);
24
+ }
25
+
26
+ function partitionPathFor(storageRoot, partitionId) {
27
+ if (typeof partitionId !== 'string' || !PARTITION_ID_PATTERN.test(partitionId)) {
28
+ throw new Error(`Invalid OMP session partition id: ${partitionId}`);
29
+ }
30
+ return path.join(ompSessionsRoot(storageRoot), partitionId);
31
+ }
32
+
33
+ function generateOmpPartitionId() {
34
+ return randomUUID();
35
+ }
36
+
37
+ /** Owner-only (0700) directory creation. Idempotent: safe to call once the row referencing this
38
+ * partitionId is already durable (row-before-directory — see task-lib/runner.js). */
39
+ function createOmpSessionPartitionDirectory(partitionPath) {
40
+ fs.mkdirSync(partitionPath, { recursive: true, mode: 0o700 });
41
+ if (process.platform !== 'win32') {
42
+ fs.chmodSync(partitionPath, 0o700);
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Allocate a fresh, random, secret-free UUID partition directory under storageRoot in one step.
48
+ * Callers that must durably record the partitionId before the directory exists on disk (fresh
49
+ * task spawn — see task-lib/runner.js) should use generateOmpPartitionId/partitionPathFor and
50
+ * createOmpSessionPartitionDirectory separately instead, in that order.
51
+ */
52
+ function allocateOmpSessionPartition(storageRoot) {
53
+ const partitionId = generateOmpPartitionId();
54
+ const partitionPath = partitionPathFor(storageRoot, partitionId);
55
+ createOmpSessionPartitionDirectory(partitionPath);
56
+ return { partitionId, path: partitionPath };
57
+ }
58
+
59
+ function identityOf(stat) {
60
+ return { device: String(stat.dev), inode: String(stat.ino) };
61
+ }
62
+
63
+ function sameIdentity(a, b) {
64
+ return Boolean(a) && Boolean(b) && a.device === b.device && a.inode === b.inode;
65
+ }
66
+
67
+ /** Descriptor-pinned directory identity: never follows a final symlink, and the returned identity
68
+ * describes the descriptor itself rather than a second pathname lookup. */
69
+ function pinDirectoryIdentity(dirPath) {
70
+ const fd = fs.openSync(dirPath, fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_DIRECTORY);
71
+ try {
72
+ const stat = fs.fstatSync(fd);
73
+ if (!stat.isDirectory()) {
74
+ throw Object.assign(new Error(`${dirPath} is not a directory`), { code: 'ENOTDIR' });
75
+ }
76
+ return { identity: identityOf(stat), uid: stat.uid };
77
+ } finally {
78
+ fs.closeSync(fd);
79
+ }
80
+ }
81
+
82
+ function currentUid() {
83
+ return typeof process.getuid === 'function' ? String(process.getuid()) : '0';
84
+ }
85
+
86
+ /**
87
+ * Phase 1 of deletion: validate the owner record against what is actually on disk and move the
88
+ * partition out of its canonical name.
89
+ *
90
+ * The check/use race (CodeQL js/file-system-race) is closed by *moving before deleting*: the
91
+ * partition is renamed, within its own parent, to an unguessable `.zeroshot-deleting-<uuid>` name
92
+ * and only then re-pinned. `rename(2)` is atomic, so after it succeeds the object under that fresh
93
+ * name can no longer be swapped by racing the original path; the post-rename identity comparison
94
+ * proves it is still the same directory that passed validation, and any mismatch aborts with the
95
+ * directory parked under its clearly-marked name rather than recursively deleting an unknown tree.
96
+ *
97
+ * Splitting the rename from the recursive removal is what lets a caller hold a *task-store* write
98
+ * fence across "no other row claims this partition" -> "the partition no longer answers to its
99
+ * canonical name" without also holding it across an arbitrarily long `rm -r`. See
100
+ * task-lib/omp-session-cleanup.js.
101
+ *
102
+ * Never throws.
103
+ * `{staged:true, stagingPath}` the directory is parked and the caller must remove it
104
+ * `{staged:false, deleted:true, ...}` there was nothing to delete
105
+ * `{staged:false, deleted:false, ...}` refused; preserve the owner record and warn
106
+ *
107
+ * @param {object} ownership canonical, already-validated task.ompSessionOwnership record
108
+ */
109
+ function stageOmpSessionPartitionForDeletion(ownership) {
110
+ if (!ownership || typeof ownership !== 'object') {
111
+ return { staged: false, deleted: false, reason: 'no ownership record' };
112
+ }
113
+ const { partitionId, storageRoot, partitionPath, ownerUid, storageRootIdentity } = ownership;
114
+
115
+ if (ownerUid !== currentUid()) {
116
+ return {
117
+ staged: false,
118
+ deleted: false,
119
+ reason: `recorded owner uid ${ownerUid} is not the current uid ${currentUid()}`,
120
+ };
121
+ }
122
+
123
+ let expectedPartitionPath;
124
+ try {
125
+ expectedPartitionPath = partitionPathFor(storageRoot, partitionId);
126
+ } catch (error) {
127
+ return { staged: false, deleted: false, reason: error.message };
128
+ }
129
+ if (expectedPartitionPath !== partitionPath) {
130
+ return {
131
+ staged: false,
132
+ deleted: false,
133
+ reason: `${partitionPath} is not the canonical partition path for ${partitionId}`,
134
+ };
135
+ }
136
+ const root = ompSessionsRoot(storageRoot);
137
+ if (path.dirname(expectedPartitionPath) !== root) {
138
+ return {
139
+ staged: false,
140
+ deleted: false,
141
+ reason: `${expectedPartitionPath} does not resolve directly under ${root}`,
142
+ };
143
+ }
144
+ // Defence in depth: OMP's shared, cross-session CAS root must never be reachable from a
145
+ // partition path, whatever a tampered or migrated storageRoot claims.
146
+ if (isInsideOmpBlobsDir(expectedPartitionPath) || isInsideOmpBlobsDir(root)) {
147
+ return {
148
+ staged: false,
149
+ deleted: false,
150
+ reason: `${expectedPartitionPath} resolves inside the shared OMP blob store; refusing to delete`,
151
+ };
152
+ }
153
+
154
+ // omp-sessions/ is the directory the staging rename happens inside, so it has to be a real,
155
+ // owner-held directory reached without following a symlink before anything is moved into it.
156
+ let rootPin;
157
+ try {
158
+ rootPin = pinDirectoryIdentity(root);
159
+ } catch (error) {
160
+ if (error.code === 'ENOENT') return { staged: false, deleted: true, reason: 'already absent' };
161
+ return { staged: false, deleted: false, reason: `${root}: ${error.message}` };
162
+ }
163
+ if (String(rootPin.uid) !== currentUid()) {
164
+ return { staged: false, deleted: false, reason: `${root} is not owned by the current user` };
165
+ }
166
+
167
+ let storagePin;
168
+ try {
169
+ storagePin = pinDirectoryIdentity(path.resolve(storageRoot));
170
+ } catch (error) {
171
+ return { staged: false, deleted: false, reason: `${storageRoot}: ${error.message}` };
172
+ }
173
+ if (!sameIdentity(storagePin.identity, storageRootIdentity)) {
174
+ return {
175
+ staged: false,
176
+ deleted: false,
177
+ reason: `${storageRoot} identity ${storagePin.identity.device}:${storagePin.identity.inode} does not match the recorded ${storageRootIdentity?.device}:${storageRootIdentity?.inode}`,
178
+ };
179
+ }
180
+ if (String(storagePin.uid) !== currentUid()) {
181
+ return { staged: false, deleted: false, reason: `${storageRoot} is not owned by the current user` };
182
+ }
183
+
184
+ let before;
185
+ try {
186
+ before = pinDirectoryIdentity(expectedPartitionPath);
187
+ } catch (error) {
188
+ if (error.code === 'ENOENT') return { staged: false, deleted: true, reason: 'already absent' };
189
+ if (error.code === 'ELOOP' || error.code === 'EMLINK') {
190
+ return { staged: false, deleted: false, reason: `${expectedPartitionPath} is a symlink; refusing to delete` };
191
+ }
192
+ if (error.code === 'ENOTDIR') {
193
+ return {
194
+ staged: false,
195
+ deleted: false,
196
+ reason: `${expectedPartitionPath} is not a real directory; refusing to delete`,
197
+ };
198
+ }
199
+ return { staged: false, deleted: false, reason: error.message };
200
+ }
201
+ if (String(before.uid) !== currentUid()) {
202
+ return { staged: false, deleted: false, reason: `${expectedPartitionPath} is not owned by the current user` };
203
+ }
204
+ if (ownership.partitionIdentity && !sameIdentity(before.identity, ownership.partitionIdentity)) {
205
+ return {
206
+ staged: false,
207
+ deleted: false,
208
+ reason: `${expectedPartitionPath} identity ${before.identity.device}:${before.identity.inode} does not match the recorded ${ownership.partitionIdentity.device}:${ownership.partitionIdentity.inode}`,
209
+ };
210
+ }
211
+
212
+ const stagingPath = path.join(root, `${DELETING_PREFIX}${randomUUID()}`);
213
+ try {
214
+ fs.renameSync(expectedPartitionPath, stagingPath);
215
+ } catch (error) {
216
+ if (error.code === 'ENOENT') return { staged: false, deleted: true, reason: 'already absent' };
217
+ return { staged: false, deleted: false, reason: `could not stage ${expectedPartitionPath}: ${error.message}` };
218
+ }
219
+
220
+ let after;
221
+ try {
222
+ after = pinDirectoryIdentity(stagingPath);
223
+ } catch (error) {
224
+ return {
225
+ staged: false,
226
+ deleted: false,
227
+ reason: `staged ${stagingPath} could not be pinned (${error.message}); left in place for inspection`,
228
+ };
229
+ }
230
+ if (!sameIdentity(after.identity, before.identity)) {
231
+ return {
232
+ staged: false,
233
+ deleted: false,
234
+ reason: `${expectedPartitionPath} was substituted before deletion; the staged directory ${stagingPath} was left in place for inspection`,
235
+ };
236
+ }
237
+
238
+ return { staged: true, stagingPath };
239
+ }
240
+
241
+ /**
242
+ * Phase 2: remove a directory that {@link stageOmpSessionPartitionForDeletion} already parked under
243
+ * its unguessable staging name. Safe to run outside any lock — the tree no longer answers to a name
244
+ * anything else knows.
245
+ */
246
+ function removeStagedOmpSessionPartition(stagingPath) {
247
+ // This is an exported recursive delete, so it re-derives that its argument really is a staging
248
+ // name this module minted — a direct `.zeroshot-deleting-*` child of an `omp-sessions/` root —
249
+ // rather than trusting the caller to have got it from stageOmpSessionPartitionForDeletion.
250
+ if (typeof stagingPath !== 'string' || !path.isAbsolute(stagingPath)) {
251
+ return { deleted: false, reason: `${stagingPath} is not an absolute staged partition path` };
252
+ }
253
+ if (!path.basename(stagingPath).startsWith(DELETING_PREFIX)) {
254
+ return { deleted: false, reason: `${stagingPath} is not a staged partition directory` };
255
+ }
256
+ if (path.basename(path.dirname(stagingPath)) !== OMP_SESSIONS_SUBDIR) {
257
+ return {
258
+ deleted: false,
259
+ reason: `${stagingPath} does not live directly under an ${OMP_SESSIONS_SUBDIR}/ root`,
260
+ };
261
+ }
262
+ try {
263
+ fs.rmSync(stagingPath, { recursive: true, force: true });
264
+ } catch (error) {
265
+ return { deleted: false, reason: `${stagingPath}: ${error.message}` };
266
+ }
267
+ return { deleted: true };
268
+ }
269
+
270
+ /**
271
+ * Validate, stage, and remove one owner-validated partition directory. Never the shared OMP CAS
272
+ * blob root, never anything outside `<storageRoot>/omp-sessions/`, and never a directory that is
273
+ * not the one the persisted ownership record describes.
274
+ *
275
+ * Never throws. `{deleted:false, reason}` means the caller must preserve the owner record and warn.
276
+ *
277
+ * @param {object} ownership canonical, already-validated task.ompSessionOwnership record
278
+ */
279
+ function deleteOmpSessionPartition(ownership) {
280
+ const staged = stageOmpSessionPartitionForDeletion(ownership);
281
+ if (!staged.staged) return { deleted: staged.deleted === true, reason: staged.reason };
282
+ return removeStagedOmpSessionPartition(staged.stagingPath);
283
+ }
284
+
285
+ module.exports = {
286
+ DELETING_PREFIX,
287
+ OMP_SESSIONS_SUBDIR,
288
+ PARTITION_ID_PATTERN,
289
+ ompSessionsRoot,
290
+ partitionPathFor,
291
+ generateOmpPartitionId,
292
+ createOmpSessionPartitionDirectory,
293
+ allocateOmpSessionPartition,
294
+ stageOmpSessionPartitionForDeletion,
295
+ removeStagedOmpSessionPartition,
296
+ deleteOmpSessionPartition,
297
+ };