@sigrun/sigrun 0.1.6 → 0.2.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 +192 -0
- package/README.md +445 -42
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +230 -22
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +259 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +92 -6
- package/dist/harness/sections/context-files.js +1 -1
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +211 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +31 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +274 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +261 -0
- package/dist/serve.js +213 -25
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +99 -36
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +213 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +152 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/notifications.js +21 -4
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +274 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +19 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +13 -9
- package/webui/dist/assets/index-SO2W4jjk.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/CONTEXT.md +0 -163
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
|
@@ -78,6 +78,10 @@ export function startNotifications(ctx) {
|
|
|
78
78
|
title: base.title,
|
|
79
79
|
body: base.body,
|
|
80
80
|
...(url === undefined ? {} : { url }),
|
|
81
|
+
// The settled outcome rides ONLY run.settled events — the
|
|
82
|
+
// other variants never gain the field (the type is the union;
|
|
83
|
+
// the conditional spread keeps the wire shape exact).
|
|
84
|
+
...(base.outcome === undefined ? {} : { outcome: base.outcome }),
|
|
81
85
|
};
|
|
82
86
|
for (const target of targets) {
|
|
83
87
|
// Fire-and-forget per transport call: one notifier's failure or
|
|
@@ -123,7 +127,7 @@ export function startNotifications(ctx) {
|
|
|
123
127
|
}, recipients);
|
|
124
128
|
};
|
|
125
129
|
/** run.settled → the owner, gated by the live `notifications.runSettled` mode. */
|
|
126
|
-
const handleRunSettled = async (conversationId) => {
|
|
130
|
+
const handleRunSettled = async (conversationId, outcome, reason) => {
|
|
127
131
|
const mode = opened.settings.read().notifications?.runSettled ?? "unwatched";
|
|
128
132
|
if (mode === "never")
|
|
129
133
|
return;
|
|
@@ -134,13 +138,26 @@ export function startNotifications(ctx) {
|
|
|
134
138
|
(watchers.get(conversationId) ?? 0) > 0) {
|
|
135
139
|
return;
|
|
136
140
|
}
|
|
141
|
+
// Translate the EXISTING wire outcome/reason straight across (no
|
|
142
|
+
// second answer read, no inferred status). An unanswered run never
|
|
143
|
+
// titles itself "Reply ready": the reason is the run's own, with
|
|
144
|
+
// the bare status as the fallback for a reason-less settle.
|
|
145
|
+
const settledOutcome = outcome === "answered"
|
|
146
|
+
? { status: "answered" }
|
|
147
|
+
: { status: "unanswered", reason: reason ?? "unanswered" };
|
|
148
|
+
const settledReason = reason ?? "unanswered";
|
|
137
149
|
deliver({
|
|
138
150
|
type: "run.settled",
|
|
139
151
|
conversationId,
|
|
140
152
|
jobId: undefined,
|
|
141
153
|
callId: undefined,
|
|
142
|
-
title:
|
|
143
|
-
|
|
154
|
+
title: outcome === "answered"
|
|
155
|
+
? "Reply ready"
|
|
156
|
+
: "Run ended without a reply",
|
|
157
|
+
body: outcome === "answered"
|
|
158
|
+
? displayName(meta, conversationId)
|
|
159
|
+
: `${displayName(meta, conversationId)} — ${settledReason}`,
|
|
160
|
+
outcome: settledOutcome,
|
|
144
161
|
}, [meta?.owner]);
|
|
145
162
|
};
|
|
146
163
|
/**
|
|
@@ -177,7 +194,7 @@ export function startNotifications(ctx) {
|
|
|
177
194
|
void bus.runBounded("notifications interaction.pending", () => handleInteraction(event.conversationId, event.callId));
|
|
178
195
|
}
|
|
179
196
|
else if (event.type === "run.settled") {
|
|
180
|
-
void bus.runBounded("notifications run.settled", () => handleRunSettled(event.conversationId));
|
|
197
|
+
void bus.runBounded("notifications run.settled", () => handleRunSettled(event.conversationId, event.outcome, event.reason));
|
|
181
198
|
}
|
|
182
199
|
else if (event.type === "job.changed") {
|
|
183
200
|
void bus.runBounded("notifications job.changed", () => {
|
package/dist/server/ownership.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
* receive, while the registry is server-instance state.
|
|
14
14
|
*/
|
|
15
15
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
16
|
+
import { AgentDoc } from "@earendil-works/pi-durable";
|
|
17
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
16
18
|
import { SCAN_PAGE_SIZE } from "./helpers.js";
|
|
17
19
|
import { SessionMetaDoc } from "./session-meta.js";
|
|
18
20
|
/** The per-server participant lists (registration order = run order). */
|
|
@@ -46,6 +48,8 @@ export async function transferOwnership(ctx, from, to) {
|
|
|
46
48
|
export function registerCoreOwnershipParticipant(ctx) {
|
|
47
49
|
registerOwnershipParticipant(ctx, async (from, to) => {
|
|
48
50
|
const { opened } = ctx;
|
|
51
|
+
const fromWorkspace = opened.identity.users.get(from)?.workspace ??
|
|
52
|
+
join(ctx.managedPaths.userAreas(from), "workspace");
|
|
49
53
|
let cursor;
|
|
50
54
|
do {
|
|
51
55
|
const page = await opened.storage.scanConversations({ order: "ascending" }, SCAN_PAGE_SIZE, cursor, BACKGROUND_CONTEXT);
|
|
@@ -59,6 +63,15 @@ export function registerCoreOwnershipParticipant(ctx) {
|
|
|
59
63
|
await conversation.commit(async (tx) => {
|
|
60
64
|
const draft = await tx.doc(SessionMetaDoc, conversation.id);
|
|
61
65
|
draft.owner = to;
|
|
66
|
+
const agent = await tx.doc(AgentDoc, conversation.id);
|
|
67
|
+
// Pin the old owner's resolution before ownership changes: a
|
|
68
|
+
// relative cwd must not retarget into the new owner's workspace.
|
|
69
|
+
if (agent.cwd === undefined)
|
|
70
|
+
agent.cwd = fromWorkspace;
|
|
71
|
+
else if (!isAbsolute(agent.cwd)) {
|
|
72
|
+
agent.cwd = resolve(fromWorkspace, agent.cwd);
|
|
73
|
+
}
|
|
74
|
+
await opened.rewriteUserSelection(tx, conversation.id, from, to);
|
|
62
75
|
}, BACKGROUND_CONTEXT);
|
|
63
76
|
}
|
|
64
77
|
cursor = page.next;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin routes (UI-blocks U4 Task 2, spec §13.3):
|
|
3
|
+
* `POST /v1/admin/restart` — the graceful in-place restart.
|
|
4
|
+
*
|
|
5
|
+
* The route exists ONLY under the supervised posture: `server.supervised`
|
|
6
|
+
* (env `SIGRUN_SUPERVISED`, config resolution) promises that something
|
|
7
|
+
* restarts the process after an intentional `exit 0` — the Helm chart
|
|
8
|
+
* and docker compose set it (both restart the container); the
|
|
9
|
+
* Dockerfile deliberately does NOT, so a bare `docker run` reports
|
|
10
|
+
* `not_supervised` rather than dying permanently.
|
|
11
|
+
*
|
|
12
|
+
* Semantics: admin-only (403 before any body read, like PATCH
|
|
13
|
+
* /v1/settings); unsupervised → 409 `{error: "not_supervised"}` and
|
|
14
|
+
* NOTHING runs; supervised admin → 202 `{restarting: true}` and the
|
|
15
|
+
* restart callback is scheduled on the NEXT tick (`setImmediate`) so
|
|
16
|
+
* the response always flushes before the drain begins. The callback
|
|
17
|
+
* (ServerContext.requestRestart, the serve composition's
|
|
18
|
+
* graceful-shutdown + `process.exit(0)`) owns the exit — the route
|
|
19
|
+
* never calls exit itself.
|
|
20
|
+
*
|
|
21
|
+
* Durability (Review Focus 4): the restart rides the SAME ordered
|
|
22
|
+
* shutdown as the SIGTERM path — the event bus drains pending settles
|
|
23
|
+
* and the harness close seals storage, so a run in flight at restart
|
|
24
|
+
* has its submission durable before exit; pi-durable resumes it after
|
|
25
|
+
* the supervisor brings the process back.
|
|
26
|
+
*/
|
|
27
|
+
/** Register the `/v1/admin` routes. */
|
|
28
|
+
export function registerAdminRoutes(app, ctx) {
|
|
29
|
+
app.post("/v1/admin/restart", async (request, reply) => {
|
|
30
|
+
// Admin-only: a restart is a control-plane action (it kills every
|
|
31
|
+
// in-flight connection, including other users' streams) — the
|
|
32
|
+
// gate runs before the supervised check so a non-admin learns
|
|
33
|
+
// nothing about the deploy's supervision posture.
|
|
34
|
+
if (!request.principal.admin) {
|
|
35
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
36
|
+
}
|
|
37
|
+
if (!ctx.config.supervised) {
|
|
38
|
+
return await reply.code(409).send({ error: "not_supervised" });
|
|
39
|
+
}
|
|
40
|
+
// Deferred past the reply: the 202 must reach the client before
|
|
41
|
+
// the drain closes its connection. A supervisor is expected, so a
|
|
42
|
+
// missing callback (a hand-built server in a test that forgot the
|
|
43
|
+
// seam) is a loud boot-composition error, not a silent no-op.
|
|
44
|
+
if (ctx.requestRestart === undefined) {
|
|
45
|
+
throw new Error("admin restart: supervised but no restart callback wired (serve composition error)");
|
|
46
|
+
}
|
|
47
|
+
const restart = ctx.requestRestart;
|
|
48
|
+
setImmediate(() => {
|
|
49
|
+
void Promise.resolve(restart()).catch((error) => {
|
|
50
|
+
// The shutdown path is already committed at this point —
|
|
51
|
+
// nothing to roll back to; log loud and let the exit/error
|
|
52
|
+
// surface in the deploy's logs.
|
|
53
|
+
console.error("sigrun: restart shutdown failed:", error);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
return await reply.code(202).send({ restarting: true });
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -19,9 +19,18 @@
|
|
|
19
19
|
* never a delegated act); listing serves the caller's tokens, with
|
|
20
20
|
* `?user=` the admin-only window into another's; revocation deletes the
|
|
21
21
|
* caller's own tokens, or any for an admin.
|
|
22
|
+
*
|
|
23
|
+
* Account links (multi-user M3a, spec §11.3) mirror the token routes
|
|
24
|
+
* exactly in who-may-see-what: `GET /v1/auth/accounts` lists the
|
|
25
|
+
* caller's own links (`?user=` is the admin-only window into another's),
|
|
26
|
+
* and `DELETE /v1/auth/accounts` removes one — the caller's own, or any
|
|
27
|
+
* for an admin. Both take QUERY parameters rather than path segments:
|
|
28
|
+
* provider ids contain `:` and subjects may contain `/`, `+`, or `@`.
|
|
22
29
|
*/
|
|
23
30
|
import { ipAllowed } from "../auth/ip.js";
|
|
31
|
+
import { sessionCookieOf } from "../auth/pipeline.js";
|
|
24
32
|
import { LoginLimiter } from "../auth/login-limiter.js";
|
|
33
|
+
import { accountLinkWire } from "../views.js";
|
|
25
34
|
/** The session cookie name (spec §5.4). */
|
|
26
35
|
export const SESSION_COOKIE = "sigrun_session";
|
|
27
36
|
/** The failed-login delay (ms), per the brief. */
|
|
@@ -37,9 +46,16 @@ export function buildSessionCookie(token, request, ctx) {
|
|
|
37
46
|
const forwarded = request.headers["x-forwarded-proto"];
|
|
38
47
|
const forwardedProto = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
39
48
|
const secure = request.protocol === "https" || (trusted && forwardedProto === "https");
|
|
40
|
-
|
|
49
|
+
const maxAge = sessionTtlMs(ctx) / 1_000;
|
|
50
|
+
return (`${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}` +
|
|
41
51
|
(secure ? "; Secure" : ""));
|
|
42
52
|
}
|
|
53
|
+
/** The live session TTL consumed by the login flow (days → milliseconds). */
|
|
54
|
+
export function sessionTtlMs(ctx) {
|
|
55
|
+
const days = ctx.opened.settings.read().server?.auth?.sessionDays ??
|
|
56
|
+
ctx.config.auth.sessionDays;
|
|
57
|
+
return days * 86_400_000;
|
|
58
|
+
}
|
|
43
59
|
/** The wire form of one token record — never the digest. */
|
|
44
60
|
function tokenWire(record) {
|
|
45
61
|
return {
|
|
@@ -47,6 +63,8 @@ function tokenWire(record) {
|
|
|
47
63
|
name: record.name,
|
|
48
64
|
user: record.user,
|
|
49
65
|
createdAt: record.createdAt,
|
|
66
|
+
...(record.kind === undefined ? {} : { kind: record.kind }),
|
|
67
|
+
...(record.expiresAt === undefined ? {} : { expiresAt: record.expiresAt }),
|
|
50
68
|
};
|
|
51
69
|
}
|
|
52
70
|
/** Register the auth routes: login, logout, whoami, and token management. */
|
|
@@ -81,7 +99,14 @@ export function registerAuthRoutes(app, ctx) {
|
|
|
81
99
|
.header("set-cookie", buildSessionCookie(token, request, ctx))
|
|
82
100
|
.send({ user: user.user });
|
|
83
101
|
});
|
|
84
|
-
app.post("/v1/auth/logout", async (
|
|
102
|
+
app.post("/v1/auth/logout", async (request, reply) => {
|
|
103
|
+
const presented = sessionCookieOf(request);
|
|
104
|
+
const record = presented
|
|
105
|
+
? ctx.opened.identity.tokens.verify(presented)
|
|
106
|
+
: undefined;
|
|
107
|
+
if (record?.kind === "session") {
|
|
108
|
+
await ctx.opened.identity.tokens.revoke(record.id);
|
|
109
|
+
}
|
|
85
110
|
return await reply
|
|
86
111
|
.code(204)
|
|
87
112
|
.header("set-cookie", `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`)
|
|
@@ -89,13 +114,19 @@ export function registerAuthRoutes(app, ctx) {
|
|
|
89
114
|
});
|
|
90
115
|
app.get("/v1/auth/whoami", async (request) => {
|
|
91
116
|
const principal = request.principal;
|
|
117
|
+
const record = principal.user === undefined
|
|
118
|
+
? undefined
|
|
119
|
+
: ctx.opened.identity.users.get(principal.user);
|
|
92
120
|
return {
|
|
93
121
|
...(principal.user === undefined ? {} : { user: principal.user }),
|
|
94
122
|
...(principal.name === undefined ? {} : { name: principal.name }),
|
|
95
123
|
...(principal.email === undefined ? {} : { email: principal.email }),
|
|
124
|
+
...(record?.workspace === undefined ? {} : { workspace: record.workspace }),
|
|
125
|
+
...(record?.grants === undefined ? {} : { grants: record.grants }),
|
|
96
126
|
admin: principal.admin,
|
|
97
127
|
service: principal.service,
|
|
98
128
|
method: principal.method,
|
|
129
|
+
...(principal.provider === undefined ? {} : { provider: principal.provider }),
|
|
99
130
|
};
|
|
100
131
|
});
|
|
101
132
|
// ─── Token management (Task 4) ─────────────────────────────────────
|
|
@@ -169,4 +200,59 @@ export function registerAuthRoutes(app, ctx) {
|
|
|
169
200
|
await ctx.opened.identity.tokens.revoke(id);
|
|
170
201
|
return await reply.code(204).send();
|
|
171
202
|
});
|
|
203
|
+
// ─── Account links (multi-user M3a, spec §11.3) ────────────────────
|
|
204
|
+
app.get("/v1/auth/accounts", async (request, reply) => {
|
|
205
|
+
const principal = request.principal;
|
|
206
|
+
const query = request.query;
|
|
207
|
+
// `?user=` is the admin-only window into another's links; naming
|
|
208
|
+
// oneself is the same payload as no query (the identity is theirs
|
|
209
|
+
// either way) and is allowed for the same reason the token list
|
|
210
|
+
// allows it.
|
|
211
|
+
if (query.user !== undefined) {
|
|
212
|
+
if (!principal.admin && query.user !== principal.user) {
|
|
213
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
214
|
+
}
|
|
215
|
+
const response = {
|
|
216
|
+
accounts: ctx.opened.identity.accounts
|
|
217
|
+
.listForUser(query.user)
|
|
218
|
+
.map(accountLinkWire),
|
|
219
|
+
};
|
|
220
|
+
return response;
|
|
221
|
+
}
|
|
222
|
+
// The caller's own links — a principal with no directory user (the
|
|
223
|
+
// naive single-operator superuser, a userless service principal)
|
|
224
|
+
// gets the whole store, which for that posture IS the operator's own
|
|
225
|
+
// (the same rule the token list applies).
|
|
226
|
+
const response = {
|
|
227
|
+
accounts: principal.user === undefined
|
|
228
|
+
? ctx.opened.identity.accounts.list().map(accountLinkWire)
|
|
229
|
+
: ctx.opened.identity.accounts
|
|
230
|
+
.listForUser(principal.user)
|
|
231
|
+
.map(accountLinkWire),
|
|
232
|
+
};
|
|
233
|
+
return response;
|
|
234
|
+
});
|
|
235
|
+
app.delete("/v1/auth/accounts", async (request, reply) => {
|
|
236
|
+
const principal = request.principal;
|
|
237
|
+
const query = request.query;
|
|
238
|
+
if (query.provider === undefined ||
|
|
239
|
+
query.provider === "" ||
|
|
240
|
+
query.subject === undefined ||
|
|
241
|
+
query.subject === "") {
|
|
242
|
+
return await reply.code(400).send({ error: "invalid_query" });
|
|
243
|
+
}
|
|
244
|
+
// The lookup, the ownership check, and the removal happen INSIDE one
|
|
245
|
+
// serialized mutation: reading the link here and removing it later
|
|
246
|
+
// would leave a window where the subject is unlinked and re-linked
|
|
247
|
+
// to a different user, and this request would delete the NEW
|
|
248
|
+
// holder's link on the strength of a stale check.
|
|
249
|
+
const outcome = await ctx.opened.identity.accounts.unlinkAuthorized(query.provider, query.subject, { user: principal.user, admin: principal.admin });
|
|
250
|
+
if (outcome === "absent") {
|
|
251
|
+
return await reply.code(404).send({ error: "unknown_account" });
|
|
252
|
+
}
|
|
253
|
+
if (outcome === "forbidden") {
|
|
254
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
255
|
+
}
|
|
256
|
+
return await reply.code(204).send();
|
|
257
|
+
});
|
|
172
258
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* list, import) take no pre-handler.
|
|
8
8
|
*/
|
|
9
9
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
10
|
-
import {
|
|
10
|
+
import { accessPrincipalOf } from "../auth/access-principal.js";
|
|
11
11
|
import { ConversationOpError, createConversationOp, forkOp, listConversationsOp, patchMetaOp, readMetaOp, } from "../conversation-ops.js";
|
|
12
12
|
import { loadConversation } from "../conversation-handler.js";
|
|
13
13
|
import { ID_PARAMS_SCHEMA, readView, stampSessionOwner, storeAgentChange, } from "../helpers.js";
|
|
@@ -70,10 +70,7 @@ export function registerConversationRoutes(app, ctx) {
|
|
|
70
70
|
: { archivedOnly: query.archived === "true" }),
|
|
71
71
|
...(query.mine === "true" ? { mine: true } : {}),
|
|
72
72
|
};
|
|
73
|
-
const conversations = await listConversationsOp(ctx,
|
|
74
|
-
user: request.principal.user,
|
|
75
|
-
superuser: isSuperuser(request.principal),
|
|
76
|
-
}, filters);
|
|
73
|
+
const conversations = await listConversationsOp(ctx, accessPrincipalOf(request.principal, opened.identity.users), filters);
|
|
77
74
|
return { conversations };
|
|
78
75
|
});
|
|
79
76
|
app.get("/v1/conversations/:id", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx) }, async (request) => {
|
|
@@ -234,7 +231,7 @@ export function registerConversationRoutes(app, ctx) {
|
|
|
234
231
|
}, BACKGROUND_CONTEXT);
|
|
235
232
|
// The imported conversation is the IMPORTER's, not the source's
|
|
236
233
|
// (the JSONL never carries meta — the stamp is the only owner).
|
|
237
|
-
await stampSessionOwner(conversation, request.principal.user);
|
|
234
|
+
await stampSessionOwner(conversation, request.principal.user, opened);
|
|
238
235
|
const view = await readView(conversation);
|
|
239
236
|
return await reply.code(201).send({ id: conversation.id, view });
|
|
240
237
|
});
|
|
@@ -31,14 +31,15 @@
|
|
|
31
31
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
32
32
|
import { randomUUID } from "node:crypto";
|
|
33
33
|
import { accessOf } from "@sigrun/shared/access";
|
|
34
|
+
import { accessPrincipalOf } from "../auth/access-principal.js";
|
|
34
35
|
import { SessionMetaDoc } from "../session-meta.js";
|
|
35
36
|
/** The heartbeat cadence (the /stream pattern: proxy idle-timeout survival). */
|
|
36
37
|
const HEARTBEAT_MS = 15_000;
|
|
37
38
|
/** Register the global events stream route. */
|
|
38
39
|
export function registerEventRoutes(app, ctx) {
|
|
39
40
|
app.get("/v1/events", async (request, reply) => {
|
|
40
|
-
const principal = request.principal;
|
|
41
41
|
const { opened } = ctx;
|
|
42
|
+
const { principal } = request;
|
|
42
43
|
reply.hijack();
|
|
43
44
|
const raw = reply.raw;
|
|
44
45
|
raw.writeHead(200, {
|
|
@@ -84,6 +85,9 @@ export function registerEventRoutes(app, ctx) {
|
|
|
84
85
|
* principal-facing).
|
|
85
86
|
*/
|
|
86
87
|
const visible = async (event) => {
|
|
88
|
+
// Like SessionMeta, stored groups are current PER EVENT: group
|
|
89
|
+
// changes affect the next event on an existing connection.
|
|
90
|
+
const accessPrincipal = accessPrincipalOf(principal, opened.identity.users);
|
|
87
91
|
switch (event.type) {
|
|
88
92
|
// Conversation-scoped: SessionMeta access, re-snapshotted per
|
|
89
93
|
// event (share → the NEXT event arrives; un-share withholds the
|
|
@@ -103,7 +107,7 @@ export function registerEventRoutes(app, ctx) {
|
|
|
103
107
|
console.error("events route: SessionMeta snapshot failed:", error);
|
|
104
108
|
return undefined; // a raced read withholds that one event
|
|
105
109
|
}
|
|
106
|
-
return accessOf(
|
|
110
|
+
return accessOf(accessPrincipal, meta ?? {}) !== "none" ? event : undefined;
|
|
107
111
|
}
|
|
108
112
|
// Broadcast: every authenticated principal.
|
|
109
113
|
case "providers.changed":
|
|
@@ -112,7 +116,7 @@ export function registerEventRoutes(app, ctx) {
|
|
|
112
116
|
// carries the caller's access (the wire adds what the hub's
|
|
113
117
|
// access-neutral copy omits).
|
|
114
118
|
case "job.changed": {
|
|
115
|
-
const access = accessOf(
|
|
119
|
+
const access = accessOf(accessPrincipal, {
|
|
116
120
|
...(event.job.owner === undefined
|
|
117
121
|
? {}
|
|
118
122
|
: { owner: event.job.owner }),
|
|
@@ -129,6 +133,12 @@ export function registerEventRoutes(app, ctx) {
|
|
|
129
133
|
// principal-facing wire event.
|
|
130
134
|
case "taskSettled":
|
|
131
135
|
return undefined;
|
|
136
|
+
// UI-blocks U4 (spec §13.2): settings changes are OPERATOR
|
|
137
|
+
// surface — delivered to ADMINS only, both scopes (a
|
|
138
|
+
// server-scope PATCH and an extension's settings PUT alike;
|
|
139
|
+
// the admin gate is the same one the writes carry).
|
|
140
|
+
case "settings.changed":
|
|
141
|
+
return request.principal.admin ? event : undefined;
|
|
132
142
|
// UI-blocks U3 Task 4 (spec §7.3): delivered to a watching
|
|
133
143
|
// stream ONLY. The hub copy is the service's internal event
|
|
134
144
|
// (SurfaceChangedInternal) which carries the resolved stream
|
|
@@ -137,6 +147,21 @@ export function registerEventRoutes(app, ctx) {
|
|
|
137
147
|
// The DELIVERED frame is the wire shape (the internal field
|
|
138
148
|
// stripped — the stream list is routing metadata, never wire).
|
|
139
149
|
case "surface.changed": {
|
|
150
|
+
// The CHAT shape (U5 Task 2, spec §8.2): conversation-scoped —
|
|
151
|
+
// the event names its (conversationId, entryId) directly, so
|
|
152
|
+
// visibility is the conversation's own (SessionMeta
|
|
153
|
+
// re-snapshotted per event; hidden conversations see nothing).
|
|
154
|
+
if ("conversationId" in event) {
|
|
155
|
+
let meta;
|
|
156
|
+
try {
|
|
157
|
+
meta = await opened.harness.snapshot(SessionMetaDoc, event.conversationId, BACKGROUND_CONTEXT);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
console.error("events route: SessionMeta snapshot failed:", error);
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
return accessOf(principal, meta ?? {}) !== "none" ? event : undefined;
|
|
164
|
+
}
|
|
140
165
|
// SAFETY: the hub carries the service's internal event (the
|
|
141
166
|
// wire shape plus the publish-time stream list); every
|
|
142
167
|
// surface.changed the hub sees comes from bumpRev, which
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* - `GET /v1/extensions` — one entry per loaded outcome with `status`,
|
|
6
6
|
* `error?`, the manifest `version?`, the declared
|
|
7
7
|
* `interactionKinds`/`ambientKeys`, the idle-worker projection, and
|
|
8
|
-
* the contributed channel ids (gateway C1, `channels?`).
|
|
8
|
+
* the contributed channel ids (gateway C1, `channels?`). Every principal
|
|
9
|
+
* may read the inventory; `services` is populated for admins only.
|
|
9
10
|
* - `GET`/`PUT /v1/extensions/:slug/settings` (PUT admin) — the
|
|
10
11
|
* `settings/<slug>.json` object verbatim. Values an extension reads at
|
|
11
12
|
* factory time apply at RESTART; per-run reads see the write live.
|
|
@@ -35,7 +36,7 @@ export function registerExtensionRoutes(app, ctx) {
|
|
|
35
36
|
// row here — `builtin: true`, `replacedBy` the configured specifier,
|
|
36
37
|
// status "replaced" — while a DISABLED one is absent entirely
|
|
37
38
|
// (ruling 6).
|
|
38
|
-
app.get("/v1/extensions", async () => {
|
|
39
|
+
app.get("/v1/extensions", async (request) => {
|
|
39
40
|
const workerStatuses = opened.workerStatuses();
|
|
40
41
|
const extensions = opened.extensionOutcomes.map((outcome) => {
|
|
41
42
|
const kinds = opened.seamKinds.get(outcome.name);
|
|
@@ -65,6 +66,9 @@ export function registerExtensionRoutes(app, ctx) {
|
|
|
65
66
|
interactionKinds: [...(kinds?.interaction ?? [])],
|
|
66
67
|
ambientKeys: [...(kinds?.ambient ?? [])],
|
|
67
68
|
workers: workers ?? [],
|
|
69
|
+
services: request.principal.admin
|
|
70
|
+
? opened.userServices.statuses().filter((s) => s.slug === outcome.name)
|
|
71
|
+
: [],
|
|
68
72
|
routes: routes?.routes.map((route) => ({
|
|
69
73
|
method: route.method,
|
|
70
74
|
path: route.path,
|
|
@@ -106,6 +110,9 @@ export function registerExtensionRoutes(app, ctx) {
|
|
|
106
110
|
interactionKinds: [],
|
|
107
111
|
ambientKeys: [],
|
|
108
112
|
workers: [],
|
|
113
|
+
services: request.principal.admin
|
|
114
|
+
? opened.userServices.statuses().filter((s) => s.slug === slug)
|
|
115
|
+
: [],
|
|
109
116
|
routes: [],
|
|
110
117
|
});
|
|
111
118
|
}
|
|
@@ -113,13 +120,32 @@ export function registerExtensionRoutes(app, ctx) {
|
|
|
113
120
|
});
|
|
114
121
|
// The per-extension settings object (`settings/<slug>.json`), absent
|
|
115
122
|
// file → `{}`. GET is ungated beyond the pipeline: settings values are
|
|
116
|
-
// operator-facing config, not secrets.
|
|
123
|
+
// operator-facing config, not secrets. U4 (spec §13.2): the response
|
|
124
|
+
// carries `pendingRestart` too — extension-declared fields default to
|
|
125
|
+
// LIVE (§13.1) and core carries no extension-restart keys, so the list
|
|
126
|
+
// is always `[]` today; the field exists so the §13.4 callout renders
|
|
127
|
+
// from one shape and an extension that later declares restart fields
|
|
128
|
+
// has its slot.
|
|
117
129
|
app.get("/v1/extensions/:slug/settings", async (request, reply) => {
|
|
118
130
|
const { slug } = request.params;
|
|
119
131
|
if (!isKnownSlug(slug)) {
|
|
120
132
|
return await reply.code(404).send({ error: "extension_not_found" });
|
|
121
133
|
}
|
|
122
|
-
|
|
134
|
+
const stored = await opened.extensionStores.settingsFor(slug).get();
|
|
135
|
+
// Declared secret keys NEVER leave this route (U4 fix wave): a
|
|
136
|
+
// settings file predating the write-only rule (or a hand edit)
|
|
137
|
+
// can carry one in the clear — the surface's read path strips
|
|
138
|
+
// it; the raw GET must too.
|
|
139
|
+
const declaredSecrets = new Set((opened.extensionSettingsSections.find((binding) => binding.extension === slug)?.defs ?? []).flatMap((def) => (def.secrets ?? []).map((secret) => secret.key)));
|
|
140
|
+
const settings = {};
|
|
141
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
142
|
+
if (!declaredSecrets.has(key))
|
|
143
|
+
settings[key] = value;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
settings,
|
|
147
|
+
pendingRestart: [],
|
|
148
|
+
};
|
|
123
149
|
});
|
|
124
150
|
// Replace the settings object VERBATIM (admin — a settings file is
|
|
125
151
|
// code-adjacent input the extension consumes). The body must be a
|
|
@@ -142,7 +168,13 @@ export function registerExtensionRoutes(app, ctx) {
|
|
|
142
168
|
}
|
|
143
169
|
const value = body;
|
|
144
170
|
await opened.extensionStores.settingsFor(slug).set(value);
|
|
145
|
-
|
|
171
|
+
// U4 §13.2: the write committed — publish `settings.changed`
|
|
172
|
+
// (extension scope) and fire every extension's
|
|
173
|
+
// `onSettingsChanged`. The keys are the object's TOP-LEVEL
|
|
174
|
+
// fields (§13.1's extension vocabulary: an extension's fields
|
|
175
|
+
// are its own schema; core's dotted registry does not apply).
|
|
176
|
+
ctx.publishSettingsChanged({ extension: slug }, Object.keys(value));
|
|
177
|
+
return { settings: value, pendingRestart: [] };
|
|
146
178
|
});
|
|
147
179
|
// The secret key list: stored keys plus the env fallbacks that are not
|
|
148
180
|
// stored. Key names and sources only — the response never carries a
|