dsh-zen-remote 2.2.9 → 2.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +1 -1
- package/docs/remote-access.md +20 -0
- package/lan-gate.mjs +11 -1
- package/lib/adapters/dsh-better-sidebar/index.js +6 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/methods.js +3 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
- package/lib/admin-routes.js +33 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/client.js +3293 -6
- package/lib/config.js +70 -0
- package/lib/config.js.map +1 -1
- package/lib/index.js +232 -2
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -3
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +58 -3
- package/lib/merge-streams.js +42 -12
- package/lib/merge-streams.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts +13 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +204 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts +29 -0
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -0
- package/lib/types/client/wizard/WizardCard.d.ts +30 -0
- package/lib/types/client/wizard/WizardCard.d.ts.map +1 -0
- package/lib/types/client/wizard/cards/MigrateCard.d.ts +15 -0
- package/lib/types/client/wizard/cards/MigrateCard.d.ts.map +1 -0
- package/lib/types/client/wizard/cards/PortForwardCard.d.ts +15 -0
- package/lib/types/client/wizard/cards/PortForwardCard.d.ts.map +1 -0
- package/lib/types/client/wizard/cards/ProxyCard.d.ts +19 -0
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -0
- package/lib/types/client/wizard/cards/TokenCard.d.ts +20 -0
- package/lib/types/client/wizard/cards/TokenCard.d.ts.map +1 -0
- package/lib/types/client/wizard/register-wizard.d.ts +51 -0
- package/lib/types/client/wizard/register-wizard.d.ts.map +1 -0
- package/lib/types/client/wizard/wizard-css.d.ts +10 -0
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -0
- package/lib/types/client-data/qr.d.ts +20 -0
- package/lib/types/client-data/qr.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +7 -2
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +247 -0
- package/lib/types/client-data/wizard.d.ts.map +1 -0
- package/lib/types/config.d.ts +27 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/wizard/binary-io.d.ts +111 -0
- package/lib/types/wizard/binary-io.d.ts.map +1 -0
- package/lib/types/wizard/binary.d.ts +118 -0
- package/lib/types/wizard/binary.d.ts.map +1 -0
- package/lib/types/wizard/caddy-process.d.ts +101 -0
- package/lib/types/wizard/caddy-process.d.ts.map +1 -0
- package/lib/types/wizard/caddy.d.ts +53 -0
- package/lib/types/wizard/caddy.d.ts.map +1 -0
- package/lib/types/wizard/cloudflare.d.ts +112 -0
- package/lib/types/wizard/cloudflare.d.ts.map +1 -0
- package/lib/types/wizard/cloudflared.d.ts +71 -0
- package/lib/types/wizard/cloudflared.d.ts.map +1 -0
- package/lib/types/wizard/probe.d.ts +66 -0
- package/lib/types/wizard/probe.d.ts.map +1 -0
- package/lib/types/wizard/proxy-snippets.d.ts +60 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -0
- package/lib/types/wizard/routes.d.ts +152 -0
- package/lib/types/wizard/routes.d.ts.map +1 -0
- package/lib/types/wizard/source-detect.d.ts +53 -0
- package/lib/types/wizard/source-detect.d.ts.map +1 -0
- package/lib/types/wizard/status.d.ts +115 -0
- package/lib/types/wizard/status.d.ts.map +1 -0
- package/lib/types/wizard/token-store.d.ts +27 -0
- package/lib/types/wizard/token-store.d.ts.map +1 -0
- package/lib/types/wizard/tunnel-orchestrator.d.ts +90 -0
- package/lib/types/wizard/tunnel-orchestrator.d.ts.map +1 -0
- package/lib/types/wizard/verify.d.ts +97 -0
- package/lib/types/wizard/verify.d.ts.map +1 -0
- package/lib/wizard/binary-io.js +386 -0
- package/lib/wizard/binary-io.js.map +1 -0
- package/lib/wizard/binary.js +138 -0
- package/lib/wizard/binary.js.map +1 -0
- package/lib/wizard/caddy-process.js +162 -0
- package/lib/wizard/caddy-process.js.map +1 -0
- package/lib/wizard/caddy.js +80 -0
- package/lib/wizard/caddy.js.map +1 -0
- package/lib/wizard/cloudflare.js +334 -0
- package/lib/wizard/cloudflare.js.map +1 -0
- package/lib/wizard/cloudflared.js +158 -0
- package/lib/wizard/cloudflared.js.map +1 -0
- package/lib/wizard/probe.js +118 -0
- package/lib/wizard/probe.js.map +1 -0
- package/lib/wizard/proxy-snippets.js +112 -0
- package/lib/wizard/proxy-snippets.js.map +1 -0
- package/lib/wizard/routes.js +462 -0
- package/lib/wizard/routes.js.map +1 -0
- package/lib/wizard/source-detect.js +77 -0
- package/lib/wizard/source-detect.js.map +1 -0
- package/lib/wizard/status.js +94 -0
- package/lib/wizard/status.js.map +1 -0
- package/lib/wizard/token-store.js +71 -0
- package/lib/wizard/token-store.js.map +1 -0
- package/lib/wizard/tunnel-orchestrator.js +155 -0
- package/lib/wizard/tunnel-orchestrator.js.map +1 -0
- package/lib/wizard/verify.js +171 -0
- package/lib/wizard/verify.js.map +1 -0
- package/package.json +37 -37
- package/scripts/check-settings-form.mjs +5 -4
- package/scripts/check-wizard-ui.mjs +690 -0
- package/src/adapters/dsh-better-sidebar/index.ts +6 -2
- package/src/adapters/dsh-better-sidebar/methods.ts +3 -0
- package/src/admin-routes.ts +44 -2
- package/src/client/index.tsx +9 -0
- package/src/client/locales.ts +410 -2
- package/src/client/wizard/GatewayWizard.tsx +2224 -0
- package/src/client/wizard/WizardCard.tsx +81 -0
- package/src/client/wizard/cards/MigrateCard.tsx +42 -0
- package/src/client/wizard/cards/PortForwardCard.tsx +37 -0
- package/src/client/wizard/cards/ProxyCard.tsx +56 -0
- package/src/client/wizard/cards/TokenCard.tsx +81 -0
- package/src/client/wizard/register-wizard.ts +99 -0
- package/src/client/wizard/wizard-css.ts +948 -0
- package/src/client-data/qr.ts +284 -0
- package/src/client-data/settings-form.ts +12 -3
- package/src/client-data/wizard.ts +432 -0
- package/src/config.ts +87 -0
- package/src/index.ts +227 -2
- package/src/intercept.ts +43 -3
- package/src/merge-streams.ts +41 -12
- package/src/wizard/binary-io.ts +449 -0
- package/src/wizard/binary.ts +177 -0
- package/src/wizard/caddy-process.ts +228 -0
- package/src/wizard/caddy.ts +97 -0
- package/src/wizard/cloudflare.ts +413 -0
- package/src/wizard/cloudflared.ts +196 -0
- package/src/wizard/probe.ts +161 -0
- package/src/wizard/proxy-snippets.ts +128 -0
- package/src/wizard/routes.ts +537 -0
- package/src/wizard/source-detect.ts +87 -0
- package/src/wizard/status.ts +180 -0
- package/src/wizard/token-store.ts +73 -0
- package/src/wizard/tunnel-orchestrator.ts +197 -0
- package/src/wizard/verify.ts +236 -0
package/src/index.ts
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { createOwnerPeerSource, createSharedApiFetchGetter } from './owner-peer.js'
|
|
19
|
-
import { lstat, mkdir, open, realpath, rm, writeFile } from 'node:fs/promises'
|
|
19
|
+
import { lstat, mkdir, open, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
|
20
20
|
import type { FileHandle } from 'node:fs/promises'
|
|
21
21
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
22
22
|
import { randomBytes } from 'node:crypto'
|
|
23
|
-
import { homedir } from 'node:os'
|
|
23
|
+
import { homedir, networkInterfaces } from 'node:os'
|
|
24
24
|
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
25
25
|
import type { Context } from '@deepseek-ai/cordis'
|
|
26
26
|
import type {} from '@deepseek-ai/dsh-agent'
|
|
@@ -68,6 +68,15 @@ import { ADAPTERS } from './adapters/index.js'
|
|
|
68
68
|
import { createRelayHandler, loadServerId, RELAY_PREFIX, resolveDshVersion } from './relay-server.js'
|
|
69
69
|
import type { RelayGateway, RelayHandler } from './relay-server.js'
|
|
70
70
|
import { createBridgeRegistry, compareBridges } from './plugin-bridge.js'
|
|
71
|
+
import { createWizardHandler, WIZARD_ROUTE_PREFIX } from './wizard/routes.js'
|
|
72
|
+
import { createCaddyManager } from './wizard/caddy-process.js'
|
|
73
|
+
import type { SubprocessServiceLike } from './wizard/caddy-process.js'
|
|
74
|
+
import { cfTokenConfigured, deleteCfTokenFile, readCfTokenFile, writeCfTokenFile } from './wizard/token-store.js'
|
|
75
|
+
import { createCloudflaredManager, tunnelTokenConfigured } from './wizard/cloudflared.js'
|
|
76
|
+
import { checkDomainOnCloudflare as runTunnelCheck, setupTunnel } from './wizard/tunnel-orchestrator.js'
|
|
77
|
+
import { writeTunnelTokenFile } from './wizard/cloudflared.js'
|
|
78
|
+
import { detectForwardedSource, parseProxyList } from './wizard/source-detect.js'
|
|
79
|
+
import { createVerificationManager } from './wizard/verify.js'
|
|
71
80
|
import type { BridgeDeclaration, BridgeRegistry, BridgeStatus, ServerPluginEntry, ZenRemoteService } from './plugin-bridge.js'
|
|
72
81
|
import { createPluginLoopback } from './plugin-loopback.js'
|
|
73
82
|
import { createPluginTunnel } from './plugin-tunnel.js'
|
|
@@ -789,6 +798,222 @@ export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
|
789
798
|
}),
|
|
790
799
|
}), 'dsh-zen-remote: admin routes')
|
|
791
800
|
})
|
|
801
|
+
// Gateway-wizard routes (GW-4, extended by GW-5): a separate LONGEST-
|
|
802
|
+
// prefix registration under the admin prefix — the host web server
|
|
803
|
+
// matches the longest prefix first, so /admin/wizard/* lands here and
|
|
804
|
+
// everything else stays with the admin handler above. Same host-only
|
|
805
|
+
// gate and admit wall; the wizard handler applies its own via-marker
|
|
806
|
+
// and same-origin walls to every route. The runtime probe reads the
|
|
807
|
+
// gateway's own /lan-gate/status for the untrusted-proxy detection
|
|
808
|
+
// (read-only, best-effort); the trust-proxy persister merges into the
|
|
809
|
+
// FILE layer of lan-gate.config.json — the only writable config surface
|
|
810
|
+
// the plugin owns — which the restart watcher's poll picks up and
|
|
811
|
+
// reloads the row from (trustedProxies is a restart field), restarting
|
|
812
|
+
// the gateway with the new trust list. When the resolved value comes
|
|
813
|
+
// from env or the row the route refuses instead: a file-layer write
|
|
814
|
+
// would be shadowed there.
|
|
815
|
+
ctx.inject(['webServer', 'connection'], (wizardCtx) => {
|
|
816
|
+
// GW-13a: the managed Caddy lives in THIS row's closure — a row
|
|
817
|
+
// reload rebuilds the manager (and the row's disposal, below, kills
|
|
818
|
+
// the child), which is the reconcile-by-process-generation rule. The
|
|
819
|
+
// subprocess service is a lazy reflection lookup: adding it to the
|
|
820
|
+
// inject list would keep the whole wizard off compositions that lack
|
|
821
|
+
// it, and there `reflect.get` answers undefined instead.
|
|
822
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
823
|
+
const subprocessService = wizardCtx.reflect.get('subprocess') as SubprocessServiceLike | undefined
|
|
824
|
+
const caddyManager = subprocessService === undefined ? undefined : createCaddyManager({ subprocess: subprocessService, dshHome })
|
|
825
|
+
const cloudflaredManager = subprocessService === undefined ? undefined : createCloudflaredManager({ subprocess: subprocessService, dshHome })
|
|
826
|
+
// The verification session lives in THIS manager's closure (GW-9):
|
|
827
|
+
// memory-only by design, and a row reload rebuilds it — the same
|
|
828
|
+
// "stale" verdict the config fingerprint produces, arriving via the
|
|
829
|
+
// process generation instead.
|
|
830
|
+
const currentMode = (): string => resolveConfig(config, readFileConfig(), process.env).values.ingressMode
|
|
831
|
+
const detectLanAddress = (): string | undefined => {
|
|
832
|
+
try {
|
|
833
|
+
const nets = networkInterfaces()
|
|
834
|
+
let tailscaleIp: string | undefined
|
|
835
|
+
let lanIp: string | undefined
|
|
836
|
+
for (const entries of Object.values(nets)) {
|
|
837
|
+
if (!Array.isArray(entries)) continue
|
|
838
|
+
for (const entry of entries) {
|
|
839
|
+
if (!entry || entry.internal || entry.family !== 'IPv4') continue
|
|
840
|
+
const addr = entry.address
|
|
841
|
+
if (
|
|
842
|
+
/^192\.168\./.test(addr)
|
|
843
|
+
|| /^10\./.test(addr)
|
|
844
|
+
|| /^172\.(?:1[6-9]|2\d|3[01])\./.test(addr)
|
|
845
|
+
) {
|
|
846
|
+
lanIp ??= addr
|
|
847
|
+
} else if (/^100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) {
|
|
848
|
+
tailscaleIp ??= addr
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return lanIp ?? tailscaleIp
|
|
853
|
+
} catch {
|
|
854
|
+
return undefined
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// One runtime probe per request: the gateway's own status (source-IP
|
|
858
|
+
// detection + the ACTUAL port for snippets/QR), plus our processes'
|
|
859
|
+
// liveness for the projection.
|
|
860
|
+
const buildRuntime = async () => {
|
|
861
|
+
const lanAddress = detectLanAddress()
|
|
862
|
+
try {
|
|
863
|
+
const response = await fetch(`http://127.0.0.1:${effective.values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
|
|
864
|
+
if (!response.ok) return { caddyRunning: caddyManager?.isRunning() === true, lanAddress }
|
|
865
|
+
const body = (await response.json()) as { port?: unknown; lastForwardedSource?: unknown }
|
|
866
|
+
return {
|
|
867
|
+
lastForwardedSource: detectForwardedSource(body),
|
|
868
|
+
// GW-fix-3/M2: the wizard's snippets and QR target the
|
|
869
|
+
// gateway's ACTUAL port (it may have retried past the
|
|
870
|
+
// configured one).
|
|
871
|
+
gatewayActualPort: typeof body?.port === 'number' ? body.port : undefined,
|
|
872
|
+
lanAddress,
|
|
873
|
+
caddyRunning: caddyManager?.isRunning() === true,
|
|
874
|
+
cloudflaredRunning: cloudflaredManager?.isRunning() === true,
|
|
875
|
+
tunnelTokenConfigured: cloudflaredManager !== undefined,
|
|
876
|
+
cfTokenConfigured: cfTokenConfigured(dshHome),
|
|
877
|
+
}
|
|
878
|
+
} catch {
|
|
879
|
+
// No answer is no detection — the status projection renders
|
|
880
|
+
// its null shape and the UI offers nothing.
|
|
881
|
+
return {
|
|
882
|
+
lanAddress,
|
|
883
|
+
caddyRunning: caddyManager?.isRunning() === true,
|
|
884
|
+
cloudflaredRunning: cloudflaredManager?.isRunning() === true,
|
|
885
|
+
tunnelTokenConfigured: tunnelTokenConfigured(dshHome),
|
|
886
|
+
cfTokenConfigured: cfTokenConfigured(dshHome),
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
const verification = createVerificationManager({
|
|
891
|
+
gatewayBase: `http://127.0.0.1:${effective.values.port}`,
|
|
892
|
+
getValues: () => resolveConfig(config, readFileConfig(), process.env).values,
|
|
893
|
+
// runtimeReady now covers the managed paths: Caddy must actually be
|
|
894
|
+
// alive; cloudflared arrives with GW-13b; the external path has no
|
|
895
|
+
// process of ours to wait for.
|
|
896
|
+
runtimeReady: () => {
|
|
897
|
+
const mode = currentMode()
|
|
898
|
+
if (mode === 'caddy') return caddyManager?.isRunning() === true
|
|
899
|
+
if (mode === 'cloudflared') return cloudflaredManager?.isRunning() === true
|
|
900
|
+
return true
|
|
901
|
+
},
|
|
902
|
+
})
|
|
903
|
+
wizardCtx.effect(() => {
|
|
904
|
+
const unregister = wizardCtx.webServer.register({
|
|
905
|
+
kind: 'prefix',
|
|
906
|
+
path: WIZARD_ROUTE_PREFIX,
|
|
907
|
+
handler: createWizardHandler({
|
|
908
|
+
admit: (req) => wizardCtx.connection.admit(req),
|
|
909
|
+
getConfig: () => resolveConfig(config, readFileConfig(), process.env),
|
|
910
|
+
tokenStore: {
|
|
911
|
+
writeCfToken: (token) => { writeCfTokenFile(dshHome, token) },
|
|
912
|
+
deleteCfToken: () => {
|
|
913
|
+
// Stop the consumers first (the mode-switch rule): whichever
|
|
914
|
+
// process might hold the token in its environment dies before
|
|
915
|
+
// the file disappears.
|
|
916
|
+
caddyManager?.stop()
|
|
917
|
+
cloudflaredManager?.stop()
|
|
918
|
+
deleteCfTokenFile(dshHome)
|
|
919
|
+
},
|
|
920
|
+
cfTokenConfigured: () => cfTokenConfigured(dshHome),
|
|
921
|
+
writeTunnelToken: (token) => { writeTunnelTokenFile(dshHome, token) },
|
|
922
|
+
},
|
|
923
|
+
...(caddyManager === undefined ? {} : {
|
|
924
|
+
caddy: {
|
|
925
|
+
start: async (input: { domain: string; httpsPort: number; gatewayPort: number }) => {
|
|
926
|
+
// 模式切换:starting managed Caddy stops any running cloudflared tunnel first,
|
|
927
|
+
// and restarts Caddy if it was already running with an older config.
|
|
928
|
+
cloudflaredManager?.stop()
|
|
929
|
+
if (caddyManager.isRunning()) caddyManager.stop()
|
|
930
|
+
// The token rides the child env only — read from the store
|
|
931
|
+
// inside the wiring, never through a route payload.
|
|
932
|
+
const token = readCfTokenFile(dshHome)
|
|
933
|
+
if (token === undefined) return { ok: false as const, error: 'missing-token' as const }
|
|
934
|
+
return caddyManager.start({ ...input, cfToken: token })
|
|
935
|
+
},
|
|
936
|
+
stop: () => caddyManager.stop(),
|
|
937
|
+
status: () => (caddyManager.isRunning() ? 'running' as const : caddyManager.hasStarted() ? 'stopped' as const : 'never-started' as const),
|
|
938
|
+
},
|
|
939
|
+
}),
|
|
940
|
+
...(cloudflaredManager === undefined ? {} : {
|
|
941
|
+
tunnel: {
|
|
942
|
+
checkDomain: (domain: string) => runTunnelCheck(
|
|
943
|
+
{ readToken: () => readCfTokenFile(dshHome) },
|
|
944
|
+
domain,
|
|
945
|
+
),
|
|
946
|
+
setup: (input: { domain: string; tunnelId?: string }) => setupTunnel(
|
|
947
|
+
{
|
|
948
|
+
dshHome,
|
|
949
|
+
readToken: () => readCfTokenFile(dshHome),
|
|
950
|
+
gatewayPort: async () => {
|
|
951
|
+
// The ingress service target is the gateway's ACTUAL
|
|
952
|
+
// port — same fallback as the caddy start route: probe
|
|
953
|
+
// /lan-gate/status first, fall back to the configured
|
|
954
|
+
// port when it does not answer (GW-fix-4/M2).
|
|
955
|
+
try {
|
|
956
|
+
const response = await fetch(`http://127.0.0.1:${resolveConfig(config, readFileConfig(), process.env).values.port}/lan-gate/status`, { signal: AbortSignal.timeout(3000) })
|
|
957
|
+
if (response.ok) {
|
|
958
|
+
const body = (await response.json()) as { port?: unknown }
|
|
959
|
+
if (typeof body?.port === 'number') return body.port
|
|
960
|
+
}
|
|
961
|
+
} catch { /* fall through to the configured port */ }
|
|
962
|
+
return resolveConfig(config, readFileConfig(), process.env).values.port
|
|
963
|
+
},
|
|
964
|
+
cloudflared: {
|
|
965
|
+
start: async (startInput: { tunnelToken: string }) => {
|
|
966
|
+
if (cloudflaredManager.isRunning()) cloudflaredManager.stop()
|
|
967
|
+
const started = await cloudflaredManager.start({ tunnelToken: startInput.tunnelToken })
|
|
968
|
+
return started.ok ? ({ ok: true } as const) : ({ ok: false, error: started.error ?? 'spawn-failed' } as const)
|
|
969
|
+
},
|
|
970
|
+
// 模式切换:starting the tunnel stops the managed Caddy.
|
|
971
|
+
stopCaddy: () => caddyManager?.stop(),
|
|
972
|
+
},
|
|
973
|
+
},
|
|
974
|
+
input,
|
|
975
|
+
),
|
|
976
|
+
stop: () => cloudflaredManager.stop(),
|
|
977
|
+
status: () => (cloudflaredManager.isRunning() ? 'running' as const : cloudflaredManager.hasStarted() ? 'stopped' as const : 'never-started' as const),
|
|
978
|
+
},
|
|
979
|
+
}),
|
|
980
|
+
verify: { start: (input) => verification.start(input), check: () => verification.check() },
|
|
981
|
+
getRuntime: () => buildRuntime(),
|
|
982
|
+
trustProxy: {
|
|
983
|
+
resolved: () => {
|
|
984
|
+
const snapshot = resolveConfig(config, readFileConfig(), process.env)
|
|
985
|
+
return { value: snapshot.values.trustedProxies, source: snapshot.sources.trustedProxies }
|
|
986
|
+
},
|
|
987
|
+
write: async (expected, list) => {
|
|
988
|
+
const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
989
|
+
const file = join(home, 'lan-gate.config.json')
|
|
990
|
+
const current = readFileConfig()
|
|
991
|
+
// Concurrent-write guard (GW-fix-2/L2): the route resolved
|
|
992
|
+
// the list moments ago; if the file layer moved since (a
|
|
993
|
+
// parallel trust click, a hand edit), refuse instead of
|
|
994
|
+
// clobbering the other writer's entry. Both sides compare
|
|
995
|
+
// normalized so an illegal file value reads as "empty", the
|
|
996
|
+
// same thing resolveConfig saw.
|
|
997
|
+
const now = typeof current.trustedProxies === 'string' ? current.trustedProxies : ''
|
|
998
|
+
if (parseProxyList(now).join(',') !== parseProxyList(expected).join(',')) return false
|
|
999
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
1000
|
+
await writeFile(tmp, `${JSON.stringify({ ...current, trustedProxies: list }, null, 2)}\n`, { mode: 0o600 })
|
|
1001
|
+
await rename(tmp, file)
|
|
1002
|
+
return true
|
|
1003
|
+
},
|
|
1004
|
+
},
|
|
1005
|
+
}),
|
|
1006
|
+
})
|
|
1007
|
+
// H1: the row's disposal must not orphan the managed children — the
|
|
1008
|
+
// Caddy/cloudflared processes die WITH the row that spawned them,
|
|
1009
|
+
// before the route registration unregisters.
|
|
1010
|
+
return () => {
|
|
1011
|
+
caddyManager?.stop()
|
|
1012
|
+
cloudflaredManager?.stop()
|
|
1013
|
+
unregister()
|
|
1014
|
+
}
|
|
1015
|
+
}, 'dsh-zen-remote: wizard routes')
|
|
1016
|
+
})
|
|
792
1017
|
// The idle sweeper (T22c): every minute, hand the table the CURRENT
|
|
793
1018
|
// idleHours and let it close sessions quiet past that. idleHours is a
|
|
794
1019
|
// volatile row field, so getIdleHours re-resolves from the SAME row
|
package/src/intercept.ts
CHANGED
|
@@ -1330,6 +1330,10 @@ interface MergedGlobalStreamDeps {
|
|
|
1330
1330
|
* session" (T62); the merged stream re-judges immediately instead of
|
|
1331
1331
|
* waiting for a frame or the fallback poll. Returns the disposer. */
|
|
1332
1332
|
watchCurrentSession(fn: () => void): () => void
|
|
1333
|
+
/** Subscribe one callback to {@link InterceptHandle.uninstall} so a live
|
|
1334
|
+
* merged stream removes its remote groups and archives/hides its remote
|
|
1335
|
+
* sessions when the plugin unloads or switches role from client to host. */
|
|
1336
|
+
watchUninstall(fn: () => void): () => void
|
|
1333
1337
|
/** T63: the summary-sync hub. The WORKSPACE route reports the virtual
|
|
1334
1338
|
* session ids each forwarded frame batch carried, so the events leg's
|
|
1335
1339
|
* sync can announce list rows the UI has no summary for; the control
|
|
@@ -1430,6 +1434,24 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
|
|
|
1430
1434
|
for (const frame of merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined)) channel.push(frame)
|
|
1431
1435
|
}
|
|
1432
1436
|
const offCurrentSession = deps.watchCurrentSession(informCurrentSession)
|
|
1437
|
+
const offUninstall = deps.watchUninstall(() => {
|
|
1438
|
+
try {
|
|
1439
|
+
// When the client intercept is uninstalled (e.g. role switched from
|
|
1440
|
+
// client to host), transition to `unpaired` (and ensure a readable
|
|
1441
|
+
// current-session state so all remote sessions move into the archived
|
|
1442
|
+
// set and are hidden from the default sidebar view while staying in
|
|
1443
|
+
// their workspace group so they never become 「未分组」 strays).
|
|
1444
|
+
const read = deps.getCurrentSession()
|
|
1445
|
+
merger.setCurrentSession(read.kind === 'open' ? read.sessionId : undefined)
|
|
1446
|
+
applyAnnotation('unpaired')
|
|
1447
|
+
merger.onRemoteDown()
|
|
1448
|
+
currentController?.abort()
|
|
1449
|
+
} catch {
|
|
1450
|
+
// ignore teardown errors
|
|
1451
|
+
} finally {
|
|
1452
|
+
flushWaiters()
|
|
1453
|
+
}
|
|
1454
|
+
})
|
|
1433
1455
|
let sessionPoll: unknown = undefined
|
|
1434
1456
|
const pollCurrentSession = (): void => {
|
|
1435
1457
|
sessionPoll = undefined
|
|
@@ -1471,11 +1493,21 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
|
|
|
1471
1493
|
// persisted server-side, so a re-pair to the SAME server keeps the
|
|
1472
1494
|
// virtual prefix — and the UI's removedIds blacklist never clears
|
|
1473
1495
|
// during a page's life, so a remove here would make the group
|
|
1474
|
-
// un-revivable. Handle it
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1496
|
+
// un-revivable. Handle it like a remote death: keep the shown
|
|
1497
|
+
// workspace group, archive/hide the remote sessions via
|
|
1498
|
+
// applyAnnotation → onStatusChanged (when `unpaired` or when
|
|
1499
|
+
// current-session is readable), and cut the in-flight leg; the
|
|
1500
|
+
// re-pair lands as `online` and restores the sessions under the SAME
|
|
1501
|
+
// identity.
|
|
1502
|
+
const read = deps.getCurrentSession()
|
|
1503
|
+
if (state === 'unpaired' && read.kind === 'unavailable') {
|
|
1504
|
+
merger.setCurrentSession(undefined)
|
|
1505
|
+
} else {
|
|
1506
|
+
informCurrentSession()
|
|
1507
|
+
}
|
|
1477
1508
|
applyAnnotation(annotationOf(state))
|
|
1478
1509
|
merger.onRemoteDown()
|
|
1510
|
+
ensureSessionPoll()
|
|
1479
1511
|
currentController?.abort()
|
|
1480
1512
|
return
|
|
1481
1513
|
}
|
|
@@ -1703,6 +1735,7 @@ async function* mergedGlobalStream(deps: MergedGlobalStreamDeps): AsyncGenerator
|
|
|
1703
1735
|
alive = false
|
|
1704
1736
|
if (sessionPoll !== undefined) clock.clearTimeout(sessionPoll)
|
|
1705
1737
|
offCurrentSession()
|
|
1738
|
+
offUninstall()
|
|
1706
1739
|
offState()
|
|
1707
1740
|
if (signal !== undefined) signal.removeEventListener('abort', onExternalAbort)
|
|
1708
1741
|
currentController?.abort()
|
|
@@ -2400,6 +2433,11 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
2400
2433
|
currentSessionListeners.add(fn)
|
|
2401
2434
|
return () => { currentSessionListeners.delete(fn) }
|
|
2402
2435
|
}
|
|
2436
|
+
const uninstallListeners = new Set<() => void>()
|
|
2437
|
+
const watchUninstall = (fn: () => void): (() => void) => {
|
|
2438
|
+
uninstallListeners.add(fn)
|
|
2439
|
+
return () => { uninstallListeners.delete(fn) }
|
|
2440
|
+
}
|
|
2403
2441
|
const gateway = raw as Record<string | symbol, unknown>
|
|
2404
2442
|
const shape = checkGatewayShape(raw)
|
|
2405
2443
|
const counters = { openWireStream: 0, dispatchRpc: 0 }
|
|
@@ -3301,6 +3339,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3301
3339
|
clock,
|
|
3302
3340
|
getCurrentSession,
|
|
3303
3341
|
watchCurrentSession,
|
|
3342
|
+
watchUninstall,
|
|
3304
3343
|
summarySync,
|
|
3305
3344
|
})
|
|
3306
3345
|
})()
|
|
@@ -3378,6 +3417,7 @@ export function installIntercept(options: InstallInterceptOptions): InterceptHan
|
|
|
3378
3417
|
uninstall() {
|
|
3379
3418
|
if (!installed) return
|
|
3380
3419
|
installed = false
|
|
3420
|
+
for (const listener of [...uninstallListeners]) listener()
|
|
3381
3421
|
// The catalog watcher dies with the wrap (T52-fix2): a late online
|
|
3382
3422
|
// transition after this point belongs to another generation.
|
|
3383
3423
|
offCatalogWatch?.()
|
package/src/merge-streams.ts
CHANGED
|
@@ -419,19 +419,43 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
|
|
|
419
419
|
return currentOriginal() !== id
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
-
/**
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
|
|
428
|
-
|
|
422
|
+
/** Whether the relay status is an unpair or token revocation — while in
|
|
423
|
+
* either state, every known remote session is archived/hidden (except the
|
|
424
|
+
* currently open session when current session is readable, so its open page
|
|
425
|
+
* is not kicked home by `clearArchivedCurrent`). Reconnecting (`none`) or
|
|
426
|
+
* re-sharing automatically reveals them again. */
|
|
427
|
+
function isPairingDisconnected(): boolean {
|
|
428
|
+
return annotation === 'unpaired' || annotation === 'revoked'
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** T58: re-derive {@link hiddenSessions} from the live tombstone set (and
|
|
432
|
+
* when unpaired/revoked with a readable current session, every known remote
|
|
433
|
+
* session) — tombstones hide unless they ARE the current session (or no
|
|
434
|
+
* readable current is known, or the tombstone is inside the T65 hide
|
|
435
|
+
* grace); live-carried ids and ids that (re-)became the current session
|
|
436
|
+
* leave the set when paired. Since T65 the hide/reveal transitions move
|
|
437
|
+
* ONLY the archived set — the session stays in its group's forwarded copy
|
|
438
|
+
* either way — so the caller emits just the merged archived frame when
|
|
439
|
+
* anything changed. */
|
|
429
440
|
function syncHiddenSessions(): { hidden: string[]; revealed: string[] } {
|
|
430
441
|
const hidden: string[] = []
|
|
431
442
|
const revealed: string[] = []
|
|
443
|
+
const disconnected = isPairingDisconnected()
|
|
444
|
+
const current = currentOriginal()
|
|
432
445
|
for (const [id, home] of sessionHome) {
|
|
433
446
|
const record = remote.get(home)
|
|
434
447
|
if (record === undefined) continue
|
|
448
|
+
if (disconnected) {
|
|
449
|
+
if (currentReadable && current !== id) {
|
|
450
|
+
if (!hiddenSessions.has(id)) {
|
|
451
|
+
hiddenSessions.add(id)
|
|
452
|
+
hidden.push(id)
|
|
453
|
+
}
|
|
454
|
+
} else if (hiddenSessions.delete(id)) {
|
|
455
|
+
revealed.push(id)
|
|
456
|
+
}
|
|
457
|
+
continue
|
|
458
|
+
}
|
|
435
459
|
if (stringList(record.sessionIds).includes(id)) {
|
|
436
460
|
if (hiddenSessions.delete(id)) revealed.push(id)
|
|
437
461
|
continue
|
|
@@ -794,7 +818,10 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
|
|
|
794
818
|
|
|
795
819
|
onStatusChanged(): unknown[] {
|
|
796
820
|
if (!localSeen || remote.size === 0) return []
|
|
797
|
-
|
|
821
|
+
const changed = syncHiddenSessions()
|
|
822
|
+
const out = upsertFrames()
|
|
823
|
+
if (changed.hidden.length > 0 || changed.revealed.length > 0) out.push(mergedArchivedFrame())
|
|
824
|
+
return out
|
|
798
825
|
},
|
|
799
826
|
|
|
800
827
|
setCurrentSession(sessionId: string | undefined): unknown[] {
|
|
@@ -811,13 +838,15 @@ export function createWorkspaceMerger(options: WorkspaceMergerOptions): Workspac
|
|
|
811
838
|
},
|
|
812
839
|
|
|
813
840
|
get hasPendingHide(): boolean {
|
|
814
|
-
// Some tombstone is being kept visible:
|
|
815
|
-
// (either it IS the current session, or no
|
|
816
|
-
// decision yet) — the caller keeps polling
|
|
841
|
+
// Some tombstone (or unpaired/revoked session) is being kept visible:
|
|
842
|
+
// it is not in the hidden set (either it IS the current session, or no
|
|
843
|
+
// readable current made any decision yet) — the caller keeps polling
|
|
844
|
+
// until it hides.
|
|
845
|
+
const disconnected = isPairingDisconnected()
|
|
817
846
|
for (const [id, home] of sessionHome) {
|
|
818
847
|
if (hiddenSessions.has(id)) continue
|
|
819
848
|
const record = remote.get(home)
|
|
820
|
-
if (record !== undefined && !stringList(record.sessionIds).includes(id)) return true
|
|
849
|
+
if (record !== undefined && (disconnected || !stringList(record.sessionIds).includes(id))) return true
|
|
821
850
|
}
|
|
822
851
|
return false
|
|
823
852
|
},
|