@monster-orange/den 0.4.14 → 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 +127 -0
- package/README.md +7 -7
- package/docs/distributions.md +144 -8
- package/package.json +1 -1
- package/src/shared/actor-token.ts +12 -0
- package/src/shared/agents/conversations.ts +33 -4
- package/src/shared/agents/den-claude.ts +13 -3
- package/src/shared/agents/records.ts +18 -0
- package/src/shared/agents/runtime.ts +6 -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/web-session.ts +64 -5
- package/src/ui/cli/agent-ops.ts +75 -22
- package/src/ui/cli/agents-command.ts +4 -1
- 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 +2 -5
- 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 +91 -4
- 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 +29 -0
- package/src/ui/server/public/i18n/ja.js +31 -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 +21 -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 +313 -0
- package/src/ui/server/request-guard.ts +2 -0
- package/src/ui/server/route-classes.ts +28 -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
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approvals (0.4.15, ON-3's writes; Ward 2026-11-07): an agent's change
|
|
3
|
+
* waits for a person.
|
|
4
|
+
*
|
|
5
|
+
* Who may approve: approverAllowed, the one check. A signed-in browser
|
|
6
|
+
* session only: its cookie, no Authorization header of any kind, a POST
|
|
7
|
+
* that carried that session's own CSRF token (checkRequest checked it:
|
|
8
|
+
* with a session, it accepts only the session's token), and the session's
|
|
9
|
+
* approval key (X-Den-Approve-Key, web-session.ts). The key is what a
|
|
10
|
+
* stolen cookie lacks: browsers send a localhost cookie to every localhost
|
|
11
|
+
* port, so an agent's dev server on another port receives it, and GET
|
|
12
|
+
* /api/session gives any cookie holder the CSRF token; only the signed-in
|
|
13
|
+
* page holds the key. Not an anonymous
|
|
14
|
+
* loopback request, though loopback lets those read and change everything
|
|
15
|
+
* else (an agent with a shell can send one); not a person's or an agent's
|
|
16
|
+
* bearer. Seam: a later "loopback isn't identity" mode (cloud machines)
|
|
17
|
+
* changes what counts as a session in checkRequest; this check stays.
|
|
18
|
+
*
|
|
19
|
+
* The queue: request() holds a write with a copy of its exact args, so
|
|
20
|
+
* nothing can change between Allow and the run. decide("allow") runs it
|
|
21
|
+
* once, on the server, from the stored args, and keeps the result;
|
|
22
|
+
* "deny" and expiry run nothing; a second decision is refused; Allow is
|
|
23
|
+
* refused once the agent that asked has been stopped. An agent may have 5
|
|
24
|
+
* requests waiting; settled ones are dropped an hour later. In memory: a
|
|
25
|
+
* restart drops what waits (the agent asks again).
|
|
26
|
+
*
|
|
27
|
+
* Who may read what waits (Vision 2026-11-07): the person's list needs
|
|
28
|
+
* the same proof as approving, on a GET (approverAllowed(…, "read"): the
|
|
29
|
+
* cookie, the session's CSRF token sent as X-Den-CSRF, the approval key).
|
|
30
|
+
* An agent's token reads its own requests only. An anonymous loopback
|
|
31
|
+
* asker reads only the one it holds the ticket for: the POST's reply
|
|
32
|
+
* hands it a random ticket (X-Den-Approval-Ticket), stored hashed.
|
|
33
|
+
*
|
|
34
|
+
* Limit: an agent running as the same OS user could, in principle, read
|
|
35
|
+
* the browser's cookie store from disk. That is the strength of Den's
|
|
36
|
+
* other person-only routes; only an OS boundary is stronger.
|
|
37
|
+
*/
|
|
38
|
+
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
39
|
+
import type * as http from "http";
|
|
40
|
+
import {
|
|
41
|
+
APPROVE_KEY_HEADER,
|
|
42
|
+
approveKeyMatches,
|
|
43
|
+
} from "../../shared/web-session.js";
|
|
44
|
+
import { CSRF_HEADER, type GuardResult } from "./request-guard.js";
|
|
45
|
+
|
|
46
|
+
/** The header an anonymous asker reads its own request with. */
|
|
47
|
+
export const APPROVAL_TICKET_HEADER = "x-den-approval-ticket";
|
|
48
|
+
|
|
49
|
+
/** A new ticket and its hash (only the hash is kept). */
|
|
50
|
+
export function newApprovalTicket(): { ticket: string; hash: string } {
|
|
51
|
+
const ticket = randomBytes(24).toString("base64url");
|
|
52
|
+
return { ticket, hash: ticketHash(ticket) };
|
|
53
|
+
}
|
|
54
|
+
function ticketHash(t: string): string {
|
|
55
|
+
return createHash("sha256").update(t).digest("base64url");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ApproverCheck =
|
|
59
|
+
| { ok: true; session: string }
|
|
60
|
+
| { ok: false; code: "signin_to_approve"; error: string };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether this request may approve or deny an agent's change ("decide",
|
|
64
|
+
* a POST the guard checked CSRF on), or read what waits ("read", a GET:
|
|
65
|
+
* the session's CSRF token is checked here, as the guard checks a POST's).
|
|
66
|
+
*/
|
|
67
|
+
export function approverAllowed(
|
|
68
|
+
req: http.IncomingMessage,
|
|
69
|
+
guard: Extract<GuardResult, { ok: true }>,
|
|
70
|
+
mode: "decide" | "read" = "decide",
|
|
71
|
+
): ApproverCheck {
|
|
72
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
73
|
+
const p = guard.principal;
|
|
74
|
+
const csrf = req.headers[CSRF_HEADER];
|
|
75
|
+
const methodOk =
|
|
76
|
+
mode === "decide"
|
|
77
|
+
? method === "POST"
|
|
78
|
+
: (method === "GET" || method === "HEAD") &&
|
|
79
|
+
typeof csrf === "string" &&
|
|
80
|
+
!!guard.session &&
|
|
81
|
+
same(csrf, guard.session.csrf);
|
|
82
|
+
const signedIn =
|
|
83
|
+
methodOk &&
|
|
84
|
+
guard.session &&
|
|
85
|
+
p?.kind === "person-browser" &&
|
|
86
|
+
p.session === guard.session.key &&
|
|
87
|
+
req.headers.authorization === undefined;
|
|
88
|
+
if (!signedIn || !guard.session)
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
code: "signin_to_approve",
|
|
92
|
+
error:
|
|
93
|
+
"Approving an agent's change needs a signed-in browser. Sign in on this page, then approve.",
|
|
94
|
+
};
|
|
95
|
+
const given = req.headers[APPROVE_KEY_HEADER];
|
|
96
|
+
if (
|
|
97
|
+
!approveKeyMatches(
|
|
98
|
+
guard.session.approveKeyHash,
|
|
99
|
+
typeof given === "string" ? given : null,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
code: "signin_to_approve",
|
|
105
|
+
error:
|
|
106
|
+
"This browser can't approve yet. Sign in again on this page, then approve.",
|
|
107
|
+
};
|
|
108
|
+
return { ok: true, session: guard.session.key };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function same(a: string, b: string): boolean {
|
|
112
|
+
const x = Buffer.from(a);
|
|
113
|
+
const y = Buffer.from(b);
|
|
114
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Routes that let another browser in (pair a device, a sign-in link for
|
|
119
|
+
* an edge hostname, link or unlink the GitHub owner). On loopback an
|
|
120
|
+
* anonymous request counts as the person, and an agent with a shell can
|
|
121
|
+
* send one, so these need more: the person's bearer (the CLI, which
|
|
122
|
+
* refuses inside an agent's session), or what may approve (a signed-in
|
|
123
|
+
* browser holding its approval key).
|
|
124
|
+
*/
|
|
125
|
+
export const PERSON_GRANT_ROUTES: readonly string[] = [
|
|
126
|
+
"/api/web/pair",
|
|
127
|
+
"/api/web/approve",
|
|
128
|
+
"/api/web/owner/link",
|
|
129
|
+
"/api/web/owner/unlink",
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
export type PersonGrantCheck =
|
|
133
|
+
| { ok: true }
|
|
134
|
+
| { ok: false; code: "signin_to_approve"; error: string };
|
|
135
|
+
|
|
136
|
+
/** Whether this request may let another browser in (PERSON_GRANT_ROUTES). */
|
|
137
|
+
export function personGrantAllowed(
|
|
138
|
+
req: http.IncomingMessage,
|
|
139
|
+
guard: Extract<GuardResult, { ok: true }>,
|
|
140
|
+
cli: string,
|
|
141
|
+
): PersonGrantCheck {
|
|
142
|
+
if (guard.principal?.kind === "person") return { ok: true };
|
|
143
|
+
if (approverAllowed(req, guard).ok) return { ok: true };
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
code: "signin_to_approve",
|
|
147
|
+
error: `This lets another browser in, so it needs you: sign in on this page first, or run \`${cli} web\` commands in your own terminal.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type ApprovalState =
|
|
152
|
+
| "pending"
|
|
153
|
+
| "running"
|
|
154
|
+
| "done"
|
|
155
|
+
| "denied"
|
|
156
|
+
| "expired";
|
|
157
|
+
|
|
158
|
+
export interface Approval {
|
|
159
|
+
id: string;
|
|
160
|
+
/** Who asked: `agent:<id>` (or the person's MCP server, `person:…`). */
|
|
161
|
+
actor: string;
|
|
162
|
+
/** The asking agent's session name, for the dialog ("server wants to …"). */
|
|
163
|
+
agent?: string;
|
|
164
|
+
/** The action's name (an MCP write tool's). */
|
|
165
|
+
action: string;
|
|
166
|
+
/** A copy of the exact args, taken when it was asked for. */
|
|
167
|
+
args: Readonly<Record<string, unknown>>;
|
|
168
|
+
/** One plain line for the dialog, written by Den from the action and args (never the agent's words). */
|
|
169
|
+
summary: string;
|
|
170
|
+
/** The same line's parts, for a page to word it in its language (Den's, from the args). */
|
|
171
|
+
params?: Readonly<Record<string, string>>;
|
|
172
|
+
/** Review: what it will do, line by line (Den's, from the args). */
|
|
173
|
+
details?: readonly string[];
|
|
174
|
+
/** What the agent said about it: shown only quoted, as "Agent says". */
|
|
175
|
+
note?: string;
|
|
176
|
+
requestedAt: number;
|
|
177
|
+
expiresAt: number;
|
|
178
|
+
state: ApprovalState;
|
|
179
|
+
/** The session that decided. */
|
|
180
|
+
decidedBy?: string;
|
|
181
|
+
decidedAt?: number;
|
|
182
|
+
/** What the run said (Allow only). */
|
|
183
|
+
result?: unknown;
|
|
184
|
+
/** Why it ended without running, when it wasn't the person (actor_gone). */
|
|
185
|
+
endedBecause?: "actor_gone";
|
|
186
|
+
/** sha256 of the anonymous asker's ticket; never in a view. */
|
|
187
|
+
ticketHash?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type Decision = "allow" | "deny";
|
|
191
|
+
|
|
192
|
+
export type DecideResult =
|
|
193
|
+
| { ok: true; approval: Approval }
|
|
194
|
+
| {
|
|
195
|
+
ok: false;
|
|
196
|
+
code: "not_found" | "already_decided" | "expired" | "actor_gone";
|
|
197
|
+
error: string;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export interface ApprovalQueueDeps {
|
|
201
|
+
now?: () => number;
|
|
202
|
+
/** How long a request waits for a person (default 10 minutes). */
|
|
203
|
+
ttlMs?: number;
|
|
204
|
+
/** Whether the agent that asked still runs (its token not revoked). */
|
|
205
|
+
actorLive?: (actor: string) => boolean;
|
|
206
|
+
/** Runs an allowed action from its stored args. */
|
|
207
|
+
run: (
|
|
208
|
+
action: string,
|
|
209
|
+
args: Readonly<Record<string, unknown>>,
|
|
210
|
+
) => Promise<unknown>;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A deep copy of plain JSON args, frozen. */
|
|
214
|
+
function frozenCopy(
|
|
215
|
+
args: Record<string, unknown>,
|
|
216
|
+
): Readonly<Record<string, unknown>> {
|
|
217
|
+
const copy = JSON.parse(JSON.stringify(args ?? {})) as Record<
|
|
218
|
+
string,
|
|
219
|
+
unknown
|
|
220
|
+
>;
|
|
221
|
+
const freeze = (o: unknown): void => {
|
|
222
|
+
if (o && typeof o === "object") {
|
|
223
|
+
Object.freeze(o);
|
|
224
|
+
for (const v of Object.values(o)) freeze(v);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
freeze(copy);
|
|
228
|
+
return copy;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Requests one agent may have waiting at once. */
|
|
232
|
+
export const MAX_PENDING_PER_ACTOR = 5;
|
|
233
|
+
/** How long a settled request is kept (to show its outcome). */
|
|
234
|
+
const KEEP_SETTLED_MS = 60 * 60_000;
|
|
235
|
+
|
|
236
|
+
export class ApprovalQueue {
|
|
237
|
+
private readonly items = new Map<string, Approval>();
|
|
238
|
+
private readonly now: () => number;
|
|
239
|
+
private readonly ttlMs: number;
|
|
240
|
+
|
|
241
|
+
constructor(private readonly deps: ApprovalQueueDeps) {
|
|
242
|
+
this.now = deps.now ?? Date.now;
|
|
243
|
+
this.ttlMs = deps.ttlMs ?? 10 * 60_000;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
request(r: {
|
|
247
|
+
actor: string;
|
|
248
|
+
agent?: string;
|
|
249
|
+
action: string;
|
|
250
|
+
args: Record<string, unknown>;
|
|
251
|
+
summary: string;
|
|
252
|
+
params?: Record<string, string>;
|
|
253
|
+
details?: string[];
|
|
254
|
+
note?: string;
|
|
255
|
+
ticketHash?: string;
|
|
256
|
+
}): Approval {
|
|
257
|
+
this.prune();
|
|
258
|
+
let waiting = 0;
|
|
259
|
+
for (const x of this.items.values())
|
|
260
|
+
if (x.actor === r.actor && x.state === "pending") waiting++;
|
|
261
|
+
if (waiting >= MAX_PENDING_PER_ACTOR)
|
|
262
|
+
throw new Error(
|
|
263
|
+
`This agent already has ${MAX_PENDING_PER_ACTOR} requests waiting for a person. Wait for those first.`,
|
|
264
|
+
);
|
|
265
|
+
const t = this.now();
|
|
266
|
+
const a: Approval = {
|
|
267
|
+
id: randomBytes(12).toString("base64url"),
|
|
268
|
+
actor: r.actor,
|
|
269
|
+
action: r.action,
|
|
270
|
+
args: frozenCopy(r.args),
|
|
271
|
+
summary: r.summary.slice(0, 300),
|
|
272
|
+
...(r.params
|
|
273
|
+
? {
|
|
274
|
+
params: Object.freeze(
|
|
275
|
+
Object.fromEntries(
|
|
276
|
+
Object.entries(r.params).map(([k, v]) => [k, v.slice(0, 300)]),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
}
|
|
280
|
+
: {}),
|
|
281
|
+
...(r.details
|
|
282
|
+
? {
|
|
283
|
+
details: Object.freeze(
|
|
284
|
+
r.details.slice(0, 80).map((l) => l.slice(0, 2000)),
|
|
285
|
+
),
|
|
286
|
+
}
|
|
287
|
+
: {}),
|
|
288
|
+
...(r.agent ? { agent: r.agent.slice(0, 64) } : {}),
|
|
289
|
+
...(r.note?.trim()
|
|
290
|
+
? { note: r.note.replace(/\s+/g, " ").trim().slice(0, 300) }
|
|
291
|
+
: {}),
|
|
292
|
+
...(r.ticketHash ? { ticketHash: r.ticketHash } : {}),
|
|
293
|
+
requestedAt: t,
|
|
294
|
+
expiresAt: t + this.ttlMs,
|
|
295
|
+
state: "pending",
|
|
296
|
+
};
|
|
297
|
+
this.items.set(a.id, a);
|
|
298
|
+
return { ...a };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private settle(a: Approval): void {
|
|
302
|
+
if (a.state === "pending" && this.now() >= a.expiresAt) {
|
|
303
|
+
a.state = "expired";
|
|
304
|
+
a.decidedAt = a.expiresAt;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Drop what settled more than an hour ago. */
|
|
309
|
+
private prune(): void {
|
|
310
|
+
const now = this.now();
|
|
311
|
+
for (const [id, a] of this.items) {
|
|
312
|
+
this.settle(a);
|
|
313
|
+
if (
|
|
314
|
+
a.state !== "pending" &&
|
|
315
|
+
a.state !== "running" &&
|
|
316
|
+
a.decidedAt !== undefined &&
|
|
317
|
+
now - a.decidedAt > KEEP_SETTLED_MS
|
|
318
|
+
)
|
|
319
|
+
this.items.delete(id);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get(id: string): Approval | null {
|
|
324
|
+
const a = this.items.get(id);
|
|
325
|
+
if (!a) return null;
|
|
326
|
+
this.settle(a);
|
|
327
|
+
return { ...a };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** One actor's requests, every state, oldest first (an agent's own list). */
|
|
331
|
+
ofActor(actor: string): Approval[] {
|
|
332
|
+
this.prune();
|
|
333
|
+
const out: Approval[] = [];
|
|
334
|
+
for (const a of this.items.values()) {
|
|
335
|
+
this.settle(a);
|
|
336
|
+
if (a.actor === actor) out.push({ ...a });
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Whether `ticket` is the one this request's anonymous asker was given. */
|
|
342
|
+
ticketMatches(id: string, ticket: string | undefined): boolean {
|
|
343
|
+
const h = this.items.get(id)?.ticketHash;
|
|
344
|
+
return !!h && typeof ticket === "string" && same(ticketHash(ticket), h);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The actors with a request waiting (AG-6a: their agents are "waiting"). */
|
|
348
|
+
waitingActors(): Set<string> {
|
|
349
|
+
const out = new Set<string>();
|
|
350
|
+
for (const a of this.pending()) out.add(a.actor);
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** What waits for a person, oldest first. */
|
|
355
|
+
pending(): Approval[] {
|
|
356
|
+
this.prune();
|
|
357
|
+
const out: Approval[] = [];
|
|
358
|
+
for (const a of this.items.values()) {
|
|
359
|
+
this.settle(a);
|
|
360
|
+
if (a.state === "pending") out.push({ ...a });
|
|
361
|
+
}
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async decide(
|
|
366
|
+
id: string,
|
|
367
|
+
decision: Decision,
|
|
368
|
+
by: string,
|
|
369
|
+
): Promise<DecideResult> {
|
|
370
|
+
const a = this.items.get(id);
|
|
371
|
+
if (!a)
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
code: "not_found",
|
|
375
|
+
error: "No such request (it may have ended at a restart).",
|
|
376
|
+
};
|
|
377
|
+
this.settle(a);
|
|
378
|
+
if (a.state === "expired")
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
code: "expired",
|
|
382
|
+
error: "This request waited too long. Ask the agent to try again.",
|
|
383
|
+
};
|
|
384
|
+
if (a.state !== "pending")
|
|
385
|
+
return {
|
|
386
|
+
ok: false,
|
|
387
|
+
code: "already_decided",
|
|
388
|
+
error: "This request was already decided.",
|
|
389
|
+
};
|
|
390
|
+
// Claimed before anything awaits: a second decide sees "running".
|
|
391
|
+
a.decidedBy = by;
|
|
392
|
+
a.decidedAt = this.now();
|
|
393
|
+
if (decision === "deny") {
|
|
394
|
+
a.state = "denied";
|
|
395
|
+
return { ok: true, approval: { ...a } };
|
|
396
|
+
}
|
|
397
|
+
if (this.deps.actorLive && !this.deps.actorLive(a.actor)) {
|
|
398
|
+
a.state = "denied";
|
|
399
|
+
a.endedBecause = "actor_gone";
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
code: "actor_gone",
|
|
403
|
+
error:
|
|
404
|
+
"The agent that asked has been stopped since, so this won't run.",
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
a.state = "running";
|
|
408
|
+
try {
|
|
409
|
+
a.result = await this.deps.run(a.action, a.args);
|
|
410
|
+
} catch (e) {
|
|
411
|
+
a.result = { ok: false, error: String((e as Error)?.message ?? e) };
|
|
412
|
+
}
|
|
413
|
+
a.state = "done";
|
|
414
|
+
return { ok: true, approval: { ...a } };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -34,12 +34,12 @@ export interface GithubRouteDeps {
|
|
|
34
34
|
cli: string;
|
|
35
35
|
sendJson: (res: http.ServerResponse, body: unknown, status?: number) => void;
|
|
36
36
|
readBody: (req: http.IncomingMessage) => Promise<string>;
|
|
37
|
-
/** A new browser session for this request
|
|
37
|
+
/** A new browser session for this request: its Set-Cookie line and approval key. */
|
|
38
38
|
signIn: (
|
|
39
39
|
req: http.IncomingMessage,
|
|
40
40
|
via: "github" | "link",
|
|
41
41
|
login?: string,
|
|
42
|
-
) => string;
|
|
42
|
+
) => { cookie: string; approveKey: string };
|
|
43
43
|
/** The edge hostnames that route here, and the URL a browser uses for each. */
|
|
44
44
|
edgeRoutes: () => { host: string; url: string }[];
|
|
45
45
|
/** A one-time sign-in link at `origin` (60 s), or null with no secret. */
|
|
@@ -203,8 +203,16 @@ export async function handleGithubRoutes(
|
|
|
203
203
|
deps.sendJson(res, { ok: false, state: "gone" }, 404);
|
|
204
204
|
return true;
|
|
205
205
|
}
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
const s = deps.signIn(req, "github", v.login);
|
|
207
|
+
res.setHeader("Set-Cookie", s.cookie);
|
|
208
|
+
res.setHeader("Cache-Control", "no-store");
|
|
209
|
+
// The approval key goes to this answer only (web-session.ts).
|
|
210
|
+
deps.sendJson(res, {
|
|
211
|
+
ok: true,
|
|
212
|
+
state: "ok",
|
|
213
|
+
login: v.login,
|
|
214
|
+
approveKey: s.approveKey,
|
|
215
|
+
});
|
|
208
216
|
return true;
|
|
209
217
|
}
|
|
210
218
|
const message = finishedMessage(v, deps.cli);
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The writes ON-3 holds for the person (approval-routes.ts): each one's
|
|
3
|
+
* check (the args turned into Den's own words, bad args refused before
|
|
4
|
+
* anything waits) and its run. The runs are the existing code paths, not
|
|
5
|
+
* copies:
|
|
6
|
+
*
|
|
7
|
+
* workspace_create the Workspaces page's create (workspaceCreateRefusal,
|
|
8
|
+
* createWorkspace)
|
|
9
|
+
* repo_add `repo add --json` (repo-command.ts repoAdd), a local
|
|
10
|
+
* folder inside home only, as manifest_draft reads; its
|
|
11
|
+
* dry run is the Review
|
|
12
|
+
* service_try `up` through the service runner, as
|
|
13
|
+
* /api/service/exec and service_up run it
|
|
14
|
+
* (lifecycle-exec.ts), until it is ready or exits
|
|
15
|
+
*/
|
|
16
|
+
import * as path from "path";
|
|
17
|
+
import { checkOnboardPath, type PathRules } from "../cli/onboard-reads.js";
|
|
18
|
+
import {
|
|
19
|
+
repoAdd,
|
|
20
|
+
type ManifestValidator,
|
|
21
|
+
type RepoAddResult,
|
|
22
|
+
} from "../cli/repo-command.js";
|
|
23
|
+
import type { HeldAction, HeldActions, HeldCheck } from "./approval-routes.js";
|
|
24
|
+
|
|
25
|
+
export interface TryResult {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
exitCode: number | null;
|
|
28
|
+
/** Started and healthy (or, with no check, still running). */
|
|
29
|
+
ready: boolean;
|
|
30
|
+
lines: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface HeldActionDeps {
|
|
34
|
+
rules: () => PathRules;
|
|
35
|
+
validate: ManifestValidator;
|
|
36
|
+
workspaceCreateRefusal: (name: string | undefined) => string | null;
|
|
37
|
+
createWorkspace: (name: string) => void;
|
|
38
|
+
workspaceExists: (name: string) => boolean;
|
|
39
|
+
/** Where `repo add` puts a repo with no -w: the focused one, else `default`. */
|
|
40
|
+
defaultWorkspace: () => string;
|
|
41
|
+
/** The service `up` would start, or why it can't (the exec route's checks). */
|
|
42
|
+
serviceTarget: (
|
|
43
|
+
service: string,
|
|
44
|
+
workspace?: string,
|
|
45
|
+
) => { ok: true; address: string } | { ok: false; error: string };
|
|
46
|
+
tryService: (address: string, workspace?: string) => Promise<TryResult>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const str = (v: unknown): string | undefined =>
|
|
50
|
+
typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
51
|
+
|
|
52
|
+
/** A path as the person reads it: ~/… inside home. */
|
|
53
|
+
function shown(p: string, home: string): string {
|
|
54
|
+
const rel = path.relative(home, p);
|
|
55
|
+
return rel && !rel.startsWith("..") && !path.isAbsolute(rel)
|
|
56
|
+
? `~/${rel}`
|
|
57
|
+
: rel === ""
|
|
58
|
+
? "~"
|
|
59
|
+
: p;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
63
|
+
|
|
64
|
+
/** A refusal from a dry run, with its question and choices when it asks. */
|
|
65
|
+
function draftError(r: RepoAddResult): string {
|
|
66
|
+
return [
|
|
67
|
+
r.error ?? "repo add can't add it",
|
|
68
|
+
r.ask ? `Ask the person: ${r.ask}` : "",
|
|
69
|
+
r.choices?.length ? `Choices: ${r.choices.join(" | ")}` : "",
|
|
70
|
+
]
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join(" ");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function heldActions(d: HeldActionDeps): HeldActions {
|
|
76
|
+
const workspaceCreate: HeldAction = {
|
|
77
|
+
check(a): HeldCheck {
|
|
78
|
+
const name = str(a.name);
|
|
79
|
+
const refusal = d.workspaceCreateRefusal(name);
|
|
80
|
+
if (refusal || !name)
|
|
81
|
+
return { ok: false, error: refusal ?? "name required" };
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
args: { name },
|
|
85
|
+
summary: `create the workspace "${name}"`,
|
|
86
|
+
params: { name },
|
|
87
|
+
details: [`A new, empty workspace named "${name}".`],
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
async run(a) {
|
|
91
|
+
const name = String(a.name);
|
|
92
|
+
d.createWorkspace(name);
|
|
93
|
+
return { ok: true, name };
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const repoAddHeld: HeldAction = {
|
|
98
|
+
check(a): HeldCheck {
|
|
99
|
+
const rules = d.rules();
|
|
100
|
+
const where = checkOnboardPath(str(a.path), rules);
|
|
101
|
+
if (!where.ok) return { ok: false, error: where.error };
|
|
102
|
+
const workspace = str(a.workspace);
|
|
103
|
+
if (workspace !== undefined && !NAME_RE.test(workspace))
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error: `workspace "${workspace}" must use only letters, digits, ".", "_" and "-"`,
|
|
107
|
+
};
|
|
108
|
+
const opts = {
|
|
109
|
+
name: str(a.name),
|
|
110
|
+
run: str(a.run),
|
|
111
|
+
down: str(a.down),
|
|
112
|
+
health: str(a.health),
|
|
113
|
+
};
|
|
114
|
+
const draft = repoAdd(
|
|
115
|
+
{ spec: where.dir, sidecar: false, json: true, dryRun: true, ...opts },
|
|
116
|
+
d.validate,
|
|
117
|
+
where.dir,
|
|
118
|
+
);
|
|
119
|
+
if (!draft.ok) return { ok: false, error: draftError(draft) };
|
|
120
|
+
const ws = workspace ?? d.defaultWorkspace();
|
|
121
|
+
const isNew = !d.workspaceExists(ws);
|
|
122
|
+
const folder = shown(where.dir, rules.home);
|
|
123
|
+
const details = [
|
|
124
|
+
`Folder: ${folder} (your checkout; Den never changes the repo).`,
|
|
125
|
+
`Workspace: ${ws}${isNew ? " (new)" : ""}.`,
|
|
126
|
+
draft.manifest?.source === "repo"
|
|
127
|
+
? `Manifest: the repo's own (${path.basename(draft.manifest.file)}).`
|
|
128
|
+
: `Manifest: a side-car Den writes in this distribution's manifests/ folder${draft.manifest?.file ? ` (${path.basename(draft.manifest.file)})` : ""}:`,
|
|
129
|
+
...(draft.draft
|
|
130
|
+
? draft.draft
|
|
131
|
+
.replace(/\n$/, "")
|
|
132
|
+
.split("\n")
|
|
133
|
+
.map((l) => ` ${l}`)
|
|
134
|
+
: []),
|
|
135
|
+
...(draft.services?.length
|
|
136
|
+
? [`Services: ${draft.services.join(", ")}.`]
|
|
137
|
+
: []),
|
|
138
|
+
];
|
|
139
|
+
return {
|
|
140
|
+
ok: true,
|
|
141
|
+
args: {
|
|
142
|
+
path: where.dir,
|
|
143
|
+
...(workspace ? { workspace } : {}),
|
|
144
|
+
...Object.fromEntries(Object.entries(opts).filter(([, v]) => v)),
|
|
145
|
+
},
|
|
146
|
+
summary: `add the repo at ${folder} to workspace "${ws}"${isNew ? " (new)" : ""}`,
|
|
147
|
+
params: {
|
|
148
|
+
path: folder,
|
|
149
|
+
workspace: ws,
|
|
150
|
+
...(isNew ? { isNew: "1" } : {}),
|
|
151
|
+
},
|
|
152
|
+
details,
|
|
153
|
+
};
|
|
154
|
+
},
|
|
155
|
+
async run(a) {
|
|
156
|
+
const dir = String(a.path);
|
|
157
|
+
return repoAdd(
|
|
158
|
+
{
|
|
159
|
+
spec: dir,
|
|
160
|
+
sidecar: false,
|
|
161
|
+
json: true,
|
|
162
|
+
workspace: str(a.workspace),
|
|
163
|
+
name: str(a.name),
|
|
164
|
+
run: str(a.run),
|
|
165
|
+
down: str(a.down),
|
|
166
|
+
health: str(a.health),
|
|
167
|
+
},
|
|
168
|
+
d.validate,
|
|
169
|
+
dir,
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const serviceTry: HeldAction = {
|
|
175
|
+
check(a): HeldCheck {
|
|
176
|
+
const service = str(a.service);
|
|
177
|
+
if (!service)
|
|
178
|
+
return { ok: false, error: "give service: <repo> or <repo>/<service>" };
|
|
179
|
+
const workspace = str(a.workspace);
|
|
180
|
+
const t = d.serviceTarget(service, workspace);
|
|
181
|
+
if (!t.ok) return { ok: false, error: t.error };
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
args: { service: t.address, ...(workspace ? { workspace } : {}) },
|
|
185
|
+
summary: `start ${t.address}${workspace ? ` in workspace "${workspace}"` : ""}`,
|
|
186
|
+
params: { service: t.address, ...(workspace ? { workspace } : {}) },
|
|
187
|
+
details: [
|
|
188
|
+
`Runs the service's local "up", as the Services page's Start does.`,
|
|
189
|
+
`Den reports the exit code and the last lines of its output.`,
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
async run(a) {
|
|
194
|
+
return d.tryService(String(a.service), str(a.workspace));
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
workspace_create: workspaceCreate,
|
|
200
|
+
repo_add: repoAddHeld,
|
|
201
|
+
service_try: serviceTry,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
@@ -106,6 +106,41 @@ export function serveFile(
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* 0.4.15: the headers for a file something other than Den wrote (a test
|
|
111
|
+
* run's report: an agent may have written it). CSP `sandbox` without
|
|
112
|
+
* allow-same-origin runs its scripts in an opaque origin, so they can't
|
|
113
|
+
* read Den's localStorage (the approval key) or act as Den's page;
|
|
114
|
+
* nosniff keeps a .json from being run as a page.
|
|
115
|
+
*/
|
|
116
|
+
export const UNTRUSTED_FILE_HEADERS: Readonly<Record<string, string>> = {
|
|
117
|
+
"Content-Security-Policy":
|
|
118
|
+
"sandbox allow-scripts allow-popups allow-downloads",
|
|
119
|
+
"X-Content-Type-Options": "nosniff",
|
|
120
|
+
"Cache-Control": "no-store",
|
|
121
|
+
"Referrer-Policy": "no-referrer",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** A file something other than Den wrote, as it is (no product tokens), sandboxed. */
|
|
125
|
+
export function serveUntrustedFile(
|
|
126
|
+
res: http.ServerResponse,
|
|
127
|
+
filePath: string,
|
|
128
|
+
): void {
|
|
129
|
+
let data: Buffer;
|
|
130
|
+
try {
|
|
131
|
+
data = fs.readFileSync(filePath);
|
|
132
|
+
} catch {
|
|
133
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
134
|
+
res.end("Not found");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
res.writeHead(200, {
|
|
138
|
+
"Content-Type": mime(filePath),
|
|
139
|
+
...UNTRUSTED_FILE_HEADERS,
|
|
140
|
+
});
|
|
141
|
+
res.end(data);
|
|
142
|
+
}
|
|
143
|
+
|
|
109
144
|
export function send400(res: http.ServerResponse, msg: string): void {
|
|
110
145
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
111
146
|
res.end(msg);
|