@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.
@@ -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 foreign host ${JSON.stringify(hostname)}`,
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
- const localHostname = options.hostname ?? systemHostname();
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
- typeof localHostname !== 'string' ||
653
- localHostname.trim().length === 0
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 !== localHostname) return 'unknown';
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
- if (owner.hostname !== localHostname) {
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: localHostname,
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, hostname: localHostname, pid: localPid, probeProcess });
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, hostname: localHostname, pid: localPid, probeProcess });
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
- const uid = process.getuid?.();
6759
- const verify = (stat, directory) => {
6760
- const required = directory ? 0o700 : 0o600;
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
+ }
@@ -43,8 +43,15 @@ export declare function installRetainedGenerationsForLaunch(options: {
43
43
  export declare function executionConfigFromPlan(plan: any): SessionExecutionProjection;
44
44
  export declare function validateFrozenExecutionConfig(structural: SessionStructuralProjection, execution: SessionExecutionProjection, dependencies: { loadModule: (specifier: string) => Promise<any>; prepareRegistryModule?: (request: {id: string; from: string; authoredFrom: string}) => Promise<string | void> }): Promise<SessionExecutionProjection>;
45
45
  export declare function driveHeadlessCaptainTurn(options: any): Promise<any>;
46
- export declare function normalizeLaunchPlan(top: any, options?: any): Promise<any>;
47
- export declare function loadLaunchPlan(options: any): Promise<any>;
46
+ /** Playbook id to registry module specifier, supplied at launch ahead of `playbooks.<id>.from` (DR-083). */
47
+ export type PlaybookModuleLocations = Readonly<Record<string, string>>;
48
+ export interface LaunchPlanModuleOptions {
49
+ /** Absolute paths become file URLs; file URLs and package specifiers are kept; relative paths are refused. */
50
+ readonly modules?: PlaybookModuleLocations;
51
+ readonly [key: string]: any;
52
+ }
53
+ export declare function normalizeLaunchPlan(top: any, options?: LaunchPlanModuleOptions): Promise<any>;
54
+ export declare function loadLaunchPlan(options: LaunchPlanModuleOptions): Promise<any>;
48
55
  export declare function composeGenericConfig(top: any, loadModule: (specifier: string) => Promise<any>, configPath?: string): Promise<any>;
49
56
  export declare function projectTmuxConfig(plan: any): any;
50
57
  export declare function resolveLaunchSessionsDir(options: any): string;