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,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
|
|
266
|
+
return stripGatewayBase(url);
|
|
231
267
|
}
|
|
232
|
-
return url
|
|
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
|
-
|
|
285
|
+
if (isHttpGatewayUrl(absoluteUrl)) {
|
|
286
|
+
return stripGatewayBase(absoluteUrl);
|
|
287
|
+
}
|
|
250
288
|
} catch {
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
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
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
617
|
-
|
|
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
|
|
666
|
-
|
|
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(
|
|
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
|
|
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
|
-
};
|