@oversk7/pi-web-dsh-style 1.0.0 → 1.1.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/README.md +52 -3
- package/THIRD_PARTY_NOTICES.md +4 -0
- package/attachment-parser.ts +210 -0
- package/attachment-store.ts +67 -24
- package/background-jobs.ts +52 -0
- package/extension-settings.ts +50 -0
- package/file-preview.ts +7 -1
- package/frp-process.ts +135 -0
- package/index.ts +35 -3
- package/local-session.ts +10 -2
- package/network-access.ts +171 -0
- package/network-settings.ts +146 -0
- package/package.json +17 -4
- package/pi-rpc.ts +7 -5
- package/server.ts +408 -79
- package/terminal-proxy.ts +50 -0
- package/transcript.ts +13 -0
- package/web/app.css +253 -44
- package/web/app.js +5097 -4226
- package/web/assets/markdown.LICENSE.txt +200 -0
- package/web/assets/markdown.js +25 -0
- package/web/assets/themes/sakura.webp +0 -0
- package/web/assets/themes/starlight.webp +0 -0
- package/web/index.html +6 -2
- package/web/markdown.ts +118 -0
- package/web/slash-commands.d.ts +11 -0
- package/web/slash-commands.js +46 -0
- package/web/themes.css +158 -0
package/frp-process.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export type FrpStatus = { state: "stopped" | "connecting" | "connected" | "error"; message: string };
|
|
6
|
+
type StartOptions = { executable: string; args: string[]; cwd: string };
|
|
7
|
+
type Run = { child: ChildProcess; stopped: boolean; closed: Promise<void> };
|
|
8
|
+
|
|
9
|
+
export class FrpProcess {
|
|
10
|
+
status: FrpStatus = { state: "stopped", message: "隧道已停止" };
|
|
11
|
+
private current?: Run;
|
|
12
|
+
private pending: Promise<void> = Promise.resolve();
|
|
13
|
+
|
|
14
|
+
private onStatus?: (status: FrpStatus) => void;
|
|
15
|
+
|
|
16
|
+
constructor(onStatus?: (status: FrpStatus) => void) {
|
|
17
|
+
this.onStatus = onStatus;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private update(state: FrpStatus["state"], message: string): void {
|
|
21
|
+
if (this.status.state === state && this.status.message === message) return;
|
|
22
|
+
this.status = { state, message };
|
|
23
|
+
this.onStatus?.({ ...this.status });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private enqueue(action: () => Promise<void>): Promise<void> {
|
|
27
|
+
const result = this.pending.then(action);
|
|
28
|
+
this.pending = result.catch(() => {});
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
start(options: StartOptions): Promise<void> {
|
|
33
|
+
const launch = { ...options, args: [...options.args] };
|
|
34
|
+
return this.enqueue(async () => {
|
|
35
|
+
await this.stopCurrent();
|
|
36
|
+
this.update("connecting", "正在连接隧道服务器");
|
|
37
|
+
const windows = process.platform === "win32";
|
|
38
|
+
let child: ChildProcess;
|
|
39
|
+
try {
|
|
40
|
+
child = spawn(windows
|
|
41
|
+
? join(process.env.SystemRoot || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
|
|
42
|
+
: launch.executable, windows
|
|
43
|
+
? ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", fileURLToPath(new URL("./windows-rpc-job.ps1", import.meta.url))]
|
|
44
|
+
: launch.args, {
|
|
45
|
+
cwd: launch.cwd,
|
|
46
|
+
env: { ...process.env, NO_COLOR: "1", ...(windows ? {
|
|
47
|
+
PI_WEB_RPC_JOB: JSON.stringify({ ownerPid: process.pid, ...launch }),
|
|
48
|
+
} : {}) },
|
|
49
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
shell: false,
|
|
52
|
+
detached: !windows,
|
|
53
|
+
});
|
|
54
|
+
} catch {
|
|
55
|
+
this.update("error", "隧道进程启动失败");
|
|
56
|
+
throw new Error("隧道进程启动失败");
|
|
57
|
+
}
|
|
58
|
+
let resolveClosed!: () => void;
|
|
59
|
+
const run: Run = { child, stopped: false, closed: new Promise<void>((resolve) => { resolveClosed = resolve; }) };
|
|
60
|
+
this.current = run;
|
|
61
|
+
const line = (text: string) => {
|
|
62
|
+
if (this.current !== run || run.stopped) return;
|
|
63
|
+
const clean = text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
64
|
+
if (/start proxy success/i.test(clean)) {
|
|
65
|
+
this.update("connected", "隧道已连接");
|
|
66
|
+
} else if (/login.*failed|connection.*(?:closed|lost)|connect.*(?:fail|refused|timeout)|reconnect|retry|re-?login|连接.*(?:丢失|断开|失败)|重试/i.test(clean)) {
|
|
67
|
+
this.update("connecting", "连接中断,正在重连");
|
|
68
|
+
} else if (/\b(?:error|fatal|failed)\b|\[E\]|错误/i.test(clean)) {
|
|
69
|
+
this.update("error", "隧道发生错误,请检查连接设置");
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
for (const stream of [child.stdout, child.stderr]) {
|
|
73
|
+
let buffer = "";
|
|
74
|
+
stream?.setEncoding("utf8");
|
|
75
|
+
stream?.on("data", (chunk: string) => {
|
|
76
|
+
buffer += chunk;
|
|
77
|
+
let end: number;
|
|
78
|
+
while ((end = buffer.indexOf("\n")) >= 0) {
|
|
79
|
+
line(buffer.slice(0, end));
|
|
80
|
+
buffer = buffer.slice(end + 1);
|
|
81
|
+
}
|
|
82
|
+
// Bound unfinished log lines from a long-lived process.
|
|
83
|
+
if (buffer.length > 16_384) buffer = buffer.slice(-16_384);
|
|
84
|
+
});
|
|
85
|
+
stream?.on("end", () => { if (buffer) line(buffer); });
|
|
86
|
+
}
|
|
87
|
+
child.on("error", () => {
|
|
88
|
+
if (this.current === run && !run.stopped) this.update("error", "隧道进程启动失败");
|
|
89
|
+
});
|
|
90
|
+
child.once("exit", () => { if (!windows) this.terminate(run); });
|
|
91
|
+
child.once("close", () => {
|
|
92
|
+
if (this.current === run) {
|
|
93
|
+
this.current = undefined;
|
|
94
|
+
if (run.stopped) this.update("stopped", "隧道已停止");
|
|
95
|
+
else this.update("error", "隧道进程意外退出");
|
|
96
|
+
}
|
|
97
|
+
resolveClosed();
|
|
98
|
+
});
|
|
99
|
+
// On Windows, spawn confirms the launcher; target creation failures arrive as an exit.
|
|
100
|
+
await new Promise<void>((resolve, reject) => {
|
|
101
|
+
child.once("spawn", resolve);
|
|
102
|
+
child.once("error", () => reject(new Error("隧道进程启动失败")));
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private terminate(run: Run): void {
|
|
108
|
+
if (process.platform !== "win32" && run.child.pid) {
|
|
109
|
+
try { process.kill(-run.child.pid, "SIGKILL"); } catch { /* The process group has already exited. */ }
|
|
110
|
+
}
|
|
111
|
+
run.child.kill("SIGKILL");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async stopCurrent(): Promise<void> {
|
|
115
|
+
const run = this.current;
|
|
116
|
+
if (run) {
|
|
117
|
+
run.stopped = true;
|
|
118
|
+
this.terminate(run);
|
|
119
|
+
await run.closed;
|
|
120
|
+
}
|
|
121
|
+
this.update("stopped", "隧道已停止");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
stop(): Promise<void> {
|
|
125
|
+
return this.enqueue(() => this.stopCurrent());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
kill(): void {
|
|
129
|
+
const run = this.current;
|
|
130
|
+
if (run) {
|
|
131
|
+
run.stopped = true;
|
|
132
|
+
this.terminate(run);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { getPackageDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai/compat";
|
|
5
|
+
import type { NetworkMode } from "./network-settings.ts";
|
|
5
6
|
|
|
6
7
|
interface MaintenanceCommandResult {
|
|
7
8
|
stdout: string;
|
|
@@ -15,6 +16,8 @@ interface WebServerModule {
|
|
|
15
16
|
defaultCwd?: string;
|
|
16
17
|
port?: number;
|
|
17
18
|
open?: boolean;
|
|
19
|
+
lan?: boolean;
|
|
20
|
+
mode?: NetworkMode;
|
|
18
21
|
initialModelInfo?: {
|
|
19
22
|
models: unknown[];
|
|
20
23
|
thinkingLevels: string[];
|
|
@@ -27,10 +30,13 @@ interface WebServerModule {
|
|
|
27
30
|
}): Promise<string>;
|
|
28
31
|
stopWebServer(): Promise<void>;
|
|
29
32
|
getWebUrl(): string | undefined;
|
|
33
|
+
getWebLanUrls(): string[];
|
|
34
|
+
getWebConnectionOptions(): { port: number; lan: boolean; mode: NetworkMode } | undefined;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
interface ReloadState {
|
|
33
38
|
resumeWebAfterReload: boolean;
|
|
39
|
+
connection?: { port: number; lan: boolean; mode: NetworkMode };
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
const RELOAD_STATE_KEY = Symbol.for("@oversk7/pi-web-dsh-style/reload-state");
|
|
@@ -57,6 +63,9 @@ function parseArgs(args?: string): Record<string, string | boolean> {
|
|
|
57
63
|
const token = tokens[i];
|
|
58
64
|
if (token === "--stop") out.stop = true;
|
|
59
65
|
else if (token === "--no-open") out.open = "false";
|
|
66
|
+
else if (token === "--lan") out.mode = "lan";
|
|
67
|
+
else if (token === "--local") out.mode = "local";
|
|
68
|
+
else if (token === "--relay") out.mode = "relay";
|
|
60
69
|
else if (token === "--port" && tokens[i + 1]) {
|
|
61
70
|
out.port = tokens[++i];
|
|
62
71
|
}
|
|
@@ -78,6 +87,7 @@ async function startServer(
|
|
|
78
87
|
defaultCwd: ctx.cwd,
|
|
79
88
|
port: typeof options.port === "string" ? Number(options.port) : undefined,
|
|
80
89
|
open: options.open !== "false",
|
|
90
|
+
mode: options.mode as NetworkMode | undefined,
|
|
81
91
|
initialModelInfo: {
|
|
82
92
|
models: initialModels,
|
|
83
93
|
thinkingLevels: ctx.model ? getSupportedThinkingLevels(ctx.model) : [],
|
|
@@ -103,6 +113,7 @@ async function startServer(
|
|
|
103
113
|
},
|
|
104
114
|
});
|
|
105
115
|
ctx.ui.notify(`pi-web: ${url}`, "info");
|
|
116
|
+
for (const lanUrl of mod.getWebLanUrls()) ctx.ui.notify(`手机访问: ${lanUrl}`, "info");
|
|
106
117
|
} catch (error) {
|
|
107
118
|
serverPromise = undefined;
|
|
108
119
|
ctx.ui.notify(`pi-web failed: ${String(error)}`, "error");
|
|
@@ -141,17 +152,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
141
152
|
default: false,
|
|
142
153
|
});
|
|
143
154
|
|
|
155
|
+
pi.registerFlag("web-lan", {
|
|
156
|
+
description: "Start the web UI with authenticated LAN access for phones",
|
|
157
|
+
type: "boolean",
|
|
158
|
+
default: false,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
pi.registerFlag("web-relay", {
|
|
162
|
+
description: "Start the web UI with the configured native frp relay",
|
|
163
|
+
type: "boolean",
|
|
164
|
+
default: false,
|
|
165
|
+
});
|
|
166
|
+
|
|
144
167
|
pi.on("session_start", async (_event, ctx) => {
|
|
145
168
|
const resumeAfterReload = reloadState.resumeWebAfterReload;
|
|
169
|
+
const connection = reloadState.connection;
|
|
146
170
|
reloadState.resumeWebAfterReload = false;
|
|
147
|
-
|
|
148
|
-
|
|
171
|
+
reloadState.connection = undefined;
|
|
172
|
+
if (pi.getFlag("web") === true || pi.getFlag("web-lan") === true || pi.getFlag("web-relay") === true || resumeAfterReload) {
|
|
173
|
+
await startServer(pi, ctx, resumeAfterReload && connection
|
|
174
|
+
? { open: "false", port: String(connection.port), mode: connection.mode }
|
|
175
|
+
: pi.getFlag("web-relay") === true ? { mode: "relay" }
|
|
176
|
+
: pi.getFlag("web-lan") === true ? { mode: "lan" } : {});
|
|
149
177
|
}
|
|
150
178
|
});
|
|
151
179
|
|
|
152
|
-
pi.on("session_shutdown", async () => {
|
|
180
|
+
pi.on("session_shutdown", async (event) => {
|
|
153
181
|
if (!serverPromise) return;
|
|
154
182
|
const mod = await serverPromise;
|
|
183
|
+
if (event.reason === "reload" && mod.getWebUrl()) {
|
|
184
|
+
reloadState.resumeWebAfterReload = true;
|
|
185
|
+
reloadState.connection = mod.getWebConnectionOptions();
|
|
186
|
+
}
|
|
155
187
|
await mod.stopWebServer();
|
|
156
188
|
serverPromise = undefined;
|
|
157
189
|
});
|
package/local-session.ts
CHANGED
|
@@ -18,6 +18,7 @@ interface SessionEntry {
|
|
|
18
18
|
parentId?: string | null;
|
|
19
19
|
timestamp?: string;
|
|
20
20
|
message?: Json;
|
|
21
|
+
usage?: Json;
|
|
21
22
|
provider?: string;
|
|
22
23
|
modelId?: string;
|
|
23
24
|
thinkingLevel?: string;
|
|
@@ -143,6 +144,14 @@ export function loadLocalSession(sessionFile: string, fallbackCwd?: string): Loc
|
|
|
143
144
|
let input = 0;
|
|
144
145
|
let output = 0;
|
|
145
146
|
let cost = 0;
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
const message = entry.type === "message" ? entry.message : undefined;
|
|
149
|
+
const usage = message?.usage ?? ((entry.type === "compaction" || entry.type === "branch_summary") ? entry.usage : undefined);
|
|
150
|
+
const usageCost = usage && typeof usage === "object" ? (usage as Json).cost : undefined;
|
|
151
|
+
const total = usageCost && typeof usageCost === "object" ? (usageCost as Json).total : usageCost;
|
|
152
|
+
if (typeof total === "number" && Number.isFinite(total)) cost += total;
|
|
153
|
+
else if (typeof message?.cost === "number" && Number.isFinite(message.cost)) cost += message.cost;
|
|
154
|
+
}
|
|
146
155
|
for (const raw of rawMessages) {
|
|
147
156
|
const message = raw as Json;
|
|
148
157
|
const role = message.role;
|
|
@@ -158,7 +167,6 @@ export function loadLocalSession(sessionFile: string, fallbackCwd?: string): Loc
|
|
|
158
167
|
input += Number(usage.input ?? 0);
|
|
159
168
|
output += Number(usage.output ?? 0);
|
|
160
169
|
}
|
|
161
|
-
if (typeof message.cost === "number") cost += message.cost;
|
|
162
170
|
}
|
|
163
171
|
|
|
164
172
|
const state: Json = {
|
|
@@ -182,7 +190,7 @@ export function loadLocalSession(sessionFile: string, fallbackCwd?: string): Loc
|
|
|
182
190
|
toolCalls,
|
|
183
191
|
tokens: { input, output },
|
|
184
192
|
contextUsage: { tokens: contextTokens },
|
|
185
|
-
cost
|
|
193
|
+
cost,
|
|
186
194
|
},
|
|
187
195
|
title: sessionName || undefined,
|
|
188
196
|
sessionFile: manager.getSessionFile(),
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createHmac, randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import type { IncomingMessage } from "node:http";
|
|
3
|
+
import { networkInterfaces } from "node:os";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { loadJsonWithLegacyMigration, writeJsonAtomically } from "./state-store.ts";
|
|
6
|
+
|
|
7
|
+
const derivePassword = promisify(scrypt);
|
|
8
|
+
const LOGIN_SECONDS = 30 * 24 * 60 * 60;
|
|
9
|
+
|
|
10
|
+
export interface WebProxyConfig {
|
|
11
|
+
origin: string;
|
|
12
|
+
secret: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function equalSecret(value: unknown, expected: string): boolean {
|
|
16
|
+
if (typeof value !== "string") return false;
|
|
17
|
+
const candidate = Buffer.from(value);
|
|
18
|
+
const secret = Buffer.from(expected);
|
|
19
|
+
return candidate.length === secret.length && timingSafeEqual(candidate, secret);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface WebCredentials {
|
|
23
|
+
salt: string;
|
|
24
|
+
passwordHash: string;
|
|
25
|
+
sessionKey: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validCredentials(value: unknown): value is WebCredentials {
|
|
29
|
+
const data = value as WebCredentials | null;
|
|
30
|
+
return Boolean(data && typeof data.salt === "string" && /^[a-f0-9]{32}$/.test(data.salt)
|
|
31
|
+
&& typeof data.passwordHash === "string" && /^[a-f0-9]{128}$/.test(data.passwordHash)
|
|
32
|
+
&& typeof data.sessionKey === "string" && /^[a-f0-9]{64}$/.test(data.sessionKey));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
36
|
+
|
|
37
|
+
export function lanAddresses(): string[] {
|
|
38
|
+
return [...new Set(Object.values(networkInterfaces()).flatMap((entries) => (
|
|
39
|
+
(entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal).map((entry) => entry.address)
|
|
40
|
+
)))];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function phoneLanAddress(interfaces = networkInterfaces()): string | undefined {
|
|
44
|
+
const candidates = Object.entries(interfaces)
|
|
45
|
+
.filter(([name]) => !/vEthernet|WSL|Hyper-V|VirtualBox|VMware|Loopback/i.test(name))
|
|
46
|
+
.flatMap(([name, entries]) => (entries ?? [])
|
|
47
|
+
.filter((entry) => entry.family === "IPv4" && !entry.internal && !entry.address.startsWith("169.254."))
|
|
48
|
+
.map((entry) => ({ name, address: entry.address })));
|
|
49
|
+
return (candidates.find(({ name }) => /wi-?fi|wlan|无线/i.test(name)) ?? candidates[0])?.address;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function requestHostname(req: IncomingMessage): string {
|
|
53
|
+
try {
|
|
54
|
+
const authority = req.headers.host ?? "";
|
|
55
|
+
const url = new URL(`http://${authority}`);
|
|
56
|
+
if (url.host.toLowerCase() !== authority.toLowerCase() && `${url.hostname}:80` !== authority.toLowerCase()) return "";
|
|
57
|
+
return url.hostname;
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isLocalRequest(req: IncomingMessage): boolean {
|
|
64
|
+
const address = req.socket.remoteAddress;
|
|
65
|
+
return (address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1")
|
|
66
|
+
&& LOOPBACK_HOSTS.has(requestHostname(req))
|
|
67
|
+
&& !req.headers["x-pi-web-proxy-secret"];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class WebAccess {
|
|
71
|
+
lan: boolean;
|
|
72
|
+
private credentials?: WebCredentials;
|
|
73
|
+
private readonly proxy?: WebProxyConfig;
|
|
74
|
+
|
|
75
|
+
constructor(lan: boolean, credentials?: WebCredentials, proxy?: WebProxyConfig) {
|
|
76
|
+
this.lan = lan;
|
|
77
|
+
this.credentials = credentials;
|
|
78
|
+
this.proxy = proxy;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static async load(lan: boolean, path: string, proxy?: WebProxyConfig): Promise<WebAccess> {
|
|
82
|
+
const credentials = await loadJsonWithLegacyMigration({ stateFile: path, validate: validCredentials });
|
|
83
|
+
return new WebAccess(lan, credentials, proxy);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get remoteEnabled(): boolean {
|
|
87
|
+
return this.lan || Boolean(this.proxy);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private isProxyRequest(req: IncomingMessage): boolean {
|
|
91
|
+
return Boolean(this.proxy
|
|
92
|
+
&& req.headers.host?.toLowerCase() === new URL(this.proxy.origin).host
|
|
93
|
+
&& equalSecret(req.headers["x-pi-web-proxy-secret"], this.proxy.secret));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get passwordConfigured(): boolean {
|
|
97
|
+
return Boolean(this.credentials);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async setPassword(password: unknown, path: string): Promise<void> {
|
|
101
|
+
if (typeof password !== "string" || !password.trim() || password.length > 256) {
|
|
102
|
+
throw new Error("请输入 1–256 个字符的密码");
|
|
103
|
+
}
|
|
104
|
+
const salt = randomBytes(16).toString("hex");
|
|
105
|
+
const passwordHash = (await derivePassword(password, salt, 64) as Buffer).toString("hex");
|
|
106
|
+
const credentials = { salt, passwordHash, sessionKey: randomBytes(32).toString("hex") };
|
|
107
|
+
await writeJsonAtomically(path, credentials);
|
|
108
|
+
this.credentials = credentials;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
trusted(req: IncomingMessage): boolean {
|
|
112
|
+
const hostname = requestHostname(req);
|
|
113
|
+
const proxied = this.isProxyRequest(req);
|
|
114
|
+
if (req.headers["x-pi-web-proxy-secret"] && !proxied) return false;
|
|
115
|
+
if (!proxied && !LOOPBACK_HOSTS.has(hostname) && !(this.lan && lanAddresses().includes(hostname))) return false;
|
|
116
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
117
|
+
if (req.headers.origin) {
|
|
118
|
+
try {
|
|
119
|
+
const expected = proxied ? this.proxy!.origin : new URL(`http://${req.headers.host}`).origin;
|
|
120
|
+
if (new URL(req.headers.origin).origin !== expected) return false;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async acceptsPassword(value: unknown): Promise<boolean> {
|
|
129
|
+
const credentials = this.credentials;
|
|
130
|
+
if (!credentials || typeof value !== "string" || value.length > 256) return false;
|
|
131
|
+
const candidate = await derivePassword(value, credentials.salt, 64) as Buffer;
|
|
132
|
+
return credentials === this.credentials && timingSafeEqual(candidate, Buffer.from(credentials.passwordHash, "hex"));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private signature(expires: string): string {
|
|
136
|
+
return createHmac("sha256", Buffer.from(this.credentials!.sessionKey, "hex")).update(expires).digest("hex");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private acceptsCookie(value: string): boolean {
|
|
140
|
+
if (!this.credentials) return false;
|
|
141
|
+
const match = /^(\d+)\.([a-f0-9]{64})$/.exec(value);
|
|
142
|
+
if (!match || Number(match[1]) <= Math.floor(Date.now() / 1000)) return false;
|
|
143
|
+
return timingSafeEqual(Buffer.from(match[2], "hex"), Buffer.from(this.signature(match[1]), "hex"));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
cookieName(req: IncomingMessage): string {
|
|
147
|
+
return `pi_web_access_${req.socket.localPort}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
authorized(req: IncomingMessage): boolean {
|
|
151
|
+
if (isLocalRequest(req)) return true;
|
|
152
|
+
if (!this.remoteEnabled) return false;
|
|
153
|
+
const prefix = `${this.cookieName(req)}=`;
|
|
154
|
+
return (req.headers.cookie ?? "").split(";").some((part) => {
|
|
155
|
+
const cookie = part.trim();
|
|
156
|
+
return cookie.startsWith(prefix) && this.acceptsCookie(cookie.slice(prefix.length));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
cookie(req: IncomingMessage): string {
|
|
161
|
+
const expires = String(Math.floor(Date.now() / 1000) + LOGIN_SECONDS);
|
|
162
|
+
const secure = this.isProxyRequest(req) ? "; Secure" : "";
|
|
163
|
+
return `${this.cookieName(req)}=${expires}.${this.signature(expires)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${LOGIN_SECONDS}${secure}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
urls(port: number): string[] {
|
|
167
|
+
if (this.proxy) return [`${this.proxy.origin}/`];
|
|
168
|
+
const address = this.lan ? phoneLanAddress() : undefined;
|
|
169
|
+
return address ? [`http://${address}:${port}/`] : [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { access, chmod, stat } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { loadJsonWithLegacyMigration, writeJsonAtomically } from "./state-store.ts";
|
|
6
|
+
import type { WebProxyConfig } from "./network-access.ts";
|
|
7
|
+
|
|
8
|
+
export type NetworkMode = "local" | "lan" | "relay";
|
|
9
|
+
|
|
10
|
+
export interface RelaySettings {
|
|
11
|
+
serverAddr: string;
|
|
12
|
+
serverPort: number;
|
|
13
|
+
token: string;
|
|
14
|
+
proxyName: string;
|
|
15
|
+
secretKey: string;
|
|
16
|
+
origin: string;
|
|
17
|
+
proxySecret: string;
|
|
18
|
+
frpcPath: string;
|
|
19
|
+
visitorPort: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface NetworkSettings {
|
|
23
|
+
mode: NetworkMode;
|
|
24
|
+
relay?: RelaySettings;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function text(value: unknown, label: string, max = 1024): string {
|
|
28
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || /[\x00-\x1f\x7f]/.test(value)) {
|
|
29
|
+
throw new Error(`${label}不能为空,且不能包含换行或控制字符`);
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function port(value: unknown, label: string): number {
|
|
35
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > 65535) throw new Error(`${label}必须是 1–65535 之间的整数`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function validateRelay(value: RelaySettings): RelaySettings {
|
|
40
|
+
text(value.serverAddr, "服务器地址", 253);
|
|
41
|
+
if (!/^[a-zA-Z0-9.-]+$/.test(value.serverAddr)) throw new Error("服务器地址应填写域名或 IPv4,不包含协议和端口");
|
|
42
|
+
port(value.serverPort, "frps 端口");
|
|
43
|
+
port(value.visitorPort, "服务器内部端口");
|
|
44
|
+
text(value.token, "frps 认证密钥");
|
|
45
|
+
text(value.frpcPath, "frpc 路径");
|
|
46
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(value.proxyName)) throw new Error("隧道名称只能包含英文字母、数字、下划线和短横线");
|
|
47
|
+
for (const key of [value.secretKey, value.proxySecret]) {
|
|
48
|
+
if (typeof key !== "string" || !/^[a-zA-Z0-9_-]{32,256}$/.test(key)) throw new Error("中转密钥必须包含 32–256 个字母、数字、下划线或短横线");
|
|
49
|
+
}
|
|
50
|
+
let url: URL;
|
|
51
|
+
try { url = new URL(value.origin); } catch { throw new Error("手机访问地址应填写 HTTPS 域名,例如 https://web.example.com"); }
|
|
52
|
+
if (url.protocol !== "https:" || url.origin !== value.origin || !/^[a-zA-Z0-9.-]+$/.test(url.hostname)) {
|
|
53
|
+
throw new Error("手机访问地址必须是 HTTPS 根地址,不包含路径、查询参数或用户名");
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function updateNetworkSettings(input: unknown, current: NetworkSettings = { mode: "local" }): NetworkSettings {
|
|
59
|
+
if (!input || typeof input !== "object") throw new Error("网络配置格式不正确");
|
|
60
|
+
const data = input as Record<string, unknown>;
|
|
61
|
+
if (!["local", "lan", "relay"].includes(data.mode as string)) throw new Error("请选择仅本机、局域网或服务器中转");
|
|
62
|
+
const mode = data.mode as NetworkMode;
|
|
63
|
+
if (data.relay === undefined) {
|
|
64
|
+
if (mode === "relay" && !current.relay) throw new Error("请先在设置中填写服务器中转配置");
|
|
65
|
+
return { mode, ...(current.relay ? { relay: current.relay } : {}) };
|
|
66
|
+
}
|
|
67
|
+
if (!data.relay || typeof data.relay !== "object") throw new Error("服务器中转配置格式不正确");
|
|
68
|
+
const incoming = data.relay as Record<string, unknown>;
|
|
69
|
+
const previous = current.relay;
|
|
70
|
+
const relay = validateRelay({
|
|
71
|
+
serverAddr: incoming.serverAddr as string,
|
|
72
|
+
serverPort: incoming.serverPort as number,
|
|
73
|
+
token: (incoming.token || previous?.token) as string,
|
|
74
|
+
proxyName: (incoming.proxyName ?? previous?.proxyName ?? `pi-web-${randomBytes(4).toString("hex")}`) as string,
|
|
75
|
+
secretKey: (incoming.secretKey ?? previous?.secretKey ?? randomBytes(32).toString("hex")) as string,
|
|
76
|
+
origin: incoming.origin as string,
|
|
77
|
+
proxySecret: (incoming.proxySecret ?? previous?.proxySecret ?? randomBytes(32).toString("hex")) as string,
|
|
78
|
+
frpcPath: (incoming.frpcPath || "frpc") as string,
|
|
79
|
+
visitorPort: (incoming.visitorPort ?? 13000) as number,
|
|
80
|
+
});
|
|
81
|
+
return { mode, relay };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function loadNetworkSettings(path: string): Promise<NetworkSettings> {
|
|
85
|
+
return await loadJsonWithLegacyMigration({ stateFile: path, validate: (value): value is NetworkSettings => {
|
|
86
|
+
try {
|
|
87
|
+
const data = value as NetworkSettings;
|
|
88
|
+
if (!data || !["local", "lan", "relay"].includes(data.mode)) return false;
|
|
89
|
+
if (data.relay) validateRelay(data.relay);
|
|
90
|
+
return data.mode !== "relay" || Boolean(data.relay);
|
|
91
|
+
} catch { return false; }
|
|
92
|
+
} }) ?? { mode: "local" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function saveNetworkSettings(path: string, settings: NetworkSettings): Promise<void> {
|
|
96
|
+
await writeJsonAtomically(path, settings);
|
|
97
|
+
await chmod(path, 0o600);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function publicNetworkSettings(settings: NetworkSettings) {
|
|
101
|
+
const relay = settings.relay;
|
|
102
|
+
return {
|
|
103
|
+
mode: settings.mode,
|
|
104
|
+
...(relay ? { relay: {
|
|
105
|
+
serverAddr: relay.serverAddr, serverPort: relay.serverPort, proxyName: relay.proxyName,
|
|
106
|
+
origin: relay.origin, frpcPath: relay.frpcPath, visitorPort: relay.visitorPort, tokenConfigured: Boolean(relay.token),
|
|
107
|
+
} } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function proxyForNetwork(settings: NetworkSettings): WebProxyConfig | undefined {
|
|
112
|
+
return settings.mode === "relay" && settings.relay ? { origin: settings.relay.origin, secret: settings.relay.proxySecret } : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function resolveFrpc(executable: string, configDir: string): Promise<string> {
|
|
116
|
+
const candidates = isAbsolute(executable) || /[\\/]/.test(executable)
|
|
117
|
+
? [resolve(configDir, executable)]
|
|
118
|
+
: (process.env.PATH ?? "").split(delimiter).filter(Boolean).map((directory) => join(directory.replace(/^"|"$/g, ""), process.platform === "win32" && !executable.toLowerCase().endsWith(".exe") ? `${executable}.exe` : executable));
|
|
119
|
+
for (const candidate of candidates) {
|
|
120
|
+
try {
|
|
121
|
+
if (!(await stat(candidate)).isFile()) continue;
|
|
122
|
+
await access(candidate, constants.X_OK);
|
|
123
|
+
return candidate;
|
|
124
|
+
} catch { /* Try the next PATH directory. */ }
|
|
125
|
+
}
|
|
126
|
+
throw new Error("未找到 frpc。请下载原生 frpc,并在服务器中转设置中填写 frpc.exe 路径,或将其加入 PATH。");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function frpCommon(relay: RelaySettings, serverAddr: string): string {
|
|
130
|
+
return `serverAddr = ${JSON.stringify(serverAddr)}\nserverPort = ${relay.serverPort}\nauth.method = "token"\nauth.token = ${JSON.stringify(relay.token)}\ntransport.tls.enable = true\nloginFailExit = false\nlog.to = "console"\nlog.disablePrintColor = true\n`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function frpcConfig(relay: RelaySettings, localPort: number): string {
|
|
134
|
+
return `${frpCommon(relay, relay.serverAddr)}\n[[proxies]]\nname = ${JSON.stringify(relay.proxyName)}\ntype = "stcp"\nsecretKey = ${JSON.stringify(relay.secretKey)}\nlocalIP = "127.0.0.1"\nlocalPort = ${port(localPort, "本机端口")}\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function relayServerFiles(relay: RelaySettings): Record<string, string> {
|
|
138
|
+
const url = new URL(relay.origin);
|
|
139
|
+
const zone = `pi_web_${relay.proxyName.replaceAll("-", "_")}`;
|
|
140
|
+
const proxyHeaders = ` proxy_http_version 1.1;\n proxy_set_header Host ${url.host};\n proxy_set_header X-Pi-Web-Proxy-Secret "${relay.proxySecret}";\n proxy_set_header X-Forwarded-Proto https;\n proxy_set_header Connection "";\n proxy_buffering off;\n proxy_request_buffering off;\n proxy_read_timeout 3600s;\n proxy_send_timeout 3600s;`;
|
|
141
|
+
return {
|
|
142
|
+
"frps.toml": `bindPort = ${relay.serverPort}\nauth.method = "token"\nauth.token = ${JSON.stringify(relay.token)}\ntransport.tls.force = true\n`,
|
|
143
|
+
"visitor.toml": `${frpCommon(relay, "127.0.0.1")}\n[[visitors]]\nname = ${JSON.stringify(`${relay.proxyName}-visitor`)}\ntype = "stcp"\nserverName = ${JSON.stringify(relay.proxyName)}\nsecretKey = ${JSON.stringify(relay.secretKey)}\nbindAddr = "127.0.0.1"\nbindPort = ${relay.visitorPort}\n`,
|
|
144
|
+
"nginx.conf": `limit_req_zone $binary_remote_addr zone=${zone}:10m rate=10r/m;\n\nserver {\n listen 80;\n server_name ${url.hostname};\n return 301 ${relay.origin}$request_uri;\n}\n\nserver {\n listen ${url.port || 443} ssl;\n server_name ${url.hostname};\n ssl_certificate /etc/nginx/ssl/pi-web.pem;\n ssl_certificate_key /etc/nginx/ssl/pi-web.key;\n client_max_body_size 21m;\n${proxyHeaders}\n\n location = /api/auth {\n limit_req zone=${zone} burst=10 nodelay;\n limit_req_status 429;\n proxy_pass http://127.0.0.1:${relay.visitorPort};\n }\n location / {\n proxy_pass http://127.0.0.1:${relay.visitorPort};\n }\n}\n`,
|
|
145
|
+
};
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oversk7/pi-web-dsh-style",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "An unofficial DSH-styled
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "An unofficial DSH-styled Windows browser UI for pi coding agent with configurable LAN and native frp relay access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "oversk7",
|
|
@@ -21,10 +21,17 @@
|
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"abort-session.ts",
|
|
24
|
+
"background-jobs.ts",
|
|
24
25
|
"attachment-store.ts",
|
|
26
|
+
"attachment-parser.ts",
|
|
25
27
|
"file-preview.ts",
|
|
28
|
+
"extension-settings.ts",
|
|
26
29
|
"index.ts",
|
|
27
30
|
"local-session.ts",
|
|
31
|
+
"network-access.ts",
|
|
32
|
+
"network-settings.ts",
|
|
33
|
+
"terminal-proxy.ts",
|
|
34
|
+
"frp-process.ts",
|
|
28
35
|
"pi-rpc.ts",
|
|
29
36
|
"sse-channel.ts",
|
|
30
37
|
"server.ts",
|
|
@@ -38,11 +45,12 @@
|
|
|
38
45
|
"THIRD_PARTY_NOTICES.md"
|
|
39
46
|
],
|
|
40
47
|
"scripts": {
|
|
48
|
+
"build:markdown": "node scripts/build-markdown.mjs",
|
|
41
49
|
"typecheck": "tsc --noEmit",
|
|
42
50
|
"test": "node --test tests/*.test.ts",
|
|
43
51
|
"check:package": "node scripts/check-package.mjs",
|
|
44
52
|
"verify": "npm run typecheck && npm test && npm run check:package",
|
|
45
|
-
"prepack": "npm run verify"
|
|
53
|
+
"prepack": "npm run build:markdown && npm run verify"
|
|
46
54
|
},
|
|
47
55
|
"pi": {
|
|
48
56
|
"extensions": [
|
|
@@ -58,6 +66,7 @@
|
|
|
58
66
|
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
59
67
|
"@earendil-works/pi-server": "0.85.0",
|
|
60
68
|
"@types/node": "^22.19.19",
|
|
69
|
+
"esbuild": "^0.28.2",
|
|
61
70
|
"typescript": "^5.9.3"
|
|
62
71
|
},
|
|
63
72
|
"publishConfig": {
|
|
@@ -66,6 +75,10 @@
|
|
|
66
75
|
"tag": "latest"
|
|
67
76
|
},
|
|
68
77
|
"dependencies": {
|
|
69
|
-
"
|
|
78
|
+
"fast-xml-parser": "^5.11.1",
|
|
79
|
+
"fflate": "^0.8.3",
|
|
80
|
+
"highlight.js": "^11.12.0",
|
|
81
|
+
"markdown-it": "^15.0.2",
|
|
82
|
+
"pdfjs-dist": "^6.3.289"
|
|
70
83
|
}
|
|
71
84
|
}
|