blun-king-cli 9.1.563 → 9.1.565
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/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,581 +1,581 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { constants as fsConstants } from "node:fs";
|
|
3
|
-
import { lstat, open, opendir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
-
import { isAbsolute, join } from "node:path";
|
|
5
|
-
import { buildCatalog } from "./catalog.js";
|
|
6
|
-
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
7
|
-
import { linkEntities, loadGraph, unlinkEntities, upsertEntity } from "./graph.js";
|
|
8
|
-
import { isInside, projectStateDir } from "./paths.js";
|
|
9
|
-
|
|
10
|
-
export const PERSONA_POLICY_SCHEMA = "agentspine.persona-policy/v1";
|
|
11
|
-
export const PERSONA_RUNTIME_SCHEMA = "agentspine.persona-runtime/v1";
|
|
12
|
-
export const PERSONA_EVENT_SCHEMA = "agentspine.persona-event/v1";
|
|
13
|
-
export const PERSONA_ROSTER_SCHEMA = "agentspine.persona-roster/v1";
|
|
14
|
-
|
|
15
|
-
const CONFIRMATION = "local-owner-confirmed";
|
|
16
|
-
const MAX_BYTES = 5 * 1024 * 1024;
|
|
17
|
-
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/+~-]{0,255}$/;
|
|
18
|
-
const KINDS = new Set(["person", "agent", "bot"]);
|
|
19
|
-
const EVENTS = new Set(["join", "rename", "group-change", "leave", "deactivate", "rejoin"]);
|
|
20
|
-
const ACTIVE = new Set(["active", "left", "deactivated"]);
|
|
21
|
-
const AUTHENTICATORS = new Set(["host-manifest", "oauth-subject", "local-roster", "signed-roster"]);
|
|
22
|
-
const NATIVE_HOSTS = new Set(["claude", "codex"]);
|
|
23
|
-
const NATIVE_SCOPES = new Set(["user", "project"]);
|
|
24
|
-
const FORBIDDEN = /\b(?:permission|rights?|roles?|owner|trusted|reports[- ]to|responsible[- ]for|delegat|token|secret|credential|api[-_ ]?key|network|production|payment|send capability|tool capability)\b/i;
|
|
25
|
-
|
|
26
|
-
function emptyPolicy(root) {
|
|
27
|
-
return { schema: PERSONA_POLICY_SCHEMA, root, revision: 0, bindings: [], history: [] };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function emptyRuntime(root) {
|
|
31
|
-
return { schema: PERSONA_RUNTIME_SCHEMA, root, revision: 0, personas: [], events: [], receipts: [] };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function exactId(value, field, nullable = false) {
|
|
35
|
-
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
36
|
-
if (typeof value !== "string" || !ID_RE.test(value) || value.includes("*")) {
|
|
37
|
-
throw new Error(field + " must be an exact stable ID without wildcards");
|
|
38
|
-
}
|
|
39
|
-
return value;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function exactText(value, field, maximum = 200) {
|
|
43
|
-
if (typeof value !== "string" || !value.trim()) throw new Error(field + " is required");
|
|
44
|
-
const text = value.trim().replace(/\s+/g, " ").slice(0, maximum);
|
|
45
|
-
if (FORBIDDEN.test(text)) throw new Error(field + " contains authority or secret-shaped content");
|
|
46
|
-
return text;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function sourceText(value) {
|
|
50
|
-
if (typeof value !== "string" || !value.trim() || value.includes("\0")) throw new Error("sourceBinding is invalid");
|
|
51
|
-
return value.trim().replaceAll("\\", "/").slice(0, 500);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function at(value = new Date()) {
|
|
55
|
-
const date = value instanceof Date ? value : new Date(value);
|
|
56
|
-
if (!Number.isFinite(date.getTime())) throw new Error("timestamp is invalid");
|
|
57
|
-
return date.toISOString();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function digest(value) {
|
|
61
|
-
return createHash("sha256").update(value).digest("hex");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function identityMaterial(binding) {
|
|
65
|
-
return [binding.authenticator, binding.issuer, binding.tenantId, binding.host, binding.profileId,
|
|
66
|
-
binding.subjectId, binding.kind].join("\0");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function stablePersonaId(binding) {
|
|
70
|
-
return binding.kind + ":runtime:" + digest(identityMaterial(binding)).slice(0, 32);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function validBinding(binding) {
|
|
74
|
-
return binding && ID_RE.test(binding.id || "") && KINDS.has(binding.kind)
|
|
75
|
-
&& AUTHENTICATORS.has(binding.authenticator) && ID_RE.test(binding.issuer || "")
|
|
76
|
-
&& ID_RE.test(binding.tenantId || "") && ID_RE.test(binding.host || "")
|
|
77
|
-
&& ID_RE.test(binding.profileId || "") && ID_RE.test(binding.subjectId || "")
|
|
78
|
-
&& ID_RE.test(binding.personaId || "") && binding.personaId === stablePersonaId(binding)
|
|
79
|
-
&& typeof binding.displayName === "string" && binding.displayName.length > 0 && !FORBIDDEN.test(binding.displayName)
|
|
80
|
-
&& (binding.sourceBinding === null || typeof binding.sourceBinding === "string")
|
|
81
|
-
&& (binding.groupId === null || ID_RE.test(binding.groupId || ""))
|
|
82
|
-
&& typeof binding.active === "boolean" && typeof binding.deactivated === "boolean"
|
|
83
|
-
&& !(binding.active && binding.deactivated) && binding.authority === "authenticated-identity-policy"
|
|
84
|
-
&& Number.isFinite(new Date(binding.createdAt).getTime())
|
|
85
|
-
&& Number.isFinite(new Date(binding.updatedAt).getTime());
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function validPersona(persona) {
|
|
89
|
-
return persona && ID_RE.test(persona.personaId || "") && ID_RE.test(persona.bindingId || "")
|
|
90
|
-
&& KINDS.has(persona.kind) && ACTIVE.has(persona.status)
|
|
91
|
-
&& typeof persona.displayName === "string" && persona.displayName.length > 0
|
|
92
|
-
&& (persona.groupId === null || ID_RE.test(persona.groupId || ""))
|
|
93
|
-
&& Number.isInteger(persona.sequence) && persona.sequence >= 1
|
|
94
|
-
&& persona.authority === "identity-state-only" && Number.isFinite(new Date(persona.updatedAt).getTime());
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function validEvent(event) {
|
|
98
|
-
if (!(event && event.schema === PERSONA_EVENT_SCHEMA && ID_RE.test(event.eventId || "")
|
|
99
|
-
&& ID_RE.test(event.personaId || "") && ID_RE.test(event.bindingId || "")
|
|
100
|
-
&& EVENTS.has(event.type) && Number.isInteger(event.sequence) && event.sequence >= 1
|
|
101
|
-
&& Number.isFinite(new Date(event.observedAt).getTime())
|
|
102
|
-
&& /^[a-f0-9]{64}$/.test(event.payloadDigest || "")
|
|
103
|
-
&& event.authority === "identity-state-only")) return false;
|
|
104
|
-
const payload = { personaId: event.personaId, bindingId: event.bindingId, type: event.type, sequence: event.sequence,
|
|
105
|
-
displayName: event.displayName, groupId: event.groupId, observedAt: event.observedAt };
|
|
106
|
-
const payloadDigest = digest(JSON.stringify(payload));
|
|
107
|
-
return event.payloadDigest === payloadDigest && event.eventId === "persona-event:" + payloadDigest.slice(0, 32);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function validReceipt(receipt) {
|
|
111
|
-
if (!(receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
|
|
112
|
-
&& /^[a-f0-9]{64}$/.test(receipt.payloadDigest || "") && /^[a-f0-9]{64}$/.test(receipt.digest || "")
|
|
113
|
-
&& Number.isFinite(new Date(receipt.at).getTime()) && receipt.authority === "identity-state-only")) return false;
|
|
114
|
-
const material = { eventId: receipt.eventId, payloadDigest: receipt.payloadDigest, at: receipt.at,
|
|
115
|
-
authority: "identity-state-only" };
|
|
116
|
-
return receipt.digest === digest(JSON.stringify(material)) && receipt.id === "persona-receipt:" + receipt.digest.slice(0, 24);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function normalizePolicy(value, root) {
|
|
120
|
-
if (!value || value.schema !== PERSONA_POLICY_SCHEMA || value.root !== root
|
|
121
|
-
|| !Number.isInteger(value.revision) || !Array.isArray(value.bindings) || !Array.isArray(value.history)
|
|
122
|
-
|| value.bindings.some((item) => !validBinding(item))
|
|
123
|
-
|| value.history.some((item) => !item || !validBinding(item.value) || item.authority !== "authenticated-identity-policy")) {
|
|
124
|
-
throw new Error("persona policy is invalid; automatic persona synchronization is disabled");
|
|
125
|
-
}
|
|
126
|
-
return value;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function normalizeRuntime(value, root) {
|
|
130
|
-
if (!value || value.schema !== PERSONA_RUNTIME_SCHEMA || value.root !== root
|
|
131
|
-
|| !Number.isInteger(value.revision) || !Array.isArray(value.personas)
|
|
132
|
-
|| !Array.isArray(value.events) || !Array.isArray(value.receipts)
|
|
133
|
-
|| value.personas.some((item) => !validPersona(item)) || value.events.some((item) => !validEvent(item))
|
|
134
|
-
|| value.receipts.some((item) => !validReceipt(item))) {
|
|
135
|
-
throw new Error("persona runtime is invalid; persona context is disabled");
|
|
136
|
-
}
|
|
137
|
-
return value;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function pathsFor(root, providedCatalog = null) {
|
|
141
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
142
|
-
const directory = await projectStateDir(catalog.root);
|
|
143
|
-
return { catalog, directory, personaPolicyPath: join(directory, "persona-policy.json"), personaRuntimePath: join(directory, "persona-runtime.json") };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function readJson(path, root, normalize, empty) {
|
|
147
|
-
try {
|
|
148
|
-
const metadata = await stat(path);
|
|
149
|
-
if (metadata.size > MAX_BYTES) throw new Error("persona state exceeds 5 MiB");
|
|
150
|
-
return normalize(JSON.parse(await readFile(path, "utf8")), root);
|
|
151
|
-
} catch (error) {
|
|
152
|
-
if (error.code !== "ENOENT") throw error;
|
|
153
|
-
return empty(root);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
async function writeJson(path, value) {
|
|
158
|
-
const content = JSON.stringify(value, null, 2) + "\n";
|
|
159
|
-
if (Buffer.byteLength(content) > MAX_BYTES) throw new Error("persona state exceeds 5 MiB");
|
|
160
|
-
const temporary = path + "." + process.pid + "." + randomUUID() + ".tmp";
|
|
161
|
-
try {
|
|
162
|
-
await writeFile(temporary, content, { mode: 0o600 });
|
|
163
|
-
await replaceFileWithRetry(temporary, path);
|
|
164
|
-
} finally {
|
|
165
|
-
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
async function withLock(paths, task) {
|
|
170
|
-
const lockPath = join(paths.directory, "persona-runtime.lock");
|
|
171
|
-
let handle;
|
|
172
|
-
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
173
|
-
try { handle = await open(lockPath, "wx", 0o600); break; } catch (error) {
|
|
174
|
-
if (!isFileLockContention(error)) throw error;
|
|
175
|
-
try {
|
|
176
|
-
const metadata = await stat(lockPath);
|
|
177
|
-
if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
|
|
178
|
-
} catch (lockError) { if (lockError.code !== "ENOENT") throw lockError; }
|
|
179
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
if (!handle) throw new Error("persona state is busy; retry later");
|
|
183
|
-
try { return await task(); } finally {
|
|
184
|
-
await handle.close();
|
|
185
|
-
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function normalizeBinding(input, timestamp) {
|
|
190
|
-
const binding = {
|
|
191
|
-
id: exactId(input.id || "persona-binding:" + randomUUID(), "bindingId"),
|
|
192
|
-
authenticator: AUTHENTICATORS.has(input.authenticator) ? input.authenticator : null,
|
|
193
|
-
issuer: exactId(input.issuer, "issuer"), tenantId: exactId(input.tenantId, "tenantId"),
|
|
194
|
-
host: exactId(input.host, "host"), profileId: exactId(input.profileId, "profileId"),
|
|
195
|
-
subjectId: exactId(input.subjectId, "subjectId"), kind: input.kind,
|
|
196
|
-
displayName: exactText(input.displayName, "displayName"),
|
|
197
|
-
sourceBinding: input.sourceBinding === null || input.sourceBinding === undefined ? null : sourceText(input.sourceBinding),
|
|
198
|
-
groupId: exactId(input.groupId, "groupId", true), deactivated: input.deactivated === true,
|
|
199
|
-
active: input.deactivated === true ? false : input.active !== false,
|
|
200
|
-
createdAt: timestamp, updatedAt: timestamp, authority: "authenticated-identity-policy"
|
|
201
|
-
};
|
|
202
|
-
if (!binding.authenticator || !KINDS.has(binding.kind)) throw new Error("persona binding requires an approved authenticator and person, agent, or bot kind");
|
|
203
|
-
binding.personaId = stablePersonaId(binding);
|
|
204
|
-
return binding;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function eventPayload(binding, type, sequence, observedAt) {
|
|
208
|
-
const payload = { personaId: binding.personaId, bindingId: binding.id, type, sequence, displayName: binding.displayName,
|
|
209
|
-
groupId: binding.groupId, observedAt };
|
|
210
|
-
return { schema: PERSONA_EVENT_SCHEMA, eventId: "persona-event:" + digest(JSON.stringify(payload)).slice(0, 32),
|
|
211
|
-
...payload, payloadDigest: digest(JSON.stringify(payload)), authority: "identity-state-only" };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function eventReceipt(event) {
|
|
215
|
-
const material = { eventId: event.eventId, payloadDigest: event.payloadDigest, at: event.observedAt,
|
|
216
|
-
authority: "identity-state-only" };
|
|
217
|
-
const receiptDigest = digest(JSON.stringify(material));
|
|
218
|
-
return { id: "persona-receipt:" + receiptDigest.slice(0, 24), ...material, digest: receiptDigest };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function nextEventTypes(previous, binding) {
|
|
222
|
-
if (!previous) return ["join"];
|
|
223
|
-
if (!binding.active && previous.status === "active") return [binding.deactivated ? "deactivate" : "leave"];
|
|
224
|
-
if (!binding.active) return [];
|
|
225
|
-
const types = previous.status !== "active" ? ["rejoin"] : [];
|
|
226
|
-
if (previous.displayName !== binding.displayName) types.push("rename");
|
|
227
|
-
if (previous.groupId !== binding.groupId) types.push("group-change");
|
|
228
|
-
return types;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function runtimeEntityAttributes(persona, binding, existing) {
|
|
232
|
-
return {
|
|
233
|
-
...(existing?.attributes || {}),
|
|
234
|
-
runtimeKind: persona.kind,
|
|
235
|
-
identityBindingId: persona.bindingId,
|
|
236
|
-
identityStatus: persona.status,
|
|
237
|
-
sourceBinding: binding?.sourceBinding || null
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function sameRuntimeEntity(entity, persona, binding) {
|
|
242
|
-
if (!entity) return false;
|
|
243
|
-
const expectedKind = persona.kind === "bot" ? "agent" : persona.kind;
|
|
244
|
-
const expectedPrivacy = persona.groupId ? "group" : "shared";
|
|
245
|
-
const attributes = runtimeEntityAttributes(persona, binding, entity);
|
|
246
|
-
return entity.kind === expectedKind && entity.displayName === persona.displayName
|
|
247
|
-
&& entity.privacy === expectedPrivacy
|
|
248
|
-
&& JSON.stringify(entity.attributes) === JSON.stringify(attributes);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
async function reconcilePersonaGraph(paths, policy, runtime) {
|
|
252
|
-
let { graph } = await loadGraph(paths.catalog.root, paths.catalog);
|
|
253
|
-
const changes = { groupsCreated: 0, entitiesUpdated: 0, membershipsAdded: 0, membershipsRemoved: 0 };
|
|
254
|
-
const activeGroupIds = [...new Set(runtime.personas
|
|
255
|
-
.filter((item) => item.status === "active" && item.groupId !== null)
|
|
256
|
-
.map((item) => item.groupId))].sort();
|
|
257
|
-
|
|
258
|
-
for (const groupId of activeGroupIds) {
|
|
259
|
-
const existing = graph.entities.find((item) => item.id === groupId);
|
|
260
|
-
if (existing && existing.kind !== "group") {
|
|
261
|
-
throw new Error(`authenticated persona group conflicts with a non-group entity: ${groupId}`);
|
|
262
|
-
}
|
|
263
|
-
if (existing?.privacy === "private") {
|
|
264
|
-
throw new Error(`authenticated persona group cannot use private relationship visibility: ${groupId}`);
|
|
265
|
-
}
|
|
266
|
-
if (!existing) {
|
|
267
|
-
await upsertEntity({ root: paths.catalog.root, id: groupId, kind: "group", privacy: "group",
|
|
268
|
-
attributes: { identitySource: "authenticated-persona-roster" }, confidence: 1, catalog: paths.catalog });
|
|
269
|
-
changes.groupsCreated += 1;
|
|
270
|
-
graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
for (const persona of runtime.personas) {
|
|
275
|
-
const binding = policy.bindings.find((item) => item.id === persona.bindingId);
|
|
276
|
-
let existing = graph.entities.find((item) => item.id === persona.personaId);
|
|
277
|
-
if (existing && existing.kind !== (persona.kind === "bot" ? "agent" : persona.kind)) {
|
|
278
|
-
throw new Error(`authenticated persona conflicts with an existing entity kind: ${persona.personaId}`);
|
|
279
|
-
}
|
|
280
|
-
if (!sameRuntimeEntity(existing, persona, binding)) {
|
|
281
|
-
await upsertEntity({ root: paths.catalog.root, id: persona.personaId,
|
|
282
|
-
kind: persona.kind === "bot" ? "agent" : persona.kind, displayName: persona.displayName,
|
|
283
|
-
aliases: existing?.aliases || [], attributes: runtimeEntityAttributes(persona, binding, existing),
|
|
284
|
-
sourceDocument: existing?.sourceDocument
|
|
285
|
-
&& paths.catalog.documents.some((item) => item.relativePath === existing.sourceDocument)
|
|
286
|
-
? existing.sourceDocument : null,
|
|
287
|
-
privacy: persona.groupId ? "group" : "shared", confidence: 1, catalog: paths.catalog });
|
|
288
|
-
changes.entitiesUpdated += 1;
|
|
289
|
-
graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
|
|
290
|
-
existing = graph.entities.find((item) => item.id === persona.personaId);
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const memberships = graph.entityEdges.filter((edge) => edge.from === persona.personaId && edge.relation === "member-of");
|
|
294
|
-
for (const edge of memberships.filter((item) => persona.status !== "active" || item.to !== persona.groupId)) {
|
|
295
|
-
await unlinkEntities({ root: paths.catalog.root, from: edge.from, to: edge.to, relation: edge.relation,
|
|
296
|
-
catalog: paths.catalog });
|
|
297
|
-
changes.membershipsRemoved += 1;
|
|
298
|
-
graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
|
|
299
|
-
}
|
|
300
|
-
if (persona.status === "active" && persona.groupId !== null
|
|
301
|
-
&& !graph.entityEdges.some((edge) => edge.from === persona.personaId && edge.to === persona.groupId
|
|
302
|
-
&& edge.relation === "member-of" && edge.privacy === "group")) {
|
|
303
|
-
await linkEntities({ root: paths.catalog.root, from: persona.personaId, to: persona.groupId,
|
|
304
|
-
relation: "member-of", reason: "Authenticated roster membership; context only.", confidence: 1, privacy: "group",
|
|
305
|
-
catalog: paths.catalog });
|
|
306
|
-
changes.membershipsAdded += 1;
|
|
307
|
-
graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
return changes;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export async function applyPersonaRoster({
|
|
314
|
-
root = process.cwd(), bindings, rosterScopes = [], confirmation, now = new Date(), catalog: providedCatalog = null
|
|
315
|
-
}) {
|
|
316
|
-
if (confirmation !== CONFIRMATION) throw new Error("persona roster changes require explicit local owner confirmation");
|
|
317
|
-
if (!Array.isArray(bindings) || bindings.length > 256 || (!bindings.length && !rosterScopes.length)) {
|
|
318
|
-
throw new Error("bindings must contain up to 256 persona bindings or one explicit roster scope");
|
|
319
|
-
}
|
|
320
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
321
|
-
return withLock(paths, async () => {
|
|
322
|
-
const [policy, runtime] = await Promise.all([
|
|
323
|
-
readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
324
|
-
readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime)
|
|
325
|
-
]);
|
|
326
|
-
const observedAt = at(now);
|
|
327
|
-
const normalized = bindings.map((item) => normalizeBinding(item, observedAt));
|
|
328
|
-
let changed = false;
|
|
329
|
-
const scopedRosterKeys = new Set([
|
|
330
|
-
...normalized.map((item) => [item.authenticator, item.issuer, item.tenantId, item.host, item.profileId].join("\0")),
|
|
331
|
-
...rosterScopes.map((item) => [item.authenticator, item.issuer, item.tenantId, item.host, item.profileId].join("\0"))
|
|
332
|
-
]);
|
|
333
|
-
const suppliedIds = new Set(normalized.map((item) => item.id));
|
|
334
|
-
for (const missing of policy.bindings.filter((item) => item.active && scopedRosterKeys.has([
|
|
335
|
-
item.authenticator, item.issuer, item.tenantId, item.host, item.profileId
|
|
336
|
-
].join("\0")) && !suppliedIds.has(item.id))) {
|
|
337
|
-
const retired = { ...missing, active: false, deactivated: false, updatedAt: observedAt };
|
|
338
|
-
normalized.push(retired);
|
|
339
|
-
}
|
|
340
|
-
const identities = new Set();
|
|
341
|
-
for (const binding of normalized) {
|
|
342
|
-
const key = identityMaterial(binding);
|
|
343
|
-
if (identities.has(key)) throw new Error("persona roster contains a duplicate authenticated identity");
|
|
344
|
-
identities.add(key);
|
|
345
|
-
const previousBinding = policy.bindings.find((item) => item.id === binding.id);
|
|
346
|
-
if (previousBinding && previousBinding.personaId !== binding.personaId) throw new Error("a binding ID cannot be reassigned to another authenticated identity");
|
|
347
|
-
const unchangedBinding = previousBinding && ["authenticator", "issuer", "tenantId", "host", "profileId", "subjectId", "kind",
|
|
348
|
-
"displayName", "sourceBinding", "groupId", "active", "deactivated", "personaId"].every((key) => previousBinding[key] === binding[key]);
|
|
349
|
-
if (unchangedBinding) continue;
|
|
350
|
-
changed = true;
|
|
351
|
-
if (previousBinding) {
|
|
352
|
-
policy.history.push({ recordId: previousBinding.id, supersededAt: observedAt, value: structuredClone(previousBinding), authority: "authenticated-identity-policy" });
|
|
353
|
-
binding.createdAt = previousBinding.createdAt;
|
|
354
|
-
}
|
|
355
|
-
policy.bindings = policy.bindings.filter((item) => item.id !== binding.id);
|
|
356
|
-
policy.bindings.push(binding);
|
|
357
|
-
const previous = runtime.personas.find((item) => item.personaId === binding.personaId);
|
|
358
|
-
const types = nextEventTypes(previous, binding);
|
|
359
|
-
let sequence = previous?.sequence || 0;
|
|
360
|
-
for (const type of types) {
|
|
361
|
-
sequence += 1;
|
|
362
|
-
const event = eventPayload(binding, type, sequence, observedAt);
|
|
363
|
-
if (!runtime.receipts.some((item) => item.eventId === event.eventId)) {
|
|
364
|
-
runtime.events.push(event);
|
|
365
|
-
runtime.receipts.push(eventReceipt(event));
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
if (types.length) {
|
|
369
|
-
runtime.personas = runtime.personas.filter((item) => item.personaId !== binding.personaId);
|
|
370
|
-
runtime.personas.push({ personaId: binding.personaId, bindingId: binding.id, kind: binding.kind,
|
|
371
|
-
displayName: binding.displayName, groupId: binding.groupId,
|
|
372
|
-
status: binding.active ? "active" : binding.deactivated ? "deactivated" : "left",
|
|
373
|
-
sequence, updatedAt: observedAt, authority: "identity-state-only" });
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
if (changed) {
|
|
377
|
-
policy.bindings.sort((a, b) => a.id.localeCompare(b.id));
|
|
378
|
-
runtime.personas.sort((a, b) => a.personaId.localeCompare(b.personaId));
|
|
379
|
-
runtime.events.sort((a, b) => a.observedAt.localeCompare(b.observedAt) || a.eventId.localeCompare(b.eventId));
|
|
380
|
-
policy.revision += 1;
|
|
381
|
-
runtime.revision += 1;
|
|
382
|
-
await Promise.all([writeJson(paths.personaPolicyPath, policy), writeJson(paths.personaRuntimePath, runtime)]);
|
|
383
|
-
}
|
|
384
|
-
const graphChanges = await reconcilePersonaGraph(paths, policy, runtime);
|
|
385
|
-
const graphReconciled = Object.values(graphChanges).some((value) => value > 0);
|
|
386
|
-
return { policy, runtime, personaPolicyPath: paths.personaPolicyPath,
|
|
387
|
-
personaRuntimePath: paths.personaRuntimePath, duplicate: !changed, graphReconciled, graphChanges };
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
function normalizeNativeDiscovery(value) {
|
|
392
|
-
if (!value || typeof value !== "object" || !NATIVE_HOSTS.has(value.host) || !NATIVE_SCOPES.has(value.scope)) {
|
|
393
|
-
throw new Error("native persona discovery requires a supported host and user or project scope");
|
|
394
|
-
}
|
|
395
|
-
return {
|
|
396
|
-
id: exactId(value.id, "nativeDiscovery.id"), host: value.host, scope: value.scope,
|
|
397
|
-
authenticator: "host-manifest", issuer: exactId(value.issuer, "nativeDiscovery.issuer"),
|
|
398
|
-
tenantId: exactId(value.tenantId, "nativeDiscovery.tenantId"),
|
|
399
|
-
profileId: exactId(value.profileId, "nativeDiscovery.profileId"),
|
|
400
|
-
kind: value.kind === "bot" ? "bot" : "agent", groupId: exactId(value.groupId, "nativeDiscovery.groupId", true)
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function nativeAgentDirectory(scope, catalog, env) {
|
|
405
|
-
if (scope.scope === "project") return join(catalog.root, scope.host === "claude" ? ".claude" : ".codex", "agents");
|
|
406
|
-
const home = scope.host === "claude" ? env.CLAUDE_CONFIG_DIR
|
|
407
|
-
: env.CODEX_HOME || env.BLUN_HOME;
|
|
408
|
-
if (home) {
|
|
409
|
-
if (!isAbsolute(home)) throw new Error(scope.host + " home override must be absolute");
|
|
410
|
-
return join(home, "agents");
|
|
411
|
-
}
|
|
412
|
-
const userHome = env.HOME || env.USERPROFILE;
|
|
413
|
-
if (!userHome || !isAbsolute(userHome)) throw new Error("native persona discovery requires an absolute host home");
|
|
414
|
-
return join(userHome, scope.host === "claude" ? ".claude" : ".codex", "agents");
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function manifestName(host, content) {
|
|
418
|
-
if (host === "claude") {
|
|
419
|
-
const frontmatter = content.startsWith("---") ? content.slice(3, content.indexOf("\n---", 3) + 1) : "";
|
|
420
|
-
const match = frontmatter.match(/^\s*name\s*:\s*['\"]?([^'\"\r\n]+)['\"]?\s*$/mi);
|
|
421
|
-
return match ? exactText(match[1], "manifest name") : null;
|
|
422
|
-
}
|
|
423
|
-
const match = content.match(/^\s*name\s*=\s*['\"]([^'\"\r\n]+)['\"]\s*$/mi);
|
|
424
|
-
return match ? exactText(match[1], "manifest name") : null;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
async function nativeManifestBindings(scope, catalog, env) {
|
|
428
|
-
const directory = nativeAgentDirectory(scope, catalog, env);
|
|
429
|
-
let metadata;
|
|
430
|
-
try { metadata = await lstat(directory); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
431
|
-
if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw new Error("native persona directory must be a regular non-symlink directory");
|
|
432
|
-
const canonicalDirectory = await realpath(directory);
|
|
433
|
-
const stream = await opendir(canonicalDirectory); const entries = [];
|
|
434
|
-
try { for await (const entry of stream) entries.push(entry); } finally { await stream.close().catch(() => {}); }
|
|
435
|
-
const extension = scope.host === "claude" ? ".md" : ".toml";
|
|
436
|
-
const candidates = entries.filter((entry) => entry.isFile() && !entry.isSymbolicLink() && entry.name.endsWith(extension))
|
|
437
|
-
.sort((left, right) => left.name.localeCompare(right.name));
|
|
438
|
-
if (candidates.length > 128) throw new Error("native persona directory exceeds 128 direct manifests");
|
|
439
|
-
const bindings = [];
|
|
440
|
-
for (const entry of candidates) {
|
|
441
|
-
const path = join(canonicalDirectory, entry.name);
|
|
442
|
-
const pathBefore = await lstat(path);
|
|
443
|
-
if (pathBefore.isSymbolicLink() || !pathBefore.isFile()) throw new Error("native persona manifest must be a regular non-symlink file");
|
|
444
|
-
const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
445
|
-
try {
|
|
446
|
-
const before = await handle.stat();
|
|
447
|
-
if (!before.isFile() || before.size > 256 * 1024) throw new Error("native persona manifest exceeds 256 KiB");
|
|
448
|
-
const content = await handle.readFile("utf8");
|
|
449
|
-
const after = await handle.stat();
|
|
450
|
-
const pathAfter = await lstat(path);
|
|
451
|
-
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
452
|
-
throw new Error("native persona manifest changed during read");
|
|
453
|
-
}
|
|
454
|
-
if (pathAfter.isSymbolicLink() || !pathAfter.isFile() || pathAfter.dev !== before.dev || pathAfter.ino !== before.ino) {
|
|
455
|
-
throw new Error("native persona manifest was exchanged during read");
|
|
456
|
-
}
|
|
457
|
-
const displayName = manifestName(scope.host, content);
|
|
458
|
-
if (!displayName) continue;
|
|
459
|
-
const sourceBinding = `${scope.host}:${scope.scope}-agents/${entry.name}`;
|
|
460
|
-
const subjectId = "manifest:" + digest([scope.host, scope.profileId, sourceBinding].join("\0")).slice(0, 32);
|
|
461
|
-
bindings.push({ id: "persona-binding:native:" + digest(scope.id + "\0" + sourceBinding).slice(0, 24),
|
|
462
|
-
authenticator: "host-manifest", issuer: scope.issuer, tenantId: scope.tenantId, host: scope.host,
|
|
463
|
-
profileId: scope.profileId, subjectId, kind: scope.kind, displayName, sourceBinding, groupId: scope.groupId });
|
|
464
|
-
} finally { await handle.close(); }
|
|
465
|
-
}
|
|
466
|
-
return bindings;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
export async function loadPersonaRuntime(root = process.cwd(), providedCatalog = null) {
|
|
470
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
471
|
-
const [policy, runtime] = await Promise.all([
|
|
472
|
-
readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
473
|
-
readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime)
|
|
474
|
-
]);
|
|
475
|
-
return { policy, runtime, ...paths };
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
export async function syncPersonaRosterFromEnvironment({
|
|
479
|
-
root = process.cwd(), env = process.env, now = new Date(), catalog: providedCatalog = null
|
|
480
|
-
} = {}) {
|
|
481
|
-
const configured = env.AGENTSPINE_PERSONA_ROSTER_FILE;
|
|
482
|
-
if (!configured) return { configured: false, changed: false };
|
|
483
|
-
if (!isAbsolute(configured)) throw new Error("AGENTSPINE_PERSONA_ROSTER_FILE must be an absolute path");
|
|
484
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
485
|
-
const supplied = await lstat(configured);
|
|
486
|
-
if (supplied.isSymbolicLink() || !supplied.isFile()) throw new Error("persona roster must be a regular non-symlink file");
|
|
487
|
-
const canonical = await realpath(configured);
|
|
488
|
-
if (isInside(catalog.root, canonical)) throw new Error("persona roster must remain outside the agent project");
|
|
489
|
-
const handle = await open(canonical, "r");
|
|
490
|
-
let value;
|
|
491
|
-
try {
|
|
492
|
-
const metadata = await handle.stat();
|
|
493
|
-
if (!metadata.isFile() || metadata.size > 1024 * 1024) throw new Error("persona roster exceeds its 1 MiB limit");
|
|
494
|
-
value = JSON.parse(await handle.readFile("utf8"));
|
|
495
|
-
} finally { await handle.close(); }
|
|
496
|
-
if (!value || value.schema !== PERSONA_ROSTER_SCHEMA || !Number.isInteger(value.revision)
|
|
497
|
-
|| value.revision < 1 || !Array.isArray(value.bindings || []) || !Array.isArray(value.nativeDiscovery || [])) {
|
|
498
|
-
throw new Error("persona roster envelope is invalid");
|
|
499
|
-
}
|
|
500
|
-
const nativeScopes = (value.nativeDiscovery || []).map(normalizeNativeDiscovery);
|
|
501
|
-
const nativeBindings = (await Promise.all(nativeScopes.map((scope) => nativeManifestBindings(scope, catalog, env)))).flat();
|
|
502
|
-
const bindings = [...(value.bindings || []), ...nativeBindings];
|
|
503
|
-
const rosterScopes = nativeScopes.map((scope) => ({ authenticator: "host-manifest", issuer: scope.issuer,
|
|
504
|
-
tenantId: scope.tenantId, host: scope.host, profileId: scope.profileId }));
|
|
505
|
-
const result = await applyPersonaRoster({ root: catalog.root, bindings, rosterScopes,
|
|
506
|
-
confirmation: CONFIRMATION, now: value.observedAt || now, catalog });
|
|
507
|
-
return { configured: true, changed: !result.duplicate || result.graphReconciled,
|
|
508
|
-
rosterChanged: !result.duplicate, graphReconciled: result.graphReconciled, graphChanges: result.graphChanges,
|
|
509
|
-
revision: value.revision,
|
|
510
|
-
personas: result.runtime.personas.length, nativeManifests: nativeBindings.length,
|
|
511
|
-
rosterDigest: digest(JSON.stringify(value)) };
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
export async function inspectPersonaRuntime(root = process.cwd(), providedCatalog = null) {
|
|
515
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
516
|
-
const errors = [];
|
|
517
|
-
let policy = emptyPolicy(paths.catalog.root);
|
|
518
|
-
let runtime = emptyRuntime(paths.catalog.root);
|
|
519
|
-
try { policy = await readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push("policy:" + error.message); }
|
|
520
|
-
try { runtime = await readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime); } catch (error) { errors.push("runtime:" + error.message); }
|
|
521
|
-
return { policy, runtime, errors, ...paths };
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
export function personaRuntimeFindings(policy, runtime, graph = null) {
|
|
525
|
-
const findings = [];
|
|
526
|
-
const bindingIds = new Set();
|
|
527
|
-
const personaIds = new Set();
|
|
528
|
-
for (const binding of policy.bindings) {
|
|
529
|
-
if (!validBinding(binding)) findings.push("invalid-persona-binding:" + (binding?.id || "unknown"));
|
|
530
|
-
if (bindingIds.has(binding.id)) findings.push("duplicate-persona-binding:" + binding.id);
|
|
531
|
-
bindingIds.add(binding.id);
|
|
532
|
-
}
|
|
533
|
-
for (const persona of runtime.personas) {
|
|
534
|
-
if (!validPersona(persona)) findings.push("invalid-persona:" + (persona?.personaId || "unknown"));
|
|
535
|
-
if (personaIds.has(persona.personaId)) findings.push("duplicate-persona:" + persona.personaId);
|
|
536
|
-
if (!bindingIds.has(persona.bindingId)) findings.push("orphan-persona:" + persona.personaId);
|
|
537
|
-
if (graph) {
|
|
538
|
-
const entity = graph.entities.find((item) => item.id === persona.personaId);
|
|
539
|
-
const expectedKind = persona.kind === "bot" ? "agent" : persona.kind;
|
|
540
|
-
if (!entity || entity.kind !== expectedKind) findings.push("persona-graph-mismatch:" + persona.personaId);
|
|
541
|
-
if (entity?.attributes?.identityStatus !== persona.status) findings.push("persona-graph-status-mismatch:" + persona.personaId);
|
|
542
|
-
const memberships = graph.entityEdges.filter((edge) => edge.from === persona.personaId && edge.relation === "member-of");
|
|
543
|
-
const group = persona.groupId === null ? null : graph.entities.find((item) => item.id === persona.groupId);
|
|
544
|
-
if (persona.status === "active" && persona.groupId !== null
|
|
545
|
-
&& (!group || group.kind !== "group" || group.privacy === "private")) {
|
|
546
|
-
findings.push("persona-group-entity-mismatch:" + persona.personaId);
|
|
547
|
-
}
|
|
548
|
-
if (persona.status === "active" && persona.groupId !== null
|
|
549
|
-
&& !memberships.some((edge) => edge.to === persona.groupId && edge.privacy === "group")) {
|
|
550
|
-
findings.push("persona-group-membership-mismatch:" + persona.personaId);
|
|
551
|
-
}
|
|
552
|
-
if (memberships.some((edge) => edge.to !== persona.groupId || persona.status !== "active")) {
|
|
553
|
-
findings.push("persona-stale-group-membership:" + persona.personaId);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
personaIds.add(persona.personaId);
|
|
557
|
-
}
|
|
558
|
-
const eventIds = new Set();
|
|
559
|
-
for (const event of runtime.events) {
|
|
560
|
-
if (!validEvent(event) || !personaIds.has(event.personaId)) findings.push("invalid-persona-event:" + (event?.eventId || "unknown"));
|
|
561
|
-
if (eventIds.has(event.eventId)) findings.push("duplicate-persona-event:" + event.eventId);
|
|
562
|
-
eventIds.add(event.eventId);
|
|
563
|
-
}
|
|
564
|
-
const receiptIds = new Set();
|
|
565
|
-
for (const receipt of runtime.receipts) {
|
|
566
|
-
if (!validReceipt(receipt) || !eventIds.has(receipt.eventId)) findings.push("invalid-persona-receipt:" + (receipt?.id || "unknown"));
|
|
567
|
-
if (receiptIds.has(receipt.id)) findings.push("duplicate-persona-receipt:" + receipt.id);
|
|
568
|
-
receiptIds.add(receipt.id);
|
|
569
|
-
}
|
|
570
|
-
return findings;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
export async function personaContext({ root = process.cwd(), personaId = null, groupId = undefined, includeInactive = false } = {}) {
|
|
574
|
-
const { policy, runtime } = await loadPersonaRuntime(root);
|
|
575
|
-
const findings = personaRuntimeFindings(policy, runtime);
|
|
576
|
-
if (findings.length) throw new Error("persona runtime failed closed: " + findings.join(", "));
|
|
577
|
-
const items = runtime.personas.filter((item) => (includeInactive || item.status === "active")
|
|
578
|
-
&& (personaId === null || item.personaId === exactId(personaId, "personaId"))
|
|
579
|
-
&& (groupId === undefined || item.groupId === exactId(groupId, "groupId", true)));
|
|
580
|
-
return { schema: "agentspine.persona-context/v1", items: structuredClone(items), authority: "identity-state-only" };
|
|
581
|
-
}
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants as fsConstants } from "node:fs";
|
|
3
|
+
import { lstat, open, opendir, readFile, realpath, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
import { buildCatalog } from "./catalog.js";
|
|
6
|
+
import { isFileLockContention, replaceFileWithRetry } from "./filesystem-retry.js";
|
|
7
|
+
import { linkEntities, loadGraph, unlinkEntities, upsertEntity } from "./graph.js";
|
|
8
|
+
import { isInside, projectStateDir } from "./paths.js";
|
|
9
|
+
|
|
10
|
+
export const PERSONA_POLICY_SCHEMA = "agentspine.persona-policy/v1";
|
|
11
|
+
export const PERSONA_RUNTIME_SCHEMA = "agentspine.persona-runtime/v1";
|
|
12
|
+
export const PERSONA_EVENT_SCHEMA = "agentspine.persona-event/v1";
|
|
13
|
+
export const PERSONA_ROSTER_SCHEMA = "agentspine.persona-roster/v1";
|
|
14
|
+
|
|
15
|
+
const CONFIRMATION = "local-owner-confirmed";
|
|
16
|
+
const MAX_BYTES = 5 * 1024 * 1024;
|
|
17
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/+~-]{0,255}$/;
|
|
18
|
+
const KINDS = new Set(["person", "agent", "bot"]);
|
|
19
|
+
const EVENTS = new Set(["join", "rename", "group-change", "leave", "deactivate", "rejoin"]);
|
|
20
|
+
const ACTIVE = new Set(["active", "left", "deactivated"]);
|
|
21
|
+
const AUTHENTICATORS = new Set(["host-manifest", "oauth-subject", "local-roster", "signed-roster"]);
|
|
22
|
+
const NATIVE_HOSTS = new Set(["claude", "codex"]);
|
|
23
|
+
const NATIVE_SCOPES = new Set(["user", "project"]);
|
|
24
|
+
const FORBIDDEN = /\b(?:permission|rights?|roles?|owner|trusted|reports[- ]to|responsible[- ]for|delegat|token|secret|credential|api[-_ ]?key|network|production|payment|send capability|tool capability)\b/i;
|
|
25
|
+
|
|
26
|
+
function emptyPolicy(root) {
|
|
27
|
+
return { schema: PERSONA_POLICY_SCHEMA, root, revision: 0, bindings: [], history: [] };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function emptyRuntime(root) {
|
|
31
|
+
return { schema: PERSONA_RUNTIME_SCHEMA, root, revision: 0, personas: [], events: [], receipts: [] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function exactId(value, field, nullable = false) {
|
|
35
|
+
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
36
|
+
if (typeof value !== "string" || !ID_RE.test(value) || value.includes("*")) {
|
|
37
|
+
throw new Error(field + " must be an exact stable ID without wildcards");
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function exactText(value, field, maximum = 200) {
|
|
43
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(field + " is required");
|
|
44
|
+
const text = value.trim().replace(/\s+/g, " ").slice(0, maximum);
|
|
45
|
+
if (FORBIDDEN.test(text)) throw new Error(field + " contains authority or secret-shaped content");
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sourceText(value) {
|
|
50
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) throw new Error("sourceBinding is invalid");
|
|
51
|
+
return value.trim().replaceAll("\\", "/").slice(0, 500);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function at(value = new Date()) {
|
|
55
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
56
|
+
if (!Number.isFinite(date.getTime())) throw new Error("timestamp is invalid");
|
|
57
|
+
return date.toISOString();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function digest(value) {
|
|
61
|
+
return createHash("sha256").update(value).digest("hex");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function identityMaterial(binding) {
|
|
65
|
+
return [binding.authenticator, binding.issuer, binding.tenantId, binding.host, binding.profileId,
|
|
66
|
+
binding.subjectId, binding.kind].join("\0");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stablePersonaId(binding) {
|
|
70
|
+
return binding.kind + ":runtime:" + digest(identityMaterial(binding)).slice(0, 32);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function validBinding(binding) {
|
|
74
|
+
return binding && ID_RE.test(binding.id || "") && KINDS.has(binding.kind)
|
|
75
|
+
&& AUTHENTICATORS.has(binding.authenticator) && ID_RE.test(binding.issuer || "")
|
|
76
|
+
&& ID_RE.test(binding.tenantId || "") && ID_RE.test(binding.host || "")
|
|
77
|
+
&& ID_RE.test(binding.profileId || "") && ID_RE.test(binding.subjectId || "")
|
|
78
|
+
&& ID_RE.test(binding.personaId || "") && binding.personaId === stablePersonaId(binding)
|
|
79
|
+
&& typeof binding.displayName === "string" && binding.displayName.length > 0 && !FORBIDDEN.test(binding.displayName)
|
|
80
|
+
&& (binding.sourceBinding === null || typeof binding.sourceBinding === "string")
|
|
81
|
+
&& (binding.groupId === null || ID_RE.test(binding.groupId || ""))
|
|
82
|
+
&& typeof binding.active === "boolean" && typeof binding.deactivated === "boolean"
|
|
83
|
+
&& !(binding.active && binding.deactivated) && binding.authority === "authenticated-identity-policy"
|
|
84
|
+
&& Number.isFinite(new Date(binding.createdAt).getTime())
|
|
85
|
+
&& Number.isFinite(new Date(binding.updatedAt).getTime());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validPersona(persona) {
|
|
89
|
+
return persona && ID_RE.test(persona.personaId || "") && ID_RE.test(persona.bindingId || "")
|
|
90
|
+
&& KINDS.has(persona.kind) && ACTIVE.has(persona.status)
|
|
91
|
+
&& typeof persona.displayName === "string" && persona.displayName.length > 0
|
|
92
|
+
&& (persona.groupId === null || ID_RE.test(persona.groupId || ""))
|
|
93
|
+
&& Number.isInteger(persona.sequence) && persona.sequence >= 1
|
|
94
|
+
&& persona.authority === "identity-state-only" && Number.isFinite(new Date(persona.updatedAt).getTime());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function validEvent(event) {
|
|
98
|
+
if (!(event && event.schema === PERSONA_EVENT_SCHEMA && ID_RE.test(event.eventId || "")
|
|
99
|
+
&& ID_RE.test(event.personaId || "") && ID_RE.test(event.bindingId || "")
|
|
100
|
+
&& EVENTS.has(event.type) && Number.isInteger(event.sequence) && event.sequence >= 1
|
|
101
|
+
&& Number.isFinite(new Date(event.observedAt).getTime())
|
|
102
|
+
&& /^[a-f0-9]{64}$/.test(event.payloadDigest || "")
|
|
103
|
+
&& event.authority === "identity-state-only")) return false;
|
|
104
|
+
const payload = { personaId: event.personaId, bindingId: event.bindingId, type: event.type, sequence: event.sequence,
|
|
105
|
+
displayName: event.displayName, groupId: event.groupId, observedAt: event.observedAt };
|
|
106
|
+
const payloadDigest = digest(JSON.stringify(payload));
|
|
107
|
+
return event.payloadDigest === payloadDigest && event.eventId === "persona-event:" + payloadDigest.slice(0, 32);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validReceipt(receipt) {
|
|
111
|
+
if (!(receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
|
|
112
|
+
&& /^[a-f0-9]{64}$/.test(receipt.payloadDigest || "") && /^[a-f0-9]{64}$/.test(receipt.digest || "")
|
|
113
|
+
&& Number.isFinite(new Date(receipt.at).getTime()) && receipt.authority === "identity-state-only")) return false;
|
|
114
|
+
const material = { eventId: receipt.eventId, payloadDigest: receipt.payloadDigest, at: receipt.at,
|
|
115
|
+
authority: "identity-state-only" };
|
|
116
|
+
return receipt.digest === digest(JSON.stringify(material)) && receipt.id === "persona-receipt:" + receipt.digest.slice(0, 24);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizePolicy(value, root) {
|
|
120
|
+
if (!value || value.schema !== PERSONA_POLICY_SCHEMA || value.root !== root
|
|
121
|
+
|| !Number.isInteger(value.revision) || !Array.isArray(value.bindings) || !Array.isArray(value.history)
|
|
122
|
+
|| value.bindings.some((item) => !validBinding(item))
|
|
123
|
+
|| value.history.some((item) => !item || !validBinding(item.value) || item.authority !== "authenticated-identity-policy")) {
|
|
124
|
+
throw new Error("persona policy is invalid; automatic persona synchronization is disabled");
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeRuntime(value, root) {
|
|
130
|
+
if (!value || value.schema !== PERSONA_RUNTIME_SCHEMA || value.root !== root
|
|
131
|
+
|| !Number.isInteger(value.revision) || !Array.isArray(value.personas)
|
|
132
|
+
|| !Array.isArray(value.events) || !Array.isArray(value.receipts)
|
|
133
|
+
|| value.personas.some((item) => !validPersona(item)) || value.events.some((item) => !validEvent(item))
|
|
134
|
+
|| value.receipts.some((item) => !validReceipt(item))) {
|
|
135
|
+
throw new Error("persona runtime is invalid; persona context is disabled");
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function pathsFor(root, providedCatalog = null) {
|
|
141
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
142
|
+
const directory = await projectStateDir(catalog.root);
|
|
143
|
+
return { catalog, directory, personaPolicyPath: join(directory, "persona-policy.json"), personaRuntimePath: join(directory, "persona-runtime.json") };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function readJson(path, root, normalize, empty) {
|
|
147
|
+
try {
|
|
148
|
+
const metadata = await stat(path);
|
|
149
|
+
if (metadata.size > MAX_BYTES) throw new Error("persona state exceeds 5 MiB");
|
|
150
|
+
return normalize(JSON.parse(await readFile(path, "utf8")), root);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error.code !== "ENOENT") throw error;
|
|
153
|
+
return empty(root);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function writeJson(path, value) {
|
|
158
|
+
const content = JSON.stringify(value, null, 2) + "\n";
|
|
159
|
+
if (Buffer.byteLength(content) > MAX_BYTES) throw new Error("persona state exceeds 5 MiB");
|
|
160
|
+
const temporary = path + "." + process.pid + "." + randomUUID() + ".tmp";
|
|
161
|
+
try {
|
|
162
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
163
|
+
await replaceFileWithRetry(temporary, path);
|
|
164
|
+
} finally {
|
|
165
|
+
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function withLock(paths, task) {
|
|
170
|
+
const lockPath = join(paths.directory, "persona-runtime.lock");
|
|
171
|
+
let handle;
|
|
172
|
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
173
|
+
try { handle = await open(lockPath, "wx", 0o600); break; } catch (error) {
|
|
174
|
+
if (!isFileLockContention(error)) throw error;
|
|
175
|
+
try {
|
|
176
|
+
const metadata = await stat(lockPath);
|
|
177
|
+
if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
|
|
178
|
+
} catch (lockError) { if (lockError.code !== "ENOENT") throw lockError; }
|
|
179
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!handle) throw new Error("persona state is busy; retry later");
|
|
183
|
+
try { return await task(); } finally {
|
|
184
|
+
await handle.close();
|
|
185
|
+
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeBinding(input, timestamp) {
|
|
190
|
+
const binding = {
|
|
191
|
+
id: exactId(input.id || "persona-binding:" + randomUUID(), "bindingId"),
|
|
192
|
+
authenticator: AUTHENTICATORS.has(input.authenticator) ? input.authenticator : null,
|
|
193
|
+
issuer: exactId(input.issuer, "issuer"), tenantId: exactId(input.tenantId, "tenantId"),
|
|
194
|
+
host: exactId(input.host, "host"), profileId: exactId(input.profileId, "profileId"),
|
|
195
|
+
subjectId: exactId(input.subjectId, "subjectId"), kind: input.kind,
|
|
196
|
+
displayName: exactText(input.displayName, "displayName"),
|
|
197
|
+
sourceBinding: input.sourceBinding === null || input.sourceBinding === undefined ? null : sourceText(input.sourceBinding),
|
|
198
|
+
groupId: exactId(input.groupId, "groupId", true), deactivated: input.deactivated === true,
|
|
199
|
+
active: input.deactivated === true ? false : input.active !== false,
|
|
200
|
+
createdAt: timestamp, updatedAt: timestamp, authority: "authenticated-identity-policy"
|
|
201
|
+
};
|
|
202
|
+
if (!binding.authenticator || !KINDS.has(binding.kind)) throw new Error("persona binding requires an approved authenticator and person, agent, or bot kind");
|
|
203
|
+
binding.personaId = stablePersonaId(binding);
|
|
204
|
+
return binding;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function eventPayload(binding, type, sequence, observedAt) {
|
|
208
|
+
const payload = { personaId: binding.personaId, bindingId: binding.id, type, sequence, displayName: binding.displayName,
|
|
209
|
+
groupId: binding.groupId, observedAt };
|
|
210
|
+
return { schema: PERSONA_EVENT_SCHEMA, eventId: "persona-event:" + digest(JSON.stringify(payload)).slice(0, 32),
|
|
211
|
+
...payload, payloadDigest: digest(JSON.stringify(payload)), authority: "identity-state-only" };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function eventReceipt(event) {
|
|
215
|
+
const material = { eventId: event.eventId, payloadDigest: event.payloadDigest, at: event.observedAt,
|
|
216
|
+
authority: "identity-state-only" };
|
|
217
|
+
const receiptDigest = digest(JSON.stringify(material));
|
|
218
|
+
return { id: "persona-receipt:" + receiptDigest.slice(0, 24), ...material, digest: receiptDigest };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function nextEventTypes(previous, binding) {
|
|
222
|
+
if (!previous) return ["join"];
|
|
223
|
+
if (!binding.active && previous.status === "active") return [binding.deactivated ? "deactivate" : "leave"];
|
|
224
|
+
if (!binding.active) return [];
|
|
225
|
+
const types = previous.status !== "active" ? ["rejoin"] : [];
|
|
226
|
+
if (previous.displayName !== binding.displayName) types.push("rename");
|
|
227
|
+
if (previous.groupId !== binding.groupId) types.push("group-change");
|
|
228
|
+
return types;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function runtimeEntityAttributes(persona, binding, existing) {
|
|
232
|
+
return {
|
|
233
|
+
...(existing?.attributes || {}),
|
|
234
|
+
runtimeKind: persona.kind,
|
|
235
|
+
identityBindingId: persona.bindingId,
|
|
236
|
+
identityStatus: persona.status,
|
|
237
|
+
sourceBinding: binding?.sourceBinding || null
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function sameRuntimeEntity(entity, persona, binding) {
|
|
242
|
+
if (!entity) return false;
|
|
243
|
+
const expectedKind = persona.kind === "bot" ? "agent" : persona.kind;
|
|
244
|
+
const expectedPrivacy = persona.groupId ? "group" : "shared";
|
|
245
|
+
const attributes = runtimeEntityAttributes(persona, binding, entity);
|
|
246
|
+
return entity.kind === expectedKind && entity.displayName === persona.displayName
|
|
247
|
+
&& entity.privacy === expectedPrivacy
|
|
248
|
+
&& JSON.stringify(entity.attributes) === JSON.stringify(attributes);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function reconcilePersonaGraph(paths, policy, runtime) {
|
|
252
|
+
let { graph } = await loadGraph(paths.catalog.root, paths.catalog);
|
|
253
|
+
const changes = { groupsCreated: 0, entitiesUpdated: 0, membershipsAdded: 0, membershipsRemoved: 0 };
|
|
254
|
+
const activeGroupIds = [...new Set(runtime.personas
|
|
255
|
+
.filter((item) => item.status === "active" && item.groupId !== null)
|
|
256
|
+
.map((item) => item.groupId))].sort();
|
|
257
|
+
|
|
258
|
+
for (const groupId of activeGroupIds) {
|
|
259
|
+
const existing = graph.entities.find((item) => item.id === groupId);
|
|
260
|
+
if (existing && existing.kind !== "group") {
|
|
261
|
+
throw new Error(`authenticated persona group conflicts with a non-group entity: ${groupId}`);
|
|
262
|
+
}
|
|
263
|
+
if (existing?.privacy === "private") {
|
|
264
|
+
throw new Error(`authenticated persona group cannot use private relationship visibility: ${groupId}`);
|
|
265
|
+
}
|
|
266
|
+
if (!existing) {
|
|
267
|
+
await upsertEntity({ root: paths.catalog.root, id: groupId, kind: "group", privacy: "group",
|
|
268
|
+
attributes: { identitySource: "authenticated-persona-roster" }, confidence: 1, catalog: paths.catalog });
|
|
269
|
+
changes.groupsCreated += 1;
|
|
270
|
+
graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const persona of runtime.personas) {
|
|
275
|
+
const binding = policy.bindings.find((item) => item.id === persona.bindingId);
|
|
276
|
+
let existing = graph.entities.find((item) => item.id === persona.personaId);
|
|
277
|
+
if (existing && existing.kind !== (persona.kind === "bot" ? "agent" : persona.kind)) {
|
|
278
|
+
throw new Error(`authenticated persona conflicts with an existing entity kind: ${persona.personaId}`);
|
|
279
|
+
}
|
|
280
|
+
if (!sameRuntimeEntity(existing, persona, binding)) {
|
|
281
|
+
await upsertEntity({ root: paths.catalog.root, id: persona.personaId,
|
|
282
|
+
kind: persona.kind === "bot" ? "agent" : persona.kind, displayName: persona.displayName,
|
|
283
|
+
aliases: existing?.aliases || [], attributes: runtimeEntityAttributes(persona, binding, existing),
|
|
284
|
+
sourceDocument: existing?.sourceDocument
|
|
285
|
+
&& paths.catalog.documents.some((item) => item.relativePath === existing.sourceDocument)
|
|
286
|
+
? existing.sourceDocument : null,
|
|
287
|
+
privacy: persona.groupId ? "group" : "shared", confidence: 1, catalog: paths.catalog });
|
|
288
|
+
changes.entitiesUpdated += 1;
|
|
289
|
+
graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
|
|
290
|
+
existing = graph.entities.find((item) => item.id === persona.personaId);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const memberships = graph.entityEdges.filter((edge) => edge.from === persona.personaId && edge.relation === "member-of");
|
|
294
|
+
for (const edge of memberships.filter((item) => persona.status !== "active" || item.to !== persona.groupId)) {
|
|
295
|
+
await unlinkEntities({ root: paths.catalog.root, from: edge.from, to: edge.to, relation: edge.relation,
|
|
296
|
+
catalog: paths.catalog });
|
|
297
|
+
changes.membershipsRemoved += 1;
|
|
298
|
+
graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
|
|
299
|
+
}
|
|
300
|
+
if (persona.status === "active" && persona.groupId !== null
|
|
301
|
+
&& !graph.entityEdges.some((edge) => edge.from === persona.personaId && edge.to === persona.groupId
|
|
302
|
+
&& edge.relation === "member-of" && edge.privacy === "group")) {
|
|
303
|
+
await linkEntities({ root: paths.catalog.root, from: persona.personaId, to: persona.groupId,
|
|
304
|
+
relation: "member-of", reason: "Authenticated roster membership; context only.", confidence: 1, privacy: "group",
|
|
305
|
+
catalog: paths.catalog });
|
|
306
|
+
changes.membershipsAdded += 1;
|
|
307
|
+
graph = (await loadGraph(paths.catalog.root, paths.catalog)).graph;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return changes;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function applyPersonaRoster({
|
|
314
|
+
root = process.cwd(), bindings, rosterScopes = [], confirmation, now = new Date(), catalog: providedCatalog = null
|
|
315
|
+
}) {
|
|
316
|
+
if (confirmation !== CONFIRMATION) throw new Error("persona roster changes require explicit local owner confirmation");
|
|
317
|
+
if (!Array.isArray(bindings) || bindings.length > 256 || (!bindings.length && !rosterScopes.length)) {
|
|
318
|
+
throw new Error("bindings must contain up to 256 persona bindings or one explicit roster scope");
|
|
319
|
+
}
|
|
320
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
321
|
+
return withLock(paths, async () => {
|
|
322
|
+
const [policy, runtime] = await Promise.all([
|
|
323
|
+
readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
324
|
+
readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime)
|
|
325
|
+
]);
|
|
326
|
+
const observedAt = at(now);
|
|
327
|
+
const normalized = bindings.map((item) => normalizeBinding(item, observedAt));
|
|
328
|
+
let changed = false;
|
|
329
|
+
const scopedRosterKeys = new Set([
|
|
330
|
+
...normalized.map((item) => [item.authenticator, item.issuer, item.tenantId, item.host, item.profileId].join("\0")),
|
|
331
|
+
...rosterScopes.map((item) => [item.authenticator, item.issuer, item.tenantId, item.host, item.profileId].join("\0"))
|
|
332
|
+
]);
|
|
333
|
+
const suppliedIds = new Set(normalized.map((item) => item.id));
|
|
334
|
+
for (const missing of policy.bindings.filter((item) => item.active && scopedRosterKeys.has([
|
|
335
|
+
item.authenticator, item.issuer, item.tenantId, item.host, item.profileId
|
|
336
|
+
].join("\0")) && !suppliedIds.has(item.id))) {
|
|
337
|
+
const retired = { ...missing, active: false, deactivated: false, updatedAt: observedAt };
|
|
338
|
+
normalized.push(retired);
|
|
339
|
+
}
|
|
340
|
+
const identities = new Set();
|
|
341
|
+
for (const binding of normalized) {
|
|
342
|
+
const key = identityMaterial(binding);
|
|
343
|
+
if (identities.has(key)) throw new Error("persona roster contains a duplicate authenticated identity");
|
|
344
|
+
identities.add(key);
|
|
345
|
+
const previousBinding = policy.bindings.find((item) => item.id === binding.id);
|
|
346
|
+
if (previousBinding && previousBinding.personaId !== binding.personaId) throw new Error("a binding ID cannot be reassigned to another authenticated identity");
|
|
347
|
+
const unchangedBinding = previousBinding && ["authenticator", "issuer", "tenantId", "host", "profileId", "subjectId", "kind",
|
|
348
|
+
"displayName", "sourceBinding", "groupId", "active", "deactivated", "personaId"].every((key) => previousBinding[key] === binding[key]);
|
|
349
|
+
if (unchangedBinding) continue;
|
|
350
|
+
changed = true;
|
|
351
|
+
if (previousBinding) {
|
|
352
|
+
policy.history.push({ recordId: previousBinding.id, supersededAt: observedAt, value: structuredClone(previousBinding), authority: "authenticated-identity-policy" });
|
|
353
|
+
binding.createdAt = previousBinding.createdAt;
|
|
354
|
+
}
|
|
355
|
+
policy.bindings = policy.bindings.filter((item) => item.id !== binding.id);
|
|
356
|
+
policy.bindings.push(binding);
|
|
357
|
+
const previous = runtime.personas.find((item) => item.personaId === binding.personaId);
|
|
358
|
+
const types = nextEventTypes(previous, binding);
|
|
359
|
+
let sequence = previous?.sequence || 0;
|
|
360
|
+
for (const type of types) {
|
|
361
|
+
sequence += 1;
|
|
362
|
+
const event = eventPayload(binding, type, sequence, observedAt);
|
|
363
|
+
if (!runtime.receipts.some((item) => item.eventId === event.eventId)) {
|
|
364
|
+
runtime.events.push(event);
|
|
365
|
+
runtime.receipts.push(eventReceipt(event));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (types.length) {
|
|
369
|
+
runtime.personas = runtime.personas.filter((item) => item.personaId !== binding.personaId);
|
|
370
|
+
runtime.personas.push({ personaId: binding.personaId, bindingId: binding.id, kind: binding.kind,
|
|
371
|
+
displayName: binding.displayName, groupId: binding.groupId,
|
|
372
|
+
status: binding.active ? "active" : binding.deactivated ? "deactivated" : "left",
|
|
373
|
+
sequence, updatedAt: observedAt, authority: "identity-state-only" });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (changed) {
|
|
377
|
+
policy.bindings.sort((a, b) => a.id.localeCompare(b.id));
|
|
378
|
+
runtime.personas.sort((a, b) => a.personaId.localeCompare(b.personaId));
|
|
379
|
+
runtime.events.sort((a, b) => a.observedAt.localeCompare(b.observedAt) || a.eventId.localeCompare(b.eventId));
|
|
380
|
+
policy.revision += 1;
|
|
381
|
+
runtime.revision += 1;
|
|
382
|
+
await Promise.all([writeJson(paths.personaPolicyPath, policy), writeJson(paths.personaRuntimePath, runtime)]);
|
|
383
|
+
}
|
|
384
|
+
const graphChanges = await reconcilePersonaGraph(paths, policy, runtime);
|
|
385
|
+
const graphReconciled = Object.values(graphChanges).some((value) => value > 0);
|
|
386
|
+
return { policy, runtime, personaPolicyPath: paths.personaPolicyPath,
|
|
387
|
+
personaRuntimePath: paths.personaRuntimePath, duplicate: !changed, graphReconciled, graphChanges };
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function normalizeNativeDiscovery(value) {
|
|
392
|
+
if (!value || typeof value !== "object" || !NATIVE_HOSTS.has(value.host) || !NATIVE_SCOPES.has(value.scope)) {
|
|
393
|
+
throw new Error("native persona discovery requires a supported host and user or project scope");
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
id: exactId(value.id, "nativeDiscovery.id"), host: value.host, scope: value.scope,
|
|
397
|
+
authenticator: "host-manifest", issuer: exactId(value.issuer, "nativeDiscovery.issuer"),
|
|
398
|
+
tenantId: exactId(value.tenantId, "nativeDiscovery.tenantId"),
|
|
399
|
+
profileId: exactId(value.profileId, "nativeDiscovery.profileId"),
|
|
400
|
+
kind: value.kind === "bot" ? "bot" : "agent", groupId: exactId(value.groupId, "nativeDiscovery.groupId", true)
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function nativeAgentDirectory(scope, catalog, env) {
|
|
405
|
+
if (scope.scope === "project") return join(catalog.root, scope.host === "claude" ? ".claude" : ".codex", "agents");
|
|
406
|
+
const home = scope.host === "claude" ? env.CLAUDE_CONFIG_DIR
|
|
407
|
+
: env.CODEX_HOME || env.BLUN_HOME;
|
|
408
|
+
if (home) {
|
|
409
|
+
if (!isAbsolute(home)) throw new Error(scope.host + " home override must be absolute");
|
|
410
|
+
return join(home, "agents");
|
|
411
|
+
}
|
|
412
|
+
const userHome = env.HOME || env.USERPROFILE;
|
|
413
|
+
if (!userHome || !isAbsolute(userHome)) throw new Error("native persona discovery requires an absolute host home");
|
|
414
|
+
return join(userHome, scope.host === "claude" ? ".claude" : ".codex", "agents");
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function manifestName(host, content) {
|
|
418
|
+
if (host === "claude") {
|
|
419
|
+
const frontmatter = content.startsWith("---") ? content.slice(3, content.indexOf("\n---", 3) + 1) : "";
|
|
420
|
+
const match = frontmatter.match(/^\s*name\s*:\s*['\"]?([^'\"\r\n]+)['\"]?\s*$/mi);
|
|
421
|
+
return match ? exactText(match[1], "manifest name") : null;
|
|
422
|
+
}
|
|
423
|
+
const match = content.match(/^\s*name\s*=\s*['\"]([^'\"\r\n]+)['\"]\s*$/mi);
|
|
424
|
+
return match ? exactText(match[1], "manifest name") : null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function nativeManifestBindings(scope, catalog, env) {
|
|
428
|
+
const directory = nativeAgentDirectory(scope, catalog, env);
|
|
429
|
+
let metadata;
|
|
430
|
+
try { metadata = await lstat(directory); } catch (error) { if (error.code === "ENOENT") return []; throw error; }
|
|
431
|
+
if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw new Error("native persona directory must be a regular non-symlink directory");
|
|
432
|
+
const canonicalDirectory = await realpath(directory);
|
|
433
|
+
const stream = await opendir(canonicalDirectory); const entries = [];
|
|
434
|
+
try { for await (const entry of stream) entries.push(entry); } finally { await stream.close().catch(() => {}); }
|
|
435
|
+
const extension = scope.host === "claude" ? ".md" : ".toml";
|
|
436
|
+
const candidates = entries.filter((entry) => entry.isFile() && !entry.isSymbolicLink() && entry.name.endsWith(extension))
|
|
437
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
438
|
+
if (candidates.length > 128) throw new Error("native persona directory exceeds 128 direct manifests");
|
|
439
|
+
const bindings = [];
|
|
440
|
+
for (const entry of candidates) {
|
|
441
|
+
const path = join(canonicalDirectory, entry.name);
|
|
442
|
+
const pathBefore = await lstat(path);
|
|
443
|
+
if (pathBefore.isSymbolicLink() || !pathBefore.isFile()) throw new Error("native persona manifest must be a regular non-symlink file");
|
|
444
|
+
const handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
|
|
445
|
+
try {
|
|
446
|
+
const before = await handle.stat();
|
|
447
|
+
if (!before.isFile() || before.size > 256 * 1024) throw new Error("native persona manifest exceeds 256 KiB");
|
|
448
|
+
const content = await handle.readFile("utf8");
|
|
449
|
+
const after = await handle.stat();
|
|
450
|
+
const pathAfter = await lstat(path);
|
|
451
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
|
|
452
|
+
throw new Error("native persona manifest changed during read");
|
|
453
|
+
}
|
|
454
|
+
if (pathAfter.isSymbolicLink() || !pathAfter.isFile() || pathAfter.dev !== before.dev || pathAfter.ino !== before.ino) {
|
|
455
|
+
throw new Error("native persona manifest was exchanged during read");
|
|
456
|
+
}
|
|
457
|
+
const displayName = manifestName(scope.host, content);
|
|
458
|
+
if (!displayName) continue;
|
|
459
|
+
const sourceBinding = `${scope.host}:${scope.scope}-agents/${entry.name}`;
|
|
460
|
+
const subjectId = "manifest:" + digest([scope.host, scope.profileId, sourceBinding].join("\0")).slice(0, 32);
|
|
461
|
+
bindings.push({ id: "persona-binding:native:" + digest(scope.id + "\0" + sourceBinding).slice(0, 24),
|
|
462
|
+
authenticator: "host-manifest", issuer: scope.issuer, tenantId: scope.tenantId, host: scope.host,
|
|
463
|
+
profileId: scope.profileId, subjectId, kind: scope.kind, displayName, sourceBinding, groupId: scope.groupId });
|
|
464
|
+
} finally { await handle.close(); }
|
|
465
|
+
}
|
|
466
|
+
return bindings;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export async function loadPersonaRuntime(root = process.cwd(), providedCatalog = null) {
|
|
470
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
471
|
+
const [policy, runtime] = await Promise.all([
|
|
472
|
+
readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
473
|
+
readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime)
|
|
474
|
+
]);
|
|
475
|
+
return { policy, runtime, ...paths };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export async function syncPersonaRosterFromEnvironment({
|
|
479
|
+
root = process.cwd(), env = process.env, now = new Date(), catalog: providedCatalog = null
|
|
480
|
+
} = {}) {
|
|
481
|
+
const configured = env.AGENTSPINE_PERSONA_ROSTER_FILE;
|
|
482
|
+
if (!configured) return { configured: false, changed: false };
|
|
483
|
+
if (!isAbsolute(configured)) throw new Error("AGENTSPINE_PERSONA_ROSTER_FILE must be an absolute path");
|
|
484
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
485
|
+
const supplied = await lstat(configured);
|
|
486
|
+
if (supplied.isSymbolicLink() || !supplied.isFile()) throw new Error("persona roster must be a regular non-symlink file");
|
|
487
|
+
const canonical = await realpath(configured);
|
|
488
|
+
if (isInside(catalog.root, canonical)) throw new Error("persona roster must remain outside the agent project");
|
|
489
|
+
const handle = await open(canonical, "r");
|
|
490
|
+
let value;
|
|
491
|
+
try {
|
|
492
|
+
const metadata = await handle.stat();
|
|
493
|
+
if (!metadata.isFile() || metadata.size > 1024 * 1024) throw new Error("persona roster exceeds its 1 MiB limit");
|
|
494
|
+
value = JSON.parse(await handle.readFile("utf8"));
|
|
495
|
+
} finally { await handle.close(); }
|
|
496
|
+
if (!value || value.schema !== PERSONA_ROSTER_SCHEMA || !Number.isInteger(value.revision)
|
|
497
|
+
|| value.revision < 1 || !Array.isArray(value.bindings || []) || !Array.isArray(value.nativeDiscovery || [])) {
|
|
498
|
+
throw new Error("persona roster envelope is invalid");
|
|
499
|
+
}
|
|
500
|
+
const nativeScopes = (value.nativeDiscovery || []).map(normalizeNativeDiscovery);
|
|
501
|
+
const nativeBindings = (await Promise.all(nativeScopes.map((scope) => nativeManifestBindings(scope, catalog, env)))).flat();
|
|
502
|
+
const bindings = [...(value.bindings || []), ...nativeBindings];
|
|
503
|
+
const rosterScopes = nativeScopes.map((scope) => ({ authenticator: "host-manifest", issuer: scope.issuer,
|
|
504
|
+
tenantId: scope.tenantId, host: scope.host, profileId: scope.profileId }));
|
|
505
|
+
const result = await applyPersonaRoster({ root: catalog.root, bindings, rosterScopes,
|
|
506
|
+
confirmation: CONFIRMATION, now: value.observedAt || now, catalog });
|
|
507
|
+
return { configured: true, changed: !result.duplicate || result.graphReconciled,
|
|
508
|
+
rosterChanged: !result.duplicate, graphReconciled: result.graphReconciled, graphChanges: result.graphChanges,
|
|
509
|
+
revision: value.revision,
|
|
510
|
+
personas: result.runtime.personas.length, nativeManifests: nativeBindings.length,
|
|
511
|
+
rosterDigest: digest(JSON.stringify(value)) };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function inspectPersonaRuntime(root = process.cwd(), providedCatalog = null) {
|
|
515
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
516
|
+
const errors = [];
|
|
517
|
+
let policy = emptyPolicy(paths.catalog.root);
|
|
518
|
+
let runtime = emptyRuntime(paths.catalog.root);
|
|
519
|
+
try { policy = await readJson(paths.personaPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push("policy:" + error.message); }
|
|
520
|
+
try { runtime = await readJson(paths.personaRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime); } catch (error) { errors.push("runtime:" + error.message); }
|
|
521
|
+
return { policy, runtime, errors, ...paths };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function personaRuntimeFindings(policy, runtime, graph = null) {
|
|
525
|
+
const findings = [];
|
|
526
|
+
const bindingIds = new Set();
|
|
527
|
+
const personaIds = new Set();
|
|
528
|
+
for (const binding of policy.bindings) {
|
|
529
|
+
if (!validBinding(binding)) findings.push("invalid-persona-binding:" + (binding?.id || "unknown"));
|
|
530
|
+
if (bindingIds.has(binding.id)) findings.push("duplicate-persona-binding:" + binding.id);
|
|
531
|
+
bindingIds.add(binding.id);
|
|
532
|
+
}
|
|
533
|
+
for (const persona of runtime.personas) {
|
|
534
|
+
if (!validPersona(persona)) findings.push("invalid-persona:" + (persona?.personaId || "unknown"));
|
|
535
|
+
if (personaIds.has(persona.personaId)) findings.push("duplicate-persona:" + persona.personaId);
|
|
536
|
+
if (!bindingIds.has(persona.bindingId)) findings.push("orphan-persona:" + persona.personaId);
|
|
537
|
+
if (graph) {
|
|
538
|
+
const entity = graph.entities.find((item) => item.id === persona.personaId);
|
|
539
|
+
const expectedKind = persona.kind === "bot" ? "agent" : persona.kind;
|
|
540
|
+
if (!entity || entity.kind !== expectedKind) findings.push("persona-graph-mismatch:" + persona.personaId);
|
|
541
|
+
if (entity?.attributes?.identityStatus !== persona.status) findings.push("persona-graph-status-mismatch:" + persona.personaId);
|
|
542
|
+
const memberships = graph.entityEdges.filter((edge) => edge.from === persona.personaId && edge.relation === "member-of");
|
|
543
|
+
const group = persona.groupId === null ? null : graph.entities.find((item) => item.id === persona.groupId);
|
|
544
|
+
if (persona.status === "active" && persona.groupId !== null
|
|
545
|
+
&& (!group || group.kind !== "group" || group.privacy === "private")) {
|
|
546
|
+
findings.push("persona-group-entity-mismatch:" + persona.personaId);
|
|
547
|
+
}
|
|
548
|
+
if (persona.status === "active" && persona.groupId !== null
|
|
549
|
+
&& !memberships.some((edge) => edge.to === persona.groupId && edge.privacy === "group")) {
|
|
550
|
+
findings.push("persona-group-membership-mismatch:" + persona.personaId);
|
|
551
|
+
}
|
|
552
|
+
if (memberships.some((edge) => edge.to !== persona.groupId || persona.status !== "active")) {
|
|
553
|
+
findings.push("persona-stale-group-membership:" + persona.personaId);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
personaIds.add(persona.personaId);
|
|
557
|
+
}
|
|
558
|
+
const eventIds = new Set();
|
|
559
|
+
for (const event of runtime.events) {
|
|
560
|
+
if (!validEvent(event) || !personaIds.has(event.personaId)) findings.push("invalid-persona-event:" + (event?.eventId || "unknown"));
|
|
561
|
+
if (eventIds.has(event.eventId)) findings.push("duplicate-persona-event:" + event.eventId);
|
|
562
|
+
eventIds.add(event.eventId);
|
|
563
|
+
}
|
|
564
|
+
const receiptIds = new Set();
|
|
565
|
+
for (const receipt of runtime.receipts) {
|
|
566
|
+
if (!validReceipt(receipt) || !eventIds.has(receipt.eventId)) findings.push("invalid-persona-receipt:" + (receipt?.id || "unknown"));
|
|
567
|
+
if (receiptIds.has(receipt.id)) findings.push("duplicate-persona-receipt:" + receipt.id);
|
|
568
|
+
receiptIds.add(receipt.id);
|
|
569
|
+
}
|
|
570
|
+
return findings;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
export async function personaContext({ root = process.cwd(), personaId = null, groupId = undefined, includeInactive = false } = {}) {
|
|
574
|
+
const { policy, runtime } = await loadPersonaRuntime(root);
|
|
575
|
+
const findings = personaRuntimeFindings(policy, runtime);
|
|
576
|
+
if (findings.length) throw new Error("persona runtime failed closed: " + findings.join(", "));
|
|
577
|
+
const items = runtime.personas.filter((item) => (includeInactive || item.status === "active")
|
|
578
|
+
&& (personaId === null || item.personaId === exactId(personaId, "personaId"))
|
|
579
|
+
&& (groupId === undefined || item.groupId === exactId(groupId, "groupId", true)));
|
|
580
|
+
return { schema: "agentspine.persona-context/v1", items: structuredClone(items), authority: "identity-state-only" };
|
|
581
|
+
}
|