@monster-orange/den 0.4.0 → 0.4.2

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 (58) hide show
  1. package/CHANGELOG.md +457 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +216 -16
  4. package/docs/reference.md +177 -0
  5. package/package.json +3 -1
  6. package/src/shared/actor-token.ts +275 -0
  7. package/src/shared/agent-actor.ts +12 -2
  8. package/src/shared/agents/den-claude.ts +99 -1
  9. package/src/shared/agents/runtime.ts +7 -0
  10. package/src/shared/agents/runtimes.ts +9 -2
  11. package/src/shared/den-config.ts +52 -3
  12. package/src/shared/environment-manager/scope.ts +22 -0
  13. package/src/shared/environment-manager/service-runner.ts +94 -2
  14. package/src/shared/profile.ts +15 -4
  15. package/src/shared/sessions/client.ts +16 -2
  16. package/src/shared/sessions/den-pty.ts +10 -2
  17. package/src/shared/sessions/env.ts +13 -3
  18. package/src/shared/sessions/host.ts +56 -8
  19. package/src/shared/sessions/types.ts +8 -0
  20. package/src/shared/web-secret.ts +181 -0
  21. package/src/shared/web-session.ts +491 -0
  22. package/src/ui/cli/agent-ops.ts +843 -0
  23. package/src/ui/cli/agents-command.ts +97 -615
  24. package/src/ui/cli/flag-parser.ts +21 -0
  25. package/src/ui/cli/index.ts +127 -11
  26. package/src/ui/cli/sessions-command.ts +49 -5
  27. package/src/ui/cli/target-workspace.ts +105 -0
  28. package/src/ui/cli/web-command.ts +542 -14
  29. package/src/ui/mcp/api.ts +7 -0
  30. package/src/ui/mcp/server.ts +74 -6
  31. package/src/ui/server/agent-routes.ts +324 -0
  32. package/src/ui/server/audit.ts +81 -27
  33. package/src/ui/server/http-errors.ts +21 -3
  34. package/src/ui/server/index.ts +821 -66
  35. package/src/ui/server/public/agents-page.js +654 -0
  36. package/src/ui/server/public/agents-section.js +299 -0
  37. package/src/ui/server/public/agents.html +439 -0
  38. package/src/ui/server/public/appearance.js +3 -0
  39. package/src/ui/server/public/error-page.js +103 -0
  40. package/src/ui/server/public/header.js +45 -11
  41. package/src/ui/server/public/i18n/es.js +90 -1
  42. package/src/ui/server/public/i18n/ja.js +86 -1
  43. package/src/ui/server/public/local-dev.html +53 -0
  44. package/src/ui/server/public/logs.html +3 -0
  45. package/src/ui/server/public/panel.js +1 -1
  46. package/src/ui/server/public/release.html +8 -0
  47. package/src/ui/server/public/settings-page.js +5 -0
  48. package/src/ui/server/public/settings-registry.js +33 -0
  49. package/src/ui/server/public/shell.js +248 -5
  50. package/src/ui/server/public/topbar.js +51 -2
  51. package/src/ui/server/request-guard.ts +670 -0
  52. package/src/ui/server/route-classes.ts +317 -0
  53. package/src/ui/server/session-routes.ts +445 -0
  54. package/src/ui/server/shutdown-guard.ts +91 -0
  55. package/src/ui/server/sudo-store.ts +29 -3
  56. package/src/ui/server/ui-assets.ts +10 -1
  57. package/src/ui/server/websocket.ts +326 -0
  58. package/src/ui/server/workspace-routes.ts +13 -7
@@ -16,7 +16,8 @@ import {
16
16
  type ResolvedMcpConfig,
17
17
  } from "../../shared/den-config.js";
18
18
  import { readWebPort } from "../../shared/edge/config.js";
