@sublang/playbook 17.5.0 → 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/package.json +9 -1
- package/reference/sdlc/code.playbook/bin/machine-identity.js +233 -0
- 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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sublang/playbook",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.6.0",
|
|
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",
|
|
@@ -81,6 +81,8 @@
|
|
|
81
81
|
"reference/sdlc/code.playbook/session-store.d.ts",
|
|
82
82
|
"reference/sdlc/code.playbook/host-capabilities.js",
|
|
83
83
|
"reference/sdlc/code.playbook/host-capabilities.d.ts",
|
|
84
|
+
"reference/sdlc/code.playbook/machine-identity.js",
|
|
85
|
+
"reference/sdlc/code.playbook/machine-identity.d.ts",
|
|
84
86
|
"reference/sdlc/code.playbook/code.gears.md",
|
|
85
87
|
"reference/sdlc/code.playbook/playbook.config.template.yaml",
|
|
86
88
|
"reference/sdlc/code.playbook/bin/playbook.js",
|
|
@@ -89,6 +91,8 @@
|
|
|
89
91
|
"reference/sdlc/code.playbook/bin/interactive-session.js",
|
|
90
92
|
"reference/sdlc/code.playbook/bin/replay-observer.js",
|
|
91
93
|
"reference/sdlc/code.playbook/bin/session-store.js",
|
|
94
|
+
"reference/sdlc/code.playbook/bin/private-paths.js",
|
|
95
|
+
"reference/sdlc/code.playbook/bin/machine-identity.js",
|
|
92
96
|
"reference/sdlc/code.playbook/bin/provision.js",
|
|
93
97
|
"reference/sdlc/code.playbook/bin/adapter-sdk.js",
|
|
94
98
|
"reference/sdlc/code.playbook/bin/repository-effects.js",
|
|
@@ -201,6 +205,10 @@
|
|
|
201
205
|
"types": "./reference/sdlc/code.playbook/host-capabilities.d.ts",
|
|
202
206
|
"default": "./reference/sdlc/code.playbook/host-capabilities.js"
|
|
203
207
|
},
|
|
208
|
+
"./machine-identity": {
|
|
209
|
+
"types": "./reference/sdlc/code.playbook/machine-identity.d.ts",
|
|
210
|
+
"default": "./reference/sdlc/code.playbook/machine-identity.js"
|
|
211
|
+
},
|
|
204
212
|
"./slc/*": "./slc/*",
|
|
205
213
|
"./review/playbook": {
|
|
206
214
|
"types": "./reference/sdlc/review.playbook/review.playbook.d.ts",
|
|
@@ -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
|
+
}
|
|
@@ -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);
|
|
@@ -44,6 +44,13 @@ import {
|
|
|
44
44
|
assertPlaybookCaptainShellSnapshot,
|
|
45
45
|
assertPlaybookCaptainUnresolvedEffects,
|
|
46
46
|
} from '../playbook-captain.js';
|
|
47
|
+
import {
|
|
48
|
+
classifyOwnerMachine,
|
|
49
|
+
describeOwnerMachine,
|
|
50
|
+
isMachineIdentity,
|
|
51
|
+
resolveMachineIdentity,
|
|
52
|
+
} from './machine-identity.js';
|
|
53
|
+
import { sameFileIdentity, syncDirectory, tightenPrivateEntry } from './private-paths.js';
|
|
47
54
|
|
|
48
55
|
export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 6;
|
|
49
56
|
export const CAPTAIN_SESSION_RECORD_KIND = 'captain-session';
|
|
@@ -211,7 +218,7 @@ class CaptainSessionLeaseActiveError extends Error {
|
|
|
211
218
|
|
|
212
219
|
function foreignCaptainSessionLeaseActiveError(sessionId, hostname) {
|
|
213
220
|
const error = new Error(
|
|
214
|
-
`Captain session ${JSON.stringify(sessionId)} lease is owned by
|
|
221
|
+
`Captain session ${JSON.stringify(sessionId)} lease is owned by ${describeOwnerMachine(hostname)}`,
|
|
215
222
|
);
|
|
216
223
|
error.code = PLAYBOOK_SESSION_LEASE_ACTIVE;
|
|
217
224
|
error[ACTIVE_LEASE_ERROR] = true;
|
|
@@ -630,7 +637,25 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
630
637
|
const now = options.now ?? (() => new Date());
|
|
631
638
|
const createTempId = options.createTempId ?? randomUUID;
|
|
632
639
|
const createLeaseToken = options.createLeaseToken ?? randomUUID;
|
|
633
|
-
|
|
640
|
+
// DR-087: the owner's `hostname` carries this machine's identity, read
|
|
641
|
+
// once at the first lease boundary; an explicit value stands in for it
|
|
642
|
+
// (tests, migration sub-stores). An untagged owner is a legacy host
|
|
643
|
+
// name, compared with the current one — or with an explicit untagged
|
|
644
|
+
// value, which then names this machine both ways.
|
|
645
|
+
const explicitHostname = options.hostname;
|
|
646
|
+
const legacyHostname =
|
|
647
|
+
options.legacyHostname ??
|
|
648
|
+
(typeof explicitHostname === 'string' && !isMachineIdentity(explicitHostname)
|
|
649
|
+
? explicitHostname
|
|
650
|
+
: systemHostname());
|
|
651
|
+
let localIdentityPending;
|
|
652
|
+
const localIdentity = () =>
|
|
653
|
+
(localIdentityPending ??=
|
|
654
|
+
explicitHostname !== undefined
|
|
655
|
+
? Promise.resolve(explicitHostname)
|
|
656
|
+
: options.resolveHostname !== undefined
|
|
657
|
+
? options.resolveHostname()
|
|
658
|
+
: resolveMachineIdentity({ env, homeDir: home }));
|
|
634
659
|
const localPid = options.pid ?? process.pid;
|
|
635
660
|
const probeProcess =
|
|
636
661
|
options.probeProcess ?? ((pid) => process.kill(pid, 0));
|
|
@@ -649,8 +674,9 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
649
674
|
throw new Error('Captain session store path must be absolute');
|
|
650
675
|
}
|
|
651
676
|
if (
|
|
652
|
-
|
|
653
|
-
|
|
677
|
+
explicitHostname !== undefined &&
|
|
678
|
+
(typeof explicitHostname !== 'string' ||
|
|
679
|
+
explicitHostname.trim().length === 0)
|
|
654
680
|
) {
|
|
655
681
|
throw new Error('Captain session lease hostname must be a non-empty string');
|
|
656
682
|
}
|
|
@@ -1242,7 +1268,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1242
1268
|
} catch (cause) { return cause?.code === 'ENOENT' ? 'idle' : 'unknown'; }
|
|
1243
1269
|
try {
|
|
1244
1270
|
const owner = await readLeaseOwner(sessionId);
|
|
1245
|
-
if (owner.hostname !==
|
|
1271
|
+
if (classifyOwnerMachine(owner.hostname, await localIdentity(), legacyHostname) !== 'local') return 'unknown';
|
|
1246
1272
|
let state = 'active';
|
|
1247
1273
|
try { await probeProcess(owner.pid); }
|
|
1248
1274
|
catch (cause) {
|
|
@@ -1255,12 +1281,18 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1255
1281
|
};
|
|
1256
1282
|
|
|
1257
1283
|
const activeLeaseErrorFor = async (sessionId, owner) => {
|
|
1258
|
-
|
|
1284
|
+
const machine = classifyOwnerMachine(owner.hostname, await localIdentity(), legacyHostname);
|
|
1285
|
+
if (machine === 'foreign') {
|
|
1259
1286
|
return foreignCaptainSessionLeaseActiveError(
|
|
1260
1287
|
sessionId,
|
|
1261
1288
|
owner.hostname,
|
|
1262
1289
|
);
|
|
1263
1290
|
}
|
|
1291
|
+
if (machine !== 'local') {
|
|
1292
|
+
throw new Error(
|
|
1293
|
+
`Captain session ${JSON.stringify(sessionId)} lease owner machine ${JSON.stringify(owner.hostname)} cannot be verified`,
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1264
1296
|
try {
|
|
1265
1297
|
await probeProcess(owner.pid);
|
|
1266
1298
|
return new CaptainSessionLeaseActiveError(sessionId, owner.pid);
|
|
@@ -1350,7 +1382,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1350
1382
|
sessionId,
|
|
1351
1383
|
ownerToken,
|
|
1352
1384
|
pid: localPid,
|
|
1353
|
-
hostname:
|
|
1385
|
+
hostname: await localIdentity(),
|
|
1354
1386
|
acquiredAt: timestampFrom(now(), 'lease timestamp'),
|
|
1355
1387
|
});
|
|
1356
1388
|
let handle;
|
|
@@ -1596,7 +1628,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1596
1628
|
let sourceLease, lease;
|
|
1597
1629
|
try {
|
|
1598
1630
|
if (external) {
|
|
1599
|
-
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env, homeDir: home, fsOps: fs,
|
|
1631
|
+
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env, homeDir: home, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
|
|
1600
1632
|
sourceLease = await sourceStore.acquireManagement(sessionId);
|
|
1601
1633
|
}
|
|
1602
1634
|
lease = await acquire(sessionId, true);
|
|
@@ -1722,7 +1754,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1722
1754
|
const sourceDir = join(sourceEnv.XDG_STATE_HOME || join(sourceHome, '.local', 'state'), 'playbook', 'sessions');
|
|
1723
1755
|
const result = { sourceDir, migrated: [], skipped: [] };
|
|
1724
1756
|
if (sourceDir === sessionsDir) return result;
|
|
1725
|
-
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs,
|
|
1757
|
+
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
|
|
1726
1758
|
await sourceStore.prepare();
|
|
1727
1759
|
try { await assertPrivateDirectory(sourceDir, fs); }
|
|
1728
1760
|
catch (cause) { if (cause?.code === 'ENOENT') return result; throw cause; }
|
|
@@ -2444,9 +2476,6 @@ function replayFileIdentity(stat) {
|
|
|
2444
2476
|
return Object.freeze({ dev: stat.dev, ino: stat.ino });
|
|
2445
2477
|
}
|
|
2446
2478
|
|
|
2447
|
-
function sameFileIdentity(left, right) {
|
|
2448
|
-
return left.dev === right.dev && left.ino === right.ino;
|
|
2449
|
-
}
|
|
2450
2479
|
|
|
2451
2480
|
function sameReplayIdentity(left, right) {
|
|
2452
2481
|
return left.dev === right.dev && left.ino === right.ino;
|
|
@@ -6676,15 +6705,6 @@ async function assertPathMissing(path, fs, message) {
|
|
|
6676
6705
|
throw new Error(message);
|
|
6677
6706
|
}
|
|
6678
6707
|
|
|
6679
|
-
async function syncDirectory(path, fs) {
|
|
6680
|
-
let directory;
|
|
6681
|
-
try {
|
|
6682
|
-
directory = await fs.open(path, 'r');
|
|
6683
|
-
await directory.sync();
|
|
6684
|
-
} finally {
|
|
6685
|
-
await directory?.close();
|
|
6686
|
-
}
|
|
6687
|
-
}
|
|
6688
6708
|
|
|
6689
6709
|
async function ensurePrivateDirectory(path, fs) {
|
|
6690
6710
|
try {
|
|
@@ -6755,24 +6775,9 @@ async function prepareSessionPermissions(sessionsDir, fs, selectedSessionId, inc
|
|
|
6755
6775
|
let initial;
|
|
6756
6776
|
try { initial = await fs.lstat(sessionsDir); }
|
|
6757
6777
|
catch (cause) { if (cause?.code === 'ENOENT') return; throw cause; }
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1) || (uid !== undefined && stat.uid !== uid) || (stat.mode & required) !== required) throw new Error('session permission preparation refuses unsafe ownership, links, type, or owner access');
|
|
6762
|
-
};
|
|
6763
|
-
const tighten = async (path, before, directory) => {
|
|
6764
|
-
verify(before, directory);
|
|
6765
|
-
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0) | (directory ? constants.O_DIRECTORY ?? 0 : 0));
|
|
6766
|
-
try {
|
|
6767
|
-
const opened = await handle.stat(); verify(opened, directory);
|
|
6768
|
-
if (!sameFileIdentity(before, opened)) throw new Error('session entry changed during permission preparation');
|
|
6769
|
-
const mode = directory ? 0o700 : 0o600;
|
|
6770
|
-
if ((opened.mode & 0o7777) !== mode) await handle.chmod(mode);
|
|
6771
|
-
const after = await handle.stat(); verify(after, directory);
|
|
6772
|
-
const current = await fs.lstat(path); verify(current, directory);
|
|
6773
|
-
if (!sameFileIdentity(after, current) || (after.mode & 0o7777) !== mode || (current.mode & 0o7777) !== mode) throw new Error('session permission tightening could not be verified');
|
|
6774
|
-
} finally { await handle.close(); }
|
|
6775
|
-
};
|
|
6778
|
+
// The one verified tighten-only rule, shared with the machine identity
|
|
6779
|
+
// (session-storage-1, playbook-cli-97).
|
|
6780
|
+
const tighten = (path, before, directory) => tightenPrivateEntry(path, before, directory, fs, 'session permission preparation');
|
|
6776
6781
|
await tighten(sessionsDir, initial, true);
|
|
6777
6782
|
for (const name of await fs.readdir(sessionsDir)) {
|
|
6778
6783
|
const eligible = /^[0-9a-f-]{36}\.(?:json|records\.jsonl|hints\.json)$/.test(name)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
export declare const MACHINE_IDENTITY_TAG_PREFIX: 'machine-id:v1:';
|
|
5
|
+
export declare function isMachineIdentity(value: unknown): value is string;
|
|
6
|
+
export declare function machineIdentityPath(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
7
|
+
export interface ResolveMachineIdentityOptions {
|
|
8
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
9
|
+
readonly homeDir?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function resolveMachineIdentity(options?: ResolveMachineIdentityOptions): Promise<string>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 SubLang International <https://sublang.ai>
|
|
3
|
+
|
|
4
|
+
// DR-087: the public `@sublang/playbook/machine-identity` facade. Every
|
|
5
|
+
// member is the CLI host's own identity implementation; this module only
|
|
6
|
+
// narrows the accepted arguments to the declared surface (playbook-cli-98).
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
MACHINE_IDENTITY_TAG_PREFIX,
|
|
10
|
+
isMachineIdentity,
|
|
11
|
+
machineIdentityPath,
|
|
12
|
+
resolveMachineIdentity as resolve,
|
|
13
|
+
} from './bin/machine-identity.js';
|
|
14
|
+
|
|
15
|
+
export { MACHINE_IDENTITY_TAG_PREFIX, isMachineIdentity, machineIdentityPath };
|
|
16
|
+
|
|
17
|
+
export function resolveMachineIdentity(options = {}) {
|
|
18
|
+
return resolve({
|
|
19
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
20
|
+
...(options.homeDir !== undefined ? { homeDir: options.homeDir } : {}),
|
|
21
|
+
});
|
|
22
|
+
}
|