agents-can-communicate 0.7.1 → 0.8.1
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 +3 -1
- package/bin/entrypoints/acc-bootstrap.mjs +6 -48
- package/bin/entrypoints/acc-claude-channel.mjs +47 -157
- package/bin/entrypoints/acc-mcp.mjs +0 -1
- package/bin/entrypoints/acc.mjs +0 -1
- package/docs/ADAPTER_AUTHORING.md +74 -24
- package/docs/ARCHITECTURE.md +31 -18
- package/docs/CAPABILITIES.md +38 -25
- package/docs/CLI.md +51 -13
- package/docs/CONCEPTS.md +5 -4
- package/docs/CONFIGURATION.md +18 -11
- package/docs/GETTING_STARTED.md +15 -12
- package/docs/GLOSSARY.md +2 -1
- package/docs/HOW_IT_WORKS.md +8 -4
- package/docs/PROTOCOL.md +25 -6
- package/docs/RELEASING.md +8 -5
- package/docs/SECURITY_MODEL.md +14 -1
- package/docs/TROUBLESHOOTING.md +66 -29
- package/docs/UPGRADING.md +49 -8
- package/docs/WHY_ACC.md +5 -3
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +2 -3
- package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +14 -73
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +29 -71
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282-product-evidence.json +161 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.282.json +28 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/inbox-wake-2.1.282.json +44 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +5 -2
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +26 -30
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +6 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-contract.mjs +10 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +249 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +166 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +71 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +25 -31
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +3 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +10 -13
- package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +5 -5
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +46 -10
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +5 -7
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +16 -12
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +12 -2
- package/node_modules/@agents-can-communicate/adapter-codex/src/service-setup.mjs +26 -9
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +12 -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/certification.mjs +2 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/events.mjs +16 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +4 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +4 -3
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-activation.mjs +4 -14
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +104 -84
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-vocabulary.mjs +8 -4
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +8 -6
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +8 -23
- package/node_modules/@agents-can-communicate/cli/src/install-delivery-consent.mjs +3 -4
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +24 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -43
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +9 -15
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +13 -4
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +3 -4
- package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +2 -2
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +17 -2
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +25 -2
- package/node_modules/@agents-can-communicate/core/src/status.mjs +31 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/refresh-binding.mjs +2 -2
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +33 -48
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +30 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +6 -8
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +27 -15
- package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +30 -14
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +4 -2
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +19 -5
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +0 -2
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +6 -11
- package/node_modules/@agents-can-communicate/installer/src/index.mjs +4 -7
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +23 -61
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +7 -1
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +15 -5
- package/node_modules/@agents-can-communicate/installer/src/shell-bootstrap.mjs +10 -133
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +6 -1
- package/package.json +1 -1
- package/bin/acc-bootstrap.mjs +0 -7
- package/bin/acc-claude-channel.mjs +0 -10
- package/bin/entrypoints/claude-channel-binding.mjs +0 -46
- package/bin/entrypoints/claude-channel-stdio.mjs +0 -29
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/.mcp.json +0 -8
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +0 -381
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +0 -232
- package/node_modules/@agents-can-communicate/installer/src/bootstrap-runtime.mjs +0 -150
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { versionOrder } from "@agents-can-communicate/adapter-sdk";
|
|
6
|
+
|
|
7
|
+
import { PROTOCOL_CONTRACT } from "./inbox-contract.mjs";
|
|
8
|
+
import { inboxSocketIsSafe } from "./inbox-registry.mjs";
|
|
9
|
+
import { isReception } from "./inbox-settings.mjs";
|
|
10
|
+
|
|
11
|
+
// One private record per hook binding, the way the Codex adapter keeps its
|
|
12
|
+
// daemon endpoints: core holds only the random id, and the socket path, pid and
|
|
13
|
+
// session id stay in a 0600 file under the workspace runtime directory.
|
|
14
|
+
|
|
15
|
+
const KEYS = ["schemaVersion", "endpointId", "socketPath", "configDir", "clientPid", "sessionId",
|
|
16
|
+
"clientVersion", "protocolContract", "leaseUntil", "reception"];
|
|
17
|
+
const ENDPOINT = /^claude_inbox_[a-f0-9]{32}$/;
|
|
18
|
+
const MAX_BYTES = 8_192;
|
|
19
|
+
const absolute = value => typeof value === "string" && path.isAbsolute(value) && !value.includes("\0");
|
|
20
|
+
const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
|
|
21
|
+
const invalid = () => new Error("invalid Claude inbox endpoint registration");
|
|
22
|
+
|
|
23
|
+
export const newEndpointId = () => `claude_inbox_${randomBytes(16).toString("hex")}`;
|
|
24
|
+
|
|
25
|
+
function valid(record) {
|
|
26
|
+
return record !== null && typeof record === "object" && !Array.isArray(record)
|
|
27
|
+
&& Object.keys(record).length === KEYS.length && KEYS.every(key => Object.hasOwn(record, key))
|
|
28
|
+
&& record.schemaVersion === 1 && ENDPOINT.test(record.endpointId)
|
|
29
|
+
&& absolute(record.socketPath) && absolute(record.configDir)
|
|
30
|
+
&& Number.isInteger(record.clientPid) && record.clientPid > 0
|
|
31
|
+
&& typeof record.sessionId === "string" && record.sessionId !== "" && record.sessionId.length <= 200
|
|
32
|
+
&& typeof record.clientVersion === "string" && versionOrder(record.clientVersion) !== null
|
|
33
|
+
&& record.protocolContract === PROTOCOL_CONTRACT
|
|
34
|
+
&& typeof record.leaseUntil === "string"
|
|
35
|
+
&& /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(record.leaseUntil)
|
|
36
|
+
&& Number.isFinite(Date.parse(record.leaseUntil))
|
|
37
|
+
&& isReception(record.reception);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function directory(runtimeDir, create = false) {
|
|
41
|
+
if (!absolute(runtimeDir)) throw invalid();
|
|
42
|
+
if (create) await mkdir(runtimeDir, { recursive: true, mode: 0o700 });
|
|
43
|
+
const root = await realpath(runtimeDir);
|
|
44
|
+
const dir = path.join(root, "claude-inbox-endpoints");
|
|
45
|
+
if (create) await mkdir(dir, { mode: 0o700 }).catch(error => {
|
|
46
|
+
if (error.code !== "EEXIST") throw error;
|
|
47
|
+
});
|
|
48
|
+
const info = await lstat(dir);
|
|
49
|
+
if (!info.isDirectory() || info.isSymbolicLink() || !own(info) || (info.mode & 0o022) !== 0) throw invalid();
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function writeInboxEndpoint({ runtimeDir, record }) {
|
|
54
|
+
if (!valid(record) || !await inboxSocketIsSafe(record.socketPath)) throw invalid();
|
|
55
|
+
const text = `${JSON.stringify(record)}\n`;
|
|
56
|
+
if (Buffer.byteLength(text) > MAX_BYTES) throw invalid();
|
|
57
|
+
const dir = await directory(runtimeDir, true);
|
|
58
|
+
const file = path.join(dir, `${record.endpointId}.json`);
|
|
59
|
+
const temporary = path.join(dir, `.${record.endpointId}.${randomBytes(8).toString("hex")}.tmp`);
|
|
60
|
+
let handle;
|
|
61
|
+
try {
|
|
62
|
+
handle = await open(temporary, "wx", 0o600);
|
|
63
|
+
await handle.writeFile(text, "utf8");
|
|
64
|
+
await handle.close(); handle = null;
|
|
65
|
+
await rename(temporary, file);
|
|
66
|
+
} finally {
|
|
67
|
+
await handle?.close().catch(() => null);
|
|
68
|
+
await rm(temporary, { force: true }).catch(() => null);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// An expired record is metadata for re-verification, never proof of reach.
|
|
73
|
+
export async function readInboxEndpoint({ runtimeDir, endpointId }) {
|
|
74
|
+
if (typeof endpointId !== "string" || !ENDPOINT.test(endpointId)) return null;
|
|
75
|
+
let handle;
|
|
76
|
+
try {
|
|
77
|
+
const dir = await directory(runtimeDir);
|
|
78
|
+
handle = await open(path.join(dir, `${endpointId}.json`),
|
|
79
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
80
|
+
const info = await handle.stat();
|
|
81
|
+
if (!info.isFile() || !own(info) || info.size > MAX_BYTES || (info.mode & 0o077) !== 0) return null;
|
|
82
|
+
const record = JSON.parse(await handle.readFile("utf8"));
|
|
83
|
+
return valid(record) && record.endpointId === endpointId ? record : null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
} finally {
|
|
87
|
+
await handle?.close().catch(() => null);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function removeInboxEndpoint({ runtimeDir, endpointId }) {
|
|
92
|
+
if (typeof endpointId !== "string" || !ENDPOINT.test(endpointId)) return;
|
|
93
|
+
try {
|
|
94
|
+
const dir = await directory(runtimeDir);
|
|
95
|
+
await rm(path.join(dir, `${endpointId}.json`), { force: true });
|
|
96
|
+
} catch { /* retirement is already recorded; cleanup is best effort */ }
|
|
97
|
+
try {
|
|
98
|
+
await rm(path.join(await wakesDirectory(runtimeDir), endpointId), { recursive: true, force: true });
|
|
99
|
+
} catch { /* the same */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const processExists = pid => {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(pid, 0);
|
|
105
|
+
return true;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return error.code === "EPERM";
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A Claude Code process that crashed ran no SessionEnd, so its binding was never
|
|
113
|
+
* retired. Only a record whose process is gone is removed: a live session whose
|
|
114
|
+
* registry briefly names another conversation (right after /resume) keeps its own.
|
|
115
|
+
*/
|
|
116
|
+
export async function sweepDeadEndpoints({ runtimeDir, exists = processExists, limit = 64 }) {
|
|
117
|
+
let names;
|
|
118
|
+
try {
|
|
119
|
+
names = await readdir(await directory(runtimeDir));
|
|
120
|
+
} catch {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const name of names.filter(item => /^claude_inbox_[a-f0-9]{32}\.json$/.test(item)).slice(0, limit)) {
|
|
124
|
+
const endpointId = name.slice(0, -".json".length);
|
|
125
|
+
const record = await readInboxEndpoint({ runtimeDir, endpointId });
|
|
126
|
+
if (record !== null && !exists(record.clientPid)) await removeInboxEndpoint({ runtimeDir, endpointId });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// One empty marker per message a binding woke. A wake leaves the receipt
|
|
131
|
+
// queued until the receiver's hook commits it, so a replayed send reaches the
|
|
132
|
+
// transport again; the marker keeps it to one wake per message and binding.
|
|
133
|
+
const WAKE_MESSAGE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
134
|
+
|
|
135
|
+
async function wakesDirectory(runtimeDir, create = false) {
|
|
136
|
+
const dir = path.join(path.dirname(await directory(runtimeDir, create)), "claude-inbox-wakes");
|
|
137
|
+
if (create) await mkdir(dir, { mode: 0o700 }).catch(error => {
|
|
138
|
+
if (error.code !== "EEXIST") throw error;
|
|
139
|
+
});
|
|
140
|
+
const info = await lstat(dir);
|
|
141
|
+
if (!info.isDirectory() || info.isSymbolicLink() || !own(info) || (info.mode & 0o022) !== 0) throw invalid();
|
|
142
|
+
return dir;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True when this call is the first to wake the binding for the message. */
|
|
146
|
+
export async function claimWake({ runtimeDir, endpointId, messageId }) {
|
|
147
|
+
if (!ENDPOINT.test(endpointId) || !WAKE_MESSAGE.test(messageId)) throw invalid();
|
|
148
|
+
const dir = path.join(await wakesDirectory(runtimeDir, true), endpointId);
|
|
149
|
+
await mkdir(dir, { mode: 0o700 }).catch(error => {
|
|
150
|
+
if (error.code !== "EEXIST") throw error;
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
await (await open(path.join(dir, messageId), "wx", 0o600)).close();
|
|
154
|
+
return true;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (error.code === "EEXIST") return false;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function releaseWake({ runtimeDir, endpointId, messageId }) {
|
|
162
|
+
if (!ENDPOINT.test(endpointId) || !WAKE_MESSAGE.test(messageId)) return;
|
|
163
|
+
try {
|
|
164
|
+
await rm(path.join(await wakesDirectory(runtimeDir), endpointId, messageId), { force: true });
|
|
165
|
+
} catch { /* a marker left behind only suppresses a repeat wake */ }
|
|
166
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
// Claude Code's own record of a live session: `<config>/sessions/<pid>.json`
|
|
7
|
+
// names the session id and the inbox socket it listens on. ACC reads that file
|
|
8
|
+
// and nothing beside it - never the `<pid>.<hash>.key` next to it.
|
|
9
|
+
|
|
10
|
+
const MAX_BYTES = 16_384;
|
|
11
|
+
const own = info => typeof process.getuid !== "function" || info.uid === process.getuid();
|
|
12
|
+
|
|
13
|
+
export const claudeConfigDir = env => typeof env?.CLAUDE_CONFIG_DIR === "string"
|
|
14
|
+
&& path.isAbsolute(env.CLAUDE_CONFIG_DIR)
|
|
15
|
+
? env.CLAUDE_CONFIG_DIR : path.join(os.homedir(), ".claude");
|
|
16
|
+
|
|
17
|
+
/** The registry entry for one pid, or null. Bounded, owner-checked, never through a link. */
|
|
18
|
+
export async function readSessionRecord({ configDir, clientPid }) {
|
|
19
|
+
if (!Number.isInteger(clientPid) || clientPid <= 0 || typeof configDir !== "string") return null;
|
|
20
|
+
let handle;
|
|
21
|
+
try {
|
|
22
|
+
handle = await open(path.join(configDir, "sessions", `${clientPid}.json`),
|
|
23
|
+
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
24
|
+
const info = await handle.stat();
|
|
25
|
+
if (!info.isFile() || !own(info) || info.size > MAX_BYTES) return null;
|
|
26
|
+
const record = JSON.parse(await handle.readFile("utf8"));
|
|
27
|
+
return record?.pid === clientPid && typeof record.sessionId === "string" && record.sessionId !== ""
|
|
28
|
+
&& typeof record.messagingSocketPath === "string" && path.isAbsolute(record.messagingSocketPath)
|
|
29
|
+
? { pid: record.pid, sessionId: record.sessionId, messagingSocketPath: record.messagingSocketPath,
|
|
30
|
+
cwd: typeof record.cwd === "string" && path.isAbsolute(record.cwd) ? record.cwd : null }
|
|
31
|
+
: null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
} finally {
|
|
35
|
+
await handle?.close().catch(() => null);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A socket this user owns, reached without a link, closed to group and others. */
|
|
40
|
+
export async function inboxSocketIsSafe(socketPath) {
|
|
41
|
+
if (typeof socketPath !== "string" || !path.isAbsolute(socketPath)) return false;
|
|
42
|
+
try {
|
|
43
|
+
const info = await lstat(socketPath);
|
|
44
|
+
return info.isSocket() && !info.isSymbolicLink() && own(info) && (info.mode & 0o077) === 0;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether this socket is the inbox of this session, right now.
|
|
52
|
+
*
|
|
53
|
+
* The socket path alone proves nothing: pids are reused, and a new process
|
|
54
|
+
* gets a new registry entry under the same name. Requiring the registry to
|
|
55
|
+
* name both this session id and this socket is what keeps a wake from reaching
|
|
56
|
+
* whatever process holds the pid later. Returns null, or the reason it is not.
|
|
57
|
+
*
|
|
58
|
+
* `anyConversation` is for the bind alone. Claude Code runs SessionStart for a
|
|
59
|
+
* /resume before it rewrites the entry, so there the hook's own process and
|
|
60
|
+
* socket are the proof; every offer and refresh still requires the session id.
|
|
61
|
+
*/
|
|
62
|
+
export async function verifyInbox({ configDir, clientPid, sessionId, socketPath, anyConversation = false }) {
|
|
63
|
+
if (!await inboxSocketIsSafe(socketPath)) return "native_endpoint_unavailable";
|
|
64
|
+
const record = await readSessionRecord({ configDir, clientPid });
|
|
65
|
+
if (record === null || (!anyConversation && record.sessionId !== sessionId)) {
|
|
66
|
+
return "native_session_unavailable";
|
|
67
|
+
}
|
|
68
|
+
const [registered, expected] = await Promise.all([
|
|
69
|
+
realpath(record.messagingSocketPath).catch(() => null), realpath(socketPath).catch(() => null)]);
|
|
70
|
+
return registered !== null && registered === expected ? null : "native_session_unavailable";
|
|
71
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// What Claude Code does with a wake from a sender that attests no permission
|
|
6
|
+
// mode - ACC never attests one. A session that bypasses permission prompts
|
|
7
|
+
// holds it for approval; `crossSessionInbound` overrides that both ways. ACC
|
|
8
|
+
// reads the same inputs so the sender is told what will happen, and never
|
|
9
|
+
// changes any of them.
|
|
10
|
+
|
|
11
|
+
const MAX_BYTES = 256 * 1024;
|
|
12
|
+
const RECEPTIONS = Object.freeze(["delivered", "held", "refused"]);
|
|
13
|
+
|
|
14
|
+
export const MANAGED_SETTINGS = Object.freeze({
|
|
15
|
+
darwin: "/Library/Application Support/ClaudeCode/managed-settings.json",
|
|
16
|
+
linux: "/etc/claude-code/managed-settings.json",
|
|
17
|
+
win32: "C:\\Program Files\\ClaudeCode\\managed-settings.json",
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
async function readSettings(file) {
|
|
21
|
+
try {
|
|
22
|
+
const text = await readFile(file, "utf8");
|
|
23
|
+
if (Buffer.byteLength(text) > MAX_BYTES) return null;
|
|
24
|
+
const value = JSON.parse(text);
|
|
25
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const text = value => (typeof value === "string" && value !== "" ? value : null);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The two inputs, each from the first file that sets it, in Claude Code's own
|
|
35
|
+
* order: managed policy, then the project's local and shared settings, then
|
|
36
|
+
* the user's.
|
|
37
|
+
*/
|
|
38
|
+
export async function readInboundSettings({ configDir, projectDir, managedSettingsPath }) {
|
|
39
|
+
const files = [managedSettingsPath,
|
|
40
|
+
...(typeof projectDir === "string" && path.isAbsolute(projectDir)
|
|
41
|
+
? [path.join(projectDir, ".claude", "settings.local.json"),
|
|
42
|
+
path.join(projectDir, ".claude", "settings.json")] : []),
|
|
43
|
+
path.join(configDir, "settings.json")].filter(file => typeof file === "string");
|
|
44
|
+
const layers = await Promise.all(files.map(readSettings));
|
|
45
|
+
const first = pick => layers.map(layer => (layer === null ? null : pick(layer))).find(value => value !== null)
|
|
46
|
+
?? null;
|
|
47
|
+
return {
|
|
48
|
+
crossSessionInbound: first(layer => text(layer.crossSessionInbound)),
|
|
49
|
+
defaultMode: first(layer => text(layer.permissions?.defaultMode)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The permission mode a Claude Code process was started with, from its own
|
|
55
|
+
* arguments, or null. Only the two flags are read; nothing else is kept.
|
|
56
|
+
*/
|
|
57
|
+
export function permissionModeFromArgs(args) {
|
|
58
|
+
if (!Array.isArray(args)) return null;
|
|
59
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
60
|
+
const arg = args[index];
|
|
61
|
+
if (arg === "--dangerously-skip-permissions") return "bypassPermissions";
|
|
62
|
+
if (arg === "--permission-mode") return text(args[index + 1]);
|
|
63
|
+
if (typeof arg === "string" && arg.startsWith("--permission-mode=")) {
|
|
64
|
+
return text(arg.slice("--permission-mode=".length));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A process's arguments as `ps` prints them, split on spaces, or null. */
|
|
71
|
+
export function readProcessArgs(pid, { timeoutMs = 500 } = {}) {
|
|
72
|
+
if (!Number.isInteger(pid) || pid <= 0 || process.platform === "win32") return Promise.resolve(null);
|
|
73
|
+
return new Promise(resolve => {
|
|
74
|
+
execFile("ps", ["-o", "args=", "-p", String(pid)], { timeout: timeoutMs, maxBuffer: 64 * 1024 },
|
|
75
|
+
(error, stdout) => resolve(error ? null : String(stdout).trim().split(/\s+/).filter(Boolean)));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function receptionOf({ permissionMode, crossSessionInbound }) {
|
|
80
|
+
if (crossSessionInbound === "accept") return "delivered";
|
|
81
|
+
if (crossSessionInbound === "refuse") return "refused";
|
|
82
|
+
if (crossSessionInbound === "hold" || permissionMode === "bypassPermissions") return "held";
|
|
83
|
+
return "delivered";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const isReception = value => RECEPTIONS.includes(value);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What `acc doctor` tells a person who turned live delivery on: the machine-wide
|
|
90
|
+
* view, from managed policy and the user's settings. A project can still set
|
|
91
|
+
* its own value; the bind reads that one per session.
|
|
92
|
+
*/
|
|
93
|
+
export async function inboundStatus({ configDir, managedSettingsPath }) {
|
|
94
|
+
const userFile = path.join(configDir, "settings.json");
|
|
95
|
+
for (const file of [managedSettingsPath, userFile].filter(item => typeof item === "string")) {
|
|
96
|
+
const value = text((await readSettings(file))?.crossSessionInbound);
|
|
97
|
+
if (value === "accept") return { state: "accepted", diagnostic: null };
|
|
98
|
+
if (value === "refuse") {
|
|
99
|
+
return { state: "refused", diagnostic: `crossSessionInbound is "refuse" in ${file}, so `
|
|
100
|
+
+ "Claude Code drops every ACC wake; messages arrive with each next turn; set it to "
|
|
101
|
+
+ "\"accept\" for live delivery" };
|
|
102
|
+
}
|
|
103
|
+
if (value !== null) break;
|
|
104
|
+
}
|
|
105
|
+
return { state: "attention", diagnostic: "a session that bypasses permission prompts asks "
|
|
106
|
+
+ `before each ACC wake; add "crossSessionInbound": "accept" to ${userFile} to take wakes `
|
|
107
|
+
+ "without asking; the next turn shows the message either way" };
|
|
108
|
+
}
|
|
109
|
+
|
|
@@ -7,13 +7,14 @@ import path from "node:path";
|
|
|
7
7
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
|
|
10
|
-
import { acccreatedFile, bakeSkillCommand, blankJson,
|
|
10
|
+
import { acccreatedFile, bakeSkillCommand, blankJson,
|
|
11
11
|
mergeOwnedEntries, ownedEntries,
|
|
12
12
|
keepVersions, ownVersion, stampPluginVersion,
|
|
13
13
|
removeIfEmpty,
|
|
14
14
|
removeInstalledTree,
|
|
15
15
|
removeOwnedEntries, writeCliShim, writeForeignJson, writeHookShim }
|
|
16
16
|
from "@agents-can-communicate/adapter-sdk";
|
|
17
|
+
import { MANAGED_SETTINGS, inboundStatus } from "./inbox-settings.mjs";
|
|
17
18
|
|
|
18
19
|
const bundle = fileURLToPath(new URL("../plugin", import.meta.url));
|
|
19
20
|
const manifest = fileURLToPath(new URL("../plugin/.claude-plugin/plugin.json",
|
|
@@ -22,9 +23,6 @@ const manifest = fileURLToPath(new URL("../plugin/.claude-plugin/plugin.json",
|
|
|
22
23
|
const PLUGIN_NAME = "agents-can-communicate";
|
|
23
24
|
const MARKETPLACE = "acc-local";
|
|
24
25
|
const QUALIFIED = `${PLUGIN_NAME}@${MARKETPLACE}`;
|
|
25
|
-
const CHANNEL_ACTIVATION_CHECK = "Check Claude's Channels startup notice for ACC; "
|
|
26
|
-
+ "an MCP connection does not verify inbound delivery. If Channels are unavailable "
|
|
27
|
-
+ "or blocked, resolve that client warning before expecting live messages.";
|
|
28
26
|
|
|
29
27
|
/**
|
|
30
28
|
* How this client actually installs a plugin.
|
|
@@ -147,24 +145,21 @@ const marketplaceManifest = () => ({
|
|
|
147
145
|
}],
|
|
148
146
|
});
|
|
149
147
|
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// removed entirely for a non-live install so a plain launch spawns nothing.
|
|
148
|
+
// Up to 0.7.x a live install wrote the Channel MCP server here, and Claude
|
|
149
|
+
// Code spawned it for every session that enabled the plugin. The inbox wake
|
|
150
|
+
// needs no MCP server, so no plugin copy ACC keeps may name one.
|
|
154
151
|
const mcpPath = target => path.join(target, ".mcp.json");
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
152
|
+
|
|
153
|
+
async function removeChannelConfig(versionsRoot) {
|
|
154
|
+
const versions = await readdir(versionsRoot).catch(() => []);
|
|
155
|
+
await Promise.all(versions.map(version => rm(mcpPath(path.join(versionsRoot, version)),
|
|
156
|
+
{ force: true })));
|
|
158
157
|
}
|
|
159
158
|
|
|
160
159
|
/** A plugin tree with the shim written and the skill's command baked in. */
|
|
161
|
-
async function layOutPlugin(target, { runner, node, cli
|
|
160
|
+
async function layOutPlugin(target, { runner, node, cli }) {
|
|
162
161
|
await rm(target, { recursive: true, force: true });
|
|
163
162
|
await cp(bundle, target, { recursive: true });
|
|
164
|
-
// The bundle ships a placeholder .mcp.json; the real one is written only for a
|
|
165
|
-
// live install, and a non-live tree carries none.
|
|
166
|
-
await rm(mcpPath(target), { force: true });
|
|
167
|
-
if (live) await writeChannelMcp(target, { node, channel });
|
|
168
163
|
// The skill ships with a placeholder where the command belongs: `acc` is not
|
|
169
164
|
// on PATH everywhere, and an agent that cannot run it improvises. The shim
|
|
170
165
|
// carries the pinning so each example can name one path. No data home is
|
|
@@ -181,9 +176,8 @@ async function layOutPlugin(target, { runner, node, cli, channel, live }) {
|
|
|
181
176
|
version: await pluginVersion(), io: { readFile, writeFile } });
|
|
182
177
|
}
|
|
183
178
|
|
|
184
|
-
export async function installClaudePlugin({ configDir, runner, cli, keepPreviousVersion = null,
|
|
185
|
-
|
|
186
|
-
const live = livePolicy === "actionable" || livePolicy === "all";
|
|
179
|
+
export async function installClaudePlugin({ configDir, runner, cli, keepPreviousVersion = null,
|
|
180
|
+
node = process.execPath, now = new Date() }) {
|
|
187
181
|
// Everything this will merge into, read before a byte is written. A settings
|
|
188
182
|
// file that will not parse used to be discovered after the plugin tree was
|
|
189
183
|
// already on disk, and the install then failed with nineteen files left
|
|
@@ -200,12 +194,12 @@ export async function installClaudePlugin({ configDir, runner, cli, keepPrevious
|
|
|
200
194
|
const source = sourceDir(configDir);
|
|
201
195
|
const cached = cachePath(configDir, version);
|
|
202
196
|
|
|
203
|
-
await layOutPlugin(source, { runner, node, cli
|
|
197
|
+
await layOutPlugin(source, { runner, node, cli });
|
|
204
198
|
await writeJson(marketplaceFile(configDir), marketplaceManifest());
|
|
205
199
|
// The copy the client runs from. Written here rather than asking the user to
|
|
206
200
|
// run `claude plugin install`, exactly as the Codex adapter does, because the
|
|
207
201
|
// command's only effect is this copy plus the two registry entries below.
|
|
208
|
-
await layOutPlugin(cached, { runner, node, cli
|
|
202
|
+
await layOutPlugin(cached, { runner, node, cli });
|
|
209
203
|
// The copy just written, plus the one an upgrade moved off. A client caches a
|
|
210
204
|
// plugin under its version, so every upgrade would otherwise leave the previous
|
|
211
205
|
// release's tree beside this one - invisible while the version never moved,
|
|
@@ -214,6 +208,10 @@ export async function installClaudePlugin({ configDir, runner, cli, keepPrevious
|
|
|
214
208
|
// anything older than that holds no session and is litter.
|
|
215
209
|
await keepVersions({ root: path.dirname(cached), keep: [version, keepPreviousVersion],
|
|
216
210
|
io: { readdir, rm } });
|
|
211
|
+
// The copy an upgrade moved off stays for sessions already running from it,
|
|
212
|
+
// but its Channel config would still start a Channel for any session that
|
|
213
|
+
// reads it. Those sessions keep what they loaded; nothing new starts one.
|
|
214
|
+
await removeChannelConfig(path.dirname(cached));
|
|
217
215
|
|
|
218
216
|
await writeClientJson(knownMarketplacesPath(configDir), {
|
|
219
217
|
...known,
|
|
@@ -254,12 +252,9 @@ export async function installClaudePlugin({ configDir, runner, cli, keepPrevious
|
|
|
254
252
|
|
|
255
253
|
return { ok: true,
|
|
256
254
|
changes: [source, cached, marketplaceFile(configDir),
|
|
257
|
-
knownMarketplacesPath(configDir), installedPluginsPath(configDir), file,
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
diagnostics: live
|
|
261
|
-
? ["native channel wired; Claude's experimental development-channel warning still applies"]
|
|
262
|
-
: [] };
|
|
255
|
+
knownMarketplacesPath(configDir), installedPluginsPath(configDir), file],
|
|
256
|
+
needsAction: [],
|
|
257
|
+
diagnostics: [] };
|
|
263
258
|
}
|
|
264
259
|
|
|
265
260
|
export async function uninstallClaudePlugin({ configDir, keep = [] }) {
|
|
@@ -297,16 +292,15 @@ export async function uninstallClaudePlugin({ configDir, keep = [] }) {
|
|
|
297
292
|
return { ok: true, changes, diagnostics: [] };
|
|
298
293
|
}
|
|
299
294
|
|
|
300
|
-
export async function detectClaude({ configDir }) {
|
|
295
|
+
export async function detectClaude({ configDir, managedSettingsPath = MANAGED_SETTINGS[process.platform] }) {
|
|
301
296
|
const settings = await readJson(settingsPath(configDir), null);
|
|
302
297
|
const enabled = settings?.enabledPlugins?.[QUALIFIED] === true;
|
|
303
298
|
const registered = Object.hasOwn(
|
|
304
299
|
(await readJson(installedPluginsPath(configDir), { plugins: {} })).plugins ?? {},
|
|
305
300
|
QUALIFIED);
|
|
306
|
-
const channelConfigured = enabled && registered
|
|
307
|
-
&& (await readJson(mcpPath(sourceDir(configDir)), null))?.mcpServers?.["acc-channel"] != null;
|
|
308
301
|
return { ok: true, changes: [],
|
|
309
|
-
|
|
302
|
+
inboundDelivery: await inboundStatus({ configDir, managedSettingsPath }),
|
|
303
|
+
needsAction: [],
|
|
310
304
|
diagnostics: [enabled && registered
|
|
311
305
|
? "acc plugin registered and enabled"
|
|
312
306
|
: "acc plugin not registered"] };
|
|
@@ -18,7 +18,7 @@ import { planCodexInstall, detectCodex, installCodexPlugin, preflightCodexUninst
|
|
|
18
18
|
export { CODEX_QUEUE_MINIMUM };
|
|
19
19
|
export const CODEX_VERSION = "0.147.0";
|
|
20
20
|
export const CODEX_DELIVERY_FALLBACK = Object.freeze({
|
|
21
|
-
diagnostic: "Codex native delivery requires codex-cli 0.152.1 or newer
|
|
21
|
+
diagnostic: "Codex native delivery requires codex-cli 0.152.1 or newer, "
|
|
22
22
|
+ "recorded recipient consent and a reachable LocalDaemon session with exact thread, cwd, "
|
|
23
23
|
+ "process, version and protocol verification. Embedded or unreachable sessions retain "
|
|
24
24
|
+ "durable messages. Message delivery does not start, restart or stop the vendor daemon and adds no "
|
|
@@ -61,9 +61,8 @@ export function createCodexAdapter() {
|
|
|
61
61
|
// Native acceleration is a separate, per-session eligibility decision.
|
|
62
62
|
|
|
63
63
|
nativeDelivery: {
|
|
64
|
-
|
|
65
|
-
anchors: [{
|
|
66
|
-
protocolContract: PROTOCOL_CONTRACT }], knownBad: [],
|
|
64
|
+
minimum: CODEX_QUEUE_MINIMUM,
|
|
65
|
+
anchors: [{ version: CODEX_QUEUE_MINIMUM, protocolContract: PROTOCOL_CONTRACT }], knownBad: [],
|
|
67
66
|
activationKinds: ["native-service"], policySource: "installation-record",
|
|
68
67
|
},
|
|
69
68
|
probeNativeDelivery, planNativeActivation, bindNativeSession,
|
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
export
|
|
34
|
-
|
|
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 (
|
|
62
|
-
return { supported: false, serverVersion, reasonCode: "
|
|
58
|
+
if (versionOrder(serverVersion) === null) {
|
|
59
|
+
return { supported: false, serverVersion, reasonCode: "version_unavailable" };
|
|
63
60
|
}
|
|
64
|
-
if (
|
|
61
|
+
if (compareVersions(serverVersion, minimum) < 0) {
|
|
65
62
|
return { supported: false, serverVersion, reasonCode: "below_minimum_version" };
|
|
66
63
|
}
|
|
67
64
|
try {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { channelSocketDirectory, tomlString } from "@agents-can-communicate/adapter-sdk";
|
|
3
|
-
import {
|
|
3
|
+
import { compareVersions } from "./app-server-client.mjs";
|
|
4
4
|
import { addPermissions, inspectPermissions } from "./permission-ownership.mjs";
|
|
5
5
|
import { scanConfig } from "./toml-scan.mjs";
|
|
6
6
|
|
|
@@ -8,9 +8,9 @@ const PROFILE = "acc-workspace";
|
|
|
8
8
|
const MINIMUM = "0.153.4";
|
|
9
9
|
const equal = (keys, expected) => JSON.stringify(keys) === JSON.stringify(expected);
|
|
10
10
|
const literal = value => { try { return JSON.parse(value); } catch { return null; } };
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// Captured on darwin-arm64 with 0.153.4; the capture's minimum applies on
|
|
12
|
+
// every platform, and a prerelease is judged by its release triple.
|
|
13
|
+
const supported = context => compareVersions(context.clientVersion, MINIMUM) >= 0;
|
|
14
14
|
const hasPermissions = entries => entries.some(entry =>
|
|
15
15
|
["permissions", "default_permissions", "profile", "profiles"].includes(entry.keys[0]));
|
|
16
16
|
const configured = (source, context) => {
|
|
@@ -69,7 +69,7 @@ export function outgoingStatus(source, context) {
|
|
|
69
69
|
? "outgoing live delivery: local socket permissions configured for new sessions; active session overrides remain unverified"
|
|
70
70
|
: `outgoing live delivery: sender permissions unverified in ${context.file}; `
|
|
71
71
|
+ (reasonCode === "permission_configuration_uncaptured"
|
|
72
|
-
? `automatic setup requires Codex ${MINIMUM} or newer
|
|
72
|
+
? `automatic setup requires Codex ${MINIMUM} or newer`
|
|
73
73
|
: custom
|
|
74
74
|
? "custom permission policy was preserved; manually allow ACC state and local sockets "
|
|
75
75
|
+ "through the network proxy in the effective workspace profile"
|