@zq-silk/yui 0.6.7 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -735,7 +735,7 @@ process cleanup explicitly, and revalidates process, tmux pane, and socket
735
735
  identity immediately before acting. Partial failures are reported without
736
736
  hiding the resources that remain. Use `--all` to include discovered Yui homes.
737
737
 
738
- `controller restart` replaces the Controller process and its scheduler/socket services with the currently installed Yui version. It does not stop or restart managed tmux/Agent sessions.
738
+ `controller restart` replaces the Controller process and its scheduler/socket services with the currently installed Yui version. It can recover a lost discovery record only when the old process still matches the current UID, Controller entrypoint, physical Home, PID, and process-start identity. It does not stop or restart managed tmux/Agent sessions.
739
739
 
740
740
  Successful `setup`, `upgrade`, and `update` commands ensure that the current
741
741
  Home has a running Controller, starting one when the Home was previously idle.
@@ -927,9 +927,13 @@ make install-local
927
927
 
928
928
  `make install-local` writes a self-contained launcher at `output/dev/bin/yui`
929
929
  and never touches the user-level `yui` command. The launcher resolves its own
930
- checkout and defaults `YUI_HOME` to this checkout's `output/dev/home`, so every
931
- instance identity that Yui derives from `YUI_HOME`—Controller socket, tmux
932
- server, and state—stays separate from any other checkout or the global install.
930
+ checkout and defaults `YUI_HOME` to this checkout's `output/dev/home`. The
931
+ Controller socket is derived from that Home's durable `homeId` at the fixed
932
+ Linux path `/tmp/yui-<uid>/<homeId>.sock`; discovery also binds the physical
933
+ Home directory, so caller `TMPDIR`, path aliases, and copied runtime records
934
+ cannot redirect control requests. The tmux server namespace and state remain
935
+ scoped to the selected Home, so the checkout stays separate from other
936
+ checkouts and the global install.
933
937
  It is idempotent, so re-run it after pulling new code (then run
934
938
  `./output/dev/bin/yui controller restart` if a Controller is already running).
935
939
  Call the launcher by its absolute path for a stable per-checkout entry point;
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
- import { callController, readControllerDiscovery } from "../core/controllerClient.js";
3
+ import { callController, readControllerDiscovery, stopOrphanedFileTaskController, stopPreviousFileTaskController } from "../core/controllerClient.js";
4
4
  import { FILE_TASK_CONTROLLER_PROTOCOL_VERSION } from "../core/protocol.js";
5
5
  import { AGENT_OPERATIONAL_ENVIRONMENT_NAMES, nativeAgentEnvironmentNames, operationalAgentEnvironment, selectEnvironment, YUI_MANAGED_RUNTIME_ENVIRONMENT_NAMES } from "../agent/launchEnvironment.js";
6
6
  import { openCompatibleFileTaskStore } from "../storage/compatibleTaskStore.js";
