@ouro.bot/friends 0.1.0-alpha.5 → 0.1.0-alpha.7
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 +65 -16
- package/changelog.json +12 -0
- package/dist/a2a-client/index.d.ts +1 -0
- package/dist/a2a-client/index.js +5 -1
- package/dist/a2a-client/roster-verify.d.ts +15 -0
- package/dist/a2a-client/roster-verify.js +61 -0
- package/dist/account-roster.d.ts +52 -0
- package/dist/account-roster.js +108 -0
- package/dist/agent-peer.js +5 -1
- package/dist/audit.d.ts +42 -0
- package/dist/audit.js +86 -0
- package/dist/connect-authority.d.ts +43 -0
- package/dist/connect-authority.js +84 -0
- package/dist/connect.d.ts +55 -0
- package/dist/connect.js +160 -0
- package/dist/coordination.d.ts +17 -1
- package/dist/coordination.js +80 -6
- package/dist/file-bundle.d.ts +5 -0
- package/dist/file-bundle.js +4 -0
- package/dist/friend-lookup.d.ts +9 -0
- package/dist/friend-lookup.js +69 -0
- package/dist/identity.d.ts +17 -0
- package/dist/identity.js +68 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +51 -2
- package/dist/mailbox/index.d.ts +13 -10
- package/dist/mailbox/index.js +1 -1
- package/dist/mcp/dispatch.d.ts +27 -1
- package/dist/mcp/dispatch.js +110 -3
- package/dist/mcp/run-main.js +8 -5
- package/dist/mcp/schemas.js +51 -4
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +2 -2
- package/dist/mission-result.d.ts +82 -0
- package/dist/mission-result.js +200 -0
- package/dist/mission-store-file.js +8 -0
- package/dist/resolver.d.ts +32 -1
- package/dist/resolver.js +50 -3
- package/dist/roster-store-file.d.ts +16 -0
- package/dist/roster-store-file.js +125 -0
- package/dist/roster-store-memory.d.ts +9 -0
- package/dist/roster-store-memory.js +20 -0
- package/dist/roster-store.d.ts +29 -0
- package/dist/roster-store.js +9 -0
- package/dist/roster-verifier.d.ts +23 -0
- package/dist/roster-verifier.js +47 -0
- package/dist/trust-explanation.d.ts +7 -1
- package/dist/trust-explanation.js +52 -34
- package/dist/trust-mutation.d.ts +13 -1
- package/dist/trust-mutation.js +31 -2
- package/dist/types.d.ts +64 -0
- package/package.json +2 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { SenseType } from "./types";
|
|
2
|
+
import type { AccountMembershipResult } from "./account-roster";
|
|
3
|
+
/** Input to the management-sense authority predicate. `senseType` is the sense the
|
|
4
|
+
* `connect_to` arrived through (the MCP boundary supplies `local` for the owner-only
|
|
5
|
+
* stdio path; a network transport passes its real senseType). `membership` is the
|
|
6
|
+
* PRE-COMPUTED account-roster decision for the `closed` branch — the caller runs
|
|
7
|
+
* evaluateAccountMembership and passes its result; absent ⇒ no membership proven. */
|
|
8
|
+
export interface AuthorizeConnectInput {
|
|
9
|
+
senseType: SenseType;
|
|
10
|
+
membership?: AccountMembershipResult;
|
|
11
|
+
}
|
|
12
|
+
/** The authority decision (PINNED discriminated union). `commit` ⇒ the `connect_to`
|
|
13
|
+
* may link inline + audit. `downgrade` ⇒ it must NOT commit; the caller raises a
|
|
14
|
+
* confirm-prompt instead. The `reason` names exactly why the inline commit was
|
|
15
|
+
* withheld, so the prompt copy + the audit/no-audit branch are unambiguous. */
|
|
16
|
+
export type ConnectAuthorization = {
|
|
17
|
+
decision: "commit";
|
|
18
|
+
} | {
|
|
19
|
+
decision: "downgrade";
|
|
20
|
+
reason: "open_sense_needs_confirmation" | "closed_sense_not_member" | "internal_sense_not_management";
|
|
21
|
+
};
|
|
22
|
+
/** Decide whether a `connect_to` may COMMIT inline from the given management sense.
|
|
23
|
+
*
|
|
24
|
+
* `local` is the owner-only management sense (stdio/CLI — the user who launched the
|
|
25
|
+
* process). `closed` is org-gated but NOT inherently the owner, so it commits ONLY
|
|
26
|
+
* when the peer is proven same-account family via the signed roster (the caller's
|
|
27
|
+
* pre-computed `membership`); any other decision — or no membership at all — downgrades
|
|
28
|
+
* (NEVER a blanket allow on `closed`). An `open` sense (anyone can reach it) never
|
|
29
|
+
* commits inline regardless of membership. `internal` is the agent's inner dialog —
|
|
30
|
+
* not a management surface at all. Every non-commit is a structured downgrade RETURN.
|
|
31
|
+
*
|
|
32
|
+
* ┌─ PRE-CONDITION before any non-local / networked `controlContext` is ever wired ──────┐
|
|
33
|
+
* │ (security review inc-2 findings 2-3): this gate authenticates the CALLER's │
|
|
34
|
+
* │ sense/membership but places NO constraint on the TARGET, and connectAgents defaults │
|
|
35
|
+
* │ the introduce trust to `family`. Both are correct + safe ONLY because the path is │
|
|
36
|
+
* │ owner-only stdio today (every wire supplies `senseType: "local"`; no wire constructs │
|
|
37
|
+
* │ a non-`local` controlContext). The `connect` commit MUST add target-side roster │
|
|
38
|
+
* │ verification (the target did must ALSO be roster-checked, not just TOFU-upserted) │
|
|
39
|
+
* │ AND validate the caller-supplied `trustLevel` against the authority decision BEFORE │
|
|
40
|
+
* │ any non-`local`/networked controlContext is wired. The current `family` default + │
|
|
41
|
+
* │ unconstrained target are safe only for the owner-only-stdio path. │
|
|
42
|
+
* └──────────────────────────────────────────────────────────────────────────────────────┘ */
|
|
43
|
+
export declare function authorizeConnect(input: AuthorizeConnectInput): ConnectAuthorization;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.authorizeConnect = authorizeConnect;
|
|
4
|
+
// authorizeConnect — the management-sense authority predicate (brick 8, greenfield).
|
|
5
|
+
//
|
|
6
|
+
// The control-plane gate for `connect_to`: a PURE function that decides whether the
|
|
7
|
+
// owner introducing one of their own agents into the calling agent's fleet may
|
|
8
|
+
// COMMIT inline, or must DOWNGRADE to a confirm-prompt. Brick 8 is genuinely
|
|
9
|
+
// greenfield in friends — there is NO trust-gate.ts / handleStranger to patch (those
|
|
10
|
+
// live in ouroboros); this predicate is the management-sense authority from scratch.
|
|
11
|
+
//
|
|
12
|
+
// CORE-CLEAN by construction: the `closed`-branch membership decision arrives
|
|
13
|
+
// PRE-COMPUTED via the injected seam. The CALLER runs evaluateAccountMembership
|
|
14
|
+
// against the increment-1 roster surface and passes the `AccountMembershipResult` in;
|
|
15
|
+
// this module never calls it, so it imports NO a2a-client / libsodium (the lint
|
|
16
|
+
// enforces the direction). It is a pure value→value map with one observability emit.
|
|
17
|
+
//
|
|
18
|
+
// The contract (every branch — see connect-authority.test.ts):
|
|
19
|
+
// - local → commit (owner-only stdio/CLI — the management sense)
|
|
20
|
+
// - closed + membership family_same_account → commit (org-gated AND a roster-verified same-account peer)
|
|
21
|
+
// - closed + any other / absent membership → downgrade closed_sense_not_member (NEVER a blanket allow)
|
|
22
|
+
// - open (a2a/mail/bluebubbles) → downgrade open_sense_needs_confirmation (regardless of membership)
|
|
23
|
+
// - internal → downgrade internal_sense_not_management
|
|
24
|
+
// A downgrade is ALWAYS a structured RETURN value, never a throw — the caller turns it
|
|
25
|
+
// into a confirm-prompt and writes no audit / makes no link.
|
|
26
|
+
const observability_1 = require("./observability");
|
|
27
|
+
/** Decide whether a `connect_to` may COMMIT inline from the given management sense.
|
|
28
|
+
*
|
|
29
|
+
* `local` is the owner-only management sense (stdio/CLI — the user who launched the
|
|
30
|
+
* process). `closed` is org-gated but NOT inherently the owner, so it commits ONLY
|
|
31
|
+
* when the peer is proven same-account family via the signed roster (the caller's
|
|
32
|
+
* pre-computed `membership`); any other decision — or no membership at all — downgrades
|
|
33
|
+
* (NEVER a blanket allow on `closed`). An `open` sense (anyone can reach it) never
|
|
34
|
+
* commits inline regardless of membership. `internal` is the agent's inner dialog —
|
|
35
|
+
* not a management surface at all. Every non-commit is a structured downgrade RETURN.
|
|
36
|
+
*
|
|
37
|
+
* ┌─ PRE-CONDITION before any non-local / networked `controlContext` is ever wired ──────┐
|
|
38
|
+
* │ (security review inc-2 findings 2-3): this gate authenticates the CALLER's │
|
|
39
|
+
* │ sense/membership but places NO constraint on the TARGET, and connectAgents defaults │
|
|
40
|
+
* │ the introduce trust to `family`. Both are correct + safe ONLY because the path is │
|
|
41
|
+
* │ owner-only stdio today (every wire supplies `senseType: "local"`; no wire constructs │
|
|
42
|
+
* │ a non-`local` controlContext). The `connect` commit MUST add target-side roster │
|
|
43
|
+
* │ verification (the target did must ALSO be roster-checked, not just TOFU-upserted) │
|
|
44
|
+
* │ AND validate the caller-supplied `trustLevel` against the authority decision BEFORE │
|
|
45
|
+
* │ any non-`local`/networked controlContext is wired. The current `family` default + │
|
|
46
|
+
* │ unconstrained target are safe only for the owner-only-stdio path. │
|
|
47
|
+
* └──────────────────────────────────────────────────────────────────────────────────────┘ */
|
|
48
|
+
function authorizeConnect(input) {
|
|
49
|
+
const result = decide(input);
|
|
50
|
+
(0, observability_1.emitNervesEvent)({
|
|
51
|
+
component: "friends",
|
|
52
|
+
event: "friends.connect_authorized",
|
|
53
|
+
message: "evaluated connect_to management-sense authority",
|
|
54
|
+
meta: {
|
|
55
|
+
senseType: input.senseType,
|
|
56
|
+
decision: result.decision,
|
|
57
|
+
...(result.decision === "downgrade" ? { reason: result.reason } : {}),
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
/** The pure decision, factored out so the single observability emit wraps every
|
|
63
|
+
* branch (the one return path keeps the emit DRY and fully covered). */
|
|
64
|
+
function decide(input) {
|
|
65
|
+
switch (input.senseType) {
|
|
66
|
+
case "local":
|
|
67
|
+
// Owner-only management sense — the user who launched the process. Commit.
|
|
68
|
+
return { decision: "commit" };
|
|
69
|
+
case "closed":
|
|
70
|
+
// Org-gated but not inherently the owner: commit ONLY for a roster-verified
|
|
71
|
+
// same-account peer. Anything else (incl. absent membership) downgrades — there
|
|
72
|
+
// is NO blanket allow on `closed`.
|
|
73
|
+
return input.membership?.decision === "family_same_account"
|
|
74
|
+
? { decision: "commit" }
|
|
75
|
+
: { decision: "downgrade", reason: "closed_sense_not_member" };
|
|
76
|
+
case "open":
|
|
77
|
+
// Anyone can reach an open sense — it can NEVER commit a control-plane link
|
|
78
|
+
// inline, regardless of any membership claim.
|
|
79
|
+
return { decision: "downgrade", reason: "open_sense_needs_confirmation" };
|
|
80
|
+
case "internal":
|
|
81
|
+
// The agent's inner dialog — not a management surface.
|
|
82
|
+
return { decision: "downgrade", reason: "internal_sense_not_management" };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { FriendStore } from "./store";
|
|
2
|
+
import type { AuditSink } from "./audit";
|
|
3
|
+
import type { ConnectAuthorization } from "./connect-authority";
|
|
4
|
+
import type { AccountMembershipResult } from "./account-roster";
|
|
5
|
+
import type { FriendRecord, SenseType, TrustLevel } from "./types";
|
|
6
|
+
/** The named peer to link, by any of the three handles the owner might supply. An
|
|
7
|
+
* `agentId` or `did` is a resolvable handle on its own; a bare `name` resolves ONLY by
|
|
8
|
+
* hitting an existing record (§3.3 — never fabricated). */
|
|
9
|
+
export interface ConnectPeer {
|
|
10
|
+
agentId?: string;
|
|
11
|
+
did?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface ConnectAgentsInput {
|
|
15
|
+
peer: ConnectPeer;
|
|
16
|
+
/** The management sense the connect_to arrived through (the MCP boundary supplies
|
|
17
|
+
* `local` for the owner-only stdio path; a network transport its real senseType). */
|
|
18
|
+
senseType: SenseType;
|
|
19
|
+
/** The PRE-COMPUTED account-roster membership for the `closed` branch (the caller
|
|
20
|
+
* runs evaluateAccountMembership). Absent ⇒ no membership proven. */
|
|
21
|
+
membership?: AccountMembershipResult;
|
|
22
|
+
/** The trust to link the peer at. Own-fleet linked agents default to `family`. */
|
|
23
|
+
trustLevel?: TrustLevel;
|
|
24
|
+
}
|
|
25
|
+
export interface ConnectAgentsDeps {
|
|
26
|
+
/** The control-plane audit sink. Absent ⇒ the link is made but no audit is appended
|
|
27
|
+
* (no-sink no-op, mirroring setFriendTrust / the onboard_agent seat). */
|
|
28
|
+
audit?: AuditSink;
|
|
29
|
+
/** WHO performed the connect (e.g. "owner:stdio"). */
|
|
30
|
+
actor: string;
|
|
31
|
+
/** WHENCE — the origin sense string stamped on the audit (e.g. "stdio"). */
|
|
32
|
+
originSense: string;
|
|
33
|
+
}
|
|
34
|
+
export type ConnectStatus = "connected" | "needs_handle_or_introduction" | "downgraded";
|
|
35
|
+
export type ConnectResult = {
|
|
36
|
+
ok: true;
|
|
37
|
+
status: "connected";
|
|
38
|
+
record: FriendRecord;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
status: "needs_handle_or_introduction";
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
status: "downgraded";
|
|
45
|
+
downgrade: ConnectAuthorization;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Link a named peer into the calling agent's store from the owner's vantage. Composes:
|
|
49
|
+
* authority gate (authorizeConnect) → disambiguation (resolvePeer, never fabricates) →
|
|
50
|
+
* the introduce effect (upsertAgentPeer at the linked trust, default family) → the
|
|
51
|
+
* action:"connect" control-plane audit (mirroring the onboard_agent seat). A downgrade
|
|
52
|
+
* authorization makes NO link and writes NO audit (mirrors setFriendTrust's no-audit
|
|
53
|
+
* early return); an unresolvable bare name returns needs_handle_or_introduction.
|
|
54
|
+
*/
|
|
55
|
+
export declare function connectAgents(store: FriendStore, input: ConnectAgentsInput, deps: ConnectAgentsDeps): Promise<ConnectResult>;
|
package/dist/connect.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.connectAgents = connectAgents;
|
|
4
|
+
// connectAgents — the `connect_to` library fn (brick 8, greenfield).
|
|
5
|
+
//
|
|
6
|
+
// The owner's first-class capability to link one of their OWN agents into the calling
|
|
7
|
+
// agent's fleet (introduce a target peer INTO this store). It is the single entry point
|
|
8
|
+
// for "go connect to @peer" (spec §3.2), gated to a management sense (§3.1) and audited
|
|
9
|
+
// (§3.4). Brick 8 is greenfield in friends — there is NO trust-gate.ts to patch (that
|
|
10
|
+
// lives in ouroboros); this composes the new authority predicate (connect-authority.ts)
|
|
11
|
+
// with the increment-1 roster-pre-computed membership + the increment-1 control-plane
|
|
12
|
+
// audit (AuditSink / action:"connect").
|
|
13
|
+
//
|
|
14
|
+
// MENTAL MODEL (resolves "A↔B on separate stores"): A and B live in SEPARATE
|
|
15
|
+
// stores/processes (own-fleet on two machines), so a single connectAgents call operates
|
|
16
|
+
// on ONE store — it upserts the named peer as an agent-peer at the linked trust + audits
|
|
17
|
+
// action:"connect". The bidirectional A↔B link is the owner running the introduction on
|
|
18
|
+
// EACH side (the LOCAL proof drives both). The fn does NOT reach across stores.
|
|
19
|
+
//
|
|
20
|
+
// CORE-CLEAN: the `membership` arrives PRE-COMPUTED via `input` (the caller runs
|
|
21
|
+
// evaluateAccountMembership against the increment-1 roster surface), so this module
|
|
22
|
+
// imports NO a2a-client / libsodium. The lint enforces the direction.
|
|
23
|
+
//
|
|
24
|
+
// DISAMBIGUATION HONESTY (spec §3.3): given a bare name with no resolvable handle/DID
|
|
25
|
+
// and no record hit, connectAgents returns a structured "need a handle or an
|
|
26
|
+
// introduction" result rather than FABRICATING a target. An agentId or a did IS an
|
|
27
|
+
// owner-supplied/resolved handle; a bare name resolves ONLY by matching an existing
|
|
28
|
+
// record (the BUILT FileFriendStore name-fallback scan) — never invented.
|
|
29
|
+
const observability_1 = require("./observability");
|
|
30
|
+
const connect_authority_1 = require("./connect-authority");
|
|
31
|
+
const agent_peer_1 = require("./agent-peer");
|
|
32
|
+
const friend_lookup_1 = require("./friend-lookup");
|
|
33
|
+
const identity_1 = require("./identity");
|
|
34
|
+
/** Read an agent record's join-key agentId — the durable `a2a.agentId`, falling back
|
|
35
|
+
* to the `a2a-agent` externalId. Returns undefined when the record names no agentId
|
|
36
|
+
* (e.g. a human record), so it can never be linked as an agent target. */
|
|
37
|
+
function agentIdOf(record) {
|
|
38
|
+
return record.agentMeta?.a2a?.agentId ?? record.externalIds.find((e) => e.provider === "a2a-agent")?.externalId;
|
|
39
|
+
}
|
|
40
|
+
/** Resolve the peer to a usable handle WITHOUT fabricating one (§3.3):
|
|
41
|
+
* - `agentId` present → it IS the handle; enrich from any existing record by it.
|
|
42
|
+
* - else `did` present → resolve an existing record by did (the did is the handle);
|
|
43
|
+
* a did with no matching record does NOT resolve (needs a handle/introduction).
|
|
44
|
+
* - else `name` present → match an existing record by case-insensitive name (the
|
|
45
|
+
* FileFriendStore name-fallback semantics, made store-agnostic via listAll); a bare
|
|
46
|
+
* name with no hit does NOT resolve.
|
|
47
|
+
* - else (nothing) → does not resolve.
|
|
48
|
+
* Returns null when the peer cannot be resolved to a real handle. */
|
|
49
|
+
async function resolvePeer(store, peer) {
|
|
50
|
+
if (peer.agentId) {
|
|
51
|
+
const existing = await store.findByExternalId("a2a-agent", peer.agentId);
|
|
52
|
+
return { agentId: peer.agentId, existing: existing ?? undefined, name: peer.name ?? existing?.name ?? peer.agentId };
|
|
53
|
+
}
|
|
54
|
+
if (peer.did) {
|
|
55
|
+
const existing = await (0, friend_lookup_1.findFriendByDid)(store, peer.did);
|
|
56
|
+
if (!existing)
|
|
57
|
+
return null;
|
|
58
|
+
const agentId = agentIdOf(existing);
|
|
59
|
+
if (!agentId)
|
|
60
|
+
return null;
|
|
61
|
+
return { agentId, existing, name: peer.name ?? existing.name };
|
|
62
|
+
}
|
|
63
|
+
if (peer.name) {
|
|
64
|
+
const existing = await findByName(store, peer.name);
|
|
65
|
+
if (!existing)
|
|
66
|
+
return null;
|
|
67
|
+
const agentId = agentIdOf(existing);
|
|
68
|
+
if (!agentId)
|
|
69
|
+
return null;
|
|
70
|
+
return { agentId, existing, name: peer.name };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
/** Case-insensitive name match over the store's records (the §3.3 name-fallback scan,
|
|
75
|
+
* made store-agnostic by using listAll). Returns the first match, or null. A store with
|
|
76
|
+
* no listAll yields null (best-effort, never a throw). */
|
|
77
|
+
async function findByName(store, name) {
|
|
78
|
+
if (typeof store.listAll !== "function")
|
|
79
|
+
return null;
|
|
80
|
+
const all = await store.listAll();
|
|
81
|
+
const lower = name.toLowerCase();
|
|
82
|
+
return all.find((f) => f.name.toLowerCase() === lower) ?? null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Link a named peer into the calling agent's store from the owner's vantage. Composes:
|
|
86
|
+
* authority gate (authorizeConnect) → disambiguation (resolvePeer, never fabricates) →
|
|
87
|
+
* the introduce effect (upsertAgentPeer at the linked trust, default family) → the
|
|
88
|
+
* action:"connect" control-plane audit (mirroring the onboard_agent seat). A downgrade
|
|
89
|
+
* authorization makes NO link and writes NO audit (mirrors setFriendTrust's no-audit
|
|
90
|
+
* early return); an unresolvable bare name returns needs_handle_or_introduction.
|
|
91
|
+
*/
|
|
92
|
+
async function connectAgents(store, input, deps) {
|
|
93
|
+
// 1) Authority FIRST. A downgrade never commits inline (no link, no audit).
|
|
94
|
+
const authorization = (0, connect_authority_1.authorizeConnect)({ senseType: input.senseType, membership: input.membership });
|
|
95
|
+
if (authorization.decision === "downgrade") {
|
|
96
|
+
(0, observability_1.emitNervesEvent)({
|
|
97
|
+
component: "friends",
|
|
98
|
+
event: "friends.connect_downgraded",
|
|
99
|
+
message: "connect_to downgraded to confirm-prompt (not committed inline)",
|
|
100
|
+
meta: { senseType: input.senseType, reason: authorization.reason },
|
|
101
|
+
});
|
|
102
|
+
return { ok: false, status: "downgraded", downgrade: authorization };
|
|
103
|
+
}
|
|
104
|
+
// 2) Disambiguation honesty — resolve the peer to a real handle, never fabricate.
|
|
105
|
+
const resolved = await resolvePeer(store, input.peer);
|
|
106
|
+
if (!resolved) {
|
|
107
|
+
(0, observability_1.emitNervesEvent)({
|
|
108
|
+
component: "friends",
|
|
109
|
+
event: "friends.connect_needs_handle",
|
|
110
|
+
message: "connect_to could not resolve the peer to a handle — needs a handle or an introduction",
|
|
111
|
+
meta: {},
|
|
112
|
+
});
|
|
113
|
+
return { ok: false, status: "needs_handle_or_introduction" };
|
|
114
|
+
}
|
|
115
|
+
// 3) The introduce effect — upsert the peer as an agent-peer at the linked trust.
|
|
116
|
+
// Own-fleet linked agents default to family; the level is overridable. Pass any
|
|
117
|
+
// existing a2a coords through so the upsert preserves them (incl. a legacy a2a.did).
|
|
118
|
+
//
|
|
119
|
+
// ┌─ PRE-CONDITION before any non-local / networked `controlContext` is ever wired ──────┐
|
|
120
|
+
// │ (security review inc-2 findings 2-3): the `family` DEFAULT here, and the fact that │
|
|
121
|
+
// │ the TARGET is upserted with no roster constraint (TOFU — see resolvePeer above), │
|
|
122
|
+
// │ are correct + safe ONLY because the authority gate (authorizeConnect) only COMMITs │
|
|
123
|
+
// │ on the owner-only `local` stdio sense today (no wire constructs a non-`local` │
|
|
124
|
+
// │ controlContext). BEFORE any non-`local`/networked controlContext is ever wired, the │
|
|
125
|
+
// │ `connect` commit MUST add target-side roster verification (the target did must ALSO │
|
|
126
|
+
// │ be roster-checked, not just TOFU-upserted) AND validate the caller-supplied │
|
|
127
|
+
// │ `trustLevel` against the authority decision. The current `family` default + │
|
|
128
|
+
// │ unconstrained target are safe only for the owner-only-stdio path. │
|
|
129
|
+
// └──────────────────────────────────────────────────────────────────────────────────────┘
|
|
130
|
+
const trustLevel = input.trustLevel ?? "family";
|
|
131
|
+
const a2a = resolved.existing?.agentMeta?.a2a;
|
|
132
|
+
const record = await (0, agent_peer_1.upsertAgentPeer)(store, {
|
|
133
|
+
name: resolved.name,
|
|
134
|
+
agentId: resolved.agentId,
|
|
135
|
+
trustLevel,
|
|
136
|
+
...(a2a ? { a2a } : {}),
|
|
137
|
+
});
|
|
138
|
+
// 4) The control-plane audit — ONE action:"connect" record through the wired sink
|
|
139
|
+
// (mirrors the onboard_agent seat writer). No sink ⇒ a clean no-op.
|
|
140
|
+
if (deps.audit) {
|
|
141
|
+
const targetDid = (0, identity_1.resolveAgentIdentity)(record.agentMeta).did;
|
|
142
|
+
const auditRecord = {
|
|
143
|
+
action: "connect",
|
|
144
|
+
targetId: record.id,
|
|
145
|
+
...(targetDid !== undefined ? { targetDid } : {}),
|
|
146
|
+
level: trustLevel,
|
|
147
|
+
actor: deps.actor,
|
|
148
|
+
originSense: deps.originSense,
|
|
149
|
+
ts: record.updatedAt,
|
|
150
|
+
};
|
|
151
|
+
await deps.audit.append(auditRecord);
|
|
152
|
+
}
|
|
153
|
+
(0, observability_1.emitNervesEvent)({
|
|
154
|
+
component: "friends",
|
|
155
|
+
event: "friends.connect_linked",
|
|
156
|
+
message: "connect_to linked an own-fleet agent peer",
|
|
157
|
+
meta: { targetId: record.id, level: trustLevel },
|
|
158
|
+
});
|
|
159
|
+
return { ok: true, status: "connected", record };
|
|
160
|
+
}
|
package/dist/coordination.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { MissionStore } from "./mission-store";
|
|
2
2
|
import type { FriendStore } from "./store";
|
|
3
3
|
import type { GrantStore } from "./grant-store";
|
|
4
|
-
import type { AgentAttribution, CoordinationIntent, MissionRecord, TrustLevel } from "./types";
|
|
4
|
+
import type { AgentAttribution, CoordinationIntent, MissionRecord, MissionTaskSpec, TrustLevel } from "./types";
|
|
5
5
|
import type { ConsentPolicy } from "./consent";
|
|
6
6
|
import type { AgentVerifier } from "./verifier";
|
|
7
7
|
/** The cross-agent coordination envelope (brick 5). Names the subject by JOIN KEY
|
|
@@ -25,6 +25,12 @@ export interface CoordinationEnvelope {
|
|
|
25
25
|
* PROPOSES as the new assignee (named by join-key agentId). The receiver's own
|
|
26
26
|
* accept is what actually sets it — a handoff never forces an assignment. */
|
|
27
27
|
proposedAssignee?: AgentAttribution;
|
|
28
|
+
/** The delegation task-spec, meaningful ONLY on intent:"request" (gap-1, p11 inc2):
|
|
29
|
+
* the structured "what B is being asked to do", carrying the minted `requestId` the
|
|
30
|
+
* result-return correlates against. Present only when the producer was given a task on
|
|
31
|
+
* a request; a plain coordination request omits it (back-compat). Mirrors how
|
|
32
|
+
* `proposedAssignee` rides only `handoff`. */
|
|
33
|
+
task?: MissionTaskSpec;
|
|
28
34
|
/** Opaque, verifier-specific proof slot. The TOFU verifier ignores it. */
|
|
29
35
|
proof?: string;
|
|
30
36
|
issuedAt: string;
|
|
@@ -43,6 +49,16 @@ export interface PrepareCoordinationInput {
|
|
|
43
49
|
/** This agent's own join-key agentId — the asserter of the first-party log entry
|
|
44
50
|
* (and, on an `accept`, the assignee it claims for itself). */
|
|
45
51
|
selfAgentId: string;
|
|
52
|
+
/** Optional delegation task-spec (gap-1, p11 inc2), meaningful ONLY on
|
|
53
|
+
* intent:"request". When provided on a request, the producer MINTS a `requestId`,
|
|
54
|
+
* stamps the full `MissionTaskSpec` on the envelope's `task`, and records it first-party
|
|
55
|
+
* under the mission's `delegations[requestId]`. Ignored on any non-request intent.
|
|
56
|
+
* Absent ⇒ a plain coordination request, byte-identical to today (back-compat). */
|
|
57
|
+
task?: {
|
|
58
|
+
summary: string;
|
|
59
|
+
details?: string;
|
|
60
|
+
inputs?: Record<string, string>;
|
|
61
|
+
};
|
|
46
62
|
/** Optional proof to stamp on the envelope (for a non-TOFU recipient verifier). */
|
|
47
63
|
proof?: string;
|
|
48
64
|
}
|
package/dist/coordination.js
CHANGED
|
@@ -49,8 +49,12 @@ function holdsAssignment(record, selfAgentId) {
|
|
|
49
49
|
/** Apply the OUTGOING intent to the producer's own mission record as a first-party
|
|
50
50
|
* step: always append the intent to `coordination.log`; on an `accept`, also claim
|
|
51
51
|
* the assignment for self (the accepter is taking it). No other intent moves the
|
|
52
|
-
* producer's `assignee`.
|
|
53
|
-
|
|
52
|
+
* producer's `assignee`. When a `taskSpec` is supplied (a request carrying a task,
|
|
53
|
+
* gap-1), ALSO record it first-party under `delegations[requestId]` — the task-spec, the
|
|
54
|
+
* delegated-TO `assignee` (`input.toAgentId`), and first-party provenance. The assignee is
|
|
55
|
+
* the anchor importMissionResult checks the result's source against (security-review inc-2
|
|
56
|
+
* finding 1). Mirrors how recordMission stamps first-party provenance. */
|
|
57
|
+
function applyOutgoingIntent(record, input, now, taskSpec) {
|
|
54
58
|
const entry = {
|
|
55
59
|
intent: input.intent,
|
|
56
60
|
fromAgentId: input.selfAgentId,
|
|
@@ -62,7 +66,38 @@ function applyOutgoingIntent(record, input, now) {
|
|
|
62
66
|
const coordination = input.intent === "accept"
|
|
63
67
|
? { ...withLog, assignee: { agentId: input.selfAgentId }, assignedAt: now }
|
|
64
68
|
: withLog;
|
|
65
|
-
|
|
69
|
+
// gap-1: record the issued delegation first-party under delegations[requestId]. PERSIST
|
|
70
|
+
// the ASSIGNEE — the agent this task is delegated TO (input.toAgentId) — alongside the
|
|
71
|
+
// task-spec (security-review inc-2 finding 1). This is the anchor importMissionResult
|
|
72
|
+
// checks the result's SOURCE against: A only accepts a result for this requestId from the
|
|
73
|
+
// very agent it delegated TO. Without it, a trusted non-assignee who learned the
|
|
74
|
+
// requestId could inject a forged result.
|
|
75
|
+
const delegations = taskSpec
|
|
76
|
+
? {
|
|
77
|
+
...(record.delegations ?? {}),
|
|
78
|
+
[taskSpec.requestId]: { task: taskSpec, assignee: { agentId: input.toAgentId }, provenance: { origin: "first_party" } },
|
|
79
|
+
}
|
|
80
|
+
: record.delegations;
|
|
81
|
+
return {
|
|
82
|
+
...record,
|
|
83
|
+
coordination,
|
|
84
|
+
...(delegations ? { delegations } : {}),
|
|
85
|
+
updatedAt: now,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Build the MissionTaskSpec for a request carrying a task (gap-1): mint the
|
|
89
|
+
* `requestId` and carry the optional details/inputs only when present. Returns undefined
|
|
90
|
+
* when there is no task to attach, or the intent is not a request (a task on any other
|
|
91
|
+
* intent is ignored). */
|
|
92
|
+
function buildTaskSpec(input) {
|
|
93
|
+
if (input.intent !== "request" || input.task === undefined)
|
|
94
|
+
return undefined;
|
|
95
|
+
return {
|
|
96
|
+
requestId: (0, node_crypto_1.randomUUID)(),
|
|
97
|
+
summary: input.task.summary,
|
|
98
|
+
...(input.task.details !== undefined ? { details: input.task.details } : {}),
|
|
99
|
+
...(input.task.inputs !== undefined ? { inputs: input.task.inputs } : {}),
|
|
100
|
+
};
|
|
66
101
|
}
|
|
67
102
|
/**
|
|
68
103
|
* Producer half of the coordination primitive. Consent-gated (subject = the
|
|
@@ -101,6 +136,10 @@ async function prepareCoordination(missions, store, grants, input, consent = con
|
|
|
101
136
|
return { ok: false, status: "not_assignee" };
|
|
102
137
|
}
|
|
103
138
|
const now = new Date().toISOString();
|
|
139
|
+
// gap-1: a request carrying a task mints a requestId + a MissionTaskSpec (undefined on
|
|
140
|
+
// any non-request intent, or when no task was given). Minted ONCE so the envelope's
|
|
141
|
+
// task and the first-party delegations[requestId] share the same correlation key.
|
|
142
|
+
const taskSpec = buildTaskSpec(input);
|
|
104
143
|
const envelope = {
|
|
105
144
|
subject: { missionKey: record.missionKey, title: record.title },
|
|
106
145
|
fromAgentId: input.selfAgentId,
|
|
@@ -110,11 +149,13 @@ async function prepareCoordination(missions, store, grants, input, consent = con
|
|
|
110
149
|
...(input.intent === "handoff" && input.proposedAssignee !== undefined
|
|
111
150
|
? { proposedAssignee: input.proposedAssignee }
|
|
112
151
|
: {}),
|
|
152
|
+
...(taskSpec ? { task: taskSpec } : {}),
|
|
113
153
|
...(input.proof !== undefined ? { proof: input.proof } : {}),
|
|
114
154
|
};
|
|
115
155
|
// Record the outgoing intent on the producer's own mission (first-party), so the
|
|
116
|
-
// sender's record reflects "I asked / I offered / I accepted"
|
|
117
|
-
|
|
156
|
+
// sender's record reflects "I asked / I offered / I accepted" — and, for a request
|
|
157
|
+
// with a task, the issued delegation under delegations[requestId].
|
|
158
|
+
const updated = applyOutgoingIntent(record, input, now, taskSpec);
|
|
118
159
|
await missions.put(updated.id, updated);
|
|
119
160
|
(0, observability_1.emitNervesEvent)({
|
|
120
161
|
component: "friends",
|
|
@@ -174,8 +215,41 @@ function applyIncomingIntent(record, envelope, fromAgentId, now) {
|
|
|
174
215
|
: withLog;
|
|
175
216
|
return { record: { ...record, coordination, updatedAt: now }, assigned: isLater };
|
|
176
217
|
}
|
|
218
|
+
// gap-1: a request carrying a task lands the task-spec QUARANTINED under
|
|
219
|
+
// importedDelegations[fromAgentId][requestId] (attributed, imported), NEVER touching
|
|
220
|
+
// first-party `delegations`/`learnings`/`notes`/`status`. Idempotent per (agentId,
|
|
221
|
+
// requestId): an existing entry is preserved (never re-stamped).
|
|
222
|
+
const importedDelegations = envelope.intent === "request" && envelope.task !== undefined
|
|
223
|
+
? mergeImportedDelegation(record, envelope.task, fromAgentId, now)
|
|
224
|
+
: record.importedDelegations;
|
|
177
225
|
// request / offer / decline / handoff → log only; assignee untouched.
|
|
178
|
-
return {
|
|
226
|
+
return {
|
|
227
|
+
record: {
|
|
228
|
+
...record,
|
|
229
|
+
coordination: withLog,
|
|
230
|
+
...(importedDelegations ? { importedDelegations } : {}),
|
|
231
|
+
updatedAt: now,
|
|
232
|
+
},
|
|
233
|
+
assigned: false,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** Land one imported task-spec under `importedDelegations[fromAgentId][requestId]`,
|
|
237
|
+
* returning a NEW namespace (never mutates the input). First-party `delegations` are NOT
|
|
238
|
+
* passed in and stay physically untouched. Idempotent per (agentId, requestId): an entry
|
|
239
|
+
* that already exists is preserved unchanged (never re-stamped with a new importedAt). */
|
|
240
|
+
function mergeImportedDelegation(record, task, fromAgentId, now) {
|
|
241
|
+
const existing = record.importedDelegations ?? {};
|
|
242
|
+
const forAgent = existing[fromAgentId] ?? {};
|
|
243
|
+
// Idempotent: keep the existing entry for this requestId (do not re-stamp on replay).
|
|
244
|
+
if (forAgent[task.requestId])
|
|
245
|
+
return existing;
|
|
246
|
+
return {
|
|
247
|
+
...existing,
|
|
248
|
+
[fromAgentId]: {
|
|
249
|
+
...forAgent,
|
|
250
|
+
[task.requestId]: { task, provenance: { origin: "imported", assertedBy: { agentId: fromAgentId }, importedAt: now } },
|
|
251
|
+
},
|
|
252
|
+
};
|
|
179
253
|
}
|
|
180
254
|
/** Create a freshly-seeded mission for a previously-unknown key, carrying the
|
|
181
255
|
* subject's join key + title, `status:"active"`, empty first-party `learnings`. The
|
package/dist/file-bundle.d.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { FileFriendStore } from "./store-file";
|
|
2
2
|
import { FileGrantStore } from "./grant-store-file";
|
|
3
3
|
import { FileMissionStore } from "./mission-store-file";
|
|
4
|
+
import { FileAuditSink } from "./audit";
|
|
4
5
|
export interface FileBundle {
|
|
5
6
|
store: FileFriendStore;
|
|
6
7
|
grants: FileGrantStore;
|
|
7
8
|
missions: FileMissionStore;
|
|
9
|
+
/** Control-plane audit sink (Bug B, finding 3) over the sibling `_audit/control.jsonl`,
|
|
10
|
+
* so the live MCP `set_trust` / `onboard_agent` trust seat write audit records. */
|
|
11
|
+
audit: FileAuditSink;
|
|
8
12
|
friendsDir: string;
|
|
9
13
|
grantsDir: string;
|
|
10
14
|
missionsDir: string;
|
|
15
|
+
auditPath: string;
|
|
11
16
|
}
|
|
12
17
|
export declare function openFileBundle(friendsDir: string): FileBundle;
|
package/dist/file-bundle.js
CHANGED
|
@@ -7,17 +7,21 @@ exports.openFileBundle = openFileBundle;
|
|
|
7
7
|
const store_file_1 = require("./store-file");
|
|
8
8
|
const grant_store_file_1 = require("./grant-store-file");
|
|
9
9
|
const mission_store_file_1 = require("./mission-store-file");
|
|
10
|
+
const audit_1 = require("./audit");
|
|
10
11
|
const observability_1 = require("./observability");
|
|
11
12
|
function openFileBundle(friendsDir) {
|
|
12
13
|
const grantsDir = (0, grant_store_file_1.grantsDirFor)(friendsDir);
|
|
13
14
|
const missionsDir = (0, mission_store_file_1.missionsDirFor)(friendsDir);
|
|
15
|
+
const auditPath = (0, audit_1.auditPathFor)(friendsDir);
|
|
14
16
|
(0, observability_1.emitNervesEvent)({ component: "friends", event: "friends.file_bundle_opened", message: "opened file bundle", meta: {} });
|
|
15
17
|
return {
|
|
16
18
|
store: new store_file_1.FileFriendStore(friendsDir),
|
|
17
19
|
grants: new grant_store_file_1.FileGrantStore(grantsDir),
|
|
18
20
|
missions: new mission_store_file_1.FileMissionStore(missionsDir),
|
|
21
|
+
audit: new audit_1.FileAuditSink(auditPath),
|
|
19
22
|
friendsDir,
|
|
20
23
|
grantsDir,
|
|
21
24
|
missionsDir,
|
|
25
|
+
auditPath,
|
|
22
26
|
};
|
|
23
27
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FriendStore } from "./store";
|
|
2
|
+
import type { FriendRecord } from "./types";
|
|
3
|
+
/** Find the friend record whose durable identity DID equals `did`. A DUPLICATE did is
|
|
4
|
+
* an anomaly: it emits a loud `friends.duplicate_did` warning and resolves
|
|
5
|
+
* deterministically WITHOUT rewarding back-dating — a pinned/verified record wins, else
|
|
6
|
+
* the lowest record `id` (a stable, non-temporal tie-break) — see {@link preferOverBest}.
|
|
7
|
+
* Returns null when no record matches, the query did is falsy, or the store has no
|
|
8
|
+
* `listAll`. */
|
|
9
|
+
export declare function findFriendByDid(store: FriendStore, did: string): Promise<FriendRecord | null>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findFriendByDid = findFriendByDid;
|
|
4
|
+
// did-aware friend lookup (p11 Item 2 — the DID re-key).
|
|
5
|
+
//
|
|
6
|
+
// `did` is the durable cross-agent primary key. This pure helper finds a friend
|
|
7
|
+
// record by did WITHOUT changing the FriendStore interface contract: it scans
|
|
8
|
+
// `store.listAll?.()` and matches on the record's resolved identity
|
|
9
|
+
// (`resolveAgentIdentity(f.agentMeta).did`, which already prefers identity.did and
|
|
10
|
+
// migrates a2a.did on read). Additive — `findByExternalId` is untouched. A store
|
|
11
|
+
// with no `listAll` yields null (the lookup is best-effort, never a throw).
|
|
12
|
+
const observability_1 = require("./observability");
|
|
13
|
+
const identity_1 = require("./identity");
|
|
14
|
+
/** Whether `candidate` should replace the current best among duplicate-did records.
|
|
15
|
+
*
|
|
16
|
+
* SECURITY (finding 5, MEDIUM): the tie-break must NOT reward back-dating — the old
|
|
17
|
+
* "lowest createdAt wins" rule let an attacker mint a duplicate-did record with an
|
|
18
|
+
* earlier createdAt to silently shadow a legit one. Instead:
|
|
19
|
+
* 1) Prefer a trust-relevant signal — a record carrying a TOFU-pinned key
|
|
20
|
+
* (`pinnedKey`) is the verified one and beats an unpinned duplicate.
|
|
21
|
+
* 2) When pinned-status is equal, break the tie by the record `id` (a stable,
|
|
22
|
+
* non-temporal key) — back-dating `createdAt` no longer gains anything. */
|
|
23
|
+
function preferOverBest(candidate, best) {
|
|
24
|
+
const candidatePinned = (0, identity_1.resolveAgentIdentity)(candidate.agentMeta).pinnedKey !== undefined;
|
|
25
|
+
const bestPinned = (0, identity_1.resolveAgentIdentity)(best.agentMeta).pinnedKey !== undefined;
|
|
26
|
+
if (candidatePinned !== bestPinned)
|
|
27
|
+
return candidatePinned; // pinned beats unpinned
|
|
28
|
+
return candidate.id < best.id; // stable, non-temporal tie-break
|
|
29
|
+
}
|
|
30
|
+
/** Find the friend record whose durable identity DID equals `did`. A DUPLICATE did is
|
|
31
|
+
* an anomaly: it emits a loud `friends.duplicate_did` warning and resolves
|
|
32
|
+
* deterministically WITHOUT rewarding back-dating — a pinned/verified record wins, else
|
|
33
|
+
* the lowest record `id` (a stable, non-temporal tie-break) — see {@link preferOverBest}.
|
|
34
|
+
* Returns null when no record matches, the query did is falsy, or the store has no
|
|
35
|
+
* `listAll`. */
|
|
36
|
+
async function findFriendByDid(store, did) {
|
|
37
|
+
// SECURITY (finding 4, MEDIUM): a falsy did query must never match. Without this,
|
|
38
|
+
// findFriendByDid(store, undefined|"") matched the first did-less record (a did-less
|
|
39
|
+
// record resolves to `undefined`, and `undefined !== undefined` is false → match).
|
|
40
|
+
if (!did)
|
|
41
|
+
return null;
|
|
42
|
+
if (typeof store.listAll !== "function")
|
|
43
|
+
return null;
|
|
44
|
+
const all = await store.listAll();
|
|
45
|
+
let best = null;
|
|
46
|
+
let matchCount = 0;
|
|
47
|
+
for (const f of all) {
|
|
48
|
+
const resolvedDid = (0, identity_1.resolveAgentIdentity)(f.agentMeta).did;
|
|
49
|
+
// Skip records whose resolved did is falsy (absent/empty) so they can never match —
|
|
50
|
+
// belt-and-braces with resolveAgentIdentity's own empty-string guard (finding 6).
|
|
51
|
+
if (!resolvedDid || resolvedDid !== did)
|
|
52
|
+
continue;
|
|
53
|
+
matchCount += 1;
|
|
54
|
+
if (best === null || preferOverBest(f, best))
|
|
55
|
+
best = f;
|
|
56
|
+
}
|
|
57
|
+
// SECURITY (finding 5): a duplicate did is itself an anomaly — surface it loudly so a
|
|
58
|
+
// shadowing attempt is visible, rather than silently resolving it away.
|
|
59
|
+
if (matchCount > 1) {
|
|
60
|
+
(0, observability_1.emitNervesEvent)({
|
|
61
|
+
level: "warn",
|
|
62
|
+
component: "friends",
|
|
63
|
+
event: "friends.duplicate_did",
|
|
64
|
+
message: `duplicate did detected across ${matchCount} friend records — resolving to the pinned/lowest-id record (NOT lowest-createdAt); investigate possible record shadowing`,
|
|
65
|
+
meta: { did, matchCount },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return best;
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentMeta } from "./types";
|
|
2
|
+
/** The resolved durable identity of an agent peer — independent of which on-disk
|
|
3
|
+
* shape carried it. All fields optional: a did-less legacy record reads clean. */
|
|
4
|
+
export interface ResolvedAgentIdentity {
|
|
5
|
+
did?: string;
|
|
6
|
+
pinnedKey?: string;
|
|
7
|
+
handle?: string;
|
|
8
|
+
pinnedAt?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Read an agent's durable identity, preferring `meta.identity` and lifting the
|
|
11
|
+
* legacy `meta.a2a.did` on a miss (migrate-on-read). Returns `{}` for a did-less
|
|
12
|
+
* or absent meta. (Unit 4a stub — not implemented.) */
|
|
13
|
+
export declare function resolveAgentIdentity(meta: AgentMeta | undefined): ResolvedAgentIdentity;
|
|
14
|
+
/** Return a meta whose `identity.did` is backfilled from `a2a.did` when the durable
|
|
15
|
+
* home is absent (migrate-on-write); a meta already carrying `identity` is returned
|
|
16
|
+
* unchanged (no clobber). Absent meta is returned as-is. (Unit 4a stub.) */
|
|
17
|
+
export declare function withMigratedIdentity(meta: AgentMeta | undefined): AgentMeta | undefined;
|