blun-king-cli 9.1.486 → 9.1.489
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/CHANGELOG.md +14 -0
- package/LIESMICH.txt +28 -7
- package/README.md +16 -3
- package/agent-spine-plugin/.claude-plugin/marketplace.json +20 -0
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -0
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -0
- package/agent-spine-plugin/.mcp.json +8 -0
- package/agent-spine-plugin/CHANGELOG.md +277 -0
- package/agent-spine-plugin/LICENSE +186 -0
- package/agent-spine-plugin/README.md +342 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -0
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -0
- package/agent-spine-plugin/bin/agentspine.js +7 -0
- package/agent-spine-plugin/blun.plugin.json +33 -0
- package/agent-spine-plugin/docs/acceptance.md +61 -0
- package/agent-spine-plugin/docs/architecture.md +183 -0
- package/agent-spine-plugin/docs/attention.md +121 -0
- package/agent-spine-plugin/docs/automatic-continuity.md +79 -0
- package/agent-spine-plugin/docs/channel-runtime.md +92 -0
- package/agent-spine-plugin/docs/coordination.md +138 -0
- package/agent-spine-plugin/docs/feed-transport.md +99 -0
- package/agent-spine-plugin/docs/gateway-runtime.md +109 -0
- package/agent-spine-plugin/docs/harness-reference.md +45 -0
- package/agent-spine-plugin/docs/host-integration.md +129 -0
- package/agent-spine-plugin/docs/https-transport.md +116 -0
- package/agent-spine-plugin/docs/learning.md +98 -0
- package/agent-spine-plugin/docs/object-transport.md +93 -0
- package/agent-spine-plugin/docs/peer-transport.md +88 -0
- package/agent-spine-plugin/docs/preservation-contract.md +53 -0
- package/agent-spine-plugin/docs/quality-gates.md +50 -0
- package/agent-spine-plugin/docs/relationships.md +67 -0
- package/agent-spine-plugin/docs/releasing.md +83 -0
- package/agent-spine-plugin/docs/roadmap.md +307 -0
- package/agent-spine-plugin/docs/selfstarter.md +88 -0
- package/agent-spine-plugin/docs/session-briefing.md +74 -0
- package/agent-spine-plugin/docs/shared-memory.md +259 -0
- package/agent-spine-plugin/docs/source-roots.md +86 -0
- package/agent-spine-plugin/docs/sqlite-transport.md +76 -0
- package/agent-spine-plugin/hooks/hooks.json +95 -0
- package/agent-spine-plugin/hooks/version.json +5 -0
- package/agent-spine-plugin/package.json +69 -0
- package/agent-spine-plugin/scripts/check-hosts.js +193 -0
- package/agent-spine-plugin/scripts/check-install.js +551 -0
- package/agent-spine-plugin/scripts/check-syntax.js +29 -0
- package/agent-spine-plugin/scripts/github-actions.js +11 -0
- package/agent-spine-plugin/scripts/release-check.js +127 -0
- package/agent-spine-plugin/scripts/run-acceptance.js +19 -0
- package/agent-spine-plugin/scripts/run-checks.js +46 -0
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +73 -0
- package/agent-spine-plugin/skill/SKILL.md +76 -0
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +84 -0
- package/agent-spine-plugin/spine-example/1-identity.md +12 -0
- package/agent-spine-plugin/spine-example/2-voice.md +6 -0
- package/agent-spine-plugin/spine-example/3-conduct.md +8 -0
- package/agent-spine-plugin/spine-example/4-history.md +4 -0
- package/agent-spine-plugin/src/cli.js +950 -0
- package/agent-spine-plugin/src/hook.js +646 -0
- package/agent-spine-plugin/src/index.js +84 -0
- package/agent-spine-plugin/src/lib/acceptance.js +294 -0
- package/agent-spine-plugin/src/lib/attention.js +755 -0
- package/agent-spine-plugin/src/lib/audit.js +308 -0
- package/agent-spine-plugin/src/lib/authentication.js +513 -0
- package/agent-spine-plugin/src/lib/briefing.js +305 -0
- package/agent-spine-plugin/src/lib/catalog.js +142 -0
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -0
- package/agent-spine-plugin/src/lib/context.js +154 -0
- package/agent-spine-plugin/src/lib/continuity.js +333 -0
- package/agent-spine-plugin/src/lib/coordination.js +577 -0
- package/agent-spine-plugin/src/lib/documents.js +199 -0
- package/agent-spine-plugin/src/lib/feed-transport.js +497 -0
- package/agent-spine-plugin/src/lib/filesystem-retry.js +27 -0
- package/agent-spine-plugin/src/lib/gateway-runtime.js +760 -0
- package/agent-spine-plugin/src/lib/graph.js +284 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -0
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -0
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -0
- package/agent-spine-plugin/src/lib/learning.js +645 -0
- package/agent-spine-plugin/src/lib/object-transport.js +206 -0
- package/agent-spine-plugin/src/lib/paths.js +60 -0
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -0
- package/agent-spine-plugin/src/lib/persona-runtime.js +503 -0
- package/agent-spine-plugin/src/lib/runtime.js +13 -0
- package/agent-spine-plugin/src/lib/selfstarter.js +819 -0
- package/agent-spine-plugin/src/lib/sharing.js +969 -0
- package/agent-spine-plugin/src/lib/source-roots.js +451 -0
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -0
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -0
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -0
- package/agent-spine-plugin/src/mcp.js +526 -0
- package/agent-spine-plugin/src/version.js +1 -0
- package/agent-spine-plugin/src/worker.js +178 -0
- package/bin/identity-context-policy.cjs +51 -1
- package/bin/launcher-runtime.js +42 -1
- package/bin/plugin-bootstrap.js +68 -27
- package/bin/repeated-user-message-projection.cjs +6 -0
- package/bin/turn-thinking-policy.cjs +5 -4
- package/bin/turn-tool-performance-policy.cjs +16 -2
- package/blun.mjs +187 -35
- package/package.json +3 -1
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { buildCatalog } from "./catalog.js";
|
|
4
|
+
import { projectStateDir } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
const RELATIONS = new Set([
|
|
7
|
+
"loads", "belongs-to", "explains", "supports", "related",
|
|
8
|
+
"contradicts", "supersedes-in-relevance"
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const ENTITY_KINDS = new Set(["person", "agent", "group", "channel", "project"]);
|
|
12
|
+
const ENTITY_RELATIONS = new Set([
|
|
13
|
+
"knows", "works-with", "member-of", "communicates-via",
|
|
14
|
+
"responsible-for", "reports-to", "related"
|
|
15
|
+
]);
|
|
16
|
+
const ANNOTATION_LAYERS = new Set([
|
|
17
|
+
"soul", "memory-index", "memory-fact", "reference", "project-reference",
|
|
18
|
+
"identity", "voice", "conduct", "history"
|
|
19
|
+
]);
|
|
20
|
+
const FORBIDDEN_ATTRIBUTE_KEYS = /^(permissions?|rights?|authorization|credentials?|secrets?|tokens?|api[-_]?keys?)$/i;
|
|
21
|
+
|
|
22
|
+
function emptyGraph(root) {
|
|
23
|
+
return {
|
|
24
|
+
schema: "agentspine.graph/v1",
|
|
25
|
+
root,
|
|
26
|
+
edges: [],
|
|
27
|
+
annotations: [],
|
|
28
|
+
entities: [],
|
|
29
|
+
entityEdges: [],
|
|
30
|
+
history: []
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeGraph(value, root) {
|
|
35
|
+
const graph = value && typeof value === "object" ? value : emptyGraph(root);
|
|
36
|
+
graph.schema = "agentspine.graph/v1";
|
|
37
|
+
graph.root = root;
|
|
38
|
+
for (const key of ["edges", "annotations", "entities", "entityEdges", "history"]) {
|
|
39
|
+
if (!Array.isArray(graph[key])) graph[key] = [];
|
|
40
|
+
}
|
|
41
|
+
return graph;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function preservePrevious(graph, kind, value) {
|
|
45
|
+
if (!value) return;
|
|
46
|
+
graph.history.push({
|
|
47
|
+
kind,
|
|
48
|
+
supersededAt: new Date().toISOString(),
|
|
49
|
+
value: { ...value, authority: "context-only" },
|
|
50
|
+
privacy: value.privacy || "private",
|
|
51
|
+
authority: "context-only"
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function normalizeRelativePath(value, field) {
|
|
56
|
+
if (!value || typeof value !== "string") throw new Error(`${field} is required`);
|
|
57
|
+
const normalized = value.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
58
|
+
if (normalized.startsWith("/") || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
59
|
+
throw new Error(`${field} must be a project-relative path`);
|
|
60
|
+
}
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeConfidence(value) {
|
|
65
|
+
const number = Number(value);
|
|
66
|
+
if (!Number.isFinite(number)) throw new Error("confidence must be a finite number");
|
|
67
|
+
return Math.max(0, Math.min(1, number));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateAttributes(value, path = "attributes") {
|
|
71
|
+
if (value === null || value === undefined) return {};
|
|
72
|
+
if (Array.isArray(value)) return value.map((item, index) => validateAttributes(item, `${path}[${index}]`));
|
|
73
|
+
if (typeof value !== "object") return value;
|
|
74
|
+
const result = {};
|
|
75
|
+
for (const [key, item] of Object.entries(value)) {
|
|
76
|
+
if (FORBIDDEN_ATTRIBUTE_KEYS.test(key)) {
|
|
77
|
+
throw new Error(`${path}.${key} is authority data and cannot be stored in relationship memory`);
|
|
78
|
+
}
|
|
79
|
+
result[key] = validateAttributes(item, `${path}.${key}`);
|
|
80
|
+
}
|
|
81
|
+
if (Buffer.byteLength(JSON.stringify(result)) > 16384) throw new Error("attributes exceed 16 KiB");
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function loadGraph(root, providedCatalog = null) {
|
|
86
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
87
|
+
const directory = await projectStateDir(catalog.root);
|
|
88
|
+
const path = join(directory, "graph.json");
|
|
89
|
+
try {
|
|
90
|
+
const metadata = await stat(path);
|
|
91
|
+
if (metadata.size > 5 * 1024 * 1024) throw new Error("relationship graph exceeds the 5 MiB read limit");
|
|
92
|
+
return { graph: normalizeGraph(JSON.parse(await readFile(path, "utf8")), catalog.root), graphPath: path, catalog };
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error.code !== "ENOENT") throw error;
|
|
95
|
+
return {
|
|
96
|
+
graph: emptyGraph(catalog.root),
|
|
97
|
+
graphPath: path,
|
|
98
|
+
catalog
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function saveGraph(graph, path) {
|
|
104
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
105
|
+
const content = `${JSON.stringify(graph, null, 2)}\n`;
|
|
106
|
+
if (Buffer.byteLength(content) > 5 * 1024 * 1024) throw new Error("relationship graph exceeds 5 MiB; archive or compact old observations first");
|
|
107
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
108
|
+
await rename(temporary, path);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function linkDocuments({ root = process.cwd(), from, to, relation = "related", reason = "", confidence = 0.5 }) {
|
|
112
|
+
if (!RELATIONS.has(relation)) throw new Error(`unsupported relation: ${relation}`);
|
|
113
|
+
const { graph, graphPath, catalog } = await loadGraph(root);
|
|
114
|
+
from = normalizeRelativePath(from, "from");
|
|
115
|
+
to = normalizeRelativePath(to, "to");
|
|
116
|
+
const known = new Set(catalog.documents.map((document) => document.relativePath));
|
|
117
|
+
if (!known.has(from)) throw new Error(`unknown source document: ${from}`);
|
|
118
|
+
if (!known.has(to)) throw new Error(`unknown target document: ${to}`);
|
|
119
|
+
const edge = {
|
|
120
|
+
from,
|
|
121
|
+
to,
|
|
122
|
+
relation,
|
|
123
|
+
reason: String(reason).slice(0, 1000),
|
|
124
|
+
confidence: normalizeConfidence(confidence),
|
|
125
|
+
updatedAt: new Date().toISOString(),
|
|
126
|
+
authority: "context-only"
|
|
127
|
+
};
|
|
128
|
+
const key = (item) => `${item.from}\0${item.to}\0${item.relation}`;
|
|
129
|
+
preservePrevious(graph, "document-edge", graph.edges.find((item) => key(item) === key(edge)));
|
|
130
|
+
graph.edges = graph.edges.filter((item) => key(item) !== key(edge));
|
|
131
|
+
graph.edges.push(edge);
|
|
132
|
+
graph.edges.sort((a, b) => key(a).localeCompare(key(b)));
|
|
133
|
+
await saveGraph(graph, graphPath);
|
|
134
|
+
return { edge, graphPath };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function annotateDocument({ root = process.cwd(), path, layer, reason = "", confidence = 0.5 }) {
|
|
138
|
+
if (!ANNOTATION_LAYERS.has(layer)) throw new Error(`unsupported context-only layer: ${layer}`);
|
|
139
|
+
const { graph, graphPath, catalog } = await loadGraph(root);
|
|
140
|
+
path = normalizeRelativePath(path, "path");
|
|
141
|
+
if (!catalog.documents.some((document) => document.relativePath === path)) {
|
|
142
|
+
throw new Error(`unknown document: ${path}`);
|
|
143
|
+
}
|
|
144
|
+
const annotation = {
|
|
145
|
+
path,
|
|
146
|
+
layer,
|
|
147
|
+
reason: String(reason).slice(0, 1000),
|
|
148
|
+
confidence: normalizeConfidence(confidence),
|
|
149
|
+
updatedAt: new Date().toISOString(),
|
|
150
|
+
authority: "context-only"
|
|
151
|
+
};
|
|
152
|
+
preservePrevious(graph, "document-annotation", graph.annotations.find((item) => item.path === path));
|
|
153
|
+
graph.annotations = graph.annotations.filter((item) => item.path !== path);
|
|
154
|
+
graph.annotations.push(annotation);
|
|
155
|
+
graph.annotations.sort((a, b) => a.path.localeCompare(b.path));
|
|
156
|
+
await saveGraph(graph, graphPath);
|
|
157
|
+
return { annotation, graphPath };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function upsertEntity({
|
|
161
|
+
root = process.cwd(), id, kind, displayName = "", aliases = [],
|
|
162
|
+
attributes = {}, sourceDocument = null, confidence = 0.5, privacy = "private"
|
|
163
|
+
}) {
|
|
164
|
+
if (!id || !/^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/.test(id)) throw new Error("id must be a stable, whitespace-free identifier");
|
|
165
|
+
if (!ENTITY_KINDS.has(kind)) throw new Error(`unsupported entity kind: ${kind}`);
|
|
166
|
+
if (!["private", "shared", "group"].includes(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
167
|
+
if (!attributes || Array.isArray(attributes) || typeof attributes !== "object") throw new Error("attributes must be an object");
|
|
168
|
+
const { graph, graphPath, catalog } = await loadGraph(root);
|
|
169
|
+
if (sourceDocument !== null) {
|
|
170
|
+
sourceDocument = normalizeRelativePath(sourceDocument, "sourceDocument");
|
|
171
|
+
if (!catalog.documents.some((document) => document.relativePath === sourceDocument)) {
|
|
172
|
+
throw new Error(`unknown source document: ${sourceDocument}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const entity = {
|
|
176
|
+
id,
|
|
177
|
+
kind,
|
|
178
|
+
displayName: String(displayName).slice(0, 200),
|
|
179
|
+
aliases: [...new Set((Array.isArray(aliases) ? aliases : []).map((alias) => String(alias).slice(0, 200)))].slice(0, 20),
|
|
180
|
+
attributes: validateAttributes(attributes),
|
|
181
|
+
sourceDocument,
|
|
182
|
+
confidence: normalizeConfidence(confidence),
|
|
183
|
+
privacy,
|
|
184
|
+
updatedAt: new Date().toISOString(),
|
|
185
|
+
authority: "context-only"
|
|
186
|
+
};
|
|
187
|
+
preservePrevious(graph, "entity", graph.entities.find((item) => item.id === id));
|
|
188
|
+
graph.entities = graph.entities.filter((item) => item.id !== id);
|
|
189
|
+
graph.entities.push(entity);
|
|
190
|
+
graph.entities.sort((a, b) => a.id.localeCompare(b.id));
|
|
191
|
+
await saveGraph(graph, graphPath);
|
|
192
|
+
return { entity, graphPath };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function linkEntities({ root = process.cwd(), from, to, relation = "related", reason = "", confidence = 0.5, privacy = "private" }) {
|
|
196
|
+
if (!ENTITY_RELATIONS.has(relation)) throw new Error(`unsupported entity relation: ${relation}`);
|
|
197
|
+
if (!["private", "shared", "group"].includes(privacy)) throw new Error(`unsupported privacy scope: ${privacy}`);
|
|
198
|
+
const { graph, graphPath } = await loadGraph(root);
|
|
199
|
+
const known = new Set(graph.entities.map((entity) => entity.id));
|
|
200
|
+
if (!known.has(from)) throw new Error(`unknown source entity: ${from}`);
|
|
201
|
+
if (!known.has(to)) throw new Error(`unknown target entity: ${to}`);
|
|
202
|
+
const edge = {
|
|
203
|
+
from,
|
|
204
|
+
to,
|
|
205
|
+
relation,
|
|
206
|
+
reason: String(reason).slice(0, 1000),
|
|
207
|
+
confidence: normalizeConfidence(confidence),
|
|
208
|
+
privacy,
|
|
209
|
+
updatedAt: new Date().toISOString(),
|
|
210
|
+
authority: "context-only"
|
|
211
|
+
};
|
|
212
|
+
const key = (item) => `${item.from}\0${item.to}\0${item.relation}`;
|
|
213
|
+
preservePrevious(graph, "entity-edge", graph.entityEdges.find((item) => key(item) === key(edge)));
|
|
214
|
+
graph.entityEdges = graph.entityEdges.filter((item) => key(item) !== key(edge));
|
|
215
|
+
graph.entityEdges.push(edge);
|
|
216
|
+
graph.entityEdges.sort((a, b) => key(a).localeCompare(key(b)));
|
|
217
|
+
await saveGraph(graph, graphPath);
|
|
218
|
+
return { edge, graphPath };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function unlinkEntities({ root = process.cwd(), from, to, relation = "related" }) {
|
|
222
|
+
if (!ENTITY_RELATIONS.has(relation)) throw new Error(`unsupported entity relation: ${relation}`);
|
|
223
|
+
const { graph, graphPath } = await loadGraph(root);
|
|
224
|
+
const previous = graph.entityEdges.find((item) => item.from === from && item.to === to && item.relation === relation);
|
|
225
|
+
if (!previous) return { removed: null, duplicate: true, graphPath };
|
|
226
|
+
preservePrevious(graph, "entity-edge", previous);
|
|
227
|
+
graph.entityEdges = graph.entityEdges.filter((item) => !(item.from === from && item.to === to && item.relation === relation));
|
|
228
|
+
await saveGraph(graph, graphPath);
|
|
229
|
+
return { removed: previous, duplicate: false, graphPath };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function relationshipAudience(graph, groupId) {
|
|
233
|
+
const ids = new Set([groupId]);
|
|
234
|
+
for (const edge of graph.entityEdges) {
|
|
235
|
+
if (edge.relation !== "member-of" || edge.privacy === "private") continue;
|
|
236
|
+
if (edge.from === groupId) ids.add(edge.to);
|
|
237
|
+
if (edge.to === groupId) ids.add(edge.from);
|
|
238
|
+
}
|
|
239
|
+
return ids;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function relationshipContext({
|
|
243
|
+
root = process.cwd(), entityId, includePrivate = false, groupId = null, catalog: providedCatalog = null
|
|
244
|
+
}) {
|
|
245
|
+
if (!entityId) throw new Error("entityId is required");
|
|
246
|
+
const { graph } = await loadGraph(root, providedCatalog);
|
|
247
|
+
if (groupId !== null) {
|
|
248
|
+
const group = graph.entities.find((item) => item.id === groupId && item.kind === "group");
|
|
249
|
+
if (!group) throw new Error(`unknown group entity: ${groupId}`);
|
|
250
|
+
if (includePrivate) throw new Error("private relationship context cannot be assembled for a group audience");
|
|
251
|
+
}
|
|
252
|
+
const entity = graph.entities.find((item) => item.id === entityId);
|
|
253
|
+
if (!entity) throw new Error(`unknown entity: ${entityId}`);
|
|
254
|
+
const audience = groupId === null ? null : relationshipAudience(graph, groupId);
|
|
255
|
+
if (audience && !audience.has(entityId)) throw new Error(`entity is not a visible member of group: ${groupId}`);
|
|
256
|
+
const visible = (item) => {
|
|
257
|
+
if (item.privacy === "private") return includePrivate && groupId === null;
|
|
258
|
+
if (item.privacy === "group" && !audience) return false;
|
|
259
|
+
if (audience && item.id && !audience.has(item.id)) return false;
|
|
260
|
+
return true;
|
|
261
|
+
};
|
|
262
|
+
if (!visible(entity)) throw new Error(`private relationship context requires includePrivate: ${entityId}`);
|
|
263
|
+
const entities = new Map(graph.entities.map((item) => [item.id, item]));
|
|
264
|
+
const visibleEdge = (edge) => visible(edge)
|
|
265
|
+
&& (!audience || [edge.from, edge.to].every((id) => audience.has(id)))
|
|
266
|
+
&& [edge.from, edge.to].every((id) => !entities.has(id) || visible(entities.get(id)));
|
|
267
|
+
const edges = graph.entityEdges.filter((edge) => (edge.from === entityId || edge.to === entityId) && visibleEdge(edge));
|
|
268
|
+
const ids = new Set(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
269
|
+
ids.add(entityId);
|
|
270
|
+
return {
|
|
271
|
+
entity,
|
|
272
|
+
relatedEntities: graph.entities.filter((item) => ids.has(item.id) && visible(item)),
|
|
273
|
+
edges,
|
|
274
|
+
history: graph.history.filter((entry) => {
|
|
275
|
+
if (!visible(entry)) return false;
|
|
276
|
+
const value = entry.value || {};
|
|
277
|
+
if (value.id && entities.has(value.id) && !visible(entities.get(value.id))) return false;
|
|
278
|
+
if ((value.from || value.to) && !visibleEdge(value)) return false;
|
|
279
|
+
return value.id === entityId || value.from === entityId || value.to === entityId;
|
|
280
|
+
}),
|
|
281
|
+
groupId,
|
|
282
|
+
authority: "context-only"
|
|
283
|
+
};
|
|
284
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
3
|
+
import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { BlockList, isIP } from "node:net";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
+
import { verifyEnvelope } from "./authentication.js";
|
|
9
|
+
import { buildCatalog } from "./catalog.js";
|
|
10
|
+
import { isInside } from "./paths.js";
|
|
11
|
+
import { pullShared, readDirectoryExchange, validateSharedEvent } from "./sharing.js";
|
|
12
|
+
|
|
13
|
+
const SCHEMA = "agentspine.https-snapshot/v1";
|
|
14
|
+
const CONFIRMATION = "local-share-confirmed";
|
|
15
|
+
const MAX_EVENTS = 2000;
|
|
16
|
+
const MAX_SNAPSHOT_BYTES = 21 * 1024 * 1024;
|
|
17
|
+
const MAX_TOKEN_BYTES = 8192;
|
|
18
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
19
|
+
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
20
|
+
const TOKEN_ENV_RE = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
|
21
|
+
|
|
22
|
+
const blockedAddresses = new BlockList();
|
|
23
|
+
const publicIpv6 = new BlockList();
|
|
24
|
+
publicIpv6.addSubnet("2000::", 3, "ipv6");
|
|
25
|
+
for (const [network, prefix] of [
|
|
26
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
|
27
|
+
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
|
|
28
|
+
["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
|
|
29
|
+
["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4]
|
|
30
|
+
]) blockedAddresses.addSubnet(network, prefix, "ipv4");
|
|
31
|
+
for (const [network, prefix] of [
|
|
32
|
+
["::", 128], ["::1", 128], ["64:ff9b::", 96],
|
|
33
|
+
["100::", 64], ["2001::", 32], ["2001:2::", 48], ["2001:10::", 28],
|
|
34
|
+
["2001:20::", 28], ["2001:db8::", 32], ["2002::", 16], ["3fff::", 20],
|
|
35
|
+
["fc00::", 7], ["fe80::", 10], ["ff00::", 8]
|
|
36
|
+
]) blockedAddresses.addSubnet(network, prefix, "ipv6");
|
|
37
|
+
|
|
38
|
+
function canonical(value) {
|
|
39
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
40
|
+
if (value && typeof value === "object") {
|
|
41
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
42
|
+
}
|
|
43
|
+
return JSON.stringify(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function digest(value) {
|
|
47
|
+
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function exactKeys(value, expected) {
|
|
51
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
52
|
+
const actual = Object.keys(value).sort();
|
|
53
|
+
const wanted = [...expected].sort();
|
|
54
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function validDate(value) {
|
|
58
|
+
return typeof value === "string" && value.length > 0 && Number.isFinite(new Date(value).getTime());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function withoutDigest(value) {
|
|
62
|
+
const { digest: _digest, ...body } = value;
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function eventId(document) {
|
|
67
|
+
const payload = document?.schema === "agentspine.signed-envelope/v1" ? document.payload : null;
|
|
68
|
+
return payload?.id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function eventFilename(id) {
|
|
72
|
+
return `${createHash("sha256").update(id).digest("hex")}.json`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function prospectiveCanonicalPath(path) {
|
|
76
|
+
let ancestor = path;
|
|
77
|
+
const missing = [];
|
|
78
|
+
while (true) {
|
|
79
|
+
try {
|
|
80
|
+
const canonicalAncestor = await realpath(ancestor);
|
|
81
|
+
return resolve(canonicalAncestor, ...missing.reverse());
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error.code !== "ENOENT") throw error;
|
|
84
|
+
const parent = dirname(ancestor);
|
|
85
|
+
if (parent === ancestor) throw error;
|
|
86
|
+
missing.push(basename(ancestor));
|
|
87
|
+
ancestor = parent;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateHttpsEndpoint(value) {
|
|
93
|
+
let endpoint;
|
|
94
|
+
try {
|
|
95
|
+
endpoint = new URL(value);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new Error("HTTPS snapshot URL is invalid");
|
|
98
|
+
}
|
|
99
|
+
if (endpoint.protocol !== "https:") throw new Error("HTTPS snapshots require an https:// URL");
|
|
100
|
+
if (endpoint.username || endpoint.password) throw new Error("credentials must not be embedded in an HTTPS snapshot URL");
|
|
101
|
+
if (endpoint.search || endpoint.hash) throw new Error("HTTPS snapshot URLs cannot contain a query or fragment");
|
|
102
|
+
if (!endpoint.hostname || endpoint.pathname.endsWith("/")) {
|
|
103
|
+
throw new Error("HTTPS snapshot URL must identify one explicit JSON resource");
|
|
104
|
+
}
|
|
105
|
+
return endpoint;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function addressBlocked(address) {
|
|
109
|
+
const family = isIP(address);
|
|
110
|
+
if (!family) throw new Error(`DNS returned an invalid address: ${address}`);
|
|
111
|
+
if (family === 6 && !publicIpv6.check(address, "ipv6")) return true;
|
|
112
|
+
return blockedAddresses.check(address, family === 4 ? "ipv4" : "ipv6");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function within(promise, milliseconds, message) {
|
|
116
|
+
let timer;
|
|
117
|
+
try {
|
|
118
|
+
return await Promise.race([
|
|
119
|
+
promise,
|
|
120
|
+
new Promise((_resolve, reject) => {
|
|
121
|
+
timer = setTimeout(() => reject(new Error(message)), milliseconds);
|
|
122
|
+
})
|
|
123
|
+
]);
|
|
124
|
+
} finally {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function resolveHttpsEndpoint(endpoint, {
|
|
130
|
+
allowPrivateNetwork = false, lookup = dnsLookup
|
|
131
|
+
} = {}) {
|
|
132
|
+
const parsed = endpoint instanceof URL ? endpoint : validateHttpsEndpoint(endpoint);
|
|
133
|
+
const lookupHostname = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]")
|
|
134
|
+
? parsed.hostname.slice(1, -1) : parsed.hostname;
|
|
135
|
+
const literalFamily = isIP(lookupHostname);
|
|
136
|
+
const answers = literalFamily
|
|
137
|
+
? [{ address: lookupHostname, family: literalFamily }]
|
|
138
|
+
: await lookup(lookupHostname, { all: true, verbatim: true });
|
|
139
|
+
if (!Array.isArray(answers) || answers.length === 0) throw new Error("HTTPS snapshot host did not resolve");
|
|
140
|
+
for (const answer of answers) {
|
|
141
|
+
if (!answer || isIP(answer.address) !== Number(answer.family)) throw new Error("HTTPS snapshot DNS result is invalid");
|
|
142
|
+
if (!allowPrivateNetwork && addressBlocked(answer.address)) {
|
|
143
|
+
throw new Error("HTTPS snapshot host resolves to a private, local, reserved, or documentation address");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
endpoint: parsed, lookupHostname,
|
|
148
|
+
addresses: answers.map((answer) => ({ address: answer.address, family: Number(answer.family) }))
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function bearerToken(tokenEnv, environment) {
|
|
153
|
+
if (tokenEnv === null || tokenEnv === undefined) return null;
|
|
154
|
+
if (!TOKEN_ENV_RE.test(tokenEnv)) throw new Error("tokenEnv must be an uppercase environment variable name");
|
|
155
|
+
const value = environment[tokenEnv];
|
|
156
|
+
if (typeof value !== "string" || !value) throw new Error(`HTTPS snapshot token environment variable is missing: ${tokenEnv}`);
|
|
157
|
+
if (Buffer.byteLength(value) > MAX_TOKEN_BYTES || /[\0-\x1f\x7f]/.test(value)) {
|
|
158
|
+
throw new Error("HTTPS snapshot bearer token is invalid");
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function fetchHttpsSnapshot({
|
|
164
|
+
url, tokenEnv = null, timeoutMs = 10000, allowPrivateNetwork = false,
|
|
165
|
+
confirmation = null, environment = process.env, lookup = dnsLookup, request = httpsRequest
|
|
166
|
+
}) {
|
|
167
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 30000) {
|
|
168
|
+
throw new Error("HTTPS snapshot timeout must be between 1000 and 30000 milliseconds");
|
|
169
|
+
}
|
|
170
|
+
if (allowPrivateNetwork && confirmation !== CONFIRMATION) {
|
|
171
|
+
throw new Error("private-network HTTPS access requires explicit local owner confirmation");
|
|
172
|
+
}
|
|
173
|
+
const resolved = await within(
|
|
174
|
+
resolveHttpsEndpoint(validateHttpsEndpoint(url), { allowPrivateNetwork, lookup }),
|
|
175
|
+
timeoutMs,
|
|
176
|
+
"HTTPS snapshot DNS resolution timed out"
|
|
177
|
+
);
|
|
178
|
+
const token = bearerToken(tokenEnv, environment);
|
|
179
|
+
const selected = resolved.addresses[0];
|
|
180
|
+
const headers = {
|
|
181
|
+
accept: "application/json",
|
|
182
|
+
"accept-encoding": "identity",
|
|
183
|
+
"user-agent": "AgentSpine/0.1 HTTPS snapshot transport"
|
|
184
|
+
};
|
|
185
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
186
|
+
const body = await new Promise((resolvePromise, rejectPromise) => {
|
|
187
|
+
let deadline;
|
|
188
|
+
const reject = (error) => {
|
|
189
|
+
clearTimeout(deadline);
|
|
190
|
+
rejectPromise(error);
|
|
191
|
+
};
|
|
192
|
+
const resolve = (value) => {
|
|
193
|
+
clearTimeout(deadline);
|
|
194
|
+
resolvePromise(value);
|
|
195
|
+
};
|
|
196
|
+
const requestOptions = {
|
|
197
|
+
protocol: "https:", hostname: resolved.lookupHostname,
|
|
198
|
+
port: resolved.endpoint.port || 443,
|
|
199
|
+
path: resolved.endpoint.pathname, method: "GET", headers,
|
|
200
|
+
agent: false, autoSelectFamily: false,
|
|
201
|
+
servername: isIP(resolved.lookupHostname) ? undefined : resolved.lookupHostname,
|
|
202
|
+
lookup: (_hostname, options, callback) => {
|
|
203
|
+
const family = typeof options === "object" && options.family ? Number(options.family) : 0;
|
|
204
|
+
const usable = family ? resolved.addresses.find((item) => item.family === family) : selected;
|
|
205
|
+
if (!usable) return callback(new Error("no vetted DNS address matches the requested family"));
|
|
206
|
+
return callback(null, usable.address, usable.family);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const active = request(requestOptions, (response) => {
|
|
210
|
+
const status = Number(response.statusCode || 0);
|
|
211
|
+
if (status >= 300 && status < 400) {
|
|
212
|
+
response.resume();
|
|
213
|
+
reject(new Error("HTTPS snapshot redirects are not followed"));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (status !== 200) {
|
|
217
|
+
response.resume();
|
|
218
|
+
reject(new Error(`HTTPS snapshot request failed with status ${status}`));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const contentType = String(response.headers["content-type"] || "").toLowerCase();
|
|
222
|
+
if (!/^application\/(?:json|[a-z0-9.+-]+\+json)(?:\s*;|$)/.test(contentType)) {
|
|
223
|
+
response.resume();
|
|
224
|
+
reject(new Error("HTTPS snapshot response must use an application/json content type"));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const encoding = String(response.headers["content-encoding"] || "identity").toLowerCase();
|
|
228
|
+
if (encoding !== "identity") {
|
|
229
|
+
response.resume();
|
|
230
|
+
reject(new Error("compressed HTTPS snapshots are rejected to keep byte limits exact"));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const declared = Number(response.headers["content-length"] || 0);
|
|
234
|
+
if (declared && (!Number.isSafeInteger(declared) || declared > MAX_SNAPSHOT_BYTES)) {
|
|
235
|
+
response.resume();
|
|
236
|
+
reject(new Error("HTTPS snapshot exceeds the 21 MiB response limit"));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const chunks = [];
|
|
240
|
+
let bytes = 0;
|
|
241
|
+
response.on("data", (chunk) => {
|
|
242
|
+
bytes += chunk.length;
|
|
243
|
+
if (bytes > MAX_SNAPSHOT_BYTES) {
|
|
244
|
+
active.destroy(new Error("HTTPS snapshot exceeds the 21 MiB response limit"));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
chunks.push(chunk);
|
|
248
|
+
});
|
|
249
|
+
response.on("end", () => resolve(Buffer.concat(chunks)));
|
|
250
|
+
response.on("error", reject);
|
|
251
|
+
});
|
|
252
|
+
active.setTimeout(timeoutMs, () => active.destroy(new Error("HTTPS snapshot request timed out")));
|
|
253
|
+
deadline = setTimeout(() => active.destroy(new Error("HTTPS snapshot request timed out")), timeoutMs);
|
|
254
|
+
active.on("error", reject);
|
|
255
|
+
active.end();
|
|
256
|
+
});
|
|
257
|
+
let snapshot;
|
|
258
|
+
try {
|
|
259
|
+
snapshot = JSON.parse(body.toString("utf8"));
|
|
260
|
+
} catch {
|
|
261
|
+
throw new Error("HTTPS snapshot response is not valid JSON");
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
snapshot: validateHttpsSnapshot(snapshot),
|
|
265
|
+
endpoint: `${resolved.endpoint.origin}${resolved.endpoint.pathname}`,
|
|
266
|
+
authenticatedRequest: Boolean(token)
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function validateHttpsSnapshot(snapshot) {
|
|
271
|
+
if (!exactKeys(snapshot, [
|
|
272
|
+
"schema", "snapshotId", "scopeId", "adapterId", "generatedAt",
|
|
273
|
+
"manifest", "events", "authority", "digest"
|
|
274
|
+
]) || snapshot.schema !== SCHEMA || !ID_RE.test(snapshot.snapshotId || "")
|
|
275
|
+
|| !ID_RE.test(snapshot.scopeId || "") || !ID_RE.test(snapshot.adapterId || "")
|
|
276
|
+
|| !validDate(snapshot.generatedAt) || snapshot.authority !== "context-only"
|
|
277
|
+
|| !DIGEST_RE.test(snapshot.digest || "") || digest(withoutDigest(snapshot)) !== snapshot.digest
|
|
278
|
+
|| !Array.isArray(snapshot.events) || snapshot.events.length > MAX_EVENTS) {
|
|
279
|
+
throw new Error("HTTPS snapshot is invalid or has failed its integrity check");
|
|
280
|
+
}
|
|
281
|
+
if (Buffer.byteLength(JSON.stringify(snapshot)) > MAX_SNAPSHOT_BYTES) {
|
|
282
|
+
throw new Error("HTTPS snapshot exceeds the 21 MiB validation limit");
|
|
283
|
+
}
|
|
284
|
+
const verifiedManifest = verifyEnvelope(snapshot.manifest, "manifest");
|
|
285
|
+
if (verifiedManifest.payload?.scopeId !== snapshot.scopeId
|
|
286
|
+
|| verifiedManifest.payload?.adapterId !== snapshot.adapterId
|
|
287
|
+
|| verifiedManifest.payload?.adapter !== "directory") {
|
|
288
|
+
throw new Error("HTTPS snapshot metadata does not match its signed manifest");
|
|
289
|
+
}
|
|
290
|
+
const seen = new Map();
|
|
291
|
+
for (const document of snapshot.events) {
|
|
292
|
+
const verified = verifyEnvelope(document, "event");
|
|
293
|
+
const event = validateSharedEvent(verified.payload, snapshot.scopeId);
|
|
294
|
+
const previous = seen.get(event.id);
|
|
295
|
+
if (previous && previous !== event.digest) throw new Error(`HTTPS snapshot event ID collision: ${event.id}`);
|
|
296
|
+
if (previous) throw new Error(`HTTPS snapshot contains duplicate event: ${event.id}`);
|
|
297
|
+
seen.set(event.id, event.digest);
|
|
298
|
+
}
|
|
299
|
+
return snapshot;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function buildHttpsSnapshot({
|
|
303
|
+
root = process.cwd(), directory, snapshotId = `snapshot:${randomUUID()}`, now = new Date()
|
|
304
|
+
}) {
|
|
305
|
+
if (!ID_RE.test(snapshotId || "")) throw new Error("snapshotId must be a stable, whitespace-free identifier");
|
|
306
|
+
const exchange = await readDirectoryExchange({ root, directory, requireAuthenticated: true });
|
|
307
|
+
const generatedAt = new Date(now).toISOString();
|
|
308
|
+
if (!validDate(generatedAt)) throw new Error("now must be a valid date");
|
|
309
|
+
const body = {
|
|
310
|
+
schema: SCHEMA, snapshotId, scopeId: exchange.scopeId, adapterId: exchange.adapterId,
|
|
311
|
+
generatedAt, manifest: exchange.manifest, events: exchange.events,
|
|
312
|
+
authority: "context-only"
|
|
313
|
+
};
|
|
314
|
+
return validateHttpsSnapshot({ ...body, digest: digest(body) });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function exportHttpsSnapshot({
|
|
318
|
+
root = process.cwd(), directory, output, snapshotId = `snapshot:${randomUUID()}`,
|
|
319
|
+
confirmation, now = new Date()
|
|
320
|
+
}) {
|
|
321
|
+
if (confirmation !== CONFIRMATION) throw new Error("HTTPS snapshot export requires explicit local owner confirmation");
|
|
322
|
+
if (!output || typeof output !== "string") throw new Error("HTTPS snapshot output path is required");
|
|
323
|
+
const snapshot = await buildHttpsSnapshot({ root, directory, snapshotId, now });
|
|
324
|
+
const catalog = await buildCatalog(root);
|
|
325
|
+
const target = resolve(output);
|
|
326
|
+
const prospectiveTarget = await prospectiveCanonicalPath(target);
|
|
327
|
+
if (isInside(catalog.root, prospectiveTarget)) {
|
|
328
|
+
throw new Error("HTTPS snapshot output must remain outside the scanned project");
|
|
329
|
+
}
|
|
330
|
+
let parent = dirname(target);
|
|
331
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
332
|
+
parent = await realpath(parent);
|
|
333
|
+
if (isInside(catalog.root, parent)) {
|
|
334
|
+
throw new Error("HTTPS snapshot output must remain outside the scanned project");
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
await lstat(target);
|
|
338
|
+
throw new Error("HTTPS snapshot output already exists; choose a new path");
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error.code !== "ENOENT") throw error;
|
|
341
|
+
}
|
|
342
|
+
const content = `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
343
|
+
if (Buffer.byteLength(content) > MAX_SNAPSHOT_BYTES) throw new Error("HTTPS snapshot exceeds the 21 MiB export limit");
|
|
344
|
+
const handle = await open(target, "wx", 0o600);
|
|
345
|
+
try {
|
|
346
|
+
await handle.writeFile(content, "utf8");
|
|
347
|
+
await handle.sync();
|
|
348
|
+
} finally {
|
|
349
|
+
await handle.close();
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
created: true, output: target, snapshotId: snapshot.snapshotId,
|
|
353
|
+
scopeId: snapshot.scopeId, adapterId: snapshot.adapterId,
|
|
354
|
+
events: snapshot.events.length, digest: snapshot.digest,
|
|
355
|
+
authority: "context-only"
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function importHttpsSnapshot({ root = process.cwd(), snapshot, now = new Date() }) {
|
|
360
|
+
const validated = validateHttpsSnapshot(structuredClone(snapshot));
|
|
361
|
+
const directory = await mkdtemp(join(tmpdir(), "agentspine-https-snapshot-"));
|
|
362
|
+
try {
|
|
363
|
+
const eventsDirectory = join(directory, "events");
|
|
364
|
+
await mkdir(eventsDirectory, { mode: 0o700 });
|
|
365
|
+
await writeFile(
|
|
366
|
+
join(directory, ".agentspine-exchange.json"),
|
|
367
|
+
`${JSON.stringify(validated.manifest, null, 2)}\n`, { mode: 0o600 }
|
|
368
|
+
);
|
|
369
|
+
for (const document of validated.events) {
|
|
370
|
+
const id = eventId(document);
|
|
371
|
+
await writeFile(join(eventsDirectory, eventFilename(id)), `${JSON.stringify(document, null, 2)}\n`, {
|
|
372
|
+
mode: 0o600, flag: "wx"
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
const result = await pullShared({ root, directory, requireAuthenticated: true, now });
|
|
376
|
+
return {
|
|
377
|
+
...result, transport: "https-snapshot", snapshotId: validated.snapshotId,
|
|
378
|
+
snapshotDigest: validated.digest, authority: "context-only"
|
|
379
|
+
};
|
|
380
|
+
} finally {
|
|
381
|
+
await rm(directory, { recursive: true, force: true });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function pullHttpsSnapshot(options) {
|
|
386
|
+
const fetched = await fetchHttpsSnapshot(options);
|
|
387
|
+
const imported = await importHttpsSnapshot({ root: options.root, snapshot: fetched.snapshot, now: options.now });
|
|
388
|
+
return {
|
|
389
|
+
...imported, endpoint: fetched.endpoint,
|
|
390
|
+
authenticatedRequest: fetched.authenticatedRequest
|
|
391
|
+
};
|
|
392
|
+
}
|