@monster-orange/den 0.4.14 → 0.4.16
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 +194 -0
- package/README.md +7 -7
- package/docs/distributions.md +226 -19
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +155 -8
- package/src/shared/agents/den-claude.ts +25 -3
- package/src/shared/agents/records.ts +37 -0
- package/src/shared/agents/runtime.ts +11 -1
- package/src/shared/claim-code.ts +163 -0
- package/src/shared/cli-command.ts +31 -0
- package/src/shared/den-config.ts +12 -0
- package/src/shared/environment-manager/workspaces.ts +20 -0
- package/src/shared/github-app.ts +7 -0
- package/src/shared/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +372 -23
- package/src/ui/cli/agents-command.ts +71 -4
- 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 +14 -2
- package/src/ui/mcp/server.ts +12 -2
- package/src/ui/mcp/tools.ts +368 -1
- package/src/ui/server/agent-routes.ts +91 -7
- package/src/ui/server/approval-routes.ts +390 -0
- package/src/ui/server/approvals.ts +416 -0
- package/src/ui/server/github-routes.ts +13 -5
- package/src/ui/server/held-actions.ts +203 -0
- package/src/ui/server/http-helpers.ts +35 -0
- package/src/ui/server/index.ts +308 -21
- 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 +378 -30
- package/src/ui/server/public/agents.html +1 -0
- package/src/ui/server/public/error-page.js +97 -18
- package/src/ui/server/public/error.html +18 -0
- package/src/ui/server/public/header.js +1 -1
- package/src/ui/server/public/i18n/es.js +65 -5
- package/src/ui/server/public/i18n/ja.js +67 -5
- 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 +32 -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 +348 -5
- package/src/ui/server/request-guard.ts +2 -0
- package/src/ui/server/route-classes.ts +34 -1
- package/src/ui/server/session-routes.ts +14 -3
- package/src/ui/server/workspace-routes.ts +21 -10
- package/themes/den/den-theme.json +6 -6
|
@@ -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
|
+
}
|