@linxin666/dsh-remote-web-ui 0.2.8 → 0.3.0
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/LICENSE +202 -28
- package/README.i18n.yaml +2 -2
- package/README.md +51 -15
- package/README.zh.md +9 -4
- package/lib/client.js +65 -34
- package/lib/client.js.map +1 -1
- package/lib/index.js +667 -77
- package/lib/mobile.js +874 -64
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +35 -5
- package/lib/types/client/remote-channel.d.ts +4 -5
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +13 -28
- package/lib/types/http.d.ts +37 -8
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +76 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +40 -5
- package/lib/types/mobile/api.d.ts +57 -0
- package/lib/types/mobile/api.d.ts.map +1 -1
- package/lib/types/mobile/api.js +29 -0
- package/lib/types/mobile/markdown.d.ts +7 -1
- package/lib/types/mobile/markdown.d.ts.map +1 -1
- package/lib/types/mobile/markdown.js +16 -7
- package/lib/types/mobile/markdown.test.js +8 -0
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +325 -6
- package/lib/types/mobile/views/ChatView.d.ts +10 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +205 -8
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.stream.test.js +205 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
- package/lib/types/mobile/views/WorkspaceView.js +46 -5
- package/lib/types/mobile/views/WorkspaceView.test.js +129 -2
- package/lib/types/mobile-api.d.ts +9 -3
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +61 -16
- package/lib/types/mobile-pending.d.ts +38 -0
- package/lib/types/mobile-pending.d.ts.map +1 -0
- package/lib/types/mobile-pending.js +59 -0
- package/lib/types/paired-model-catalog.d.ts +17 -0
- package/lib/types/paired-model-catalog.d.ts.map +1 -0
- package/lib/types/paired-model-catalog.js +252 -0
- package/lib/types/pairing.d.ts +13 -4
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +13 -5
- package/lib/types/remote-api.d.ts +13 -4
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +27 -17
- package/lib/types/remote-channel-boot.d.ts +26 -0
- package/lib/types/remote-channel-boot.d.ts.map +1 -0
- package/lib/types/remote-channel-boot.js +128 -0
- package/lib/types/remote-channel-rules.d.ts +40 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -0
- package/lib/types/remote-channel-rules.js +32 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +12 -23
- package/lib/types/uuid-polyfill.d.ts +10 -0
- package/lib/types/uuid-polyfill.d.ts.map +1 -0
- package/lib/types/uuid-polyfill.js +27 -0
- package/package.json +1 -1
- package/src/client/index.ts +41 -5
- package/src/client/remote-channel.ts +18 -28
- package/src/http.ts +87 -12
- package/src/index.ts +42 -6
- package/src/mobile/api.ts +91 -0
- package/src/mobile/markdown.test.ts +9 -0
- package/src/mobile/markdown.ts +17 -7
- package/src/mobile/mobile-styles.ts +326 -6
- package/src/mobile/views/ChatView.stream.test.tsx +231 -0
- package/src/mobile/views/ChatView.tsx +328 -10
- package/src/mobile/views/WorkspaceView.test.tsx +163 -2
- package/src/mobile/views/WorkspaceView.tsx +152 -26
- package/src/mobile-api.ts +59 -17
- package/src/mobile-pending.ts +88 -0
- package/src/paired-model-catalog.ts +224 -0
- package/src/pairing.ts +13 -5
- package/src/remote-api.ts +34 -18
- package/src/remote-channel-boot.ts +131 -0
- package/src/remote-channel-rules.ts +62 -0
- package/src/routes.ts +13 -24
- package/src/uuid-polyfill.ts +28 -0
package/lib/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { spawn } from "node:child_process";
|
|
|
24
24
|
* @module @deepseek-ai/dsh-settings/redact
|
|
25
25
|
*/
|
|
26
26
|
/** Whether a value is a plain data object the walker may recurse into. */
|
|
27
|
-
function isRecord(value) {
|
|
27
|
+
function isRecord$1(value) {
|
|
28
28
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
29
29
|
}
|
|
30
30
|
function walk(node, value, path, secrets) {
|
|
@@ -39,7 +39,7 @@ function walk(node, value, path, secrets) {
|
|
|
39
39
|
switch (node.type) {
|
|
40
40
|
case "object": {
|
|
41
41
|
const properties = node.dict ?? {};
|
|
42
|
-
const source = isRecord(value) ? value : void 0;
|
|
42
|
+
const source = isRecord$1(value) ? value : void 0;
|
|
43
43
|
const rebuilt = {};
|
|
44
44
|
if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
|
|
45
45
|
if (key in properties) continue;
|
|
@@ -52,7 +52,7 @@ function walk(node, value, path, secrets) {
|
|
|
52
52
|
return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
|
|
53
53
|
}
|
|
54
54
|
case "dict": {
|
|
55
|
-
if (!isRecord(value)) return value;
|
|
55
|
+
if (!isRecord$1(value)) return value;
|
|
56
56
|
const rebuilt = {};
|
|
57
57
|
for (const [key, entry] of Object.entries(value)) {
|
|
58
58
|
const stripped = walk(node.inner, entry, [...path, key], secrets);
|
|
@@ -250,10 +250,13 @@ const defaultClock = {
|
|
|
250
250
|
randomToken: () => randomBytes(16).toString("hex")
|
|
251
251
|
};
|
|
252
252
|
/**
|
|
253
|
-
* The pairing state machine.
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
253
|
+
* The pairing state machine. Structural mutations issue/accept/stop/revoke
|
|
254
|
+
* and config updates (LAN bases, tunnel, posture) notify state listeners
|
|
255
|
+
* after the commit point that makes them true, and notification dedupes
|
|
256
|
+
* against the last emitted snapshot. Presence-only updates (touchDevice /
|
|
257
|
+
* heartbeat) just mark the store dirty and broadcast on the next sweep,
|
|
258
|
+
* which also surfaces time-driven transitions (a device aging offline)
|
|
259
|
+
* without any mutation.
|
|
257
260
|
*/
|
|
258
261
|
var PairingService = class {
|
|
259
262
|
config;
|
|
@@ -491,6 +494,12 @@ var PairingService = class {
|
|
|
491
494
|
* The api/gate path: record activity for a device id and report whether
|
|
492
495
|
* the request may proceed. Unknown or revoked ids (including any device
|
|
493
496
|
* after stop() or idle expiry) are refused.
|
|
497
|
+
*
|
|
498
|
+
* Presence refreshes are throttled on purpose: every gated request (and
|
|
499
|
+
* the mobile SSE keepalive) lands here, so a broadcast per call would fan
|
|
500
|
+
* a full snapshot out to every status stream on the hot path. The refresh
|
|
501
|
+
* only updates lastSeenAt and marks the store dirty; the snapshot reaches
|
|
502
|
+
* listeners at the next sweep() (structural changes notify immediately).
|
|
494
503
|
* @param deviceId - the cookie value of the requesting device.
|
|
495
504
|
* @returns true when the device session is live and was refreshed.
|
|
496
505
|
*/
|
|
@@ -499,7 +508,6 @@ var PairingService = class {
|
|
|
499
508
|
if (session === void 0) return false;
|
|
500
509
|
session.lastSeenAt = this.clock.now();
|
|
501
510
|
this.dirty = true;
|
|
502
|
-
this.notify();
|
|
503
511
|
return true;
|
|
504
512
|
}
|
|
505
513
|
/** Explicit presence heartbeat (the phone's client sends these). */
|
|
@@ -793,18 +801,18 @@ var RemoteWebUiPairing = class extends Service {
|
|
|
793
801
|
};
|
|
794
802
|
//#endregion
|
|
795
803
|
//#region src/http.ts
|
|
796
|
-
/**
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
res.end(payload);
|
|
804
|
-
}
|
|
804
|
+
/** Default body cap for readJsonBody: 64 KiB. */
|
|
805
|
+
const DEFAULT_JSON_BODY_MAX_BYTES = 64 * 1024;
|
|
806
|
+
/** Family-default JSON response headers; callers may append or override. */
|
|
807
|
+
const JSON_HEADERS = {
|
|
808
|
+
"content-type": "application/json; charset=utf-8",
|
|
809
|
+
"referrer-policy": "no-referrer"
|
|
810
|
+
};
|
|
805
811
|
/**
|
|
806
|
-
*
|
|
807
|
-
*
|
|
812
|
+
* Strict bounded body reader: parse a request body of at most maxBytes as
|
|
813
|
+
* JSON.
|
|
814
|
+
* @throws 'body too large' past the cap, or the JSON.parse error for an
|
|
815
|
+
* invalid or empty payload.
|
|
808
816
|
*/
|
|
809
817
|
async function readBoundedJson(req, maxBytes) {
|
|
810
818
|
const chunks = [];
|
|
@@ -817,6 +825,54 @@ async function readBoundedJson(req, maxBytes) {
|
|
|
817
825
|
}
|
|
818
826
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
819
827
|
}
|
|
828
|
+
/**
|
|
829
|
+
* Lenient bounded body reader: parse a request body as JSON, or null on an
|
|
830
|
+
* empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
|
|
831
|
+
* Overflow destroys the request instead of draining the remainder (no drain
|
|
832
|
+
* call, matching the current repo-wide behavior); callers must not keep
|
|
833
|
+
* reading the request afterwards. With objectOnly, non-JSON-object payloads
|
|
834
|
+
* also yield null.
|
|
835
|
+
*/
|
|
836
|
+
async function readJsonBody(req, opts = {}) {
|
|
837
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_JSON_BODY_MAX_BYTES;
|
|
838
|
+
const chunks = [];
|
|
839
|
+
let size = 0;
|
|
840
|
+
for await (const chunk of req) {
|
|
841
|
+
const buffer = chunk;
|
|
842
|
+
size += buffer.length;
|
|
843
|
+
if (size > maxBytes) {
|
|
844
|
+
req.destroy();
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
chunks.push(buffer);
|
|
848
|
+
}
|
|
849
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
850
|
+
if (text === "") return null;
|
|
851
|
+
try {
|
|
852
|
+
const parsed = JSON.parse(text);
|
|
853
|
+
if (opts.objectOnly && !isJsonObject(parsed)) return null;
|
|
854
|
+
return parsed;
|
|
855
|
+
} catch {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/** Whether a value is a JSON object: typeof object, not null, not an array. */
|
|
860
|
+
function isJsonObject(value) {
|
|
861
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Write one JSON response. Default headers are the family defaults
|
|
865
|
+
* (content-type and referrer-policy); caller headers are appended or
|
|
866
|
+
* override them.
|
|
867
|
+
*/
|
|
868
|
+
function writeJson(res, status, body, headers = {}) {
|
|
869
|
+
const payload = JSON.stringify(body);
|
|
870
|
+
res.writeHead(status, {
|
|
871
|
+
...JSON_HEADERS,
|
|
872
|
+
...headers
|
|
873
|
+
});
|
|
874
|
+
res.end(payload);
|
|
875
|
+
}
|
|
820
876
|
//#endregion
|
|
821
877
|
//#region src/routes.ts
|
|
822
878
|
/**
|
|
@@ -857,7 +913,7 @@ function isTrustedApiRequest(request, trustedHosts) {
|
|
|
857
913
|
}
|
|
858
914
|
}
|
|
859
915
|
/** Cap on pairing request bodies (tokens and workspace ids are tiny). */
|
|
860
|
-
const MAX_BODY_BYTES = 4096;
|
|
916
|
+
const MAX_BODY_BYTES$1 = 4096;
|
|
861
917
|
/**
|
|
862
918
|
* The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
|
|
863
919
|
* from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
|
|
@@ -902,24 +958,16 @@ const acceptPayloadSchema = z$1.object({ token: z$1.string().default("") });
|
|
|
902
958
|
const revokePayloadSchema = z$1.object({ deviceId: z$1.string().min(1) });
|
|
903
959
|
const pairActionPayloadSchema = z$1.object({}).passthrough();
|
|
904
960
|
/**
|
|
905
|
-
* Parse a pair request body through schema. A missing/empty
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
961
|
+
* Parse a pair request body through schema. A missing/empty, unparseable
|
|
962
|
+
* or non-object body (shared readJsonBody with objectOnly yields null for
|
|
963
|
+
* all of them) is treated as an empty object — the desktop stop/heartbeat
|
|
964
|
+
* send no body — and a value that fails the schema returns `undefined` so
|
|
965
|
+
* the caller can answer with the existing error shape.
|
|
909
966
|
*/
|
|
910
967
|
function parsePairPayload(schema, body) {
|
|
911
968
|
const result = schema.safeParse(body ?? {});
|
|
912
969
|
return result.success ? result.data : void 0;
|
|
913
970
|
}
|
|
914
|
-
/** Read a request body up to MAX_BODY_BYTES and parse it as JSON (undefined on failure). */
|
|
915
|
-
async function readJsonBody$1(req) {
|
|
916
|
-
try {
|
|
917
|
-
const parsed = await readBoundedJson(req, MAX_BODY_BYTES);
|
|
918
|
-
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
919
|
-
} catch {
|
|
920
|
-
return;
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
971
|
/** The SSE fan-out for desktop panel status. */
|
|
924
972
|
var PairingEventsStream = class {
|
|
925
973
|
streams = /* @__PURE__ */ new Set();
|
|
@@ -1023,7 +1071,10 @@ function makeRoutes(deps) {
|
|
|
1023
1071
|
});
|
|
1024
1072
|
return;
|
|
1025
1073
|
}
|
|
1026
|
-
const body = await readJsonBody
|
|
1074
|
+
const body = await readJsonBody(req, {
|
|
1075
|
+
maxBytes: MAX_BODY_BYTES$1,
|
|
1076
|
+
objectOnly: true
|
|
1077
|
+
});
|
|
1027
1078
|
const payload = parsePairPayload(issuePayloadSchema, body);
|
|
1028
1079
|
if (payload === void 0) {
|
|
1029
1080
|
writeJson(res, 400, {
|
|
@@ -1069,7 +1120,10 @@ function makeRoutes(deps) {
|
|
|
1069
1120
|
});
|
|
1070
1121
|
return;
|
|
1071
1122
|
}
|
|
1072
|
-
const body = await readJsonBody
|
|
1123
|
+
const body = await readJsonBody(req, {
|
|
1124
|
+
maxBytes: MAX_BODY_BYTES$1,
|
|
1125
|
+
objectOnly: true
|
|
1126
|
+
});
|
|
1073
1127
|
const payload = parsePairPayload(acceptPayloadSchema, body);
|
|
1074
1128
|
if (payload === void 0) {
|
|
1075
1129
|
writeJson(res, 400, {
|
|
@@ -1087,14 +1141,10 @@ function makeRoutes(deps) {
|
|
|
1087
1141
|
});
|
|
1088
1142
|
return;
|
|
1089
1143
|
}
|
|
1090
|
-
res
|
|
1091
|
-
"content-type": "application/json; charset=utf-8",
|
|
1092
|
-
"set-cookie": [`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`]
|
|
1093
|
-
});
|
|
1094
|
-
res.end(JSON.stringify({
|
|
1144
|
+
writeJson(res, 200, {
|
|
1095
1145
|
ok: true,
|
|
1096
1146
|
deviceId: result.deviceId
|
|
1097
|
-
}));
|
|
1147
|
+
}, { "set-cookie": [`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`] });
|
|
1098
1148
|
};
|
|
1099
1149
|
const handleStop = async (req, res) => {
|
|
1100
1150
|
if (!requireMethod(req, res, "POST")) return;
|
|
@@ -1105,7 +1155,10 @@ function makeRoutes(deps) {
|
|
|
1105
1155
|
});
|
|
1106
1156
|
return;
|
|
1107
1157
|
}
|
|
1108
|
-
const body = await readJsonBody
|
|
1158
|
+
const body = await readJsonBody(req, {
|
|
1159
|
+
maxBytes: MAX_BODY_BYTES$1,
|
|
1160
|
+
objectOnly: true
|
|
1161
|
+
});
|
|
1109
1162
|
if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
|
|
1110
1163
|
writeJson(res, 400, {
|
|
1111
1164
|
ok: false,
|
|
@@ -1125,7 +1178,10 @@ function makeRoutes(deps) {
|
|
|
1125
1178
|
});
|
|
1126
1179
|
return;
|
|
1127
1180
|
}
|
|
1128
|
-
const body = await readJsonBody
|
|
1181
|
+
const body = await readJsonBody(req, {
|
|
1182
|
+
maxBytes: MAX_BODY_BYTES$1,
|
|
1183
|
+
objectOnly: true
|
|
1184
|
+
});
|
|
1129
1185
|
const payload = parsePairPayload(revokePayloadSchema, body);
|
|
1130
1186
|
if (payload === void 0) {
|
|
1131
1187
|
writeJson(res, 400, {
|
|
@@ -1152,7 +1208,10 @@ function makeRoutes(deps) {
|
|
|
1152
1208
|
});
|
|
1153
1209
|
return;
|
|
1154
1210
|
}
|
|
1155
|
-
const body = await readJsonBody
|
|
1211
|
+
const body = await readJsonBody(req, {
|
|
1212
|
+
maxBytes: MAX_BODY_BYTES$1,
|
|
1213
|
+
objectOnly: true
|
|
1214
|
+
});
|
|
1156
1215
|
if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
|
|
1157
1216
|
writeJson(res, 400, {
|
|
1158
1217
|
ok: false,
|
|
@@ -1487,6 +1546,8 @@ function makeMobileRoutes() {
|
|
|
1487
1546
|
* device at a time.
|
|
1488
1547
|
*/
|
|
1489
1548
|
const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
1549
|
+
"host.listDirectory",
|
|
1550
|
+
"workspace.create",
|
|
1490
1551
|
"workspace.list",
|
|
1491
1552
|
"agentPreset.list",
|
|
1492
1553
|
"session.create",
|
|
@@ -1504,6 +1565,8 @@ const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
|
|
|
1504
1565
|
* settings-domain write).
|
|
1505
1566
|
*/
|
|
1506
1567
|
const MOBILE_PREFERENCES_METHOD = "mobile.preferences";
|
|
1568
|
+
const MOBILE_PENDING_METHOD = "mobile.pending";
|
|
1569
|
+
const MOBILE_RESPOND_METHOD = "mobile.respond";
|
|
1507
1570
|
/** One session.list page (thin phones load incrementally). */
|
|
1508
1571
|
const SESSION_PAGE_SIZE = 20;
|
|
1509
1572
|
/** SSE keep-alive ping cadence for the live mux stream (single connection). */
|
|
@@ -1586,7 +1649,7 @@ function makeMobileApiRoutes(deps) {
|
|
|
1586
1649
|
return;
|
|
1587
1650
|
}
|
|
1588
1651
|
const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length);
|
|
1589
|
-
const local = method === MOBILE_PREFERENCES_METHOD;
|
|
1652
|
+
const local = method === MOBILE_PREFERENCES_METHOD || method === MOBILE_PENDING_METHOD || method === MOBILE_RESPOND_METHOD;
|
|
1590
1653
|
if (!MOBILE_ALLOWLIST.has(method) && !local) {
|
|
1591
1654
|
writeJson(res, 403, {
|
|
1592
1655
|
ok: false,
|
|
@@ -1599,7 +1662,7 @@ function makeMobileApiRoutes(deps) {
|
|
|
1599
1662
|
}
|
|
1600
1663
|
let envelope;
|
|
1601
1664
|
try {
|
|
1602
|
-
envelope = await
|
|
1665
|
+
envelope = await readBoundedJson(req, 64 * 1024);
|
|
1603
1666
|
} catch {
|
|
1604
1667
|
writeJson(res, 400, {
|
|
1605
1668
|
ok: false,
|
|
@@ -1623,7 +1686,7 @@ function makeMobileApiRoutes(deps) {
|
|
|
1623
1686
|
return;
|
|
1624
1687
|
}
|
|
1625
1688
|
if (local) {
|
|
1626
|
-
writeJson(res, 200, {
|
|
1689
|
+
if (method === MOBILE_PREFERENCES_METHOD) writeJson(res, 200, {
|
|
1627
1690
|
type: "server-response",
|
|
1628
1691
|
rpcId,
|
|
1629
1692
|
result: {
|
|
@@ -1631,6 +1694,48 @@ function makeMobileApiRoutes(deps) {
|
|
|
1631
1694
|
value: { mobileEnterToSend: mobileEnterToSend() }
|
|
1632
1695
|
}
|
|
1633
1696
|
});
|
|
1697
|
+
else if (method === MOBILE_PENDING_METHOD) {
|
|
1698
|
+
const payload = parsed.payload;
|
|
1699
|
+
writeJson(res, 200, {
|
|
1700
|
+
type: "server-response",
|
|
1701
|
+
rpcId,
|
|
1702
|
+
result: {
|
|
1703
|
+
ok: true,
|
|
1704
|
+
value: deps.pendingTracker.pending(payload?.sessionId)
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
} else if (method === MOBILE_RESPOND_METHOD) {
|
|
1708
|
+
const payload = parsed.payload;
|
|
1709
|
+
try {
|
|
1710
|
+
writeJson(res, 200, {
|
|
1711
|
+
type: "server-response",
|
|
1712
|
+
rpcId,
|
|
1713
|
+
result: {
|
|
1714
|
+
ok: true,
|
|
1715
|
+
value: await apiProxy.respond({
|
|
1716
|
+
type: "client-response",
|
|
1717
|
+
rpcId: RpcId(payload.rpcId),
|
|
1718
|
+
result: {
|
|
1719
|
+
ok: true,
|
|
1720
|
+
value: payload.response
|
|
1721
|
+
}
|
|
1722
|
+
})
|
|
1723
|
+
}
|
|
1724
|
+
});
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
writeJson(res, 200, {
|
|
1727
|
+
type: "server-response",
|
|
1728
|
+
rpcId,
|
|
1729
|
+
result: {
|
|
1730
|
+
ok: false,
|
|
1731
|
+
error: {
|
|
1732
|
+
code: "internal",
|
|
1733
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1634
1739
|
return;
|
|
1635
1740
|
}
|
|
1636
1741
|
try {
|
|
@@ -1661,8 +1766,13 @@ function makeMobileApiRoutes(deps) {
|
|
|
1661
1766
|
return;
|
|
1662
1767
|
}
|
|
1663
1768
|
if (!gateOk(req)) {
|
|
1664
|
-
res
|
|
1665
|
-
|
|
1769
|
+
writeJson(res, 403, {
|
|
1770
|
+
ok: false,
|
|
1771
|
+
error: {
|
|
1772
|
+
code: "unpaired",
|
|
1773
|
+
message: "mobile session is not paired"
|
|
1774
|
+
}
|
|
1775
|
+
});
|
|
1666
1776
|
return;
|
|
1667
1777
|
}
|
|
1668
1778
|
res.writeHead(200, {
|
|
@@ -1694,6 +1804,7 @@ function makeMobileApiRoutes(deps) {
|
|
|
1694
1804
|
}, controller.signal);
|
|
1695
1805
|
for await (const frame of frames) {
|
|
1696
1806
|
if (closed) break;
|
|
1807
|
+
deps.pendingTracker.onFrame(frame);
|
|
1697
1808
|
res.write(`data: ${JSON.stringify(frame)}\n\n`);
|
|
1698
1809
|
}
|
|
1699
1810
|
} catch {} finally {
|
|
@@ -1712,10 +1823,6 @@ function makeMobileApiRoutes(deps) {
|
|
|
1712
1823
|
handler: handleEvents
|
|
1713
1824
|
}];
|
|
1714
1825
|
}
|
|
1715
|
-
/** Read a request body as JSON (bounded). */
|
|
1716
|
-
async function readJsonBody(req) {
|
|
1717
|
-
return readBoundedJson(req, 64 * 1024);
|
|
1718
|
-
}
|
|
1719
1826
|
/** Dispatch one allowlisted method through the host ApiProxy. */
|
|
1720
1827
|
async function dispatch(apiProxy, method, payload, rpcId, signal) {
|
|
1721
1828
|
const request = {
|
|
@@ -1757,6 +1864,8 @@ async function dispatch(apiProxy, method, payload, rpcId, signal) {
|
|
|
1757
1864
|
result: response.result
|
|
1758
1865
|
});
|
|
1759
1866
|
if (method === "workspace.list") return wrap(await apiProxy.workspace.list(request));
|
|
1867
|
+
if (method === "workspace.create") return wrap(await apiProxy.workspace.create(request));
|
|
1868
|
+
if (method === "host.listDirectory") return wrap(await apiProxy.host.listDirectory(request, signal ?? new AbortController().signal));
|
|
1760
1869
|
if (method === "agentPreset.list") return wrap(await apiProxy.agentPresets.list(request));
|
|
1761
1870
|
if (method === "session.create") return wrap(await apiProxy.sessions.create(request));
|
|
1762
1871
|
if (method === "session.history") return wrap(await apiProxy.sessions.history(request));
|
|
@@ -1768,6 +1877,379 @@ async function dispatch(apiProxy, method, payload, rpcId, signal) {
|
|
|
1768
1877
|
throw new Error(`unhandled allowlisted method ${method}`);
|
|
1769
1878
|
}
|
|
1770
1879
|
//#endregion
|
|
1880
|
+
//#region src/mobile-pending.ts
|
|
1881
|
+
var PendingTracker = class {
|
|
1882
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1883
|
+
/** Process one mux frame and update the pending state. */
|
|
1884
|
+
onFrame(frame) {
|
|
1885
|
+
const payload = frame.payload;
|
|
1886
|
+
if (payload.type === "approval/requested") this._getOrInit(payload.sessionId).approvals.set(payload.approvalId, {
|
|
1887
|
+
rpcId: frame.rpcId,
|
|
1888
|
+
approvalId: payload.approvalId,
|
|
1889
|
+
toolName: payload.toolName,
|
|
1890
|
+
callId: payload.callId,
|
|
1891
|
+
reason: payload.reason
|
|
1892
|
+
});
|
|
1893
|
+
else if (payload.type === "approval/resolved") {
|
|
1894
|
+
const state = this.sessions.get(payload.sessionId);
|
|
1895
|
+
if (state) state.approvals.delete(payload.approvalId);
|
|
1896
|
+
} else if (payload.type === "question/requested") this._getOrInit(payload.sessionId).questions.push({
|
|
1897
|
+
rpcId: frame.rpcId,
|
|
1898
|
+
questions: payload.questions
|
|
1899
|
+
});
|
|
1900
|
+
else if (payload.type === "question/resolved") {
|
|
1901
|
+
const state = this.sessions.get(payload.sessionId);
|
|
1902
|
+
if (state) state.questions = state.questions.filter((q) => q.rpcId !== payload.questionRpcId);
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
_getOrInit(sessionId) {
|
|
1906
|
+
let state = this.sessions.get(sessionId);
|
|
1907
|
+
if (!state) {
|
|
1908
|
+
state = {
|
|
1909
|
+
approvals: /* @__PURE__ */ new Map(),
|
|
1910
|
+
questions: []
|
|
1911
|
+
};
|
|
1912
|
+
this.sessions.set(sessionId, state);
|
|
1913
|
+
}
|
|
1914
|
+
return state;
|
|
1915
|
+
}
|
|
1916
|
+
/** Query pending items for a session. */
|
|
1917
|
+
pending(sessionId) {
|
|
1918
|
+
const state = this.sessions.get(sessionId);
|
|
1919
|
+
if (!state) return {
|
|
1920
|
+
approvals: [],
|
|
1921
|
+
questions: []
|
|
1922
|
+
};
|
|
1923
|
+
return {
|
|
1924
|
+
approvals: Array.from(state.approvals.values()),
|
|
1925
|
+
questions: state.questions
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
/** Clear all pending state for a session. */
|
|
1929
|
+
clear(sessionId) {
|
|
1930
|
+
this.sessions.delete(sessionId);
|
|
1931
|
+
}
|
|
1932
|
+
};
|
|
1933
|
+
//#endregion
|
|
1934
|
+
//#region src/paired-model-catalog.ts
|
|
1935
|
+
const MAX_BODY_BYTES = 16 * 1024;
|
|
1936
|
+
const MAX_IDENTIFIER_LENGTH = 160;
|
|
1937
|
+
const MAX_DISPLAY_NAME_LENGTH = 240;
|
|
1938
|
+
const MAX_TOKEN_LIMIT = 1e7;
|
|
1939
|
+
const REASONING_EFFORTS = [
|
|
1940
|
+
"off",
|
|
1941
|
+
"minimal",
|
|
1942
|
+
"low",
|
|
1943
|
+
"medium",
|
|
1944
|
+
"high",
|
|
1945
|
+
"xhigh",
|
|
1946
|
+
"max"
|
|
1947
|
+
];
|
|
1948
|
+
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");
|
|
1949
|
+
const providerSchema = z$1.object({ provider: stringField(MAX_IDENTIFIER_LENGTH) }).strict();
|
|
1950
|
+
const modelSchema = z$1.object({
|
|
1951
|
+
id: stringField(MAX_IDENTIFIER_LENGTH),
|
|
1952
|
+
name: stringField(MAX_DISPLAY_NAME_LENGTH).optional(),
|
|
1953
|
+
contextWindow: z$1.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
|
|
1954
|
+
maxTokens: z$1.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
|
|
1955
|
+
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()
|
|
1956
|
+
}).strict();
|
|
1957
|
+
const upsertSchema = z$1.object({
|
|
1958
|
+
provider: stringField(MAX_IDENTIFIER_LENGTH),
|
|
1959
|
+
model: modelSchema
|
|
1960
|
+
}).strict();
|
|
1961
|
+
const PAIRED_MODEL_CATALOG_PATHS = {
|
|
1962
|
+
catalog: "/api/pair/model-catalog",
|
|
1963
|
+
discover: "/api/pair/model-catalog/discover",
|
|
1964
|
+
upsert: "/api/pair/model-catalog/upsert"
|
|
1965
|
+
};
|
|
1966
|
+
let rpcSequence = 0;
|
|
1967
|
+
function request$1(payload) {
|
|
1968
|
+
rpcSequence += 1;
|
|
1969
|
+
return {
|
|
1970
|
+
rpcId: `paired-model-catalog-${Date.now().toString(36)}-${rpcSequence.toString(36)}`,
|
|
1971
|
+
payload
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
function isRecord(value) {
|
|
1975
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1976
|
+
}
|
|
1977
|
+
function valueOf(response) {
|
|
1978
|
+
return response.result.ok ? response.result.value : void 0;
|
|
1979
|
+
}
|
|
1980
|
+
function errorCode(response) {
|
|
1981
|
+
return response.result.ok ? void 0 : response.result.error.code;
|
|
1982
|
+
}
|
|
1983
|
+
function reject(res, status, error) {
|
|
1984
|
+
writeJson(res, status, { error });
|
|
1985
|
+
}
|
|
1986
|
+
function publicProviderId(value) {
|
|
1987
|
+
const normalized = value.replace(/[\r\n\0]/g, " ").trim().slice(0, MAX_IDENTIFIER_LENGTH);
|
|
1988
|
+
return normalized === "" ? "unknown" : normalized;
|
|
1989
|
+
}
|
|
1990
|
+
function publicCatalog(value) {
|
|
1991
|
+
return {
|
|
1992
|
+
groups: value.groups.map((group) => ({
|
|
1993
|
+
id: group.id,
|
|
1994
|
+
name: group.name,
|
|
1995
|
+
models: group.models.map((model) => ({
|
|
1996
|
+
id: model.id,
|
|
1997
|
+
name: model.name,
|
|
1998
|
+
...model.description === void 0 ? {} : { description: model.description },
|
|
1999
|
+
...model.reasoning === void 0 ? {} : { reasoning: model.reasoning }
|
|
2000
|
+
}))
|
|
2001
|
+
})),
|
|
2002
|
+
failures: value.failures.map((failure) => ({
|
|
2003
|
+
id: failure.id,
|
|
2004
|
+
name: failure.name,
|
|
2005
|
+
message: `model catalog unavailable for provider ${publicProviderId(failure.id)}`
|
|
2006
|
+
}))
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
function publicCandidates(models) {
|
|
2010
|
+
return models.map((model) => ({
|
|
2011
|
+
id: model.id,
|
|
2012
|
+
...model.name === void 0 ? {} : { name: model.name },
|
|
2013
|
+
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
|
|
2014
|
+
...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }
|
|
2015
|
+
}));
|
|
2016
|
+
}
|
|
2017
|
+
function fieldsOf(model, includeId) {
|
|
2018
|
+
const fields = {
|
|
2019
|
+
...includeId ? { id: model.id } : {},
|
|
2020
|
+
...model.name === void 0 ? {} : { name: model.name },
|
|
2021
|
+
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
|
|
2022
|
+
...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }
|
|
2023
|
+
};
|
|
2024
|
+
if (model.reasoningEfforts !== void 0) fields.reasoningEfforts = Object.fromEntries(model.reasoningEfforts.map((effort) => [effort, effort === "off" ? null : effort]));
|
|
2025
|
+
return fields;
|
|
2026
|
+
}
|
|
2027
|
+
function groupFor(catalog, provider) {
|
|
2028
|
+
return catalog.groups.find((group) => group.id === provider);
|
|
2029
|
+
}
|
|
2030
|
+
async function readCatalog(apiProxy) {
|
|
2031
|
+
try {
|
|
2032
|
+
return valueOf(await apiProxy.llm.models(request$1({})));
|
|
2033
|
+
} catch {
|
|
2034
|
+
return;
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
/** Resolve one exact active llm-pi-ai provider address, never an input-supplied path. */
|
|
2038
|
+
async function eligibleProvider(apiProxy, requested) {
|
|
2039
|
+
let providersResponse;
|
|
2040
|
+
let settingsResponse;
|
|
2041
|
+
try {
|
|
2042
|
+
providersResponse = await apiProxy.llm.providers(request$1({}));
|
|
2043
|
+
settingsResponse = await apiProxy.settings.describe(request$1({}));
|
|
2044
|
+
} catch {
|
|
2045
|
+
return {
|
|
2046
|
+
status: 502,
|
|
2047
|
+
error: "model catalog capability is unavailable"
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
const providers = valueOf(providersResponse);
|
|
2051
|
+
const settings = valueOf(settingsResponse);
|
|
2052
|
+
if (providers === void 0 || settings === void 0) return {
|
|
2053
|
+
status: 502,
|
|
2054
|
+
error: "model catalog capability is unavailable"
|
|
2055
|
+
};
|
|
2056
|
+
const route = providers.providers.find((candidate) => candidate.provider === requested);
|
|
2057
|
+
if (route === void 0) return {
|
|
2058
|
+
status: 404,
|
|
2059
|
+
error: `unknown provider ${requested}`
|
|
2060
|
+
};
|
|
2061
|
+
if (!settings.writable || !route.active || route.settingsNs !== "llm-pi-ai" || route.settingsPath.length !== 2 || route.settingsPath[0] !== "providers" || route.settingsPath[1] !== requested) return {
|
|
2062
|
+
status: 403,
|
|
2063
|
+
error: `provider ${requested} is not eligible for the paired model catalog`
|
|
2064
|
+
};
|
|
2065
|
+
const namespace = settings.namespaces.find((candidate) => candidate.ns === "llm-pi-ai");
|
|
2066
|
+
const root = namespace !== void 0 && isRecord(namespace.value) ? namespace.value : void 0;
|
|
2067
|
+
const providersValue = root !== void 0 && isRecord(root.providers) ? root.providers : void 0;
|
|
2068
|
+
const profile = providersValue !== void 0 && isRecord(providersValue[requested]) ? providersValue[requested] : void 0;
|
|
2069
|
+
if (namespace === void 0 || profile === void 0) return {
|
|
2070
|
+
status: 403,
|
|
2071
|
+
error: `provider ${requested} is not eligible for the paired model catalog`
|
|
2072
|
+
};
|
|
2073
|
+
return { provider: {
|
|
2074
|
+
provider: requested,
|
|
2075
|
+
displayName: route.displayName,
|
|
2076
|
+
namespace,
|
|
2077
|
+
profile
|
|
2078
|
+
} };
|
|
2079
|
+
}
|
|
2080
|
+
function updatedModels(existing, model) {
|
|
2081
|
+
const next = existing.map((entry) => isRecord(entry) ? { ...entry } : entry);
|
|
2082
|
+
const index = next.findIndex((entry) => isRecord(entry) && entry.id === model.id);
|
|
2083
|
+
const patch = fieldsOf(model, true);
|
|
2084
|
+
if (index >= 0 && isRecord(next[index])) next[index] = {
|
|
2085
|
+
...next[index],
|
|
2086
|
+
...patch
|
|
2087
|
+
};
|
|
2088
|
+
else next.push(patch);
|
|
2089
|
+
return next;
|
|
2090
|
+
}
|
|
2091
|
+
function mutationPlan(profile, provider, model, catalog) {
|
|
2092
|
+
if (Array.isArray(profile.models)) return {
|
|
2093
|
+
path: [
|
|
2094
|
+
"providers",
|
|
2095
|
+
provider,
|
|
2096
|
+
"models"
|
|
2097
|
+
],
|
|
2098
|
+
value: updatedModels(profile.models, model)
|
|
2099
|
+
};
|
|
2100
|
+
const group = groupFor(catalog, provider);
|
|
2101
|
+
if (group?.models.some((entry) => entry.id === model.id) === true) {
|
|
2102
|
+
const current = (isRecord(profile.modelOverrides) ? profile.modelOverrides : void 0)?.[model.id];
|
|
2103
|
+
const existing = isRecord(current) ? current : {};
|
|
2104
|
+
return {
|
|
2105
|
+
path: [
|
|
2106
|
+
"providers",
|
|
2107
|
+
provider,
|
|
2108
|
+
"modelOverrides",
|
|
2109
|
+
model.id
|
|
2110
|
+
],
|
|
2111
|
+
value: {
|
|
2112
|
+
...existing,
|
|
2113
|
+
...fieldsOf(model, false)
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
if (group === void 0 || catalog.failures.some((failure) => failure.id === provider)) return void 0;
|
|
2118
|
+
return {
|
|
2119
|
+
path: [
|
|
2120
|
+
"providers",
|
|
2121
|
+
provider,
|
|
2122
|
+
"models"
|
|
2123
|
+
],
|
|
2124
|
+
value: [...group.models.map((entry) => ({ id: entry.id })), fieldsOf(model, true)]
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
function paired(req, service) {
|
|
2128
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
2129
|
+
return deviceId !== void 0 && service.touchDevice(deviceId);
|
|
2130
|
+
}
|
|
2131
|
+
async function bodyOf(req, schema) {
|
|
2132
|
+
try {
|
|
2133
|
+
return schema.parse(await readBoundedJson(req, MAX_BODY_BYTES));
|
|
2134
|
+
} catch {
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
/** Build the narrow paired-device model catalog routes. */
|
|
2139
|
+
function makePairedModelCatalogRoutes(deps) {
|
|
2140
|
+
const { service, apiProxy, lanAddresses } = deps;
|
|
2141
|
+
/** Same fence as the /api/pair family: loopback, the advertised LAN literals, or the configured public host. */
|
|
2142
|
+
const fence = (req) => {
|
|
2143
|
+
const publicHost = publicHostOf(service.publicBaseUrl);
|
|
2144
|
+
return isTrustedApiRequest(req, publicHost === void 0 ? lanAddresses : [...lanAddresses, publicHost]);
|
|
2145
|
+
};
|
|
2146
|
+
const gate = (req, res) => {
|
|
2147
|
+
if (!fence(req)) {
|
|
2148
|
+
req.resume();
|
|
2149
|
+
reject(res, 403, "paired model catalog is not reachable for this origin");
|
|
2150
|
+
return false;
|
|
2151
|
+
}
|
|
2152
|
+
if (!paired(req, service)) {
|
|
2153
|
+
req.resume();
|
|
2154
|
+
reject(res, 403, "paired model catalog requires a live paired device");
|
|
2155
|
+
return false;
|
|
2156
|
+
}
|
|
2157
|
+
return true;
|
|
2158
|
+
};
|
|
2159
|
+
const catalog = async (req, res) => {
|
|
2160
|
+
if (!gate(req, res)) return;
|
|
2161
|
+
if (req.method !== "GET") return reject(res, 405, "method not allowed");
|
|
2162
|
+
let providersResponse;
|
|
2163
|
+
let settingsResponse;
|
|
2164
|
+
try {
|
|
2165
|
+
providersResponse = await apiProxy.llm.providers(request$1({}));
|
|
2166
|
+
settingsResponse = await apiProxy.settings.describe(request$1({}));
|
|
2167
|
+
} catch {
|
|
2168
|
+
return reject(res, 502, "model catalog capability is unavailable");
|
|
2169
|
+
}
|
|
2170
|
+
const providers = valueOf(providersResponse);
|
|
2171
|
+
const settings = valueOf(settingsResponse);
|
|
2172
|
+
if (providers === void 0 || settings === void 0) return reject(res, 502, "model catalog capability is unavailable");
|
|
2173
|
+
const namespace = settings.namespaces.find((candidate) => candidate.ns === "llm-pi-ai");
|
|
2174
|
+
const providerMap = namespace !== void 0 && isRecord(namespace.value) && isRecord(namespace.value.providers) ? namespace.value.providers : void 0;
|
|
2175
|
+
writeJson(res, 200, {
|
|
2176
|
+
capability: "paired-model-catalog",
|
|
2177
|
+
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) => ({
|
|
2178
|
+
provider: candidate.provider,
|
|
2179
|
+
displayName: candidate.displayName
|
|
2180
|
+
})) : []
|
|
2181
|
+
});
|
|
2182
|
+
};
|
|
2183
|
+
const discover = async (req, res) => {
|
|
2184
|
+
if (!gate(req, res)) return;
|
|
2185
|
+
if (req.method !== "POST") return reject(res, 405, "method not allowed");
|
|
2186
|
+
const body = await bodyOf(req, providerSchema);
|
|
2187
|
+
if (body === void 0) return reject(res, 400, "invalid model catalog request");
|
|
2188
|
+
const eligible = await eligibleProvider(apiProxy, body.provider);
|
|
2189
|
+
if (eligible.provider === void 0) return reject(res, eligible.status ?? 502, eligible.error ?? "model catalog capability is unavailable");
|
|
2190
|
+
try {
|
|
2191
|
+
const result = valueOf(await apiProxy.llm.discoverModels(request$1({
|
|
2192
|
+
settingsNs: "llm-pi-ai",
|
|
2193
|
+
provider: eligible.provider.provider
|
|
2194
|
+
})));
|
|
2195
|
+
if (result === void 0) return reject(res, 502, `model discovery failed for provider ${eligible.provider.provider}`);
|
|
2196
|
+
writeJson(res, 200, { models: publicCandidates(result.models) });
|
|
2197
|
+
} catch {
|
|
2198
|
+
reject(res, 502, `model discovery failed for provider ${eligible.provider.provider}`);
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
const upsert = async (req, res) => {
|
|
2202
|
+
if (!gate(req, res)) return;
|
|
2203
|
+
if (req.method !== "POST") return reject(res, 405, "method not allowed");
|
|
2204
|
+
const body = await bodyOf(req, upsertSchema);
|
|
2205
|
+
if (body === void 0) return reject(res, 400, "invalid model catalog request");
|
|
2206
|
+
const eligible = await eligibleProvider(apiProxy, body.provider);
|
|
2207
|
+
if (eligible.provider === void 0) return reject(res, eligible.status ?? 502, eligible.error ?? "model catalog capability is unavailable");
|
|
2208
|
+
const before = await readCatalog(apiProxy);
|
|
2209
|
+
if (before === void 0) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`);
|
|
2210
|
+
const plan = mutationPlan(eligible.provider.profile, body.provider, body.model, before);
|
|
2211
|
+
if (plan === void 0) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`);
|
|
2212
|
+
let mutation;
|
|
2213
|
+
try {
|
|
2214
|
+
mutation = await apiProxy.settings.mutate(request$1({
|
|
2215
|
+
ns: "llm-pi-ai",
|
|
2216
|
+
expectedRevision: eligible.provider.namespace.revision,
|
|
2217
|
+
ops: [{
|
|
2218
|
+
op: "set",
|
|
2219
|
+
path: plan.path,
|
|
2220
|
+
value: plan.value
|
|
2221
|
+
}]
|
|
2222
|
+
}));
|
|
2223
|
+
} catch {
|
|
2224
|
+
return reject(res, 502, `model update failed for provider ${body.provider}`);
|
|
2225
|
+
}
|
|
2226
|
+
const code = errorCode(mutation);
|
|
2227
|
+
if (code === "settings-conflict") return reject(res, 409, `model update conflicted for provider ${body.provider}`);
|
|
2228
|
+
if (code === "settings-rejected") return reject(res, 422, `model update was rejected for provider ${body.provider}`);
|
|
2229
|
+
if (code !== void 0) return reject(res, 502, `model update failed for provider ${body.provider}`);
|
|
2230
|
+
const after = await readCatalog(apiProxy);
|
|
2231
|
+
if (after === void 0) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`);
|
|
2232
|
+
writeJson(res, 200, publicCatalog(after));
|
|
2233
|
+
};
|
|
2234
|
+
return [
|
|
2235
|
+
{
|
|
2236
|
+
kind: "exact",
|
|
2237
|
+
path: PAIRED_MODEL_CATALOG_PATHS.catalog,
|
|
2238
|
+
handler: catalog
|
|
2239
|
+
},
|
|
2240
|
+
{
|
|
2241
|
+
kind: "exact",
|
|
2242
|
+
path: PAIRED_MODEL_CATALOG_PATHS.discover,
|
|
2243
|
+
handler: discover
|
|
2244
|
+
},
|
|
2245
|
+
{
|
|
2246
|
+
kind: "exact",
|
|
2247
|
+
path: PAIRED_MODEL_CATALOG_PATHS.upsert,
|
|
2248
|
+
handler: upsert
|
|
2249
|
+
}
|
|
2250
|
+
];
|
|
2251
|
+
}
|
|
2252
|
+
//#endregion
|
|
1771
2253
|
//#region src/loopback-proxy.ts
|
|
1772
2254
|
/** WebSocket handshake headers forwarded to the loopback upstream. */
|
|
1773
2255
|
const WS_FORWARD_HEADERS = [
|
|
@@ -1881,13 +2363,13 @@ function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath) {
|
|
|
1881
2363
|
* client half can pin them without importing the host SDK graph.
|
|
1882
2364
|
*/
|
|
1883
2365
|
/** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
|
|
1884
|
-
const REMOTE_PREFIX = "/remote";
|
|
2366
|
+
const REMOTE_PREFIX$1 = "/remote";
|
|
1885
2367
|
/** Connection-plugin method prefix under the gated channel. */
|
|
1886
|
-
const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`;
|
|
2368
|
+
const REMOTE_API_PREFIX$1 = `${REMOTE_PREFIX$1}/api`;
|
|
1887
2369
|
/** WebSocket event-stream paths served by the channel (client rewrites to these). */
|
|
1888
2370
|
const REMOTE_API_PATHS = {
|
|
1889
|
-
mux: `${REMOTE_API_PREFIX}/events.mux`,
|
|
1890
|
-
host: `${REMOTE_API_PREFIX}/events.host`
|
|
2371
|
+
mux: `${REMOTE_API_PREFIX$1}/events.mux`,
|
|
2372
|
+
host: `${REMOTE_API_PREFIX$1}/events.host`
|
|
1891
2373
|
};
|
|
1892
2374
|
/**
|
|
1893
2375
|
* Exact upgrade paths registered on webServer (the SDK matches upgrades by
|
|
@@ -1896,9 +2378,9 @@ const REMOTE_API_PATHS = {
|
|
|
1896
2378
|
const REMOTE_UPGRADE_PATHS = [
|
|
1897
2379
|
REMOTE_API_PATHS.mux,
|
|
1898
2380
|
REMOTE_API_PATHS.host,
|
|
1899
|
-
`${REMOTE_PREFIX}/sidebar/ws/terminal`,
|
|
1900
|
-
`${REMOTE_PREFIX}/sidebar/ws/agent-terminals`,
|
|
1901
|
-
`${REMOTE_API_PREFIX}/dsh-ssh/terminal`
|
|
2381
|
+
`${REMOTE_PREFIX$1}/sidebar/ws/terminal`,
|
|
2382
|
+
`${REMOTE_PREFIX$1}/sidebar/ws/agent-terminals`,
|
|
2383
|
+
`${REMOTE_API_PREFIX$1}/dsh-ssh/terminal`
|
|
1902
2384
|
];
|
|
1903
2385
|
/**
|
|
1904
2386
|
* Loopback-only methods of the host API surface, mirrored from
|
|
@@ -1989,17 +2471,19 @@ function loopbackOnlyDenial(innerPath) {
|
|
|
1989
2471
|
}
|
|
1990
2472
|
/**
|
|
1991
2473
|
* Build the remote desktop channel HTTP routes.
|
|
1992
|
-
* @param deps - pairing service + local port.
|
|
2474
|
+
* @param deps - pairing service + local port + live pairing policy.
|
|
1993
2475
|
* @returns the routes to register on webServer.
|
|
1994
2476
|
*/
|
|
1995
2477
|
function makeRemoteApiRoutes(deps) {
|
|
1996
|
-
const { service, port } = deps;
|
|
2478
|
+
const { service, port, requirePairingForLan = true } = deps;
|
|
1997
2479
|
const handler = (req, res) => {
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2480
|
+
if (typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan) {
|
|
2481
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
2482
|
+
if (!(deviceId !== void 0 && service.touchDevice(deviceId))) {
|
|
2483
|
+
req.resume();
|
|
2484
|
+
envelopeError(res, 403, "invalid-request", "unpaired", "this device is not paired with the desktop");
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2003
2487
|
}
|
|
2004
2488
|
const method = req.method ?? "GET";
|
|
2005
2489
|
if (!ALLOWED_METHODS.has(method)) {
|
|
@@ -2024,7 +2508,7 @@ function makeRemoteApiRoutes(deps) {
|
|
|
2024
2508
|
};
|
|
2025
2509
|
return [{
|
|
2026
2510
|
kind: "prefix",
|
|
2027
|
-
path: REMOTE_PREFIX,
|
|
2511
|
+
path: REMOTE_PREFIX$1,
|
|
2028
2512
|
handler
|
|
2029
2513
|
}];
|
|
2030
2514
|
}
|
|
@@ -2046,17 +2530,19 @@ function upgradeInnerPath(reqUrl, fallbackPath) {
|
|
|
2046
2530
|
/**
|
|
2047
2531
|
* Build the WebSocket upgrade routes for the event streams and known plugin
|
|
2048
2532
|
* sockets. webServer matches upgrades by exact path.
|
|
2049
|
-
* @param deps - pairing service + local port.
|
|
2533
|
+
* @param deps - pairing service + local port + live pairing policy.
|
|
2050
2534
|
* @returns the upgrade routes to register on webServer.
|
|
2051
2535
|
*/
|
|
2052
2536
|
function makeRemoteApiUpgradeRoutes(deps) {
|
|
2053
|
-
const { service, port } = deps;
|
|
2537
|
+
const { service, port, requirePairingForLan = true } = deps;
|
|
2054
2538
|
const handlerFor = (fallbackPath) => (req, socket, head) => {
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2539
|
+
if (typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan) {
|
|
2540
|
+
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
2541
|
+
if (deviceId === void 0 || !service.touchDevice(deviceId)) {
|
|
2542
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
2543
|
+
socket.destroy();
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2060
2546
|
}
|
|
2061
2547
|
const inner = upgradeInnerPath(req.url, fallbackPath);
|
|
2062
2548
|
if (loopbackOnlyDenial(inner.split("?")[0] ?? inner) !== void 0) {
|
|
@@ -2989,6 +3475,86 @@ function mountOnce(packageName, fn) {
|
|
|
2989
3475
|
return fn(...args);
|
|
2990
3476
|
});
|
|
2991
3477
|
}
|
|
3478
|
+
/** The live rule set. */
|
|
3479
|
+
const REMOTE_CHANNEL_RULES = {
|
|
3480
|
+
remotePrefix: "/remote",
|
|
3481
|
+
apiPrefix: "/api/",
|
|
3482
|
+
pairPrefix: "/api/pair/",
|
|
3483
|
+
updatePrefix: "/api/update/",
|
|
3484
|
+
desktopLauncherPrefix: "/api/dsh-desktop-launcher",
|
|
3485
|
+
settingsBridgePrefix: "/api/dsh-web-ui-settings",
|
|
3486
|
+
sidebarPrefix: "/sidebar/",
|
|
3487
|
+
gitPrefix: "/git/",
|
|
3488
|
+
petPrefix: "/pet/",
|
|
3489
|
+
wsPaths: [
|
|
3490
|
+
"/api/events.mux",
|
|
3491
|
+
"/api/events.host",
|
|
3492
|
+
"/sidebar/ws/terminal",
|
|
3493
|
+
"/sidebar/ws/agent-terminals",
|
|
3494
|
+
"/api/dsh-ssh/terminal"
|
|
3495
|
+
]
|
|
3496
|
+
};
|
|
3497
|
+
/** The window global the boot patch publishes its seat under. */
|
|
3498
|
+
const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
3499
|
+
//#endregion
|
|
3500
|
+
//#region src/remote-channel-boot.ts
|
|
3501
|
+
/**
|
|
3502
|
+
* Parse-time remote-channel boot patch (issue #987): the browser-half patch
|
|
3503
|
+
* (client/remote-channel.ts) installs when this plugin's boot entry runs,
|
|
3504
|
+
* but `dsh-client-connection` boots earlier and opens its event streams
|
|
3505
|
+
* unrewritten — on a non-loopback origin the SDK fence rejects them and the
|
|
3506
|
+
* workspace list never loads. The host therefore inlines this classic script
|
|
3507
|
+
* right after the opening <head> tag (webserver/index-inject), so the
|
|
3508
|
+
* fetch/WebSocket/EventSource/src rewrite is active before ANY boot entry
|
|
3509
|
+
* executes. The plugin's client apply later adopts the installed seat
|
|
3510
|
+
* (hooks + pending unpaired signal) instead of patching twice.
|
|
3511
|
+
*
|
|
3512
|
+
* The rewrite decisions are generated from REMOTE_CHANNEL_RULES, the same
|
|
3513
|
+
* data the browser patch consumes — the two cannot drift apart. The script
|
|
3514
|
+
* self-skips on loopback origins and never throws.
|
|
3515
|
+
* @module @linxin666/dsh-remote-web-ui/remote-channel-boot
|
|
3516
|
+
*/
|
|
3517
|
+
/**
|
|
3518
|
+
* Build the inline boot script. The result contains no `<\/script` sequence
|
|
3519
|
+
* (the injection contract) and installs a {@link RemoteChannelBootSeat} on
|
|
3520
|
+
* the window global.
|
|
3521
|
+
*/
|
|
3522
|
+
function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
|
|
3523
|
+
const json = JSON.stringify(rules);
|
|
3524
|
+
const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL);
|
|
3525
|
+
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){}})();";
|
|
3526
|
+
}
|
|
3527
|
+
/** The script this plugin contributes; built once from the live rules. */
|
|
3528
|
+
const REMOTE_CHANNEL_BOOT_SCRIPT = buildRemoteChannelBootScript();
|
|
3529
|
+
//#endregion
|
|
3530
|
+
//#region src/uuid-polyfill.ts
|
|
3531
|
+
/**
|
|
3532
|
+
* Polyfill for crypto.randomUUID for non-secure contexts (LAN HTTP)
|
|
3533
|
+
* where the browser does not expose crypto.randomUUID.
|
|
3534
|
+
*
|
|
3535
|
+
* The script string is injected as an inline <script> in the desktop HTML
|
|
3536
|
+
* head so the SDK's mintRpcId() sees a working randomUUID even on plain
|
|
3537
|
+
* http:// origins (#1024).
|
|
3538
|
+
*/
|
|
3539
|
+
const UUID_POLYFILL_SCRIPT = [
|
|
3540
|
+
"(function(){",
|
|
3541
|
+
"var g=typeof globalThis!==\"undefined\"?globalThis:typeof window!==\"undefined\"?window:self;",
|
|
3542
|
+
"if(typeof g.crypto===\"undefined\"){g.crypto={};}",
|
|
3543
|
+
"if(typeof g.crypto.randomUUID!==\"function\"){",
|
|
3544
|
+
"g.crypto.randomUUID=function(){",
|
|
3545
|
+
"var a=new Uint8Array(16);",
|
|
3546
|
+
"if(g.crypto.getRandomValues){g.crypto.getRandomValues(a);}",
|
|
3547
|
+
"else{for(var j=0;j<16;j++){a[j]=Math.random()*256|0;}}",
|
|
3548
|
+
"a[6]=(a[6]&0x0f)|0x40;",
|
|
3549
|
+
"a[8]=(a[8]&0x3f)|0x80;",
|
|
3550
|
+
"var h=\"\",x=\"0123456789abcdef\";",
|
|
3551
|
+
"for(var i=0;i<16;i++){h+=x[a[i]>>4]+x[a[i]&0x0f];",
|
|
3552
|
+
"if(i===3||i===5||i===7||i===9)h+=\"-\";}",
|
|
3553
|
+
"return h;",
|
|
3554
|
+
"};",
|
|
3555
|
+
"}",
|
|
3556
|
+
"})();"
|
|
3557
|
+
].join("");
|
|
2992
3558
|
//#endregion
|
|
2993
3559
|
//#region src/index.ts
|
|
2994
3560
|
/**
|
|
@@ -3194,17 +3760,25 @@ function applyImpl(ctx, config) {
|
|
|
3194
3760
|
...apiProxy !== void 0 ? makeMobileApiRoutes({
|
|
3195
3761
|
service,
|
|
3196
3762
|
apiProxy,
|
|
3763
|
+
pendingTracker: new PendingTracker(),
|
|
3197
3764
|
mobileEnterToSend: () => resolve().mobileEnterToSend
|
|
3198
3765
|
}) : [],
|
|
3766
|
+
...apiProxy !== void 0 ? makePairedModelCatalogRoutes({
|
|
3767
|
+
service,
|
|
3768
|
+
apiProxy,
|
|
3769
|
+
lanAddresses
|
|
3770
|
+
}) : [],
|
|
3199
3771
|
...makeRemoteApiRoutes({
|
|
3200
3772
|
service,
|
|
3201
|
-
port: ctx.webServer.port
|
|
3773
|
+
port: ctx.webServer.port,
|
|
3774
|
+
requirePairingForLan: () => resolve().requirePairingForLan
|
|
3202
3775
|
}),
|
|
3203
3776
|
...updateRoutes
|
|
3204
3777
|
];
|
|
3205
3778
|
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
3206
3779
|
service,
|
|
3207
|
-
port: ctx.webServer.port
|
|
3780
|
+
port: ctx.webServer.port,
|
|
3781
|
+
requirePairingForLan: () => resolve().requirePairingForLan
|
|
3208
3782
|
});
|
|
3209
3783
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
|
|
3210
3784
|
ctx.effect(() => ctx.on("api/gate", gate), "remote-web-ui: api gate");
|
|
@@ -3288,6 +3862,22 @@ function applyImpl(ctx, config) {
|
|
|
3288
3862
|
}
|
|
3289
3863
|
runPostureProbe();
|
|
3290
3864
|
};
|
|
3865
|
+
ctx.effect(() => ctx.on("webserver/index-inject", (table) => {
|
|
3866
|
+
table.push({
|
|
3867
|
+
kind: "script",
|
|
3868
|
+
placement: "head",
|
|
3869
|
+
text: UUID_POLYFILL_SCRIPT
|
|
3870
|
+
});
|
|
3871
|
+
}), "remote-web-ui: uuid polyfill");
|
|
3872
|
+
ctx.effect(() => ctx.on("webserver/index-inject", (table) => {
|
|
3873
|
+
const value = resolve();
|
|
3874
|
+
if (!value.enabled || !value.requirePairingForLan) return;
|
|
3875
|
+
table.push({
|
|
3876
|
+
kind: "script",
|
|
3877
|
+
placement: "head",
|
|
3878
|
+
text: REMOTE_CHANNEL_BOOT_SCRIPT
|
|
3879
|
+
});
|
|
3880
|
+
}), "remote-web-ui: remote channel boot patch");
|
|
3291
3881
|
installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
3292
3882
|
setSource: (source) => {
|
|
3293
3883
|
current = source;
|