19
- import { engineVersion } from "../../shared/paths.js";
19
+ import { engineVersion, getStateDir } from "../../shared/paths.js";
20
+ import { ACTOR_TOKEN_ENV, lookupActorToken } from "../../shared/actor-token.js";
20
21
  import { product } from "../../shared/product.js";
21
22
  import { DEFAULT_PORT } from "../../shared/constants.js";
22
23
  import { createDenApi, type DenApi } from "./api.js";
@@ -259,6 +260,63 @@ export async function runOneShot(
259
260
  return r.isError ? 1 : 0;
260
261
  }
261
262
 
263
+ /**
264
+ * SEC-1c: the MCP server Den starts for an agent carries the agent's actor
265
+ * token (DEN_ACTOR_TOKEN, inherited from the agent's session) as the
266
+ * credential of every call (cliAuthHeaders), and the web server records
267
+ * that token's agent as the actor. Why it must not start, or null: a token
268
+ * minted for another agent than `--actor`, or one this distribution never
269
+ * minted. With no token it runs as before (a person's own scoped server:
270
+ * the person token, its --actor an unverified claim).
271
+ */
272
+ export function actorTokenRefusal(
273
+ scope: McpScope | null,
274
+ env: NodeJS.ProcessEnv,
275
+ stateDir: string,
276
+ ): string | null {
277
+ const token = env[ACTOR_TOKEN_ENV];
278
+ if (token === undefined) return null;
279
+ const rec = lookupActorToken(stateDir, token);
280
+ if (!rec)
281
+ return `${ACTOR_TOKEN_ENV} is not a live actor token of this distribution (stopped, resumed, or never minted here)`;
282
+ if (rec.revokedAt)
283
+ return `${ACTOR_TOKEN_ENV} was revoked (\`web logout --all\`): restart or resume the agent to get a new one`;
284
+ if (scope && scope.actor !== rec.actor)
285
+ return `--actor ${scope.actor} does not match ${ACTOR_TOKEN_ENV}, which is ${rec.actor}'s`;
286
+ if (scope && scope.workspace !== rec.workspace)
287
+ return `--workspace ${scope.workspace} does not match ${ACTOR_TOKEN_ENV}, which is workspace ${rec.workspace}'s`;
288
+ return null;
289
+ }
290
+
291
+ /**
292
+ * The line the stdio server prints at start (stderr). Its actor reads
293
+ * `as agent:<id>` only when DEN_ACTOR_TOKEN verified it (actorTokenRefusal
294
+ * passed with a token); without one the actor is a claim, and says so.
295
+ */
296
+ export function mcpStartupLine(o: {
297
+ cli: string;
298
+ tools: number;
299
+ actions: readonly string[];
300
+ baseUrl: string;
301
+ scope: McpScope | null;
302
+ /** A DEN_ACTOR_TOKEN that matched the scope's actor and workspace. */
303
+ verified: boolean;
304
+ }): string {
305
+ const { scope } = o;
306
+ const actor = scope
307
+ ? o.verified
308
+ ? scope.actor
309
+ : `${scope.actor} (unverified: no ${ACTOR_TOKEN_ENV})`
310
+ : "";
311
+ return (
312
+ `${o.cli} mcp: ${plural(o.tools, "tool")} ` +
313
+ `(${o.actions.length ? `actions: ${o.actions.join(", ")}` : "read only"}) → ${o.baseUrl}` +
314
+ (scope ? ` · scoped to ${scope.workspace} as ${actor}` : "") +
315
+ (scope?.grants?.length ? ` · grants: ${scope.grants.join(", ")}` : "") +
316
+ "\n"
317
+ );
318
+ }
319
+
262
320
  /** Run `<cli> mcp` until stdin closes (or one `--call` / `--list`). */
263
321
  export async function runMcp(rawArgv: string[]): Promise<void> {
264
322
  const cli = product().cli;
@@ -279,6 +337,12 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
279
337
  process.exitCode = 2;
280
338
  return;
281
339
  }
