@monster-orange/den 0.4.0 → 0.4.2
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 +457 -0
- package/README.md +88 -158
- package/docs/distributions.md +216 -16
- package/docs/reference.md +177 -0
- package/package.json +3 -1
- package/src/shared/actor-token.ts +275 -0
- package/src/shared/agent-actor.ts +12 -2
- package/src/shared/agents/den-claude.ts +99 -1
- package/src/shared/agents/runtime.ts +7 -0
- package/src/shared/agents/runtimes.ts +9 -2
- package/src/shared/den-config.ts +52 -3
- package/src/shared/environment-manager/scope.ts +22 -0
- package/src/shared/environment-manager/service-runner.ts +94 -2
- package/src/shared/profile.ts +15 -4
- package/src/shared/sessions/client.ts +16 -2
- package/src/shared/sessions/den-pty.ts +10 -2
- package/src/shared/sessions/env.ts +13 -3
- package/src/shared/sessions/host.ts +56 -8
- package/src/shared/sessions/types.ts +8 -0
- package/src/shared/web-secret.ts +181 -0
- package/src/shared/web-session.ts +491 -0
- package/src/ui/cli/agent-ops.ts +843 -0
- package/src/ui/cli/agents-command.ts +97 -615
- package/src/ui/cli/flag-parser.ts +21 -0
- package/src/ui/cli/index.ts +127 -11
- package/src/ui/cli/sessions-command.ts +49 -5
- package/src/ui/cli/target-workspace.ts +105 -0
- package/src/ui/cli/web-command.ts +542 -14
- package/src/ui/mcp/api.ts +7 -0
- package/src/ui/mcp/server.ts +74 -6
- package/src/ui/server/agent-routes.ts +324 -0
- package/src/ui/server/audit.ts +81 -27
- package/src/ui/server/http-errors.ts +21 -3
- package/src/ui/server/index.ts +821 -66
- package/src/ui/server/public/agents-page.js +654 -0
- package/src/ui/server/public/agents-section.js +299 -0
- package/src/ui/server/public/agents.html +439 -0
- package/src/ui/server/public/appearance.js +3 -0
- package/src/ui/server/public/error-page.js +103 -0
- package/src/ui/server/public/header.js +45 -11
- package/src/ui/server/public/i18n/es.js +90 -1
- package/src/ui/server/public/i18n/ja.js +86 -1
- package/src/ui/server/public/local-dev.html +53 -0
- package/src/ui/server/public/logs.html +3 -0
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/public/release.html +8 -0
- package/src/ui/server/public/settings-page.js +5 -0
- package/src/ui/server/public/settings-registry.js +33 -0
- package/src/ui/server/public/shell.js +248 -5
- package/src/ui/server/public/topbar.js +51 -2
- package/src/ui/server/request-guard.ts +670 -0
- package/src/ui/server/route-classes.ts +317 -0
- package/src/ui/server/session-routes.ts +445 -0
- package/src/ui/server/shutdown-guard.ts +91 -0
- package/src/ui/server/sudo-store.ts +29 -3
- package/src/ui/server/ui-assets.ts +10 -1
- package/src/ui/server/websocket.ts +326 -0
- package/src/ui/server/workspace-routes.ts +13 -7
package/src/ui/server/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
import { healthCheckFields, type HealthCheckType } from "./health-row.js";
|
|
20
20
|
import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
|
|
21
21
|
import * as http from "http";
|
|
22
|
+
import * as net from "net";
|
|
22
23
|
import * as fs from "fs";
|
|
23
24
|
import * as os from "os";
|
|
24
25
|
import * as path from "path";
|
|
@@ -58,6 +59,8 @@ import {
|
|
|
58
59
|
import {
|
|
59
60
|
parseScope,
|
|
60
61
|
scopeKey,
|
|
62
|
+
pollView,
|
|
63
|
+
inViewMarker,
|
|
61
64
|
scopePayload,
|
|
62
65
|
scopeView,
|
|
63
66
|
storedView,
|
|
@@ -127,6 +130,7 @@ import {
|
|
|
127
130
|
engineVersion,
|
|
128
131
|
getDistRoot,
|
|
129
132
|
getEngineRoot,
|
|
133
|
+
getStateDir,
|
|
130
134
|
} from "../../shared/paths.js";
|
|
131
135
|
import { enginePath } from "../../shared/engine.js";
|
|
132
136
|
import {
|
|
@@ -136,7 +140,10 @@ import {
|
|
|
136
140
|
type ReleaseEvent,
|
|
137
141
|
} from "./release-service.js";
|
|
138
142
|
import { deploySessions } from "./release-deploy-session.js";
|
|
139
|
-
import {
|
|
143
|
+
import {
|
|
144
|
+
hasDenHostsEntry,
|
|
145
|
+
spawnDetachedWebRestart,
|
|
146
|
+
} from "../cli/web-command.js";
|
|
140
147
|
import { recordInvocation } from "../../shared/dev-metrics/emit.js";
|
|
141
148
|
import {
|
|
142
149
|
validateManifest,
|
|
@@ -164,7 +171,6 @@ import {
|
|
|
164
171
|
import { DEN_BANNER_LINES, DEN_SUBTITLE } from "../../shared/banner.js";
|
|
165
172
|
import { actorLabel } from "../../shared/agent-actor.js";
|
|
166
173
|
import {
|
|
167
|
-
actorFromHeaders,
|
|
168
174
|
actorSuffix,
|
|
169
175
|
recordAction,
|
|
170
176
|
recordAgentRefusal,
|
|
@@ -173,6 +179,44 @@ import {
|
|
|
173
179
|
setAuditSink,
|
|
174
180
|
} from "./audit.js";
|
|
175
181
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
182
|
+
import {
|
|
183
|
+
auditActorOf,
|
|
184
|
+
bindRefusal,
|
|
185
|
+
checkRequest,
|
|
186
|
+
isEdgePeer,
|
|
187
|
+
isLoopbackAddress,
|
|
188
|
+
isLoopbackBind,
|
|
189
|
+
newCsrfToken,
|
|
190
|
+
principalOf,
|
|
191
|
+
requestIsHttps,
|
|
192
|
+
sessionRequestAllowed,
|
|
193
|
+
setPrincipal,
|
|
194
|
+
signinMessage,
|
|
195
|
+
type GuardContext,
|
|
196
|
+
type GuardResult,
|
|
197
|
+
} from "./request-guard.js";
|
|
198
|
+
import {
|
|
199
|
+
rotateWebSecret,
|
|
200
|
+
verifyPersonToken,
|
|
201
|
+
WebSecretStore,
|
|
202
|
+
} from "../../shared/web-secret.js";
|
|
203
|
+
import {
|
|
204
|
+
ACTOR_TOKEN_PREFIX,
|
|
205
|
+
checkActorToken,
|
|
206
|
+
revokeAllActorTokens,
|
|
207
|
+
} from "../../shared/actor-token.js";
|
|
208
|
+
import {
|
|
209
|
+
checkLoginCode,
|
|
210
|
+
ignoredConfigWarning,
|
|
211
|
+
localhostOrigin,
|
|
212
|
+
readWebAuthConfig,
|
|
213
|
+
safeNext,
|
|
214
|
+
SESSION_COOKIE_MAX_AGE_S,
|
|
215
|
+
sessionCookieName,
|
|
216
|
+
sessionIdPrefix,
|
|
217
|
+
WebSessionStore,
|
|
218
|
+
type WebAuthConfig,
|
|
219
|
+
} from "../../shared/web-session.js";
|
|
176
220
|
import { serveUiAsset } from "./ui-assets.js";
|
|
177
221
|
import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
|
|
178
222
|
import { graphPageHtml } from "./graph-page.js";
|
|
@@ -239,10 +283,16 @@ interface HealthResult {
|
|
|
239
283
|
/** From the focused workspace: the header sorts these first. */
|
|
240
284
|
focused?: boolean;
|
|
241
285
|
/**
|
|
242
|
-
* The workspaces
|
|
286
|
+
* The active workspaces that define this service (focus first). More
|
|
243
287
|
* than one means a shared service: it is listed once, with its owners.
|
|
244
288
|
*/
|
|
245
289
|
workspaces?: string[];
|
|
290
|
+
/**
|
|
291
|
+
* WS-6.4: whether the view scope shows it (one of its workspaces is in
|
|
292
|
+
* view). Health covers every active workspace; the header's chip and
|
|
293
|
+
* drawer count only these, while the pages see every row.
|
|
294
|
+
*/
|
|
295
|
+
inView?: boolean;
|
|
246
296
|
}
|
|
247
297
|
|
|
248
298
|
/**
|
|
@@ -274,6 +324,19 @@ function viewerRewrite(
|
|
|
274
324
|
});
|
|
275
325
|
}
|
|
276
326
|
|
|
327
|
+
/**
|
|
328
|
+
* A health row as this request sees it: its links rewritten for the
|
|
329
|
+
* viewer's host, and `inView` from the view scope now (WS-6.4: the rows
|
|
330
|
+
* are cached for every active workspace; the scope is applied per answer).
|
|
331
|
+
*/
|
|
332
|
+
function healthForViewer(
|
|
333
|
+
host: string | undefined,
|
|
334
|
+
): (r: HealthResult) => HealthResult {
|
|
335
|
+
const rewrite = viewerRewrite(host);
|
|
336
|
+
const inView = inViewMarker(scopeView());
|
|
337
|
+
return (r) => ({ ...rewrite(r), inView: inView(r.workspaces) });
|
|
338
|
+
}
|
|
339
|
+
|
|
277
340
|
const HEALTH_CACHE_TTL_MS = 8_000;
|
|
278
341
|
/** Consecutive timed-out health cycles per check (see TimeoutStreaks). */
|
|
279
342
|
const healthTimeouts = new TimeoutStreaks();
|
|
@@ -303,10 +366,11 @@ enableReadCache(READ_CACHE_TTL_MS);
|
|
|
303
366
|
const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
|
|
304
367
|
/**
|
|
305
368
|
* A workspace-scoped run covers a different service set than the default
|
|
306
|
-
* one, and the default one covers
|
|
369
|
+
* one, and the default one covers every active workspace (WS-6.4: the view
|
|
370
|
+
* scope filters what is displayed, never what is polled).
|
|
307
371
|
*/
|
|
308
372
|
function healthCacheKey(env: Environment, workspace: string | null): string {
|
|
309
|
-
return `${env}|${workspace ?? `*:${scopeKey(
|
|
373
|
+
return `${env}|${workspace ?? `*:${scopeKey(pollView())}`}`;
|
|
310
374
|
}
|
|
311
375
|
import {
|
|
312
376
|
LOG_SWEEP_INTERVAL_MS,
|
|
@@ -435,9 +499,13 @@ function pushLog(kind: string, text: string, actor?: string): void {
|
|
|
435
499
|
*/
|
|
436
500
|
function serverLog(msg: string, kind: string = "info", actor?: string): void {
|
|
437
501
|
pushLog(kind, msg, actor);
|
|
438
|
-
if (!SILENT) process.stdout.write(msg + "\n");
|
|
502
|
+
if (!SILENT && !outputLost()) process.stdout.write(msg + "\n");
|
|
439
503
|
}
|
|
440
504
|
|
|
505
|
+
// Its output's reader gone (the terminal, runner or script that started it
|
|
506
|
+
// was killed): write nothing more, and shut down (shutdown-guard.ts).
|
|
507
|
+
guardStdio(() => beginShutdown("SIGPIPE"));
|
|
508
|
+
|
|
441
509
|
// ── Theme ────────────────────────────────────────────────────────────────────
|
|
442
510
|
// The active theme (user's, else the org config's `theme:`, else den). A
|
|
443
511
|
// theme switch is a write of user.yml `appearance:` and a new /theme.css:
|
|
@@ -470,6 +538,17 @@ setHtmlFill((html) => {
|
|
|
470
538
|
.join(mode)
|
|
471
539
|
.split("@@DEN_DEFAULT_MODE@@")
|
|
472
540
|
.join(def);
|
|
541
|
+
// 0.4.1: every page says when the config fails validation, so the shell
|
|
542
|
+
// starts none of its config polls (each would answer 422).
|
|
543
|
+
try {
|
|
544
|
+
assertDenConfigValid();
|
|
545
|
+
} catch (e) {
|
|
546
|
+
if (isConfigError(e))
|
|
547
|
+
out = out.replace(
|
|
548
|
+
'<div id="den-shell"',
|
|
549
|
+
() => '<div id="den-shell" data-config-invalid="1"',
|
|
550
|
+
);
|
|
551
|
+
}
|
|
473
552
|
// MD-2: every page says when the engine on disk is not the one running.
|
|
474
553
|
const engine = engineCheck();
|
|
475
554
|
if (engine.stale)
|
|
@@ -522,10 +601,22 @@ function sendErrorPage(
|
|
|
522
601
|
res.end();
|
|
523
602
|
return;
|
|
524
603
|
}
|
|
604
|
+
// A refusal page (421, signed out): every /api call the shell frame
|
|
605
|
+
// would make answers the same refusal, so the shell starts none of its
|
|
606
|
+
// polls and streams (data-api-off; shell.js apiOff).
|
|
607
|
+
const off = data.status === 421 || data.code === "signin_required";
|
|
525
608
|
serveFile(
|
|
526
609
|
res,
|
|
527
610
|
path.join(PUBLIC_DIR, "error.html"),
|
|
528
|
-
(html) =>
|
|
611
|
+
(html) => {
|
|
612
|
+
const page = fillErrorPage(html, full);
|
|
613
|
+
return off
|
|
614
|
+
? page.replace(
|
|
615
|
+
'<div id="den-shell"',
|
|
616
|
+
() => '<div id="den-shell" data-api-off="1"',
|
|
617
|
+
)
|
|
618
|
+
: page;
|
|
619
|
+
},
|
|
529
620
|
data.status,
|
|
530
621
|
);
|
|
531
622
|
}
|
|
@@ -587,6 +678,12 @@ function allWorkspaceCheckouts(): ResolvedService[] {
|
|
|
587
678
|
*/
|
|
588
679
|
const CONFIG_FREE_ROUTES = new Set([
|
|
589
680
|
"/api/server-info",
|
|
681
|
+
// SEC-1a: the shell's CSRF token, which the Settings fix itself needs.
|
|
682
|
+
"/api/session",
|
|
683
|
+
// SEC-1b: signing out, and the CLI's `web sessions` / `web logout`.
|
|
684
|
+
"/api/session/logout",
|
|
685
|
+
"/api/web/sessions",
|
|
686
|
+
"/api/web/logout",
|
|
590
687
|
"/api/server-log",
|
|
591
688
|
"/api/changelog",
|
|
592
689
|
"/api/web/restart",
|
|
@@ -610,11 +707,19 @@ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
|
|
|
610
707
|
import {
|
|
611
708
|
setSudoPassword,
|
|
612
709
|
clearSudoPassword,
|
|
710
|
+
closeLockForThisRequest,
|
|
711
|
+
inRequestLockScope,
|
|
613
712
|
getSudoEnv,
|
|
614
713
|
prewarmSudo,
|
|
615
714
|
hasSudoPassword,
|
|
616
715
|
hasPasswordlessSudo,
|
|
617
716
|
} from "./sudo-store.js";
|
|
717
|
+
import {
|
|
718
|
+
guardStdio,
|
|
719
|
+
isLostOutputError,
|
|
720
|
+
outputLost,
|
|
721
|
+
requestShutdown,
|
|
722
|
+
} from "./shutdown-guard.js";
|
|
618
723
|
|
|
619
724
|
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
620
725
|
import { sweepLogs } from "../../shared/process/log-rotation.js";
|
|
@@ -658,9 +763,17 @@ import { parseChangelog } from "./changelog-parser.js";
|
|
|
658
763
|
import { GitHubService } from "./github-service.js";
|
|
659
764
|
import { EditorManager, SUPPORTED_LANGUAGES } from "./editor-manager.js";
|
|
660
765
|
import { handleWorkspaceRoutes } from "./workspace-routes.js";
|
|
766
|
+
import { handleAgentRoutes } from "./agent-routes.js";
|
|
767
|
+
import {
|
|
768
|
+
handleSessionList,
|
|
769
|
+
handleSessionUpgrade,
|
|
770
|
+
isSessionIoPath,
|
|
771
|
+
sendSessionHttp,
|
|
772
|
+
} from "./session-routes.js";
|
|
773
|
+
import { refuseUpgrade } from "./websocket.js";
|
|
661
774
|
import { isLifecycleExecCmd, streamLifecycleExec } from "./lifecycle-exec.js";
|
|
662
775
|
import { readEdgeConfig, readWebPort } from "../../shared/edge/config.js";
|
|
663
|
-
import { edgeHttps } from "../../shared/edge/render.js";
|
|
776
|
+
import { edgeDomains, edgeHttps, routeHost } from "../../shared/edge/render.js";
|
|
664
777
|
import {
|
|
665
778
|
linksFor,
|
|
666
779
|
openUrlFor,
|
|
@@ -776,6 +889,358 @@ function mirrorLine(runId: string, text: string): void {
|
|
|
776
889
|
process.stdout.write(` ${dim(`[${runId}]`)} ${stripAnsi(text)}\n`);
|
|
777
890
|
}
|
|
778
891
|
|
|
892
|
+
// ── Request checks (SEC-1a, SEC-1 spec §3) ─────────────────────────────────────
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* The edge hostnames that route to this server: each route whose upstream
|
|
896
|
+
* is this server's port (or the configured `web.port`, which the edge's
|
|
897
|
+
* own route for Den names) on the docker host, under every edge domain.
|
|
898
|
+
* None without an edge block.
|
|
899
|
+
*/
|
|
900
|
+
function denEdgeHosts(): string[] {
|
|
901
|
+
let cfg: ReturnType<typeof readEdgeConfig>;
|
|
902
|
+
try {
|
|
903
|
+
cfg = readEdgeConfig();
|
|
904
|
+
} catch {
|
|
905
|
+
return [];
|
|
906
|
+
}
|
|
907
|
+
const ports = new Set([PORT, readWebPortSafe()]);
|
|
908
|
+
const names = Object.entries(cfg.routes)
|
|
909
|
+
.filter(([, upstream]) => {
|
|
910
|
+
try {
|
|
911
|
+
const u = new URL(upstream);
|
|
912
|
+
return (
|
|
913
|
+
ports.has(Number(u.port || (u.protocol === "https:" ? 443 : 80))) &&
|
|
914
|
+
["host.docker.internal", "localhost", "127.0.0.1"].includes(
|
|
915
|
+
u.hostname,
|
|
916
|
+
)
|
|
917
|
+
);
|
|
918
|
+
} catch {
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
})
|
|
922
|
+
.map(([name]) => name);
|
|
923
|
+
return names.flatMap((n) =>
|
|
924
|
+
edgeDomains(cfg).map((d) => routeHost(n, d).toLowerCase()),
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Bind loopback only: this UI runs privileged local actions (sudo-backed
|
|
929
|
+
// service commands). DEN_BIND_HOST=0.0.0.0 exposes it deliberately, and
|
|
930
|
+
// (SEC-1b) only with HTTPS in front or web.allow_plain_http_remote.
|
|
931
|
+
const BIND_HOST = process.env.DEN_BIND_HOST ?? "127.0.0.1";
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* SEC-1b: `web.allow_plain_http_remote`, read at start. A wrong value
|
|
935
|
+
* refuses to start, naming its key. The 0.4.1-dev `web.session` lifetimes
|
|
936
|
+
* are ignored (a session never expires), with a warning at start.
|
|
937
|
+
*/
|
|
938
|
+
const WEB_AUTH: WebAuthConfig = (() => {
|
|
939
|
+
try {
|
|
940
|
+
return readWebAuthConfig(getDistRoot(), product().configFile);
|
|
941
|
+
} catch (e) {
|
|
942
|
+
console.error(`\n ✗ ${(e as Error).message}\n`);
|
|
943
|
+
process.exit(1);
|
|
944
|
+
}
|
|
945
|
+
})();
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The loopback names, with the port: localhost (canonical), 127.0.0.1 and
|
|
949
|
+
* [::1]; the product's own name when /etc/hosts maps it to 127.0.0.1 (the
|
|
950
|
+
* URL `web up` opens then); and a loopback address DEN_BIND_HOST binds.
|
|
951
|
+
*/
|
|
952
|
+
function loopbackNames(): string[] {
|
|
953
|
+
const names = ["localhost", "127.0.0.1", "[::1]"];
|
|
954
|
+
try {
|
|
955
|
+
if (hasDenHostsEntry(fs.readFileSync("/etc/hosts", "utf-8")))
|
|
956
|
+
names.push(product().cli.toLowerCase());
|
|
957
|
+
} catch {
|
|
958
|
+
/* no hosts file */
|
|
959
|
+
}
|
|
960
|
+
const bind = BIND_HOST.trim().toLowerCase();
|
|
961
|
+
if (net.isIP(bind) && isLoopbackBind(bind))
|
|
962
|
+
names.push(net.isIPv6(bind) ? `[${bind}]` : bind);
|
|
963
|
+
return [...new Set(names)];
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* SEC-1b: this machine's own LAN names, with the port, for a non-loopback
|
|
968
|
+
* bind that `web.allow_plain_http_remote` allows: the bound address, or
|
|
969
|
+
* (0.0.0.0, ::) every interface's address and the host name. Beyond
|
|
970
|
+
* loopback, so every route needs a session. An IP literal or this
|
|
971
|
+
* machine's own name can't be rebound; any other name stays 421.
|
|
972
|
+
*/
|
|
973
|
+
function lanHosts(): string[] {
|
|
974
|
+
const bind = BIND_HOST.trim().toLowerCase();
|
|
975
|
+
if (isLoopbackBind(bind) || !WEB_AUTH.allowPlainHttpRemote) return [];
|
|
976
|
+
const br = (a: string) => (net.isIPv6(a) ? `[${a}]` : a);
|
|
977
|
+
if (bind !== "0.0.0.0" && bind !== "::")
|
|
978
|
+
return net.isIP(bind) ? [br(bind)] : [bind];
|
|
979
|
+
const out: string[] = [];
|
|
980
|
+
for (const list of Object.values(os.networkInterfaces()))
|
|
981
|
+
for (const a of list ?? [])
|
|
982
|
+
if (!a.internal)
|
|
983
|
+
out.push(br(a.address.toLowerCase().replace(/%.*$/, "")));
|
|
984
|
+
const host = os.hostname().toLowerCase();
|
|
985
|
+
if (host) out.push(host, host.endsWith(".local") ? host : `${host}.local`);
|
|
986
|
+
return [...new Set(out)];
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** HTTPS in front of this server: an edge with TLS that routes to it. */
|
|
990
|
+
function edgeFronted(): boolean {
|
|
991
|
+
try {
|
|
992
|
+
return (
|
|
993
|
+
readEdgeConfig().tls.strategy !== "none" && denEdgeHosts().length > 0
|
|
994
|
+
);
|
|
995
|
+
} catch {
|
|
996
|
+
return false;
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const webSecret = new WebSecretStore();
|
|
1001
|
+
const webSessions = new WebSessionStore({
|
|
1002
|
+
secret: () => webSecret.current(),
|
|
1003
|
+
});
|
|
1004
|
+
const SESSION_COOKIE = sessionCookieName(PORT);
|
|
1005
|
+
|
|
1006
|
+
/** The request's `den_session_<port>` cookie value, if any. */
|
|
1007
|
+
function sessionCookie(req: http.IncomingMessage): string | undefined {
|
|
1008
|
+
const raw = req.headers.cookie;
|
|
1009
|
+
if (!raw) return undefined;
|
|
1010
|
+
for (const part of raw.split(";")) {
|
|
1011
|
+
const i = part.indexOf("=");
|
|
1012
|
+
if (i > 0 && part.slice(0, i).trim() === SESSION_COOKIE)
|
|
1013
|
+
return part.slice(i + 1).trim();
|
|
1014
|
+
}
|
|
1015
|
+
return undefined;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const LAN_HOSTS = lanHosts();
|
|
1019
|
+
const GUARD: GuardContext = {
|
|
1020
|
+
port: PORT,
|
|
1021
|
+
loopbackNames: loopbackNames(),
|
|
1022
|
+
edgeHosts: denEdgeHosts,
|
|
1023
|
+
lanHosts: () => LAN_HOSTS,
|
|
1024
|
+
verifyBearer: (token) => {
|
|
1025
|
+
// SEC-1c: an agent's actor token, live while its session runs.
|
|
1026
|
+
if (token.startsWith(ACTOR_TOKEN_PREFIX)) {
|
|
1027
|
+
const rec = checkActorToken(getStateDir(), token);
|
|
1028
|
+
if (rec === "revoked") return "revoked";
|
|
1029
|
+
return rec
|
|
1030
|
+
? {
|
|
1031
|
+
kind: "agent",
|
|
1032
|
+
actor: rec.actor,
|
|
1033
|
+
workspace: rec.workspace,
|
|
1034
|
+
grants: rec.grants,
|
|
1035
|
+
session: rec.session,
|
|
1036
|
+
}
|
|
1037
|
+
: null;
|
|
1038
|
+
}
|
|
1039
|
+
const secret = webSecret.current();
|
|
1040
|
+
return secret ? verifyPersonToken(token, secret) : null;
|
|
1041
|
+
},
|
|
1042
|
+
csrfToken: newCsrfToken(),
|
|
1043
|
+
session: (req) => webSessions.lookup(sessionCookie(req)),
|
|
1044
|
+
peerIsLoopback: (req) => isLoopbackAddress(req.socket?.remoteAddress),
|
|
1045
|
+
// The edge hostname only from the edge's own peers (loopback, Docker).
|
|
1046
|
+
peerIsEdge: (req) => isEdgePeer(req.socket?.remoteAddress),
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* The Set-Cookie for a session: HttpOnly, SameSite=Strict, Path=/, Secure
|
|
1051
|
+
* when the request came over HTTPS (requestIsHttps: the edge's own header
|
|
1052
|
+
* only from the edge), for as long as a browser keeps a cookie: the
|
|
1053
|
+
* session itself never expires.
|
|
1054
|
+
*/
|
|
1055
|
+
function sessionSetCookie(id: string, secure: boolean): string {
|
|
1056
|
+
return (
|
|
1057
|
+
`${SESSION_COOKIE}=${id}; Path=/; HttpOnly; SameSite=Strict; ` +
|
|
1058
|
+
`Max-Age=${SESSION_COOKIE_MAX_AGE_S}${secure ? "; Secure" : ""}`
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
function clearSessionCookie(secure: boolean): string {
|
|
1062
|
+
return (
|
|
1063
|
+
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0` +
|
|
1064
|
+
(secure ? "; Secure" : "")
|
|
1065
|
+
);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/** The loopback names that aren't localhost: their signed-out page links there. */
|
|
1069
|
+
function nonCanonicalLoopback(req: http.IncomingMessage): boolean {
|
|
1070
|
+
const h = String(req.headers.host ?? "")
|
|
1071
|
+
.toLowerCase()
|
|
1072
|
+
.replace(/:\d+$/, "");
|
|
1073
|
+
return h !== "localhost" && GUARD.loopbackNames.includes(h);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* The signed-out page (§2): the shell frame, "Sign in to use the terminal"
|
|
1078
|
+
* (shell-grade on loopback) or "Sign in to use <title>", the command that
|
|
1079
|
+
* prints a link, and on 127.0.0.1 or [::1] a link to the same path on
|
|
1080
|
+
* localhost (where the cookie is). Never a form for a secret or a token.
|
|
1081
|
+
*/
|
|
1082
|
+
function sendSignedOut(
|
|
1083
|
+
req: http.IncomingMessage,
|
|
1084
|
+
res: http.ServerResponse,
|
|
1085
|
+
opts: {
|
|
1086
|
+
pathname: string;
|
|
1087
|
+
search: string;
|
|
1088
|
+
scope: "terminal" | "agents" | "den";
|
|
1089
|
+
expired?: boolean;
|
|
1090
|
+
requestId: string;
|
|
1091
|
+
message: string;
|
|
1092
|
+
},
|
|
1093
|
+
): void {
|
|
1094
|
+
const host = String(req.headers.host ?? "").toLowerCase();
|
|
1095
|
+
const toLocal = nonCanonicalLoopback(req);
|
|
1096
|
+
const via = GUARD.edgeHosts().includes(host.replace(/:\d+$/, ""))
|
|
1097
|
+
? "edge"
|
|
1098
|
+
: "other";
|
|
1099
|
+
const origin = `${requestIsHttps(req, via === "edge" ? "edge" : null) ? "https" : "http"}://${host}`;
|
|
1100
|
+
const cmd =
|
|
1101
|
+
`${product().cli} web login` +
|
|
1102
|
+
(toLocal || host === `localhost:${PORT}` ? "" : ` --origin ${origin}`);
|
|
1103
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1104
|
+
sendErrorPage(req, res, {
|
|
1105
|
+
status: 401,
|
|
1106
|
+
code: "signin_required",
|
|
1107
|
+
path: opts.pathname,
|
|
1108
|
+
message: opts.message,
|
|
1109
|
+
requestId: opts.requestId,
|
|
1110
|
+
signin: {
|
|
1111
|
+
scope: opts.scope,
|
|
1112
|
+
name: product().title,
|
|
1113
|
+
cmd,
|
|
1114
|
+
...(opts.expired ? { expired: true } : {}),
|
|
1115
|
+
...(toLocal && !opts.expired
|
|
1116
|
+
? {
|
|
1117
|
+
localhost: `${localhostOrigin(PORT)}${opts.pathname}${opts.search}`,
|
|
1118
|
+
}
|
|
1119
|
+
: {}),
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const PUBLIC_ASSET_EXTS = new Set([
|
|
1125
|
+
".js",
|
|
1126
|
+
".css",
|
|
1127
|
+
".ico",
|
|
1128
|
+
".png",
|
|
1129
|
+
".svg",
|
|
1130
|
+
".webp",
|
|
1131
|
+
".woff",
|
|
1132
|
+
".woff2",
|
|
1133
|
+
]);
|
|
1134
|
+
/** A GET of the UI's code or styling (never /api, a report or a page). */
|
|
1135
|
+
function isPublicAsset(req: http.IncomingMessage, pathname: string): boolean {
|
|
1136
|
+
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
|
1137
|
+
if (/^\/(api|reports?)(\/|$)/.test(pathname)) return false;
|
|
1138
|
+
return PUBLIC_ASSET_EXTS.has(path.extname(pathname).toLowerCase());
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** A refused request: R26 JSON for /api, the error page for a page. */
|
|
1142
|
+
function sendGuardRefusal(
|
|
1143
|
+
req: http.IncomingMessage,
|
|
1144
|
+
res: http.ServerResponse,
|
|
1145
|
+
pathname: string,
|
|
1146
|
+
refusal: Extract<GuardResult, { ok: false }>,
|
|
1147
|
+
requestId: string,
|
|
1148
|
+
search = "",
|
|
1149
|
+
): void {
|
|
1150
|
+
if (pathname === "/api" || pathname.startsWith("/api/"))
|
|
1151
|
+
return sendJson(
|
|
1152
|
+
res,
|
|
1153
|
+
{
|
|
1154
|
+
ok: false,
|
|
1155
|
+
error: refusal.error,
|
|
1156
|
+
code: refusal.code,
|
|
1157
|
+
// signin_required: why, so the page words it in its own language.
|
|
1158
|
+
...(refusal.scope ? { scope: refusal.scope } : {}),
|
|
1159
|
+
requestId,
|
|
1160
|
+
},
|
|
1161
|
+
refusal.status,
|
|
1162
|
+
);
|
|
1163
|
+
if (refusal.code === "signin_required" && wantsHtmlPage(req, pathname))
|
|
1164
|
+
return sendSignedOut(req, res, {
|
|
1165
|
+
pathname,
|
|
1166
|
+
search,
|
|
1167
|
+
scope: refusal.scope ?? "den",
|
|
1168
|
+
requestId,
|
|
1169
|
+
message: refusal.error,
|
|
1170
|
+
});
|
|
1171
|
+
if (wantsHtmlPage(req, pathname))
|
|
1172
|
+
return sendErrorPage(req, res, {
|
|
1173
|
+
status: refusal.status,
|
|
1174
|
+
path: pathname,
|
|
1175
|
+
message: refusal.error,
|
|
1176
|
+
code: refusal.code,
|
|
1177
|
+
requestId,
|
|
1178
|
+
});
|
|
1179
|
+
res.writeHead(refusal.status, { "Content-Type": "text/plain" });
|
|
1180
|
+
res.end(`${refusal.error} (${refusal.code}, request ${requestId})`);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* `GET /auth?c=<code>&next=<path>` (§2): a valid, unused login code (60 s,
|
|
1185
|
+
* constant-time mac, single use) becomes a session and its cookie, then a
|
|
1186
|
+
* 303 to `next` when it is a same-origin path, else `/`. Anything else gets
|
|
1187
|
+
* the signed-out page, which never echoes the code.
|
|
1188
|
+
*/
|
|
1189
|
+
function handleAuth(
|
|
1190
|
+
req: http.IncomingMessage,
|
|
1191
|
+
res: http.ServerResponse,
|
|
1192
|
+
url: URL,
|
|
1193
|
+
requestId: string,
|
|
1194
|
+
via: "loopback" | "edge" | "lan" | null,
|
|
1195
|
+
): void {
|
|
1196
|
+
const secret = webSecret.current();
|
|
1197
|
+
const check = secret
|
|
1198
|
+
? checkLoginCode(url.searchParams.get("c") ?? "", secret)
|
|
1199
|
+
: null;
|
|
1200
|
+
if (!check?.ok || !webSessions.consumeCode(check.nonce, check.expiresAt)) {
|
|
1201
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1202
|
+
return sendSignedOut(req, res, {
|
|
1203
|
+
pathname: "/auth",
|
|
1204
|
+
search: "",
|
|
1205
|
+
scope: "den",
|
|
1206
|
+
expired: true,
|
|
1207
|
+
requestId,
|
|
1208
|
+
message: `That sign-in link expired or was used. Run \`${product().cli} web login\` for a new one.`,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
const { id } = webSessions.create({
|
|
1212
|
+
userAgent: req.headers["user-agent"],
|
|
1213
|
+
host: req.headers.host,
|
|
1214
|
+
});
|
|
1215
|
+
res.writeHead(303, {
|
|
1216
|
+
Location: safeNext(url.searchParams.get("next")),
|
|
1217
|
+
"Set-Cookie": sessionSetCookie(id, requestIsHttps(req, via)),
|
|
1218
|
+
"Cache-Control": "no-store",
|
|
1219
|
+
"Referrer-Policy": "no-referrer",
|
|
1220
|
+
});
|
|
1221
|
+
res.end();
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* A service's declared command `cmd` for `env`, own properties only
|
|
1226
|
+
* (SEC-1 §6b.2): `constructor` or `__proto__` is no command, never
|
|
1227
|
+
* something from Object.prototype.
|
|
1228
|
+
*/
|
|
1229
|
+
function declaredCommand<T>(
|
|
1230
|
+
commands: Record<string, Record<string, T> | undefined> | undefined,
|
|
1231
|
+
env: string,
|
|
1232
|
+
cmd: string,
|
|
1233
|
+
): T | undefined {
|
|
1234
|
+
const own = (o: object | undefined, k: string) =>
|
|
1235
|
+
o !== undefined && Object.prototype.hasOwnProperty.call(o, k);
|
|
1236
|
+
if (!own(commands, env)) return undefined;
|
|
1237
|
+
const forEnv = commands![env];
|
|
1238
|
+
return own(forEnv, cmd) ? forEnv![cmd] : undefined;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/** A test module's name as a run takes it (SEC-1 §6b.2): never a path out. */
|
|
1242
|
+
const SAFE_MODULE_RE = /^[A-Za-z0-9_@][A-Za-z0-9._@/-]{0,127}$/;
|
|
1243
|
+
|
|
779
1244
|
// ── Server ────────────────────────────────────────────────────────────────────
|
|
780
1245
|
|
|
781
1246
|
async function handleRequest(
|
|
@@ -790,15 +1255,6 @@ async function handleRequest(
|
|
|
790
1255
|
// R26b: every /api error answers JSON, whatever the route sends.
|
|
791
1256
|
if (pathname === "/api" || pathname.startsWith("/api/"))
|
|
792
1257
|
jsonApiErrors(res, requestId);
|
|
793
|
-
// A mutating request may change what /api/services would list (exec and
|
|
794
|
-
// sync stream over GET, for EventSource).
|
|
795
|
-
if (
|
|
796
|
-
(req.method !== "GET" && req.method !== "HEAD") ||
|
|
797
|
-
/\/(exec|sync)$/.test(pathname)
|
|
798
|
-
) {
|
|
799
|
-
servicesCache.clear();
|
|
800
|
-
clearReadCache();
|
|
801
|
-
}
|
|
802
1258
|
|
|
803
1259
|
// ── Request logging ────────────────────────────────────────────────────────
|
|
804
1260
|
// /api/health fires on the UI's 5s polling interval forever — it dominates
|
|
@@ -823,6 +1279,69 @@ async function handleRequest(
|
|
|
823
1279
|
}
|
|
824
1280
|
}
|
|
825
1281
|
|
|
1282
|
+
// ── Request checks (SEC-1a): Host, credential, Origin, CSRF ────────────────
|
|
1283
|
+
// Before any route, and before a refused request can clear a cache.
|
|
1284
|
+
const guard = checkRequest(req, GUARD, product().cli, product().title);
|
|
1285
|
+
// The UI's own scripts and styles answer any host, and without a
|
|
1286
|
+
// session, so the 421 and signed-out pages (which need shell.js and the
|
|
1287
|
+
// theme) render; they hold no data. The request log above records the
|
|
1288
|
+
// refusal (status, request id; never the query, where a login code is).
|
|
1289
|
+
if (
|
|
1290
|
+
!guard.ok &&
|
|
1291
|
+
!(
|
|
1292
|
+
(guard.code === "misdirected" || guard.code === "signin_required") &&
|
|
1293
|
+
isPublicAsset(req, pathname)
|
|
1294
|
+
)
|
|
1295
|
+
)
|
|
1296
|
+
return sendGuardRefusal(req, res, pathname, guard, requestId, url.search);
|
|
1297
|
+
if (guard.ok) setPrincipal(req, guard.principal);
|
|
1298
|
+
// SEC-1c (R8): an agent's request never passes the lock.
|
|
1299
|
+
if (guard.ok && guard.principal?.kind === "agent") closeLockForThisRequest();
|
|
1300
|
+
|
|
1301
|
+
// ── Sign-in (SEC-1b, §2) ───────────────────────────────────────────────────
|
|
1302
|
+
// Before config validation: signing in never depends on the config.
|
|
1303
|
+
if (pathname === "/auth" && (req.method === "GET" || req.method === "HEAD"))
|
|
1304
|
+
return handleAuth(req, res, url, requestId, guard.ok ? guard.via : null);
|
|
1305
|
+
if (
|
|
1306
|
+
pathname === "/signin" &&
|
|
1307
|
+
(req.method === "GET" || req.method === "HEAD")
|
|
1308
|
+
) {
|
|
1309
|
+
// Read-only and sign-in free (§6b.3): it checks the session itself.
|
|
1310
|
+
// Signed in, or on loopback (no sign-in there), it goes on to a
|
|
1311
|
+
// same-origin `next`; signed out beyond loopback, it is the signed-out
|
|
1312
|
+
// page (the `web login` hint, never a form for the secret).
|
|
1313
|
+
if (guard.ok && (guard.session || !guard.beyond)) {
|
|
1314
|
+
res.writeHead(303, {
|
|
1315
|
+
Location: safeNext(url.searchParams.get("next")),
|
|
1316
|
+
"Cache-Control": "no-store",
|
|
1317
|
+
});
|
|
1318
|
+
res.end();
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
return sendSignedOut(req, res, {
|
|
1322
|
+
pathname,
|
|
1323
|
+
search: url.search,
|
|
1324
|
+
scope: "den",
|
|
1325
|
+
requestId,
|
|
1326
|
+
message: signinMessage("den", product().cli, product().title),
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
// AG-4a-term's session routes (`/api/sessions/<id>/io`): shell-grade, and
|
|
1330
|
+
// an agent's token reaches its own session's only (request-guard). The io
|
|
1331
|
+
// is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
|
|
1332
|
+
if (pathname.startsWith("/api/sessions/"))
|
|
1333
|
+
return sendSessionHttp(req, res, pathname);
|
|
1334
|
+
|
|
1335
|
+
// A mutating request may change what /api/services would list (exec and
|
|
1336
|
+
// sync stream over GET, for EventSource).
|
|
1337
|
+
if (
|
|
1338
|
+
(req.method !== "GET" && req.method !== "HEAD") ||
|
|
1339
|
+
/\/(exec|sync)$/.test(pathname)
|
|
1340
|
+
) {
|
|
1341
|
+
servicesCache.clear();
|
|
1342
|
+
clearReadCache();
|
|
1343
|
+
}
|
|
1344
|
+
|
|
826
1345
|
// ── Config validation ──────────────────────────────────────────────────────
|
|
827
1346
|
// A config that fails validation answers every API route that reads it as
|
|
828
1347
|
// the ConfigError the handler below maps to 422 JSON, before any route's own
|
|
@@ -882,6 +1401,11 @@ async function handleRequest(
|
|
|
882
1401
|
);
|
|
883
1402
|
}
|
|
884
1403
|
|
|
1404
|
+
// The Agents tab (AG-4a-term, AG spec §5d): sessions and terminal panes.
|
|
1405
|
+
if (pathname === "/agents" || pathname === "/agents.html") {
|
|
1406
|
+
return serveFile(res, path.join(PUBLIC_DIR, "agents.html"));
|
|
1407
|
+
}
|
|
1408
|
+
|
|
885
1409
|
if (pathname === "/tests" || pathname === "/index.html") {
|
|
886
1410
|
return serveFile(res, path.join(PUBLIC_DIR, "index.html"));
|
|
887
1411
|
}
|
|
@@ -1068,7 +1592,8 @@ async function handleRequest(
|
|
|
1068
1592
|
// route; it reports them here so the activity trail shows failed attempts.
|
|
1069
1593
|
// Agents only (the audit header), and the entry is all it can write.
|
|
1070
1594
|
if (pathname === "/api/activity" && req.method === "POST") {
|
|
1071
|
-
|
|
1595
|
+
// An agent's token, or (unverified) an MCP server's audit headers.
|
|
1596
|
+
const actor = auditActorOf(req);
|
|
1072
1597
|
if (!actor) return sendJson(res, { error: "agents only" }, 403);
|
|
1073
1598
|
const raw = await readBody(req).catch(() => "");
|
|
1074
1599
|
if (raw.length > 4096) return send400(res, "report too large");
|
|
@@ -1091,11 +1616,11 @@ async function handleRequest(
|
|
|
1091
1616
|
}
|
|
1092
1617
|
// A call into a granted workspace (AG-2a): both workspaces recorded.
|
|
1093
1618
|
if (report.crossWorkspace !== undefined) {
|
|
1094
|
-
const crossed = recordCrossWorkspace(actor, report);
|
|
1619
|
+
const crossed = recordCrossWorkspace(actor, report, principalOf(req));
|
|
1095
1620
|
if (!crossed) return send400(res, "not a cross-workspace report");
|
|
1096
1621
|
return sendJson(res, { recorded: true, ts: crossed.ts });
|
|
1097
1622
|
}
|
|
1098
|
-
const entry = recordAgentRefusal(actor, report);
|
|
1623
|
+
const entry = recordAgentRefusal(actor, report, principalOf(req));
|
|
1099
1624
|
return sendJson(res, { recorded: true, ts: entry.ts });
|
|
1100
1625
|
}
|
|
1101
1626
|
|
|
@@ -1172,11 +1697,11 @@ async function handleRequest(
|
|
|
1172
1697
|
|
|
1173
1698
|
// ── API: health check ─────────────────────────────────────────────────────
|
|
1174
1699
|
/**
|
|
1175
|
-
* Workspaces a health run covers: one named workspace, else
|
|
1176
|
-
*
|
|
1700
|
+
* Workspaces a health run covers: one named workspace, else every active
|
|
1701
|
+
* one, focus first, whatever the view scope (WS-6.4). Health is
|
|
1177
1702
|
* machine-level: a service running under a non-focused active workspace
|
|
1178
|
-
* is still up, and
|
|
1179
|
-
*
|
|
1703
|
+
* is still up, and the Workspaces page must say so for any workspace.
|
|
1704
|
+
* The scope filters what is displayed: each row's `inView` (forViewer).
|
|
1180
1705
|
*/
|
|
1181
1706
|
function healthWorkspaces(
|
|
1182
1707
|
workspaceName: string | null,
|
|
@@ -1185,7 +1710,7 @@ async function handleRequest(
|
|
|
1185
1710
|
const named = getWorkspaceByName(workspaceName);
|
|
1186
1711
|
return named ? [named] : [];
|
|
1187
1712
|
}
|
|
1188
|
-
return
|
|
1713
|
+
return pollView().workspaces.filter((w) => w.config);
|
|
1189
1714
|
}
|
|
1190
1715
|
|
|
1191
1716
|
/**
|
|
@@ -1543,7 +2068,7 @@ async function handleRequest(
|
|
|
1543
2068
|
// single-service `?address=` probe always runs live: it exists to re-check one
|
|
1544
2069
|
// node on demand, where staleness is the whole thing being avoided.
|
|
1545
2070
|
if (pathname === "/api/health") {
|
|
1546
|
-
const forViewer =
|
|
2071
|
+
const forViewer = healthForViewer(req.headers.host);
|
|
1547
2072
|
const env = (url.searchParams.get("env") ?? "local") as Environment;
|
|
1548
2073
|
const addressFilter = url.searchParams.get("address");
|
|
1549
2074
|
const wsParam = url.searchParams.get("workspace");
|
|
@@ -1572,7 +2097,7 @@ async function handleRequest(
|
|
|
1572
2097
|
// Same checks, streamed: each result is sent the moment it lands so the fast
|
|
1573
2098
|
// ones paint immediately instead of every dot waiting on the slowest.
|
|
1574
2099
|
if (pathname === "/api/health/stream") {
|
|
1575
|
-
const forViewer =
|
|
2100
|
+
const forViewer = healthForViewer(req.headers.host);
|
|
1576
2101
|
const env = (url.searchParams.get("env") ?? "local") as Environment;
|
|
1577
2102
|
const wsParam = url.searchParams.get("workspace");
|
|
1578
2103
|
if (wsParam && !getWorkspaceByName(wsParam))
|
|
@@ -1657,12 +2182,16 @@ async function handleRequest(
|
|
|
1657
2182
|
// Every fail here is before anything ran: a refusal or a lookup miss.
|
|
1658
2183
|
// Recorded, so failed attempts show in the activity trail (F2).
|
|
1659
2184
|
const fail = (msg: string): void => {
|
|
1660
|
-
recordAction(
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2185
|
+
recordAction(
|
|
2186
|
+
auditActorOf(req),
|
|
2187
|
+
{
|
|
2188
|
+
verb: "exec",
|
|
2189
|
+
detail: `${repoName}/${svcName} ${cmdName} (${env})`,
|
|
2190
|
+
outcome: "refused",
|
|
2191
|
+
reason: msg,
|
|
2192
|
+
},
|
|
2193
|
+
principalOf(req),
|
|
2194
|
+
);
|
|
1666
2195
|
emit({ type: "error", text: msg });
|
|
1667
2196
|
emit({ type: "done", exitCode: 1, duration: 0 });
|
|
1668
2197
|
res.end();
|
|
@@ -1698,8 +2227,11 @@ async function handleRequest(
|
|
|
1698
2227
|
);
|
|
1699
2228
|
if (refusal) return fail(refusal);
|
|
1700
2229
|
if (manifest) {
|
|
1701
|
-
cmdDef =
|
|
1702
|
-
manifest.services?.[resolved.subsystem]?.commands
|
|
2230
|
+
cmdDef = declaredCommand(
|
|
2231
|
+
manifest.services?.[resolved.subsystem]?.commands,
|
|
2232
|
+
env,
|
|
2233
|
+
cmdName,
|
|
2234
|
+
);
|
|
1703
2235
|
}
|
|
1704
2236
|
// Lifecycle commands run through the service runner, as the CLI's do:
|
|
1705
2237
|
// restart without a declared restart is down + up, up on a healthy
|
|
@@ -1712,11 +2244,15 @@ async function handleRequest(
|
|
|
1712
2244
|
);
|
|
1713
2245
|
const execId = shortRunId();
|
|
1714
2246
|
const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
|
|
1715
|
-
const execActor =
|
|
1716
|
-
recordAction(
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
2247
|
+
const execActor = auditActorOf(req);
|
|
2248
|
+
recordAction(
|
|
2249
|
+
execActor,
|
|
2250
|
+
{
|
|
2251
|
+
verb: "exec",
|
|
2252
|
+
detail: `${execDesc} [${execId}]`,
|
|
2253
|
+
},
|
|
2254
|
+
principalOf(req),
|
|
2255
|
+
);
|
|
1720
2256
|
prewarmSudo();
|
|
1721
2257
|
void streamLifecycleExec(
|
|
1722
2258
|
{
|
|
@@ -1771,7 +2307,11 @@ async function handleRequest(
|
|
|
1771
2307
|
)
|
|
1772
2308
|
: null;
|
|
1773
2309
|
if (refusal) return fail(refusal);
|
|
1774
|
-
cmdDef =
|
|
2310
|
+
cmdDef = declaredCommand(
|
|
2311
|
+
repo?.manifest.services?.[svcName]?.commands,
|
|
2312
|
+
env,
|
|
2313
|
+
cmdName,
|
|
2314
|
+
);
|
|
1775
2315
|
}
|
|
1776
2316
|
|
|
1777
2317
|
if (!cmdDef || !repoPath) {
|
|
@@ -1817,11 +2357,15 @@ async function handleRequest(
|
|
|
1817
2357
|
|
|
1818
2358
|
const execId = shortRunId();
|
|
1819
2359
|
const execDesc = `${repoName}/${svcName} ${cmdName} (${env})`;
|
|
1820
|
-
const execActor =
|
|
1821
|
-
recordAction(
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
2360
|
+
const execActor = auditActorOf(req);
|
|
2361
|
+
recordAction(
|
|
2362
|
+
execActor,
|
|
2363
|
+
{
|
|
2364
|
+
verb: "exec",
|
|
2365
|
+
detail: `${execDesc} [${execId}]`,
|
|
2366
|
+
},
|
|
2367
|
+
principalOf(req),
|
|
2368
|
+
);
|
|
1825
2369
|
|
|
1826
2370
|
// For detachable commands (up/restart), the service is spawned as a daemon
|
|
1827
2371
|
// writing to a log file so it survives den restarts. The tail proc
|
|
@@ -2448,6 +2992,17 @@ async function handleRequest(
|
|
|
2448
2992
|
}
|
|
2449
2993
|
|
|
2450
2994
|
if (!pillar) return send400(res, "pillar required");
|
|
2995
|
+
// SEC-1 §6b.2: a run takes the ids of declared modules, never a path. A
|
|
2996
|
+
// module that climbs out (`..`), is absolute, or reads as a flag would
|
|
2997
|
+
// point the runner at a script of the caller's choosing.
|
|
2998
|
+
const badModule = modules.find(
|
|
2999
|
+
(m) => !SAFE_MODULE_RE.test(m) || m.split("/").includes(".."),
|
|
3000
|
+
);
|
|
3001
|
+
if (badModule !== undefined)
|
|
3002
|
+
return send400(
|
|
3003
|
+
res,
|
|
3004
|
+
`module ${JSON.stringify(badModule.slice(0, 80))} is not a module name`,
|
|
3005
|
+
);
|
|
2451
3006
|
|
|
2452
3007
|
// Resolve declarative optional fields → per-module env. Only the perf pillar
|
|
2453
3008
|
// declares optionalFields; for every other pillar this is an empty map.
|
|
@@ -2535,8 +3090,12 @@ async function handleRequest(
|
|
|
2535
3090
|
const runId = shortRunId();
|
|
2536
3091
|
const modDesc = modules.length ? modules.join(",") : "all";
|
|
2537
3092
|
const runDesc = `${pillar}/${env} · modules=${modDesc}`;
|
|
2538
|
-
const runActor =
|
|
2539
|
-
recordAction(
|
|
3093
|
+
const runActor = auditActorOf(req);
|
|
3094
|
+
recordAction(
|
|
3095
|
+
runActor,
|
|
3096
|
+
{ verb: "run", detail: `${runDesc} [${runId}]` },
|
|
3097
|
+
principalOf(req),
|
|
3098
|
+
);
|
|
2540
3099
|
if (
|
|
2541
3100
|
pillar === "synthetic" ||
|
|
2542
3101
|
pillar === "smoke" ||
|
|
@@ -2709,6 +3268,110 @@ async function handleRequest(
|
|
|
2709
3268
|
}
|
|
2710
3269
|
|
|
2711
3270
|
// ── API: server info + staleness (drives the header "stale server" pill) ────
|
|
3271
|
+
// ── API: the browser's session (SEC-1a, SEC-1b) ──────────────────────────
|
|
3272
|
+
// The CSRF token the shell sends on mutating calls (X-Den-CSRF), for a
|
|
3273
|
+
// same-origin caller only: the session's own when signed in
|
|
3274
|
+
// (actor "person-browser"); else, on loopback, this server start's.
|
|
3275
|
+
// Beyond loopback without a session there is none to hand out (every
|
|
3276
|
+
// route there needs a session first).
|
|
3277
|
+
if (pathname === "/api/session" && req.method === "GET") {
|
|
3278
|
+
if (!sessionRequestAllowed(req, GUARD))
|
|
3279
|
+
return sendJson(
|
|
3280
|
+
res,
|
|
3281
|
+
{
|
|
3282
|
+
ok: false,
|
|
3283
|
+
error: "same-origin callers only",
|
|
3284
|
+
code: "origin_rejected",
|
|
3285
|
+
},
|
|
3286
|
+
403,
|
|
3287
|
+
);
|
|
3288
|
+
res.setHeader("Cache-Control", "no-store");
|
|
3289
|
+
const g = guard.ok ? guard : null;
|
|
3290
|
+
// An agent's token gets no browser CSRF token: it has its bearer.
|
|
3291
|
+
if (g?.principal?.kind === "agent")
|
|
3292
|
+
return sendJson(res, {
|
|
3293
|
+
csrf: null,
|
|
3294
|
+
actor: g.principal.actor,
|
|
3295
|
+
signedIn: false,
|
|
3296
|
+
});
|
|
3297
|
+
if (g?.session)
|
|
3298
|
+
return sendJson(res, {
|
|
3299
|
+
csrf: g.session.csrf,
|
|
3300
|
+
actor: "person-browser",
|
|
3301
|
+
signedIn: true,
|
|
3302
|
+
});
|
|
3303
|
+
return sendJson(res, {
|
|
3304
|
+
csrf: g?.beyond ? null : GUARD.csrfToken,
|
|
3305
|
+
actor: null,
|
|
3306
|
+
signedIn: false,
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
// Sign out: this browser's session ends and its cookie is cleared.
|
|
3310
|
+
if (pathname === "/api/session/logout" && req.method === "POST") {
|
|
3311
|
+
const g = guard.ok ? guard : null;
|
|
3312
|
+
const ended = g?.session ? webSessions.end(g.session.key) : false;
|
|
3313
|
+
res.setHeader(
|
|
3314
|
+
"Set-Cookie",
|
|
3315
|
+
clearSessionCookie(requestIsHttps(req, g?.via ?? null)),
|
|
3316
|
+
);
|
|
3317
|
+
return sendJson(res, { ok: true, ended });
|
|
3318
|
+
}
|
|
3319
|
+
// `<cli> web sessions` (shell-grade: the person's bearer, or a session).
|
|
3320
|
+
if (pathname === "/api/web/sessions" && req.method === "GET") {
|
|
3321
|
+
const mine = guard.ok ? guard.session?.key : undefined;
|
|
3322
|
+
return sendJson(res, {
|
|
3323
|
+
sessions: webSessions.list().map((x) => ({
|
|
3324
|
+
id: sessionIdPrefix(x.key),
|
|
3325
|
+
createdAt: new Date(x.createdAt).toISOString(),
|
|
3326
|
+
lastSeenAt: new Date(x.lastSeenAt).toISOString(),
|
|
3327
|
+
userAgent: x.userAgent,
|
|
3328
|
+
host: x.host,
|
|
3329
|
+
...(x.key === mine ? { current: true } : {}),
|
|
3330
|
+
})),
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
3333
|
+
// `<cli> web logout [<id>] [--all]` (shell-grade). `rotate` (--all) also
|
|
3334
|
+
// replaces web.secret: every session and person token derived from the
|
|
3335
|
+
// old one stops verifying; the CLI re-reads the file, so it keeps working.
|
|
3336
|
+
// And (SEC-1 §6c.2) it revokes every agent's actor token: a running
|
|
3337
|
+
// agent's next call gets 401 token_revoked. One browser's or every
|
|
3338
|
+
// browser's logout without --all leaves agent tokens alone.
|
|
3339
|
+
if (pathname === "/api/web/logout" && req.method === "POST") {
|
|
3340
|
+
let body: { id?: unknown; rotate?: unknown } = {};
|
|
3341
|
+
try {
|
|
3342
|
+
body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
|
|
3343
|
+
} catch {
|
|
3344
|
+
return send400(res, "the body must be JSON: { id?, rotate? }");
|
|
3345
|
+
}
|
|
3346
|
+
if (body.id !== undefined && typeof body.id !== "string")
|
|
3347
|
+
return send400(res, "id must be a session id prefix");
|
|
3348
|
+
try {
|
|
3349
|
+
const ended =
|
|
3350
|
+
typeof body.id === "string"
|
|
3351
|
+
? [sessionIdPrefix(webSessions.endByPrefix(body.id).key)]
|
|
3352
|
+
: webSessions.list().map((x) => sessionIdPrefix(x.key));
|
|
3353
|
+
if (typeof body.id !== "string") webSessions.endAll();
|
|
3354
|
+
const rotated = body.rotate === true;
|
|
3355
|
+
let agentTokens = 0;
|
|
3356
|
+
if (rotated) {
|
|
3357
|
+
rotateWebSecret();
|
|
3358
|
+
agentTokens = revokeAllActorTokens(getStateDir());
|
|
3359
|
+
}
|
|
3360
|
+
return sendJson(res, {
|
|
3361
|
+
ok: true,
|
|
3362
|
+
ended,
|
|
3363
|
+
rotated,
|
|
3364
|
+
...(rotated ? { agentTokens } : {}),
|
|
3365
|
+
});
|
|
3366
|
+
} catch (e) {
|
|
3367
|
+
return sendJson(
|
|
3368
|
+
res,
|
|
3369
|
+
{ ok: false, error: (e as Error).message, code: "not_found" },
|
|
3370
|
+
404,
|
|
3371
|
+
);
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
|
|
2712
3375
|
if (pathname === "/api/server-info" && req.method === "GET") {
|
|
2713
3376
|
const { stale, scanned } = computeStale();
|
|
2714
3377
|
const engine = engineCheck();
|
|
@@ -3034,6 +3697,20 @@ async function handleRequest(
|
|
|
3034
3697
|
return;
|
|
3035
3698
|
}
|
|
3036
3699
|
|
|
3700
|
+
// ── Agents on the Workspaces page (AG-4a, agent-routes.ts) ───────────────
|
|
3701
|
+
if (await handleAgentRoutes(req, res, pathname)) return;
|
|
3702
|
+
// ── The Agents tab's list (AG-4a-term, session-routes.ts) ────────────────
|
|
3703
|
+
// Beyond loopback a terminal needs a signed-in session (or a bearer).
|
|
3704
|
+
if (
|
|
3705
|
+
await handleSessionList(
|
|
3706
|
+
req,
|
|
3707
|
+
res,
|
|
3708
|
+
pathname,
|
|
3709
|
+
guard.ok && (!guard.beyond || !!guard.session || !!guard.principal),
|
|
3710
|
+
)
|
|
3711
|
+
)
|
|
3712
|
+
return;
|
|
3713
|
+
|
|
3037
3714
|
// ── Workspace routes (extracted to workspace-routes.ts) ──────────────────
|
|
3038
3715
|
await handleWorkspaceRoutes(req, res, pathname, url, editors, github);
|
|
3039
3716
|
if (res.headersSent) return;
|
|
@@ -3630,7 +4307,8 @@ const server = http.createServer((req, res) => {
|
|
|
3630
4307
|
// An async handler with no catch turns any route throw into an unhandled
|
|
3631
4308
|
// rejection — which CRASHES the whole server on modern Node (freezing every
|
|
3632
4309
|
// health dot) and leaves the request hanging. Fail the one request instead.
|
|
3633
|
-
|
|
4310
|
+
// Each request in its own lock scope (SEC-1c: an agent's stays shut).
|
|
4311
|
+
inRequestLockScope(() => handleRequest(req, res)).catch((err: unknown) => {
|
|
3634
4312
|
// R26: the log has the stack under the request id; the browser gets the
|
|
3635
4313
|
// id, never the stack. The route is its template: no query values.
|
|
3636
4314
|
const requestId = requestIdOf(req);
|
|
@@ -3712,6 +4390,49 @@ const server = http.createServer((req, res) => {
|
|
|
3712
4390
|
});
|
|
3713
4391
|
});
|
|
3714
4392
|
|
|
4393
|
+
// WebSocket upgrades pass the same checks (SEC-1 §3: Origin on every
|
|
4394
|
+
// upgrade; CSRF as the `den.csrf.<token>` subprotocol). One route takes
|
|
4395
|
+
// one: a session's io (AG-4a-term, session-routes.ts); any other passing
|
|
4396
|
+
// upgrade answers 404.
|
|
4397
|
+
server.on(
|
|
4398
|
+
"upgrade",
|
|
4399
|
+
(req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
|
|
4400
|
+
socket.on("error", () => undefined);
|
|
4401
|
+
const requestId = requestIdOf(req);
|
|
4402
|
+
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
|
4403
|
+
const guard = checkRequest(req, GUARD, product().cli, product().title);
|
|
4404
|
+
const refuse = (status: number, error: string, code: string) =>
|
|
4405
|
+
refuseUpgrade(
|
|
4406
|
+
socket,
|
|
4407
|
+
status,
|
|
4408
|
+
{ ok: false, error, code, requestId },
|
|
4409
|
+
http.STATUS_CODES[status] ?? "",
|
|
4410
|
+
);
|
|
4411
|
+
if (!guard.ok) {
|
|
4412
|
+
serverLog(
|
|
4413
|
+
` ${logTimestamp()} ${bold("WS ")} ${pathname} → ${logStatus(guard.status)}` +
|
|
4414
|
+
dim(` req ${requestId}`),
|
|
4415
|
+
"request",
|
|
4416
|
+
);
|
|
4417
|
+
return refuse(guard.status, guard.error, guard.code);
|
|
4418
|
+
}
|
|
4419
|
+
setPrincipal(req, guard.principal);
|
|
4420
|
+
if (!isSessionIoPath(pathname))
|
|
4421
|
+
return refuse(404, "No WebSocket route here", "not_found");
|
|
4422
|
+
handleSessionUpgrade(req, socket, head, pathname, requestId).catch(
|
|
4423
|
+
(err: unknown) => {
|
|
4424
|
+
serverLog(
|
|
4425
|
+
` ✗ WS ${routeTemplate(pathname)} failed (req ${requestId}): ${
|
|
4426
|
+
err instanceof Error ? err.message : String(err)
|
|
4427
|
+
}`,
|
|
4428
|
+
"error",
|
|
4429
|
+
);
|
|
4430
|
+
socket.destroy();
|
|
4431
|
+
},
|
|
4432
|
+
);
|
|
4433
|
+
},
|
|
4434
|
+
);
|
|
4435
|
+
|
|
3715
4436
|
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
3716
4437
|
if (err.code === "EADDRINUSE") {
|
|
3717
4438
|
console.error(
|
|
@@ -3801,10 +4522,14 @@ setInterval(sweepServiceLogs, LOG_SWEEP_INTERVAL_MS).unref();
|
|
|
3801
4522
|
// timers — code the per-request catch can't see) would kill the whole
|
|
3802
4523
|
// dashboard; a TDZ bug in a down-flow callback did exactly that. For a local
|
|
3803
4524
|
// dev server, staying alive degraded beats dying — log loudly and continue.
|
|
4525
|
+
// Never log a lost-output error, or anything once the output is gone: that
|
|
4526
|
+
// write fails too, and looped (shutdown-guard.ts).
|
|
3804
4527
|
process.on("uncaughtException", (err) => {
|
|
4528
|
+
if (isLostOutputError(err) || outputLost()) return;
|
|
3805
4529
|
console.error("[server] uncaught exception (continuing):", err);
|
|
3806
4530
|
});
|
|
3807
4531
|
process.on("unhandledRejection", (reason) => {
|
|
4532
|
+
if (isLostOutputError(reason) || outputLost()) return;
|
|
3808
4533
|
console.error("[server] unhandled rejection (continuing):", reason);
|
|
3809
4534
|
});
|
|
3810
4535
|
|
|
@@ -3818,10 +4543,35 @@ try {
|
|
|
3818
4543
|
serverLog(` ⚠ cache warm-up failed: ${(e as Error).message}`);
|
|
3819
4544
|
}
|
|
3820
4545
|
|
|
3821
|
-
//
|
|
3822
|
-
//
|
|
3823
|
-
//
|
|
3824
|
-
|
|
4546
|
+
// Ignored config keys (a distribution's web.allow_plain_http_remote, the
|
|
4547
|
+
// old web.session lifetimes): the in-app log, and stderr, which is web.log
|
|
4548
|
+
// even when `web up` starts this --silent. Before the bind refusal, which
|
|
4549
|
+
// such a key often explains.
|
|
4550
|
+
{
|
|
4551
|
+
const ignored = ignoredConfigWarning(WEB_AUTH);
|
|
4552
|
+
if (ignored) {
|
|
4553
|
+
pushLog("warn", ` ⚠ ${ignored}`);
|
|
4554
|
+
if (!outputLost()) process.stderr.write(` ⚠ ${ignored}\n`);
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4557
|
+
// §4: a non-loopback bind needs HTTPS in front, or the person's say-so.
|
|
4558
|
+
{
|
|
4559
|
+
const refusal = bindRefusal(BIND_HOST, {
|
|
4560
|
+
allowPlainHttpRemote: WEB_AUTH.allowPlainHttpRemote,
|
|
4561
|
+
fronted: edgeFronted(),
|
|
4562
|
+
});
|
|
4563
|
+
if (refusal) {
|
|
4564
|
+
console.error(`\n ✗ ${refusal}\n`);
|
|
4565
|
+
process.exit(1);
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4568
|
+
// SEC-1a: the secret the CLI's person token is derived from (0600 in the
|
|
4569
|
+
// state dir). Never printed or logged; a failure leaves bearers unverifiable.
|
|
4570
|
+
try {
|
|
4571
|
+
webSecret.ensure();
|
|
4572
|
+
} catch (e) {
|
|
4573
|
+
serverLog(` ⚠ web.secret could not be created: ${(e as Error).message}`);
|
|
4574
|
+
}
|
|
3825
4575
|
server.listen(PORT, BIND_HOST, () => {
|
|
3826
4576
|
const hosts = fs.existsSync("/etc/hosts")
|
|
3827
4577
|
? fs.readFileSync("/etc/hosts", "utf-8")
|
|
@@ -3941,7 +4691,8 @@ async function handleShutdown(signal: string): Promise<void> {
|
|
|
3941
4691
|
process.exit(0);
|
|
3942
4692
|
}
|
|
3943
4693
|
|
|
3944
|
-
|
|
4694
|
+
// Lost output (SIGPIPE): nobody can answer a prompt.
|
|
4695
|
+
const isTTY = process.stdin.isTTY && signal !== "SIGPIPE";
|
|
3945
4696
|
|
|
3946
4697
|
if (!isTTY) {
|
|
3947
4698
|
// Non-interactive shutdown usually means `den web` is being restarted.
|
|
@@ -3981,10 +4732,8 @@ async function handleShutdown(signal: string): Promise<void> {
|
|
|
3981
4732
|
}
|
|
3982
4733
|
|
|
3983
4734
|
/**
|
|
3984
|
-
* Run the shutdown for `signal`. A failure still ends the process
|
|
3985
|
-
*
|
|
3986
|
-
* the server running and deaf to SIGTERM (a service manager then waits out
|
|
3987
|
-
* its timeout and SIGKILLs it).
|
|
4735
|
+
* Run the shutdown for `signal`. A failure still ends the process, at once
|
|
4736
|
+
* rather than at the deadline.
|
|
3988
4737
|
*/
|
|
3989
4738
|
function shutdownOn(signal: string): void {
|
|
3990
4739
|
handleShutdown(signal).catch((e: unknown) => {
|
|
@@ -3993,16 +4742,22 @@ function shutdownOn(signal: string): void {
|
|
|
3993
4742
|
});
|
|
3994
4743
|
}
|
|
3995
4744
|
|
|
3996
|
-
|
|
4745
|
+
/**
|
|
4746
|
+
* Start the shutdown once; a second signal exits at once, and a shutdown
|
|
4747
|
+
* nobody waits on (stdin isn't a TTY) is forced to exit after a deadline
|
|
4748
|
+
* (shutdown-guard.ts).
|
|
4749
|
+
*/
|
|
4750
|
+
function beginShutdown(signal: string): void {
|
|
4751
|
+
requestShutdown(signal, shutdownOn, {
|
|
4752
|
+
interactive: Boolean(process.stdin.isTTY) && signal !== "SIGPIPE",
|
|
4753
|
+
});
|
|
4754
|
+
}
|
|
4755
|
+
|
|
3997
4756
|
process.on("SIGINT", () => {
|
|
3998
|
-
if (
|
|
3999
|
-
|
|
4000
|
-
console.log(""); // newline after ^C
|
|
4001
|
-
shutdownOn("SIGINT");
|
|
4757
|
+
if (!outputLost()) console.log(""); // newline after ^C
|
|
4758
|
+
beginShutdown("SIGINT");
|
|
4002
4759
|
});
|
|
4003
4760
|
|
|
4004
4761
|
process.on("SIGTERM", () => {
|
|
4005
|
-
|
|
4006
|
-
_shuttingDown = true;
|
|
4007
|
-
shutdownOn("SIGTERM");
|
|
4762
|
+
beginShutdown("SIGTERM");
|
|
4008
4763
|
});
|