@sigrun/sigrun 0.1.6 → 0.2.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 (137) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +445 -42
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/schema.js +9 -2
  11. package/dist/blocks/validate.d.ts +73 -0
  12. package/dist/blocks/validate.js +57 -8
  13. package/dist/cli.js +26 -2
  14. package/dist/config.js +67 -1
  15. package/dist/extension.d.ts +230 -22
  16. package/dist/harness/ambient.js +39 -185
  17. package/dist/harness/blocks/entries.js +47 -0
  18. package/dist/harness/blocks/state.js +58 -0
  19. package/dist/harness/blocks/tools.js +274 -0
  20. package/dist/harness/boot.js +259 -20
  21. package/dist/harness/builtins.js +15 -11
  22. package/dist/harness/event-bus.js +105 -10
  23. package/dist/harness/identity/accounts.js +186 -0
  24. package/dist/harness/identity/groups.js +14 -0
  25. package/dist/harness/identity/index.js +13 -6
  26. package/dist/harness/identity/json-store.js +15 -4
  27. package/dist/harness/identity/setup-code-check.js +9 -0
  28. package/dist/harness/identity/tokens.js +28 -5
  29. package/dist/harness/identity/users.js +111 -12
  30. package/dist/harness/interactions.js +292 -136
  31. package/dist/harness/loader.js +271 -17
  32. package/dist/harness/packages.js +1 -1
  33. package/dist/harness/reserved-sections.js +21 -0
  34. package/dist/harness/seam-host.js +92 -6
  35. package/dist/harness/sections/context-files.js +1 -1
  36. package/dist/harness/settings.js +63 -2
  37. package/dist/harness/stores.js +241 -4
  38. package/dist/harness/user-services.js +391 -0
  39. package/dist/harness/user-tools.js +187 -0
  40. package/dist/harness/workspace.js +61 -57
  41. package/dist/jobs/fire.js +10 -16
  42. package/dist/sandbox/availability.js +211 -0
  43. package/dist/sandbox/command-observer.js +59 -0
  44. package/dist/sandbox/grants.js +159 -0
  45. package/dist/sandbox/helper.js +29 -0
  46. package/dist/sandbox/isolation.js +29 -0
  47. package/dist/sandbox/launch.js +31 -0
  48. package/dist/sandbox/os-account.js +49 -0
  49. package/dist/sandbox/policy.js +274 -0
  50. package/dist/sandbox/pool.js +548 -0
  51. package/dist/sandbox/protocol.js +167 -0
  52. package/dist/sandbox/sandboxed-env.js +245 -0
  53. package/dist/sandbox/unavailable-env.js +49 -0
  54. package/dist/sandbox/worker.js +261 -0
  55. package/dist/serve.js +213 -25
  56. package/dist/server/auth/access-principal.js +16 -0
  57. package/dist/server/auth/pipeline.js +99 -36
  58. package/dist/server/blocks/answers.js +94 -0
  59. package/dist/server/blocks/chat-surfaces.js +393 -0
  60. package/dist/server/blocks/notes.js +216 -0
  61. package/dist/server/blocks/summaries.js +328 -0
  62. package/dist/server/commands.js +5 -0
  63. package/dist/server/content-parts-lazy.js +13 -0
  64. package/dist/server/conversation-handler.js +2 -5
  65. package/dist/server/conversation-ops.js +138 -37
  66. package/dist/server/helpers.js +18 -6
  67. package/dist/server/host-bridge.js +40 -20
  68. package/dist/server/images/cache.js +213 -0
  69. package/dist/server/images/convert.js +13 -0
  70. package/dist/server/images/fetch.js +302 -0
  71. package/dist/server/images/ranges.js +42 -0
  72. package/dist/server/images/rewrite.js +94 -0
  73. package/dist/server/images/sign.js +84 -0
  74. package/dist/server/images/sniff.js +62 -0
  75. package/dist/server/index.js +152 -4
  76. package/dist/server/login/flows.js +126 -0
  77. package/dist/server/login/http.js +31 -0
  78. package/dist/server/login/resolve.js +133 -0
  79. package/dist/server/login/setup-claim.js +55 -0
  80. package/dist/server/login/usernames.js +15 -0
  81. package/dist/server/notifications.js +21 -4
  82. package/dist/server/ownership.js +13 -0
  83. package/dist/server/routes/admin.js +58 -0
  84. package/dist/server/routes/auth.js +88 -2
  85. package/dist/server/routes/conversations.js +3 -6
  86. package/dist/server/routes/events.js +28 -3
  87. package/dist/server/routes/extensions.js +37 -5
  88. package/dist/server/routes/images.js +274 -0
  89. package/dist/server/routes/interactions.js +57 -68
  90. package/dist/server/routes/jobs.js +2 -8
  91. package/dist/server/routes/login.js +223 -0
  92. package/dist/server/routes/me.js +90 -0
  93. package/dist/server/routes/meta.js +19 -0
  94. package/dist/server/routes/onboarding.js +94 -84
  95. package/dist/server/routes/settings-sections.js +190 -0
  96. package/dist/server/routes/settings.js +16 -4
  97. package/dist/server/routes/submissions.js +1 -1
  98. package/dist/server/routes/surfaces.js +175 -23
  99. package/dist/server/routes/users.js +116 -20
  100. package/dist/server/schema-fixture.js +213 -0
  101. package/dist/server/session-meta.js +3 -0
  102. package/dist/server/shutdown.js +11 -4
  103. package/dist/server/static.js +18 -0
  104. package/dist/server/surfaces/dedup.js +9 -2
  105. package/dist/server/surfaces/service.js +181 -13
  106. package/dist/server/surfaces/settings-surfaces.js +597 -0
  107. package/dist/server/turns/submit-turn.js +26 -0
  108. package/dist/server/views.js +16 -0
  109. package/dist/settings/core-sections.js +532 -0
  110. package/dist/settings/pending.js +181 -0
  111. package/dist/settings/registry.js +453 -0
  112. package/dist/skills/index.js +6 -0
  113. package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
  114. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  115. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  116. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  117. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  118. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  119. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  120. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  121. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  122. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  123. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  124. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  125. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  126. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  127. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  128. package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
  129. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  130. package/node_modules/@sigrun/shared/package.json +1 -1
  131. package/package.json +13 -9
  132. package/webui/dist/assets/index-SO2W4jjk.js +55 -0
  133. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  134. package/webui/dist/index.html +2 -2
  135. package/CONTEXT.md +0 -163
  136. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  137. package/webui/dist/assets/index-JDfOQV9z.js +0 -52