340
+ const refusedToken = actorTokenRefusal(scope, process.env, getStateDir());
341
+ if (refusedToken) {
342
+ process.stderr.write(`${cli} mcp: ${refusedToken}\n`);
343
+ process.exitCode = 2;
344
+ return;
345
+ }
282
346
  if (scope?.grants?.length) {
283
347
  // A grant is config: a flag alone never widens a scope.
284
348
  let refused: string | null;
@@ -336,10 +400,14 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
336
400
  });
337
401
  await server.connect(new StdioServerTransport());
338
402
  process.stderr.write(
339
- `${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
340
- `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}` +
341
- (scope ? ` · scoped to ${scope.workspace} as ${scope.actor}` : "") +
342
- (scope?.grants?.length ? ` · grants: ${scope.grants.join(", ")}` : "") +
343
- "\n",
403
+ mcpStartupLine({
404
+ cli,
405
+ tools: toolsFor(cfg).length,
406
+ actions: cfg.actions,
407
+ baseUrl,
408
+ scope,
409
+ // actorTokenRefusal passed: a token, if any, is this scope's.
410
+ verified: process.env[ACTOR_TOKEN_ENV] !== undefined,
411
+ }),
344
412
  );
345
413
  }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * The Workspaces page's Agents section (AG-4a, AG spec §5c), its routes:
3
+ *
4
+ * GET /api/personas personas and runtimes
5
+ * GET /api/workspaces/<ws>/agents the workspace's agents
6
+ * POST /api/workspaces/<ws>/agents start { persona, runtime?, model?, id? }
7
+ * POST /api/workspaces/<ws>/agents/<id>/stop
8
+ * POST /api/workspaces/<ws>/agents/<id>/resume
9
+ *
10
+ * Each calls the CLI's own operation (cli/agent-ops.ts): neither the page
11
+ * nor the CLI keeps state of its own, so an agent started here is the one
12
+ * `<cli> agents list -w <ws>` shows, and the other way round. A workspace
13
+ * lists its own agents only: another workspace's id is no agent here.
14
+ *
15
+ * Guards on Start, Stop and Resume, as on a service action:
16
+ * - the lock (R8): locked, the action is refused (423 locked); the page
17
+ * opens the lock popover first, as Local Dev's service actions do;
18
+ * - a person only: an agent (its actor token, SEC-1c) can't start or
19
+ * stop agents from the page (403; agents use MCP agent_spawn, §5,
20
+ * under its own guards);
21
+ * - agents.max_concurrent (the host's cap on agents, never plain
22
+ * sessions: 429 cap).
23
+ * The env is the CLI's (§5a-env): the allowlist and the runtime's, built
24
+ * from this server's environment, never all of it. Every action, refused
25
+ * or done, is audited (recordAction) with the person as its actor.
26
+ *
27
+ * There is no terminal here: the Agents tab's io (session-routes.ts) is
28
+ * the only route that writes to a session or streams its output.
29
+ */
30
+ import * as http from "http";
31
+ import { getReposRoot } from "../../shared/den-config.js";
32
+ import {
33
+ readWorkspacesFile,
34
+ resolveWorkspaceDir,
35
+ } from "../../shared/environment-manager/workspaces.js";
36
+ import { getStateDir } from "../../shared/paths.js";
37
+ import { HostError } from "../../shared/sessions/client.js";
38
+ import {
39
+ AgentError,
40
+ agentRecord,
41
+ listAgents,
42
+ listPersonas,
43
+ listRuntimes,
44
+ NOT_IN_WORKSPACE,
45
+ resolveAgentsDeps,
46
+ resumeAgent,
47
+ startAgent,
48
+ stopAgent,
49
+ type AgentsDeps,
50
+ type AgentsResolved,
51
+ type Started,
52
+ } from "../cli/agent-ops.js";
53
+ import { recordAction } from "./audit.js";
54
+ import { auditActorOf, principalOf } from "./request-guard.js";
55
+ import { readBody, sendJson } from "./http-helpers.js";
56
+ import { hasPasswordlessSudo, hasSudoPassword } from "./sudo-store.js";
57
+ import type { Actor } from "../../shared/agent-actor.js";
58
+
59
+ export interface AgentRouteDeps {
60
+ /** The operations' deps (tests: an in-process host, a temp state dir). */
61
+ agents?: AgentsDeps;
62
+ /** R8: whether the lock is open (the lock chip's own status). */
63
+ unlocked?: () => boolean;
64
+ }
65
+
66
+ const AGENTS_PATH =
67
+ /^\/api\/workspaces\/([^/]+)\/agents(?:\/([^/]+)\/(stop|resume))?$/;
68
+ /** A start's body is a few short names. */
69
+ const MAX_BODY = 4096;
70
+
71
+ /** Never a TTY: a web start's terminal is 80×24. */
72
+ const NO_TTY = { isTTY: false } as NodeJS.WriteStream;
73
+
74
+ function webDeps(over: AgentsDeps = {}): AgentsResolved {
75
+ return resolveAgentsDeps({
76
+ stateDir: () => getStateDir(),
77
+ workspaceExists: (ws) => Boolean(readWorkspacesFile().workspaces[ws]),
78
+ workspaceDir: (ws) => {
79
+ const cfg = readWorkspacesFile().workspaces[ws];
80
+ if (!cfg) throw new AgentError(`workspace "${ws}" does not exist`);
81
+ return resolveWorkspaceDir(ws, cfg, getReposRoot());
82
+ },
83
+ stdout: NO_TTY,
84
+ out: () => undefined,
85
+ err: () => undefined,
86
+ ...over,
87
+ });
88
+ }
89
+
90
+ /** The lock chip's state: an admin password set, or sudo without one. */
91
+ function defaultUnlocked(): boolean {
92
+ return hasSudoPassword() || hasPasswordlessSudo();
93
+ }
94
+
95
+ /** An AgentError (or the host's refusal) as the R26 answer. */
96
+ function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
97
+ if (e instanceof AgentError) {
98
+ // From another workspace, an agent isn't there at all: no name of it.
99
+ const error =
100
+ e.code === NOT_IN_WORKSPACE && ws
101
+ ? `no agent here in workspace ${ws}`
102
+ : e.message;
103
+ sendJson(
104
+ res,
105
+ {
106
+ ok: false,
107
+ error,
108
+ ...(e.code && e.code !== NOT_IN_WORKSPACE ? { code: e.code } : {}),
109
+ ...(e.hints.length ? { hints: e.hints } : {}),
110
+ },
111
+ e.status,
112
+ );
113
+ return;
114
+ }
115
+ if (e instanceof HostError) {
116
+ sendJson(
117
+ res,
118
+ { ok: false, error: e.message, code: "session_host" },
119
+ e.code === "cap" ? 429 : 503,
120
+ );
121
+ return;
122
+ }
123
+ throw e;
124
+ }
125
+
126
+ /** A started or resumed agent's answer: its record, or its early exit. */
127
+ function sendStarted(res: http.ServerResponse, s: Started): void {
128
+ if (s.early) {
129
+ const code =
130
+ s.early.exitCode === undefined ? "" : ` (exit ${s.early.exitCode})`;
131
+ sendJson(
132
+ res,
133
+ {
134
+ ok: false,
135
+ error: `agent ${s.rec.id} exited at once${code}`,
136
+ code: "exited_at_once",
137
+ tail: s.early.tail,
138
+ session: s.early.sessionName,
139
+ ...(s.warnings.length ? { warnings: s.warnings } : {}),
140
+ },
141
+ 502,
142
+ );
143
+ return;
144
+ }
145
+ sendJson(res, {
146
+ ok: true,
147
+ agent: {
148
+ id: s.rec.id,
149
+ persona: s.rec.persona,
150
+ runtime: s.rec.runtime,
151
+ workspace: s.rec.workspace,
152
+ cwd: s.rec.cwd,
153
+ session: s.rec.sessionName,
154
+ // The host's id: the Agents tab opens its pane by it (?open=<id>).
155
+ sessionId: s.info.ref.id,
156
+ startedAt: s.rec.startedAt,
157
+ },
158
+ ...(s.warnings.length ? { warnings: s.warnings } : {}),
159
+ });
160
+ }
161
+
162
+ /** What a POST's body names: a few optional strings. */
163
+ async function startBody(
164
+ req: http.IncomingMessage,
165
+ ): Promise<Record<string, string | undefined>> {
166
+ const raw = await readBody(req).catch(() => "");
167
+ if (raw.length > MAX_BODY)
168
+ throw new AgentError("request too large", { status: 413, usage: true });
169
+ let v: unknown;
170
+ try {
171
+ v = JSON.parse(raw || "{}");
172
+ } catch {
173
+ throw new AgentError("invalid JSON", { usage: true });
174
+ }
175
+ if (!v || typeof v !== "object" || Array.isArray(v))
176
+ throw new AgentError("the body must be a JSON object", { usage: true });
177
+ const out: Record<string, string | undefined> = {};
178
+ for (const k of ["persona", "runtime", "model", "id"]) {
179
+ const x = (v as Record<string, unknown>)[k];
180
+ if (x === undefined || x === null || x === "") continue;
181
+ if (typeof x !== "string")
182
+ throw new AgentError(`${k} must be a string`, { usage: true });
183
+ out[k] = x;
184
+ }
185
+ return out;
186
+ }
187
+
188
+ /**
189
+ * Handles the routes above; false when `pathname` is none of them. Every
190
+ * answer is JSON; an error is R26's { ok: false, error, code, requestId }
191
+ * (the server's jsonApiErrors adds the requestId).
192
+ */
193
+ export async function handleAgentRoutes(
194
+ req: http.IncomingMessage,
195
+ res: http.ServerResponse,
196
+ pathname: string,
197
+ deps: AgentRouteDeps = {},
198
+ ): Promise<boolean> {
199
+ const m = AGENTS_PATH.exec(pathname);
200
+ if (pathname !== "/api/personas" && !m) return false;
201
+ const method = req.method ?? "GET";
202
+ const d = webDeps(deps.agents);
203
+
204
+ if (!m) {
205
+ if (method !== "GET") return methodNotAllowed(res, "GET");
206
+ try {
207
+ sendJson(res, { personas: listPersonas(d), runtimes: listRuntimes(d) });
208
+ } catch (e) {
209
+ sendError(res, e);
210
+ }
211
+ return true;
212
+ }
213
+
214
+ let ws: string;
215
+ try {
216
+ ws = decodeURIComponent(m[1]);
217
+ } catch {
218
+ sendJson(res, { ok: false, error: "not a workspace name" }, 400);
219
+ return true;
220
+ }
221
+ const id = m[2] === undefined ? undefined : safeDecode(m[2]);
222
+ const action = m[3] as "stop" | "resume" | undefined;
223
+
224
+ if (!action && method === "GET") {
225
+ try {
226
+ if (!d.workspaceExists(ws))
227
+ throw new AgentError(`workspace "${ws}" does not exist`, {
228
+ status: 404,
229
+ });
230
+ sendJson(res, {
231
+ workspace: ws,
232
+ // Asking never starts a session host: with none, nothing runs.
233
+ agents: await listAgents(d, ws, { startHost: false }),
234
+ maxConcurrent: d.maxConcurrent(),
235
+ });
236
+ } catch (e) {
237
+ sendError(res, e, ws);
238
+ }
239
+ return true;
240
+ }
241
+ if (method !== "POST")
242
+ return methodNotAllowed(res, action ? "POST" : "GET, POST");
243
+
244
+ // From the credential (SEC-1c): an agent's actor token, never a header.
245
+ const actor = auditActorOf(req);
246
+ const verb = action ?? "start";
247
+ const audit = (detail: string, reason?: string) =>
248
+ recordAction(
249
+ actor,
250
+ {
251
+ verb: "agents",
252
+ detail,
253
+ ...(reason ? { outcome: "refused" as const, reason } : {}),
254
+ },
255
+ principalOf(req),
256
+ );
257
+ let what = `${verb} ${id ?? "?"} in ${ws}`;
258
+ try {
259
+ // Another workspace's agent is no agent here: a 404 that doesn't name
260
+ // it, before the lock (a locked request says the same).
261
+ if (id !== undefined) agentRecord(d, id, ws);
262
+ guard(actor, deps.unlocked ?? defaultUnlocked);
263
+ if (verb === "start") {
264
+ const body = await startBody(req);
265
+ if (!body.persona)
266
+ throw new AgentError("persona is required", { usage: true });
267
+ what = `start ${body.id ?? body.persona} in ${ws}`;
268
+ const s = await startAgent(d, {
269
+ workspace: ws,
270
+ persona: body.persona,
271
+ runtime: body.runtime,
272
+ model: body.model,
273
+ id: body.id,
274
+ });
275
+ what = `start ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
276
+ audit(what, s.early ? "exited at once" : undefined);
277
+ sendStarted(res, s);
278
+ } else if (verb === "stop") {
279
+ const s = await stopAgent(d, id!, ws);
280
+ audit(what);
281
+ sendJson(res, { ok: true, agent: s.rec.id, wasRunning: !!s.session });
282
+ } else {
283
+ const s = await resumeAgent(d, id!, ws);
284
+ audit(what);
285
+ sendStarted(res, s);
286
+ }
287
+ } catch (e) {
288
+ audit(what, e instanceof Error ? e.message : String(e));
289
+ sendError(res, e, ws);
290
+ }
291
+ return true;
292
+ }
293
+
294
+ /**
295
+ * R8 and the actor: a person, with the lock open. An agent is one its actor
296
+ * token proves (the request checks already refuse its start and resume);
297
+ * an x-den-actor header alone is an unverified claim, not an agent.
298
+ */
299
+ function guard(actor: Actor, unlocked: () => boolean): void {
300
+ if (actor && !actor.unverified)
301
+ throw new AgentError(
302
+ "an agent can't start, stop or resume agents from the page; agents use MCP agent_spawn",
303
+ { status: 403 },
304
+ );
305
+ if (!unlocked())
306
+ throw new AgentError("Unlock first: agent actions need the lock open", {
307
+ status: 423,
308
+ code: "locked",
309
+ });
310
+ }
311
+
312
+ function safeDecode(s: string): string {
313
+ try {
314
+ return decodeURIComponent(s);
315
+ } catch {
316
+ return s;
317
+ }
318
+ }
319
+
320
+ function methodNotAllowed(res: http.ServerResponse, allow: string): true {
321
+ res.setHeader("Allow", allow);
322
+ sendJson(res, { ok: false, error: "method not allowed" }, 405);
323
+ return true;
324
+ }
@@ -17,7 +17,28 @@ export { actorFromHeaders } from "../../shared/agent-actor.js";
17
17
 
