agents-can-communicate 0.1.6 → 0.1.8
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/docs/CAPABILITIES.md +18 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/hooks.mjs +8 -3
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +6 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/hooks.mjs +22 -3
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +37 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/hooks.mjs +4 -3
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/src/hooks.mjs +5 -4
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +26 -6
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/shell-writes.mjs +319 -0
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +6 -2
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +36 -2
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/status.mjs +10 -0
- 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/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/docs/CAPABILITIES.md
CHANGED
|
@@ -60,10 +60,17 @@ toolset contained no `apply_patch`.
|
|
|
60
60
|
`plan` modes the client declares no write tool to the model at all. `write_file` and
|
|
61
61
|
`replace` appear under `auto_edit`; `run_shell_command` under `yolo`.
|
|
62
62
|
|
|
63
|
-
**`guards.beforeShell` is
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
**`guards.beforeShell` is resource-aware where the write is unambiguous.** ACC reads the
|
|
64
|
+
command for its write positions only - a redirection, an operand of a command whose whole
|
|
65
|
+
job is to put bytes somewhere - and declares those paths as targets. Reading positions are
|
|
66
|
+
left alone: `cat file` and `grep file` name a path and write nothing, and treating them as
|
|
67
|
+
writes would have sessions blocking each other for looking.
|
|
68
|
+
|
|
69
|
+
What it does not see is a language runtime opening the file itself (`python3 -c
|
|
70
|
+
"open(...)"`), a command assembled at runtime, or an `eval`. A shell can still evade the
|
|
71
|
+
guard. Until 0.1.7 every shell write did, which is why partial sight is the improvement it
|
|
72
|
+
is: a session told to prefer the shell for file changes walked through every claim in the
|
|
73
|
+
workspace.
|
|
67
74
|
|
|
68
75
|
Where the guard cannot help, the turn context does: it names the claims other sessions
|
|
69
76
|
hold and says which way this session stands with them. Two facts decide the wording -
|
|
@@ -71,7 +78,7 @@ what the claim's owner asked for, and whether ACC can stop this session at all:
|
|
|
71
78
|
|
|
72
79
|
| Claim | This session | Note |
|
|
73
80
|
|---|---|---|
|
|
74
|
-
| guarded | can be guarded | `file edits are blocked;
|
|
81
|
+
| guarded | can be guarded | `file edits and recognised shell writes are blocked; a runtime can still get past` |
|
|
75
82
|
| guarded | cannot be guarded | `not enforced for this session; do not edit it` |
|
|
76
83
|
| advisory | either | `advisory; nothing will stop you, the owner is asking` |
|
|
77
84
|
|
|
@@ -154,5 +161,9 @@ harness name. Both default to the weaker reading, so a generic MCP client or a h
|
|
|
154
161
|
the CLI reads as advisory and manual.
|
|
155
162
|
|
|
156
163
|
A workspace reports `protection: guarded` only when every live session can be stopped. One
|
|
157
|
-
MCP client
|
|
158
|
-
|
|
164
|
+
MCP client and a guarded claim is advice - so the workspace says `advisory`, whatever its
|
|
165
|
+
claims were declared as.
|
|
166
|
+
|
|
167
|
+
"Stoppable" is not "unevadable". Even in a guarded workspace, a session that writes through
|
|
168
|
+
a language runtime rather than a recognised shell form gets past. The claim still says who
|
|
169
|
+
is working where; enforcement is the floor, not the ceiling.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
|
|
1
|
+
import { normalizedEvent, shellWriteTargets } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
// Confirmed by capture on 2.1.233, and matching the published documentation
|
|
@@ -66,10 +66,15 @@ export const CLAUDE_EDIT_TOOLS = Object.freeze(["Write", "Edit", "MultiEdit",
|
|
|
66
66
|
* Only the path is taken. `content`, `old_string` and `new_string` are the
|
|
67
67
|
* file's contents, which ACC has no use for and must not carry.
|
|
68
68
|
*
|
|
69
|
-
* `Bash` declares
|
|
70
|
-
* of
|
|
69
|
+
* `Bash` declares no path, but its command names the ones it would write. Those
|
|
70
|
+
* are read out of the write positions only - a redirection, an operand of a
|
|
71
|
+
* command whose job is to put bytes somewhere - because a session told to prefer
|
|
72
|
+
* the shell would otherwise walk through every claim in the workspace. The
|
|
73
|
+
* command string itself is conversation content and stays out; only paths leave
|
|
74
|
+
* this function.
|
|
71
75
|
*/
|
|
72
76
|
function writeTargets(tool, input) {
|
|
77
|
+
if (tool === "Bash") return shellWriteTargets(input?.command);
|
|
73
78
|
if (!CLAUDE_EDIT_TOOLS.includes(tool)) return [];
|
|
74
79
|
const target = input?.file_path ?? input?.notebook_path;
|
|
75
80
|
return typeof target === "string" && target !== "" ? [target] : [];
|
|
@@ -62,10 +62,12 @@ export function createCodexAdapter() {
|
|
|
62
62
|
// property of the model's metadata (apply_patch_tool_type), not a user
|
|
63
63
|
// setting. With a model that does not have it, edits go through
|
|
64
64
|
// exec_command, which reaches hooks as tool_name \"Bash\" carrying a
|
|
65
|
-
// command string
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
// command string. Since 0.1.7 that command is read for its write
|
|
66
|
+
// positions, so those edits are matched too - as far as the reading
|
|
67
|
+
// goes. Verified on 0.147.0.
|
|
68
|
+
"write guards cover apply_patch and the shell writes ACC can read; a model "
|
|
69
|
+
+ "without apply_patch edits through the shell, where a redirection or an "
|
|
70
|
+
+ "mv is matched and a runtime opening the file is not",
|
|
69
71
|
"Codex requires hooks to be trusted before they run; an untrusted plugin is "
|
|
70
72
|
+ "installed but inert",
|
|
71
73
|
],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
|
|
1
|
+
import { normalizedEvent, shellWriteTargets } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
// The event names are settled: they are an enum in the installed 0.147.0 binary,
|
|
@@ -68,7 +68,7 @@ export function normalizeCodexHook(payload) {
|
|
|
68
68
|
model: pick(payload, FIELD_CANDIDATES.model),
|
|
69
69
|
parentSessionId: pick(payload, FIELD_CANDIDATES.parentSessionId),
|
|
70
70
|
tool,
|
|
71
|
-
targets:
|
|
71
|
+
targets: writeTargets(tool, payload?.tool_input),
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -76,6 +76,7 @@ export function normalizeCodexHook(payload) {
|
|
|
76
76
|
// patch body, one per operation, so a single call can touch several files.
|
|
77
77
|
// Reading `tool_input.path` here - the shape every other harness uses - would
|
|
78
78
|
// find nothing and leave every edit unguarded.
|
|
79
|
+
const PATCH_ENVELOPE = "*** Begin Patch";
|
|
79
80
|
const PATCH_OPERATION = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/;
|
|
80
81
|
const PATCH_MOVE = /^\*\*\* Move to: (.+)$/;
|
|
81
82
|
|
|
@@ -86,9 +87,9 @@ const PATCH_MOVE = /^\*\*\* Move to: (.+)$/;
|
|
|
86
87
|
* are never read: they are the file's contents, which ACC has no use for.
|
|
87
88
|
*/
|
|
88
89
|
export function patchTargets(tool, input) {
|
|
89
|
-
if (tool !== "apply_patch") return [];
|
|
90
90
|
const body = input?.command ?? input?.patch ?? input?.input;
|
|
91
91
|
if (typeof body !== "string") return [];
|
|
92
|
+
if (tool !== "apply_patch" && !body.trimStart().startsWith(PATCH_ENVELOPE)) return [];
|
|
92
93
|
const targets = [];
|
|
93
94
|
for (const line of body.split("\n")) {
|
|
94
95
|
const operation = PATCH_OPERATION.exec(line) ?? PATCH_MOVE.exec(line);
|
|
@@ -97,6 +98,24 @@ export function patchTargets(tool, input) {
|
|
|
97
98
|
return targets;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* The paths a tool call would write.
|
|
103
|
+
*
|
|
104
|
+
* The guard is wired to three tool names here, and which one a shell command
|
|
105
|
+
* arrives under is a property of the model's metadata rather than of this
|
|
106
|
+
* config. So the shape decides instead of the name: a patch envelope is read as
|
|
107
|
+
* a patch, and any other command string is read as a shell command. Reading
|
|
108
|
+
* positions in that command are left alone.
|
|
109
|
+
*/
|
|
110
|
+
export function writeTargets(tool, input) {
|
|
111
|
+
const patched = patchTargets(tool, input);
|
|
112
|
+
if (patched.length > 0) return patched;
|
|
113
|
+
const command = input?.command ?? input?.input;
|
|
114
|
+
if (typeof command !== "string") return [];
|
|
115
|
+
if (command.trimStart().startsWith(PATCH_ENVELOPE)) return [];
|
|
116
|
+
return shellWriteTargets(command);
|
|
117
|
+
}
|
|
118
|
+
|
|
100
119
|
/**
|
|
101
120
|
* Deny a tool call the way this client understands it.
|
|
102
121
|
*
|
|
@@ -229,6 +229,34 @@ async function removeEmptyDirs(directories) {
|
|
|
229
229
|
}
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Drop the client's trust record for hooks that are about to stop existing.
|
|
234
|
+
*
|
|
235
|
+
* Scoped to tables whose key begins with ACC's own `plugin@marketplace:` - a
|
|
236
|
+
* table belonging to any other plugin is the client's business and stays. The
|
|
237
|
+
* file is rewritten only when something was found, so an install that never ran
|
|
238
|
+
* here leaves the config byte for byte as it was.
|
|
239
|
+
*/
|
|
240
|
+
export async function removeHookTrust(file, prefix) {
|
|
241
|
+
const before = await readFile(file, "utf8").catch(() => null);
|
|
242
|
+
if (before === null || !before.includes(`hooks.state."${prefix}`)) return false;
|
|
243
|
+
|
|
244
|
+
const lines = before.split("\n");
|
|
245
|
+
const kept = [];
|
|
246
|
+
let dropping = false;
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
const header = /^\s*\[([^\]]*)\]\s*$/.exec(line);
|
|
249
|
+
if (header !== null) dropping = header[1].startsWith(`hooks.state."${prefix}`);
|
|
250
|
+
if (dropping) continue;
|
|
251
|
+
kept.push(line);
|
|
252
|
+
}
|
|
253
|
+
// A table's trailing blank line goes with it rather than piling up.
|
|
254
|
+
const text = kept.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
255
|
+
if (text === before) return false;
|
|
256
|
+
await writeFile(file, text, "utf8");
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
232
260
|
export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
233
261
|
codexHome = path.join(home, ".codex"), keep = [] }) {
|
|
234
262
|
const file = marketplacePath(agentsHome);
|
|
@@ -240,6 +268,15 @@ export async function uninstallCodexPlugin({ home, agentsHome = home,
|
|
|
240
268
|
await writeMarketplace(file, { ...existing, plugins: kept });
|
|
241
269
|
}
|
|
242
270
|
if (await removeTomlBlock(configPath(codexHome))) changes.push(configPath(codexHome));
|
|
271
|
+
// This client keeps its own record of which hook files it has trusted, one
|
|
272
|
+
// table per hook, keyed by the plugin that declared them. ACC never writes
|
|
273
|
+
// those - they are the client's bookkeeping about ACC - but once the plugin is
|
|
274
|
+
// gone they name a thing that does not exist, five of them per install, and
|
|
275
|
+
// nothing else will ever clear them. Verified inert first: a hook whose hash
|
|
276
|
+
// no longer matches still runs, so this is tidiness rather than repair.
|
|
277
|
+
if (await removeHookTrust(configPath(codexHome), `${PLUGIN_NAME}@${MARKETPLACE}:`)) {
|
|
278
|
+
if (!changes.includes(configPath(codexHome))) changes.push(configPath(codexHome));
|
|
279
|
+
}
|
|
243
280
|
// The marketplace directory is ACC's too, so it goes rather than being left
|
|
244
281
|
// behind empty.
|
|
245
282
|
// A blank TOML config and an absent one are the same to this client, and a
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
|
|
1
|
+
import { normalizedEvent, shellWriteTargets } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
// All eight observed in a live 0.37.0 configuration; the six that ACC uses were
|
|
@@ -59,10 +59,11 @@ export function normalizeGeminiHook(payload) {
|
|
|
59
59
|
* Both editing tools take `file_path`, confirmed from a capture. The file's
|
|
60
60
|
* contents are not read: they are conversation content.
|
|
61
61
|
*
|
|
62
|
-
* `run_shell_command` declares
|
|
63
|
-
*
|
|
62
|
+
* `run_shell_command` declares no path, so its command is read for the positions
|
|
63
|
+
* where a write is unambiguous, and for nothing else.
|
|
64
64
|
*/
|
|
65
65
|
function writeTargets(tool, input) {
|
|
66
|
+
if (tool === "run_shell_command") return shellWriteTargets(input?.command);
|
|
66
67
|
if (!GEMINI_EDIT_TOOLS.includes(tool)) return [];
|
|
67
68
|
const target = input?.file_path;
|
|
68
69
|
return typeof target === "string" && target !== "" ? [target] : [];
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizedEvent } from "@agents-can-communicate/adapter-sdk";
|
|
1
|
+
import { normalizedEvent, shellWriteTargets } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
3
3
|
|
|
4
4
|
// Not read from documentation and not guessed: this client validates its config
|
|
@@ -73,11 +73,12 @@ export function normalizeKimiHook(payload) {
|
|
|
73
73
|
* `old_string` and `new_string`. Nothing else is read: the contents and the
|
|
74
74
|
* replacement strings are conversation content and stay out.
|
|
75
75
|
*
|
|
76
|
-
* `Bash` declares
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* `Bash` declares no path of its own, so the command is read for the positions
|
|
77
|
+
* where a write is unambiguous. Reading positions are left alone: naming a file
|
|
78
|
+
* is not touching it.
|
|
79
79
|
*/
|
|
80
80
|
function writeTargets(tool, input) {
|
|
81
|
+
if (tool === "Bash") return shellWriteTargets(input?.command);
|
|
81
82
|
if (!KIMI_EDIT_TOOLS.includes(tool)) return [];
|
|
82
83
|
const path = input?.path;
|
|
83
84
|
return typeof path === "string" && path !== "" ? [path] : [];
|
|
@@ -33,6 +33,12 @@ function escapePeerText(value) {
|
|
|
33
33
|
return String(value)
|
|
34
34
|
.replaceAll(new RegExp(`${FENCE}${BLOCK}`, "g"), `'${FENCE}${BLOCK}`)
|
|
35
35
|
.replaceAll(FENCE, `'${FENCE}`)
|
|
36
|
+
// The labels that frame this block are ACC's words at the start of a line.
|
|
37
|
+
// A peer writing one would otherwise produce a second line reading as ACC
|
|
38
|
+
// framing a different message - the same break-out the fence rule prevents,
|
|
39
|
+
// and neutralised the same way rather than by reflowing the text, which a
|
|
40
|
+
// handoff body cannot survive.
|
|
41
|
+
.replace(/^(subject:|body:)/gm, "'$1")
|
|
36
42
|
.replace(CONTROL_CHARACTERS,
|
|
37
43
|
character => `\\u${character.codePointAt(0).toString(16).padStart(4, "0")}`);
|
|
38
44
|
}
|
|
@@ -85,11 +91,12 @@ function claimNote(claim) {
|
|
|
85
91
|
if (claim.enforceable === false) {
|
|
86
92
|
return " - not enforced for this session; do not edit it";
|
|
87
93
|
}
|
|
88
|
-
// Guarded, and this session can be stopped -
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// claimed" would reasonably
|
|
92
|
-
|
|
94
|
+
// Guarded, and this session can be stopped - on a file edit, and on the shell
|
|
95
|
+
// writes the guard can read: a redirection, an operand of a command whose job
|
|
96
|
+
// is to put bytes somewhere. A language runtime opening the file itself still
|
|
97
|
+
// gets past, and a session told merely "this is claimed" would reasonably
|
|
98
|
+
// assume either more or less than is true.
|
|
99
|
+
return " - file edits and recognised shell writes are blocked; a runtime can still get past";
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
function claimLines(claims) {
|
|
@@ -117,12 +124,25 @@ function peerBlocks(messages) {
|
|
|
117
124
|
`${FENCE}${BLOCK}`,
|
|
118
125
|
`id ${message.messageId} | from ${message.fromSessionId} | type ${message.type}`
|
|
119
126
|
+ " | untrusted peer message",
|
|
120
|
-
escapePeerText(message.subject)
|
|
127
|
+
`subject: ${oneLine(escapePeerText(message.subject))}`,
|
|
128
|
+
"body:",
|
|
121
129
|
escapePeerText(message.body),
|
|
122
130
|
FENCE,
|
|
123
131
|
]);
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A subject is one line, whatever the peer sent.
|
|
136
|
+
*
|
|
137
|
+
* The subject sits on the label's own line, so a newline inside it would push
|
|
138
|
+
* peer text to column 0 where ACC's labels live. Rendering the break visibly
|
|
139
|
+
* keeps the text readable and the frame ACC's.
|
|
140
|
+
*/
|
|
141
|
+
function oneLine(value) {
|
|
142
|
+
return value.replaceAll("\r\n", "\\n").replaceAll("\n", "\\n").replaceAll("\r", "\\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
126
146
|
/**
|
|
127
147
|
* Project a SyncResult into bounded text for one adapter to inject.
|
|
128
148
|
*
|
|
@@ -8,6 +8,7 @@ export { assertRunner, bakeSkillCommand, defaultCli, defaultRunner, removeInstal
|
|
|
8
8
|
export { BEGIN, END, removeTomlBlock, renderBlock, stripBlock, tomlString, writeTomlBlock }
|
|
9
9
|
from "./toml-block.mjs";
|
|
10
10
|
export { projectContext } from "./context-projector.mjs";
|
|
11
|
+
export { shellWriteTargets } from "./shell-writes.mjs";
|
|
11
12
|
export { editJson, readJson } from "./json-text.mjs";
|
|
12
13
|
export { formatJsonAs, jsonStyleOf, mergeOwnedConfig, mergeOwnedEntries, ownedEntries, ownedKeys,
|
|
13
14
|
acccreatedFile, removeIfEmpty, removeOwnedConfig, removeOwnedEntries, writeForeignJson,
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a shell command would write.
|
|
3
|
+
*
|
|
4
|
+
* The guard used to declare no targets for a shell call at all, on the reasoning
|
|
5
|
+
* that a command can write anywhere and a guessed path is wrong in both
|
|
6
|
+
* directions. That reasoning held until agents started being told to prefer the
|
|
7
|
+
* shell for file edits: a claim that any `printf >> file` walks through is not a
|
|
8
|
+
* claim. The answer is not to guess. It is to read the positions where a write
|
|
9
|
+
* is unambiguous - a redirection, the operand of a command whose whole job is to
|
|
10
|
+
* put bytes somewhere - and to stay silent everywhere else.
|
|
11
|
+
*
|
|
12
|
+
* Two properties matter more than coverage:
|
|
13
|
+
*
|
|
14
|
+
* - A read is never reported. `cat file` and `grep file` name a path in an
|
|
15
|
+
* argument position that writes nothing; treating those as writes would have
|
|
16
|
+
* sessions blocking each other for looking.
|
|
17
|
+
* - It never throws. This runs on the hook path inside a budget that fails
|
|
18
|
+
* open, so an unparseable command yields no targets rather than an error.
|
|
19
|
+
*
|
|
20
|
+
* What it does not see is stated where a person reads it (`context-projector`):
|
|
21
|
+
* a language runtime opening a file, an `eval`, a command built at runtime. A
|
|
22
|
+
* shell can still evade this. Partial sight is not the same as none - today
|
|
23
|
+
* every one of these walks through - and an agent told where the guard ends
|
|
24
|
+
* behaves better than one who believes it absolute.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const SEPARATORS = new Set([";", "&&", "||", "|", "|&", "&", "\n"]);
|
|
28
|
+
|
|
29
|
+
// Redirections that write. `<` and `<<` read; `>&`/`<&` duplicate a descriptor
|
|
30
|
+
// and name no file.
|
|
31
|
+
const WRITE_REDIRECTION = /^(?:[0-9]*|&)(?:>>|>\|?)$/;
|
|
32
|
+
|
|
33
|
+
// Sinks that discard. Naming one is not touching a file anybody claims.
|
|
34
|
+
const NOT_A_FILE = new Set(["/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How each command spends its operands.
|
|
38
|
+
*
|
|
39
|
+
* `pick` decides which operands are written: every one, only the last (a
|
|
40
|
+
* destination), or every one but the first (a script that is not a file).
|
|
41
|
+
* `valueFlags` are the flags whose following token is a value rather than a
|
|
42
|
+
* path - without them, `truncate -s 0 file` would report a file named `0`.
|
|
43
|
+
*/
|
|
44
|
+
const WRITERS = new Map(Object.entries({
|
|
45
|
+
tee: { pick: "all" },
|
|
46
|
+
touch: { pick: "all", valueFlags: new Set(["-d", "-r", "-t", "--date", "--reference"]) },
|
|
47
|
+
truncate: { pick: "all", valueFlags: new Set(["-s", "--size", "-r", "--reference"]) },
|
|
48
|
+
rm: { pick: "all" },
|
|
49
|
+
unlink: { pick: "all" },
|
|
50
|
+
shred: { pick: "all", valueFlags: new Set(["-n", "--iterations", "-s", "--size"]) },
|
|
51
|
+
mkdir: { pick: "all", valueFlags: new Set(["-m", "--mode"]) },
|
|
52
|
+
rmdir: { pick: "all" },
|
|
53
|
+
cp: { pick: "last", valueFlags: new Set(["-t", "--target-directory", "-S", "--suffix"]) },
|
|
54
|
+
install: { pick: "last", valueFlags: new Set(["-t", "--target-directory", "-m", "-o", "-g"]) },
|
|
55
|
+
rsync: { pick: "last", valueFlags: new Set(["-e", "--rsh", "--exclude"]) },
|
|
56
|
+
ln: { pick: "last", valueFlags: new Set(["-t", "--target-directory", "-S", "--suffix"]) },
|
|
57
|
+
// A move writes the destination and empties the source. Both are the peer's
|
|
58
|
+
// business, so both are reported.
|
|
59
|
+
mv: { pick: "all", valueFlags: new Set(["-t", "--target-directory", "-S", "--suffix"]) },
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// In-place editors: a write only when the in-place flag is present, and their
|
|
63
|
+
// script operand is never a file.
|
|
64
|
+
const IN_PLACE = new Map(Object.entries({
|
|
65
|
+
sed: { valueFlags: new Set(["-e", "--expression", "-f", "--file", "-l", "-E"]) },
|
|
66
|
+
perl: { valueFlags: new Set(["-e", "-E", "-M", "-m"]) },
|
|
67
|
+
ruby: { valueFlags: new Set(["-e", "-r"]) },
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Split a command line into tokens, keeping quoted text out of the grammar.
|
|
72
|
+
*
|
|
73
|
+
* Returns null on anything it cannot read - an unterminated quote, most often -
|
|
74
|
+
* which the caller turns into "no targets" rather than a guess.
|
|
75
|
+
*/
|
|
76
|
+
function tokenize(command) {
|
|
77
|
+
const tokens = [];
|
|
78
|
+
let text = "";
|
|
79
|
+
let started = false;
|
|
80
|
+
let quoted = false;
|
|
81
|
+
|
|
82
|
+
const flush = () => {
|
|
83
|
+
if (started) tokens.push({ text, quoted });
|
|
84
|
+
text = "";
|
|
85
|
+
started = false;
|
|
86
|
+
quoted = false;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
90
|
+
const character = command[index];
|
|
91
|
+
|
|
92
|
+
if (character === "\\") {
|
|
93
|
+
const next = command[index + 1];
|
|
94
|
+
if (next === undefined) return null;
|
|
95
|
+
text += next;
|
|
96
|
+
started = true;
|
|
97
|
+
quoted = true;
|
|
98
|
+
index += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (character === "'" || character === '"') {
|
|
103
|
+
const close = command.indexOf(character, index + 1);
|
|
104
|
+
if (close === -1) return null;
|
|
105
|
+
text += command.slice(index + 1, close);
|
|
106
|
+
started = true;
|
|
107
|
+
quoted = true;
|
|
108
|
+
index = close;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (character === " " || character === "\t") {
|
|
113
|
+
flush();
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (character === "\n") {
|
|
118
|
+
flush();
|
|
119
|
+
tokens.push({ operator: "\n" });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (character === ";" || character === "&" || character === "|" || character === ">"
|
|
124
|
+
|| character === "<") {
|
|
125
|
+
// A redirection may carry its descriptor on the front (`2>`), which is
|
|
126
|
+
// already sitting in `text` unquoted.
|
|
127
|
+
const prefix = !quoted && /^[0-9]+$/.test(text) ? text : null;
|
|
128
|
+
if (prefix === null) flush();
|
|
129
|
+
else { text = ""; started = false; }
|
|
130
|
+
|
|
131
|
+
let operator = character;
|
|
132
|
+
while (index + 1 < command.length && "&|<>".includes(command[index + 1])) {
|
|
133
|
+
operator += command[index + 1];
|
|
134
|
+
index += 1;
|
|
135
|
+
}
|
|
136
|
+
tokens.push({ operator: prefix === null ? operator : prefix + operator });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
text += character;
|
|
141
|
+
started = true;
|
|
142
|
+
}
|
|
143
|
+
flush();
|
|
144
|
+
return tokens;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Break a token list at the separators, so every simple command is read. */
|
|
148
|
+
function simpleCommands(tokens) {
|
|
149
|
+
const commands = [[]];
|
|
150
|
+
for (const token of tokens) {
|
|
151
|
+
if (token.operator !== undefined && SEPARATORS.has(token.operator)) commands.push([]);
|
|
152
|
+
else commands.at(-1).push(token);
|
|
153
|
+
}
|
|
154
|
+
return commands.filter(command => command.length > 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Drop heredoc bodies.
|
|
159
|
+
*
|
|
160
|
+
* The body is content the shell never parses, and it is exactly where a `>` or
|
|
161
|
+
* an `rm` is most likely to appear innocently. Reading it would invent targets
|
|
162
|
+
* out of a file's own text.
|
|
163
|
+
*/
|
|
164
|
+
function stripHeredocs(command) {
|
|
165
|
+
const pattern = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/g;
|
|
166
|
+
let result = command;
|
|
167
|
+
for (;;) {
|
|
168
|
+
pattern.lastIndex = 0;
|
|
169
|
+
const opener = pattern.exec(result);
|
|
170
|
+
if (opener === null) return result;
|
|
171
|
+
const lineEnd = result.indexOf("\n", opener.index);
|
|
172
|
+
if (lineEnd === -1) return result.slice(0, opener.index) + result.slice(opener.index).replace(pattern, "");
|
|
173
|
+
const terminator = new RegExp(`^\\s*${opener[2]}\\s*$`, "m");
|
|
174
|
+
const rest = result.slice(lineEnd + 1);
|
|
175
|
+
const end = terminator.exec(rest);
|
|
176
|
+
const body = end === null ? rest.length : end.index + end[0].length;
|
|
177
|
+
result = result.slice(0, opener.index) + result.slice(opener.index, lineEnd).replace(pattern, "")
|
|
178
|
+
+ "\n" + rest.slice(body);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The word a redirection writes to, or null when it names no file. */
|
|
183
|
+
function redirectionTarget(tokens, index) {
|
|
184
|
+
const next = tokens[index + 1];
|
|
185
|
+
if (next === undefined || next.operator !== undefined) return null;
|
|
186
|
+
if (next.text.startsWith("&")) return null;
|
|
187
|
+
return NOT_A_FILE.has(next.text) ? null : next.text;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Operands of a simple command, with flags and their values removed. */
|
|
191
|
+
function operandsOf(words, valueFlags = new Set()) {
|
|
192
|
+
const operands = [];
|
|
193
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
194
|
+
const word = words[index];
|
|
195
|
+
if (!word.quoted && word.text.startsWith("-") && word.text !== "-") {
|
|
196
|
+
if (valueFlags.has(word.text)) index += 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
operands.push(word.text);
|
|
200
|
+
}
|
|
201
|
+
return operands;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Strip the leading environment assignments and wrappers off a command. */
|
|
205
|
+
function commandWords(words) {
|
|
206
|
+
let start = 0;
|
|
207
|
+
while (start < words.length) {
|
|
208
|
+
const word = words[start];
|
|
209
|
+
if (word.operator !== undefined) return null;
|
|
210
|
+
if (!word.quoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(word.text)) { start += 1; continue; }
|
|
211
|
+
if (["sudo", "command", "nohup", "time", "nice"].includes(word.text)) { start += 1; continue; }
|
|
212
|
+
if (word.text === "env") { start += 1; continue; }
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
return start >= words.length ? null : words.slice(start);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Targets a `dd` call writes, which it names as `of=`. */
|
|
219
|
+
function ddTargets(words) {
|
|
220
|
+
return words.slice(1)
|
|
221
|
+
.filter(word => word.text.startsWith("of="))
|
|
222
|
+
.map(word => word.text.slice(3))
|
|
223
|
+
.filter(target => target !== "" && !NOT_A_FILE.has(target));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Targets a `git` call writes into the working tree.
|
|
228
|
+
*
|
|
229
|
+
* Only the two that overwrite a file a peer may be holding. `git log -- path`
|
|
230
|
+
* and `git diff path` name the same path and touch nothing, which is why the
|
|
231
|
+
* subcommand is read before the operands.
|
|
232
|
+
*/
|
|
233
|
+
function gitTargets(words) {
|
|
234
|
+
const subcommand = words[1]?.text;
|
|
235
|
+
if (subcommand === "restore") {
|
|
236
|
+
return operandsOf(words.slice(1), new Set(["--source", "-s"]));
|
|
237
|
+
}
|
|
238
|
+
if (subcommand === "checkout") {
|
|
239
|
+
const separator = words.findIndex(word => word.text === "--" && !word.quoted);
|
|
240
|
+
if (separator === -1) return [];
|
|
241
|
+
return words.slice(separator + 1).map(word => word.text);
|
|
242
|
+
}
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Targets of an in-place editor, whose script operand is not a file. */
|
|
247
|
+
function inPlaceTargets(words, spec) {
|
|
248
|
+
const flags = words.slice(1).filter(word => !word.quoted && word.text.startsWith("-"));
|
|
249
|
+
const inPlace = flags.some(word => word.text === "-i" || word.text.startsWith("-i")
|
|
250
|
+
|| word.text === "--in-place" || word.text.startsWith("--in-place="));
|
|
251
|
+
if (!inPlace) return [];
|
|
252
|
+
|
|
253
|
+
const valueFlags = new Set(spec.valueFlags);
|
|
254
|
+
const operands = [];
|
|
255
|
+
for (let index = 1; index < words.length; index += 1) {
|
|
256
|
+
const word = words[index];
|
|
257
|
+
if (!word.quoted && word.text.startsWith("-") && word.text !== "-") {
|
|
258
|
+
// `-i ''` on BSD takes its suffix as a separate word; a cluster carrying
|
|
259
|
+
// `e` takes the script that follows it.
|
|
260
|
+
if (valueFlags.has(word.text)) index += 1;
|
|
261
|
+
else if (/^-[A-Za-z]*[eEf]$/.test(word.text)) index += 1;
|
|
262
|
+
else if (word.text === "-i" && words[index + 1]?.quoted
|
|
263
|
+
&& (words[index + 1].text === "" || words[index + 1].text.startsWith("."))) index += 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
operands.push(word.text);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const scriptGiven = flags.some(word => /^-[A-Za-z]*[eEf]$/.test(word.text)
|
|
270
|
+
|| word.text.startsWith("--expression") || word.text.startsWith("--file"));
|
|
271
|
+
return scriptGiven ? operands : operands.slice(1);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Paths a shell command would write.
|
|
276
|
+
*
|
|
277
|
+
* Order is the order they appear; a path named twice is reported once. Anything
|
|
278
|
+
* unreadable yields an empty list.
|
|
279
|
+
*/
|
|
280
|
+
export function shellWriteTargets(command) {
|
|
281
|
+
if (typeof command !== "string" || command.trim() === "") return [];
|
|
282
|
+
|
|
283
|
+
let targets = [];
|
|
284
|
+
try {
|
|
285
|
+
const tokens = tokenize(stripHeredocs(command));
|
|
286
|
+
if (tokens === null) return [];
|
|
287
|
+
|
|
288
|
+
for (const simple of simpleCommands(tokens)) {
|
|
289
|
+
for (let index = 0; index < simple.length; index += 1) {
|
|
290
|
+
const token = simple[index];
|
|
291
|
+
if (token.operator === undefined || !WRITE_REDIRECTION.test(token.operator)) continue;
|
|
292
|
+
const target = redirectionTarget(simple, index);
|
|
293
|
+
if (target !== null) targets.push(target);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const words = commandWords(simple.filter(token => token.operator === undefined));
|
|
297
|
+
if (words === null || words.length === 0) continue;
|
|
298
|
+
const name = words[0].text.split("/").at(-1);
|
|
299
|
+
|
|
300
|
+
if (name === "dd") { targets.push(...ddTargets(words)); continue; }
|
|
301
|
+
if (name === "git") { targets.push(...gitTargets(words)); continue; }
|
|
302
|
+
|
|
303
|
+
const inPlace = IN_PLACE.get(name);
|
|
304
|
+
if (inPlace !== undefined) { targets.push(...inPlaceTargets(words, inPlace)); continue; }
|
|
305
|
+
|
|
306
|
+
const writer = WRITERS.get(name);
|
|
307
|
+
if (writer === undefined) continue;
|
|
308
|
+
const operands = operandsOf(words, writer.valueFlags);
|
|
309
|
+
if (operands.length === 0) continue;
|
|
310
|
+
targets.push(...(writer.pick === "last" ? operands.slice(-1) : operands));
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// The hook path fails open. A command this cannot read is a command it
|
|
314
|
+
// reports nothing about.
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return [...new Set(targets.filter(target => target !== "" && !NOT_A_FILE.has(target)))];
|
|
319
|
+
}
|
|
@@ -17,8 +17,12 @@ export const COMMANDS = Object.freeze({
|
|
|
17
17
|
"state", "workstream"], repeated: ["hint"], flags: ["clear"] },
|
|
18
18
|
claim: { required: ["resource"],
|
|
19
19
|
optional: ["session", "generation", "mode", "enforcement", "reason", "lease"] },
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Neither is required on its own, because either one names the claim: an id
|
|
21
|
+
// is precise, and a resource is what the caller typed to take it in the first
|
|
22
|
+
// place. Requiring the id meant a round trip through `acc status --json` to
|
|
23
|
+
// look up something the caller never chose.
|
|
24
|
+
release: { required: [],
|
|
25
|
+
optional: ["claim", "resource", "session", "generation", "authority", "reason"] },
|
|
22
26
|
message: { required: ["subject", "body"],
|
|
23
27
|
optional: ["session", "generation", "type", "priority", "workstream"],
|
|
24
28
|
repeated: ["to"], flags: ["requires-ack"] },
|
|
@@ -21,6 +21,39 @@ import { discoverWorkspace } from "./workspace-discovery.mjs";
|
|
|
21
21
|
|
|
22
22
|
const DEFAULT_CADENCE_MS = 30_000;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The claim a caller means when they name a resource instead of an id.
|
|
26
|
+
*
|
|
27
|
+
* `acc claim` takes `--resource`; `acc release` took `--claim`. The asymmetry
|
|
28
|
+
* cost every caller - a person and an agent alike - a round trip through
|
|
29
|
+
* `acc status --json` to find an id they never chose. The id is still accepted,
|
|
30
|
+
* and is still the precise answer when two sessions hold the same resource and
|
|
31
|
+
* an authority is releasing someone else's.
|
|
32
|
+
*/
|
|
33
|
+
async function claimOn(options, context) {
|
|
34
|
+
if (options.resource === undefined) {
|
|
35
|
+
throw new AccError(EXIT.USAGE,
|
|
36
|
+
"release needs the claim to give back: --resource is what you claimed, "
|
|
37
|
+
+ "--claim is its id");
|
|
38
|
+
}
|
|
39
|
+
const resource = await canonicalClaim(options.resource, context.descriptor);
|
|
40
|
+
const { claims } = await context.service.collectStatus({});
|
|
41
|
+
// An authority releasing another session's claim is not looking for its own.
|
|
42
|
+
const mine = claims.filter(claim => claim.resource === resource
|
|
43
|
+
&& (options.authority !== undefined || claim.ownerSessionId === options.session));
|
|
44
|
+
|
|
45
|
+
if (mine.length === 0) {
|
|
46
|
+
throw new AccError(EXIT.DATA, `no claim on ${resource} to release`,
|
|
47
|
+
{ resource, held: claims.map(claim => claim.resource) });
|
|
48
|
+
}
|
|
49
|
+
if (mine.length > 1) {
|
|
50
|
+
throw new AccError(EXIT.DATA,
|
|
51
|
+
`${mine.length} claims on ${resource}; name the one you mean with --claim`,
|
|
52
|
+
{ resource, claims: mine.map(claim => claim.claimId) });
|
|
53
|
+
}
|
|
54
|
+
return mine[0].claimId;
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
async function openContext(options, runtime) {
|
|
25
58
|
const descriptor = await discoverWorkspace({
|
|
26
59
|
cwd: options.cwd ?? runtime.cwd,
|
|
@@ -120,11 +153,12 @@ const HANDLERS = Object.freeze({
|
|
|
120
153
|
},
|
|
121
154
|
|
|
122
155
|
release: async ({ options, context }) => {
|
|
123
|
-
const
|
|
156
|
+
const claimId = options.claim ?? await claimOn(options, context);
|
|
157
|
+
const request = { claimId, sessionId: options.session,
|
|
124
158
|
generation: options.generation, authority: options.authority, reason: options.reason };
|
|
125
159
|
if (options.authority === undefined) await context.service.releaseClaim(request);
|
|
126
160
|
else await context.service.forceReleaseClaim(request);
|
|
127
|
-
return { data: { claimId
|
|
161
|
+
return { data: { claimId }, text: `released ${claimId}` };
|
|
128
162
|
},
|
|
129
163
|
|
|
130
164
|
message: async ({ options, context }) => {
|
|
@@ -108,12 +108,22 @@ export function createStatusService(ports, sessions) {
|
|
|
108
108
|
state: workstream.state,
|
|
109
109
|
coordinatorSessionId: workstream.coordinatorSessionId,
|
|
110
110
|
})),
|
|
111
|
+
// The owner is named twice on purpose. Every command that reaches a peer
|
|
112
|
+
// takes a participant id, so a claim that gave only a session id sent the
|
|
113
|
+
// reader back through the roster to answer "who is holding this, and how
|
|
114
|
+
// do I ask them for it". The session id stays because it is what
|
|
115
|
+
// `acc release --authority` acts on, and because two sessions of one
|
|
116
|
+
// participant are still two holders.
|
|
111
117
|
claims: claims.map(claim => ({
|
|
112
118
|
claimId: claim.claimId,
|
|
113
119
|
resource: claim.resource,
|
|
114
120
|
mode: claim.mode,
|
|
115
121
|
enforcement: claim.enforcement,
|
|
116
122
|
ownerSessionId: claim.ownerSessionId,
|
|
123
|
+
// Read from every session on record, not only the live ones: a claim
|
|
124
|
+
// outliving its session is exactly when this question gets asked.
|
|
125
|
+
ownerParticipantId: sessionRecords
|
|
126
|
+
.find(session => session.sessionId === claim.ownerSessionId)?.participantId ?? null,
|
|
117
127
|
expiresAt: claim.expiresAt,
|
|
118
128
|
})),
|
|
119
129
|
attention: computeAttention(snapshot, { session: null,
|