@monotykamary/dsh-web-app 0.1.0-rc.10

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/lib/index.js ADDED
@@ -0,0 +1,432 @@
1
+ import { createRequire } from "node:module";
2
+ import { execFile, spawn } from "node:child_process";
3
+ import { networkInterfaces } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import z from "@monotykamary/schemastery";
6
+ import { addHarnessSourceSection } from "@monotykamary/dsh-app-boot";
7
+ import * as FrontendStatic from "@monotykamary/dsh-host-frontend-static";
8
+ import { launchEnvironmentOf } from "@monotykamary/dsh-launch-environment";
9
+ import { scrubbedParentEnv } from "@monotykamary/dsh-subprocess";
10
+ import { createConnection } from "node:net";
11
+ import { promisify } from "node:util";
12
+ //#region lib/types/surfaces.js
13
+ /**
14
+ * Deployment-surface resolution for `dsh web`: derives the tailnet and
15
+ * portless serving authorities from the machine's installed tooling for the
16
+ * /api browser-trust fence and the URL line. Every probe is best-effort
17
+ * environment detection, not configuration validation: an absent binary, an
18
+ * unmatched route, or a dead proxy yields a warning and no surface, never a
19
+ * failed boot. Resolution never rejects; unexpected probe failures surface
20
+ * as warnings too, so a hanging or broken tool cannot take the URL line
21
+ * (a supervisor readiness signal) down with it.
22
+ * @module @monotykamary/dsh-web-app/surfaces
23
+ */
24
+ const execFileAsync = promisify(execFile);
25
+ /** Milliseconds one tailscale or portless binary invocation may run before the probe gives up. */
26
+ const BINARY_PROBE_TIMEOUT_MS = 3e3;
27
+ /** Milliseconds one TCP connect probe may wait for the portless proxy. */
28
+ const PROXY_PROBE_TIMEOUT_MS = 1e3;
29
+ /** The portless proxy's loopback HTTPS port (the fixed port its alias service owns). */
30
+ const PORTLESS_PROXY_PORT = 443;
31
+ /** The portless alias naming this app's HTTPS surface: the `dsh.localhost` host. */
32
+ const PORTLESS_ALIAS_NAME = "dsh";
33
+ /** Tailscale's canonical HTTPS port for `tailscale serve --https`. */
34
+ const TAILSCALE_HTTPS_PORT = 443;
35
+ function isEnoent(error) {
36
+ return error instanceof Error && error.code === "ENOENT";
37
+ }
38
+ function errorText(error) {
39
+ return error instanceof Error ? error.message : String(error);
40
+ }
41
+ function parseJson(stdout) {
42
+ const trimmed = stdout.trim();
43
+ if (trimmed === "") return null;
44
+ try {
45
+ return JSON.parse(trimmed);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+ const stripTrailingDot = (dnsName) => dnsName.replace(/\.+$/, "");
51
+ const defaultExec = (file, args) => execFileAsync(file, args, { timeout: BINARY_PROBE_TIMEOUT_MS });
52
+ const defaultProbe = (host, port) => new Promise((resolve) => {
53
+ const socket = createConnection({
54
+ host,
55
+ port
56
+ });
57
+ const timer = setTimeout(() => {
58
+ socket.destroy();
59
+ resolve(false);
60
+ }, PROXY_PROBE_TIMEOUT_MS);
61
+ socket.once("connect", () => {
62
+ clearTimeout(timer);
63
+ socket.destroy();
64
+ resolve(true);
65
+ });
66
+ socket.once("error", () => {
67
+ clearTimeout(timer);
68
+ socket.destroy();
69
+ resolve(false);
70
+ });
71
+ });
72
+ /**
73
+ * Resolve the tailnet surface: the tailscale node DNS name when
74
+ * `tailscale serve` fronts the bound port with HTTPS (canonical port 443
75
+ * Web handler or a TCP HTTPS listener), else a warning naming the mismatch.
76
+ * @param port - the webserver's bound port (the serve target).
77
+ * @param exec - the binary seam.
78
+ * @returns the surface or one warning.
79
+ */
80
+ async function resolveTailnetSurface(port, exec) {
81
+ let serveStdout;
82
+ try {
83
+ serveStdout = (await exec("tailscale", [
84
+ "serve",
85
+ "status",
86
+ "--json"
87
+ ])).stdout;
88
+ } catch (error) {
89
+ if (isEnoent(error)) return { warning: "tailscale not installed — install from https://tailscale.com/download" };
90
+ return { warning: `tailscale serve status failed: ${errorText(error)}` };
91
+ }
92
+ let selfStdout;
93
+ try {
94
+ selfStdout = (await exec("tailscale", ["status", "--json"])).stdout;
95
+ } catch (error) {
96
+ if (isEnoent(error)) return { warning: "tailscale not installed — install from https://tailscale.com/download" };
97
+ return { warning: `tailscale status failed: ${errorText(error)}` };
98
+ }
99
+ const self = parseJson(selfStdout)?.Self;
100
+ if (self?.Online === false) return { warning: "tailscale offline — run `tailscale up` before using the tailnet surface" };
101
+ const dnsName = stripTrailingDot(self?.DNSName ?? "");
102
+ if (dnsName === "") return { warning: "tailscale node has no DNS name — run `tailscale up` before using the tailnet surface" };
103
+ const serveStatus = parseJson(serveStdout);
104
+ const webProxy = serveStatus?.Web?.[`${dnsName}:${TAILSCALE_HTTPS_PORT}`]?.Handlers?.["/"]?.Proxy;
105
+ if (webProxy === `http://localhost:${port}` || webProxy === `http://127.0.0.1:${port}`) return { surface: {
106
+ url: `https://${dnsName}`,
107
+ authority: dnsName
108
+ } };
109
+ const httpsTcpPorts = [];
110
+ for (const [hostPort, entry] of Object.entries(serveStatus?.TCP ?? {})) {
111
+ if (entry.HTTPS !== true) continue;
112
+ const match = /^(.*):(\d+)$/u.exec(hostPort);
113
+ if (match === null) continue;
114
+ if (match[1] !== dnsName && match[1] !== "localhost" && match[1] !== "127.0.0.1") continue;
115
+ httpsTcpPorts.push(Number(match[2]));
116
+ }
117
+ if (httpsTcpPorts.length > 0) return { surface: {
118
+ url: `https://${dnsName}:${httpsTcpPorts.includes(port) ? port : Math.min(...httpsTcpPorts)}`,
119
+ authority: dnsName
120
+ } };
121
+ return { warning: `tailscale serve does not front port ${port} — run \`tailscale serve --bg --https ${port} localhost:${port}\` to expose the tailnet surface` };
122
+ }
123
+ /**
124
+ * Resolve the portless surface: register the \`dsh\` alias for the bound port
125
+ * and confirm the proxy serves loopback :443 (IPv4 or IPv6 — observed setups
126
+ * answer on only one), else a warning naming the missing piece.
127
+ * @param port - the webserver's bound port (the alias target).
128
+ * @param exec - the binary seam.
129
+ * @param probe - the TCP probe seam.
130
+ * @returns the surface or one warning.
131
+ */
132
+ async function resolvePortlessSurface(port, exec, probe) {
133
+ try {
134
+ await exec("portless", [
135
+ "alias",
136
+ PORTLESS_ALIAS_NAME,
137
+ String(port),
138
+ "--force"
139
+ ]);
140
+ } catch (error) {
141
+ if (isEnoent(error)) return { warning: "portless not installed — install from https://www.npmjs.com/package/portless for named localhost URLs" };
142
+ return { warning: `portless alias registration failed: ${errorText(error)}` };
143
+ }
144
+ if (!(await Promise.all([probe("127.0.0.1", PORTLESS_PROXY_PORT), probe("::1", PORTLESS_PROXY_PORT)])).some(Boolean)) return { warning: "portless proxy not running on :443 — run `portless service install` for named localhost URLs" };
145
+ return { surface: {
146
+ url: `https://${PORTLESS_ALIAS_NAME}.localhost`,
147
+ authority: `${PORTLESS_ALIAS_NAME}.localhost`
148
+ } };
149
+ }
150
+ const settleUnexpected = (label) => (error) => ({ warning: `${label} surface resolution failed: ${errorText(error)}` });
151
+ /**
152
+ * Resolve the enabled remote surfaces for the bound port. Disabled flags run
153
+ * no probe and settle at once; enabled probes run in parallel and every
154
+ * failure, expected or not, lands in `warnings`.
155
+ * @param port - the webserver's bound port.
156
+ * @param tailnet - whether the tailscale serve surface is enabled.
157
+ * @param portless - whether the portless HTTPS surface is enabled.
158
+ * @param options - binary and probe seams (tests).
159
+ * @returns the resolved surfaces and their warnings; never rejects.
160
+ */
161
+ async function resolveRemoteSurfaces(port, tailnet, portless, options = {}) {
162
+ if (!tailnet && !portless) return { warnings: [] };
163
+ const exec = options.exec ?? defaultExec;
164
+ const probe = options.probe ?? defaultProbe;
165
+ const [tailnetSettlement, portlessSettlement] = await Promise.all([tailnet ? resolveTailnetSurface(port, exec).catch(settleUnexpected("tailnet")) : Promise.resolve({}), portless ? resolvePortlessSurface(port, exec, probe).catch(settleUnexpected("portless")) : Promise.resolve({})]);
166
+ const resolution = { warnings: [] };
167
+ if (tailnetSettlement.surface !== void 0) resolution.tailnet = tailnetSettlement.surface;
168
+ if (tailnetSettlement.warning !== void 0) resolution.warnings.push(tailnetSettlement.warning);
169
+ if (portlessSettlement.surface !== void 0) resolution.portless = portlessSettlement.surface;
170
+ if (portlessSettlement.warning !== void 0) resolution.warnings.push(portlessSettlement.warning);
171
+ return resolution;
172
+ }
173
+ //#endregion
174
+ //#region lib/types/index.js
175
+ /**
176
+ * @monotykamary/dsh-web-app — the browser-surface bundle's runtime glue plugin
177
+ * plus the bundle patch (`cordis.patch.yml`, declared by the `dsh.bundle.patch`
178
+ * manifest field). The plugin owns the browser-surface glue: it resolves
179
+ * the built frontend dist (workspace knowledge of this bundle, never user
180
+ * config), mounts the `frontend-static` fallback owner over it, registers the
181
+ * harness-source and web-surface prompt sections, the bash-visible web runtime
182
+ * variable, the URL line, and the default-browser handoff. App command-line
183
+ * values arrive through the `webStartup` service expressions in the bundle
184
+ * patch.
185
+ * @module @monotykamary/dsh-web-app
186
+ */
187
+ /** Stable Cordis plugin name. */
188
+ const name = "web-app";
189
+ /** This dsh installation's root, from either this package's source or built entry. */
190
+ const SOURCE_ROOT = fileURLToPath(new URL("../../../..", import.meta.url));
191
+ /** Runtime service that releases Web rows after bind-dependent values resolve. */
192
+ const WEB_RUNTIME_SERVICE = "webRuntime";
193
+ /** Services required before the web runtime can mount. */
194
+ const inject = ["webServer"];
195
+ const Config = z.object({
196
+ openBrowser: z.boolean().default(true),
197
+ printUrl: z.boolean().default(true),
198
+ surfaceContext: z.boolean().default(true),
199
+ trustedHosts: z.array(String).default([]),
200
+ tailnet: z.boolean().default(false),
201
+ portless: z.boolean().default(false)
202
+ });
203
+ /** Environment variable naming the canonical local URL of this Web GUI. */
204
+ const DSH_WEB_URL = "DSH_WEB_URL";
205
+ const LOOPBACK_HOST = "127.0.0.1";
206
+ /** The webserver schema's all-interfaces bind literal. */
207
+ const ALL_INTERFACES_HOST = "0.0.0.0";
208
+ /** Whether this process was launched through SSH, including a forwarded-port session. */
209
+ function launchedThroughSsh(ctx) {
210
+ const environment = launchEnvironmentOf(ctx);
211
+ return ["SSH_CONNECTION", "SSH_TTY"].some((name) => {
212
+ const value = environment.getFrom(name, ["process"])?.value;
213
+ return value !== void 0 && value !== "";
214
+ });
215
+ }
216
+ const BROWSER_OPENER_MODULE = import.meta.resolve("open");
217
+ const BROWSER_OPENER_PROGRAM = `
218
+ try {
219
+ const { default: open } = await import(${JSON.stringify(BROWSER_OPENER_MODULE)})
220
+ const launcher = await open(process.argv[1])
221
+ if (process.platform === 'win32') {
222
+ // open resolves at PowerShell spawn; keep it referenced until that launcher hands the URL to Windows.
223
+ const code = launcher.exitCode ?? await new Promise((resolve, reject) => {
224
+ function onError(error) {
225
+ launcher.off('close', onClose)
226
+ reject(error)
227
+ }
228
+ function onClose(code) {
229
+ launcher.off('error', onError)
230
+ resolve(code)
231
+ }
232
+ launcher.ref()
233
+ launcher.once('error', onError)
234
+ launcher.once('close', onClose)
235
+ })
236
+ if (code !== 0) throw new Error('browser operating-system launcher exited with code ' + String(code))
237
+ }
238
+ process.exitCode = 0
239
+ } catch (error) {
240
+ // The parent turns this exit into the manual-URL warning.
241
+ console.error(error)
242
+ process.exitCode = 1
243
+ }
244
+ `;
245
+ /**
246
+ * Resolve one LAN-trust snapshot from the active server bind.
247
+ *
248
+ * Derived entries are port-less IP literals: DNS rebinding needs an
249
+ * attacker-controlled name, while an IP-literal Host is safe on any port and
250
+ * an OS-assigned port is unknowable before bind.
251
+ * @param bindHost - the active webserver bind host.
252
+ * @param extra - explicit `--trusted-host` values, in argument order.
253
+ * @returns the LAN display addresses and invocation-derived fence authorities.
254
+ */
255
+ function resolveLanTrust(bindHost, extra) {
256
+ const lanAddresses = bindHost === ALL_INTERFACES_HOST ? Object.values(networkInterfaces()).flat().filter((iface) => iface !== void 0 && iface.family === "IPv4" && !iface.internal).map((iface) => iface.address) : [];
257
+ return {
258
+ lanAddresses,
259
+ trustedHosts: [...lanAddresses, ...extra]
260
+ };
261
+ }
262
+ /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
263
+ function webSurfacePrompt(webUrl) {
264
+ return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while \`pnpm run dev:web\` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL.`;
265
+ }
266
+ /** Resolve the canonical loopback URL from the active Web server. */
267
+ function localWebUrl(ctx) {
268
+ const port = ctx.get("webServer")?.port;
269
+ if (port === void 0) throw new Error("web-app: webServer service missing while resolving Web runtime");
270
+ return `http://${LOOPBACK_HOST}:${String(port)}`;
271
+ }
272
+ /** Dist location is workspace knowledge of this bundle: resolved through the frontend package exports, not configured. */
273
+ function resolveDistIndex() {
274
+ const require = createRequire(import.meta.url);
275
+ try {
276
+ return require.resolve("@monotykamary/dsh-web-frontend/dist/index.html");
277
+ } catch {
278
+ /* v8 ignore next 2 -- reachable only on a checkout without a built dist; the test tree builds it */
279
+ throw new Error("web-app: frontend dist not built; run pnpm run build from the repository root first");
280
+ }
281
+ }
282
+ /** Start the maintained platform opener without forwarding Harness credentials. */
283
+ function spawnBrowserLauncher(url) {
284
+ return spawn(process.execPath, [
285
+ "--input-type=module",
286
+ "--eval",
287
+ BROWSER_OPENER_PROGRAM,
288
+ "--",
289
+ url
290
+ ], {
291
+ env: scrubbedParentEnv(),
292
+ stdio: [
293
+ "ignore",
294
+ "inherit",
295
+ "pipe"
296
+ ]
297
+ });
298
+ }
299
+ /** Hand one URL to the operating system's default browser. */
300
+ async function openBrowser(url) {
301
+ const launcher = spawnBrowserLauncher(url);
302
+ let launcherStderr = "";
303
+ launcher.stderr?.setEncoding("utf8");
304
+ launcher.stderr?.on("data", (chunk) => {
305
+ launcherStderr += chunk;
306
+ });
307
+ await new Promise((resolve, reject) => {
308
+ function onError(error) {
309
+ launcher.off("close", onClose);
310
+ reject(error);
311
+ }
312
+ function onClose(code) {
313
+ launcher.off("error", onError);
314
+ if (code !== 0) {
315
+ const firstLine = launcherStderr.trim().split(/\r?\n/u)[0];
316
+ const reason = firstLine === void 0 || firstLine === "" ? `browser launcher exited with code ${String(code)}` : firstLine.replace(/^(?:[A-Za-z]*Error):\s*/u, "");
317
+ reject(new Error(reason));
318
+ return;
319
+ }
320
+ if (launcherStderr !== "") process.stderr.write(launcherStderr);
321
+ resolve();
322
+ }
323
+ launcher.once("error", onError);
324
+ launcher.once("close", onClose);
325
+ });
326
+ }
327
+ /** Test hooks for the built dist and native browser handoff; production never mutates them. */
328
+ /**
329
+ * Test hooks: hosts with no built frontend dist substitute the dist resolver;
330
+ * surface tests substitute the prober. Production never touches these.
331
+ */
332
+ /**
333
+ * Test hooks: hosts with no built frontend dist substitute the dist resolver;
334
+ * surface tests substitute the prober. Production never touches these.
335
+ */
336
+ const internals = {
337
+ resolveDistIndex,
338
+ resolveSurfaces: resolveRemoteSurfaces,
339
+ openBrowser
340
+ };
341
+ /**
342
+ * Resolve the enabled remote surfaces once per boot and publish their
343
+ * authorities into the /api fence. The add runs only after resolution
344
+ * commits, and a derived authority failing canonical validation is dropped
345
+ * from the announcement with a warning instead of silently widening trust.
346
+ * @param ctx - plugin context carrying the bound webServer and the connection fence owner.
347
+ * @param config - validated plugin config.
348
+ * @returns the settled surface snapshot the URL line announces.
349
+ */
350
+ async function settleSurfaces(ctx, config) {
351
+ const resolved = await internals.resolveSurfaces(ctx.webServer.port, config.tailnet, config.portless);
352
+ const connection = ctx.get("connection");
353
+ const resolution = { warnings: [...resolved.warnings] };
354
+ for (const key of ["tailnet", "portless"]) {
355
+ const surface = resolved[key];
356
+ if (surface === void 0) continue;
357
+ if (connection !== void 0) try {
358
+ connection.addTrustedAuthority(surface.authority);
359
+ } catch (error) {
360
+ resolution.warnings.push(`derived ${key} surface authority ${JSON.stringify(surface.authority)} refused: ${error instanceof Error ? error.message : String(error)}`);
361
+ continue;
362
+ }
363
+ resolution[key] = surface;
364
+ }
365
+ return resolution;
366
+ }
367
+ /**
368
+ * Print the readiness URL line: the loopback URL first (supervisors parse
369
+ * it), then the LAN snapshot and the derived remote surfaces.
370
+ * @param ctx - plugin context carrying the bound webServer.
371
+ * @param runtime - the bind-dependent LAN snapshot.
372
+ * @param resolution - the settled surface snapshot.
373
+ */
374
+ function printUrl(ctx, runtime, resolution) {
375
+ const port = ctx.webServer.port;
376
+ const entries = [];
377
+ const lanCandidate = runtime.lanAddresses[0];
378
+ if (lanCandidate !== void 0) entries.push(`LAN: http://${lanCandidate}:${String(port)}`);
379
+ if (resolution.tailnet !== void 0) entries.push(`tailnet: ${resolution.tailnet.url}`);
380
+ if (resolution.portless !== void 0) entries.push(`portless: ${resolution.portless.url}`);
381
+ console.log(`dsh web: ${localWebUrl(ctx)}${entries.length === 0 ? "" : ` (${entries.join(", ")})`}`);
382
+ }
383
+ /**
384
+ * Mount the Web runtime: dist serving, surface prompt, the bash runtime
385
+ * variable, the URL line, and the default-browser handoff.
386
+ * @param ctx - plugin context carrying the webServer service.
387
+ * @param config - validated {@link Config}.
388
+ */
389
+ function apply(ctx, config) {
390
+ const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts);
391
+ const handoffBrowser = config.openBrowser && !launchedThroughSsh(ctx);
392
+ ctx.provide(WEB_RUNTIME_SERVICE, runtime);
393
+ ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() });
394
+ if (config.surfaceContext) {
395
+ ctx.inject(["systemPrompt"], (promptCtx) => {
396
+ addHarnessSourceSection(promptCtx, SOURCE_ROOT);
397
+ promptCtx.systemPrompt.section({
398
+ name: "app:web-surface",
399
+ order: -98,
400
+ text: () => webSurfacePrompt(localWebUrl(promptCtx))
401
+ });
402
+ });
403
+ ctx.inject(["shellEnv"], (runtimeCtx) => {
404
+ runtimeCtx.shellEnv.register({
405
+ name: "web-runtime",
406
+ variables: { [DSH_WEB_URL]: { description: "Canonical local URL of the DeepSeek Harness Web GUI serving this session." } },
407
+ resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx) })
408
+ });
409
+ });
410
+ }
411
+ if (config.printUrl || handoffBrowser || config.tailnet || config.portless) {
412
+ const settled = ctx.get("loader")?.await();
413
+ const afterSettlement = async () => {
414
+ if (ctx.get("webServer") === void 0) return;
415
+ const webUrl = localWebUrl(ctx);
416
+ const resolution = await settleSurfaces(ctx, config);
417
+ for (const warning of resolution.warnings) ctx.logger.warn(warning);
418
+ if (config.printUrl) printUrl(ctx, runtime, resolution);
419
+ if (handoffBrowser) {
420
+ console.log("dsh web: opening the default browser; pass --no-open to disable");
421
+ internals.openBrowser(webUrl).catch((error) => {
422
+ const reason = error instanceof Error ? error.message : String(error);
423
+ console.error(`web-app: could not open the default browser because ${reason}; visit ${webUrl} manually`);
424
+ });
425
+ }
426
+ };
427
+ if (settled === void 0) afterSettlement();
428
+ else settled.then(afterSettlement, () => {});
429
+ }
430
+ }
431
+ //#endregion
432
+ export { Config, apply, inject, internals, name, resolveLanTrust };
@@ -0,0 +1,25 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@monotykamary/dsh-web-app`.
4
+ * @module @monotykamary/dsh-web-app/invariant
5
+ */
6
+ const PACKAGE_NAME = "@monotykamary/dsh-web-app";
7
+ /** Cordis companion plugin name. */
8
+ const name = "web-app-invariant";
9
+ /** Service required before the companion can register. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: every contribution (frontend-static child plugin,
13
+ * prompt section, bashEnv registration) is registry-disposed with the fiber,
14
+ * and each owning registry's package carries that relation's invariant; the
15
+ * package holds no mutable state of its own to audit.
16
+ */
17
+ const install = () => {};
18
+ /**
19
+ * Register this package's invariant companion.
20
+ * @param ctx - Cordis context carrying the invariant service.
21
+ * @returns the installed registration's disposer after setup succeeds.
22
+ */
23
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
24
+ //#endregion
25
+ export { apply, inject, name };
package/lib/startup.js ADDED
@@ -0,0 +1,68 @@
1
+ import { Command } from "commander";
2
+ import { parseCmdline } from "@monotykamary/dsh-cmdline";
3
+ //#region lib/types/startup.js
4
+ /**
5
+ * The web app's command-line provider: it parses the `dsh --profile web` flag
6
+ * family (`--host`, `--port`, `--trusted-host`, `--tailnet`,
7
+ * `--portless`, `--no-open`) and its `--help` text, then provides the
8
+ * immutable values as {@link WEB_STARTUP_SERVICE}. Ordinary rows inject that
9
+ * service before reading it from lazy config.
10
+ * @module @monotykamary/dsh-web-app/startup
11
+ */
12
+ /** Stable Cordis plugin name. */
13
+ const name = "web-startup";
14
+ /** Services required before the flags can be resolved. */
15
+ const inject = ["cmdlineArgs"];
16
+ /** Service provided by this ordinary plugin and injected by flag-configured rows. */
17
+ const WEB_STARTUP_SERVICE = "webStartup";
18
+ /**
19
+ * This app's command: its flags, its description, and its help text.
20
+ * @returns a fresh program, so one process can parse more than once (tests).
21
+ */
22
+ function webCommand() {
23
+ return new Command().name("dsh --profile web").description("Serve the DeepSeek Harness browser UI.").helpOption("-h, --help", "show this help").option("--host <host>", "bind host").option("--no-open", "do not open the Web UI in the default browser").option("--port <port>", "listen port; pass 0 to let the OS pick a free one").option("--trusted-host <authority...>", "extra authority the /api browser-trust fence accepts (host or host:port; repeatable)").option("--tailnet", "resolve the tailscale serve surface: trust its DNS name and announce its URL").option("--portless", "resolve the portless HTTPS surface: register the dsh alias, trust dsh.localhost, and announce it").option("--identity <provider>", "identity provider: header (trust a reverse proxy) or passkey (WebAuthn)").option("--identity-header <name>", "identity header the trusted proxy sets (default x-forwarded-user)").option("--identity-trusted-proxy <spec>", "source allowlist for the identity header: loopback (default), private, a CIDR, or an address").option("--identity-registration <policy>", "passkey registration: open (default) or closed").option("--identity-rp-name <name>", "passkey relying-party display name (default dsh)").addHelpText("after", `
24
+ Examples:
25
+ dsh --profile web serve on the composed host and port
26
+ dsh --profile web --no-open serve without opening a browser
27
+ dsh --profile web --port 8080 serve on another port
28
+ dsh --profile web --tailnet announce and trust https://<node>.ts.net
29
+ dsh --profile web --identity header partition sessions by a proxy-set x-forwarded-user
30
+ dsh --profile web --identity passkey self-contained WebAuthn login (prints an operator token)
31
+ `);
32
+ }
33
+ /**
34
+ * Parse and provide the Web invocation as an ordinary Cordis service. The
35
+ * command's action publishes the flags this invocation named; `--host 0.0.0.0`
36
+ * or a non-numeric `--port` is a usage error, so on rejection (and on `--help`)
37
+ * nothing is provided.
38
+ * @param ctx - plugin context carrying the command line.
39
+ */
40
+ function apply(ctx) {
41
+ const program = webCommand();
42
+ program.action(() => {
43
+ const options = program.opts();
44
+ if (options.host === "0.0.0.0") program.error("error: --host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead");
45
+ if (options.port !== void 0 && !/^\d+$/.test(options.port)) program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`);
46
+ const identity = options.identity === "header" ? {
47
+ provider: "header",
48
+ ...options.identityHeader !== void 0 && { header: options.identityHeader },
49
+ ...options.identityTrustedProxy !== void 0 && { trustedProxy: options.identityTrustedProxy }
50
+ } : options.identity === "passkey" ? {
51
+ provider: "passkey",
52
+ ...options.identityRegistration !== void 0 && { registration: options.identityRegistration },
53
+ ...options.identityRpName !== void 0 && { rpName: options.identityRpName }
54
+ } : void 0;
55
+ ctx.provide(WEB_STARTUP_SERVICE, {
56
+ openBrowser: options.open,
57
+ ...options.host !== void 0 && { host: options.host },
58
+ ...options.port !== void 0 && { port: Number(options.port) },
59
+ trustedHosts: options.trustedHost ?? [],
60
+ tailnet: options.tailnet ?? false,
61
+ portless: options.portless ?? false,
62
+ ...identity === void 0 ? {} : { identity }
63
+ });
64
+ });
65
+ parseCmdline(ctx, program);
66
+ }
67
+ //#endregion
68
+ export { WEB_STARTUP_SERVICE, apply, inject, name };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @monotykamary/dsh-web-app — the browser-surface bundle's runtime glue plugin
3
+ * plus the bundle patch (`cordis.patch.yml`, declared by the `dsh.bundle.patch`
4
+ * manifest field). The plugin owns the browser-surface glue: it resolves
5
+ * the built frontend dist (workspace knowledge of this bundle, never user
6
+ * config), mounts the `frontend-static` fallback owner over it, registers the
7
+ * harness-source and web-surface prompt sections, the bash-visible web runtime
8
+ * variable, the URL line, and the default-browser handoff. App command-line
9
+ * values arrive through the `webStartup` service expressions in the bundle
10
+ * patch.
11
+ * @module @monotykamary/dsh-web-app
12
+ */
13
+ import type { Context } from '@monotykamary/cordis';
14
+ import z from '@monotykamary/schemastery';
15
+ import { type SurfaceResolution } from './surfaces.ts';
16
+ /** Stable Cordis plugin name. */
17
+ export declare const name = "web-app";
18
+ /** Services required before the web runtime can mount. */
19
+ export declare const inject: string[];
20
+ /** Plugin config: composed deployment settings plus per-invocation command-line values. */
21
+ export interface Config {
22
+ /** Permit default-browser handoff after the Loader tree settles; an SSH launch suppresses it. */
23
+ openBrowser: boolean;
24
+ /** Print the URL line on activation; a non-interactive layer can turn it off. */
25
+ printUrl: boolean;
26
+ /**
27
+ * Register the model-visible surface context (the `app:web-surface` prompt
28
+ * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
29
+ * layer can turn it off when its user is not in the GUI, so the
30
+ * orientation text would be false.
31
+ */
32
+ surfaceContext: boolean;
33
+ /** Explicit `--trusted-host` authorities from this invocation. */
34
+ trustedHosts: string[];
35
+ /** `--tailnet`: resolve the tailscale serve surface, trust its DNS name, and announce its URL. */
36
+ tailnet: boolean;
37
+ /** `--portless`: resolve the portless HTTPS surface, trust its alias host, and announce it. */
38
+ portless: boolean;
39
+ }
40
+ export declare const Config: z<Config>;
41
+ /** Bind-dependent Web values shared by the trust fence and URL display. */
42
+ export interface WebRuntimeValues {
43
+ /** LAN IPv4 literals sampled once when the server binds all interfaces. */
44
+ lanAddresses: string[];
45
+ /** LAN literals followed by explicit invocation authorities. */
46
+ trustedHosts: string[];
47
+ }
48
+ /**
49
+ * Resolve one LAN-trust snapshot from the active server bind.
50
+ *
51
+ * Derived entries are port-less IP literals: DNS rebinding needs an
52
+ * attacker-controlled name, while an IP-literal Host is safe on any port and
53
+ * an OS-assigned port is unknowable before bind.
54
+ * @param bindHost - the active webserver bind host.
55
+ * @param extra - explicit `--trusted-host` values, in argument order.
56
+ * @returns the LAN display addresses and invocation-derived fence authorities.
57
+ */
58
+ export declare function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues;
59
+ /** Test hooks for the built dist and native browser handoff; production never mutates them. */
60
+ /**
61
+ * Test hooks: hosts with no built frontend dist substitute the dist resolver;
62
+ * surface tests substitute the prober. Production never touches these.
63
+ */
64
+ /**
65
+ * Test hooks: hosts with no built frontend dist substitute the dist resolver;
66
+ * surface tests substitute the prober. Production never touches these.
67
+ */
68
+ export declare const internals: {
69
+ resolveDistIndex: () => string;
70
+ resolveSurfaces: (port: number, tailnet: boolean, portless: boolean) => Promise<SurfaceResolution>;
71
+ openBrowser: (url: string) => Promise<void>;
72
+ };
73
+ /**
74
+ * Mount the Web runtime: dist serving, surface prompt, the bash runtime
75
+ * variable, the URL line, and the default-browser handoff.
76
+ * @param ctx - plugin context carrying the webServer service.
77
+ * @param config - validated {@link Config}.
78
+ */
79
+ export declare function apply(ctx: Context, config: Config): void;
80
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@monotykamary/dsh-web-app`.
3
+ * @module @monotykamary/dsh-web-app/invariant
4
+ */
5
+ import type { Context } from '@monotykamary/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "web-app-invariant";
8
+ /** Service required before the companion can register. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The web app's command-line provider: it parses the `dsh --profile web` flag
3
+ * family (`--host`, `--port`, `--trusted-host`, `--tailnet`,
4
+ * `--portless`, `--no-open`) and its `--help` text, then provides the
5
+ * immutable values as {@link WEB_STARTUP_SERVICE}. Ordinary rows inject that
6
+ * service before reading it from lazy config.
7
+ * @module @monotykamary/dsh-web-app/startup
8
+ */
9
+ import type { Context } from '@monotykamary/cordis';
10
+ /** Stable Cordis plugin name. */
11
+ export declare const name = "web-startup";
12
+ /** Services required before the flags can be resolved. */
13
+ export declare const inject: string[];
14
+ /** Service provided by this ordinary plugin and injected by flag-configured rows. */
15
+ export declare const WEB_STARTUP_SERVICE = "webStartup";
16
+ /** What the web rows read from {@link WEB_STARTUP_SERVICE}. */
17
+ export interface WebStartupValues {
18
+ /** Whether this invocation opens the default browser after startup. */
19
+ openBrowser: boolean;
20
+ /** `--host`, absent when the invocation did not name one. */
21
+ host?: string;
22
+ /** `--port`, absent when the invocation did not name one. */
23
+ port?: number;
24
+ /** Explicit `--trusted-host` authorities, in argument order. */
25
+ trustedHosts: string[];
26
+ /** `--tailnet`: resolve and trust the tailscale serve surface. */
27
+ tailnet: boolean;
28
+ /** `--portless`: resolve and trust the portless HTTPS surface. */
29
+ portless: boolean;
30
+ /** The identity provider config `--identity` assembled, absent when off. */
31
+ identity?: {
32
+ provider: 'header';
33
+ header?: string;
34
+ trustedProxy?: string;
35
+ } | {
36
+ provider: 'passkey';
37
+ rpName?: string;
38
+ registration?: 'open' | 'closed';
39
+ };
40
+ }
41
+ /**
42
+ * Parse and provide the Web invocation as an ordinary Cordis service. The
43
+ * command's action publishes the flags this invocation named; `--host 0.0.0.0`
44
+ * or a non-numeric `--port` is a usage error, so on rejection (and on `--help`)
45
+ * nothing is provided.
46
+ * @param ctx - plugin context carrying the command line.
47
+ */
48
+ export declare function apply(ctx: Context): void;
49
+ //# sourceMappingURL=startup.d.ts.map