@monster-orange/den 0.4.3 → 0.4.5
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 +348 -0
- package/README.md +12 -11
- package/docs/distributions.md +438 -91
- package/docs/reference.md +8 -3
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +131 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/den-config.ts +64 -3
- package/src/shared/dev-metrics/emit.ts +31 -1
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/agent-ops.ts +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/web-command.ts +512 -33
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +540 -33
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +89 -21
- package/src/ui/server/public/i18n/ja.js +88 -21
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +44 -21
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +10 -2
- package/src/ui/server/public/settings-registry.js +617 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +151 -33
- package/src/ui/server/public/topbar.js +170 -206
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +116 -4
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
|
@@ -26,10 +26,18 @@
|
|
|
26
26
|
* included (Ward, 2026-11-06: the lock, R8, is the only prompt there;
|
|
27
27
|
* Host, Origin and CSRF cover CSRF and DNS rebinding). SIGNIN_FREE
|
|
28
28
|
* routes never need one.
|
|
29
|
-
* - The edge
|
|
30
|
-
*
|
|
31
|
-
* X-Forwarded-For
|
|
32
|
-
*
|
|
29
|
+
* - The edge (0.4.3, 0.4.5: edgeClient): the edge hostname from an edge
|
|
30
|
+
* peer gets loopback's treatment when the edge attests the client is
|
|
31
|
+
* this machine (the right-most X-Forwarded-For entry, the one the edge
|
|
32
|
+
* itself appended, is loopback or one of this machine's addresses). A
|
|
33
|
+
* client it can't place (none, or a Docker/VM gateway's address) signs
|
|
34
|
+
* in unless `web.edge_unattested: local`; another machine always does,
|
|
35
|
+
* as does every edge request when this server is deliberately exposed
|
|
36
|
+
* (`exposed`: a non-loopback DEN_BIND_HOST bind). `localhost` (a
|
|
37
|
+
* loopback Host from a loopback peer) never asks.
|
|
38
|
+
* - Signing in with GitHub (SEC-2a): its start and poll are sign-in free,
|
|
39
|
+
* and its start needs an allowed Origin but no CSRF token (a signed-out
|
|
40
|
+
* page has none; route-classes.ts SIGNIN_CSRF_FREE).
|
|
33
41
|
* - Origin, on POST/PUT/PATCH/DELETE and every WebSocket upgrade: an
|
|
34
42
|
* allowed host (http or https). No Origin needs a valid bearer.
|
|
35
43
|
* Otherwise 403 `origin_rejected`.
|
|
@@ -40,7 +48,8 @@
|
|
|
40
48
|
* GETs, pages and SSE streams on loopback pass with only the Host check
|
|
41
49
|
* (§6), except the GETs route-classes.ts marks `mutating` or `shell-grade`
|
|
42
50
|
* (a run, a service's lifecycle command): same-origin only, or a CLI's
|
|
43
|
-
* bearer.
|
|
51
|
+
* bearer. A sign-in GET (SIGNIN_GETS: /auth, the GitHub sign-in poll) may
|
|
52
|
+
* also come with no fetch metadata at all, as a plain-http LAN origin's do.
|
|
44
53
|
*
|
|
45
54
|
* The audit actor (auditActorOf, principalOf) comes from the credential,
|
|
46
55
|
* never from a header the caller sets: `agent:<id>` for an actor token,
|
|
@@ -61,7 +70,9 @@ import {
|
|
|
61
70
|
isMutatingGet,
|
|
62
71
|
pathMatches,
|
|
63
72
|
routeClass,
|
|
73
|
+
SIGNIN_CSRF_FREE,
|
|
64
74
|
SIGNIN_FREE,
|
|
75
|
+
SIGNIN_GETS,
|
|
65
76
|
type RouteClass,
|
|
66
77
|
} from "./route-classes.js";
|
|
67
78
|
|
|
@@ -143,6 +154,11 @@ export interface GuardContext {
|
|
|
143
154
|
* needs a sign-in too, as before 0.4.3.
|
|
144
155
|
*/
|
|
145
156
|
exposed?: boolean;
|
|
157
|
+
/**
|
|
158
|
+
* `web.edge_unattested` (SEC-2a): an edge client Den can't place signs
|
|
159
|
+
* in (`signin`, the default when unset) or counts as local (`local`).
|
|
160
|
+
*/
|
|
161
|
+
edgeUnattested?: "local" | "signin";
|
|
146
162
|
}
|
|
147
163
|
|
|
148
164
|
export type GuardResult =
|
|
@@ -155,6 +171,8 @@ export type GuardResult =
|
|
|
155
171
|
beyond: boolean;
|
|
156
172
|
/** The signed-in session, when the cookie names a live one. */
|
|
157
173
|
session: GuardSession | null;
|
|
174
|
+
/** The client's address when Den can attest it (SEC-2a: a device's record). */
|
|
175
|
+
client?: string;
|
|
158
176
|
}
|
|
159
177
|
| {
|
|
160
178
|
ok: false;
|
|
@@ -329,14 +347,10 @@ function inSubnet(a: string, net4: { address: string; netmask: string }) {
|
|
|
329
347
|
return m > 0 && lo(ipv4(a)) === lo(ipv4(net4.address));
|
|
330
348
|
}
|
|
331
349
|
|
|
332
|
-
/**
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
export function forwardedClient(xff: string | undefined): string | undefined {
|
|
337
|
-
const first = xff?.split(",")[0]?.trim();
|
|
338
|
-
if (!first) return undefined;
|
|
339
|
-
let a = first;
|
|
350
|
+
/** One X-Forwarded-For entry as an IP (a port or brackets dropped), or undefined. */
|
|
351
|
+
function forwardedEntry(entry: string | undefined): string | undefined {
|
|
352
|
+
let a = entry?.trim();
|
|
353
|
+
if (!a) return undefined;
|
|
340
354
|
const b = /^\[([^\]]+)\](?::\d+)?$/.exec(a);
|
|
341
355
|
if (b) a = b[1];
|
|
342
356
|
else if (/^\d+\.\d+\.\d+\.\d+:\d+$/.test(a)) a = a.replace(/:\d+$/, "");
|
|
@@ -345,31 +359,48 @@ export function forwardedClient(xff: string | undefined): string | undefined {
|
|
|
345
359
|
}
|
|
346
360
|
|
|
347
361
|
/**
|
|
348
|
-
* The edge
|
|
349
|
-
*
|
|
350
|
-
*
|
|
362
|
+
* The client the edge vouches for (SEC-2a): the right-most X-Forwarded-For
|
|
363
|
+
* entry, the one the edge's proxy appended itself (its TCP peer, or the
|
|
364
|
+
* client a trusted front passed on with the PROXY protocol). The entries
|
|
365
|
+
* left of it are whatever the client, or a hop the edge trusts, sent, so
|
|
366
|
+
* they are never believed (0.4.4 and earlier read the left-most: forgeable). Undefined
|
|
367
|
+
* when there is none, or it isn't an IP.
|
|
368
|
+
*/
|
|
369
|
+
export function forwardedClient(xff: string | undefined): string | undefined {
|
|
370
|
+
const all = xff?.split(",") ?? [];
|
|
371
|
+
return forwardedEntry(all[all.length - 1]);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Who an edge request's client is, as far as Den can tell (edgeClient). */
|
|
375
|
+
export type EdgeClient = "local" | "unattested" | "foreign";
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Who an edge request (the edge hostname from an edge peer) comes from, by
|
|
379
|
+
* the client the edge vouches for (forwardedClient):
|
|
351
380
|
* - loopback, or one of this machine's own interface addresses (its LAN
|
|
352
|
-
* or VPN address, a Tailscale 100.x one included): local
|
|
381
|
+
* or VPN address, a Tailscale 100.x one included): `local`;
|
|
353
382
|
* - missing, unreadable, or a container/VM network's address (the Docker
|
|
354
383
|
* ranges isEdgePeer takes, a subnet of one of this machine's bridges,
|
|
355
384
|
* one of the edge's own Docker networks, or OrbStack's 0.250.250.254):
|
|
356
|
-
*
|
|
357
|
-
*
|
|
358
|
-
* client
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* (edge-exposure.ts): with no sign-in, anyone who reaches it is local.
|
|
385
|
+
* `unattested`. Docker Desktop and OrbStack on macOS hand the edge
|
|
386
|
+
* their gateway's address, and a TCP passthrough in front hides the
|
|
387
|
+
* client, so Den can't tell. With SEC-2a it signs in, unless
|
|
388
|
+
* `web.edge_unattested: local` (0.4.4's allow);
|
|
389
|
+
* - anything else: `foreign`, another machine (sign-in required).
|
|
362
390
|
*/
|
|
363
|
-
export function
|
|
391
|
+
export function edgeClient(
|
|
364
392
|
xff: string | undefined,
|
|
365
393
|
local: LocalNet = { own: [], bridges: [] },
|
|
366
|
-
):
|
|
394
|
+
): EdgeClient {
|
|
367
395
|
const c = forwardedClient(xff);
|
|
368
|
-
if (!c
|
|
396
|
+
if (!c) return "unattested";
|
|
397
|
+
if (isLoopbackAddress(c)) return "local";
|
|
398
|
+
if (isEdgePeer(c) || local.bridges.some((b) => inSubnet(c, b)))
|
|
399
|
+
return "unattested";
|
|
369
400
|
// 0.0.0.0/8 ("this network") is never a client on another machine:
|
|
370
401
|
// OrbStack presents the host to its containers as 0.250.250.254.
|
|
371
|
-
if (net.isIPv4(c) && c.startsWith("0.")) return
|
|
372
|
-
return local.
|
|
402
|
+
if (net.isIPv4(c) && c.startsWith("0.")) return "unattested";
|
|
403
|
+
return local.own.includes(c) ? "local" : "foreign";
|
|
373
404
|
}
|
|
374
405
|
|
|
375
406
|
/**
|
|
@@ -479,13 +510,25 @@ export function checkRequest(
|
|
|
479
510
|
"127.0.0.1, [::1] with its port, or its edge hostname).",
|
|
480
511
|
};
|
|
481
512
|
const peerLoopback = ctx.peerIsLoopback ? ctx.peerIsLoopback(req) : true;
|
|
482
|
-
// The edge
|
|
483
|
-
//
|
|
513
|
+
// The edge: local when it attests this machine; a client it can't place
|
|
514
|
+
// signs in unless web.edge_unattested is local; never local when this
|
|
515
|
+
// server is deliberately exposed. A LAN name or peer is beyond.
|
|
516
|
+
const xff = header(req, "x-forwarded-for");
|
|
517
|
+
const who = via === "edge" ? edgeClient(xff, ctx.localNet?.()) : null;
|
|
484
518
|
const beyond =
|
|
485
519
|
via === "edge"
|
|
486
520
|
? !!ctx.exposed ||
|
|
487
|
-
|
|
521
|
+
who === "foreign" ||
|
|
522
|
+
(who === "unattested" && ctx.edgeUnattested !== "local")
|
|
488
523
|
: via !== "loopback" || !peerLoopback;
|
|
524
|
+
// The address a device's record keeps: the edge's attested client, or
|
|
525
|
+
// the peer itself off the edge.
|
|
526
|
+
const client =
|
|
527
|
+
via === "edge"
|
|
528
|
+
? who === "unattested"
|
|
529
|
+
? undefined
|
|
530
|
+
: forwardedClient(xff)
|
|
531
|
+
: (req.socket?.remoteAddress ?? undefined);
|
|
489
532
|
|
|
490
533
|
let principal: Principal | null = null;
|
|
491
534
|
const auth = header(req, "authorization");
|
|
@@ -532,7 +575,8 @@ export function checkRequest(
|
|
|
532
575
|
}
|
|
533
576
|
|
|
534
577
|
// §4: a session (or a bearer) beyond loopback; on loopback, never.
|
|
535
|
-
|
|
578
|
+
const signinFree = SIGNIN_FREE.some((r) => pathMatches(r, pathname));
|
|
579
|
+
if (!principal && !session && !signinFree && beyond) {
|
|
536
580
|
const scope: SigninScope = "den";
|
|
537
581
|
return {
|
|
538
582
|
ok: false,
|
|
@@ -550,6 +594,7 @@ export function checkRequest(
|
|
|
550
594
|
via,
|
|
551
595
|
beyond,
|
|
552
596
|
session,
|
|
597
|
+
...(client ? { client: normAddress(client) } : {}),
|
|
553
598
|
});
|
|
554
599
|
const browser: Principal | null = session
|
|
555
600
|
? { kind: "person-browser", session: session.key }
|
|
@@ -557,7 +602,7 @@ export function checkRequest(
|
|
|
557
602
|
|
|
558
603
|
if (!MUTATING.has(method) && !isUpgrade(req)) {
|
|
559
604
|
if (isMutatingGet(method, pathname)) {
|
|
560
|
-
const refused = mutatingGetRefusal(req, ctx, principal, cli);
|
|
605
|
+
const refused = mutatingGetRefusal(req, ctx, pathname, principal, cli);
|
|
561
606
|
if (refused) return refused;
|
|
562
607
|
}
|
|
563
608
|
return ok(principal ?? browser);
|
|
@@ -584,6 +629,10 @@ export function checkRequest(
|
|
|
584
629
|
};
|
|
585
630
|
// A valid bearer is a CLI's or an agent's call: no CSRF to prove.
|
|
586
631
|
if (principal) return ok(principal);
|
|
632
|
+
// Starting a GitHub sign-in from the signed-out page (which holds no
|
|
633
|
+
// CSRF token): the allowed Origin above is the check.
|
|
634
|
+
if (!session && SIGNIN_CSRF_FREE.some((r) => pathMatches(r, pathname)))
|
|
635
|
+
return ok(null);
|
|
587
636
|
// A browser's WebSocket can't send a header: on an upgrade the token
|
|
588
637
|
// rides as a subprotocol, `den.csrf.<token>` (AG-4a-term's io).
|
|
589
638
|
const csrf =
|
|
@@ -606,13 +655,15 @@ export function checkRequest(
|
|
|
606
655
|
export const AGENT_FORBIDDEN: readonly string[] = [
|
|
607
656
|
// web.secret's management and the browser sign-in: an agent mints nothing.
|
|
608
657
|
"/api/web/*",
|
|
658
|
+
"/api/github/*",
|
|
659
|
+
"/api/signin/*",
|
|
609
660
|
"/auth",
|
|
610
661
|
"/signin",
|
|
611
662
|
"/api/session/logout",
|
|
612
663
|
];
|
|
613
664
|
|
|
614
665
|
/**
|
|
615
|
-
* Routes an agent's token may read but never change: the
|
|
666
|
+
* Routes an agent's token may read but never change: the admin password (R8).
|
|
616
667
|
* Privileged actions stay with people (AG-5); its status is a read.
|
|
617
668
|
*/
|
|
618
669
|
export const AGENT_READ_ONLY: readonly string[] = [
|
|
@@ -673,13 +724,15 @@ function workspaceRefusal(_p: AgentPrincipal, _pathname: string): null {
|
|
|
673
724
|
* is a GET too, so these pass only:
|
|
674
725
|
* - from this server's own pages: Sec-Fetch-Site same-origin (or none, a
|
|
675
726
|
* URL the person typed), and an Origin, if any, that is allowed;
|
|
676
|
-
* - or with neither header (the CLI, curl, MCP) and a valid bearer
|
|
727
|
+
* - or with neither header (the CLI, curl, MCP) and a valid bearer, or
|
|
728
|
+
* (SIGNIN_GETS) a sign-in GET, which carries its own credential.
|
|
677
729
|
* Sec-Fetch-Site same-site or cross-site: 403 `cross_site` (another port on
|
|
678
730
|
* localhost is same-site, not same-origin).
|
|
679
731
|
*/
|
|
680
732
|
function mutatingGetRefusal(
|
|
681
733
|
req: http.IncomingMessage,
|
|
682
734
|
ctx: GuardContext,
|
|
735
|
+
pathname: string,
|
|
683
736
|
principal: Principal | null,
|
|
684
737
|
cli: string,
|
|
685
738
|
): Extract<GuardResult, { ok: false }> | null {
|
|
@@ -699,7 +752,21 @@ function mutatingGetRefusal(
|
|
|
699
752
|
code: "origin_rejected",
|
|
700
753
|
error: "Requests from another site can't start or change anything here.",
|
|
701
754
|
};
|
|
702
|
-
|
|
755
|
+
// A sign-in GET (route-classes.ts SIGNIN_GETS) may come with neither
|
|
756
|
+
// header. A plain-http origin that isn't localhost (a LAN bind with
|
|
757
|
+
// web.allow_plain_http_remote) is not "potentially trustworthy", so
|
|
758
|
+
// Chromium sends it no Sec-Fetch-* headers, and a top-level navigation
|
|
759
|
+
// sends no Origin: the person's own `web login --origin` link arrives
|
|
760
|
+
// bare. That is safe: the request carries its own unguessable credential
|
|
761
|
+
// (/auth a one-time code derived from web.secret, the poll its flow id),
|
|
762
|
+
// which another site can't know, and a cross-site request from a modern
|
|
763
|
+
// browser sends Sec-Fetch-Site or Origin, which the checks above refuse.
|
|
764
|
+
if (
|
|
765
|
+
site === undefined &&
|
|
766
|
+
origin === undefined &&
|
|
767
|
+
!principal &&
|
|
768
|
+
!SIGNIN_GETS.some((r) => pathMatches(r, pathname))
|
|
769
|
+
)
|
|
703
770
|
return {
|
|
704
771
|
ok: false,
|
|
705
772
|
status: 403,
|
|
@@ -145,6 +145,7 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
145
145
|
"/api/graph",
|
|
146
146
|
"/api/applications",
|
|
147
147
|
"/api/personas",
|
|
148
|
+
"/api/agents/runtimes",
|
|
148
149
|
"/api/workspaces/:ws/agents",
|
|
149
150
|
].map((p) => R(p)),
|
|
150
151
|
R(
|
|
@@ -182,9 +183,9 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
182
183
|
|
|
183
184
|
// ── Sign-in (SEC-1b, §2) ────────────────────────────────────────────────
|
|
184
185
|
M(
|
|
185
|
-
["GET", "HEAD"],
|
|
186
|
+
["GET", "HEAD", "POST"],
|
|
186
187
|
"/auth",
|
|
187
|
-
"swaps a one-time login code for a session cookie",
|
|
188
|
+
"swaps a one-time login code for a session cookie (POST: with the code `web login` printed)",
|
|
188
189
|
),
|
|
189
190
|
R(
|
|
190
191
|
"/signin",
|
|
@@ -201,6 +202,71 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
201
202
|
"/api/web/logout",
|
|
202
203
|
"ends browser sessions, and rotates web.secret (`web logout`)",
|
|
203
204
|
),
|
|
205
|
+
// ── SEC-2a: GitHub sign-in, the owner, pairing ─────────────────────────
|
|
206
|
+
M(
|
|
207
|
+
["POST"],
|
|
208
|
+
"/api/github/signin",
|
|
209
|
+
"starts a GitHub sign-in (a device flow the server runs)",
|
|
210
|
+
),
|
|
211
|
+
M(
|
|
212
|
+
["GET", "HEAD"],
|
|
213
|
+
"/api/github/signin/:id",
|
|
214
|
+
"a GitHub sign-in's outcome; once the owner approved it, sets the session cookie",
|
|
215
|
+
),
|
|
216
|
+
R("/api/web/owner", "who the owner is (`web owner show`)"),
|
|
217
|
+
S(
|
|
218
|
+
["POST"],
|
|
219
|
+
"/api/web/owner/link",
|
|
220
|
+
"links the owner's GitHub account (`web owner link`)",
|
|
221
|
+
),
|
|
222
|
+
S(
|
|
223
|
+
["GET", "HEAD"],
|
|
224
|
+
"/api/web/owner/link/:id",
|
|
225
|
+
"an owner link's device code and outcome",
|
|
226
|
+
),
|
|
227
|
+
S(
|
|
228
|
+
["POST"],
|
|
229
|
+
"/api/web/owner/unlink",
|
|
230
|
+
"unlinks the owner (`web owner unlink`)",
|
|
231
|
+
),
|
|
232
|
+
R("/api/web/pair", "the edge hostnames a pairing link can be for"),
|
|
233
|
+
// ── SEC-2a: approve this computer with a code (pair-routes.ts) ─────────
|
|
234
|
+
M(
|
|
235
|
+
["POST"],
|
|
236
|
+
"/api/signin/code",
|
|
237
|
+
"asks for a code the owner approves this browser with (in memory, 10 minutes)",
|
|
238
|
+
),
|
|
239
|
+
M(
|
|
240
|
+
["GET", "HEAD"],
|
|
241
|
+
"/api/signin/code/:id",
|
|
242
|
+
"a code request's state; once approved, sets the session cookie (once)",
|
|
243
|
+
),
|
|
244
|
+
S(
|
|
245
|
+
["GET", "HEAD"],
|
|
246
|
+
"/api/web/waiting",
|
|
247
|
+
"lists the devices waiting for approval (Settings › Access, `web approve --list`)",
|
|
248
|
+
),
|
|
249
|
+
S(
|
|
250
|
+
["POST"],
|
|
251
|
+
"/api/web/approve",
|
|
252
|
+
"approves a waiting device: it signs in (`web approve <code>`)",
|
|
253
|
+
),
|
|
254
|
+
S(["POST"], "/api/web/deny", "denies a waiting device"),
|
|
255
|
+
S(
|
|
256
|
+
["POST"],
|
|
257
|
+
"/api/web/logins",
|
|
258
|
+
"registers `web login`'s link and its code, to wait on",
|
|
259
|
+
),
|
|
260
|
+
S(
|
|
261
|
+
["GET", "HEAD"],
|
|
262
|
+
"/api/web/logins/:id",
|
|
263
|
+
"`web login`'s wait: whether its link was used, by which browser",
|
|
264
|
+
),
|
|
265
|
+
S(
|
|
266
|
+
["POST"],
|
|
267
|
+
"/api/web/pair",
|
|
268
|
+
"mints a one-time sign-in link for another device (Pair a device)",
|
|
269
|
+
),
|
|
204
270
|
|
|
205
271
|
// ── Mutations (checked by method) ───────────────────────────────────────
|
|
206
272
|
M(["POST"], "/api/activity", "an agent's refused-call report"),
|
|
@@ -209,8 +275,13 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
209
275
|
M(["POST", "DELETE"], "/api/release/deploy", "starts or dismisses a deploy"),
|
|
210
276
|
M(["POST"], "/api/manifest/validate", "validates posted content (no write)"),
|
|
211
277
|
M(["POST"], "/api/local-dev"),
|
|
212
|
-
M(["POST"], "/api/sudo-password", "
|
|
213
|
-
M(
|
|
278
|
+
M(["POST"], "/api/sudo-password", "sets the admin password, for sudo (R8)"),
|
|
279
|
+
M(
|
|
280
|
+
["POST"],
|
|
281
|
+
"/api/sudo-password/load-from-vault",
|
|
282
|
+
"sets the admin password from the vault, for sudo (R8)",
|
|
283
|
+
),
|
|
284
|
+
M(["POST"], "/api/sudo-password/forget", "forgets the admin password (R8)"),
|
|
214
285
|
M(["POST"], "/api/scope"),
|
|
215
286
|
M(["POST"], "/api/settings"),
|
|
216
287
|
M(
|
|
@@ -241,6 +312,11 @@ export const ROUTES: readonly RouteEntry[] = [
|
|
|
241
312
|
"/api/workspaces/:ws/agents/:id/resume",
|
|
242
313
|
"resumes an agent: a runtime in a PTY",
|
|
243
314
|
),
|
|
315
|
+
S(
|
|
316
|
+
["POST"],
|
|
317
|
+
"/api/agents/runtimes/:id/path",
|
|
318
|
+
"runs the path given (its version command) before saving it as a runtime's CLI (AG-0.5)",
|
|
319
|
+
),
|
|
244
320
|
S(
|
|
245
321
|
["GET", "HEAD"],
|
|
246
322
|
"/api/sessions/:id/io",
|
|
@@ -263,6 +339,37 @@ export const SIGNIN_FREE: readonly string[] = [
|
|
|
263
339
|
"/auth",
|
|
264
340
|
"/signin",
|
|
265
341
|
"/api/session",
|
|
342
|
+
// SEC-2a: Sign in with GitHub, from the signed-out page.
|
|
343
|
+
"/api/github/signin",
|
|
344
|
+
"/api/github/signin/:id",
|
|
345
|
+
// SEC-2a: approve this computer with a code, from the signed-out page.
|
|
346
|
+
"/api/signin/code",
|
|
347
|
+
"/api/signin/code/:id",
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* SIGNIN_FREE mutations a signed-out page makes, which holds no CSRF token:
|
|
352
|
+
* they need an allowed Origin only (SEC-2a: starting a GitHub sign-in, and
|
|
353
|
+
* asking for a code to be approved with).
|
|
354
|
+
*/
|
|
355
|
+
export const SIGNIN_CSRF_FREE: readonly string[] = [
|
|
356
|
+
"/api/github/signin",
|
|
357
|
+
"/api/signin/code",
|
|
358
|
+
// SEC-2a: a waited-on link's page posts the code `web login` printed.
|
|
359
|
+
"/auth",
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Sign-in GETs that carry their own unguessable credential: /auth a one-time
|
|
364
|
+
* code derived from web.secret, the GitHub sign-in poll its flow id, the
|
|
365
|
+
* code request's poll its own id (never the code). They
|
|
366
|
+
* may come with no fetch metadata at all (request-guard.ts,
|
|
367
|
+
* mutatingGetRefusal): a plain-http LAN origin gets none from the browser.
|
|
368
|
+
*/
|
|
369
|
+
export const SIGNIN_GETS: readonly string[] = [
|
|
370
|
+
"/auth",
|
|
371
|
+
"/api/github/signin/:id",
|
|
372
|
+
"/api/signin/code/:id",
|
|
266
373
|
];
|
|
267
374
|
|
|
268
375
|
/** The dispatchers' route regexes, by `<file>:<const>`, and their entries. */
|
|
@@ -272,7 +379,12 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
|
|
|
272
379
|
"/api/workspaces/:ws/agents/:id/stop",
|
|
273
380
|
"/api/workspaces/:ws/agents/:id/resume",
|
|
274
381
|
],
|
|
382
|
+
"agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
|
|
275
383
|
"session-routes.ts:IO_PATH": ["/api/sessions/:id/io"],
|
|
384
|
+
"github-routes.ts:SIGNIN_PATH": ["/api/github/signin/:id"],
|
|
385
|
+
"github-routes.ts:LINK_PATH": ["/api/web/owner/link/:id"],
|
|
386
|
+
"pair-routes.ts:POLL_PATH": ["/api/signin/code/:id"],
|
|
387
|
+
"index.ts:LOGIN_WAIT_PATH": ["/api/web/logins/:id"],
|
|
276
388
|
};
|
|
277
389
|
|
|
278
390
|
/** Whether `pathname` matches a table path. */
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* - on attach, the scrollback replay, then live output: binary frames;
|
|
19
19
|
* - a text frame `{"type":"session",…}` first (what is attached), and
|
|
20
20
|
* `{"type":"exit","exitCode":n}` when the session ends;
|
|
21
|
+
* - `{"type":"size","cols":c,"rows":r}`: the PTY's size, on attach and
|
|
22
|
+
* each time any viewer (another browser, a CLI attach) resizes it. The
|
|
23
|
+
* size is last-writer-wins; a pane that learns it is out of step says
|
|
24
|
+
* its own again when it is used (focused, typed in, shown);
|
|
21
25
|
* - in: binary frames are input; a text frame is JSON control:
|
|
22
26
|
* `{"type":"resize","cols":c,"rows":r}` (or `{"type":"input","data":s}`).
|
|
23
27
|
* Closing the socket detaches. Nothing here ever signals a session: leaving
|
|
@@ -391,6 +395,9 @@ export async function handleSessionUpgrade(
|
|
|
391
395
|
let exited = s.exited === true;
|
|
392
396
|
const att = client.attach(s.ref.id, {
|
|
393
397
|
onData: (data) => ws.send(data),
|
|
398
|
+
onSize: (cols, rows) => {
|
|
399
|
+
if (ws.open) ws.send(JSON.stringify({ type: "size", cols, rows }));
|
|
400
|
+
},
|
|
394
401
|
onExit: (exitCode) => {
|
|
395
402
|
exited = true;
|
|
396
403
|
ws.send(
|
|
@@ -417,8 +424,9 @@ export async function handleSessionUpgrade(
|
|
|
417
424
|
return;
|
|
418
425
|
}
|
|
419
426
|
if (msg?.type === "resize") {
|
|
427
|
+
// Never below 2x2: a pane measured before it was laid out.
|
|
420
428
|
const ok = (n: unknown): n is number =>
|
|
421
|
-
typeof n === "number" && Number.isInteger(n) && n >=
|
|
429
|
+
typeof n === "number" && Number.isInteger(n) && n >= 2 && n <= 1000;
|
|
422
430
|
if (!exited && ok(msg.cols) && ok(msg.rows))
|
|
423
431
|
client.resize(s.ref.id, msg.cols, msg.rows);
|
|
424
432
|
} else if (msg?.type === "input" && typeof msg.data === "string") {
|