linkshell-cli 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/dist/cli/src/commands/gateway.d.ts +2 -0
- package/dist/cli/src/commands/gateway.js +29 -0
- package/dist/cli/src/commands/gateway.js.map +1 -0
- package/dist/cli/src/commands/host.d.ts +2 -0
- package/dist/cli/src/commands/host.js +12 -2
- package/dist/cli/src/commands/host.js.map +1 -1
- package/dist/cli/src/commands/setup.js +4 -2
- package/dist/cli/src/commands/setup.js.map +1 -1
- package/dist/cli/src/index.js +12 -334
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/utils/daemon.d.ts +0 -7
- package/dist/cli/src/utils/daemon.js +1 -18
- package/dist/cli/src/utils/daemon.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/gateway/src/accounts.d.ts +6 -0
- package/dist/gateway/src/accounts.js +32 -0
- package/dist/gateway/src/accounts.js.map +1 -0
- package/dist/gateway/src/index.d.ts +4 -0
- package/dist/gateway/src/index.js +5 -0
- package/dist/gateway/src/index.js.map +1 -0
- package/dist/gateway/src/main.d.ts +1 -0
- package/dist/gateway/src/main.js +57 -0
- package/dist/gateway/src/main.js.map +1 -0
- package/dist/gateway/src/rate-limit.d.ts +22 -0
- package/dist/gateway/src/rate-limit.js +66 -0
- package/dist/gateway/src/rate-limit.js.map +1 -0
- package/dist/gateway/src/relay.d.ts +84 -0
- package/dist/gateway/src/relay.js +434 -0
- package/dist/gateway/src/relay.js.map +1 -0
- package/dist/gateway/src/serve.d.ts +34 -0
- package/dist/gateway/src/serve.js +102 -0
- package/dist/gateway/src/serve.js.map +1 -0
- package/dist/gateway/src/store.d.ts +35 -0
- package/dist/gateway/src/store.js +109 -0
- package/dist/gateway/src/store.js.map +1 -0
- package/dist/gateway/src/subscription.d.ts +18 -0
- package/dist/gateway/src/subscription.js +46 -0
- package/dist/gateway/src/subscription.js.map +1 -0
- package/dist/host/src/drivers/claude/transcript.d.ts +23 -23
- package/dist/host/src/fs.d.ts +3 -3
- package/dist/host/src/input.d.ts +4 -4
- package/dist/host/src/terminals.d.ts +1 -1
- package/dist/wire/src/model.d.ts +6 -6
- package/dist/wire/src/relay.d.ts +8 -8
- package/dist/wire/src/rpc.d.ts +818 -818
- package/dist/wire/src/updates.d.ts +18 -18
- package/package.json +4 -13
- package/src/commands/gateway.ts +31 -0
- package/src/commands/host.ts +13 -2
- package/src/commands/setup.ts +4 -2
- package/src/index.ts +14 -429
- package/src/utils/daemon.ts +2 -27
- package/dist/cli/src/config.d.ts +0 -14
- package/dist/cli/src/config.js +0 -23
- package/dist/cli/src/config.js.map +0 -1
- package/dist/cli/src/machine-id.d.ts +0 -6
- package/dist/cli/src/machine-id.js +0 -50
- package/dist/cli/src/machine-id.js.map +0 -1
- package/dist/cli/src/providers.d.ts +0 -13
- package/dist/cli/src/providers.js +0 -77
- package/dist/cli/src/providers.js.map +0 -1
- package/dist/cli/src/runtime/acp/acp-client.d.ts +0 -128
- package/dist/cli/src/runtime/acp/acp-client.js +0 -430
- package/dist/cli/src/runtime/acp/acp-client.js.map +0 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +0 -48
- package/dist/cli/src/runtime/acp/agent-process-discovery.js +0 -278
- package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +0 -1
- package/dist/cli/src/runtime/acp/agent-session.d.ts +0 -62
- package/dist/cli/src/runtime/acp/agent-session.js +0 -1081
- package/dist/cli/src/runtime/acp/agent-session.js.map +0 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +0 -311
- package/dist/cli/src/runtime/acp/agent-workspace.js +0 -6083
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +0 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +0 -121
- package/dist/cli/src/runtime/acp/claude-sdk-client.js +0 -1147
- package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +0 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +0 -53
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +0 -552
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +0 -1
- package/dist/cli/src/runtime/acp/json-rpc.d.ts +0 -22
- package/dist/cli/src/runtime/acp/json-rpc.js +0 -160
- package/dist/cli/src/runtime/acp/json-rpc.js.map +0 -1
- package/dist/cli/src/runtime/acp/provider-resolver.d.ts +0 -16
- package/dist/cli/src/runtime/acp/provider-resolver.js +0 -132
- package/dist/cli/src/runtime/acp/provider-resolver.js.map +0 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +0 -97
- package/dist/cli/src/runtime/bridge-session.js +0 -1364
- package/dist/cli/src/runtime/bridge-session.js.map +0 -1
- package/dist/cli/src/runtime/hook-command.d.ts +0 -9
- package/dist/cli/src/runtime/hook-command.js +0 -80
- package/dist/cli/src/runtime/hook-command.js.map +0 -1
- package/dist/cli/src/runtime/screen-fallback.d.ts +0 -22
- package/dist/cli/src/runtime/screen-fallback.js +0 -202
- package/dist/cli/src/runtime/screen-fallback.js.map +0 -1
- package/dist/cli/src/runtime/screen-share.d.ts +0 -34
- package/dist/cli/src/runtime/screen-share.js +0 -376
- package/dist/cli/src/runtime/screen-share.js.map +0 -1
- package/dist/cli/src/runtime/scrollback.d.ts +0 -11
- package/dist/cli/src/runtime/scrollback.js +0 -33
- package/dist/cli/src/runtime/scrollback.js.map +0 -1
- package/dist/cli/src/runtime/usage-report.d.ts +0 -34
- package/dist/cli/src/runtime/usage-report.js +0 -332
- package/dist/cli/src/runtime/usage-report.js.map +0 -1
- package/dist/cli/src/utils/keep-awake.d.ts +0 -6
- package/dist/cli/src/utils/keep-awake.js +0 -48
- package/dist/cli/src/utils/keep-awake.js.map +0 -1
- package/dist/cli/src/utils/lan-ip.d.ts +0 -9
- package/dist/cli/src/utils/lan-ip.js +0 -61
- package/dist/cli/src/utils/lan-ip.js.map +0 -1
- package/dist/host/src/input-helper.d.ts +0 -1
- package/dist/host/src/input-helper.js +0 -564
- package/dist/host/src/input-helper.js.map +0 -1
- package/dist/shared-protocol/src/index.d.ts +0 -17029
- package/dist/shared-protocol/src/index.js +0 -1064
- package/dist/shared-protocol/src/index.js.map +0 -1
- package/scripts/copy-web.mjs +0 -34
- package/src/config.ts +0 -36
- package/src/machine-id.ts +0 -61
- package/src/providers.ts +0 -97
- package/src/runtime/acp/acp-client.ts +0 -521
- package/src/runtime/acp/agent-process-discovery.ts +0 -308
- package/src/runtime/acp/agent-session.ts +0 -1186
- package/src/runtime/acp/agent-workspace.ts +0 -6590
- package/src/runtime/acp/claude-sdk-client.ts +0 -1221
- package/src/runtime/acp/claude-stream-json-client.ts +0 -633
- package/src/runtime/acp/json-rpc.ts +0 -184
- package/src/runtime/acp/provider-resolver.ts +0 -154
- package/src/runtime/bridge-session.ts +0 -1549
- package/src/runtime/hook-command.ts +0 -73
- package/src/runtime/screen-fallback.ts +0 -235
- package/src/runtime/screen-share.ts +0 -426
- package/src/runtime/scrollback.ts +0 -43
- package/src/runtime/usage-report.ts +0 -342
- package/src/utils/keep-awake.ts +0 -61
- package/src/utils/lan-ip.ts +0 -61
- package/web/assets/FileBrowser-wOAfY-xj.js +0 -2
- package/web/assets/PortPreview-Cbvv7yzA.js +0 -2
- package/web/assets/TerminalPanel-CTSklkct.js +0 -1
- package/web/assets/UsageDashboard-CJ1AsNmd.js +0 -1
- package/web/assets/index-BsnDaeCt.css +0 -33
- package/web/assets/index-q85psYZ5.js +0 -36
- package/web/assets/vendor-highlight-NXWAAyIv.js +0 -5
- package/web/assets/vendor-react-M7xG3qiw.js +0 -49
- package/web/assets/vendor-xterm-D15THcI4.js +0 -9
- package/web/index.html +0 -37
- package/web/logos/claude.png +0 -0
- package/web/logos/codex.png +0 -0
- package/web/logos/copilot.png +0 -0
- package/web/logos/gemini.png +0 -0
- package/web/logos/linkshell-1024.png +0 -0
- package/web/logos/linkshell-splash.png +0 -0
- package/web/logos/linkshell.png +0 -0
|
@@ -1,1549 +0,0 @@
|
|
|
1
|
-
import * as pty from "node-pty";
|
|
2
|
-
import * as http from "node:http";
|
|
3
|
-
import WebSocket from "ws";
|
|
4
|
-
import { hostname, platform, homedir } from "node:os";
|
|
5
|
-
import { writeFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
|
|
6
|
-
import { tmpdir } from "node:os";
|
|
7
|
-
import { join, basename, resolve, relative, isAbsolute } from "node:path";
|
|
8
|
-
import {
|
|
9
|
-
agentV2MessageRoute,
|
|
10
|
-
createEnvelope,
|
|
11
|
-
parseEnvelope,
|
|
12
|
-
parseTypedPayload,
|
|
13
|
-
serializeEnvelope,
|
|
14
|
-
PROTOCOL_VERSION,
|
|
15
|
-
} from "@linkshell/protocol";
|
|
16
|
-
import type { Envelope } from "@linkshell/protocol";
|
|
17
|
-
import type { ProviderConfig } from "../providers.js";
|
|
18
|
-
import { ScrollbackBuffer } from "./scrollback.js";
|
|
19
|
-
import { ScreenFallback } from "./screen-fallback.js";
|
|
20
|
-
import { ScreenShare } from "./screen-share.js";
|
|
21
|
-
import { getLanIp } from "../utils/lan-ip.js";
|
|
22
|
-
import { startKeepAwake, type KeepAwakeHandle } from "../utils/keep-awake.js";
|
|
23
|
-
import { loadOrCreateMachineIdentity, type MachineIdentity } from "../machine-id.js";
|
|
24
|
-
import { getValidToken, refreshAccessToken } from "../auth.js";
|
|
25
|
-
import { AgentSessionProxy } from "./acp/agent-session.js";
|
|
26
|
-
import { AgentWorkspaceProxy, makeAgentV2RemoteConversationId } from "./acp/agent-workspace.js";
|
|
27
|
-
import { detectAvailableProviders, type AgentProvider } from "./acp/provider-resolver.js";
|
|
28
|
-
import { sweepLinkShellHookConfigs } from "./hook-command.js";
|
|
29
|
-
|
|
30
|
-
export interface BridgeSessionOptions {
|
|
31
|
-
gatewayUrl: string;
|
|
32
|
-
gatewayHttpUrl: string;
|
|
33
|
-
pairingGateway?: string;
|
|
34
|
-
sessionId?: string;
|
|
35
|
-
cols: number;
|
|
36
|
-
rows: number;
|
|
37
|
-
clientName: string;
|
|
38
|
-
hostname?: string;
|
|
39
|
-
verbose?: boolean;
|
|
40
|
-
screen?: boolean;
|
|
41
|
-
providerConfig: ProviderConfig;
|
|
42
|
-
authToken?: string;
|
|
43
|
-
keepAwake?: boolean;
|
|
44
|
-
agentUi?: boolean;
|
|
45
|
-
agentProvider?: AgentProvider;
|
|
46
|
-
agentCommand?: string;
|
|
47
|
-
/**
|
|
48
|
-
* Confinement root for client-driven filesystem operations
|
|
49
|
-
* (terminal.browse / terminal.file.read / terminal.mkdir).
|
|
50
|
-
* Defaults to the user's home directory. Paths resolving outside this root are rejected.
|
|
51
|
-
*/
|
|
52
|
-
fileRoot?: string;
|
|
53
|
-
/**
|
|
54
|
-
* Ports the tunnel proxy is allowed to connect to on 127.0.0.1.
|
|
55
|
-
* Defaults to DEFAULT_TUNNEL_PORTS when omitted.
|
|
56
|
-
*/
|
|
57
|
-
allowedTunnelPorts?: number[];
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const HEARTBEAT_INTERVAL = 15_000;
|
|
61
|
-
// Transport-layer WS keepalive: ping the gateway periodically; if a pong
|
|
62
|
-
// doesn't arrive before the next ping, the socket is half-open (network
|
|
63
|
-
// silently dropped, no FIN) — terminate it so the reconnect loop kicks in.
|
|
64
|
-
const WS_PING_INTERVAL = 20_000;
|
|
65
|
-
const RECONNECT_BASE_DELAY = 1_000;
|
|
66
|
-
const RECONNECT_MAX_DELAY = 30_000;
|
|
67
|
-
// Cap the exponent so the backoff levels off at RECONNECT_MAX_DELAY instead of
|
|
68
|
-
// overflowing. There is NO attempt ceiling — a connected host never gives up
|
|
69
|
-
// (only `stop()`/all-PTYs-exited ends it), so it survives gateway restarts and
|
|
70
|
-
// arbitrarily long network outages, reconnecting whenever connectivity returns.
|
|
71
|
-
const RECONNECT_MAX_EXPONENT = 5;
|
|
72
|
-
const DEFAULT_TERMINAL_ID = "default";
|
|
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];
|
|
83
|
-
interface TerminalInstance {
|
|
84
|
-
id: string;
|
|
85
|
-
pty: pty.IPty;
|
|
86
|
-
cwd: string;
|
|
87
|
-
projectName: string;
|
|
88
|
-
provider: string;
|
|
89
|
-
scrollback: ScrollbackBuffer;
|
|
90
|
-
outputSeq: number;
|
|
91
|
-
statusSeq: number;
|
|
92
|
-
status: "running" | "exited";
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function stripGatewayBase(url: URL): string {
|
|
96
|
-
url.hash = "";
|
|
97
|
-
url.search = "";
|
|
98
|
-
url.pathname = "";
|
|
99
|
-
return url.toString().replace(/\/+$/, "");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function isHttpGatewayUrl(url: URL): boolean {
|
|
103
|
-
return url.protocol === "http:" || url.protocol === "https:";
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
|
|
107
|
-
try {
|
|
108
|
-
const url = new URL(gatewayHttpUrl);
|
|
109
|
-
if (!isHttpGatewayUrl(url)) {
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
const hostname = url.hostname.trim().toLowerCase();
|
|
113
|
-
if (
|
|
114
|
-
hostname === "localhost" ||
|
|
115
|
-
hostname === "127.0.0.1" ||
|
|
116
|
-
hostname === "0.0.0.0" ||
|
|
117
|
-
hostname === "::1"
|
|
118
|
-
) {
|
|
119
|
-
// Replace localhost with LAN IP so real devices can reach it
|
|
120
|
-
const lanIp = getLanIp();
|
|
121
|
-
if (lanIp === "127.0.0.1") {
|
|
122
|
-
return undefined; // No LAN interface found, can't help
|
|
123
|
-
}
|
|
124
|
-
url.hostname = lanIp;
|
|
125
|
-
return stripGatewayBase(url);
|
|
126
|
-
}
|
|
127
|
-
return stripGatewayBase(url);
|
|
128
|
-
} catch {
|
|
129
|
-
return gatewayHttpUrl.replace(/\/+$/, "") || undefined;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
export function resolvePairingGateway(
|
|
134
|
-
gatewayHttpUrl: string,
|
|
135
|
-
pairingGateway?: string,
|
|
136
|
-
): string | undefined {
|
|
137
|
-
const override = pairingGateway?.trim();
|
|
138
|
-
if (!override) {
|
|
139
|
-
return getPairingGatewayParam(gatewayHttpUrl);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
try {
|
|
143
|
-
const absoluteUrl = new URL(override);
|
|
144
|
-
if (isHttpGatewayUrl(absoluteUrl)) {
|
|
145
|
-
return stripGatewayBase(absoluteUrl);
|
|
146
|
-
}
|
|
147
|
-
} catch {
|
|
148
|
-
// Fall through and treat the override as a host[:port] value.
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
try {
|
|
152
|
-
const baseUrl = new URL(gatewayHttpUrl);
|
|
153
|
-
if (!isHttpGatewayUrl(baseUrl)) {
|
|
154
|
-
return override.replace(/\/+$/, "") || undefined;
|
|
155
|
-
}
|
|
156
|
-
const normalizedHost = override
|
|
157
|
-
.replace(/^https?:\/\//i, "")
|
|
158
|
-
.replace(/\/.*$/, "")
|
|
159
|
-
.trim();
|
|
160
|
-
|
|
161
|
-
if (!normalizedHost) {
|
|
162
|
-
return getPairingGatewayParam(gatewayHttpUrl);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
baseUrl.host = normalizedHost;
|
|
166
|
-
return stripGatewayBase(baseUrl);
|
|
167
|
-
} catch {
|
|
168
|
-
try {
|
|
169
|
-
const prefixed = new URL(`http://${override.replace(/^\/+/, "")}`);
|
|
170
|
-
return stripGatewayBase(prefixed);
|
|
171
|
-
} catch {
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function normalizeAgentProvider(provider: unknown): AgentProvider {
|
|
178
|
-
if (typeof provider === "string" && provider.trim()) return provider.trim();
|
|
179
|
-
return "codex";
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function resolveAgentWorkspaceProviders(options: {
|
|
183
|
-
agentProvider?: AgentProvider;
|
|
184
|
-
agentCommand?: string;
|
|
185
|
-
}): AgentProvider[] {
|
|
186
|
-
if (options.agentCommand?.trim()) {
|
|
187
|
-
return [normalizeAgentProvider(options.agentProvider ?? "custom")];
|
|
188
|
-
}
|
|
189
|
-
const defaultProviders: AgentProvider[] = ["codex", "claude"];
|
|
190
|
-
const detected = detectAvailableProviders();
|
|
191
|
-
const requested = options.agentProvider ? normalizeAgentProvider(options.agentProvider) : undefined;
|
|
192
|
-
const ordered = [
|
|
193
|
-
...(requested ? [requested] : []),
|
|
194
|
-
...detected,
|
|
195
|
-
...defaultProviders,
|
|
196
|
-
];
|
|
197
|
-
const unique: AgentProvider[] = [];
|
|
198
|
-
for (const provider of ordered) {
|
|
199
|
-
if (!unique.includes(provider)) unique.push(provider);
|
|
200
|
-
}
|
|
201
|
-
return unique;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export class BridgeSession {
|
|
205
|
-
private readonly options: BridgeSessionOptions;
|
|
206
|
-
private socket: WebSocket | undefined;
|
|
207
|
-
private terminals = new Map<string, TerminalInstance>();
|
|
208
|
-
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
209
|
-
private wsPingTimer: ReturnType<typeof setInterval> | undefined;
|
|
210
|
-
// True between sending a WS ping and receiving its pong. If still true at the
|
|
211
|
-
// next ping tick, the socket is half-open and gets terminated.
|
|
212
|
-
private awaitingPong = false;
|
|
213
|
-
private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
214
|
-
private reconnectAttempts = 0;
|
|
215
|
-
private reconnecting = false;
|
|
216
|
-
private sessionId = "";
|
|
217
|
-
private hostToken: string | undefined;
|
|
218
|
-
private exited = false;
|
|
219
|
-
private stopped = false;
|
|
220
|
-
private screenCapture: ScreenFallback | undefined;
|
|
221
|
-
private screenShare: ScreenShare | undefined;
|
|
222
|
-
private tunnelSockets = new Map<string, WebSocket>();
|
|
223
|
-
private keepAwake: KeepAwakeHandle | undefined;
|
|
224
|
-
private agentSession: AgentSessionProxy | undefined;
|
|
225
|
-
private agentWorkspace: AgentWorkspaceProxy | undefined;
|
|
226
|
-
private outboundAgentQueue: Envelope[] = [];
|
|
227
|
-
private machineIdentity: MachineIdentity | undefined;
|
|
228
|
-
// Default host workspace shown to clients and used by the initial PTY.
|
|
229
|
-
private readonly defaultCwd: string;
|
|
230
|
-
// Confinement root for client-driven filesystem ops; resolved once at construction.
|
|
231
|
-
private readonly fileRoot: string;
|
|
232
|
-
// Ports the tunnel proxy may connect to on 127.0.0.1.
|
|
233
|
-
private readonly allowedTunnelPorts: Set<number>;
|
|
234
|
-
// Pending deletions of exited terminals (terminalId → timer) so we can clear on stop().
|
|
235
|
-
private exitedTerminalTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
236
|
-
// Invoked when all terminals have exited naturally — closes embedded gateway,
|
|
237
|
-
// removes daemon PID file, etc. Set by the CLI entry point.
|
|
238
|
-
private onAllTerminalsExited: (() => void | Promise<void>) | undefined;
|
|
239
|
-
|
|
240
|
-
constructor(options: BridgeSessionOptions) {
|
|
241
|
-
this.options = options;
|
|
242
|
-
this.sessionId = options.sessionId ?? "";
|
|
243
|
-
this.defaultCwd = resolve(homedir());
|
|
244
|
-
// Confine client-driven file ops to the user's home by default (single-user
|
|
245
|
-
// self-hosted model: the owner browses their own projects under ~). This
|
|
246
|
-
// still blocks /etc, /usr, other users' dirs. Override via --file-root.
|
|
247
|
-
this.fileRoot = resolve(options.fileRoot ?? homedir());
|
|
248
|
-
this.allowedTunnelPorts = new Set(
|
|
249
|
-
options.allowedTunnelPorts && options.allowedTunnelPorts.length > 0
|
|
250
|
-
? options.allowedTunnelPorts
|
|
251
|
-
: DEFAULT_TUNNEL_PORTS,
|
|
252
|
-
);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** Register a callback invoked once all terminals exit naturally (process self-cleanup). */
|
|
256
|
-
setOnAllTerminalsExited(handler: () => void | Promise<void>): void {
|
|
257
|
-
this.onAllTerminalsExited = handler;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/**
|
|
261
|
-
* Resolve a client-supplied path inside the confinement root.
|
|
262
|
-
* Returns the absolute resolved path, or null if it escapes the root.
|
|
263
|
-
*/
|
|
264
|
-
private resolveConfinedPath(clientPath: string): string | null {
|
|
265
|
-
const expanded = clientPath.startsWith("~")
|
|
266
|
-
? clientPath.replace(/^~/, homedir())
|
|
267
|
-
: clientPath;
|
|
268
|
-
// Resolve relative paths against the confinement root, not process.cwd().
|
|
269
|
-
const target = isAbsolute(expanded)
|
|
270
|
-
? resolve(expanded)
|
|
271
|
-
: resolve(this.fileRoot, expanded);
|
|
272
|
-
const rel = relative(this.fileRoot, target);
|
|
273
|
-
if (rel === "") return target; // the root itself
|
|
274
|
-
if (rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
275
|
-
return target;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
private log(msg: string): void {
|
|
279
|
-
if (this.options.verbose) {
|
|
280
|
-
process.stderr.write(`[bridge:verbose] ${msg}\n`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
async start(): Promise<void> {
|
|
285
|
-
this.log(
|
|
286
|
-
`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`,
|
|
287
|
-
);
|
|
288
|
-
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
289
|
-
// One-shot leftover sweep. Structured agent control is ACP/SDK subprocesses;
|
|
290
|
-
// we never rewrite hook configs.
|
|
291
|
-
sweepLinkShellHookConfigs(homedir(), [join(this.defaultCwd, "hooks.json")]);
|
|
292
|
-
if (!this.sessionId) {
|
|
293
|
-
await this.createPairing();
|
|
294
|
-
}
|
|
295
|
-
if (this.options.keepAwake) {
|
|
296
|
-
this.keepAwake = startKeepAwake();
|
|
297
|
-
} else {
|
|
298
|
-
process.stderr.write("[bridge] keep-awake disabled\n");
|
|
299
|
-
}
|
|
300
|
-
if (this.options.agentUi) {
|
|
301
|
-
const availableProviders = resolveAgentWorkspaceProviders(this.options);
|
|
302
|
-
const agentOptions = {
|
|
303
|
-
sessionId: this.sessionId,
|
|
304
|
-
cwd: this.defaultCwd,
|
|
305
|
-
availableProviders,
|
|
306
|
-
command: this.options.agentCommand,
|
|
307
|
-
verbose: this.options.verbose,
|
|
308
|
-
send: (envelope: Envelope) => this.send(envelope),
|
|
309
|
-
};
|
|
310
|
-
this.agentSession = new AgentSessionProxy({
|
|
311
|
-
...agentOptions,
|
|
312
|
-
});
|
|
313
|
-
this.agentWorkspace = new AgentWorkspaceProxy({
|
|
314
|
-
...agentOptions,
|
|
315
|
-
});
|
|
316
|
-
process.stderr.write(`[bridge] agent workspace channel enabled (providers: ${availableProviders.join(", ") || "none"})\n`);
|
|
317
|
-
}
|
|
318
|
-
await this.spawnTerminal(DEFAULT_TERMINAL_ID, this.defaultCwd);
|
|
319
|
-
await this.connectGateway();
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
private async createPairing(): Promise<void> {
|
|
323
|
-
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
324
|
-
const authToken = await this.resolveAuthToken();
|
|
325
|
-
if (authToken) {
|
|
326
|
-
headers["Authorization"] = `Bearer ${authToken}`;
|
|
327
|
-
}
|
|
328
|
-
const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
|
|
329
|
-
method: "POST",
|
|
330
|
-
headers,
|
|
331
|
-
body: JSON.stringify({}),
|
|
332
|
-
});
|
|
333
|
-
if (!res.ok) {
|
|
334
|
-
throw new Error(`Failed to create pairing: ${res.status}`);
|
|
335
|
-
}
|
|
336
|
-
const body = (await res.json()) as {
|
|
337
|
-
sessionId: string;
|
|
338
|
-
pairingCode: string;
|
|
339
|
-
hostToken?: string;
|
|
340
|
-
expiresAt: string;
|
|
341
|
-
};
|
|
342
|
-
this.sessionId = body.sessionId;
|
|
343
|
-
// Secret token proving we are the legitimate host for this session; sent on
|
|
344
|
-
// every host WS connect so a third party who learns the sessionId can't
|
|
345
|
-
// hijack the host role.
|
|
346
|
-
this.hostToken = body.hostToken;
|
|
347
|
-
|
|
348
|
-
const pairingGateway = resolvePairingGateway(
|
|
349
|
-
this.options.gatewayHttpUrl,
|
|
350
|
-
this.options.pairingGateway,
|
|
351
|
-
);
|
|
352
|
-
const deepLink = pairingGateway
|
|
353
|
-
? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
|
|
354
|
-
: `linkshell://pair?code=${body.pairingCode}`;
|
|
355
|
-
|
|
356
|
-
process.stderr.write(
|
|
357
|
-
`\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`,
|
|
358
|
-
);
|
|
359
|
-
process.stderr.write(` Session: ${body.sessionId}\n`);
|
|
360
|
-
process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
|
|
361
|
-
if (!pairingGateway) {
|
|
362
|
-
process.stderr.write(
|
|
363
|
-
" Note: QR will use the app's current gateway because the CLI is pointed at a local-only address.\n\n",
|
|
364
|
-
);
|
|
365
|
-
} else if (this.options.pairingGateway) {
|
|
366
|
-
process.stderr.write(` Pairing gateway: ${pairingGateway}\n\n`);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Show QR code for mobile scanning
|
|
370
|
-
try {
|
|
371
|
-
const qrModule = await import("qrcode-terminal");
|
|
372
|
-
const qrDriver = qrModule.default ?? qrModule;
|
|
373
|
-
if (typeof qrDriver.generate === "function") {
|
|
374
|
-
qrDriver.generate(deepLink, { small: true }, (code: string) => {
|
|
375
|
-
process.stderr.write(` Scan to connect:\n`);
|
|
376
|
-
for (const line of code.split("\n")) {
|
|
377
|
-
process.stderr.write(` ${line}\n`);
|
|
378
|
-
}
|
|
379
|
-
process.stderr.write(`\n`);
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
} catch {
|
|
383
|
-
// qrcode-terminal not available, skip
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
process.stderr.write(` Deep link: ${deepLink}\n\n`);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
private async resolveAuthToken(): Promise<string | undefined> {
|
|
390
|
-
if (!this.options.authToken) return undefined;
|
|
391
|
-
try {
|
|
392
|
-
const token = await getValidToken();
|
|
393
|
-
if (token) {
|
|
394
|
-
this.options.authToken = token;
|
|
395
|
-
return token;
|
|
396
|
-
}
|
|
397
|
-
// Refresh failed (e.g. offline / transient Supabase error). Do NOT clear
|
|
398
|
-
// the token — keep using the last one we had. It may be expired and the
|
|
399
|
-
// gateway may reject it, but the close handler force-refreshes on the next
|
|
400
|
-
// attempt, and clearing it would permanently downgrade us to no-auth and
|
|
401
|
-
// lock a pro host out of its own sessions once connectivity returns.
|
|
402
|
-
process.stderr.write(
|
|
403
|
-
"[bridge] token refresh failed; reusing last token (will retry refresh on reconnect)\n",
|
|
404
|
-
);
|
|
405
|
-
return this.options.authToken;
|
|
406
|
-
} catch (error) {
|
|
407
|
-
this.log(`failed to refresh login token: ${error instanceof Error ? error.message : String(error)}`);
|
|
408
|
-
return this.options.authToken;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// After an auth-class WS close, force a token refresh before reconnecting so
|
|
413
|
-
// we don't spin on 401/4001/4003. A gateway restart surfaces as 1006 right
|
|
414
|
-
// after a fresh connect, so we refresh on that too (cheap, debounced by the
|
|
415
|
-
// reconnect backoff). No-op for clean/normal closes.
|
|
416
|
-
private async maybeRefreshTokenForClose(code: number): Promise<void> {
|
|
417
|
-
if (!this.options.authToken) return;
|
|
418
|
-
const authClass = code === 4001 || code === 4003 || code === 1006 || code === 401;
|
|
419
|
-
if (!authClass) return;
|
|
420
|
-
try {
|
|
421
|
-
const refreshed = await refreshAccessToken();
|
|
422
|
-
if (refreshed?.accessToken) {
|
|
423
|
-
this.options.authToken = refreshed.accessToken;
|
|
424
|
-
this.log("refreshed auth token after auth-class close");
|
|
425
|
-
}
|
|
426
|
-
} catch {
|
|
427
|
-
// Offline — keep the old token; the reconnect loop will retry later.
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
private async connectGateway(): Promise<void> {
|
|
432
|
-
if (this.stopped) {
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
const url = new URL(this.options.gatewayUrl);
|
|
437
|
-
url.searchParams.set("sessionId", this.sessionId);
|
|
438
|
-
url.searchParams.set("role", "host");
|
|
439
|
-
const authToken = await this.resolveAuthToken();
|
|
440
|
-
const wsOptions: WebSocket.ClientOptions = {};
|
|
441
|
-
const headers: Record<string, string> = {};
|
|
442
|
-
if (authToken) {
|
|
443
|
-
// Prefer the Authorization header so the token doesn't leak into URLs/logs.
|
|
444
|
-
headers["Authorization"] = `Bearer ${authToken}`;
|
|
445
|
-
// TODO: remove the query param once all gateways read the Authorization
|
|
446
|
-
// header. Kept for backward compatibility with older gateways that only
|
|
447
|
-
// read the `auth_token` query parameter.
|
|
448
|
-
url.searchParams.set("auth_token", authToken);
|
|
449
|
-
}
|
|
450
|
-
if (this.hostToken) {
|
|
451
|
-
headers["x-linkshell-host-token"] = this.hostToken;
|
|
452
|
-
}
|
|
453
|
-
if (Object.keys(headers).length > 0) {
|
|
454
|
-
wsOptions.headers = headers;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
this.socket = new WebSocket(url, wsOptions);
|
|
458
|
-
|
|
459
|
-
this.socket.on("open", () => {
|
|
460
|
-
process.stderr.write(
|
|
461
|
-
this.reconnectAttempts > 0
|
|
462
|
-
? "[bridge] gateway reconnected\n"
|
|
463
|
-
: "[bridge] gateway connected\n",
|
|
464
|
-
);
|
|
465
|
-
this.reconnectAttempts = 0;
|
|
466
|
-
this.reconnecting = false;
|
|
467
|
-
this.send(
|
|
468
|
-
createEnvelope({
|
|
469
|
-
type: "session.connect",
|
|
470
|
-
sessionId: this.sessionId,
|
|
471
|
-
payload: {
|
|
472
|
-
role: "host" as const,
|
|
473
|
-
clientName: this.options.clientName,
|
|
474
|
-
provider: this.options.providerConfig.provider,
|
|
475
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
476
|
-
machineId: this.machineIdentity?.machineId,
|
|
477
|
-
hostname: this.options.hostname || hostname(),
|
|
478
|
-
platform: platform(),
|
|
479
|
-
cwd: this.defaultCwd,
|
|
480
|
-
projectName: basename(this.defaultCwd),
|
|
481
|
-
},
|
|
482
|
-
}),
|
|
483
|
-
);
|
|
484
|
-
if (this.outboundAgentQueue.length > 0) {
|
|
485
|
-
const queued = this.outboundAgentQueue.splice(0);
|
|
486
|
-
for (const envelope of queued) this.send(envelope);
|
|
487
|
-
}
|
|
488
|
-
this.startHeartbeat();
|
|
489
|
-
this.startWsPing();
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
// Gateway answered our WS ping — connection is alive.
|
|
493
|
-
this.socket.on("pong", () => {
|
|
494
|
-
this.awaitingPong = false;
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
this.socket.on("message", (data) => {
|
|
498
|
-
let envelope: Envelope;
|
|
499
|
-
try {
|
|
500
|
-
envelope = parseEnvelope(data.toString());
|
|
501
|
-
} catch (err) {
|
|
502
|
-
this.log(`invalid gateway message ignored: ${err}`);
|
|
503
|
-
return;
|
|
504
|
-
}
|
|
505
|
-
this.log(
|
|
506
|
-
`recv ${envelope.type}${envelope.seq !== undefined ? ` seq=${envelope.seq}` : ""}`,
|
|
507
|
-
);
|
|
508
|
-
this.handleMessage(envelope).catch((err) => {
|
|
509
|
-
this.log(`handleMessage error: ${err}`);
|
|
510
|
-
});
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
this.socket.on("close", (code, reasonBuffer) => {
|
|
514
|
-
this.stopHeartbeat();
|
|
515
|
-
this.stopWsPing();
|
|
516
|
-
this.socket = undefined;
|
|
517
|
-
const reason = reasonBuffer.toString();
|
|
518
|
-
// Quiet during an ongoing outage: log the first close (we were connected,
|
|
519
|
-
// attempts==0) and every 10th, so multi-hour outages don't flood the log.
|
|
520
|
-
if (this.reconnectAttempts === 0 || this.reconnectAttempts % 10 === 0) {
|
|
521
|
-
process.stderr.write(
|
|
522
|
-
`[bridge] gateway connection closed (code=${code}${reason ? `, reason=${reason}` : ""})\n`,
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
if (!this.exited) {
|
|
526
|
-
// Auth-class closes (token expired / gateway restarted) get a forced
|
|
527
|
-
// token refresh before the next attempt, so we don't loop on 401/4xxx.
|
|
528
|
-
this.maybeRefreshTokenForClose(code).finally(() => {
|
|
529
|
-
this.scheduleReconnect();
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
this.socket.on("error", (error) => {
|
|
535
|
-
// During an outage every retry emits a connection error; the `close`
|
|
536
|
-
// handler already logs (quietly) and drives reconnect. Only surface the
|
|
537
|
-
// first error of an outage to avoid flooding the daemon log.
|
|
538
|
-
if (this.reconnectAttempts <= 1) {
|
|
539
|
-
process.stderr.write(`[bridge] gateway error: ${error.message || "connection failed"}\n`);
|
|
540
|
-
}
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
private async handleMessage(envelope: Envelope): Promise<void> {
|
|
545
|
-
const tid = envelope.terminalId ?? DEFAULT_TERMINAL_ID;
|
|
546
|
-
const agentV2Route = agentV2MessageRoute(envelope.type);
|
|
547
|
-
if (agentV2Route === "client_write" || agentV2Route === "client_read") {
|
|
548
|
-
if (!this.agentWorkspace) {
|
|
549
|
-
this.send(
|
|
550
|
-
createEnvelope({
|
|
551
|
-
type: "agent.v2.capabilities",
|
|
552
|
-
sessionId: this.sessionId,
|
|
553
|
-
payload: {
|
|
554
|
-
enabled: false,
|
|
555
|
-
provider: normalizeAgentProvider(
|
|
556
|
-
this.options.agentProvider ?? "codex",
|
|
557
|
-
),
|
|
558
|
-
machineId: this.machineIdentity?.machineId,
|
|
559
|
-
workspaceProtocolVersion: 2,
|
|
560
|
-
error: "Agent Workspace is not enabled. Start CLI with --agent-ui.",
|
|
561
|
-
supportsSessionList: false,
|
|
562
|
-
supportsSessionLoad: false,
|
|
563
|
-
supportsImages: false,
|
|
564
|
-
supportsAudio: false,
|
|
565
|
-
supportsPermission: false,
|
|
566
|
-
supportsPlan: false,
|
|
567
|
-
supportsCancel: false,
|
|
568
|
-
},
|
|
569
|
-
}),
|
|
570
|
-
);
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
573
|
-
try {
|
|
574
|
-
await this.agentWorkspace.handleEnvelope(envelope);
|
|
575
|
-
} catch (error) {
|
|
576
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
577
|
-
this.log(`agent.v2 invalid message: ${message}`);
|
|
578
|
-
this.send(createEnvelope({
|
|
579
|
-
type: "session.error",
|
|
580
|
-
sessionId: this.sessionId,
|
|
581
|
-
payload: {
|
|
582
|
-
code: "invalid_message",
|
|
583
|
-
message: `Invalid Agent Workspace message: ${message}`,
|
|
584
|
-
},
|
|
585
|
-
}));
|
|
586
|
-
}
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
switch (envelope.type) {
|
|
590
|
-
case "terminal.input": {
|
|
591
|
-
const p = parseTypedPayload("terminal.input", envelope.payload);
|
|
592
|
-
const term = this.terminals.get(tid);
|
|
593
|
-
if (term && term.status === "running") term.pty.write(p.data);
|
|
594
|
-
break;
|
|
595
|
-
}
|
|
596
|
-
case "terminal.resize": {
|
|
597
|
-
const p = parseTypedPayload("terminal.resize", envelope.payload);
|
|
598
|
-
const term = this.terminals.get(tid);
|
|
599
|
-
if (term && term.status === "running") term.pty.resize(p.cols, p.rows);
|
|
600
|
-
break;
|
|
601
|
-
}
|
|
602
|
-
case "terminal.spawn": {
|
|
603
|
-
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
604
|
-
const normalizedCwd = resolve(p.cwd);
|
|
605
|
-
// Cap concurrent terminals — forceNew removed the cwd-dedup ceiling, so
|
|
606
|
-
// bound growth explicitly to avoid PTY/fd exhaustion from a runaway client.
|
|
607
|
-
const liveCount = [...this.terminals.values()].filter((t) => t.status === "running").length;
|
|
608
|
-
if (liveCount >= MAX_TERMINALS) {
|
|
609
|
-
this.send(createEnvelope({
|
|
610
|
-
type: "session.error",
|
|
611
|
-
sessionId: this.sessionId,
|
|
612
|
-
payload: { code: "too_many_terminals", message: `最多同时打开 ${MAX_TERMINALS} 个终端` },
|
|
613
|
-
}));
|
|
614
|
-
break;
|
|
615
|
-
}
|
|
616
|
-
// Dedup by cwd UNLESS the client explicitly asks for a fresh terminal
|
|
617
|
-
// (forceNew lets web open multiple tabs in the same directory).
|
|
618
|
-
const existing = p.forceNew
|
|
619
|
-
? undefined
|
|
620
|
-
: [...this.terminals.values()].find(
|
|
621
|
-
(t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
|
|
622
|
-
);
|
|
623
|
-
if (existing) {
|
|
624
|
-
this.send(createEnvelope({
|
|
625
|
-
type: "terminal.spawned",
|
|
626
|
-
sessionId: this.sessionId,
|
|
627
|
-
terminalId: existing.id,
|
|
628
|
-
payload: { terminalId: existing.id, cwd: existing.cwd, projectName: existing.projectName, provider: existing.provider },
|
|
629
|
-
}));
|
|
630
|
-
} else {
|
|
631
|
-
const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
632
|
-
try {
|
|
633
|
-
await this.spawnTerminal(newId, normalizedCwd, p.provider);
|
|
634
|
-
this.send(createEnvelope({
|
|
635
|
-
type: "terminal.spawned",
|
|
636
|
-
sessionId: this.sessionId,
|
|
637
|
-
terminalId: newId,
|
|
638
|
-
payload: { terminalId: newId, cwd: normalizedCwd, projectName: basename(normalizedCwd), provider: p.provider },
|
|
639
|
-
}));
|
|
640
|
-
} catch (err) {
|
|
641
|
-
this.log(`failed to spawn terminal ${newId}: ${err}`);
|
|
642
|
-
this.send(createEnvelope({
|
|
643
|
-
type: "terminal.exit",
|
|
644
|
-
sessionId: this.sessionId,
|
|
645
|
-
terminalId: newId,
|
|
646
|
-
payload: { exitCode: 1, signal: 0 },
|
|
647
|
-
}));
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
this.sendTerminalList();
|
|
651
|
-
break;
|
|
652
|
-
}
|
|
653
|
-
case "terminal.kill": {
|
|
654
|
-
const p = parseTypedPayload("terminal.kill", envelope.payload);
|
|
655
|
-
const term = this.terminals.get(p.terminalId);
|
|
656
|
-
if (term && term.status === "running") {
|
|
657
|
-
term.pty.kill();
|
|
658
|
-
}
|
|
659
|
-
break;
|
|
660
|
-
}
|
|
661
|
-
case "terminal.browse": {
|
|
662
|
-
const p = parseTypedPayload("terminal.browse", envelope.payload);
|
|
663
|
-
const browsePath = this.resolveConfinedPath(p.path);
|
|
664
|
-
if (browsePath === null) {
|
|
665
|
-
this.send(createEnvelope({
|
|
666
|
-
type: "terminal.browse.result",
|
|
667
|
-
sessionId: this.sessionId,
|
|
668
|
-
payload: { path: p.path, entries: [], error: "Path is outside the allowed root", requestId: p.requestId },
|
|
669
|
-
}));
|
|
670
|
-
break;
|
|
671
|
-
}
|
|
672
|
-
try {
|
|
673
|
-
const entries = readdirSync(browsePath, { withFileTypes: true })
|
|
674
|
-
.filter((d) => !d.name.startsWith(".") && (d.isDirectory() || (p.includeFiles && d.isFile())))
|
|
675
|
-
.map((d) => {
|
|
676
|
-
const entryPath = join(browsePath, d.name);
|
|
677
|
-
const stats = statSync(entryPath);
|
|
678
|
-
return {
|
|
679
|
-
name: d.name,
|
|
680
|
-
path: entryPath,
|
|
681
|
-
isDirectory: d.isDirectory(),
|
|
682
|
-
size: stats.size,
|
|
683
|
-
modifiedAt: stats.mtime.toISOString(),
|
|
684
|
-
};
|
|
685
|
-
})
|
|
686
|
-
.sort((a, b) => {
|
|
687
|
-
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
|
688
|
-
return a.name.localeCompare(b.name);
|
|
689
|
-
});
|
|
690
|
-
this.send(createEnvelope({
|
|
691
|
-
type: "terminal.browse.result",
|
|
692
|
-
sessionId: this.sessionId,
|
|
693
|
-
payload: { path: browsePath, entries, requestId: p.requestId },
|
|
694
|
-
}));
|
|
695
|
-
} catch (err: unknown) {
|
|
696
|
-
this.send(createEnvelope({
|
|
697
|
-
type: "terminal.browse.result",
|
|
698
|
-
sessionId: this.sessionId,
|
|
699
|
-
payload: { path: browsePath, entries: [], error: (err as Error).message, requestId: p.requestId },
|
|
700
|
-
}));
|
|
701
|
-
}
|
|
702
|
-
break;
|
|
703
|
-
}
|
|
704
|
-
case "terminal.file.read": {
|
|
705
|
-
const p = parseTypedPayload("terminal.file.read", envelope.payload);
|
|
706
|
-
const filePath = this.resolveConfinedPath(p.path);
|
|
707
|
-
if (filePath === null) {
|
|
708
|
-
this.send(createEnvelope({
|
|
709
|
-
type: "terminal.file.read.result",
|
|
710
|
-
sessionId: this.sessionId,
|
|
711
|
-
payload: {
|
|
712
|
-
path: p.path,
|
|
713
|
-
content: "",
|
|
714
|
-
encoding: "utf8",
|
|
715
|
-
truncated: false,
|
|
716
|
-
error: "Path is outside the allowed root",
|
|
717
|
-
requestId: p.requestId,
|
|
718
|
-
},
|
|
719
|
-
}));
|
|
720
|
-
break;
|
|
721
|
-
}
|
|
722
|
-
try {
|
|
723
|
-
const stats = statSync(filePath);
|
|
724
|
-
if (!stats.isFile()) {
|
|
725
|
-
throw new Error("Path is not a file");
|
|
726
|
-
}
|
|
727
|
-
const maxBytes = p.maxBytes ?? 256_000;
|
|
728
|
-
const bytesToRead = Math.min(stats.size, maxBytes);
|
|
729
|
-
const buffer = Buffer.alloc(bytesToRead);
|
|
730
|
-
const fd = openSync(filePath, "r");
|
|
731
|
-
try {
|
|
732
|
-
readSync(fd, buffer, 0, bytesToRead, 0);
|
|
733
|
-
} finally {
|
|
734
|
-
closeSync(fd);
|
|
735
|
-
}
|
|
736
|
-
if (buffer.includes(0)) {
|
|
737
|
-
throw new Error("Binary files cannot be previewed");
|
|
738
|
-
}
|
|
739
|
-
this.send(createEnvelope({
|
|
740
|
-
type: "terminal.file.read.result",
|
|
741
|
-
sessionId: this.sessionId,
|
|
742
|
-
payload: {
|
|
743
|
-
path: filePath,
|
|
744
|
-
content: buffer.toString("utf8"),
|
|
745
|
-
encoding: "utf8",
|
|
746
|
-
size: stats.size,
|
|
747
|
-
truncated: stats.size > maxBytes,
|
|
748
|
-
requestId: p.requestId,
|
|
749
|
-
},
|
|
750
|
-
}));
|
|
751
|
-
} catch (err: unknown) {
|
|
752
|
-
this.send(createEnvelope({
|
|
753
|
-
type: "terminal.file.read.result",
|
|
754
|
-
sessionId: this.sessionId,
|
|
755
|
-
payload: {
|
|
756
|
-
path: filePath,
|
|
757
|
-
content: "",
|
|
758
|
-
encoding: "utf8",
|
|
759
|
-
truncated: false,
|
|
760
|
-
error: (err as Error).message,
|
|
761
|
-
requestId: p.requestId,
|
|
762
|
-
},
|
|
763
|
-
}));
|
|
764
|
-
}
|
|
765
|
-
break;
|
|
766
|
-
}
|
|
767
|
-
case "terminal.mkdir": {
|
|
768
|
-
const p = parseTypedPayload("terminal.mkdir", envelope.payload);
|
|
769
|
-
const dirPath = this.resolveConfinedPath(p.path);
|
|
770
|
-
if (dirPath === null) {
|
|
771
|
-
this.send(createEnvelope({
|
|
772
|
-
type: "terminal.browse.result",
|
|
773
|
-
sessionId: this.sessionId,
|
|
774
|
-
payload: { path: p.path, entries: [], error: "Path is outside the allowed root" },
|
|
775
|
-
}));
|
|
776
|
-
break;
|
|
777
|
-
}
|
|
778
|
-
try {
|
|
779
|
-
mkdirSync(dirPath, { recursive: true });
|
|
780
|
-
// Browse the parent to refresh the listing
|
|
781
|
-
const parentPath = join(dirPath, "..");
|
|
782
|
-
const entries = readdirSync(parentPath, { withFileTypes: true })
|
|
783
|
-
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
784
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
785
|
-
.map((d) => ({
|
|
786
|
-
name: d.name,
|
|
787
|
-
path: join(parentPath, d.name),
|
|
788
|
-
isDirectory: true,
|
|
789
|
-
}));
|
|
790
|
-
this.send(createEnvelope({
|
|
791
|
-
type: "terminal.browse.result",
|
|
792
|
-
sessionId: this.sessionId,
|
|
793
|
-
payload: { path: parentPath, entries },
|
|
794
|
-
}));
|
|
795
|
-
} catch (err: unknown) {
|
|
796
|
-
this.send(createEnvelope({
|
|
797
|
-
type: "terminal.browse.result",
|
|
798
|
-
sessionId: this.sessionId,
|
|
799
|
-
payload: { path: dirPath, entries: [], error: (err as Error).message },
|
|
800
|
-
}));
|
|
801
|
-
}
|
|
802
|
-
break;
|
|
803
|
-
}
|
|
804
|
-
case "terminal.list": {
|
|
805
|
-
this.sendTerminalList();
|
|
806
|
-
break;
|
|
807
|
-
}
|
|
808
|
-
case "terminal.history.request": {
|
|
809
|
-
const p = parseTypedPayload("terminal.history.request", envelope.payload);
|
|
810
|
-
const count = p.count ?? 100;
|
|
811
|
-
let entries: string[] = [];
|
|
812
|
-
let shell = "unknown";
|
|
813
|
-
try {
|
|
814
|
-
const home = homedir();
|
|
815
|
-
// Try zsh first, then bash
|
|
816
|
-
const histFiles = [
|
|
817
|
-
{ path: join(home, ".zsh_history"), shell: "zsh" },
|
|
818
|
-
{ path: join(home, ".bash_history"), shell: "bash" },
|
|
819
|
-
];
|
|
820
|
-
for (const hf of histFiles) {
|
|
821
|
-
if (existsSync(hf.path)) {
|
|
822
|
-
const raw = readFileSync(hf.path, "utf8");
|
|
823
|
-
const lines = raw.split("\n").filter(Boolean);
|
|
824
|
-
// zsh history lines may start with ": <timestamp>:0;" — strip prefix
|
|
825
|
-
const parsed = lines.map((l) => {
|
|
826
|
-
const m = l.match(/^:\s*\d+:\d+;(.*)$/);
|
|
827
|
-
return m ? m[1]! : l;
|
|
828
|
-
});
|
|
829
|
-
// Deduplicate and take last N
|
|
830
|
-
const unique = [...new Set(parsed.reverse())].slice(0, count).reverse();
|
|
831
|
-
entries = unique;
|
|
832
|
-
shell = hf.shell;
|
|
833
|
-
break;
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
} catch {}
|
|
837
|
-
this.send(createEnvelope({
|
|
838
|
-
type: "terminal.history.response",
|
|
839
|
-
sessionId: this.sessionId,
|
|
840
|
-
payload: { entries, shell },
|
|
841
|
-
}));
|
|
842
|
-
break;
|
|
843
|
-
}
|
|
844
|
-
case "session.ack": {
|
|
845
|
-
const p = parseTypedPayload("session.ack", envelope.payload);
|
|
846
|
-
const term = this.terminals.get(tid);
|
|
847
|
-
if (term) {
|
|
848
|
-
term.scrollback.trimUpTo(p.seq);
|
|
849
|
-
}
|
|
850
|
-
break;
|
|
851
|
-
}
|
|
852
|
-
case "session.resume": {
|
|
853
|
-
const p = parseTypedPayload("session.resume", envelope.payload);
|
|
854
|
-
// Replay all terminals
|
|
855
|
-
for (const [termId, term] of this.terminals) {
|
|
856
|
-
this.replayFrom(
|
|
857
|
-
termId,
|
|
858
|
-
term,
|
|
859
|
-
p.lastAckedSeqByTerminal[termId] ?? p.lastAckedSeq,
|
|
860
|
-
);
|
|
861
|
-
}
|
|
862
|
-
// Also send terminal list so client knows what's available
|
|
863
|
-
this.sendTerminalList();
|
|
864
|
-
break;
|
|
865
|
-
}
|
|
866
|
-
case "session.heartbeat":
|
|
867
|
-
break;
|
|
868
|
-
case "screen.start": {
|
|
869
|
-
const p = parseTypedPayload("screen.start", envelope.payload);
|
|
870
|
-
this.startScreenCapture(p.fps, p.quality, p.scale);
|
|
871
|
-
break;
|
|
872
|
-
}
|
|
873
|
-
case "screen.stop": {
|
|
874
|
-
this.stopScreenCapture();
|
|
875
|
-
break;
|
|
876
|
-
}
|
|
877
|
-
case "screen.answer": {
|
|
878
|
-
const p = parseTypedPayload("screen.answer", envelope.payload);
|
|
879
|
-
this.screenShare?.handleAnswer(p.sdp);
|
|
880
|
-
break;
|
|
881
|
-
}
|
|
882
|
-
case "screen.ice": {
|
|
883
|
-
const p = parseTypedPayload("screen.ice", envelope.payload);
|
|
884
|
-
this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
|
|
885
|
-
break;
|
|
886
|
-
}
|
|
887
|
-
case "agent.initialize":
|
|
888
|
-
case "agent.session.new":
|
|
889
|
-
case "agent.session.load":
|
|
890
|
-
case "agent.session.list":
|
|
891
|
-
case "agent.prompt":
|
|
892
|
-
case "agent.cancel": {
|
|
893
|
-
if (!this.agentSession) {
|
|
894
|
-
this.send(
|
|
895
|
-
createEnvelope({
|
|
896
|
-
type: "agent.capabilities",
|
|
897
|
-
sessionId: this.sessionId,
|
|
898
|
-
payload: {
|
|
899
|
-
enabled: false,
|
|
900
|
-
provider: normalizeAgentProvider(
|
|
901
|
-
this.options.agentProvider ?? "codex",
|
|
902
|
-
),
|
|
903
|
-
machineId: this.machineIdentity?.machineId,
|
|
904
|
-
error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
|
|
905
|
-
supportsSessionList: false,
|
|
906
|
-
supportsSessionLoad: false,
|
|
907
|
-
supportsImages: false,
|
|
908
|
-
supportsAudio: false,
|
|
909
|
-
supportsPermission: false,
|
|
910
|
-
supportsPlan: false,
|
|
911
|
-
supportsCancel: false,
|
|
912
|
-
},
|
|
913
|
-
}),
|
|
914
|
-
);
|
|
915
|
-
break;
|
|
916
|
-
}
|
|
917
|
-
await this.agentSession.handleEnvelope(envelope);
|
|
918
|
-
break;
|
|
919
|
-
}
|
|
920
|
-
case "agent.permission.response": {
|
|
921
|
-
const p = parseTypedPayload("agent.permission.response", envelope.payload);
|
|
922
|
-
if (!this.agentSession) {
|
|
923
|
-
this.send(
|
|
924
|
-
createEnvelope({
|
|
925
|
-
type: "agent.capabilities",
|
|
926
|
-
sessionId: this.sessionId,
|
|
927
|
-
payload: {
|
|
928
|
-
enabled: false,
|
|
929
|
-
provider: normalizeAgentProvider(
|
|
930
|
-
this.options.agentProvider ?? "codex",
|
|
931
|
-
),
|
|
932
|
-
machineId: this.machineIdentity?.machineId,
|
|
933
|
-
error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
|
|
934
|
-
supportsSessionList: false,
|
|
935
|
-
supportsSessionLoad: false,
|
|
936
|
-
supportsImages: false,
|
|
937
|
-
supportsAudio: false,
|
|
938
|
-
supportsPermission: false,
|
|
939
|
-
supportsPlan: false,
|
|
940
|
-
supportsCancel: false,
|
|
941
|
-
},
|
|
942
|
-
}),
|
|
943
|
-
);
|
|
944
|
-
break;
|
|
945
|
-
}
|
|
946
|
-
await this.agentSession.handleEnvelope(envelope);
|
|
947
|
-
break;
|
|
948
|
-
}
|
|
949
|
-
case "file.upload": {
|
|
950
|
-
const p = parseTypedPayload("file.upload", envelope.payload);
|
|
951
|
-
const ext = p.filename.split(".").pop() || "png";
|
|
952
|
-
const tempPath = join(tmpdir(), `linkshell-image-${Date.now()}.${ext}`);
|
|
953
|
-
writeFileSync(tempPath, Buffer.from(p.data, "base64"));
|
|
954
|
-
this.log(`image saved to ${tempPath}`);
|
|
955
|
-
const term = this.terminals.get(tid);
|
|
956
|
-
if (term && term.status === "running") {
|
|
957
|
-
term.pty.write(`\x1b[200~${tempPath}\x1b[201~`);
|
|
958
|
-
}
|
|
959
|
-
break;
|
|
960
|
-
}
|
|
961
|
-
case "permission.decision": {
|
|
962
|
-
const p = envelope.payload as { requestId: string; decision: "allow" | "deny" };
|
|
963
|
-
process.stderr.write(
|
|
964
|
-
`[bridge] permission.decision ignored (ACP/SDK owns authorization) request=${p.requestId} decision=${p.decision}\n`,
|
|
965
|
-
);
|
|
966
|
-
this.send(createEnvelope({
|
|
967
|
-
type: "permission.decision.result",
|
|
968
|
-
sessionId: this.sessionId,
|
|
969
|
-
terminalId: tid,
|
|
970
|
-
payload: {
|
|
971
|
-
requestId: p.requestId,
|
|
972
|
-
decision: p.decision,
|
|
973
|
-
resolved: false,
|
|
974
|
-
delivered: false,
|
|
975
|
-
source: "permission.decision",
|
|
976
|
-
message: "Authorization is handled by the ACP/SDK agent subprocess",
|
|
977
|
-
},
|
|
978
|
-
}));
|
|
979
|
-
break;
|
|
980
|
-
}
|
|
981
|
-
case "tunnel.request": {
|
|
982
|
-
const p = parseTypedPayload("tunnel.request", envelope.payload);
|
|
983
|
-
this.handleTunnelRequest(p);
|
|
984
|
-
break;
|
|
985
|
-
}
|
|
986
|
-
case "tunnel.ws.data": {
|
|
987
|
-
const p = parseTypedPayload("tunnel.ws.data", envelope.payload);
|
|
988
|
-
this.handleTunnelWsData(p);
|
|
989
|
-
break;
|
|
990
|
-
}
|
|
991
|
-
case "tunnel.ws.close": {
|
|
992
|
-
const p = parseTypedPayload("tunnel.ws.close", envelope.payload);
|
|
993
|
-
this.handleTunnelWsClose(p);
|
|
994
|
-
break;
|
|
995
|
-
}
|
|
996
|
-
default:
|
|
997
|
-
break;
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
// ── Tunnel handlers ────────────────────────────────────────────────
|
|
1002
|
-
|
|
1003
|
-
private handleTunnelRequest(payload: {
|
|
1004
|
-
requestId: string;
|
|
1005
|
-
method: string;
|
|
1006
|
-
url: string;
|
|
1007
|
-
headers: Record<string, string>;
|
|
1008
|
-
body: string | null;
|
|
1009
|
-
port: number;
|
|
1010
|
-
}): void {
|
|
1011
|
-
const { requestId, method, url: reqUrl, headers, body, port } = payload;
|
|
1012
|
-
|
|
1013
|
-
// SSRF guard: only proxy to ports in the allowlist (pinned to 127.0.0.1).
|
|
1014
|
-
if (!this.allowedTunnelPorts.has(port)) {
|
|
1015
|
-
this.log(`rejecting tunnel request to disallowed port ${port}`);
|
|
1016
|
-
if (headers.upgrade === "websocket") {
|
|
1017
|
-
this.send(createEnvelope({
|
|
1018
|
-
type: "tunnel.ws.close",
|
|
1019
|
-
sessionId: this.sessionId,
|
|
1020
|
-
payload: { requestId, code: 1008, reason: `Port ${port} is not allowed` },
|
|
1021
|
-
}));
|
|
1022
|
-
} else {
|
|
1023
|
-
this.sendTunnelError(requestId, 403, `Port ${port} is not allowed`);
|
|
1024
|
-
}
|
|
1025
|
-
return;
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
// WebSocket upgrade request
|
|
1029
|
-
if (headers.upgrade === "websocket") {
|
|
1030
|
-
this.handleTunnelWsUpgrade(requestId, port, reqUrl);
|
|
1031
|
-
return;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
const parsedUrl = new URL(reqUrl, `http://127.0.0.1:${port}`);
|
|
1035
|
-
|
|
1036
|
-
const reqOptions: http.RequestOptions = {
|
|
1037
|
-
hostname: "127.0.0.1",
|
|
1038
|
-
port,
|
|
1039
|
-
path: parsedUrl.pathname + parsedUrl.search,
|
|
1040
|
-
method,
|
|
1041
|
-
headers: { ...headers, host: `127.0.0.1:${port}` },
|
|
1042
|
-
};
|
|
1043
|
-
|
|
1044
|
-
const proxyReq = http.request(reqOptions, (proxyRes) => {
|
|
1045
|
-
// Collect response headers
|
|
1046
|
-
const resHeaders: Record<string, string> = {};
|
|
1047
|
-
for (const [key, val] of Object.entries(proxyRes.headers)) {
|
|
1048
|
-
if (typeof val === "string") resHeaders[key] = val;
|
|
1049
|
-
else if (Array.isArray(val)) resHeaders[key] = val.join(", ");
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
let firstChunk = true;
|
|
1053
|
-
proxyRes.on("data", (chunk: Buffer) => {
|
|
1054
|
-
this.send(
|
|
1055
|
-
createEnvelope({
|
|
1056
|
-
type: "tunnel.response",
|
|
1057
|
-
sessionId: this.sessionId,
|
|
1058
|
-
payload: {
|
|
1059
|
-
requestId,
|
|
1060
|
-
statusCode: proxyRes.statusCode ?? 200,
|
|
1061
|
-
headers: firstChunk ? resHeaders : {},
|
|
1062
|
-
body: chunk.toString("base64"),
|
|
1063
|
-
isFinal: false,
|
|
1064
|
-
},
|
|
1065
|
-
}),
|
|
1066
|
-
);
|
|
1067
|
-
firstChunk = false;
|
|
1068
|
-
});
|
|
1069
|
-
|
|
1070
|
-
proxyRes.on("end", () => {
|
|
1071
|
-
this.send(
|
|
1072
|
-
createEnvelope({
|
|
1073
|
-
type: "tunnel.response",
|
|
1074
|
-
sessionId: this.sessionId,
|
|
1075
|
-
payload: {
|
|
1076
|
-
requestId,
|
|
1077
|
-
statusCode: proxyRes.statusCode ?? 200,
|
|
1078
|
-
headers: firstChunk ? resHeaders : {},
|
|
1079
|
-
body: "",
|
|
1080
|
-
isFinal: true,
|
|
1081
|
-
},
|
|
1082
|
-
}),
|
|
1083
|
-
);
|
|
1084
|
-
});
|
|
1085
|
-
|
|
1086
|
-
proxyRes.on("error", () => {
|
|
1087
|
-
this.sendTunnelError(requestId, 502, "Upstream read error");
|
|
1088
|
-
});
|
|
1089
|
-
});
|
|
1090
|
-
|
|
1091
|
-
proxyReq.on("error", () => {
|
|
1092
|
-
this.sendTunnelError(requestId, 502, "Connection refused");
|
|
1093
|
-
});
|
|
1094
|
-
|
|
1095
|
-
proxyReq.setTimeout(30_000, () => {
|
|
1096
|
-
proxyReq.destroy();
|
|
1097
|
-
this.sendTunnelError(requestId, 504, "Upstream timeout");
|
|
1098
|
-
});
|
|
1099
|
-
|
|
1100
|
-
if (body) {
|
|
1101
|
-
proxyReq.write(Buffer.from(body, "base64"));
|
|
1102
|
-
}
|
|
1103
|
-
proxyReq.end();
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
private handleTunnelWsUpgrade(requestId: string, port: number, url: string): void {
|
|
1107
|
-
const wsUrl = `ws://127.0.0.1:${port}${url}`;
|
|
1108
|
-
const localWs = new WebSocket(wsUrl);
|
|
1109
|
-
|
|
1110
|
-
localWs.on("open", () => {
|
|
1111
|
-
this.tunnelSockets.set(requestId, localWs);
|
|
1112
|
-
});
|
|
1113
|
-
|
|
1114
|
-
localWs.on("message", (data: Buffer | string) => {
|
|
1115
|
-
const isBinary = typeof data !== "string";
|
|
1116
|
-
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
1117
|
-
this.send(
|
|
1118
|
-
createEnvelope({
|
|
1119
|
-
type: "tunnel.ws.data",
|
|
1120
|
-
sessionId: this.sessionId,
|
|
1121
|
-
payload: {
|
|
1122
|
-
requestId,
|
|
1123
|
-
data: buf.toString("base64"),
|
|
1124
|
-
isBinary,
|
|
1125
|
-
},
|
|
1126
|
-
}),
|
|
1127
|
-
);
|
|
1128
|
-
});
|
|
1129
|
-
|
|
1130
|
-
localWs.on("close", (code, reason) => {
|
|
1131
|
-
this.tunnelSockets.delete(requestId);
|
|
1132
|
-
const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
|
|
1133
|
-
this.send(
|
|
1134
|
-
createEnvelope({
|
|
1135
|
-
type: "tunnel.ws.close",
|
|
1136
|
-
sessionId: this.sessionId,
|
|
1137
|
-
payload: {
|
|
1138
|
-
requestId,
|
|
1139
|
-
code: safeCode,
|
|
1140
|
-
reason: reason?.toString() || "",
|
|
1141
|
-
},
|
|
1142
|
-
}),
|
|
1143
|
-
);
|
|
1144
|
-
});
|
|
1145
|
-
|
|
1146
|
-
localWs.on("error", () => {
|
|
1147
|
-
this.tunnelSockets.delete(requestId);
|
|
1148
|
-
this.send(
|
|
1149
|
-
createEnvelope({
|
|
1150
|
-
type: "tunnel.ws.close",
|
|
1151
|
-
sessionId: this.sessionId,
|
|
1152
|
-
payload: {
|
|
1153
|
-
requestId,
|
|
1154
|
-
code: 1001,
|
|
1155
|
-
reason: "Local WebSocket error",
|
|
1156
|
-
},
|
|
1157
|
-
}),
|
|
1158
|
-
);
|
|
1159
|
-
});
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
private handleTunnelWsData(payload: {
|
|
1163
|
-
requestId: string;
|
|
1164
|
-
data: string;
|
|
1165
|
-
isBinary: boolean;
|
|
1166
|
-
}): void {
|
|
1167
|
-
const ws = this.tunnelSockets.get(payload.requestId);
|
|
1168
|
-
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
1169
|
-
const buf = Buffer.from(payload.data, "base64");
|
|
1170
|
-
ws.send(payload.isBinary ? buf : buf.toString("utf8"));
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
private handleTunnelWsClose(payload: {
|
|
1174
|
-
requestId: string;
|
|
1175
|
-
code?: number;
|
|
1176
|
-
reason?: string;
|
|
1177
|
-
}): void {
|
|
1178
|
-
const ws = this.tunnelSockets.get(payload.requestId);
|
|
1179
|
-
if (!ws) return;
|
|
1180
|
-
const code = payload.code && payload.code >= 1000 && payload.code <= 4999 ? payload.code : 1000;
|
|
1181
|
-
ws.close(code, payload.reason ?? "");
|
|
1182
|
-
this.tunnelSockets.delete(payload.requestId);
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
private sendTunnelError(requestId: string, statusCode: number, message: string): void {
|
|
1186
|
-
this.send(
|
|
1187
|
-
createEnvelope({
|
|
1188
|
-
type: "tunnel.response",
|
|
1189
|
-
sessionId: this.sessionId,
|
|
1190
|
-
payload: {
|
|
1191
|
-
requestId,
|
|
1192
|
-
statusCode,
|
|
1193
|
-
headers: { "content-type": "text/plain" },
|
|
1194
|
-
body: Buffer.from(message).toString("base64"),
|
|
1195
|
-
isFinal: true,
|
|
1196
|
-
},
|
|
1197
|
-
}),
|
|
1198
|
-
);
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
private sendTerminalList(): void {
|
|
1202
|
-
const terminals = [...this.terminals.values()].map((t) => ({
|
|
1203
|
-
terminalId: t.id,
|
|
1204
|
-
cwd: t.cwd,
|
|
1205
|
-
projectName: t.projectName,
|
|
1206
|
-
provider: t.provider,
|
|
1207
|
-
status: t.status,
|
|
1208
|
-
}));
|
|
1209
|
-
this.send(createEnvelope({
|
|
1210
|
-
type: "terminal.list",
|
|
1211
|
-
sessionId: this.sessionId,
|
|
1212
|
-
payload: { terminals },
|
|
1213
|
-
}));
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
private replayFrom(terminalId: string, term: TerminalInstance, afterSeq: number): void {
|
|
1217
|
-
const messages = term.scrollback.replayFrom(afterSeq);
|
|
1218
|
-
for (const msg of messages) {
|
|
1219
|
-
const payload = msg.payload as {
|
|
1220
|
-
stream: string;
|
|
1221
|
-
data: string;
|
|
1222
|
-
encoding: string;
|
|
1223
|
-
isReplay: boolean;
|
|
1224
|
-
isFinal: boolean;
|
|
1225
|
-
};
|
|
1226
|
-
this.send(
|
|
1227
|
-
createEnvelope({
|
|
1228
|
-
type: "terminal.output",
|
|
1229
|
-
sessionId: this.sessionId,
|
|
1230
|
-
terminalId,
|
|
1231
|
-
seq: msg.seq,
|
|
1232
|
-
payload: { ...payload, isReplay: true },
|
|
1233
|
-
}),
|
|
1234
|
-
);
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
private async spawnTerminal(terminalId: string, cwd: string, providerOverride?: string): Promise<void> {
|
|
1239
|
-
const cleanEnv: Record<string, string> = {};
|
|
1240
|
-
for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
|
|
1241
|
-
if (v !== undefined) cleanEnv[k] = v;
|
|
1242
|
-
}
|
|
1243
|
-
|
|
1244
|
-
const provider = providerOverride ?? this.options.providerConfig.provider;
|
|
1245
|
-
const args = [...this.options.providerConfig.args];
|
|
1246
|
-
|
|
1247
|
-
const term: TerminalInstance = {
|
|
1248
|
-
id: terminalId,
|
|
1249
|
-
pty: pty.spawn(
|
|
1250
|
-
this.options.providerConfig.command,
|
|
1251
|
-
args,
|
|
1252
|
-
{
|
|
1253
|
-
name: "xterm-256color",
|
|
1254
|
-
cols: this.options.cols,
|
|
1255
|
-
rows: this.options.rows,
|
|
1256
|
-
cwd,
|
|
1257
|
-
env: cleanEnv,
|
|
1258
|
-
},
|
|
1259
|
-
),
|
|
1260
|
-
cwd,
|
|
1261
|
-
projectName: basename(cwd),
|
|
1262
|
-
provider,
|
|
1263
|
-
scrollback: new ScrollbackBuffer(SCROLLBACK_LINES),
|
|
1264
|
-
outputSeq: 0,
|
|
1265
|
-
statusSeq: 0,
|
|
1266
|
-
status: "running",
|
|
1267
|
-
};
|
|
1268
|
-
|
|
1269
|
-
term.pty.onData((data) => {
|
|
1270
|
-
const seq = term.outputSeq++;
|
|
1271
|
-
const envelope = createEnvelope({
|
|
1272
|
-
type: "terminal.output",
|
|
1273
|
-
sessionId: this.sessionId,
|
|
1274
|
-
terminalId,
|
|
1275
|
-
seq,
|
|
1276
|
-
payload: {
|
|
1277
|
-
stream: "stdout" as const,
|
|
1278
|
-
data,
|
|
1279
|
-
encoding: "utf8" as const,
|
|
1280
|
-
isReplay: false,
|
|
1281
|
-
isFinal: false,
|
|
1282
|
-
},
|
|
1283
|
-
});
|
|
1284
|
-
term.scrollback.push(envelope);
|
|
1285
|
-
this.send(envelope);
|
|
1286
|
-
});
|
|
1287
|
-
|
|
1288
|
-
term.pty.onExit(({ exitCode, signal }) => {
|
|
1289
|
-
term.status = "exited";
|
|
1290
|
-
this.send(createEnvelope({
|
|
1291
|
-
type: "terminal.exit",
|
|
1292
|
-
sessionId: this.sessionId,
|
|
1293
|
-
terminalId,
|
|
1294
|
-
payload: { exitCode, signal },
|
|
1295
|
-
}));
|
|
1296
|
-
this.sendTerminalList();
|
|
1297
|
-
|
|
1298
|
-
// Reap the exited terminal (and its scrollback) after a grace period so
|
|
1299
|
-
// clients can replay final output, then free the memory. Tracked so the
|
|
1300
|
-
// timer can be cleared on stop().
|
|
1301
|
-
const reapTimer = setTimeout(() => {
|
|
1302
|
-
this.exitedTerminalTimers.delete(terminalId);
|
|
1303
|
-
const t = this.terminals.get(terminalId);
|
|
1304
|
-
if (t && t.status === "exited") {
|
|
1305
|
-
this.terminals.delete(terminalId);
|
|
1306
|
-
this.log(`reaped exited terminal ${terminalId}`);
|
|
1307
|
-
}
|
|
1308
|
-
}, EXITED_TERMINAL_GRACE_MS);
|
|
1309
|
-
if (typeof reapTimer.unref === "function") reapTimer.unref();
|
|
1310
|
-
this.exitedTerminalTimers.set(terminalId, reapTimer);
|
|
1311
|
-
|
|
1312
|
-
// If all terminals exited, close the session
|
|
1313
|
-
const allExited = [...this.terminals.values()].every((t) => t.status === "exited");
|
|
1314
|
-
if (allExited) {
|
|
1315
|
-
this.exited = true;
|
|
1316
|
-
setTimeout(() => {
|
|
1317
|
-
this.stopHeartbeat();
|
|
1318
|
-
this.socket?.close();
|
|
1319
|
-
// Self-clean process-level resources (embedded gateway + PID file)
|
|
1320
|
-
// since the PTYs exited naturally rather than via a signal handler.
|
|
1321
|
-
void this.onAllTerminalsExited?.();
|
|
1322
|
-
}, 500);
|
|
1323
|
-
process.exitCode = exitCode ?? 0;
|
|
1324
|
-
}
|
|
1325
|
-
});
|
|
1326
|
-
|
|
1327
|
-
this.terminals.set(terminalId, term);
|
|
1328
|
-
this.log(`spawned terminal ${terminalId} in ${cwd}`);
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
private send(message: Envelope): void {
|
|
1333
|
-
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
1334
|
-
if (message.type.startsWith("agent.v2.")) {
|
|
1335
|
-
this.outboundAgentQueue.push(message);
|
|
1336
|
-
if (this.outboundAgentQueue.length > 40) {
|
|
1337
|
-
this.outboundAgentQueue.splice(0, this.outboundAgentQueue.length - 40);
|
|
1338
|
-
}
|
|
1339
|
-
}
|
|
1340
|
-
return;
|
|
1341
|
-
}
|
|
1342
|
-
const machineId = this.machineIdentity?.machineId;
|
|
1343
|
-
const enriched = machineId && (
|
|
1344
|
-
message.type === "terminal.status" ||
|
|
1345
|
-
message.type === "agent.capabilities" ||
|
|
1346
|
-
message.type === "agent.snapshot" ||
|
|
1347
|
-
message.type === "agent.v2.capabilities" ||
|
|
1348
|
-
message.type === "agent.v2.snapshot"
|
|
1349
|
-
)
|
|
1350
|
-
? {
|
|
1351
|
-
...message,
|
|
1352
|
-
payload: {
|
|
1353
|
-
...(message.payload as Record<string, unknown>),
|
|
1354
|
-
machineId,
|
|
1355
|
-
},
|
|
1356
|
-
}
|
|
1357
|
-
: message;
|
|
1358
|
-
this.socket.send(serializeEnvelope(enriched));
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
private startHeartbeat(): void {
|
|
1362
|
-
this.stopHeartbeat();
|
|
1363
|
-
this.heartbeatTimer = setInterval(() => {
|
|
1364
|
-
this.send(
|
|
1365
|
-
createEnvelope({
|
|
1366
|
-
type: "session.heartbeat",
|
|
1367
|
-
sessionId: this.sessionId,
|
|
1368
|
-
payload: { ts: Date.now() },
|
|
1369
|
-
}),
|
|
1370
|
-
);
|
|
1371
|
-
}, HEARTBEAT_INTERVAL);
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
private stopHeartbeat(): void {
|
|
1375
|
-
if (this.heartbeatTimer) {
|
|
1376
|
-
clearInterval(this.heartbeatTimer);
|
|
1377
|
-
this.heartbeatTimer = undefined;
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
// Transport-layer keepalive. WS ping/pong detects a half-open socket (network
|
|
1382
|
-
// dropped without a TCP FIN, e.g. laptop sleep / Wi-Fi loss) that would
|
|
1383
|
-
// otherwise hang silently. If the previous ping's pong never arrived, the
|
|
1384
|
-
// socket is dead → terminate it, which fires `close` → scheduleReconnect.
|
|
1385
|
-
private startWsPing(): void {
|
|
1386
|
-
this.stopWsPing();
|
|
1387
|
-
this.awaitingPong = false;
|
|
1388
|
-
this.wsPingTimer = setInterval(() => {
|
|
1389
|
-
const sock = this.socket;
|
|
1390
|
-
if (!sock || sock.readyState !== WebSocket.OPEN) return;
|
|
1391
|
-
if (this.awaitingPong) {
|
|
1392
|
-
// No pong since the last ping → half-open. Forcibly close so we reconnect.
|
|
1393
|
-
process.stderr.write("[bridge] no pong from gateway; terminating dead connection\n");
|
|
1394
|
-
this.awaitingPong = false;
|
|
1395
|
-
try {
|
|
1396
|
-
sock.terminate();
|
|
1397
|
-
} catch {
|
|
1398
|
-
// terminate may throw if already closing; close handler still runs.
|
|
1399
|
-
}
|
|
1400
|
-
return;
|
|
1401
|
-
}
|
|
1402
|
-
this.awaitingPong = true;
|
|
1403
|
-
try {
|
|
1404
|
-
sock.ping();
|
|
1405
|
-
} catch {
|
|
1406
|
-
// ping failed → let the next tick terminate, or close already fired.
|
|
1407
|
-
}
|
|
1408
|
-
}, WS_PING_INTERVAL);
|
|
1409
|
-
if (typeof this.wsPingTimer.unref === "function") this.wsPingTimer.unref();
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
private stopWsPing(): void {
|
|
1413
|
-
if (this.wsPingTimer) {
|
|
1414
|
-
clearInterval(this.wsPingTimer);
|
|
1415
|
-
this.wsPingTimer = undefined;
|
|
1416
|
-
}
|
|
1417
|
-
this.awaitingPong = false;
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
private startScreenCapture(fps: number, quality: number, scale: number): void {
|
|
1421
|
-
if (!this.options.screen) {
|
|
1422
|
-
this.log("screen sharing not enabled (use --screen)");
|
|
1423
|
-
this.send(
|
|
1424
|
-
createEnvelope({
|
|
1425
|
-
type: "screen.status",
|
|
1426
|
-
sessionId: this.sessionId,
|
|
1427
|
-
payload: { active: false, mode: "off" as const, error: "Screen sharing not enabled on host. Start CLI with --screen flag." },
|
|
1428
|
-
}),
|
|
1429
|
-
);
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
this.stopScreenCapture();
|
|
1433
|
-
this.log(`starting screen capture (fps=${fps}, quality=${quality}, scale=${scale})`);
|
|
1434
|
-
|
|
1435
|
-
// Try WebRTC first, fall back to screenshot stream
|
|
1436
|
-
if (ScreenShare.isAvailable()) {
|
|
1437
|
-
this.log("WebRTC available, starting screen share");
|
|
1438
|
-
this.screenShare = new ScreenShare({
|
|
1439
|
-
sessionId: this.sessionId,
|
|
1440
|
-
fps,
|
|
1441
|
-
quality,
|
|
1442
|
-
scale,
|
|
1443
|
-
onSignal: (envelope) => this.send(envelope),
|
|
1444
|
-
onStatus: (envelope) => this.send(envelope),
|
|
1445
|
-
});
|
|
1446
|
-
this.screenShare.start().catch((err) => {
|
|
1447
|
-
this.log(`WebRTC failed, falling back to screenshot stream: ${err}`);
|
|
1448
|
-
this.screenShare = undefined;
|
|
1449
|
-
this.startFallbackCapture(fps, quality, scale);
|
|
1450
|
-
});
|
|
1451
|
-
} else {
|
|
1452
|
-
this.log("WebRTC not available (missing werift or ffmpeg), using screenshot fallback");
|
|
1453
|
-
this.startFallbackCapture(fps, quality, scale);
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
private startFallbackCapture(fps: number, quality: number, scale: number): void {
|
|
1458
|
-
this.screenCapture = new ScreenFallback({
|
|
1459
|
-
fps,
|
|
1460
|
-
quality,
|
|
1461
|
-
scale,
|
|
1462
|
-
sessionId: this.sessionId,
|
|
1463
|
-
onFrame: (envelope) => this.send(envelope),
|
|
1464
|
-
onStatus: (envelope) => this.send(envelope),
|
|
1465
|
-
});
|
|
1466
|
-
this.screenCapture.start();
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
private stopScreenCapture(): void {
|
|
1470
|
-
if (this.screenShare) {
|
|
1471
|
-
this.log("stopping WebRTC screen share");
|
|
1472
|
-
this.screenShare.stop();
|
|
1473
|
-
this.screenShare = undefined;
|
|
1474
|
-
}
|
|
1475
|
-
if (this.screenCapture) {
|
|
1476
|
-
this.log("stopping screenshot capture");
|
|
1477
|
-
this.screenCapture.stop();
|
|
1478
|
-
this.screenCapture = undefined;
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
private scheduleReconnect(): void {
|
|
1483
|
-
if (this.reconnecting || this.stopped || this.exited) return;
|
|
1484
|
-
|
|
1485
|
-
this.reconnecting = true;
|
|
1486
|
-
// Exponential backoff capped at RECONNECT_MAX_DELAY, with full jitter so a
|
|
1487
|
-
// fleet of hosts doesn't stampede a restarting gateway. The attempt counter
|
|
1488
|
-
// only ever grows toward the cap — it is NEVER reset to "give up"; the host
|
|
1489
|
-
// keeps trying forever until stop()/exit.
|
|
1490
|
-
const exponent = Math.min(this.reconnectAttempts, RECONNECT_MAX_EXPONENT);
|
|
1491
|
-
const base = Math.min(RECONNECT_BASE_DELAY * 2 ** exponent, RECONNECT_MAX_DELAY);
|
|
1492
|
-
const delay = Math.round(base * (0.5 + Math.random() * 0.5));
|
|
1493
|
-
this.reconnectAttempts++;
|
|
1494
|
-
// Quiet logging: only the first retry of an outage and every 10th attempt,
|
|
1495
|
-
// so a multi-hour offline window doesn't flood the daemon log.
|
|
1496
|
-
if (this.reconnectAttempts === 1 || this.reconnectAttempts % 10 === 0) {
|
|
1497
|
-
process.stderr.write(
|
|
1498
|
-
`[bridge] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})\n`,
|
|
1499
|
-
);
|
|
1500
|
-
}
|
|
1501
|
-
this.reconnectTimer = setTimeout(() => {
|
|
1502
|
-
this.reconnectTimer = undefined;
|
|
1503
|
-
if (this.stopped || this.exited) {
|
|
1504
|
-
return;
|
|
1505
|
-
}
|
|
1506
|
-
this.reconnecting = false;
|
|
1507
|
-
void this.connectGateway();
|
|
1508
|
-
}, delay);
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
stop(exitCode = 0): void {
|
|
1512
|
-
if (this.stopped) {
|
|
1513
|
-
return;
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
this.stopped = true;
|
|
1517
|
-
this.exited = true;
|
|
1518
|
-
this.stopHeartbeat();
|
|
1519
|
-
this.stopWsPing();
|
|
1520
|
-
this.stopScreenCapture();
|
|
1521
|
-
this.agentSession?.stop();
|
|
1522
|
-
this.agentSession = undefined;
|
|
1523
|
-
this.agentWorkspace?.stop();
|
|
1524
|
-
this.agentWorkspace = undefined;
|
|
1525
|
-
this.keepAwake?.stop();
|
|
1526
|
-
this.keepAwake = undefined;
|
|
1527
|
-
if (this.reconnectTimer) {
|
|
1528
|
-
clearTimeout(this.reconnectTimer);
|
|
1529
|
-
this.reconnectTimer = undefined;
|
|
1530
|
-
}
|
|
1531
|
-
this.socket?.close();
|
|
1532
|
-
this.socket = undefined;
|
|
1533
|
-
// Clean up tunnel WebSockets
|
|
1534
|
-
for (const ws of this.tunnelSockets.values()) {
|
|
1535
|
-
ws.close(1001, "Session stopped");
|
|
1536
|
-
}
|
|
1537
|
-
this.tunnelSockets.clear();
|
|
1538
|
-
// Clear pending exited-terminal reap timers.
|
|
1539
|
-
for (const timer of this.exitedTerminalTimers.values()) {
|
|
1540
|
-
clearTimeout(timer);
|
|
1541
|
-
}
|
|
1542
|
-
this.exitedTerminalTimers.clear();
|
|
1543
|
-
for (const term of this.terminals.values()) {
|
|
1544
|
-
if (term.status === "running") term.pty.kill();
|
|
1545
|
-
}
|
|
1546
|
-
this.terminals.clear();
|
|
1547
|
-
process.exitCode = exitCode;
|
|
1548
|
-
}
|
|
1549
|
-
}
|