@riceawa/dsh-lan-gateway 0.5.5 → 0.6.1

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 CHANGED
@@ -1897,33 +1897,91 @@ const name = "dsh-lan-gateway";
1897
1897
  /** Requires the web server service (binds before this row's apply runs) and the tool registry. */
1898
1898
  const inject = ["webServer", "tools"];
1899
1899
  /**
1900
- * The `lan-gateway` user-settings namespace, mirroring the composition schema.
1901
- * A plain string literal: dsh-settings dropped the `settingsNamespace()` brand
1902
- * helper in 0.1.2-rc.1 and `register` validates the literal itself, so this
1903
- * shape works against both that release line and the older branded one.
1900
+ * Schemastery configuration validated by the Loader.
1901
+ *
1902
+ * Every field is `.volatile()`, which is what lets the Settings service write
1903
+ * it: 0.1.7 projects only volatile fields into forms and refuses an edit to any
1904
+ * other path (`not volatile`). The mark also changes the runtime shape — a
1905
+ * volatile field arrives as a reference (see `ConfigRefs`), never as the plain
1906
+ * value the rest of this file expects — so read it through `readConfig`.
1904
1907
  */
1905
- const NS = "lan-gateway";
1906
- /** Schemastery configuration validated by the Loader. */
1907
1908
  const Config = z.object({
1908
- enabled: z.boolean().default(false),
1909
- gatewayPort: z.natural().min(1).max(65535).default(3081),
1910
- dshTargetPort: z.natural().min(1).max(65535),
1911
- lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
1912
- lanPasswordless: z.boolean().default(false),
1913
- authRequired: z.boolean().default(true),
1914
- cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
1915
- cookieName: z.string().default("dsh_gw_auth"),
1916
- tlsEnabled: z.boolean().default(false),
1917
- tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed"),
1918
- tlsCertPath: z.string(),
1919
- tlsKeyPath: z.string(),
1920
- tlsSelfSignedHosts: z.string().default("localhost"),
1921
- tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
1922
- allowInsecurePlaintext: z.boolean().default(false),
1923
- trustedTerminator: z.string(),
1924
- secureCookies: z.boolean()
1909
+ enabled: z.boolean().default(false).volatile(),
1910
+ gatewayPort: z.natural().min(1).max(65535).default(3081).volatile(),
1911
+ dshTargetPort: z.natural().min(1).max(65535).volatile(),
1912
+ lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]).volatile(),
1913
+ lanPasswordless: z.boolean().default(false).volatile(),
1914
+ authRequired: z.boolean().default(true).volatile(),
1915
+ cookieMaxAgeDays: z.natural().min(1).max(365).default(7).volatile(),
1916
+ cookieName: z.string().default("dsh_gw_auth").volatile(),
1917
+ tlsEnabled: z.boolean().default(false).volatile(),
1918
+ tlsMode: z.union([z.const("self-signed"), z.const("custom")]).default("self-signed").volatile(),
1919
+ tlsCertPath: z.string().volatile(),
1920
+ tlsKeyPath: z.string().volatile(),
1921
+ tlsSelfSignedHosts: z.string().default("localhost").volatile(),
1922
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825).volatile(),
1923
+ allowInsecurePlaintext: z.boolean().default(false).volatile(),
1924
+ trustedTerminator: z.string().volatile(),
1925
+ secureCookies: z.boolean().volatile()
1925
1926
  });
