agents-can-communicate 0.4.3 → 0.5.0
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 -4
- package/bin/acc-maintenance-worker.mjs +6 -0
- package/bin/entrypoints/acc-hook.mjs +58 -5
- package/docs/ARCHITECTURE.md +21 -9
- package/docs/CLI.md +21 -10
- package/docs/CONCEPTS.md +2 -1
- package/docs/CONFIGURATION.md +2 -1
- package/docs/GETTING_STARTED.md +4 -2
- package/docs/HOW_IT_WORKS.md +2 -1
- package/docs/PROTOCOL.md +7 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +16 -7
- package/docs/TROUBLESHOOTING.md +13 -6
- package/docs/UPGRADING.md +121 -22
- package/docs/WHY_ACC.md +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +10 -4
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-host.mjs +120 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance-threads.mjs +49 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/maintenance.mjs +108 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
- 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/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/native-delivery.mjs +63 -16
- package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +16 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +10 -5
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +2 -0
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +5 -3
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +225 -11
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/command.mjs +15 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +36 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/download.mjs +17 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/entry.mjs +54 -14
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/generation.mjs +44 -25
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/install.mjs +46 -29
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/legacy-maintenance.mjs +47 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-state.mjs +57 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +118 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +140 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +80 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/refresh.mjs +6 -7
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/schedule.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/staging.mjs +95 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/state.mjs +8 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/worker.mjs +34 -12
- package/node_modules/@agents-can-communicate/cli/src/native-session-diagnostics.mjs +83 -7
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/package.json +1 -1
- package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +44 -2
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/native-binding.mjs +10 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +55 -6
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/installer/src/apply.mjs +2 -2
- package/node_modules/@agents-can-communicate/installer/src/plan.mjs +10 -3
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-directory.mjs +38 -4
- package/package.json +3 -1
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
import { listSessionBindings, loadNativeAttempt } from "@agents-can-communicate/adapter-sdk";
|
|
2
2
|
import { describeNativeReason } from "@agents-can-communicate/installer";
|
|
3
3
|
|
|
4
|
+
// One re-verification per bound session, well inside a doctor run's budget.
|
|
5
|
+
const VERIFY_TIMEOUT_MS = 750;
|
|
6
|
+
|
|
7
|
+
// A lease is not deliverability.
|
|
8
|
+
//
|
|
9
|
+
// Doctor used to answer "local transport active" from the binding record
|
|
10
|
+
// alone: present, policy on, lease not yet expired. All three can hold while
|
|
11
|
+
// every single send falls back to durable, because none of them says the
|
|
12
|
+
// receiver that binding names can still be reached. Measured exactly that way
|
|
13
|
+
// - `Codex CLI live delivery: available; enabled (actionable); local transport
|
|
14
|
+
// active` on a machine where live delivery had never once worked.
|
|
15
|
+
//
|
|
16
|
+
// So ask the adapter the question the router asks: its own bounded
|
|
17
|
+
// re-verification of this exact binding, which resolves the opaque reference
|
|
18
|
+
// to the receiver record, checks the socket, and re-reads the version,
|
|
19
|
+
// protocol, thread and workspace from the process that would actually serve
|
|
20
|
+
// the push. Read-only - it writes no record and renews no lease - and it
|
|
21
|
+
// answers in the closed reason-code vocabulary doctor already speaks. An
|
|
22
|
+
// adapter with no such method gets no verdict and keeps the lease answer.
|
|
23
|
+
async function verifyDelivery(adapter, { binding, runtimeDir }) {
|
|
24
|
+
if (typeof adapter?.refreshNativeSession !== "function") return null;
|
|
25
|
+
try {
|
|
26
|
+
const handshake = await adapter.refreshNativeSession({ binding, runtimeDir,
|
|
27
|
+
timeoutMs: VERIFY_TIMEOUT_MS });
|
|
28
|
+
return handshake?.supported === true
|
|
29
|
+
? { deliverable: true, reasonCode: null, clientVersion: handshake.clientVersion ?? null }
|
|
30
|
+
: { deliverable: false, reasonCode: handshake?.reasonCode ?? "handshake_failed",
|
|
31
|
+
clientVersion: null };
|
|
32
|
+
} catch {
|
|
33
|
+
return { deliverable: false, reasonCode: "handshake_failed", clientVersion: null };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
4
37
|
// Session diagnostics are read only here, never projected into agent context.
|
|
5
38
|
// Verify the owner generation against core before attributing any last attempt.
|
|
6
|
-
export async function updateNativeSessions(adapters, { service, status, root, now
|
|
39
|
+
export async function updateNativeSessions(adapters, { service, status, root, now,
|
|
40
|
+
registry = [] }) {
|
|
7
41
|
const owners = await listSessionBindings({ runtimeDir: root }).catch(() => []);
|
|
42
|
+
const byAdapterId = new Map((registry ?? []).map(adapter => [adapter.id, adapter]));
|
|
8
43
|
const transports = new Map();
|
|
9
44
|
for (const entry of adapters) {
|
|
10
45
|
const native = entry.nativeDelivery;
|
|
@@ -23,13 +58,28 @@ export async function updateNativeSessions(adapters, { service, status, root, no
|
|
|
23
58
|
const binding = transports.get(peer.participantId).find(b => b.sessionId === peer.sessionId
|
|
24
59
|
&& b.generation === current.generation && b.adapterId === entry.adapterId
|
|
25
60
|
&& b.availableModes.includes("livePush"));
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
61
|
+
const off = native.policySource === "installation-record" && !native.configured;
|
|
62
|
+
const leased = binding !== undefined && Date.parse(binding.leaseUntil) > Date.parse(now);
|
|
63
|
+
// Only where the old rule would have said "active": everywhere else the
|
|
64
|
+
// answer is already degraded or unbound, and a live check would buy
|
|
65
|
+
// nothing but a socket round trip.
|
|
66
|
+
const delivery = off || !leased ? null
|
|
67
|
+
: await verifyDelivery(byAdapterId.get(entry.adapterId), { binding, runtimeDir: root });
|
|
68
|
+
const runtime = off ? "inactive"
|
|
69
|
+
: binding === undefined ? "unbound"
|
|
70
|
+
: leased && (delivery === null || delivery.deliverable) ? "active" : "degraded";
|
|
29
71
|
native.sessions.push({ sessionId: peer.sessionId, participantId: peer.participantId,
|
|
30
|
-
presence: peer.presence, runtime,
|
|
72
|
+
presence: peer.presence, runtime, clientVersion: binding?.clientVersion ?? null, delivery,
|
|
31
73
|
lastAttempt: owner === null ? null : await loadNativeAttempt({ runtimeDir: root, ...owner }) });
|
|
32
74
|
}
|
|
75
|
+
// The adapter's own line is the one a reader sees first, and it was the
|
|
76
|
+
// one claiming health while nothing could be delivered. If this adapter
|
|
77
|
+
// has bound sessions in this workspace and not one of them can take a
|
|
78
|
+
// push, this is not an active transport.
|
|
79
|
+
if (native.runtime === "active" && native.sessions.length > 0
|
|
80
|
+
&& !native.sessions.some(session => session.runtime === "active")) {
|
|
81
|
+
native.runtime = "degraded";
|
|
82
|
+
}
|
|
33
83
|
}
|
|
34
84
|
}
|
|
35
85
|
|
|
@@ -37,8 +87,15 @@ const failures = {
|
|
|
37
87
|
client_process_unknown: "the client process could not be identified; start a new client session",
|
|
38
88
|
handshake_failed: "the session handshake failed; check the client's integration/channel setup",
|
|
39
89
|
handshake_timeout: "the session handshake timed out; check the local channel and retry on the next turn",
|
|
40
|
-
|
|
90
|
+
// Since the contract gate shipped, this reason means the version now serving
|
|
91
|
+
// is below the captured minimum - not that two reported versions differ.
|
|
92
|
+
// A daemon serving an older build than its CLI is ordinary and admitted; the
|
|
93
|
+
// fix for this one is to restart the service onto the build the CLI has.
|
|
94
|
+
handshake_version_mismatch: "the version now serving is below the captured native delivery "
|
|
95
|
+
+ "minimum; restart the client's local delivery service",
|
|
41
96
|
session_generation_stale: "the session was replaced during the handshake",
|
|
97
|
+
workspace_identity_unavailable: "the bound client session is no longer in this workspace; "
|
|
98
|
+
+ "start a new client session here",
|
|
42
99
|
};
|
|
43
100
|
|
|
44
101
|
function describeAttempt(attempt) {
|
|
@@ -56,9 +113,28 @@ function describeAttempt(attempt) {
|
|
|
56
113
|
return `last attempt ${attempt.at}: ${detail}`;
|
|
57
114
|
}
|
|
58
115
|
|
|
116
|
+
// The two records this joins are the delivery binding core published and the
|
|
117
|
+
// receiver the adapter privately holds. Naming a difference between them is
|
|
118
|
+
// the point: one that still delivers is information about a service that
|
|
119
|
+
// updated under its binding, and one that does not is the whole reason a send
|
|
120
|
+
// falls back, said where a reader can act on it.
|
|
121
|
+
function describeDelivery(session) {
|
|
122
|
+
const delivery = session.delivery;
|
|
123
|
+
if (delivery === null || delivery === undefined) return null;
|
|
124
|
+
if (!delivery.deliverable) {
|
|
125
|
+
return "no live delivery: "
|
|
126
|
+
+ (failures[delivery.reasonCode] ?? describeNativeReason(delivery.reasonCode));
|
|
127
|
+
}
|
|
128
|
+
return delivery.clientVersion !== null && delivery.clientVersion !== session.clientVersion
|
|
129
|
+
? `receiver verified, now serving ${delivery.clientVersion} `
|
|
130
|
+
+ `where this binding recorded ${session.clientVersion}`
|
|
131
|
+
: "receiver verified";
|
|
132
|
+
}
|
|
133
|
+
|
|
59
134
|
export function nativeSessionLines(adapters) {
|
|
60
135
|
return adapters.flatMap(entry => (entry.nativeDelivery.sessions ?? []).map(session =>
|
|
61
136
|
` ${entry.displayName} session ${session.participantId} (${session.sessionId}): `
|
|
62
137
|
+ `${session.runtime === "active" ? "local transport active" : session.runtime}; `
|
|
63
|
-
+ describeAttempt(session.lastAttempt)
|
|
138
|
+
+ [describeDelivery(session), describeAttempt(session.lastAttempt)]
|
|
139
|
+
.filter(part => part !== null).join("; ")));
|
|
64
140
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { evaluateVersionContract } from "@agents-can-communicate/adapter-sdk";
|
|
2
|
+
|
|
1
3
|
import { refreshExpiredBinding } from "./refresh-binding.mjs";
|
|
2
4
|
|
|
3
5
|
const SAFE_ERRORS = new Set(["ambiguous_recipient_sessions", "delivery_disabled",
|
|
@@ -19,6 +21,30 @@ const LIVE_POLICIES = new Set(["off", "actionable", "all"]);
|
|
|
19
21
|
const permits = (policy, kind) => policy === "all"
|
|
20
22
|
|| (policy === "actionable" && ACTIONABLE.has(kind));
|
|
21
23
|
|
|
24
|
+
// A native-delivery contract is captured per platform, and the only platform a
|
|
25
|
+
// router can judge an offer against is the one it is running on - which is why
|
|
26
|
+
// both entrypoints pass exactly this value. Leaving the parameter optional made
|
|
27
|
+
// its absence silently disable every live offer: `undefined` reached the
|
|
28
|
+
// contract check as "platform_not_captured" and the offer was refused as an
|
|
29
|
+
// unsupported client version. Default it here instead, so a router that is
|
|
30
|
+
// handed nothing behaves as the host it runs on. Injection stays available for
|
|
31
|
+
// callers that need to judge against another platform.
|
|
32
|
+
const HOST_PLATFORM = `${process.platform}-${process.arch}`;
|
|
33
|
+
|
|
34
|
+
// evaluateVersionContract answers two different questions with one field.
|
|
35
|
+
// "below_minimum_version", "known_bad_version", "version_unavailable" and
|
|
36
|
+
// "prerelease_not_captured" are a capture reading the reported version and
|
|
37
|
+
// refusing it. "platform_not_captured" is the opposite: a complete, valid
|
|
38
|
+
// declaration that records nothing for the platform this router runs on. That
|
|
39
|
+
// is not a malformed adapter, it is the ordinary shape of every shipped
|
|
40
|
+
// adapter away from darwin-arm64 - the only platform any of them has captured
|
|
41
|
+
// - so reading it as a refusal disables live delivery on Linux entirely.
|
|
42
|
+
//
|
|
43
|
+
// "native_delivery_unsupported" is absent deliberately: liveCapable already
|
|
44
|
+
// required a nativeDelivery declaration before an offer was attempted, so the
|
|
45
|
+
// contract check is never handed an adapter without one.
|
|
46
|
+
const UNCAPTURED_PLATFORM = "platform_not_captured";
|
|
47
|
+
|
|
22
48
|
// Compatibility was decided twice already - at bootstrap by the probe and at
|
|
23
49
|
// SessionStart by the generation-bound handshake that published this binding.
|
|
24
50
|
// The router validates binding identity and the adapter's answer; it does not
|
|
@@ -42,7 +68,8 @@ function safeTransport(value, opaqueEndpointRef) {
|
|
|
42
68
|
return opaqueEndpointRef === "live-adapter" ? "native-live" : "live-adapter";
|
|
43
69
|
}
|
|
44
70
|
|
|
45
|
-
export function createDeliveryRouter({ service, adapters, clock, platform
|
|
71
|
+
export function createDeliveryRouter({ service, adapters, clock, platform = HOST_PLATFORM,
|
|
72
|
+
readLivePolicy }) {
|
|
46
73
|
const registry = adaptersById(adapters);
|
|
47
74
|
|
|
48
75
|
async function policyFor(adapter, binding) {
|
|
@@ -167,7 +194,22 @@ export function createDeliveryRouter({ service, adapters, clock, platform, readL
|
|
|
167
194
|
await recordFailure(binding, message, participantId, transport, code);
|
|
168
195
|
return durable(participantId, code);
|
|
169
196
|
}
|
|
170
|
-
|
|
197
|
+
// Compatibility was already decided at bind time; a serving version that
|
|
198
|
+
// still satisfies the adapter's captured contract keeps offering, even
|
|
199
|
+
// when it differs from the value recorded when the binding was created.
|
|
200
|
+
// Only a version below the captured minimum or on the denylist refuses.
|
|
201
|
+
// When the contract captured nothing for this platform there is no
|
|
202
|
+
// minimum to judge against, so the offer keeps the rule this path had
|
|
203
|
+
// before the contract gate existed: the version that answered must be the
|
|
204
|
+
// one the binding recorded. defineAdapter rejecting a partial declaration
|
|
205
|
+
// does not make this unreachable - a complete, valid contract that names
|
|
206
|
+
// only darwin-arm64 says nothing about linux-x64, and that is every
|
|
207
|
+
// shipped adapter on Linux.
|
|
208
|
+
const versionRule = evaluateVersionContract(adapter, { clientVersion: response.clientVersion, platform });
|
|
209
|
+
const admitted = versionRule.reasonCode === null
|
|
210
|
+
|| (versionRule.reasonCode === UNCAPTURED_PLATFORM
|
|
211
|
+
&& response.clientVersion === binding.clientVersion);
|
|
212
|
+
if (!admitted) {
|
|
171
213
|
await recordFailure(binding, message, participantId, transport,
|
|
172
214
|
"unsupported_client_version");
|
|
173
215
|
return durable(participantId, "unsupported_client_version");
|
|
@@ -75,8 +75,17 @@ export async function establishNativeBinding({ adapter, event, hookBinding, clie
|
|
|
75
75
|
if (!(lease > now)) return outcome("degraded", "handshake_failed");
|
|
76
76
|
const ceiling = now + 2 * heartbeatCadenceMs;
|
|
77
77
|
const leaseUntil = lease > ceiling ? new Date(ceiling).toISOString() : verdict.leaseUntil;
|
|
78
|
+
// The binding's clientVersion is the version this binding's live pushes
|
|
79
|
+
// will be served by, as the handshake reported it and the contract
|
|
80
|
+
// admitted it - not the version `--version` printed. The two differ
|
|
81
|
+
// whenever a client's background service has updated under its CLI, and
|
|
82
|
+
// that is the case native delivery exists to survive. Publishing the
|
|
83
|
+
// detected CLI version instead made the binding disagree with the
|
|
84
|
+
// adapter's own endpoint record, which since 0.5.0 carries the serving
|
|
85
|
+
// version, and every send fell back to durable. The detected CLI version
|
|
86
|
+
// is not lost: the session-owner record beside this one still carries it.
|
|
78
87
|
await service.publishDeliveryBinding({
|
|
79
|
-
sessionId, generation, adapterId: adapter.id, clientVersion,
|
|
88
|
+
sessionId, generation, adapterId: adapter.id, clientVersion: verdict.clientVersion,
|
|
80
89
|
availableModes: [...verdict.modes], livePolicy: policy,
|
|
81
90
|
opaqueEndpointRef: verdict.opaqueEndpointRef, leaseUntil,
|
|
82
91
|
});
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { retireNativeBinding } from "./native-retirement.mjs";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { readFile, realpath } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
|
|
6
7
|
import { clearNativeAttempt, clearSessionBinding, effectiveCapabilities, loadSessionBinding, storeSessionBinding }
|
|
7
8
|
from "@agents-can-communicate/adapter-sdk";
|
|
8
9
|
import { createCoordinationService } from "@agents-can-communicate/core";
|
|
9
10
|
import { AccError, createId } from "@agents-can-communicate/protocol";
|
|
10
11
|
import { openFilesystemStore } from "@agents-can-communicate/storage-filesystem";
|
|
11
|
-
import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
|
|
12
|
+
import { clearPin, createGitProbe, discoverWorkspace, platformDataHome, runtimePaths, writePin }
|
|
12
13
|
from "@agents-can-communicate/cli";
|
|
13
14
|
|
|
14
15
|
import { resolveClientPid } from "./client-pid.mjs";
|
|
@@ -178,6 +179,38 @@ export function participantFor(adapterId, harnessSessionId, env = {}) {
|
|
|
178
179
|
return `${adapterId}-${suffix}`;
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
// The runtime generation this module ships as part of, found by walking up
|
|
183
|
+
// from its own file for the nearest ancestor manifest that declares a store
|
|
184
|
+
// contract. A workspace package's own manifest (this one's included) carries a
|
|
185
|
+
// `version` but never `accStoreVersion` - only the generation's root manifest
|
|
186
|
+
// does - so this cannot stop at the first `package.json` the way a version
|
|
187
|
+
// lookup could. Answers null facts rather than throwing: a binding published
|
|
188
|
+
// without a readable contract is exactly today's unknown-hold behaviour.
|
|
189
|
+
//
|
|
190
|
+
// `version` rides along from the same manifest read, for a pin (below) to name
|
|
191
|
+
// the generation it names by, distinct from `storeVersion`'s contract number.
|
|
192
|
+
async function runtimeFacts(fromUrl) {
|
|
193
|
+
let directory = path.dirname(fileURLToPath(fromUrl));
|
|
194
|
+
for (;;) {
|
|
195
|
+
const manifest = await readFile(path.join(directory, "package.json"), "utf8")
|
|
196
|
+
.then(JSON.parse).catch(() => null);
|
|
197
|
+
if (Number.isSafeInteger(manifest?.accStoreVersion)) {
|
|
198
|
+
return { storeVersion: manifest.accStoreVersion, runtimeRoot: directory,
|
|
199
|
+
version: typeof manifest.version === "string" ? manifest.version : null };
|
|
200
|
+
}
|
|
201
|
+
const parent = path.dirname(directory);
|
|
202
|
+
if (parent === directory) return { storeVersion: null, runtimeRoot: null, version: null };
|
|
203
|
+
directory = parent;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Where a pin lives: the managed runtime's own root, sibling of `generations`
|
|
208
|
+
// and `leases`, never this workspace's store. The hook entry point that will
|
|
209
|
+
// read a pin (a later task) has not resolved a workspace yet when it needs to
|
|
210
|
+
// - it only has the data home - so a pin keyed under `paths.root` would be
|
|
211
|
+
// unreachable from there.
|
|
212
|
+
const managerRootFor = dataHome => path.join(dataHome, "acc", "runtime");
|
|
213
|
+
|
|
181
214
|
async function openContext({ cwd, dataHome, runtime, env, deadline }) {
|
|
182
215
|
assertHookBudget(deadline);
|
|
183
216
|
const descriptor = await discoverWorkspace({ cwd, env: env ?? {},
|
|
@@ -307,12 +340,18 @@ async function projectTurn({ binding, context, adapter, adapterId }) {
|
|
|
307
340
|
const HANDLERS = {
|
|
308
341
|
async sessionStart({ event, context, adapter, adapterId, binding, paths,
|
|
309
342
|
readProcessTable, probeClientVersion, platform, deadline }) {
|
|
343
|
+
// Where this session's pin lives, resolved once and reused at every write
|
|
344
|
+
// below. Beside `paths.root`, never inside it: see `managerRootFor`.
|
|
345
|
+
const pinRoot = managerRootFor(context.dataHome);
|
|
310
346
|
// A repeated start refreshes the client's version/platform. Remove the old
|
|
311
347
|
// certified facts before any probe, PID lookup, resume, or open can fail;
|
|
312
348
|
// keep only the generation identity needed for a successful resume.
|
|
313
349
|
if (binding !== null) {
|
|
350
|
+
const facts = await runtimeFacts(import.meta.url);
|
|
314
351
|
await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
|
|
315
|
-
accSessionId: binding.accSessionId, generation: binding.generation });
|
|
352
|
+
accSessionId: binding.accSessionId, generation: binding.generation, ...facts });
|
|
353
|
+
await writePin({ root: pinRoot, harnessSessionId: event.sessionId, runtimeRoot: facts.runtimeRoot,
|
|
354
|
+
version: facts.version, storeVersion: facts.storeVersion });
|
|
316
355
|
}
|
|
317
356
|
const clientVersion = await probeClientVersion(adapter,
|
|
318
357
|
{ timeoutMs: Math.max(1, Math.min(1_000, deadline - Date.now())) });
|
|
@@ -357,8 +396,11 @@ const HANDLERS = {
|
|
|
357
396
|
if (resumed !== null) {
|
|
358
397
|
const hookBinding = { accSessionId: resumed.sessionId, generation: resumed.generation,
|
|
359
398
|
...clientFacts, clientPid };
|
|
399
|
+
const facts = await runtimeFacts(import.meta.url);
|
|
360
400
|
await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
|
|
361
|
-
...hookBinding });
|
|
401
|
+
...hookBinding, ...facts });
|
|
402
|
+
await writePin({ root: pinRoot, harnessSessionId: event.sessionId, runtimeRoot: facts.runtimeRoot,
|
|
403
|
+
version: facts.version, storeVersion: facts.storeVersion, clientPid });
|
|
362
404
|
return { accSessionId: resumed.sessionId, generation: resumed.generation,
|
|
363
405
|
...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
|
|
364
406
|
}
|
|
@@ -368,8 +410,11 @@ const HANDLERS = {
|
|
|
368
410
|
// happened (or it was closed), a retry allocates a NEW pair, never revives it.
|
|
369
411
|
const opening = { sessionId: context.service.ids.next("session"),
|
|
370
412
|
generation: context.service.ids.next("generation") };
|
|
413
|
+
const openingFacts = await runtimeFacts(import.meta.url);
|
|
371
414
|
await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
|
|
372
|
-
accSessionId: opening.sessionId, generation: opening.generation });
|
|
415
|
+
accSessionId: opening.sessionId, generation: opening.generation, ...openingFacts });
|
|
416
|
+
await writePin({ root: pinRoot, harnessSessionId: event.sessionId, runtimeRoot: openingFacts.runtimeRoot,
|
|
417
|
+
version: openingFacts.version, storeVersion: openingFacts.storeVersion, clientPid });
|
|
373
418
|
const session = await context.service.openSession({
|
|
374
419
|
...opening,
|
|
375
420
|
workspaceId: context.descriptor.id,
|
|
@@ -387,8 +432,11 @@ const HANDLERS = {
|
|
|
387
432
|
});
|
|
388
433
|
const hookBinding = { accSessionId: session.sessionId, generation: session.generation,
|
|
389
434
|
...clientFacts, clientPid };
|
|
435
|
+
const openedFacts = await runtimeFacts(import.meta.url);
|
|
390
436
|
await storeSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId,
|
|
391
|
-
...hookBinding });
|
|
437
|
+
...hookBinding, ...openedFacts });
|
|
438
|
+
await writePin({ root: pinRoot, harnessSessionId: event.sessionId, runtimeRoot: openedFacts.runtimeRoot,
|
|
439
|
+
version: openedFacts.version, storeVersion: openedFacts.storeVersion, clientPid });
|
|
392
440
|
return { accSessionId: session.sessionId, generation: session.generation,
|
|
393
441
|
...clientFacts, capabilities, nativeBinding: await native(hookBinding) };
|
|
394
442
|
},
|
|
@@ -415,6 +463,7 @@ const HANDLERS = {
|
|
|
415
463
|
await context.service.clearDeliveryBinding(owner);
|
|
416
464
|
}
|
|
417
465
|
await clearSessionBinding({ runtimeDir: paths.root, deadlineAt: deadline, harnessSessionId: event.sessionId });
|
|
466
|
+
await clearPin({ root: managerRootFor(context.dataHome), harnessSessionId: event.sessionId });
|
|
418
467
|
const diagnosticDeadline = nativeDiagnosticDeadline(deadline);
|
|
419
468
|
if (diagnosticDeadline !== null) {
|
|
420
469
|
await clearNativeAttempt({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
|
|
@@ -37,7 +37,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
37
37
|
const installContext = { ...context,
|
|
38
38
|
clientVersion: operation.clientVersion, platform: operation.platform,
|
|
39
39
|
requestedLivePolicy: operation.livePolicy ?? "off",
|
|
40
|
-
livePolicy: operation.effectiveLivePolicy ?? "off" };
|
|
40
|
+
livePolicy: operation.configuredLivePolicy ?? operation.effectiveLivePolicy ?? "off" };
|
|
41
41
|
const outcome = await adapter.install(installContext);
|
|
42
42
|
// A consented activation is applied after the adapter's own wiring, in
|
|
43
43
|
// a fixed order; an explicit off takes a recorded one back first so the
|
|
@@ -50,7 +50,7 @@ export async function applyPlan({ plan, adapters, context, dataHome, dryRun = fa
|
|
|
50
50
|
notes.push(...describeTeardown(report));
|
|
51
51
|
retainedMechanisms = report.retainedMechanisms;
|
|
52
52
|
}
|
|
53
|
-
let native = null;
|
|
53
|
+
let native = operation.retainedNativeActivation ?? null;
|
|
54
54
|
let appendedRcBlock = false;
|
|
55
55
|
if (operation.nativeActivation !== undefined) {
|
|
56
56
|
const applied = await applyNativeActivation({ adapter,
|
|
@@ -108,6 +108,10 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
108
108
|
const liveDeliverySupported = native?.state === "eligible"
|
|
109
109
|
&& native.activationPlan?.eligible === true;
|
|
110
110
|
const effectiveLivePolicy = liveDeliverySupported ? delivery : "off";
|
|
111
|
+
const previous = recordedById.get(entry.adapterId)?.nativeActivation ?? null;
|
|
112
|
+
const retainedActivation = action === "install" && delivery !== "off"
|
|
113
|
+
&& !liveDeliverySupported && previous?.livePolicy === delivery ? previous : null;
|
|
114
|
+
const configuredLivePolicy = retainedActivation ? delivery : effectiveLivePolicy;
|
|
111
115
|
const deliveryDiagnostic = action === "install" && delivery !== "off"
|
|
112
116
|
&& !liveDeliverySupported
|
|
113
117
|
? entry.deliveryDiagnostic ?? adapter.deliveryFallback?.diagnostic
|
|
@@ -117,20 +121,21 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
117
121
|
const deliverySummary = action === "install"
|
|
118
122
|
? describeInstallDelivery(entry, delivery, effectiveLivePolicy) : null;
|
|
119
123
|
const installContext = { ...context, requestedLivePolicy: delivery,
|
|
120
|
-
livePolicy:
|
|
124
|
+
livePolicy: configuredLivePolicy, clientVersion: entry.version, platform: entry.platform };
|
|
121
125
|
const setupNotes = action === "install" && delivery !== "off"
|
|
122
126
|
? [entry.outgoingDelivery?.setup, entry.nativeSetup].filter(note => typeof note === "string") : [];
|
|
123
127
|
// A consented activation that this run keeps, activates, or takes back.
|
|
124
128
|
// Only an explicit off or an uninstall removes one; an absent record never
|
|
125
129
|
// creates one.
|
|
126
|
-
const previous = recordedById.get(entry.adapterId)?.nativeActivation ?? null;
|
|
127
130
|
const nativeActivation = action === "install" && effectiveLivePolicy !== "off"
|
|
128
131
|
? { livePolicy: effectiveLivePolicy, protocolContract: native.eligibility.protocolContract,
|
|
129
132
|
shell: context?.shell ?? null, rcFile: rcFileFor(context?.home, context?.shell),
|
|
130
133
|
shimDir: typeof context?.stateRoot === "string" ? shimDirFor(context.stateRoot) : null,
|
|
131
134
|
mechanisms: native.activationPlan.mechanisms }
|
|
132
135
|
: null;
|
|
133
|
-
|
|
136
|
+
// A failed readiness probe is not revocation. Keep existing guarded launch
|
|
137
|
+
// setup without applying service commands or claiming it is available.
|
|
138
|
+
const retirements = retainedActivation ? [] : planActivationRetirements({ previous, desired: nativeActivation });
|
|
134
139
|
const deactivation = retirements.length > 0 ? { ...previous, mechanisms: retirements } : null;
|
|
135
140
|
const artifacts = (record?.artifacts ?? adapter.planInstall(installContext))
|
|
136
141
|
.map(artifact => ({ path: artifact.path, kind: artifact.kind ?? "file" }))
|
|
@@ -149,6 +154,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
149
154
|
alreadyInstalled: entry.installed === true,
|
|
150
155
|
livePolicy: delivery,
|
|
151
156
|
effectiveLivePolicy,
|
|
157
|
+
...(retainedActivation ? { configuredLivePolicy, retainedNativeActivation: retainedActivation } : {}),
|
|
152
158
|
...(deliveryDiagnostic === null ? {} : { deliveryDiagnostic }),
|
|
153
159
|
...(deliverySummary === null ? {} : { deliverySummary }),
|
|
154
160
|
...(setupNotes.length ? { setupNotes } : {}),
|
|
@@ -161,6 +167,7 @@ export function planInstallation({ adapters, detected, context, action = "instal
|
|
|
161
167
|
...(entry.present ? [] : [`${adapter.displayName ?? adapter.id} is no longer on `
|
|
162
168
|
+ "this machine; removing what ACC recorded writing"]),
|
|
163
169
|
...(deliverySummary === null ? [] : [deliverySummary]),
|
|
170
|
+
...(retainedActivation ? ["keep existing native delivery setup; current readiness is unverified"] : []),
|
|
164
171
|
...setupNotes,
|
|
165
172
|
...artifacts.filter(a => a.kind === "tree")
|
|
166
173
|
.map(a => `${action === "install" ? "create" : "remove"} ${a.path}`),
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
1
|
+
import { lstat, mkdir, realpath, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
5
5
|
|
|
6
6
|
// Ported from the reconciled prototype without semantic change. Every managed
|
|
7
7
|
// path is validated segment by segment against the canonical root, so a
|
|
8
|
-
// symlinked ancestor cannot redirect a read or a publication
|
|
8
|
+
// symlinked ancestor cannot redirect a read or a publication - neither outside
|
|
9
|
+
// the store, nor to another directory inside it.
|
|
9
10
|
function invalidDirectory(message, directory, root, cause) {
|
|
10
11
|
return new AccError(EXIT.DATA, message, {
|
|
11
12
|
directory,
|
|
@@ -60,17 +61,50 @@ async function inspectManagedDirectory(rootPath, directoryPath, create) {
|
|
|
60
61
|
let details = await inspectRealDirectory(root, root, create);
|
|
61
62
|
const canonicalRoot = await realpath(root);
|
|
62
63
|
let current = root;
|
|
64
|
+
// The canonical location of the directory validated so far. Every segment
|
|
65
|
+
// has to be a child of it, which carries containment in the canonical root
|
|
66
|
+
// down the whole walk by induction.
|
|
67
|
+
let canonicalParent = canonicalRoot;
|
|
63
68
|
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
64
69
|
current = path.join(current, segment);
|
|
65
70
|
details = await inspectRealDirectory(current, root, create);
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
// realpath answers with the name the directory carries *now*, which is not
|
|
72
|
+
// always the name that was asked for: on Darwin it resolves by opening the
|
|
73
|
+
// path and asking the kernel for that vnode's current path, so a directory
|
|
74
|
+
// renamed between those two steps comes back under its new name. Renaming
|
|
75
|
+
// managed directories is ordinary here - the writer lock is granted,
|
|
76
|
+
// reclaimed and released entirely by rename - so demanding the exact name
|
|
77
|
+
// back reported an escape for an in-store move, intermittently and only
|
|
78
|
+
// under load, and took real writes down with it.
|
|
79
|
+
//
|
|
80
|
+
// What a move cannot change is where the directory lives. An ancestor
|
|
81
|
+
// replaced by a symlink after it was checked resolves somewhere else
|
|
82
|
+
// entirely, so the parent is the thing to hold fixed: this segment must be
|
|
83
|
+
// a child of the directory the previous one validated. Moving the leaf is
|
|
84
|
+
// then the only disagreement left, and that one is settled by identity -
|
|
85
|
+
// a moved directory is the same vnode under another name, while a sibling
|
|
86
|
+
// swapped in under this name is not. ENOENT on the re-stat means it moved
|
|
87
|
+
// again between the two calls, which cannot redirect anything: whatever
|
|
88
|
+
// the caller opens through this path next fails the same way.
|
|
89
|
+
const resolved = await realpath(current);
|
|
90
|
+
if (path.dirname(resolved) !== canonicalParent
|
|
91
|
+
|| (path.basename(resolved) !== segment
|
|
92
|
+
&& !await stillTheSameDirectory(details, resolved, current, root))) {
|
|
68
93
|
throw invalidDirectory("managed directory escapes the canonical store root", current, root);
|
|
69
94
|
}
|
|
95
|
+
canonicalParent = resolved;
|
|
70
96
|
}
|
|
71
97
|
return { directory, stat: details };
|
|
72
98
|
}
|
|
73
99
|
|
|
100
|
+
async function stillTheSameDirectory(details, resolved, current, root) {
|
|
101
|
+
const served = await stat(resolved).then(found => found, error => {
|
|
102
|
+
if (error.code === "ENOENT") return null;
|
|
103
|
+
throw invalidDirectory("cannot inspect managed directory", current, root, error.message);
|
|
104
|
+
});
|
|
105
|
+
return served === null || (served.dev === details.dev && served.ino === details.ino);
|
|
106
|
+
}
|
|
107
|
+
|
|
74
108
|
export async function assertManagedDirectory(rootPath, directoryPath) {
|
|
75
109
|
return inspectManagedDirectory(rootPath, directoryPath, false);
|
|
76
110
|
}
|
package/package.json
CHANGED