agents-can-communicate 0.4.4 → 0.5.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 +5 -3
- package/bin/entrypoints/acc-hook.mjs +58 -5
- package/docs/ARCHITECTURE.md +9 -4
- package/docs/CLI.md +12 -8
- package/docs/GETTING_STARTED.md +1 -1
- package/docs/PROTOCOL.md +7 -0
- package/docs/RELEASING.md +1 -1
- package/docs/SECURITY_MODEL.md +4 -2
- package/docs/TROUBLESHOOTING.md +9 -5
- package/docs/UPGRADING.md +86 -28
- 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 +4 -2
- package/node_modules/@agents-can-communicate/adapter-codex/src/app-server-client.mjs +4 -0
- package/node_modules/@agents-can-communicate/adapter-codex/src/native-delivery.mjs +67 -22
- package/node_modules/@agents-can-communicate/adapter-codex/src/permission-ownership.mjs +19 -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/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/doctor-command.mjs +7 -1
- package/node_modules/@agents-can-communicate/cli/src/index.mjs +5 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/activation.mjs +187 -11
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/diagnostics.mjs +2 -1
- 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 +14 -6
- 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/maintenance-state.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance-worker.mjs +9 -1
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/maintenance.mjs +52 -4
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/management-recovery.mjs +18 -9
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/pins.mjs +125 -0
- package/node_modules/@agents-can-communicate/cli/src/managed-runtime/retire.mjs +56 -0
- 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 +18 -8
- 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/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 +2 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readdir, rm } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { confirmedDead, defaultPidIsAlive } from "./mutex.mjs";
|
|
6
|
+
import { canonicalManagerRoot, managedDirectory, readManagedJson, syncDirectory, writeManagedJson } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
const SCHEMA_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
/** Staging renames a fully-formed generation into place, then runs it as a
|
|
11
|
+
* spawned process to verify it, before any control pointer, lease, or pin
|
|
12
|
+
* can reference it - out of the manager lock, since verification can run for
|
|
13
|
+
* several seconds and holding the lock that long would stall every hook on
|
|
14
|
+
* the machine. This hold closes that window: the caller writes it for the
|
|
15
|
+
* generation's final path *before* either the check that may return that
|
|
16
|
+
* path unchanged, or the rename that makes a freshly staged one visible
|
|
17
|
+
* there - so the directory can never be observed without it.
|
|
18
|
+
*
|
|
19
|
+
* Returns the hold's own file path: an opaque handle for attachStagingTemp
|
|
20
|
+
* and releaseStagingHold. The caller owns its lifecycle end to end - this
|
|
21
|
+
* function never releases what it creates. */
|
|
22
|
+
export async function holdStagedGeneration({ root, generationRoot, stagingRoot = null, pid = process.pid }) {
|
|
23
|
+
const directory = path.join(root, "staging");
|
|
24
|
+
await managedDirectory(directory, { create: true });
|
|
25
|
+
const file = path.join(directory, `${randomUUID()}.json`);
|
|
26
|
+
await writeManagedJson(file, { schemaVersion: SCHEMA_VERSION, generationRoot, stagingRoot, pid,
|
|
27
|
+
createdAt: new Date().toISOString() });
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Once mkdtemp names the actual in-flight temp, attach it to the same
|
|
32
|
+
* record a live hold already protects: a dead owner's abandoned temp is
|
|
33
|
+
* only ever found by way of this link, since reclaim never otherwise looks
|
|
34
|
+
* at a dot-prefixed generations entry.
|
|
35
|
+
*
|
|
36
|
+
* If the hold has already vanished (readManagedJson returns undefined on
|
|
37
|
+
* ENOENT), do nothing rather than write `{ stagingRoot }` alone: spreading
|
|
38
|
+
* an `undefined` record silently produces exactly the schemaVersion-less,
|
|
39
|
+
* generationRoot-less, pid-less fragment reclaimGenerations reads as a
|
|
40
|
+
* malformed hold - and reapStagingHolds can never reap it either, since it
|
|
41
|
+
* has no valid pid to confirm dead. Manufacturing that record would be a
|
|
42
|
+
* self-inflicted, permanent block on reclamation, not a corruption that
|
|
43
|
+
* needed an external cause. */
|
|
44
|
+
export async function attachStagingTemp(file, stagingRoot) {
|
|
45
|
+
const record = await readManagedJson(file);
|
|
46
|
+
if (record === undefined) return;
|
|
47
|
+
await writeManagedJson(file, { ...record, stagingRoot });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The caller must call this on every exit from its own staging attempt -
|
|
51
|
+
* success, a failed verify, a timeout, or a thrown error - once the
|
|
52
|
+
* generation is either published (and so independently protected by the
|
|
53
|
+
* control pointer) or abandoned. Confirmed-dead reaping is the backstop for
|
|
54
|
+
* a crash that skips this, never the release mechanism itself. */
|
|
55
|
+
export async function releaseStagingHold(file) {
|
|
56
|
+
if (typeof file === "string" && file) await rm(file, { force: true });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const nonempty = value => typeof value === "string" && value.length > 0;
|
|
60
|
+
|
|
61
|
+
/** A staging process that exits without releasing its hold (crash, kill, a
|
|
62
|
+
* SIGKILL mid-verify) leaves both the hold and, if it had gotten that far,
|
|
63
|
+
* its in-flight temp behind - the only sweeper either one ever gets. Same
|
|
64
|
+
* confirmed-death rule as reapPins. The temp is only ever removed alongside
|
|
65
|
+
* its owning record, and only after it is confirmed to sit directly inside
|
|
66
|
+
* this root's own `generations`, named the way mkdtemp names one: a corrupt
|
|
67
|
+
* or foreign path in the record must never turn a reap into an arbitrary
|
|
68
|
+
* delete. */
|
|
69
|
+
export async function reapStagingHolds({ root, pidIsAlive = defaultPidIsAlive } = {}) {
|
|
70
|
+
root = await canonicalManagerRoot(root);
|
|
71
|
+
const directory = path.join(root, "staging");
|
|
72
|
+
if (!await managedDirectory(directory)) return;
|
|
73
|
+
const generations = path.join(root, "generations");
|
|
74
|
+
let removed = false;
|
|
75
|
+
for (const name of await readdir(directory)) {
|
|
76
|
+
if (!name.endsWith(".json")) continue;
|
|
77
|
+
const file = path.join(directory, name);
|
|
78
|
+
const record = await readManagedJson(file).catch(() => null);
|
|
79
|
+
const pid = Number.isSafeInteger(record?.pid) ? record.pid : null;
|
|
80
|
+
if (pid !== null && await confirmedDead(pid, pidIsAlive)) {
|
|
81
|
+
if (nonempty(record.stagingRoot)) {
|
|
82
|
+
// Resolved the same way every generations entry is (see
|
|
83
|
+
// resolveCandidate in activation.mjs): a symlinked data home must
|
|
84
|
+
// never make a real temp look foreign, or a foreign path look real.
|
|
85
|
+
const resolved = await canonicalManagerRoot(record.stagingRoot).catch(() => null);
|
|
86
|
+
if (resolved && path.dirname(resolved) === generations && path.basename(resolved).startsWith(".staging-")) {
|
|
87
|
+
await rm(resolved, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
await rm(file, { force: true });
|
|
91
|
+
removed = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (removed) await syncDirectory(directory);
|
|
95
|
+
}
|
|
@@ -77,6 +77,13 @@ const nullableString = value => value === null || nonempty(value);
|
|
|
77
77
|
export async function validateRuntime(root, runtime) {
|
|
78
78
|
if (!runtime || !nonempty(runtime.version) || typeof runtime.root !== "string"
|
|
79
79
|
|| !path.isAbsolute(runtime.root)) throw new Error("invalid control generation");
|
|
80
|
+
// Absent means a pointer written before the contract field existed. Present
|
|
81
|
+
// but malformed is a corrupt record, and reading it as unknown would let an
|
|
82
|
+
// incompatible generation past the gate.
|
|
83
|
+
if (runtime.storeVersion !== undefined && runtime.storeVersion !== null
|
|
84
|
+
&& (!Number.isSafeInteger(runtime.storeVersion) || runtime.storeVersion <= 0)) {
|
|
85
|
+
throw new Error("invalid control generation");
|
|
86
|
+
}
|
|
80
87
|
const generations = path.join(root, "generations");
|
|
81
88
|
const resolved = await canonicalManagerRoot(runtime.root);
|
|
82
89
|
const relative = path.relative(generations, resolved);
|
|
@@ -88,7 +95,7 @@ export async function validateRuntime(root, runtime) {
|
|
|
88
95
|
if (await managedDirectory(generations)) {
|
|
89
96
|
if (await realpath(generations) !== generations) throw new Error("invalid control generations directory");
|
|
90
97
|
}
|
|
91
|
-
return { version: runtime.version, root: resolved };
|
|
98
|
+
return { version: runtime.version, root: resolved, storeVersion: runtime.storeVersion ?? null };
|
|
92
99
|
}
|
|
93
100
|
|
|
94
101
|
async function validateControl(root, value) {
|
|
@@ -2,6 +2,7 @@ import { activatePending } from "./activation.mjs";
|
|
|
2
2
|
import { downloadRelease, fetchRelease, newerVersion } from "./download.mjs";
|
|
3
3
|
import { withManagerLock } from "./mutex.mjs";
|
|
4
4
|
import { readControl, writeControl } from "./state.mjs";
|
|
5
|
+
import { releaseStagingHold } from "./staging.mjs";
|
|
5
6
|
import { MAINTENANCE_ACTIVE, maintenanceNotice, maintenanceReport, readMaintenance } from "./maintenance-state.mjs";
|
|
6
7
|
import { requestMaintenance } from "./maintenance.mjs";
|
|
7
8
|
|
|
@@ -40,14 +41,23 @@ export async function performUpdate(root, { force = false, check = false, env =
|
|
|
40
41
|
const checked = { ...data, checked: true, latest: release.version, newer };
|
|
41
42
|
if (check || !newer) return checked;
|
|
42
43
|
const generation = await download(root, release, { env });
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
// generation.hold has protected this candidate since it was staged;
|
|
45
|
+
// release it once this publish attempt resolves either way - published,
|
|
46
|
+
// its own control pointer takes over protecting it, and abandoned
|
|
47
|
+
// (state_changed) it is no longer wanted at all.
|
|
48
|
+
let published;
|
|
49
|
+
try {
|
|
50
|
+
published = await withManagerLock(root, async () => {
|
|
51
|
+
const current = await readControl(root);
|
|
52
|
+
if (current.phase !== "ready" || current.active.root !== initial.active.root
|
|
53
|
+
|| current.pin !== initial.pin || !force && !current.auto) return false;
|
|
54
|
+
await writeControl(root, { ...current, pending: generation,
|
|
55
|
+
notice: `ACC ${generation.version} is downloaded and verified.` });
|
|
56
|
+
return true;
|
|
57
|
+
});
|
|
58
|
+
} finally {
|
|
59
|
+
await releaseStagingHold(generation.hold);
|
|
60
|
+
}
|
|
51
61
|
if (!published) return { ...checked, reason: "state_changed" };
|
|
52
62
|
return { ...checked, pending: generation.version, ...await activate(root, { env, ignorePid }) };
|
|
53
63
|
}
|
|
@@ -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,
|
|
@@ -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