@monster-orange/den 0.4.13 → 0.4.15

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 (57) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +185 -7
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +256 -0
  7. package/src/shared/agents/den-claude.ts +19 -0
  8. package/src/shared/agents/records.ts +23 -0
  9. package/src/shared/agents/runtime.ts +14 -0
  10. package/src/shared/claim-code.ts +163 -0
  11. package/src/shared/cli-command.ts +31 -0
  12. package/src/shared/den-config.ts +14 -0
  13. package/src/shared/environment-manager/service-runner.ts +31 -12
  14. package/src/shared/environment-manager/workspaces.ts +31 -0
  15. package/src/shared/web-session.ts +64 -5
  16. package/src/ui/cli/agent-ops.ts +320 -18
  17. package/src/ui/cli/agents-command.ts +89 -2
  18. package/src/ui/cli/doctor-command.ts +29 -3
  19. package/src/ui/cli/doctor-onboard.ts +26 -0
  20. package/src/ui/cli/flag-parser.ts +9 -1
  21. package/src/ui/cli/index.ts +29 -3
  22. package/src/ui/cli/onboard-command.ts +180 -0
  23. package/src/ui/cli/onboard-reads.ts +613 -0
  24. package/src/ui/cli/repo-command.ts +156 -10
  25. package/src/ui/cli/web-command.ts +92 -1
  26. package/src/ui/mcp/api.ts +26 -3
  27. package/src/ui/mcp/server.ts +12 -2
  28. package/src/ui/mcp/tools.ts +427 -1
  29. package/src/ui/server/agent-resume-grant.ts +35 -0
  30. package/src/ui/server/agent-routes.ts +149 -1
  31. package/src/ui/server/approval-routes.ts +390 -0
  32. package/src/ui/server/approvals.ts +416 -0
  33. package/src/ui/server/github-routes.ts +12 -4
  34. package/src/ui/server/held-actions.ts +203 -0
  35. package/src/ui/server/http-helpers.ts +35 -0
  36. package/src/ui/server/index.ts +286 -16
  37. package/src/ui/server/lifecycle-exec.ts +70 -0
  38. package/src/ui/server/onboard-routes.ts +132 -0
  39. package/src/ui/server/pair-routes.ts +7 -4
  40. package/src/ui/server/public/agents-page.js +288 -8
  41. package/src/ui/server/public/agents.html +18 -0
  42. package/src/ui/server/public/error-page.js +27 -3
  43. package/src/ui/server/public/header.js +1 -1
  44. package/src/ui/server/public/i18n/es.js +52 -0
  45. package/src/ui/server/public/i18n/ja.js +55 -0
  46. package/src/ui/server/public/index.html +1 -1
  47. package/src/ui/server/public/local-dev.html +5 -4
  48. package/src/ui/server/public/persona-ui.css +45 -0
  49. package/src/ui/server/public/release.html +0 -1
  50. package/src/ui/server/public/settings-registry.js +3 -1
  51. package/src/ui/server/public/settings.html +9 -1
  52. package/src/ui/server/public/shell.js +336 -0
  53. package/src/ui/server/request-guard.ts +19 -4
  54. package/src/ui/server/route-classes.ts +41 -1
  55. package/src/ui/server/session-routes.ts +11 -3
  56. package/src/ui/server/workspace-routes.ts +21 -10
  57. package/themes/den/den-theme.json +6 -6
@@ -29,6 +29,18 @@
29
29
  * instruction is typed into
30
30
  * the session once it is
31
31
  * ready, never an argument
32
+ * GET /api/workspaces/<ws>/conversations[?runtime=<r>] Bring in
33
+ * (AG-17.1): each runtime's
34
+ * own conversations in the
35
+ * workspace's folder, newest
36
+ * first, with which runtimes
37
+ * can bring one in
38
+ * POST /api/workspaces/<ws>/conversations/<id>/import { persona,
39
+ * runtime?, id? }: the
40
+ * runtime's resume of it, as
41
+ * a Den agent; 409
42
+ * open_elsewhere when it was
43
+ * written in the last minute
32
44
  * POST /api/workspaces/<ws>/sessions a plain session: the login
33
45
  * shell in the workspace's
34
46
  * folder (AG-10.1)
@@ -86,12 +98,14 @@ import {
86
98
  } from "../../shared/den-config.js";
