@sigrun/sigrun 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +352 -36
  3. package/THIRD_PARTY_NOTICES.md +23 -0
  4. package/dist/blocks/schema.js +9 -2
  5. package/dist/blocks/validate.js +57 -8
  6. package/dist/cli.js +26 -2
  7. package/dist/config.js +67 -1
  8. package/dist/extension.d.ts +189 -15
  9. package/dist/harness/agent-env.js +62 -0
  10. package/dist/harness/ambient.js +39 -185
  11. package/dist/harness/blocks/entries.js +47 -0
  12. package/dist/harness/blocks/state.js +58 -0
  13. package/dist/harness/blocks/tools.js +274 -0
  14. package/dist/harness/boot.js +253 -20
  15. package/dist/harness/builtins.js +15 -11
  16. package/dist/harness/event-bus.js +105 -10
  17. package/dist/harness/identity/accounts.js +186 -0
  18. package/dist/harness/identity/groups.js +14 -0
  19. package/dist/harness/identity/index.js +13 -6
  20. package/dist/harness/identity/json-store.js +15 -4
  21. package/dist/harness/identity/setup-code-check.js +9 -0
  22. package/dist/harness/identity/tokens.js +28 -5
  23. package/dist/harness/identity/users.js +111 -12
  24. package/dist/harness/interactions.js +292 -136
  25. package/dist/harness/loader.js +271 -17
  26. package/dist/harness/packages.js +1 -1
  27. package/dist/harness/reserved-sections.js +21 -0
  28. package/dist/harness/seam-host.js +80 -6
  29. package/dist/harness/settings.js +63 -2
  30. package/dist/harness/stores.js +241 -4
  31. package/dist/harness/user-services.js +391 -0
  32. package/dist/harness/user-tools.js +187 -0
  33. package/dist/harness/workspace.js +61 -55
  34. package/dist/jobs/fire.js +10 -16
  35. package/dist/sandbox/availability.js +162 -0
  36. package/dist/sandbox/command-observer.js +59 -0
  37. package/dist/sandbox/grants.js +159 -0
  38. package/dist/sandbox/helper.js +29 -0
  39. package/dist/sandbox/isolation.js +29 -0
  40. package/dist/sandbox/launch.js +20 -0
  41. package/dist/sandbox/os-account.js +49 -0
  42. package/dist/sandbox/policy.js +253 -0
  43. package/dist/sandbox/pool.js +548 -0
  44. package/dist/sandbox/protocol.js +167 -0
  45. package/dist/sandbox/sandboxed-env.js +245 -0
  46. package/dist/sandbox/unavailable-env.js +49 -0
  47. package/dist/sandbox/worker.js +281 -0
  48. package/dist/serve.js +82 -7
  49. package/dist/server/auth/access-principal.js +16 -0
  50. package/dist/server/auth/pipeline.js +47 -33
  51. package/dist/server/blocks/answers.js +94 -0
  52. package/dist/server/blocks/chat-surfaces.js +393 -0
  53. package/dist/server/blocks/notes.js +216 -0
  54. package/dist/server/blocks/summaries.js +328 -0
  55. package/dist/server/commands.js +5 -0
  56. package/dist/server/content-parts-lazy.js +13 -0
  57. package/dist/server/conversation-handler.js +2 -5
  58. package/dist/server/conversation-ops.js +138 -37
  59. package/dist/server/helpers.js +18 -6
  60. package/dist/server/host-bridge.js +40 -20
  61. package/dist/server/images/cache.js +205 -0
  62. package/dist/server/images/convert.js +13 -0
  63. package/dist/server/images/fetch.js +302 -0
  64. package/dist/server/images/ranges.js +42 -0
  65. package/dist/server/images/rewrite.js +94 -0
  66. package/dist/server/images/sign.js +84 -0
  67. package/dist/server/images/sniff.js +62 -0
  68. package/dist/server/index.js +144 -4
  69. package/dist/server/login/flows.js +126 -0
  70. package/dist/server/login/http.js +31 -0
  71. package/dist/server/login/resolve.js +133 -0
  72. package/dist/server/login/setup-claim.js +55 -0
  73. package/dist/server/login/usernames.js +15 -0
  74. package/dist/server/ownership.js +13 -0
  75. package/dist/server/routes/admin.js +58 -0
  76. package/dist/server/routes/auth.js +88 -2
  77. package/dist/server/routes/conversations.js +3 -6
  78. package/dist/server/routes/events.js +28 -3
  79. package/dist/server/routes/extensions.js +37 -5
  80. package/dist/server/routes/images.js +267 -0
  81. package/dist/server/routes/interactions.js +57 -68
  82. package/dist/server/routes/jobs.js +2 -8
  83. package/dist/server/routes/login.js +223 -0
  84. package/dist/server/routes/me.js +90 -0
  85. package/dist/server/routes/meta.js +15 -0
  86. package/dist/server/routes/onboarding.js +94 -84
  87. package/dist/server/routes/settings-sections.js +190 -0
  88. package/dist/server/routes/settings.js +16 -4
  89. package/dist/server/routes/submissions.js +1 -1
  90. package/dist/server/routes/surfaces.js +175 -23
  91. package/dist/server/routes/users.js +116 -20
  92. package/dist/server/schema-fixture.js +213 -0
  93. package/dist/server/session-meta.js +3 -0
  94. package/dist/server/shutdown.js +11 -4
  95. package/dist/server/static.js +18 -0
  96. package/dist/server/surfaces/dedup.js +9 -2
  97. package/dist/server/surfaces/service.js +181 -13
  98. package/dist/server/surfaces/settings-surfaces.js +597 -0
  99. package/dist/server/turns/submit-turn.js +26 -0
  100. package/dist/server/views.js +16 -0
  101. package/dist/settings/core-sections.js +532 -0
  102. package/dist/settings/pending.js +181 -0
  103. package/dist/settings/registry.js +453 -0
  104. package/dist/skills/index.js +6 -0
  105. package/node_modules/@sigrun/shared/CHANGELOG.md +15 -0
  106. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  107. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  108. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  109. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  110. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  111. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  112. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  113. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  114. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  115. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  116. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  117. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  118. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  119. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  120. package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
  121. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  122. package/node_modules/@sigrun/shared/package.json +1 -1
  123. package/package.json +13 -8
  124. package/webui/dist/assets/index-CxB969qp.js +55 -0
  125. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  126. package/webui/dist/index.html +2 -2
  127. package/webui/dist/assets/index-B4-GYqEb.js +0 -50
  128. package/webui/dist/assets/index-BiSMljer.css +0 -1
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The refused-address ranges for image-proxy fetching (UI-blocks U7,
3
+ * spec §11.2 verbatim): loopback, `0.0.0.0/8`, RFC 1918 private,
4
+ * link-local, CGNAT `100.64.0.0/10`, unique-local `fc00::/7`,
5
+ * multicast, and the IPv4-mapped IPv6 forms of all of them.
6
+ *
7
+ * IPv4-mapped literals (`::ffff:10.0.0.1`) are normalized by
8
+ * `BlockList.check`, so the IPv4 rules cover every mapped form in
9
+ * both dotted and full 8-group notation. Anything that is not an IP
10
+ * literal at all is refused too (fail closed): the fetcher connects
11
+ * only to literals the vetting understood.
12
+ */
13
+ import { BlockList, isIP } from "node:net";
14
+ const REFUSED = new BlockList();
15
+ // IPv4
16
+ REFUSED.addSubnet("0.0.0.0", 8, "ipv4");
17
+ REFUSED.addSubnet("127.0.0.0", 8, "ipv4");
18
+ REFUSED.addSubnet("10.0.0.0", 8, "ipv4");
19
+ REFUSED.addSubnet("172.16.0.0", 12, "ipv4");
20
+ REFUSED.addSubnet("192.168.0.0", 16, "ipv4");
21
+ REFUSED.addSubnet("169.254.0.0", 16, "ipv4");
22
+ REFUSED.addSubnet("100.64.0.0", 10, "ipv4");
23
+ REFUSED.addSubnet("224.0.0.0", 4, "ipv4");
24
+ REFUSED.addAddress("255.255.255.255", "ipv4");
25
+ // IPv6
26
+ // (Spec-level gap, known: the deprecated IPv4-compatible form
27
+ // `::127.0.0.1` is NOT refused — only the IPv4-mapped `::ffff:` form
28
+ // normalizes into the IPv4 rules.)
29
+ REFUSED.addSubnet("::", 128, "ipv6");
30
+ REFUSED.addSubnet("::1", 128, "ipv6");
31
+ REFUSED.addSubnet("fc00::", 7, "ipv6");
32
+ REFUSED.addSubnet("fe80::", 10, "ipv6");
33
+ REFUSED.addSubnet("ff00::", 8, "ipv6");
34
+ /** True when `ip` is an address the image proxy must never connect to. */
35
+ export function isRefusedAddress(ip) {
36
+ const family = isIP(ip);
37
+ if (family === 4)
38
+ return REFUSED.check(ip, "ipv4");
39
+ if (family === 6)
40
+ return REFUSED.check(ip, "ipv6");
41
+ return true;
42
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The image-source rewriter (UI-blocks U7 Task 1, spec §11.1/§11.2):
3
+ * walks a tree or data JSON value and replaces `https://` image sources
4
+ * with the signer's proxy URL before the value leaves the server:
5
+ *
6
+ * - an `image` block's string `src` (literals only — a binding resolves
7
+ * client-side against the data, whose own slots are rewritten below);
8
+ * - a card `image` slot (a string `image` prop), in a tree's item
9
+ * template AND in data items (spec §5.5.1: the slot takes a binding
10
+ * relative to the item, so the URL usually lives in the data);
11
+ * - `![alt](https://…)` markdown image links in any string (markdown
12
+ * text, card/callout bodies, and the data strings they bind to).
13
+ *
14
+ * `asset:` and `data:` sources are untouched (§11.1). The walk is pure:
15
+ * the input is never mutated, and a branch is rebuilt only when a
16
+ * descendant changed (copy-on-write, the discipline of
17
+ * `shared/blocks/ops.ts`); an untouched subtree is returned by
18
+ * reference.
19
+ *
20
+ * Scope note (a spec-reading decision): the whole-string rewrite keys
21
+ * on the two shapes the block set defines for image sources — the
22
+ * `image` block's `src` prop and the card `image` prop — plus markdown
23
+ * image links. A binding that resolves to an https URL stored in data
24
+ * under any OTHER prop name is not rewritten (the walk cannot trace
25
+ * bindings); the webui's `img-src 'self' data:` CSP (Task 3) makes such
26
+ * a miss fail closed rather than load.
27
+ */
28
+ /** A markdown image link: `![alt](https://…)`. */
29
+ const MARKDOWN_IMAGE = /(!\[[^\]]*\]\()(https:\/\/[^)\s]+)(\))/g;
30
+ /** Whether `s` is an https image source the rewriter replaces. */
31
+ function isHttps(s) {
32
+ return s.startsWith("https://");
33
+ }
34
+ /**
35
+ * Rewrite one string's markdown image links; `null` when nothing
36
+ * matched (so the caller keeps the original reference).
37
+ */
38
+ function rewriteMarkdown(text, sign) {
39
+ let changed = false;
40
+ const out = text.replace(MARKDOWN_IMAGE, (_all, pre, url, post) => {
41
+ changed = true;
42
+ return `${pre}${sign(url)}${post}`;
43
+ });
44
+ return changed ? out : null;
45
+ }
46
+ /**
47
+ * Rewrite every https image source in a tree or data value. Pure; see
48
+ * the module docstring for the three rewrite shapes and the scope note.
49
+ */
50
+ export function rewriteImages(value, sign) {
51
+ return rewrite(value, sign);
52
+ }
53
+ /** The structural walk (copy-on-write: `null`-free, returns a value). */
54
+ function rewrite(value, sign) {
55
+ if (typeof value === "string") {
56
+ const md = rewriteMarkdown(value, sign);
57
+ return md === null ? value : md;
58
+ }
59
+ if (Array.isArray(value)) {
60
+ let changed = false;
61
+ const out = [];
62
+ for (const entry of value) {
63
+ const rewritten = rewrite(entry, sign);
64
+ if (rewritten !== entry)
65
+ changed = true;
66
+ out.push(rewritten);
67
+ }
68
+ return changed ? out : value;
69
+ }
70
+ if (typeof value === "object" && value !== null) {
71
+ let changed = false;
72
+ const out = {};
73
+ for (const [key, entry] of Object.entries(value)) {
74
+ let rewritten;
75
+ if (typeof entry === "string" &&
76
+ (key === "src" || key === "image") &&
77
+ isHttps(entry)) {
78
+ // The two whole-string shapes: an image block's `src` and a
79
+ // card's `image` slot. Markdown links inside the string (none
80
+ // in a bare URL) are covered by the string branch below, so
81
+ // rewrite directly.
82
+ rewritten = sign(entry);
83
+ }
84
+ else {
85
+ rewritten = rewrite(entry, sign);
86
+ }
87
+ if (rewritten !== entry)
88
+ changed = true;
89
+ out[key] = rewritten;
90
+ }
91
+ return changed ? out : value;
92
+ }
93
+ return value;
94
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The signing half of the image proxy (UI-blocks U7 Task 1, spec §11.2
3
+ * verbatim):
4
+ *
5
+ * - `s` is HMAC-SHA256 (hex) over the exact bytes `u + "\n" + e`, where
6
+ * `u` is the URL string as it appeared in the tree — the server never
7
+ * re-parses or re-encodes it between verifying and fetching.
8
+ * - `e` is an expiry 24 hours after signing (epoch milliseconds).
9
+ * - The key is 32 random bytes at `<stateDir>/image-proxy.key`, mode
10
+ * 0600, created at first boot and reused after. Deleting the file
11
+ * rotates it: old signatures fail and channels re-fetch the surface,
12
+ * which re-signs.
13
+ * - Signatures are not bound to a user; a copied signed URL works for
14
+ * any signed-in user until it expires (spec: that reveals nothing the
15
+ * URL didn't already).
16
+ */
17
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
18
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
19
+ import { join } from "node:path";
20
+ import { randomUUID } from "node:crypto";
21
+ /** The key file's name under the state dir. */
22
+ export const IMAGE_PROXY_KEY_FILE = "image-proxy.key";
23
+ /** The signature lifetime (spec §11.2): 24 hours. */
24
+ export const SIGNATURE_TTL_MS = 24 * 60 * 60 * 1000;
25
+ /** The proxy path every signed URL is rooted at. */
26
+ const PROXY_PATH = "/v1/image";
27
+ /** Load (or create) the proxy key at `<stateDir>/image-proxy.key`. */
28
+ export async function loadOrCreateKey(stateDir) {
29
+ const path = join(stateDir, IMAGE_PROXY_KEY_FILE);
30
+ try {
31
+ return await readFile(path);
32
+ }
33
+ catch (cause) {
34
+ if (cause.code !== "ENOENT")
35
+ throw cause;
36
+ }
37
+ // First boot: create the key atomically (stage + chmod + rename, the
38
+ // same discipline as the stores' `writeFileAtomic` — chmod BEFORE the
39
+ // rename so there is no window at the wrong mode, and a chmod beats
40
+ // writeFile's `mode` option because umask masks the latter).
41
+ await mkdir(stateDir, { recursive: true });
42
+ const key = randomBytes(32);
43
+ const staged = `${path}.${randomUUID()}.tmp`;
44
+ try {
45
+ await writeFile(staged, key, { mode: 0o600 });
46
+ await chmod(staged, 0o600);
47
+ await rename(staged, path);
48
+ }
49
+ catch (cause) {
50
+ await rm(staged, { force: true });
51
+ throw cause;
52
+ }
53
+ return key;
54
+ }
55
+ /** Sign one URL at time `now`: `/v1/image?u=…&e=…&s=…`. */
56
+ export function signUrl(key, u, now) {
57
+ const e = String(now + SIGNATURE_TTL_MS);
58
+ const s = signature(key, u, e);
59
+ return `${PROXY_PATH}?u=${encodeURIComponent(u)}&e=${e}&s=${s}`;
60
+ }
61
+ /**
62
+ * Verify a signed URL's parts at time `now`: the HMAC matches (constant
63
+ * time) AND `now <= e`. Malformed parts (a non-numeric `e`, a wrong-length
64
+ * or non-hex `s`) reject instead of throwing.
65
+ */
66
+ export function verify(key, u, e, s, now) {
67
+ if (!/^\d+$/.test(e))
68
+ return false;
69
+ if (!/^[0-9a-f]+$/.test(s))
70
+ return false;
71
+ const expected = signature(key, u, e);
72
+ // Constant-time over the fixed 64-byte hex digests; the length check
73
+ // first because timingSafeEqual throws on a length mismatch.
74
+ if (s.length !== expected.length)
75
+ return false;
76
+ if (!timingSafeEqual(Buffer.from(expected, "utf8"), Buffer.from(s, "utf8"))) {
77
+ return false;
78
+ }
79
+ return now <= Number(e);
80
+ }
81
+ /** The HMAC hex digest over the exact bytes `u + "\n" + e`. */
82
+ function signature(key, u, e) {
83
+ return createHmac("sha256", key).update(`${u}\n${e}`, "utf8").digest("hex");
84
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Content sniffing for image-proxy responses (UI-blocks U7, spec
3
+ * §11.2): the body must sniff as PNG, JPEG, GIF or WebP — the
4
+ * `Content-Type` header is never trusted. SVG and AVIF are refused
5
+ * even when the header says `image/png` (SVG can carry script; AVIF
6
+ * has no decoder in pi-tui).
7
+ */
8
+ /**
9
+ * Sniff `bytes` by magic prefix. Returns `"svg"` / `"avif"` for the
10
+ * refused-but-recognizable types (so callers can name the reason),
11
+ * and `undefined` when nothing matches.
12
+ */
13
+ export function sniffImage(bytes) {
14
+ if (bytes.length >= 8) {
15
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
16
+ if (bytes[0] === 0x89 &&
17
+ bytes[1] === 0x50 &&
18
+ bytes[2] === 0x4e &&
19
+ bytes[3] === 0x47 &&
20
+ bytes[4] === 0x0d &&
21
+ bytes[5] === 0x0a &&
22
+ bytes[6] === 0x1a &&
23
+ bytes[7] === 0x0a) {
24
+ return "png";
25
+ }
26
+ // GIF: GIF87a / GIF89a
27
+ if (bytes.subarray(0, 3).toString("latin1") === "GIF" &&
28
+ (bytes.subarray(3, 6).toString("latin1") === "87a" ||
29
+ bytes.subarray(3, 6).toString("latin1") === "89a")) {
30
+ return "gif";
31
+ }
32
+ }
33
+ // JPEG: FF D8 FF
34
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
35
+ return "jpeg";
36
+ }
37
+ // WebP: "RIFF" + len + "WEBP"
38
+ if (bytes.length >= 12 &&
39
+ bytes.subarray(0, 4).toString("latin1") === "RIFF" &&
40
+ bytes.subarray(8, 12).toString("latin1") === "WEBP") {
41
+ return "webp";
42
+ }
43
+ // AVIF/HEIF: "ftyp" box at offset 4; the brand at offset 8 is
44
+ // avif/avis (or the heic family — refused all the same, but named
45
+ // avif only for actual avif brands so the reason is honest).
46
+ if (bytes.length >= 12 && bytes.subarray(4, 8).toString("latin1") === "ftyp") {
47
+ const brand = bytes.subarray(8, 12).toString("latin1").toLowerCase();
48
+ if (brand === "avif" || brand === "avis")
49
+ return "avif";
50
+ }
51
+ // SVG: an XML prolog or an `<svg` opening anywhere in the first
52
+ // 512 bytes (handles BOM, XML decl, comments, and DOCTYPE).
53
+ const head = bytes.subarray(0, 512).toString("utf8");
54
+ if (/^\uFEFF?\s*(<\?xml[\s\S]*?\?>\s*)?(<!--[\s\S]*?-->\s*)*(!DOCTYPE[\s\S]*?\]\s*)?<svg/i.test(head)) {
55
+ return "svg";
56
+ }
57
+ return undefined;
58
+ }
59
+ /** True only for the four accepted types (the refused ones reject). */
60
+ export function isAcceptedImageType(type) {
61
+ return type === "png" || type === "jpeg" || type === "gif" || type === "webp";
62
+ }
@@ -31,6 +31,8 @@
31
31
  * enumeration; the storage scan is authoritative.
32
32
  */
33
33
  import Fastify from "fastify";
34
+ import { join } from "node:path";
35
+ import { LoginFlows } from "./login/flows.js";
34
36
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
35
37
  import { buildCommandCatalog, SLASH_COMMANDS } from "./commands.js";
36
38
  import { effectiveMethods } from "../config.js";
@@ -41,7 +43,9 @@ import { storeAgentChange } from "./helpers.js";
41
43
  import { registerCoreOwnershipParticipant } from "./ownership.js";
42
44
  import { createHostBridge } from "./host-bridge.js";
43
45
  import { registerJobsOwnershipParticipant } from "../jobs/transfer.js";
46
+ import { registerAdminRoutes } from "./routes/admin.js";
44
47
  import { registerAuthRoutes } from "./routes/auth.js";
48
+ import { registerLoginRoutes } from "./routes/login.js";
45
49
  import { registerConversationRoutes } from "./routes/conversations.js";
46
50
  import { registerEventRoutes } from "./routes/events.js";
47
51
  import { registerExtensionRoutes as registerExtensionSettingsRoutes } from "./routes/extensions.js";
@@ -50,17 +54,26 @@ import { registerExtAssetRoutes } from "./routes/ext-assets.js";
50
54
  import { registerFileRoutes } from "./routes/files.js";
51
55
  import { registerJobRoutes } from "./routes/jobs.js";
52
56
  import { registerInteractionRoutes } from "./routes/interactions.js";
57
+ import { registerMeRoutes } from "./routes/me.js";
53
58
  import { registerMetaRoutes } from "./routes/meta.js";
54
59
  import { registerMetricsRoutes } from "./routes/metrics.js";
55
60
  import { registerOnboardingRoutes } from "./routes/onboarding.js";
56
61
  import { registerProbeRoutes } from "./routes/probes.js";
57
62
  import { registerProviderRoutes } from "./routes/providers.js";
58
63
  import { registerSettingsRoutes } from "./routes/settings.js";
64
+ import { registerSettingsSectionsRoutes } from "./routes/settings-sections.js";
59
65
  import { registerStreamRoutes } from "./routes/stream.js";
60
66
  import { startNotifications } from "./notifications.js";
67
+ import { selfPackageRoot } from "../harness/packages.js";
61
68
  import { registerSubmissionRoutes } from "./routes/submissions.js";
62
69
  import { registerSurfaceRoutes } from "./routes/surfaces.js";
70
+ import { subscribeChatSurfaceChanged } from "../harness/blocks/tools.js";
71
+ import { loadOrCreateKey, signUrl } from "./images/sign.js";
72
+ import { ImageCache } from "./images/cache.js";
73
+ import { registerImageRoutes } from "./routes/images.js";
63
74
  import { SurfaceService } from "./surfaces/service.js";
75
+ import { settingsSurfaceFor } from "./surfaces/settings-surfaces.js";
76
+ import { CORE_SETTINGS_SECTIONS } from "../settings/core-sections.js";
64
77
  import { registerUserRoutes } from "./routes/users.js";
65
78
  import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
66
79
  /**
@@ -79,8 +92,9 @@ import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
79
92
  * checks, the public surface, the env service token, the setup gate,
80
93
  * then the configured methods — the resolved principal stamps
81
94
  * `SessionMeta.owner` at create/fork/import.
82
- * @param options.listenHost the host the server will bind (default
83
- * `"0.0.0.0"`); the pipeline's Host check and the default method list
95
+ * @param options.listenHost the host the server will bind (fallback
96
+ * `"0.0.0.0"` when omitted; `serve()` passes the dual-stack `::`);
97
+ * the pipeline's Host check and the default method list
84
98
  * (`effectiveMethods`) both derive from it.
85
99
  * @param contributedBundles the extension-contributed webui-bundle registry
86
100
  * (build-and-webui Task 6 threads it here from boot; the serve-time
@@ -93,7 +107,17 @@ import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
93
107
  * env-less boot).
94
108
  */
95
109
  export async function createServer(opened, config, contributedBundles = [], options) {
96
- const app = Fastify({ logger: false });
110
+ const app = Fastify({
111
+ logger: false,
112
+ // U7 Task 3: the image route's GET `src=data:` form carries up to
113
+ // 192 KiB of base64 in the query string, which counts toward
114
+ // Node's 16 KiB default header cap and would be 431-rejected at
115
+ // the HTTP layer before the route's own §11.1 413 could answer.
116
+ // 256 KiB admits the cap-sized URL; the route enforces the real
117
+ // limit (192 KiB) itself. The POST form (the body path) stays the
118
+ // recommended shape for long data URLs.
119
+ http: { maxHeaderSize: 256 * 1024 },
120
+ });
97
121
  const listenHost = options?.listenHost ?? "0.0.0.0";
98
122
  const env = options?.env ?? {};
99
123
  // The one body-limit error shape (spec §6.2, W2): Fastify's
@@ -162,13 +186,38 @@ export async function createServer(opened, config, contributedBundles = [], opti
162
186
  // effective method list), and the boot env (the settings routes'
163
187
  // `effective` block reads it).
164
188
  const sse = new SseRegistry();
189
+ // The chat-surface seam's unsubscribe (filled in just below, after
190
+ // the events hub exists to publish onto; never reassigned after).
191
+ let unsubscribeChatSurfaceChanged = () => { };
165
192
  // Decorated for the shutdown path (serve.ts): fastify's own drain waits
166
193
  // on in-flight requests, so the SSE registry must be endable from the
167
194
  // OUTSIDE before app.close() — a decoration is the typed, discoverable
168
195
  // way to hand that handle up without widening ServerContext's consumers.
169
196
  app.decorate("sse", sse);
197
+ // The image-proxy signer (U7 Task 1, spec §11.2): the key is loaded
198
+ // (created at first boot) from the state dir — deleting the file
199
+ // rotates it — and every surface tree/data answer rewrites https
200
+ // image sources through it at serve time.
201
+ const imageKey = await loadOrCreateKey(config.stateDir);
202
+ const imageSign = (u) => signUrl(imageKey, u, Date.now());
203
+ // The proxy cache + fetch deps (U7 Task 3, spec §11.2): the on-disk
204
+ // LRU under `<cacheDir>/images/`; the deps are the real resolver +
205
+ // clock unless a test injects its own.
206
+ const imageCache = new ImageCache(config.cacheDir);
207
+ const imageDeps = {
208
+ resolve: async (host) => {
209
+ const { lookup } = await import("node:dns/promises");
210
+ const addresses = await lookup(host, { all: true, verbatim: true });
211
+ return addresses.map((entry) => entry.address);
212
+ },
213
+ now: Date.now,
214
+ ...options?.imageFetch,
215
+ };
170
216
  const surfacesService = new SurfaceService(opened.extensionSurfaces, {
171
217
  maxUploadBytes: config.maxUploadBytes,
218
+ // Signs https image URLs into expiring proxy URLs (U7 Task 1,
219
+ // spec §11.2) on every tree/data response.
220
+ imageSign,
172
221
  // Task 4: bumpRev (actions AND the host seam's changed()) fans the
173
222
  // surface.changed event out to the hub; the events route's
174
223
  // visibility case delivers it to watching streams only.
@@ -180,21 +229,90 @@ export async function createServer(opened, config, contributedBundles = [], opti
180
229
  opened.events.publish(event);
181
230
  },
182
231
  });
232
+ // The settings sections (UI-blocks U4 Task 3, spec §13.3): each
233
+ // declared section adapts to a settings-placement surface served under
234
+ // the declaring extension's own `/v1/ext/<slug>/surfaces/` mount. The
235
+ // save handlers close over the service (the optimistic-concurrency
236
+ // rev read), so the adaptation attaches AFTER the service exists.
237
+ surfacesService.attachSettingsSurfaces(opened.extensionSettingsSections.map((binding) => ({
238
+ extension: binding.extension,
239
+ defs: binding.defs.map((section) => settingsSurfaceFor(binding.extension, section, {
240
+ stores: opened.extensionStores,
241
+ publishSettingsChanged: opened.publishSettingsChanged,
242
+ service: surfacesService,
243
+ })),
244
+ })));
245
+ // The core settings sections (U4 Task 4, spec §13.4): the same
246
+ // renderer under the reserved `core` slug — core and extensions run
247
+ // ONE code path. The core saves write the core SettingsStore (dotted
248
+ // names land at their nested paths, apply-typed), not the extension
249
+ // settings dir.
250
+ surfacesService.attachSettingsSurfaces([
251
+ {
252
+ extension: "core",
253
+ defs: CORE_SETTINGS_SECTIONS.map((section) => settingsSurfaceFor("core", section, {
254
+ stores: opened.extensionStores,
255
+ publishSettingsChanged: opened.publishSettingsChanged,
256
+ service: surfacesService,
257
+ core: {
258
+ settings: opened.settings,
259
+ bootSnapshot: opened.settingsBootSnapshot,
260
+ env,
261
+ // The Tools section's default-tools options (the boot
262
+ // registry's tool names, deduped — the roster the
263
+ // checkbox list edits against).
264
+ toolRoster: [
265
+ ...new Set(opened.registry
266
+ .snapshot()
267
+ .tools()
268
+ .map((entry) => entry.tool.name)),
269
+ ],
270
+ },
271
+ })),
272
+ },
273
+ ]);
183
274
  app.decorate("surfaces", surfacesService);
275
+ // The chat-block change seam (UI-blocks U5 Task 2, spec §8.2):
276
+ // show_blocks/update_blocks AND the user-edit route publish
277
+ // `surface.changed` through the Task-1 in-process seam; binding it
278
+ // here forwards every event onto the hub, where the events route's
279
+ // exhaustive switch gives the CHAT shape the conversation-scoped
280
+ // delivery path (SessionMeta re-snapshot + accessOf per event).
281
+ unsubscribeChatSurfaceChanged = subscribeChatSurfaceChanged((event) => {
282
+ // SAFETY: the chat event IS the wire union's second surface.changed
283
+ // member (same declaration, re-exported through the seam type) —
284
+ // the publish types on the union the events switch discriminates.
285
+ opened.events.publish(event);
286
+ });
287
+ const roots = opened.extensionStores.roots;
184
288
  const ctx = {
185
289
  opened,
186
290
  config,
291
+ managedPaths: {
292
+ roots,
293
+ storagePath: config.storagePath,
294
+ coreRoot: selfPackageRoot() ?? process.cwd(),
295
+ userAreas: (user) => join(roots.dataDir, "users", user),
296
+ },
187
297
  rootId,
188
298
  webuiBundle,
189
299
  commandCatalog,
190
300
  listenHost,
191
301
  methods,
192
302
  env,
303
+ // U4 §13.2: the harness's settings-changed fan-out (hub publish +
304
+ // extension onSettingsChanged + the scheduler re-arm).
305
+ publishSettingsChanged: opened.publishSettingsChanged,
193
306
  watchersByConversation: new Map(),
194
307
  surfaces: surfacesService,
195
308
  sse,
309
+ imageProxy: { cache: imageCache, deps: imageDeps, key: imageKey },
196
310
  ...(options?.gateway === undefined ? {} : { gateway: options.gateway }),
311
+ ...(options?.onRestart === undefined
312
+ ? {}
313
+ : { requestRestart: options.onRestart }),
197
314
  };
315
+ app.decorate("sigrunContext", ctx);
198
316
  // The host bridge (W5 Task 5, spec §9.5/§9.7): ONE bridge over this
199
317
  // server's context, bound onto every seam host — the extension-facing
200
318
  // conversations/users/access/identity surface then runs the SAME
@@ -202,7 +320,11 @@ export async function createServer(opened, config, contributedBundles = [], opti
202
320
  // implementation). Bound here, after the context exists and before any
203
321
  // route or extension code can serve a request; calls issued earlier
204
322
  // (a factory body) have been waiting on exactly this assignment.
205
- opened.bindHostBridge(createHostBridge(ctx));
323
+ const hostBridge = createHostBridge(ctx);
324
+ opened.bindHostBridge(hostBridge);
325
+ // Decorated like `sse`/`surfaces`: the bridge is the server-owned
326
+ // handle tests (and only tests) reach the W5 bridge path through.
327
+ app.decorate("hostBridge", hostBridge);
206
328
  // The notifications surface (W5 Task 6, spec §9.6): one hub
207
329
  // subscription turning host events into per-recipient
208
330
  // NotificationEvents for every collected notifier contribution,
@@ -217,10 +339,16 @@ export async function createServer(opened, config, contributedBundles = [], opti
217
339
  // `request.principal` before any route runs.
218
340
  registerAuthPipeline(app, ctx);
219
341
  registerAuthRoutes(app, ctx);
342
+ registerLoginRoutes(app, ctx, {
343
+ ...(options?.loginResolver === undefined ? {} : { resolver: options.loginResolver }),
344
+ ...(options?.loginNow === undefined ? {} : { flows: new LoginFlows(options.loginNow) }),
345
+ });
220
346
  registerOnboardingRoutes(app, ctx);
221
347
  registerUserRoutes(app, ctx);
348
+ registerMeRoutes(app, ctx);
222
349
  registerProbeRoutes(app, ctx);
223
350
  registerMetaRoutes(app, ctx);
351
+ registerAdminRoutes(app, ctx);
224
352
  registerMetricsRoutes(app, ctx);
225
353
  registerConversationRoutes(app, ctx);
226
354
  registerStreamRoutes(app, ctx);
@@ -230,6 +358,7 @@ export async function createServer(opened, config, contributedBundles = [], opti
230
358
  registerFileRoutes(app, ctx);
231
359
  registerProviderRoutes(app, ctx);
232
360
  registerSettingsRoutes(app, ctx);
361
+ registerSettingsSectionsRoutes(app, ctx);
233
362
  registerExtensionSettingsRoutes(app, ctx);
234
363
  // The extension HTTP routes (W5 Task 2, spec §9.2): every collected
235
364
  // `routes` contribution mounted at `/v1/ext/<slug>/<path>` behind the
@@ -244,6 +373,10 @@ export async function createServer(opened, config, contributedBundles = [], opti
244
373
  // `/v1/ext/<slug>/assets/*` serves the package's declared
245
374
  // `sigrun.assets` dir, realpath-contained, sniffed content types.
246
375
  registerExtAssetRoutes(app, ctx);
376
+ // The image-proxy routes (UI-blocks U7 Task 3, spec §11.2): signed
377
+ // remote fetches through the on-disk LRU, the `src=` asset/data form,
378
+ // and the POST long-data-URL form.
379
+ registerImageRoutes(app, ctx);
247
380
  // The jobs routes (W4 Task 4, spec §8.4): the access-scoped CRUD +
248
381
  // run-now + history surface over `OpenedHarness.jobs` (Task 3's
249
382
  // service). Hidden-is-404 per §5.9; `file:`/`ext:` ids are immutable
@@ -263,5 +396,12 @@ export async function createServer(opened, config, contributedBundles = [], opti
263
396
  // next boot's reconciliation warns (the declared owner no longer
264
397
  // exists — the job runs ownerless until the file is edited).
265
398
  registerJobsOwnershipParticipant(ctx);
399
+ // The chat-surface seam's teardown: the subscription lives for the
400
+ // app's lifetime (the module seam is process-global), but the
401
+ // unsubscribe is kept so a future embedder (a second createServer
402
+ // over one harness in tests) can detach a bound server cleanly.
403
+ app.addHook("onClose", async () => {
404
+ unsubscribeChatSurfaceChanged();
405
+ });
266
406
  return app;
267
407
  }
@@ -0,0 +1,126 @@
1
+ import { randomBytes } from "node:crypto";
2
+ const FLOW_TTL_MS = 600_000;
3
+ /** Generous per-server ceiling; oldest entries are evicted under pressure. */
4
+ export const MAX_ACTIVE_LOGIN_FLOWS = 10_000;
5
+ /** One short-lived random browser/state credential. */
6
+ function key() {
7
+ return randomBytes(32).toString("base64url");
8
+ }
9
+ /**
10
+ * In-memory, per-server login flows. `take` is atomic and single-use: a
11
+ * replay, a different browser key, or an expired entry returns undefined.
12
+ */
13
+ export class LoginFlows {
14
+ #now;
15
+ #flows = new Map();
16
+ #consumed = new Map();
17
+ #pending = new Map();
18
+ constructor(now = Date.now) {
19
+ this.#now = now;
20
+ }
21
+ get size() {
22
+ return this.#flows.size;
23
+ }
24
+ get consumedSize() {
25
+ return this.#consumed.size;
26
+ }
27
+ start(provider, opts) {
28
+ this.#prune();
29
+ const state = key();
30
+ const browserKey = key();
31
+ const flow = {
32
+ provider,
33
+ state,
34
+ callbackUrl: "",
35
+ data: {},
36
+ returnTo: opts.returnTo,
37
+ ...(opts.link === undefined ? {} : { link: opts.link }),
38
+ browserKey,
39
+ expiresAt: this.#now() + FLOW_TTL_MS,
40
+ };
41
+ this.#flows.set(state, flow);
42
+ this.#evictOldest(this.#flows);
43
+ return flow;
44
+ }
45
+ /** Save provider scratch data and the callback URL after `begin` succeeds. */
46
+ save(state, flow) {
47
+ const stored = this.#flows.get(state);
48
+ if (stored === undefined)
49
+ return;
50
+ stored.callbackUrl = flow.callbackUrl;
51
+ stored.data = flow.data;
52
+ }
53
+ take(state, browserKey) {
54
+ this.#prune();
55
+ const flow = this.#flows.get(state);
56
+ if (flow === undefined || flow.browserKey !== browserKey)
57
+ return undefined;
58
+ this.#flows.delete(state);
59
+ this.#consumed.set(state, {
60
+ browserKey,
61
+ expiresAt: flow.expiresAt,
62
+ });
63
+ this.#evictOldest(this.#consumed);
64
+ return flow;
65
+ }
66
+ /** Park a verified proof for ten more minutes under a separate, single-use key. */
67
+ park(flow, result) {
68
+ this.#prune();
69
+ const pendingKey = key();
70
+ const pending = {
71
+ provider: flow.provider,
72
+ result,
73
+ returnTo: flow.returnTo,
74
+ expiresAt: this.#now() + FLOW_TTL_MS,
75
+ };
76
+ this.#pending.set(pendingKey, pending);
77
+ this.#evictOldest(this.#pending);
78
+ return { key: pendingKey, pending };
79
+ }
80
+ /** Put a pending proof back after a rejected code, preserving its original expiry. */
81
+ restorePending(pendingKey, pending) {
82
+ this.#prune();
83
+ this.#pending.set(pendingKey, pending);
84
+ this.#evictOldest(this.#pending);
85
+ }
86
+ /** Consume a code-resume key atomically; replay/expiry/restart all fail closed. */
87
+ takePending(pendingKey) {
88
+ this.#prune();
89
+ const pending = this.#pending.get(pendingKey);
90
+ if (pending !== undefined)
91
+ this.#pending.delete(pendingKey);
92
+ return pending;
93
+ }
94
+ /** Explain a failed take without exposing flow data (replay vs expired/unknown). */
95
+ failure(state, browserKey) {
96
+ this.#prune();
97
+ if (this.#flows.has(state))
98
+ return "invalid_state";
99
+ return this.#consumed.get(state)?.browserKey === browserKey
100
+ ? "invalid_state"
101
+ : "login_expired";
102
+ }
103
+ #evictOldest(entries) {
104
+ while (entries.size > MAX_ACTIVE_LOGIN_FLOWS) {
105
+ const oldest = entries.keys().next();
106
+ if (oldest.done === true)
107
+ break;
108
+ entries.delete(oldest.value);
109
+ }
110
+ }
111
+ #prune() {
112
+ const now = this.#now();
113
+ for (const [state, flow] of this.#flows) {
114
+ if (flow.expiresAt <= now)
115
+ this.#flows.delete(state);
116
+ }
117
+ for (const [state, consumed] of this.#consumed) {
118
+ if (consumed.expiresAt <= now)
119
+ this.#consumed.delete(state);
120
+ }
121
+ for (const [pendingKey, pending] of this.#pending) {
122
+ if (pending.expiresAt <= now)
123
+ this.#pending.delete(pendingKey);
124
+ }
125
+ }
126
+ }