1926
1927
  /**
1928
+ * Unwrap the config references into the plain values every other function in
1929
+ * this file reads. Called on each access rather than once, because a settings
1930
+ * write updates the references in place.
1931
+ * @param refs - the config object handed to `apply`.
1932
+ * @returns one detached plain snapshot.
1933
+ */
1934
+ function readConfig(refs) {
1935
+ const dshTargetPort = refs.dshTargetPort?.get();
1936
+ const authRequired = refs.authRequired?.get();
1937
+ const tlsCertPath = refs.tlsCertPath?.get();
1938
+ const tlsKeyPath = refs.tlsKeyPath?.get();
1939
+ const tlsSelfSignedHosts = refs.tlsSelfSignedHosts?.get();
1940
+ const trustedTerminator = refs.trustedTerminator?.get();
1941
+ const secureCookies = refs.secureCookies?.get();
1942
+ return {
1943
+ enabled: refs.enabled.get(),
1944
+ gatewayPort: refs.gatewayPort.get(),
1945
+ lanCidrs: [...refs.lanCidrs.get()],
1946
+ lanPasswordless: refs.lanPasswordless.get(),
1947
+ cookieMaxAgeDays: refs.cookieMaxAgeDays.get(),
1948
+ cookieName: refs.cookieName.get(),
1949
+ tlsEnabled: refs.tlsEnabled.get(),
1950
+ tlsMode: refs.tlsMode.get(),
1951
+ tlsCertMaxAgeDays: refs.tlsCertMaxAgeDays.get(),
1952
+ allowInsecurePlaintext: refs.allowInsecurePlaintext.get(),
1953
+ ...dshTargetPort !== void 0 ? { dshTargetPort } : {},
1954
+ ...authRequired !== void 0 ? { authRequired } : {},
1955
+ ...tlsCertPath !== void 0 ? { tlsCertPath } : {},
1956
+ ...tlsKeyPath !== void 0 ? { tlsKeyPath } : {},
1957
+ ...tlsSelfSignedHosts !== void 0 ? { tlsSelfSignedHosts } : {},
1958
+ ...trustedTerminator !== void 0 ? { trustedTerminator } : {},
1959
+ ...secureCookies !== void 0 ? { secureCookies } : {}
1960
+ };
1961
+ }
1962
+ /**
1963
+ * Build the reference-shaped config `apply` receives, exactly as the Loader
1964
+ * builds it. Exported for tests that drive `apply` directly.
1965
+ * @param raw - a config object; missing fields take their schema defaults.
1966
+ * @returns one reference per volatile field.
1967
+ */
1968
+ function configRefs(raw) {
1969
+ return Config(raw);
1970
+ }
1971
+ /**
1972
+ * Validate a raw config object the way the Loader does, and unwrap it.
1973
+ *
1974
+ * `Config` marks every field volatile, so a validation hands the values back as
1975
+ * references (typed deeply-readonly by schemastery); this returns the plain
1976
+ * shape the rest of the file reads. Used to judge a config the Settings card is
1977
+ * about to save, before it is persisted.
1978
+ * @param raw - a config object; missing fields take their schema defaults.
1979
+ * @returns the validated plain config.
1980
+ */
1981
+ function validateConfig(raw) {
1982
+ return readConfig(configRefs(raw));
1983
+ }
1984
+ /**
1927
1985
  * The fail-closed problems that prevent a config from enabling the listener.
1928
1986
  * Returns every problem (not just the first) so the operator sees the full
1929
1987
  * migration at once. Exported for tests.
@@ -2101,16 +2159,18 @@ function apply(ctx, config) {
2101
2159
  let connectionGeneration = 0;
2102
2160
  /** Builds a fresh shared-session relay for a dsh port, once the base supports sessions. */
2103
2161
  let makeRelay;
2104
- /** The authoritative config: settings section when attached, else composition. */
2105
- let configSource = () => config;
2106
- /** Whether writes go to the settings section rather than staying in memory. */
2162
+ /** Whether the settings service is attached, so writes reach the profile entry. */
2107
2163
  let settingsAttached = false;
2108
- /** The settings scope for the `lan-gateway` namespace, while one is attached. */
2109
- let settingsScope;
2110
2164
  /**
2111
- * The settings provider, for the one write a scope cannot express: a section
2112
- * key must be *removed* to re-inherit the composition layer, and only the
2113
- * provider's path-addressed `mutate` can unset one.
2165
+ * This plugin's own Loader entry id. dsh 0.1.7 addresses a settings write by
2166
+ * the *entry id* — the `lan-gateway` namespace this plugin used to register
2167
+ * with is gone along with `settingsScope`.
2168
+ */
2169
+ let settingsEntryId;
2170
+ /**
2171
+ * The settings service, for the one write a merge patch cannot express: a key
2172
+ * must be *removed* to re-inherit the composition layer, and only its
2173
+ * path-addressed `mutate` can unset one.
2114
2174
  */
2115
2175
  let settingsProvider;
2116
2176
  /**
@@ -2121,7 +2181,7 @@ function apply(ctx, config) {
2121
2181
  let lifecycle = Promise.resolve();
2122
2182
  /** Set by the dispose hook; a start that completes after it must undo itself. */
