@monster-orange/den 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +348 -0
- package/README.md +12 -11
- package/docs/distributions.md +438 -91
- package/docs/reference.md +8 -3
- package/package.json +1 -1
- package/src/shared/agents/catalog-runtime.ts +178 -0
- package/src/shared/agents/catalog.ts +343 -0
- package/src/shared/agents/den-claude.ts +14 -18
- package/src/shared/agents/runtime-search.ts +142 -15
- package/src/shared/agents/runtime.ts +18 -1
- package/src/shared/agents/runtimes.ts +23 -1
- package/src/shared/auth/sudo-vault.ts +131 -11
- package/src/shared/cli-command.ts +186 -0
- package/src/shared/confirm-code.ts +34 -0
- package/src/shared/den-config.ts +64 -3
- package/src/shared/dev-metrics/emit.ts +31 -1
- package/src/shared/edge/config.ts +39 -0
- package/src/shared/edge/render.ts +92 -18
- package/src/shared/edge/runtime.ts +57 -1
- package/src/shared/edge/status.ts +134 -0
- package/src/shared/edge/types.ts +11 -1
- package/src/shared/environment-manager/health.ts +82 -8
- package/src/shared/environment-manager/workspaces.ts +54 -0
- package/src/shared/github-app.ts +94 -0
- package/src/shared/github-device.ts +286 -0
- package/src/shared/health/not-checked.ts +42 -0
- package/src/shared/health/rollup.ts +4 -0
- package/src/shared/process/spawn-streaming.ts +8 -3
- package/src/shared/secrets/onepassword.ts +27 -4
- package/src/shared/sessions/client.ts +5 -1
- package/src/shared/sessions/host.ts +29 -1
- package/src/shared/sessions/protocol.ts +6 -1
- package/src/shared/sessions/types.ts +6 -0
- package/src/shared/stacks/status.ts +24 -0
- package/src/shared/strategies/registry.ts +5 -0
- package/src/shared/types/index.ts +6 -0
- package/src/shared/web-owner.ts +91 -0
- package/src/shared/web-session.ts +135 -1
- package/src/ui/cli/agent-ops.ts +210 -19
- package/src/ui/cli/agents-command.ts +50 -21
- package/src/ui/cli/edge-command.ts +16 -3
- package/src/ui/cli/flag-parser.ts +1 -1
- package/src/ui/cli/health-display.ts +4 -1
- package/src/ui/cli/web-command.ts +512 -33
- package/src/ui/mcp/scope.ts +23 -3
- package/src/ui/mcp/server.ts +31 -7
- package/src/ui/mcp/tools.ts +327 -20
- package/src/ui/mcp/views.ts +450 -0
- package/src/ui/server/agent-routes.ts +118 -28
- package/src/ui/server/device-flows.ts +274 -0
- package/src/ui/server/edge-exposure.ts +16 -4
- package/src/ui/server/github-routes.ts +321 -0
- package/src/ui/server/http-errors.ts +10 -0
- package/src/ui/server/index.ts +540 -33
- package/src/ui/server/login-waits.ts +203 -0
- package/src/ui/server/pair-requests.ts +237 -0
- package/src/ui/server/pair-routes.ts +196 -0
- package/src/ui/server/public/agent-start.js +46 -7
- package/src/ui/server/public/agents-page.js +232 -97
- package/src/ui/server/public/agents-section.js +3 -5
- package/src/ui/server/public/agents.html +12 -28
- package/src/ui/server/public/error-page.js +452 -40
- package/src/ui/server/public/error.html +59 -0
- package/src/ui/server/public/graph-model.js +2 -0
- package/src/ui/server/public/header.js +2 -3
- package/src/ui/server/public/health-rollup.js +20 -8
- package/src/ui/server/public/i18n/es.js +89 -21
- package/src/ui/server/public/i18n/ja.js +88 -21
- package/src/ui/server/public/index.html +17 -4
- package/src/ui/server/public/local-dev.html +44 -21
- package/src/ui/server/public/qr.js +317 -0
- package/src/ui/server/public/services.html +21 -5
- package/src/ui/server/public/settings-page.js +10 -2
- package/src/ui/server/public/settings-registry.js +617 -1
- package/src/ui/server/public/settings.html +90 -1
- package/src/ui/server/public/shell.js +151 -33
- package/src/ui/server/public/topbar.js +170 -206
- package/src/ui/server/public/workspace-picker.js +1 -1
- package/src/ui/server/readonly-routes.ts +29 -5
- package/src/ui/server/request-guard.ts +103 -36
- package/src/ui/server/route-classes.ts +116 -4
- package/src/ui/server/session-routes.ts +9 -1
- package/src/ui/server/sudo-store.ts +170 -6
- package/src/ui/server/ui-assets.ts +2 -0
- package/src/ui/server/workspace-routes.ts +10 -3
package/src/ui/server/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type ContainerHealthTarget,
|
|
18
18
|
} from "../../shared/stacks/compose-health.js";
|
|
19
19
|
import { healthCheckFields, type HealthCheckType } from "./health-row.js";
|
|
20
|
+
import { notCheckedReason } from "../../shared/health/not-checked.js";
|
|
20
21
|
import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
|
|
21
22
|
import * as http from "http";
|
|
22
23
|
import * as net from "net";
|
|
@@ -162,6 +163,7 @@ import {
|
|
|
162
163
|
saveSudoToVault,
|
|
163
164
|
sudoRef,
|
|
164
165
|
sudoVaultStatus,
|
|
166
|
+
sudoVaultWhere,
|
|
165
167
|
SUDO_REF_NAME,
|
|
166
168
|
} from "../../shared/auth/sudo-vault.js";
|
|
167
169
|
import {
|
|
@@ -209,8 +211,13 @@ import {
|
|
|
209
211
|
} from "../../shared/actor-token.js";
|
|
210
212
|
import {
|
|
211
213
|
checkLoginCode,
|
|
214
|
+
deviceName,
|
|
215
|
+
loginCodeNonce,
|
|
212
216
|
ignoredConfigWarning,
|
|
213
217
|
localhostOrigin,
|
|
218
|
+
loginLink,
|
|
219
|
+
LOGIN_CODE_TTL_MS,
|
|
220
|
+
mintLoginCode,
|
|
214
221
|
readWebAuthConfig,
|
|
215
222
|
safeNext,
|
|
216
223
|
SESSION_COOKIE_MAX_AGE_S,
|
|
@@ -220,6 +227,32 @@ import {
|
|
|
220
227
|
type WebAuthConfig,
|
|
221
228
|
} from "../../shared/web-session.js";
|
|
222
229
|
import { serveUiAsset } from "./ui-assets.js";
|
|
230
|
+
import {
|
|
231
|
+
expiringTokenWarning,
|
|
232
|
+
githubClientId,
|
|
233
|
+
githubEndpoints,
|
|
234
|
+
noClientIdMessage,
|
|
235
|
+
} from "../../shared/github-app.js";
|
|
236
|
+
import { githubDeviceClient } from "../../shared/github-device.js";
|
|
237
|
+
import { readWebOwner } from "../../shared/web-owner.js";
|
|
238
|
+
import { DeviceFlows } from "./device-flows.js";
|
|
239
|
+
import {
|
|
240
|
+
decideFlow,
|
|
241
|
+
handleGithubRoutes,
|
|
242
|
+
isGithubRoute,
|
|
243
|
+
} from "./github-routes.js";
|
|
244
|
+
import { PairRequests } from "./pair-requests.js";
|
|
245
|
+
import { LoginWaits } from "./login-waits.js";
|
|
246
|
+
import { handlePairRoutes, isPairRoute } from "./pair-routes.js";
|
|
247
|
+
import {
|
|
248
|
+
cliCommand,
|
|
249
|
+
cliPrefix,
|
|
250
|
+
isIpLiteral,
|
|
251
|
+
machineLabel,
|
|
252
|
+
parseMachineName,
|
|
253
|
+
sshCommand,
|
|
254
|
+
sshTarget,
|
|
255
|
+
} from "../../shared/cli-command.js";
|
|
223
256
|
import { graphPrefs, patchStoredGraph } from "./graph-prefs.js";
|
|
224
257
|
import { graphPageHtml } from "./graph-page.js";
|
|
225
258
|
import { mergeShellPrefs, shellPrefs, storedShell } from "./shell-prefs.js";
|
|
@@ -275,6 +308,10 @@ interface HealthResult {
|
|
|
275
308
|
pending?: boolean;
|
|
276
309
|
status?: number;
|
|
277
310
|
error?: string;
|
|
311
|
+
/** A 3xx answer's target (MCP-2.4): ok null, not down. */
|
|
312
|
+
redirect?: string;
|
|
313
|
+
/** With ok null and nothing checked: why not (MCP-2.4). */
|
|
314
|
+
why?: string;
|
|
278
315
|
/** Where the app opens for this env (open-in-new-tab), or null. */
|
|
279
316
|
openUrl?: string | null;
|
|
280
317
|
/** Extra named links for this env. */
|
|
@@ -556,7 +593,7 @@ setHtmlFill((html) => {
|
|
|
556
593
|
out = out.replace(
|
|
557
594
|
'<div id="den-shell"',
|
|
558
595
|
() =>
|
|
559
|
-
`<div id="den-shell" data-edge-exposed="${escAttr(
|
|
596
|
+
`<div id="den-shell" data-edge-exposed="${escAttr(edgeExposedMessage(WEB_AUTH.edgeUnattested))}"`,
|
|
560
597
|
);
|
|
561
598
|
// MD-2: every page says when the engine on disk is not the one running.
|
|
562
599
|
const engine = engineCheck();
|
|
@@ -716,12 +753,14 @@ const CONFIG_FREE_PAGES = new Set(["/settings", "/settings.html", "/logs"]);
|
|
|
716
753
|
import {
|
|
717
754
|
setSudoPassword,
|
|
718
755
|
clearSudoPassword,
|
|
756
|
+
clearSudoShims,
|
|
719
757
|
closeLockForThisRequest,
|
|
720
758
|
inRequestLockScope,
|
|
721
759
|
getSudoEnv,
|
|
722
760
|
prewarmSudo,
|
|
723
761
|
hasSudoPassword,
|
|
724
762
|
hasPasswordlessSudo,
|
|
763
|
+
watchSudo,
|
|
725
764
|
} from "./sudo-store.js";
|
|
726
765
|
import {
|
|
727
766
|
guardStdio,
|
|
@@ -730,6 +769,9 @@ import {
|
|
|
730
769
|
requestShutdown,
|
|
731
770
|
} from "./shutdown-guard.js";
|
|
732
771
|
|
|
772
|
+
/** This server saved the admin password to the vault or loaded it from there. */
|
|
773
|
+
let sudoInVault = false;
|
|
774
|
+
|
|
733
775
|
// ── HTTP helpers ─────────────────────────────────────────────────────────────
|
|
734
776
|
import { sweepLogs } from "../../shared/process/log-rotation.js";
|
|
735
777
|
import {
|
|
@@ -788,7 +830,7 @@ import {
|
|
|
788
830
|
webRouteHosts,
|
|
789
831
|
} from "../../shared/edge/render.js";
|
|
790
832
|
import {
|
|
791
|
-
|
|
833
|
+
edgeExposedMessage,
|
|
792
834
|
edgeExposure,
|
|
793
835
|
edgeNetworkSubnets,
|
|
794
836
|
} from "./edge-exposure.js";
|
|
@@ -1077,13 +1119,154 @@ const GUARD: GuardContext = {
|
|
|
1077
1119
|
peerIsLoopback: (req) => isLoopbackAddress(req.socket?.remoteAddress),
|
|
1078
1120
|
// The edge hostname only from the edge's own peers (loopback, Docker).
|
|
1079
1121
|
peerIsEdge: (req) => isEdgePeer(req.socket?.remoteAddress),
|
|
1080
|
-
//
|
|
1122
|
+
// This machine's addresses and bridges, for edgeClient (SEC-2a).
|
|
1081
1123
|
localNet: currentLocalNet,
|
|
1082
1124
|
// A deliberate exposure (DEN_BIND_HOST beyond loopback, other than the
|
|
1083
1125
|
// Linux edge's Docker bridge): the edge needs a sign-in too.
|
|
1084
1126
|
exposed: !isLoopbackBind(BIND_HOST) && !isDockerBridgeAddress(BIND_HOST),
|
|
1127
|
+
// SEC-2a: an edge client Den can't place signs in (web.edge_unattested).
|
|
1128
|
+
edgeUnattested: WEB_AUTH.edgeUnattested,
|
|
1085
1129
|
};
|
|
1086
1130
|
|
|
1131
|
+
// ── SEC-2a: Sign in with GitHub, the owner, pairing ──────────────────────────
|
|
1132
|
+
const GITHUB_CLIENT_ID = githubClientId(WEB_AUTH.githubClientId);
|
|
1133
|
+
const GITHUB = githubEndpoints();
|
|
1134
|
+
const DEVICE_FLOWS = new DeviceFlows({
|
|
1135
|
+
client: githubDeviceClient(GITHUB),
|
|
1136
|
+
clientId: () => GITHUB_CLIENT_ID,
|
|
1137
|
+
decide: decideFlow(getStateDir, (l) => serverLog(l, "lifecycle")),
|
|
1138
|
+
// A test's fake GitHub may answer interval 0; the real one never does.
|
|
1139
|
+
minIntervalMs: GITHUB.fake ? 50 : 1000,
|
|
1140
|
+
onExpiringToken: noteExpiringToken,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* The GitHub App has "Expire user authorization tokens" on (a token answer
|
|
1145
|
+
* carried expires_in or a refresh_token). Sign-in still completes; Den
|
|
1146
|
+
* warns once, naming the fix, and Settings › Access (GET /api/web/owner)
|
|
1147
|
+
* says so as a boolean. The token itself is never logged or kept.
|
|
1148
|
+
*/
|
|
1149
|
+
let GITHUB_TOKENS_EXPIRE = false;
|
|
1150
|
+
function noteExpiringToken(): void {
|
|
1151
|
+
if (GITHUB_TOKENS_EXPIRE) return;
|
|
1152
|
+
GITHUB_TOKENS_EXPIRE = true;
|
|
1153
|
+
// The in-app log, and stderr (web.log), as the edge's exposure warning.
|
|
1154
|
+
const line = ` ⚠ ${expiringTokenWarning()}`;
|
|
1155
|
+
pushLog("warn", line);
|
|
1156
|
+
if (!outputLost()) process.stderr.write(line + "\n");
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// ── SEC-2a: approve this computer with a code (pair-requests.ts) ──────────
|
|
1160
|
+
const PAIRS = new PairRequests();
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* What every printed command starts with (cli-command.ts): `<cli>` when the
|
|
1164
|
+
* first one on this server's PATH is this distribution's, else its
|
|
1165
|
+
* absolute path. PATH doesn't change while the server runs: once.
|
|
1166
|
+
*/
|
|
1167
|
+
let CLI_PREFIX: string | null = null;
|
|
1168
|
+
function printedCli(): string {
|
|
1169
|
+
CLI_PREFIX ??= cliPrefix(product().cli, getDistRoot(), getEngineRoot());
|
|
1170
|
+
return CLI_PREFIX;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* The name people read for this machine: `web.machine_name` (user.yml,
|
|
1175
|
+
* read now, so a change in Settings shows at once), else the short
|
|
1176
|
+
* hostname. Never the browser's Host.
|
|
1177
|
+
*/
|
|
1178
|
+
function machineName(): string {
|
|
1179
|
+
let set: string | undefined;
|
|
1180
|
+
try {
|
|
1181
|
+
set = readWebAuthConfig(getDistRoot(), product().configFile).machineName;
|
|
1182
|
+
} catch {
|
|
1183
|
+
set = WEB_AUTH.machineName;
|
|
1184
|
+
}
|
|
1185
|
+
return machineLabel(set, os.hostname());
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** `web.machine_name` as user.yml holds it, or null (unset or unreadable). */
|
|
1189
|
+
function machineNameSetting(user: {
|
|
1190
|
+
web?: Record<string, unknown>;
|
|
1191
|
+
}): string | null {
|
|
1192
|
+
try {
|
|
1193
|
+
return parseMachineName(user.web?.machine_name);
|
|
1194
|
+
} catch {
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* A path as the request log writes it: a sign-in poll's id is its
|
|
1201
|
+
* credential (SEC-2a: the code request's, a GitHub flow's), so it shows
|
|
1202
|
+
* as `:id`.
|
|
1203
|
+
*/
|
|
1204
|
+
function loggedPath(pathname: string): string {
|
|
1205
|
+
return pathname.replace(
|
|
1206
|
+
/^(\/api\/(?:signin\/code|github\/signin|web\/owner\/link))\/[^/]+$/,
|
|
1207
|
+
"$1/:id",
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** The ssh target for this request: the Host it used, minus the port. */
|
|
1212
|
+
function sshTargetOf(req: http.IncomingMessage): string {
|
|
1213
|
+
return sshTarget(req.headers.host, os.hostname());
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* A browser's live WebSockets (a session's terminal), by its session's
|
|
1218
|
+
* key: revoking a device closes them at once (SEC-2a).
|
|
1219
|
+
*/
|
|
1220
|
+
const SESSION_SOCKETS = new Map<string, Set<net.Socket>>();
|
|
1221
|
+
function trackSessionSocket(key: string, socket: net.Socket): void {
|
|
1222
|
+
const set = SESSION_SOCKETS.get(key) ?? new Set<net.Socket>();
|
|
1223
|
+
set.add(socket);
|
|
1224
|
+
SESSION_SOCKETS.set(key, set);
|
|
1225
|
+
socket.once("close", () => {
|
|
1226
|
+
set.delete(socket);
|
|
1227
|
+
if (set.size === 0) SESSION_SOCKETS.delete(key);
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
function closeSessionSockets(keys: Iterable<string>): void {
|
|
1231
|
+
for (const k of keys) {
|
|
1232
|
+
for (const sock of SESSION_SOCKETS.get(k) ?? []) sock.destroy();
|
|
1233
|
+
SESSION_SOCKETS.delete(k);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/** A new session for this request and its Set-Cookie line (GitHub or a link). */
|
|
1238
|
+
function startSession(
|
|
1239
|
+
req: http.IncomingMessage,
|
|
1240
|
+
via: "github" | "link" | "code",
|
|
1241
|
+
login?: string,
|
|
1242
|
+
): string {
|
|
1243
|
+
const g = checkRequest(req, GUARD, product().cli, product().title);
|
|
1244
|
+
const named = g.ok ? g.via : null;
|
|
1245
|
+
const { id } = webSessions.create({
|
|
1246
|
+
userAgent: req.headers["user-agent"],
|
|
1247
|
+
host: req.headers.host,
|
|
1248
|
+
via,
|
|
1249
|
+
...(g.ok && g.client ? { client: g.client } : {}),
|
|
1250
|
+
...(login ? { login } : {}),
|
|
1251
|
+
});
|
|
1252
|
+
return sessionSetCookie(id, requestIsHttps(req, named));
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/** The edge hostnames that route here, and their URLs (Pair a device). */
|
|
1256
|
+
function denEdgeRoutes(): { host: string; url: string }[] {
|
|
1257
|
+
try {
|
|
1258
|
+
return webRouteHosts(readEdgeConfig(), new Set([PORT, readWebPortSafe()]));
|
|
1259
|
+
} catch {
|
|
1260
|
+
return [];
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** What the signed-out page says about Sign in with GitHub. */
|
|
1265
|
+
function githubSigninState(): "ready" | "no_client_id" | "no_owner" {
|
|
1266
|
+
if (!GITHUB_CLIENT_ID) return "no_client_id";
|
|
1267
|
+
return readWebOwner(getStateDir()) ? "ready" : "no_owner";
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1087
1270
|
/**
|
|
1088
1271
|
* 0.4.3: why the edge is reachable beyond this machine (edge-exposure.ts),
|
|
1089
1272
|
* once at start; null until known, or when Den can't tell.
|
|
@@ -1107,7 +1290,7 @@ function checkEdgeExposure(): void {
|
|
|
1107
1290
|
EDGE_EXPOSED = why;
|
|
1108
1291
|
// The in-app log, and stderr, which is web.log even when `web up`
|
|
1109
1292
|
// starts this --silent (stdout is not written then).
|
|
1110
|
-
const line = ` ⚠ ${
|
|
1293
|
+
const line = ` ⚠ ${edgeExposedMessage(WEB_AUTH.edgeUnattested)} (${why})`;
|
|
1111
1294
|
pushLog("warn", line);
|
|
1112
1295
|
if (!outputLost()) process.stderr.write(line + "\n");
|
|
1113
1296
|
})
|
|
@@ -1155,6 +1338,8 @@ function sendSignedOut(
|
|
|
1155
1338
|
search: string;
|
|
1156
1339
|
scope: "terminal" | "agents" | "den";
|
|
1157
1340
|
expired?: boolean;
|
|
1341
|
+
/** SEC-2a: a waited-on link: ask for the code `web login` printed. */
|
|
1342
|
+
confirm?: boolean;
|
|
1158
1343
|
requestId: string;
|
|
1159
1344
|
message: string;
|
|
1160
1345
|
},
|
|
@@ -1165,9 +1350,11 @@ function sendSignedOut(
|
|
|
1165
1350
|
? "edge"
|
|
1166
1351
|
: "other";
|
|
1167
1352
|
const origin = `${requestIsHttps(req, via === "edge" ? "edge" : null) ? "https" : "http"}://${host}`;
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
|
|
1353
|
+
const bin = printedCli();
|
|
1354
|
+
const loginArgs =
|
|
1355
|
+
toLocal || host === `localhost:${PORT}` ? [] : ["--origin", origin];
|
|
1356
|
+
const cmd = cliCommand(bin, "web", "login", ...loginArgs);
|
|
1357
|
+
const ssh = sshTargetOf(req);
|
|
1171
1358
|
res.setHeader("Cache-Control", "no-store");
|
|
1172
1359
|
sendErrorPage(req, res, {
|
|
1173
1360
|
status: 401,
|
|
@@ -1179,7 +1366,24 @@ function sendSignedOut(
|
|
|
1179
1366
|
scope: opts.scope,
|
|
1180
1367
|
name: product().title,
|
|
1181
1368
|
cmd,
|
|
1369
|
+
// SEC-2a: which machine runs the commands, and how to reach it.
|
|
1370
|
+
machine: machineName(),
|
|
1371
|
+
ssh: {
|
|
1372
|
+
target: ssh,
|
|
1373
|
+
// Open Terminal (ssh://) only for a name, never an IP literal.
|
|
1374
|
+
name: !isIpLiteral(ssh),
|
|
1375
|
+
login: sshCommand(ssh, cmd),
|
|
1376
|
+
},
|
|
1377
|
+
// SEC-2a: Sign in with GitHub, and why it can't be offered yet.
|
|
1378
|
+
github: {
|
|
1379
|
+
state: githubSigninState(),
|
|
1380
|
+
owner: cliCommand(bin, "web", "owner", "link"),
|
|
1381
|
+
...(GITHUB_CLIENT_ID
|
|
1382
|
+
? {}
|
|
1383
|
+
: { message: noClientIdMessage(product().cli) }),
|
|
1384
|
+
},
|
|
1182
1385
|
...(opts.expired ? { expired: true } : {}),
|
|
1386
|
+
...(opts.confirm ? { confirm: true } : {}),
|
|
1183
1387
|
...(toLocal && !opts.expired
|
|
1184
1388
|
? {
|
|
1185
1389
|
localhost: `${localhostOrigin(PORT)}${opts.pathname}${opts.search}`,
|
|
@@ -1254,19 +1458,34 @@ function sendGuardRefusal(
|
|
|
1254
1458
|
* 303 to `next` when it is a same-origin path, else `/`. Anything else gets
|
|
1255
1459
|
* the signed-out page, which never echoes the code.
|
|
1256
1460
|
*/
|
|
1257
|
-
function handleAuth(
|
|
1461
|
+
async function handleAuth(
|
|
1258
1462
|
req: http.IncomingMessage,
|
|
1259
1463
|
res: http.ServerResponse,
|
|
1260
1464
|
url: URL,
|
|
1261
1465
|
requestId: string,
|
|
1262
|
-
|
|
1263
|
-
): void {
|
|
1466
|
+
): Promise<void> {
|
|
1264
1467
|
const secret = webSecret.current();
|
|
1468
|
+
const c = url.searchParams.get("c") ?? "";
|
|
1469
|
+
const nonce = loginCodeNonce(c);
|
|
1470
|
+
// SEC-2a: a link `web login` waits on asks for its code first. Opened,
|
|
1471
|
+
// its code is checked as of the moment it was opened (then 5 minutes).
|
|
1472
|
+
const wait = nonce ? LOGIN_WAITS.forNonce(nonce) : null;
|
|
1265
1473
|
const check = secret
|
|
1266
|
-
? checkLoginCode(
|
|
1474
|
+
? checkLoginCode(c, secret, wait?.openedAt ?? Date.now())
|
|
1267
1475
|
: null;
|
|
1268
|
-
|
|
1476
|
+
const post = req.method === "POST";
|
|
1477
|
+
const gone = () => {
|
|
1269
1478
|
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1479
|
+
if (post)
|
|
1480
|
+
return sendJson(
|
|
1481
|
+
res,
|
|
1482
|
+
{
|
|
1483
|
+
ok: false,
|
|
1484
|
+
code: "expired",
|
|
1485
|
+
error: `This link no longer works. On ${machineName()}, run web login again for a new one.`,
|
|
1486
|
+
},
|
|
1487
|
+
410,
|
|
1488
|
+
);
|
|
1270
1489
|
return sendSignedOut(req, res, {
|
|
1271
1490
|
pathname: "/auth",
|
|
1272
1491
|
search: "",
|
|
@@ -1275,20 +1494,90 @@ function handleAuth(
|
|
|
1275
1494
|
requestId,
|
|
1276
1495
|
message: `That sign-in link expired or was used. Run \`${product().cli} web login\` for a new one.`,
|
|
1277
1496
|
});
|
|
1497
|
+
};
|
|
1498
|
+
if (!check?.ok) return gone();
|
|
1499
|
+
if (wait && nonce) {
|
|
1500
|
+
if (!post) {
|
|
1501
|
+
if (wait.state !== "waiting" && wait.state !== "opened") return gone();
|
|
1502
|
+
LOGIN_WAITS.open(nonce);
|
|
1503
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1504
|
+
return sendSignedOut(req, res, {
|
|
1505
|
+
pathname: "/auth",
|
|
1506
|
+
search: "",
|
|
1507
|
+
scope: "den",
|
|
1508
|
+
confirm: true,
|
|
1509
|
+
requestId,
|
|
1510
|
+
message: "Enter the code `web login` printed.",
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
let body: { code?: unknown } = {};
|
|
1514
|
+
try {
|
|
1515
|
+
body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
|
|
1516
|
+
} catch {
|
|
1517
|
+
return sendJson(
|
|
1518
|
+
res,
|
|
1519
|
+
{ ok: false, error: "the body must be JSON: { code }" },
|
|
1520
|
+
400,
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
const r = LOGIN_WAITS.confirm(nonce, String(body.code ?? ""));
|
|
1524
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1525
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1526
|
+
if (!r.ok) {
|
|
1527
|
+
if (r.error === "expired") return gone();
|
|
1528
|
+
return sendJson(
|
|
1529
|
+
res,
|
|
1530
|
+
r.error === "wrong"
|
|
1531
|
+
? {
|
|
1532
|
+
ok: false,
|
|
1533
|
+
code: "wrong_code",
|
|
1534
|
+
left: r.left,
|
|
1535
|
+
error:
|
|
1536
|
+
"That code isn't right. Check what web login printed and try again.",
|
|
1537
|
+
}
|
|
1538
|
+
: r.error === "rate_limited"
|
|
1539
|
+
? {
|
|
1540
|
+
ok: false,
|
|
1541
|
+
code: "rate_limited",
|
|
1542
|
+
error: "Too many wrong codes. Wait a minute and try again.",
|
|
1543
|
+
}
|
|
1544
|
+
: {
|
|
1545
|
+
ok: false,
|
|
1546
|
+
code: "refused",
|
|
1547
|
+
error: `Too many wrong codes: this link no longer works. On ${machineName()}, run web login again.`,
|
|
1548
|
+
},
|
|
1549
|
+
r.error === "wrong" ? 401 : r.error === "rate_limited" ? 429 : 410,
|
|
1550
|
+
);
|
|
1551
|
+
}
|
|
1552
|
+
if (!webSessions.consumeCode(check.nonce, check.expiresAt)) return gone();
|
|
1553
|
+
const g = checkRequest(req, GUARD, product().cli, product().title);
|
|
1554
|
+
LOGIN_WAITS.used(nonce, {
|
|
1555
|
+
device: deviceName(req.headers["user-agent"]),
|
|
1556
|
+
...(g.ok && g.client ? { client: g.client } : {}),
|
|
1557
|
+
});
|
|
1558
|
+
res.setHeader("Set-Cookie", startSession(req, "link"));
|
|
1559
|
+
return sendJson(res, {
|
|
1560
|
+
ok: true,
|
|
1561
|
+
next: safeNext(url.searchParams.get("next")),
|
|
1562
|
+
});
|
|
1278
1563
|
}
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1564
|
+
// A link nobody waits on (`web login --no-wait`, or minted with no
|
|
1565
|
+
// server running): it signs in when opened, once, as before.
|
|
1566
|
+
if (post || !webSessions.consumeCode(check.nonce, check.expiresAt))
|
|
1567
|
+
return gone();
|
|
1283
1568
|
res.writeHead(303, {
|
|
1284
1569
|
Location: safeNext(url.searchParams.get("next")),
|
|
1285
|
-
"Set-Cookie":
|
|
1570
|
+
"Set-Cookie": startSession(req, "link"),
|
|
1286
1571
|
"Cache-Control": "no-store",
|
|
1287
1572
|
"Referrer-Policy": "no-referrer",
|
|
1288
1573
|
});
|
|
1289
1574
|
res.end();
|
|
1290
1575
|
}
|
|
1291
1576
|
|
|
1577
|
+
/** `web login`'s wait (login-waits.ts), by the CLI's id. */
|
|
1578
|
+
const LOGIN_WAIT_PATH = /^\/api\/web\/logins\/([A-Za-z0-9_-]{16,64})$/;
|
|
1579
|
+
const LOGIN_WAITS = new LoginWaits();
|
|
1580
|
+
|
|
1292
1581
|
/**
|
|
1293
1582
|
* A service's declared command `cmd` for `env`, own properties only
|
|
1294
1583
|
* (SEC-1 §6b.2): `constructor` or `__proto__` is no command, never
|
|
@@ -1339,7 +1628,7 @@ async function handleRequest(
|
|
|
1339
1628
|
res.on("finish", () => {
|
|
1340
1629
|
const ms = dim(`${Date.now() - start}ms`);
|
|
1341
1630
|
serverLog(
|
|
1342
|
-
` ${logTimestamp()} ${method} ${pathname} → ${logStatus(res.statusCode)} ${ms}` +
|
|
1631
|
+
` ${logTimestamp()} ${method} ${loggedPath(pathname)} → ${logStatus(res.statusCode)} ${ms}` +
|
|
1343
1632
|
(res.statusCode >= 400 ? dim(` req ${requestId}`) : ""),
|
|
1344
1633
|
"request",
|
|
1345
1634
|
);
|
|
@@ -1368,8 +1657,51 @@ async function handleRequest(
|
|
|
1368
1657
|
|
|
1369
1658
|
// ── Sign-in (SEC-1b, §2) ───────────────────────────────────────────────────
|
|
1370
1659
|
// Before config validation: signing in never depends on the config.
|
|
1371
|
-
if (
|
|
1372
|
-
|
|
1660
|
+
if (
|
|
1661
|
+
pathname === "/auth" &&
|
|
1662
|
+
(req.method === "GET" || req.method === "HEAD" || req.method === "POST")
|
|
1663
|
+
)
|
|
1664
|
+
return handleAuth(req, res, url, requestId);
|
|
1665
|
+
// SEC-2a: `web login` registers its link's wait, then polls it
|
|
1666
|
+
// (shell-grade: the CLI's bearer).
|
|
1667
|
+
if (pathname === "/api/web/logins" && req.method === "POST") {
|
|
1668
|
+
let body: { nonce?: unknown; code?: unknown; expiresAt?: unknown } = {};
|
|
1669
|
+
try {
|
|
1670
|
+
body = (JSON.parse((await readBody(req)) || "{}") ?? {}) as typeof body;
|
|
1671
|
+
} catch {
|
|
1672
|
+
return send400(res, "the body must be JSON: { nonce, code, expiresAt }");
|
|
1673
|
+
}
|
|
1674
|
+
const exp = Number(body.expiresAt);
|
|
1675
|
+
const id =
|
|
1676
|
+
Number.isFinite(exp) &&
|
|
1677
|
+
exp > Date.now() &&
|
|
1678
|
+
exp - Date.now() <= LOGIN_CODE_TTL_MS + 5_000
|
|
1679
|
+
? LOGIN_WAITS.register(
|
|
1680
|
+
String(body.nonce ?? ""),
|
|
1681
|
+
String(body.code ?? ""),
|
|
1682
|
+
exp,
|
|
1683
|
+
)
|
|
1684
|
+
: null;
|
|
1685
|
+
if (!id) return send400(res, "not a login link's nonce, code and expiry");
|
|
1686
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1687
|
+
return sendJson(res, { ok: true, id });
|
|
1688
|
+
}
|
|
1689
|
+
{
|
|
1690
|
+
const w = LOGIN_WAIT_PATH.exec(pathname);
|
|
1691
|
+
if (w && (req.method === "GET" || req.method === "HEAD")) {
|
|
1692
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1693
|
+
const v = LOGIN_WAITS.get(w[1]);
|
|
1694
|
+
if (!v) return sendJson(res, { ok: false, state: "expired" }, 404);
|
|
1695
|
+
return sendJson(res, {
|
|
1696
|
+
ok: true,
|
|
1697
|
+
state: v.state,
|
|
1698
|
+
...(v.device ? { device: v.device } : {}),
|
|
1699
|
+
...(v.client ? { client: v.client } : {}),
|
|
1700
|
+
...(v.usedAt ? { usedAt: new Date(v.usedAt).toISOString() } : {}),
|
|
1701
|
+
expiresAt: new Date(v.expiresAt).toISOString(),
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1373
1705
|
if (
|
|
1374
1706
|
pathname === "/signin" &&
|
|
1375
1707
|
(req.method === "GET" || req.method === "HEAD")
|
|
@@ -1399,6 +1731,65 @@ async function handleRequest(
|
|
|
1399
1731
|
// is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
|
|
1400
1732
|
if (pathname.startsWith("/api/sessions/"))
|
|
1401
1733
|
return sendSessionHttp(req, res, pathname);
|
|
1734
|
+
// SEC-2a: Sign in with GitHub, the owner's link, Pair a device. Before
|
|
1735
|
+
// config validation, like the sign-in above.
|
|
1736
|
+
if (
|
|
1737
|
+
isGithubRoute(pathname) &&
|
|
1738
|
+
(await handleGithubRoutes(req, res, pathname, {
|
|
1739
|
+
flows: DEVICE_FLOWS,
|
|
1740
|
+
clientId: () => GITHUB_CLIENT_ID,
|
|
1741
|
+
tokensExpire: () => GITHUB_TOKENS_EXPIRE,
|
|
1742
|
+
cli: product().cli,
|
|
1743
|
+
sendJson,
|
|
1744
|
+
readBody,
|
|
1745
|
+
signIn: startSession,
|
|
1746
|
+
edgeRoutes: denEdgeRoutes,
|
|
1747
|
+
mintLink: (origin) => {
|
|
1748
|
+
const secret = webSecret.current();
|
|
1749
|
+
if (!secret) return null;
|
|
1750
|
+
const now = Date.now();
|
|
1751
|
+
return {
|
|
1752
|
+
url: loginLink(origin, mintLoginCode(secret, now)),
|
|
1753
|
+
expiresAt: now + LOGIN_CODE_TTL_MS,
|
|
1754
|
+
};
|
|
1755
|
+
},
|
|
1756
|
+
log: (l) => serverLog(l, "lifecycle"),
|
|
1757
|
+
stateDir: getStateDir,
|
|
1758
|
+
}))
|
|
1759
|
+
)
|
|
1760
|
+
return;
|
|
1761
|
+
|
|
1762
|
+
// SEC-2a: approve this computer with a code. Before config validation.
|
|
1763
|
+
if (
|
|
1764
|
+
isPairRoute(pathname) &&
|
|
1765
|
+
(await handlePairRoutes(req, res, pathname, {
|
|
1766
|
+
pairs: PAIRS,
|
|
1767
|
+
sendJson,
|
|
1768
|
+
readBody,
|
|
1769
|
+
signIn: (r) => startSession(r, "code"),
|
|
1770
|
+
client: (r) => {
|
|
1771
|
+
const g = checkRequest(r, GUARD, product().cli, product().title);
|
|
1772
|
+
return g.ok ? g.client : undefined;
|
|
1773
|
+
},
|
|
1774
|
+
commands: (r, code) => {
|
|
1775
|
+
const approve = cliCommand(printedCli(), "web", "approve", code);
|
|
1776
|
+
return { approve, ssh: sshCommand(sshTargetOf(r), approve) };
|
|
1777
|
+
},
|
|
1778
|
+
audit: (r, verb, detail, refused) =>
|
|
1779
|
+
recordAction(
|
|
1780
|
+
auditActorOf(r),
|
|
1781
|
+
{
|
|
1782
|
+
verb,
|
|
1783
|
+
detail,
|
|
1784
|
+
...(refused
|
|
1785
|
+
? { outcome: "refused" as const, reason: refused }
|
|
1786
|
+
: {}),
|
|
1787
|
+
},
|
|
1788
|
+
principalOf(r),
|
|
1789
|
+
),
|
|
1790
|
+
}))
|
|
1791
|
+
)
|
|
1792
|
+
return;
|
|
1402
1793
|
|
|
1403
1794
|
// A mutating request may change what /api/services would list (exec and
|
|
1404
1795
|
// sync stream over GET, for EventSource).
|
|
@@ -1843,6 +2234,8 @@ async function handleRequest(
|
|
|
1843
2234
|
compose: ComposeHealthTarget | null;
|
|
1844
2235
|
/** A `type: container` check: answered from the shared listing. */
|
|
1845
2236
|
container: ContainerHealthTarget | null;
|
|
2237
|
+
/** The repo has no manifest (the row is kept, unchecked). */
|
|
2238
|
+
noManifest?: boolean;
|
|
1846
2239
|
meta: Pick<HealthResult, "runs" | "target" | "focused" | "workspaces">;
|
|
1847
2240
|
}> = [];
|
|
1848
2241
|
const focusName = getFocusedWorkspaceName();
|
|
@@ -1885,6 +2278,7 @@ async function handleRequest(
|
|
|
1885
2278
|
org: orgOf(resolved.repoName, catalog),
|
|
1886
2279
|
compose: null,
|
|
1887
2280
|
container: null,
|
|
2281
|
+
noManifest: true,
|
|
1888
2282
|
meta: {
|
|
1889
2283
|
runs: "none",
|
|
1890
2284
|
target: null,
|
|
@@ -2012,6 +2406,7 @@ async function handleRequest(
|
|
|
2012
2406
|
expect,
|
|
2013
2407
|
compose,
|
|
2014
2408
|
container,
|
|
2409
|
+
noManifest,
|
|
2015
2410
|
}) => {
|
|
2016
2411
|
if (checkType !== "none") {
|
|
2017
2412
|
const started = Date.now();
|
|
@@ -2078,6 +2473,13 @@ async function handleRequest(
|
|
|
2078
2473
|
isEntrypoint,
|
|
2079
2474
|
...meta,
|
|
2080
2475
|
ok: null,
|
|
2476
|
+
why: notCheckedReason({
|
|
2477
|
+
env,
|
|
2478
|
+
runs: meta.runs,
|
|
2479
|
+
target: meta.target,
|
|
2480
|
+
manifest: !noManifest,
|
|
2481
|
+
conventional,
|
|
2482
|
+
}),
|
|
2081
2483
|
};
|
|
2082
2484
|
};
|
|
2083
2485
|
// Stream each result the moment it lands. The checks run concurrently but
|
|
@@ -2244,9 +2646,10 @@ async function handleRequest(
|
|
|
2244
2646
|
Connection: "keep-alive",
|
|
2245
2647
|
"X-Accel-Buffering": "no",
|
|
2246
2648
|
});
|
|
2247
|
-
|
|
2649
|
+
// LK-0: the command's sudo without the password is a needs-sudo event.
|
|
2650
|
+
const emit = watchSudo((obj: unknown): void => {
|
|
2248
2651
|
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
|
2249
|
-
};
|
|
2652
|
+
}, `${repoName}/${svcName} ${cmdName}`);
|
|
2250
2653
|
// Every fail here is before anything ran: a refusal or a lookup miss.
|
|
2251
2654
|
// Recorded, so failed attempts show in the activity trail (F2).
|
|
2252
2655
|
const fail = (msg: string): void => {
|
|
@@ -3362,22 +3765,41 @@ async function handleRequest(
|
|
|
3362
3765
|
actor: g.principal.actor,
|
|
3363
3766
|
signedIn: false,
|
|
3364
3767
|
});
|
|
3365
|
-
|
|
3768
|
+
// SEC-2a: who this browser is (the rail's identity chip), and, once
|
|
3769
|
+
// after a sign-in, that it just signed in (the shell's toast).
|
|
3770
|
+
if (g?.session) {
|
|
3771
|
+
const rec = webSessions.list().find((x) => x.key === g.session!.key);
|
|
3772
|
+
const github = rec?.via === "github";
|
|
3366
3773
|
return sendJson(res, {
|
|
3367
3774
|
csrf: g.session.csrf,
|
|
3368
3775
|
actor: "person-browser",
|
|
3369
3776
|
signedIn: true,
|
|
3777
|
+
identity: {
|
|
3778
|
+
kind: github ? "github" : "device",
|
|
3779
|
+
via: rec?.via ?? "link",
|
|
3780
|
+
...(github && rec?.login ? { login: rec.login } : {}),
|
|
3781
|
+
},
|
|
3782
|
+
...(webSessions.takeWelcome(g.session.key)
|
|
3783
|
+
? {
|
|
3784
|
+
justSignedIn: true,
|
|
3785
|
+
product: product().title,
|
|
3786
|
+
machine: machineName(),
|
|
3787
|
+
}
|
|
3788
|
+
: {}),
|
|
3370
3789
|
});
|
|
3790
|
+
}
|
|
3371
3791
|
return sendJson(res, {
|
|
3372
3792
|
csrf: g?.beyond ? null : GUARD.csrfToken,
|
|
3373
3793
|
actor: null,
|
|
3374
3794
|
signedIn: false,
|
|
3795
|
+
...(g && !g.beyond ? { identity: { kind: "local" } } : {}),
|
|
3375
3796
|
});
|
|
3376
3797
|
}
|
|
3377
3798
|
// Sign out: this browser's session ends and its cookie is cleared.
|
|
3378
3799
|
if (pathname === "/api/session/logout" && req.method === "POST") {
|
|
3379
3800
|
const g = guard.ok ? guard : null;
|
|
3380
3801
|
const ended = g?.session ? webSessions.end(g.session.key) : false;
|
|
3802
|
+
if (g?.session) closeSessionSockets([g.session.key]);
|
|
3381
3803
|
res.setHeader(
|
|
3382
3804
|
"Set-Cookie",
|
|
3383
3805
|
clearSessionCookie(requestIsHttps(req, g?.via ?? null)),
|
|
@@ -3394,6 +3816,11 @@ async function handleRequest(
|
|
|
3394
3816
|
lastSeenAt: new Date(x.lastSeenAt).toISOString(),
|
|
3395
3817
|
userAgent: x.userAgent,
|
|
3396
3818
|
host: x.host,
|
|
3819
|
+
// SEC-2a: a linked device's name, how it signed in, its address.
|
|
3820
|
+
name: deviceName(x.userAgent),
|
|
3821
|
+
via: x.via ?? "link",
|
|
3822
|
+
...(x.login ? { login: x.login } : {}),
|
|
3823
|
+
...(x.client ? { client: x.client } : {}),
|
|
3397
3824
|
...(x.key === mine ? { current: true } : {}),
|
|
3398
3825
|
})),
|
|
3399
3826
|
});
|
|
@@ -3414,11 +3841,14 @@ async function handleRequest(
|
|
|
3414
3841
|
if (body.id !== undefined && typeof body.id !== "string")
|
|
3415
3842
|
return send400(res, "id must be a session id prefix");
|
|
3416
3843
|
try {
|
|
3417
|
-
const
|
|
3844
|
+
const endedKeys =
|
|
3418
3845
|
typeof body.id === "string"
|
|
3419
|
-
? [
|
|
3420
|
-
: webSessions.list().map((x) =>
|
|
3846
|
+
? [webSessions.endByPrefix(body.id).key]
|
|
3847
|
+
: webSessions.list().map((x) => x.key);
|
|
3421
3848
|
if (typeof body.id !== "string") webSessions.endAll();
|
|
3849
|
+
// SEC-2a: a revoked device's open terminals close now, too.
|
|
3850
|
+
closeSessionSockets(endedKeys);
|
|
3851
|
+
const ended = endedKeys.map(sessionIdPrefix);
|
|
3422
3852
|
const rotated = body.rotate === true;
|
|
3423
3853
|
let agentTokens = 0;
|
|
3424
3854
|
if (rotated) {
|
|
@@ -3864,10 +4294,15 @@ async function handleRequest(
|
|
|
3864
4294
|
}
|
|
3865
4295
|
|
|
3866
4296
|
// ── API: sudo password ────────────────────────────────────────────────────
|
|
4297
|
+
// LK-0: the admin password is for sudo, and nothing else: no route asks
|
|
4298
|
+
// for it as an access check. A run that is about to run sudo without it
|
|
4299
|
+
// streams a needs-sudo event (watchSudo); the page asks, then runs again.
|
|
3867
4300
|
// POST /api/sudo-password body: { password: string, saveToVault?: boolean }
|
|
3868
|
-
//
|
|
4301
|
+
// POST /api/sudo-password/forget → { ok, vaultCleared, vaultError? }
|
|
4302
|
+
// GET /api/sudo-password/status → { set, passwordless,
|
|
4303
|
+
// saved: "vault" | "session" | null, vault: { configured, label } }
|
|
3869
4304
|
// GET /api/sudo-password/vault-status → { backend, label, configured,
|
|
3870
|
-
// backendAvailable, reason?, hasStored, opAvailable (deprecated) }
|
|
4305
|
+
// canSave, backendAvailable, reason?, hasStored, opAvailable (deprecated) }
|
|
3871
4306
|
// POST /api/sudo-password/load-from-vault → { ok, reason?, message? }
|
|
3872
4307
|
if (pathname === "/api/sudo-password") {
|
|
3873
4308
|
if (req.method !== "POST") {
|
|
@@ -3897,25 +4332,75 @@ async function handleRequest(
|
|
|
3897
4332
|
setSudoPassword(password);
|
|
3898
4333
|
let savedToVault = false;
|
|
3899
4334
|
let vaultError: string | undefined;
|
|
4335
|
+
let refCreated = false;
|
|
3900
4336
|
if (saveToVault) {
|
|
4337
|
+
// LK-4: with no secrets.refs.sudo, this creates the item and writes
|
|
4338
|
+
// the ref to user.yml. The error never carries the password.
|
|
3901
4339
|
try {
|
|
3902
|
-
await saveSudoToVault(password);
|
|
4340
|
+
refCreated = (await saveSudoToVault(password)).ref === "created";
|
|
3903
4341
|
savedToVault = true;
|
|
4342
|
+
sudoInVault = true;
|
|
3904
4343
|
} catch (err) {
|
|
3905
4344
|
vaultError = (err as Error).message;
|
|
3906
4345
|
}
|
|
3907
4346
|
}
|
|
3908
|
-
return sendJson(res, {
|
|
4347
|
+
return sendJson(res, {
|
|
4348
|
+
ok: true,
|
|
4349
|
+
savedToVault,
|
|
4350
|
+
...(refCreated ? { refCreated } : {}),
|
|
4351
|
+
vaultError,
|
|
4352
|
+
});
|
|
3909
4353
|
} catch (e) {
|
|
3910
4354
|
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
3911
4355
|
}
|
|
3912
4356
|
}
|
|
3913
4357
|
|
|
3914
4358
|
if (pathname === "/api/sudo-password/status") {
|
|
3915
|
-
// passwordless: NOPASSWD sudoer (or warm cache) —
|
|
4359
|
+
// passwordless: NOPASSWD sudoer (or warm cache) — sudo needs none.
|
|
4360
|
+
// saved (Settings, LK-0): "vault" once this server saved it there or
|
|
4361
|
+
// loaded it from there, "session" when it is held in memory only.
|
|
4362
|
+
const set = hasSudoPassword();
|
|
3916
4363
|
return sendJson(res, {
|
|
3917
|
-
set
|
|
4364
|
+
set,
|
|
3918
4365
|
passwordless: hasPasswordlessSudo(),
|
|
4366
|
+
saved: sudoInVault ? "vault" : set ? "session" : null,
|
|
4367
|
+
vault: sudoVaultWhere(),
|
|
4368
|
+
});
|
|
4369
|
+
}
|
|
4370
|
+
|
|
4371
|
+
// Forget (Settings, LK-0): the password this server holds goes, and the
|
|
4372
|
+
// vault's copy when this server knows there is one (written empty, which
|
|
4373
|
+
// every read takes as none). sudo's own cache is dropped too.
|
|
4374
|
+
if (pathname === "/api/sudo-password/forget") {
|
|
4375
|
+
if (req.method !== "POST") {
|
|
4376
|
+
res.writeHead(405);
|
|
4377
|
+
res.end();
|
|
4378
|
+
return;
|
|
4379
|
+
}
|
|
4380
|
+
clearSudoPassword();
|
|
4381
|
+
try {
|
|
4382
|
+
execSync("sudo -k", {
|
|
4383
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
4384
|
+
timeout: CLI_SPAWN_TIMEOUT_MS,
|
|
4385
|
+
});
|
|
4386
|
+
} catch {
|
|
4387
|
+
/* best-effort */
|
|
4388
|
+
}
|
|
4389
|
+
let vaultCleared = false;
|
|
4390
|
+
let vaultError: string | undefined;
|
|
4391
|
+
if (sudoInVault) {
|
|
4392
|
+
try {
|
|
4393
|
+
await saveSudoToVault("");
|
|
4394
|
+
vaultCleared = true;
|
|
4395
|
+
sudoInVault = false;
|
|
4396
|
+
} catch (err) {
|
|
4397
|
+
vaultError = (err as Error).message;
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
return sendJson(res, {
|
|
4401
|
+
ok: true,
|
|
4402
|
+
vaultCleared,
|
|
4403
|
+
...(vaultError ? { vaultError } : {}),
|
|
3919
4404
|
});
|
|
3920
4405
|
}
|
|
3921
4406
|
|
|
@@ -3960,6 +4445,7 @@ async function handleRequest(
|
|
|
3960
4445
|
return sendJson(res, { ok: false, reason: "stale" });
|
|
3961
4446
|
}
|
|
3962
4447
|
setSudoPassword(password);
|
|
4448
|
+
sudoInVault = true;
|
|
3963
4449
|
return sendJson(res, { ok: true });
|
|
3964
4450
|
}
|
|
3965
4451
|
|
|
@@ -4025,6 +4511,10 @@ async function handleRequest(
|
|
|
4025
4511
|
// The UI's language (R19), and the ones it has catalogs for.
|
|
4026
4512
|
language: uiLanguage(user.language),
|
|
4027
4513
|
ui_languages: UI_LANGUAGES,
|
|
4514
|
+
// SEC-2a: the name the signed-out page gives this machine, and
|
|
4515
|
+
// what it is when unset (the short hostname).
|
|
4516
|
+
machine_name: machineNameSetting(user),
|
|
4517
|
+
machine_name_default: machineLabel(null, os.hostname()),
|
|
4028
4518
|
});
|
|
4029
4519
|
}
|
|
4030
4520
|
if (req.method === "POST") {
|
|
@@ -4037,8 +4527,23 @@ async function handleRequest(
|
|
|
4037
4527
|
appearance?: unknown;
|
|
4038
4528
|
shell?: unknown;
|
|
4039
4529
|
language?: unknown;
|
|
4530
|
+
machine_name?: unknown;
|
|
4040
4531
|
};
|
|
4041
4532
|
const user = readUserConfig();
|
|
4533
|
+
// SEC-2a: web.machine_name (user.yml); null or "" clears it.
|
|
4534
|
+
if (body.machine_name !== undefined) {
|
|
4535
|
+
let name: string | null;
|
|
4536
|
+
try {
|
|
4537
|
+
name = parseMachineName(body.machine_name);
|
|
4538
|
+
} catch (e) {
|
|
4539
|
+
return send400(res, (e as Error).message);
|
|
4540
|
+
}
|
|
4541
|
+
const web = { ...(user.web ?? {}) };
|
|
4542
|
+
if (name) web.machine_name = name;
|
|
4543
|
+
else delete web.machine_name;
|
|
4544
|
+
if (Object.keys(web).length) user.web = web;
|
|
4545
|
+
else delete user.web;
|
|
4546
|
+
}
|
|
4042
4547
|
// null clears (0.3.0-dev.32, a consumer's nit): a block or a key
|
|
4043
4548
|
// set to null is removed from user.yml, and a block left empty
|
|
4044
4549
|
// goes too, so setting a pref and clearing it again leaves the
|
|
@@ -4133,6 +4638,7 @@ async function handleRequest(
|
|
|
4133
4638
|
appearance: appearancePrefs(user.appearance),
|
|
4134
4639
|
shell: shellPrefs(user.shell),
|
|
4135
4640
|
language: uiLanguage(user.language),
|
|
4641
|
+
machine_name: machineNameSetting(user),
|
|
4136
4642
|
});
|
|
4137
4643
|
} catch (e) {
|
|
4138
4644
|
return send400(res, `Invalid request: ${(e as Error).message}`);
|
|
@@ -4487,6 +4993,7 @@ server.on(
|
|
|
4487
4993
|
setPrincipal(req, guard.principal);
|
|
4488
4994
|
if (!isSessionIoPath(pathname))
|
|
4489
4995
|
return refuse(404, "No WebSocket route here", "not_found");
|
|
4996
|
+
if (guard.session) trackSessionSocket(guard.session.key, socket);
|
|
4490
4997
|
handleSessionUpgrade(req, socket, head, pathname, requestId).catch(
|
|
4491
4998
|
(err: unknown) => {
|
|
4492
4999
|
serverLog(
|
|
@@ -4747,7 +5254,7 @@ async function stopDaemons(): Promise<void> {
|
|
|
4747
5254
|
}
|
|
4748
5255
|
|
|
4749
5256
|
async function handleShutdown(signal: string): Promise<void> {
|
|
4750
|
-
|
|
5257
|
+
clearSudoShims();
|
|
4751
5258
|
// The registry lives in the state dir, which is resolved from the
|
|
4752
5259
|
// distribution root on every call: if that can no longer be found (den.yml
|
|
4753
5260
|
// moved or removed while the server ran), still exit. Never product() here:
|