@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.
Files changed (137) hide show
  1. package/CHANGELOG.md +192 -0
  2. package/README.md +445 -42
  3. package/dist/blocks/build-stamp.json +13 -0
  4. package/dist/blocks/builders.d.ts +57 -0
  5. package/dist/blocks/images.d.ts +26 -0
  6. package/dist/blocks/index.d.ts +20 -0
  7. package/dist/blocks/limits.d.ts +25 -0
  8. package/dist/blocks/placements.d.ts +26 -0
  9. package/dist/blocks/schema.d.ts +38 -0
  10. package/dist/blocks/schema.js +9 -2
  11. package/dist/blocks/validate.d.ts +73 -0
  12. package/dist/blocks/validate.js +57 -8
  13. package/dist/cli.js +26 -2
  14. package/dist/config.js +67 -1
  15. package/dist/extension.d.ts +230 -22
  16. package/dist/harness/ambient.js +39 -185
  17. package/dist/harness/blocks/entries.js +47 -0
  18. package/dist/harness/blocks/state.js +58 -0
  19. package/dist/harness/blocks/tools.js +274 -0
  20. package/dist/harness/boot.js +259 -20
  21. package/dist/harness/builtins.js +15 -11
  22. package/dist/harness/event-bus.js +105 -10
  23. package/dist/harness/identity/accounts.js +186 -0
  24. package/dist/harness/identity/groups.js +14 -0
  25. package/dist/harness/identity/index.js +13 -6
  26. package/dist/harness/identity/json-store.js +15 -4
  27. package/dist/harness/identity/setup-code-check.js +9 -0
  28. package/dist/harness/identity/tokens.js +28 -5
  29. package/dist/harness/identity/users.js +111 -12
  30. package/dist/harness/interactions.js +292 -136
  31. package/dist/harness/loader.js +271 -17
  32. package/dist/harness/packages.js +1 -1
  33. package/dist/harness/reserved-sections.js +21 -0
  34. package/dist/harness/seam-host.js +92 -6
  35. package/dist/harness/sections/context-files.js +1 -1
  36. package/dist/harness/settings.js +63 -2
  37. package/dist/harness/stores.js +241 -4
  38. package/dist/harness/user-services.js +391 -0
  39. package/dist/harness/user-tools.js +187 -0
  40. package/dist/harness/workspace.js +61 -57
  41. package/dist/jobs/fire.js +10 -16
  42. package/dist/sandbox/availability.js +211 -0
  43. package/dist/sandbox/command-observer.js +59 -0
  44. package/dist/sandbox/grants.js +159 -0
  45. package/dist/sandbox/helper.js +29 -0
  46. package/dist/sandbox/isolation.js +29 -0
  47. package/dist/sandbox/launch.js +31 -0
  48. package/dist/sandbox/os-account.js +49 -0
  49. package/dist/sandbox/policy.js +274 -0
  50. package/dist/sandbox/pool.js +548 -0
  51. package/dist/sandbox/protocol.js +167 -0
  52. package/dist/sandbox/sandboxed-env.js +245 -0
  53. package/dist/sandbox/unavailable-env.js +49 -0
  54. package/dist/sandbox/worker.js +261 -0
  55. package/dist/serve.js +213 -25
  56. package/dist/server/auth/access-principal.js +16 -0
  57. package/dist/server/auth/pipeline.js +99 -36
  58. package/dist/server/blocks/answers.js +94 -0
  59. package/dist/server/blocks/chat-surfaces.js +393 -0
  60. package/dist/server/blocks/notes.js +216 -0
  61. package/dist/server/blocks/summaries.js +328 -0
  62. package/dist/server/commands.js +5 -0
  63. package/dist/server/content-parts-lazy.js +13 -0
  64. package/dist/server/conversation-handler.js +2 -5
  65. package/dist/server/conversation-ops.js +138 -37
  66. package/dist/server/helpers.js +18 -6
  67. package/dist/server/host-bridge.js +40 -20
  68. package/dist/server/images/cache.js +213 -0
  69. package/dist/server/images/convert.js +13 -0
  70. package/dist/server/images/fetch.js +302 -0
  71. package/dist/server/images/ranges.js +42 -0
  72. package/dist/server/images/rewrite.js +94 -0
  73. package/dist/server/images/sign.js +84 -0
  74. package/dist/server/images/sniff.js +62 -0
  75. package/dist/server/index.js +152 -4
  76. package/dist/server/login/flows.js +126 -0
  77. package/dist/server/login/http.js +31 -0
  78. package/dist/server/login/resolve.js +133 -0
  79. package/dist/server/login/setup-claim.js +55 -0
  80. package/dist/server/login/usernames.js +15 -0
  81. package/dist/server/notifications.js +21 -4
  82. package/dist/server/ownership.js +13 -0
  83. package/dist/server/routes/admin.js +58 -0
  84. package/dist/server/routes/auth.js +88 -2
  85. package/dist/server/routes/conversations.js +3 -6
  86. package/dist/server/routes/events.js +28 -3
  87. package/dist/server/routes/extensions.js +37 -5
  88. package/dist/server/routes/images.js +274 -0
  89. package/dist/server/routes/interactions.js +57 -68
  90. package/dist/server/routes/jobs.js +2 -8
  91. package/dist/server/routes/login.js +223 -0
  92. package/dist/server/routes/me.js +90 -0
  93. package/dist/server/routes/meta.js +19 -0
  94. package/dist/server/routes/onboarding.js +94 -84
  95. package/dist/server/routes/settings-sections.js +190 -0
  96. package/dist/server/routes/settings.js +16 -4
  97. package/dist/server/routes/submissions.js +1 -1
  98. package/dist/server/routes/surfaces.js +175 -23
  99. package/dist/server/routes/users.js +116 -20
  100. package/dist/server/schema-fixture.js +213 -0
  101. package/dist/server/session-meta.js +3 -0
  102. package/dist/server/shutdown.js +11 -4
  103. package/dist/server/static.js +18 -0
  104. package/dist/server/surfaces/dedup.js +9 -2
  105. package/dist/server/surfaces/service.js +181 -13
  106. package/dist/server/surfaces/settings-surfaces.js +597 -0
  107. package/dist/server/turns/submit-turn.js +26 -0
  108. package/dist/server/views.js +16 -0
  109. package/dist/settings/core-sections.js +532 -0
  110. package/dist/settings/pending.js +181 -0
  111. package/dist/settings/registry.js +453 -0
  112. package/dist/skills/index.js +6 -0
  113. package/node_modules/@sigrun/shared/CHANGELOG.md +11 -0
  114. package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
  115. package/node_modules/@sigrun/shared/dist/access.js +11 -4
  116. package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
  117. package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
  118. package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
  119. package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
  120. package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
  121. package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
  122. package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
  123. package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
  124. package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
  125. package/node_modules/@sigrun/shared/dist/themes.js +2 -3
  126. package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
  127. package/node_modules/@sigrun/shared/dist/turns.js +53 -3
  128. package/node_modules/@sigrun/shared/dist/views.d.ts +202 -12
  129. package/node_modules/@sigrun/shared/dist/views.js +16 -0
  130. package/node_modules/@sigrun/shared/package.json +1 -1
  131. package/package.json +13 -9
  132. package/webui/dist/assets/index-SO2W4jjk.js +55 -0
  133. package/webui/dist/assets/index-yqkL6BSS.css +1 -0
  134. package/webui/dist/index.html +2 -2
  135. package/CONTEXT.md +0 -163
  136. package/webui/dist/assets/index-CiYInlLx.css +0 -1
  137. 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: "Reply ready",
143
- body: displayName(meta, conversationId),
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", () => {
@@ -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
- return (`${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/` +
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 (_request, reply) => {
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 { isSuperuser } from "../auth/principal.js";
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(principal, meta ?? {}) !== "none" ? event : undefined;
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(principal, {
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
- return opened.extensionStores.settingsFor(slug).get();
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
- return value;
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