@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 +4 -3
- package/docs/configuration.md +22 -12
- package/docs/releases/18.1.1-preparation.md +33 -0
- package/package.json +11 -2
- package/reference/sdlc/code.playbook/bin/launch-config.js +65 -35
- package/reference/sdlc/code.playbook/bin/machine-identity.js +2 -2
- package/reference/sdlc/code.playbook/bin/playbook.js +2 -2
- package/reference/sdlc/code.playbook/bin/private-paths.js +49 -9
- package/reference/sdlc/code.playbook/bin/repository-effects.js +7 -5
- package/reference/sdlc/code.playbook/bin/session-chunks.js +23 -23
- package/reference/sdlc/code.playbook/bin/session-replay.js +17 -17
- package/reference/sdlc/code.playbook/bin/session-store.js +18 -18
- package/reference/sdlc/code.playbook/seed-config.d.ts +12 -0
- package/reference/sdlc/code.playbook/seed-config.js +22 -0
- package/reference/sdlc/code.playbook/session-assets.js +13 -11
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
|
|
36
|
-
|
|
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.
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
68
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
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 (
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1692
|
-
|
|
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
|
-
|
|
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 {
|
|
1714
|
-
|
|
1715
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
56
|
-
(current
|
|
94
|
+
!privatePermissions(after, mode, path) ||
|
|
95
|
+
!privatePermissions(current, mode, path)
|
|
57
96
|
) {
|
|
58
97
|
throw new Error(`${label}: permission tightening could not be verified`);
|
|
59
98
|
}
|
|
@@ -64,6 +103,7 @@ export async function tightenPrivateEntry(path, before, directory, fs, label) {
|
|
|
64
103
|
}
|
|
65
104
|
|
|
66
105
|
export async function syncDirectory(path, fs) {
|
|
106
|
+
if (process.platform === 'win32') return;
|
|
67
107
|
let directory;
|
|
68
108
|
try {
|
|
69
109
|
directory = await fs.open(path, 'r');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
3
|
|
|
4
|
+
import { privatePermissions, mkdirPrivate } from "./private-paths.js";
|
|
4
5
|
import { execFile } from 'node:child_process';
|
|
5
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
7
|
import { constants } from 'node:fs';
|
|
@@ -242,6 +243,7 @@ async function runGitText(cwd, args, options = {}) {
|
|
|
242
243
|
}
|
|
243
244
|
|
|
244
245
|
async function syncDirectory(path) {
|
|
246
|
+
if (process.platform === 'win32') return;
|
|
245
247
|
let directory;
|
|
246
248
|
try {
|
|
247
249
|
directory = await open(path, 'r');
|
|
@@ -1167,7 +1169,7 @@ async function assertPrivateDirectory(path, label) {
|
|
|
1167
1169
|
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
1168
1170
|
throw new Error(`${label} is not a private directory`);
|
|
1169
1171
|
}
|
|
1170
|
-
if ((info
|
|
1172
|
+
if (!privatePermissions(info, 0o700, path)) {
|
|
1171
1173
|
throw new Error(`${label} permissions must be 0700`);
|
|
1172
1174
|
}
|
|
1173
1175
|
return info;
|
|
@@ -1176,7 +1178,7 @@ async function assertPrivateDirectory(path, label) {
|
|
|
1176
1178
|
async function ensureClaimRoot(path) {
|
|
1177
1179
|
let created = false;
|
|
1178
1180
|
try {
|
|
1179
|
-
await
|
|
1181
|
+
await mkdirPrivate(path, { mkdir, lstat });
|
|
1180
1182
|
created = true;
|
|
1181
1183
|
} catch (error) {
|
|
1182
1184
|
if (errorCode(error) !== 'EEXIST') throw error;
|
|
@@ -1199,7 +1201,7 @@ async function readClaimOwner(activePath) {
|
|
|
1199
1201
|
if (
|
|
1200
1202
|
!pathInfo.isFile() ||
|
|
1201
1203
|
pathInfo.isSymbolicLink() ||
|
|
1202
|
-
(pathInfo
|
|
1204
|
+
!privatePermissions(pathInfo, 0o600, filePath)
|
|
1203
1205
|
) {
|
|
1204
1206
|
throw new Error('repository claim owner is not a private regular file');
|
|
1205
1207
|
}
|
|
@@ -1213,7 +1215,7 @@ async function readClaimOwner(activePath) {
|
|
|
1213
1215
|
const info = await handle.stat();
|
|
1214
1216
|
if (
|
|
1215
1217
|
!info.isFile() ||
|
|
1216
|
-
(info
|
|
1218
|
+
!privatePermissions(info, 0o600, filePath) ||
|
|
1217
1219
|
info.dev !== pathInfo.dev ||
|
|
1218
1220
|
info.ino !== pathInfo.ino
|
|
1219
1221
|
) {
|
|
@@ -1234,7 +1236,7 @@ async function readClaimOwner(activePath) {
|
|
|
1234
1236
|
|
|
1235
1237
|
async function createClaimStage(root, owner) {
|
|
1236
1238
|
const stagePath = join(root, `.stage-${owner.ownerToken}`);
|
|
1237
|
-
await
|
|
1239
|
+
await mkdirPrivate(stagePath, { mkdir, lstat });
|
|
1238
1240
|
await chmod(stagePath, 0o700);
|
|
1239
1241
|
let handle;
|
|
1240
1242
|
try {
|
|
@@ -8,7 +8,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
8
8
|
import { constants } from 'node:fs';
|
|
9
9
|
import * as nativeFs from 'node:fs/promises';
|
|
10
10
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
11
|
-
import { sameFileIdentity, tightenPrivateEntry, verifyPrivateEntry } from './private-paths.js';
|
|
11
|
+
import { sameFileIdentity, tightenPrivateEntry, verifyPrivateEntry, mkdirPrivate, privatePermissions } from './private-paths.js';
|
|
12
12
|
|
|
13
13
|
export const MAX_SESSION_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
14
14
|
const INLINE_BYTES = 16 * 1024;
|
|
@@ -28,14 +28,14 @@ function reference(value) {
|
|
|
28
28
|
value.byteLength < 1 || value.byteLength > MAX_SESSION_CHUNK_BYTES) fail();
|
|
29
29
|
return value;
|
|
30
30
|
}
|
|
31
|
-
function privateStat(stat, directory) {
|
|
31
|
+
function privateStat(stat, directory, path) {
|
|
32
32
|
if (typeof stat.mode === 'bigint') {
|
|
33
33
|
const original = stat;
|
|
34
34
|
stat = { ...stat, mode: Number(stat.mode), uid: Number(stat.uid), nlink: Number(stat.nlink),
|
|
35
35
|
isFile: () => original.isFile(), isDirectory: () => original.isDirectory(), isSymbolicLink: () => original.isSymbolicLink() };
|
|
36
36
|
}
|
|
37
|
-
verifyPrivateEntry(stat, directory, 'session chunk');
|
|
38
|
-
if ((stat
|
|
37
|
+
verifyPrivateEntry(stat, directory, 'session chunk', undefined, path);
|
|
38
|
+
if (!privatePermissions(stat, directory ? 0o700 : 0o600, path)) throw new Error('session chunk permissions must be private');
|
|
39
39
|
}
|
|
40
40
|
function unchanged(before, after) {
|
|
41
41
|
const precise = before.mtimeNs !== undefined && after.mtimeNs !== undefined;
|
|
@@ -64,7 +64,7 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
64
64
|
while (cachedBytes > CACHE_BYTES || verified.size > CACHE_NODES) forget(verified.keys().next().value);
|
|
65
65
|
};
|
|
66
66
|
const operations = new AsyncLocalStorage();
|
|
67
|
-
const freshDirectory = async () => { const stat = await fs.lstat(directory); privateStat(stat, true); return stat; };
|
|
67
|
+
const freshDirectory = async () => { const stat = await fs.lstat(directory); privateStat(stat, true, directory); return stat; };
|
|
68
68
|
const checkDirectory = async () => {
|
|
69
69
|
const context = operations.getStore();
|
|
70
70
|
if (context?.owner) return context.owner;
|
|
@@ -82,10 +82,10 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
82
82
|
const sync = async (path, expected) => {
|
|
83
83
|
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_DIRECTORY ?? 0));
|
|
84
84
|
try {
|
|
85
|
-
const opened = await handle.stat(); privateStat(opened, true);
|
|
85
|
+
const opened = await handle.stat(); privateStat(opened, true, path);
|
|
86
86
|
if (expected && !sameFileIdentity(expected, opened)) throw new Error('session chunk directory changed');
|
|
87
|
-
await handle.sync();
|
|
88
|
-
const current = await fs.lstat(path); privateStat(current, true);
|
|
87
|
+
if (process.platform !== 'win32') await handle.sync();
|
|
88
|
+
const current = await fs.lstat(path); privateStat(current, true, path);
|
|
89
89
|
if (!sameFileIdentity(opened, current)) throw new Error('session chunk directory changed');
|
|
90
90
|
} finally { await handle.close(); }
|
|
91
91
|
};
|
|
@@ -95,8 +95,8 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
95
95
|
try { stat = await fs.lstat(directory); }
|
|
96
96
|
catch (error) {
|
|
97
97
|
if (error.code !== 'ENOENT' || !createDirectory) throw error;
|
|
98
|
-
const parent = await fs.lstat(dirname(directory)); privateStat(parent, true);
|
|
99
|
-
try { await
|
|
98
|
+
const parent = await fs.lstat(dirname(directory)); privateStat(parent, true, dirname(directory));
|
|
99
|
+
try { await mkdirPrivate(directory, fs); }
|
|
100
100
|
catch (cause) { if (cause.code !== 'EEXIST') throw cause; }
|
|
101
101
|
stat = await fs.lstat(directory);
|
|
102
102
|
await sync(dirname(directory), parent);
|
|
@@ -113,12 +113,12 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
113
113
|
if (!match) continue;
|
|
114
114
|
const path = join(directory, name);
|
|
115
115
|
const temporary = await fs.lstat(path);
|
|
116
|
-
if (temporary.nlink === 1) privateStat(temporary, false);
|
|
116
|
+
if (temporary.nlink === 1) privateStat(temporary, false, path);
|
|
117
117
|
else {
|
|
118
118
|
// A crash between exclusive link publication and alias removal may
|
|
119
119
|
// leave precisely these two names for one private store-owned inode.
|
|
120
120
|
if (temporary.isSymbolicLink() || !temporary.isFile() || temporary.nlink !== 2 ||
|
|
121
|
-
(process.getuid && temporary.uid !== process.getuid()) || (temporary
|
|
121
|
+
(process.getuid && temporary.uid !== process.getuid()) || !privatePermissions(temporary, 0o600, path)) throw new Error('unsafe interrupted session chunk publication');
|
|
122
122
|
const destination = await fs.lstat(join(directory, match[1]));
|
|
123
123
|
if (!unchanged(temporary, destination) || destination.isSymbolicLink()) throw new Error('session chunk temporary alias differs from publication');
|
|
124
124
|
pendingAliases.set(match[1], temporary);
|
|
@@ -144,7 +144,7 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
144
144
|
reference(ref);
|
|
145
145
|
const owner = await checkDirectory();
|
|
146
146
|
const path = join(directory, ref.sha256);
|
|
147
|
-
const before = await fs.lstat(path, { bigint: true }); privateStat(before, false);
|
|
147
|
+
const before = await fs.lstat(path, { bigint: true }); privateStat(before, false, path);
|
|
148
148
|
if (BigInt(before.size) !== BigInt(ref.byteLength)) throw new Error('session chunk length mismatch');
|
|
149
149
|
const cached = verified.get(ref.sha256);
|
|
150
150
|
if (!operations.getStore()?.force && cached && before.mtimeNs !== undefined &&
|
|
@@ -155,7 +155,7 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
155
155
|
forget(ref.sha256);
|
|
156
156
|
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
157
157
|
try {
|
|
158
|
-
const opened = await handle.stat({ bigint: true }); privateStat(opened, false);
|
|
158
|
+
const opened = await handle.stat({ bigint: true }); privateStat(opened, false, path);
|
|
159
159
|
if (!unchanged(before, opened)) throw new Error('session chunk changed before read');
|
|
160
160
|
// Read the proven bounded length, rather than readFile on a growing file.
|
|
161
161
|
const bytes = Buffer.alloc(ref.byteLength);
|
|
@@ -165,8 +165,8 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
165
165
|
if (!bytesRead) throw new Error('session chunk truncated during read');
|
|
166
166
|
offset += bytesRead;
|
|
167
167
|
}
|
|
168
|
-
const after = await handle.stat({ bigint: true }); privateStat(after, false);
|
|
169
|
-
const current = await fs.lstat(path, { bigint: true }); privateStat(current, false);
|
|
168
|
+
const after = await handle.stat({ bigint: true }); privateStat(after, false, path);
|
|
169
|
+
const current = await fs.lstat(path, { bigint: true }); privateStat(current, false, path);
|
|
170
170
|
if (!unchanged(opened, after) || !unchanged(after, current) || !sameFileIdentity(owner, await checkDirectory())) throw new Error('session chunk changed during read');
|
|
171
171
|
if (hash(bytes) !== ref.sha256) throw new Error('session chunk digest mismatch');
|
|
172
172
|
if ([before, opened, after, current].every((stat) => stat.mtimeNs !== undefined && stat.ctimeNs !== undefined)) remember(ref.sha256, bytes, current, owner);
|
|
@@ -202,10 +202,10 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
202
202
|
try {
|
|
203
203
|
handle = await fs.open(temporary, 'wx', 0o600);
|
|
204
204
|
await handle.chmod(0o600);
|
|
205
|
-
const opened = await handle.stat(); privateStat(opened, false);
|
|
205
|
+
const opened = await handle.stat(); privateStat(opened, false, temporary);
|
|
206
206
|
await handle.writeFile(bytes); await handle.sync();
|
|
207
|
-
const after = await handle.stat(); privateStat(after, false);
|
|
208
|
-
const current = await fs.lstat(temporary); privateStat(current, false);
|
|
207
|
+
const after = await handle.stat(); privateStat(after, false, temporary);
|
|
208
|
+
const current = await fs.lstat(temporary); privateStat(current, false, temporary);
|
|
209
209
|
if (!sameFileIdentity(opened, after) || !unchanged(after, current) || after.size !== bytes.length || !sameFileIdentity(owner, await checkDirectory())) throw new Error('session chunk changed during write');
|
|
210
210
|
await handle.close(); handle = undefined;
|
|
211
211
|
// link is exclusive publication; remove its temporary alias before any
|
|
@@ -384,9 +384,9 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
384
384
|
const temporary = /^\.write-([0-9a-f]{64})-[0-9a-f-]{36}\.tmp$/.exec(name);
|
|
385
385
|
if (temporary && stat.nlink === 2) {
|
|
386
386
|
if (stat.isSymbolicLink() || !stat.isFile() ||
|
|
387
|
-
(process.getuid && stat.uid !== process.getuid()) || (stat
|
|
387
|
+
(process.getuid && stat.uid !== process.getuid()) || !privatePermissions(stat, 0o600, path) ||
|
|
388
388
|
!unchanged(stat, await fs.lstat(join(directory, temporary[1])))) throw new Error('unsafe interrupted session chunk publication');
|
|
389
|
-
} else privateStat(stat, false);
|
|
389
|
+
} else privateStat(stat, false, join(directory, name));
|
|
390
390
|
continue;
|
|
391
391
|
}
|
|
392
392
|
if (stat.size > MAX_SESSION_CHUNK_BYTES) throw new Error('session chunk exceeds file bound');
|
|
@@ -409,10 +409,10 @@ export function createSessionChunks({ directory, fsOps, createDirectory = true }
|
|
|
409
409
|
const names = await fs.readdir(directory);
|
|
410
410
|
// Validate every candidate before removing any; links and subdirectories
|
|
411
411
|
// are never followed or recursively removed.
|
|
412
|
-
for (const name of names) privateStat(await fs.lstat(join(directory, name)), false);
|
|
412
|
+
for (const name of names) privateStat(await fs.lstat(join(directory, name)), false, join(directory, name));
|
|
413
413
|
for (const name of names) {
|
|
414
414
|
if (!sameFileIdentity(owner, await checkDirectory())) throw new Error('session chunk directory changed during deletion');
|
|
415
|
-
const path = join(directory, name); privateStat(await fs.lstat(path), false); await fs.unlink(path);
|
|
415
|
+
const path = join(directory, name); privateStat(await fs.lstat(path), false, path); await fs.unlink(path);
|
|
416
416
|
}
|
|
417
417
|
await sync(directory, owner);
|
|
418
418
|
await fs.rmdir(directory);
|
|
@@ -8,7 +8,7 @@ import { constants } from 'node:fs';
|
|
|
8
8
|
import * as nativeFs from 'node:fs/promises';
|
|
9
9
|
import { isAbsolute, join } from 'node:path';
|
|
10
10
|
import { createSessionChunks, MAX_SESSION_CHUNK_BYTES } from './session-chunks.js';
|
|
11
|
-
import { sameFileIdentity, tightenPrivateEntry, verifyPrivateEntry } from './private-paths.js';
|
|
11
|
+
import { sameFileIdentity, tightenPrivateEntry, verifyPrivateEntry, privatePermissions } from './private-paths.js';
|
|
12
12
|
|
|
13
13
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
14
14
|
const DIGEST = /^[0-9a-f]{64}$/;
|
|
@@ -21,9 +21,9 @@ function reference(value) {
|
|
|
21
21
|
!count(value.byteLength) || value.byteLength < 1 || value.byteLength > MAX_SESSION_CHUNK_BYTES) invalid();
|
|
22
22
|
return value;
|
|
23
23
|
}
|
|
24
|
-
function privateStat(stat, directory) {
|
|
25
|
-
verifyPrivateEntry(stat, directory, 'session replay');
|
|
26
|
-
if ((stat
|
|
24
|
+
function privateStat(stat, directory, path) {
|
|
25
|
+
verifyPrivateEntry(stat, directory, 'session replay', undefined, path);
|
|
26
|
+
if (!privatePermissions(stat, directory ? 0o700 : 0o600, path)) throw new Error('session replay permissions must be private');
|
|
27
27
|
}
|
|
28
28
|
const unchanged = (left, right) => sameFileIdentity(left, right) && left.size === right.size &&
|
|
29
29
|
left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
@@ -32,8 +32,8 @@ function rootChanged(message) {
|
|
|
32
32
|
error.replayRootRetry = true;
|
|
33
33
|
return error;
|
|
34
34
|
}
|
|
35
|
-
function privateRootStat(stat) {
|
|
36
|
-
try { privateStat(stat, false); }
|
|
35
|
+
function privateRootStat(stat, path) {
|
|
36
|
+
try { privateStat(stat, false, path); }
|
|
37
37
|
catch (error) {
|
|
38
38
|
// A complete initial root can briefly have its staging link, and an
|
|
39
39
|
// already opened old root loses its last link on atomic replacement.
|
|
@@ -81,14 +81,14 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
81
81
|
return result;
|
|
82
82
|
};
|
|
83
83
|
const directory = async () => {
|
|
84
|
-
const stat = await fs.lstat(sessionsDir); privateStat(stat, true); return stat;
|
|
84
|
+
const stat = await fs.lstat(sessionsDir); privateStat(stat, true, sessionsDir); return stat;
|
|
85
85
|
};
|
|
86
86
|
const syncDirectory = async (expected) => {
|
|
87
87
|
const handle = await fs.open(sessionsDir, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_DIRECTORY ?? 0));
|
|
88
88
|
try {
|
|
89
|
-
const stat = await handle.stat(); privateStat(stat, true);
|
|
89
|
+
const stat = await handle.stat(); privateStat(stat, true, sessionsDir);
|
|
90
90
|
if (!sameFileIdentity(expected, stat)) throw new Error('session replay directory changed');
|
|
91
|
-
await handle.sync();
|
|
91
|
+
if (process.platform !== 'win32') await handle.sync();
|
|
92
92
|
if (!sameFileIdentity(stat, await directory())) throw new Error('session replay directory changed');
|
|
93
93
|
} finally { await handle.close(); }
|
|
94
94
|
};
|
|
@@ -104,11 +104,11 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
104
104
|
let before;
|
|
105
105
|
try { before = await fs.lstat(path); }
|
|
106
106
|
catch (error) { if (error.code === 'ENOENT') return { root: null, rootBytes: null, stat: null, owner }; throw error; }
|
|
107
|
-
privateRootStat(before);
|
|
107
|
+
privateRootStat(before, path);
|
|
108
108
|
if (before.size > MAX_SESSION_CHUNK_BYTES) throw new Error('session replay root exceeds file bound');
|
|
109
109
|
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
110
110
|
try {
|
|
111
|
-
const opened = await handle.stat(); privateRootStat(opened);
|
|
111
|
+
const opened = await handle.stat(); privateRootStat(opened, path);
|
|
112
112
|
if (!unchanged(before, opened)) throw rootChanged('session replay root changed before read');
|
|
113
113
|
const bytes = Buffer.alloc(opened.size);
|
|
114
114
|
let offset = 0;
|
|
@@ -117,7 +117,7 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
117
117
|
if (!bytesRead) throw rootChanged('session replay root truncated during read');
|
|
118
118
|
offset += bytesRead;
|
|
119
119
|
}
|
|
120
|
-
const after = await handle.stat(); privateRootStat(after);
|
|
120
|
+
const after = await handle.stat(); privateRootStat(after, path);
|
|
121
121
|
if (!unchanged(opened, after)) throw rootChanged('session replay root changed during read');
|
|
122
122
|
if (!sameFileIdentity(owner, await directory())) throw new Error('session replay directory changed during read');
|
|
123
123
|
return { root: validateRoot(JSON.parse(bytes.toString('utf8'))), rootBytes: bytes, stat: after, owner };
|
|
@@ -137,7 +137,7 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
137
137
|
throw new Error('session replay root changed before publication');
|
|
138
138
|
}
|
|
139
139
|
if (snapshot.stat !== null) {
|
|
140
|
-
const latest = await fs.lstat(path); privateStat(latest, false);
|
|
140
|
+
const latest = await fs.lstat(path); privateStat(latest, false, path);
|
|
141
141
|
if (!unchanged(snapshot.stat, latest)) throw new Error('session replay root changed before publication');
|
|
142
142
|
}
|
|
143
143
|
};
|
|
@@ -177,10 +177,10 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
177
177
|
await assertSnapshot(snapshot);
|
|
178
178
|
handle = await fs.open(temporary, 'wx', 0o600);
|
|
179
179
|
await handle.chmod(0o600);
|
|
180
|
-
temporaryStat = await handle.stat(); privateStat(temporaryStat, false);
|
|
180
|
+
temporaryStat = await handle.stat(); privateStat(temporaryStat, false, temporary);
|
|
181
181
|
await handle.writeFile(bytes); await handle.sync();
|
|
182
|
-
const after = await handle.stat(); privateStat(after, false);
|
|
183
|
-
const current = await fs.lstat(temporary); privateStat(current, false);
|
|
182
|
+
const after = await handle.stat(); privateStat(after, false, temporary);
|
|
183
|
+
const current = await fs.lstat(temporary); privateStat(current, false, temporary);
|
|
184
184
|
if (!sameFileIdentity(temporaryStat, after) || !sameFileIdentity(after, current)) throw new Error('session replay temporary root changed');
|
|
185
185
|
await handle.close(); handle = undefined;
|
|
186
186
|
await assertOwner();
|
|
@@ -191,7 +191,7 @@ export async function createBoundedReplay({ sessionId, sessionsDir, fsOps, asser
|
|
|
191
191
|
// name before readers' single-link verification and directory syncing.
|
|
192
192
|
if (exclusive || snapshot.root === null) await fs.unlink(temporary);
|
|
193
193
|
await syncDirectory(snapshot.owner);
|
|
194
|
-
const published = await fs.lstat(path); privateStat(published, false);
|
|
194
|
+
const published = await fs.lstat(path); privateStat(published, false, path);
|
|
195
195
|
if (!sameFileIdentity(temporaryStat, published) || published.size !== bytes.length) throw new Error('session replay root changed after publication');
|
|
196
196
|
const completed = { owner: snapshot.owner, stat: published, rootBytes: bytes };
|
|
197
197
|
mutationAnchor = proofOf(completed);
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
unlink,
|
|
29
29
|
} from 'node:fs/promises';
|
|
30
30
|
import { homedir, hostname as systemHostname } from 'node:os';
|
|
31
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
31
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
32
32
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
33
33
|
import { isDeepStrictEqual } from 'node:util';
|
|
34
34
|
import { assertSupportedEffort } from '@sublang/cligent';
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
lazyMachineIdentity,
|
|
52
52
|
resolveMachineIdentity,
|
|
53
53
|
} from './machine-identity.js';
|
|
54
|
-
import { sameFileIdentity, syncDirectory, tightenPrivateEntry } from './private-paths.js';
|
|
54
|
+
import { sameFileIdentity, syncDirectory, tightenPrivateEntry, mkdirPrivate, privatePermissions } from './private-paths.js';
|
|
55
55
|
import { createSessionChunks } from './session-chunks.js';
|
|
56
56
|
import { createBoundedReplay } from './session-replay.js';
|
|
57
57
|
import { decodeStoredManifest, encodeStoredManifest, SESSION_STORAGE_VERSION } from './storage-manifest.js';
|
|
@@ -309,7 +309,7 @@ export function sanitizeReplayRecord(value) {
|
|
|
309
309
|
// Bounded physical replay preserves the public readable/durable distinction.
|
|
310
310
|
// A failed append latches history incompleteness; recovery writes remain open.
|
|
311
311
|
async function createLeaseReplayWriter({ sessionsDir, path, fs, assertOwner, readStream, chunks }) {
|
|
312
|
-
const sessionId =
|
|
312
|
+
const sessionId = basename(path, '.records.jsonl');
|
|
313
313
|
const backend = await createBoundedReplay({ sessionId, sessionsDir, fsOps: fs, assertOwner, chunks });
|
|
314
314
|
let lastReadableSeq = null, lastDurableSeq = null, incomplete = true, closed = false;
|
|
315
315
|
let tail = Promise.resolve();
|
|
@@ -895,7 +895,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
895
895
|
ownsTemporary = true;
|
|
896
896
|
await handle.chmod(0o600);
|
|
897
897
|
const tempStat = await handle.stat();
|
|
898
|
-
if (!tempStat.isFile() || (tempStat
|
|
898
|
+
if (!tempStat.isFile() || !privatePermissions(tempStat, 0o600, temporary)) {
|
|
899
899
|
throw new Error('Captain session temporary path is not a private regular file');
|
|
900
900
|
}
|
|
901
901
|
await handle.writeFile(encoded);
|
|
@@ -991,7 +991,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
991
991
|
if (
|
|
992
992
|
directoryStat.isSymbolicLink() ||
|
|
993
993
|
!directoryStat.isDirectory() ||
|
|
994
|
-
(directoryStat
|
|
994
|
+
!privatePermissions(directoryStat, 0o700, path)
|
|
995
995
|
) {
|
|
996
996
|
throw new Error('lease path is not a private real directory');
|
|
997
997
|
}
|
|
@@ -1160,21 +1160,21 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1160
1160
|
let handle;
|
|
1161
1161
|
let created = false;
|
|
1162
1162
|
try {
|
|
1163
|
-
await
|
|
1163
|
+
await mkdirPrivate(stagePath, fs);
|
|
1164
1164
|
created = true;
|
|
1165
1165
|
await fs.chmod(stagePath, 0o700);
|
|
1166
1166
|
const stageStat = await fs.lstat(stagePath);
|
|
1167
1167
|
if (
|
|
1168
1168
|
stageStat.isSymbolicLink() ||
|
|
1169
1169
|
!stageStat.isDirectory() ||
|
|
1170
|
-
(stageStat
|
|
1170
|
+
!privatePermissions(stageStat, 0o700, stagePath)
|
|
1171
1171
|
) {
|
|
1172
1172
|
throw new Error('Captain session lease stage is not a private directory');
|
|
1173
1173
|
}
|
|
1174
1174
|
handle = await fs.open(join(stagePath, LEASE_OWNER_FILE), 'wx', 0o600);
|
|
1175
1175
|
await handle.chmod(0o600);
|
|
1176
1176
|
const ownerStat = await handle.stat();
|
|
1177
|
-
if (!ownerStat.isFile() || (ownerStat
|
|
1177
|
+
if (!ownerStat.isFile() || !privatePermissions(ownerStat, 0o600, join(stagePath, LEASE_OWNER_FILE))) {
|
|
1178
1178
|
throw new Error('Captain session lease owner is not a private regular file');
|
|
1179
1179
|
}
|
|
1180
1180
|
await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8');
|
|
@@ -1922,7 +1922,7 @@ async function readReplaySnapshot({
|
|
|
1922
1922
|
|
|
1923
1923
|
// During explicit migration the old root remains authoritative until the
|
|
1924
1924
|
// new manifest publishes. New replay-only sessions have no manifest yet.
|
|
1925
|
-
const sessionId =
|
|
1925
|
+
const sessionId = basename(path, '.records.jsonl');
|
|
1926
1926
|
let storedRoot;
|
|
1927
1927
|
try { storedRoot = JSON.parse(await readPrivateRegularFile(join(sessionsDir, `${sessionId}.json`), 0o600, fs, 'record')); }
|
|
1928
1928
|
catch (cause) { if (cause?.code !== 'ENOENT' && !(cause instanceof SyntaxError)) throw cause; }
|
|
@@ -1963,7 +1963,7 @@ async function readReplaySnapshot({
|
|
|
1963
1963
|
|
|
1964
1964
|
try {
|
|
1965
1965
|
const openedStat = await handle.stat();
|
|
1966
|
-
assertPrivateRegularStat(openedStat, 0o600, 'replay stream');
|
|
1966
|
+
assertPrivateRegularStat(openedStat, 0o600, 'replay stream', path);
|
|
1967
1967
|
if (!sameFileIdentity(pathStat, openedStat)) {
|
|
1968
1968
|
throw new ReplaySnapshotChangedError(
|
|
1969
1969
|
'replay stream was replaced while opening its snapshot',
|
|
@@ -1987,7 +1987,7 @@ async function readReplaySnapshot({
|
|
|
1987
1987
|
const bytes = await readReplayRange(handle, startOffset, snapshotLength);
|
|
1988
1988
|
|
|
1989
1989
|
const finalHandleStat = await handle.stat();
|
|
1990
|
-
assertPrivateRegularStat(finalHandleStat, 0o600, 'replay stream');
|
|
1990
|
+
assertPrivateRegularStat(finalHandleStat, 0o600, 'replay stream', path);
|
|
1991
1991
|
if (
|
|
1992
1992
|
!sameFileIdentity(openedStat, finalHandleStat) ||
|
|
1993
1993
|
requireReplayFileSize(finalHandleStat.size) < snapshotLength
|
|
@@ -2225,11 +2225,11 @@ function requireReplayFileSize(value) {
|
|
|
2225
2225
|
return value;
|
|
2226
2226
|
}
|
|
2227
2227
|
|
|
2228
|
-
function assertPrivateRegularStat(stat, mode, label) {
|
|
2228
|
+
function assertPrivateRegularStat(stat, mode, label, path) {
|
|
2229
2229
|
if (!stat.isFile()) {
|
|
2230
2230
|
throw new Error(`${label} path is not a regular file`);
|
|
2231
2231
|
}
|
|
2232
|
-
if ((stat
|
|
2232
|
+
if (!privatePermissions(stat, mode, path)) {
|
|
2233
2233
|
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
2234
2234
|
}
|
|
2235
2235
|
}
|
|
@@ -6412,7 +6412,7 @@ async function readPrivateRegularFile(path, mode, fs, label, raw = false) {
|
|
|
6412
6412
|
if (!stat.isFile()) {
|
|
6413
6413
|
throw new Error(`${label} path is not a regular file`);
|
|
6414
6414
|
}
|
|
6415
|
-
if ((stat
|
|
6415
|
+
if (!privatePermissions(stat, mode, path)) {
|
|
6416
6416
|
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
6417
6417
|
}
|
|
6418
6418
|
if (!sameFileIdentity(before, stat)) throw privateFileChanged(label, 'open');
|
|
@@ -6431,7 +6431,7 @@ async function assertPrivateRegularPath(path, mode, fs, label) {
|
|
|
6431
6431
|
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
6432
6432
|
throw new Error(`${label} path is not a real regular file`);
|
|
6433
6433
|
}
|
|
6434
|
-
if ((stat
|
|
6434
|
+
if (!privatePermissions(stat, mode, path)) {
|
|
6435
6435
|
throw new Error(`${label} permissions must be ${octal(mode)}`);
|
|
6436
6436
|
}
|
|
6437
6437
|
return stat;
|
|
@@ -6486,7 +6486,7 @@ async function ensurePrivateDirectory(path, fs, mayCreate = () => true) {
|
|
|
6486
6486
|
for (const directory of missing.reverse()) {
|
|
6487
6487
|
let created = false;
|
|
6488
6488
|
try {
|
|
6489
|
-
await
|
|
6489
|
+
await mkdirPrivate(directory, fs);
|
|
6490
6490
|
created = true;
|
|
6491
6491
|
} catch (cause) {
|
|
6492
6492
|
if (cause?.code !== 'EEXIST') throw cause;
|
|
@@ -6497,7 +6497,7 @@ async function ensurePrivateDirectory(path, fs, mayCreate = () => true) {
|
|
|
6497
6497
|
await syncDirectory(dirname(directory), fs);
|
|
6498
6498
|
} else if (
|
|
6499
6499
|
directory === path &&
|
|
6500
|
-
(stat
|
|
6500
|
+
!privatePermissions(stat, 0o700, path)
|
|
6501
6501
|
) {
|
|
6502
6502
|
throw new Error('Captain session store directory permissions must be 0700');
|
|
6503
6503
|
}
|
|
@@ -6533,7 +6533,7 @@ function isPathOrAncestor(directory, path) {
|
|
|
6533
6533
|
|
|
6534
6534
|
async function assertPrivateDirectory(path, fs) {
|
|
6535
6535
|
const stat = await assertDirectoryNotLink(path, fs);
|
|
6536
|
-
if ((stat
|
|
6536
|
+
if (!privatePermissions(stat, 0o700, path)) {
|
|
6537
6537
|
throw new Error('Captain session store directory permissions must be 0700');
|
|
6538
6538
|
}
|
|
6539
6539
|
}
|
|
@@ -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
|
|
71
|
+
if (process.platform === 'win32' ? !privatePermissions(stat, 0o700, path) : (stat.mode & 0o777) !== 0o700) throw new Error('asset owner directory must have private 0700 permissions');
|
|
71
72
|
}
|
|
72
|
-
function assertFile(stat, label) {
|
|
73
|
+
function assertFile(stat, label, path) {
|
|
73
74
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error(`${label} must be a single-link regular file`);
|
|
74
75
|
assertOwner(stat, label);
|
|
75
|
-
if (process.platform
|
|
76
|
+
if (process.platform === 'win32' ? !privatePermissions(stat, 0o600, path) : (stat.mode & 0o777) !== 0o600) throw new Error(`${label} must have private 0600 permissions`);
|
|
76
77
|
}
|
|
77
78
|
function sameFile(a, b) { return a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs; }
|
|
78
79
|
async function syncDirectory(directory) {
|
|
79
80
|
if (process.platform === 'win32') return;
|
|
80
81
|
const handle = await open(directory, constants.O_RDONLY | noFollow);
|
|
81
|
-
try { assertDirectory(await handle.stat()); await handle.sync(); } finally { await handle.close(); }
|
|
82
|
+
try { assertDirectory(await handle.stat(), directory); await handle.sync(); } finally { await handle.close(); }
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
/** One immutable owner. A session host holds its ordinary lease around mutations. */
|
|
@@ -93,10 +94,10 @@ export function createAssetStore(options) {
|
|
|
93
94
|
let importTail = Promise.resolve();
|
|
94
95
|
const ensureDirectory = async (create) => {
|
|
95
96
|
if (create) {
|
|
96
|
-
try { await
|
|
97
|
+
try { await mkdirPrivate(directory, { mkdir, lstat }, { recursive: createAncestors }); }
|
|
97
98
|
catch (error) { if (createAncestors || error.code !== 'EEXIST') throw error; }
|
|
98
99
|
}
|
|
99
|
-
assertDirectory(await lstat(directory));
|
|
100
|
+
assertDirectory(await lstat(directory), directory);
|
|
100
101
|
};
|
|
101
102
|
const prepare = async () => {
|
|
102
103
|
let before;
|
|
@@ -107,6 +108,7 @@ export function createAssetStore(options) {
|
|
|
107
108
|
const directoryHandle = await open(directory, constants.O_RDONLY | noFollow);
|
|
108
109
|
try {
|
|
109
110
|
const opened = await directoryHandle.stat();
|
|
111
|
+
if (process.platform === 'win32') assertDirectory(opened, directory);
|
|
110
112
|
if (opened.dev !== before.dev || opened.ino !== before.ino) throw new Error('asset owner directory changed during preparation');
|
|
111
113
|
// fchmod always moves ctime, which verified readers pin: change only modes that differ.
|
|
112
114
|
if (process.platform !== 'win32' && (opened.mode & 0o777) !== 0o700) await directoryHandle.chmod(0o700);
|
|
@@ -123,25 +125,25 @@ export function createAssetStore(options) {
|
|
|
123
125
|
const pinned = await handle.stat();
|
|
124
126
|
if (!sameFile(stat, pinned)) throw new Error('asset entry changed during preparation');
|
|
125
127
|
if (process.platform !== 'win32' && (pinned.mode & 0o777) !== 0o600) await handle.chmod(0o600);
|
|
126
|
-
assertFile(await handle.stat(), 'asset entry');
|
|
128
|
+
assertFile(await handle.stat(), 'asset entry', path);
|
|
127
129
|
const final = await lstat(path);
|
|
128
130
|
if (final.dev !== pinned.dev || final.ino !== pinned.ino || final.nlink !== 1) throw new Error('asset entry changed during preparation');
|
|
129
131
|
} finally { await handle.close(); }
|
|
130
132
|
}
|
|
131
133
|
const final = await lstat(directory);
|
|
132
134
|
if (final.dev !== opened.dev || final.ino !== opened.ino || final.isSymbolicLink()) throw new Error('asset owner directory changed during preparation');
|
|
133
|
-
assertDirectory(await directoryHandle.stat());
|
|
135
|
+
assertDirectory(await directoryHandle.stat(), directory);
|
|
134
136
|
} finally { await directoryHandle.close(); }
|
|
135
137
|
};
|
|
136
138
|
const checkedOpen = async (filename, label) => {
|
|
137
139
|
await ensureDirectory(false);
|
|
138
140
|
const path = join(directory, filename);
|
|
139
141
|
const before = await lstat(path);
|
|
140
|
-
assertFile(before, label);
|
|
142
|
+
assertFile(before, label, path);
|
|
141
143
|
const handle = await open(path, constants.O_RDONLY | noFollow);
|
|
142
144
|
try {
|
|
143
145
|
const after = await handle.stat();
|
|
144
|
-
assertFile(after, label);
|
|
146
|
+
assertFile(after, label, path);
|
|
145
147
|
if (!sameFile(before, after)) throw new Error(`${label} changed while opening`);
|
|
146
148
|
return { handle, stat: after };
|
|
147
149
|
} catch (error) { await handle.close(); throw error; }
|