agents-can-communicate 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/bin/acc-maintenance-worker.mjs +6 -0
- package/bin/entrypoints/acc-hook.mjs +58 -5
- package/docs/ARCHITECTURE.md +21 -9
- package/docs/CLI.md +21 -10
- package/docs/CONCEPTS.md +2 -1
- package/docs/CONFIGURATION.md +2 -1
- package/docs/GETTING_STARTED.md +4 -2
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/PROTOCOL.md +7 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +16 -7
- package/docs/TROUBLESHOOTING.md +13 -6
- package/docs/UPGRADING.md +121 -22
- package/docs/WHY_ACC.md +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +10 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +120 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-threads.mjs +49 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +108 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +63 -16
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +16 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +10 -5
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +225 -11
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +15 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +36 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +17 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +54 -14
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +44 -25
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +46 -29
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/legacy-maintenance.mjs +47 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +57 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +118 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +140 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +80 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +6 -7
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/staging.mjs +95 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +8 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +34 -12
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +83 -7
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +44 -2
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +10 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +55 -6
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +10 -3
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +38 -4
- package/package.json +3 -1
|
@@ -2,22 +2,27 @@ import { defineAdapter, projectContext, projectContextResult }
|
|
|
2
2
|
from "@agents-can-communicate/adapter-sdk";
|
|
3
3
|
import { probeNativeDelivery, planNativeActivation, bindNativeSession, refreshNativeSession, retireNativeSession, offerMessage } from "./native-delivery.mjs";
|
|
4
4
|
import certification from "../certification.json" with { type: "json" };
|
|
5
|
+
import { createCodexMaintenance } from "./maintenance.mjs";
|
|
6
|
+
export { sameMaintenanceIdentity } from "./maintenance.mjs";
|
|
5
7
|
|
|
6
|
-
import { PROTOCOL_CONTRACT } from "./app-server-client.mjs";
|
|
8
|
+
import { CODEX_QUEUE_MINIMUM, PROTOCOL_CONTRACT } from "./app-server-client.mjs";
|
|
7
9
|
import { allowOutcome, denyOutcome, injectOutcome, normalizeCodexHook }
|
|
8
10
|
from "./hooks.mjs";
|
|
9
11
|
import { planCodexInstall, detectCodex, installCodexPlugin, preflightCodexUninstall,
|
|
10
12
|
uninstallCodexPlugin } from "./install.mjs";
|
|
11
13
|
// Public native wiring is enabled only with installed-product capture evidence.
|
|
12
14
|
|
|
15
|
+
// Re-exported so existing importers of CODEX_QUEUE_MINIMUM keep resolving; the
|
|
16
|
+
// value now lives in app-server-client.mjs alongside the other version rules.
|
|
17
|
+
export { CODEX_QUEUE_MINIMUM };
|
|
13
18
|
export const CODEX_VERSION = "0.147.0";
|
|
14
|
-
export const CODEX_QUEUE_MINIMUM = "0.152.1";
|
|
15
19
|
export const CODEX_DELIVERY_FALLBACK = Object.freeze({
|
|
16
20
|
diagnostic: "Codex native delivery requires codex-cli 0.152.1 or newer on darwin-arm64, "
|
|
17
21
|
+ "recorded recipient consent and a reachable LocalDaemon session with exact thread, cwd, "
|
|
18
22
|
+ "process, version and protocol verification. Embedded or unreachable sessions retain "
|
|
19
|
-
+ "durable messages.
|
|
20
|
-
+ "launch arguments; fallback is exact-certified next-turn delivery or acc inbox"
|
|
23
|
+
+ "durable messages. Native delivery does not start, restart or stop the vendor daemon and adds no "
|
|
24
|
+
+ "launch arguments; fallback is exact-certified next-turn delivery or acc inbox. "
|
|
25
|
+
+ "Explicitly confirmed acc update maintenance has a separate daemon restart check",
|
|
21
26
|
});
|
|
22
27
|
|
|
23
28
|
/**
|
|
@@ -61,6 +66,7 @@ export function createCodexAdapter() {
|
|
|
61
66
|
},
|
|
62
67
|
probeNativeDelivery, planNativeActivation, bindNativeSession,
|
|
63
68
|
refreshNativeSession, retireNativeSession, offerMessage,
|
|
69
|
+
...createCodexMaintenance(),
|
|
64
70
|
startSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
|
|
65
71
|
endSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
|
|
66
72
|
guardWrite: async () => ({ ok: true, changes: [], diagnostics: [] }),
|
|
@@ -12,6 +12,10 @@ import { openWebSocketPeer } from "./ws-json-rpc.mjs";
|
|
|
12
12
|
|
|
13
13
|
export const PROTOCOL_CONTRACT = "codex-app-server-thread-queue-v1";
|
|
14
14
|
export const MINIMUM_VERSION = "0.152.1";
|
|
15
|
+
// The captured native-delivery contract's floor. Shared with MINIMUM_VERSION
|
|
16
|
+
// today, but named separately: this is the version a binding was verified
|
|
17
|
+
// against, not the probe's own support floor.
|
|
18
|
+
export const CODEX_QUEUE_MINIMUM = MINIMUM_VERSION;
|
|
15
19
|
export const QUEUE_MODES = Object.freeze(["livePush", "idleWake", "busyQueue"]);
|
|
16
20
|
const CLIENT_INFO = Object.freeze({ name: "agents-can-communicate", version: "0.2.0" });
|
|
17
21
|
const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, lstat, open, realpath } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { compareStableVersions, parseStableVersion } from "./app-server-client.mjs";
|
|
7
|
+
|
|
8
|
+
export const MINIMUM_MAINTENANCE_CLI = "0.154.0";
|
|
9
|
+
export const absolute = value => typeof value === "string" && path.isAbsolute(value)
|
|
10
|
+
&& !/[\0\r\n]/.test(value);
|
|
11
|
+
export const failMaintenance = reasonCode => { throw Object.assign(new Error(reasonCode), { reasonCode }); };
|
|
12
|
+
const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
|
|
13
|
+
export const startTimeValid = value => typeof value === "string"
|
|
14
|
+
&& /^[A-Z][a-z]{2} [A-Z][a-z]{2} [ \d]\d \d\d:\d\d:\d\d \d{4}$/.test(value);
|
|
15
|
+
export const managedExecutablePaths = codexHome => ["bin/codex", "codex"].map(name =>
|
|
16
|
+
path.join(codexHome, "packages/standalone/current", name));
|
|
17
|
+
|
|
18
|
+
export function runMaintenanceCommand(command, args, options) {
|
|
19
|
+
return new Promise(resolve => execFile(command, args,
|
|
20
|
+
{ ...options, timeout: 20_000, maxBuffer: 131_072, windowsHide: true },
|
|
21
|
+
(error, stdout, stderr) => resolve({ status: error ?
|
|
22
|
+
(typeof error.code === "number" ? error.code : null) : 0, stdout, stderr })));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function maintenanceContext(context = {}) {
|
|
26
|
+
const env = context.env ?? process.env;
|
|
27
|
+
const home = context.home ?? env.HOME ?? os.homedir();
|
|
28
|
+
let codexHome = env.CODEX_HOME ?? path.join(home, ".codex");
|
|
29
|
+
if (!absolute(home) || !absolute(codexHome)) failMaintenance("invalid_service_home");
|
|
30
|
+
try { codexHome = await realpath(codexHome); }
|
|
31
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
32
|
+
const [binPath, legacyPath] = managedExecutablePaths(codexHome);
|
|
33
|
+
const managedPath = await metadataExists(binPath) ? binPath : legacyPath;
|
|
34
|
+
return { codexHome, socketPath: path.join(codexHome, "app-server-control/app-server-control.sock"),
|
|
35
|
+
pidPath: path.join(codexHome, "app-server-daemon/app-server.pid"),
|
|
36
|
+
managedPath,
|
|
37
|
+
platform: context.platform ?? `${process.platform}-${process.arch}`,
|
|
38
|
+
options: { cwd: codexHome, env: { ...env, HOME: home, CODEX_HOME: codexHome } } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function metadataExists(file) {
|
|
42
|
+
try { await lstat(file); return true; }
|
|
43
|
+
catch (error) { if (error.code === "ENOENT") return false; throw error; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function resolveCli(pathEnv) {
|
|
47
|
+
for (const directory of String(pathEnv ?? "").split(path.delimiter)) {
|
|
48
|
+
if (!absolute(directory)) continue;
|
|
49
|
+
const candidate = path.join(directory, "codex");
|
|
50
|
+
try {
|
|
51
|
+
await access(candidate, constants.X_OK);
|
|
52
|
+
const resolved = await realpath(candidate);
|
|
53
|
+
if ((await lstat(resolved)).isFile()) return resolved;
|
|
54
|
+
} catch { /* another PATH entry may be usable */ }
|
|
55
|
+
}
|
|
56
|
+
failMaintenance("maintenance_cli_unavailable");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const cliVersion = response => response.status === 0
|
|
60
|
+
? /^codex-cli (\d+\.\d+\.\d+)\s*$/.exec(response.stdout)?.[1] ?? null : null;
|
|
61
|
+
|
|
62
|
+
export async function probeMaintenanceInstall(paths, run) {
|
|
63
|
+
const cliPath = await resolveCli(paths.options.env.PATH);
|
|
64
|
+
const version = cliVersion(await run(cliPath, ["--version"], paths.options));
|
|
65
|
+
if (!parseStableVersion(version) || compareStableVersions(version, MINIMUM_MAINTENANCE_CLI) < 0) {
|
|
66
|
+
failMaintenance("maintenance_cli_unsupported");
|
|
67
|
+
}
|
|
68
|
+
const help = await run(cliPath, ["app-server", "daemon", "--help"], paths.options);
|
|
69
|
+
if (help.status !== 0 || ["start", "stop", "version"].some(command =>
|
|
70
|
+
!new RegExp(`^\\s+${command}\\s+`, "m").test(help.stdout))) failMaintenance("maintenance_cli_unsupported");
|
|
71
|
+
const managedVersion = cliVersion(await run(paths.managedPath, ["--version"], paths.options));
|
|
72
|
+
if (managedVersion !== version) failMaintenance("managed_binary_mismatch");
|
|
73
|
+
return { cliPath, cliVersion: version, managedVersion };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function readMaintenancePid(file) {
|
|
77
|
+
let handle;
|
|
78
|
+
try {
|
|
79
|
+
handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
80
|
+
const info = await handle.stat();
|
|
81
|
+
if (!info.isFile() || !own(info) || info.size > 4_096 || (info.mode & 0o022) !== 0) {
|
|
82
|
+
failMaintenance("daemon_identity_unavailable");
|
|
83
|
+
}
|
|
84
|
+
const value = JSON.parse(await handle.readFile("utf8"));
|
|
85
|
+
if (!Number.isSafeInteger(value.pid) || value.pid < 2 || !startTimeValid(value.processStartTime)) {
|
|
86
|
+
failMaintenance("daemon_identity_unavailable");
|
|
87
|
+
}
|
|
88
|
+
return { pid: value.pid, processStartTime: value.processStartTime };
|
|
89
|
+
} finally { await handle?.close(); }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function observeMaintenanceProcess(pid, paths, run) {
|
|
93
|
+
const response = await run("/bin/ps", ["-p", String(pid), "-o", "lstart=,command="], paths.options);
|
|
94
|
+
if (response.status === 1 && response.stdout === "" && response.stderr === "") return { state: "dead" };
|
|
95
|
+
if (response.status !== 0) return { state: "unknown" };
|
|
96
|
+
const match = /^(.{24})\s+(.+)$/.exec(response.stdout.trim());
|
|
97
|
+
if (!match || !startTimeValid(match[1])) return { state: "unknown" };
|
|
98
|
+
return { state: "alive", processStartTime: match[1], command: match[2] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function verifyMaintenanceProcess(snapshot, paths, run) {
|
|
102
|
+
const process = await observeMaintenanceProcess(snapshot.pid, paths, run);
|
|
103
|
+
const suffix = " app-server --listen unix://";
|
|
104
|
+
const executable = process.command?.endsWith(suffix) ? process.command.slice(0, -suffix.length) : null;
|
|
105
|
+
if (process.state !== "alive" || process.processStartTime !== snapshot.processStartTime
|
|
106
|
+
|| !managedExecutablePaths(paths.codexHome).includes(executable)
|
|
107
|
+
|| await realpath(executable) !== await realpath(paths.managedPath)) {
|
|
108
|
+
failMaintenance("daemon_identity_unavailable");
|
|
109
|
+
}
|
|
110
|
+
const sockets = await run("/usr/sbin/lsof", ["-n", "-a", "-p", String(snapshot.pid), "-U", "-Fn"], paths.options);
|
|
111
|
+
if (sockets.status !== 0 || !sockets.stdout.split("\n").includes(`n${paths.socketPath}`)) {
|
|
112
|
+
failMaintenance("daemon_socket_unproven");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function approvedProcessIsDead(expected, paths, run) {
|
|
117
|
+
const observed = await observeMaintenanceProcess(expected.pid, paths, run);
|
|
118
|
+
return observed.state === "dead" || (observed.state === "alive"
|
|
119
|
+
&& observed.processStartTime !== expected.processStartTime);
|
|
120
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { initializeCodex } from "./app-server-client.mjs";
|
|
2
|
+
import { failMaintenance } from "./maintenance-host.mjs";
|
|
3
|
+
|
|
4
|
+
async function loadedThreads(peer) {
|
|
5
|
+
const ids = [], cursors = new Set();
|
|
6
|
+
let cursor = null;
|
|
7
|
+
for (let page = 0; page < 20; page += 1) {
|
|
8
|
+
const response = await peer.request("thread/loaded/list", { limit: 100, ...(cursor ? { cursor } : {}) });
|
|
9
|
+
if (!Array.isArray(response?.data) || response.data.some(id => typeof id !== "string" || !id)) {
|
|
10
|
+
failMaintenance("maintenance_protocol_unavailable");
|
|
11
|
+
}
|
|
12
|
+
ids.push(...response.data);
|
|
13
|
+
cursor = response.nextCursor ?? null;
|
|
14
|
+
if (cursor === null) {
|
|
15
|
+
if (new Set(ids).size !== ids.length) failMaintenance("maintenance_protocol_unavailable");
|
|
16
|
+
return ids.sort();
|
|
17
|
+
}
|
|
18
|
+
if (typeof cursor !== "string" || !cursor || cursors.has(cursor)) failMaintenance("maintenance_protocol_unavailable");
|
|
19
|
+
cursors.add(cursor);
|
|
20
|
+
}
|
|
21
|
+
failMaintenance("maintenance_protocol_unavailable");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The caller receives counts only. No thread/list previews, turns, queue bodies,
|
|
25
|
+
// notification history, or vendor errors are retained in maintenance snapshots.
|
|
26
|
+
export async function maintenanceWorkload(snapshot, open) {
|
|
27
|
+
let peer;
|
|
28
|
+
try {
|
|
29
|
+
peer = await open({ socketPath: snapshot.socketPath });
|
|
30
|
+
if (await initializeCodex(peer) !== snapshot.serverVersion) failMaintenance("daemon_version_changed");
|
|
31
|
+
const ids = await loadedThreads(peer);
|
|
32
|
+
let busyThreads = 0, queuedRequests = 0;
|
|
33
|
+
for (const threadId of ids) {
|
|
34
|
+
const response = await peer.request("thread/read", { threadId, includeTurns: false });
|
|
35
|
+
const thread = response?.thread;
|
|
36
|
+
if (thread?.id !== threadId || !Array.isArray(thread.turns) || thread.turns.length !== 0
|
|
37
|
+
|| !["idle", "active"].includes(thread.status?.type)) failMaintenance("maintenance_protocol_unavailable");
|
|
38
|
+
if (thread.status.type === "active") busyThreads += 1;
|
|
39
|
+
const queue = await peer.request("thread/queue/list", { threadId });
|
|
40
|
+
if (!Array.isArray(queue?.data) || queue.nextCursor != null
|
|
41
|
+
|| queue.data.some(entry => typeof entry?.id !== "string" || !entry.id)) {
|
|
42
|
+
failMaintenance("maintenance_protocol_unavailable");
|
|
43
|
+
}
|
|
44
|
+
queuedRequests += queue.data.length;
|
|
45
|
+
}
|
|
46
|
+
if (JSON.stringify(await loadedThreads(peer)) !== JSON.stringify(ids)) failMaintenance("loaded_threads_changed");
|
|
47
|
+
return { busyThreads, queuedRequests };
|
|
48
|
+
} finally { await peer?.close(); }
|
|
49
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { openCodexAppServer, parseStableVersion } from "./app-server-client.mjs";
|
|
3
|
+
import { socketIsReady } from "./native-endpoint.mjs";
|
|
4
|
+
import { absolute, approvedProcessIsDead, failMaintenance, maintenanceContext,
|
|
5
|
+
managedExecutablePaths, metadataExists, probeMaintenanceInstall, readMaintenancePid, runMaintenanceCommand,
|
|
6
|
+
startTimeValid, verifyMaintenanceProcess } from "./maintenance-host.mjs";
|
|
7
|
+
import { maintenanceWorkload } from "./maintenance-threads.mjs";
|
|
8
|
+
|
|
9
|
+
const identityKeys = ["serviceId", "pid", "processStartTime", "codexHome", "cliPath", "cliVersion",
|
|
10
|
+
"serverVersion", "managedPath", "managedVersion", "socketPath"];
|
|
11
|
+
const installationKeys = ["codexHome", "cliPath", "cliVersion", "managedPath", "managedVersion", "socketPath"];
|
|
12
|
+
const refused = (reasonCode, snapshot) => ({ ok: false, reasonCode, ...(snapshot ? { snapshot } : {}) });
|
|
13
|
+
const sameFields = (left, right, fields) => fields.every(key => left?.[key] === right?.[key]);
|
|
14
|
+
|
|
15
|
+
function validApproval(value) {
|
|
16
|
+
return value?.serviceId === "codex-app-server" && ["ready", "busy"].includes(value.state)
|
|
17
|
+
&& Number.isSafeInteger(value.pid) && value.pid > 1 && startTimeValid(value.processStartTime)
|
|
18
|
+
&& ["codexHome", "cliPath", "managedPath", "socketPath"].every(key => absolute(value[key]))
|
|
19
|
+
&& ["cliVersion", "serverVersion", "managedVersion"].every(key => parseStableVersion(value[key]))
|
|
20
|
+
&& value.managedVersion === value.cliVersion
|
|
21
|
+
&& managedExecutablePaths(value.codexHome).includes(value.managedPath)
|
|
22
|
+
&& value.socketPath === path.join(value.codexHome, "app-server-control/app-server-control.sock");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sameMaintenanceIdentity(expected, current) {
|
|
26
|
+
return validApproval(expected) && validApproval(current) && sameFields(expected, current, identityKeys);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createCodexMaintenance({ run = runMaintenanceCommand, open = openCodexAppServer } = {}) {
|
|
30
|
+
async function inspectMaintenance(context) {
|
|
31
|
+
let snapshot = { serviceId: "codex-app-server", state: "unsupported", reasonCode: null,
|
|
32
|
+
pid: null, processStartTime: null, codexHome: null, cliPath: null, cliVersion: null,
|
|
33
|
+
serverVersion: null, managedPath: null, managedVersion: null, socketPath: null,
|
|
34
|
+
busyThreads: 0, queuedRequests: 0 };
|
|
35
|
+
try {
|
|
36
|
+
const paths = await maintenanceContext(context);
|
|
37
|
+
Object.assign(snapshot, { codexHome: paths.codexHome, socketPath: paths.socketPath, managedPath: paths.managedPath });
|
|
38
|
+
if (!await metadataExists(paths.socketPath) && !await metadataExists(paths.pidPath)) return null;
|
|
39
|
+
if (paths.platform !== "darwin-arm64") failMaintenance("maintenance_platform_unsupported");
|
|
40
|
+
if (!await socketIsReady(paths.socketPath)) failMaintenance("daemon_socket_unproven");
|
|
41
|
+
Object.assign(snapshot, await probeMaintenanceInstall(paths, run));
|
|
42
|
+
const version = await run(snapshot.cliPath, ["app-server", "daemon", "version"], paths.options);
|
|
43
|
+
if (version.status !== 0) failMaintenance("daemon_version_unavailable");
|
|
44
|
+
const observed = JSON.parse(version.stdout);
|
|
45
|
+
if (observed.status !== "running" || observed.backend !== "pid") failMaintenance("maintenance_backend_unsupported");
|
|
46
|
+
if (observed.managedCodexPath !== snapshot.managedPath || observed.socketPath !== snapshot.socketPath
|
|
47
|
+
|| observed.cliVersion !== snapshot.cliVersion || observed.managedCodexVersion !== snapshot.managedVersion
|
|
48
|
+
|| !parseStableVersion(observed.appServerVersion)) failMaintenance("daemon_identity_unavailable");
|
|
49
|
+
snapshot.serverVersion = observed.appServerVersion;
|
|
50
|
+
Object.assign(snapshot, await readMaintenancePid(paths.pidPath));
|
|
51
|
+
await verifyMaintenanceProcess(snapshot, paths, run);
|
|
52
|
+
Object.assign(snapshot, await maintenanceWorkload(snapshot, open));
|
|
53
|
+
const finalPid = await readMaintenancePid(paths.pidPath);
|
|
54
|
+
if (!sameFields(snapshot, finalPid, ["pid", "processStartTime"])) failMaintenance("service_identity_changed");
|
|
55
|
+
await verifyMaintenanceProcess(snapshot, paths, run);
|
|
56
|
+
snapshot.state = snapshot.busyThreads || snapshot.queuedRequests ? "busy" : "ready";
|
|
57
|
+
snapshot.reasonCode = snapshot.state === "busy" ? "service_busy" : null;
|
|
58
|
+
} catch (error) { snapshot.reasonCode = error.reasonCode ?? "maintenance_inspection_failed"; }
|
|
59
|
+
return snapshot;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function stopForMaintenance({ context, expected } = {}) {
|
|
63
|
+
let stopAttempted = false;
|
|
64
|
+
const refuseStop = (reasonCode, snapshot) => ({ ...refused(reasonCode, snapshot), stopAttempted });
|
|
65
|
+
if (!validApproval(expected)) return refuseStop("invalid_maintenance_approval");
|
|
66
|
+
try {
|
|
67
|
+
const current = await inspectMaintenance(context);
|
|
68
|
+
if (!sameMaintenanceIdentity(expected, current)) return refuseStop("service_identity_changed", current);
|
|
69
|
+
if (current.state !== "ready" || current.busyThreads !== 0 || current.queuedRequests !== 0) {
|
|
70
|
+
return refuseStop("service_busy", current);
|
|
71
|
+
}
|
|
72
|
+
const paths = await maintenanceContext(context);
|
|
73
|
+
stopAttempted = true;
|
|
74
|
+
const stopped = await run(current.cliPath, ["app-server", "daemon", "stop"], paths.options);
|
|
75
|
+
if (stopped.status !== 0) return refuseStop("daemon_stop_failed");
|
|
76
|
+
if (!await approvedProcessIsDead(expected, paths, run)) return refuseStop("daemon_death_unverified");
|
|
77
|
+
return { ok: true, reasonCode: null, stopAttempted };
|
|
78
|
+
} catch { return refuseStop("daemon_stop_failed"); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function startAfterMaintenance({ context, expected } = {}) {
|
|
82
|
+
if (!validApproval(expected)) return refused("invalid_maintenance_approval");
|
|
83
|
+
try {
|
|
84
|
+
const pinned = { ...context, env: { ...(context?.env ?? process.env), CODEX_HOME: expected.codexHome } };
|
|
85
|
+
const paths = await maintenanceContext(pinned);
|
|
86
|
+
if (paths.platform !== "darwin-arm64") return refused("maintenance_platform_unsupported");
|
|
87
|
+
const install = { ...paths, ...await probeMaintenanceInstall(paths, run) };
|
|
88
|
+
if (!sameFields(expected, install, installationKeys)) return refused("service_identity_changed");
|
|
89
|
+
const existing = await inspectMaintenance(pinned);
|
|
90
|
+
if (existing) {
|
|
91
|
+
if (["ready", "busy"].includes(existing.state) && sameFields(expected, existing, installationKeys)
|
|
92
|
+
&& existing.serverVersion === expected.cliVersion) return { ok: true, reasonCode: null, snapshot: existing };
|
|
93
|
+
return refused("service_identity_changed", existing);
|
|
94
|
+
}
|
|
95
|
+
if (!await approvedProcessIsDead(expected, paths, run)) return refused("daemon_death_unverified");
|
|
96
|
+
const started = await run(install.cliPath, ["app-server", "daemon", "start"], paths.options);
|
|
97
|
+
if (started.status !== 0) return refused("daemon_start_failed");
|
|
98
|
+
const current = await inspectMaintenance(pinned);
|
|
99
|
+
if (!current || !["ready", "busy"].includes(current.state)
|
|
100
|
+
|| !sameFields(expected, current, installationKeys) || current.serverVersion !== expected.cliVersion) {
|
|
101
|
+
return refused("daemon_start_unverified", current);
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, reasonCode: null, snapshot: current };
|
|
104
|
+
} catch { return refused("daemon_start_failed"); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { inspectMaintenance, stopForMaintenance, startAfterMaintenance };
|
|
108
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { realpath } from "node:fs/promises";
|
|
2
2
|
import { decisionBody } from "@agents-can-communicate/adapter-sdk";
|
|
3
3
|
|
|
4
|
-
import { MINIMUM_VERSION, PROTOCOL_CONTRACT, QUEUE_MODES,
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { CODEX_QUEUE_MINIMUM, MINIMUM_VERSION, PROTOCOL_CONTRACT, QUEUE_MODES,
|
|
5
|
+
addCodexQueueMessage, canonicalCwd, compareStableVersions, controlSocketPath,
|
|
6
|
+
locateCodexThread, openCodexAppServer, parseStableVersion, probeCodexQueue,
|
|
7
|
+
safeReason, serverVersionOf } from "./app-server-client.mjs";
|
|
7
8
|
import { newEndpointId, readNativeEndpoint, removeNativeEndpoint, socketIsReady,
|
|
8
9
|
writeNativeEndpoint } from "./native-endpoint.mjs";
|
|
9
10
|
|
|
@@ -62,13 +63,35 @@ export function planNativeActivation({ detection }) {
|
|
|
62
63
|
] };
|
|
63
64
|
}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
// A daemon that restarted onto a newer build still satisfies the captured
|
|
67
|
+
// contract. The thread it may have lost is reported separately by
|
|
68
|
+
// locateCodexThread, so refusing on the version alone only hides the real
|
|
69
|
+
// reason. Returns the version that actually answered as `servingVersion`
|
|
70
|
+
// (null when refused) instead of mutating the endpoint it was handed, so a
|
|
71
|
+
// caller can decide what to persist on the one path that owns persistence,
|
|
72
|
+
// after its own validation has already run.
|
|
73
|
+
export async function verifyReceiver(peer, endpoint, { probe = probeCodexQueue,
|
|
74
|
+
locate = locateCodexThread } = {}) {
|
|
75
|
+
const result = await probe(peer, { threadId: endpoint.threadId });
|
|
76
|
+
if (!result.supported) {
|
|
77
|
+
// probeCodexQueue applies the same floor and answers first, so a daemon
|
|
78
|
+
// serving below the minimum arrives here as a refused probe rather than
|
|
79
|
+
// reaching the explicit check below. Reporting that as a protocol
|
|
80
|
+
// mismatch named the wrong condition everywhere it surfaced - doctor,
|
|
81
|
+
// the offer's safe error code, the recorded failure - and left the
|
|
82
|
+
// version answer this function already knows how to give unreachable.
|
|
83
|
+
return { reasonCode: result.reasonCode === "probe_timeout" ? "handshake_timeout"
|
|
84
|
+
: result.reasonCode === "below_minimum_version" ? "handshake_version_mismatch"
|
|
85
|
+
: "protocol_mismatch", servingVersion: null };
|
|
86
|
+
}
|
|
87
|
+
if (compareStableVersions(result.serverVersion, CODEX_QUEUE_MINIMUM) < 0) {
|
|
88
|
+
return { reasonCode: "handshake_version_mismatch", servingVersion: null };
|
|
89
|
+
}
|
|
90
|
+
const located = await locate(peer, { threadId: endpoint.threadId, cwd: endpoint.cwd });
|
|
91
|
+
return located.found
|
|
92
|
+
? { reasonCode: null, servingVersion: result.serverVersion }
|
|
93
|
+
: { reasonCode: located.reasonCode === "cwd_mismatch" ? "workspace_identity_unavailable" : "handshake_failed",
|
|
94
|
+
servingVersion: null };
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
export async function bindNativeSession({ event, clientPid, clientVersion, runtimeDir,
|
|
@@ -86,20 +109,41 @@ export async function bindNativeSession({ event, clientPid, clientVersion, runti
|
|
|
86
109
|
const endpoint = { schemaVersion: 1, endpointId: newEndpointId(), socketPath,
|
|
87
110
|
threadId: event.sessionId, cwd, clientVersion, protocolContract: PROTOCOL_CONTRACT,
|
|
88
111
|
leaseUntil: new Date(now() + LEASE_MS).toISOString() };
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
112
|
+
const { reasonCode, servingVersion } = await verifyReceiver(peer, endpoint);
|
|
113
|
+
if (reasonCode !== null) return rejected(reasonCode);
|
|
114
|
+
// The daemon may have restarted onto a different build since the
|
|
115
|
+
// caller's claimed clientVersion was captured; persist what actually
|
|
116
|
+
// answered so the binding and its later reads reflect the serving
|
|
117
|
+
// process, not the caller's claim.
|
|
118
|
+
const verified = { ...endpoint, clientVersion: servingVersion };
|
|
119
|
+
await writeNativeEndpoint({ runtimeDir, record: verified });
|
|
120
|
+
return handshake(verified, now);
|
|
93
121
|
});
|
|
94
122
|
} catch (error) {
|
|
95
123
|
return rejected(error?.code === "ETIMEDOUT" ? "handshake_timeout" : "handshake_failed");
|
|
96
124
|
}
|
|
97
125
|
}
|
|
98
126
|
|
|
127
|
+
// Resolving the binding's opaque reference is the whole of the identity check:
|
|
128
|
+
// the endpoint id is 128 random bits minted per bind, readNativeEndpoint
|
|
129
|
+
// re-checks that the record it read carries that exact id, and no two bindings
|
|
130
|
+
// can name the same record.
|
|
131
|
+
//
|
|
132
|
+
// The two records' clientVersion snapshots used to be compared here as well.
|
|
133
|
+
// That was never identity - the id already settles that - it was a third
|
|
134
|
+
// version rule, and the only one applied to a stored snapshot rather than to
|
|
135
|
+
// the process now answering. It is deliberately gone. It cannot refuse
|
|
136
|
+
// anything verifyReceiver does not refuse better two lines on, against the
|
|
137
|
+
// version that actually answers, with the thread and cwd, before anything is
|
|
138
|
+
// queued; and against the snapshot it could only ever be vacuous, because a
|
|
139
|
+
// record is written solely from a serving version that already cleared
|
|
140
|
+
// CODEX_QUEUE_MINIMUM. What it did do was fire on the case 0.5.0 exists to
|
|
141
|
+
// support: the endpoint records what the daemon serves, the binding recorded
|
|
142
|
+
// what `codex --version` printed, and a daemon that had updated under its CLI
|
|
143
|
+
// was refused here on that difference alone.
|
|
99
144
|
async function receiverFor(binding, runtimeDir) {
|
|
100
145
|
const endpoint = await readNativeEndpoint({ runtimeDir, endpointId: binding?.opaqueEndpointRef });
|
|
101
|
-
return endpoint
|
|
102
|
-
&& await socketIsReady(endpoint?.socketPath) ? endpoint : null;
|
|
146
|
+
return endpoint !== null && await socketIsReady(endpoint.socketPath) ? endpoint : null;
|
|
103
147
|
}
|
|
104
148
|
|
|
105
149
|
export async function refreshNativeSession({ binding, runtimeDir, timeoutMs = 750,
|
|
@@ -109,8 +153,9 @@ export async function refreshNativeSession({ binding, runtimeDir, timeoutMs = 75
|
|
|
109
153
|
if (endpoint === null) return rejected("handshake_failed");
|
|
110
154
|
try {
|
|
111
155
|
return await usingPeer(endpoint.socketPath, timeoutMs, open, async peer => {
|
|
112
|
-
const
|
|
113
|
-
return
|
|
156
|
+
const { reasonCode, servingVersion } = await verifyReceiver(peer, endpoint);
|
|
157
|
+
return reasonCode === null
|
|
158
|
+
? handshake({ ...endpoint, clientVersion: servingVersion }, now) : rejected(reasonCode);
|
|
114
159
|
});
|
|
115
160
|
} catch (error) {
|
|
116
161
|
return rejected(error?.code === "ETIMEDOUT" ? "handshake_timeout" : "handshake_failed");
|
|
@@ -125,12 +170,12 @@ export async function offerMessage({ binding, message, runtimeDir, timeoutMs = 5
|
|
|
125
170
|
if (endpoint === null) return rejected("recipient_unavailable");
|
|
126
171
|
try {
|
|
127
172
|
return await usingPeer(endpoint.socketPath, timeoutMs, open, async peer => {
|
|
128
|
-
const
|
|
129
|
-
if (
|
|
130
|
-
:
|
|
173
|
+
const { reasonCode, servingVersion } = await verifyReceiver(peer, endpoint);
|
|
174
|
+
if (reasonCode !== null) return rejected(reasonCode === "handshake_timeout" ? "transport_error"
|
|
175
|
+
: reasonCode === "handshake_version_mismatch" ? "unsupported_client_version" : "recipient_unavailable");
|
|
131
176
|
await addCodexQueueMessage(peer, { threadId: endpoint.threadId,
|
|
132
177
|
messageId: message.messageId, text: renderText(message) });
|
|
133
|
-
return { accepted: true, transport: "codex-app-server", clientVersion:
|
|
178
|
+
return { accepted: true, transport: "codex-app-server", clientVersion: servingVersion };
|
|
134
179
|
});
|
|
135
180
|
} catch (error) {
|
|
136
181
|
if (["EPERM", "EACCES"].includes(error?.code)) return rejected("transport_permission_denied");
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export { CAPABILITY_SHAPE, assertCapabilities, defineAdapter } from "./capabilities.mjs";
|
|
4
4
|
export { effectiveCapabilities, validateCertification } from "./certification.mjs";
|
|
5
5
|
export { NATIVE_ACTIVATION_KINDS, NATIVE_BINDING_MODES, NATIVE_PLATFORMS, NATIVE_REASON_CODES,
|
|
6
|
-
compareStableVersions, evaluateNativeEligibility, parseStableVersion,
|
|
6
|
+
compareStableVersions, evaluateNativeEligibility, evaluateVersionContract, parseStableVersion,
|
|
7
7
|
validateNativeActivationPlan, validateNativeDeliveryContract, validateNativeHandshake }
|
|
8
8
|
from "./native-delivery.mjs";
|
|
9
9
|
export { EVENT_KINDS, NORMALIZED_EVENT_KEYS, normalizedEvent } from "./events.mjs";
|