agents-can-communicate 0.4.1 → 0.4.3

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 (57) hide show
  1. package/README.md +17 -4
  2. package/docs/ARCHITECTURE.md +7 -1
  3. package/docs/CLI.md +23 -3
  4. package/docs/CONFIGURATION.md +25 -0
  5. package/docs/GETTING_STARTED.md +17 -5
  6. package/docs/PROTOCOL.md +4 -0
  7. package/docs/RELEASING.md +1 -1
  8. package/docs/SECURITY_MODEL.md +7 -0
  9. package/docs/TROUBLESHOOTING.md +60 -7
  10. package/docs/UPGRADING.md +36 -19
  11. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
  12. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -3
  13. package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +2 -4
  14. package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +10 -0
  15. package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +3 -1
  16. package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
  17. package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +1 -1
  18. package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +4 -88
  19. package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -15
  20. package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +110 -0
  21. package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +2 -1
  22. package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +109 -0
  23. package/node_modules/@agents-can-communicate/adapter-codex/src/toml-scan.mjs +99 -0
  24. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  25. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
  26. package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
  27. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
  28. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  29. package/node_modules/@agents-can-communicate/adapter-sdk/src/channel-directory.mjs +8 -0
  30. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +3 -0
  31. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt-writer.mjs +64 -0
  32. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-attempt.mjs +65 -0
  33. package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +1 -0
  34. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  35. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +19 -52
  36. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +23 -45
  37. package/node_modules/@agents-can-communicate/cli/src/main.mjs +3 -1
  38. package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +88 -0
  39. package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +64 -0
  40. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  41. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +1 -0
  42. package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
  43. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +1 -1
  44. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  45. package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +43 -0
  46. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +17 -27
  47. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  48. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -0
  49. package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +36 -0
  50. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +28 -12
  51. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -1
  52. package/node_modules/@agents-can-communicate/installer/src/live-policy.mjs +10 -3
  53. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +12 -2
  54. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  55. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  56. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  57. package/package.json +1 -1
