@sigrun/sigrun 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +89 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Extension HTTP routes (core hardening W5 Task 2, spec §9.2): every
3
+ * `routes` contribution a loaded extension declared, mounted at
4
+ * `/v1/ext/<slug>/<path>` behind a web-standard Request/Response
5
+ * adapter — no Fastify type crosses the extension contract.
6
+ *
7
+ * Mount shape: each extension's routes register inside ONE ENCAPSULATED
8
+ * Fastify plugin scope that parses EVERY content type as a raw buffer —
9
+ * the built-in JSON/text/plain parsers AND the app-level text/plain
10
+ * parser are removed inside the scope, then a `"*"` catch-all buffer
11
+ * parser covers every content type — so the app's core routes keep
12
+ * their own parsers (encapsulation contains the override) and the
13
+ * adapter always sees the raw bytes. `bodyLimit` rides the route
14
+ * options (default 1 MiB, spec §9.2); Fastify's
15
+ * `FST_ERR_CTP_BODY_TOO_LARGE` maps to `413 too_large` through the app
16
+ * error handler.
17
+ *
18
+ * Auth (the §5 pipeline plus the route's declared `auth`):
19
+ * - `public` — the pipeline skips stage 5 (authentication) ONLY: stages
20
+ * 1 (Host/Origin) and 4 (the unclaimed setup gate) still ran, and the
21
+ * handler verifies its own caller (a webhook signature, an OAuth
22
+ * `state`). No principal is attached.
23
+ * - `user` — the pipeline authenticates like every other `/v1` route.
24
+ * - `admin` — authenticated AND `principal.admin`, else 403.
25
+ *
26
+ * The adapter builds `new Request(absoluteUrl, {method, headers,
27
+ * body})` — `absoluteUrl` is `server.publicUrl` (`SIGRUN_PUBLIC_URL`)
28
+ * when set, else `http://<Host header>` — and writes the returned
29
+ * `Response`'s status, headers, and body back to the reply. A handler
30
+ * that THROWS or returns a non-`Response` maps to
31
+ * `500 {error: "extension_route_failed", extension}` (logged; the
32
+ * process never crashes). The handler's `Request` body may be read ONCE
33
+ * per the web standard (a second `text()` rejects "Body is unusable");
34
+ * the adapter hands it a fresh `Request` over the buffered body and
35
+ * needs no handling for that.
36
+ */
37
+ /** The default ext-route body ceiling (spec §9.2): 1 MiB. */
38
+ export const DEFAULT_EXT_ROUTE_BODY_LIMIT = 1_048_576;
39
+ /** Headers the reply owns — forwarding them would fight Fastify's framing. */
40
+ const HOP_BY_HOP = new Set(["transfer-encoding", "connection"]);
41
+ /**
42
+ * Mount every collected extension route. Called from `createServer`
43
+ * AFTER the auth pipeline (the root-level onRequest hook applies to the
44
+ * encapsulated scopes' routes too); a boot with no route bindings
45
+ * registers nothing.
46
+ */
47
+ export function registerExtensionRoutes(app, ctx) {
48
+ const { opened, config } = ctx;
49
+ for (const binding of opened.extensionRoutes) {
50
+ // The handler context's `host` is the extension's seam host — the
51
+ // contract's `SigrunHost` seam surface. The conversation/identity/
52
+ // access members land with the host bridge (W5 Task 5 extends
53
+ // SeamHost to the full contract shape); the cast spans that gap
54
+ // without widening this task, and routes that never touch the
55
+ // not-yet-bridged members are unaffected.
56
+ const host = opened.seamHosts.get(binding.extension);
57
+ app.register(async (scope) => {
58
+ // Raw bodies for this extension's routes ONLY: the encapsulated
59
+ // scope drops the inherited parsers (the built-ins and whatever
60
+ // the app registered) and installs one catch-all buffer parser —
61
+ // `addContentTypeParser("*")` alone would NOT override them, and
62
+ // a JSON webhook body would arrive pre-parsed.
63
+ scope.removeContentTypeParser("application/json");
64
+ scope.removeContentTypeParser("text/plain");
65
+ scope.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => done(null, body));
66
+ for (const route of binding.routes) {
67
+ scope.route({
68
+ method: route.method,
69
+ url: `/v1/ext/${binding.extension}/${route.path}`,
70
+ bodyLimit: route.bodyLimit ?? DEFAULT_EXT_ROUTE_BODY_LIMIT,
71
+ // The auth pipeline's stage-4b read (public skips stage 5).
72
+ config: { extAuth: route.auth },
73
+ handler: async (request, reply) => {
74
+ if (route.auth === "admin" && request.principal?.admin !== true) {
75
+ return await reply.code(403).send({ error: "forbidden" });
76
+ }
77
+ try {
78
+ const base = config.publicUrl ??
79
+ `http://${request.headers.host ?? "localhost"}`;
80
+ const incoming = new Request(`${base}${request.url}`, {
81
+ method: request.method,
82
+ headers: request.headers,
83
+ // The scope's catch-all parser hands the raw bytes; unknown
84
+ // at the type level (Fastify cannot know the parser shape),
85
+ // and undici accepts a Buffer as BodyInit at runtime.
86
+ ...(request.body === undefined
87
+ ? {}
88
+ : { body: request.body }),
89
+ });
90
+ const response = await route.handler(incoming, {
91
+ ...(request.principal === undefined
92
+ ? {}
93
+ : { principal: request.principal }),
94
+ host: host,
95
+ });
96
+ if (!(response instanceof Response)) {
97
+ throw new Error(`returned a non-Response (${typeof response})`);
98
+ }
99
+ reply.code(response.status);
100
+ for (const [name, value] of response.headers) {
101
+ if (HOP_BY_HOP.has(name))
102
+ continue;
103
+ reply.header(name, value);
104
+ }
105
+ // set-cookie is the one multi-valued header the
106
+ // spec's iterator joins (information loss); serve
107
+ // each cookie as its own header.
108
+ for (const cookie of response.headers.getSetCookie()) {
109
+ reply.header("set-cookie", cookie);
110
+ }
111
+ const body = Buffer.from(await response.arrayBuffer());
112
+ return await reply.send(body.length > 0 ? body : null);
113
+ }
114
+ catch (cause) {
115
+ const mounted = `/v1/ext/${binding.extension}/${route.path}`;
116
+ console.warn(`extension route failed: ${binding.extension} ${request.method} ${mounted}: ${cause instanceof Error ? cause.message : String(cause)}`);
117
+ return await reply.code(500).send({
118
+ error: "extension_route_failed",
119
+ extension: binding.extension,
120
+ });
121
+ }
122
+ },
123
+ });
124
+ }
125
+ });
126
+ }
127
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Extension settings/secrets routes (core hardening W3 Task 7, spec
3
+ * §7.7): the operator surface over the loaded extension set.
4
+ *
5
+ * - `GET /v1/extensions` — one entry per loaded outcome with `status`,
6
+ * `error?`, the manifest `version?`, the declared
7
+ * `interactionKinds`/`ambientKeys`, and the idle-worker projection.
8
+ * - `GET`/`PUT /v1/extensions/:slug/settings` (PUT admin) — the
9
+ * `settings/<slug>.json` object verbatim. Values an extension reads at
10
+ * factory time apply at RESTART; per-run reads see the write live.
11
+ * - `GET /v1/extensions/:slug/secrets` — `{secrets: [{key, source}]}`:
12
+ * stored keys plus the `SIGRUN_EXT_<SLUG>_` env fallbacks that are not
13
+ * stored. VALUES ARE NEVER RETURNED by any route here — the API lists
14
+ * and writes secrets, never reads them back.
15
+ * - `PUT`/`DELETE /v1/extensions/:slug/secrets/:key` (admin).
16
+ *
17
+ * Every `:slug` route 404s `extension_not_found` when the slug is not
18
+ * among the loaded outcomes (the same list `GET /v1/meta` reports).
19
+ */
20
+ import { BUILTIN_EXTENSIONS } from "../../harness/builtins.js";
21
+ /** Register the extension settings/secrets routes. */
22
+ export function registerExtensionRoutes(app, ctx) {
23
+ const { opened } = ctx;
24
+ // The loaded-outcome lookup every :slug route gates on: ok outcomes
25
+ // carry the slug; quarantined outcomes carry the display specifier —
26
+ // both are "among the loaded outcomes" for the 404 rule.
27
+ const isKnownSlug = (slug) => opened.extensionOutcomes.some((outcome) => outcome.name === slug);
28
+ // The inventory: outcomes + declared kinds + workers + routes, one
29
+ // entry per configured extension, in load order. Pure synchronous
30
+ // derivations over boot-retained state (the /v1/meta discipline) — a
31
+ // client may cache it per boot. Built-in extensions (Task 2) carry
32
+ // `builtin: true` from their outcomes; a REPLACED built-in (a
33
+ // configured extension resolved to the same package name) appends a
34
+ // row here — `builtin: true`, `replacedBy` the configured specifier,
35
+ // status "replaced" — while a DISABLED one is absent entirely
36
+ // (ruling 6).
37
+ app.get("/v1/extensions", async () => {
38
+ const workerStatuses = opened.workerStatuses();
39
+ const extensions = opened.extensionOutcomes.map((outcome) => {
40
+ const kinds = opened.seamKinds.get(outcome.name);
41
+ const workers = workerStatuses.get(outcome.name);
42
+ const routes = opened.extensionRoutes.find((binding) => binding.extension === outcome.name);
43
+ return {
44
+ name: outcome.name,
45
+ status: outcome.status,
46
+ ...(outcome.error === undefined ? {} : { error: outcome.error }),
47
+ ...(outcome.version === undefined
48
+ ? {}
49
+ : { version: outcome.version }),
50
+ ...(outcome.builtin === true ? { builtin: true } : {}),
51
+ interactionKinds: [...(kinds?.interaction ?? [])],
52
+ ambientKeys: [...(kinds?.ambient ?? [])],
53
+ workers: workers ?? [],
54
+ routes: routes?.routes.map((route) => ({
55
+ method: route.method,
56
+ path: route.path,
57
+ auth: route.auth,
58
+ })) ?? [],
59
+ };
60
+ });
61
+ // Replaced built-ins (Task 2 ruling 5): one row each, under the slug
62
+ // the built-in itself would have loaded under (`BUILTIN_EXTENSIONS
63
+ // .slug` — the declared extension name's slug, e.g. `sigrun-mcp` for
64
+ // the package `@sigrun/ext-mcp`), appended after the loaded set.
65
+ for (const skipped of opened.builtinSkipped) {
66
+ if (skipped.reason !== "replaced")
67
+ continue;
68
+ const slug = BUILTIN_EXTENSIONS.find((builtin) => builtin.key === skipped.key)?.slug;
69
+ if (slug === undefined)
70
+ continue;
71
+ extensions.push({
72
+ name: slug,
73
+ status: "replaced",
74
+ builtin: true,
75
+ ...(skipped.by === undefined ? {} : { replacedBy: skipped.by }),
76
+ interactionKinds: [],
77
+ ambientKeys: [],
78
+ workers: [],
79
+ routes: [],
80
+ });
81
+ }
82
+ return { extensions };
83
+ });
84
+ // The per-extension settings object (`settings/<slug>.json`), absent
85
+ // file → `{}`. GET is ungated beyond the pipeline: settings values are
86
+ // operator-facing config, not secrets.
87
+ app.get("/v1/extensions/:slug/settings", async (request, reply) => {
88
+ const { slug } = request.params;
89
+ if (!isKnownSlug(slug)) {
90
+ return await reply.code(404).send({ error: "extension_not_found" });
91
+ }
92
+ return opened.extensionStores.settingsFor(slug).get();
93
+ });
94
+ // Replace the settings object VERBATIM (admin — a settings file is
95
+ // code-adjacent input the extension consumes). The body must be a
96
+ // JSON object; arrays/scalars are 400 (the PATCH /v1/settings
97
+ // vocabulary). The write lands live for per-run reads; a
98
+ // factory-time reader applies it at restart.
99
+ app.put("/v1/extensions/:slug/settings", async (request, reply) => {
100
+ if (!request.principal.admin) {
101
+ return await reply.code(403).send({ error: "forbidden" });
102
+ }
103
+ const { slug } = request.params;
104
+ if (!isKnownSlug(slug)) {
105
+ return await reply.code(404).send({ error: "extension_not_found" });
106
+ }
107
+ const body = request.body;
108
+ if (typeof body !== "object" ||
109
+ body === null ||
110
+ Array.isArray(body)) {
111
+ return await reply.code(400).send({ error: "invalid_body" });
112
+ }
113
+ const value = body;
114
+ await opened.extensionStores.settingsFor(slug).set(value);
115
+ return value;
116
+ });
117
+ // The secret key list: stored keys plus the env fallbacks that are not
118
+ // stored. Key names and sources only — the response never carries a
119
+ // value, and the store surface has no value-returning read at all.
120
+ app.get("/v1/extensions/:slug/secrets", async (request, reply) => {
121
+ const { slug } = request.params;
122
+ if (!isKnownSlug(slug)) {
123
+ return await reply.code(404).send({ error: "extension_not_found" });
124
+ }
125
+ return { secrets: await opened.extensionStores.secretKeys(slug) };
126
+ });
127
+ // Set one secret (admin): `{value: string}` — a non-empty string,
128
+ // nothing else (errors name the field, never the value). Returns the
129
+ // updated key list so the UI refreshes in one round-trip. The stored
130
+ // write takes precedence over any env twin from the next read on —
131
+ // the same `resolveSecretFor` rule the listing reports.
132
+ app.put("/v1/extensions/:slug/secrets/:key", async (request, reply) => {
133
+ if (!request.principal.admin) {
134
+ return await reply.code(403).send({ error: "forbidden" });
135
+ }
136
+ const { slug, key } = request.params;
137
+ if (!isKnownSlug(slug)) {
138
+ return await reply.code(404).send({ error: "extension_not_found" });
139
+ }
140
+ const body = request.body;
141
+ if (typeof body !== "object" ||
142
+ body === null ||
143
+ Array.isArray(body) ||
144
+ typeof body.value !== "string" ||
145
+ body.value === "") {
146
+ return await reply
147
+ .code(400)
148
+ .send({ error: "invalid_field", field: "value" });
149
+ }
150
+ const { value } = body;
151
+ await opened.extensionStores.setSecret(slug, key, value);
152
+ return { secrets: await opened.extensionStores.secretKeys(slug) };
153
+ });
154
+ // Delete one secret (admin): an idempotent section-key removal —
155
+ // deleting an absent key is a no-op success, and an env-sourced key
156
+ // has nothing stored to delete (the env var is the deploy's).
157
+ app.delete("/v1/extensions/:slug/secrets/:key", async (request, reply) => {
158
+ if (!request.principal.admin) {
159
+ return await reply.code(403).send({ error: "forbidden" });
160
+ }
161
+ const { slug, key } = request.params;
162
+ if (!isKnownSlug(slug)) {
163
+ return await reply.code(404).send({ error: "extension_not_found" });
164
+ }
165
+ await opened.extensionStores.deleteSecret(slug, key);
166
+ return { secrets: await opened.extensionStores.secretKeys(slug) };
167
+ });
168
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Workspace file routes: upload — the raw §6.2 PUT (octet-stream bytes,
3
+ * `server.maxUploadBytes`-limited) plus the base64 POST it replaces in
4
+ * one release — the read-only directory listing, and file content (text
5
+ * when strictly valid UTF-8, else base64; size-capped). Every route
6
+ * resolves its conversation through the `loadConversation` pre-handler
7
+ * (404 for an unknown id) and reads the workspace through
8
+ * `conversationEnv` — one seam, so a workspace-service swap moves them
9
+ * all at once.
10
+ */
11
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
12
+ import { extname } from "node:path";
13
+ import { loadConversation } from "../conversation-handler.js";
14
+ import { isConfinedPath } from "../content-parts.js";
15
+ import { base64BodyLimit } from "../../config.js";
16
+ import { ID_PARAMS_SCHEMA, FILE_CONTENT_MAX_BYTES, conversationEnv, decodeBase64Strict, } from "../helpers.js";
17
+ /**
18
+ * The one upload-name rule, shared by the base64 POST body's `name` and
19
+ * the raw PUT's `:name` param: no `/` or `\\`, not `.` or `..`, non-empty.
20
+ * The PUT checks it AFTER Fastify decodes the percent-encoded param
21
+ * (`..%2Fx` arrives as `../x`, `%2e%2e` as `..`) — the check runs on the
22
+ * decoded value, never the raw path text.
23
+ */
24
+ function validUploadName(name) {
25
+ return (name.length > 0 &&
26
+ !name.includes("/") &&
27
+ !name.includes("\\") &&
28
+ name !== "." &&
29
+ name !== "..");
30
+ }
31
+ /** The `:id` + `:name` params of the raw upload route. */
32
+ const UPLOAD_PARAMS_SCHEMA = {
33
+ type: "object",
34
+ properties: { id: { type: "integer" }, name: { type: "string" } },
35
+ required: ["id", "name"],
36
+ additionalProperties: false,
37
+ };
38
+ /**
39
+ * The extension→mime map for the raw file read (spec §7.8): the common
40
+ * workspace-file extensions a client renders inline. A file whose
41
+ * extension is absent from the map serves `application/octet-stream`.
42
+ * The KEY is the LOWERCASE extension including the dot (the lookup
43
+ * lowercases, so `Photo.PNG` maps too).
44
+ */
45
+ const MIME_BY_EXTENSION = new Map([
46
+ [".png", "image/png"],
47
+ [".jpg", "image/jpeg"],
48
+ [".jpeg", "image/jpeg"],
49
+ [".gif", "image/gif"],
50
+ [".webp", "image/webp"],
51
+ [".svg", "image/svg+xml"],
52
+ [".pdf", "application/pdf"],
53
+ [".txt", "text/plain"],
54
+ [".md", "text/markdown"],
55
+ [".json", "application/json"],
56
+ [".html", "text/html"],
57
+ [".css", "text/css"],
58
+ [".js", "text/javascript"],
59
+ ]);
60
+ /** The raw read's streaming chunk size (1 MiB): bounds reader memory. */
61
+ const RAW_READ_CHUNK_BYTES = 1024 * 1024;
62
+ /** Register the upload/listing/content workspace file routes. */
63
+ export function registerFileRoutes(app, ctx) {
64
+ const { opened, config } = ctx;
65
+ // Raw octet-stream bodies (the upload PUT route below): parse as a
66
+ // buffer, no interpretation. Registered once on the app — the route's
67
+ // own `bodyLimit` carries the knob.
68
+ app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_request, body, done) => done(null, body));
69
+ // Uploads (raw): the §6.2 route phone photos need — the body is the
70
+ // file's bytes, the name rides the param, and the route's `bodyLimit`
71
+ // is the `server.maxUploadBytes` knob verbatim (no base64 inflation).
72
+ // An absent content-type defaults to `application/octet-stream` via the
73
+ // route-scoped onRequest below (Fastify would otherwise 415 an untyped
74
+ // non-empty body); the default applies ONLY to this route.
75
+ app.put("/v1/conversations/:id/uploads/:name", {
76
+ schema: { params: UPLOAD_PARAMS_SCHEMA },
77
+ // Uploads mutate the conversation: write access (spec §6.2).
78
+ preHandler: loadConversation(ctx, "write"),
79
+ bodyLimit: config.maxUploadBytes,
80
+ onRequest: async (request) => {
81
+ if (request.headers["content-type"] === undefined) {
82
+ request.headers["content-type"] = "application/octet-stream";
83
+ }
84
+ },
85
+ }, async (request, reply) => {
86
+ const conversation = request.conversation;
87
+ const { name } = request.params;
88
+ if (!validUploadName(name)) {
89
+ return await reply.code(400).send({ error: "invalid_body" });
90
+ }
91
+ // Fastify hands the parsed buffer (or nothing for a truly empty
92
+ // body — an empty file is a valid upload).
93
+ const bytes = request.body === undefined
94
+ ? new Uint8Array(0)
95
+ : new Uint8Array(request.body);
96
+ // Same namespace as the base64 route: v1 namespaces uploads per
97
+ // conversation, and the returned path is exactly what a content
98
+ // part takes.
99
+ const path = `uploads/${conversation.id}/${name}`;
100
+ const env = await conversationEnv(opened, conversation);
101
+ const written = await env.writeFile(path, bytes, BACKGROUND_CONTEXT);
102
+ if (!written.ok) {
103
+ // A failed upload is a server-side fault: fail loud.
104
+ throw written.error;
105
+ }
106
+ return await reply.code(201).send({ path });
107
+ });
108
+ // Uploads (base64, one more release): materialize a base64 body into
109
+ // the conversation's upload namespace via the workspace env (spec §5:
110
+ // correct for remote workspaces). The `bodyLimit` is the knob-derived
111
+ // ceiling — the base64 inflation of the capped content plus the JSON
112
+ // wrapper (spec §6.2).
113
+ app.post("/v1/conversations/:id/uploads", {
114
+ schema: { params: ID_PARAMS_SCHEMA },
115
+ preHandler: loadConversation(ctx, "write"),
116
+ bodyLimit: base64BodyLimit(config.maxUploadBytes),
117
+ }, async (request, reply) => {
118
+ const conversation = request.conversation;
119
+ const body = request.body;
120
+ const name = body?.name;
121
+ const contentBase64 = body?.contentBase64;
122
+ if (body === undefined ||
123
+ typeof name !== "string" ||
124
+ !validUploadName(name) ||
125
+ typeof contentBase64 !== "string") {
126
+ return await reply.code(400).send({ error: "invalid_body" });
127
+ }
128
+ let bytes;
129
+ try {
130
+ bytes = decodeBase64Strict(contentBase64);
131
+ }
132
+ catch {
133
+ bytes = undefined;
134
+ }
135
+ if (bytes === undefined) {
136
+ return await reply.code(400).send({ error: "invalid_body" });
137
+ }
138
+ // v1 namespaces uploads per conversation: workspaces are static and
139
+ // shared in v1, so un-namespaced names would clobber across
140
+ // conversations. The returned path is exactly what a content part
141
+ // takes.
142
+ const path = `uploads/${conversation.id}/${name}`;
143
+ const env = await conversationEnv(opened, conversation);
144
+ const written = await env.writeFile(path, new Uint8Array(bytes), BACKGROUND_CONTEXT);
145
+ if (!written.ok) {
146
+ // A failed upload is a server-side fault: fail loud.
147
+ throw written.error;
148
+ }
149
+ return await reply.code(201).send({ path });
150
+ });
151
+ // Files: read-only workspace listing through the conversation env.
152
+ app.get("/v1/conversations/:id/files", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx) }, async (request, reply) => {
153
+ const conversation = request.conversation;
154
+ const query = request.query;
155
+ const path = query.path ?? ".";
156
+ if (!isConfinedPath(path)) {
157
+ return await reply.code(400).send({ error: "invalid_path" });
158
+ }
159
+ const env = await conversationEnv(opened, conversation);
160
+ const listed = await env.listDir(path, BACKGROUND_CONTEXT);
161
+ if (!listed.ok) {
162
+ if (listed.error.code === "not_found") {
163
+ return await reply.code(404).send({ error: "not_found" });
164
+ }
165
+ throw listed.error;
166
+ }
167
+ // pi-durable's listDir returns ABSOLUTE paths (resolve(cwd, name)),
168
+ // but the §5 files/content endpoint requires workspace-RELATIVE
169
+ // paths (isConfinedPath rejects absolute). Relativize each entry
170
+ // against the workspace root so the tree's paths feed back into the
171
+ // content endpoint (a listed file can be read).
172
+ const workspaceRoot = config.workspace.path;
173
+ const entries = listed.value.map((entry) => ({
174
+ ...entry,
175
+ path: entry.path.startsWith(`${workspaceRoot}/`)
176
+ ? entry.path.slice(workspaceRoot.length + 1)
177
+ : entry.path,
178
+ }));
179
+ return { entries };
180
+ });
181
+ // Files content: text when strictly valid UTF-8, else base64; size-capped.
182
+ app.get("/v1/conversations/:id/files/content", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx) }, async (request, reply) => {
183
+ const conversation = request.conversation;
184
+ const query = request.query;
185
+ const path = query.path ?? "";
186
+ if (!isConfinedPath(path)) {
187
+ return await reply.code(400).send({ error: "invalid_path" });
188
+ }
189
+ if (path.length === 0 || path === ".") {
190
+ return await reply.code(400).send({ error: "invalid_path" });
191
+ }
192
+ const env = await conversationEnv(opened, conversation);
193
+ const info = await env.fileInfo(path, BACKGROUND_CONTEXT);
194
+ if (!info.ok) {
195
+ if (info.error.code === "not_found") {
196
+ return await reply.code(404).send({ error: "not_found" });
197
+ }
198
+ throw info.error;
199
+ }
200
+ if (info.value.size > FILE_CONTENT_MAX_BYTES) {
201
+ return await reply.code(413).send({
202
+ error: "file_too_large",
203
+ maxBytes: FILE_CONTENT_MAX_BYTES,
204
+ });
205
+ }
206
+ const read = await env.readBinaryFile(path, BACKGROUND_CONTEXT);
207
+ if (!read.ok) {
208
+ if (read.error.code === "not_found") {
209
+ return await reply.code(404).send({ error: "not_found" });
210
+ }
211
+ throw read.error;
212
+ }
213
+ const bytes = read.value;
214
+ // Text when strictly valid UTF-8, else base64 — the same rule as
215
+ // file-part inlining, applied at the read boundary.
216
+ try {
217
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
218
+ return { encoding: "text", content: text, size: bytes.length };
219
+ }
220
+ catch {
221
+ return {
222
+ encoding: "base64",
223
+ content: Buffer.from(bytes).toString("base64"),
224
+ size: bytes.length,
225
+ };
226
+ }
227
+ });
228
+ // Files raw (spec §7.8, core hardening W3 Task 7): the bounded raw
229
+ // read the workspace file surface serves when a file is bigger (or
230
+ // binary-er) than files/content's 1 MiB text/base64 cap — phone photos
231
+ // a client wants to RENDER, export blobs, whatever the agent wrote.
232
+ // Semantics: `isConfinedPath` (the content route's rule — `../x` and
233
+ // absolute paths are 400 `invalid_path`); a directory is 400
234
+ // `invalid_path` (Review Focus 5: rejected explicitly, never streamed);
235
+ // over `server.maxFileReadBytes` is 413 `file_too_large` + `maxBytes`;
236
+ // otherwise CHUNKS from `env.openBinaryReader` (never `readBinaryFile`
237
+ // — the whole point is not buffering the file in memory) stream into
238
+ // `reply.raw` with the extension-derived `content-type` and an exact
239
+ // `content-length`. The symlink policy is unchanged from
240
+ // files/content: the default (follows-symlinks) reader.
241
+ app.get("/v1/conversations/:id/files/raw", {
242
+ schema: { params: ID_PARAMS_SCHEMA },
243
+ preHandler: loadConversation(ctx),
244
+ }, async (request, reply) => {
245
+ const conversation = request.conversation;
246
+ const query = request.query;
247
+ const path = query.path ?? "";
248
+ if (!isConfinedPath(path) || path.length === 0 || path === ".") {
249
+ return await reply.code(400).send({ error: "invalid_path" });
250
+ }
251
+ const env = await conversationEnv(opened, conversation);
252
+ const info = await env.fileInfo(path, BACKGROUND_CONTEXT);
253
+ if (!info.ok) {
254
+ if (info.error.code === "not_found") {
255
+ return await reply.code(404).send({ error: "not_found" });
256
+ }
257
+ throw info.error;
258
+ }
259
+ if (info.value.kind === "directory") {
260
+ return await reply.code(400).send({ error: "invalid_path" });
261
+ }
262
+ const maxBytes = config.maxFileReadBytes;
263
+ if (info.value.size > maxBytes) {
264
+ return await reply
265
+ .code(413)
266
+ .send({ error: "file_too_large", maxBytes });
267
+ }
268
+ const readerOpened = await env.openBinaryReader(path, undefined, BACKGROUND_CONTEXT);
269
+ if (!readerOpened.ok) {
270
+ if (readerOpened.error.code === "not_found") {
271
+ return await reply.code(404).send({ error: "not_found" });
272
+ }
273
+ throw readerOpened.error;
274
+ }
275
+ const reader = readerOpened.value;
276
+ // Hijack BEFORE the first write: every reply.send above ran on the
277
+ // normal path, but from here the response is raw bytes the JSON
278
+ // layer must not touch. All headers are known by now (size is).
279
+ reply.hijack();
280
+ const raw = reply.raw;
281
+ raw.on("error", () => { });
282
+ try {
283
+ raw.writeHead(200, {
284
+ "content-type": MIME_BY_EXTENSION.get(extname(path).toLowerCase()) ??
285
+ "application/octet-stream",
286
+ "content-length": String(info.value.size),
287
+ });
288
+ for (let offset = 0; offset < info.value.size; offset += RAW_READ_CHUNK_BYTES) {
289
+ const chunk = await reader.read(offset, Math.min(RAW_READ_CHUNK_BYTES, info.value.size - offset), BACKGROUND_CONTEXT);
290
+ if (!chunk.ok)
291
+ throw chunk.error;
292
+ raw.write(Buffer.from(chunk.value));
293
+ }
294
+ raw.end();
295
+ }
296
+ catch (error) {
297
+ // The raw 'error' handler above already swallows socket teardown;
298
+ // anything reaching THIS catch is a read-side failure mid-stream
299
+ // (headers already sent — the log is the honest record, the
300
+ // destroy the honest wire signal).
301
+ console.warn(`sigrun: files/raw ${path}: ${error instanceof Error ? error.message : String(error)}`);
302
+ raw.destroy();
303
+ }
304
+ finally {
305
+ await reader.close(BACKGROUND_CONTEXT).catch(() => { });
306
+ }
307
+ });
308
+ }