@sublang/playbook 17.4.1 → 17.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/cli.md +30 -3
- package/docs/configuration.md +32 -2
- package/docs/embedding.md +29 -0
- package/package.json +9 -1
- package/reference/sdlc/code.playbook/bin/launch-config.js +249 -39
- package/reference/sdlc/code.playbook/bin/machine-identity.js +233 -0
- package/reference/sdlc/code.playbook/bin/playbook.js +43 -6
- package/reference/sdlc/code.playbook/bin/private-paths.js +74 -0
- package/reference/sdlc/code.playbook/bin/repository-effects.js +39 -5
- package/reference/sdlc/code.playbook/bin/run.js +37 -3
- package/reference/sdlc/code.playbook/bin/session-store.js +44 -39
- package/reference/sdlc/code.playbook/machine-identity.d.ts +11 -0
- package/reference/sdlc/code.playbook/machine-identity.js +22 -0
- package/reference/sdlc/code.playbook/session-host.d.ts +9 -2
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
// One machine identity per user and machine (DR-087, playbook-cli-97): a
|
|
5
|
+
// tagged value read from a private file under the XDG state directory,
|
|
6
|
+
// published once and exclusively, never replaced, and carried in the
|
|
7
|
+
// `hostname` field of session leases and repository claims in place of
|
|
8
|
+
// `os.hostname()`, which the network renames.
|
|
9
|
+
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { constants } from 'node:fs';
|
|
12
|
+
import { chmod, link, lstat, mkdir, open, unlink } from 'node:fs/promises';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { sameFileIdentity, syncDirectory, tightenPrivateEntry } from './private-paths.js';
|
|
16
|
+
|
|
17
|
+
export const MACHINE_IDENTITY_TAG_PREFIX = 'machine-id:v1:';
|
|
18
|
+
export const PLAYBOOK_MACHINE_IDENTITY_UNAVAILABLE =
|
|
19
|
+
'PLAYBOOK_MACHINE_IDENTITY_UNAVAILABLE';
|
|
20
|
+
|
|
21
|
+
const IDENTITY_FILE = 'machine-id';
|
|
22
|
+
const UUID_PATTERN =
|
|
23
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
24
|
+
const PUBLISH_ATTEMPTS = 3;
|
|
25
|
+
// A publication links its complete temporary into place and unlinks the
|
|
26
|
+
// temporary right after, so a reader can meet the file with two links for
|
|
27
|
+
// an instant; it re-reads a few times before calling that a hard link.
|
|
28
|
+
const LINK_SETTLE_ATTEMPTS = 10;
|
|
29
|
+
const LINK_SETTLE_DELAY_MS = 20;
|
|
30
|
+
const DEFAULT_FS_OPERATIONS = Object.freeze({
|
|
31
|
+
chmod,
|
|
32
|
+
link,
|
|
33
|
+
lstat,
|
|
34
|
+
mkdir,
|
|
35
|
+
open,
|
|
36
|
+
unlink,
|
|
37
|
+
});
|
|
38
|
+
const resolved = new Map();
|
|
39
|
+
|
|
40
|
+
/** Exactly the tagged form: the prefix and one lowercase UUID. */
|
|
41
|
+
export function isMachineIdentity(value) {
|
|
42
|
+
return (
|
|
43
|
+
typeof value === 'string' &&
|
|
44
|
+
value.startsWith(MACHINE_IDENTITY_TAG_PREFIX) &&
|
|
45
|
+
UUID_PATTERN.test(value.slice(MACHINE_IDENTITY_TAG_PREFIX.length))
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** How a lease or claim reader reads an owner's `hostname` field
|
|
50
|
+
* (playbook-cli-23): the exact tag is an identity compared with this
|
|
51
|
+
* machine's; a value that begins like the tag but fails its form is
|
|
52
|
+
* unverifiable; anything else is a legacy host name compared with the
|
|
53
|
+
* current one. */
|
|
54
|
+
export function classifyOwnerMachine(value, localIdentity, legacyHostname) {
|
|
55
|
+
if (isMachineIdentity(value)) {
|
|
56
|
+
return value === localIdentity ? 'local' : 'foreign';
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === 'string' && value.startsWith('machine-id:')) {
|
|
59
|
+
return 'unverifiable';
|
|
60
|
+
}
|
|
61
|
+
return value === legacyHostname ? 'local' : 'foreign';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A reader's word for an owner's `hostname` value in a diagnostic. */
|
|
65
|
+
export function describeOwnerMachine(value) {
|
|
66
|
+
return isMachineIdentity(value)
|
|
67
|
+
? `another machine (identity ${JSON.stringify(value)})`
|
|
68
|
+
: `foreign host ${JSON.stringify(value)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function machineIdentityPath(env = process.env, home = homedir()) {
|
|
72
|
+
const stateHome =
|
|
73
|
+
typeof env.XDG_STATE_HOME === 'string' && env.XDG_STATE_HOME.trim().length > 0
|
|
74
|
+
? env.XDG_STATE_HOME
|
|
75
|
+
: join(
|
|
76
|
+
typeof env.HOME === 'string' && env.HOME.trim().length > 0 ? env.HOME : home,
|
|
77
|
+
'.local',
|
|
78
|
+
'state',
|
|
79
|
+
);
|
|
80
|
+
return join(stateHome, 'playbook', IDENTITY_FILE);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class MachineIdentityUnavailableError extends Error {
|
|
84
|
+
constructor(path, reason) {
|
|
85
|
+
super(`machine identity file ${path} is unavailable: ${reason}`);
|
|
86
|
+
this.name = 'MachineIdentityUnavailableError';
|
|
87
|
+
this.code = PLAYBOOK_MACHINE_IDENTITY_UNAVAILABLE;
|
|
88
|
+
this.path = path;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function errorMessage(error) {
|
|
93
|
+
return error instanceof Error ? error.message : String(error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Read the identity, publishing one when none exists; the result is kept
|
|
97
|
+
* for the process, and a refusal is not, so a repaired file is read next
|
|
98
|
+
* time. */
|
|
99
|
+
export function resolveMachineIdentity(options = {}) {
|
|
100
|
+
const env = options.env ?? process.env;
|
|
101
|
+
const home = options.homeDir ?? env.HOME ?? homedir();
|
|
102
|
+
const path = machineIdentityPath(env, home);
|
|
103
|
+
if (options.fsOps === undefined && resolved.has(path)) return resolved.get(path);
|
|
104
|
+
const pending = readOrPublish(path, {
|
|
105
|
+
...DEFAULT_FS_OPERATIONS,
|
|
106
|
+
...(options.fsOps ?? {}),
|
|
107
|
+
}, options.createIdentity ?? randomUUID);
|
|
108
|
+
if (options.fsOps === undefined) {
|
|
109
|
+
resolved.set(path, pending);
|
|
110
|
+
pending.catch(() => resolved.delete(path));
|
|
111
|
+
}
|
|
112
|
+
return pending;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readOrPublish(path, fs, createIdentity) {
|
|
116
|
+
const directory = dirname(path);
|
|
117
|
+
await prepareDirectory(directory, path, fs);
|
|
118
|
+
for (let attempt = 0; attempt < PUBLISH_ATTEMPTS; attempt += 1) {
|
|
119
|
+
const existing = await readIdentity(path, fs);
|
|
120
|
+
if (existing !== undefined) return existing;
|
|
121
|
+
await publish(path, directory, fs, createIdentity);
|
|
122
|
+
}
|
|
123
|
+
throw new MachineIdentityUnavailableError(path, 'the identity could not be published');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function prepareDirectory(directory, path, fs) {
|
|
127
|
+
let stat;
|
|
128
|
+
try {
|
|
129
|
+
stat = await fs.lstat(directory);
|
|
130
|
+
} catch (cause) {
|
|
131
|
+
if (cause?.code !== 'ENOENT') {
|
|
132
|
+
throw new MachineIdentityUnavailableError(path, `its directory cannot be read: ${errorMessage(cause)}`);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
136
|
+
await fs.chmod(directory, 0o700);
|
|
137
|
+
stat = await fs.lstat(directory);
|
|
138
|
+
} catch (creation) {
|
|
139
|
+
throw new MachineIdentityUnavailableError(path, `its directory cannot be created: ${errorMessage(creation)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
await tightenPrivateEntry(directory, stat, true, fs, 'machine identity directory');
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
throw new MachineIdentityUnavailableError(path, errorMessage(cause));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readIdentity(path, fs) {
|
|
150
|
+
let before;
|
|
151
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
152
|
+
try {
|
|
153
|
+
before = await fs.lstat(path);
|
|
154
|
+
} catch (cause) {
|
|
155
|
+
if (cause?.code === 'ENOENT') return undefined;
|
|
156
|
+
throw new MachineIdentityUnavailableError(path, `it cannot be read: ${errorMessage(cause)}`);
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
before.nlink <= 1 ||
|
|
160
|
+
before.isSymbolicLink() ||
|
|
161
|
+
!before.isFile() ||
|
|
162
|
+
attempt >= LINK_SETTLE_ATTEMPTS
|
|
163
|
+
) {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
await new Promise((settle) => setTimeout(settle, LINK_SETTLE_DELAY_MS));
|
|
167
|
+
}
|
|
168
|
+
let current;
|
|
169
|
+
try {
|
|
170
|
+
current = await tightenPrivateEntry(path, before, false, fs, 'machine identity file');
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
throw new MachineIdentityUnavailableError(path, errorMessage(cause));
|
|
173
|
+
}
|
|
174
|
+
let text;
|
|
175
|
+
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
176
|
+
try {
|
|
177
|
+
const opened = await handle.stat();
|
|
178
|
+
if (!opened.isFile() || !sameFileIdentity(current, opened)) {
|
|
179
|
+
throw new MachineIdentityUnavailableError(path, 'it changed while being read');
|
|
180
|
+
}
|
|
181
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(await handle.readFile());
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
if (cause instanceof MachineIdentityUnavailableError) throw cause;
|
|
184
|
+
throw new MachineIdentityUnavailableError(path, `it cannot be read: ${errorMessage(cause)}`);
|
|
185
|
+
} finally {
|
|
186
|
+
await handle.close();
|
|
187
|
+
}
|
|
188
|
+
const value = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
189
|
+
if (!isMachineIdentity(value) || value.includes('\n')) {
|
|
190
|
+
throw new MachineIdentityUnavailableError(path, 'it does not hold one machine identity');
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function publish(path, directory, fs, createIdentity) {
|
|
196
|
+
const uuid = createIdentity();
|
|
197
|
+
const value = `${MACHINE_IDENTITY_TAG_PREFIX}${uuid}`;
|
|
198
|
+
if (!isMachineIdentity(value)) {
|
|
199
|
+
throw new MachineIdentityUnavailableError(path, 'the identity generator returned an invalid value');
|
|
200
|
+
}
|
|
201
|
+
const temporary = join(directory, `.${IDENTITY_FILE}.${randomUUID()}.tmp`);
|
|
202
|
+
let handle;
|
|
203
|
+
try {
|
|
204
|
+
handle = await fs.open(temporary, 'wx', 0o600);
|
|
205
|
+
await handle.chmod(0o600);
|
|
206
|
+
await handle.writeFile(`${value}\n`, 'utf8');
|
|
207
|
+
await handle.sync();
|
|
208
|
+
await handle.close();
|
|
209
|
+
handle = undefined;
|
|
210
|
+
try {
|
|
211
|
+
await fs.link(temporary, path);
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
// Another creator published first; its complete file is read next.
|
|
214
|
+
if (cause?.code === 'EEXIST') return;
|
|
215
|
+
throw cause;
|
|
216
|
+
}
|
|
217
|
+
await fs.unlink(temporary);
|
|
218
|
+
await syncDirectory(directory, fs);
|
|
219
|
+
} catch (cause) {
|
|
220
|
+
throw new MachineIdentityUnavailableError(path, `it cannot be published: ${errorMessage(cause)}`);
|
|
221
|
+
} finally {
|
|
222
|
+
try {
|
|
223
|
+
await handle?.close();
|
|
224
|
+
} catch {
|
|
225
|
+
// The publication failure stands.
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
await fs.unlink(temporary);
|
|
229
|
+
} catch {
|
|
230
|
+
// A temporary already gone leaves nothing to remove.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -18,10 +18,12 @@ import {
|
|
|
18
18
|
} from "./adapter-sdk.js";
|
|
19
19
|
import {
|
|
20
20
|
adaptersFromLaunchPlan,
|
|
21
|
+
extractModuleFlags,
|
|
21
22
|
extractWithFlags,
|
|
22
23
|
loadLaunchPlan,
|
|
23
24
|
loadSelectedLaunchPlanDataOnly,
|
|
24
25
|
projectTmuxConfig,
|
|
26
|
+
suppliedModulesFromFlags,
|
|
25
27
|
resolveLaunchSessionsDir,
|
|
26
28
|
relocateLegacyUserConfig,
|
|
27
29
|
resolveFormerUserConfigPaths,
|
|
@@ -54,15 +56,18 @@ export {
|
|
|
54
56
|
checkReadiness,
|
|
55
57
|
composeGenericConfig,
|
|
56
58
|
deriveLaunchReadiness,
|
|
59
|
+
extractModuleFlags,
|
|
57
60
|
extractWithFlags,
|
|
58
61
|
loadLaunchPlan,
|
|
59
62
|
loadOverlayFragment,
|
|
60
63
|
mergeConfigs,
|
|
61
64
|
migrateRetiredProfiles,
|
|
62
65
|
normalizeLaunchPlan,
|
|
66
|
+
parseModuleFlagValue,
|
|
63
67
|
projectTmuxConfig,
|
|
64
68
|
resolveAgent,
|
|
65
69
|
resolveUserConfigPath,
|
|
70
|
+
suppliedModulesFromFlags,
|
|
66
71
|
} from "./launch-config.js";
|
|
67
72
|
|
|
68
73
|
const READINESS_FAILURE_EXIT_CODE = 2;
|
|
@@ -188,10 +193,15 @@ export async function runPlaybookCli(options = {}) {
|
|
|
188
193
|
// PBCLI-25/26: `--with <path>` overlays are launcher-owned — consumed
|
|
189
194
|
// here, never forwarded to tmux-play, and incompatible with a raw
|
|
190
195
|
// `--config` launch, which bypasses the composition they target.
|
|
196
|
+
// PBCLI-94 (DR-083): `--module <id>=<specifier>` is launcher-owned in the
|
|
197
|
+
// same way: consumed, never forwarded, and refused beside raw `--config`.
|
|
191
198
|
let withPaths;
|
|
199
|
+
let modulePairs;
|
|
200
|
+
let modules;
|
|
192
201
|
let forwardArgv;
|
|
193
202
|
try {
|
|
194
203
|
({ withPaths, rest: forwardArgv } = extractWithFlags(argv));
|
|
204
|
+
({ modulePairs, rest: forwardArgv } = extractModuleFlags(forwardArgv));
|
|
195
205
|
} catch (error) {
|
|
196
206
|
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
197
207
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
@@ -203,6 +213,22 @@ export async function runPlaybookCli(options = {}) {
|
|
|
203
213
|
);
|
|
204
214
|
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
205
215
|
}
|
|
216
|
+
if (modulePairs.length > 0 && hasExplicitConfig(argv)) {
|
|
217
|
+
stderr.write(
|
|
218
|
+
"playbook: --module supplies a playbook module for the top-level " +
|
|
219
|
+
"config and cannot combine with a raw --config launch\n",
|
|
220
|
+
);
|
|
221
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
222
|
+
}
|
|
223
|
+
try {
|
|
224
|
+
modules = suppliedModulesFromFlags(
|
|
225
|
+
modulePairs,
|
|
226
|
+
options.cwd ?? process.cwd(),
|
|
227
|
+
);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
stderr.write(`playbook: ${errorMessage(error)}\n`);
|
|
230
|
+
return { code: COMPOSITION_FAILURE_EXIT_CODE };
|
|
231
|
+
}
|
|
206
232
|
const noProvision = forwardArgv.includes("--no-provision");
|
|
207
233
|
forwardArgv = forwardArgv.filter((arg) => arg !== "--no-provision");
|
|
208
234
|
if (noProvision && hasExplicitConfig(argv)) {
|
|
@@ -349,6 +375,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
349
375
|
? await loadSelectedLaunchPlanDataOnly({
|
|
350
376
|
userConfigPath,
|
|
351
377
|
overlayPaths: withPaths,
|
|
378
|
+
modules,
|
|
352
379
|
structuralProjection: selectedRecord.structuralProjection,
|
|
353
380
|
onNotice: (line) => stderr.write(line),
|
|
354
381
|
env,
|
|
@@ -357,6 +384,7 @@ export async function runPlaybookCli(options = {}) {
|
|
|
357
384
|
: await loadLaunchPlan({
|
|
358
385
|
userConfigPath,
|
|
359
386
|
overlayPaths: withPaths,
|
|
387
|
+
modules,
|
|
360
388
|
env,
|
|
361
389
|
homeDir: home,
|
|
362
390
|
loadModule,
|
|
@@ -923,13 +951,17 @@ function helpText({
|
|
|
923
951
|
...sdkFailureLines,
|
|
924
952
|
...failures,
|
|
925
953
|
"Usage:",
|
|
926
|
-
" playbook [--with <path>]... [--
|
|
927
|
-
"
|
|
928
|
-
" playbook --
|
|
929
|
-
"
|
|
954
|
+
" playbook [--with <path>]... [--module <id>=<specifier>]...",
|
|
955
|
+
" [--no-provision] [--cwd <path>]",
|
|
956
|
+
" playbook --session <id> [--with <path>]...",
|
|
957
|
+
" [--module <id>=<specifier>]... [--no-provision]",
|
|
958
|
+
" playbook --list [--with <path>]... [--module <id>=<specifier>]...",
|
|
959
|
+
" [--no-provision]",
|
|
960
|
+
" playbook --theme-diagnostics [--with <path>]...",
|
|
961
|
+
" [--module <id>=<specifier>]... [--cwd <path>]",
|
|
930
962
|
" playbook --config <path> [tmux-play arguments...]",
|
|
931
|
-
" playbook run [--with <path>]... [--
|
|
932
|
-
" [--verbose] [--] [input]",
|
|
963
|
+
" playbook run [--with <path>]... [--module <id>=<specifier>]...",
|
|
964
|
+
" [--no-provision] [--json] [--verbose] [--] [input]",
|
|
933
965
|
" playbook run (--continue | --session <id>) [reply]",
|
|
934
966
|
" playbook run --session <id> --retry-uncertain",
|
|
935
967
|
" playbook run --session <id> --discard-uncertain",
|
|
@@ -947,6 +979,11 @@ function helpText({
|
|
|
947
979
|
" the default config) for a fresh launch or compatible ordinary reopen —",
|
|
948
980
|
" maps merge recursively, other values replace, later files win, and the",
|
|
949
981
|
" default config file is never modified.",
|
|
982
|
+
" --module <id>=<specifier> supplies playbook <id>'s registry module",
|
|
983
|
+
" ahead of its playbooks.<id>.from (repeatable, one per id); a relative",
|
|
984
|
+
" path resolves against the current directory, and the module is never",
|
|
985
|
+
" written back to your config files. Without --module or from, a",
|
|
986
|
+
" reopened session uses the module it already records.",
|
|
950
987
|
" --no-provision keeps configured filesystem registries read-only;",
|
|
951
988
|
" any missing engine links remain a launch error.",
|
|
952
989
|
" `playbook run --verbose` prints Captain telemetry topics to stderr.",
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
// The one verified tighten-only rule for private entries (session-storage-1):
|
|
5
|
+
// an entry qualifies only as current-user-owned and non-symlink — a file
|
|
6
|
+
// also as a single-link regular file — with its owner bits present; a
|
|
7
|
+
// qualifying entry with excess permissions is restricted through an opened
|
|
8
|
+
// handle whose identity is compared before and after, so a link swapped in
|
|
9
|
+
// between check and use cannot be tightened in its place. The session store
|
|
10
|
+
// applies it to the sessions directory and its files; the machine identity
|
|
11
|
+
// applies it to its state directory and file (playbook-cli-97).
|
|
12
|
+
|
|
13
|
+
import { constants } from 'node:fs';
|
|
14
|
+
|
|
15
|
+
export function sameFileIdentity(left, right) {
|
|
16
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function verifyPrivateEntry(stat, directory, label, uid = process.getuid?.()) {
|
|
20
|
+
const required = directory ? 0o700 : 0o600;
|
|
21
|
+
if (
|
|
22
|
+
stat.isSymbolicLink() ||
|
|
23
|
+
(directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) ||
|
|
24
|
+
(uid !== undefined && stat.uid !== uid) ||
|
|
25
|
+
(stat.mode & required) !== required
|
|
26
|
+
) {
|
|
27
|
+
throw new Error(`${label} refuses unsafe ownership, links, type, or owner access`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function tightenPrivateEntry(path, before, directory, fs, label) {
|
|
32
|
+
const uid = process.getuid?.();
|
|
33
|
+
verifyPrivateEntry(before, directory, label, uid);
|
|
34
|
+
const handle = await fs.open(
|
|
35
|
+
path,
|
|
36
|
+
constants.O_RDONLY |
|
|
37
|
+
(constants.O_NOFOLLOW ?? 0) |
|
|
38
|
+
(constants.O_NONBLOCK ?? 0) |
|
|
39
|
+
(directory ? constants.O_DIRECTORY ?? 0 : 0),
|
|
40
|
+
);
|
|
41
|
+
try {
|
|
42
|
+
const opened = await handle.stat();
|
|
43
|
+
verifyPrivateEntry(opened, directory, label, uid);
|
|
44
|
+
if (!sameFileIdentity(before, opened)) {
|
|
45
|
+
throw new Error(`${label}: the entry changed during permission preparation`);
|
|
46
|
+
}
|
|
47
|
+
const mode = directory ? 0o700 : 0o600;
|
|
48
|
+
if ((opened.mode & 0o7777) !== mode) await handle.chmod(mode);
|
|
49
|
+
const after = await handle.stat();
|
|
50
|
+
verifyPrivateEntry(after, directory, label, uid);
|
|
51
|
+
const current = await fs.lstat(path);
|
|
52
|
+
verifyPrivateEntry(current, directory, label, uid);
|
|
53
|
+
if (
|
|
54
|
+
!sameFileIdentity(after, current) ||
|
|
55
|
+
(after.mode & 0o7777) !== mode ||
|
|
56
|
+
(current.mode & 0o7777) !== mode
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(`${label}: permission tightening could not be verified`);
|
|
59
|
+
}
|
|
60
|
+
return current;
|
|
61
|
+
} finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function syncDirectory(path, fs) {
|
|
67
|
+
let directory;
|
|
68
|
+
try {
|
|
69
|
+
directory = await fs.open(path, 'r');
|
|
70
|
+
await directory.sync();
|
|
71
|
+
} finally {
|
|
72
|
+
await directory?.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -26,6 +26,12 @@ import {
|
|
|
26
26
|
snapshotJsonValue,
|
|
27
27
|
} from '../../../../src/xstate-runtime.js';
|
|
28
28
|
import { applyEffectLedgerCommands } from './session-store.js';
|
|
29
|
+
import {
|
|
30
|
+
classifyOwnerMachine,
|
|
31
|
+
describeOwnerMachine,
|
|
32
|
+
isMachineIdentity,
|
|
33
|
+
resolveMachineIdentity,
|
|
34
|
+
} from './machine-identity.js';
|
|
29
35
|
|
|
30
36
|
const CLAIM_SCHEMA = 1;
|
|
31
37
|
const CLAIM_OWNER_FILE = 'owner.json';
|
|
@@ -1321,13 +1327,35 @@ function validateCohort(options) {
|
|
|
1321
1327
|
}
|
|
1322
1328
|
|
|
1323
1329
|
export function createRepositoryEffectCoordinator(options = {}) {
|
|
1324
|
-
|
|
1330
|
+
// DR-087: the claim owner's `hostname` carries this machine's identity,
|
|
1331
|
+
// read once at the first claim; an explicit value stands in for it
|
|
1332
|
+
// (tests). An untagged owner is a legacy host name compared with the
|
|
1333
|
+
// current one — or with an explicit untagged value, which then names
|
|
1334
|
+
// this machine both ways.
|
|
1335
|
+
const explicitHostname = options.hostname;
|
|
1336
|
+
const legacyHostname =
|
|
1337
|
+
options.legacyHostname ??
|
|
1338
|
+
(typeof explicitHostname === 'string' && !isMachineIdentity(explicitHostname)
|
|
1339
|
+
? explicitHostname
|
|
1340
|
+
: systemHostname());
|
|
1341
|
+
let currentIdentityPending;
|
|
1342
|
+
const currentIdentity = () =>
|
|
1343
|
+
(currentIdentityPending ??=
|
|
1344
|
+
explicitHostname !== undefined
|
|
1345
|
+
? Promise.resolve(explicitHostname)
|
|
1346
|
+
: resolveMachineIdentity({
|
|
1347
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
1348
|
+
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
|
|
1349
|
+
}));
|
|
1325
1350
|
const currentPid = options.pid ?? process.pid;
|
|
1326
1351
|
const pollIntervalMs = options.pollIntervalMs ?? 10;
|
|
1327
1352
|
const probeProcess = options.probeProcess ?? defaultProbeProcess;
|
|
1328
1353
|
const createOwnerToken = options.createOwnerToken ?? randomUUID;
|
|
1329
1354
|
const afterClaimPublished = options._testAfterClaimPublished;
|
|
1330
|
-
if (
|
|
1355
|
+
if (
|
|
1356
|
+
explicitHostname !== undefined &&
|
|
1357
|
+
(typeof explicitHostname !== 'string' || explicitHostname.length === 0)
|
|
1358
|
+
) {
|
|
1331
1359
|
throw new TypeError('repository coordinator hostname must be nonempty');
|
|
1332
1360
|
}
|
|
1333
1361
|
if (!Number.isSafeInteger(currentPid) || currentPid <= 0) {
|
|
@@ -1426,7 +1454,7 @@ export function createRepositoryEffectCoordinator(options = {}) {
|
|
|
1426
1454
|
schema: CLAIM_SCHEMA,
|
|
1427
1455
|
ownerToken,
|
|
1428
1456
|
pid: currentPid,
|
|
1429
|
-
hostname:
|
|
1457
|
+
hostname: await currentIdentity(),
|
|
1430
1458
|
});
|
|
1431
1459
|
|
|
1432
1460
|
// A directory that is not a repository yet has no `.git` to publish a
|
|
@@ -1468,9 +1496,15 @@ export function createRepositoryEffectCoordinator(options = {}) {
|
|
|
1468
1496
|
if (activeOwner.ownerToken === owner.ownerToken) {
|
|
1469
1497
|
throw new Error('repository claim owner token was reused');
|
|
1470
1498
|
}
|
|
1471
|
-
|
|
1499
|
+
const machine = classifyOwnerMachine(activeOwner.hostname, owner.hostname, legacyHostname);
|
|
1500
|
+
if (machine === 'foreign') {
|
|
1501
|
+
throw new Error(
|
|
1502
|
+
`repository claim is owned by ${describeOwnerMachine(activeOwner.hostname)}`,
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
if (machine !== 'local') {
|
|
1472
1506
|
throw new Error(
|
|
1473
|
-
`repository claim
|
|
1507
|
+
`repository claim owner machine ${JSON.stringify(activeOwner.hostname)} cannot be verified`,
|
|
1474
1508
|
);
|
|
1475
1509
|
}
|
|
1476
1510
|
const state = await probeProcess(activeOwner.pid);
|
|
@@ -39,12 +39,14 @@ import {
|
|
|
39
39
|
invalidRegistryEntryReason,
|
|
40
40
|
loadLaunchPlan,
|
|
41
41
|
loadSelectedLaunchPlanDataOnly,
|
|
42
|
+
parseModuleFlagValue,
|
|
42
43
|
projectHostAgent,
|
|
43
44
|
resolveLaunchSessionsDir,
|
|
44
45
|
relocateLegacyUserConfig,
|
|
45
46
|
resolveFormerUserConfigPaths,
|
|
46
47
|
resolveUserConfigPath,
|
|
47
48
|
snapshotRegistryEntry,
|
|
49
|
+
suppliedModulesFromFlags,
|
|
48
50
|
} from "./launch-config.js";
|
|
49
51
|
import { prepareConfiguredRegistries } from "./provision.js";
|
|
50
52
|
import {
|
|
@@ -122,6 +124,12 @@ export async function runPlaybookRun(options = {}) {
|
|
|
122
124
|
const recovering = args.retryUncertain || args.discardUncertain;
|
|
123
125
|
const continuing = args.continue || args.sessionId !== undefined;
|
|
124
126
|
let input = args.input;
|
|
127
|
+
// PBCLI-94 (DR-083): a relative supplied module resolves against the
|
|
128
|
+
// invocation's working directory before the loader sees it.
|
|
129
|
+
const modules = suppliedModulesFromFlags(
|
|
130
|
+
args.modulePairs,
|
|
131
|
+
options.cwd ?? process.cwd(),
|
|
132
|
+
);
|
|
125
133
|
|
|
126
134
|
// PBCLI-18/40: a fresh piped producer is drained before config,
|
|
127
135
|
// preparation, import, or readiness. Continuations first inspect the
|
|
@@ -369,6 +377,7 @@ export async function runPlaybookRun(options = {}) {
|
|
|
369
377
|
? await loadSelectedLaunchPlanDataOnly({
|
|
370
378
|
userConfigPath,
|
|
371
379
|
overlayPaths: args.withPaths,
|
|
380
|
+
modules,
|
|
372
381
|
structuralProjection: priorRecord.structuralProjection,
|
|
373
382
|
onNotice: (line) => configNotices.push(line),
|
|
374
383
|
env,
|
|
@@ -377,6 +386,7 @@ export async function runPlaybookRun(options = {}) {
|
|
|
377
386
|
: await loadLaunchPlan({
|
|
378
387
|
userConfigPath,
|
|
379
388
|
overlayPaths: args.withPaths,
|
|
389
|
+
modules,
|
|
380
390
|
env,
|
|
381
391
|
homeDir: home,
|
|
382
392
|
loadModule,
|
|
@@ -1561,6 +1571,7 @@ export function parseRunArgs(argv) {
|
|
|
1561
1571
|
const parsed = {
|
|
1562
1572
|
input: undefined,
|
|
1563
1573
|
withPaths: [],
|
|
1574
|
+
modulePairs: [],
|
|
1564
1575
|
noProvision: false,
|
|
1565
1576
|
json: false,
|
|
1566
1577
|
verbose: false,
|
|
@@ -1625,6 +1636,18 @@ export function parseRunArgs(argv) {
|
|
|
1625
1636
|
const value = arg.slice("--with=".length);
|
|
1626
1637
|
if (value === "") throw new Error("--with needs a value");
|
|
1627
1638
|
parsed.withPaths.push(value);
|
|
1639
|
+
} else if (arg === "--module" || arg.startsWith("--module=")) {
|
|
1640
|
+
// PBCLI-94 (DR-083): one `<id>=<specifier>` per playbook id.
|
|
1641
|
+
const pair = parseModuleFlagValue(
|
|
1642
|
+
arg === "--module" ? argv[index + 1] : arg.slice("--module=".length),
|
|
1643
|
+
);
|
|
1644
|
+
if (arg === "--module") index += 1;
|
|
1645
|
+
if (parsed.modulePairs.some(([id]) => id === pair[0])) {
|
|
1646
|
+
throw new Error(
|
|
1647
|
+
`--module names playbook ${JSON.stringify(pair[0])} more than once`,
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
parsed.modulePairs.push(pair);
|
|
1628
1651
|
} else if (
|
|
1629
1652
|
RETIRED_FLAGS.has(arg) ||
|
|
1630
1653
|
[...RETIRED_FLAGS].some((flag) => arg.startsWith(`${flag}=`))
|
|
@@ -1681,6 +1704,12 @@ export function parseRunArgs(argv) {
|
|
|
1681
1704
|
) {
|
|
1682
1705
|
throw new Error("--with is unavailable during uncertain-turn recovery");
|
|
1683
1706
|
}
|
|
1707
|
+
if (
|
|
1708
|
+
(parsed.retryUncertain || parsed.discardUncertain) &&
|
|
1709
|
+
parsed.modulePairs.length > 0
|
|
1710
|
+
) {
|
|
1711
|
+
throw new Error("--module is unavailable during uncertain-turn recovery");
|
|
1712
|
+
}
|
|
1684
1713
|
if (
|
|
1685
1714
|
parsed.sessionId !== undefined &&
|
|
1686
1715
|
!SESSION_ID_PATTERN.test(parsed.sessionId)
|
|
@@ -1928,10 +1957,11 @@ async function writeStream(stream, text) {
|
|
|
1928
1957
|
function runHelpText(userConfigPath) {
|
|
1929
1958
|
return [
|
|
1930
1959
|
"Usage:",
|
|
1931
|
-
" playbook run [--with <path>]... [--
|
|
1932
|
-
" [--verbose] [--] [input]",
|
|
1960
|
+
" playbook run [--with <path>]... [--module <id>=<specifier>]...",
|
|
1961
|
+
" [--no-provision] [--json] [--verbose] [--] [input]",
|
|
1933
1962
|
" playbook run (--continue | --session <id>) [--with <path>]...",
|
|
1934
|
-
" [--
|
|
1963
|
+
" [--module <id>=<specifier>]... [--no-provision] [--json]",
|
|
1964
|
+
" [--verbose] [--] [reply]",
|
|
1935
1965
|
" playbook run --session <id> --retry-uncertain [--no-provision]",
|
|
1936
1966
|
" playbook run --session <id> --discard-uncertain",
|
|
1937
1967
|
"",
|
|
@@ -1959,6 +1989,10 @@ function runHelpText(userConfigPath) {
|
|
|
1959
1989
|
"",
|
|
1960
1990
|
"Options:",
|
|
1961
1991
|
" --with <path> overlay a generic config fragment (repeatable)",
|
|
1992
|
+
" --module <id>=<specifier>",
|
|
1993
|
+
" supply playbook <id>'s module ahead of its",
|
|
1994
|
+
" playbooks.<id>.from (repeatable, one per id); a",
|
|
1995
|
+
" continuation without either uses the stored module",
|
|
1962
1996
|
" --no-provision do not provision thin filesystem registry engines",
|
|
1963
1997
|
" --continue prefer this working directory, else global newest",
|
|
1964
1998
|
" --session <id> reply to one durable Captain session UUID",
|