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,9 @@ import WebSocket from "ws";
4
4
  import { hostname, platform, homedir } from "node:os";
5
5
  import { writeFileSync, readFileSync, readdirSync, statSync, unlinkSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
6
6
  import { tmpdir } from "node:os";
7
- import { join, basename, resolve } from "node:path";
7
+ import { join, basename, resolve, relative, isAbsolute } from "node:path";
8
8
  import {
9
+ agentV2MessageRoute,
9
10
  createEnvelope,
10
11
  parseEnvelope,
11
12
  parseTypedPayload,
@@ -42,6 +43,17 @@ export interface BridgeSessionOptions {
42
43
  agentUi?: boolean;
43
44
  agentProvider?: AgentProvider;
44
45
  agentCommand?: string;
46
+ /**
47
+ * Confinement root for client-driven filesystem operations
48
+ * (terminal.browse / terminal.file.read / terminal.mkdir).
49
+ * Defaults to the bridge's cwd. Paths resolving outside this root are rejected.
50
+ */
51
+ fileRoot?: string;
52
+ /**
53
+ * Ports the tunnel proxy is allowed to connect to on 127.0.0.1.
54
+ * Defaults to DEFAULT_TUNNEL_PORTS when omitted.
55
+ */
56
+ allowedTunnelPorts?: number[];
45
57
  }
46
58
 
47
59
  const HEARTBEAT_INTERVAL = 15_000;
@@ -50,6 +62,16 @@ const RECONNECT_MAX_DELAY = 30_000;
50
62
  const RECONNECT_MAX_ATTEMPTS = 20;
51
63
  const DEFAULT_TERMINAL_ID = "default";
52
64
  const HOOK_BODY_LIMIT = 256 * 1024;
65
+ const SCROLLBACK_LINES = 500;
66
+ // Upper bound on concurrent live terminals — forceNew removed the cwd-dedup
67
+ // ceiling, so bound growth to avoid PTY/fd exhaustion from a runaway client.
68
+ const MAX_TERMINALS = 12;
69
+ // How long to keep an exited terminal (with its scrollback) in memory so the
70
+ // client can replay its final output before it is reaped.
71
+ const EXITED_TERMINAL_GRACE_MS = 30_000;
72
+ // Default ports the bridge will proxy tunnel requests to when no explicit
73
+ // allowlist is configured. Covers the common dev servers the app tunnels to.
74
+ const DEFAULT_TUNNEL_PORTS = [3000, 3001, 4321, 5173, 5174, 8080, 8000, 8081];
53
75
  const PERMISSION_REQUEST_TIMEOUT_MS = Number(
54
76
  process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
55
77
  );
@@ -211,9 +233,23 @@ function hookPermissionOptions(suggestions: unknown[]): Array<{
211
233
  ];
212
234
  }
213
235
 
236
+ function stripGatewayBase(url: URL): string {
237
+ url.hash = "";
238
+ url.search = "";
239
+ url.pathname = "";
240
+ return url.toString().replace(/\/+$/, "");
241
+ }
242
+
243
+ function isHttpGatewayUrl(url: URL): boolean {
244
+ return url.protocol === "http:" || url.protocol === "https:";
245
+ }
246
+
214
247
  function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
215
248
  try {
216
249
  const url = new URL(gatewayHttpUrl);
250
+ if (!isHttpGatewayUrl(url)) {
251
+ return undefined;
252
+ }
217
253
  const hostname = url.hostname.trim().toLowerCase();
218
254
  if (
219
255
  hostname === "localhost" ||
@@ -227,15 +263,15 @@ function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
227
263
  return undefined; // No LAN interface found, can't help
228
264
  }
229
265
  url.hostname = lanIp;
230
- return url.toString().replace(/\/+$/, "");
266
+ return stripGatewayBase(url);
231
267
  }
232
- return url.toString().replace(/\/+$/, "");
268
+ return stripGatewayBase(url);
233
269
  } catch {
234
270
  return gatewayHttpUrl.replace(/\/+$/, "") || undefined;
235
271
  }
236
272
  }
237
273
 
