@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.
- package/CHANGELOG.md +214 -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 +89 -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,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resolved caller (core hardening W1 Task 3, spec §5.2/§5.8): what
|
|
3
|
+
* the auth pipeline stamps on every request and what `whoami` serves.
|
|
4
|
+
* `method` names the proof — the empty-method naive path ("none"), a
|
|
5
|
+
* verified token ("token"), or a trusted forward-auth header set
|
|
6
|
+
* ("proxy"). `service` marks the env service token (it skips the
|
|
7
|
+
* setup gate; it is not a directory user).
|
|
8
|
+
*
|
|
9
|
+
* SINGLE-SOURCE BRIDGE (core hardening W5 Task 2, the Task 1 review's
|
|
10
|
+
* binding carry-forward): the shape is DECLARED ONCE, on the extension
|
|
11
|
+
* contract (`Principal` in src/extension.ts, shipped as the types-only
|
|
12
|
+
* `@sigrun/sigrun/extension` subpath) — this server-side name is a re-export of
|
|
13
|
+
* it, so the pipeline, the routes, and every extension handler share
|
|
14
|
+
* one spelling that cannot drift. The bridge lives HERE (server-side)
|
|
15
|
+
* because the contract's import allowlist is pi-durable/pi-ai/shared:
|
|
16
|
+
* the contract cannot import this module.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Whether `p` may act as the superuser (spec §5.8): the unauthenticated
|
|
20
|
+
* naive principal on a loopback workstation (`method "none"` with no
|
|
21
|
+
* user header — the single-operator default) or a service token.
|
|
22
|
+
* Authenticated principals are superusers only via `admin`.
|
|
23
|
+
*/
|
|
24
|
+
export function isSuperuser(p) {
|
|
25
|
+
return (p.method === "none" && p.user === undefined) || p.service;
|
|
26
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slash-command catalog (Task 7, pi parity): a THIN registry of command
|
|
3
|
+
* METADATA — name, description, args hint, and the §5 endpoint each command
|
|
4
|
+
* drives. Deliberately NO server-side `execute` indirection: the composer
|
|
5
|
+
* owns the client calls, the registry only tells it what exists and where
|
|
6
|
+
* it goes (the task plan's "thin catalog over the endpoints Tasks 4–6
|
|
7
|
+
* built").
|
|
8
|
+
*
|
|
9
|
+
* SPLIT (build-and-webui Task 1): the WIRE types (`SlashCommandEndpoint`,
|
|
10
|
+
* `SlashCommand`, `CommandsResponse`) live in the `@sigrun/shared` package
|
|
11
|
+
* (`shared/src/commands.ts` — the canonical declarations both clients
|
|
12
|
+
* import); this module owns the CATALOG runtime and re-exports the types
|
|
13
|
+
* for server-side importers.
|
|
14
|
+
*
|
|
15
|
+
* Scope (controller rulings): ONLY commands mapping to EXISTING endpoints.
|
|
16
|
+
* `/login`/`/logout` are core-spec §6.4 exclusions (credentials never cross
|
|
17
|
+
* the API), and `/skill:name` + prompt templates were this slice's
|
|
18
|
+
* deferral — Task 2c resolves them as `kind: "expand"` catalog entries
|
|
19
|
+
* (no endpoint: the composer inserts `/name ` into the text and the
|
|
20
|
+
* SERVER expands it at admission; see src/commands/expand.ts). `/model`
|
|
21
|
+
* and `/thinking` ARE included: both map cleanly onto `POST
|
|
22
|
+
* .../configure`'s existing `{model?, thinking?}` body (each sends
|
|
23
|
+
* exactly one of the two fields).
|
|
24
|
+
*
|
|
25
|
+
* EXTENSION COMMANDS (core hardening W5 Task 3, spec §9.3): the core
|
|
26
|
+
* table below stays untouched; `buildCommandCatalog` merges the
|
|
27
|
+
* loader-collected extension declarations into the served list.
|
|
28
|
+
* Extension entries carry the contributing `extension` slug and the ONE
|
|
29
|
+
* server-side invoke endpoint (`POST /v1/conversations/:id/commands/
|
|
30
|
+
* <name>`) — for those, unlike core commands, the composer is a pure
|
|
31
|
+
* POST-and-render client.
|
|
32
|
+
*/
|
|
33
|
+
/** The catalog, keyed by bare name. */
|
|
34
|
+
export const SLASH_COMMANDS = new Map([
|
|
35
|
+
{
|
|
36
|
+
name: "new",
|
|
37
|
+
description: "Start a new conversation",
|
|
38
|
+
endpoint: { method: "POST", path: "/v1/conversations" },
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "fork",
|
|
42
|
+
description: "Fork this conversation at an entry (default: newest)",
|
|
43
|
+
argsHint: "[at]",
|
|
44
|
+
endpoint: {
|
|
45
|
+
method: "POST",
|
|
46
|
+
path: "/v1/conversations/:id/fork",
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: "compact",
|
|
51
|
+
description: "Compact this conversation's history",
|
|
52
|
+
argsHint: "[instructions]",
|
|
53
|
+
endpoint: {
|
|
54
|
+
method: "POST",
|
|
55
|
+
path: "/v1/conversations/:id/compact",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "export",
|
|
60
|
+
description: "Download this conversation as JSONL",
|
|
61
|
+
endpoint: { method: "GET", path: "/v1/conversations/:id/export" },
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "import",
|
|
65
|
+
description: "Create a conversation from pasted JSONL",
|
|
66
|
+
argsHint: "<jsonl>",
|
|
67
|
+
endpoint: {
|
|
68
|
+
method: "POST",
|
|
69
|
+
path: "/v1/conversations/import",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "name",
|
|
74
|
+
description: "Name this conversation",
|
|
75
|
+
argsHint: "<name>",
|
|
76
|
+
endpoint: {
|
|
77
|
+
method: "PATCH",
|
|
78
|
+
path: "/v1/conversations/:id/meta",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: "model",
|
|
83
|
+
description: "Switch this conversation's model",
|
|
84
|
+
argsHint: "<provider/model>",
|
|
85
|
+
endpoint: {
|
|
86
|
+
method: "POST",
|
|
87
|
+
path: "/v1/conversations/:id/configure",
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: "thinking",
|
|
92
|
+
description: "Set this conversation's thinking level",
|
|
93
|
+
argsHint: "<level>",
|
|
94
|
+
endpoint: {
|
|
95
|
+
method: "POST",
|
|
96
|
+
path: "/v1/conversations/:id/configure",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "jobs",
|
|
101
|
+
description: "List jobs (name, next run, last outcome)",
|
|
102
|
+
endpoint: { method: "GET", path: "/v1/jobs" },
|
|
103
|
+
},
|
|
104
|
+
].map((command) => [command.name, command]));
|
|
105
|
+
/**
|
|
106
|
+
* Merge the core slash-command table with every extension's `commands`
|
|
107
|
+
* declaration (spec §9.3). Called ONCE per server (the catalog is a boot
|
|
108
|
+
* constant, like the model catalog); the two collision rules:
|
|
109
|
+
*
|
|
110
|
+
* - CORE names are reserved: an extension command naming one is DROPPED
|
|
111
|
+
* with one console warning and NOT recorded in `collisions` — a core
|
|
112
|
+
* command is not replaceable, so there is no winner to report. The
|
|
113
|
+
* dropped name never reaches the served list NOR `runnable` (invoking
|
|
114
|
+
* it is 404 `command_not_found`, never the extension's run).
|
|
115
|
+
* - ext-vs-ext collisions are LAST-REGISTERED-WINS (the load order the
|
|
116
|
+
* loader collected): warned, recorded in `collisions`, and the winner
|
|
117
|
+
* replaces the loser's catalog entry and `runnable` binding in place.
|
|
118
|
+
*/
|
|
119
|
+
export function buildCommandCatalog(core, bindings) {
|
|
120
|
+
const commands = [...core.values()];
|
|
121
|
+
const collisions = [];
|
|
122
|
+
const runnable = new Map();
|
|
123
|
+
for (const binding of bindings) {
|
|
124
|
+
for (const contribution of binding.commands) {
|
|
125
|
+
if (core.has(contribution.name)) {
|
|
126
|
+
console.warn(`extension command dropped: "${binding.extension}" declares ` +
|
|
127
|
+
`command "${contribution.name}" — core command names are reserved`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const command = {
|
|
131
|
+
name: contribution.name,
|
|
132
|
+
description: contribution.description,
|
|
133
|
+
...(contribution.args === undefined
|
|
134
|
+
? {}
|
|
135
|
+
: { argsHint: contribution.args }),
|
|
136
|
+
extension: binding.extension,
|
|
137
|
+
endpoint: {
|
|
138
|
+
method: "POST",
|
|
139
|
+
path: `/v1/conversations/:id/commands/${contribution.name}`,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
const previous = runnable.get(contribution.name);
|
|
143
|
+
if (previous !== undefined) {
|
|
144
|
+
collisions.push({
|
|
145
|
+
kind: "command",
|
|
146
|
+
name: contribution.name,
|
|
147
|
+
previous: previous.extension,
|
|
148
|
+
contributor: binding.extension,
|
|
149
|
+
});
|
|
150
|
+
console.warn(`extension command collision: "${binding.extension}" replaces ` +
|
|
151
|
+
`"${previous.extension}" command "${contribution.name}" ` +
|
|
152
|
+
"(last-registered-wins)");
|
|
153
|
+
// Replace in place: the loser's position in the served list
|
|
154
|
+
// is the winner's (stable ordering, one entry per name).
|
|
155
|
+
const index = commands.findIndex((entry) => entry.name === contribution.name);
|
|
156
|
+
if (index !== -1)
|
|
157
|
+
commands[index] = command;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
commands.push(command);
|
|
161
|
+
}
|
|
162
|
+
runnable.set(contribution.name, {
|
|
163
|
+
command,
|
|
164
|
+
extension: binding.extension,
|
|
165
|
+
run: contribution.run,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { commands, collisions, runnable };
|
|
170
|
+
}
|
|
171
|
+
// ─── The expand entries (Task 2c: /skill:name and prompt templates) ─────────
|
|
172
|
+
/**
|
|
173
|
+
* Build the `kind: "expand"` catalog entries for the discovered skills
|
|
174
|
+
* and templates (Task 2c): every skill becomes `skill:<name>` (including
|
|
175
|
+
* model-hidden ones — `/skill:name` works for them; the description is
|
|
176
|
+
* the skill's frontmatter description, no args hint), every template
|
|
177
|
+
* becomes `{name, description, argsHint?}` from its frontmatter. The
|
|
178
|
+
* caller (the served list) appends these AFTER the core + extension
|
|
179
|
+
* commands; they carry NO endpoint — the composer inserts `/name ` into
|
|
180
|
+
* the text and the server expands the submission at admission.
|
|
181
|
+
*/
|
|
182
|
+
export function expandCommandEntries(skills, templates) {
|
|
183
|
+
return [
|
|
184
|
+
...skills.map((skill) => ({
|
|
185
|
+
name: `skill:${skill.name}`,
|
|
186
|
+
description: skill.description,
|
|
187
|
+
kind: "expand",
|
|
188
|
+
})),
|
|
189
|
+
...templates.map((template) => ({
|
|
190
|
+
name: template.name,
|
|
191
|
+
description: template.description,
|
|
192
|
+
...(template.argsHint === undefined
|
|
193
|
+
? {}
|
|
194
|
+
: { argsHint: template.argsHint }),
|
|
195
|
+
kind: "expand",
|
|
196
|
+
})),
|
|
197
|
+
];
|
|
198
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §5 content-part resolution (Task 9b, Review Focus #1).
|
|
3
|
+
*
|
|
4
|
+
* `POST /v1/conversations/:id/submissions` accepts wire parts —
|
|
5
|
+
* `{type:"text",text}` and `{type:"image"|"file",path,mime?}` with
|
|
6
|
+
* workspace-relative `path` — and this module resolves them to the inline
|
|
7
|
+
* `UserInput` content pi-durable stores (`TextContent` / base64
|
|
8
|
+
* `ImageContent`) at SUBMIT time, through the conversation's `ExecutionEnv`
|
|
9
|
+
* (the same `envBuilder` seam tool calls use, so remote workspaces stay
|
|
10
|
+
* correct, spec §3.4/§4.5).
|
|
11
|
+
*
|
|
12
|
+
* Resolution never silently drops a part: a `not_found` path becomes an
|
|
13
|
+
* explicit error text part addressed to the model (it stays visible in the
|
|
14
|
+
* transcript, and the model can react), and any other `FileError`
|
|
15
|
+
* (permission denied, aborted, …) fails the whole request loud — the repo
|
|
16
|
+
* rule "fail-loud on workspace read errors except not_found". A binary file
|
|
17
|
+
* part that is not valid UTF-8 also becomes an explicit error part
|
|
18
|
+
* (`invalid_utf8`) rather than mojibake text.
|
|
19
|
+
*/
|
|
20
|
+
/** The explicit error-part marker for a part that could not be resolved. */
|
|
21
|
+
function errorPart(path, reason) {
|
|
22
|
+
return { type: "text", text: `[part error] ${path}: ${reason}` };
|
|
23
|
+
}
|
|
24
|
+
/** Extension → mime type for image parts without an explicit `mime`. */
|
|
25
|
+
const IMAGE_MIME_BY_EXTENSION = {
|
|
26
|
+
png: "image/png",
|
|
27
|
+
jpg: "image/jpeg",
|
|
28
|
+
jpeg: "image/jpeg",
|
|
29
|
+
gif: "image/gif",
|
|
30
|
+
webp: "image/webp",
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Validate a submission `input` body: the §5 content-part array, or a bare
|
|
34
|
+
* string (upstream `UserInput` also admits a plain string — simple clients
|
|
35
|
+
* submit text without wrapping it in a parts array; the server maps it to
|
|
36
|
+
* one text part). Part paths must be workspace-relative (no absolute paths,
|
|
37
|
+
* no `..` traversal — `NodeExecutionEnv` resolves against its `cwd` but
|
|
38
|
+
* accepts absolute paths as-is, so the server must confine client paths
|
|
39
|
+
* itself).
|
|
40
|
+
*/
|
|
41
|
+
export function parseSubmissionInput(input) {
|
|
42
|
+
if (typeof input === "string") {
|
|
43
|
+
return { ok: true, parts: [{ type: "text", text: input }] };
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(input) || input.length === 0)
|
|
46
|
+
return { ok: false };
|
|
47
|
+
const parts = [];
|
|
48
|
+
for (const candidate of input) {
|
|
49
|
+
if (typeof candidate !== "object" || candidate === null) {
|
|
50
|
+
return { ok: false };
|
|
51
|
+
}
|
|
52
|
+
const part = candidate;
|
|
53
|
+
if (part.type === "text") {
|
|
54
|
+
if (typeof part.text !== "string")
|
|
55
|
+
return { ok: false };
|
|
56
|
+
parts.push({ type: "text", text: part.text });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (part.type === "image" || part.type === "file") {
|
|
60
|
+
if (typeof part.path !== "string" || !isConfinedPath(part.path)) {
|
|
61
|
+
return { ok: false };
|
|
62
|
+
}
|
|
63
|
+
if (part.mime !== undefined && typeof part.mime !== "string") {
|
|
64
|
+
return { ok: false };
|
|
65
|
+
}
|
|
66
|
+
parts.push({
|
|
67
|
+
type: part.type,
|
|
68
|
+
path: part.path,
|
|
69
|
+
...(part.mime === undefined ? {} : { mime: part.mime }),
|
|
70
|
+
});
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
return { ok: false };
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, parts };
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A path the server will hand to the workspace env: relative, and without a
|
|
79
|
+
* `..` segment. `.` and empty are fine (`.` is the workspace root in list
|
|
80
|
+
* calls).
|
|
81
|
+
*/
|
|
82
|
+
export function isConfinedPath(path) {
|
|
83
|
+
if (path.length === 0)
|
|
84
|
+
return true;
|
|
85
|
+
if (path.startsWith("/") || path.includes("\\"))
|
|
86
|
+
return false;
|
|
87
|
+
const segments = path.split("/");
|
|
88
|
+
return !segments.includes("..");
|
|
89
|
+
}
|
|
90
|
+
/** Read one workspace file strictly-UTF-8: `undefined` when not valid UTF-8. */
|
|
91
|
+
function decodeStrictUtf8(bytes) {
|
|
92
|
+
try {
|
|
93
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Resolve one wire part against the conversation's env. */
|
|
100
|
+
async function resolvePart(part, env, context) {
|
|
101
|
+
if (part.type === "text")
|
|
102
|
+
return { type: "text", text: part.text };
|
|
103
|
+
const read = await env.readBinaryFile(part.path, context);
|
|
104
|
+
if (!read.ok) {
|
|
105
|
+
if (read.error.code === "not_found") {
|
|
106
|
+
// Review Focus #1: explicit error part, never a silent drop.
|
|
107
|
+
return errorPart(part.path, "not_found");
|
|
108
|
+
}
|
|
109
|
+
// Every other workspace failure is a server-side fault: fail loud.
|
|
110
|
+
throw read.error;
|
|
111
|
+
}
|
|
112
|
+
if (part.type === "image") {
|
|
113
|
+
const mime = part.mime ??
|
|
114
|
+
IMAGE_MIME_BY_EXTENSION[extensionOf(part.path)] ??
|
|
115
|
+
"application/octet-stream";
|
|
116
|
+
return {
|
|
117
|
+
type: "image",
|
|
118
|
+
data: Buffer.from(read.value).toString("base64"),
|
|
119
|
+
mimeType: mime,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const text = decodeStrictUtf8(read.value);
|
|
123
|
+
if (text === undefined) {
|
|
124
|
+
return errorPart(part.path, "invalid_utf8");
|
|
125
|
+
}
|
|
126
|
+
return { type: "text", text };
|
|
127
|
+
}
|
|
128
|
+
function extensionOf(path) {
|
|
129
|
+
const dot = path.lastIndexOf(".");
|
|
130
|
+
return dot === -1 ? "" : path.slice(dot + 1).toLowerCase();
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a validated parts array to inline content. Rejects (throws the
|
|
134
|
+
* `FileError`) on any non-`not_found` workspace failure — fail-loud.
|
|
135
|
+
*/
|
|
136
|
+
export async function resolveParts(parts, env, context) {
|
|
137
|
+
const resolved = [];
|
|
138
|
+
for (const part of parts) {
|
|
139
|
+
resolved.push(await resolvePart(part, env, context));
|
|
140
|
+
}
|
|
141
|
+
return resolved;
|
|
142
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared server context (server restructure W0): what every route
|
|
3
|
+
* module receives from `createServer` instead of capturing harness or
|
|
4
|
+
* config state directly — one immutable record, so route modules stay
|
|
5
|
+
* pure functions of `(app, ctx)`.
|
|
6
|
+
*
|
|
7
|
+
* This module also owns the Fastify request augmentation (`declare
|
|
8
|
+
* module "fastify"`): the pipeline's `principal` and the
|
|
9
|
+
* conversation pre-handler's `conversation` decoration are declared
|
|
10
|
+
* here once, so every module that imports this context (a type-only
|
|
11
|
+
* import suffices) sees the augmented fields.
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conversation pre-handler: one hook every `:id` route declares, so
|
|
3
|
+
* conversation resolution, the unknown-id 404, and the §5.9 content-
|
|
4
|
+
* access check are uniform across route modules. It runs BEFORE the
|
|
5
|
+
* handler — the SSE route's 404 lands as plain JSON before
|
|
6
|
+
* `reply.hijack()` — and after Fastify's param validation (routes keep
|
|
7
|
+
* `schema: { params: ID_PARAMS_SCHEMA }`).
|
|
8
|
+
*
|
|
9
|
+
* Access (spec §5.9): the hook snapshots the session-meta doc and
|
|
10
|
+
* resolves `accessOf(principal, meta)`:
|
|
11
|
+
*
|
|
12
|
+
* - `"none"` (hidden) → 404 `conversation_not_found` — a hidden
|
|
13
|
+
* conversation is indistinguishable from an unknown one (no 403 tell);
|
|
14
|
+
* - `"read"` on a WRITE route → 403 `forbidden` (the route names its need
|
|
15
|
+
* with `loadConversation(ctx, need)`; GETs are `"read"`, everything
|
|
16
|
+
* else is `"write"`).
|
|
17
|
+
*
|
|
18
|
+
* The polling surfaces (GET interactions/ambient) do NOT take this hook —
|
|
19
|
+
* they apply the visibility half of the rule inside their handlers (a
|
|
20
|
+
* channel polling a torn-down conversation must not error; see
|
|
21
|
+
* routes/interactions.ts).
|
|
22
|
+
*/
|
|
23
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
24
|
+
import { accessOf } from "@sigrun/shared/access";
|
|
25
|
+
import { isSuperuser } from "./auth/principal.js";
|
|
26
|
+
import { conversationId } from "./helpers.js";
|
|
27
|
+
import { SessionMetaDoc } from "./session-meta.js";
|
|
28
|
+
/**
|
|
29
|
+
* Build the pre-handler: resolve `params.id` through the harness's
|
|
30
|
+
* storage-backed line, enforce the §5.9 access rule for `need`, and
|
|
31
|
+
* decorate `request.conversation`; an id no storage ever recorded — or a
|
|
32
|
+
* conversation the caller may not see — replies 404
|
|
33
|
+
* `{error: "conversation_not_found"}`; a read-only caller on a write
|
|
34
|
+
* route gets 403 `{error: "forbidden"}`.
|
|
35
|
+
*/
|
|
36
|
+
export function loadConversation(ctx, need = "read") {
|
|
37
|
+
return async (request, reply) => {
|
|
38
|
+
const { id } = request.params;
|
|
39
|
+
const conversation = await ctx.opened.harness.conversation(conversationId(id), BACKGROUND_CONTEXT);
|
|
40
|
+
if (conversation === undefined) {
|
|
41
|
+
return await reply
|
|
42
|
+
.code(404)
|
|
43
|
+
.send({ error: "conversation_not_found" });
|
|
44
|
+
}
|
|
45
|
+
const meta = await ctx.opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
46
|
+
const access = accessOf({
|
|
47
|
+
user: request.principal.user,
|
|
48
|
+
superuser: isSuperuser(request.principal),
|
|
49
|
+
}, meta ?? {});
|
|
50
|
+
if (access === "none") {
|
|
51
|
+
// Hidden is 404 on every :id route (spec §5.9).
|
|
52
|
+
return await reply
|
|
53
|
+
.code(404)
|
|
54
|
+
.send({ error: "conversation_not_found" });
|
|
55
|
+
}
|
|
56
|
+
if (access === "read" && need === "write") {
|
|
57
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
58
|
+
}
|
|
59
|
+
request.conversation = conversation;
|
|
60
|
+
};
|
|
61
|
+
}
|