@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.
@@ -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.0",
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.1",
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 fs.mkdir(directory, { recursive: true, mode: 0o700 });
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.mode & 0o7777) !== mode ||
56
- (current.mode & 0o7777) !== mode
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.mode & 0o7777) !== 0o700) {
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 mkdir(path, { mode: 0o700 });
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.mode & 0o7777) !== 0o600
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.mode & 0o7777) !== 0o600 ||
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 mkdir(stagePath, { mode: 0o700 });
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.mode & 0o7777) !== (directory ? 0o700 : 0o600)) throw new Error('session chunk permissions must be private');
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 fs.mkdir(directory, { mode: 0o700 }); }
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.mode & 0o7777) !== 0o600) throw new Error('unsafe interrupted session chunk publication');
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.mode & 0o7777) !== 0o600 ||
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.mode & 0o7777) !== (directory ? 0o700 : 0o600)) throw new Error('session replay permissions must be private');
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 = path.slice(path.lastIndexOf('/') + 1, -'.records.jsonl'.length);
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.mode & 0o7777) !== 0o600) {
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.mode & 0o7777) !== 0o700
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 fs.mkdir(stagePath, { mode: 0o700 });
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.mode & 0o7777) !== 0o700
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.mode & 0o7777) !== 0o600) {
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 = path.slice(path.lastIndexOf('/') + 1, -'.records.jsonl'.length);
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.mode & 0o7777) !== mode) {
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.mode & 0o7777) !== mode) {
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.mode & 0o7777) !== mode) {
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 fs.mkdir(directory, { mode: 0o700 });
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.mode & 0o7777) !== 0o700
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.mode & 0o7777) !== 0o700) {
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 !== 'win32' && (stat.mode & 0o777) !== 0o700) throw new Error('asset owner directory must have private 0700 permissions');
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 !== 'win32' && (stat.mode & 0o777) !== 0o600) throw new Error(`${label} must have private 0600 permissions`);
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 mkdir(directory, { recursive: createAncestors, mode: 0o700 }); }
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; }