238
- function resolvePairingGateway(
274
+ export function resolvePairingGateway(
239
275
  gatewayHttpUrl: string,
240
276
  pairingGateway?: string,
241
277
  ): string | undefined {
@@ -246,24 +282,36 @@ function resolvePairingGateway(
246
282
 
247
283
  try {
248
284
  const absoluteUrl = new URL(override);
249
- return absoluteUrl.toString().replace(/\/+$/, "");
285
+ if (isHttpGatewayUrl(absoluteUrl)) {
286
+ return stripGatewayBase(absoluteUrl);
287
+ }
250
288
  } catch {
251
- try {
252
- const baseUrl = new URL(gatewayHttpUrl);
253
- const normalizedHost = override
254
- .replace(/^https?:\/\//i, "")
255
- .replace(/\/.*$/, "")
256
- .trim();
257
-
258
- if (!normalizedHost) {
259
- return getPairingGatewayParam(gatewayHttpUrl);
260
- }
289
+ // Fall through and treat the override as a host[:port] value.
290
+ }
261
291
 
262
- baseUrl.hostname = normalizedHost;
263
- return baseUrl.toString().replace(/\/+$/, "");
264
- } catch {
292
+ try {
293
+ const baseUrl = new URL(gatewayHttpUrl);
294
+ if (!isHttpGatewayUrl(baseUrl)) {
265
295
  return override.replace(/\/+$/, "") || undefined;
266
296
  }
297
+ const normalizedHost = override
298
+ .replace(/^https?:\/\//i, "")
299
+ .replace(/\/.*$/, "")
300
+ .trim();
301
+
302
+ if (!normalizedHost) {
303
+ return getPairingGatewayParam(gatewayHttpUrl);
304
+ }
305
+
306
+ baseUrl.host = normalizedHost;
307
+ return stripGatewayBase(baseUrl);
308
+ } catch {
309
+ try {
310
+ const prefixed = new URL(`http://${override.replace(/^\/+/, "")}`);
311
+ return stripGatewayBase(prefixed);
312
+ } catch {
313
+ return undefined;
314
+ }
267
315
  }
268
316
  }
269
317
 
@@ -274,6 +322,28 @@ function normalizeAgentProvider(provider: unknown): AgentProvider {
274
322
  return "codex";
275
323
  }
276
324
 
325
+ export function resolveAgentWorkspaceProviders(options: {
326
+ agentProvider?: AgentProvider;
327
+ agentCommand?: string;
328
+ }): AgentProvider[] {
329
+ if (options.agentCommand?.trim()) {
330
+ return [normalizeAgentProvider(options.agentProvider ?? "custom")];
331
+ }
332
+ const defaultProviders: AgentProvider[] = ["codex", "claude"];
333
+ const detected = detectAvailableProviders();
334
+ const requested = options.agentProvider ? normalizeAgentProvider(options.agentProvider) : undefined;
335
+ const ordered = [
336
+ ...(requested ? [requested] : []),
337
+ ...detected,
338
+ ...defaultProviders,
339
+ ];
340
+ const unique: AgentProvider[] = [];
341
+ for (const provider of ordered) {
342
+ if (!unique.includes(provider)) unique.push(provider);
343
+ }
344
+ return unique;
345
+ }
346
+
277
347
  export class BridgeSession {
278
348
  private readonly options: BridgeSessionOptions;
279
349
  private socket: WebSocket | undefined;
@@ -283,6 +353,7 @@ export class BridgeSession {
283
353
  private reconnectAttempts = 0;
284
354
  private reconnecting = false;
285
355
  private sessionId = "";
356
+ private hostToken: string | undefined;
286
357
  private exited = false;
287
358
  private stopped = false;
288
359
  private permissionStacks = new Map<string, Array<{
@@ -302,10 +373,51 @@ export class BridgeSession {
302
373
  private agentSession: AgentSessionProxy | undefined;
303
374
  private agentWorkspace: AgentWorkspaceProxy | undefined;
304
375
  private machineIdentity: MachineIdentity | undefined;
376
+ // Confinement root for client-driven filesystem ops; resolved once at construction.
377
+ private readonly fileRoot: string;
378
+ // Ports the tunnel proxy may connect to on 127.0.0.1.
379
+ private readonly allowedTunnelPorts: Set<number>;
380
+ // Pending deletions of exited terminals (terminalId → timer) so we can clear on stop().
381
+ private exitedTerminalTimers = new Map<string, ReturnType<typeof setTimeout>>();
382
+ // Invoked when all terminals have exited naturally — closes embedded gateway,
383
+ // removes daemon PID file, etc. Set by the CLI entry point.
384
+ private onAllTerminalsExited: (() => void | Promise<void>) | undefined;
305
385
 
306
386
  constructor(options: BridgeSessionOptions) {
307
387
  this.options = options;
308
388
  this.sessionId = options.sessionId ?? "";
389
+ // Confine client-driven file ops to the user's home by default (single-user
390
+ // self-hosted model: the owner browses their own projects under ~). This
391
+ // still blocks /etc, /usr, other users' dirs. Override via --file-root.
392
+ this.fileRoot = resolve(options.fileRoot ?? homedir());
393
+ this.allowedTunnelPorts = new Set(
394
+ options.allowedTunnelPorts && options.allowedTunnelPorts.length > 0
395
+ ? options.allowedTunnelPorts
396
+ : DEFAULT_TUNNEL_PORTS,
397
+ );
398
+ }
399
+
400
+ /** Register a callback invoked once all terminals exit naturally (process self-cleanup). */
401
+ setOnAllTerminalsExited(handler: () => void | Promise<void>): void {
402
+ this.onAllTerminalsExited = handler;
403
+ }
404
+
405
+ /**
406
+ * Resolve a client-supplied path inside the confinement root.
407
+ * Returns the absolute resolved path, or null if it escapes the root.
408
+ */
409
+ private resolveConfinedPath(clientPath: string): string | null {
410
+ const expanded = clientPath.startsWith("~")
411
+ ? clientPath.replace(/^~/, homedir())
412
+ : clientPath;
413
+ // Resolve relative paths against the confinement root, not process.cwd().
414
+ const target = isAbsolute(expanded)
415
+ ? resolve(expanded)
416
+ : resolve(this.fileRoot, expanded);
417
+ const rel = relative(this.fileRoot, target);
418
+ if (rel === "") return target; // the root itself
419
+ if (rel.startsWith("..") || isAbsolute(rel)) return null;
420
+ return target;
309
421
  }
310
422
 
311
423
  private log(msg: string): void {
@@ -324,6 +436,9 @@ export class BridgeSession {
324
436
  `starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`,
325
437
  );
326
438
  this.machineIdentity = loadOrCreateMachineIdentity();
439
+ // Sweep stale LinkShell hook entries (dead curl ports) left by a prior crash
440
+ // before we write fresh ones for this session.
441
+ this.sweepStaleHookConfigs();
327
442
  if (!this.sessionId) {
328
443
  await this.createPairing();
329
444
  }
@@ -334,9 +449,7 @@ export class BridgeSession {
334
449
  }
335
450
  if (this.options.agentUi) {
336
451
  process.env.LINKSHELL_ID = this.terminalHookMarker(DEFAULT_TERMINAL_ID);
337
- const availableProviders = this.options.agentProvider
338
- ? [normalizeAgentProvider(this.options.agentProvider)]
339
- : detectAvailableProviders();
452
+ const availableProviders = resolveAgentWorkspaceProviders(this.options);
340
453
  const agentOptions = {
341
454
  sessionId: this.sessionId,
342
455
  cwd: process.cwd(),
@@ -374,9 +487,14 @@ export class BridgeSession {
374
487
  const body = (await res.json()) as {
375
488
  sessionId: string;
376
489
  pairingCode: string;
490
+ hostToken?: string;
377
491
  expiresAt: string;
378
492
  };
379
493
  this.sessionId = body.sessionId;
494
+ // Secret token proving we are the legitimate host for this session; sent on
495
+ // every host WS connect so a third party who learns the sessionId can't
496
+ // hijack the host role.
497
+ this.hostToken = body.hostToken;
380
498
 
381
499
  const pairingGateway = resolvePairingGateway(
382
500
  this.options.gatewayHttpUrl,
@@ -447,11 +565,24 @@ export class BridgeSession {
447
565
  url.searchParams.set("sessionId", this.sessionId);
448
566
  url.searchParams.set("role", "host");
449
567
  const authToken = await this.resolveAuthToken();
568
+ const wsOptions: WebSocket.ClientOptions = {};
569
+ const headers: Record<string, string> = {};
450
570
  if (authToken) {
571
+ // Prefer the Authorization header so the token doesn't leak into URLs/logs.
572
+ headers["Authorization"] = `Bearer ${authToken}`;
573
+ // TODO: remove the query param once all gateways read the Authorization
574
+ // header. Kept for backward compatibility with older gateways that only
575
+ // read the `auth_token` query parameter.
451
576
  url.searchParams.set("auth_token", authToken);
452
577
  }
578
+ if (this.hostToken) {
579
+ headers["x-linkshell-host-token"] = this.hostToken;
580
+ }
581
+ if (Object.keys(headers).length > 0) {
582
+ wsOptions.headers = headers;
583
+ }
453
584
 
454
- this.socket = new WebSocket(url);
585
+ this.socket = new WebSocket(url, wsOptions);
455
586
 
456
587
  this.socket.on("open", () => {
457
588
  process.stderr.write(
@@ -516,6 +647,52 @@ export class BridgeSession {
516
647
 
517
648
  private async handleMessage(envelope: Envelope): Promise<void> {
518
649
  const tid = envelope.terminalId ?? DEFAULT_TERMINAL_ID;
650
+ const agentV2Route = agentV2MessageRoute(envelope.type);
651
+ if (agentV2Route === "client_write" || agentV2Route === "client_read") {
652
+ if (!this.agentWorkspace) {
653
+ this.send(
654
+ createEnvelope({
655
+ type: "agent.v2.capabilities",
656
+ sessionId: this.sessionId,
657
+ payload: {
658
+ enabled: false,
659
+ provider: normalizeAgentProvider(
660
+ this.options.agentProvider ?? "codex",
661
+ ),
662
+ machineId: this.machineIdentity?.machineId,
663
+ workspaceProtocolVersion: 2,
664
+ error: "Agent Workspace is not enabled. Start CLI with --agent-ui.",
665
+ supportsSessionList: false,
666
+ supportsSessionLoad: false,
667
+ supportsImages: false,
668
+ supportsAudio: false,
669
+ supportsPermission: false,
670
+ supportsPlan: false,
671
+ supportsCancel: false,
672
+ },
673
+ }),
674
+ );
675
+ return;
676
+ }
677
+ if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute") {
678
+ this.refreshAgentPermissionHooks();
679
+ }
680
+ try {
681
+ await this.agentWorkspace.handleEnvelope(envelope);
682
+ } catch (error) {
683
+ const message = error instanceof Error ? error.message : String(error);
684
+ this.log(`agent.v2 invalid message: ${message}`);
685
+ this.send(createEnvelope({
686
+ type: "session.error",
687
+ sessionId: this.sessionId,
688
+ payload: {
689
+ code: "invalid_message",
690
+ message: `Invalid Agent Workspace message: ${message}`,
691
+ },
692
+ }));
693
+ }
694
+ return;
695
+ }
519
696
  switch (envelope.type) {
520
697
  case "terminal.input": {
521
698
  const p = parseTypedPayload("terminal.input", envelope.payload);
@@ -532,10 +709,24 @@ export class BridgeSession {
532
709
  case "terminal.spawn": {
533
710
  const p = parseTypedPayload("terminal.spawn", envelope.payload);
534
711
  const normalizedCwd = resolve(p.cwd);
535
- // Dedup: if a running terminal already exists for this cwd, return it
536
- const existing = [...this.terminals.values()].find(
537
- (t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
538
- );
712
+ // Cap concurrent terminals — forceNew removed the cwd-dedup ceiling, so
713
+ // bound growth explicitly to avoid PTY/fd exhaustion from a runaway client.
714
+ const liveCount = [...this.terminals.values()].filter((t) => t.status === "running").length;
715
+ if (liveCount >= MAX_TERMINALS) {
716
+ this.send(createEnvelope({
717
+ type: "session.error",
718
+ sessionId: this.sessionId,
719
+ payload: { code: "too_many_terminals", message: `最多同时打开 ${MAX_TERMINALS} 个终端` },
720
+ }));
721
+ break;
722
+ }
723
+ // Dedup by cwd UNLESS the client explicitly asks for a fresh terminal
724
+ // (forceNew lets web open multiple tabs in the same directory).
725
+ const existing = p.forceNew
726
+ ? undefined
727
+ : [...this.terminals.values()].find(
728
+ (t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
729
+ );
539
730
  if (existing) {
540
731
  this.send(createEnvelope({
541
732
  type: "terminal.spawned",
@@ -576,9 +767,15 @@ export class BridgeSession {
576
767
  }
577
768
  case "terminal.browse": {
578
769
  const p = parseTypedPayload("terminal.browse", envelope.payload);
579
- // Expand ~ to home directory
580
- const rawPath = p.path.startsWith("~") ? p.path.replace(/^~/, homedir()) : p.path;
581
- const browsePath = resolve(rawPath);
770
+ const browsePath = this.resolveConfinedPath(p.path);
771
+ if (browsePath === null) {
772
+ this.send(createEnvelope({
773
+ type: "terminal.browse.result",
774
+ sessionId: this.sessionId,
775
+ payload: { path: p.path, entries: [], error: "Path is outside the allowed root", requestId: p.requestId },
776
+ }));
777
+ break;
778
+ }
582
779
  try {
583
780
  const entries = readdirSync(browsePath, { withFileTypes: true })
584
781
  .filter((d) => !d.name.startsWith(".") && (d.isDirectory() || (p.includeFiles && d.isFile())))
@@ -613,8 +810,22 @@ export class BridgeSession {
613
810
  }
614
811
  case "terminal.file.read": {
615
812
  const p = parseTypedPayload("terminal.file.read", envelope.payload);
616
- const rawPath = p.path.startsWith("~") ? p.path.replace(/^~/, homedir()) : p.path;
617
- const filePath = resolve(rawPath);
813
+ const filePath = this.resolveConfinedPath(p.path);
814
+ if (filePath === null) {
815
+ this.send(createEnvelope({
816
+ type: "terminal.file.read.result",
817
+ sessionId: this.sessionId,
818
+ payload: {
819
+ path: p.path,
820
+ content: "",
821
+ encoding: "utf8",
822
+ truncated: false,
823
+ error: "Path is outside the allowed root",
824
+ requestId: p.requestId,
825
+ },
826
+ }));
827
+ break;
828
+ }
618
829
  try {
619
830
  const stats = statSync(filePath);
620
831
  if (!stats.isFile()) {
@@ -662,8 +873,15 @@ export class BridgeSession {
662
873
  }
663
874
  case "terminal.mkdir": {
664
875
  const p = parseTypedPayload("terminal.mkdir", envelope.payload);
665
- const rawPath = p.path.startsWith("~") ? p.path.replace(/^~/, homedir()) : p.path;
666
- const dirPath = resolve(rawPath);
876
+ const dirPath = this.resolveConfinedPath(p.path);
877
+ if (dirPath === null) {
878
+ this.send(createEnvelope({
879
+ type: "terminal.browse.result",
880
+ sessionId: this.sessionId,
881
+ payload: { path: p.path, entries: [], error: "Path is outside the allowed root" },
882
+ }));
883
+ break;
884
+ }
667
885
  try {
668
886
  mkdirSync(dirPath, { recursive: true });
669
887
  // Browse the parent to refresh the listing
@@ -842,44 +1060,6 @@ export class BridgeSession {
842
1060
  await this.agentSession.handleEnvelope(envelope);
843
1061
  break;
844
1062
  }
845
- case "agent.v2.capabilities.request":
846
- case "agent.v2.conversation.open":
847
- case "agent.v2.conversation.list":
848
- case "agent.v2.prompt":
849
- case "agent.v2.command.execute":
850
- case "agent.v2.cancel":
851
- case "agent.v2.permission.respond":
852
- case "agent.v2.structured_input.respond":
853
- case "agent.v2.snapshot.request": {
854
- if (!this.agentWorkspace) {
855
- this.send(
856
- createEnvelope({
857
- type: "agent.v2.capabilities",
858
- sessionId: this.sessionId,
859
- payload: {
860
- enabled: false,
861
- provider: normalizeAgentProvider(
862
- this.options.agentProvider ?? "codex",
863
- ),
864
- machineId: this.machineIdentity?.machineId,
865
- workspaceProtocolVersion: 2,
866
- error: "Agent Workspace is not enabled. Start CLI with --agent-ui.",
867
- supportsSessionList: false,
868
- supportsSessionLoad: false,
869
- supportsImages: false,
870
- supportsAudio: false,
871
- supportsPermission: false,
872
- supportsPlan: false,
873
- supportsCancel: false,
874
- },
875
- }),
876
- );
877
- break;
878
- }
879
- if (envelope.type === "agent.v2.prompt" || envelope.type === "agent.v2.command.execute") this.refreshAgentPermissionHooks();
880
- await this.agentWorkspace.handleEnvelope(envelope);
881
- break;
882
- }
883
1063
  case "file.upload": {
884
1064
  const p = parseTypedPayload("file.upload", envelope.payload);
885
1065
  const ext = p.filename.split(".").pop() || "png";
@@ -962,6 +1142,21 @@ export class BridgeSession {
962
1142
  }): void {
963
1143
  const { requestId, method, url: reqUrl, headers, body, port } = payload;
964
1144
 
1145
+ // SSRF guard: only proxy to ports in the allowlist (pinned to 127.0.0.1).
1146
+ if (!this.allowedTunnelPorts.has(port)) {
1147
+ this.log(`rejecting tunnel request to disallowed port ${port}`);
1148
+ if (headers.upgrade === "websocket") {
1149
+ this.send(createEnvelope({
1150
+ type: "tunnel.ws.close",
1151
+ sessionId: this.sessionId,
1152
+ payload: { requestId, code: 1008, reason: `Port ${port} is not allowed` },
1153
+ }));
1154
+ } else {
1155
+ this.sendTunnelError(requestId, 403, `Port ${port} is not allowed`);
1156
+ }
1157
+ return;
1158
+ }
1159
+
965
1160
  // WebSocket upgrade request
966
1161
  if (headers.upgrade === "websocket") {
967
1162
  this.handleTunnelWsUpgrade(requestId, port, reqUrl);
@@ -1223,7 +1418,7 @@ export class BridgeSession {
1223
1418
  cwd,
1224
1419
  projectName: basename(cwd),
1225
1420
  provider,
1226
- scrollback: new ScrollbackBuffer(1000),
1421
+ scrollback: new ScrollbackBuffer(SCROLLBACK_LINES),
1227
1422
  outputSeq: 0,
1228
1423
  statusSeq: 0,
1229
1424
  status: "running",
@@ -1263,6 +1458,20 @@ export class BridgeSession {
1263
1458
  }));
1264
1459
  this.sendTerminalList();
1265
1460
 
1461
+ // Reap the exited terminal (and its scrollback) after a grace period so
1462
+ // clients can replay final output, then free the memory. Tracked so the
1463
+ // timer can be cleared on stop().
1464
+ const reapTimer = setTimeout(() => {
1465
+ this.exitedTerminalTimers.delete(terminalId);
1466
+ const t = this.terminals.get(terminalId);
1467
+ if (t && t.status === "exited") {
1468
+ this.terminals.delete(terminalId);
1469
+ this.log(`reaped exited terminal ${terminalId}`);
1470
+ }
1471
+ }, EXITED_TERMINAL_GRACE_MS);
1472
+ if (typeof reapTimer.unref === "function") reapTimer.unref();
1473
+ this.exitedTerminalTimers.set(terminalId, reapTimer);
1474
+
1266
1475
  // If all terminals exited, close the session
1267
1476
  const allExited = [...this.terminals.values()].every((t) => t.status === "exited");
1268
1477
  if (allExited) {
@@ -1270,6 +1479,9 @@ export class BridgeSession {
1270
1479
  setTimeout(() => {
1271
1480
  this.stopHeartbeat();
1272
1481
  this.socket?.close();
1482
+ // Self-clean process-level resources (embedded gateway + PID file)
1483
+ // since the PTYs exited naturally rather than via a signal handler.
1484
+ void this.onAllTerminalsExited?.();
1273
1485
  }, 500);
1274
1486
  process.exitCode = exitCode ?? 0;
1275
1487
  }
@@ -1397,9 +1609,7 @@ export class BridgeSession {
1397
1609
  if (!term?.hookPort) return;
1398
1610
  const marker = term.hookMarker;
1399
1611
  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`;
1400
- const providers = this.options.agentProvider
1401
- ? [normalizeAgentProvider(this.options.agentProvider)]
1402
- : detectAvailableProviders();
1612
+ const providers = resolveAgentWorkspaceProviders(this.options);
1403
1613
  try {
1404
1614
  for (const provider of providers) {
1405
1615
  if (provider === "codex") {
@@ -1993,6 +2203,59 @@ export class BridgeSession {
1993
2203
  } catch { /* ignore parse errors */ }
1994
2204
  }
1995
2205
 
2206
+ /**
2207
+ * Remove ALL LinkShell hook entries (any marker) from a JSON config file.
2208
+ * Used on startup to sweep stale entries left behind by a crashed instance,
2209
+ * whose curl ports are now dead. Matches via isLinkShellHookEntry rather than
2210
+ * a single marker so entries from previous runs are also cleaned.
2211
+ */
2212
+ private sweepLinkShellHookEntries(configPath: string): void {
2213
+ if (!existsSync(configPath)) return;
2214
+ try {
2215
+ // Re-read immediately before write to minimize the window for a
2216
+ // concurrent writer to clobber our changes.
2217
+ const raw = JSON.parse(readFileSync(configPath, "utf8"));
2218
+ const hooks = raw.hooks as Record<string, unknown[]> | undefined;
2219
+ if (!hooks) return;
2220
+
2221
+ let changed = false;
2222
+ for (const [eventName, entries] of Object.entries(hooks)) {
2223
+ if (!Array.isArray(entries)) continue;
2224
+ const filtered = entries.filter((entry) => !isLinkShellHookEntry(entry));
2225
+ if (filtered.length !== entries.length) {
2226
+ changed = true;
2227
+ if (filtered.length === 0) {
2228
+ delete hooks[eventName];
2229
+ } else {
2230
+ hooks[eventName] = filtered;
2231
+ }
2232
+ }
2233
+ }
2234
+
2235
+ if (changed) {
2236
+ if (Object.keys(hooks).length === 0) {
2237
+ delete raw.hooks;
2238
+ }
2239
+ writeFileSync(configPath, JSON.stringify(raw, null, 2));
2240
+ this.log(`swept stale LinkShell hook entries from ${configPath}`);
2241
+ }
2242
+ } catch { /* ignore parse errors */ }
2243
+ }
2244
+
2245
+ /** Sweep stale LinkShell hook entries from known shared config files on startup. */
2246
+ private sweepStaleHookConfigs(): void {
2247
+ const home = homedir();
2248
+ const candidates = [
2249
+ join(home, ".claude", "settings.json"),
2250
+ join(home, ".codex", "hooks.json"),
2251
+ join(home, ".gemini", "settings.json"),
2252
+ join(process.cwd(), "hooks.json"), // copilot (per-cwd)
2253
+ ];
2254
+ for (const path of candidates) {
2255
+ this.sweepLinkShellHookEntries(path);
2256
+ }
2257
+ }
2258
+
1996
2259
  private send(message: Envelope): void {
1997
2260
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
1998
2261
  return;
@@ -2154,6 +2417,11 @@ export class BridgeSession {
2154
2417
  ws.close(1001, "Session stopped");
2155
2418
  }
2156
2419
  this.tunnelSockets.clear();
2420
+ // Clear pending exited-terminal reap timers.
2421
+ for (const timer of this.exitedTerminalTimers.values()) {
2422
+ clearTimeout(timer);
2423
+ }
2424
+ this.exitedTerminalTimers.clear();
2157
2425
  for (const term of this.terminals.values()) {
2158
2426
  this.cleanupHookServer(term);
2159
2427
  if (term.status === "running") term.pty.kill();
@@ -1,57 +0,0 @@
1
- export interface ClaudeStoredSession {
2
- id: string;
3
- cwd: string;
4
- title?: string;
5
- createdAt?: number;
6
- lastModified: number;
7
- }
8
- export interface StoredAgentTimelineItem {
9
- id: string;
10
- conversationId: string;
11
- type: "message" | "tool_call";
12
- kind?: "tool_activity" | "command_execution" | "file_change";
13
- itemId?: string;
14
- role?: "user" | "assistant" | "system";
15
- content?: Array<{
16
- type: "text";
17
- text: string;
18
- }>;
19
- text?: string;
20
- toolCall?: {
21
- id: string;
22
- name: string;
23
- input?: string;
24
- output?: string;
25
- createdAt?: number;
26
- status: "pending" | "running" | "completed" | "failed";
27
- };
28
- commandExecution?: {
29
- command?: string;
30
- cwd?: string;
31
- output?: string;
32
- exitCode?: number | null;
33
- status?: "pending" | "running" | "completed" | "failed";
34
- };
35
- fileChange?: {
36
- entries: Array<{
37
- path: string;
38
- kind?: string;
39
- added?: number;
40
- removed?: number;
41
- }>;
42
- diff?: string;
43
- summary?: string;
44
- status?: "pending" | "running" | "completed" | "failed";
45
- };
46
- createdAt: number;
47
- updatedAt?: number;
48
- metadata?: Record<string, unknown>;
49
- }
50
- export declare function listClaudeStoredSessions(inputCwd: string): {
51
- sessions: ClaudeStoredSession[];
52
- };
53
- export declare function loadClaudeStoredTimeline(sessionId: string, conversationId: string, options?: {
54
- maxItems?: number;
55
- }): {
56
- items: StoredAgentTimelineItem[];
57
- };