agents-can-communicate 0.4.2 → 0.4.4
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 +12 -4
- package/bin/acc-maintenance-worker.mjs +6 -0
- package/docs/ARCHITECTURE.md +12 -5
- package/docs/CLI.md +13 -6
- package/docs/CONCEPTS.md +2 -1
- package/docs/CONFIGURATION.md +27 -1
- package/docs/GETTING_STARTED.md +10 -4
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/PROTOCOL.md +4 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +19 -5
- package/docs/TROUBLESHOOTING.md +22 -4
- package/docs/UPGRADING.md +60 -14
- 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-claude-code/src/channel.mjs +2 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +6 -2
- package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +4 -88
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -15
- package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +110 -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 +1 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/toml-scan.mjs +99 -0
- 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/channel-directory.mjs +8 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
- 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 +5 -4
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +3 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +8 -4
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +45 -7
- 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 +35 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +40 -8
- 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 +110 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +92 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +71 -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/schedule.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +16 -4
- package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +1 -1
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +1 -0
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +4 -2
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +5 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -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/package.json +2 -1
|
@@ -3,10 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { BEGIN, END, renderBlock } from "@agents-can-communicate/adapter-sdk";
|
|
4
4
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
+ "repair ambiguous TOML or ACC markers before retrying", { reason });
|
|
9
|
-
};
|
|
6
|
+
import { statements, declaration, unsafe } from "./toml-scan.mjs";
|
|
7
|
+
|
|
10
8
|
const ROOT = "# ACC sandbox writable_roots created with: ";
|
|
11
9
|
export const sandboxOwnership = root => `${ROOT}${JSON.stringify(root)}`;
|
|
12
10
|
|
|
@@ -16,88 +14,6 @@ const OWNED = new Map([
|
|
|
16
14
|
[JSON.stringify(["sandbox_workspace_write"]), ["writable_roots"]],
|
|
17
15
|
]);
|
|
18
16
|
|
|
19
|
-
// A bounded lexical scan, not a TOML value parser. Newlines inside strings,
|
|
20
|
-
// arrays and inline tables cannot introduce a header or a managed marker.
|
|
21
|
-
function* statements(source) {
|
|
22
|
-
let start = 0, code = "", quote = null;
|
|
23
|
-
const stack = [];
|
|
24
|
-
for (let i = 0; i < source.length;) {
|
|
25
|
-
const char = source[i];
|
|
26
|
-
if (quote) {
|
|
27
|
-
if (quote[0] === '"' && char === "\\") {
|
|
28
|
-
code += source.slice(i, i + 2); i += 2; continue;
|
|
29
|
-
}
|
|
30
|
-
if (source.startsWith(quote, i)) {
|
|
31
|
-
let count = quote.length;
|
|
32
|
-
if (quote.length === 3) {
|
|
33
|
-
while (source[i + count] === quote[0]) count++;
|
|
34
|
-
if (count > 5) unsafe("unsupported string quote delimiter");
|
|
35
|
-
}
|
|
36
|
-
code += source.slice(i, i + count); i += count; quote = null; continue;
|
|
37
|
-
}
|
|
38
|
-
if (quote.length === 1 && /[\r\n]/.test(char)) unsafe("newline in single-line string");
|
|
39
|
-
code += char; i++; continue;
|
|
40
|
-
}
|
|
41
|
-
if (char === "#") {
|
|
42
|
-
while (i < source.length && source[i] !== "\n") i++;
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
if (char === '"' || char === "'") {
|
|
46
|
-
quote = source.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
|
|
47
|
-
code += quote; i += quote.length; continue;
|
|
48
|
-
}
|
|
49
|
-
if (char === "[" || char === "{") stack.push(char);
|
|
50
|
-
if (char === "]" || char === "}") {
|
|
51
|
-
if (stack.pop() !== (char === "]" ? "[" : "{")) unsafe("mismatched collection delimiter");
|
|
52
|
-
}
|
|
53
|
-
code += char; i++;
|
|
54
|
-
if (char === "\n" && stack.length === 0) {
|
|
55
|
-
yield { raw: source.slice(start, i), code: code.trim() };
|
|
56
|
-
start = i; code = "";
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
if (quote || stack.length) unsafe(quote ? "unclosed string" : "unclosed collection");
|
|
60
|
-
if (start < source.length) yield { raw: source.slice(start), code: code.trim() };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function decodeKey(key) {
|
|
64
|
-
if (key[0] === "'") return key.slice(1, -1);
|
|
65
|
-
if (key[0] !== '"') return key;
|
|
66
|
-
return key.slice(1, -1).replace(/\\(U[\da-fA-F]{8}|u[\da-fA-F]{4}|[btnfr"\\])|\\./g,
|
|
67
|
-
(match, escape) => {
|
|
68
|
-
if (!escape) unsafe("unsupported quoted-key escape");
|
|
69
|
-
if (/^[uU]/.test(escape)) {
|
|
70
|
-
const point = Number.parseInt(escape.slice(1), 16);
|
|
71
|
-
if (point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) {
|
|
72
|
-
unsafe("invalid quoted-key Unicode escape");
|
|
73
|
-
}
|
|
74
|
-
return String.fromCodePoint(point);
|
|
75
|
-
}
|
|
76
|
-
return ({ b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" })[escape];
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function keyPath(code) {
|
|
81
|
-
const keys = [];
|
|
82
|
-
let rest = code.trimStart();
|
|
83
|
-
for (;;) {
|
|
84
|
-
const match = /^(?:[A-Za-z0-9_-]+|'[^'\r\n]*'|"(?:[^"\\\r\n]|\\[^\r\n])*")/.exec(rest);
|
|
85
|
-
if (!match) unsafe("unrecognized TOML key");
|
|
86
|
-
keys.push(decodeKey(match[0]));
|
|
87
|
-
rest = rest.slice(match[0].length).trimStart();
|
|
88
|
-
if (!rest.startsWith(".")) return { keys, rest };
|
|
89
|
-
rest = rest.slice(1).trimStart();
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function declaration(code) {
|
|
94
|
-
const header = code.startsWith("[");
|
|
95
|
-
const array = header && code.startsWith("[[");
|
|
96
|
-
const { keys, rest } = keyPath(header ? code.slice(array ? 2 : 1) : code);
|
|
97
|
-
if (header ? rest !== (array ? "]]" : "]") : !rest.startsWith("=")) unsafe();
|
|
98
|
-
return { keys, header, array, value: header ? null : rest.slice(1).trim() };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
17
|
// An unchanged, newly generated sandbox has value provenance. A legacy or
|
|
102
18
|
// edited sandbox belongs to the client as a whole and remains byte-preserved.
|
|
103
19
|
function ownsSandbox(body) {
|
|
@@ -214,8 +130,8 @@ export async function readConfig(file) {
|
|
|
214
130
|
});
|
|
215
131
|
}
|
|
216
132
|
|
|
217
|
-
export async function writeTomlBlock(file, body) {
|
|
218
|
-
const
|
|
133
|
+
export async function writeTomlBlock(file, body, preparedSource) {
|
|
134
|
+
const source = preparedSource ?? inspectConfig(await readConfig(file), file).source;
|
|
219
135
|
const separator = source === "" || source.endsWith("\n") ? "" : "\n";
|
|
220
136
|
await mkdir(path.dirname(file), { recursive: true });
|
|
221
137
|
await writeFile(file, `${source}${separator}${renderBlock(body)}\n`);
|
|
@@ -9,7 +9,8 @@ import { bakeSkillCommand, blankJson, blankText, removeIfEmpty, removeInstalledT
|
|
|
9
9
|
tomlString, writeForeignJson, writeHookShim }
|
|
10
10
|
from "@agents-can-communicate/adapter-sdk";
|
|
11
11
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
12
|
-
import { inspectConfig, readConfig,
|
|
12
|
+
import { inspectConfig, readConfig, sandboxOwnership, writeTomlBlock } from "./config-block.mjs";
|
|
13
|
+
import { outgoingStatus, prepareLivePermissions, removeLivePermissions } from "./live-permissions.mjs";
|
|
13
14
|
|
|
14
15
|
// Kept cohesive above 300 lines because Codex plugin install, cache, config,
|
|
15
16
|
// detection, and ownership share one client topology; splitting would duplicate
|
|
@@ -159,7 +160,8 @@ const sandboxReview = (config, file, stateRoot) =>
|
|
|
159
160
|
: [];
|
|
160
161
|
|
|
161
162
|
export async function installCodexPlugin({ home, agentsHome = home,
|
|
162
|
-
codexHome = path.join(home, ".codex"), dataHome, stateRoot, runner, node, cli, preserveVersions = false
|
|
163
|
+
codexHome = path.join(home, ".codex"), dataHome, stateRoot, runner, node, cli, preserveVersions = false,
|
|
164
|
+
requestedLivePolicy, livePolicy, clientVersion, platform }) {
|
|
163
165
|
// Read before writing, so a manifest that will not parse is found before a
|
|
164
166
|
// plugin tree is laid down that nothing will then be able to remove.
|
|
165
167
|
const existing = await readJson(marketplacePath(agentsHome), { name: MARKETPLACE,
|
|
@@ -179,7 +181,10 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
179
181
|
`marketplace ${MARKETPLACE} or its plugin is already registered in this config; `
|
|
180
182
|
+ "remove it and install again", { config });
|
|
181
183
|
}
|
|
182
|
-
const
|
|
184
|
+
const permissions = prepareLivePermissions(foreign.source, { home, codexHome, stateRoot,
|
|
185
|
+
file: config, requestedLivePolicy, livePolicy, clientVersion, platform });
|
|
186
|
+
const theirSandbox = permissions.skipLegacy || inspectConfig(permissions.source, config).sandbox;
|
|
187
|
+
const permissionActions = permissions.status?.state === "unverified" ? [permissions.status.diagnostic] : [];
|
|
183
188
|
|
|
184
189
|
const target = pluginPath(agentsHome);
|
|
185
190
|
await rm(target, { recursive: true, force: true });
|
|
@@ -207,7 +212,7 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
207
212
|
`[plugins.${tomlString(QUALIFIED)}]`,
|
|
208
213
|
"enabled = true",
|
|
209
214
|
...sandboxTable(stateRoot, theirSandbox),
|
|
210
|
-
]);
|
|
215
|
+
], permissions.source);
|
|
211
216
|
|
|
212
217
|
// The client runs the cached copy, so this has to happen after the shim and
|
|
213
218
|
// the rewritten hooks.json are in place.
|
|
@@ -235,9 +240,11 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
235
240
|
// while ACC invented its own marketplace name and so had a root to itself.
|
|
236
241
|
// make the record stale the moment the plugin version changes.
|
|
237
242
|
return { ok: true, changes: [target, file, config, cachePath(codexHome)],
|
|
238
|
-
needsAction: [HOOK_REVIEW, ...
|
|
243
|
+
needsAction: [HOOK_REVIEW, ...permissionActions,
|
|
244
|
+
...(permissions.skipLegacy ? [] : sandboxReview(before, config, stateRoot))],
|
|
239
245
|
diagnostics: ["hooks require explicit trust in Codex before they run",
|
|
240
|
-
...
|
|
246
|
+
...(permissions.status ? [permissions.status.diagnostic] : []),
|
|
247
|
+
...(permissions.skipLegacy ? [] : sandboxReview(before, config, stateRoot))] };
|
|
241
248
|
}
|
|
242
249
|
|
|
243
250
|
/** Remove each directory that is empty, in the order given. */
|
|
@@ -255,21 +262,25 @@ export async function preflightCodexUninstall({ home, agentsHome = home,
|
|
|
255
262
|
if (error.code === "ENOENT") return "";
|
|
256
263
|
throw error;
|
|
257
264
|
});
|
|
258
|
-
|
|
265
|
+
const permissions = removeLivePermissions(inspectConfig(before, configPath(codexHome)).source);
|
|
266
|
+
return { existing, before, withoutOurs: permissions.source, permissionState: permissions.state };
|
|
259
267
|
}
|
|
260
268
|
|
|
261
269
|
export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
262
270
|
codexHome = path.join(home, ".codex"), keep = [] }) {
|
|
263
271
|
const file = marketplacePath(agentsHome);
|
|
264
272
|
// Direct adapter callers need the same check as the installer boundary.
|
|
265
|
-
const { existing, withoutOurs } = await preflightCodexUninstall({ home, agentsHome, codexHome });
|
|
273
|
+
const { existing, before, withoutOurs, permissionState } = await preflightCodexUninstall({ home, agentsHome, codexHome });
|
|
266
274
|
const changes = [];
|
|
267
275
|
if (existing !== null) {
|
|
268
276
|
const kept = (existing.plugins ?? []).filter(entry => entry.name !== PLUGIN_NAME);
|
|
269
277
|
if (kept.length !== (existing.plugins ?? []).length) changes.push(PLUGIN_NAME);
|
|
270
278
|
await writeMarketplace(file, { ...existing, plugins: kept });
|
|
271
279
|
}
|
|
272
|
-
if (
|
|
280
|
+
if (before !== withoutOurs) {
|
|
281
|
+
await writeFile(configPath(codexHome), withoutOurs);
|
|
282
|
+
changes.push(configPath(codexHome));
|
|
283
|
+
}
|
|
273
284
|
// The client's `[hooks.state."<plugin>:…"]` tables stay. 0.1.9 removed them as
|
|
274
285
|
// litter naming a plugin that was gone; that was wrong, and wrong in a way
|
|
275
286
|
// worth writing down. The check behind it perturbed the record - a hook whose
|
|
@@ -312,13 +323,16 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
|
312
323
|
marketplaceRoot(agentsHome),
|
|
313
324
|
cacheRoot(codexHome),
|
|
314
325
|
]);
|
|
315
|
-
|
|
316
|
-
? ["
|
|
317
|
-
|
|
326
|
+
const permissionReview = permissionState === "customized"
|
|
327
|
+
? ["customized native permissions were preserved together with their default selection and network proxy; review them in Codex config"] : [];
|
|
328
|
+
return { ok: true, changes, needsAction: permissionReview, diagnostics: [
|
|
329
|
+
...(declaresSandbox(withoutOurs)
|
|
330
|
+
? ["existing sandbox_workspace_write configuration was preserved; review any retained writable_roots after removal"] : []),
|
|
331
|
+
...permissionReview] };
|
|
318
332
|
}
|
|
319
333
|
|
|
320
334
|
export async function detectCodex({ home, agentsHome = home,
|
|
321
|
-
codexHome = path.join(home, ".codex"), stateRoot }) {
|
|
335
|
+
codexHome = path.join(home, ".codex"), stateRoot, clientVersion, platform, nativeDelivery }) {
|
|
322
336
|
const marketplace = await readJson(marketplacePath(agentsHome), null);
|
|
323
337
|
const published = (marketplace?.plugins ?? []).some(entry => entry.name === PLUGIN_NAME);
|
|
324
338
|
const config = await readFile(configPath(codexHome), "utf8").catch(() => "");
|
|
@@ -326,11 +340,17 @@ export async function detectCodex({ home, agentsHome = home,
|
|
|
326
340
|
const pluginEntry = config.includes(`[plugins."${QUALIFIED}"]`);
|
|
327
341
|
const cached = await stat(cachePath(codexHome))
|
|
328
342
|
.then(() => true).catch(() => false);
|
|
343
|
+
const outgoingDelivery = outgoingStatus(config, { home, codexHome, stateRoot,
|
|
344
|
+
file: configPath(codexHome), clientVersion, platform });
|
|
329
345
|
// Saved trust is not readiness. Codex compares every current definition's
|
|
330
346
|
// hash and can disable a trusted hook. Even a commented or stale single record
|
|
331
347
|
// previously suppressed this check. Leave verification to Codex's /hooks;
|
|
332
348
|
// detection neither invents its hash algorithm nor starts a client service.
|
|
333
|
-
return { ok: true, changes: [],
|
|
349
|
+
return { ok: true, changes: [], outgoingDelivery,
|
|
350
|
+
nativeSetup: nativeDelivery?.reasonCode === "native_endpoint_unavailable"
|
|
351
|
+
? "Codex CLI: run codex app-server daemon start, then open a new Codex session; ACC never starts or restarts the daemon"
|
|
352
|
+
: null,
|
|
353
|
+
diagnostics: [
|
|
334
354
|
published ? "acc plugin published in the marketplace" : "acc plugin not registered",
|
|
335
355
|
registered && pluginEntry
|
|
336
356
|
? "marketplace and plugin entries found in config; activation not verified"
|
|
@@ -347,7 +367,8 @@ export async function detectCodex({ home, agentsHome = home,
|
|
|
347
367
|
: []),
|
|
348
368
|
],
|
|
349
369
|
needsAction: cached
|
|
350
|
-
? [HOOK_REVIEW, ...sandboxReview(config, configPath(codexHome), stateRoot)
|
|
370
|
+
? [HOOK_REVIEW, ...sandboxReview(config, configPath(codexHome), stateRoot),
|
|
371
|
+
...(outgoingDelivery.state === "unverified" ? [outgoingDelivery.diagnostic] : [])]
|
|
351
372
|
: [] };
|
|
352
373
|
}
|
|
353
374
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { channelSocketDirectory, tomlString } from "@agents-can-communicate/adapter-sdk";
|
|
3
|
+
import { compareStableVersions, parseStableVersion } from "./app-server-client.mjs";
|
|
4
|
+
import { addPermissions, inspectPermissions } from "./permission-ownership.mjs";
|
|
5
|
+
import { scanConfig } from "./toml-scan.mjs";
|
|
6
|
+
|
|
7
|
+
const PROFILE = "acc-workspace";
|
|
8
|
+
const MINIMUM = "0.153.4";
|
|
9
|
+
const equal = (keys, expected) => JSON.stringify(keys) === JSON.stringify(expected);
|
|
10
|
+
const literal = value => { try { return JSON.parse(value); } catch { return null; } };
|
|
11
|
+
const supported = context => context.platform === "darwin-arm64"
|
|
12
|
+
&& parseStableVersion(context.clientVersion) !== null
|
|
13
|
+
&& compareStableVersions(context.clientVersion, MINIMUM) >= 0;
|
|
14
|
+
const hasPermissions = entries => entries.some(entry =>
|
|
15
|
+
["permissions", "default_permissions", "profile", "profiles"].includes(entry.keys[0]));
|
|
16
|
+
const configured = (source, context) => {
|
|
17
|
+
const entries = scanConfig(source);
|
|
18
|
+
if (entries.some(entry => ["profile", "profiles"].includes(entry.keys[0]))) return false;
|
|
19
|
+
const value = keys => entries.find(entry => !entry.header && equal(entry.keys, keys))?.value;
|
|
20
|
+
const profile = literal(value(["default_permissions"]));
|
|
21
|
+
if (!profile || value(["sandbox_mode"]) || entries.some(entry => entry.keys[0] === "sandbox_workspace_write")) return false;
|
|
22
|
+
return literal(value(["permissions", profile, "extends"])) === ":workspace"
|
|
23
|
+
&& value(["features", "network_proxy"]) === "true"
|
|
24
|
+
&& value(["permissions", profile, "network", "enabled"]) === "true"
|
|
25
|
+
&& literal(value(["permissions", profile, "filesystem", context.stateRoot])) === "write"
|
|
26
|
+
&& sockets(context).every(socket => literal(value(["permissions", profile, "network", "unix_sockets", socket])) === "allow");
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Match the channel's per-user temporary directory and Codex's actual home.
|
|
30
|
+
// Grants cover only ACC's local channel namespace, never arbitrary Unix sockets.
|
|
31
|
+
const sockets = context => [channelSocketDirectory(),
|
|
32
|
+
path.join(context.codexHome ?? path.join(context.home, ".codex"), "app-server-control", "app-server-control.sock")];
|
|
33
|
+
|
|
34
|
+
export function outgoingStatus(source, context) {
|
|
35
|
+
const state = inspectPermissions(source).state;
|
|
36
|
+
const ready = state === "owned" && supported(context) && configured(source, context);
|
|
37
|
+
const reasonCode = state === "customized" ? "permission_configuration_modified"
|
|
38
|
+
: !supported(context) ? "permission_configuration_uncaptured"
|
|
39
|
+
: ready ? null : "sender_permissions_unverified";
|
|
40
|
+
return { state: reasonCode === null ? "configured" : "unverified", reasonCode,
|
|
41
|
+
setup: supported(context)
|
|
42
|
+
? "Codex outgoing setup: default workspace settings gain ACC state write access and an ACC local socket allowlist "
|
|
43
|
+
+ "through the network proxy (external network stays denied); custom policies are preserved. Start a new session after installation."
|
|
44
|
+
: null,
|
|
45
|
+
diagnostic: reasonCode === null
|
|
46
|
+
? "outgoing live delivery: local socket permissions configured for new sessions; active session overrides remain unverified"
|
|
47
|
+
: `outgoing live delivery: sender permissions unverified in ${context.file}; `
|
|
48
|
+
+ (reasonCode === "permission_configuration_uncaptured"
|
|
49
|
+
? `automatic setup requires Codex ${MINIMUM} or newer on darwin-arm64`
|
|
50
|
+
: "existing permissions were preserved; run acc install --adapter codex, then start a new session; "
|
|
51
|
+
+ "custom permission policies must allow ACC state and local sockets through the network proxy") };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function prepareLivePermissions(source, context) {
|
|
55
|
+
const owned = inspectPermissions(source);
|
|
56
|
+
const requested = (context.requestedLivePolicy ?? context.livePolicy ?? "off") !== "off";
|
|
57
|
+
if (owned.state === "customized") return { source, skipLegacy: true, status: outgoingStatus(source, context) };
|
|
58
|
+
if (!requested) return { source: owned.source, skipLegacy: hasPermissions(scanConfig(owned.source)) };
|
|
59
|
+
if (!supported(context) || !context.stateRoot) {
|
|
60
|
+
return { source, skipLegacy: hasPermissions(scanConfig(source)), status: outgoingStatus(source, context) };
|
|
61
|
+
}
|
|
62
|
+
if (owned.state === "owned" && configured(source, context)) {
|
|
63
|
+
return { source, skipLegacy: true, status: outgoingStatus(source, context) };
|
|
64
|
+
}
|
|
65
|
+
source = owned.source;
|
|
66
|
+
const entries = scanConfig(source);
|
|
67
|
+
const find = keys => entries.filter(entry => equal(entry.keys, keys));
|
|
68
|
+
const modes = find(["sandbox_mode"]), defaults = find(["default_permissions"]);
|
|
69
|
+
const legacy = entries.filter(entry => entry.keys[0] === "sandbox_workspace_write");
|
|
70
|
+
const features = entries.filter(entry => entry.keys[0] === "features");
|
|
71
|
+
const featureTable = find(["features"]);
|
|
72
|
+
const proxies = find(["features", "network_proxy"]);
|
|
73
|
+
const customized = entries.some(entry => ["permissions", "profile", "profiles"].includes(entry.keys[0]))
|
|
74
|
+
|| modes.length > 1 || defaults.length > 1
|
|
75
|
+
|| modes.some(entry => entry.header || literal(entry.value) !== "workspace-write")
|
|
76
|
+
|| defaults.some(entry => entry.header || literal(entry.value) !== ":workspace")
|
|
77
|
+
|| (legacy.length > 0 && (legacy.length !== 2 || !legacy[0].header || legacy[0].array
|
|
78
|
+
|| !equal(legacy[1].keys, ["sandbox_workspace_write", "writable_roots"])
|
|
79
|
+
|| JSON.stringify(literal(legacy[1].value)) !== JSON.stringify([context.stateRoot])))
|
|
80
|
+
|| (features.length > 0 && (featureTable.length !== 1 || !featureTable[0].header || featureTable[0].array))
|
|
81
|
+
|| proxies.length > 1 || proxies.some(entry => entry.header || !["true", "false"].includes(entry.value));
|
|
82
|
+
if (customized) return { source, skipLegacy: hasPermissions(entries), status: outgoingStatus(source, context) };
|
|
83
|
+
const edits = [{ id: "selection", start: 0, end: 0, body: `default_permissions = "${PROFILE}"\n` }];
|
|
84
|
+
for (const [id, values] of [["mode", modes], ["default", defaults]]) {
|
|
85
|
+
if (values.length) edits.push({ id, start: values[0].start, end: values[0].end, body: "" });
|
|
86
|
+
}
|
|
87
|
+
if (legacy.length) {
|
|
88
|
+
const end = entries.find(entry => entry.header && entry.start > legacy[0].start)?.start ?? source.length;
|
|
89
|
+
edits.push({ id: "legacy", start: legacy[0].start, end, body: "" });
|
|
90
|
+
}
|
|
91
|
+
const proxy = proxies[0], table = featureTable[0];
|
|
92
|
+
edits.push({ id: "proxy", start: proxy?.start ?? table?.end ?? source.length,
|
|
93
|
+
end: proxy?.end ?? table?.end ?? source.length, table: table ? ["features"] : null,
|
|
94
|
+
body: `${table ? "" : "[features]\n"}network_proxy = true\n` });
|
|
95
|
+
// Preserve a final line without a newline through the same ownership unit.
|
|
96
|
+
const last = entries.at(-1);
|
|
97
|
+
if (last && !source.endsWith("\n") && !edits.some(edit => edit.start <= last.start && edit.end >= last.end)) {
|
|
98
|
+
edits.push({ id: "tail", start: last.start, end: last.end, body: `${last.raw}\n` });
|
|
99
|
+
}
|
|
100
|
+
edits.push({ id: "profile", start: source.length, end: source.length, body:
|
|
101
|
+
`[permissions.${PROFILE}]\nextends = ":workspace"\n`
|
|
102
|
+
+ `[permissions.${PROFILE}.filesystem]\n${tomlString(context.stateRoot)} = "write"\n`
|
|
103
|
+
+ `[permissions.${PROFILE}.network]\nenabled = true\n`
|
|
104
|
+
+ `[permissions.${PROFILE}.network.unix_sockets]\n`
|
|
105
|
+
+ sockets(context).map(socket => `${tomlString(socket)} = "allow"\n`).join("") });
|
|
106
|
+
const prepared = addPermissions(source, edits);
|
|
107
|
+
return { source: prepared, skipLegacy: true, status: outgoingStatus(prepared, context) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const removeLivePermissions = source => inspectPermissions(source);
|
|
@@ -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
|
+
}
|
|
@@ -133,6 +133,7 @@ export async function offerMessage({ binding, message, runtimeDir, timeoutMs = 5
|
|
|
133
133
|
return { accepted: true, transport: "codex-app-server", clientVersion: endpoint.clientVersion };
|
|
134
134
|
});
|
|
135
135
|
} catch (error) {
|
|
136
|
+
if (["EPERM", "EACCES"].includes(error?.code)) return rejected("transport_permission_denied");
|
|
136
137
|
const reason = safeReason(error);
|
|
137
138
|
return rejected(reason === "request_timeout" ? "transport_error"
|
|
138
139
|
: reason === "vendor_error" ? "transport_rejected" : "recipient_unavailable");
|