linkshell-cli 0.2.63 → 0.2.65
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/runtime/bridge-session.d.ts +2 -0
- package/dist/cli/src/runtime/bridge-session.js +107 -44
- 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 +56 -50
- package/dist/shared-protocol/src/index.js +4 -1
- package/dist/shared-protocol/src/index.js.map +1 -1
- package/package.json +3 -3
- package/src/runtime/bridge-session.ts +124 -45
|
@@ -39,6 +39,10 @@ const RECONNECT_BASE_DELAY = 1_000;
|
|
|
39
39
|
const RECONNECT_MAX_DELAY = 30_000;
|
|
40
40
|
const RECONNECT_MAX_ATTEMPTS = 20;
|
|
41
41
|
const DEFAULT_TERMINAL_ID = "default";
|
|
42
|
+
const HOOK_BODY_LIMIT = 256 * 1024;
|
|
43
|
+
const PERMISSION_REQUEST_TIMEOUT_MS = Number(
|
|
44
|
+
process.env.LINKSHELL_PERMISSION_TIMEOUT_MS ?? 5 * 60_000,
|
|
45
|
+
);
|
|
42
46
|
|
|
43
47
|
interface TerminalInstance {
|
|
44
48
|
id: string;
|
|
@@ -55,6 +59,17 @@ interface TerminalInstance {
|
|
|
55
59
|
hookConfigPaths: string[];
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
interface PendingPermission {
|
|
63
|
+
terminalId: string;
|
|
64
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
65
|
+
resolve: (decision: "allow" | "deny") => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isLinkShellHookEntry(entry: unknown): boolean {
|
|
69
|
+
const raw = JSON.stringify(entry);
|
|
70
|
+
return /\/hook\?m=lsh-[^"'\s]+/.test(raw);
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
|
|
59
74
|
try {
|
|
60
75
|
const url = new URL(gatewayHttpUrl);
|
|
@@ -130,7 +145,7 @@ export class BridgeSession {
|
|
|
130
145
|
timestamp: number;
|
|
131
146
|
}>>();
|
|
132
147
|
// Pending permission responses: requestId → HTTP response callback
|
|
133
|
-
private pendingPermissions = new Map<string,
|
|
148
|
+
private pendingPermissions = new Map<string, PendingPermission>();
|
|
134
149
|
private hookMarker = `lsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
135
150
|
private screenCapture: ScreenFallback | undefined;
|
|
136
151
|
private screenShare: ScreenShare | undefined;
|
|
@@ -261,7 +276,13 @@ export class BridgeSession {
|
|
|
261
276
|
});
|
|
262
277
|
|
|
263
278
|
this.socket.on("message", (data) => {
|
|
264
|
-
|
|
279
|
+
let envelope: Envelope;
|
|
280
|
+
try {
|
|
281
|
+
envelope = parseEnvelope(data.toString());
|
|
282
|
+
} catch (err) {
|
|
283
|
+
this.log(`invalid gateway message ignored: ${err}`);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
265
286
|
this.log(
|
|
266
287
|
`recv ${envelope.type}${envelope.seq !== undefined ? ` seq=${envelope.seq}` : ""}`,
|
|
267
288
|
);
|
|
@@ -457,7 +478,11 @@ export class BridgeSession {
|
|
|
457
478
|
const p = parseTypedPayload("session.resume", envelope.payload);
|
|
458
479
|
// Replay all terminals
|
|
459
480
|
for (const [termId, term] of this.terminals) {
|
|
460
|
-
this.replayFrom(
|
|
481
|
+
this.replayFrom(
|
|
482
|
+
termId,
|
|
483
|
+
term,
|
|
484
|
+
p.lastAckedSeqByTerminal[termId] ?? p.lastAckedSeq,
|
|
485
|
+
);
|
|
461
486
|
}
|
|
462
487
|
// Also send terminal list so client knows what's available
|
|
463
488
|
this.sendTerminalList();
|
|
@@ -498,20 +523,8 @@ export class BridgeSession {
|
|
|
498
523
|
}
|
|
499
524
|
case "permission.decision": {
|
|
500
525
|
const p = envelope.payload as { requestId: string; decision: "allow" | "deny" };
|
|
501
|
-
|
|
502
|
-
if (resolve) {
|
|
503
|
-
this.pendingPermissions.delete(p.requestId);
|
|
504
|
-
resolve(p.decision);
|
|
526
|
+
if (this.resolvePendingPermission(p.requestId, p.decision)) {
|
|
505
527
|
this.log(`permission decision for ${p.requestId}: ${p.decision}`);
|
|
506
|
-
// Pop from permission stack
|
|
507
|
-
if (p.decision === "allow" || p.decision === "deny") {
|
|
508
|
-
const stack = this.permissionStacks.get(tid);
|
|
509
|
-
if (stack) {
|
|
510
|
-
const idx = stack.findIndex((s) => s.requestId === p.requestId);
|
|
511
|
-
if (idx >= 0) stack.splice(idx, 1);
|
|
512
|
-
if (stack.length === 0) this.permissionStacks.delete(tid);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
528
|
} else {
|
|
516
529
|
this.log(`no pending permission for ${p.requestId}`);
|
|
517
530
|
}
|
|
@@ -889,8 +902,19 @@ export class BridgeSession {
|
|
|
889
902
|
return;
|
|
890
903
|
}
|
|
891
904
|
let body = "";
|
|
892
|
-
|
|
905
|
+
let bodyTooLarge = false;
|
|
906
|
+
req.on("data", (chunk: Buffer) => {
|
|
907
|
+
if (bodyTooLarge) return;
|
|
908
|
+
body += chunk.toString();
|
|
909
|
+
if (Buffer.byteLength(body, "utf8") > HOOK_BODY_LIMIT) {
|
|
910
|
+
bodyTooLarge = true;
|
|
911
|
+
res.writeHead(413);
|
|
912
|
+
res.end("payload too large");
|
|
913
|
+
req.destroy();
|
|
914
|
+
}
|
|
915
|
+
});
|
|
893
916
|
req.on("end", () => {
|
|
917
|
+
if (bodyTooLarge || res.writableEnded) return;
|
|
894
918
|
this.log(`hook body (${body.length} bytes): ${body.slice(0, 200)}`);
|
|
895
919
|
try {
|
|
896
920
|
const event = JSON.parse(body);
|
|
@@ -899,15 +923,26 @@ export class BridgeSession {
|
|
|
899
923
|
// PermissionRequest: hold connection, wait for user decision from mobile app
|
|
900
924
|
if (hookName === "PermissionRequest") {
|
|
901
925
|
const requestId = `pr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
926
|
+
const timeout = setTimeout(() => {
|
|
927
|
+
if (this.resolvePendingPermission(requestId, "deny")) {
|
|
928
|
+
this.log(`permission request ${requestId} timed out`);
|
|
929
|
+
this.sendPermissionSnapshot(terminalId, "thinking", "permission timed out");
|
|
930
|
+
}
|
|
931
|
+
}, PERMISSION_REQUEST_TIMEOUT_MS);
|
|
932
|
+
this.pendingPermissions.set(requestId, {
|
|
933
|
+
terminalId,
|
|
934
|
+
timeout,
|
|
935
|
+
resolve: (decision) => {
|
|
936
|
+
if (res.writableEnded) return;
|
|
937
|
+
const responseJson = JSON.stringify({
|
|
938
|
+
hookSpecificOutput: {
|
|
939
|
+
hookEventName: "PermissionRequest",
|
|
940
|
+
decision: { behavior: decision },
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
944
|
+
res.end(responseJson);
|
|
945
|
+
},
|
|
911
946
|
});
|
|
912
947
|
// Send status with requestId so app can route decision back
|
|
913
948
|
this.handleHookEvent(terminalId, event, provider, requestId);
|
|
@@ -964,9 +999,16 @@ export class BridgeSession {
|
|
|
964
999
|
} catch { /* doesn't exist yet */ }
|
|
965
1000
|
|
|
966
1001
|
const hookEntry = { matcher: "", hooks: [{ type: "command", command: curlCmd, timeout: 5 }] };
|
|
967
|
-
const permissionEntry = {
|
|
1002
|
+
const permissionEntry = {
|
|
1003
|
+
matcher: "",
|
|
1004
|
+
hooks: [{
|
|
1005
|
+
type: "command",
|
|
1006
|
+
command: curlCmd,
|
|
1007
|
+
timeout: Math.ceil((PERMISSION_REQUEST_TIMEOUT_MS + 30_000) / 1000),
|
|
1008
|
+
}],
|
|
1009
|
+
};
|
|
968
1010
|
|
|
969
|
-
const hookEvents: Record<string, typeof hookEntry> = {
|
|
1011
|
+
const hookEvents: Record<string, typeof hookEntry | typeof permissionEntry> = {
|
|
970
1012
|
PreToolUse: hookEntry,
|
|
971
1013
|
PostToolUse: hookEntry,
|
|
972
1014
|
PostToolUseFailure: hookEntry,
|
|
@@ -981,7 +1023,7 @@ export class BridgeSession {
|
|
|
981
1023
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
982
1024
|
let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
|
983
1025
|
// Remove any dead linkshell hook entries (from previous instances)
|
|
984
|
-
arr = arr.filter((e) => !
|
|
1026
|
+
arr = arr.filter((e) => !isLinkShellHookEntry(e));
|
|
985
1027
|
arr.push(entry);
|
|
986
1028
|
existingHooks[eventName] = arr;
|
|
987
1029
|
}
|
|
@@ -1036,7 +1078,7 @@ export class BridgeSession {
|
|
|
1036
1078
|
const existingHooks = existing.hooks ?? {};
|
|
1037
1079
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1038
1080
|
let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
|
1039
|
-
arr = arr.filter((e) => !
|
|
1081
|
+
arr = arr.filter((e) => !isLinkShellHookEntry(e));
|
|
1040
1082
|
arr.push(entry);
|
|
1041
1083
|
existingHooks[eventName] = arr;
|
|
1042
1084
|
}
|
|
@@ -1069,7 +1111,7 @@ export class BridgeSession {
|
|
|
1069
1111
|
const existingHooks = (existing.hooks ?? {}) as Record<string, unknown[]>;
|
|
1070
1112
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1071
1113
|
let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
|
1072
|
-
arr = arr.filter((e) => !
|
|
1114
|
+
arr = arr.filter((e) => !isLinkShellHookEntry(e));
|
|
1073
1115
|
arr.push(entry);
|
|
1074
1116
|
existingHooks[eventName] = arr;
|
|
1075
1117
|
}
|
|
@@ -1104,7 +1146,7 @@ export class BridgeSession {
|
|
|
1104
1146
|
const existingHooks = existing.hooks ?? {};
|
|
1105
1147
|
for (const [eventName, entry] of Object.entries(hookEvents)) {
|
|
1106
1148
|
let arr = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
|
|
1107
|
-
arr = arr.filter((e) => !
|
|
1149
|
+
arr = arr.filter((e) => !isLinkShellHookEntry(e));
|
|
1108
1150
|
arr.push(entry);
|
|
1109
1151
|
existingHooks[eventName] = arr;
|
|
1110
1152
|
}
|
|
@@ -1334,10 +1376,7 @@ export class BridgeSession {
|
|
|
1334
1376
|
|
|
1335
1377
|
/** Auto-resolve a single pending permission (user acted in terminal) */
|
|
1336
1378
|
private autoResolvePending(requestId: string): void {
|
|
1337
|
-
|
|
1338
|
-
if (resolve) {
|
|
1339
|
-
this.pendingPermissions.delete(requestId);
|
|
1340
|
-
resolve("allow");
|
|
1379
|
+
if (this.resolvePendingPermission(requestId, "allow")) {
|
|
1341
1380
|
this.log(`auto-resolved pending permission ${requestId} (user acted in terminal)`);
|
|
1342
1381
|
}
|
|
1343
1382
|
}
|
|
@@ -1346,16 +1385,53 @@ export class BridgeSession {
|
|
|
1346
1385
|
private drainPendingPermissions(terminalId: string): void {
|
|
1347
1386
|
const stack = this.permissionStacks.get(terminalId);
|
|
1348
1387
|
if (!stack) return;
|
|
1349
|
-
for (const entry of stack) {
|
|
1350
|
-
|
|
1351
|
-
if (resolve) {
|
|
1352
|
-
this.pendingPermissions.delete(entry.requestId);
|
|
1353
|
-
resolve("deny");
|
|
1388
|
+
for (const entry of [...stack]) {
|
|
1389
|
+
if (this.resolvePendingPermission(entry.requestId, "deny")) {
|
|
1354
1390
|
this.log(`drained pending permission ${entry.requestId}`);
|
|
1355
1391
|
}
|
|
1356
1392
|
}
|
|
1357
1393
|
}
|
|
1358
1394
|
|
|
1395
|
+
private resolvePendingPermission(requestId: string, decision: "allow" | "deny"): boolean {
|
|
1396
|
+
const pending = this.pendingPermissions.get(requestId);
|
|
1397
|
+
if (!pending) return false;
|
|
1398
|
+
this.pendingPermissions.delete(requestId);
|
|
1399
|
+
clearTimeout(pending.timeout);
|
|
1400
|
+
pending.resolve(decision);
|
|
1401
|
+
|
|
1402
|
+
const stack = this.permissionStacks.get(pending.terminalId);
|
|
1403
|
+
if (stack) {
|
|
1404
|
+
const idx = stack.findIndex((entry) => entry.requestId === requestId);
|
|
1405
|
+
if (idx >= 0) stack.splice(idx, 1);
|
|
1406
|
+
if (stack.length === 0) this.permissionStacks.delete(pending.terminalId);
|
|
1407
|
+
}
|
|
1408
|
+
return true;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
private sendPermissionSnapshot(
|
|
1412
|
+
terminalId: string,
|
|
1413
|
+
phase: string,
|
|
1414
|
+
summary?: string,
|
|
1415
|
+
): void {
|
|
1416
|
+
const stack = this.permissionStacks.get(terminalId);
|
|
1417
|
+
const topPermission = stack && stack.length > 0 ? stack[stack.length - 1] : undefined;
|
|
1418
|
+
const pendingPermissionCount = stack?.length ?? 0;
|
|
1419
|
+
const term = this.terminals.get(terminalId);
|
|
1420
|
+
const seq = term ? term.statusSeq++ : 0;
|
|
1421
|
+
this.send(createEnvelope({
|
|
1422
|
+
type: "terminal.status",
|
|
1423
|
+
sessionId: this.sessionId,
|
|
1424
|
+
terminalId,
|
|
1425
|
+
payload: {
|
|
1426
|
+
phase,
|
|
1427
|
+
seq,
|
|
1428
|
+
...(summary && { summary }),
|
|
1429
|
+
...(topPermission && { topPermission }),
|
|
1430
|
+
...(pendingPermissionCount > 0 && { pendingPermissionCount }),
|
|
1431
|
+
},
|
|
1432
|
+
}));
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1359
1435
|
private cleanupHookServer(term: TerminalInstance): void {
|
|
1360
1436
|
// Drain any pending permission requests for this terminal
|
|
1361
1437
|
this.drainPendingPermissions(term.id);
|
|
@@ -1508,13 +1584,16 @@ export class BridgeSession {
|
|
|
1508
1584
|
}
|
|
1509
1585
|
|
|
1510
1586
|
private scheduleReconnect(): void {
|
|
1511
|
-
if (this.reconnecting
|
|
1587
|
+
if (this.reconnecting) return;
|
|
1588
|
+
|
|
1589
|
+
// In daemon mode, never give up — reset attempts after hitting max
|
|
1590
|
+
if (this.reconnectAttempts >= RECONNECT_MAX_ATTEMPTS) {
|
|
1512
1591
|
process.stderr.write(
|
|
1513
|
-
"[bridge] max reconnect attempts reached,
|
|
1592
|
+
"[bridge] max reconnect attempts reached, resetting counter and continuing...\n",
|
|
1514
1593
|
);
|
|
1515
|
-
this.
|
|
1516
|
-
return;
|
|
1594
|
+
this.reconnectAttempts = 0;
|
|
1517
1595
|
}
|
|
1596
|
+
|
|
1518
1597
|
this.reconnecting = true;
|
|
1519
1598
|
const delay = Math.min(
|
|
1520
1599
|
RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts,
|