agents-can-communicate 0.8.5 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/bin/entrypoints/acc-antigravity-relay.mjs +2 -4
- package/bin/entrypoints/acc-hook.mjs +33 -19
- package/bin/entrypoints/hook-output.mjs +14 -5
- package/docs/ADAPTER_AUTHORING.md +29 -0
- package/docs/CAPABILITIES.md +7 -6
- package/docs/CLI.md +5 -0
- package/docs/CONFIGURATION.md +41 -10
- package/docs/DESIGN_DECISIONS.md +0 -2
- package/docs/GETTING_STARTED.md +39 -6
- package/docs/HOW_IT_WORKS.md +13 -8
- package/docs/SECURITY_MODEL.md +32 -5
- package/docs/TROUBLESHOOTING.md +76 -8
- package/docs/UPGRADING.md +47 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/certification.json +78 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/certification-provenance.json +111 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product-evidence.json +156 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/delivery/antigravity-desktop-2.19.1-product.json +25 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/fixtures/desktop-2.19.1.json +141 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/package.json +4 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/plugin.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/adapter.mjs +13 -2
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/agentapi.mjs +7 -5
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/allow-rule.mjs +98 -17
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/client-process.mjs +64 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-allow-rule.mjs +140 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-delivery.mjs +110 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/desktop-endpoint.mjs +203 -0
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/install.mjs +61 -30
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/native-delivery.mjs +61 -24
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-endpoint.mjs +30 -20
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay-start.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relay.mjs +20 -12
- package/node_modules/@agents-can-communicate/adapter-antigravity/src/relays.mjs +18 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +6 -0
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-delivery.mjs +50 -37
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-endpoint.mjs +44 -31
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-registry.mjs +99 -14
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-settings.mjs +3 -7
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/inbox-socket-directories.mjs +6 -4
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/install.mjs +32 -7
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/allow-rule.mjs +129 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +68 -3
- package/node_modules/@agents-can-communicate/adapter-codex/src/daemon-layouts.mjs +13 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/embedded-host.mjs +42 -11
- package/node_modules/@agents-can-communicate/adapter-codex/src/install.mjs +36 -13
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +68 -27
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +36 -18
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-endpoint.mjs +39 -16
- package/node_modules/@agents-can-communicate/adapter-codex/src/ws-json-rpc.mjs +4 -2
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +15 -9
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +25 -14
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +3 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +2 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/src/install.mjs +15 -10
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +50 -5
- package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +11 -2
- package/node_modules/@agents-can-communicate/adapter-sdk/src/executables.mjs +149 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +79 -12
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +9 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +65 -10
- package/node_modules/@agents-can-communicate/adapter-sdk/src/own-version.mjs +27 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/private-files.mjs +35 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/process-table.mjs +232 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +8 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-command.mjs +69 -0
- package/node_modules/@agents-can-communicate/adapter-sdk/src/windows-process-chain.wsf +45 -0
- package/node_modules/@agents-can-communicate/cli/package.json +2 -1
- package/node_modules/@agents-can-communicate/cli/src/config-command.mjs +2 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +60 -4
- package/node_modules/@agents-can-communicate/cli/src/hook-support.mjs +9 -0
- package/node_modules/@agents-can-communicate/cli/src/hook-workspace.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +3 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +12 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +7 -5
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +7 -7
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/launchers.mjs +8 -5
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/mutex.mjs +7 -6
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/portable-fs.mjs +165 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +18 -8
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +11 -3
- package/node_modules/@agents-can-communicate/cli/src/platform-paths.mjs +15 -10
- package/node_modules/@agents-can-communicate/cli/src/runtime-paths.mjs +3 -2
- package/node_modules/@agents-can-communicate/cli/src/update-command.mjs +11 -1
- package/node_modules/@agents-can-communicate/cli/src/workspace-discovery.mjs +4 -2
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +7 -0
- package/node_modules/@agents-can-communicate/core/src/materialisation.mjs +39 -1
- package/node_modules/@agents-can-communicate/core/src/session-opening.mjs +11 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +8 -2
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/client-pid.mjs +65 -6
- package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +80 -0
- package/node_modules/@agents-can-communicate/hook-runner/src/native-attempt.mjs +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/owner-context.mjs +34 -6
- package/node_modules/@agents-can-communicate/hook-runner/src/process-table.mjs +3 -50
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +154 -28
- package/node_modules/@agents-can-communicate/installer/package.json +3 -2
- package/node_modules/@agents-can-communicate/installer/src/delivery-diagnostics.mjs +2 -1
- package/node_modules/@agents-can-communicate/installer/src/detect.mjs +13 -3
- package/node_modules/@agents-can-communicate/installer/src/hook-support.mjs +5 -0
- package/node_modules/@agents-can-communicate/installer/src/native-activation.mjs +6 -14
- package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +6 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +14 -0
- 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/protocol/src/config.mjs +6 -2
- package/node_modules/@agents-can-communicate/protocol/src/resources.mjs +6 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +5 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +41 -23
- package/node_modules/@agents-can-communicate/storage-filesystem/src/doomed-directory.mjs +4 -12
- package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +7 -3
- package/node_modules/@agents-can-communicate/storage-filesystem/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +24 -7
- package/node_modules/@agents-can-communicate/storage-filesystem/src/portable-fs.mjs +165 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/publication-path.mjs +8 -0
- package/node_modules/@agents-can-communicate/storage-filesystem/src/reclaim.mjs +26 -17
- package/node_modules/@agents-can-communicate/storage-filesystem/src/recovery.mjs +7 -5
- package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +7 -16
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +138 -24
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +3 -2
- package/node_modules/@agents-can-communicate/storage-filesystem/src/stage-sweep.mjs +9 -6
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +30 -8
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +102 -32
- package/package.json +1 -5
|
@@ -23,7 +23,7 @@ export { validateNativeActivationPlan } from "./native-activation.mjs";
|
|
|
23
23
|
|
|
24
24
|
const orderedModes = modes => NATIVE_BINDING_MODES.filter(mode => modes.includes(mode));
|
|
25
25
|
|
|
26
|
-
export function validateNativeDeliveryContract(value, { certification, client }) {
|
|
26
|
+
export function validateNativeDeliveryContract(value, { certification, client, clients = [client] }) {
|
|
27
27
|
// Consent to live delivery is what `acc install` recorded. Up to 0.7.x a
|
|
28
28
|
// Claude shell shim could export it instead ("bootstrap-environment"); the
|
|
29
29
|
// shim is gone, and the installation record is the one source left.
|
|
@@ -65,11 +65,19 @@ export function validateNativeDeliveryContract(value, { certification, client })
|
|
|
65
65
|
+ "is recorded by its evidence, and the anchor names the version alone "
|
|
66
66
|
+ "(docs/design/2026-09-26-native-delivery-across-platforms.md)");
|
|
67
67
|
}
|
|
68
|
-
closed(anchor, ["version", "protocolContract"], "nativeDelivery anchor");
|
|
68
|
+
closed(anchor, ["version", "protocolContract"], "nativeDelivery anchor", ["client"]);
|
|
69
69
|
if (parseStableVersion(anchor.version) === null) {
|
|
70
70
|
usage(`nativeDelivery anchor ${index} version must be a stable version`);
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
// An anchor may belong to another product the adapter serves (see
|
|
73
|
+
// client.variants). Its versions count on that product's own line, so the
|
|
74
|
+
// primary minimum says nothing about them.
|
|
75
|
+
const owner = Object.hasOwn(anchor, "client") ? anchor.client : client;
|
|
76
|
+
if (!clients.includes(owner)) {
|
|
77
|
+
usage(`nativeDelivery anchor ${anchor.version} names client ${owner}, which the adapter `
|
|
78
|
+
+ "does not declare", { anchor });
|
|
79
|
+
}
|
|
80
|
+
if (owner === client && compareStableVersions(anchor.version, minimum) < 0) {
|
|
73
81
|
usage(`nativeDelivery anchor ${index} version ${anchor.version} is below the minimum ${minimum}: `
|
|
74
82
|
+ "the minimum must be the first passing capture");
|
|
75
83
|
}
|
|
@@ -79,7 +87,7 @@ export function validateNativeDeliveryContract(value, { certification, client })
|
|
|
79
87
|
// Where the capture was taken is provenance the evidence row keeps; the
|
|
80
88
|
// anchor is proven by a passing capture of this version on any platform.
|
|
81
89
|
const proven = (certification?.evidence ?? []).some(item => item.result === "pass"
|
|
82
|
-
&& item.capability === "delivery.livePush" && item.client ===
|
|
90
|
+
&& item.capability === "delivery.livePush" && item.client === owner
|
|
83
91
|
&& item.version === anchor.version);
|
|
84
92
|
if (!proven) {
|
|
85
93
|
usage(`nativeDelivery anchor ${anchor.version} has no passing delivery.livePush certification`,
|
|
@@ -87,7 +95,18 @@ export function validateNativeDeliveryContract(value, { certification, client })
|
|
|
87
95
|
}
|
|
88
96
|
return { ...anchor };
|
|
89
97
|
});
|
|
90
|
-
|
|
98
|
+
const owners = new Map();
|
|
99
|
+
for (const anchor of anchors) {
|
|
100
|
+
const owner = anchor.client ?? client;
|
|
101
|
+
const previous = owners.get(anchor.protocolContract);
|
|
102
|
+
if (previous !== undefined && previous !== owner) {
|
|
103
|
+
usage(`nativeDelivery protocol ${anchor.protocolContract} is anchored for both ${previous} `
|
|
104
|
+
+ `and ${owner}`, { protocolContract: anchor.protocolContract });
|
|
105
|
+
}
|
|
106
|
+
owners.set(anchor.protocolContract, owner);
|
|
107
|
+
}
|
|
108
|
+
if (!anchors.some(anchor => (anchor.client ?? client) === client
|
|
109
|
+
&& compareStableVersions(anchor.version, minimum) === 0)) {
|
|
91
110
|
usage(`nativeDelivery minimum ${minimum} must be the first passing capture: no anchor matches it`);
|
|
92
111
|
}
|
|
93
112
|
if (!Array.isArray(value.knownBad)) usage("nativeDelivery.knownBad must be an array");
|
|
@@ -144,7 +163,7 @@ function knownBadHit(contract, order) {
|
|
|
144
163
|
// validateNativeHandshake below, and the delivery router's own offer check,
|
|
145
164
|
// which uses this function alone because a response already carries no probe
|
|
146
165
|
// or handshake shape to check further).
|
|
147
|
-
export function evaluateVersionContract(adapter, { clientVersion } = {}) {
|
|
166
|
+
export function evaluateVersionContract(adapter, { clientVersion, protocolContract } = {}) {
|
|
148
167
|
const contract = adapter?.nativeDelivery;
|
|
149
168
|
const unsupported = { reasonCode: "native_delivery_unsupported", minimumVersion: null,
|
|
150
169
|
protocolContract: null };
|
|
@@ -161,9 +180,18 @@ export function evaluateVersionContract(adapter, { clientVersion } = {}) {
|
|
|
161
180
|
// already answers malformed input this way.
|
|
162
181
|
const minimumVersion = isText(contract.minimum) ? contract.minimum : null;
|
|
163
182
|
if (minimumVersion === null) return unsupported;
|
|
164
|
-
const
|
|
165
|
-
|
|
183
|
+
const anchors = Array.isArray(contract.anchors) ? contract.anchors : [];
|
|
184
|
+
const primary = adapter.client?.certificationName ?? adapter.client?.command;
|
|
185
|
+
const anchor = anchors.find(item => item?.version === minimumVersion
|
|
186
|
+
&& (!isText(item?.client) || item.client === primary));
|
|
166
187
|
if (anchor === undefined) return unsupported;
|
|
188
|
+
// Another product's protocol has its own floor - the first capture that
|
|
189
|
+
// anchored it - and the denylist, which names the primary product's
|
|
190
|
+
// versions, does not reach it. Asked about no protocol, or the primary one,
|
|
191
|
+
// the rule is the primary minimum as it always was.
|
|
192
|
+
if (isText(protocolContract) && protocolContract !== anchor.protocolContract) {
|
|
193
|
+
return protocolFloor(anchors, protocolContract, clientVersion);
|
|
194
|
+
}
|
|
167
195
|
const facts = { minimumVersion, protocolContract: anchor.protocolContract };
|
|
168
196
|
const order = versionOrder(clientVersion);
|
|
169
197
|
if (order === null) return { ...facts, reasonCode: "version_unavailable" };
|
|
@@ -174,6 +202,31 @@ export function evaluateVersionContract(adapter, { clientVersion } = {}) {
|
|
|
174
202
|
return { ...facts, reasonCode: null };
|
|
175
203
|
}
|
|
176
204
|
|
|
205
|
+
function protocolFloor(anchors, protocolContract, clientVersion) {
|
|
206
|
+
const own = anchors.filter(item => item?.protocolContract === protocolContract
|
|
207
|
+
&& isText(item?.version));
|
|
208
|
+
if (own.length === 0) {
|
|
209
|
+
return { reasonCode: "protocol_mismatch", minimumVersion: null, protocolContract };
|
|
210
|
+
}
|
|
211
|
+
const minimumVersion = own.map(item => item.version)
|
|
212
|
+
.reduce((first, version) => (compareStableVersions(version, first) < 0 ? version : first));
|
|
213
|
+
const facts = { minimumVersion, protocolContract };
|
|
214
|
+
const order = versionOrder(clientVersion);
|
|
215
|
+
if (order === null) return { ...facts, reasonCode: "version_unavailable" };
|
|
216
|
+
if (compareVersionOrder(order, versionOrder(minimumVersion)) < 0) {
|
|
217
|
+
return { ...facts, reasonCode: "below_minimum_version" };
|
|
218
|
+
}
|
|
219
|
+
return { ...facts, reasonCode: null };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The protocol a probe or handshake reports, when it is one an anchor names:
|
|
223
|
+
// that protocol's own floor judges it. Any other value is left to the primary
|
|
224
|
+
// rule, so it still ends as the protocol_mismatch it always was.
|
|
225
|
+
function reportedProtocol(adapter, reported) {
|
|
226
|
+
return isText(reported) && (adapter?.nativeDelivery?.anchors ?? [])
|
|
227
|
+
.some(item => item?.protocolContract === reported) ? reported : undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
177
230
|
function validateNativeProbe(probe) {
|
|
178
231
|
closed(probe, PROBE_KEYS, "native probe");
|
|
179
232
|
if (typeof probe.supported !== "boolean") usage("native probe supported must be a boolean");
|
|
@@ -199,7 +252,8 @@ export function evaluateNativeEligibility(adapter, { clientVersion, probe }) {
|
|
|
199
252
|
// Only the version is read here; the shape is validated where it always was,
|
|
200
253
|
// so a malformed probe still returns a closed result rather than throwing.
|
|
201
254
|
const serving = isText(probe?.clientVersion) ? probe.clientVersion : clientVersion;
|
|
202
|
-
const rule = evaluateVersionContract(adapter, { clientVersion: serving
|
|
255
|
+
const rule = evaluateVersionContract(adapter, { clientVersion: serving,
|
|
256
|
+
protocolContract: reportedProtocol(adapter, probe?.protocolContract) });
|
|
203
257
|
const base = { eligible: false, reasonCode: null, minimumVersion: rule.minimumVersion,
|
|
204
258
|
protocolContract: rule.protocolContract, modes: [] };
|
|
205
259
|
const closedResult = reasonCode => deepFreeze({ ...base, reasonCode });
|
|
@@ -263,7 +317,8 @@ export function validateNativeHandshake(adapter, { clientVersion, handshake }) {
|
|
|
263
317
|
// the version is read here; the shape is validated where it always was, so
|
|
264
318
|
// a malformed handshake still returns a closed result rather than throwing.
|
|
265
319
|
const serving = isText(handshake?.clientVersion) ? handshake.clientVersion : clientVersion;
|
|
266
|
-
const rule = evaluateVersionContract(adapter, { clientVersion: serving
|
|
320
|
+
const rule = evaluateVersionContract(adapter, { clientVersion: serving,
|
|
321
|
+
protocolContract: reportedProtocol(adapter, handshake?.protocolContract) });
|
|
267
322
|
const base = { ok: false, reasonCode: null, clientVersion: null,
|
|
268
323
|
protocolContract: rule.protocolContract, modes: [],
|
|
269
324
|
opaqueEndpointRef: null, leaseUntil: null };
|
|
@@ -75,13 +75,39 @@ export async function stampPluginVersion({ file, version, io }) {
|
|
|
75
75
|
* holds every plugin installed from that marketplace, and removing that root
|
|
76
76
|
* once took a plugin the user had installed themselves - so a sibling here is
|
|
77
77
|
* an older ACC, and a sibling one level up is somebody else's.
|
|
78
|
+
*
|
|
79
|
+
* A version a running session marks in use stays whatever `keep` says. Claude
|
|
80
|
+
* Code puts `.in_use/<pid>`, named after a running session, into a cached
|
|
81
|
+
* version and drops the file when that session exits (2.1.286). A reinstall
|
|
82
|
+
* after an update names only the active version, so it removed a copy the
|
|
83
|
+
* client had marked (#257). A marker whose process has exited holds nothing; a
|
|
84
|
+
* reused pid at worst keeps a copy until the next install.
|
|
78
85
|
*/
|
|
79
|
-
|
|
86
|
+
const IN_USE = ".in_use";
|
|
87
|
+
const PID = /^[1-9]\d*$/;
|
|
88
|
+
|
|
89
|
+
// `kill(pid, 0)` delivers nothing; EPERM means the process exists under another user.
|
|
90
|
+
function processAlive(pid) {
|
|
91
|
+
try {
|
|
92
|
+
process.kill(pid, 0);
|
|
93
|
+
return true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return error.code === "EPERM";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function markedInUse(directory, io, alive) {
|
|
100
|
+
const markers = await io.readdir(path.join(directory, IN_USE)).catch(() => []);
|
|
101
|
+
return markers.some(name => PID.test(name) && alive(Number(name)));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function keepVersions({ root, keep, io, alive = processAlive }) {
|
|
80
105
|
const wanted = new Set(keep.filter(version => typeof version === "string" && version !== ""));
|
|
81
106
|
const entries = await io.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
82
107
|
const removed = [];
|
|
83
108
|
for (const entry of entries) {
|
|
84
109
|
if (!entry.isDirectory() || wanted.has(entry.name)) continue;
|
|
110
|
+
if (await markedInUse(path.join(root, entry.name), io, alive)) continue;
|
|
85
111
|
await io.rm(path.join(root, entry.name), { recursive: true, force: true });
|
|
86
112
|
removed.push(entry.name);
|
|
87
113
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
// Private records an adapter keeps beside a live endpoint: one user's, never
|
|
5
|
+
// reached through a link. POSIX keeps them to the owner with mode bits on each
|
|
6
|
+
// file and directory. Windows has none - Node reports every file as 0o666 - and
|
|
7
|
+
// keeps them private through the profile's ACL, checked for the data home once
|
|
8
|
+
// (docs/design/2026-09-30-native-windows-support.md, section 8).
|
|
9
|
+
|
|
10
|
+
export const isWindowsPlatform = platform => String(platform).startsWith("win32");
|
|
11
|
+
|
|
12
|
+
/** Whether `info` grants none of `mask` to others; always so on Windows. */
|
|
13
|
+
export const closedTo = (info, mask, { platform = process.platform } = {}) =>
|
|
14
|
+
isWindowsPlatform(platform) || (info.mode & mask) === 0;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Open a regular file without following a link at its name. Windows has no
|
|
18
|
+
* O_NOFOLLOW, so the name is checked first and the handle after the open has to
|
|
19
|
+
* hold the file the name named.
|
|
20
|
+
*/
|
|
21
|
+
export async function openRegularNoFollow(file, flags = constants.O_RDONLY,
|
|
22
|
+
{ platform = process.platform } = {}) {
|
|
23
|
+
if (!isWindowsPlatform(platform)) {
|
|
24
|
+
return open(file, flags | constants.O_NOFOLLOW | (constants.O_NONBLOCK ?? 0));
|
|
25
|
+
}
|
|
26
|
+
const named = await lstat(file, { bigint: true });
|
|
27
|
+
if (!named.isFile()) throw Object.assign(new Error(`not a regular file: ${file}`), { code: "EINVAL" });
|
|
28
|
+
const handle = await open(file, flags);
|
|
29
|
+
const opened = await handle.stat({ bigint: true }).catch(() => null);
|
|
30
|
+
if (opened?.dev !== named.dev || opened?.ino !== named.ino) {
|
|
31
|
+
await handle.close().catch(() => null);
|
|
32
|
+
throw Object.assign(new Error(`replaced while opening: ${file}`), { code: "EINVAL" });
|
|
33
|
+
}
|
|
34
|
+
return handle;
|
|
35
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
// A hook runs in front of someone's turn. Reading the table is worth a few
|
|
9
|
+
// hundred milliseconds once per session and nothing at all if it is slow.
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 1_000;
|
|
11
|
+
|
|
12
|
+
const LINE = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/;
|
|
13
|
+
// A pid, then the whole rest of the line: a command line has spaces anywhere.
|
|
14
|
+
const ARGS_LINE = /^\s*(\d+)\s+(.+?)\s*$/;
|
|
15
|
+
// Command lines are long; measured at 231 KB for 1,137 processes on a desktop
|
|
16
|
+
// Mac, a quarter of execFile's default buffer.
|
|
17
|
+
const ARGS_BUFFER = 16 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
// Deep enough for a client that wraps its hook in a shell and a launcher.
|
|
20
|
+
const WINDOWS_HOPS = 8;
|
|
21
|
+
|
|
22
|
+
async function posixTable({ run, timeoutMs }) {
|
|
23
|
+
const options = { timeout: timeoutMs, killSignal: "SIGKILL", windowsHide: true };
|
|
24
|
+
const [tree, commandLines] = await Promise.all([
|
|
25
|
+
run("ps", ["-o", "pid=,ppid=,comm=", "-A"], options).then(({ stdout }) => stdout, () => null),
|
|
26
|
+
run("ps", ["-A", "-o", "pid=,args="], { ...options, maxBuffer: ARGS_BUFFER })
|
|
27
|
+
.then(({ stdout }) => stdout, () => ""),
|
|
28
|
+
]);
|
|
29
|
+
if (typeof tree !== "string") return new Map();
|
|
30
|
+
const args = new Map();
|
|
31
|
+
for (const line of String(commandLines).split("\n")) {
|
|
32
|
+
const match = ARGS_LINE.exec(line);
|
|
33
|
+
if (match !== null) args.set(Number(match[1]), match[2]);
|
|
34
|
+
}
|
|
35
|
+
const table = new Map();
|
|
36
|
+
for (const line of tree.split("\n")) {
|
|
37
|
+
const match = LINE.exec(line);
|
|
38
|
+
if (match === null) continue;
|
|
39
|
+
const pid = Number(match[1]);
|
|
40
|
+
table.set(pid, { ppid: Number(match[2]), comm: match[3],
|
|
41
|
+
...(args.has(pid) ? { args: args.get(pid) } : {}) });
|
|
42
|
+
}
|
|
43
|
+
return table;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Windows has no `ps` and no longer ships `wmic`. Win32_Process through CIM
|
|
47
|
+
// answers pid, parent, creation time and command line. A full scan took 1.07 s
|
|
48
|
+
// in Windows PowerShell 5.1 on a windows-latest runner, so the script asks only
|
|
49
|
+
// for the chain it needs, one filtered query per hop. The creation time goes
|
|
50
|
+
// out as a string: a FILETIME is larger than a double holds exactly.
|
|
51
|
+
const chainScript = (from, hops) => `$ErrorActionPreference = 'Stop'
|
|
52
|
+
$next = ${Number(from)}
|
|
53
|
+
$chain = New-Object System.Collections.ArrayList
|
|
54
|
+
for ($hop = 0; $hop -lt ${Number(hops)}; $hop++) {
|
|
55
|
+
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$next" -Property ProcessId,ParentProcessId,CreationDate,Name,CommandLine
|
|
56
|
+
if ($null -eq $p) { break }
|
|
57
|
+
[void]$chain.Add([pscustomobject]@{ pid = [int]$p.ProcessId; ppid = [int]$p.ParentProcessId; name = [string]$p.Name; start = $p.CreationDate.ToFileTimeUtc().ToString(); cmd = [string]$p.CommandLine })
|
|
58
|
+
if ($p.ParentProcessId -eq 0 -or $p.ParentProcessId -eq $next) { break }
|
|
59
|
+
$next = [int]$p.ParentProcessId
|
|
60
|
+
}
|
|
61
|
+
ConvertTo-Json -Compress -InputObject @($chain)`;
|
|
62
|
+
|
|
63
|
+
// PowerShell by absolute path only, so a PATH entry cannot stand in for it.
|
|
64
|
+
// PowerShell 7 is not part of Windows; where it is installed it answers faster
|
|
65
|
+
// (0.37 s against 1.07 s for a full scan, measured), and a missing one fails to
|
|
66
|
+
// spawn at once.
|
|
67
|
+
function powershellCandidates(env) {
|
|
68
|
+
const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
|
|
69
|
+
const programs = env.ProgramFiles ?? env.PROGRAMFILES ?? "C:\\Program Files";
|
|
70
|
+
return [path.win32.join(programs, "PowerShell", "7", "pwsh.exe"),
|
|
71
|
+
path.win32.join(root, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Windows Script Host reads the same chain through WMI in a quarter of the
|
|
75
|
+
// time: 145 ms against PowerShell's 580, and 258 against 1,080 with four hooks
|
|
76
|
+
// at once (windows-latest, measured), because it starts no .NET runtime. A
|
|
77
|
+
// machine can switch it off by policy; PowerShell then reads the chain.
|
|
78
|
+
const WMI_CHAIN = fileURLToPath(new URL("./windows-process-chain.wsf", import.meta.url));
|
|
79
|
+
|
|
80
|
+
// WMI's `yyyymmddHHMMSS.ffffff+UUU` - offset in minutes - as a FILETIME string,
|
|
81
|
+
// the form the PowerShell script prints.
|
|
82
|
+
function wmiFileTime(value) {
|
|
83
|
+
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})\.(\d{6})([+-])(\d{3})$/.exec(value ?? "");
|
|
84
|
+
if (match === null) return undefined;
|
|
85
|
+
const [, year, month, day, hour, minute, second, micros, sign, offset] = match;
|
|
86
|
+
const local = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour),
|
|
87
|
+
Number(minute), Number(second));
|
|
88
|
+
const utc = local - (sign === "+" ? 1 : -1) * Number(offset) * 60_000;
|
|
89
|
+
return String((BigInt(utc) + 11_644_473_600_000n) * 10_000n + BigInt(micros) * 10n);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const chainOf = stdout => {
|
|
93
|
+
const parsed = JSON.parse(stdout);
|
|
94
|
+
return (Array.isArray(parsed) ? parsed : [parsed]).map(hop => (typeof hop?.created === "string"
|
|
95
|
+
? { ...hop, start: wmiFileTime(hop.created) } : hop));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
async function windowsChain({ from, hops, run, timeoutMs, env }) {
|
|
99
|
+
const deadline = Date.now() + timeoutMs;
|
|
100
|
+
const options = () => ({ timeout: Math.max(1, deadline - Date.now()), killSignal: "SIGKILL",
|
|
101
|
+
windowsHide: true, maxBuffer: ARGS_BUFFER });
|
|
102
|
+
const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows";
|
|
103
|
+
try {
|
|
104
|
+
const { stdout } = await run(path.win32.join(root, "System32", "cscript.exe"),
|
|
105
|
+
["//Nologo", WMI_CHAIN, String(Number(from)), String(Number(hops))], options());
|
|
106
|
+
return chainOf(stdout);
|
|
107
|
+
} catch {
|
|
108
|
+
// Script Host is off or failed; PowerShell below.
|
|
109
|
+
}
|
|
110
|
+
const encoded = Buffer.from(chainScript(from, hops), "utf16le").toString("base64");
|
|
111
|
+
for (const shell of powershellCandidates(env)) {
|
|
112
|
+
if (Date.now() >= deadline) break;
|
|
113
|
+
try {
|
|
114
|
+
const { stdout } = await run(shell, ["-NoLogo", "-NoProfile", "-NonInteractive",
|
|
115
|
+
"-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], options());
|
|
116
|
+
return chainOf(stdout);
|
|
117
|
+
} catch {
|
|
118
|
+
// Try the next shell; none left is the empty answer below.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Windows: one process - pid, parent, image name, creation time as a FILETIME
|
|
126
|
+
* string (to the microsecond, as WMI keeps it) and command line - or null when
|
|
127
|
+
* it cannot be read.
|
|
128
|
+
*/
|
|
129
|
+
export async function readWindowsProcess(pid, { run = execFileAsync, timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
130
|
+
env = process.env } = {}) {
|
|
131
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
132
|
+
const [found] = await windowsChain({ from: pid, hops: 1, run, timeoutMs, env });
|
|
133
|
+
return found?.pid === pid ? found : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function windowsTable({ from, run, timeoutMs, env }) {
|
|
137
|
+
const table = new Map();
|
|
138
|
+
let child = null;
|
|
139
|
+
for (const hop of await windowsChain({ from, hops: WINDOWS_HOPS, run, timeoutMs, env })) {
|
|
140
|
+
if (!Number.isSafeInteger(hop?.pid) || typeof hop.name !== "string") break;
|
|
141
|
+
// Windows never re-parents an orphan: its ppid can name a process that
|
|
142
|
+
// died and whose number was reused. A parent created after its child is
|
|
143
|
+
// exactly that, and nothing above it belongs to this chain.
|
|
144
|
+
if (child !== null && !(BigInt(hop.start ?? "0") <= BigInt(child.start ?? "0"))) break;
|
|
145
|
+
table.set(hop.pid, { ppid: hop.ppid, comm: hop.name,
|
|
146
|
+
...(typeof hop.cmd === "string" && hop.cmd !== "" ? { args: hop.cmd } : {}),
|
|
147
|
+
...(typeof hop.start === "string" ? { start: hop.start } : {}) });
|
|
148
|
+
child = hop;
|
|
149
|
+
}
|
|
150
|
+
return table;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Processes on this machine, as pid -> parent, executable and, when it could be
|
|
155
|
+
* read, command line.
|
|
156
|
+
*
|
|
157
|
+
* POSIX reads the whole table with `ps`. Windows reads the chain from `from`
|
|
158
|
+
* upward - the only part a hook asks about - with a creation time for each hop.
|
|
159
|
+
* A failed read is an empty map, never an error: an empty table resolves no
|
|
160
|
+
* client, which is the "nobody knows" answer every caller already handles.
|
|
161
|
+
*/
|
|
162
|
+
export async function readProcessTable({ platform = process.platform, from = process.pid,
|
|
163
|
+
run = execFileAsync, timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env } = {}) {
|
|
164
|
+
if (platform === "win32") return windowsTable({ from, run, timeoutMs, env });
|
|
165
|
+
return posixTable({ run, timeoutMs });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The words one process was started with, or null when they cannot be read.
|
|
170
|
+
*
|
|
171
|
+
* POSIX splits `ps -o args=` on whitespace, as every caller did before. Windows
|
|
172
|
+
* keeps one command-line string per process, split here the way the C runtime
|
|
173
|
+
* splits it.
|
|
174
|
+
*/
|
|
175
|
+
export async function readProcessArgs(pid, { platform = process.platform, run = execFileAsync,
|
|
176
|
+
timeoutMs = DEFAULT_TIMEOUT_MS, env = process.env, ps = "ps" } = {}) {
|
|
177
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
178
|
+
if (platform === "win32") {
|
|
179
|
+
const [own] = await windowsChain({ from: pid, hops: 1, run, timeoutMs, env });
|
|
180
|
+
return own?.pid === pid && typeof own.cmd === "string" && own.cmd !== ""
|
|
181
|
+
? splitWindowsCommandLine(own.cmd) : null;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const { stdout } = await run(ps, ["-o", "args=", "-p", String(pid)],
|
|
185
|
+
{ timeout: timeoutMs, killSignal: "SIGKILL", windowsHide: true });
|
|
186
|
+
const words = stdout.trim().split(/\s+/).filter(Boolean);
|
|
187
|
+
return words.length > 0 ? words : null;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Split a Windows command line into argv the way CommandLineToArgvW does:
|
|
195
|
+
* whitespace separates words outside quotes; 2n backslashes before a quote are
|
|
196
|
+
* n backslashes and the quote toggles quoting; 2n+1 are n backslashes and a
|
|
197
|
+
* literal quote; backslashes elsewhere are literal.
|
|
198
|
+
*/
|
|
199
|
+
export function splitWindowsCommandLine(line) {
|
|
200
|
+
const words = [];
|
|
201
|
+
let word = "";
|
|
202
|
+
let started = false;
|
|
203
|
+
let quoted = false;
|
|
204
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
205
|
+
const char = line[index];
|
|
206
|
+
if (char === "\\") {
|
|
207
|
+
let count = 0;
|
|
208
|
+
while (line[index] === "\\") { count += 1; index += 1; }
|
|
209
|
+
if (line[index] === "\"") {
|
|
210
|
+
word += "\\".repeat(Math.floor(count / 2));
|
|
211
|
+
if (count % 2 === 1) word += "\"";
|
|
212
|
+
else quoted = !quoted;
|
|
213
|
+
} else {
|
|
214
|
+
word += "\\".repeat(count);
|
|
215
|
+
index -= 1;
|
|
216
|
+
}
|
|
217
|
+
started = true;
|
|
218
|
+
} else if (char === "\"") {
|
|
219
|
+
quoted = !quoted;
|
|
220
|
+
started = true;
|
|
221
|
+
} else if ((char === " " || char === "\t") && !quoted) {
|
|
222
|
+
if (started) words.push(word);
|
|
223
|
+
word = "";
|
|
224
|
+
started = false;
|
|
225
|
+
} else {
|
|
226
|
+
word += char;
|
|
227
|
+
started = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (started) words.push(word);
|
|
231
|
+
return words;
|
|
232
|
+
}
|
|
@@ -43,13 +43,17 @@ function assertBindingDeadline(deadlineAt) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export async function storeSessionBinding({ runtimeDir, harnessSessionId, accSessionId,
|
|
46
|
-
generation, clientVersion, platform, clientPid, deadlineAt, storeVersion, runtimeRoot }) {
|
|
46
|
+
generation, clientVersion, clientName, platform, clientPid, deadlineAt, storeVersion, runtimeRoot }) {
|
|
47
47
|
assertBindingDeadline(deadlineAt);
|
|
48
48
|
const file = fileFor(runtimeDir, harnessSessionId);
|
|
49
49
|
const record = { schemaVersion: SCHEMA_VERSION, harnessSessionId, accSessionId, generation };
|
|
50
50
|
assertIdentity(record);
|
|
51
51
|
await mkdir(path.dirname(file), { recursive: true });
|
|
52
52
|
if (typeof clientVersion === "string" && clientVersion !== "") record.clientVersion = clientVersion;
|
|
53
|
+
// The product the session runs in, when it is one of the adapter's variants
|
|
54
|
+
// rather than its own client: its evidence and its version line are its own.
|
|
55
|
+
// A reader that predates the field ignores it.
|
|
56
|
+
if (typeof clientName === "string" && clientName !== "") record.clientName = clientName;
|
|
53
57
|
if (typeof platform === "string" && platform !== "") record.platform = platform;
|
|
54
58
|
// The vendor process this session runs in, resolved once at SessionStart.
|
|
55
59
|
// A native endpoint is matched against it, so a binding without one can
|
|
@@ -106,6 +110,7 @@ export async function loadSessionBinding({ runtimeDir, harnessSessionId }) {
|
|
|
106
110
|
assertIdentity(record, harnessSessionId);
|
|
107
111
|
const binding = { accSessionId: record.accSessionId, generation: record.generation };
|
|
108
112
|
if (typeof record.clientVersion === "string") binding.clientVersion = record.clientVersion;
|
|
113
|
+
if (typeof record.clientName === "string" && record.clientName !== "") binding.clientName = record.clientName;
|
|
109
114
|
if (typeof record.platform === "string") binding.platform = record.platform;
|
|
110
115
|
if (isPid(record.clientPid)) binding.clientPid = record.clientPid;
|
|
111
116
|
return binding;
|
|
@@ -147,6 +152,8 @@ export async function listSessionBindings({ runtimeDir }) {
|
|
|
147
152
|
accSessionId: record.accSessionId, generation: record.generation,
|
|
148
153
|
...(typeof record.clientVersion === "string"
|
|
149
154
|
? { clientVersion: record.clientVersion } : {}),
|
|
155
|
+
...(typeof record.clientName === "string" && record.clientName !== ""
|
|
156
|
+
? { clientName: record.clientName } : {}),
|
|
150
157
|
...(typeof record.platform === "string" ? { platform: record.platform } : {}),
|
|
151
158
|
...(isPid(record.clientPid) ? { clientPid: record.clientPid } : {}) });
|
|
152
159
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// A hook command in the form each client's Windows hook runner reads. Measured
|
|
2
|
+
// from the clients' sources and binaries (docs/design/2026-09-30-native-windows-
|
|
3
|
+
// support.md): Codex and Gemini CLI run PowerShell, Grok detects PowerShell,
|
|
4
|
+
// Git Bash or cmd, Kimi Code runs cmd.exe, and Antigravity runs `cmd /c` through
|
|
5
|
+
// Go, which escapes inner quotes as `\"` - a form cmd does not read.
|
|
6
|
+
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
|
|
10
|
+
const defaultRun = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
// Characters one of those shells expands or ends a quoted word on. A path that
|
|
13
|
+
// carries one is refused: writing it would run something other than the shim.
|
|
14
|
+
const EXPANDED = /[$`"%]/;
|
|
15
|
+
// What cmd acts on in a word outside quotes, whitespace included.
|
|
16
|
+
const UNQUOTED_UNSAFE = /[\s&|<>^(),;=!]/;
|
|
17
|
+
|
|
18
|
+
const powershellLiteral = value => `'${String(value).replaceAll("'", "''")}'`;
|
|
19
|
+
const forward = value => String(value).replaceAll("\\", "/");
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {"powershell"|"portable"|"cmd"|"unquoted"} shell
|
|
23
|
+
* @param {{ node: string, shim: string, args: string[] }} parts
|
|
24
|
+
*/
|
|
25
|
+
export function windowsHookCommand(shell, { node, shim, args }) {
|
|
26
|
+
for (const word of [node, shim]) {
|
|
27
|
+
if (EXPANDED.test(word)) {
|
|
28
|
+
throw new Error(`a hook command cannot name ${word}: a Windows shell would expand it`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const tail = args.join(" ");
|
|
32
|
+
switch (shell) {
|
|
33
|
+
// `&` runs a quoted program; single quotes take nothing literally but `''`.
|
|
34
|
+
// pwsh -Command reports exit 2 as 1 unless the command exits itself.
|
|
35
|
+
case "powershell":
|
|
36
|
+
return `& ${powershellLiteral(node)} ${powershellLiteral(shim)} ${tail}; exit $LASTEXITCODE`;
|
|
37
|
+
// Node from PATH, which every one of the three shells finds; the shim then
|
|
38
|
+
// prefers the pinned runner. Forward slashes survive bash unquoting.
|
|
39
|
+
case "portable":
|
|
40
|
+
return `node "${forward(shim)}" ${tail}`;
|
|
41
|
+
case "cmd":
|
|
42
|
+
return `"${node}" "${shim}" ${tail}`;
|
|
43
|
+
// The path reaches cmd bare: & | < > ^ ( ) run as cmd's own operators and
|
|
44
|
+
// , ; = split the word the way a space does, so none may be in it.
|
|
45
|
+
case "unquoted":
|
|
46
|
+
if (UNQUOTED_UNSAFE.test(shim)) {
|
|
47
|
+
throw new Error(`a hook command cannot name ${shim} unquoted: it has a space or a character cmd acts on`);
|
|
48
|
+
}
|
|
49
|
+
return `node ${forward(shim)} ${tail}`;
|
|
50
|
+
default:
|
|
51
|
+
throw new Error(`unknown Windows hook shell: ${shell}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The 8.3 form of a path with a space in it, for a hook runner that cannot pass
|
|
57
|
+
* a quoted path (Antigravity CLI). cmd prints it through `%~s`. A volume with 8.3
|
|
58
|
+
* names turned off answers with the long path, which the caller then refuses.
|
|
59
|
+
*/
|
|
60
|
+
export async function shortPath(file, { run = defaultRun, env = process.env } = {}) {
|
|
61
|
+
if (!/\s/.test(file)) return file;
|
|
62
|
+
if (/["%^&|<>!]/.test(file)) return file;
|
|
63
|
+
const comspec = env.ComSpec ?? env.COMSPEC ?? "cmd.exe";
|
|
64
|
+
const { stdout } = await run(comspec, ["/d", "/c", `for %I in ("${file}") do @echo %~sI`],
|
|
65
|
+
{ windowsHide: true, windowsVerbatimArguments: true, timeout: 5_000 });
|
|
66
|
+
const answer = String(stdout).trim().split(/\r?\n/).at(-1) ?? "";
|
|
67
|
+
return answer === "" ? file : answer;
|
|
68
|
+
}
|
|
69
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<?xml version="1.0" ?>
|
|
2
|
+
<!--
|
|
3
|
+
The chain of processes from one pid upward, read through WMI by Windows Script
|
|
4
|
+
Host: cscript //Nologo windows-process-chain.wsf <pid> <hops>. Prints a JSON
|
|
5
|
+
array of { pid, ppid, name, created, cmd }, created in WMI's own date form.
|
|
6
|
+
JScript here predates JSON, so strings are escaped by hand, and everything
|
|
7
|
+
outside printable ASCII as \uXXXX: the output never depends on a code page.
|
|
8
|
+
-->
|
|
9
|
+
<job>
|
|
10
|
+
<script language="JScript"><![CDATA[
|
|
11
|
+
var next = parseInt(WScript.Arguments(0), 10);
|
|
12
|
+
var hops = parseInt(WScript.Arguments(1), 10);
|
|
13
|
+
var wmi = GetObject("winmgmts:{impersonationLevel=impersonate}!\\\\.\\root\\cimv2");
|
|
14
|
+
|
|
15
|
+
function text(value) {
|
|
16
|
+
if (value === null || typeof value === "undefined") return "null";
|
|
17
|
+
var source = String(value);
|
|
18
|
+
var out = "\"";
|
|
19
|
+
for (var index = 0; index < source.length; index++) {
|
|
20
|
+
var code = source.charCodeAt(index);
|
|
21
|
+
if (code === 34) out += "\\\"";
|
|
22
|
+
else if (code === 92) out += "\\\\";
|
|
23
|
+
else if (code < 32 || code > 126) {
|
|
24
|
+
var hex = code.toString(16);
|
|
25
|
+
out += "\\u" + "0000".substr(hex.length) + hex;
|
|
26
|
+
} else out += source.charAt(index);
|
|
27
|
+
}
|
|
28
|
+
return out + "\"";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var chain = [];
|
|
32
|
+
for (var hop = 0; hop < hops && next > 0; hop++) {
|
|
33
|
+
var found = new Enumerator(wmi.ExecQuery("SELECT ProcessId,ParentProcessId,Name,CommandLine,"
|
|
34
|
+
+ "CreationDate FROM Win32_Process WHERE ProcessId=" + next));
|
|
35
|
+
if (found.atEnd()) break;
|
|
36
|
+
var process = found.item();
|
|
37
|
+
chain.push("{\"pid\":" + process.ProcessId + ",\"ppid\":" + process.ParentProcessId
|
|
38
|
+
+ ",\"name\":" + text(process.Name) + ",\"created\":" + text(process.CreationDate)
|
|
39
|
+
+ ",\"cmd\":" + text(process.CommandLine) + "}");
|
|
40
|
+
if (process.ParentProcessId === 0 || process.ParentProcessId === next) break;
|
|
41
|
+
next = process.ParentProcessId;
|
|
42
|
+
}
|
|
43
|
+
WScript.StdOut.Write("[" + chain.join(",") + "]");
|
|
44
|
+
]]></script>
|
|
45
|
+
</job>
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agents-can-communicate/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.mjs",
|
|
8
|
+
"./hook-support": "./src/hook-support.mjs",
|
|
8
9
|
"./managed-entry": "./src/managed-runtime/entry.mjs",
|
|
9
10
|
"./managed-worker": "./src/managed-runtime/worker.mjs"
|
|
10
11
|
},
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
|
|
5
5
|
import { AccError, CONFIG_FILENAME, EXIT, createId, defaultProjectConfig,
|
|
6
6
|
validateProjectConfig } from "@agents-can-communicate/protocol";
|
|
7
|
+
import { openNoFollow } from "@agents-can-communicate/storage-filesystem";
|
|
7
8
|
|
|
8
9
|
const configPathIn = cwd => path.join(cwd, CONFIG_FILENAME);
|
|
9
10
|
|
|
@@ -32,7 +33,7 @@ async function readConfig(cwd) {
|
|
|
32
33
|
try {
|
|
33
34
|
// Same rule as discovery: a config reached through a symlink may point
|
|
34
35
|
// anywhere, so it is refused rather than followed.
|
|
35
|
-
handle = await
|
|
36
|
+
handle = await openNoFollow(file, constants.O_RDONLY);
|
|
36
37
|
} catch (error) {
|
|
37
38
|
if (error.code === "ENOENT") return null;
|
|
38
39
|
throw new AccError(EXIT.DATA, "cannot safely read the workspace config",
|