char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somwindows: add terminal support via ConPTY + PowerShell0853dde

main
9.5 KiB289 linesraw
1import type { Context } from "@earendil-works/chord";
2import type { Pty, PtyOptions } from "./environment.ts";
3
4const EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
5const CREATE_UNICODE_ENVIRONMENT = 0x00000400;
6const STARTF_USESTDHANDLES = 0x00000100;
7const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = 0x20016n;
8const INFINITE = 0xffffffff;
9
10let kernel: ReturnType<typeof openKernel> | undefined;
11
12function openKernel() {
13  return Deno.dlopen("kernel32.dll", {
14    CreatePipe: { parameters: ["buffer", "buffer", "pointer", "u32"], result: "i32" },
15    CloseHandle: { parameters: ["pointer"], result: "i32" },
16    GetLastError: { parameters: [], result: "u32" },
17    CreatePseudoConsole: {
18      parameters: ["u32", "pointer", "pointer", "u32", "buffer"],
19      result: "i32",
20    },
21    ResizePseudoConsole: { parameters: ["pointer", "u32"], result: "i32" },
22    // closing can wait for output to drain on Windows versions before 11 24H2
23    ClosePseudoConsole: { parameters: ["pointer"], result: "void", nonblocking: true },
24    InitializeProcThreadAttributeList: {
25      parameters: ["buffer", "u32", "u32", "buffer"],
26      result: "i32",
27    },
28    UpdateProcThreadAttribute: {
29      parameters: ["buffer", "u32", "usize", "pointer", "usize", "pointer", "pointer"],
30      result: "i32",
31    },
32    DeleteProcThreadAttributeList: { parameters: ["buffer"], result: "void" },
33    CreateProcessW: {
34      parameters: [
35        "pointer",
36        "buffer",
37        "pointer",
38        "pointer",
39        "i32",
40        "u32",
41        "buffer",
42        "buffer",
43        "buffer",
44        "buffer",
45      ],
46      result: "i32",
47    },
48    WaitForSingleObject: { parameters: ["pointer", "u32"], result: "u32", nonblocking: true },
49    GetExitCodeProcess: { parameters: ["pointer", "buffer"], result: "i32" },
50    TerminateProcess: { parameters: ["pointer", "u32"], result: "i32" },
51    ReadFile: {
52      parameters: ["pointer", "buffer", "u32", "buffer", "pointer"],
53      result: "i32",
54      nonblocking: true,
55    },
56    WriteFile: {
57      parameters: ["pointer", "buffer", "u32", "buffer", "pointer"],
58      result: "i32",
59      nonblocking: true,
60    },
61  }).symbols;
62}
63
64function wide(text: string): Uint16Array {
65  const buffer = new Uint16Array(text.length + 1);
66  for (let i = 0; i < text.length; i++) buffer[i] = text.charCodeAt(i);
67  return buffer;
68}
69
70function size(cols: number, rows: number): number {
71  if (![cols, rows].every((n) => Number.isInteger(n) && n > 0 && n <= 32767)) {
72    throw new Error("Terminal dimensions must be between 1 and 32767");
73  }
74  // COORD is two signed shorts, passed by value
75  return (rows << 16) | cols;
76}
77
78/** A native ConPTY session on Windows 10 1809 or newer, with UTF-8 terminal I/O. */
79export async function windowsPty(
80  argv: readonly string[],
81  { cwd, env, cols, rows }: PtyOptions,
82  context: Context,
83): Promise<Pty> {
84  context.abortSignal?.throwIfAborted();
85  if (!argv.length || [...argv, cwd ?? ""].some((s) => s.includes("\0"))) {
86    throw new Error("Expected a program and arguments without NUL characters");
87  }
88  const dimensions = size(cols, rows);
89  const c = kernel ??= openKernel();
90  const fail = (operation: string) =>
91    new Error(`${operation} failed (Windows error ${c.GetLastError()})`);
92  const pointer = (handle: bigint) => Deno.UnsafePointer.create(handle);
93  const handles = new Set<bigint>();
94  const closeHandle = (handle: bigint) => {
95    if (handles.delete(handle)) c.CloseHandle(pointer(handle));
96  };
97  const pipe = () => {
98    const read = new BigUint64Array(1);
99    const write = new BigUint64Array(1);
100    if (!c.CreatePipe(read, write, null, 0)) throw fail("CreatePipe");
101    handles.add(read[0]);
102    handles.add(write[0]);
103    return [read[0], write[0]];
104  };
105  const console = new BigUint64Array(1);
106  let input: bigint;
107  let output: bigint;
108  let process: bigint;
109  try {
110    const [inputRead, inputWrite] = pipe();
111    const [outputRead, outputWrite] = pipe();
112    input = inputWrite;
113    output = outputRead;
114    const hr = c.CreatePseudoConsole(
115      dimensions,
116      pointer(inputRead),
117      pointer(outputWrite),
118      0,
119      console,
120    );
121    if (hr < 0) {
122      throw new Error(`CreatePseudoConsole failed (HRESULT 0x${(hr >>> 0).toString(16)})`);
123    }
124
125    const bytes = new BigUint64Array(1);
126    c.InitializeProcThreadAttributeList(null, 1, 0, bytes);
127    const attributes = new Uint8Array(Number(bytes[0]));
128    if (!c.InitializeProcThreadAttributeList(attributes, 1, 0, bytes)) {
129      throw fail("InitializeProcThreadAttributeList");
130    }
131    try {
132      if (
133        !c.UpdateProcThreadAttribute(
134          attributes,
135          0,
136          PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
137          pointer(console[0]),
138          8n,
139          null,
140          null,
141        )
142      ) {
143        throw fail("UpdateProcThreadAttribute");
144      }
145      // STARTUPINFOEXW and PROCESS_INFORMATION have the same layout on x64 and arm64
146      const startup = new Uint8Array(112);
147      const view = new DataView(startup.buffer);
148      view.setUint32(0, startup.byteLength, true);
149      // explicit null standard handles bind to ConPTY instead of inheriting the worker's protocol pipes
150      view.setUint32(60, STARTF_USESTDHANDLES, true);
151      view.setBigUint64(
152        104,
153        BigInt(Deno.UnsafePointer.value(Deno.UnsafePointer.of(attributes))),
154        true,
155      );
156      const info = new BigUint64Array(3);
157      const variables = new Map<string, [string, string]>();
158      for (
159        const [key, value] of Object.entries(Deno.env.toObject()).concat(Object.entries(env ?? {}))
160      ) {
161        if (key.includes("\0") || value.includes("\0")) {
162          throw new Error("NUL in process environment");
163        }
164        variables.set(key.toUpperCase(), [key, value]);
165      }
166      const block = [...variables].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
167        .map(([, [key, value]]) => `${key}=${value}`).join("\0") + "\0";
168      // preserve quotes and their preceding backslashes through Windows command-line parsing
169      const command = argv.map((arg) =>
170        `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`
171      ).join(" ");
172      if (
173        !c.CreateProcessW(
174          null,
175          wide(command),
176          null,
177          null,
178          0,
179          EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT,
180          wide(block),
181          cwd === undefined ? null : wide(cwd),
182          startup,
183          info,
184        )
185      ) throw fail(`Could not start ${argv[0]}`);
186      process = info[0];
187      handles.add(process);
188      c.CloseHandle(pointer(info[1]));
189    } finally {
190      c.DeleteProcThreadAttributeList(attributes);
191    }
192    closeHandle(inputRead);
193    closeHandle(outputWrite);
194  } catch (error) {
195    for (const handle of handles) closeHandle(handle);
196    if (console[0]) await c.ClosePseudoConsole(pointer(console[0]));
197    throw error;
198  }
199
200  let closing: Promise<void> | undefined;
201  let writing = Promise.resolve();
202  let killed = false;
203  const close = () => {
204    if (closing) return;
205    closing = c.ClosePseudoConsole(pointer(console[0]))
206      .then(() => writing.catch(() => {}))
207      .then(() => closeHandle(input));
208  };
209  const kill = () => {
210    if (closing) return;
211    killed = true;
212    // a process aborted before its console initialization can miss ConPTY's close notification
213    c.TerminateProcess(pointer(process), 1);
214    close();
215  };
216  context.abortSignal?.addEventListener("abort", kill, { once: true });
217  if (context.abortSignal?.aborted) kill();
218  const exited = c.WaitForSingleObject(pointer(process), INFINITE).then((status) => {
219    const code = new Uint32Array(1);
220    return status === 0 && c.GetExitCodeProcess(pointer(process), code) && !killed ? code[0] : null;
221  }).finally(() => {
222    closeHandle(process);
223    context.abortSignal?.removeEventListener("abort", kill);
224    close();
225  });
226
227  let ended = false;
228  let cancelled = false;
229  let reading = Promise.resolve();
230  const read = async () => {
231    if (ended) return;
232    const buffer = new Uint8Array(65536);
233    const count = new Uint32Array(1);
234    if (await c.ReadFile(pointer(output), buffer, buffer.length, count, null) && count[0]) {
235      return buffer.subarray(0, count[0]);
236    }
237    ended = true;
238    closeHandle(output);
239    await closing;
240  };
241  const readable = new ReadableStream<Uint8Array>({
242    pull(controller) {
243      reading = read().then((chunk) => {
244        if (cancelled) return;
245        if (chunk) controller.enqueue(chunk);
246        else controller.close();
247      });
248      return reading;
249    },
250    async cancel() {
251      cancelled = true;
252      kill();
253      // keep draining for ClosePseudoConsole even after the consumer has gone away
254      await reading;
255      while (!ended) await read();
256    },
257  }, { highWaterMark: 0 });
258  const writable = new WritableStream<Uint8Array>({
259    write(chunk) {
260      writing = (async () => {
261        const count = new Uint32Array(1);
262        for (let at = 0; at < chunk.length;) {
263          if (closing) throw new Error("The terminal is closed");
264          const rest = chunk.subarray(at);
265          if (!await c.WriteFile(pointer(input), rest, rest.length, count, null) || !count[0]) {
266            throw new Error("Could not write to the terminal");
267          }
268          at += count[0];
269        }
270      })();
271      return writing;
272    },
273    close: () => closeHandle(input),
274    abort: kill,
275  });
276  return {
277    readable,
278    writable,
279    exited,
280    kill,
281    resize(cols, rows) {
282      if (closing) return;
283      const hr = c.ResizePseudoConsole(pointer(console[0]), size(cols, rows));
284      if (hr < 0) {
285        throw new Error(`ResizePseudoConsole failed (HRESULT 0x${(hr >>> 0).toString(16)})`);
286      }
287    },
288  };
289}