@sigrun/sigrun 0.0.0-stage → 0.1.1

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 (130) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +89 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * W2 Task 8 (spec §6.9): `GET /metrics` — the Prometheus text
3
+ * exposition of in-process state. The families:
4
+ *
5
+ * - `sigrun_up` — 1, or 0 when `harness.inspect()` throws (Review Focus
6
+ * 5: a scrape racing shutdown must degrade, never 500 — a closed
7
+ * harness fails the inspect, the route still answers 200 with the
8
+ * inspect-derived families omitted);
9
+ * - `sigrun_ready` — core-hardening Task 5: `checkStorage` (the SAME
10
+ * root-conversation read `/readyz` gates on) as a gauge — 1 when
11
+ * `"ok"`, 0 when `"failed"`. Always emitted: the finding was a
12
+ * storage read failing while inspection stayed healthy (`sigrun_up
13
+ * 1`), so `/readyz` 503'd but the scrape stayed green and nothing
14
+ * paged; sharing one derivation means the gauge and the probe
15
+ * cannot disagree.
16
+ * - `sigrun_live_runs` — conversations with a placed-but-unsettled input
17
+ * submission (one run per conversation at a time), durable-derived
18
+ * from the same inspection;
19
+ * - `sigrun_inflight_submission_oldest_seconds` — the event bus's
20
+ * admission clock (`oldestLiveAdmissionMs`; 0 when no run is live).
21
+ * In-process: a run admitted before a restart is invisible until it
22
+ * settles (spec §6.9 accepts this for a wedge-while-up alarm);
23
+ * - `sigrun_tasks_live{status}` — live tasks grouped by scheduler state
24
+ * kind, the full five-kind vocabulary emitted (0s included) so the
25
+ * family's label set is scrape-stable. `inspect()` is a Session-line
26
+ * read (scrape-rate only, never a hot path);
27
+ * - `sigrun_provider_ready{provider,status}` — the per-provider auth
28
+ * readiness derivation (`providerAuthStatus`, the exact chain
29
+ * `GET /v1/auth/check` serves), value 1 at the current status;
30
+ * - `sigrun_extensions{status}` — load outcomes (`ok` | `quarantined`),
31
+ * both emitted even at 0 for the same label-stability reason;
32
+ * - `sigrun_backup_last_success_timestamp_seconds` — Task 7's marker,
33
+ * read per request like `meta.ts` does (the CronJob is a separate
34
+ * process; the filesystem is the only fresh-enough view). OMITTED when
35
+ * backup is unconfigured or no marker exists — absence is what the
36
+ * `SigrunBackupStale` alert's chart gate keys on.
37
+ * - W4 Task 4's job families: `sigrun_job_last_run_timestamp_seconds
38
+ * {job,outcome}` and `sigrun_job_next_run_timestamp_seconds{job}` —
39
+ * per-record gauges re-read per scrape (the same scrape-rate posture
40
+ * as the inspect families); `sigrun_job_runs_total{outcome}` — a
41
+ * COUNTER over the boot's in-process run-settle observation (reset on
42
+ * restart by design; the durable history lives in the run records).
43
+ * Families with no samples are omitted (the backup-marker
44
+ * convention).
45
+ *
46
+ * ACCESS: `/metrics` is public iff `config.metricsPublic` (strict
47
+ * `SIGRUN_METRICS_PUBLIC`, W1 Task 1) — the decision is made by the
48
+ * matched-route auth pipeline (`src/server/auth/pipeline.ts` rule 2:
49
+ * pattern `/metrics` and `metricsPublic` → public, else authenticated).
50
+ * The chart pins the env `true` so the in-cluster scrape survives.
51
+ *
52
+ * FORMAT: every family carries `# HELP` + `# TYPE` lines (gauge, or
53
+ * counter for the one cumulative family); label values escape
54
+ * backslash, quote, and newline per the exposition format; the content
55
+ * type is `text/plain; version=0.0.4; charset=utf-8`.
56
+ */
57
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
58
+ import { readBackupMarker } from "../../backup/marker.js";
59
+ import { scanJobs } from "../../jobs/docs.js";
60
+ import { providerAuthStatus } from "../helpers.js";
61
+ import { checkStorage } from "../readiness.js";
62
+ /** The exposition content type (spec §6.9's format rule). */
63
+ const EXPOSITION_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
64
+ /** Escape one label value per the exposition format: `\` → `\\`,
65
+ * `"` → `\"`, newline → `\n`. */
66
+ function escapeLabelValue(value) {
67
+ return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
68
+ }
69
+ /** Render one label set (`{a="1",b="2"}`, or `""` when label-less). */
70
+ function renderLabels(labels) {
71
+ const entries = Object.entries(labels);
72
+ if (entries.length === 0)
73
+ return "";
74
+ return `{${entries.map(([name, value]) => `${name}="${escapeLabelValue(value)}"`).join(",")}}`;
75
+ }
76
+ /**
77
+ * Register `GET /metrics`.
78
+ *
79
+ * One family per call: the HELP/TYPE lines plus one sample per
80
+ * `{labels, value}` pair, appended to the shared line buffer. A family
81
+ * with NO samples is omitted entirely (the backup-marker case) —
82
+ * callers that must always exist pass their 0-valued sample themselves.
83
+ */
84
+ export function registerMetricsRoutes(app, ctx) {
85
+ const { opened, config } = ctx;
86
+ app.get("/metrics", async (_request, reply) => {
87
+ const lines = [];
88
+ const family = (name, help, samples, type = "gauge") => {
89
+ if (samples.length === 0)
90
+ return;
91
+ lines.push(`# HELP ${name} ${help}`);
92
+ lines.push(`# TYPE ${name} ${type}`);
93
+ for (const sample of samples) {
94
+ lines.push(`${name}${renderLabels(sample.labels)} ${sample.value}`);
95
+ }
96
+ };
97
+ // Review Focus 5: the harness liveness probe. `inspect()` throws on
98
+ // a closed harness (the shutdown race) and on storage failure — the
99
+ // scrape degrades to `sigrun_up 0` with the inspect-derived
100
+ // families (live runs, wedge age, tasks) omitted rather than 500ing.
101
+ let up = 1;
102
+ try {
103
+ const inspection = await opened.harness.inspect(BACKGROUND_CONTEXT);
104
+ // Live runs: conversations holding a placed-but-unsettled INPUT
105
+ // submission (queued inputs run nothing yet; write-type
106
+ // submissions never run at all). Durable-derived, so a
107
+ // pre-restart run still counts — only the wedge AGE above is
108
+ // restart-invisible.
109
+ const liveConversations = new Set();
110
+ for (const submission of inspection.submissions) {
111
+ if (submission.type !== "input" || submission.status !== "placed") {
112
+ continue;
113
+ }
114
+ liveConversations.add(submission.conversationId);
115
+ }
116
+ family("sigrun_live_runs", "Conversations with a live input run", [
117
+ { labels: {}, value: liveConversations.size },
118
+ ]);
119
+ // The wedge gauge: the admission clock's age over the oldest
120
+ // live run. Always exists while the harness answers (0 when no
121
+ // run is live — the alert's `> 1800` must not key on absence).
122
+ const oldestMs = opened.eventBus.oldestLiveAdmissionMs();
123
+ family("sigrun_inflight_submission_oldest_seconds", "Age of the oldest in-flight submission's admission (0 when none; in-process clock — a pre-restart run is invisible until it settles)", [{ labels: {}, value: Math.round((oldestMs ?? 0) / 1000) }]);
124
+ // Live tasks by scheduler state kind. The full five-kind
125
+ // vocabulary is emitted (0s included) for label stability.
126
+ const taskStatuses = {
127
+ running: 0,
128
+ ready: 0,
129
+ waiting: 0,
130
+ completing: 0,
131
+ blocked: 0,
132
+ };
133
+ for (const task of inspection.tasks) {
134
+ taskStatuses[task.state.kind] = (taskStatuses[task.state.kind] ?? 0) + 1;
135
+ }
136
+ family("sigrun_tasks_live", "Live tasks by scheduler state", Object.entries(taskStatuses).map(([status, count]) => ({
137
+ labels: { status },
138
+ value: count,
139
+ })));
140
+ // W4 Task 4's job families — record-derived gauges, scrape-rate
141
+ // reads over the same records-only scan the jobs service lists
142
+ // through (inside the inspect guard: a closed harness degrades to
143
+ // omission, never a 500).
144
+ const jobRecords = await scanJobs(opened.storage, BACKGROUND_CONTEXT);
145
+ const epochSeconds = (iso) => Math.floor(Date.parse(iso) / 1000);
146
+ family("sigrun_job_last_run_timestamp_seconds", "Unix time of each job's last run (by outcome)", jobRecords.flatMap(({ id, record }) => record.lastRun === undefined
147
+ ? []
148
+ : [
149
+ {
150
+ labels: { job: id, outcome: record.lastRun.outcome },
151
+ value: epochSeconds(record.lastRun.settledAt ?? record.lastRun.startedAt),
152
+ },
153
+ ]));
154
+ family("sigrun_job_next_run_timestamp_seconds", "Unix time of each job's next scheduled fire", jobRecords.flatMap(({ id, record }) => record.nextRun === undefined
155
+ ? []
156
+ : [
157
+ {
158
+ labels: { job: id },
159
+ value: epochSeconds(record.nextRun),
160
+ },
161
+ ]));
162
+ }
163
+ catch (error) {
164
+ up = 0;
165
+ console.error("metrics: harness inspection failed:", error);
166
+ }
167
+ family("sigrun_up", "1 when the harness answers inspection", [
168
+ { labels: {}, value: up },
169
+ ]);
170
+ // Core-hardening Task 5: the readiness storage check as a gauge —
171
+ // the exact call /readyz gates on (never throws, never omitted), so
172
+ // SigrunNotReady's `or sigrun_ready == 0` arm fires on a storage
173
+ // failure even while inspection (and the whole scrape) stays green.
174
+ const storage = await checkStorage(ctx);
175
+ family("sigrun_ready", "1 when the readiness storage check (the /readyz root-conversation read) succeeds", [{ labels: {}, value: storage === "ok" ? 1 : 0 }]);
176
+ // Provider readiness: the shared derivation (`GET /v1/auth/check`'s
177
+ // exact chain), one 1-valued sample per (provider, current status).
178
+ const producersByName = new Map(opened.providerHealth.map((producer) => [producer.name, producer]));
179
+ family("sigrun_provider_ready", "1 per provider at its current auth-readiness status", opened.models.getProviders().map(({ id }) => ({
180
+ labels: {
181
+ provider: id,
182
+ status: providerAuthStatus(opened, id, producersByName).status,
183
+ },
184
+ value: 1,
185
+ })));
186
+ // Extension load outcomes: both statuses emitted even at 0 so the
187
+ // family exists on a bare-core deploy (label stability).
188
+ const extensionCounts = { ok: 0, quarantined: 0 };
189
+ for (const outcome of opened.extensionOutcomes) {
190
+ extensionCounts[outcome.status] =
191
+ (extensionCounts[outcome.status] ?? 0) + 1;
192
+ }
193
+ family("sigrun_extensions", "Configured extensions by load outcome", Object.entries(extensionCounts).map(([status, count]) => ({
194
+ labels: { status },
195
+ value: count,
196
+ })));
197
+ // The in-process run counter (§6.9): boot counts run SETTLES from
198
+ // `sigrun.job.run` commits in this process — reset on restart by
199
+ // design; the alert's `increase(…[1h])` reads the deltas.
200
+ family("sigrun_job_runs_total", "Job runs settled by outcome since process start", [...opened.jobRunTotals()].map(([outcome, count]) => ({
201
+ labels: { outcome },
202
+ value: count,
203
+ })), "counter");
204
+ // Task 7's marker, read per request like meta.ts (the CronJob is a
205
+ // separate process). Omitted when backup is unconfigured or the
206
+ // marker is absent/corrupt — the SigrunBackupStale alert's chart
207
+ // gate treats an unbacked deploy as "no series, no page".
208
+ if (config.backup !== undefined) {
209
+ const marker = await readBackupMarker(config.stateDir);
210
+ if (marker !== undefined) {
211
+ const seconds = Math.floor(Date.parse(marker.at) / 1000);
212
+ if (Number.isFinite(seconds)) {
213
+ family("sigrun_backup_last_success_timestamp_seconds", "Unix time of the last successful backup run", [{ labels: {}, value: seconds }]);
214
+ }
215
+ }
216
+ }
217
+ return await reply.code(200).type(EXPOSITION_CONTENT_TYPE).send(`${lines.join("\n")}\n`);
218
+ });
219
+ }
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The onboarding routes (core hardening W1 Task 4, spec §5.8): the
3
+ * unclaimed state's whole surface. The pipeline public-paths
4
+ * `/v1/onboarding*` (stage 2), so these routes resolve trust THEMSELVES:
5
+ * a trusted proxy identity (headers from a trusted peer) or a loopback
6
+ * bind is trust enough to claim with no code; everything else must
7
+ * present the setup code (the unclaimed boot's one-shot credential) or,
8
+ * once claimed, a valid invite code.
9
+ *
10
+ * Claims and invite redemptions serialize on the ONE in-process claim
11
+ * mutex (`claim-lock.ts`) with the directory write atomic — concurrent
12
+ * correct claims yield exactly one 200; the rest get 409
13
+ * `already_claimed`.
14
+ */
15
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
16
+ import { timingSafeEqual } from "node:crypto";
17
+ import { withClaimLock } from "../auth/claim-lock.js";
18
+ import { ipAllowed } from "../auth/ip.js";
19
+ import { isLoopbackHost } from "../auth/loopback.js";
20
+ import { readSetupCode, clearSetupCode } from "../../harness/identity/index.js";
21
+ import { stampSessionOwner } from "../helpers.js";
22
+ import { buildSessionCookie } from "./auth.js";
23
+ /** One header read (Fastify headers are string | string[]; first wins). */
24
+ function headerOf(request, name) {
25
+ const value = request.headers[name.toLowerCase()];
26
+ const single = Array.isArray(value) ? value[0] : value;
27
+ return single?.trim() || undefined;
28
+ }
29
+ /** The username rule (spec §5.8, shared with the directory's own check). */
30
+ const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
31
+ /** Constant-time string equality (the pipeline's discipline). */
32
+ function timingSafeStringEqual(a, b) {
33
+ const left = Buffer.from(a, "utf8");
34
+ const right = Buffer.from(b, "utf8");
35
+ if (left.length !== right.length)
36
+ return false;
37
+ return timingSafeEqual(left, right);
38
+ }
39
+ /**
40
+ * Resolve the request's trusted proxy identity WITHOUT provisioning: the
41
+ * proxy method must be configured, the peer trusted, and the user header
42
+ * present (and a shape the directory could hold — the pipeline's
43
+ * invalid-username posture). When a shared secret is configured, it is
44
+ * required here TOO — the pipeline never runs on public paths (stage 2
45
+ * returns before the proxy method), so these routes own the same secret
46
+ * discipline, and the header is deleted from the request after the check
47
+ * exactly as the pipeline would have done. Name/email ride their headers
48
+ * when present.
49
+ */
50
+ function resolveProxyIdentity(request, ctx) {
51
+ if (!ctx.methods.includes("proxy"))
52
+ return undefined;
53
+ const { proxy } = ctx.config.auth;
54
+ if (!ipAllowed(request.socket.remoteAddress, proxy.trustedProxies)) {
55
+ return undefined;
56
+ }
57
+ if (proxy.secret !== undefined && proxy.secretHeader !== undefined) {
58
+ const name = proxy.secretHeader.toLowerCase();
59
+ const presented = headerOf(request, name);
60
+ delete request.headers[name];
61
+ if (presented === undefined ||
62
+ !timingSafeStringEqual(presented, proxy.secret)) {
63
+ return undefined;
64
+ }
65
+ }
66
+ const user = headerOf(request, proxy.userHeader);
67
+ if (user === undefined || !USERNAME_PATTERN.test(user))
68
+ return undefined;
69
+ const name = headerOf(request, proxy.nameHeader);
70
+ const email = headerOf(request, proxy.emailHeader);
71
+ return {
72
+ user,
73
+ ...(name === undefined ? {} : { name }),
74
+ ...(email === undefined ? {} : { email }),
75
+ };
76
+ }
77
+ /**
78
+ * Validate the claim body strictly: unknown keys and wrong types are
79
+ * client errors (the repo's fail-loud validator posture).
80
+ */
81
+ function validateClaimBody(body) {
82
+ if (body === undefined)
83
+ return {};
84
+ if (typeof body !== "object" || Array.isArray(body)) {
85
+ return { error: "invalid_body" };
86
+ }
87
+ const record = body;
88
+ const result = {};
89
+ for (const key of Object.keys(record)) {
90
+ const value = record[key];
91
+ switch (key) {
92
+ case "code":
93
+ case "user":
94
+ case "name":
95
+ case "email":
96
+ case "tokenName":
97
+ if (typeof value !== "string")
98
+ return { error: "invalid_body" };
99
+ result[key] = value;
100
+ break;
101
+ case "issueToken":
102
+ if (typeof value !== "boolean")
103
+ return { error: "invalid_body" };
104
+ result.issueToken = value;
105
+ break;
106
+ default:
107
+ return { error: "invalid_field", field: key };
108
+ }
109
+ }
110
+ return result;
111
+ }
112
+ /** Register the onboarding state + claim routes. */
113
+ export function registerOnboardingRoutes(app, ctx) {
114
+ const { identity } = ctx.opened;
115
+ app.get("/v1/onboarding", async (request) => {
116
+ const proxyIdentity = resolveProxyIdentity(request, ctx);
117
+ // Loopback is the trust itself; a trusted proxy identity likewise.
118
+ const codeRequired = !isLoopbackHost(ctx.listenHost) && proxyIdentity === undefined;
119
+ return {
120
+ state: identity.users.isClaimed() ? "claimed" : "unclaimed",
121
+ methods: ctx.methods,
122
+ codeRequired,
123
+ ...(proxyIdentity === undefined ? {} : { proxyIdentity }),
124
+ };
125
+ });
126
+ app.post("/v1/onboarding/claim", async (request, reply) => {
127
+ const validated = validateClaimBody(request.body);
128
+ if ("error" in validated) {
129
+ return await reply.code(400).send(validated);
130
+ }
131
+ const proxyIdentity = resolveProxyIdentity(request, ctx);
132
+ const loopback = isLoopbackHost(ctx.listenHost);
133
+ // The code's validity is evaluated ONCE, BEFORE the mutex: the winner
134
+ // clears the setup-code file inside its critical section, so a claim
135
+ // that loses the race must still be recognizable as having held a
136
+ // valid code — it gets 409 `already_claimed` (the brief's "one
137
+ // claimant wins and the rest get 409"), never a misleading 403.
138
+ const codeWasSetup = validated.code !== undefined &&
139
+ validated.code === readSetupCode(identity.cacheDir);
140
+ // The claim critical section: read state, decide, add — all inside
141
+ // the one mutex, so concurrent claims cannot both pass the gate.
142
+ const outcome = await withClaimLock(async () => {
143
+ const claimed = identity.users.isClaimed();
144
+ if (!claimed) {
145
+ // The first admin: proxy headers, loopback bind, or the setup
146
+ // code ("the first invite" — same shape, admin grant).
147
+ let grant;
148
+ if (proxyIdentity !== undefined) {
149
+ // Proxy claims take identity from the headers, verbatim
150
+ // (body identity fields are ignored — the proxy vouched).
151
+ grant = { ...proxyIdentity, source: "proxy" };
152
+ }
153
+ else if (loopback) {
154
+ if (validated.user === undefined)
155
+ return { status: 400, error: "invalid_body" };
156
+ grant = {
157
+ user: validated.user,
158
+ ...(validated.name === undefined ? {} : { name: validated.name }),
159
+ ...(validated.email === undefined ? {} : { email: validated.email }),
160
+ source: "cli",
161
+ };
162
+ }
163
+ else if (codeWasSetup) {
164
+ if (validated.user === undefined)
165
+ return { status: 400, error: "invalid_body" };
166
+ grant = {
167
+ user: validated.user,
168
+ ...(validated.name === undefined ? {} : { name: validated.name }),
169
+ ...(validated.email === undefined ? {} : { email: validated.email }),
170
+ source: "cli",
171
+ };
172
+ }
173
+ else {
174
+ // Wrong or absent code where one is required.
175
+ return { status: 403, error: "invalid_code" };
176
+ }
177
+ if (!USERNAME_PATTERN.test(grant.user)) {
178
+ return { status: 400, error: "invalid_username" };
179
+ }
180
+ const record = await identity.users.add({
181
+ user: grant.user,
182
+ ...(grant.name === undefined ? {} : { name: grant.name }),
183
+ ...(grant.email === undefined ? {} : { email: grant.email }),
184
+ admin: true,
185
+ source: grant.source,
186
+ });
187
+ // The setup code dies with the claim (single-shot); the
188
+ // pinned env form re-materializes at the next unclaimed
189
+ // boot, which no longer exists once claimed.
190
+ await clearSetupCode(identity.cacheDir);
191
+ // The reserved root conversation belongs to the claimant —
192
+ // the server's first configured resource.
193
+ const root = await ctx.opened.harness.conversation(ctx.rootId, BACKGROUND_CONTEXT);
194
+ if (root !== undefined) {
195
+ await stampSessionOwner(root, record.user);
196
+ }
197
+ return { status: 200, record };
198
+ }
199
+ // Claimed: an invite code admits one more user; a codeless
200
+ // proxy/loopback caller auto-provisions (or re-surfaces); a code
201
+ // that WAS the setup code lost the race — 409, per the mutex
202
+ // contract (one winner, the rest already_claimed).
203
+ if (codeWasSetup) {
204
+ return { status: 409, error: "already_claimed" };
205
+ }
206
+ if (validated.code !== undefined) {
207
+ const invite = validated.user === undefined
208
+ ? undefined
209
+ : await identity.invites.redeem(validated.code, validated.user);
210
+ if (invite === undefined) {
211
+ return { status: 403, error: "invalid_code" };
212
+ }
213
+ const record = await identity.users.add({
214
+ user: validated.user,
215
+ ...(validated.name === undefined ? {} : { name: validated.name }),
216
+ ...(validated.email === undefined ? {} : { email: validated.email }),
217
+ admin: invite.admin,
218
+ source: "invite",
219
+ });
220
+ return { status: 200, record };
221
+ }
222
+ // Claimed + no code: only a trusted proxy identity still
223
+ // admits (auto-provision, or the existing record back) — a
224
+ // loopback/token caller has “nothing valid” and gets 409.
225
+ if (proxyIdentity === undefined) {
226
+ return { status: 409, error: "already_claimed" };
227
+ }
228
+ const existing = identity.users.get(proxyIdentity.user);
229
+ if (existing !== undefined)
230
+ return { status: 200, record: existing };
231
+ const record = await identity.users.add({
232
+ user: proxyIdentity.user,
233
+ ...(proxyIdentity.name === undefined ? {} : { name: proxyIdentity.name }),
234
+ ...(proxyIdentity.email === undefined ? {} : { email: proxyIdentity.email }),
235
+ admin: false,
236
+ source: "proxy",
237
+ });
238
+ return { status: 200, record };
239
+ });
240
+ if (outcome.status !== 200) {
241
+ return await reply.code(outcome.status).send({ error: outcome.error });
242
+ }
243
+ const response = {
244
+ user: {
245
+ user: outcome.record.user,
246
+ ...(outcome.record.name === undefined ? {} : { name: outcome.record.name }),
247
+ ...(outcome.record.email === undefined ? {} : { email: outcome.record.email }),
248
+ admin: outcome.record.admin,
249
+ createdAt: outcome.record.createdAt,
250
+ source: outcome.record.source,
251
+ },
252
+ ...(validated.issueToken
253
+ ? {
254
+ token: (await identity.tokens.create(validated.tokenName ?? "onboarding", outcome.record.user)).token,
255
+ }
256
+ : {}),
257
+ };
258
+ // issueToken also logs the browser in: the session cookie is the
259
+ // webui's (and the TUI attach flow's) carry-home credential.
260
+ if (validated.issueToken && response.token !== undefined) {
261
+ return await reply
262
+ .code(200)
263
+ .header("set-cookie", buildSessionCookie(response.token, request, ctx))
264
+ .send(response);
265
+ }
266
+ return await reply.code(200).send(response);
267
+ });
268
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The deploy probes (spec §4.4 "distinguishes process-up from
3
+ * harness-ready"): liveness and readiness, both scrape-compatible plain
4
+ * text. Registered before the API routes, matching the unsplit server's
5
+ * registration order.
6
+ */
7
+ import { checkStorage } from "../readiness.js";
8
+ /**
9
+ * Register `GET /healthz` + `GET /readyz`.
10
+ *
11
+ * Liveness: 200 ALWAYS once listening — no aggregation, no storage; the
12
+ * dead-simple "the event loop answers" signal. The body is a single
13
+ * Prometheus-exposition gauge line so the endpoint is scrape-compatible
14
+ * (`/v1/meta` is JSON and cannot be scraped). A degraded-but-alive process
15
+ * must NOT fail liveness — the kubelet would restart a process that is
16
+ * merely degraded.
17
+ *
18
+ * Readiness: 200 only when the harness is GENUINELY ready — and in W2
19
+ * (§6.1) that is STORAGE-ONLY: boot complete + a real storage read.
20
+ * Provider and memory health do NOT gate readiness — a degraded upstream
21
+ * is never a Service outage (no endpoint gap, no pod eviction); they stay
22
+ * visible in `/v1/meta`, `/v1/providers` (W1), and `/metrics` (W2 Task 8).
23
+ * The derivation, evaluated per request:
24
+ * 1. booted — trivially true while this route answers (the server only
25
+ * exists after `openHarness` completed); documented, not probed.
26
+ * 2. `checkStorage` (core-hardening Task 5) — a cheap REAL storage read
27
+ * resolving the reserved root conversation through the session line.
28
+ * The SAME call feeds `/metrics`'s `sigrun_ready` gauge, so the k8s
29
+ * probe and the alert signal cannot disagree.
30
+ * Not ready ⇒ 503 + `{ ready: false, checks: { storage: "failed" } }` (so
31
+ * a `kubectl get endpoints` gap has a story). Ready ⇒ the exposition
32
+ * line, scrape-compatible for the same reason as `/healthz`.
33
+ */
34
+ export function registerProbeRoutes(app, ctx) {
35
+ app.get("/healthz", async (_request, reply) => {
36
+ return await reply
37
+ .code(200)
38
+ .type("text/plain; version=0.0.4; charset=utf-8")
39
+ .send("sigrun_liveness 1\n");
40
+ });
41
+ app.get("/readyz", async (_request, reply) => {
42
+ if ((await checkStorage(ctx)) !== "ok") {
43
+ return await reply.code(503).send({
44
+ ready: false,
45
+ checks: { storage: "failed" },
46
+ });
47
+ }
48
+ return await reply
49
+ .code(200)
50
+ .type("text/plain; version=0.0.4; charset=utf-8")
51
+ .send("sigrun_readiness 1\n");
52
+ });
53
+ }