@@ -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, removeTomlBlock, sandboxOwnership, writeTomlBlock } from "./config-block.mjs";
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 theirSandbox = foreign.sandbox;
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, ...sandboxReview(before, config, stateRoot)],
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
- ...sandboxReview(before, config, stateRoot)] };
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
- return { existing, withoutOurs: inspectConfig(before, configPath(codexHome)).source };
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 (await removeTomlBlock(configPath(codexHome))) changes.push(configPath(codexHome));
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
- return { ok: true, changes, diagnostics: declaresSandbox(withoutOurs)
316
- ? ["existing sandbox_workspace_write configuration was preserved; review any retained writable_roots after removal"]
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: [], diagnostics: [
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);
@@ -35,7 +35,7 @@ export async function probeNativeDelivery({ timeoutMs = 750, env = process.env,
35
35
  const unsupported = (reasonCode, clientVersion = null) => ({ supported: false, clientVersion,
36
36
  protocolContract: PROTOCOL_CONTRACT, executableFingerprint: null, modes: [], reasonCode });
37
37
  const socketPath = controlSocketPath(env);
38
- if (!await socketIsReady(socketPath)) return unsupported("feature_probe_failed");
38
+ if (!await socketIsReady(socketPath)) return unsupported("native_endpoint_unavailable");
39
39
  try {
40
40
  return await usingPeer(socketPath, timeoutMs, open, async peer => {
41
41
  const probe = await probeCodexQueue(peer);
@@ -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");
@@ -0,0 +1,109 @@
1
+ import { createHash } from "node:crypto";
2
+ import { scanConfig, unsafe } from "./toml-scan.mjs";
3
+
4
+ const BEGIN = "# ACC native permissions begin ";
5
+ const END = "# ACC native permissions end ";
6
+ const META = "# ACC native permissions restore ";
7
+ const hash = (body, record) => createHash("sha256")
8
+ .update(JSON.stringify({ body, id: record.id, before: record.before, table: record.table })).digest("hex");
9
+ const marker = (source, prefix) => source.startsWith(prefix) ? source.slice(prefix.length) : null;
10
+
11
+ // Permission selection, proxy and network grants form one ownership unit. If
12
+ // any component changes, none can be removed independently: enabled networking
13
+ // without its proxy has a different security meaning to Codex.
14
+ export function inspectPermissions(source) {
15
+ const blocks = [];
16
+ let open = null, metadata = null;
17
+ for (const entry of scanConfig(source)) {
18
+ const comment = entry.code ? "" : entry.raw.trim();
19
+ const start = marker(comment, BEGIN), end = marker(comment, END);
20
+ if (start !== null) {
21
+ if (open || !/^[a-z]+$/.test(start) || blocks.some(block => block.id === start)) {
22
+ unsafe("duplicate or nested native permission marker");
23
+ }
24
+ open = { id: start, start: entry.start, table: entry.table, contentStart: entry.end };
25
+ } else if (comment.startsWith(META)) {
26
+ if (!open || open.id !== "selection" || metadata !== null || entry.start !== open.contentStart) {
27
+ unsafe("misplaced permission metadata");
28
+ }
29
+ try { metadata = JSON.parse(comment.slice(META.length)); }
30
+ catch { unsafe("invalid permission metadata"); }
31
+ open.contentStart = entry.end;
32
+ } else if (end !== null) {
33
+ if (!open || open.id !== end) unsafe("unmatched native permission marker");
34
+ blocks.push({ ...open, end: entry.end, body: source.slice(open.contentStart, entry.start) });
35
+ open = null;
36
+ }
37
+ }
38
+ if (open) return { source, state: "customized" };
39
+ if (!blocks.length && !metadata) return { source, state: "absent" };
40
+ if (!Array.isArray(metadata) || metadata.length !== blocks.length
41
+ || metadata.some(item => !item || typeof item !== "object" || Array.isArray(item))
42
+ || new Set(metadata.map(item => item.id)).size !== metadata.length) return { source, state: "customized" };
43
+ for (const block of blocks) {
44
+ const record = metadata.find(item => item.id === block.id);
45
+ if (!record || Object.keys(record).sort().join() !== "before,hash,id,table"
46
+ || typeof record.before !== "string" || record.hash !== hash(block.body, record)
47
+ || (block.id === "selection" && block.table.length !== 0)
48
+ || JSON.stringify(block.table) !== JSON.stringify(record.table)) {
49
+ return { source, state: "customized" };
50
+ }
51
+ }
52
+ // A retained reference or declaration must not be left pointing at a deleted
53
+ // profile. Ignore peer-looking text inside strings/comments as markers.
54
+ const entries = scanConfig(source);
55
+ const inside = entry => blocks.some(block => entry.start >= block.start && entry.end <= block.end);
56
+ const generatedTables = entries.filter(entry => entry.header && inside(entry)).map(entry => JSON.stringify(entry.keys));
57
+ const outside = entries.filter(entry => !inside(entry));
58
+ // New profile declarations and selectors are custom policy, regardless of
59
+ // how TOML spells their values (including escaped or multiline references).
60
+ if (outside.some(entry => entry.code && (entry.keys.includes("default_permissions")
61
+ || entry.keys[0] === "permissions"
62
+ || (!entry.header && generatedTables.includes(JSON.stringify(entry.table)))))) {
63
+ return { source, state: "customized" };
64
+ }
65
+ let restored = source;
66
+ for (const block of blocks.toSorted((a, b) => b.start - a.start)) {
67
+ restored = restored.slice(0, block.start) + metadata.find(item => item.id === block.id).before
68
+ + restored.slice(block.end);
69
+ }
70
+ // Inserting restored headers can reparent foreign assignments added after
71
+ // a removal marker. Verify the candidate's scopes before restoring any byte.
72
+ let restoredEntries;
73
+ try { restoredEntries = new Map(scanConfig(restored).map(entry => [entry.start, entry])); }
74
+ catch { return { source, state: "customized" }; }
75
+ for (const entry of outside.filter(item => item.code)) {
76
+ const delta = blocks.filter(block => block.end <= entry.start).reduce((total, block) =>
77
+ total + metadata.find(item => item.id === block.id).before.length - (block.end - block.start), 0);
78
+ const candidate = restoredEntries.get(entry.start + delta);
79
+ if (!candidate || JSON.stringify(candidate.keys) !== JSON.stringify(entry.keys)) {
80
+ return { source, state: "customized" };
81
+ }
82
+ }
83
+ return { source: restored, state: "owned" };
84
+ }
85
+
86
+ export function addPermissions(source, edits) {
87
+ const metadata = edits.map(({ id, start, end, body, table = [] }) => {
88
+ const record = { id, before: source.slice(start, end), table };
89
+ return { ...record, hash: hash(body, record) };
90
+ });
91
+ const initialMetadata = JSON.stringify(metadata);
92
+ // Insertions at the same offset retain caller order (selection always first).
93
+ for (const edit of edits.map((item, index) => ({ ...item, index }))
94
+ .sort((a, b) => b.start - a.start || b.index - a.index)) {
95
+ const block = `${BEGIN}${edit.id}\n`
96
+ + (edit.id === "selection" ? `${META}${JSON.stringify(metadata)}\n` : "")
97
+ + edit.body + `${END}${edit.id}\n`;
98
+ source = source.slice(0, edit.start) + block + source.slice(edit.end);
99
+ }
100
+ // Record the installed scope, which can differ after removing a legacy table.
101
+ for (const entry of scanConfig(source)) {
102
+ const id = !entry.code ? marker(entry.raw.trim(), BEGIN) : null;
103
+ const record = metadata.find(item => item.id === id);
104
+ if (!record) continue;
105
+ record.table = entry.table;
106
+ record.hash = hash(edits.find(item => item.id === id).body, record);
107
+ }
108
+ return source.replace(`${META}${initialMetadata}`, `${META}${JSON.stringify(metadata)}`);
109
+ }
@@ -0,0 +1,99 @@
1
+ import { AccError, EXIT } from "@agents-can-communicate/protocol";
2
+
3
+ export const unsafe = (reason = "unrecognized TOML declaration") => {
4
+ throw new AccError(EXIT.CONFLICT, `cannot safely edit Codex config: ${reason}; `
5
+ + "repair ambiguous TOML or ACC markers before retrying", { reason });
6
+ };
7
+ // A bounded lexical scan, not a TOML value parser. Newlines inside strings,
8
+ // arrays and inline tables cannot introduce a header or a managed marker.
9
+ export function* statements(source) {
10
+ let start = 0, code = "", quote = null;
11
+ const stack = [];
12
+ for (let i = 0; i < source.length;) {
13
+ const char = source[i];
14
+ if (quote) {
15
+ if (quote[0] === '"' && char === "\\") {
16
+ code += source.slice(i, i + 2); i += 2; continue;
17
+ }
18
+ if (source.startsWith(quote, i)) {
19
+ let count = quote.length;
20
+ if (quote.length === 3) {
21
+ while (source[i + count] === quote[0]) count++;
22
+ if (count > 5) unsafe("unsupported string quote delimiter");
23
+ }
24
+ code += source.slice(i, i + count); i += count; quote = null; continue;
25
+ }
26
+ if (quote.length === 1 && /[\r\n]/.test(char)) unsafe("newline in single-line string");
27
+ code += char; i++; continue;
28
+ }
29
+ if (char === "#") {
30
+ while (i < source.length && source[i] !== "\n") i++;
31
+ continue;
32
+ }
33
+ if (char === '"' || char === "'") {
34
+ quote = source.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
35
+ code += quote; i += quote.length; continue;
36
+ }
37
+ if (char === "[" || char === "{") stack.push(char);
38
+ if (char === "]" || char === "}") {
39
+ if (stack.pop() !== (char === "]" ? "[" : "{")) unsafe("mismatched collection delimiter");
40
+ }
41
+ code += char; i++;
42
+ if (char === "\n" && stack.length === 0) {
43
+ yield { raw: source.slice(start, i), code: code.trim(), start, end: i };
44
+ start = i; code = "";
45
+ }
46
+ }
47
+ if (quote || stack.length) unsafe(quote ? "unclosed string" : "unclosed collection");
48
+ if (start < source.length) yield { raw: source.slice(start), code: code.trim(), start, end: source.length };
49
+ }
50
+
51
+ function decodeKey(key) {
52
+ if (key[0] === "'") return key.slice(1, -1);
53
+ if (key[0] !== '"') return key;
54
+ return key.slice(1, -1).replace(/\\(U[\da-fA-F]{8}|u[\da-fA-F]{4}|[btnfr"\\])|\\./g,
55
+ (match, escape) => {
56
+ if (!escape) unsafe("unsupported quoted-key escape");
57
+ if (/^[uU]/.test(escape)) {
58
+ const point = Number.parseInt(escape.slice(1), 16);
59
+ if (point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) {
60
+ unsafe("invalid quoted-key Unicode escape");
61
+ }
62
+ return String.fromCodePoint(point);
63
+ }
64
+ return ({ b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" })[escape];
65
+ });
66
+ }
67
+
68
+ function keyPath(code) {
69
+ const keys = [];
70
+ let rest = code.trimStart();
71
+ for (;;) {
72
+ const match = /^(?:[A-Za-z0-9_-]+|'[^'\r\n]*'|"(?:[^"\\\r\n]|\\[^\r\n])*")/.exec(rest);
73
+ if (!match) unsafe("unrecognized TOML key");
74
+ keys.push(decodeKey(match[0]));
75
+ rest = rest.slice(match[0].length).trimStart();
76
+ if (!rest.startsWith(".")) return { keys, rest };
77
+ rest = rest.slice(1).trimStart();
78
+ }
79
+ }
80
+
81
+ export function declaration(code) {
82
+ const header = code.startsWith("[");
83
+ const array = header && code.startsWith("[[");
84
+ const { keys, rest } = keyPath(header ? code.slice(array ? 2 : 1) : code);
85
+ if (header ? rest !== (array ? "]]" : "]") : !rest.startsWith("=")) unsafe();
86
+ return { keys, header, array, value: header ? null : rest.slice(1).trim() };
87
+ }
88
+
89
+ // Resolved declaration paths are only structural facts, never parsed user values.
90
+ export function scanConfig(source) {
91
+ let table = [];
92
+ return [...statements(source)].map(item => {
93
+ if (!item.code) return { ...item, table: [...table], keys: [] };
94
+ const entry = declaration(item.code);
95
+ if (entry.header) table = entry.keys;
96
+ return { ...item, ...entry, table: [...table],
97
+ keys: entry.header ? entry.keys : [...table, ...entry.keys] };
98
+ });
99
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-can-communicate",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Coordinate this Gemini CLI session with other AI agent sessions working in the same workspace.",
5
5
  "contextFileName": "skills/acc/SKILL.md"
6
6
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-gemini-cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-grok",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-kimi",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents-can-communicate/adapter-sdk",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,8 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ // macOS Unix paths are capped at 104 bytes. A stable, short per-user namespace
5
+ // also lets a sandbox grant access regardless of each client's TMPDIR value.
6
+ export const channelSocketDirectory = () => path.join(
7
+ process.platform === "darwin" ? "/tmp" : os.tmpdir(),
8
+ `acc-ch-${typeof process.getuid === "function" ? process.getuid() : "u"}`);
@@ -23,3 +23,6 @@ export { formatJsonAs, jsonStyleOf, mergeOwnedConfig, mergeOwnedEntries, ownedEn
23
23
  export { clearSessionBinding, listSessionBindings, loadSessionBinding,
24
24
  storeSessionBinding }
25
25
  from "./session-binding.mjs";
26
+
27
+ export { clearNativeAttempt, loadNativeAttempt, storeNativeAttempt } from "./native-attempt.mjs";
28
+ export { channelSocketDirectory } from "./channel-directory.mjs";
@@ -0,0 +1,64 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, rename, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { parentPort, workerData } from "node:worker_threads";
5
+
6
+ import { assertPortableId } from "@agents-can-communicate/protocol";
7
+ import { withWriterMutex } from "@agents-can-communicate/storage-filesystem";
8
+ import { loadNativeAttempt, nativeAttemptFrom } from "./native-attempt.mjs";
9
+
10
+ const keyFor = harnessSessionId => createHash("sha256").update(String(harnessSessionId))
11
+ .digest("hex").slice(0, 32);
12
+ const fileFor = (runtimeDir, harnessSessionId) =>
13
+ path.join(runtimeDir, "native-attempts", `${keyFor(harnessSessionId)}.json`);
14
+ const checkDeadline = deadline => {
15
+ if (Date.now() >= deadline) throw new Error("native diagnostic deadline expired");
16
+ };
17
+
18
+ // The caller stops waiting after 250 ms at most. An in-flight filesystem call
19
+ // may complete later, so retain this SEPARATE mutex until its continuation is
20
+ // finished. Unique staging names also isolate cleanup after stale-lock recovery.
21
+ // No diagnostic operation can touch the authoritative hook-owner file.
22
+ async function boundedWrite(input, operation) {
23
+ const deadline = input.deadlineAt;
24
+ try {
25
+ checkDeadline(deadline);
26
+ await withWriterMutex({ locks: path.join(input.runtimeDir, "native-attempt-locks",
27
+ keyFor(input.harnessSessionId)) }, { root: input.runtimeDir,
28
+ clock: { now: () => new Date().toISOString() }, deadlineAt: deadline },
29
+ () => operation(deadline));
30
+ return true;
31
+ } catch { return false; }
32
+ }
33
+
34
+ async function storeNativeAttempt(input) {
35
+ return boundedWrite(input, async deadline => {
36
+ const { runtimeDir, harnessSessionId, accSessionId, generation } = input;
37
+ assertPortableId(accSessionId, "native diagnostic session id");
38
+ assertPortableId(generation, "native diagnostic generation");
39
+ const attempt = nativeAttemptFrom(input.nativeAttempt);
40
+ if (attempt === null) return;
41
+ const file = fileFor(runtimeDir, harnessSessionId);
42
+ const temporary = `${file}.${randomUUID()}.tmp`;
43
+ checkDeadline(deadline);
44
+ await mkdir(path.dirname(file), { recursive: true });
45
+ try {
46
+ checkDeadline(deadline);
47
+ await writeFile(temporary, JSON.stringify({ schemaVersion: 1, accSessionId, generation,
48
+ attempt }) + "\n", "utf8");
49
+ checkDeadline(deadline);
50
+ await rename(temporary, file);
51
+ } finally { await rm(temporary, { force: true }).catch(() => {}); }
52
+ });
53
+ }
54
+
55
+ async function clearNativeAttempt(input) {
56
+ return boundedWrite(input, async deadline => {
57
+ if (await loadNativeAttempt(input) === null) return;
58
+ checkDeadline(deadline);
59
+ await rm(fileFor(input.runtimeDir, input.harnessSessionId), { force: true });
60
+ });
61
+ }
62
+
63
+ const operation = workerData.operation === "clear" ? clearNativeAttempt : storeNativeAttempt;
64
+ parentPort.postMessage(await operation(workerData.input));