@sigrun/sigrun 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- 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
|
+
}
|