@sublang/playbook 18.0.1 → 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/README.md CHANGED
@@ -32,8 +32,9 @@ Run `playbook` for an interactive tmux UI powered by [cligent](https://github.co
32
32
  Out of the box, Playbook includes **CODE** for implementation, **REVIEW** for commit-based review and fixes, **DECIDE** for independently proposed and reviewed specification decisions, **DEV** for repository-aware planning of a development request, **BRANCH** for checking out a new branch for a GitHub issue, **PR** for publishing, checking, and merging that branch through a pull request, and **INSPECT** for explaining supplied material or a running interface without changing the repository.
33
33
  CODE and DECIDE call REVIEW as a nested playbook; DEV calls CODE, or DECIDE and then CODE, and wraps them in BRANCH and PR when the request names a GitHub issue or asks for a pull request.
34
34
 
35
- The shared starter config selects one adapter for the Captain and all players
36
- from locally visible credentials: Claude first, then Codex. If neither is
35
+ The shared starter config selects the Captain's and players' adapter from
36
+ locally visible credentials: Claude first, then Codex, and where both are
37
+ configured only `dev.reviewer` seeds on Codex. If neither is
37
38
  configured, it seeds Claude and prints a notice. CODE, REVIEW, DECIDE, BRANCH, and PR bind
38
39
  their local roles explicitly to `dev.coder` and `dev.reviewer`, so nested and later
39
40
  engagements share a conversation only where their bindings name the same
@@ -51,7 +52,7 @@ If an SDK is missing or older than cligent supports, Playbook prints the pinned
51
52
  Prerequisites:
52
53
 
53
54
  - Node.js >= 20.6.0
54
- - Credentials for the configured adapter: authenticated [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) or `ANTHROPIC_API_KEY` for Claude; authenticated [Codex CLI](https://github.com/openai/codex) or `OPENAI_API_KEY` for Codex
55
+ - Credentials for the configured adapter: authenticated [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) or `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` for Claude; authenticated [Codex CLI](https://github.com/openai/codex) or `OPENAI_API_KEY` / `CODEX_ACCESS_TOKEN` for Codex
55
56
  - [GitHub CLI](https://cli.github.com/) (`gh`) authenticated for the repository's GitHub remote, for `/branch`, `/pr`, and `/dev` requests that name an issue or ask for a pull request
56
57
 
57
58
  Interactive `playbook` additionally needs tmux 3.3 or newer and [`glow`](https://github.com/charmbracelet/glow#installation) on `PATH`; headless `playbook run` does not.
@@ -64,16 +64,23 @@ one; see [Modules supplied at launch](#modules-supplied-at-launch). Every
64
64
  manifest role must be present exactly once. The launcher injects the rest — you do not write host wiring by
65
65
  hand.
66
66
 
67
- The launcher seeds one adapter for the Captain and all stable players from
68
- locally visible credentials: Claude first, then Codex. It uses Claude Opus 5.5
69
- for Claude or GPT-6 Sol for Codex, with Captain and Coder at `high` effort
70
- and Reviewer and Analyst at `xhigh`. If neither adapter is configured, it
71
- seeds Claude and prints a notice. Credentials do not prove SDK availability
72
- or remaining quota; launch still checks the configured adapter. Existing
73
- configs are left unchanged.
67
+ The launcher seeds the Captain and all stable players from locally visible
68
+ credentials (an API key in the environment, or `~/.claude` / `~/.codex`):
74
69
 
75
- The Claude default is shown below. A Codex seed also adds
76
- `permissions.writablePaths: ['.git']` to each agent:
70
+ | Credentials | Seeded agents |
71
+ | --- | --- |
72
+ | Claude only, or neither | every agent on Claude Opus 5.5 (with a notice when neither is configured) |
73
+ | Codex only | every agent on GPT-6 Sol |
74
+ | both | `dev.reviewer` on GPT-6 Sol, every other agent on Claude Opus 5.5 |
75
+
76
+ Captain, Coder, and Inspector run at `high` effort and Reviewer and Analyst at
77
+ `xhigh`. Credentials do not prove SDK availability or remaining quota; launch
78
+ still checks the configured adapter. Existing configs are left unchanged.
79
+ Embedding hosts seed the same agent settings through
80
+ `seedConfigText({ env, homeDir })` from `@sublang/playbook/seed-config`.
81
+
82
+ The Claude default is shown below. Each Codex agent also carries
83
+ `permissions.writablePaths: ['.git']`:
77
84
 
78
85
  ```yaml
79
86
  captain:
@@ -273,9 +280,12 @@ It may not finish the specialist’s task or invent a decision; discarding work
273
280
  Preparation has a 150-second limit.
274
281
 
275
282
  Adapter readiness is intentionally light: `claude` is ready with local
276
- Claude Code auth or `ANTHROPIC_API_KEY`; `codex` with local Codex CLI
277
- auth or `OPENAI_API_KEY`. A known adapter that is not ready blocks the
278
- launch and prints the help text.
283
+ Claude Code auth, `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or
284
+ `CLAUDE_CODE_OAUTH_TOKEN`; `codex` with local Codex CLI auth,
285
+ `OPENAI_API_KEY`, `CODEX_API_KEY`, or `CODEX_ACCESS_TOKEN`. Only presence
286
+ counts; no token is checked. A known adapter that is not ready blocks the
287
+ launch and prints the help text. The same check picks the starter config's
288
+ lineup.
279
289
 
280
290
  ## Skills across agents
281
291
 
@@ -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.0.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",
@@ -83,6 +83,8 @@
83
83
  "reference/sdlc/code.playbook/host-capabilities.d.ts",
84
84
  "reference/sdlc/code.playbook/machine-identity.js",
85
85
  "reference/sdlc/code.playbook/machine-identity.d.ts",
86
+ "reference/sdlc/code.playbook/seed-config.js",
87
+ "reference/sdlc/code.playbook/seed-config.d.ts",
86
88
  "reference/sdlc/code.playbook/code.gears.md",
87
89
  "reference/sdlc/code.playbook/playbook.config.template.yaml",
88
90
  "reference/sdlc/code.playbook/bin/playbook.js",
@@ -212,6 +214,10 @@
212
214
  "types": "./reference/sdlc/code.playbook/machine-identity.d.ts",
213
215
  "default": "./reference/sdlc/code.playbook/machine-identity.js"
214
216
  },
217
+ "./seed-config": {
218
+ "types": "./reference/sdlc/code.playbook/seed-config.d.ts",
219
+ "default": "./reference/sdlc/code.playbook/seed-config.js"
220
+ },
215
221
  "./slc/*": "./slc/*",
216
222
  "./review/playbook": {
217
223
  "types": "./reference/sdlc/review.playbook/review.playbook.d.ts",
@@ -284,7 +290,7 @@
284
290
  "provenance": true
285
291
  },
286
292
  "dependencies": {
287
- "@sublang/cligent": "^0.36.1",
293
+ "@sublang/cligent": "^0.36.2",
288
294
  "@sublang/spex": "^3.0.0",
289
295
  "p-queue": "^9.3.1",
290
296
  "xstate": "^5.19.4",
@@ -296,5 +302,8 @@
296
302
  "@types/node": "^22.0.0",
297
303
  "typescript": "^5.8.0",
298
304
  "vitest": "^3.0.0"
305
+ },
306
+ "optionalDependencies": {
307
+ "@openclaw/fs-safe": "0.26.13"
299
308
  }
300
309
  }
@@ -1438,23 +1438,36 @@ export function adaptersFromComposedConfig(config) {
1438
1438
  return [...adapters];
1439
1439
  }
1440
1440
 
1441
+ // DR-097: each agent's documented environment forms, any one of which, or the
1442
+ // agent's sign-in folder, counts it as ready. No value is read.
1443
+ const CREDENTIAL_FORMS = {
1444
+ claude: {
1445
+ names: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"],
1446
+ folder: ".claude",
1447
+ },
1448
+ codex: {
1449
+ names: ["OPENAI_API_KEY", "CODEX_API_KEY", "CODEX_ACCESS_TOKEN"],
1450
+ folder: ".codex",
1451
+ },
1452
+ };
1453
+
1441
1454
  export function checkReadiness(adapters, env = process.env, home = homedir()) {
1442
1455
  const failingAdapters = [];
1443
1456
  const unknownAdapters = [];
1444
1457
  for (const adapter of adapters) {
1445
- if (adapter === "claude") {
1446
- if (!env.ANTHROPIC_API_KEY && !existsSync(join(home, ".claude"))) {
1447
- failingAdapters.push(adapter);
1448
- }
1458
+ const forms = Object.hasOwn(CREDENTIAL_FORMS, adapter)
1459
+ ? CREDENTIAL_FORMS[adapter]
1460
+ : undefined;
1461
+ if (!forms) {
1462
+ unknownAdapters.push(adapter);
1449
1463
  continue;
1450
1464
  }
1451
- if (adapter === "codex") {
1452
- if (!env.OPENAI_API_KEY && !existsSync(join(home, ".codex"))) {
1453
- failingAdapters.push(adapter);
1454
- }
1455
- continue;
1465
+ if (
1466
+ !forms.names.some((name) => env[name]) &&
1467
+ !existsSync(join(home, forms.folder))
1468
+ ) {
1469
+ failingAdapters.push(adapter);
1456
1470
  }
1457
- unknownAdapters.push(adapter);
1458
1471
  }
1459
1472
  return { failingAdapters, unknownAdapters };
1460
1473
  }
@@ -1651,9 +1664,14 @@ const SEED_LINEUP = Object.freeze([
1651
1664
  Object.freeze({ adapter: "codex", model: "gpt-6-sol" }),
1652
1665
  ]);
1653
1666
 
1667
+ // DR-096: where both adapters are ready, this player alone takes the
1668
+ // second row, so review comes from a different provider than the work.
1669
+ const SECOND_ADAPTER_PLAYER = "dev.reviewer";
1670
+
1654
1671
  /**
1655
1672
  * PBCLI-11: the lineup a seed would write here, and whether anything
1656
- * actually probed ready.
1673
+ * actually probed ready. `reviewer` is the row `dev.reviewer` takes;
1674
+ * every other agent takes `lineup`.
1657
1675
  *
1658
1676
  * Only the credential check participates. The SDK probe constructs an
1659
1677
  * adapter and awaits `isAvailable()`, which reads whatever happens to
@@ -1666,7 +1684,12 @@ export function selectSeedLineup(env = process.env, home = homedir()) {
1666
1684
  (entry) =>
1667
1685
  checkReadiness([entry.adapter], env, home).failingAdapters.length === 0,
1668
1686
  );
1669
- return { lineup: ready[0] ?? SEED_LINEUP[0], anyReady: ready.length > 0 };
1687
+ const lineup = ready[0] ?? SEED_LINEUP[0];
1688
+ return {
1689
+ lineup,
1690
+ reviewer: ready.length > 1 ? ready[1] : lineup,
1691
+ anyReady: ready.length > 0,
1692
+ };
1670
1693
  }
1671
1694
 
1672
1695
  /**
@@ -1675,9 +1698,7 @@ export function selectSeedLineup(env = process.env, home = homedir()) {
1675
1698
  * byte copy it always was. A different adapter rewrites the agent
1676
1699
  * scalars in place, which the Document API carries comments through.
1677
1700
  */
1678
- function seedTextForLineup(templatePath, lineup) {
1679
- const template = readFileSync(templatePath, "utf8");
1680
- if (lineup.adapter === SEED_LINEUP[0].adapter) return template;
1701
+ function seedTextForLineup(template, { lineup, reviewer }) {
1681
1702
  const doc = parseYamlDocument(template);
1682
1703
  const players = doc.getIn(["players"]);
1683
1704
  const agentPaths = [
@@ -1687,18 +1708,35 @@ function seedTextForLineup(templatePath, lineup) {
1687
1708
  String(pair.key),
1688
1709
  ]),
1689
1710
  ];
1711
+ let changed = false;
1690
1712
  for (const path of agentPaths) {
1691
- doc.setIn([...path, "adapter"], lineup.adapter);
1692
- doc.setIn([...path, "model"], lineup.model);
1713
+ const entry = path[1] === SECOND_ADAPTER_PLAYER ? reviewer : lineup;
1714
+ if (entry.adapter === SEED_LINEUP[0].adapter) continue;
1715
+ changed = true;
1716
+ doc.setIn([...path, "adapter"], entry.adapter);
1717
+ doc.setIn([...path, "model"], entry.model);
1693
1718
  // A codex agent writes git metadata under its sandbox; claude's
1694
1719
  // auto mode needs no grant, so the key exists for one adapter only.
1695
- if (lineup.adapter === "codex") {
1696
- doc.setIn([...path, "permissions", "writablePaths"], [".git"]);
1697
- } else {
1698
- doc.deleteIn([...path, "permissions", "writablePaths"]);
1699
- }
1720
+ doc.setIn([...path, "permissions", "writablePaths"], [".git"]);
1700
1721
  }
1701
- return String(doc);
1722
+ return changed ? String(doc) : template;
1723
+ }
1724
+
1725
+ /**
1726
+ * PBCLI-100: the starter text a seed writes under this environment and
1727
+ * home. The public `seed-config` facade and the launcher both call it,
1728
+ * so every host seeding through it selects the same agent settings.
1729
+ */
1730
+ export function seedConfigText(
1731
+ templatePath,
1732
+ env = process.env,
1733
+ homeDir = env.HOME ?? homedir(),
1734
+ ) {
1735
+ const selection = selectSeedLineup(env, homeDir);
1736
+ return {
1737
+ text: seedTextForLineup(readFileSync(templatePath, "utf8"), selection),
1738
+ anyReady: selection.anyReady,
1739
+ };
1702
1740
  }
1703
1741
 
1704
1742
  function seedUserConfigIfMissing(
@@ -1710,21 +1748,13 @@ function seedUserConfigIfMissing(
1710
1748
  ) {
1711
1749
  if (existsSync(userConfigPath)) return;
1712
1750
  mkdirSync(dirname(userConfigPath), { recursive: true });
1713
- const { lineup, anyReady } = selectSeedLineup(env, homeDir);
1714
- if (lineup.adapter === SEED_LINEUP[0].adapter && anyReady) {
1715
- // Unchanged default path: one exclusive create of the template bytes.
1716
- copyFileSync(templatePath, userConfigPath, constants.COPYFILE_EXCL);
1717
- } else {
1718
- // Still one exclusive create, so two seeding calls cannot disagree.
1719
- writeFileSync(userConfigPath, seedTextForLineup(templatePath, lineup), {
1720
- encoding: "utf8",
1721
- flag: "wx",
1722
- });
1723
- }
1751
+ const { text, anyReady } = seedConfigText(templatePath, env, homeDir);
1752
+ // One exclusive create, so two seeding calls cannot disagree.
1753
+ writeFileSync(userConfigPath, text, { encoding: "utf8", flag: "wx" });
1724
1754
  onNotice(`playbook: created config at ${userConfigPath}\n`);
1725
1755
  if (!anyReady) {
1726
1756
  onNotice(
1727
- `playbook: no adapter probed ready; seeded ${lineup.adapter}\n`,
1757
+ `playbook: no adapter probed ready; seeded ${SEED_LINEUP[0].adapter}\n`,
1728
1758
  );
1729
1759
  }
1730
1760
  }
@@ -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) {
@@ -993,9 +993,9 @@ function helpText({
993
993
  "",
994
994
  "Adapter setup:",
995
995
  " claude: npm install -g @anthropic-ai/claude-agent-sdk, then run",
996
- " Claude Code once or set ANTHROPIC_API_KEY.",
996
+ " Claude Code once or set ANTHROPIC_API_KEY (or CLAUDE_CODE_OAUTH_TOKEN).",
997
997
  " codex: npm install -g @openai/codex-sdk, then run Codex CLI once",
998
- " or set OPENAI_API_KEY.",
998
+ " or set OPENAI_API_KEY (or CODEX_API_KEY, CODEX_ACCESS_TOKEN).",
999
999
  " Each SDK is an optional peer dependency, so you install only the",
1000
1000
  " vendors your config actually names.",
1001
1001
  "",
@@ -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
  }
@@ -0,0 +1,12 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ export interface SeedConfigOptions {
5
+ readonly env?: NodeJS.ProcessEnv;
6
+ readonly homeDir?: string;
7
+ }
8
+ export interface SeedConfigText {
9
+ readonly text: string;
10
+ readonly anyReady: boolean;
11
+ }
12
+ export declare function seedConfigText(options?: SeedConfigOptions): SeedConfigText;
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
3
+
4
+ // DR-096: the public `@sublang/playbook/seed-config` facade. The text is
5
+ // the launcher's own seed (playbook-cli-11); this module only fixes the
6
+ // bundled template and narrows the accepted options (playbook-cli-100).
7
+
8
+ import { fileURLToPath } from 'node:url';
9
+ import { seedConfigText as seedText } from './bin/launch-config.js';
10
+
11
+ const TEMPLATE_PATH = fileURLToPath(
12
+ new URL('./playbook.config.template.yaml', import.meta.url),
13
+ );
14
+
15
+ export function seedConfigText(options = {}) {
16
+ const env = options.env ?? process.env;
17
+ return seedText(
18
+ TEMPLATE_PATH,
19
+ env,
20
+ ...(options.homeDir !== undefined ? [options.homeDir] : []),
21
+ );
22
+ }
@@ -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; }