agents-can-communicate 0.8.0 → 0.8.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.
Files changed (78) hide show
  1. package/bin/entrypoints/acc-mcp.mjs +0 -1
  2. package/bin/entrypoints/acc.mjs +0 -1
  3. package/docs/ADAPTER_AUTHORING.md +50 -17
  4. package/docs/ARCHITECTURE.md +9 -6
  5. package/docs/CAPABILITIES.md +24 -14
  6. package/docs/CLI.md +22 -3
  7. package/docs/CONFIGURATION.md +73 -8
  8. package/docs/GETTING_STARTED.md +6 -3
  9. package/docs/HOW_IT_WORKS.md +3 -1
  10. package/docs/PROTOCOL.md +2 -2
  11. package/docs/RELEASING.md +6 -4
  12. package/docs/SECURITY_MODEL.md +12 -1
  13. package/docs/TROUBLESHOOTING.md +34 -7
  14. package/docs/UPGRADING.md +29 -1
  15. package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
  16. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +7 -0
  18. package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +5 -4
  19. package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +204 -0
  20. package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +45 -10
  21. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +14 -4
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +12 -17
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +5 -3
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-observed-directories.mjs +62 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +41 -0
  27. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +6 -4
  29. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +10 -13
  30. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +5 -4
  31. package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +69 -17
  32. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +46 -10
  33. package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +5 -7
  34. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +16 -12
  35. package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +12 -2
  36. package/node_modules/@agents-can-communicate/adapter-codex/src/service-setup.mjs +26 -9
  37. package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +12 -0
  38. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  39. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  40. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  41. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +7 -0
  44. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +2 -2
  45. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +16 -2
  46. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +4 -3
  47. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +85 -77
  48. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +4 -4
  49. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  50. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +9 -4
  51. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +11 -4
  52. package/node_modules/@agents-can-communicate/cli/src/main.mjs +49 -12
  53. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +9 -15
  54. package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +13 -4
  55. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +20 -10
  56. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  57. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +17 -2
  58. package/node_modules/@agents-can-communicate/core/src/status.mjs +26 -2
  59. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  60. package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +2 -2
  61. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +9 -41
  62. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  63. package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +30 -1
  64. package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +3 -4
  65. package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +30 -14
  66. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +12 -1
  67. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  68. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +0 -2
  69. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -7
  70. package/node_modules/@agents-can-communicate/installer/src/index.mjs +1 -0
  71. package/node_modules/@agents-can-communicate/installer/src/receiver-sockets.mjs +19 -0
  72. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  73. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  75. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +30 -4
  76. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +56 -25
  77. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +6 -2
  78. package/package.json +1 -1
