@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.9
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.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +955 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +73 -39
- package/lib/types/client/locales.d.ts +26 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -22
- package/lib/types/client/mobile-adapt.d.ts +40 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +967 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +77 -39
- package/src/client/locales.ts +36 -22
- package/src/client/mobile-adapt.ts +913 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17658
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -149
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -561
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -63
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -821
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -685
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -268
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1342
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
package/lib/index.js
CHANGED
|
@@ -1,218 +1,17 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
-
import { setInterval
|
|
4
|
-
import { Service } from "@deepseek-ai/cordis";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
3
|
+
import { setInterval, setTimeout as setTimeout$1 } from "node:timers";
|
|
5
4
|
import z from "schemastery";
|
|
6
5
|
import { randomBytes } from "node:crypto";
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
8
7
|
import { homedir, networkInterfaces } from "node:os";
|
|
9
8
|
import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
|
|
9
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
10
10
|
import { z as z$1 } from "zod";
|
|
11
|
-
import { readFile } from "node:fs/promises";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
|
-
import { gzipSync } from "node:zlib";
|
|
14
|
-
import { RpcId } from "@deepseek-ai/dsh-host-apiproxy/api/rpc";
|
|
15
11
|
import http, { request } from "node:http";
|
|
16
12
|
import { connect } from "node:net";
|
|
13
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
17
14
|
import { Tunnel, bin, install } from "cloudflared";
|
|
18
|
-
import { spawn } from "node:child_process";
|
|
19
|
-
//#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@0.1.1-rc.2_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-brand@0_02aaf429ec98c58247037e2222c17a8f/node_modules/@deepseek-ai/dsh-settings/lib/index.js
|
|
20
|
-
/**
|
|
21
|
-
* Structural secret redaction for settings values. `role('secret')` fields are
|
|
22
|
-
* removed from a value before it crosses a wire boundary; a sidecar records
|
|
23
|
-
* each schema-declared secret position and whether it currently holds a value,
|
|
24
|
-
* so a configuration surface can render a write-only input without ever
|
|
25
|
-
* receiving the secret itself.
|
|
26
|
-
* @module @deepseek-ai/dsh-settings/redact
|
|
27
|
-
*/
|
|
28
|
-
/** Whether a value is a plain data object the walker may recurse into. */
|
|
29
|
-
function isRecord$1(value) {
|
|
30
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
-
}
|
|
32
|
-
function walk(node, value, path, secrets) {
|
|
33
|
-
if (node === void 0) return value;
|
|
34
|
-
if (node.meta?.role === "secret") {
|
|
35
|
-
secrets.push({
|
|
36
|
-
path,
|
|
37
|
-
set: value !== void 0
|
|
38
|
-
});
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
switch (node.type) {
|
|
42
|
-
case "object": {
|
|
43
|
-
const properties = node.dict ?? {};
|
|
44
|
-
const source = isRecord$1(value) ? value : void 0;
|
|
45
|
-
const rebuilt = {};
|
|
46
|
-
if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
|
|
47
|
-
if (key in properties) continue;
|
|
48
|
-
rebuilt[key] = entry;
|
|
49
|
-
}
|
|
50
|
-
for (const [key, child] of Object.entries(properties)) {
|
|
51
|
-
const stripped = walk(child, source?.[key], [...path, key], secrets);
|
|
52
|
-
if (stripped !== void 0) rebuilt[key] = stripped;
|
|
53
|
-
}
|
|
54
|
-
return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
|
|
55
|
-
}
|
|
56
|
-
case "dict": {
|
|
57
|
-
if (!isRecord$1(value)) return value;
|
|
58
|
-
const rebuilt = {};
|
|
59
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
60
|
-
const stripped = walk(node.inner, entry, [...path, key], secrets);
|
|
61
|
-
if (stripped !== void 0) rebuilt[key] = stripped;
|
|
62
|
-
}
|
|
63
|
-
return rebuilt;
|
|
64
|
-
}
|
|
65
|
-
case "array":
|
|
66
|
-
if (!Array.isArray(value)) return value;
|
|
67
|
-
return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
|
|
68
|
-
default: return value;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
|
|
73
|
-
* per-namespace sections; plugins register a namespace schema and read the
|
|
74
|
-
* resolved value, which layers schema defaults, the registrant's composition
|
|
75
|
-
* `base`, and the user document section, in that order.
|
|
76
|
-
* @module @deepseek-ai/dsh-settings
|
|
77
|
-
*/
|
|
78
|
-
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
79
|
-
/**
|
|
80
|
-
* Brand a raw string as a {@link SettingsNamespace}.
|
|
81
|
-
* @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
|
|
82
|
-
* @returns the branded namespace.
|
|
83
|
-
*/
|
|
84
|
-
function settingsNamespace(value) {
|
|
85
|
-
if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
|
|
86
|
-
return value;
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Deep equality over JSON-compatible data (objects, arrays, primitives) — the
|
|
90
|
-
* Service Definition's single change-detection predicate, exported so the invariant
|
|
91
|
-
* companion checks exactly the implementation's relation.
|
|
92
|
-
* @param a - one JSON-compatible value.
|
|
93
|
-
* @param b - the other JSON-compatible value.
|
|
94
|
-
* @returns whether the two values are structurally equal.
|
|
95
|
-
*/
|
|
96
|
-
function deepEqualJson(a, b) {
|
|
97
|
-
if (a === b) return true;
|
|
98
|
-
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
99
|
-
if (Array.isArray(a) || Array.isArray(b)) {
|
|
100
|
-
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
101
|
-
return a.every((entry, index) => deepEqualJson(entry, b[index]));
|
|
102
|
-
}
|
|
103
|
-
const left = a;
|
|
104
|
-
const right = b;
|
|
105
|
-
const keys = Object.keys(left);
|
|
106
|
-
if (keys.length !== Object.keys(right).length) return false;
|
|
107
|
-
return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
|
|
108
|
-
}
|
|
109
|
-
/** Whether a value is a plain data object (not an array, null, or class instance). */
|
|
110
|
-
function isPlainObject(value) {
|
|
111
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
112
|
-
const proto = Object.getPrototypeOf(value);
|
|
113
|
-
return proto === Object.prototype || proto === null;
|
|
114
|
-
}
|
|
115
|
-
/** Apply one path op to a detached section, returning the next section. */
|
|
116
|
-
function applyPathOp(section, op) {
|
|
117
|
-
const [head, ...rest] = op.path;
|
|
118
|
-
if (head === void 0) {
|
|
119
|
-
if (op.op === "unset") return {};
|
|
120
|
-
if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
|
|
121
|
-
return { ...op.value };
|
|
122
|
-
}
|
|
123
|
-
if (rest.length === 0) {
|
|
124
|
-
if (op.op === "set") return {
|
|
125
|
-
...section,
|
|
126
|
-
[head]: op.value
|
|
127
|
-
};
|
|
128
|
-
const { [head]: _removed, ...kept } = section;
|
|
129
|
-
return kept;
|
|
130
|
-
}
|
|
131
|
-
const child = section[head];
|
|
132
|
-
if (!isPlainObject(child)) {
|
|
133
|
-
if (op.op === "unset") return section;
|
|
134
|
-
return {
|
|
135
|
-
...section,
|
|
136
|
-
[head]: applyPathOp({}, {
|
|
137
|
-
...op,
|
|
138
|
-
path: rest
|
|
139
|
-
})
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
return {
|
|
143
|
-
...section,
|
|
144
|
-
[head]: applyPathOp(child, {
|
|
145
|
-
...op,
|
|
146
|
-
path: rest
|
|
147
|
-
})
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
/**
|
|
151
|
-
* Layer `over` onto `under`: plain objects merge recursively, every other
|
|
152
|
-
* value (arrays included) replaces the lower layer wholesale. `over` never
|
|
153
|
-
* carries `undefined` entries — sections come from parsed documents and write
|
|
154
|
-
* snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
|
|
155
|
-
* cannot erase lower keys.
|
|
156
|
-
*/
|
|
157
|
-
function mergeLayers(under, over) {
|
|
158
|
-
if (over === void 0) return under;
|
|
159
|
-
if (!isPlainObject(under) || !isPlainObject(over)) return over;
|
|
160
|
-
const merged = { ...under };
|
|
161
|
-
for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
|
|
162
|
-
return merged;
|
|
163
|
-
}
|
|
164
|
-
/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
|
|
165
|
-
function deepFreeze(value) {
|
|
166
|
-
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
|
167
|
-
for (const entry of Object.values(value)) deepFreeze(entry);
|
|
168
|
-
return Object.freeze(value);
|
|
169
|
-
}
|
|
170
|
-
Service.init;
|
|
171
|
-
/**
|
|
172
|
-
* Value mirror of the `FiberState` members {@link isUnloading} compares
|
|
173
|
-
* against: a const enum has no runtime object to import, and the value is
|
|
174
|
-
* needed at runtime (same rationale as the CLI boot driver's mirror).
|
|
175
|
-
*/
|
|
176
|
-
const FIBER_DISPOSED = 4;
|
|
177
|
-
const FIBER_UNLOADING = 5;
|
|
178
|
-
/** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
|
|
179
|
-
function isUnloading(ctx) {
|
|
180
|
-
const state = ctx.fiber.state;
|
|
181
|
-
return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Install the canonical optional-settings consumer wiring: while a settings
|
|
185
|
-
* service exists, register `ns` with the consumer's composition entry as the
|
|
186
|
-
* `base` layer and point the source thunk at the resolved scope; when the
|
|
187
|
-
* service goes away (disposal, provider reload), fall back to the entry so
|
|
188
|
-
* the consumer keeps working exactly as composed. The registration rides the
|
|
189
|
-
* scoped fiber, so no settings service ever mounted means none of this runs.
|
|
190
|
-
* @param ctx - consumer plugin context owning the wiring.
|
|
191
|
-
* @param ns - the consumer-owned settings namespace.
|
|
192
|
-
* @param schema - schema resolving the namespace (typically the plugin Config).
|
|
193
|
-
* @param entry - the consumer's composition entry config, used as `base`.
|
|
194
|
-
* @param hooks - source sink and change notification.
|
|
195
|
-
*/
|
|
196
|
-
function installSettingsSection(ctx, ns, schema, entry, hooks) {
|
|
197
|
-
ctx.inject(["settings"], (sctx) => {
|
|
198
|
-
const scope = sctx.settings.register(ns, schema, {
|
|
199
|
-
base: entry,
|
|
200
|
-
...hooks.validate === void 0 ? {} : { validate: hooks.validate }
|
|
201
|
-
});
|
|
202
|
-
hooks.setSource(() => scope.get());
|
|
203
|
-
sctx.effect(() => () => {
|
|
204
|
-
if (isUnloading(ctx)) return;
|
|
205
|
-
hooks.setSource(() => entry);
|
|
206
|
-
hooks.onChange();
|
|
207
|
-
});
|
|
208
|
-
hooks.onChange();
|
|
209
|
-
scope.watch(() => {
|
|
210
|
-
if (isUnloading(ctx)) return;
|
|
211
|
-
hooks.onChange();
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
//#endregion
|
|
216
15
|
//#region src/pairing.ts
|
|
217
16
|
/**
|
|
218
17
|
* Pairing state machine: one active one-time token, a device-session table,
|
|
@@ -223,8 +22,12 @@ function installSettingsSection(ctx, ns, schema, entry, hooks) {
|
|
|
223
22
|
* Security invariants:
|
|
224
23
|
* - One active token at a time; `issue()` replaces it, so a refreshed QR
|
|
225
24
|
* immediately invalidates the previous link.
|
|
226
|
-
* - A token
|
|
227
|
-
*
|
|
25
|
+
* - A token stays re-usable until it expires or is replaced: the first
|
|
26
|
+
* successful `accept()` marks it consumed, but the same link may pair
|
|
27
|
+
* again within the window (each re-accept mints a fresh device session).
|
|
28
|
+
* Mobile flows routinely split across cookie contexts (camera preview to
|
|
29
|
+
* in-app browser to the system browser), and the later context must be
|
|
30
|
+
* able to complete its own pairing from the same link.
|
|
228
31
|
* - Tokens expire; `accept()` on an expired token is refused like an
|
|
229
32
|
* unknown one (no oracle for validity).
|
|
230
33
|
* - `stop()` revokes every device session and clears the token, so paired
|
|
@@ -232,8 +35,16 @@ function installSettingsSection(ctx, ns, schema, entry, hooks) {
|
|
|
232
35
|
* - `revoke()` drops one device session; idle sessions older than
|
|
233
36
|
* `idleExpireMs` are deleted on sweep, load, and the next gated request.
|
|
234
37
|
*/
|
|
235
|
-
/**
|
|
236
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Default idle-expiry window: 30 days without a heartbeat or a gated
|
|
40
|
+
* request. The reopen service worker refreshes lastSeenAt on every
|
|
41
|
+
* navigation it serves, so the window only runs out through genuine
|
|
42
|
+
* disuse; 30 days matches the browser-credential lifetimes the surrounding
|
|
43
|
+
* flow was built around, while the effective device lifetime stays shorter
|
|
44
|
+
* than the 365-day cookie because of this sweep. Override per deployment
|
|
45
|
+
* through the idleExpireMs config.
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_IDLE_EXPIRE_MS = 720 * 60 * 60 * 1e3;
|
|
237
48
|
/** Cap on the persisted/displayed User-Agent string. */
|
|
238
49
|
const MAX_USER_AGENT_CHARS = 180;
|
|
239
50
|
/** Thrown by issue() for an address outside the sampled LAN literals. */
|
|
@@ -326,7 +137,7 @@ var PairingService = class {
|
|
|
326
137
|
/** Drop sessions whose lastSeenAt is older than idleExpireMs. */
|
|
327
138
|
evictIdle() {
|
|
328
139
|
const now = this.clock.now();
|
|
329
|
-
const limit = this.config.idleExpireMs ??
|
|
140
|
+
const limit = this.config.idleExpireMs ?? 2592e6;
|
|
330
141
|
let removed = false;
|
|
331
142
|
for (const [id, session] of [...this.devices]) if (now - session.lastSeenAt > limit) {
|
|
332
143
|
this.devices.delete(id);
|
|
@@ -439,9 +250,9 @@ var PairingService = class {
|
|
|
439
250
|
*/
|
|
440
251
|
accept(token, userAgent) {
|
|
441
252
|
const record = this.tokens.get(token);
|
|
442
|
-
if (record === void 0 ||
|
|
253
|
+
if (record === void 0 || this.stopped || this.clock.now() > record.expiresAt) return {
|
|
443
254
|
ok: false,
|
|
444
|
-
code:
|
|
255
|
+
code: "invalid"
|
|
445
256
|
};
|
|
446
257
|
record.consumed = true;
|
|
447
258
|
const deviceId = this.clock.randomToken();
|
|
@@ -577,7 +388,7 @@ var PairingService = class {
|
|
|
577
388
|
if (this.stopped) return void 0;
|
|
578
389
|
const session = this.devices.get(deviceId);
|
|
579
390
|
if (session === void 0) return void 0;
|
|
580
|
-
const limit = this.config.idleExpireMs ??
|
|
391
|
+
const limit = this.config.idleExpireMs ?? 2592e6;
|
|
581
392
|
if (this.clock.now() - session.lastSeenAt > limit) {
|
|
582
393
|
this.devices.delete(deviceId);
|
|
583
394
|
this.persist();
|
|
@@ -767,8 +578,8 @@ function makeGateListener(service, requirePairingForLan = true, enabled = true)
|
|
|
767
578
|
}
|
|
768
579
|
/**
|
|
769
580
|
* Whether a request carries a live, non-revoked paired-device cookie for
|
|
770
|
-
* this service. Sibling host routes outside /api (
|
|
771
|
-
* the same check via the remoteWebUiPairing service.
|
|
581
|
+
* this service. Sibling host routes outside /api (the right-panel
|
|
582
|
+
* /sidebar/* routes, etc.) use the same check via the remoteWebUiPairing service.
|
|
772
583
|
* @param service - the pairing service that owns the device table.
|
|
773
584
|
* @param request - the incoming HTTP request.
|
|
774
585
|
* @returns true when the cookie names a live session (and lastSeenAt was refreshed).
|
|
@@ -815,23 +626,6 @@ const JSON_HEADERS = {
|
|
|
815
626
|
"referrer-policy": "no-referrer"
|
|
816
627
|
};
|
|
817
628
|
/**
|
|
818
|
-
* Strict bounded body reader: parse a request body of at most maxBytes as
|
|
819
|
-
* JSON.
|
|
820
|
-
* @throws 'body too large' past the cap, or the JSON.parse error for an
|
|
821
|
-
* invalid or empty payload.
|
|
822
|
-
*/
|
|
823
|
-
async function readBoundedJson(req, maxBytes) {
|
|
824
|
-
const chunks = [];
|
|
825
|
-
let size = 0;
|
|
826
|
-
for await (const chunk of req) {
|
|
827
|
-
const buffer = chunk;
|
|
828
|
-
size += buffer.length;
|
|
829
|
-
if (size > maxBytes) throw new Error("body too large");
|
|
830
|
-
chunks.push(buffer);
|
|
831
|
-
}
|
|
832
|
-
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
833
|
-
}
|
|
834
|
-
/**
|
|
835
629
|
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
836
630
|
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
837
631
|
* Overflow destroys the request instead of draining the remainder (no drain
|
|
@@ -919,7 +713,20 @@ function isTrustedApiRequest(request, trustedHosts) {
|
|
|
919
713
|
}
|
|
920
714
|
}
|
|
921
715
|
/** Cap on pairing request bodies (tokens and workspace ids are tiny). */
|
|
922
|
-
const MAX_BODY_BYTES
|
|
716
|
+
const MAX_BODY_BYTES = 4096;
|
|
717
|
+
/**
|
|
718
|
+
* The rate-limit bucket key for one accept attempt (pure; unit-tested).
|
|
719
|
+
* The first client-visible XFF hop separates buckets behind the auto-tunnel
|
|
720
|
+
* (every internet client arrives from 127.0.0.1 there) — but only for
|
|
721
|
+
* loopback peers, since a direct LAN client can rotate the header freely.
|
|
722
|
+
* @param socketIp - the socket peer address.
|
|
723
|
+
* @param forwarded - the first XFF hop, already trimmed, if any.
|
|
724
|
+
* @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
|
|
725
|
+
*/
|
|
726
|
+
function acceptLimitKey(socketIp, forwarded, bucket) {
|
|
727
|
+
if (isLoopbackAddress(socketIp) && forwarded !== void 0 && forwarded !== "") return `${bucket}|${socketIp}|${forwarded}`;
|
|
728
|
+
return `${bucket}|${socketIp}`;
|
|
729
|
+
}
|
|
923
730
|
/**
|
|
924
731
|
* The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
|
|
925
732
|
* from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
|
|
@@ -938,6 +745,124 @@ function publicHostOf(url) {
|
|
|
938
745
|
}
|
|
939
746
|
/** Cookie lifetime: one year; revoked sessions die at the gate regardless. */
|
|
940
747
|
const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60;
|
|
748
|
+
/**
|
|
749
|
+
* The cookieless device credential: pass the device id from the /pair-app
|
|
750
|
+
* URL into sessionStorage (and localStorage for tab reloads) before any app
|
|
751
|
+
* script runs - same key the boot patch and the channel gate read. The
|
|
752
|
+
* replaceState to '/' hides the credential URL from the address bar and
|
|
753
|
+
* leaves the SPA at its canonical root path. The reopen service worker is
|
|
754
|
+
* registered in the same breath: later navigations to '/' (history,
|
|
755
|
+
* bookmark, tab restore) must not fall through to the harness index gate,
|
|
756
|
+
* which the cookieless flow can never satisfy.
|
|
757
|
+
*/
|
|
758
|
+
const APP_DEVICE_STORAGE_KEY = "dsh-remote-device";
|
|
759
|
+
function appShellCaptureScript(deviceId) {
|
|
760
|
+
const safeId = JSON.stringify(deviceId);
|
|
761
|
+
const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`;
|
|
762
|
+
return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});localStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}${register}})()<\/script>`;
|
|
763
|
+
}
|
|
764
|
+
/** Patch the official index document with the device-capture script. */
|
|
765
|
+
function patchAppShell(html, deviceId) {
|
|
766
|
+
const script = appShellCaptureScript(deviceId);
|
|
767
|
+
const at = html.indexOf("</head>");
|
|
768
|
+
if (at === -1) return script + html;
|
|
769
|
+
return html.slice(0, at) + script + html.slice(at);
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* The reopen service worker served at PAIR_PATHS.appServiceWorker. A paired
|
|
773
|
+
* phone comes back to `/` from history, bookmarks, or tab restore; the
|
|
774
|
+
* harness fallback seat answers that navigation with its browser-auth 401,
|
|
775
|
+
* and the cookieless mobile flow never holds a browser-auth cookie. The
|
|
776
|
+
* worker owns navigations to `/` instead: network-first through /pair-app
|
|
777
|
+
* (which validates the device cookie and refreshes its presence), the
|
|
778
|
+
* cached shell for offline opens, and a pass-through of the original
|
|
779
|
+
* request when the plugin no longer answers. Plain-HTTP LAN origins are not
|
|
780
|
+
* secure contexts, so the worker never registers there — the LAN reopen
|
|
781
|
+
* path stays "scan a fresh QR", which is cheap in-network.
|
|
782
|
+
*
|
|
783
|
+
* Kept as a plain string (same pattern as the capture script): it must load
|
|
784
|
+
* with no build step, run on every JS engine that ships service workers,
|
|
785
|
+
* and be assertable as source in tests. Bump SHELL_CACHE when the storage
|
|
786
|
+
* layout changes so old caches are pruned on activate.
|
|
787
|
+
*/
|
|
788
|
+
function appServiceWorkerScript() {
|
|
789
|
+
return `'use strict';
|
|
790
|
+
var SHELL_CACHE = 'dsh-remote-shell-v1';
|
|
791
|
+
var SHELL_KEY = '/dsh-remote-shell';
|
|
792
|
+
var APP_URL = '/pair-app';
|
|
793
|
+
self.addEventListener('install', function (event) {
|
|
794
|
+
event.waitUntil(refreshShell().then(function () { return self.skipWaiting(); }));
|
|
795
|
+
});
|
|
796
|
+
self.addEventListener('activate', function (event) {
|
|
797
|
+
event.waitUntil(self.caches.keys().then(function (names) {
|
|
798
|
+
return Promise.all(names.map(function (name) {
|
|
799
|
+
return name === SHELL_CACHE ? Promise.resolve() : self.caches.delete(name);
|
|
800
|
+
}));
|
|
801
|
+
}).then(function () { return self.clients.claim(); }));
|
|
802
|
+
});
|
|
803
|
+
self.addEventListener('fetch', function (event) {
|
|
804
|
+
var request = event.request;
|
|
805
|
+
if (request.method !== 'GET' || request.mode !== 'navigate') return;
|
|
806
|
+
var path;
|
|
807
|
+
try { path = new URL(request.url).pathname; } catch (error) { return; }
|
|
808
|
+
if (path !== '/') return;
|
|
809
|
+
event.respondWith(reopen(event));
|
|
810
|
+
});
|
|
811
|
+
/* Network-first: a live pairing gets the current shell (the /pair-app check
|
|
812
|
+
refreshes lastSeenAt, so every reopen also keeps the session alive); a
|
|
813
|
+
refused shell passes the original navigation through for the harness's
|
|
814
|
+
own response; an unreachable server falls back to the cached shell. */
|
|
815
|
+
function reopen(event) {
|
|
816
|
+
var request = event.request;
|
|
817
|
+
return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
|
|
818
|
+
if (!response.ok) return fetch(request);
|
|
819
|
+
var copy = response.clone();
|
|
820
|
+
event.waitUntil(self.caches.open(SHELL_CACHE).then(function (cache) {
|
|
821
|
+
return cache.put(SHELL_KEY, copy);
|
|
822
|
+
}));
|
|
823
|
+
return response;
|
|
824
|
+
}, function () {
|
|
825
|
+
return self.caches.match(SHELL_KEY).then(function (cached) {
|
|
826
|
+
return cached !== undefined ? cached : fetch(request);
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
/* Best-effort shell warm-up and refresh; never rejects. */
|
|
831
|
+
function refreshShell() {
|
|
832
|
+
return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
|
|
833
|
+
if (!response.ok) return undefined;
|
|
834
|
+
var copy = response.clone();
|
|
835
|
+
return self.caches.open(SHELL_CACHE).then(function (cache) {
|
|
836
|
+
return cache.put(SHELL_KEY, copy);
|
|
837
|
+
});
|
|
838
|
+
}, function () { return undefined; });
|
|
839
|
+
}`;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* The dead-end guard for a failed /pair-accept and for a dead reopen (the
|
|
843
|
+
* reopen service worker serves this page at `/` when the pairing no longer
|
|
844
|
+
* passes): a device without a live pairing has no harness browser-auth
|
|
845
|
+
* cookie, so a redirect to bare `/` would land on the harness browser-auth
|
|
846
|
+
* 401 page ("authentication required"), which reads like a broken server.
|
|
847
|
+
* Serve a plain bilingual explanation instead; an already-paired device (live
|
|
848
|
+
* device cookie, browser credential redeemed during its first scan) keeps
|
|
849
|
+
* the old behavior and is sent on to the app.
|
|
850
|
+
*/
|
|
851
|
+
function pairingFailurePage() {
|
|
852
|
+
return [
|
|
853
|
+
"<!doctype html><html><head><meta charset=\"utf-8\">",
|
|
854
|
+
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">",
|
|
855
|
+
"<meta name=\"referrer\" content=\"no-referrer\">",
|
|
856
|
+
"<title>Pairing link invalid</title></head>",
|
|
857
|
+
"<body style=\"font-family:system-ui,sans-serif;padding:24px;max-width:32em;margin:0 auto;line-height:1.6\">",
|
|
858
|
+
"<p><strong>配对已失效,或配对链接已过期。</strong></p>",
|
|
859
|
+
"<p>请在桌面端打开远程控制面板,刷新二维码后重新扫码;重新配对后本机即可恢复访问。</p>",
|
|
860
|
+
"<hr style=\"border:none;border-top:1px solid #ccc;margin:16px 0\">",
|
|
861
|
+
"<p><strong>This pairing has expired or the link is no longer valid.</strong></p>",
|
|
862
|
+
"<p>Open the remote panel on the desktop, refresh the QR code, and scan again — re-pairing restores access on this device.</p>",
|
|
863
|
+
"</body></html>"
|
|
864
|
+
].join("");
|
|
865
|
+
}
|
|
941
866
|
/** Route paths (exact matches under /api). */
|
|
942
867
|
const PAIR_PATHS = {
|
|
943
868
|
issue: "/api/pair/issue",
|
|
@@ -946,7 +871,17 @@ const PAIR_PATHS = {
|
|
|
946
871
|
revoke: "/api/pair/revoke",
|
|
947
872
|
heartbeat: "/api/pair/heartbeat",
|
|
948
873
|
status: "/api/pair/status",
|
|
949
|
-
events: "/api/pair/events"
|
|
874
|
+
events: "/api/pair/events",
|
|
875
|
+
lanBind: "/api/pair/lan-bind",
|
|
876
|
+
/** Top-level accept-and-redirect entry the QR link points at. */
|
|
877
|
+
acceptPage: "/pair-accept",
|
|
878
|
+
/** The cookieless app landing: serves the official shell for a paired device. */
|
|
879
|
+
appPage: "/pair-app",
|
|
880
|
+
/**
|
|
881
|
+
* The reopen service worker (registered by the capture script). Root-level
|
|
882
|
+
* path so its default script-directory scope already covers `/`.
|
|
883
|
+
*/
|
|
884
|
+
appServiceWorker: "/pair-app.sw.js"
|
|
950
885
|
};
|
|
951
886
|
/**
|
|
952
887
|
* /api/pair request payload contracts. Each POST endpoint validates its body
|
|
@@ -956,10 +891,7 @@ const PAIR_PATHS = {
|
|
|
956
891
|
* issue/accept enforce their optional/required fields. Unknown (extra) keys
|
|
957
892
|
* are tolerated exactly as the previous manual reads ignored them.
|
|
958
893
|
*/
|
|
959
|
-
const issuePayloadSchema = z$1.object({
|
|
960
|
-
workspaceId: z$1.string().min(1).optional(),
|
|
961
|
-
address: z$1.string().min(1).optional()
|
|
962
|
-
});
|
|
894
|
+
const issuePayloadSchema = z$1.object({ address: z$1.string().min(1).optional() });
|
|
963
895
|
const acceptPayloadSchema = z$1.object({ token: z$1.string().default("") });
|
|
964
896
|
const revokePayloadSchema = z$1.object({ deviceId: z$1.string().min(1) });
|
|
965
897
|
const pairActionPayloadSchema = z$1.object({}).passthrough();
|
|
@@ -1034,10 +966,11 @@ function makeRoutes(deps) {
|
|
|
1034
966
|
const events = new PairingEventsStream(service);
|
|
1035
967
|
/** Loopback-only fence: the desktop panel's control endpoints. */
|
|
1036
968
|
const loopbackFence = (req) => isTrustedApiRequest(req, []);
|
|
1037
|
-
/** Phone-facing fence: loopback, the
|
|
969
|
+
/** Phone-facing fence: loopback, the service's live LAN literals, or the configured public host. */
|
|
1038
970
|
const lanFence = (req) => {
|
|
1039
971
|
const publicHost = publicHostOf(service.publicBaseUrl);
|
|
1040
|
-
|
|
972
|
+
const bases = service.lanAddresses;
|
|
973
|
+
return isTrustedApiRequest(req, publicHost === void 0 ? bases : [...bases, publicHost]);
|
|
1041
974
|
};
|
|
1042
975
|
const requireMethod = (req, res, method) => {
|
|
1043
976
|
if (req.method === method) return true;
|
|
@@ -1049,10 +982,13 @@ function makeRoutes(deps) {
|
|
|
1049
982
|
const acceptAttempts = /* @__PURE__ */ new Map();
|
|
1050
983
|
const ACCEPT_MAX_ATTEMPTS = 10;
|
|
1051
984
|
const ACCEPT_WINDOW_MS = 3e4;
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
985
|
+
/**
|
|
986
|
+
* @param bucket - the POST /api/pair/accept and the GET /pair-accept flows
|
|
987
|
+
* count separately: a QR re-scan (page navigation) must not consume a
|
|
988
|
+
* brute-force budget that belongs to token guessing (and vice versa).
|
|
989
|
+
*/
|
|
990
|
+
const rateLimitAccept = (req, bucket) => {
|
|
991
|
+
const ip = acceptLimitKey(req.socket?.remoteAddress ?? "unknown", typeof req.headers["x-forwarded-for"] === "string" ? (req.headers["x-forwarded-for"].split(",")[0] ?? "").trim() : void 0, bucket);
|
|
1056
992
|
const nowMs = Date.now();
|
|
1057
993
|
if (acceptAttempts.size > 256) {
|
|
1058
994
|
for (const [key, attempt] of acceptAttempts) if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key);
|
|
@@ -1078,7 +1014,7 @@ function makeRoutes(deps) {
|
|
|
1078
1014
|
return;
|
|
1079
1015
|
}
|
|
1080
1016
|
const body = await readJsonBody(req, {
|
|
1081
|
-
maxBytes: MAX_BODY_BYTES
|
|
1017
|
+
maxBytes: MAX_BODY_BYTES,
|
|
1082
1018
|
objectOnly: true
|
|
1083
1019
|
});
|
|
1084
1020
|
const payload = parsePairPayload(issuePayloadSchema, body);
|
|
@@ -1089,14 +1025,14 @@ function makeRoutes(deps) {
|
|
|
1089
1025
|
});
|
|
1090
1026
|
return;
|
|
1091
1027
|
}
|
|
1092
|
-
const {
|
|
1028
|
+
const { address } = payload;
|
|
1093
1029
|
try {
|
|
1094
|
-
const { token, expiresAt } = service.issue(
|
|
1030
|
+
const { token, expiresAt } = service.issue(void 0, address);
|
|
1095
1031
|
const base = address === void 0 ? service.publicBaseUrl ?? service.lanBaseUrl : service.lanBaseUrlFor(address);
|
|
1096
1032
|
if (base === void 0) throw new Error("remote-web-ui: base unavailable");
|
|
1097
1033
|
writeJson(res, 200, {
|
|
1098
1034
|
ok: true,
|
|
1099
|
-
url: `${base}/
|
|
1035
|
+
url: `${base}/pair-accept?pair=${token}`,
|
|
1100
1036
|
token,
|
|
1101
1037
|
expiresAt,
|
|
1102
1038
|
lanAddresses: service.lanAddresses,
|
|
@@ -1119,7 +1055,7 @@ function makeRoutes(deps) {
|
|
|
1119
1055
|
});
|
|
1120
1056
|
return;
|
|
1121
1057
|
}
|
|
1122
|
-
if (rateLimitAccept(req)) {
|
|
1058
|
+
if (rateLimitAccept(req, "api")) {
|
|
1123
1059
|
writeJson(res, 429, {
|
|
1124
1060
|
ok: false,
|
|
1125
1061
|
code: "rate-limited"
|
|
@@ -1127,7 +1063,7 @@ function makeRoutes(deps) {
|
|
|
1127
1063
|
return;
|
|
1128
1064
|
}
|
|
1129
1065
|
const body = await readJsonBody(req, {
|
|
1130
|
-
maxBytes: MAX_BODY_BYTES
|
|
1066
|
+
maxBytes: MAX_BODY_BYTES,
|
|
1131
1067
|
objectOnly: true
|
|
1132
1068
|
});
|
|
1133
1069
|
const payload = parsePairPayload(acceptPayloadSchema, body);
|
|
@@ -1162,7 +1098,7 @@ function makeRoutes(deps) {
|
|
|
1162
1098
|
return;
|
|
1163
1099
|
}
|
|
1164
1100
|
const body = await readJsonBody(req, {
|
|
1165
|
-
maxBytes: MAX_BODY_BYTES
|
|
1101
|
+
maxBytes: MAX_BODY_BYTES,
|
|
1166
1102
|
objectOnly: true
|
|
1167
1103
|
});
|
|
1168
1104
|
if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
|
|
@@ -1185,7 +1121,7 @@ function makeRoutes(deps) {
|
|
|
1185
1121
|
return;
|
|
1186
1122
|
}
|
|
1187
1123
|
const body = await readJsonBody(req, {
|
|
1188
|
-
maxBytes: MAX_BODY_BYTES
|
|
1124
|
+
maxBytes: MAX_BODY_BYTES,
|
|
1189
1125
|
objectOnly: true
|
|
1190
1126
|
});
|
|
1191
1127
|
const payload = parsePairPayload(revokePayloadSchema, body);
|
|
@@ -1215,7 +1151,7 @@ function makeRoutes(deps) {
|
|
|
1215
1151
|
return;
|
|
1216
1152
|
}
|
|
1217
1153
|
const body = await readJsonBody(req, {
|
|
1218
|
-
maxBytes: MAX_BODY_BYTES
|
|
1154
|
+
maxBytes: MAX_BODY_BYTES,
|
|
1219
1155
|
objectOnly: true
|
|
1220
1156
|
});
|
|
1221
1157
|
if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
|
|
@@ -1272,1138 +1208,208 @@ function makeRoutes(deps) {
|
|
|
1272
1208
|
events.open(req, res);
|
|
1273
1209
|
events.push(service.snapshot());
|
|
1274
1210
|
};
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
handler: handleIssue
|
|
1280
|
-
},
|
|
1281
|
-
{
|
|
1282
|
-
kind: "exact",
|
|
1283
|
-
path: PAIR_PATHS.accept,
|
|
1284
|
-
handler: handleAccept
|
|
1285
|
-
},
|
|
1286
|
-
{
|
|
1287
|
-
kind: "exact",
|
|
1288
|
-
path: PAIR_PATHS.stop,
|
|
1289
|
-
handler: handleStop
|
|
1290
|
-
},
|
|
1291
|
-
{
|
|
1292
|
-
kind: "exact",
|
|
1293
|
-
path: PAIR_PATHS.revoke,
|
|
1294
|
-
handler: handleRevoke
|
|
1295
|
-
},
|
|
1296
|
-
{
|
|
1297
|
-
kind: "exact",
|
|
1298
|
-
path: PAIR_PATHS.heartbeat,
|
|
1299
|
-
handler: handleHeartbeat
|
|
1300
|
-
},
|
|
1301
|
-
{
|
|
1302
|
-
kind: "exact",
|
|
1303
|
-
path: PAIR_PATHS.status,
|
|
1304
|
-
handler: handleStatus
|
|
1305
|
-
},
|
|
1306
|
-
{
|
|
1307
|
-
kind: "exact",
|
|
1308
|
-
path: PAIR_PATHS.events,
|
|
1309
|
-
handler: handleEvents
|
|
1310
|
-
}
|
|
1311
|
-
];
|
|
1312
|
-
}
|
|
1313
|
-
//#endregion
|
|
1314
|
-
//#region src/mobile-routes.ts
|
|
1315
|
-
/**
|
|
1316
|
-
* The mobile surface's page routes: /m canonicalizes to /m/, which serves the
|
|
1317
|
-
* standalone phone UI. The page and worker live in the /m/ scope so the PWA
|
|
1318
|
-
* can cache only its static shell; the paired-device data channel remains at
|
|
1319
|
-
* /m/api and is never handled by the worker.
|
|
1320
|
-
*/
|
|
1321
|
-
const MOBILE_ROOT = "/m/";
|
|
1322
|
-
const MOBILE_BUNDLE_URL = "/m/mobile.js";
|
|
1323
|
-
const MOBILE_MANIFEST_URL = "/m/manifest.webmanifest";
|
|
1324
|
-
const MOBILE_WORKER_URL = "/m/service-worker.js";
|
|
1325
|
-
const MOBILE_OFFLINE_URL = "/m/offline.html";
|
|
1326
|
-
/** The standalone mobile bundle (built artifact, next to this file's own lib output). */
|
|
1327
|
-
function mobileBundlePath() {
|
|
1328
|
-
return fileURLToPath(new URL("../lib/mobile.js", import.meta.url));
|
|
1329
|
-
}
|
|
1330
|
-
/** A package asset available to the host after a registry or git install. */
|
|
1331
|
-
function mobileAssetPath(name) {
|
|
1332
|
-
return fileURLToPath(new URL("../assets/" + name, import.meta.url));
|
|
1333
|
-
}
|
|
1334
|
-
/** The mobile page shell: self-contained, with no document-level user data. */
|
|
1335
|
-
function pageHtml(bundleUrl) {
|
|
1336
|
-
return [
|
|
1337
|
-
"<!doctype html>",
|
|
1338
|
-
"<html lang=\"zh-CN\">",
|
|
1339
|
-
"<head>",
|
|
1340
|
-
"<meta charset=\"utf-8\">",
|
|
1341
|
-
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover\">",
|
|
1342
|
-
"<meta name=\"theme-color\" content=\"#f3f5f9\">",
|
|
1343
|
-
"<meta name=\"referrer\" content=\"no-referrer\">",
|
|
1344
|
-
"<meta name=\"mobile-web-app-capable\" content=\"yes\">",
|
|
1345
|
-
"<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">",
|
|
1346
|
-
"<meta name=\"apple-mobile-web-app-title\" content=\"DSH Remote\">",
|
|
1347
|
-
"<link rel=\"manifest\" href=\"/m/manifest.webmanifest\">",
|
|
1348
|
-
"<link rel=\"apple-touch-icon\" href=\"/m/apple-touch-icon.png\">",
|
|
1349
|
-
"<title>远程访问</title>",
|
|
1350
|
-
"</head>",
|
|
1351
|
-
"<body>",
|
|
1352
|
-
"<div id=\"root\"></div>",
|
|
1353
|
-
"<script type=\"module\" src=\"" + bundleUrl + "\"><\/script>",
|
|
1354
|
-
"</body>",
|
|
1355
|
-
"</html>"
|
|
1356
|
-
].join("");
|
|
1357
|
-
}
|
|
1358
|
-
/** The installable app identity and icon declarations for the /m scope. */
|
|
1359
|
-
function manifestJson() {
|
|
1360
|
-
return JSON.stringify({
|
|
1361
|
-
id: MOBILE_ROOT,
|
|
1362
|
-
name: "DSH Remote",
|
|
1363
|
-
short_name: "DSH Remote",
|
|
1364
|
-
start_url: MOBILE_ROOT,
|
|
1365
|
-
scope: MOBILE_ROOT,
|
|
1366
|
-
display: "standalone",
|
|
1367
|
-
background_color: "#151424",
|
|
1368
|
-
theme_color: "#f3f5f9",
|
|
1369
|
-
icons: [{
|
|
1370
|
-
src: "/m/icon-192.png",
|
|
1371
|
-
sizes: "192x192",
|
|
1372
|
-
type: "image/png",
|
|
1373
|
-
purpose: "any"
|
|
1374
|
-
}, {
|
|
1375
|
-
src: "/m/icon-512.png",
|
|
1376
|
-
sizes: "512x512",
|
|
1377
|
-
type: "image/png",
|
|
1378
|
-
purpose: "any maskable"
|
|
1379
|
-
}]
|
|
1380
|
-
}, null, 2);
|
|
1381
|
-
}
|
|
1382
|
-
/** A static fallback that contains no paired-device, session, or workspace data. */
|
|
1383
|
-
function offlineHtml() {
|
|
1384
|
-
return [
|
|
1385
|
-
"<!doctype html>",
|
|
1386
|
-
"<html lang=\"zh-CN\">",
|
|
1387
|
-
"<head>",
|
|
1388
|
-
"<meta charset=\"utf-8\">",
|
|
1389
|
-
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">",
|
|
1390
|
-
"<meta name=\"theme-color\" content=\"#151424\">",
|
|
1391
|
-
"<title>DSH Remote</title>",
|
|
1392
|
-
"<style>body{margin:0;background:#151424;color:#f3f5f9;font:16px system-ui,sans-serif}main{box-sizing:border-box;display:grid;min-height:100vh;place-content:center;padding:32px;text-align:center}h1{margin:0 0 12px;font-size:24px}p{margin:0;color:#c4c7d8;line-height:1.5}</style>",
|
|
1393
|
-
"</head>",
|
|
1394
|
-
"<body><main><h1>DSH Remote</h1><p>Cannot reach the running DSH host. Restore the connection and reopen the app.</p></main></body>",
|
|
1395
|
-
"</html>"
|
|
1396
|
-
].join("");
|
|
1397
|
-
}
|
|
1398
|
-
/** Send a small static UTF-8 body with revalidation headers. */
|
|
1399
|
-
function writeStatic(res, status, type, body) {
|
|
1400
|
-
res.writeHead(status, {
|
|
1401
|
-
"content-type": type + "; charset=utf-8",
|
|
1402
|
-
"cache-control": "no-cache",
|
|
1403
|
-
"referrer-policy": "no-referrer"
|
|
1404
|
-
});
|
|
1405
|
-
res.end(body);
|
|
1406
|
-
}
|
|
1407
|
-
/** Send the worker with the same scope it registers for, without HTTP caching. */
|
|
1408
|
-
function writeServiceWorker(res, body) {
|
|
1409
|
-
res.writeHead(200, {
|
|
1410
|
-
"content-type": "text/javascript; charset=utf-8",
|
|
1411
|
-
"cache-control": "no-cache",
|
|
1412
|
-
"referrer-policy": "no-referrer",
|
|
1413
|
-
"service-worker-allowed": MOBILE_ROOT
|
|
1414
|
-
});
|
|
1415
|
-
res.end(body);
|
|
1416
|
-
}
|
|
1417
|
-
/** Send a PNG body without decoding it to UTF-8. */
|
|
1418
|
-
function writePng(res, status, body) {
|
|
1419
|
-
res.writeHead(status, {
|
|
1420
|
-
"content-type": "image/png",
|
|
1421
|
-
"cache-control": "public, max-age=31536000, immutable",
|
|
1422
|
-
"referrer-policy": "no-referrer"
|
|
1423
|
-
});
|
|
1424
|
-
res.end(body);
|
|
1425
|
-
}
|
|
1426
|
-
/** Canonicalize the old /m route while retaining pair and workspace query parameters. */
|
|
1427
|
-
function redirectToMobileRoot(req, res) {
|
|
1428
|
-
const url = new URL(req.url ?? "/m", "http://x");
|
|
1429
|
-
res.writeHead(308, {
|
|
1430
|
-
location: MOBILE_ROOT + url.search,
|
|
1431
|
-
"cache-control": "no-store",
|
|
1432
|
-
"referrer-policy": "no-referrer"
|
|
1433
|
-
});
|
|
1434
|
-
res.end();
|
|
1435
|
-
}
|
|
1436
|
-
/**
|
|
1437
|
-
* Build the mobile page routes.
|
|
1438
|
-
* @returns Exact routes for the canonical page, static shell, and PWA assets.
|
|
1439
|
-
*/
|
|
1440
|
-
function makeMobileRoutes() {
|
|
1441
|
-
const handlePage = (_req, res) => {
|
|
1442
|
-
writeStatic(res, 200, "text/html", pageHtml(MOBILE_BUNDLE_URL));
|
|
1443
|
-
};
|
|
1444
|
-
const handleManifest = (_req, res) => {
|
|
1445
|
-
writeStatic(res, 200, "application/manifest+json", manifestJson());
|
|
1446
|
-
};
|
|
1447
|
-
const handleOffline = (_req, res) => {
|
|
1448
|
-
writeStatic(res, 200, "text/html", offlineHtml());
|
|
1449
|
-
};
|
|
1450
|
-
let bundleBody;
|
|
1451
|
-
let bundleGzip;
|
|
1452
|
-
const handleBundle = async (req, res) => {
|
|
1453
|
-
if (bundleBody === void 0) {
|
|
1454
|
-
const path = mobileBundlePath();
|
|
1455
|
-
if (!existsSync(path)) {
|
|
1456
|
-
writeStatic(res, 503, "text/plain", "mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build");
|
|
1457
|
-
return;
|
|
1458
|
-
}
|
|
1459
|
-
try {
|
|
1460
|
-
bundleBody = await readFile(path, "utf8");
|
|
1461
|
-
bundleGzip = gzipSync(Buffer.from(bundleBody, "utf8"));
|
|
1462
|
-
} catch {
|
|
1463
|
-
writeStatic(res, 500, "text/plain", "failed to read the mobile bundle");
|
|
1464
|
-
return;
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
const acceptEncoding = req.headers["accept-encoding"] ?? "";
|
|
1468
|
-
if (typeof acceptEncoding === "string" && acceptEncoding.includes("gzip") && bundleGzip !== void 0) {
|
|
1469
|
-
res.writeHead(200, {
|
|
1470
|
-
"content-type": "text/javascript; charset=utf-8",
|
|
1471
|
-
"content-encoding": "gzip",
|
|
1472
|
-
"cache-control": "no-cache",
|
|
1473
|
-
vary: "Accept-Encoding",
|
|
1474
|
-
"referrer-policy": "no-referrer"
|
|
1475
|
-
});
|
|
1476
|
-
res.end(bundleGzip);
|
|
1477
|
-
} else {
|
|
1478
|
-
res.writeHead(200, {
|
|
1479
|
-
"content-type": "text/javascript; charset=utf-8",
|
|
1480
|
-
"cache-control": "no-cache",
|
|
1481
|
-
vary: "Accept-Encoding",
|
|
1482
|
-
"referrer-policy": "no-referrer"
|
|
1483
|
-
});
|
|
1484
|
-
res.end(bundleBody);
|
|
1485
|
-
}
|
|
1486
|
-
};
|
|
1487
|
-
let workerBody;
|
|
1488
|
-
const handleWorker = async (_req, res) => {
|
|
1489
|
-
if (workerBody === void 0) {
|
|
1490
|
-
const path = mobileAssetPath("mobile-service-worker.js");
|
|
1491
|
-
if (!existsSync(path)) {
|
|
1492
|
-
writeStatic(res, 503, "text/plain", "mobile service worker not found");
|
|
1493
|
-
return;
|
|
1494
|
-
}
|
|
1495
|
-
try {
|
|
1496
|
-
workerBody = await readFile(path, "utf8");
|
|
1497
|
-
} catch {
|
|
1498
|
-
writeStatic(res, 500, "text/plain", "failed to read the mobile service worker");
|
|
1499
|
-
return;
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
writeServiceWorker(res, workerBody);
|
|
1503
|
-
};
|
|
1504
|
-
const makeIconHandler = (asset) => async (_req, res) => {
|
|
1505
|
-
const path = mobileAssetPath(asset);
|
|
1506
|
-
if (!existsSync(path)) {
|
|
1507
|
-
writeStatic(res, 404, "text/plain", asset + " not found");
|
|
1508
|
-
return;
|
|
1509
|
-
}
|
|
1510
|
-
try {
|
|
1511
|
-
writePng(res, 200, await readFile(path));
|
|
1512
|
-
} catch {
|
|
1513
|
-
writeStatic(res, 500, "text/plain", "failed to read " + asset);
|
|
1514
|
-
}
|
|
1515
|
-
};
|
|
1516
|
-
return [
|
|
1517
|
-
{
|
|
1518
|
-
kind: "exact",
|
|
1519
|
-
path: "/m",
|
|
1520
|
-
handler: redirectToMobileRoot
|
|
1521
|
-
},
|
|
1522
|
-
{
|
|
1523
|
-
kind: "exact",
|
|
1524
|
-
path: MOBILE_ROOT,
|
|
1525
|
-
handler: handlePage
|
|
1526
|
-
},
|
|
1527
|
-
{
|
|
1528
|
-
kind: "exact",
|
|
1529
|
-
path: MOBILE_BUNDLE_URL,
|
|
1530
|
-
handler: handleBundle
|
|
1531
|
-
},
|
|
1532
|
-
{
|
|
1533
|
-
kind: "exact",
|
|
1534
|
-
path: MOBILE_MANIFEST_URL,
|
|
1535
|
-
handler: handleManifest
|
|
1536
|
-
},
|
|
1537
|
-
{
|
|
1538
|
-
kind: "exact",
|
|
1539
|
-
path: MOBILE_WORKER_URL,
|
|
1540
|
-
handler: handleWorker
|
|
1541
|
-
},
|
|
1542
|
-
{
|
|
1543
|
-
kind: "exact",
|
|
1544
|
-
path: MOBILE_OFFLINE_URL,
|
|
1545
|
-
handler: handleOffline
|
|
1546
|
-
},
|
|
1547
|
-
{
|
|
1548
|
-
kind: "exact",
|
|
1549
|
-
path: "/m/apple-touch-icon.png",
|
|
1550
|
-
handler: makeIconHandler("apple-touch-icon.png")
|
|
1551
|
-
},
|
|
1552
|
-
{
|
|
1553
|
-
kind: "exact",
|
|
1554
|
-
path: "/m/icon-192.png",
|
|
1555
|
-
handler: makeIconHandler("mobile-icon-192.png")
|
|
1556
|
-
},
|
|
1557
|
-
{
|
|
1558
|
-
kind: "exact",
|
|
1559
|
-
path: "/m/icon-512.png",
|
|
1560
|
-
handler: makeIconHandler("mobile-icon-512.png")
|
|
1561
|
-
}
|
|
1562
|
-
];
|
|
1563
|
-
}
|
|
1564
|
-
//#endregion
|
|
1565
|
-
//#region src/mobile-api.ts
|
|
1566
|
-
/**
|
|
1567
|
-
* Methods the phone surface may call. Everything else is refused HERE — but
|
|
1568
|
-
* note the paired-device cookie also passes the global api/gate for the full
|
|
1569
|
-
* ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
|
|
1570
|
-
* the allowlist only constrains this /m/api proxy, not the cookie's reach.
|
|
1571
|
-
* stop() revokes every device; the loopback panel can also revoke one
|
|
1572
|
-
* device at a time.
|
|
1573
|
-
*/
|
|
1574
|
-
const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
1575
|
-
"host.listDirectory",
|
|
1576
|
-
"workspace.create",
|
|
1577
|
-
"workspace.list",
|
|
1578
|
-
"agentPreset.list",
|
|
1579
|
-
"session.create",
|
|
1580
|
-
"session.list",
|
|
1581
|
-
"session.history",
|
|
1582
|
-
"session.search",
|
|
1583
|
-
"session.prompt",
|
|
1584
|
-
"session.models",
|
|
1585
|
-
"session.selectModel",
|
|
1586
|
-
"session.rename",
|
|
1587
|
-
"session.cancel"
|
|
1588
|
-
]);
|
|
1589
|
-
/**
|
|
1590
|
-
* Locally answered display-preference method (the phone's read-only
|
|
1591
|
-
* surface preferences; never proxied to the host ApiProxy and never a
|
|
1592
|
-
* settings-domain write).
|
|
1593
|
-
*/
|
|
1594
|
-
const MOBILE_PREFERENCES_METHOD = "mobile.preferences";
|
|
1595
|
-
const MOBILE_PENDING_METHOD = "mobile.pending";
|
|
1596
|
-
const MOBILE_RESPOND_METHOD = "mobile.respond";
|
|
1597
|
-
/** Execute a slash command through the host command registry (#1125). */
|
|
1598
|
-
const MOBILE_COMMAND_METHOD = "mobile.command";
|
|
1599
|
-
/** One session.list page (thin phones load incrementally). */
|
|
1600
|
-
const SESSION_PAGE_SIZE = 20;
|
|
1601
|
-
/** SSE keep-alive ping cadence for the live mux stream (single connection). */
|
|
1602
|
-
const DEFAULT_EVENTS_HEARTBEAT_MS = 15e3;
|
|
1603
|
-
/** Encode one list position as an opaque continuation cursor. */
|
|
1604
|
-
function sessionListCursor(updatedAt, sessionId) {
|
|
1605
|
-
return `${updatedAt}:${sessionId}`;
|
|
1606
|
-
}
|
|
1607
|
-
/** Parse a cursor; malformed cursors mean "start over" (safe failure mode). */
|
|
1608
|
-
function parseSessionListCursor(cursor) {
|
|
1609
|
-
const separator = cursor.indexOf(":");
|
|
1610
|
-
if (separator < 0) return void 0;
|
|
1611
|
-
const updatedAt = Number(cursor.slice(0, separator));
|
|
1612
|
-
if (!Number.isFinite(updatedAt)) return void 0;
|
|
1613
|
-
return {
|
|
1614
|
-
updatedAt,
|
|
1615
|
-
sessionId: cursor.slice(separator + 1)
|
|
1616
|
-
};
|
|
1617
|
-
}
|
|
1618
|
-
/** Whether a row comes strictly after the cursor position. */
|
|
1619
|
-
function afterCursor(row, position) {
|
|
1620
|
-
return row.updatedAt < position.updatedAt || row.updatedAt === position.updatedAt && row.sessionId > position.sessionId;
|
|
1621
|
-
}
|
|
1622
|
-
/** Mobile API route paths. */
|
|
1623
|
-
const MOBILE_API_PATHS = { events: "/m/api/events.mux" };
|
|
1624
|
-
/** The mobile-api prefix (every other path under it is a method name). */
|
|
1625
|
-
const MOBILE_API_PREFIX = "/m/api";
|
|
1626
|
-
/** Method extraction: the prefix plus one slash. */
|
|
1627
|
-
const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
|
|
1628
|
-
/**
|
|
1629
|
-
* Build the mobile data-channel routes.
|
|
1630
|
-
* @param deps - pairing service + apiProxy.
|
|
1631
|
-
* @returns the routes to register on webServer.
|
|
1632
|
-
*/
|
|
1633
|
-
function makeMobileApiRoutes(deps) {
|
|
1634
|
-
const { service, apiProxy, mobileEnterToSend } = deps;
|
|
1635
|
-
const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;
|
|
1636
|
-
/**
|
|
1637
|
-
* Refresh the paired device's presence and report whether it is live.
|
|
1638
|
-
* The mobile surface (unlike the desktop Web UI) has no `/api/pair/heartbeat`
|
|
1639
|
-
* sender, so any activity on the mobile channel — a gated RPC, or the live
|
|
1640
|
-
* SSE stream staying open — must count as presence. Without this, an
|
|
1641
|
-
* idle-but-connected phone ages past `offlineAfterMs` and the desktop panel
|
|
1642
|
-
* wrongly reports it as disconnected.
|
|
1643
|
-
*/
|
|
1644
|
-
const touchDeviceFor = (req) => {
|
|
1645
|
-
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1646
|
-
if (deviceId === void 0) return false;
|
|
1647
|
-
return service.touchDevice(deviceId);
|
|
1648
|
-
};
|
|
1649
|
-
/** The phone gate: a live paired-device cookie, or nothing else proceeds. */
|
|
1650
|
-
const gateOk = (req) => {
|
|
1651
|
-
return touchDeviceFor(req);
|
|
1652
|
-
};
|
|
1653
|
-
const handleMethod = async (req, res) => {
|
|
1654
|
-
if (req.method !== "POST") {
|
|
1655
|
-
res.writeHead(405);
|
|
1656
|
-
res.end();
|
|
1657
|
-
return;
|
|
1658
|
-
}
|
|
1659
|
-
if (!gateOk(req)) {
|
|
1660
|
-
writeJson(res, 403, {
|
|
1661
|
-
ok: false,
|
|
1662
|
-
error: {
|
|
1663
|
-
code: "unpaired",
|
|
1664
|
-
message: "mobile session is not paired"
|
|
1665
|
-
}
|
|
1666
|
-
});
|
|
1667
|
-
return;
|
|
1668
|
-
}
|
|
1669
|
-
const pathname = new URL(req.url ?? "/", "http://x").pathname;
|
|
1670
|
-
if (!pathname.startsWith(MOBILE_API_METHOD_PREFIX)) {
|
|
1671
|
-
writeJson(res, 404, {
|
|
1672
|
-
ok: false,
|
|
1673
|
-
error: {
|
|
1674
|
-
code: "not-found",
|
|
1675
|
-
message: "unknown mobile api path"
|
|
1676
|
-
}
|
|
1677
|
-
});
|
|
1678
|
-
return;
|
|
1679
|
-
}
|
|
1680
|
-
const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length);
|
|
1681
|
-
const local = method === MOBILE_PREFERENCES_METHOD || method === MOBILE_PENDING_METHOD || method === MOBILE_RESPOND_METHOD || method === MOBILE_COMMAND_METHOD;
|
|
1682
|
-
if (!MOBILE_ALLOWLIST.has(method) && !local) {
|
|
1683
|
-
writeJson(res, 403, {
|
|
1684
|
-
ok: false,
|
|
1685
|
-
error: {
|
|
1686
|
-
code: "forbidden",
|
|
1687
|
-
message: `method ${method} is not exposed to the mobile surface`
|
|
1688
|
-
}
|
|
1689
|
-
});
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
let envelope;
|
|
1693
|
-
try {
|
|
1694
|
-
envelope = await readBoundedJson(req, 64 * 1024);
|
|
1695
|
-
} catch {
|
|
1696
|
-
writeJson(res, 400, {
|
|
1697
|
-
ok: false,
|
|
1698
|
-
error: {
|
|
1699
|
-
code: "bad-request",
|
|
1700
|
-
message: "invalid json body"
|
|
1701
|
-
}
|
|
1702
|
-
});
|
|
1703
|
-
return;
|
|
1704
|
-
}
|
|
1705
|
-
const parsed = envelope;
|
|
1706
|
-
const rpcId = typeof parsed?.rpcId === "string" ? parsed.rpcId : "";
|
|
1707
|
-
if (rpcId === "") {
|
|
1708
|
-
writeJson(res, 400, {
|
|
1709
|
-
ok: false,
|
|
1710
|
-
error: {
|
|
1711
|
-
code: "bad-request",
|
|
1712
|
-
message: "missing rpcId"
|
|
1713
|
-
}
|
|
1714
|
-
});
|
|
1715
|
-
return;
|
|
1716
|
-
}
|
|
1717
|
-
if (local) {
|
|
1718
|
-
if (method === MOBILE_PREFERENCES_METHOD) writeJson(res, 200, {
|
|
1719
|
-
type: "server-response",
|
|
1720
|
-
rpcId,
|
|
1721
|
-
result: {
|
|
1722
|
-
ok: true,
|
|
1723
|
-
value: { mobileEnterToSend: mobileEnterToSend() }
|
|
1724
|
-
}
|
|
1725
|
-
});
|
|
1726
|
-
else if (method === MOBILE_PENDING_METHOD) {
|
|
1727
|
-
const payload = parsed.payload;
|
|
1728
|
-
writeJson(res, 200, {
|
|
1729
|
-
type: "server-response",
|
|
1730
|
-
rpcId,
|
|
1731
|
-
result: {
|
|
1732
|
-
ok: true,
|
|
1733
|
-
value: deps.pendingTracker.pending(payload?.sessionId)
|
|
1734
|
-
}
|
|
1735
|
-
});
|
|
1736
|
-
} else if (method === MOBILE_RESPOND_METHOD) {
|
|
1737
|
-
const payload = parsed.payload;
|
|
1738
|
-
try {
|
|
1739
|
-
writeJson(res, 200, {
|
|
1740
|
-
type: "server-response",
|
|
1741
|
-
rpcId,
|
|
1742
|
-
result: {
|
|
1743
|
-
ok: true,
|
|
1744
|
-
value: await apiProxy.respond({
|
|
1745
|
-
type: "client-response",
|
|
1746
|
-
rpcId: RpcId(payload.rpcId),
|
|
1747
|
-
result: {
|
|
1748
|
-
ok: true,
|
|
1749
|
-
value: payload.response
|
|
1750
|
-
}
|
|
1751
|
-
})
|
|
1752
|
-
}
|
|
1753
|
-
});
|
|
1754
|
-
} catch (error) {
|
|
1755
|
-
writeJson(res, 200, {
|
|
1756
|
-
type: "server-response",
|
|
1757
|
-
rpcId,
|
|
1758
|
-
result: {
|
|
1759
|
-
ok: false,
|
|
1760
|
-
error: {
|
|
1761
|
-
code: "internal",
|
|
1762
|
-
message: error instanceof Error ? error.message : String(error)
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1765
|
-
});
|
|
1766
|
-
}
|
|
1767
|
-
} else if (method === MOBILE_COMMAND_METHOD) {
|
|
1768
|
-
const payload = parsed.payload;
|
|
1769
|
-
const sessionId = payload?.sessionId;
|
|
1770
|
-
const line = payload?.line;
|
|
1771
|
-
if (typeof sessionId !== "string" || typeof line !== "string") writeJson(res, 200, {
|
|
1772
|
-
type: "server-response",
|
|
1773
|
-
rpcId,
|
|
1774
|
-
result: {
|
|
1775
|
-
ok: false,
|
|
1776
|
-
error: {
|
|
1777
|
-
code: "bad-request",
|
|
1778
|
-
message: "missing sessionId or line"
|
|
1779
|
-
}
|
|
1780
|
-
}
|
|
1781
|
-
});
|
|
1782
|
-
else if (deps.commandDispatcher === void 0) writeJson(res, 200, {
|
|
1783
|
-
type: "server-response",
|
|
1784
|
-
rpcId,
|
|
1785
|
-
result: {
|
|
1786
|
-
ok: false,
|
|
1787
|
-
error: {
|
|
1788
|
-
code: "unavailable",
|
|
1789
|
-
message: "command dispatcher is not available"
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
1792
|
-
});
|
|
1793
|
-
else try {
|
|
1794
|
-
const abort = new AbortController();
|
|
1795
|
-
res.on("close", () => {
|
|
1796
|
-
if (!res.writableEnded) abort.abort();
|
|
1797
|
-
});
|
|
1798
|
-
const outcome = await deps.commandDispatcher.execute(sessionId, line, abort.signal);
|
|
1799
|
-
if ("error" in outcome) writeJson(res, 200, {
|
|
1800
|
-
type: "server-response",
|
|
1801
|
-
rpcId,
|
|
1802
|
-
result: {
|
|
1803
|
-
ok: false,
|
|
1804
|
-
error: {
|
|
1805
|
-
code: outcome.error,
|
|
1806
|
-
message: outcome.error
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
});
|
|
1810
|
-
else writeJson(res, 200, {
|
|
1811
|
-
type: "server-response",
|
|
1812
|
-
rpcId,
|
|
1813
|
-
result: {
|
|
1814
|
-
ok: true,
|
|
1815
|
-
value: {
|
|
1816
|
-
matched: true,
|
|
1817
|
-
result: outcome.result
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
});
|
|
1821
|
-
} catch (error) {
|
|
1822
|
-
writeJson(res, 200, {
|
|
1823
|
-
type: "server-response",
|
|
1824
|
-
rpcId,
|
|
1825
|
-
result: {
|
|
1826
|
-
ok: false,
|
|
1827
|
-
error: {
|
|
1828
|
-
code: "internal",
|
|
1829
|
-
message: error instanceof Error ? error.message : String(error)
|
|
1830
|
-
}
|
|
1831
|
-
}
|
|
1832
|
-
});
|
|
1833
|
-
}
|
|
1834
|
-
}
|
|
1835
|
-
return;
|
|
1836
|
-
}
|
|
1837
|
-
try {
|
|
1838
|
-
const abort = new AbortController();
|
|
1839
|
-
res.on("close", () => {
|
|
1840
|
-
if (!res.writableEnded) abort.abort();
|
|
1841
|
-
});
|
|
1842
|
-
writeJson(res, 200, await dispatch(apiProxy, method, parsed?.payload, rpcId, abort.signal));
|
|
1843
|
-
} catch (error) {
|
|
1844
|
-
writeJson(res, 200, {
|
|
1845
|
-
type: "server-response",
|
|
1846
|
-
rpcId,
|
|
1847
|
-
result: {
|
|
1848
|
-
ok: false,
|
|
1849
|
-
error: {
|
|
1850
|
-
code: "internal",
|
|
1851
|
-
message: error instanceof Error ? error.message : String(error)
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
});
|
|
1855
|
-
}
|
|
1856
|
-
};
|
|
1857
|
-
/** Bridge the host mux stream over SSE: one `data:` frame per mux frame. */
|
|
1858
|
-
const handleEvents = async (req, res) => {
|
|
1859
|
-
if (req.method !== "GET") {
|
|
1860
|
-
res.writeHead(405);
|
|
1861
|
-
res.end();
|
|
1862
|
-
return;
|
|
1863
|
-
}
|
|
1864
|
-
if (!gateOk(req)) {
|
|
1211
|
+
/** LAN-bind facts for the settings card; loopback-only, read per request. */
|
|
1212
|
+
const handleLanBind = (req, res) => {
|
|
1213
|
+
if (!requireMethod(req, res, "GET")) return;
|
|
1214
|
+
if (!loopbackFence(req)) {
|
|
1865
1215
|
writeJson(res, 403, {
|
|
1866
1216
|
ok: false,
|
|
1867
|
-
|
|
1868
|
-
code: "unpaired",
|
|
1869
|
-
message: "mobile session is not paired"
|
|
1870
|
-
}
|
|
1217
|
+
code: "forbidden"
|
|
1871
1218
|
});
|
|
1872
1219
|
return;
|
|
1873
1220
|
}
|
|
1874
|
-
res
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
connection: "keep-alive"
|
|
1878
|
-
});
|
|
1879
|
-
const controller = new AbortController();
|
|
1880
|
-
let closed = false;
|
|
1881
|
-
const heartbeat = setInterval(() => {
|
|
1882
|
-
if (closed) return;
|
|
1883
|
-
touchDeviceFor(req);
|
|
1884
|
-
try {
|
|
1885
|
-
res.write(": ping\n\n");
|
|
1886
|
-
} catch {}
|
|
1887
|
-
}, eventsHeartbeatMs);
|
|
1888
|
-
const onClose = () => {
|
|
1889
|
-
if (closed) return;
|
|
1890
|
-
closed = true;
|
|
1891
|
-
controller.abort();
|
|
1892
|
-
clearInterval(heartbeat);
|
|
1893
|
-
};
|
|
1894
|
-
res.on("close", onClose);
|
|
1895
|
-
req.on("close", onClose);
|
|
1896
|
-
try {
|
|
1897
|
-
const frames = apiProxy.events.mux({
|
|
1898
|
-
rpcId: RpcId(`mobile-mux-${Date.now().toString(36)}`),
|
|
1899
|
-
payload: {}
|
|
1900
|
-
}, controller.signal);
|
|
1901
|
-
for await (const frame of frames) {
|
|
1902
|
-
if (closed) break;
|
|
1903
|
-
deps.pendingTracker.onFrame(frame);
|
|
1904
|
-
res.write(`data: ${JSON.stringify(frame)}\n\n`);
|
|
1905
|
-
}
|
|
1906
|
-
} catch {} finally {
|
|
1907
|
-
controller.abort();
|
|
1908
|
-
clearInterval(heartbeat);
|
|
1909
|
-
}
|
|
1910
|
-
if (!closed) res.end();
|
|
1911
|
-
};
|
|
1912
|
-
return [{
|
|
1913
|
-
kind: "prefix",
|
|
1914
|
-
path: MOBILE_API_PREFIX,
|
|
1915
|
-
handler: handleMethod
|
|
1916
|
-
}, {
|
|
1917
|
-
kind: "exact",
|
|
1918
|
-
path: MOBILE_API_PATHS.events,
|
|
1919
|
-
handler: handleEvents
|
|
1920
|
-
}];
|
|
1921
|
-
}
|
|
1922
|
-
/** Dispatch one allowlisted method through the host ApiProxy. */
|
|
1923
|
-
async function dispatch(apiProxy, method, payload, rpcId, signal) {
|
|
1924
|
-
const request = {
|
|
1925
|
-
rpcId: RpcId(rpcId),
|
|
1926
|
-
payload
|
|
1927
|
-
};
|
|
1928
|
-
if (method === "session.list") {
|
|
1929
|
-
const full = await apiProxy.sessions.list(request);
|
|
1930
|
-
if (!full.result.ok) return {
|
|
1931
|
-
type: "server-response",
|
|
1932
|
-
rpcId,
|
|
1933
|
-
result: full.result
|
|
1934
|
-
};
|
|
1935
|
-
const items = full.result.value.items;
|
|
1936
|
-
const cursor = payload?.cursor;
|
|
1937
|
-
items.sort((a, b) => b.updatedAt - a.updatedAt || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
|
|
1938
|
-
const position = cursor === void 0 ? void 0 : parseSessionListCursor(cursor);
|
|
1939
|
-
const from = position === void 0 ? 0 : items.findIndex((row) => afterCursor(row, position));
|
|
1940
|
-
const start = from < 0 ? items.length : from;
|
|
1941
|
-
const page = items.slice(start, start + SESSION_PAGE_SIZE);
|
|
1942
|
-
const last = page[page.length - 1];
|
|
1943
|
-
const nextCursor = last !== void 0 && start + page.length < items.length ? sessionListCursor(last.updatedAt, last.sessionId) : void 0;
|
|
1944
|
-
return {
|
|
1945
|
-
type: "server-response",
|
|
1946
|
-
rpcId,
|
|
1947
|
-
result: {
|
|
1948
|
-
ok: true,
|
|
1949
|
-
value: {
|
|
1950
|
-
items: page,
|
|
1951
|
-
hasMore: nextCursor !== void 0,
|
|
1952
|
-
...nextCursor !== void 0 ? { nextCursor } : {}
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
};
|
|
1956
|
-
}
|
|
1957
|
-
const wrap = (response) => ({
|
|
1958
|
-
type: "server-response",
|
|
1959
|
-
rpcId,
|
|
1960
|
-
result: response.result
|
|
1961
|
-
});
|
|
1962
|
-
if (method === "workspace.list") return wrap(await apiProxy.workspace.list(request));
|
|
1963
|
-
if (method === "workspace.create") return wrap(await apiProxy.workspace.create(request));
|
|
1964
|
-
if (method === "host.listDirectory") return wrap(await apiProxy.host.listDirectory(request, signal ?? new AbortController().signal));
|
|
1965
|
-
if (method === "agentPreset.list") return wrap(await apiProxy.agentPresets.list(request));
|
|
1966
|
-
if (method === "session.create") return wrap(await apiProxy.sessions.create(request));
|
|
1967
|
-
if (method === "session.history") return wrap(await apiProxy.sessions.history(request));
|
|
1968
|
-
if (method === "session.search") return wrap(await apiProxy.sessions.search(request, signal ?? new AbortController().signal));
|
|
1969
|
-
if (method === "session.prompt") return wrap(await apiProxy.sessions.prompt(request));
|
|
1970
|
-
if (method === "session.models") return wrap(await apiProxy.sessions.models(request));
|
|
1971
|
-
if (method === "session.selectModel") return wrap(await apiProxy.sessions.selectModel(request));
|
|
1972
|
-
if (method === "session.rename") return wrap(await apiProxy.sessions.rename(request));
|
|
1973
|
-
if (method === "session.cancel") return wrap(await apiProxy.sessions.cancel(request));
|
|
1974
|
-
throw new Error(`unhandled allowlisted method ${method}`);
|
|
1975
|
-
}
|
|
1976
|
-
//#endregion
|
|
1977
|
-
//#region src/mobile-pending.ts
|
|
1978
|
-
var PendingTracker = class {
|
|
1979
|
-
sessions = /* @__PURE__ */ new Map();
|
|
1980
|
-
/** Process one mux frame and update the pending state. */
|
|
1981
|
-
onFrame(frame) {
|
|
1982
|
-
const payload = frame.payload;
|
|
1983
|
-
if (payload.type === "approval/requested") this._getOrInit(payload.sessionId).approvals.set(payload.approvalId, {
|
|
1984
|
-
rpcId: frame.rpcId,
|
|
1985
|
-
approvalId: payload.approvalId,
|
|
1986
|
-
toolName: payload.toolName,
|
|
1987
|
-
callId: payload.callId,
|
|
1988
|
-
reason: payload.reason
|
|
1989
|
-
});
|
|
1990
|
-
else if (payload.type === "approval/resolved") {
|
|
1991
|
-
const state = this.sessions.get(payload.sessionId);
|
|
1992
|
-
if (state) state.approvals.delete(payload.approvalId);
|
|
1993
|
-
} else if (payload.type === "question/requested") this._getOrInit(payload.sessionId).questions.push({
|
|
1994
|
-
rpcId: frame.rpcId,
|
|
1995
|
-
questions: payload.questions
|
|
1996
|
-
});
|
|
1997
|
-
else if (payload.type === "question/resolved") {
|
|
1998
|
-
const state = this.sessions.get(payload.sessionId);
|
|
1999
|
-
if (state) state.questions = state.questions.filter((q) => q.rpcId !== payload.questionRpcId);
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2002
|
-
_getOrInit(sessionId) {
|
|
2003
|
-
let state = this.sessions.get(sessionId);
|
|
2004
|
-
if (!state) {
|
|
2005
|
-
state = {
|
|
2006
|
-
approvals: /* @__PURE__ */ new Map(),
|
|
2007
|
-
questions: []
|
|
2008
|
-
};
|
|
2009
|
-
this.sessions.set(sessionId, state);
|
|
2010
|
-
}
|
|
2011
|
-
return state;
|
|
2012
|
-
}
|
|
2013
|
-
/** Query pending items for a session. */
|
|
2014
|
-
pending(sessionId) {
|
|
2015
|
-
const state = this.sessions.get(sessionId);
|
|
2016
|
-
if (!state) return {
|
|
2017
|
-
approvals: [],
|
|
2018
|
-
questions: []
|
|
2019
|
-
};
|
|
2020
|
-
return {
|
|
2021
|
-
approvals: Array.from(state.approvals.values()),
|
|
2022
|
-
questions: state.questions
|
|
2023
|
-
};
|
|
2024
|
-
}
|
|
2025
|
-
/** Clear all pending state for a session. */
|
|
2026
|
-
clear(sessionId) {
|
|
2027
|
-
this.sessions.delete(sessionId);
|
|
2028
|
-
}
|
|
2029
|
-
};
|
|
2030
|
-
//#endregion
|
|
2031
|
-
//#region src/paired-model-catalog.ts
|
|
2032
|
-
const MAX_BODY_BYTES = 16 * 1024;
|
|
2033
|
-
const MAX_IDENTIFIER_LENGTH = 160;
|
|
2034
|
-
const MAX_DISPLAY_NAME_LENGTH = 240;
|
|
2035
|
-
const MAX_TOKEN_LIMIT = 1e7;
|
|
2036
|
-
const REASONING_EFFORTS = [
|
|
2037
|
-
"off",
|
|
2038
|
-
"minimal",
|
|
2039
|
-
"low",
|
|
2040
|
-
"medium",
|
|
2041
|
-
"high",
|
|
2042
|
-
"xhigh",
|
|
2043
|
-
"max"
|
|
2044
|
-
];
|
|
2045
|
-
const stringField = (max) => z$1.string().trim().min(1).max(max).refine((value) => !/[\r\n\0]/.test(value), "must not contain a newline or NUL");
|
|
2046
|
-
const providerSchema = z$1.object({ provider: stringField(MAX_IDENTIFIER_LENGTH) }).strict();
|
|
2047
|
-
const modelSchema = z$1.object({
|
|
2048
|
-
id: stringField(MAX_IDENTIFIER_LENGTH),
|
|
2049
|
-
name: stringField(MAX_DISPLAY_NAME_LENGTH).optional(),
|
|
2050
|
-
contextWindow: z$1.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
|
|
2051
|
-
maxTokens: z$1.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
|
|
2052
|
-
reasoningEfforts: z$1.array(z$1.enum(REASONING_EFFORTS)).min(1).refine((efforts) => efforts.every((effort, index) => index === 0 || REASONING_EFFORTS.indexOf(efforts[index - 1]) < REASONING_EFFORTS.indexOf(effort)), "reasoning efforts must be unique and in canonical order").optional()
|
|
2053
|
-
}).strict();
|
|
2054
|
-
const upsertSchema = z$1.object({
|
|
2055
|
-
provider: stringField(MAX_IDENTIFIER_LENGTH),
|
|
2056
|
-
model: modelSchema
|
|
2057
|
-
}).strict();
|
|
2058
|
-
const PAIRED_MODEL_CATALOG_PATHS = {
|
|
2059
|
-
catalog: "/api/pair/model-catalog",
|
|
2060
|
-
discover: "/api/pair/model-catalog/discover",
|
|
2061
|
-
upsert: "/api/pair/model-catalog/upsert"
|
|
2062
|
-
};
|
|
2063
|
-
let rpcSequence = 0;
|
|
2064
|
-
function request$1(payload) {
|
|
2065
|
-
rpcSequence += 1;
|
|
2066
|
-
return {
|
|
2067
|
-
rpcId: `paired-model-catalog-${Date.now().toString(36)}-${rpcSequence.toString(36)}`,
|
|
2068
|
-
payload
|
|
2069
|
-
};
|
|
2070
|
-
}
|
|
2071
|
-
function isRecord(value) {
|
|
2072
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2073
|
-
}
|
|
2074
|
-
function valueOf(response) {
|
|
2075
|
-
return response.result.ok ? response.result.value : void 0;
|
|
2076
|
-
}
|
|
2077
|
-
function errorCode(response) {
|
|
2078
|
-
return response.result.ok ? void 0 : response.result.error.code;
|
|
2079
|
-
}
|
|
2080
|
-
function reject(res, status, error) {
|
|
2081
|
-
writeJson(res, status, { error });
|
|
2082
|
-
}
|
|
2083
|
-
function publicProviderId(value) {
|
|
2084
|
-
const normalized = value.replace(/[\r\n\0]/g, " ").trim().slice(0, MAX_IDENTIFIER_LENGTH);
|
|
2085
|
-
return normalized === "" ? "unknown" : normalized;
|
|
2086
|
-
}
|
|
2087
|
-
function publicCatalog(value) {
|
|
2088
|
-
return {
|
|
2089
|
-
groups: value.groups.map((group) => ({
|
|
2090
|
-
id: group.id,
|
|
2091
|
-
name: group.name,
|
|
2092
|
-
models: group.models.map((model) => ({
|
|
2093
|
-
id: model.id,
|
|
2094
|
-
name: model.name,
|
|
2095
|
-
...model.description === void 0 ? {} : { description: model.description },
|
|
2096
|
-
...model.reasoning === void 0 ? {} : { reasoning: model.reasoning }
|
|
2097
|
-
}))
|
|
2098
|
-
})),
|
|
2099
|
-
failures: value.failures.map((failure) => ({
|
|
2100
|
-
id: failure.id,
|
|
2101
|
-
name: failure.name,
|
|
2102
|
-
message: `model catalog unavailable for provider ${publicProviderId(failure.id)}`
|
|
2103
|
-
}))
|
|
2104
|
-
};
|
|
2105
|
-
}
|
|
2106
|
-
function publicCandidates(models) {
|
|
2107
|
-
return models.map((model) => ({
|
|
2108
|
-
id: model.id,
|
|
2109
|
-
...model.name === void 0 ? {} : { name: model.name },
|
|
2110
|
-
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
|
|
2111
|
-
...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }
|
|
2112
|
-
}));
|
|
2113
|
-
}
|
|
2114
|
-
function fieldsOf(model, includeId) {
|
|
2115
|
-
const fields = {
|
|
2116
|
-
...includeId ? { id: model.id } : {},
|
|
2117
|
-
...model.name === void 0 ? {} : { name: model.name },
|
|
2118
|
-
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
|
|
2119
|
-
...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }
|
|
2120
|
-
};
|
|
2121
|
-
if (model.reasoningEfforts !== void 0) fields.reasoningEfforts = Object.fromEntries(model.reasoningEfforts.map((effort) => [effort, effort === "off" ? null : effort]));
|
|
2122
|
-
return fields;
|
|
2123
|
-
}
|
|
2124
|
-
function groupFor(catalog, provider) {
|
|
2125
|
-
if (catalog.failures.some((failure) => failure.id === provider)) return void 0;
|
|
2126
|
-
return catalog.groups.find((group) => group.id === provider);
|
|
2127
|
-
}
|
|
2128
|
-
async function readCatalog(apiProxy) {
|
|
2129
|
-
try {
|
|
2130
|
-
return valueOf(await apiProxy.llm.models(request$1({})));
|
|
2131
|
-
} catch {
|
|
2132
|
-
return;
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
/** Resolve one exact active llm-pi-ai provider address, never an input-supplied path. */
|
|
2136
|
-
async function eligibleProvider(apiProxy, requested) {
|
|
2137
|
-
let providersResponse;
|
|
2138
|
-
let settingsResponse;
|
|
2139
|
-
try {
|
|
2140
|
-
providersResponse = await apiProxy.llm.providers(request$1({}));
|
|
2141
|
-
settingsResponse = await apiProxy.settings.describe(request$1({}));
|
|
2142
|
-
} catch {
|
|
2143
|
-
return {
|
|
2144
|
-
status: 502,
|
|
2145
|
-
error: "model catalog capability is unavailable"
|
|
2146
|
-
};
|
|
2147
|
-
}
|
|
2148
|
-
const providers = valueOf(providersResponse);
|
|
2149
|
-
const settings = valueOf(settingsResponse);
|
|
2150
|
-
if (providers === void 0 || settings === void 0) return {
|
|
2151
|
-
status: 502,
|
|
2152
|
-
error: "model catalog capability is unavailable"
|
|
2153
|
-
};
|
|
2154
|
-
const route = providers.providers.find((candidate) => candidate.provider === requested);
|
|
2155
|
-
if (route === void 0) return {
|
|
2156
|
-
status: 404,
|
|
2157
|
-
error: `unknown provider ${requested}`
|
|
2158
|
-
};
|
|
2159
|
-
if (!settings.writable || !route.active || route.settingsNs !== "llm-pi-ai" || route.settingsPath.length !== 2 || route.settingsPath[0] !== "providers" || route.settingsPath[1] !== requested) return {
|
|
2160
|
-
status: 403,
|
|
2161
|
-
error: `provider ${requested} is not eligible for the paired model catalog`
|
|
2162
|
-
};
|
|
2163
|
-
const namespace = settings.namespaces.find((candidate) => candidate.ns === "llm-pi-ai");
|
|
2164
|
-
const root = namespace !== void 0 && isRecord(namespace.value) ? namespace.value : void 0;
|
|
2165
|
-
const providersValue = root !== void 0 && isRecord(root.providers) ? root.providers : void 0;
|
|
2166
|
-
const profile = providersValue !== void 0 && isRecord(providersValue[requested]) ? providersValue[requested] : void 0;
|
|
2167
|
-
if (namespace === void 0 || profile === void 0) return {
|
|
2168
|
-
status: 403,
|
|
2169
|
-
error: `provider ${requested} is not eligible for the paired model catalog`
|
|
2170
|
-
};
|
|
2171
|
-
return { provider: {
|
|
2172
|
-
provider: requested,
|
|
2173
|
-
displayName: route.displayName,
|
|
2174
|
-
namespace,
|
|
2175
|
-
profile
|
|
2176
|
-
} };
|
|
2177
|
-
}
|
|
2178
|
-
function validIdentifier(value) {
|
|
2179
|
-
const parsed = stringField(MAX_IDENTIFIER_LENGTH).safeParse(value);
|
|
2180
|
-
return parsed.success && parsed.data === value;
|
|
2181
|
-
}
|
|
2182
|
-
function configuredModels(profile) {
|
|
2183
|
-
if (!Object.hasOwn(profile, "models")) return { inherited: true };
|
|
2184
|
-
if (!Array.isArray(profile.models)) return void 0;
|
|
2185
|
-
if (profile.models.length === 0) return { inherited: true };
|
|
2186
|
-
const values = [];
|
|
2187
|
-
for (const entry of profile.models) {
|
|
2188
|
-
if (!isRecord(entry) || !validIdentifier(entry.id)) return void 0;
|
|
2189
|
-
values.push({ ...entry });
|
|
2190
|
-
}
|
|
2191
|
-
return {
|
|
2192
|
-
inherited: false,
|
|
2193
|
-
values
|
|
2194
|
-
};
|
|
2195
|
-
}
|
|
2196
|
-
function configuredOverrides(profile) {
|
|
2197
|
-
if (!Object.hasOwn(profile, "modelOverrides")) return {
|
|
2198
|
-
present: false,
|
|
2199
|
-
values: /* @__PURE__ */ new Map()
|
|
2200
|
-
};
|
|
2201
|
-
if (!isRecord(profile.modelOverrides)) return void 0;
|
|
2202
|
-
const values = /* @__PURE__ */ new Map();
|
|
2203
|
-
for (const [id, value] of Object.entries(profile.modelOverrides)) {
|
|
2204
|
-
if (!validIdentifier(id) || !isRecord(value)) return void 0;
|
|
2205
|
-
values.set(id, { ...value });
|
|
2206
|
-
}
|
|
2207
|
-
return {
|
|
2208
|
-
present: true,
|
|
2209
|
-
values
|
|
2210
|
-
};
|
|
2211
|
-
}
|
|
2212
|
-
function updatedModels(existing, model) {
|
|
2213
|
-
const next = existing.map((entry) => ({ ...entry }));
|
|
2214
|
-
const index = next.findIndex((entry) => entry.id === model.id);
|
|
2215
|
-
const patch = fieldsOf(model, true);
|
|
2216
|
-
if (index >= 0) next[index] = {
|
|
2217
|
-
...next[index],
|
|
2218
|
-
...patch
|
|
2219
|
-
};
|
|
2220
|
-
else next.push(patch);
|
|
2221
|
-
return next;
|
|
2222
|
-
}
|
|
2223
|
-
function mutationPlan(profile, provider, model, catalog) {
|
|
2224
|
-
const configured = configuredModels(profile);
|
|
2225
|
-
const overrides = configuredOverrides(profile);
|
|
2226
|
-
if (configured === void 0 || overrides === void 0) return void 0;
|
|
2227
|
-
const modelsPath = [
|
|
2228
|
-
"providers",
|
|
2229
|
-
provider,
|
|
2230
|
-
"models"
|
|
2231
|
-
];
|
|
2232
|
-
const overridesPath = [
|
|
2233
|
-
"providers",
|
|
2234
|
-
provider,
|
|
2235
|
-
"modelOverrides"
|
|
2236
|
-
];
|
|
2237
|
-
if (!configured.inherited) {
|
|
2238
|
-
if (overrides.values.size > 0) return void 0;
|
|
2239
|
-
const ops = [{
|
|
2240
|
-
op: "set",
|
|
2241
|
-
path: modelsPath,
|
|
2242
|
-
value: updatedModels(configured.values, model)
|
|
2243
|
-
}];
|
|
2244
|
-
if (overrides.present) ops.push({
|
|
2245
|
-
op: "unset",
|
|
2246
|
-
path: overridesPath
|
|
2247
|
-
});
|
|
2248
|
-
return { ops };
|
|
2249
|
-
}
|
|
2250
|
-
const group = groupFor(catalog, provider);
|
|
2251
|
-
if (group?.models.some((entry) => entry.id === model.id) === true) {
|
|
2252
|
-
const existing = overrides.values.get(model.id) ?? {};
|
|
2253
|
-
return { ops: [{
|
|
2254
|
-
op: "set",
|
|
2255
|
-
path: [...overridesPath, model.id],
|
|
2256
|
-
value: {
|
|
2257
|
-
...existing,
|
|
2258
|
-
...fieldsOf(model, false)
|
|
2259
|
-
}
|
|
2260
|
-
}] };
|
|
2261
|
-
}
|
|
2262
|
-
if (group === void 0) return void 0;
|
|
2263
|
-
const materialized = [];
|
|
2264
|
-
const materializedIds = /* @__PURE__ */ new Set();
|
|
2265
|
-
for (const installed of group.models) {
|
|
2266
|
-
materialized.push({
|
|
2267
|
-
...overrides.values.get(installed.id),
|
|
2268
|
-
id: installed.id
|
|
1221
|
+
writeJson(res, 200, {
|
|
1222
|
+
ok: true,
|
|
1223
|
+
...deps.lanBindStatus?.() ?? {}
|
|
2269
1224
|
});
|
|
2270
|
-
materializedIds.add(installed.id);
|
|
2271
|
-
}
|
|
2272
|
-
for (const [id, override] of overrides.values) if (!materializedIds.has(id)) materialized.push({
|
|
2273
|
-
...override,
|
|
2274
|
-
id
|
|
2275
|
-
});
|
|
2276
|
-
const ops = [{
|
|
2277
|
-
op: "set",
|
|
2278
|
-
path: modelsPath,
|
|
2279
|
-
value: updatedModels(materialized, model)
|
|
2280
|
-
}];
|
|
2281
|
-
if (overrides.present) ops.push({
|
|
2282
|
-
op: "unset",
|
|
2283
|
-
path: overridesPath
|
|
2284
|
-
});
|
|
2285
|
-
return { ops };
|
|
2286
|
-
}
|
|
2287
|
-
function paired(req, service) {
|
|
2288
|
-
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
2289
|
-
return deviceId !== void 0 && service.touchDevice(deviceId);
|
|
2290
|
-
}
|
|
2291
|
-
async function bodyOf(req, schema) {
|
|
2292
|
-
try {
|
|
2293
|
-
return schema.parse(await readBoundedJson(req, MAX_BODY_BYTES));
|
|
2294
|
-
} catch {
|
|
2295
|
-
return;
|
|
2296
|
-
}
|
|
2297
|
-
}
|
|
2298
|
-
/** Build the narrow paired-device model catalog routes. */
|
|
2299
|
-
function makePairedModelCatalogRoutes(deps) {
|
|
2300
|
-
const { service, apiProxy, lanAddresses } = deps;
|
|
2301
|
-
/** Same fence as the /api/pair family: loopback, the advertised LAN literals, or the configured public host. */
|
|
2302
|
-
const fence = (req) => {
|
|
2303
|
-
const publicHost = publicHostOf(service.publicBaseUrl);
|
|
2304
|
-
return isTrustedApiRequest(req, publicHost === void 0 ? lanAddresses : [...lanAddresses, publicHost]);
|
|
2305
1225
|
};
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
1226
|
+
/**
|
|
1227
|
+
* The QR entry: navigate here with ?pair=<token>. Sets the device cookie,
|
|
1228
|
+
* then redirects to the authenticated home (the connection service's
|
|
1229
|
+
* launch-token URL), so a LAN device that has never seen this authority
|
|
1230
|
+
* clears the browser-auth gate and boots the paired official UI in one
|
|
1231
|
+
* chain: /pair-accept → /?token=<launch> → /. A device that is already
|
|
1232
|
+
* authenticated (or loopback) skips straight through the same way.
|
|
1233
|
+
*/
|
|
1234
|
+
const handleAcceptPage = async (req, res) => {
|
|
1235
|
+
if (!requireMethod(req, res, "GET")) return;
|
|
1236
|
+
if (!lanFence(req)) {
|
|
1237
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
1238
|
+
res.end("forbidden");
|
|
1239
|
+
return;
|
|
2311
1240
|
}
|
|
2312
|
-
if (
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
return
|
|
1241
|
+
if (rateLimitAccept(req, "page")) {
|
|
1242
|
+
res.writeHead(429, { "content-type": "text/plain; charset=utf-8" });
|
|
1243
|
+
res.end("rate limited");
|
|
1244
|
+
return;
|
|
2316
1245
|
}
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
1246
|
+
const token = new URL(req.url ?? "/", "http://pair.invalid").searchParams.get("pair") ?? "";
|
|
1247
|
+
const ua = req.headers["user-agent"];
|
|
1248
|
+
const result = token === "" ? {
|
|
1249
|
+
ok: false,
|
|
1250
|
+
code: "invalid"
|
|
1251
|
+
} : service.accept(token, typeof ua === "string" ? ua : void 0);
|
|
1252
|
+
if (!result.ok) {
|
|
1253
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1254
|
+
if (deviceId !== void 0 && service.hasDevice(deviceId)) {
|
|
1255
|
+
res.writeHead(303, {
|
|
1256
|
+
location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(deviceId)}`,
|
|
1257
|
+
"cache-control": "no-store",
|
|
1258
|
+
"referrer-policy": "no-referrer"
|
|
1259
|
+
});
|
|
1260
|
+
res.end();
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
res.writeHead(200, {
|
|
1264
|
+
"content-type": "text/html; charset=utf-8",
|
|
1265
|
+
"cache-control": "no-store",
|
|
1266
|
+
"referrer-policy": "no-referrer"
|
|
1267
|
+
});
|
|
1268
|
+
res.end(pairingFailurePage());
|
|
1269
|
+
return;
|
|
2329
1270
|
}
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
writeJson(res, 200, {
|
|
2336
|
-
capability: "paired-model-catalog",
|
|
2337
|
-
providers: settings.writable && providerMap !== void 0 ? providers.providers.filter((candidate) => candidate.active && candidate.settingsNs === "llm-pi-ai" && candidate.settingsPath.length === 2 && candidate.settingsPath[0] === "providers" && candidate.settingsPath[1] === candidate.provider && isRecord(providerMap[candidate.provider])).map((candidate) => ({
|
|
2338
|
-
provider: candidate.provider,
|
|
2339
|
-
displayName: candidate.displayName
|
|
2340
|
-
})) : []
|
|
1271
|
+
res.writeHead(303, {
|
|
1272
|
+
location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(result.deviceId)}`,
|
|
1273
|
+
"cache-control": "no-store",
|
|
1274
|
+
"referrer-policy": "no-referrer",
|
|
1275
|
+
"set-cookie": [`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`]
|
|
2341
1276
|
});
|
|
1277
|
+
res.end();
|
|
2342
1278
|
};
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
1279
|
+
/**
|
|
1280
|
+
* The cookieless app landing. A paired device (device query or live
|
|
1281
|
+
* pairing cookie) receives the official index shell patched with the
|
|
1282
|
+
* device-capture script; the shell itself is the official document and
|
|
1283
|
+
* carries no data, so serving it needs only the device credential.
|
|
1284
|
+
*/
|
|
1285
|
+
const handleAppPage = async (req, res) => {
|
|
1286
|
+
if (!requireMethod(req, res, "GET")) return;
|
|
1287
|
+
if (!lanFence(req)) {
|
|
1288
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
1289
|
+
res.end("forbidden");
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
if (rateLimitAccept(req, "page")) {
|
|
1293
|
+
res.writeHead(429, { "content-type": "text/plain; charset=utf-8" });
|
|
1294
|
+
res.end("rate limited");
|
|
1295
|
+
return;
|
|
2359
1296
|
}
|
|
1297
|
+
const device = new URL(req.url ?? "/", "http://pair.invalid").searchParams.get("device") ?? "";
|
|
1298
|
+
const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1299
|
+
const id = device !== "" && service.touchDevice(device) ? device : cookieDevice !== void 0 && service.touchDevice(cookieDevice) ? cookieDevice : void 0;
|
|
1300
|
+
if (id === void 0) {
|
|
1301
|
+
res.writeHead(200, {
|
|
1302
|
+
"content-type": "text/html; charset=utf-8",
|
|
1303
|
+
"cache-control": "no-store",
|
|
1304
|
+
"referrer-policy": "no-referrer"
|
|
1305
|
+
});
|
|
1306
|
+
res.end(pairingFailurePage());
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
const html = await deps.indexDocument?.(id).catch(() => void 0);
|
|
1310
|
+
if (html === void 0) {
|
|
1311
|
+
res.writeHead(502, {
|
|
1312
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1313
|
+
"cache-control": "no-store"
|
|
1314
|
+
});
|
|
1315
|
+
res.end("remote device app unavailable");
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
res.writeHead(200, {
|
|
1319
|
+
"content-type": "text/html; charset=utf-8",
|
|
1320
|
+
"cache-control": "no-store",
|
|
1321
|
+
"referrer-policy": "no-referrer",
|
|
1322
|
+
"set-cookie": [`${service.config.cookieName}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`]
|
|
1323
|
+
});
|
|
1324
|
+
res.end(patchAppShell(html, id));
|
|
2360
1325
|
};
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
if (
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
mutation = await apiProxy.settings.mutate(request$1({
|
|
2375
|
-
ns: "llm-pi-ai",
|
|
2376
|
-
expectedRevision: eligible.provider.namespace.revision,
|
|
2377
|
-
ops: plan.ops
|
|
2378
|
-
}));
|
|
2379
|
-
} catch {
|
|
2380
|
-
return reject(res, 502, `model update failed for provider ${body.provider}`);
|
|
1326
|
+
/**
|
|
1327
|
+
* The reopen service worker script. Same phone-facing fence as the app
|
|
1328
|
+
* page but no rate limit and no device check: the script is inert logic
|
|
1329
|
+
* (no secrets, no data), and browsers re-fetch it on navigations for
|
|
1330
|
+
* update checks, which must not trip a brute-force budget. no-store keeps
|
|
1331
|
+
* the update check meaningful across deploys.
|
|
1332
|
+
*/
|
|
1333
|
+
const handleAppServiceWorker = (req, res) => {
|
|
1334
|
+
if (!requireMethod(req, res, "GET")) return;
|
|
1335
|
+
if (!lanFence(req)) {
|
|
1336
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
1337
|
+
res.end("forbidden");
|
|
1338
|
+
return;
|
|
2381
1339
|
}
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
writeJson(res, 200, publicCatalog(after));
|
|
1340
|
+
res.writeHead(200, {
|
|
1341
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
1342
|
+
"cache-control": "no-store",
|
|
1343
|
+
"service-worker-allowed": "/"
|
|
1344
|
+
});
|
|
1345
|
+
res.end(appServiceWorkerScript());
|
|
2389
1346
|
};
|
|
2390
|
-
|
|
1347
|
+
const routes = [
|
|
1348
|
+
{
|
|
1349
|
+
kind: "exact",
|
|
1350
|
+
path: PAIR_PATHS.issue,
|
|
1351
|
+
handler: handleIssue
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
kind: "exact",
|
|
1355
|
+
path: PAIR_PATHS.accept,
|
|
1356
|
+
handler: handleAccept
|
|
1357
|
+
},
|
|
1358
|
+
{
|
|
1359
|
+
kind: "exact",
|
|
1360
|
+
path: PAIR_PATHS.stop,
|
|
1361
|
+
handler: handleStop
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
kind: "exact",
|
|
1365
|
+
path: PAIR_PATHS.revoke,
|
|
1366
|
+
handler: handleRevoke
|
|
1367
|
+
},
|
|
2391
1368
|
{
|
|
2392
1369
|
kind: "exact",
|
|
2393
|
-
path:
|
|
2394
|
-
handler:
|
|
1370
|
+
path: PAIR_PATHS.heartbeat,
|
|
1371
|
+
handler: handleHeartbeat
|
|
2395
1372
|
},
|
|
2396
1373
|
{
|
|
2397
1374
|
kind: "exact",
|
|
2398
|
-
path:
|
|
2399
|
-
handler:
|
|
1375
|
+
path: PAIR_PATHS.status,
|
|
1376
|
+
handler: handleStatus
|
|
2400
1377
|
},
|
|
2401
1378
|
{
|
|
2402
1379
|
kind: "exact",
|
|
2403
|
-
path:
|
|
2404
|
-
handler:
|
|
1380
|
+
path: PAIR_PATHS.events,
|
|
1381
|
+
handler: handleEvents
|
|
2405
1382
|
}
|
|
2406
1383
|
];
|
|
1384
|
+
if (deps.lanBindStatus !== void 0) routes.push({
|
|
1385
|
+
kind: "exact",
|
|
1386
|
+
path: PAIR_PATHS.lanBind,
|
|
1387
|
+
handler: handleLanBind
|
|
1388
|
+
});
|
|
1389
|
+
routes.push({
|
|
1390
|
+
kind: "exact",
|
|
1391
|
+
path: PAIR_PATHS.acceptPage,
|
|
1392
|
+
handler: handleAcceptPage
|
|
1393
|
+
});
|
|
1394
|
+
if (deps.indexDocument !== void 0) {
|
|
1395
|
+
routes.push({
|
|
1396
|
+
kind: "exact",
|
|
1397
|
+
path: PAIR_PATHS.appPage,
|
|
1398
|
+
handler: handleAppPage
|
|
1399
|
+
});
|
|
1400
|
+
routes.push({
|
|
1401
|
+
kind: "exact",
|
|
1402
|
+
path: PAIR_PATHS.appServiceWorker,
|
|
1403
|
+
handler: handleAppServiceWorker
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
return routes;
|
|
1407
|
+
}
|
|
1408
|
+
/** The request origin (https when a tunnel edge says so). */
|
|
1409
|
+
function appOrigin(req) {
|
|
1410
|
+
const proto = req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
|
|
1411
|
+
const origin = `http://${req.headers.host ?? "127.0.0.1"}`;
|
|
1412
|
+
return proto === "https" ? origin.replace("http://", "https://") : origin;
|
|
2407
1413
|
}
|
|
2408
1414
|
//#endregion
|
|
2409
1415
|
//#region src/loopback-proxy.ts
|
|
@@ -2429,8 +1435,17 @@ const HTTP_FORWARD_RESPONSE_HEADERS = [
|
|
|
2429
1435
|
* @param res - the outer response.
|
|
2430
1436
|
* @param port - local webServer port.
|
|
2431
1437
|
* @param upstreamPath - path + query on 127.0.0.1 (must start with `/`).
|
|
2432
|
-
|
|
2433
|
-
|
|
1438
|
+
* @param auth - when given, the process's inner browser-auth credential is
|
|
1439
|
+
* attached so the connection plugin's /api route (fence + browser auth,
|
|
1440
|
+
* authority-bound cookie, no loopback exemption on this cohort) accepts
|
|
1441
|
+
* the re-issued request; a 401 answer invalidates the cached credential.
|
|
1442
|
+
*/
|
|
1443
|
+
function proxyLoopbackHttp(req, res, port, upstreamPath, auth) {
|
|
1444
|
+
Promise.resolve(auth?.ready()).catch(() => void 0).then((cookie) => {
|
|
1445
|
+
pipeLoopbackHttp(req, res, port, upstreamPath, auth, typeof cookie === "string" ? cookie : void 0);
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
function pipeLoopbackHttp(req, res, port, upstreamPath, auth, cookie) {
|
|
2434
1449
|
const headers = {
|
|
2435
1450
|
host: `127.0.0.1:${String(port)}`,
|
|
2436
1451
|
"sec-fetch-site": "same-origin"
|
|
@@ -2441,6 +1456,7 @@ function proxyLoopbackHttp(req, res, port, upstreamPath) {
|
|
|
2441
1456
|
if (typeof contentLength === "string") headers["content-length"] = contentLength;
|
|
2442
1457
|
const accept = req.headers.accept;
|
|
2443
1458
|
if (typeof accept === "string") headers.accept = accept;
|
|
1459
|
+
if (cookie !== void 0) headers.cookie = cookie;
|
|
2444
1460
|
const upstream = request({
|
|
2445
1461
|
host: "127.0.0.1",
|
|
2446
1462
|
port,
|
|
@@ -2448,6 +1464,7 @@ function proxyLoopbackHttp(req, res, port, upstreamPath) {
|
|
|
2448
1464
|
method: req.method,
|
|
2449
1465
|
headers
|
|
2450
1466
|
}, (upstreamRes) => {
|
|
1467
|
+
if (upstreamRes.statusCode === 401 && cookie !== void 0) auth?.invalidate();
|
|
2451
1468
|
const out = {};
|
|
2452
1469
|
for (const name of HTTP_FORWARD_RESPONSE_HEADERS) {
|
|
2453
1470
|
const value = upstreamRes.headers[name];
|
|
@@ -2478,14 +1495,17 @@ function proxyLoopbackHttp(req, res, port, upstreamPath) {
|
|
|
2478
1495
|
* @param head - bytes already read past the handshake.
|
|
2479
1496
|
* @param port - local webServer port.
|
|
2480
1497
|
* @param upstreamPath - path + query on 127.0.0.1.
|
|
1498
|
+
* @param cookie - the inner browser-auth credential for the handshake, when
|
|
1499
|
+
* the upstream route enforces it (the gateway event-stream mux does).
|
|
2481
1500
|
*/
|
|
2482
|
-
function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath) {
|
|
1501
|
+
function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath, cookie) {
|
|
2483
1502
|
const lines = [
|
|
2484
1503
|
`GET ${upstreamPath} HTTP/1.1`,
|
|
2485
1504
|
`Host: 127.0.0.1:${String(port)}`,
|
|
2486
1505
|
"Upgrade: websocket",
|
|
2487
1506
|
"Connection: Upgrade"
|
|
2488
1507
|
];
|
|
1508
|
+
if (cookie !== void 0) lines.push(`Cookie: ${cookie}`);
|
|
2489
1509
|
for (const name of WS_FORWARD_HEADERS) {
|
|
2490
1510
|
const value = req.headers[name];
|
|
2491
1511
|
if (value === void 0) continue;
|
|
@@ -2493,6 +1513,8 @@ function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath) {
|
|
|
2493
1513
|
}
|
|
2494
1514
|
const handshake = `${lines.join("\r\n")}\r\n\r\n`;
|
|
2495
1515
|
const upstream = connect(port, "127.0.0.1");
|
|
1516
|
+
socket.setKeepAlive(true, 2e4);
|
|
1517
|
+
upstream.setKeepAlive(true, 2e4);
|
|
2496
1518
|
const tearDown = () => {
|
|
2497
1519
|
upstream.destroy();
|
|
2498
1520
|
socket.destroy();
|
|
@@ -2517,51 +1539,67 @@ function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath) {
|
|
|
2517
1539
|
/**
|
|
2518
1540
|
* Remote desktop channel constants — SDK-independent so tests and the
|
|
2519
1541
|
* client half can pin them without importing the host SDK graph.
|
|
1542
|
+
*
|
|
1543
|
+
* Access model on the 0.1.2-alpha.2 line: the host /api surface has no
|
|
1544
|
+
* per-method privilege pin — the "configuration plane is local" behavior
|
|
1545
|
+
* lives in the browser (client plugins branch on connection.isLoopback), and
|
|
1546
|
+
* the paired remote desktop flips into host mode via the transport hook
|
|
1547
|
+
* (ownsHost) while every call rides this gated channel as a loopback-shaped
|
|
1548
|
+
* request. A paired device is therefore a full-control credential by
|
|
1549
|
+
* design; the only paths that stay physically local are the control planes
|
|
1550
|
+
* below (pairing control, self-update, plugin install/remove, host power).
|
|
2520
1551
|
*/
|
|
2521
1552
|
/** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
|
|
2522
1553
|
const REMOTE_PREFIX$1 = "/remote";
|
|
2523
1554
|
/** Connection-plugin method prefix under the gated channel. */
|
|
2524
1555
|
const REMOTE_API_PREFIX$1 = `${REMOTE_PREFIX$1}/api`;
|
|
2525
|
-
/** WebSocket event-stream paths served by the channel (client rewrites to these). */
|
|
2526
|
-
const REMOTE_API_PATHS = {
|
|
2527
|
-
mux: `${REMOTE_API_PREFIX$1}/events.mux`,
|
|
2528
|
-
host: `${REMOTE_API_PREFIX$1}/events.host`
|
|
2529
|
-
};
|
|
2530
1556
|
/**
|
|
2531
1557
|
* Exact upgrade paths registered on webServer (the SDK matches upgrades by
|
|
2532
1558
|
* exact path, not prefix). Query strings ride on the request URL.
|
|
2533
1559
|
*/
|
|
2534
1560
|
const REMOTE_UPGRADE_PATHS = [
|
|
2535
|
-
|
|
2536
|
-
REMOTE_API_PATHS.host,
|
|
1561
|
+
{ mux: `${REMOTE_API_PREFIX$1}/remote.mux` }.mux,
|
|
2537
1562
|
`${REMOTE_PREFIX$1}/sidebar/ws/terminal`,
|
|
2538
1563
|
`${REMOTE_PREFIX$1}/sidebar/ws/agent-terminals`,
|
|
2539
1564
|
`${REMOTE_API_PREFIX$1}/dsh-ssh/terminal`
|
|
2540
1565
|
];
|
|
1566
|
+
/** Plugin-manager HTTP prefix: install/remove stay physically local. */
|
|
1567
|
+
const PLUGIN_MANAGER_PATH = "/api/plugin-manager";
|
|
1568
|
+
/** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
|
|
1569
|
+
const DESKTOP_LAUNCHER_PATH = "/api/dsh-desktop-launcher";
|
|
1570
|
+
/**
|
|
1571
|
+
* The cookieless device credential: the boot patch reads the device id from
|
|
1572
|
+
* the /pair-app URL, keeps it in sessionStorage, and attaches it to every
|
|
1573
|
+
* gated HTTP call as this header (and to WebSocket upgrades as the `device`
|
|
1574
|
+
* query parameter - WS handshakes cannot carry headers from the Web API).
|
|
1575
|
+
* The channel gate accepts it exactly like the device cookie, so the mobile
|
|
1576
|
+
* flow works even with browser cookies fully blocked; the cookie remains the
|
|
1577
|
+
* primary credential on normal browsers.
|
|
1578
|
+
*/
|
|
1579
|
+
const REMOTE_DEVICE_HEADER = "x-dsh-remote-device";
|
|
1580
|
+
const REMOTE_DEVICE_QUERY = "device";
|
|
1581
|
+
/**
|
|
1582
|
+
* Path prefixes that stay physically local even for a paired device. A
|
|
1583
|
+
* paired remote desktop may use the full host API (chat, sessions,
|
|
1584
|
+
* settings, credentials, presets — it is a full-control credential), but it
|
|
1585
|
+
* must not reach the machine-control planes: pairing control itself, the
|
|
1586
|
+
* dsh-web self-update installer, plugin install/remove, and desktop
|
|
1587
|
+
* launcher actions (host shutdown, shortcuts).
|
|
1588
|
+
*/
|
|
1589
|
+
const LOCAL_ONLY_PREFIXES = [
|
|
1590
|
+
"/api/pair",
|
|
1591
|
+
"/api/update",
|
|
1592
|
+
PLUGIN_MANAGER_PATH,
|
|
1593
|
+
DESKTOP_LAUNCHER_PATH
|
|
1594
|
+
];
|
|
2541
1595
|
/**
|
|
2542
|
-
*
|
|
2543
|
-
*
|
|
2544
|
-
*
|
|
2545
|
-
* unreachable from a paired remote desktop, matching the SDK's own stance
|
|
2546
|
-
* that the configuration plane is loopback-same-origin only.
|
|
1596
|
+
* Whether a paired inner path must stay physically local.
|
|
1597
|
+
* @param innerPath - the rewritten inner path (e.g. `/api/session.list`).
|
|
1598
|
+
* @returns a denial message, or undefined when the path may be proxied.
|
|
2547
1599
|
*/
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
"agentPreset.openDocument",
|
|
2552
|
-
"agentPreset.remove",
|
|
2553
|
-
"host.pickDirectory",
|
|
2554
|
-
"host.openPath",
|
|
2555
|
-
"settings.describe",
|
|
2556
|
-
"settings.openDocument",
|
|
2557
|
-
"settings.update",
|
|
2558
|
-
"settings.replace",
|
|
2559
|
-
"settings.mutate",
|
|
2560
|
-
"credentials.describe",
|
|
2561
|
-
"credentials.set",
|
|
2562
|
-
"credentials.unset",
|
|
2563
|
-
"llm.discoverModels"
|
|
2564
|
-
]);
|
|
1600
|
+
function localOnlyDenial(innerPath) {
|
|
1601
|
+
for (const prefix of LOCAL_ONLY_PREFIXES) if (innerPath === prefix || innerPath.startsWith(`${prefix}/`)) return `${prefix.slice(1)} stays physically local and stays unreachable from a paired remote desktop`;
|
|
1602
|
+
}
|
|
2565
1603
|
//#endregion
|
|
2566
1604
|
//#region src/remote-api.ts
|
|
2567
1605
|
const ALLOWED_METHODS = /* @__PURE__ */ new Set([
|
|
@@ -2612,18 +1650,25 @@ function innerPathOf(pathname) {
|
|
|
2612
1650
|
return rest;
|
|
2613
1651
|
}
|
|
2614
1652
|
/**
|
|
2615
|
-
* Whether a paired inner path must stay physically local
|
|
1653
|
+
* Whether a paired inner path must stay physically local (delegates to the
|
|
1654
|
+
* shared LOCAL_ONLY_PREFIXES table).
|
|
2616
1655
|
* @returns a denial message, or undefined when the path may be proxied.
|
|
2617
1656
|
*/
|
|
2618
1657
|
function loopbackOnlyDenial(innerPath) {
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
1658
|
+
return localOnlyDenial(innerPath);
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Resolve a live device credential for a gated HTTP request: the pairing
|
|
1662
|
+
* cookie first, then the cookieless header the boot patch attaches. Unknown
|
|
1663
|
+
* or revoked ids are a no-op - a stale id never re-arms a device.
|
|
1664
|
+
* @returns the touched device id, or undefined when neither credential is live.
|
|
1665
|
+
*/
|
|
1666
|
+
function pairedDeviceIdOf(req, service) {
|
|
1667
|
+
const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1668
|
+
const headerDevice = typeof req.headers["x-dsh-remote-device"] === "string" ? req.headers[REMOTE_DEVICE_HEADER] : void 0;
|
|
1669
|
+
const id = cookieDevice ?? headerDevice;
|
|
1670
|
+
if (id === void 0) return void 0;
|
|
1671
|
+
return service.touchDevice(id) ? id : void 0;
|
|
2627
1672
|
}
|
|
2628
1673
|
/**
|
|
2629
1674
|
* Build the remote desktop channel HTTP routes.
|
|
@@ -2634,8 +1679,7 @@ function makeRemoteApiRoutes(deps) {
|
|
|
2634
1679
|
const { service, port, requirePairingForLan = true } = deps;
|
|
2635
1680
|
const handler = (req, res) => {
|
|
2636
1681
|
if (typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan) {
|
|
2637
|
-
|
|
2638
|
-
if (!(deviceId !== void 0 && service.touchDevice(deviceId))) {
|
|
1682
|
+
if (pairedDeviceIdOf(req, service) === void 0) {
|
|
2639
1683
|
req.resume();
|
|
2640
1684
|
envelopeError(res, 403, "invalid-request", "unpaired", "this device is not paired with the desktop");
|
|
2641
1685
|
return;
|
|
@@ -2660,7 +1704,7 @@ function makeRemoteApiRoutes(deps) {
|
|
|
2660
1704
|
envelopeError(res, 403, "invalid-request", "forbidden", denied);
|
|
2661
1705
|
return;
|
|
2662
1706
|
}
|
|
2663
|
-
proxyLoopbackHttp(req, res, port, `${inner}${url.search}
|
|
1707
|
+
proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth);
|
|
2664
1708
|
};
|
|
2665
1709
|
return [{
|
|
2666
1710
|
kind: "prefix",
|
|
@@ -2693,8 +1737,11 @@ function makeRemoteApiUpgradeRoutes(deps) {
|
|
|
2693
1737
|
const { service, port, requirePairingForLan = true } = deps;
|
|
2694
1738
|
const handlerFor = (fallbackPath) => (req, socket, head) => {
|
|
2695
1739
|
if (typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan) {
|
|
2696
|
-
|
|
2697
|
-
|
|
1740
|
+
let queryDevice;
|
|
1741
|
+
try {
|
|
1742
|
+
queryDevice = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("device") ?? void 0;
|
|
1743
|
+
} catch {}
|
|
1744
|
+
if ((pairedDeviceIdOf(req, service) ?? (queryDevice !== void 0 && service.touchDevice(queryDevice) ? queryDevice : void 0)) === void 0) {
|
|
2698
1745
|
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
2699
1746
|
socket.destroy();
|
|
2700
1747
|
return;
|
|
@@ -2706,7 +1753,10 @@ function makeRemoteApiUpgradeRoutes(deps) {
|
|
|
2706
1753
|
socket.destroy();
|
|
2707
1754
|
return;
|
|
2708
1755
|
}
|
|
2709
|
-
|
|
1756
|
+
Promise.resolve(deps.auth?.ready()).catch(() => void 0).then((cookie) => {
|
|
1757
|
+
if (socket.destroyed) return;
|
|
1758
|
+
proxyLoopbackUpgrade(req, socket, head, port, inner, typeof cookie === "string" ? cookie : void 0);
|
|
1759
|
+
});
|
|
2710
1760
|
};
|
|
2711
1761
|
return REMOTE_UPGRADE_PATHS.map((path) => ({
|
|
2712
1762
|
path,
|
|
@@ -2714,6 +1764,79 @@ function makeRemoteApiUpgradeRoutes(deps) {
|
|
|
2714
1764
|
}));
|
|
2715
1765
|
}
|
|
2716
1766
|
//#endregion
|
|
1767
|
+
//#region src/remote-presence-pet.ts
|
|
1768
|
+
/** Default restore grace: the presence sweep flips a device offline after
|
|
1769
|
+
* ~25 s, and a phone that was briefly backgrounded should not flicker the
|
|
1770
|
+
* pet; 2 minutes keeps the restore stable. */
|
|
1771
|
+
const DEFAULT_RESTORE_AFTER_MS = 12e4;
|
|
1772
|
+
const nodeTimers = {
|
|
1773
|
+
setTimeout,
|
|
1774
|
+
clearTimeout
|
|
1775
|
+
};
|
|
1776
|
+
/**
|
|
1777
|
+
* Start the presence link. Returns the disposer (withdraws the state
|
|
1778
|
+
* subscription and cancels a pending restore).
|
|
1779
|
+
* @param deps - pairing stream + pet seam (+ test seams).
|
|
1780
|
+
*/
|
|
1781
|
+
function startRemotePresencePet(deps) {
|
|
1782
|
+
const restoreAfterMs = deps.restoreAfterMs ?? DEFAULT_RESTORE_AFTER_MS;
|
|
1783
|
+
const timers = deps.timers ?? nodeTimers;
|
|
1784
|
+
let previousOnline = false;
|
|
1785
|
+
let hiddenByRemote = false;
|
|
1786
|
+
let restoreTimer;
|
|
1787
|
+
let disposed = false;
|
|
1788
|
+
const clearRestore = () => {
|
|
1789
|
+
if (restoreTimer !== void 0) {
|
|
1790
|
+
timers.clearTimeout(restoreTimer);
|
|
1791
|
+
restoreTimer = void 0;
|
|
1792
|
+
}
|
|
1793
|
+
};
|
|
1794
|
+
const scheduleRestore = () => {
|
|
1795
|
+
clearRestore();
|
|
1796
|
+
restoreTimer = timers.setTimeout(() => {
|
|
1797
|
+
restoreTimer = void 0;
|
|
1798
|
+
restoreAfterTick();
|
|
1799
|
+
}, restoreAfterMs);
|
|
1800
|
+
};
|
|
1801
|
+
const restoreAfterTick = () => {
|
|
1802
|
+
if (disposed || !hiddenByRemote) return;
|
|
1803
|
+
const pet = deps.pet();
|
|
1804
|
+
if (pet === void 0) return;
|
|
1805
|
+
hiddenByRemote = false;
|
|
1806
|
+
pet.setVisible(true).catch(() => {});
|
|
1807
|
+
};
|
|
1808
|
+
const hideForRemote = () => {
|
|
1809
|
+
if (hiddenByRemote) return;
|
|
1810
|
+
const pet = deps.pet();
|
|
1811
|
+
if (pet === void 0) return;
|
|
1812
|
+
pet.state().then((value) => {
|
|
1813
|
+
if (disposed || hiddenByRemote) return;
|
|
1814
|
+
if (value.display?.visible !== true) return;
|
|
1815
|
+
hiddenByRemote = true;
|
|
1816
|
+
return pet.setVisible(false).catch(() => {
|
|
1817
|
+
hiddenByRemote = false;
|
|
1818
|
+
});
|
|
1819
|
+
}, () => {});
|
|
1820
|
+
};
|
|
1821
|
+
const handle = (snapshot) => {
|
|
1822
|
+
const online = snapshot.phase === "connected" && snapshot.onlineCount > 0;
|
|
1823
|
+
if (online && !previousOnline) {
|
|
1824
|
+
cancelRestoreAndClear();
|
|
1825
|
+
hideForRemote();
|
|
1826
|
+
} else if (!online && previousOnline) scheduleRestore();
|
|
1827
|
+
previousOnline = online;
|
|
1828
|
+
};
|
|
1829
|
+
const cancelRestoreAndClear = () => {
|
|
1830
|
+
clearRestore();
|
|
1831
|
+
};
|
|
1832
|
+
const unsubscribe = deps.onState(handle);
|
|
1833
|
+
return () => {
|
|
1834
|
+
disposed = true;
|
|
1835
|
+
unsubscribe();
|
|
1836
|
+
clearRestore();
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
//#endregion
|
|
2717
1840
|
//#region src/posture.ts
|
|
2718
1841
|
/**
|
|
2719
1842
|
* Build the forged Host values to probe: the public base authority (host or
|
|
@@ -2845,6 +1968,442 @@ function lanIPv4Addresses() {
|
|
|
2845
1968
|
}).map((iface) => iface.address);
|
|
2846
1969
|
}
|
|
2847
1970
|
//#endregion
|
|
1971
|
+
//#region src/firewall.ts
|
|
1972
|
+
/**
|
|
1973
|
+
* Host firewall management for the LAN bind toggle: while LAN access is on,
|
|
1974
|
+
* keep one inbound allow rule for the bound port; when it turns off, remove
|
|
1975
|
+
* the rule. Windows manages the Windows Defender Firewall rule via netsh;
|
|
1976
|
+
* Linux uses the first available manager among firewalld / ufw / iptables.
|
|
1977
|
+
* On every other platform (macOS included) the plugin reports the firewall
|
|
1978
|
+
* as unmanaged: the port usually needs no rule there, and managing pf or the
|
|
1979
|
+
* application firewall is out of scope for a distributable plugin.
|
|
1980
|
+
*
|
|
1981
|
+
* Ported from the dsh-LAN reference implementation (MIT), adapted to this
|
|
1982
|
+
* package's structure and test seams.
|
|
1983
|
+
*/
|
|
1984
|
+
/** Rule name shared by every backend so re-runs recreate the same rule. */
|
|
1985
|
+
const FIREWALL_RULE_NAME = "remote-web-ui (auto)";
|
|
1986
|
+
/** Production runner: spawn without a shell, capture text, 20s timeout. */
|
|
1987
|
+
const spawnRunner = (cmd, args) => {
|
|
1988
|
+
const result = spawnSync(cmd, args, {
|
|
1989
|
+
shell: false,
|
|
1990
|
+
encoding: "utf8",
|
|
1991
|
+
windowsHide: process.platform === "win32",
|
|
1992
|
+
timeout: 2e4
|
|
1993
|
+
});
|
|
1994
|
+
return {
|
|
1995
|
+
ok: result.status === 0,
|
|
1996
|
+
out: result.stdout ?? "",
|
|
1997
|
+
err: result.stderr ?? "",
|
|
1998
|
+
missing: result.error !== void 0
|
|
1999
|
+
};
|
|
2000
|
+
};
|
|
2001
|
+
function netshBackend(run) {
|
|
2002
|
+
const show = () => {
|
|
2003
|
+
const result = run("netsh", [
|
|
2004
|
+
"advfirewall",
|
|
2005
|
+
"firewall",
|
|
2006
|
+
"show",
|
|
2007
|
+
"rule",
|
|
2008
|
+
`name=${FIREWALL_RULE_NAME}`,
|
|
2009
|
+
"verbose"
|
|
2010
|
+
]);
|
|
2011
|
+
return result.ok && result.out.includes("remote-web-ui (auto)");
|
|
2012
|
+
};
|
|
2013
|
+
return {
|
|
2014
|
+
label: "netsh",
|
|
2015
|
+
ruleExists: show,
|
|
2016
|
+
addRule: (port) => run("netsh", [
|
|
2017
|
+
"advfirewall",
|
|
2018
|
+
"firewall",
|
|
2019
|
+
"add",
|
|
2020
|
+
"rule",
|
|
2021
|
+
`name=${FIREWALL_RULE_NAME}`,
|
|
2022
|
+
"dir=in",
|
|
2023
|
+
"action=allow",
|
|
2024
|
+
"protocol=TCP",
|
|
2025
|
+
`localport=${String(port)}`,
|
|
2026
|
+
"profile=private,domain"
|
|
2027
|
+
]).ok,
|
|
2028
|
+
removeRule: () => {
|
|
2029
|
+
if (!show()) return true;
|
|
2030
|
+
return run("netsh", [
|
|
2031
|
+
"advfirewall",
|
|
2032
|
+
"firewall",
|
|
2033
|
+
"delete",
|
|
2034
|
+
"rule",
|
|
2035
|
+
`name=${FIREWALL_RULE_NAME}`
|
|
2036
|
+
]).ok;
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
function firewalldBackend(run) {
|
|
2041
|
+
return {
|
|
2042
|
+
label: "firewalld",
|
|
2043
|
+
ruleExists: (port) => run("firewall-cmd", [
|
|
2044
|
+
"--permanent",
|
|
2045
|
+
"--query-port",
|
|
2046
|
+
`${String(port)}/tcp`
|
|
2047
|
+
]).ok,
|
|
2048
|
+
addRule: (port) => {
|
|
2049
|
+
const add = run("firewall-cmd", [
|
|
2050
|
+
"--permanent",
|
|
2051
|
+
"--add-port",
|
|
2052
|
+
`${String(port)}/tcp`
|
|
2053
|
+
]);
|
|
2054
|
+
const reload = run("firewall-cmd", ["--reload"]);
|
|
2055
|
+
return add.ok && reload.ok;
|
|
2056
|
+
},
|
|
2057
|
+
removeRule: (port) => {
|
|
2058
|
+
const del = run("firewall-cmd", [
|
|
2059
|
+
"--permanent",
|
|
2060
|
+
"--remove-port",
|
|
2061
|
+
`${String(port)}/tcp`
|
|
2062
|
+
]);
|
|
2063
|
+
const reload = run("firewall-cmd", ["--reload"]);
|
|
2064
|
+
return del.ok && reload.ok;
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
function ufwBackend(run) {
|
|
2069
|
+
return {
|
|
2070
|
+
label: "ufw",
|
|
2071
|
+
ruleExists: (port) => {
|
|
2072
|
+
const result = run("ufw", ["status"]);
|
|
2073
|
+
return result.ok && new RegExp(`(^|\\s)${String(port)}/tcp\\s+ALLOW`, "i").test(result.out);
|
|
2074
|
+
},
|
|
2075
|
+
addRule: (port) => run("ufw", ["allow", `${String(port)}/tcp`]).ok,
|
|
2076
|
+
removeRule: (port) => run("ufw", [
|
|
2077
|
+
"delete",
|
|
2078
|
+
"allow",
|
|
2079
|
+
`${String(port)}/tcp`
|
|
2080
|
+
]).ok
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
function iptablesBackend(run) {
|
|
2084
|
+
const rule = (port) => [
|
|
2085
|
+
"INPUT",
|
|
2086
|
+
"-p",
|
|
2087
|
+
"tcp",
|
|
2088
|
+
"--dport",
|
|
2089
|
+
String(port),
|
|
2090
|
+
"-j",
|
|
2091
|
+
"ACCEPT"
|
|
2092
|
+
];
|
|
2093
|
+
return {
|
|
2094
|
+
label: "iptables",
|
|
2095
|
+
ruleExists: (port) => run("iptables", ["-C", ...rule(port)]).ok,
|
|
2096
|
+
addRule: (port) => run("iptables", ["-A", ...rule(port)]).ok,
|
|
2097
|
+
removeRule: (port) => run("iptables", ["-D", ...rule(port)]).ok
|
|
2098
|
+
};
|
|
2099
|
+
}
|
|
2100
|
+
function toolAvailable(run, cmd) {
|
|
2101
|
+
try {
|
|
2102
|
+
return run(cmd, ["--version"]).missing !== true;
|
|
2103
|
+
} catch {
|
|
2104
|
+
return false;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
/**
|
|
2108
|
+
* Detect the platform firewall manager. Returns undefined when the platform
|
|
2109
|
+
* has no supported manager (macOS, unknown Linux without tools): the port
|
|
2110
|
+
* then usually needs no rule and the UI reports "unmanaged".
|
|
2111
|
+
*/
|
|
2112
|
+
function detectFirewallBackend(platform, run = spawnRunner) {
|
|
2113
|
+
if (platform === "win32") return netshBackend(run);
|
|
2114
|
+
if (platform !== "linux") return void 0;
|
|
2115
|
+
if (toolAvailable(run, "firewall-cmd")) {
|
|
2116
|
+
if (run("firewall-cmd", ["--state"]).ok) return firewalldBackend(run);
|
|
2117
|
+
}
|
|
2118
|
+
if (toolAvailable(run, "ufw")) return ufwBackend(run);
|
|
2119
|
+
if (toolAvailable(run, "iptables")) return iptablesBackend(run);
|
|
2120
|
+
}
|
|
2121
|
+
let cachedBackend;
|
|
2122
|
+
/** Cached detection for the running platform (per process). */
|
|
2123
|
+
function firewallBackend() {
|
|
2124
|
+
const platform = process.platform;
|
|
2125
|
+
if (cachedBackend === void 0 || cachedBackend.platform !== platform) cachedBackend = {
|
|
2126
|
+
platform,
|
|
2127
|
+
backend: detectFirewallBackend(platform)
|
|
2128
|
+
};
|
|
2129
|
+
return cachedBackend.backend;
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Delete-and-add: recreating the rule is idempotent and locale-proof (netsh
|
|
2133
|
+
* output is localized, so parsing the live rule's port is fragile; the Linux
|
|
2134
|
+
* backends follow the same recreate pattern).
|
|
2135
|
+
*/
|
|
2136
|
+
function ensureFirewallRule(port) {
|
|
2137
|
+
invalidateFirewallSummary();
|
|
2138
|
+
const backend = firewallBackend();
|
|
2139
|
+
if (backend === void 0) return true;
|
|
2140
|
+
backend.removeRule(port);
|
|
2141
|
+
return backend.addRule(port);
|
|
2142
|
+
}
|
|
2143
|
+
function removeFirewallRule(port) {
|
|
2144
|
+
invalidateFirewallSummary();
|
|
2145
|
+
const backend = firewallBackend();
|
|
2146
|
+
if (backend === void 0) return true;
|
|
2147
|
+
return backend.removeRule(port);
|
|
2148
|
+
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Human-readable firewall state for the status endpoint. A missing backend
|
|
2151
|
+
* means the platform has nothing to manage; detection stays at the call site
|
|
2152
|
+
* so an explicit undefined cannot silently re-probe the real OS mid-test.
|
|
2153
|
+
*/
|
|
2154
|
+
function computeFirewallSummary(port, lanEnabled, backend) {
|
|
2155
|
+
if (backend === void 0) return {
|
|
2156
|
+
ok: true,
|
|
2157
|
+
managed: false
|
|
2158
|
+
};
|
|
2159
|
+
return {
|
|
2160
|
+
ok: lanEnabled ? backend.ruleExists(port) : !backend.ruleExists(port),
|
|
2161
|
+
managed: true,
|
|
2162
|
+
note: backend.label
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
let summaryCache;
|
|
2166
|
+
/** Forget the cached summary (rule mutations call this). */
|
|
2167
|
+
function invalidateFirewallSummary() {
|
|
2168
|
+
summaryCache = void 0;
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* The settings card polls this every ten seconds; the probes are blocking
|
|
2172
|
+
* spawnSync subprocesses on managed platforms, so short-TTL memoization
|
|
2173
|
+
* keeps one poll from freezing the host event loop per request.
|
|
2174
|
+
*/
|
|
2175
|
+
function firewallSummary(port, lanEnabled) {
|
|
2176
|
+
const key = `${String(port)}|${lanEnabled ? "1" : "0"}`;
|
|
2177
|
+
const now = Date.now();
|
|
2178
|
+
if (summaryCache !== void 0 && summaryCache.key === key && now - summaryCache.at < 3e4) return summaryCache.value;
|
|
2179
|
+
const value = computeFirewallSummary(port, lanEnabled, firewallBackend());
|
|
2180
|
+
summaryCache = {
|
|
2181
|
+
key,
|
|
2182
|
+
at: now,
|
|
2183
|
+
value
|
|
2184
|
+
};
|
|
2185
|
+
return value;
|
|
2186
|
+
}
|
|
2187
|
+
//#endregion
|
|
2188
|
+
//#region src/lan-bind.ts
|
|
2189
|
+
/**
|
|
2190
|
+
* LAN bind toggle: the managed cordis patch block that pins the web
|
|
2191
|
+
* server's bind. On = a block binding 0.0.0.0; off = the same block binding
|
|
2192
|
+
* 127.0.0.1. The block takes effect on the next `dsh web` start (the live
|
|
2193
|
+
* patch watcher cannot rebind a running listener, and on this harness line
|
|
2194
|
+
* the user patch layer cannot evaluate webStartup-dependent expressions
|
|
2195
|
+
* reliably - static values are the only dependable form), so the plugin
|
|
2196
|
+
* re-asserts the block at every boot and the settings card reports the
|
|
2197
|
+
* running bind honestly.
|
|
2198
|
+
*
|
|
2199
|
+
* Same-id patch rows REPLACE the row config wholesale, so the block carries
|
|
2200
|
+
* the official web-app webserver row's full config (bind + compression)
|
|
2201
|
+
* with the bind values materialized. The CLI's --host 0.0.0.0 guard stays
|
|
2202
|
+
* intact: deliberate LAN exposure happens through this configuration layer
|
|
2203
|
+
* only. Until the user flips the toggle once, the plugin never touches the
|
|
2204
|
+
* patch file.
|
|
2205
|
+
*
|
|
2206
|
+
* The block discipline (markers, atomic write, strip-and-rewrite) follows
|
|
2207
|
+
* the dsh-LAN reference implementation (MIT).
|
|
2208
|
+
*/
|
|
2209
|
+
/** Profile names safe to interpolate into the patch path: one path segment, no traversal. */
|
|
2210
|
+
const PROFILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
2211
|
+
const LAN_BIND_BLOCK_BEGIN = "# --- remote-web-ui lan-bind block (managed - do not edit) ---";
|
|
2212
|
+
const LAN_BIND_BLOCK_END = "# --- end remote-web-ui lan-bind block ---";
|
|
2213
|
+
/**
|
|
2214
|
+
* The absolute path of the profile patch file this toggle manages. The value
|
|
2215
|
+
* is config-controlled (and the DSH_PROFILE env fallback bypasses schema
|
|
2216
|
+
* validation entirely), so the path is guarded twice: the profile must be a
|
|
2217
|
+
* single safe path segment, and the resolved file must stay under the
|
|
2218
|
+
* profiles directory.
|
|
2219
|
+
*/
|
|
2220
|
+
function profilePatchFile(profile, home = dshHome()) {
|
|
2221
|
+
if (!PROFILE_PATTERN.test(profile)) throw new Error(`remote-web-ui: unsafe lan-bind profile ${JSON.stringify(profile)}`);
|
|
2222
|
+
const file = resolve(join(home, "profiles", profile, "cordis.patch.yml"));
|
|
2223
|
+
const root = resolve(join(home, "profiles")) + sep;
|
|
2224
|
+
if (!file.startsWith(root)) throw new Error(`remote-web-ui: lan-bind profile ${JSON.stringify(profile)} escapes the profiles directory`);
|
|
2225
|
+
return file;
|
|
2226
|
+
}
|
|
2227
|
+
function readPatchContent(file) {
|
|
2228
|
+
if (!existsSync(file)) return "";
|
|
2229
|
+
return readFileSync(file, "utf8");
|
|
2230
|
+
}
|
|
2231
|
+
/** Escape one literal string for embedding into a RegExp pattern. */
|
|
2232
|
+
function escapeRegex(text) {
|
|
2233
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2234
|
+
}
|
|
2235
|
+
/** Remove the managed block (and its markers) from patch content. */
|
|
2236
|
+
function stripManagedBlock(content) {
|
|
2237
|
+
const begin = escapeRegex(LAN_BIND_BLOCK_BEGIN);
|
|
2238
|
+
const end = escapeRegex(LAN_BIND_BLOCK_END);
|
|
2239
|
+
const pattern = new RegExp(`\\r?\\n?${begin}[\\s\\S]*?${end}\\r?\\n?`, "g");
|
|
2240
|
+
return content.replace(pattern, "\n");
|
|
2241
|
+
}
|
|
2242
|
+
/**
|
|
2243
|
+
* Render the managed block for one bind state. The values are static: the
|
|
2244
|
+
* user patch layer has no reliable lazy service evaluation, and the plugin
|
|
2245
|
+
* re-asserts the block at every boot so CLI flags (--port, --host) win by
|
|
2246
|
+
* rewriting it before the next start.
|
|
2247
|
+
*/
|
|
2248
|
+
function managedBlock(host, port) {
|
|
2249
|
+
return [
|
|
2250
|
+
LAN_BIND_BLOCK_BEGIN,
|
|
2251
|
+
"- id: webserver",
|
|
2252
|
+
" name: '@deepseek-ai/dsh-host-webserver'",
|
|
2253
|
+
" config:",
|
|
2254
|
+
` host: '${host}'`,
|
|
2255
|
+
` port: ${String(port)}`,
|
|
2256
|
+
" compression: gzip",
|
|
2257
|
+
" compressionLevel: 1",
|
|
2258
|
+
" compressionThresholdBytes: 1024",
|
|
2259
|
+
LAN_BIND_BLOCK_END,
|
|
2260
|
+
""
|
|
2261
|
+
].join("\n");
|
|
2262
|
+
}
|
|
2263
|
+
/**
|
|
2264
|
+
* Parse the block's pinned bind out of patch content. A block whose values
|
|
2265
|
+
* were hand-edited reports the literals as-is so the card can surface them
|
|
2266
|
+
* instead of silently claiming one of the two known states.
|
|
2267
|
+
*/
|
|
2268
|
+
function managedBindOf(content) {
|
|
2269
|
+
const begin = content.indexOf(LAN_BIND_BLOCK_BEGIN);
|
|
2270
|
+
if (begin === -1) return void 0;
|
|
2271
|
+
const end = content.indexOf(LAN_BIND_BLOCK_END, begin);
|
|
2272
|
+
const block = end === -1 ? content.slice(begin) : content.slice(begin, end);
|
|
2273
|
+
const hostMatch = /host:\s*'([^']+)'/.exec(block);
|
|
2274
|
+
const portMatch = /port:\s*(\d+)/.exec(block);
|
|
2275
|
+
return {
|
|
2276
|
+
host: hostMatch?.[1] ?? "",
|
|
2277
|
+
port: portMatch !== null ? Number(portMatch[1]) : void 0
|
|
2278
|
+
};
|
|
2279
|
+
}
|
|
2280
|
+
/** Full file-level state for the settings card and the boot re-assert. */
|
|
2281
|
+
function lanBindState(profile, home = dshHome()) {
|
|
2282
|
+
const state = managedBindOf(readPatchContent(profilePatchFile(profile, home)));
|
|
2283
|
+
if (state === void 0) return { blockPresent: false };
|
|
2284
|
+
return {
|
|
2285
|
+
blockPresent: true,
|
|
2286
|
+
host: state.host,
|
|
2287
|
+
port: state.port
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* Write (or rewrite) the managed block with the given bind. The rest of the
|
|
2292
|
+
* patch file is preserved; the block is rewritten atomically (unique temp
|
|
2293
|
+
* file + rename, so concurrent writers can never rename a half-written
|
|
2294
|
+
* file) with the original file's permissions preserved. A hand-truncated
|
|
2295
|
+
* unterminated block (BEGIN marker without END, which stripManagedBlock
|
|
2296
|
+
* cannot match) is truncated at its BEGIN marker first so the rewrite can
|
|
2297
|
+
* never stack a second webserver row onto the orphan.
|
|
2298
|
+
*/
|
|
2299
|
+
function writeLanBind(host, port, profile, home = dshHome()) {
|
|
2300
|
+
const file = profilePatchFile(profile, home);
|
|
2301
|
+
const stripped = stripManagedBlock(readPatchContent(file));
|
|
2302
|
+
const orphanBegin = stripped.indexOf(LAN_BIND_BLOCK_BEGIN);
|
|
2303
|
+
const content = `${(orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd()}\n\n${managedBlock(host, port)}`;
|
|
2304
|
+
const mode = existsSync(file) ? statSync(file).mode & 511 : 384;
|
|
2305
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
2306
|
+
const temp = `${file}.remote-web-ui-tmp-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2307
|
+
writeFileSync(temp, content, { mode });
|
|
2308
|
+
renameSync(temp, file);
|
|
2309
|
+
}
|
|
2310
|
+
//#endregion
|
|
2311
|
+
//#region src/lan-bind-plan.ts
|
|
2312
|
+
/**
|
|
2313
|
+
* The bind host the managed block should pin: an explicit CLI --host wins,
|
|
2314
|
+
* otherwise the toggle decides. CLI hosts outside the two managed literals
|
|
2315
|
+
* (a pinned specific IP) are not a state the toggle manages — the toggle
|
|
2316
|
+
* decides, and the card surfaces the divergence honestly.
|
|
2317
|
+
* @param lanBind - the toggle value.
|
|
2318
|
+
* @param startupHost - the CLI-provided bind host, when given.
|
|
2319
|
+
* @returns the desired bind host literal.
|
|
2320
|
+
*/
|
|
2321
|
+
function desiredBindHost(lanBind, startupHost) {
|
|
2322
|
+
if (startupHost === "0.0.0.0" || startupHost === "127.0.0.1") return startupHost;
|
|
2323
|
+
return lanBind ? "0.0.0.0" : "127.0.0.1";
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* The port the managed block should pin: the CLI port when given, else the
|
|
2327
|
+
* currently bound port. Undefined when neither is readable yet (the caller
|
|
2328
|
+
* skips the write and reports why).
|
|
2329
|
+
* @param startupPort - the CLI-provided port, when given.
|
|
2330
|
+
* @param livePort - the currently bound port, when finite.
|
|
2331
|
+
*/
|
|
2332
|
+
function desiredBindPort(startupPort, livePort) {
|
|
2333
|
+
if (typeof startupPort === "number" && Number.isFinite(startupPort)) return startupPort;
|
|
2334
|
+
return typeof livePort === "number" && Number.isFinite(livePort) ? livePort : void 0;
|
|
2335
|
+
}
|
|
2336
|
+
/**
|
|
2337
|
+
* Whether the running bind has not caught up with what the toggle (plus a
|
|
2338
|
+
* possibly overriding CLI host) will pin. Compared against the effective
|
|
2339
|
+
* desired host — not the raw toggle — so a flag-managed bind is not reported
|
|
2340
|
+
* as pending forever.
|
|
2341
|
+
* @param lanBind - the toggle value (undefined = untouched, never pending).
|
|
2342
|
+
* @param desiredHost - the effective desired host, or undefined when untouched.
|
|
2343
|
+
* @param liveHost - the currently bound host.
|
|
2344
|
+
*/
|
|
2345
|
+
function pendingRestartOf(lanBind, desiredHost, liveHost) {
|
|
2346
|
+
if (lanBind === void 0 || desiredHost === void 0) return false;
|
|
2347
|
+
return liveHost !== desiredHost;
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* Whether the firewall rule must be (re)applied: only when the toggle or the
|
|
2351
|
+
* bound port moved since the last application. Keeps unrelated settings
|
|
2352
|
+
* saves from spawning netsh/firewall-cmd delete+add churn.
|
|
2353
|
+
* @param previous - the state applied earlier by this process, if any.
|
|
2354
|
+
* @param next - the state the settings now ask for, if derivable.
|
|
2355
|
+
*/
|
|
2356
|
+
function firewallActionNeeded(previous, next) {
|
|
2357
|
+
if (next === void 0) return false;
|
|
2358
|
+
if (previous === void 0) return true;
|
|
2359
|
+
return previous.enabled !== next.enabled || previous.port !== next.port;
|
|
2360
|
+
}
|
|
2361
|
+
//#endregion
|
|
2362
|
+
//#region src/inner-auth.ts
|
|
2363
|
+
/** The harness browser-auth cookie name prefix (see dsh-client-connection). */
|
|
2364
|
+
const BROWSER_AUTH_COOKIE_PREFIX = "dsh-auth-";
|
|
2365
|
+
/**
|
|
2366
|
+
* Build the inner-auth handle.
|
|
2367
|
+
* @param launchUrl - the launch-token URL for the inner loopback authority
|
|
2368
|
+
* (the connection service's authenticatedUrl of `http://127.0.0.1:<port>/`),
|
|
2369
|
+
* or undefined when the connection service or port is unavailable.
|
|
2370
|
+
* @param fetchImpl - injectable fetch (tests).
|
|
2371
|
+
*/
|
|
2372
|
+
function createInnerAuth(launchUrl, fetchImpl = fetch) {
|
|
2373
|
+
let cached;
|
|
2374
|
+
let inflight;
|
|
2375
|
+
const redeem = async () => {
|
|
2376
|
+
const url = launchUrl();
|
|
2377
|
+
if (url === void 0) return void 0;
|
|
2378
|
+
try {
|
|
2379
|
+
const raw = (await fetchImpl(url, { redirect: "manual" })).headers.get("set-cookie");
|
|
2380
|
+
if (raw === null) return void 0;
|
|
2381
|
+
const entry = raw.split(",").map((part) => part.trim()).find((part) => part.startsWith(BROWSER_AUTH_COOKIE_PREFIX));
|
|
2382
|
+
if (entry === void 0) return void 0;
|
|
2383
|
+
const pair = entry.split(";")[0]?.trim();
|
|
2384
|
+
return pair !== void 0 && pair.includes("=") ? pair : void 0;
|
|
2385
|
+
} catch {
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
};
|
|
2389
|
+
return {
|
|
2390
|
+
ready() {
|
|
2391
|
+
if (cached !== void 0) return Promise.resolve(cached);
|
|
2392
|
+
inflight ??= redeem().then((value) => {
|
|
2393
|
+
if (value !== void 0) cached = value;
|
|
2394
|
+
inflight = void 0;
|
|
2395
|
+
return value;
|
|
2396
|
+
}, () => {
|
|
2397
|
+
inflight = void 0;
|
|
2398
|
+
});
|
|
2399
|
+
return inflight;
|
|
2400
|
+
},
|
|
2401
|
+
invalidate() {
|
|
2402
|
+
cached = void 0;
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
//#endregion
|
|
2848
2407
|
//#region src/tunnel.ts
|
|
2849
2408
|
/**
|
|
2850
2409
|
* Auto-tunnel manager: spawns a Cloudflare quick tunnel (`cloudflared
|
|
@@ -2866,7 +2425,10 @@ async function defaultEnsureBinary() {
|
|
|
2866
2425
|
}
|
|
2867
2426
|
/** Default factory: the cloudflared package's quick tunnel (no account). */
|
|
2868
2427
|
function defaultFactory(targetUrl) {
|
|
2869
|
-
return Tunnel.quick(targetUrl, {
|
|
2428
|
+
return Tunnel.quick(targetUrl, {
|
|
2429
|
+
"--no-autoupdate": true,
|
|
2430
|
+
"--protocol": "http2"
|
|
2431
|
+
});
|
|
2870
2432
|
}
|
|
2871
2433
|
/** Node timers. */
|
|
2872
2434
|
const nodeTimer = {
|
|
@@ -3725,12 +3287,14 @@ const REMOTE_CHANNEL_RULES = {
|
|
|
3725
3287
|
gitPrefix: "/git/",
|
|
3726
3288
|
petPrefix: "/pet/",
|
|
3727
3289
|
wsPaths: [
|
|
3728
|
-
"/api/
|
|
3729
|
-
"/api/events.host",
|
|
3290
|
+
"/api/remote.mux",
|
|
3730
3291
|
"/sidebar/ws/terminal",
|
|
3731
3292
|
"/sidebar/ws/agent-terminals",
|
|
3732
3293
|
"/api/dsh-ssh/terminal"
|
|
3733
|
-
]
|
|
3294
|
+
],
|
|
3295
|
+
deviceHeader: REMOTE_DEVICE_HEADER,
|
|
3296
|
+
deviceKey: "dsh-remote-device",
|
|
3297
|
+
deviceQuery: REMOTE_DEVICE_QUERY
|
|
3734
3298
|
};
|
|
3735
3299
|
/** The window global the boot patch publishes its seat under. */
|
|
3736
3300
|
const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
@@ -3748,8 +3312,15 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
|
3748
3312
|
* (hooks + pending unpaired signal) instead of patching twice.
|
|
3749
3313
|
*
|
|
3750
3314
|
* The rewrite decisions are generated from REMOTE_CHANNEL_RULES, the same
|
|
3751
|
-
* data the browser patch consumes — the two cannot drift apart.
|
|
3752
|
-
*
|
|
3315
|
+
* data the browser patch consumes — the two cannot drift apart. On this
|
|
3316
|
+
* 0.1.2-alpha.2 line the "configuration plane is local" behavior lives in
|
|
3317
|
+
* the browser (client plugins branch on connection.isLoopback), so the
|
|
3318
|
+
* script also flips the official UI into host mode by installing the
|
|
3319
|
+
* transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
|
|
3320
|
+
* connection plugin reads it: the paired remote desktop gets the full
|
|
3321
|
+
* settings/credentials/presets surface, and every call still rides the
|
|
3322
|
+
* gated /remote channel. The script self-skips on loopback origins and
|
|
3323
|
+
* never throws.
|
|
3753
3324
|
* @module @linxin666/dsh-remote-web-ui/remote-channel-boot
|
|
3754
3325
|
*/
|
|
3755
3326
|
/**
|
|
@@ -3760,7 +3331,26 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
|
3760
3331
|
function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
|
|
3761
3332
|
const json = JSON.stringify(rules);
|
|
3762
3333
|
const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL);
|
|
3763
|
-
return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;var R=" + json + ";function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.desktopLauncherPrefix||p.indexOf(R.desktopLauncherPrefix+\"/\")===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,init)).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat}catch(e){}})();";
|
|
3334
|
+
return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}var R=" + json + ";function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.desktopLauncherPrefix||p.indexOf(R.desktopLauncherPrefix+\"/\")===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
|
|
3335
|
+
}
|
|
3336
|
+
/** Boot-watchdog latch: one self-reload per session while the boot is broken. */
|
|
3337
|
+
const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
|
|
3338
|
+
/** Total wait for the app's first conversation surface before the reload. */
|
|
3339
|
+
const BOOT_WATCHDOG_WAIT_MS = 15e3;
|
|
3340
|
+
/** Watchdog poll cadence. */
|
|
3341
|
+
const BOOT_WATCHDOG_POLL_MS = 1e3;
|
|
3342
|
+
/**
|
|
3343
|
+
* Build the boot-watchdog script fragment. A remote boot has no recovery
|
|
3344
|
+
* path of its own: the SPA mounts nothing when a boot-critical request dies
|
|
3345
|
+
* (a tunnel-edge 429 under burst, a dropped stream, a boot-order race), and
|
|
3346
|
+
* the phone then shows a permanently blank shell. The watchdog polls for the
|
|
3347
|
+
* app's conversation surface and reloads once when it never appears; the
|
|
3348
|
+
* sessionStorage latch keeps a genuinely broken deployment from looping,
|
|
3349
|
+
* and a successful boot clears the latch so a later failure can recover.
|
|
3350
|
+
*/
|
|
3351
|
+
function buildBootWatchdogScript(waitMs = BOOT_WATCHDOG_WAIT_MS, pollMs = BOOT_WATCHDOG_POLL_MS) {
|
|
3352
|
+
const key = JSON.stringify(BOOT_WATCHDOG_KEY);
|
|
3353
|
+
return ";function wBoot(){try{return !!(w.document&&w.document.querySelector&&(w.document.querySelector(\"[data-conversation-scroll]\")||w.document.querySelector(\"[data-slot=\\\"conversation\\\"]\")))}catch(e){return false}}function wTick(n){try{if(wBoot()){try{w.sessionStorage.removeItem(" + key + ")}catch(e){}return}if(n<" + waitMs + "){w.setTimeout(function(){wTick(n+" + pollMs + ")}," + pollMs + ");return}var done=false;try{done=w.sessionStorage.getItem(" + key + ")===\"1\"}catch(e){}if(done)return;try{w.sessionStorage.setItem(" + key + ",\"1\")}catch(e){}w.location.reload()}catch(e){}}if(typeof w.setTimeout===\"function\")w.setTimeout(function(){wTick(0)}," + pollMs + ");";
|
|
3764
3354
|
}
|
|
3765
3355
|
/** The script this plugin contributes; built once from the live rules. */
|
|
3766
3356
|
const REMOTE_CHANNEL_BOOT_SCRIPT = buildRemoteChannelBootScript();
|
|
@@ -3809,16 +3399,15 @@ const name = "remote-web-ui";
|
|
|
3809
3399
|
/** Services required before the pairing surfaces can mount. */
|
|
3810
3400
|
const inject = [
|
|
3811
3401
|
"webServer",
|
|
3812
|
-
"
|
|
3813
|
-
"
|
|
3814
|
-
"agents"
|
|
3402
|
+
"typertGateway",
|
|
3403
|
+
"connection"
|
|
3815
3404
|
];
|
|
3816
3405
|
/**
|
|
3817
3406
|
* Settings namespace of the remote-control capability — the section the web
|
|
3818
3407
|
* settings surface edits. Spelled here rather than imported: the browser
|
|
3819
3408
|
* half spells the same value and must not depend on a Host package.
|
|
3820
3409
|
*/
|
|
3821
|
-
const REMOTE_WEB_UI_SETTINGS_NAMESPACE =
|
|
3410
|
+
const REMOTE_WEB_UI_SETTINGS_NAMESPACE = "remote-web-ui";
|
|
3822
3411
|
const Config = z.object({
|
|
3823
3412
|
tokenTtlMs: z.number().step(1).min(6e4).default(10 * 6e4),
|
|
3824
3413
|
offlineAfterMs: z.number().step(1).min(5e3).default(25e3),
|
|
@@ -3829,7 +3418,8 @@ const Config = z.object({
|
|
|
3829
3418
|
publicBaseUrl: z.string(),
|
|
3830
3419
|
devicesFile: z.string(),
|
|
3831
3420
|
autoTunnel: z.boolean().default(false),
|
|
3832
|
-
|
|
3421
|
+
lanBind: z.boolean(),
|
|
3422
|
+
profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
3833
3423
|
enabled: z.boolean().default(true)
|
|
3834
3424
|
});
|
|
3835
3425
|
/** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
|
|
@@ -3865,7 +3455,8 @@ const DEFAULTS = {
|
|
|
3865
3455
|
publicBaseUrl: void 0,
|
|
3866
3456
|
devicesFile: defaultDevicesFile(),
|
|
3867
3457
|
autoTunnel: false,
|
|
3868
|
-
|
|
3458
|
+
lanBind: void 0,
|
|
3459
|
+
profile: process.env.DSH_PROFILE ?? "web",
|
|
3869
3460
|
enabled: true
|
|
3870
3461
|
};
|
|
3871
3462
|
/**
|
|
@@ -3885,7 +3476,8 @@ function applyImpl(ctx, config) {
|
|
|
3885
3476
|
publicBaseUrl: config?.publicBaseUrl,
|
|
3886
3477
|
devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
|
|
3887
3478
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
3888
|
-
|
|
3479
|
+
lanBind: config?.lanBind,
|
|
3480
|
+
profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
3889
3481
|
enabled: config?.enabled ?? DEFAULTS.enabled
|
|
3890
3482
|
};
|
|
3891
3483
|
let current = () => config ?? {};
|
|
@@ -3901,7 +3493,8 @@ function applyImpl(ctx, config) {
|
|
|
3901
3493
|
publicBaseUrl: value.publicBaseUrl,
|
|
3902
3494
|
devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
|
|
3903
3495
|
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
3904
|
-
|
|
3496
|
+
lanBind: value.lanBind,
|
|
3497
|
+
profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
3905
3498
|
enabled: value.enabled ?? DEFAULTS.enabled
|
|
3906
3499
|
};
|
|
3907
3500
|
};
|
|
@@ -3931,16 +3524,15 @@ function applyImpl(ctx, config) {
|
|
|
3931
3524
|
ctx.effect(() => () => {
|
|
3932
3525
|
tunnel.dispose();
|
|
3933
3526
|
}, "remote-web-ui: auto tunnel");
|
|
3934
|
-
|
|
3935
|
-
address
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3527
|
+
if ((ctx.webServer.host === "0.0.0.0" || ctx.webServer.host === "127.0.0.1") && Number.isFinite(ctx.webServer.port)) {
|
|
3528
|
+
const lanBases = ctx.webServer.host === "0.0.0.0" ? lanIPv4Addresses().map((address) => ({
|
|
3529
|
+
address,
|
|
3530
|
+
base: `http://${address}:${String(ctx.webServer.port)}`
|
|
3531
|
+
})) : [];
|
|
3532
|
+
service.setLanBases(lanBases);
|
|
3533
|
+
}
|
|
3940
3534
|
let disposeRoutes;
|
|
3941
3535
|
let disposeSweep;
|
|
3942
|
-
const apiProxy = ctx.get("apiProxy");
|
|
3943
|
-
if (apiProxy === void 0) console.warn("remote-web-ui: apiProxy service unavailable — the mobile data channel is disabled");
|
|
3944
3536
|
const requireFromHost = createRequire(import.meta.url);
|
|
3945
3537
|
const resolveAnchorPath = () => resolveAnchorManifest((specifier) => {
|
|
3946
3538
|
try {
|
|
@@ -4006,45 +3598,84 @@ function applyImpl(ctx, config) {
|
|
|
4006
3598
|
});
|
|
4007
3599
|
}
|
|
4008
3600
|
});
|
|
3601
|
+
let lastKnownPort;
|
|
3602
|
+
let lastFirewallApplied;
|
|
3603
|
+
const lanBindStatus = () => {
|
|
3604
|
+
const resolvedNow = resolve();
|
|
3605
|
+
let state;
|
|
3606
|
+
try {
|
|
3607
|
+
state = lanBindState(resolvedNow.profile);
|
|
3608
|
+
} catch {
|
|
3609
|
+
state = { blockPresent: false };
|
|
3610
|
+
}
|
|
3611
|
+
const lanOn = state.host === "0.0.0.0";
|
|
3612
|
+
const port = Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : lastKnownPort;
|
|
3613
|
+
if (Number.isFinite(ctx.webServer.port)) lastKnownPort = ctx.webServer.port;
|
|
3614
|
+
const startup = ctx.get("webStartup");
|
|
3615
|
+
const desiredHost = resolvedNow.lanBind === void 0 ? void 0 : desiredBindHost(resolvedNow.lanBind === true, startup?.host);
|
|
3616
|
+
return {
|
|
3617
|
+
profile: resolvedNow.profile,
|
|
3618
|
+
setting: resolvedNow.lanBind ?? null,
|
|
3619
|
+
blockHost: state.host ?? null,
|
|
3620
|
+
bindHost: ctx.webServer.host,
|
|
3621
|
+
port,
|
|
3622
|
+
lanUrls: ctx.webServer.host === "0.0.0.0" && port !== void 0 ? lanIPv4Addresses().map((address) => `http://${address}:${String(port)}`) : [],
|
|
3623
|
+
firewall: port !== void 0 ? firewallSummary(port, lanOn) : {
|
|
3624
|
+
ok: true,
|
|
3625
|
+
managed: false
|
|
3626
|
+
},
|
|
3627
|
+
platform: process.platform,
|
|
3628
|
+
pendingRestart: pendingRestartOf(resolvedNow.lanBind, desiredHost, ctx.webServer.host)
|
|
3629
|
+
};
|
|
3630
|
+
};
|
|
3631
|
+
const innerAuth = createInnerAuth(() => {
|
|
3632
|
+
if (!Number.isFinite(ctx.webServer.port)) return void 0;
|
|
3633
|
+
try {
|
|
3634
|
+
return ctx.connection.authenticatedUrl?.(`http://127.0.0.1:${String(ctx.webServer.port)}/`);
|
|
3635
|
+
} catch {
|
|
3636
|
+
return;
|
|
3637
|
+
}
|
|
3638
|
+
});
|
|
3639
|
+
const APP_SHELL_TTL_MS = 3e4;
|
|
3640
|
+
let appShellCache;
|
|
3641
|
+
const fetchAppShell = async () => {
|
|
3642
|
+
if (!Number.isFinite(ctx.webServer.port)) return void 0;
|
|
3643
|
+
if (appShellCache !== void 0 && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html;
|
|
3644
|
+
const cookie = await innerAuth.ready();
|
|
3645
|
+
try {
|
|
3646
|
+
const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, { headers: cookie !== void 0 ? { cookie } : void 0 });
|
|
3647
|
+
if (!response.ok) return void 0;
|
|
3648
|
+
const html = await response.text();
|
|
3649
|
+
appShellCache = {
|
|
3650
|
+
at: Date.now(),
|
|
3651
|
+
html
|
|
3652
|
+
};
|
|
3653
|
+
return html;
|
|
3654
|
+
} catch {
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
};
|
|
4009
3658
|
const routes = [
|
|
4010
3659
|
...makeRoutes({
|
|
4011
3660
|
service,
|
|
4012
|
-
lanAddresses,
|
|
4013
|
-
requirePairingForLan: () => resolve().requirePairingForLan
|
|
3661
|
+
lanAddresses: service.lanAddresses,
|
|
3662
|
+
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
3663
|
+
lanBindStatus,
|
|
3664
|
+
indexDocument: fetchAppShell
|
|
4014
3665
|
}),
|
|
4015
|
-
...makeMobileRoutes(),
|
|
4016
|
-
...apiProxy !== void 0 ? makeMobileApiRoutes({
|
|
4017
|
-
service,
|
|
4018
|
-
apiProxy,
|
|
4019
|
-
pendingTracker: new PendingTracker(),
|
|
4020
|
-
mobileEnterToSend: () => resolve().mobileEnterToSend,
|
|
4021
|
-
commandDispatcher: ctx.commands !== void 0 && ctx.agents !== void 0 ? { async execute(sessionId, line, signal) {
|
|
4022
|
-
const agent = ctx.agents.get(sessionId);
|
|
4023
|
-
if (agent === void 0) return { error: "session-not-found" };
|
|
4024
|
-
const execution = await ctx.commands.execute(agent, line, [], signal);
|
|
4025
|
-
if (execution === void 0) return { error: "unknown-command" };
|
|
4026
|
-
return {
|
|
4027
|
-
ok: true,
|
|
4028
|
-
result: execution.result
|
|
4029
|
-
};
|
|
4030
|
-
} } : void 0
|
|
4031
|
-
}) : [],
|
|
4032
|
-
...apiProxy !== void 0 ? makePairedModelCatalogRoutes({
|
|
4033
|
-
service,
|
|
4034
|
-
apiProxy,
|
|
4035
|
-
lanAddresses
|
|
4036
|
-
}) : [],
|
|
4037
3666
|
...makeRemoteApiRoutes({
|
|
4038
3667
|
service,
|
|
4039
3668
|
port: ctx.webServer.port,
|
|
4040
|
-
requirePairingForLan: () => resolve().requirePairingForLan
|
|
3669
|
+
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
3670
|
+
auth: innerAuth
|
|
4041
3671
|
}),
|
|
4042
3672
|
...updateRoutes
|
|
4043
3673
|
];
|
|
4044
3674
|
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
4045
3675
|
service,
|
|
4046
3676
|
port: ctx.webServer.port,
|
|
4047
|
-
requirePairingForLan: () => resolve().requirePairingForLan
|
|
3677
|
+
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
3678
|
+
auth: innerAuth
|
|
4048
3679
|
});
|
|
4049
3680
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
|
|
4050
3681
|
ctx.effect(() => ctx.on("api/gate", gate), "remote-web-ui: api gate");
|
|
@@ -4087,13 +3718,50 @@ function applyImpl(ctx, config) {
|
|
|
4087
3718
|
if (!resolve().enabled) return false;
|
|
4088
3719
|
return isPairedDeviceRequest(service, request);
|
|
4089
3720
|
});
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
3721
|
+
const presencePet = startRemotePresencePet({
|
|
3722
|
+
onState: (listener) => service.onState(listener),
|
|
3723
|
+
pet: () => {
|
|
3724
|
+
try {
|
|
3725
|
+
return ctx.get("pet");
|
|
3726
|
+
} catch {
|
|
3727
|
+
return;
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
});
|
|
3731
|
+
ctx.effect(() => presencePet, "remote-web-ui: remote-presence pet visibility");
|
|
3732
|
+
if (service.lanAddresses.length > 0) {
|
|
3733
|
+
const urls = service.lanAddresses.map((ip) => `http://${ip}:${String(ctx.webServer.port)}`).join(" , ");
|
|
3734
|
+
console.log(`remote-web-ui: the paired Web GUI is reachable on LAN at ${urls}`);
|
|
4093
3735
|
}
|
|
3736
|
+
if (ctx.webServer.host === "0.0.0.0") console.warn("remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)");
|
|
4094
3737
|
const sync = () => {
|
|
4095
3738
|
const value = resolve();
|
|
4096
3739
|
service.config = pairingConfigOf(value);
|
|
3740
|
+
if (value.lanBind !== void 0) {
|
|
3741
|
+
const startup = ctx.get("webStartup");
|
|
3742
|
+
const desiredHost = desiredBindHost(value.lanBind === true, startup?.host);
|
|
3743
|
+
const desiredPort = desiredBindPort(startup?.port, Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : void 0);
|
|
3744
|
+
if (desiredPort === void 0) console.error("remote-web-ui: cannot assert the lan-bind block — the web server port is not known yet");
|
|
3745
|
+
else try {
|
|
3746
|
+
const current = lanBindState(value.profile);
|
|
3747
|
+
if (current.host !== desiredHost || current.port !== desiredPort) {
|
|
3748
|
+
writeLanBind(desiredHost, desiredPort, value.profile);
|
|
3749
|
+
console.log(`remote-web-ui: lan-bind block written for profile ${value.profile} (${desiredHost}:${String(desiredPort)}); it takes effect when the profile next applies (the card reports pendingRestart until the running bind follows)`);
|
|
3750
|
+
}
|
|
3751
|
+
} catch (error) {
|
|
3752
|
+
console.error(`remote-web-ui: failed to write the lan-bind block: ${error instanceof Error ? error.message : String(error)}`);
|
|
3753
|
+
}
|
|
3754
|
+
const livePort = Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : void 0;
|
|
3755
|
+
if (livePort === void 0) console.error("remote-web-ui: cannot align the host firewall rule — the web server port is not known yet");
|
|
3756
|
+
else {
|
|
3757
|
+
const nextFirewall = {
|
|
3758
|
+
enabled: value.lanBind === true,
|
|
3759
|
+
port: livePort
|
|
3760
|
+
};
|
|
3761
|
+
if (firewallActionNeeded(lastFirewallApplied, nextFirewall)) if (nextFirewall.enabled ? ensureFirewallRule(nextFirewall.port) : removeFirewallRule(nextFirewall.port)) lastFirewallApplied = nextFirewall;
|
|
3762
|
+
else console.error("remote-web-ui: the host firewall rule could not be updated (admin rights required on managed platforms)");
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
4097
3765
|
autoTunnel = value.autoTunnel === true;
|
|
4098
3766
|
if (autoTunnel) {
|
|
4099
3767
|
if (value.publicBaseUrl !== void 0) console.warn("remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl");
|
|
@@ -4118,7 +3786,7 @@ function applyImpl(ctx, config) {
|
|
|
4118
3786
|
disposeRoutes = void 0;
|
|
4119
3787
|
}
|
|
4120
3788
|
if (disposeSweep === void 0 && enabled) disposeSweep = ctx.effect(() => {
|
|
4121
|
-
const timer = setInterval
|
|
3789
|
+
const timer = setInterval(() => {
|
|
4122
3790
|
service.sweep();
|
|
4123
3791
|
}, SWEEP_INTERVAL_MS);
|
|
4124
3792
|
timer.unref();
|
|
@@ -4148,12 +3816,14 @@ function applyImpl(ctx, config) {
|
|
|
4148
3816
|
text: REMOTE_CHANNEL_BOOT_SCRIPT
|
|
4149
3817
|
});
|
|
4150
3818
|
}), "remote-web-ui: remote channel boot patch");
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
3819
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
3820
|
+
settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
3821
|
+
setSource: (source) => {
|
|
3822
|
+
current = source;
|
|
3823
|
+
sync();
|
|
3824
|
+
},
|
|
3825
|
+
onChange: sync
|
|
3826
|
+
});
|
|
4157
3827
|
});
|
|
4158
3828
|
sync();
|
|
4159
3829
|
}
|