@zq-silk/yui 1.0.1 → 1.1.2
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 +27 -9
- package/dist/cli/commandCatalog.js +7 -5
- package/dist/cli/invocationAuthority.js +4 -0
- package/dist/cli.js +130 -5
- package/dist/commands/releaseCommands.js +33 -24
- package/dist/commands/taskCommands.js +16 -25
- package/dist/commands/taskFactCommands.js +6 -2
- package/dist/controller/clientRuntime.js +7 -2
- package/dist/controller/domainIdentity.js +7 -7
- package/dist/controller/handoverCandidate.js +2 -5
- package/dist/controller/jobSupervisor.js +4 -4
- package/dist/controller/resourceCleanupLinux.js +35 -19
- package/dist/controller/resourceInventoryLinux.js +104 -7
- package/dist/controller/runtime.js +4 -13
- package/dist/controller/runtimeLaunchCoordinator.js +2 -4
- package/dist/controller/sessionOwnerReconciliation.js +40 -16
- package/dist/core/controllerClient.js +44 -16
- package/dist/core/controllerEndpoint.js +4 -3
- package/dist/core/controllerProcessIdentity.js +54 -0
- package/dist/core/controllerServer.js +16 -11
- package/dist/core/fileLockOwner.js +53 -12
- package/dist/doctor/doctor.js +232 -12
- package/dist/doctor/ptyProbe.js +74 -0
- package/dist/doctor/ptyProbeChild.js +90 -0
- package/dist/execution/workItemMainRun.js +1 -1
- package/dist/executor/agentAdapter.js +0 -8
- package/dist/executor/agentExecutor.js +4 -1
- package/dist/executor/fileRoleLaunchPlanner.js +3 -22
- package/dist/message/messageContinuation.js +1 -1
- package/dist/release/releaseHandover.js +77 -25
- package/dist/release/runtimeRelease.js +7 -7
- package/dist/repository/gitWorkspace.js +9 -8
- package/dist/repository/taskWorkspacePreparer.js +15 -57
- package/dist/resources/liveReferences.js +64 -7
- package/dist/runtime/agentEndpointIdentity.js +4 -1
- package/dist/runtime/agentHost.js +8 -9
- package/dist/runtime/codexInteractiveHost.js +1 -1
- package/dist/runtime/codexSharedDaemon.js +211 -0
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/jsonLineChannel.js +6 -1
- package/dist/runtime/localStartup.js +65 -0
- package/dist/runtime/native/darwin-arm64/claude-process-owner +0 -0
- package/dist/runtime/native/darwin-arm64/process-identity +0 -0
- package/dist/runtime/native/darwin-x64/claude-process-owner +0 -0
- package/dist/runtime/native/darwin-x64/process-identity +0 -0
- package/dist/runtime/{claude-process-owner → native/linux-x64/claude-process-owner} +0 -0
- package/dist/runtime/nativeExecutable.js +10 -0
- package/dist/runtime/runtimeCoherence.js +10 -3
- package/dist/runtime/sessionOwnerIdentity.js +19 -0
- package/dist/runtime/sessionTerminationGuard.js +10 -3
- package/dist/runtime/structuredProviderHost.js +51 -26
- package/dist/scheduler/activeRoleRunDelivery.js +2 -11
- package/dist/scheduler/leaderWakeupProcessor.js +1 -2
- package/dist/scheduler/ports.js +1 -8
- package/dist/scheduler/taskExecutionProjection.js +1 -6
- package/dist/storage/sqliteSchema.js +15 -3
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/taskCatalog.js +10 -2
- package/dist/storage/upgrade/taskMainWorkspaceMigration.js +83 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +25 -6
- package/dist/task/task.js +0 -12
- package/dist/tmux/tmuxManager.js +8 -2
- package/dist/tmux/tmuxSocketEndpoint.js +23 -1
- package/dist/web/assets/client/app.js +131 -11
- package/dist/web/assets/client/i18n.js +18 -0
- package/dist/web/assets/client/taskSummary.js +7 -2
- package/dist/web/assets/client/taskSurface.js +54 -5
- package/dist/web/assets/client/view.js +7 -2
- package/dist/web/assets/shell.js +21 -11
- package/dist/web/assets/styles/layout.js +33 -6
- package/dist/web/assets/styles/responsive.js +18 -5
- package/dist/web/assets/styles/tokens.js +1 -1
- package/dist/web/assets/styles/widgets.js +5 -0
- package/docs/provider-runtime.md +7 -0
- package/docs/provider-runtime.zh-CN.md +5 -0
- package/docs/release-workflow.md +7 -1
- package/docs/task-delivery.md +5 -4
- package/docs/testing/verification-levels.md +19 -7
- package/docs/testing/verification-levels.zh-CN.md +8 -0
- package/i18n/README.zh-CN.md +19 -6
- package/package.json +7 -8
- package/skills/yui-leader/SKILL.md +8 -0
- package/skills/yui-leader/references/execution.md +4 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { nativeExecutable } from "../runtime/nativeExecutable.js";
|
|
2
4
|
import { readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
|
|
3
5
|
import { readHomeFilesystemId, validateHomeFilesystemId } from "./homeFilesystemIdentity.js";
|
|
4
6
|
/** Exact live-process fence used at Controller startup and handover. */
|
|
@@ -6,6 +8,22 @@ export function inspectLiveControllerProcess(pid, homeFilesystemId, expectedProc
|
|
|
6
8
|
if (!Number.isSafeInteger(pid) || pid < 1 || pid === process.pid)
|
|
7
9
|
return undefined;
|
|
8
10
|
const expectedFilesystemId = validateHomeFilesystemId(homeFilesystemId);
|
|
11
|
+
if (process.platform === "darwin") {
|
|
12
|
+
const candidate = inspectDarwinController(pid);
|
|
13
|
+
if (candidate === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
try {
|
|
16
|
+
if (readHomeFilesystemId(candidate.home) !== expectedFilesystemId)
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
if (expectedProcessStartIdentity !== undefined
|
|
23
|
+
&& candidate.processStartIdentity !== expectedProcessStartIdentity)
|
|
24
|
+
return undefined;
|
|
25
|
+
return Object.freeze({ pid, processStartIdentity: candidate.processStartIdentity });
|
|
26
|
+
}
|
|
9
27
|
try {
|
|
10
28
|
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
11
29
|
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
|
@@ -42,6 +60,20 @@ export function inspectLiveControllerProcess(pid, homeFilesystemId, expectedProc
|
|
|
42
60
|
/** Finds a Controller whose discovery record was lost. */
|
|
43
61
|
export function findLiveControllerProcessForHome(homeFilesystemId) {
|
|
44
62
|
validateHomeFilesystemId(homeFilesystemId);
|
|
63
|
+
if (process.platform === "darwin") {
|
|
64
|
+
const output = runDarwinIdentityHelper(["--controllers"]);
|
|
65
|
+
if (output === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
for (const line of output.trim().split("\n")) {
|
|
68
|
+
const candidate = parseDarwinController(line);
|
|
69
|
+
if (candidate === undefined)
|
|
70
|
+
continue;
|
|
71
|
+
const match = inspectLiveControllerProcess(candidate.pid, homeFilesystemId, candidate.processStartIdentity);
|
|
72
|
+
if (match !== undefined)
|
|
73
|
+
return match;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
45
77
|
if (process.platform !== "linux")
|
|
46
78
|
return undefined;
|
|
47
79
|
const entries = readdirSync("/proc", { encoding: "utf8" });
|
|
@@ -54,3 +86,25 @@ export function findLiveControllerProcessForHome(homeFilesystemId) {
|
|
|
54
86
|
}
|
|
55
87
|
return undefined;
|
|
56
88
|
}
|
|
89
|
+
function inspectDarwinController(pid) {
|
|
90
|
+
const output = runDarwinIdentityHelper(["--controller", String(pid)]);
|
|
91
|
+
return output === undefined ? undefined : parseDarwinController(output.trim());
|
|
92
|
+
}
|
|
93
|
+
function parseDarwinController(line) {
|
|
94
|
+
const match = /^([1-9][0-9]*)\t([1-9][0-9]{0,31})\t([^\t\n]+)$/u.exec(line);
|
|
95
|
+
if (match === null)
|
|
96
|
+
return undefined;
|
|
97
|
+
const pid = Number(match[1]);
|
|
98
|
+
if (!Number.isSafeInteger(pid))
|
|
99
|
+
return undefined;
|
|
100
|
+
return { pid, processStartIdentity: match[2], home: match[3] };
|
|
101
|
+
}
|
|
102
|
+
function runDarwinIdentityHelper(args) {
|
|
103
|
+
try {
|
|
104
|
+
return execFileSync(nativeExecutable("process-identity"), [...args], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024,
|
|
105
|
+
stdio: ["ignore", "pipe", "ignore"] });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -8,6 +8,7 @@ import { CONTROLLER_DISCOVERY_PATH, FILE_TASK_CONTROLLER_PROTOCOL_VERSION, MAX_C
|
|
|
8
8
|
import { controllerSocketPath } from "./controllerEndpoint.js";
|
|
9
9
|
import { readHomeFilesystemId } from "./homeFilesystemIdentity.js";
|
|
10
10
|
import { findLiveControllerProcessForHome } from "./controllerProcessIdentity.js";
|
|
11
|
+
import { currentProcessStartIdentity, processGenerationIsLive } from "./fileLockOwner.js";
|
|
11
12
|
import { validateHomeId } from "../repository/homeIdentity.js";
|
|
12
13
|
import { readCurrentHomeIdentity } from "../storage/currentTaskStore.js";
|
|
13
14
|
import { YUI_VERSION, yuiVersionIdentity } from "../version.js";
|
|
@@ -98,7 +99,9 @@ async function startControllerServerLocked(home, dispatcher, beforeDiscoveryRemo
|
|
|
98
99
|
eventLoopDelay.start();
|
|
99
100
|
try {
|
|
100
101
|
await chmod(socketPath, 0o600);
|
|
101
|
-
const processStartIdentity =
|
|
102
|
+
const processStartIdentity = process.platform === "linux"
|
|
103
|
+
? await readLinuxProcessStartIdentity(process.pid)
|
|
104
|
+
: currentProcessStartIdentity();
|
|
102
105
|
const discovery = Object.freeze({
|
|
103
106
|
schemaVersion: 1,
|
|
104
107
|
protocolVersion: FILE_TASK_CONTROLLER_PROTOCOL_VERSION,
|
|
@@ -202,6 +205,14 @@ async function assertExistingDiscoveryReplaceable(discoveryPath) {
|
|
|
202
205
|
|| !/^[0-9]{1,32}$/u.test(value.processStartIdentity)) {
|
|
203
206
|
throw unsafeExistingDiscovery(discoveryPath);
|
|
204
207
|
}
|
|
208
|
+
if (process.platform !== "linux") {
|
|
209
|
+
// The native helper proves the exact macOS generation; a live PID with
|
|
210
|
+
// unreadable identity cannot authorize discovery replacement.
|
|
211
|
+
if (processGenerationIsLive(pid, value.processStartIdentity)) {
|
|
212
|
+
throw controllerAlreadyRunning();
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
205
216
|
let currentIdentity;
|
|
206
217
|
try {
|
|
207
218
|
currentIdentity = await readLinuxProcessStartIdentity(pid);
|
|
@@ -340,15 +351,7 @@ async function routeRequest(socket, line, token, dispatcher, stop, status, telem
|
|
|
340
351
|
releaseDrifted = true;
|
|
341
352
|
}
|
|
342
353
|
}
|
|
343
|
-
|
|
344
|
-
try {
|
|
345
|
-
processStartIdentity = readLinuxProcessStartIdentitySync(process.pid);
|
|
346
|
-
}
|
|
347
|
-
catch {
|
|
348
|
-
// Non-Linux or an unreadable /proc: the owner cannot be fenced, so the
|
|
349
|
-
// handover path treats it as absent. The rest of the identity stays
|
|
350
|
-
// available for read-only consumers.
|
|
351
|
-
}
|
|
354
|
+
const processStartIdentity = currentProcessStartIdentity();
|
|
352
355
|
send({
|
|
353
356
|
id: request.id,
|
|
354
357
|
ok: true,
|
|
@@ -621,7 +624,9 @@ function currentHandoverOwner() {
|
|
|
621
624
|
const release = detectRunningRelease(fileURLToPath(import.meta.url));
|
|
622
625
|
return Object.freeze({
|
|
623
626
|
pid: process.pid,
|
|
624
|
-
processStartIdentity:
|
|
627
|
+
processStartIdentity: process.platform === "linux"
|
|
628
|
+
? readLinuxProcessStartIdentitySync(process.pid)
|
|
629
|
+
: currentProcessStartIdentity(),
|
|
625
630
|
buildId: release?.manifest.buildId ?? "dev",
|
|
626
631
|
version: YUI_VERSION
|
|
627
632
|
});
|
|
@@ -1,8 +1,41 @@
|
|
|
1
1
|
import { lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { join } from "node:path";
|
|
4
|
+
import { nativeExecutable } from "../runtime/nativeExecutable.js";
|
|
3
5
|
/** One process generation, shared by file-lock producers and reclaim checks. */
|
|
4
6
|
export function currentFileLockOwner() {
|
|
5
|
-
return `${process.pid}:${
|
|
7
|
+
return `${process.pid}:${currentProcessStartIdentity()}`;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Identity of the current process. Linux reads the /proc start-time field,
|
|
11
|
+
* which survives PID reuse. macOS uses libproc's process start timestamp.
|
|
12
|
+
*/
|
|
13
|
+
export function currentProcessStartIdentity() {
|
|
14
|
+
const identity = readProcessStartIdentity(process.pid);
|
|
15
|
+
if (identity === undefined)
|
|
16
|
+
throw new Error(`Process start identity is unavailable for PID ${process.pid}.`);
|
|
17
|
+
return identity;
|
|
18
|
+
}
|
|
19
|
+
export function readProcessStartIdentity(pid) {
|
|
20
|
+
if (!Number.isSafeInteger(pid) || pid < 1)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (process.platform === "linux") {
|
|
23
|
+
try {
|
|
24
|
+
return readProcessStart(pid);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (process.platform !== "darwin")
|
|
31
|
+
return undefined;
|
|
32
|
+
try {
|
|
33
|
+
const identity = execFileSync(nativeExecutable("process-identity"), [String(pid)], { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
34
|
+
return /^(0|[1-9][0-9]{0,31})$/u.test(identity) ? identity : undefined;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
6
39
|
}
|
|
7
40
|
/**
|
|
8
41
|
* False requires proof of absence or a different process generation.
|
|
@@ -42,20 +75,28 @@ export function processGenerationIsLive(pid, startIdentity) {
|
|
|
42
75
|
if (!Number.isSafeInteger(pid) || pid < 1 || !/^(0|[1-9][0-9]{0,31})$/u.test(startIdentity)) {
|
|
43
76
|
throw new Error(`Unverified process generation for PID ${pid}.`);
|
|
44
77
|
}
|
|
78
|
+
const actual = readProcessStartIdentity(pid);
|
|
79
|
+
if (actual !== undefined)
|
|
80
|
+
return actual === startIdentity;
|
|
81
|
+
// PID liveness can prove a process is gone, never prove its generation.
|
|
45
82
|
try {
|
|
46
|
-
|
|
83
|
+
process.kill(pid, 0);
|
|
47
84
|
}
|
|
48
|
-
catch {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (hasCode(error, "ESRCH"))
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
throw new Error(`Unverified process generation for PID ${pid}; OS identity could not be read.`);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Owner liveness used by reconciliation. An unreadable identity for a live
|
|
93
|
+
* PID is unknown and must not authorize cleanup or release of durable custody.
|
|
94
|
+
*/
|
|
95
|
+
export function processOwnerIsLive(pid, startIdentity) {
|
|
96
|
+
if (!Number.isSafeInteger(pid) || pid < 1 || !/^(0|[1-9][0-9]{0,31})$/u.test(startIdentity)) {
|
|
97
|
+
throw new Error(`Unverified process generation for PID ${pid}.`);
|
|
58
98
|
}
|
|
99
|
+
return processGenerationIsLive(pid, startIdentity);
|
|
59
100
|
}
|
|
60
101
|
function readProcessStart(pid) {
|
|
61
102
|
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
package/dist/doctor/doctor.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { accessSync, constants, existsSync, lstatSync } from "node:fs";
|
|
1
|
+
import { accessSync, constants, existsSync, lstatSync, realpathSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
import Database from "better-sqlite3";
|
|
4
|
+
import { inspectPty } from "./ptyProbe.js";
|
|
4
5
|
import { configuredAgentToDefinition, resolveAgentEnvironment } from "../agent/agent.js";
|
|
5
6
|
import { operationalAgentEnvironment } from "../agent/launchEnvironment.js";
|
|
6
7
|
import { resolveTmuxBin } from "../config/yuiConfig.js";
|
|
7
8
|
import { compileRoleSessionContext } from "../context/roleSessionContext.js";
|
|
9
|
+
import { callController, ControllerClientError } from "../core/controllerClient.js";
|
|
8
10
|
import { EPHEMERAL_DOMAIN_GRACE_MS, readEphemeralDomainIdentity, readLinuxProcessStartIdentity } from "../controller/domainIdentity.js";
|
|
9
11
|
import { usageError } from "../errors/cliError.js";
|
|
10
12
|
import { inspectAgentCapabilities, resolveAgentAdapter } from "../executor/agentAdapter.js";
|
|
@@ -12,6 +14,8 @@ import { assertCodexLaunchOverridesAvailable, inspectCodexLaunchConfig } from ".
|
|
|
12
14
|
import { resolveEffectiveLaunch } from "../executor/effectiveLaunch.js";
|
|
13
15
|
import { nativeAdditionalDirectories, nativeAgentWorkspace, withNativeProjectDirectories } from "../executor/fileRoleLaunchPlanner.js";
|
|
14
16
|
import { defaultTableWidth, renderTable } from "../output/table.js";
|
|
17
|
+
import { codexAppNeedsRestart, codexAppProcessIds, codexAppWebSocketUrl, codexSharedSocketPath, findMacCodexAppExecutable, isCodexInspectionPermissionError } from "../runtime/codexSharedDaemon.js";
|
|
18
|
+
import { codexAgentsInUse } from "../runtime/localStartup.js";
|
|
15
19
|
import { CURRENT_DATABASE_FILENAME, openCurrentTaskStore } from "../storage/currentTaskStore.js";
|
|
16
20
|
import { inspectStorageSchema } from "../storage/storageSchema.js";
|
|
17
21
|
import { resolveStoreWorkerEnabledForHome } from "../storage/storeRpc.js";
|
|
@@ -42,8 +46,8 @@ export function summarizeStorageHealth(checks) {
|
|
|
42
46
|
return { healthy: blocking.length === 0, blocking };
|
|
43
47
|
}
|
|
44
48
|
/** Build the full machine-readable doctor report (checks + storage health). */
|
|
45
|
-
export function buildDoctorReport(env, executor) {
|
|
46
|
-
const inspection = inspectDoctor(env, executor);
|
|
49
|
+
export async function buildDoctorReport(env, executor) {
|
|
50
|
+
const inspection = await inspectDoctor(env, executor);
|
|
47
51
|
const home = resolveYuiHome(env);
|
|
48
52
|
return {
|
|
49
53
|
checks: inspection.checks,
|
|
@@ -100,29 +104,30 @@ function inspectStorageDetails(home, env) {
|
|
|
100
104
|
};
|
|
101
105
|
}
|
|
102
106
|
/** Runs the read-only current-storage diagnostics used by `yui doctor`. */
|
|
103
|
-
export function runDoctorCommand(args, env, executor) {
|
|
107
|
+
export async function runDoctorCommand(args, env, executor) {
|
|
104
108
|
if (args.length !== 0)
|
|
105
109
|
throw usageError("Doctor usage: yui doctor");
|
|
106
|
-
const inspection = inspectDoctor(env, executor);
|
|
110
|
+
const inspection = await inspectDoctor(env, executor);
|
|
107
111
|
return renderDoctor(inspection.checks, inspection.review);
|
|
108
112
|
}
|
|
109
|
-
export function getDoctorChecks(env, executor) {
|
|
110
|
-
return inspectDoctor(env, executor).checks;
|
|
113
|
+
export async function getDoctorChecks(env, executor) {
|
|
114
|
+
return (await inspectDoctor(env, executor)).checks;
|
|
111
115
|
}
|
|
112
|
-
function inspectDoctor(env, executor) {
|
|
116
|
+
async function inspectDoctor(env, executor) {
|
|
113
117
|
const home = resolveYuiHome(env);
|
|
114
118
|
const homeCheck = checkHome(home);
|
|
115
119
|
const schema = readSchema(home);
|
|
116
120
|
const compatibility = inspectCompatibility(home, homeCheck, schema);
|
|
117
121
|
const schemaCheck = checkSchema(schema);
|
|
118
122
|
const storage = inspectState(home, homeCheck, schema, compatibility);
|
|
123
|
+
const controller = await checkController(home);
|
|
119
124
|
const domain = checkEphemeralDomain(home);
|
|
120
125
|
const durableConfig = readDurableConfigSafely(home);
|
|
121
126
|
const toolChecks = [
|
|
122
127
|
checkExecutable("git", durableConfig.gitBin, ["--version"], executor),
|
|
123
128
|
checkExecutable("tmux", durableConfig.tmuxBin, ["-V"], executor)
|
|
124
129
|
];
|
|
125
|
-
const agentChecks = storage.agents.flatMap((agent) => checkAgent(agent, executor, env));
|
|
130
|
+
const agentChecks = storage.agents.flatMap((agent) => checkAgent(agent, executor, env, home, storage.activeCodexAgentIds.has(agent.id)));
|
|
126
131
|
const review = inspectReview({ ...storage.review, home }, agentChecks, env);
|
|
127
132
|
return {
|
|
128
133
|
checks: [
|
|
@@ -130,14 +135,49 @@ function inspectDoctor(env, executor) {
|
|
|
130
135
|
schemaCheck,
|
|
131
136
|
compatibility.check,
|
|
132
137
|
storage.check,
|
|
138
|
+
controller,
|
|
133
139
|
...(domain === undefined ? [] : [domain]),
|
|
134
140
|
...toolChecks,
|
|
141
|
+
...inspectPty(),
|
|
135
142
|
...agentChecks,
|
|
136
143
|
...review.checks
|
|
137
144
|
],
|
|
138
145
|
review: review.report
|
|
139
146
|
};
|
|
140
147
|
}
|
|
148
|
+
/** Query the authenticated Controller endpoint without starting it. */
|
|
149
|
+
export async function checkController(home, probe = callController) {
|
|
150
|
+
try {
|
|
151
|
+
const result = await probe(home, "controller.status", {}, { timeoutMs: 1_000 });
|
|
152
|
+
if (result !== null && typeof result === "object" && !Array.isArray(result)) {
|
|
153
|
+
const status = result;
|
|
154
|
+
if (status.running === true && Number.isSafeInteger(status.pid) && Number(status.pid) > 0) {
|
|
155
|
+
return { name: "controller", status: "ok", detail: `Controller is running pid=${status.pid}.` };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
name: "controller", status: "invalid",
|
|
160
|
+
detail: "Controller returned an invalid status; inspect yui controller status --verbose."
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error instanceof ControllerClientError && error.code === "CONTROLLER_NOT_RUNNING") {
|
|
165
|
+
return { name: "controller", status: "missing", detail: `${error.message} Run yui start.` };
|
|
166
|
+
}
|
|
167
|
+
if (error instanceof ControllerClientError && error.code === "CONTROLLER_UNAVAILABLE") {
|
|
168
|
+
return {
|
|
169
|
+
name: "controller",
|
|
170
|
+
status: "invalid",
|
|
171
|
+
detail: `Controller is not reachable: ${error.message} Run yui start. Its process state is unverified.`
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
name: "controller",
|
|
176
|
+
status: "invalid",
|
|
177
|
+
detail: `Controller status could not be verified: ${error instanceof Error ? error.message : String(error)}. Inspect yui controller status --verbose.`
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
141
181
|
function checkEphemeralDomain(home) {
|
|
142
182
|
const identity = readEphemeralDomainIdentity(home);
|
|
143
183
|
if (identity.status === "absent")
|
|
@@ -327,6 +367,7 @@ function inspectState(home, homeCheck, schema, compatibility) {
|
|
|
327
367
|
detail: `yui.db readable agents=${agents.length} tasks=${tasks.length} roles=${roleCount} globalRoles=${globalRoles.length} defaultAgent=${config.defaultAgent ?? "none"}`
|
|
328
368
|
},
|
|
329
369
|
agents,
|
|
370
|
+
activeCodexAgentIds: new Set(codexAgentsInUse(store).map((agent) => agent.id)),
|
|
330
371
|
review: {
|
|
331
372
|
storageReady: true,
|
|
332
373
|
storageDetail: "yui.db is readable",
|
|
@@ -362,6 +403,7 @@ function blockedStorage(status, detail) {
|
|
|
362
403
|
return {
|
|
363
404
|
check: { name: "storage state", status, detail },
|
|
364
405
|
agents: [],
|
|
406
|
+
activeCodexAgentIds: new Set(),
|
|
365
407
|
review: {
|
|
366
408
|
storageReady: false,
|
|
367
409
|
storageDetail: detail
|
|
@@ -655,11 +697,14 @@ function checkExecutable(name, command, args, executor) {
|
|
|
655
697
|
};
|
|
656
698
|
}
|
|
657
699
|
}
|
|
658
|
-
function checkAgent(agent, executor, environment) {
|
|
700
|
+
function checkAgent(agent, executor, environment, home, activeCodex) {
|
|
659
701
|
let snapshot;
|
|
702
|
+
let baseArgs;
|
|
703
|
+
let launchEnvironment;
|
|
660
704
|
try {
|
|
661
705
|
const definition = configuredAgentToDefinition(agent);
|
|
662
|
-
|
|
706
|
+
baseArgs = definition.baseArgs;
|
|
707
|
+
launchEnvironment = resolveDoctorAgentEnvironment(definition, environment);
|
|
663
708
|
snapshot = inspectAgentCapabilities(definition, {
|
|
664
709
|
run: (command, args) => runAgentProbe(executor, command, args, launchEnvironment)
|
|
665
710
|
});
|
|
@@ -686,6 +731,9 @@ function checkAgent(agent, executor, environment) {
|
|
|
686
731
|
const available = snapshot.fields.filter((field) => field.status === "available").length;
|
|
687
732
|
const degraded = snapshot.fields.filter((field) => field.status === "degraded").length;
|
|
688
733
|
const unavailable = snapshot.fields.filter((field) => field.status === "unavailable").length;
|
|
734
|
+
const daemon = activeCodex && status === "ok"
|
|
735
|
+
? checkCodexSharedDaemon(agent.command, baseArgs, launchEnvironment, executor, agent.id)
|
|
736
|
+
: undefined;
|
|
689
737
|
return [
|
|
690
738
|
{ name: `agent:${agent.id}:command`, status, detail: commandDetail },
|
|
691
739
|
{
|
|
@@ -698,9 +746,181 @@ function checkAgent(agent, executor, environment) {
|
|
|
698
746
|
"installation/help inspection only; no model or account capability query",
|
|
699
747
|
...snapshot.warnings.map((warning) => `warning=${warning}`)
|
|
700
748
|
].join(" ")
|
|
701
|
-
}
|
|
749
|
+
},
|
|
750
|
+
...(daemon === undefined ? [] : [daemon]),
|
|
751
|
+
...(daemon !== undefined && process.platform === "darwin"
|
|
752
|
+
? checkMacCodexApp(agent.id, launchEnvironment, home, daemon, executor)
|
|
753
|
+
: [])
|
|
702
754
|
];
|
|
703
755
|
}
|
|
756
|
+
export function checkMacCodexApp(agentId, environment, home, daemon, executor, appExecutable = findMacCodexAppExecutable(environment)) {
|
|
757
|
+
if (appExecutable === undefined)
|
|
758
|
+
return [{
|
|
759
|
+
name: `agent:${agentId}:mac-app`, status: "ok",
|
|
760
|
+
detail: "Codex App was not detected; App sharing is not configured on this Mac."
|
|
761
|
+
}];
|
|
762
|
+
const expectedUrl = codexAppWebSocketUrl(environment, home);
|
|
763
|
+
let appPids;
|
|
764
|
+
try {
|
|
765
|
+
appPids = codexAppProcessIds(executor.run("/bin/ps", ["-axo", "pid=,args="], {
|
|
766
|
+
timeoutMs: 5_000
|
|
767
|
+
}), appExecutable);
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
return [{
|
|
771
|
+
name: `agent:${agentId}:mac-app`, status: "invalid",
|
|
772
|
+
detail: `Cannot inspect Codex App processes: ${commandFailure(error)}`
|
|
773
|
+
}];
|
|
774
|
+
}
|
|
775
|
+
const processCheck = {
|
|
776
|
+
name: `agent:${agentId}:mac-app`,
|
|
777
|
+
status: appPids.length > 0 ? "ok" : "missing",
|
|
778
|
+
detail: appPids.length > 0
|
|
779
|
+
? `Codex App is running pid=${appPids.join(",")}.`
|
|
780
|
+
: "Codex App is installed but not running."
|
|
781
|
+
};
|
|
782
|
+
let launchCheck;
|
|
783
|
+
try {
|
|
784
|
+
const launchUrl = executor.run("/bin/launchctl", ["getenv", "CODEX_APP_SERVER_WS_URL"], {
|
|
785
|
+
timeoutMs: 5_000
|
|
786
|
+
}).trim();
|
|
787
|
+
launchCheck = launchUrl === expectedUrl
|
|
788
|
+
? {
|
|
789
|
+
name: `agent:${agentId}:mac-app-launch`, status: "ok",
|
|
790
|
+
detail: "Future Codex App launches are configured for this shared app-server."
|
|
791
|
+
}
|
|
792
|
+
: {
|
|
793
|
+
name: `agent:${agentId}:mac-app-launch`, status: "missing",
|
|
794
|
+
detail: "This login is not prepared for App sharing. Run yui start before opening or restarting App."
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
launchCheck = {
|
|
799
|
+
name: `agent:${agentId}:mac-app-launch`, status: "invalid",
|
|
800
|
+
detail: `Cannot inspect the App launch environment: ${commandFailure(error)}`
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
const connectionName = `agent:${agentId}:mac-app-connection`;
|
|
804
|
+
if (appPids.length === 0) {
|
|
805
|
+
return [processCheck, launchCheck, {
|
|
806
|
+
name: connectionName, status: "missing", detail: "No detected App process; connection is not verified."
|
|
807
|
+
}];
|
|
808
|
+
}
|
|
809
|
+
if (daemon.status !== "ok") {
|
|
810
|
+
return [processCheck, launchCheck, {
|
|
811
|
+
name: connectionName, status: daemon.status,
|
|
812
|
+
detail: "Shared daemon is unavailable or unverified; App connection to it cannot be confirmed."
|
|
813
|
+
}];
|
|
814
|
+
}
|
|
815
|
+
let configuredCount = 0;
|
|
816
|
+
for (const pid of appPids) {
|
|
817
|
+
try {
|
|
818
|
+
const command = executor.run("/bin/ps", ["eww", "-p", pid, "-o", "command="], {
|
|
819
|
+
timeoutMs: 5_000
|
|
820
|
+
});
|
|
821
|
+
if (!codexAppNeedsRestart(command, expectedUrl, false))
|
|
822
|
+
configuredCount += 1;
|
|
823
|
+
}
|
|
824
|
+
catch (error) {
|
|
825
|
+
return [processCheck, launchCheck, {
|
|
826
|
+
name: connectionName, status: "invalid",
|
|
827
|
+
detail: `Cannot inspect running Codex App: ${commandFailure(error)}`
|
|
828
|
+
}];
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
const socketTarget = realpathSync(codexSharedSocketPath(environment, home));
|
|
833
|
+
const sockets = parseMacUnixSockets(executor.run("/usr/sbin/lsof", ["-nP", "-U", "-F", "pfdn"], {
|
|
834
|
+
timeoutMs: 5_000
|
|
835
|
+
}));
|
|
836
|
+
const daemonSockets = sockets.filter((socket) => socket.name === socketTarget);
|
|
837
|
+
if (daemonSockets.length === 0) {
|
|
838
|
+
return [processCheck, launchCheck, {
|
|
839
|
+
name: connectionName, status: "invalid",
|
|
840
|
+
detail: "Daemon reports running, but its Unix socket endpoint was not observable."
|
|
841
|
+
}];
|
|
842
|
+
}
|
|
843
|
+
const peer = sockets.find((socket) => appPids.includes(socket.pid)
|
|
844
|
+
&& daemonSockets.some((daemonSocket) => socket.name === `->${daemonSocket.device}`));
|
|
845
|
+
return [processCheck, launchCheck, peer === undefined
|
|
846
|
+
? {
|
|
847
|
+
name: connectionName, status: "missing",
|
|
848
|
+
detail: configuredCount > 0
|
|
849
|
+
? "App has the shared URL, but no live socket peer to this daemon was observed."
|
|
850
|
+
: "No live App socket peer to this daemon was observed; reopen App after Yui prepares sharing."
|
|
851
|
+
}
|
|
852
|
+
: {
|
|
853
|
+
name: connectionName, status: "ok",
|
|
854
|
+
detail: `App pid=${peer.pid} has a live Unix socket peer on shared daemon pid=${daemonSockets[0].pid}.`
|
|
855
|
+
}];
|
|
856
|
+
}
|
|
857
|
+
catch (error) {
|
|
858
|
+
return [processCheck, launchCheck, {
|
|
859
|
+
name: connectionName, status: "invalid",
|
|
860
|
+
detail: `Cannot verify the live App-to-daemon socket: ${commandFailure(error)}`
|
|
861
|
+
}];
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
function parseMacUnixSockets(output) {
|
|
865
|
+
const sockets = [];
|
|
866
|
+
let pid = "";
|
|
867
|
+
let device = "";
|
|
868
|
+
let name = "";
|
|
869
|
+
const finish = () => {
|
|
870
|
+
if (pid !== "" && /^0x[0-9a-f]+$/iu.test(device) && name !== "") {
|
|
871
|
+
sockets.push({ pid, device, name });
|
|
872
|
+
}
|
|
873
|
+
device = "";
|
|
874
|
+
name = "";
|
|
875
|
+
};
|
|
876
|
+
for (const line of output.split("\n")) {
|
|
877
|
+
if (line.startsWith("p")) {
|
|
878
|
+
finish();
|
|
879
|
+
pid = line.slice(1);
|
|
880
|
+
}
|
|
881
|
+
else if (line.startsWith("f"))
|
|
882
|
+
finish();
|
|
883
|
+
else if (line.startsWith("d"))
|
|
884
|
+
device = line.slice(1);
|
|
885
|
+
else if (line.startsWith("n"))
|
|
886
|
+
name = line.slice(1);
|
|
887
|
+
}
|
|
888
|
+
finish();
|
|
889
|
+
return sockets;
|
|
890
|
+
}
|
|
891
|
+
export function checkCodexSharedDaemon(command, baseArgs, environment, executor, agentId) {
|
|
892
|
+
const name = `agent:${agentId}:daemon`;
|
|
893
|
+
const probe = runAgentProbe(executor, command, [...baseArgs, "app-server", "daemon", "version"], environment);
|
|
894
|
+
if (probe.status !== 0) {
|
|
895
|
+
const detail = probe.stderr.trim() || probe.error?.message || "The Codex daemon could not be inspected.";
|
|
896
|
+
if (isCodexInspectionPermissionError(detail))
|
|
897
|
+
return {
|
|
898
|
+
name,
|
|
899
|
+
status: "invalid",
|
|
900
|
+
detail: `Shared Codex app-server state is unverified: this process was denied access during the daemon probe (${detail}). Run Doctor from a local terminal with access to the Codex socket.`
|
|
901
|
+
};
|
|
902
|
+
return {
|
|
903
|
+
name,
|
|
904
|
+
status: /No such file or directory|Connection refused/iu.test(detail) ? "missing" : "invalid",
|
|
905
|
+
detail: `Shared Codex app-server probe failed: ${detail}`
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
const version = JSON.parse(probe.stdout);
|
|
910
|
+
if (version === null || typeof version !== "object" || Array.isArray(version))
|
|
911
|
+
throw new Error();
|
|
912
|
+
const state = version;
|
|
913
|
+
if (state.status !== "running")
|
|
914
|
+
throw new Error();
|
|
915
|
+
return {
|
|
916
|
+
name, status: "ok",
|
|
917
|
+
detail: `Shared Codex app-server running version=${String(state.appServerVersion ?? "unknown")}`
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
catch {
|
|
921
|
+
return { name, status: "invalid", detail: "Codex daemon version did not confirm a running app-server." };
|
|
922
|
+
}
|
|
923
|
+
}
|
|
704
924
|
function runAgentProbe(executor, command, args, environment) {
|
|
705
925
|
try {
|
|
706
926
|
return {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
/** Native loading and spawn happen outside Doctor: a native abort cannot kill the CLI. */
|
|
6
|
+
export function inspectPty(options = {}) {
|
|
7
|
+
const started = Date.now();
|
|
8
|
+
const checks = [];
|
|
9
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
10
|
+
if (![20, 22, 24, 26].includes(major)) {
|
|
11
|
+
checks.push({
|
|
12
|
+
name: "pty Node runtime", status: "unsupported",
|
|
13
|
+
detail: `Node ${process.versions.node} is outside Yui's supported Node 20/22/24/26 range; this is not evidence of a helper permission failure.`
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
let entry;
|
|
17
|
+
let identity;
|
|
18
|
+
try {
|
|
19
|
+
const require = createRequire(options.requireFrom ?? import.meta.url);
|
|
20
|
+
entry = require.resolve("node-pty");
|
|
21
|
+
const manifest = require.resolve("node-pty/package.json");
|
|
22
|
+
const version = JSON.parse(readFileSync(manifest, "utf8")).version;
|
|
23
|
+
identity = `node-pty ${version}; entry=${entry}; package=${manifest}`;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return [...checks, {
|
|
27
|
+
name: "node-pty",
|
|
28
|
+
status: error.code === "MODULE_NOT_FOUND" ? "missing" : "invalid",
|
|
29
|
+
detail: `Dependency resolution failed: ${String(error)}`
|
|
30
|
+
}];
|
|
31
|
+
}
|
|
32
|
+
const timeoutMs = options.timeoutMs ?? 1_500;
|
|
33
|
+
const result = spawnSync(process.execPath, [
|
|
34
|
+
fileURLToPath(new URL("./ptyProbeChild.js", import.meta.url)),
|
|
35
|
+
entry, String(options.probeTimeoutMs ?? 750)
|
|
36
|
+
], {
|
|
37
|
+
// No NODE_OPTIONS, shell startup files, preload hooks or user configuration.
|
|
38
|
+
env: { PATH: "/usr/bin:/bin", LANG: "C" },
|
|
39
|
+
cwd: "/",
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
timeout: timeoutMs,
|
|
42
|
+
killSignal: "SIGKILL",
|
|
43
|
+
maxBuffer: 64 * 1024
|
|
44
|
+
});
|
|
45
|
+
for (const line of (result.stdout ?? "").split("\n").filter(Boolean)) {
|
|
46
|
+
try {
|
|
47
|
+
const check = JSON.parse(line);
|
|
48
|
+
if (["node-pty", "pty helper", "pty spawn"].includes(check.name)
|
|
49
|
+
&& ["ok", "missing", "invalid"].includes(check.status)
|
|
50
|
+
&& typeof check.detail === "string") {
|
|
51
|
+
checks.push({ ...check, detail: `${identity}; ${check.detail}` });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch { /* Native diagnostics on stdout are retained below on failure. */ }
|
|
55
|
+
}
|
|
56
|
+
const completed = checks.some(check => check.name === "pty spawn")
|
|
57
|
+
|| checks.some(check => check.status !== "ok" && check.name !== "pty Node runtime");
|
|
58
|
+
if (result.error || result.signal || result.status !== 0 || !completed) {
|
|
59
|
+
const timedOut = result.error?.code === "ETIMEDOUT";
|
|
60
|
+
// A partial success line is not success if the isolated process failed to finish.
|
|
61
|
+
for (let i = 0; i < checks.length; i += 1) {
|
|
62
|
+
if (checks[i].name === "pty spawn" && checks[i].status === "ok") {
|
|
63
|
+
checks[i] = { ...checks[i], status: "invalid", detail: `${checks[i].detail}; isolated probe did not finish successfully` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
checks.push({
|
|
67
|
+
name: "pty probe", status: "invalid",
|
|
68
|
+
detail: `${identity}; ${timedOut ? `timeout after ${timeoutMs}ms` : "isolated probe failed"}; exit=${result.status} signal=${result.signal}; ${result.error?.message ?? ""} ${(result.stderr || result.stdout || "").trim()}`
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return checks.map(check => ({
|
|
72
|
+
...check, detail: `${check.detail}; elapsed=${Date.now() - started}ms`
|
|
73
|
+
}));
|
|
74
|
+
}
|