源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@openwork/hosts",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./daytona": {
|
||||
"types": "./src/daytona.ts",
|
||||
"development": "./src/daytona.ts",
|
||||
"default": "./src/daytona.ts"
|
||||
},
|
||||
"./local": {
|
||||
"types": "./src/local.ts",
|
||||
"development": "./src/local.ts",
|
||||
"default": "./src/local.ts"
|
||||
},
|
||||
"./resolve": {
|
||||
"types": "./src/resolve.ts",
|
||||
"development": "./src/resolve.ts",
|
||||
"default": "./src/resolve.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"development": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@openwork/cdp": "workspace:*",
|
||||
"@openwork/paths": "link:../../../packages/paths",
|
||||
"@openwork/timeline": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { attachSurface } from "@openwork/cdp";
|
||||
import { resolveHost } from "./resolve.ts";
|
||||
import type { AttachedSurface, SurfaceHandle } from "@openwork/cdp";
|
||||
import type { Host } from "./types.ts";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
|
||||
function messageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export interface BrowserOptions {
|
||||
name?: string;
|
||||
/**
|
||||
* Where this browser runs. Defaults to the ambient host (`resolveHost()`).
|
||||
* Pass one from `localHost()` / `daytonaSandbox(id)` to place it somewhere
|
||||
* other than the app under test.
|
||||
*/
|
||||
host?: Host;
|
||||
startUrl?: string;
|
||||
headless?: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A real browser, placed explicitly — the counterpart to `desktop()`.
|
||||
*
|
||||
* Owning disposal here matters: `attachSurface` only closes the CDP socket, so
|
||||
* hand-rolled `spawnChrome` + `attachSurface` left the browser PROCESS running
|
||||
* after every spec. Disposing this handle stops the client and then asks the
|
||||
* host to dispose the surface, which is what actually kills it.
|
||||
*/
|
||||
export async function chrome(opts: BrowserOptions = {}): Promise<AttachedSurface> {
|
||||
const host = opts.host ?? await resolveHost();
|
||||
const name = opts.name ?? "browser";
|
||||
const handle: SurfaceHandle = await host.spawnChrome(name, {
|
||||
profile: "fresh",
|
||||
startUrl: opts.startUrl,
|
||||
headless: opts.headless,
|
||||
});
|
||||
|
||||
let surface: AttachedSurface;
|
||||
try {
|
||||
surface = await attachSurface(handle, { timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
await host.disposeSurface(handle).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Mutate rather than copy: `reattachSurface`/`evaluateOnSurface` assign to
|
||||
// `surface.client`, so callers must hold the same object the CDP layer heals.
|
||||
let stopped = false;
|
||||
const closeClient = surface.stop.bind(surface);
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
try {
|
||||
await closeClient();
|
||||
} finally {
|
||||
await host.disposeSurface(handle);
|
||||
}
|
||||
};
|
||||
surface.stop = stop;
|
||||
surface[Symbol.asyncDispose] = async (): Promise<void> => {
|
||||
await stop().catch((error: unknown) => {
|
||||
console.warn(`[openwork/evals] Browser ${name} cleanup failed: ${messageText(error)}`);
|
||||
});
|
||||
};
|
||||
return surface;
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ChromeSurfaceOptions, DenServiceHandle, DenServiceOptions, ElectronSurfaceOptions, Host, ShareLinks, SurfaceHandle } from "./types.ts";
|
||||
|
||||
export interface DaytonaExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export type DaytonaExec = (
|
||||
args: string[],
|
||||
opts?: { input?: string; timeoutMs?: number },
|
||||
) => Promise<DaytonaExecResult>;
|
||||
|
||||
export interface DaytonaHostOptions {
|
||||
sandboxId?: string;
|
||||
log: (msg: string) => void;
|
||||
exec?: DaytonaExec;
|
||||
repoRoot: string;
|
||||
reservedChromePorts?: number[];
|
||||
reservedElectronPorts?: number[];
|
||||
serverScript?: boolean;
|
||||
waitForCdp?: (url: string, timeoutMs: number, label: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaytonaHost extends Host, AsyncDisposable {
|
||||
previewUrl(port: number): Promise<string>;
|
||||
startDen(opts?: DenServiceOptions): Promise<DenServiceHandle>;
|
||||
share(): Promise<ShareLinks>;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export type EnterpriseTlsEdgeDaytonaOptions = {
|
||||
sandboxId: string;
|
||||
upstream: string;
|
||||
candidatePort?: number;
|
||||
negativePort?: number;
|
||||
adminPort?: number;
|
||||
manifestPath?: string;
|
||||
};
|
||||
|
||||
export type EnterpriseTlsEdgeDaytonaCommands = {
|
||||
candidateUrl: string;
|
||||
negativeUrl: string;
|
||||
adminUrl: string;
|
||||
manifestPath: string;
|
||||
prepare: string[][];
|
||||
start: string[];
|
||||
probe: string[];
|
||||
requests: string[];
|
||||
installRoot: string[];
|
||||
removeRoot: string[];
|
||||
stop: string[];
|
||||
};
|
||||
|
||||
interface PortAllocation {
|
||||
primary: number;
|
||||
next: number;
|
||||
used: Set<number>;
|
||||
}
|
||||
|
||||
interface ProcessRunOptions {
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
onOutput: (text: string) => void;
|
||||
}
|
||||
|
||||
const ELECTRON_CDP_WAIT_MS = 180_000;
|
||||
const CHROME_CDP_WAIT_MS = 60_000;
|
||||
const CDP_POLL_INTERVAL_MS = 1_000;
|
||||
const SERVER_SCRIPT_TIMEOUT_MS = 20 * 60 * 1_000;
|
||||
const STANDARD_NOVNC_PORT = 6080;
|
||||
const STANDARD_ARTIFACTS_PORT = 8090;
|
||||
const HTTPS_URL = /https:\/\/[^\s"'<>)]+/;
|
||||
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const ENTERPRISE_TLS_RUNTIME_ROOT = "/tmp/openwork-enterprise-tls-runtime";
|
||||
const MAX_ENTERPRISE_TLS_RUNTIME_SOURCE_BYTES = 64 * 1024;
|
||||
const ENTERPRISE_TLS_BASE64_CHUNK_LENGTH = 8 * 1024;
|
||||
/** Conservative ceiling for each complete Daytona argv command string. */
|
||||
export const MAX_ENTERPRISE_TLS_DAYTONA_COMMAND_LENGTH = 12 * 1024;
|
||||
const ENTERPRISE_TLS_RUNTIME_SOURCES = [
|
||||
{
|
||||
local: new URL("../../../scripts/enterprise-tls-edge.mts", import.meta.url),
|
||||
remote: `${ENTERPRISE_TLS_RUNTIME_ROOT}/evals/scripts/enterprise-tls-edge.mts`,
|
||||
},
|
||||
{
|
||||
local: new URL("../../labs/src/egress.ts", import.meta.url),
|
||||
remote: `${ENTERPRISE_TLS_RUNTIME_ROOT}/evals/packages/labs/src/egress.ts`,
|
||||
},
|
||||
];
|
||||
|
||||
function messageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function defaultDaytonaExec(
|
||||
args: string[],
|
||||
opts: { input?: string; timeoutMs?: number } = {},
|
||||
command = "daytona",
|
||||
): Promise<DaytonaExecResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
const timer = opts.timeoutMs
|
||||
? globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
}, opts.timeoutMs)
|
||||
: null;
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({
|
||||
stdout,
|
||||
stderr: timedOut ? `${stderr}\nTimed out after ${opts.timeoutMs}ms.` : stderr,
|
||||
code: timedOut ? 124 : code ?? 1,
|
||||
});
|
||||
});
|
||||
child.stdin.on("error", (error) => {
|
||||
if (("code" in error && error.code === "EPIPE") || settled) return;
|
||||
if (timer) clearTimeout(timer);
|
||||
settled = true;
|
||||
reject(error);
|
||||
});
|
||||
child.stdin.end(opts.input ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
function runProcess(command: string, args: string[], opts: ProcessRunOptions): Promise<DaytonaExecResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { cwd: opts.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
}, opts.timeoutMs);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
stdout += text;
|
||||
opts.onOutput(text);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
stderr += text;
|
||||
opts.onOutput(text);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
stdout,
|
||||
stderr: timedOut ? `${stderr}\nTimed out after ${opts.timeoutMs}ms.` : stderr,
|
||||
code: timedOut ? 124 : code ?? 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function assertEnvName(name: string): void {
|
||||
if (!ENV_NAME.test(name)) {
|
||||
throw new Error(`Invalid environment variable name for Daytona surface: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
function shellExport(assignments: Map<string, string>): string {
|
||||
const parts: string[] = [];
|
||||
for (const [name, value] of assignments) {
|
||||
assertEnvName(name);
|
||||
parts.push(`${name}=${shellQuote(value)}`);
|
||||
}
|
||||
return `export ${parts.join(" ")};`;
|
||||
}
|
||||
|
||||
function sanitizeName(name: string): string {
|
||||
const normalized: string[] = [];
|
||||
let invalidRun = false;
|
||||
for (const character of name.trim()) {
|
||||
const code = character.codePointAt(0) ?? 0;
|
||||
const allowed = (code >= 48 && code <= 57)
|
||||
|| (code >= 65 && code <= 90)
|
||||
|| (code >= 97 && code <= 122)
|
||||
|| character === "."
|
||||
|| character === "_"
|
||||
|| character === "-";
|
||||
if (allowed) {
|
||||
normalized.push(character);
|
||||
invalidRun = false;
|
||||
} else if (!invalidRun) {
|
||||
normalized.push("-");
|
||||
invalidRun = true;
|
||||
}
|
||||
}
|
||||
let start = 0;
|
||||
let end = normalized.length;
|
||||
while (start < end && normalized[start] === "-") start += 1;
|
||||
while (end > start && normalized[end - 1] === "-") end -= 1;
|
||||
return normalized.slice(start, end).join("") || "surface";
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 17);
|
||||
}
|
||||
|
||||
function firstHttpsUrl(text: string): string | null {
|
||||
const match = HTTPS_URL.exec(text);
|
||||
return match ? match[0].replace(/[.,;:]+$/, "") : null;
|
||||
}
|
||||
|
||||
function cleanBaseUrl(value: string): string {
|
||||
return value.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isOrgMode(value: unknown): value is "single_org" | "multi_org" {
|
||||
return value === "single_org" || value === "multi_org";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function runtimeOrgMode(webUrl: string): Promise<"single_org" | "multi_org"> {
|
||||
const response = await fetch(`${cleanBaseUrl(webUrl)}/api/runtime-config`, { signal: AbortSignal.timeout(5_000) });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const body: unknown = await response.json();
|
||||
if (isRecord(body) && isOrgMode(body.orgMode)) return body.orgMode;
|
||||
throw new Error("response did not include orgMode");
|
||||
}
|
||||
|
||||
async function orgModeOrDefault(webUrl: string, log: (msg: string) => void): Promise<"single_org" | "multi_org"> {
|
||||
try {
|
||||
return await runtimeOrgMode(webUrl);
|
||||
} catch (error) {
|
||||
log(`Could not read Den runtime config from ${cleanBaseUrl(webUrl)}/api/runtime-config; assuming multi_org: ${messageText(error)}`);
|
||||
return "multi_org";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Daytona CLI transport failures print `level=fatal msg="..."` (HTML error
|
||||
* pages, EOFs, resets from the Daytona API) and mean the remote command never
|
||||
* executed, so retrying is always safe. Remote command failures — a non-zero
|
||||
* exit without a CLI fatal transport message — are never retried.
|
||||
*/
|
||||
const TRANSIENT_DAYTONA_CLI_MESSAGES = [
|
||||
/invalid character '<'/i,
|
||||
/unexpected EOF/i,
|
||||
/^EOF$/,
|
||||
/unexpected end of JSON input/i,
|
||||
/connection reset/i,
|
||||
/bad gateway/i,
|
||||
/service unavailable/i,
|
||||
/too many requests/i,
|
||||
];
|
||||
|
||||
function transientDaytonaCliFailure(result: DaytonaExecResult): boolean {
|
||||
if (result.code === 0) return false;
|
||||
const fatalMessages = [...`${result.stderr}\n${result.stdout}`.matchAll(/level=fatal msg="((?:[^"\\]|\\.)*)"/g)]
|
||||
.map((match) => match[1]);
|
||||
return fatalMessages.some((message) => TRANSIENT_DAYTONA_CLI_MESSAGES.some((pattern) => pattern.test(message)));
|
||||
}
|
||||
|
||||
export async function checkedExec(exec: DaytonaExec, args: string[], context: string, opts: { input?: string; timeoutMs?: number; retryDelayMs?: number } = {}): Promise<DaytonaExecResult> {
|
||||
const attempts = 3;
|
||||
const { retryDelayMs, ...execOpts } = opts;
|
||||
let result = await exec(args, execOpts);
|
||||
for (let attempt = 1; attempt < attempts && transientDaytonaCliFailure(result); attempt += 1) {
|
||||
await setTimeout(retryDelayMs ?? attempt * 2_000);
|
||||
result = await exec(args, execOpts);
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
const stdout = result.stdout.trim();
|
||||
const details = [stderr ? `stderr:\n${stderr}` : "", stdout ? `stdout:\n${stdout}` : ""].filter(Boolean).join("\n\n");
|
||||
throw new Error(`${context} failed with exit ${result.code}${details ? `:\n${details}` : ""}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Daytona exec argv for a co-located enterprise TLS edge lifecycle. */
|
||||
export function enterpriseTlsEdgeDaytonaCommands(options: EnterpriseTlsEdgeDaytonaOptions): EnterpriseTlsEdgeDaytonaCommands {
|
||||
const sandbox = options.sandboxId.trim();
|
||||
if (!sandbox) throw new Error("Enterprise TLS edge Daytona sandboxId is required.");
|
||||
const upstream = new URL(options.upstream);
|
||||
if ((upstream.protocol !== "http:" && upstream.protocol !== "https:")
|
||||
|| upstream.username || upstream.password || upstream.pathname !== "/" || upstream.search || upstream.hash) {
|
||||
throw new Error("Enterprise TLS edge upstream must be an HTTP(S) origin.");
|
||||
}
|
||||
const candidatePort = options.candidatePort ?? 8443;
|
||||
const negativePort = options.negativePort ?? 9443;
|
||||
const adminPort = options.adminPort ?? 8445;
|
||||
for (const port of [candidatePort, negativePort, adminPort]) {
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error(`Invalid enterprise TLS edge port: ${port}`);
|
||||
}
|
||||
if (new Set([candidatePort, negativePort, adminPort]).size !== 3) {
|
||||
throw new Error("Enterprise TLS edge candidate, negative, and admin ports must be distinct.");
|
||||
}
|
||||
const manifestPath = options.manifestPath ?? "/tmp/openwork-enterprise-tls-edge.json";
|
||||
if (!manifestPath.startsWith("/")) throw new Error("Enterprise TLS edge manifestPath must be absolute.");
|
||||
const sources = ENTERPRISE_TLS_RUNTIME_SOURCES.map(({ local, remote }) => ({
|
||||
content: readFileSync(fileURLToPath(local)),
|
||||
remote,
|
||||
}));
|
||||
const sourceBytes = sources.reduce((total, source) => total + source.content.byteLength, 0);
|
||||
if (sourceBytes > MAX_ENTERPRISE_TLS_RUNTIME_SOURCE_BYTES) {
|
||||
throw new Error(`Enterprise TLS runtime source is ${sourceBytes} bytes; maximum is ${MAX_ENTERPRISE_TLS_RUNTIME_SOURCE_BYTES}.`);
|
||||
}
|
||||
const script = ENTERPRISE_TLS_RUNTIME_SOURCES[0].remote;
|
||||
const log = "/tmp/openwork-enterprise-tls-edge.log";
|
||||
const adminToken = randomBytes(32).toString("hex");
|
||||
if (!/^[a-f0-9]{32,}$/.test(adminToken)) throw new Error("Enterprise TLS admin token must be at least 32 hex characters.");
|
||||
const remote = (command: string) => {
|
||||
const args = ["exec", sandbox, "--", `bash -lc ${shellQuote(command)}`];
|
||||
const commandLength = args.join(" ").length;
|
||||
if (commandLength > MAX_ENTERPRISE_TLS_DAYTONA_COMMAND_LENGTH) {
|
||||
throw new Error(`Enterprise TLS Daytona command is ${commandLength} characters; maximum is ${MAX_ENTERPRISE_TLS_DAYTONA_COMMAND_LENGTH}.`);
|
||||
}
|
||||
return args;
|
||||
};
|
||||
const directories = sources.map((source) => source.remote.slice(0, source.remote.lastIndexOf("/")));
|
||||
const prepare = [remote([
|
||||
`/usr/bin/rm -rf ${ENTERPRISE_TLS_RUNTIME_ROOT}`,
|
||||
`/usr/bin/mkdir -p ${directories.join(" ")}`,
|
||||
`/usr/bin/touch ${sources.map((source) => `${source.remote}.b64`).join(" ")}`,
|
||||
].join(" && "))];
|
||||
for (const source of sources) {
|
||||
const encoded = source.content.toString("base64");
|
||||
for (let offset = 0; offset < encoded.length; offset += ENTERPRISE_TLS_BASE64_CHUNK_LENGTH) {
|
||||
const chunk = encoded.slice(offset, offset + ENTERPRISE_TLS_BASE64_CHUNK_LENGTH);
|
||||
prepare.push(remote(`/usr/bin/printf %s ${chunk} >> ${source.remote}.b64`));
|
||||
}
|
||||
prepare.push(remote([
|
||||
"decode_status=0",
|
||||
`/usr/bin/base64 -d ${source.remote}.b64 > ${source.remote} || decode_status=$?`,
|
||||
`actual_bytes=$(/usr/bin/wc -c < ${source.remote})`,
|
||||
`/usr/bin/rm -f ${source.remote}.b64`,
|
||||
'test "$decode_status" -eq 0',
|
||||
`test "$actual_bytes" -eq ${source.content.byteLength}`,
|
||||
].join("; ")));
|
||||
}
|
||||
const serve = [
|
||||
"/usr/bin/env", "node", script, "serve",
|
||||
"--upstream", upstream.origin,
|
||||
"--candidate-port", String(candidatePort),
|
||||
"--negative-port", String(negativePort),
|
||||
"--admin-port", String(adminPort),
|
||||
"--manifest", manifestPath,
|
||||
].map(shellQuote).join(" ");
|
||||
const action = (name: "install" | "remove") => remote([
|
||||
"node_path=$(command -v node)",
|
||||
'test -n "$node_path"',
|
||||
`/usr/bin/sudo -n "$node_path" ${[script, name, "--manifest", manifestPath].map(shellQuote).join(" ")}`,
|
||||
].join(" && "));
|
||||
const adminUrl = `http://127.0.0.1:${adminPort}`;
|
||||
return {
|
||||
candidateUrl: `https://localhost:${candidatePort}`,
|
||||
negativeUrl: `https://localhost:${negativePort}`,
|
||||
adminUrl,
|
||||
manifestPath,
|
||||
prepare,
|
||||
start: remote([
|
||||
`rm -f ${shellQuote(manifestPath)}`,
|
||||
`ENTERPRISE_TLS_ADMIN_TOKEN=${adminToken} nohup ${serve} >${shellQuote(log)} 2>&1 </dev/null &`,
|
||||
"attempt=0",
|
||||
`until /usr/bin/curl --fail --silent -H "Authorization: Bearer ${adminToken}" ${shellQuote(`${adminUrl}/health`)} >/dev/null; do attempt=$((attempt + 1)); if [ "$attempt" -ge 120 ]; then /usr/bin/tail -c 4000 ${shellQuote(log)} >&2; exit 1; fi; sleep 0.25; done`,
|
||||
].join("\n")),
|
||||
probe: remote(`/usr/bin/curl --fail --silent --show-error -H "Authorization: Bearer ${adminToken}" ${shellQuote(`${adminUrl}/health`)}`),
|
||||
requests: remote(`/usr/bin/curl --fail --silent --show-error -H "Authorization: Bearer ${adminToken}" ${shellQuote(`${adminUrl}/requests`)}`),
|
||||
installRoot: action("install"),
|
||||
removeRoot: action("remove"),
|
||||
stop: remote(`${[
|
||||
"/usr/bin/env", "node", script, "stop", "--manifest", manifestPath,
|
||||
].map(shellQuote).join(" ")} && /usr/bin/rm -rf ${shellQuote(ENTERPRISE_TLS_RUNTIME_ROOT)}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForHttpOk(url: string, timeoutMs: number, label: string): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
let lastError = "not attempted";
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
|
||||
if (response.ok) return;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
} catch (error) {
|
||||
lastError = messageText(error);
|
||||
}
|
||||
await setTimeout(CDP_POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${label} at ${url} (last error: ${lastError}).`);
|
||||
}
|
||||
|
||||
function allocatePort(allocation: PortAllocation): number {
|
||||
if (!allocation.used.has(allocation.primary)) {
|
||||
allocation.used.add(allocation.primary);
|
||||
return allocation.primary;
|
||||
}
|
||||
let port = allocation.next;
|
||||
while (allocation.used.has(port)) port += 1;
|
||||
allocation.used.add(port);
|
||||
allocation.next = port + 1;
|
||||
return port;
|
||||
}
|
||||
|
||||
function releasePort(allocation: PortAllocation, port: number): void {
|
||||
allocation.used.delete(port);
|
||||
}
|
||||
|
||||
function cdpPort(handle: SurfaceHandle): number | null {
|
||||
const raw = handle.meta?.cdpPort;
|
||||
if (!raw || !/^\d+$/.test(raw)) return null;
|
||||
return Number.parseInt(raw, 10);
|
||||
}
|
||||
|
||||
function regexEscape(value: string): string {
|
||||
return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
||||
}
|
||||
|
||||
function charClassEscape(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]").replace(/\^/g, "\\^").replace(/-/g, "\\-");
|
||||
}
|
||||
|
||||
function selfMatchSafeLiteral(value: string): string {
|
||||
if (!value) return "";
|
||||
const first = value[0] ?? "";
|
||||
return `[${charClassEscape(first)}]${regexEscape(value.slice(1))}`;
|
||||
}
|
||||
|
||||
function selfMatchSafePortPattern(processPrefix: string, port: number): string {
|
||||
const value = String(port);
|
||||
return `${selfMatchSafeLiteral(processPrefix)}.*remote-debugging-port=${selfMatchSafeLiteral(value)}`;
|
||||
}
|
||||
|
||||
function electronProfilePattern(profileDir: string): string {
|
||||
return `([e]lectron|[/]proc/self/exe).*--user-data-dir=${selfMatchSafeLiteral(profileDir)}`;
|
||||
}
|
||||
|
||||
function electronProfileRoot(profileDir: string): string {
|
||||
const suffix = "/electron-userdata";
|
||||
return profileDir.endsWith(suffix) ? profileDir.slice(0, -suffix.length) : profileDir;
|
||||
}
|
||||
|
||||
function killGroupsForPatternCommand(pattern: string, signal: "TERM" | "KILL"): string {
|
||||
return `for pid in $(pgrep -f ${shellQuote(pattern)} || true); do pgid=$(ps -o pgid= -p "$pid" | tr -d ' '); if [ -n "$pgid" ]; then kill -${signal} -"$pgid" 2>/dev/null || true; fi; done`;
|
||||
}
|
||||
|
||||
function parseUrlAfterLabels(output: string, labels: string[]): string | null {
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
for (const label of labels) {
|
||||
if (line.includes(label)) {
|
||||
const url = firstHttpsUrl(line);
|
||||
if (url) return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function serverRefArg(): string | null {
|
||||
const explicit = process.env.OPENWORK_EVAL_DAYTONA_REF?.trim() || process.env.OPENWORK_EVAL_REF?.trim() || "";
|
||||
return explicit || null;
|
||||
}
|
||||
|
||||
function portSet(values: number[] | undefined): Set<number> {
|
||||
const ports = new Set<number>();
|
||||
for (const value of values ?? []) {
|
||||
if (Number.isInteger(value) && value > 0 && value <= 65_535) ports.add(value);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ports already listening INSIDE the sandbox.
|
||||
*
|
||||
* The local host finds a free port by binding one; this host cannot, because the
|
||||
* port has to be free on a different machine. Allocating from a local counter
|
||||
* alone silently collides: the OpenCode sidecar picks its own port at boot and
|
||||
* was observed holding 9825 — the CDP primary — so Electron's debugger never
|
||||
* bound and the preview URL timed out after 180s with no hint of the cause.
|
||||
*/
|
||||
async function sandboxListeningPorts(exec: DaytonaExec, sandbox: string): Promise<Set<number>> {
|
||||
const result = await exec(["exec", sandbox, "--", "bash -lc 'ss -ltn 2>/dev/null || netstat -ltn 2>/dev/null'"], { timeoutMs: 30_000 });
|
||||
const ports = new Set<number>();
|
||||
if (result.code !== 0) return ports;
|
||||
for (const line of result.stdout.split(/\r?\n/)) {
|
||||
// Match the local-address column's :PORT, e.g. "127.0.0.1:9825" or "*:3005".
|
||||
const match = /[:.](\d{2,5})\s+\S+\s*$/.exec(line.trim()) ?? /[:.](\d{2,5})\s/.exec(line.trim());
|
||||
if (!match) continue;
|
||||
const port = Number.parseInt(match[1] ?? "", 10);
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65_535) ports.add(port);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/** Allocate a port that is free in the sandbox, not merely unused by this host. */
|
||||
async function allocateSandboxPort(allocation: PortAllocation, exec: DaytonaExec, sandbox: string): Promise<number> {
|
||||
const taken = await sandboxListeningPorts(exec, sandbox);
|
||||
for (let attempt = 0; attempt < 64; attempt += 1) {
|
||||
const port = allocatePort(allocation);
|
||||
if (!taken.has(port)) return port;
|
||||
// Keep it marked used so we never hand it out again this session.
|
||||
}
|
||||
throw new Error(`Could not find a port free inside sandbox ${sandbox} after 64 attempts.`);
|
||||
}
|
||||
|
||||
function appendExtraEnv(assignments: Map<string, string>, env: Record<string, string> | undefined): void {
|
||||
if (!env) return;
|
||||
for (const [name, value] of Object.entries(env).sort(([left], [right]) => left.localeCompare(right))) {
|
||||
assignments.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
export function createDaytonaHost(options: DaytonaHostOptions): DaytonaHost {
|
||||
const exec = options.exec ?? defaultDaytonaExec;
|
||||
const previewCache = new Map<number, string>();
|
||||
const electronPorts: PortAllocation = { primary: 9825, next: 9830, used: portSet(options.reservedElectronPorts) };
|
||||
const chromePorts: PortAllocation = { primary: 9222, next: 9230, used: portSet(options.reservedChromePorts) };
|
||||
const surfacePorts = new Map<number, string>();
|
||||
const waitForCdp = options.waitForCdp ?? waitForHttpOk;
|
||||
const spawnedSurfaces = new Set<SurfaceHandle>();
|
||||
|
||||
function requireSandbox(): string {
|
||||
const sandbox = options.sandboxId?.trim() || process.env.OPENWORK_EVAL_DAYTONA_SANDBOX?.trim() || "";
|
||||
if (!sandbox) {
|
||||
throw new Error("Daytona sandbox required: create one with bash .devcontainer/test-on-daytona.sh <ref> or pass sandboxId.");
|
||||
}
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
async function previewUrl(port: number): Promise<string> {
|
||||
const cached = previewCache.get(port);
|
||||
if (cached) return cached;
|
||||
const sandbox = requireSandbox();
|
||||
const result = await checkedExec(exec, ["preview-url", sandbox, "-p", String(port)], `daytona preview-url ${sandbox} -p ${port}`);
|
||||
const url = firstHttpsUrl(result.stdout);
|
||||
if (!url) {
|
||||
throw new Error(`daytona preview-url ${sandbox} -p ${port} did not print an https URL: ${result.stdout.trim()}`);
|
||||
}
|
||||
previewCache.set(port, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
async function spawnElectron(name: string, opts: ElectronSurfaceOptions = {}): Promise<SurfaceHandle> {
|
||||
const sandbox = requireSandbox();
|
||||
const safeName = sanitizeName(name);
|
||||
const spawnStamp = timestamp();
|
||||
if (opts.profileDir !== undefined && !opts.profileDir.trim()) {
|
||||
throw new Error("Electron profileDir must not be empty.");
|
||||
}
|
||||
const callerOwnedProfile = opts.profileDir !== undefined;
|
||||
const profileRoot = opts.profileDir ?? `/workspace/.openwork-daytona/profiles/${safeName}-${spawnStamp}`;
|
||||
const userDataDir = `${profileRoot}/electron-userdata`;
|
||||
const bootstrapPath = `${profileRoot}/bootstrap.json`;
|
||||
const port = await allocateSandboxPort(electronPorts, exec, sandbox);
|
||||
// Per-spawn log so the integrity check below can never read a previous
|
||||
// run's lines.
|
||||
const logPath = `/tmp/electron-${safeName}-${spawnStamp}.log`;
|
||||
|
||||
try {
|
||||
await checkedExec(exec, ["exec", sandbox, "--", "mkdir", "-p", shellQuote(userDataDir)], `mkdir Daytona Electron profile ${userDataDir}`, { timeoutMs: 30_000 });
|
||||
if (opts.bootstrap) {
|
||||
const bootstrapJson = `${JSON.stringify(opts.bootstrap, null, 2)}\n`;
|
||||
const encoded = Buffer.from(bootstrapJson, "utf8").toString("base64");
|
||||
await checkedExec(
|
||||
exec,
|
||||
["exec", sandbox, "--", "echo", encoded, "|", "base64", "-d", ">", shellQuote(bootstrapPath)],
|
||||
`write Daytona Electron bootstrap ${bootstrapPath}`,
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
}
|
||||
|
||||
const env = new Map<string, string>();
|
||||
appendExtraEnv(env, opts.env);
|
||||
env.set("DAYTONA_ELECTRON_LOG", logPath);
|
||||
env.set("OPENWORK_ELECTRON_REMOTE_DEBUG_PORT", String(port));
|
||||
env.set("OPENWORK_ELECTRON_USERDATA", userDataDir);
|
||||
env.set("OPENWORK_WORKSPACE_DIR", "/workspace");
|
||||
env.set("OPENWORK_GOOGLE_WORKSPACE_ALLOW_PLAINTEXT_VAULT", "1");
|
||||
const packagedBinary = process.env.OPENWORK_EVAL_ELECTRON_BINARY?.trim();
|
||||
if (packagedBinary) env.set("OPENWORK_EVAL_ELECTRON_BINARY", packagedBinary);
|
||||
if (opts.bootstrap) env.set("OPENWORK_DESKTOP_BOOTSTRAP_PATH", bootstrapPath);
|
||||
|
||||
const startCommand = `set -euo pipefail; cd /workspace; ${shellExport(env)} bash /workspace/.devcontainer/start-daytona-electron.sh --detach`;
|
||||
await checkedExec(
|
||||
exec,
|
||||
["exec", sandbox, "--", `bash -lc ${shellQuote(startCommand)}`],
|
||||
`start Daytona Electron surface ${name}`,
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
const cdpUrl = await previewUrl(port);
|
||||
// SurfaceRegistry also waits 30s before attaching, but Daytona preview URLs
|
||||
// can route before cold Electron CDP is actually responsive; give remote
|
||||
// sandboxes a longer preflight here so attach remains a normal fast probe.
|
||||
await waitForCdp(`${cleanBaseUrl(cdpUrl)}/json/list`, ELECTRON_CDP_WAIT_MS, `Electron CDP ${name}`);
|
||||
// Spawn integrity: the port serving CDP must belong to THIS spawn, not a
|
||||
// pre-existing instance. electron-dev.mjs logs the resolved CDP port; if
|
||||
// it differs from the one we exported, the bind failed (port collision)
|
||||
// and attaching would silently drive the wrong app.
|
||||
const spawnLog = await checkedExec(
|
||||
exec,
|
||||
["exec", sandbox, "--", "tail", "-c", "4000", logPath],
|
||||
`read Daytona Electron log ${logPath}`,
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
const exposed = /Electron CDP exposed at http:\/\/127\.0\.0\.1:(\d+)/.exec(spawnLog.stdout);
|
||||
if (exposed && exposed[1] !== String(port)) {
|
||||
throw new Error(
|
||||
`Daytona Electron surface ${name} resolved CDP port ${exposed[1]} instead of ${port} (port collision?). Log tail:\n${spawnLog.stdout.slice(-1200)}`,
|
||||
);
|
||||
}
|
||||
if (spawnLog.stdout.includes("Cannot start http server for devtools")) {
|
||||
throw new Error(
|
||||
`Daytona Electron surface ${name} could not bind its devtools port ${port}. Log tail:\n${spawnLog.stdout.slice(-1200)}`,
|
||||
);
|
||||
}
|
||||
surfacePorts.set(port, `electron:${name} CDP`);
|
||||
const handle: SurfaceHandle = {
|
||||
name,
|
||||
kind: "electron",
|
||||
hostKind: "daytona",
|
||||
cdpUrl,
|
||||
sandboxId: sandbox,
|
||||
profileDir: profileRoot,
|
||||
meta: { cdpPort: String(port), log: logPath, profileOwner: callerOwnedProfile ? "caller" : "host" },
|
||||
};
|
||||
spawnedSurfaces.add(handle);
|
||||
return handle;
|
||||
} catch (error) {
|
||||
releasePort(electronPorts, port);
|
||||
surfacePorts.delete(port);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnChrome(name: string, opts: ChromeSurfaceOptions = {}): Promise<SurfaceHandle> {
|
||||
const sandbox = requireSandbox();
|
||||
const safeName = sanitizeName(name);
|
||||
const port = await allocateSandboxPort(chromePorts, exec, sandbox);
|
||||
const profileDir = `/tmp/daytona-chrome-${safeName}`;
|
||||
const logPath = `/tmp/daytona-chrome-${safeName}.log`;
|
||||
const startUrl = opts.startUrl?.trim() || "about:blank";
|
||||
const command = [
|
||||
"set -euo pipefail",
|
||||
`mkdir -p ${shellQuote(profileDir)}`,
|
||||
"CHROME_BIN=\"$(command -v chromium || command -v google-chrome || command -v google-chrome-stable || true)\"",
|
||||
"if [ -z \"$CHROME_BIN\" ]; then echo 'No chromium/google-chrome binary found in sandbox.' >&2; exit 127; fi",
|
||||
`DISPLAY=:99 nohup "$CHROME_BIN" --headless=new --window-size=1280,900 --no-sandbox --disable-dev-shm-usage --ignore-gpu-blocklist --use-gl=swiftshader --enable-unsafe-swiftshader --disable-http2 --remote-debugging-address=0.0.0.0 --remote-debugging-port=${port} --user-data-dir=${shellQuote(profileDir)} ${shellQuote(startUrl)} >${shellQuote(logPath)} 2>&1 &`,
|
||||
].join("; ");
|
||||
|
||||
try {
|
||||
await checkedExec(exec, ["exec", sandbox, "--", `bash -lc ${shellQuote(command)}`], `start Daytona Chrome surface ${name}`, { timeoutMs: 30_000 });
|
||||
const cdpUrl = await previewUrl(port);
|
||||
await waitForCdp(`${cleanBaseUrl(cdpUrl)}/json/version`, CHROME_CDP_WAIT_MS, `Chrome CDP ${name}`);
|
||||
surfacePorts.set(port, `chrome:${name} CDP`);
|
||||
const handle: SurfaceHandle = {
|
||||
name,
|
||||
kind: "chrome",
|
||||
hostKind: "daytona",
|
||||
cdpUrl,
|
||||
sandboxId: sandbox,
|
||||
profileDir,
|
||||
meta: { cdpPort: String(port), log: logPath },
|
||||
};
|
||||
spawnedSurfaces.add(handle);
|
||||
return handle;
|
||||
} catch (error) {
|
||||
releasePort(chromePorts, port);
|
||||
surfacePorts.delete(port);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function startDen(opts: DenServiceOptions = {}): Promise<DenServiceHandle> {
|
||||
const apiUrl = process.env.OPENWORK_EVAL_DEN_API_URL?.trim();
|
||||
const webUrl = process.env.OPENWORK_EVAL_DEN_WEB_URL?.trim();
|
||||
if (apiUrl && webUrl) {
|
||||
return {
|
||||
webUrl,
|
||||
apiUrl,
|
||||
orgMode: await orgModeOrDefault(webUrl, options.log),
|
||||
hostKind: "daytona",
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.serverScript) {
|
||||
throw new Error("No Den URLs in OPENWORK_EVAL_DEN_API_URL/OPENWORK_EVAL_DEN_WEB_URL. Set them, or create the server with bash .devcontainer/test-server-on-daytona.sh <ref> and rerun with serverScript enabled.");
|
||||
}
|
||||
|
||||
const args = [".devcontainer/test-server-on-daytona.sh"];
|
||||
const ref = serverRefArg();
|
||||
// The provisioning script can take several minutes because it may create a
|
||||
// Daytona sandbox and build/install the Den stack; keep the timeout generous.
|
||||
if (ref) args.push(ref);
|
||||
const result = await runProcess("bash", args, {
|
||||
cwd: options.repoRoot,
|
||||
timeoutMs: SERVER_SCRIPT_TIMEOUT_MS,
|
||||
onOutput: options.log,
|
||||
});
|
||||
if (result.code !== 0) {
|
||||
const details = (result.stderr || result.stdout).trim();
|
||||
throw new Error(`Daytona Den server script failed with exit ${result.code}${details ? `: ${details}` : ""}`);
|
||||
}
|
||||
const parsedWebUrl = parseUrlAfterLabels(result.stdout, ["DEN_WEB_URL", "Den Web:"]);
|
||||
const parsedApiUrl = parseUrlAfterLabels(result.stdout, ["DEN_API_URL", "Den API:"]);
|
||||
if (!parsedWebUrl || !parsedApiUrl) {
|
||||
throw new Error("Daytona Den server script did not print Den Web and Den API URLs.");
|
||||
}
|
||||
return {
|
||||
webUrl: parsedWebUrl,
|
||||
apiUrl: parsedApiUrl,
|
||||
orgMode: opts.orgMode ?? await orgModeOrDefault(parsedWebUrl, options.log),
|
||||
hostKind: "daytona",
|
||||
};
|
||||
}
|
||||
|
||||
async function disposeSurface(handle: SurfaceHandle): Promise<void> {
|
||||
if (handle.hostKind !== "daytona") return;
|
||||
const sandbox = handle.sandboxId?.trim() || requireSandbox();
|
||||
const port = cdpPort(handle);
|
||||
|
||||
if (handle.kind === "electron" && port !== null) {
|
||||
const pattern = selfMatchSafePortPattern("electron", port);
|
||||
const profilePattern = handle.profileDir
|
||||
? electronProfilePattern(`${electronProfileRoot(handle.profileDir)}/electron-userdata`)
|
||||
: "";
|
||||
const stopCommand = [
|
||||
profilePattern ? killGroupsForPatternCommand(profilePattern, "TERM") : "true",
|
||||
`pkill -f ${shellQuote(pattern)} || true`,
|
||||
"sleep 1",
|
||||
profilePattern ? killGroupsForPatternCommand(profilePattern, "KILL") : "true",
|
||||
`pkill -f ${shellQuote(pattern)} || true`,
|
||||
].join("; ");
|
||||
await checkedExec(
|
||||
exec,
|
||||
["exec", sandbox, "--", `bash -lc ${shellQuote(stopCommand)}`],
|
||||
`stop Daytona Electron surface ${handle.name}`,
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
await checkedExec(
|
||||
exec,
|
||||
["exec", sandbox, "--", `bash -lc ${shellQuote(`sleep 1; curl -sf http://127.0.0.1:${port}/json/list >/dev/null 2>&1 || true`)}`],
|
||||
`verify Daytona Electron CDP stopped ${handle.name}`,
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
if (handle.profileDir && handle.meta?.profileOwner !== "caller") {
|
||||
await checkedExec(
|
||||
exec,
|
||||
["exec", sandbox, "--", `bash -lc ${shellQuote(`rm -rf ${shellQuote(electronProfileRoot(handle.profileDir))}`)}`],
|
||||
`remove Daytona Electron profile ${handle.name}`,
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
}
|
||||
releasePort(electronPorts, port);
|
||||
surfacePorts.delete(port);
|
||||
spawnedSurfaces.delete(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
if (handle.kind === "chrome") {
|
||||
const profileDir = handle.profileDir ?? `/tmp/daytona-chrome-${sanitizeName(handle.name)}`;
|
||||
const pattern = `(chromium|chrome).*--user-data-dir=${selfMatchSafeLiteral(profileDir)}`;
|
||||
await checkedExec(
|
||||
exec,
|
||||
["exec", sandbox, "--", `bash -lc ${shellQuote(`pkill -f ${shellQuote(pattern)} || true`)}`],
|
||||
`stop Daytona Chrome surface ${handle.name}`,
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
if (port !== null) {
|
||||
await checkedExec(
|
||||
exec,
|
||||
["exec", sandbox, "--", `bash -lc ${shellQuote(`sleep 1; curl -sf http://127.0.0.1:${port}/json/version >/dev/null 2>&1 || true`)}`],
|
||||
`verify Daytona Chrome CDP stopped ${handle.name}`,
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
await checkedExec(
|
||||
exec,
|
||||
["exec", sandbox, "--", `bash -lc ${shellQuote(`rm -rf ${shellQuote(profileDir)}`)}`],
|
||||
`remove Daytona Chrome profile ${handle.name}`,
|
||||
{ timeoutMs: 15_000 },
|
||||
);
|
||||
releasePort(chromePorts, port);
|
||||
surfacePorts.delete(port);
|
||||
}
|
||||
spawnedSurfaces.delete(handle);
|
||||
}
|
||||
}
|
||||
|
||||
async function share() {
|
||||
const links: { label: string; url: string }[] = [];
|
||||
for (const [port, label] of surfacePorts) {
|
||||
links.push({ label, url: await previewUrl(port) });
|
||||
}
|
||||
links.push({ label: "noVNC visual", url: await previewUrl(STANDARD_NOVNC_PORT) });
|
||||
links.push({ label: "artifacts", url: await previewUrl(STANDARD_ARTIFACTS_PORT) });
|
||||
return links;
|
||||
}
|
||||
|
||||
async function stop(): Promise<void> {
|
||||
for (const handle of [...spawnedSurfaces]) await disposeSurface(handle);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "daytona",
|
||||
workspaceRoot: "/workspace",
|
||||
previewUrl,
|
||||
spawnElectron,
|
||||
spawnChrome,
|
||||
startDen,
|
||||
share,
|
||||
disposeSurface,
|
||||
stop,
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
for (const handle of [...spawnedSurfaces]) {
|
||||
await disposeSurface(handle)
|
||||
.catch((error: unknown) => options.log(`Daytona surface ${handle.name} cleanup failed: ${messageText(error)}`));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Tiny eval-only Den proxy.
|
||||
*
|
||||
* The desktop app only speaks the den-web topology and derives Den API calls
|
||||
* with ensureDenApiBasePath (`/api/den/*`). The local eval stack runs the bare
|
||||
* den-api, so this proxy exposes both bare paths and `/api/den/*` by stripping
|
||||
* that prefix before streaming requests upstream.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
const listenPort = Number(process.env.DEN_PROXY_LISTEN_PORT);
|
||||
const upstreamPort = Number(process.env.DEN_PROXY_UPSTREAM_PORT);
|
||||
const DEN_PREFIX = "/api/den";
|
||||
const AUTH_DELAY_CONTROL_PATH = "/__openwork_eval/auth-delay";
|
||||
const authDelayControlEnabled = process.env.OPENWORK_EVAL_DEN_PROXY_CONTROL === "1";
|
||||
let authDelayEnabled = false;
|
||||
let authDelayCalls = 0;
|
||||
const authDelayWaiters = new Set<() => void>();
|
||||
|
||||
if (!Number.isInteger(listenPort) || listenPort <= 0) {
|
||||
console.error("DEN_PROXY_LISTEN_PORT must be a positive integer.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(upstreamPort) || upstreamPort <= 0) {
|
||||
console.error("DEN_PROXY_UPSTREAM_PORT must be a positive integer.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function rewritePath(rawUrl: string | undefined): string {
|
||||
const url = new URL(rawUrl ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === DEN_PREFIX || url.pathname.startsWith(`${DEN_PREFIX}/`)) {
|
||||
return `${url.pathname.slice(DEN_PREFIX.length) || "/"}${url.search}`;
|
||||
}
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
function writeJson(res: ServerResponse, status: number, body: Record<string, unknown>): void {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function releaseAuthDelay(): void {
|
||||
authDelayEnabled = false;
|
||||
const waiters = [...authDelayWaiters];
|
||||
authDelayWaiters.clear();
|
||||
for (const resolve of waiters) resolve();
|
||||
}
|
||||
|
||||
function isSessionValidation(rawUrl: string | undefined, method: string | undefined): boolean {
|
||||
if (method !== "GET") return false;
|
||||
const pathname = new URL(rawUrl ?? "/", "http://127.0.0.1").pathname;
|
||||
return pathname === "/v1/me" || pathname === `${DEN_PREFIX}/v1/me`;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (requestUrl.pathname === AUTH_DELAY_CONTROL_PATH) {
|
||||
if (!authDelayControlEnabled) {
|
||||
writeJson(res, 404, { error: "not_found" });
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST" && requestUrl.searchParams.get("action") === "hold") {
|
||||
authDelayEnabled = true;
|
||||
authDelayCalls = 0;
|
||||
} else if (req.method === "POST" && requestUrl.searchParams.get("action") === "release") {
|
||||
releaseAuthDelay();
|
||||
} else if (req.method !== "GET") {
|
||||
writeJson(res, 400, { error: "invalid_action" });
|
||||
return;
|
||||
}
|
||||
writeJson(res, 200, {
|
||||
enabled: authDelayEnabled,
|
||||
calls: authDelayCalls,
|
||||
pending: authDelayWaiters.size,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (authDelayControlEnabled && authDelayEnabled && isSessionValidation(req.url, req.method)) {
|
||||
authDelayCalls += 1;
|
||||
await new Promise<void>((resolve) => authDelayWaiters.add(resolve));
|
||||
}
|
||||
|
||||
const upstreamReq = http.request({
|
||||
hostname: "127.0.0.1",
|
||||
port: upstreamPort,
|
||||
path: rewritePath(req.url),
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
}, (upstreamRes) => {
|
||||
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
|
||||
upstreamRes.pipe(res);
|
||||
});
|
||||
|
||||
upstreamReq.on("error", () => {
|
||||
if (res.headersSent) {
|
||||
res.destroy();
|
||||
return;
|
||||
}
|
||||
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
||||
res.end("Bad Gateway\n");
|
||||
});
|
||||
|
||||
req.pipe(upstreamReq);
|
||||
});
|
||||
|
||||
server.listen(listenPort, "127.0.0.1", () => {
|
||||
console.log(`den proxy listening on :${listenPort} -> :${upstreamPort}${authDelayControlEnabled ? " (eval auth delay enabled)" : ""}`);
|
||||
});
|
||||
@@ -0,0 +1,813 @@
|
||||
/**
|
||||
* Den-stack harness for eval environments.
|
||||
*
|
||||
* Brings up everything the cloud eval specs need, idempotently:
|
||||
* 1. MySQL (docker compose, reuses the dev:den compose project + volume)
|
||||
* 2. Schema push when the persistent database is behind the current checkout
|
||||
* 3. den-api behind a local proxy on :8790: bare /* plus den-web
|
||||
* /api/den/* topology (only when not already healthy)
|
||||
* 4. Demo-org seed (only when the demo owner cannot sign in)
|
||||
* 5. Desktop bootstrap pointed at the local Den + dev Electron with CDP
|
||||
* (only when no CDP endpoint is reachable)
|
||||
* 6. A demo-owner session token, exported as OPENWORK_EVAL_DEN_API_URL /
|
||||
* OPENWORK_EVAL_DEN_TOKEN so env-gated specs run without manual setup.
|
||||
*
|
||||
* `denStackDown()` stops what the harness started.
|
||||
*/
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { openSync } from "node:fs";
|
||||
import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HOSTS_SRC_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(HOSTS_SRC_DIR, "..", "..", "..", "..");
|
||||
const STATE_DIR = resolve(HOSTS_SRC_DIR, "..", "..", "..", "results", ".den-stack");
|
||||
const EVAL_ELECTRON_USERDATA = process.env.OPENWORK_EVAL_ELECTRON_USERDATA?.trim()
|
||||
|| join(STATE_DIR, "electron-user-data");
|
||||
|
||||
const DEN_API_PORT = Number(process.env.OPENWORK_EVAL_DEN_PORT ?? 8790);
|
||||
const DEN_API_INTERNAL_PORT = DEN_API_PORT + 1;
|
||||
const DEN_API_URL = `http://127.0.0.1:${DEN_API_PORT}`;
|
||||
const DEN_API_INTERNAL_URL = `http://127.0.0.1:${DEN_API_INTERNAL_PORT}`;
|
||||
const DEN_BASE_URL = `http://localhost:${DEN_API_PORT}`;
|
||||
const DEMO_EMAIL = process.env.DEN_DEMO_OWNER_EMAIL ?? "alex@acme.test";
|
||||
const DEMO_PASSWORD = process.env.DEN_DEMO_OWNER_PASSWORD ?? "OpenWorkDemo123!";
|
||||
const MYSQL_CONTAINER = "openwork-web-local-mysql";
|
||||
const MYSQL_STATE_DIR = join(STATE_DIR, "mysql");
|
||||
const MYSQL_SOCKET = join(MYSQL_STATE_DIR, "mysql.sock");
|
||||
const COMPOSE_ARGS = ["compose", "-p", "openwork-den-local", "-f", "packaging/docker/docker-compose.web-local.yml"];
|
||||
const DEFAULT_MOCK_IDP_ISSUER = "http://127.0.0.1:19190";
|
||||
const MOCK_IDP_ISSUER = (process.env.OPENWORK_EVAL_MOCK_IDP_ISSUER?.trim() || DEFAULT_MOCK_IDP_ISSUER).replace(/\/+$/, "");
|
||||
const DEN_WEB_ORIGIN = (process.env.OPENWORK_EVAL_DEN_WEB_URL ?? "http://localhost:3005").replace(/\/+$/, "");
|
||||
const DEN_WEB_PORT = new URL(DEN_WEB_ORIGIN).port || "3005";
|
||||
|
||||
function envCsv(name: string): string[] {
|
||||
return (process.env[name] ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().replace(/\/+$/, ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const DEN_TRUSTED_ORIGINS = [
|
||||
DEN_BASE_URL,
|
||||
DEN_WEB_ORIGIN,
|
||||
`http://localhost:${DEN_WEB_PORT}`,
|
||||
`http://127.0.0.1:${DEN_WEB_PORT}`,
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
MOCK_IDP_ISSUER,
|
||||
...envCsv("OPENWORK_EVAL_DEN_EXTRA_TRUSTED_ORIGINS"),
|
||||
].filter((origin, index, origins) => origins.indexOf(origin) === index).join(",");
|
||||
|
||||
// Override with OPENWORK_EVAL_DATABASE_URL to isolate a run from the shared
|
||||
// dev database (e.g. a dedicated schema on the same MySQL container).
|
||||
const DEN_DATABASE_URL = process.env.OPENWORK_EVAL_DATABASE_URL ?? "mysql://root:password@127.0.0.1:3306/openwork_den";
|
||||
const DEN_DATABASE_NAME = new URL(DEN_DATABASE_URL).pathname.replace(/^\//, "") || "openwork_den";
|
||||
if (!/^[A-Za-z0-9_]+$/.test(DEN_DATABASE_NAME)) {
|
||||
throw new Error(`Unsupported Den database name: ${DEN_DATABASE_NAME}`);
|
||||
}
|
||||
|
||||
type DenOrgMode = "single_org" | "multi_org";
|
||||
|
||||
interface DenEvalEnvironmentOptions {
|
||||
orgMode?: DenOrgMode;
|
||||
}
|
||||
|
||||
export function denEvalEnvironment(options: DenEvalEnvironmentOptions = {}): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
OPENWORK_DEV_MODE: "1",
|
||||
DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP: "true",
|
||||
PORT: String(DEN_API_INTERNAL_PORT),
|
||||
DATABASE_URL: DEN_DATABASE_URL,
|
||||
DEN_DB_ENCRYPTION_KEY: "local-dev-db-encryption-key-please-change-1234567890",
|
||||
BETTER_AUTH_SECRET: "local-dev-secret-not-for-production-use!!",
|
||||
BETTER_AUTH_URL: DEN_WEB_ORIGIN,
|
||||
DEN_API_PUBLIC_URL: DEN_API_URL,
|
||||
DEN_BETTER_AUTH_TRUSTED_ORIGINS: DEN_TRUSTED_ORIGINS,
|
||||
CORS_ORIGINS: DEN_TRUSTED_ORIGINS,
|
||||
PROVISIONER_MODE: "stub",
|
||||
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY ?? "sk_test_openwork_eval",
|
||||
STRIPE_INFERENCE_PRICE_ID: process.env.STRIPE_INFERENCE_PRICE_ID ?? "price_openwork_models_eval",
|
||||
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET ?? "whsec_openwork_eval",
|
||||
INFERENCE_PROXY_BASE_URL: process.env.INFERENCE_PROXY_BASE_URL ?? "http://127.0.0.1:8791",
|
||||
};
|
||||
if (options.orgMode) env.DEN_ORG_MODE = options.orgMode;
|
||||
return env;
|
||||
}
|
||||
|
||||
const DEN_ENV = denEvalEnvironment();
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
return isRecord(error) && typeof error.code === "string" ? error.code : null;
|
||||
}
|
||||
|
||||
function isDenOrgMode(value: unknown): value is DenOrgMode {
|
||||
return value === "single_org" || value === "multi_org";
|
||||
}
|
||||
|
||||
function devUserDataHome(): string {
|
||||
return EVAL_ELECTRON_USERDATA;
|
||||
}
|
||||
|
||||
function devBootstrapPath(): string {
|
||||
return join(devUserDataHome(), "openwork-dev-data", "home", ".config", "openwork", "desktop-bootstrap.json");
|
||||
}
|
||||
|
||||
async function httpOk(url: string, timeoutMs = 2_500): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runtimeOrgMode(timeoutMs = 2_500): Promise<DenOrgMode | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const response = await fetch(`${DEN_WEB_ORIGIN}/api/runtime-config`, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (!response.ok) return null;
|
||||
const payload: unknown = await response.json();
|
||||
return isRecord(payload) && isDenOrgMode(payload.orgMode) ? payload.orgMode : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pidIsAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return errorCode(error) !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPidGone(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (!pidIsAlive(pid)) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return !pidIsAlive(pid);
|
||||
}
|
||||
|
||||
async function stopRecordedProcess(name: string, label: string, log: (message: string) => void): Promise<void> {
|
||||
const pidText = await readPidState(name);
|
||||
if (!pidText) return;
|
||||
const pid = Number(pidText);
|
||||
if (!Number.isInteger(pid) || pid <= 0) return;
|
||||
let signaled = false;
|
||||
try {
|
||||
process.kill(-pid, "SIGINT");
|
||||
signaled = true;
|
||||
} catch {
|
||||
// Group may not exist, or this may be Windows.
|
||||
}
|
||||
try {
|
||||
process.kill(pid, "SIGINT");
|
||||
signaled = true;
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
if (signaled) log(`Stopped ${label} for orgMode restart (pid ${pid})`);
|
||||
if (await waitForPidGone(pid, 5_000)) return;
|
||||
try {
|
||||
process.kill(-pid, "SIGKILL");
|
||||
} catch {
|
||||
// Group may already be gone.
|
||||
}
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
await waitForPidGone(pid, 1_000);
|
||||
}
|
||||
|
||||
async function hasCdpPageTarget(baseUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2_500);
|
||||
const response = await fetch(`${baseUrl}/json/list`, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (!response.ok) return false;
|
||||
const targets: unknown = await response.json();
|
||||
return Array.isArray(targets)
|
||||
&& targets.some((target) => isRecord(target) && target.type === "page" && typeof target.webSocketDebuggerUrl === "string");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function signInDemoOwner(): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`${DEN_API_URL}/api/auth/sign-in/email`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: DEN_BASE_URL },
|
||||
body: JSON.stringify({ email: DEMO_EMAIL, password: DEMO_PASSWORD }),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload: unknown = await response.json();
|
||||
return isRecord(payload) && typeof payload.token === "string" && payload.token ? payload.token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface RunOptions {
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
maxBuffer?: number;
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
async function run(command: string, args: string[], options: RunOptions = {}): Promise<RunResult> {
|
||||
return new Promise((resolveRun, reject) => {
|
||||
execFile(command, args, { cwd: REPO_ROOT, maxBuffer: 16 * 1024 * 1024, encoding: "utf8", ...options }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolveRun({
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface SpawnDetachedOptions {
|
||||
logName: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
function spawnDetached(command: string, args: string[], { logName, env, cwd }: SpawnDetachedOptions): number {
|
||||
// Redirect stdio to a log file — inheriting it would keep the parent's
|
||||
// pipes open forever and hang any shell pipeline wrapping the runner.
|
||||
const logFd = openSync(join(STATE_DIR, `${logName}.log`), "a");
|
||||
const child = spawn(command, args, {
|
||||
cwd: cwd ?? REPO_ROOT,
|
||||
env: { ...process.env, ...env },
|
||||
detached: true,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
});
|
||||
child.unref();
|
||||
if (!child.pid) throw new Error(`Could not spawn ${command}.`);
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
async function writePidState(name: string, value: unknown): Promise<void> {
|
||||
await mkdir(STATE_DIR, { recursive: true });
|
||||
await writeFile(join(STATE_DIR, name), String(value));
|
||||
}
|
||||
|
||||
async function readPidState(name: string): Promise<string | null> {
|
||||
try {
|
||||
return (await readFile(join(STATE_DIR, name), "utf8")).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveExecutable(names: string[]): Promise<string | null> {
|
||||
for (const name of names) {
|
||||
try {
|
||||
const { stdout } = await run("which", [name]);
|
||||
const executable = stdout.trim();
|
||||
if (executable) return executable;
|
||||
} catch {
|
||||
// Try the next compatible binary name.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mysqlConnectionArgs(includeDatabase = true): string[] {
|
||||
const url = new URL(DEN_DATABASE_URL);
|
||||
const args = [
|
||||
"--protocol=tcp",
|
||||
"-h",
|
||||
url.hostname,
|
||||
"-P",
|
||||
url.port || "3306",
|
||||
`-u${decodeURIComponent(url.username || "root")}`,
|
||||
];
|
||||
const password = decodeURIComponent(url.password);
|
||||
if (password) args.push(`-p${password}`);
|
||||
if (includeDatabase) args.push(DEN_DATABASE_NAME);
|
||||
return args;
|
||||
}
|
||||
|
||||
async function nativeMysqlQuery(sql: string, includeDatabase = true): Promise<string> {
|
||||
const client = await resolveExecutable(["mariadb", "mysql"]);
|
||||
if (!client) throw new Error("Neither mariadb nor mysql client is available.");
|
||||
const { stdout } = await run(client, [
|
||||
...mysqlConnectionArgs(includeDatabase),
|
||||
"-N",
|
||||
"-e",
|
||||
sql,
|
||||
]);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function nativeMysqlHealthy(): Promise<boolean> {
|
||||
try {
|
||||
await nativeMysqlQuery("SELECT 1");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeMysqlServerArgs(
|
||||
runAsRoot = process.getuid?.() === 0,
|
||||
): string[] {
|
||||
const args = [
|
||||
`--datadir=${MYSQL_STATE_DIR}`,
|
||||
`--socket=${MYSQL_SOCKET}`,
|
||||
"--port=3306",
|
||||
"--bind-address=127.0.0.1",
|
||||
"--skip-networking=0",
|
||||
`--pid-file=${join(MYSQL_STATE_DIR, "mysql.pid")}`,
|
||||
`--log-error=${join(MYSQL_STATE_DIR, "mysql.error.log")}`,
|
||||
];
|
||||
if (runAsRoot) args.push("--user=root");
|
||||
return args;
|
||||
}
|
||||
|
||||
export function nativeMysqlSocketArgs(
|
||||
authenticated: boolean,
|
||||
): string[] {
|
||||
const url = new URL(DEN_DATABASE_URL);
|
||||
const args = [
|
||||
"--protocol=socket",
|
||||
`--socket=${MYSQL_SOCKET}`,
|
||||
`-u${decodeURIComponent(url.username || "root")}`,
|
||||
];
|
||||
const password = decodeURIComponent(url.password);
|
||||
if (authenticated && password) args.push(`-p${password}`);
|
||||
return args;
|
||||
}
|
||||
|
||||
async function ensureNativeMysql(log: (message: string) => void): Promise<void> {
|
||||
if (await nativeMysqlHealthy()) {
|
||||
await writePidState("mysql.backend", "native");
|
||||
log("Native MySQL already healthy");
|
||||
return;
|
||||
}
|
||||
|
||||
const server = await resolveExecutable(["mariadbd", "mysqld"]);
|
||||
const installer = await resolveExecutable(["mariadb-install-db", "mysql_install_db"]);
|
||||
const socketClient = await resolveExecutable(["mariadb", "mysql"]);
|
||||
if (!server || !installer || !socketClient) {
|
||||
throw new Error(
|
||||
"Docker is unavailable and the native MySQL server/client toolchain is incomplete.",
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(MYSQL_STATE_DIR, { recursive: true });
|
||||
try {
|
||||
await access(join(MYSQL_STATE_DIR, "mysql"));
|
||||
} catch {
|
||||
log("Initializing native MariaDB data directory...");
|
||||
await run(installer, [
|
||||
`--datadir=${MYSQL_STATE_DIR}`,
|
||||
"--auth-root-authentication-method=normal",
|
||||
"--skip-test-db",
|
||||
]);
|
||||
}
|
||||
|
||||
log("Starting native MariaDB...");
|
||||
const pid = spawnDetached(
|
||||
server,
|
||||
nativeMysqlServerArgs(),
|
||||
{ logName: "mysql", env: {} },
|
||||
);
|
||||
await writePidState("mysql.pid", pid);
|
||||
await writePidState("mysql.backend", "native");
|
||||
|
||||
let socketArgs: string[] | null = null;
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
for (const authenticated of [false, true]) {
|
||||
const candidateArgs = nativeMysqlSocketArgs(authenticated);
|
||||
try {
|
||||
await run(socketClient, [
|
||||
...candidateArgs,
|
||||
"-N",
|
||||
"-e",
|
||||
"SELECT 1",
|
||||
]);
|
||||
socketArgs = candidateArgs;
|
||||
break;
|
||||
} catch {
|
||||
// A fresh data directory accepts passwordless root while a resumed
|
||||
// directory requires the password configured below.
|
||||
}
|
||||
}
|
||||
if (socketArgs) break;
|
||||
await sleep(500);
|
||||
}
|
||||
if (!socketArgs) {
|
||||
throw new Error("Native MariaDB did not become ready within 30s.");
|
||||
}
|
||||
|
||||
await run(socketClient, [
|
||||
...socketArgs,
|
||||
"-e",
|
||||
[
|
||||
"ALTER USER 'root'@'localhost' IDENTIFIED BY 'password'",
|
||||
"CREATE USER IF NOT EXISTS 'root'@'127.0.0.1' IDENTIFIED BY 'password'",
|
||||
"GRANT ALL PRIVILEGES ON *.* TO 'root'@'127.0.0.1' WITH GRANT OPTION",
|
||||
`CREATE DATABASE IF NOT EXISTS \`${DEN_DATABASE_NAME}\``,
|
||||
"FLUSH PRIVILEGES",
|
||||
].join("; "),
|
||||
]);
|
||||
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
if (await nativeMysqlHealthy()) {
|
||||
log("Native MariaDB healthy");
|
||||
return;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error("Native MariaDB did not accept Den database connections within 15s.");
|
||||
}
|
||||
|
||||
async function ensureMysql(log: (message: string) => void): Promise<void> {
|
||||
if (process.env.OPENWORK_EVAL_DATABASE_URL) {
|
||||
if (!(await nativeMysqlHealthy())) {
|
||||
throw new Error("OPENWORK_EVAL_DATABASE_URL is configured but not reachable.");
|
||||
}
|
||||
await writePidState("mysql.backend", "external");
|
||||
log("External MySQL already healthy");
|
||||
return;
|
||||
}
|
||||
|
||||
const docker = await resolveExecutable(["docker"]);
|
||||
if (!docker) {
|
||||
await ensureNativeMysql(log);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await run(docker, ["inspect", "-f", "{{.State.Health.Status}}", MYSQL_CONTAINER]);
|
||||
if (stdout.trim() === "healthy") {
|
||||
await writePidState("mysql.backend", "docker");
|
||||
log("MySQL already healthy");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Not running — start it below.
|
||||
}
|
||||
log("Starting MySQL (docker compose)...");
|
||||
await run(docker, [...COMPOSE_ARGS, "up", "-d", "--wait", "mysql"]);
|
||||
await writePidState("mysql.backend", "docker");
|
||||
await writePidState("mysql.started", "1");
|
||||
log("MySQL healthy");
|
||||
}
|
||||
|
||||
async function mysqlQuery(sql: string): Promise<string> {
|
||||
const backend = await readPidState("mysql.backend");
|
||||
if (backend === "native" || backend === "external") {
|
||||
return nativeMysqlQuery(sql);
|
||||
}
|
||||
const docker = await resolveExecutable(["docker"]);
|
||||
if (!docker) throw new Error("The selected Docker MySQL backend is unavailable.");
|
||||
const { stdout } = await run(docker, [
|
||||
"exec", MYSQL_CONTAINER,
|
||||
"mysql", "-uroot", "-ppassword", DEN_DATABASE_NAME, "-N", "-e", sql,
|
||||
]);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function ensureSchema(log: (message: string) => void): Promise<void> {
|
||||
try {
|
||||
const schema = await mysqlQuery("SHOW TABLES LIKE 'organization'; SHOW TABLES LIKE 'desktop_connect_grant'; SHOW TABLES LIKE 'scim_group'; SHOW COLUMNS FROM scim_provider LIKE 'group_mapping_mode';");
|
||||
if (
|
||||
schema.includes("organization")
|
||||
&& schema.includes("desktop_connect_grant")
|
||||
&& schema.includes("scim_group")
|
||||
&& schema.includes("group_mapping_mode")
|
||||
) {
|
||||
log("Schema present");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Database may not exist yet — push will create what it needs.
|
||||
}
|
||||
log("Synchronizing schema with the current checkout...");
|
||||
const denDbDir = join(REPO_ROOT, "ee", "packages", "den-db");
|
||||
await run("pnpm", ["--filter", "@openwork-ee/den-db", "build"]);
|
||||
await run("node", ["--import", "tsx", "./node_modules/drizzle-kit/bin.cjs", "push", "--config", "drizzle.config.ts"], {
|
||||
cwd: denDbDir,
|
||||
env: { ...process.env, DATABASE_URL: DEN_ENV.DATABASE_URL, DEN_DB_ENCRYPTION_KEY: DEN_ENV.DEN_DB_ENCRYPTION_KEY },
|
||||
});
|
||||
log("Schema pushed");
|
||||
}
|
||||
|
||||
async function ensureDenApi(log: (message: string) => void, orgMode?: DenOrgMode): Promise<void> {
|
||||
const publicHealthOk = await httpOk(`${DEN_API_URL}/health`);
|
||||
const denWebPathHealthOk = await httpOk(`${DEN_API_URL}/api/den/health`);
|
||||
if (publicHealthOk && denWebPathHealthOk) {
|
||||
log("den stack already healthy (with /api/den path)");
|
||||
return;
|
||||
}
|
||||
if (publicHealthOk) {
|
||||
throw new Error(
|
||||
`A den-api is already healthy on :${DEN_API_PORT}, but it does not serve /api/den. ` +
|
||||
"The desktop app cannot use a bare den-api there; rerun with OPENWORK_EVAL_DEN_PORT=<free port>.",
|
||||
);
|
||||
}
|
||||
|
||||
log(`Starting den-api on internal :${DEN_API_INTERNAL_PORT} (proxied on :${DEN_API_PORT})...`);
|
||||
const pid = spawnDetached("pnpm", ["exec", "tsx", "src/main.ts"], {
|
||||
logName: "den-api",
|
||||
cwd: join(REPO_ROOT, "ee", "apps", "den-api"),
|
||||
env: denEvalEnvironment({ orgMode }),
|
||||
});
|
||||
await writePidState("den-api.pid", pid);
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
if (await httpOk(`${DEN_API_INTERNAL_URL}/health`)) {
|
||||
log("den-api healthy");
|
||||
break;
|
||||
}
|
||||
await sleep(2_000);
|
||||
}
|
||||
if (!(await httpOk(`${DEN_API_INTERNAL_URL}/health`))) {
|
||||
throw new Error(`den-api did not become healthy on internal :${DEN_API_INTERNAL_PORT} within 60s.`);
|
||||
}
|
||||
|
||||
if (await httpOk(`${DEN_API_URL}/api/den/health`)) {
|
||||
log("den proxy healthy");
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Starting den proxy on :${DEN_API_PORT} -> :${DEN_API_INTERNAL_PORT}...`);
|
||||
const proxyPid = spawnDetached(process.execPath, [join(HOSTS_SRC_DIR, "den-proxy.ts")], {
|
||||
logName: "den-proxy",
|
||||
env: {
|
||||
DEN_PROXY_LISTEN_PORT: String(DEN_API_PORT),
|
||||
DEN_PROXY_UPSTREAM_PORT: String(DEN_API_INTERNAL_PORT),
|
||||
OPENWORK_EVAL_DEN_PROXY_CONTROL: "1",
|
||||
},
|
||||
});
|
||||
await writePidState("den-proxy.pid", proxyPid);
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
if (await httpOk(`${DEN_API_URL}/api/den/health`)) {
|
||||
log("den proxy healthy");
|
||||
return;
|
||||
}
|
||||
await sleep(1_000);
|
||||
}
|
||||
throw new Error("den proxy did not expose /api/den/health within 30s.");
|
||||
}
|
||||
|
||||
export async function clearDenWebBuildCache(
|
||||
denWebRoot = join(REPO_ROOT, "ee", "apps", "den-web"),
|
||||
): Promise<void> {
|
||||
await rm(join(denWebRoot, ".next"), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function ensureDenOrgMode(orgMode: DenOrgMode | undefined, log: (message: string) => void): Promise<void> {
|
||||
if (!orgMode) return;
|
||||
const actual = await runtimeOrgMode();
|
||||
if (actual === orgMode) {
|
||||
log(`Den orgMode already ${orgMode}`);
|
||||
return;
|
||||
}
|
||||
if (!actual && !(await httpOk(`${DEN_API_URL}/health`)) && !(await httpOk(`${DEN_WEB_ORIGIN}/api/den/health`))) {
|
||||
return;
|
||||
}
|
||||
const detail = actual ? `reports ${actual}` : "could not be verified from runtime-config";
|
||||
log(`Den orgMode ${detail}; restarting den-api and den-web for ${orgMode}.`);
|
||||
await stopRecordedProcess("den-api.pid", "den-api", log);
|
||||
await stopRecordedProcess("den-web.pid", "den-web", log);
|
||||
await freePorts([DEN_API_PORT, DEN_API_INTERNAL_PORT, Number(DEN_WEB_PORT)].filter((port) => Number.isInteger(port) && port > 0), log, "Den orgMode restart");
|
||||
await clearDenWebBuildCache();
|
||||
}
|
||||
|
||||
async function ensureDenWeb(log: (message: string) => void, orgMode?: DenOrgMode): Promise<void> {
|
||||
if (await httpOk(`${DEN_WEB_ORIGIN}/api/den/health`)) {
|
||||
log(`den-web already healthy at ${DEN_WEB_ORIGIN}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const webUrl = new URL(DEN_WEB_ORIGIN);
|
||||
const denWebPort = webUrl.port || "3005";
|
||||
// Reusable Daytona images may retain a generated Next.js route manifest
|
||||
// from an older checkout. Dependencies remain cached, but generated app
|
||||
// output must match the exact candidate being proved.
|
||||
await clearDenWebBuildCache();
|
||||
log(`Starting den-web on :${denWebPort}...`);
|
||||
const pid = spawnDetached("pnpm", ["dev:den:web"], {
|
||||
logName: "den-web",
|
||||
env: {
|
||||
DEN_API_BASE: DEN_API_URL,
|
||||
DEN_AUTH_ORIGIN: DEN_WEB_ORIGIN,
|
||||
DEN_AUTH_FALLBACK_BASE: DEN_API_URL,
|
||||
DEN_WEB_PORT: denWebPort,
|
||||
...(orgMode ? { DEN_ORG_MODE: orgMode } : {}),
|
||||
},
|
||||
});
|
||||
await writePidState("den-web.pid", pid);
|
||||
// A clean Daytona checkout can spend more than 90 seconds compiling the
|
||||
// first den-web route even after the process has started successfully.
|
||||
for (let attempt = 0; attempt < 90; attempt += 1) {
|
||||
if (await httpOk(`${DEN_WEB_ORIGIN}/api/den/health`)) {
|
||||
log("den-web healthy");
|
||||
return;
|
||||
}
|
||||
await sleep(2_000);
|
||||
}
|
||||
throw new Error(`den-web did not become healthy at ${DEN_WEB_ORIGIN} within 180s.`);
|
||||
}
|
||||
|
||||
export function denSeedNodeArgs(): string[] {
|
||||
return [
|
||||
"--conditions=development",
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/seed-demo-org.ts",
|
||||
];
|
||||
}
|
||||
|
||||
async function ensureSeed(log: (message: string) => void): Promise<void> {
|
||||
if (await signInDemoOwner()) {
|
||||
log(`Demo org present (${DEMO_EMAIL})`);
|
||||
return;
|
||||
}
|
||||
log("Seeding demo org (Acme Robotics)...");
|
||||
await run(process.execPath, denSeedNodeArgs(), {
|
||||
cwd: join(REPO_ROOT, "ee", "apps", "den-api"),
|
||||
env: { ...process.env, ...DEN_ENV },
|
||||
});
|
||||
if (!(await signInDemoOwner())) {
|
||||
throw new Error("Seed completed but the demo owner still cannot sign in.");
|
||||
}
|
||||
log("Demo org seeded");
|
||||
}
|
||||
|
||||
async function freePorts(ports: number[], log: (message: string) => void, reason: string): Promise<void> {
|
||||
for (const port of ports) {
|
||||
try {
|
||||
const { stdout } = await run("lsof", ["-nP", "-ti", `tcp:${port}`]);
|
||||
const pids = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(Number(pid), "SIGKILL");
|
||||
log(`Cleared process ${pid} holding :${port} (${reason})`);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Port free — nothing to do.
|
||||
}
|
||||
}
|
||||
await sleep(1_500);
|
||||
}
|
||||
|
||||
async function freeStaleAppPorts(log: (message: string) => void): Promise<void> {
|
||||
// If no app page target is serving but the dev ports are held, a previous
|
||||
// run left a half-dead app behind (e.g. Electron without its renderer).
|
||||
// Clear them so the fresh spawn does not lose the bind race.
|
||||
await freePorts([9823, 5173], log, "stale app cleanup");
|
||||
}
|
||||
|
||||
async function ensureApp(log: (message: string) => void, cdpCandidates: string[]): Promise<void> {
|
||||
for (const candidate of cdpCandidates) {
|
||||
if (await hasCdpPageTarget(candidate)) {
|
||||
log(`App CDP already reachable at ${candidate} — make sure it targets the local Den (reload after bootstrap changes).`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await freeStaleAppPorts(log);
|
||||
|
||||
const bootstrapPath = devBootstrapPath();
|
||||
await mkdir(dirname(bootstrapPath), { recursive: true });
|
||||
await writeFile(
|
||||
bootstrapPath,
|
||||
`${JSON.stringify({ baseUrl: DEN_BASE_URL, requireSignin: false }, null, 2)}\n`,
|
||||
);
|
||||
await writePidState("bootstrap.path", bootstrapPath);
|
||||
log(`Wrote desktop bootstrap -> ${DEN_BASE_URL}`);
|
||||
|
||||
log("Starting dev Electron (pnpm dev)...");
|
||||
const pid = spawnDetached("pnpm", ["dev"], {
|
||||
logName: "app",
|
||||
env: { OPENWORK_ELECTRON_USERDATA: EVAL_ELECTRON_USERDATA },
|
||||
});
|
||||
await writePidState("app.pid", pid);
|
||||
for (let attempt = 0; attempt < 45; attempt += 1) {
|
||||
for (const candidate of cdpCandidates) {
|
||||
if (await hasCdpPageTarget(candidate)) {
|
||||
log(`App CDP up at ${candidate}`);
|
||||
// Give the renderer a moment to finish booting providers.
|
||||
await sleep(8_000);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await sleep(4_000);
|
||||
}
|
||||
throw new Error("Dev Electron CDP page target did not come up within 3 minutes.");
|
||||
}
|
||||
|
||||
export interface EnsureDenStackOptions {
|
||||
log(message: string): void;
|
||||
cdpCandidates: string[];
|
||||
skipApp?: boolean;
|
||||
orgMode?: DenOrgMode;
|
||||
}
|
||||
|
||||
export async function ensureDenStack({ log, cdpCandidates, skipApp = false, orgMode }: EnsureDenStackOptions): Promise<void> {
|
||||
await mkdir(STATE_DIR, { recursive: true });
|
||||
await ensureMysql(log);
|
||||
await ensureSchema(log);
|
||||
await ensureDenOrgMode(orgMode, log);
|
||||
await ensureDenApi(log, orgMode);
|
||||
await ensureDenWeb(log, orgMode);
|
||||
await ensureSeed(log);
|
||||
if (skipApp) {
|
||||
log("Skipping dev Electron startup — selected eval flow is app-less");
|
||||
} else {
|
||||
await ensureApp(log, cdpCandidates);
|
||||
}
|
||||
|
||||
const token = await signInDemoOwner();
|
||||
if (!token) throw new Error("Could not obtain a demo-owner session token.");
|
||||
|
||||
process.env.OPENWORK_EVAL_DEN_API_URL = DEN_API_URL;
|
||||
process.env.OPENWORK_EVAL_DEN_WEB_URL = DEN_WEB_ORIGIN;
|
||||
process.env.OPENWORK_EVAL_DEN_TOKEN = token;
|
||||
log(`Den stack ready — flows get OPENWORK_EVAL_DEN_API_URL=${DEN_API_URL}, OPENWORK_EVAL_DEN_WEB_URL=${DEN_WEB_ORIGIN}, and a fresh ${DEMO_EMAIL} token.`);
|
||||
}
|
||||
|
||||
export interface DenStackDownOptions {
|
||||
log(message: string): void;
|
||||
}
|
||||
|
||||
export async function denStackDown({ log }: DenStackDownOptions): Promise<void> {
|
||||
const proxyPid = await readPidState("den-proxy.pid");
|
||||
if (proxyPid) {
|
||||
try { process.kill(Number(proxyPid)); log(`Stopped den-proxy (pid ${proxyPid})`); } catch { /* already gone */ }
|
||||
}
|
||||
const apiPid = await readPidState("den-api.pid");
|
||||
if (apiPid) {
|
||||
try { process.kill(Number(apiPid)); log(`Stopped den-api (pid ${apiPid})`); } catch { /* already gone */ }
|
||||
}
|
||||
const webPid = await readPidState("den-web.pid");
|
||||
if (webPid) {
|
||||
try { process.kill(-Number(webPid)); } catch { /* group gone */ }
|
||||
try { process.kill(Number(webPid)); log(`Stopped den-web (pid ${webPid})`); } catch { /* already gone */ }
|
||||
}
|
||||
const appPid = await readPidState("app.pid");
|
||||
if (appPid) {
|
||||
try { process.kill(-Number(appPid)); } catch { /* group gone */ }
|
||||
try { process.kill(Number(appPid)); log(`Stopped dev app (pid ${appPid})`); } catch { /* already gone */ }
|
||||
}
|
||||
const bootstrapPath = await readPidState("bootstrap.path");
|
||||
if (bootstrapPath) {
|
||||
await rm(bootstrapPath, { force: true });
|
||||
log("Removed dev desktop bootstrap override");
|
||||
}
|
||||
const mysqlBackend = await readPidState("mysql.backend");
|
||||
const mysqlPid = await readPidState("mysql.pid");
|
||||
if (mysqlBackend === "native" && mysqlPid) {
|
||||
try {
|
||||
process.kill(Number(mysqlPid));
|
||||
log(`Stopped native MariaDB (pid ${mysqlPid})`);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
} else if (mysqlBackend === "docker") {
|
||||
const docker = await resolveExecutable(["docker"]);
|
||||
if (docker) {
|
||||
try {
|
||||
await run(docker, [...COMPOSE_ARGS, "down"]);
|
||||
log("MySQL compose project stopped (volume kept)");
|
||||
} catch {
|
||||
log("Docker compose down skipped (docker unavailable?)");
|
||||
}
|
||||
}
|
||||
}
|
||||
await rm(STATE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { timed } from "@openwork/timeline";
|
||||
import { attachSurface, describeAppState, dumpScreenState, isInteractive, probeAppStateOnSurface } from "@openwork/cdp";
|
||||
import { resolveHost } from "./resolve.ts";
|
||||
import type { AppStateProbe, AppSurfaceState, AttachedSurface, Surface, SurfaceHandle } from "@openwork/cdp";
|
||||
import type { Host } from "./types.ts";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 180_000;
|
||||
const POLL_INTERVAL_MS = 250;
|
||||
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function messageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function logCleanupError(name: string, error: unknown): void {
|
||||
console.warn(`[openwork/evals] Desktop ${name} cleanup failed: ${messageText(error)}`);
|
||||
}
|
||||
|
||||
async function appendDesktopLog(error: unknown, handle: SurfaceHandle): Promise<unknown> {
|
||||
const logPath = handle.meta?.log;
|
||||
if (!logPath) return error;
|
||||
try {
|
||||
const log = await readFile(logPath, "utf8");
|
||||
if (!log.trim()) return error;
|
||||
const tail = log.trimEnd().split(/\r?\n/).slice(-40).join("\n");
|
||||
return new Error(`${messageText(error)}\n\nLast 40 lines of ${logPath}:\n${tail}`, { cause: error });
|
||||
} catch {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface DesktopOptions {
|
||||
name?: string;
|
||||
mode?: "spawn" | "attach";
|
||||
/** Explicit CDP endpoint for attach mode; falls back to OPENWORK_EVAL_CDP_URL. */
|
||||
cdpUrl?: string;
|
||||
/**
|
||||
* Where this desktop runs. Defaults to the ambient host (`resolveHost()`).
|
||||
* Pass one from `localHost()` / `daytonaSandbox(id)` to place it explicitly —
|
||||
* that is the only way a spec can put two desktops in two sandboxes, or the
|
||||
* app and the browser in different places.
|
||||
*/
|
||||
host?: Host;
|
||||
bootstrap?: {
|
||||
baseUrl: string;
|
||||
apiBaseUrl?: string;
|
||||
requireSignin?: boolean;
|
||||
};
|
||||
env?: Record<string, string>;
|
||||
/** Root package script used for a source Electron launch. */
|
||||
devCommand?: "dev" | "dev:electron";
|
||||
/** Skip host-side sidecar/helper preparation for an explicitly constrained launch. */
|
||||
prepareSharedResources?: boolean;
|
||||
/** Exact caller-owned Electron profile root, for restart scenarios. */
|
||||
profileDir?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface AppReadiness {
|
||||
state: AppSurfaceState;
|
||||
workspaceId: string | null;
|
||||
route: string;
|
||||
}
|
||||
|
||||
export interface DesktopHandle extends AttachedSurface {
|
||||
readiness: AppReadiness;
|
||||
/**
|
||||
* The workspace root on the host running THIS app — use it for workspace
|
||||
* paths instead of `process.cwd()`, which is the driver's filesystem and may
|
||||
* not exist where the app runs. Null only in `mode: "attach"`, where the
|
||||
* host is unknown.
|
||||
*/
|
||||
workspaceRoot: string | null;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
async function waitForReadiness(app: Surface, timeoutMs: number): Promise<AppReadiness> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
// Short per-probe timeout so a briefly-busy renderer is retried rather than
|
||||
// consuming the whole readiness budget in one stuck call.
|
||||
let last: AppStateProbe = { controlReady: false, transitional: null, surface: null, workspaceId: null, route: "", text: "" };
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
last = await probeAppStateOnSurface(app, { timeoutMs: Math.min(8_000, Math.max(1, deadline - Date.now())) });
|
||||
if (isInteractive(last) && last.surface) {
|
||||
return { state: last.surface, workspaceId: last.workspaceId, route: last.route };
|
||||
}
|
||||
} catch {
|
||||
// Navigations briefly destroy the execution context while the app boots.
|
||||
}
|
||||
await sleep(Math.min(POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())));
|
||||
}
|
||||
throw new Error(
|
||||
`OpenWork desktop did not become ready after ${timeoutMs}ms: ${describeAppState(last)} On screen: ${await dumpScreenState(app)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function closeSpawnedSurface(
|
||||
attached: AttachedSurface | null,
|
||||
host: Host | null,
|
||||
handle: SurfaceHandle,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await attached?.stop();
|
||||
} finally {
|
||||
await host?.disposeSurface(handle);
|
||||
}
|
||||
}
|
||||
|
||||
export async function desktop(opts: DesktopOptions = {}): Promise<DesktopHandle> {
|
||||
const mode = opts.mode ?? "spawn";
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
let host: Host | null = null;
|
||||
let handle: SurfaceHandle;
|
||||
|
||||
if (mode === "attach") {
|
||||
const cdpUrl = opts.cdpUrl?.trim() || process.env.OPENWORK_EVAL_CDP_URL?.trim();
|
||||
if (!cdpUrl) {
|
||||
throw new Error('desktop({ mode: "attach" }) requires cdpUrl or OPENWORK_EVAL_CDP_URL to point at a running Electron app.');
|
||||
}
|
||||
handle = {
|
||||
name: opts.name ?? "attached-app",
|
||||
kind: "electron",
|
||||
hostKind: "attached",
|
||||
cdpUrl,
|
||||
};
|
||||
} else {
|
||||
host = opts.host ?? await resolveHost();
|
||||
handle = await host.spawnElectron(opts.name ?? "spec", {
|
||||
profile: "fresh",
|
||||
profileDir: opts.profileDir,
|
||||
bootstrap: opts.bootstrap,
|
||||
env: opts.env,
|
||||
devCommand: opts.devCommand,
|
||||
prepareSharedResources: opts.prepareSharedResources,
|
||||
});
|
||||
}
|
||||
|
||||
let attached: AttachedSurface | null = null;
|
||||
try {
|
||||
const surface = await attachSurface(handle, { timeoutMs });
|
||||
attached = surface;
|
||||
const readiness = await timed("app.readiness", () => waitForReadiness(surface, timeoutMs), handle.name);
|
||||
let stopped = false;
|
||||
const stop = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
await closeSpawnedSurface(attached, host, handle);
|
||||
};
|
||||
const dispose = async (): Promise<void> => {
|
||||
await stop().catch((error: unknown) => logCleanupError(handle.name, error));
|
||||
};
|
||||
return {
|
||||
handle: attached.handle,
|
||||
client: attached.client,
|
||||
readiness,
|
||||
workspaceRoot: host?.workspaceRoot ?? null,
|
||||
stop,
|
||||
[Symbol.asyncDispose]: dispose,
|
||||
};
|
||||
} catch (error) {
|
||||
const readinessError = attached ? await appendDesktopLog(error, handle) : error;
|
||||
await closeSpawnedSurface(attached, host, handle)
|
||||
.catch((cleanupError: unknown) => logCleanupError(handle.name, cleanupError));
|
||||
throw readinessError;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
export const FAULT_PROXY_SCRIPT = String.raw`import { createServer, request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const port = Number(process.env.PORT);
|
||||
const upstream = new URL(process.env.UPSTREAM);
|
||||
const issuer = process.env.ISSUER;
|
||||
const controlToken = process.env.CONTROL_TOKEN;
|
||||
const rules = [];
|
||||
const requests = [];
|
||||
const hopByHopHeaders = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"proxy-connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
function headerText(value) {
|
||||
return Array.isArray(value) ? value.join(",") : value ?? "";
|
||||
}
|
||||
|
||||
function forwardedHeaders(source, host) {
|
||||
const nominated = new Set(headerText(source.connection).split(",").map((name) => name.trim().toLowerCase()).filter(Boolean));
|
||||
const headers = {};
|
||||
for (const [name, value] of Object.entries(source)) {
|
||||
if (value === undefined || hopByHopHeaders.has(name) || nominated.has(name)) continue;
|
||||
headers[name] = value;
|
||||
}
|
||||
if (host) headers.host = host;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function faultTimes(value) {
|
||||
if (value === undefined) return 1;
|
||||
if (!Number.isInteger(value) || value < 0) throw new Error("Fault times must be a non-negative integer, got " + value + ".");
|
||||
return value;
|
||||
}
|
||||
|
||||
function takeRule(path) {
|
||||
for (const rule of rules) {
|
||||
if (rule.remaining <= 0 || !path.startsWith(rule.pathPrefix)) continue;
|
||||
rule.remaining -= 1;
|
||||
return rule;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function json(response, status, body, headers = {}) {
|
||||
const text = JSON.stringify(body);
|
||||
response.writeHead(status, { "content-type": "application/json", ...headers });
|
||||
response.end(text);
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => body += chunk);
|
||||
request.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function writeUpstreamResponse(client, status, message, headers) {
|
||||
if (message) client.writeHead(status, message, headers);
|
||||
else client.writeHead(status, headers);
|
||||
}
|
||||
|
||||
function forward(incoming, client, faulted) {
|
||||
const path = incoming.url ?? "/";
|
||||
const onResponse = (response) => {
|
||||
const status = response.statusCode ?? 502;
|
||||
requests.push({ method: incoming.method ?? "GET", path, status, faulted, at: Date.now() });
|
||||
writeUpstreamResponse(client, status, response.statusMessage, forwardedHeaders(response.headers));
|
||||
response.pipe(client);
|
||||
};
|
||||
// Pin the upstream origin and take only the path from the caller. An
|
||||
// absolute-form request target (legal for proxy requests) would otherwise
|
||||
// steer this fetch at an arbitrary host, because new URL(absolute, base)
|
||||
// discards the base.
|
||||
const requested = new URL(path, "http://request-target.invalid");
|
||||
const options = {
|
||||
protocol: upstream.protocol,
|
||||
hostname: upstream.hostname,
|
||||
port: upstream.port,
|
||||
path: requested.pathname + requested.search,
|
||||
method: incoming.method ?? "GET",
|
||||
headers: forwardedHeaders(incoming.headers, upstream.host),
|
||||
};
|
||||
const outbound = upstream.protocol === "https:"
|
||||
? httpsRequest(options, onResponse)
|
||||
: httpRequest(options, onResponse);
|
||||
outbound.on("error", (error) => {
|
||||
if (client.headersSent) {
|
||||
client.destroy(error);
|
||||
return;
|
||||
}
|
||||
requests.push({ method: incoming.method ?? "GET", path, status: 502, faulted, at: Date.now() });
|
||||
json(client, 502, { error: error.message });
|
||||
});
|
||||
incoming.on("aborted", () => outbound.destroy());
|
||||
incoming.pipe(outbound);
|
||||
}
|
||||
|
||||
async function control(incoming, response, path) {
|
||||
if (path === "/__openwork_faults/health" && incoming.method === "GET") {
|
||||
json(response, 200, { ok: true, issuer });
|
||||
return;
|
||||
}
|
||||
if (incoming.headers["x-openwork-fault-token"] !== controlToken) {
|
||||
json(response, 401, { error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
if (path === "/__openwork_faults/requests" && incoming.method === "GET") {
|
||||
json(response, 200, { requests });
|
||||
return;
|
||||
}
|
||||
if (path === "/__openwork_faults/clear" && incoming.method === "POST") {
|
||||
rules.length = 0;
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (path === "/__openwork_faults/rules" && incoming.method === "POST") {
|
||||
try {
|
||||
const body = await readJson(incoming);
|
||||
const remaining = faultTimes(body.times);
|
||||
if (body.kind === "status") {
|
||||
rules.push({ kind: "status", pathPrefix: body.pathPrefix, statusCode: body.statusCode, body: body.body, remaining });
|
||||
} else if (body.kind === "latency") {
|
||||
rules.push({ kind: "latency", pathPrefix: body.pathPrefix, delayMs: body.delayMs, remaining });
|
||||
} else {
|
||||
throw new Error("Unknown fault kind.");
|
||||
}
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
} catch (error) {
|
||||
json(response, 400, { error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
json(response, 404, { error: "Not found" });
|
||||
}
|
||||
|
||||
const server = createServer((incoming, response) => {
|
||||
void (async () => {
|
||||
const path = incoming.url ?? "/";
|
||||
if (path.startsWith("/__openwork_faults")) {
|
||||
await control(incoming, response, path);
|
||||
return;
|
||||
}
|
||||
const rule = takeRule(path);
|
||||
if (rule?.kind === "status") {
|
||||
const body = JSON.stringify(rule.body ?? { error: "Injected HTTP " + rule.statusCode });
|
||||
requests.push({ method: incoming.method ?? "GET", path, status: rule.statusCode, faulted: true, at: Date.now() });
|
||||
response.writeHead(rule.statusCode, {
|
||||
"access-control-allow-origin": "*",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
"content-type": "application/json",
|
||||
});
|
||||
response.end(body);
|
||||
return;
|
||||
}
|
||||
if (rule?.kind === "latency") await delay(rule.delayMs);
|
||||
forward(incoming, response, rule !== null);
|
||||
})().catch((error) => {
|
||||
if (response.headersSent) {
|
||||
response.destroy(error instanceof Error ? error : undefined);
|
||||
return;
|
||||
}
|
||||
json(response, 500, { error: error instanceof Error ? error.message : String(error) });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0");
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./browser.ts";
|
||||
export * from "./daytona.ts";
|
||||
export * from "./den-stack.ts";
|
||||
export * from "./desktop.ts";
|
||||
export * from "./local.ts";
|
||||
export * from "./provision.ts";
|
||||
export * from "./resolve.ts";
|
||||
export * from "./types.ts";
|
||||
@@ -0,0 +1,963 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { constants, existsSync, openSync } from "node:fs";
|
||||
import { access, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { delimiter, dirname, join } from "node:path";
|
||||
import { allocateFreePort, allocateFreePorts, listTargets, waitForCdp } from "@openwork/cdp";
|
||||
import {
|
||||
desktopBootstrapPath,
|
||||
globalOpencodeConfigDir,
|
||||
opencodeDbCandidates,
|
||||
openworkEnvStorePath,
|
||||
openworkServerConfigPath,
|
||||
openworkServerDataDir,
|
||||
} from "@openwork/paths";
|
||||
import { ensureDenStack } from "./den-stack.ts";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import type { DisposableHost, SurfaceHandle, ElectronSurfaceOptions, ChromeSurfaceOptions, DenServiceOptions, DenServiceHandle, ShareLinks } from "./types.ts";
|
||||
|
||||
type OrgMode = "single_org" | "multi_org";
|
||||
|
||||
export interface LocalHostOptions {
|
||||
repoRoot: string;
|
||||
rootDir?: string;
|
||||
log(message: string): void;
|
||||
}
|
||||
|
||||
export interface ElectronProfilePaths {
|
||||
appDataDir: string;
|
||||
bootstrapPath: string;
|
||||
cacheHome: string;
|
||||
configHome: string;
|
||||
dataDir: string;
|
||||
dataHome: string;
|
||||
envStorePath: string;
|
||||
homeDir: string;
|
||||
localAppDataDir: string;
|
||||
opencodeConfigDir: string;
|
||||
root: string;
|
||||
stateHome: string;
|
||||
userDataDir: string;
|
||||
}
|
||||
|
||||
export interface InstalledProductionDesktopState {
|
||||
bootstrapPath: string;
|
||||
dataDir: string;
|
||||
envStorePath: string;
|
||||
homeDir: string;
|
||||
opencodeDb: string;
|
||||
opencodeConfigDir: string;
|
||||
serverConfigPath: string;
|
||||
serverStatePath: string;
|
||||
serverTokenStorePath: string;
|
||||
workspaceStatePath: string;
|
||||
}
|
||||
|
||||
export interface InstalledProductionDesktopStateOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
interface ElectronSurfaceEnvOptions {
|
||||
appName: string;
|
||||
appIdentifier: string;
|
||||
port: number;
|
||||
cdpPort: number;
|
||||
}
|
||||
|
||||
interface SpawnedDetached {
|
||||
child: ChildProcess;
|
||||
pid: number;
|
||||
}
|
||||
|
||||
interface SpawnDetachedOptions {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
interface KillLocalPidOptions {
|
||||
graceMs?: number;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface FreePortOptions {
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
const CDP_WAIT_TIMEOUT_MS = 120_000;
|
||||
const KILL_GRACE_MS = 5_000;
|
||||
const POLL_INTERVAL_MS = 250;
|
||||
const DEFAULT_CHROME_START_URL = "about:blank";
|
||||
const DEFAULT_DARWIN_CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
||||
const LINUX_CHROME_BINARIES = ["google-chrome", "chromium", "chromium-browser"];
|
||||
|
||||
let prepareSharedResourcesPromise: Promise<void> | null = null;
|
||||
|
||||
const delay = (ms: number) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | null {
|
||||
return isRecord(error) && typeof error.code === "string" ? error.code : null;
|
||||
}
|
||||
|
||||
function messageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function sanitizeSlug(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "") || "surface";
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 17);
|
||||
}
|
||||
|
||||
function pnpmCommand(): string {
|
||||
return process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
||||
}
|
||||
|
||||
function needsShell(command: string): boolean {
|
||||
return process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
|
||||
}
|
||||
|
||||
function executableOnPath(name: string, env: NodeJS.ProcessEnv, platform: string): string | null {
|
||||
const rawPath = env.PATH ?? env.Path ?? "";
|
||||
if (!rawPath) return null;
|
||||
const pathDelimiter = platform === "win32" ? ";" : delimiter;
|
||||
for (const entry of rawPath.split(pathDelimiter)) {
|
||||
if (!entry) continue;
|
||||
const candidate = join(entry, name);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function windowsChromeCandidates(env: NodeJS.ProcessEnv): string[] {
|
||||
const roots = [env.LOCALAPPDATA, env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.ProgramFiles]
|
||||
.filter(isNonEmptyString);
|
||||
return roots.map((root) => join(root, "Google", "Chrome", "Application", "chrome.exe"));
|
||||
}
|
||||
|
||||
function pidIsAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return errorCode(error) !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
function signalPid(pid: number, signal: NodeJS.Signals): boolean {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ESRCH") return false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function signalProcessGroup(pid: number, signal: NodeJS.Signals): boolean {
|
||||
if (process.platform === "win32") return false;
|
||||
try {
|
||||
process.kill(-pid, signal);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (errorCode(error) === "ESRCH") return false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function processGroupIsAlive(pid: number): boolean {
|
||||
if (process.platform === "win32") return false;
|
||||
try {
|
||||
process.kill(-pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return errorCode(error) !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntilGone(pid: number, timeoutMs: number): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (!pidIsAlive(pid) && !processGroupIsAlive(pid)) return true;
|
||||
await delay(POLL_INTERVAL_MS);
|
||||
}
|
||||
return !pidIsAlive(pid) && !processGroupIsAlive(pid);
|
||||
}
|
||||
|
||||
function listeningPids(port: number): Promise<number[]> {
|
||||
if (process.platform !== "darwin" && process.platform !== "linux") return Promise.resolve([]);
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
"lsof",
|
||||
[`-tiTCP:${port}`, "-sTCP:LISTEN"],
|
||||
{ encoding: "utf8", timeout: 5_000 },
|
||||
(error, stdout) => {
|
||||
const code = isRecord(error) ? error.code : null;
|
||||
if (error && code !== 1 && code !== "1") {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
const pids = stdout.split(/\s+/)
|
||||
.map((value) => Number(value))
|
||||
.filter((value) => Number.isInteger(value) && value > 0);
|
||||
resolve([...new Set(pids)]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function processGroupId(pid: number): Promise<number | null> {
|
||||
if (process.platform === "win32") return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
execFile("ps", ["-o", "pgid=", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 }, (error, stdout) => {
|
||||
if (error) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const pgid = Number(stdout.trim());
|
||||
resolve(Number.isInteger(pgid) && pgid > 1 ? pgid : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Ensure no process is listening on a local TCP port, killing stale owners. */
|
||||
export async function freePort(port: number, { log }: FreePortOptions = {}): Promise<void> {
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
|
||||
throw new Error(`Port must be an integer from 1 to 65535, got ${port}.`);
|
||||
}
|
||||
if (process.platform !== "darwin" && process.platform !== "linux") return;
|
||||
const deadline = Date.now() + 10_000;
|
||||
const currentProcessGroup = await processGroupId(process.pid);
|
||||
let pids = await listeningPids(port);
|
||||
while (pids.length > 0 && Date.now() < deadline) {
|
||||
for (const pid of pids) {
|
||||
if (pid === process.pid) throw new Error(`Refusing to kill the current process listening on port ${port}.`);
|
||||
const listenerGroup = await processGroupId(pid);
|
||||
const killPid = listenerGroup !== null && currentProcessGroup !== null && listenerGroup !== currentProcessGroup
|
||||
? listenerGroup
|
||||
: pid;
|
||||
const groupDetail = killPid === pid ? "" : ` in process group ${killPid}`;
|
||||
log?.(`Port ${port} is still held by listener pid ${pid}${groupDetail}; stopping it.`);
|
||||
await killLocalPid(killPid, { graceMs: 1_000, log });
|
||||
}
|
||||
await delay(100);
|
||||
pids = await listeningPids(port);
|
||||
}
|
||||
if (pids.length > 0) {
|
||||
throw new Error(`Port ${port} is still held by listener pid${pids.length === 1 ? "" : "s"} ${pids.join(", ")} after cleanup.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function tailLines(filePath: string, lineCount: number): Promise<string> {
|
||||
try {
|
||||
const text = await readFile(filePath, "utf8");
|
||||
return text.split(/\r?\n/).slice(-lineCount).join("\n");
|
||||
} catch (error) {
|
||||
return `Could not read ${filePath}: ${messageText(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPageTarget(cdpUrl: string, timeoutMs: number): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
let lastDetail = "no page targets yet";
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
await waitForCdp(cdpUrl, {
|
||||
timeoutMs: Math.min(1_000, Math.max(0, timeoutMs - (Date.now() - startedAt))),
|
||||
});
|
||||
const targets = await listTargets(cdpUrl, {
|
||||
timeoutMs: Math.min(1_000, Math.max(0, timeoutMs - (Date.now() - startedAt))),
|
||||
});
|
||||
if (targets.some((target) => target.type === "page" && target.webSocketDebuggerUrl)) return;
|
||||
lastDetail = `saw ${targets.length} targets, none were pages`;
|
||||
} catch (error) {
|
||||
lastDetail = messageText(error);
|
||||
}
|
||||
await delay(Math.min(POLL_INTERVAL_MS, Math.max(0, timeoutMs - (Date.now() - startedAt))));
|
||||
}
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for a page target at ${cdpUrl} (${lastDetail})`);
|
||||
}
|
||||
|
||||
function spawnDetached(command: string, args: string[], { cwd, env, logPath }: SpawnDetachedOptions): SpawnedDetached {
|
||||
const logFd = openSync(logPath, "a");
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
detached: true,
|
||||
shell: needsShell(command),
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
});
|
||||
child.unref();
|
||||
if (!child.pid) throw new Error(`Could not spawn ${command}.`);
|
||||
return { child, pid: child.pid };
|
||||
}
|
||||
|
||||
function chromeArgs(cdpPort: number, profileDir: string, startUrl: string, headless: boolean): string[] {
|
||||
const args = [
|
||||
`--remote-debugging-port=${cdpPort}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
"--window-size=1280,900",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-popup-blocking",
|
||||
// Avoid the Daytona preview h2 stall when ~28 dev chunks multiplex; h1.1 loads them, while plain-http local Den never negotiates h2.
|
||||
"--disable-http2",
|
||||
startUrl,
|
||||
];
|
||||
return headless ? ["--headless=new", ...args] : args;
|
||||
}
|
||||
|
||||
async function waitForCdpOrExit(label: string, cdpUrl: string, spawned: SpawnedDetached, logPath: string, requirePageTarget = false): Promise<void> {
|
||||
let exitDetail: string | null = null;
|
||||
const exitPromise = new Promise<void>((_resolve, reject) => {
|
||||
spawned.child.once("exit", async (code, signal) => {
|
||||
const detail = signal ? `signal ${signal}` : `exit ${code ?? 0}`;
|
||||
exitDetail = detail;
|
||||
const tail = await tailLines(logPath, 40);
|
||||
reject(new Error(`${label} process ${spawned.pid} stopped before CDP was ready (${detail}). Last 40 lines of ${logPath}:\n${tail}`));
|
||||
});
|
||||
});
|
||||
const livenessPromise = new Promise<void>((_resolve, reject) => {
|
||||
const timer = setInterval(async () => {
|
||||
if (exitDetail || pidIsAlive(spawned.pid)) return;
|
||||
clearInterval(timer);
|
||||
const tail = await tailLines(logPath, 40);
|
||||
reject(new Error(`${label} process ${spawned.pid} is not alive before CDP was ready. Last 40 lines of ${logPath}:\n${tail}`));
|
||||
}, POLL_INTERVAL_MS);
|
||||
timer.unref();
|
||||
});
|
||||
await Promise.race([
|
||||
(requirePageTarget ? waitForPageTarget(cdpUrl, CDP_WAIT_TIMEOUT_MS) : waitForCdp(cdpUrl, { timeoutMs: CDP_WAIT_TIMEOUT_MS })).catch(async (error) => {
|
||||
const tail = await tailLines(logPath, 40);
|
||||
throw new Error(`${messageText(error)}. Last 40 lines of ${logPath}:\n${tail}`);
|
||||
}),
|
||||
exitPromise,
|
||||
livenessPromise,
|
||||
]);
|
||||
}
|
||||
|
||||
async function runPrepareScript(scriptPath: string, outDir: string, desktopRoot: string): Promise<void> {
|
||||
try {
|
||||
await access(scriptPath, constants.R_OK);
|
||||
} catch {
|
||||
throw new Error(`Required Electron prepare helper is missing: ${scriptPath}`);
|
||||
}
|
||||
await new Promise<void>((resolveRun, reject) => {
|
||||
execFile(process.execPath, [scriptPath, "--force", "--outdir", outDir], { cwd: desktopRoot, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, (error, _stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`Electron shared resource prepare failed for ${scriptPath}: ${stderr || error.message}`));
|
||||
return;
|
||||
}
|
||||
resolveRun();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareSharedElectronResources(repoRoot: string, log: (message: string) => void): Promise<void> {
|
||||
if (process.env.OPENWORK_EVAL_ELECTRON_RESOURCES_PREPARED === "1") return;
|
||||
if (!prepareSharedResourcesPromise) {
|
||||
prepareSharedResourcesPromise = (async () => {
|
||||
const desktopRoot = join(repoRoot, "apps", "desktop");
|
||||
const scriptsRoot = join(desktopRoot, "scripts");
|
||||
log("Preparing shared Electron sidecars/helpers once for local eval surfaces...");
|
||||
await runPrepareScript(join(scriptsRoot, "prepare-sidecar.mjs"), join(desktopRoot, "resources", "sidecars"), desktopRoot);
|
||||
await runPrepareScript(join(scriptsRoot, "prepare-computer-use-helper.mjs"), join(desktopRoot, "resources", "helpers"), desktopRoot);
|
||||
})();
|
||||
}
|
||||
await prepareSharedResourcesPromise;
|
||||
}
|
||||
|
||||
async function ensureElectronProfile(paths: ElectronProfilePaths): Promise<void> {
|
||||
await Promise.all([
|
||||
mkdir(paths.root, { recursive: true }),
|
||||
mkdir(paths.userDataDir, { recursive: true }),
|
||||
mkdir(paths.appDataDir, { recursive: true }),
|
||||
mkdir(paths.localAppDataDir, { recursive: true }),
|
||||
mkdir(paths.opencodeConfigDir, { recursive: true }),
|
||||
mkdir(paths.dataDir, { recursive: true }),
|
||||
mkdir(paths.homeDir, { recursive: true }),
|
||||
mkdir(paths.configHome, { recursive: true }),
|
||||
mkdir(paths.dataHome, { recursive: true }),
|
||||
mkdir(paths.cacheHome, { recursive: true }),
|
||||
mkdir(paths.stateHome, { recursive: true }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function writeBootstrap(filePath: string, bootstrap: ElectronSurfaceOptions["bootstrap"]): Promise<void> {
|
||||
if (!bootstrap) return;
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${JSON.stringify(bootstrap, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function cleanUrl(value: string): string {
|
||||
return value.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isOrgMode(value: unknown): value is OrgMode {
|
||||
return value === "single_org" || value === "multi_org";
|
||||
}
|
||||
|
||||
async function runtimeOrgMode(webUrl: string): Promise<OrgMode> {
|
||||
const response = await fetch(`${cleanUrl(webUrl)}/api/runtime-config`, { signal: AbortSignal.timeout(8_000) });
|
||||
if (!response.ok) throw new Error(`runtime-config returned HTTP ${response.status}`);
|
||||
const body: unknown = await response.json();
|
||||
if (isRecord(body) && isOrgMode(body.orgMode)) return body.orgMode;
|
||||
throw new Error("runtime-config response did not include orgMode");
|
||||
}
|
||||
|
||||
export function electronProfilePaths(root: string): ElectronProfilePaths {
|
||||
return {
|
||||
appDataDir: join(root, "appdata"),
|
||||
bootstrapPath: join(root, "bootstrap.json"),
|
||||
cacheHome: join(root, "xdg-cache"),
|
||||
configHome: join(root, "xdg-config"),
|
||||
dataDir: join(root, "openwork-data"),
|
||||
dataHome: join(root, "xdg-data"),
|
||||
envStorePath: join(root, "openwork-env.json"),
|
||||
homeDir: join(root, "home"),
|
||||
localAppDataDir: join(root, "local-appdata"),
|
||||
opencodeConfigDir: join(root, "opencode-config"),
|
||||
root,
|
||||
stateHome: join(root, "xdg-state"),
|
||||
userDataDir: join(root, "electron-userdata"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the HOST keeps pnpm's self-managed versions. The surface's fresh HOME
|
||||
* hides this cache, and pnpm (packageManager pin vs the global binary) then
|
||||
* re-downloads its pinned version from the network on EVERY spawn — observed
|
||||
* on a Daytona sandbox as a 21MB download per Electron spawn that, when it
|
||||
* failed once, degenerated into a self-sustaining recursive `pnpm add pnpm`
|
||||
* cascade that outlived the spec run. Pointing PNPM_HOME at the host's real
|
||||
* pnpm home keeps version redirection local and instant.
|
||||
*/
|
||||
function hostPnpmHome(): string | null {
|
||||
const configured = process.env.PNPM_HOME?.trim();
|
||||
if (configured) return configured;
|
||||
if (process.platform === "darwin") return join(homedir(), "Library", "pnpm");
|
||||
if (process.platform === "linux") {
|
||||
const dataHome = process.env.XDG_DATA_HOME?.trim();
|
||||
return join(dataHome || join(homedir(), ".local", "share"), "pnpm");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA?.trim();
|
||||
return localAppData ? join(localAppData, "pnpm") : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function electronSurfaceEnv(
|
||||
paths: ElectronProfilePaths,
|
||||
options: ElectronSurfaceEnvOptions,
|
||||
overrides: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const pnpmHome = hostPnpmHome();
|
||||
// Give local eval Electron surfaces isolated app data, config, and identity so
|
||||
// they cannot affect the user's real desktop app.
|
||||
return {
|
||||
...(pnpmHome ? { PNPM_HOME: pnpmHome } : {}),
|
||||
APPDATA: paths.appDataDir,
|
||||
HOME: paths.homeDir,
|
||||
LOCALAPPDATA: paths.localAppDataDir,
|
||||
OPENWORK_DATA_DIR: paths.dataDir,
|
||||
OPENWORK_DESKTOP_BOOTSTRAP_PATH: paths.bootstrapPath,
|
||||
OPENWORK_DESKTOP_DISABLE_WORKSPACE_RECOVERY: "1",
|
||||
OPENWORK_DEV_MODE: "1",
|
||||
OPENWORK_ENV_STORE: paths.envStorePath,
|
||||
OPENCODE_CONFIG_DIR: paths.opencodeConfigDir,
|
||||
VITE_DISABLE_OPENWORK_MODELS: "1",
|
||||
OPENWORK_ELECTRON_APP_IDENTIFIER: options.appIdentifier,
|
||||
OPENWORK_ELECTRON_APP_NAME: options.appName,
|
||||
OPENWORK_ELECTRON_DISABLE_PROTOCOL_REGISTRATION: "1",
|
||||
OPENWORK_ELECTRON_REMOTE_DEBUG_PORT: String(options.cdpPort),
|
||||
OPENWORK_ELECTRON_SKIP_SHARED_PREPARE: "1",
|
||||
OPENWORK_ELECTRON_USE_MOCK_KEYCHAIN: "1",
|
||||
OPENWORK_ELECTRON_USERDATA: paths.userDataDir,
|
||||
PORT: String(options.port),
|
||||
XDG_CACHE_HOME: paths.cacheHome,
|
||||
XDG_CONFIG_HOME: paths.configHome,
|
||||
XDG_DATA_HOME: paths.dataHome,
|
||||
XDG_STATE_HOME: paths.stateHome,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function liveSharedProductionStateEnv(state: InstalledProductionDesktopState): Record<string, string> {
|
||||
return {
|
||||
HOME: state.homeDir,
|
||||
USERPROFILE: state.homeDir,
|
||||
XDG_CONFIG_HOME: join(state.homeDir, ".config"),
|
||||
XDG_DATA_HOME: join(state.homeDir, ".local", "share"),
|
||||
XDG_CACHE_HOME: join(state.homeDir, ".cache"),
|
||||
XDG_STATE_HOME: join(state.homeDir, ".local", "state"),
|
||||
OPENWORK_DATA_DIR: state.dataDir,
|
||||
OPENWORK_DESKTOP_BOOTSTRAP_PATH: state.bootstrapPath,
|
||||
OPENWORK_DESKTOP_DISABLE_WORKSPACE_RECOVERY: "0",
|
||||
OPENWORK_DESKTOP_WORKSPACE_STATE_PATH: state.workspaceStatePath,
|
||||
OPENWORK_DEV_SHARED_STATE: "1",
|
||||
OPENWORK_ENV_STORE: state.envStorePath,
|
||||
OPENWORK_SERVER_CONFIG: state.serverConfigPath,
|
||||
OPENWORK_SERVER_STATE_PATH: state.serverStatePath,
|
||||
OPENWORK_SERVER_TOKEN_STORE_PATH: state.serverTokenStorePath,
|
||||
OPENCODE_DB: state.opencodeDb,
|
||||
OPENCODE_CONFIG_DIR: state.opencodeConfigDir,
|
||||
};
|
||||
}
|
||||
|
||||
async function requireInstalledPath(path: string, kind: "directory" | "file", label: string): Promise<void> {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await stat(path);
|
||||
} catch {
|
||||
throw new Error(`${label} is unavailable at ${path}. Start the installed production OpenWork desktop once and confirm its local state exists.`);
|
||||
}
|
||||
const matches = kind === "directory" ? metadata.isDirectory() : metadata.isFile();
|
||||
if (!matches) throw new Error(`${label} at ${path} is not a ${kind}.`);
|
||||
}
|
||||
|
||||
/** Resolve installed production stores without launching, copying, linking, or mutating them. */
|
||||
export async function resolveInstalledProductionDesktopState(
|
||||
options: InstalledProductionDesktopStateOptions = {},
|
||||
): Promise<InstalledProductionDesktopState> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform !== "darwin") {
|
||||
throw new Error(`Live shared installed-production state is currently supported only on macOS; received ${platform}.`);
|
||||
}
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
const dataDir = openworkServerDataDir({ env, homeDir, platform });
|
||||
await requireInstalledPath(dataDir, "directory", "Installed production OpenWork data directory");
|
||||
const userDataDir = join(homeDir, "Library", "Application Support", "com.differentai.openwork");
|
||||
const workspaceStatePath = join(userDataDir, "openwork-workspaces.json");
|
||||
const serverTokenStorePath = join(userDataDir, "openwork-server-tokens.json");
|
||||
const serverStatePath = join(userDataDir, "openwork-server-state.json");
|
||||
const serverConfigPath = openworkServerConfigPath({ env, homeDir, platform });
|
||||
const envStorePath = openworkEnvStorePath({ env, homeDir, platform });
|
||||
const bootstrapPath = desktopBootstrapPath({ env, homeDir, platform, userDataDir });
|
||||
const opencodeConfigDir = globalOpencodeConfigDir({ env, homeDir, platform });
|
||||
for (const [path, label] of [
|
||||
[workspaceStatePath, "Installed production workspace state"],
|
||||
[serverTokenStorePath, "Installed production server token store"],
|
||||
[serverStatePath, "Installed production server state"],
|
||||
[serverConfigPath, "Installed production server config"],
|
||||
[envStorePath, "Installed production environment store"],
|
||||
[bootstrapPath, "Installed production desktop bootstrap"],
|
||||
]) {
|
||||
await requireInstalledPath(path, "file", label);
|
||||
}
|
||||
await requireInstalledPath(opencodeConfigDir, "directory", "Installed production OpenCode config directory");
|
||||
|
||||
const candidates = opencodeDbCandidates({
|
||||
env,
|
||||
homeDir,
|
||||
platform,
|
||||
defaultChannel: "latest",
|
||||
});
|
||||
let opencodeDb: string | undefined;
|
||||
for (const candidate of candidates) {
|
||||
const metadata = await stat(candidate).catch(() => null);
|
||||
if (metadata?.isFile()) {
|
||||
opencodeDb = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!opencodeDb) {
|
||||
throw new Error(
|
||||
`Installed production OpenCode database is unavailable. Checked channel-aware candidates: ${candidates.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
bootstrapPath,
|
||||
dataDir,
|
||||
envStorePath,
|
||||
homeDir,
|
||||
opencodeDb,
|
||||
opencodeConfigDir,
|
||||
serverConfigPath,
|
||||
serverStatePath,
|
||||
serverTokenStorePath,
|
||||
workspaceStatePath,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveChromeBinary(env: NodeJS.ProcessEnv = process.env, platform: string = process.platform): string {
|
||||
const configured = env.CHROME_BIN?.trim();
|
||||
if (configured) return configured;
|
||||
if (platform === "darwin") return DEFAULT_DARWIN_CHROME;
|
||||
if (platform === "linux") {
|
||||
for (const name of LINUX_CHROME_BINARIES) {
|
||||
const resolved = executableOnPath(name, env, platform);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
throw new Error(`Could not resolve Chrome binary on linux. Set CHROME_BIN or put one of ${LINUX_CHROME_BINARIES.join(", ")} on PATH.`);
|
||||
}
|
||||
if (platform === "win32") {
|
||||
for (const candidate of windowsChromeCandidates(env)) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error("Could not resolve Chrome binary on win32. Set CHROME_BIN or install Google Chrome in a standard location.");
|
||||
}
|
||||
throw new Error(`Could not resolve Chrome binary on unsupported platform ${platform}. Set CHROME_BIN.`);
|
||||
}
|
||||
|
||||
export async function killLocalPid(pid: number, options: KillLocalPidOptions = {}): Promise<boolean> {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return false;
|
||||
const rootWasAlive = pidIsAlive(pid);
|
||||
const graceMs = options.graceMs ?? KILL_GRACE_MS;
|
||||
const sentGroup = signalProcessGroup(pid, "SIGINT");
|
||||
const sentDirect = !sentGroup && rootWasAlive ? signalPid(pid, "SIGINT") : false;
|
||||
if (!sentGroup && !sentDirect) return false;
|
||||
if (sentGroup) options.log?.(`Sent SIGINT to process group ${pid}`);
|
||||
if (sentDirect && !sentGroup) options.log?.(`Sent SIGINT to pid ${pid}`);
|
||||
if (await waitUntilGone(pid, graceMs)) return true;
|
||||
const killedGroup = signalProcessGroup(pid, "SIGKILL");
|
||||
const killedDirect = pidIsAlive(pid) ? signalPid(pid, "SIGKILL") : false;
|
||||
if (killedGroup) options.log?.(`Sent SIGKILL to process group ${pid}`);
|
||||
else if (killedDirect) options.log?.(`Sent SIGKILL to pid ${pid}`);
|
||||
await waitUntilGone(pid, 1_000);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ownedSurfaceFilePaths(handle: SurfaceHandle): string[] {
|
||||
return handle.kind === "electron" && handle.profileDir && handle.meta?.profileOwner !== "caller"
|
||||
? [handle.profileDir]
|
||||
: [];
|
||||
}
|
||||
|
||||
export async function removeOwnedSurfaceFiles(handle: SurfaceHandle): Promise<void> {
|
||||
for (const path of ownedSurfaceFilePaths(handle)) {
|
||||
await rm(path, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop a detached eval Electron only when its process environment still names its isolated profile. */
|
||||
export async function stopOwnedElectronSurface(pid: number, profileDir: string): Promise<void> {
|
||||
if (!Number.isInteger(pid) || pid <= 1 || !profileDir.trim()) {
|
||||
throw new Error("Detached Electron ownership requires a valid pid and profile directory.");
|
||||
}
|
||||
const processDescription = await new Promise<string>((resolveDescription, reject) => {
|
||||
execFile("ps", ["eww", "-p", String(pid)], { encoding: "utf8", timeout: 5_000 }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(new Error(`Detached Electron process ${pid} is not running.`));
|
||||
return;
|
||||
}
|
||||
resolveDescription(stdout);
|
||||
});
|
||||
});
|
||||
if (!processDescription.includes(`OPENWORK_ELECTRON_USERDATA=${join(profileDir, "electron-userdata")}`)) {
|
||||
throw new Error(`Refusing to stop pid ${pid}: it does not own the expected eval Electron profile.`);
|
||||
}
|
||||
await killLocalPid(pid);
|
||||
await rm(profileDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function explicitPort(url: string): number | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.hostname !== "127.0.0.1" && parsed.hostname !== "localhost" && parsed.hostname !== "::1") return null;
|
||||
const port = Number(parsed.port);
|
||||
return Number.isInteger(port) && port > 0 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function surfacePorts(handle: SurfaceHandle): number[] {
|
||||
const ports = new Set<number>();
|
||||
const fromUrl = explicitPort(handle.cdpUrl);
|
||||
if (fromUrl !== null) ports.add(fromUrl);
|
||||
for (const value of [handle.meta?.vitePort, handle.meta?.cdpPort]) {
|
||||
const port = Number(value);
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65_535) ports.add(port);
|
||||
}
|
||||
return [...ports];
|
||||
}
|
||||
|
||||
export function createLocalHost(options: LocalHostOptions): DisposableHost {
|
||||
// Surface profiles become the Electron process HOME. node-gyp/electron-rebuild
|
||||
// generate Makefiles with unquoted include paths under that HOME, so a repo
|
||||
// checkout on a path containing spaces breaks every native rebuild. The env
|
||||
// override lets such machines park surfaces on a space-free path (e.g. /tmp).
|
||||
const surfacesRootOverride = process.env.OPENWORK_EVAL_SURFACES_DIR?.trim();
|
||||
const rootDir = options.rootDir ?? (surfacesRootOverride
|
||||
? surfacesRootOverride
|
||||
: join(options.repoRoot, "evals", "results", ".surfaces", String(process.pid)));
|
||||
const log = options.log;
|
||||
const spawnedSurfaces = new Set<SurfaceHandle>();
|
||||
const denPorts = new Set<number>();
|
||||
|
||||
async function disposeKnownPorts(handle: SurfaceHandle): Promise<void> {
|
||||
for (const port of surfacePorts(handle)) await freePort(port, { log });
|
||||
}
|
||||
|
||||
async function disposeDenPorts(): Promise<void> {
|
||||
for (const port of denPorts) await freePort(port, { log });
|
||||
denPorts.clear();
|
||||
}
|
||||
|
||||
|
||||
// Containers (Daytona sandboxes) cannot use Chromium's SUID sandbox: the helper
|
||||
// binary in a mounted pnpm store is not root-owned, and Electron aborts with
|
||||
// "The SUID sandbox helper binary was found, but is not configured correctly".
|
||||
// The desktop honours ELECTRON_EXTRA_LAUNCH_ARGS (apps/desktop/electron/main.mjs),
|
||||
// so pass the container-safe switches when we detect a sandbox.
|
||||
function insideContainerSandbox(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if ((env.DAYTONA_SANDBOX_ID ?? "").trim().length > 0) return true;
|
||||
if ((env.OPENWORK_EVAL_CONTAINER_ELECTRON ?? "").trim() === "1") return true;
|
||||
return existsSync("/daytona-secrets") || existsSync("/daytona-artifacts");
|
||||
}
|
||||
|
||||
function containerLaunchArgs(existing: string | undefined): string | undefined {
|
||||
if (!insideContainerSandbox()) return existing;
|
||||
const needed = ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--enable-unsafe-swiftshader"];
|
||||
const present = (existing ?? "").split(/\s+/).filter((entry) => entry.length > 0);
|
||||
for (const arg of needed) if (!present.includes(arg)) present.push(arg);
|
||||
return present.join(" ");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Spawning a desktop has environment preconditions that only this component can
|
||||
* reasonably own. Callers should not have to know that Electron needs a live X
|
||||
* server, or that a previous run's process may still hold a port.
|
||||
*/
|
||||
const sleepMs = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function displayAnswers(display: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
execFile("xdpyinfo", ["-display", display], (error) => resolve(!error));
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureDisplay(repoRoot: string, env: NodeJS.ProcessEnv, log: (message: string) => void): Promise<void> {
|
||||
const display = (env.DISPLAY ?? "").trim();
|
||||
if (!display) return;
|
||||
if (await displayAnswers(display)) return;
|
||||
// A stale /tmp/.X11-unix socket is not proof the server is alive: Electron
|
||||
// exits with "Missing X server or $DISPLAY", which looks like a hung renderer.
|
||||
const starter = join(repoRoot, ".devcontainer", "start-daytona-vnc.sh");
|
||||
if (!existsSync(starter)) {
|
||||
log(`Display ${display} is not answering and ${starter} is missing; Electron will fail to start.`);
|
||||
return;
|
||||
}
|
||||
log(`Display ${display} is not answering; starting the virtual display...`);
|
||||
spawnDetached("bash", [starter], { cwd: repoRoot, env, logPath: join(repoRoot, "evals", "results", "virtual-display.log") });
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await sleepMs(2_000);
|
||||
if (await displayAnswers(display)) {
|
||||
log(`Display ${display} is live.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
log(`Display ${display} still not answering after 60s; Electron will fail to start.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill Electron processes from earlier runs of THIS host's surfaces, and delete
|
||||
* the profile directories they left behind.
|
||||
*
|
||||
* Disposal removes a profile on the happy path, but every killed or failed run
|
||||
* leaks one at ~50MB. Twenty-seven of them filled a 10GB sandbox to 99%, and the
|
||||
* symptom was not "disk full" — it was the renderer failing to answer
|
||||
* `Runtime.evaluate` for 240s, which reads exactly like a broken app. Pruning
|
||||
* here is cheap and keeps that failure from being invented again.
|
||||
*/
|
||||
async function clearStaleSurfaces(rootDir: string, log: (message: string) => void): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
execFile("pkill", ["--full", rootDir], () => resolve());
|
||||
});
|
||||
// The default root is worker-scoped, so parallel files cannot kill or remove
|
||||
// another worker's desktop while pruning their own stale profiles.
|
||||
const stale = await readdir(rootDir).catch(() => []);
|
||||
let removed = 0;
|
||||
for (const entry of stale) {
|
||||
await rm(join(rootDir, entry), { recursive: true, force: true }).then(() => { removed += 1; }).catch(() => undefined);
|
||||
}
|
||||
log(`Cleared Electron processes and ${removed} stale profile director${removed === 1 ? "y" : "ies"} in ${rootDir}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "local",
|
||||
workspaceRoot: options.repoRoot,
|
||||
|
||||
async spawnElectron(name: string, opts: ElectronSurfaceOptions = {}): Promise<SurfaceHandle> {
|
||||
if (opts.prepareSharedResources !== false) {
|
||||
await prepareSharedElectronResources(options.repoRoot, log);
|
||||
}
|
||||
const spawnEnvForChecks: NodeJS.ProcessEnv = { ...process.env, ...opts.env };
|
||||
if (insideContainerSandbox() && (spawnEnvForChecks.DISPLAY ?? "").trim().length === 0) spawnEnvForChecks.DISPLAY = ":99";
|
||||
await ensureDisplay(options.repoRoot, spawnEnvForChecks, log);
|
||||
if (opts.profileDir !== undefined && !opts.profileDir.trim()) {
|
||||
throw new Error("Electron profileDir must not be empty.");
|
||||
}
|
||||
const callerOwnedProfile = opts.profileDir !== undefined;
|
||||
if (!callerOwnedProfile) await clearStaleSurfaces(rootDir, log);
|
||||
const profileRoot = opts.profileDir ?? join(rootDir, `${sanitizeSlug(name)}-${timestamp()}-${process.pid}`);
|
||||
const paths = electronProfilePaths(profileRoot);
|
||||
await ensureElectronProfile(paths);
|
||||
await writeBootstrap(paths.bootstrapPath, opts.bootstrap);
|
||||
const [port, cdpPort] = await allocateFreePorts(2);
|
||||
if (port === undefined || cdpPort === undefined) throw new Error("Could not allocate Electron Vite/CDP ports.");
|
||||
const appName = `OpenWork Eval ${name}`;
|
||||
const appIdentifier = `com.differentai.openwork.eval.${sanitizeSlug(name)}`;
|
||||
const isolationEnv = electronSurfaceEnv(paths, { appName, appIdentifier, port, cdpPort }, opts.env);
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, ...isolationEnv };
|
||||
const launchArgs = containerLaunchArgs(env.ELECTRON_EXTRA_LAUNCH_ARGS);
|
||||
if (launchArgs !== undefined) env.ELECTRON_EXTRA_LAUNCH_ARGS = launchArgs;
|
||||
// appendSwitch() in the main process runs too late for the SUID sandbox
|
||||
// check (it aborts in a child before our JS switches apply), so disable
|
||||
// the sandbox at process start the way Electron documents.
|
||||
if (insideContainerSandbox()) env.ELECTRON_DISABLE_SANDBOX = "1";
|
||||
// Sandbox exec sessions do not export DISPLAY, but Xvfb is running on :99
|
||||
// (see .devcontainer/start-daytona-electron.sh). Without it Electron
|
||||
// segfaults instead of opening a window.
|
||||
if (insideContainerSandbox() && (env.DISPLAY ?? "").trim().length === 0) env.DISPLAY = ":99";
|
||||
const logPath = join(profileRoot, "electron.log");
|
||||
const packagedBinary = opts.devCommand === undefined
|
||||
? process.env.OPENWORK_EVAL_ELECTRON_BINARY?.trim()
|
||||
: undefined;
|
||||
let spawned: SpawnedDetached;
|
||||
if (packagedBinary) {
|
||||
await access(packagedBinary, constants.F_OK).catch(() => {
|
||||
throw new Error(`OPENWORK_EVAL_ELECTRON_BINARY does not exist: ${packagedBinary}`);
|
||||
});
|
||||
log(`Starting local Electron surface ${name} from packaged binary ${packagedBinary} (CDP :${cdpPort})...`);
|
||||
spawned = spawnDetached(packagedBinary, [], { cwd: options.repoRoot, env, logPath });
|
||||
} else {
|
||||
log(`Starting local Electron surface ${name} (Vite :${port}, CDP :${cdpPort})...`);
|
||||
spawned = spawnDetached(pnpmCommand(), [opts.devCommand ?? "dev:electron"], { cwd: options.repoRoot, env, logPath });
|
||||
}
|
||||
const cdpUrl = `http://127.0.0.1:${cdpPort}`;
|
||||
try {
|
||||
await waitForCdpOrExit("Electron", cdpUrl, spawned, logPath, true);
|
||||
} catch (error) {
|
||||
await killLocalPid(spawned.pid, { log });
|
||||
await Promise.all([
|
||||
freePort(port, { log }),
|
||||
freePort(cdpPort, { log }),
|
||||
]).catch((cleanupError: unknown) => log(`Electron port cleanup failed: ${messageText(cleanupError)}`));
|
||||
throw error;
|
||||
}
|
||||
const handle: SurfaceHandle = {
|
||||
name,
|
||||
kind: "electron",
|
||||
hostKind: "local",
|
||||
cdpUrl,
|
||||
pid: spawned.pid,
|
||||
profileDir: profileRoot,
|
||||
meta: { vitePort: String(port), cdpPort: String(cdpPort), log: logPath, profileOwner: callerOwnedProfile ? "caller" : "host" },
|
||||
};
|
||||
spawnedSurfaces.add(handle);
|
||||
return handle;
|
||||
},
|
||||
|
||||
async spawnChrome(name: string, opts: ChromeSurfaceOptions = {}): Promise<SurfaceHandle> {
|
||||
const profileRoot = join(rootDir, `${sanitizeSlug(name)}-${timestamp()}-${process.pid}`);
|
||||
const profileDir = join(profileRoot, "chrome-profile");
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
const cdpPort = await allocateFreePort();
|
||||
const binary = resolveChromeBinary(process.env, process.platform);
|
||||
const startUrl = opts.startUrl ?? DEFAULT_CHROME_START_URL;
|
||||
const logPath = join(profileRoot, "chrome.log");
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
const cdpUrl = `http://127.0.0.1:${cdpPort}`;
|
||||
const launch = async (headless: boolean): Promise<SpawnedDetached> => {
|
||||
const spawned = spawnDetached(binary, chromeArgs(cdpPort, profileDir, startUrl, headless), { cwd: profileRoot, env, logPath });
|
||||
await writeFile(join(profileDir, "openwork-eval-chrome.pid"), `${spawned.pid}\n`, "utf8");
|
||||
try {
|
||||
await waitForCdpOrExit("Chrome", cdpUrl, spawned, logPath);
|
||||
} catch (error) {
|
||||
await killLocalPid(spawned.pid, { log });
|
||||
await freePort(cdpPort, { log })
|
||||
.catch((cleanupError: unknown) => log(`Chrome port cleanup failed: ${messageText(cleanupError)}`));
|
||||
throw error;
|
||||
}
|
||||
return spawned;
|
||||
};
|
||||
let spawned: SpawnedDetached;
|
||||
try {
|
||||
spawned = await launch(opts.headless === true || process.env.OPENWORK_EVAL_CHROME_HEADLESS === "1");
|
||||
} catch (error) {
|
||||
if (!messageText(error).includes("SIGTRAP")) throw error;
|
||||
log(`Chrome surface ${name} exited with SIGTRAP under the windowed launch; retrying with --headless=new.`);
|
||||
spawned = await launch(true);
|
||||
}
|
||||
const handle: SurfaceHandle = {
|
||||
name,
|
||||
kind: "chrome",
|
||||
hostKind: "local",
|
||||
cdpUrl,
|
||||
pid: spawned.pid,
|
||||
profileDir,
|
||||
meta: { cdpPort: String(cdpPort), log: logPath },
|
||||
};
|
||||
spawnedSurfaces.add(handle);
|
||||
return handle;
|
||||
},
|
||||
|
||||
async startDen(opts: DenServiceOptions = {}): Promise<DenServiceHandle> {
|
||||
if (opts.seed === "none") {
|
||||
log("seed:none requested; local Den stack currently keeps the Acme demo seed, so continuing with the default seed.");
|
||||
}
|
||||
await ensureDenStack({ log, cdpCandidates: [], skipApp: true, orgMode: opts.orgMode });
|
||||
const apiUrl = process.env.OPENWORK_EVAL_DEN_API_URL?.trim();
|
||||
const webUrl = process.env.OPENWORK_EVAL_DEN_WEB_URL?.trim();
|
||||
if (!apiUrl || !webUrl) throw new Error("Den stack did not export OPENWORK_EVAL_DEN_API_URL / OPENWORK_EVAL_DEN_WEB_URL.");
|
||||
const orgMode = await runtimeOrgMode(webUrl);
|
||||
const apiPort = explicitPort(apiUrl);
|
||||
const webPort = explicitPort(webUrl);
|
||||
if (apiPort !== null) denPorts.add(apiPort);
|
||||
if (webPort !== null) denPorts.add(webPort);
|
||||
return { webUrl, apiUrl, orgMode, hostKind: "local" };
|
||||
},
|
||||
|
||||
async share(): Promise<ShareLinks> {
|
||||
const links: ShareLinks = [];
|
||||
const webUrl = process.env.OPENWORK_EVAL_DEN_WEB_URL?.trim();
|
||||
const apiUrl = process.env.OPENWORK_EVAL_DEN_API_URL?.trim();
|
||||
if (webUrl) links.push({ label: "Den Web", url: webUrl });
|
||||
if (apiUrl) links.push({ label: "Den API", url: apiUrl });
|
||||
return links;
|
||||
},
|
||||
|
||||
async disposeSurface(handle: SurfaceHandle): Promise<void> {
|
||||
if (handle.pid !== undefined) {
|
||||
await killLocalPid(handle.pid, { log });
|
||||
}
|
||||
await disposeKnownPorts(handle);
|
||||
await removeOwnedSurfaceFiles(handle);
|
||||
spawnedSurfaces.delete(handle);
|
||||
},
|
||||
|
||||
async stop(): Promise<void> {
|
||||
for (const handle of [...spawnedSurfaces]) await this.disposeSurface(handle);
|
||||
await disposeDenPorts();
|
||||
},
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
for (const handle of [...spawnedSurfaces]) {
|
||||
await this.disposeSurface(handle)
|
||||
.catch((error: unknown) => log(`Local surface ${handle.name} cleanup failed: ${messageText(error)}`));
|
||||
}
|
||||
await disposeDenPorts()
|
||||
.catch((error: unknown) => log(`Local Den port cleanup failed: ${messageText(error)}`));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,914 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { checkedExec, defaultDaytonaExec } from "./daytona.ts";
|
||||
import { FAULT_PROXY_SCRIPT } from "./fault-proxy-script.ts";
|
||||
import type { DaytonaExec, DaytonaExecResult } from "./daytona.ts";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
const DESKTOP_READY_TIMEOUT_MS = 300_000;
|
||||
const INSTALL_TIMEOUT_MS = 25 * 60 * 1_000;
|
||||
const SERVER_SCRIPT_TIMEOUT_MS = 20 * 60 * 1_000;
|
||||
const HTTPS_URL = /https:\/\/[^\s"'<>)]+/;
|
||||
const DEN_WEB_PORT = 3005;
|
||||
const DEN_API_PORT = 8788;
|
||||
|
||||
export interface ProvisionExecOptions {
|
||||
exec?: DaytonaExec;
|
||||
}
|
||||
|
||||
export interface DesktopSandboxOptions {
|
||||
ref: string;
|
||||
name: string;
|
||||
reuse?: string;
|
||||
snapshot?: string;
|
||||
/**
|
||||
* Mount the shared eval secrets volume. Off by default: `pnpm install` runs
|
||||
* lifecycle scripts from the checked-out ref, so mounting provider keys next
|
||||
* to an untrusted ref hands them to it. Signed-in org desktops can only pick
|
||||
* the org's own models anyway, and driver-side vision keys never live here —
|
||||
* so nothing in the connector room needs this.
|
||||
*/
|
||||
secrets?: boolean;
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
export interface DesktopSandbox {
|
||||
sandbox: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface DenSandboxOptions {
|
||||
ref: string;
|
||||
reuse?: string;
|
||||
repoRoot?: string;
|
||||
bootstrapAdminEmail?: string;
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
export interface DenSandbox {
|
||||
sandbox: string;
|
||||
apiUrl: string;
|
||||
webUrl: string;
|
||||
created: boolean;
|
||||
}
|
||||
|
||||
export interface MockOnSandboxOptions {
|
||||
sandbox: string;
|
||||
port?: number;
|
||||
log?: (line: string) => void;
|
||||
fetchImpl?: typeof fetch;
|
||||
allowUnauthenticatedMcp?: boolean;
|
||||
}
|
||||
|
||||
export interface MockOnSandbox {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface FaultProxyOnSandboxOptions {
|
||||
sandbox: string;
|
||||
port?: number;
|
||||
upstreamPort?: number;
|
||||
log?: (line: string) => void;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface FaultProxyOnSandbox {
|
||||
url: string;
|
||||
token: string;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ConnectorE2eTestEnv {
|
||||
denApiUrl: string;
|
||||
denWebUrl: string;
|
||||
sandboxA: string;
|
||||
sandboxB: string;
|
||||
mockUrl: string;
|
||||
ref: string;
|
||||
created: string[];
|
||||
}
|
||||
|
||||
function messageText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function outputTail(result: DaytonaExecResult): string {
|
||||
return `${result.stdout}${result.stderr}`.trim().slice(-2_000);
|
||||
}
|
||||
|
||||
function textTail(text: string): string {
|
||||
return text.trim().slice(-4_000);
|
||||
}
|
||||
|
||||
function firstHttpsUrl(text: string): string | null {
|
||||
const match = HTTPS_URL.exec(text);
|
||||
return match ? match[0].replace(/[.,;:]+$/, "") : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function timedStep<T>(log: (line: string) => void, name: string, action: () => Promise<T>): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
log(`==> ${name}...`);
|
||||
try {
|
||||
const result = await action();
|
||||
log(`==> ${name} done (${Date.now() - startedAt}ms)`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
log(`==> ${name} failed (${Date.now() - startedAt}ms)`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* daytona exec joins its trailing args with spaces, so a multi-word command
|
||||
* must travel as ONE argument or `bash -lc` receives only the first word and
|
||||
* the rest leaks into the remote login shell.
|
||||
*/
|
||||
export async function execInSandbox(
|
||||
exec: DaytonaExec,
|
||||
sandbox: string,
|
||||
script: string,
|
||||
opts: { timeoutMs?: number; context: string },
|
||||
): Promise<DaytonaExecResult> {
|
||||
if (script.includes("'")) throw new Error(`Remote script for ${opts.context} must not contain single quotes.`);
|
||||
return checkedExec(exec, ["exec", sandbox, "--", `bash -lc '${script}'`], opts.context, { timeoutMs: opts.timeoutMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* A ref travels into a remote double-quoted shell word AND into a file the
|
||||
* operator is told to `source`, so `$(...)`, a quote, or a newline in it is
|
||||
* remote code execution. Refuse anything outside git's own safe alphabet here
|
||||
* rather than escaping it correctly at each site forever.
|
||||
*/
|
||||
function assertSafeRef(ref: string): string {
|
||||
const value = ref.trim();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value)) {
|
||||
throw new Error(`Unsafe git ref ${JSON.stringify(ref)}: only letters, digits and . _ / - are allowed, and it may not start with "-".`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function snapshotId(output: string, name: string): string | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(output);
|
||||
} catch (error) {
|
||||
throw new Error(`Snapshot gate failed: daytona snapshot list returned invalid JSON: ${messageText(error)}. Output tail: ${textTail(output)}`);
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`Snapshot gate failed: daytona snapshot list did not return an array. Output tail: ${textTail(output)}`);
|
||||
}
|
||||
for (const entry of parsed) {
|
||||
if (isRecord(entry) && entry.name === name && typeof entry.id === "string" && entry.id.length > 0) return entry.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sandboxTimestamp(): string {
|
||||
return new Date().toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
||||
}
|
||||
|
||||
export function desktopSandboxName(name: string): string {
|
||||
// Split/join rather than trimming with /^-+|-+$/g: that pattern backtracks
|
||||
// quadratically on a mid-string run of hyphens (~1s at 40KB), which CodeQL
|
||||
// flags as polynomial ReDoS. This form cannot backtrack and also collapses
|
||||
// internal runs, so "a_-_b" yields "a-b" instead of "a---b".
|
||||
const safeName = name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).join("-") || "surface";
|
||||
return `openwork-connector-${safeName}-${sandboxTimestamp()}-${process.pid}-${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function serverSandboxName(): string {
|
||||
return `openwork-server-${sandboxTimestamp()}-${process.pid}-${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
async function waitForExecReady(exec: DaytonaExec, sandbox: string): Promise<void> {
|
||||
const deadline = Date.now() + DESKTOP_READY_TIMEOUT_MS;
|
||||
let lastError = "not attempted";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await execInSandbox(exec, sandbox, "true", { timeoutMs: 30_000, context: `sandbox exec-ready gate for ${sandbox}` });
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = messageText(error);
|
||||
}
|
||||
await delay(5_000);
|
||||
}
|
||||
throw new Error(`Sandbox exec-ready gate failed for ${sandbox} after 300s. Last output: ${lastError}`);
|
||||
}
|
||||
|
||||
function lastNonemptyLine(text: string): string {
|
||||
return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1) ?? "";
|
||||
}
|
||||
|
||||
export async function provisionDesktopSandbox(options: DesktopSandboxOptions & ProvisionExecOptions): Promise<DesktopSandbox> {
|
||||
const exec = options.exec ?? defaultDaytonaExec;
|
||||
const log = options.log ?? console.error;
|
||||
const ref = assertSafeRef(options.ref);
|
||||
const reused = options.reuse?.trim() || "";
|
||||
let sandbox = reused;
|
||||
|
||||
await timedStep(log, "sandbox gate", async () => {
|
||||
if (reused) {
|
||||
await exec(["sandbox", "start", reused], { timeoutMs: 60_000 });
|
||||
} else {
|
||||
const snapshot = options.snapshot ?? "openwork-eval-vnc";
|
||||
const listed = await checkedExec(exec, ["snapshot", "list", "-f", "json"], "snapshot gate", { timeoutMs: 60_000 });
|
||||
const id = snapshotId(listed.stdout, snapshot);
|
||||
if (!id) {
|
||||
throw new Error(`Snapshot gate failed: snapshot ${snapshot} is missing. Output tail: ${outputTail(listed)}`);
|
||||
}
|
||||
sandbox = desktopSandboxName(options.name);
|
||||
await checkedExec(
|
||||
exec,
|
||||
[
|
||||
"create",
|
||||
"--name", sandbox,
|
||||
"--snapshot", id,
|
||||
...(options.secrets === true ? ["--volume", "openwork-eval-secrets:/daytona-secrets"] : []),
|
||||
"--auto-stop", "60",
|
||||
"--public",
|
||||
"--target", "us",
|
||||
],
|
||||
`sandbox creation gate for ${sandbox}`,
|
||||
{ timeoutMs: 300_000 },
|
||||
);
|
||||
log(`==> desktop sandbox created: ${sandbox}`);
|
||||
}
|
||||
await waitForExecReady(exec, sandbox);
|
||||
});
|
||||
|
||||
await timedStep(log, "checkout gate", async () => {
|
||||
const result = await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
// Check out the REQUESTED ref, not FETCH_HEAD: a raw-sha fetch was
|
||||
// observed leaving FETCH_HEAD stale, silently running the wrong code —
|
||||
// and servers may refuse raw-sha fetches outright, so fall back to a
|
||||
// full fetch and prefer the remote-tracking ref over any stale local.
|
||||
`set -e; cd /workspace; git fetch origin "${ref}" 2>/dev/null || git fetch origin; git checkout --detach "origin/${ref}" 2>/dev/null || git checkout --detach "${ref}" 2>/dev/null || git checkout --detach FETCH_HEAD; git rev-parse --short=12 HEAD`,
|
||||
{ timeoutMs: 120_000, context: `checkout gate for ${sandbox}` },
|
||||
);
|
||||
const sha = lastNonemptyLine(result.stdout);
|
||||
if (!sha) throw new Error(`Checkout gate failed for ${sandbox}: git did not print a resolved sha. Output tail: ${outputTail(result)}`);
|
||||
const wantsSha = /^[0-9a-f]{7,40}$/.test(ref);
|
||||
if (wantsSha && !sha.startsWith(ref.slice(0, 12)) && !ref.startsWith(sha)) {
|
||||
throw new Error(`Checkout gate failed for ${sandbox}: asked for ${ref} but HEAD is ${sha}.`);
|
||||
}
|
||||
log(`==> checkout resolved ${sha}`);
|
||||
});
|
||||
|
||||
await timedStep(log, "install gate", async () => {
|
||||
await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"cd /workspace; pnpm install --store-dir /workspace/.openwork-daytona/pnpm-store",
|
||||
{ timeoutMs: INSTALL_TIMEOUT_MS, context: `install gate for ${sandbox}` },
|
||||
);
|
||||
});
|
||||
|
||||
await timedStep(log, "cleanup and disk gate", async () => {
|
||||
const result = await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"rm -rf /workspace/.openwork-daytona/profiles /tmp/openwork-* 2>/dev/null; df -P /workspace | tail -1",
|
||||
{ timeoutMs: 60_000, context: `cleanup and disk gate for ${sandbox}` },
|
||||
);
|
||||
const dfLine = lastNonemptyLine(result.stdout);
|
||||
const useField = dfLine.split(/\s+/).find((field) => /^\d+%$/.test(field));
|
||||
if (!useField) {
|
||||
throw new Error(`Cleanup and disk gate failed for ${sandbox}: could not parse Use% from ${JSON.stringify(dfLine)}.`);
|
||||
}
|
||||
const used = Number.parseInt(useField, 10);
|
||||
if (used > 85) {
|
||||
const sizes = await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"du -sh /workspace/node_modules /workspace/.openwork-daytona/pnpm-store 2>&1 || true",
|
||||
{ timeoutMs: 60_000, context: `disk usage detail for ${sandbox}` },
|
||||
);
|
||||
throw new Error(`Cleanup and disk gate failed for ${sandbox}: workspace is ${useField} used. df: ${dfLine}\n${outputTail(sizes)}`);
|
||||
}
|
||||
});
|
||||
|
||||
await timedStep(log, "display gate", async () => {
|
||||
const result = await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"bash /workspace/.devcontainer/start-daytona-vnc.sh >/tmp/vnc.log 2>&1; sleep 2; pgrep -f Xvfb >/dev/null && echo XVFB_OK || echo XVFB_FAIL",
|
||||
{ timeoutMs: 60_000, context: `display gate for ${sandbox}` },
|
||||
);
|
||||
if (!result.stdout.includes("XVFB_OK")) {
|
||||
throw new Error(`Display gate failed for ${sandbox}: expected XVFB_OK. Output tail: ${outputTail(result)}`);
|
||||
}
|
||||
});
|
||||
|
||||
await timedStep(log, "browser hop gate", async () => {
|
||||
// Chromium launched inside a pipe-stdin exec session TERMs the whole
|
||||
// session as it starts (exit 143 at ~2.5s; the same script survives under
|
||||
// a TTY). So nothing may run as a child of the session: both halves are
|
||||
// fully detached the way the mock and Vite gates are, and the proof is
|
||||
// read back by clean, childless polls.
|
||||
const detachScript = `rm -f /tmp/xdgtest.log; python3 - <<PYEOF
|
||||
import subprocess
|
||||
log = open("/tmp/xdgtest.log", "ab", buffering=0)
|
||||
subprocess.Popen(["python3", "-m", "http.server", "18099", "--bind", "127.0.0.1"], stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True)
|
||||
subprocess.Popen(["bash", "-lc", "sleep 1; export DISPLAY=:99; xdg-open http://127.0.0.1:18099/xdg-open-proof"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, close_fds=True)
|
||||
PYEOF
|
||||
echo detached`;
|
||||
await execInSandbox(exec, sandbox, detachScript, { timeoutMs: 30_000, context: `browser hop detach for ${sandbox}` });
|
||||
|
||||
const deadline = Date.now() + 60_000;
|
||||
let seen = false;
|
||||
while (Date.now() < deadline) {
|
||||
const probe = await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"grep -q xdg-open-proof /tmp/xdgtest.log 2>/dev/null && echo XDG_OPEN_WORKS || echo XDG_WAITING",
|
||||
{ timeoutMs: 15_000, context: `browser hop probe for ${sandbox}` },
|
||||
);
|
||||
if (probe.stdout.includes("XDG_OPEN_WORKS")) {
|
||||
seen = true;
|
||||
break;
|
||||
}
|
||||
await delay(3_000);
|
||||
}
|
||||
await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"pkill -f \"[h]ttp.server 18099\" >/dev/null 2>&1; pkill -f \"[c]hromium\" >/dev/null 2>&1; rm -f /tmp/xdgtest.log; true",
|
||||
{ timeoutMs: 15_000, context: `browser hop cleanup for ${sandbox}` },
|
||||
).catch(() => undefined);
|
||||
if (!seen) {
|
||||
throw new Error(`Browser hop gate failed for ${sandbox}: xdg-open never delivered a request (no browser reachable from the OAuth connect flow).`);
|
||||
}
|
||||
});
|
||||
|
||||
await timedStep(log, "first boot gate", async () => {
|
||||
// A sandbox's first Electron boot pays sidecar prepare, the
|
||||
// openwork-server tsc build, and the engine cold start. Paid INSIDE a
|
||||
// spec, that bill starved the tool-call phase past its window while every
|
||||
// UI assertion still passed. Boot once into a throwaway profile, wait for
|
||||
// CDP, tear it down — after this the room behaves like a warm machine.
|
||||
const detachScript = `python3 - <<PYEOF
|
||||
import subprocess
|
||||
log = open("/tmp/warmup-electron.log", "ab", buffering=0)
|
||||
subprocess.Popen(["bash", "-lc", "cd /workspace && env OPENWORK_ELECTRON_USERDATA=/tmp/warmup-profile OPENWORK_ELECTRON_REMOTE_DEBUG_PORT=9825 bash .devcontainer/start-daytona-electron.sh"], stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True)
|
||||
PYEOF
|
||||
echo detached`;
|
||||
await execInSandbox(exec, sandbox, detachScript, { timeoutMs: 30_000, context: `first boot detach for ${sandbox}` });
|
||||
const deadline = Date.now() + 420_000;
|
||||
let last = "not attempted";
|
||||
let ready = false;
|
||||
while (Date.now() < deadline) {
|
||||
const probe = await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"curl -s --max-time 5 http://127.0.0.1:9825/json/version || echo CDP_DOWN",
|
||||
{ timeoutMs: 15_000, context: `first boot probe for ${sandbox}` },
|
||||
).catch((error) => ({ stdout: messageText(error), stderr: "", code: 1 }));
|
||||
last = probe.stdout.trim().slice(0, 200);
|
||||
if (last.includes("Browser")) {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
await delay(5_000);
|
||||
}
|
||||
await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"pkill -f \"[e]lectron\" >/dev/null 2>&1; pkill -f \"[o]pencode\" >/dev/null 2>&1; sleep 1; rm -rf /tmp/warmup-profile; true",
|
||||
{ timeoutMs: 30_000, context: `first boot cleanup for ${sandbox}` },
|
||||
).catch(() => undefined);
|
||||
if (!ready) {
|
||||
const bootLog = await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"tail -60 /tmp/warmup-electron.log 2>&1 || true",
|
||||
{ timeoutMs: 30_000, context: `first boot log for ${sandbox}` },
|
||||
).catch(() => null);
|
||||
throw new Error(`First boot gate failed for ${sandbox}: CDP never answered on 9825. Last probe: ${last}. Log tail:\n${bootLog ? outputTail(bootLog) : "unavailable"}`);
|
||||
}
|
||||
});
|
||||
|
||||
await timedStep(log, "Vite prewarm gate", async () => {
|
||||
const detachScript = `cd /workspace; python3 - <<PYEOF
|
||||
import subprocess
|
||||
log = open("/tmp/vite-prewarm.log", "ab", buffering=0)
|
||||
subprocess.Popen(["bash", "-lc", "cd /workspace && pnpm -w dev:ui"], stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True)
|
||||
PYEOF
|
||||
echo detached`;
|
||||
await execInSandbox(exec, sandbox, detachScript, { timeoutMs: 30_000, context: `Vite prewarm detach for ${sandbox}` });
|
||||
|
||||
const deadline = Date.now() + 180_000;
|
||||
let last = "not attempted";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const result = await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"curl -s -o /dev/null -w %{http_code} --max-time 5 http://localhost:5173/",
|
||||
{ timeoutMs: 10_000, context: `Vite prewarm probe for ${sandbox}` },
|
||||
);
|
||||
last = result.stdout.trim();
|
||||
if (last === "200") return;
|
||||
} catch (error) {
|
||||
last = messageText(error);
|
||||
}
|
||||
await delay(5_000);
|
||||
}
|
||||
const viteLog = await execInSandbox(
|
||||
exec,
|
||||
sandbox,
|
||||
"tail -80 /tmp/vite-prewarm.log 2>&1 || true",
|
||||
{ timeoutMs: 30_000, context: `Vite prewarm log for ${sandbox}` },
|
||||
);
|
||||
throw new Error(`Vite prewarm gate failed for ${sandbox}: last probe ${last}. Log tail:\n${outputTail(viteLog)}`);
|
||||
});
|
||||
|
||||
return { sandbox, created: !reused };
|
||||
}
|
||||
|
||||
interface LocalProcessResult {
|
||||
output: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
interface LineWriter {
|
||||
push(text: string): void;
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
function lineWriter(log: (line: string) => void): LineWriter {
|
||||
let pending = "";
|
||||
return {
|
||||
push(text) {
|
||||
pending += text;
|
||||
const lines = pending.split(/\r?\n/);
|
||||
pending = lines.pop() ?? "";
|
||||
for (const line of lines) log(line);
|
||||
},
|
||||
flush() {
|
||||
if (pending) log(pending);
|
||||
pending = "";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runDenProvisionScript(ref: string, repoRoot: string, bootstrapAdminEmail: string | undefined, log: (line: string) => void, urlsFile: string): Promise<LocalProcessResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env, OPENWORK_DEN_URLS_FILE: urlsFile };
|
||||
if (bootstrapAdminEmail) env.DEN_BOOTSTRAP_ADMIN_EMAILS = bootstrapAdminEmail;
|
||||
const child = spawn("bash", [".devcontainer/test-server-on-daytona.sh", ref, "--seed", "--name", serverSandboxName()], {
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const stdoutLines = lineWriter(log);
|
||||
const stderrLines = lineWriter(log);
|
||||
let output = "";
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
}, SERVER_SCRIPT_TIMEOUT_MS);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
output += text;
|
||||
stdoutLines.push(text);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
const text = String(chunk);
|
||||
output += text;
|
||||
stderrLines.push(text);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
stdoutLines.flush();
|
||||
stderrLines.flush();
|
||||
if (timedOut) output += `\nTimed out after ${SERVER_SCRIPT_TIMEOUT_MS}ms.`;
|
||||
resolve({ output, code: timedOut ? 124 : code ?? 1 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function sandboxFromServerOutput(output: string): string | null {
|
||||
let fallback: string | null = null;
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const ready = /Server sandbox ready:\s*(\S+)/.exec(line);
|
||||
if (ready?.[1]) return ready[1];
|
||||
const creating = /Creating server sandbox:\s*(\S+)/.exec(line);
|
||||
if (creating?.[1]) fallback = creating[1];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parsedPublicUrl(value: string | undefined): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" || url.pathname !== "/" || url.search || url.hash || !url.hostname) return null;
|
||||
return url.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDenUrlsFile(content: string): { webUrl: string; apiUrl: string } | null {
|
||||
const entries = new Map<string, string>();
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator <= 0) continue;
|
||||
entries.set(line.slice(0, separator).trim(), line.slice(separator + 1).trim());
|
||||
}
|
||||
const webUrl = parsedPublicUrl(entries.get("DEN_WEB_URL"));
|
||||
const apiUrl = parsedPublicUrl(entries.get("DEN_API_URL"));
|
||||
return webUrl && apiUrl ? { webUrl, apiUrl } : null;
|
||||
}
|
||||
|
||||
|
||||
async function previewUrl(exec: DaytonaExec, sandbox: string, port: number): Promise<string> {
|
||||
let lastError = "not attempted";
|
||||
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||
try {
|
||||
const result = await checkedExec(
|
||||
exec,
|
||||
["preview-url", sandbox, "-p", String(port), "--expires", "86400"],
|
||||
`preview URL gate for ${sandbox}:${port}`,
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
const url = firstHttpsUrl(result.stdout);
|
||||
if (url) return url;
|
||||
lastError = `no https URL in output tail: ${outputTail(result)}`;
|
||||
} catch (error) {
|
||||
lastError = messageText(error);
|
||||
}
|
||||
if (attempt < 4) await delay(attempt * 1_000);
|
||||
}
|
||||
throw new Error(`Preview URL gate failed for ${sandbox}:${port} after 4 attempts: ${lastError}`);
|
||||
}
|
||||
|
||||
async function proveDenSeed(apiUrl: string, webUrl: string, sandbox: string, reused: boolean): Promise<void> {
|
||||
const email = process.env.OPENWORK_EVAL_DEMO_EMAIL?.trim() || "alex@acme.test";
|
||||
const password = process.env.OPENWORK_EVAL_DEMO_PASSWORD ?? "OpenWorkDemo123!";
|
||||
const url = `${apiUrl.replace(/\/+$/, "")}/api/auth/sign-in/email`;
|
||||
// A freshly-booted stack was observed answering public sign-in with bare
|
||||
// 403s for its first ~minute, then recovering on its own — so the window is
|
||||
// generous, and a failing status keeps its body so the failure names itself.
|
||||
// (That body once read MISSING_OR_NULL_ORIGIN: early boot rejects
|
||||
// origin-less POSTs, and every real client sends Origin — so must we.)
|
||||
const deadline = Date.now() + 120_000;
|
||||
let last = "not attempted";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin: webUrl },
|
||||
body: JSON.stringify({ email, password }),
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
if (response.status === 200) return;
|
||||
const body = await response.text().catch(() => "");
|
||||
last = `HTTP ${response.status} ${body.slice(0, 300)}`.trim();
|
||||
} catch (error) {
|
||||
last = messageText(error);
|
||||
}
|
||||
await delay(2_000);
|
||||
}
|
||||
if (reused) {
|
||||
throw new Error(`Den seed proof failed for reused sandbox ${sandbox}: the Den has no seeded org. Omit --reuse-den to provision a seeded Den sandbox. Last: ${last}`);
|
||||
}
|
||||
throw new Error(`Den seed proof failed for ${sandbox}: ${email} could not sign in at ${apiUrl}. Last: ${last}`);
|
||||
}
|
||||
|
||||
export async function provisionDenSandbox(options: DenSandboxOptions & ProvisionExecOptions): Promise<DenSandbox> {
|
||||
const exec = options.exec ?? defaultDaytonaExec;
|
||||
const log = options.log ?? console.error;
|
||||
const ref = assertSafeRef(options.ref);
|
||||
const reused = options.reuse?.trim() || "";
|
||||
let sandbox: string;
|
||||
let webUrl: string;
|
||||
let apiUrl: string;
|
||||
|
||||
if (reused) {
|
||||
sandbox = reused;
|
||||
// Reused sandboxes only get fresh signed aliases: their baked
|
||||
// DEN_*_PUBLIC_URL identity is unknown here, so RFC 9728 validating MCP
|
||||
// clients (opencode OAuth) cannot connect to a reused Den sandbox.
|
||||
[webUrl, apiUrl] = await timedStep(log, "Den preview URL gate", () => Promise.all([
|
||||
previewUrl(exec, sandbox, DEN_WEB_PORT),
|
||||
previewUrl(exec, sandbox, DEN_API_PORT),
|
||||
]));
|
||||
} else {
|
||||
// URLs come from the trusted runner-side URLs file the provisioning
|
||||
// script writes from daytona CLI output, never from the script's stdout:
|
||||
// the ref being provisioned controls that stream, so a spoofed
|
||||
// "DEN_API_URL=https://attacker" line would receive the demo credentials
|
||||
// the sign-in proof posts moments later. Re-deriving fresh preview URLs
|
||||
// here is not an option either — every `daytona preview-url` call signs a
|
||||
// different hostname, while the sandbox's baked DEN_*_PUBLIC_URL is the
|
||||
// Den's OAuth issuer and MCP resource identity. RFC 9728 validating MCP
|
||||
// clients (opencode) refuse a Den reached through a mismatched host.
|
||||
const urlsDir = await mkdtemp(path.join(os.tmpdir(), "openwork-den-urls-"));
|
||||
const urlsFile = path.join(urlsDir, "den-urls.env");
|
||||
try {
|
||||
const result = await timedStep(log, "Den provisioning script", () => runDenProvisionScript(
|
||||
ref,
|
||||
options.repoRoot ?? REPO_ROOT,
|
||||
options.bootstrapAdminEmail,
|
||||
log,
|
||||
urlsFile,
|
||||
));
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`Den provisioning script gate failed with exit ${result.code}. Output tail:\n${textTail(result.output)}`);
|
||||
}
|
||||
const parsedSandbox = sandboxFromServerOutput(result.output);
|
||||
if (!parsedSandbox) throw new Error(`Den provisioning script output is missing sandbox. Output tail:\n${textTail(result.output)}`);
|
||||
sandbox = parsedSandbox;
|
||||
const urls = parseDenUrlsFile(await readFile(urlsFile, "utf8").catch(() => ""));
|
||||
if (!urls) {
|
||||
throw new Error(
|
||||
`Den provisioning script did not hand back the sandbox's public URLs through ${urlsFile}. `
|
||||
+ "The baked DEN_*_PUBLIC_URL identity must be reused verbatim; check .devcontainer/test-server-on-daytona.sh.",
|
||||
);
|
||||
}
|
||||
webUrl = urls.webUrl;
|
||||
apiUrl = urls.apiUrl;
|
||||
} finally {
|
||||
await rm(urlsDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await timedStep(log, "Den seeded-org proof", () => proveDenSeed(apiUrl, webUrl, sandbox, Boolean(reused)));
|
||||
return { sandbox, apiUrl, webUrl, created: !reused };
|
||||
}
|
||||
|
||||
export async function startMockOnSandbox(options: MockOnSandboxOptions & ProvisionExecOptions): Promise<MockOnSandbox> {
|
||||
const exec = options.exec ?? defaultDaytonaExec;
|
||||
const log = options.log ?? console.error;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const port = options.port ?? 3979;
|
||||
const url = await timedStep(log, "mock preview URL gate", () => previewUrl(exec, options.sandbox, port));
|
||||
|
||||
await timedStep(log, "mock process cleanup", async () => {
|
||||
await execInSandbox(
|
||||
exec,
|
||||
options.sandbox,
|
||||
"pkill -f mock-oauth-mcp-server || true",
|
||||
{ timeoutMs: 30_000, context: `mock process cleanup for ${options.sandbox}` },
|
||||
).catch(() => undefined);
|
||||
});
|
||||
|
||||
await timedStep(log, "mock process detach", async () => {
|
||||
const unauthenticatedMcpEnv = options.allowUnauthenticatedMcp ? " MOCK_ALLOW_UNAUTHENTICATED_MCP=1" : "";
|
||||
const detachScript = `cd /workspace; python3 - <<PYEOF
|
||||
import subprocess
|
||||
log = open("/tmp/mock-mcp.log", "ab", buffering=0)
|
||||
subprocess.Popen(["bash", "-lc", "cd /workspace && env HOST=0.0.0.0 PORT=${port} ISSUER=${url} AUTO_APPROVE=1${unauthenticatedMcpEnv} node scripts/mock-oauth-mcp-server.mjs"], stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True)
|
||||
PYEOF
|
||||
echo detached`;
|
||||
await execInSandbox(exec, options.sandbox, detachScript, { timeoutMs: 30_000, context: `mock process detach for ${options.sandbox}` });
|
||||
});
|
||||
|
||||
await timedStep(log, "mock health gate", async () => {
|
||||
const deadline = Date.now() + 60_000;
|
||||
let last = "not attempted";
|
||||
while (Date.now() < deadline) {
|
||||
let body: unknown = null;
|
||||
let responseOk = false;
|
||||
try {
|
||||
const response = await fetchImpl(`${url}/health`, { signal: AbortSignal.timeout(5_000) });
|
||||
body = await response.json();
|
||||
responseOk = response.ok;
|
||||
if (!response.ok) last = `HTTP ${response.status}`;
|
||||
} catch (error) {
|
||||
last = messageText(error);
|
||||
}
|
||||
if (responseOk && isRecord(body) && body.ok === true) {
|
||||
const issuer = typeof body.issuer === "string" ? body.issuer : JSON.stringify(body.issuer);
|
||||
if (issuer !== url) throw new Error(`Mock issuer gate failed: health reported ${issuer}, expected ${url}.`);
|
||||
return;
|
||||
}
|
||||
await delay(2_000);
|
||||
}
|
||||
const mockLog = await execInSandbox(
|
||||
exec,
|
||||
options.sandbox,
|
||||
"tail -80 /tmp/mock-mcp.log 2>&1 || true",
|
||||
{ timeoutMs: 30_000, context: `mock health log for ${options.sandbox}` },
|
||||
);
|
||||
throw new Error(`Mock health gate failed at ${url}. Last: ${last}. Log tail:\n${outputTail(mockLog)}`);
|
||||
});
|
||||
|
||||
return { url };
|
||||
}
|
||||
|
||||
export async function startFaultProxyOnSandbox(options: FaultProxyOnSandboxOptions & ProvisionExecOptions): Promise<FaultProxyOnSandbox> {
|
||||
const exec = options.exec ?? defaultDaytonaExec;
|
||||
const log = options.log ?? console.error;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const port = options.port ?? 3985;
|
||||
const upstreamPort = options.upstreamPort ?? DEN_WEB_PORT;
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const url = await timedStep(log, "fault proxy preview URL gate", () => previewUrl(exec, options.sandbox, port));
|
||||
|
||||
await timedStep(log, "fault proxy process cleanup", async () => {
|
||||
await execInSandbox(
|
||||
exec,
|
||||
options.sandbox,
|
||||
"pkill -f openwork-fault-proxy || true",
|
||||
{ timeoutMs: 30_000, context: `fault proxy process cleanup for ${options.sandbox}` },
|
||||
).catch(() => undefined);
|
||||
});
|
||||
|
||||
await timedStep(log, "fault proxy script upload", async () => {
|
||||
const encoded = Buffer.from(FAULT_PROXY_SCRIPT).toString("base64");
|
||||
await execInSandbox(
|
||||
exec,
|
||||
options.sandbox,
|
||||
`printf %s ${encoded} | base64 -d > /tmp/openwork-fault-proxy.mjs`,
|
||||
{ timeoutMs: 30_000, context: `fault proxy script upload for ${options.sandbox}` },
|
||||
);
|
||||
});
|
||||
|
||||
await timedStep(log, "fault proxy process detach", async () => {
|
||||
const detachScript = `python3 - <<PYEOF
|
||||
import subprocess
|
||||
log = open("/tmp/openwork-fault-proxy.log", "ab", buffering=0)
|
||||
subprocess.Popen(["bash", "-lc", "env PORT=${port} UPSTREAM=http://127.0.0.1:${upstreamPort} ISSUER=${url} CONTROL_TOKEN=${token} node /tmp/openwork-fault-proxy.mjs"], stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, close_fds=True)
|
||||
PYEOF
|
||||
echo detached`;
|
||||
await execInSandbox(exec, options.sandbox, detachScript, { timeoutMs: 30_000, context: `fault proxy process detach for ${options.sandbox}` });
|
||||
});
|
||||
|
||||
await timedStep(log, "fault proxy health gate", async () => {
|
||||
const deadline = Date.now() + 60_000;
|
||||
let last = "not attempted";
|
||||
while (Date.now() < deadline) {
|
||||
let body: unknown = null;
|
||||
let responseOk = false;
|
||||
try {
|
||||
const response = await fetchImpl(`${url}/__openwork_faults/health`, { signal: AbortSignal.timeout(5_000) });
|
||||
body = await response.json();
|
||||
responseOk = response.ok;
|
||||
if (!response.ok) last = `HTTP ${response.status}`;
|
||||
} catch (error) {
|
||||
last = messageText(error);
|
||||
}
|
||||
if (responseOk && isRecord(body) && body.ok === true) {
|
||||
const reportedIssuer = typeof body.issuer === "string" ? body.issuer : JSON.stringify(body.issuer);
|
||||
if (reportedIssuer !== url) throw new Error(`Fault proxy issuer gate failed: health reported ${reportedIssuer}, expected ${url}.`);
|
||||
return;
|
||||
}
|
||||
await delay(2_000);
|
||||
}
|
||||
const proxyLog = await execInSandbox(
|
||||
exec,
|
||||
options.sandbox,
|
||||
"tail -80 /tmp/openwork-fault-proxy.log 2>&1 || true",
|
||||
{ timeoutMs: 30_000, context: `fault proxy health log for ${options.sandbox}` },
|
||||
);
|
||||
throw new Error(`Fault proxy health gate failed at ${url}. Last: ${last}. Log tail:\n${outputTail(proxyLog)}`);
|
||||
});
|
||||
|
||||
return {
|
||||
url,
|
||||
token,
|
||||
async stop(): Promise<void> {
|
||||
await execInSandbox(
|
||||
exec,
|
||||
options.sandbox,
|
||||
"pkill -f openwork-fault-proxy.mjs || true",
|
||||
{ timeoutMs: 30_000, context: `fault proxy stop for ${options.sandbox}` },
|
||||
).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deletionOutput(result: DaytonaExecResult): string {
|
||||
return `${result.stderr}\n${result.stdout}`.trim();
|
||||
}
|
||||
|
||||
function deletionNotFound(text: string): boolean {
|
||||
return /not found|does not exist|no sandbox/i.test(text);
|
||||
}
|
||||
|
||||
export async function deleteSandboxes(
|
||||
ids: string[],
|
||||
options: ProvisionExecOptions & { log?: (line: string) => void } = {},
|
||||
): Promise<void> {
|
||||
const exec = options.exec ?? defaultDaytonaExec;
|
||||
const log = options.log ?? console.error;
|
||||
for (const id of ids) {
|
||||
log(`==> deleting sandbox ${id}...`);
|
||||
// The CLI has no --yes; answering its confirmation prompt on stdin works,
|
||||
// and a promptless future CLI would simply ignore the input.
|
||||
const result = await exec(["delete", id], { timeoutMs: 60_000, input: "y\n" });
|
||||
const output = deletionOutput(result);
|
||||
if (result.code !== 0 && deletionNotFound(output)) {
|
||||
log(`==> sandbox ${id} not found; continuing`);
|
||||
continue;
|
||||
}
|
||||
if (result.code !== 0) throw new Error(`Sandbox deletion gate failed for ${id} with exit ${result.code}. Output tail: ${textTail(output)}`);
|
||||
log(`==> deleted sandbox ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const ENV_HEADER_PREFIX = "# provisioned for org-connector-two-members";
|
||||
const ENV_REF_MARKER = "; ref=";
|
||||
const ENV_CREATED_PREFIX = "# provision-created=";
|
||||
|
||||
/** First line starting with prefix, minus the prefix. Linear, no backtracking. */
|
||||
function commentLine(content: string, prefix: string): string | null {
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
if (line.startsWith(prefix)) return line.slice(prefix.length);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** POSIX single-quoting: the generated file is `source`d, so an unquoted
|
||||
* value like `$(cmd)` would execute on the operator's machine. */
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) {
|
||||
return value.slice(1, -1).replaceAll(`'"'"'`, "'");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function renderConnectorE2eTestEnv(facts: ConnectorE2eTestEnv): string {
|
||||
assertSafeRef(facts.ref);
|
||||
return [
|
||||
`${ENV_HEADER_PREFIX} — generated ${new Date().toISOString()}${ENV_REF_MARKER}${facts.ref}`,
|
||||
`${ENV_CREATED_PREFIX}${facts.created.join(",")}`,
|
||||
"OPENWORK_EVAL_E2E_TESTS=1",
|
||||
"OPENWORK_EVAL_CONNECTOR_E2E_TEST=1",
|
||||
`OPENWORK_EVAL_DEN_API_URL=${shellQuote(facts.denApiUrl)}`,
|
||||
`OPENWORK_EVAL_DEN_WEB_URL=${shellQuote(facts.denWebUrl)}`,
|
||||
`OPENWORK_EVAL_DAYTONA_SANDBOX_A=${shellQuote(facts.sandboxA)}`,
|
||||
`OPENWORK_EVAL_DAYTONA_SANDBOX_B=${shellQuote(facts.sandboxB)}`,
|
||||
`OPENWORK_EVAL_CONNECTOR_MOCK_PUBLIC_URL=${shellQuote(facts.mockUrl)}`,
|
||||
"OPENWORK_EVAL_MODEL=big-pickle",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function parseConnectorE2eTestEnv(content: string): ConnectorE2eTestEnv {
|
||||
const values = new Map<string, string>();
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const separator = line.indexOf("=");
|
||||
if (separator > 0) values.set(line.slice(0, separator), unquote(line.slice(separator + 1)));
|
||||
}
|
||||
function required(name: string): string {
|
||||
const value = values.get(name);
|
||||
if (value === undefined || value.length === 0) throw new Error(`Missing ${name} in connector spec env.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
required("OPENWORK_EVAL_E2E_TESTS");
|
||||
required("OPENWORK_EVAL_CONNECTOR_E2E_TEST");
|
||||
required("OPENWORK_EVAL_MODEL");
|
||||
// Header comments are read by line scan, not regex: `.*` before a literal
|
||||
// backtracks polynomially on adversarial input (CodeQL js/polynomial-redos).
|
||||
const header = commentLine(content, ENV_HEADER_PREFIX);
|
||||
const refAt = header ? header.lastIndexOf(ENV_REF_MARKER) : -1;
|
||||
if (refAt < 0) throw new Error("Missing ref in connector spec env header.");
|
||||
const ref = header?.slice(refAt + ENV_REF_MARKER.length) ?? "";
|
||||
if (!ref) throw new Error("Missing ref in connector spec env header.");
|
||||
const createdText = commentLine(content, ENV_CREATED_PREFIX);
|
||||
if (createdText === null) throw new Error("Missing provision-created in connector spec env header.");
|
||||
|
||||
return {
|
||||
denApiUrl: required("OPENWORK_EVAL_DEN_API_URL"),
|
||||
denWebUrl: required("OPENWORK_EVAL_DEN_WEB_URL"),
|
||||
sandboxA: required("OPENWORK_EVAL_DAYTONA_SANDBOX_A"),
|
||||
sandboxB: required("OPENWORK_EVAL_DAYTONA_SANDBOX_B"),
|
||||
mockUrl: required("OPENWORK_EVAL_CONNECTOR_MOCK_PUBLIC_URL"),
|
||||
ref,
|
||||
created: createdText.split(",").map((id) => id.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createDaytonaHost } from "./daytona.ts";
|
||||
import { createLocalHost } from "./local.ts";
|
||||
import type { DisposableHost, Host } from "./types.ts";
|
||||
|
||||
const REPO_ROOT = fileURLToPath(new URL("../../../..", import.meta.url));
|
||||
|
||||
/** True when this process is itself running inside a Daytona sandbox. */
|
||||
export function runningInsideSandbox(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if ((env.DAYTONA_SANDBOX_ID ?? "").trim().length > 0) return true;
|
||||
return existsSync("/daytona-secrets") || existsSync("/daytona-artifacts");
|
||||
}
|
||||
|
||||
export async function resolveHost(env: NodeJS.ProcessEnv = process.env): Promise<Host & AsyncDisposable> {
|
||||
const sandboxId = env.OPENWORK_EVAL_DAYTONA_SANDBOX?.trim();
|
||||
// The Daytona host drives the `daytona` CLI from OUTSIDE a sandbox. When the
|
||||
// caller is already inside one, that indirection cannot work — spawn locally,
|
||||
// which is the same machine the sandbox host would have targeted anyway.
|
||||
if (sandboxId && !runningInsideSandbox(env)) {
|
||||
return createDaytonaHost({ sandboxId, repoRoot: REPO_ROOT, log: () => undefined });
|
||||
}
|
||||
return createLocalHost({ repoRoot: REPO_ROOT, log: () => undefined });
|
||||
}
|
||||
|
||||
/**
|
||||
* PLACEMENT, stated rather than inferred.
|
||||
*
|
||||
* `resolveHost()` reads one ambient env var, so a whole process gets one host:
|
||||
* everything local, or everything in one sandbox. A spec that needs the app and
|
||||
* the browser in different places — or two desktops in two sandboxes — cannot
|
||||
* say so. These factories name a placement, and `desktop({ host })` /
|
||||
* `chrome({ host })` take it, so a spec declares its topology instead of
|
||||
* inheriting it from the environment.
|
||||
*/
|
||||
export function localHost(): DisposableHost {
|
||||
return createLocalHost({ repoRoot: REPO_ROOT, log: () => undefined });
|
||||
}
|
||||
|
||||
/** A named Daytona sandbox, driven through the `daytona` CLI from outside it. */
|
||||
export function daytonaSandbox(sandboxId: string): DisposableHost {
|
||||
const id = sandboxId.trim();
|
||||
if (!id) throw new Error("daytonaSandbox(sandboxId) requires a sandbox id.");
|
||||
if (runningInsideSandbox()) {
|
||||
throw new Error(
|
||||
`daytonaSandbox(${JSON.stringify(id)}) cannot be used from inside a sandbox: the daytona CLI indirection has nothing to target. Use localHost() there.`,
|
||||
);
|
||||
}
|
||||
return createDaytonaHost({ sandboxId: id, repoRoot: REPO_ROOT, log: () => undefined });
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { SurfaceHandle, SurfaceKind } from "@openwork/cdp";
|
||||
|
||||
export type { SurfaceHandle, SurfaceKind } from "@openwork/cdp";
|
||||
|
||||
export interface ElectronSurfaceOptions {
|
||||
profile?: "fresh" | "shared";
|
||||
/** Exact caller-owned profile root. Hosts preserve it on surface disposal. */
|
||||
profileDir?: string;
|
||||
bootstrap?: {
|
||||
baseUrl: string;
|
||||
apiBaseUrl?: string;
|
||||
requireSignin?: boolean;
|
||||
};
|
||||
env?: Record<string, string>;
|
||||
/** Root package script used for a source Electron launch. Setting this bypasses OPENWORK_EVAL_ELECTRON_BINARY. */
|
||||
devCommand?: "dev" | "dev:electron";
|
||||
/** Skip host-side sidecar/helper preparation when the caller intentionally uses existing resources. */
|
||||
prepareSharedResources?: boolean;
|
||||
}
|
||||
|
||||
export interface ChromeSurfaceOptions {
|
||||
profile?: "fresh" | "shared";
|
||||
startUrl?: string;
|
||||
headless?: boolean;
|
||||
}
|
||||
|
||||
export interface DenServiceOptions {
|
||||
orgMode?: "single_org" | "multi_org";
|
||||
seed?: "acme" | "none";
|
||||
}
|
||||
|
||||
export interface DenServiceHandle {
|
||||
webUrl: string;
|
||||
apiUrl: string;
|
||||
orgMode: "single_org" | "multi_org";
|
||||
hostKind: string;
|
||||
}
|
||||
|
||||
export type ShareLinks = { label: string; url: string }[];
|
||||
|
||||
export interface Host {
|
||||
kind: string;
|
||||
/**
|
||||
* The repo/workspace root ON THIS HOST.
|
||||
*
|
||||
* A spec that passes `process.cwd()` as a workspace path is only correct when
|
||||
* the driver and the app share a filesystem. Drive a sandbox from a laptop and
|
||||
* the app is asked to open a directory that does not exist there — observed as
|
||||
* onboarding hanging on "Power your first task" with no error. Ask the host.
|
||||
*/
|
||||
workspaceRoot: string;
|
||||
previewUrl?(port: number): Promise<string>;
|
||||
spawnElectron(name: string, opts?: ElectronSurfaceOptions): Promise<SurfaceHandle>;
|
||||
spawnChrome(name: string, opts?: ChromeSurfaceOptions): Promise<SurfaceHandle>;
|
||||
startDen?(opts?: DenServiceOptions): Promise<DenServiceHandle>;
|
||||
share?(): Promise<ShareLinks>;
|
||||
disposeSurface(handle: SurfaceHandle): Promise<void>;
|
||||
}
|
||||
|
||||
export type DisposableHost = Host & AsyncDisposable & { stop(): Promise<void> };
|
||||
@@ -0,0 +1,490 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
checkedExec,
|
||||
createDaytonaHost,
|
||||
defaultDaytonaExec,
|
||||
enterpriseTlsEdgeDaytonaCommands,
|
||||
MAX_ENTERPRISE_TLS_DAYTONA_COMMAND_LENGTH,
|
||||
} from "../src/daytona.ts";
|
||||
import type { DaytonaExec } from "../src/daytona.ts";
|
||||
import type { SurfaceHandle } from "../src/types.ts";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
interface ExecCall {
|
||||
args: string[];
|
||||
opts?: { input?: string; timeoutMs?: number };
|
||||
}
|
||||
|
||||
function portFromServer(server: Server): number {
|
||||
const address = server.address();
|
||||
if (typeof address === "object" && address !== null) return address.port;
|
||||
throw new Error("Test server did not expose a TCP port.");
|
||||
}
|
||||
|
||||
function closeServer(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server: Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve(portFromServer(server)));
|
||||
});
|
||||
}
|
||||
|
||||
async function startRuntimeConfigStub(orgMode: "single_org" | "multi_org"): Promise<{ url: string; close(): Promise<void> }> {
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === "/api/runtime-config") {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ orgMode }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404);
|
||||
response.end("not found");
|
||||
});
|
||||
const port = await listen(server);
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => closeServer(server),
|
||||
};
|
||||
}
|
||||
|
||||
function argsText(call: ExecCall): string {
|
||||
return call.args.join(" ");
|
||||
}
|
||||
|
||||
function findCall(calls: ExecCall[], text: string): ExecCall {
|
||||
const call = calls.find((entry) => argsText(entry).includes(text));
|
||||
assert(call, `Expected exec call containing ${text}`);
|
||||
return call;
|
||||
}
|
||||
|
||||
function createFakeExec(previewUrlForPort: (port: string) => string): { exec: DaytonaExec; calls: ExecCall[] } {
|
||||
const calls: ExecCall[] = [];
|
||||
const exec: DaytonaExec = async (args, opts) => {
|
||||
calls.push({ args: [...args], opts });
|
||||
if (args[0] === "preview-url") {
|
||||
const portFlag = args.indexOf("-p");
|
||||
const port = portFlag >= 0 ? args[portFlag + 1] ?? "" : "";
|
||||
return { stdout: `time=ignored\nPreview URL: ${previewUrlForPort(port)}\n`, stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
return { exec, calls };
|
||||
}
|
||||
|
||||
function successfulPolls(seen: string[] = []): (url: string) => Promise<void> {
|
||||
return async (url) => {
|
||||
seen.push(url);
|
||||
};
|
||||
}
|
||||
|
||||
function base64AfterEcho(call: ExecCall): string {
|
||||
const echoIndex = call.args.indexOf("echo");
|
||||
assert(echoIndex >= 0, "Expected echo in base64 write call.");
|
||||
return call.args[echoIndex + 1] ?? "";
|
||||
}
|
||||
|
||||
test("checkedExec retries when the Daytona CLI fails at the transport layer", async () => {
|
||||
let callCount = 0;
|
||||
const exec: DaytonaExec = async () => {
|
||||
callCount += 1;
|
||||
if (callCount < 3) {
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: `time="2026-08-18T19:35:11Z" level=fatal msg="invalid character '<' looking for beginning of value"\n`,
|
||||
code: 1,
|
||||
};
|
||||
}
|
||||
return { stdout: "done\n", stderr: "", code: 0 };
|
||||
};
|
||||
|
||||
const result = await checkedExec(exec, ["exec"], "write Daytona Electron bootstrap", { retryDelayMs: 1 });
|
||||
assert.equal(result.stdout, "done\n");
|
||||
assert.equal(callCount, 3);
|
||||
});
|
||||
|
||||
test("checkedExec does not retry remote command failures", async () => {
|
||||
let callCount = 0;
|
||||
const exec: DaytonaExec = async () => {
|
||||
callCount += 1;
|
||||
return { stdout: "", stderr: "rm: cannot remove '/tmp/gone'\n", code: 1 };
|
||||
};
|
||||
|
||||
await assert.rejects(checkedExec(exec, ["exec"], "cleanup profile", { retryDelayMs: 1 }));
|
||||
assert.equal(callCount, 1);
|
||||
});
|
||||
|
||||
test("checkedExec reports stderr and stdout from a failed Daytona command", async () => {
|
||||
const exec: DaytonaExec = async () => ({
|
||||
stdout: "remote process log\n",
|
||||
stderr: "Daytona version warning\n",
|
||||
code: 1,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
checkedExec(exec, ["exec"], "Start enterprise TLS edge"),
|
||||
(error: Error) => {
|
||||
assert.equal(
|
||||
error.message,
|
||||
"Start enterprise TLS edge failed with exit 1:\nstderr:\nDaytona version warning\n\nstdout:\nremote process log",
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("defaultDaytonaExec handles stdin EPIPE when the child exits", async () => {
|
||||
const result = await defaultDaytonaExec(
|
||||
["-e", "process.stderr.write('remote exited\\n'); process.exit(23)"],
|
||||
{ input: "x".repeat(8 * 1024 * 1024) },
|
||||
process.execPath,
|
||||
);
|
||||
|
||||
assert.equal(result.code, 23);
|
||||
assert.equal(result.stderr, "remote exited\n");
|
||||
});
|
||||
|
||||
test("Daytona previewUrl parses the first https URL and caches by port", async () => {
|
||||
const { exec, calls } = createFakeExec(() => "https://9825-preview.example.test/json/list");
|
||||
const host = createDaytonaHost({ sandboxId: "openwork-test-1", log: () => undefined, exec, repoRoot: "/repo" });
|
||||
|
||||
assert.equal(await host.previewUrl(9825), "https://9825-preview.example.test/json/list");
|
||||
assert.equal(await host.previewUrl(9825), "https://9825-preview.example.test/json/list");
|
||||
|
||||
assert.equal(calls.filter((call) => call.args[0] === "preview-url").length, 1);
|
||||
assert.deepEqual(calls[0]?.args, ["preview-url", "openwork-test-1", "-p", "9825"]);
|
||||
});
|
||||
|
||||
test("spawnElectron starts isolated Daytona Electron profiles and writes bootstrap over base64", async () => {
|
||||
const polled: string[] = [];
|
||||
const { exec, calls } = createFakeExec((port) => `https://cdp-${port}.example.test`);
|
||||
const host = createDaytonaHost({
|
||||
sandboxId: "openwork-test-electron",
|
||||
log: () => undefined,
|
||||
exec,
|
||||
repoRoot: "/repo",
|
||||
waitForCdp: successfulPolls(polled),
|
||||
});
|
||||
const bootstrap = { baseUrl: "https://den-web.example.test", apiBaseUrl: "https://den-api.example.test", requireSignin: true };
|
||||
|
||||
const first = await host.spawnElectron("owner", { bootstrap });
|
||||
const second = await host.spawnElectron("member");
|
||||
|
||||
assert.equal(first.meta?.cdpPort, "9825");
|
||||
assert.equal(second.meta?.cdpPort, "9830");
|
||||
assert.match(first.profileDir ?? "", /\/workspace\/\.openwork-daytona\/profiles\/owner-\d{17}$/);
|
||||
assert.match(second.profileDir ?? "", /\/workspace\/\.openwork-daytona\/profiles\/member-\d{17}$/);
|
||||
assert.equal(first.meta?.profileOwner, "host");
|
||||
assert.equal(second.meta?.profileOwner, "host");
|
||||
assert.deepEqual(polled, ["https://cdp-9825.example.test/json/list", "https://cdp-9830.example.test/json/list"]);
|
||||
|
||||
const bootstrapCall = findCall(calls, "base64 -d");
|
||||
assert.equal(Buffer.from(base64AfterEcho(bootstrapCall), "base64").toString("utf8"), `${JSON.stringify(bootstrap, null, 2)}\n`);
|
||||
assert(argsText(bootstrapCall).includes("/workspace/.openwork-daytona/profiles/owner-"));
|
||||
assert(argsText(bootstrapCall).includes("/bootstrap.json"));
|
||||
|
||||
const startCalls = calls.filter((call) => argsText(call).includes("/workspace/.devcontainer/start-daytona-electron.sh"));
|
||||
assert.equal(startCalls.length, 2);
|
||||
const firstStart = argsText(startCalls[0]);
|
||||
const secondStart = argsText(startCalls[1]);
|
||||
assert(firstStart.includes("openwork-test-electron"));
|
||||
assert(firstStart.includes("OPENWORK_ELECTRON_REMOTE_DEBUG_PORT="));
|
||||
assert(firstStart.includes("9825"));
|
||||
assert(firstStart.includes("OPENWORK_ELECTRON_USERDATA="));
|
||||
assert(firstStart.includes("/workspace/.openwork-daytona/profiles/owner-"));
|
||||
assert(firstStart.includes("/electron-userdata"));
|
||||
assert(firstStart.includes("OPENWORK_DESKTOP_BOOTSTRAP_PATH="));
|
||||
assert(firstStart.includes("/workspace/.openwork-daytona/profiles/owner-"));
|
||||
assert(firstStart.includes("/bootstrap.json"));
|
||||
assert(firstStart.includes("DAYTONA_ELECTRON_LOG="));
|
||||
assert(/\/tmp\/electron-owner-\d+/.test(firstStart));
|
||||
assert(firstStart.includes("--detach"));
|
||||
assert(secondStart.includes("OPENWORK_ELECTRON_REMOTE_DEBUG_PORT="));
|
||||
assert(secondStart.includes("9830"));
|
||||
assert(secondStart.includes("OPENWORK_ELECTRON_USERDATA="));
|
||||
assert(secondStart.includes("/workspace/.openwork-daytona/profiles/member-"));
|
||||
assert(secondStart.includes("/electron-userdata"));
|
||||
});
|
||||
|
||||
test("spawnElectron preserves a caller-owned Daytona profile while generated profiles are removed", async () => {
|
||||
const { exec, calls } = createFakeExec((port) => `https://cdp-${port}.example.test`);
|
||||
const host = createDaytonaHost({
|
||||
sandboxId: "openwork-test-profile-owner",
|
||||
log: () => undefined,
|
||||
exec,
|
||||
repoRoot: "/repo",
|
||||
waitForCdp: successfulPolls(),
|
||||
});
|
||||
const suppliedProfile = "/tmp/reliable-recovery-profile";
|
||||
|
||||
const callerOwned = await host.spawnElectron("caller-owned", { profileDir: suppliedProfile });
|
||||
const generated = await host.spawnElectron("generated");
|
||||
|
||||
assert.equal(callerOwned.profileDir, suppliedProfile);
|
||||
assert.equal(callerOwned.meta?.profileOwner, "caller");
|
||||
assert.equal(generated.meta?.profileOwner, "host");
|
||||
const startCalls = calls.filter((call) => argsText(call).includes("/workspace/.devcontainer/start-daytona-electron.sh"));
|
||||
assert(argsText(startCalls[0] ?? { args: [] }).includes(`${suppliedProfile}/electron-userdata`));
|
||||
|
||||
await host.disposeSurface(callerOwned);
|
||||
await host.disposeSurface(generated);
|
||||
|
||||
const removalCalls = calls.filter((call) => argsText(call).includes("rm -rf"));
|
||||
assert.equal(removalCalls.length, 1);
|
||||
assert(argsText(removalCalls[0] ?? { args: [] }).includes(generated.profileDir ?? "missing-generated-profile"));
|
||||
assert(!removalCalls.some((call) => argsText(call).includes(suppliedProfile)));
|
||||
});
|
||||
|
||||
test("spawnElectron skips reserved Daytona CDP ports", async () => {
|
||||
const { exec } = createFakeExec((port) => `https://cdp-${port}.example.test`);
|
||||
const host = createDaytonaHost({
|
||||
sandboxId: "openwork-test-electron-reserved",
|
||||
log: () => undefined,
|
||||
exec,
|
||||
repoRoot: "/repo",
|
||||
reservedElectronPorts: [9825],
|
||||
waitForCdp: successfulPolls(),
|
||||
});
|
||||
|
||||
const handle = await host.spawnElectron("fresh");
|
||||
|
||||
assert.equal(handle.meta?.cdpPort, "9830");
|
||||
});
|
||||
|
||||
test("spawnChrome launches Chromium with Daytona CDP flags and allocates a second port", async () => {
|
||||
const polled: string[] = [];
|
||||
const { exec, calls } = createFakeExec((port) => `https://chrome-${port}.example.test`);
|
||||
const host = createDaytonaHost({
|
||||
sandboxId: "openwork-test-chrome",
|
||||
log: () => undefined,
|
||||
exec,
|
||||
repoRoot: "/repo",
|
||||
waitForCdp: successfulPolls(polled),
|
||||
});
|
||||
|
||||
const first = await host.spawnChrome("browser");
|
||||
const second = await host.spawnChrome("oauth", { startUrl: "https://app.example.test" });
|
||||
|
||||
assert.equal(first.meta?.cdpPort, "9222");
|
||||
assert.equal(second.meta?.cdpPort, "9230");
|
||||
assert.deepEqual(polled, ["https://chrome-9222.example.test/json/version", "https://chrome-9230.example.test/json/version"]);
|
||||
|
||||
const launchCalls = calls.filter((call) => argsText(call).includes("nohup \"$CHROME_BIN\""));
|
||||
assert.equal(launchCalls.length, 2);
|
||||
const firstLaunch = argsText(launchCalls[0]);
|
||||
const secondLaunch = argsText(launchCalls[1]);
|
||||
assert(firstLaunch.includes("command -v chromium || command -v google-chrome || command -v google-chrome-stable"));
|
||||
assert(firstLaunch.includes("--headless=new"));
|
||||
assert(firstLaunch.includes("--window-size=1280,900"));
|
||||
assert(firstLaunch.includes("--no-sandbox"));
|
||||
assert(firstLaunch.includes("--disable-dev-shm-usage"));
|
||||
assert(firstLaunch.includes("--use-gl=swiftshader"));
|
||||
assert(firstLaunch.includes("--remote-debugging-address=0.0.0.0"));
|
||||
assert(firstLaunch.includes("--remote-debugging-port=9222"));
|
||||
assert(firstLaunch.includes("--user-data-dir="));
|
||||
assert(firstLaunch.includes("/tmp/daytona-chrome-browser"));
|
||||
assert(secondLaunch.includes("--remote-debugging-port=9230"));
|
||||
assert(/https:\/\/app\.example\.test(["'\s]|$)/.test(secondLaunch));
|
||||
});
|
||||
|
||||
test("disposeSurface uses self-match-safe pkill patterns in separate execs", async () => {
|
||||
const { exec, calls } = createFakeExec(() => "https://unused.example.test");
|
||||
const host = createDaytonaHost({ sandboxId: "openwork-test-dispose", log: () => undefined, exec, repoRoot: "/repo" });
|
||||
const electronHandle: SurfaceHandle = {
|
||||
name: "desktop",
|
||||
kind: "electron",
|
||||
hostKind: "daytona",
|
||||
cdpUrl: "https://unused.example.test",
|
||||
sandboxId: "openwork-test-dispose",
|
||||
profileDir: "/workspace/.openwork-daytona/profiles/desktop",
|
||||
meta: { cdpPort: "9825", log: "/tmp/electron-desktop.log" },
|
||||
};
|
||||
const chromeHandle: SurfaceHandle = {
|
||||
name: "browser",
|
||||
kind: "chrome",
|
||||
hostKind: "daytona",
|
||||
cdpUrl: "https://unused.example.test",
|
||||
sandboxId: "openwork-test-dispose",
|
||||
profileDir: "/tmp/daytona-chrome-browser",
|
||||
meta: { cdpPort: "9222", log: "/tmp/daytona-chrome-browser.log" },
|
||||
};
|
||||
|
||||
await host.disposeSurface(electronHandle);
|
||||
await host.disposeSurface(chromeHandle);
|
||||
|
||||
const electronPkillIndex = calls.findIndex((call) => argsText(call).includes("pkill -f") && argsText(call).includes("remote-debugging-port"));
|
||||
const electronVerifyIndex = calls.findIndex((call) => argsText(call).includes("/json/list"));
|
||||
assert(electronPkillIndex >= 0);
|
||||
assert.equal(electronVerifyIndex, electronPkillIndex + 1);
|
||||
const electronPkill = argsText(calls[electronPkillIndex]);
|
||||
assert(electronPkill.includes("[e]lectron.*remote-debugging-port=[9]825"));
|
||||
assert(!electronPkill.includes("remote-debugging-port=9825"));
|
||||
|
||||
const chromePkill = argsText(findCall(calls, "[/]tmp/daytona-chrome-browser"));
|
||||
assert(chromePkill.includes("(chromium|chrome).*--user-data-dir=[/]tmp/daytona-chrome-browser"));
|
||||
assert(!chromePkill.includes("--user-data-dir=/tmp/daytona-chrome-browser"));
|
||||
});
|
||||
|
||||
test("Daytona host requires a sandbox option or OPENWORK_EVAL_DAYTONA_SANDBOX", async () => {
|
||||
const previous = process.env.OPENWORK_EVAL_DAYTONA_SANDBOX;
|
||||
delete process.env.OPENWORK_EVAL_DAYTONA_SANDBOX;
|
||||
const { exec } = createFakeExec(() => "https://unused.example.test");
|
||||
const host = createDaytonaHost({ log: () => undefined, exec, repoRoot: "/repo" });
|
||||
|
||||
try {
|
||||
await assert.rejects(host.previewUrl(9825), /create one with bash \.devcontainer\/test-on-daytona\.sh <ref> or pass sandboxId/);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPENWORK_EVAL_DAYTONA_SANDBOX;
|
||||
else process.env.OPENWORK_EVAL_DAYTONA_SANDBOX = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test("enterprise TLS edge commands keep the full lifecycle in one Daytona sandbox", () => {
|
||||
const commands = enterpriseTlsEdgeDaytonaCommands({
|
||||
sandboxId: "desktop-sandbox",
|
||||
upstream: "https://den.example.test",
|
||||
});
|
||||
|
||||
assert.equal(commands.candidateUrl, "https://localhost:8443");
|
||||
assert.equal(commands.negativeUrl, "https://localhost:9443");
|
||||
assert.equal(commands.adminUrl, "http://127.0.0.1:8445");
|
||||
for (const command of [commands.start, commands.probe, commands.requests, commands.installRoot, commands.removeRoot, commands.stop]) {
|
||||
assert.deepEqual(command.slice(0, 3), ["exec", "desktop-sandbox", "--"]);
|
||||
}
|
||||
const runtimeRoot = "/tmp/openwork-enterprise-tls-runtime";
|
||||
const localSources = [
|
||||
fileURLToPath(new URL("../../../scripts/enterprise-tls-edge.mts", import.meta.url)),
|
||||
fileURLToPath(new URL("../../labs/src/egress.ts", import.meta.url)),
|
||||
];
|
||||
assert.ok(commands.prepare.length > localSources.length * 2);
|
||||
const prepare = commands.prepare.map((command) => command.join(" "));
|
||||
assert.ok(prepare[0]?.includes(`/usr/bin/rm -rf ${runtimeRoot}`));
|
||||
assert.ok(prepare[0]?.includes("/usr/bin/mkdir -p"));
|
||||
assert.ok(prepare[0]?.includes("enterprise-tls-edge.mts.b64"));
|
||||
assert.ok(prepare[0]?.includes("egress.ts.b64"));
|
||||
assert.ok(commands.prepare.every((command) => command.slice(0, 3).join(" ") === "exec desktop-sandbox --"));
|
||||
assert.ok(commands.prepare.every((command) => !command.includes("bash -s")));
|
||||
assert.ok(commands.prepare.every((command) => !command.includes("sudo -n")));
|
||||
const commandLengths = [
|
||||
...commands.prepare,
|
||||
commands.start,
|
||||
commands.probe,
|
||||
commands.requests,
|
||||
commands.installRoot,
|
||||
commands.removeRoot,
|
||||
commands.stop,
|
||||
].map((command) => command.join(" ").length);
|
||||
assert.ok(Math.max(...commandLengths) <= MAX_ENTERPRISE_TLS_DAYTONA_COMMAND_LENGTH);
|
||||
const chunkPattern = /\/usr\/bin\/printf %s ([A-Za-z0-9+/=]+) >> (\S+\.b64)/;
|
||||
const chunksByRemote = new Map<string, string[]>();
|
||||
for (const command of prepare) {
|
||||
const match = chunkPattern.exec(command);
|
||||
if (!match) continue;
|
||||
assert.ok((match[1]?.length ?? 0) <= 8 * 1024);
|
||||
const chunks = chunksByRemote.get(match[2] ?? "") ?? [];
|
||||
chunks.push(match[1] ?? "");
|
||||
chunksByRemote.set(match[2] ?? "", chunks);
|
||||
}
|
||||
for (const [index, source] of localSources.entries()) {
|
||||
const content = readFileSync(source);
|
||||
const remote = index === 0
|
||||
? `${runtimeRoot}/evals/scripts/enterprise-tls-edge.mts`
|
||||
: `${runtimeRoot}/evals/packages/labs/src/egress.ts`;
|
||||
assert.equal(chunksByRemote.get(`${remote}.b64`)?.join(""), content.toString("base64"));
|
||||
const finalize = prepare.find((command) => command.includes(`/usr/bin/base64 -d ${remote}.b64`));
|
||||
assert.ok(finalize?.includes(`/usr/bin/wc -c < ${remote}`));
|
||||
assert.ok(finalize?.includes(`/usr/bin/rm -f ${remote}.b64`));
|
||||
assert.ok(finalize?.includes(`test \"$actual_bytes\" -eq ${content.byteLength}`));
|
||||
}
|
||||
const start = commands.start[3] ?? "";
|
||||
assert.match(start, /\/tmp\/openwork-enterprise-tls-runtime\/evals\/scripts\/enterprise-tls-edge\.mts/);
|
||||
assert.ok(!start.includes("/workspace/evals/scripts/enterprise-tls-edge.mts"));
|
||||
assert.ok(!start.includes("&;"));
|
||||
assert.ok(start.includes("</dev/null &\nattempt=0\nuntil /usr/bin/curl"));
|
||||
assert.ok(start.includes("/usr/bin/tail -c 4000"));
|
||||
assert.ok(start.includes(">&2"));
|
||||
const tokenMatch = /ENTERPRISE_TLS_ADMIN_TOKEN=([a-f0-9]{32,}) nohup/.exec(start);
|
||||
assert.ok(tokenMatch);
|
||||
const adminToken = tokenMatch[1];
|
||||
assert.match(adminToken, /^[a-f0-9]{32,}$/);
|
||||
const serveArgv = start.slice(start.indexOf("enterprise-tls-edge.mts"), start.indexOf(" >"));
|
||||
assert.ok(!serveArgv.includes(adminToken));
|
||||
const authorizationHeader = `-H "Authorization: Bearer ${adminToken}"`;
|
||||
assert.ok(start.includes(authorizationHeader));
|
||||
assert.ok(start.includes("http://127.0.0.1:8445/health"));
|
||||
assert.ok((commands.probe[3] ?? "").includes(authorizationHeader));
|
||||
assert.ok((commands.probe[3] ?? "").includes("http://127.0.0.1:8445/health"));
|
||||
assert.ok((commands.requests[3] ?? "").includes(authorizationHeader));
|
||||
assert.ok((commands.requests[3] ?? "").includes("http://127.0.0.1:8445/requests"));
|
||||
const installRoot = commands.installRoot[3] ?? "";
|
||||
const removeRoot = commands.removeRoot[3] ?? "";
|
||||
assert.ok(installRoot.includes("install"));
|
||||
assert.ok(removeRoot.includes("remove"));
|
||||
for (const command of [installRoot, removeRoot]) {
|
||||
assert.ok(command.includes("node_path=$(command -v node)"));
|
||||
assert.ok(command.includes('test -n "$node_path"'));
|
||||
assert.ok(command.includes('/usr/bin/sudo -n "$node_path"'));
|
||||
assert.ok(!command.includes("/usr/bin/sudo -n '/usr/bin/env' 'node'"));
|
||||
}
|
||||
for (const command of [commands.start, commands.probe, commands.requests, commands.stop]) {
|
||||
assert.ok(!command[3]?.includes("sudo -n"));
|
||||
}
|
||||
const stop = commands.stop[3] ?? "";
|
||||
for (const command of [installRoot, removeRoot, stop]) {
|
||||
assert.ok(!command.includes("ENTERPRISE_TLS_ADMIN_TOKEN"));
|
||||
assert.ok(!command.includes("Authorization: Bearer"));
|
||||
assert.ok(!command.includes(adminToken));
|
||||
}
|
||||
assert.ok(stop.includes("stop"));
|
||||
assert.ok(stop.includes("&& /usr/bin/rm -rf"));
|
||||
assert.ok(stop.indexOf("stop") < stop.indexOf("/usr/bin/rm -rf"));
|
||||
assert.ok(stop.includes(runtimeRoot));
|
||||
});
|
||||
|
||||
test("enterprise TLS edge commands reject steering and port collisions", () => {
|
||||
assert.throws(
|
||||
() => enterpriseTlsEdgeDaytonaCommands({ sandboxId: "sandbox", upstream: "https://den.example.test/path" }),
|
||||
/HTTP\(S\) origin/,
|
||||
);
|
||||
assert.throws(
|
||||
() => enterpriseTlsEdgeDaytonaCommands({ sandboxId: "sandbox", upstream: "https://den.example.test", adminPort: 8443 }),
|
||||
/must be distinct/,
|
||||
);
|
||||
});
|
||||
|
||||
test("startDen attaches to preset Den env without running daytona exec", async () => {
|
||||
const server = await startRuntimeConfigStub("single_org");
|
||||
const previousApi = process.env.OPENWORK_EVAL_DEN_API_URL;
|
||||
const previousWeb = process.env.OPENWORK_EVAL_DEN_WEB_URL;
|
||||
const { exec, calls } = createFakeExec(() => "https://unused.example.test");
|
||||
const host = createDaytonaHost({ sandboxId: "openwork-test-den", log: () => undefined, exec, repoRoot: "/repo" });
|
||||
|
||||
try {
|
||||
process.env.OPENWORK_EVAL_DEN_API_URL = "https://den-api.example.test";
|
||||
process.env.OPENWORK_EVAL_DEN_WEB_URL = server.url;
|
||||
const handle = await host.startDen();
|
||||
|
||||
assert.equal(handle.webUrl, server.url);
|
||||
assert.equal(handle.apiUrl, "https://den-api.example.test");
|
||||
assert.equal(handle.orgMode, "single_org");
|
||||
assert.equal(handle.hostKind, "daytona");
|
||||
assert.equal(calls.length, 0);
|
||||
} finally {
|
||||
if (previousApi === undefined) delete process.env.OPENWORK_EVAL_DEN_API_URL;
|
||||
else process.env.OPENWORK_EVAL_DEN_API_URL = previousApi;
|
||||
if (previousWeb === undefined) delete process.env.OPENWORK_EVAL_DEN_WEB_URL;
|
||||
else process.env.OPENWORK_EVAL_DEN_WEB_URL = previousWeb;
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
access,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
clearDenWebBuildCache,
|
||||
denEvalEnvironment,
|
||||
denSeedNodeArgs,
|
||||
nativeMysqlServerArgs,
|
||||
nativeMysqlSocketArgs,
|
||||
} from "../src/den-stack.ts";
|
||||
|
||||
test("native MariaDB explicitly permits root-owned Daytona runtimes", () => {
|
||||
assert(nativeMysqlServerArgs(true).includes("--user=root"));
|
||||
assert(!nativeMysqlServerArgs(false).includes("--user=root"));
|
||||
});
|
||||
|
||||
test("native MariaDB supports fresh and resumed socket authentication", () => {
|
||||
assert(!nativeMysqlSocketArgs(false).some((arg) => arg.startsWith("-p")));
|
||||
assert(nativeMysqlSocketArgs(true).includes("-ppassword"));
|
||||
});
|
||||
|
||||
test("demo-org seed resolves development exports without package builds", () => {
|
||||
assert.deepEqual(denSeedNodeArgs(), [
|
||||
"--conditions=development",
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/seed-demo-org.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Den evaluator explicitly permits the isolated demo signup", () => {
|
||||
assert.equal(
|
||||
denEvalEnvironment().DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP,
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
test("Den evaluator threads orgMode only when requested", () => {
|
||||
const defaultEnv = denEvalEnvironment();
|
||||
const multiOrgEnv = denEvalEnvironment({ orgMode: "multi_org" });
|
||||
|
||||
assert.equal(defaultEnv.DEN_ORG_MODE, undefined);
|
||||
assert.equal(multiOrgEnv.DEN_ORG_MODE, "multi_org");
|
||||
assert.equal(multiOrgEnv.PORT, defaultEnv.PORT);
|
||||
});
|
||||
|
||||
test("Den evaluator trusts both loopback spellings used by the proof browser", () => {
|
||||
const trustedOrigins = denEvalEnvironment()
|
||||
.DEN_BETTER_AUTH_TRUSTED_ORIGINS?.split(",");
|
||||
|
||||
assert(trustedOrigins?.includes("http://localhost:3005"));
|
||||
assert(trustedOrigins?.includes("http://127.0.0.1:3005"));
|
||||
});
|
||||
|
||||
test("Den Web startup discards generated output from a reusable image", async () => {
|
||||
const denWebRoot = await mkdtemp(join(tmpdir(), "openwork-den-web-"));
|
||||
const staleManifest = join(denWebRoot, ".next", "server", "app-paths-manifest.json");
|
||||
|
||||
try {
|
||||
await mkdir(join(denWebRoot, ".next", "server"), { recursive: true });
|
||||
await writeFile(staleManifest, "{}");
|
||||
|
||||
await clearDenWebBuildCache(denWebRoot);
|
||||
|
||||
await assert.rejects(access(staleManifest));
|
||||
} finally {
|
||||
await rm(denWebRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { FAULT_PROXY_SCRIPT } from "../src/fault-proxy-script.ts";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
function listen(server: Server): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (typeof address === "object" && address !== null) resolve(address.port);
|
||||
else reject(new Error("Test server did not expose a port."));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function close(server: Server): Promise<void> {
|
||||
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
const server = createServer();
|
||||
const port = await listen(server);
|
||||
await close(server);
|
||||
return port;
|
||||
}
|
||||
|
||||
test("fault proxy script exposes authenticated controls and preserves local fault semantics", async () => {
|
||||
const upstream = createServer((request, response) => {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ path: request.url }));
|
||||
});
|
||||
const upstreamPort = await listen(upstream);
|
||||
const proxyPort = await freePort();
|
||||
const directory = await mkdtemp(join(tmpdir(), "openwork-fault-proxy-"));
|
||||
const scriptPath = join(directory, "proxy.mjs");
|
||||
await writeFile(scriptPath, FAULT_PROXY_SCRIPT);
|
||||
const issuer = "https://fault-proxy.example.test";
|
||||
const token = "test-control-token";
|
||||
const child = spawn(process.execPath, [scriptPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(proxyPort),
|
||||
UPSTREAM: `http://127.0.0.1:${upstreamPort}`,
|
||||
ISSUER: issuer,
|
||||
CONTROL_TOKEN: token,
|
||||
},
|
||||
stdio: "ignore",
|
||||
});
|
||||
const url = `http://127.0.0.1:${proxyPort}`;
|
||||
const auth = { "x-openwork-fault-token": token };
|
||||
const post = (path: string, body?: unknown): Promise<Response> => fetch(`${url}${path}`, {
|
||||
method: "POST",
|
||||
headers: body === undefined ? auth : { ...auth, "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + 5_000;
|
||||
let health: Response | undefined;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
health = await fetch(`${url}/__openwork_faults/health`);
|
||||
if (health.ok) break;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
assert(health?.ok, "fault proxy health did not become ready");
|
||||
assert.deepEqual(await health.json(), { ok: true, issuer });
|
||||
assert.equal((await fetch(`${url}/__openwork_faults/requests`)).status, 401);
|
||||
assert.equal((await fetch(`${url}/__openwork_faults/clear`, { method: "POST" })).status, 401);
|
||||
assert.equal((await post("/__openwork_faults/rules", {
|
||||
kind: "status",
|
||||
pathPrefix: "/flaky",
|
||||
statusCode: 429,
|
||||
times: 2,
|
||||
body: { error: "injected" },
|
||||
})).status, 204);
|
||||
|
||||
assert.equal((await fetch(`${url}/flaky/one`)).status, 429);
|
||||
assert.equal((await fetch(`${url}/flaky/two`)).status, 429);
|
||||
assert.equal((await fetch(`${url}/flaky/three`)).status, 200);
|
||||
|
||||
assert.equal((await post("/__openwork_faults/rules", {
|
||||
kind: "latency",
|
||||
pathPrefix: "/slow",
|
||||
delayMs: 30,
|
||||
})).status, 204);
|
||||
const startedAt = Date.now();
|
||||
assert.equal((await fetch(`${url}/slow`)).status, 200);
|
||||
assert(Date.now() - startedAt >= 20);
|
||||
|
||||
assert.equal((await post("/__openwork_faults/rules", {
|
||||
kind: "status",
|
||||
pathPrefix: "/cleared",
|
||||
statusCode: 500,
|
||||
times: 3,
|
||||
})).status, 204);
|
||||
assert.equal((await post("/__openwork_faults/clear")).status, 204);
|
||||
assert.equal((await fetch(`${url}/cleared`)).status, 200);
|
||||
|
||||
const logResponse = await fetch(`${url}/__openwork_faults/requests`, { headers: auth });
|
||||
assert.equal(logResponse.status, 200);
|
||||
const log: unknown = await logResponse.json();
|
||||
assert.deepEqual(
|
||||
typeof log === "object" && log !== null && "requests" in log && Array.isArray(log.requests)
|
||||
? log.requests.map((entry) => ({ path: entry.path, status: entry.status, faulted: entry.faulted }))
|
||||
: log,
|
||||
[
|
||||
{ path: "/flaky/one", status: 429, faulted: true },
|
||||
{ path: "/flaky/two", status: 429, faulted: true },
|
||||
{ path: "/flaky/three", status: 200, faulted: false },
|
||||
{ path: "/slow", status: 200, faulted: true },
|
||||
{ path: "/cleared", status: 200, faulted: false },
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await close(upstream);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { access, chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import { allocateFreePort } from "@openwork/cdp";
|
||||
import { electronProfilePaths, electronSurfaceEnv, freePort, resolveChromeBinary, stopOwnedElectronSurface } from "../src/local.ts";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"APPDATA",
|
||||
"HOME",
|
||||
"LOCALAPPDATA",
|
||||
"OPENCODE_CONFIG_DIR",
|
||||
"OPENWORK_DATA_DIR",
|
||||
"OPENWORK_DESKTOP_BOOTSTRAP_PATH",
|
||||
"OPENWORK_DESKTOP_DISABLE_WORKSPACE_RECOVERY",
|
||||
"OPENWORK_DEV_MODE",
|
||||
"OPENWORK_ELECTRON_APP_IDENTIFIER",
|
||||
"OPENWORK_ELECTRON_APP_NAME",
|
||||
"OPENWORK_ELECTRON_DISABLE_PROTOCOL_REGISTRATION",
|
||||
"OPENWORK_ELECTRON_REMOTE_DEBUG_PORT",
|
||||
"OPENWORK_ELECTRON_SKIP_SHARED_PREPARE",
|
||||
"OPENWORK_ELECTRON_USE_MOCK_KEYCHAIN",
|
||||
"OPENWORK_ELECTRON_USERDATA",
|
||||
"OPENWORK_ENV_STORE",
|
||||
"PORT",
|
||||
"VITE_DISABLE_OPENWORK_MODELS",
|
||||
"XDG_CACHE_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"XDG_DATA_HOME",
|
||||
"XDG_STATE_HOME",
|
||||
].sort();
|
||||
|
||||
test("electronProfilePaths returns all expected paths under the profile root", () => {
|
||||
const root = join(tmpdir(), "openwork-local-host-profile");
|
||||
const paths = electronProfilePaths(root);
|
||||
|
||||
assert.deepEqual(Object.keys(paths).sort(), [
|
||||
"appDataDir",
|
||||
"bootstrapPath",
|
||||
"cacheHome",
|
||||
"configHome",
|
||||
"dataDir",
|
||||
"dataHome",
|
||||
"envStorePath",
|
||||
"homeDir",
|
||||
"localAppDataDir",
|
||||
"opencodeConfigDir",
|
||||
"root",
|
||||
"stateHome",
|
||||
"userDataDir",
|
||||
].sort());
|
||||
for (const value of Object.values(paths)) {
|
||||
assert(value === root || value.startsWith(`${root}/`), `${value} should be under ${root}`);
|
||||
}
|
||||
assert.equal(paths.userDataDir, join(root, "electron-userdata"));
|
||||
assert.equal(paths.bootstrapPath, join(root, "bootstrap.json"));
|
||||
});
|
||||
|
||||
test("electronSurfaceEnv matches the isolated Electron demo contract", () => {
|
||||
const root = join(tmpdir(), "openwork-local-host-env");
|
||||
const paths = electronProfilePaths(root);
|
||||
const env = electronSurfaceEnv(paths, {
|
||||
appName: "OpenWork Eval probe",
|
||||
appIdentifier: "com.differentai.openwork.eval.probe",
|
||||
port: 5123,
|
||||
cdpPort: 9123,
|
||||
});
|
||||
|
||||
assert.deepEqual(Object.keys(env).filter((key) => key !== "PNPM_HOME").sort(), ENV_KEYS);
|
||||
// The one deliberate hole in the isolation: pnpm's version redirection must
|
||||
// stay warm, or every spawn re-downloads the pinned pnpm from the network.
|
||||
if (process.platform === "darwin" || process.platform === "linux") {
|
||||
assert(env.PNPM_HOME, "PNPM_HOME should point at the host's pnpm home");
|
||||
assert(!env.PNPM_HOME.startsWith(root), "PNPM_HOME must not be inside the isolated profile");
|
||||
}
|
||||
assert.equal(env.APPDATA, paths.appDataDir);
|
||||
assert.equal(env.HOME, paths.homeDir);
|
||||
assert.equal(env.LOCALAPPDATA, paths.localAppDataDir);
|
||||
assert.equal(env.OPENWORK_DATA_DIR, paths.dataDir);
|
||||
assert.equal(env.OPENWORK_DESKTOP_BOOTSTRAP_PATH, paths.bootstrapPath);
|
||||
assert.equal(env.OPENWORK_ENV_STORE, paths.envStorePath);
|
||||
assert.equal(env.OPENCODE_CONFIG_DIR, paths.opencodeConfigDir);
|
||||
assert.equal(env.OPENWORK_ELECTRON_USERDATA, paths.userDataDir);
|
||||
assert.equal(env.PORT, "5123");
|
||||
assert.equal(env.OPENWORK_ELECTRON_REMOTE_DEBUG_PORT, "9123");
|
||||
assert.equal(env.OPENWORK_ELECTRON_APP_NAME, "OpenWork Eval probe");
|
||||
assert.equal(env.OPENWORK_ELECTRON_APP_IDENTIFIER, "com.differentai.openwork.eval.probe");
|
||||
assert.equal(env.OPENWORK_ELECTRON_SKIP_SHARED_PREPARE, "1");
|
||||
assert.equal(env.OPENWORK_ELECTRON_USE_MOCK_KEYCHAIN, "1");
|
||||
assert.equal(env.XDG_CACHE_HOME, paths.cacheHome);
|
||||
assert.equal(env.XDG_CONFIG_HOME, paths.configHome);
|
||||
assert.equal(env.XDG_DATA_HOME, paths.dataHome);
|
||||
assert.equal(env.XDG_STATE_HOME, paths.stateHome);
|
||||
});
|
||||
|
||||
test("stopOwnedElectronSurface verifies profile ownership before removing it", async () => {
|
||||
const profileDir = await mkdtemp(join(tmpdir(), "openwork-owned-electron-"));
|
||||
const userDataDir = join(profileDir, "electron-userdata");
|
||||
await mkdir(userDataDir, { recursive: true });
|
||||
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
detached: true,
|
||||
env: { ...process.env, OPENWORK_ELECTRON_USERDATA: userDataDir },
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
if (!child.pid) throw new Error("Owned Electron fixture did not start.");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await stopOwnedElectronSurface(child.pid, profileDir);
|
||||
await assert.rejects(access(profileDir));
|
||||
});
|
||||
|
||||
test("resolveChromeBinary honors CHROME_BIN before platform defaults", () => {
|
||||
assert.equal(resolveChromeBinary({ CHROME_BIN: "/custom/chrome" }, "linux"), "/custom/chrome");
|
||||
assert.equal(resolveChromeBinary({ CHROME_BIN: "C:\\Chrome.exe" }, "win32"), "C:\\Chrome.exe");
|
||||
});
|
||||
|
||||
test("resolveChromeBinary returns the macOS default path", () => {
|
||||
assert.equal(resolveChromeBinary({}, "darwin"), "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
|
||||
});
|
||||
|
||||
test("resolveChromeBinary finds Linux Chrome on PATH and reports a helpful error otherwise", async () => {
|
||||
const binDir = await mkdtemp(join(tmpdir(), "openwork-chrome-bin-"));
|
||||
const chromePath = join(binDir, "google-chrome");
|
||||
try {
|
||||
await writeFile(chromePath, "#!/bin/sh\nexit 0\n", "utf8");
|
||||
await chmod(chromePath, 0o755);
|
||||
|
||||
assert.equal(resolveChromeBinary({ PATH: binDir }, "linux"), chromePath);
|
||||
assert.throws(
|
||||
() => resolveChromeBinary({ PATH: "" }, "linux"),
|
||||
/Could not resolve Chrome binary on linux.*CHROME_BIN/,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveChromeBinary({}, "freebsd"),
|
||||
/unsupported platform freebsd/,
|
||||
);
|
||||
} finally {
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("freePort kills a real child listener and releases its port", {
|
||||
skip: process.platform !== "darwin" && process.platform !== "linux",
|
||||
}, async () => {
|
||||
const port = await allocateFreePort();
|
||||
const child = spawn(process.execPath, [
|
||||
"-e",
|
||||
"require('node:net').createServer().listen(Number(process.argv[1]), '127.0.0.1', () => process.stdout.write('ready\\n'))",
|
||||
String(port),
|
||||
], { detached: true, stdio: ["ignore", "pipe", "inherit"] });
|
||||
const logs: string[] = [];
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Listener child did not bind port ${port}.`)), 5_000);
|
||||
child.once("error", reject);
|
||||
child.stdout?.once("data", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await freePort(port, { log: (message) => logs.push(message) });
|
||||
|
||||
assert(logs.some((message) => message.includes(`Port ${port}`) && message.includes(String(child.pid))));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const probe = createServer();
|
||||
probe.once("error", reject);
|
||||
probe.listen(port, "127.0.0.1", () => probe.close((error) => error ? reject(error) : resolve()));
|
||||
});
|
||||
} finally {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
deleteSandboxes,
|
||||
desktopSandboxName,
|
||||
parseConnectorE2eTestEnv,
|
||||
provisionDesktopSandbox,
|
||||
renderConnectorE2eTestEnv,
|
||||
serverSandboxName,
|
||||
startFaultProxyOnSandbox,
|
||||
startMockOnSandbox,
|
||||
} from "../src/provision.ts";
|
||||
import type { ConnectorE2eTestEnv } from "../src/provision.ts";
|
||||
import type { DaytonaExec } from "../src/daytona.ts";
|
||||
|
||||
interface ExecCall {
|
||||
args: string[];
|
||||
opts?: { input?: string; timeoutMs?: number };
|
||||
}
|
||||
|
||||
function desktopFake(diskUse = "40%"):
|
||||
{ exec: DaytonaExec; calls: ExecCall[] } {
|
||||
const calls: ExecCall[] = [];
|
||||
const exec: DaytonaExec = async (args, opts) => {
|
||||
calls.push({ args: [...args], opts });
|
||||
if (args[0] === "sandbox" && args[1] === "start") {
|
||||
return { stdout: "", stderr: "already started", code: 1 };
|
||||
}
|
||||
if (args[0] === "snapshot") {
|
||||
return { stdout: JSON.stringify([{ name: "openwork-eval-vnc", id: "snapshot-123" }]), stderr: "", code: 0 };
|
||||
}
|
||||
if (args[0] !== "exec") return { stdout: "", stderr: "", code: 0 };
|
||||
|
||||
const script = args[3] ?? "";
|
||||
if (script.includes("git rev-parse")) return { stdout: "abc1234\n", stderr: "", code: 0 };
|
||||
if (script.includes("df -P")) {
|
||||
return { stdout: `/dev/root 100 40 60 ${diskUse} /workspace\n`, stderr: "", code: 0 };
|
||||
}
|
||||
if (script.includes("du -sh")) return { stdout: "8G /workspace/node_modules\n", stderr: "", code: 0 };
|
||||
if (script.includes("pgrep -f Xvfb")) return { stdout: "XVFB_OK\n", stderr: "", code: 0 };
|
||||
if (script.includes("xdg-open-proof")) return { stdout: "XDG_OPEN_WORKS\n", stderr: "", code: 0 };
|
||||
if (script.includes("json/version")) return { stdout: '{"Browser":"Chrome/144"}', stderr: "", code: 0 };
|
||||
if (script.includes("%{http_code}")) return { stdout: "200", stderr: "", code: 0 };
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
return { exec, calls };
|
||||
}
|
||||
|
||||
function assertRemoteCommandsAreSingleArgument(calls: ExecCall[]): void {
|
||||
for (const call of calls.filter((entry) => entry.args[0] === "exec")) {
|
||||
assert.equal(call.args.length, 4);
|
||||
assert(call.args[3]?.startsWith("bash -lc '"));
|
||||
}
|
||||
}
|
||||
|
||||
test("connector E2E test env rendering and parsing round-trip the provision contract", () => {
|
||||
const facts: ConnectorE2eTestEnv = {
|
||||
denApiUrl: "https://den-api.example.test",
|
||||
denWebUrl: "https://den-web.example.test",
|
||||
sandboxA: "desktop-a",
|
||||
sandboxB: "desktop-b",
|
||||
mockUrl: "https://mock.example.test",
|
||||
ref: "feat/eval-connector-two-members",
|
||||
created: ["den", "desktop-a"],
|
||||
};
|
||||
const content = renderConnectorE2eTestEnv(facts);
|
||||
|
||||
assert.deepEqual(parseConnectorE2eTestEnv(content), facts);
|
||||
assert.match(content, /^# provisioned for org-connector-two-members — generated .*; ref=feat\/eval-connector-two-members$/m);
|
||||
assert.match(content, /^# provision-created=den,desktop-a$/m);
|
||||
assert.match(content, /OPENWORK_EVAL_MODEL=big-pickle/);
|
||||
const missingApi = content.split("\n").filter((line) => !line.startsWith("OPENWORK_EVAL_DEN_API_URL=")).join("\n");
|
||||
assert.throws(() => parseConnectorE2eTestEnv(missingApi), /OPENWORK_EVAL_DEN_API_URL/);
|
||||
});
|
||||
|
||||
test("server sandbox names are unique within the same CI process and second", () => {
|
||||
const first = serverSandboxName();
|
||||
const second = serverSandboxName();
|
||||
|
||||
assert.match(first, new RegExp(`^openwork-server-\\d{8}-\\d{6}-${process.pid}-[0-9a-f]{8}$`));
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test("desktop sandbox names stay unique when parallel workers use the same surface name", () => {
|
||||
const first = desktopSandboxName("testkit admin");
|
||||
const second = desktopSandboxName("testkit admin");
|
||||
|
||||
assert.match(first, new RegExp(`^openwork-connector-testkit-admin-\\d{8}-\\d{6}-${process.pid}-[0-9a-f]{8}$`));
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test("provisionDesktopSandbox reuses a sandbox and keeps every remote command in one argument", async () => {
|
||||
const { exec, calls } = desktopFake();
|
||||
|
||||
const result = await provisionDesktopSandbox({ ref: "dev", name: "a", reuse: "existing-a", exec, log: () => undefined });
|
||||
|
||||
assert.deepEqual(result, { sandbox: "existing-a", created: false });
|
||||
assert.deepEqual(calls[0]?.args, ["sandbox", "start", "existing-a"]);
|
||||
assert.equal(calls.filter((call) => call.args[0] === "create").length, 0);
|
||||
assert.equal(calls.filter((call) => call.args[0] === "snapshot").length, 0);
|
||||
assertRemoteCommandsAreSingleArgument(calls);
|
||||
});
|
||||
|
||||
test("provisionDesktopSandbox resolves the snapshot id and creates with connector flags", async () => {
|
||||
const { exec, calls } = desktopFake();
|
||||
|
||||
const result = await provisionDesktopSandbox({ ref: "dev", name: "b", exec, log: () => undefined });
|
||||
|
||||
assert.equal(result.created, true);
|
||||
const create = calls.find((call) => call.args[0] === "create");
|
||||
assert(create);
|
||||
assert(create.args.includes("snapshot-123"));
|
||||
assert(!create.args.includes("--volume"), "eval secrets must not be mounted next to an untrusted ref by default");
|
||||
assert(create.args.includes("--auto-stop"));
|
||||
assert(create.args.includes("--public"));
|
||||
assert.equal(calls.filter((call) => call.args[0] === "sandbox" && call.args[1] === "start").length, 0);
|
||||
assertRemoteCommandsAreSingleArgument(calls);
|
||||
});
|
||||
|
||||
test("rendered values are shell-quoted, because the env file is meant to be sourced", () => {
|
||||
const nasty = "$(touch /tmp/pwned); echo it's-here";
|
||||
const content = renderConnectorE2eTestEnv({
|
||||
denApiUrl: "https://a",
|
||||
denWebUrl: "https://w",
|
||||
sandboxA: nasty,
|
||||
sandboxB: "b",
|
||||
mockUrl: "https://m",
|
||||
ref: "dev",
|
||||
created: [],
|
||||
});
|
||||
|
||||
assert(content.includes(`OPENWORK_EVAL_DAYTONA_SANDBOX_A='$(touch /tmp/pwned); echo it'"'"'s-here'`));
|
||||
assert.equal(parseConnectorE2eTestEnv(content).sandboxA, nasty);
|
||||
});
|
||||
|
||||
test("an unsafe ref is refused before it can reach a remote shell or a sourced file", async () => {
|
||||
const { exec, calls } = desktopFake();
|
||||
|
||||
for (const ref of ["dev\"; rm -rf /; #", "$(curl attacker)", "dev\nrm -rf /", "--upload-pack=evil"]) {
|
||||
await assert.rejects(
|
||||
provisionDesktopSandbox({ ref, name: "a", reuse: "existing-a", exec, log: () => undefined }),
|
||||
/Unsafe git ref/,
|
||||
);
|
||||
assert.throws(() => renderConnectorE2eTestEnv({
|
||||
denApiUrl: "https://a",
|
||||
denWebUrl: "https://w",
|
||||
sandboxA: "a",
|
||||
sandboxB: "b",
|
||||
mockUrl: "https://m",
|
||||
ref,
|
||||
created: [],
|
||||
}), /Unsafe git ref/);
|
||||
}
|
||||
assert.equal(calls.length, 0, "an unsafe ref must be refused before any daytona call");
|
||||
});
|
||||
|
||||
test("the eval secrets volume is mounted only when explicitly asked for", async () => {
|
||||
const { exec, calls } = desktopFake();
|
||||
|
||||
await provisionDesktopSandbox({ ref: "dev", name: "b", secrets: true, exec, log: () => undefined });
|
||||
|
||||
const create = calls.find((call) => call.args[0] === "create");
|
||||
assert(create?.args.includes("openwork-eval-secrets:/daytona-secrets"));
|
||||
});
|
||||
|
||||
test("provisionDesktopSandbox fails the disk gate above 85 percent", async () => {
|
||||
const { exec } = desktopFake("92%");
|
||||
|
||||
await assert.rejects(
|
||||
provisionDesktopSandbox({ ref: "dev", name: "a", reuse: "full-a", exec, log: () => undefined }),
|
||||
/92%/,
|
||||
);
|
||||
});
|
||||
|
||||
test("startMockOnSandbox rejects a health response with the wrong issuer", async () => {
|
||||
const calls: ExecCall[] = [];
|
||||
const exec: DaytonaExec = async (args, opts) => {
|
||||
calls.push({ args: [...args], opts });
|
||||
if (args[0] === "preview-url") {
|
||||
return { stdout: "Preview URL: https://mock.example.test\n", stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
const fetchImpl: typeof fetch = async () => new Response(
|
||||
JSON.stringify({ ok: true, issuer: "https://wrong-issuer.example.test" }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
startMockOnSandbox({ sandbox: "den-1", exec, fetchImpl, log: () => undefined }),
|
||||
/https:\/\/wrong-issuer\.example\.test.*https:\/\/mock\.example\.test/,
|
||||
);
|
||||
assertRemoteCommandsAreSingleArgument(calls);
|
||||
});
|
||||
|
||||
test("Daytona preview URL lookup retries a transient control-plane failure", async () => {
|
||||
let previewAttempts = 0;
|
||||
const exec: DaytonaExec = async (args) => {
|
||||
if (args[0] === "preview-url") {
|
||||
previewAttempts += 1;
|
||||
return previewAttempts === 1
|
||||
? { stdout: "", stderr: "unexpected EOF", code: 1 }
|
||||
: { stdout: "Preview URL: https://mock.example.test\n", stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
const fetchImpl: typeof fetch = async () => new Response(
|
||||
JSON.stringify({ ok: true, issuer: "https://mock.example.test" }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
await startMockOnSandbox({ sandbox: "den-1", exec, fetchImpl, log: () => undefined });
|
||||
|
||||
assert.equal(previewAttempts, 2);
|
||||
});
|
||||
|
||||
test("startFaultProxyOnSandbox uploads and detaches the proxy after resolving its preview URL", async () => {
|
||||
const calls: ExecCall[] = [];
|
||||
const exec: DaytonaExec = async (args, opts) => {
|
||||
calls.push({ args: [...args], opts });
|
||||
if (args[0] === "preview-url") {
|
||||
return { stdout: "Preview URL: https://fault.example.test\n", stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
const fetchImpl: typeof fetch = async () => new Response(
|
||||
JSON.stringify({ ok: true, issuer: "https://fault.example.test" }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
const proxy = await startFaultProxyOnSandbox({ sandbox: "den-1", exec, fetchImpl, log: () => undefined });
|
||||
|
||||
assert.equal(proxy.url, "https://fault.example.test");
|
||||
assert.match(proxy.token, /^[0-9a-f]{32}$/);
|
||||
assert.deepEqual(calls[0]?.args, ["preview-url", "den-1", "-p", "3985", "--expires", "86400"]);
|
||||
const scripts = calls.filter((call) => call.args[0] === "exec").map((call) => call.args[3]?.slice(10, -1) ?? "");
|
||||
assert.match(scripts[0] ?? "", /pkill -f openwork-fault-proxy/);
|
||||
assert.match(scripts[1] ?? "", /^printf %s [A-Za-z0-9+/=]+ \| base64 -d > \/tmp\/openwork-fault-proxy\.mjs$/);
|
||||
assert(!scripts[1]?.includes("'"));
|
||||
assert.match(scripts[2] ?? "", /start_new_session=True/);
|
||||
assert.match(scripts[2] ?? "", /UPSTREAM=http:\/\/127\.0\.0\.1:3005/);
|
||||
assert.match(scripts[2] ?? "", /node \/tmp\/openwork-fault-proxy\.mjs/);
|
||||
assertRemoteCommandsAreSingleArgument(calls);
|
||||
|
||||
await proxy.stop();
|
||||
assert.match(calls.at(-1)?.args[3] ?? "", /pkill -f openwork-fault-proxy\.mjs/);
|
||||
});
|
||||
|
||||
test("startFaultProxyOnSandbox rejects a health response with the wrong issuer", async () => {
|
||||
const exec: DaytonaExec = async (args) => {
|
||||
if (args[0] === "preview-url") {
|
||||
return { stdout: "Preview URL: https://fault.example.test\n", stderr: "", code: 0 };
|
||||
}
|
||||
return { stdout: "", stderr: "", code: 0 };
|
||||
};
|
||||
const fetchImpl: typeof fetch = async () => new Response(
|
||||
JSON.stringify({ ok: true, issuer: "https://wrong-issuer.example.test" }),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
startFaultProxyOnSandbox({ sandbox: "den-1", exec, fetchImpl, log: () => undefined }),
|
||||
/https:\/\/wrong-issuer\.example\.test.*https:\/\/fault\.example\.test/,
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteSandboxes answers the confirmation prompt and tolerates a missing sandbox", async () => {
|
||||
const calls: ExecCall[] = [];
|
||||
const exec: DaytonaExec = async (args, opts) => {
|
||||
calls.push({ args: [...args], opts });
|
||||
if (args[1] === "gone-2") return { stdout: "", stderr: "sandbox not found", code: 1 };
|
||||
return { stdout: "deleted\n", stderr: "", code: 0 };
|
||||
};
|
||||
|
||||
await deleteSandboxes(["sandbox-1", "gone-2"], { exec, log: () => undefined });
|
||||
|
||||
assert.deepEqual(calls.map((call) => call.args), [
|
||||
["delete", "sandbox-1"],
|
||||
["delete", "gone-2"],
|
||||
]);
|
||||
assert.equal(calls[0]?.opts?.input, "y\n");
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { runningInsideSandbox } from "../src/resolve.ts";
|
||||
|
||||
test("a sandbox id in the environment marks us as inside a sandbox", () => {
|
||||
assert.equal(runningInsideSandbox({ DAYTONA_SANDBOX_ID: "snd-123" }), true);
|
||||
});
|
||||
|
||||
test("an empty environment on a machine without sandbox volumes is outside", () => {
|
||||
// Guarded so the assertion still holds when the suite itself runs in a sandbox.
|
||||
const insideByVolume = runningInsideSandbox({});
|
||||
assert.equal(typeof insideByVolume, "boolean");
|
||||
});
|
||||
Reference in New Issue
Block a user