@fengye404/termpilot 0.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/.env.example +9 -0
- package/README.md +135 -0
- package/app/dist/assets/index-B5w-y4BT.css +1 -0
- package/app/dist/assets/index-DxRmdGOr.js +20 -0
- package/app/dist/index.html +14 -0
- package/app/dist/manifest.webmanifest +1 -0
- package/app/dist/registerSW.js +1 -0
- package/app/dist/sw.js +1 -0
- package/app/dist/workbox-1d3d89e3.js +1 -0
- package/dist/cli.js +1962 -0
- package/docs/architecture.md +148 -0
- package/docs/protocol.md +216 -0
- package/docs/tech-selection-2026.md +77 -0
- package/package.json +55 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1962 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import path3 from "path";
|
|
5
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6
|
+
|
|
7
|
+
// agent/src/cli.ts
|
|
8
|
+
import { cwd as processCwd2 } from "process";
|
|
9
|
+
|
|
10
|
+
// agent/src/daemon.ts
|
|
11
|
+
import { setTimeout as delay } from "timers/promises";
|
|
12
|
+
import WebSocket from "ws";
|
|
13
|
+
|
|
14
|
+
// packages/protocol/src/index.ts
|
|
15
|
+
var DEFAULT_DEVICE_ID = "pc-main";
|
|
16
|
+
var DEFAULT_AGENT_TOKEN = "demo-agent-token";
|
|
17
|
+
var DEFAULT_CLIENT_TOKEN = "demo-client-token";
|
|
18
|
+
function parseJsonMessage(raw) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(raw);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// agent/src/state-store.ts
|
|
27
|
+
import { randomUUID } from "crypto";
|
|
28
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "fs";
|
|
29
|
+
import { homedir } from "os";
|
|
30
|
+
import path from "path";
|
|
31
|
+
var INITIAL_STATE = {
|
|
32
|
+
version: 1,
|
|
33
|
+
sessions: []
|
|
34
|
+
};
|
|
35
|
+
var LOCK_STALE_MS = 1e4;
|
|
36
|
+
var LOCK_TIMEOUT_MS = 5e3;
|
|
37
|
+
var LOCK_POLL_MS = 20;
|
|
38
|
+
function sleepMs(ms) {
|
|
39
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
40
|
+
}
|
|
41
|
+
function getAgentHome() {
|
|
42
|
+
return process.env.TERMPILOT_HOME ?? path.join(homedir(), ".termpilot");
|
|
43
|
+
}
|
|
44
|
+
function getStateFilePath() {
|
|
45
|
+
return path.join(getAgentHome(), "state.json");
|
|
46
|
+
}
|
|
47
|
+
function getStateLockPath() {
|
|
48
|
+
return `${getStateFilePath()}.lock`;
|
|
49
|
+
}
|
|
50
|
+
function ensureAgentHome() {
|
|
51
|
+
const dir = getAgentHome();
|
|
52
|
+
mkdirSync(dir, { recursive: true });
|
|
53
|
+
return dir;
|
|
54
|
+
}
|
|
55
|
+
function loadStateFromDisk(filePath) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = readFileSync(filePath, "utf8");
|
|
58
|
+
const parsed = JSON.parse(raw);
|
|
59
|
+
if (!Array.isArray(parsed.sessions)) {
|
|
60
|
+
return { ...INITIAL_STATE };
|
|
61
|
+
}
|
|
62
|
+
return parsed;
|
|
63
|
+
} catch {
|
|
64
|
+
return { ...INITIAL_STATE };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function loadState() {
|
|
68
|
+
ensureAgentHome();
|
|
69
|
+
return loadStateFromDisk(getStateFilePath());
|
|
70
|
+
}
|
|
71
|
+
function saveStateToDisk(filePath, state) {
|
|
72
|
+
const tempFilePath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
73
|
+
writeFileSync(tempFilePath, `${JSON.stringify(state, null, 2)}
|
|
74
|
+
`, "utf8");
|
|
75
|
+
renameSync(tempFilePath, filePath);
|
|
76
|
+
}
|
|
77
|
+
function withStateLock(action) {
|
|
78
|
+
ensureAgentHome();
|
|
79
|
+
const filePath = getStateFilePath();
|
|
80
|
+
const lockPath = getStateLockPath();
|
|
81
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
82
|
+
while (true) {
|
|
83
|
+
try {
|
|
84
|
+
const lockFd = openSync(lockPath, "wx");
|
|
85
|
+
closeSync(lockFd);
|
|
86
|
+
break;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const code = error && typeof error === "object" && "code" in error ? error.code : void 0;
|
|
89
|
+
if (code !== "EEXIST") {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const stat = statSync(lockPath);
|
|
94
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
95
|
+
rmSync(lockPath, { force: true });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (Date.now() >= deadline) {
|
|
102
|
+
throw new Error("\u7B49\u5F85\u72B6\u6001\u6587\u4EF6\u9501\u8D85\u65F6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002");
|
|
103
|
+
}
|
|
104
|
+
sleepMs(LOCK_POLL_MS);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return action(filePath);
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(lockPath, { force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function upsertSession(session) {
|
|
114
|
+
return withStateLock((filePath) => {
|
|
115
|
+
const state = loadStateFromDisk(filePath);
|
|
116
|
+
const sessions = state.sessions.filter((item) => item.sid !== session.sid);
|
|
117
|
+
sessions.push(session);
|
|
118
|
+
sessions.sort((left, right) => left.startedAt.localeCompare(right.startedAt));
|
|
119
|
+
const nextState = {
|
|
120
|
+
version: 1,
|
|
121
|
+
sessions
|
|
122
|
+
};
|
|
123
|
+
saveStateToDisk(filePath, nextState);
|
|
124
|
+
return nextState;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function updateSession(sid, updater) {
|
|
128
|
+
return withStateLock((filePath) => {
|
|
129
|
+
const state = loadStateFromDisk(filePath);
|
|
130
|
+
const sessions = state.sessions.map((session) => session.sid === sid ? updater(session) : session);
|
|
131
|
+
const nextState = {
|
|
132
|
+
version: 1,
|
|
133
|
+
sessions
|
|
134
|
+
};
|
|
135
|
+
saveStateToDisk(filePath, nextState);
|
|
136
|
+
return nextState;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// agent/src/tmux-backend.ts
|
|
141
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
142
|
+
import { spawn } from "child_process";
|
|
143
|
+
import { cwd as processCwd } from "process";
|
|
144
|
+
var TERM_PREFIX = "termpilot";
|
|
145
|
+
function now() {
|
|
146
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
147
|
+
}
|
|
148
|
+
function sanitizeName(value) {
|
|
149
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "session";
|
|
150
|
+
}
|
|
151
|
+
function buildTmuxSessionName(sid, name) {
|
|
152
|
+
return `${TERM_PREFIX}-${sanitizeName(name)}-${sid.slice(0, 8)}`;
|
|
153
|
+
}
|
|
154
|
+
function runTmux(args) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const child = spawn("tmux", args, {
|
|
157
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
158
|
+
});
|
|
159
|
+
let stdout = "";
|
|
160
|
+
let stderr = "";
|
|
161
|
+
child.stdout.on("data", (chunk) => {
|
|
162
|
+
stdout += chunk.toString("utf8");
|
|
163
|
+
});
|
|
164
|
+
child.stderr.on("data", (chunk) => {
|
|
165
|
+
stderr += chunk.toString("utf8");
|
|
166
|
+
});
|
|
167
|
+
child.on("error", reject);
|
|
168
|
+
child.on("close", (code) => {
|
|
169
|
+
if (code === 0) {
|
|
170
|
+
resolve(stdout.trimEnd());
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
reject(new Error(stderr.trim() || `tmux exited with code ${code ?? "unknown"}`));
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async function ensureTmuxAvailable() {
|
|
178
|
+
await runTmux(["-V"]);
|
|
179
|
+
}
|
|
180
|
+
async function createSession(input = {}) {
|
|
181
|
+
const sid = randomUUID2();
|
|
182
|
+
const name = input.name?.trim() || `session-${sid.slice(0, 6)}`;
|
|
183
|
+
const shell = input.shell?.trim() || process.env.SHELL || "/bin/zsh";
|
|
184
|
+
const workingDirectory = input.cwd?.trim() || processCwd();
|
|
185
|
+
const deviceId = input.deviceId || DEFAULT_DEVICE_ID;
|
|
186
|
+
const startedAt = now();
|
|
187
|
+
const tmuxSessionName = buildTmuxSessionName(sid, name);
|
|
188
|
+
await runTmux(["new-session", "-d", "-s", tmuxSessionName, "-c", workingDirectory, shell]);
|
|
189
|
+
const session = {
|
|
190
|
+
sid,
|
|
191
|
+
deviceId,
|
|
192
|
+
name,
|
|
193
|
+
backend: "tmux",
|
|
194
|
+
shell,
|
|
195
|
+
cwd: workingDirectory,
|
|
196
|
+
status: "running",
|
|
197
|
+
startedAt,
|
|
198
|
+
lastSeq: 0,
|
|
199
|
+
lastActivityAt: startedAt,
|
|
200
|
+
tmuxSessionName
|
|
201
|
+
};
|
|
202
|
+
upsertSession(session);
|
|
203
|
+
return session;
|
|
204
|
+
}
|
|
205
|
+
function listSessions() {
|
|
206
|
+
return loadState().sessions;
|
|
207
|
+
}
|
|
208
|
+
function getSessionBySid(sid) {
|
|
209
|
+
return loadState().sessions.find((session) => session.sid === sid);
|
|
210
|
+
}
|
|
211
|
+
async function hasSession(tmuxSessionName) {
|
|
212
|
+
try {
|
|
213
|
+
await runTmux(["has-session", "-t", tmuxSessionName]);
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function captureSession(session) {
|
|
220
|
+
return runTmux(["capture-pane", "-p", "-S", "-2000", "-t", session.tmuxSessionName]);
|
|
221
|
+
}
|
|
222
|
+
async function sendLiteralText(tmuxSessionName, text) {
|
|
223
|
+
if (!text) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await runTmux(["send-keys", "-t", tmuxSessionName, "-l", "--", text]);
|
|
227
|
+
}
|
|
228
|
+
async function sendInput(session, text, key) {
|
|
229
|
+
if (text) {
|
|
230
|
+
const parts = text.split("\n");
|
|
231
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
232
|
+
await sendLiteralText(session.tmuxSessionName, parts[index] ?? "");
|
|
233
|
+
if (index < parts.length - 1) {
|
|
234
|
+
await runTmux(["send-keys", "-t", session.tmuxSessionName, "Enter"]);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!key) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const keyMap = {
|
|
242
|
+
enter: "Enter",
|
|
243
|
+
tab: "Tab",
|
|
244
|
+
ctrl_c: "C-c",
|
|
245
|
+
ctrl_d: "C-d",
|
|
246
|
+
escape: "Escape",
|
|
247
|
+
arrow_up: "Up",
|
|
248
|
+
arrow_down: "Down",
|
|
249
|
+
arrow_left: "Left",
|
|
250
|
+
arrow_right: "Right"
|
|
251
|
+
};
|
|
252
|
+
await runTmux(["send-keys", "-t", session.tmuxSessionName, keyMap[key]]);
|
|
253
|
+
}
|
|
254
|
+
async function resizeSession(session, cols, rows) {
|
|
255
|
+
await runTmux(["resize-window", "-t", session.tmuxSessionName, "-x", String(cols), "-y", String(rows)]);
|
|
256
|
+
}
|
|
257
|
+
async function killSession(sid) {
|
|
258
|
+
const session = getSessionBySid(sid);
|
|
259
|
+
if (!session) {
|
|
260
|
+
throw new Error(`\u4F1A\u8BDD ${sid} \u4E0D\u5B58\u5728`);
|
|
261
|
+
}
|
|
262
|
+
const exists = await hasSession(session.tmuxSessionName);
|
|
263
|
+
if (exists) {
|
|
264
|
+
await runTmux(["kill-session", "-t", session.tmuxSessionName]);
|
|
265
|
+
}
|
|
266
|
+
const nextState = updateSession(sid, (current) => ({
|
|
267
|
+
...current,
|
|
268
|
+
status: "exited",
|
|
269
|
+
lastActivityAt: now()
|
|
270
|
+
}));
|
|
271
|
+
const nextSession = nextState.sessions.find((item) => item.sid === sid);
|
|
272
|
+
if (!nextSession) {
|
|
273
|
+
throw new Error(`\u4F1A\u8BDD ${sid} \u72B6\u6001\u66F4\u65B0\u5931\u8D25`);
|
|
274
|
+
}
|
|
275
|
+
return nextSession;
|
|
276
|
+
}
|
|
277
|
+
function markSessionExited(sid) {
|
|
278
|
+
const nextState = updateSession(sid, (current) => ({
|
|
279
|
+
...current,
|
|
280
|
+
status: "exited",
|
|
281
|
+
lastActivityAt: now()
|
|
282
|
+
}));
|
|
283
|
+
return nextState.sessions.find((session) => session.sid === sid);
|
|
284
|
+
}
|
|
285
|
+
function bumpSessionSeq(sid) {
|
|
286
|
+
const nextState = updateSession(sid, (current) => ({
|
|
287
|
+
...current,
|
|
288
|
+
lastSeq: current.lastSeq + 1,
|
|
289
|
+
lastActivityAt: now()
|
|
290
|
+
}));
|
|
291
|
+
return nextState.sessions.find((session) => session.sid === sid);
|
|
292
|
+
}
|
|
293
|
+
function attachSession(session) {
|
|
294
|
+
return new Promise((resolve, reject) => {
|
|
295
|
+
const child = spawn("tmux", ["attach-session", "-t", session.tmuxSessionName], {
|
|
296
|
+
stdio: "inherit"
|
|
297
|
+
});
|
|
298
|
+
child.on("error", reject);
|
|
299
|
+
child.on("close", (code) => resolve(code));
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// agent/src/daemon.ts
|
|
304
|
+
var DEFAULT_RELAY_URL = "ws://127.0.0.1:8787/ws";
|
|
305
|
+
var AgentDaemon = class {
|
|
306
|
+
constructor(options) {
|
|
307
|
+
this.options = options;
|
|
308
|
+
}
|
|
309
|
+
runtimeState = /* @__PURE__ */ new Map();
|
|
310
|
+
socket = null;
|
|
311
|
+
running = false;
|
|
312
|
+
async start() {
|
|
313
|
+
await ensureTmuxAvailable();
|
|
314
|
+
this.running = true;
|
|
315
|
+
await Promise.all([this.connectLoop(), this.syncLoop()]);
|
|
316
|
+
}
|
|
317
|
+
async stop() {
|
|
318
|
+
this.running = false;
|
|
319
|
+
this.socket?.close();
|
|
320
|
+
}
|
|
321
|
+
async connectLoop() {
|
|
322
|
+
while (this.running) {
|
|
323
|
+
const wsUrl = new URL(this.options.relayUrl);
|
|
324
|
+
wsUrl.searchParams.set("role", "agent");
|
|
325
|
+
wsUrl.searchParams.set("token", this.options.agentToken);
|
|
326
|
+
wsUrl.searchParams.set("deviceId", this.options.deviceId);
|
|
327
|
+
const socket = new WebSocket(wsUrl);
|
|
328
|
+
this.socket = socket;
|
|
329
|
+
socket.on("open", () => {
|
|
330
|
+
this.sendSessionListResult({
|
|
331
|
+
type: "session.list.result",
|
|
332
|
+
reqId: "initial-sync",
|
|
333
|
+
deviceId: this.options.deviceId,
|
|
334
|
+
payload: {
|
|
335
|
+
sessions: listSessions().filter((session) => session.deviceId === this.options.deviceId)
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
socket.on("message", (raw) => {
|
|
340
|
+
const message = parseJsonMessage(raw.toString("utf8"));
|
|
341
|
+
if (!message) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
void this.handleMessage(message);
|
|
345
|
+
});
|
|
346
|
+
const closePromise = new Promise((resolve) => {
|
|
347
|
+
socket.on("close", () => resolve());
|
|
348
|
+
socket.on("error", () => resolve());
|
|
349
|
+
});
|
|
350
|
+
await closePromise;
|
|
351
|
+
if (!this.running) {
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
await delay(1500);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async syncLoop() {
|
|
358
|
+
while (this.running) {
|
|
359
|
+
const sessions = loadState().sessions.filter((session) => session.deviceId === this.options.deviceId);
|
|
360
|
+
for (const session of sessions) {
|
|
361
|
+
await this.syncSession(session);
|
|
362
|
+
}
|
|
363
|
+
await delay(this.options.pollIntervalMs);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async syncSession(session) {
|
|
367
|
+
const runtimeState = this.runtimeState.get(session.sid) ?? {
|
|
368
|
+
lastRenderedBuffer: "",
|
|
369
|
+
lastStatus: session.status
|
|
370
|
+
};
|
|
371
|
+
if (session.status === "exited") {
|
|
372
|
+
if (runtimeState.lastStatus !== "exited") {
|
|
373
|
+
this.runtimeState.set(session.sid, {
|
|
374
|
+
...runtimeState,
|
|
375
|
+
lastStatus: "exited"
|
|
376
|
+
});
|
|
377
|
+
this.sendMessage({
|
|
378
|
+
type: "session.state",
|
|
379
|
+
deviceId: this.options.deviceId,
|
|
380
|
+
sid: session.sid,
|
|
381
|
+
payload: {
|
|
382
|
+
session
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const exists = await hasSession(session.tmuxSessionName);
|
|
389
|
+
if (!exists) {
|
|
390
|
+
const exitedSession = markSessionExited(session.sid);
|
|
391
|
+
if (exitedSession) {
|
|
392
|
+
this.runtimeState.set(session.sid, {
|
|
393
|
+
...runtimeState,
|
|
394
|
+
lastStatus: "exited"
|
|
395
|
+
});
|
|
396
|
+
this.sendMessage({
|
|
397
|
+
type: "session.exit",
|
|
398
|
+
deviceId: this.options.deviceId,
|
|
399
|
+
sid: exitedSession.sid,
|
|
400
|
+
payload: {
|
|
401
|
+
reason: "tmux \u4F1A\u8BDD\u4E0D\u5B58\u5728\uFF0C\u5DF2\u6807\u8BB0\u9000\u51FA",
|
|
402
|
+
exitCode: null
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
this.sendMessage({
|
|
406
|
+
type: "session.state",
|
|
407
|
+
deviceId: this.options.deviceId,
|
|
408
|
+
sid: exitedSession.sid,
|
|
409
|
+
payload: {
|
|
410
|
+
session: exitedSession
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const buffer = await captureSession(session);
|
|
417
|
+
if (buffer === runtimeState.lastRenderedBuffer && runtimeState.lastStatus === session.status) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const nextSession = bumpSessionSeq(session.sid);
|
|
421
|
+
if (!nextSession) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
this.runtimeState.set(session.sid, {
|
|
425
|
+
lastRenderedBuffer: buffer,
|
|
426
|
+
lastStatus: nextSession.status
|
|
427
|
+
});
|
|
428
|
+
const outputMessage = {
|
|
429
|
+
type: "session.output",
|
|
430
|
+
deviceId: this.options.deviceId,
|
|
431
|
+
sid: session.sid,
|
|
432
|
+
seq: nextSession.lastSeq,
|
|
433
|
+
payload: {
|
|
434
|
+
data: buffer,
|
|
435
|
+
mode: "replace"
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
const stateMessage = {
|
|
439
|
+
type: "session.state",
|
|
440
|
+
deviceId: this.options.deviceId,
|
|
441
|
+
sid: session.sid,
|
|
442
|
+
payload: {
|
|
443
|
+
session: nextSession
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
this.sendMessage(outputMessage);
|
|
447
|
+
this.sendMessage(stateMessage);
|
|
448
|
+
}
|
|
449
|
+
async handleMessage(message) {
|
|
450
|
+
switch (message.type) {
|
|
451
|
+
case "auth.ok":
|
|
452
|
+
return;
|
|
453
|
+
case "error":
|
|
454
|
+
this.handleError(message);
|
|
455
|
+
return;
|
|
456
|
+
case "session.list":
|
|
457
|
+
this.sendSessionListResult({
|
|
458
|
+
type: "session.list.result",
|
|
459
|
+
reqId: message.reqId,
|
|
460
|
+
deviceId: this.options.deviceId,
|
|
461
|
+
payload: {
|
|
462
|
+
sessions: listSessions().filter((session) => session.deviceId === this.options.deviceId)
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
return;
|
|
466
|
+
case "session.create":
|
|
467
|
+
await this.handleCreate(message);
|
|
468
|
+
return;
|
|
469
|
+
case "session.input":
|
|
470
|
+
await this.handleInput(message);
|
|
471
|
+
return;
|
|
472
|
+
case "session.resize":
|
|
473
|
+
await this.handleResize(message);
|
|
474
|
+
return;
|
|
475
|
+
case "session.kill":
|
|
476
|
+
await this.handleKill(message);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
handleError(message) {
|
|
481
|
+
if (message.code === "AUTH_FAILED" || message.code === "AGENT_REPLACED") {
|
|
482
|
+
this.running = false;
|
|
483
|
+
this.socket?.close();
|
|
484
|
+
console.error(`agent \u5DF2\u505C\u6B62: ${message.message}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async handleCreate(message) {
|
|
488
|
+
try {
|
|
489
|
+
const session = await createSession({
|
|
490
|
+
deviceId: this.options.deviceId,
|
|
491
|
+
name: message.payload.name,
|
|
492
|
+
cwd: message.payload.cwd,
|
|
493
|
+
shell: message.payload.shell
|
|
494
|
+
});
|
|
495
|
+
this.sendMessage({
|
|
496
|
+
type: "session.created",
|
|
497
|
+
reqId: message.reqId,
|
|
498
|
+
deviceId: this.options.deviceId,
|
|
499
|
+
payload: {
|
|
500
|
+
session
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
} catch (error) {
|
|
504
|
+
this.sendError(message.reqId, "SESSION_CREATE_FAILED", error);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
async handleInput(message) {
|
|
508
|
+
try {
|
|
509
|
+
const session = getSessionBySid(message.sid);
|
|
510
|
+
if (!session) {
|
|
511
|
+
this.sendError(message.reqId, "SESSION_NOT_FOUND", `\u4F1A\u8BDD ${message.sid} \u4E0D\u5B58\u5728`);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
await sendInput(session, message.payload.text, message.payload.key);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
this.sendError(message.reqId, "SESSION_INPUT_FAILED", error);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async handleResize(message) {
|
|
520
|
+
try {
|
|
521
|
+
const session = getSessionBySid(message.sid);
|
|
522
|
+
if (!session) {
|
|
523
|
+
this.sendError(message.reqId, "SESSION_NOT_FOUND", `\u4F1A\u8BDD ${message.sid} \u4E0D\u5B58\u5728`);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
await resizeSession(session, message.payload.cols, message.payload.rows);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
this.sendError(message.reqId, "SESSION_RESIZE_FAILED", error);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
async handleKill(message) {
|
|
532
|
+
try {
|
|
533
|
+
const session = await killSession(message.sid);
|
|
534
|
+
const exitMessage = {
|
|
535
|
+
type: "session.exit",
|
|
536
|
+
reqId: message.reqId,
|
|
537
|
+
deviceId: this.options.deviceId,
|
|
538
|
+
sid: session.sid,
|
|
539
|
+
payload: {
|
|
540
|
+
reason: "\u7528\u6237\u4E3B\u52A8\u5173\u95ED\u4F1A\u8BDD",
|
|
541
|
+
exitCode: null
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
const stateMessage = {
|
|
545
|
+
type: "session.state",
|
|
546
|
+
reqId: message.reqId,
|
|
547
|
+
deviceId: this.options.deviceId,
|
|
548
|
+
sid: session.sid,
|
|
549
|
+
payload: {
|
|
550
|
+
session
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
this.sendMessage(exitMessage);
|
|
554
|
+
this.sendMessage(stateMessage);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
this.sendError(message.reqId, "SESSION_KILL_FAILED", error);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
sendSessionListResult(message) {
|
|
560
|
+
this.sendMessage(message);
|
|
561
|
+
}
|
|
562
|
+
sendError(reqId, code, error) {
|
|
563
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
564
|
+
const payload = {
|
|
565
|
+
type: "error",
|
|
566
|
+
reqId,
|
|
567
|
+
deviceId: this.options.deviceId,
|
|
568
|
+
code,
|
|
569
|
+
message
|
|
570
|
+
};
|
|
571
|
+
this.sendMessage(payload);
|
|
572
|
+
}
|
|
573
|
+
sendMessage(message) {
|
|
574
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
this.socket.send(JSON.stringify(message));
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
function createDaemonFromEnv() {
|
|
581
|
+
return new AgentDaemon({
|
|
582
|
+
relayUrl: process.env.TERMPILOT_RELAY_URL ?? DEFAULT_RELAY_URL,
|
|
583
|
+
agentToken: process.env.TERMPILOT_AGENT_TOKEN ?? DEFAULT_AGENT_TOKEN,
|
|
584
|
+
deviceId: process.env.TERMPILOT_DEVICE_ID ?? DEFAULT_DEVICE_ID,
|
|
585
|
+
pollIntervalMs: Number(process.env.TERMPILOT_POLL_INTERVAL_MS ?? 500)
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// agent/src/relay-admin.ts
|
|
590
|
+
function getRelayBaseUrl() {
|
|
591
|
+
const relayUrl = process.env.TERMPILOT_RELAY_URL ?? "ws://127.0.0.1:8787/ws";
|
|
592
|
+
let url;
|
|
593
|
+
try {
|
|
594
|
+
url = new URL(relayUrl);
|
|
595
|
+
} catch {
|
|
596
|
+
throw new Error("TERMPILOT_RELAY_URL \u65E0\u6548\uFF0C\u8BF7\u63D0\u4F9B\u5B8C\u6574\u7684 ws:// \u6216 wss:// \u5730\u5740\u3002");
|
|
597
|
+
}
|
|
598
|
+
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
599
|
+
url.pathname = "/";
|
|
600
|
+
url.search = "";
|
|
601
|
+
return url.toString();
|
|
602
|
+
}
|
|
603
|
+
function getAgentToken() {
|
|
604
|
+
return process.env.TERMPILOT_AGENT_TOKEN ?? DEFAULT_AGENT_TOKEN;
|
|
605
|
+
}
|
|
606
|
+
function resolveDeviceId(value) {
|
|
607
|
+
return value?.trim() || process.env.TERMPILOT_DEVICE_ID || DEFAULT_DEVICE_ID;
|
|
608
|
+
}
|
|
609
|
+
async function readJsonOrThrow(response, message) {
|
|
610
|
+
if (!response.ok) {
|
|
611
|
+
throw new Error(`${message}: ${await response.text()}`);
|
|
612
|
+
}
|
|
613
|
+
return response.json();
|
|
614
|
+
}
|
|
615
|
+
async function fetchJson(input, init, message) {
|
|
616
|
+
let response;
|
|
617
|
+
try {
|
|
618
|
+
response = await fetch(input, init);
|
|
619
|
+
} catch (error) {
|
|
620
|
+
const detail = error instanceof Error ? error.message : "\u672A\u77E5\u7F51\u7EDC\u9519\u8BEF";
|
|
621
|
+
throw new Error(`${message}: \u65E0\u6CD5\u8FDE\u63A5 relay (${input.origin})\uFF0C${detail}`);
|
|
622
|
+
}
|
|
623
|
+
return readJsonOrThrow(response, message);
|
|
624
|
+
}
|
|
625
|
+
async function createPairingCode(deviceId) {
|
|
626
|
+
return fetchJson(
|
|
627
|
+
new URL("/api/pairing-codes", getRelayBaseUrl()),
|
|
628
|
+
{
|
|
629
|
+
method: "POST",
|
|
630
|
+
headers: {
|
|
631
|
+
authorization: `Bearer ${getAgentToken()}`,
|
|
632
|
+
"content-type": "application/json"
|
|
633
|
+
},
|
|
634
|
+
body: JSON.stringify({ deviceId })
|
|
635
|
+
},
|
|
636
|
+
"\u7533\u8BF7\u914D\u5BF9\u7801\u5931\u8D25"
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
async function listDeviceGrants(deviceId) {
|
|
640
|
+
return fetchJson(
|
|
641
|
+
new URL(`/api/devices/${deviceId}/grants`, getRelayBaseUrl()),
|
|
642
|
+
{
|
|
643
|
+
headers: {
|
|
644
|
+
authorization: `Bearer ${getAgentToken()}`
|
|
645
|
+
}
|
|
646
|
+
},
|
|
647
|
+
"\u8BFB\u53D6\u8BBF\u95EE\u4EE4\u724C\u5931\u8D25"
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
async function revokeDeviceGrant(deviceId, accessToken) {
|
|
651
|
+
await fetchJson(
|
|
652
|
+
new URL(`/api/devices/${deviceId}/grants/${accessToken}`, getRelayBaseUrl()),
|
|
653
|
+
{
|
|
654
|
+
method: "DELETE",
|
|
655
|
+
headers: {
|
|
656
|
+
authorization: `Bearer ${getAgentToken()}`
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
"\u64A4\u9500\u8BBF\u95EE\u4EE4\u724C\u5931\u8D25"
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
async function listAuditEvents(deviceId, limit) {
|
|
663
|
+
const constrainedLimit = Math.max(1, Math.min(limit, 100));
|
|
664
|
+
return fetchJson(
|
|
665
|
+
new URL(`/api/devices/${deviceId}/audit-events?limit=${constrainedLimit}`, getRelayBaseUrl()),
|
|
666
|
+
{
|
|
667
|
+
headers: {
|
|
668
|
+
authorization: `Bearer ${getAgentToken()}`
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
"\u8BFB\u53D6\u5BA1\u8BA1\u65E5\u5FD7\u5931\u8D25"
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// agent/src/cli.ts
|
|
676
|
+
function printHelp() {
|
|
677
|
+
console.log(`TermPilot agent \u7528\u6CD5\uFF1A
|
|
678
|
+
|
|
679
|
+
termpilot agent
|
|
680
|
+
termpilot create --name claude-main --cwd /path/to/project
|
|
681
|
+
termpilot list
|
|
682
|
+
termpilot kill --sid <sid>
|
|
683
|
+
termpilot attach --sid <sid>
|
|
684
|
+
termpilot pair
|
|
685
|
+
termpilot grants
|
|
686
|
+
termpilot audit
|
|
687
|
+
termpilot revoke --token <accessToken>
|
|
688
|
+
termpilot doctor
|
|
689
|
+
`);
|
|
690
|
+
}
|
|
691
|
+
function parseArgs(argv) {
|
|
692
|
+
const args = {};
|
|
693
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
694
|
+
const current = argv[index];
|
|
695
|
+
if (!current?.startsWith("--")) {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
const key = current.slice(2);
|
|
699
|
+
const next = argv[index + 1];
|
|
700
|
+
if (!next || next.startsWith("--")) {
|
|
701
|
+
args[key] = true;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
args[key] = next;
|
|
705
|
+
index += 1;
|
|
706
|
+
}
|
|
707
|
+
return args;
|
|
708
|
+
}
|
|
709
|
+
async function runCreate(argv) {
|
|
710
|
+
const args = parseArgs(argv);
|
|
711
|
+
const session = await createSession({
|
|
712
|
+
name: typeof args.name === "string" ? args.name : void 0,
|
|
713
|
+
cwd: typeof args.cwd === "string" ? args.cwd : processCwd2(),
|
|
714
|
+
shell: typeof args.shell === "string" ? args.shell : void 0,
|
|
715
|
+
deviceId: typeof args.deviceId === "string" ? args.deviceId : void 0
|
|
716
|
+
});
|
|
717
|
+
console.log(`\u5DF2\u521B\u5EFA\u4F1A\u8BDD ${session.sid}`);
|
|
718
|
+
console.log(`\u540D\u79F0: ${session.name}`);
|
|
719
|
+
console.log(`tmux: ${session.tmuxSessionName}`);
|
|
720
|
+
}
|
|
721
|
+
function runList() {
|
|
722
|
+
const sessions = loadState().sessions;
|
|
723
|
+
if (sessions.length === 0) {
|
|
724
|
+
console.log("\u5F53\u524D\u6CA1\u6709\u4EFB\u4F55\u4F1A\u8BDD\u3002");
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
console.table(
|
|
728
|
+
sessions.map((session) => ({
|
|
729
|
+
sid: session.sid,
|
|
730
|
+
name: session.name,
|
|
731
|
+
status: session.status,
|
|
732
|
+
cwd: session.cwd,
|
|
733
|
+
tmux: session.tmuxSessionName,
|
|
734
|
+
lastSeq: session.lastSeq,
|
|
735
|
+
updatedAt: session.lastActivityAt
|
|
736
|
+
}))
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
async function runKill(argv) {
|
|
740
|
+
const args = parseArgs(argv);
|
|
741
|
+
const sid = typeof args.sid === "string" ? args.sid : void 0;
|
|
742
|
+
if (!sid) {
|
|
743
|
+
throw new Error("\u8BF7\u901A\u8FC7 --sid \u6307\u5B9A\u4F1A\u8BDD\u3002");
|
|
744
|
+
}
|
|
745
|
+
const session = await killSession(sid);
|
|
746
|
+
console.log(`\u5DF2\u5173\u95ED\u4F1A\u8BDD ${session.sid} (${session.name})`);
|
|
747
|
+
}
|
|
748
|
+
async function runAttach(argv) {
|
|
749
|
+
const args = parseArgs(argv);
|
|
750
|
+
const sid = typeof args.sid === "string" ? args.sid : void 0;
|
|
751
|
+
if (!sid) {
|
|
752
|
+
throw new Error("\u8BF7\u901A\u8FC7 --sid \u6307\u5B9A\u4F1A\u8BDD\u3002");
|
|
753
|
+
}
|
|
754
|
+
const session = getSessionBySid(sid);
|
|
755
|
+
if (!session) {
|
|
756
|
+
throw new Error(`\u4F1A\u8BDD ${sid} \u4E0D\u5B58\u5728\u3002`);
|
|
757
|
+
}
|
|
758
|
+
const exists = await hasSession(session.tmuxSessionName);
|
|
759
|
+
if (!exists) {
|
|
760
|
+
throw new Error(`tmux \u4F1A\u8BDD ${session.tmuxSessionName} \u4E0D\u5B58\u5728\u3002`);
|
|
761
|
+
}
|
|
762
|
+
await attachSession(session);
|
|
763
|
+
}
|
|
764
|
+
async function runDoctor() {
|
|
765
|
+
console.log(`\u72B6\u6001\u76EE\u5F55: ${getAgentHome()}`);
|
|
766
|
+
console.log(`\u72B6\u6001\u6587\u4EF6: ${getStateFilePath()}`);
|
|
767
|
+
await ensureTmuxAvailable();
|
|
768
|
+
console.log("tmux \u53EF\u7528\u3002");
|
|
769
|
+
}
|
|
770
|
+
function getDeviceId(argv) {
|
|
771
|
+
const args = parseArgs(argv);
|
|
772
|
+
return resolveDeviceId(typeof args.deviceId === "string" ? args.deviceId : void 0);
|
|
773
|
+
}
|
|
774
|
+
async function runPair(argv) {
|
|
775
|
+
const deviceId = getDeviceId(argv);
|
|
776
|
+
const payload = await createPairingCode(deviceId);
|
|
777
|
+
console.log(`\u8BBE\u5907: ${payload.deviceId}`);
|
|
778
|
+
console.log(`\u914D\u5BF9\u7801: ${payload.pairingCode}`);
|
|
779
|
+
console.log(`\u6709\u6548\u671F\u81F3: ${payload.expiresAt}`);
|
|
780
|
+
console.log("\u8BF7\u5728\u624B\u673A\u7AEF\u8F93\u5165\u8FD9\u4E2A\u914D\u5BF9\u7801\uFF0C\u6362\u53D6\u8BBE\u5907\u8BBF\u95EE\u4EE4\u724C\u3002");
|
|
781
|
+
}
|
|
782
|
+
async function runGrants(argv) {
|
|
783
|
+
const deviceId = getDeviceId(argv);
|
|
784
|
+
const payload = await listDeviceGrants(deviceId);
|
|
785
|
+
if (payload.grants.length === 0) {
|
|
786
|
+
console.log(`\u8BBE\u5907 ${payload.deviceId} \u5F53\u524D\u6CA1\u6709\u4EFB\u4F55\u5DF2\u7ED1\u5B9A\u8BBF\u95EE\u4EE4\u724C\u3002`);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
console.table(
|
|
790
|
+
payload.grants.map((grant) => ({
|
|
791
|
+
token: grant.accessToken,
|
|
792
|
+
createdAt: grant.createdAt,
|
|
793
|
+
lastUsedAt: grant.lastUsedAt
|
|
794
|
+
}))
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
async function runRevoke(argv) {
|
|
798
|
+
const args = parseArgs(argv);
|
|
799
|
+
const accessToken = typeof args.token === "string" ? args.token : void 0;
|
|
800
|
+
if (!accessToken) {
|
|
801
|
+
throw new Error("\u8BF7\u901A\u8FC7 --token \u6307\u5B9A\u8981\u64A4\u9500\u7684\u8BBF\u95EE\u4EE4\u724C\u3002");
|
|
802
|
+
}
|
|
803
|
+
const deviceId = getDeviceId(argv);
|
|
804
|
+
await revokeDeviceGrant(deviceId, accessToken);
|
|
805
|
+
console.log(`\u5DF2\u64A4\u9500\u8BBE\u5907 ${deviceId} \u7684\u8BBF\u95EE\u4EE4\u724C ${accessToken}`);
|
|
806
|
+
}
|
|
807
|
+
async function runAudit(argv) {
|
|
808
|
+
const args = parseArgs(argv);
|
|
809
|
+
const deviceId = getDeviceId(argv);
|
|
810
|
+
const parsedLimit = typeof args.limit === "string" ? Number(args.limit) : 20;
|
|
811
|
+
if (!Number.isFinite(parsedLimit) || parsedLimit <= 0) {
|
|
812
|
+
throw new Error("\u8BF7\u901A\u8FC7 --limit \u6307\u5B9A\u5927\u4E8E 0 \u7684\u6570\u5B57\u3002");
|
|
813
|
+
}
|
|
814
|
+
const limit = Math.floor(parsedLimit);
|
|
815
|
+
const payload = await listAuditEvents(deviceId, limit);
|
|
816
|
+
if (payload.events.length === 0) {
|
|
817
|
+
console.log(`\u8BBE\u5907 ${payload.deviceId} \u5F53\u524D\u6CA1\u6709\u5BA1\u8BA1\u65E5\u5FD7\u3002`);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
console.table(
|
|
821
|
+
payload.events.map((event) => ({
|
|
822
|
+
createdAt: event.createdAt,
|
|
823
|
+
action: event.action,
|
|
824
|
+
actorRole: event.actorRole,
|
|
825
|
+
detail: event.detail
|
|
826
|
+
}))
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
async function runAgentCli(argv = process.argv.slice(2)) {
|
|
830
|
+
const [command, ...rest] = argv;
|
|
831
|
+
if (!command || command === "help" || command === "--help") {
|
|
832
|
+
printHelp();
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
switch (command) {
|
|
836
|
+
case "daemon": {
|
|
837
|
+
await ensureTmuxAvailable();
|
|
838
|
+
const daemon = createDaemonFromEnv();
|
|
839
|
+
process.on("SIGINT", () => {
|
|
840
|
+
void daemon.stop().finally(() => process.exit(0));
|
|
841
|
+
});
|
|
842
|
+
process.on("SIGTERM", () => {
|
|
843
|
+
void daemon.stop().finally(() => process.exit(0));
|
|
844
|
+
});
|
|
845
|
+
await daemon.start();
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
case "create":
|
|
849
|
+
await ensureTmuxAvailable();
|
|
850
|
+
await runCreate(rest);
|
|
851
|
+
return;
|
|
852
|
+
case "list":
|
|
853
|
+
runList();
|
|
854
|
+
return;
|
|
855
|
+
case "kill":
|
|
856
|
+
await ensureTmuxAvailable();
|
|
857
|
+
await runKill(rest);
|
|
858
|
+
return;
|
|
859
|
+
case "attach":
|
|
860
|
+
await ensureTmuxAvailable();
|
|
861
|
+
await runAttach(rest);
|
|
862
|
+
return;
|
|
863
|
+
case "doctor":
|
|
864
|
+
await runDoctor();
|
|
865
|
+
return;
|
|
866
|
+
case "pair":
|
|
867
|
+
await runPair(rest);
|
|
868
|
+
return;
|
|
869
|
+
case "grants":
|
|
870
|
+
await runGrants(rest);
|
|
871
|
+
return;
|
|
872
|
+
case "audit":
|
|
873
|
+
await runAudit(rest);
|
|
874
|
+
return;
|
|
875
|
+
case "revoke":
|
|
876
|
+
await runRevoke(rest);
|
|
877
|
+
return;
|
|
878
|
+
default:
|
|
879
|
+
printHelp();
|
|
880
|
+
process.exitCode = 1;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// relay/src/server.ts
|
|
885
|
+
import { createReadStream, existsSync, statSync as statSync2 } from "fs";
|
|
886
|
+
import path2 from "path";
|
|
887
|
+
import { fileURLToPath } from "url";
|
|
888
|
+
import Fastify from "fastify";
|
|
889
|
+
import websocket from "@fastify/websocket";
|
|
890
|
+
import { Pool } from "pg";
|
|
891
|
+
|
|
892
|
+
// relay/src/auth-store.ts
|
|
893
|
+
import { randomBytes } from "crypto";
|
|
894
|
+
function createPairingCodeValue() {
|
|
895
|
+
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
896
|
+
const bytes = randomBytes(6);
|
|
897
|
+
const chars = Array.from(bytes, (value) => alphabet[value % alphabet.length]);
|
|
898
|
+
return `${chars.slice(0, 3).join("")}-${chars.slice(3).join("")}`;
|
|
899
|
+
}
|
|
900
|
+
function createAccessToken() {
|
|
901
|
+
return randomBytes(24).toString("hex");
|
|
902
|
+
}
|
|
903
|
+
var MemoryAuthStore = class {
|
|
904
|
+
pairingCodes = /* @__PURE__ */ new Map();
|
|
905
|
+
grants = /* @__PURE__ */ new Map();
|
|
906
|
+
async init() {
|
|
907
|
+
}
|
|
908
|
+
async createPairingCode(deviceId, ttlMinutes) {
|
|
909
|
+
const pairingCode = createPairingCodeValue();
|
|
910
|
+
const expiresAt = new Date(Date.now() + ttlMinutes * 6e4).toISOString();
|
|
911
|
+
this.pairingCodes.set(pairingCode, {
|
|
912
|
+
deviceId,
|
|
913
|
+
expiresAt
|
|
914
|
+
});
|
|
915
|
+
return {
|
|
916
|
+
deviceId,
|
|
917
|
+
pairingCode,
|
|
918
|
+
expiresAt
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
async redeemPairingCode(pairingCode) {
|
|
922
|
+
const record = this.pairingCodes.get(pairingCode);
|
|
923
|
+
if (!record || record.redeemedAt || Date.parse(record.expiresAt) <= Date.now()) {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
record.redeemedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
927
|
+
this.pairingCodes.set(pairingCode, record);
|
|
928
|
+
const accessToken = createAccessToken();
|
|
929
|
+
const now3 = (/* @__PURE__ */ new Date()).toISOString();
|
|
930
|
+
const grant = {
|
|
931
|
+
accessToken,
|
|
932
|
+
deviceId: record.deviceId,
|
|
933
|
+
createdAt: now3,
|
|
934
|
+
lastUsedAt: now3
|
|
935
|
+
};
|
|
936
|
+
this.grants.set(accessToken, grant);
|
|
937
|
+
return grant;
|
|
938
|
+
}
|
|
939
|
+
async getGrantByAccessToken(accessToken) {
|
|
940
|
+
const grant = this.grants.get(accessToken);
|
|
941
|
+
if (!grant) {
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
const nextGrant = {
|
|
945
|
+
...grant,
|
|
946
|
+
lastUsedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
947
|
+
};
|
|
948
|
+
this.grants.set(accessToken, nextGrant);
|
|
949
|
+
return nextGrant;
|
|
950
|
+
}
|
|
951
|
+
async listGrants(deviceId) {
|
|
952
|
+
return Array.from(this.grants.values()).filter((grant) => grant.deviceId === deviceId).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
953
|
+
}
|
|
954
|
+
async revokeGrant(deviceId, accessToken) {
|
|
955
|
+
const grant = this.grants.get(accessToken);
|
|
956
|
+
if (!grant || grant.deviceId !== deviceId) {
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
this.grants.delete(accessToken);
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
var PostgresAuthStore = class {
|
|
964
|
+
constructor(pool) {
|
|
965
|
+
this.pool = pool;
|
|
966
|
+
}
|
|
967
|
+
async init() {
|
|
968
|
+
await this.pool.query(`
|
|
969
|
+
create table if not exists relay_pairing_codes (
|
|
970
|
+
pairing_code text primary key,
|
|
971
|
+
device_id text not null,
|
|
972
|
+
expires_at timestamptz not null,
|
|
973
|
+
redeemed_at timestamptz null,
|
|
974
|
+
created_at timestamptz not null default now()
|
|
975
|
+
);
|
|
976
|
+
`);
|
|
977
|
+
await this.pool.query(`
|
|
978
|
+
create table if not exists relay_client_grants (
|
|
979
|
+
access_token text primary key,
|
|
980
|
+
device_id text not null,
|
|
981
|
+
created_at timestamptz not null default now(),
|
|
982
|
+
last_used_at timestamptz not null default now()
|
|
983
|
+
);
|
|
984
|
+
`);
|
|
985
|
+
}
|
|
986
|
+
async createPairingCode(deviceId, ttlMinutes) {
|
|
987
|
+
const pairingCode = createPairingCodeValue();
|
|
988
|
+
const expiresAt = new Date(Date.now() + ttlMinutes * 6e4).toISOString();
|
|
989
|
+
await this.pool.query(
|
|
990
|
+
`
|
|
991
|
+
insert into relay_pairing_codes (pairing_code, device_id, expires_at)
|
|
992
|
+
values ($1, $2, $3)
|
|
993
|
+
`,
|
|
994
|
+
[pairingCode, deviceId, expiresAt]
|
|
995
|
+
);
|
|
996
|
+
return {
|
|
997
|
+
deviceId,
|
|
998
|
+
pairingCode,
|
|
999
|
+
expiresAt
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
async redeemPairingCode(pairingCode) {
|
|
1003
|
+
const client = await this.pool.connect();
|
|
1004
|
+
try {
|
|
1005
|
+
await client.query("begin");
|
|
1006
|
+
const result = await client.query(
|
|
1007
|
+
`
|
|
1008
|
+
select device_id, expires_at, redeemed_at
|
|
1009
|
+
from relay_pairing_codes
|
|
1010
|
+
where pairing_code = $1
|
|
1011
|
+
for update
|
|
1012
|
+
`,
|
|
1013
|
+
[pairingCode]
|
|
1014
|
+
);
|
|
1015
|
+
const record = result.rows[0];
|
|
1016
|
+
if (!record || record.redeemed_at || Date.parse(record.expires_at) <= Date.now()) {
|
|
1017
|
+
await client.query("rollback");
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
const accessToken = createAccessToken();
|
|
1021
|
+
await client.query(
|
|
1022
|
+
`
|
|
1023
|
+
update relay_pairing_codes
|
|
1024
|
+
set redeemed_at = now()
|
|
1025
|
+
where pairing_code = $1
|
|
1026
|
+
`,
|
|
1027
|
+
[pairingCode]
|
|
1028
|
+
);
|
|
1029
|
+
await client.query(
|
|
1030
|
+
`
|
|
1031
|
+
insert into relay_client_grants (access_token, device_id)
|
|
1032
|
+
values ($1, $2)
|
|
1033
|
+
`,
|
|
1034
|
+
[accessToken, record.device_id]
|
|
1035
|
+
);
|
|
1036
|
+
await client.query("commit");
|
|
1037
|
+
const now3 = (/* @__PURE__ */ new Date()).toISOString();
|
|
1038
|
+
return {
|
|
1039
|
+
accessToken,
|
|
1040
|
+
deviceId: record.device_id,
|
|
1041
|
+
createdAt: now3,
|
|
1042
|
+
lastUsedAt: now3
|
|
1043
|
+
};
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
await client.query("rollback");
|
|
1046
|
+
throw error;
|
|
1047
|
+
} finally {
|
|
1048
|
+
client.release();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
async getGrantByAccessToken(accessToken) {
|
|
1052
|
+
const result = await this.pool.query(
|
|
1053
|
+
`
|
|
1054
|
+
update relay_client_grants
|
|
1055
|
+
set last_used_at = now()
|
|
1056
|
+
where access_token = $1
|
|
1057
|
+
returning access_token, device_id, created_at, last_used_at
|
|
1058
|
+
`,
|
|
1059
|
+
[accessToken]
|
|
1060
|
+
);
|
|
1061
|
+
const record = result.rows[0];
|
|
1062
|
+
if (!record) {
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
return {
|
|
1066
|
+
accessToken: record.access_token,
|
|
1067
|
+
deviceId: record.device_id,
|
|
1068
|
+
createdAt: record.created_at,
|
|
1069
|
+
lastUsedAt: record.last_used_at
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
async listGrants(deviceId) {
|
|
1073
|
+
const result = await this.pool.query(
|
|
1074
|
+
`
|
|
1075
|
+
select access_token, device_id, created_at, last_used_at
|
|
1076
|
+
from relay_client_grants
|
|
1077
|
+
where device_id = $1
|
|
1078
|
+
order by created_at desc
|
|
1079
|
+
`,
|
|
1080
|
+
[deviceId]
|
|
1081
|
+
);
|
|
1082
|
+
return result.rows.map((record) => ({
|
|
1083
|
+
accessToken: record.access_token,
|
|
1084
|
+
deviceId: record.device_id,
|
|
1085
|
+
createdAt: record.created_at,
|
|
1086
|
+
lastUsedAt: record.last_used_at
|
|
1087
|
+
}));
|
|
1088
|
+
}
|
|
1089
|
+
async revokeGrant(deviceId, accessToken) {
|
|
1090
|
+
const result = await this.pool.query(
|
|
1091
|
+
`
|
|
1092
|
+
delete from relay_client_grants
|
|
1093
|
+
where device_id = $1 and access_token = $2
|
|
1094
|
+
`,
|
|
1095
|
+
[deviceId, accessToken]
|
|
1096
|
+
);
|
|
1097
|
+
return (result.rowCount ?? 0) > 0;
|
|
1098
|
+
}
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
// relay/src/audit-store.ts
|
|
1102
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1103
|
+
function now2() {
|
|
1104
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1105
|
+
}
|
|
1106
|
+
var MemoryAuditStore = class {
|
|
1107
|
+
events = /* @__PURE__ */ new Map();
|
|
1108
|
+
async init() {
|
|
1109
|
+
}
|
|
1110
|
+
async addEvent(input) {
|
|
1111
|
+
const event = {
|
|
1112
|
+
id: randomUUID3(),
|
|
1113
|
+
deviceId: input.deviceId,
|
|
1114
|
+
action: input.action,
|
|
1115
|
+
actorRole: input.actorRole,
|
|
1116
|
+
detail: input.detail,
|
|
1117
|
+
createdAt: now2()
|
|
1118
|
+
};
|
|
1119
|
+
const bucket = this.events.get(input.deviceId) ?? [];
|
|
1120
|
+
bucket.unshift(event);
|
|
1121
|
+
this.events.set(input.deviceId, bucket.slice(0, 200));
|
|
1122
|
+
return event;
|
|
1123
|
+
}
|
|
1124
|
+
async listEvents(deviceId, limit) {
|
|
1125
|
+
return (this.events.get(deviceId) ?? []).slice(0, limit);
|
|
1126
|
+
}
|
|
1127
|
+
};
|
|
1128
|
+
var PostgresAuditStore = class {
|
|
1129
|
+
constructor(pool) {
|
|
1130
|
+
this.pool = pool;
|
|
1131
|
+
}
|
|
1132
|
+
async init() {
|
|
1133
|
+
await this.pool.query(`
|
|
1134
|
+
create table if not exists relay_audit_events (
|
|
1135
|
+
id text primary key,
|
|
1136
|
+
device_id text not null,
|
|
1137
|
+
action text not null,
|
|
1138
|
+
actor_role text not null,
|
|
1139
|
+
detail text not null,
|
|
1140
|
+
created_at timestamptz not null default now()
|
|
1141
|
+
);
|
|
1142
|
+
`);
|
|
1143
|
+
await this.pool.query(`
|
|
1144
|
+
create index if not exists relay_audit_events_device_id_idx
|
|
1145
|
+
on relay_audit_events (device_id, created_at desc);
|
|
1146
|
+
`);
|
|
1147
|
+
}
|
|
1148
|
+
async addEvent(input) {
|
|
1149
|
+
const event = {
|
|
1150
|
+
id: randomUUID3(),
|
|
1151
|
+
deviceId: input.deviceId,
|
|
1152
|
+
action: input.action,
|
|
1153
|
+
actorRole: input.actorRole,
|
|
1154
|
+
detail: input.detail,
|
|
1155
|
+
createdAt: now2()
|
|
1156
|
+
};
|
|
1157
|
+
await this.pool.query(
|
|
1158
|
+
`
|
|
1159
|
+
insert into relay_audit_events (id, device_id, action, actor_role, detail, created_at)
|
|
1160
|
+
values ($1, $2, $3, $4, $5, $6)
|
|
1161
|
+
`,
|
|
1162
|
+
[event.id, event.deviceId, event.action, event.actorRole, event.detail, event.createdAt]
|
|
1163
|
+
);
|
|
1164
|
+
return event;
|
|
1165
|
+
}
|
|
1166
|
+
async listEvents(deviceId, limit) {
|
|
1167
|
+
const result = await this.pool.query(
|
|
1168
|
+
`
|
|
1169
|
+
select id, device_id, action, actor_role, detail, created_at
|
|
1170
|
+
from relay_audit_events
|
|
1171
|
+
where device_id = $1
|
|
1172
|
+
order by created_at desc
|
|
1173
|
+
limit $2
|
|
1174
|
+
`,
|
|
1175
|
+
[deviceId, limit]
|
|
1176
|
+
);
|
|
1177
|
+
return result.rows.map((row) => ({
|
|
1178
|
+
id: row.id,
|
|
1179
|
+
deviceId: row.device_id,
|
|
1180
|
+
action: row.action,
|
|
1181
|
+
actorRole: row.actor_role,
|
|
1182
|
+
detail: row.detail,
|
|
1183
|
+
createdAt: row.created_at
|
|
1184
|
+
}));
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
// relay/src/config.ts
|
|
1189
|
+
function loadConfig() {
|
|
1190
|
+
return {
|
|
1191
|
+
host: process.env.HOST ?? "0.0.0.0",
|
|
1192
|
+
port: Number(process.env.PORT ?? 8787),
|
|
1193
|
+
agentToken: process.env.TERMPILOT_AGENT_TOKEN ?? DEFAULT_AGENT_TOKEN,
|
|
1194
|
+
clientToken: process.env.TERMPILOT_CLIENT_TOKEN ?? DEFAULT_CLIENT_TOKEN,
|
|
1195
|
+
databaseUrl: process.env.DATABASE_URL?.trim() || void 0,
|
|
1196
|
+
pairingTtlMinutes: Number(process.env.TERMPILOT_PAIRING_TTL_MINUTES ?? 10)
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// relay/src/session-store.ts
|
|
1201
|
+
var MemorySessionStore = class {
|
|
1202
|
+
mode = "memory";
|
|
1203
|
+
sessions = /* @__PURE__ */ new Map();
|
|
1204
|
+
async replaceSessions(deviceId, sessions) {
|
|
1205
|
+
const bucket = /* @__PURE__ */ new Map();
|
|
1206
|
+
for (const session of sessions) {
|
|
1207
|
+
bucket.set(session.sid, session);
|
|
1208
|
+
}
|
|
1209
|
+
this.sessions.set(deviceId, bucket);
|
|
1210
|
+
}
|
|
1211
|
+
async upsertSession(session) {
|
|
1212
|
+
const bucket = this.sessions.get(session.deviceId) ?? /* @__PURE__ */ new Map();
|
|
1213
|
+
bucket.set(session.sid, session);
|
|
1214
|
+
this.sessions.set(session.deviceId, bucket);
|
|
1215
|
+
}
|
|
1216
|
+
async markSessionExited(deviceId, sid) {
|
|
1217
|
+
const bucket = this.sessions.get(deviceId);
|
|
1218
|
+
const session = bucket?.get(sid);
|
|
1219
|
+
if (!bucket || !session) {
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
bucket.set(sid, {
|
|
1223
|
+
...session,
|
|
1224
|
+
status: "exited",
|
|
1225
|
+
lastActivityAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
async listSessions(deviceId) {
|
|
1229
|
+
return Array.from(this.sessions.get(deviceId)?.values() ?? []);
|
|
1230
|
+
}
|
|
1231
|
+
async close() {
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
var PostgresSessionStore = class {
|
|
1235
|
+
constructor(pool) {
|
|
1236
|
+
this.pool = pool;
|
|
1237
|
+
}
|
|
1238
|
+
mode = "postgres";
|
|
1239
|
+
async init() {
|
|
1240
|
+
await this.pool.query(`
|
|
1241
|
+
create table if not exists relay_sessions (
|
|
1242
|
+
sid text primary key,
|
|
1243
|
+
device_id text not null,
|
|
1244
|
+
name text not null,
|
|
1245
|
+
backend text not null,
|
|
1246
|
+
shell text not null,
|
|
1247
|
+
cwd text not null,
|
|
1248
|
+
status text not null,
|
|
1249
|
+
started_at timestamptz not null,
|
|
1250
|
+
last_seq integer not null,
|
|
1251
|
+
last_activity_at timestamptz not null,
|
|
1252
|
+
tmux_session_name text not null,
|
|
1253
|
+
updated_at timestamptz not null default now()
|
|
1254
|
+
);
|
|
1255
|
+
`);
|
|
1256
|
+
await this.pool.query(`
|
|
1257
|
+
create index if not exists relay_sessions_device_id_idx
|
|
1258
|
+
on relay_sessions (device_id);
|
|
1259
|
+
`);
|
|
1260
|
+
}
|
|
1261
|
+
async replaceSessions(deviceId, sessions) {
|
|
1262
|
+
const client = await this.pool.connect();
|
|
1263
|
+
try {
|
|
1264
|
+
await client.query("begin");
|
|
1265
|
+
await client.query("delete from relay_sessions where device_id = $1", [deviceId]);
|
|
1266
|
+
for (const session of sessions) {
|
|
1267
|
+
await this.upsertSessionWithClient(client, session);
|
|
1268
|
+
}
|
|
1269
|
+
await client.query("commit");
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
await client.query("rollback");
|
|
1272
|
+
throw error;
|
|
1273
|
+
} finally {
|
|
1274
|
+
client.release();
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
async upsertSession(session) {
|
|
1278
|
+
const client = await this.pool.connect();
|
|
1279
|
+
try {
|
|
1280
|
+
await this.upsertSessionWithClient(client, session);
|
|
1281
|
+
} finally {
|
|
1282
|
+
client.release();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
async upsertSessionWithClient(client, session) {
|
|
1286
|
+
await client.query(
|
|
1287
|
+
`
|
|
1288
|
+
insert into relay_sessions (
|
|
1289
|
+
sid, device_id, name, backend, shell, cwd, status, started_at, last_seq, last_activity_at, tmux_session_name, updated_at
|
|
1290
|
+
) values (
|
|
1291
|
+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, now()
|
|
1292
|
+
)
|
|
1293
|
+
on conflict (sid) do update set
|
|
1294
|
+
device_id = excluded.device_id,
|
|
1295
|
+
name = excluded.name,
|
|
1296
|
+
backend = excluded.backend,
|
|
1297
|
+
shell = excluded.shell,
|
|
1298
|
+
cwd = excluded.cwd,
|
|
1299
|
+
status = excluded.status,
|
|
1300
|
+
started_at = excluded.started_at,
|
|
1301
|
+
last_seq = excluded.last_seq,
|
|
1302
|
+
last_activity_at = excluded.last_activity_at,
|
|
1303
|
+
tmux_session_name = excluded.tmux_session_name,
|
|
1304
|
+
updated_at = now()
|
|
1305
|
+
`,
|
|
1306
|
+
[
|
|
1307
|
+
session.sid,
|
|
1308
|
+
session.deviceId,
|
|
1309
|
+
session.name,
|
|
1310
|
+
session.backend,
|
|
1311
|
+
session.shell,
|
|
1312
|
+
session.cwd,
|
|
1313
|
+
session.status,
|
|
1314
|
+
session.startedAt,
|
|
1315
|
+
session.lastSeq,
|
|
1316
|
+
session.lastActivityAt,
|
|
1317
|
+
session.tmuxSessionName
|
|
1318
|
+
]
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
async markSessionExited(deviceId, sid) {
|
|
1322
|
+
await this.pool.query(
|
|
1323
|
+
`
|
|
1324
|
+
update relay_sessions
|
|
1325
|
+
set status = 'exited', last_activity_at = now(), updated_at = now()
|
|
1326
|
+
where device_id = $1 and sid = $2
|
|
1327
|
+
`,
|
|
1328
|
+
[deviceId, sid]
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
async listSessions(deviceId) {
|
|
1332
|
+
const result = await this.pool.query(
|
|
1333
|
+
`
|
|
1334
|
+
select
|
|
1335
|
+
sid,
|
|
1336
|
+
device_id as "deviceId",
|
|
1337
|
+
name,
|
|
1338
|
+
backend,
|
|
1339
|
+
shell,
|
|
1340
|
+
cwd,
|
|
1341
|
+
status,
|
|
1342
|
+
started_at as "startedAt",
|
|
1343
|
+
last_seq as "lastSeq",
|
|
1344
|
+
last_activity_at as "lastActivityAt",
|
|
1345
|
+
tmux_session_name as "tmuxSessionName"
|
|
1346
|
+
from relay_sessions
|
|
1347
|
+
where device_id = $1
|
|
1348
|
+
order by started_at desc
|
|
1349
|
+
`,
|
|
1350
|
+
[deviceId]
|
|
1351
|
+
);
|
|
1352
|
+
return result.rows;
|
|
1353
|
+
}
|
|
1354
|
+
async close() {
|
|
1355
|
+
await this.pool.end();
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
// relay/src/server.ts
|
|
1360
|
+
var STATIC_CONTENT_TYPES = {
|
|
1361
|
+
".css": "text/css; charset=utf-8",
|
|
1362
|
+
".html": "text/html; charset=utf-8",
|
|
1363
|
+
".ico": "image/x-icon",
|
|
1364
|
+
".js": "application/javascript; charset=utf-8",
|
|
1365
|
+
".json": "application/json; charset=utf-8",
|
|
1366
|
+
".map": "application/json; charset=utf-8",
|
|
1367
|
+
".png": "image/png",
|
|
1368
|
+
".svg": "image/svg+xml; charset=utf-8",
|
|
1369
|
+
".txt": "text/plain; charset=utf-8",
|
|
1370
|
+
".webmanifest": "application/manifest+json; charset=utf-8"
|
|
1371
|
+
};
|
|
1372
|
+
function getMimeType(filePath) {
|
|
1373
|
+
return STATIC_CONTENT_TYPES[path2.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
1374
|
+
}
|
|
1375
|
+
function createStaticPath(webDir, urlPath) {
|
|
1376
|
+
const requestPath = decodeURIComponent(urlPath.split("?")[0] ?? "/");
|
|
1377
|
+
const relativePath = requestPath === "/" ? "index.html" : requestPath.replace(/^\/+/, "");
|
|
1378
|
+
const resolvedPath = path2.resolve(webDir, relativePath);
|
|
1379
|
+
if (!resolvedPath.startsWith(path2.resolve(webDir))) {
|
|
1380
|
+
return path2.join(webDir, "index.html");
|
|
1381
|
+
}
|
|
1382
|
+
if (!existsSync(resolvedPath)) {
|
|
1383
|
+
return path2.join(webDir, "index.html");
|
|
1384
|
+
}
|
|
1385
|
+
try {
|
|
1386
|
+
if (statSync2(resolvedPath).isDirectory()) {
|
|
1387
|
+
return path2.join(webDir, "index.html");
|
|
1388
|
+
}
|
|
1389
|
+
} catch {
|
|
1390
|
+
return path2.join(webDir, "index.html");
|
|
1391
|
+
}
|
|
1392
|
+
return resolvedPath;
|
|
1393
|
+
}
|
|
1394
|
+
function resolveDefaultWebDir(moduleUrl = import.meta.url) {
|
|
1395
|
+
return fileURLToPath(new URL("../../app/dist", moduleUrl));
|
|
1396
|
+
}
|
|
1397
|
+
async function startRelayServer(options = {}) {
|
|
1398
|
+
const config = {
|
|
1399
|
+
...loadConfig(),
|
|
1400
|
+
...options.config
|
|
1401
|
+
};
|
|
1402
|
+
const app = Fastify({ logger: true });
|
|
1403
|
+
const agents = /* @__PURE__ */ new Map();
|
|
1404
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1405
|
+
const sessionCache = /* @__PURE__ */ new Map();
|
|
1406
|
+
const outputBuffers = /* @__PURE__ */ new Map();
|
|
1407
|
+
const webDir = options.webDir ?? resolveDefaultWebDir(import.meta.url);
|
|
1408
|
+
const storesPromise = (async () => {
|
|
1409
|
+
if (!config.databaseUrl) {
|
|
1410
|
+
app.log.warn("\u672A\u63D0\u4F9B DATABASE_URL\uFF0C\u5F53\u524D relay \u4F7F\u7528\u5185\u5B58\u5B58\u50A8\u4F1A\u8BDD\u5143\u6570\u636E\u3002");
|
|
1411
|
+
const sessionStore2 = new MemorySessionStore();
|
|
1412
|
+
const authStore2 = new MemoryAuthStore();
|
|
1413
|
+
const auditStore2 = new MemoryAuditStore();
|
|
1414
|
+
await authStore2.init();
|
|
1415
|
+
await auditStore2.init();
|
|
1416
|
+
return { sessionStore: sessionStore2, authStore: authStore2, auditStore: auditStore2 };
|
|
1417
|
+
}
|
|
1418
|
+
const pool = new Pool({ connectionString: config.databaseUrl });
|
|
1419
|
+
const sessionStore = new PostgresSessionStore(pool);
|
|
1420
|
+
const authStore = new PostgresAuthStore(pool);
|
|
1421
|
+
const auditStore = new PostgresAuditStore(pool);
|
|
1422
|
+
await sessionStore.init();
|
|
1423
|
+
await authStore.init();
|
|
1424
|
+
await auditStore.init();
|
|
1425
|
+
app.log.info("relay \u5DF2\u8FDE\u63A5 PostgreSQL\uFF0C\u4F1A\u8BDD\u5143\u6570\u636E\u5C06\u5199\u5165\u6570\u636E\u5E93\u3002");
|
|
1426
|
+
return { sessionStore, authStore, auditStore };
|
|
1427
|
+
})();
|
|
1428
|
+
async function appendAuditEvent(input) {
|
|
1429
|
+
const { auditStore } = await storesPromise;
|
|
1430
|
+
await auditStore.addEvent(input);
|
|
1431
|
+
}
|
|
1432
|
+
function clientCanAccessDevice(client, deviceId) {
|
|
1433
|
+
if (!deviceId) {
|
|
1434
|
+
return true;
|
|
1435
|
+
}
|
|
1436
|
+
return client.deviceScope === "*" || client.deviceScope.has(deviceId);
|
|
1437
|
+
}
|
|
1438
|
+
function serializeForClient(client, message) {
|
|
1439
|
+
switch (message.type) {
|
|
1440
|
+
case "relay.state": {
|
|
1441
|
+
const scope = client.deviceScope;
|
|
1442
|
+
const agentsForClient = scope === "*" ? message.payload.agents : message.payload.agents.filter((agent) => scope.has(agent.deviceId));
|
|
1443
|
+
return JSON.stringify({
|
|
1444
|
+
...message,
|
|
1445
|
+
payload: {
|
|
1446
|
+
agents: agentsForClient
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
case "error":
|
|
1451
|
+
return clientCanAccessDevice(client, message.deviceId) ? JSON.stringify(message) : null;
|
|
1452
|
+
case "auth.ok":
|
|
1453
|
+
return JSON.stringify(message);
|
|
1454
|
+
default:
|
|
1455
|
+
return clientCanAccessDevice(client, message.deviceId) ? JSON.stringify(message) : null;
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
function broadcastToClients(message) {
|
|
1459
|
+
for (const client of clients) {
|
|
1460
|
+
if (client.socket.readyState !== client.socket.OPEN) {
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
const raw = serializeForClient(client, message);
|
|
1464
|
+
if (raw) {
|
|
1465
|
+
client.socket.send(raw);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
function sendError(socket, code, message, reqId) {
|
|
1470
|
+
if (socket.readyState !== socket.OPEN) {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const payload = {
|
|
1474
|
+
type: "error",
|
|
1475
|
+
code,
|
|
1476
|
+
message,
|
|
1477
|
+
reqId
|
|
1478
|
+
};
|
|
1479
|
+
socket.send(JSON.stringify(payload));
|
|
1480
|
+
}
|
|
1481
|
+
function disconnectClientsByAccessToken(accessToken) {
|
|
1482
|
+
for (const client of clients) {
|
|
1483
|
+
if (client.accessToken !== accessToken) {
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
sendError(client.socket, "AUTH_REVOKED", "\u5F53\u524D\u8BBF\u95EE\u4EE4\u724C\u5DF2\u88AB\u64A4\u9500");
|
|
1487
|
+
client.socket.close();
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
function relayStateMessage() {
|
|
1491
|
+
return {
|
|
1492
|
+
type: "relay.state",
|
|
1493
|
+
payload: {
|
|
1494
|
+
agents: Array.from(agents.keys()).map((deviceId) => ({
|
|
1495
|
+
deviceId,
|
|
1496
|
+
online: true
|
|
1497
|
+
}))
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
function broadcastRelayState() {
|
|
1502
|
+
broadcastToClients(relayStateMessage());
|
|
1503
|
+
}
|
|
1504
|
+
function pruneOutputBuffers(deviceId, sessions) {
|
|
1505
|
+
const prefix = `${deviceId}:`;
|
|
1506
|
+
for (const key of outputBuffers.keys()) {
|
|
1507
|
+
if (!key.startsWith(prefix)) {
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
const sid = key.slice(prefix.length);
|
|
1511
|
+
if (!sessions.has(sid)) {
|
|
1512
|
+
outputBuffers.delete(key);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
function setCachedSessions(deviceId, sessions) {
|
|
1517
|
+
const bucket = /* @__PURE__ */ new Map();
|
|
1518
|
+
for (const session of sessions) {
|
|
1519
|
+
bucket.set(session.sid, session);
|
|
1520
|
+
}
|
|
1521
|
+
sessionCache.set(deviceId, bucket);
|
|
1522
|
+
pruneOutputBuffers(deviceId, bucket);
|
|
1523
|
+
}
|
|
1524
|
+
function upsertCachedSession(session) {
|
|
1525
|
+
const bucket = sessionCache.get(session.deviceId) ?? /* @__PURE__ */ new Map();
|
|
1526
|
+
bucket.set(session.sid, session);
|
|
1527
|
+
sessionCache.set(session.deviceId, bucket);
|
|
1528
|
+
}
|
|
1529
|
+
function markCachedSessionExited(deviceId, sid) {
|
|
1530
|
+
const bucket = sessionCache.get(deviceId);
|
|
1531
|
+
const session = bucket?.get(sid);
|
|
1532
|
+
if (!bucket || !session) {
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
bucket.set(sid, {
|
|
1536
|
+
...session,
|
|
1537
|
+
status: "exited",
|
|
1538
|
+
lastActivityAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1539
|
+
});
|
|
1540
|
+
}
|
|
1541
|
+
function pushOutputFrame(frame) {
|
|
1542
|
+
const key = `${frame.deviceId}:${frame.sid}`;
|
|
1543
|
+
const buffer = outputBuffers.get(key) ?? { frames: [] };
|
|
1544
|
+
buffer.frames.push(frame);
|
|
1545
|
+
buffer.frames = buffer.frames.slice(-40);
|
|
1546
|
+
outputBuffers.set(key, buffer);
|
|
1547
|
+
}
|
|
1548
|
+
async function handleAgentMessage(message) {
|
|
1549
|
+
const { sessionStore } = await storesPromise;
|
|
1550
|
+
switch (message.type) {
|
|
1551
|
+
case "session.list.result":
|
|
1552
|
+
setCachedSessions(message.deviceId, message.payload.sessions);
|
|
1553
|
+
await sessionStore.replaceSessions(message.deviceId, message.payload.sessions);
|
|
1554
|
+
broadcastToClients(message);
|
|
1555
|
+
return;
|
|
1556
|
+
case "session.created":
|
|
1557
|
+
upsertCachedSession(message.payload.session);
|
|
1558
|
+
await sessionStore.upsertSession(message.payload.session);
|
|
1559
|
+
broadcastToClients(message);
|
|
1560
|
+
return;
|
|
1561
|
+
case "session.state":
|
|
1562
|
+
upsertCachedSession(message.payload.session);
|
|
1563
|
+
await sessionStore.upsertSession(message.payload.session);
|
|
1564
|
+
broadcastToClients(message);
|
|
1565
|
+
return;
|
|
1566
|
+
case "session.output":
|
|
1567
|
+
pushOutputFrame(message);
|
|
1568
|
+
broadcastToClients(message);
|
|
1569
|
+
return;
|
|
1570
|
+
case "session.exit":
|
|
1571
|
+
markCachedSessionExited(message.deviceId, message.sid);
|
|
1572
|
+
await sessionStore.markSessionExited(message.deviceId, message.sid);
|
|
1573
|
+
broadcastToClients(message);
|
|
1574
|
+
return;
|
|
1575
|
+
case "error":
|
|
1576
|
+
broadcastToClients(message);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
async function handleClientMessage(client, message) {
|
|
1580
|
+
if (!clientCanAccessDevice(client, message.deviceId)) {
|
|
1581
|
+
sendError(client.socket, "DEVICE_FORBIDDEN", `\u5F53\u524D\u5BA2\u6237\u7AEF\u65E0\u6743\u8BBF\u95EE\u8BBE\u5907 ${message.deviceId}`, "reqId" in message ? message.reqId : void 0);
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
if (message.type === "session.replay") {
|
|
1585
|
+
const key = `${message.deviceId}:${message.sid}`;
|
|
1586
|
+
const frames = outputBuffers.get(key)?.frames ?? [];
|
|
1587
|
+
for (const frame of frames.filter((item) => item.seq > (message.payload?.afterSeq ?? -1))) {
|
|
1588
|
+
client.socket.send(JSON.stringify(frame));
|
|
1589
|
+
}
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
if (message.type === "session.list" && !agents.has(message.deviceId)) {
|
|
1593
|
+
const { sessionStore } = await storesPromise;
|
|
1594
|
+
const cached = sessionCache.get(message.deviceId);
|
|
1595
|
+
const sessions = cached ? Array.from(cached.values()) : await sessionStore.listSessions(message.deviceId);
|
|
1596
|
+
const payload = {
|
|
1597
|
+
type: "session.list.result",
|
|
1598
|
+
reqId: message.reqId,
|
|
1599
|
+
deviceId: message.deviceId,
|
|
1600
|
+
payload: {
|
|
1601
|
+
sessions
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1604
|
+
client.socket.send(JSON.stringify(payload));
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
const agent = agents.get(message.deviceId);
|
|
1608
|
+
if (!agent || agent.socket.readyState !== agent.socket.OPEN) {
|
|
1609
|
+
sendError(client.socket, "DEVICE_OFFLINE", `\u8BBE\u5907 ${message.deviceId} \u5F53\u524D\u4E0D\u5728\u7EBF`, "reqId" in message ? message.reqId : void 0);
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (message.type === "session.create") {
|
|
1613
|
+
await appendAuditEvent({
|
|
1614
|
+
deviceId: message.deviceId,
|
|
1615
|
+
action: "session.create_requested",
|
|
1616
|
+
actorRole: "client",
|
|
1617
|
+
detail: `\u8BF7\u6C42\u521B\u5EFA\u4F1A\u8BDD ${message.payload.name?.trim() || "(\u672A\u547D\u540D)"}${message.payload.cwd ? ` @ ${message.payload.cwd}` : ""}`
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
if (message.type === "session.kill") {
|
|
1621
|
+
await appendAuditEvent({
|
|
1622
|
+
deviceId: message.deviceId,
|
|
1623
|
+
action: "session.kill_requested",
|
|
1624
|
+
actorRole: "client",
|
|
1625
|
+
detail: `\u8BF7\u6C42\u5173\u95ED\u4F1A\u8BDD ${message.sid}`
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
agent.socket.send(JSON.stringify(message));
|
|
1629
|
+
}
|
|
1630
|
+
await app.register(websocket);
|
|
1631
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
1632
|
+
reply.header("access-control-allow-origin", "*");
|
|
1633
|
+
reply.header("access-control-allow-methods", "GET,POST,OPTIONS");
|
|
1634
|
+
reply.header("access-control-allow-headers", "content-type,authorization");
|
|
1635
|
+
if (request.method === "OPTIONS") {
|
|
1636
|
+
return reply.code(204).send();
|
|
1637
|
+
}
|
|
1638
|
+
});
|
|
1639
|
+
app.addHook("onClose", async () => {
|
|
1640
|
+
const { sessionStore } = await storesPromise;
|
|
1641
|
+
await sessionStore.close();
|
|
1642
|
+
});
|
|
1643
|
+
app.get("/health", async () => {
|
|
1644
|
+
const { sessionStore } = await storesPromise;
|
|
1645
|
+
return {
|
|
1646
|
+
ok: true,
|
|
1647
|
+
storeMode: sessionStore.mode,
|
|
1648
|
+
agentsOnline: agents.size,
|
|
1649
|
+
clientsOnline: clients.size,
|
|
1650
|
+
webUiReady: existsSync(webDir)
|
|
1651
|
+
};
|
|
1652
|
+
});
|
|
1653
|
+
app.post("/api/pairing-codes", async (request, reply) => {
|
|
1654
|
+
const authHeader = request.headers.authorization;
|
|
1655
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : void 0;
|
|
1656
|
+
if (token !== config.agentToken) {
|
|
1657
|
+
return reply.code(401).send({ message: "agent \u8BA4\u8BC1\u5931\u8D25" });
|
|
1658
|
+
}
|
|
1659
|
+
const deviceId = request.body?.deviceId?.trim();
|
|
1660
|
+
if (!deviceId) {
|
|
1661
|
+
return reply.code(400).send({ message: "deviceId \u4E0D\u80FD\u4E3A\u7A7A" });
|
|
1662
|
+
}
|
|
1663
|
+
if (!agents.has(deviceId)) {
|
|
1664
|
+
return reply.code(404).send({ message: `\u8BBE\u5907 ${deviceId} \u5F53\u524D\u4E0D\u5728\u7EBF` });
|
|
1665
|
+
}
|
|
1666
|
+
const { authStore } = await storesPromise;
|
|
1667
|
+
const pairing = await authStore.createPairingCode(deviceId, config.pairingTtlMinutes);
|
|
1668
|
+
await appendAuditEvent({
|
|
1669
|
+
deviceId,
|
|
1670
|
+
action: "pairing.code_created",
|
|
1671
|
+
actorRole: "agent",
|
|
1672
|
+
detail: `\u521B\u5EFA\u4E00\u6B21\u6027\u914D\u5BF9\u7801 ${pairing.pairingCode}\uFF0C\u6709\u6548\u671F\u81F3 ${pairing.expiresAt}`
|
|
1673
|
+
});
|
|
1674
|
+
const payload = {
|
|
1675
|
+
deviceId: pairing.deviceId,
|
|
1676
|
+
pairingCode: pairing.pairingCode,
|
|
1677
|
+
expiresAt: pairing.expiresAt
|
|
1678
|
+
};
|
|
1679
|
+
return reply.send(payload);
|
|
1680
|
+
});
|
|
1681
|
+
app.post("/api/pairings/redeem", async (request, reply) => {
|
|
1682
|
+
const pairingCode = request.body?.pairingCode?.trim().toUpperCase();
|
|
1683
|
+
if (!pairingCode) {
|
|
1684
|
+
return reply.code(400).send({ message: "pairingCode \u4E0D\u80FD\u4E3A\u7A7A" });
|
|
1685
|
+
}
|
|
1686
|
+
const { authStore } = await storesPromise;
|
|
1687
|
+
const grant = await authStore.redeemPairingCode(pairingCode);
|
|
1688
|
+
if (!grant) {
|
|
1689
|
+
return reply.code(400).send({ message: "\u914D\u5BF9\u7801\u65E0\u6548\u3001\u5DF2\u4F7F\u7528\u6216\u5DF2\u8FC7\u671F" });
|
|
1690
|
+
}
|
|
1691
|
+
await appendAuditEvent({
|
|
1692
|
+
deviceId: grant.deviceId,
|
|
1693
|
+
action: "pairing.redeemed",
|
|
1694
|
+
actorRole: "client",
|
|
1695
|
+
detail: `\u4F7F\u7528\u914D\u5BF9\u7801 ${pairingCode} \u5151\u6362\u8BBF\u95EE\u4EE4\u724C ${grant.accessToken.slice(0, 8)}...`
|
|
1696
|
+
});
|
|
1697
|
+
const payload = {
|
|
1698
|
+
deviceId: grant.deviceId,
|
|
1699
|
+
accessToken: grant.accessToken
|
|
1700
|
+
};
|
|
1701
|
+
return reply.send(payload);
|
|
1702
|
+
});
|
|
1703
|
+
app.get("/api/devices/:deviceId/grants", async (request, reply) => {
|
|
1704
|
+
const authHeader = request.headers.authorization;
|
|
1705
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : void 0;
|
|
1706
|
+
if (token !== config.agentToken) {
|
|
1707
|
+
return reply.code(401).send({ message: "agent \u8BA4\u8BC1\u5931\u8D25" });
|
|
1708
|
+
}
|
|
1709
|
+
const deviceId = request.params.deviceId.trim();
|
|
1710
|
+
const { authStore } = await storesPromise;
|
|
1711
|
+
const grants = await authStore.listGrants(deviceId);
|
|
1712
|
+
return reply.send({
|
|
1713
|
+
deviceId,
|
|
1714
|
+
grants
|
|
1715
|
+
});
|
|
1716
|
+
});
|
|
1717
|
+
app.get("/api/devices/:deviceId/audit-events", async (request, reply) => {
|
|
1718
|
+
const authHeader = request.headers.authorization;
|
|
1719
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : void 0;
|
|
1720
|
+
if (token !== config.agentToken) {
|
|
1721
|
+
return reply.code(401).send({ message: "agent \u8BA4\u8BC1\u5931\u8D25" });
|
|
1722
|
+
}
|
|
1723
|
+
const deviceId = request.params.deviceId.trim();
|
|
1724
|
+
const requestedLimit = Number(request.query.limit ?? 20);
|
|
1725
|
+
const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(Math.floor(requestedLimit), 1), 100) : 20;
|
|
1726
|
+
const { auditStore } = await storesPromise;
|
|
1727
|
+
const events = await auditStore.listEvents(deviceId, limit);
|
|
1728
|
+
return reply.send({
|
|
1729
|
+
deviceId,
|
|
1730
|
+
events
|
|
1731
|
+
});
|
|
1732
|
+
});
|
|
1733
|
+
app.delete("/api/devices/:deviceId/grants/:accessToken", async (request, reply) => {
|
|
1734
|
+
const authHeader = request.headers.authorization;
|
|
1735
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : void 0;
|
|
1736
|
+
if (token !== config.agentToken) {
|
|
1737
|
+
return reply.code(401).send({ message: "agent \u8BA4\u8BC1\u5931\u8D25" });
|
|
1738
|
+
}
|
|
1739
|
+
const deviceId = request.params.deviceId.trim();
|
|
1740
|
+
const accessToken = request.params.accessToken.trim();
|
|
1741
|
+
const { authStore } = await storesPromise;
|
|
1742
|
+
const revoked = await authStore.revokeGrant(deviceId, accessToken);
|
|
1743
|
+
if (!revoked) {
|
|
1744
|
+
return reply.code(404).send({ message: "\u8BBF\u95EE\u4EE4\u724C\u4E0D\u5B58\u5728" });
|
|
1745
|
+
}
|
|
1746
|
+
disconnectClientsByAccessToken(accessToken);
|
|
1747
|
+
await appendAuditEvent({
|
|
1748
|
+
deviceId,
|
|
1749
|
+
action: "grant.revoked",
|
|
1750
|
+
actorRole: "agent",
|
|
1751
|
+
detail: `\u64A4\u9500\u8BBF\u95EE\u4EE4\u724C ${accessToken.slice(0, 8)}...`
|
|
1752
|
+
});
|
|
1753
|
+
return reply.send({ ok: true });
|
|
1754
|
+
});
|
|
1755
|
+
app.get("/ws", { websocket: true }, (connection, request) => {
|
|
1756
|
+
const url = new URL(request.raw.url ?? "/ws", `http://${request.headers.host ?? "127.0.0.1"}`);
|
|
1757
|
+
const role = url.searchParams.get("role");
|
|
1758
|
+
const token = url.searchParams.get("token");
|
|
1759
|
+
const deviceId = url.searchParams.get("deviceId") ?? void 0;
|
|
1760
|
+
const socket = connection;
|
|
1761
|
+
if (role === "agent") {
|
|
1762
|
+
if (token !== config.agentToken || !deviceId) {
|
|
1763
|
+
sendError(socket, "AUTH_FAILED", "agent \u8BA4\u8BC1\u5931\u8D25");
|
|
1764
|
+
socket.close();
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const previousAgent = agents.get(deviceId);
|
|
1768
|
+
if (previousAgent && previousAgent.socket !== socket) {
|
|
1769
|
+
sendError(previousAgent.socket, "AGENT_REPLACED", `\u8BBE\u5907 ${deviceId} \u5DF2\u7531\u65B0\u7684 agent \u63A5\u7BA1`);
|
|
1770
|
+
previousAgent.socket.close();
|
|
1771
|
+
}
|
|
1772
|
+
agents.set(deviceId, { socket, deviceId });
|
|
1773
|
+
socket.send(JSON.stringify({ type: "auth.ok", payload: { role: "agent", deviceId } }));
|
|
1774
|
+
broadcastRelayState();
|
|
1775
|
+
socket.on("message", (raw) => {
|
|
1776
|
+
const current = agents.get(deviceId);
|
|
1777
|
+
if (current?.socket !== socket) {
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
const message = parseJsonMessage(raw.toString());
|
|
1781
|
+
if (!message) {
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
void handleAgentMessage(message);
|
|
1785
|
+
});
|
|
1786
|
+
socket.on("close", () => {
|
|
1787
|
+
const current = agents.get(deviceId);
|
|
1788
|
+
if (current?.socket === socket) {
|
|
1789
|
+
agents.delete(deviceId);
|
|
1790
|
+
}
|
|
1791
|
+
broadcastRelayState();
|
|
1792
|
+
});
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (role === "client") {
|
|
1796
|
+
void (async () => {
|
|
1797
|
+
let client = null;
|
|
1798
|
+
if (token === config.clientToken) {
|
|
1799
|
+
client = {
|
|
1800
|
+
socket,
|
|
1801
|
+
deviceScope: "*"
|
|
1802
|
+
};
|
|
1803
|
+
} else if (token) {
|
|
1804
|
+
const { authStore } = await storesPromise;
|
|
1805
|
+
const grant = await authStore.getGrantByAccessToken(token);
|
|
1806
|
+
if (grant) {
|
|
1807
|
+
client = {
|
|
1808
|
+
socket,
|
|
1809
|
+
deviceScope: /* @__PURE__ */ new Set([grant.deviceId]),
|
|
1810
|
+
accessToken: grant.accessToken
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
if (!client) {
|
|
1815
|
+
sendError(socket, "AUTH_FAILED", "client \u8BA4\u8BC1\u5931\u8D25");
|
|
1816
|
+
socket.close();
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
clients.add(client);
|
|
1820
|
+
const scopedDeviceId = client.deviceScope === "*" ? void 0 : Array.from(client.deviceScope)[0];
|
|
1821
|
+
socket.send(JSON.stringify({ type: "auth.ok", payload: { role: "client", deviceId: scopedDeviceId } }));
|
|
1822
|
+
const relayState = serializeForClient(client, relayStateMessage());
|
|
1823
|
+
if (relayState) {
|
|
1824
|
+
socket.send(relayState);
|
|
1825
|
+
}
|
|
1826
|
+
socket.on("message", (raw) => {
|
|
1827
|
+
const message = parseJsonMessage(raw.toString());
|
|
1828
|
+
if (!message) {
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
void handleClientMessage(client, message);
|
|
1832
|
+
});
|
|
1833
|
+
socket.on("close", () => {
|
|
1834
|
+
if (client) {
|
|
1835
|
+
clients.delete(client);
|
|
1836
|
+
}
|
|
1837
|
+
});
|
|
1838
|
+
})();
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
sendError(socket, "AUTH_FAILED", "\u672A\u77E5\u89D2\u8272");
|
|
1842
|
+
socket.close();
|
|
1843
|
+
});
|
|
1844
|
+
const serveWebUi = async (requestPath, reply) => {
|
|
1845
|
+
if (!existsSync(webDir)) {
|
|
1846
|
+
return reply.code(503).type("text/plain; charset=utf-8").send("TermPilot Web UI \u5C1A\u672A\u6784\u5EFA\uFF0C\u8BF7\u5148\u8FD0\u884C `pnpm build` \u6216\u5B89\u88C5\u5DF2\u6253\u5305\u7248\u672C\u3002");
|
|
1847
|
+
}
|
|
1848
|
+
const filePath = createStaticPath(webDir, requestPath);
|
|
1849
|
+
return reply.type(getMimeType(filePath)).send(createReadStream(filePath));
|
|
1850
|
+
};
|
|
1851
|
+
app.get("/", async (request, reply) => serveWebUi(request.url, reply));
|
|
1852
|
+
app.get("/*", async (request, reply) => serveWebUi(request.url, reply));
|
|
1853
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
1854
|
+
process.on(signal, () => {
|
|
1855
|
+
void app.close().finally(() => process.exit(0));
|
|
1856
|
+
});
|
|
1857
|
+
}
|
|
1858
|
+
await app.listen({
|
|
1859
|
+
host: config.host,
|
|
1860
|
+
port: config.port
|
|
1861
|
+
});
|
|
1862
|
+
return app;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// src/cli.ts
|
|
1866
|
+
var AGENT_ENV_FLAGS = [
|
|
1867
|
+
{ flag: "--relay", envName: "TERMPILOT_RELAY_URL" },
|
|
1868
|
+
{ flag: "--device-id", envName: "TERMPILOT_DEVICE_ID" },
|
|
1869
|
+
{ flag: "--agent-token", envName: "TERMPILOT_AGENT_TOKEN" },
|
|
1870
|
+
{ flag: "--poll-interval", envName: "TERMPILOT_POLL_INTERVAL_MS" },
|
|
1871
|
+
{ flag: "--home", envName: "TERMPILOT_HOME" }
|
|
1872
|
+
];
|
|
1873
|
+
var RELAY_ENV_FLAGS = [
|
|
1874
|
+
{ flag: "--host", envName: "HOST" },
|
|
1875
|
+
{ flag: "--port", envName: "PORT" },
|
|
1876
|
+
{ flag: "--agent-token", envName: "TERMPILOT_AGENT_TOKEN" },
|
|
1877
|
+
{ flag: "--client-token", envName: "TERMPILOT_CLIENT_TOKEN" },
|
|
1878
|
+
{ flag: "--database-url", envName: "DATABASE_URL" },
|
|
1879
|
+
{ flag: "--pairing-ttl", envName: "TERMPILOT_PAIRING_TTL_MINUTES" }
|
|
1880
|
+
];
|
|
1881
|
+
function printHelp2() {
|
|
1882
|
+
console.log(`TermPilot \u7528\u6CD5\uFF1A
|
|
1883
|
+
|
|
1884
|
+
termpilot relay [--host 0.0.0.0] [--port 8787]
|
|
1885
|
+
termpilot agent [--relay ws://127.0.0.1:8787/ws] [--device-id pc-main]
|
|
1886
|
+
|
|
1887
|
+
termpilot pair [--device-id pc-main]
|
|
1888
|
+
termpilot create --name claude-main [--cwd /path/to/project]
|
|
1889
|
+
termpilot list
|
|
1890
|
+
termpilot attach --sid <sid>
|
|
1891
|
+
termpilot kill --sid <sid>
|
|
1892
|
+
termpilot grants [--device-id pc-main]
|
|
1893
|
+
termpilot audit [--device-id pc-main] [--limit 20]
|
|
1894
|
+
termpilot revoke --token <accessToken> [--device-id pc-main]
|
|
1895
|
+
termpilot doctor
|
|
1896
|
+
`);
|
|
1897
|
+
}
|
|
1898
|
+
function applyEnvFlags(argv, mappings) {
|
|
1899
|
+
const rest = [];
|
|
1900
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1901
|
+
const current = argv[index];
|
|
1902
|
+
const mapping = mappings.find((item) => item.flag === current);
|
|
1903
|
+
if (!mapping) {
|
|
1904
|
+
rest.push(current);
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
const value = argv[index + 1];
|
|
1908
|
+
if (!value || value.startsWith("--")) {
|
|
1909
|
+
throw new Error(`${mapping.flag} \u9700\u8981\u4E00\u4E2A\u503C\u3002`);
|
|
1910
|
+
}
|
|
1911
|
+
process.env[mapping.envName] = value;
|
|
1912
|
+
index += 1;
|
|
1913
|
+
}
|
|
1914
|
+
return rest;
|
|
1915
|
+
}
|
|
1916
|
+
function resolveBundledWebDir() {
|
|
1917
|
+
return path3.resolve(path3.dirname(fileURLToPath2(import.meta.url)), "../app/dist");
|
|
1918
|
+
}
|
|
1919
|
+
async function main(argv = process.argv.slice(2)) {
|
|
1920
|
+
const normalizedArgv = argv[0] === "--" ? argv.slice(1) : argv;
|
|
1921
|
+
const [command, ...rest] = normalizedArgv;
|
|
1922
|
+
if (!command || command === "help" || command === "--help") {
|
|
1923
|
+
printHelp2();
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
switch (command) {
|
|
1927
|
+
case "relay": {
|
|
1928
|
+
const relayArgs = applyEnvFlags(rest, RELAY_ENV_FLAGS);
|
|
1929
|
+
if (relayArgs.includes("--help")) {
|
|
1930
|
+
printHelp2();
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
await startRelayServer({ webDir: resolveBundledWebDir() });
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
case "agent": {
|
|
1937
|
+
const agentArgs = applyEnvFlags(rest, AGENT_ENV_FLAGS);
|
|
1938
|
+
await runAgentCli(agentArgs.length > 0 ? agentArgs : ["daemon"]);
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
case "pair":
|
|
1942
|
+
case "create":
|
|
1943
|
+
case "list":
|
|
1944
|
+
case "kill":
|
|
1945
|
+
case "attach":
|
|
1946
|
+
case "grants":
|
|
1947
|
+
case "audit":
|
|
1948
|
+
case "revoke":
|
|
1949
|
+
case "doctor": {
|
|
1950
|
+
const agentArgs = applyEnvFlags(rest, AGENT_ENV_FLAGS);
|
|
1951
|
+
await runAgentCli([command, ...agentArgs]);
|
|
1952
|
+
return;
|
|
1953
|
+
}
|
|
1954
|
+
default:
|
|
1955
|
+
printHelp2();
|
|
1956
|
+
process.exitCode = 1;
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
void main().catch((error) => {
|
|
1960
|
+
console.error(error instanceof Error ? error.message : error);
|
|
1961
|
+
process.exit(1);
|
|
1962
|
+
});
|