@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
package/src/ui/server/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
|
48
48
|
import {
|
|
49
49
|
getActiveWorkspace,
|
|
50
50
|
getActiveWorkspaces,
|
|
51
|
+
createWorkspace,
|
|
51
52
|
getFocusedWorkspaceName,
|
|
52
53
|
getWorkspaceByName,
|
|
53
54
|
isWorkspaceActive,
|
|
@@ -112,6 +113,7 @@ import {
|
|
|
112
113
|
import {
|
|
113
114
|
assertDenConfigValid,
|
|
114
115
|
readDenConfig,
|
|
116
|
+
readMcpConfig,
|
|
115
117
|
isProductionEnv,
|
|
116
118
|
isLocalEnabled,
|
|
117
119
|
readUserConfig,
|
|
@@ -183,6 +185,11 @@ import {
|
|
|
183
185
|
AUDIT_FILE,
|
|
184
186
|
} from "./audit.js";
|
|
185
187
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
188
|
+
import {
|
|
189
|
+
defaultOnboardRouteDeps,
|
|
190
|
+
handleOnboardRoutes,
|
|
191
|
+
type OnboardRouteDeps,
|
|
192
|
+
} from "./onboard-routes.js";
|
|
186
193
|
import {
|
|
187
194
|
auditActorOf,
|
|
188
195
|
bindRefusal,
|
|
@@ -226,9 +233,12 @@ import {
|
|
|
226
233
|
sessionCookieName,
|
|
227
234
|
sessionIdPrefix,
|
|
228
235
|
WebSessionStore,
|
|
236
|
+
withApproveKey,
|
|
229
237
|
type WebAuthConfig,
|
|
230
238
|
} from "../../shared/web-session.js";
|
|
231
239
|
import { serveUiAsset } from "./ui-assets.js";
|
|
240
|
+
import { PERSON_GRANT_ROUTES, personGrantAllowed } from "./approvals.js";
|
|
241
|
+
import { ClaimNonces, checkClaimCode } from "../../shared/claim-code.js";
|
|
232
242
|
import {
|
|
233
243
|
expiringTokenWarning,
|
|
234
244
|
githubClientId,
|
|
@@ -253,7 +263,9 @@ import {
|
|
|
253
263
|
machineLabel,
|
|
254
264
|
parseMachineName,
|
|
255
265
|
sshCommand,
|
|
266
|
+
sshLogin,
|
|
256
267
|
sshTarget,
|
|
268
|
+
sshUser,
|
|
257
269
|
} from "../../shared/cli-command.js";
|
|
258
270
|
import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
|
|
259
271
|
import { agentsLayout, patchAgentsLayout } from "./agents-layout-pref.js";
|
|
@@ -612,6 +624,9 @@ setHtmlFill((html) => {
|
|
|
612
624
|
|
|
613
625
|
// ── MD-2: the engine this server loaded, against the one on disk ─────────────
|
|
614
626
|
const ENGINE = engineWatch(getEngineRoot);
|
|
627
|
+
|
|
628
|
+
/** The onboarding routes' deps, made on first use. */
|
|
629
|
+
let onboardDeps: OnboardRouteDeps | null = null;
|
|
615
630
|
function engineCheck(): EngineCheck {
|
|
616
631
|
return ENGINE.check();
|
|
617
632
|
}
|
|
@@ -789,6 +804,8 @@ import {
|
|
|
789
804
|
readBody,
|
|
790
805
|
sendJson,
|
|
791
806
|
serveFile,
|
|
807
|
+
serveUntrustedFile,
|
|
808
|
+
UNTRUSTED_FILE_HEADERS,
|
|
792
809
|
send400,
|
|
793
810
|
send404,
|
|
794
811
|
setHtmlFill,
|
|
@@ -824,7 +841,19 @@ import { parseChangelog } from "./changelog-parser.js";
|
|
|
824
841
|
// ── Extracted services ───────────────────────────────────────────────────────
|
|
825
842
|
import { GitHubService } from "./github-service.js";
|
|
826
843
|
import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
|
|
827
|
-
import {
|
|
844
|
+
import {
|
|
845
|
+
handleWorkspaceRoutes,
|
|
846
|
+
workspaceCreateRefusal,
|
|
847
|
+
} from "./workspace-routes.js";
|
|
848
|
+
import {
|
|
849
|
+
createApprovalQueue,
|
|
850
|
+
handleApprovalRoutes,
|
|
851
|
+
isApprovalPath,
|
|
852
|
+
type HeldActions,
|
|
853
|
+
} from "./approval-routes.js";
|
|
854
|
+
import { heldActions } from "./held-actions.js";
|
|
855
|
+
import type { ApprovalQueue } from "./approvals.js";
|
|
856
|
+
import { processPathRules } from "../cli/onboard-reads.js";
|
|
828
857
|
import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
|
|
829
858
|
import {
|
|
830
859
|
handleSessionList,
|
|
@@ -833,7 +862,11 @@ import {
|
|
|
833
862
|
sendSessionHttp,
|
|
834
863
|
} from "./session-routes.js";
|
|
835
864
|
import { refuseUpgrade } from "./websocket.js";
|
|
836
|
-
import {
|
|
865
|
+
import {
|
|
866
|
+
collectLifecycleExec,
|
|
867
|
+
isLifecycleExecCmd,
|
|
868
|
+
streamLifecycleExec,
|
|
869
|
+
} from "./lifecycle-exec.js";
|
|
837
870
|
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
838
871
|
import {
|
|
839
872
|
edgeHttps,
|
|
@@ -1219,6 +1252,26 @@ function loggedPath(pathname: string): string {
|
|
|
1219
1252
|
);
|
|
1220
1253
|
}
|
|
1221
1254
|
|
|
1255
|
+
/**
|
|
1256
|
+
* The account the signed-out page's ssh link logs in as (SEC-2a-UX):
|
|
1257
|
+
* `web.ssh_user` (read now), else the account Den runs as; "" for none.
|
|
1258
|
+
*/
|
|
1259
|
+
function sshUserNow(): string {
|
|
1260
|
+
let set: string | undefined;
|
|
1261
|
+
try {
|
|
1262
|
+
set = readWebAuthConfig(getDistRoot(), product().configFile).sshUser;
|
|
1263
|
+
} catch {
|
|
1264
|
+
set = WEB_AUTH.sshUser;
|
|
1265
|
+
}
|
|
1266
|
+
let osUser = "";
|
|
1267
|
+
try {
|
|
1268
|
+
osUser = os.userInfo().username;
|
|
1269
|
+
} catch {
|
|
1270
|
+
/* no account name: none on the page */
|
|
1271
|
+
}
|
|
1272
|
+
return sshUser(set, osUser);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1222
1275
|
/** The ssh target for this request: the Host it used, minus the port. */
|
|
1223
1276
|
function sshTargetOf(req: http.IncomingMessage): string {
|
|
1224
1277
|
return sshTarget(req.headers.host, os.hostname());
|
|
@@ -1245,22 +1298,28 @@ function closeSessionSockets(keys: Iterable<string>): void {
|
|
|
1245
1298
|
}
|
|
1246
1299
|
}
|
|
1247
1300
|
|
|
1248
|
-
/**
|
|
1301
|
+
/**
|
|
1302
|
+
* A new session for this request: its Set-Cookie line, and its approval
|
|
1303
|
+
* key (web-session.ts), which only this sign-in's own answer may carry.
|
|
1304
|
+
*/
|
|
1249
1305
|
function startSession(
|
|
1250
1306
|
req: http.IncomingMessage,
|
|
1251
|
-
via: "github" | "link" | "code",
|
|
1307
|
+
via: "github" | "link" | "code" | "claim",
|
|
1252
1308
|
login?: string,
|
|
1253
|
-
): string {
|
|
1309
|
+
): { cookie: string; approveKey: string } {
|
|
1254
1310
|
const g = checkRequest(req, GUARD, product().cli, product().title);
|
|
1255
1311
|
const named = g.ok ? g.via : null;
|
|
1256
|
-
const { id } = webSessions.create({
|
|
1312
|
+
const { id, approveKey } = webSessions.create({
|
|
1257
1313
|
userAgent: req.headers["user-agent"],
|
|
1258
1314
|
host: req.headers.host,
|
|
1259
1315
|
via,
|
|
1260
1316
|
...(g.ok && g.client ? { client: g.client } : {}),
|
|
1261
1317
|
...(login ? { login } : {}),
|
|
1262
1318
|
});
|
|
1263
|
-
return
|
|
1319
|
+
return {
|
|
1320
|
+
cookie: sessionSetCookie(id, requestIsHttps(req, named)),
|
|
1321
|
+
approveKey,
|
|
1322
|
+
};
|
|
1264
1323
|
}
|
|
1265
1324
|
|
|
1266
1325
|
/** The edge hostnames that route here, and their URLs (Pair a device). */
|
|
@@ -1366,6 +1425,7 @@ function sendSignedOut(
|
|
|
1366
1425
|
toLocal || host === `localhost:${PORT}` ? [] : ["--origin", origin];
|
|
1367
1426
|
const cmd = cliCommand(bin, "web", "login", ...loginArgs);
|
|
1368
1427
|
const ssh = sshTargetOf(req);
|
|
1428
|
+
const user = sshUserNow();
|
|
1369
1429
|
res.setHeader("Cache-Control", "no-store");
|
|
1370
1430
|
sendErrorPage(req, res, {
|
|
1371
1431
|
status: 401,
|
|
@@ -1381,9 +1441,11 @@ function sendSignedOut(
|
|
|
1381
1441
|
machine: machineName(),
|
|
1382
1442
|
ssh: {
|
|
1383
1443
|
target: ssh,
|
|
1444
|
+
// SEC-2a-UX: the account to log in as ("" for none).
|
|
1445
|
+
user,
|
|
1384
1446
|
// Open Terminal (ssh://) only for a name, never an IP literal.
|
|
1385
1447
|
name: !isIpLiteral(ssh),
|
|
1386
|
-
login: sshCommand(ssh, cmd),
|
|
1448
|
+
login: sshCommand(sshLogin(ssh, user), cmd),
|
|
1387
1449
|
},
|
|
1388
1450
|
// SEC-2a: Sign in with GitHub, and why it can't be offered yet.
|
|
1389
1451
|
github: {
|
|
@@ -1475,6 +1537,8 @@ async function handleAuth(
|
|
|
1475
1537
|
url: URL,
|
|
1476
1538
|
requestId: string,
|
|
1477
1539
|
): Promise<void> {
|
|
1540
|
+
if (url.searchParams.has("claim"))
|
|
1541
|
+
return handleClaim(req, res, url, requestId);
|
|
1478
1542
|
const secret = webSecret.current();
|
|
1479
1543
|
const c = url.searchParams.get("c") ?? "";
|
|
1480
1544
|
const nonce = loginCodeNonce(c);
|
|
@@ -1566,25 +1630,74 @@ async function handleAuth(
|
|
|
1566
1630
|
device: deviceName(req.headers["user-agent"]),
|
|
1567
1631
|
...(g.ok && g.client ? { client: g.client } : {}),
|
|
1568
1632
|
});
|
|
1569
|
-
|
|
1633
|
+
const s = startSession(req, "link");
|
|
1634
|
+
res.setHeader("Set-Cookie", s.cookie);
|
|
1635
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1570
1636
|
return sendJson(res, {
|
|
1571
1637
|
ok: true,
|
|
1572
1638
|
next: safeNext(url.searchParams.get("next")),
|
|
1639
|
+
approveKey: s.approveKey,
|
|
1573
1640
|
});
|
|
1574
1641
|
}
|
|
1575
1642
|
// A link nobody waits on (`web login --no-wait`, or minted with no
|
|
1576
1643
|
// server running): it signs in when opened, once, as before.
|
|
1577
1644
|
if (post || !webSessions.consumeCode(check.nonce, check.expiresAt))
|
|
1578
1645
|
return gone();
|
|
1646
|
+
const s = startSession(req, "link");
|
|
1579
1647
|
res.writeHead(303, {
|
|
1580
|
-
|
|
1581
|
-
|
|
1648
|
+
// The approval key in the fragment: it never reaches a server.
|
|
1649
|
+
Location: withApproveKey(
|
|
1650
|
+
safeNext(url.searchParams.get("next")),
|
|
1651
|
+
s.approveKey,
|
|
1652
|
+
),
|
|
1653
|
+
"Set-Cookie": s.cookie,
|
|
1582
1654
|
"Cache-Control": "no-store",
|
|
1583
1655
|
"Referrer-Policy": "no-referrer",
|
|
1584
1656
|
});
|
|
1585
1657
|
res.end();
|
|
1586
1658
|
}
|
|
1587
1659
|
|
|
1660
|
+
/**
|
|
1661
|
+
* `GET /auth?claim=<code>` (0.4.15): the setup link `<cli> web claim-link`
|
|
1662
|
+
* prints at the end of an install. A valid, unused claim code (30 min,
|
|
1663
|
+
* single use across restarts) becomes a session ("claimed with a setup
|
|
1664
|
+
* link" in Linked devices), then a 303 to `/` with the session's approval
|
|
1665
|
+
* key in the fragment and no claim in the address, no referrer. Anything
|
|
1666
|
+
* else: the signed-out page, which says the link was used and never
|
|
1667
|
+
* echoes the code. An agent's token never gets here (AGENT_FORBIDDEN).
|
|
1668
|
+
*/
|
|
1669
|
+
const CLAIM_NONCES = new ClaimNonces(getStateDir());
|
|
1670
|
+
function handleClaim(
|
|
1671
|
+
req: http.IncomingMessage,
|
|
1672
|
+
res: http.ServerResponse,
|
|
1673
|
+
url: URL,
|
|
1674
|
+
requestId: string,
|
|
1675
|
+
): void {
|
|
1676
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1677
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1678
|
+
const secret = webSecret.current();
|
|
1679
|
+
const check =
|
|
1680
|
+
req.method !== "POST" && secret
|
|
1681
|
+
? checkClaimCode(url.searchParams.get("claim") ?? "", secret)
|
|
1682
|
+
: null;
|
|
1683
|
+
if (!check?.ok || !CLAIM_NONCES.consume(check.nonce, check.expiresAt))
|
|
1684
|
+
return sendSignedOut(req, res, {
|
|
1685
|
+
pathname: "/auth",
|
|
1686
|
+
search: "",
|
|
1687
|
+
scope: "den",
|
|
1688
|
+
expired: true,
|
|
1689
|
+
requestId,
|
|
1690
|
+
message: `This setup link was used or has expired. If you didn't open it yourself, someone else may have: check Settings › Access › Linked devices. Sign in below, or run \`${product().cli} web claim-link\` in your own terminal for a new one.`,
|
|
1691
|
+
});
|
|
1692
|
+
const s = startSession(req, "claim");
|
|
1693
|
+
res.writeHead(303, {
|
|
1694
|
+
// The approval key in the fragment: it never reaches a server.
|
|
1695
|
+
Location: withApproveKey("/", s.approveKey),
|
|
1696
|
+
"Set-Cookie": s.cookie,
|
|
1697
|
+
});
|
|
1698
|
+
res.end();
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1588
1701
|
/** `web login`'s wait (login-waits.ts), by the CLI's id. */
|
|
1589
1702
|
const LOGIN_WAIT_PATH = /^\/api\/web\/logins\/([A-Za-z0-9_-]{16,64})$/;
|
|
1590
1703
|
const LOGIN_WAITS = new LoginWaits();
|
|
@@ -1606,6 +1719,109 @@ function declaredCommand<T>(
|
|
|
1606
1719
|
return own(forEnv, cmd) ? forEnv![cmd] : undefined;
|
|
1607
1720
|
}
|
|
1608
1721
|
|
|
1722
|
+
// ── ON-3: the writes held for the person (approval-routes.ts) ─────────────────
|
|
1723
|
+
let approvals: {
|
|
1724
|
+
queue: ApprovalQueue;
|
|
1725
|
+
actions: HeldActions;
|
|
1726
|
+
enabled: () => boolean;
|
|
1727
|
+
} | null = null;
|
|
1728
|
+
/** The held writes and their queue, made on first use. */
|
|
1729
|
+
function approvalDeps(): {
|
|
1730
|
+
queue: ApprovalQueue;
|
|
1731
|
+
actions: HeldActions;
|
|
1732
|
+
enabled: () => boolean;
|
|
1733
|
+
} {
|
|
1734
|
+
if (approvals) return approvals;
|
|
1735
|
+
const actions = heldActions({
|
|
1736
|
+
rules: () => processPathRules(getStateDir()),
|
|
1737
|
+
validate: (m) => validateManifest(m),
|
|
1738
|
+
workspaceCreateRefusal,
|
|
1739
|
+
createWorkspace: (name) => createWorkspace(name, { services: {} }),
|
|
1740
|
+
workspaceExists: (name) =>
|
|
1741
|
+
Object.prototype.hasOwnProperty.call(
|
|
1742
|
+
readWorkspacesFile().workspaces ?? {},
|
|
1743
|
+
name,
|
|
1744
|
+
),
|
|
1745
|
+
defaultWorkspace: () => getFocusedWorkspaceName() ?? "default",
|
|
1746
|
+
// The exec route's checks for a local `up` (R13 placement, HITL).
|
|
1747
|
+
serviceTarget: (service, ws) => {
|
|
1748
|
+
if (ws && !isWorkspaceActive(ws))
|
|
1749
|
+
return {
|
|
1750
|
+
ok: false,
|
|
1751
|
+
error: `Workspace "${ws}" is not active — activate it first`,
|
|
1752
|
+
};
|
|
1753
|
+
const resolved = resolveService(service, ws);
|
|
1754
|
+
if (!resolved)
|
|
1755
|
+
return {
|
|
1756
|
+
ok: false,
|
|
1757
|
+
error: `Den doesn't know a service "${service}"${ws ? ` in workspace "${ws}"` : ""}; the services tool lists what it runs`,
|
|
1758
|
+
};
|
|
1759
|
+
const manifest = readManifest(resolved.repoPath);
|
|
1760
|
+
const refusal = placementRefusal(
|
|
1761
|
+
resolved.address,
|
|
1762
|
+
servicePlacement(manifest, resolved.subsystem, "local"),
|
|
1763
|
+
(e) => isProductionEnv(e),
|
|
1764
|
+
"up",
|
|
1765
|
+
);
|
|
1766
|
+
if (refusal) return { ok: false, error: refusal };
|
|
1767
|
+
const def = manifest
|
|
1768
|
+
? declaredCommand(
|
|
1769
|
+
manifest.services?.[resolved.subsystem]?.commands,
|
|
1770
|
+
"local",
|
|
1771
|
+
"up",
|
|
1772
|
+
)
|
|
1773
|
+
: undefined;
|
|
1774
|
+
if (def && isInteractiveServiceCommand(def))
|
|
1775
|
+
return {
|
|
1776
|
+
ok: false,
|
|
1777
|
+
error: `"up" for ${resolved.address} needs an interactive terminal: the person runs \`${product().cli} up ${resolved.address}\``,
|
|
1778
|
+
};
|
|
1779
|
+
return { ok: true, address: resolved.address };
|
|
1780
|
+
},
|
|
1781
|
+
// `up` as /api/service/exec runs it, collected instead of streamed.
|
|
1782
|
+
tryService: async (address, ws) => {
|
|
1783
|
+
const resolved = resolveService(address, ws);
|
|
1784
|
+
if (!resolved)
|
|
1785
|
+
return {
|
|
1786
|
+
ok: false,
|
|
1787
|
+
exitCode: null,
|
|
1788
|
+
ready: false,
|
|
1789
|
+
lines: [`Den no longer knows a service "${address}"`],
|
|
1790
|
+
};
|
|
1791
|
+
prewarmSudo();
|
|
1792
|
+
return collectLifecycleExec(
|
|
1793
|
+
{
|
|
1794
|
+
resolved,
|
|
1795
|
+
cmd: "up",
|
|
1796
|
+
workspace: ws,
|
|
1797
|
+
env: { E2E_ENVIRONMENT: "local", ...getSudoEnv() },
|
|
1798
|
+
},
|
|
1799
|
+
{
|
|
1800
|
+
run: runServiceCommand,
|
|
1801
|
+
health: (r) => checkServiceHealth(r, "local"),
|
|
1802
|
+
secretEnv: (a, c, w) => prepareServiceSecretEnv(a, c, w),
|
|
1803
|
+
},
|
|
1804
|
+
);
|
|
1805
|
+
},
|
|
1806
|
+
});
|
|
1807
|
+
approvals = {
|
|
1808
|
+
actions,
|
|
1809
|
+
queue: createApprovalQueue(actions, () => getStateDir()),
|
|
1810
|
+
// Read per ask: a config edit takes effect without a restart. A bad
|
|
1811
|
+
// value throws, and the handler holds nothing then.
|
|
1812
|
+
enabled: () => readMcpConfig().approvals,
|
|
1813
|
+
};
|
|
1814
|
+
return approvals;
|
|
1815
|
+
}
|
|
1816
|
+
/** The agent ids with a held write waiting (their rows say "waiting"). */
|
|
1817
|
+
function waitingAgentIds(): Set<string> {
|
|
1818
|
+
const out = new Set<string>();
|
|
1819
|
+
if (!approvals) return out;
|
|
1820
|
+
for (const actor of approvals.queue.waitingActors())
|
|
1821
|
+
if (actor.startsWith("agent:")) out.add(actor.slice("agent:".length));
|
|
1822
|
+
return out;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1609
1825
|
/** A test module's name as a run takes it (SEC-1 §6b.2): never a path out. */
|
|
1610
1826
|
const SAFE_MODULE_RE = /^[A-Za-z0-9_@][A-Za-z0-9._@/-]{0,127}$/;
|
|
1611
1827
|
|
|
@@ -1742,6 +1958,40 @@ async function handleRequest(
|
|
|
1742
1958
|
// is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
|
|
1743
1959
|
if (pathname.startsWith("/api/sessions/"))
|
|
1744
1960
|
return sendSessionHttp(req, res, pathname);
|
|
1961
|
+
// ON-3: an agent's held writes; Allow from the person's browser only.
|
|
1962
|
+
if (
|
|
1963
|
+
guard.ok &&
|
|
1964
|
+
isApprovalPath(pathname) &&
|
|
1965
|
+
(await handleApprovalRoutes(req, res, pathname, guard, approvalDeps()))
|
|
1966
|
+
)
|
|
1967
|
+
return;
|
|
1968
|
+
// 0.4.15: what lets another browser in needs the person, not just
|
|
1969
|
+
// loopback (approvals.ts: an agent with a shell is on loopback too).
|
|
1970
|
+
if (req.method === "POST" && PERSON_GRANT_ROUTES.includes(pathname)) {
|
|
1971
|
+
const g = guard.ok ? guard : null;
|
|
1972
|
+
const ok = g ? personGrantAllowed(req, g, printedCli()) : null;
|
|
1973
|
+
if (!ok?.ok) {
|
|
1974
|
+
recordAction(
|
|
1975
|
+
auditActorOf(req),
|
|
1976
|
+
{
|
|
1977
|
+
verb: "person-grant",
|
|
1978
|
+
detail: pathname,
|
|
1979
|
+
outcome: "refused" as const,
|
|
1980
|
+
reason: "signin_to_approve",
|
|
1981
|
+
},
|
|
1982
|
+
principalOf(req),
|
|
1983
|
+
);
|
|
1984
|
+
return sendJson(
|
|
1985
|
+
res,
|
|
1986
|
+
{
|
|
1987
|
+
ok: false,
|
|
1988
|
+
code: "signin_to_approve",
|
|
1989
|
+
error: ok && !ok.ok ? ok.error : "Sign in first.",
|
|
1990
|
+
},
|
|
1991
|
+
403,
|
|
1992
|
+
);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1745
1995
|
// SEC-2a: Sign in with GitHub, the owner's link, Pair a device. Before
|
|
1746
1996
|
// config validation, like the sign-in above.
|
|
1747
1997
|
if (
|
|
@@ -1784,7 +2034,10 @@ async function handleRequest(
|
|
|
1784
2034
|
},
|
|
1785
2035
|
commands: (r, code) => {
|
|
1786
2036
|
const approve = cliCommand(printedCli(), "web", "approve", code);
|
|
1787
|
-
return {
|
|
2037
|
+
return {
|
|
2038
|
+
approve,
|
|
2039
|
+
ssh: sshCommand(sshLogin(sshTargetOf(r), sshUserNow()), approve),
|
|
2040
|
+
};
|
|
1788
2041
|
},
|
|
1789
2042
|
audit: (r, verb, detail, refused) =>
|
|
1790
2043
|
recordAction(
|
|
@@ -1924,7 +2177,9 @@ async function handleRequest(
|
|
|
1924
2177
|
}
|
|
1925
2178
|
|
|
1926
2179
|
// ── Playwright report ──────────────────────────────────────────────────────
|
|
1927
|
-
|
|
2180
|
+
// A test run wrote it (an agent may have): sandboxed, never Den's origin.
|
|
2181
|
+
// `/report/…` only: `/reports/security/…` is ZAP's, below.
|
|
2182
|
+
if (pathname === "/report" || pathname.startsWith("/report/")) {
|
|
1928
2183
|
const relPath = pathname.slice("/report".length) || "/index.html";
|
|
1929
2184
|
const filePath = path.resolve(path.join(REPORT_DIR, relPath));
|
|
1930
2185
|
|
|
@@ -1945,7 +2200,7 @@ async function handleRequest(
|
|
|
1945
2200
|
? path.join(filePath, "index.html")
|
|
1946
2201
|
: filePath;
|
|
1947
2202
|
|
|
1948
|
-
return
|
|
2203
|
+
return serveUntrustedFile(res, target);
|
|
1949
2204
|
}
|
|
1950
2205
|
|
|
1951
2206
|
// ── API: environments ──────────────────────────────────────────────────────
|
|
@@ -2047,9 +2302,8 @@ async function handleRequest(
|
|
|
2047
2302
|
"Content-Type": file.endsWith(".html")
|
|
2048
2303
|
? "text/html; charset=utf-8"
|
|
2049
2304
|
: "application/json",
|
|
2050
|
-
"Cache-Control": "no-store",
|
|
2051
2305
|
// ZAP's report pulls its own scripts; keep it away from Den's origin data.
|
|
2052
|
-
|
|
2306
|
+
...UNTRUSTED_FILE_HEADERS,
|
|
2053
2307
|
});
|
|
2054
2308
|
return res.end(body);
|
|
2055
2309
|
} catch {
|
|
@@ -2097,6 +2351,21 @@ async function handleRequest(
|
|
|
2097
2351
|
// ── API: stacks, edge, security plan (read-only; see readonly-routes.ts) ──
|
|
2098
2352
|
if (await handleReadonlyRoutes(req, res, pathname, url)) return;
|
|
2099
2353
|
|
|
2354
|
+
// ── API: onboarding reads (ON-3; see onboard-routes.ts) ──────────────────
|
|
2355
|
+
if (
|
|
2356
|
+
await handleOnboardRoutes(
|
|
2357
|
+
req,
|
|
2358
|
+
res,
|
|
2359
|
+
pathname,
|
|
2360
|
+
url,
|
|
2361
|
+
(onboardDeps ??= defaultOnboardRouteDeps(
|
|
2362
|
+
(m) => validateManifest(m),
|
|
2363
|
+
ENGINE.loaded.version ?? engineVersion() ?? "unknown",
|
|
2364
|
+
)),
|
|
2365
|
+
)
|
|
2366
|
+
)
|
|
2367
|
+
return;
|
|
2368
|
+
|
|
2100
2369
|
// ── API: test sources ─────────────────────────────────────────────────────
|
|
2101
2370
|
// Returns the repos that contribute tests for a given pillar, with their
|
|
2102
2371
|
// current branch + short SHA. Lets the Tests UI show "you are testing
|
|
@@ -4216,6 +4485,7 @@ async function handleRequest(
|
|
|
4216
4485
|
res,
|
|
4217
4486
|
pathname,
|
|
4218
4487
|
guard.ok && (!guard.beyond || !!guard.session || !!guard.principal),
|
|
4488
|
+
{ waitingAgents: waitingAgentIds },
|
|
4219
4489
|
)
|
|
4220
4490
|
)
|
|
4221
4491
|
return;
|
|
@@ -209,3 +209,73 @@ export async function streamLifecycleExec(
|
|
|
209
209
|
setTimeout(() => void tick(), pollMs);
|
|
210
210
|
}
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/** What a collected run said: how it ended and the end of its output. */
|
|
214
|
+
export interface CollectedExec {
|
|
215
|
+
ok: boolean;
|
|
216
|
+
/** The command's exit code; null while a detached start still runs. */
|
|
217
|
+
exitCode: number | null;
|
|
218
|
+
/** Started and healthy (or, with no health check, still running). */
|
|
219
|
+
ready: boolean;
|
|
220
|
+
lines: string[];
|
|
221
|
+
error?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Runs a lifecycle command as the route does, collecting its events
|
|
226
|
+
* instead of streaming them (ON-3's service_try): done at `ready`, at the
|
|
227
|
+
* launcher's exit (`service-exited`) or at `done`, whichever comes first,
|
|
228
|
+
* as service_up's stream stops. Then it lets go, as a closed stream does:
|
|
229
|
+
* a detached service keeps running; only the log tail stops.
|
|
230
|
+
*/
|
|
231
|
+
export function collectLifecycleExec(
|
|
232
|
+
o: Omit<LifecycleExecOptions, "emit" | "end" | "onClientClose">,
|
|
233
|
+
deps: LifecycleExecDeps,
|
|
234
|
+
keep = 30,
|
|
235
|
+
): Promise<CollectedExec> {
|
|
236
|
+
return new Promise((resolve) => {
|
|
237
|
+
const lines: string[] = [];
|
|
238
|
+
const errors: string[] = [];
|
|
239
|
+
let closer: (() => void) | null = null;
|
|
240
|
+
let settled = false;
|
|
241
|
+
const settle = (r: Omit<CollectedExec, "lines" | "error">): void => {
|
|
242
|
+
if (settled) return;
|
|
243
|
+
settled = true;
|
|
244
|
+
closer?.();
|
|
245
|
+
resolve({
|
|
246
|
+
...r,
|
|
247
|
+
lines: lines.slice(-keep),
|
|
248
|
+
...(errors.length ? { error: errors.join("\n") } : {}),
|
|
249
|
+
});
|
|
250
|
+
};
|
|
251
|
+
void streamLifecycleExec(
|
|
252
|
+
{
|
|
253
|
+
...o,
|
|
254
|
+
emit: (e) => {
|
|
255
|
+
if (e.type === "line" && typeof e.text === "string")
|
|
256
|
+
lines.push(e.text);
|
|
257
|
+
else if (e.type === "error" && typeof e.text === "string")
|
|
258
|
+
errors.push(e.text);
|
|
259
|
+
else if (e.type === "ready")
|
|
260
|
+
settle({ ok: true, exitCode: null, ready: true });
|
|
261
|
+
else if (e.type === "service-exited") {
|
|
262
|
+
const code = Number(e.exitCode);
|
|
263
|
+
settle({ ok: code === 0, exitCode: code, ready: false });
|
|
264
|
+
} else if (e.type === "done") {
|
|
265
|
+
const code = Number(e.exitCode);
|
|
266
|
+
settle({ ok: code === 0, exitCode: code, ready: false });
|
|
267
|
+
}
|
|
268
|
+
if (lines.length > keep * 4) lines.splice(0, lines.length - keep);
|
|
269
|
+
},
|
|
270
|
+
end: () => undefined,
|
|
271
|
+
onClientClose: (fn) => {
|
|
272
|
+
closer = fn;
|
|
273
|
+
},
|
|
274
|
+
},
|
|
275
|
+
deps,
|
|
276
|
+
).catch((e: unknown) => {
|
|
277
|
+
errors.push(String((e as Error)?.message ?? e));
|
|
278
|
+
settle({ ok: false, exitCode: 1, ready: false });
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ON-3's read routes (GET only; reads in route-classes.ts):
|
|
3
|
+
*
|
|
4
|
+
* /api/onboard/status what's set up and the one next step
|
|
5
|
+
* (onboard-reads.ts onboardStatus)
|
|
6
|
+
* /api/onboard/repos ?path=<folder>[&max=<n>]: the git repos in a
|
|
7
|
+
* folder, how each runs, suggested workspaces
|
|
8
|
+
* /api/onboard/manifest ?path=<repo>[&name&run&down&health]: the side-car
|
|
9
|
+
* manifest `repo add` would write, validated, and
|
|
10
|
+
* nothing written
|
|
11
|
+
*
|
|
12
|
+
* The MCP tools onboard_status, repos_detect and manifest_draft read these,
|
|
13
|
+
* and a page can show them as they are. A path outside the person's home
|
|
14
|
+
* folder, or in Den's state folder, is a 400 with the reason.
|
|
15
|
+
*/
|
|
16
|
+
import * as http from "http";
|
|
17
|
+
import { AUDIT_HEADERS } from "../../shared/agent-actor.js";
|
|
18
|
+
import { getStateDir } from "../../shared/paths.js";
|
|
19
|
+
import { readWebPort } from "../../shared/edge/config.js";
|
|
20
|
+
import { DEFAULT_PORT } from "../../shared/constants.js";
|
|
21
|
+
import { sendJson } from "./http-helpers.js";
|
|
22
|
+
import {
|
|
23
|
+
checkOnboardPath,
|
|
24
|
+
manifestDraft,
|
|
25
|
+
onboardStatus,
|
|
26
|
+
processPathRules,
|
|
27
|
+
reposDetect,
|
|
28
|
+
type OnboardStatus,
|
|
29
|
+
type PathRules,
|
|
30
|
+
type RegisteredRepo,
|
|
31
|
+
} from "../cli/onboard-reads.js";
|
|
32
|
+
import { defaultStatusDeps, registeredRepos } from "../cli/onboard-command.js";
|
|
33
|
+
import type { ManifestValidator } from "../cli/repo-command.js";
|
|
34
|
+
import { runnableCli } from "../cli/doctor-onboard.js";
|
|
35
|
+
|
|
36
|
+
export interface OnboardRouteDeps {
|
|
37
|
+
rules: () => PathRules;
|
|
38
|
+
validate: ManifestValidator;
|
|
39
|
+
registered: () => RegisteredRepo[];
|
|
40
|
+
bin: () => string;
|
|
41
|
+
status: (o: { viaMcp: boolean }) => Promise<OnboardStatus>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The web server's own deps; `validate` comes from its entry (index.ts). */
|
|
45
|
+
export function defaultOnboardRouteDeps(
|
|
46
|
+
validate: ManifestValidator,
|
|
47
|
+
version: string,
|
|
48
|
+
): OnboardRouteDeps {
|
|
49
|
+
return {
|
|
50
|
+
rules: () => processPathRules(getStateDir()),
|
|
51
|
+
validate,
|
|
52
|
+
registered: registeredRepos,
|
|
53
|
+
bin: runnableCli,
|
|
54
|
+
status: async ({ viaMcp }) => {
|
|
55
|
+
let port: number;
|
|
56
|
+
try {
|
|
57
|
+
port = readWebPort();
|
|
58
|
+
} catch {
|
|
59
|
+
port = DEFAULT_PORT;
|
|
60
|
+
}
|
|
61
|
+
// This server is the web server asked about: it is answering.
|
|
62
|
+
return onboardStatus(
|
|
63
|
+
await defaultStatusDeps({
|
|
64
|
+
webUrl: `http://localhost:${port}`,
|
|
65
|
+
probeWeb: async () => ({ kind: "up", version }),
|
|
66
|
+
viaMcp,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const param = (url: URL, k: string): string | undefined =>
|
|
74
|
+
url.searchParams.get(k) ?? undefined;
|
|
75
|
+
|
|
76
|
+
/** Handles the routes above; false when `pathname` is not one of them. */
|
|
77
|
+
export async function handleOnboardRoutes(
|
|
78
|
+
req: http.IncomingMessage,
|
|
79
|
+
res: http.ServerResponse,
|
|
80
|
+
pathname: string,
|
|
81
|
+
url: URL,
|
|
82
|
+
deps: OnboardRouteDeps,
|
|
83
|
+
): Promise<boolean> {
|
|
84
|
+
if (
|
|
85
|
+
pathname !== "/api/onboard/status" &&
|
|
86
|
+
pathname !== "/api/onboard/repos" &&
|
|
87
|
+
pathname !== "/api/onboard/manifest"
|
|
88
|
+
)
|
|
89
|
+
return false;
|
|
90
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
91
|
+
res.writeHead(405, { Allow: "GET" });
|
|
92
|
+
res.end("read-only");
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
if (pathname === "/api/onboard/status") {
|
|
97
|
+
const viaMcp = req.headers[AUDIT_HEADERS.actor] === "agent";
|
|
98
|
+
sendJson(res, await deps.status({ viaMcp }));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
const rules = deps.rules();
|
|
102
|
+
const check = checkOnboardPath(param(url, "path"), rules);
|
|
103
|
+
if (!check.ok) {
|
|
104
|
+
sendJson(res, { ok: false, error: check.error }, 400);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
if (pathname === "/api/onboard/repos") {
|
|
108
|
+
const max = Number(param(url, "max"));
|
|
109
|
+
sendJson(
|
|
110
|
+
res,
|
|
111
|
+
reposDetect(check.dir, rules, {
|
|
112
|
+
registered: deps.registered,
|
|
113
|
+
bin: deps.bin(),
|
|
114
|
+
...(Number.isInteger(max) && max > 0 ? { maxRepos: max } : {}),
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
sendJson(
|
|
120
|
+
res,
|
|
121
|
+
manifestDraft(check.dir, rules, deps.validate, {
|
|
122
|
+
name: param(url, "name"),
|
|
123
|
+
run: param(url, "run"),
|
|
124
|
+
down: param(url, "down"),
|
|
125
|
+
health: param(url, "health"),
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
sendJson(res, { ok: false, error: (e as Error).message }, 500);
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|