@uchibeke/gallivant 0.0.0-stage → 0.2.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.
Files changed (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +201 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +257 -0
  6. package/bin/package-plugin.mjs +16 -0
  7. package/bin/release-live.mjs +29 -0
  8. package/bin/release-package.mjs +23 -0
  9. package/bin/session-runtime.mjs +13 -0
  10. package/bin/task-delivery-state.mjs +12 -0
  11. package/docs/README.md +70 -0
  12. package/docs/agent-setup.md +214 -0
  13. package/docs/app.md +111 -0
  14. package/docs/architecture.md +265 -0
  15. package/docs/chains.md +102 -0
  16. package/docs/codex-cloud.md +284 -0
  17. package/docs/commands.md +620 -0
  18. package/docs/config.md +430 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +338 -0
  22. package/docs/launch.md +42 -0
  23. package/docs/learning.md +369 -0
  24. package/docs/network-recovery.md +101 -0
  25. package/docs/openai-agents.md +457 -0
  26. package/docs/prd.md +246 -0
  27. package/docs/preparation.md +110 -0
  28. package/docs/release-qualification.md +196 -0
  29. package/docs/releasing.md +140 -0
  30. package/docs/runtime-environment.md +113 -0
  31. package/docs/task-delivery.md +476 -0
  32. package/docs/tasks.md +739 -0
  33. package/lib/aport.mjs +95 -0
  34. package/lib/attribution.mjs +95 -0
  35. package/lib/block.mjs +89 -0
  36. package/lib/capabilities.mjs +3 -0
  37. package/lib/chains.mjs +222 -0
  38. package/lib/codex_cloud.mjs +160 -0
  39. package/lib/codex_cloud_config.mjs +20 -0
  40. package/lib/codex_cloud_input.mjs +49 -0
  41. package/lib/codex_cloud_result.mjs +74 -0
  42. package/lib/commands/doctor.mjs +457 -0
  43. package/lib/commands/init.mjs +34 -0
  44. package/lib/commands/learn.mjs +54 -0
  45. package/lib/commands/new.mjs +47 -0
  46. package/lib/commands/ready.mjs +45 -0
  47. package/lib/commands/register.mjs +195 -0
  48. package/lib/commands/resume.mjs +224 -0
  49. package/lib/commands/serve.mjs +349 -0
  50. package/lib/commands/start.mjs +247 -0
  51. package/lib/commands/start_services.mjs +22 -0
  52. package/lib/commands/status.mjs +150 -0
  53. package/lib/commands/task.mjs +73 -0
  54. package/lib/commands/up.mjs +228 -0
  55. package/lib/commands/why.mjs +143 -0
  56. package/lib/config.mjs +461 -0
  57. package/lib/context.mjs +100 -0
  58. package/lib/control.mjs +202 -0
  59. package/lib/control_contract.mjs +16 -0
  60. package/lib/coordinator.mjs +19 -0
  61. package/lib/credential.mjs +161 -0
  62. package/lib/daemon.mjs +156 -0
  63. package/lib/deliverable.mjs +357 -0
  64. package/lib/events.mjs +261 -0
  65. package/lib/executable.mjs +31 -0
  66. package/lib/execution.mjs +631 -0
  67. package/lib/execution_contract.mjs +15 -0
  68. package/lib/execution_input.mjs +4 -0
  69. package/lib/execution_io.mjs +94 -0
  70. package/lib/execution_journal.mjs +69 -0
  71. package/lib/execution_preparation.mjs +29 -0
  72. package/lib/execution_result.mjs +266 -0
  73. package/lib/fs.mjs +133 -0
  74. package/lib/gh.mjs +289 -0
  75. package/lib/git.mjs +434 -0
  76. package/lib/harness.mjs +521 -0
  77. package/lib/harness_log.mjs +432 -0
  78. package/lib/hash.mjs +16 -0
  79. package/lib/identity.mjs +98 -0
  80. package/lib/learning.mjs +654 -0
  81. package/lib/learning_contract.mjs +2 -0
  82. package/lib/learning_data.mjs +135 -0
  83. package/lib/learning_input.mjs +111 -0
  84. package/lib/learning_qualification.mjs +67 -0
  85. package/lib/learning_signer.mjs +151 -0
  86. package/lib/learning_store.mjs +88 -0
  87. package/lib/log.mjs +53 -0
  88. package/lib/network_diagnostics.mjs +64 -0
  89. package/lib/network_recovery.mjs +42 -0
  90. package/lib/notify.mjs +58 -0
  91. package/lib/openai_agents.mjs +373 -0
  92. package/lib/openai_agents_config.mjs +14 -0
  93. package/lib/openai_agents_http.mjs +87 -0
  94. package/lib/openai_agents_input.mjs +15 -0
  95. package/lib/openai_agents_sources.mjs +112 -0
  96. package/lib/owner_delivery.mjs +443 -0
  97. package/lib/plugin_package.mjs +340 -0
  98. package/lib/prd.mjs +635 -0
  99. package/lib/preparation_agents.mjs +33 -0
  100. package/lib/preparation_codex.mjs +212 -0
  101. package/lib/probe.mjs +126 -0
  102. package/lib/prompt.mjs +238 -0
  103. package/lib/provider_health.mjs +55 -0
  104. package/lib/public-format.mjs +31 -0
  105. package/lib/public_output.mjs +49 -0
  106. package/lib/release_package.mjs +154 -0
  107. package/lib/repository_files.mjs +86 -0
  108. package/lib/run.mjs +385 -0
  109. package/lib/run_log.mjs +94 -0
  110. package/lib/runtime-environment.mjs +87 -0
  111. package/lib/runtime_dependencies.mjs +15 -0
  112. package/lib/runtime_node.mjs +107 -0
  113. package/lib/runtime_state.mjs +174 -0
  114. package/lib/sources/index.mjs +27 -0
  115. package/lib/sources/prd.mjs +50 -0
  116. package/lib/start_contract.mjs +37 -0
  117. package/lib/state.mjs +246 -0
  118. package/lib/task_allowance.mjs +31 -0
  119. package/lib/task_completion.mjs +61 -0
  120. package/lib/task_contract.mjs +450 -0
  121. package/lib/task_delivery.mjs +219 -0
  122. package/lib/task_delivery_cancel.mjs +34 -0
  123. package/lib/task_delivery_contract.mjs +61 -0
  124. package/lib/task_delivery_mcp.mjs +40 -0
  125. package/lib/task_delivery_store.mjs +43 -0
  126. package/lib/task_delivery_view.mjs +88 -0
  127. package/lib/task_environment.mjs +281 -0
  128. package/lib/task_prepare.mjs +224 -0
  129. package/lib/task_store.mjs +38 -0
  130. package/lib/tasks.mjs +513 -0
  131. package/lib/tick.mjs +1635 -0
  132. package/lib/time.mjs +36 -0
  133. package/lib/tracker/assets.mjs +43 -0
  134. package/lib/tracker/pages.mjs +320 -0
  135. package/lib/tracker/runs.mjs +102 -0
  136. package/lib/tracker/stream.mjs +74 -0
  137. package/lib/tracker_process.mjs +107 -0
  138. package/lib/verification_sandbox.mjs +181 -0
  139. package/node_modules/smol-toml/LICENSE +24 -0
  140. package/node_modules/smol-toml/README.md +605 -0
  141. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  142. package/node_modules/smol-toml/dist/date.js +156 -0
  143. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  144. package/node_modules/smol-toml/dist/error.js +67 -0
  145. package/node_modules/smol-toml/dist/extract.js +289 -0
  146. package/node_modules/smol-toml/dist/index.cjs +770 -0
  147. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  148. package/node_modules/smol-toml/dist/index.js +34 -0
  149. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  150. package/node_modules/smol-toml/dist/parse.js +164 -0
  151. package/node_modules/smol-toml/dist/primitive.js +151 -0
  152. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  153. package/node_modules/smol-toml/dist/stringify.js +219 -0
  154. package/node_modules/smol-toml/dist/struct.js +156 -0
  155. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  156. package/node_modules/smol-toml/dist/util.js +57 -0
  157. package/node_modules/smol-toml/package.json +68 -0
  158. package/npm-shrinkwrap.json +1823 -0
  159. package/package.json +105 -4
  160. package/templates/claude-settings.json +18 -0
  161. package/templates/passport.gallivant.json +83 -0
  162. package/templates/plugin/bundled-runtime.json +26 -0
  163. package/templates/plugin/schemas/mcp.schema.json +120 -0
  164. package/templates/plugin/schemas/plugin.schema.json +65 -0
  165. package/templates/plugin/task-delivery.SKILL.md +60 -0
  166. package/templates/prd.md +32 -0
  167. package/templates/tracker/tracker.css +301 -0
  168. package/templates/tracker/tracker.js +721 -0
@@ -0,0 +1,2 @@
1
+ // Pending durable audit deliveries keep the coordinator awake on every host.
2
+ export const learningDeliveryPending = entry => Object.keys(entry?.learning_deliveries || {}).length > 0;
@@ -0,0 +1,135 @@
1
+ // All learning input is data. These bytes are also the approval/signature format.
2
+ import { createHash } from "node:crypto";
3
+ import { redact } from "./execution_io.mjs";
4
+
5
+ export const learningError = (code, message) => Object.assign(new Error(message), { code: `LEARNING_${code}` });
6
+ export const hash = value => createHash("sha256").update(typeof value === "string" || Buffer.isBuffer(value) ? value : canonical(value)).digest("hex");
7
+ const bad = message => { throw learningError("INPUT", message); };
8
+ const dangerousText = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
9
+ export function textField(value, name, max = 2048) {
10
+ if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value) > max || dangerousText.test(value) || /[\ud800-\udfff]/u.test(value.replace(/[\ud800-\udbff][\udc00-\udfff]/g, ""))) bad(`invalid ${name}`);
11
+ return value;
12
+ }
13
+ export function closed(value, keys, name) {
14
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype || Object.keys(value).some(k => !keys.includes(k)) || keys.some(k => !Object.hasOwn(value, k))) bad(`invalid ${name} schema`);
15
+ }
16
+ export function canonical(value) {
17
+ // RFC 8785: ECMAScript number serialization, UTF-16 key order, no Unicode normalization.
18
+ const walk = (v, depth = 0) => {
19
+ if (depth > 24) bad("JSON nesting limit");
20
+ if (v === null || typeof v === "boolean") return JSON.stringify(v);
21
+ if (typeof v === "number") { if (!Number.isFinite(v)) bad("nonfinite JSON number"); return JSON.stringify(v); }
22
+ if (typeof v === "string") {
23
+ if (/[\ud800-\udfff]/u.test(v.replace(/[\ud800-\udbff][\udc00-\udfff]/g, ""))) bad("invalid Unicode");
24
+ return JSON.stringify(v);
25
+ }
26
+ if (Array.isArray(v)) return `[${v.map(x => walk(x, depth + 1)).join(",")}]`;
27
+ if (v && Object.getPrototypeOf(v) === Object.prototype) return `{${Object.keys(v).sort().map(k => `${walk(k)}:${walk(v[k], depth + 1)}`).join(",")}}`;
28
+ bad("non-JSON value");
29
+ };
30
+ return walk(value);
31
+ }
32
+ export function parseJson(input, max = 256 * 1024) {
33
+ const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input);
34
+ if (bytes.length > max) bad("JSON byte limit");
35
+ let raw;
36
+ try { raw = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { bad("invalid UTF-8"); }
37
+ // Parse object keys before JSON.parse can discard duplicates. Bound nesting and tokens.
38
+ let pos = 0, tokens = 0;
39
+ const ws = () => { while (/\s/.test(raw[pos] || "") && pos < raw.length) pos++; };
40
+ const string = () => {
41
+ const start = pos++;
42
+ while (pos < raw.length) {
43
+ const c = raw[pos++];
44
+ if (c === "\\") pos++;
45
+ else if (c === '"') { try { return JSON.parse(raw.slice(start, pos)); } catch { bad("invalid JSON string"); } }
46
+ }
47
+ bad("unterminated JSON string");
48
+ };
49
+ const value = (depth = 0) => {
50
+ if (depth > 24 || ++tokens > 100_000) bad("JSON structure limit");
51
+ ws();
52
+ const c = raw[pos];
53
+ if (c === '"') return string();
54
+ if (c === "{" || c === "[") {
55
+ const object = c === "{", end = object ? "}" : "]", keys = new Set(); pos++; ws();
56
+ if (raw[pos] === end) { pos++; return; }
57
+ for (;;) {
58
+ if (object) {
59
+ ws(); if (raw[pos] !== '"') bad("invalid JSON key");
60
+ const key = string(); if (keys.has(key) || ["__proto__", "constructor", "prototype"].includes(key)) bad("duplicate or unsafe JSON key"); keys.add(key);
61
+ ws(); if (raw[pos++] !== ":") bad("invalid JSON object");
62
+ }
63
+ value(depth + 1); ws();
64
+ if (raw[pos] === end) { pos++; return; }
65
+ if (raw[pos++] !== ",") bad("invalid JSON delimiter");
66
+ }
67
+ }
68
+ const m = raw.slice(pos).match(/^(?:null|true|false|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/);
69
+ if (!m) bad("invalid JSON value"); pos += m[0].length;
70
+ };
71
+ value(); ws(); if (pos !== raw.length) bad("trailing JSON");
72
+ let result; try { result = JSON.parse(raw); } catch { bad("invalid JSON"); }
73
+ canonical(result);
74
+ return result;
75
+ }
76
+ export function literalPath(path) {
77
+ textField(path, "project path", 512);
78
+ if (path.startsWith("/") || /[\\:*?\[\]{}\s]/.test(path) || path.split("/").some(x => !x || x === "." || x === "..") || path.split("/").some(x => x.startsWith("."))) bad("unsafe literal project path");
79
+ return path;
80
+ }
81
+ export function repository(repo) {
82
+ closed(repo, ["host", "id"], "repository");
83
+ if (repo.host !== "github.com" || typeof repo.id !== "string" || !/^[1-9]\d{0,19}$/.test(repo.id)) bad("unsupported repository identity");
84
+ return repo;
85
+ }
86
+ export function repositoryName(name) {
87
+ if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name) || name.includes("..")) bad("expected owner/repository");
88
+ return name;
89
+ }
90
+ export const digestField = v => { if (typeof v !== "string" || !/^[a-f0-9]{64}$/.test(v)) bad("expected SHA-256 digest"); return v; };
91
+ export const candidateId = v => { if (typeof v !== "string" || !/^lesson-[a-z0-9-]{1,80}$/.test(v)) bad("invalid candidate ID"); return v; };
92
+ export function candidate(value, repo) {
93
+ closed(value, ["version", "id", "repo", "paths", "applicability", "guidance", "rationale", "example", "evidence", "disposition", "regression", "provenance"], "candidate");
94
+ if (value.version !== 1) bad("unsupported candidate version");
95
+ candidateId(value.id); repository(value.repo);
96
+ if (canonical(value.repo) !== canonical(repo)) bad("foreign repository candidate");
97
+ if (!Array.isArray(value.paths) || value.paths.length > 20 || new Set(value.paths).size !== value.paths.length) bad("invalid project scopes");
98
+ value.paths.forEach(literalPath);
99
+ for (const name of ["applicability", "guidance", "rationale", "example"]) textField(value[name], name);
100
+ if (!Array.isArray(value.evidence) || !value.evidence.length || value.evidence.length > 20 || new Set(value.evidence).size !== value.evidence.length) bad("invalid candidate evidence");
101
+ value.evidence.forEach(digestField);
102
+ if (!["unconfirmed", "fixed", "contested", "retracted"].includes(value.disposition)) bad("invalid fix disposition");
103
+ if (value.regression !== null) literalPath(value.regression);
104
+ closed(value.provenance, ["provider", "inputDigest", "at"], "provenance");
105
+ textField(value.provenance.provider, "provider", 80); digestField(value.provenance.inputDigest); iso(value.provenance.at);
106
+ // A lesson is advice, never an alternative configuration/policy program.
107
+ const advice = [value.applicability, value.guidance, value.rationale, value.example].join("\n");
108
+ if (/\b(?:disable|skip|bypass|override|change|replace|ignore|increase|remove|set|grant)\b[^.\n]{0,120}\b(?:permissions?|tools?|budgets?|trust|verify|verification|acceptance|merge policy|aport|gates?|sandbox|signer|checkpoint)\b/i.test(advice)) bad("candidate attempts to change authority or task gates");
109
+ if (canonical(redact(value)) !== canonical(value)) bad("candidate contains unredacted secret material");
110
+ if (Buffer.byteLength(canonical(value)) > 16 * 1024) bad("candidate byte limit");
111
+ return { content: parseJson(canonical(value)), digest: hash(value) };
112
+ }
113
+ export function iso(value) {
114
+ if (typeof value !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{3})?Z$/.test(value) || !Number.isFinite(Date.parse(value))) bad("invalid UTC timestamp");
115
+ return value;
116
+ }
117
+ export function dateRange(since, until, now) {
118
+ const end = until ? date(until) : now, start = since ? date(since) : end - 30 * 86400_000;
119
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end) bad("expected half-open UTC date range");
120
+ return { since: new Date(start).toISOString(), until: new Date(end).toISOString() };
121
+ }
122
+ const date = value => {
123
+ if (typeof value !== "string" || !/^\d{4}-\d\d-\d\d$/.test(value)) bad("dates use YYYY-MM-DD");
124
+ const n = Date.parse(value + "T00:00:00Z");
125
+ if (!Number.isFinite(n) || new Date(n).toISOString().slice(0, 10) !== value) bad("invalid date");
126
+ return n;
127
+ };
128
+ export function minimized(text, secrets = []) {
129
+ const raw = String(text ?? "");
130
+ const clean = redact(raw, secrets).replace(/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
131
+ const bytes = Buffer.from(clean), excerpt = new TextDecoder().decode(bytes.subarray(0, 2048));
132
+ return { excerpt, incomplete: bytes.length > 2048, redacted: clean !== raw, sourceDigest: hash(raw) };
133
+ }
134
+ // JSON escapes all terminal controls; Markdown is a view, never interpreted or fetched.
135
+ export const safeDisplay = value => JSON.stringify(value, null, 2).replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
@@ -0,0 +1,111 @@
1
+ // Both initial and subsequent, local and hosted dispatch use this seam. No raw
2
+ // feedback, candidate, repository instruction or task field can enable learning.
3
+ import { hash, learningError, literalPath } from "./learning_data.mjs";
4
+ import { executionRunner } from "./execution.mjs";
5
+
6
+ export { learningDeliveryPending } from "./learning_contract.mjs";
7
+
8
+ export async function revokeLearningAttempts(ctx, { repo, removed, reason, prepared = [] }) {
9
+ const outcomes = [];
10
+ const entries = new Map(ctx.state.entries());
11
+ for (const id of new Set([...entries.keys(), ...prepared.map(d => d.taskId)])) {
12
+ const entry = entries.get(id), intents = prepared.filter(d => d.taskId === id);
13
+ const delivered = entry?.learning && hash(entry.learning.repo) === hash(repo) && entry.learning.rules?.some(r => !removed || removed.some(x => x.id === r.id && x.digest === r.digest));
14
+ if (!delivered && !intents.length) continue;
15
+ const runner = executionRunner(ctx, id, entry?.harness || intents[0]?.profile), current = runner.current();
16
+ const pending = intents.some(d => current?.execution?.learning?.key === d.key || current?.execution?.operationId === d.operationId);
17
+ // An old undelivered intent must not cancel a later unrelated operation.
18
+ if (!delivered && !pending) continue;
19
+ // Preserve task identity, PR, code, review cursor and spend. Do not promise
20
+ // cancellation or erased provider memory when the backend cannot confirm it.
21
+ if (entry) ctx.state.update(id, { learning_revocation: { reason, freshSessionRequired: true, at: new Date().toISOString() } });
22
+ let answer = { confirmed: false, unsupported: true };
23
+ try { if (current?.running && runner.cancel) answer = await runner.cancel("learned advice retired"); else if (!current?.running) answer = { confirmed: true, terminal: true }; } catch { /* saved intent remains actionable */ }
24
+ if (entry) ctx.state.update(id, { learning_revocation: { reason, freshSessionRequired: true, cancellation: answer, at: new Date().toISOString() } });
25
+ outcomes.push({ id, cancellation: answer, freshSessionRequired: true });
26
+ }
27
+ return outcomes;
28
+ }
29
+
30
+ export function learningPaths(entry, paths = []) {
31
+ const selected = new Set();
32
+ for (const path of [...paths, ...(entry.learning?.paths || []), ...(entry.sources || []), entry.prd].filter(Boolean)) {
33
+ try { selected.add(literalPath(path)); } catch { continue; }
34
+ if (selected.size >= 200) break;
35
+ }
36
+ return [...selected].sort();
37
+ }
38
+
39
+ export async function learningInput(ctx, id, entry, input) {
40
+ const service = ctx.learning || ctx.learningFor?.();
41
+ if (!service) return input;
42
+ const repo = service.identityFor(entry.repo);
43
+ if (!repo) return input;
44
+ const previous = entry.learning ?? ctx.state?.get?.(id)?.learning ?? null;
45
+ const mode = service.mode(repo);
46
+ if (mode !== "apply" && !previous?.rules?.length) return input;
47
+ // Recheck immutable GitHub identity; a cached renamed/replaced owner/name is
48
+ // navigation, never authority for delivering old repository content.
49
+ if (mode === "apply") {
50
+ const actual = await service.resolveRepo(entry.repo);
51
+ if (hash(actual) !== hash(repo)) throw learningError("REPOSITORY", "repository identity changed; foreign learning cannot be delivered");
52
+ }
53
+ const paths = learningPaths(entry, input.paths);
54
+ if (paths.length) input = { ...input, paths };
55
+ // Pin an empty snapshot locally when disabled. A concurrent enable cannot
56
+ // cause unchecked repository content to enter this offline clearing attempt.
57
+ const snapshot = mode !== "apply"
58
+ ? { section: "", delivered: false, freshSessionRequired: !!previous?.rules?.length, mode }
59
+ : await service.context(repo, { operationId: input.operationId, profile: entry.harness, paths, inputDigest: hash(input), previous, taskId: id });
60
+ if (snapshot.freshSessionRequired && entry.session) throw learningError("CONTINUITY", "retired/disabled advice remains in native session memory; use resume --fresh-session after the prior run settles; code, PR and task state were preserved");
61
+ return { ...input, prompt: input.prompt + (snapshot.section ? "\n\n" + snapshot.section : ""), learning: { ...snapshot, repo, paths }, managedInputDigest: hash({ ...input, advice: snapshot.section || "" }) };
62
+ }
63
+
64
+ export async function learningDispatch(ctx, id, input, launch) {
65
+ if (!input.learning?.key) return launch();
66
+ const service = ctx.learning || ctx.learningFor?.();
67
+ let attempted = false;
68
+ try {
69
+ if (!service?.dispatch) throw learningError("UNQUALIFIED", "learning dispatch admission unavailable; nothing launched");
70
+ return await service.dispatch(input.learning.repo, { key: input.learning.key, taskId: id, contextDigest: input.learning.contextDigest }, () => { attempted = true; return launch(); });
71
+ } catch (error) {
72
+ if (!attempted) error.dispatchNotSent = true;
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ // Save this patch atomically with accepted-run ownership. The registry outbox
78
+ // survives learning-store locks, process restarts, and later accepted attempts.
79
+ export function learningDeliveryPatch(entry, input, job) {
80
+ if (!input.learning || !job?.id || (job.execution && job.execution.acceptance !== "accepted")) return {};
81
+ const learning = { ...input.learning, rules: input.learning.rules || [], runId: job.id };
82
+ if (!input.learning.key) return { learning };
83
+ const delivery = { repo: input.learning.repo, key: input.learning.key, runId: job.id };
84
+ const key = hash(delivery);
85
+ return { learning, learning_deliveries: { ...entry?.learning_deliveries, [key]: delivery } };
86
+ }
87
+
88
+ export async function learningDelivered(ctx, id) {
89
+ // Reporting must never undo or obscure provider acceptance. Successful writes
90
+ // are idempotent if the process dies before removing the registry outbox item.
91
+ try {
92
+ for (const [key, delivery] of Object.entries(ctx.state.get(id)?.learning_deliveries || {}).slice(0, 10)) {
93
+ const service = ctx.learning || ctx.learningFor?.();
94
+ const snapshot = await service.delivered(delivery.repo, delivery.key, delivery.runId);
95
+ // Retirement may have won after acceptance but before registry ownership.
96
+ // Keep the outbox until controls have been reconciled against that ownership.
97
+ if (snapshot.revoked) {
98
+ const controls = await revokeLearningAttempts(ctx, { repo: delivery.repo, removed: snapshot.rules, reason: snapshot.revoked.reason });
99
+ if (controls.some(c => c.cancellation?.confirmed !== true)) throw learningError("CONTROL", "revoked delivery cancellation remains unconfirmed");
100
+ }
101
+ ctx.state.update(id, entry => {
102
+ const pending = { ...entry.learning_deliveries };
103
+ delete pending[key];
104
+ return { learning_deliveries: pending, ...(entry.last?.run === delivery.runId && entry.learning?.key === delivery.key
105
+ ? { learning: { ...snapshot, repo: delivery.repo } } : {}) };
106
+ });
107
+ }
108
+ } catch {
109
+ ctx.log?.warn(`${id}: learning delivery audit pending; accepted run retained`);
110
+ }
111
+ }
@@ -0,0 +1,67 @@
1
+ // Disposable OS-denial evidence, never a config boolean or production enrollment.
2
+ // The current coding adapters do not launch through this outer profile. A passed
3
+ // standalone probe therefore cannot qualify their production apply mode.
4
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readlinkSync, rmSync, realpathSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { verificationSandboxSpec } from "./verification_sandbox.mjs";
8
+ import { execFileAsync } from "./gh.mjs";
9
+ import { hash } from "./learning_data.mjs";
10
+
11
+ const PROBE = `const fs=require('node:fs'),cp=require('node:child_process'),net=require('node:net');
12
+ const paths=JSON.parse(process.argv[1]),namespace=process.argv[2]||'';
13
+ if(namespace&&fs.readlinkSync('/proc/self/ns/net')===namespace)process.exit(49);
14
+ for(const p of paths) {
15
+ for(const action of [()=>fs.readFileSync(p),()=>fs.writeFileSync(p,'changed')]) {
16
+ try {action();process.exit(41);} catch(e){if(!['EPERM','EACCES','ENOENT','EROFS'].includes(e.code))process.exit(42);}
17
+ }
18
+ }
19
+ try{fs.openSync('/dev/tty','r+');process.exit(43);}catch(e){if(!['EPERM','EACCES','ENXIO','ENOENT','ENODEV'].includes(e.code))process.exit(44);}
20
+ try{cp.execFileSync('/usr/bin/sandbox-exec',['-p','(version 1)(allow default)','/bin/cat',paths[0]],{stdio:'pipe'});process.exit(45);}catch{}
21
+ const s=net.connect({host:'127.0.0.1',port:9});s.on('connect',()=>process.exit(46));
22
+ s.on('error',e=>{if(!['EPERM','EACCES'].includes(e.code)&&!(namespace&&['ENETUNREACH','EHOSTUNREACH','ECONNREFUSED'].includes(e.code)))process.exit(47);process.stdout.write('denied-key-trust-verifier-checkpoint-terminal-control');});
23
+ setTimeout(()=>process.exit(48),2000).unref();`;
24
+
25
+ export async function probeLearningIsolation({ exec = execFileAsync } = {}) {
26
+ const dir = mkdtempSync(join(realpathSync(tmpdir()), "learning-isolation-"));
27
+ try {
28
+ const worktree = join(dir, "worker"), home = join(dir, "empty-home"), protectedDir = join(dir, "protected");
29
+ for (const p of [worktree, home, protectedDir]) mkdirSync(p, { mode: 0o700 });
30
+ const canaries = ["private-key", "public-trust", "active-store", "checkpoint", "verifier"].map(name => join(protectedDir, name));
31
+ for (const p of canaries) writeFileSync(p, "disposable-canary", { mode: 0o600 });
32
+ const spec = verificationSandboxSpec({ worktree, home, env: { HOME: home, LANG: "C" } });
33
+ const namespace = spec.driver === "bubblewrap" ? readlinkSync("/proc/self/ns/net") : "";
34
+ const result = await exec(spec.cmd, [...spec.args, spec.node, "-e", PROBE, JSON.stringify(canaries), namespace], { env: spec.env, cwd: worktree, timeout: 5000, maxBuffer: 4096 });
35
+ if (result.stdout !== "denied-key-trust-verifier-checkpoint-terminal-control" || canaries.some(p => readFileSync(p, "utf8") !== "disposable-canary")) throw new Error("denial probes failed");
36
+ return { qualified: false, status: "UNQUALIFIED", standaloneProbe: "passed", platform: process.platform, driver: spec.driver, profileDigest: hash({ cmd: spec.cmd, args: spec.args, node: spec.node }), output: result.stdout, reason: "disposable offline OS profile passed; installed coding adapters are not enclosed by it", remedy: "integrate and qualify the exact installed worker/extractor launch and privileged/control routes before production apply" };
37
+ } catch (e) {
38
+ return { qualified: false, status: "UNQUALIFIED", platform: process.platform, standaloneProbe: "failed", reason: String(e.stderr || e.message).slice(0, 2048), remedy: "macOS must permit sandbox-exec; Linux needs /usr/bin/bwrap and working user/PID/network namespaces; run from the operator terminal" };
39
+ } finally { rmSync(dir, { recursive: true, force: true }); }
40
+ }
41
+
42
+ // The live ceremony owns these disposable directories and launches every probe
43
+ // through the very same compiled profile. It is never returned by config or the
44
+ // installed coordinator's learningFor factory.
45
+ export function disposableLearningProfile(dir, { exec = execFileAsync } = {}) {
46
+ const worktree = join(dir, "worker"), home = join(dir, "empty-home"), protectedDir = join(dir, "protected");
47
+ for (const p of [worktree, home, protectedDir]) mkdirSync(p, { mode: 0o700 });
48
+ const paths = ["private-key", "public-trust", "active-store", "checkpoint", "verifier"].map(name => join(protectedDir, name));
49
+ for (const path of paths) writeFileSync(path, "disposable-canary", { mode: 0o600 });
50
+ const spec = verificationSandboxSpec({ worktree, home, env: { HOME: home, LANG: "C" } });
51
+ const namespace = spec.driver === "bubblewrap" ? readlinkSync("/proc/self/ns/net") : "";
52
+ return {
53
+ worktree, home, protectedDir,
54
+ async qualify() {
55
+ const result = await exec(spec.cmd, [...spec.args, spec.node, "-e", PROBE, JSON.stringify(paths), namespace], { env: spec.env, cwd: worktree, timeout: 5000, maxBuffer: 4096 });
56
+ if (result.stdout !== "denied-key-trust-verifier-checkpoint-terminal-control" || paths.some(p => readFileSync(p, "utf8") !== "disposable-canary")) throw new Error("disposable live authority boundary failed");
57
+ return { qualified: true, disposableOnly: true, status: "disposable offline profile passed", driver: spec.driver, profileDigest: hash({ cmd: spec.cmd, args: spec.args, node: spec.node }), observed: result.stdout };
58
+ },
59
+ async consume(snapshot) {
60
+ const program = `const s=JSON.parse(process.argv[1]),c=require('node:crypto');const digest=c.createHash('sha256').update(s.section).digest('hex');if(digest!==s.contextDigest)process.exit(51);process.stdout.write(JSON.stringify({contextDigest:digest,bundleDigest:s.bundleDigest,rules:s.rules}));`;
61
+ const result = await exec(spec.cmd, [...spec.args, spec.node, "-e", program, JSON.stringify(snapshot)], { env: spec.env, cwd: worktree, timeout: 5000, maxBuffer: 4096 });
62
+ const consumed = JSON.parse(result.stdout);
63
+ if (consumed.contextDigest !== snapshot.contextDigest || consumed.bundleDigest !== snapshot.bundleDigest) throw new Error("live worker consumed different context bytes");
64
+ return { runId: `disposable-live-${hash(result.stdout).slice(0, 24)}`, consumed };
65
+ },
66
+ };
67
+ }
@@ -0,0 +1,151 @@
1
+ // OpenSSH owns cryptography and passphrase entry. No secret enters Node or its logs.
2
+ import { execFileSync, spawnSync } from "node:child_process";
3
+ import { readFileSync, lstatSync, mkdtempSync, writeFileSync, rmSync, openSync, closeSync, realpathSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { safePath, inside } from "./learning_store.mjs";
7
+ import { hash, canonical, learningError, closed, textField } from "./learning_data.mjs";
8
+
9
+ export const NAMESPACE = "gallivant-learning-v1";
10
+ export const SIGNER_ID = "learning-operator";
11
+ export const signingEnvironment = home => ({ PATH: "/usr/bin:/bin", HOME: home, LANG: "C", LC_ALL: "C", SSH_ASKPASS_REQUIRE: "never" });
12
+ export function pinnedExecutable(path, forbidden = []) {
13
+ safePath(path, { file: true });
14
+ const s = lstatSync(path);
15
+ const trustedOwner = stat => stat.uid === 0 || stat.uid === process.getuid?.();
16
+ const unsafe = () => learningError("SIGNER", "ssh-keygen and its ancestors must be root/operator-owned and protected from directory replacement outside worker writes");
17
+ if (!trustedOwner(s) || !(s.mode & 0o111) || (s.mode & 0o022) || forbidden.some(root => inside(path, root))) throw unsafe();
18
+ // File permissions do not prevent rename/unlink through a writable parent.
19
+ // Permit the fixed system temporary root (not environment-selected TMPDIR)
20
+ // as a root-owned sticky ancestor only above a private trusted
21
+ // directory: sticky semantics protect that directory's entry, and its own
22
+ // permissions protect everything below it. Never permit a writable parent of
23
+ // the executable itself. Same-UID workers still require outer isolation.
24
+ let child = s;
25
+ for (let current = dirname(path);; current = dirname(current)) {
26
+ const parent = lstatSync(current);
27
+ const protectedTemporaryAncestor = parent.uid === 0 && (parent.mode & 0o1000) && current === realpathSync("/tmp") && child.isDirectory() && trustedOwner(child) && !(child.mode & 0o077);
28
+ if (!parent.isDirectory() || !trustedOwner(parent) || ((parent.mode & 0o022) && !protectedTemporaryAncestor)) throw unsafe();
29
+ if (current === dirname(current)) break;
30
+ child = parent;
31
+ }
32
+ return { path, digest: hash(readFileSync(path)) };
33
+ }
34
+ export function encryptedKey(path) {
35
+ safePath(dirname(path), { privateMode: true }); safePath(path, { file: true, privateMode: true });
36
+ if (lstatSync(path).nlink !== 1 || lstatSync(path).size > 32 * 1024) throw learningError("KEY", "unsafe private key file");
37
+ const pem = readFileSync(path, "utf8");
38
+ if (!/^-----BEGIN OPENSSH PRIVATE KEY-----\n[A-Za-z0-9+/=\n]+-----END OPENSSH PRIVATE KEY-----\n?$/.test(pem)) throw learningError("KEY", "expected encrypted OpenSSH key");
39
+ const b = Buffer.from(pem.trim().split("\n").slice(1, -1).join(""), "base64");
40
+ let pos = 15;
41
+ const take = () => { if (pos + 4 > b.length) throw learningError("KEY", "truncated private key"); const n = b.readUInt32BE(pos); pos += 4; if (n > b.length - pos) throw learningError("KEY", "truncated key field"); const v = b.subarray(pos, pos + n); pos += n; return v; };
42
+ if (!b.subarray(0, 15).equals(Buffer.from("openssh-key-v1\0"))) throw learningError("KEY", "unknown private key format");
43
+ const cipher = take().toString(), kdf = take().toString(), options = take();
44
+ if (!["aes256-ctr", "aes256-cbc", "aes256-gcm@openssh.com", "chacha20-poly1305@openssh.com"].includes(cipher) || kdf !== "bcrypt" || options.length < 8) throw learningError("KEY", "private key must be passphrase encrypted with bcrypt");
45
+ const saltLength = options.readUInt32BE(0);
46
+ if (saltLength < 16 || saltLength + 8 !== options.length || options.readUInt32BE(4 + saltLength) < 100) throw learningError("KEY", "private key requires bcrypt rounds >= 100");
47
+ if (pos + 4 > b.length || b.readUInt32BE(pos) !== 1) throw learningError("KEY", "expected one dedicated key"); pos += 4;
48
+ const pub = take();
49
+ if (pub.length !== 51 || pub.readUInt32BE(0) !== 11 || pub.subarray(4, 15).toString() !== "ssh-ed25519" || pub.readUInt32BE(15) !== 32) throw learningError("KEY", "expected Ed25519 key");
50
+ const privatePayload = take();
51
+ if (!privatePayload.length || pos !== b.length) throw learningError("KEY", "invalid encrypted payload");
52
+ return { publicKey: `ssh-ed25519 ${pub.toString("base64")}`, fingerprint: "SHA256:" + Buffer.from(hash(pub), "hex").toString("base64").replace(/=+$/, "") };
53
+ }
54
+ export function validateAuthority(a) {
55
+ closed(a, ["version", "id", "namespace", "identity", "publicKey", "fingerprint", "signer", "keyPath", "enrollment", "signature"], "authority");
56
+ if (a.version !== 1 || a.namespace !== NAMESPACE || a.identity !== SIGNER_ID || !/^[a-f0-9-]{36}$/.test(a.id) || !/^ssh-ed25519 [A-Za-z0-9+/]{68}$/.test(a.publicKey)) throw learningError("TRUST", "invalid enrolled authority");
57
+ const blob = Buffer.from(a.publicKey.split(" ")[1], "base64");
58
+ if (a.fingerprint !== "SHA256:" + Buffer.from(hash(blob), "hex").toString("base64").replace(/=+$/, "")) throw learningError("TRUST", "enrolled key fingerprint differs");
59
+ closed(a.signer, ["path", "digest"], "pinned signer");
60
+ textField(a.fingerprint, "fingerprint", 80);
61
+ return a;
62
+ }
63
+ export function openSshSigner({ executable = "/usr/bin/ssh-keygen", forbidden = [] } = {}) {
64
+ const pinned = () => pinnedExecutable(executable, forbidden);
65
+ const unchanged = authority => {
66
+ validateAuthority(authority);
67
+ if (canonical(pinned()) !== canonical(authority.signer)) throw learningError("TRUST", "pinned signer changed; requalification required");
68
+ };
69
+ const temporary = fn => {
70
+ const dir = mkdtempSync(join(realpathSync(tmpdir()), "learning-sshsig-"));
71
+ try { return fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); }
72
+ };
73
+ const verify = (bytes, signature, authority) => {
74
+ unchanged(authority);
75
+ if (typeof signature !== "string" || signature.length > 8192 || !signature.startsWith("-----BEGIN SSH SIGNATURE-----\n")) throw learningError("SIGNATURE", "invalid SSHSIG encoding");
76
+ return temporary(dir => {
77
+ const allowed = join(dir, "allowed_signers"), sig = join(dir, "manifest.sig");
78
+ writeFileSync(allowed, `${SIGNER_ID} namespaces="${NAMESPACE}" ${authority.publicKey}\n`, { flag: "wx", mode: 0o600 });
79
+ writeFileSync(sig, signature, { flag: "wx", mode: 0o600 });
80
+ try { execFileSync(executable, ["-Y", "verify", "-f", allowed, "-I", SIGNER_ID, "-n", NAMESPACE, "-s", sig], { env: signingEnvironment(dir), input: bytes, stdio: ["pipe", "pipe", "pipe"], timeout: 10_000, maxBuffer: 8192 }); }
81
+ catch { throw learningError("SIGNATURE", "OpenSSH authorization verification failed"); }
82
+ return true;
83
+ });
84
+ };
85
+ const terminal = () => {
86
+ if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stderr.isTTY) throw learningError("TERMINAL", "setup/approval requires the operator's interactive terminal");
87
+ let fd; try { fd = openSync("/dev/tty", "r+"); } catch { throw learningError("TERMINAL", "no controlling operator terminal"); } finally { if (fd !== undefined) closeSync(fd); }
88
+ };
89
+ return {
90
+ pinned, verify, terminal,
91
+ probe() {
92
+ pinned();
93
+ return temporary(dir => {
94
+ const key = join(dir, "disposable"), data = join(dir, "probe");
95
+ const env = signingEnvironment(dir);
96
+ try {
97
+ // Only this capability probe uses a disposable unencrypted key; it is never enrolled.
98
+ execFileSync(executable, ["-q", "-t", "ed25519", "-N", "", "-f", key], { env, timeout: 10_000 });
99
+ writeFileSync(data, "capability-probe", { mode: 0o600 });
100
+ execFileSync(executable, ["-Y", "sign", "-f", key, "-n", NAMESPACE, data], { env, stdio: "pipe", timeout: 10_000 });
101
+ const pub = readFileSync(key + ".pub", "utf8").split(" ").slice(0, 2).join(" ");
102
+ const allowed = join(dir, "allowed"); writeFileSync(allowed, `${SIGNER_ID} ${pub}\n`, { mode: 0o600 });
103
+ execFileSync(executable, ["-Y", "verify", "-f", allowed, "-I", SIGNER_ID, "-n", NAMESPACE, "-s", data + ".sig"], { env, input: "capability-probe", stdio: "pipe", timeout: 10_000 });
104
+ return { sign: true, verify: true, signer: pinned(), platform: process.platform };
105
+ } catch { throw learningError("SIGNER", "OpenSSH -Y sign/verify capability probe failed; install portable OpenSSH with SSHSIG support"); }
106
+ });
107
+ },
108
+ createKey(keyPath) {
109
+ terminal(); pinned(); safePath(dirname(keyPath), { privateMode: true });
110
+ // Refuse every existing target, including dangling links.
111
+ for (const path of [keyPath, keyPath + ".pub"]) { let exists = false; try { lstatSync(path); exists = true; } catch (e) { if (e.code !== "ENOENT") throw e; } if (exists) throw learningError("KEY", "refusing to overwrite key files"); }
112
+ const r = spawnSync(executable, ["-t", "ed25519", "-a", "100", "-f", keyPath, "-C", "dedicated-learning-approval"], { env: signingEnvironment(dirname(keyPath)), stdio: "inherit", timeout: 180_000 });
113
+ if (r.status !== 0) throw learningError("CANCELLED", "key creation cancelled or failed; no enrollment was written");
114
+ return encryptedKey(keyPath);
115
+ },
116
+ sign(bytes, authority) {
117
+ terminal(); unchanged(authority);
118
+ const key = encryptedKey(authority.keyPath);
119
+ if (key.publicKey !== authority.publicKey || key.fingerprint !== authority.fingerprint) throw learningError("TRUST", "private key differs from enrolled signer");
120
+ return temporary(dir => {
121
+ const manifest = join(dir, "manifest"); writeFileSync(manifest, bytes, { flag: "wx", mode: 0o400 });
122
+ const r = spawnSync(executable, ["-Y", "sign", "-f", authority.keyPath, "-n", NAMESPACE, manifest], { env: signingEnvironment(dir), stdio: ["ignore", "inherit", "inherit"], timeout: 120_000 });
123
+ if (r.status !== 0) throw learningError("CANCELLED", "signing cancelled, timed out or failed; active state unchanged");
124
+ if (readFileSync(manifest, "utf8") !== bytes) throw learningError("SIGNATURE", "frozen manifest changed during signing");
125
+ const signature = readFileSync(manifest + ".sig", "utf8"); verify(bytes, signature, authority); return signature;
126
+ });
127
+ },
128
+ };
129
+ }
130
+
131
+
132
+ // Resolve only when cryptographic operations are needed: local collection and
133
+ // emergency disable must not depend on a working signer installation.
134
+ export function storedOpenSshSigner(store, { executable } = {}) {
135
+ let selected;
136
+ const resolveSigner = () => {
137
+ const authority = store.readAuthority();
138
+ if (authority) {
139
+ validateAuthority(authority);
140
+ if (canonical(authority.enrollment?.signer) !== canonical(authority.signer)) throw learningError("TRUST", "enrollment signer differs from trust root");
141
+ }
142
+ const recorded = authority?.signer ?? store.readEnrollment()?.signer;
143
+ if (recorded) closed(recorded, ["path", "digest"], "pinned signer");
144
+ selected ??= openSshSigner({ executable: executable ?? recorded?.path ?? "/usr/bin/ssh-keygen" });
145
+ // Includes doctor/probe and terminal checks, before any selected executable
146
+ // can run. Never silently adopt changed bytes or fall back to another binary.
147
+ if (recorded && canonical(selected.pinned()) !== canonical(recorded)) throw learningError("TRUST", "pinned signer changed; requalification required");
148
+ return selected;
149
+ };
150
+ return Object.fromEntries(["pinned", "verify", "terminal", "probe", "createKey", "sign"].map(name => [name, (...args) => resolveSigner()[name](...args)]));
151
+ }
@@ -0,0 +1,88 @@
1
+ import { join, dirname, resolve, isAbsolute } from "node:path";
2
+ import { existsSync, lstatSync, realpathSync, mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, fsyncSync, rmSync } from "node:fs";
3
+ import { randomUUID } from "node:crypto";
4
+ import { acquireProcessLock } from "./daemon.mjs";
5
+ import { canonical, hash, parseJson, learningError, repository, closed } from "./learning_data.mjs";
6
+
7
+ export const inside = (path, root) => path === root || path.startsWith(root + "/");
8
+ export function safePath(path, { file = false, privateMode = false } = {}) {
9
+ if (typeof path !== "string" || Buffer.byteLength(path) > 4096 || /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u.test(path)) throw learningError("PATH", "learning path contains terminal controls or exceeds its bound");
10
+ if (!isAbsolute(path) || resolve(path) !== path) throw learningError("PATH", "path must be explicit, absolute and normalized");
11
+ // Resolve system /tmp aliases at the caller, never silently follow a user-controlled link.
12
+ let current = path;
13
+ while (current !== dirname(current)) {
14
+ if (existsSync(current) || (() => { try { lstatSync(current); return true; } catch { return false; } })()) {
15
+ const s = lstatSync(current);
16
+ if (s.isSymbolicLink()) throw learningError("PATH", "symlink in learning path");
17
+ if (current === path && (file ? !s.isFile() : !s.isDirectory())) throw learningError("PATH", "wrong learning path type");
18
+ if (current === path && privateMode && ((s.mode & 0o077) || s.uid !== process.getuid?.())) throw learningError("PATH", "learning path must be operator-owned and private");
19
+ }
20
+ current = dirname(current);
21
+ }
22
+ return path;
23
+ }
24
+ export function durableWrite(file, value) {
25
+ safePath(dirname(file)); safePath(file, { file: true });
26
+ const bytes = canonical(value) + "\n";
27
+ if (Buffer.byteLength(bytes) > 32 * 1024 * 1024) throw learningError("STORAGE", "learning state exceeds its 32 MiB cap; preserve current state and review retention before retrying");
28
+ const tmp = `${file}.${randomUUID()}.tmp`;
29
+ let fd;
30
+ try {
31
+ fd = openSync(tmp, "wx", 0o600); writeFileSync(fd, bytes); fsyncSync(fd); closeSync(fd); fd = undefined;
32
+ renameSync(tmp, file); const dir = openSync(dirname(file), "r"); try { fsyncSync(dir); } finally { closeSync(dir); }
33
+ }
34
+ catch (e) { if (fd !== undefined) closeSync(fd); throw e; }
35
+ finally { rmSync(tmp, { force: true }); }
36
+ }
37
+ export function learningStore(root) {
38
+ safePath(root);
39
+ const loadFile = file => { safePath(file, { file: true, privateMode: true }); return parseJson(readFileSync(file), 32 * 1024 * 1024); };
40
+ const init = () => { safePath(root); mkdirSync(root, { recursive: true, mode: 0o700 }); safePath(root, { privateMode: true }); };
41
+ const repoPath = repo => { repository(repo); return join(root, `repo-${hash(repo)}.json`); };
42
+ const empty = repo => ({ version: 1, repo, mode: "off", modeGeneration: null, observations: {}, candidates: {}, scans: [], backoffUntil: null, extraction: [], deliveries: {}, sequence: 0, parent: null, active: [], transitions: {}, prepared: {}, preparationHistory: {}, checkpoint: null });
43
+ const load = repo => {
44
+ const path = repoPath(repo);
45
+ if (!existsSync(path)) return empty(repo);
46
+ const state = loadFile(path);
47
+ // Legacy stores have no obsolete-preparation archive; reading remains free
48
+ // of writes, including report and dry-run collection.
49
+ if (!Object.hasOwn(state, "preparationHistory")) state.preparationHistory = {};
50
+ if (!Object.hasOwn(state, "modeGeneration")) state.modeGeneration = null;
51
+ closed(state, Object.keys(empty(repo)), "repository state");
52
+ if (state.modeGeneration !== null && !/^[a-f0-9-]{36}$/.test(state.modeGeneration)) throw learningError("STATE", "invalid learning mode generation");
53
+ if (state.version !== 1 || canonical(state.repo) !== canonical(repo)) throw learningError("STATE", "repository state identity/schema mismatch");
54
+ if (!["off", "collect", "apply"].includes(state.mode)) throw learningError("STATE", "invalid protected learning mode");
55
+ return state;
56
+ };
57
+ const lock = () => {
58
+ init();
59
+ const path = join(root, "operation.lock");
60
+ safePath(path, { file: true, privateMode: true });
61
+ return acquireProcessLock(path, "learning operation").release;
62
+ };
63
+ return {
64
+ root, load, init, lock,
65
+ identities() { const p = join(root, "repositories.json"); return existsSync(p) ? loadFile(p) : {}; },
66
+ remember(name, repo, visibility) {
67
+ repository(repo);
68
+ if (visibility !== undefined && !["public", "private", "internal", "unknown"].includes(visibility)) throw learningError("INPUT", "invalid repository visibility");
69
+ const release = lock();
70
+ try {
71
+ const known = this.identities(), previous = known[name.toLowerCase()];
72
+ if (previous && canonical(repository(previous.repo ?? previous)) !== canonical(repo)) throw learningError("REPOSITORY", "repository identity changed; refusing to replace remembered identity");
73
+ known[name.toLowerCase()] = visibility === undefined ? repo : { repo, visibility }; durableWrite(join(root, "repositories.json"), known);
74
+ }
75
+ finally { release(); }
76
+ },
77
+ readEnrollment() { const p = join(root, "enrollment-intent.json"); return existsSync(p) ? loadFile(p) : null; },
78
+ writeEnrollment(value) { init(); durableWrite(join(root, "enrollment-intent.json"), value); },
79
+ readAuthority() { const p = join(root, "authority.json"); return existsSync(p) ? loadFile(p) : null; },
80
+ writeAuthority(value) { init(); durableWrite(join(root, "authority.json"), value); },
81
+ async transaction(repo, fn) {
82
+ const release = lock();
83
+ try { const state = load(repo), result = await fn(state); durableWrite(repoPath(repo), state); return result; } finally { release(); }
84
+ },
85
+ saveLocked(repo, state) { durableWrite(repoPath(repo), state); },
86
+ canonicalRoot() { return existsSync(root) ? realpathSync(root) : root; },
87
+ };
88
+ }
package/lib/log.mjs ADDED
@@ -0,0 +1,53 @@
1
+ // Logging: stderr for the terminal, plus errors.log on disk so `status` can show the newest problem when nobody
2
+ // was watching the terminal. The file is rotated at 1 MB and its last line is read from the tail, not the whole file.
3
+ import { join } from "node:path";
4
+ import { openSync, readSync, fstatSync, closeSync, renameSync } from "node:fs";
5
+ import { appendLine, fileMode } from "./fs.mjs";
6
+ import { nowIso } from "./time.mjs";
7
+
8
+ const MAX_BYTES = 1_000_000;
9
+
10
+ export function makeLog(home, { stream = process.stderr, clock = Date } = {}) {
11
+ const file = join(home, "errors.log");
12
+ const line = (level, msg) => `${nowIso(clock)} ${level} ${msg}`;
13
+ // Recording is best effort on purpose. The log is a convenience for `status` to show the newest problem when
14
+ // nobody was watching the terminal; the terminal write above is the report that matters and has already happened.
15
+ // A sandboxed harness, a read-only home or a full disk must not turn a warning into a failed command: `ready` had
16
+ // already printed its verdict and done its work when this threw, and the operator saw a crash instead of a result.
17
+ // Once per process is enough of a notice, since a home that cannot be written stays that way for the whole run.
18
+ let warned = false;
19
+ const record = l => {
20
+ try {
21
+ if (fileSize(file) > MAX_BYTES) renameSync(file, file + ".1");
22
+ } catch { /* no file yet, or it cannot be rotated; the append below decides whether that matters */ }
23
+ try {
24
+ appendLine(file, l);
25
+ } catch (e) {
26
+ if (!warned) { warned = true; stream.write(`${line("warn", `cannot write ${file} (${e.code || e.message}); logging to this terminal only`)}\n`); }
27
+ }
28
+ };
29
+ return {
30
+ info: msg => stream.write(line("info", msg) + "\n"),
31
+ warn: msg => { stream.write(line("warn", msg) + "\n"); record(line("warn", msg)); },
32
+ error: msg => { stream.write(line("error", msg) + "\n"); record(line("error", msg)); },
33
+ /** Newest recorded problem, for the status header. Reads only the tail of the file, and never throws. */
34
+ latest: () => { try { return lastLine(file); } catch { return null; } },
35
+ };
36
+ }
37
+
38
+ const fileSize = p => { const fd = openSync(p, "r"); try { return fstatSync(fd).size; } finally { closeSync(fd); } };
39
+
40
+ function lastLine(p) {
41
+ if (fileMode(p) === null) return null;
42
+ const fd = openSync(p, "r");
43
+ try {
44
+ const size = fstatSync(fd).size;
45
+ const len = Math.min(size, 4096);
46
+ const buf = Buffer.alloc(len);
47
+ readSync(fd, buf, 0, len, size - len);
48
+ const lines = buf.toString("utf8").trim().split("\n").filter(Boolean);
49
+ return lines.at(-1) || null;
50
+ } finally { closeSync(fd); }
51
+ }
52
+
53
+ export const silentLog = { info() {}, warn() {}, error() {}, latest: () => null };