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.
Files changed (47) hide show
  1. package/dist/cli/src/index.js +23 -1
  2. package/dist/cli/src/index.js.map +1 -1
  3. package/dist/cli/src/runtime/acp/acp-client.d.ts +16 -0
  4. package/dist/cli/src/runtime/acp/acp-client.js +81 -21
  5. package/dist/cli/src/runtime/acp/acp-client.js.map +1 -1
  6. package/dist/cli/src/runtime/acp/agent-workspace-git.d.ts +63 -0
  7. package/dist/cli/src/runtime/acp/agent-workspace-git.js +297 -0
  8. package/dist/cli/src/runtime/acp/agent-workspace-git.js.map +1 -0
  9. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +14 -0
  10. package/dist/cli/src/runtime/acp/agent-workspace.js +845 -88
  11. package/dist/cli/src/runtime/acp/agent-workspace.js.map +1 -1
  12. package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +17 -1
  13. package/dist/cli/src/runtime/acp/claude-sdk-client.js +331 -19
  14. package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +1 -1
  15. package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +1 -0
  16. package/dist/cli/src/runtime/acp/claude-stream-json-client.js +87 -14
  17. package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +1 -1
  18. package/dist/cli/src/runtime/acp/json-rpc.d.ts +1 -0
  19. package/dist/cli/src/runtime/acp/json-rpc.js +6 -0
  20. package/dist/cli/src/runtime/acp/json-rpc.js.map +1 -1
  21. package/dist/cli/src/runtime/acp-relay.d.ts +23 -0
  22. package/dist/cli/src/runtime/acp-relay.js +73 -0
  23. package/dist/cli/src/runtime/acp-relay.js.map +1 -0
  24. package/dist/cli/src/runtime/bridge-session.d.ts +37 -0
  25. package/dist/cli/src/runtime/bridge-session.js +318 -69
  26. package/dist/cli/src/runtime/bridge-session.js.map +1 -1
  27. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  28. package/dist/shared-protocol/src/index.d.ts +5436 -3886
  29. package/dist/shared-protocol/src/index.js +127 -13
  30. package/dist/shared-protocol/src/index.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/index.ts +29 -1
  33. package/src/runtime/acp/acp-client.ts +96 -21
  34. package/src/runtime/acp/agent-workspace.ts +916 -83
  35. package/src/runtime/acp/claude-sdk-client.ts +352 -31
  36. package/src/runtime/acp/claude-stream-json-client.ts +89 -16
  37. package/src/runtime/acp/json-rpc.ts +5 -0
  38. package/src/runtime/bridge-session.ts +343 -75
  39. package/dist/cli/src/runtime/acp/claude-sessions.d.ts +0 -57
  40. package/dist/cli/src/runtime/acp/claude-sessions.js +0 -506
  41. package/dist/cli/src/runtime/acp/claude-sessions.js.map +0 -1
  42. package/dist/cli/src/runtime/acp/codex-rpc-bridge.d.ts +0 -34
  43. package/dist/cli/src/runtime/acp/codex-rpc-bridge.js +0 -204
  44. package/dist/cli/src/runtime/acp/codex-rpc-bridge.js.map +0 -1
  45. package/dist/cli/src/runtime/acp/codex-sessions.d.ts +0 -60
  46. package/dist/cli/src/runtime/acp/codex-sessions.js +0 -731
  47. 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.toString().replace(/\/+$/, "");
167
+ return stripGatewayBase(url);
146
168
  }
147
- return url.toString().replace(/\/+$/, "");
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
- return absoluteUrl.toString().replace(/\/+$/, "");
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 baseUrl = new URL(gatewayHttpUrl);
165
- const normalizedHost = override
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 override.replace(/\/+$/, "") || undefined;
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.agentProvider
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.socket = new WebSocket(url);
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
- // Dedup: if a running terminal already exists for this cwd, return it
406
- const existing = [...this.terminals.values()].find((t) => t.status === "running" && resolve(t.cwd) === normalizedCwd);
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
- // Expand ~ to home directory
450
- const rawPath = p.path.startsWith("~") ? p.path.replace(/^~/, homedir()) : p.path;
451
- const browsePath = resolve(rawPath);
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 rawPath = p.path.startsWith("~") ? p.path.replace(/^~/, homedir()) : p.path;
489
- const filePath = resolve(rawPath);
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 rawPath = p.path.startsWith("~") ? p.path.replace(/^~/, homedir()) : p.path;
540
- const dirPath = resolve(rawPath);
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(1000),
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.agentProvider
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")