dsh-deeppilot 0.6.0-alpha.3 → 0.6.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/COMPATIBILITY.md +7 -12
- package/README.md +23 -22
- package/README.zh-CN.md +20 -19
- package/SECURITY.md +2 -2
- package/bin/SHA256SUMS +6 -6
- package/bin/darwin-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/darwin-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-amd64/dsh-deeppilot-tunnel +0 -0
- package/bin/linux-arm64/dsh-deeppilot-tunnel +0 -0
- package/bin/windows-amd64/dsh-deeppilot-tunnel.exe +0 -0
- package/bin/windows-arm64/dsh-deeppilot-tunnel.exe +0 -0
- package/lib/client.js +313 -37
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +23 -4
- package/lib/index.js +455 -113
- package/lib/index.js.map +1 -1
- package/package.json +17 -17
package/lib/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { createServer } from "node:http";
|
|
3
2
|
import { createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, sign, timingSafeEqual, verify } from "node:crypto";
|
|
4
|
-
import { access, chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { access, chmod, mkdir, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
5
4
|
import { dirname, join, resolve } from "node:path";
|
|
6
5
|
import { WebSocketServer } from "ws";
|
|
7
6
|
import { homedir, networkInterfaces } from "node:os";
|
|
@@ -15,8 +14,11 @@ import { fileURLToPath } from "node:url";
|
|
|
15
14
|
import { request } from "node:https";
|
|
16
15
|
import z from "@deepseek-ai/schemastery";
|
|
17
16
|
import { isIP } from "node:net";
|
|
17
|
+
import { createServer } from "node:http";
|
|
18
18
|
//#region src/device-auth.ts
|
|
19
|
-
|
|
19
|
+
function getPairingCodeTtlMs() {
|
|
20
|
+
return Number(process.env.DEEPPILOT_PAIRING_TTL_MS) || 3e5;
|
|
21
|
+
}
|
|
20
22
|
const AUTH_CHALLENGE_TTL_MS = 3e4;
|
|
21
23
|
const DEVICE_SCOPES = [
|
|
22
24
|
"sessions.read",
|
|
@@ -106,7 +108,7 @@ var PairingCodeManager = class {
|
|
|
106
108
|
issue(now = Date.now()) {
|
|
107
109
|
const grant = {
|
|
108
110
|
code: randomBytes(24).toString("base64url"),
|
|
109
|
-
expiresAt: now +
|
|
111
|
+
expiresAt: now + getPairingCodeTtlMs()
|
|
110
112
|
};
|
|
111
113
|
this.active = grant;
|
|
112
114
|
return { ...grant };
|
|
@@ -3197,6 +3199,28 @@ function parseRemote(value) {
|
|
|
3197
3199
|
updatedAt: int(s, "updatedAt", "remote.updatedAt")
|
|
3198
3200
|
};
|
|
3199
3201
|
}
|
|
3202
|
+
function parseLocal(value) {
|
|
3203
|
+
const s = rec(value, "local");
|
|
3204
|
+
const phase = str(s, "phase", "local.phase");
|
|
3205
|
+
if (![
|
|
3206
|
+
"disabled",
|
|
3207
|
+
"starting",
|
|
3208
|
+
"online",
|
|
3209
|
+
"error",
|
|
3210
|
+
"stopped"
|
|
3211
|
+
].includes(phase)) reject("local.phase");
|
|
3212
|
+
const endpoints = s.endpoints;
|
|
3213
|
+
if (!Array.isArray(endpoints) || endpoints.some((entry) => typeof entry !== "string")) reject("local.endpoints");
|
|
3214
|
+
const message = s.message;
|
|
3215
|
+
if (message !== void 0 && typeof message !== "string") reject("local.message");
|
|
3216
|
+
return {
|
|
3217
|
+
phase,
|
|
3218
|
+
port: int(s, "port", "local.port"),
|
|
3219
|
+
endpoints,
|
|
3220
|
+
...typeof message === "string" ? { message } : {},
|
|
3221
|
+
updatedAt: int(s, "updatedAt", "local.updatedAt")
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3200
3224
|
function parseRelayTestStep(value) {
|
|
3201
3225
|
const st = rec(value, "step");
|
|
3202
3226
|
const id = str(st, "id", "step.id");
|
|
@@ -3278,6 +3302,7 @@ function parseReport(value) {
|
|
|
3278
3302
|
historyBufferMax: int(s, "historyBufferMax", "historyBufferMax"),
|
|
3279
3303
|
debug: bool(s, "debug", "debug"),
|
|
3280
3304
|
lanAddresses,
|
|
3305
|
+
local: parseLocal(s.local),
|
|
3281
3306
|
remote: parseRemote(s.remote),
|
|
3282
3307
|
devices: devices.map(parseDevice)
|
|
3283
3308
|
};
|
|
@@ -3437,7 +3462,32 @@ function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceS
|
|
|
3437
3462
|
});
|
|
3438
3463
|
}
|
|
3439
3464
|
//#endregion
|
|
3465
|
+
//#region src/relay-url.ts
|
|
3466
|
+
/**
|
|
3467
|
+
* Normalize an operator-configured relay URL without a backtracking regular
|
|
3468
|
+
* expression. Scanning from the end keeps even pathological inputs linear.
|
|
3469
|
+
*/
|
|
3470
|
+
function normalizeRelayBaseUrl(value) {
|
|
3471
|
+
const trimmed = value.trim();
|
|
3472
|
+
let end = trimmed.length;
|
|
3473
|
+
while (end > 0 && trimmed.charCodeAt(end - 1) === 47) end -= 1;
|
|
3474
|
+
return end === trimmed.length ? trimmed : trimmed.slice(0, end);
|
|
3475
|
+
}
|
|
3476
|
+
//#endregion
|
|
3440
3477
|
//#region src/relay-test.ts
|
|
3478
|
+
/**
|
|
3479
|
+
* Relay connectivity self-test used by the settings page "测试中继" button.
|
|
3480
|
+
*
|
|
3481
|
+
* Two steps mirror what zero-touch enrollment actually does:
|
|
3482
|
+
* 1. health — GET {url}/healthz → is the relay reachable?
|
|
3483
|
+
* 2. enroll — POST {url}/v1/enroll → does the shared key grant a token?
|
|
3484
|
+
*
|
|
3485
|
+
* The enroll step doubles as a repair path: a token it issues is handed back
|
|
3486
|
+
* via `onEnrolled` so the bridge caches it and flips push readiness without
|
|
3487
|
+
* waiting for the next app registration.
|
|
3488
|
+
*
|
|
3489
|
+
* Transport is injectable (`fetchImpl`) so tests never touch the network.
|
|
3490
|
+
*/
|
|
3441
3491
|
const DEFAULT_TIMEOUT_MS = 6e3;
|
|
3442
3492
|
async function requestJson(fetchImpl, url, init, timeoutMs) {
|
|
3443
3493
|
const response = await fetchImpl(url, {
|
|
@@ -3454,7 +3504,7 @@ async function requestJson(fetchImpl, url, init, timeoutMs) {
|
|
|
3454
3504
|
};
|
|
3455
3505
|
}
|
|
3456
3506
|
async function runRelayProbe(options) {
|
|
3457
|
-
const base = options.url
|
|
3507
|
+
const base = normalizeRelayBaseUrl(options.url);
|
|
3458
3508
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
3459
3509
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
3460
3510
|
const steps = [];
|
|
@@ -3764,7 +3814,7 @@ var RelayClient = class {
|
|
|
3764
3814
|
debug;
|
|
3765
3815
|
log;
|
|
3766
3816
|
constructor(opts) {
|
|
3767
|
-
this.base = opts.url
|
|
3817
|
+
this.base = normalizeRelayBaseUrl(opts.url);
|
|
3768
3818
|
this.token = (opts.token ?? "").trim();
|
|
3769
3819
|
this.timeoutMs = opts.timeoutMs ?? 1e4;
|
|
3770
3820
|
this.debug = opts.debug === true;
|
|
@@ -3895,6 +3945,137 @@ function tunnelHelperArguments(originURL, statePath, options) {
|
|
|
3895
3945
|
String(normalizeFunnelConnectionLimit(options.maxConnectionsPerSource))
|
|
3896
3946
|
];
|
|
3897
3947
|
}
|
|
3948
|
+
/**
|
|
3949
|
+
* Summarize a fatal helper crash from its stderr tail. Go panics dump the
|
|
3950
|
+
* actual reason on the `panic:` line while the trailing stack frames are
|
|
3951
|
+
* `file.go:NN +0xOFF` noise — the old behavior of reporting only the final
|
|
3952
|
+
* line surfaced exactly that noise (e.g. `.../singleflight.go:194 +0x45c`)
|
|
3953
|
+
* and hid the real panic message. Keep the crash header line (a raw
|
|
3954
|
+
* `panic:`, a runtime `fatal error:`, or net/http's `http: panic serving`),
|
|
3955
|
+
* plus an optional `[signal ...]` continuation and the first non-runtime
|
|
3956
|
+
* stack frame's symbol + `file.go:NN` as a location hint. Pointer offsets
|
|
3957
|
+
* are not architecture stable, so they are deliberately dropped.
|
|
3958
|
+
*
|
|
3959
|
+
* The frame symbol sits on the line just above the indented source line in a
|
|
3960
|
+
* Go dump, e.g.:
|
|
3961
|
+
* tailscale.com/net/dnscache.(*Resolver).lookupIP(0x1400012c000, {...})
|
|
3962
|
+
* tailscale.com@v1.102.3/net/dnscache/dnscache.go:604 +0x25c
|
|
3963
|
+
* The first symbol (shortened) is used because a bare `file.go:NN` is not
|
|
3964
|
+
* unique across tailscale (singleflight.go is one example).
|
|
3965
|
+
*/
|
|
3966
|
+
function helperCrashSummary(stderr) {
|
|
3967
|
+
const lines = stderr.split(/\r?\n/);
|
|
3968
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3969
|
+
const line = lines[i].trim();
|
|
3970
|
+
if (line === "") continue;
|
|
3971
|
+
const logTimestamp = /^(\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} )/.exec(line)?.[1] ?? "";
|
|
3972
|
+
if (!/^(panic:|fatal error:|http: panic serving)/.test(logTimestamp.length > 0 ? line.slice(logTimestamp.length) : line)) continue;
|
|
3973
|
+
let summary = logTimestamp.length > 0 ? line.slice(logTimestamp.length) : line;
|
|
3974
|
+
const next = lines[i + 1]?.trim();
|
|
3975
|
+
if (next?.startsWith("[signal")) summary += " " + next;
|
|
3976
|
+
let lastSymbol;
|
|
3977
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
3978
|
+
const frame = lines[j];
|
|
3979
|
+
const trimmed = frame.trim();
|
|
3980
|
+
if (trimmed === "") continue;
|
|
3981
|
+
const sourceMatch = /^\s+(\S+\.go:\d+)/.exec(frame);
|
|
3982
|
+
if (sourceMatch !== null) {
|
|
3983
|
+
const source = sourceMatch[1];
|
|
3984
|
+
if (source.startsWith("runtime/") || source.startsWith("runtime\\")) {
|
|
3985
|
+
lastSymbol = void 0;
|
|
3986
|
+
continue;
|
|
3987
|
+
}
|
|
3988
|
+
const slash = Math.max(source.lastIndexOf("/"), source.lastIndexOf("\\"));
|
|
3989
|
+
const file = slash >= 0 ? source.slice(slash + 1) : source;
|
|
3990
|
+
const symbol = lastSymbol !== void 0 ? shortenSymbol(lastSymbol) : void 0;
|
|
3991
|
+
summary += " @ " + (symbol !== void 0 ? `${symbol} (${file})` : file);
|
|
3992
|
+
break;
|
|
3993
|
+
}
|
|
3994
|
+
if (trimmed.includes("(") || trimmed.endsWith(")")) lastSymbol = trimmed;
|
|
3995
|
+
}
|
|
3996
|
+
return summary.replace(/\s+/g, " ").trim();
|
|
3997
|
+
}
|
|
3998
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
3999
|
+
const line = lines[i].trim();
|
|
4000
|
+
if (line !== "") return line;
|
|
4001
|
+
}
|
|
4002
|
+
return "";
|
|
4003
|
+
}
|
|
4004
|
+
/** Shrink a Go frame symbol to `pkg.Receiver.method` form (drop module-root
|
|
4005
|
+
* path, generic type arguments and the trailing call arguments), so the
|
|
4006
|
+
* summary hint stays readable. Keeps a `.funcN` closure suffix when present
|
|
4007
|
+
* (e.g. `doCall.func2`), since it identifies the exact closure.
|
|
4008
|
+
*
|
|
4009
|
+
* A symbol looks like:
|
|
4010
|
+
* tailscale.com/util/singleflight.(*Group[...]).doCall.func2(0x…, …)
|
|
4011
|
+
* The trailing call arguments form a balanced paren group that starts at
|
|
4012
|
+
* the function name — that group is stripped first; generics appear only
|
|
4013
|
+
* inside `(*Group[...])` receiver brackets before the final method dot, so
|
|
4014
|
+
* they are removed by trimming from the first `[` to the matching `]`.
|
|
4015
|
+
*/
|
|
4016
|
+
function shortenSymbol(symbol) {
|
|
4017
|
+
const trimmed = symbol.trim();
|
|
4018
|
+
let end = trimmed.length;
|
|
4019
|
+
if (trimmed.endsWith(")")) {
|
|
4020
|
+
let depth = 0;
|
|
4021
|
+
for (let i = trimmed.length - 1; i >= 0; i--) {
|
|
4022
|
+
const ch = trimmed[i];
|
|
4023
|
+
if (ch === ")") depth++;
|
|
4024
|
+
else if (ch === "(") {
|
|
4025
|
+
depth--;
|
|
4026
|
+
if (depth === 0) {
|
|
4027
|
+
end = i;
|
|
4028
|
+
break;
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
let name = trimmed.slice(0, end).trim();
|
|
4034
|
+
const bracketOpen = name.indexOf("[");
|
|
4035
|
+
if (bracketOpen >= 0) {
|
|
4036
|
+
const bracketClose = name.indexOf("]", bracketOpen);
|
|
4037
|
+
if (bracketClose > bracketOpen) name = name.slice(0, bracketOpen) + name.slice(bracketClose + 1);
|
|
4038
|
+
}
|
|
4039
|
+
const slash = Math.max(name.lastIndexOf("/"), name.lastIndexOf("\\"));
|
|
4040
|
+
return slash >= 0 ? name.slice(slash + 1) : name;
|
|
4041
|
+
}
|
|
4042
|
+
/** Shared by crash summary and dump: whether the stderr looks like a Go
|
|
4043
|
+
* panic/fatal-error crash (vs. an ordinary exit). */
|
|
4044
|
+
function looksLikeCrash(stderr) {
|
|
4045
|
+
return /(?:^|\n)\s*(?:\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} )?(?:panic:|fatal error:|http: panic serving)/.test(stderr);
|
|
4046
|
+
}
|
|
4047
|
+
/** Turn a crash stderr tail into a status message and optionally persist the
|
|
4048
|
+
* full dump under the state dir. Keeps at most MAX_CRASH_DUMPS dump files. */
|
|
4049
|
+
async function analyzeCrashExit(stderr, statePath) {
|
|
4050
|
+
const summary = helperCrashSummary(stderr) || "helper exited";
|
|
4051
|
+
if (!looksLikeCrash(stderr) || stderr.trim() === "") return { summary };
|
|
4052
|
+
const target = statePath.trim();
|
|
4053
|
+
if (target === "") return { summary };
|
|
4054
|
+
try {
|
|
4055
|
+
const file = join(target, `crash-${Date.now()}.log`);
|
|
4056
|
+
await writeFile(file, stderr, { mode: 384 });
|
|
4057
|
+
await trimCrashDumps(target);
|
|
4058
|
+
return {
|
|
4059
|
+
summary,
|
|
4060
|
+
dumpPath: file
|
|
4061
|
+
};
|
|
4062
|
+
} catch {
|
|
4063
|
+
return { summary };
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
4066
|
+
/** Remove oldest crash-*.log files beyond MAX_CRASH_DUMPS. */
|
|
4067
|
+
async function trimCrashDumps(dir) {
|
|
4068
|
+
let names;
|
|
4069
|
+
try {
|
|
4070
|
+
names = (await readdir(dir)).filter((name) => name.startsWith("crash-") && name.endsWith(".log"));
|
|
4071
|
+
} catch {
|
|
4072
|
+
return;
|
|
4073
|
+
}
|
|
4074
|
+
if (names.length <= 6) return;
|
|
4075
|
+
names.sort();
|
|
4076
|
+
const excess = names.length - 6;
|
|
4077
|
+
await Promise.all(names.slice(0, excess).map((name) => rm(join(dir, name), { force: true }).catch(() => {})));
|
|
4078
|
+
}
|
|
3898
4079
|
/** Parse one helper IPC line without ever evaluating or interpolating it. */
|
|
3899
4080
|
function parseHelperEvent(line) {
|
|
3900
4081
|
try {
|
|
@@ -4049,7 +4230,7 @@ var RemoteSupervisor = class {
|
|
|
4049
4230
|
let stderrBuffer = "";
|
|
4050
4231
|
child.stderr.setEncoding("utf8");
|
|
4051
4232
|
child.stderr.on("data", (chunk) => {
|
|
4052
|
-
stderrBuffer = (stderrBuffer + chunk).slice(-
|
|
4233
|
+
stderrBuffer = (stderrBuffer + chunk).slice(-16e3);
|
|
4053
4234
|
});
|
|
4054
4235
|
child.once("error", (error) => {
|
|
4055
4236
|
this.setStatus({
|
|
@@ -4057,7 +4238,7 @@ var RemoteSupervisor = class {
|
|
|
4057
4238
|
message: `helper launch failed: ${String(error)}`
|
|
4058
4239
|
});
|
|
4059
4240
|
});
|
|
4060
|
-
child.once("exit", (
|
|
4241
|
+
child.once("exit", () => {
|
|
4061
4242
|
if (this.child === child) this.child = void 0;
|
|
4062
4243
|
if (this.stopping) {
|
|
4063
4244
|
this.setStatus({
|
|
@@ -4066,12 +4247,15 @@ var RemoteSupervisor = class {
|
|
|
4066
4247
|
});
|
|
4067
4248
|
return;
|
|
4068
4249
|
}
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4250
|
+
analyzeCrashExit(stderrBuffer, this.options.statePath).then((analysis) => {
|
|
4251
|
+
if (this.stopping) return;
|
|
4252
|
+
const message = analysis.summary + (analysis.dumpPath !== void 0 ? ` (full dump: ${analysis.dumpPath})` : "");
|
|
4253
|
+
this.setStatus({
|
|
4254
|
+
phase: "error",
|
|
4255
|
+
message
|
|
4256
|
+
});
|
|
4257
|
+
this.scheduleRestart(originURL, "crash");
|
|
4073
4258
|
});
|
|
4074
|
-
this.scheduleRestart(originURL, "crash");
|
|
4075
4259
|
});
|
|
4076
4260
|
}
|
|
4077
4261
|
async dispose() {
|
|
@@ -4364,6 +4548,24 @@ var UpdateChecker = class {
|
|
|
4364
4548
|
dispose() {}
|
|
4365
4549
|
};
|
|
4366
4550
|
//#endregion
|
|
4551
|
+
//#region src/local-policy.ts
|
|
4552
|
+
const DEFAULT_LOCAL_PORT = 3098;
|
|
4553
|
+
const MIN_LOCAL_PORT = 1024;
|
|
4554
|
+
const MAX_LOCAL_PORT = 65535;
|
|
4555
|
+
function normalizeLocalPort(value) {
|
|
4556
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1024 && value <= 65535 ? value : DEFAULT_LOCAL_PORT;
|
|
4557
|
+
}
|
|
4558
|
+
function localEndpointURLs(addresses, port) {
|
|
4559
|
+
const normalizedPort = normalizeLocalPort(port);
|
|
4560
|
+
return [...new Set(addresses)].map((address) => `http://${address}:${normalizedPort}`);
|
|
4561
|
+
}
|
|
4562
|
+
function localListenError(error, port) {
|
|
4563
|
+
const code = error?.code;
|
|
4564
|
+
if (code === "EADDRINUSE") return `local port ${port} is already in use`;
|
|
4565
|
+
if (code === "EACCES") return `permission denied while opening local port ${port}`;
|
|
4566
|
+
return error instanceof Error ? error.message : String(error);
|
|
4567
|
+
}
|
|
4568
|
+
//#endregion
|
|
4367
4569
|
//#region src/config.ts
|
|
4368
4570
|
/** Operator-run relay used by distributed builds; overridable via config. */
|
|
4369
4571
|
const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
|
|
@@ -4372,6 +4574,13 @@ const Config = z.object({
|
|
|
4372
4574
|
devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
|
|
4373
4575
|
historyBufferMax: z.natural().min(100).default(2e3),
|
|
4374
4576
|
debug: z.boolean().default(false),
|
|
4577
|
+
local: z.object({
|
|
4578
|
+
enabled: z.boolean().default(true),
|
|
4579
|
+
port: z.natural().min(MIN_LOCAL_PORT).max(MAX_LOCAL_PORT).default(DEFAULT_LOCAL_PORT).description("DeepPilot 局域网独立端口(默认 3098,修改后本地连接会短暂重连)")
|
|
4580
|
+
}).default({
|
|
4581
|
+
enabled: true,
|
|
4582
|
+
port: DEFAULT_LOCAL_PORT
|
|
4583
|
+
}),
|
|
4375
4584
|
remote: z.object({
|
|
4376
4585
|
enabled: z.boolean().default(false),
|
|
4377
4586
|
provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
|
|
@@ -4594,12 +4803,64 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
|
|
|
4594
4803
|
return transport === "relay" && outcome === "failed" && reason === "HTTP 401" && opts.hasEnrollKey && opts.usedCellToken && opts.tokenStillCurrent;
|
|
4595
4804
|
}
|
|
4596
4805
|
//#endregion
|
|
4806
|
+
//#region src/phone-server.ts
|
|
4807
|
+
/**
|
|
4808
|
+
* A deliberately narrow transport listener. Both the LAN endpoint and the
|
|
4809
|
+
* loopback-only Funnel origin use this factory, so neither can accidentally
|
|
4810
|
+
* inherit DSH's wider web/API route surface.
|
|
4811
|
+
*/
|
|
4812
|
+
function createPhoneServer(handlers) {
|
|
4813
|
+
const server = createServer((req, res) => {
|
|
4814
|
+
const path = requestPath(req);
|
|
4815
|
+
if (path === "/phone/health") handlers.health(req, res);
|
|
4816
|
+
else if (path === "/phone/pair") handlers.pair(req, res);
|
|
4817
|
+
else {
|
|
4818
|
+
res.statusCode = 404;
|
|
4819
|
+
res.end("not found");
|
|
4820
|
+
}
|
|
4821
|
+
});
|
|
4822
|
+
server.on("upgrade", (req, socket, head) => {
|
|
4823
|
+
if (requestPath(req) !== "/phone") {
|
|
4824
|
+
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
4825
|
+
return;
|
|
4826
|
+
}
|
|
4827
|
+
handlers.upgrade(req, socket, head);
|
|
4828
|
+
});
|
|
4829
|
+
return server;
|
|
4830
|
+
}
|
|
4831
|
+
function requestPath(req) {
|
|
4832
|
+
try {
|
|
4833
|
+
return new URL(req.url ?? "/", "http://phone.local").pathname;
|
|
4834
|
+
} catch {
|
|
4835
|
+
return "/";
|
|
4836
|
+
}
|
|
4837
|
+
}
|
|
4838
|
+
function listen(server, port, host) {
|
|
4839
|
+
return new Promise((resolve, reject) => {
|
|
4840
|
+
const onError = (error) => {
|
|
4841
|
+
server.off("listening", onListening);
|
|
4842
|
+
reject(error);
|
|
4843
|
+
};
|
|
4844
|
+
const onListening = () => {
|
|
4845
|
+
server.off("error", onError);
|
|
4846
|
+
resolve();
|
|
4847
|
+
};
|
|
4848
|
+
server.once("error", onError);
|
|
4849
|
+
server.once("listening", onListening);
|
|
4850
|
+
server.listen(port, host);
|
|
4851
|
+
});
|
|
4852
|
+
}
|
|
4853
|
+
function closeServer(server) {
|
|
4854
|
+
if (server === void 0 || !server.listening) return Promise.resolve();
|
|
4855
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
4856
|
+
}
|
|
4857
|
+
//#endregion
|
|
4597
4858
|
//#region src/index.ts
|
|
4598
4859
|
/**
|
|
4599
4860
|
* dsh-deeppilot — data bridge between the DSH host and DeepPilot
|
|
4600
|
-
* clients.
|
|
4601
|
-
*
|
|
4602
|
-
* UI
|
|
4861
|
+
* clients. Owns independent, narrowly routed LAN and Funnel-origin listeners;
|
|
4862
|
+
* temporary compatibility routes remain on the existing DSH web server. The
|
|
4863
|
+
* web UI and the rest of DSH's API are never exposed by these listeners.
|
|
4603
4864
|
*
|
|
4604
4865
|
* Data plane: an in-process HostBridge consumes a local compatibility façade
|
|
4605
4866
|
* over DSH 0.1.2 Session/Workspace controllers, mirrors session summaries,
|
|
@@ -4610,7 +4871,7 @@ function shouldReEnrollRelayToken(transport, outcome, reason, opts) {
|
|
|
4610
4871
|
* Swift models mirror that v2 contract.
|
|
4611
4872
|
*/
|
|
4612
4873
|
const name = "deeppilot";
|
|
4613
|
-
/** No eager service requirement:
|
|
4874
|
+
/** No eager web-service requirement: independent transports start on their own. */
|
|
4614
4875
|
const inject = [];
|
|
4615
4876
|
const SERVER_VERSION = readOwnPackageVersion();
|
|
4616
4877
|
const MAX_CLIENT_CONNECTIONS = 16;
|
|
@@ -4650,6 +4911,7 @@ function apply(ctx, options) {
|
|
|
4650
4911
|
*/
|
|
4651
4912
|
let liveSource;
|
|
4652
4913
|
let scheduleRemoteReconcile;
|
|
4914
|
+
let scheduleLocalReconcile;
|
|
4653
4915
|
const currentConfig = () => {
|
|
4654
4916
|
if (liveSource !== void 0) return normalizeOptions(liveSource());
|
|
4655
4917
|
return normalizeOptions(options);
|
|
@@ -4658,9 +4920,15 @@ function apply(ctx, options) {
|
|
|
4658
4920
|
const settingsHooks = {
|
|
4659
4921
|
setSource: (source) => {
|
|
4660
4922
|
liveSource = source;
|
|
4661
|
-
queueMicrotask(() =>
|
|
4923
|
+
queueMicrotask(() => {
|
|
4924
|
+
scheduleLocalReconcile?.();
|
|
4925
|
+
scheduleRemoteReconcile?.();
|
|
4926
|
+
});
|
|
4662
4927
|
},
|
|
4663
|
-
onChange: () => queueMicrotask(() =>
|
|
4928
|
+
onChange: () => queueMicrotask(() => {
|
|
4929
|
+
scheduleLocalReconcile?.();
|
|
4930
|
+
scheduleRemoteReconcile?.();
|
|
4931
|
+
})
|
|
4664
4932
|
};
|
|
4665
4933
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
4666
4934
|
settingsCtx.settings.installSection(ctx, "deeppilot", Config, normalizeOptions(void 0), settingsHooks);
|
|
@@ -5066,6 +5334,16 @@ function apply(ctx, options) {
|
|
|
5066
5334
|
maxPayload: MAX_FRAME_BYTES
|
|
5067
5335
|
});
|
|
5068
5336
|
let remoteSupervisor;
|
|
5337
|
+
let localState = {
|
|
5338
|
+
phase: currentConfig().enabled === true && currentConfig().local?.enabled !== false ? "starting" : "disabled",
|
|
5339
|
+
port: normalizeLocalPort(currentConfig().local?.port),
|
|
5340
|
+
endpoints: [],
|
|
5341
|
+
updatedAt: Date.now()
|
|
5342
|
+
};
|
|
5343
|
+
const localStatus = (addresses) => ({
|
|
5344
|
+
...localState,
|
|
5345
|
+
endpoints: localState.phase === "online" ? localEndpointURLs(addresses, localState.port) : []
|
|
5346
|
+
});
|
|
5069
5347
|
const remoteStatus = () => remoteSupervisor?.status() ?? {
|
|
5070
5348
|
provider: "tailscale-funnel",
|
|
5071
5349
|
phase: currentConfig().remote?.enabled === true ? "stopped" : "disabled",
|
|
@@ -5099,6 +5377,7 @@ function apply(ctx, options) {
|
|
|
5099
5377
|
}));
|
|
5100
5378
|
} catch {}
|
|
5101
5379
|
const update = updateInfo();
|
|
5380
|
+
const lanAddresses = localLANIPv4Addresses();
|
|
5102
5381
|
return {
|
|
5103
5382
|
protocolVersion: 2,
|
|
5104
5383
|
serverVersion: SERVER_VERSION,
|
|
@@ -5111,7 +5390,8 @@ function apply(ctx, options) {
|
|
|
5111
5390
|
activeConnections: connections.size,
|
|
5112
5391
|
historyBufferMax: currentConfig().historyBufferMax ?? 2e3,
|
|
5113
5392
|
debug: currentConfig().debug === true,
|
|
5114
|
-
lanAddresses
|
|
5393
|
+
lanAddresses,
|
|
5394
|
+
local: localStatus(lanAddresses),
|
|
5115
5395
|
remote: remoteStatus(),
|
|
5116
5396
|
devices
|
|
5117
5397
|
};
|
|
@@ -5381,6 +5661,158 @@ function apply(ctx, options) {
|
|
|
5381
5661
|
res.end(JSON.stringify({ ok: false }));
|
|
5382
5662
|
}
|
|
5383
5663
|
};
|
|
5664
|
+
const phoneHandlers = {
|
|
5665
|
+
health: handleHealth,
|
|
5666
|
+
pair: handlePair,
|
|
5667
|
+
upgrade: handleUpgrade
|
|
5668
|
+
};
|
|
5669
|
+
let localServer;
|
|
5670
|
+
let appliedLocalKey;
|
|
5671
|
+
let localDisposed = false;
|
|
5672
|
+
let localTail = Promise.resolve();
|
|
5673
|
+
const reconcileLocal = async () => {
|
|
5674
|
+
if (localDisposed) return;
|
|
5675
|
+
const config = currentConfig();
|
|
5676
|
+
const localConfig = config.local ?? {};
|
|
5677
|
+
const next = {
|
|
5678
|
+
enabled: config.enabled === true && localConfig.enabled !== false,
|
|
5679
|
+
port: normalizeLocalPort(localConfig.port)
|
|
5680
|
+
};
|
|
5681
|
+
const nextKey = JSON.stringify(next);
|
|
5682
|
+
if (nextKey === appliedLocalKey) return;
|
|
5683
|
+
appliedLocalKey = nextKey;
|
|
5684
|
+
const previous = localServer;
|
|
5685
|
+
localServer = void 0;
|
|
5686
|
+
await closeServer(previous);
|
|
5687
|
+
if (localDisposed) return;
|
|
5688
|
+
if (!next.enabled) {
|
|
5689
|
+
localState = {
|
|
5690
|
+
phase: "disabled",
|
|
5691
|
+
port: next.port,
|
|
5692
|
+
endpoints: [],
|
|
5693
|
+
updatedAt: Date.now()
|
|
5694
|
+
};
|
|
5695
|
+
log("local transport disabled");
|
|
5696
|
+
return;
|
|
5697
|
+
}
|
|
5698
|
+
localState = {
|
|
5699
|
+
phase: "starting",
|
|
5700
|
+
port: next.port,
|
|
5701
|
+
endpoints: [],
|
|
5702
|
+
updatedAt: Date.now()
|
|
5703
|
+
};
|
|
5704
|
+
const server = createPhoneServer(phoneHandlers);
|
|
5705
|
+
localServer = server;
|
|
5706
|
+
try {
|
|
5707
|
+
await listen(server, next.port, "0.0.0.0");
|
|
5708
|
+
if (localDisposed || localServer !== server) {
|
|
5709
|
+
await closeServer(server);
|
|
5710
|
+
return;
|
|
5711
|
+
}
|
|
5712
|
+
localState = {
|
|
5713
|
+
phase: "online",
|
|
5714
|
+
port: next.port,
|
|
5715
|
+
endpoints: [],
|
|
5716
|
+
updatedAt: Date.now()
|
|
5717
|
+
};
|
|
5718
|
+
log(`local transport listening on 0.0.0.0:${next.port}`);
|
|
5719
|
+
} catch (error) {
|
|
5720
|
+
if (localServer === server) localServer = void 0;
|
|
5721
|
+
await closeServer(server);
|
|
5722
|
+
const message = localListenError(error, next.port);
|
|
5723
|
+
localState = {
|
|
5724
|
+
phase: "error",
|
|
5725
|
+
port: next.port,
|
|
5726
|
+
endpoints: [],
|
|
5727
|
+
message,
|
|
5728
|
+
updatedAt: Date.now()
|
|
5729
|
+
};
|
|
5730
|
+
log("local transport failed: " + message);
|
|
5731
|
+
}
|
|
5732
|
+
};
|
|
5733
|
+
scheduleLocalReconcile = () => {
|
|
5734
|
+
localTail = localTail.then(reconcileLocal).catch((error) => log("local reconcile failed: " + String(error)));
|
|
5735
|
+
};
|
|
5736
|
+
const originServer = createPhoneServer(phoneHandlers);
|
|
5737
|
+
let originURL;
|
|
5738
|
+
let appliedRemoteKey;
|
|
5739
|
+
let remoteDisposed = false;
|
|
5740
|
+
let remoteTail = Promise.resolve();
|
|
5741
|
+
const reconcileRemote = async () => {
|
|
5742
|
+
if (remoteDisposed || originURL === void 0) return;
|
|
5743
|
+
const config = currentConfig();
|
|
5744
|
+
const remoteConfig = config.remote ?? {};
|
|
5745
|
+
const remotePort = remoteConfig.funnelPort === 8443 || remoteConfig.funnelPort === 1e4 ? remoteConfig.funnelPort : 443;
|
|
5746
|
+
const helperPath = remoteConfig.helperPath?.trim() || void 0;
|
|
5747
|
+
const next = {
|
|
5748
|
+
enabled: config.enabled === true && remoteConfig.enabled === true && remoteConfig.provider === "tailscale-funnel",
|
|
5749
|
+
hostname: normalizeRemoteHostname(remoteConfig.hostname),
|
|
5750
|
+
statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
|
|
5751
|
+
helperPath,
|
|
5752
|
+
funnelPort: remotePort,
|
|
5753
|
+
maxConnectionsPerSource: normalizeFunnelConnectionLimit(remoteConfig.maxConnectionsPerSource)
|
|
5754
|
+
};
|
|
5755
|
+
const nextKey = JSON.stringify(next);
|
|
5756
|
+
if (nextKey === appliedRemoteKey) return;
|
|
5757
|
+
const previous = remoteSupervisor;
|
|
5758
|
+
remoteSupervisor = void 0;
|
|
5759
|
+
if (previous !== void 0) await previous.dispose();
|
|
5760
|
+
if (remoteDisposed) return;
|
|
5761
|
+
const supervisor = new RemoteSupervisor({
|
|
5762
|
+
enabled: next.enabled,
|
|
5763
|
+
hostname: next.hostname,
|
|
5764
|
+
statePath: next.statePath,
|
|
5765
|
+
...next.helperPath ? { helperPath: next.helperPath } : {},
|
|
5766
|
+
funnelPort: next.funnelPort,
|
|
5767
|
+
maxConnectionsPerSource: next.maxConnectionsPerSource,
|
|
5768
|
+
log
|
|
5769
|
+
});
|
|
5770
|
+
remoteSupervisor = supervisor;
|
|
5771
|
+
appliedRemoteKey = nextKey;
|
|
5772
|
+
await supervisor.start(originURL);
|
|
5773
|
+
};
|
|
5774
|
+
scheduleRemoteReconcile = () => {
|
|
5775
|
+
remoteTail = remoteTail.then(reconcileRemote).catch((error) => log("remote reconcile failed: " + String(error)));
|
|
5776
|
+
};
|
|
5777
|
+
ctx.effect(() => {
|
|
5778
|
+
scheduleLocalReconcile?.();
|
|
5779
|
+
listen(originServer, 0, "127.0.0.1").then(() => {
|
|
5780
|
+
const address = originServer.address();
|
|
5781
|
+
if (address && typeof address === "object") {
|
|
5782
|
+
originURL = `http://127.0.0.1:${address.port}`;
|
|
5783
|
+
scheduleRemoteReconcile?.();
|
|
5784
|
+
}
|
|
5785
|
+
}, (error) => log("remote origin failed: " + String(error)));
|
|
5786
|
+
return async () => {
|
|
5787
|
+
localDisposed = true;
|
|
5788
|
+
remoteDisposed = true;
|
|
5789
|
+
scheduleLocalReconcile = void 0;
|
|
5790
|
+
scheduleRemoteReconcile = void 0;
|
|
5791
|
+
const activeLocal = localServer;
|
|
5792
|
+
localServer = void 0;
|
|
5793
|
+
await Promise.allSettled([closeServer(activeLocal), closeServer(originServer)]);
|
|
5794
|
+
localState = {
|
|
5795
|
+
phase: "stopped",
|
|
5796
|
+
port: localState.port,
|
|
5797
|
+
endpoints: [],
|
|
5798
|
+
updatedAt: Date.now()
|
|
5799
|
+
};
|
|
5800
|
+
await Promise.allSettled([localTail, remoteTail.then(async () => {
|
|
5801
|
+
const supervisor = remoteSupervisor;
|
|
5802
|
+
remoteSupervisor = void 0;
|
|
5803
|
+
if (supervisor !== void 0) await supervisor.dispose();
|
|
5804
|
+
})]);
|
|
5805
|
+
};
|
|
5806
|
+
}, "deeppilot: independent transports");
|
|
5807
|
+
const sweep = setInterval(() => {
|
|
5808
|
+
const now = Date.now();
|
|
5809
|
+
for (const connection of connections) if (connection.isStale(now, 6e4)) {
|
|
5810
|
+
log("dropping stale connection");
|
|
5811
|
+
connection.closeIdle();
|
|
5812
|
+
connections.delete(connection);
|
|
5813
|
+
}
|
|
5814
|
+
}, 3e4);
|
|
5815
|
+
ctx.effect(() => () => clearInterval(sweep), "deeppilot: stale sweep");
|
|
5384
5816
|
/**
|
|
5385
5817
|
* Whether the resident Gateway Client represents a real phone surface. A
|
|
5386
5818
|
* paired device remains answerable even while offline: HostBridge retains an
|
|
@@ -5423,7 +5855,7 @@ function apply(ctx, options) {
|
|
|
5423
5855
|
const webCtx = sub;
|
|
5424
5856
|
const web = webCtx.webServer;
|
|
5425
5857
|
if (!web) {
|
|
5426
|
-
log("webServer service absent
|
|
5858
|
+
log("webServer service absent; 3080 compatibility routes unavailable (independent transports remain active)");
|
|
5427
5859
|
return;
|
|
5428
5860
|
}
|
|
5429
5861
|
webCtx.effect(() => web.registerUpgrade({
|
|
@@ -5440,98 +5872,8 @@ function apply(ctx, options) {
|
|
|
5440
5872
|
path: "/phone/pair",
|
|
5441
5873
|
handler: handlePair
|
|
5442
5874
|
}), "deeppilot: /phone/pair");
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
for (const connection of connections) if (connection.isStale(now, 6e4)) {
|
|
5446
|
-
log("dropping stale connection");
|
|
5447
|
-
connection.closeIdle();
|
|
5448
|
-
connections.delete(connection);
|
|
5449
|
-
}
|
|
5450
|
-
}, 3e4);
|
|
5451
|
-
webCtx.effect(() => () => clearInterval(sweep), "deeppilot: stale sweep");
|
|
5452
|
-
const originServer = createServer((req, res) => {
|
|
5453
|
-
let path = "/";
|
|
5454
|
-
try {
|
|
5455
|
-
path = new URL(req.url ?? "/", "http://phone.local").pathname;
|
|
5456
|
-
} catch {}
|
|
5457
|
-
if (path === "/phone/health") handleHealth(req, res);
|
|
5458
|
-
else if (path === "/phone/pair") handlePair(req, res);
|
|
5459
|
-
else {
|
|
5460
|
-
res.statusCode = 404;
|
|
5461
|
-
res.end("not found");
|
|
5462
|
-
}
|
|
5463
|
-
});
|
|
5464
|
-
originServer.on("upgrade", (req, socket, head) => {
|
|
5465
|
-
let path = "/";
|
|
5466
|
-
try {
|
|
5467
|
-
path = new URL(req.url ?? "/", "http://phone.local").pathname;
|
|
5468
|
-
} catch {}
|
|
5469
|
-
if (path !== "/phone") {
|
|
5470
|
-
socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
5471
|
-
return;
|
|
5472
|
-
}
|
|
5473
|
-
handleUpgrade(req, socket, head);
|
|
5474
|
-
});
|
|
5475
|
-
let originURL;
|
|
5476
|
-
let appliedRemoteKey;
|
|
5477
|
-
let remoteDisposed = false;
|
|
5478
|
-
let reconcileTail = Promise.resolve();
|
|
5479
|
-
const reconcileRemote = async () => {
|
|
5480
|
-
if (remoteDisposed || originURL === void 0) return;
|
|
5481
|
-
const config = currentConfig();
|
|
5482
|
-
const remoteConfig = config.remote ?? {};
|
|
5483
|
-
const remotePort = remoteConfig.funnelPort === 8443 || remoteConfig.funnelPort === 1e4 ? remoteConfig.funnelPort : 443;
|
|
5484
|
-
const helperPath = remoteConfig.helperPath?.trim() || void 0;
|
|
5485
|
-
const next = {
|
|
5486
|
-
enabled: config.enabled === true && remoteConfig.enabled === true && remoteConfig.provider === "tailscale-funnel",
|
|
5487
|
-
hostname: normalizeRemoteHostname(remoteConfig.hostname),
|
|
5488
|
-
statePath: remoteConfig.statePath?.trim() || join(dataDir, "tailscale"),
|
|
5489
|
-
helperPath,
|
|
5490
|
-
funnelPort: remotePort,
|
|
5491
|
-
maxConnectionsPerSource: normalizeFunnelConnectionLimit(remoteConfig.maxConnectionsPerSource)
|
|
5492
|
-
};
|
|
5493
|
-
const nextKey = JSON.stringify(next);
|
|
5494
|
-
if (nextKey === appliedRemoteKey) return;
|
|
5495
|
-
const previous = remoteSupervisor;
|
|
5496
|
-
remoteSupervisor = void 0;
|
|
5497
|
-
if (previous !== void 0) await previous.dispose();
|
|
5498
|
-
if (remoteDisposed) return;
|
|
5499
|
-
const supervisor = new RemoteSupervisor({
|
|
5500
|
-
enabled: next.enabled,
|
|
5501
|
-
hostname: next.hostname,
|
|
5502
|
-
statePath: next.statePath,
|
|
5503
|
-
...next.helperPath ? { helperPath: next.helperPath } : {},
|
|
5504
|
-
funnelPort: next.funnelPort,
|
|
5505
|
-
maxConnectionsPerSource: next.maxConnectionsPerSource,
|
|
5506
|
-
log
|
|
5507
|
-
});
|
|
5508
|
-
remoteSupervisor = supervisor;
|
|
5509
|
-
appliedRemoteKey = nextKey;
|
|
5510
|
-
await supervisor.start(originURL);
|
|
5511
|
-
};
|
|
5512
|
-
scheduleRemoteReconcile = () => {
|
|
5513
|
-
reconcileTail = reconcileTail.then(reconcileRemote).catch((error) => log("remote reconcile failed: " + String(error)));
|
|
5514
|
-
};
|
|
5515
|
-
originServer.listen(0, "127.0.0.1", () => {
|
|
5516
|
-
const address = originServer.address();
|
|
5517
|
-
if (address && typeof address === "object") {
|
|
5518
|
-
originURL = `http://127.0.0.1:${address.port}`;
|
|
5519
|
-
scheduleRemoteReconcile?.();
|
|
5520
|
-
}
|
|
5521
|
-
});
|
|
5522
|
-
originServer.on("error", (error) => log("remote origin failed: " + String(error)));
|
|
5523
|
-
webCtx.effect(() => () => {
|
|
5524
|
-
remoteDisposed = true;
|
|
5525
|
-
scheduleRemoteReconcile = void 0;
|
|
5526
|
-
originServer.close();
|
|
5527
|
-
reconcileTail = reconcileTail.then(async () => {
|
|
5528
|
-
const supervisor = remoteSupervisor;
|
|
5529
|
-
remoteSupervisor = void 0;
|
|
5530
|
-
if (supervisor !== void 0) await supervisor.dispose();
|
|
5531
|
-
});
|
|
5532
|
-
}, "deeppilot: embedded Funnel");
|
|
5533
|
-
if (enabledNow()) log("/phone WebSocket registered");
|
|
5534
|
-
else log("bridge disabled; /phone refuses connections until re-enabled and restarted");
|
|
5875
|
+
if (enabledNow()) log("/phone compatibility routes registered on DSH web server");
|
|
5876
|
+
else log("bridge disabled; /phone refuses connections until re-enabled");
|
|
5535
5877
|
});
|
|
5536
5878
|
ctx.effect(() => async () => {
|
|
5537
5879
|
closeAllConnections();
|