@@ -34,14 +34,22 @@
34
34
  * request before handlers run) + a non-empty user header; a
35
35
  * directory miss auto-provisions (`source:"proxy"`, admin:false)
36
36
  * unless `autoProvision:false` → 403 `user_not_provisioned`.
37
+ * Every proxy rejection (401/403) emits ONE sanitized `warn` naming
38
+ * the socket peer + a short reason (release-readiness Task 1, spec
39
+ * §3 — never tokens, cookies, identity-header VALUES, query
40
+ * strings, or the full URL; a request presenting a Bearer/cookie
41
+ * credential is a token attempt and stays silent).
37
42
  * `token`: Bearer or the `sigrun_session` cookie → `tokens.verify`;
38
- * the token's user must exist in the directory, else 401.
43
+ * the token's user must exist in the directory, else 401 (no log —
44
+ * token failures are not proxy diagnostics).
39
45
  * 6. **Empty list** (the naive workstation path): the trimmed
40
46
  * `X-Sigrun-User` header verbatim, `admin: true`, method `"none"`.
41
47
  */
42
48
  import { timingSafeEqual } from "node:crypto";
43
49
  import { ipAllowed } from "./ip.js";
44
50
  import { isLoopbackHost } from "./loopback.js";
51
+ import { AdmissionRefusedError } from "../../harness/identity/users.js";
52
+ import { effectiveAdmin } from "../../harness/identity/groups.js";
45
53
  /** One header read: Fastify headers may be string | string[]; first wins. */
