@awak-app/simy-cli 0.2.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -1
- package/package.json +14 -5
- package/src/agent.js +740 -37
- package/src/bounded-local-task-subtask-pool.js +708 -0
- package/src/bounded-local-task-subtasks-contract.js +1189 -0
- package/src/cli-contract.js +42 -3
- package/src/console/app.js +212 -90
- package/src/desktop-executor.js +21 -2
- package/src/durable-local-task-steps-contract.js +1256 -0
- package/src/durable-local-task-worker.js +2607 -0
- package/src/execution-capability-contract.js +116 -0
- package/src/execution-guardrail.js +69 -12
- package/src/index.js +22 -7
- package/src/local-attachments.js +94 -113
- package/src/local-task-artifact-contract.js +266 -0
- package/src/local-task-attachment-store.js +447 -0
- package/src/local-task-file-capabilities.js +738 -0
- package/src/local-task-scenario-packs.js +681 -0
- package/src/local-task.js +1137 -0
- package/src/orchestrator/audit.js +42 -1
- package/src/orchestrator/loop.js +29 -3
- package/src/repository-inventory.js +37 -1
- package/src/runner.js +44 -0
- package/src/shutdown.js +63 -0
- package/src/sqm/bundle-store.js +249 -0
- package/src/sqm/canonical.js +45 -0
- package/src/sqm/checkers.js +299 -0
- package/src/sqm/command.js +149 -0
- package/src/sqm/evidence-client.js +12 -0
- package/src/sqm/index.js +141 -0
- package/src/sqm/proof.js +154 -0
- package/src/sqm/repository.js +163 -0
- package/src/sqm/session.js +58 -0
- package/src/sqm/validation.js +305 -0
- package/src/workspace-context.js +40 -7
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
normalizeVisualReview,
|
|
14
14
|
} from "./completion-contract.js";
|
|
15
15
|
|
|
16
|
-
export async function auditAttempt(charter, attempt, { previousAttempt = null } = {}) {
|
|
16
|
+
export async function auditAttempt(charter, attempt, { previousAttempt = null, sqm = null } = {}) {
|
|
17
17
|
const checks = [];
|
|
18
18
|
const addCheck = (
|
|
19
19
|
id,
|
|
@@ -219,6 +219,7 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null }
|
|
|
219
219
|
? "Implementation evidence passed local verification."
|
|
220
220
|
: "Implementation evidence failed local verification.",
|
|
221
221
|
checks,
|
|
222
|
+
sqm: normalizeSqmAudit(sqm),
|
|
222
223
|
};
|
|
223
224
|
|
|
224
225
|
return {
|
|
@@ -257,6 +258,46 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null }
|
|
|
257
258
|
release_gate: null,
|
|
258
259
|
work_log_signals: [],
|
|
259
260
|
work_log_interventions: [],
|
|
261
|
+
sqm: normalizeSqmAudit(sqm),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function normalizeSqmAudit(sqm) {
|
|
266
|
+
if (!sqm) return null;
|
|
267
|
+
return {
|
|
268
|
+
status: sqm.status || "unavailable",
|
|
269
|
+
mode: "shadow",
|
|
270
|
+
bundle: sqm.bundle || null,
|
|
271
|
+
warnings: Array.isArray(sqm.warnings) ? sqm.warnings : [],
|
|
272
|
+
findings: (Array.isArray(sqm.findings) ? sqm.findings : []).map((finding) => ({
|
|
273
|
+
finding_id: finding.finding_id,
|
|
274
|
+
module_id: finding.module_id,
|
|
275
|
+
module_version: finding.module_version,
|
|
276
|
+
rule_id: finding.rule_id,
|
|
277
|
+
severity: finding.severity,
|
|
278
|
+
file: finding.file || null,
|
|
279
|
+
line: finding.line || null,
|
|
280
|
+
side: finding.side || null,
|
|
281
|
+
evidence_hash: finding.evidence_hash,
|
|
282
|
+
bundle_id: finding.bundle_id,
|
|
283
|
+
bundle_version: finding.bundle_version,
|
|
284
|
+
bundle_digest: finding.bundle_digest,
|
|
285
|
+
})),
|
|
286
|
+
min_check: sqm.min_check ? {
|
|
287
|
+
result_digest: sqm.min_check.result_digest || null,
|
|
288
|
+
status: sqm.min_check.status || null,
|
|
289
|
+
executed_at: sqm.min_check.executed_at || null,
|
|
290
|
+
} : null,
|
|
291
|
+
proof: sqm.proof ? {
|
|
292
|
+
proof_id: sqm.proof.proof_id || null,
|
|
293
|
+
digest: sqm.proof.integrity?.digest || null,
|
|
294
|
+
outcome: sqm.proof.outcome?.status || null,
|
|
295
|
+
} : null,
|
|
296
|
+
signed_evidence: sqm.signed_evidence ? {
|
|
297
|
+
evidence_id: sqm.signed_evidence.evidence_id || null,
|
|
298
|
+
key_id: sqm.signed_evidence.signer?.key_id || null,
|
|
299
|
+
algorithm: sqm.signed_evidence.integrity?.algorithm || null,
|
|
300
|
+
} : null,
|
|
260
301
|
};
|
|
261
302
|
}
|
|
262
303
|
|
package/src/orchestrator/loop.js
CHANGED
|
@@ -14,6 +14,7 @@ export async function runCodingLoop({
|
|
|
14
14
|
executeAttempt,
|
|
15
15
|
executeIndependentAudit,
|
|
16
16
|
collectEvidence,
|
|
17
|
+
checkSqm = null,
|
|
17
18
|
onUpdate,
|
|
18
19
|
humanGuidance = "",
|
|
19
20
|
resume = false,
|
|
@@ -127,6 +128,7 @@ export async function runCodingLoop({
|
|
|
127
128
|
});
|
|
128
129
|
await publish(snapshot, "collecting_evidence", onUpdate);
|
|
129
130
|
attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
|
|
131
|
+
attempt.sqm = await safeSqm(checkSqm, attempt);
|
|
130
132
|
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
131
133
|
|
|
132
134
|
appendEvent(snapshot, "auditing", `Verifying local evidence for attempt ${attemptNumber}.`, {
|
|
@@ -140,7 +142,10 @@ export async function runCodingLoop({
|
|
|
140
142
|
attempt.observed_evidence?.github?.available === true,
|
|
141
143
|
});
|
|
142
144
|
await publish(snapshot, "auditing", onUpdate);
|
|
143
|
-
attempt.audit = await auditAttempt(snapshot.charter, attempt, {
|
|
145
|
+
attempt.audit = await auditAttempt(snapshot.charter, attempt, {
|
|
146
|
+
previousAttempt,
|
|
147
|
+
sqm: attempt.sqm,
|
|
148
|
+
});
|
|
144
149
|
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
145
150
|
attempt.implementation_gate = attempt.audit.implementation_gate;
|
|
146
151
|
snapshot.attempts.push(attempt);
|
|
@@ -212,8 +217,12 @@ export async function runCodingLoop({
|
|
|
212
217
|
|
|
213
218
|
// Re-collect after the read-only auditor to catch any mutated HEAD or working tree.
|
|
214
219
|
attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
|
|
220
|
+
attempt.sqm = await safeSqm(checkSqm, attempt, attempt.sqm);
|
|
215
221
|
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
216
|
-
attempt.audit = await auditAttempt(snapshot.charter, attempt, {
|
|
222
|
+
attempt.audit = await auditAttempt(snapshot.charter, attempt, {
|
|
223
|
+
previousAttempt,
|
|
224
|
+
sqm: attempt.sqm,
|
|
225
|
+
});
|
|
217
226
|
if (shouldStop()) return stopCodingLoop(snapshot, onUpdate);
|
|
218
227
|
attempt.implementation_gate = attempt.audit.implementation_gate;
|
|
219
228
|
|
|
@@ -267,7 +276,7 @@ async function stopCodingLoop(snapshot, onUpdate) {
|
|
|
267
276
|
return snapshot;
|
|
268
277
|
}
|
|
269
278
|
|
|
270
|
-
export async function recheckPrReadiness({ snapshot, collectEvidence, onUpdate }) {
|
|
279
|
+
export async function recheckPrReadiness({ snapshot, collectEvidence, checkSqm = null, onUpdate }) {
|
|
271
280
|
const attempt = snapshot.attempts.at(-1);
|
|
272
281
|
if (!attempt || !attempt.audit?.passed || !attempt.independent_audit?.passed) return snapshot;
|
|
273
282
|
appendEvent(snapshot, "checking_pr", "Refreshing GitHub review and CI evidence.", {
|
|
@@ -275,8 +284,10 @@ export async function recheckPrReadiness({ snapshot, collectEvidence, onUpdate }
|
|
|
275
284
|
});
|
|
276
285
|
await publish(snapshot, "checking_pr", onUpdate);
|
|
277
286
|
attempt.observed_evidence = await safeEvidence(collectEvidence, attempt);
|
|
287
|
+
attempt.sqm = await safeSqm(checkSqm, attempt, attempt.sqm, "full");
|
|
278
288
|
attempt.audit = await auditAttempt(snapshot.charter, attempt, {
|
|
279
289
|
previousAttempt: snapshot.attempts.at(-2) ?? null,
|
|
290
|
+
sqm: attempt.sqm,
|
|
280
291
|
});
|
|
281
292
|
snapshot.implementation_gate_passed = attempt.audit.passed;
|
|
282
293
|
attempt.pr_readiness = evaluatePrReadiness(snapshot.charter, attempt);
|
|
@@ -352,6 +363,21 @@ async function safeEvidence(collector, attempt) {
|
|
|
352
363
|
}
|
|
353
364
|
}
|
|
354
365
|
|
|
366
|
+
async function safeSqm(checker, attempt, previous = null, phase = "min") {
|
|
367
|
+
if (!checker) return previous;
|
|
368
|
+
try {
|
|
369
|
+
return await checker(attempt, { phase });
|
|
370
|
+
} catch (error) {
|
|
371
|
+
return {
|
|
372
|
+
status: "unavailable",
|
|
373
|
+
mode: "shadow",
|
|
374
|
+
bundle: null,
|
|
375
|
+
findings: [],
|
|
376
|
+
warnings: [error instanceof Error ? error.message : "SQM shadow check failed."],
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
355
381
|
function unavailableEvidence(error) {
|
|
356
382
|
return {
|
|
357
383
|
collected_at: new Date().toISOString(),
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
readdir,
|
|
7
|
+
realpath,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from "node:fs/promises";
|
|
3
10
|
import { homedir } from "node:os";
|
|
4
11
|
import { dirname, join, resolve } from "node:path";
|
|
5
12
|
import { promisify } from "node:util";
|
|
@@ -152,6 +159,35 @@ export function findRepository(inventory, repository) {
|
|
|
152
159
|
);
|
|
153
160
|
}
|
|
154
161
|
|
|
162
|
+
export async function verifyRepositoryIdentity(entry, expectedRepository) {
|
|
163
|
+
const expected = normalizeGitHubRemote(expectedRepository)?.toLowerCase();
|
|
164
|
+
const localPath = resolve(String(entry?.local_path || ""));
|
|
165
|
+
if (!expected || !localPath) return null;
|
|
166
|
+
try {
|
|
167
|
+
const details = await lstat(localPath);
|
|
168
|
+
if (!details.isDirectory() || details.isSymbolicLink()) return null;
|
|
169
|
+
const canonicalPath = await realpath(localPath);
|
|
170
|
+
const [{ stdout: root }, { stdout: remote }] = await Promise.all([
|
|
171
|
+
execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd: canonicalPath }),
|
|
172
|
+
execFileAsync("git", ["remote", "get-url", "origin"], { cwd: canonicalPath }),
|
|
173
|
+
]);
|
|
174
|
+
const canonicalRoot = await realpath(resolve(String(root || "").trim()));
|
|
175
|
+
if (
|
|
176
|
+
canonicalRoot !== canonicalPath ||
|
|
177
|
+
normalizeGitHubRemote(remote)?.toLowerCase() !== expected
|
|
178
|
+
) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
...entry,
|
|
183
|
+
repository: normalizeGitHubRemote(remote),
|
|
184
|
+
local_path: canonicalPath,
|
|
185
|
+
};
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
async function inspectGitRepository(directory) {
|
|
156
192
|
try {
|
|
157
193
|
const [{ stdout: root }, { stdout: remote }, { stdout: branch }] = await Promise.all([
|
package/src/runner.js
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
import { LocalRunRegistry } from "./run-registry.js";
|
|
41
41
|
import { webApiHeaders, webApiUrl } from "./web-api.js";
|
|
42
42
|
import { normalizeGitHubRemote } from "./workspace-context.js";
|
|
43
|
+
import { CLI_VERSION } from "./cli-contract.js";
|
|
44
|
+
import { createSqmChecker } from "./sqm/index.js";
|
|
43
45
|
|
|
44
46
|
const execFileAsync = promisify(execFile);
|
|
45
47
|
const MAX_LOCAL_LOG_LINES = 1_000;
|
|
@@ -543,6 +545,20 @@ async function runLocalCodingRun(
|
|
|
543
545
|
attempt,
|
|
544
546
|
repositoryPath,
|
|
545
547
|
}));
|
|
548
|
+
const sqmChecker =
|
|
549
|
+
run.sqmChecker ||
|
|
550
|
+
createSqmChecker({
|
|
551
|
+
cwd: repositoryPath,
|
|
552
|
+
baseBranch: run.snapshot.charter.base_branch,
|
|
553
|
+
cliVersion: CLI_VERSION,
|
|
554
|
+
token: run.session?.token,
|
|
555
|
+
endpoint: sqmBundleEndpoint(run.session),
|
|
556
|
+
evidenceEndpoint: sqmEvidenceEndpoint(run.session),
|
|
557
|
+
accountId: run.session?.account_id || run.session?.auth_user_id || run.session?.device_id,
|
|
558
|
+
organizationId: run.session?.organization_id || run.session?.org_id,
|
|
559
|
+
deviceId: run.session?.device_id,
|
|
560
|
+
});
|
|
561
|
+
run.sqmChecker = sqmChecker;
|
|
546
562
|
|
|
547
563
|
try {
|
|
548
564
|
await runCodingLoop({
|
|
@@ -550,6 +566,7 @@ async function runLocalCodingRun(
|
|
|
550
566
|
executeAttempt: executor,
|
|
551
567
|
executeIndependentAudit: independentAuditor,
|
|
552
568
|
collectEvidence: evidenceCollector,
|
|
569
|
+
checkSqm: sqmChecker,
|
|
553
570
|
humanGuidance,
|
|
554
571
|
resume,
|
|
555
572
|
shouldStop: () => run.stopRequested,
|
|
@@ -601,9 +618,24 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
|
|
|
601
618
|
attempt,
|
|
602
619
|
repositoryPath,
|
|
603
620
|
}));
|
|
621
|
+
const sqmChecker =
|
|
622
|
+
run.sqmChecker ||
|
|
623
|
+
createSqmChecker({
|
|
624
|
+
cwd: repositoryPath,
|
|
625
|
+
baseBranch: run.snapshot.charter.base_branch,
|
|
626
|
+
cliVersion: CLI_VERSION,
|
|
627
|
+
token: run.session?.token,
|
|
628
|
+
endpoint: sqmBundleEndpoint(run.session),
|
|
629
|
+
evidenceEndpoint: sqmEvidenceEndpoint(run.session),
|
|
630
|
+
accountId: run.session?.account_id || run.session?.auth_user_id || run.session?.device_id,
|
|
631
|
+
organizationId: run.session?.organization_id || run.session?.org_id,
|
|
632
|
+
deviceId: run.session?.device_id,
|
|
633
|
+
});
|
|
634
|
+
run.sqmChecker = sqmChecker;
|
|
604
635
|
await recheckPrReadiness({
|
|
605
636
|
snapshot: run.snapshot,
|
|
606
637
|
collectEvidence: evidenceCollector,
|
|
638
|
+
checkSqm: sqmChecker,
|
|
607
639
|
onUpdate: async (snapshot) => {
|
|
608
640
|
run.snapshot = snapshot;
|
|
609
641
|
await updateRun(run, snapshot.state);
|
|
@@ -612,6 +644,18 @@ export async function recheckLocalCodingRun(run, { collectEvidence } = {}) {
|
|
|
612
644
|
return run.snapshot;
|
|
613
645
|
}
|
|
614
646
|
|
|
647
|
+
function sqmBundleEndpoint(session) {
|
|
648
|
+
if (process.env.SIMY_SQM_BUNDLE_URL?.trim()) return process.env.SIMY_SQM_BUNDLE_URL.trim();
|
|
649
|
+
if (!session?.api_base_url) return undefined;
|
|
650
|
+
return new URL("sqm/knowledge-bundle", session.api_base_url).href;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function sqmEvidenceEndpoint(session) {
|
|
654
|
+
if (process.env.SIMY_SQM_EVIDENCE_URL?.trim()) return process.env.SIMY_SQM_EVIDENCE_URL.trim();
|
|
655
|
+
if (!session?.api_base_url) return undefined;
|
|
656
|
+
return new URL("sqm/run-evidence", session.api_base_url).href;
|
|
657
|
+
}
|
|
658
|
+
|
|
615
659
|
async function executeProcessAttempt({
|
|
616
660
|
backend,
|
|
617
661
|
instruction,
|
package/src/shutdown.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"];
|
|
2
|
+
|
|
3
|
+
export function createAgentShutdown(agent) {
|
|
4
|
+
let shutdownPromise = null;
|
|
5
|
+
return () => {
|
|
6
|
+
if (!shutdownPromise) shutdownPromise = closeAgent(agent);
|
|
7
|
+
return shutdownPromise;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function closeAgent(agent) {
|
|
12
|
+
const serverClose = beginServerClose(agent?.server);
|
|
13
|
+
let shutdownError = null;
|
|
14
|
+
try {
|
|
15
|
+
await agent?.shutdown?.();
|
|
16
|
+
} catch (error) {
|
|
17
|
+
shutdownError = error;
|
|
18
|
+
} finally {
|
|
19
|
+
// server.close() stops new connections immediately, but its callback waits
|
|
20
|
+
// for active SSE and other long-lived requests. Drain Provider work and its
|
|
21
|
+
// final durable status first, then release those remaining connections.
|
|
22
|
+
agent?.server?.closeAllConnections?.();
|
|
23
|
+
}
|
|
24
|
+
await serverClose;
|
|
25
|
+
if (shutdownError) throw shutdownError;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function installAgentShutdownSignalHandlers({
|
|
29
|
+
signalSource = process,
|
|
30
|
+
shutdown,
|
|
31
|
+
exit = (code) => process.exit(code),
|
|
32
|
+
reportError = (error) => console.error(error instanceof Error ? error.message : String(error)),
|
|
33
|
+
} = {}) {
|
|
34
|
+
if (typeof shutdown !== "function") throw new TypeError("shutdown is required");
|
|
35
|
+
const handlers = new Map();
|
|
36
|
+
for (const signal of SHUTDOWN_SIGNALS) {
|
|
37
|
+
const handler = () => {
|
|
38
|
+
void shutdown()
|
|
39
|
+
.then(() => exit(0))
|
|
40
|
+
.catch((error) => {
|
|
41
|
+
reportError(error);
|
|
42
|
+
exit(1);
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
handlers.set(signal, handler);
|
|
46
|
+
signalSource.once(signal, handler);
|
|
47
|
+
}
|
|
48
|
+
return () => {
|
|
49
|
+
for (const [signal, handler] of handlers) {
|
|
50
|
+
signalSource.off(signal, handler);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function beginServerClose(server) {
|
|
56
|
+
if (!server?.listening) return Promise.resolve();
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
server.close((error) => {
|
|
59
|
+
if (error) reject(error);
|
|
60
|
+
else resolve();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { createHash, createPublicKey } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { verifyBundleIntegrity } from "./canonical.js";
|
|
7
|
+
import { validateKnowledgeBundle } from "./validation.js";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
10
|
+
export const SQM_BUNDLE_FETCH_TIMEOUT_MS = 15_000;
|
|
11
|
+
|
|
12
|
+
export async function loadKnowledgeBundle({
|
|
13
|
+
repository,
|
|
14
|
+
cliVersion,
|
|
15
|
+
endpoint = process.env.SIMY_SQM_BUNDLE_URL,
|
|
16
|
+
token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN,
|
|
17
|
+
accountId = process.env.SIMY_SQM_ACCOUNT_ID || (token ? `token:${createHash("sha256").update(token).digest("hex")}` : "anonymous"),
|
|
18
|
+
organizationId = process.env.SIMY_SQM_ORGANIZATION_ID,
|
|
19
|
+
cacheRoot = process.env.SIMY_SQM_CACHE_DIR || path.join(process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy"), "sqm"),
|
|
20
|
+
ttlMs = numberEnv("SIMY_SQM_CACHE_TTL_MS", DEFAULT_TTL_MS),
|
|
21
|
+
offline = false,
|
|
22
|
+
refresh = false,
|
|
23
|
+
fetchImpl = globalThis.fetch,
|
|
24
|
+
now = Date.now(),
|
|
25
|
+
publicKeys = publicKeysFromEnvironment(),
|
|
26
|
+
}) {
|
|
27
|
+
if (!organizationId) {
|
|
28
|
+
return result(null, ["SQM organization identity is unavailable; shadow checks were skipped."], "unavailable");
|
|
29
|
+
}
|
|
30
|
+
const cacheIdentity = normalizedCacheIdentity({ endpoint, accountId, organizationId, repository });
|
|
31
|
+
const location = cacheLocation(cacheRoot, cacheIdentity);
|
|
32
|
+
const validation = { cliVersion, publicKeys, organizationId };
|
|
33
|
+
const cached = await readVerifiedCache(location, validation, cacheIdentity);
|
|
34
|
+
const fresh =
|
|
35
|
+
cached &&
|
|
36
|
+
now - cached.fetchedAt < ttlMs &&
|
|
37
|
+
Date.parse(cached.bundle.expires_at) > now;
|
|
38
|
+
if (!offline && endpoint && (!fresh || refresh)) {
|
|
39
|
+
try {
|
|
40
|
+
const fetched = await fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag: cached?.etag, fetchImpl });
|
|
41
|
+
if (fetched.notModified) {
|
|
42
|
+
if (!cached) throw new Error("SQM Cloud returned 304 without a verified local bundle.");
|
|
43
|
+
if (Date.parse(cached.bundle.expires_at) <= now) throw nonFallbackError("SQM Cloud returned 304 for an expired knowledge bundle.");
|
|
44
|
+
await writeMetadata(location, { ...cached.metadata, fetched_at: new Date(now).toISOString(), etag: fetched.etag || cached.etag });
|
|
45
|
+
return result(cached.bundle, [], "cache_revalidated");
|
|
46
|
+
}
|
|
47
|
+
const discoveredTrust = signingTrustForFetch({
|
|
48
|
+
bundle: fetched.bundle,
|
|
49
|
+
endpoint,
|
|
50
|
+
publicKeys,
|
|
51
|
+
signingKeyId: fetched.signingKeyId,
|
|
52
|
+
signingPublicKey: fetched.signingPublicKey,
|
|
53
|
+
});
|
|
54
|
+
const bundle = verifyAndValidate(fetched.bundle, {
|
|
55
|
+
...validation,
|
|
56
|
+
publicKeys: discoveredTrust.publicKeys,
|
|
57
|
+
});
|
|
58
|
+
if (Date.parse(bundle.expires_at) <= now) {
|
|
59
|
+
throw nonFallbackError("SQM Cloud returned an expired knowledge bundle.");
|
|
60
|
+
}
|
|
61
|
+
if (fetched.etag && unquote(fetched.etag) !== bundle.integrity.digest) throw nonFallbackError("SQM response ETag does not match the bundle digest.");
|
|
62
|
+
await writeCache(location, bundle, {
|
|
63
|
+
...cacheIdentity,
|
|
64
|
+
fetched_at: new Date(now).toISOString(),
|
|
65
|
+
etag: fetched.etag || `\"${bundle.integrity.digest}\"`,
|
|
66
|
+
signing_key_id: discoveredTrust.signingKeyId,
|
|
67
|
+
signing_public_key: discoveredTrust.signingPublicKey,
|
|
68
|
+
});
|
|
69
|
+
return result(bundle, [], "cloud");
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.fallbackAllowed !== false && cached && Date.parse(cached.bundle.expires_at) > now) return result(cached.bundle, [`SQM Cloud unavailable; using last verified bundle: ${message(error)}`], "stale_cache");
|
|
72
|
+
return result(null, [`SQM knowledge could not be used; shadow checks were skipped: ${message(error)}`], "unavailable");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (cached) {
|
|
76
|
+
if (Date.parse(cached.bundle.expires_at) <= now) return result(null, ["The cached SQM knowledge bundle is expired; shadow checks were skipped."], "unavailable");
|
|
77
|
+
const warnings = [];
|
|
78
|
+
if (offline) warnings.push("SQM is offline; using the last verified knowledge bundle.");
|
|
79
|
+
else if (!endpoint) warnings.push("SQM Cloud endpoint is not configured; using the last verified knowledge bundle.");
|
|
80
|
+
return result(cached.bundle, warnings, fresh ? "cache" : "stale_cache");
|
|
81
|
+
}
|
|
82
|
+
const reason = offline ? "offline mode" : "SQM Cloud endpoint is not configured";
|
|
83
|
+
return result(null, [`SQM has no verified cache (${reason}); shadow checks were skipped.`], "unavailable");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function verifyAndValidate(bundle, { cliVersion, publicKeys, organizationId } = {}) {
|
|
87
|
+
try { validateKnowledgeBundle(bundle, { cliVersion }); } catch (error) { throw nonFallbackError(message(error), error); }
|
|
88
|
+
if (organizationId && bundle.organization_id !== organizationId) throw nonFallbackError(`SQM bundle organization mismatch: expected ${organizationId}.`);
|
|
89
|
+
for (const module of bundle.modules) {
|
|
90
|
+
if (module.scope.visibility === "organization" && module.scope.organization_id !== bundle.organization_id) throw nonFallbackError(`SQM module ${module.id} is scoped to a different organization.`);
|
|
91
|
+
}
|
|
92
|
+
const keyId = bundle.integrity.key_id;
|
|
93
|
+
const key = publicKeys.get(keyId);
|
|
94
|
+
if (!key) throw nonFallbackError(`No trusted SQM public key is configured for ${keyId}.`);
|
|
95
|
+
try { verifyBundleIntegrity(bundle, key); } catch (error) { throw nonFallbackError(message(error), error); }
|
|
96
|
+
return bundle;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function publicKeysFromEnvironment(env = process.env) {
|
|
100
|
+
const keys = new Map();
|
|
101
|
+
if (env.SIMY_SQM_PUBLIC_KEYS_JSON) {
|
|
102
|
+
let parsed;
|
|
103
|
+
try { parsed = JSON.parse(env.SIMY_SQM_PUBLIC_KEYS_JSON); } catch { throw new Error("SIMY_SQM_PUBLIC_KEYS_JSON is not valid JSON."); }
|
|
104
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("SIMY_SQM_PUBLIC_KEYS_JSON must be an object keyed by key ID.");
|
|
105
|
+
for (const [id, value] of Object.entries(parsed)) keys.set(id, parsePublicKey(value));
|
|
106
|
+
}
|
|
107
|
+
if (env.SIMY_SQM_PUBLIC_KEY && env.SIMY_SQM_KEY_ID) keys.set(env.SIMY_SQM_KEY_ID, parsePublicKey(env.SIMY_SQM_PUBLIC_KEY));
|
|
108
|
+
return keys;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parsePublicKey(value) {
|
|
112
|
+
const text = String(value || "").replace(/\\n/g, "\n").trim();
|
|
113
|
+
if (text.startsWith("-----BEGIN")) return createPublicKey(text);
|
|
114
|
+
const raw = Buffer.from(text, "base64");
|
|
115
|
+
if (raw.length === 32) {
|
|
116
|
+
const prefix = Buffer.from("302a300506032b6570032100", "hex");
|
|
117
|
+
return createPublicKey({ key: Buffer.concat([prefix, raw]), format: "der", type: "spki" });
|
|
118
|
+
}
|
|
119
|
+
return createPublicKey({ key: raw, format: "der", type: "spki" });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function signingTrustForFetch({ bundle, endpoint, publicKeys, signingKeyId, signingPublicKey }) {
|
|
123
|
+
const keyId = bundle?.integrity?.key_id;
|
|
124
|
+
if (publicKeys.has(keyId)) {
|
|
125
|
+
return { publicKeys, signingKeyId: null, signingPublicKey: null };
|
|
126
|
+
}
|
|
127
|
+
if (!trustedDiscoveryEndpoint(endpoint)) {
|
|
128
|
+
throw nonFallbackError("SQM signing-key discovery requires authenticated HTTPS or loopback.");
|
|
129
|
+
}
|
|
130
|
+
if (!signingKeyId || signingKeyId !== keyId || !signingPublicKey) {
|
|
131
|
+
throw nonFallbackError(`SQM Cloud did not provide the trusted public key for ${keyId}.`);
|
|
132
|
+
}
|
|
133
|
+
const discovered = new Map(publicKeys);
|
|
134
|
+
try {
|
|
135
|
+
discovered.set(keyId, parsePublicKey(signingPublicKey));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw nonFallbackError(`SQM Cloud returned an invalid public key for ${keyId}.`, error);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
publicKeys: discovered,
|
|
141
|
+
signingKeyId,
|
|
142
|
+
signingPublicKey: String(signingPublicKey).trim(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function trustedDiscoveryEndpoint(endpoint) {
|
|
147
|
+
const url = new URL(endpoint);
|
|
148
|
+
return url.protocol === "https:" ||
|
|
149
|
+
(url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function fetchBundle({ endpoint, repository, cliVersion, token, organizationId, etag, fetchImpl }) {
|
|
153
|
+
const url = new URL(endpoint);
|
|
154
|
+
url.searchParams.set("repository", repository);
|
|
155
|
+
url.searchParams.set("cli_version", cliVersion);
|
|
156
|
+
const headers = { Accept: "application/json" };
|
|
157
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
158
|
+
if (organizationId) headers["x-simy-org-id"] = organizationId;
|
|
159
|
+
if (etag) headers["If-None-Match"] = etag;
|
|
160
|
+
const response = await fetchImpl(url, {
|
|
161
|
+
headers,
|
|
162
|
+
signal: AbortSignal.timeout(SQM_BUNDLE_FETCH_TIMEOUT_MS),
|
|
163
|
+
});
|
|
164
|
+
const responseEtag = response.headers?.get?.("etag") || null;
|
|
165
|
+
const signingKeyId = response.headers?.get?.("x-simy-sqm-key-id") || null;
|
|
166
|
+
const signingPublicKey = response.headers?.get?.("x-simy-sqm-public-key") || null;
|
|
167
|
+
if (response.status === 304) {
|
|
168
|
+
return { notModified: true, etag: responseEtag, signingKeyId, signingPublicKey };
|
|
169
|
+
}
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
const body = await response.text().catch(() => "");
|
|
172
|
+
const error = new Error(`SQM Cloud returned HTTP ${response.status}${body ? `: ${body.slice(0, 300)}` : ""}.`);
|
|
173
|
+
error.fallbackAllowed = response.status >= 500 && response.status <= 599;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
return {
|
|
178
|
+
notModified: false,
|
|
179
|
+
etag: responseEtag,
|
|
180
|
+
signingKeyId,
|
|
181
|
+
signingPublicKey,
|
|
182
|
+
bundle: await response.json(),
|
|
183
|
+
};
|
|
184
|
+
} catch (error) {
|
|
185
|
+
throw nonFallbackError("SQM Cloud returned an invalid JSON knowledge bundle.", error);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function readVerifiedCache(location, options, identity) {
|
|
190
|
+
try {
|
|
191
|
+
const [bundleRaw, metadataRaw] = await Promise.all([readFile(location.bundle, "utf8"), readFile(location.metadata, "utf8")]);
|
|
192
|
+
const metadata = JSON.parse(metadataRaw);
|
|
193
|
+
for (const key of ["endpoint", "account_id", "organization_id", "repository"]) if (metadata[key] !== identity[key]) throw new Error("SQM cache identity does not match the current tenant context.");
|
|
194
|
+
const publicKeys = new Map(options.publicKeys);
|
|
195
|
+
if (metadata.signing_key_id && metadata.signing_public_key && !publicKeys.has(metadata.signing_key_id)) {
|
|
196
|
+
publicKeys.set(metadata.signing_key_id, parsePublicKey(metadata.signing_public_key));
|
|
197
|
+
}
|
|
198
|
+
const bundle = verifyAndValidate(JSON.parse(bundleRaw), { ...options, publicKeys });
|
|
199
|
+
if (metadata.digest !== bundle.integrity.digest || metadata.bundle_id !== bundle.bundle_id || metadata.version !== bundle.version) throw new Error("SQM cache metadata does not match its bundle.");
|
|
200
|
+
if (metadata.signing_key_id && metadata.signing_key_id !== bundle.integrity.key_id) throw new Error("SQM cache signing key does not match its bundle.");
|
|
201
|
+
return { bundle, metadata, etag: metadata.etag || null, fetchedAt: Date.parse(metadata.fetched_at) || 0 };
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function writeCache(location, bundle, metadata) {
|
|
208
|
+
await mkdir(location.directory, { recursive: true, mode: 0o700 });
|
|
209
|
+
await atomicWrite(location.bundle, `${JSON.stringify(bundle)}\n`);
|
|
210
|
+
await writeMetadata(location, { ...metadata, bundle_id: bundle.bundle_id, version: bundle.version, digest: bundle.integrity.digest, verified_at: new Date().toISOString() });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function writeMetadata(location, metadata) {
|
|
214
|
+
await mkdir(location.directory, { recursive: true, mode: 0o700 });
|
|
215
|
+
await atomicWrite(location.metadata, `${JSON.stringify(metadata)}\n`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function atomicWrite(target, content) {
|
|
219
|
+
const temporary = `${target}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
220
|
+
await writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
221
|
+
await rename(temporary, target);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cacheLocation(root, identity) {
|
|
225
|
+
const id = createHash("sha256").update(JSON.stringify(identity)).digest("hex");
|
|
226
|
+
const directory = path.join(root, id);
|
|
227
|
+
return { directory, bundle: path.join(directory, "bundle.json"), metadata: path.join(directory, "metadata.json") };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function normalizedCacheIdentity({ endpoint, accountId, organizationId, repository }) {
|
|
231
|
+
let normalizedEndpoint = "unconfigured";
|
|
232
|
+
if (endpoint) {
|
|
233
|
+
const url = new URL(endpoint);
|
|
234
|
+
url.hash = "";
|
|
235
|
+
normalizedEndpoint = url.href;
|
|
236
|
+
}
|
|
237
|
+
return { endpoint: normalizedEndpoint, account_id: String(accountId || "anonymous"), organization_id: organizationId ? String(organizationId) : null, repository: String(repository).toLowerCase() };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function nonFallbackError(message, cause) {
|
|
241
|
+
const error = new Error(message, { cause });
|
|
242
|
+
error.fallbackAllowed = false;
|
|
243
|
+
return error;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function result(bundle, warnings, source) { return { bundle, warnings, source }; }
|
|
247
|
+
function message(error) { return error instanceof Error ? error.message : String(error); }
|
|
248
|
+
function numberEnv(name, fallback) { const parsed = Number(process.env[name]); return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; }
|
|
249
|
+
function unquote(value) { return String(value).replace(/^W\//, "").replace(/^\"|\"$/g, ""); }
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash, verify } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function canonicalize(value) {
|
|
4
|
+
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
5
|
+
return JSON.stringify(value);
|
|
6
|
+
}
|
|
7
|
+
if (typeof value === "number") {
|
|
8
|
+
if (!Number.isFinite(value)) throw new TypeError("JCS does not allow non-finite numbers.");
|
|
9
|
+
return JSON.stringify(value);
|
|
10
|
+
}
|
|
11
|
+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
|
12
|
+
if (value && typeof value === "object") {
|
|
13
|
+
return `{${Object.keys(value)
|
|
14
|
+
.sort()
|
|
15
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`)
|
|
16
|
+
.join(",")}}`;
|
|
17
|
+
}
|
|
18
|
+
throw new TypeError(`JCS does not allow ${typeof value} values.`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function bundlePayloadBytes(bundle) {
|
|
22
|
+
const { integrity: _integrity, ...payload } = bundle;
|
|
23
|
+
return Buffer.from(canonicalize(payload), "utf8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function bundleDigest(bundle) {
|
|
27
|
+
return createHash("sha256").update(bundlePayloadBytes(bundle)).digest();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function verifyBundleIntegrity(bundle, publicKey) {
|
|
31
|
+
const digest = bundleDigest(bundle);
|
|
32
|
+
const expected = `sha256:${digest.toString("hex")}`;
|
|
33
|
+
if (bundle?.integrity?.digest !== expected) {
|
|
34
|
+
throw new Error(`SQM bundle digest mismatch: expected ${expected}.`);
|
|
35
|
+
}
|
|
36
|
+
const encoded = String(bundle?.integrity?.signature || "");
|
|
37
|
+
if (!/^[A-Za-z0-9+/]+$/.test(encoded)) {
|
|
38
|
+
throw new Error("SQM bundle signature is not valid base64.");
|
|
39
|
+
}
|
|
40
|
+
const signature = Buffer.from(encoded, "base64");
|
|
41
|
+
if (signature.length !== 64 || !verify(null, digest, publicKey, signature)) {
|
|
42
|
+
throw new Error("SQM bundle Ed25519 signature verification failed.");
|
|
43
|
+
}
|
|
44
|
+
return expected;
|
|
45
|
+
}
|