dsh-zen-remote 2.3.11 → 2.4.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/CHANGELOG.md +45 -78
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +608 -375
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-ops.js +68 -0
- package/lib/share-ops.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +47 -1
- package/lib/types/share-ops.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +608 -380
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-ops.ts +82 -1
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
package/lib/index.js
CHANGED
|
@@ -20,17 +20,17 @@ import { lstat, mkdir, open, realpath, rename, rm, writeFile } from 'node:fs/pro
|
|
|
20
20
|
import { randomBytes } from 'node:crypto';
|
|
21
21
|
import { homedir, networkInterfaces } from 'node:os';
|
|
22
22
|
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
23
|
-
import { readFileConfig, resolveConfig, unwrapVolatile } from './config.js';
|
|
23
|
+
import { readFileConfig, resolveConfig, unwrapVolatile, servesRole, connectsRole } from './config.js';
|
|
24
24
|
import { createActivityTracker, createParentIndex, seedParentIndexFromSnapshots, startSweeper } from './activity.js';
|
|
25
25
|
import { ADMIN_ROUTE_PREFIX, createAdminHandler } from './admin-routes.js';
|
|
26
|
-
import { normalizedRowServerUrl } from './client-pairing.js';
|
|
27
26
|
import { CLIENT_ROUTE_PREFIX, createClientHandler } from './client-routes.js';
|
|
28
27
|
import { responseJson, sameOriginPost } from './http.js';
|
|
29
|
-
import { onSessionCreated, restoreBusy } from './share-ops.js';
|
|
28
|
+
import { isBlankSession, isFirstTurnStart, onSessionCreated, restoreBusy } from './share-ops.js';
|
|
30
29
|
import { startRestartWatcher } from './restart-watcher.js';
|
|
31
30
|
import { handleShareExport, SHARE_EXPORT_ROUTE } from './share-export.js';
|
|
32
31
|
import { createShareStore } from './share-store.js';
|
|
33
|
-
import {
|
|
32
|
+
import { createRelayPool } from './relay-pool.js';
|
|
33
|
+
import { volatileUpdateTouchesServerName } from './relay-client.js';
|
|
34
34
|
import { computeFingerprints } from './fingerprint.js';
|
|
35
35
|
import { runSelfCheck, installIntercept } from './intercept.js';
|
|
36
36
|
import { checkGatewayShape } from './intercept-shape.js';
|
|
@@ -58,12 +58,14 @@ import { createPluginLoopback } from './plugin-loopback.js';
|
|
|
58
58
|
import { createPluginTunnel } from './plugin-tunnel.js';
|
|
59
59
|
import { createLogRing, createPluginLog } from './log-ring.js';
|
|
60
60
|
import { DEFAULT_MAX_UPLOAD_BYTES } from './upload-limits.js';
|
|
61
|
+
import { applyUnshadowedRowKeys, clearUnshadowedRowKeys, writeCliConfig } from './cli/config-writer.js';
|
|
62
|
+
import { acquireCliIpcServer } from './cli/ipc-server.js';
|
|
61
63
|
// The loader's config schema (settings form) and the role normalizer live in
|
|
62
64
|
// src/config.ts next to the resolution they describe; re-exported so the
|
|
63
65
|
// plugin row's public surface stays on the main entry. The same-origin gate
|
|
64
66
|
// moved to src/http.ts next to the response envelope (T14) but its export
|
|
65
67
|
// stays here for scripts/check-upload-endpoint.mjs.
|
|
66
|
-
export { Config, resolveRole } from './config.js';
|
|
68
|
+
export { Config, resolveRole, servesRole, connectsRole } from './config.js';
|
|
67
69
|
export { sameOriginPost } from './http.js';
|
|
68
70
|
// The two sub-plugin entries the host role loads. They ship as plain .mjs at
|
|
69
71
|
// the package root, and `..` resolves there both from this file (via the
|
|
@@ -124,10 +126,27 @@ const MAX_COLLISION_TRIES = 100;
|
|
|
124
126
|
* compositions that never ran this module's apply).
|
|
125
127
|
*/
|
|
126
128
|
let clientRelayClient;
|
|
127
|
-
/** The live client-role relay client, if one was built.
|
|
129
|
+
/** The live client-role relay client, if one was built. MS-5: the wiring no
|
|
130
|
+
* longer builds one — the relay POOL below replaced it — so this stays
|
|
131
|
+
* undefined in production compositions; the getter (and the client-routes
|
|
132
|
+
* option of the same shape) remain for the single-client contract's old
|
|
133
|
+
* consumers and tests. */
|
|
128
134
|
export function getRelayClient() {
|
|
129
135
|
return clientRelayClient;
|
|
130
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The client role's relay pool (MS-5), created inside apply() and kept on
|
|
139
|
+
* the module so the debug-report wiring or a later half can reach it without
|
|
140
|
+
* reaching into a context. Undefined on a pure host role (and in
|
|
141
|
+
* compositions that never ran this module's apply), and — identity-guarded
|
|
142
|
+
* in the disposal effect — after the row that owned a pool was unloaded or
|
|
143
|
+
* reloaded (the T23a-fix discipline the relay client's slot always followed).
|
|
144
|
+
*/
|
|
145
|
+
let clientRelayPool;
|
|
146
|
+
/** The live client-role relay pool, if one was built. */
|
|
147
|
+
export function getRelayPool() {
|
|
148
|
+
return clientRelayPool;
|
|
149
|
+
}
|
|
131
150
|
/**
|
|
132
151
|
* The plugin bridge's declaration registry (PB-1), created inside apply() on
|
|
133
152
|
* BOTH roles and kept on the module so later tasks (the handshake's plugins
|
|
@@ -447,6 +466,8 @@ export async function handleUpload(ctx, maxBytes, req, res, admit, warn) {
|
|
|
447
466
|
* request.
|
|
448
467
|
*/
|
|
449
468
|
export function apply(ctx, config = {}) {
|
|
469
|
+
const initialHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
470
|
+
applyUnshadowedRowKeys(initialHome, config);
|
|
450
471
|
// CU-1: the plugin's own log ring (SPEC 方案 C) — rebuilt per apply, so a
|
|
451
472
|
// row reload clears it (expected: the report describes THIS process
|
|
452
473
|
// generation). `logWarn` / `logInfo` are the branch-shared double-writers:
|
|
@@ -635,10 +656,44 @@ export function apply(ctx, config = {}) {
|
|
|
635
656
|
return undefined;
|
|
636
657
|
}
|
|
637
658
|
};
|
|
638
|
-
|
|
659
|
+
// MS-5: the two role halves are INDEPENDENT branches now, not an either/or
|
|
660
|
+
// — a future 'peer' deploy (servesRole AND connectsRole both true) runs
|
|
661
|
+
// both; host runs only the serving half; client only the dialing half.
|
|
662
|
+
// CLI-1: the row-scoped pieces the CLI IPC delegate forwards to. The role
|
|
663
|
+
// branches fill the slots they own; the restart-watcher effect at the
|
|
664
|
+
// bottom hands them to acquireCliIpcServer as ONE delegate, whose release
|
|
665
|
+
// rides the same effect (role-wiring pins the top-level effect counts, so
|
|
666
|
+
// the IPC lifecycle may not register its own).
|
|
667
|
+
const noopAdmit = () => ({ peer: {} });
|
|
668
|
+
const reflectNow = () => ctx.reflect;
|
|
669
|
+
let cliWizardHandler;
|
|
670
|
+
let cliAdminHandler;
|
|
671
|
+
let cliClientHandler;
|
|
672
|
+
let reportedBrowserLang;
|
|
673
|
+
const resolveActiveLang = () => {
|
|
674
|
+
const cfg = resolveConfig(config, readFileConfig(), process.env).values.lang;
|
|
675
|
+
if (cfg === 'en')
|
|
676
|
+
return 'en';
|
|
677
|
+
if (cfg === 'zh')
|
|
678
|
+
return 'zh';
|
|
679
|
+
if (reportedBrowserLang !== undefined)
|
|
680
|
+
return reportedBrowserLang;
|
|
681
|
+
return 'zh';
|
|
682
|
+
};
|
|
683
|
+
/** The managed ingress children THIS row generation owns (host branch
|
|
684
|
+
* only): the restart-watcher disposer stops them, which is the ONLY stop
|
|
685
|
+
* on compositions without a webServer (there the wizard effect — the
|
|
686
|
+
* historic owner of the stop — never fires). */
|
|
687
|
+
const cliManagedChildren = [];
|
|
688
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
689
|
+
if (servesRole(effective.values.role)) {
|
|
639
690
|
// A row that switched from client to host must not leave a stale relay
|
|
640
|
-
// client where getRelayClient() can find it (T23a-fix)
|
|
691
|
+
// client where getRelayClient() can find it (T23a-fix) — and no stale
|
|
692
|
+
// pool either where getRelayPool() can (MS-5; a peer row rebuilds its
|
|
693
|
+
// pool right after, this block having only cleared the previous
|
|
694
|
+
// generation's slot).
|
|
641
695
|
clientRelayClient = undefined;
|
|
696
|
+
clientRelayPool = undefined;
|
|
642
697
|
// The relay shared secret (T22a): minted fresh per apply, handed to the
|
|
643
698
|
// gateway child through LAN_GATE_RELAY_SECRET and kept here for the
|
|
644
699
|
// relay routes below. The push half never sees it.
|
|
@@ -668,10 +723,14 @@ export function apply(ctx, config = {}) {
|
|
|
668
723
|
// The shared-session table backing the relay's access control. The relay
|
|
669
724
|
// route consumes it below; the activity tracker and idle sweeper (T22c)
|
|
670
725
|
// keep its "quiet for idleHours → close" promise. The settings surface is
|
|
671
|
-
// a later task.
|
|
672
|
-
|
|
726
|
+
// a later task. (`home` is hoisted to apply() level — the CLI IPC
|
|
727
|
+
// delegate binds its socket under the same directory.)
|
|
728
|
+
// MS-1: the table now lives INSIDE the three-in-one state file (alongside
|
|
729
|
+
// the server identity and the paired-servers list); share-store's loader
|
|
730
|
+
// migrates a legacy zen-remote-shares.json in and every write preserves
|
|
731
|
+
// the other two top-level fields.
|
|
673
732
|
const store = createShareStore({
|
|
674
|
-
file: join(home, 'zen-remote-
|
|
733
|
+
file: join(home, 'zen-remote-state.json'),
|
|
675
734
|
idleHours: effective.values.idleHours,
|
|
676
735
|
});
|
|
677
736
|
// The live relay handler, kept so the admin shares route can read viewer
|
|
@@ -696,6 +755,17 @@ export function apply(ctx, config = {}) {
|
|
|
696
755
|
// clocks and the relay can walk the same chain for reachability.
|
|
697
756
|
const parentIndex = createParentIndex();
|
|
698
757
|
const tracker = createActivityTracker(store, parentIndex.parentOf);
|
|
758
|
+
// Track sessions explicitly unshared by a user or client during this row's
|
|
759
|
+
// lifetime so the `turn/start` (turn 1) fallback for `reuseLive` blank
|
|
760
|
+
// sessions never overrides an intentional manual unshare before Turn 1.
|
|
761
|
+
const manuallyUnshared = new Set();
|
|
762
|
+
store.subscribe((ev) => {
|
|
763
|
+
if (ev.type === 'shared')
|
|
764
|
+
manuallyUnshared.delete(ev.sessionId);
|
|
765
|
+
else if (ev.type === 'unshared' && (ev.reason === 'manual' || ev.reason === 'client')) {
|
|
766
|
+
manuallyUnshared.add(ev.sessionId);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
699
769
|
try {
|
|
700
770
|
ctx.on('session/event', (session, event) => {
|
|
701
771
|
// Record the parent link BEFORE tracking, so a child's very first
|
|
@@ -704,23 +774,38 @@ export function apply(ctx, config = {}) {
|
|
|
704
774
|
const sessionId = session?.id;
|
|
705
775
|
if (typeof sessionId !== 'string' || sessionId === '')
|
|
706
776
|
return;
|
|
777
|
+
// When `session/create` reuses a blank session that was ALREADY live
|
|
778
|
+
// in memory (`reuseLive`), DSH emits no `agent/created`. If that blank
|
|
779
|
+
// session was not shared (e.g. `autoShareNewSessions` was turned on
|
|
780
|
+
// after it was pre-created, or its idle window expired in memory) and
|
|
781
|
+
// was not manually unshared while blank, share it as Turn 1 starts so
|
|
782
|
+
// `tracker.onEvent` right below marks it busy too.
|
|
783
|
+
if (isFirstTurnStart(session, event) && !manuallyUnshared.has(sessionId) && !store.isShared(sessionId)) {
|
|
784
|
+
try {
|
|
785
|
+
onSessionCreated(session.header, {
|
|
786
|
+
store,
|
|
787
|
+
autoShare: resolveConfig(config, readFileConfig(), process.env).values.autoShareNewSessions,
|
|
788
|
+
parentOf: parentIndex.parentOf,
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
logWarn('dsh-zen-remote cannot share new session "%s": %s', sessionId, message(error));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
707
795
|
tracker.onEvent(sessionId, event.type);
|
|
708
796
|
});
|
|
709
797
|
}
|
|
710
798
|
catch (error) {
|
|
711
799
|
logWarn('dsh-zen-remote cannot listen on "session/event": %s', message(error));
|
|
712
800
|
}
|
|
713
|
-
// Auto-share and fork-following (T33a), off `agent/created
|
|
714
|
-
//
|
|
715
|
-
// 'startup' for fresh creation (top-level, fork and subagent all go
|
|
801
|
+
// Auto-share and fork-following (T33a), off `agent/created`. Its `source`
|
|
802
|
+
// is 'startup' for fresh creation (top-level, fork and subagent all go
|
|
716
803
|
// through the same factory call) and 'resume' when a persisted session
|
|
717
|
-
// re-enters the live store
|
|
718
|
-
//
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
// `dsh-session/types#SessionId`), and by announce time the fresh header
|
|
723
|
-
// already carries the subagent/fork markers the share decision reads.
|
|
804
|
+
// re-enters the live store. DSH's "New Session" (`createInWorkspace` →
|
|
805
|
+
// `session/create`) reuses persisted blank sessions (zero turns ever
|
|
806
|
+
// started) via `agents.resume`, which arrives with `source === 'resume'`;
|
|
807
|
+
// `isBlankSession` lets those reclaimed placeholders auto-share while
|
|
808
|
+
// keeping established conversations closed across reopens.
|
|
724
809
|
// autoShareNewSessions is a volatile row setting: resolved at EVENT time,
|
|
725
810
|
// never snapshotted here.
|
|
726
811
|
try {
|
|
@@ -739,8 +824,14 @@ export function apply(ctx, config = {}) {
|
|
|
739
824
|
// whose share it inherits through its parent, and the forwarded
|
|
740
825
|
// status frames the client needs to announce the row never left.
|
|
741
826
|
parentIndex.observe(agent.session);
|
|
742
|
-
|
|
827
|
+
const turnBoundaryOf = (s) => {
|
|
828
|
+
const ag = reflectNow()?.get('agents');
|
|
829
|
+
return typeof ag?.turnBoundaryOf === 'function' ? ag.turnBoundaryOf(s) : undefined;
|
|
830
|
+
};
|
|
831
|
+
if (source !== 'startup' && !(source === 'resume' && isBlankSession(agent.session, turnBoundaryOf)))
|
|
743
832
|
return;
|
|
833
|
+
if (typeof agent.id === 'string')
|
|
834
|
+
manuallyUnshared.delete(agent.id);
|
|
744
835
|
onSessionCreated(agent.session.header, {
|
|
745
836
|
store,
|
|
746
837
|
autoShare: resolveConfig(config, readFileConfig(), process.env).values.autoShareNewSessions,
|
|
@@ -790,6 +881,253 @@ export function apply(ctx, config = {}) {
|
|
|
790
881
|
catch (error) {
|
|
791
882
|
logWarn('dsh-zen-remote cannot listen on "sessionPersistence": %s', message(error));
|
|
792
883
|
}
|
|
884
|
+
// CLI-1: the managed-ingress machinery MOVED OUT of the wizard route's
|
|
885
|
+
// inject so the CLI IPC delegate shares ONE set of managers / handler
|
|
886
|
+
// options with the browser routes (same caddyManager, same
|
|
887
|
+
// cloudflaredManager, same verification session, same relay store). The
|
|
888
|
+
// subprocess service is read the lazy way — off the reflection layer,
|
|
889
|
+
// which answers undefined on compositions without it — so the wizard
|
|
890
|
+
// options simply degrade to their 501 placeholders there.
|
|
891
|
+
const getConfig = () => resolveConfig(config, readFileConfig(), process.env);
|
|
892
|
+
const subprocessService = reflectNow()?.get('subprocess');
|
|
893
|
+
const caddyManager = subprocessService === undefined ? undefined : createCaddyManager({ subprocess: subprocessService, dshHome: home });
|
|
894
|
+
const cloudflaredManager = subprocessService === undefined ? undefined : createCloudflaredManager({ subprocess: subprocessService, dshHome: home });
|
|
895
|
+
for (const child of [caddyManager, cloudflaredManager]) {
|
|
896
|
+
if (child !== undefined)
|
|
897
|
+
cliManagedChildren.push(child);
|
|
898
|
+
}
|
|
899
|
+
// The verification session lives in THIS manager set's closure (GW-9):
|
|
900
|
+
// memory-only by design, and a row reload rebuilds it — the same
|
|
901
|
+
// "stale" verdict the config fingerprint produces, arriving via the
|
|
902
|
+
// process generation instead.
|
|
903
|
+
const currentMode = () => getConfig().values.ingressMode;
|
|
904
|
+
const detectLanCandidates = () => {
|
|
905
|
+
try {
|
|
906
|
+
const nets = networkInterfaces();
|
|
907
|
+
const tailscale = [];
|
|
908
|
+
const primaryLan = [];
|
|
909
|
+
const bridgeLan = [];
|
|
910
|
+
for (const [iface, entries] of Object.entries(nets)) {
|
|
911
|
+
if (!Array.isArray(entries))
|
|
912
|
+
continue;
|
|
913
|
+
const isContainerBridge = /^(?:docker\d*|br-|veth|cni\d*|flannel|virbr\d*|cali)/i.test(iface);
|
|
914
|
+
for (const entry of entries) {
|
|
915
|
+
if (!entry || entry.internal || entry.family !== 'IPv4')
|
|
916
|
+
continue;
|
|
917
|
+
const addr = entry.address;
|
|
918
|
+
if (/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) {
|
|
919
|
+
tailscale.push({ ip: addr, kind: 'tailscale', iface });
|
|
920
|
+
}
|
|
921
|
+
else if (/^192\.168\./.test(addr)
|
|
922
|
+
|| /^10\./.test(addr)
|
|
923
|
+
|| /^172\.(?:1[6-9]|2\d|3[01])\./.test(addr)) {
|
|
924
|
+
if (isContainerBridge)
|
|
925
|
+
bridgeLan.push({ ip: addr, kind: 'lan', iface });
|
|
926
|
+
else
|
|
927
|
+
primaryLan.push({ ip: addr, kind: 'lan', iface });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return [...tailscale, ...primaryLan, ...bridgeLan];
|
|
932
|
+
}
|
|
933
|
+
catch {
|
|
934
|
+
return [];
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
// One runtime probe per request: the gateway's own status (source-IP
|
|
938
|
+
// detection + the ACTUAL port for snippets/QR), plus our processes'
|
|
939
|
+
// liveness for the projection.
|
|
940
|
+
const buildRuntime = async () => {
|
|
941
|
+
const lanAddresses = detectLanCandidates();
|
|
942
|
+
const lanAddress = (lanAddresses.find((c) => c.kind === 'lan') ?? lanAddresses[0])?.ip;
|
|
943
|
+
try {
|
|
944
|
+
const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
|
|
945
|
+
if (!response.ok)
|
|
946
|
+
return { caddyRunning: caddyManager?.isRunning() === true, lanAddress, lanAddresses };
|
|
947
|
+
const body = (await response.json());
|
|
948
|
+
return {
|
|
949
|
+
lastForwardedSource: detectForwardedSource(body),
|
|
950
|
+
// GW-fix-3/M2: the wizard's snippets and QR target the
|
|
951
|
+
// gateway's ACTUAL port (it may have retried past the
|
|
952
|
+
// configured one).
|
|
953
|
+
gatewayActualPort: typeof body?.port === 'number' ? body.port : undefined,
|
|
954
|
+
gatewayActualHost: typeof body?.host === 'string' ? body.host : undefined,
|
|
955
|
+
lanAddress,
|
|
956
|
+
lanAddresses,
|
|
957
|
+
caddyRunning: caddyManager?.isRunning() === true,
|
|
958
|
+
cloudflaredRunning: cloudflaredManager?.isRunning() === true,
|
|
959
|
+
tunnelTokenConfigured: cloudflaredManager !== undefined,
|
|
960
|
+
cfTokenConfigured: cfTokenConfigured(home),
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
// No answer is no detection — the status projection renders
|
|
965
|
+
// its null shape and the UI offers nothing.
|
|
966
|
+
return {
|
|
967
|
+
lanAddress,
|
|
968
|
+
lanAddresses,
|
|
969
|
+
caddyRunning: caddyManager?.isRunning() === true,
|
|
970
|
+
cloudflaredRunning: cloudflaredManager?.isRunning() === true,
|
|
971
|
+
tunnelTokenConfigured: tunnelTokenConfigured(home),
|
|
972
|
+
cfTokenConfigured: cfTokenConfigured(home),
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
const verification = createVerificationManager({
|
|
977
|
+
gatewayBase: `http://127.0.0.1:${effective.values.port}`,
|
|
978
|
+
getValues: () => getConfig().values,
|
|
979
|
+
// runtimeReady now covers the managed paths: Caddy must actually be
|
|
980
|
+
// alive; cloudflared arrives with GW-13b; the external path has no
|
|
981
|
+
// process of ours to wait for.
|
|
982
|
+
runtimeReady: () => {
|
|
983
|
+
const mode = currentMode();
|
|
984
|
+
if (mode === 'caddy')
|
|
985
|
+
return caddyManager?.isRunning() === true;
|
|
986
|
+
if (mode === 'cloudflared')
|
|
987
|
+
return cloudflaredManager?.isRunning() === true;
|
|
988
|
+
return true;
|
|
989
|
+
},
|
|
990
|
+
});
|
|
991
|
+
const wizardOptionsFor = (admit) => ({
|
|
992
|
+
admit,
|
|
993
|
+
getConfig,
|
|
994
|
+
tokenStore: {
|
|
995
|
+
writeCfToken: (token) => { writeCfTokenFile(home, token); },
|
|
996
|
+
deleteCfToken: () => {
|
|
997
|
+
// Stop the consumers first (the mode-switch rule): whichever
|
|
998
|
+
// process might hold the token in its environment dies before
|
|
999
|
+
// the file disappears.
|
|
1000
|
+
caddyManager?.stop();
|
|
1001
|
+
cloudflaredManager?.stop();
|
|
1002
|
+
deleteCfTokenFile(home);
|
|
1003
|
+
},
|
|
1004
|
+
cfTokenConfigured: () => cfTokenConfigured(home),
|
|
1005
|
+
writeTunnelToken: (token) => { writeTunnelTokenFile(home, token); },
|
|
1006
|
+
},
|
|
1007
|
+
...(caddyManager === undefined ? {} : {
|
|
1008
|
+
caddy: {
|
|
1009
|
+
start: async (input) => {
|
|
1010
|
+
// 模式切换:starting managed Caddy stops any running cloudflared tunnel first,
|
|
1011
|
+
// and restarts Caddy if it was already running with an older config.
|
|
1012
|
+
cloudflaredManager?.stop();
|
|
1013
|
+
if (caddyManager.isRunning())
|
|
1014
|
+
caddyManager.stop();
|
|
1015
|
+
// The token rides the child env only — read from the store
|
|
1016
|
+
// inside the wiring, never through a route payload.
|
|
1017
|
+
const token = readCfTokenFile(home);
|
|
1018
|
+
if (token === undefined)
|
|
1019
|
+
return { ok: false, error: 'missing-token' };
|
|
1020
|
+
return caddyManager.start({ ...input, cfToken: token });
|
|
1021
|
+
},
|
|
1022
|
+
stop: () => caddyManager.stop(),
|
|
1023
|
+
status: () => (caddyManager.isRunning() ? 'running' : caddyManager.hasStarted() ? 'stopped' : 'never-started'),
|
|
1024
|
+
},
|
|
1025
|
+
}),
|
|
1026
|
+
...(cloudflaredManager === undefined ? {} : {
|
|
1027
|
+
tunnel: {
|
|
1028
|
+
checkDomain: (domain) => runTunnelCheck({ readToken: () => readCfTokenFile(home) }, domain),
|
|
1029
|
+
setup: (input) => setupTunnel({
|
|
1030
|
+
dshHome: home,
|
|
1031
|
+
readToken: () => readCfTokenFile(home),
|
|
1032
|
+
gatewayPort: async () => {
|
|
1033
|
+
// The ingress service target is the gateway's ACTUAL
|
|
1034
|
+
// port — same fallback as the caddy start route: probe
|
|
1035
|
+
// /lan-gate/status first, fall back to the configured
|
|
1036
|
+
// port when it does not answer (GW-fix-4/M2).
|
|
1037
|
+
try {
|
|
1038
|
+
const response = await fetch(`http://127.0.0.1:${getConfig().values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
|
|
1039
|
+
if (response.ok) {
|
|
1040
|
+
const body = (await response.json());
|
|
1041
|
+
if (typeof body?.port === 'number')
|
|
1042
|
+
return body.port;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
catch { /* fall through to the configured port */ }
|
|
1046
|
+
return getConfig().values.port;
|
|
1047
|
+
},
|
|
1048
|
+
cloudflared: {
|
|
1049
|
+
start: async (startInput) => {
|
|
1050
|
+
if (cloudflaredManager.isRunning())
|
|
1051
|
+
cloudflaredManager.stop();
|
|
1052
|
+
const started = await cloudflaredManager.start({ tunnelToken: startInput.tunnelToken });
|
|
1053
|
+
return started.ok ? { ok: true } : { ok: false, error: started.error ?? 'spawn-failed' };
|
|
1054
|
+
},
|
|
1055
|
+
// 模式切换:starting the tunnel stops the managed Caddy.
|
|
1056
|
+
stopCaddy: () => caddyManager?.stop(),
|
|
1057
|
+
},
|
|
1058
|
+
}, input),
|
|
1059
|
+
stop: () => cloudflaredManager.stop(),
|
|
1060
|
+
status: () => (cloudflaredManager.isRunning() ? 'running' : cloudflaredManager.hasStarted() ? 'stopped' : 'never-started'),
|
|
1061
|
+
},
|
|
1062
|
+
}),
|
|
1063
|
+
verify: { start: (input) => verification.start(input), check: () => verification.check() },
|
|
1064
|
+
getRuntime: () => buildRuntime(),
|
|
1065
|
+
trustProxy: {
|
|
1066
|
+
resolved: () => {
|
|
1067
|
+
const snapshot = resolveConfig(config, readFileConfig(), process.env);
|
|
1068
|
+
return { value: snapshot.values.trustedProxies, source: snapshot.sources.trustedProxies };
|
|
1069
|
+
},
|
|
1070
|
+
write: async (expected, list) => {
|
|
1071
|
+
const file = join(home, 'lan-gate.config.json');
|
|
1072
|
+
const current = readFileConfig();
|
|
1073
|
+
// Concurrent-write guard (GW-fix-2/L2): the route resolved
|
|
1074
|
+
// the list moments ago; if the file layer moved since (a
|
|
1075
|
+
// parallel trust click, a hand edit), refuse instead of
|
|
1076
|
+
// clobbering the other writer's entry. Both sides compare
|
|
1077
|
+
// normalized so an illegal file value reads as "empty", the
|
|
1078
|
+
// same thing resolveConfig saw.
|
|
1079
|
+
const now = typeof current.trustedProxies === 'string' ? current.trustedProxies : '';
|
|
1080
|
+
if (parseProxyList(now).join(',') !== parseProxyList(expected).join(','))
|
|
1081
|
+
return false;
|
|
1082
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1083
|
+
await writeFile(tmp, `${JSON.stringify({ ...current, trustedProxies: list }, null, 2)}\n`, { mode: 0o600 });
|
|
1084
|
+
await rename(tmp, file);
|
|
1085
|
+
return true;
|
|
1086
|
+
},
|
|
1087
|
+
},
|
|
1088
|
+
});
|
|
1089
|
+
const adminOptionsFor = (admit) => ({
|
|
1090
|
+
admit,
|
|
1091
|
+
gatewayBase: `http://127.0.0.1:${effective.values.port}`,
|
|
1092
|
+
getConfig,
|
|
1093
|
+
// Shares routes (T33a): the table above is the truth they serve and
|
|
1094
|
+
// mutate. typertGateway and agents are deliberately NOT declared in
|
|
1095
|
+
// any inject — adding them would keep the WHOLE admin prefix off
|
|
1096
|
+
// 0.1.7 / Electron compositions that lack them — so they are read PER
|
|
1097
|
+
// REQUEST through the reflection layer's store lookup (runtime
|
|
1098
|
+
// `ctx.get`, declared `ctx.reflect.get`), which answers undefined
|
|
1099
|
+
// when the service is absent. Property access is not an option:
|
|
1100
|
+
// cordis refuses reads of services the context did not declare
|
|
1101
|
+
// (`cannot get property … without inject`), which would turn every
|
|
1102
|
+
// shares request into a 500.
|
|
1103
|
+
store,
|
|
1104
|
+
typert: () => reflectNow()?.get('typertGateway'),
|
|
1105
|
+
getPeer: createOwnerPeerSource(() => reflectNow()?.get('connection')),
|
|
1106
|
+
listAgents: () => reflectNow()?.get('agents')?.list() ?? [],
|
|
1107
|
+
// The "who is looking" badge (T33a2): read through whichever relay
|
|
1108
|
+
// handler is live right now. sessionQuery likewise — the share
|
|
1109
|
+
// action reads the durable session header through it to judge
|
|
1110
|
+
// subagent sessions, degrading to the projection identity when the
|
|
1111
|
+
// composition lacks the service.
|
|
1112
|
+
viewerCount: (id) => relayHandler?.viewerCount(id) ?? 0,
|
|
1113
|
+
sessionQuery: () => reflectNow()?.get('sessionQuery'),
|
|
1114
|
+
// The plugin bridge listing (PB-5): the SAME registry this apply
|
|
1115
|
+
// built and the SAME live consent reader the relay's plugins table
|
|
1116
|
+
// uses — one source of truth per question. PB-11: the listing
|
|
1117
|
+
// marks the plugins an adapter stands in for (live — a plugin
|
|
1118
|
+
// that registered its own declaration stops being marked).
|
|
1119
|
+
pluginBridge: {
|
|
1120
|
+
registry,
|
|
1121
|
+
getConsent: () => getConfig().values.pluginBridgeConsent,
|
|
1122
|
+
adaptedPlugins: () => new Set(adapters.filter((adapter) => adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))).map((adapter) => adapter.plugin)),
|
|
1123
|
+
},
|
|
1124
|
+
});
|
|
1125
|
+
// The CLI IPC delegate's OWN handler instances (CLI-1): same options,
|
|
1126
|
+
// same managers, same verification session as the browser routes below
|
|
1127
|
+
// — only the admit differs, because the request arrived over the 0600
|
|
1128
|
+
// socket (same user, same machine) instead of DSH's cookie wall.
|
|
1129
|
+
cliAdminHandler = createAdminHandler(adminOptionsFor(noopAdmit));
|
|
1130
|
+
cliWizardHandler = createWizardHandler(wizardOptionsFor(noopAdmit));
|
|
793
1131
|
// Settings-surface admin routes (T14). The browser cannot call the
|
|
794
1132
|
// gateway's loopback-only admin API from DSH's origin, so the host
|
|
795
1133
|
// process re-exposes it same-origin and the handler calls the gateway AS
|
|
@@ -806,42 +1144,7 @@ export function apply(ctx, config = {}) {
|
|
|
806
1144
|
webCtx.effect(() => webCtx.webServer.register({
|
|
807
1145
|
kind: 'prefix',
|
|
808
1146
|
path: ADMIN_ROUTE_PREFIX,
|
|
809
|
-
handler: createAdminHandler(
|
|
810
|
-
admit: (req) => webCtx.connection.admit(req),
|
|
811
|
-
gatewayBase: `http://127.0.0.1:${effective.values.port}`,
|
|
812
|
-
getConfig: () => resolveConfig(config, readFileConfig(), process.env),
|
|
813
|
-
// Shares routes (T33a): the table above is the truth they serve and
|
|
814
|
-
// mutate. typertGateway and agents are deliberately NOT in this
|
|
815
|
-
// inject's declaration list — adding them would keep the WHOLE
|
|
816
|
-
// admin prefix off 0.1.7 / Electron compositions that lack them —
|
|
817
|
-
// so they are read PER REQUEST through the reflection layer's
|
|
818
|
-
// store lookup (runtime `ctx.get`, declared `ctx.reflect.get`),
|
|
819
|
-
// which answers undefined when the service is absent. Property
|
|
820
|
-
// access is not an option: cordis refuses reads of services the
|
|
821
|
-
// context did not declare (`cannot get property … without inject`),
|
|
822
|
-
// which would turn every shares request into a 500.
|
|
823
|
-
store,
|
|
824
|
-
typert: () => webCtx.reflect.get('typertGateway'),
|
|
825
|
-
getPeer: createOwnerPeerSource(() => webCtx.connection),
|
|
826
|
-
listAgents: () => webCtx.reflect.get('agents')?.list() ?? [],
|
|
827
|
-
// The "who is looking" badge (T33a2): read through whichever relay
|
|
828
|
-
// handler is live right now. sessionQuery likewise — the share
|
|
829
|
-
// action reads the durable session header through it to judge
|
|
830
|
-
// subagent sessions, degrading to the projection identity when the
|
|
831
|
-
// composition lacks the service.
|
|
832
|
-
viewerCount: (id) => relayHandler?.viewerCount(id) ?? 0,
|
|
833
|
-
sessionQuery: () => webCtx.reflect.get('sessionQuery'),
|
|
834
|
-
// The plugin bridge listing (PB-5): the SAME registry this apply
|
|
835
|
-
// built and the SAME live consent reader the relay's plugins table
|
|
836
|
-
// uses — one source of truth per question. PB-11: the listing
|
|
837
|
-
// marks the plugins an adapter stands in for (live — a plugin
|
|
838
|
-
// that registered its own declaration stops being marked).
|
|
839
|
-
pluginBridge: {
|
|
840
|
-
registry,
|
|
841
|
-
getConsent: () => resolveConfig(config, readFileConfig(), process.env).values.pluginBridgeConsent,
|
|
842
|
-
adaptedPlugins: () => new Set(adapters.filter((adapter) => adapterOwnsRegistryEntry(adapter, registry.get(adapter.plugin))).map((adapter) => adapter.plugin)),
|
|
843
|
-
},
|
|
844
|
-
}),
|
|
1147
|
+
handler: createAdminHandler(adminOptionsFor((req) => webCtx.connection.admit(req))),
|
|
845
1148
|
}), 'dsh-zen-remote: admin routes');
|
|
846
1149
|
});
|
|
847
1150
|
// Gateway-wizard routes (GW-4, extended by GW-5): a separate LONGEST-
|
|
@@ -857,204 +1160,16 @@ export function apply(ctx, config = {}) {
|
|
|
857
1160
|
// reloads the row from (trustedProxies is a restart field), restarting
|
|
858
1161
|
// the gateway with the new trust list. When the resolved value comes
|
|
859
1162
|
// from env or the row the route refuses instead: a file-layer write
|
|
860
|
-
// would be shadowed there.
|
|
1163
|
+
// would be shadowed there. (CLI-1: the managers and the handler options
|
|
1164
|
+
// moved OUT of this inject to apply() level — see wizardOptionsFor —
|
|
1165
|
+
// so the CLI IPC delegate drives the SAME manager set; this effect only
|
|
1166
|
+
// registers the route and stops the children with the row.)
|
|
861
1167
|
ctx.inject(['webServer', 'connection'], (wizardCtx) => {
|
|
862
|
-
// GW-13a: the managed Caddy lives in THIS row's closure — a row
|
|
863
|
-
// reload rebuilds the manager (and the row's disposal, below, kills
|
|
864
|
-
// the child), which is the reconcile-by-process-generation rule. The
|
|
865
|
-
// subprocess service is a lazy reflection lookup: adding it to the
|
|
866
|
-
// inject list would keep the whole wizard off compositions that lack
|
|
867
|
-
// it, and there `reflect.get` answers undefined instead.
|
|
868
|
-
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
869
|
-
const subprocessService = wizardCtx.reflect.get('subprocess');
|
|
870
|
-
const caddyManager = subprocessService === undefined ? undefined : createCaddyManager({ subprocess: subprocessService, dshHome });
|
|
871
|
-
const cloudflaredManager = subprocessService === undefined ? undefined : createCloudflaredManager({ subprocess: subprocessService, dshHome });
|
|
872
|
-
// The verification session lives in THIS manager's closure (GW-9):
|
|
873
|
-
// memory-only by design, and a row reload rebuilds it — the same
|
|
874
|
-
// "stale" verdict the config fingerprint produces, arriving via the
|
|
875
|
-
// process generation instead.
|
|
876
|
-
const currentMode = () => resolveConfig(config, readFileConfig(), process.env).values.ingressMode;
|
|
877
|
-
const detectLanAddress = () => {
|
|
878
|
-
try {
|
|
879
|
-
const nets = networkInterfaces();
|
|
880
|
-
let tailscaleIp;
|
|
881
|
-
let lanIp;
|
|
882
|
-
for (const entries of Object.values(nets)) {
|
|
883
|
-
if (!Array.isArray(entries))
|
|
884
|
-
continue;
|
|
885
|
-
for (const entry of entries) {
|
|
886
|
-
if (!entry || entry.internal || entry.family !== 'IPv4')
|
|
887
|
-
continue;
|
|
888
|
-
const addr = entry.address;
|
|
889
|
-
if (/^192\.168\./.test(addr)
|
|
890
|
-
|| /^10\./.test(addr)
|
|
891
|
-
|| /^172\.(?:1[6-9]|2\d|3[01])\./.test(addr)) {
|
|
892
|
-
lanIp ??= addr;
|
|
893
|
-
}
|
|
894
|
-
else if (/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) {
|
|
895
|
-
tailscaleIp ??= addr;
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
return lanIp ?? tailscaleIp;
|
|
900
|
-
}
|
|
901
|
-
catch {
|
|
902
|
-
return undefined;
|
|
903
|
-
}
|
|
904
|
-
};
|
|
905
|
-
// One runtime probe per request: the gateway's own status (source-IP
|
|
906
|
-
// detection + the ACTUAL port for snippets/QR), plus our processes'
|
|
907
|
-
// liveness for the projection.
|
|
908
|
-
const buildRuntime = async () => {
|
|
909
|
-
const lanAddress = detectLanAddress();
|
|
910
|
-
try {
|
|
911
|
-
const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
|
|
912
|
-
if (!response.ok)
|
|
913
|
-
return { caddyRunning: caddyManager?.isRunning() === true, lanAddress };
|
|
914
|
-
const body = (await response.json());
|
|
915
|
-
return {
|
|
916
|
-
lastForwardedSource: detectForwardedSource(body),
|
|
917
|
-
// GW-fix-3/M2: the wizard's snippets and QR target the
|
|
918
|
-
// gateway's ACTUAL port (it may have retried past the
|
|
919
|
-
// configured one).
|
|
920
|
-
gatewayActualPort: typeof body?.port === 'number' ? body.port : undefined,
|
|
921
|
-
lanAddress,
|
|
922
|
-
caddyRunning: caddyManager?.isRunning() === true,
|
|
923
|
-
cloudflaredRunning: cloudflaredManager?.isRunning() === true,
|
|
924
|
-
tunnelTokenConfigured: cloudflaredManager !== undefined,
|
|
925
|
-
cfTokenConfigured: cfTokenConfigured(dshHome),
|
|
926
|
-
};
|
|
927
|
-
}
|
|
928
|
-
catch {
|
|
929
|
-
// No answer is no detection — the status projection renders
|
|
930
|
-
// its null shape and the UI offers nothing.
|
|
931
|
-
return {
|
|
932
|
-
lanAddress,
|
|
933
|
-
caddyRunning: caddyManager?.isRunning() === true,
|
|
934
|
-
cloudflaredRunning: cloudflaredManager?.isRunning() === true,
|
|
935
|
-
tunnelTokenConfigured: tunnelTokenConfigured(dshHome),
|
|
936
|
-
cfTokenConfigured: cfTokenConfigured(dshHome),
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
|
-
};
|
|
940
|
-
const verification = createVerificationManager({
|
|
941
|
-
gatewayBase: `http://127.0.0.1:${effective.values.port}`,
|
|
942
|
-
getValues: () => resolveConfig(config, readFileConfig(), process.env).values,
|
|
943
|
-
// runtimeReady now covers the managed paths: Caddy must actually be
|
|
944
|
-
// alive; cloudflared arrives with GW-13b; the external path has no
|
|
945
|
-
// process of ours to wait for.
|
|
946
|
-
runtimeReady: () => {
|
|
947
|
-
const mode = currentMode();
|
|
948
|
-
if (mode === 'caddy')
|
|
949
|
-
return caddyManager?.isRunning() === true;
|
|
950
|
-
if (mode === 'cloudflared')
|
|
951
|
-
return cloudflaredManager?.isRunning() === true;
|
|
952
|
-
return true;
|
|
953
|
-
},
|
|
954
|
-
});
|
|
955
1168
|
wizardCtx.effect(() => {
|
|
956
1169
|
const unregister = wizardCtx.webServer.register({
|
|
957
1170
|
kind: 'prefix',
|
|
958
1171
|
path: WIZARD_ROUTE_PREFIX,
|
|
959
|
-
handler: createWizardHandler(
|
|
960
|
-
admit: (req) => wizardCtx.connection.admit(req),
|
|
961
|
-
getConfig: () => resolveConfig(config, readFileConfig(), process.env),
|
|
962
|
-
tokenStore: {
|
|
963
|
-
writeCfToken: (token) => { writeCfTokenFile(dshHome, token); },
|
|
964
|
-
deleteCfToken: () => {
|
|
965
|
-
// Stop the consumers first (the mode-switch rule): whichever
|
|
966
|
-
// process might hold the token in its environment dies before
|
|
967
|
-
// the file disappears.
|
|
968
|
-
caddyManager?.stop();
|
|
969
|
-
cloudflaredManager?.stop();
|
|
970
|
-
deleteCfTokenFile(dshHome);
|
|
971
|
-
},
|
|
972
|
-
cfTokenConfigured: () => cfTokenConfigured(dshHome),
|
|
973
|
-
writeTunnelToken: (token) => { writeTunnelTokenFile(dshHome, token); },
|
|
974
|
-
},
|
|
975
|
-
...(caddyManager === undefined ? {} : {
|
|
976
|
-
caddy: {
|
|
977
|
-
start: async (input) => {
|
|
978
|
-
// 模式切换:starting managed Caddy stops any running cloudflared tunnel first,
|
|
979
|
-
// and restarts Caddy if it was already running with an older config.
|
|
980
|
-
cloudflaredManager?.stop();
|
|
981
|
-
if (caddyManager.isRunning())
|
|
982
|
-
caddyManager.stop();
|
|
983
|
-
// The token rides the child env only — read from the store
|
|
984
|
-
// inside the wiring, never through a route payload.
|
|
985
|
-
const token = readCfTokenFile(dshHome);
|
|
986
|
-
if (token === undefined)
|
|
987
|
-
return { ok: false, error: 'missing-token' };
|
|
988
|
-
return caddyManager.start({ ...input, cfToken: token });
|
|
989
|
-
},
|
|
990
|
-
stop: () => caddyManager.stop(),
|
|
991
|
-
status: () => (caddyManager.isRunning() ? 'running' : caddyManager.hasStarted() ? 'stopped' : 'never-started'),
|
|
992
|
-
},
|
|
993
|
-
}),
|
|
994
|
-
...(cloudflaredManager === undefined ? {} : {
|
|
995
|
-
tunnel: {
|
|
996
|
-
checkDomain: (domain) => runTunnelCheck({ readToken: () => readCfTokenFile(dshHome) }, domain),
|
|
997
|
-
setup: (input) => setupTunnel({
|
|
998
|
-
dshHome,
|
|
999
|
-
readToken: () => readCfTokenFile(dshHome),
|
|
1000
|
-
gatewayPort: async () => {
|
|
1001
|
-
// The ingress service target is the gateway's ACTUAL
|
|
1002
|
-
// port — same fallback as the caddy start route: probe
|
|
1003
|
-
// /lan-gate/status first, fall back to the configured
|
|
1004
|
-
// port when it does not answer (GW-fix-4/M2).
|
|
1005
|
-
try {
|
|
1006
|
-
const response = await fetch(`http://127.0.0.1:${resolveConfig(config, readFileConfig(), process.env).values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) });
|
|
1007
|
-
if (response.ok) {
|
|
1008
|
-
const body = (await response.json());
|
|
1009
|
-
if (typeof body?.port === 'number')
|
|
1010
|
-
return body.port;
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
catch { /* fall through to the configured port */ }
|
|
1014
|
-
return resolveConfig(config, readFileConfig(), process.env).values.port;
|
|
1015
|
-
},
|
|
1016
|
-
cloudflared: {
|
|
1017
|
-
start: async (startInput) => {
|
|
1018
|
-
if (cloudflaredManager.isRunning())
|
|
1019
|
-
cloudflaredManager.stop();
|
|
1020
|
-
const started = await cloudflaredManager.start({ tunnelToken: startInput.tunnelToken });
|
|
1021
|
-
return started.ok ? { ok: true } : { ok: false, error: started.error ?? 'spawn-failed' };
|
|
1022
|
-
},
|
|
1023
|
-
// 模式切换:starting the tunnel stops the managed Caddy.
|
|
1024
|
-
stopCaddy: () => caddyManager?.stop(),
|
|
1025
|
-
},
|
|
1026
|
-
}, input),
|
|
1027
|
-
stop: () => cloudflaredManager.stop(),
|
|
1028
|
-
status: () => (cloudflaredManager.isRunning() ? 'running' : cloudflaredManager.hasStarted() ? 'stopped' : 'never-started'),
|
|
1029
|
-
},
|
|
1030
|
-
}),
|
|
1031
|
-
verify: { start: (input) => verification.start(input), check: () => verification.check() },
|
|
1032
|
-
getRuntime: () => buildRuntime(),
|
|
1033
|
-
trustProxy: {
|
|
1034
|
-
resolved: () => {
|
|
1035
|
-
const snapshot = resolveConfig(config, readFileConfig(), process.env);
|
|
1036
|
-
return { value: snapshot.values.trustedProxies, source: snapshot.sources.trustedProxies };
|
|
1037
|
-
},
|
|
1038
|
-
write: async (expected, list) => {
|
|
1039
|
-
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
1040
|
-
const file = join(home, 'lan-gate.config.json');
|
|
1041
|
-
const current = readFileConfig();
|
|
1042
|
-
// Concurrent-write guard (GW-fix-2/L2): the route resolved
|
|
1043
|
-
// the list moments ago; if the file layer moved since (a
|
|
1044
|
-
// parallel trust click, a hand edit), refuse instead of
|
|
1045
|
-
// clobbering the other writer's entry. Both sides compare
|
|
1046
|
-
// normalized so an illegal file value reads as "empty", the
|
|
1047
|
-
// same thing resolveConfig saw.
|
|
1048
|
-
const now = typeof current.trustedProxies === 'string' ? current.trustedProxies : '';
|
|
1049
|
-
if (parseProxyList(now).join(',') !== parseProxyList(expected).join(','))
|
|
1050
|
-
return false;
|
|
1051
|
-
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
1052
|
-
await writeFile(tmp, `${JSON.stringify({ ...current, trustedProxies: list }, null, 2)}\n`, { mode: 0o600 });
|
|
1053
|
-
await rename(tmp, file);
|
|
1054
|
-
return true;
|
|
1055
|
-
},
|
|
1056
|
-
},
|
|
1057
|
-
}),
|
|
1172
|
+
handler: createWizardHandler(wizardOptionsFor((req) => wizardCtx.connection.admit(req))),
|
|
1058
1173
|
});
|
|
1059
1174
|
// H1: the row's disposal must not orphan the managed children — the
|
|
1060
1175
|
// Caddy/cloudflared processes die WITH the row that spawned them,
|
|
@@ -1271,106 +1386,130 @@ export function apply(ctx, config = {}) {
|
|
|
1271
1386
|
getGatewaySummary: gatewaySummary,
|
|
1272
1387
|
getProfileName: profileName,
|
|
1273
1388
|
getPluginVersion: () => pluginVersion,
|
|
1389
|
+
lang: resolveActiveLang,
|
|
1274
1390
|
}),
|
|
1275
1391
|
}), 'dsh-zen-remote: client routes (host)');
|
|
1276
1392
|
});
|
|
1393
|
+
// CLI-1: the IPC delegate's client face on a serving role — remote-status
|
|
1394
|
+
// and the debug report answer (both are every role's own); the IPC
|
|
1395
|
+
// prefix gate covers the pairing routes (409 wrong-role before any
|
|
1396
|
+
// request reaches here), and the handler keeps the host mount's
|
|
1397
|
+
// least-exposure wall besides.
|
|
1398
|
+
cliClientHandler = createClientHandler({
|
|
1399
|
+
admit: noopAdmit,
|
|
1400
|
+
getRowConfig: () => config,
|
|
1401
|
+
remoteStatusOnly: true,
|
|
1402
|
+
getServiceProbe: uploadServiceProbe(ctx),
|
|
1403
|
+
getLogSnapshot: () => ring.snapshot(),
|
|
1404
|
+
getGatewaySummary: gatewaySummary,
|
|
1405
|
+
getProfileName: profileName,
|
|
1406
|
+
getPluginVersion: () => pluginVersion,
|
|
1407
|
+
lang: resolveActiveLang,
|
|
1408
|
+
});
|
|
1277
1409
|
}
|
|
1278
|
-
|
|
1279
|
-
// Sub-client half (T16, T23a
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
//
|
|
1283
|
-
//
|
|
1284
|
-
//
|
|
1285
|
-
//
|
|
1286
|
-
// settings page pairs or unpairs.
|
|
1287
|
-
//
|
|
1288
|
-
// The relay client (T23a) reads the SAME volatile fields live at every
|
|
1289
|
-
// request; the address passes the claim route's normalizer, and an
|
|
1290
|
-
// address that no longer normalizes counts as missing — a hand-edited
|
|
1291
|
-
// row must not receive the pairing token.
|
|
1410
|
+
if (connectsRole(effective.values.role)) {
|
|
1411
|
+
// Sub-client half (T16, T23a; MS-5 pool rewiring): the paired-servers
|
|
1412
|
+
// connection pool instead of the single row-driven client. The row's
|
|
1413
|
+
// serverUrl/deviceToken survive only as the LEGACY source — read once
|
|
1414
|
+
// here so an old single-server row migrates into
|
|
1415
|
+
// `zen-remote-state.json`'s paired list (state-store's migration keeps
|
|
1416
|
+
// the list's other entries); from then on the pool's paired list is the
|
|
1417
|
+
// single truth, edited by the client routes' claim/unpair.
|
|
1292
1418
|
const row = config;
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1419
|
+
// (`home` is hoisted to apply() level — the pool's state file and the
|
|
1420
|
+
// CLI IPC socket share the same directory.)
|
|
1421
|
+
// MS-3/MS-5: the intercept handle is created inside the typertGateway
|
|
1422
|
+
// inject below, but the POOL's state callbacks (created first) forward
|
|
1423
|
+
// to it — the closure is fine (the callbacks fire after apply() returned
|
|
1424
|
+
// or not at all); the declaration just moved up so the wiring reads
|
|
1425
|
+
// top-down.
|
|
1426
|
+
let interceptHandle;
|
|
1427
|
+
const legacyCredentials = () => {
|
|
1428
|
+
const url = unwrapVolatile(row.serverUrl);
|
|
1429
|
+
const token = unwrapVolatile(row.deviceToken);
|
|
1430
|
+
const serverUrl = typeof url === 'string' && url.trim() !== '' ? url : undefined;
|
|
1431
|
+
const deviceToken = typeof token === 'string' && token !== '' ? token : undefined;
|
|
1432
|
+
return serverUrl === undefined && deviceToken === undefined ? undefined : { serverUrl, deviceToken };
|
|
1433
|
+
};
|
|
1434
|
+
// CU-1 lifecycle: the pool's per-server link flips enter the ring —
|
|
1435
|
+
// `online` with the entry and its handshake server id, `offline` with
|
|
1436
|
+
// the failure code (lastError is a short code by contract, never free
|
|
1437
|
+
// text). The client's subscribe re-announces the CURRENT state when only
|
|
1438
|
+
// the handshake identity moved (the T59 heartbeat), so the per-entry
|
|
1439
|
+
// dedupe keeps the heartbeats out of the ring: one line per actual state
|
|
1440
|
+
// change. Every event ALSO reaches the intercept's pool hub — the merged
|
|
1441
|
+
// streams re-scan `pool.entries()` on it.
|
|
1442
|
+
const poolRingStates = new Map();
|
|
1443
|
+
const relayPool = createRelayPool({
|
|
1444
|
+
stateFile: join(home, 'zen-remote-state.json'),
|
|
1445
|
+
legacySource: legacyCredentials,
|
|
1299
1446
|
// Interface compatibility (T42): after each completed handshake this
|
|
1300
1447
|
// side computes its OWN fingerprints against THIS process's typert
|
|
1301
1448
|
// registry (read per call — at creation time the row context may still
|
|
1302
|
-
// be assembling), and the
|
|
1303
|
-
//
|
|
1449
|
+
// be assembling), and the client stores the group-by-group verdict the
|
|
1450
|
+
// status route reports.
|
|
1304
1451
|
computeOwnFingerprints: () => computeFingerprints(ctx),
|
|
1452
|
+
onStateChange: ({ entryId, serverId, state, lastError }) => {
|
|
1453
|
+
if (poolRingStates.get(entryId) !== state) {
|
|
1454
|
+
poolRingStates.set(entryId, state);
|
|
1455
|
+
if (state === 'online')
|
|
1456
|
+
ring.push('info', 'relay pool online (entry=%s server=%s)', entryId, serverId ?? '?');
|
|
1457
|
+
else if (state === 'offline')
|
|
1458
|
+
ring.push('warn', 'relay pool offline (entry=%s code=%s)', entryId, lastError ?? '?');
|
|
1459
|
+
}
|
|
1460
|
+
interceptHandle?.notePoolEvent();
|
|
1461
|
+
},
|
|
1462
|
+
onServerRemoved: ({ entryId, reason }) => {
|
|
1463
|
+
poolRingStates.delete(entryId);
|
|
1464
|
+
ring.push('info', 'relay pool server removed (entry=%s reason=%s)', entryId, reason);
|
|
1465
|
+
interceptHandle?.notePoolEvent();
|
|
1466
|
+
},
|
|
1305
1467
|
});
|
|
1306
|
-
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1468
|
+
clientRelayPool = relayPool;
|
|
1469
|
+
// A stopped or reloaded row must not leave a stale pool where the module
|
|
1470
|
+
// getter can hand it out (T23a-fix's discipline), and its clients'
|
|
1471
|
+
// reconnect machinery must not outlive the row either: stop() ends every
|
|
1472
|
+
// client (which cancels armed retry timers and blocks scheduling). The
|
|
1473
|
+
// identity guard keeps a slow dispose from clearing a NEWER row's pool.
|
|
1474
|
+
ctx.effect(() => () => {
|
|
1475
|
+
relayPool.stop();
|
|
1476
|
+
if (clientRelayPool === relayPool)
|
|
1477
|
+
clientRelayPool = undefined;
|
|
1478
|
+
}, 'dsh-zen-remote: relay pool disposal');
|
|
1479
|
+
// The plugin bridge's live verdict (PB-2), per POOL ENTRY now (MS-5) — a
|
|
1480
|
+
// host has no remote sessions of its own to bridge anything into, and
|
|
1481
|
+
// server A's verdicts must never answer for server B's. Computed PER
|
|
1309
1482
|
// CALL (never a stored conclusion): the local registry is this apply's
|
|
1310
|
-
// own closure, the
|
|
1483
|
+
// own closure, the entry's table is ITS LAST handshake's `plugins`
|
|
1311
1484
|
// (undefined until one succeeded — an old server reads server-too-old),
|
|
1312
|
-
// and `online` is
|
|
1485
|
+
// and `online` is that client's CURRENT state — so a registry change, a
|
|
1313
1486
|
// fresh handshake or a link flip shows up at the very next read.
|
|
1314
|
-
const
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
});
|
|
1333
|
-
// A stopped or reloaded row must not leave a stale instance where the
|
|
1334
|
-
// module getter can hand it out (T23a-fix), and its reconnect machinery
|
|
1335
|
-
// (T43) must not outlive the row either: stop() cancels the armed retry
|
|
1336
|
-
// timer and blocks further scheduling. The identity guard keeps a slow
|
|
1337
|
-
// dispose from clearing a NEWER row's instance that a reload already
|
|
1338
|
-
// installed.
|
|
1339
|
-
ctx.effect(() => () => {
|
|
1340
|
-
offRelayRing();
|
|
1341
|
-
relayClient.stop();
|
|
1342
|
-
if (clientRelayClient === relayClient)
|
|
1343
|
-
clientRelayClient = undefined;
|
|
1344
|
-
}, 'dsh-zen-remote: relay client disposal');
|
|
1345
|
-
// Credentials watch (T43): serverUrl / deviceToken are volatile row
|
|
1346
|
-
// fields, and a pairing write (or unpair) commits them WITHOUT a row
|
|
1347
|
-
// restart. The loader announces the commit on the row context; the
|
|
1348
|
-
// client drops its pending reconnect wait and dials the new credentials
|
|
1349
|
-
// immediately. Failure to subscribe degrades to a warning — the ladder
|
|
1350
|
-
// and the per-request getters keep working without it.
|
|
1351
|
-
// T59 rides the same announcement, and ONLY that announcement (T59-fix):
|
|
1352
|
-
// the row's `serverName` is this device's name, and a USER-committed
|
|
1353
|
-
// change is forwarded to the gateway's device table — but the event
|
|
1354
|
-
// fires for EVERY volatile commit (an unrelated knob, or the settings
|
|
1355
|
-
// page's own server-driven follow write arriving at startup), so the
|
|
1356
|
-
// listener filters on the changed paths. A push fires only when
|
|
1357
|
-
// `serverName` itself moved and differs from the server's record
|
|
1358
|
-
// (queueDeviceName's contract) — which is what keeps the follow write
|
|
1359
|
-
// from bouncing back and forth. NOT pushed at startup either: the row's
|
|
1360
|
-
// loaded name is stale by definition (the server's table may have been
|
|
1361
|
-
// edited while we were gone) and an unconditional boot-time push would
|
|
1362
|
-
// overwrite an administrator's rename. A rename committed offline and
|
|
1363
|
-
// lost to a process exit therefore stays local until the user edits it
|
|
1364
|
-
// again — accepted; the server's record wins by default.
|
|
1487
|
+
const pluginBridgeStatusFor = (serverId, client) => compareBridges(registry.list(), client.handshakeInfo?.plugins, client.state === 'online');
|
|
1488
|
+
// Device-name watch (T59 through MS-5's pool): the row's `serverName` is
|
|
1489
|
+
// this device's name, and a USER-committed change is broadcast to EVERY
|
|
1490
|
+
// paired server's gateway device table — one-way (MS decision: the
|
|
1491
|
+
// server's record never rewrites the row). The loader announces the
|
|
1492
|
+
// commit on the row context, but the event fires for EVERY volatile
|
|
1493
|
+
// commit (an unrelated knob, or a settings page's own server-driven
|
|
1494
|
+
// follow write), so the listener filters on the changed paths. Each
|
|
1495
|
+
// client pushes only when the name differs from its server's record
|
|
1496
|
+
// (queueDeviceName's contract) — which is what keeps a follow write from
|
|
1497
|
+
// bouncing back and forth. NOT pushed at startup either: the row's
|
|
1498
|
+
// loaded name is stale by definition and an unconditional boot-time push
|
|
1499
|
+
// would overwrite an administrator's rename. A rename committed offline
|
|
1500
|
+
// and lost to a process exit stays local until the user edits it again —
|
|
1501
|
+
// accepted; the server's record wins by default. (The old T43
|
|
1502
|
+
// credentialsChanged half of this listener is gone with the row client:
|
|
1503
|
+
// the pool's credentials live in the state file, edited by claim/unpair,
|
|
1504
|
+
// and its clients react to THOSE on their own.)
|
|
1365
1505
|
ctx.effect(() => {
|
|
1366
1506
|
try {
|
|
1367
1507
|
const off = ctx.on('loader/volatile-update', (paths) => {
|
|
1368
|
-
relayClient.credentialsChanged();
|
|
1369
1508
|
if (!volatileUpdateTouchesServerName(paths))
|
|
1370
1509
|
return;
|
|
1371
1510
|
const name = unwrapVolatile(row.serverName);
|
|
1372
1511
|
if (typeof name === 'string' && name.trim() !== '')
|
|
1373
|
-
|
|
1512
|
+
relayPool.queueDeviceNameAll(name);
|
|
1374
1513
|
});
|
|
1375
1514
|
return () => {
|
|
1376
1515
|
off();
|
|
@@ -1381,24 +1520,15 @@ export function apply(ctx, config = {}) {
|
|
|
1381
1520
|
return () => { };
|
|
1382
1521
|
}
|
|
1383
1522
|
}, 'dsh-zen-remote: relay credentials watcher');
|
|
1384
|
-
// One connection attempt at startup (T23a). Failures only log — the
|
|
1385
|
-
// status route surfaces the resulting state, and the client's own
|
|
1386
|
-
// backoff ladder (T43) takes over from the first failure. An unpaired
|
|
1387
|
-
// row is the normal not-configured shape and stays silent.
|
|
1388
|
-
void relayClient.connect().catch((error) => {
|
|
1389
|
-
if (error instanceof RelayError && error.code === 'unpaired')
|
|
1390
|
-
return;
|
|
1391
|
-
const code = error instanceof RelayError ? error.code : 'error';
|
|
1392
|
-
logWarn('dsh-zen-remote relay client initial connect failed (%s): %s', code, message(error));
|
|
1393
|
-
});
|
|
1394
1523
|
// The typertGateway interception (T23b-1): remote-session calls ride the
|
|
1395
1524
|
// relay, everything else reaches the local gateway untouched. The live
|
|
1396
1525
|
// diagnostics flow to the client status route through whichever of the
|
|
1397
1526
|
// two slots is filled — an installed handle, or the shape verdict that
|
|
1398
1527
|
// REFUSED an install (both belong on the settings surface). A
|
|
1399
1528
|
// composition without typertGateway (0.1.7 client) leaves both empty and
|
|
1400
|
-
// the status answer simply carries no intercept field.
|
|
1401
|
-
|
|
1529
|
+
// the status answer simply carries no intercept field. (The handle's
|
|
1530
|
+
// declaration itself sits above, beside the pool — its callbacks forward
|
|
1531
|
+
// pool events here.)
|
|
1402
1532
|
let interceptRefused;
|
|
1403
1533
|
// T62: the browser's current-session report, held here and read live by
|
|
1404
1534
|
// the intercept (which cannot reach the browser's localStorage — it
|
|
@@ -1406,8 +1536,10 @@ export function apply(ctx, config = {}) {
|
|
|
1406
1536
|
// first report arrives the value is `unavailable` and the intercept
|
|
1407
1537
|
// keeps its conservative tombstone behavior.
|
|
1408
1538
|
let reportedCurrentSession = { kind: 'unavailable' };
|
|
1409
|
-
const onCurrentSession = (read) => {
|
|
1539
|
+
const onCurrentSession = (read, reportedLang) => {
|
|
1410
1540
|
reportedCurrentSession = read;
|
|
1541
|
+
if (reportedLang !== undefined)
|
|
1542
|
+
reportedBrowserLang = reportedLang;
|
|
1411
1543
|
// Re-judge NOW — the merger dedupes no-change calls, so a report that
|
|
1412
1544
|
// carries nothing new costs one compare.
|
|
1413
1545
|
interceptHandle?.currentSessionChanged();
|
|
@@ -1425,19 +1557,22 @@ export function apply(ctx, config = {}) {
|
|
|
1425
1557
|
// between two stream refreshes waits for the next refresh by design
|
|
1426
1558
|
// (the documented ceiling in merge-streams.ts). Read-only consumption:
|
|
1427
1559
|
// nothing below ever mutates what a merger handed over.
|
|
1560
|
+
// MS-5: each share is tagged with the serverId its stream asked the slot
|
|
1561
|
+
// for, so the plugin-route's per-server face can answer the union of ONE
|
|
1562
|
+
// server's ids — a rewrite against server A must never see B's ids.
|
|
1428
1563
|
const knownSessionShares = new Map();
|
|
1429
1564
|
const knownRealIds = new Set();
|
|
1430
1565
|
const rebuildKnownRealIds = () => {
|
|
1431
1566
|
knownRealIds.clear();
|
|
1432
1567
|
for (const share of knownSessionShares.values()) {
|
|
1433
|
-
for (const id of share)
|
|
1568
|
+
for (const id of share.ids)
|
|
1434
1569
|
knownRealIds.add(id);
|
|
1435
1570
|
}
|
|
1436
1571
|
};
|
|
1437
|
-
const createSessionInventorySlot = () => {
|
|
1572
|
+
const createSessionInventorySlot = (serverId) => {
|
|
1438
1573
|
const slot = {
|
|
1439
1574
|
replace(ids) {
|
|
1440
|
-
knownSessionShares.set(slot, ids);
|
|
1575
|
+
knownSessionShares.set(slot, { serverId, ids });
|
|
1441
1576
|
rebuildKnownRealIds();
|
|
1442
1577
|
},
|
|
1443
1578
|
dispose() {
|
|
@@ -1447,6 +1582,19 @@ export function apply(ctx, config = {}) {
|
|
|
1447
1582
|
};
|
|
1448
1583
|
return slot;
|
|
1449
1584
|
};
|
|
1585
|
+
/** MS-5: one server's share of the union, for the plugin-route rewrite's
|
|
1586
|
+
* per-server face. An entry that never reported (its stream not open
|
|
1587
|
+
* yet) contributes nothing — the request-local scan still runs. */
|
|
1588
|
+
const knownRealIdsForServer = (serverId) => {
|
|
1589
|
+
const out = new Set();
|
|
1590
|
+
for (const share of knownSessionShares.values()) {
|
|
1591
|
+
if (share.serverId !== serverId)
|
|
1592
|
+
continue;
|
|
1593
|
+
for (const id of share.ids)
|
|
1594
|
+
out.add(id);
|
|
1595
|
+
}
|
|
1596
|
+
return out;
|
|
1597
|
+
};
|
|
1450
1598
|
const interceptDiagnostics = () => {
|
|
1451
1599
|
if (interceptHandle !== undefined)
|
|
1452
1600
|
return interceptHandle.diagnostics();
|
|
@@ -1475,9 +1623,14 @@ export function apply(ctx, config = {}) {
|
|
|
1475
1623
|
}
|
|
1476
1624
|
const handle = installIntercept({
|
|
1477
1625
|
raw,
|
|
1478
|
-
|
|
1479
|
-
|
|
1626
|
+
// MS-5 POOL mode: routing and merging read the relay pool — every
|
|
1627
|
+
// call/stream/event rides the virtual id's serverId half to ITS
|
|
1628
|
+
// server, the three global reads merge ALL paired servers in pairing
|
|
1629
|
+
// order. (The single-server `relay` + `getServerId` form is gone
|
|
1630
|
+
// from the wiring with the row client.)
|
|
1631
|
+
pool: relayPool,
|
|
1480
1632
|
getCurrentSession: () => reportedCurrentSession,
|
|
1633
|
+
lang: resolveActiveLang,
|
|
1481
1634
|
// PB-25b: each merged stream reports its real-session inventory
|
|
1482
1635
|
// share into the union above.
|
|
1483
1636
|
createSessionInventorySlot,
|
|
@@ -1572,8 +1725,9 @@ export function apply(ctx, config = {}) {
|
|
|
1572
1725
|
}
|
|
1573
1726
|
// Shape first, like the typert wrap: a failed check leaves every route
|
|
1574
1727
|
// exactly as it was — local uploads untouched, remote uploads keep
|
|
1575
|
-
// failing with the honest local error.
|
|
1576
|
-
|
|
1728
|
+
// failing with the honest local error. (The media note rides along:
|
|
1729
|
+
// this wiring always hands a resolveRemoteFileScope over, P55-2.)
|
|
1730
|
+
const shape = checkFetchRouteShape(raw, true);
|
|
1577
1731
|
if (!shape.ok) {
|
|
1578
1732
|
fetchRouteRefused = shape;
|
|
1579
1733
|
logWarn('dsh-zen-remote fetch-route intercept not installed: %s', shape.reasons.join('; '));
|
|
@@ -1581,8 +1735,14 @@ export function apply(ctx, config = {}) {
|
|
|
1581
1735
|
}
|
|
1582
1736
|
const handle = installFetchRouteIntercept({
|
|
1583
1737
|
connection: raw,
|
|
1584
|
-
|
|
1585
|
-
|
|
1738
|
+
// MS-5 pool mode: each upload's serverId half picks its own client.
|
|
1739
|
+
getRelayForServer: (serverId) => relayPool.getByServerId(serverId)?.client,
|
|
1740
|
+
// P55-2: the /api/file media route forwards to the session the
|
|
1741
|
+
// intercept currently holds — read LIVE per request, so an uninstalled
|
|
1742
|
+
// (or not-yet-installed) typert wrap answers undefined and the media
|
|
1743
|
+
// request passes through to the local route.
|
|
1744
|
+
resolveRemoteFileScope: (filePath) => interceptHandle?.resolveRemoteFileScope(filePath),
|
|
1745
|
+
lang: resolveActiveLang,
|
|
1586
1746
|
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
|
|
1587
1747
|
});
|
|
1588
1748
|
fetchRouteHandle = handle;
|
|
@@ -1650,7 +1810,9 @@ export function apply(ctx, config = {}) {
|
|
|
1650
1810
|
}
|
|
1651
1811
|
// Shape against the proxy first (fetchRoutes reads through it); the
|
|
1652
1812
|
// raw instance comes off the same symbols slot the inject path uses.
|
|
1653
|
-
|
|
1813
|
+
// (The media note rides along — same install options as the inject
|
|
1814
|
+
// path, P55-2.)
|
|
1815
|
+
const shape = checkFetchRouteShape(connection, true);
|
|
1654
1816
|
if (!shape.ok) {
|
|
1655
1817
|
// Structural mismatch is a verdict, not a wait: record the attempt
|
|
1656
1818
|
// and stop. A later inject trigger may still install (it checks the
|
|
@@ -1671,8 +1833,12 @@ export function apply(ctx, config = {}) {
|
|
|
1671
1833
|
}
|
|
1672
1834
|
const handle = installFetchRouteIntercept({
|
|
1673
1835
|
connection: raw,
|
|
1674
|
-
|
|
1675
|
-
|
|
1836
|
+
// MS-5 pool mode: each upload's serverId half picks its own client.
|
|
1837
|
+
getRelayForServer: (serverId) => relayPool.getByServerId(serverId)?.client,
|
|
1838
|
+
// P55-2: same live read as the inject path — the media route follows
|
|
1839
|
+
// whichever typert intercept is currently installed.
|
|
1840
|
+
resolveRemoteFileScope: (filePath) => interceptHandle?.resolveRemoteFileScope(filePath),
|
|
1841
|
+
lang: resolveActiveLang,
|
|
1676
1842
|
log: (format, ...args) => { logInfo(`dsh-zen-remote ${format}`, ...args); },
|
|
1677
1843
|
});
|
|
1678
1844
|
fetchRouteHandle = handle;
|
|
@@ -1743,9 +1909,16 @@ export function apply(ctx, config = {}) {
|
|
|
1743
1909
|
const handle = installPluginRouteIntercept({
|
|
1744
1910
|
webServer: raw,
|
|
1745
1911
|
registry,
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1912
|
+
// MS-5 pool mode: validation, forwarding and response rewriting are
|
|
1913
|
+
// judged against the located id's OWN server — its relay client, its
|
|
1914
|
+
// bridge verdicts (its handshake's plugin table against the local
|
|
1915
|
+
// registry) and its own known-real-id inventory.
|
|
1916
|
+
getRelayForServer: (serverId) => relayPool.getByServerId(serverId)?.client,
|
|
1917
|
+
getStatusForServer: (serverId) => {
|
|
1918
|
+
const entry = relayPool.getByServerId(serverId);
|
|
1919
|
+
return entry !== undefined ? pluginBridgeStatusFor(entry.serverId, entry.client) : [];
|
|
1920
|
+
},
|
|
1921
|
+
getKnownRealIdsForServer: knownRealIdsForServer,
|
|
1749
1922
|
adapters,
|
|
1750
1923
|
// PB-12: the browser's currently open session — a VIRTUAL id means
|
|
1751
1924
|
// a remote panel is the request's origin, the signal the adapter
|
|
@@ -1770,24 +1943,36 @@ export function apply(ctx, config = {}) {
|
|
|
1770
1943
|
pluginRouteHandle = undefined;
|
|
1771
1944
|
}, 'dsh-zen-remote: plugin route intercept');
|
|
1772
1945
|
});
|
|
1946
|
+
// CLI-1: one options factory, two handler instances — the browser mount
|
|
1947
|
+
// below keeps its admit wall, and the CLI IPC delegate's instance (same
|
|
1948
|
+
// pool, same diagnostics getters, same everything but admit) lets the
|
|
1949
|
+
// local CLI drive THIS pool directly over the 0600 socket.
|
|
1950
|
+
const clientOptionsFor = (admit, probeCtx) => ({
|
|
1951
|
+
admit,
|
|
1952
|
+
getRowConfig: () => config,
|
|
1953
|
+
// MS-5 POOL mode: the paired list is the truth — claim pairs into
|
|
1954
|
+
// the pool (the token never reaches the browser), unpair/reconnect
|
|
1955
|
+
// address pool entries, and status / remote-status report every
|
|
1956
|
+
// server. The row still rides along for the non-connection knobs
|
|
1957
|
+
// (the debug report's config summary) — never for credentials.
|
|
1958
|
+
pool: relayPool,
|
|
1959
|
+
getPluginBridgeStatusForServer: pluginBridgeStatusFor,
|
|
1960
|
+
getIntercept: interceptDiagnostics,
|
|
1961
|
+
getFetchRouteIntercept: fetchRouteDiagnostics,
|
|
1962
|
+
getServiceProbe: uploadServiceProbe(probeCtx),
|
|
1963
|
+
getPluginRouteIntercept: pluginRouteDiagnostics,
|
|
1964
|
+
onCurrentSession,
|
|
1965
|
+
getLogSnapshot: () => ring.snapshot(),
|
|
1966
|
+
getProfileName: profileName,
|
|
1967
|
+
getPluginVersion: () => pluginVersion,
|
|
1968
|
+
lang: resolveActiveLang,
|
|
1969
|
+
});
|
|
1970
|
+
cliClientHandler = createClientHandler(clientOptionsFor(noopAdmit, ctx));
|
|
1773
1971
|
ctx.inject(['webServer', 'connection'], (clientCtx) => {
|
|
1774
1972
|
clientCtx.effect(() => clientCtx.webServer.register({
|
|
1775
1973
|
kind: 'prefix',
|
|
1776
1974
|
path: CLIENT_ROUTE_PREFIX,
|
|
1777
|
-
handler: createClientHandler(
|
|
1778
|
-
admit: (req) => clientCtx.connection.admit(req),
|
|
1779
|
-
getRowConfig: () => config,
|
|
1780
|
-
getRelayClient: () => relayClient,
|
|
1781
|
-
getIntercept: interceptDiagnostics,
|
|
1782
|
-
getFetchRouteIntercept: fetchRouteDiagnostics,
|
|
1783
|
-
getServiceProbe: uploadServiceProbe(clientCtx),
|
|
1784
|
-
getPluginRouteIntercept: pluginRouteDiagnostics,
|
|
1785
|
-
getPluginBridgeStatus: pluginBridgeStatus,
|
|
1786
|
-
onCurrentSession,
|
|
1787
|
-
getLogSnapshot: () => ring.snapshot(),
|
|
1788
|
-
getProfileName: profileName,
|
|
1789
|
-
getPluginVersion: () => pluginVersion,
|
|
1790
|
-
}),
|
|
1975
|
+
handler: createClientHandler(clientOptionsFor((req) => clientCtx.connection.admit(req), clientCtx)),
|
|
1791
1976
|
}), 'dsh-zen-remote: client routes');
|
|
1792
1977
|
});
|
|
1793
1978
|
}
|
|
@@ -1839,12 +2024,60 @@ export function apply(ctx, config = {}) {
|
|
|
1839
2024
|
// Registration failure degrades to a warning, like the session feeds
|
|
1840
2025
|
// above: the poll trigger keeps the watcher fully functional without it.
|
|
1841
2026
|
try {
|
|
1842
|
-
ctx.on('loader/volatile-update', () => {
|
|
2027
|
+
ctx.on('loader/volatile-update', (paths) => {
|
|
2028
|
+
if (Array.isArray(paths)) {
|
|
2029
|
+
const topKeys = paths
|
|
2030
|
+
.map((p) => (Array.isArray(p) && typeof p[0] === 'string' ? p[0] : undefined))
|
|
2031
|
+
.filter((k) => k !== undefined);
|
|
2032
|
+
if (topKeys.length > 0)
|
|
2033
|
+
clearUnshadowedRowKeys(home, topKeys);
|
|
2034
|
+
}
|
|
2035
|
+
watcher.checkNow();
|
|
2036
|
+
});
|
|
1843
2037
|
}
|
|
1844
2038
|
catch (error) {
|
|
1845
2039
|
logWarn('dsh-zen-remote: cannot listen on "loader/volatile-update": %s', message(error));
|
|
1846
2040
|
}
|
|
1847
|
-
|
|
2041
|
+
// CLI-1: hot-swap THIS row generation's delegate into the module-level
|
|
2042
|
+
// IPC server (acquire is synchronous — apply() runs inside cordis; the
|
|
2043
|
+
// first bind completes in the background and the CLI client retries
|
|
2044
|
+
// until then). writeCliConfig's onAfterWrite re-checks the fingerprint
|
|
2045
|
+
// IMMEDIATELY, so a CLI config write to a restart-required field
|
|
2046
|
+
// reloads the row without waiting out the 2s poll. Every role getter
|
|
2047
|
+
// re-resolves per call: ping answers the CURRENT effective role even in
|
|
2048
|
+
// the instant before a pending reload lands. The IPC lifecycle rides
|
|
2049
|
+
// this existing effect on purpose — role-wiring pins the top-level
|
|
2050
|
+
// effect counts, so it may not register its own.
|
|
2051
|
+
const delegate = {
|
|
2052
|
+
role: () => resolveConfig(config, readFileConfig(), process.env).values.role,
|
|
2053
|
+
serverName: () => resolveConfig(config, readFileConfig(), process.env).values.serverName,
|
|
2054
|
+
profileName,
|
|
2055
|
+
pluginVersion: () => pluginVersion,
|
|
2056
|
+
writeConfig: (input) => writeCliConfig({
|
|
2057
|
+
dshHome: home,
|
|
2058
|
+
row: config,
|
|
2059
|
+
env: process.env,
|
|
2060
|
+
set: input.set,
|
|
2061
|
+
unset: input.unset,
|
|
2062
|
+
onAfterWrite: () => { watcher.checkNow(); },
|
|
2063
|
+
}),
|
|
2064
|
+
wizardHandler: cliWizardHandler,
|
|
2065
|
+
adminHandler: cliAdminHandler,
|
|
2066
|
+
clientHandler: cliClientHandler,
|
|
2067
|
+
};
|
|
2068
|
+
const cliIpc = acquireCliIpcServer(home, delegate);
|
|
2069
|
+
return () => {
|
|
2070
|
+
// Release THIS row's hold (a concurrent reload's new acquire keeps
|
|
2071
|
+
// the socket alive — that is the coordinator's grace window). The
|
|
2072
|
+
// managed ingress children die with the row here too: on compositions
|
|
2073
|
+
// WITHOUT a webServer the wizard effect above never fires, so this is
|
|
2074
|
+
// the only stop they would ever get (with webServer both stop — stop
|
|
2075
|
+
// is idempotent, safe before any start).
|
|
2076
|
+
cliIpc.release();
|
|
2077
|
+
watcher.stop();
|
|
2078
|
+
for (const child of cliManagedChildren)
|
|
2079
|
+
child.stop();
|
|
2080
|
+
};
|
|
1848
2081
|
}, 'dsh-zen-remote: restart watcher');
|
|
1849
2082
|
// The upload route carries the T17 admission wall too: webServer routes
|
|
1850
2083
|
// skip DSH's /api authentication, so without the connection service's
|