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,283 +1,283 @@
|
|
|
1
|
-
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
assertTrustedIdentity, loadTrust, signEnvelope, verifyEnvelope
|
|
5
|
-
} from "./authentication.js";
|
|
6
|
-
import {
|
|
7
|
-
buildHttpsSnapshot, importHttpsSnapshot, validateHttpsSnapshot
|
|
8
|
-
} from "./https-transport.js";
|
|
9
|
-
|
|
10
|
-
const REQUEST_SCHEMA = "agentspine.peer-request/v1";
|
|
11
|
-
const RESPONSE_SCHEMA = "agentspine.peer-response/v1";
|
|
12
|
-
const CONFIRMATION = "local-share-confirmed";
|
|
13
|
-
const DEFAULT_MAX_BYTES = 22 * 1024 * 1024;
|
|
14
|
-
const MIN_MAX_BYTES = 1024 * 1024;
|
|
15
|
-
const MAX_MAX_BYTES = 22 * 1024 * 1024;
|
|
16
|
-
const MAX_REQUEST_BYTES = 4096;
|
|
17
|
-
const MAX_COMMAND_BYTES = 32768;
|
|
18
|
-
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
19
|
-
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
20
|
-
const CHALLENGE_RE = /^[a-f0-9]{64}$/;
|
|
21
|
-
|
|
22
|
-
function canonical(value) {
|
|
23
|
-
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
24
|
-
if (value && typeof value === "object") {
|
|
25
|
-
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
26
|
-
}
|
|
27
|
-
return JSON.stringify(value);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function digest(value) {
|
|
31
|
-
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function exactKeys(value, expected) {
|
|
35
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
36
|
-
const actual = Object.keys(value).sort();
|
|
37
|
-
const wanted = [...expected].sort();
|
|
38
|
-
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function validDate(value) {
|
|
42
|
-
return typeof value === "string" && Number.isFinite(new Date(value).getTime());
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function isoDate(value, field = "date") {
|
|
46
|
-
const parsed = value instanceof Date ? value : new Date(value);
|
|
47
|
-
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
48
|
-
return parsed.toISOString();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function byteLimit(value) {
|
|
52
|
-
if (!Number.isInteger(value) || value < MIN_MAX_BYTES || value > MAX_MAX_BYTES) {
|
|
53
|
-
throw new Error("peer maxBytes must be an integer between 1 MiB and 22 MiB");
|
|
54
|
-
}
|
|
55
|
-
return value;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function timeout(value) {
|
|
59
|
-
if (!Number.isInteger(value) || value < 1000 || value > 30000) {
|
|
60
|
-
throw new Error("peer timeout must be between 1000 and 30000 milliseconds");
|
|
61
|
-
}
|
|
62
|
-
return value;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function createPeerRequest({
|
|
66
|
-
requestId = `request:${randomUUID()}`, challenge = randomBytes(32).toString("hex"),
|
|
67
|
-
maxBytes = DEFAULT_MAX_BYTES
|
|
68
|
-
} = {}) {
|
|
69
|
-
byteLimit(maxBytes);
|
|
70
|
-
if (!ID_RE.test(requestId || "")) throw new Error("peer requestId must be a stable, whitespace-free identifier");
|
|
71
|
-
if (!CHALLENGE_RE.test(challenge || "")) throw new Error("peer challenge must be 32 random bytes encoded as lowercase hex");
|
|
72
|
-
return {
|
|
73
|
-
schema: REQUEST_SCHEMA, requestId, challenge, maxBytes, authority: "context-only"
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function validatePeerRequest(request) {
|
|
78
|
-
if (!exactKeys(request, ["schema", "requestId", "challenge", "maxBytes", "authority"])
|
|
79
|
-
|| request.schema !== REQUEST_SCHEMA || !ID_RE.test(request.requestId || "")
|
|
80
|
-
|| !CHALLENGE_RE.test(request.challenge || "") || request.authority !== "context-only") {
|
|
81
|
-
throw new Error("peer request is invalid");
|
|
82
|
-
}
|
|
83
|
-
byteLimit(request.maxBytes);
|
|
84
|
-
if (Buffer.byteLength(JSON.stringify(request)) > MAX_REQUEST_BYTES) throw new Error("peer request exceeds 4 KiB");
|
|
85
|
-
return request;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function buildPeerResponse({
|
|
89
|
-
root = process.cwd(), directory, signerId, request, now = new Date(),
|
|
90
|
-
snapshotId = `snapshot:peer:${randomUUID()}`
|
|
91
|
-
}) {
|
|
92
|
-
const validatedRequest = validatePeerRequest(structuredClone(request));
|
|
93
|
-
if (!ID_RE.test(signerId || "")) throw new Error("signerId is required for peer responses");
|
|
94
|
-
const snapshot = await buildHttpsSnapshot({ root, directory, snapshotId, now });
|
|
95
|
-
const body = {
|
|
96
|
-
schema: RESPONSE_SCHEMA,
|
|
97
|
-
requestId: validatedRequest.requestId,
|
|
98
|
-
challenge: validatedRequest.challenge,
|
|
99
|
-
snapshot,
|
|
100
|
-
createdAt: isoDate(now, "now"),
|
|
101
|
-
authority: "context-only"
|
|
102
|
-
};
|
|
103
|
-
const payload = { ...body, digest: digest(body) };
|
|
104
|
-
const envelope = await signEnvelope({ root, signerId, kind: "manifest", payload, now });
|
|
105
|
-
const manifest = verifyEnvelope(snapshot.manifest, "manifest");
|
|
106
|
-
if (manifest.publicIdentity.keyId !== envelope.signer.keyId) {
|
|
107
|
-
throw new Error("peer response signer must match the snapshot manifest signer");
|
|
108
|
-
}
|
|
109
|
-
if (Buffer.byteLength(JSON.stringify(envelope)) > validatedRequest.maxBytes) {
|
|
110
|
-
throw new Error("peer response exceeds the request byte limit");
|
|
111
|
-
}
|
|
112
|
-
return envelope;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function validatePeerResponse(envelope, request) {
|
|
116
|
-
const validatedRequest = validatePeerRequest(structuredClone(request));
|
|
117
|
-
const verified = verifyEnvelope(envelope, "manifest");
|
|
118
|
-
const response = verified.payload;
|
|
119
|
-
if (!exactKeys(response, [
|
|
120
|
-
"schema", "requestId", "challenge", "snapshot", "createdAt", "authority", "digest"
|
|
121
|
-
]) || response.schema !== RESPONSE_SCHEMA || response.requestId !== validatedRequest.requestId
|
|
122
|
-
|| response.challenge !== validatedRequest.challenge || !validDate(response.createdAt)
|
|
123
|
-
|| response.authority !== "context-only" || !DIGEST_RE.test(response.digest || "")) {
|
|
124
|
-
throw new Error("peer response does not match the live request");
|
|
125
|
-
}
|
|
126
|
-
const { digest: _digest, ...body } = response;
|
|
127
|
-
if (digest(body) !== response.digest) throw new Error("peer response failed its integrity check");
|
|
128
|
-
const snapshot = validateHttpsSnapshot(structuredClone(response.snapshot));
|
|
129
|
-
const manifest = verifyEnvelope(snapshot.manifest, "manifest");
|
|
130
|
-
if (manifest.publicIdentity.keyId !== verified.publicIdentity.keyId) {
|
|
131
|
-
throw new Error("peer response signer does not match the snapshot manifest signer");
|
|
132
|
-
}
|
|
133
|
-
if (Buffer.byteLength(JSON.stringify(envelope)) > validatedRequest.maxBytes) {
|
|
134
|
-
throw new Error("peer response exceeds the request byte limit");
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
envelope, response, snapshot,
|
|
138
|
-
authentication: verified.authentication,
|
|
139
|
-
publicIdentity: verified.publicIdentity
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function readJsonLine(input, { maxBytes, timeoutMs, label }) {
|
|
144
|
-
let timer;
|
|
145
|
-
const chunks = [];
|
|
146
|
-
let bytes = 0;
|
|
147
|
-
let complete = false;
|
|
148
|
-
const deadline = new Promise((_resolve, reject) => {
|
|
149
|
-
timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
|
|
150
|
-
});
|
|
151
|
-
const reading = (async () => {
|
|
152
|
-
for await (const chunkValue of input) {
|
|
153
|
-
const chunk = Buffer.isBuffer(chunkValue) ? chunkValue : Buffer.from(chunkValue);
|
|
154
|
-
bytes += chunk.length;
|
|
155
|
-
if (bytes > maxBytes + 1) throw new Error(`${label} exceeds its byte limit`);
|
|
156
|
-
chunks.push(chunk);
|
|
157
|
-
if (chunk.includes(0x0a)) { complete = true; break; }
|
|
158
|
-
}
|
|
159
|
-
if (!complete) throw new Error(`${label} ended before one complete JSON line`);
|
|
160
|
-
const content = Buffer.concat(chunks);
|
|
161
|
-
const newline = content.indexOf(0x0a);
|
|
162
|
-
if (newline > maxBytes) throw new Error(`${label} exceeds its byte limit`);
|
|
163
|
-
if (content.subarray(newline + 1).toString("utf8").trim()) {
|
|
164
|
-
throw new Error(`${label} contains unexpected output after the JSON frame`);
|
|
165
|
-
}
|
|
166
|
-
try { return JSON.parse(content.subarray(0, newline).toString("utf8")); } catch {
|
|
167
|
-
throw new Error(`${label} is not valid JSON`);
|
|
168
|
-
}
|
|
169
|
-
})();
|
|
170
|
-
try { return await Promise.race([reading, deadline]); } finally { clearTimeout(timer); }
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export async function servePeerOnce({
|
|
174
|
-
root = process.cwd(), directory, signerId, input = process.stdin, output = process.stdout,
|
|
175
|
-
confirmation = null, timeoutMs = 10000, now = new Date()
|
|
176
|
-
}) {
|
|
177
|
-
if (confirmation !== CONFIRMATION) throw new Error("peer serving requires explicit local owner confirmation");
|
|
178
|
-
timeout(timeoutMs);
|
|
179
|
-
const request = validatePeerRequest(await readJsonLine(input, {
|
|
180
|
-
maxBytes: MAX_REQUEST_BYTES, timeoutMs, label: "peer request"
|
|
181
|
-
}));
|
|
182
|
-
const envelope = await buildPeerResponse({ root, directory, signerId, request, now });
|
|
183
|
-
const frame = `${JSON.stringify(envelope)}\n`;
|
|
184
|
-
if (Buffer.byteLength(frame) > request.maxBytes + 1) throw new Error("peer response exceeds the request byte limit");
|
|
185
|
-
await new Promise((resolvePromise, rejectPromise) => {
|
|
186
|
-
output.write(frame, (error) => error ? rejectPromise(error) : resolvePromise());
|
|
187
|
-
});
|
|
188
|
-
return {
|
|
189
|
-
served: true, requestId: request.requestId,
|
|
190
|
-
snapshotId: envelope.payload.snapshot.snapshotId,
|
|
191
|
-
snapshotDigest: envelope.payload.snapshot.digest,
|
|
192
|
-
bytes: Buffer.byteLength(frame) - 1,
|
|
193
|
-
authority: "context-only"
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function parseCommand(commandJson) {
|
|
198
|
-
let command;
|
|
199
|
-
try { command = typeof commandJson === "string" ? JSON.parse(commandJson) : structuredClone(commandJson); } catch {
|
|
200
|
-
throw new Error("peer command must be a JSON array of an executable and arguments");
|
|
201
|
-
}
|
|
202
|
-
if (!Array.isArray(command) || command.length < 1 || command.length > 64
|
|
203
|
-
|| command.some((item) => typeof item !== "string" || !item || item.length > 8192 || item.includes("\0"))
|
|
204
|
-
|| Buffer.byteLength(JSON.stringify(command)) > MAX_COMMAND_BYTES) {
|
|
205
|
-
throw new Error("peer command must contain 1 to 64 bounded strings");
|
|
206
|
-
}
|
|
207
|
-
return command;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function processEnvironment(environment = process.env) {
|
|
211
|
-
const allowed = new Set([
|
|
212
|
-
"PATH", "Path", "HOME", "USERPROFILE", "SystemRoot", "SYSTEMROOT", "WINDIR",
|
|
213
|
-
"TMP", "TEMP", "TMPDIR", "LANG", "LC_ALL", "SSH_AUTH_SOCK", "SSH_AGENT_PID",
|
|
214
|
-
"AGENTSPINE_STATE_DIR", "XDG_STATE_HOME", "LOCALAPPDATA", "APPDATA"
|
|
215
|
-
]);
|
|
216
|
-
return Object.fromEntries(Object.entries(environment).filter(([key]) => allowed.has(key)));
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async function exchangeCommand({ command, request, timeoutMs, spawnProcess }) {
|
|
220
|
-
const [executable, ...args] = command;
|
|
221
|
-
const child = spawnProcess(executable, args, {
|
|
222
|
-
shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
|
|
223
|
-
env: processEnvironment()
|
|
224
|
-
});
|
|
225
|
-
const processError = new Promise((_resolve, reject) => {
|
|
226
|
-
child.once("error", () => reject(new Error("peer command could not be started")));
|
|
227
|
-
});
|
|
228
|
-
let stderrBytes = 0;
|
|
229
|
-
child.stderr?.on("data", (chunk) => {
|
|
230
|
-
stderrBytes += chunk.length;
|
|
231
|
-
if (stderrBytes > 64 * 1024) child.kill();
|
|
232
|
-
});
|
|
233
|
-
child.stdin.on("error", () => {});
|
|
234
|
-
child.stdin.end(`${JSON.stringify(request)}\n`);
|
|
235
|
-
try {
|
|
236
|
-
return await Promise.race([
|
|
237
|
-
readJsonLine(child.stdout, {
|
|
238
|
-
maxBytes: request.maxBytes, timeoutMs, label: "peer response"
|
|
239
|
-
}),
|
|
240
|
-
processError
|
|
241
|
-
]);
|
|
242
|
-
} finally {
|
|
243
|
-
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export async function importPeerResponse({
|
|
248
|
-
root = process.cwd(), envelope, request, now = new Date()
|
|
249
|
-
}) {
|
|
250
|
-
const validated = validatePeerResponse(structuredClone(envelope), request);
|
|
251
|
-
const { trust } = await loadTrust(root);
|
|
252
|
-
assertTrustedIdentity(validated.publicIdentity, trust);
|
|
253
|
-
const imported = await importHttpsSnapshot({ root, snapshot: validated.snapshot, now });
|
|
254
|
-
return {
|
|
255
|
-
...imported,
|
|
256
|
-
transport: "peer-stdio",
|
|
257
|
-
requestId: validated.response.requestId,
|
|
258
|
-
snapshotId: validated.snapshot.snapshotId,
|
|
259
|
-
snapshotDigest: validated.snapshot.digest,
|
|
260
|
-
peerSignerId: validated.publicIdentity.signerId,
|
|
261
|
-
peerKeyId: validated.publicIdentity.keyId,
|
|
262
|
-
authority: "context-only"
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export async function pullPeerCommand({
|
|
267
|
-
root = process.cwd(), commandJson, confirmation = null,
|
|
268
|
-
timeoutMs = 10000, maxBytes = DEFAULT_MAX_BYTES,
|
|
269
|
-
now = new Date(), spawnProcess = spawn
|
|
270
|
-
}) {
|
|
271
|
-
if (confirmation !== CONFIRMATION) throw new Error("peer command execution requires explicit local owner confirmation");
|
|
272
|
-
timeout(timeoutMs);
|
|
273
|
-
const command = parseCommand(commandJson);
|
|
274
|
-
const request = createPeerRequest({ maxBytes });
|
|
275
|
-
const envelope = await exchangeCommand({ command, request, timeoutMs, spawnProcess });
|
|
276
|
-
const imported = await importPeerResponse({ root, envelope, request, now });
|
|
277
|
-
return {
|
|
278
|
-
...imported,
|
|
279
|
-
executable: command[0],
|
|
280
|
-
commandArguments: command.length - 1,
|
|
281
|
-
authority: "context-only"
|
|
282
|
-
};
|
|
283
|
-
}
|
|
1
|
+
import { randomBytes, randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
assertTrustedIdentity, loadTrust, signEnvelope, verifyEnvelope
|
|
5
|
+
} from "./authentication.js";
|
|
6
|
+
import {
|
|
7
|
+
buildHttpsSnapshot, importHttpsSnapshot, validateHttpsSnapshot
|
|
8
|
+
} from "./https-transport.js";
|
|
9
|
+
|
|
10
|
+
const REQUEST_SCHEMA = "agentspine.peer-request/v1";
|
|
11
|
+
const RESPONSE_SCHEMA = "agentspine.peer-response/v1";
|
|
12
|
+
const CONFIRMATION = "local-share-confirmed";
|
|
13
|
+
const DEFAULT_MAX_BYTES = 22 * 1024 * 1024;
|
|
14
|
+
const MIN_MAX_BYTES = 1024 * 1024;
|
|
15
|
+
const MAX_MAX_BYTES = 22 * 1024 * 1024;
|
|
16
|
+
const MAX_REQUEST_BYTES = 4096;
|
|
17
|
+
const MAX_COMMAND_BYTES = 32768;
|
|
18
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/-]{0,127}$/;
|
|
19
|
+
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
20
|
+
const CHALLENGE_RE = /^[a-f0-9]{64}$/;
|
|
21
|
+
|
|
22
|
+
function canonical(value) {
|
|
23
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
24
|
+
if (value && typeof value === "object") {
|
|
25
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
26
|
+
}
|
|
27
|
+
return JSON.stringify(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function digest(value) {
|
|
31
|
+
return createHash("sha256").update(canonical(value)).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function exactKeys(value, expected) {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
36
|
+
const actual = Object.keys(value).sort();
|
|
37
|
+
const wanted = [...expected].sort();
|
|
38
|
+
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validDate(value) {
|
|
42
|
+
return typeof value === "string" && Number.isFinite(new Date(value).getTime());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isoDate(value, field = "date") {
|
|
46
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
47
|
+
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
48
|
+
return parsed.toISOString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function byteLimit(value) {
|
|
52
|
+
if (!Number.isInteger(value) || value < MIN_MAX_BYTES || value > MAX_MAX_BYTES) {
|
|
53
|
+
throw new Error("peer maxBytes must be an integer between 1 MiB and 22 MiB");
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function timeout(value) {
|
|
59
|
+
if (!Number.isInteger(value) || value < 1000 || value > 30000) {
|
|
60
|
+
throw new Error("peer timeout must be between 1000 and 30000 milliseconds");
|
|
61
|
+
}
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createPeerRequest({
|
|
66
|
+
requestId = `request:${randomUUID()}`, challenge = randomBytes(32).toString("hex"),
|
|
67
|
+
maxBytes = DEFAULT_MAX_BYTES
|
|
68
|
+
} = {}) {
|
|
69
|
+
byteLimit(maxBytes);
|
|
70
|
+
if (!ID_RE.test(requestId || "")) throw new Error("peer requestId must be a stable, whitespace-free identifier");
|
|
71
|
+
if (!CHALLENGE_RE.test(challenge || "")) throw new Error("peer challenge must be 32 random bytes encoded as lowercase hex");
|
|
72
|
+
return {
|
|
73
|
+
schema: REQUEST_SCHEMA, requestId, challenge, maxBytes, authority: "context-only"
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function validatePeerRequest(request) {
|
|
78
|
+
if (!exactKeys(request, ["schema", "requestId", "challenge", "maxBytes", "authority"])
|
|
79
|
+
|| request.schema !== REQUEST_SCHEMA || !ID_RE.test(request.requestId || "")
|
|
80
|
+
|| !CHALLENGE_RE.test(request.challenge || "") || request.authority !== "context-only") {
|
|
81
|
+
throw new Error("peer request is invalid");
|
|
82
|
+
}
|
|
83
|
+
byteLimit(request.maxBytes);
|
|
84
|
+
if (Buffer.byteLength(JSON.stringify(request)) > MAX_REQUEST_BYTES) throw new Error("peer request exceeds 4 KiB");
|
|
85
|
+
return request;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function buildPeerResponse({
|
|
89
|
+
root = process.cwd(), directory, signerId, request, now = new Date(),
|
|
90
|
+
snapshotId = `snapshot:peer:${randomUUID()}`
|
|
91
|
+
}) {
|
|
92
|
+
const validatedRequest = validatePeerRequest(structuredClone(request));
|
|
93
|
+
if (!ID_RE.test(signerId || "")) throw new Error("signerId is required for peer responses");
|
|
94
|
+
const snapshot = await buildHttpsSnapshot({ root, directory, snapshotId, now });
|
|
95
|
+
const body = {
|
|
96
|
+
schema: RESPONSE_SCHEMA,
|
|
97
|
+
requestId: validatedRequest.requestId,
|
|
98
|
+
challenge: validatedRequest.challenge,
|
|
99
|
+
snapshot,
|
|
100
|
+
createdAt: isoDate(now, "now"),
|
|
101
|
+
authority: "context-only"
|
|
102
|
+
};
|
|
103
|
+
const payload = { ...body, digest: digest(body) };
|
|
104
|
+
const envelope = await signEnvelope({ root, signerId, kind: "manifest", payload, now });
|
|
105
|
+
const manifest = verifyEnvelope(snapshot.manifest, "manifest");
|
|
106
|
+
if (manifest.publicIdentity.keyId !== envelope.signer.keyId) {
|
|
107
|
+
throw new Error("peer response signer must match the snapshot manifest signer");
|
|
108
|
+
}
|
|
109
|
+
if (Buffer.byteLength(JSON.stringify(envelope)) > validatedRequest.maxBytes) {
|
|
110
|
+
throw new Error("peer response exceeds the request byte limit");
|
|
111
|
+
}
|
|
112
|
+
return envelope;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function validatePeerResponse(envelope, request) {
|
|
116
|
+
const validatedRequest = validatePeerRequest(structuredClone(request));
|
|
117
|
+
const verified = verifyEnvelope(envelope, "manifest");
|
|
118
|
+
const response = verified.payload;
|
|
119
|
+
if (!exactKeys(response, [
|
|
120
|
+
"schema", "requestId", "challenge", "snapshot", "createdAt", "authority", "digest"
|
|
121
|
+
]) || response.schema !== RESPONSE_SCHEMA || response.requestId !== validatedRequest.requestId
|
|
122
|
+
|| response.challenge !== validatedRequest.challenge || !validDate(response.createdAt)
|
|
123
|
+
|| response.authority !== "context-only" || !DIGEST_RE.test(response.digest || "")) {
|
|
124
|
+
throw new Error("peer response does not match the live request");
|
|
125
|
+
}
|
|
126
|
+
const { digest: _digest, ...body } = response;
|
|
127
|
+
if (digest(body) !== response.digest) throw new Error("peer response failed its integrity check");
|
|
128
|
+
const snapshot = validateHttpsSnapshot(structuredClone(response.snapshot));
|
|
129
|
+
const manifest = verifyEnvelope(snapshot.manifest, "manifest");
|
|
130
|
+
if (manifest.publicIdentity.keyId !== verified.publicIdentity.keyId) {
|
|
131
|
+
throw new Error("peer response signer does not match the snapshot manifest signer");
|
|
132
|
+
}
|
|
133
|
+
if (Buffer.byteLength(JSON.stringify(envelope)) > validatedRequest.maxBytes) {
|
|
134
|
+
throw new Error("peer response exceeds the request byte limit");
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
envelope, response, snapshot,
|
|
138
|
+
authentication: verified.authentication,
|
|
139
|
+
publicIdentity: verified.publicIdentity
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function readJsonLine(input, { maxBytes, timeoutMs, label }) {
|
|
144
|
+
let timer;
|
|
145
|
+
const chunks = [];
|
|
146
|
+
let bytes = 0;
|
|
147
|
+
let complete = false;
|
|
148
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
149
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
|
|
150
|
+
});
|
|
151
|
+
const reading = (async () => {
|
|
152
|
+
for await (const chunkValue of input) {
|
|
153
|
+
const chunk = Buffer.isBuffer(chunkValue) ? chunkValue : Buffer.from(chunkValue);
|
|
154
|
+
bytes += chunk.length;
|
|
155
|
+
if (bytes > maxBytes + 1) throw new Error(`${label} exceeds its byte limit`);
|
|
156
|
+
chunks.push(chunk);
|
|
157
|
+
if (chunk.includes(0x0a)) { complete = true; break; }
|
|
158
|
+
}
|
|
159
|
+
if (!complete) throw new Error(`${label} ended before one complete JSON line`);
|
|
160
|
+
const content = Buffer.concat(chunks);
|
|
161
|
+
const newline = content.indexOf(0x0a);
|
|
162
|
+
if (newline > maxBytes) throw new Error(`${label} exceeds its byte limit`);
|
|
163
|
+
if (content.subarray(newline + 1).toString("utf8").trim()) {
|
|
164
|
+
throw new Error(`${label} contains unexpected output after the JSON frame`);
|
|
165
|
+
}
|
|
166
|
+
try { return JSON.parse(content.subarray(0, newline).toString("utf8")); } catch {
|
|
167
|
+
throw new Error(`${label} is not valid JSON`);
|
|
168
|
+
}
|
|
169
|
+
})();
|
|
170
|
+
try { return await Promise.race([reading, deadline]); } finally { clearTimeout(timer); }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function servePeerOnce({
|
|
174
|
+
root = process.cwd(), directory, signerId, input = process.stdin, output = process.stdout,
|
|
175
|
+
confirmation = null, timeoutMs = 10000, now = new Date()
|
|
176
|
+
}) {
|
|
177
|
+
if (confirmation !== CONFIRMATION) throw new Error("peer serving requires explicit local owner confirmation");
|
|
178
|
+
timeout(timeoutMs);
|
|
179
|
+
const request = validatePeerRequest(await readJsonLine(input, {
|
|
180
|
+
maxBytes: MAX_REQUEST_BYTES, timeoutMs, label: "peer request"
|
|
181
|
+
}));
|
|
182
|
+
const envelope = await buildPeerResponse({ root, directory, signerId, request, now });
|
|
183
|
+
const frame = `${JSON.stringify(envelope)}\n`;
|
|
184
|
+
if (Buffer.byteLength(frame) > request.maxBytes + 1) throw new Error("peer response exceeds the request byte limit");
|
|
185
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
186
|
+
output.write(frame, (error) => error ? rejectPromise(error) : resolvePromise());
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
served: true, requestId: request.requestId,
|
|
190
|
+
snapshotId: envelope.payload.snapshot.snapshotId,
|
|
191
|
+
snapshotDigest: envelope.payload.snapshot.digest,
|
|
192
|
+
bytes: Buffer.byteLength(frame) - 1,
|
|
193
|
+
authority: "context-only"
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseCommand(commandJson) {
|
|
198
|
+
let command;
|
|
199
|
+
try { command = typeof commandJson === "string" ? JSON.parse(commandJson) : structuredClone(commandJson); } catch {
|
|
200
|
+
throw new Error("peer command must be a JSON array of an executable and arguments");
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(command) || command.length < 1 || command.length > 64
|
|
203
|
+
|| command.some((item) => typeof item !== "string" || !item || item.length > 8192 || item.includes("\0"))
|
|
204
|
+
|| Buffer.byteLength(JSON.stringify(command)) > MAX_COMMAND_BYTES) {
|
|
205
|
+
throw new Error("peer command must contain 1 to 64 bounded strings");
|
|
206
|
+
}
|
|
207
|
+
return command;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function processEnvironment(environment = process.env) {
|
|
211
|
+
const allowed = new Set([
|
|
212
|
+
"PATH", "Path", "HOME", "USERPROFILE", "SystemRoot", "SYSTEMROOT", "WINDIR",
|
|
213
|
+
"TMP", "TEMP", "TMPDIR", "LANG", "LC_ALL", "SSH_AUTH_SOCK", "SSH_AGENT_PID",
|
|
214
|
+
"AGENTSPINE_STATE_DIR", "XDG_STATE_HOME", "LOCALAPPDATA", "APPDATA"
|
|
215
|
+
]);
|
|
216
|
+
return Object.fromEntries(Object.entries(environment).filter(([key]) => allowed.has(key)));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function exchangeCommand({ command, request, timeoutMs, spawnProcess }) {
|
|
220
|
+
const [executable, ...args] = command;
|
|
221
|
+
const child = spawnProcess(executable, args, {
|
|
222
|
+
shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
|
|
223
|
+
env: processEnvironment()
|
|
224
|
+
});
|
|
225
|
+
const processError = new Promise((_resolve, reject) => {
|
|
226
|
+
child.once("error", () => reject(new Error("peer command could not be started")));
|
|
227
|
+
});
|
|
228
|
+
let stderrBytes = 0;
|
|
229
|
+
child.stderr?.on("data", (chunk) => {
|
|
230
|
+
stderrBytes += chunk.length;
|
|
231
|
+
if (stderrBytes > 64 * 1024) child.kill();
|
|
232
|
+
});
|
|
233
|
+
child.stdin.on("error", () => {});
|
|
234
|
+
child.stdin.end(`${JSON.stringify(request)}\n`);
|
|
235
|
+
try {
|
|
236
|
+
return await Promise.race([
|
|
237
|
+
readJsonLine(child.stdout, {
|
|
238
|
+
maxBytes: request.maxBytes, timeoutMs, label: "peer response"
|
|
239
|
+
}),
|
|
240
|
+
processError
|
|
241
|
+
]);
|
|
242
|
+
} finally {
|
|
243
|
+
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function importPeerResponse({
|
|
248
|
+
root = process.cwd(), envelope, request, now = new Date()
|
|
249
|
+
}) {
|
|
250
|
+
const validated = validatePeerResponse(structuredClone(envelope), request);
|
|
251
|
+
const { trust } = await loadTrust(root);
|
|
252
|
+
assertTrustedIdentity(validated.publicIdentity, trust);
|
|
253
|
+
const imported = await importHttpsSnapshot({ root, snapshot: validated.snapshot, now });
|
|
254
|
+
return {
|
|
255
|
+
...imported,
|
|
256
|
+
transport: "peer-stdio",
|
|
257
|
+
requestId: validated.response.requestId,
|
|
258
|
+
snapshotId: validated.snapshot.snapshotId,
|
|
259
|
+
snapshotDigest: validated.snapshot.digest,
|
|
260
|
+
peerSignerId: validated.publicIdentity.signerId,
|
|
261
|
+
peerKeyId: validated.publicIdentity.keyId,
|
|
262
|
+
authority: "context-only"
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function pullPeerCommand({
|
|
267
|
+
root = process.cwd(), commandJson, confirmation = null,
|
|
268
|
+
timeoutMs = 10000, maxBytes = DEFAULT_MAX_BYTES,
|
|
269
|
+
now = new Date(), spawnProcess = spawn
|
|
270
|
+
}) {
|
|
271
|
+
if (confirmation !== CONFIRMATION) throw new Error("peer command execution requires explicit local owner confirmation");
|
|
272
|
+
timeout(timeoutMs);
|
|
273
|
+
const command = parseCommand(commandJson);
|
|
274
|
+
const request = createPeerRequest({ maxBytes });
|
|
275
|
+
const envelope = await exchangeCommand({ command, request, timeoutMs, spawnProcess });
|
|
276
|
+
const imported = await importPeerResponse({ root, envelope, request, now });
|
|
277
|
+
return {
|
|
278
|
+
...imported,
|
|
279
|
+
executable: command[0],
|
|
280
|
+
commandArguments: command.length - 1,
|
|
281
|
+
authority: "context-only"
|
|
282
|
+
};
|
|
283
|
+
}
|