@sublang/playbook 18.1.0 → 18.1.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/docs/releases/18.1.1-preparation.md +33 -0
- package/package.json +5 -2
- package/reference/sdlc/code.playbook/bin/machine-identity.js +2 -2
- package/reference/sdlc/code.playbook/bin/private-paths.js +49 -9
- package/reference/sdlc/code.playbook/bin/repository-effects.js +7 -5
- package/reference/sdlc/code.playbook/bin/session-chunks.js +23 -23
- package/reference/sdlc/code.playbook/bin/session-replay.js +17 -17
- package/reference/sdlc/code.playbook/bin/session-store.js +18 -18
- package/reference/sdlc/code.playbook/session-assets.js +13 -11
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
2
|
+
<!-- SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai> -->
|
|
3
|
+
|
|
4
|
+
# Playbook 18.1.1 preparation
|
|
5
|
+
|
|
6
|
+
## Scope and version classification
|
|
7
|
+
|
|
8
|
+
The reviewed source range is v18.1.0 through a5592537bbface6eeb1af9b334f0c49a6cc84c24: native Windows private storage, preserved opened-directory validation and the corresponding CI required-check correction.
|
|
9
|
+
The owner selected a PATCH release for the reviewed existing consumers; public API declarations, persisted formats, the POSIX Node floor and storage/evidence requirements remain unchanged.
|
|
10
|
+
The 24 public typed entry points match the registry-published 18.1.0 declarations byte for byte.
|
|
11
|
+
The Windows Node minimum is 22.19; directory fsync durability is not claimed equivalent to POSIX.
|
|
12
|
+
|
|
13
|
+
The Cligent adoption must use the actually published 0.36.2 package, with its prepared and registry integrity matched, before this package is tagged.
|
|
14
|
+
The accepted Cligent legacy-export removal and raw U+2028/U+2029 output limitations remain disclosed upstream; this release does not fix or waive them for unknown consumers.
|
|
15
|
+
SLC remains outside this release scope because its existing Cligent and Playbook ranges admit these PATCH versions.
|
|
16
|
+
|
|
17
|
+
## Verification and explicit exceptions
|
|
18
|
+
|
|
19
|
+
- [x] Adopt registry Cligent 0.36.2 with dependency floor `^0.36.2`; frozen registry installation succeeded. The official SHA-512 matches the locally prepared archive when packed using the CI runtime (Node 22.23.4, npm 11.8.0); the npm gitHead is 8f61490171f109af9d3abfbcdb3ee6de50f4100d and its provenance subjects match that archive.
|
|
20
|
+
- [x] Canonical local `pnpm test`: 2910 main-suite tests and 146 Cligent capability checks passed; 26 tests skipped by the existing suite on Mac.
|
|
21
|
+
- [x] Final build after registry adoption passed with no compiled `.js` / `.d.ts` drift. Spec lint, SPDX, prepush and all 4322 relative documentation links passed.
|
|
22
|
+
- [x] Canonical local model-free `pnpm smoke:release` passed all 11 steps (1039.1 seconds), including packed installation, shared-Captain continuation, compiled-artifact fidelity, release capabilities, persistence/assets and host capabilities.
|
|
23
|
+
- [x] Real Claude Code acceptance: NOT RUN, explicitly waived by the owner on 2026-11-02 after the configured local provider rejected authentication. No credential is recorded here.
|
|
24
|
+
- [x] The canonical mixed Claude/Codex pnpm test:acceptance suite is NOT claimed passed: its workflows require Claude Code. The owner-authorized omission is release-specific; no test source or general release rule is disabled.
|
|
25
|
+
- [x] A packed, globally installed candidate plus the official Codex SDK 0.159.0 completed the built-in `/inspect` workflow using the existing Mac login and gpt-6.1-sol. Exit 0, expected README marker returned, durable session ID present, repository HEAD/README/status unchanged. This is narrower evidence than the canonical mixed-agent acceptance suite; it is not a Windows or SPEX APP test.
|
|
26
|
+
- [x] Conditional manual tmux UX check: NOT RUN, explicitly waived by the owner on 2026-11-02. Computer Use denied access to Terminal; the owner separately authorized this release-specific exception. No visual or manual interaction pass is claimed and no general release rule is disabled.
|
|
27
|
+
- [ ] Commit and push the prepared candidate, require its CI, and create the matching v18.1.1 tag only after the remaining gates or explicit owner exceptions are settled.
|
|
28
|
+
|
|
29
|
+
Local prepush (spec lint and tsc --noEmit) passed; SPDX passed for all 558 tracked files.
|
|
30
|
+
The lockfile changes only the Cligent version, caret floor, snapshot key and registry integrity; unchanged Linux libc selectors remain intact.
|
|
31
|
+
Local pre-adoption storage checks passed 102 tests with two Windows-only cases skipped on Mac.
|
|
32
|
+
The final candidate still requires CI, including native Windows storage at Node 22.19; Mac checks alone are not Windows evidence.
|
|
33
|
+
This pre-tag checkpoint does not claim publication or all-platform/all-agent acceptance.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sublang/playbook",
|
|
3
|
-
"version": "18.1.
|
|
3
|
+
"version": "18.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Composable XState v5 playbook runtime with compiled Captain, CODE, REVIEW, DECIDE, DEV, BRANCH, PR, and INSPECT workflows driven by GEARS specs.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -290,7 +290,7 @@
|
|
|
290
290
|
"provenance": true
|
|
291
291
|
},
|
|
292
292
|
"dependencies": {
|
|
293
|
-
"@sublang/cligent": "^0.36.
|
|
293
|
+
"@sublang/cligent": "^0.36.2",
|
|
294
294
|
"@sublang/spex": "^3.0.0",
|
|
295
295
|
"p-queue": "^9.3.1",
|
|
296
296
|
"xstate": "^5.19.4",
|
|
@@ -302,5 +302,8 @@
|
|
|
302
302
|
"@types/node": "^22.0.0",
|
|
303
303
|
"typescript": "^5.8.0",
|
|
304
304
|
"vitest": "^3.0.0"
|
|
305
|
+
},
|
|
306
|
+
"optionalDependencies": {
|
|
307
|
+
"@openclaw/fs-safe": "0.26.13"
|
|
305
308
|
}
|
|
306
309
|
}
|
|
@@ -12,7 +12,7 @@ import { constants } from 'node:fs';
|
|
|
12
12
|
import { chmod, link, lstat, mkdir, open, unlink } from 'node:fs/promises';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
|
-
import { sameFileIdentity, syncDirectory, tightenPrivateEntry } from './private-paths.js';
|
|
15
|
+
import { sameFileIdentity, syncDirectory, tightenPrivateEntry, mkdirPrivate } from './private-paths.js';
|
|
16
16
|
|
|
17
17
|
export const MACHINE_IDENTITY_TAG_PREFIX = 'machine-id:v1:';
|
|
18
18
|
export const PLAYBOOK_MACHINE_IDENTITY_UNAVAILABLE =
|
|
@@ -150,7 +150,7 @@ async function prepareDirectory(directory, path, fs) {
|
|
|
150
150
|
throw new MachineIdentityUnavailableError(path, `its directory cannot be read: ${errorMessage(cause)}`);
|
|
151
151
|
}
|
|
152
152
|
try {
|
|
153
|
-
await
|
|
153
|
+
await mkdirPrivate(directory, fs, { recursive: true });
|
|
154
154
|
await fs.chmod(directory, 0o700);
|
|
155
155
|
stat = await fs.lstat(directory);
|
|
156
156
|
} catch (creation) {
|
|
@@ -11,18 +11,55 @@
|
|
|
11
11
|
// applies it to its state directory and file (playbook-cli-97).
|
|
12
12
|
|
|
13
13
|
import { constants } from 'node:fs';
|
|
14
|
+
import { dirname } from 'node:path';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
// POSIX hosts retain the existing Node floor and never load the optional
|
|
17
|
+
// Windows mechanism. Native Windows requires Node 22.19 or newer.
|
|
18
|
+
const windowsPermissions = process.platform === 'win32'
|
|
19
|
+
? createRequire(import.meta.url)('@openclaw/fs-safe/permissions') : undefined;
|
|
20
|
+
|
|
21
|
+
// The library owns Windows descriptor inspection and creation. This is the
|
|
22
|
+
// store's admission policy, deliberately conservative even for unusual ACEs.
|
|
23
|
+
export function privatePermissions(stat, mode, path) {
|
|
24
|
+
if (process.platform !== 'win32') return (Number(stat.mode) & 0o7777) === mode;
|
|
25
|
+
if (!path) throw new Error('private Windows permission checks require the entry path');
|
|
26
|
+
const facts = windowsPermissions.readOwnerAndDacl(path);
|
|
27
|
+
if (facts.status !== 'supported' || !facts.complete || !facts.isLocal || !facts.daclPresent || facts.unsupportedAceTypes.length) return false;
|
|
28
|
+
const trusted = new Set([facts.currentUserSid, 's-1-5-18', 's-1-5-32-544']);
|
|
29
|
+
return trusted.has(facts.ownerSid) && facts.aces.every(ace =>
|
|
30
|
+
ace.aceType === 'deny' || ace.mask === 0 || trusted.has(ace.sid));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function mkdirPrivate(path, fs, options = {}) {
|
|
34
|
+
if (process.platform !== 'win32') return fs.mkdir(path, { ...options, mode: 0o700 });
|
|
35
|
+
if (options.recursive) {
|
|
36
|
+
try { await fs.lstat(dirname(path)); }
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== 'ENOENT') throw error;
|
|
39
|
+
await mkdirPrivate(dirname(path), fs, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try { await windowsPermissions.createPrivateDirectory(path); }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code !== 'EEXIST' || !options.recursive) throw error;
|
|
45
|
+
}
|
|
46
|
+
const stat = await fs.lstat(path);
|
|
47
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || !privatePermissions(stat, 0o700, path)) {
|
|
48
|
+
throw new Error('private Windows directory permission verification failed');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
14
51
|
|
|
15
52
|
export function sameFileIdentity(left, right) {
|
|
16
53
|
return left.dev === right.dev && left.ino === right.ino;
|
|
17
54
|
}
|
|
18
55
|
|
|
19
|
-
export function verifyPrivateEntry(stat, directory, label, uid = process.getuid?.()) {
|
|
56
|
+
export function verifyPrivateEntry(stat, directory, label, uid = process.getuid?.(), path) {
|
|
20
57
|
const required = directory ? 0o700 : 0o600;
|
|
21
58
|
if (
|
|
22
59
|
stat.isSymbolicLink() ||
|
|
23
60
|
(directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) ||
|
|
24
61
|
(uid !== undefined && stat.uid !== uid) ||
|
|
25
|
-
(stat.mode & required) !== required
|
|
62
|
+
(process.platform === 'win32' ? !privatePermissions(stat, required, path) : (stat.mode & required) !== required)
|
|
26
63
|
) {
|
|
27
64
|
throw new Error(`${label} refuses unsafe ownership, links, type, or owner access`);
|
|
28
65
|
}
|
|
@@ -30,7 +67,9 @@ export function verifyPrivateEntry(stat, directory, label, uid = process.getuid?
|
|
|
30
67
|
|
|
31
68
|
export async function tightenPrivateEntry(path, before, directory, fs, label) {
|
|
32
69
|
const uid = process.getuid?.();
|
|
33
|
-
verifyPrivateEntry(before, directory, label, uid);
|
|
70
|
+
verifyPrivateEntry(before, directory, label, uid, path);
|
|
71
|
+
// Keep descriptor identity checks on both platforms; Windows admits ACLs
|
|
72
|
+
// without attempting POSIX fchmod on the opened directory.
|
|
34
73
|
const handle = await fs.open(
|
|
35
74
|
path,
|
|
36
75
|
constants.O_RDONLY |
|
|
@@ -40,20 +79,20 @@ export async function tightenPrivateEntry(path, before, directory, fs, label) {
|
|
|
40
79
|
);
|
|
41
80
|
try {
|
|
42
81
|
const opened = await handle.stat();
|
|
43
|
-
verifyPrivateEntry(opened, directory, label, uid);
|
|
82
|
+
verifyPrivateEntry(opened, directory, label, uid, path);
|
|
44
83
|
if (!sameFileIdentity(before, opened)) {
|
|
45
84
|
throw new Error(`${label}: the entry changed during permission preparation`);
|
|
46
85
|
}
|
|
47
86
|
const mode = directory ? 0o700 : 0o600;
|
|
48
|
-
if ((opened.mode & 0o7777) !== mode) await handle.chmod(mode);
|
|
87
|
+
if (process.platform !== 'win32' && (opened.mode & 0o7777) !== mode) await handle.chmod(mode);
|
|
49
88
|
const after = await handle.stat();
|
|
50
|
-
verifyPrivateEntry(after, directory, label, uid);
|
|
89
|
+
verifyPrivateEntry(after, directory, label, uid, path);
|
|
51
90
|
const current = await fs.lstat(path);
|
|
52
|
-
verifyPrivateEntry(current, directory, label, uid);
|
|
91
|
+
verifyPrivateEntry(current, directory, label, uid, path);
|
|
53
92
|
if (
|
|
54
93
|
!sameFileIdentity(after, current) ||
|
|
55
|
-
(after
|
|
56
|
-
(current
|
|
94
|
+
!privatePermissions(after, mode, path) ||
|
|
95
|
+
!privatePermissions(current, mode, path)
|
|
57
96
|
) {
|
|
58
97
|
throw new Error(`${label}: permission tightening could not be verified`);
|
|
59
98
|
}
|
|
@@ -64,6 +103,7 @@ export async function tightenPrivateEntry(path, before, directory, fs, label) {
|
|
|
64
103
|
}
|
|
65
104
|
|
|
66
105
|
export async function syncDirectory(path, fs) {
|
|
106
|
+
if (process.platform === 'win32') return;
|
|
67
107
|
let directory;
|
|
68
108
|
try {
|
|
69
109
|
directory = await fs.open(path, 'r');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
|
+
import { privatePermissions, mkdirPrivate } from "./private-paths.js";
|
|
4
5
|
import { execFile } from 'node:child_process';
|
|
5
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
7
|
import { constants } from 'node:fs';
|
|
@@ -242,6 +243,7 @@ async function runGitText(cwd, args, options = {}) {
|
|
|
242
243
|
}
|
|
243
244
|
|
|
244
245
|
async function syncDirectory(path) {
|
|
246
|
+
if (process.platform === 'win32') return;
|
|
245
247
|
let directory;
|
|
246
248
|
try {
|
|
247
249
|
directory = await open(path, 'r');
|
|
@@ -1167,7 +1169,7 @@ async function assertPrivateDirectory(path, label) {
|
|
|
1167
1169
|
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
1168
1170
|
throw new Error(`${label} is not a private directory`);
|
|
1169
1171
|
}
|
|
1170
|
-
if ((info
|
|
1172
|
+
if (!privatePermissions(info, 0o700, path)) {
|
|
1171
1173
|
throw new Error(`${label} permissions must be 0700`);
|
|
1172
1174
|
}
|
|
1173
1175
|
return info;
|
|
@@ -1176,7 +1178,7 @@ async function assertPrivateDirectory(path, label) {
|
|
|
1176
1178
|
async function ensureClaimRoot(path) {
|
|
1177
1179
|
let created = false;
|
|
1178
1180
|
try {
|
|
1179
|
-
await
|
|
1181
|
+
await mkdirPrivate(path, { mkdir, lstat });
|
|
1180
1182
|
created = true;
|
|
1181
1183
|
} catch (error) {
|
|
1182
1184
|
if (errorCode(error) !== 'EEXIST') throw error;
|
|
@@ -1199,7 +1201,7 @@ async function readClaimOwner(activePath) {
|
|
|
1199
1201
|
if (
|
|
1200
1202
|
!pathInfo.isFile() ||
|
|
1201
1203
|
pathInfo.isSymbolicLink() ||
|
|
1202
|
-
(pathInfo
|
|
1204
|
+
!privatePermissions(pathInfo, 0o600, filePath)
|
|
1203
1205
|
) {
|
|
1204
1206
|
throw new Error('repository claim owner is not a private regular file');
|
|
1205
1207
|
}
|
|
@@ -1213,7 +1215,7 @@ async function readClaimOwner(activePath) {
|
|
|
1213
1215
|
const info = await handle.stat();
|
|
1214
1216
|
if (
|
|
1215
1217
|
!info.isFile() ||
|
|
1216
|
-
(info
|
|
1218
|
+
!privatePermissions(info, 0o600, filePath) ||
|
|
1217
1219
|
info.dev !== pathInfo.dev ||
|
|
1218
1220
|
info.ino !== pathInfo.ino
|
|
1219
1221
|
) {
|
|
@@ -1234,7 +1236,7 @@ async function readClaimOwner(activePath) {
|
|
|
1234
1236
|
|
|
1235
1237
|
async function createClaimStage(root, owner) {
|
|
1236
1238
|
const stagePath = join(root, `.stage-${owner.ownerToken}`);
|
|
1237
|
-
await
|
|
1239
|
+
await mkdirPrivate(stagePath, { mkdir, lstat });
|
|
1238
1240
|
await chmod(stagePath, 0o700);
|
|
1239
1241
|
let handle;
|
|
1240
1242
|
try {
|
|
@@ -8,7 +8,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
8
8
|
import { constants } from 'node:fs';
|
|
9
9
|
import * as nativeFs from 'node:fs/promises';
|
|
10
10
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
11
|
-
import { sameFileIdentity, tightenPrivateEntry, verifyPrivateEntry } from './private-paths.js';
|
|
11
|
+
import { sameFileIdentity, tightenPrivateEntry, verifyPrivateEntry, mkdirPrivate, privatePermissions } from './private-paths.js';
|
|
12
12
|
|
|
13
13
|
export const MAX_SESSION_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
14
14
|
const INLINE_BYTES = 16 * 1024;
|
|
@@ -28,14 +28,14 @@ function reference(value) {
|
|
|
28
28
|
value.byteLength < 1 || value.byteLength > MAX_SESSION_CHUNK_BYTES) fail();
|
|
29
29
|
return value;
|
|
30
30
|
}
|
|
31
|
-
function privateStat(stat, directory) {
|
|
31
|
+
function privateStat(stat, directory, path) {
|
|
32
32
|
if (typeof stat.mode === 'bigint') {
|
|
33
33
|
const original = stat;
|
|
34
34
|
stat = { ...stat, mode: Number(stat.mode), uid: Number(stat.uid), nlink: Number(stat.nlink),
|
|
35
35
|
isFile: () => original.isFile(), isDirectory: () => original.isDirectory(), isSymbolicLink: () => original.isSymbolicLink() };
|
|
36
36
|
}
|
|
37
|
-
verifyPrivateEntry(stat, directory, 'session chunk');
|
|
38
|
-
if ((stat
|
|
37
|
+
verifyPrivateEntry(stat, directory, 'session chunk', undefined, path);
|
|
38
|
+
if (!privatePermissions(stat, directory ? 0o700 : 0o600, path)) throw new Error('session chunk permissions must be private');
|
|
39
39
|
}
|
|
40
40
|
function unchanged(before, after) {
|
|
41
41
|
const precise = before.mtimeNs !== undefined && after.mtimeNs !== undefined;
|
|
@@ -64,7 +64,7 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
64
64
|
while (cachedBytes > CACHE_BYTES || verified.size > CACHE_NODES) forget(verified.keys().next().value);
|
|
65
65
|
};
|
|
66
66
|
const operations = new AsyncLocalStorage();
|
|
67
|
-
const freshDirectory = async () => { const stat = await fs.lstat(directory); privateStat(stat, true); return stat; };
|
|
67
|
+
const freshDirectory = async () => { const stat = await fs.lstat(directory); privateStat(stat, true, directory); return stat; };
|
|
68
68
|
const checkDirectory = async () => {
|
|
69
69
|
const context = operations.getStore();
|
|
70
70
|
if (context?.owner) return context.owner;
|
|
@@ -82,10 +82,10 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
82
82
|
const sync = async (path, expected) => {
|
|
83
83
|
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_DIRECTORY ?? 0));
|
|
84
84
|
try {
|
|
85
|
-
const opened = await handle.stat(); privateStat(opened, true);
|
|
85
|
+
const opened = await handle.stat(); privateStat(opened, true, path);
|
|
86
86
|
if (expected && !sameFileIdentity(expected, opened)) throw new Error('session chunk directory changed');
|
|
87
|
-
await handle.sync();
|
|
88
|
-
const current = await fs.lstat(path); privateStat(current, true);
|
|
87
|
+
if (process.platform !== 'win32') await handle.sync();
|
|
88
|
+
const current = await fs.lstat(path); privateStat(current, true, path);
|
|
89
89
|
if (!sameFileIdentity(opened, current)) throw new Error('session chunk directory changed');
|
|
90
90
|
} finally { await handle.close(); }
|
|
91
91
|
};
|
|
@@ -95,8 +95,8 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
95
95
|
try { stat = await fs.lstat(directory); }
|
|
96
96
|
catch (error) {
|
|
97
97
|
if (error.code !== 'ENOENT' || !createDirectory) throw error;
|
|
98
|
-
const parent = await fs.lstat(dirname(directory)); privateStat(parent, true);
|
|
99
|
-
try { await
|
|
98
|
+
const parent = await fs.lstat(dirname(directory)); privateStat(parent, true, dirname(directory));
|
|
99
|
+
try { await mkdirPrivate(directory, fs); }
|
|
100
100
|
catch (cause) { if (cause.code !== 'EEXIST') throw cause; }
|
|
101
101
|
stat = await fs.lstat(directory);
|
|
102
102
|
await sync(dirname(directory), parent);
|
|
@@ -113,12 +113,12 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
113
113
|
if (!match) continue;
|
|
114
114
|
const path = join(directory, name);
|
|
115
115
|
const temporary = await fs.lstat(path);
|
|
116
|
-
if (temporary.nlink === 1) privateStat(temporary, false);
|
|
116
|
+
if (temporary.nlink === 1) privateStat(temporary, false, path);
|
|
117
117
|
else {
|
|
118
118
|
// A crash between exclusive link publication and alias removal may
|
|
119
119
|
// leave precisely these two names for one private store-owned inode.
|
|
120
120
|
if (temporary.isSymbolicLink() || !temporary.isFile() || temporary.nlink !== 2 ||
|
|
121
|
-
(process.getuid && temporary.uid !== process.getuid()) || (temporary
|
|
121
|
+
(process.getuid && temporary.uid !== process.getuid()) || !privatePermissions(temporary, 0o600, path)) throw new Error('unsafe interrupted session chunk publication');
|
|
122
122
|
const destination = await fs.lstat(join(directory, match[1]));
|
|
123
123
|
if (!unchanged(temporary, destination) || destination.isSymbolicLink()) throw new Error('session chunk temporary alias differs from publication');
|
|
124
124
|
pendingAliases.set(match[1], temporary);
|
|
@@ -144,7 +144,7 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
144
144
|
reference(ref);
|
|
145
145
|
const owner = await checkDirectory();
|
|
146
146
|
const path = join(directory, ref.sha256);
|
|
147
|
-
const before = await fs.lstat(path, { bigint: true }); privateStat(before, false);
|
|
147
|
+
const before = await fs.lstat(path, { bigint: true }); privateStat(before, false, path);
|
|
148
148
|
if (BigInt(before.size) !== BigInt(ref.byteLength)) throw new Error('session chunk length mismatch');
|
|
149
149
|
const cached = verified.get(ref.sha256);
|
|
150
150
|
if (!operations.getStore()?.force && cached && before.mtimeNs !== undefined &&
|
|
@@ -155,7 +155,7 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
155
155
|
forget(ref.sha256);
|
|
156
156
|
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
157
157
|
try {
|
|
158
|
-
const opened = await handle.stat({ bigint: true }); privateStat(opened, false);
|
|
158
|
+
const opened = await handle.stat({ bigint: true }); privateStat(opened, false, path);
|
|
159
159
|
if (!unchanged(before, opened)) throw new Error('session chunk changed before read');
|
|
160
160
|
// Read the proven bounded length, rather than readFile on a growing file.
|
|
161
161
|
const bytes = Buffer.alloc(ref.byteLength);
|
|
@@ -165,8 +165,8 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
165
165
|
if (!bytesRead) throw new Error('session chunk truncated during read');
|
|
166
166
|
offset += bytesRead;
|
|
167
167
|
}
|
|
168
|
-
const after = await handle.stat({ bigint: true }); privateStat(after, false);
|
|
169
|
-
const current = await fs.lstat(path, { bigint: true }); privateStat(current, false);
|
|
168
|
+
const after = await handle.stat({ bigint: true }); privateStat(after, false, path);
|
|
169
|
+
const current = await fs.lstat(path, { bigint: true }); privateStat(current, false, path);
|
|
170
170
|
if (!unchanged(opened, after) || !unchanged(after, current) || !sameFileIdentity(owner, await checkDirectory())) throw new Error('session chunk changed during read');
|
|
171
171
|
if (hash(bytes) !== ref.sha256) throw new Error('session chunk digest mismatch');
|
|
172
172
|
if ([before, opened, after, current].every((stat) => stat.mtimeNs !== undefined && stat.ctimeNs !== undefined)) remember(ref.sha256, bytes, current, owner);
|
|
@@ -202,10 +202,10 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
202
202
|
try {
|
|
203
203
|
handle = await fs.open(temporary, 'wx', 0o600);
|
|
204
204
|
await handle.chmod(0o600);
|
|
205
|
-
const opened = await handle.stat(); privateStat(opened, false);
|
|
205
|
+
const opened = await handle.stat(); privateStat(opened, false, temporary);
|
|
206
206
|
await handle.writeFile(bytes); await handle.sync();
|
|
207
|
-
const after = await handle.stat(); privateStat(after, false);
|
|
208
|
-
const current = await fs.lstat(temporary); privateStat(current, false);
|
|
207
|
+
const after = await handle.stat(); privateStat(after, false, temporary);
|
|
208
|
+
const current = await fs.lstat(temporary); privateStat(current, false, temporary);
|
|
209
209
|
if (!sameFileIdentity(opened, after) || !unchanged(after, current) || after.size !== bytes.length || !sameFileIdentity(owner, await checkDirectory())) throw new Error('session chunk changed during write');
|
|
210
210
|
await handle.close(); handle = undefined;
|
|
211
211
|
// link is exclusive publication; remove its temporary alias before any
|
|
@@ -384,9 +384,9 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
384
384
|
const temporary = /^\.write-([0-9a-f]{64})-[0-9a-f-]{36}\.tmp$/.exec(name);
|
|
385
385
|
if (temporary && stat.nlink === 2) {
|
|
386
386
|
if (stat.isSymbolicLink() || !stat.isFile() ||
|
|
387
|
-
(process.getuid && stat.uid !== process.getuid()) || (stat
|
|
387
|
+
(process.getuid && stat.uid !== process.getuid()) || !privatePermissions(stat, 0o600, path) ||
|
|
388
388
|
!unchanged(stat, await fs.lstat(join(directory, temporary[1])))) throw new Error('unsafe interrupted session chunk publication');
|
|
389
|
-
} else privateStat(stat, false);
|
|
389
|
+
} else privateStat(stat, false, join(directory, name));
|
|
390
390
|
continue;
|
|
391
391
|
}
|
|
392
392
|
if (stat.size > MAX_SESSION_CHUNK_BYTES) throw new Error('session chunk exceeds file bound');
|
|
@@ -409,10 +409,10 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
409
409
|
const names = await fs.readdir(directory);
|
|
410
410
|
// Validate every candidate before removing any; links and subdirectories
|
|
411
411
|
// are never followed or recursively removed.
|
|
412
|
-
for (const name of names) privateStat(await fs.lstat(join(directory, name)), false);
|
|
412
|
+
for (const name of names) privateStat(await fs.lstat(join(directory, name)), false, join(directory, name));
|
|
413
413
|
for (const name of names) {
|
|
414
414
|
if (!sameFileIdentity(owner, await checkDirectory())) throw new Error('session chunk directory changed during deletion');
|
|
415
|
-
const path = join(directory, name); privateStat(await fs.lstat(path), false); await fs.unlink(path);
|
|
415
|
+
const path = join(directory, name); privateStat(await fs.lstat(path), false, path); await fs.unlink(path);
|
|
416
416
|
}
|
|
417
417
|
await sync(directory, owner);
|
|
418
418
|
await fs.rmdir(directory);
|
|
@@ -8,7 +8,7 @@ import { constants } from 'node:fs';
|
|
|
8
8
|
import * as nativeFs from 'node:fs/promises';
|
|
9
9
|
import { isAbsolute, join } from 'node:path';
|
|
10
10
|
import { createSessionChunks, MAX_SESSION_CHUNK_BYTES } from './session-chunks.js';
|
|
11
|
-
import { sameFileIdentity, tightenPrivateEntry, verifyPrivateEntry } from './private-paths.js';
|
|
11
|
+
import { sameFileIdentity, tightenPrivateEntry, verifyPrivateEntry, privatePermissions } from './private-paths.js';
|
|
12
12
|
|
|
13
13
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
14
14
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
@@ -21,9 +21,9 @@ function reference(value) {
|
|
|
21
21
|
!count(value.byteLength) || value.byteLength < 1 || value.byteLength > MAX_SESSION_CHUNK_BYTES) invalid();
|
|
22
22
|
return value;
|
|
23
23
|
}
|
|
24
|
-
function privateStat(stat, directory) {
|
|
25
|
-
verifyPrivateEntry(stat, directory, 'session replay');
|
|
26
|
-
if ((stat
|
|
24
|
+
function privateStat(stat, directory, path) {
|
|
25
|
+
verifyPrivateEntry(stat, directory, 'session replay', undefined, path);
|
|
26
|
+
if (!privatePermissions(stat, directory ? 0o700 : 0o600, path)) throw new Error('session replay permissions must be private');
|
|
27
27
|
}
|
|
28
28
|
const unchanged = (left, right) => sameFileIdentity(left, right) && left.size === right.size &&
|
|
29
29
|
left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
@@ -32,8 +32,8 @@ function rootChanged(message) {
|
|
|
32
32
|
error.replayRootRetry = true;
|
|
33
33
|
return error;
|
|
34
34
|
}
|
|
35
|
-
function privateRootStat(stat) {
|
|
36
|
-
try { privateStat(stat, false); }
|
|
35
|
+
function privateRootStat(stat, path) {
|
|
36
|
+
try { privateStat(stat, false, path); }
|
|
37
37
|
catch (error) {
|
|
38
38
|
// A complete initial root can briefly have its staging link, and an
|
|
39
39
|
// already opened old root loses its last link on atomic replacement.
|
|
@@ -81,14 +81,14 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
81
81
|
return result;
|
|
82
82
|
};
|
|
83
83
|
const directory = async () => {
|
|
84
|
-
const stat = await fs.lstat(sessionsDir); privateStat(stat, true); return stat;
|
|
84
|
+
const stat = await fs.lstat(sessionsDir); privateStat(stat, true, sessionsDir); return stat;
|
|
85
85
|
};
|
|
86
86
|
const syncDirectory = async (expected) => {
|
|
87
87
|
const handle = await fs.open(sessionsDir, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_DIRECTORY ?? 0));
|
|
88
88
|
try {
|
|
89
|
-
const stat = await handle.stat(); privateStat(stat, true);
|
|
89
|
+
const stat = await handle.stat(); privateStat(stat, true, sessionsDir);
|
|
90
90
|
if (!sameFileIdentity(expected, stat)) throw new Error('session replay directory changed');
|
|
91
|
-
await handle.sync();
|
|
91
|
+
if (process.platform !== 'win32') await handle.sync();
|
|
92
92
|
if (!sameFileIdentity(stat, await directory())) throw new Error('session replay directory changed');
|
|
93
93
|
} finally { await handle.close(); }
|
|
94
94
|
};
|
|
@@ -104,11 +104,11 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
104
104
|
let before;
|
|
105
105
|
try { before = await fs.lstat(path); }
|
|
106
106
|
catch (error) { if (error.code === 'ENOENT') return { root: null, rootBytes: null, stat: null, owner }; throw error; }
|
|
107
|
-
privateRootStat(before);
|
|
107
|
+
privateRootStat(before, path);
|
|
108
108
|
if (before.size > MAX_SESSION_CHUNK_BYTES) throw new Error('session replay root exceeds file bound');
|
|
109
109
|
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
110
110
|
try {
|
|
111
|
-
const opened = await handle.stat(); privateRootStat(opened);
|
|
111
|
+
const opened = await handle.stat(); privateRootStat(opened, path);
|
|
112
112
|
if (!unchanged(before, opened)) throw rootChanged('session replay root changed before read');
|
|
113
113
|
const bytes = Buffer.alloc(opened.size);
|
|
114
114
|
let offset = 0;
|
|
@@ -117,7 +117,7 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
117
117
|
if (!bytesRead) throw rootChanged('session replay root truncated during read');
|
|
118
118
|
offset += bytesRead;
|
|
119
119
|
}
|
|
120
|
-
const after = await handle.stat(); privateRootStat(after);
|
|
120
|
+
const after = await handle.stat(); privateRootStat(after, path);
|
|
121
121
|
if (!unchanged(opened, after)) throw rootChanged('session replay root changed during read');
|
|
122
122
|
if (!sameFileIdentity(owner, await directory())) throw new Error('session replay directory changed during read');
|
|
123
123
|
return { root: validateRoot(JSON.parse(bytes.toString('utf8'))), rootBytes: bytes, stat: after, owner };
|
|
@@ -137,7 +137,7 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
137
137
|
throw new Error('session replay root changed before publication');
|
|
138
138
|
}
|
|
139
139
|
if (snapshot.stat !== null) {
|
|
140
|
-
const latest = await fs.lstat(path); privateStat(latest, false);
|
|
140
|
+
const latest = await fs.lstat(path); privateStat(latest, false, path);
|
|
141
141
|
if (!unchanged(snapshot.stat, latest)) throw new Error('session replay root changed before publication');
|
|
142
142
|
}
|
|
143
143
|
};
|
|
@@ -177,10 +177,10 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
177
177
|
await assertSnapshot(snapshot);
|
|
178
178
|
handle = await fs.open(temporary, 'wx', 0o600);
|
|
179
179
|
await handle.chmod(0o600);
|
|
180
|
-
temporaryStat = await handle.stat(); privateStat(temporaryStat, false);
|
|
180
|
+
temporaryStat = await handle.stat(); privateStat(temporaryStat, false, temporary);
|
|
181
181
|
await handle.writeFile(bytes); await handle.sync();
|
|
182
|
-
const after = await handle.stat(); privateStat(after, false);
|
|
183
|
-
const current = await fs.lstat(temporary); privateStat(current, false);
|
|
182
|
+
const after = await handle.stat(); privateStat(after, false, temporary);
|
|
183
|
+
const current = await fs.lstat(temporary); privateStat(current, false, temporary);
|
|
184
184
|
if (!sameFileIdentity(temporaryStat, after) || !sameFileIdentity(after, current)) throw new Error('session replay temporary root changed');
|
|
185
185
|
await handle.close(); handle = undefined;
|
|
186
186
|
await assertOwner();
|
|
@@ -191,7 +191,7 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
191
191
|
// name before readers' single-link verification and directory syncing.
|
|
192
192
|
if (exclusive || snapshot.root === null) await fs.unlink(temporary);
|
|
193
193
|
await syncDirectory(snapshot.owner);
|
|
194
|
-
const published = await fs.lstat(path); privateStat(published, false);
|
|
194
|
+
const published = await fs.lstat(path); privateStat(published, false, path);
|
|
195
195
|
if (!sameFileIdentity(temporaryStat, published) || published.size !== bytes.length) throw new Error('session replay root changed after publication');
|
|
196
196
|
const completed = { owner: snapshot.owner, stat: published, rootBytes: bytes };
|
|
197
197
|
mutationAnchor = proofOf(completed);
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
unlink,
|
|
29
29
|
} from 'node:fs/promises';
|
|
30
30
|
import { homedir, hostname as systemHostname } from 'node:os';
|
|
31
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
31
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
32
32
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
33
33
|
import { isDeepStrictEqual } from 'node:util';
|
|
34
34
|
import { assertSupportedEffort } from '@sublang/cligent';
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
lazyMachineIdentity,
|
|
52
52
|
resolveMachineIdentity,
|
|
53
53
|
} from './machine-identity.js';
|
|
54
|
-
import { sameFileIdentity, syncDirectory, tightenPrivateEntry } from './private-paths.js';
|
|
54
|
+
import { sameFileIdentity, syncDirectory, tightenPrivateEntry, mkdirPrivate, privatePermissions } from './private-paths.js';
|
|
55
55
|
import { createSessionChunks } from './session-chunks.js';
|
|
56
56
|
import { createBoundedReplay } from './session-replay.js';
|
|
57
57
|
import { decodeStoredManifest, encodeStoredManifest, SESSION_STORAGE_VERSION } from './storage-manifest.js';
|
|
@@ -309,7 +309,7 @@ export function sanitizeReplayRecord(value) {
|
|
|
309
309
|
// Bounded physical replay preserves the public readable/durable distinction.
|
|
310
310
|
// A failed append latches history incompleteness; recovery writes remain open.
|
|
311
311
|
async function createLeaseReplayWriter({ sessionsDir, path, fs, assertOwner, readStream, chunks }) {
|
|
312
|
-
const sessionId =
|
|
312
|
+
const sessionId = basename(path, '.records.jsonl');
|
|
313
313
|
const backend = await createBoundedReplay({ sessionId, sessionsDir, fsOps: fs, assertOwner, chunks });
|
|
314
314
|
let lastReadableSeq = null, lastDurableSeq = null, incomplete = true, closed = false;
|
|
315
315
|
let tail = Promise.resolve();
|
|
@@ -895,7 +895,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
895
895
|
ownsTemporary = true;
|
|
896
896
|
await handle.chmod(0o600);
|
|
897
897
|
const tempStat = await handle.stat();
|
|
898
|
-
if (!tempStat.isFile() || (tempStat
|
|
898
|
+
if (!tempStat.isFile() || !privatePermissions(tempStat, 0o600, temporary)) {
|
|
899
899
|
throw new Error('Captain session temporary path is not a private regular file');
|
|
900
900
|
}
|
|
901
901
|
await handle.writeFile(encoded);
|
|
@@ -991,7 +991,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
991
991
|
if (
|
|
992
992
|
directoryStat.isSymbolicLink() ||
|
|
993
993
|
!directoryStat.isDirectory() ||
|
|
994
|
-
(directoryStat
|
|
994
|
+
!privatePermissions(directoryStat, 0o700, path)
|
|
995
995
|
) {
|
|
996
996
|
throw new Error('lease path is not a private real directory');
|
|
997
997
|
}
|
|
@@ -1160,21 +1160,21 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1160
1160
|
let handle;
|
|
1161
1161
|
let created = false;
|
|
1162
1162
|
try {
|
|
1163
|
-
await
|
|
1163
|
+
await mkdirPrivate(stagePath, fs);
|
|
1164
1164
|
created = true;
|
|
1165
1165
|
await fs.chmod(stagePath, 0o700);
|
|
1166
1166
|
const stageStat = await fs.lstat(stagePath);
|
|
1167
1167
|
if (
|
|
1168
1168
|
stageStat.isSymbolicLink() ||
|
|
1169
1169
|
!stageStat.isDirectory() ||
|
|
1170
|
-
(stageStat
|
|
1170
|
+
!privatePermissions(stageStat, 0o700, stagePath)
|
|
1171
1171
|
) {
|
|
1172
1172
|
throw new Error('Captain session lease stage is not a private directory');
|
|
1173
1173
|
}
|
|
1174
1174
|
handle = await fs.open(join(stagePath, LEASE_OWNER_FILE), 'wx', 0o600);
|
|
1175
1175
|
await handle.chmod(0o600);
|
|
1176
1176
|
const ownerStat = await handle.stat();
|
|
1177
|
-
if (!ownerStat.isFile() || (ownerStat
|
|
1177
|
+
if (!ownerStat.isFile() || !privatePermissions(ownerStat, 0o600, join(stagePath, LEASE_OWNER_FILE))) {
|
|
1178
1178
|
throw new Error('Captain session lease owner is not a private regular file');
|
|
1179
1179
|
}
|
|
1180
1180
|
await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8');
|
|
@@ -1922,7 +1922,7 @@ async function readReplaySnapshot({
|
|
|
1922
1922
|
|
|
1923
1923
|
// During explicit migration the old root remains authoritative until the
|
|
1924
1924
|
// new manifest publishes. New replay-only sessions have no manifest yet.
|
|
1925
|
-
const sessionId =
|
|
1925
|
+
const sessionId = basename(path, '.records.jsonl');
|
|
1926
1926
|
let storedRoot;
|
|
1927
1927
|
try { storedRoot = JSON.parse(await readPrivateRegularFile(join(sessionsDir, `${sessionId}.json`), 0o600, fs, 'record')); }
|
|
1928
1928
|
catch (cause) { if (cause?.code !== 'ENOENT' && !(cause instanceof SyntaxError)) throw cause; }
|
|
@@ -1963,7 +1963,7 @@ async function readReplaySnapshot({
|
|
|
1963
1963
|
|
|
1964
1964
|
try {
|
|
1965
1965
|
const openedStat = await handle.stat();
|
|
1966
|
-
assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
|
|
1966
|
+
assertPrivateRegularStat(openedStat, 0o600, 'replay stream', path);
|
|
1967
1967
|
if (!sameFileIdentity(pathStat, openedStat)) {
|
|
1968
1968
|
throw new ReplaySnapshotChangedError(
|
|
1969
1969
|
'replay stream was replaced while opening its snapshot',
|
|
@@ -1987,7 +1987,7 @@ async function readReplaySnapshot({
|
|
|
1987
1987
|
const bytes = await readReplayRange(handle, startOffset, snapshotLength);
|
|
1988
1988
|
|
|
1989
1989
|
const finalHandleStat = await handle.stat();
|
|
1990
|
-
assertPrivateRegularStat(finalHandleStat, 0o600, 'replay stream');
|
|
1990
|
+
assertPrivateRegularStat(finalHandleStat, 0o600, 'replay stream', path);
|
|
1991
1991
|
if (
|
|
1992
1992
|
!sameFileIdentity(openedStat, finalHandleStat) ||
|
|
1993
1993
|
requireReplayFileSize(finalHandleStat.size) < snapshotLength
|
|
@@ -2225,11 +2225,11 @@ function requireReplayFileSize(value) {
|
|
|
2225
2225
|
return value;
|
|
2226
2226
|
}
|
|
2227
2227
|
|
|
2228
|
-
function assertPrivateRegularStat(stat, mode, label) {
|
|
2228
|
+
function assertPrivateRegularStat(stat, mode, label, path) {
|
|
2229
2229
|
if (!stat.isFile()) {
|
|
2230
2230
|
throw new Error(`${label} path is not a regular file`);
|
|
2231
2231
|
}
|
|
2232
|
-
if ((stat
|
|
2232
|
+
if (!privatePermissions(stat, mode, path)) {
|
|
2233
2233
|
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
2234
2234
|
}
|
|
2235
2235
|
}
|
|
@@ -6412,7 +6412,7 @@ async function readPrivateRegularFile(path, mode, fs, label, raw = false) {
|
|
|
6412
6412
|
if (!stat.isFile()) {
|
|
6413
6413
|
throw new Error(`${label} path is not a regular file`);
|
|
6414
6414
|
}
|
|
6415
|
-
if ((stat
|
|
6415
|
+
if (!privatePermissions(stat, mode, path)) {
|
|
6416
6416
|
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
6417
6417
|
}
|
|
6418
6418
|
if (!sameFileIdentity(before, stat)) throw privateFileChanged(label, 'open');
|
|
@@ -6431,7 +6431,7 @@ async function assertPrivateRegularPath(path, mode, fs, label) {
|
|
|
6431
6431
|
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
6432
6432
|
throw new Error(`${label} path is not a real regular file`);
|
|
6433
6433
|
}
|
|
6434
|
-
if ((stat
|
|
6434
|
+
if (!privatePermissions(stat, mode, path)) {
|
|
6435
6435
|
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
6436
6436
|
}
|
|
6437
6437
|
return stat;
|
|
@@ -6486,7 +6486,7 @@ async function ensurePrivateDirectory(path, fs, mayCreate = () => true) {
|
|
|
6486
6486
|
for (const directory of missing.reverse()) {
|
|
6487
6487
|
let created = false;
|
|
6488
6488
|
try {
|
|
6489
|
-
await
|
|
6489
|
+
await mkdirPrivate(directory, fs);
|
|
6490
6490
|
created = true;
|
|
6491
6491
|
} catch (cause) {
|
|
6492
6492
|
if (cause?.code !== 'EEXIST') throw cause;
|
|
@@ -6497,7 +6497,7 @@ async function ensurePrivateDirectory(path, fs, mayCreate = () => true) {
|
|
|
6497
6497
|
await syncDirectory(dirname(directory), fs);
|
|
6498
6498
|
} else if (
|
|
6499
6499
|
directory === path &&
|
|
6500
|
-
(stat
|
|
6500
|
+
!privatePermissions(stat, 0o700, path)
|
|
6501
6501
|
) {
|
|
6502
6502
|
throw new Error('Captain session store directory permissions must be 0700');
|
|
6503
6503
|
}
|
|
@@ -6533,7 +6533,7 @@ function isPathOrAncestor(directory, path) {
|
|
|
6533
6533
|
|
|
6534
6534
|
async function assertPrivateDirectory(path, fs) {
|
|
6535
6535
|
const stat = await assertDirectoryNotLink(path, fs);
|
|
6536
|
-
if ((stat
|
|
6536
|
+
if (!privatePermissions(stat, 0o700, path)) {
|
|
6537
6537
|
throw new Error('Captain session store directory permissions must be 0700');
|
|
6538
6538
|
}
|
|
6539
6539
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { constants } from 'node:fs';
|
|
6
|
+
import { mkdirPrivate, privatePermissions } from './bin/private-paths.js';
|
|
6
7
|
import { link, lstat, mkdir, open, readdir, unlink } from 'node:fs/promises';
|
|
7
8
|
import { basename, join, resolve } from 'node:path';
|
|
8
9
|
|
|
@@ -64,21 +65,21 @@ export async function externalizeAgentEvent(store, event) {
|
|
|
64
65
|
function assertOwner(stat, label) {
|
|
65
66
|
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) throw new Error(`${label} is not owned by the current user`);
|
|
66
67
|
}
|
|
67
|
-
function assertDirectory(stat) {
|
|
68
|
+
function assertDirectory(stat, path) {
|
|
68
69
|
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('asset owner directory must be a real directory');
|
|
69
70
|
assertOwner(stat, 'asset owner directory');
|
|
70
|
-
if (process.platform
|
|
71
|
+
if (process.platform === 'win32' ? !privatePermissions(stat, 0o700, path) : (stat.mode & 0o777) !== 0o700) throw new Error('asset owner directory must have private 0700 permissions');
|
|
71
72
|
}
|
|
72
|
-
function assertFile(stat, label) {
|
|
73
|
+
function assertFile(stat, label, path) {
|
|
73
74
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error(`${label} must be a single-link regular file`);
|
|
74
75
|
assertOwner(stat, label);
|
|
75
|
-
if (process.platform
|
|
76
|
+
if (process.platform === 'win32' ? !privatePermissions(stat, 0o600, path) : (stat.mode & 0o777) !== 0o600) throw new Error(`${label} must have private 0600 permissions`);
|
|
76
77
|
}
|
|
77
78
|
function sameFile(a, b) { return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs; }
|
|
78
79
|
async function syncDirectory(directory) {
|
|
79
80
|
if (process.platform === 'win32') return;
|
|
80
81
|
const handle = await open(directory, constants.O_RDONLY | noFollow);
|
|
81
|
-
try { assertDirectory(await handle.stat()); await handle.sync(); } finally { await handle.close(); }
|
|
82
|
+
try { assertDirectory(await handle.stat(), directory); await handle.sync(); } finally { await handle.close(); }
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/** One immutable owner. A session host holds its ordinary lease around mutations. */
|
|
@@ -93,10 +94,10 @@ export function createAssetStore(options) {
|
|
|
93
94
|
let importTail = Promise.resolve();
|
|
94
95
|
const ensureDirectory = async (create) => {
|
|
95
96
|
if (create) {
|
|
96
|
-
try { await
|
|
97
|
+
try { await mkdirPrivate(directory, { mkdir, lstat }, { recursive: createAncestors }); }
|
|
97
98
|
catch (error) { if (createAncestors || error.code !== 'EEXIST') throw error; }
|
|
98
99
|
}
|
|
99
|
-
assertDirectory(await lstat(directory));
|
|
100
|
+
assertDirectory(await lstat(directory), directory);
|
|
100
101
|
};
|
|
101
102
|
const prepare = async () => {
|
|
102
103
|
let before;
|
|
@@ -107,6 +108,7 @@ export function createAssetStore(options) {
|
|
|
107
108
|
const directoryHandle = await open(directory, constants.O_RDONLY | noFollow);
|
|
108
109
|
try {
|
|
109
110
|
const opened = await directoryHandle.stat();
|
|
111
|
+
if (process.platform === 'win32') assertDirectory(opened, directory);
|
|
110
112
|
if (opened.dev !== before.dev || opened.ino !== before.ino) throw new Error('asset owner directory changed during preparation');
|
|
111
113
|
// fchmod always moves ctime, which verified readers pin: change only modes that differ.
|
|
112
114
|
if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) await directoryHandle.chmod(0o700);
|
|
@@ -123,25 +125,25 @@ export function createAssetStore(options) {
|
|
|
123
125
|
const pinned = await handle.stat();
|
|
124
126
|
if (!sameFile(stat, pinned)) throw new Error('asset entry changed during preparation');
|
|
125
127
|
if (process.platform !== 'win32' && (pinned.mode & 0o777) !== 0o600) await handle.chmod(0o600);
|
|
126
|
-
assertFile(await handle.stat(), 'asset entry');
|
|
128
|
+
assertFile(await handle.stat(), 'asset entry', path);
|
|
127
129
|
const final = await lstat(path);
|
|
128
130
|
if (final.dev !== pinned.dev || final.ino !== pinned.ino || final.nlink !== 1) throw new Error('asset entry changed during preparation');
|
|
129
131
|
} finally { await handle.close(); }
|
|
130
132
|
}
|
|
131
133
|
const final = await lstat(directory);
|
|
132
134
|
if (final.dev !== opened.dev || final.ino !== opened.ino || final.isSymbolicLink()) throw new Error('asset owner directory changed during preparation');
|
|
133
|
-
assertDirectory(await directoryHandle.stat());
|
|
135
|
+
assertDirectory(await directoryHandle.stat(), directory);
|
|
134
136
|
} finally { await directoryHandle.close(); }
|
|
135
137
|
};
|
|
136
138
|
const checkedOpen = async (filename, label) => {
|
|
137
139
|
await ensureDirectory(false);
|
|
138
140
|
const path = join(directory, filename);
|
|
139
141
|
const before = await lstat(path);
|
|
140
|
-
assertFile(before, label);
|
|
142
|
+
assertFile(before, label, path);
|
|
141
143
|
const handle = await open(path, constants.O_RDONLY | noFollow);
|
|
142
144
|
try {
|
|
143
145
|
const after = await handle.stat();
|
|
144
|
-
assertFile(after, label);
|
|
146
|
+
assertFile(after, label, path);
|
|
145
147
|
if (!sameFile(before, after)) throw new Error(`${label} changed while opening`);
|
|
146
148
|
return { handle, stat: after };
|
|
147
149
|
} catch (error) { await handle.close(); throw error; }
|