@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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ambient seam (spec: docs/approval-ux-seams.md, "Ambient surfaces"):
|
|
3
|
+
* core-owned, channel-neutral records for persistent read-mostly state —
|
|
4
|
+
* statusline slots and panel widgets — written by extensions through the
|
|
5
|
+
* host context and rendered by channels generically.
|
|
6
|
+
*
|
|
7
|
+
* SPLIT (build-and-webui Task 1): the canonical TYPE declarations live in
|
|
8
|
+
* the `@sigrun/shared` package (`shared/src/ambient.ts` — the records ride
|
|
9
|
+
* the §5 wire as-is, so the shapes ARE the channel contract); this module
|
|
10
|
+
* owns the docs, validation, and registry RUNTIME, importing the types
|
|
11
|
+
* (type-only — no runtime dependency on the built package).
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle difference from interactions: an ambient surface is replaced
|
|
14
|
+
* repeatedly (write = FULL state replacement, never a delta) and never
|
|
15
|
+
* blocks. One slot key is owned by exactly one extension (allocation,
|
|
16
|
+
* not silent composition — the pi-zentui chrome-skin lesson).
|
|
17
|
+
*
|
|
18
|
+
* Layering mirrors the interaction seam: strict envelope (key, place,
|
|
19
|
+
* budget, refresh, expires) around a body composed only from the closed
|
|
20
|
+
* ambient primitives (line, rows, gauge, ledger), with opaque `details`.
|
|
21
|
+
*/
|
|
22
|
+
import { defineDoc, defineDocFamily } from "@earendil-works/pi-durable";
|
|
23
|
+
/**
|
|
24
|
+
* The ambient record family: one record per surface key, per conversation.
|
|
25
|
+
* (Session-scoped statuslines that outlive one conversation are a v2
|
|
26
|
+
* concern; channels today render conversation-scoped state.)
|
|
27
|
+
*/
|
|
28
|
+
export const AmbientDoc = defineDocFamily({
|
|
29
|
+
kind: "sigrun.ambient",
|
|
30
|
+
version: 1,
|
|
31
|
+
scope: "conversation",
|
|
32
|
+
history: "latest",
|
|
33
|
+
fork: "current",
|
|
34
|
+
family: true,
|
|
35
|
+
initial: (key) => ({
|
|
36
|
+
key,
|
|
37
|
+
owner: "",
|
|
38
|
+
place: "status",
|
|
39
|
+
expires: "never",
|
|
40
|
+
updatedAt: 0,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
export const AmbientOwnersDoc = defineDoc({
|
|
44
|
+
kind: "sigrun.ambient.owners",
|
|
45
|
+
version: 1,
|
|
46
|
+
scope: "session",
|
|
47
|
+
initial: () => ({ owners: {} }),
|
|
48
|
+
});
|
|
49
|
+
const PLACES = new Set(["status", "footer", "panel"]);
|
|
50
|
+
const OVERFLOWS = new Set(["drop", "wrap", "summarize"]);
|
|
51
|
+
const FORMATS = new Set(["compact", "usd", "percent"]);
|
|
52
|
+
const ZONES = new Set(["left", "middle", "right"]);
|
|
53
|
+
/** Structural validation of one ambient record. Returns the error text or null. */
|
|
54
|
+
export function validateAmbient(value) {
|
|
55
|
+
if (typeof value !== "object" || value === null) {
|
|
56
|
+
return "ambient record must be an object";
|
|
57
|
+
}
|
|
58
|
+
const r = value;
|
|
59
|
+
if (typeof r.key !== "string" || r.key.length === 0) {
|
|
60
|
+
return "key must be a non-empty string";
|
|
61
|
+
}
|
|
62
|
+
if (typeof r.place !== "string" || !PLACES.has(r.place)) {
|
|
63
|
+
return `place must be one of status|footer|panel (got ${String(r.place)})`;
|
|
64
|
+
}
|
|
65
|
+
if (r.maxRows !== undefined && typeof r.maxRows !== "number") {
|
|
66
|
+
return "maxRows must be a number";
|
|
67
|
+
}
|
|
68
|
+
if (r.overflow !== undefined && !OVERFLOWS.has(String(r.overflow))) {
|
|
69
|
+
return `overflow must be one of drop|wrap|summarize (got ${String(r.overflow)})`;
|
|
70
|
+
}
|
|
71
|
+
if (r.refreshMs !== undefined && typeof r.refreshMs !== "number") {
|
|
72
|
+
return "refreshMs must be a number";
|
|
73
|
+
}
|
|
74
|
+
if (r.expires !== "never" && r.expires !== "idle") {
|
|
75
|
+
return `expires must be "never" or "idle" (got ${String(r.expires)})`;
|
|
76
|
+
}
|
|
77
|
+
if (typeof r.updatedAt !== "number") {
|
|
78
|
+
return "updatedAt must be a number";
|
|
79
|
+
}
|
|
80
|
+
const body = r.body;
|
|
81
|
+
if (body === undefined)
|
|
82
|
+
return null; // an empty write clears the surface
|
|
83
|
+
if (typeof body !== "object" || body === null) {
|
|
84
|
+
return "body must be an object";
|
|
85
|
+
}
|
|
86
|
+
const b = body;
|
|
87
|
+
const known = new Set(["line", "rows", "gauge", "ledger"]);
|
|
88
|
+
const unknownKey = Object.keys(b).find((k) => !known.has(k));
|
|
89
|
+
if (unknownKey !== undefined) {
|
|
90
|
+
return `body.${unknownKey} is outside the closed primitives (move it to details)`;
|
|
91
|
+
}
|
|
92
|
+
if (b.line !== undefined) {
|
|
93
|
+
const problem = validateLine(b.line);
|
|
94
|
+
if (problem !== null)
|
|
95
|
+
return problem;
|
|
96
|
+
}
|
|
97
|
+
if (b.rows !== undefined) {
|
|
98
|
+
if (!Array.isArray(b.rows))
|
|
99
|
+
return "body.rows must be an array";
|
|
100
|
+
for (const row of b.rows) {
|
|
101
|
+
const problem = validateRow(row);
|
|
102
|
+
if (problem !== null)
|
|
103
|
+
return problem;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (b.gauge !== undefined) {
|
|
107
|
+
const problem = validateGauge(b.gauge);
|
|
108
|
+
if (problem !== null)
|
|
109
|
+
return problem;
|
|
110
|
+
}
|
|
111
|
+
if (b.ledger !== undefined) {
|
|
112
|
+
if (!Array.isArray(b.ledger))
|
|
113
|
+
return "body.ledger must be an array";
|
|
114
|
+
for (const e of b.ledger) {
|
|
115
|
+
const problem = validateLedgerEntry(e);
|
|
116
|
+
if (problem !== null)
|
|
117
|
+
return problem;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
function validateLine(value) {
|
|
123
|
+
if (typeof value !== "object" || value === null)
|
|
124
|
+
return "line must be an object";
|
|
125
|
+
const l = value;
|
|
126
|
+
if (l.text === undefined && l.segments === undefined) {
|
|
127
|
+
return "line needs text or segments";
|
|
128
|
+
}
|
|
129
|
+
if (l.text !== undefined && typeof l.text !== "string") {
|
|
130
|
+
return "line.text must be a string";
|
|
131
|
+
}
|
|
132
|
+
if (l.segments !== undefined) {
|
|
133
|
+
if (!Array.isArray(l.segments))
|
|
134
|
+
return "line.segments must be an array";
|
|
135
|
+
for (const s of l.segments) {
|
|
136
|
+
if (typeof s !== "object" || s === null)
|
|
137
|
+
return "segment must be an object";
|
|
138
|
+
const seg = s;
|
|
139
|
+
if (typeof seg.id !== "string" || seg.id.length === 0) {
|
|
140
|
+
return "segment.id must be a non-empty string";
|
|
141
|
+
}
|
|
142
|
+
if (typeof seg.text !== "string")
|
|
143
|
+
return "segment.text must be a string";
|
|
144
|
+
if (seg.place !== undefined && !ZONES.has(String(seg.place))) {
|
|
145
|
+
return `segment.place must be one of left|middle|right (got ${String(seg.place)})`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
function validateRow(value) {
|
|
152
|
+
if (typeof value !== "object" || value === null)
|
|
153
|
+
return "row must be an object";
|
|
154
|
+
const r = value;
|
|
155
|
+
if (typeof r.id !== "string" || r.id.length === 0) {
|
|
156
|
+
return "row.id must be a non-empty string";
|
|
157
|
+
}
|
|
158
|
+
if (typeof r.label !== "string")
|
|
159
|
+
return "row.label must be a string";
|
|
160
|
+
if (r.status !== undefined && typeof r.status !== "string") {
|
|
161
|
+
return "row.status must be a string";
|
|
162
|
+
}
|
|
163
|
+
if (r.severity !== undefined &&
|
|
164
|
+
r.severity !== 0 &&
|
|
165
|
+
r.severity !== 1 &&
|
|
166
|
+
r.severity !== 2) {
|
|
167
|
+
return "row.severity must be 0, 1, or 2";
|
|
168
|
+
}
|
|
169
|
+
if (r.quiet !== undefined && typeof r.quiet !== "boolean") {
|
|
170
|
+
return "row.quiet must be a boolean";
|
|
171
|
+
}
|
|
172
|
+
if (r.detail !== undefined && typeof r.detail !== "string") {
|
|
173
|
+
return "row.detail must be a string";
|
|
174
|
+
}
|
|
175
|
+
if (r.depth !== undefined && typeof r.depth !== "number") {
|
|
176
|
+
return "row.depth must be a number";
|
|
177
|
+
}
|
|
178
|
+
if (r.deps !== undefined && !Array.isArray(r.deps)) {
|
|
179
|
+
return "row.deps must be an array of ids";
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(r.deps) && r.deps.some((d) => typeof d !== "string")) {
|
|
182
|
+
return "row.deps must be an array of ids";
|
|
183
|
+
}
|
|
184
|
+
if (r.spinnerLabel !== undefined && typeof r.spinnerLabel !== "string") {
|
|
185
|
+
return "row.spinnerLabel must be a string";
|
|
186
|
+
}
|
|
187
|
+
if (r.metrics !== undefined) {
|
|
188
|
+
const problem = validateMetrics(r.metrics);
|
|
189
|
+
if (problem !== null)
|
|
190
|
+
return problem;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
function validateMetrics(value) {
|
|
195
|
+
if (typeof value !== "object" || value === null)
|
|
196
|
+
return "metrics must be an object";
|
|
197
|
+
const m = value;
|
|
198
|
+
for (const k of ["tokens", "cost", "current", "total", "completed"]) {
|
|
199
|
+
if (m[k] !== undefined && typeof m[k] !== "number") {
|
|
200
|
+
return `metrics.${k} must be a number`;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (m.format !== undefined && !FORMATS.has(String(m.format))) {
|
|
204
|
+
return `metrics.format must be one of compact|usd|percent (got ${String(m.format)})`;
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
function validateGauge(value) {
|
|
209
|
+
if (typeof value !== "object" || value === null)
|
|
210
|
+
return "gauge must be an object";
|
|
211
|
+
const g = value;
|
|
212
|
+
if (typeof g.label !== "string")
|
|
213
|
+
return "gauge.label must be a string";
|
|
214
|
+
if (g.percent !== undefined && typeof g.percent !== "number") {
|
|
215
|
+
return "gauge.percent must be a number";
|
|
216
|
+
}
|
|
217
|
+
if (g.phase !== undefined && typeof g.phase !== "string") {
|
|
218
|
+
return "gauge.phase must be a string";
|
|
219
|
+
}
|
|
220
|
+
if (g.elapsedMs !== undefined && typeof g.elapsedMs !== "number") {
|
|
221
|
+
return "gauge.elapsedMs must be a number";
|
|
222
|
+
}
|
|
223
|
+
if (g.recentOutput !== undefined && !Array.isArray(g.recentOutput)) {
|
|
224
|
+
return "gauge.recentOutput must be an array of strings";
|
|
225
|
+
}
|
|
226
|
+
if (Array.isArray(g.recentOutput) && g.recentOutput.some((l) => typeof l !== "string")) {
|
|
227
|
+
return "gauge.recentOutput must be an array of strings";
|
|
228
|
+
}
|
|
229
|
+
if (g.checks !== undefined && !Array.isArray(g.checks)) {
|
|
230
|
+
return "gauge.checks must be row-shaped";
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(g.checks)) {
|
|
233
|
+
for (const row of g.checks) {
|
|
234
|
+
const problem = validateRow(row);
|
|
235
|
+
if (problem !== null)
|
|
236
|
+
return `gauge.checks: ${problem}`;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
function validateLedgerEntry(value) {
|
|
242
|
+
if (typeof value !== "object" || value === null)
|
|
243
|
+
return "ledger entry must be an object";
|
|
244
|
+
const e = value;
|
|
245
|
+
if (typeof e.id !== "string" || e.id.length === 0) {
|
|
246
|
+
return "ledger.id must be a non-empty string";
|
|
247
|
+
}
|
|
248
|
+
if (typeof e.at !== "number")
|
|
249
|
+
return "ledger.at must be a number";
|
|
250
|
+
if (typeof e.kind !== "string")
|
|
251
|
+
return "ledger.kind must be a string";
|
|
252
|
+
if (typeof e.summary !== "string")
|
|
253
|
+
return "ledger.summary must be a string";
|
|
254
|
+
if (e.detail !== undefined && !Array.isArray(e.detail)) {
|
|
255
|
+
return "ledger.detail must be an array of strings";
|
|
256
|
+
}
|
|
257
|
+
if (Array.isArray(e.detail) && e.detail.some((d) => typeof d !== "string")) {
|
|
258
|
+
return "ledger.detail must be an array of strings";
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auth store (the 2026-10-13 config-surface ruling): a pi-style
|
|
3
|
+
* credentials file at `<config-dir>/auth.json` — the same directory the
|
|
4
|
+
* settings store lives in (`SIGRUN_CONFIG_DIR` / `~/.config/sigrun`).
|
|
5
|
+
*
|
|
6
|
+
* Shape: `{ "<provider>": { "apiKey": "..." } }`, one entry per provider
|
|
7
|
+
* id, plus the magic key `"default"` naming the DEFAULT provider's key —
|
|
8
|
+
* so a deployment that does not know the resolved provider id up front
|
|
9
|
+
* can still hand the env-driven OpenAI-compatible provider its key.
|
|
10
|
+
*
|
|
11
|
+
* The file is created with mode 0o600 (owner-only) whenever a write
|
|
12
|
+
* happens. Key material is NEVER logged or echoed: validation errors
|
|
13
|
+
* name the key path, never the value; callers must not interpolate
|
|
14
|
+
* resolved keys into messages.
|
|
15
|
+
*
|
|
16
|
+
* Absent file = no stored keys (the `OPENAI_API_KEY` env fallback still
|
|
17
|
+
* applies). A malformed file (bad JSON, non-object, a key present but not
|
|
18
|
+
* a non-empty string) fails loud on read, like a bad `SIGRUN_*` value.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
21
|
+
import { mkdir } from "node:fs/promises";
|
|
22
|
+
import { dirname, join } from "node:path";
|
|
23
|
+
import { defaultSettingsDir } from "./settings.js";
|
|
24
|
+
import { writeFileAtomic } from "./stores.js";
|
|
25
|
+
/** The magic provider key meaning "the resolved default provider's key". */
|
|
26
|
+
export const DEFAULT_AUTH_KEY = "default";
|
|
27
|
+
/** Default file path: `<dir>/auth.json`. */
|
|
28
|
+
export function authPath(dir) {
|
|
29
|
+
return join(dir, "auth.json");
|
|
30
|
+
}
|
|
31
|
+
/** A field-level validation error; names the key path, never the value. */
|
|
32
|
+
export class AuthValidationError extends Error {
|
|
33
|
+
field;
|
|
34
|
+
constructor(field, reason) {
|
|
35
|
+
super(`auth: ${field} ${reason}`);
|
|
36
|
+
this.field = field;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A file-backed auth store. Reads are fresh per call (same discipline as
|
|
41
|
+
* the settings store). The store is permissive about unknown fields inside
|
|
42
|
+
* an entry (future auth kinds — token refresh, base-url pins — may add
|
|
43
|
+
* some) but strict about `apiKey`: when present it must be a non-empty
|
|
44
|
+
* string.
|
|
45
|
+
*/
|
|
46
|
+
export class AuthStore {
|
|
47
|
+
path;
|
|
48
|
+
constructor(options = {}) {
|
|
49
|
+
this.path = authPath(options.dir ?? defaultSettingsDir());
|
|
50
|
+
}
|
|
51
|
+
/** The current auth file; absent file = `{}` (no stored keys). */
|
|
52
|
+
read() {
|
|
53
|
+
if (!existsSync(this.path))
|
|
54
|
+
return {};
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(readFileSync(this.path, "utf8"));
|
|
58
|
+
}
|
|
59
|
+
catch (cause) {
|
|
60
|
+
throw new Error(`auth: ${this.path} is not valid JSON (${cause instanceof Error ? cause.message : String(cause)})`);
|
|
61
|
+
}
|
|
62
|
+
if (typeof parsed !== "object" ||
|
|
63
|
+
parsed === null ||
|
|
64
|
+
Array.isArray(parsed)) {
|
|
65
|
+
throw new Error(`auth: ${this.path} must contain a JSON object`);
|
|
66
|
+
}
|
|
67
|
+
const file = parsed;
|
|
68
|
+
for (const [provider, entry] of Object.entries(file)) {
|
|
69
|
+
if (typeof entry !== "object" ||
|
|
70
|
+
entry === null ||
|
|
71
|
+
Array.isArray(entry)) {
|
|
72
|
+
throw new AuthValidationError(provider, "must be an object");
|
|
73
|
+
}
|
|
74
|
+
const apiKey = entry.apiKey;
|
|
75
|
+
if (apiKey !== undefined &&
|
|
76
|
+
(typeof apiKey !== "string" || apiKey === "")) {
|
|
77
|
+
throw new AuthValidationError(`${provider}.apiKey`, "must be a non-empty string");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return file;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Persist `file` wholesale, creating the file mode 0o600 (owner-only —
|
|
84
|
+
* the file carries key material). An existing file is chmod'd to
|
|
85
|
+
* 0o600 as well: a hand-created world-readable auth file is tightened
|
|
86
|
+
* on the first write.
|
|
87
|
+
*/
|
|
88
|
+
write(file) {
|
|
89
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
90
|
+
writeFileSync(this.path, `${JSON.stringify(file, null, "\t")}\n`, {
|
|
91
|
+
mode: 0o600,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Set one key's api key (core hardening W3 Task 6, spec §7.6): the
|
|
96
|
+
* server's credential ROUTE writes through this — read-modify-write
|
|
97
|
+
* over the current file (other entries untouched), staged through
|
|
98
|
+
* {@link writeFileAtomic} at 0600 so a crash or concurrent reader only
|
|
99
|
+
* ever sees the old or new complete file. `key` is the auth.json
|
|
100
|
+
* provider key (`<id>`, or the magic `"default"` for the default
|
|
101
|
+
* model's provider); `apiKey` must be a non-empty string — anything
|
|
102
|
+
* else is refused HERE rather than written as a file the store's own
|
|
103
|
+
* `read()` would reject. The pi-ai bridge stays read-only; this method
|
|
104
|
+
* is the server's single write seam.
|
|
105
|
+
*/
|
|
106
|
+
async setApiKey(key, apiKey) {
|
|
107
|
+
if (typeof apiKey !== "string" || apiKey === "") {
|
|
108
|
+
throw new AuthValidationError(`${key}.apiKey`, "must be a non-empty string");
|
|
109
|
+
}
|
|
110
|
+
const file = { ...this.read() };
|
|
111
|
+
file[key] = { ...file[key], apiKey };
|
|
112
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
113
|
+
await writeFileAtomic(this.path, `${JSON.stringify(file, null, "\t")}\n`, 0o600);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Remove one key's entry entirely (spec §7.6: "DELETE removes the
|
|
117
|
+
* entry") — atomic and 0600 like every write. Idempotent: an absent
|
|
118
|
+
* key writes nothing (DELETE of a never-set credential is a no-op,
|
|
119
|
+
* not an error).
|
|
120
|
+
*/
|
|
121
|
+
async deleteApiKey(key) {
|
|
122
|
+
const file = this.read();
|
|
123
|
+
if (file[key] === undefined)
|
|
124
|
+
return;
|
|
125
|
+
const next = { ...file };
|
|
126
|
+
delete next[key];
|
|
127
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
128
|
+
await writeFileAtomic(this.path, `${JSON.stringify(next, null, "\t")}\n`, 0o600);
|
|
129
|
+
}
|
|
130
|
+
}
|