@sublang/playbook 17.5.0 → 17.7.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 +251 -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 +103 -48
- 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-assets.js +7 -1
- package/reference/sdlc/code.playbook/session-store.d.ts +13 -1
- package/reference/sdlc/code.playbook/session-store.js +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sublang/playbook",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.7.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,251 @@
|
|
|
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
|
+
/** One store's or coordinator's lazy resolution (playbook-cli-97): the
|
|
116
|
+
* first identity it resolves is kept for its lifetime, and a refusal is
|
|
117
|
+
* not, so its next lease or claim resolves again once the file is
|
|
118
|
+
* repaired. */
|
|
119
|
+
export function lazyMachineIdentity(resolve) {
|
|
120
|
+
let pending;
|
|
121
|
+
return () => {
|
|
122
|
+
if (pending === undefined) {
|
|
123
|
+
const attempt = Promise.resolve(resolve());
|
|
124
|
+
pending = attempt;
|
|
125
|
+
attempt.catch(() => {
|
|
126
|
+
if (pending === attempt) pending = undefined;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return pending;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function readOrPublish(path, fs, createIdentity) {
|
|
134
|
+
const directory = dirname(path);
|
|
135
|
+
await prepareDirectory(directory, path, fs);
|
|
136
|
+
for (let attempt = 0; attempt < PUBLISH_ATTEMPTS; attempt += 1) {
|
|
137
|
+
const existing = await readIdentity(path, fs);
|
|
138
|
+
if (existing !== undefined) return existing;
|
|
139
|
+
await publish(path, directory, fs, createIdentity);
|
|
140
|
+
}
|
|
141
|
+
throw new MachineIdentityUnavailableError(path, 'the identity could not be published');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function prepareDirectory(directory, path, fs) {
|
|
145
|
+
let stat;
|
|
146
|
+
try {
|
|
147
|
+
stat = await fs.lstat(directory);
|
|
148
|
+
} catch (cause) {
|
|
149
|
+
if (cause?.code !== 'ENOENT') {
|
|
150
|
+
throw new MachineIdentityUnavailableError(path, `its directory cannot be read: ${errorMessage(cause)}`);
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
154
|
+
await fs.chmod(directory, 0o700);
|
|
155
|
+
stat = await fs.lstat(directory);
|
|
156
|
+
} catch (creation) {
|
|
157
|
+
throw new MachineIdentityUnavailableError(path, `its directory cannot be created: ${errorMessage(creation)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
await tightenPrivateEntry(directory, stat, true, fs, 'machine identity directory');
|
|
162
|
+
} catch (cause) {
|
|
163
|
+
throw new MachineIdentityUnavailableError(path, errorMessage(cause));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readIdentity(path, fs) {
|
|
168
|
+
let before;
|
|
169
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
170
|
+
try {
|
|
171
|
+
before = await fs.lstat(path);
|
|
172
|
+
} catch (cause) {
|
|
173
|
+
if (cause?.code === 'ENOENT') return undefined;
|
|
174
|
+
throw new MachineIdentityUnavailableError(path, `it cannot be read: ${errorMessage(cause)}`);
|
|
175
|
+
}
|
|
176
|
+
if (
|
|
177
|
+
before.nlink <= 1 ||
|
|
178
|
+
before.isSymbolicLink() ||
|
|
179
|
+
!before.isFile() ||
|
|
180
|
+
attempt >= LINK_SETTLE_ATTEMPTS
|
|
181
|
+
) {
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
await new Promise((settle) => setTimeout(settle, LINK_SETTLE_DELAY_MS));
|
|
185
|
+
}
|
|
186
|
+
let current;
|
|
187
|
+
try {
|
|
188
|
+
current = await tightenPrivateEntry(path, before, false, fs, 'machine identity file');
|
|
189
|
+
} catch (cause) {
|
|
190
|
+
throw new MachineIdentityUnavailableError(path, errorMessage(cause));
|
|
191
|
+
}
|
|
192
|
+
let text;
|
|
193
|
+
const handle = await fs.open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
194
|
+
try {
|
|
195
|
+
const opened = await handle.stat();
|
|
196
|
+
if (!opened.isFile() || !sameFileIdentity(current, opened)) {
|
|
197
|
+
throw new MachineIdentityUnavailableError(path, 'it changed while being read');
|
|
198
|
+
}
|
|
199
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(await handle.readFile());
|
|
200
|
+
} catch (cause) {
|
|
201
|
+
if (cause instanceof MachineIdentityUnavailableError) throw cause;
|
|
202
|
+
throw new MachineIdentityUnavailableError(path, `it cannot be read: ${errorMessage(cause)}`);
|
|
203
|
+
} finally {
|
|
204
|
+
await handle.close();
|
|
205
|
+
}
|
|
206
|
+
const value = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
207
|
+
if (!isMachineIdentity(value) || value.includes('\n')) {
|
|
208
|
+
throw new MachineIdentityUnavailableError(path, 'it does not hold one machine identity');
|
|
209
|
+
}
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function publish(path, directory, fs, createIdentity) {
|
|
214
|
+
const uuid = createIdentity();
|
|
215
|
+
const value = `${MACHINE_IDENTITY_TAG_PREFIX}${uuid}`;
|
|
216
|
+
if (!isMachineIdentity(value)) {
|
|
217
|
+
throw new MachineIdentityUnavailableError(path, 'the identity generator returned an invalid value');
|
|
218
|
+
}
|
|
219
|
+
const temporary = join(directory, `.${IDENTITY_FILE}.${randomUUID()}.tmp`);
|
|
220
|
+
let handle;
|
|
221
|
+
try {
|
|
222
|
+
handle = await fs.open(temporary, 'wx', 0o600);
|
|
223
|
+
await handle.chmod(0o600);
|
|
224
|
+
await handle.writeFile(`${value}\n`, 'utf8');
|
|
225
|
+
await handle.sync();
|
|
226
|
+
await handle.close();
|
|
227
|
+
handle = undefined;
|
|
228
|
+
try {
|
|
229
|
+
await fs.link(temporary, path);
|
|
230
|
+
} catch (cause) {
|
|
231
|
+
// Another creator published first; its complete file is read next.
|
|
232
|
+
if (cause?.code === 'EEXIST') return;
|
|
233
|
+
throw cause;
|
|
234
|
+
}
|
|
235
|
+
await fs.unlink(temporary);
|
|
236
|
+
await syncDirectory(directory, fs);
|
|
237
|
+
} catch (cause) {
|
|
238
|
+
throw new MachineIdentityUnavailableError(path, `it cannot be published: ${errorMessage(cause)}`);
|
|
239
|
+
} finally {
|
|
240
|
+
try {
|
|
241
|
+
await handle?.close();
|
|
242
|
+
} catch {
|
|
243
|
+
// The publication failure stands.
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
await fs.unlink(temporary);
|
|
247
|
+
} catch {
|
|
248
|
+
// A temporary already gone leaves nothing to remove.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -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,13 @@ 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
|
+
lazyMachineIdentity,
|
|
34
|
+
resolveMachineIdentity,
|
|
35
|
+
} from './machine-identity.js';
|
|
29
36
|
|
|
30
37
|
const CLAIM_SCHEMA = 1;
|
|
31
38
|
const CLAIM_OWNER_FILE = 'owner.json';
|
|
@@ -1321,13 +1328,34 @@ function validateCohort(options) {
|
|
|
1321
1328
|
}
|
|
1322
1329
|
|
|
1323
1330
|
export function createRepositoryEffectCoordinator(options = {}) {
|
|
1324
|
-
|
|
1331
|
+
// DR-087: the claim owner's `hostname` carries this machine's identity,
|
|
1332
|
+
// read at the first claim and kept once read, while a refusal is read
|
|
1333
|
+
// again at the next (playbook-cli-97); an explicit value stands in for
|
|
1334
|
+
// it (tests). An untagged owner is a legacy host name compared with the
|
|
1335
|
+
// current one — or with an explicit untagged value, which then names
|
|
1336
|
+
// this machine both ways.
|
|
1337
|
+
const explicitHostname = options.hostname;
|
|
1338
|
+
const legacyHostname =
|
|
1339
|
+
options.legacyHostname ??
|
|
1340
|
+
(typeof explicitHostname === 'string' && !isMachineIdentity(explicitHostname)
|
|
1341
|
+
? explicitHostname
|
|
1342
|
+
: systemHostname());
|
|
1343
|
+
const currentIdentity = lazyMachineIdentity(() =>
|
|
1344
|
+
explicitHostname !== undefined
|
|
1345
|
+
? 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);
|
|
@@ -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, resolve } from 'node:path';
|
|
31
|
+
import { 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';
|
|
@@ -44,6 +44,14 @@ import {
|
|
|
44
44
|
assertPlaybookCaptainShellSnapshot,
|
|
45
45
|
assertPlaybookCaptainUnresolvedEffects,
|
|
46
46
|
} from '../playbook-captain.js';
|
|
47
|
+
import {
|
|
48
|
+
classifyOwnerMachine,
|
|
49
|
+
describeOwnerMachine,
|
|
50
|
+
isMachineIdentity,
|
|
51
|
+
lazyMachineIdentity,
|
|
52
|
+
resolveMachineIdentity,
|
|
53
|
+
} from './machine-identity.js';
|
|
54
|
+
import { sameFileIdentity, syncDirectory, tightenPrivateEntry } from './private-paths.js';
|
|
47
55
|
|
|
48
56
|
export const CAPTAIN_SESSION_RECORD_SCHEMA_VERSION = 6;
|
|
49
57
|
export const CAPTAIN_SESSION_RECORD_KIND = 'captain-session';
|
|
@@ -211,7 +219,7 @@ class CaptainSessionLeaseActiveError extends Error {
|
|
|
211
219
|
|
|
212
220
|
function foreignCaptainSessionLeaseActiveError(sessionId, hostname) {
|
|
213
221
|
const error = new Error(
|
|
214
|
-
`Captain session ${JSON.stringify(sessionId)} lease is owned by
|
|
222
|
+
`Captain session ${JSON.stringify(sessionId)} lease is owned by ${describeOwnerMachine(hostname)}`,
|
|
215
223
|
);
|
|
216
224
|
error.code = PLAYBOOK_SESSION_LEASE_ACTIVE;
|
|
217
225
|
error[ACTIVE_LEASE_ERROR] = true;
|
|
@@ -630,27 +638,55 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
630
638
|
const now = options.now ?? (() => new Date());
|
|
631
639
|
const createTempId = options.createTempId ?? randomUUID;
|
|
632
640
|
const createLeaseToken = options.createLeaseToken ?? randomUUID;
|
|
633
|
-
|
|
641
|
+
// DR-087: the owner's `hostname` carries this machine's identity, read
|
|
642
|
+
// at the first lease boundary and kept once read, while a refusal is
|
|
643
|
+
// read again at the next (playbook-cli-97); an explicit value stands in
|
|
644
|
+
// for it (tests, migration sub-stores). An untagged owner is a legacy
|
|
645
|
+
// host name, compared with the current one — or with an explicit
|
|
646
|
+
// untagged value, which then names this machine both ways.
|
|
647
|
+
const explicitHostname = options.hostname;
|
|
648
|
+
const legacyHostname =
|
|
649
|
+
options.legacyHostname ??
|
|
650
|
+
(typeof explicitHostname === 'string' && !isMachineIdentity(explicitHostname)
|
|
651
|
+
? explicitHostname
|
|
652
|
+
: systemHostname());
|
|
653
|
+
const localIdentity = lazyMachineIdentity(() =>
|
|
654
|
+
explicitHostname !== undefined
|
|
655
|
+
? 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));
|
|
637
662
|
const fs = { ...DEFAULT_FS_OPERATIONS, ...(options.fsOps ?? {}) };
|
|
663
|
+
// session-storage-11: a host that prepared the directory may forbid
|
|
664
|
+
// creating it or an ancestor, so a directory that moves or vanishes
|
|
665
|
+
// mid-operation is refused rather than recreated at its former path.
|
|
666
|
+
const createSessionsDir =
|
|
667
|
+
options.createSessionsDir === undefined ? true : options.createSessionsDir;
|
|
668
|
+
const mayCreateDirectory = (directory) =>
|
|
669
|
+
createSessionsDir || !isPathOrAncestor(directory, sessionsDir);
|
|
638
670
|
const replayReadCursors = new Map();
|
|
639
671
|
const replayReadQueues = new Map();
|
|
640
672
|
const portableWriters = new Map();
|
|
641
673
|
const assetStores = new Map();
|
|
642
674
|
const assetStoreFor = (sessionId) => {
|
|
643
675
|
assertSessionId(sessionId);
|
|
644
|
-
if (!assetStores.has(sessionId)) assetStores.set(sessionId, createAssetStore({ directory: join(sessionsDir, `${sessionId}.assets`), ...(options.maxAssetBytes === undefined ? {} : { maxAssetBytes: options.maxAssetBytes }) }));
|
|
676
|
+
if (!assetStores.has(sessionId)) assetStores.set(sessionId, createAssetStore({ directory: join(sessionsDir, `${sessionId}.assets`), createAncestors: createSessionsDir, ...(options.maxAssetBytes === undefined ? {} : { maxAssetBytes: options.maxAssetBytes }) }));
|
|
645
677
|
return assetStores.get(sessionId);
|
|
646
678
|
};
|
|
647
679
|
|
|
648
680
|
if (!isAbsolute(sessionsDir)) {
|
|
649
681
|
throw new Error('Captain session store path must be absolute');
|
|
650
682
|
}
|
|
683
|
+
if (typeof createSessionsDir !== 'boolean') {
|
|
684
|
+
throw new TypeError('Captain session store createSessionsDir must be a boolean');
|
|
685
|
+
}
|
|
651
686
|
if (
|
|
652
|
-
|
|
653
|
-
|
|
687
|
+
explicitHostname !== undefined &&
|
|
688
|
+
(typeof explicitHostname !== 'string' ||
|
|
689
|
+
explicitHostname.trim().length === 0)
|
|
654
690
|
) {
|
|
655
691
|
throw new Error('Captain session lease hostname must be a non-empty string');
|
|
656
692
|
}
|
|
@@ -1075,7 +1111,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1075
1111
|
: await portableWriters.get(recordValue.sessionId)?.checkpoint(recordValue);
|
|
1076
1112
|
if (record === undefined) throw new Error('session persistence requires its owning lifecycle');
|
|
1077
1113
|
const destination = recordPathFor(record.sessionId);
|
|
1078
|
-
await ensurePrivateDirectory(sessionsDir, fs);
|
|
1114
|
+
await ensurePrivateDirectory(sessionsDir, fs, mayCreateDirectory);
|
|
1079
1115
|
|
|
1080
1116
|
if (noReplace) {
|
|
1081
1117
|
await assertPathMissing(
|
|
@@ -1242,7 +1278,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1242
1278
|
} catch (cause) { return cause?.code === 'ENOENT' ? 'idle' : 'unknown'; }
|
|
1243
1279
|
try {
|
|
1244
1280
|
const owner = await readLeaseOwner(sessionId);
|
|
1245
|
-
if (owner.hostname !==
|
|
1281
|
+
if (classifyOwnerMachine(owner.hostname, await localIdentity(), legacyHostname) !== 'local') return 'unknown';
|
|
1246
1282
|
let state = 'active';
|
|
1247
1283
|
try { await probeProcess(owner.pid); }
|
|
1248
1284
|
catch (cause) {
|
|
@@ -1255,12 +1291,18 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1255
1291
|
};
|
|
1256
1292
|
|
|
1257
1293
|
const activeLeaseErrorFor = async (sessionId, owner) => {
|
|
1258
|
-
|
|
1294
|
+
const machine = classifyOwnerMachine(owner.hostname, await localIdentity(), legacyHostname);
|
|
1295
|
+
if (machine === 'foreign') {
|
|
1259
1296
|
return foreignCaptainSessionLeaseActiveError(
|
|
1260
1297
|
sessionId,
|
|
1261
1298
|
owner.hostname,
|
|
1262
1299
|
);
|
|
1263
1300
|
}
|
|
1301
|
+
if (machine !== 'local') {
|
|
1302
|
+
throw new Error(
|
|
1303
|
+
`Captain session ${JSON.stringify(sessionId)} lease owner machine ${JSON.stringify(owner.hostname)} cannot be verified`,
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1264
1306
|
try {
|
|
1265
1307
|
await probeProcess(owner.pid);
|
|
1266
1308
|
return new CaptainSessionLeaseActiveError(sessionId, owner.pid);
|
|
@@ -1326,7 +1368,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1326
1368
|
};
|
|
1327
1369
|
|
|
1328
1370
|
const makeLeaseStage = async (sessionId) => {
|
|
1329
|
-
await ensurePrivateDirectory(sessionsDir, fs);
|
|
1371
|
+
await ensurePrivateDirectory(sessionsDir, fs, mayCreateDirectory);
|
|
1330
1372
|
const ownerToken = createLeaseToken();
|
|
1331
1373
|
assertUuid(ownerToken, 'Captain session lease owner token');
|
|
1332
1374
|
const retiredPath = retiredPathFor(sessionId, ownerToken);
|
|
@@ -1350,7 +1392,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1350
1392
|
sessionId,
|
|
1351
1393
|
ownerToken,
|
|
1352
1394
|
pid: localPid,
|
|
1353
|
-
hostname:
|
|
1395
|
+
hostname: await localIdentity(),
|
|
1354
1396
|
acquiredAt: timestampFrom(now(), 'lease timestamp'),
|
|
1355
1397
|
});
|
|
1356
1398
|
let handle;
|
|
@@ -1593,10 +1635,10 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1593
1635
|
// the lease below; a preflight read never authorizes migration writes.
|
|
1594
1636
|
try { decodeSource(await readPrivateRegularFile(sourcePath, 0o600, fs, 'migration source')); }
|
|
1595
1637
|
catch (cause) { if (cause?.code !== 'ENOENT' || !sidecar && !external) throw cause; }
|
|
1596
|
-
let sourceLease, lease;
|
|
1638
|
+
let sourceLease, lease, failure;
|
|
1597
1639
|
try {
|
|
1598
1640
|
if (external) {
|
|
1599
|
-
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env, homeDir: home, fsOps: fs,
|
|
1641
|
+
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, createSessionsDir, env, homeDir: home, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
|
|
1600
1642
|
sourceLease = await sourceStore.acquireManagement(sessionId);
|
|
1601
1643
|
}
|
|
1602
1644
|
lease = await acquire(sessionId, true);
|
|
@@ -1687,7 +1729,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1687
1729
|
if (existing !== `${JSON.stringify(manifest)}\n`) throw new Error('migration destination manifest diverged');
|
|
1688
1730
|
} catch (cause) { if (cause?.code !== 'ENOENT') throw cause; }
|
|
1689
1731
|
}
|
|
1690
|
-
await ensurePrivateDirectory(inputsDir, fs);
|
|
1732
|
+
await ensurePrivateDirectory(inputsDir, fs, mayCreateDirectory);
|
|
1691
1733
|
const inputs = [{ path: sourcePath, bytes: Buffer.from(sourceBytes, 'utf8') }, ...(originalReplay === undefined ? [] : [{ path: sourceReplayPath, bytes: originalReplay }])];
|
|
1692
1734
|
for (const [index, input] of inputs.entries()) {
|
|
1693
1735
|
const path = join(inputsDir, String(index));
|
|
@@ -1711,8 +1753,11 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1711
1753
|
if (sidecar || external) { await fs.unlink(sourcePath); await syncDirectory(sourceDir, fs); }
|
|
1712
1754
|
await writePrivateJson(receiptPath, { ...receipt, complete: true }, fs);
|
|
1713
1755
|
return { manifest, migrated: true, reasons: [] };
|
|
1756
|
+
} catch (cause) {
|
|
1757
|
+
failure = { cause };
|
|
1758
|
+
throw cause;
|
|
1714
1759
|
} finally {
|
|
1715
|
-
|
|
1760
|
+
await releaseInOrder([lease, sourceLease], failure, `cannot migrate Captain session ${JSON.stringify(sessionId)} without leaving ownership uncertain`);
|
|
1716
1761
|
}
|
|
1717
1762
|
};
|
|
1718
1763
|
|
|
@@ -1722,7 +1767,7 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1722
1767
|
const sourceDir = join(sourceEnv.XDG_STATE_HOME || join(sourceHome, '.local', 'state'), 'playbook', 'sessions');
|
|
1723
1768
|
const result = { sourceDir, migrated: [], skipped: [] };
|
|
1724
1769
|
if (sourceDir === sessionsDir) return result;
|
|
1725
|
-
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs,
|
|
1770
|
+
const sourceStore = createCaptainSessionStore({ sessionsDir: sourceDir, createSessionsDir, env: sourceEnv, homeDir: sourceHome, fsOps: fs, resolveHostname: localIdentity, legacyHostname, pid: localPid, probeProcess });
|
|
1726
1771
|
await sourceStore.prepare();
|
|
1727
1772
|
try { await assertPrivateDirectory(sourceDir, fs); }
|
|
1728
1773
|
catch (cause) { if (cause?.code === 'ENOENT') return result; throw cause; }
|
|
@@ -1746,8 +1791,10 @@ export function createCaptainSessionStore(options = {}) {
|
|
|
1746
1791
|
|
|
1747
1792
|
const remove = async (sessionId) => {
|
|
1748
1793
|
const lease = await acquire(sessionId, true);
|
|
1794
|
+
let failure;
|
|
1749
1795
|
try { await lease.assertOwner(); await deleteRecord(sessionId); }
|
|
1750
|
-
|
|
1796
|
+
catch (cause) { failure = { cause }; throw cause; }
|
|
1797
|
+
finally { await releaseInOrder([lease], failure, `cannot delete Captain session ${JSON.stringify(sessionId)} without leaving ownership uncertain`); }
|
|
1751
1798
|
};
|
|
1752
1799
|
|
|
1753
1800
|
const exportBundle = async (sessionId) => {
|
|
@@ -2444,9 +2491,6 @@ function replayFileIdentity(stat) {
|
|
|
2444
2491
|
return Object.freeze({ dev: stat.dev, ino: stat.ino });
|
|
2445
2492
|
}
|
|
2446
2493
|
|
|
2447
|
-
function sameFileIdentity(left, right) {
|
|
2448
|
-
return left.dev === right.dev && left.ino === right.ino;
|
|
2449
|
-
}
|
|
2450
2494
|
|
|
2451
2495
|
function sameReplayIdentity(left, right) {
|
|
2452
2496
|
return left.dev === right.dev && left.ino === right.ino;
|
|
@@ -6676,17 +6720,8 @@ async function assertPathMissing(path, fs, message) {
|
|
|
6676
6720
|
throw new Error(message);
|
|
6677
6721
|
}
|
|
6678
6722
|
|
|
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
6723
|
|
|
6689
|
-
async function ensurePrivateDirectory(path, fs) {
|
|
6724
|
+
async function ensurePrivateDirectory(path, fs, mayCreate = () => true) {
|
|
6690
6725
|
try {
|
|
6691
6726
|
await assertPrivateDirectory(path, fs);
|
|
6692
6727
|
return;
|
|
@@ -6708,6 +6743,15 @@ async function ensurePrivateDirectory(path, fs) {
|
|
|
6708
6743
|
cursor = parent;
|
|
6709
6744
|
}
|
|
6710
6745
|
}
|
|
6746
|
+
// Refuse before creating anything, so a forbidden ancestor is never
|
|
6747
|
+
// recreated beneath a permitted descendant (session-storage-11).
|
|
6748
|
+
const forbidden = missing.find((directory) => !mayCreate(directory));
|
|
6749
|
+
if (forbidden !== undefined) {
|
|
6750
|
+
throw Object.assign(
|
|
6751
|
+
new Error(`Captain session store directory ${JSON.stringify(forbidden)} is missing and this store may not create it`),
|
|
6752
|
+
{ code: 'ENOENT' },
|
|
6753
|
+
);
|
|
6754
|
+
}
|
|
6711
6755
|
for (const directory of missing.reverse()) {
|
|
6712
6756
|
let created = false;
|
|
6713
6757
|
try {
|
|
@@ -6730,6 +6774,32 @@ async function ensurePrivateDirectory(path, fs) {
|
|
|
6730
6774
|
await assertPrivateDirectory(path, fs);
|
|
6731
6775
|
}
|
|
6732
6776
|
|
|
6777
|
+
// Releases every acquired lease in order. Without an earlier failure the
|
|
6778
|
+
// last release error propagates, as nested try/finally releases would; a
|
|
6779
|
+
// release error never hides the failure that preceded it.
|
|
6780
|
+
async function releaseInOrder(leases, failure, message) {
|
|
6781
|
+
const errors = [];
|
|
6782
|
+
for (const lease of leases) {
|
|
6783
|
+
try {
|
|
6784
|
+
await lease?.release();
|
|
6785
|
+
} catch (error) {
|
|
6786
|
+
errors.push(error);
|
|
6787
|
+
}
|
|
6788
|
+
}
|
|
6789
|
+
if (errors.length === 0) return;
|
|
6790
|
+
if (failure === undefined) throw errors.at(-1);
|
|
6791
|
+
throw new AggregateError([failure.cause, ...errors], message);
|
|
6792
|
+
}
|
|
6793
|
+
|
|
6794
|
+
function isPathOrAncestor(directory, path) {
|
|
6795
|
+
const fromDirectory = relative(directory, path);
|
|
6796
|
+
return fromDirectory === '' || (
|
|
6797
|
+
fromDirectory !== '..' &&
|
|
6798
|
+
!fromDirectory.startsWith(`..${sep}`) &&
|
|
6799
|
+
!isAbsolute(fromDirectory)
|
|
6800
|
+
);
|
|
6801
|
+
}
|
|
6802
|
+
|
|
6733
6803
|
async function assertPrivateDirectory(path, fs) {
|
|
6734
6804
|
const stat = await assertDirectoryNotLink(path, fs);
|
|
6735
6805
|
if ((stat.mode & 0o7777) !== 0o700) {
|
|
@@ -6755,24 +6825,9 @@ async function prepareSessionPermissions(sessionsDir, fs, selectedSessionId, inc
|
|
|
6755
6825
|
let initial;
|
|
6756
6826
|
try { initial = await fs.lstat(sessionsDir); }
|
|
6757
6827
|
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
|
-
};
|
|
6828
|
+
// The one verified tighten-only rule, shared with the machine identity
|
|
6829
|
+
// (session-storage-1, playbook-cli-97).
|
|
6830
|
+
const tighten = (path, before, directory) => tightenPrivateEntry(path, before, directory, fs, 'session permission preparation');
|
|
6776
6831
|
await tighten(sessionsDir, initial, true);
|
|
6777
6832
|
for (const name of await fs.readdir(sessionsDir)) {
|
|
6778
6833
|
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
|
+
}
|
|
@@ -87,9 +87,15 @@ export function createAssetStore(options) {
|
|
|
87
87
|
const directory = resolve(options.directory);
|
|
88
88
|
const maxAssetBytes = options.maxAssetBytes ?? DEFAULT_MAX_ASSET_BYTES;
|
|
89
89
|
if (!Number.isSafeInteger(maxAssetBytes) || maxAssetBytes <= 0) throw new TypeError('maximum asset bytes must be a positive safe integer');
|
|
90
|
+
// A session store forbidding creation of its sessions directory creates
|
|
91
|
+
// only this leaf inside it (session-storage-11).
|
|
92
|
+
const createAncestors = options.createAncestors ?? true;
|
|
90
93
|
let importTail = Promise.resolve();
|
|
91
94
|
const ensureDirectory = async (create) => {
|
|
92
|
-
if (create)
|
|
95
|
+
if (create) {
|
|
96
|
+
try { await mkdir(directory, { recursive: createAncestors, mode: 0o700 }); }
|
|
97
|
+
catch (error) { if (createAncestors || error.code !== 'EEXIST') throw error; }
|
|
98
|
+
}
|
|
93
99
|
assertDirectory(await lstat(directory));
|
|
94
100
|
};
|
|
95
101
|
const prepare = async () => {
|
|
@@ -285,7 +285,19 @@ export interface SharedSessionStore {
|
|
|
285
285
|
migrateLegacyDefault(options?: { env?: Readonly<Record<string, string | undefined>>; homeDir?: string }): Promise<{ sourceDir: string; migrated: readonly string[]; skipped: readonly { sessionId: string; reason: string }[] }>;
|
|
286
286
|
migrate(sessionId: string, options?: { sourcePath?: string; cwd?: string; backupDir?: string }): Promise<{ manifest: SessionManifest; migrated: boolean; reasons: readonly string[] }>;
|
|
287
287
|
}
|
|
288
|
-
export declare function createSessionStore(options?: {
|
|
288
|
+
export declare function createSessionStore(options?: {
|
|
289
|
+
maxAssetBytes?: number;
|
|
290
|
+
sessionsDir?: string;
|
|
291
|
+
/**
|
|
292
|
+
* Default `true`. `false` refuses, rather than creates, a missing sessions directory or ancestor when preparing
|
|
293
|
+
* session payload, lease, asset or migration directories. Machine-identity provisioning keeps its own rule and may
|
|
294
|
+
* still create its directory, including an ancestor it shares with the sessions directory.
|
|
295
|
+
*/
|
|
296
|
+
createSessionsDir?: boolean;
|
|
297
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
298
|
+
homeDir?: string;
|
|
299
|
+
[key: string]: any;
|
|
300
|
+
}): SharedSessionStore;
|
|
289
301
|
export declare function validateSessionManifest(value: unknown): SessionManifest;
|
|
290
302
|
export declare function validateSessionContext(value: unknown): SessionContext;
|
|
291
303
|
export declare function projectCaptainSessionStructure(value: SessionExecutionProjection): SessionStructuralProjection;
|
|
@@ -21,9 +21,19 @@ export function openSessionStore(sessionsDir) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
function wrapStore(store) {
|
|
24
|
+
// Preparation is kept once it succeeds; a refusal is not, so the next
|
|
25
|
+
// operation prepares again once the directory is repaired
|
|
26
|
+
// (session-storage-1).
|
|
24
27
|
let preparation;
|
|
25
28
|
const prepared = async (operation) => {
|
|
26
|
-
|
|
29
|
+
if (preparation === undefined) {
|
|
30
|
+
const attempt = store.prepare();
|
|
31
|
+
preparation = attempt;
|
|
32
|
+
attempt.catch(() => {
|
|
33
|
+
if (preparation === attempt) preparation = undefined;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
await preparation;
|
|
27
37
|
return operation();
|
|
28
38
|
};
|
|
29
39
|
return Object.freeze({
|