87
99
  import {
88
100
  readWorkspacesFile,
101
+ conversationFolders,
89
102
  resolveWorkspaceDir,
90
103
  } from "../../shared/environment-manager/workspaces.js";
91
104
  import { getStateDir } from "../../shared/paths.js";
92
105
  import { HostError } from "../../shared/sessions/client.js";
93
106
  import {
94
107
  AgentError,
108
+ defaultId,
95
109
  agentRecord,
96
110
  checkInstruction,
97
111
  createPersona,
@@ -114,6 +128,8 @@ import {
114
128
  resumeInterrupted,
115
129
  setRuntimePath,
116
130
  resolveAgentsDeps,
131
+ importAgent,
132
+ listConversations,
117
133
  relaunchAgent,
118
134
  resumeAgent,
119
135
  startAgent,
@@ -131,7 +147,10 @@ import {
131
147
  auditActorOf,
132
148
  principalOf,
133
149
  } from "./request-guard.js";
134
- import { agentResumeRefusal } from "./agent-resume-grant.js";
150
+ import {
151
+ agentImportRefusal,
152
+ agentResumeRefusal,
153
+ } from "./agent-resume-grant.js";
135
154
  import { readBody, sendJson } from "./http-helpers.js";
136
155
  import type { Actor } from "../../shared/agent-actor.js";
137
156
 
@@ -148,6 +167,8 @@ const AGENTS_PATH =
148
167
  const RUNTIME_PATH = /^\/api\/agents\/runtimes\/([^/]+)\/path$/;
149
168
  const PERSONA_PATH = /^\/api\/personas\/([^/]+)$/;
150
169
  const PLAIN_PATH = /^\/api\/workspaces\/([^/]+)\/sessions$/;
170
+ const CONVERSATIONS_PATH =
171
+ /^\/api\/workspaces\/([^/]+)\/conversations(?:\/([^/]+)\/import)?$/;
151
172
  /** A start's body: a few names, commands and a first instruction. */
152
173
  const MAX_BODY = 32 * 1024;
153
174
  /** A list of names (a page's workspaces, an Undo's agents). */
@@ -166,6 +187,17 @@ function webDeps(over: AgentsDeps = {}): AgentsResolved {
166
187
  if (!cfg) throw new AgentError(`workspace "${ws}" does not exist`);
167
188
  return resolveWorkspaceDir(ws, cfg, getReposRoot());
168
189
  },
190
+ // AG-17.1: its folder and each by-path repo's, outside it too. A caller
191
+ // that gives its own folder (a test) gives its folders too, or just it.
192
+ ...(over.workspaceDir
193
+ ? {}
194
+ : {
195
+ workspaceFolders: (ws: string) => {
196
+ const cfg = readWorkspacesFile().workspaces[ws];
197
+ if (!cfg) throw new AgentError(`workspace "${ws}" does not exist`);
198
+ return conversationFolders(ws, cfg, getReposRoot());
199
+ },
200
+ }),
169
201
  stdout: NO_TTY,
170
202
  out: () => undefined,
171
203
  err: () => undefined,
@@ -188,6 +220,7 @@ function sendError(res: http.ServerResponse, e: unknown, ws?: string): void {
188
220
  error,
189
221
  ...(e.code && e.code !== NOT_IN_WORKSPACE ? { code: e.code } : {}),
190
222
  ...(e.hints.length ? { hints: e.hints } : {}),
223
+ ...(e.running ? { running: e.running } : {}),
191
224
  },
192
225
  e.status,
193
226
  );
@@ -275,6 +308,8 @@ interface StartBody {
275
308
  permission?: string;
276
309
  allow?: unknown;
277
310
  instruction?: unknown;
311
+ /** AG-10.10: a display name, checked as a rename's is. */
312
+ name?: unknown;
278
313
  }
279
314
 
280
315
  const START_KEYS = [
@@ -286,6 +321,7 @@ const START_KEYS = [
286
321
  "permission",
287
322
  "allow",
288
323
  "instruction",
324
+ "name",
289
325
  ];
290
326
 
291
327
  async function startBody(req: http.IncomingMessage): Promise<StartBody> {
@@ -331,6 +367,8 @@ async function startBody(req: http.IncomingMessage): Promise<StartBody> {
331
367
  }
332
368
  if (o.allow !== undefined && o.allow !== null) out.allow = o.allow;
333
369
  if (o.instruction !== undefined) out.instruction = o.instruction;
370
+ if (o.name !== undefined && o.name !== null && o.name !== "")
371
+ out.name = o.name;
334
372
  return out as StartBody;
335
373
  }
336
374
 
@@ -349,6 +387,7 @@ export async function handleAgentRoutes(
349
387
  const rp = RUNTIME_PATH.exec(pathname);
350
388
  const pp = PERSONA_PATH.exec(pathname);
351
389
  const plain = PLAIN_PATH.exec(pathname);
390
+ const conv = CONVERSATIONS_PATH.exec(pathname);
352
391
  // AG-14's routes over many agents at once.
353
392
  const many =
354
393
  pathname === "/api/agents/clear-stopped" ||
@@ -361,6 +400,7 @@ export async function handleAgentRoutes(
361
400
  !rp &&
362
401
  !pp &&
363
402
  !plain &&
403
+ !conv &&
364
404
  !many
365
405
  )
366
406
  return false;
@@ -369,6 +409,14 @@ export async function handleAgentRoutes(
369
409
 
370
410
  if (pp) return personaRoute(req, res, safeDecode(pp[1]), d);
371
411
  if (plain) return plainSession(req, res, safeDecode(plain[1]), d);
412
+ if (conv)
413
+ return conversations(
414
+ req,
415
+ res,
416
+ safeDecode(conv[1]),
417
+ conv[2] === undefined ? undefined : safeDecode(conv[2]),
418
+ d,
419
+ );
372
420
  if (pathname === "/api/personas" && method === "POST")
373
421
  return personaRoute(req, res, null, d);
374
422
 
@@ -387,6 +435,20 @@ export async function handleAgentRoutes(
387
435
  ...(pathname === "/api/personas"
388
436
  ? { personas: listPersonas(d), ...registries(d) }
389
437
  : {}),
438
+ // AG-10.10: the id a new start of each persona takes here.
439
+ ...(pathname === "/api/personas" && ws
440
+ ? {
441
+ startsAs: Object.fromEntries(
442
+ listPersonas(d).flatMap((p) => {
443
+ try {
444
+ return [[p.id, defaultId(d.stateDir(), p.id, ws)]];
445
+ } catch {
446
+ return []; // no free id: the start says so
447
+ }
448
+ }),
449
+ ),
450
+ }
451
+ : {}),
390
452
  runtimes,
391
453
  ...(ws ? { workspace: ws } : {}),
392
454
  // AG-0.2: the file a person edits to set a runtime's path.
@@ -509,6 +571,7 @@ export async function handleAgentRoutes(
509
571
  ? { permission: body.permission }
510
572
  : {}),
511
573
  ...(body.allow !== undefined ? { allow: body.allow } : {}),
574
+ ...(body.name !== undefined ? { name: body.name } : {}),
512
575
  strict: true,
513
576
  });
514
577
  const ov = s.rec.overrides?.length
@@ -600,6 +663,91 @@ export async function handleAgentRoutes(
600
663
  return true;
601
664
  }
602
665
 
666
+ const IMPORT_KEYS = ["persona", "runtime", "id"];
667
+
668
+ /**
669
+ * Bring in (AG-17.1): GET lists the workspace's conversations, POST
670
+ * …/<id>/import brings one in. A person's, or an agent's opted in through
671
+ * mcp.actions' agent_import inside its scope. Every import is audited.
672
+ */
673
+ async function conversations(
674
+ req: http.IncomingMessage,
675
+ res: http.ServerResponse,
676
+ ws: string,
677
+ session: string | undefined,
678
+ d: AgentsResolved,
679
+ ): Promise<true> {
680
+ const method = req.method ?? "GET";
681
+ const actor = auditActorOf(req);
682
+ const agent = agentPrincipalOf(req);
683
+ const allowed = () => {
684
+ if (agent) {
685
+ const why = agentImportRefusal(agent, ws, mcpActions());
686
+ if (why) throw new AgentError(why, { status: 403 });
687
+ } else guard(actor);
688
+ };
689
+ if (session === undefined) {
690
+ if (method !== "GET" && method !== "HEAD")
691
+ return methodNotAllowed(res, "GET");
692
+ try {
693
+ allowed();
694
+ const runtime =
695
+ new URL(req.url ?? "/", "http://x").searchParams.get("runtime") ??
696
+ undefined;
697
+ const listed = listConversations(d, ws, runtime);
698
+ sendJson(res, { workspace: ws, folder: d.workspaceDir(ws), ...listed });
699
+ } catch (e) {
700
+ sendError(res, e, ws);
701
+ }
702
+ return true;
703
+ }
704
+ if (method !== "POST") return methodNotAllowed(res, "POST");
705
+ let what = `bring in ${session} in ${ws}`;
706
+ const audit = (reason?: string) =>
707
+ recordAction(
708
+ actor,
709
+ {
710
+ verb: "agents",
711
+ detail: what,
712
+ ...(reason ? { outcome: "refused" as const, reason } : {}),
713
+ },
714
+ principalOf(req),
715
+ );
716
+ try {
717
+ allowed();
718
+ const body = await jsonBody(req);
719
+ for (const k of Object.keys(body))
720
+ if (!IMPORT_KEYS.includes(k))
721
+ throw new AgentError(
722
+ `${k}: unknown field (an import takes ${IMPORT_KEYS.join(", ")})`,
723
+ { usage: true },
724
+ );
725
+ const str = (k: string): string | undefined => {
726
+ const v = body[k];
727
+ if (v === undefined || v === null || v === "") return undefined;
728
+ if (typeof v !== "string" || !NAME_RE.test(v))
729
+ throw new AgentError(`${k} must be a name`, { usage: true });
730
+ return v;
731
+ };
732
+ const persona = str("persona");
733
+ if (!persona) throw new AgentError("persona is required", { usage: true });
734
+ const s = await importAgent(d, {
735
+ workspace: ws,
736
+ session,
737
+ persona,
738
+ runtime: str("runtime"),
739
+ id: str("id"),
740
+ });
741
+ what = `bring in ${session} as ${s.rec.id} (${s.rec.persona}, ${s.rec.runtime}) in ${ws}`;
742
+ audit(s.early ? "exited at once" : undefined);
743
+ sendStarted(res, s, d);
744
+ } catch (e) {
745
+ audit(e instanceof Error ? e.message : String(e));
746
+ sendError(res, e, ws);
747
+ }
748
+ return true;
749
+ }
750
+
603
751
  /** The org config's mcp.actions; none when it can't be read. */
604
752
  function mcpActions(): readonly string[] {
605
753
  try {
@@ -0,0 +1,390 @@
1
+ /**
2
+ * ON-3's held writes (0.4.15; ON-SPEC ON-3 and the 2026-11-07 decisions):
3
+ * an agent's change waits for the person, and runs only on their Allow.
4
+ *
5
+ * POST /api/approvals { action, args, note? }: hold one
6
+ * (anyone who may reach the server; an
7
+ * agent's own MCP tools call it)
8
+ * GET /api/approvals what waits, for the person's dialog
9
+ * (never an agent's token)
10
+ * GET /api/approvals/<id> one request: the asker's own, or the
11
+ * person's
12
+ * POST /api/approvals/<id>/decide { decision: allow | deny }: the
13
+ * person's signed-in browser only
14
+ * (approvals.ts approverAllowed)
15
+ *
16
+ * The dialog's words are Den's: each action's check() turns the args into
17
+ * a line, its parts and the Review lines, and refuses bad args before
18
+ * anything waits. The agent's note is only ever shown quoted. Allow runs
19
+ * the action from the args stored when it was asked, after checking them
20
+ * again (the world may have changed while it waited). Every ask and every
21
+ * decision is in the activity trail.
22
+ */
23
+ import type * as http from "http";
24
+ import { agentTokenLive } from "../../shared/actor-token.js";
25
+ import {
26
+ APPROVAL_TICKET_HEADER,
27
+ ApprovalQueue,
28
+ approverAllowed,
29
+ newApprovalTicket,
30
+ type Approval,
31
+ type Decision,
32
+ } from "./approvals.js";
33
+ import { recordAction } from "./audit.js";
34
+ import { readBody, sendJson } from "./http-helpers.js";
35
+ import {
36
+ agentPrincipalOf,
37
+ auditActorOf,
38
+ principalOf,
39
+ type GuardResult,
40
+ } from "./request-guard.js";
41
+
42
+ export type HeldCheck =
43
+ | {
44
+ ok: true;
45
+ /** The args as they will run (normalized). */
46
+ args: Record<string, unknown>;
47
+ /** One plain line: what it will do ("create the workspace \"web\""). */
48
+ summary: string;
49
+ /** The line's parts, for a page to word it in its own language. */
50
+ params: Record<string, string>;
51
+ /** Review: what it will do, line by line. */
52
+ details: string[];
53
+ }
54
+ | { ok: false; error: string };
55
+
56
+ export interface HeldAction {
57
+ check(args: Record<string, unknown>): Promise<HeldCheck> | HeldCheck;
58
+ /** Runs it from checked args; answers like the CLI's --json ({ ok, … }). */
59
+ run(args: Record<string, unknown>): Promise<unknown>;
60
+ }
61
+
62
+ export type HeldActions = Readonly<Record<string, HeldAction>>;
63
+
64
+ /** The queue, its run being the action's, with the args checked again. */
65
+ export function createApprovalQueue(
66
+ actions: HeldActions,
67
+ stateDir: () => string,
68
+ o: { ttlMs?: number; now?: () => number } = {},
69
+ ): ApprovalQueue {
70
+ return new ApprovalQueue({
71
+ ...o,
72
+ // An agent with no live token (stopped, `web logout --all`) is gone.
73
+ actorLive: (actor) =>
74
+ !actor.startsWith("agent:") ||
75
+ agentTokenLive(stateDir(), actor.slice("agent:".length)),
76
+ run: async (action, args) => {
77
+ const a = actions[action];
78
+ if (!a) return { ok: false, error: `no action ${action}` };
79
+ const again = await a.check({ ...args });
80
+ if (!again.ok) return { ok: false, error: again.error };
81
+ return a.run(again.args);
82
+ },
83
+ });
84
+ }
85
+
86
+ const ONE_PATH = /^\/api\/approvals\/([A-Za-z0-9_-]{16})$/;
87
+ const DECIDE_PATH = /^\/api\/approvals\/([A-Za-z0-9_-]{16})\/decide$/;
88
+
89
+ /** Whether `pathname` is one of the routes above. */
90
+ export function isApprovalPath(pathname: string): boolean {
91
+ return (
92
+ pathname === "/api/approvals" ||
93
+ ONE_PATH.test(pathname) ||
94
+ DECIDE_PATH.test(pathname)
95
+ );
96
+ }
97
+
98
+ /** Who asked: the credential's actor, else this machine's anonymous caller. */
99
+ function askerOf(req: http.IncomingMessage): string {
100
+ return principalOf(req) ?? "loopback";
101
+ }
102
+
103
+ /** What a page or the asking agent sees of one request. */
104
+ export function approvalView(a: Approval): Record<string, unknown> {
105
+ return {
106
+ id: a.id,
107
+ action: a.action,
108
+ ...(a.agent ? { agent: a.agent } : {}),
109
+ summary: a.summary,
110
+ ...(a.params ? { params: a.params } : {}),
111
+ ...(a.details ? { details: a.details } : {}),
112
+ ...(a.note ? { note: a.note } : {}),
113
+ state: a.state,
114
+ requestedAt: new Date(a.requestedAt).toISOString(),
115
+ expiresAt: new Date(a.expiresAt).toISOString(),
116
+ ...(a.decidedAt ? { decidedAt: new Date(a.decidedAt).toISOString() } : {}),
117
+ ...(a.result !== undefined ? { result: a.result } : {}),
118
+ ...(a.endedBecause ? { endedBecause: a.endedBecause } : {}),
119
+ };
120
+ }
121
+
122
+ async function jsonBody(
123
+ req: http.IncomingMessage,
124
+ ): Promise<Record<string, unknown> | null> {
125
+ try {
126
+ const raw = await readBody(req);
127
+ if (raw.length > 32 * 1024) return null;
128
+ const v: unknown = JSON.parse(raw || "{}");
129
+ return v && typeof v === "object" && !Array.isArray(v)
130
+ ? (v as Record<string, unknown>)
131
+ : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ const refuse = (
138
+ res: http.ServerResponse,
139
+ status: number,
140
+ code: string,
141
+ error: string,
142
+ ): void => sendJson(res, { ok: false, code, error }, status);
143
+
144
+ /** Handles the routes above; false when `pathname` is not one of them. */
145
+ export async function handleApprovalRoutes(
146
+ req: http.IncomingMessage,
147
+ res: http.ServerResponse,
148
+ pathname: string,
149
+ guard: Extract<GuardResult, { ok: true }>,
150
+ deps: {
151
+ queue: ApprovalQueue;
152
+ actions: HeldActions;
153
+ /** `mcp.approvals` (default on); off, nothing new is held. */
154
+ enabled?: () => boolean;
155
+ },
156
+ ): Promise<boolean> {
157
+ if (!isApprovalPath(pathname)) return false;
158
+ const method = (req.method ?? "GET").toUpperCase();
159
+ res.setHeader("Cache-Control", "no-store");
160
+ const agent = agentPrincipalOf(req);
161
+
162
+ if (pathname === "/api/approvals" && method === "POST") {
163
+ let on: boolean;
164
+ try {
165
+ on = deps.enabled ? deps.enabled() : true;
166
+ } catch {
167
+ on = false;
168
+ }
169
+ if (!on) {
170
+ refuse(
171
+ res,
172
+ 403,
173
+ "approvals_off",
174
+ "This distribution turned the held writes off (mcp.approvals: false).",
175
+ );
176
+ return true;
177
+ }
178
+ const body = await jsonBody(req);
179
+ if (!body) {
180
+ refuse(
181
+ res,
182
+ 400,
183
+ "bad_body",
184
+ "The body must be JSON: { action, args, note? }.",
185
+ );
186
+ return true;
187
+ }
188
+ const extra = Object.keys(body).filter(
189
+ (k) => k !== "action" && k !== "args" && k !== "note",
190
+ );
191
+ if (extra.length) {
192
+ refuse(res, 400, "unknown_field", `Unknown field: ${extra.join(", ")}.`);
193
+ return true;
194
+ }
195
+ const action = typeof body.action === "string" ? body.action : "";
196
+ const held = Object.hasOwn(deps.actions, action)
197
+ ? deps.actions[action]
198
+ : undefined;
199
+ if (!held) {
200
+ refuse(
201
+ res,
202
+ 400,
203
+ "unknown_action",
204
+ `Den holds ${Object.keys(deps.actions).join(", ")} for the person; not "${action}".`,
205
+ );
206
+ return true;
207
+ }
208
+ const args =
209
+ body.args && typeof body.args === "object" && !Array.isArray(body.args)
210
+ ? (body.args as Record<string, unknown>)
211
+ : {};
212
+ const checked = await held.check(args);
213
+ if (!checked.ok) {
214
+ recordAction(
215
+ auditActorOf(req),
216
+ {
217
+ verb: "approval-ask",
218
+ detail: action,
219
+ outcome: "refused",
220
+ reason: checked.error,
221
+ },
222
+ principalOf(req),
223
+ );
224
+ refuse(res, 400, "invalid_args", checked.error);
225
+ return true;
226
+ }
227
+ // An asker with no agent token proves it is the asker with a ticket.
228
+ const ticket = agent ? null : newApprovalTicket();
229
+ let a: Approval;
230
+ try {
231
+ a = deps.queue.request({
232
+ ...(ticket ? { ticketHash: ticket.hash } : {}),
233
+ actor: askerOf(req),
234
+ ...(agent ? { agent: agent.actor.slice("agent:".length) } : {}),
235
+ action,
236
+ args: checked.args,
237
+ summary: checked.summary,
238
+ params: checked.params,
239
+ details: checked.details,
240
+ note: typeof body.note === "string" ? body.note : undefined,
241
+ });
242
+ } catch (e) {
243
+ refuse(res, 429, "too_many_waiting", (e as Error).message);
244
+ return true;
245
+ }
246
+ recordAction(
247
+ auditActorOf(req),
248
+ { verb: "approval-ask", detail: `${action}: ${a.summary} [${a.id}]` },
249
+ principalOf(req),
250
+ );
251
+ sendJson(
252
+ res,
253
+ {
254
+ ok: true,
255
+ ...approvalView(a),
256
+ ...(ticket ? { ticket: ticket.ticket } : {}),
257
+ },
258
+ 202,
259
+ );
260
+ return true;
261
+ }
262
+
263
+ if (
264
+ pathname === "/api/approvals" &&
265
+ (method === "GET" || method === "HEAD")
266
+ ) {
267
+ // An agent: its own requests and their outcomes, never another's.
268
+ if (agent) {
269
+ sendJson(res, {
270
+ ok: true,
271
+ mine: deps.queue.ofActor(agent.actor).map(approvalView),
272
+ });
273
+ return true;
274
+ }
275
+ // `?count=1`: how many wait, and nothing else, for a page that holds no
276
+ // approval key (it says "sign in to see it" without a refusal a poll
277
+ // would log every 3 s).
278
+ if (new URL(req.url ?? "/", "http://x").searchParams.get("count") === "1") {
279
+ sendJson(res, { ok: true, waiting: deps.queue.pending().length });
280
+ return true;
281
+ }
282
+ const who = approverAllowed(req, guard, "read");
283
+ if (!who.ok) {
284
+ // How many wait, so a signed-out page can say "sign in to see it";
285
+ // never what they are.
286
+ sendJson(
287
+ res,
288
+ {
289
+ ok: false,
290
+ code: who.code,
291
+ error:
292
+ "What waits for the person is theirs to see: a signed-in browser holding its approval key.",
293
+ waiting: deps.queue.pending().length,
294
+ },
295
+ 403,
296
+ );
297
+ return true;
298
+ }
299
+ sendJson(res, {
300
+ ok: true,
301
+ pending: deps.queue.pending().map(approvalView),
302
+ });
303
+ return true;
304
+ }
305
+
306
+ const one = ONE_PATH.exec(pathname);
307
+ if (one && (method === "GET" || method === "HEAD")) {
308
+ const a = deps.queue.get(one[1]);
309
+ // An agent sees its own requests only; an anonymous asker the one it
310
+ // holds the ticket for; the person any. Anything else is "no such request".
311
+ const ticket = req.headers[APPROVAL_TICKET_HEADER];
312
+ const mayRead =
313
+ !!a &&
314
+ (agent
315
+ ? a.actor === agent.actor
316
+ : deps.queue.ticketMatches(
317
+ a.id,
318
+ typeof ticket === "string" ? ticket : undefined,
319
+ ) || approverAllowed(req, guard, "read").ok);
320
+ if (!a || !mayRead) {
321
+ refuse(
322
+ res,
323
+ 404,
324
+ "not_found",
325
+ "No such request (it may have ended at a restart).",
326
+ );
327
+ return true;
328
+ }
329
+ sendJson(res, { ok: true, ...approvalView(a) });
330
+ return true;
331
+ }
332
+
333
+ const dec = DECIDE_PATH.exec(pathname);
334
+ if (dec && method === "POST") {
335
+ const who = approverAllowed(req, guard);
336
+ if (!who.ok) {
337
+ recordAction(
338
+ auditActorOf(req),
339
+ {
340
+ verb: "approval-decide",
341
+ detail: dec[1],
342
+ outcome: "refused",
343
+ reason: who.code,
344
+ },
345
+ principalOf(req),
346
+ );
347
+ refuse(res, 403, who.code, who.error);
348
+ return true;
349
+ }
350
+ const body = await jsonBody(req);
351
+ const decision = body?.decision;
352
+ if (decision !== "allow" && decision !== "deny") {
353
+ refuse(res, 400, "bad_decision", 'decision is "allow" or "deny".');
354
+ return true;
355
+ }
356
+ const r = await deps.queue.decide(
357
+ dec[1],
358
+ decision as Decision,
359
+ who.session,
360
+ );
361
+ recordAction(
362
+ auditActorOf(req),
363
+ r.ok
364
+ ? {
365
+ verb: "approval-decide",
366
+ detail: `${decision} ${r.approval.action}: ${r.approval.summary} [${dec[1]}]`,
367
+ }
368
+ : {
369
+ verb: "approval-decide",
370
+ detail: `${decision} [${dec[1]}]`,
371
+ outcome: "refused",
372
+ reason: r.code,
373
+ },
374
+ principalOf(req),
375
+ );
376
+ if (r.ok) sendJson(res, { ok: true, ...approvalView(r.approval) });
377
+ else
378
+ refuse(
379
+ res,
380
+ r.code === "not_found" ? 404 : r.code === "expired" ? 410 : 409,
381
+ r.code,
382
+ r.error,
383
+ );
384
+ return true;
385
+ }
386
+
387
+ res.writeHead(405, { Allow: "GET, POST" });
388
+ res.end();
389
+ return true;
390
+ }