@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.
Files changed (48) hide show
  1. package/README.md +84 -0
  2. package/package.json +2 -2
  3. package/src/agent.js +191 -11
  4. package/src/backend-executable.js +21 -1
  5. package/src/cli-contract.js +1 -1
  6. package/src/codex-plugins/simy/hooks/README.md +22 -0
  7. package/src/codex-plugins/simy/hooks/continuation-policy.mjs +27 -0
  8. package/src/codex-plugins/simy/hooks/delivery-loop.mjs +179 -34
  9. package/src/codex-plugins/simy/hooks/outcome-progress.mjs +129 -0
  10. package/src/coding-run-recovery.js +88 -0
  11. package/src/coding-run-store.js +2 -2
  12. package/src/continuation-policy.js +2 -0
  13. package/src/delivery-hooks/command.js +11 -5
  14. package/src/delivery-hooks/desktop.js +12 -3
  15. package/src/delivery-hooks/supervisor.js +14 -10
  16. package/src/desktop-host.js +8 -6
  17. package/src/desktop-sqm-settings.js +24 -7
  18. package/src/device-signing-identity.js +65 -0
  19. package/src/diagnostic-log.js +68 -0
  20. package/src/doctor.js +55 -0
  21. package/src/index.js +7 -0
  22. package/src/local-task.js +68 -3
  23. package/src/orchestrator/budget.js +55 -4
  24. package/src/orchestrator/charter-validation.js +33 -0
  25. package/src/orchestrator/completion-contract.js +1 -1
  26. package/src/orchestrator/contract.js +2 -0
  27. package/src/orchestrator/delivery/references/state-and-receipts.md +21 -3
  28. package/src/orchestrator/independent-audit.js +9 -0
  29. package/src/orchestrator/instruction.js +6 -1
  30. package/src/orchestrator/loop.js +38 -41
  31. package/src/orchestrator/outcome-monitor.js +58 -38
  32. package/src/orchestrator/outcome-progress.js +2 -0
  33. package/src/purpose-local-proof.js +380 -0
  34. package/src/runner.js +224 -36
  35. package/src/session-supervision/codex-app-server.js +3 -2
  36. package/src/session-supervision/index.js +16 -4
  37. package/src/sqm/bundle-store.js +11 -7
  38. package/src/sqm/claude-plugin.js +2 -0
  39. package/src/sqm/command.js +36 -4
  40. package/src/sqm/diagnostics.js +1 -1
  41. package/src/sqm/evidence-client.js +23 -7
  42. package/src/sqm/history-store.js +4 -0
  43. package/src/sqm/index.js +104 -14
  44. package/src/sqm/proof.js +3 -23
  45. package/src/sqm/request-recovery.js +82 -0
  46. package/src/sqm/review-worker.js +59 -12
  47. package/src/sqm/session.js +33 -3
  48. package/src/startup-preflight.js +44 -0
@@ -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
- const token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN || validSession?.token;
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
  }
@@ -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
- const response = await fetchImpl(endpoint, {
5
- method: "POST",
6
- headers: { accept: "application/json", authorization: `Bearer ${token}`, "content-type": "application/json" },
7
- body: JSON.stringify({ proof, signed_evidence: signedEvidence }),
8
- signal: AbortSignal.timeout(10_000),
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) throw Object.assign(new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`), safeSqmDiagnostic({ status: response.status, code: payload?.error?.code, requestId: response.headers?.get?.("x-request-id") }, "evidence_upload", "evidence_upload_failed"));
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
  }
@@ -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, ...dependencies });
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) return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings, diagnostic: loaded.diagnostic };
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 full = await buildFullCheck({ repository, bundle: loaded.bundle, minCheck: latestMinCheck, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion, deviceId, accountId });
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)}`, diagnostic: safeSqmDiagnostic(error, "evidence_upload", "evidence_upload_failed") };
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), diagnostic: safeSqmDiagnostic(error, phase, "sqm_execution_failed"), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`] };
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
- return async (attempt, options = {}) => {
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) { await save({ status: "failed", diagnostic: safeSqmDiagnostic(error, options.phase || "min") }); throw 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 (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw Object.assign(new Error("Repository identity changed after min-check; run `simy sqm min-check` again."), { code: "repository_identity_changed", stage: "identity_validation" });
93
- if (options.minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) throw Object.assign(new Error("Knowledge bundle changed after min-check; run `simy sqm min-check` again so both phases use one pinned bundle."), { code: "knowledge_bundle_changed", stage: "identity_validation" });
94
- const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
95
- return buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck: options.minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath });
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 loaded = await (options.loadKnowledgeBundleImpl || loadKnowledgeBundle)({ repository: repository.repository, cliVersion: options.cliVersion, offline: options.offline, refresh: options.refresh, token: options.token, endpoint: options.endpoint, accountId: options.accountId, organizationId: options.organizationId, ...(options.bundleDependencies || {}) });
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, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign, verify } from "node:crypto";
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 loadOrCreateSigningIdentity(keyPath);
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
+ }