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,392 +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
|
-
}
|
|
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
|
+
}
|