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