@@ -227,8 +227,26 @@ export async function restartFileTaskController(home, options = {}) {
227
227
  const call = options.call ?? callController;
228
228
  const shutdownTimeoutMs = positive(options.shutdownTimeoutMs, SHUTDOWN_TIMEOUT_MS, "shutdownTimeoutMs");
229
229
  const pollMs = positive(options.pollIntervalMs, POLL_INTERVAL_MS, "pollIntervalMs");
230
- const current = await readOptionalControllerStatus(home, call);
231
- const previousPid = controllerPid(current);
230
+ let current = null;
231
+ let previousPid;
232
+ try {
233
+ current = await readOptionalControllerStatus(home, call);
234
+ previousPid = controllerPid(current);
235
+ }
236
+ catch (error) {
237
+ if (options.call !== undefined || !isInvalidDiscovery(error))
238
+ throw error;
239
+ // Protocol v3 is the immediately previous released Controller. Keep its
240
+ // parser and transport confined to this explicit replacement operation;
241
+ // no ordinary request can fall back to it.
242
+ const previous = await stopPreviousFileTaskController(home, shutdownTimeoutMs);
243
+ previousPid = previous.pid;
244
+ }
245
+ if (!controllerRunning(current) && options.call === undefined) {
246
+ const orphan = await stopOrphanedFileTaskController(home, shutdownTimeoutMs);
247
+ if (orphan !== undefined)
248
+ previousPid = orphan.pid;
249
+ }
232
250
  if (controllerRunning(current)) {
233
251
  await callFileTaskController(home, "controller.stop", {}, options);
234
252
  const deadline = Date.now() + shutdownTimeoutMs;
@@ -555,6 +573,11 @@ function isDefinitelyNotRunning(error) {
555
573
  const code = error.code;
556
574
  return code === "CONTROLLER_NOT_RUNNING";
557
575
  }
576
+ function isInvalidDiscovery(error) {
577
+ if (typeof error !== "object" || error === null || !("code" in error))
578
+ return false;
579
+ return error.code === "CONTROLLER_DISCOVERY_INVALID";
580
+ }
558
581
  async function readOptionalControllerStatus(home, call) {
559
582
  try {
560
583
  return await call(home, "controller.status", {});
@@ -257,8 +257,9 @@ function assertOwnedArtifactPath(resource, environment) {
257
257
  }
258
258
  const home = resource.yuiHome;
259
259
  if (home !== undefined
260
+ && resource.homeId !== undefined
260
261
  && artifact.artifactKind === "controller-socket"
261
- && path === controllerSocketPath(home)) {
262
+ && path === controllerSocketPath(resource.homeId)) {
262
263
  return;
263
264
  }
264
265
  if (home !== undefined
@@ -174,7 +174,7 @@ export function buildControllerResourceInventory(facts) {
174
174
  ? homeFact.artifacts
175
175
  : [discoveryArtifact, ...homeFact.artifacts.filter((artifact) => (artifact.path !== discoveryArtifact.path))];
176
176
  for (const artifact of artifacts) {
177
- resources.push(artifactResource(artifact, yuiHome, homeFact.domain));
177
+ resources.push(artifactResource(artifact, yuiHome, homeFact.domain, homeFact.homeId));
178
178
  }
179
179
  }
180
180
  for (const artifact of facts.globalArtifacts) {
@@ -186,6 +186,7 @@ export function buildControllerResourceInventory(facts) {
186
186
  const owned = ordered.filter((resource) => resource.yuiHome === yuiHome);
187
187
  return {
188
188
  yuiHome,
189
+ ...(home.homeId === undefined ? {} : { homeId: home.homeId }),
189
190
  storageStatus: home.storageStatus,
190
191
  resourceCount: owned.length,
191
192
  liveProcessCount: uniqueProcesses(owned).length,
@@ -332,7 +333,7 @@ function processResource(input) {
332
333
  ...(input.domain === undefined ? {} : { domain: input.domain })
333
334
  };
334
335
  }
335
- function artifactResource(artifact, yuiHome, domain) {
336
+ function artifactResource(artifact, yuiHome, domain, homeId) {
336
337
  const stale = !artifact.active;
337
338
  const baseDisposition = stale ? "safe" : "report-only";
338
339
  const disposition = domainDisposition(baseDisposition, undefined, domain);
@@ -346,6 +347,7 @@ function artifactResource(artifact, yuiHome, domain) {
346
347
  disposition,
347
348
  reasonCode: domainReason(stale ? `stale-${artifact.artifactKind}` : `active-unmapped-${artifact.artifactKind}`, domain, disposition),
348
349
  ...(yuiHome === undefined ? {} : { yuiHome }),
350
+ ...(homeId === undefined ? {} : { homeId }),
349
351
  owner: yuiHome === undefined
350
352
  ? { kind: "none" }
351
353
  : { kind: "controller-domain", yuiHome },
@@ -10,6 +10,7 @@ import { tmuxSocketDirectory, tmuxSocketEnvironment } from "../tmux/tmuxSocketEn
10
10
  import { readControllerDiscovery } from "../core/controllerClient.js";
11
11
  import { CONTROLLER_DISCOVERY_PATH } from "../core/protocol.js";
12
12
  import { controllerSocketPath } from "../core/controllerEndpoint.js";
13
+ import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
13
14
  import { buildControllerResourceInventory } from "./resourceInventory.js";
14
15
  import { CONTROLLER_DOMAIN_PATH, EPHEMERAL_DOMAIN_GRACE_MS, ephemeralDomainFingerprint, readEphemeralDomainIdentity, readLinuxProcessStartIdentity } from "./domainIdentity.js";
15
16
  const LINUX_PROCESS_SCAN_BATCH_SIZE = 64;
@@ -20,8 +21,7 @@ export async function scanControllerResourceInventory(options) {
20
21
  const observedAt = (options.now ?? (() => new Date()))();
21
22
  const warnings = [];
22
23
  const activeSockets = readActiveUnixSocketPaths(warnings);
23
- const processes = (await listLinuxProcesses(warnings))
24
- .filter(({ pid }) => pid !== process.pid);
24
+ const processes = normalizePhysicalHomeAliases((await listLinuxProcesses(warnings)).filter(({ pid }) => pid !== process.pid), currentHome);
25
25
  const homes = new Set([currentHome]);
26
26
  if (options.scope === "all") {
27
27
  for (const process of processes) {
@@ -88,19 +88,23 @@ export async function scanControllerResourceInventory(options) {
88
88
  || (discovery.pid === process.pid
89
89
  && readLinuxProcessStartIdentity(process.pid) === discovery.processStartIdentity));
90
90
  const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
91
- const expectedControllerSocketPath = controllerSocketPath(home);
91
+ const expectedControllerSocketPath = state.homeId === undefined
92
+ ? undefined
93
+ : controllerSocketPath(state.homeId);
92
94
  if (discovery.status === "valid"
93
95
  && !validDiscoveryProcess
94
96
  && existsSync(discoveryPath)) {
95
97
  artifacts.push(fileArtifact(discoveryPath, "controller-discovery", false));
96
98
  }
97
- if (existsSync(expectedControllerSocketPath)
99
+ if (expectedControllerSocketPath !== undefined
100
+ && existsSync(expectedControllerSocketPath)
98
101
  && !activeSockets.has(expectedControllerSocketPath)
99
102
  && !validDiscoveryProcess) {
100
103
  artifacts.push(fileArtifact(expectedControllerSocketPath, "controller-socket", false));
101
104
  }
102
105
  homeFacts.push({
103
106
  yuiHome: home,
107
+ ...(state.homeId === undefined ? {} : { homeId: state.homeId }),
104
108
  exists: existsSync(home),
105
109
  storageStatus: state.storageStatus,
106
110
  discovery,
@@ -203,6 +207,9 @@ async function listLinuxProcesses(warnings) {
203
207
  const args = splitNullDelimited(readFileSync(`/proc/${pid}/cmdline`));
204
208
  const command = readFileSync(`/proc/${pid}/comm`, "utf8").trim();
205
209
  const yuiHome = readYuiHome(pid);
210
+ const homeFilesystemId = yuiHome === undefined
211
+ ? undefined
212
+ : optionalHomeFilesystemId(yuiHome);
206
213
  const io = readProcessIo(pid);
207
214
  result.push({
208
215
  pid,
@@ -210,6 +217,7 @@ async function listLinuxProcesses(warnings) {
210
217
  uid: processUid,
211
218
  startIdentity: parsed.startIdentity,
212
219
  ...(yuiHome === undefined ? {} : { yuiHome }),
220
+ ...(homeFilesystemId === undefined ? {} : { homeFilesystemId }),
213
221
  kind: classifyRuntimeProcess(args, command),
214
222
  command,
215
223
  args,
@@ -227,6 +235,47 @@ async function listLinuxProcesses(warnings) {
227
235
  });
228
236
  return result;
229
237
  }
238
+ /**
239
+ * Process environments retain their launch-time path spelling. Collapse
240
+ * symlink and bind-mount aliases by physical directory identity so inventory,
241
+ * update reconciliation, and cleanup all attribute them to one Home.
242
+ */
243
+ function normalizePhysicalHomeAliases(processes, currentHome) {
244
+ const currentFilesystemId = optionalHomeFilesystemId(currentHome);
245
+ const representatives = new Map();
246
+ if (currentFilesystemId !== undefined) {
247
+ representatives.set(currentFilesystemId, currentHome);
248
+ }
249
+ for (const processFact of processes) {
250
+ const filesystemId = processFact.homeFilesystemId;
251
+ const yuiHome = processFact.yuiHome;
252
+ if (filesystemId === undefined
253
+ || yuiHome === undefined
254
+ || filesystemId === currentFilesystemId)
255
+ continue;
256
+ const existing = representatives.get(filesystemId);
257
+ if (existing === undefined || yuiHome.localeCompare(existing) < 0) {
258
+ representatives.set(filesystemId, yuiHome);
259
+ }
260
+ }
261
+ return processes.map((processFact) => {
262
+ const filesystemId = processFact.homeFilesystemId;
263
+ const representative = filesystemId === undefined
264
+ ? undefined
265
+ : representatives.get(filesystemId);
266
+ return representative === undefined || representative === processFact.yuiHome
267
+ ? processFact
268
+ : { ...processFact, yuiHome: representative };
269
+ });
270
+ }
271
+ function optionalHomeFilesystemId(home) {
272
+ try {
273
+ return readHomeFilesystemId(home);
274
+ }
275
+ catch {
276
+ return undefined;
277
+ }
278
+ }
230
279
  /** Cooperatively visits synchronous inventory entries in bounded turns. */
231
280
  export async function forEachInEventLoopBatches(entries, batchSize, visit, timing = {}) {
232
281
  if (!Number.isSafeInteger(batchSize) || batchSize < 1) {
@@ -372,6 +421,10 @@ async function inspectDiscovery(home, processes, activeSockets) {
372
421
  const discovery = await readControllerDiscovery(home);
373
422
  return {
374
423
  status: "valid",
424
+ protocolVersion: discovery.protocolVersion,
425
+ homeId: discovery.homeId,
426
+ homeFilesystemId: discovery.homeFilesystemId,
427
+ controllerInstanceId: discovery.controllerInstanceId,
375
428
  pid: discovery.pid,
376
429
  processStartIdentity: discovery.processStartIdentity,
377
430
  socketPath: discovery.socketPath,
@@ -659,7 +712,11 @@ function loadHomeState(home, warnings, options) {
659
712
  }
660
713
  }
661
714
  }
662
- return { storageStatus: schema.status, roles };
715
+ return {
716
+ storageStatus: schema.status,
717
+ homeId: store.getHomeIdentity().homeId,
718
+ roles
719
+ };
663
720
  }
664
721
  catch (error) {
665
722
  warnings.push(`Cannot load runtime ownership for ${home}: ${message(error)}`);
@@ -84,6 +84,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
84
84
  // the same WAL db and serialize via BEGIN IMMEDIATE + busy_timeout.
85
85
  ? new SqliteTaskStore(home)
86
86
  : openCompatibleFileTaskStore(home));
87
+ const homeId = store.getHomeIdentity().homeId;
87
88
  // When the worker backend is active, the db-touching observer folds run in
88
89
  // the worker (off the main event loop). The client is closed on shutdown.
89
90
  const asyncStoreClient = useWorker
@@ -147,7 +148,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
147
148
  runtimeRoot: `${resolve(home)}.task-runtimes`,
148
149
  controlPlane: {
149
150
  yuiHome: home,
150
- controllerSocketPath: controllerSocketPath(home),
151
+ controllerSocketPath: controllerSocketPath(homeId),
151
152
  tmuxNamespace: yuiTmuxServerName(home),
152
153
  globalInstallPaths: [process.execPath]
153
154
  }
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { lstat, readFile } from "node:fs/promises";
3
3
  import { createConnection } from "node:net";
4
- import { join } from "node:path";
5
- import { CONTROLLER_DISCOVERY_PATH, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, encodeControllerRequest, parseControllerDiscovery, parseControllerResponse } from "./protocol.js";
6
6
  import { isControllerSocketPathForHome } from "./controllerEndpoint.js";
7
+ import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
8
+ import { findLiveControllerProcessForHome, inspectLiveControllerProcess } from "./controllerProcessIdentity.js";
7
9
  export class ControllerClientError extends Error {
8
10
  code;
9
11
  constructor(code, message) {
@@ -24,8 +26,11 @@ export async function readControllerDiscovery(home) {
24
26
  throw invalidDiscovery();
25
27
  }
26
28
  const value = JSON.parse(await readFile(discoveryPath, "utf8"));
27
- const socketPath = discoverySocketPath(home, value);
28
- return parseControllerDiscovery(value, socketPath);
29
+ const socketPath = discoverySocketPath(value);
30
+ return parseControllerDiscovery(value, {
31
+ homeFilesystemId: readHomeFilesystemId(home),
32
+ socketPath
33
+ });
29
34
  }
30
35
  catch (error) {
31
36
  if (error instanceof ControllerClientError)
@@ -36,13 +41,15 @@ export async function readControllerDiscovery(home) {
36
41
  throw invalidDiscovery();
37
42
  }
38
43
  }
39
- function discoverySocketPath(home, value) {
44
+ function discoverySocketPath(value) {
40
45
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
41
46
  throw invalidDiscovery();
42
47
  }
48
+ const homeId = Reflect.get(value, "homeId");
43
49
  const socketPath = Reflect.get(value, "socketPath");
44
- if (typeof socketPath !== "string"
45
- || !isControllerSocketPathForHome(home, socketPath)) {
50
+ if (typeof homeId !== "string"
51
+ || typeof socketPath !== "string"
52
+ || !isControllerSocketPathForHome(homeId, socketPath)) {
46
53
  throw invalidDiscovery();
47
54
  }
48
55
  return socketPath;
@@ -59,6 +66,10 @@ export async function callController(home, method, params = {}, options = {}) {
59
66
  requestLine = encodeControllerRequest({
60
67
  id,
61
68
  token: discovery.token,
69
+ protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
70
+ homeId: discovery.homeId,
71
+ homeFilesystemId: discovery.homeFilesystemId,
72
+ controllerInstanceId: discovery.controllerInstanceId,
62
73
  method,
63
74
  params
64
75
  });
@@ -71,6 +82,129 @@ export async function callController(home, method, params = {}, options = {}) {
71
82
  }
72
83
  return exchange(discovery.socketPath, requestLine, id, timeoutMs);
73
84
  }
85
+ /**
86
+ * One-generation transition used only by the documented `controller restart`
87
+ * upgrade path from the last released protocol (v3). Ordinary calls never
88
+ * accept or dispatch through this legacy shape.
89
+ */
90
+ export async function stopPreviousFileTaskController(home, timeoutMs) {
91
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
92
+ throw new TypeError("Controller timeout must be a positive integer.");
93
+ }
94
+ const discovery = await readPreviousControllerDiscovery(home);
95
+ const homeFilesystemId = readHomeFilesystemId(home);
96
+ if (inspectLiveControllerProcess(discovery.pid, homeFilesystemId, discovery.processStartIdentity) === undefined)
97
+ throw invalidDiscovery();
98
+ const id = randomUUID();
99
+ const requestLine = `${JSON.stringify({
100
+ id,
101
+ token: discovery.token,
102
+ method: "controller.stop",
103
+ params: { expectedPid: discovery.pid }
104
+ })}\n`;
105
+ const deadline = Date.now() + timeoutMs;
106
+ await exchange(discovery.socketPath, requestLine, id, timeoutMs);
107
+ while (inspectLiveControllerProcess(discovery.pid, homeFilesystemId, discovery.processStartIdentity) !== undefined) {
108
+ if (Date.now() >= deadline) {
109
+ throw new ControllerClientError("CONTROLLER_TIMEOUT", `Previous Controller did not stop within ${timeoutMs} ms.`);
110
+ }
111
+ await new Promise((resolve) => setTimeout(resolve, 20));
112
+ }
113
+ return Object.freeze({ pid: discovery.pid });
114
+ }
115
+ /**
116
+ * Explicit restart recovery for an exact Controller whose discovery record
117
+ * was lost. The signal is fenced by same UID, controller entrypoint, physical
118
+ * Home identity, PID, and process-start identity; an unprovable process is
119
+ * never touched.
120
+ */
121
+ export async function stopOrphanedFileTaskController(home, timeoutMs) {
122
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
123
+ throw new TypeError("Controller timeout must be a positive integer.");
124
+ }
125
+ const homeFilesystemId = readHomeFilesystemId(home);
126
+ const candidate = findLiveControllerProcessForHome(homeFilesystemId);
127
+ if (candidate === undefined)
128
+ return undefined;
129
+ if (inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity) === undefined)
130
+ return undefined;
131
+ try {
132
+ process.kill(candidate.pid, "SIGTERM");
133
+ }
134
+ catch (error) {
135
+ if (isNodeError(error) && error.code === "ESRCH")
136
+ return undefined;
137
+ throw error;
138
+ }
139
+ const deadline = Date.now() + timeoutMs;
140
+ while (inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity) !== undefined) {
141
+ if (Date.now() >= deadline) {
142
+ throw new ControllerClientError("CONTROLLER_TIMEOUT", `Orphaned Controller did not stop within ${timeoutMs} ms.`);
143
+ }
144
+ await new Promise((resolve) => setTimeout(resolve, 20));
145
+ }
146
+ return Object.freeze({ pid: candidate.pid });
147
+ }
148
+ async function readPreviousControllerDiscovery(home) {
149
+ const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
150
+ try {
151
+ const metadata = await lstat(discoveryPath);
152
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
153
+ if (!metadata.isFile()
154
+ || (metadata.mode & 0o077) !== 0
155
+ || metadata.size > 4_096
156
+ || (uid !== undefined && metadata.uid !== uid))
157
+ throw invalidDiscovery();
158
+ const value = JSON.parse(await readFile(discoveryPath, "utf8"));
159
+ if (typeof value !== "object"
160
+ || value === null
161
+ || Array.isArray(value)
162
+ || !hasExactStringKeys(value, [
163
+ "pid",
164
+ "processStartIdentity",
165
+ "socketPath",
166
+ "token"
167
+ ]))
168
+ throw invalidDiscovery();
169
+ const record = value;
170
+ if (!Number.isSafeInteger(record.pid)
171
+ || record.pid < 1
172
+ || typeof record.processStartIdentity !== "string"
173
+ || !/^[0-9]{1,32}$/u.test(record.processStartIdentity)
174
+ || typeof record.socketPath !== "string"
175
+ || !isPreviousControllerSocketPath(record.socketPath)
176
+ || typeof record.token !== "string"
177
+ || !/^[a-f0-9]{64}$/u.test(record.token))
178
+ throw invalidDiscovery();
179
+ return Object.freeze({
180
+ pid: record.pid,
181
+ processStartIdentity: record.processStartIdentity,
182
+ socketPath: record.socketPath,
183
+ token: record.token
184
+ });
185
+ }
186
+ catch (error) {
187
+ if (error instanceof ControllerClientError)
188
+ throw error;
189
+ if (isNodeError(error) && error.code === "ENOENT") {
190
+ throw new ControllerClientError("CONTROLLER_NOT_RUNNING", "Controller is not running.");
191
+ }
192
+ throw invalidDiscovery();
193
+ }
194
+ }
195
+ function hasExactStringKeys(value, expected) {
196
+ const keys = Reflect.ownKeys(value);
197
+ return keys.length === expected.length
198
+ && keys.every((key) => typeof key === "string")
199
+ && expected.every((key) => Object.hasOwn(value, key));
200
+ }
201
+ function isPreviousControllerSocketPath(candidate) {
202
+ if (!isAbsolute(candidate) || resolve(candidate) !== candidate)
203
+ return false;
204
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
205
+ return basename(dirname(candidate)) === `yui-${uid}`
206
+ && /^[a-f0-9]{24}\.sock$/u.test(basename(candidate));
207
+ }
74
208
  function exchange(socketPath, requestLine, expectedId, timeoutMs) {
75
209
  return new Promise((resolve, reject) => {
76
210
  const socket = createConnection(socketPath);
@@ -1,37 +1,25 @@
1
- import { createHash } from "node:crypto";
2
1
  import { tmpdir } from "node:os";
3
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
- const LINUX_UNIX_SOCKET_PATH_BUDGET = 100;
5
- export function controllerSocketPath(home) {
2
+ import { join } from "node:path";
3
+ import { validateHomeId } from "../repository/homeIdentity.js";
4
+ export function controllerSocketPath(homeId) {
5
+ const identity = validateHomeId(homeId);
6
6
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
7
- const socketName = `${controllerSocketIdentity(home)}.sock`;
8
- const isolatedPath = join(tmpdir(), `yui-${uid}`, socketName);
9
- if (process.platform !== "linux"
10
- || Buffer.byteLength(isolatedPath) < LINUX_UNIX_SOCKET_PATH_BUDGET) {
11
- return isolatedPath;
12
- }
13
- // Linux limits Unix-socket paths to a small fixed budget. Managed Task
14
- // runtimes intentionally use isolated TMPDIR roots, which may themselves
15
- // be nested deeply enough to exhaust that budget. Keep the Home/uid/name
16
- // fence while using the compact system temporary root only for this
17
- // exceptional path; clients follow the Home-owned discovery record.
18
- return join("/tmp", `yui-${uid}`, socketName);
7
+ const socketName = `${identity}.sock`;
8
+ // Linux Unix-socket paths have a small fixed budget. A stable per-uid system
9
+ // root is both short and independent of each caller's isolated TMPDIR.
10
+ const root = process.platform === "linux" ? "/tmp" : tmpdir();
11
+ return join(root, `yui-${uid}`, socketName);
19
12
  }
20
13
  /**
21
- * A protected Home discovery record owns the Controller endpoint. Clients may
22
- * run with an isolated TMPDIR, so validate the published Home/uid identity
23
- * without recomputing the Controller process's temporary root.
14
+ * A protected Home discovery record owns the Controller endpoint. Linux uses
15
+ * one fixed root, so every caller validates the exact Home/uid endpoint
16
+ * independently of its own TMPDIR.
24
17
  */
25
- export function isControllerSocketPathForHome(home, candidate) {
26
- if (!isAbsolute(candidate) || resolve(candidate) !== candidate)
18
+ export function isControllerSocketPathForHome(homeId, candidate) {
19
+ try {
20
+ return candidate === controllerSocketPath(validateHomeId(homeId));
21
+ }
22
+ catch {
27
23
  return false;
28
- const uid = typeof process.getuid === "function" ? process.getuid() : 0;
29
- return basename(dirname(candidate)) === `yui-${uid}`
30
- && basename(candidate) === `${controllerSocketIdentity(home)}.sock`;
31
- }
32
- function controllerSocketIdentity(home) {
33
- return createHash("sha256")
34
- .update(resolve(home))
35
- .digest("hex")
36
- .slice(0, 24);
24
+ }
37
25
  }
@@ -0,0 +1,56 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
3
+ import { readHomeFilesystemId, validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
4
+ /** Exact live-process fence used only at Controller startup and v3 handoff. */
5
+ export function inspectLiveControllerProcess(pid, homeFilesystemId, expectedProcessStartIdentity) {
6
+ if (!Number.isSafeInteger(pid) || pid < 1 || pid === process.pid)
7
+ return undefined;
8
+ const expectedFilesystemId = validateHomeFilesystemId(homeFilesystemId);
9
+ try {
10
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
11
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
12
+ const processUid = /^Uid:\s+([0-9]+)/mu.exec(status)?.[1];
13
+ if (processUid === undefined || Number(processUid) !== uid)
14
+ return undefined;
15
+ const args = readFileSync(`/proc/${pid}/cmdline`)
16
+ .toString("utf8")
17
+ .split("\0")
18
+ .filter((argument) => argument.length > 0);
19
+ if (!args.some((argument) => /(?:^|\/)controllerMain\.js$/u.test(argument))) {
20
+ return undefined;
21
+ }
22
+ const environment = readFileSync(`/proc/${pid}/environ`)
23
+ .toString("utf8")
24
+ .split("\0");
25
+ const yuiHome = environment
26
+ .find((entry) => entry.startsWith("YUI_HOME="))
27
+ ?.slice("YUI_HOME=".length);
28
+ if (yuiHome === undefined || readHomeFilesystemId(yuiHome) !== expectedFilesystemId) {
29
+ return undefined;
30
+ }
31
+ const processStartIdentity = readLinuxProcessStartIdentity(pid);
32
+ if (processStartIdentity === undefined
33
+ || (expectedProcessStartIdentity !== undefined
34
+ && processStartIdentity !== expectedProcessStartIdentity))
35
+ return undefined;
36
+ return Object.freeze({ pid, processStartIdentity });
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ }
42
+ /** Finds an older/current Controller whose discovery record was lost. */
43
+ export function findLiveControllerProcessForHome(homeFilesystemId) {
44
+ validateHomeFilesystemId(homeFilesystemId);
45
+ if (process.platform !== "linux")
46
+ return undefined;
47
+ const entries = readdirSync("/proc", { encoding: "utf8" });
48
+ for (const entry of entries) {
49
+ if (!/^[1-9][0-9]*$/u.test(entry))
50
+ continue;
51
+ const match = inspectLiveControllerProcess(Number(entry), homeFilesystemId);
52
+ if (match !== undefined)
53
+ return match;
54
+ }
55
+ return undefined;
56
+ }
@@ -6,6 +6,10 @@ import { basename, dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_CONTROLLER_MESSAGE_BYTES, ControllerProtocolError, controllerFailure, encodeControllerResponse, isEmptyParams, parseControllerRequest } from "./protocol.js";
8
8
  import { controllerSocketPath } from "./controllerEndpoint.js";
9
+ import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
10
+ import { findLiveControllerProcessForHome } from "./controllerProcessIdentity.js";
11
+ import { validateHomeId } from "../repository/homeIdentity.js";
12
+ import { readCompatibleHomeIdentity } from "../storage/compatibleTaskStore.js";
9
13
  import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
10
14
  import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
11
15
  import { resolveTaskStoreBackendForHome } from "../storage/sqliteStore.js";
@@ -32,7 +36,14 @@ export async function startControllerServer(home, dispatcher, beforeDiscoveryRem
32
36
  }
33
37
  async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemoval, options = {}) {
34
38
  const discoveryPath = join(home, CONTROLLER_DISCOVERY_PATH);
35
- const socketPath = controllerSocketPath(home);
39
+ const homeId = validateHomeId(readCompatibleHomeIdentity(home).homeId);
40
+ const homeFilesystemId = readHomeFilesystemId(home);
41
+ await assertExistingDiscoveryReplaceable(discoveryPath);
42
+ if (findLiveControllerProcessForHome(homeFilesystemId) !== undefined) {
43
+ throw controllerAlreadyRunning();
44
+ }
45
+ const controllerInstanceId = randomBytes(16).toString("hex");
46
+ const socketPath = controllerSocketPath(homeId);
36
47
  const runtimeDirectory = dirname(discoveryPath);
37
48
  const socketDirectory = dirname(socketPath);
38
49
  await mkdir(runtimeDirectory, { recursive: true, mode: 0o700 });
@@ -66,7 +77,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
66
77
  // the live primary, so its startup identity is still truthful.
67
78
  let primaryIdentity = null;
68
79
  const netServer = createServer((socket) => {
69
- receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, () => primaryIdentity);
80
+ receiveRequest(socket, token, dispatcher, () => closeRunning(), options.status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, () => primaryIdentity);
70
81
  });
71
82
  try {
72
83
  await listen(netServer, socketPath);
@@ -91,6 +102,11 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
91
102
  await chmod(socketPath, 0o600);
92
103
  const processStartIdentity = await readLinuxProcessStartIdentity(process.pid);
93
104
  const discovery = Object.freeze({
105
+ schemaVersion: 1,
106
+ protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
107
+ homeId,
108
+ homeFilesystemId,
109
+ controllerInstanceId,
94
110
  pid: process.pid,
95
111
  processStartIdentity,
96
112
  socketPath,
@@ -125,7 +141,7 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
125
141
  eventLoopDelay.stop();
126
142
  await beforeDiscoveryRemoval?.();
127
143
  await closeNetServer(netServer);
128
- await removeOwnedDiscovery(discoveryPath, token);
144
+ await removeOwnedDiscovery(discoveryPath, token, controllerInstanceId);
129
145
  if (options.domainIdentity !== undefined) {
130
146
  // Keep the exact target fence while detached Role panes survive a
131
147
  // Controller stop/restart. The owning test teardown or an expired
@@ -156,6 +172,54 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
156
172
  throw error;
157
173
  }
158
174
  }
175
+ /**
176
+ * Fence an older Controller whose socket name was derived by another protocol.
177
+ * A dead or PID-reused owner is stale and may be replaced; an unverifiable
178
+ * live owner fails closed so an endpoint migration can never create dual
179
+ * writers for one durable Home.
180
+ */
181
+ async function assertExistingDiscoveryReplaceable(discoveryPath) {
182
+ let value;
183
+ try {
184
+ value = JSON.parse(await readFile(discoveryPath, "utf8"));
185
+ }
186
+ catch (error) {
187
+ if (isNodeError(error) && error.code === "ENOENT")
188
+ return;
189
+ throw unsafeExistingDiscovery(discoveryPath, error);
190
+ }
191
+ if (typeof value !== "object"
192
+ || value === null
193
+ || Array.isArray(value)
194
+ || !("pid" in value)
195
+ || !Number.isSafeInteger(value.pid)
196
+ || value.pid < 1) {
197
+ throw unsafeExistingDiscovery(discoveryPath);
198
+ }
199
+ const pid = value.pid;
200
+ if (!isProcessAlive(pid))
201
+ return;
202
+ if (!("processStartIdentity" in value)
203
+ || typeof value.processStartIdentity !== "string"
204
+ || !/^[0-9]{1,32}$/u.test(value.processStartIdentity)) {
205
+ throw unsafeExistingDiscovery(discoveryPath);
206
+ }
207
+ let currentIdentity;
208
+ try {
209
+ currentIdentity = await readLinuxProcessStartIdentity(pid);
210
+ }
211
+ catch (error) {
212
+ if (!isProcessAlive(pid))
213
+ return;
214
+ throw unsafeExistingDiscovery(discoveryPath, error);
215
+ }
216
+ if (currentIdentity === value.processStartIdentity) {
217
+ throw controllerAlreadyRunning();
218
+ }
219
+ }
220
+ function unsafeExistingDiscovery(discoveryPath, cause) {
221
+ return new Error(`Cannot safely replace existing Controller discovery: ${discoveryPath}.`, cause === undefined ? undefined : { cause });
222
+ }
159
223
  async function writeDiscoveryAtomically(discoveryPath, discovery) {
160
224
  const temporaryPath = `${discoveryPath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
161
225
  try {
@@ -171,7 +235,7 @@ async function writeDiscoveryAtomically(discoveryPath, discovery) {
171
235
  throw error;
172
236
  }
173
237
  }
174
- function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
238
+ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
175
239
  let buffer = Buffer.alloc(0);
176
240
  let complete = false;
177
241
  const fail = (code, message, id = "invalid") => {
@@ -200,7 +264,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
200
264
  return;
201
265
  }
202
266
  complete = true;
203
- void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity);
267
+ void routeRequest(socket, buffer.subarray(0, newline).toString("utf8"), token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity);
204
268
  });
205
269
  socket.on("end", () => {
206
270
  if (!complete)
@@ -208,7 +272,7 @@ function receiveRequest(socket, token, dispatcher, stop, status, telemetry, hand
208
272
  });
209
273
  socket.on("error", () => undefined);
210
274
  }
211
- async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, getPrimaryIdentity) {
275
+ async function routeRequest(socket, line, token, dispatcher, stop, status, telemetry, handover, home, homeId, homeFilesystemId, controllerInstanceId, getPrimaryIdentity) {
212
276
  let request;
213
277
  try {
214
278
  request = parseControllerRequest(line);
@@ -224,6 +288,16 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
224
288
  sendResponse(socket, controllerFailure(request.id, "UNAUTHORIZED", "Controller authentication failed."));
225
289
  return;
226
290
  }
291
+ if (request.protocolVersion !== FILE_TASK_CONTROLLER_PROTOCOL_VERSION) {
292
+ sendResponse(socket, controllerFailure(request.id, "CONTROLLER_PROTOCOL_MISMATCH", "Controller protocol version does not match the running Controller."));
293
+ return;
294
+ }
295
+ if (request.homeId !== homeId
296
+ || request.homeFilesystemId !== homeFilesystemId
297
+ || request.controllerInstanceId !== controllerInstanceId) {
298
+ sendResponse(socket, controllerFailure(request.id, "CONTROLLER_IDENTITY_MISMATCH", "Controller request identity does not match the running Controller."));
299
+ return;
300
+ }
227
301
  // Every authenticated request is observed once at the routing layer. The
228
302
  // observation completes when the response is handed to the socket, so a
229
303
  // later status snapshot sees prior built-in completions and the dispatcher
@@ -290,6 +364,9 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
290
364
  cliRealpath: controllerCliRealpath(),
291
365
  controllerRealpath: realpathSync(fileURLToPath(import.meta.url)),
292
366
  controllerProtocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
367
+ homeId,
368
+ homeFilesystemId,
369
+ controllerInstanceId,
293
370
  storageBackend: resolveTaskStoreBackendForHome(home, process.env),
294
371
  workerEnabled: resolveStoreWorkerEnabledForHome(home, process.env),
295
372
  mode: "primary",
@@ -322,6 +399,9 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
322
399
  uptimeMs: Math.round(process.uptime() * 1000),
323
400
  rssBytes: process.memoryUsage().rss,
324
401
  protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
402
+ homeId,
403
+ homeFilesystemId,
404
+ controllerInstanceId,
325
405
  version: YUI_VERSION,
326
406
  storageLayoutVersion: yuiVersionIdentity().storageLayoutVersion,
327
407
  aggregateSchemaVersion: yuiVersionIdentity().aggregateSchemaVersion,
@@ -688,13 +768,15 @@ function closeNetServer(server) {
688
768
  server.close((error) => error === undefined ? resolve() : reject(error));
689
769
  });
690
770
  }
691
- async function removeOwnedDiscovery(path, token) {
771
+ async function removeOwnedDiscovery(path, token, controllerInstanceId) {
692
772
  try {
693
773
  const value = JSON.parse(await readFile(path, "utf8"));
694
774
  if (typeof value === "object"
695
775
  && value !== null
696
776
  && "token" in value
697
- && value.token === token) {
777
+ && value.token === token
778
+ && "controllerInstanceId" in value
779
+ && value.controllerInstanceId === controllerInstanceId) {
698
780
  await rm(path, { force: true });
699
781
  }
700
782
  }
@@ -0,0 +1,24 @@
1
+ import { statSync } from "node:fs";
2
+ const HOME_FILESYSTEM_ID_PATTERN = /^[0-9]{1,32}:[0-9]{1,32}$/u;
3
+ /**
4
+ * Runtime-only identity of the physical Home directory.
5
+ *
6
+ * Durable `homeId` names the logical Home. Device + inode distinguishes an
7
+ * accidental filesystem copy while allowing symlink aliases of the same
8
+ * directory to share one Controller. It is deliberately not persisted in the
9
+ * Home aggregate because replacing or copying the directory must change this
10
+ * runtime fence.
11
+ */
12
+ export function readHomeFilesystemId(home) {
13
+ const metadata = statSync(home, { bigint: true });
14
+ if (!metadata.isDirectory()) {
15
+ throw new Error("YUI_HOME is not a directory.");
16
+ }
17
+ return `${metadata.dev}:${metadata.ino}`;
18
+ }
19
+ export function validateHomeFilesystemId(value) {
20
+ if (typeof value !== "string" || !HOME_FILESYSTEM_ID_PATTERN.test(value)) {
21
+ throw new Error("Home filesystem identity is invalid.");
22
+ }
23
+ return value;
24
+ }
@@ -1,7 +1,9 @@
1
+ import { validateHomeId } from "../repository/homeIdentity.js";
2
+ import { validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
1
3
  export const MAX_CONTROLLER_MESSAGE_BYTES = 1_048_576;
2
4
  export const CONTROLLER_DISCOVERY_PATH = "runtime/controller.json";
3
5
  /** Bump when a running Controller cannot safely share one YUI_HOME with this CLI. */
4
- export const FILE_TASK_CONTROLLER_PROTOCOL_VERSION = 3;
6
+ export const FILE_TASK_CONTROLLER_PROTOCOL_VERSION = 4;
5
7
  export class ControllerProtocolError extends Error {
6
8
  code;
7
9
  id;
@@ -18,12 +20,27 @@ export function parseControllerRequest(line) {
18
20
  }
19
21
  try {
20
22
  const value = JSON.parse(line);
21
- if (!isRecord(value) || !hasExactKeys(value, ["id", "token", "method", "params"])) {
23
+ if (!isRecord(value) || !hasExactKeys(value, [
24
+ "id",
25
+ "token",
26
+ "protocolVersion",
27
+ "homeId",
28
+ "homeFilesystemId",
29
+ "controllerInstanceId",
30
+ "method",
31
+ "params"
32
+ ])) {
22
33
  throw new Error("shape");
23
34
  }
24
35
  if (!isIdentifier(value.id)
25
36
  || typeof value.token !== "string"
26
37
  || !/^[a-f0-9]{64}$/u.test(value.token)
38
+ || !Number.isSafeInteger(value.protocolVersion)
39
+ || value.protocolVersion < 1
40
+ || !isHomeId(value.homeId)
41
+ || !isHomeFilesystemId(value.homeFilesystemId)
42
+ || typeof value.controllerInstanceId !== "string"
43
+ || !/^[a-f0-9]{32}$/u.test(value.controllerInstanceId)
27
44
  || typeof value.method !== "string"
28
45
  || value.method.length > 128
29
46
  || !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/u.test(value.method)
@@ -33,6 +50,10 @@ export function parseControllerRequest(line) {
33
50
  return Object.freeze({
34
51
  id: value.id,
35
52
  token: value.token,
53
+ protocolVersion: value.protocolVersion,
54
+ homeId: value.homeId,
55
+ homeFilesystemId: value.homeFilesystemId,
56
+ controllerInstanceId: value.controllerInstanceId,
36
57
  method: value.method,
37
58
  params: value.params
38
59
  });
@@ -96,19 +117,41 @@ export function controllerFailure(id, code, message) {
96
117
  error: Object.freeze({ code, message })
97
118
  });
98
119
  }
99
- export function parseControllerDiscovery(value, expectedSocketPath) {
120
+ export function parseControllerDiscovery(value, expected) {
100
121
  if (!isRecord(value)
101
- || !hasExactKeys(value, ["pid", "processStartIdentity", "socketPath", "token"])
122
+ || !hasExactKeys(value, [
123
+ "schemaVersion",
124
+ "protocolVersion",
125
+ "homeId",
126
+ "homeFilesystemId",
127
+ "controllerInstanceId",
128
+ "pid",
129
+ "processStartIdentity",
130
+ "socketPath",
131
+ "token"
132
+ ])
133
+ || value.schemaVersion !== 1
134
+ || value.protocolVersion !== FILE_TASK_CONTROLLER_PROTOCOL_VERSION
135
+ || !isHomeId(value.homeId)
136
+ || value.homeFilesystemId !== expected.homeFilesystemId
137
+ || !isHomeFilesystemId(value.homeFilesystemId)
138
+ || typeof value.controllerInstanceId !== "string"
139
+ || !/^[a-f0-9]{32}$/u.test(value.controllerInstanceId)
102
140
  || !Number.isSafeInteger(value.pid)
103
141
  || value.pid < 1
104
142
  || typeof value.processStartIdentity !== "string"
105
143
  || !/^[0-9]{1,32}$/u.test(value.processStartIdentity)
106
- || value.socketPath !== expectedSocketPath
144
+ || value.socketPath !== expected.socketPath
107
145
  || typeof value.token !== "string"
108
146
  || !/^[a-f0-9]{64}$/u.test(value.token)) {
109
147
  throw new ControllerProtocolError("CONTROLLER_DISCOVERY_INVALID", "Controller discovery is invalid.");
110
148
  }
111
149
  return Object.freeze({
150
+ schemaVersion: 1,
151
+ protocolVersion: value.protocolVersion,
152
+ homeId: value.homeId,
153
+ homeFilesystemId: value.homeFilesystemId,
154
+ controllerInstanceId: value.controllerInstanceId,
112
155
  pid: value.pid,
113
156
  processStartIdentity: value.processStartIdentity,
114
157
  socketPath: value.socketPath,
@@ -131,6 +174,26 @@ function isIdentifier(value) {
131
174
  return typeof value === "string"
132
175
  && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value);
133
176
  }
177
+ function isHomeId(value) {
178
+ if (typeof value !== "string")
179
+ return false;
180
+ try {
181
+ validateHomeId(value);
182
+ return true;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ function isHomeFilesystemId(value) {
189
+ try {
190
+ validateHomeFilesystemId(value);
191
+ return true;
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ }
134
197
  function isRecord(value) {
135
198
  return typeof value === "object" && value !== null && !Array.isArray(value);
136
199
  }
@@ -59,7 +59,7 @@ export class GitIntegrationService {
59
59
  environment;
60
60
  runtimeIsolation;
61
61
  #resourceRegistrarValue;
62
- constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), environment = process.env, runtimeIsolation = defaultIntegrationRuntimeIsolation(home), jobPort) {
62
+ constructor(home, store, git = new NodeGitWorkspace(), now = () => new Date(), environment = process.env, runtimeIsolation = defaultIntegrationRuntimeIsolation(home, store.getHomeIdentity().homeId), jobPort) {
63
63
  this.store = store;
64
64
  this.git = git;
65
65
  this.now = now;
@@ -883,14 +883,14 @@ function isNodeCode(error, code) {
883
883
  && "code" in error
884
884
  && error.code === code;
885
885
  }
886
- function defaultIntegrationRuntimeIsolation(home) {
886
+ function defaultIntegrationRuntimeIsolation(home, homeId) {
887
887
  const controlHome = resolve(home);
888
888
  return new FileTaskRuntimeIsolation({
889
889
  runtimeRoot: integrationRuntimeRoot(),
890
890
  pathLayout: "compact",
891
891
  controlPlane: {
892
892
  yuiHome: controlHome,
893
- controllerSocketPath: controllerSocketPath(controlHome),
893
+ controllerSocketPath: controllerSocketPath(homeId),
894
894
  tmuxNamespace: yuiTmuxServerName(controlHome),
895
895
  globalInstallPaths: [process.execPath]
896
896
  }
@@ -1,6 +1,12 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  export const HOME_IDENTITY_PATTERN = /^home-[a-f0-9]{16}$/;
3
3
  const HOME_ENTROPY_BYTES = 16;
4
+ export function validateHomeId(homeId) {
5
+ if (typeof homeId !== "string" || !HOME_IDENTITY_PATTERN.test(homeId)) {
6
+ throw new Error("Home identity id is invalid.");
7
+ }
8
+ return homeId;
9
+ }
4
10
  export function generateHomeIdentity(now, source = () => randomBytes(HOME_ENTROPY_BYTES)) {
5
11
  const entropy = source().toString("hex");
6
12
  const homeId = `home-${randomBytes(8).toString("hex")}`;
@@ -15,9 +21,7 @@ export function validateHomeIdentity(identity) {
15
21
  if (identity.schemaVersion !== 1) {
16
22
  throw new Error("Home identity must use schemaVersion 1.");
17
23
  }
18
- if (typeof identity.homeId !== "string" || !HOME_IDENTITY_PATTERN.test(identity.homeId)) {
19
- throw new Error("Home identity id is invalid.");
20
- }
24
+ validateHomeId(identity.homeId);
21
25
  if (typeof identity.entropy !== "string" || !/^[a-f0-9]{32}$/.test(identity.entropy)) {
22
26
  throw new Error("Home identity entropy is invalid.");
23
27
  }
@@ -12,6 +12,9 @@ import { join, resolve } from "node:path";
12
12
  import { promisify } from "node:util";
13
13
  import { parseExactTaskRuntimeDescriptor } from "../runtime/exactControlPlane.js";
14
14
  import { readRuntimeIdentity, releasesDirectory, resolveActiveRelease } from "../release/runtimeRelease.js";
15
+ import { isControllerSocketPathForHome } from "../core/controllerEndpoint.js";
16
+ import { parseControllerDiscovery } from "../core/protocol.js";
17
+ import { readHomeFilesystemId } from "../core/homeFilesystemIdentity.js";
15
18
  const executeFile = promisify(execFile);
16
19
  /**
17
20
  * Collect every live reference for the given paths. The scan is fail-closed:
@@ -481,7 +484,11 @@ export function readControllerDiscovery(home) {
481
484
  }
482
485
  let record;
483
486
  try {
484
- record = JSON.parse(readFileSync(path, "utf8"));
487
+ const value = JSON.parse(readFileSync(path, "utf8"));
488
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
489
+ throw new Error("controller.json is not an object");
490
+ }
491
+ record = value;
485
492
  }
486
493
  catch (error) {
487
494
  return Object.freeze({
@@ -537,9 +544,35 @@ export function readControllerDiscovery(home) {
537
544
  }
538
545
  });
539
546
  }
547
+ let discovery;
548
+ try {
549
+ const homeId = record.homeId;
550
+ const socketPath = record.socketPath;
551
+ if (typeof homeId !== "string"
552
+ || typeof socketPath !== "string"
553
+ || !isControllerSocketPathForHome(homeId, socketPath)) {
554
+ throw new Error("Controller endpoint identity is invalid.");
555
+ }
556
+ discovery = parseControllerDiscovery(record, {
557
+ homeFilesystemId: readHomeFilesystemId(home),
558
+ socketPath
559
+ });
560
+ }
561
+ catch (error) {
562
+ return Object.freeze({
563
+ protects: false,
564
+ token: "",
565
+ protectedPaths: Object.freeze([]),
566
+ diagnostic: {
567
+ source: "controller",
568
+ severity: "error",
569
+ message: `controller.json identity is invalid: ${error instanceof Error ? error.message : "unknown error"}`
570
+ }
571
+ });
572
+ }
540
573
  return Object.freeze({
541
574
  protects: true,
542
- token: `controller:${record.pid}`,
575
+ token: `controller:${discovery.homeId}:${discovery.controllerInstanceId}:${record.pid}`,
543
576
  protectedPaths: Object.freeze([path])
544
577
  });
545
578
  }
@@ -3,8 +3,9 @@ import { join } from "node:path";
3
3
  import { StorageCompatibilityError, loadCompatibleSnapshot } from "./migration/index.js";
4
4
  import { createProductionStorageRegistry } from "./migration/productionRegistry.js";
5
5
  import { FileTaskStore, STORAGE_STATE_FILE, stateFileFingerprint, StorageRecordError, validateCurrentStorageStateSnapshot } from "./taskStore.js";
6
- import { SqliteTaskStore } from "./sqliteStore.js";
6
+ import { readSqliteHomeIdentity, SqliteTaskStore } from "./sqliteStore.js";
7
7
  import { COMMITTED_DATABASE_FILENAME } from "./upgrade/sqliteStateMigration.js";
8
+ import { validateHomeIdentity } from "../repository/homeIdentity.js";
8
9
  import { ensureStorageSchema, inspectStorageSchema, readStorageSchemaManifest, STORAGE_SCHEMA_FILE } from "./storageSchema.js";
9
10
  import { classifyHome } from "./upgrade/homeClassification.js";
10
11
  import { inspectSnapshotVersionState } from "./upgrade/homeMigrationTarget.js";
@@ -81,6 +82,40 @@ export function openCompatibleFileTaskStore(home, options = {}) {
81
82
  throw new StorageCompatibilityError(describeUnsupported(classification.classification));
82
83
  }
83
84
  }
85
+ /**
86
+ * Read the one authoritative durable Home identity without creating runtime
87
+ * state. SQLite is opened read-only; file-backed Homes read only the immutable
88
+ * identity field, so an aggregate that requires offline migration can still
89
+ * authenticate its already-running Controller before the migration stops it.
90
+ */
91
+ export function readCompatibleHomeIdentity(home, options = {}) {
92
+ const schema = inspectStorageSchema(home);
93
+ if (!options.forceStateSource
94
+ && (schema.status === "current" || schema.status === "unsupported")
95
+ && schema.currentLayoutVersion >= 7
96
+ && existsSync(join(home, COMMITTED_DATABASE_FILENAME))) {
97
+ return readSqliteHomeIdentity(home, COMMITTED_DATABASE_FILENAME);
98
+ }
99
+ if (!existsSync(join(home, STORAGE_STATE_FILE))) {
100
+ throw new StorageRecordError("Durable Home identity is missing.");
101
+ }
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(readFileSync(join(home, STORAGE_STATE_FILE), "utf8"));
105
+ }
106
+ catch {
107
+ throw new StorageRecordError("Durable Home identity state is invalid JSON.");
108
+ }
109
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
110
+ throw new StorageRecordError("Durable Home identity state is invalid.");
111
+ }
112
+ try {
113
+ return validateHomeIdentity(parsed.homeIdentity);
114
+ }
115
+ catch {
116
+ throw new StorageRecordError("Durable Home identity is invalid.");
117
+ }
118
+ }
84
119
  /**
85
120
  * The one-snapshot open for a Home whose manifest is already current. A single
86
121
  * fingerprint-fenced read of `state.json` supplies:
@@ -43,7 +43,7 @@ import { mailboxTargetKey } from "../coordination/workMailbox.js";
43
43
  import { validatePendingTurnCompletion } from "../executor/turnCompletion.js";
44
44
  import { compareRuntimeSessionCandidates, projectRuntimeSessionCandidate } from "../runtime/runtimeSessionCandidate.js";
45
45
  import { validateReviewFinding } from "../review/reviewFinding.js";
46
- import { generateHomeIdentity } from "../repository/homeIdentity.js";
46
+ import { generateHomeIdentity, validateHomeIdentity } from "../repository/homeIdentity.js";
47
47
  import { validateIntegrationQueueEntry } from "../integration/integrationQueueEntry.js";
48
48
  import { validDurableJobTransition, validateDurableJob } from "../job/durableJob.js";
49
49
  import { TASK_RECORD_ID_PREFIXES } from "../task/taskRecordReference.js";
@@ -53,6 +53,30 @@ import { CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, C
53
53
  import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
54
54
  import { migrateSqliteSchema, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
55
55
  import { inspectStorageSchema } from "./storageSchema.js";
56
+ /** Read the immutable Home identity without opening a writable Store connection. */
57
+ export function readSqliteHomeIdentity(rootDir, databaseFilename = "yui.db") {
58
+ const database = new Database(join(rootDir, databaseFilename), {
59
+ readonly: true,
60
+ fileMustExist: true
61
+ });
62
+ try {
63
+ const row = database.prepare("SELECT home_identity FROM home_meta WHERE id = 1").get();
64
+ if (typeof row?.home_identity !== "string") {
65
+ throw new StorageRecordError("SQLite Home identity is missing.");
66
+ }
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(row.home_identity);
70
+ }
71
+ catch {
72
+ throw new StorageRecordError("SQLite Home identity is invalid JSON.");
73
+ }
74
+ return validateHomeIdentity(parsed);
75
+ }
76
+ finally {
77
+ database.close();
78
+ }
79
+ }
56
80
  const DEFAULT_CONFIG = { schemaVersion: CURRENT_CONFIG_SCHEMA_VERSION };
57
81
  function numericCompare(left, right) {
58
82
  return left.localeCompare(right, undefined, { numeric: true });
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, resolve } from "node:path";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
4
  import { isDeepStrictEqual } from "node:util";
5
5
  import { validateConfiguredAgent } from "../agent/agent.js";
6
6
  import { validateCapabilityGrant } from "../grant/capabilityGrant.js";
@@ -1735,11 +1735,56 @@ export class StorageCancelledError extends Error {
1735
1735
  constructor(message) { super(message); this.name = "StorageCancelledError"; }
1736
1736
  }
1737
1737
  export function resolveYuiHome(env) {
1738
- return env.YUI_HOME === undefined || env.YUI_HOME.length === 0
1738
+ const requested = env.YUI_HOME === undefined || env.YUI_HOME.length === 0
1739
1739
  ? join(homedir(), ".yui")
1740
1740
  : resolve(env.YUI_HOME);
1741
+ return canonicalizeYuiHome(requested);
1741
1742
  }
1742
1743
  export function ensureYuiHome(rootDir) { mkdirSync(rootDir, { recursive: true, mode: 0o700 }); }
1744
+ /**
1745
+ * YUI_HOME is a runtime identity, not only a storage path. Resolve every
1746
+ * existing symlink component before Controller/tmux namespaces or exact
1747
+ * descriptors derive identity from it. A Home may not exist before `setup`,
1748
+ * so retain proven-missing trailing components below the longest existing
1749
+ * physical ancestor. Existing-but-unresolvable paths fail closed.
1750
+ */
1751
+ function canonicalizeYuiHome(value) {
1752
+ const absolute = resolve(value);
1753
+ let current = absolute;
1754
+ const trailing = [];
1755
+ for (;;) {
1756
+ try {
1757
+ const physical = realpathSync(current);
1758
+ return trailing.length === 0 ? physical : join(physical, ...trailing);
1759
+ }
1760
+ catch (realpathError) {
1761
+ if (!isErrno(realpathError, "ENOENT")) {
1762
+ throw unstableYuiHome(absolute, realpathError);
1763
+ }
1764
+ try {
1765
+ lstatSync(current);
1766
+ }
1767
+ catch (lstatError) {
1768
+ if (!isErrno(lstatError, "ENOENT")) {
1769
+ throw unstableYuiHome(absolute, lstatError);
1770
+ }
1771
+ const parent = dirname(current);
1772
+ if (parent === current)
1773
+ return absolute;
1774
+ trailing.unshift(basename(current));
1775
+ current = parent;
1776
+ continue;
1777
+ }
1778
+ throw unstableYuiHome(absolute, realpathError);
1779
+ }
1780
+ }
1781
+ }
1782
+ function unstableYuiHome(path, cause) {
1783
+ return new Error(`YUI_HOME cannot be resolved to a stable physical path: ${path}.`, { cause });
1784
+ }
1785
+ function isErrno(error, code) {
1786
+ return error instanceof Error && "code" in error && error.code === code;
1787
+ }
1743
1788
  function emptyState() {
1744
1789
  return {
1745
1790
  schemaVersion: CURRENT_STORAGE_STATE_SCHEMA_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,