46
54
  function headerOf(request, name) {
47
55
  const value = request.headers[name.toLowerCase()];
@@ -57,7 +65,7 @@ function timingSafeStringEqual(a, b) {
57
65
  return timingSafeEqual(left, right);
58
66
  }
59
67
  /** The request's Bearer credential, if the Authorization header carries one. */
60
- function bearerOf(request) {
68
+ export function bearerOf(request) {
61
69
  const value = headerOf(request, "authorization");
62
70
  if (value === undefined)
63
71
  return undefined;
@@ -65,7 +73,7 @@ function bearerOf(request) {
65
73
  return match?.[1]?.trim() || undefined;
66
74
  }
67
75
  /** The `sigrun_session` cookie value (naive parse; the cookie is ours). */
68
- function sessionCookieOf(request) {
76
+ export function sessionCookieOf(request) {
69
77
  const raw = headerOf(request, "cookie");
70
78
  if (raw === undefined)
71
79
  return undefined;
@@ -160,6 +168,15 @@ function isPublicPath(request, ctx) {
160
168
  return true;
161
169
  if (pattern === "/v1/auth/logout" && request.method === "POST")
162
170
  return true;
171
+ if (pattern === "/v1/auth/providers" && request.method === "GET")
172
+ return true;
173
+ if (pattern === "/v1/auth/login/:provider" && request.method === "GET")
174
+ return true;
175
+ if (pattern === "/v1/auth/login/code" && request.method === "POST")
176
+ return true;
177
+ if (pattern === "/v1/auth/callback/:provider" &&
178
+ (request.method === "GET" || request.method === "POST"))
179
+ return true;
163
180
  if (pattern === "/v1/onboarding" || pattern?.startsWith("/v1/onboarding/")) {
164
181
  return true;
165
182
  }
@@ -173,6 +190,22 @@ function isPublicPath(request, ctx) {
173
190
  }
174
191
  return false;
175
192
  }
193
+ /**
194
+ * The proxy-auth rejection diagnostic (release-readiness Task 1, spec §3):
195
+ * ONE `warn` per rejected request, naming the socket peer, the request
196
+ * method, a short reason, and — only when Fastify already resolved it —
197
+ * the ROUTE TEMPLATE (`routeOptions.url`, never the raw URL). The line
198
+ * must never carry API tokens, cookies, identity-header VALUES, query
199
+ * strings, or the full request URL; a header NAME appears only where the
200
+ * reason itself is "that configured header was absent/wrong" (its value
201
+ * never does). This is diagnostic logging, not evidence that the
202
+ * configured allowlist is correct.
203
+ */
204
+ function warnProxyRejected(request, reason) {
205
+ const peer = request.socket.remoteAddress ?? "unknown";
206
+ const route = request.routeOptions.url;
207
+ console.warn(`sigrun: proxy auth rejected ${request.method} request from ${peer} (${reason})${route === undefined ? "" : ` on ${route}`}`);
208
+ }
176
209
  /**
177
210
  * Stage 5a (proxy): resolve the forward-auth headers, or reply with the
178
211
  * rejection. The secret header is DELETED from the request before any
@@ -180,7 +213,24 @@ function isPublicPath(request, ctx) {
180
213
  */
181
214
  async function resolveProxy(request, ctx) {
182
215
  const { proxy, adminGroups } = ctx.config.auth;
216
+ // A request presenting a Bearer/cookie CREDENTIAL is attempting token
217
+ // auth (or riding the stage-3 service token), not claiming a proxy
218
+ // identity: when the token method is ALSO configured, suppress the
219
+ // rejection diagnostic — a denied credential is the token method's
220
+ // failure, which never logs (spec §3). The pipeline visits the proxy
221
+ // method FIRST for such a request (a trusted peer presenting a session
222
+ // without proxy headers is a legitimate client shape behind
223
+ // forward-auth), and a "missing secret" warn would fire even when the
224
+ // token then authenticates. In a PROXY-ONLY deployment the suppression
225
+ // does not apply: a rejected credential-bearing request there IS a
226
+ // proxy-method 401 and must log (spec §3 exempts token-auth failures,
227
+ // and no token method exists).
228
+ const attemptingToken = (bearerOf(request) !== undefined || sessionCookieOf(request) !== undefined) &&
229
+ ctx.methods.includes("token");
183
230
  if (!ipAllowed(request.socket.remoteAddress, proxy.trustedProxies)) {
231
+ if (!attemptingToken) {
232
+ warnProxyRejected(request, "untrusted proxy");
233
+ }
184
234
  return { status: 401 };
185
235
  }
186
236
  if (proxy.secret !== undefined && proxy.secretHeader !== undefined) {
@@ -188,12 +238,19 @@ async function resolveProxy(request, ctx) {
188
238
  const presented = headerOf(request, name);
189
239
  delete request.headers[name];
190
240
  if (presented === undefined || !timingSafeStringEqual(presented, proxy.secret)) {
241
+ if (!attemptingToken) {
242
+ warnProxyRejected(request, `missing or wrong shared secret (${proxy.secretHeader} header)`);
243
+ }
191
244
  return { status: 401 };
192
245
  }
193
246
  }
194
247
  const user = headerOf(request, proxy.userHeader);
195
- if (user === undefined)
248
+ if (user === undefined) {
249
+ if (!attemptingToken) {
250
+ warnProxyRejected(request, `missing identity header (${proxy.userHeader})`);
251
+ }
196
252
  return { status: 401 };
253
+ }
197
254
  const groups = headerOf(request, proxy.groupsHeader)
198
255
  ?.split(",")
199
256
  .map((group) => group.trim())
@@ -201,53 +258,54 @@ async function resolveProxy(request, ctx) {
201
258
  const { users } = ctx.opened.identity;
202
259
  // The invalid-username case (a proxy header that can't be a directory
203
260
  // user) is a loud 403, not a 500: the proxy vouched for an identity
204
- // this server can never represent.
261
+ // this server can never represent. The warn's reason never carries the
262
+ // claimed value (spec §3: no identity-header values).
205
263
  if (!/^[a-z0-9][a-z0-9._-]*$/.test(user)) {
264
+ warnProxyRejected(request, "unusable identity claim");
206
265
  return { status: 403, error: "invalid_username" };
207
266
  }
208
267
  const existing = users.get(user);
209
268
  if (existing === undefined && !proxy.autoProvision) {
269
+ warnProxyRejected(request, "untrusted identity source");
210
270
  return { status: 403, error: "user_not_provisioned" };
211
271
  }
212
- const groupAdmin = groups.some((group) => adminGroups.includes(group));
213
- const record = existing ??
214
- (await users.add({
215
- user,
216
- ...(headerOf(request, proxy.nameHeader) === undefined
217
- ? {}
218
- : { name: headerOf(request, proxy.nameHeader) }),
219
- ...(headerOf(request, proxy.emailHeader) === undefined
220
- ? {}
221
- : { email: headerOf(request, proxy.emailHeader) }),
222
- admin: false,
223
- // Gateway C8: the grant is stamped at provision — host-side
224
- // checks (chat commands, no headers) read it from the record.
225
- ...(adminGroups.length > 0 ? { groupAdmin } : {}),
226
- source: "proxy",
227
- }));
228
- // Gateway C8 write-through: host-side code has no request headers, so
229
- // the computed group grant must live on the directory record. Only a
230
- // CHANGE writes (the steady state never rewrites the file), and only
231
- // while `adminGroups` is configured — an empty list leaves the field
232
- // untouched (a group-exit clears it at this user's next request under
233
- // a non-empty list, never under an empty one). Absent counts as false;
234
- // the auto-provisioned record was added with the flag already set, so
235
- // the comparison is against the RESOLVED record, never `existing`.
236
- if (adminGroups.length > 0 &&
237
- (record.groupAdmin ?? false) !== groupAdmin) {
238
- await users.update(user, { groupAdmin });
272
+ let record;
273
+ try {
274
+ record = existing ??
275
+ (await users.add({
276
+ user,
277
+ ...(headerOf(request, proxy.nameHeader) === undefined
278
+ ? {}
279
+ : { name: headerOf(request, proxy.nameHeader) }),
280
+ ...(headerOf(request, proxy.emailHeader) === undefined
281
+ ? {}
282
+ : { email: headerOf(request, proxy.emailHeader) }),
283
+ admin: false,
284
+ source: "proxy",
285
+ }));
286
+ }
287
+ catch (error) {
288
+ if (error instanceof AdmissionRefusedError) {
289
+ return { status: 403, error: error.code };
290
+ }
291
+ throw error;
239
292
  }
293
+ // Persist the authenticated source's groups on every proxy request;
294
+ // setGroups avoids the write when the values are unchanged. Effective
295
+ // admin is computed over the UPDATED record, so a group exit revokes
296
+ // the request's grant immediately and host-side checks agree.
297
+ record = await users.setGroups(user, "proxy", groups);
240
298
  return {
241
299
  user: record.user,
242
300
  ...(record.name === undefined ? {} : { name: record.name }),
243
301
  ...(record.email === undefined ? {} : { email: record.email }),
244
- admin: record.admin || groupAdmin,
302
+ admin: effectiveAdmin(record, adminGroups),
245
303
  service: false,
246
304
  method: "proxy",
247
305
  };
248
306
  }
249
307
  /** Stage 5b (token): verify a presented credential against the token store. */
250
- function resolveToken(request, ctx) {
308
+ export function resolveToken(request, ctx) {
251
309
  const presented = bearerOf(request) ?? sessionCookieOf(request);
252
310
  if (presented === undefined)
253
311
  return { status: 401 };
@@ -262,9 +320,14 @@ function resolveToken(request, ctx) {
262
320
  user: user.user,
263
321
  ...(user.name === undefined ? {} : { name: user.name }),
264
322
  ...(user.email === undefined ? {} : { email: user.email }),
265
- admin: user.admin,
323
+ // Token/session credentials are not group sources: derive from the
324
+ // stored record without writing groups on this request path.
325
+ admin: effectiveAdmin(user, ctx.config.auth.adminGroups),
266
326
  service: false,
267
- method: "token",
327
+ method: record.kind === "session" ? "session" : "token",
328
+ ...(record.kind === "session" && record.provider !== undefined
329
+ ? { provider: record.provider }
330
+ : {}),
268
331
  };
269
332
  }
270
333
  /** The ONE request hook. Stages 1→5 in brief order; a stage replies or falls through. */
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The block answer (UI-blocks U5 Task 3, spec §8.4): a turn action
3
+ * (`submit`, default `turn: true`; any action with `turn: true`) makes
4
+ * core submit a user message for the acting user through the normal
5
+ * submission path (`submitOp`):
6
+ *
7
+ * { type: "input",
8
+ * content: textProjection, // "Submitted 'Ranking': 1. …"
9
+ * entry: { kind: "sigrun.blocks.answer",
10
+ * data: { entryId, action, payload, state } },
11
+ * requestId: "blocks:" + actionId }
12
+ *
13
+ * The surface's unnoted change records fold into `content` first and
14
+ * `notedThrough` advances BEFORE the §8.3 note step runs — so the turn
15
+ * sends no separate note for this surface, while changes on OTHER
16
+ * surfaces still note. `requestId` makes a double click one turn.
17
+ */
18
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
+ import { toText } from "@sigrun/shared/blocks";
20
+ import { submitOp } from "../conversation-ops.js";
21
+ import { readChatBlock } from "./chat-surfaces.js";
22
+ import { NOTE_HEADER } from "./notes.js";
23
+ import { PROJECTION_LOCALE, PROJECTION_WIDTH, } from "../../harness/blocks/tools.js";
24
+ import { BlocksAnswerEntry } from "../../harness/blocks/entries.js";
25
+ import { BlocksStateDoc } from "../../harness/blocks/state.js";
26
+ /**
27
+ * Submit one block answer: fold the surface's unnoted changes and its
28
+ * current projection into the user-visible content, advance the
29
+ * surface's `notedThrough`, then admit the input through `submitOp` with
30
+ * `skipNoteFor` (this surface notes no further this turn) and the
31
+ * answer entry. The route owns access gating and actionId dedup; this
32
+ * caller runs only for a located, write-accessible block whose handler
33
+ * starts a turn.
34
+ *
35
+ * The fold and the `notedThrough` advance run in ONE commit BEFORE the
36
+ * note step (§8.4: "The surface's unnoted change records are folded into
37
+ * `content` first, and `notedThrough` advances, before the §8.3 note
38
+ * step runs"), so the answer turn never double-reports its surface.
39
+ */
40
+ export async function answerBlock(ctx, conversationId, entryId, action, payload, actionId) {
41
+ const { opened } = ctx;
42
+ const block = await readChatBlock(opened.harness, conversationId, entryId);
43
+ if (block === undefined) {
44
+ throw new Error(`answerBlock: block ${entryId} not found`);
45
+ }
46
+ const conversation = await opened.harness.conversation(conversationId, BACKGROUND_CONTEXT);
47
+ if (conversation === undefined) {
48
+ throw new Error(`answerBlock: conversation ${conversationId} not found`);
49
+ }
50
+ // The fold (one commit): read the CURRENT state, build the content
51
+ // (changes + projection), advance notedThrough past every record the
52
+ // fold covers.
53
+ const folded = await conversation.commit(async (tx) => {
54
+ const draft = await tx.doc(BlocksStateDoc, conversationId, entryId, entryId);
55
+ const pending = draft.changes.filter((record) => record.seq > draft.notedThrough);
56
+ const lines = [];
57
+ if (pending.length > 0) {
58
+ const byAuthor = new Map();
59
+ for (const record of pending) {
60
+ const list = byAuthor.get(record.by) ?? [];
61
+ list.push(record.summary);
62
+ byAuthor.set(record.by, list);
63
+ }
64
+ lines.push(NOTE_HEADER);
65
+ for (const [author, summaries] of byAuthor) {
66
+ lines.push(`• ${block.title} (${author}): ${summaries.join("; ")}`);
67
+ }
68
+ }
69
+ lines.push(`Submitted '${block.title}': ${toText(block.tree, draft.data, {
70
+ width: PROJECTION_WIDTH,
71
+ locale: PROJECTION_LOCALE,
72
+ })}`);
73
+ if (pending.length > 0) {
74
+ draft.notedThrough = pending[pending.length - 1].seq;
75
+ }
76
+ return {
77
+ content: lines.join("\n"),
78
+ state: JSON.parse(JSON.stringify(draft.data)),
79
+ };
80
+ }, BACKGROUND_CONTEXT);
81
+ await submitOp(ctx, conversation, folded.content, {
82
+ ...(actionId === undefined ? {} : { requestId: `blocks:${actionId}` }),
83
+ skipNoteFor: entryId,
84
+ entry: {
85
+ kind: BlocksAnswerEntry.kind,
86
+ data: {
87
+ entryId,
88
+ action,
89
+ payload,
90
+ state: folded.state,
91
+ },
92
+ },
93
+ });
94
+ }