@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
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,14 +233,18 @@ 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,
|
|
235
245
|
githubEndpoints,
|
|
236
246
|
noClientIdMessage,
|
|
247
|
+
GITHUB_SIGNIN_DOCS,
|
|
237
248
|
} from "../../shared/github-app.js";
|
|
238
249
|
import { githubDeviceClient } from "../../shared/github-device.js";
|
|
239
250
|
import { readWebOwner } from "../../shared/web-owner.js";
|
|
@@ -253,7 +264,9 @@ import {
|
|
|
253
264
|
machineLabel,
|
|
254
265
|
parseMachineName,
|
|
255
266
|
sshCommand,
|
|
267
|
+
sshLogin,
|
|
256
268
|
sshTarget,
|
|
269
|
+
sshUser,
|
|
257
270
|
} from "../../shared/cli-command.js";
|
|
258
271
|
import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
|
|
259
272
|
import { agentsLayout, patchAgentsLayout } from "./agents-layout-pref.js";
|
|
@@ -612,6 +625,9 @@ setHtmlFill((html) => {
|
|
|
612
625
|
|
|
613
626
|
// ── MD-2: the engine this server loaded, against the one on disk ─────────────
|
|
614
627
|
const ENGINE = engineWatch(getEngineRoot);
|
|
628
|
+
|
|
629
|
+
/** The onboarding routes' deps, made on first use. */
|
|
630
|
+
let onboardDeps: OnboardRouteDeps | null = null;
|
|
615
631
|
function engineCheck(): EngineCheck {
|
|
616
632
|
return ENGINE.check();
|
|
617
633
|
}
|
|
@@ -789,6 +805,8 @@ import {
|
|
|
789
805
|
readBody,
|
|
790
806
|
sendJson,
|
|
791
807
|
serveFile,
|
|
808
|
+
serveUntrustedFile,
|
|
809
|
+
UNTRUSTED_FILE_HEADERS,
|
|
792
810
|
send400,
|
|
793
811
|
send404,
|
|
794
812
|
setHtmlFill,
|
|
@@ -824,7 +842,19 @@ import { parseChangelog } from "./changelog-parser.js";
|
|
|
824
842
|
// ── Extracted services ───────────────────────────────────────────────────────
|
|
825
843
|
import { GitHubService } from "./github-service.js";
|
|
826
844
|
import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
|
|
827
|
-
import {
|
|
845
|
+
import {
|
|
846
|
+
handleWorkspaceRoutes,
|
|
847
|
+
workspaceCreateRefusal,
|
|
848
|
+
} from "./workspace-routes.js";
|
|
849
|
+
import {
|
|
850
|
+
createApprovalQueue,
|
|
851
|
+
handleApprovalRoutes,
|
|
852
|
+
isApprovalPath,
|
|
853
|
+
type HeldActions,
|
|
854
|
+
} from "./approval-routes.js";
|
|
855
|
+
import { heldActions } from "./held-actions.js";
|
|
856
|
+
import type { ApprovalQueue } from "./approvals.js";
|
|
857
|
+
import { processPathRules } from "../cli/onboard-reads.js";
|
|
828
858
|
import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
|
|
829
859
|
import {
|
|
830
860
|
handleSessionList,
|
|
@@ -833,7 +863,11 @@ import {
|
|
|
833
863
|
sendSessionHttp,
|
|
834
864
|
} from "./session-routes.js";
|
|
835
865
|
import { refuseUpgrade } from "./websocket.js";
|
|
836
|
-
import {
|
|
866
|
+
import {
|
|
867
|
+
collectLifecycleExec,
|
|
868
|
+
isLifecycleExecCmd,
|
|
869
|
+
streamLifecycleExec,
|
|
870
|
+
} from "./lifecycle-exec.js";
|
|
837
871
|
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
838
872
|
import {
|
|
839
873
|
edgeHttps,
|
|
@@ -1219,6 +1253,26 @@ function loggedPath(pathname: string): string {
|
|
|
1219
1253
|
);
|
|
1220
1254
|
}
|
|
1221
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* The account the signed-out page's ssh link logs in as (SEC-2a-UX):
|
|
1258
|
+
* `web.ssh_user` (read now), else the account Den runs as; "" for none.
|
|
1259
|
+
*/
|
|
1260
|
+
function sshUserNow(): string {
|
|
1261
|
+
let set: string | undefined;
|
|
1262
|
+
try {
|
|
1263
|
+
set = readWebAuthConfig(getDistRoot(), product().configFile).sshUser;
|
|
1264
|
+
} catch {
|
|
1265
|
+
set = WEB_AUTH.sshUser;
|
|
1266
|
+
}
|
|
1267
|
+
let osUser = "";
|
|
1268
|
+
try {
|
|
1269
|
+
osUser = os.userInfo().username;
|
|
1270
|
+
} catch {
|
|
1271
|
+
/* no account name: none on the page */
|
|
1272
|
+
}
|
|
1273
|
+
return sshUser(set, osUser);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1222
1276
|
/** The ssh target for this request: the Host it used, minus the port. */
|
|
1223
1277
|
function sshTargetOf(req: http.IncomingMessage): string {
|
|
1224
1278
|
return sshTarget(req.headers.host, os.hostname());
|
|
@@ -1245,22 +1299,28 @@ function closeSessionSockets(keys: Iterable<string>): void {
|
|
|
1245
1299
|
}
|
|
1246
1300
|
}
|
|
1247
1301
|
|
|
1248
|
-
/**
|
|
1302
|
+
/**
|
|
1303
|
+
* A new session for this request: its Set-Cookie line, and its approval
|
|
1304
|
+
* key (web-session.ts), which only this sign-in's own answer may carry.
|
|
1305
|
+
*/
|
|
1249
1306
|
function startSession(
|
|
1250
1307
|
req: http.IncomingMessage,
|
|
1251
|
-
via: "github" | "link" | "code",
|
|
1308
|
+
via: "github" | "link" | "code" | "claim",
|
|
1252
1309
|
login?: string,
|
|
1253
|
-
): string {
|
|
1310
|
+
): { cookie: string; approveKey: string } {
|
|
1254
1311
|
const g = checkRequest(req, GUARD, product().cli, product().title);
|
|
1255
1312
|
const named = g.ok ? g.via : null;
|
|
1256
|
-
const { id } = webSessions.create({
|
|
1313
|
+
const { id, approveKey } = webSessions.create({
|
|
1257
1314
|
userAgent: req.headers["user-agent"],
|
|
1258
1315
|
host: req.headers.host,
|
|
1259
1316
|
via,
|
|
1260
1317
|
...(g.ok && g.client ? { client: g.client } : {}),
|
|
1261
1318
|
...(login ? { login } : {}),
|
|
1262
1319
|
});
|
|
1263
|
-
return
|
|
1320
|
+
return {
|
|
1321
|
+
cookie: sessionSetCookie(id, requestIsHttps(req, named)),
|
|
1322
|
+
approveKey,
|
|
1323
|
+
};
|
|
1264
1324
|
}
|
|
1265
1325
|
|
|
1266
1326
|
/** The edge hostnames that route here, and their URLs (Pair a device). */
|
|
@@ -1272,10 +1332,19 @@ function denEdgeRoutes(): { host: string; url: string }[] {
|
|
|
1272
1332
|
}
|
|
1273
1333
|
}
|
|
1274
1334
|
|
|
1275
|
-
/**
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1335
|
+
/**
|
|
1336
|
+
* What the signed-out page says about Sign in with GitHub: its state, and
|
|
1337
|
+
* (SEC-2a-UX) the owner's login once one is linked, for step 1's "Owner:
|
|
1338
|
+
* @login".
|
|
1339
|
+
*/
|
|
1340
|
+
function githubSignin():
|
|
1341
|
+
| { state: "no_client_id" | "no_owner" }
|
|
1342
|
+
| { state: "ready"; ownerLogin: string } {
|
|
1343
|
+
if (!GITHUB_CLIENT_ID) return { state: "no_client_id" };
|
|
1344
|
+
const owner = readWebOwner(getStateDir());
|
|
1345
|
+
return owner
|
|
1346
|
+
? { state: "ready", ownerLogin: owner.login }
|
|
1347
|
+
: { state: "no_owner" };
|
|
1279
1348
|
}
|
|
1280
1349
|
|
|
1281
1350
|
/**
|
|
@@ -1366,6 +1435,8 @@ function sendSignedOut(
|
|
|
1366
1435
|
toLocal || host === `localhost:${PORT}` ? [] : ["--origin", origin];
|
|
1367
1436
|
const cmd = cliCommand(bin, "web", "login", ...loginArgs);
|
|
1368
1437
|
const ssh = sshTargetOf(req);
|
|
1438
|
+
const user = sshUserNow();
|
|
1439
|
+
const gh = githubSignin();
|
|
1369
1440
|
res.setHeader("Cache-Control", "no-store");
|
|
1370
1441
|
sendErrorPage(req, res, {
|
|
1371
1442
|
status: 401,
|
|
@@ -1381,14 +1452,18 @@ function sendSignedOut(
|
|
|
1381
1452
|
machine: machineName(),
|
|
1382
1453
|
ssh: {
|
|
1383
1454
|
target: ssh,
|
|
1455
|
+
// SEC-2a-UX: the account to log in as ("" for none).
|
|
1456
|
+
user,
|
|
1384
1457
|
// Open Terminal (ssh://) only for a name, never an IP literal.
|
|
1385
1458
|
name: !isIpLiteral(ssh),
|
|
1386
|
-
login: sshCommand(ssh, cmd),
|
|
1459
|
+
login: sshCommand(sshLogin(ssh, user), cmd),
|
|
1387
1460
|
},
|
|
1388
1461
|
// SEC-2a: Sign in with GitHub, and why it can't be offered yet.
|
|
1389
1462
|
github: {
|
|
1390
|
-
state:
|
|
1463
|
+
state: gh.state,
|
|
1391
1464
|
owner: cliCommand(bin, "web", "owner", "link"),
|
|
1465
|
+
...(gh.state === "ready" ? { ownerLogin: gh.ownerLogin } : {}),
|
|
1466
|
+
learnMore: GITHUB_SIGNIN_DOCS,
|
|
1392
1467
|
...(GITHUB_CLIENT_ID
|
|
1393
1468
|
? {}
|
|
1394
1469
|
: { message: noClientIdMessage(product().cli) }),
|
|
@@ -1475,6 +1550,8 @@ async function handleAuth(
|
|
|
1475
1550
|
url: URL,
|
|
1476
1551
|
requestId: string,
|
|
1477
1552
|
): Promise<void> {
|
|
1553
|
+
if (url.searchParams.has("claim"))
|
|
1554
|
+
return handleClaim(req, res, url, requestId);
|
|
1478
1555
|
const secret = webSecret.current();
|
|
1479
1556
|
const c = url.searchParams.get("c") ?? "";
|
|
1480
1557
|
const nonce = loginCodeNonce(c);
|
|
@@ -1566,25 +1643,74 @@ async function handleAuth(
|
|
|
1566
1643
|
device: deviceName(req.headers["user-agent"]),
|
|
1567
1644
|
...(g.ok && g.client ? { client: g.client } : {}),
|
|
1568
1645
|
});
|
|
1569
|
-
|
|
1646
|
+
const s = startSession(req, "link");
|
|
1647
|
+
res.setHeader("Set-Cookie", s.cookie);
|
|
1648
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1570
1649
|
return sendJson(res, {
|
|
1571
1650
|
ok: true,
|
|
1572
1651
|
next: safeNext(url.searchParams.get("next")),
|
|
1652
|
+
approveKey: s.approveKey,
|
|
1573
1653
|
});
|
|
1574
1654
|
}
|
|
1575
1655
|
// A link nobody waits on (`web login --no-wait`, or minted with no
|
|
1576
1656
|
// server running): it signs in when opened, once, as before.
|
|
1577
1657
|
if (post || !webSessions.consumeCode(check.nonce, check.expiresAt))
|
|
1578
1658
|
return gone();
|
|
1659
|
+
const s = startSession(req, "link");
|
|
1579
1660
|
res.writeHead(303, {
|
|
1580
|
-
|
|
1581
|
-
|
|
1661
|
+
// The approval key in the fragment: it never reaches a server.
|
|
1662
|
+
Location: withApproveKey(
|
|
1663
|
+
safeNext(url.searchParams.get("next")),
|
|
1664
|
+
s.approveKey,
|
|
1665
|
+
),
|
|
1666
|
+
"Set-Cookie": s.cookie,
|
|
1582
1667
|
"Cache-Control": "no-store",
|
|
1583
1668
|
"Referrer-Policy": "no-referrer",
|
|
1584
1669
|
});
|
|
1585
1670
|
res.end();
|
|
1586
1671
|
}
|
|
1587
1672
|
|
|
1673
|
+
/**
|
|
1674
|
+
* `GET /auth?claim=<code>` (0.4.15): the setup link `<cli> web claim-link`
|
|
1675
|
+
* prints at the end of an install. A valid, unused claim code (30 min,
|
|
1676
|
+
* single use across restarts) becomes a session ("claimed with a setup
|
|
1677
|
+
* link" in Linked devices), then a 303 to `/` with the session's approval
|
|
1678
|
+
* key in the fragment and no claim in the address, no referrer. Anything
|
|
1679
|
+
* else: the signed-out page, which says the link was used and never
|
|
1680
|
+
* echoes the code. An agent's token never gets here (AGENT_FORBIDDEN).
|
|
1681
|
+
*/
|
|
1682
|
+
const CLAIM_NONCES = new ClaimNonces(getStateDir());
|
|
1683
|
+
function handleClaim(
|
|
1684
|
+
req: http.IncomingMessage,
|
|
1685
|
+
res: http.ServerResponse,
|
|
1686
|
+
url: URL,
|
|
1687
|
+
requestId: string,
|
|
1688
|
+
): void {
|
|
1689
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1690
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1691
|
+
const secret = webSecret.current();
|
|
1692
|
+
const check =
|
|
1693
|
+
req.method !== "POST" && secret
|
|
1694
|
+
? checkClaimCode(url.searchParams.get("claim") ?? "", secret)
|
|
1695
|
+
: null;
|
|
1696
|
+
if (!check?.ok || !CLAIM_NONCES.consume(check.nonce, check.expiresAt))
|
|
1697
|
+
return sendSignedOut(req, res, {
|
|
1698
|
+
pathname: "/auth",
|
|
1699
|
+
search: "",
|
|
1700
|
+
scope: "den",
|
|
1701
|
+
expired: true,
|
|
1702
|
+
requestId,
|
|
1703
|
+
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.`,
|
|
1704
|
+
});
|
|
1705
|
+
const s = startSession(req, "claim");
|
|
1706
|
+
res.writeHead(303, {
|
|
1707
|
+
// The approval key in the fragment: it never reaches a server.
|
|
1708
|
+
Location: withApproveKey("/", s.approveKey),
|
|
1709
|
+
"Set-Cookie": s.cookie,
|
|
1710
|
+
});
|
|
1711
|
+
res.end();
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1588
1714
|
/** `web login`'s wait (login-waits.ts), by the CLI's id. */
|
|
1589
1715
|
const LOGIN_WAIT_PATH = /^\/api\/web\/logins\/([A-Za-z0-9_-]{16,64})$/;
|
|
1590
1716
|
const LOGIN_WAITS = new LoginWaits();
|
|
@@ -1606,6 +1732,109 @@ function declaredCommand<T>(
|
|
|
1606
1732
|
return own(forEnv, cmd) ? forEnv![cmd] : undefined;
|
|
1607
1733
|
}
|
|
1608
1734
|
|
|
1735
|
+
// ── ON-3: the writes held for the person (approval-routes.ts) ─────────────────
|
|
1736
|
+
let approvals: {
|
|
1737
|
+
queue: ApprovalQueue;
|
|
1738
|
+
actions: HeldActions;
|
|
1739
|
+
enabled: () => boolean;
|
|
1740
|
+
} | null = null;
|
|
1741
|
+
/** The held writes and their queue, made on first use. */
|
|
1742
|
+
function approvalDeps(): {
|
|
1743
|
+
queue: ApprovalQueue;
|
|
1744
|
+
actions: HeldActions;
|
|
1745
|
+
enabled: () => boolean;
|
|
1746
|
+
} {
|
|
1747
|
+
if (approvals) return approvals;
|
|
1748
|
+
const actions = heldActions({
|
|
1749
|
+
rules: () => processPathRules(getStateDir()),
|
|
1750
|
+
validate: (m) => validateManifest(m),
|
|
1751
|
+
workspaceCreateRefusal,
|
|
1752
|
+
createWorkspace: (name) => createWorkspace(name, { services: {} }),
|
|
1753
|
+
workspaceExists: (name) =>
|
|
1754
|
+
Object.prototype.hasOwnProperty.call(
|
|
1755
|
+
readWorkspacesFile().workspaces ?? {},
|
|
1756
|
+
name,
|
|
1757
|
+
),
|
|
1758
|
+
defaultWorkspace: () => getFocusedWorkspaceName() ?? "default",
|
|
1759
|
+
// The exec route's checks for a local `up` (R13 placement, HITL).
|
|
1760
|
+
serviceTarget: (service, ws) => {
|
|
1761
|
+
if (ws && !isWorkspaceActive(ws))
|
|
1762
|
+
return {
|
|
1763
|
+
ok: false,
|
|
1764
|
+
error: `Workspace "${ws}" is not active — activate it first`,
|
|
1765
|
+
};
|
|
1766
|
+
const resolved = resolveService(service, ws);
|
|
1767
|
+
if (!resolved)
|
|
1768
|
+
return {
|
|
1769
|
+
ok: false,
|
|
1770
|
+
error: `Den doesn't know a service "${service}"${ws ? ` in workspace "${ws}"` : ""}; the services tool lists what it runs`,
|
|
1771
|
+
};
|
|
1772
|
+
const manifest = readManifest(resolved.repoPath);
|
|
1773
|
+
const refusal = placementRefusal(
|
|
1774
|
+
resolved.address,
|
|
1775
|
+
servicePlacement(manifest, resolved.subsystem, "local"),
|
|
1776
|
+
(e) => isProductionEnv(e),
|
|
1777
|
+
"up",
|
|
1778
|
+
);
|
|
1779
|
+
if (refusal) return { ok: false, error: refusal };
|
|
1780
|
+
const def = manifest
|
|
1781
|
+
? declaredCommand(
|
|
1782
|
+
manifest.services?.[resolved.subsystem]?.commands,
|
|
1783
|
+
"local",
|
|
1784
|
+
"up",
|
|
1785
|
+
)
|
|
1786
|
+
: undefined;
|
|
1787
|
+
if (def && isInteractiveServiceCommand(def))
|
|
1788
|
+
return {
|
|
1789
|
+
ok: false,
|
|
1790
|
+
error: `"up" for ${resolved.address} needs an interactive terminal: the person runs \`${product().cli} up ${resolved.address}\``,
|
|
1791
|
+
};
|
|
1792
|
+
return { ok: true, address: resolved.address };
|
|
1793
|
+
},
|
|
1794
|
+
// `up` as /api/service/exec runs it, collected instead of streamed.
|
|
1795
|
+
tryService: async (address, ws) => {
|
|
1796
|
+
const resolved = resolveService(address, ws);
|
|
1797
|
+
if (!resolved)
|
|
1798
|
+
return {
|
|
1799
|
+
ok: false,
|
|
1800
|
+
exitCode: null,
|
|
1801
|
+
ready: false,
|
|
1802
|
+
lines: [`Den no longer knows a service "${address}"`],
|
|
1803
|
+
};
|
|
1804
|
+
prewarmSudo();
|
|
1805
|
+
return collectLifecycleExec(
|
|
1806
|
+
{
|
|
1807
|
+
resolved,
|
|
1808
|
+
cmd: "up",
|
|
1809
|
+
workspace: ws,
|
|
1810
|
+
env: { E2E_ENVIRONMENT: "local", ...getSudoEnv() },
|
|
1811
|
+
},
|
|
1812
|
+
{
|
|
1813
|
+
run: runServiceCommand,
|
|
1814
|
+
health: (r) => checkServiceHealth(r, "local"),
|
|
1815
|
+
secretEnv: (a, c, w) => prepareServiceSecretEnv(a, c, w),
|
|
1816
|
+
},
|
|
1817
|
+
);
|
|
1818
|
+
},
|
|
1819
|
+
});
|
|
1820
|
+
approvals = {
|
|
1821
|
+
actions,
|
|
1822
|
+
queue: createApprovalQueue(actions, () => getStateDir()),
|
|
1823
|
+
// Read per ask: a config edit takes effect without a restart. A bad
|
|
1824
|
+
// value throws, and the handler holds nothing then.
|
|
1825
|
+
enabled: () => readMcpConfig().approvals,
|
|
1826
|
+
};
|
|
1827
|
+
return approvals;
|
|
1828
|
+
}
|
|
1829
|
+
/** The agent ids with a held write waiting (their rows say "waiting"). */
|
|
1830
|
+
function waitingAgentIds(): Set<string> {
|
|
1831
|
+
const out = new Set<string>();
|
|
1832
|
+
if (!approvals) return out;
|
|
1833
|
+
for (const actor of approvals.queue.waitingActors())
|
|
1834
|
+
if (actor.startsWith("agent:")) out.add(actor.slice("agent:".length));
|
|
1835
|
+
return out;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1609
1838
|
/** A test module's name as a run takes it (SEC-1 §6b.2): never a path out. */
|
|
1610
1839
|
const SAFE_MODULE_RE = /^[A-Za-z0-9_@][A-Za-z0-9._@/-]{0,127}$/;
|
|
1611
1840
|
|
|
@@ -1742,6 +1971,40 @@ async function handleRequest(
|
|
|
1742
1971
|
// is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
|
|
1743
1972
|
if (pathname.startsWith("/api/sessions/"))
|
|
1744
1973
|
return sendSessionHttp(req, res, pathname);
|
|
1974
|
+
// ON-3: an agent's held writes; Allow from the person's browser only.
|
|
1975
|
+
if (
|
|
1976
|
+
guard.ok &&
|
|
1977
|
+
isApprovalPath(pathname) &&
|
|
1978
|
+
(await handleApprovalRoutes(req, res, pathname, guard, approvalDeps()))
|
|
1979
|
+
)
|
|
1980
|
+
return;
|
|
1981
|
+
// 0.4.15: what lets another browser in needs the person, not just
|
|
1982
|
+
// loopback (approvals.ts: an agent with a shell is on loopback too).
|
|
1983
|
+
if (req.method === "POST" && PERSON_GRANT_ROUTES.includes(pathname)) {
|
|
1984
|
+
const g = guard.ok ? guard : null;
|
|
1985
|
+
const ok = g ? personGrantAllowed(req, g, printedCli()) : null;
|
|
1986
|
+
if (!ok?.ok) {
|
|
1987
|
+
recordAction(
|
|
1988
|
+
auditActorOf(req),
|
|
1989
|
+
{
|
|
1990
|
+
verb: "person-grant",
|
|
1991
|
+
detail: pathname,
|
|
1992
|
+
outcome: "refused" as const,
|
|
1993
|
+
reason: "signin_to_approve",
|
|
1994
|
+
},
|
|
1995
|
+
principalOf(req),
|
|
1996
|
+
);
|
|
1997
|
+
return sendJson(
|
|
1998
|
+
res,
|
|
1999
|
+
{
|
|
2000
|
+
ok: false,
|
|
2001
|
+
code: "signin_to_approve",
|
|
2002
|
+
error: ok && !ok.ok ? ok.error : "Sign in first.",
|
|
2003
|
+
},
|
|
2004
|
+
403,
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
1745
2008
|
// SEC-2a: Sign in with GitHub, the owner's link, Pair a device. Before
|
|
1746
2009
|
// config validation, like the sign-in above.
|
|
1747
2010
|
if (
|
|
@@ -1784,7 +2047,10 @@ async function handleRequest(
|
|
|
1784
2047
|
},
|
|
1785
2048
|
commands: (r, code) => {
|
|
1786
2049
|
const approve = cliCommand(printedCli(), "web", "approve", code);
|
|
1787
|
-
return {
|
|
2050
|
+
return {
|
|
2051
|
+
approve,
|
|
2052
|
+
ssh: sshCommand(sshLogin(sshTargetOf(r), sshUserNow()), approve),
|
|
2053
|
+
};
|
|
1788
2054
|
},
|
|
1789
2055
|
audit: (r, verb, detail, refused) =>
|
|
1790
2056
|
recordAction(
|
|
@@ -1924,7 +2190,9 @@ async function handleRequest(
|
|
|
1924
2190
|
}
|
|
1925
2191
|
|
|
1926
2192
|
// ── Playwright report ──────────────────────────────────────────────────────
|
|
1927
|
-
|
|
2193
|
+
// A test run wrote it (an agent may have): sandboxed, never Den's origin.
|
|
2194
|
+
// `/report/…` only: `/reports/security/…` is ZAP's, below.
|
|
2195
|
+
if (pathname === "/report" || pathname.startsWith("/report/")) {
|
|
1928
2196
|
const relPath = pathname.slice("/report".length) || "/index.html";
|
|
1929
2197
|
const filePath = path.resolve(path.join(REPORT_DIR, relPath));
|
|
1930
2198
|
|
|
@@ -1945,7 +2213,7 @@ async function handleRequest(
|
|
|
1945
2213
|
? path.join(filePath, "index.html")
|
|
1946
2214
|
: filePath;
|
|
1947
2215
|
|
|
1948
|
-
return
|
|
2216
|
+
return serveUntrustedFile(res, target);
|
|
1949
2217
|
}
|
|
1950
2218
|
|
|
1951
2219
|
// ── API: environments ──────────────────────────────────────────────────────
|
|
@@ -2047,9 +2315,8 @@ async function handleRequest(
|
|
|
2047
2315
|
"Content-Type": file.endsWith(".html")
|
|
2048
2316
|
? "text/html; charset=utf-8"
|
|
2049
2317
|
: "application/json",
|
|
2050
|
-
"Cache-Control": "no-store",
|
|
2051
2318
|
// ZAP's report pulls its own scripts; keep it away from Den's origin data.
|
|
2052
|
-
|
|
2319
|
+
...UNTRUSTED_FILE_HEADERS,
|
|
2053
2320
|
});
|
|
2054
2321
|
return res.end(body);
|
|
2055
2322
|
} catch {
|
|
@@ -2097,6 +2364,21 @@ async function handleRequest(
|
|
|
2097
2364
|
// ── API: stacks, edge, security plan (read-only; see readonly-routes.ts) ──
|
|
2098
2365
|
if (await handleReadonlyRoutes(req, res, pathname, url)) return;
|
|
2099
2366
|
|
|
2367
|
+
// ── API: onboarding reads (ON-3; see onboard-routes.ts) ──────────────────
|
|
2368
|
+
if (
|
|
2369
|
+
await handleOnboardRoutes(
|
|
2370
|
+
req,
|
|
2371
|
+
res,
|
|
2372
|
+
pathname,
|
|
2373
|
+
url,
|
|
2374
|
+
(onboardDeps ??= defaultOnboardRouteDeps(
|
|
2375
|
+
(m) => validateManifest(m),
|
|
2376
|
+
ENGINE.loaded.version ?? engineVersion() ?? "unknown",
|
|
2377
|
+
)),
|
|
2378
|
+
)
|
|
2379
|
+
)
|
|
2380
|
+
return;
|
|
2381
|
+
|
|
2100
2382
|
// ── API: test sources ─────────────────────────────────────────────────────
|
|
2101
2383
|
// Returns the repos that contribute tests for a given pillar, with their
|
|
2102
2384
|
// current branch + short SHA. Lets the Tests UI show "you are testing
|
|
@@ -3804,6 +4086,10 @@ async function handleRequest(
|
|
|
3804
4086
|
actor: null,
|
|
3805
4087
|
signedIn: false,
|
|
3806
4088
|
...(g && !g.beyond ? { identity: { kind: "local" } } : {}),
|
|
4089
|
+
// SEC-2a-UX: the signed-out page watches this, so Sign in with
|
|
4090
|
+
// GitHub appears once an owner is linked, with no reload. The
|
|
4091
|
+
// owner's login is what that page shows anyway.
|
|
4092
|
+
...(g?.beyond || !g ? { github: githubSignin() } : {}),
|
|
3807
4093
|
});
|
|
3808
4094
|
}
|
|
3809
4095
|
// Sign out: this browser's session ends and its cookie is cleared.
|
|
@@ -4216,6 +4502,7 @@ async function handleRequest(
|
|
|
4216
4502
|
res,
|
|
4217
4503
|
pathname,
|
|
4218
4504
|
guard.ok && (!guard.beyond || !!guard.session || !!guard.principal),
|
|
4505
|
+
{ waitingAgents: waitingAgentIds },
|
|
4219
4506
|
)
|
|
4220
4507
|
)
|
|
4221
4508
|
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
|
+
}
|