agents-can-communicate 0.4.2 → 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.
- package/README.md +8 -2
- package/docs/CONFIGURATION.md +25 -0
- package/docs/GETTING_STARTED.md +7 -3
- package/docs/PROTOCOL.md +4 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +7 -0
- package/docs/TROUBLESHOOTING.md +17 -2
- package/docs/UPGRADING.md +16 -11
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/channel.mjs +2 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/native-delivery.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/config-block.mjs +4 -88
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -15
- package/node_modules/@agents-can-communicate/adapter-codex/src/live-permissions.mjs +110 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +1 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +109 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/toml-scan.mjs +99 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/channel-directory.mjs +8 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +3 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +3 -1
- package/node_modules/@agents-can-communicate/cli/src/native-delivery-status.mjs +1 -1
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/receipts.mjs +1 -0
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -0
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +5 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +6 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,6 +65,12 @@ then restart your clients from the project directory. In Codex, check `/plugins`
|
|
|
65
65
|
review the current ACC definitions in `/hooks`; changed hooks may need fresh trust.
|
|
66
66
|
[Getting started](docs/GETTING_STARTED.md) covers activation and preserved sandbox settings.
|
|
67
67
|
|
|
68
|
+
For Codex live delivery on Apple Silicon macOS, installation also configures outgoing
|
|
69
|
+
local socket access on Codex 0.153.4 or newer when using default workspace permissions.
|
|
70
|
+
Custom policies are preserved. If its local service is missing, doctor gives the command
|
|
71
|
+
`codex app-server daemon start`; then open a new Codex session. Doctor reports receiving
|
|
72
|
+
channel state and outgoing permission configuration separately.
|
|
73
|
+
|
|
68
74
|
Open two sessions and give them ordinary tasks, as above. Look for an agent discovering a
|
|
69
75
|
peer, checking who is changing a file, asking about a shared dependency, or replying to a
|
|
70
76
|
review request.
|
|
@@ -73,8 +79,8 @@ Run `acc doctor` from the project if a peer is missing. A directory containing A
|
|
|
73
79
|
state, commonly your home directory, cannot be used as a workspace; start the client in a
|
|
74
80
|
project directory. See [Troubleshooting](docs/TROUBLESHOOTING.md).
|
|
75
81
|
|
|
76
|
-
Already using ACC? Follow the [upgrade guide](docs/UPGRADING.md), including the 0.4.
|
|
77
|
-
0.4.
|
|
82
|
+
Already using ACC? Follow the [upgrade guide](docs/UPGRADING.md), including the 0.4.x →
|
|
83
|
+
0.4.3 update and the data-format boundary when moving from 0.3.1.
|
|
78
84
|
|
|
79
85
|
## When messages arrive
|
|
80
86
|
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -144,6 +144,31 @@ consent from the installation record, not a shell-shim variable. Unavailable or
|
|
|
144
144
|
sessions retain durable inbox fallback. Use `acc install --adapter codex --delivery off`
|
|
145
145
|
to stop new native offers; bypassing a shim does not disable that recorded opt-in.
|
|
146
146
|
|
|
147
|
+
### Codex outgoing permissions
|
|
148
|
+
|
|
149
|
+
Receiving a native message and sending from the agent's sandbox are separate operations.
|
|
150
|
+
On Codex 0.153.4 or newer on macOS arm64, live opt-in also configures outgoing access
|
|
151
|
+
for default workspace permissions. This happens even when the daemon is not yet available.
|
|
152
|
+
Installation, its preview, and the interactive consent question disclose this change.
|
|
153
|
+
|
|
154
|
+
ACC selects a permission profile named `acc-workspace`, extending `:workspace`. It grants
|
|
155
|
+
write access to the ACC state directory and allows only the local ACC channel directory
|
|
156
|
+
(`/tmp/acc-ch-<uid>`) and the Codex home control socket. The profile enables networking
|
|
157
|
+
through `features.network_proxy = true`, with no external domains allowed. The proxy and
|
|
158
|
+
socket grants must stay together: enabling networking without the proxy changes its scope.
|
|
159
|
+
|
|
160
|
+
An existing legacy workspace-write configuration is migrated only when its sole writable
|
|
161
|
+
root is ACC's state directory. Custom permission/configuration profiles, additional roots,
|
|
162
|
+
inline settings and uncaptured clients are preserved and reported as unverified. ACC does
|
|
163
|
+
not merge arbitrary security policies or verify overrides in an already running session.
|
|
164
|
+
For a custom policy, grant the same state and socket access through the proxy in the
|
|
165
|
+
client's effective workspace profile; do not combine it with legacy sandbox settings.
|
|
166
|
+
|
|
167
|
+
Restart Codex after changing permissions. Explicit delivery `off` or uninstall restores
|
|
168
|
+
the previous configuration only while every generated permission component is unchanged.
|
|
169
|
+
If any component was edited or another setting depends on it, ACC preserves the entire
|
|
170
|
+
bundle and reports it for review. Plugin registration can still be removed independently.
|
|
171
|
+
|
|
147
172
|
## Override local paths and identity
|
|
148
173
|
|
|
149
174
|
Nothing in `acc.workspace.json` says where state is stored, and nothing there can — that is
|
package/docs/GETTING_STARTED.md
CHANGED
|
@@ -15,7 +15,7 @@ pair or session-bound ACC MCP tools. See [CLI ownership](CLI.md#coordinate-from-
|
|
|
15
15
|
|
|
16
16
|
ACC requires macOS or Linux and Node.js 24 or newer.
|
|
17
17
|
|
|
18
|
-
Already using ACC? Follow the [upgrade guide](UPGRADING.md) for the 0.4.
|
|
18
|
+
Already using ACC? Follow the [upgrade guide](UPGRADING.md) for the 0.4.x → 0.4.3
|
|
19
19
|
update or the data-format boundary when upgrading from 0.3.1.
|
|
20
20
|
|
|
21
21
|
```bash
|
|
@@ -39,8 +39,12 @@ printed by the installer. In Codex, use `/plugins` to check ACC is enabled, then
|
|
|
39
39
|
to review each ACC hook and enable/trust its current definition if needed. Restart the
|
|
40
40
|
session after that review; changed hook definitions may require trust again.
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
For Codex live delivery, follow the outgoing-permission and local-service instructions
|
|
43
|
+
from install/doctor, then start a new session. Default workspace permissions can be
|
|
44
|
+
configured automatically on Codex 0.153.4 or newer on macOS arm64. Custom settings are
|
|
45
|
+
preserved; see [outgoing permissions](CONFIGURATION.md#codex-outgoing-permissions).
|
|
46
|
+
With legacy sandbox settings, the named ACC state directory must also be in
|
|
47
|
+
`sandbox_workspace_write.writable_roots` in the config the installer identifies.
|
|
44
48
|
ACC keeps those user settings unchanged. Installed files alone do not establish that hooks
|
|
45
49
|
are active; `acc doctor` leaves current readiness unverified and directs you to Codex.
|
|
46
50
|
|
package/docs/PROTOCOL.md
CHANGED
|
@@ -199,6 +199,10 @@ peer body into diagnostics.
|
|
|
199
199
|
Receipt `offered` is committed only after the transport accepts bytes. A failed attempt
|
|
200
200
|
leaves the receipt queued.
|
|
201
201
|
|
|
202
|
+
`transport_permission_denied` means the sender's OS or sandbox refused local transport
|
|
203
|
+
access (`EPERM` or `EACCES`). It is distinct from `recipient_unavailable` and remains a
|
|
204
|
+
failed offer with a queued receipt; raw OS error strings are not persisted.
|
|
205
|
+
|
|
202
206
|
## Inbox, reply, and acknowledgement
|
|
203
207
|
|
|
204
208
|
Without an id, public `inbox` lists bounded summary pages for unresolved messages owned
|
package/docs/RELEASING.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Releasing
|
|
2
2
|
|
|
3
3
|
Use this procedure to build one auditable npm artifact, verify that exact tarball, and keep
|
|
4
|
-
its evidence tied to the commit that supplied its bytes. The package is currently `0.4.
|
|
4
|
+
its evidence tied to the commit that supplied its bytes. The package is currently `0.4.3`;
|
|
5
5
|
the commands derive the version from `package.json` so the filename cannot drift.
|
|
6
6
|
|
|
7
7
|
```mermaid
|
package/docs/SECURITY_MODEL.md
CHANGED
|
@@ -79,6 +79,13 @@ consent before offers. A loaded daemon thread can receive messages after its TUI
|
|
|
79
79
|
exits. Delivery off and uninstall prevent new Codex submissions; a submission
|
|
80
80
|
already accepted by the vendor queue cannot be withdrawn.
|
|
81
81
|
|
|
82
|
+
On captured Codex/macOS configurations, live opt-in also grants outgoing access to ACC's
|
|
83
|
+
state and local Unix sockets through a deny-by-default network proxy. Custom permission
|
|
84
|
+
policies remain user-owned. Selection, proxy and grants are restored together only while
|
|
85
|
+
unchanged; edited or newly referenced settings remain intact. See
|
|
86
|
+
[Codex outgoing permissions](CONFIGURATION.md#codex-outgoing-permissions). Configuration
|
|
87
|
+
readiness does not prove that an active session's overrides permit a live offer.
|
|
88
|
+
|
|
82
89
|
## Claims
|
|
83
90
|
|
|
84
91
|
CLI claims default to advisory. Guarded enforcement must be requested explicitly and
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -100,6 +100,20 @@ ambiguous recipients or failed identity checks retain durable inbox access.
|
|
|
100
100
|
An interactive install can save consent in either case. ACC preserves it through a temporary
|
|
101
101
|
outage and does not start the daemon for you.
|
|
102
102
|
|
|
103
|
+
On a supported Codex CLI, a missing daemon can be started with
|
|
104
|
+
`codex app-server daemon start`; open a new Codex session afterward. Doctor includes this
|
|
105
|
+
command when it detects the missing endpoint.
|
|
106
|
+
|
|
107
|
+
If a reply stays queued with `transport_permission_denied`, the sender's permissions
|
|
108
|
+
blocked local transport. This is distinct from an unavailable recipient. The message was
|
|
109
|
+
recorded successfully; the error does not mean it was read or acknowledged.
|
|
110
|
+
On Codex 0.153.4 or newer on macOS arm64, rerun `acc install --adapter codex` with existing
|
|
111
|
+
live consent (or add `--delivery actionable` to opt in), then start a new session.
|
|
112
|
+
Doctor's `outgoingDelivery` reports the installed permission configuration separately
|
|
113
|
+
from `nativeDelivery.runtime`: an active receiving channel does not establish outgoing
|
|
114
|
+
access. Custom policies and active-session overrides remain unverified; inspect the config
|
|
115
|
+
path doctor names and follow [outgoing permissions](CONFIGURATION.md#codex-outgoing-permissions).
|
|
116
|
+
|
|
103
117
|
Check `acc doctor --json` and `acc status --json` while both clients are open. If policy is
|
|
104
118
|
`off`, opt in with `acc install --adapter codex --delivery actionable` (or select another
|
|
105
119
|
adapter). This can spend model tokens. If policy is enabled but runtime is `waiting` and
|
|
@@ -153,8 +167,9 @@ definitions need review, and a trusted hook can still be disabled.
|
|
|
153
167
|
|
|
154
168
|
`acc doctor` reports installed files separately from hook readiness, which it leaves
|
|
155
169
|
unverified. A saved trust record cannot prove current activation. If installation preserved
|
|
156
|
-
your own sandbox configuration, doctor also names the ACC state directory whose
|
|
157
|
-
you should check in `sandbox_workspace_write.writable_roots`.
|
|
170
|
+
your own legacy sandbox configuration, doctor also names the ACC state directory whose
|
|
171
|
+
access you should check in `sandbox_workspace_write.writable_roots`. Live delivery requires
|
|
172
|
+
local socket permissions as well; a writable state directory alone is insufficient.
|
|
158
173
|
|
|
159
174
|
## Gemini does not guard a write
|
|
160
175
|
|
package/docs/UPGRADING.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# Upgrading to 0.4.
|
|
1
|
+
# Upgrading to 0.4.3
|
|
2
2
|
|
|
3
|
-
## From 0.4.1 or 0.4.0
|
|
3
|
+
## From 0.4.2, 0.4.1 or 0.4.0
|
|
4
4
|
|
|
5
|
-
This patch keeps the existing workspace data format. It
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
This patch keeps the existing workspace data format. It fixes outgoing native delivery
|
|
6
|
+
from Codex's sandbox, separates permission failures from unavailable recipients, and
|
|
7
|
+
shows outgoing setup alongside receiving-channel state in doctor. It also includes the
|
|
8
|
+
previous 0.4.x delivery diagnostics, session recovery and client integration fixes.
|
|
9
9
|
|
|
10
10
|
Close participating clients and persistent ACC MCP processes, then run:
|
|
11
11
|
|
|
@@ -15,10 +15,15 @@ acc version
|
|
|
15
15
|
acc doctor
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
After publication, `acc version` should report 0.4.
|
|
19
|
-
select 0.4.
|
|
18
|
+
After publication, `acc version` should report 0.4.3. If a pin keeps an older version,
|
|
19
|
+
select 0.4.3 or clear it first. A managed update refreshes installed integrations and
|
|
20
20
|
skills; use the npm instructions below for unmanaged or pre-0.4 installations. Start
|
|
21
|
-
clients again and complete any hook/trust review they request.
|
|
21
|
+
clients again and complete any hook/trust review they request. With existing live consent,
|
|
22
|
+
Codex 0.153.4 or newer on macOS arm64 receives local socket permissions when its workspace
|
|
23
|
+
policy is default or contains only ACC's legacy state root. Custom policies are preserved
|
|
24
|
+
and reported as unverified. See [outgoing permissions](CONFIGURATION.md#codex-outgoing-permissions).
|
|
25
|
+
If doctor names a missing local service, run `codex app-server daemon start` before opening
|
|
26
|
+
the new session.
|
|
22
27
|
|
|
23
28
|
Run `acc doctor` in the project after the new clients have started (or submitted a normal
|
|
24
29
|
turn). Its per-session lines now explain missing launch consent, an unidentified client
|
|
@@ -59,7 +64,7 @@ restores access; deleting or editing the record is not a remedy.
|
|
|
59
64
|
2. Install the released package, then refresh the client integrations:
|
|
60
65
|
|
|
61
66
|
```bash
|
|
62
|
-
npm install --global agents-can-communicate@0.4.
|
|
67
|
+
npm install --global agents-can-communicate@0.4.3
|
|
63
68
|
acc version
|
|
64
69
|
acc install
|
|
65
70
|
```
|
|
@@ -109,7 +114,7 @@ acc update # download and apply, or report what is keeping it
|
|
|
109
114
|
acc update --check # check without installing or changing update settings
|
|
110
115
|
acc update --auto off # disable background updates
|
|
111
116
|
acc update --auto on # enable them again
|
|
112
|
-
acc update --pin 0.4.
|
|
117
|
+
acc update --pin 0.4.3 # stay on this exact stable version
|
|
113
118
|
acc update --pin none # follow stable releases again
|
|
114
119
|
```
|
|
115
120
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import net from "node:net";
|
|
4
|
-
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
5
|
+
import { channelSocketDirectory } from "@agents-can-communicate/adapter-sdk";
|
|
6
6
|
|
|
7
7
|
// The production ACC Channel: a stdio MCP server Claude Code spawns, plus a
|
|
8
8
|
// session-scoped Unix endpoint the delivery router reaches. One envelope on the
|
|
@@ -39,8 +39,6 @@ const shortId = () => `endpoint_${randomBytes(16).toString("hex")}`;
|
|
|
39
39
|
// while the registration - discoverable by the hook-resolved pid - stays under
|
|
40
40
|
// the workspace. The registration records the socket's absolute path, so the
|
|
41
41
|
// two are decoupled.
|
|
42
|
-
const defaultSocketDir = () => path.join(os.tmpdir(),
|
|
43
|
-
`acc-ch-${typeof process.getuid === "function" ? process.getuid() : "u"}`);
|
|
44
42
|
|
|
45
43
|
/**
|
|
46
44
|
* Compose a Channel over an endpoint directory and injected ACC callbacks.
|
|
@@ -49,7 +47,7 @@ const defaultSocketDir = () => path.join(os.tmpdir(),
|
|
|
49
47
|
* ones backed by the real conversation service; a test supplies fakes. Neither
|
|
50
48
|
* the socket path nor the nonce leaves through a return value or a log line.
|
|
51
49
|
*/
|
|
52
|
-
export function createAccChannel({ endpointDir, socketDir =
|
|
50
|
+
export function createAccChannel({ endpointDir, socketDir = channelSocketDirectory(), clientPid,
|
|
53
51
|
protocolContract = PROTOCOL_CONTRACT, modes = CHANNEL_MODES, leaseMs = 60_000, routeReply,
|
|
54
52
|
routeAck, refreshBinding = null, observe = () => {},
|
|
55
53
|
clock = () => new Date().toISOString(), write, now = Date.now }) {
|
|
@@ -206,7 +206,9 @@ function sendEnvelope({ record, message, binding, connect, timeoutMs, rejected }
|
|
|
206
206
|
const finish = value => { if (settled) return; settled = true; clearTimeout(timer);
|
|
207
207
|
socket.destroy(); resolve(value); };
|
|
208
208
|
const timer = setTimeout(() => finish(rejected("transport_error")), timeoutMs);
|
|
209
|
-
socket.once("error",
|
|
209
|
+
socket.once("error", error => finish(rejected(
|
|
210
|
+
["EPERM", "EACCES"].includes(error.code)
|
|
211
|
+
? "transport_permission_denied" : "recipient_unavailable")));
|
|
210
212
|
socket.once("connect", () => {
|
|
211
213
|
socket.write(`${JSON.stringify(envelope)}\n`);
|
|
212
214
|
let buffer = "";
|
|
@@ -3,10 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { BEGIN, END, renderBlock } from "@agents-can-communicate/adapter-sdk";
|
|
4
4
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
+ "repair ambiguous TOML or ACC markers before retrying", { reason });
|
|
9
|
-
};
|
|
6
|
+
import { statements, declaration, unsafe } from "./toml-scan.mjs";
|
|
7
|
+
|
|
10
8
|
const ROOT = "# ACC sandbox writable_roots created with: ";
|
|
11
9
|
export const sandboxOwnership = root => `${ROOT}${JSON.stringify(root)}`;
|
|
12
10
|
|
|
@@ -16,88 +14,6 @@ const OWNED = new Map([
|
|
|
16
14
|
[JSON.stringify(["sandbox_workspace_write"]), ["writable_roots"]],
|
|
17
15
|
]);
|
|
18
16
|
|
|
19
|
-
// A bounded lexical scan, not a TOML value parser. Newlines inside strings,
|
|
20
|
-
// arrays and inline tables cannot introduce a header or a managed marker.
|
|
21
|
-
function* statements(source) {
|
|
22
|
-
let start = 0, code = "", quote = null;
|
|
23
|
-
const stack = [];
|
|
24
|
-
for (let i = 0; i < source.length;) {
|
|
25
|
-
const char = source[i];
|
|
26
|
-
if (quote) {
|
|
27
|
-
if (quote[0] === '"' && char === "\\") {
|
|
28
|
-
code += source.slice(i, i + 2); i += 2; continue;
|
|
29
|
-
}
|
|
30
|
-
if (source.startsWith(quote, i)) {
|
|
31
|
-
let count = quote.length;
|
|
32
|
-
if (quote.length === 3) {
|
|
33
|
-
while (source[i + count] === quote[0]) count++;
|
|
34
|
-
if (count > 5) unsafe("unsupported string quote delimiter");
|
|
35
|
-
}
|
|
36
|
-
code += source.slice(i, i + count); i += count; quote = null; continue;
|
|
37
|
-
}
|
|
38
|
-
if (quote.length === 1 && /[\r\n]/.test(char)) unsafe("newline in single-line string");
|
|
39
|
-
code += char; i++; continue;
|
|
40
|
-
}
|
|
41
|
-
if (char === "#") {
|
|
42
|
-
while (i < source.length && source[i] !== "\n") i++;
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
if (char === '"' || char === "'") {
|
|
46
|
-
quote = source.startsWith(char.repeat(3), i) ? char.repeat(3) : char;
|
|
47
|
-
code += quote; i += quote.length; continue;
|
|
48
|
-
}
|
|
49
|
-
if (char === "[" || char === "{") stack.push(char);
|
|
50
|
-
if (char === "]" || char === "}") {
|
|
51
|
-
if (stack.pop() !== (char === "]" ? "[" : "{")) unsafe("mismatched collection delimiter");
|
|
52
|
-
}
|
|
53
|
-
code += char; i++;
|
|
54
|
-
if (char === "\n" && stack.length === 0) {
|
|
55
|
-
yield { raw: source.slice(start, i), code: code.trim() };
|
|
56
|
-
start = i; code = "";
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
if (quote || stack.length) unsafe(quote ? "unclosed string" : "unclosed collection");
|
|
60
|
-
if (start < source.length) yield { raw: source.slice(start), code: code.trim() };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function decodeKey(key) {
|
|
64
|
-
if (key[0] === "'") return key.slice(1, -1);
|
|
65
|
-
if (key[0] !== '"') return key;
|
|
66
|
-
return key.slice(1, -1).replace(/\\(U[\da-fA-F]{8}|u[\da-fA-F]{4}|[btnfr"\\])|\\./g,
|
|
67
|
-
(match, escape) => {
|
|
68
|
-
if (!escape) unsafe("unsupported quoted-key escape");
|
|
69
|
-
if (/^[uU]/.test(escape)) {
|
|
70
|
-
const point = Number.parseInt(escape.slice(1), 16);
|
|
71
|
-
if (point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff)) {
|
|
72
|
-
unsafe("invalid quoted-key Unicode escape");
|
|
73
|
-
}
|
|
74
|
-
return String.fromCodePoint(point);
|
|
75
|
-
}
|
|
76
|
-
return ({ b: "\b", t: "\t", n: "\n", f: "\f", r: "\r", '"': '"', "\\": "\\" })[escape];
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function keyPath(code) {
|
|
81
|
-
const keys = [];
|
|
82
|
-
let rest = code.trimStart();
|
|
83
|
-
for (;;) {
|
|
84
|
-
const match = /^(?:[A-Za-z0-9_-]+|'[^'\r\n]*'|"(?:[^"\\\r\n]|\\[^\r\n])*")/.exec(rest);
|
|
85
|
-
if (!match) unsafe("unrecognized TOML key");
|
|
86
|
-
keys.push(decodeKey(match[0]));
|
|
87
|
-
rest = rest.slice(match[0].length).trimStart();
|
|
88
|
-
if (!rest.startsWith(".")) return { keys, rest };
|
|
89
|
-
rest = rest.slice(1).trimStart();
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function declaration(code) {
|
|
94
|
-
const header = code.startsWith("[");
|
|
95
|
-
const array = header && code.startsWith("[[");
|
|
96
|
-
const { keys, rest } = keyPath(header ? code.slice(array ? 2 : 1) : code);
|
|
97
|
-
if (header ? rest !== (array ? "]]" : "]") : !rest.startsWith("=")) unsafe();
|
|
98
|
-
return { keys, header, array, value: header ? null : rest.slice(1).trim() };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
17
|
// An unchanged, newly generated sandbox has value provenance. A legacy or
|
|
102
18
|
// edited sandbox belongs to the client as a whole and remains byte-preserved.
|
|
103
19
|
function ownsSandbox(body) {
|
|
@@ -214,8 +130,8 @@ export async function readConfig(file) {
|
|
|
214
130
|
});
|
|
215
131
|
}
|
|
216
132
|
|
|
217
|
-
export async function writeTomlBlock(file, body) {
|
|
218
|
-
const
|
|
133
|
+
export async function writeTomlBlock(file, body, preparedSource) {
|
|
134
|
+
const source = preparedSource ?? inspectConfig(await readConfig(file), file).source;
|
|
219
135
|
const separator = source === "" || source.endsWith("\n") ? "" : "\n";
|
|
220
136
|
await mkdir(path.dirname(file), { recursive: true });
|
|
221
137
|
await writeFile(file, `${source}${separator}${renderBlock(body)}\n`);
|
|
@@ -9,7 +9,8 @@ import { bakeSkillCommand, blankJson, blankText, removeIfEmpty, removeInstalledT
|
|
|
9
9
|
tomlString, writeForeignJson, writeHookShim }
|
|
10
10
|
from "@agents-can-communicate/adapter-sdk";
|
|
11
11
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
12
|
-
import { inspectConfig, readConfig,
|
|
12
|
+
import { inspectConfig, readConfig, sandboxOwnership, writeTomlBlock } from "./config-block.mjs";
|
|
13
|
+
import { outgoingStatus, prepareLivePermissions, removeLivePermissions } from "./live-permissions.mjs";
|
|
13
14
|
|
|
14
15
|
// Kept cohesive above 300 lines because Codex plugin install, cache, config,
|
|
15
16
|
// detection, and ownership share one client topology; splitting would duplicate
|
|
@@ -159,7 +160,8 @@ const sandboxReview = (config, file, stateRoot) =>
|
|
|
159
160
|
: [];
|
|
160
161
|
|
|
161
162
|
export async function installCodexPlugin({ home, agentsHome = home,
|
|
162
|
-
codexHome = path.join(home, ".codex"), dataHome, stateRoot, runner, node, cli, preserveVersions = false
|
|
163
|
+
codexHome = path.join(home, ".codex"), dataHome, stateRoot, runner, node, cli, preserveVersions = false,
|
|
164
|
+
requestedLivePolicy, livePolicy, clientVersion, platform }) {
|
|
163
165
|
// Read before writing, so a manifest that will not parse is found before a
|
|
164
166
|
// plugin tree is laid down that nothing will then be able to remove.
|
|
165
167
|
const existing = await readJson(marketplacePath(agentsHome), { name: MARKETPLACE,
|
|
@@ -179,7 +181,10 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
179
181
|
`marketplace ${MARKETPLACE} or its plugin is already registered in this config; `
|
|
180
182
|
+ "remove it and install again", { config });
|
|
181
183
|
}
|
|
182
|
-
const
|
|
184
|
+
const permissions = prepareLivePermissions(foreign.source, { home, codexHome, stateRoot,
|
|
185
|
+
file: config, requestedLivePolicy, livePolicy, clientVersion, platform });
|
|
186
|
+
const theirSandbox = permissions.skipLegacy || inspectConfig(permissions.source, config).sandbox;
|
|
187
|
+
const permissionActions = permissions.status?.state === "unverified" ? [permissions.status.diagnostic] : [];
|
|
183
188
|
|
|
184
189
|
const target = pluginPath(agentsHome);
|
|
185
190
|
await rm(target, { recursive: true, force: true });
|
|
@@ -207,7 +212,7 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
207
212
|
`[plugins.${tomlString(QUALIFIED)}]`,
|
|
208
213
|
"enabled = true",
|
|
209
214
|
...sandboxTable(stateRoot, theirSandbox),
|
|
210
|
-
]);
|
|
215
|
+
], permissions.source);
|
|
211
216
|
|
|
212
217
|
// The client runs the cached copy, so this has to happen after the shim and
|
|
213
218
|
// the rewritten hooks.json are in place.
|
|
@@ -235,9 +240,11 @@ export async function installCodexPlugin({ home, agentsHome = home,
|
|
|
235
240
|
// while ACC invented its own marketplace name and so had a root to itself.
|
|
236
241
|
// make the record stale the moment the plugin version changes.
|
|
237
242
|
return { ok: true, changes: [target, file, config, cachePath(codexHome)],
|
|
238
|
-
needsAction: [HOOK_REVIEW, ...
|
|
243
|
+
needsAction: [HOOK_REVIEW, ...permissionActions,
|
|
244
|
+
...(permissions.skipLegacy ? [] : sandboxReview(before, config, stateRoot))],
|
|
239
245
|
diagnostics: ["hooks require explicit trust in Codex before they run",
|
|
240
|
-
...
|
|
246
|
+
...(permissions.status ? [permissions.status.diagnostic] : []),
|
|
247
|
+
...(permissions.skipLegacy ? [] : sandboxReview(before, config, stateRoot))] };
|
|
241
248
|
}
|
|
242
249
|
|
|
243
250
|
/** Remove each directory that is empty, in the order given. */
|
|
@@ -255,21 +262,25 @@ export async function preflightCodexUninstall({ home, agentsHome = home,
|
|
|
255
262
|
if (error.code === "ENOENT") return "";
|
|
256
263
|
throw error;
|
|
257
264
|
});
|
|
258
|
-
|
|
265
|
+
const permissions = removeLivePermissions(inspectConfig(before, configPath(codexHome)).source);
|
|
266
|
+
return { existing, before, withoutOurs: permissions.source, permissionState: permissions.state };
|
|
259
267
|
}
|
|
260
268
|
|
|
261
269
|
export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
262
270
|
codexHome = path.join(home, ".codex"), keep = [] }) {
|
|
263
271
|
const file = marketplacePath(agentsHome);
|
|
264
272
|
// Direct adapter callers need the same check as the installer boundary.
|
|
265
|
-
const { existing, withoutOurs } = await preflightCodexUninstall({ home, agentsHome, codexHome });
|
|
273
|
+
const { existing, before, withoutOurs, permissionState } = await preflightCodexUninstall({ home, agentsHome, codexHome });
|
|
266
274
|
const changes = [];
|
|
267
275
|
if (existing !== null) {
|
|
268
276
|
const kept = (existing.plugins ?? []).filter(entry => entry.name !== PLUGIN_NAME);
|
|
269
277
|
if (kept.length !== (existing.plugins ?? []).length) changes.push(PLUGIN_NAME);
|
|
270
278
|
await writeMarketplace(file, { ...existing, plugins: kept });
|
|
271
279
|
}
|
|
272
|
-
if (
|
|
280
|
+
if (before !== withoutOurs) {
|
|
281
|
+
await writeFile(configPath(codexHome), withoutOurs);
|
|
282
|
+
changes.push(configPath(codexHome));
|
|
283
|
+
}
|
|
273
284
|
// The client's `[hooks.state."<plugin>:…"]` tables stay. 0.1.9 removed them as
|
|
274
285
|
// litter naming a plugin that was gone; that was wrong, and wrong in a way
|
|
275
286
|
// worth writing down. The check behind it perturbed the record - a hook whose
|
|
@@ -312,13 +323,16 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
|
312
323
|
marketplaceRoot(agentsHome),
|
|
313
324
|
cacheRoot(codexHome),
|
|
314
325
|
]);
|
|
315
|
-
|
|
316
|
-
? ["
|
|
317
|
-
|
|
326
|
+
const permissionReview = permissionState === "customized"
|
|
327
|
+
? ["customized native permissions were preserved together with their default selection and network proxy; review them in Codex config"] : [];
|
|
328
|
+
return { ok: true, changes, needsAction: permissionReview, diagnostics: [
|
|
329
|
+
...(declaresSandbox(withoutOurs)
|
|
330
|
+
? ["existing sandbox_workspace_write configuration was preserved; review any retained writable_roots after removal"] : []),
|
|
331
|
+
...permissionReview] };
|
|
318
332
|
}
|
|
319
333
|
|
|
320
334
|
export async function detectCodex({ home, agentsHome = home,
|
|
321
|
-
codexHome = path.join(home, ".codex"), stateRoot }) {
|
|
335
|
+
codexHome = path.join(home, ".codex"), stateRoot, clientVersion, platform, nativeDelivery }) {
|
|
322
336
|
const marketplace = await readJson(marketplacePath(agentsHome), null);
|
|
323
337
|
const published = (marketplace?.plugins ?? []).some(entry => entry.name === PLUGIN_NAME);
|
|
324
338
|
const config = await readFile(configPath(codexHome), "utf8").catch(() => "");
|
|
@@ -326,11 +340,17 @@ export async function detectCodex({ home, agentsHome = home,
|
|
|
326
340
|
const pluginEntry = config.includes(`[plugins."${QUALIFIED}"]`);
|
|
327
341
|
const cached = await stat(cachePath(codexHome))
|
|
328
342
|
.then(() => true).catch(() => false);
|
|
343
|
+
const outgoingDelivery = outgoingStatus(config, { home, codexHome, stateRoot,
|
|
344
|
+
file: configPath(codexHome), clientVersion, platform });
|
|
329
345
|
// Saved trust is not readiness. Codex compares every current definition's
|
|
330
346
|
// hash and can disable a trusted hook. Even a commented or stale single record
|
|
331
347
|
// previously suppressed this check. Leave verification to Codex's /hooks;
|
|
332
348
|
// detection neither invents its hash algorithm nor starts a client service.
|
|
333
|
-
return { ok: true, changes: [],
|
|
349
|
+
return { ok: true, changes: [], outgoingDelivery,
|
|
350
|
+
nativeSetup: nativeDelivery?.reasonCode === "native_endpoint_unavailable"
|
|
351
|
+
? "Codex CLI: run codex app-server daemon start, then open a new Codex session; ACC never starts or restarts the daemon"
|
|
352
|
+
: null,
|
|
353
|
+
diagnostics: [
|
|
334
354
|
published ? "acc plugin published in the marketplace" : "acc plugin not registered",
|
|
335
355
|
registered && pluginEntry
|
|
336
356
|
? "marketplace and plugin entries found in config; activation not verified"
|
|
@@ -347,7 +367,8 @@ export async function detectCodex({ home, agentsHome = home,
|
|
|
347
367
|
: []),
|
|
348
368
|
],
|
|
349
369
|
needsAction: cached
|
|
350
|
-
? [HOOK_REVIEW, ...sandboxReview(config, configPath(codexHome), stateRoot)
|
|
370
|
+
? [HOOK_REVIEW, ...sandboxReview(config, configPath(codexHome), stateRoot),
|
|
371
|
+
...(outgoingDelivery.state === "unverified" ? [outgoingDelivery.diagnostic] : [])]
|
|
351
372
|
: [] };
|
|
352
373
|
}
|
|
353
374
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { channelSocketDirectory, tomlString } from "@agents-can-communicate/adapter-sdk";
|
|
3
|
+
import { compareStableVersions, parseStableVersion } from "./app-server-client.mjs";
|
|
4
|
+
import { addPermissions, inspectPermissions } from "./permission-ownership.mjs";
|
|
5
|
+
import { scanConfig } from "./toml-scan.mjs";
|
|
6
|
+
|
|
7
|
+
const PROFILE = "acc-workspace";
|
|
8
|
+
const MINIMUM = "0.153.4";
|
|
9
|
+
const equal = (keys, expected) => JSON.stringify(keys) === JSON.stringify(expected);
|
|
10
|
+
const literal = value => { try { return JSON.parse(value); } catch { return null; } };
|
|
11
|
+
const supported = context => context.platform === "darwin-arm64"
|
|
12
|
+
&& parseStableVersion(context.clientVersion) !== null
|
|
13
|
+
&& compareStableVersions(context.clientVersion, MINIMUM) >= 0;
|
|
14
|
+
const hasPermissions = entries => entries.some(entry =>
|
|
15
|
+
["permissions", "default_permissions", "profile", "profiles"].includes(entry.keys[0]));
|
|
16
|
+
const configured = (source, context) => {
|
|
17
|
+
const entries = scanConfig(source);
|
|
18
|
+
if (entries.some(entry => ["profile", "profiles"].includes(entry.keys[0]))) return false;
|
|
19
|
+
const value = keys => entries.find(entry => !entry.header && equal(entry.keys, keys))?.value;
|
|
20
|
+
const profile = literal(value(["default_permissions"]));
|
|
21
|
+
if (!profile || value(["sandbox_mode"]) || entries.some(entry => entry.keys[0] === "sandbox_workspace_write")) return false;
|
|
22
|
+
return literal(value(["permissions", profile, "extends"])) === ":workspace"
|
|
23
|
+
&& value(["features", "network_proxy"]) === "true"
|
|
24
|
+
&& value(["permissions", profile, "network", "enabled"]) === "true"
|
|
25
|
+
&& literal(value(["permissions", profile, "filesystem", context.stateRoot])) === "write"
|
|
26
|
+
&& sockets(context).every(socket => literal(value(["permissions", profile, "network", "unix_sockets", socket])) === "allow");
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Match the channel's per-user temporary directory and Codex's actual home.
|
|
30
|
+
// Grants cover only ACC's local channel namespace, never arbitrary Unix sockets.
|
|
31
|
+
const sockets = context => [channelSocketDirectory(),
|
|
32
|
+
path.join(context.codexHome ?? path.join(context.home, ".codex"), "app-server-control", "app-server-control.sock")];
|
|
33
|
+
|
|
34
|
+
export function outgoingStatus(source, context) {
|
|
35
|
+
const state = inspectPermissions(source).state;
|
|
36
|
+
const ready = state === "owned" && supported(context) && configured(source, context);
|
|
37
|
+
const reasonCode = state === "customized" ? "permission_configuration_modified"
|
|
38
|
+
: !supported(context) ? "permission_configuration_uncaptured"
|
|
39
|
+
: ready ? null : "sender_permissions_unverified";
|
|
40
|
+
return { state: reasonCode === null ? "configured" : "unverified", reasonCode,
|
|
41
|
+
setup: supported(context)
|
|
42
|
+
? "Codex outgoing setup: default workspace settings gain ACC state write access and an ACC local socket allowlist "
|
|
43
|
+
+ "through the network proxy (external network stays denied); custom policies are preserved. Start a new session after installation."
|
|
44
|
+
: null,
|
|
45
|
+
diagnostic: reasonCode === null
|
|
46
|
+
? "outgoing live delivery: local socket permissions configured for new sessions; active session overrides remain unverified"
|
|
47
|
+
: `outgoing live delivery: sender permissions unverified in ${context.file}; `
|
|
48
|
+
+ (reasonCode === "permission_configuration_uncaptured"
|
|
49
|
+
? `automatic setup requires Codex ${MINIMUM} or newer on darwin-arm64`
|
|
50
|
+
: "existing permissions were preserved; run acc install --adapter codex, then start a new session; "
|
|
51
|
+
+ "custom permission policies must allow ACC state and local sockets through the network proxy") };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function prepareLivePermissions(source, context) {
|
|
55
|
+
const owned = inspectPermissions(source);
|
|
56
|
+
const requested = (context.requestedLivePolicy ?? context.livePolicy ?? "off") !== "off";
|
|
57
|
+
if (owned.state === "customized") return { source, skipLegacy: true, status: outgoingStatus(source, context) };
|
|
58
|
+
if (!requested) return { source: owned.source, skipLegacy: hasPermissions(scanConfig(owned.source)) };
|
|
59
|
+
if (!supported(context) || !context.stateRoot) {
|
|
60
|
+
return { source, skipLegacy: hasPermissions(scanConfig(source)), status: outgoingStatus(source, context) };
|
|
61
|
+
}
|
|
62
|
+
if (owned.state === "owned" && configured(source, context)) {
|
|
63
|
+
return { source, skipLegacy: true, status: outgoingStatus(source, context) };
|
|
64
|
+
}
|
|
65
|
+
source = owned.source;
|
|
66
|
+
const entries = scanConfig(source);
|
|
67
|
+
const find = keys => entries.filter(entry => equal(entry.keys, keys));
|
|
68
|
+
const modes = find(["sandbox_mode"]), defaults = find(["default_permissions"]);
|
|
69
|
+
const legacy = entries.filter(entry => entry.keys[0] === "sandbox_workspace_write");
|
|
70
|
+
const features = entries.filter(entry => entry.keys[0] === "features");
|
|
71
|
+
const featureTable = find(["features"]);
|
|
72
|
+
const proxies = find(["features", "network_proxy"]);
|
|
73
|
+
const customized = entries.some(entry => ["permissions", "profile", "profiles"].includes(entry.keys[0]))
|
|
74
|
+
|| modes.length > 1 || defaults.length > 1
|
|
75
|
+
|| modes.some(entry => entry.header || literal(entry.value) !== "workspace-write")
|
|
76
|
+
|| defaults.some(entry => entry.header || literal(entry.value) !== ":workspace")
|
|
77
|
+
|| (legacy.length > 0 && (legacy.length !== 2 || !legacy[0].header || legacy[0].array
|
|
78
|
+
|| !equal(legacy[1].keys, ["sandbox_workspace_write", "writable_roots"])
|
|
79
|
+
|| JSON.stringify(literal(legacy[1].value)) !== JSON.stringify([context.stateRoot])))
|
|
80
|
+
|| (features.length > 0 && (featureTable.length !== 1 || !featureTable[0].header || featureTable[0].array))
|
|
81
|
+
|| proxies.length > 1 || proxies.some(entry => entry.header || !["true", "false"].includes(entry.value));
|
|
82
|
+
if (customized) return { source, skipLegacy: hasPermissions(entries), status: outgoingStatus(source, context) };
|
|
83
|
+
const edits = [{ id: "selection", start: 0, end: 0, body: `default_permissions = "${PROFILE}"\n` }];
|
|
84
|
+
for (const [id, values] of [["mode", modes], ["default", defaults]]) {
|
|
85
|
+
if (values.length) edits.push({ id, start: values[0].start, end: values[0].end, body: "" });
|
|
86
|
+
}
|
|
87
|
+
if (legacy.length) {
|
|
88
|
+
const end = entries.find(entry => entry.header && entry.start > legacy[0].start)?.start ?? source.length;
|
|
89
|
+
edits.push({ id: "legacy", start: legacy[0].start, end, body: "" });
|
|
90
|
+
}
|
|
91
|
+
const proxy = proxies[0], table = featureTable[0];
|
|
92
|
+
edits.push({ id: "proxy", start: proxy?.start ?? table?.end ?? source.length,
|
|
93
|
+
end: proxy?.end ?? table?.end ?? source.length, table: table ? ["features"] : null,
|
|
94
|
+
body: `${table ? "" : "[features]\n"}network_proxy = true\n` });
|
|
95
|
+
// Preserve a final line without a newline through the same ownership unit.
|
|
96
|
+
const last = entries.at(-1);
|
|
97
|
+
if (last && !source.endsWith("\n") && !edits.some(edit => edit.start <= last.start && edit.end >= last.end)) {
|
|
98
|
+
edits.push({ id: "tail", start: last.start, end: last.end, body: `${last.raw}\n` });
|
|
99
|
+
}
|
|
100
|
+
edits.push({ id: "profile", start: source.length, end: source.length, body:
|
|
101
|
+
`[permissions.${PROFILE}]\nextends = ":workspace"\n`
|
|
102
|
+
+ `[permissions.${PROFILE}.filesystem]\n${tomlString(context.stateRoot)} = "write"\n`
|
|
103
|
+
+ `[permissions.${PROFILE}.network]\nenabled = true\n`
|
|
104
|
+
+ `[permissions.${PROFILE}.network.unix_sockets]\n`
|
|
105
|
+
+ sockets(context).map(socket => `${tomlString(socket)} = "allow"\n`).join("") });
|
|
106
|
+
const prepared = addPermissions(source, edits);
|
|
107
|
+
return { source: prepared, skipLegacy: true, status: outgoingStatus(prepared, context) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const removeLivePermissions = source => inspectPermissions(source);
|
|
@@ -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
|
+
}
|
|
@@ -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"}`);
|
|
@@ -25,3 +25,4 @@ export { clearSessionBinding, listSessionBindings, loadSessionBinding,
|
|
|
25
25
|
from "./session-binding.mjs";
|
|
26
26
|
|
|
27
27
|
export { clearNativeAttempt, loadNativeAttempt, storeNativeAttempt } from "./native-attempt.mjs";
|
|
28
|
+
export { channelSocketDirectory } from "./channel-directory.mjs";
|
|
@@ -273,6 +273,8 @@ export async function runDoctor({ options, context, runtime }) {
|
|
|
273
273
|
+ `${describeNative(adapter.nativeDelivery, { clientVersion: adapter.version })}; `
|
|
274
274
|
+ `fallback: ${describeDeliveryFallback(adapter)}`),
|
|
275
275
|
...nativeSessionLines(adapters),
|
|
276
|
+
...adapters.filter(adapter => adapter.present && adapter.outgoingDelivery)
|
|
277
|
+
.map(adapter => ` ${adapter.displayName} ${adapter.outgoingDelivery.diagnostic}`),
|
|
276
278
|
...(manager === null ? [] : [` automatic updates ${manager.auto ? "on" : "off"}; ACC ${manager.active.version}`
|
|
277
279
|
+ (manager.pin ? `; pinned to ${manager.pin}` : ""), ...(manager.notice ? [` ${manager.notice}`] : [])]),
|
|
278
280
|
...data.remediation.map(line => ` ${line}`),
|
|
@@ -153,6 +153,8 @@ export function describeOutcome({ action, acted, failed = [], skipped = [],
|
|
|
153
153
|
...operations.filter(operation => operation.applied)
|
|
154
154
|
.flatMap(operation => (operation.needsAction ?? [])
|
|
155
155
|
.map(step => ` ${operation.adapterId}: ${step}`)),
|
|
156
|
+
...operations.filter(operation => operation.applied)
|
|
157
|
+
.flatMap(operation => (operation.setupNotes ?? []).map(note => ` ${note}`)),
|
|
156
158
|
...skipped.map(entry => ` skip ${entry.adapterId}: ${entry.reason}`),
|
|
157
159
|
...failed.map(entry => ` ${entry.adapterId}: ${entry.error}`),
|
|
158
160
|
// Said once, where it is needed: the reader has just been shown a list of
|
|
@@ -212,6 +214,7 @@ function questionFor(entry) {
|
|
|
212
214
|
return [
|
|
213
215
|
`Let ${entry.displayName} answer peer requests while idle? (experimental)`,
|
|
214
216
|
" Yes: automatic turns can spend tokens without waiting for you.",
|
|
217
|
+
...(entry.outgoingDelivery?.setup ? [` ${entry.outgoingDelivery.setup}`] : []),
|
|
215
218
|
...(warns ? [" Allow development channels when prompted; check the client's startup notice."] : []),
|
|
216
219
|
...(entry.nativeDelivery.state !== "eligible"
|
|
217
220
|
? [" Save consent now; delivery waits for an available client service."] : []),
|
|
@@ -137,7 +137,9 @@ function recordedText(message, delivery) {
|
|
|
137
137
|
const diagnostics = delivery.map(item => item.outcome === "offered"
|
|
138
138
|
? `live offered to ${item.recipientParticipantId} via ${item.transport}`
|
|
139
139
|
: item.outcome === "queued"
|
|
140
|
-
?
|
|
140
|
+
? item.errorCode === "transport_permission_denied"
|
|
141
|
+
? "live offer blocked by sender permissions; message remains queued; run acc doctor in the sending session"
|
|
142
|
+
: `live offer unavailable (${item.errorCode ?? "durable_fallback"})`
|
|
141
143
|
: `delivery already ${item.outcome}`);
|
|
142
144
|
return [`recorded ${message.messageId}`, ...diagnostics].join("; ");
|
|
143
145
|
}
|
|
@@ -32,7 +32,7 @@ export function nativeRemediation(entry) {
|
|
|
32
32
|
+ "then run acc doctor here");
|
|
33
33
|
}
|
|
34
34
|
if (native.reasonCode === "native_endpoint_unavailable") {
|
|
35
|
-
steps.push(`${entry.displayName}: check the client's local delivery service setup; `
|
|
35
|
+
steps.push(entry.nativeSetup ?? `${entry.displayName}: check the client's local delivery service setup; `
|
|
36
36
|
+ "ACC does not start or restart that service");
|
|
37
37
|
} else if (native.reasonCode === "native_session_unavailable") {
|
|
38
38
|
steps.push(`${entry.displayName}: open a session connected to the client's local delivery service`);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { refreshExpiredBinding } from "./refresh-binding.mjs";
|
|
2
2
|
|
|
3
3
|
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
4
|
-
"recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected",
|
|
4
|
+
"recipient_busy", "recipient_unavailable", "transport_error", "transport_rejected", "transport_permission_denied",
|
|
5
5
|
"unsupported_client_version"]);
|
|
6
6
|
const NAMED_LIVE_TRANSPORTS = new Set(["claude-channel", "codex-app-server"]);
|
|
7
7
|
|
|
@@ -35,6 +35,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
35
35
|
const createdDirectories = await missingArtifactParents({ home: context.home,
|
|
36
36
|
artifacts: operation.artifacts });
|
|
37
37
|
const installContext = { ...context,
|
|
38
|
+
clientVersion: operation.clientVersion, platform: operation.platform,
|
|
38
39
|
requestedLivePolicy: operation.livePolicy ?? "off",
|
|
39
40
|
livePolicy: operation.effectiveLivePolicy ?? "off" };
|
|
40
41
|
const outcome = await adapter.install(installContext);
|
|
@@ -103,6 +104,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
103
104
|
retainedNativeActivation: retainedMechanisms.length === 0 ? null
|
|
104
105
|
: { ...operation.deactivation, mechanisms: retainedMechanisms } });
|
|
105
106
|
results.operations.push({ ...operation, applied: true,
|
|
107
|
+
needsAction: outcome.needsAction ?? [],
|
|
106
108
|
changes: outcome.changes ?? [], removed: owned.removed, kept: owned.kept,
|
|
107
109
|
removedDirectories: directories.removed, keptDirectories: directories.kept,
|
|
108
110
|
missingDirectories: directories.missing,
|
|
@@ -118,7 +118,7 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
|
|
|
118
118
|
// deterministic rather than dependent on registry order.
|
|
119
119
|
.sort((left, right) => left.id.localeCompare(right.id))
|
|
120
120
|
.map(async adapter => {
|
|
121
|
-
const entry = { adapterId: adapter.id, displayName: adapter.displayName,
|
|
121
|
+
const entry = { adapterId: adapter.id, displayName: adapter.displayName, platform,
|
|
122
122
|
present: false, version: null, versionOutput: null, installed: false,
|
|
123
123
|
diagnostics: [], needsAction: [], capabilities: effectiveCapabilities(adapter),
|
|
124
124
|
deliveryDiagnostic: null, error: null };
|
|
@@ -146,7 +146,10 @@ export async function detectInstallation({ adapters, context, probe = spawnProbe
|
|
|
146
146
|
? "native_delivery_unsupported" : "version_unavailable");
|
|
147
147
|
|
|
148
148
|
try {
|
|
149
|
-
const detected = await adapter.detect(context
|
|
149
|
+
const detected = await adapter.detect({ ...context, clientVersion: entry.version,
|
|
150
|
+
platform, nativeDelivery: entry.nativeDelivery });
|
|
151
|
+
if (detected.outgoingDelivery) entry.outgoingDelivery = detected.outgoingDelivery;
|
|
152
|
+
if (detected.nativeSetup) entry.nativeSetup = detected.nativeSetup;
|
|
150
153
|
entry.diagnostics = [...(detected.diagnostics ?? [])];
|
|
151
154
|
// What a person has to do, as opposed to what is true. Adapters that
|
|
152
155
|
// have nothing to ask for say nothing.
|
|
@@ -117,7 +117,9 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
117
117
|
const deliverySummary = action === "install"
|
|
118
118
|
? describeInstallDelivery(entry, delivery, effectiveLivePolicy) : null;
|
|
119
119
|
const installContext = { ...context, requestedLivePolicy: delivery,
|
|
120
|
-
livePolicy: effectiveLivePolicy };
|
|
120
|
+
livePolicy: effectiveLivePolicy, clientVersion: entry.version, platform: entry.platform };
|
|
121
|
+
const setupNotes = action === "install" && delivery !== "off"
|
|
122
|
+
? [entry.outgoingDelivery?.setup, entry.nativeSetup].filter(note => typeof note === "string") : [];
|
|
121
123
|
// A consented activation that this run keeps, activates, or takes back.
|
|
122
124
|
// Only an explicit off or an uninstall removes one; an absent record never
|
|
123
125
|
// creates one.
|
|
@@ -139,6 +141,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
139
141
|
displayName: adapter.displayName,
|
|
140
142
|
action,
|
|
141
143
|
clientVersion: entry.version ?? record?.version ?? null,
|
|
144
|
+
platform: entry.platform ?? context?.platform,
|
|
142
145
|
// "Remove these files for a client that is not here" is a different thing
|
|
143
146
|
// to approve than an ordinary uninstall, so it is said rather than left
|
|
144
147
|
// to be inferred from a client version that is null.
|
|
@@ -148,6 +151,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
148
151
|
effectiveLivePolicy,
|
|
149
152
|
...(deliveryDiagnostic === null ? {} : { deliveryDiagnostic }),
|
|
150
153
|
...(deliverySummary === null ? {} : { deliverySummary }),
|
|
154
|
+
...(setupNotes.length ? { setupNotes } : {}),
|
|
151
155
|
...(nativeActivation === null ? {} : { nativeActivation }),
|
|
152
156
|
...(deactivation === null ? {} : { deactivation }),
|
|
153
157
|
artifacts,
|
|
@@ -157,6 +161,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
157
161
|
...(entry.present ? [] : [`${adapter.displayName ?? adapter.id} is no longer on `
|
|
158
162
|
+ "this machine; removing what ACC recorded writing"]),
|
|
159
163
|
...(deliverySummary === null ? [] : [deliverySummary]),
|
|
164
|
+
...setupNotes,
|
|
160
165
|
...artifacts.filter(a => a.kind === "tree")
|
|
161
166
|
.map(a => `${action === "install" ? "create" : "remove"} ${a.path}`),
|
|
162
167
|
...artifacts.filter(a => a.kind === "merge")
|