@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.
- package/CHANGELOG.md +176 -0
- package/README.md +7 -7
- package/docs/distributions.md +185 -7
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +256 -0
- package/src/shared/agents/den-claude.ts +19 -0
- package/src/shared/agents/records.ts +23 -0
- package/src/shared/agents/runtime.ts +14 -0
- package/src/shared/claim-code.ts +163 -0
- package/src/shared/cli-command.ts +31 -0
- package/src/shared/den-config.ts +14 -0
- package/src/shared/environment-manager/service-runner.ts +31 -12
- package/src/shared/environment-manager/workspaces.ts +31 -0
- package/src/shared/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +320 -18
- package/src/ui/cli/agents-command.ts +89 -2
- package/src/ui/cli/doctor-command.ts +29 -3
- package/src/ui/cli/doctor-onboard.ts +26 -0
- package/src/ui/cli/flag-parser.ts +9 -1
- package/src/ui/cli/index.ts +29 -3
- package/src/ui/cli/onboard-command.ts +180 -0
- package/src/ui/cli/onboard-reads.ts +613 -0
- package/src/ui/cli/repo-command.ts +156 -10
- package/src/ui/cli/web-command.ts +92 -1
- package/src/ui/mcp/api.ts +26 -3
- package/src/ui/mcp/server.ts +12 -2
- package/src/ui/mcp/tools.ts +427 -1
- package/src/ui/server/agent-resume-grant.ts +35 -0
- package/src/ui/server/agent-routes.ts +149 -1
- package/src/ui/server/approval-routes.ts +390 -0
- package/src/ui/server/approvals.ts +416 -0
- package/src/ui/server/github-routes.ts +12 -4
- package/src/ui/server/held-actions.ts +203 -0
- package/src/ui/server/http-helpers.ts +35 -0
- package/src/ui/server/index.ts +286 -16
- package/src/ui/server/lifecycle-exec.ts +70 -0
- package/src/ui/server/onboard-routes.ts +132 -0
- package/src/ui/server/pair-routes.ts +7 -4
- package/src/ui/server/public/agents-page.js +288 -8
- package/src/ui/server/public/agents.html +18 -0
- package/src/ui/server/public/error-page.js +27 -3
- package/src/ui/server/public/header.js +1 -1
- package/src/ui/server/public/i18n/es.js +52 -0
- package/src/ui/server/public/i18n/ja.js +55 -0
- package/src/ui/server/public/index.html +1 -1
- package/src/ui/server/public/local-dev.html +5 -4
- package/src/ui/server/public/persona-ui.css +45 -0
- package/src/ui/server/public/release.html +0 -1
- package/src/ui/server/public/settings-registry.js +3 -1
- package/src/ui/server/public/settings.html +9 -1
- package/src/ui/server/public/shell.js +336 -0
- package/src/ui/server/request-guard.ts +19 -4
- package/src/ui/server/route-classes.ts +41 -1
- package/src/ui/server/session-routes.ts +11 -3
- package/src/ui/server/workspace-routes.ts +21 -10
- 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 {
|
|
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
|
+
}
|