@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.
@@ -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: { pty: opts.pty === true },
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
- const sink = this.__sink;
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