linkshell-cli 0.5.1 → 0.5.4
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 +23 -1
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/runtime/acp/acp-client.d.ts +16 -0
- package/dist/cli/src/runtime/acp/acp-client.js +81 -21
- package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/agent-workspace-git.d.ts +63 -0
- package/dist/cli/src/runtime/acp/agent-workspace-git.js +297 -0
- package/dist/cli/src/runtime/acp/agent-workspace-git.js.map +1 -0
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +14 -0
- package/dist/cli/src/runtime/acp/agent-workspace.js +845 -88
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +17 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.js +331 -19
- package/dist/cli/src/runtime/acp/claude-sdk-client.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 +87 -14
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
- package/dist/cli/src/runtime/acp/json-rpc.d.ts +1 -0
- package/dist/cli/src/runtime/acp/json-rpc.js +6 -0
- package/dist/cli/src/runtime/acp/json-rpc.js.map +1 -1
- package/dist/cli/src/runtime/acp-relay.d.ts +23 -0
- package/dist/cli/src/runtime/acp-relay.js +73 -0
- package/dist/cli/src/runtime/acp-relay.js.map +1 -0
- package/dist/cli/src/runtime/bridge-session.d.ts +37 -0
- package/dist/cli/src/runtime/bridge-session.js +318 -69
- 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 +5436 -3886
- package/dist/shared-protocol/src/index.js +127 -13
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +29 -1
- package/src/runtime/acp/acp-client.ts +96 -21
- package/src/runtime/acp/agent-workspace.ts +916 -83
- package/src/runtime/acp/claude-sdk-client.ts +352 -31
- package/src/runtime/acp/claude-stream-json-client.ts +89 -16
- package/src/runtime/acp/json-rpc.ts +5 -0
- package/src/runtime/bridge-session.ts +343 -75
- package/dist/cli/src/runtime/acp/claude-sessions.d.ts +0 -57
- package/dist/cli/src/runtime/acp/claude-sessions.js +0 -506
- package/dist/cli/src/runtime/acp/claude-sessions.js.map +0 -1
- package/dist/cli/src/runtime/acp/codex-rpc-bridge.d.ts +0 -34
- package/dist/cli/src/runtime/acp/codex-rpc-bridge.js +0 -204
- package/dist/cli/src/runtime/acp/codex-rpc-bridge.js.map +0 -1
- package/dist/cli/src/runtime/acp/codex-sessions.d.ts +0 -60
- package/dist/cli/src/runtime/acp/codex-sessions.js +0 -731
- package/dist/cli/src/runtime/acp/codex-sessions.js.map +0 -1
|
@@ -4,8 +4,8 @@ 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";
|
|
8
|
-
import { createEnvelope, parseEnvelope, parseTypedPayload, serializeEnvelope, PROTOCOL_VERSION, } from "@linkshell/protocol";
|
|
7
|
+
import { join, basename, resolve, relative, isAbsolute } from "node:path";
|
|
8
|
+
import { agentV2MessageRoute, createEnvelope, parseEnvelope, parseTypedPayload, serializeEnvelope, PROTOCOL_VERSION, } from "@linkshell/protocol";
|
|
9
9
|
import { ScrollbackBuffer } from "./scrollback.js";
|
|
10
10
|
import { ScreenFallback } from "./screen-fallback.js";
|
|
11
11
|
import { ScreenShare } from "./screen-share.js";
|
|
@@ -22,6 +22,16 @@ const RECONNECT_MAX_DELAY = 30_000;
|
|
|
22
22
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
23
23
|
const DEFAULT_TERMINAL_ID = "default";
|
|
24
24
|
const HOOK_BODY_LIMIT = 256 * 1024;
|
|
25
|
+
const SCROLLBACK_LINES = 500;
|
|
26
|
+
// Upper bound on concurrent live terminals — forceNew removed the cwd-dedup
|
|
27
|
+
// ceiling, so bound growth to avoid PTY/fd exhaustion from a runaway client.
|
|
28
|
+
const MAX_TERMINALS = 12;
|
|
29
|
+
// How long to keep an exited terminal (with its scrollback) in memory so the
|
|
30
|
+
// client can replay its final output before it is reaped.
|
|
31
|
+
const EXITED_TERMINAL_GRACE_MS = 30_000;
|
|
32
|
+
// Default ports the bridge will proxy tunnel requests to when no explicit
|
|
33
|
+
// allowlist is configured. Covers the common dev servers the app tunnels to.
|
|
34
|
+
const DEFAULT_TUNNEL_PORTS = [3000, 3001, 4321, 5173, 5174, 8080, 8000, 8081];
|
|
25
35
|
const PERMISSION_REQUEST_TIMEOUT_MS = Number(process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000);
|
|
26
36
|
const LINKSHELL_PERMISSION_GUARD_MARKER = "LINKSHELL_PERMISSION_GUARD";
|
|
27
37
|
function isLinkShellHookEntry(entry, marker) {
|
|
@@ -128,9 +138,21 @@ function hookPermissionOptions(suggestions) {
|
|
|
128
138
|
: []),
|
|
129
139
|
];
|
|
130
140
|
}
|
|
141
|
+
function stripGatewayBase(url) {
|
|
142
|
+
url.hash = "";
|
|
143
|
+
url.search = "";
|
|
144
|
+
url.pathname = "";
|
|
145
|
+
return url.toString().replace(/\/+$/, "");
|
|
146
|
+
}
|
|
147
|
+
function isHttpGatewayUrl(url) {
|
|
148
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
149
|
+
}
|
|
131
150
|
function getPairingGatewayParam(gatewayHttpUrl) {
|
|
132
151
|
try {
|
|
133
152
|
const url = new URL(gatewayHttpUrl);
|
|
153
|
+
if (!isHttpGatewayUrl(url)) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
134
156
|
const hostname = url.hostname.trim().toLowerCase();
|
|
135
157
|
if (hostname === "localhost" ||
|
|
136
158
|
hostname === "127.0.0.1" ||
|
|
@@ -142,38 +164,50 @@ function getPairingGatewayParam(gatewayHttpUrl) {
|
|
|
142
164
|
return undefined; // No LAN interface found, can't help
|
|
143
165
|
}
|
|
144
166
|
url.hostname = lanIp;
|
|
145
|
-
return url
|
|
167
|
+
return stripGatewayBase(url);
|
|
146
168
|
}
|
|
147
|
-
return url
|
|
169
|
+
return stripGatewayBase(url);
|
|
148
170
|
}
|
|
149
171
|
catch {
|
|
150
172
|
return gatewayHttpUrl.replace(/\/+$/, "") || undefined;
|
|
151
173
|
}
|
|
152
174
|
}
|
|
153
|
-
function resolvePairingGateway(gatewayHttpUrl, pairingGateway) {
|
|
175
|
+
export function resolvePairingGateway(gatewayHttpUrl, pairingGateway) {
|
|
154
176
|
const override = pairingGateway?.trim();
|
|
155
177
|
if (!override) {
|
|
156
178
|
return getPairingGatewayParam(gatewayHttpUrl);
|
|
157
179
|
}
|
|
158
180
|
try {
|
|
159
181
|
const absoluteUrl = new URL(override);
|
|
160
|
-
|
|
182
|
+
if (isHttpGatewayUrl(absoluteUrl)) {
|
|
183
|
+
return stripGatewayBase(absoluteUrl);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// Fall through and treat the override as a host[:port] value.
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const baseUrl = new URL(gatewayHttpUrl);
|
|
191
|
+
if (!isHttpGatewayUrl(baseUrl)) {
|
|
192
|
+
return override.replace(/\/+$/, "") || undefined;
|
|
193
|
+
}
|
|
194
|
+
const normalizedHost = override
|
|
195
|
+
.replace(/^https?:\/\//i, "")
|
|
196
|
+
.replace(/\/.*$/, "")
|
|
197
|
+
.trim();
|
|
198
|
+
if (!normalizedHost) {
|
|
199
|
+
return getPairingGatewayParam(gatewayHttpUrl);
|
|
200
|
+
}
|
|
201
|
+
baseUrl.host = normalizedHost;
|
|
202
|
+
return stripGatewayBase(baseUrl);
|
|
161
203
|
}
|
|
162
204
|
catch {
|
|
163
205
|
try {
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
.replace(/^https?:\/\//i, "")
|
|
167
|
-
.replace(/\/.*$/, "")
|
|
168
|
-
.trim();
|
|
169
|
-
if (!normalizedHost) {
|
|
170
|
-
return getPairingGatewayParam(gatewayHttpUrl);
|
|
171
|
-
}
|
|
172
|
-
baseUrl.hostname = normalizedHost;
|
|
173
|
-
return baseUrl.toString().replace(/\/+$/, "");
|
|
206
|
+
const prefixed = new URL(`http://${override.replace(/^\/+/, "")}`);
|
|
207
|
+
return stripGatewayBase(prefixed);
|
|
174
208
|
}
|
|
175
209
|
catch {
|
|
176
|
-
return
|
|
210
|
+
return undefined;
|
|
177
211
|
}
|
|
178
212
|
}
|
|
179
213
|
}
|
|
@@ -183,6 +217,25 @@ function normalizeAgentProvider(provider) {
|
|
|
183
217
|
}
|
|
184
218
|
return "codex";
|
|
185
219
|
}
|
|
220
|
+
export function resolveAgentWorkspaceProviders(options) {
|
|
221
|
+
if (options.agentCommand?.trim()) {
|
|
222
|
+
return [normalizeAgentProvider(options.agentProvider ?? "custom")];
|
|
223
|
+
}
|
|
224
|
+
const defaultProviders = ["codex", "claude"];
|
|
225
|
+
const detected = detectAvailableProviders();
|
|
226
|
+
const requested = options.agentProvider ? normalizeAgentProvider(options.agentProvider) : undefined;
|
|
227
|
+
const ordered = [
|
|
228
|
+
...(requested ? [requested] : []),
|
|
229
|
+
...detected,
|
|
230
|
+
...defaultProviders,
|
|
231
|
+
];
|
|
232
|
+
const unique = [];
|
|
233
|
+
for (const provider of ordered) {
|
|
234
|
+
if (!unique.includes(provider))
|
|
235
|
+
unique.push(provider);
|
|
236
|
+
}
|
|
237
|
+
return unique;
|
|
238
|
+
}
|
|
186
239
|
export class BridgeSession {
|
|
187
240
|
options;
|
|
188
241
|
socket;
|
|
@@ -192,6 +245,7 @@ export class BridgeSession {
|
|
|
192
245
|
reconnectAttempts = 0;
|
|
193
246
|
reconnecting = false;
|
|
194
247
|
sessionId = "";
|
|
248
|
+
hostToken;
|
|
195
249
|
exited = false;
|
|
196
250
|
stopped = false;
|
|
197
251
|
permissionStacks = new Map();
|
|
@@ -205,9 +259,48 @@ export class BridgeSession {
|
|
|
205
259
|
agentSession;
|
|
206
260
|
agentWorkspace;
|
|
207
261
|
machineIdentity;
|
|
262
|
+
// Confinement root for client-driven filesystem ops; resolved once at construction.
|
|
263
|
+
fileRoot;
|
|
264
|
+
// Ports the tunnel proxy may connect to on 127.0.0.1.
|
|
265
|
+
allowedTunnelPorts;
|
|
266
|
+
// Pending deletions of exited terminals (terminalId → timer) so we can clear on stop().
|
|
267
|
+
exitedTerminalTimers = new Map();
|
|
268
|
+
// Invoked when all terminals have exited naturally — closes embedded gateway,
|
|
269
|
+
// removes daemon PID file, etc. Set by the CLI entry point.
|
|
270
|
+
onAllTerminalsExited;
|
|
208
271
|
constructor(options) {
|
|
209
272
|
this.options = options;
|
|
210
273
|
this.sessionId = options.sessionId ?? "";
|
|
274
|
+
// Confine client-driven file ops to the user's home by default (single-user
|
|
275
|
+
// self-hosted model: the owner browses their own projects under ~). This
|
|
276
|
+
// still blocks /etc, /usr, other users' dirs. Override via --file-root.
|
|
277
|
+
this.fileRoot = resolve(options.fileRoot ?? homedir());
|
|
278
|
+
this.allowedTunnelPorts = new Set(options.allowedTunnelPorts && options.allowedTunnelPorts.length > 0
|
|
279
|
+
? options.allowedTunnelPorts
|
|
280
|
+
: DEFAULT_TUNNEL_PORTS);
|
|
281
|
+
}
|
|
282
|
+
/** Register a callback invoked once all terminals exit naturally (process self-cleanup). */
|
|
283
|
+
setOnAllTerminalsExited(handler) {
|
|
284
|
+
this.onAllTerminalsExited = handler;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Resolve a client-supplied path inside the confinement root.
|
|
288
|
+
* Returns the absolute resolved path, or null if it escapes the root.
|
|
289
|
+
*/
|
|
290
|
+
resolveConfinedPath(clientPath) {
|
|
291
|
+
const expanded = clientPath.startsWith("~")
|
|
292
|
+
? clientPath.replace(/^~/, homedir())
|
|
293
|
+
: clientPath;
|
|
294
|
+
// Resolve relative paths against the confinement root, not process.cwd().
|
|
295
|
+
const target = isAbsolute(expanded)
|
|
296
|
+
? resolve(expanded)
|
|
297
|
+
: resolve(this.fileRoot, expanded);
|
|
298
|
+
const rel = relative(this.fileRoot, target);
|
|
299
|
+
if (rel === "")
|
|
300
|
+
return target; // the root itself
|
|
301
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
302
|
+
return null;
|
|
303
|
+
return target;
|
|
211
304
|
}
|
|
212
305
|
log(msg) {
|
|
213
306
|
if (this.options.verbose) {
|
|
@@ -221,6 +314,9 @@ export class BridgeSession {
|
|
|
221
314
|
async start() {
|
|
222
315
|
this.log(`starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`);
|
|
223
316
|
this.machineIdentity = loadOrCreateMachineIdentity();
|
|
317
|
+
// Sweep stale LinkShell hook entries (dead curl ports) left by a prior crash
|
|
318
|
+
// before we write fresh ones for this session.
|
|
319
|
+
this.sweepStaleHookConfigs();
|
|
224
320
|
if (!this.sessionId) {
|
|
225
321
|
await this.createPairing();
|
|
226
322
|
}
|
|
@@ -232,9 +328,7 @@ export class BridgeSession {
|
|
|
232
328
|
}
|
|
233
329
|
if (this.options.agentUi) {
|
|
234
330
|
process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
|
|
235
|
-
const availableProviders = this.options
|
|
236
|
-
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
237
|
-
: detectAvailableProviders();
|
|
331
|
+
const availableProviders = resolveAgentWorkspaceProviders(this.options);
|
|
238
332
|
const agentOptions = {
|
|
239
333
|
sessionId: this.sessionId,
|
|
240
334
|
cwd: process.cwd(),
|
|
@@ -270,6 +364,10 @@ export class BridgeSession {
|
|
|
270
364
|
}
|
|
271
365
|
const body = (await res.json());
|
|
272
366
|
this.sessionId = body.sessionId;
|
|
367
|
+
// Secret token proving we are the legitimate host for this session; sent on
|
|
368
|
+
// every host WS connect so a third party who learns the sessionId can't
|
|
369
|
+
// hijack the host role.
|
|
370
|
+
this.hostToken = body.hostToken;
|
|
273
371
|
const pairingGateway = resolvePairingGateway(this.options.gatewayHttpUrl, this.options.pairingGateway);
|
|
274
372
|
const deepLink = pairingGateway
|
|
275
373
|
? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
|
|
@@ -328,10 +426,23 @@ export class BridgeSession {
|
|
|
328
426
|
url.searchParams.set("sessionId", this.sessionId);
|
|
329
427
|
url.searchParams.set("role", "host");
|
|
330
428
|
const authToken = await this.resolveAuthToken();
|
|
429
|
+
const wsOptions = {};
|
|
430
|
+
const headers = {};
|
|
331
431
|
if (authToken) {
|
|
432
|
+
// Prefer the Authorization header so the token doesn't leak into URLs/logs.
|
|
433
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
434
|
+
// TODO: remove the query param once all gateways read the Authorization
|
|
435
|
+
// header. Kept for backward compatibility with older gateways that only
|
|
436
|
+
// read the `auth_token` query parameter.
|
|
332
437
|
url.searchParams.set("auth_token", authToken);
|
|
333
438
|
}
|
|
334
|
-
this.
|
|
439
|
+
if (this.hostToken) {
|
|
440
|
+
headers["x-linkshell-host-token"] = this.hostToken;
|
|
441
|
+
}
|
|
442
|
+
if (Object.keys(headers).length > 0) {
|
|
443
|
+
wsOptions.headers = headers;
|
|
444
|
+
}
|
|
445
|
+
this.socket = new WebSocket(url, wsOptions);
|
|
335
446
|
this.socket.on("open", () => {
|
|
336
447
|
process.stderr.write(this.reconnectAttempts > 0
|
|
337
448
|
? "[bridge] gateway reconnected\n"
|
|
@@ -384,6 +495,49 @@ export class BridgeSession {
|
|
|
384
495
|
}
|
|
385
496
|
async handleMessage(envelope) {
|
|
386
497
|
const tid = envelope.terminalId ?? DEFAULT_TERMINAL_ID;
|
|
498
|
+
const agentV2Route = agentV2MessageRoute(envelope.type);
|
|
499
|
+
if (agentV2Route === "client_write" || agentV2Route === "client_read") {
|
|
500
|
+
if (!this.agentWorkspace) {
|
|
501
|
+
this.send(createEnvelope({
|
|
502
|
+
type: "agent.v2.capabilities",
|
|
503
|
+
sessionId: this.sessionId,
|
|
504
|
+
payload: {
|
|
505
|
+
enabled: false,
|
|
506
|
+
provider: normalizeAgentProvider(this.options.agentProvider ?? "codex"),
|
|
507
|
+
machineId: this.machineIdentity?.machineId,
|
|
508
|
+
workspaceProtocolVersion: 2,
|
|
509
|
+
error: "Agent Workspace is not enabled. Start CLI with --agent-ui.",
|
|
510
|
+
supportsSessionList: false,
|
|
511
|
+
supportsSessionLoad: false,
|
|
512
|
+
supportsImages: false,
|
|
513
|
+
supportsAudio: false,
|
|
514
|
+
supportsPermission: false,
|
|
515
|
+
supportsPlan: false,
|
|
516
|
+
supportsCancel: false,
|
|
517
|
+
},
|
|
518
|
+
}));
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute") {
|
|
522
|
+
this.refreshAgentPermissionHooks();
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
await this.agentWorkspace.handleEnvelope(envelope);
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
529
|
+
this.log(`agent.v2 invalid message: ${message}`);
|
|
530
|
+
this.send(createEnvelope({
|
|
531
|
+
type: "session.error",
|
|
532
|
+
sessionId: this.sessionId,
|
|
533
|
+
payload: {
|
|
534
|
+
code: "invalid_message",
|
|
535
|
+
message: `Invalid Agent Workspace message: ${message}`,
|
|
536
|
+
},
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
387
541
|
switch (envelope.type) {
|
|
388
542
|
case "terminal.input": {
|
|
389
543
|
const p = parseTypedPayload("terminal.input", envelope.payload);
|
|
@@ -402,8 +556,22 @@ export class BridgeSession {
|
|
|
402
556
|
case "terminal.spawn": {
|
|
403
557
|
const p = parseTypedPayload("terminal.spawn", envelope.payload);
|
|
404
558
|
const normalizedCwd = resolve(p.cwd);
|
|
405
|
-
//
|
|
406
|
-
|
|
559
|
+
// Cap concurrent terminals — forceNew removed the cwd-dedup ceiling, so
|
|
560
|
+
// bound growth explicitly to avoid PTY/fd exhaustion from a runaway client.
|
|
561
|
+
const liveCount = [...this.terminals.values()].filter((t) => t.status === "running").length;
|
|
562
|
+
if (liveCount >= MAX_TERMINALS) {
|
|
563
|
+
this.send(createEnvelope({
|
|
564
|
+
type: "session.error",
|
|
565
|
+
sessionId: this.sessionId,
|
|
566
|
+
payload: { code: "too_many_terminals", message: `最多同时打开 ${MAX_TERMINALS} 个终端` },
|
|
567
|
+
}));
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
// Dedup by cwd UNLESS the client explicitly asks for a fresh terminal
|
|
571
|
+
// (forceNew lets web open multiple tabs in the same directory).
|
|
572
|
+
const existing = p.forceNew
|
|
573
|
+
? undefined
|
|
574
|
+
: [...this.terminals.values()].find((t) => t.status === "running" && resolve(t.cwd) === normalizedCwd);
|
|
407
575
|
if (existing) {
|
|
408
576
|
this.send(createEnvelope({
|
|
409
577
|
type: "terminal.spawned",
|
|
@@ -446,9 +614,15 @@ export class BridgeSession {
|
|
|
446
614
|
}
|
|
447
615
|
case "terminal.browse": {
|
|
448
616
|
const p = parseTypedPayload("terminal.browse", envelope.payload);
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
617
|
+
const browsePath = this.resolveConfinedPath(p.path);
|
|
618
|
+
if (browsePath === null) {
|
|
619
|
+
this.send(createEnvelope({
|
|
620
|
+
type: "terminal.browse.result",
|
|
621
|
+
sessionId: this.sessionId,
|
|
622
|
+
payload: { path: p.path, entries: [], error: "Path is outside the allowed root", requestId: p.requestId },
|
|
623
|
+
}));
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
452
626
|
try {
|
|
453
627
|
const entries = readdirSync(browsePath, { withFileTypes: true })
|
|
454
628
|
.filter((d) => !d.name.startsWith(".") && (d.isDirectory() || (p.includeFiles && d.isFile())))
|
|
@@ -485,8 +659,22 @@ export class BridgeSession {
|
|
|
485
659
|
}
|
|
486
660
|
case "terminal.file.read": {
|
|
487
661
|
const p = parseTypedPayload("terminal.file.read", envelope.payload);
|
|
488
|
-
const
|
|
489
|
-
|
|
662
|
+
const filePath = this.resolveConfinedPath(p.path);
|
|
663
|
+
if (filePath === null) {
|
|
664
|
+
this.send(createEnvelope({
|
|
665
|
+
type: "terminal.file.read.result",
|
|
666
|
+
sessionId: this.sessionId,
|
|
667
|
+
payload: {
|
|
668
|
+
path: p.path,
|
|
669
|
+
content: "",
|
|
670
|
+
encoding: "utf8",
|
|
671
|
+
truncated: false,
|
|
672
|
+
error: "Path is outside the allowed root",
|
|
673
|
+
requestId: p.requestId,
|
|
674
|
+
},
|
|
675
|
+
}));
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
490
678
|
try {
|
|
491
679
|
const stats = statSync(filePath);
|
|
492
680
|
if (!stats.isFile()) {
|
|
@@ -536,8 +724,15 @@ export class BridgeSession {
|
|
|
536
724
|
}
|
|
537
725
|
case "terminal.mkdir": {
|
|
538
726
|
const p = parseTypedPayload("terminal.mkdir", envelope.payload);
|
|
539
|
-
const
|
|
540
|
-
|
|
727
|
+
const dirPath = this.resolveConfinedPath(p.path);
|
|
728
|
+
if (dirPath === null) {
|
|
729
|
+
this.send(createEnvelope({
|
|
730
|
+
type: "terminal.browse.result",
|
|
731
|
+
sessionId: this.sessionId,
|
|
732
|
+
payload: { path: p.path, entries: [], error: "Path is outside the allowed root" },
|
|
733
|
+
}));
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
541
736
|
try {
|
|
542
737
|
mkdirSync(dirPath, { recursive: true });
|
|
543
738
|
// Browse the parent to refresh the listing
|
|
@@ -707,41 +902,6 @@ export class BridgeSession {
|
|
|
707
902
|
await this.agentSession.handleEnvelope(envelope);
|
|
708
903
|
break;
|
|
709
904
|
}
|
|
710
|
-
case "agent.v2.capabilities.request":
|
|
711
|
-
case "agent.v2.conversation.open":
|
|
712
|
-
case "agent.v2.conversation.list":
|
|
713
|
-
case "agent.v2.prompt":
|
|
714
|
-
case "agent.v2.command.execute":
|
|
715
|
-
case "agent.v2.cancel":
|
|
716
|
-
case "agent.v2.permission.respond":
|
|
717
|
-
case "agent.v2.structured_input.respond":
|
|
718
|
-
case "agent.v2.snapshot.request": {
|
|
719
|
-
if (!this.agentWorkspace) {
|
|
720
|
-
this.send(createEnvelope({
|
|
721
|
-
type: "agent.v2.capabilities",
|
|
722
|
-
sessionId: this.sessionId,
|
|
723
|
-
payload: {
|
|
724
|
-
enabled: false,
|
|
725
|
-
provider: normalizeAgentProvider(this.options.agentProvider ?? "codex"),
|
|
726
|
-
machineId: this.machineIdentity?.machineId,
|
|
727
|
-
workspaceProtocolVersion: 2,
|
|
728
|
-
error: "Agent Workspace is not enabled. Start CLI with --agent-ui.",
|
|
729
|
-
supportsSessionList: false,
|
|
730
|
-
supportsSessionLoad: false,
|
|
731
|
-
supportsImages: false,
|
|
732
|
-
supportsAudio: false,
|
|
733
|
-
supportsPermission: false,
|
|
734
|
-
supportsPlan: false,
|
|
735
|
-
supportsCancel: false,
|
|
736
|
-
},
|
|
737
|
-
}));
|
|
738
|
-
break;
|
|
739
|
-
}
|
|
740
|
-
if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute")
|
|
741
|
-
this.refreshAgentPermissionHooks();
|
|
742
|
-
await this.agentWorkspace.handleEnvelope(envelope);
|
|
743
|
-
break;
|
|
744
|
-
}
|
|
745
905
|
case "file.upload": {
|
|
746
906
|
const p = parseTypedPayload("file.upload", envelope.payload);
|
|
747
907
|
const ext = p.filename.split(".").pop() || "png";
|
|
@@ -807,6 +967,21 @@ export class BridgeSession {
|
|
|
807
967
|
// ── Tunnel handlers ────────────────────────────────────────────────
|
|
808
968
|
handleTunnelRequest(payload) {
|
|
809
969
|
const { requestId, method, url: reqUrl, headers, body, port } = payload;
|
|
970
|
+
// SSRF guard: only proxy to ports in the allowlist (pinned to 127.0.0.1).
|
|
971
|
+
if (!this.allowedTunnelPorts.has(port)) {
|
|
972
|
+
this.log(`rejecting tunnel request to disallowed port ${port}`);
|
|
973
|
+
if (headers.upgrade === "websocket") {
|
|
974
|
+
this.send(createEnvelope({
|
|
975
|
+
type: "tunnel.ws.close",
|
|
976
|
+
sessionId: this.sessionId,
|
|
977
|
+
payload: { requestId, code: 1008, reason: `Port ${port} is not allowed` },
|
|
978
|
+
}));
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
this.sendTunnelError(requestId, 403, `Port ${port} is not allowed`);
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
810
985
|
// WebSocket upgrade request
|
|
811
986
|
if (headers.upgrade === "websocket") {
|
|
812
987
|
this.handleTunnelWsUpgrade(requestId, port, reqUrl);
|
|
@@ -1018,7 +1193,7 @@ export class BridgeSession {
|
|
|
1018
1193
|
cwd,
|
|
1019
1194
|
projectName: basename(cwd),
|
|
1020
1195
|
provider,
|
|
1021
|
-
scrollback: new ScrollbackBuffer(
|
|
1196
|
+
scrollback: new ScrollbackBuffer(SCROLLBACK_LINES),
|
|
1022
1197
|
outputSeq: 0,
|
|
1023
1198
|
statusSeq: 0,
|
|
1024
1199
|
status: "running",
|
|
@@ -1055,6 +1230,20 @@ export class BridgeSession {
|
|
|
1055
1230
|
payload: { exitCode, signal },
|
|
1056
1231
|
}));
|
|
1057
1232
|
this.sendTerminalList();
|
|
1233
|
+
// Reap the exited terminal (and its scrollback) after a grace period so
|
|
1234
|
+
// clients can replay final output, then free the memory. Tracked so the
|
|
1235
|
+
// timer can be cleared on stop().
|
|
1236
|
+
const reapTimer = setTimeout(() => {
|
|
1237
|
+
this.exitedTerminalTimers.delete(terminalId);
|
|
1238
|
+
const t = this.terminals.get(terminalId);
|
|
1239
|
+
if (t && t.status === "exited") {
|
|
1240
|
+
this.terminals.delete(terminalId);
|
|
1241
|
+
this.log(`reaped exited terminal ${terminalId}`);
|
|
1242
|
+
}
|
|
1243
|
+
}, EXITED_TERMINAL_GRACE_MS);
|
|
1244
|
+
if (typeof reapTimer.unref === "function")
|
|
1245
|
+
reapTimer.unref();
|
|
1246
|
+
this.exitedTerminalTimers.set(terminalId, reapTimer);
|
|
1058
1247
|
// If all terminals exited, close the session
|
|
1059
1248
|
const allExited = [...this.terminals.values()].every((t) => t.status === "exited");
|
|
1060
1249
|
if (allExited) {
|
|
@@ -1062,6 +1251,9 @@ export class BridgeSession {
|
|
|
1062
1251
|
setTimeout(() => {
|
|
1063
1252
|
this.stopHeartbeat();
|
|
1064
1253
|
this.socket?.close();
|
|
1254
|
+
// Self-clean process-level resources (embedded gateway + PID file)
|
|
1255
|
+
// since the PTYs exited naturally rather than via a signal handler.
|
|
1256
|
+
void this.onAllTerminalsExited?.();
|
|
1065
1257
|
}, 500);
|
|
1066
1258
|
process.exitCode = exitCode ?? 0;
|
|
1067
1259
|
}
|
|
@@ -1186,9 +1378,7 @@ export class BridgeSession {
|
|
|
1186
1378
|
return;
|
|
1187
1379
|
const marker = term.hookMarker;
|
|
1188
1380
|
const curlCmd = `curl -s --connect-timeout 1 --max-time ${Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000)} -X POST "http://127.0.0.1:${term.hookPort}/hook?m=${marker}&lid=$LINKSHELL_ID" -H 'Content-Type: application/json' --data-binary @- || true`;
|
|
1189
|
-
const providers = this.options
|
|
1190
|
-
? [normalizeAgentProvider(this.options.agentProvider)]
|
|
1191
|
-
: detectAvailableProviders();
|
|
1381
|
+
const providers = resolveAgentWorkspaceProviders(this.options);
|
|
1192
1382
|
try {
|
|
1193
1383
|
for (const provider of providers) {
|
|
1194
1384
|
if (provider === "codex") {
|
|
@@ -1751,6 +1941,60 @@ export class BridgeSession {
|
|
|
1751
1941
|
}
|
|
1752
1942
|
catch { /* ignore parse errors */ }
|
|
1753
1943
|
}
|
|
1944
|
+
/**
|
|
1945
|
+
* Remove ALL LinkShell hook entries (any marker) from a JSON config file.
|
|
1946
|
+
* Used on startup to sweep stale entries left behind by a crashed instance,
|
|
1947
|
+
* whose curl ports are now dead. Matches via isLinkShellHookEntry rather than
|
|
1948
|
+
* a single marker so entries from previous runs are also cleaned.
|
|
1949
|
+
*/
|
|
1950
|
+
sweepLinkShellHookEntries(configPath) {
|
|
1951
|
+
if (!existsSync(configPath))
|
|
1952
|
+
return;
|
|
1953
|
+
try {
|
|
1954
|
+
// Re-read immediately before write to minimize the window for a
|
|
1955
|
+
// concurrent writer to clobber our changes.
|
|
1956
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8"));
|
|
1957
|
+
const hooks = raw.hooks;
|
|
1958
|
+
if (!hooks)
|
|
1959
|
+
return;
|
|
1960
|
+
let changed = false;
|
|
1961
|
+
for (const [eventName, entries] of Object.entries(hooks)) {
|
|
1962
|
+
if (!Array.isArray(entries))
|
|
1963
|
+
continue;
|
|
1964
|
+
const filtered = entries.filter((entry) => !isLinkShellHookEntry(entry));
|
|
1965
|
+
if (filtered.length !== entries.length) {
|
|
1966
|
+
changed = true;
|
|
1967
|
+
if (filtered.length === 0) {
|
|
1968
|
+
delete hooks[eventName];
|
|
1969
|
+
}
|
|
1970
|
+
else {
|
|
1971
|
+
hooks[eventName] = filtered;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
if (changed) {
|
|
1976
|
+
if (Object.keys(hooks).length === 0) {
|
|
1977
|
+
delete raw.hooks;
|
|
1978
|
+
}
|
|
1979
|
+
writeFileSync(configPath, JSON.stringify(raw, null, 2));
|
|
1980
|
+
this.log(`swept stale LinkShell hook entries from ${configPath}`);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
catch { /* ignore parse errors */ }
|
|
1984
|
+
}
|
|
1985
|
+
/** Sweep stale LinkShell hook entries from known shared config files on startup. */
|
|
1986
|
+
sweepStaleHookConfigs() {
|
|
1987
|
+
const home = homedir();
|
|
1988
|
+
const candidates = [
|
|
1989
|
+
join(home, ".claude", "settings.json"),
|
|
1990
|
+
join(home, ".codex", "hooks.json"),
|
|
1991
|
+
join(home, ".gemini", "settings.json"),
|
|
1992
|
+
join(process.cwd(), "hooks.json"), // copilot (per-cwd)
|
|
1993
|
+
];
|
|
1994
|
+
for (const path of candidates) {
|
|
1995
|
+
this.sweepLinkShellHookEntries(path);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1754
1998
|
send(message) {
|
|
1755
1999
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
1756
2000
|
return;
|
|
@@ -1890,6 +2134,11 @@ export class BridgeSession {
|
|
|
1890
2134
|
ws.close(1001, "Session stopped");
|
|
1891
2135
|
}
|
|
1892
2136
|
this.tunnelSockets.clear();
|
|
2137
|
+
// Clear pending exited-terminal reap timers.
|
|
2138
|
+
for (const timer of this.exitedTerminalTimers.values()) {
|
|
2139
|
+
clearTimeout(timer);
|
|
2140
|
+
}
|
|
2141
|
+
this.exitedTerminalTimers.clear();
|
|
1893
2142
|
for (const term of this.terminals.values()) {
|
|
1894
2143
|
this.cleanupHookServer(term);
|
|
1895
2144
|
if (term.status === "running")
|