@@ -0,0 +1,41 @@
1
+ import path from "node:path";
2
+
3
+ // Where Claude Code binds a session's inbox socket, read from the 2.1.283
4
+ // binary (2026-09-27):
5
+ //
6
+ // `${XDG_RUNTIME_DIR || CLAUDE_CODE_TMPDIR || "/tmp"}/cc-socks/<pid>.sock`
7
+ //
8
+ // and, when that path is longer than 103 bytes, `/tmp/cc-socks-<uid>/<pid>.sock`
9
+ // (`$PREFIX/tmp` when TERMUX_VERSION is set). Claude Code's own list of default
10
+ // socket directories adds `/run/user/<uid>/cc-socks` on Linux. Observed on
11
+ // macOS: `/tmp/cc-socks/<pid>.sock`.
12
+ //
13
+ // A sender in a sandbox - a Codex shell running `acc reply` - has to be allowed
14
+ // to connect here, or every live offer to a Claude Code session fails with
15
+ // EPERM and waits for the recipient's next turn. The grant is a directory; the
16
+ // adapter still checks Claude Code's session registry before every write, so a
17
+ // wider grant cannot wake a reused pid.
18
+ //
19
+ // A relative override is resolved against the Claude Code process's own working
20
+ // directory, which no installer can know, so it is left out. Native Windows
21
+ // serves the inbox on a named pipe, which no Unix socket grant covers.
22
+
23
+ const hostOf = platform => String(platform ?? process.platform).split("-")[0];
24
+ const set = value => typeof value === "string" && value !== "";
25
+
26
+ export function inboxSocketDirectories({ env = {}, platform, uid } = {}) {
27
+ const host = hostOf(platform);
28
+ if (host === "win32") return [];
29
+ const user = Number.isInteger(uid) ? uid : (process.getuid?.() ?? 0);
30
+ // Claude Code's `||`: an empty value falls through to the next one.
31
+ const root = [env.XDG_RUNTIME_DIR, env.CLAUDE_CODE_TMPDIR].find(set) ?? "/tmp";
32
+ const fallbackRoot = set(env.TERMUX_VERSION) && set(env.PREFIX)
33
+ ? path.join(env.PREFIX, "tmp") : "/tmp";
34
+ const directories = [
35
+ path.join(root, "cc-socks"),
36
+ "/tmp/cc-socks",
37
+ ...(host === "linux" ? [`/run/user/${user}/cc-socks`] : []),
38
+ path.join(fallbackRoot, `cc-socks-${user}`),
39
+ ];
40
+ return [...new Set(directories.filter(directory => path.isAbsolute(directory)))];
41
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-codex",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -11,6 +11,7 @@ import { allowOutcome, denyOutcome, injectOutcome, normalizeCodexHook }
11
11
  from "./hooks.mjs";
12
12
  import { planCodexInstall, detectCodex, installCodexPlugin, preflightCodexUninstall,
13
13
  uninstallCodexPlugin } from "./install.mjs";
14
+ import { controlSocket } from "./live-permissions.mjs";
14
15
  // Public native wiring is enabled only with installed-product capture evidence.
15
16
 
16
17
  // Re-exported so existing importers of CODEX_QUEUE_MINIMUM keep resolving; the
@@ -18,7 +19,7 @@ import { planCodexInstall, detectCodex, installCodexPlugin, preflightCodexUninst
18
19
  export { CODEX_QUEUE_MINIMUM };
19
20
  export const CODEX_VERSION = "0.147.0";
20
21
  export const CODEX_DELIVERY_FALLBACK = Object.freeze({
21
- diagnostic: "Codex native delivery requires codex-cli 0.152.1 or newer on darwin-arm64, "
22
+ diagnostic: "Codex native delivery requires codex-cli 0.152.1 or newer, "
22
23
  + "recorded recipient consent and a reachable LocalDaemon session with exact thread, cwd, "
23
24
  + "process, version and protocol verification. Embedded or unreachable sessions retain "
24
25
  + "durable messages. Message delivery does not start, restart or stop the vendor daemon and adds no "
@@ -61,13 +62,14 @@ export function createCodexAdapter() {
61
62
  // Native acceleration is a separate, per-session eligibility decision.
62
63
 
63
64
  nativeDelivery: {
64
- minimumByPlatform: { "darwin-arm64": CODEX_QUEUE_MINIMUM },
65
- anchors: [{ platform: "darwin-arm64", version: CODEX_QUEUE_MINIMUM,
66
- protocolContract: PROTOCOL_CONTRACT }], knownBad: [],
65
+ minimum: CODEX_QUEUE_MINIMUM,
66
+ anchors: [{ version: CODEX_QUEUE_MINIMUM, protocolContract: PROTOCOL_CONTRACT }], knownBad: [],
67
67
  activationKinds: ["native-service"], policySource: "installation-record",
68
68
  },
69
69
  probeNativeDelivery, planNativeActivation, bindNativeSession,
70
70
  refreshNativeSession, retireNativeSession, offerMessage,
71
+ // A Codex sender reaches another Codex session through its control socket.
72
+ inboundSockets: context => [controlSocket(context)],
71
73
  ...createCodexMaintenance(),
72
74
  ...createCodexServiceSetup(),
73
75
  startSession: async () => ({ ok: true, changes: [], diagnostics: [] }),
@@ -2,6 +2,8 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  import { realpath, stat } from "node:fs/promises";
4
4
 
5
+ import { compareVersionOrder, versionOrder } from "@agents-can-communicate/adapter-sdk";
6
+
5
7
  import { openWebSocketPeer } from "./ws-json-rpc.mjs";
6
8
 
7
9
  // The Codex App Server queue protocol, captured on codex-cli 0.152.1. Every
@@ -18,7 +20,6 @@ export const MINIMUM_VERSION = "0.152.1";
18
20
  export const CODEX_QUEUE_MINIMUM = MINIMUM_VERSION;
19
21
  export const QUEUE_MODES = Object.freeze(["livePush", "idleWake", "busyQueue"]);
20
22
  const CLIENT_INFO = Object.freeze({ name: "agents-can-communicate", version: "0.2.0" });
21
- const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
22
23
  const MAX_PAGES = 20;
23
24
  const METHOD_NOT_FOUND = -32601;
24
25
  const INVALID_REQUEST = -32600;
@@ -27,15 +28,11 @@ export const controlSocketPath = (env = process.env) =>
27
28
  path.join(env.CODEX_HOME ?? path.join(os.homedir(), ".codex"),
28
29
  "app-server-control", "app-server-control.sock");
29
30
 
30
- export function parseStableVersion(text) {
31
- return STABLE_VERSION.test(String(text ?? "")) ? String(text).split(".").map(Number) : null;
32
- }
33
- export function compareStableVersions(left, right) {
34
- const a = parseStableVersion(left);
35
- const b = parseStableVersion(right);
36
- for (let index = 0; index < 3; index += 1) if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
37
- return 0;
38
- }
31
+ // A version is judged by its release triple, the same order certification
32
+ // evidence uses: a prerelease daemon above the minimum is admitted by the queue
33
+ // method it answers with, not refused for its suffix. Unreadable orders as null.
34
+ export { versionOrder };
35
+ export const compareVersions = (left, right) => compareVersionOrder(versionOrder(left), versionOrder(right));
39
36
  export function serverVersionOf(userAgent) {
40
37
  return /^[^\s/]+\/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)(?=[\s(]|$)/
41
38
  .exec(String(userAgent ?? ""))?.[1] ?? null;
@@ -58,10 +55,10 @@ export async function initializeCodex(peer) {
58
55
 
59
56
  export async function probeCodexQueue(peer, { threadId, minimum = MINIMUM_VERSION } = {}) {
60
57
  const serverVersion = await initializeCodex(peer);
61
- if (serverVersion === null || parseStableVersion(serverVersion) === null) {
62
- return { supported: false, serverVersion, reasonCode: "prerelease_not_captured" };
58
+ if (versionOrder(serverVersion) === null) {
59
+ return { supported: false, serverVersion, reasonCode: "version_unavailable" };
63
60
  }
64
- if (compareStableVersions(serverVersion, minimum) < 0) {
61
+ if (compareVersions(serverVersion, minimum) < 0) {
65
62
  return { supported: false, serverVersion, reasonCode: "below_minimum_version" };
66
63
  }
67
64
  try {
@@ -161,7 +161,7 @@ const sandboxReview = (config, file, stateRoot) =>
161
161
 
162
162
  export async function installCodexPlugin({ home, agentsHome = home,
163
163
  codexHome = path.join(home, ".codex"), dataHome, stateRoot, runner, node, cli, keepPreviousVersion = null,
164
- requestedLivePolicy, livePolicy, clientVersion, platform }) {
164
+ requestedLivePolicy, livePolicy, clientVersion, platform, receiverSockets }) {
165
165
  // Read before writing, so a manifest that will not parse is found before a
166
166
  // plugin tree is laid down that nothing will then be able to remove.
167
167
  const existing = await readJson(marketplacePath(agentsHome), { name: MARKETPLACE,
@@ -182,7 +182,7 @@ export async function installCodexPlugin({ home, agentsHome = home,
182
182
  + "remove it and install again", { config });
183
183
  }
184
184
  const permissions = prepareLivePermissions(foreign.source, { home, codexHome, stateRoot,
185
- file: config, requestedLivePolicy, livePolicy, clientVersion, platform });
185
+ file: config, requestedLivePolicy, livePolicy, clientVersion, platform, receiverSockets });
186
186
  const theirSandbox = permissions.skipLegacy || inspectConfig(permissions.source, config).sandbox;
187
187
  const permissionActions = permissions.status?.state === "unverified" ? [permissions.status.diagnostic] : [];
188
188
 
@@ -338,7 +338,8 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
338
338
  }
339
339
 
340
340
  export async function detectCodex({ home, agentsHome = home,
341
- codexHome = path.join(home, ".codex"), stateRoot, clientVersion, platform, nativeDelivery }) {
341
+ codexHome = path.join(home, ".codex"), stateRoot, clientVersion, platform, nativeDelivery,
342
+ receiverSockets }) {
342
343
  const marketplace = await readJson(marketplacePath(agentsHome), null);
343
344
  const published = (marketplace?.plugins ?? []).some(entry => entry.name === PLUGIN_NAME);
344
345
  const config = await readFile(configPath(codexHome), "utf8").catch(() => "");
@@ -347,7 +348,7 @@ export async function detectCodex({ home, agentsHome = home,
347
348
  const cached = await stat(cachePath(codexHome))
348
349
  .then(() => true).catch(() => false);
349
350
  const outgoingDelivery = outgoingStatus(config, { home, codexHome, stateRoot,
350
- file: configPath(codexHome), clientVersion, platform });
351
+ file: configPath(codexHome), clientVersion, platform, receiverSockets });
351
352
  // Saved trust is not readiness. Codex compares every current definition's
352
353
  // hash and can disable a trusted hook. Even a commented or stale single record
353
354
  // previously suppressed this check. Leave verification to Codex's /hooks;
@@ -1,6 +1,7 @@
1
+ import { realpathSync } from "node:fs";
1
2
  import path from "node:path";
2
- import { channelSocketDirectory, tomlString } from "@agents-can-communicate/adapter-sdk";
3
- import { compareStableVersions, parseStableVersion } from "./app-server-client.mjs";
3
+ import { tomlString } from "@agents-can-communicate/adapter-sdk";
4
+ import { compareVersions } from "./app-server-client.mjs";
4
5
  import { addPermissions, inspectPermissions } from "./permission-ownership.mjs";
5
6
  import { scanConfig } from "./toml-scan.mjs";
6
7
 
@@ -8,9 +9,9 @@ const PROFILE = "acc-workspace";
8
9
  const MINIMUM = "0.153.4";
9
10
  const equal = (keys, expected) => JSON.stringify(keys) === JSON.stringify(expected);
10
11
  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;
12
+ // Captured on darwin-arm64 with 0.153.4; the capture's minimum applies on
13
+ // every platform, and a prerelease is judged by its release triple.
14
+ const supported = context => compareVersions(context.clientVersion, MINIMUM) >= 0;
14
15
  const hasPermissions = entries => entries.some(entry =>
15
16
  ["permissions", "default_permissions", "profile", "profiles"].includes(entry.keys[0]));
16
17
  const configured = (source, context) => {
@@ -23,8 +24,12 @@ const configured = (source, context) => {
23
24
  && value(["features", "network_proxy"]) === "true"
24
25
  && value(["permissions", profile, "network", "enabled"]) === "true"
25
26
  && literal(value(["permissions", profile, "filesystem", context.stateRoot])) === "write"
26
- && sockets(context).every(socket => literal(value(["permissions", profile, "network", "unix_sockets", socket])) === "allow");
27
+ && missingSockets(entries, profile, context).length === 0;
27
28
  };
29
+ // The grants a profile lacks for the sockets ACC delivers to.
30
+ const missingSockets = (entries, profile, context) => sockets(context).filter(socket =>
31
+ literal(entries.find(entry => !entry.header
32
+ && equal(entry.keys, ["permissions", profile, "network", "unix_sockets", socket]))?.value) !== "allow");
28
33
 
29
34
  const hasCustomPolicy = (source, context) => {
30
35
  const ownership = inspectPermissions(source);
@@ -48,10 +53,37 @@ const hasCustomPolicy = (source, context) => {
48
53
  || proxies.length > 1 || proxies.some(entry => entry.header || !["true", "false"].includes(entry.value));
49
54
  };
50
55
 
51
- // Match the channel's per-user temporary directory and Codex's actual home.
52
- // Grants cover only ACC's local channel namespace, never arbitrary Unix sockets.
53
- const sockets = context => [channelSocketDirectory(),
54
- path.join(context.codexHome ?? path.join(context.home, ".codex"), "app-server-control", "app-server-control.sock")];
56
+ // Where this adapter's own sessions receive: the app-server control socket in
57
+ // Codex's actual home.
58
+ export const controlSocket = context => path.join(context.codexHome ?? path.join(context.home, ".codex"),
59
+ "app-server-control", "app-server-control.sock");
60
+
61
+ // Codex 0.157.1 on macOS, measured with `codex sandbox -P` (2026-09-27): a
62
+ // granted path that exists when Codex builds the sandbox policy is compared in
63
+ // its resolved form, and one that does not is kept as written, while Seatbelt
64
+ // checks the path the kernel resolved. A `/tmp/cc-socks-501` grant made before
65
+ // that directory existed never matched `/private/tmp/cc-socks-501/<pid>.sock`;
66
+ // `/private/tmp/cc-socks-501` did, both before and after. So the existing
67
+ // parent is resolved the way the kernel will, and the last component is kept as
68
+ // spelled: a socket that is a link - Codex's own control socket - must keep
69
+ // naming the link, because its target changes with every daemon.
70
+ export function grantPath(target, realpath = realpathSync.native) {
71
+ const tail = [path.basename(target)];
72
+ for (let parent = path.dirname(target); ; parent = path.dirname(parent)) {
73
+ try {
74
+ return path.join(realpath(parent), ...tail);
75
+ } catch {
76
+ if (parent === path.dirname(parent)) return target;
77
+ tail.unshift(path.basename(parent));
78
+ }
79
+ }
80
+ }
81
+
82
+ // Every socket ACC delivers to - Codex's own and each receiving adapter's
83
+ // declaration, composed by the installer as `receiverSockets` - and nothing
84
+ // else: grants cover ACC's local receivers, never arbitrary Unix sockets.
85
+ const sockets = context => [...new Set([controlSocket(context), ...(context.receiverSockets ?? [])]
86
+ .map(socket => grantPath(socket, context.realpath)))].sort();
55
87
 
56
88
  export function outgoingStatus(source, context) {
57
89
  const state = inspectPermissions(source).state;
@@ -62,19 +94,34 @@ export function outgoingStatus(source, context) {
62
94
  : ready ? null : "sender_permissions_unverified";
63
95
  return { state: reasonCode === null ? "configured" : "unverified", reasonCode,
64
96
  setup: supported(context)
65
- ? "Codex outgoing setup: default workspace settings gain ACC state write access and an ACC local socket allowlist "
66
- + "through the network proxy (external network stays denied); custom policies are preserved. Start a new session after installation."
97
+ ? "Codex outgoing setup: default workspace settings gain ACC state write access and an allowlist of the "
98
+ + "local sockets ACC delivers to - ACC's own channel, the Codex control socket and other clients' session "
99
+ + "inboxes - through the network proxy (external network stays denied); custom policies are preserved. "
100
+ + "Start a new session after installation."
67
101
  : null,
68
102
  diagnostic: reasonCode === null
69
103
  ? "outgoing live delivery: local socket permissions configured for new sessions; active session overrides remain unverified"
70
104
  : `outgoing live delivery: sender permissions unverified in ${context.file}; `
71
105
  + (reasonCode === "permission_configuration_uncaptured"
72
- ? `automatic setup requires Codex ${MINIMUM} or newer on darwin-arm64`
106
+ ? `automatic setup requires Codex ${MINIMUM} or newer`
73
107
  : custom
74
108
  ? "custom permission policy was preserved; manually allow ACC state and local sockets "
75
109
  + "through the network proxy in the effective workspace profile"
76
- : "ACC outgoing grants are absent; run acc install --adapter codex --delivery actionable, "
77
- + "then start a new session") };
110
+ : state === "owned"
111
+ ? `${incomplete(source, context)}; run acc install --adapter codex, then start a new session`
112
+ : "ACC outgoing grants are absent; run acc install --adapter codex --delivery actionable, "
113
+ + "then start a new session") };
114
+ }
115
+
116
+ // An owned profile ACC wrote for fewer receivers - 0.8.1 predates the Claude
117
+ // Code inbox grant - or for another state root. Reinstalling rewrites it.
118
+ function incomplete(source, context) {
119
+ const entries = scanConfig(source);
120
+ const profile = literal(entries.find(entry => !entry.header && equal(entry.keys, ["default_permissions"]))?.value);
121
+ const missing = typeof profile === "string" ? missingSockets(entries, profile, context) : [];
122
+ return missing.length > 0
123
+ ? `ACC outgoing grants do not cover every socket ACC delivers to; missing ${missing.join(", ")}`
124
+ : "ACC outgoing grants are out of date";
78
125
  }
79
126
 
80
127
  export function prepareLivePermissions(source, context) {
@@ -85,10 +132,15 @@ export function prepareLivePermissions(source, context) {
85
132
  // Once ACC owns a valid outgoing profile, turning incoming requests off must
86
133
  // not revoke the already approved ability to use ACC from the sandbox. A
87
134
  // fresh off install still creates no grants because it has no owned unit.
88
- if (!requested && owned.state === "owned") {
135
+ // An owned unit written for fewer receivers is brought up to date even so:
136
+ // the approval was for ACC's local sockets, and without this `acc install`
137
+ // under a recorded off could never repair a profile an older ACC wrote.
138
+ const stale = owned.state === "owned" && supported(context) && Boolean(context.stateRoot)
139
+ && !configured(source, context) && !hasCustomPolicy(owned.source, context);
140
+ if (!requested && owned.state === "owned" && !stale) {
89
141
  return { source, skipLegacy: true, status: outgoingStatus(source, context) };
90
142
  }
91
- if (!requested) return { source: owned.source, skipLegacy: hasPermissions(scanConfig(owned.source)) };
143
+ if (!requested && !stale) return { source: owned.source, skipLegacy: hasPermissions(scanConfig(owned.source)) };
92
144
  if (!supported(context) || !context.stateRoot) {
93
145
  return { source, skipLegacy: hasPermissions(scanConfig(source)), status: outgoingStatus(source, context) };
94
146
  }
@@ -3,7 +3,7 @@ import { constants } from "node:fs";
3
3
  import { access, lstat, open, realpath } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { compareStableVersions, parseStableVersion } from "./app-server-client.mjs";
6
+ import { compareVersions, versionOrder } from "./app-server-client.mjs";
7
7
 
8
8
  export const MINIMUM_MAINTENANCE_CLI = "0.154.0";
9
9
  export const absolute = value => typeof value === "string" && path.isAbsolute(value)
@@ -15,6 +15,20 @@ export const startTimeValid = value => typeof value === "string"
15
15
  export const managedExecutablePaths = codexHome => ["bin/codex", "codex"].map(name =>
16
16
  path.join(codexHome, "packages/standalone/current", name));
17
17
 
18
+ // Where lsof lives: /usr/sbin on macOS, /usr/bin on Linux. A fixed list of
19
+ // absolute paths, never PATH, so a directory an operator can write to cannot
20
+ // supply the tool that proves socket ownership. The first executable wins;
21
+ // none means the socket cannot be proven and verification fails closed.
22
+ export const LSOF_CANDIDATES = Object.freeze(["/usr/sbin/lsof", "/usr/bin/lsof"]);
23
+
24
+ export async function resolveLsof({ access: check = access } = {}) {
25
+ for (const candidate of LSOF_CANDIDATES) {
26
+ try { await check(candidate, constants.X_OK); return candidate; }
27
+ catch { /* the next candidate may be the one this host has */ }
28
+ }
29
+ return null;
30
+ }
31
+
18
32
  export function runMaintenanceCommand(command, args, options) {
19
33
  return new Promise(resolve => execFile(command, args,
20
34
  { timeout: 20_000, ...options, maxBuffer: 131_072, windowsHide: true },
@@ -71,12 +85,12 @@ async function resolveCli(pathEnv) {
71
85
  }
72
86
 
73
87
  const cliVersion = response => response.status === 0
74
- ? /^codex-cli (\d+\.\d+\.\d+)\s*$/.exec(response.stdout)?.[1] ?? null : null;
88
+ ? /^codex-cli (\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)\s*$/.exec(response.stdout)?.[1] ?? null : null;
75
89
 
76
90
  export async function probeMaintenanceCli(paths, run) {
77
91
  const cliPath = await resolveCli(paths.options.env.PATH);
78
92
  const version = cliVersion(await run(cliPath, ["--version"], paths.options));
79
- if (!parseStableVersion(version) || compareStableVersions(version, MINIMUM_MAINTENANCE_CLI) < 0) {
93
+ if (!versionOrder(version) || compareVersions(version, MINIMUM_MAINTENANCE_CLI) < 0) {
80
94
  failMaintenance("maintenance_cli_unsupported");
81
95
  }
82
96
  const help = await run(cliPath, ["app-server", "daemon", "--help"], paths.options);
@@ -117,17 +131,39 @@ export async function observeMaintenanceProcess(pid, paths, run) {
117
131
  return { state: "alive", processStartTime: match[1], command: match[2] };
118
132
  }
119
133
 
120
- export async function verifyMaintenanceProcess(snapshot, paths, run) {
134
+ /** Whether `lsof -Fn` output lists a socket at one of these paths. macOS prints
135
+ * the path alone; Linux appends the socket type (`/path type=STREAM`, measured
136
+ * on the ubuntu CI job), and a connected peer may carry more after it. The name
137
+ * is the text before the first ` type=`, compared whole. */
138
+ export function socketListedIn(stdout, socketPaths) {
139
+ const names = new Set(typeof socketPaths === "string" ? [socketPaths] : socketPaths);
140
+ return stdout.split("\n").some(line => line.startsWith("n")
141
+ && names.has(line.slice(1).split(" type=")[0]));
142
+ }
143
+
144
+ // The daemon's command line as measured: 0.154.0 ran `<current path> app-server
145
+ // --listen unix://`; 0.157.1 runs the resolved releases path with
146
+ // `--managed-daemon` appended. The executable must live in the standalone tree
147
+ // and be the managed binary itself, however the tree names it.
148
+ const DAEMON_COMMAND = /^(\/.+?) app-server --listen unix:\/\/(?: --managed-daemon)?$/;
149
+
150
+ export async function verifyMaintenanceProcess(snapshot, paths, run, { lsof = resolveLsof } = {}) {
121
151
  const process = await observeMaintenanceProcess(snapshot.pid, paths, run);
122
- const suffix = " app-server --listen unix://";
123
- const executable = process.command?.endsWith(suffix) ? process.command.slice(0, -suffix.length) : null;
152
+ const executable = DAEMON_COMMAND.exec(process.command ?? "")?.[1] ?? null;
153
+ const standalone = path.join(paths.codexHome, "packages", "standalone") + path.sep;
124
154
  if (process.state !== "alive" || process.processStartTime !== snapshot.processStartTime
125
- || !managedExecutablePaths(paths.codexHome).includes(executable)
126
- || await realpath(executable) !== await realpath(paths.managedPath)) {
155
+ || executable === null || !executable.startsWith(standalone)
156
+ || await realpath(executable).catch(() => null) !== await realpath(paths.managedPath)) {
127
157
  failMaintenance("daemon_identity_unavailable");
128
158
  }
129
- const sockets = await run("/usr/sbin/lsof", ["-n", "-a", "-p", String(snapshot.pid), "-U", "-Fn"], paths.options);
130
- if (sockets.status !== 0 || !sockets.stdout.split("\n").includes(`n${paths.socketPath}`)) {
159
+ // 0.157.1 lists the socket by the path it listens on, not by the symlink it
160
+ // reports; either names the same socket.
161
+ const listening = [paths.socketPath, await realpath(paths.socketPath).catch(() => null)]
162
+ .filter(value => value !== null);
163
+ const lsofPath = await lsof();
164
+ if (lsofPath === null) failMaintenance("daemon_socket_unproven");
165
+ const sockets = await run(lsofPath, ["-n", "-a", "-p", String(snapshot.pid), "-U", "-Fn"], paths.options);
166
+ if (sockets.status !== 0 || !socketListedIn(sockets.stdout, listening)) {
131
167
  failMaintenance("daemon_socket_unproven");
132
168
  }
133
169
  }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
- import { openCodexAppServer, parseStableVersion } from "./app-server-client.mjs";
3
- import { socketIsReady } from "./native-endpoint.mjs";
2
+ import { openCodexAppServer, versionOrder } from "./app-server-client.mjs";
3
+ import { readySocketPath } from "./native-endpoint.mjs";
4
4
  import { absolute, approvedProcessIsDead, failMaintenance, maintenanceContext,
5
5
  managedExecutablePaths, metadataExists, probeMaintenanceInstall, readMaintenancePid, runMaintenanceCommand,
6
6
  startTimeValid, verifyMaintenanceProcess } from "./maintenance-host.mjs";
@@ -16,7 +16,7 @@ function validApproval(value) {
16
16
  return value?.serviceId === "codex-app-server" && ["ready", "busy"].includes(value.state)
17
17
  && Number.isSafeInteger(value.pid) && value.pid > 1 && startTimeValid(value.processStartTime)
18
18
  && ["codexHome", "cliPath", "managedPath", "socketPath"].every(key => absolute(value[key]))
19
- && ["cliVersion", "serverVersion", "managedVersion"].every(key => parseStableVersion(value[key]))
19
+ && ["cliVersion", "serverVersion", "managedVersion"].every(key => versionOrder(value[key]))
20
20
  && value.managedVersion === value.cliVersion
21
21
  && managedExecutablePaths(value.codexHome).includes(value.managedPath)
22
22
  && value.socketPath === path.join(value.codexHome, "app-server-control/app-server-control.sock");
@@ -36,8 +36,7 @@ export function createCodexMaintenance({ run = runMaintenanceCommand, open = ope
36
36
  const paths = await maintenanceContext(context);
37
37
  Object.assign(snapshot, { codexHome: paths.codexHome, socketPath: paths.socketPath, managedPath: paths.managedPath });
38
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");
39
+ if (!await readySocketPath(paths.socketPath)) failMaintenance("daemon_socket_unproven");
41
40
  Object.assign(snapshot, await probeMaintenanceInstall(paths, run));
42
41
  const version = await run(snapshot.cliPath, ["app-server", "daemon", "version"], paths.options);
43
42
  if (version.status !== 0) failMaintenance("daemon_version_unavailable");
@@ -45,7 +44,7 @@ export function createCodexMaintenance({ run = runMaintenanceCommand, open = ope
45
44
  if (observed.status !== "running" || observed.backend !== "pid") failMaintenance("maintenance_backend_unsupported");
46
45
  if (observed.managedCodexPath !== snapshot.managedPath || observed.socketPath !== snapshot.socketPath
47
46
  || observed.cliVersion !== snapshot.cliVersion || observed.managedCodexVersion !== snapshot.managedVersion
48
- || !parseStableVersion(observed.appServerVersion)) failMaintenance("daemon_identity_unavailable");
47
+ || !versionOrder(observed.appServerVersion)) failMaintenance("daemon_identity_unavailable");
49
48
  snapshot.serverVersion = observed.appServerVersion;
50
49
  Object.assign(snapshot, await readMaintenancePid(paths.pidPath));
51
50
  await verifyMaintenanceProcess(snapshot, paths, run);
@@ -83,7 +82,6 @@ export function createCodexMaintenance({ run = runMaintenanceCommand, open = ope
83
82
  try {
84
83
  const pinned = { ...context, env: { ...(context?.env ?? process.env), CODEX_HOME: expected.codexHome } };
85
84
  const paths = await maintenanceContext(pinned);
86
- if (paths.platform !== "darwin-arm64") return refused("maintenance_platform_unsupported");
87
85
  const install = { ...paths, ...await probeMaintenanceInstall(paths, run) };
88
86
  if (!sameFields(expected, install, installationKeys)) return refused("service_identity_changed");
89
87
  const existing = await inspectMaintenance(pinned);
@@ -1,11 +1,10 @@
1
- import { realpath } from "node:fs/promises";
2
1
  import { decisionBody } from "@agents-can-communicate/adapter-sdk";
3
2
 
4
3
  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";
8
- import { newEndpointId, readNativeEndpoint, removeNativeEndpoint, socketIsReady,
4
+ addCodexQueueMessage, canonicalCwd, compareVersions, controlSocketPath,
5
+ locateCodexThread, openCodexAppServer, probeCodexQueue, safeReason, serverVersionOf,
6
+ versionOrder } from "./app-server-client.mjs";
7
+ import { newEndpointId, readNativeEndpoint, readySocketPath, removeNativeEndpoint, socketIsReady,
9
8
  writeNativeEndpoint } from "./native-endpoint.mjs";
10
9
 
11
10
  // The receiver's hook supplies thread and cwd. Core holds only a random endpoint
@@ -35,8 +34,8 @@ export async function probeNativeDelivery({ timeoutMs = 750, env = process.env,
35
34
  open = openCodexAppServer } = {}) {
36
35
  const unsupported = (reasonCode, clientVersion = null) => ({ supported: false, clientVersion,
37
36
  protocolContract: PROTOCOL_CONTRACT, executableFingerprint: null, modes: [], reasonCode });
38
- const socketPath = controlSocketPath(env);
39
- if (!await socketIsReady(socketPath)) return unsupported("native_endpoint_unavailable");
37
+ const socketPath = await readySocketPath(controlSocketPath(env));
38
+ if (socketPath === null) return unsupported("native_endpoint_unavailable");
40
39
  try {
41
40
  return await usingPeer(socketPath, timeoutMs, open, async peer => {
42
41
  const probe = await probeCodexQueue(peer);
@@ -46,7 +45,12 @@ export async function probeNativeDelivery({ timeoutMs = 750, env = process.env,
46
45
  modes: [...QUEUE_MODES], reasonCode: null };
47
46
  });
48
47
  } catch (error) {
49
- return unsupported(error?.code === "ETIMEDOUT" ? "probe_timeout" : "feature_probe_failed");
48
+ if (error?.code === "ETIMEDOUT") return unsupported("probe_timeout");
49
+ // A service that died without cleaning up leaves its socket file behind,
50
+ // and a connect to it is refused. That is a missing service, the one fact
51
+ // whose advice (start the daemon) fixes it, not a failed feature probe.
52
+ return unsupported(safeReason(error) === "transport_unavailable"
53
+ ? "native_endpoint_unavailable" : "feature_probe_failed");
50
54
  }
51
55
  }
52
56
 
@@ -85,7 +89,7 @@ export async function verifyReceiver(peer, endpoint, { probe = probeCodexQueue,
85
89
  : result.reasonCode === "below_minimum_version" ? "handshake_version_mismatch"
86
90
  : "protocol_mismatch", servingVersion: null };
87
91
  }
88
- if (compareStableVersions(result.serverVersion, CODEX_QUEUE_MINIMUM) < 0) {
92
+ if (compareVersions(result.serverVersion, CODEX_QUEUE_MINIMUM) < 0) {
89
93
  return { reasonCode: "handshake_version_mismatch", servingVersion: null };
90
94
  }
91
95
  const located = await locate(peer, { threadId: endpoint.threadId, cwd: endpoint.cwd });
@@ -99,12 +103,12 @@ export async function bindNativeSession({ event, clientPid, clientVersion, runti
99
103
  env = process.env, timeoutMs = 750, now = Date.now, open = openCodexAppServer } = {}) {
100
104
  const rejected = reason => closed(clientVersion, reason);
101
105
  if (!Number.isInteger(clientPid) || clientPid <= 0) return rejected("client_process_unknown");
102
- if (parseStableVersion(clientVersion) === null) return rejected("version_unavailable");
106
+ if (versionOrder(clientVersion) === null) return rejected("version_unavailable");
103
107
  if (typeof event?.sessionId !== "string" || event.sessionId === "") return rejected("handshake_failed");
104
108
  const cwd = await canonicalCwd(event.cwd);
105
109
  if (cwd === null) return rejected("workspace_identity_unavailable");
106
- const socketPath = await realpath(controlSocketPath(env)).catch(() => null);
107
- if (!await socketIsReady(socketPath)) return rejected("handshake_failed");
110
+ const socketPath = await readySocketPath(controlSocketPath(env));
111
+ if (socketPath === null) return rejected("handshake_failed");
108
112
  try {
109
113
  return await usingPeer(socketPath, timeoutMs, open, async peer => {
110
114
  const endpoint = { schemaVersion: 1, endpointId: newEndpointId(), socketPath,
@@ -3,7 +3,7 @@ import { constants } from "node:fs";
3
3
  import { lstat, mkdir, open, realpath, rename, rm } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
 
6
- import { PROTOCOL_CONTRACT, parseStableVersion } from "./app-server-client.mjs";
6
+ import { PROTOCOL_CONTRACT, versionOrder } from "./app-server-client.mjs";
7
7
 
8
8
  const KEYS = ["schemaVersion", "endpointId", "socketPath", "threadId", "cwd",
9
9
  "clientVersion", "protocolContract", "leaseUntil"];
@@ -21,7 +21,7 @@ function valid(record) {
21
21
  && record.schemaVersion === 1 && ENDPOINT.test(record.endpointId)
22
22
  && absolute(record.socketPath) && absolute(record.cwd)
23
23
  && typeof record.threadId === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(record.threadId)
24
- && parseStableVersion(record.clientVersion) !== null
24
+ && versionOrder(record.clientVersion) !== null
25
25
  && record.protocolContract === PROTOCOL_CONTRACT
26
26
  && typeof record.leaseUntil === "string"
27
27
  && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(record.leaseUntil)
@@ -49,6 +49,16 @@ export async function socketIsReady(socketPath) {
49
49
  } catch { return false; }
50
50
  }
51
51
 
52
+ /** The socket a control socket path leads to, or null. Codex 0.157.1 keeps a
53
+ * symlink at the path it reports and the socket itself under /private/tmp
54
+ * (measured 2026-09-26), so the path is followed once and the socket at its
55
+ * end is held to socketIsReady: an owned socket, never itself a link. */
56
+ export async function readySocketPath(socketPath) {
57
+ if (!absolute(socketPath)) return null;
58
+ const resolved = await realpath(socketPath).catch(() => null);
59
+ return resolved !== null && await socketIsReady(resolved) ? resolved : null;
60
+ }
61
+
52
62
  // A fresh, immutable registration for one hook binding. Refresh observes the
53
63
  // same registration again; it cannot overwrite a successor's random endpoint.
54
64
  export async function writeNativeEndpoint({ runtimeDir, record }) {