linkshell-cli 0.5.2 → 0.5.5
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/dist/cli/src/index.js +22 -0
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/acp/acp-client.js +41 -19
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +8 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +164 -16
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +1 -0
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +18 -0
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +37 -0
- package/dist/cli/src/runtime/bridge-session.js +333 -28
- package/dist/cli/src/runtime/bridge-session.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/shared-protocol/src/index.d.ts +3810 -2286
- package/dist/shared-protocol/src/index.js +81 -13
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +28 -0
- package/src/runtime/acp/acp-client.ts +43 -19
- package/src/runtime/acp/agent-workspace.ts +173 -16
- package/src/runtime/acp/claude-stream-json-client.ts +23 -0
- package/src/runtime/bridge-session.ts +348 -39
|
@@ -4,7 +4,7 @@ import WebSocket from "ws";
|
|
|
4
4
|
import { hostname, platform, homedir } from "node:os";
|
|
5
5
|
import { writeFileSync, readFileSync, readdirSync, statSync, unlinkSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
|
-
import { join, basename, resolve } from "node:path";
|
|
7
|
+
import { join, basename, resolve, relative, isAbsolute } from "node:path";
|
|
8
8
|
import {
|
|
9
9
|
agentV2MessageRoute,
|
|
10
10
|
createEnvelope,
|
|
@@ -21,7 +21,7 @@ import { ScreenShare } from "./screen-share.js";
|
|
|
21
21
|
import { getLanIp } from "../utils/lan-ip.js";
|
|
22
22
|
import { startKeepAwake, type KeepAwakeHandle } from "../utils/keep-awake.js";
|
|
23
23
|
import { loadOrCreateMachineIdentity, type MachineIdentity } from "../machine-id.js";
|
|
24
|
-
import { getValidToken } from "../auth.js";
|
|
24
|
+
import { getValidToken, refreshAccessToken } from "../auth.js";
|
|
25
25
|
import { AgentSessionProxy } from "./acp/agent-session.js";
|
|
26
26
|
import { AgentWorkspaceProxy } from "./acp/agent-workspace.js";
|
|
27
27
|
import { detectAvailableProviders, type AgentProvider } from "./acp/provider-resolver.js";
|
|
@@ -43,14 +43,43 @@ export interface BridgeSessionOptions {
|
|
|
43
43
|
agentUi?: boolean;
|
|
44
44
|
agentProvider?: AgentProvider;
|
|
45
45
|
agentCommand?: string;
|
|
46
|
+
/**
|
|
47
|
+
* Confinement root for client-driven filesystem operations
|
|
48
|
+
* (terminal.browse / terminal.file.read / terminal.mkdir).
|
|
49
|
+
* Defaults to the bridge's cwd. Paths resolving outside this root are rejected.
|
|
50
|
+
*/
|
|
51
|
+
fileRoot?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Ports the tunnel proxy is allowed to connect to on 127.0.0.1.
|
|
54
|
+
* Defaults to DEFAULT_TUNNEL_PORTS when omitted.
|
|
55
|
+
*/
|
|
56
|
+
allowedTunnelPorts?: number[];
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
const HEARTBEAT_INTERVAL = 15_000;
|
|
60
|
+
// Transport-layer WS keepalive: ping the gateway periodically; if a pong
|
|
61
|
+
// doesn't arrive before the next ping, the socket is half-open (network
|
|
62
|
+
// silently dropped, no FIN) — terminate it so the reconnect loop kicks in.
|
|
63
|
+
const WS_PING_INTERVAL = 20_000;
|
|
49
64
|
const RECONNECT_BASE_DELAY = 1_000;
|
|
50
65
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
51
|
-
|
|
66
|
+
// Cap the exponent so the backoff levels off at RECONNECT_MAX_DELAY instead of
|
|
67
|
+
// overflowing. There is NO attempt ceiling — a connected host never gives up
|
|
68
|
+
// (only `stop()`/all-PTYs-exited ends it), so it survives gateway restarts and
|
|
69
|
+
// arbitrarily long network outages, reconnecting whenever connectivity returns.
|
|
70
|
+
const RECONNECT_MAX_EXPONENT = 5;
|
|
52
71
|
const DEFAULT_TERMINAL_ID = "default";
|
|
53
72
|
const HOOK_BODY_LIMIT = 256 * 1024;
|
|
73
|
+
const SCROLLBACK_LINES = 500;
|
|
74
|
+
// Upper bound on concurrent live terminals — forceNew removed the cwd-dedup
|
|
75
|
+
// ceiling, so bound growth to avoid PTY/fd exhaustion from a runaway client.
|
|
76
|
+
const MAX_TERMINALS = 12;
|
|
77
|
+
// How long to keep an exited terminal (with its scrollback) in memory so the
|
|
78
|
+
// client can replay its final output before it is reaped.
|
|
79
|
+
const EXITED_TERMINAL_GRACE_MS = 30_000;
|
|
80
|
+
// Default ports the bridge will proxy tunnel requests to when no explicit
|
|
81
|
+
// allowlist is configured. Covers the common dev servers the app tunnels to.
|
|
82
|
+
const DEFAULT_TUNNEL_PORTS = [3000, 3001, 4321, 5173, 5174, 8080, 8000, 8081];
|
|
54
83
|
const PERMISSION_REQUEST_TIMEOUT_MS = Number(
|
|
55
84
|
process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
|
|
56
85
|
);
|
|
@@ -328,10 +357,15 @@ export class BridgeSession {
|
|
|
328
357
|
private socket: WebSocket | undefined;
|
|
329
358
|
private terminals = new Map<string, TerminalInstance>();
|
|
330
359
|
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
360
|
+
private wsPingTimer: ReturnType<typeof setInterval> | undefined;
|
|
361
|
+
// True between sending a WS ping and receiving its pong. If still true at the
|
|
362
|
+
// next ping tick, the socket is half-open and gets terminated.
|
|
363
|
+
private awaitingPong = false;
|
|
331
364
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
332
365
|
private reconnectAttempts = 0;
|
|
333
366
|
private reconnecting = false;
|
|
334
367
|
private sessionId = "";
|
|
368
|
+
private hostToken: string | undefined;
|
|
335
369
|
private exited = false;
|
|
336
370
|
private stopped = false;
|
|
337
371
|
private permissionStacks = new Map<string, Array<{
|
|
@@ -351,10 +385,51 @@ export class BridgeSession {
|
|
|
351
385
|
private agentSession: AgentSessionProxy | undefined;
|
|
352
386
|
private agentWorkspace: AgentWorkspaceProxy | undefined;
|
|
353
387
|
private machineIdentity: MachineIdentity | undefined;
|
|
388
|
+
// Confinement root for client-driven filesystem ops; resolved once at construction.
|
|
389
|
+
private readonly fileRoot: string;
|
|
390
|
+
// Ports the tunnel proxy may connect to on 127.0.0.1.
|
|
391
|
+
private readonly allowedTunnelPorts: Set<number>;
|
|
392
|
+
// Pending deletions of exited terminals (terminalId → timer) so we can clear on stop().
|
|
393
|
+
private exitedTerminalTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
394
|
+
// Invoked when all terminals have exited naturally — closes embedded gateway,
|
|
395
|
+
// removes daemon PID file, etc. Set by the CLI entry point.
|
|
396
|
+
private onAllTerminalsExited: (() => void | Promise<void>) | undefined;
|
|
354
397
|
|
|
355
398
|
constructor(options: BridgeSessionOptions) {
|
|
356
399
|
this.options = options;
|
|
357
400
|
this.sessionId = options.sessionId ?? "";
|
|
401
|
+
// Confine client-driven file ops to the user's home by default (single-user
|
|
402
|
+
// self-hosted model: the owner browses their own projects under ~). This
|
|
403
|
+
// still blocks /etc, /usr, other users' dirs. Override via --file-root.
|
|
404
|
+
this.fileRoot = resolve(options.fileRoot ?? homedir());
|
|
405
|
+
this.allowedTunnelPorts = new Set(
|
|
406
|
+
options.allowedTunnelPorts && options.allowedTunnelPorts.length > 0
|
|
407
|
+
? options.allowedTunnelPorts
|
|
408
|
+
: DEFAULT_TUNNEL_PORTS,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Register a callback invoked once all terminals exit naturally (process self-cleanup). */
|
|
413
|
+
setOnAllTerminalsExited(handler: () => void | Promise<void>): void {
|
|
414
|
+
this.onAllTerminalsExited = handler;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Resolve a client-supplied path inside the confinement root.
|
|
419
|
+
* Returns the absolute resolved path, or null if it escapes the root.
|
|
420
|
+
*/
|
|
421
|
+
private resolveConfinedPath(clientPath: string): string | null {
|
|
422
|
+
const expanded = clientPath.startsWith("~")
|
|
423
|
+
? clientPath.replace(/^~/, homedir())
|
|
424
|
+
: clientPath;
|
|
425
|
+
// Resolve relative paths against the confinement root, not process.cwd().
|
|
426
|
+
const target = isAbsolute(expanded)
|
|
427
|
+
? resolve(expanded)
|
|
428
|
+
: resolve(this.fileRoot, expanded);
|
|
429
|
+
const rel = relative(this.fileRoot, target);
|
|
430
|
+
if (rel === "") return target; // the root itself
|
|
431
|
+
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
432
|
+
return target;
|
|
358
433
|
}
|
|
359
434
|
|
|
360
435
|
private log(msg: string): void {
|
|
@@ -373,6 +448,9 @@ export class BridgeSession {
|
|
|
373
448
|
`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`,
|
|
374
449
|
);
|
|
375
450
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
451
|
+
// Sweep stale LinkShell hook entries (dead curl ports) left by a prior crash
|
|
452
|
+
// before we write fresh ones for this session.
|
|
453
|
+
this.sweepStaleHookConfigs();
|
|
376
454
|
if (!this.sessionId) {
|
|
377
455
|
await this.createPairing();
|
|
378
456
|
}
|
|
@@ -421,9 +499,14 @@ export class BridgeSession {
|
|
|
421
499
|
const body = (await res.json()) as {
|
|
422
500
|
sessionId: string;
|
|
423
501
|
pairingCode: string;
|
|
502
|
+
hostToken?: string;
|
|
424
503
|
expiresAt: string;
|
|
425
504
|
};
|
|
426
505
|
this.sessionId = body.sessionId;
|
|
506
|
+
// Secret token proving we are the legitimate host for this session; sent on
|
|
507
|
+
// every host WS connect so a third party who learns the sessionId can't
|
|
508
|
+
// hijack the host role.
|
|
509
|
+
this.hostToken = body.hostToken;
|
|
427
510
|
|
|
428
511
|
const pairingGateway = resolvePairingGateway(
|
|
429
512
|
this.options.gatewayHttpUrl,
|
|
@@ -474,17 +557,40 @@ export class BridgeSession {
|
|
|
474
557
|
this.options.authToken = token;
|
|
475
558
|
return token;
|
|
476
559
|
}
|
|
560
|
+
// Refresh failed (e.g. offline / transient Supabase error). Do NOT clear
|
|
561
|
+
// the token — keep using the last one we had. It may be expired and the
|
|
562
|
+
// gateway may reject it, but the close handler force-refreshes on the next
|
|
563
|
+
// attempt, and clearing it would permanently downgrade us to no-auth and
|
|
564
|
+
// lock a pro host out of its own sessions once connectivity returns.
|
|
477
565
|
process.stderr.write(
|
|
478
|
-
"[bridge]
|
|
566
|
+
"[bridge] token refresh failed; reusing last token (will retry refresh on reconnect)\n",
|
|
479
567
|
);
|
|
480
|
-
this.options.authToken
|
|
481
|
-
return undefined;
|
|
568
|
+
return this.options.authToken;
|
|
482
569
|
} catch (error) {
|
|
483
570
|
this.log(`failed to refresh login token: ${error instanceof Error ? error.message : String(error)}`);
|
|
484
571
|
return this.options.authToken;
|
|
485
572
|
}
|
|
486
573
|
}
|
|
487
574
|
|
|
575
|
+
// After an auth-class WS close, force a token refresh before reconnecting so
|
|
576
|
+
// we don't spin on 401/4001/4003. A gateway restart surfaces as 1006 right
|
|
577
|
+
// after a fresh connect, so we refresh on that too (cheap, debounced by the
|
|
578
|
+
// reconnect backoff). No-op for clean/normal closes.
|
|
579
|
+
private async maybeRefreshTokenForClose(code: number): Promise<void> {
|
|
580
|
+
if (!this.options.authToken) return;
|
|
581
|
+
const authClass = code === 4001 || code === 4003 || code === 1006 || code === 401;
|
|
582
|
+
if (!authClass) return;
|
|
583
|
+
try {
|
|
584
|
+
const refreshed = await refreshAccessToken();
|
|
585
|
+
if (refreshed?.accessToken) {
|
|
586
|
+
this.options.authToken = refreshed.accessToken;
|
|
587
|
+
this.log("refreshed auth token after auth-class close");
|
|
588
|
+
}
|
|
589
|
+
} catch {
|
|
590
|
+
// Offline — keep the old token; the reconnect loop will retry later.
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
488
594
|
private async connectGateway(): Promise<void> {
|
|
489
595
|
if (this.stopped) {
|
|
490
596
|
return;
|
|
@@ -494,11 +600,24 @@ export class BridgeSession {
|
|
|
494
600
|
url.searchParams.set("sessionId", this.sessionId);
|
|
495
601
|
url.searchParams.set("role", "host");
|
|
496
602
|
const authToken = await this.resolveAuthToken();
|
|
603
|
+
const wsOptions: WebSocket.ClientOptions = {};
|
|
604
|
+
const headers: Record<string, string> = {};
|
|
497
605
|
if (authToken) {
|
|
606
|
+
// Prefer the Authorization header so the token doesn't leak into URLs/logs.
|
|
607
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
608
|
+
// TODO: remove the query param once all gateways read the Authorization
|
|
609
|
+
// header. Kept for backward compatibility with older gateways that only
|
|
610
|
+
// read the `auth_token` query parameter.
|
|
498
611
|
url.searchParams.set("auth_token", authToken);
|
|
499
612
|
}
|
|
613
|
+
if (this.hostToken) {
|
|
614
|
+
headers["x-linkshell-host-token"] = this.hostToken;
|
|
615
|
+
}
|
|
616
|
+
if (Object.keys(headers).length > 0) {
|
|
617
|
+
wsOptions.headers = headers;
|
|
618
|
+
}
|
|
500
619
|
|
|
501
|
-
this.socket = new WebSocket(url);
|
|
620
|
+
this.socket = new WebSocket(url, wsOptions);
|
|
502
621
|
|
|
503
622
|
this.socket.on("open", () => {
|
|
504
623
|
process.stderr.write(
|
|
@@ -526,6 +645,12 @@ export class BridgeSession {
|
|
|
526
645
|
}),
|
|
527
646
|
);
|
|
528
647
|
this.startHeartbeat();
|
|
648
|
+
this.startWsPing();
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// Gateway answered our WS ping — connection is alive.
|
|
652
|
+
this.socket.on("pong", () => {
|
|
653
|
+
this.awaitingPong = false;
|
|
529
654
|
});
|
|
530
655
|
|
|
531
656
|
this.socket.on("message", (data) => {
|
|
@@ -546,18 +671,32 @@ export class BridgeSession {
|
|
|
546
671
|
|
|
547
672
|
this.socket.on("close", (code, reasonBuffer) => {
|
|
548
673
|
this.stopHeartbeat();
|
|
674
|
+
this.stopWsPing();
|
|
549
675
|
this.socket = undefined;
|
|
550
676
|
const reason = reasonBuffer.toString();
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
)
|
|
677
|
+
// Quiet during an ongoing outage: log the first close (we were connected,
|
|
678
|
+
// attempts==0) and every 10th, so multi-hour outages don't flood the log.
|
|
679
|
+
if (this.reconnectAttempts === 0 || this.reconnectAttempts % 10 === 0) {
|
|
680
|
+
process.stderr.write(
|
|
681
|
+
`[bridge] gateway connection closed (code=${code}${reason ? `, reason=${reason}` : ""})\n`,
|
|
682
|
+
);
|
|
683
|
+
}
|
|
554
684
|
if (!this.exited) {
|
|
555
|
-
|
|
685
|
+
// Auth-class closes (token expired / gateway restarted) get a forced
|
|
686
|
+
// token refresh before the next attempt, so we don't loop on 401/4xxx.
|
|
687
|
+
this.maybeRefreshTokenForClose(code).finally(() => {
|
|
688
|
+
this.scheduleReconnect();
|
|
689
|
+
});
|
|
556
690
|
}
|
|
557
691
|
});
|
|
558
692
|
|
|
559
693
|
this.socket.on("error", (error) => {
|
|
560
|
-
|
|
694
|
+
// During an outage every retry emits a connection error; the `close`
|
|
695
|
+
// handler already logs (quietly) and drives reconnect. Only surface the
|
|
696
|
+
// first error of an outage to avoid flooding the daemon log.
|
|
697
|
+
if (this.reconnectAttempts <= 1) {
|
|
698
|
+
process.stderr.write(`[bridge] gateway error: ${error.message || "connection failed"}\n`);
|
|
699
|
+
}
|
|
561
700
|
});
|
|
562
701
|
}
|
|
563
702
|
|
|
@@ -625,10 +764,24 @@ export class BridgeSession {
|
|
|
625
764
|
case "terminal.spawn": {
|
|
626
765
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
627
766
|
const normalizedCwd = resolve(p.cwd);
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
)
|
|
767
|
+
// Cap concurrent terminals — forceNew removed the cwd-dedup ceiling, so
|
|
768
|
+
// bound growth explicitly to avoid PTY/fd exhaustion from a runaway client.
|
|
769
|
+
const liveCount = [...this.terminals.values()].filter((t) => t.status === "running").length;
|
|
770
|
+
if (liveCount >= MAX_TERMINALS) {
|
|
771
|
+
this.send(createEnvelope({
|
|
772
|
+
type: "session.error",
|
|
773
|
+
sessionId: this.sessionId,
|
|
774
|
+
payload: { code: "too_many_terminals", message: `最多同时打开 ${MAX_TERMINALS} 个终端` },
|
|
775
|
+
}));
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
// Dedup by cwd UNLESS the client explicitly asks for a fresh terminal
|
|
779
|
+
// (forceNew lets web open multiple tabs in the same directory).
|
|
780
|
+
const existing = p.forceNew
|
|
781
|
+
? undefined
|
|
782
|
+
: [...this.terminals.values()].find(
|
|
783
|
+
(t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
|
|
784
|
+
);
|
|
632
785
|
if (existing) {
|
|
633
786
|
this.send(createEnvelope({
|
|
634
787
|
type: "terminal.spawned",
|
|
@@ -669,9 +822,15 @@ export class BridgeSession {
|
|
|
669
822
|
}
|
|
670
823
|
case "terminal.browse": {
|
|
671
824
|
const p = parseTypedPayload("terminal.browse", envelope.payload);
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
825
|
+
const browsePath = this.resolveConfinedPath(p.path);
|
|
826
|
+
if (browsePath === null) {
|
|
827
|
+
this.send(createEnvelope({
|
|
828
|
+
type: "terminal.browse.result",
|
|
829
|
+
sessionId: this.sessionId,
|
|
830
|
+
payload: { path: p.path, entries: [], error: "Path is outside the allowed root", requestId: p.requestId },
|
|
831
|
+
}));
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
675
834
|
try {
|
|
676
835
|
const entries = readdirSync(browsePath, { withFileTypes: true })
|
|
677
836
|
.filter((d) => !d.name.startsWith(".") && (d.isDirectory() || (p.includeFiles && d.isFile())))
|
|
@@ -706,8 +865,22 @@ export class BridgeSession {
|
|
|
706
865
|
}
|
|
707
866
|
case "terminal.file.read": {
|
|
708
867
|
const p = parseTypedPayload("terminal.file.read", envelope.payload);
|
|
709
|
-
const
|
|
710
|
-
|
|
868
|
+
const filePath = this.resolveConfinedPath(p.path);
|
|
869
|
+
if (filePath === null) {
|
|
870
|
+
this.send(createEnvelope({
|
|
871
|
+
type: "terminal.file.read.result",
|
|
872
|
+
sessionId: this.sessionId,
|
|
873
|
+
payload: {
|
|
874
|
+
path: p.path,
|
|
875
|
+
content: "",
|
|
876
|
+
encoding: "utf8",
|
|
877
|
+
truncated: false,
|
|
878
|
+
error: "Path is outside the allowed root",
|
|
879
|
+
requestId: p.requestId,
|
|
880
|
+
},
|
|
881
|
+
}));
|
|
882
|
+
break;
|
|
883
|
+
}
|
|
711
884
|
try {
|
|
712
885
|
const stats = statSync(filePath);
|
|
713
886
|
if (!stats.isFile()) {
|
|
@@ -755,8 +928,15 @@ export class BridgeSession {
|
|
|
755
928
|
}
|
|
756
929
|
case "terminal.mkdir": {
|
|
757
930
|
const p = parseTypedPayload("terminal.mkdir", envelope.payload);
|
|
758
|
-
const
|
|
759
|
-
|
|
931
|
+
const dirPath = this.resolveConfinedPath(p.path);
|
|
932
|
+
if (dirPath === null) {
|
|
933
|
+
this.send(createEnvelope({
|
|
934
|
+
type: "terminal.browse.result",
|
|
935
|
+
sessionId: this.sessionId,
|
|
936
|
+
payload: { path: p.path, entries: [], error: "Path is outside the allowed root" },
|
|
937
|
+
}));
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
760
940
|
try {
|
|
761
941
|
mkdirSync(dirPath, { recursive: true });
|
|
762
942
|
// Browse the parent to refresh the listing
|
|
@@ -1017,6 +1197,21 @@ export class BridgeSession {
|
|
|
1017
1197
|
}): void {
|
|
1018
1198
|
const { requestId, method, url: reqUrl, headers, body, port } = payload;
|
|
1019
1199
|
|
|
1200
|
+
// SSRF guard: only proxy to ports in the allowlist (pinned to 127.0.0.1).
|
|
1201
|
+
if (!this.allowedTunnelPorts.has(port)) {
|
|
1202
|
+
this.log(`rejecting tunnel request to disallowed port ${port}`);
|
|
1203
|
+
if (headers.upgrade === "websocket") {
|
|
1204
|
+
this.send(createEnvelope({
|
|
1205
|
+
type: "tunnel.ws.close",
|
|
1206
|
+
sessionId: this.sessionId,
|
|
1207
|
+
payload: { requestId, code: 1008, reason: `Port ${port} is not allowed` },
|
|
1208
|
+
}));
|
|
1209
|
+
} else {
|
|
1210
|
+
this.sendTunnelError(requestId, 403, `Port ${port} is not allowed`);
|
|
1211
|
+
}
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1020
1215
|
// WebSocket upgrade request
|
|
1021
1216
|
if (headers.upgrade === "websocket") {
|
|
1022
1217
|
this.handleTunnelWsUpgrade(requestId, port, reqUrl);
|
|
@@ -1278,7 +1473,7 @@ export class BridgeSession {
|
|
|
1278
1473
|
cwd,
|
|
1279
1474
|
projectName: basename(cwd),
|
|
1280
1475
|
provider,
|
|
1281
|
-
scrollback: new ScrollbackBuffer(
|
|
1476
|
+
scrollback: new ScrollbackBuffer(SCROLLBACK_LINES),
|
|
1282
1477
|
outputSeq: 0,
|
|
1283
1478
|
statusSeq: 0,
|
|
1284
1479
|
status: "running",
|
|
@@ -1318,6 +1513,20 @@ export class BridgeSession {
|
|
|
1318
1513
|
}));
|
|
1319
1514
|
this.sendTerminalList();
|
|
1320
1515
|
|
|
1516
|
+
// Reap the exited terminal (and its scrollback) after a grace period so
|
|
1517
|
+
// clients can replay final output, then free the memory. Tracked so the
|
|
1518
|
+
// timer can be cleared on stop().
|
|
1519
|
+
const reapTimer = setTimeout(() => {
|
|
1520
|
+
this.exitedTerminalTimers.delete(terminalId);
|
|
1521
|
+
const t = this.terminals.get(terminalId);
|
|
1522
|
+
if (t && t.status === "exited") {
|
|
1523
|
+
this.terminals.delete(terminalId);
|
|
1524
|
+
this.log(`reaped exited terminal ${terminalId}`);
|
|
1525
|
+
}
|
|
1526
|
+
}, EXITED_TERMINAL_GRACE_MS);
|
|
1527
|
+
if (typeof reapTimer.unref === "function") reapTimer.unref();
|
|
1528
|
+
this.exitedTerminalTimers.set(terminalId, reapTimer);
|
|
1529
|
+
|
|
1321
1530
|
// If all terminals exited, close the session
|
|
1322
1531
|
const allExited = [...this.terminals.values()].every((t) => t.status === "exited");
|
|
1323
1532
|
if (allExited) {
|
|
@@ -1325,6 +1534,9 @@ export class BridgeSession {
|
|
|
1325
1534
|
setTimeout(() => {
|
|
1326
1535
|
this.stopHeartbeat();
|
|
1327
1536
|
this.socket?.close();
|
|
1537
|
+
// Self-clean process-level resources (embedded gateway + PID file)
|
|
1538
|
+
// since the PTYs exited naturally rather than via a signal handler.
|
|
1539
|
+
void this.onAllTerminalsExited?.();
|
|
1328
1540
|
}, 500);
|
|
1329
1541
|
process.exitCode = exitCode ?? 0;
|
|
1330
1542
|
}
|
|
@@ -2046,6 +2258,59 @@ export class BridgeSession {
|
|
|
2046
2258
|
} catch { /* ignore parse errors */ }
|
|
2047
2259
|
}
|
|
2048
2260
|
|
|
2261
|
+
/**
|
|
2262
|
+
* Remove ALL LinkShell hook entries (any marker) from a JSON config file.
|
|
2263
|
+
* Used on startup to sweep stale entries left behind by a crashed instance,
|
|
2264
|
+
* whose curl ports are now dead. Matches via isLinkShellHookEntry rather than
|
|
2265
|
+
* a single marker so entries from previous runs are also cleaned.
|
|
2266
|
+
*/
|
|
2267
|
+
private sweepLinkShellHookEntries(configPath: string): void {
|
|
2268
|
+
if (!existsSync(configPath)) return;
|
|
2269
|
+
try {
|
|
2270
|
+
// Re-read immediately before write to minimize the window for a
|
|
2271
|
+
// concurrent writer to clobber our changes.
|
|
2272
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
2273
|
+
const hooks = raw.hooks as Record<string, unknown[]> | undefined;
|
|
2274
|
+
if (!hooks) return;
|
|
2275
|
+
|
|
2276
|
+
let changed = false;
|
|
2277
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
2278
|
+
if (!Array.isArray(entries)) continue;
|
|
2279
|
+
const filtered = entries.filter((entry) => !isLinkShellHookEntry(entry));
|
|
2280
|
+
if (filtered.length !== entries.length) {
|
|
2281
|
+
changed = true;
|
|
2282
|
+
if (filtered.length === 0) {
|
|
2283
|
+
delete hooks[eventName];
|
|
2284
|
+
} else {
|
|
2285
|
+
hooks[eventName] = filtered;
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
if (changed) {
|
|
2291
|
+
if (Object.keys(hooks).length === 0) {
|
|
2292
|
+
delete raw.hooks;
|
|
2293
|
+
}
|
|
2294
|
+
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
2295
|
+
this.log(`swept stale LinkShell hook entries from ${configPath}`);
|
|
2296
|
+
}
|
|
2297
|
+
} catch { /* ignore parse errors */ }
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
/** Sweep stale LinkShell hook entries from known shared config files on startup. */
|
|
2301
|
+
private sweepStaleHookConfigs(): void {
|
|
2302
|
+
const home = homedir();
|
|
2303
|
+
const candidates = [
|
|
2304
|
+
join(home, ".claude", "settings.json"),
|
|
2305
|
+
join(home, ".codex", "hooks.json"),
|
|
2306
|
+
join(home, ".gemini", "settings.json"),
|
|
2307
|
+
join(process.cwd(), "hooks.json"), // copilot (per-cwd)
|
|
2308
|
+
];
|
|
2309
|
+
for (const path of candidates) {
|
|
2310
|
+
this.sweepLinkShellHookEntries(path);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2049
2314
|
private send(message: Envelope): void {
|
|
2050
2315
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
2051
2316
|
return;
|
|
@@ -2089,6 +2354,45 @@ export class BridgeSession {
|
|
|
2089
2354
|
}
|
|
2090
2355
|
}
|
|
2091
2356
|
|
|
2357
|
+
// Transport-layer keepalive. WS ping/pong detects a half-open socket (network
|
|
2358
|
+
// dropped without a TCP FIN, e.g. laptop sleep / Wi-Fi loss) that would
|
|
2359
|
+
// otherwise hang silently. If the previous ping's pong never arrived, the
|
|
2360
|
+
// socket is dead → terminate it, which fires `close` → scheduleReconnect.
|
|
2361
|
+
private startWsPing(): void {
|
|
2362
|
+
this.stopWsPing();
|
|
2363
|
+
this.awaitingPong = false;
|
|
2364
|
+
this.wsPingTimer = setInterval(() => {
|
|
2365
|
+
const sock = this.socket;
|
|
2366
|
+
if (!sock || sock.readyState !== WebSocket.OPEN) return;
|
|
2367
|
+
if (this.awaitingPong) {
|
|
2368
|
+
// No pong since the last ping → half-open. Forcibly close so we reconnect.
|
|
2369
|
+
process.stderr.write("[bridge] no pong from gateway; terminating dead connection\n");
|
|
2370
|
+
this.awaitingPong = false;
|
|
2371
|
+
try {
|
|
2372
|
+
sock.terminate();
|
|
2373
|
+
} catch {
|
|
2374
|
+
// terminate may throw if already closing; close handler still runs.
|
|
2375
|
+
}
|
|
2376
|
+
return;
|
|
2377
|
+
}
|
|
2378
|
+
this.awaitingPong = true;
|
|
2379
|
+
try {
|
|
2380
|
+
sock.ping();
|
|
2381
|
+
} catch {
|
|
2382
|
+
// ping failed → let the next tick terminate, or close already fired.
|
|
2383
|
+
}
|
|
2384
|
+
}, WS_PING_INTERVAL);
|
|
2385
|
+
if (typeof this.wsPingTimer.unref === "function") this.wsPingTimer.unref();
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
private stopWsPing(): void {
|
|
2389
|
+
if (this.wsPingTimer) {
|
|
2390
|
+
clearInterval(this.wsPingTimer);
|
|
2391
|
+
this.wsPingTimer = undefined;
|
|
2392
|
+
}
|
|
2393
|
+
this.awaitingPong = false;
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2092
2396
|
private startScreenCapture(fps: number, quality: number, scale: number): void {
|
|
2093
2397
|
if (!this.options.screen) {
|
|
2094
2398
|
this.log("screen sharing not enabled (use --screen)");
|
|
@@ -2152,25 +2456,24 @@ export class BridgeSession {
|
|
|
2152
2456
|
}
|
|
2153
2457
|
|
|
2154
2458
|
private scheduleReconnect(): void {
|
|
2155
|
-
if (this.reconnecting) return;
|
|
2459
|
+
if (this.reconnecting || this.stopped || this.exited) return;
|
|
2156
2460
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2461
|
+
this.reconnecting = true;
|
|
2462
|
+
// Exponential backoff capped at RECONNECT_MAX_DELAY, with full jitter so a
|
|
2463
|
+
// fleet of hosts doesn't stampede a restarting gateway. The attempt counter
|
|
2464
|
+
// only ever grows toward the cap — it is NEVER reset to "give up"; the host
|
|
2465
|
+
// keeps trying forever until stop()/exit.
|
|
2466
|
+
const exponent = Math.min(this.reconnectAttempts, RECONNECT_MAX_EXPONENT);
|
|
2467
|
+
const base = Math.min(RECONNECT_BASE_DELAY * 2 ** exponent, RECONNECT_MAX_DELAY);
|
|
2468
|
+
const delay = Math.round(base * (0.5 + Math.random() * 0.5));
|
|
2469
|
+
this.reconnectAttempts++;
|
|
2470
|
+
// Quiet logging: only the first retry of an outage and every 10th attempt,
|
|
2471
|
+
// so a multi-hour offline window doesn't flood the daemon log.
|
|
2472
|
+
if (this.reconnectAttempts === 1 || this.reconnectAttempts % 10 === 0) {
|
|
2159
2473
|
process.stderr.write(
|
|
2160
|
-
|
|
2474
|
+
`[bridge] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})\n`,
|
|
2161
2475
|
);
|
|
2162
|
-
this.reconnectAttempts = 0;
|
|
2163
2476
|
}
|
|
2164
|
-
|
|
2165
|
-
this.reconnecting = true;
|
|
2166
|
-
const delay = Math.min(
|
|
2167
|
-
RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts,
|
|
2168
|
-
RECONNECT_MAX_DELAY,
|
|
2169
|
-
);
|
|
2170
|
-
this.reconnectAttempts++;
|
|
2171
|
-
process.stderr.write(
|
|
2172
|
-
`[bridge] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})\n`,
|
|
2173
|
-
);
|
|
2174
2477
|
this.reconnectTimer = setTimeout(() => {
|
|
2175
2478
|
this.reconnectTimer = undefined;
|
|
2176
2479
|
if (this.stopped || this.exited) {
|
|
@@ -2189,6 +2492,7 @@ export class BridgeSession {
|
|
|
2189
2492
|
this.stopped = true;
|
|
2190
2493
|
this.exited = true;
|
|
2191
2494
|
this.stopHeartbeat();
|
|
2495
|
+
this.stopWsPing();
|
|
2192
2496
|
this.stopScreenCapture();
|
|
2193
2497
|
this.agentSession?.stop();
|
|
2194
2498
|
this.agentSession = undefined;
|
|
@@ -2207,6 +2511,11 @@ export class BridgeSession {
|
|
|
2207
2511
|
ws.close(1001, "Session stopped");
|
|
2208
2512
|
}
|
|
2209
2513
|
this.tunnelSockets.clear();
|
|
2514
|
+
// Clear pending exited-terminal reap timers.
|
|
2515
|
+
for (const timer of this.exitedTerminalTimers.values()) {
|
|
2516
|
+
clearTimeout(timer);
|
|
2517
|
+
}
|
|
2518
|
+
this.exitedTerminalTimers.clear();
|
|
2210
2519
|
for (const term of this.terminals.values()) {
|
|
2211
2520
|
this.cleanupHookServer(term);
|
|
2212
2521
|
if (term.status === "running") term.pty.kill();
|