@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,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
|
+
};
|