@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,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
|
+
}
|