@paperclipai/plugin-daytona 2026.824.0 → 2026.825.0-beta.1
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/duplex-command-stream.d.ts +97 -0
- package/dist/duplex-command-stream.d.ts.map +1 -0
- package/dist/duplex-command-stream.js +205 -0
- package/dist/duplex-command-stream.js.map +1 -0
- package/dist/duplex-command-stream.live.test.d.ts +2 -0
- package/dist/duplex-command-stream.live.test.d.ts.map +1 -0
- package/dist/duplex-command-stream.live.test.js +324 -0
- package/dist/duplex-command-stream.live.test.js.map +1 -0
- package/dist/duplex-command-stream.test.d.ts +2 -0
- package/dist/duplex-command-stream.test.d.ts.map +1 -0
- package/dist/duplex-command-stream.test.js +519 -0
- package/dist/duplex-command-stream.test.js.map +1 -0
- package/dist/file-sync.d.ts.map +1 -1
- package/dist/file-sync.js +93 -42
- package/dist/file-sync.js.map +1 -1
- package/dist/file-sync.test.d.ts +2 -0
- package/dist/file-sync.test.d.ts.map +1 -0
- package/dist/file-sync.test.js +127 -0
- package/dist/file-sync.test.js.map +1 -0
- package/dist/login-pty.d.ts +187 -0
- package/dist/login-pty.d.ts.map +1 -0
- package/dist/login-pty.js +389 -0
- package/dist/login-pty.js.map +1 -0
- package/dist/login-pty.test.d.ts +2 -0
- package/dist/login-pty.test.d.ts.map +1 -0
- package/dist/login-pty.test.js +496 -0
- package/dist/login-pty.test.js.map +1 -0
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +37 -8
- package/dist/manifest.js.map +1 -1
- package/dist/plugin.d.ts +4 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +151 -31
- package/dist/plugin.js.map +1 -1
- package/dist/plugin.test.d.ts +2 -0
- package/dist/plugin.test.d.ts.map +1 -0
- package/dist/plugin.test.js +4481 -0
- package/dist/plugin.test.js.map +1 -0
- package/dist/pty-chunked-input.d.ts +48 -0
- package/dist/pty-chunked-input.d.ts.map +1 -0
- package/dist/pty-chunked-input.js +74 -0
- package/dist/pty-chunked-input.js.map +1 -0
- package/dist/pty-chunked-input.test.d.ts +2 -0
- package/dist/pty-chunked-input.test.d.ts.map +1 -0
- package/dist/pty-chunked-input.test.js +115 -0
- package/dist/pty-chunked-input.test.js.map +1 -0
- package/dist/scripts/login-home-create.cjs +69 -0
- package/dist/scripts/login-home-inspect.cjs +66 -0
- package/package.json +2 -2
- package/dist/setup-token-pty.d.ts +0 -89
- package/dist/setup-token-pty.d.ts.map +0 -1
- package/dist/setup-token-pty.js +0 -115
- package/dist/setup-token-pty.js.map +0 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { DaytonaPtyProcess } from "./login-pty.js";
|
|
2
|
+
export type { DaytonaPtyCreateOptions, DaytonaPtyHandle, DaytonaPtyProcess, } from "./login-pty.js";
|
|
3
|
+
/**
|
|
4
|
+
* A live duplex channel session for one command. The session allocates a real
|
|
5
|
+
* pseudo-terminal in raw mode, streams the raw output, accepts host input, and
|
|
6
|
+
* stops the child. The shape matches the worker duplex channel session, so the
|
|
7
|
+
* worker forwards the data and the exit with no adapter.
|
|
8
|
+
*/
|
|
9
|
+
export interface DuplexChannelSession {
|
|
10
|
+
/** Registers the one data listener. The session streams each raw byte chunk in order. */
|
|
11
|
+
onData(listener: (chunk: Uint8Array) => void): void;
|
|
12
|
+
/** Writes raw input bytes to the pseudo-terminal. */
|
|
13
|
+
write(data: Uint8Array): void;
|
|
14
|
+
/**
|
|
15
|
+
* Resolves when the command ends or the transport closes. A numeric `exitCode`
|
|
16
|
+
* is a real process exit. `transportClosed` is true when the pseudo-terminal
|
|
17
|
+
* socket closed with no exit data, so the caller can tell a real process exit
|
|
18
|
+
* from a reason-less transport close.
|
|
19
|
+
*/
|
|
20
|
+
wait(): Promise<{
|
|
21
|
+
exitCode: number | null;
|
|
22
|
+
transportClosed: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
/** Stops the child process. Safe to call more than one time. */
|
|
25
|
+
kill(): void;
|
|
26
|
+
/** Releases the session resources. Safe to call more than one time. */
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Opens a {@link DuplexChannelSession} for `command`. The transport calls it one
|
|
31
|
+
* time. `command` is an argument vector: element 0 is the program and the rest
|
|
32
|
+
* are its arguments. The session quotes each element, so a shell metacharacter in
|
|
33
|
+
* an element cannot inject a shell command.
|
|
34
|
+
*/
|
|
35
|
+
export type DuplexChannelSessionOpener = (command: readonly string[]) => Promise<DuplexChannelSession>;
|
|
36
|
+
/**
|
|
37
|
+
* The typed, closed reason for a duplex channel write failure. The seam reports
|
|
38
|
+
* only this constant, never the raw provider error text. It maps to the host
|
|
39
|
+
* telemetry `write_error` loss reason.
|
|
40
|
+
*/
|
|
41
|
+
export declare const DAYTONA_DUPLEX_WRITE_ERROR_REASON: "write_error";
|
|
42
|
+
/** The typed reason a rejected host-to-sandbox write reports. */
|
|
43
|
+
export type DaytonaDuplexWriteErrorReason = typeof DAYTONA_DUPLEX_WRITE_ERROR_REASON;
|
|
44
|
+
/** The options for the Daytona duplex channel session. */
|
|
45
|
+
export interface DaytonaDuplexChannelOptions {
|
|
46
|
+
/** The working directory for the duplex channel PTY. Defaults to the sandbox default. */
|
|
47
|
+
cwd?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The absolute sandbox path for the gateway diagnostics. The wrapper redirects
|
|
50
|
+
* the terminal's stderr to this path, so a diagnostic line never reaches the
|
|
51
|
+
* stdout frame stream. Defaults to a per-channel path under `/tmp`.
|
|
52
|
+
*/
|
|
53
|
+
diagnosticsPath?: string;
|
|
54
|
+
/**
|
|
55
|
+
* The write-error seam. The session calls it one time when a host-to-sandbox
|
|
56
|
+
* `sendInput` rejects. The session then ends the channel at once. The seam
|
|
57
|
+
* carries only the typed {@link DaytonaDuplexWriteErrorReason}; the raw provider
|
|
58
|
+
* error never reaches it.
|
|
59
|
+
*/
|
|
60
|
+
onWriteError?: (reason: DaytonaDuplexWriteErrorReason) => void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Builds the launch wrapper input line for the duplex channel. `command` is an
|
|
64
|
+
* argument vector: element 0 is the program and the rest are its arguments.
|
|
65
|
+
*
|
|
66
|
+
* The wrapper does three steps in order:
|
|
67
|
+
* 1. `exec 2>'<diagnosticsPath>'` redirects the shell's stderr to the file. The
|
|
68
|
+
* later `exec` inherits it, so the gateway diagnostics land in the file and
|
|
69
|
+
* never on the stdout frame stream.
|
|
70
|
+
* 2. `stty raw -echo` sets the terminal to raw mode with echo off, so the
|
|
71
|
+
* terminal neither echoes host input as data nor translates newlines.
|
|
72
|
+
* 3. `exec '<program>' '<arg>'...` replaces the shell with the gateway, so the
|
|
73
|
+
* PTY runs the gateway directly and the gateway exit code becomes the PTY
|
|
74
|
+
* exit code.
|
|
75
|
+
*
|
|
76
|
+
* The wrapper quotes the diagnostics path and every command argument as a
|
|
77
|
+
* single-quoted shell word. So a shell metacharacter in a path or an argument
|
|
78
|
+
* stays literal text and cannot inject a shell command.
|
|
79
|
+
*/
|
|
80
|
+
export declare function buildDuplexChannelLaunchWrapper(command: readonly string[], diagnosticsPath: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* Opens a Daytona duplex channel PTY session for `command` and returns it as a
|
|
83
|
+
* {@link DuplexChannelSession}. The session allocates a real pseudo-terminal in
|
|
84
|
+
* raw mode, streams the raw output, accepts host input, and stops the child.
|
|
85
|
+
*
|
|
86
|
+
* The function forwards the terminal bytes unchanged. It buffers the output
|
|
87
|
+
* until the transport registers the listener, so no early chunk is lost.
|
|
88
|
+
*/
|
|
89
|
+
export declare function openDaytonaDuplexChannelSession(process: DaytonaPtyProcess, command: readonly string[], options?: DaytonaDuplexChannelOptions): Promise<DuplexChannelSession>;
|
|
90
|
+
/**
|
|
91
|
+
* Creates a {@link DuplexChannelSessionOpener} bound to a Daytona `process`. Pass
|
|
92
|
+
* `sandbox.process` from the Daytona SDK. The opener runs the gateway command on a
|
|
93
|
+
* raw pseudo-terminal, streams the output, accepts host input, and stops the child
|
|
94
|
+
* for a terminal state.
|
|
95
|
+
*/
|
|
96
|
+
export declare function createDaytonaDuplexChannelSessionOpener(process: DaytonaPtyProcess, options?: DaytonaDuplexChannelOptions): DuplexChannelSessionOpener;
|
|
97
|
+
//# sourceMappingURL=duplex-command-stream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duplex-command-stream.d.ts","sourceRoot":"","sources":["../src/duplex-command-stream.ts"],"names":[],"mappings":"AAmCA,OAAO,KAAK,EAGV,iBAAiB,EAClB,MAAM,gBAAgB,CAAC;AAExB,YAAY,EACV,uBAAuB,EACvB,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,gBAAgB,CAAC;AAIxB;;;;;GAKG;AACH,MAAM,WAAW,oBAAoB;IACnC,yFAAyF;IACzF,MAAM,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,GAAG,IAAI,CAAC;IACpD,qDAAqD;IACrD,KAAK,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAAC;IAC9B;;;;;OAKG;IACH,IAAI,IAAI,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,eAAe,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;IACvE,gEAAgE;IAChE,IAAI,IAAI,IAAI,CAAC;IACb,uEAAuE;IACvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED;;;;;GAKG;AACH,MAAM,MAAM,0BAA0B,GAAG,CACvC,OAAO,EAAE,SAAS,MAAM,EAAE,KACvB,OAAO,CAAC,oBAAoB,CAAC,CAAC;AAEnC;;;;GAIG;AACH,eAAO,MAAM,iCAAiC,EAAG,aAAsB,CAAC;AAExE,iEAAiE;AACjE,MAAM,MAAM,6BAA6B,GAAG,OAAO,iCAAiC,CAAC;AAErF,0DAA0D;AAC1D,MAAM,WAAW,2BAA2B;IAC1C,yFAAyF;IACzF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,YAAY,CAAC,EAAE,CAAC,MAAM,EAAE,6BAA6B,KAAK,IAAI,CAAC;CAChE;AAwBD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,+BAA+B,CAC7C,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,eAAe,EAAE,MAAM,GACtB,MAAM,CASR;AAED;;;;;;;GAOG;AACH,wBAAsB,+BAA+B,CACnD,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,SAAS,MAAM,EAAE,EAC1B,OAAO,CAAC,EAAE,2BAA2B,GACpC,OAAO,CAAC,oBAAoB,CAAC,CA2G/B;AAED;;;;;GAKG;AACH,wBAAgB,uCAAuC,CACrD,OAAO,EAAE,iBAAiB,EAC1B,OAAO,CAAC,EAAE,2BAA2B,GACpC,0BAA0B,CAG5B"}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// The Daytona duplex command stream for the sandbox callback bridge. The bridge
|
|
2
|
+
// needs one live bidirectional byte stream: the host writes request frames to the
|
|
3
|
+
// process, and the process writes response frames back, on one connection while
|
|
4
|
+
// the command runs. This module binds a Daytona pseudo-terminal (PTY) to a small
|
|
5
|
+
// session that the duplex transport consumes, so the gateway runs on a real
|
|
6
|
+
// terminal, streams its output, and receives host input.
|
|
7
|
+
//
|
|
8
|
+
// Primitive choice (the first acceptance criterion of the phase): the Daytona SDK
|
|
9
|
+
// proves two primitives. The ordinary session primitive (`createSession` plus
|
|
10
|
+
// `executeSessionCommand` plus `getSessionCommandLogs`) dispatches a command and
|
|
11
|
+
// tails its output, split into separate stdout and stderr streams. It exposes no
|
|
12
|
+
// call that writes bytes to a running command's stdin. The PTY primitive
|
|
13
|
+
// (`createPty`) exposes `sendInput` for host-to-process bytes and one `onData`
|
|
14
|
+
// callback for process-to-host bytes, on one live socket. Only the PTY carries a
|
|
15
|
+
// bidirectional stream on one connection, so this module uses the PTY. The
|
|
16
|
+
// characterization test in `duplex-command-stream.test.ts` records the evidence.
|
|
17
|
+
//
|
|
18
|
+
// Clean stream: a PTY echoes its input and translates newlines by default
|
|
19
|
+
// (`ICRNL` on input, `ONLCR` on output). The frames are newline-delimited JSON, so
|
|
20
|
+
// echo and newline translation corrupt the stream. The launch wrapper runs
|
|
21
|
+
// `stty raw -echo` before it starts the gateway, so the terminal becomes an 8-bit
|
|
22
|
+
// transparent path with no echo and no newline translation. The wrapper also
|
|
23
|
+
// redirects the gateway diagnostics to a file, so a diagnostic line never reaches
|
|
24
|
+
// the stdout frame stream. The wrapper starts the gateway with `exec`, so the PTY
|
|
25
|
+
// runs the gateway directly and the gateway exit code becomes the PTY exit code.
|
|
26
|
+
//
|
|
27
|
+
// Dependency boundary: this provider plugin ships standalone (the workspace
|
|
28
|
+
// excludes `packages/plugins/sandbox-providers/**`). So the module imports no
|
|
29
|
+
// workspace package. It reuses the narrow Daytona PTY surface that
|
|
30
|
+
// `login-pty.ts` declares (`DaytonaPtyProcess`, `DaytonaPtyHandle`,
|
|
31
|
+
// `DaytonaPtyCreateOptions`), so a caller passes `sandbox.process` with no
|
|
32
|
+
// adapter. The narrow surface keeps the module unit-testable with a fake PTY.
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
34
|
+
import { sendPtyInputInChunks } from "./pty-chunked-input.js";
|
|
35
|
+
/**
|
|
36
|
+
* The typed, closed reason for a duplex channel write failure. The seam reports
|
|
37
|
+
* only this constant, never the raw provider error text. It maps to the host
|
|
38
|
+
* telemetry `write_error` loss reason.
|
|
39
|
+
*/
|
|
40
|
+
export const DAYTONA_DUPLEX_WRITE_ERROR_REASON = "write_error";
|
|
41
|
+
// The terminal size for the duplex channel PTY. The channel carries bytes, not a
|
|
42
|
+
// visible UI, so a fixed standard size is enough. A fixed size keeps the launch
|
|
43
|
+
// deterministic.
|
|
44
|
+
const DUPLEX_CHANNEL_PTY_COLS = 120;
|
|
45
|
+
const DUPLEX_CHANNEL_PTY_ROWS = 30;
|
|
46
|
+
/**
|
|
47
|
+
* The input terminator that submits the launch wrapper line. The terminal reads
|
|
48
|
+
* the carriage return as the Enter key, so the shell runs the wrapper line.
|
|
49
|
+
*/
|
|
50
|
+
const PTY_COMMAND_TERMINATOR = "\r";
|
|
51
|
+
/**
|
|
52
|
+
* Quotes one value for a POSIX shell. The result is one single-quoted word, so
|
|
53
|
+
* the shell reads it as literal text and never expands or splits it. The function
|
|
54
|
+
* closes the quote, adds an escaped single quote, and reopens the quote for each
|
|
55
|
+
* single quote in the value.
|
|
56
|
+
*/
|
|
57
|
+
function shellQuote(value) {
|
|
58
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Builds the launch wrapper input line for the duplex channel. `command` is an
|
|
62
|
+
* argument vector: element 0 is the program and the rest are its arguments.
|
|
63
|
+
*
|
|
64
|
+
* The wrapper does three steps in order:
|
|
65
|
+
* 1. `exec 2>'<diagnosticsPath>'` redirects the shell's stderr to the file. The
|
|
66
|
+
* later `exec` inherits it, so the gateway diagnostics land in the file and
|
|
67
|
+
* never on the stdout frame stream.
|
|
68
|
+
* 2. `stty raw -echo` sets the terminal to raw mode with echo off, so the
|
|
69
|
+
* terminal neither echoes host input as data nor translates newlines.
|
|
70
|
+
* 3. `exec '<program>' '<arg>'...` replaces the shell with the gateway, so the
|
|
71
|
+
* PTY runs the gateway directly and the gateway exit code becomes the PTY
|
|
72
|
+
* exit code.
|
|
73
|
+
*
|
|
74
|
+
* The wrapper quotes the diagnostics path and every command argument as a
|
|
75
|
+
* single-quoted shell word. So a shell metacharacter in a path or an argument
|
|
76
|
+
* stays literal text and cannot inject a shell command.
|
|
77
|
+
*/
|
|
78
|
+
export function buildDuplexChannelLaunchWrapper(command, diagnosticsPath) {
|
|
79
|
+
if (command.length === 0) {
|
|
80
|
+
throw new Error("The duplex channel launch command needs at least one argument.");
|
|
81
|
+
}
|
|
82
|
+
const quotedCommand = command.map(shellQuote).join(" ");
|
|
83
|
+
return (`exec 2>${shellQuote(diagnosticsPath)}; stty raw -echo; ` +
|
|
84
|
+
`exec ${quotedCommand}${PTY_COMMAND_TERMINATOR}`);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Opens a Daytona duplex channel PTY session for `command` and returns it as a
|
|
88
|
+
* {@link DuplexChannelSession}. The session allocates a real pseudo-terminal in
|
|
89
|
+
* raw mode, streams the raw output, accepts host input, and stops the child.
|
|
90
|
+
*
|
|
91
|
+
* The function forwards the terminal bytes unchanged. It buffers the output
|
|
92
|
+
* until the transport registers the listener, so no early chunk is lost.
|
|
93
|
+
*/
|
|
94
|
+
export async function openDaytonaDuplexChannelSession(process, command, options) {
|
|
95
|
+
let listener = null;
|
|
96
|
+
let buffered = Buffer.alloc(0);
|
|
97
|
+
const diagnosticsPath = options?.diagnosticsPath ?? `/tmp/paperclip-duplex-${randomUUID()}.log`;
|
|
98
|
+
const handle = await process.createPty({
|
|
99
|
+
id: `paperclip-duplex-${randomUUID()}`,
|
|
100
|
+
...(options?.cwd ? { cwd: options.cwd } : {}),
|
|
101
|
+
cols: DUPLEX_CHANNEL_PTY_COLS,
|
|
102
|
+
rows: DUPLEX_CHANNEL_PTY_ROWS,
|
|
103
|
+
onData: (data) => {
|
|
104
|
+
// Forward the raw bytes unchanged; the frame codec owns the
|
|
105
|
+
// newline-delimited JSON parsing and any multi-byte character
|
|
106
|
+
// reassembly on the read side.
|
|
107
|
+
if (data.byteLength === 0)
|
|
108
|
+
return;
|
|
109
|
+
if (listener)
|
|
110
|
+
listener(data);
|
|
111
|
+
else
|
|
112
|
+
buffered = buffered.length === 0 ? Buffer.from(data) : Buffer.concat([buffered, data]);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
await handle.waitForConnection();
|
|
116
|
+
// Start the gateway through the raw-mode wrapper. Raw mode with echo off stops
|
|
117
|
+
// the terminal from echoing host input back as data and from translating the
|
|
118
|
+
// frame newlines. The diagnostics redirect keeps the stdout frame stream clean.
|
|
119
|
+
await handle.sendInput(buildDuplexChannelLaunchWrapper(command, diagnosticsPath));
|
|
120
|
+
// Report a host-to-sandbox write failure one time and end the channel at once.
|
|
121
|
+
// The seam carries only the typed reason; the raw provider error never leaves
|
|
122
|
+
// this scope. The channel end propagates the loss up through the exit.
|
|
123
|
+
//
|
|
124
|
+
// `channelTerminated` turns true on the first rejected chunk, before the early
|
|
125
|
+
// return, so a later queued write reads it and sends no chunk. Without the flag
|
|
126
|
+
// a queued write runs after the terminal kill and calls `sendInput` on the
|
|
127
|
+
// closed transport.
|
|
128
|
+
let channelTerminated = false;
|
|
129
|
+
let writeErrorReported = false;
|
|
130
|
+
const endOnWriteError = () => {
|
|
131
|
+
channelTerminated = true;
|
|
132
|
+
if (writeErrorReported)
|
|
133
|
+
return;
|
|
134
|
+
writeErrorReported = true;
|
|
135
|
+
options?.onWriteError?.(DAYTONA_DUPLEX_WRITE_ERROR_REASON);
|
|
136
|
+
void handle.kill().catch(() => undefined);
|
|
137
|
+
};
|
|
138
|
+
// The tail of the write chain. The chunker awaits each chunk, so one write can
|
|
139
|
+
// suspend between its chunks. The chain runs each write after the previous
|
|
140
|
+
// write ends, so the chunks of two writes never interleave on the wire. The
|
|
141
|
+
// chain never rejects, because the chunker maps a rejected send to
|
|
142
|
+
// `endOnWriteError` and returns.
|
|
143
|
+
let writeChain = Promise.resolve();
|
|
144
|
+
return {
|
|
145
|
+
onData(next) {
|
|
146
|
+
listener = next;
|
|
147
|
+
if (buffered.length > 0) {
|
|
148
|
+
const pending = buffered;
|
|
149
|
+
buffered = Buffer.alloc(0);
|
|
150
|
+
next(pending);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
write(data) {
|
|
154
|
+
// Send the input as byte-bounded chunks under the provider message cap. A
|
|
155
|
+
// whole payload in one message can cross the cap and take the channel down,
|
|
156
|
+
// so the chunker slices the payload and sends each chunk in order. The chain
|
|
157
|
+
// runs this write after the previous write ends, so two writes keep their
|
|
158
|
+
// order and never interleave their chunks. A write error must not throw into
|
|
159
|
+
// the transport, so the transport's stream stays the single result path. On
|
|
160
|
+
// a rejected chunk, the chunker ends the channel one time through
|
|
161
|
+
// `endOnWriteError` and sends no later chunk. The raw provider error never
|
|
162
|
+
// reaches a sink.
|
|
163
|
+
//
|
|
164
|
+
// A rejected chunk terminalizes the channel and kills the transport. So this
|
|
165
|
+
// write skips the send when `channelTerminated` is true, and a queued write
|
|
166
|
+
// never calls `sendInput` on the closed transport.
|
|
167
|
+
writeChain = writeChain.then(() => {
|
|
168
|
+
if (channelTerminated)
|
|
169
|
+
return;
|
|
170
|
+
return sendPtyInputInChunks((chunk) => handle.sendInput(chunk), data, endOnWriteError);
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
async wait() {
|
|
174
|
+
const result = await handle.wait();
|
|
175
|
+
if (typeof result.exitCode === "number") {
|
|
176
|
+
// A numeric exit code is a real process exit. The SDK parses it from the
|
|
177
|
+
// pseudo-terminal WebSocket close reason (for example `{"exitCode":0}`).
|
|
178
|
+
return { exitCode: result.exitCode, transportClosed: false };
|
|
179
|
+
}
|
|
180
|
+
// A non-numeric exit code marks a reason-less transport close: the socket
|
|
181
|
+
// closed with no exit data. It is a transport close, not a process exit.
|
|
182
|
+
return { exitCode: null, transportClosed: true };
|
|
183
|
+
},
|
|
184
|
+
kill() {
|
|
185
|
+
void handle.kill().catch(() => undefined);
|
|
186
|
+
},
|
|
187
|
+
async close() {
|
|
188
|
+
// Stop the child and release the socket. `kill` ends the gateway and `wait`
|
|
189
|
+
// resolves with the exit; `disconnect` releases the PTY socket. Both are
|
|
190
|
+
// safe to call more than one time, so close stays idempotent.
|
|
191
|
+
await handle.kill().catch(() => undefined);
|
|
192
|
+
await handle.disconnect().catch(() => undefined);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Creates a {@link DuplexChannelSessionOpener} bound to a Daytona `process`. Pass
|
|
198
|
+
* `sandbox.process` from the Daytona SDK. The opener runs the gateway command on a
|
|
199
|
+
* raw pseudo-terminal, streams the output, accepts host input, and stops the child
|
|
200
|
+
* for a terminal state.
|
|
201
|
+
*/
|
|
202
|
+
export function createDaytonaDuplexChannelSessionOpener(process, options) {
|
|
203
|
+
return (command) => openDaytonaDuplexChannelSession(process, command, options);
|
|
204
|
+
}
|
|
205
|
+
//# sourceMappingURL=duplex-command-stream.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duplex-command-stream.js","sourceRoot":"","sources":["../src/duplex-command-stream.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"duplex-command-stream.live.test.d.ts","sourceRoot":"","sources":["../src/duplex-command-stream.live.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Live characterization of the Daytona duplex channel against the real provider.
|
|
2
|
+
//
|
|
3
|
+
// The test is credential-gated by design. It runs only when the run shell holds a
|
|
4
|
+
// Daytona API key, and it skips cleanly when the key is absent. The key comes from
|
|
5
|
+
// `DAYTONA_API_KEY`, which the plugin also reads as the credential fallback
|
|
6
|
+
// (`plugin.ts`). A skip is acceptable: the merged live coverage re-runs at a later
|
|
7
|
+
// phase and at rollout.
|
|
8
|
+
//
|
|
9
|
+
// What it proves on one live sandbox:
|
|
10
|
+
// 1. The duplex channel and an ordinary command run at the same time.
|
|
11
|
+
// 2. Host input sent after the child starts arrives, with no terminal echo and
|
|
12
|
+
// no frame corruption.
|
|
13
|
+
// 3. Channel teardown leaves no leaked provider session.
|
|
14
|
+
//
|
|
15
|
+
// The channel runs `cat` as the child. `cat` copies its input to its output, so a
|
|
16
|
+
// host write returns one time as program output. The launch wrapper sets the
|
|
17
|
+
// pseudo-terminal to raw mode with echo off, so the terminal adds no second copy
|
|
18
|
+
// and no newline translation. A returned line that equals the sent line, one time,
|
|
19
|
+
// proves both the delivery and the clean stream.
|
|
20
|
+
//
|
|
21
|
+
// The Daytona SDK is a runtime dependency of this live test only. The module loads
|
|
22
|
+
// it with a dynamic import inside the gated block, so the file imports with no SDK
|
|
23
|
+
// present and the other Daytona tests still run without it.
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
import http2 from "node:http2";
|
|
27
|
+
import { Duplex } from "node:stream";
|
|
28
|
+
import { openDaytonaDuplexChannelSession, } from "./duplex-command-stream.js";
|
|
29
|
+
const DAYTONA_API_KEY = process.env.DAYTONA_API_KEY?.trim() ?? "";
|
|
30
|
+
const HAS_DAYTONA_CREDENTIAL = DAYTONA_API_KEY.length > 0;
|
|
31
|
+
const describeLive = HAS_DAYTONA_CREDENTIAL ? describe : describe.skip;
|
|
32
|
+
if (!HAS_DAYTONA_CREDENTIAL) {
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.warn("Skipping the Daytona duplex channel live test: DAYTONA_API_KEY is not set in the run shell.");
|
|
35
|
+
}
|
|
36
|
+
// A live sandbox create and destroy needs a generous timeout. The provider round
|
|
37
|
+
// trip dominates the wall-clock time.
|
|
38
|
+
const LIVE_TIMEOUT_MS = 180_000;
|
|
39
|
+
/**
|
|
40
|
+
* Wait until `predicate(text)` is true for the running output, or reject after
|
|
41
|
+
* `timeoutMs`. The poll interval is short, so the wait ends soon after the output
|
|
42
|
+
* arrives.
|
|
43
|
+
*/
|
|
44
|
+
async function waitFor(readOutput, predicate, timeoutMs, label) {
|
|
45
|
+
const deadline = Date.now() + timeoutMs;
|
|
46
|
+
for (;;) {
|
|
47
|
+
if (predicate(readOutput()))
|
|
48
|
+
return;
|
|
49
|
+
if (Date.now() > deadline) {
|
|
50
|
+
throw new Error(`Timed out waiting for ${label}. Output so far: ${JSON.stringify(readOutput())}`);
|
|
51
|
+
}
|
|
52
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Open a `cat` duplex channel and collect its output into a growing string. */
|
|
56
|
+
async function openCatChannel(sandbox) {
|
|
57
|
+
let output = "";
|
|
58
|
+
const session = await openDaytonaDuplexChannelSession(sandbox.process, ["cat"]);
|
|
59
|
+
session.onData((chunk) => {
|
|
60
|
+
output += chunk;
|
|
61
|
+
});
|
|
62
|
+
return { session, readOutput: () => output };
|
|
63
|
+
}
|
|
64
|
+
describeLive("Daytona duplex channel (live)", () => {
|
|
65
|
+
let sandbox = null;
|
|
66
|
+
beforeAll(async () => {
|
|
67
|
+
const { Daytona } = await import("@daytonaio/sdk");
|
|
68
|
+
const client = new Daytona({ apiKey: DAYTONA_API_KEY });
|
|
69
|
+
sandbox = (await client.create());
|
|
70
|
+
}, LIVE_TIMEOUT_MS);
|
|
71
|
+
afterAll(async () => {
|
|
72
|
+
await sandbox?.delete().catch(() => undefined);
|
|
73
|
+
}, LIVE_TIMEOUT_MS);
|
|
74
|
+
it("runs the duplex channel and an ordinary command on one live sandbox", async () => {
|
|
75
|
+
const live = sandbox;
|
|
76
|
+
const { session, readOutput } = await openCatChannel(live);
|
|
77
|
+
try {
|
|
78
|
+
// Run an ordinary command while the channel child stays alive. Both use the
|
|
79
|
+
// same live sandbox, so a success on both proves they coexist.
|
|
80
|
+
const token = `ordinary-${randomUUID()}`;
|
|
81
|
+
const ordinary = await live.process.executeCommand(`printf %s ${token}`);
|
|
82
|
+
expect(ordinary.exitCode ?? 0).toBe(0);
|
|
83
|
+
expect(ordinary.result ?? "").toContain(token);
|
|
84
|
+
// The channel child still answers. A write returns as program output.
|
|
85
|
+
const ping = `chan-${randomUUID()}`;
|
|
86
|
+
session.write(Buffer.from(`${ping}\n`));
|
|
87
|
+
await waitFor(readOutput, (text) => text.includes(ping), 30_000, "channel echo");
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await session.close();
|
|
91
|
+
}
|
|
92
|
+
}, LIVE_TIMEOUT_MS);
|
|
93
|
+
it("delivers host input sent after startup with no echo and no frame corruption", async () => {
|
|
94
|
+
const live = sandbox;
|
|
95
|
+
const { session, readOutput } = await openCatChannel(live);
|
|
96
|
+
try {
|
|
97
|
+
// Let the launch wrapper run `stty raw -echo` before the host input. The
|
|
98
|
+
// wrapper output echoes in cooked mode, so wait for the raw-mode switch
|
|
99
|
+
// before the measured write.
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 4_000));
|
|
101
|
+
const baseline = readOutput().length;
|
|
102
|
+
const line = `PING-${randomUUID()}`;
|
|
103
|
+
session.write(Buffer.from(`${line}\n`));
|
|
104
|
+
await waitFor(readOutput, (text) => text.slice(baseline).includes(line), 30_000, "delayed host input");
|
|
105
|
+
// Only `cat` writes the line, one time. Raw mode with echo off adds no
|
|
106
|
+
// second copy, so the sent token appears exactly one time after the write.
|
|
107
|
+
const afterWrite = readOutput().slice(baseline);
|
|
108
|
+
const occurrences = afterWrite.split(line).length - 1;
|
|
109
|
+
expect(occurrences).toBe(1);
|
|
110
|
+
// The stream carries the frame bytes with no newline translation. The
|
|
111
|
+
// returned line keeps its single line-feed and gains no carriage return.
|
|
112
|
+
expect(afterWrite).toContain(`${line}\n`);
|
|
113
|
+
expect(afterWrite).not.toContain(`${line}\r`);
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await session.close();
|
|
117
|
+
}
|
|
118
|
+
}, LIVE_TIMEOUT_MS);
|
|
119
|
+
it("leaves no leaked provider session after channel close", async () => {
|
|
120
|
+
const live = sandbox;
|
|
121
|
+
const listSessions = live.process.listSessions?.bind(live.process);
|
|
122
|
+
const baseline = listSessions ? (await listSessions()).length : 0;
|
|
123
|
+
const { session } = await openCatChannel(live);
|
|
124
|
+
await session.close();
|
|
125
|
+
if (listSessions) {
|
|
126
|
+
// The channel uses a pseudo-terminal, not an ordinary session. So the
|
|
127
|
+
// session list stays at the baseline. A leaked session would raise the
|
|
128
|
+
// count.
|
|
129
|
+
const after = (await listSessions()).length;
|
|
130
|
+
expect(after).toBe(baseline);
|
|
131
|
+
}
|
|
132
|
+
// The child exits after close, so a fresh ordinary command still succeeds on
|
|
133
|
+
// the same sandbox. A leaked channel would block or corrupt the sandbox.
|
|
134
|
+
const token = `after-close-${randomUUID()}`;
|
|
135
|
+
const probe = await live.process.executeCommand(`printf %s ${token}`);
|
|
136
|
+
expect(probe.exitCode ?? 0).toBe(0);
|
|
137
|
+
expect(probe.result ?? "").toContain(token);
|
|
138
|
+
}, LIVE_TIMEOUT_MS);
|
|
139
|
+
it("serves repeated channel round trips with no idle polling and no per-request session growth", async () => {
|
|
140
|
+
const live = sandbox;
|
|
141
|
+
const listSessions = live.process.listSessions?.bind(live.process);
|
|
142
|
+
const { session, readOutput } = await openCatChannel(live);
|
|
143
|
+
try {
|
|
144
|
+
// Wait for the raw-mode switch, then take the open baseline. The channel
|
|
145
|
+
// uses a pseudo-terminal, not an ordinary session, so the session list
|
|
146
|
+
// stays flat through the whole exchange.
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 4_000));
|
|
148
|
+
const openSessions = listSessions ? (await listSessions()).length : 0;
|
|
149
|
+
// Send a batch of request-shaped lines over the one open channel and
|
|
150
|
+
// measure the round-trip latency of each. The transport writes no queue
|
|
151
|
+
// file and runs no exec per line, so the round trip is the stream latency.
|
|
152
|
+
const latenciesMs = [];
|
|
153
|
+
const rounds = 10;
|
|
154
|
+
for (let index = 0; index < rounds; index += 1) {
|
|
155
|
+
const line = `RTT-${index}-${randomUUID()}`;
|
|
156
|
+
const baseline = readOutput().length;
|
|
157
|
+
const start = Date.now();
|
|
158
|
+
session.write(Buffer.from(`${line}\n`));
|
|
159
|
+
await waitFor(readOutput, (text) => text.slice(baseline).includes(line), 30_000, "channel round trip");
|
|
160
|
+
latenciesMs.push(Date.now() - start);
|
|
161
|
+
}
|
|
162
|
+
// Hold the channel idle. A polling transport would run a provider exec on
|
|
163
|
+
// each tick and raise the session count. The duplex channel polls nothing.
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
|
165
|
+
if (listSessions) {
|
|
166
|
+
const afterSessions = (await listSessions()).length;
|
|
167
|
+
// Zero idle polls and zero per-request session growth: the session count
|
|
168
|
+
// never rose above the open baseline.
|
|
169
|
+
expect(afterSessions).toBe(openSessions);
|
|
170
|
+
}
|
|
171
|
+
const min = Math.min(...latenciesMs);
|
|
172
|
+
const max = Math.max(...latenciesMs);
|
|
173
|
+
const avg = Math.round(latenciesMs.reduce((sum, value) => sum + value, 0) / latenciesMs.length);
|
|
174
|
+
// eslint-disable-next-line no-console
|
|
175
|
+
console.log(`[duplex-live] channel round-trip latency over ${rounds} requests: min=${min}ms avg=${avg}ms max=${max}ms; idle-poll session growth=0; per-request session growth=0`);
|
|
176
|
+
expect(latenciesMs.length).toBe(rounds);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
await session.close();
|
|
180
|
+
}
|
|
181
|
+
}, LIVE_TIMEOUT_MS);
|
|
182
|
+
it("leaves no leaked session and keeps the sandbox usable after a forced mid-flight disconnect", async () => {
|
|
183
|
+
const live = sandbox;
|
|
184
|
+
const listSessions = live.process.listSessions?.bind(live.process);
|
|
185
|
+
const baseline = listSessions ? (await listSessions()).length : 0;
|
|
186
|
+
const { session, readOutput } = await openCatChannel(live);
|
|
187
|
+
// Wait for the raw-mode switch, then start a write and force a disconnect
|
|
188
|
+
// before its echo returns. The abrupt close models a lost provider channel.
|
|
189
|
+
await new Promise((resolve) => setTimeout(resolve, 4_000));
|
|
190
|
+
const inFlight = `INFLIGHT-${randomUUID()}`;
|
|
191
|
+
session.write(Buffer.from(`${inFlight}\n`));
|
|
192
|
+
// Close at once, without waiting for the echo. The pending round trip never
|
|
193
|
+
// settles through the stream; the channel tears down instead.
|
|
194
|
+
await session.close();
|
|
195
|
+
if (listSessions) {
|
|
196
|
+
// The forced disconnect left no leaked provider session. The count is back
|
|
197
|
+
// at the baseline that preceded the channel.
|
|
198
|
+
const after = (await listSessions()).length;
|
|
199
|
+
expect(after).toBe(baseline);
|
|
200
|
+
}
|
|
201
|
+
// Do not read the in-flight echo; the disconnect settled the run. Reference
|
|
202
|
+
// the output length only to keep the reader wired.
|
|
203
|
+
expect(readOutput().length).toBeGreaterThanOrEqual(0);
|
|
204
|
+
// The sandbox stays usable: a fresh ordinary command still succeeds. A
|
|
205
|
+
// leaked channel would block or corrupt the sandbox.
|
|
206
|
+
const token = `post-disconnect-${randomUUID()}`;
|
|
207
|
+
const probe = await live.process.executeCommand(`printf %s ${token}`);
|
|
208
|
+
expect(probe.exitCode ?? 0).toBe(0);
|
|
209
|
+
expect(probe.result ?? "").toContain(token);
|
|
210
|
+
// eslint-disable-next-line no-console
|
|
211
|
+
console.log("[duplex-live] forced mid-flight disconnect: leaked sessions=0; sandbox usable after=yes");
|
|
212
|
+
}, LIVE_TIMEOUT_MS);
|
|
213
|
+
it("test_live_daytona_run_uses_http2_v1_end_to_end", async () => {
|
|
214
|
+
// Phase 4 selects http2_v1 by running one Node HTTP/2 session directly
|
|
215
|
+
// on this same pseudo-terminal channel, right after the READY line.
|
|
216
|
+
// This package ships standalone (see the file header), so it cannot
|
|
217
|
+
// import the host readiness gate or the preface scan from
|
|
218
|
+
// `@paperclipai/adapter-utils`; this test reimplements the minimal,
|
|
219
|
+
// self-contained version of both, using only `node:http2`, so the
|
|
220
|
+
// proof runs against a real Daytona PTY end to end.
|
|
221
|
+
const live = sandbox;
|
|
222
|
+
const nonce = randomUUID();
|
|
223
|
+
// The 24-octet HTTP/2 client connection preface (RFC 9113, Section 3.4).
|
|
224
|
+
const CLIENT_PREFACE = Buffer.from("505249202a20485454502f322e300d0a0d0a534d0d0a0d0a", "hex");
|
|
225
|
+
// The sandbox-side child: send the READY line, then hand stdin/stdout to
|
|
226
|
+
// a real HTTP/2 client session and dispatch one request — the same
|
|
227
|
+
// shape `runHttp2Gateway()` in `sandbox-callback-bridge.ts` runs.
|
|
228
|
+
const nodeScript = [
|
|
229
|
+
`process.stdout.write(JSON.stringify({version:2,type:"ready",nonce:${JSON.stringify(nonce)}})+"\\n");`,
|
|
230
|
+
`const http2=require("node:http2");`,
|
|
231
|
+
`const {Duplex}=require("node:stream");`,
|
|
232
|
+
`const stdio=new Duplex({read(){},write(chunk,enc,cb){const ok=process.stdout.write(chunk);if(ok)cb();else process.stdout.once("drain",cb);}});`,
|
|
233
|
+
`process.stdin.on("data",c=>stdio.push(c));`,
|
|
234
|
+
`process.stdin.on("end",()=>stdio.push(null));`,
|
|
235
|
+
`const session=http2.connect("http://bridge.internal",{createConnection:()=>stdio});`,
|
|
236
|
+
`session.on("error",()=>process.exit(1));`,
|
|
237
|
+
`const stream=session.request({":method":"GET",":path":"/ping"});`,
|
|
238
|
+
`let body="";`,
|
|
239
|
+
`stream.on("data",c=>{body+=c;});`,
|
|
240
|
+
`stream.on("error",()=>process.exit(3));`,
|
|
241
|
+
`stream.on("end",()=>{session.close(()=>process.exit(body==="pong"?0:2));});`,
|
|
242
|
+
`stream.end();`,
|
|
243
|
+
].join("");
|
|
244
|
+
const session = await openDaytonaDuplexChannelSession(live.process, ["node", "-e", nodeScript]);
|
|
245
|
+
try {
|
|
246
|
+
// Reads bytes from the channel until it finds one complete newline-
|
|
247
|
+
// terminated READY line, then hands every later byte — including any
|
|
248
|
+
// already-buffered suffix of the same chunk — to `onAfterReady`.
|
|
249
|
+
const readyAndAfter = await new Promise((resolve, reject) => {
|
|
250
|
+
let buffer = Buffer.alloc(0);
|
|
251
|
+
const timer = setTimeout(() => reject(new Error(`Timed out waiting for the READY line. Bytes so far: ${buffer.length}`)), 30_000);
|
|
252
|
+
session.onData((chunk) => {
|
|
253
|
+
buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
|
254
|
+
const newlineIndex = buffer.indexOf(0x0a);
|
|
255
|
+
if (newlineIndex === -1)
|
|
256
|
+
return;
|
|
257
|
+
clearTimeout(timer);
|
|
258
|
+
const line = buffer.subarray(0, newlineIndex).toString("utf8");
|
|
259
|
+
const decoded = JSON.parse(line);
|
|
260
|
+
resolve({ nonce: decoded.nonce ?? "", afterReady: Buffer.from(buffer.subarray(newlineIndex + 1)) });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
expect(readyAndAfter.nonce).toBe(nonce);
|
|
264
|
+
// Scan the retained suffix for the client preface, the same rule
|
|
265
|
+
// `createHttp2PrefaceScanningChannel` in `execution-target.ts` applies:
|
|
266
|
+
// the scan window opens only on bytes after the accepted READY line.
|
|
267
|
+
let sawPreface = false;
|
|
268
|
+
let downstream = null;
|
|
269
|
+
let pendingAfterPreface = Buffer.alloc(0);
|
|
270
|
+
let scanBuffer = readyAndAfter.afterReady;
|
|
271
|
+
function deliver(chunk) {
|
|
272
|
+
if (downstream)
|
|
273
|
+
downstream(chunk);
|
|
274
|
+
else
|
|
275
|
+
pendingAfterPreface = Buffer.concat([pendingAfterPreface, chunk]);
|
|
276
|
+
}
|
|
277
|
+
function handleChunk(chunk) {
|
|
278
|
+
if (sawPreface) {
|
|
279
|
+
deliver(chunk);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
scanBuffer = Buffer.concat([scanBuffer, chunk]);
|
|
283
|
+
const offset = scanBuffer.indexOf(CLIENT_PREFACE);
|
|
284
|
+
if (offset === -1)
|
|
285
|
+
return;
|
|
286
|
+
sawPreface = true;
|
|
287
|
+
const fromPreface = Buffer.from(scanBuffer.subarray(offset));
|
|
288
|
+
scanBuffer = Buffer.alloc(0);
|
|
289
|
+
deliver(fromPreface);
|
|
290
|
+
}
|
|
291
|
+
// The already-retained suffix might already hold the preface.
|
|
292
|
+
handleChunk(Buffer.alloc(0));
|
|
293
|
+
session.onData((chunk) => handleChunk(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
|
|
294
|
+
// Wrap the channel as a Node `Duplex` starting at the preface offset,
|
|
295
|
+
// and bind one plaintext HTTP/2 server session on it.
|
|
296
|
+
const boundDuplex = new Duplex({
|
|
297
|
+
read() { },
|
|
298
|
+
write(chunk, _encoding, callback) {
|
|
299
|
+
session.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
300
|
+
callback();
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
downstream = (chunk) => boundDuplex.push(chunk);
|
|
304
|
+
if (pendingAfterPreface.length > 0) {
|
|
305
|
+
boundDuplex.push(pendingAfterPreface);
|
|
306
|
+
pendingAfterPreface = Buffer.alloc(0);
|
|
307
|
+
}
|
|
308
|
+
const server = http2.createServer();
|
|
309
|
+
server.on("stream", (stream) => {
|
|
310
|
+
stream.respond({ ":status": 200 });
|
|
311
|
+
stream.end("pong");
|
|
312
|
+
});
|
|
313
|
+
server.emit("connection", boundDuplex);
|
|
314
|
+
const exit = await session.wait();
|
|
315
|
+
expect(exit.exitCode).toBe(0);
|
|
316
|
+
// eslint-disable-next-line no-console
|
|
317
|
+
console.log("[duplex-live] http2_v1: READY, then the real client preface, then one full HTTP/2 round trip, all over one live Daytona PTY.");
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
await session.close();
|
|
321
|
+
}
|
|
322
|
+
}, LIVE_TIMEOUT_MS);
|
|
323
|
+
});
|
|
324
|
+
//# sourceMappingURL=duplex-command-stream.live.test.js.map
|