@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,351 @@
1
+ /**
2
+ * Shared §5 server helpers: the validation vocabularies, the
3
+ * store-derived agent change, and the per-conversation read/env seams
4
+ * the route modules consume.
5
+ */
6
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { isAbsolute, resolve } from "node:path";
10
+ import { effectiveServerSection, } from "../config.js";
11
+ import { DEFAULT_AUTH_KEY } from "../harness/auth.js";
12
+ import { OPENAI_API_KEY_ENV } from "../harness/providers/index.js";
13
+ import { SessionMetaDoc } from "./session-meta.js";
14
+ import { serializeConversationView, } from "./views.js";
15
+ /** The `thinking` values `configure` accepts (pi-ai `ModelThinkingLevel`). */
16
+ export const THINKING_LEVELS = [
17
+ "off",
18
+ "minimal",
19
+ "low",
20
+ "medium",
21
+ "high",
22
+ "xhigh",
23
+ "max",
24
+ ];
25
+ /**
26
+ * The `whenBusy` values a submission accepts (pi-durable
27
+ * `SubmissionDraft`'s full input union — the §5 client sends "steer";
28
+ * "followUp" and "reject" are upstream's other busy semantics).
29
+ */
30
+ export const WHEN_BUSY_MODES = ["steer", "followUp", "reject"];
31
+ /**
32
+ * The MCP-driven auto-enable (Task 2, ruling 4): when at least one
33
+ * ENABLED MCP server's effective exposure is `codemode` (@sigrun/ext-mcp's
34
+ * server default), the `codemode` tool stays in the model-offered set for
35
+ * newly created conversations; when any is `deferred`, `tool_search`
36
+ * stays offered. Both tools otherwise narrow OUT of the default offered
37
+ * set (ruling 8's pi parity: with no MCP servers they are not offered).
38
+ * `autoEnableCodemode: false` turns both arms off; an explicit
39
+ * `defaultTools` filter always wins (auto-enable only edits the DEFAULT
40
+ * set, never the user's named list).
41
+ *
42
+ * The registry path (ruling 3): the SAME place the extensions read it —
43
+ * `SIGRUN_MCP_CONFIG` when set, else `~/.config/sigrun/mcp.json`. This is
44
+ * deliberately NOT unified with `SIGRUN_CONFIG_DIR`: the extensions read
45
+ * the fixed path directly, so the auto-enable must agree with them (the
46
+ * divergence from the config-dir convention is a known, recorded
47
+ * limitation for a later task — do not "fix" it here).
48
+ *
49
+ * A create-time decision (like `defaultTools`), not a live toggle. The
50
+ * narrowing rides the stored `modelTools` `{remove}` EDIT form — never
51
+ * the array form — so pi-durable's default-offered set stays the base
52
+ * and `tool_search`'s `control.addTools` merge keeps working over it.
53
+ *
54
+ * A missing/unparseable registry reads as EMPTY (both tools off): a
55
+ * broken mcp.json must never block conversation creation.
56
+ */
57
+ function autoEnableRemovals(settings, env) {
58
+ if (settings.autoEnableCodemode === false) {
59
+ return ["codemode", "tool_search"];
60
+ }
61
+ const override = env.SIGRUN_MCP_CONFIG;
62
+ const path = override !== undefined && override !== ""
63
+ ? isAbsolute(override)
64
+ ? override
65
+ : resolve(override)
66
+ : resolve(homedir(), ".config", "sigrun", "mcp.json");
67
+ let servers;
68
+ try {
69
+ if (!existsSync(path))
70
+ return ["codemode", "tool_search"];
71
+ servers = JSON.parse(readFileSync(path, "utf8"))
72
+ .servers;
73
+ }
74
+ catch {
75
+ return ["codemode", "tool_search"];
76
+ }
77
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
78
+ return ["codemode", "tool_search"];
79
+ }
80
+ let wantsCodemode = false;
81
+ let wantsToolSearch = false;
82
+ for (const entry of Object.values(servers)) {
83
+ if (typeof entry !== "object" || entry === null)
84
+ continue;
85
+ const server = entry;
86
+ if (server.enabled === false || server.disabled === true)
87
+ continue;
88
+ // @sigrun/ext-mcp's server default is "codemode"; its pi-compat
89
+ // alias `codemode-deferred` normalizes to "codemode".
90
+ const exposure = server.exposure === "codemode-deferred"
91
+ ? "codemode"
92
+ : (server.exposure ?? "codemode");
93
+ if (exposure === "codemode")
94
+ wantsCodemode = true;
95
+ if (exposure === "deferred")
96
+ wantsToolSearch = true;
97
+ }
98
+ const removals = [];
99
+ if (!wantsCodemode)
100
+ removals.push("codemode");
101
+ if (!wantsToolSearch)
102
+ removals.push("tool_search");
103
+ return removals;
104
+ }
105
+ /**
106
+ * The agent change every server-side conversation create applies: the
107
+ * boot default model, plus the settings store's sigrun-resolved fields —
108
+ * `defaultTools` (a tool-NAME filter mapped to pi-durable 1.3.0's
109
+ * `AgentChange.modelTools`, the MODEL-OFFERED filter — NOT
110
+ * `AgentChange.tools`, the enabled filter: filtering the offered set never
111
+ * disables a tool for nested calls, the two-layer-filter ruling) and
112
+ * `perModelThinking`'s override for THAT model (applied at create because
113
+ * the model is known here; configure re-applies on every model change).
114
+ * Both come from the STORE (re-read here), so a PATCHed settings file
115
+ * reaches conversations created afterwards without a restart. Applies at
116
+ * CREATES only — configure never re-applies the tool filter, so a
117
+ * `control.addTools`-loaded deferred tool in a LIVE conversation's stored
118
+ * filter is never clobbered.
119
+ */
120
+ export function storeAgentChange(opened, model) {
121
+ const settings = opened.settings.read();
122
+ const filter = settings.defaultTools;
123
+ // `AgentChange.modelTools` takes ToolRegistration OBJECTS (pi-durable's
124
+ // applyChange maps `item.name`), so resolve the store's names against
125
+ // the registry snapshot's composed tool set. An unregistered name in
126
+ // the filter is a config drift: dropped here (resolveAgent's own
127
+ // behavior for an unknown stored name), never a boot failure — but
128
+ // dropped LOUDLY: a user writing `"my-ext-tool"` into the settings
129
+ // file deserves to know the name resolved to nothing.
130
+ let modelTools;
131
+ let modelToolsEdit;
132
+ if (filter !== undefined) {
133
+ const registered = new Set(opened.registry
134
+ .snapshot()
135
+ .tools()
136
+ .map((entry) => entry.tool.name));
137
+ const dropped = filter.filter((name) => !registered.has(name));
138
+ if (dropped.length > 0) {
139
+ console.warn(`settings: dropping unregistered defaultTools name(s): ${dropped.join(", ")} (not in the composed tool set)`);
140
+ }
141
+ modelTools = opened.registry
142
+ .snapshot()
143
+ .tools()
144
+ .filter((entry) => filter.includes(entry.tool.name))
145
+ .map((entry) => entry.tool);
146
+ }
147
+ else {
148
+ // No explicit filter: the auto-enable ruling narrows the DEFAULT
149
+ // offered set (removing codemode/tool_search unless an MCP server's
150
+ // exposure engages them). Registered names only — a built-in
151
+ // disabled via settings simply drops out of the removal list. The
152
+ // env is the BOOT env (`opened.providerEnv`), so the hermetic test
153
+ // seam's SIGRUN_MCP_CONFIG override applies and the live process
154
+ // env is never read.
155
+ const removals = autoEnableRemovals(settings, opened.providerEnv);
156
+ if (removals.length > 0) {
157
+ const registered = new Set(opened.registry
158
+ .snapshot()
159
+ .tools()
160
+ .map((entry) => entry.tool.name));
161
+ const removedTools = opened.registry
162
+ .snapshot()
163
+ .tools()
164
+ .filter((entry) => removals.includes(entry.tool.name) && registered.has(entry.tool.name))
165
+ .map((entry) => entry.tool);
166
+ if (removedTools.length > 0) {
167
+ modelToolsEdit = { remove: removedTools };
168
+ }
169
+ }
170
+ }
171
+ const thinking = model !== undefined
172
+ ? settings.perModelThinking?.[`${model.provider}/${model.modelId}`]
173
+ : undefined;
174
+ return {
175
+ ...(model === undefined ? {} : { model }),
176
+ // An EMPTY stored array keeps every tool (an empty emit would offer
177
+ // the model nothing — the same guard the panel's none-on note leans
178
+ // on), so the emit is skipped when nothing resolved.
179
+ ...(modelTools === undefined || modelTools.length === 0
180
+ ? modelToolsEdit === undefined
181
+ ? {}
182
+ : { modelTools: modelToolsEdit }
183
+ : { modelTools }),
184
+ // The store validates every level against pi-ai's union, so this
185
+ // unsound-looking cast is checked at every write.
186
+ ...(thinking === undefined
187
+ ? {}
188
+ : { thinkingLevel: thinking }),
189
+ };
190
+ }
191
+ /** Integer path params for `/v1/conversations/:id`. */
192
+ export const ID_PARAMS_SCHEMA = {
193
+ type: "object",
194
+ properties: { id: { type: "integer" } },
195
+ required: ["id"],
196
+ additionalProperties: false,
197
+ };
198
+ /** The settings response over the live store + registry snapshot. */
199
+ /**
200
+ * The `/v1/settings` payload (GET and the post-PATCH readback): the
201
+ * file's effective contents, the registry-computed `toolRoster`, and
202
+ * the `effective` block (spec §6.6) — every server key's resolved value
203
+ * and source, computed PER REQUEST over the fresh file read (a PATCH
204
+ * lands as a `file` source on the next GET) and the boot env/config.
205
+ */
206
+ export function settingsResponse(opened, config, env) {
207
+ const names = opened.registry
208
+ .snapshot()
209
+ .tools()
210
+ .map((entry) => entry.tool.name);
211
+ // Names may repeat across extensions (RegistrySnapshot.tools); the
212
+ // panel keys checkboxes by name, so dedupe.
213
+ const file = opened.settings.read();
214
+ return {
215
+ ...file,
216
+ toolRoster: [...new Set(names)],
217
+ effective: effectiveServerSection(env, file.server, config),
218
+ };
219
+ }
220
+ /** Re-brand a route id as a ConversationId (the branded Id type is a number). */
221
+ export function conversationId(id) {
222
+ return id;
223
+ }
224
+ /** Re-brand a route id as a SubmissionId (the branded Id type is a number). */
225
+ export function submissionId(id) {
226
+ return id;
227
+ }
228
+ /** Read one conversation's current view frame; disposes the state handle. */
229
+ export async function readView(conversation) {
230
+ const state = await conversation.viewState(BACKGROUND_CONTEXT);
231
+ const view = serializeConversationView(state.value);
232
+ await state.dispose();
233
+ return view;
234
+ }
235
+ /**
236
+ * Stamp the resolved caller identity onto a fresh conversation's
237
+ * session-meta doc as `owner` (spec §5 seam; the auth hook's only writer).
238
+ * Undefined (naive mode — no header) is a NO-OP: the doc stays
239
+ * unmaterialized and reads serve the defaults (defaults are free; only a
240
+ * PATCH or a stamped owner writes). A fork's doc carries the parent's
241
+ * copied state (`fork: "current"`), so the caller's identity OVERWRITES
242
+ * the source's.
243
+ */
244
+ export async function stampSessionOwner(conversation, owner) {
245
+ if (owner === undefined)
246
+ return;
247
+ await conversation.commit(async (tx) => {
248
+ const draft = await tx.doc(SessionMetaDoc, conversation.id);
249
+ draft.owner = owner;
250
+ }, BACKGROUND_CONTEXT);
251
+ }
252
+ /**
253
+ * Build the conversation's workspace `ExecutionEnv` the same way
254
+ * `Harness.buildEnv` does for tool calls (dist/harness/harness.js:133-139):
255
+ * resolve the committed `agent.cwd` and hand it, with the harness as the
256
+ * doc `read` line, to the boot `envBuilder`. The §5 file/upload/submission
257
+ * endpoints resolve through this — one seam, so a workspace-service swap
258
+ * moves them all at once.
259
+ */
260
+ export async function conversationEnv(opened, conversation) {
261
+ const agent = await conversation.agent(BACKGROUND_CONTEXT);
262
+ const built = await opened.envBuilder({
263
+ conversationId: conversation.id,
264
+ ...(agent.cwd === undefined ? {} : { cwd: agent.cwd }),
265
+ read: opened.harness,
266
+ }, BACKGROUND_CONTEXT);
267
+ // `HarnessOptions.env` types as optional; sigrun always passes one, so a
268
+ // missing env is a boot-invariant violation — fail loud, never null-check.
269
+ if (built === undefined) {
270
+ throw new Error("envBuilder returned no ExecutionEnv");
271
+ }
272
+ return built;
273
+ }
274
+ /** The upload read cap for `files/content` (binary-safe; 1 MiB). */
275
+ export const FILE_CONTENT_MAX_BYTES = 1024 * 1024;
276
+ /**
277
+ * One provider's auth readiness, derived from boot-retained state (spec
278
+ * §6.4 + provider plugins Task 6): the same three-priority chain
279
+ * `GET /v1/auth/check` serves — (1) the owning contributor's readiness
280
+ * tracker (three-state vocabulary, verbatim), (2) a tracker-less
281
+ * contributor's legacy §3 producer mapping (ok→ready, degraded→invalid,
282
+ * absent→not_ready), (3) the degenerate env provider's key-presence
283
+ * mapping (auth.json "default"/own entry over `OPENAI_API_KEY`).
284
+ * `producersByName` is the caller's per-request index of
285
+ * `opened.providerHealth` (a producer is per-contributor, not
286
+ * per-provider, so the owning contributor's producer is looked up by
287
+ * name). Extracted for W2 Task 8: `GET /metrics` reports the same
288
+ * per-provider statuses as `sigrun_provider_ready{provider,status}` —
289
+ * ONE derivation, never two that can drift.
290
+ */
291
+ export function providerAuthStatus(opened, providerId, producersByName) {
292
+ const tracker = opened.providerReadiness.get(providerId);
293
+ if (tracker !== undefined) {
294
+ const readiness = tracker();
295
+ return readiness.detail === undefined
296
+ ? { status: readiness.status }
297
+ : { status: readiness.status, detail: readiness.detail };
298
+ }
299
+ const owner = opened.providerOwnership.get(providerId);
300
+ if (owner !== undefined) {
301
+ const producer = producersByName.get(owner);
302
+ const status = producer === undefined
303
+ ? "not_ready"
304
+ : producer.health() === "ok"
305
+ ? "ready"
306
+ : "invalid";
307
+ return { status };
308
+ }
309
+ // The degenerate provider's key resolves auth.json (the "default"
310
+ // magic key or the provider's own entry) BEFORE the OPENAI_API_KEY
311
+ // env fallback. Presence only — key material never leaves the store. A
312
+ // malformed auth file reads as no key rather than failing the caller.
313
+ let storedKey = false;
314
+ try {
315
+ const authFile = opened.auth.read();
316
+ storedKey =
317
+ authFile[DEFAULT_AUTH_KEY]?.apiKey !== undefined ||
318
+ authFile[providerId]?.apiKey !== undefined;
319
+ }
320
+ catch {
321
+ storedKey = false;
322
+ }
323
+ return {
324
+ status: storedKey || (opened.providerEnv[OPENAI_API_KEY_ENV] ?? "").trim() !== ""
325
+ ? "ready"
326
+ : "not_ready",
327
+ };
328
+ }
329
+ /**
330
+ * One `scanConversations` page size for the listing route. Not a client
331
+ * limit — the route walks the continuation cursor to completion (v1 is
332
+ * unpaginated); a larger page only trades per-page round-trips.
333
+ */
334
+ export const SCAN_PAGE_SIZE = 1_000;
335
+ /**
336
+ * Decode a base64 body strictly: Node's `Buffer.from(s, "base64")` is
337
+ * LENIENT (invalid characters are skipped, so `"!!!"` decodes to empty), so
338
+ * uploads validate the alphabet and round-trip — invalid base64 is a client
339
+ * error, never a silent partial decode.
340
+ */
341
+ export function decodeBase64Strict(value) {
342
+ const trimmed = value.trim();
343
+ if (trimmed.length === 0 || trimmed.length % 4 !== 0)
344
+ return undefined;
345
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(trimmed))
346
+ return undefined;
347
+ const bytes = Buffer.from(trimmed, "base64");
348
+ if (bytes.toString("base64") !== trimmed)
349
+ return undefined;
350
+ return bytes;
351
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The host bridge (core hardening W5 Task 5, spec §9.5/§9.7): the
3
+ * in-process implementation behind every seam host's `conversations` /
4
+ * `users` / `access` / `identity` surface. `createServer` builds ONE
5
+ * bridge over its `ServerContext` and `OpenedHarness.bindHostBridge`
6
+ * hands it to every seam host — so an extension driving conversations
7
+ * in-process runs the SAME code paths the HTTP routes run
8
+ * (src/server/conversation-ops.ts): content-part resolution, the
9
+ * served-model check, activity stamping, and the durable listing are
10
+ * identical by construction, never re-implemented.
11
+ *
12
+ * Posture (spec §9.5): extensions are TRUSTED IN-PROCESS code — they may
13
+ * act for any user (no per-principal access gating on the bridge paths;
14
+ * mapping a channel's users to sigrun users is the extension's own
15
+ * config), and the bridge's `list` reads the directory the way a
16
+ * superuser's HTTP list does: every row visible, `access: "write"`.
17
+ */
18
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
+ import { accessOf as accessOfShared, } from "@sigrun/shared/access";
20
+ import { isSuperuser } from "./auth/principal.js";
21
+ import { createConversationOp, listConversationsOp, readMetaOp, submitOp, } from "./conversation-ops.js";
22
+ import { SessionMetaDoc } from "./session-meta.js";
23
+ import { serializeConversationView } from "./views.js";
24
+ /** Build the host bridge over one server context. */
25
+ export function createHostBridge(ctx) {
26
+ const { opened } = ctx;
27
+ // The bridge's conversation resolution: an id no storage ever
28
+ // recorded fails LOUD (the routes' 404 analog — the caller passed a
29
+ // bad id, never a silent default).
30
+ const conversationOf = async (id) => {
31
+ const conversation = await opened.harness.conversation(id, BACKGROUND_CONTEXT);
32
+ if (conversation === undefined) {
33
+ throw new Error(`conversation ${id} not found`);
34
+ }
35
+ return conversation;
36
+ };
37
+ return {
38
+ conversations: {
39
+ async create(opts = {}) {
40
+ const created = await createConversationOp(ctx, opts);
41
+ // `ConversationCreateResponse.id` is the unbranded wire number;
42
+ // the host hands back the branded id (the brand is nominal).
43
+ return created.id;
44
+ },
45
+ async submit(id, input, opts) {
46
+ const conversation = await conversationOf(id);
47
+ // `user` is accepted and NOT verified: extensions own their
48
+ // user mapping (spec §9.5) — a channel's unmapped user submits
49
+ // fine, and owner attribution happened at create.
50
+ return await submitOp(ctx, conversation, input, {
51
+ ...(opts?.requestId === undefined
52
+ ? {}
53
+ : { requestId: opts.requestId }),
54
+ ...(opts?.whenBusy === undefined
55
+ ? {}
56
+ : { whenBusy: opts.whenBusy }),
57
+ });
58
+ },
59
+ async watch(id, listener) {
60
+ const conversation = await conversationOf(id);
61
+ // Snapshot-first (the SSE stream's shape, spec §7.3): the
62
+ // acquisition revision is the first listener call, then every
63
+ // watch revision after (pending frames flow because start
64
+ // never invokes the listener inline).
65
+ const watch = await conversation.watch(BACKGROUND_CONTEXT);
66
+ listener(serializeConversationView(watch.value));
67
+ watch.start((value) => {
68
+ // An extension listener that throws (or returns a rejecting
69
+ // promise — async listeners) must not end the watch or the
70
+ // process — warn and keep delivering.
71
+ return Promise.resolve()
72
+ .then(() => listener(serializeConversationView(value)))
73
+ .catch((cause) => {
74
+ console.warn(`host bridge: watch listener failed: ${cause instanceof Error ? cause.message : String(cause)}`);
75
+ });
76
+ });
77
+ return async () => {
78
+ await watch.stop();
79
+ };
80
+ },
81
+ async list(opts = {}) {
82
+ // The trusted read: extensions may act for any user, so the
83
+ // bridge list sees every row (the superuser shape) — the
84
+ // per-caller filtering is the HTTP route's own concern.
85
+ return await listConversationsOp(ctx, { superuser: true }, {
86
+ ...(opts.owner === undefined ? {} : { owner: opts.owner }),
87
+ ...(opts.archived === undefined
88
+ ? {}
89
+ : { archivedOnly: opts.archived }),
90
+ });
91
+ },
92
+ async meta(id) {
93
+ await conversationOf(id);
94
+ return await readMetaOp(opened, id);
95
+ },
96
+ },
97
+ users: {
98
+ // The directory in wire form (emails are admin material; the
99
+ // bridge is in-process server code, the same trust as the
100
+ // admin-gated /v1/users routes).
101
+ async list() {
102
+ return opened.identity.users.list();
103
+ },
104
+ async get(user) {
105
+ return opened.identity.users.get(user);
106
+ },
107
+ },
108
+ access: {
109
+ async accessOf(principal, resource) {
110
+ // A full Principal maps through the SAME superuser rule the
111
+ // server's routes use (naive-loopback or service — a
112
+ // directory admin is not a superuser for accessOf); the bare
113
+ // `{user}` shape is the shared rule's own principal.
114
+ const resolved = "admin" in principal
115
+ ? {
116
+ user: principal.user,
117
+ superuser: isSuperuser(principal),
118
+ }
119
+ : { user: principal.user };
120
+ return accessOfShared(resolved, resource);
121
+ },
122
+ },
123
+ identity: {
124
+ // The conversation's owner — who the agent acts for (§9.7).
125
+ async actingUser(conversationId) {
126
+ const meta = await opened.harness.snapshot(SessionMetaDoc, conversationId, BACKGROUND_CONTEXT);
127
+ return meta?.owner;
128
+ },
129
+ },
130
+ };
131
+ }