@awak-app/simy-cli 0.5.72 → 0.5.73
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 +84 -0
- package/package.json +2 -2
- package/src/agent.js +191 -11
- package/src/backend-executable.js +21 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/hooks/README.md +22 -0
- package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +179 -34
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
- package/src/coding-run-recovery.js +88 -0
- package/src/coding-run-store.js +2 -2
- package/src/continuation-policy.js +2 -0
- package/src/delivery-hooks/command.js +11 -5
- package/src/delivery-hooks/desktop.js +12 -3
- package/src/delivery-hooks/supervisor.js +14 -10
- package/src/desktop-host.js +8 -6
- package/src/desktop-sqm-settings.js +24 -7
- package/src/device-signing-identity.js +65 -0
- package/src/diagnostic-log.js +68 -0
- package/src/doctor.js +55 -0
- package/src/index.js +7 -0
- package/src/local-task.js +68 -3
- package/src/orchestrator/budget.js +55 -4
- package/src/orchestrator/charter-validation.js +33 -0
- package/src/orchestrator/completion-contract.js +1 -1
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
- package/src/orchestrator/independent-audit.js +9 -0
- package/src/orchestrator/instruction.js +6 -1
- package/src/orchestrator/loop.js +38 -41
- package/src/orchestrator/outcome-monitor.js +58 -38
- package/src/orchestrator/outcome-progress.js +2 -0
- package/src/purpose-local-proof.js +380 -0
- package/src/runner.js +224 -36
- package/src/session-supervision/codex-app-server.js +3 -2
- package/src/session-supervision/index.js +16 -4
- package/src/sqm/bundle-store.js +11 -7
- package/src/sqm/claude-plugin.js +2 -0
- package/src/sqm/command.js +36 -4
- package/src/sqm/diagnostics.js +1 -1
- package/src/sqm/evidence-client.js +23 -7
- package/src/sqm/history-store.js +4 -0
- package/src/sqm/index.js +104 -14
- package/src/sqm/proof.js +3 -23
- package/src/sqm/request-recovery.js +82 -0
- package/src/sqm/review-worker.js +59 -12
- package/src/sqm/session.js +33 -3
- package/src/startup-preflight.js +44 -0
package/src/sqm/command.js
CHANGED
|
@@ -11,8 +11,9 @@ import { uploadRunEvidence } from "./evidence-client.js";
|
|
|
11
11
|
import { evaluateKnowledgeBundle } from "./checkers.js";
|
|
12
12
|
import { runSqmCheck, runSqmMinCheck } from "./index.js";
|
|
13
13
|
import { lintKnowledgeModule, summarizeReplay } from "./reliability.js";
|
|
14
|
-
import { ensureSqmSession } from "./session.js";
|
|
14
|
+
import { ensureSqmSession, sqmSessionScope } from "./session.js";
|
|
15
15
|
import { formatSqmCoverage } from "./report.js";
|
|
16
|
+
import { sqmDiagnostic } from "./request-recovery.js";
|
|
16
17
|
|
|
17
18
|
export async function runSqmCommand(argv, { cliVersion }) {
|
|
18
19
|
if (argv.includes("--help") || argv.includes("-h") || !argv.length) {
|
|
@@ -24,14 +25,17 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
24
25
|
if (argv[0] === "replay") return await runReplayCommand(argv);
|
|
25
26
|
if (!["min-check", "check"].includes(argv[0])) throw new Error("Usage: simy sqm <min-check|check|lint|replay> [options]");
|
|
26
27
|
const webOrigin = resolveWebOrigin(option(argv, "--host"));
|
|
28
|
+
const authorizationLogger = argv.includes("--json")
|
|
29
|
+
? { log: message => console.warn(message), error: message => console.error(message) }
|
|
30
|
+
: console;
|
|
27
31
|
let session = await readSession(webOrigin);
|
|
28
32
|
let validSession = isSessionValid(session, Date.now(), webOrigin) ? session : null;
|
|
29
33
|
if (!validSession && !explicitSqmEnvironment(argv)) {
|
|
30
|
-
session = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open") });
|
|
34
|
+
session = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open"), logger: authorizationLogger });
|
|
31
35
|
validSession = session;
|
|
32
36
|
}
|
|
33
37
|
const organizationId = process.env.SIMY_SQM_ORGANIZATION_ID || validSession?.organization_id || validSession?.org_id;
|
|
34
|
-
|
|
38
|
+
let token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN || validSession?.token;
|
|
35
39
|
const accountId = process.env.SIMY_SQM_ACCOUNT_ID || validSession?.account_id || validSession?.auth_user_id || validSession?.device_id;
|
|
36
40
|
const deviceId = process.env.SIMY_SQM_DEVICE_ID || validSession?.device_id;
|
|
37
41
|
if (!organizationId) throw new Error(`SQM needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
|
|
@@ -46,10 +50,32 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
46
50
|
endpoint,
|
|
47
51
|
accountId,
|
|
48
52
|
organizationId,
|
|
53
|
+
deviceId,
|
|
49
54
|
};
|
|
50
55
|
|
|
51
56
|
const save = createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
|
|
52
57
|
phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
|
|
58
|
+
const onRecoveryEvent = async diagnostic => createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
|
|
59
|
+
phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch })({ status: "failed", diagnostic });
|
|
60
|
+
common.bundleDependencies = { onRecoveryEvent };
|
|
61
|
+
common.onRecoveryEvent = onRecoveryEvent;
|
|
62
|
+
let authenticationAttempted = false;
|
|
63
|
+
if (validSession && !process.env.SIMY_SQM_TOKEN && !process.env.SIMY_ACCESS_TOKEN) {
|
|
64
|
+
const expectedScope = sqmSessionScope(validSession, webOrigin);
|
|
65
|
+
common.onAuthenticationRequired = async ({ rejectedToken }) => {
|
|
66
|
+
if (authenticationAttempted) {
|
|
67
|
+
const error = new Error("SQM authorization was already refreshed once; resolve the sign-in error before another check.");
|
|
68
|
+
error.code = "authorization_refresh_failed"; throw error;
|
|
69
|
+
}
|
|
70
|
+
authenticationAttempted = true;
|
|
71
|
+
validSession = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open"), rejectedToken, expectedScope, logger: authorizationLogger });
|
|
72
|
+
token = validSession.token;
|
|
73
|
+
common.token = token;
|
|
74
|
+
return { token, accountId: validSession.account_id || validSession.auth_user_id || validSession.device_id,
|
|
75
|
+
organizationId: validSession.organization_id || validSession.org_id, deviceId: validSession.device_id,
|
|
76
|
+
endpoint: process.env.SIMY_SQM_BUNDLE_URL || new URL("sqm/knowledge-bundle", validSession.api_base_url).href };
|
|
77
|
+
};
|
|
78
|
+
}
|
|
53
79
|
let stage = "min_check";
|
|
54
80
|
try {
|
|
55
81
|
if (argv[0] === "min-check") {
|
|
@@ -77,6 +103,12 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
77
103
|
stage = "full_check";
|
|
78
104
|
const result = await runSqmCheck({ ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined });
|
|
79
105
|
if (result.status === "unavailable") { await save(result); return printUnavailable(result, argv); }
|
|
106
|
+
if (result.recovery?.code === "knowledge_bundle_changed") {
|
|
107
|
+
// Keep the earlier min as history; never rewrite it into an apparent
|
|
108
|
+
// success for the new bundle.
|
|
109
|
+
await atomicJson(`${minPath}.previous.${minCheck.result_digest.slice(-16)}.json`, minCheck);
|
|
110
|
+
await atomicJson(minPath, result.min_check);
|
|
111
|
+
}
|
|
80
112
|
const proofPath = path.resolve(option(argv, "--proof") || "proof.json");
|
|
81
113
|
const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
|
|
82
114
|
const diagnosticsPath = path.resolve(option(argv, "--diagnostics") || `${proofPath}.diagnostics.json`);
|
|
@@ -107,7 +139,7 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
107
139
|
else printFull(result);
|
|
108
140
|
return result.proof.outcome.status === "failed" ? 1 : 0;
|
|
109
141
|
} catch (error) {
|
|
110
|
-
await save({ status: "failed", diagnostic: safeSqmDiagnostic(error, stage) });
|
|
142
|
+
await save({ status: "failed", diagnostic: { ...safeSqmDiagnostic(error, stage), ...sqmDiagnostic(error, stage) } });
|
|
111
143
|
throw error;
|
|
112
144
|
}
|
|
113
145
|
}
|
package/src/sqm/diagnostics.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export function safeSqmDiagnostic(error, stage = "check", code = "sqm_check_failed") {
|
|
4
4
|
const value = error?.diagnostic || (error?.error && typeof error.error === "object" ? error.error : error) || {};
|
|
5
5
|
const machine = (candidate, fallback) => typeof candidate === "string" && /^[a-zA-Z][a-zA-Z0-9_]{0,79}$/.test(candidate) ? candidate : fallback;
|
|
6
|
-
const status = [value.status, value.errorStatus, value.httpStatus, value.error_status].find(candidate => Number.isInteger(candidate) && candidate >= 400 && candidate <= 599);
|
|
6
|
+
const status = [value.status, value.http_status, value.errorStatus, value.httpStatus, value.error_status].find(candidate => Number.isInteger(candidate) && candidate >= 400 && candidate <= 599);
|
|
7
7
|
const requestId = value.requestId ?? value.request_id;
|
|
8
8
|
return {
|
|
9
9
|
stage: machine(value.stage ?? value.errorStage ?? value.error_stage, stage),
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
2
2
|
export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence, fetchImpl = globalThis.fetch }) {
|
|
3
3
|
if (!endpoint || !token) return { uploaded: false, reason: "authenticated_cloud_session_unavailable" };
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
let response;
|
|
5
|
+
try {
|
|
6
|
+
response = await fetchImpl(endpoint, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: { accept: "application/json", authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
9
|
+
body: JSON.stringify({ proof, signed_evidence: signedEvidence }),
|
|
10
|
+
signal: AbortSignal.timeout(10_000),
|
|
11
|
+
});
|
|
12
|
+
} catch (cause) {
|
|
13
|
+
const error = cause instanceof Error ? cause : new Error("SQM run evidence upload failed.");
|
|
14
|
+
error.stage = "evidence_upload";
|
|
15
|
+
error.retryable = false;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
10
18
|
const payload = await response.json().catch(() => null);
|
|
11
|
-
if (!response.ok)
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const error = new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`);
|
|
21
|
+
error.status = response.status;
|
|
22
|
+
error.stage = "evidence_upload";
|
|
23
|
+
error.retryable = false;
|
|
24
|
+
Object.assign(error, safeSqmDiagnostic({ status: response.status, code: payload?.error?.code,
|
|
25
|
+
requestId: response.headers?.get?.("x-request-id") }, "evidence_upload", "evidence_upload_failed"));
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
12
28
|
return { uploaded: true, ...payload };
|
|
13
29
|
}
|
package/src/sqm/history-store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { diagnosticLogger } from "../diagnostic-log.js";
|
|
1
2
|
import { safeSqmDiagnostic, formatSqmDiagnostic } from "./diagnostics.js";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
@@ -45,6 +46,8 @@ export function createSqmHistoryStore({ root = defaultSessionRoot(), now = () =>
|
|
|
45
46
|
try {
|
|
46
47
|
await writeFile(temp, JSON.stringify(item), { mode: 0o600 });
|
|
47
48
|
await rename(temp, target);
|
|
49
|
+
if (["failed", "unavailable"].includes(item.state)) await diagnosticLogger({ root, scope }).record({
|
|
50
|
+
kind: item.kind, stage: entry.errorStage || item.phase || "unknown", code: item.errorCode, provider: item.provider, http_status: entry.httpStatus, at: item.finishedAt || item.startedAt });
|
|
48
51
|
} finally { await rm(temp, { force: true }); cache.delete(dir); }
|
|
49
52
|
const items = await list(scope);
|
|
50
53
|
for (const kind of ["analysis", "check"]) {
|
|
@@ -74,6 +77,7 @@ function metadata(entry) {
|
|
|
74
77
|
if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
|
|
75
78
|
}
|
|
76
79
|
if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
|
|
80
|
+
if (Number.isInteger(entry.httpStatus) && entry.httpStatus >= 400 && entry.httpStatus <= 599) item.httpStatus = entry.httpStatus;
|
|
77
81
|
return item;
|
|
78
82
|
}
|
|
79
83
|
|
package/src/sqm/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { loadKnowledgeBundle } from "./bundle-store.js";
|
|
|
6
6
|
import { uploadRunEvidence } from "./evidence-client.js";
|
|
7
7
|
import { buildProof, finalizeMinCheck, repositoryIdentity, sameRepositoryIdentity, signProof, verifyMinCheck, verifySignedEvidence } from "./proof.js";
|
|
8
8
|
import { inspectRepository } from "./repository.js";
|
|
9
|
+
import { sqmDiagnostic } from "./request-recovery.js";
|
|
10
|
+
import { canonicalize } from "./canonical.js";
|
|
9
11
|
|
|
10
12
|
export function createSqmChecker({
|
|
11
13
|
cwd = process.cwd(),
|
|
@@ -21,6 +23,7 @@ export function createSqmChecker({
|
|
|
21
23
|
webOrigin,
|
|
22
24
|
historyRoot,
|
|
23
25
|
evidenceEndpoint,
|
|
26
|
+
signingKeyPath,
|
|
24
27
|
uploadRunEvidenceImpl = uploadRunEvidence,
|
|
25
28
|
inspectRepositoryImpl = inspectRepository,
|
|
26
29
|
loadKnowledgeBundleImpl = loadKnowledgeBundle,
|
|
@@ -31,34 +34,59 @@ export function createSqmChecker({
|
|
|
31
34
|
let pinnedLoad = null;
|
|
32
35
|
let pinnedRepository = null;
|
|
33
36
|
let latestMinCheck = null;
|
|
37
|
+
let pinnedSystemScope;
|
|
38
|
+
const onRecoveryEvent = async (diagnostic) => {
|
|
39
|
+
await createSqmCheckRecorder({ root: historyRoot,
|
|
40
|
+
scope: historyRoot ? { webOrigin, accountId, organizationId } : null,
|
|
41
|
+
phase: diagnostic.stage === "evidence_upload" ? "full" : "min", cwd, baseBranch })({ status: "failed", diagnostic });
|
|
42
|
+
await dependencies.onRecoveryEvent?.(diagnostic);
|
|
43
|
+
};
|
|
34
44
|
|
|
35
45
|
const checkCurrentRepository = async function(_attempt = null, { phase = "min" } = {}) {
|
|
36
46
|
let repository;
|
|
37
47
|
try {
|
|
38
48
|
repository = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
|
|
39
49
|
} catch (error) {
|
|
40
|
-
return { ...unavailable(`SQM repository inspection failed: ${message(error)}`), diagnostic: safeSqmDiagnostic(error, "repository", "repository_inspection_failed") };
|
|
50
|
+
return { ...unavailable(`SQM repository inspection failed: ${message(error)}`), diagnostic: { ...safeSqmDiagnostic(error, "repository", "repository_inspection_failed"), ...sqmDiagnostic(error, "repository") } };
|
|
41
51
|
}
|
|
42
52
|
if (pinnedRepository && pinnedRepository !== repository.repository) return unavailable(`SQM repository changed during a pinned run (${pinnedRepository} to ${repository.repository}).`, repository.repository);
|
|
43
53
|
if (!pinnedLoad) {
|
|
44
54
|
pinnedRepository = repository.repository;
|
|
45
|
-
pinnedLoad = loadKnowledgeBundleImpl({ repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId,
|
|
55
|
+
pinnedLoad = loadKnowledgeBundleImpl({ ...dependencies, repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId, onRecoveryEvent });
|
|
46
56
|
}
|
|
47
57
|
const loaded = await pinnedLoad;
|
|
48
|
-
if (!loaded.bundle)
|
|
58
|
+
if (!loaded.bundle) {
|
|
59
|
+
// A failed read must not poison the run permanently. A later explicit
|
|
60
|
+
// same-scope check or refreshed sign-in may try loading again.
|
|
61
|
+
pinnedLoad = null;
|
|
62
|
+
return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings, diagnostic: loaded.diagnostic };
|
|
63
|
+
}
|
|
64
|
+
if (pinnedSystemScope === undefined) pinnedSystemScope = loaded.bundle.system_scope || null;
|
|
65
|
+
else if (!sameSystemScope(pinnedSystemScope, loaded.bundle.system_scope || null)) {
|
|
66
|
+
return { ...unavailable("SQM system scope changed during recovery; authorize the current scope before a new check.", repository.repository),
|
|
67
|
+
diagnostic: { stage: "knowledge_loading", code: "authorization_scope_changed", retryable: false } };
|
|
68
|
+
}
|
|
49
69
|
try {
|
|
50
70
|
const evaluate = executeKnowledgeBundleImpl
|
|
51
71
|
? async (bundle, currentRepository) => ({ findings: await executeKnowledgeBundleImpl(bundle, currentRepository), modules: [] })
|
|
52
72
|
: evaluateKnowledgeBundleImpl;
|
|
53
73
|
if (phase === "full") {
|
|
54
|
-
if (!latestMinCheck || !sameRepositoryIdentity(latestMinCheck.repository_identity, repositoryIdentity(repository))
|
|
74
|
+
if (!latestMinCheck || !sameRepositoryIdentity(latestMinCheck.repository_identity, repositoryIdentity(repository)) ||
|
|
75
|
+
latestMinCheck.knowledge_bundle.digest !== loaded.bundle.integrity.digest) {
|
|
55
76
|
latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
|
|
56
77
|
}
|
|
57
|
-
const
|
|
78
|
+
const fullEvaluation = await evaluate(loaded.bundle, repository);
|
|
79
|
+
const current = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
|
|
80
|
+
if (!sameRepositoryIdentity(repositoryIdentity(repository), repositoryIdentity(current))) {
|
|
81
|
+
throw candidateError("Repository identity changed during SQM full verification; no current proof can be signed.");
|
|
82
|
+
}
|
|
83
|
+
const full = await buildFullCheck({ repository, bundle: loaded.bundle, minCheck: latestMinCheck, evaluation: fullEvaluation, organizationId: organizationId || loaded.bundle.organization_id, cliVersion, deviceId, accountId, signingKeyPath });
|
|
58
84
|
try {
|
|
59
85
|
full.upload = await uploadRunEvidenceImpl({ endpoint: evidenceEndpoint, token, proof: full.proof, signedEvidence: full.signed_evidence });
|
|
60
86
|
} catch (error) {
|
|
61
|
-
full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}
|
|
87
|
+
full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}` };
|
|
88
|
+
full.upload.diagnostic = { ...safeSqmDiagnostic(error, "evidence_upload", "evidence_upload_failed"), ...sqmDiagnostic(error, "evidence_upload") };
|
|
89
|
+
await onRecoveryEvent(full.upload.diagnostic);
|
|
62
90
|
full.warnings.push(`SQM signed evidence remains local because Cloud upload failed: ${message(error)}`);
|
|
63
91
|
}
|
|
64
92
|
return full;
|
|
@@ -66,16 +94,32 @@ export function createSqmChecker({
|
|
|
66
94
|
latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
|
|
67
95
|
return toLegacyCheck(latestMinCheck, loaded);
|
|
68
96
|
} catch (error) {
|
|
69
|
-
return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle),
|
|
97
|
+
return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`], diagnostic: { ...safeSqmDiagnostic(error, `${phase}_check`, "sqm_execution_failed"), ...sqmDiagnostic(error, `${phase}_check`) } };
|
|
70
98
|
}
|
|
71
99
|
};
|
|
72
|
-
|
|
100
|
+
const checker = async (attempt, options = {}) => {
|
|
73
101
|
const save = createSqmCheckRecorder({ root: historyRoot, scope: historyRoot ? { webOrigin, accountId, organizationId } : null, phase: options.phase || "min", cwd, baseBranch });
|
|
74
102
|
try {
|
|
75
103
|
const result = await checkCurrentRepository(attempt, options);
|
|
76
104
|
await save(result); return result;
|
|
77
|
-
} catch (error) {
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const stage = options.phase === "full" ? "full_check" : "min_check";
|
|
107
|
+
const diagnostic = { ...safeSqmDiagnostic(error, stage), ...sqmDiagnostic(error, stage) };
|
|
108
|
+
if (diagnostic.retryable) pinnedLoad = null;
|
|
109
|
+
await save({ status: "failed", diagnostic }); throw error;
|
|
110
|
+
}
|
|
78
111
|
};
|
|
112
|
+
checker.updateSession = (next) => {
|
|
113
|
+
for (const [field, current] of Object.entries({ accountId, organizationId, deviceId, endpoint, webOrigin })) {
|
|
114
|
+
if (current && next?.[field] !== current) {
|
|
115
|
+
const error = new Error(`SQM session changed its ${field}; the original scope cannot be resumed with it.`);
|
|
116
|
+
error.code = "authorization_scope_changed"; throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!next?.token) throw new Error("An authenticated same-scope SQM session is required.");
|
|
120
|
+
if (next.token !== token) { token = next.token; pinnedLoad = null; }
|
|
121
|
+
};
|
|
122
|
+
return checker;
|
|
79
123
|
}
|
|
80
124
|
|
|
81
125
|
export async function runSqmMinCheck(options) {
|
|
@@ -89,15 +133,52 @@ export async function runSqmCheck(options) {
|
|
|
89
133
|
const context = await loadContext(options);
|
|
90
134
|
if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
|
|
91
135
|
verifyMinCheck(options.minCheck);
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
136
|
+
if (options.minCheck.organization_id !== options.organizationId || context.loaded.bundle.organization_id !== options.organizationId) {
|
|
137
|
+
throw scopeError("SQM organization changed after min-check; authorize the original organization before resuming.");
|
|
138
|
+
}
|
|
139
|
+
if (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw Object.assign(candidateError("Repository identity changed after min-check; run `simy sqm min-check` again."), { stage: "identity_validation" });
|
|
140
|
+
if (!sameSystemScope(options.minCheck.knowledge_bundle.system_scope || null, context.loaded.bundle.system_scope || null)) {
|
|
141
|
+
throw scopeError("SQM system scope changed after min-check; authorize the current scope before a new check.");
|
|
142
|
+
}
|
|
143
|
+
const evaluate = options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle;
|
|
144
|
+
let minCheck = options.minCheck;
|
|
145
|
+
if (minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) {
|
|
146
|
+
await options.onRecoveryEvent?.({ stage: "full_check", code: "knowledge_bundle_changed", retryable: true });
|
|
147
|
+
minCheck = await buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle,
|
|
148
|
+
evaluation: await evaluate(context.loaded.bundle, context.repository), organizationId: options.organizationId, cliVersion: options.cliVersion });
|
|
149
|
+
const current = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
|
|
150
|
+
if (!sameRepositoryIdentity(minCheck.repository_identity, repositoryIdentity(current))) throw new Error("Repository identity changed during min-check recovery; run `simy sqm min-check` again.");
|
|
151
|
+
}
|
|
152
|
+
const evaluation = await evaluate(context.loaded.bundle, context.repository);
|
|
153
|
+
const verifiedRepository = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
|
|
154
|
+
if (!sameRepositoryIdentity(repositoryIdentity(context.repository), repositoryIdentity(verifiedRepository))) {
|
|
155
|
+
throw candidateError("Repository identity changed during SQM full verification; no current proof can be signed.");
|
|
156
|
+
}
|
|
157
|
+
const result = await buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath });
|
|
158
|
+
if (minCheck !== options.minCheck) result.recovery = { code: "knowledge_bundle_changed", previous_min_digest: options.minCheck.result_digest, current_min_digest: minCheck.result_digest };
|
|
159
|
+
return result;
|
|
96
160
|
}
|
|
97
161
|
|
|
98
162
|
async function loadContext(options) {
|
|
99
163
|
const repository = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
|
|
100
|
-
const
|
|
164
|
+
const rejectedToken = options.token;
|
|
165
|
+
const originalIdentity = repositoryIdentity(repository);
|
|
166
|
+
const scope = Object.fromEntries(["accountId", "organizationId", "deviceId", "endpoint"].map(field => [field, options[field]]));
|
|
167
|
+
const load = (token) => (options.loadKnowledgeBundleImpl || loadKnowledgeBundle)({ ...(options.bundleDependencies || {}),
|
|
168
|
+
repository: repository.repository, cliVersion: options.cliVersion, offline: options.offline, refresh: options.refresh,
|
|
169
|
+
token, endpoint: scope.endpoint, accountId: scope.accountId, organizationId: scope.organizationId });
|
|
170
|
+
let loaded = await load(rejectedToken);
|
|
171
|
+
if (!loaded.bundle && loaded.diagnostic?.http_status === 401 && options.onAuthenticationRequired && !options.offline) {
|
|
172
|
+
await options.onRecoveryEvent?.(loaded.diagnostic);
|
|
173
|
+
const next = await options.onAuthenticationRequired({ rejectedToken, ...scope });
|
|
174
|
+
for (const field of ["accountId", "organizationId", "deviceId", "endpoint"]) {
|
|
175
|
+
if (scope[field] && next?.[field] !== scope[field]) throw scopeError(`SQM authorization changed its ${field}; do not resume another scope.`);
|
|
176
|
+
}
|
|
177
|
+
if (!next?.token || next.token === rejectedToken) throw scopeError("SQM authorization did not replace the rejected token.");
|
|
178
|
+
const current = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
|
|
179
|
+
if (!sameRepositoryIdentity(originalIdentity, repositoryIdentity(current))) throw candidateError("Repository identity changed while waiting for SQM authorization; begin a new check against the current candidate.");
|
|
180
|
+
loaded = await load(next.token);
|
|
181
|
+
}
|
|
101
182
|
return { repository, loaded };
|
|
102
183
|
}
|
|
103
184
|
|
|
@@ -159,3 +240,12 @@ function unavailable(warning, repository = null) {
|
|
|
159
240
|
}
|
|
160
241
|
|
|
161
242
|
function message(error) { return error instanceof Error ? error.message : String(error); }
|
|
243
|
+
|
|
244
|
+
function sameSystemScope(left, right) {
|
|
245
|
+
const normalized = (scope) => scope ? { organization_id: scope.organization_id,
|
|
246
|
+
repository: scope.repository?.toLowerCase(), graph_revision: scope.graph_revision,
|
|
247
|
+
repositories: [...(scope.repositories || [])].map((repository) => repository.toLowerCase()).sort() } : null;
|
|
248
|
+
return canonicalize(normalized(left)) === canonicalize(normalized(right));
|
|
249
|
+
}
|
|
250
|
+
function scopeError(message) { const error = new Error(message); error.code = "authorization_scope_changed"; return error; }
|
|
251
|
+
function candidateError(message) { const error = new Error(message); error.code = "repository_identity_changed"; return error; }
|
package/src/sqm/proof.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { createHash,
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
1
|
+
import { createHash, createPublicKey, randomUUID, sign, verify } from "node:crypto";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
|
|
6
5
|
import { canonicalize } from "./canonical.js";
|
|
6
|
+
import { loadDeviceSigningIdentity } from "../device-signing-identity.js";
|
|
7
7
|
|
|
8
8
|
export function repositoryIdentity(repository) {
|
|
9
9
|
return {
|
|
@@ -98,7 +98,7 @@ export async function signProof(proof, {
|
|
|
98
98
|
issuedAt = new Date().toISOString(),
|
|
99
99
|
} = {}) {
|
|
100
100
|
if (!deviceId) throw new Error("A trusted SIMY CLI device identity is required to sign SQM proof.");
|
|
101
|
-
const identity = await
|
|
101
|
+
const identity = await loadDeviceSigningIdentity(keyPath, { create: true });
|
|
102
102
|
const proofDigest = verifyProof(proof);
|
|
103
103
|
const signature = sign(null, Buffer.from(proofDigest.slice("sha256:".length), "hex"), identity.privateKey).toString("base64");
|
|
104
104
|
return {
|
|
@@ -133,26 +133,6 @@ export function verifySignedEvidence(proof, evidence) {
|
|
|
133
133
|
return { proof_digest: proofDigest, key_id: keyId, valid: true };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
async function loadOrCreateSigningIdentity(location) {
|
|
137
|
-
try {
|
|
138
|
-
const parsed = JSON.parse(await readFile(location, "utf8"));
|
|
139
|
-
const privateKey = createPrivateKey({ key: Buffer.from(parsed.private_key, "base64"), format: "der", type: "pkcs8" });
|
|
140
|
-
const publicKeyDer = Buffer.from(parsed.public_key, "base64");
|
|
141
|
-
const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
|
|
142
|
-
if (parsed.key_id !== keyId) throw new Error("stored key ID mismatch");
|
|
143
|
-
return { privateKey, publicKeyDer, keyId };
|
|
144
|
-
} catch (error) {
|
|
145
|
-
if (error?.code !== "ENOENT") throw new Error(`SQM signing identity is invalid: ${error.message}`);
|
|
146
|
-
}
|
|
147
|
-
const pair = generateKeyPairSync("ed25519");
|
|
148
|
-
const privateKeyDer = pair.privateKey.export({ format: "der", type: "pkcs8" });
|
|
149
|
-
const publicKeyDer = pair.publicKey.export({ format: "der", type: "spki" });
|
|
150
|
-
const keyId = `sha256:${createHash("sha256").update(publicKeyDer).digest("hex")}`;
|
|
151
|
-
await mkdir(path.dirname(location), { recursive: true, mode: 0o700 });
|
|
152
|
-
await writeFile(location, `${JSON.stringify({ algorithm: "ed25519", key_id: keyId, private_key: privateKeyDer.toString("base64"), public_key: publicKeyDer.toString("base64"), created_at: new Date().toISOString() }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
153
|
-
return { privateKey: pair.privateKey, publicKeyDer, keyId };
|
|
154
|
-
}
|
|
155
|
-
|
|
156
136
|
function taggedDigest(value) {
|
|
157
137
|
return `sha256:${createHash("sha256").update(canonicalize(value), "utf8").digest("hex")}`;
|
|
158
138
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const TRANSIENT_STATUSES = new Set([502, 503, 504]);
|
|
2
|
+
const NETWORK_CODES = new Set(["ECONNRESET", "ECONNREFUSED", "ENETUNREACH", "EAI_AGAIN", "ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET"]);
|
|
3
|
+
|
|
4
|
+
export function sqmDiagnostic(error, stage = "unknown") {
|
|
5
|
+
const status = error?.http_status ?? error?.status;
|
|
6
|
+
const timeout = error?.name === "TimeoutError" || error?.code === "ETIMEDOUT";
|
|
7
|
+
const network = (error instanceof TypeError && /fetch failed|failed to fetch|network request failed/i.test(error.message)) ||
|
|
8
|
+
NETWORK_CODES.has(error?.code) || NETWORK_CODES.has(error?.cause?.code);
|
|
9
|
+
const safeCode = typeof error?.code === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(error.code) ? error.code : null;
|
|
10
|
+
return {
|
|
11
|
+
stage: typeof error?.stage === "string" && /^[a-z][a-z0-9_]{0,79}$/.test(error.stage) ? error.stage : stage,
|
|
12
|
+
code: safeCode || (status === 401 ? "sign_in_required" : status === 403 ? "access_denied" :
|
|
13
|
+
status === 409 ? "scope_unresolved" : TRANSIENT_STATUSES.has(status) ? "gateway_unavailable" :
|
|
14
|
+
timeout ? "request_timeout" : network ? "network_unavailable" : "check_failed"),
|
|
15
|
+
...(Number.isInteger(status) && status >= 400 && status <= 599 ? { http_status: status } : {}),
|
|
16
|
+
retryable: error?.retryable !== false && (TRANSIENT_STATUSES.has(status) || timeout || network),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// This deliberately exposes only GET recovery. Uncertain POST success must be
|
|
21
|
+
// reconciled with a server idempotency contract before it may be replayed.
|
|
22
|
+
export async function fetchSqmRead(url, {
|
|
23
|
+
headers, signal, fetchImpl = globalThis.fetch,
|
|
24
|
+
timeoutMs = 45_000, requestTimeoutMs = 15_000, maxAttempts = 3,
|
|
25
|
+
sleep = abortableSleep, now = Date.now, onRecoveryEvent = async () => {},
|
|
26
|
+
} = {}) {
|
|
27
|
+
const totalLimit = boundedInteger(timeoutMs, 45_000);
|
|
28
|
+
const requestLimit = boundedInteger(requestTimeoutMs, 15_000);
|
|
29
|
+
const deadline = now() + totalLimit;
|
|
30
|
+
const deadlineSignal = AbortSignal.timeout(totalLimit);
|
|
31
|
+
const attempts = boundedInteger(maxAttempts, 3);
|
|
32
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
33
|
+
signal?.throwIfAborted();
|
|
34
|
+
deadlineSignal.throwIfAborted();
|
|
35
|
+
const remaining = deadline - now();
|
|
36
|
+
if (remaining <= 0) throw new DOMException("SQM request recovery deadline exceeded.", "TimeoutError");
|
|
37
|
+
const attemptSignal = AbortSignal.any([
|
|
38
|
+
deadlineSignal, AbortSignal.timeout(Math.min(requestLimit, remaining)),
|
|
39
|
+
...(signal ? [signal] : []),
|
|
40
|
+
]);
|
|
41
|
+
try {
|
|
42
|
+
const response = await withAbort(fetchImpl(url, { method: "GET", headers, signal: attemptSignal }), attemptSignal);
|
|
43
|
+
if (!TRANSIENT_STATUSES.has(response.status)) return response;
|
|
44
|
+
const error = new Error(`SQM Cloud returned HTTP ${response.status}.`);
|
|
45
|
+
error.status = response.status;
|
|
46
|
+
if (attempt === attempts) return response;
|
|
47
|
+
await response.body?.cancel().catch(() => {});
|
|
48
|
+
throw error;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (signal?.aborted) throw signal.reason;
|
|
51
|
+
const diagnostic = sqmDiagnostic(error, "knowledge_loading");
|
|
52
|
+
if (!diagnostic.retryable || attempt === attempts || deadlineSignal.aborted) throw error;
|
|
53
|
+
await onRecoveryEvent({ ...diagnostic, attempt, recovery: "retrying" });
|
|
54
|
+
const delay = Math.min(250 * 2 ** (attempt - 1), Math.max(0, deadline - now()));
|
|
55
|
+
if (delay <= 0) throw new DOMException("SQM request recovery deadline exceeded.", "TimeoutError");
|
|
56
|
+
await sleep(delay, AbortSignal.any([deadlineSignal, ...(signal ? [signal] : [])]));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function boundedInteger(value, maximum) {
|
|
62
|
+
return Number.isFinite(value) && value > 0 ? Math.min(maximum, Math.max(1, Math.floor(value))) : maximum;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function withAbort(promise, signal) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
|
|
68
|
+
if (signal.aborted) abort();
|
|
69
|
+
else signal.addEventListener("abort", abort, { once: true });
|
|
70
|
+
Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function abortableSleep(milliseconds, signal) {
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const finish = () => { signal.removeEventListener("abort", abort); resolve(); };
|
|
78
|
+
const timer = setTimeout(finish, milliseconds);
|
|
79
|
+
const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason); };
|
|
80
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
81
|
+
});
|
|
82
|
+
}
|