@rigkit/provider-freestyle 0.2.3 → 0.2.5
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/README.md +9 -3
- package/package.json +8 -5
- package/src/host-auth.test.ts +375 -0
- package/src/host-auth.ts +591 -0
- package/src/index.ts +37 -59
- package/src/provider.test.ts +188 -113
- package/src/provider.ts +113 -378
- package/src/terminal-session.test.ts +239 -7
- package/src/terminal-session.ts +1181 -332
- package/src/version.ts +1 -1
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { createFreestyleTerminalSession } from "./terminal-session.ts";
|
|
3
3
|
|
|
4
4
|
describe("Freestyle terminal session", () => {
|
|
5
|
-
test("serves
|
|
5
|
+
test("serves an xterm page and resolves after the user finishes", async () => {
|
|
6
6
|
const session = createFreestyleTerminalSession({
|
|
7
7
|
nodePath: "login",
|
|
8
8
|
title: "GitHub auth",
|
|
@@ -19,17 +19,30 @@ describe("Freestyle terminal session", () => {
|
|
|
19
19
|
expect(html).toContain("GitHub auth");
|
|
20
20
|
expect(html).toContain("Authenticate GitHub inside the VM.");
|
|
21
21
|
expect(html).toContain("printf interactive-ready");
|
|
22
|
-
expect(html).toContain("@
|
|
23
|
-
expect(html).toContain("@
|
|
22
|
+
expect(html).toContain("@xterm/xterm");
|
|
23
|
+
expect(html).toContain("@xterm/addon-fit");
|
|
24
|
+
expect(html).toContain("@xterm/addon-web-links");
|
|
24
25
|
expect(html).toContain("terminal-window");
|
|
25
|
-
expect(html).toContain("
|
|
26
|
-
expect(html).toContain("
|
|
26
|
+
expect(html).toContain("freestyle.sh");
|
|
27
|
+
expect(html).toContain("Complete task");
|
|
27
28
|
expect(html).toContain("document.addEventListener(\"keydown\"");
|
|
28
29
|
expect(html).toContain("{ capture: true }");
|
|
29
30
|
expect(html).toContain("terminalEl.contains(target)");
|
|
30
31
|
expect(html).toContain("user-select: text");
|
|
31
32
|
expect(html).toContain("keyEventToTerminalInput");
|
|
32
33
|
expect(html).toContain("sendTerminalInput(data)");
|
|
34
|
+
expect(html).toContain("terminalUrlFromEvent");
|
|
35
|
+
expect(html).toContain("expandKnownTerminalUrl");
|
|
36
|
+
expect(html).toContain("openPendingBrowserPrompt");
|
|
37
|
+
expect(html).toContain("window.open(normalized");
|
|
38
|
+
expect(html).toContain("document.addEventListener(\"paste\"");
|
|
39
|
+
expect(html).toContain("term-input-proxy");
|
|
40
|
+
expect(html).toContain("onPointerDownCapture");
|
|
41
|
+
expect(html).toContain("onBeforeInput");
|
|
42
|
+
expect(html).toContain("sendTextInputEventToTerminal");
|
|
43
|
+
expect(html).toContain("sendPasteEventToTerminal");
|
|
44
|
+
expect(html).not.toContain("term-send-form");
|
|
45
|
+
expect(html).not.toContain("Send to terminal");
|
|
33
46
|
const startupInput = readStartupInput(html);
|
|
34
47
|
expect(startupInput).toBe("printf interactive-ready\n");
|
|
35
48
|
|
|
@@ -102,6 +115,46 @@ describe("Freestyle terminal session", () => {
|
|
|
102
115
|
}
|
|
103
116
|
});
|
|
104
117
|
|
|
118
|
+
test("can allow finishing while the terminal process is still running", async () => {
|
|
119
|
+
const session = createFreestyleTerminalSession({
|
|
120
|
+
nodePath: "login",
|
|
121
|
+
title: "Keep-open command",
|
|
122
|
+
command: "sleep 5",
|
|
123
|
+
displayCommand: "sleep 5",
|
|
124
|
+
canFinishWhileRunning: true,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
let resolved = false;
|
|
128
|
+
session.completed.then(() => {
|
|
129
|
+
resolved = true;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const messages: unknown[] = [];
|
|
134
|
+
const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
|
|
135
|
+
socketUrl.protocol = "ws:";
|
|
136
|
+
const socket = new WebSocket(socketUrl);
|
|
137
|
+
socket.addEventListener("message", (event) => {
|
|
138
|
+
messages.push(JSON.parse(String(event.data)));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await waitForSocketOpen(socket);
|
|
142
|
+
await waitFor(() =>
|
|
143
|
+
messages.some((message) =>
|
|
144
|
+
isMessage(message, "status") && Boolean(message.canFinish)
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
148
|
+
expect(resolved).toBe(false);
|
|
149
|
+
|
|
150
|
+
socket.send(JSON.stringify({ type: "finish" }));
|
|
151
|
+
await expect(session.completed).resolves.toEqual({ finished: true });
|
|
152
|
+
socket.close();
|
|
153
|
+
} finally {
|
|
154
|
+
session.stop();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
105
158
|
test("answers cursor position reports for terminal UI prompts", async () => {
|
|
106
159
|
const session = createFreestyleTerminalSession({
|
|
107
160
|
nodePath: "prompt",
|
|
@@ -133,16 +186,195 @@ describe("Freestyle terminal session", () => {
|
|
|
133
186
|
session.stop();
|
|
134
187
|
}
|
|
135
188
|
});
|
|
189
|
+
|
|
190
|
+
test("forwards browser terminal input to process stdin", async () => {
|
|
191
|
+
const session = createFreestyleTerminalSession({
|
|
192
|
+
nodePath: "prompt",
|
|
193
|
+
title: "Input prompt",
|
|
194
|
+
command: stdinEchoProbe,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const messages: unknown[] = [];
|
|
199
|
+
const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
|
|
200
|
+
socketUrl.protocol = "ws:";
|
|
201
|
+
const socket = new WebSocket(socketUrl);
|
|
202
|
+
socket.addEventListener("message", (event) => {
|
|
203
|
+
messages.push(JSON.parse(String(event.data)));
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await waitForSocketOpen(socket);
|
|
207
|
+
socket.send(JSON.stringify({ type: "input", data: "abc123\n" }));
|
|
208
|
+
|
|
209
|
+
await waitFor(() =>
|
|
210
|
+
messages.some((message) =>
|
|
211
|
+
isMessage(message, "output") && message.data.includes("STDIN:abc123")
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
socket.send(JSON.stringify({ type: "finish" }));
|
|
216
|
+
await expect(session.completed).resolves.toEqual({ finished: true });
|
|
217
|
+
socket.close();
|
|
218
|
+
} finally {
|
|
219
|
+
session.stop();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("drops terminal-generated string control responses from browser input", async () => {
|
|
224
|
+
const session = createFreestyleTerminalSession({
|
|
225
|
+
nodePath: "prompt",
|
|
226
|
+
title: "Input prompt",
|
|
227
|
+
command: stdinHexProbe,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const messages: unknown[] = [];
|
|
232
|
+
const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
|
|
233
|
+
socketUrl.protocol = "ws:";
|
|
234
|
+
const socket = new WebSocket(socketUrl);
|
|
235
|
+
socket.addEventListener("message", (event) => {
|
|
236
|
+
messages.push(JSON.parse(String(event.data)));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await waitForSocketOpen(socket);
|
|
240
|
+
socket.send(JSON.stringify({ type: "input", data: "\x1b]10;rgb:ffff/ffff/ffff\x07" }));
|
|
241
|
+
socket.send(JSON.stringify({ type: "input", data: "y\n" }));
|
|
242
|
+
|
|
243
|
+
await waitFor(() =>
|
|
244
|
+
messages.some((message) =>
|
|
245
|
+
isMessage(message, "output") && message.data.includes("STDINHEX:790a")
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
socket.send(JSON.stringify({ type: "finish" }));
|
|
250
|
+
await expect(session.completed).resolves.toEqual({ finished: true });
|
|
251
|
+
socket.close();
|
|
252
|
+
} finally {
|
|
253
|
+
session.stop();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("runs interactive commands with a TTY stdin", async () => {
|
|
258
|
+
const session = createFreestyleTerminalSession({
|
|
259
|
+
nodePath: "prompt",
|
|
260
|
+
title: "TTY probe",
|
|
261
|
+
command: ttyProbe,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const messages: unknown[] = [];
|
|
266
|
+
const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
|
|
267
|
+
socketUrl.protocol = "ws:";
|
|
268
|
+
const socket = new WebSocket(socketUrl);
|
|
269
|
+
socket.addEventListener("message", (event) => {
|
|
270
|
+
messages.push(JSON.parse(String(event.data)));
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await waitForSocketOpen(socket);
|
|
274
|
+
|
|
275
|
+
await waitFor(() =>
|
|
276
|
+
messages.some((message) =>
|
|
277
|
+
isMessage(message, "output") && message.data.includes("TTY:true")
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
socket.send(JSON.stringify({ type: "finish" }));
|
|
282
|
+
await expect(session.completed).resolves.toEqual({ finished: true });
|
|
283
|
+
socket.close();
|
|
284
|
+
} finally {
|
|
285
|
+
session.stop();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("asks the host to open complete browser auth URLs printed by the terminal", async () => {
|
|
290
|
+
const opened: string[] = [];
|
|
291
|
+
const authUrl = "https://claude.com/cai/oauth/authorize?code=true&state=abc";
|
|
292
|
+
const session = createFreestyleTerminalSession({
|
|
293
|
+
nodePath: "login",
|
|
294
|
+
title: "Claude auth",
|
|
295
|
+
command: "printf %s " + JSON.stringify(`Opening browser to sign in...\nIf the browser didn't open, visit: ${authUrl}\nPaste code here if prompted > `),
|
|
296
|
+
openExternalTarget: (target) => opened.push(target),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
|
|
301
|
+
socketUrl.protocol = "ws:";
|
|
302
|
+
const socket = new WebSocket(socketUrl);
|
|
303
|
+
await waitForSocketOpen(socket);
|
|
304
|
+
|
|
305
|
+
await waitFor(() => opened.includes(authUrl));
|
|
306
|
+
socket.send(JSON.stringify({ type: "finish" }));
|
|
307
|
+
await expect(session.completed).resolves.toEqual({ finished: true });
|
|
308
|
+
socket.close();
|
|
309
|
+
} finally {
|
|
310
|
+
session.stop();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("does not open partial browser auth URLs while output is still streaming", async () => {
|
|
315
|
+
const opened: string[] = [];
|
|
316
|
+
const authUrl = "https://claude.com/cai/oauth/authorize?code=true&state=abc";
|
|
317
|
+
const session = createFreestyleTerminalSession({
|
|
318
|
+
nodePath: "login",
|
|
319
|
+
title: "Claude auth",
|
|
320
|
+
command: streamedAuthUrlProbe,
|
|
321
|
+
openExternalTarget: (target) => opened.push(target),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const socketUrl = new URL(session.url.replace("/?", "/terminal?"));
|
|
326
|
+
socketUrl.protocol = "ws:";
|
|
327
|
+
const socket = new WebSocket(socketUrl);
|
|
328
|
+
await waitForSocketOpen(socket);
|
|
329
|
+
|
|
330
|
+
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
331
|
+
expect(opened).toEqual([]);
|
|
332
|
+
await waitFor(() => opened.includes(authUrl), 2_500);
|
|
333
|
+
socket.send(JSON.stringify({ type: "finish" }));
|
|
334
|
+
await expect(session.completed).resolves.toEqual({ finished: true });
|
|
335
|
+
socket.close();
|
|
336
|
+
} finally {
|
|
337
|
+
session.stop();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
136
340
|
});
|
|
137
341
|
|
|
138
342
|
const localInteractiveShell = "bash --noprofile --norc -i";
|
|
139
343
|
const cursorPositionProbe = "node -e " + JSON.stringify([
|
|
344
|
+
"process.stdin.setRawMode?.(true);",
|
|
140
345
|
"process.stdout.write('\\x1b[6n');",
|
|
141
346
|
"process.stdin.once('data', (chunk) => {",
|
|
142
347
|
" process.stdout.write('CPR:' + Buffer.from(chunk).toString('hex') + '\\n');",
|
|
143
348
|
" process.exit(0);",
|
|
144
349
|
"});",
|
|
145
350
|
].join(""));
|
|
351
|
+
const stdinEchoProbe = "node -e " + JSON.stringify([
|
|
352
|
+
"process.stdin.once('data', (chunk) => {",
|
|
353
|
+
" process.stdout.write('STDIN:' + chunk.toString('utf8').trim() + '\\n');",
|
|
354
|
+
" process.exit(0);",
|
|
355
|
+
"});",
|
|
356
|
+
].join(""));
|
|
357
|
+
const stdinHexProbe = "node -e " + JSON.stringify([
|
|
358
|
+
"process.stdin.setRawMode?.(true);",
|
|
359
|
+
"let data = Buffer.alloc(0);",
|
|
360
|
+
"process.stdin.on('data', (chunk) => {",
|
|
361
|
+
" data = Buffer.concat([data, chunk]);",
|
|
362
|
+
" if (data.includes(10)) {",
|
|
363
|
+
" process.stdout.write('STDINHEX:' + data.toString('hex') + '\\n');",
|
|
364
|
+
" process.exit(0);",
|
|
365
|
+
" }",
|
|
366
|
+
"});",
|
|
367
|
+
].join(""));
|
|
368
|
+
const ttyProbe = "node -e " + JSON.stringify([
|
|
369
|
+
"process.stdout.write('TTY:' + Boolean(process.stdin.isTTY) + '\\n');",
|
|
370
|
+
"process.exit(0);",
|
|
371
|
+
].join(""));
|
|
372
|
+
const streamedAuthUrlProbe = "node -e " + JSON.stringify([
|
|
373
|
+
"process.stdout.write(\"Opening browser to sign in...\\nIf the browser didn't open, visit: https://claude.com/cai/oauth/authorize?code=true\");",
|
|
374
|
+
"setTimeout(() => {",
|
|
375
|
+
" process.stdout.write('&state=abc\\nPaste code here if prompted > ');",
|
|
376
|
+
"}, 1000);",
|
|
377
|
+
].join(""));
|
|
146
378
|
|
|
147
379
|
function readStartupInput(html: string): string {
|
|
148
380
|
const match = /const startupInput = (.*);/.exec(html);
|
|
@@ -173,10 +405,10 @@ function isMessage(
|
|
|
173
405
|
return Boolean(value && typeof value === "object" && (value as { type?: unknown }).type === type);
|
|
174
406
|
}
|
|
175
407
|
|
|
176
|
-
async function waitFor(assertion: () => boolean): Promise<void> {
|
|
408
|
+
async function waitFor(assertion: () => boolean, timeoutMs = 1_000): Promise<void> {
|
|
177
409
|
const started = Date.now();
|
|
178
410
|
while (!assertion()) {
|
|
179
|
-
if (Date.now() - started >
|
|
411
|
+
if (Date.now() - started > timeoutMs) throw new Error("Timed out waiting for terminal event");
|
|
180
412
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
181
413
|
}
|
|
182
414
|
}
|