@agentproto/acp 0.1.0-alpha.1 → 0.1.0
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/tunnel/index.d.ts +407 -6
- package/dist/tunnel/index.mjs +793 -10
- package/dist/tunnel/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/tunnel/index.mjs
CHANGED
|
@@ -16,6 +16,14 @@ var KNOWN_TYPES = /* @__PURE__ */ new Set([
|
|
|
16
16
|
"stdin",
|
|
17
17
|
"kill",
|
|
18
18
|
"resize",
|
|
19
|
+
"http_request",
|
|
20
|
+
"http_response",
|
|
21
|
+
"http_response_head",
|
|
22
|
+
"http_response_chunk",
|
|
23
|
+
"ws_open",
|
|
24
|
+
"ws_open_ack",
|
|
25
|
+
"ws_message",
|
|
26
|
+
"ws_close",
|
|
19
27
|
"ping",
|
|
20
28
|
"pong",
|
|
21
29
|
"error",
|
|
@@ -23,7 +31,8 @@ var KNOWN_TYPES = /* @__PURE__ */ new Set([
|
|
|
23
31
|
"spawned",
|
|
24
32
|
"stdout",
|
|
25
33
|
"stderr",
|
|
26
|
-
"exit"
|
|
34
|
+
"exit",
|
|
35
|
+
"reconnect_soon"
|
|
27
36
|
]);
|
|
28
37
|
function parseFrame(raw) {
|
|
29
38
|
let parsed;
|
|
@@ -51,11 +60,16 @@ function decodeData(data) {
|
|
|
51
60
|
}
|
|
52
61
|
function createTunnelServer(opts) {
|
|
53
62
|
const children = /* @__PURE__ */ new Map();
|
|
63
|
+
const ptyProcs = /* @__PURE__ */ new Map();
|
|
64
|
+
const upstreamWs = /* @__PURE__ */ new Map();
|
|
54
65
|
let closed = false;
|
|
55
66
|
opts.sink.send({
|
|
56
67
|
t: "hello",
|
|
57
68
|
version: TUNNEL_VERSION,
|
|
58
|
-
capabilities: {
|
|
69
|
+
capabilities: {
|
|
70
|
+
pty: opts.pty === true,
|
|
71
|
+
wsForward: opts.dialUpstreamWs !== void 0 && !!opts.httpUpstream
|
|
72
|
+
},
|
|
59
73
|
label: opts.label,
|
|
60
74
|
daemon: {
|
|
61
75
|
name: "agentproto",
|
|
@@ -74,6 +88,15 @@ function createTunnelServer(opts) {
|
|
|
74
88
|
closed = true;
|
|
75
89
|
for (const [, child] of children) child.kill("SIGTERM");
|
|
76
90
|
children.clear();
|
|
91
|
+
for (const [, pty] of ptyProcs) pty.kill();
|
|
92
|
+
ptyProcs.clear();
|
|
93
|
+
for (const [, ws] of upstreamWs) {
|
|
94
|
+
try {
|
|
95
|
+
ws.close(1001, "tunnel_closed");
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
upstreamWs.clear();
|
|
77
100
|
offFrame();
|
|
78
101
|
offClose();
|
|
79
102
|
});
|
|
@@ -83,6 +106,11 @@ function createTunnelServer(opts) {
|
|
|
83
106
|
await handleSpawn(frame);
|
|
84
107
|
return;
|
|
85
108
|
case "stdin": {
|
|
109
|
+
const pty = ptyProcs.get(frame.execId);
|
|
110
|
+
if (pty) {
|
|
111
|
+
pty.write(Buffer.from(frame.data, "base64").toString("utf8"));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
86
114
|
const child = children.get(frame.execId);
|
|
87
115
|
if (!child || !child.stdin) {
|
|
88
116
|
opts.sink.send({
|
|
@@ -97,6 +125,11 @@ function createTunnelServer(opts) {
|
|
|
97
125
|
return;
|
|
98
126
|
}
|
|
99
127
|
case "kill": {
|
|
128
|
+
const pty = ptyProcs.get(frame.execId);
|
|
129
|
+
if (pty) {
|
|
130
|
+
pty.kill(frame.signal ?? "SIGTERM");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
100
133
|
const child = children.get(frame.execId);
|
|
101
134
|
if (!child) {
|
|
102
135
|
opts.sink.send({
|
|
@@ -110,11 +143,60 @@ function createTunnelServer(opts) {
|
|
|
110
143
|
child.kill(frame.signal ?? "SIGTERM");
|
|
111
144
|
return;
|
|
112
145
|
}
|
|
113
|
-
case "resize":
|
|
146
|
+
case "resize": {
|
|
147
|
+
ptyProcs.get(frame.execId)?.resize(frame.cols, frame.rows);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
case "http_request":
|
|
151
|
+
await handleHttpRequest(frame);
|
|
152
|
+
return;
|
|
153
|
+
case "ws_open":
|
|
154
|
+
await handleWsOpen(frame);
|
|
155
|
+
return;
|
|
156
|
+
case "ws_message": {
|
|
157
|
+
const ws = upstreamWs.get(frame.reqId);
|
|
158
|
+
if (!ws) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
ws.send(decodeData(frame.data), { binary: frame.binary === true });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
165
|
+
opts.sink.send({
|
|
166
|
+
t: "ws_close",
|
|
167
|
+
reqId: frame.reqId,
|
|
168
|
+
code: 1011,
|
|
169
|
+
reason: `send_failed: ${message}`
|
|
170
|
+
});
|
|
171
|
+
try {
|
|
172
|
+
ws.close(1011, "send_failed");
|
|
173
|
+
} catch {
|
|
174
|
+
}
|
|
175
|
+
upstreamWs.delete(frame.reqId);
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
case "ws_close": {
|
|
180
|
+
const ws = upstreamWs.get(frame.reqId);
|
|
181
|
+
if (!ws) return;
|
|
182
|
+
upstreamWs.delete(frame.reqId);
|
|
183
|
+
try {
|
|
184
|
+
ws.close(frame.code, frame.reason);
|
|
185
|
+
} catch {
|
|
186
|
+
}
|
|
114
187
|
return;
|
|
188
|
+
}
|
|
115
189
|
case "ping":
|
|
116
190
|
opts.sink.send({ t: "pong", nonce: frame.nonce });
|
|
117
191
|
return;
|
|
192
|
+
case "reconnect_soon":
|
|
193
|
+
try {
|
|
194
|
+
opts.onReconnectSoon?.({
|
|
195
|
+
...frame.reasonMs !== void 0 ? { reasonMs: frame.reasonMs } : {}
|
|
196
|
+
});
|
|
197
|
+
} catch {
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
118
200
|
case "pong":
|
|
119
201
|
case "error":
|
|
120
202
|
return;
|
|
@@ -150,6 +232,48 @@ function createTunnelServer(opts) {
|
|
|
150
232
|
});
|
|
151
233
|
return;
|
|
152
234
|
}
|
|
235
|
+
if (approved.pty && opts.spawnPty) {
|
|
236
|
+
let pty;
|
|
237
|
+
try {
|
|
238
|
+
pty = opts.spawnPty({
|
|
239
|
+
command: approved.command,
|
|
240
|
+
args: [...approved.args],
|
|
241
|
+
cwd: approved.cwd,
|
|
242
|
+
env: approved.env ? { ...approved.env } : void 0,
|
|
243
|
+
cols: approved.cols ?? 80,
|
|
244
|
+
rows: approved.rows ?? 24
|
|
245
|
+
});
|
|
246
|
+
} catch (err) {
|
|
247
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
248
|
+
opts.sink.send({ t: "error", execId: req.execId, code: "spawn_failed", message });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
ptyProcs.set(req.execId, pty);
|
|
252
|
+
opts.sink.send({ t: "spawned", execId: req.execId, pid: pty.pid });
|
|
253
|
+
pty.onData((data) => {
|
|
254
|
+
opts.sink.send({ t: "stdout", execId: req.execId, data: encodeData(data) });
|
|
255
|
+
});
|
|
256
|
+
pty.onExit(({ exitCode, signal }) => {
|
|
257
|
+
opts.sink.send({
|
|
258
|
+
t: "exit",
|
|
259
|
+
execId: req.execId,
|
|
260
|
+
code: exitCode,
|
|
261
|
+
signal: signal != null ? String(signal) : null
|
|
262
|
+
});
|
|
263
|
+
ptyProcs.delete(req.execId);
|
|
264
|
+
});
|
|
265
|
+
if (opts.onChildSpawned) {
|
|
266
|
+
try {
|
|
267
|
+
opts.onChildSpawned({
|
|
268
|
+
execId: req.execId,
|
|
269
|
+
child: { pid: pty.pid },
|
|
270
|
+
request: approved
|
|
271
|
+
});
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
153
277
|
let child;
|
|
154
278
|
try {
|
|
155
279
|
child = spawn(approved.command, [...approved.args], {
|
|
@@ -174,6 +298,15 @@ function createTunnelServer(opts) {
|
|
|
174
298
|
} else {
|
|
175
299
|
opts.sink.send({ t: "spawned", execId: req.execId, pid: -1 });
|
|
176
300
|
}
|
|
301
|
+
if (opts.onChildSpawned) {
|
|
302
|
+
try {
|
|
303
|
+
opts.onChildSpawned({ execId: req.execId, child, request: approved });
|
|
304
|
+
} catch (err) {
|
|
305
|
+
console.warn(
|
|
306
|
+
`[tunnel-server] onChildSpawned hook threw for execId=${req.execId}: ${err instanceof Error ? err.message : String(err)}`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
177
310
|
child.stdout?.on("data", (chunk) => {
|
|
178
311
|
const f = {
|
|
179
312
|
t: "stdout",
|
|
@@ -209,6 +342,258 @@ function createTunnelServer(opts) {
|
|
|
209
342
|
children.delete(req.execId);
|
|
210
343
|
});
|
|
211
344
|
}
|
|
345
|
+
async function handleWsOpen(req) {
|
|
346
|
+
if (closed) return;
|
|
347
|
+
const reject = (status, code, message) => {
|
|
348
|
+
opts.sink.send({
|
|
349
|
+
t: "ws_open_ack",
|
|
350
|
+
reqId: req.reqId,
|
|
351
|
+
status,
|
|
352
|
+
error: { code, message }
|
|
353
|
+
});
|
|
354
|
+
};
|
|
355
|
+
if (!opts.dialUpstreamWs || !opts.httpUpstream) {
|
|
356
|
+
reject(
|
|
357
|
+
502,
|
|
358
|
+
"ws_upstream_not_configured",
|
|
359
|
+
"Daemon does not support WS forwarding \u2014 start with `--upstream ws://\u2026` (or upgrade)."
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (!req.path.startsWith("/") || req.path.startsWith("//") || req.path.includes("..")) {
|
|
364
|
+
reject(
|
|
365
|
+
400,
|
|
366
|
+
"invalid_path",
|
|
367
|
+
`Daemon WS forward requires a safe relative path; got '${req.path}'.`
|
|
368
|
+
);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
372
|
+
"connection",
|
|
373
|
+
"upgrade",
|
|
374
|
+
"sec-websocket-version",
|
|
375
|
+
"sec-websocket-key",
|
|
376
|
+
"sec-websocket-extensions",
|
|
377
|
+
"sec-websocket-protocol",
|
|
378
|
+
"host",
|
|
379
|
+
"content-length"
|
|
380
|
+
]);
|
|
381
|
+
const headers = {};
|
|
382
|
+
for (const [k, v] of Object.entries(req.headers ?? {})) {
|
|
383
|
+
if (HOP_BY_HOP.has(k.toLowerCase())) continue;
|
|
384
|
+
headers[k] = v;
|
|
385
|
+
}
|
|
386
|
+
const base = opts.httpUpstream.replace(/^http(s?):\/\//, "ws$1://");
|
|
387
|
+
const url = `${base.replace(/\/$/, "")}${req.path}`;
|
|
388
|
+
let upstream;
|
|
389
|
+
try {
|
|
390
|
+
upstream = await opts.dialUpstreamWs({
|
|
391
|
+
url,
|
|
392
|
+
...req.protocols && req.protocols.length > 0 ? { protocols: req.protocols } : {},
|
|
393
|
+
headers
|
|
394
|
+
});
|
|
395
|
+
} catch (err) {
|
|
396
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
397
|
+
const m = message.match(/Unexpected server response: (\d+)/);
|
|
398
|
+
const status = m ? Number.parseInt(m[1], 10) : 502;
|
|
399
|
+
reject(status, "upstream_ws_failed", message);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (closed) {
|
|
403
|
+
try {
|
|
404
|
+
upstream.close(1001, "tunnel_closed");
|
|
405
|
+
} catch {
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
upstreamWs.set(req.reqId, upstream);
|
|
410
|
+
opts.sink.send({
|
|
411
|
+
t: "ws_open_ack",
|
|
412
|
+
reqId: req.reqId,
|
|
413
|
+
status: 101,
|
|
414
|
+
...upstream.protocol ? { protocol: upstream.protocol } : {}
|
|
415
|
+
});
|
|
416
|
+
upstream.onMessage((data, isBinary) => {
|
|
417
|
+
if (!opts.sink.isOpen) return;
|
|
418
|
+
opts.sink.send({
|
|
419
|
+
t: "ws_message",
|
|
420
|
+
reqId: req.reqId,
|
|
421
|
+
data: encodeData(data),
|
|
422
|
+
binary: isBinary
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
upstream.onClose((code, reason) => {
|
|
426
|
+
if (!upstreamWs.has(req.reqId)) return;
|
|
427
|
+
upstreamWs.delete(req.reqId);
|
|
428
|
+
if (opts.sink.isOpen) {
|
|
429
|
+
opts.sink.send({
|
|
430
|
+
t: "ws_close",
|
|
431
|
+
reqId: req.reqId,
|
|
432
|
+
code,
|
|
433
|
+
...reason ? { reason } : {}
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
upstream.onError((err) => {
|
|
438
|
+
if (!upstreamWs.has(req.reqId)) return;
|
|
439
|
+
upstreamWs.delete(req.reqId);
|
|
440
|
+
if (opts.sink.isOpen) {
|
|
441
|
+
opts.sink.send({
|
|
442
|
+
t: "ws_close",
|
|
443
|
+
reqId: req.reqId,
|
|
444
|
+
code: 1011,
|
|
445
|
+
reason: `upstream_error: ${err.message}`
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
upstream.close(1011, "upstream_error");
|
|
450
|
+
} catch {
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
async function handleHttpRequest(req) {
|
|
455
|
+
if (closed) return;
|
|
456
|
+
const respond = (partial) => {
|
|
457
|
+
opts.sink.send({
|
|
458
|
+
t: "http_response",
|
|
459
|
+
reqId: req.reqId,
|
|
460
|
+
...partial
|
|
461
|
+
});
|
|
462
|
+
};
|
|
463
|
+
if (!opts.httpUpstream) {
|
|
464
|
+
respond({
|
|
465
|
+
status: 502,
|
|
466
|
+
error: {
|
|
467
|
+
code: "http_upstream_not_configured",
|
|
468
|
+
message: "Daemon was not started with an http upstream \u2014 cannot forward HTTP frames."
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (!req.path.startsWith("/") || req.path.startsWith("//") || req.path.includes("..")) {
|
|
474
|
+
respond({
|
|
475
|
+
status: 400,
|
|
476
|
+
error: {
|
|
477
|
+
code: "invalid_path",
|
|
478
|
+
message: `Daemon http forward requires a safe relative path; got '${req.path}'.`
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
484
|
+
"connection",
|
|
485
|
+
"keep-alive",
|
|
486
|
+
"transfer-encoding",
|
|
487
|
+
"upgrade",
|
|
488
|
+
"proxy-authenticate",
|
|
489
|
+
"proxy-authorization",
|
|
490
|
+
"te",
|
|
491
|
+
"trailer",
|
|
492
|
+
"host",
|
|
493
|
+
// upstream sets its own based on the URL
|
|
494
|
+
"content-length"
|
|
495
|
+
// fetch recomputes
|
|
496
|
+
]);
|
|
497
|
+
const headers = {};
|
|
498
|
+
for (const [k, v] of Object.entries(req.headers ?? {})) {
|
|
499
|
+
if (HOP_BY_HOP.has(k.toLowerCase())) continue;
|
|
500
|
+
headers[k] = v;
|
|
501
|
+
}
|
|
502
|
+
const url = `${opts.httpUpstream.replace(/\/$/, "")}${req.path}`;
|
|
503
|
+
const controller = new AbortController();
|
|
504
|
+
const timeoutMs = req.timeoutMs ?? 3e4;
|
|
505
|
+
let timer = setTimeout(
|
|
506
|
+
() => controller.abort(),
|
|
507
|
+
timeoutMs
|
|
508
|
+
);
|
|
509
|
+
const clearTimer = () => {
|
|
510
|
+
if (timer) {
|
|
511
|
+
clearTimeout(timer);
|
|
512
|
+
timer = null;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
try {
|
|
516
|
+
const upstreamRes = await fetch(url, {
|
|
517
|
+
method: req.method,
|
|
518
|
+
headers,
|
|
519
|
+
body: req.body === void 0 ? void 0 : decodeData(req.body),
|
|
520
|
+
signal: controller.signal
|
|
521
|
+
// node fetch follows redirects by default — fine for MCP, the
|
|
522
|
+
// upstream gateway doesn't redirect anyway.
|
|
523
|
+
});
|
|
524
|
+
const outHeaders = {};
|
|
525
|
+
upstreamRes.headers.forEach((value, key) => {
|
|
526
|
+
if (HOP_BY_HOP.has(key.toLowerCase())) return;
|
|
527
|
+
outHeaders[key] = value;
|
|
528
|
+
});
|
|
529
|
+
const contentType = (outHeaders["content-type"] ?? "").toLowerCase();
|
|
530
|
+
const isStream = contentType.startsWith("text/event-stream") || contentType.includes("application/x-ndjson");
|
|
531
|
+
if (isStream && upstreamRes.body) {
|
|
532
|
+
clearTimer();
|
|
533
|
+
opts.sink.send({
|
|
534
|
+
t: "http_response_head",
|
|
535
|
+
reqId: req.reqId,
|
|
536
|
+
status: upstreamRes.status,
|
|
537
|
+
headers: outHeaders
|
|
538
|
+
});
|
|
539
|
+
const reader = upstreamRes.body.getReader();
|
|
540
|
+
try {
|
|
541
|
+
while (true) {
|
|
542
|
+
const { value, done } = await reader.read();
|
|
543
|
+
if (done) {
|
|
544
|
+
opts.sink.send({
|
|
545
|
+
t: "http_response_chunk",
|
|
546
|
+
reqId: req.reqId,
|
|
547
|
+
end: true
|
|
548
|
+
});
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (value && value.byteLength > 0) {
|
|
552
|
+
opts.sink.send({
|
|
553
|
+
t: "http_response_chunk",
|
|
554
|
+
reqId: req.reqId,
|
|
555
|
+
data: encodeData(Buffer.from(value))
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
if (!opts.sink.isOpen) {
|
|
559
|
+
try {
|
|
560
|
+
await reader.cancel();
|
|
561
|
+
} catch {
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} catch (err) {
|
|
567
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
568
|
+
opts.sink.send({
|
|
569
|
+
t: "http_response_chunk",
|
|
570
|
+
reqId: req.reqId,
|
|
571
|
+
end: true,
|
|
572
|
+
error: { code: "upstream_stream_failed", message }
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const buf = Buffer.from(await upstreamRes.arrayBuffer());
|
|
578
|
+
respond({
|
|
579
|
+
status: upstreamRes.status,
|
|
580
|
+
headers: outHeaders,
|
|
581
|
+
body: buf.length > 0 ? encodeData(buf) : ""
|
|
582
|
+
});
|
|
583
|
+
} catch (err) {
|
|
584
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
585
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
586
|
+
respond({
|
|
587
|
+
status: aborted ? 504 : 502,
|
|
588
|
+
error: {
|
|
589
|
+
code: aborted ? "timeout" : "upstream_fetch_failed",
|
|
590
|
+
message
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
} finally {
|
|
594
|
+
clearTimer();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
212
597
|
return {
|
|
213
598
|
children,
|
|
214
599
|
async close() {
|
|
@@ -226,6 +611,9 @@ function createTunnelClient(opts) {
|
|
|
226
611
|
let helloPromise = null;
|
|
227
612
|
const childByExec = /* @__PURE__ */ new Map();
|
|
228
613
|
const spawnPending = /* @__PURE__ */ new Map();
|
|
614
|
+
const httpPending = /* @__PURE__ */ new Map();
|
|
615
|
+
const wsOpenPending = /* @__PURE__ */ new Map();
|
|
616
|
+
const wsByReq = /* @__PURE__ */ new Map();
|
|
229
617
|
const offFrame = opts.sink.onFrame((frame) => routeIncoming(frame));
|
|
230
618
|
const offClose = opts.sink.onClose(() => {
|
|
231
619
|
for (const [, duck] of childByExec) duck.__handleSinkClosed();
|
|
@@ -234,6 +622,31 @@ function createTunnelClient(opts) {
|
|
|
234
622
|
p.reject(new Error("Tunnel closed before spawn completed."));
|
|
235
623
|
}
|
|
236
624
|
spawnPending.clear();
|
|
625
|
+
for (const [, p] of httpPending) {
|
|
626
|
+
clearTimeout(p.timer);
|
|
627
|
+
if (p.mode === "buffered") {
|
|
628
|
+
p.reject(new Error("Tunnel closed before HTTP response arrived."));
|
|
629
|
+
} else {
|
|
630
|
+
if (!p.headEmitted) {
|
|
631
|
+
p.reject(new Error("Tunnel closed before HTTP response head arrived."));
|
|
632
|
+
} else if (p.controller && !p.ended) {
|
|
633
|
+
try {
|
|
634
|
+
p.controller.error(new Error("Tunnel closed mid-stream."));
|
|
635
|
+
} catch {
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
httpPending.clear();
|
|
641
|
+
for (const [, p] of wsOpenPending) {
|
|
642
|
+
clearTimeout(p.timer);
|
|
643
|
+
p.reject(new Error("Tunnel closed before WS upgrade completed."));
|
|
644
|
+
}
|
|
645
|
+
wsOpenPending.clear();
|
|
646
|
+
for (const [, duck] of wsByReq) {
|
|
647
|
+
duck.__handleSinkClosed();
|
|
648
|
+
}
|
|
649
|
+
wsByReq.clear();
|
|
237
650
|
offFrame();
|
|
238
651
|
offClose();
|
|
239
652
|
});
|
|
@@ -285,6 +698,178 @@ function createTunnelClient(opts) {
|
|
|
285
698
|
}
|
|
286
699
|
return;
|
|
287
700
|
}
|
|
701
|
+
case "http_response": {
|
|
702
|
+
const pending = httpPending.get(frame.reqId);
|
|
703
|
+
if (!pending) return;
|
|
704
|
+
httpPending.delete(frame.reqId);
|
|
705
|
+
clearTimeout(pending.timer);
|
|
706
|
+
const body = frame.body ? decodeData(frame.body) : Buffer.alloc(0);
|
|
707
|
+
if (pending.mode === "buffered") {
|
|
708
|
+
pending.resolve({
|
|
709
|
+
status: frame.status,
|
|
710
|
+
headers: frame.headers ?? {},
|
|
711
|
+
body,
|
|
712
|
+
...frame.error ? { error: frame.error } : {}
|
|
713
|
+
});
|
|
714
|
+
} else {
|
|
715
|
+
const stream = new ReadableStream({
|
|
716
|
+
start(controller) {
|
|
717
|
+
if (body.length > 0) controller.enqueue(new Uint8Array(body));
|
|
718
|
+
controller.close();
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
pending.resolveHead({
|
|
722
|
+
status: frame.status,
|
|
723
|
+
headers: frame.headers ?? {},
|
|
724
|
+
body: stream
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
case "http_response_head": {
|
|
730
|
+
const pending = httpPending.get(frame.reqId);
|
|
731
|
+
if (!pending) return;
|
|
732
|
+
const headers = frame.headers ?? {};
|
|
733
|
+
if (pending.mode === "stream") {
|
|
734
|
+
const stream = new ReadableStream({
|
|
735
|
+
start(controller) {
|
|
736
|
+
pending.controller = controller;
|
|
737
|
+
for (const chunk of pending.pendingChunks) {
|
|
738
|
+
if (chunk.length > 0) controller.enqueue(new Uint8Array(chunk));
|
|
739
|
+
}
|
|
740
|
+
pending.pendingChunks = [];
|
|
741
|
+
},
|
|
742
|
+
cancel: () => {
|
|
743
|
+
if (httpPending.get(frame.reqId) === pending) {
|
|
744
|
+
clearTimeout(pending.timer);
|
|
745
|
+
httpPending.delete(frame.reqId);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
pending.headEmitted = true;
|
|
750
|
+
clearTimeout(pending.timer);
|
|
751
|
+
pending.resolveHead({
|
|
752
|
+
status: frame.status,
|
|
753
|
+
headers,
|
|
754
|
+
body: stream
|
|
755
|
+
});
|
|
756
|
+
} else {
|
|
757
|
+
pending.streamHead = { status: frame.status, headers };
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
case "http_response_chunk": {
|
|
762
|
+
const pending = httpPending.get(frame.reqId);
|
|
763
|
+
if (!pending) return;
|
|
764
|
+
const data = frame.data ? decodeData(frame.data) : Buffer.alloc(0);
|
|
765
|
+
if (pending.mode === "stream") {
|
|
766
|
+
if (frame.error) {
|
|
767
|
+
try {
|
|
768
|
+
pending.controller?.error(
|
|
769
|
+
new Error(`${frame.error.code}: ${frame.error.message}`)
|
|
770
|
+
);
|
|
771
|
+
} catch {
|
|
772
|
+
}
|
|
773
|
+
pending.ended = true;
|
|
774
|
+
clearTimeout(pending.timer);
|
|
775
|
+
httpPending.delete(frame.reqId);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
if (pending.controller) {
|
|
779
|
+
if (data.length > 0) {
|
|
780
|
+
try {
|
|
781
|
+
pending.controller.enqueue(new Uint8Array(data));
|
|
782
|
+
} catch {
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (frame.end) {
|
|
786
|
+
try {
|
|
787
|
+
pending.controller.close();
|
|
788
|
+
} catch {
|
|
789
|
+
}
|
|
790
|
+
pending.ended = true;
|
|
791
|
+
clearTimeout(pending.timer);
|
|
792
|
+
httpPending.delete(frame.reqId);
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
if (data.length > 0) pending.pendingChunks.push(data);
|
|
796
|
+
if (frame.end) {
|
|
797
|
+
pending.ended = true;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
} else {
|
|
801
|
+
if (data.length > 0) pending.streamChunks.push(data);
|
|
802
|
+
if (frame.error) {
|
|
803
|
+
clearTimeout(pending.timer);
|
|
804
|
+
httpPending.delete(frame.reqId);
|
|
805
|
+
const concatenated = Buffer.concat(pending.streamChunks);
|
|
806
|
+
pending.resolve({
|
|
807
|
+
status: pending.streamHead?.status ?? 502,
|
|
808
|
+
headers: pending.streamHead?.headers ?? {},
|
|
809
|
+
body: concatenated,
|
|
810
|
+
error: frame.error
|
|
811
|
+
});
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (frame.end) {
|
|
815
|
+
clearTimeout(pending.timer);
|
|
816
|
+
httpPending.delete(frame.reqId);
|
|
817
|
+
const concatenated = Buffer.concat(pending.streamChunks);
|
|
818
|
+
pending.resolve({
|
|
819
|
+
status: pending.streamHead?.status ?? 200,
|
|
820
|
+
headers: pending.streamHead?.headers ?? {},
|
|
821
|
+
body: concatenated
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
case "ws_open_ack": {
|
|
828
|
+
const pending = wsOpenPending.get(frame.reqId);
|
|
829
|
+
if (!pending) return;
|
|
830
|
+
wsOpenPending.delete(frame.reqId);
|
|
831
|
+
clearTimeout(pending.timer);
|
|
832
|
+
if (frame.error || frame.status !== 101) {
|
|
833
|
+
const code = frame.error?.code ?? `http_${frame.status}`;
|
|
834
|
+
const msg = frame.error?.message ?? `Upstream WS upgrade returned ${frame.status}`;
|
|
835
|
+
pending.reject(new Error(`${code}: ${msg}`));
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const duck = new TunnelWebSocketDuck(
|
|
839
|
+
frame.reqId,
|
|
840
|
+
frame.protocol ?? null,
|
|
841
|
+
opts.sink,
|
|
842
|
+
() => wsByReq.delete(frame.reqId)
|
|
843
|
+
);
|
|
844
|
+
wsByReq.set(frame.reqId, duck);
|
|
845
|
+
pending.resolve(duck);
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
case "ws_message": {
|
|
849
|
+
const duck = wsByReq.get(frame.reqId);
|
|
850
|
+
if (!duck) return;
|
|
851
|
+
duck.__handleMessage(decodeData(frame.data), frame.binary === true);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
case "ws_close": {
|
|
855
|
+
const pending = wsOpenPending.get(frame.reqId);
|
|
856
|
+
if (pending) {
|
|
857
|
+
wsOpenPending.delete(frame.reqId);
|
|
858
|
+
clearTimeout(pending.timer);
|
|
859
|
+
pending.reject(
|
|
860
|
+
new Error(
|
|
861
|
+
`WS closed before upgrade (code ${frame.code}${frame.reason ? `: ${frame.reason}` : ""})`
|
|
862
|
+
)
|
|
863
|
+
);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const duck = wsByReq.get(frame.reqId);
|
|
867
|
+
if (duck) {
|
|
868
|
+
wsByReq.delete(frame.reqId);
|
|
869
|
+
duck.__handleClose(frame.code, frame.reason ?? "");
|
|
870
|
+
}
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
288
873
|
case "ping":
|
|
289
874
|
opts.sink.send({ t: "pong", nonce: frame.nonce });
|
|
290
875
|
return;
|
|
@@ -293,6 +878,8 @@ function createTunnelClient(opts) {
|
|
|
293
878
|
case "stdin":
|
|
294
879
|
case "kill":
|
|
295
880
|
case "resize":
|
|
881
|
+
case "http_request":
|
|
882
|
+
case "ws_open":
|
|
296
883
|
return;
|
|
297
884
|
}
|
|
298
885
|
}
|
|
@@ -341,7 +928,8 @@ function createTunnelClient(opts) {
|
|
|
341
928
|
args,
|
|
342
929
|
cwd: spawnOpts?.cwd,
|
|
343
930
|
env: spawnOpts?.env,
|
|
344
|
-
pty: spawnOpts?.pty
|
|
931
|
+
pty: spawnOpts?.pty,
|
|
932
|
+
...spawnOpts?.pty ? { cols: spawnOpts.cols ?? 80, rows: spawnOpts.rows ?? 24 } : {}
|
|
345
933
|
};
|
|
346
934
|
const spawnedFrame = await new Promise(
|
|
347
935
|
(resolve, reject) => {
|
|
@@ -353,6 +941,113 @@ function createTunnelClient(opts) {
|
|
|
353
941
|
childByExec.set(execId, duck);
|
|
354
942
|
return duck;
|
|
355
943
|
},
|
|
944
|
+
async forwardHttp(req) {
|
|
945
|
+
if (!hello) await this.ready();
|
|
946
|
+
const reqId = randomUUID();
|
|
947
|
+
const timeoutMs = req.timeoutMs ?? 3e4;
|
|
948
|
+
const body = req.body === void 0 ? void 0 : encodeData(
|
|
949
|
+
typeof req.body === "string" ? req.body : Buffer.from(req.body)
|
|
950
|
+
);
|
|
951
|
+
const frame = {
|
|
952
|
+
t: "http_request",
|
|
953
|
+
reqId,
|
|
954
|
+
method: req.method,
|
|
955
|
+
path: req.path,
|
|
956
|
+
...req.headers ? { headers: req.headers } : {},
|
|
957
|
+
...body !== void 0 ? { body } : {},
|
|
958
|
+
timeoutMs
|
|
959
|
+
};
|
|
960
|
+
return new Promise((resolve, reject) => {
|
|
961
|
+
const timer = setTimeout(() => {
|
|
962
|
+
httpPending.delete(reqId);
|
|
963
|
+
reject(
|
|
964
|
+
new Error(
|
|
965
|
+
`Tunnel forwardHttp(${req.method} ${req.path}) timed out after ${timeoutMs + 5e3}ms.`
|
|
966
|
+
)
|
|
967
|
+
);
|
|
968
|
+
}, timeoutMs + 5e3);
|
|
969
|
+
httpPending.set(reqId, {
|
|
970
|
+
mode: "buffered",
|
|
971
|
+
resolve,
|
|
972
|
+
reject,
|
|
973
|
+
timer,
|
|
974
|
+
streamHead: null,
|
|
975
|
+
streamChunks: []
|
|
976
|
+
});
|
|
977
|
+
opts.sink.send(frame);
|
|
978
|
+
});
|
|
979
|
+
},
|
|
980
|
+
async forwardHttpStream(req) {
|
|
981
|
+
if (!hello) await this.ready();
|
|
982
|
+
const reqId = randomUUID();
|
|
983
|
+
const headTimeoutMs = req.timeoutMs ?? 3e4;
|
|
984
|
+
const body = req.body === void 0 ? void 0 : encodeData(
|
|
985
|
+
typeof req.body === "string" ? req.body : Buffer.from(req.body)
|
|
986
|
+
);
|
|
987
|
+
const frame = {
|
|
988
|
+
t: "http_request",
|
|
989
|
+
reqId,
|
|
990
|
+
method: req.method,
|
|
991
|
+
path: req.path,
|
|
992
|
+
...req.headers ? { headers: req.headers } : {},
|
|
993
|
+
...body !== void 0 ? { body } : {},
|
|
994
|
+
timeoutMs: 24 * 60 * 60 * 1e3
|
|
995
|
+
// 24h — effectively "forever"
|
|
996
|
+
};
|
|
997
|
+
return new Promise((resolveHead, reject) => {
|
|
998
|
+
const timer = setTimeout(() => {
|
|
999
|
+
if (httpPending.has(reqId)) {
|
|
1000
|
+
httpPending.delete(reqId);
|
|
1001
|
+
reject(
|
|
1002
|
+
new Error(
|
|
1003
|
+
`Tunnel forwardHttpStream(${req.method} ${req.path}) head did not arrive within ${headTimeoutMs}ms.`
|
|
1004
|
+
)
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
}, headTimeoutMs);
|
|
1008
|
+
httpPending.set(reqId, {
|
|
1009
|
+
mode: "stream",
|
|
1010
|
+
resolveHead,
|
|
1011
|
+
reject,
|
|
1012
|
+
timer,
|
|
1013
|
+
controller: null,
|
|
1014
|
+
pendingChunks: [],
|
|
1015
|
+
headEmitted: false,
|
|
1016
|
+
ended: false
|
|
1017
|
+
});
|
|
1018
|
+
opts.sink.send(frame);
|
|
1019
|
+
});
|
|
1020
|
+
},
|
|
1021
|
+
async forwardWebSocket(req) {
|
|
1022
|
+
const h = await this.ready();
|
|
1023
|
+
if (h.capabilities.wsForward !== true) {
|
|
1024
|
+
throw new Error(
|
|
1025
|
+
`Tunnel daemon '${h.label ?? "(unnamed)"}' does not advertise wsForward capability. Update the daemon to forward browser WS upgrades.`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
const reqId = randomUUID();
|
|
1029
|
+
const timeoutMs = req.openTimeoutMs ?? 3e4;
|
|
1030
|
+
const frame = {
|
|
1031
|
+
t: "ws_open",
|
|
1032
|
+
reqId,
|
|
1033
|
+
path: req.path,
|
|
1034
|
+
...req.headers ? { headers: req.headers } : {},
|
|
1035
|
+
...req.protocols && req.protocols.length > 0 ? { protocols: req.protocols } : {}
|
|
1036
|
+
};
|
|
1037
|
+
return new Promise((resolve, reject) => {
|
|
1038
|
+
const timer = setTimeout(() => {
|
|
1039
|
+
if (!wsOpenPending.has(reqId)) return;
|
|
1040
|
+
wsOpenPending.delete(reqId);
|
|
1041
|
+
reject(
|
|
1042
|
+
new Error(
|
|
1043
|
+
`Tunnel forwardWebSocket(${req.path}) did not receive ws_open_ack within ${timeoutMs}ms.`
|
|
1044
|
+
)
|
|
1045
|
+
);
|
|
1046
|
+
}, timeoutMs);
|
|
1047
|
+
wsOpenPending.set(reqId, { resolve, reject, timer });
|
|
1048
|
+
opts.sink.send(frame);
|
|
1049
|
+
});
|
|
1050
|
+
},
|
|
356
1051
|
async close() {
|
|
357
1052
|
opts.sink.close("client.close");
|
|
358
1053
|
}
|
|
@@ -364,11 +1059,13 @@ var TunnelChildDuck = class extends EventEmitter {
|
|
|
364
1059
|
stdin;
|
|
365
1060
|
stdout;
|
|
366
1061
|
stderr;
|
|
1062
|
+
sink;
|
|
367
1063
|
exited = false;
|
|
368
1064
|
constructor(execId, pid, sink) {
|
|
369
1065
|
super();
|
|
370
1066
|
this.execId = execId;
|
|
371
1067
|
this.pid = pid > 0 ? pid : null;
|
|
1068
|
+
this.sink = sink;
|
|
372
1069
|
this.stdout = new Readable({ read() {
|
|
373
1070
|
} });
|
|
374
1071
|
this.stderr = new Readable({ read() {
|
|
@@ -391,10 +1088,6 @@ var TunnelChildDuck = class extends EventEmitter {
|
|
|
391
1088
|
cb();
|
|
392
1089
|
}
|
|
393
1090
|
});
|
|
394
|
-
Object.defineProperty(this, "__sink", {
|
|
395
|
-
value: sink,
|
|
396
|
-
enumerable: false
|
|
397
|
-
});
|
|
398
1091
|
}
|
|
399
1092
|
__pushStdout(buf) {
|
|
400
1093
|
this.stdout.push(buf);
|
|
@@ -426,14 +1119,104 @@ var TunnelChildDuck = class extends EventEmitter {
|
|
|
426
1119
|
}
|
|
427
1120
|
kill(signal = "SIGTERM") {
|
|
428
1121
|
if (this.exited) return false;
|
|
429
|
-
|
|
430
|
-
sink.send({
|
|
1122
|
+
this.sink.send({
|
|
431
1123
|
t: "kill",
|
|
432
1124
|
execId: this.execId,
|
|
433
1125
|
signal: typeof signal === "string" ? signal : `SIG${signal}`
|
|
434
1126
|
});
|
|
435
1127
|
return true;
|
|
436
1128
|
}
|
|
1129
|
+
resize(cols, rows) {
|
|
1130
|
+
if (this.exited) return;
|
|
1131
|
+
this.sink.send({ t: "resize", execId: this.execId, cols, rows });
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
var TunnelWebSocketDuck = class {
|
|
1135
|
+
protocol;
|
|
1136
|
+
_readyState = "open";
|
|
1137
|
+
reqId;
|
|
1138
|
+
sink;
|
|
1139
|
+
onCleanup;
|
|
1140
|
+
messageListeners = /* @__PURE__ */ new Set();
|
|
1141
|
+
closeListeners = /* @__PURE__ */ new Set();
|
|
1142
|
+
errorListeners = /* @__PURE__ */ new Set();
|
|
1143
|
+
constructor(reqId, protocol, sink, onCleanup) {
|
|
1144
|
+
this.reqId = reqId;
|
|
1145
|
+
this.protocol = protocol;
|
|
1146
|
+
this.sink = sink;
|
|
1147
|
+
this.onCleanup = onCleanup;
|
|
1148
|
+
}
|
|
1149
|
+
get readyState() {
|
|
1150
|
+
return this._readyState;
|
|
1151
|
+
}
|
|
1152
|
+
send(data) {
|
|
1153
|
+
if (this._readyState !== "open") return;
|
|
1154
|
+
const bytes = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
|
|
1155
|
+
this.sink.send({
|
|
1156
|
+
t: "ws_message",
|
|
1157
|
+
reqId: this.reqId,
|
|
1158
|
+
data: encodeData(bytes),
|
|
1159
|
+
binary: typeof data !== "string"
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
close(code = 1e3, reason) {
|
|
1163
|
+
if (this._readyState === "closed") return;
|
|
1164
|
+
if (this._readyState === "open") {
|
|
1165
|
+
this._readyState = "closing";
|
|
1166
|
+
this.sink.send({
|
|
1167
|
+
t: "ws_close",
|
|
1168
|
+
reqId: this.reqId,
|
|
1169
|
+
code,
|
|
1170
|
+
...reason ? { reason } : {}
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
this.__handleClose(code, reason ?? "");
|
|
1174
|
+
}
|
|
1175
|
+
onMessage(listener) {
|
|
1176
|
+
this.messageListeners.add(listener);
|
|
1177
|
+
return () => this.messageListeners.delete(listener);
|
|
1178
|
+
}
|
|
1179
|
+
onClose(listener) {
|
|
1180
|
+
this.closeListeners.add(listener);
|
|
1181
|
+
return () => this.closeListeners.delete(listener);
|
|
1182
|
+
}
|
|
1183
|
+
onError(listener) {
|
|
1184
|
+
this.errorListeners.add(listener);
|
|
1185
|
+
return () => this.errorListeners.delete(listener);
|
|
1186
|
+
}
|
|
1187
|
+
__handleMessage(data, isBinary) {
|
|
1188
|
+
if (this._readyState !== "open") return;
|
|
1189
|
+
for (const l of this.messageListeners) {
|
|
1190
|
+
try {
|
|
1191
|
+
l(data, isBinary);
|
|
1192
|
+
} catch {
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
__handleClose(code, reason) {
|
|
1197
|
+
if (this._readyState === "closed") return;
|
|
1198
|
+
this._readyState = "closed";
|
|
1199
|
+
this.onCleanup();
|
|
1200
|
+
for (const l of this.closeListeners) {
|
|
1201
|
+
try {
|
|
1202
|
+
l(code, reason);
|
|
1203
|
+
} catch {
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
this.messageListeners.clear();
|
|
1207
|
+
this.closeListeners.clear();
|
|
1208
|
+
this.errorListeners.clear();
|
|
1209
|
+
}
|
|
1210
|
+
__handleSinkClosed() {
|
|
1211
|
+
if (this._readyState === "closed") return;
|
|
1212
|
+
for (const l of this.errorListeners) {
|
|
1213
|
+
try {
|
|
1214
|
+
l(new Error("Tunnel closed mid-WS."));
|
|
1215
|
+
} catch {
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
this.__handleClose(1006, "tunnel_closed");
|
|
1219
|
+
}
|
|
437
1220
|
};
|
|
438
1221
|
|
|
439
1222
|
// src/tunnel/ws-adapter.ts
|