18
18
  export interface AuditEntry {
19
19
  ts: string;
20
+ /** "agent" only for an agent's actor token (SEC-1c); a claim never is. */
20
21
  actor: "agent" | "user";
22
+ /**
23
+ * Who the request's credential proves it is: `person:<os user>` for the
24
+ * person token or a signed-in browser, `agent:<id>` for an actor token
25
+ * (SEC-1c). From the credential only, never from a header the caller
26
+ * sets; absent for a call without one.
27
+ */
28
+ principal?: string;
29
+ /**
30
+ * SEC-1c: what the caller's x-den-actor headers claimed, when no actor
31
+ * token proved it (unverified is then set). A label, never the actor.
32
+ */
33
+ claimed?: {
34
+ agent?: string;
35
+ workspace?: string;
36
+ client: string;
37
+ clientVersion: string;
38
+ mcpPid: number | null;
39
+ session: string;
40
+ };
41
+ unverified?: true;
21
42
  client?: string;
22
43
  clientVersion?: string;
23
44
  mcpPid?: number | null;
@@ -71,18 +92,36 @@ export function recordAction(
71
92
  addresses?: string[];
72
93
  targetWorkspace?: string;
73
94
  },
95
+ /** The credential's actor (request-guard principalOf). */
96
+ principal?: string,
74
97
  ): AuditEntry {
98
+ const claim = actor?.unverified ? actor : null;
99
+ const verified = actor && !actor.unverified ? actor : null;
75
100
  const entry: AuditEntry = {
76
101
  ts: new Date().toISOString(),
77
- actor: actor ? "agent" : "user",
78
- ...(actor
102
+ actor: verified ? "agent" : "user",
103
+ ...(principal ? { principal } : {}),
104
+ ...(claim
79
105
  ? {
80
- client: actor.client,
81
- clientVersion: actor.clientVersion,
82
- mcpPid: actor.mcpPid,
83
- session: actor.session,
84
- ...(actor.agent ? { agent: actor.agent } : {}),
85
- ...(actor.workspace ? { workspace: actor.workspace } : {}),
106
+ claimed: {
107
+ ...(claim.agent ? { agent: claim.agent } : {}),
108
+ ...(claim.workspace ? { workspace: claim.workspace } : {}),
109
+ client: claim.client,
110
+ clientVersion: claim.clientVersion,
111
+ mcpPid: claim.mcpPid,
112
+ session: claim.session,
113
+ },
114
+ unverified: true as const,
115
+ }
116
+ : {}),
117
+ ...(verified
118
+ ? {
119
+ client: verified.client,
120
+ clientVersion: verified.clientVersion,
121
+ mcpPid: verified.mcpPid,
122
+ session: verified.session,
123
+ ...(verified.agent ? { agent: verified.agent } : {}),
124
+ ...(verified.workspace ? { workspace: verified.workspace } : {}),
86
125
  }
87
126
  : {}),
88
127
  ...(action.targetWorkspace
@@ -105,7 +144,8 @@ export function recordAction(
105
144
  ? ""
106
145
  : action.detail;
107
146
  sink?.(
108
- `${mark} ${action.verb}${what ? ` ${what}` : ""}${why}${actorSuffix(actor)}`,
147
+ `${mark} ${action.verb}${what ? ` ${what}` : ""}${why}${actorSuffix(actor)}` +
148
+ (principal && !verified ? ` · as ${principal}` : ""),
109
149
  actor,
110
150
  );
111
151
  return entry;
@@ -177,6 +217,7 @@ export function recordAgentRefusal(
177
217
  targets?: unknown;
178
218
  reason?: unknown;
179
219
  },
220
+ principal?: string,
180
221
  ): AuditEntry {
181
222
  const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
182
223
  const repo = typeof report.repo === "string" ? report.repo : "";
@@ -186,22 +227,30 @@ export function recordAgentRefusal(
186
227
  if (lifecycle && SAFE_PART.test(repo)) {
187
228
  const address =
188
229
  service && SAFE_PART.test(service) ? `${repo}/${service}` : repo;
189
- return recordAction(actor, {
190
- verb: "exec",
191
- detail: `${address} ${lifecycle[1]} (local)`,
192
- outcome: "refused",
193
- reason,
194
- });
230
+ return recordAction(
231
+ actor,
232
+ {
233
+ verb: "exec",
234
+ detail: `${address} ${lifecycle[1]} (local)`,
235
+ outcome: "refused",
236
+ reason,
237
+ },
238
+ principal,
239
+ );
195
240
  }
196
241
  // Any other tool: the refusal is the detail, under every service or repo
197
242
  // the call named (all of a logs_search's targets, in one entry).
198
- return recordAction(actor, {
199
- verb: tool,
200
- detail: reason,
201
- outcome: "refused",
202
- reason,
203
- addresses: refusedAddresses(report),
204
- });
243
+ return recordAction(
244
+ actor,
245
+ {
246
+ verb: tool,
247
+ detail: reason,
248
+ outcome: "refused",
249
+ reason,
250
+ addresses: refusedAddresses(report),
251
+ },
252
+ principal,
253
+ );
205
254
  }
206
255
 
207
256
  /**
@@ -215,6 +264,7 @@ export function recordAgentRefusal(
215
264
  export function recordCrossWorkspace(
216
265
  actor: Actor,
217
266
  report: { tool?: unknown; crossWorkspace?: unknown },
267
+ principal?: string,
218
268
  ): AuditEntry | null {
219
269
  if (!actor?.agent || !actor.workspace) return null;
220
270
  const target =
@@ -222,11 +272,15 @@ export function recordCrossWorkspace(
222
272
  if (!WORKSPACE_NAME_RE.test(target) || target === actor.workspace)
223
273
  return null;
224
274
  const tool = clean(report.tool, 64).replace(/[^A-Za-z0-9_]/g, "") || "tool";
225
- return recordAction(actor, {
226
- verb: tool,
227
- detail: `in workspace ${target}, under a grant to ${actor.agent} of workspace ${actor.workspace}`,
228
- targetWorkspace: target,
229
- });
275
+ return recordAction(
276
+ actor,
277
+ {
278
+ verb: tool,
279
+ detail: `in workspace ${target}, under a grant to ${actor.agent} of workspace ${actor.workspace}`,
280
+ targetWorkspace: target,
281
+ },
282
+ principal,
283
+ );
230
284
  }
231
285
 
232
286
  /**
@@ -262,11 +262,16 @@ export function pluginForPath(
262
262
 
263
263
  /** What error.html is told: everything error-page.js draws from. */
264
264
  export interface ErrorPageData {
265
- /** 422: the config fails validation (a ConfigError). */
266
- status: 404 | 422 | 500;
265
+ /**
266
+ * 422: the config fails validation (a ConfigError). 401, 403, 421: the
267
+ * request checks refused it (SEC-1a): `message` says why, `code` names it.
268
+ */
269
+ status: 401 | 403 | 404 | 421 | 422 | 500;
270
+ /** 401, 403, 421: the refusal's code (misdirected, origin_rejected, …). */
271
+ code?: string;
267
272
  /** 404 and 422: the path asked for (no query). */
268
273
  path?: string;
269
- /** 422: the ConfigError's message (it names config keys, never values). */
274
+ /** 422: the ConfigError's message (it names config keys, never values); 401/403/421: the refusal's. */
270
275
  message?: string;
271
276
  /** 422: each problem: its file, key path and message (0.3.0-dev.40). */
272
277
  issues?: Array<{ file: string; key: string; message: string }>;
@@ -281,6 +286,19 @@ export interface ErrorPageData {
281
286
  plugin?: PluginRoute | null;
282
287
  /** MD-2: the restart message, when the engine on disk is newer. */
283
288
  engine?: { loaded: string | null; onDisk: string | null; message: string };
289
+ /**
290
+ * 401 signin_required (SEC-1b): the signed-out page. `scope` terminal
291
+ * (shell-grade on loopback) or den (beyond loopback); `cmd` the command
292
+ * that prints a link; `localhost` the same path there (on 127.0.0.1 or
293
+ * [::1]); `expired` for a login link that expired or was used.
294
+ */
295
+ signin?: {
296
+ scope: "terminal" | "agents" | "den";
297
+ name: string;
298
+ cmd: string;
299
+ localhost?: string;
300
+ expired?: boolean;
301
+ };
284
302
  }
285
303
 
286
304
  /**