libretto 0.5.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/browser.js +31 -6
- package/dist/cli/commands/execution.js +54 -15
- package/dist/cli/commands/setup.js +78 -64
- package/dist/cli/commands/status.js +1 -1
- package/dist/cli/core/browser.js +163 -10
- package/dist/cli/core/config.js +1 -0
- package/dist/cli/core/providers/browserbase.js +53 -0
- package/dist/cli/core/providers/index.js +48 -0
- package/dist/cli/core/providers/kernel.js +46 -0
- package/dist/cli/core/providers/libretto-cloud.js +58 -0
- package/dist/cli/core/providers/types.js +0 -0
- package/dist/cli/core/session.js +9 -0
- package/dist/cli/workers/run-integration-runtime.js +3 -1
- package/dist/cli/workers/run-integration-worker-protocol.js +3 -1
- package/dist/shared/run/browser.d.ts +6 -1
- package/dist/shared/run/browser.js +39 -1
- package/dist/shared/state/session-state.d.ts +11 -1
- package/dist/shared/state/session-state.js +9 -2
- package/package.json +1 -1
- package/src/cli/commands/browser.ts +35 -7
- package/src/cli/commands/execution.ts +54 -14
- package/src/cli/commands/setup.ts +81 -64
- package/src/cli/commands/status.ts +3 -1
- package/src/cli/core/browser.ts +197 -9
- package/src/cli/core/config.ts +1 -0
- package/src/cli/core/providers/browserbase.ts +57 -0
- package/src/cli/core/providers/index.ts +62 -0
- package/src/cli/core/providers/kernel.ts +49 -0
- package/src/cli/core/providers/libretto-cloud.ts +61 -0
- package/src/cli/core/providers/types.ts +9 -0
- package/src/cli/core/session.ts +13 -0
- package/src/cli/workers/run-integration-runtime.ts +2 -0
- package/src/cli/workers/run-integration-worker-protocol.ts +2 -0
- package/src/shared/run/browser.ts +45 -0
- package/src/shared/state/session-state.ts +7 -0
package/src/cli/core/browser.ts
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
readSessionState,
|
|
38
38
|
writeSessionState,
|
|
39
39
|
} from "./session.js";
|
|
40
|
+
import type { ProviderApi } from "./providers/types.js";
|
|
41
|
+
import { getCloudProviderApi } from "./providers/index.js";
|
|
40
42
|
import { installSessionTelemetry } from "./session-telemetry.js";
|
|
41
43
|
|
|
42
44
|
const CLOSE_WAIT_MS = 1_500;
|
|
@@ -258,6 +260,16 @@ export async function connect(
|
|
|
258
260
|
endpoint,
|
|
259
261
|
pid: state.pid,
|
|
260
262
|
});
|
|
263
|
+
// Provider sessions have no local PID to check liveness.
|
|
264
|
+
// Don't destroy the remote session on a transient failure —
|
|
265
|
+
// let the user retry or explicitly close.
|
|
266
|
+
if (state.provider) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`Could not connect to ${state.provider.name} session for "${session}" at ${endpoint}. ` +
|
|
269
|
+
`The remote session may still be active. Try again, or close with: libretto close --session ${session}`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
261
273
|
if (state.pid == null || !isPidRunning(state.pid)) {
|
|
262
274
|
clearSessionState(session, logger);
|
|
263
275
|
throw new Error(
|
|
@@ -717,6 +729,106 @@ await new Promise(() => {});
|
|
|
717
729
|
);
|
|
718
730
|
}
|
|
719
731
|
|
|
732
|
+
export async function runOpenWithProvider(
|
|
733
|
+
rawUrl: string,
|
|
734
|
+
providerName: string,
|
|
735
|
+
provider: ProviderApi,
|
|
736
|
+
session: string,
|
|
737
|
+
logger: LoggerApi,
|
|
738
|
+
accessMode: SessionAccessMode = "write-access",
|
|
739
|
+
): Promise<void> {
|
|
740
|
+
const parsedUrl = normalizeUrl(rawUrl);
|
|
741
|
+
const url = parsedUrl.href;
|
|
742
|
+
logger.info("open-provider-start", { url, provider: providerName, session });
|
|
743
|
+
|
|
744
|
+
console.log(
|
|
745
|
+
`Creating ${providerName} browser session (session: ${session})...`,
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
const providerSession = await provider.createSession();
|
|
749
|
+
logger.info("open-provider-session-created", {
|
|
750
|
+
provider: providerName,
|
|
751
|
+
sessionId: providerSession.sessionId,
|
|
752
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
console.log(`Connecting to ${providerName} browser...`);
|
|
756
|
+
|
|
757
|
+
let browser: Browser | null = null;
|
|
758
|
+
try {
|
|
759
|
+
browser = await tryConnectToCDP(
|
|
760
|
+
providerSession.cdpEndpoint,
|
|
761
|
+
logger,
|
|
762
|
+
30_000,
|
|
763
|
+
);
|
|
764
|
+
if (!browser) {
|
|
765
|
+
throw new Error(
|
|
766
|
+
`Could not connect to ${providerName} browser at ${providerSession.cdpEndpoint}. The remote session was created but CDP connection failed.`,
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const contexts = browser.contexts();
|
|
771
|
+
let page: Page;
|
|
772
|
+
if (contexts.length > 0 && contexts[0].pages().length > 0) {
|
|
773
|
+
page = contexts[0].pages()[0];
|
|
774
|
+
} else {
|
|
775
|
+
const context =
|
|
776
|
+
contexts.length > 0 ? contexts[0] : await browser.newContext();
|
|
777
|
+
page = await context.newPage();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
await page.goto(url);
|
|
781
|
+
logger.info("open-provider-navigated", { url, session });
|
|
782
|
+
|
|
783
|
+
// Cloud sessions have no local port. Reconnection uses cdpEndpoint directly.
|
|
784
|
+
writeSessionState(
|
|
785
|
+
{
|
|
786
|
+
port: 0,
|
|
787
|
+
cdpEndpoint: providerSession.cdpEndpoint,
|
|
788
|
+
session,
|
|
789
|
+
startedAt: new Date().toISOString(),
|
|
790
|
+
status: "active",
|
|
791
|
+
mode: accessMode,
|
|
792
|
+
provider: {
|
|
793
|
+
name: providerName,
|
|
794
|
+
sessionId: providerSession.sessionId,
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
logger,
|
|
798
|
+
);
|
|
799
|
+
|
|
800
|
+
disconnectBrowser(browser, logger, session);
|
|
801
|
+
} catch (err) {
|
|
802
|
+
if (browser) {
|
|
803
|
+
disconnectBrowser(browser, logger, session);
|
|
804
|
+
}
|
|
805
|
+
// Clean up the remote session so it doesn't leak
|
|
806
|
+
logger.warn("open-provider-cleanup-after-error", {
|
|
807
|
+
provider: providerName,
|
|
808
|
+
sessionId: providerSession.sessionId,
|
|
809
|
+
error: err,
|
|
810
|
+
});
|
|
811
|
+
try {
|
|
812
|
+
await provider.closeSession(providerSession.sessionId);
|
|
813
|
+
} catch (cleanupErr) {
|
|
814
|
+
logger.warn("open-provider-cleanup-failed", {
|
|
815
|
+
provider: providerName,
|
|
816
|
+
sessionId: providerSession.sessionId,
|
|
817
|
+
error: cleanupErr,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
throw err;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
logger.info("open-provider-success", {
|
|
824
|
+
url,
|
|
825
|
+
provider: providerName,
|
|
826
|
+
session,
|
|
827
|
+
sessionId: providerSession.sessionId,
|
|
828
|
+
});
|
|
829
|
+
console.log(`Browser open (${providerName}): ${url}`);
|
|
830
|
+
}
|
|
831
|
+
|
|
720
832
|
export async function runSave(
|
|
721
833
|
urlOrDomain: string,
|
|
722
834
|
session: string,
|
|
@@ -811,11 +923,37 @@ export async function runClose(
|
|
|
811
923
|
return;
|
|
812
924
|
}
|
|
813
925
|
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
926
|
+
if (state.provider) {
|
|
927
|
+
// Cloud provider session — close via provider API, no local pid to kill
|
|
928
|
+
logger.info("close-provider", {
|
|
929
|
+
session,
|
|
930
|
+
provider: state.provider.name,
|
|
931
|
+
sessionId: state.provider.sessionId,
|
|
932
|
+
});
|
|
933
|
+
try {
|
|
934
|
+
const provider = getCloudProviderApi(state.provider.name);
|
|
935
|
+
await provider.closeSession(state.provider.sessionId);
|
|
936
|
+
} catch (err) {
|
|
937
|
+
logger.warn("close-provider-error", {
|
|
938
|
+
session,
|
|
939
|
+
provider: state.provider.name,
|
|
940
|
+
sessionId: state.provider.sessionId,
|
|
941
|
+
error: err,
|
|
942
|
+
});
|
|
943
|
+
// Preserve state with cleanup-failed status so the user can retry.
|
|
944
|
+
// The provider.sessionId is retained for manual or future cleanup.
|
|
945
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
946
|
+
throw new Error(
|
|
947
|
+
`Failed to close remote ${state.provider.name} session "${state.provider.sessionId}" for session "${session}". ` +
|
|
948
|
+
`State preserved with status "cleanup-failed". Retry with: libretto close --session ${session}`,
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
} else {
|
|
952
|
+
logger.info("close-killing", { session, pid: state.pid, port: state.port });
|
|
953
|
+
if (state.pid != null) {
|
|
954
|
+
sendSignalToProcessGroupOrPid(state.pid, "SIGTERM", logger, session);
|
|
955
|
+
await waitForCloseSignalWindow(CLOSE_WAIT_MS);
|
|
956
|
+
}
|
|
819
957
|
}
|
|
820
958
|
|
|
821
959
|
clearSessionState(session, logger);
|
|
@@ -827,6 +965,7 @@ type ClosableSession = {
|
|
|
827
965
|
session: string;
|
|
828
966
|
pid?: number;
|
|
829
967
|
port: number;
|
|
968
|
+
provider?: { name: string; sessionId: string };
|
|
830
969
|
};
|
|
831
970
|
|
|
832
971
|
function waitForCloseSignalWindow(ms: number): Promise<void> {
|
|
@@ -879,6 +1018,7 @@ function resolveClosableSessions(logger: LoggerApi): {
|
|
|
879
1018
|
session,
|
|
880
1019
|
pid: state.pid,
|
|
881
1020
|
port: state.port,
|
|
1021
|
+
provider: state.provider,
|
|
882
1022
|
});
|
|
883
1023
|
}
|
|
884
1024
|
|
|
@@ -888,9 +1028,11 @@ function resolveClosableSessions(logger: LoggerApi): {
|
|
|
888
1028
|
function clearStoppedSessionStates(
|
|
889
1029
|
sessions: ReadonlyArray<ClosableSession>,
|
|
890
1030
|
logger: LoggerApi,
|
|
1031
|
+
skip?: ReadonlySet<string>,
|
|
891
1032
|
): number {
|
|
892
1033
|
let cleared = 0;
|
|
893
1034
|
for (const session of sessions) {
|
|
1035
|
+
if (skip?.has(session.session)) continue;
|
|
894
1036
|
if (session.pid == null || !isPidRunning(session.pid)) {
|
|
895
1037
|
clearSessionState(session.session, logger);
|
|
896
1038
|
cleared += 1;
|
|
@@ -916,7 +1058,38 @@ export async function runCloseAll(
|
|
|
916
1058
|
return;
|
|
917
1059
|
}
|
|
918
1060
|
|
|
1061
|
+
// Close provider sessions via their APIs
|
|
1062
|
+
const failedProviderSessions = new Set<string>();
|
|
1063
|
+
for (const target of closable) {
|
|
1064
|
+
if (target.provider) {
|
|
1065
|
+
logger.info("close-all-provider", {
|
|
1066
|
+
session: target.session,
|
|
1067
|
+
provider: target.provider.name,
|
|
1068
|
+
sessionId: target.provider.sessionId,
|
|
1069
|
+
});
|
|
1070
|
+
try {
|
|
1071
|
+
const provider = getCloudProviderApi(target.provider.name);
|
|
1072
|
+
await provider.closeSession(target.provider.sessionId);
|
|
1073
|
+
} catch (err) {
|
|
1074
|
+
logger.warn("close-all-provider-error", {
|
|
1075
|
+
session: target.session,
|
|
1076
|
+
provider: target.provider.name,
|
|
1077
|
+
sessionId: target.provider.sessionId,
|
|
1078
|
+
error: err,
|
|
1079
|
+
});
|
|
1080
|
+
failedProviderSessions.add(target.session);
|
|
1081
|
+
// Mark as cleanup-failed, preserving provider.sessionId for retry
|
|
1082
|
+
const state = readSessionState(target.session, logger);
|
|
1083
|
+
if (state) {
|
|
1084
|
+
writeSessionState({ ...state, status: "cleanup-failed" }, logger);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Send SIGTERM to local sessions
|
|
919
1091
|
for (const target of closable) {
|
|
1092
|
+
if (target.provider) continue; // already handled above
|
|
920
1093
|
logger.info("close-all-sigterm", {
|
|
921
1094
|
session: target.session,
|
|
922
1095
|
pid: target.pid,
|
|
@@ -938,7 +1111,11 @@ export async function runCloseAll(
|
|
|
938
1111
|
(target) => target.pid != null && isPidRunning(target.pid),
|
|
939
1112
|
);
|
|
940
1113
|
if (survivors.length > 0 && !force) {
|
|
941
|
-
const closed = clearStoppedSessionStates(
|
|
1114
|
+
const closed = clearStoppedSessionStates(
|
|
1115
|
+
closable,
|
|
1116
|
+
logger,
|
|
1117
|
+
failedProviderSessions,
|
|
1118
|
+
);
|
|
942
1119
|
|
|
943
1120
|
throw new Error(
|
|
944
1121
|
[
|
|
@@ -971,7 +1148,11 @@ export async function runCloseAll(
|
|
|
971
1148
|
(target) => target.pid != null && isPidRunning(target.pid),
|
|
972
1149
|
);
|
|
973
1150
|
if (survivors.length > 0) {
|
|
974
|
-
const closed = clearStoppedSessionStates(
|
|
1151
|
+
const closed = clearStoppedSessionStates(
|
|
1152
|
+
closable,
|
|
1153
|
+
logger,
|
|
1154
|
+
failedProviderSessions,
|
|
1155
|
+
);
|
|
975
1156
|
throw new Error(
|
|
976
1157
|
[
|
|
977
1158
|
`Failed to force-close ${survivors.length} session(s): ${formatSessionList(survivors)}.`,
|
|
@@ -981,14 +1162,21 @@ export async function runCloseAll(
|
|
|
981
1162
|
}
|
|
982
1163
|
}
|
|
983
1164
|
|
|
984
|
-
clearStoppedSessionStates(closable, logger);
|
|
1165
|
+
clearStoppedSessionStates(closable, logger, failedProviderSessions);
|
|
1166
|
+
|
|
1167
|
+
if (failedProviderSessions.size > 0) {
|
|
1168
|
+
console.log(
|
|
1169
|
+
`Warning: ${failedProviderSessions.size} provider session(s) failed remote cleanup and were preserved with status "cleanup-failed".`,
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
985
1172
|
|
|
986
1173
|
if (clearedUnreadableStates > 0) {
|
|
987
1174
|
console.log(
|
|
988
1175
|
`Cleared ${clearedUnreadableStates} unreadable session state file(s).`,
|
|
989
1176
|
);
|
|
990
1177
|
}
|
|
991
|
-
|
|
1178
|
+
const closedCount = closable.length - failedProviderSessions.size;
|
|
1179
|
+
console.log(`Closed ${closedCount} session(s).`);
|
|
992
1180
|
if (forceKilled > 0) {
|
|
993
1181
|
console.log(`Force-killed ${forceKilled} session(s).`);
|
|
994
1182
|
}
|
package/src/cli/core/config.ts
CHANGED
|
@@ -42,6 +42,7 @@ export const LibrettoConfigSchema = z
|
|
|
42
42
|
ai: AiConfigSchema.optional(),
|
|
43
43
|
viewport: ViewportConfigSchema.optional(),
|
|
44
44
|
windowPosition: WindowPositionConfigSchema.optional(),
|
|
45
|
+
provider: z.string().optional(),
|
|
45
46
|
sessionMode: SessionAccessModeSchema.optional(),
|
|
46
47
|
})
|
|
47
48
|
.passthrough();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ProviderApi } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function createBrowserbaseProvider(): ProviderApi {
|
|
4
|
+
const apiKey = process.env.BROWSERBASE_API_KEY;
|
|
5
|
+
if (!apiKey)
|
|
6
|
+
throw new Error(
|
|
7
|
+
"BROWSERBASE_API_KEY is required for Browserbase provider.",
|
|
8
|
+
);
|
|
9
|
+
const projectId = process.env.BROWSERBASE_PROJECT_ID;
|
|
10
|
+
if (!projectId)
|
|
11
|
+
throw new Error(
|
|
12
|
+
"BROWSERBASE_PROJECT_ID is required for Browserbase provider.",
|
|
13
|
+
);
|
|
14
|
+
const endpoint =
|
|
15
|
+
process.env.BROWSERBASE_ENDPOINT ?? "https://api.browserbase.com";
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
async createSession() {
|
|
19
|
+
const resp = await fetch(`${endpoint}/v1/sessions`, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"X-BB-API-Key": apiKey,
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({ projectId }),
|
|
26
|
+
});
|
|
27
|
+
if (!resp.ok) {
|
|
28
|
+
const body = await resp.text();
|
|
29
|
+
throw new Error(`Browserbase API error (${resp.status}): ${body}`);
|
|
30
|
+
}
|
|
31
|
+
const json = (await resp.json()) as {
|
|
32
|
+
id: string;
|
|
33
|
+
connectUrl: string;
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
sessionId: json.id,
|
|
37
|
+
cdpEndpoint: json.connectUrl,
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
async closeSession(sessionId) {
|
|
41
|
+
const resp = await fetch(`${endpoint}/v1/sessions/${sessionId}`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: {
|
|
44
|
+
"X-BB-API-Key": apiKey,
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify({ status: "REQUEST_RELEASE" }),
|
|
48
|
+
});
|
|
49
|
+
if (!resp.ok) {
|
|
50
|
+
const body = await resp.text();
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Browserbase API error closing session ${sessionId} (${resp.status}): ${body}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readLibrettoConfig } from "../config.js";
|
|
2
|
+
import { createBrowserbaseProvider } from "./browserbase.js";
|
|
3
|
+
import { createKernelProvider } from "./kernel.js";
|
|
4
|
+
import { createLibrettoCloudProvider } from "./libretto-cloud.js";
|
|
5
|
+
import type { ProviderApi } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const VALID_PROVIDERS = new Set(["local", "kernel", "browserbase", "libretto-cloud"] as const);
|
|
8
|
+
export type ProviderName =
|
|
9
|
+
typeof VALID_PROVIDERS extends Set<infer T> ? T : never;
|
|
10
|
+
|
|
11
|
+
function assertValidProviderName(value: string, source: string): ProviderName {
|
|
12
|
+
if (!VALID_PROVIDERS.has(value as ProviderName)) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`Invalid provider "${value}" from ${source}. Valid providers: ${[...VALID_PROVIDERS].join(", ")}`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return value as ProviderName;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve which provider to use.
|
|
22
|
+
* Precedence: CLI flag > LIBRETTO_PROVIDER env var > config file > "local" default.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveProviderName(cliFlag?: string): ProviderName {
|
|
25
|
+
if (cliFlag) {
|
|
26
|
+
return assertValidProviderName(cliFlag, "--provider flag");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const envVar = process.env.LIBRETTO_PROVIDER;
|
|
30
|
+
if (envVar) {
|
|
31
|
+
return assertValidProviderName(envVar, "LIBRETTO_PROVIDER env var");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const config = readLibrettoConfig();
|
|
35
|
+
if (config.provider) {
|
|
36
|
+
return assertValidProviderName(config.provider, "config file");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return "local";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get a ProviderApi instance for a cloud provider.
|
|
44
|
+
* Only call this for non-"local" providers.
|
|
45
|
+
*/
|
|
46
|
+
export function getCloudProviderApi(name: string): ProviderApi {
|
|
47
|
+
switch (name) {
|
|
48
|
+
case "kernel":
|
|
49
|
+
return createKernelProvider();
|
|
50
|
+
case "browserbase":
|
|
51
|
+
return createBrowserbaseProvider();
|
|
52
|
+
case "libretto-cloud":
|
|
53
|
+
console.warn(
|
|
54
|
+
"Note: The libretto-cloud provider is in alpha.",
|
|
55
|
+
);
|
|
56
|
+
return createLibrettoCloudProvider();
|
|
57
|
+
default:
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Unknown provider "${name}". Valid cloud providers: kernel, browserbase`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ProviderApi } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function createKernelProvider(): ProviderApi {
|
|
4
|
+
const apiKey = process.env.KERNEL_API_KEY;
|
|
5
|
+
if (!apiKey)
|
|
6
|
+
throw new Error("KERNEL_API_KEY is required for Kernel provider.");
|
|
7
|
+
const endpoint = process.env.KERNEL_ENDPOINT ?? "https://api.onkernel.com";
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
async createSession() {
|
|
11
|
+
const resp = await fetch(`${endpoint}/browsers`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: {
|
|
14
|
+
Authorization: `Bearer ${apiKey}`,
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify({
|
|
18
|
+
headless: process.env.KERNEL_HEADLESS !== "false",
|
|
19
|
+
stealth: process.env.KERNEL_STEALTH === "true",
|
|
20
|
+
timeout_seconds: Number(process.env.KERNEL_TIMEOUT_SECONDS ?? 300),
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
if (!resp.ok) {
|
|
24
|
+
const body = await resp.text();
|
|
25
|
+
throw new Error(`Kernel API error (${resp.status}): ${body}`);
|
|
26
|
+
}
|
|
27
|
+
const json = (await resp.json()) as {
|
|
28
|
+
session_id: string;
|
|
29
|
+
cdp_ws_url: string;
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
sessionId: json.session_id,
|
|
33
|
+
cdpEndpoint: json.cdp_ws_url,
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
async closeSession(sessionId) {
|
|
37
|
+
const resp = await fetch(`${endpoint}/browsers/${sessionId}`, {
|
|
38
|
+
method: "DELETE",
|
|
39
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
40
|
+
});
|
|
41
|
+
if (!resp.ok) {
|
|
42
|
+
const body = await resp.text();
|
|
43
|
+
throw new Error(
|
|
44
|
+
`Kernel API error closing session ${sessionId} (${resp.status}): ${body}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ProviderApi } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function createLibrettoCloudProvider(): ProviderApi {
|
|
4
|
+
const apiKey = process.env.LIBRETTO_API_KEY;
|
|
5
|
+
if (!apiKey)
|
|
6
|
+
throw new Error(
|
|
7
|
+
"LIBRETTO_API_KEY is required for the Libretto Cloud provider.",
|
|
8
|
+
);
|
|
9
|
+
const apiUrl = process.env.LIBRETTO_API_URL;
|
|
10
|
+
if (!apiUrl)
|
|
11
|
+
throw new Error(
|
|
12
|
+
"LIBRETTO_API_URL is required for the Libretto Cloud provider.",
|
|
13
|
+
);
|
|
14
|
+
const endpoint = apiUrl.replace(/\/$/, "");
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
async createSession() {
|
|
18
|
+
const timeoutSeconds = Number(
|
|
19
|
+
process.env.LIBRETTO_TIMEOUT_SECONDS ?? 7200,
|
|
20
|
+
);
|
|
21
|
+
const resp = await fetch(`${endpoint}/v1/sessions/create`, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: {
|
|
24
|
+
"x-api-key": apiKey,
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
body: JSON.stringify({ timeout_seconds: timeoutSeconds }),
|
|
28
|
+
});
|
|
29
|
+
if (!resp.ok) {
|
|
30
|
+
const body = await resp.text();
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Libretto Cloud API error (${resp.status}): ${body}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const json = (await resp.json()) as {
|
|
36
|
+
session_id: string;
|
|
37
|
+
cdp_url: string;
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
sessionId: json.session_id,
|
|
41
|
+
cdpEndpoint: json.cdp_url,
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
async closeSession(sessionId) {
|
|
45
|
+
const resp = await fetch(`${endpoint}/v1/sessions/close`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: {
|
|
48
|
+
"x-api-key": apiKey,
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({ session_id: sessionId }),
|
|
52
|
+
});
|
|
53
|
+
if (!resp.ok) {
|
|
54
|
+
const body = await resp.text();
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ProviderSession = {
|
|
2
|
+
sessionId: string; // remote session id for cleanup
|
|
3
|
+
cdpEndpoint: string; // CDP WebSocket URL
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type ProviderApi = {
|
|
7
|
+
createSession(): Promise<ProviderSession>;
|
|
8
|
+
closeSession(sessionId: string): Promise<void>;
|
|
9
|
+
};
|
package/src/cli/core/session.ts
CHANGED
|
@@ -129,6 +129,10 @@ export function listRunningSessions(): SessionState[] {
|
|
|
129
129
|
for (const name of sessions) {
|
|
130
130
|
const state = readSessionState(name);
|
|
131
131
|
if (!state) continue;
|
|
132
|
+
if (state.provider) {
|
|
133
|
+
running.push(state);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
132
136
|
if (state.pid == null || !isPidRunning(state.pid)) continue;
|
|
133
137
|
running.push(state);
|
|
134
138
|
}
|
|
@@ -272,6 +276,15 @@ export function assertSessionAvailableForStart(
|
|
|
272
276
|
): void {
|
|
273
277
|
const existingState = readSessionState(session, logger);
|
|
274
278
|
if (!existingState) return;
|
|
279
|
+
|
|
280
|
+
// Cloud provider sessions have no local PID — treat them as active
|
|
281
|
+
// if they have a provider field with a cdpEndpoint.
|
|
282
|
+
if (existingState.provider && existingState.cdpEndpoint) {
|
|
283
|
+
throw new Error(
|
|
284
|
+
`Session "${session}" is already open via ${existingState.provider.name} provider. Close it first with: libretto close --session ${session}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
275
288
|
if (existingState.pid == null || !isPidRunning(existingState.pid)) {
|
|
276
289
|
setSessionStatus(session, "exited", logger);
|
|
277
290
|
return;
|
|
@@ -239,6 +239,8 @@ async function runIntegrationInternal(
|
|
|
239
239
|
storageStatePath,
|
|
240
240
|
viewport: args.viewport,
|
|
241
241
|
accessMode: args.accessMode,
|
|
242
|
+
cdpEndpoint: args.cdpEndpoint,
|
|
243
|
+
provider: args.provider,
|
|
242
244
|
});
|
|
243
245
|
if (!args.headless && args.visualize !== false) {
|
|
244
246
|
await installHeadedWorkflowVisualization({
|
|
@@ -10,6 +10,8 @@ export const RunIntegrationWorkerRequestSchema = z.object({
|
|
|
10
10
|
authProfileDomain: z.string().optional(),
|
|
11
11
|
viewport: z.object({ width: z.number(), height: z.number() }).optional(),
|
|
12
12
|
accessMode: SessionAccessModeSchema.default("write-access"),
|
|
13
|
+
cdpEndpoint: z.string().optional(),
|
|
14
|
+
provider: z.object({ name: z.string(), sessionId: z.string() }).optional(),
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
export type RunIntegrationWorkerRequest = z.infer<
|
|
@@ -36,6 +36,8 @@ export type LaunchBrowserArgs = {
|
|
|
36
36
|
viewport?: { width: number; height: number };
|
|
37
37
|
storageStatePath?: string;
|
|
38
38
|
accessMode?: SessionAccessMode;
|
|
39
|
+
cdpEndpoint?: string;
|
|
40
|
+
provider?: { name: string; sessionId: string };
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
export type BrowserSession = {
|
|
@@ -100,7 +102,50 @@ export async function launchBrowser({
|
|
|
100
102
|
viewport = { width: 1366, height: 768 },
|
|
101
103
|
storageStatePath,
|
|
102
104
|
accessMode = "write-access",
|
|
105
|
+
cdpEndpoint,
|
|
106
|
+
provider,
|
|
103
107
|
}: LaunchBrowserArgs): Promise<BrowserSession> {
|
|
108
|
+
// Cloud/remote mode: connect to an existing CDP endpoint instead of launching locally
|
|
109
|
+
if (cdpEndpoint) {
|
|
110
|
+
const browser = await chromium.connectOverCDP(cdpEndpoint);
|
|
111
|
+
const context =
|
|
112
|
+
browser.contexts()[0] ?? (await browser.newContext({ viewport }));
|
|
113
|
+
const page = context.pages()[0] ?? (await context.newPage());
|
|
114
|
+
page.setDefaultTimeout(30_000);
|
|
115
|
+
page.setDefaultNavigationTimeout(45_000);
|
|
116
|
+
|
|
117
|
+
const metadataPath = ensureLibrettoSessionStatePath(sessionName);
|
|
118
|
+
writeFileSync(
|
|
119
|
+
metadataPath,
|
|
120
|
+
JSON.stringify(
|
|
121
|
+
{
|
|
122
|
+
version: SESSION_STATE_VERSION,
|
|
123
|
+
session: sessionName,
|
|
124
|
+
port: 0,
|
|
125
|
+
cdpEndpoint,
|
|
126
|
+
pid: process.pid,
|
|
127
|
+
startedAt: new Date().toISOString(),
|
|
128
|
+
status: "active",
|
|
129
|
+
mode: accessMode,
|
|
130
|
+
...(provider ? { provider } : {}),
|
|
131
|
+
},
|
|
132
|
+
null,
|
|
133
|
+
2,
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
browser,
|
|
139
|
+
context,
|
|
140
|
+
page,
|
|
141
|
+
debugPort: 0,
|
|
142
|
+
metadataPath,
|
|
143
|
+
close: async () => {
|
|
144
|
+
await browser.close();
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
104
149
|
const debugPort = await pickFreePort();
|
|
105
150
|
const windowPosition = headless ? undefined : resolveWindowPosition();
|
|
106
151
|
const browser = await chromium.launch({
|
|
@@ -8,6 +8,7 @@ export const SessionStatusSchema = z.enum([
|
|
|
8
8
|
"completed",
|
|
9
9
|
"failed",
|
|
10
10
|
"exited",
|
|
11
|
+
"cleanup-failed",
|
|
11
12
|
]);
|
|
12
13
|
export const SessionAccessModeSchema = z.enum(["read-only", "write-access"]);
|
|
13
14
|
export const SessionViewportSchema = z.object({
|
|
@@ -15,6 +16,11 @@ export const SessionViewportSchema = z.object({
|
|
|
15
16
|
height: z.number().int().min(1),
|
|
16
17
|
});
|
|
17
18
|
|
|
19
|
+
export const ProviderStateSchema = z.object({
|
|
20
|
+
name: z.string(),
|
|
21
|
+
sessionId: z.string(),
|
|
22
|
+
});
|
|
23
|
+
|
|
18
24
|
export const SessionStateFileSchema = z.object({
|
|
19
25
|
version: z.literal(SESSION_STATE_VERSION),
|
|
20
26
|
port: z.number().int().min(0).max(65535),
|
|
@@ -25,6 +31,7 @@ export const SessionStateFileSchema = z.object({
|
|
|
25
31
|
status: SessionStatusSchema.optional(),
|
|
26
32
|
mode: SessionAccessModeSchema.default("write-access"),
|
|
27
33
|
viewport: SessionViewportSchema.optional(),
|
|
34
|
+
provider: ProviderStateSchema.optional(),
|
|
28
35
|
});
|
|
29
36
|
|
|
30
37
|
export type SessionStatus = z.infer<typeof SessionStatusSchema>;
|