libretto 0.6.13 → 0.6.15
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/cli/commands/auth.js +43 -33
- package/dist/cli/commands/billing.js +3 -5
- package/dist/cli/commands/browser.js +3 -6
- package/dist/cli/commands/deploy.js +54 -45
- package/dist/cli/commands/execution.js +7 -4
- package/dist/cli/commands/experiments.js +1 -1
- package/dist/cli/commands/setup.js +1 -1
- package/dist/cli/commands/shared.js +1 -1
- package/dist/cli/commands/snapshot.js +1 -1
- package/dist/cli/commands/status.js +1 -1
- package/dist/cli/core/auth-fetch.js +11 -6
- package/dist/cli/core/browser.js +10 -5
- package/dist/cli/core/daemon/daemon.js +63 -10
- package/dist/cli/core/daemon/exec-repl.js +133 -0
- package/dist/cli/core/daemon/exec.js +6 -21
- package/dist/cli/core/daemon/ipc.js +47 -4
- package/dist/cli/core/daemon/ipc.spec.js +21 -0
- package/dist/cli/core/exec-compiler.js +8 -3
- package/dist/cli/core/providers/index.js +13 -4
- package/dist/cli/core/providers/kernel.js +3 -3
- package/dist/cli/core/providers/libretto-cloud.js +178 -26
- package/dist/cli/router.js +9 -4
- package/dist/shared/ipc/socket-transport.d.ts +2 -1
- package/dist/shared/ipc/socket-transport.js +16 -5
- package/dist/shared/ipc/socket-transport.spec.js +5 -0
- package/package.json +2 -2
- package/skills/libretto/SKILL.md +33 -29
- package/skills/libretto/references/code-generation-rules.md +6 -0
- package/skills/libretto/references/configuration-file-reference.md +8 -0
- package/skills/libretto/references/site-security-review.md +6 -6
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/commands/auth.ts +46 -33
- package/src/cli/commands/billing.ts +3 -5
- package/src/cli/commands/browser.ts +5 -9
- package/src/cli/commands/deploy.ts +55 -49
- package/src/cli/commands/execution.ts +7 -4
- package/src/cli/commands/experiments.ts +1 -1
- package/src/cli/commands/setup.ts +1 -1
- package/src/cli/commands/shared.ts +1 -1
- package/src/cli/commands/snapshot.ts +1 -1
- package/src/cli/commands/status.ts +1 -1
- package/src/cli/core/auth-fetch.ts +9 -4
- package/src/cli/core/browser.ts +12 -5
- package/src/cli/core/daemon/daemon.ts +81 -9
- package/src/cli/core/daemon/exec-repl.ts +189 -0
- package/src/cli/core/daemon/exec.ts +8 -43
- package/src/cli/core/daemon/ipc.spec.ts +27 -0
- package/src/cli/core/daemon/ipc.ts +76 -7
- package/src/cli/core/exec-compiler.ts +8 -3
- package/src/cli/core/providers/index.ts +17 -4
- package/src/cli/core/providers/kernel.ts +4 -3
- package/src/cli/core/providers/libretto-cloud.ts +224 -36
- package/src/cli/router.ts +9 -4
- package/src/shared/ipc/socket-transport.spec.ts +6 -0
- package/src/shared/ipc/socket-transport.ts +20 -5
- package/dist/cli/framework/simple-cli.js +0 -880
- package/src/cli/framework/simple-cli.ts +0 -1459
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
} from "../browser.js";
|
|
64
64
|
import { handlePages } from "./pages.js";
|
|
65
65
|
import { handleExec, handleReadonlyExec } from "./exec.js";
|
|
66
|
+
import { DaemonExecRepl } from "./exec-repl.js";
|
|
66
67
|
import { handleCompactSnapshot } from "./snapshot.js";
|
|
67
68
|
import { librettoCommand } from "../../../shared/package-manager.js";
|
|
68
69
|
import type { Snapshot } from "../../../shared/snapshot/types.js";
|
|
@@ -82,7 +83,7 @@ import {
|
|
|
82
83
|
} from "./config.js";
|
|
83
84
|
import type { Experiments } from "../experiments.js";
|
|
84
85
|
import { getCloudProviderApi } from "../providers/index.js";
|
|
85
|
-
import type { ProviderApi } from "../providers/types.js";
|
|
86
|
+
import type { ProviderApi, ProviderSession } from "../providers/types.js";
|
|
86
87
|
import {
|
|
87
88
|
getAbsoluteIntegrationPath,
|
|
88
89
|
loadDefaultWorkflow,
|
|
@@ -160,7 +161,7 @@ const REQUEST_TIMEOUT_MS = 60_000;
|
|
|
160
161
|
|
|
161
162
|
class BrowserDaemon {
|
|
162
163
|
readonly logger: LoggerApi;
|
|
163
|
-
private readonly
|
|
164
|
+
private readonly execRepl: DaemonExecRepl;
|
|
164
165
|
private readonly pageById = new Map<string, Page>();
|
|
165
166
|
private readonly shutdownHandlers: ShutdownHandler[] = [];
|
|
166
167
|
private readonly connectedClis = new Set<IpcPeer<DaemonToCliApi>>();
|
|
@@ -183,6 +184,10 @@ class BrowserDaemon {
|
|
|
183
184
|
},
|
|
184
185
|
) {
|
|
185
186
|
this.logger = logger.withScope("child");
|
|
187
|
+
this.execRepl = new DaemonExecRepl({
|
|
188
|
+
browser: this.browser,
|
|
189
|
+
context: this.context,
|
|
190
|
+
});
|
|
186
191
|
}
|
|
187
192
|
|
|
188
193
|
private trackPage(page: Page): string {
|
|
@@ -230,6 +235,7 @@ class BrowserDaemon {
|
|
|
230
235
|
name: string;
|
|
231
236
|
sessionId: string;
|
|
232
237
|
};
|
|
238
|
+
beforeReady?: () => void;
|
|
233
239
|
}): Promise<BrowserDaemon> {
|
|
234
240
|
const {
|
|
235
241
|
session,
|
|
@@ -242,6 +248,7 @@ class BrowserDaemon {
|
|
|
242
248
|
navigateUrl,
|
|
243
249
|
readyProvider,
|
|
244
250
|
providerSession,
|
|
251
|
+
beforeReady,
|
|
245
252
|
} = args;
|
|
246
253
|
|
|
247
254
|
await mkdir(getSessionDir(session), { recursive: true });
|
|
@@ -346,6 +353,7 @@ class BrowserDaemon {
|
|
|
346
353
|
});
|
|
347
354
|
|
|
348
355
|
await listenOnIpcSocket(ipcServer, socketPath);
|
|
356
|
+
beforeReady?.();
|
|
349
357
|
process.send?.({ type: "ready", socketPath, provider: readyProvider });
|
|
350
358
|
daemon.logger.info("ipc-server-listening", { socketPath });
|
|
351
359
|
|
|
@@ -471,8 +479,13 @@ class BrowserDaemon {
|
|
|
471
479
|
}): Promise<BrowserDaemon> {
|
|
472
480
|
const { session, browser: config } = args;
|
|
473
481
|
const provider = getCloudProviderApi(config.providerName);
|
|
474
|
-
|
|
482
|
+
let providerSession: ProviderSession | undefined;
|
|
483
|
+
const startupCleanup = createProviderStartupCleanup({
|
|
484
|
+
provider,
|
|
485
|
+
getProviderSession: () => providerSession,
|
|
486
|
+
});
|
|
475
487
|
try {
|
|
488
|
+
providerSession = await provider.createSession();
|
|
476
489
|
const browser = await chromium.connectOverCDP(
|
|
477
490
|
providerSession.cdpEndpoint,
|
|
478
491
|
);
|
|
@@ -506,6 +519,7 @@ class BrowserDaemon {
|
|
|
506
519
|
name: config.providerName,
|
|
507
520
|
sessionId: providerSession.sessionId,
|
|
508
521
|
},
|
|
522
|
+
beforeReady: startupCleanup.dispose,
|
|
509
523
|
});
|
|
510
524
|
|
|
511
525
|
daemon.logger.info("child-provider-connected", {
|
|
@@ -518,7 +532,10 @@ class BrowserDaemon {
|
|
|
518
532
|
|
|
519
533
|
return daemon;
|
|
520
534
|
} catch (error) {
|
|
521
|
-
|
|
535
|
+
startupCleanup.dispose();
|
|
536
|
+
if (providerSession) {
|
|
537
|
+
await provider.closeSession(providerSession.sessionId);
|
|
538
|
+
}
|
|
522
539
|
throw error;
|
|
523
540
|
}
|
|
524
541
|
}
|
|
@@ -713,10 +730,7 @@ class BrowserDaemon {
|
|
|
713
730
|
const result = await handleExec(
|
|
714
731
|
page,
|
|
715
732
|
args.code,
|
|
716
|
-
this.
|
|
717
|
-
this.browser,
|
|
718
|
-
this.execState,
|
|
719
|
-
this.session,
|
|
733
|
+
this.execRepl,
|
|
720
734
|
args.visualize,
|
|
721
735
|
);
|
|
722
736
|
|
|
@@ -856,6 +870,62 @@ class BrowserDaemon {
|
|
|
856
870
|
}
|
|
857
871
|
}
|
|
858
872
|
|
|
873
|
+
function createProviderStartupCleanup(args: {
|
|
874
|
+
provider: ProviderApi;
|
|
875
|
+
getProviderSession: () => ProviderSession | undefined;
|
|
876
|
+
}): { dispose: () => void } {
|
|
877
|
+
let disposed = false;
|
|
878
|
+
let fallbackExit: NodeJS.Timeout | undefined;
|
|
879
|
+
|
|
880
|
+
const requestClose = (reason: string): void => {
|
|
881
|
+
if (disposed) return;
|
|
882
|
+
disposed = true;
|
|
883
|
+
// Startup cancellation should preserve the signal-style exit code even if
|
|
884
|
+
// provider.createSession() later rejects through the normal startup path.
|
|
885
|
+
process.exitCode = reason === "received SIGINT" ? 130 : 1;
|
|
886
|
+
const providerSession = args.getProviderSession();
|
|
887
|
+
if (!providerSession) {
|
|
888
|
+
// If cancellation lands while createSession() is still awaiting provider
|
|
889
|
+
// capacity, provider-specific signal handlers may still need a tick to
|
|
890
|
+
// close a queued session id that this daemon has not received yet.
|
|
891
|
+
fallbackExit = setTimeout(() => {
|
|
892
|
+
process.exit(process.exitCode);
|
|
893
|
+
}, 5_000);
|
|
894
|
+
fallbackExit.unref();
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
void args.provider
|
|
899
|
+
.closeSession(providerSession.sessionId)
|
|
900
|
+
.catch(() => {})
|
|
901
|
+
.finally(() => {
|
|
902
|
+
process.exit(reason === "received SIGINT" ? 130 : 1);
|
|
903
|
+
});
|
|
904
|
+
};
|
|
905
|
+
|
|
906
|
+
const onDisconnect = (): void => requestClose("parent command disconnected");
|
|
907
|
+
const onSigint = (): void => requestClose("received SIGINT");
|
|
908
|
+
const onSigterm = (): void => requestClose("received SIGTERM");
|
|
909
|
+
|
|
910
|
+
// These handlers only cover the pre-ready window. Once the daemon is ready,
|
|
911
|
+
// normal shutdown owns provider cleanup through BrowserDaemon.shutdown().
|
|
912
|
+
if (typeof process.send === "function") {
|
|
913
|
+
process.once("disconnect", onDisconnect);
|
|
914
|
+
}
|
|
915
|
+
process.once("SIGINT", onSigint);
|
|
916
|
+
process.once("SIGTERM", onSigterm);
|
|
917
|
+
|
|
918
|
+
return {
|
|
919
|
+
dispose: () => {
|
|
920
|
+
disposed = true;
|
|
921
|
+
if (fallbackExit) clearTimeout(fallbackExit);
|
|
922
|
+
process.off("disconnect", onDisconnect);
|
|
923
|
+
process.off("SIGINT", onSigint);
|
|
924
|
+
process.off("SIGTERM", onSigterm);
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
859
929
|
// ── Main ───────────────────────────────────────────────────────────────
|
|
860
930
|
|
|
861
931
|
async function main(): Promise<void> {
|
|
@@ -957,7 +1027,9 @@ function reportStartupError(error: unknown): never {
|
|
|
957
1027
|
message: error.message,
|
|
958
1028
|
});
|
|
959
1029
|
}
|
|
960
|
-
process.exit(
|
|
1030
|
+
process.exit(
|
|
1031
|
+
process.exitCode && process.exitCode !== 0 ? process.exitCode : 1,
|
|
1032
|
+
);
|
|
961
1033
|
}
|
|
962
1034
|
|
|
963
1035
|
try {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as repl from "node:repl";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { format, formatWithOptions, type InspectOptions } from "node:util";
|
|
4
|
+
import { stripTypeScriptExecCode } from "../exec-compiler.js";
|
|
5
|
+
|
|
6
|
+
const PROMPT = "__LIBRETTO_EXEC_REPL_READY__";
|
|
7
|
+
const TOP_LEVEL_RETURN_HINT =
|
|
8
|
+
"Hint: top-level return isn't supported because exec is a REPL-style environment. Use the expression value instead, for example: await page.title()";
|
|
9
|
+
const NO_RESULT = Symbol("NO_RESULT");
|
|
10
|
+
|
|
11
|
+
type ReplOutput = {
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type DaemonExecReplResult = {
|
|
17
|
+
ok: true;
|
|
18
|
+
result: unknown;
|
|
19
|
+
output: ReplOutput;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type DaemonExecReplFailure = {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: Error;
|
|
25
|
+
output: ReplOutput;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type DaemonExecReplResponse = DaemonExecReplResult | DaemonExecReplFailure;
|
|
29
|
+
|
|
30
|
+
type ActiveEval = {
|
|
31
|
+
output: string;
|
|
32
|
+
consoleOutput: ReplOutput;
|
|
33
|
+
resolve: (value: DaemonExecReplResponse) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function getErrorMessage(error: unknown): string {
|
|
37
|
+
if (error instanceof Error) return error.message;
|
|
38
|
+
if (
|
|
39
|
+
typeof error === "object" &&
|
|
40
|
+
error !== null &&
|
|
41
|
+
"message" in error &&
|
|
42
|
+
typeof error.message === "string"
|
|
43
|
+
) {
|
|
44
|
+
return error.message;
|
|
45
|
+
}
|
|
46
|
+
return String(error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isTopLevelReturnError(error: unknown): boolean {
|
|
50
|
+
const message = getErrorMessage(error);
|
|
51
|
+
return (
|
|
52
|
+
message.includes("Illegal return statement") ||
|
|
53
|
+
message.includes("Return statement is not allowed here")
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isErrorLike(value: unknown): boolean {
|
|
58
|
+
return (
|
|
59
|
+
value instanceof Error ||
|
|
60
|
+
(typeof value === "object" &&
|
|
61
|
+
value !== null &&
|
|
62
|
+
"name" in value &&
|
|
63
|
+
"message" in value &&
|
|
64
|
+
typeof value.name === "string" &&
|
|
65
|
+
typeof value.message === "string")
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toError(value: unknown): Error {
|
|
70
|
+
return value instanceof Error ? value : new Error(getErrorMessage(value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function appendTopLevelReturnHint(error: unknown): Error {
|
|
74
|
+
const message = getErrorMessage(error);
|
|
75
|
+
if (message.includes(TOP_LEVEL_RETURN_HINT)) {
|
|
76
|
+
return error instanceof Error ? error : new Error(message);
|
|
77
|
+
}
|
|
78
|
+
return new SyntaxError(`${message}\n\n${TOP_LEVEL_RETURN_HINT}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function createBufferedConsole(): { console: Console; output: ReplOutput } {
|
|
82
|
+
const output: ReplOutput = { stdout: "", stderr: "" };
|
|
83
|
+
const writeStdout = (...args: unknown[]) => {
|
|
84
|
+
output.stdout += `${format(...args)}\n`;
|
|
85
|
+
};
|
|
86
|
+
const writeStderr = (...args: unknown[]) => {
|
|
87
|
+
output.stderr += `${format(...args)}\n`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const bufferedConsole = {
|
|
91
|
+
...globalThis.console,
|
|
92
|
+
log: writeStdout,
|
|
93
|
+
info: writeStdout,
|
|
94
|
+
debug: writeStdout,
|
|
95
|
+
dir: (value?: unknown, options?: InspectOptions) => {
|
|
96
|
+
output.stdout += `${formatWithOptions(options ?? {}, value)}\n`;
|
|
97
|
+
},
|
|
98
|
+
warn: writeStderr,
|
|
99
|
+
error: writeStderr,
|
|
100
|
+
} satisfies Console;
|
|
101
|
+
|
|
102
|
+
return { console: bufferedConsole, output };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class DaemonExecRepl {
|
|
106
|
+
private readonly replServer: repl.REPLServer;
|
|
107
|
+
private readonly input = new PassThrough();
|
|
108
|
+
private readonly output = new PassThrough();
|
|
109
|
+
private readyResolve: (() => void) | undefined;
|
|
110
|
+
private readonly ready: Promise<void>;
|
|
111
|
+
private activeEval: ActiveEval | undefined;
|
|
112
|
+
private lastResult: unknown = NO_RESULT;
|
|
113
|
+
|
|
114
|
+
constructor(globals: Record<string, unknown>) {
|
|
115
|
+
this.ready = new Promise((resolve) => {
|
|
116
|
+
this.readyResolve = resolve;
|
|
117
|
+
});
|
|
118
|
+
this.output.on("data", (chunk: Buffer | string) => {
|
|
119
|
+
this.handleOutput(String(chunk));
|
|
120
|
+
});
|
|
121
|
+
this.replServer = repl.start({
|
|
122
|
+
prompt: PROMPT,
|
|
123
|
+
input: this.input,
|
|
124
|
+
output: this.output,
|
|
125
|
+
terminal: true,
|
|
126
|
+
useGlobal: false,
|
|
127
|
+
writer: (value: unknown) => {
|
|
128
|
+
this.lastResult = value;
|
|
129
|
+
return "";
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
Object.assign(this.replServer.context, globals);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async run(
|
|
136
|
+
code: string,
|
|
137
|
+
globals: Record<string, unknown>,
|
|
138
|
+
): Promise<DaemonExecReplResponse> {
|
|
139
|
+
Object.assign(this.replServer.context, globals);
|
|
140
|
+
|
|
141
|
+
let jsCode: string;
|
|
142
|
+
try {
|
|
143
|
+
jsCode = stripTypeScriptExecCode(code);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: isTopLevelReturnError(error)
|
|
148
|
+
? appendTopLevelReturnHint(error)
|
|
149
|
+
: toError(error),
|
|
150
|
+
output: { stdout: "", stderr: "" },
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await this.ready;
|
|
155
|
+
const buffered = createBufferedConsole();
|
|
156
|
+
Object.assign(this.replServer.context, { console: buffered.console });
|
|
157
|
+
return await new Promise<DaemonExecReplResponse>((resolve) => {
|
|
158
|
+
this.activeEval = { output: "", consoleOutput: buffered.output, resolve };
|
|
159
|
+
this.lastResult = NO_RESULT;
|
|
160
|
+
this.input.write(`.editor\n${jsCode}\n\x04`);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private handleOutput(chunk: string): void {
|
|
165
|
+
const active = this.activeEval;
|
|
166
|
+
if (!active) {
|
|
167
|
+
if (chunk.includes(PROMPT)) {
|
|
168
|
+
this.readyResolve?.();
|
|
169
|
+
this.readyResolve = undefined;
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
active.output += chunk;
|
|
175
|
+
if (!active.output.includes(PROMPT)) return;
|
|
176
|
+
|
|
177
|
+
this.activeEval = undefined;
|
|
178
|
+
const result = this.lastResult === NO_RESULT ? undefined : this.lastResult;
|
|
179
|
+
const output = active.consoleOutput;
|
|
180
|
+
if (isErrorLike(result)) {
|
|
181
|
+
const error = isTopLevelReturnError(result)
|
|
182
|
+
? appendTopLevelReturnHint(result)
|
|
183
|
+
: toError(result);
|
|
184
|
+
active.resolve({ ok: false, error, output });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
active.resolve({ ok: true, result, output });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
2
|
import { format, formatWithOptions, type InspectOptions } from "node:util";
|
|
3
3
|
import { installInstrumentation } from "../../../shared/instrumentation/index.js";
|
|
4
4
|
import { compileExecFunction } from "../exec-compiler.js";
|
|
5
5
|
import { createReadonlyExecHelpers } from "../readonly-exec.js";
|
|
6
|
-
import {
|
|
6
|
+
import type { DaemonExecRepl } from "./exec-repl.js";
|
|
7
7
|
|
|
8
8
|
type ExecOutput = {
|
|
9
9
|
stdout: string;
|
|
@@ -52,58 +52,23 @@ function createBufferedConsole(): { console: Console; output: ExecOutput } {
|
|
|
52
52
|
export async function handleExec(
|
|
53
53
|
targetPage: Page,
|
|
54
54
|
code: string,
|
|
55
|
-
|
|
56
|
-
browser: Browser,
|
|
57
|
-
execState: Record<string, unknown>,
|
|
58
|
-
session: string,
|
|
55
|
+
execRepl: DaemonExecRepl,
|
|
59
56
|
visualize?: boolean,
|
|
60
57
|
): Promise<ExecResponse> {
|
|
61
|
-
const buffered = createBufferedConsole();
|
|
62
|
-
|
|
63
58
|
if (visualize) {
|
|
64
59
|
await installInstrumentation(targetPage, { visualize: true });
|
|
65
60
|
}
|
|
66
61
|
|
|
67
|
-
const networkLog = (
|
|
68
|
-
opts: {
|
|
69
|
-
last?: number;
|
|
70
|
-
filter?: string;
|
|
71
|
-
method?: string;
|
|
72
|
-
pageId?: string;
|
|
73
|
-
} = {},
|
|
74
|
-
) => readNetworkLog(session, opts);
|
|
75
|
-
|
|
76
|
-
const actionLog = (
|
|
77
|
-
opts: {
|
|
78
|
-
last?: number;
|
|
79
|
-
filter?: string;
|
|
80
|
-
action?: string;
|
|
81
|
-
source?: string;
|
|
82
|
-
pageId?: string;
|
|
83
|
-
} = {},
|
|
84
|
-
) => readActionLog(session, opts);
|
|
85
|
-
|
|
86
62
|
const helpers = {
|
|
87
63
|
page: targetPage,
|
|
88
|
-
|
|
89
|
-
browser,
|
|
90
|
-
state: execState,
|
|
91
|
-
console: buffered.console,
|
|
92
|
-
networkLog,
|
|
93
|
-
actionLog,
|
|
64
|
+
frame: targetPage.mainFrame(),
|
|
94
65
|
};
|
|
95
66
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const result = await fn(...Object.values(helpers));
|
|
100
|
-
return { result, output: buffered.output };
|
|
101
|
-
} catch (error) {
|
|
102
|
-
throw new DaemonExecError(
|
|
103
|
-
error instanceof Error ? error.message : String(error),
|
|
104
|
-
buffered.output,
|
|
105
|
-
);
|
|
67
|
+
const result = await execRepl.run(code, helpers);
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
throw new DaemonExecError(result.error.message, result.output);
|
|
106
70
|
}
|
|
71
|
+
return { result: result.result, output: result.output };
|
|
107
72
|
}
|
|
108
73
|
|
|
109
74
|
export async function handleReadonlyExec(
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { getDaemonSocketPath } from "./ipc.js";
|
|
3
|
+
|
|
4
|
+
describe("daemon IPC endpoint paths", () => {
|
|
5
|
+
test("uses a Windows named pipe path on Windows", () => {
|
|
6
|
+
const socketPath = getDaemonSocketPath("windows-session", "win32");
|
|
7
|
+
|
|
8
|
+
expect(socketPath).toMatch(/^\\\\\.\\pipe\\libretto-[a-f0-9]{12}$/);
|
|
9
|
+
expect(socketPath).not.toContain("/tmp/");
|
|
10
|
+
expect(socketPath).not.toContain(".sock");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("uses a short Unix socket path on Unix-like platforms", () => {
|
|
14
|
+
const socketPath = getDaemonSocketPath("unix-session", "linux");
|
|
15
|
+
|
|
16
|
+
expect(socketPath).toMatch(/^\/tmp\/libretto-.+-[a-f0-9]{12}\.sock$/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("keeps daemon IPC endpoints deterministic per session", () => {
|
|
20
|
+
const firstPath = getDaemonSocketPath("stable-session", "linux");
|
|
21
|
+
const secondPath = getDaemonSocketPath("stable-session", "linux");
|
|
22
|
+
const otherPath = getDaemonSocketPath("other-session", "linux");
|
|
23
|
+
|
|
24
|
+
expect(secondPath).toBe(firstPath);
|
|
25
|
+
expect(otherPath).not.toBe(firstPath);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -3,6 +3,7 @@ import type { ChildProcess } from "node:child_process";
|
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { openSync, closeSync } from "node:fs";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
+
import { homedir, userInfo } from "node:os";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
8
|
import { createIpcPeer, type IpcPeer } from "../../../shared/ipc/ipc.js";
|
|
8
9
|
import { connectToIpcSocket } from "../../../shared/ipc/socket-transport.js";
|
|
@@ -105,6 +106,11 @@ export type DaemonStartupErrorMessage = {
|
|
|
105
106
|
message: string;
|
|
106
107
|
};
|
|
107
108
|
|
|
109
|
+
export type DaemonStartupStatusMessage = {
|
|
110
|
+
type: "startup-status";
|
|
111
|
+
message: string;
|
|
112
|
+
};
|
|
113
|
+
|
|
108
114
|
function isDaemonReadyMessage(message: unknown): message is DaemonReadyMessage {
|
|
109
115
|
if (typeof message !== "object" || message === null) return false;
|
|
110
116
|
const candidate = message as { type?: unknown; socketPath?: unknown };
|
|
@@ -121,6 +127,16 @@ function isDaemonStartupErrorMessage(
|
|
|
121
127
|
);
|
|
122
128
|
}
|
|
123
129
|
|
|
130
|
+
function isDaemonStartupStatusMessage(
|
|
131
|
+
message: unknown,
|
|
132
|
+
): message is DaemonStartupStatusMessage {
|
|
133
|
+
if (typeof message !== "object" || message === null) return false;
|
|
134
|
+
const candidate = message as { type?: unknown; message?: unknown };
|
|
135
|
+
return (
|
|
136
|
+
candidate.type === "startup-status" && typeof candidate.message === "string"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
124
140
|
export type DaemonClientSpawnOptions = {
|
|
125
141
|
config: DaemonConfig;
|
|
126
142
|
logger: LoggerApi;
|
|
@@ -142,18 +158,43 @@ export type DaemonClientSpawnResult = {
|
|
|
142
158
|
// ---------------------------------------------------------------------------
|
|
143
159
|
|
|
144
160
|
/**
|
|
145
|
-
* Deterministic
|
|
161
|
+
* Deterministic IPC endpoint for a given session.
|
|
162
|
+
*
|
|
163
|
+
* Unix-like platforms use a socket path in `/tmp` to stay well under the macOS
|
|
164
|
+
* 104-byte Unix socket path limit. Windows uses a named pipe path because
|
|
165
|
+
* filesystem Unix domain socket paths are not portable there.
|
|
146
166
|
*
|
|
147
|
-
* The
|
|
148
|
-
*
|
|
149
|
-
* repos (or sessions within the same repo) never collide.
|
|
167
|
+
* The hash combines `REPO_ROOT`, the session name, and a user key so different
|
|
168
|
+
* repos, sessions, or local users never collide.
|
|
150
169
|
*/
|
|
151
|
-
export function getDaemonSocketPath(
|
|
170
|
+
export function getDaemonSocketPath(
|
|
171
|
+
session: string,
|
|
172
|
+
platform: NodeJS.Platform = process.platform,
|
|
173
|
+
): string {
|
|
174
|
+
const userKey = getDaemonUserKey();
|
|
152
175
|
const hash = createHash("sha256")
|
|
153
|
-
.update(`${REPO_ROOT}:${session}`)
|
|
176
|
+
.update(`${REPO_ROOT}:${session}:${userKey}`)
|
|
154
177
|
.digest("hex")
|
|
155
178
|
.slice(0, 12);
|
|
156
|
-
|
|
179
|
+
|
|
180
|
+
if (platform === "win32") {
|
|
181
|
+
return `\\\\.\\pipe\\libretto-${hash}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return `/tmp/libretto-${userKey}-${hash}.sock`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getDaemonUserKey(): string {
|
|
188
|
+
if (typeof process.getuid === "function") return String(process.getuid());
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const info = userInfo();
|
|
192
|
+
if (info.username) return info.username;
|
|
193
|
+
} catch {
|
|
194
|
+
// Fall back below.
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return createHash("sha256").update(homedir()).digest("hex").slice(0, 12);
|
|
157
198
|
}
|
|
158
199
|
|
|
159
200
|
// ---------------------------------------------------------------------------
|
|
@@ -257,6 +298,13 @@ export class DaemonClient {
|
|
|
257
298
|
onExit: (code, signal, ready) => {
|
|
258
299
|
logger.warn("daemon-exit", { code, signal, session, pid, ready });
|
|
259
300
|
},
|
|
301
|
+
onStatus: (message) => {
|
|
302
|
+
logger.info("daemon-startup-status", {
|
|
303
|
+
session,
|
|
304
|
+
message: message.message,
|
|
305
|
+
});
|
|
306
|
+
console.log(message.message);
|
|
307
|
+
},
|
|
260
308
|
}).catch(async (error: unknown) => {
|
|
261
309
|
try {
|
|
262
310
|
process.kill(pid, "SIGTERM");
|
|
@@ -292,6 +340,7 @@ export class DaemonClient {
|
|
|
292
340
|
signal: NodeJS.Signals | null,
|
|
293
341
|
ready: boolean,
|
|
294
342
|
) => void;
|
|
343
|
+
onStatus?: (message: DaemonStartupStatusMessage) => void;
|
|
295
344
|
}): Promise<DaemonReadyMessage> {
|
|
296
345
|
const {
|
|
297
346
|
child,
|
|
@@ -302,6 +351,7 @@ export class DaemonClient {
|
|
|
302
351
|
onReady,
|
|
303
352
|
onSpawnError,
|
|
304
353
|
onExit,
|
|
354
|
+
onStatus,
|
|
305
355
|
} = args;
|
|
306
356
|
|
|
307
357
|
return new Promise<DaemonReadyMessage>((resolve, reject) => {
|
|
@@ -313,6 +363,8 @@ export class DaemonClient {
|
|
|
313
363
|
child.off("message", onMessage);
|
|
314
364
|
child.off("error", onError);
|
|
315
365
|
child.off("exit", onChildExit);
|
|
366
|
+
process.off("SIGINT", onParentSigint);
|
|
367
|
+
process.off("SIGTERM", onParentSigterm);
|
|
316
368
|
};
|
|
317
369
|
|
|
318
370
|
const fail = (error: Error): void => {
|
|
@@ -327,6 +379,10 @@ export class DaemonClient {
|
|
|
327
379
|
fail(new Error(message.message));
|
|
328
380
|
return;
|
|
329
381
|
}
|
|
382
|
+
if (isDaemonStartupStatusMessage(message)) {
|
|
383
|
+
onStatus?.(message);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
330
386
|
if (!isDaemonReadyMessage(message)) return;
|
|
331
387
|
ready = true;
|
|
332
388
|
cleanup();
|
|
@@ -348,9 +404,22 @@ export class DaemonClient {
|
|
|
348
404
|
fail(formatExitError(code, signal));
|
|
349
405
|
};
|
|
350
406
|
|
|
407
|
+
const forwardSignalToChild = (signal: NodeJS.Signals): void => {
|
|
408
|
+
try {
|
|
409
|
+
child.kill(signal);
|
|
410
|
+
} catch {
|
|
411
|
+
// Child may have already exited.
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const onParentSigint = (): void => forwardSignalToChild("SIGINT");
|
|
416
|
+
const onParentSigterm = (): void => forwardSignalToChild("SIGTERM");
|
|
417
|
+
|
|
351
418
|
child.on("message", onMessage);
|
|
352
419
|
child.on("error", onError);
|
|
353
420
|
child.on("exit", onChildExit);
|
|
421
|
+
process.once("SIGINT", onParentSigint);
|
|
422
|
+
process.once("SIGTERM", onParentSigterm);
|
|
354
423
|
});
|
|
355
424
|
}
|
|
356
425
|
|
|
@@ -19,6 +19,13 @@ const stripTypeScriptTypes = (
|
|
|
19
19
|
moduleBuiltin as { stripTypeScriptTypes?: StripTypeScriptTypesFn }
|
|
20
20
|
).stripTypeScriptTypes;
|
|
21
21
|
|
|
22
|
+
export function stripTypeScriptExecCode(code: string): string {
|
|
23
|
+
if (!stripTypeScriptTypes) return code;
|
|
24
|
+
return withSuppressedStripTypeScriptWarning(() =>
|
|
25
|
+
stripTypeScriptTypes(code, { mode: "strip" }),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
export function withSuppressedStripTypeScriptWarning<T>(action: () => T): T {
|
|
23
30
|
type EmitWarningFn = (...args: unknown[]) => void;
|
|
24
31
|
const mutableProcess = process as unknown as { emitWarning: EmitWarningFn };
|
|
@@ -66,9 +73,7 @@ export function compileTypeScriptExecFunction(
|
|
|
66
73
|
if (!stripTypeScriptTypes) return null;
|
|
67
74
|
|
|
68
75
|
const wrappedSource = `(async function __librettoExec(${helperNames.join(", ")}) {\n${code}\n})`;
|
|
69
|
-
const jsSource =
|
|
70
|
-
stripTypeScriptTypes(wrappedSource, { mode: "strip" }),
|
|
71
|
-
);
|
|
76
|
+
const jsSource = stripTypeScriptExecCode(wrappedSource);
|
|
72
77
|
const createFunction = new Function(
|
|
73
78
|
`return ${jsSource}`,
|
|
74
79
|
) as () => ExecFunction;
|
|
@@ -4,9 +4,16 @@ import { createKernelProvider } from "./kernel.js";
|
|
|
4
4
|
import { createLibrettoCloudProvider } from "./libretto-cloud.js";
|
|
5
5
|
import type { ProviderApi } from "./types.js";
|
|
6
6
|
|
|
7
|
-
const VALID_PROVIDERS = new Set([
|
|
7
|
+
const VALID_PROVIDERS = new Set([
|
|
8
|
+
"local",
|
|
9
|
+
"kernel",
|
|
10
|
+
"browserbase",
|
|
11
|
+
"libretto-cloud",
|
|
12
|
+
] as const);
|
|
8
13
|
export type ProviderName =
|
|
9
14
|
typeof VALID_PROVIDERS extends Set<infer T> ? T : never;
|
|
15
|
+
const DEFAULT_PROVIDER_STARTUP_TIMEOUT_MS = 60_000;
|
|
16
|
+
const LIBRETTO_CLOUD_STARTUP_TIMEOUT_MS = 10 * 60_000;
|
|
10
17
|
|
|
11
18
|
function assertValidProviderName(value: string, source: string): ProviderName {
|
|
12
19
|
if (!VALID_PROVIDERS.has(value as ProviderName)) {
|
|
@@ -50,9 +57,7 @@ export function getCloudProviderApi(name: string): ProviderApi {
|
|
|
50
57
|
case "browserbase":
|
|
51
58
|
return createBrowserbaseProvider();
|
|
52
59
|
case "libretto-cloud":
|
|
53
|
-
console.warn(
|
|
54
|
-
"Note: The libretto-cloud provider is in alpha.",
|
|
55
|
-
);
|
|
60
|
+
console.warn("Note: The libretto-cloud provider is in alpha.");
|
|
56
61
|
return createLibrettoCloudProvider();
|
|
57
62
|
default:
|
|
58
63
|
throw new Error(
|
|
@@ -60,3 +65,11 @@ export function getCloudProviderApi(name: string): ProviderApi {
|
|
|
60
65
|
);
|
|
61
66
|
}
|
|
62
67
|
}
|
|
68
|
+
|
|
69
|
+
export function getProviderStartupTimeoutMs(
|
|
70
|
+
providerName: string | undefined,
|
|
71
|
+
): number {
|
|
72
|
+
return providerName === "libretto-cloud"
|
|
73
|
+
? LIBRETTO_CLOUD_STARTUP_TIMEOUT_MS
|
|
74
|
+
: DEFAULT_PROVIDER_STARTUP_TIMEOUT_MS;
|
|
75
|
+
}
|