2123
2183
  let disposed = false;
2124
- const effective = () => configSource();
2184
+ const effective = () => readConfig(config);
2125
2185
  /** Queue one lifecycle action behind every action already running. */
2126
2186
  const enqueue = (reason, action) => {
2127
2187
  lifecycle = lifecycle.then(action).catch((error) => {
@@ -2198,26 +2258,62 @@ function apply(ctx, config) {
2198
2258
  }
2199
2259
  });
2200
2260
  };
2201
- /** Record the run intent where it will survive: the settings section, or memory. */
2261
+ /** Record the run intent where it will survive: the profile entry, or memory. */
2202
2262
  const setRunIntent = async (enabled) => {
2203
- if (settingsAttached && settingsScope !== void 0) {
2204
- await settingsScope.update({ enabled });
2263
+ if (settingsAttached && settingsProvider !== void 0 && settingsEntryId !== void 0) {
2264
+ await settingsProvider.update(settingsEntryId, { enabled });
2205
2265
  return;
2206
2266
  }
2207
2267
  manualOverride = enabled;
2208
2268
  };
2269
+ /**
2270
+ * Apply a login-password change and reconcile the listener. One
2271
+ * implementation behind both surfaces the operator has — the `lan_gateway`
2272
+ * tool's `set-password` command and the Settings card's
2273
+ * `/lan-gateway/password` route — so the epoch bump, the listener stop on a
2274
+ * clear, and the first-password reconcile cannot diverge between them.
2275
+ *
2276
+ * The credential itself is never read back out: `state` holds a scrypt hash
2277
+ * and salt, and neither is returned to a caller.
2278
+ */
2279
+ const applyPassword = async (password) => {
2280
+ if (password !== void 0 && password.length > 0 && password.length < 8) return {
2281
+ ok: false,
2282
+ message: `Password must be at least 8 characters.`
2283
+ };
2284
+ const setting = password !== void 0 && password.length > 0;
2285
+ const hadPassword = state.password !== void 0;
2286
+ state = await setPassword(state, setting ? password : void 0);
2287
+ saveState(state);
2288
+ gateway?.setState(state);
2289
+ if (!setting) {
2290
+ await setRunIntent(false);
2291
+ return enqueue("password cleared", async () => {
2292
+ if (gateway !== void 0) await stopGateway();
2293
+ lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
2294
+ }).then(() => ({
2295
+ ok: true,
2296
+ message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
2297
+ }));
2298
+ }
2299
+ if (!hadPassword) await syncGateway("password set");
2300
+ return {
2301
+ ok: true,
2302
+ message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
2303
+ };
2304
+ };
2209
2305
  ctx.inject(["settings"], (sctx) => {
2210
- const scope = sctx.settings.register(NS, Config, { base: config });
2211
- settingsScope = scope;
2306
+ const entryId = ctx.fiber.entry?.options.id;
2307
+ if (entryId === void 0) return;
2308
+ settingsEntryId = entryId;
2212
2309
  settingsProvider = sctx.settings;
2213
2310
  settingsAttached = true;
2214
- configSource = () => scope.get();
2215
- sctx.effect(() => scope.watch(() => {
2311
+ sctx.effect(() => sctx.settings.configure({ auto: false }, ctx.fiber));
2312
+ sctx.effect(() => ctx.on("loader/volatile-update", () => {
2216
2313
  syncGateway("settings change");
2217
2314
  }));
2218
2315
  sctx.effect(() => () => {
2219
- configSource = () => config;
2220
- settingsScope = void 0;
2316
+ settingsEntryId = void 0;
2221
2317
  settingsProvider = void 0;
2222
2318
  settingsAttached = false;
2223
2319
  syncGateway("settings detach");
@@ -2241,22 +2337,29 @@ function apply(ctx, config) {
2241
2337
  });
2242
2338
  syncGateway("connection attach");
2243
2339
  });
2244
- const configRouteHandler = async (req, res) => {
2245
- const snapshot = () => {
2246
- const cfg = effective();
2247
- return {
2248
- config: cfg,
2249
- running: gateway !== void 0,
2250
- port: cfg.gatewayPort,
2251
- tls: tlsStatusLine(cfg),
2252
- upstreamSessionAvailable,
2253
- lastError: lastError ?? null
2254
- };
2255
- };
2256
- const send = (status, body) => {
2257
- res.writeHead(status, { "content-type": "application/json" });
2258
- res.end(JSON.stringify(body));
2340
+ const sendJson = (res, status, body) => {
2341
+ res.writeHead(status, { "content-type": "application/json" });
2342
+ res.end(JSON.stringify(body));
2343
+ };
2344
+ /**
2345
+ * What both card routes report. `passwordSet` is a boolean on purpose: the
2346
+ * card must be able to say whether a credential exists without any part of
2347
+ * that credential — not even its length — ever leaving this process.
2348
+ */
2349
+ const snapshot = () => {
2350
+ const cfg = effective();
2351
+ return {
2352
+ config: cfg,
2353
+ running: gateway !== void 0,
2354
+ port: cfg.gatewayPort,
2355
+ tls: tlsStatusLine(cfg),
2356
+ upstreamSessionAvailable,
2357
+ passwordSet: state.password !== void 0,
2358
+ lastError: lastError ?? null
2259
2359
  };
2360
+ };
2361
+ const configRouteHandler = async (req, res) => {
2362
+ const send = (status, body) => sendJson(res, status, body);
2260
2363
  if (!isTrustedConfigRequest(req)) {
2261
2364
  send(403, { error: "request refused: this route answers same-origin loopback requests only" });
2262
2365
  return;
@@ -2282,12 +2385,14 @@ function apply(ctx, config) {
2282
2385
  send(400, { error: "body must be a config object" });
2283
2386
  return;
2284
2387
  }
2285
- if (settingsProvider === void 0) {
2388
+ const settings = settingsProvider;
2389
+ const entryId = settingsEntryId;
2390
+ if (settings === void 0 || entryId === void 0) {
2286
2391
  send(409, { error: "settings service unavailable — edit the profile patch (cordis.patch.yml) instead" });
2287
2392
  return;
2288
2393
  }
2289
2394
  const { patch, clear, unknown } = buildConfigPatch(submitted);
2290
- const candidate = Config({
2395
+ const candidate = validateConfig({
2291
2396
  ...effective(),
2292
2397
  ...patch
2293
2398
  });
@@ -2306,7 +2411,7 @@ function apply(ctx, config) {
2306
2411
  op: "unset",
2307
2412
  path: [key]
2308
2413
  }))];
2309
- if (ops.length > 0) await settingsProvider.mutate(NS, ops);
2414
+ if (ops.length > 0) await settings.mutate(entryId, ops);
2310
2415
  await syncGateway("config route save");
2311
2416
  const next = { ...snapshot() };
2312
2417
  if (unknown.length > 0) next["ignored"] = unknown;
@@ -2320,7 +2425,42 @@ function apply(ctx, config) {
2320
2425
  path: "/lan-gateway/config",
2321
2426
  handler: configRouteHandler
2322
2427
  }), "dsh-lan-gateway: config route");
2323
- ctx.tools.register(lanGatewayTool({
2428
+ const passwordRouteHandler = async (req, res) => {
2429
+ if (!isTrustedConfigRequest(req)) {
2430
+ sendJson(res, 403, { error: "request refused: this route answers same-origin loopback requests only" });
2431
+ return;
2432
+ }
2433
+ if (req.method !== "POST") {
2434
+ sendJson(res, 405, { error: "method not allowed" });
2435
+ return;
2436
+ }
2437
+ const body = await readBody(req, 4096, res);
2438
+ if (body === void 0) return;
2439
+ let submitted;
2440
+ try {
2441
+ submitted = JSON.parse(body);
2442
+ } catch {
2443
+ sendJson(res, 400, { error: "invalid JSON body" });
2444
+ return;
2445
+ }
2446
+ const password = typeof submitted === "object" && submitted !== null && !Array.isArray(submitted) ? submitted["password"] : void 0;
2447
+ if (typeof password !== "string" || password.length === 0) {
2448
+ sendJson(res, 400, { error: `password must be a non-empty string of at least 8 characters; this route only sets one — use the lan_gateway tool to clear it` });
2449
+ return;
2450
+ }
2451
+ const result = await applyPassword(password);
2452
+ if (!result.ok) {
2453
+ sendJson(res, 400, { error: result.message });
2454
+ return;
2455
+ }
2456
+ sendJson(res, 200, snapshot());
2457
+ };
2458
+ ctx.effect(() => ctx.webServer.register({
2459
+ kind: "exact",
2460
+ path: "/lan-gateway/password",
2461
+ handler: passwordRouteHandler
2462
+ }), "dsh-lan-gateway: password route");
2463
+ const controller = {
2324
2464
  status() {
2325
2465
  const cfg = desiredConfig();
2326
2466
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port;
@@ -2349,32 +2489,7 @@ function apply(ctx, config) {
2349
2489
  message: "Gateway disabled."
2350
2490
  };
2351
2491
  },
2352
- async setPassword(password) {
2353
- if (password !== void 0 && password.length > 0 && password.length < 8) return {
2354
- ok: false,
2355
- message: "Password must be at least 8 characters."
2356
- };
2357
- const setting = password !== void 0 && password.length > 0;
2358
- const hadPassword = state.password !== void 0;
2359
- state = await setPassword(state, setting ? password : void 0);
2360
- saveState(state);
2361
- gateway?.setState(state);
2362
- if (!setting) {
2363
- await setRunIntent(false);
2364
- return enqueue("password cleared", async () => {
2365
- if (gateway !== void 0) await stopGateway();
2366
- lastError = "Password cleared — the gateway listener was stopped (a password is required to run).";
2367
- }).then(() => ({
2368
- ok: true,
2369
- message: "Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again."
2370
- }));
2371
- }
2372
- if (!hadPassword) await syncGateway("password set");
2373
- return {
2374
- ok: true,
2375
- message: "Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again."
2376
- };
2377
- },
2492
+ setPassword: applyPassword,
2378
2493
  rotateSecret() {
2379
2494
  const next = {
2380
2495
  cookieSecret: randomBytes(32).toString("base64"),
@@ -2424,7 +2539,8 @@ function apply(ctx, config) {
2424
2539
  message: `Failed to regenerate TLS certificate: ${failure}`
2425
2540
  };
2426
2541
  }
2427
- }));
2542
+ };
2543
+ ctx.tools.register(lanGatewayTool(controller));
2428
2544
  ctx.effect(() => {
2429
2545
  syncGateway("boot");
2430
2546
  return async () => {
@@ -2434,4 +2550,4 @@ function apply(ctx, config) {
2434
2550
  }, "dsh-lan-gateway: listener lifecycle");
2435
2551
  }
2436
2552
  //#endregion
2437
- export { Config, apply, buildConfigPatch, gatewayStartProblems, inject, isTrustedConfigRequest, name, resolveSecureCookies };
2553
+ export { Config, apply, buildConfigPatch, configRefs, gatewayStartProblems, inject, isTrustedConfigRequest, name, readConfig, resolveSecureCookies, validateConfig };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@riceawa/dsh-lan-gateway",
3
- "description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, and TLS. Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
4
- "version": "0.5.5",
3
+ "description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, TLS, and the login password (the card overwrites the stored credential without ever displaying the old one; the password stays a scrypt hash in state.json, out of the config schema). Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
4
+ "version": "0.6.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -42,22 +42,34 @@
42
42
  }
43
43
  },
44
44
  "peerDependencies": {
45
- "@deepseek-ai/cordis": "^4.0.2",
46
- "@deepseek-ai/dsh-settings": "^0.1.2-rc.1 || ^0.1.5-rc.2",
47
- "@deepseek-ai/dsh-tools": "^0.1.2-rc.1 || ^0.1.5-rc.2",
48
- "@deepseek-ai/schemastery": "^3.18.2"
45
+ "@deepseek-ai/cordis": "^4.0.4",
46
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.2 || ^0.2.0-rc.1",
47
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.1",
48
+ "@deepseek-ai/schemastery": "^3.18.4"
49
49
  },
50
50
  "devDependencies": {
51
- "@deepseek-ai/cordis": "4.0.2",
52
- "@deepseek-ai/dsh-brand": "0.1.5-rc.2",
51
+ "@deepseek-ai/cordis": "4.0.4",
52
+ "@deepseek-ai/cordis-plugin-loader": "1.0.5",
53
+ "@deepseek-ai/cosmokit": "1.8.5",
54
+ "@deepseek-ai/dsh-brand": "0.1.7-rc.2",
55
+ "@deepseek-ai/dsh-agent": "0.1.7-rc.2",
56
+ "@deepseek-ai/dsh-invariants": "0.1.7-rc.2",
57
+ "@deepseek-ai/dsh-ptc-runtime": "0.1.7-rc.2",
58
+ "@deepseek-ai/dsh-sandbox": "0.1.7-rc.2",
59
+ "@deepseek-ai/dsh-sandbox-policy": "0.1.7-rc.2",
60
+ "@deepseek-ai/dsh-session": "0.1.7-rc.2",
61
+ "@deepseek-ai/dsh-system-prompt": "0.1.7-rc.2",
62
+ "@deepseek-ai/dsh-user-approval": "0.1.7-rc.2",
53
63
  "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
54
- "@deepseek-ai/dsh-client-ui-slots": "0.1.5-rc.2",
55
- "@deepseek-ai/dsh-llm": "0.1.5-rc.2",
56
- "@deepseek-ai/dsh-scope": "0.1.5-rc.2",
57
- "@deepseek-ai/dsh-settings": "0.1.5-rc.2",
58
- "@deepseek-ai/dsh-tools": "0.1.5-rc.2",
59
- "@deepseek-ai/dsh-util-values": "0.1.5-rc.2",
60
- "@deepseek-ai/schemastery": "3.18.2",
64
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "0.1.7-rc.2",
65
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.7-rc.2",
66
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.7-rc.2",
67
+ "@deepseek-ai/dsh-llm": "0.1.7-rc.2",
68
+ "@deepseek-ai/dsh-scope": "0.1.7-rc.2",
69
+ "@deepseek-ai/dsh-settings": "0.1.7-rc.2",
70
+ "@deepseek-ai/dsh-tools": "0.1.7-rc.2",
71
+ "@deepseek-ai/dsh-util-values": "0.1.7-rc.2",
72
+ "@deepseek-ai/schemastery": "3.18.4",
61
73
  "@types/node": "^22.0.0",
62
74
  "@types/react": "~18.3.1",
63
75
  "react": "^18.2.0",
@@ -41,12 +41,26 @@ Do not edit state files by hand — use the `lan_gateway` tool.
41
41
  - `lan_gateway` with `command: "set-password"` and `password: "<new pass>"`
42
42
  (min 8 chars) — set the login password. Setting it revokes every existing session
43
43
  (all sources, LAN included, must sign in again). Pass an empty password to clear —
44
- clearing stops the listener (a password is required to run).
44
+ clearing stops the listener (a password is required to run). The user can also
45
+ change it themselves from the Plugins card; see below.
45
46
  - `lan_gateway` with `command: "rotate-secret"` — invalidate every issued login
46
47
  cookie and every live WebSocket (users must sign in again).
47
48
  - `lan_gateway` with `command: "tls-regenerate"` — mint a fresh self-signed
48
49
  certificate and hot-restart the listener (tlsMode must be `self-signed`).
49
50
 
51
+ ## The user can change it without the agent
52
+
53
+ The **Plugins** page's LAN gateway card, on the machine running dsh, carries a
54
+ login-password form. It reports Set / Not set (never the stored password), takes
55
+ the new password twice, and overwrites the old one directly — no old password
56
+ required. Point the user there when they would rather not hand the password to
57
+ the model; use the tool when they ask you to do it.
58
+
59
+ The card is loopback-only, so a browser reaching dsh through the gateway gets a
60
+ 403 for it, and it can only *set* a password: clearing stops the listener, which
61
+ stays a `lan_gateway set-password` command. A change made there behaves exactly
62
+ like the tool's — the session epoch advances and every session is revoked.
63
+
50
64
  ## After enabling
51
65
 
52
66
  Tell the user the gateway URL they can share:
@@ -7,10 +7,9 @@
7
7
  * installs a getRandomValues-backed `randomUUID` on the Crypto prototype at
8
8
  * module scope. With TLS enabled the origin is secure and the shim is a
9
9
  * no-op.
10
- * 2. Settings card: registers the LAN gateway card into the official
11
- * Settings → Plugins page (`settings.plugin.item` slot), editing the
12
- * `lan-gateway` settings namespace so port, CIDRs, auth, and TLS are
13
- * adjustable from the GUI.
10
+ * 2. Settings card: registers the LAN gateway card into the official Plugins
11
+ * page (`plugins.item` slot) so port, CIDRs, auth, and TLS stay adjustable
12
+ * from the GUI.
14
13
  */
15
14
 
16
15
  /** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
@@ -62,12 +61,25 @@ export function installRandomUuidShim(): boolean {
62
61
  installRandomUuidShim()
63
62
 
64
63
  import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
65
- import { LanGatewayCard } from './lan-gateway-card.tsx'
64
+ // Type-only, and never bundled: the Plugins page's slot contract (`plugins.item`)
65
+ // and the settings domain's `configForms` service both resolve from the web
66
+ // shell's frozen module table. Importing them is what subjects the registration
67
+ // below to the platform's own contract instead of a local copy that drifts the
68
+ // next time upstream renames a slot.
69
+ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
70
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
71
+ import { LanGatewayCard, cardTitle } from './lan-gateway-card.tsx'
66
72
 
67
73
  export const name = 'dsh-lan-gateway'
68
74
 
69
- /** Only the slots service: the card itself is self-loading (ModLens-style). */
70
- export const inject = ['slots']
75
+ /**
76
+ * The profile entry id this plugin's bundle patch composes it under, and the
77
+ * settings namespace dsh ≥ 0.1.7 addresses every write by.
78
+ */
79
+ const ENTRY_ID = 'dsh-lan-gateway'
80
+
81
+ /** The slots service the card rides, and the settings mirror the gate reads. */
82
+ export const inject = ['slots', 'configForms']
71
83
 
72
84
  /**
73
85
  * Mount the settings card and the UUID shim.
@@ -76,28 +88,26 @@ export const inject = ['slots']
76
88
  export function apply(ctx: ClientContext): void {
77
89
  installRandomUuidShim()
78
90
 
79
- // The card rides the official Plugins → Configurable tab. Like ModLens, it
80
- // registers with no inject face and fetches its own loopback config route,
81
- // so it has no settings/locale/connection service dependencies.
91
+ // The card rides the official Plugins page. Like ModLens it registers with no
92
+ // inject face and fetches its own loopback config route, so it depends on no
93
+ // settings, locale, or connection service.
82
94
  //
83
- // The `settings.plugin.item` slot is keyed BY the settings namespace the
84
- // card edits (rc.8 contract): the configurable tab only dispatches entries
85
- // whose `options.key` is both present and served by the Host's settings
86
- // describe mirror. Registering with `id` alone throws
87
- // `keyed slot "settings.plugin.item" requires options.key` and the card
88
- // silently disappears from Settings → Plugins.
95
+ // dsh 0.1.7 replaced the namespace-keyed `settings.plugin.item` slot with the
96
+ // list slot `plugins.item`, which is where a host-plane plugin's own
97
+ // configuration page belongs ("one companion package per host-plane
98
+ // namespace"); the page renders the contribution as the card's one-liner and,
99
+ // once opened, as the body of the plugin's own page. Registering into the
100
+ // retired slot left the card invisible on 0.1.7.
89
101
  //
90
- // `id`/`order` ride the legacy list-slot shape (older DSH versions
91
- // dispatched this slot by id): harmless metadata on the keyed slot, and
92
- // what keeps the card mounting if this plugin ever loads into an older
93
- // deployment. Spread from a typed constant so the keyed registration type
94
- // stays exact.
95
- const legacyListOptions = { id: 'lan-gateway', order: 30 } as const
96
- ctx.slots.inject('settings.plugin.item', function* () {
97
- yield ctx.slots.register({
98
- name: 'settings.plugin.item',
99
- key: 'lan-gateway',
100
- ...legacyListOptions,
101
- }, LanGatewayCard)
102
- })
102
+ // `whileServed` keeps the entry off the page until the Host's settings mirror
103
+ // serves this plugin's entry. That is the one gate worth having: without a
104
+ // Loader entry there is nothing to write to, and the card would appear only
105
+ // to fail every save with the route's 409.
106
+ ctx.effect(() => ctx.configForms.whileServed([ENTRY_ID], () =>
107
+ ctx.slots.inject('plugins.item', () => ctx.slots.register({
108
+ name: 'plugins.item',
109
+ id: ENTRY_ID,
110
+ order: 30,
111
+ label: () => cardTitle(),
112
+ }, LanGatewayCard))))
103
113
  }