@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 +8 -4
- package/dist/controller/clientRuntime.js +26 -3
- package/dist/controller/resourceCleanupLinux.js +2 -1
- package/dist/controller/resourceInventory.js +4 -2
- package/dist/controller/resourceInventoryLinux.js +62 -5
- package/dist/controller/runtime.js +2 -1
- package/dist/core/controllerClient.js +141 -7
- package/dist/core/controllerEndpoint.js +18 -30
- package/dist/core/controllerProcessIdentity.js +56 -0
- package/dist/core/controllerServer.js +90 -8
- package/dist/core/homeFilesystemIdentity.js +24 -0
- package/dist/core/protocol.js +68 -5
- package/dist/integration/gitIntegrationService.js +3 -3
- package/dist/repository/homeIdentity.js +7 -3
- package/dist/resources/liveReferences.js +35 -2
- package/dist/storage/compatibleTaskStore.js +36 -1
- package/dist/storage/sqliteStore.js +25 -1
- package/dist/storage/taskStore.js +48 -3
- package/package.json +1 -1
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
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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(
|
|
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 =
|
|
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 (
|
|
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 {
|
|
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(
|
|
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(
|
|
28
|
-
return parseControllerDiscovery(value,
|
|
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(
|
|
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
|
|
45
|
-
||
|
|
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 {
|
|
4
|
-
|
|
5
|
-
export function controllerSocketPath(
|
|
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 = `${
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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(
|
|
26
|
-
|
|
18
|
+
export function isControllerSocketPathForHome(homeId, candidate) {
|
|
19
|
+
try {
|
|
20
|
+
return candidate === controllerSocketPath(validateHomeId(homeId));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
27
23
|
return false;
|
|
28
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/core/protocol.js
CHANGED
|
@@ -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 =
|
|
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, [
|
|
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,
|
|
120
|
+
export function parseControllerDiscovery(value, expected) {
|
|
100
121
|
if (!isRecord(value)
|
|
101
|
-
|| !hasExactKeys(value, [
|
|
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 !==
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|