2330 lines
80 KiB
JavaScript
2330 lines
80 KiB
JavaScript
import { randomUUID, X509Certificate } from "node:crypto";
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
import net from "node:net";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import tls from "node:tls";
|
|
import { fileURLToPath } from "node:url";
|
|
import { pathToFileURL } from "node:url";
|
|
import {
|
|
desktopBootstrapPath,
|
|
normalizeWorkspaceRootPath,
|
|
openworkEnvStorePath,
|
|
openworkServerConfigPath,
|
|
resolveWorkspaceOpencodeConfigPath,
|
|
} from "@openwork/paths";
|
|
import {
|
|
dedupeCertificates,
|
|
resolveSystemCaBundle,
|
|
summarizeSystemCaSources,
|
|
systemPlatformCertificateLoader,
|
|
} from "./system-ca.mjs";
|
|
|
|
const __runtimeDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
const DIRECT_RUNTIME = "direct";
|
|
const OPENWORK_SERVER_PORT_RANGE_START = 48_000;
|
|
const OPENWORK_SERVER_PORT_RANGE_END = 51_000;
|
|
const MAX_BOOTSTRAP_BYTES = 256 * 1024;
|
|
const MAX_CHAIN_REPAIR_BODY_BYTES = 64 * 1024;
|
|
const MAX_CHAIN_REPAIR_ORIGINS = 3;
|
|
const CHAIN_REPAIR_TOTAL_TIMEOUT_MS = 20000;
|
|
const CHAIN_REPAIR_SOCKET_TIMEOUT_MS = 8000;
|
|
const CHAIN_REPAIR_FETCH_TIMEOUT_MS = 8000;
|
|
const CERTIFICATE_VERIFY_USE_CHROMIUM = -3;
|
|
const CERTIFICATE_VERIFY_OK = 0;
|
|
const ERR_CERT_AUTHORITY_INVALID = -202;
|
|
const MAX_CERTIFICATE_CHAIN_LENGTH = 10;
|
|
const PEM_CERTIFICATE_PATTERN = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;
|
|
/** @type {Map<string, X509Certificate | null>} */
|
|
const chainRepairRootCache = new Map();
|
|
|
|
/** @param {unknown} value */
|
|
function parsePemCertificates(value) {
|
|
return String(value ?? "").match(PEM_CERTIFICATE_PATTERN) ?? [];
|
|
}
|
|
|
|
/** @param {string} value */
|
|
function parseCertificate(value) {
|
|
try {
|
|
return new X509Certificate(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** @param {X509Certificate} certificate */
|
|
function certificateIsCurrent(certificate, now = Date.now()) {
|
|
const validFrom = Date.parse(certificate.validFrom);
|
|
const validTo = Date.parse(certificate.validTo);
|
|
return Number.isFinite(validFrom) && Number.isFinite(validTo) && validFrom <= now && now <= validTo;
|
|
}
|
|
|
|
/** @param {X509Certificate} certificate @param {string | undefined} hostname */
|
|
function certificateMatchesHostname(certificate, hostname) {
|
|
const value = String(hostname ?? "").trim();
|
|
if (!value) return false;
|
|
try {
|
|
return net.isIP(value) ? certificate.checkIP(value) !== undefined : certificate.checkHost(value) !== undefined;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** @param {X509Certificate} certificate */
|
|
function certificateAllowsTlsServerAuthentication(certificate) {
|
|
return certificate.keyUsage?.some((usage) =>
|
|
usage === "1.3.6.1.5.5.7.3.1" || /server ?auth/i.test(usage)
|
|
) === true;
|
|
}
|
|
|
|
/**
|
|
* @typedef {Object} ElectronCertificate
|
|
* @property {string} data
|
|
* @property {ElectronCertificate} [issuerCert]
|
|
*/
|
|
|
|
/** @param {ElectronCertificate | undefined} certificate */
|
|
function presentedCertificateChain(certificate) {
|
|
const chain = [];
|
|
const seen = new Set();
|
|
let current = certificate;
|
|
while (current) {
|
|
if (chain.length >= MAX_CERTIFICATE_CHAIN_LENGTH || typeof current.data !== "string") return null;
|
|
const parsed = parseCertificate(current.data);
|
|
if (!parsed) return null;
|
|
const key = parsed.raw.toString("base64");
|
|
if (seen.has(key)) return null;
|
|
seen.add(key);
|
|
chain.push(parsed);
|
|
current = current.issuerCert;
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
/** @param {ElectronCertificate | undefined} certificate @param {string | undefined} hostname @param {X509Certificate[]} trustedAnchors */
|
|
function chainEndsAtTrustedAnchor(certificate, hostname, trustedAnchors) {
|
|
const chain = presentedCertificateChain(certificate);
|
|
if (!chain || chain.length === 0 || chain.some((entry) => !certificateIsCurrent(entry))) return false;
|
|
if (!certificateAllowsTlsServerAuthentication(chain[0])) return false;
|
|
if (!certificateMatchesHostname(chain[0], hostname)) return false;
|
|
for (let index = 0; index < chain.length - 1; index += 1) {
|
|
if (chain[index + 1].ca !== true) return false;
|
|
if (!chain[index].checkIssued(chain[index + 1]) || !chain[index].verify(chain[index + 1].publicKey)) return false;
|
|
}
|
|
const terminal = chain.at(-1);
|
|
if (!terminal) return false;
|
|
return trustedAnchors.some((anchor) =>
|
|
anchor.ca === true && terminal.checkIssued(anchor) && terminal.verify(anchor.publicKey)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {string[]} trustedCertificates
|
|
* @returns {(request: { verificationResult?: string, errorCode?: number, hostname?: string, certificate?: ElectronCertificate }, callback: (result: number) => void) => void}
|
|
*/
|
|
export function createSystemCaCertificateVerifyProc(trustedCertificates) {
|
|
const trustedAnchors = trustedCertificates.map(parseCertificate).filter((certificate) => certificate !== null);
|
|
return (request, callback) => {
|
|
const authorityInvalid = request.verificationResult
|
|
? request.verificationResult === "net::ERR_CERT_AUTHORITY_INVALID"
|
|
&& (request.errorCode === undefined || request.errorCode === ERR_CERT_AUTHORITY_INVALID)
|
|
: request.errorCode === ERR_CERT_AUTHORITY_INVALID;
|
|
if (authorityInvalid && chainEndsAtTrustedAnchor(request.certificate, request.hostname, trustedAnchors)) {
|
|
callback(CERTIFICATE_VERIFY_OK);
|
|
return;
|
|
}
|
|
callback(CERTIFICATE_VERIFY_USE_CHROMIUM);
|
|
};
|
|
}
|
|
|
|
function truncateOutput(value, limit = 8000) {
|
|
const text = String(value ?? "");
|
|
return text.length <= limit ? text : text.slice(text.length - limit);
|
|
}
|
|
|
|
function appendOutput(state, key, chunk) {
|
|
const next = `${state[key] ?? ""}${String(chunk ?? "")}`;
|
|
state[key] = truncateOutput(next);
|
|
}
|
|
|
|
function normalizeWorkspaceKey(value, platform = process.platform) {
|
|
try {
|
|
const normalized = normalizeWorkspaceRootPath(value, { platform });
|
|
if (!normalized) return "";
|
|
const paths = platform === "win32" ? path.win32 : path;
|
|
return paths.resolve(normalized).replace(/\\/g, "/").toLowerCase();
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function normalizeServerCredentials(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const clientToken = typeof value.clientToken === "string" && value.clientToken.trim()
|
|
? value.clientToken
|
|
: null;
|
|
const hostToken = typeof value.hostToken === "string" && value.hostToken.trim()
|
|
? value.hostToken
|
|
: null;
|
|
if (!clientToken || !hostToken) return null;
|
|
return {
|
|
clientToken,
|
|
hostToken,
|
|
ownerToken: typeof value.ownerToken === "string" && value.ownerToken.trim()
|
|
? value.ownerToken
|
|
: null,
|
|
updatedAt: typeof value.updatedAt === "number" && Number.isFinite(value.updatedAt)
|
|
? value.updatedAt
|
|
: 0,
|
|
};
|
|
}
|
|
|
|
export function migrateOpenworkServerTokenStore(value) {
|
|
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
const sourceWorkspaces = source.workspaces && typeof source.workspaces === "object" && !Array.isArray(source.workspaces)
|
|
? source.workspaces
|
|
: {};
|
|
const workspaceEntries = Object.entries(sourceWorkspaces);
|
|
const legacyCredentials = workspaceEntries
|
|
.flatMap(([workspaceKey, entry]) => {
|
|
const credentials = normalizeServerCredentials(entry);
|
|
return credentials ? [{ workspaceKey, credentials }] : [];
|
|
})
|
|
.sort((left, right) => {
|
|
const updatedAtDifference = right.credentials.updatedAt - left.credentials.updatedAt;
|
|
if (updatedAtDifference !== 0) return updatedAtDifference;
|
|
return left.workspaceKey < right.workspaceKey ? -1 : left.workspaceKey > right.workspaceKey ? 1 : 0;
|
|
})[0]?.credentials;
|
|
const credentials = normalizeServerCredentials(source.credentials) ?? legacyCredentials ?? {
|
|
clientToken: randomUUID(),
|
|
hostToken: randomUUID(),
|
|
ownerToken: null,
|
|
updatedAt: nowMs(),
|
|
};
|
|
return { version: 2, credentials };
|
|
}
|
|
|
|
export function prioritizeWorkspacePaths(preferredPath, workspacePaths = [], options = {}) {
|
|
const platform = options.platform ?? process.platform;
|
|
const paths = [];
|
|
const seen = new Set();
|
|
const add = (value) => {
|
|
let workspacePath;
|
|
try {
|
|
workspacePath = normalizeWorkspaceRootPath(value, { platform });
|
|
} catch {
|
|
return;
|
|
}
|
|
const key = normalizeWorkspaceKey(workspacePath, platform);
|
|
if (!workspacePath || !key || seen.has(key)) return;
|
|
paths.push(workspacePath);
|
|
seen.add(key);
|
|
};
|
|
add(preferredPath);
|
|
for (const workspacePath of workspacePaths) add(workspacePath);
|
|
return paths;
|
|
}
|
|
|
|
function workspaceInaccessibleError(workspacePath, cause) {
|
|
if (cause && typeof cause === "object" && cause.code === "workspace_inaccessible") return cause;
|
|
const error = new Error(`Workspace path is not accessible: ${workspacePath}`, { cause });
|
|
Object.defineProperties(error, {
|
|
code: { value: "workspace_inaccessible", enumerable: true },
|
|
workspacePath: { value: workspacePath, enumerable: true },
|
|
});
|
|
return error;
|
|
}
|
|
|
|
export async function prepareRuntimeWorkspaceRoot(projectDir, options = {}) {
|
|
const rawProjectDir = String(projectDir ?? "").trim();
|
|
try {
|
|
const workspaceRoot = normalizeWorkspaceRootPath(rawProjectDir, {
|
|
platform: options.platform ?? process.platform,
|
|
});
|
|
if (!workspaceRoot) throw new Error("projectDir is required");
|
|
await (options.mkdirImpl ?? mkdir)(workspaceRoot, { recursive: true });
|
|
if (typeof options.ensureConfig === "function") {
|
|
await options.ensureConfig(workspaceRoot);
|
|
}
|
|
return workspaceRoot;
|
|
} catch (error) {
|
|
throw workspaceInaccessibleError(rawProjectDir, error);
|
|
}
|
|
}
|
|
|
|
export function resolveOpenworkServerConfigPath(env = process.env) {
|
|
return openworkServerConfigPath({ env });
|
|
}
|
|
|
|
export function seedWorkspacePathsForEmbeddedServer(workspacePaths, serverConfigExists) {
|
|
return serverConfigExists ? [] : workspacePaths;
|
|
}
|
|
|
|
export function selectStickyOpenworkPortWorkspace(requestedWorkspacePaths = [], serverWorkspacePaths = []) {
|
|
for (const value of [...requestedWorkspacePaths, ...serverWorkspacePaths]) {
|
|
const workspacePath = String(value ?? "").trim();
|
|
if (workspacePath) return workspacePath;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function resolveEvalLocalServerDelayMs(env = process.env) {
|
|
const delayMs = Number(env.OPENWORK_EVAL_LOCAL_SERVER_DELAY_MS);
|
|
return Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0;
|
|
}
|
|
|
|
export function reconcileInjectedUserEnv({
|
|
processEnv,
|
|
inheritedEnv,
|
|
userEnv,
|
|
previouslyInjectedKeys = new Set(),
|
|
}) {
|
|
for (const key of previouslyInjectedKeys) {
|
|
if (Object.prototype.hasOwnProperty.call(userEnv, key)) continue;
|
|
if (Object.prototype.hasOwnProperty.call(inheritedEnv, key)) {
|
|
processEnv[key] = inheritedEnv[key];
|
|
} else {
|
|
delete processEnv[key];
|
|
}
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(userEnv)) {
|
|
if (Object.prototype.hasOwnProperty.call(inheritedEnv, key)) continue;
|
|
processEnv[key] = value;
|
|
}
|
|
|
|
return new Set(
|
|
Object.keys(userEnv).filter(
|
|
(key) => !Object.prototype.hasOwnProperty.call(inheritedEnv, key),
|
|
),
|
|
);
|
|
}
|
|
|
|
export function commandMatchesPackagedSidecar(command, sidecarDirs = []) {
|
|
const value = String(command ?? "");
|
|
if (!sidecarDirs.some((dir) => String(dir ?? "").trim() && value.includes(dir))) {
|
|
return false;
|
|
}
|
|
return /(?:^|[/\\])opencode[^/\\\s]*\s+serve\b/.test(value);
|
|
}
|
|
|
|
export function embeddedServerImportUrl(embeddedPath) {
|
|
const url = pathToFileURL(embeddedPath);
|
|
try {
|
|
const stats = statSync(embeddedPath);
|
|
url.searchParams.set("mtimeMs", String(stats.mtimeMs));
|
|
url.searchParams.set("size", String(stats.size));
|
|
} catch {
|
|
// Fall back to the deterministic file URL if stat fails; startup can continue.
|
|
}
|
|
return url.href;
|
|
}
|
|
|
|
function nowMs() {
|
|
return Date.now();
|
|
}
|
|
|
|
function createEngineState() {
|
|
return {
|
|
child: null,
|
|
childExited: true,
|
|
runtime: DIRECT_RUNTIME,
|
|
projectDir: null,
|
|
hostname: null,
|
|
port: null,
|
|
baseUrl: null,
|
|
opencodeUsername: null,
|
|
opencodePassword: null,
|
|
opencodeBinPath: null,
|
|
opencodeBinSource: null,
|
|
managedByServer: false,
|
|
managedPid: null,
|
|
managedIsAlive: null,
|
|
lastStdout: null,
|
|
lastStderr: null,
|
|
execution: null,
|
|
};
|
|
}
|
|
|
|
export function snapshotEngineState(state) {
|
|
const child = state.childExited ? null : state.child;
|
|
let managedRunning = false;
|
|
if (state.managedByServer && typeof state.managedIsAlive === "function") {
|
|
try {
|
|
managedRunning = state.managedIsAlive() === true;
|
|
} catch {
|
|
managedRunning = false;
|
|
}
|
|
}
|
|
const childRunning = Boolean(child && child.exitCode === null && !child.killed);
|
|
return {
|
|
running: managedRunning || childRunning,
|
|
runtime: state.runtime,
|
|
managedByServer: state.managedByServer === true,
|
|
baseUrl: state.baseUrl,
|
|
projectDir: state.projectDir,
|
|
hostname: state.hostname,
|
|
port: state.port,
|
|
opencodeUsername: state.opencodeUsername,
|
|
opencodePassword: state.opencodePassword,
|
|
opencodeBinPath: state.opencodeBinPath,
|
|
opencodeBinSource: state.opencodeBinSource,
|
|
pid: state.managedByServer ? state.managedPid ?? null : child?.pid ?? null,
|
|
lastStdout: state.lastStdout,
|
|
lastStderr: state.lastStderr,
|
|
execution: state.execution,
|
|
};
|
|
}
|
|
|
|
function createOpenworkServerState() {
|
|
return {
|
|
child: null,
|
|
childExited: true,
|
|
inProcess: false,
|
|
// Monotonic per-start identity assigned by startOpenworkServerInner.
|
|
// Sticky ports and persisted tokens make the connection details identical
|
|
// across restarts, so clients need this to observe a new server lifetime.
|
|
generation: null,
|
|
remoteAccessEnabled: false,
|
|
host: null,
|
|
port: null,
|
|
baseUrl: null,
|
|
connectUrl: null,
|
|
mdnsUrl: null,
|
|
lanUrl: null,
|
|
clientToken: null,
|
|
ownerToken: null,
|
|
hostToken: null,
|
|
managedOpencodeBinPath: null,
|
|
managedOpencodeBinSource: null,
|
|
lastStdout: null,
|
|
lastStderr: null,
|
|
managedOpencodeExecution: null,
|
|
};
|
|
}
|
|
|
|
export function snapshotOpenworkServerState(state) {
|
|
const child = state.childExited ? null : state.child;
|
|
const running = state.inProcess || Boolean(child && child.exitCode === null && !child.killed);
|
|
return {
|
|
running,
|
|
generation: typeof state.generation === "number" ? state.generation : null,
|
|
remoteAccessEnabled: state.remoteAccessEnabled,
|
|
host: state.host,
|
|
port: state.port,
|
|
baseUrl: state.baseUrl,
|
|
connectUrl: state.connectUrl,
|
|
mdnsUrl: state.mdnsUrl,
|
|
lanUrl: state.lanUrl,
|
|
clientToken: state.clientToken,
|
|
ownerToken: state.ownerToken,
|
|
hostToken: state.hostToken,
|
|
managedOpencodeBinPath: state.managedOpencodeBinPath,
|
|
managedOpencodeBinSource: state.managedOpencodeBinSource,
|
|
pid: child?.pid ?? null,
|
|
lastStdout: state.lastStdout,
|
|
lastStderr: state.lastStderr,
|
|
managedOpencodeExecution: state.managedOpencodeExecution,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decide whether an engineStart request keeps the running embedded server.
|
|
*
|
|
* The engine is multi-instance: it boots a per-directory instance on demand,
|
|
* so a request for a different workspace retargets the running runtime
|
|
* instead of restarting it. A restart here would abort every in-flight run,
|
|
* including sessions still working in the workspace being left. Only an
|
|
* explicit forceRestart or a host rebind (remote access change) gives up the
|
|
* running server.
|
|
*/
|
|
export function resolveOpenworkServerReuse({
|
|
forceRestart,
|
|
inProcess,
|
|
lifecycleState,
|
|
remoteAccessEnabled,
|
|
requestedRemoteAccess,
|
|
currentProjectDir,
|
|
requestedProjectDir,
|
|
platform,
|
|
}) {
|
|
if (forceRestart === true) return { reuse: false, retarget: false };
|
|
if (inProcess !== true || lifecycleState !== "healthy") return { reuse: false, retarget: false };
|
|
if (remoteAccessEnabled !== (requestedRemoteAccess === true)) return { reuse: false, retarget: false };
|
|
const retarget =
|
|
normalizeWorkspaceKey(currentProjectDir, platform) !== normalizeWorkspaceKey(requestedProjectDir, platform);
|
|
return { reuse: true, retarget };
|
|
}
|
|
|
|
/**
|
|
* A failed server start must not leave the state objects describing the
|
|
* runtime it already stopped: snapshotOpenworkServerState would report
|
|
* running:true with a dead baseUrl and assertOpenworkServerReady would pass
|
|
* against it. Keeps accumulated output for diagnostics and the project dir so
|
|
* a retry via engineRestart still knows its workspace. The engine state only
|
|
* resets when this start owned the engine (manageOpencode) — an external
|
|
* engine keeps running regardless of the server's fate.
|
|
*/
|
|
export function resetRuntimeStatesAfterFailedServerStart(openworkServerStateRef, engineStateRef, options = {}) {
|
|
const serverStdout = openworkServerStateRef.lastStdout;
|
|
const serverStderr = openworkServerStateRef.lastStderr;
|
|
Object.assign(openworkServerStateRef, createOpenworkServerState());
|
|
openworkServerStateRef.lastStdout = serverStdout;
|
|
openworkServerStateRef.lastStderr = serverStderr;
|
|
if (options.manageOpencode === true) {
|
|
const engineStdout = engineStateRef.lastStdout;
|
|
const engineStderr = engineStateRef.lastStderr;
|
|
const projectDir = engineStateRef.projectDir;
|
|
Object.assign(engineStateRef, createEngineState());
|
|
engineStateRef.lastStdout = engineStdout;
|
|
engineStateRef.lastStderr = engineStderr;
|
|
engineStateRef.projectDir = projectDir;
|
|
}
|
|
}
|
|
|
|
function assertOpenworkServerReady(snapshot) {
|
|
if (!snapshot?.running) {
|
|
throw new Error("OpenWork server did not stay running after startup.");
|
|
}
|
|
if (!snapshot.baseUrl) {
|
|
throw new Error("OpenWork server did not report a base URL after startup.");
|
|
}
|
|
if (!snapshot.ownerToken && !snapshot.clientToken) {
|
|
throw new Error("OpenWork server did not report an access token after startup.");
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
async function fileExists(targetPath) {
|
|
try {
|
|
await readFile(targetPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function readJsonFile(targetPath, fallback) {
|
|
try {
|
|
const raw = await readFile(targetPath, "utf8");
|
|
return JSON.parse(raw);
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function selectLanAddress() {
|
|
const interfaces = os.networkInterfaces();
|
|
for (const entries of Object.values(interfaces)) {
|
|
for (const entry of entries ?? []) {
|
|
if (entry && entry.family === "IPv4" && entry.internal === false) {
|
|
return entry.address;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function buildConnectUrls(port) {
|
|
const hostname = os.hostname().trim();
|
|
const mdnsUrl = hostname ? `http://${hostname.replace(/\.local$/i, "")}.local:${port}` : null;
|
|
const lan = selectLanAddress();
|
|
const lanUrl = lan ? `http://${lan}:${port}` : null;
|
|
return {
|
|
connectUrl: lanUrl ?? mdnsUrl,
|
|
mdnsUrl,
|
|
lanUrl,
|
|
};
|
|
}
|
|
|
|
function targetTriple() {
|
|
if (process.platform === "darwin") {
|
|
return process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin";
|
|
}
|
|
if (process.platform === "linux") {
|
|
return process.arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu";
|
|
}
|
|
if (process.platform === "win32") {
|
|
return process.arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function binaryFileNames(baseName) {
|
|
const ext = process.platform === "win32" ? ".exe" : "";
|
|
const triple = targetTriple();
|
|
return [
|
|
triple ? `${baseName}-${triple}${ext}` : null,
|
|
`${baseName}${ext}`,
|
|
].filter(Boolean);
|
|
}
|
|
|
|
function isDirectory(targetPath) {
|
|
try {
|
|
return statSync(targetPath).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function nvmVersionBinPaths(home) {
|
|
const base = path.join(home, ".nvm", "versions", "node");
|
|
try {
|
|
return readdirSync(base, { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => path.join(base, entry.name, "bin"))
|
|
.filter(isDirectory)
|
|
.sort()
|
|
.reverse();
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function pathHelperEntries() {
|
|
if (process.platform !== "darwin") return [];
|
|
const result = spawnSync("/usr/libexec/path_helper", ["-s"], {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
if (result.status !== 0) return [];
|
|
const stdout = String(result.stdout ?? "");
|
|
const match = stdout.match(/PATH="([^"]+)"/) ?? stdout.match(/PATH=([^;\n]+)/);
|
|
return match?.[1]?.split(path.delimiter).filter(Boolean) ?? [];
|
|
}
|
|
|
|
function extraPathEntries() {
|
|
const home = os.homedir();
|
|
const candidates = [];
|
|
|
|
if (process.platform === "darwin") {
|
|
candidates.push(
|
|
...pathHelperEntries(),
|
|
"/opt/homebrew/bin",
|
|
"/opt/homebrew/sbin",
|
|
"/usr/local/bin",
|
|
"/usr/local/sbin",
|
|
path.join(home, ".nvm", "current", "bin"),
|
|
...nvmVersionBinPaths(home),
|
|
path.join(home, ".fnm", "current", "bin"),
|
|
path.join(home, ".volta", "bin"),
|
|
path.join(home, "Library", "pnpm"),
|
|
path.join(home, ".bun", "bin"),
|
|
path.join(home, ".cargo", "bin"),
|
|
path.join(home, ".pyenv", "shims"),
|
|
path.join(home, ".local", "bin"),
|
|
);
|
|
}
|
|
|
|
if (process.platform === "linux") {
|
|
candidates.push(
|
|
"/usr/local/bin",
|
|
"/usr/local/sbin",
|
|
path.join(home, ".nvm", "current", "bin"),
|
|
...nvmVersionBinPaths(home),
|
|
path.join(home, ".fnm", "current", "bin"),
|
|
path.join(home, ".volta", "bin"),
|
|
path.join(home, ".local", "share", "pnpm"),
|
|
path.join(home, ".bun", "bin"),
|
|
path.join(home, ".cargo", "bin"),
|
|
path.join(home, ".pyenv", "shims"),
|
|
path.join(home, ".local", "bin"),
|
|
);
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
candidates.push(
|
|
path.join(home, ".volta", "bin"),
|
|
path.join(home, ".bun", "bin"),
|
|
path.join(home, ".cargo", "bin"),
|
|
process.env.APPDATA ? path.join(process.env.APPDATA, "npm") : null,
|
|
process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "pnpm") : null,
|
|
);
|
|
}
|
|
|
|
return candidates.filter((entry) => entry && isDirectory(entry));
|
|
}
|
|
|
|
function enrichedPath(sidecarDirs, currentPath) {
|
|
const entries = [
|
|
...sidecarDirs.filter(isDirectory),
|
|
...extraPathEntries(),
|
|
...String(currentPath ?? "").split(path.delimiter).filter(Boolean),
|
|
];
|
|
const deduped = entries.filter((entry, index) => entries.indexOf(entry) === index);
|
|
return deduped.length > 0 ? deduped.join(path.delimiter) : null;
|
|
}
|
|
|
|
async function portAvailable(host, port) {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
server.unref();
|
|
server.once("error", () => resolve(false));
|
|
server.listen({ host, port }, () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function findFreePort(host = "127.0.0.1") {
|
|
return new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
server.unref();
|
|
server.once("error", reject);
|
|
server.listen({ host, port: 0 }, () => {
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
server.close(() => reject(new Error("Failed to allocate a free port.")));
|
|
return;
|
|
}
|
|
const { port } = address;
|
|
server.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function fetchJson(url, options = {}, timeoutMs = 3000) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
// loopback-fetch: fetchJson callers pass runtime-managed 127.0.0.1 server URLs.
|
|
const response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
headers: {
|
|
Accept: "application/json",
|
|
...(options.headers ?? {}),
|
|
},
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
return await response.json();
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
export function resolveUserEnvFilePath(env = process.env) {
|
|
return openworkEnvStorePath({ env });
|
|
}
|
|
|
|
const USER_ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
const USER_ENV_RESERVED_PREFIXES = ["OPENWORK_", "OPENCODE_"];
|
|
|
|
// Synchronous, best-effort; absent or malformed returns {}. Reserved prefixes
|
|
// are stripped so a tampered file can never shadow OPENWORK_* / OPENCODE_*.
|
|
function loadUserEnvFile(env = process.env) {
|
|
try {
|
|
const raw = readFileSync(resolveUserEnvFilePath(env), "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || !Array.isArray(parsed.variables)) return {};
|
|
const out = {};
|
|
for (const entry of parsed.variables) {
|
|
if (!entry || typeof entry !== "object") continue;
|
|
const { key, value } = entry;
|
|
if (typeof key !== "string" || typeof value !== "string") continue;
|
|
if (!USER_ENV_KEY_PATTERN.test(key)) continue;
|
|
if (USER_ENV_RESERVED_PREFIXES.some((p) => key.startsWith(p))) continue;
|
|
out[key] = value;
|
|
}
|
|
return out;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} value
|
|
* @returns {value is Record<string, unknown>}
|
|
*/
|
|
function isPlainObject(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
/**
|
|
* Reduces an administrator-provisioned Den control-plane URL to its exact
|
|
* origin. Keep this in sync with apps/server/src/enterprise-den-origin.ts.
|
|
*
|
|
* @param {unknown} rawValue
|
|
* @returns {string | null}
|
|
*/
|
|
function exactEnterpriseOrigin(rawValue) {
|
|
if (typeof rawValue !== "string") return null;
|
|
const raw = rawValue.trim();
|
|
if (!raw || raw.length > 2 * 1024) return null;
|
|
try {
|
|
const url = new URL(raw);
|
|
if (url.protocol !== "https:") return null;
|
|
if (url.username || url.password || url.search || url.hash) return null;
|
|
return url.origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} filePath
|
|
* @returns {Promise<string | null>}
|
|
*/
|
|
async function readActivatedEnterpriseOrigin(filePath) {
|
|
if (typeof filePath !== "string" || !filePath.trim()) return null;
|
|
try {
|
|
const fileStat = await stat(filePath);
|
|
if (!fileStat.isFile() || fileStat.size > MAX_BOOTSTRAP_BYTES) return null;
|
|
const raw = await readFile(filePath, "utf8");
|
|
if (Buffer.byteLength(raw, "utf8") > MAX_BOOTSTRAP_BYTES) return null;
|
|
const data = JSON.parse(raw);
|
|
if (!isPlainObject(data)) return null;
|
|
const activation = isPlainObject(data.enterpriseActivation) ? data.enterpriseActivation : null;
|
|
if (!activation) return null;
|
|
if (typeof activation.activatedAt !== "string" || !activation.activatedAt.trim()) return null;
|
|
return exactEnterpriseOrigin(activation.denBaseUrl);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {Iterable<string>} values
|
|
* @returns {string[]}
|
|
*/
|
|
function normalizeRepairOrigins(values) {
|
|
const seen = new Set();
|
|
const origins = [];
|
|
for (const value of values) {
|
|
const origin = exactEnterpriseOrigin(value);
|
|
if (!origin || seen.has(origin)) continue;
|
|
origins.push(origin);
|
|
seen.add(origin);
|
|
if (origins.length >= MAX_CHAIN_REPAIR_ORIGINS) break;
|
|
}
|
|
return origins;
|
|
}
|
|
|
|
/**
|
|
* @param {string} origin
|
|
* @returns {{ host: string, port: number }}
|
|
*/
|
|
function tlsTargetFromOrigin(origin) {
|
|
const url = new URL(origin);
|
|
return {
|
|
host: url.hostname.replace(/^\[(.*)\]$/, "$1"),
|
|
port: url.port ? Number(url.port) : 443,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} error
|
|
* @returns {string}
|
|
*/
|
|
function errorCode(error) {
|
|
return isPlainObject(error) && typeof error.code === "string" ? error.code : "unknown";
|
|
}
|
|
|
|
/**
|
|
* @param {RuntimeChainRepairSocket} socket
|
|
* @returns {void}
|
|
*/
|
|
function destroySocket(socket) {
|
|
try {
|
|
socket.destroy();
|
|
} catch {
|
|
// Best effort.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @typedef {Object} RuntimeSystemCaTlsModule
|
|
* @property {(type?: string) => string[]} [getCACertificates]
|
|
* @property {(certificates: string[]) => void} [setDefaultCACertificates]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} RuntimeChainRepairFetchResponse
|
|
* @property {boolean} [ok]
|
|
* @property {number} [status]
|
|
* @property {() => Promise<ArrayBuffer>} arrayBuffer
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} RuntimeChainRepairFetchOptions
|
|
* @property {AbortSignal} [signal]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {(input: string, init?: RuntimeChainRepairFetchOptions) => Promise<RuntimeChainRepairFetchResponse>} RuntimeChainRepairFetch
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} RuntimeChainRepairTlsConnectOptions
|
|
* @property {string} host
|
|
* @property {number} port
|
|
* @property {string} servername
|
|
* @property {boolean} rejectUnauthorized
|
|
* @property {number} timeout
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} RuntimeChainRepairPeerCertificate
|
|
* @property {Buffer} [raw]
|
|
* @property {RuntimeChainRepairPeerCertificate} [issuerCertificate]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} RuntimeChainRepairSocket
|
|
* @property {boolean} [authorized]
|
|
* @property {unknown} [authorizationError]
|
|
* @property {(eventName: string, listener: (...args: unknown[]) => void) => RuntimeChainRepairSocket} once
|
|
* @property {(eventName: string, listener: (...args: unknown[]) => void) => RuntimeChainRepairSocket} off
|
|
* @property {(timeout: number) => RuntimeChainRepairSocket} setTimeout
|
|
* @property {() => void} destroy
|
|
* @property {() => X509Certificate | undefined} [getPeerX509Certificate]
|
|
* @property {(detailed?: boolean) => RuntimeChainRepairPeerCertificate | null} [getPeerCertificate]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {(options: RuntimeChainRepairTlsConnectOptions) => RuntimeChainRepairSocket} RuntimeChainRepairTlsConnect
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} RuntimeChainRepairOptions
|
|
* @property {string[]} [origins]
|
|
* @property {RuntimeChainRepairFetch} [fetchImpl]
|
|
* @property {() => string[]} [rootsProvider]
|
|
* @property {RuntimeChainRepairTlsConnect} [tlsConnectImpl]
|
|
* @property {string} [bootstrapPath]
|
|
* @property {boolean} [disabled]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} ResolveSystemCaEnvOptions
|
|
* @property {RuntimeSystemCaTlsModule} [tlsModule]
|
|
* @property {string} userDataDir
|
|
* @property {NodeJS.ProcessEnv} [parentEnv]
|
|
* @property {(...args: unknown[]) => void} [logInfo]
|
|
* @property {() => Promise<string[]>} [loadPlatformCertificates]
|
|
* @property {string} [platformSourceName]
|
|
* @property {NodeJS.Platform} [platform]
|
|
* @property {RuntimeChainRepairOptions} [chainRepair]
|
|
*/
|
|
|
|
/**
|
|
* @param {string} origin
|
|
* @param {RuntimeChainRepairTlsConnect} tlsConnectImpl
|
|
* @param {boolean} rejectUnauthorized
|
|
* @returns {Promise<RuntimeChainRepairSocket>}
|
|
*/
|
|
function connectForChainRepair(origin, tlsConnectImpl, rejectUnauthorized) {
|
|
const target = tlsTargetFromOrigin(origin);
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let socket;
|
|
const finish = (value, isError) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (socket) {
|
|
socket.off("secureConnect", onSecureConnect);
|
|
socket.off("error", onError);
|
|
socket.off("timeout", onTimeout);
|
|
}
|
|
if (isError) reject(value);
|
|
else resolve(value);
|
|
};
|
|
const onSecureConnect = () => finish(socket, false);
|
|
const onError = (error) => {
|
|
if (socket) destroySocket(socket);
|
|
finish(error, true);
|
|
};
|
|
const onTimeout = () => {
|
|
const error = new Error("TLS connection timed out");
|
|
Object.defineProperty(error, "code", { value: "ETIMEDOUT" });
|
|
if (socket) destroySocket(socket);
|
|
finish(error, true);
|
|
};
|
|
|
|
try {
|
|
socket = tlsConnectImpl({
|
|
host: target.host,
|
|
port: target.port,
|
|
servername: target.host,
|
|
rejectUnauthorized,
|
|
timeout: CHAIN_REPAIR_SOCKET_TIMEOUT_MS,
|
|
});
|
|
socket.once("secureConnect", onSecureConnect);
|
|
socket.once("error", onError);
|
|
socket.once("timeout", onTimeout);
|
|
socket.setTimeout(CHAIN_REPAIR_SOCKET_TIMEOUT_MS);
|
|
} catch (error) {
|
|
finish(error, true);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {string} origin
|
|
* @param {RuntimeChainRepairTlsConnect} tlsConnectImpl
|
|
* @returns {Promise<string | null>}
|
|
*/
|
|
async function strictProbeChainRepair(origin, tlsConnectImpl) {
|
|
let socket;
|
|
try {
|
|
socket = await connectForChainRepair(origin, tlsConnectImpl, true);
|
|
return socket.authorized === true ? null : String(socket.authorizationError || "UNAUTHORIZED");
|
|
} catch (error) {
|
|
return errorCode(error);
|
|
} finally {
|
|
if (socket) destroySocket(socket);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {RuntimeChainRepairPeerCertificate | null} peer
|
|
* @returns {boolean}
|
|
*/
|
|
function peerChainIsLeafOnly(peer) {
|
|
if (!peer || typeof peer !== "object") return true;
|
|
const issuer = peer.issuerCertificate;
|
|
if (!issuer || issuer === peer) return true;
|
|
if (peer.raw && issuer.raw && Buffer.compare(Buffer.from(peer.raw), Buffer.from(issuer.raw)) === 0) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param {string} origin
|
|
* @param {RuntimeChainRepairTlsConnect} tlsConnectImpl
|
|
* @returns {Promise<{ leaf: X509Certificate, leafOnly: boolean } | null>}
|
|
*/
|
|
async function introspectLeafCertificate(origin, tlsConnectImpl) {
|
|
let socket;
|
|
try {
|
|
socket = await connectForChainRepair(origin, tlsConnectImpl, false);
|
|
if (typeof socket.getPeerX509Certificate !== "function") return null;
|
|
const leaf = socket.getPeerX509Certificate();
|
|
if (!leaf) return null;
|
|
const peer = typeof socket.getPeerCertificate === "function" ? socket.getPeerCertificate(true) : null;
|
|
return { leaf, leafOnly: peerChainIsLeafOnly(peer) };
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
if (socket) destroySocket(socket);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {X509Certificate} certificate
|
|
* @returns {string[]}
|
|
*/
|
|
function caIssuerUrls(certificate) {
|
|
const urls = [];
|
|
const infoAccess = typeof certificate.infoAccess === "string" ? certificate.infoAccess : "";
|
|
for (const line of infoAccess.split(/\r?\n/)) {
|
|
const match = /^\s*CA Issuers\s*-\s*URI:(.+?)\s*$/i.exec(line);
|
|
if (!match) continue;
|
|
try {
|
|
const url = new URL(match[1].trim());
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") continue;
|
|
urls.push(url.href);
|
|
if (urls.length >= 2) break;
|
|
} catch {
|
|
// Ignore malformed AIA entries.
|
|
}
|
|
}
|
|
return urls;
|
|
}
|
|
|
|
/**
|
|
* @param {ArrayBuffer} bytes
|
|
* @returns {X509Certificate}
|
|
*/
|
|
function certificateFromBody(bytes) {
|
|
const buffer = Buffer.from(bytes);
|
|
try {
|
|
return new X509Certificate(buffer);
|
|
} catch (bufferError) {
|
|
const text = buffer.toString("utf8");
|
|
if (/-----BEGIN CERTIFICATE-----/.test(text)) return new X509Certificate(text);
|
|
throw bufferError;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {X509Certificate} certificate
|
|
* @returns {string}
|
|
*/
|
|
function certificateCommonName(certificate) {
|
|
for (const line of certificate.subject.split(/\r?\n/)) {
|
|
const match = /^CN\s*=\s*(.+)$/.exec(line.trim());
|
|
if (match) return match[1].trim();
|
|
}
|
|
return certificate.subject || "unknown subject";
|
|
}
|
|
|
|
/**
|
|
* @param {string} pem
|
|
* @returns {X509Certificate | null}
|
|
*/
|
|
function cachedRootCertificate(pem) {
|
|
const key = String(pem ?? "").trim();
|
|
if (!key) return null;
|
|
if (chainRepairRootCache.has(key)) return chainRepairRootCache.get(key) ?? null;
|
|
try {
|
|
const certificate = new X509Certificate(key);
|
|
chainRepairRootCache.set(key, certificate);
|
|
return certificate;
|
|
} catch {
|
|
chainRepairRootCache.set(key, null);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {X509Certificate} intermediate
|
|
* @param {() => string[]} rootsProvider
|
|
* @returns {boolean}
|
|
*/
|
|
function intermediateChainsToTrustedRoot(intermediate, rootsProvider) {
|
|
let roots = [];
|
|
try {
|
|
roots = rootsProvider();
|
|
} catch {
|
|
roots = [];
|
|
}
|
|
for (const pem of roots) {
|
|
const root = cachedRootCertificate(pem);
|
|
if (root && intermediate.checkIssued(root) && intermediate.verify(root.publicKey)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param {X509Certificate} leaf
|
|
* @param {X509Certificate} intermediate
|
|
* @param {() => string[]} rootsProvider
|
|
* @returns {string | null}
|
|
*/
|
|
function refusalReason(leaf, intermediate, rootsProvider) {
|
|
if (intermediate.ca !== true) return "fetched certificate is not a CA";
|
|
if (leaf.checkIssued(intermediate) !== true) return "fetched certificate did not issue leaf";
|
|
if (leaf.verify(intermediate.publicKey) !== true) return "leaf signature verification failed";
|
|
if (!intermediateChainsToTrustedRoot(intermediate, rootsProvider)) return "fetched certificate does not chain to a trusted public root";
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param {string} url
|
|
* @param {RuntimeChainRepairFetch} fetchImpl
|
|
* @returns {Promise<X509Certificate | null>}
|
|
*/
|
|
async function fetchIntermediateCertificate(url, fetchImpl) {
|
|
const response = await fetchImpl(url, { signal: AbortSignal.timeout(CHAIN_REPAIR_FETCH_TIMEOUT_MS) });
|
|
if (!response || typeof response.arrayBuffer !== "function") return null;
|
|
if (response.ok === false) return null;
|
|
const bytes = await response.arrayBuffer();
|
|
if (bytes.byteLength > MAX_CHAIN_REPAIR_BODY_BYTES) return null;
|
|
return certificateFromBody(bytes);
|
|
}
|
|
|
|
/**
|
|
* @param {ResolveSystemCaEnvOptions} options
|
|
* @returns {Promise<string[]>}
|
|
*/
|
|
async function resolveChainRepairOrigins(options) {
|
|
const env = options.parentEnv ?? {};
|
|
const chainRepair = options.chainRepair ?? {};
|
|
if (chainRepair.origins) return normalizeRepairOrigins(chainRepair.origins);
|
|
const envOrigins = typeof env.OPENWORK_CHAIN_REPAIR_ORIGINS === "string" ? env.OPENWORK_CHAIN_REPAIR_ORIGINS : "";
|
|
if (envOrigins.trim()) return normalizeRepairOrigins(envOrigins.split(","));
|
|
const bootstrapPath = chainRepair.bootstrapPath ?? desktopBootstrapPath({ env });
|
|
const origin = await readActivatedEnterpriseOrigin(bootstrapPath);
|
|
return origin ? [origin] : [];
|
|
}
|
|
|
|
/**
|
|
* @param {ResolveSystemCaEnvOptions} options
|
|
* @returns {Promise<{ pems: string[], timedOut: boolean }>}
|
|
*/
|
|
async function repairIncompleteChains(options) {
|
|
const env = options.parentEnv ?? {};
|
|
const chainRepair = options.chainRepair ?? {};
|
|
const logInfo = options.logInfo;
|
|
if (chainRepair.disabled === true || String(env.OPENWORK_DISABLE_CHAIN_REPAIR ?? "").trim() === "1") {
|
|
if (typeof logInfo === "function") logInfo("OpenWork runtime: chain repair disabled by OPENWORK_DISABLE_CHAIN_REPAIR.");
|
|
return { pems: [], timedOut: false };
|
|
}
|
|
|
|
const origins = await resolveChainRepairOrigins(options);
|
|
if (origins.length === 0) {
|
|
if (!chainRepair.origins && !String(env.OPENWORK_CHAIN_REPAIR_ORIGINS ?? "").trim() && typeof logInfo === "function") {
|
|
logInfo("OpenWork runtime: chain repair skipped: no activation record.");
|
|
}
|
|
return { pems: [], timedOut: false };
|
|
}
|
|
|
|
const fetchImpl = chainRepair.fetchImpl ?? globalThis.fetch;
|
|
const tlsModule = options.tlsModule ?? tls;
|
|
const tlsConnectImpl = chainRepair.tlsConnectImpl ?? tls.connect;
|
|
const totalTimeoutValue = Number(env.OPENWORK_CHAIN_REPAIR_TIMEOUT_MS);
|
|
const totalTimeoutMs =
|
|
Number.isFinite(totalTimeoutValue) && totalTimeoutValue >= 1000 && totalTimeoutValue <= 120000
|
|
? totalTimeoutValue
|
|
: CHAIN_REPAIR_TOTAL_TIMEOUT_MS;
|
|
const rootsProvider = chainRepair.rootsProvider ?? (() => {
|
|
if (typeof tlsModule?.getCACertificates !== "function") return [];
|
|
const roots = tlsModule.getCACertificates("default");
|
|
return Array.isArray(roots) ? roots : [];
|
|
});
|
|
|
|
if (typeof fetchImpl !== "function") {
|
|
if (typeof logInfo === "function") {
|
|
for (const origin of origins) logInfo(`OpenWork runtime: chain repair skipped for ${origin}: fetch unavailable`);
|
|
}
|
|
return { pems: [], timedOut: false };
|
|
}
|
|
|
|
const run = async () => {
|
|
const pems = [];
|
|
for (const origin of origins) {
|
|
const strictError = await strictProbeChainRepair(origin, tlsConnectImpl);
|
|
if (strictError === null) {
|
|
if (typeof logInfo === "function") logInfo(`OpenWork runtime: chain ok for ${origin}`);
|
|
continue;
|
|
}
|
|
if (strictError !== "UNABLE_TO_VERIFY_LEAF_SIGNATURE") {
|
|
if (typeof logInfo === "function") logInfo(`OpenWork runtime: chain repair skipped for ${origin}: ${strictError}`);
|
|
continue;
|
|
}
|
|
|
|
const leafState = await introspectLeafCertificate(origin, tlsConnectImpl);
|
|
if (!leafState) {
|
|
if (typeof logInfo === "function") logInfo(`OpenWork runtime: chain repair skipped for ${origin}: certificate introspection failed`);
|
|
continue;
|
|
}
|
|
if (!leafState.leafOnly) {
|
|
if (typeof logInfo === "function") logInfo(`OpenWork runtime: chain repair skipped for ${origin}: served chain includes an intermediate`);
|
|
continue;
|
|
}
|
|
|
|
const issuerUrls = caIssuerUrls(leafState.leaf);
|
|
if (issuerUrls.length === 0) {
|
|
if (typeof logInfo === "function") logInfo(`OpenWork runtime: chain repair skipped for ${origin}: no CA Issuers AIA URL`);
|
|
continue;
|
|
}
|
|
|
|
let repaired = false;
|
|
for (const url of issuerUrls) {
|
|
let intermediate;
|
|
try {
|
|
intermediate = await fetchIntermediateCertificate(url, fetchImpl);
|
|
} catch {
|
|
intermediate = null;
|
|
}
|
|
if (!intermediate) continue;
|
|
const reason = refusalReason(leafState.leaf, intermediate, rootsProvider);
|
|
if (reason) {
|
|
if (typeof logInfo === "function") logInfo(`OpenWork runtime: chain repair refused for ${origin}: ${reason}`);
|
|
continue;
|
|
}
|
|
pems.push(intermediate.toString());
|
|
repaired = true;
|
|
if (typeof logInfo === "function") {
|
|
logInfo(`OpenWork runtime: chain repaired for ${origin}: added "${certificateCommonName(intermediate)}"`);
|
|
}
|
|
break;
|
|
}
|
|
if (!repaired && typeof logInfo === "function") {
|
|
logInfo(`OpenWork runtime: chain repair skipped for ${origin}: no usable AIA issuer certificate`);
|
|
}
|
|
}
|
|
return { pems, timedOut: false };
|
|
};
|
|
|
|
let timeoutId;
|
|
const timeout = new Promise((resolve) => {
|
|
timeoutId = setTimeout(() => resolve({ pems: [], timedOut: true }), totalTimeoutMs);
|
|
timeoutId.unref?.();
|
|
});
|
|
try {
|
|
return await Promise.race([run(), timeout]);
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {ResolveSystemCaEnvOptions} options
|
|
* @returns {Promise<{ childEnv: NodeJS.ProcessEnv, trustedCertificates: string[] }>}
|
|
*/
|
|
async function resolveSystemCa({
|
|
tlsModule = tls,
|
|
userDataDir,
|
|
parentEnv = process.env,
|
|
logInfo = console.info,
|
|
loadPlatformCertificates,
|
|
platformSourceName,
|
|
platform = process.platform,
|
|
chainRepair,
|
|
}) {
|
|
const env = parentEnv ?? {};
|
|
if (Object.prototype.hasOwnProperty.call(env, "NODE_EXTRA_CA_CERTS")) {
|
|
if (typeof logInfo === "function") {
|
|
logInfo("OpenWork runtime: NODE_EXTRA_CA_CERTS is already set; skipping system CA bundle export.");
|
|
}
|
|
try {
|
|
const configuredPem = await readFile(String(env.NODE_EXTRA_CA_CERTS), "utf8");
|
|
return { childEnv: {}, trustedCertificates: parsePemCertificates(configuredPem) };
|
|
} catch {
|
|
return { childEnv: {}, trustedCertificates: [] };
|
|
}
|
|
}
|
|
|
|
try {
|
|
const platformLoader = loadPlatformCertificates
|
|
? { name: platformSourceName || "platform-stores", load: loadPlatformCertificates }
|
|
: systemPlatformCertificateLoader(platform);
|
|
const bundle = await resolveSystemCaBundle({
|
|
runtime: () => {
|
|
if (typeof tlsModule?.getCACertificates !== "function") return [];
|
|
const certs = tlsModule.getCACertificates("system");
|
|
return Array.isArray(certs) ? certs : [];
|
|
},
|
|
platform: platformLoader,
|
|
});
|
|
if (typeof logInfo === "function") {
|
|
logInfo(`OpenWork runtime: system CA bundle sources ${summarizeSystemCaSources(bundle.sources)}`);
|
|
}
|
|
let repairedPems = [];
|
|
try {
|
|
const repaired = await repairIncompleteChains({
|
|
tlsModule,
|
|
userDataDir,
|
|
parentEnv: env,
|
|
logInfo,
|
|
loadPlatformCertificates,
|
|
platformSourceName,
|
|
platform,
|
|
chainRepair,
|
|
});
|
|
repairedPems = repaired.pems;
|
|
if (repaired.timedOut && typeof logInfo === "function") {
|
|
logInfo("OpenWork runtime: chain repair skipped: timed out");
|
|
}
|
|
} catch {
|
|
repairedPems = [];
|
|
}
|
|
const certificates = dedupeCertificates([...bundle.certificates, ...repairedPems]);
|
|
if (certificates.length === 0) return { childEnv: {}, trustedCertificates: bundle.certificates };
|
|
if (typeof tlsModule?.getCACertificates === "function" && typeof tlsModule?.setDefaultCACertificates === "function") {
|
|
try {
|
|
const defaultCerts = tlsModule.getCACertificates("default");
|
|
tlsModule.setDefaultCACertificates(dedupeCertificates([...(Array.isArray(defaultCerts) ? defaultCerts : []), ...certificates]));
|
|
} catch {
|
|
// Best-effort only; child processes still receive NODE_EXTRA_CA_CERTS.
|
|
}
|
|
}
|
|
const pem = certificates.join("\n");
|
|
if (!pem) return { childEnv: {}, trustedCertificates: bundle.certificates };
|
|
const bundlePath = path.join(userDataDir, "system-ca-bundle.pem");
|
|
await mkdir(path.dirname(bundlePath), { recursive: true });
|
|
await writeFile(bundlePath, `${pem}\n`, "utf8");
|
|
return {
|
|
childEnv: { NODE_EXTRA_CA_CERTS: bundlePath },
|
|
trustedCertificates: bundle.certificates,
|
|
};
|
|
} catch {
|
|
return { childEnv: {}, trustedCertificates: [] };
|
|
}
|
|
}
|
|
|
|
export async function resolveSystemCaEnv(options) {
|
|
return (await resolveSystemCa(options)).childEnv;
|
|
}
|
|
|
|
/**
|
|
* @param {NodeJS.ProcessEnv} [baseEnv]
|
|
* @param {NodeJS.ProcessEnv} [caEnv]
|
|
* @param {NodeJS.ProcessEnv} [extra]
|
|
* @returns {NodeJS.ProcessEnv}
|
|
*/
|
|
export function mergeSystemCaChildEnv(baseEnv = {}, caEnv = {}, extra = {}) {
|
|
return {
|
|
...baseEnv,
|
|
...(Object.prototype.hasOwnProperty.call(baseEnv, "NODE_EXTRA_CA_CERTS") ? {} : caEnv),
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
export function createRuntimeManager({
|
|
app,
|
|
desktopRoot,
|
|
listLocalWorkspacePaths,
|
|
localManagedMcpVaultKey,
|
|
workspaceMkdir = mkdir,
|
|
workspacePlatform = process.platform,
|
|
}) {
|
|
const inheritedProcessEnv = { ...process.env };
|
|
let injectedUserEnvKeys = new Set();
|
|
const engineState = createEngineState();
|
|
const openworkServerState = createOpenworkServerState();
|
|
// Monotonic across this Electron process. Never reset with the server
|
|
// state: each successful server start must be observable as a new
|
|
// generation even when ports and tokens are reused.
|
|
let openworkServerGenerationCounter = 0;
|
|
|
|
// Serialize engine lifecycle operations. Without this, concurrent renderer
|
|
// invocations of engineStart/engineStop/engineRestart race: each call's
|
|
// stopAllRuntimeChildren kills the previous call's freshly-started server,
|
|
// and the prior call then times out its /health probe.
|
|
/** @type {Promise<unknown>} */
|
|
let runtimeLifecycleQueue = Promise.resolve();
|
|
let lifecycleState = "idle";
|
|
/**
|
|
* Serialize engine lifecycle operations; preserves the wrapped function's
|
|
* return type (untyped, this collapsed runtime-manager inference to
|
|
* Promise<void> and blocked tightening the DesktopCommandMap results).
|
|
* @template T
|
|
* @param {() => Promise<T>} fn
|
|
* @returns {Promise<T>}
|
|
*/
|
|
function withRuntimeLifecycle(fn) {
|
|
const next = runtimeLifecycleQueue.then(fn, fn);
|
|
runtimeLifecycleQueue = next.catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
const userDataDir = app.getPath("userData");
|
|
const sidecarDirs = [
|
|
path.join(desktopRoot, "resources", "sidecars"),
|
|
process.resourcesPath ? path.join(process.resourcesPath, "sidecars") : null,
|
|
path.join(path.dirname(app.getPath("exe")), "sidecars"),
|
|
].filter(Boolean);
|
|
let systemCaPromise = null;
|
|
|
|
function systemCa() {
|
|
systemCaPromise ??= resolveSystemCa({
|
|
tlsModule: tls,
|
|
userDataDir,
|
|
parentEnv: { ...loadUserEnvFile(process.env), ...process.env },
|
|
});
|
|
return systemCaPromise;
|
|
}
|
|
|
|
function openworkServerTokenStorePath() {
|
|
const override = process.env.OPENWORK_SERVER_TOKEN_STORE_PATH?.trim();
|
|
if (override) return path.resolve(override);
|
|
return path.join(userDataDir, "openwork-server-tokens.json");
|
|
}
|
|
|
|
function openworkServerStatePath() {
|
|
const override = process.env.OPENWORK_SERVER_STATE_PATH?.trim();
|
|
if (override) return path.resolve(override);
|
|
return path.join(userDataDir, "openwork-server-state.json");
|
|
}
|
|
|
|
function managedOpencodeWorkdir() {
|
|
return path.join(userDataDir, "managed-opencode-workdir");
|
|
}
|
|
|
|
async function loadTokenStore() {
|
|
const stored = await readJsonFile(openworkServerTokenStorePath(), { version: 1, workspaces: {} });
|
|
const migrated = migrateOpenworkServerTokenStore(stored);
|
|
if (JSON.stringify(stored) !== JSON.stringify(migrated)) {
|
|
await saveTokenStore(migrated);
|
|
}
|
|
return migrated;
|
|
}
|
|
|
|
async function saveTokenStore(store) {
|
|
const filePath = openworkServerTokenStorePath();
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, `${JSON.stringify(store, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function loadPortState() {
|
|
return readJsonFile(openworkServerStatePath(), {
|
|
version: 4,
|
|
workspacePorts: {},
|
|
preferredPort: null,
|
|
});
|
|
}
|
|
|
|
async function savePortState(state) {
|
|
const filePath = openworkServerStatePath();
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
async function loadServerCredentials() {
|
|
const store = await loadTokenStore();
|
|
return store.credentials;
|
|
}
|
|
|
|
async function persistServerOwnerToken(ownerToken) {
|
|
const store = await loadTokenStore();
|
|
store.credentials.ownerToken = ownerToken;
|
|
store.credentials.updatedAt = nowMs();
|
|
await saveTokenStore(store);
|
|
}
|
|
|
|
async function readPreferredOpenworkPort(workspaceKey) {
|
|
const state = await loadPortState();
|
|
const normalized = normalizeWorkspaceKey(workspaceKey, workspacePlatform);
|
|
if (normalized && state.workspacePorts?.[normalized]) {
|
|
return state.workspacePorts[normalized];
|
|
}
|
|
return state.preferredPort ?? null;
|
|
}
|
|
|
|
async function persistPreferredOpenworkPort(workspaceKey, port) {
|
|
const state = await loadPortState();
|
|
const normalized = normalizeWorkspaceKey(workspaceKey, workspacePlatform);
|
|
state.version = 4;
|
|
state.workspacePorts ??= {};
|
|
if (normalized) {
|
|
state.workspacePorts[normalized] = port;
|
|
state.preferredPort = null;
|
|
} else {
|
|
state.preferredPort = port;
|
|
}
|
|
await savePortState(state);
|
|
}
|
|
|
|
async function waitForPortAvailable(host, port, timeoutMs = 2000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (await portAvailable(host, port)) return true;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
}
|
|
return portAvailable(host, port);
|
|
}
|
|
|
|
async function resolveOpenworkPort(host, workspaceKey, currentPort = null) {
|
|
const preferredPort = await readPreferredOpenworkPort(workspaceKey);
|
|
if (currentPort && (await waitForPortAvailable(host, currentPort))) {
|
|
return { port: currentPort, preferredPort };
|
|
}
|
|
if (preferredPort && (await waitForPortAvailable(host, preferredPort))) {
|
|
return { port: preferredPort, preferredPort };
|
|
}
|
|
return { port: await findFreePort(host), preferredPort };
|
|
}
|
|
|
|
async function ensureDevModePaths() {
|
|
const root = path.join(userDataDir, "openwork-dev-data");
|
|
const paths = {
|
|
homeDir: path.join(root, "home"),
|
|
xdgConfigHome: path.join(root, "xdg", "config"),
|
|
xdgDataHome: path.join(root, "xdg", "data"),
|
|
xdgCacheHome: path.join(root, "xdg", "cache"),
|
|
xdgStateHome: path.join(root, "xdg", "state"),
|
|
opencodeConfigDir: path.join(root, "config", "opencode"),
|
|
};
|
|
|
|
for (const dir of Object.values(paths)) {
|
|
await mkdir(dir, { recursive: true });
|
|
}
|
|
await mkdir(path.join(paths.xdgDataHome, "opencode"), { recursive: true });
|
|
return paths;
|
|
}
|
|
|
|
async function buildChildEnv(extra = {}) {
|
|
/** @type {NodeJS.ProcessEnv} */
|
|
// User env is layered first so process.env + any caller overrides always
|
|
// win. See apps/server/src/env-file.ts — all loaders must agree on path +
|
|
// reserved-keys policy.
|
|
const devPaths = process.env.OPENWORK_DEV_MODE === "1" && process.env.OPENWORK_DEV_SHARED_STATE !== "1"
|
|
? await ensureDevModePaths()
|
|
: null;
|
|
const userEnvPathEnv = devPaths
|
|
? {
|
|
...process.env,
|
|
HOME: devPaths.homeDir,
|
|
USERPROFILE: devPaths.homeDir,
|
|
XDG_CONFIG_HOME: devPaths.xdgConfigHome,
|
|
}
|
|
: process.env;
|
|
const userEnv = loadUserEnvFile(userEnvPathEnv);
|
|
injectedUserEnvKeys = reconcileInjectedUserEnv({
|
|
processEnv: process.env,
|
|
inheritedEnv: inheritedProcessEnv,
|
|
userEnv,
|
|
previouslyInjectedKeys: injectedUserEnvKeys,
|
|
});
|
|
const baseEnv = {
|
|
...userEnv,
|
|
...process.env,
|
|
BUN_CONFIG_DNS_RESULT_ORDER: "verbatim",
|
|
};
|
|
const caEnv = Object.prototype.hasOwnProperty.call(baseEnv, "NODE_EXTRA_CA_CERTS") ? {} : (await systemCa()).childEnv;
|
|
// Bun honors Node's NODE_EXTRA_CA_CERTS, so bundled Bun sidecars inherit
|
|
// the exported OS trust store through the same child env variable.
|
|
const env = mergeSystemCaChildEnv(baseEnv, caEnv, extra);
|
|
const pathKey =
|
|
Object.prototype.hasOwnProperty.call(env, "PATH") ||
|
|
!Object.prototype.hasOwnProperty.call(env, "Path")
|
|
? "PATH"
|
|
: "Path";
|
|
const pathEnv = enrichedPath(sidecarDirs, env[pathKey]);
|
|
if (pathEnv) {
|
|
env[pathKey] = pathEnv;
|
|
}
|
|
if (devPaths) {
|
|
env.OPENWORK_DEV_MODE = "1";
|
|
env.HOME = devPaths.homeDir;
|
|
env.USERPROFILE = devPaths.homeDir;
|
|
env.XDG_CONFIG_HOME = devPaths.xdgConfigHome;
|
|
env.XDG_DATA_HOME = devPaths.xdgDataHome;
|
|
env.XDG_CACHE_HOME = devPaths.xdgCacheHome;
|
|
env.XDG_STATE_HOME = devPaths.xdgStateHome;
|
|
env.OPENCODE_CONFIG_DIR = devPaths.opencodeConfigDir;
|
|
env.OPENCODE_TEST_HOME = devPaths.homeDir;
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function resolveBinaryInfo(baseName, extraPaths = []) {
|
|
for (const directory of [...sidecarDirs, ...extraPaths]) {
|
|
for (const fileName of binaryFileNames(baseName)) {
|
|
const candidate = path.join(directory, fileName);
|
|
if (existsSync(candidate)) {
|
|
return { path: candidate, source: "bundled" };
|
|
}
|
|
}
|
|
}
|
|
|
|
const pathEntries = (enrichedPath([], process.env.PATH) ?? "")
|
|
.split(path.delimiter)
|
|
.filter(Boolean);
|
|
for (const entry of pathEntries) {
|
|
for (const fileName of binaryFileNames(baseName)) {
|
|
const candidate = path.join(entry, fileName);
|
|
if (existsSync(candidate)) {
|
|
return { path: candidate, source: "path" };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (baseName === "opencode") {
|
|
for (const candidate of [
|
|
path.join(app.getPath("home"), ".opencode", "bin", process.platform === "win32" ? "opencode.exe" : "opencode"),
|
|
path.join("/opt/homebrew/bin", process.platform === "win32" ? "opencode.exe" : "opencode"),
|
|
path.join("/usr/local/bin", process.platform === "win32" ? "opencode.exe" : "opencode"),
|
|
path.join("/usr/bin", process.platform === "win32" ? "opencode.exe" : "opencode"),
|
|
]) {
|
|
if (existsSync(candidate)) {
|
|
return { path: candidate, source: "known-location" };
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveBinary(baseName, extraPaths = []) {
|
|
return resolveBinaryInfo(baseName, extraPaths)?.path ?? null;
|
|
}
|
|
|
|
function resolveOpencodeBinary(opencodeBinPath) {
|
|
const explicitPath = typeof opencodeBinPath === "string" ? opencodeBinPath.trim() : "";
|
|
return explicitPath ? { path: explicitPath, source: "custom" } : resolveBinaryInfo("opencode");
|
|
}
|
|
|
|
function resolveDockerCandidates() {
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
|
|
for (const key of ["OPENWORK_DOCKER_BIN", "OPENWRK_DOCKER_BIN", "DOCKER_BIN"]) {
|
|
const value = process.env[key]?.trim();
|
|
if (value && !seen.has(value)) {
|
|
seen.add(value);
|
|
candidates.push(value);
|
|
}
|
|
}
|
|
|
|
for (const entry of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
const candidate = path.join(entry, process.platform === "win32" ? "docker.exe" : "docker");
|
|
if (!seen.has(candidate)) {
|
|
seen.add(candidate);
|
|
candidates.push(candidate);
|
|
}
|
|
}
|
|
|
|
for (const candidate of [
|
|
"/opt/homebrew/bin/docker",
|
|
"/usr/local/bin/docker",
|
|
"/Applications/Docker.app/Contents/Resources/bin/docker",
|
|
]) {
|
|
if (!seen.has(candidate)) {
|
|
seen.add(candidate);
|
|
candidates.push(candidate);
|
|
}
|
|
}
|
|
|
|
return candidates.filter((candidate) => existsSync(candidate));
|
|
}
|
|
|
|
function runDockerCommandDetailed(args, timeoutMs = 8000) {
|
|
const tried = [...resolveDockerCandidates(), process.platform === "win32" ? "docker.exe" : "docker"];
|
|
const errors = [];
|
|
|
|
for (const program of tried) {
|
|
try {
|
|
const result = spawnSync(program, args, {
|
|
encoding: "utf8",
|
|
timeout: timeoutMs,
|
|
windowsHide: true,
|
|
});
|
|
return {
|
|
program,
|
|
status: typeof result.status === "number" ? result.status : -1,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
};
|
|
} catch (error) {
|
|
errors.push(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`Failed to run docker: ${errors.join("; ")} (Set OPENWORK_DOCKER_BIN to your docker binary if needed)`,
|
|
);
|
|
}
|
|
|
|
const legacyOpenworkContainerPrefix = `${["openwork", "orchestrator"].join("-")}-`;
|
|
|
|
async function listOpenworkManagedContainers() {
|
|
const result = runDockerCommandDetailed(["ps", "-a", "--format", "{{.Names}}"], 8000);
|
|
if (result.status !== 0) {
|
|
const combined = `${result.stdout.trim()}\n${result.stderr.trim()}`.trim();
|
|
throw new Error(combined || `docker ps -a failed (status ${result.status})`);
|
|
}
|
|
return result.stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((name) => name && (name.startsWith(legacyOpenworkContainerPrefix) || name.startsWith("openwork-dev-") || name.startsWith("openwrk-")))
|
|
.sort();
|
|
}
|
|
|
|
async function runShellCommand(program, args, options = {}) {
|
|
const result = spawnSync(program, args, {
|
|
encoding: "utf8",
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
shell: false,
|
|
windowsHide: true,
|
|
timeout: options.timeoutMs,
|
|
});
|
|
return {
|
|
status: typeof result.status === "number" ? result.status : -1,
|
|
stdout: result.stdout ?? "",
|
|
stderr: result.stderr ?? "",
|
|
};
|
|
}
|
|
|
|
function engineDoctor(options = {}) {
|
|
const resolved = resolveOpencodeBinary(options?.opencodeBinPath);
|
|
if (!resolved?.path) {
|
|
return {
|
|
found: false,
|
|
inPath: false,
|
|
resolvedPath: null,
|
|
resolvedSource: null,
|
|
version: null,
|
|
supportsServe: false,
|
|
notes: ["OpenCode binary not found in bundled sidecars or PATH."],
|
|
serveHelpStatus: null,
|
|
serveHelpStdout: null,
|
|
serveHelpStderr: null,
|
|
};
|
|
}
|
|
|
|
const versionResult = spawnSync(resolved.path, ["--version"], { encoding: "utf8" });
|
|
const helpResult = spawnSync(resolved.path, ["serve", "--help"], { encoding: "utf8" });
|
|
const notes = [`Using ${resolved.source}: ${resolved.path}`];
|
|
if (versionResult.status !== 0) {
|
|
notes.push("OpenCode version probe failed.");
|
|
}
|
|
if (helpResult.status !== 0) {
|
|
notes.push("OpenCode serve --help probe failed.");
|
|
}
|
|
|
|
return {
|
|
found: true,
|
|
inPath: resolved.source === "path",
|
|
resolvedPath: resolved.path,
|
|
resolvedSource: resolved.source,
|
|
version: versionResult.stdout?.trim() || versionResult.stderr?.trim() || null,
|
|
supportsServe: helpResult.status === 0,
|
|
notes,
|
|
serveHelpStatus: typeof helpResult.status === "number" ? helpResult.status : null,
|
|
serveHelpStdout: helpResult.stdout?.trim() || null,
|
|
serveHelpStderr: helpResult.stderr?.trim() || null,
|
|
};
|
|
}
|
|
|
|
async function pinnedOpencodeInstallCommand() {
|
|
const constantsPath = path.resolve(desktopRoot, "../../constants.json");
|
|
const payload = JSON.parse(await readFile(constantsPath, "utf8"));
|
|
const version = String(payload?.opencodeVersion ?? "").trim().replace(/^v/, "");
|
|
if (!version) {
|
|
throw new Error("constants.json is missing opencodeVersion");
|
|
}
|
|
return `curl -fsSL https://opencode.ai/install | bash -s -- --version ${version} --no-modify-path`;
|
|
}
|
|
|
|
function processMatchesSidecar(command) {
|
|
return commandMatchesPackagedSidecar(command, sidecarDirs);
|
|
}
|
|
|
|
function killProcessId(pid, signal = "SIGTERM") {
|
|
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return;
|
|
try {
|
|
process.kill(pid, signal);
|
|
} catch {
|
|
// Process already exited or is not ours.
|
|
}
|
|
}
|
|
|
|
async function cleanupPackagedSidecars() {
|
|
if (!app.isPackaged) return;
|
|
|
|
// Safety net: an unclean Electron quit can orphan sidecars. Packaged builds
|
|
// should always own a fresh runtime per app launch, so remove any leftover
|
|
// sidecars from this app bundle before choosing ports for the new runtime.
|
|
const result = spawnSync("ps", ["-Ao", "pid=,command="], { encoding: "utf8" });
|
|
const rows = String(result.stdout ?? "").split(/\r?\n/);
|
|
const pids = [];
|
|
for (const row of rows) {
|
|
const match = row.match(/^\s*(\d+)\s+(.+)$/);
|
|
if (!match) continue;
|
|
const pid = Number(match[1]);
|
|
const command = match[2] ?? "";
|
|
if (processMatchesSidecar(command)) pids.push(pid);
|
|
}
|
|
for (const pid of pids) killProcessId(pid, "SIGTERM");
|
|
if (pids.length > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
for (const pid of pids) killProcessId(pid, "SIGKILL");
|
|
}
|
|
}
|
|
|
|
async function stopChild(state, options = {}) {
|
|
const child = state.child;
|
|
state.child = null;
|
|
state.childExited = true;
|
|
if (!child || child.exitCode != null || child.killed) return;
|
|
|
|
if (options.requestShutdown) {
|
|
try {
|
|
const shutdownRequested = await options.requestShutdown();
|
|
if (shutdownRequested) {
|
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (child.exitCode == null && !child.killed) {
|
|
child.kill("SIGTERM");
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
if (child.exitCode == null && !child.killed) {
|
|
child.kill("SIGKILL");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function ensureOpencodeConfig(projectDir) {
|
|
const configPath = resolveWorkspaceOpencodeConfigPath(projectDir);
|
|
if (await fileExists(configPath)) return;
|
|
await mkdir(path.dirname(configPath), { recursive: true });
|
|
await writeFile(
|
|
configPath,
|
|
`${JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
async function issueOwnerToken(baseUrl, hostToken) {
|
|
const payload = await fetchJson(
|
|
`${baseUrl.replace(/\/+$/, "")}/tokens`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"X-OpenWork-Host-Token": hostToken,
|
|
},
|
|
body: JSON.stringify({ scope: "owner", label: "OpenWork desktop owner token" }),
|
|
},
|
|
5000,
|
|
);
|
|
const token = typeof payload?.token === "string" ? payload.token.trim() : "";
|
|
return token || null;
|
|
}
|
|
|
|
// In-process server handle. Kept alive across restarts so we can stop it.
|
|
let inProcessServer = null;
|
|
|
|
async function startOpenworkServer(options) {
|
|
// The inner start stops any previous runtime before mutating state, so a
|
|
// throw below always happens with nothing left running.
|
|
try {
|
|
return await startOpenworkServerInner(options);
|
|
} catch (error) {
|
|
resetRuntimeStatesAfterFailedServerStart(openworkServerState, engineState, options);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function startOpenworkServerInner(options) {
|
|
const evalDelayMs = resolveEvalLocalServerDelayMs();
|
|
if (evalDelayMs > 0) {
|
|
await new Promise((resolve) => setTimeout(resolve, evalDelayMs));
|
|
}
|
|
const currentPort = openworkServerState.port;
|
|
// Stop any previously running in-process server
|
|
if (inProcessServer) {
|
|
try { await inProcessServer.stop(); } catch { /* ignore */ }
|
|
inProcessServer = null;
|
|
}
|
|
await stopChild(openworkServerState);
|
|
|
|
const host = options.remoteAccessEnabled ? "0.0.0.0" : "127.0.0.1";
|
|
|
|
const managedOpencode = options.manageOpencode ? resolveOpencodeBinary(options.opencodeBinPath) : null;
|
|
openworkServerState.managedOpencodeBinPath = managedOpencode?.path ?? null;
|
|
openworkServerState.managedOpencodeBinSource = managedOpencode?.source ?? null;
|
|
if (options.manageOpencode) {
|
|
engineState.opencodeBinPath = managedOpencode?.path ?? null;
|
|
engineState.opencodeBinSource = managedOpencode?.source ?? null;
|
|
}
|
|
|
|
// Inject user env vars so the server and managed OpenCode inherit them.
|
|
const serverEnv = await buildChildEnv({});
|
|
Object.assign(process.env, serverEnv);
|
|
|
|
// Once the embedded server has a persisted registry, it is the source of
|
|
// truth. Do not pass Electron's legacy workspace list as CLI workspaces or
|
|
// the server config loader will ignore server.json and lose server-created
|
|
// workspaces after restart.
|
|
const serverConfigPath = resolveOpenworkServerConfigPath(process.env);
|
|
const requestedWorkspacePaths = prioritizeWorkspacePaths("", options.workspacePaths, {
|
|
platform: workspacePlatform,
|
|
});
|
|
const workspacePaths = seedWorkspacePathsForEmbeddedServer(
|
|
requestedWorkspacePaths,
|
|
existsSync(serverConfigPath),
|
|
);
|
|
const activeWorkspace = selectStickyOpenworkPortWorkspace(requestedWorkspacePaths, workspacePaths);
|
|
const portSelection = await resolveOpenworkPort(host, activeWorkspace, currentPort);
|
|
const tokens = await loadServerCredentials();
|
|
|
|
// One call: resolve config, spawn managed OpenCode, start HTTP server.
|
|
// Dev must prefer apps/server/dist; build output also stages a packaged
|
|
// copy under apps/desktop/server for electron-builder.
|
|
const devPath = path.resolve(__runtimeDir, "..", "..", "server", "dist", "embedded.js");
|
|
const packagedPaths = [
|
|
path.resolve(__runtimeDir, "..", "server", "dist", "embedded.js"),
|
|
...(process.resourcesPath ? [path.resolve(process.resourcesPath, "server", "dist", "embedded.js")] : []),
|
|
];
|
|
const candidates = process.env.OPENWORK_DEV_MODE === "1"
|
|
? [devPath, ...packagedPaths]
|
|
: [...packagedPaths, devPath];
|
|
const embeddedPath = candidates.find((candidate) => existsSync(candidate));
|
|
if (!embeddedPath) {
|
|
throw new Error(`Cannot find OpenWork embedded server bundle. Checked: ${candidates.join(", ")}`);
|
|
}
|
|
const { startEmbeddedServer } = await import(embeddedServerImportUrl(embeddedPath));
|
|
// startEmbeddedServer falls back to an OS-assigned port if `port` races
|
|
// into EADDRINUSE (see apps/server/src/serve-node.ts), so the bound port
|
|
// below is authoritative.
|
|
const handle = await startEmbeddedServer({
|
|
host,
|
|
port: portSelection.port,
|
|
corsOrigins: ["*"],
|
|
approvalMode: "auto",
|
|
configPath: serverConfigPath,
|
|
workspaces: workspacePaths,
|
|
token: tokens.clientToken,
|
|
hostToken: tokens.hostToken,
|
|
opencodeBaseUrl: options.opencodeBaseUrl ?? undefined,
|
|
opencodeDirectory: activeWorkspace || undefined,
|
|
manageOpencode: options.manageOpencode === true,
|
|
opencodeBin: managedOpencode?.path ?? undefined,
|
|
opencodeCwd: managedOpencodeWorkdir(),
|
|
localManagedMcpVaultKey,
|
|
});
|
|
inProcessServer = handle;
|
|
openworkServerState.managedOpencodeExecution = handle.managedOpencodeExecution ?? null;
|
|
engineState.managedByServer = Boolean(handle.managedOpencode);
|
|
engineState.managedPid = handle.managedOpencode?.pid ?? null;
|
|
engineState.managedIsAlive = handle.managedOpencode?.isAlive ?? null;
|
|
|
|
const boundPort = handle.port;
|
|
const baseUrl = handle.url;
|
|
|
|
openworkServerState.inProcess = true;
|
|
openworkServerGenerationCounter += 1;
|
|
openworkServerState.generation = openworkServerGenerationCounter;
|
|
openworkServerState.remoteAccessEnabled = options.remoteAccessEnabled;
|
|
openworkServerState.host = host;
|
|
openworkServerState.port = boundPort;
|
|
openworkServerState.baseUrl = baseUrl;
|
|
openworkServerState.clientToken = tokens.clientToken;
|
|
openworkServerState.hostToken = tokens.hostToken;
|
|
|
|
const connectUrls = options.remoteAccessEnabled ? buildConnectUrls(boundPort) : { connectUrl: null, mdnsUrl: null, lanUrl: null };
|
|
openworkServerState.connectUrl = connectUrls.connectUrl;
|
|
openworkServerState.mdnsUrl = connectUrls.mdnsUrl;
|
|
openworkServerState.lanUrl = connectUrls.lanUrl;
|
|
|
|
// No health check needed -- startServer() resolves only after the listener is bound.
|
|
let workspaceList = null;
|
|
let ownerToken = tokens.ownerToken?.trim() || null;
|
|
if (ownerToken) {
|
|
try {
|
|
workspaceList = await fetchJson(`${baseUrl}/workspaces`, {
|
|
headers: { Authorization: `Bearer ${ownerToken}` },
|
|
}, 5000);
|
|
} catch {
|
|
ownerToken = null;
|
|
}
|
|
}
|
|
ownerToken ||= await issueOwnerToken(baseUrl, tokens.hostToken);
|
|
openworkServerState.ownerToken = ownerToken;
|
|
if (ownerToken) {
|
|
await persistServerOwnerToken(ownerToken);
|
|
}
|
|
if (ownerToken) {
|
|
try {
|
|
const list = workspaceList ?? await fetchJson(`${baseUrl}/workspaces`, {
|
|
headers: { Authorization: `Bearer ${ownerToken}` },
|
|
}, 5000);
|
|
const first = Array.isArray(list?.items) ? list.items[0] : undefined;
|
|
const opencode = first?.opencode;
|
|
if (opencode?.baseUrl) {
|
|
engineState.runtime = DIRECT_RUNTIME;
|
|
engineState.projectDir = opencode.directory ?? activeWorkspace ?? null;
|
|
engineState.hostname = new URL(opencode.baseUrl).hostname;
|
|
engineState.port = Number(new URL(opencode.baseUrl).port) || null;
|
|
engineState.baseUrl = opencode.baseUrl;
|
|
engineState.opencodeUsername = opencode.username ?? null;
|
|
engineState.opencodePassword = opencode.password ?? null;
|
|
engineState.execution = handle.managedOpencodeExecution ?? null;
|
|
engineState.child = null;
|
|
engineState.childExited = false;
|
|
}
|
|
} catch (error) {
|
|
appendOutput(openworkServerState, "lastStderr", `OpenWork server workspace probe: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
}
|
|
}
|
|
if (!portSelection.preferredPort || boundPort === portSelection.preferredPort) {
|
|
await persistPreferredOpenworkPort(activeWorkspace, boundPort);
|
|
}
|
|
return snapshotOpenworkServerState(openworkServerState);
|
|
}
|
|
|
|
async function stopAllRuntimeChildren() {
|
|
// Stop the in-process server (and its managed OpenCode child) if running.
|
|
if (inProcessServer) {
|
|
try { await inProcessServer.stop(); } catch { /* ignore */ }
|
|
inProcessServer = null;
|
|
}
|
|
await stopChild(openworkServerState);
|
|
await stopChild(engineState);
|
|
|
|
Object.assign(engineState, createEngineState());
|
|
Object.assign(openworkServerState, createOpenworkServerState());
|
|
}
|
|
|
|
async function prepareFreshRuntime() {
|
|
lifecycleState = "cleaning";
|
|
await stopAllRuntimeChildren();
|
|
await cleanupPackagedSidecars();
|
|
lifecycleState = "idle";
|
|
}
|
|
|
|
function settleAfterWorkspacePreparationFailure() {
|
|
if (snapshotOpenworkServerState(openworkServerState).running) {
|
|
lifecycleState = "healthy";
|
|
return;
|
|
}
|
|
Object.assign(engineState, createEngineState());
|
|
Object.assign(openworkServerState, createOpenworkServerState());
|
|
lifecycleState = "idle";
|
|
}
|
|
|
|
async function ensureOpenwork(options) {
|
|
let openworkServer;
|
|
try {
|
|
openworkServer = await startOpenworkServer({
|
|
workspacePaths: options.workspacePaths,
|
|
opencodeBaseUrl: engineState.baseUrl,
|
|
opencodeUsername: engineState.opencodeUsername,
|
|
opencodePassword: engineState.opencodePassword,
|
|
remoteAccessEnabled: options.remoteAccessEnabled,
|
|
manageOpencode: options.manageOpencode === true,
|
|
opencodeBinPath: options.opencodeBinPath,
|
|
});
|
|
} catch (error) {
|
|
appendOutput(engineState, "lastStderr", `OpenWork server: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
throw error;
|
|
}
|
|
|
|
assertOpenworkServerReady(openworkServer);
|
|
}
|
|
|
|
async function engineStart(projectDir, options = {}) {
|
|
const rawProjectDir = String(projectDir ?? "").trim();
|
|
if (!rawProjectDir) {
|
|
throw new Error("projectDir is required");
|
|
}
|
|
let safeProjectDir;
|
|
try {
|
|
safeProjectDir = normalizeWorkspaceRootPath(rawProjectDir, { platform: workspacePlatform });
|
|
} catch (error) {
|
|
settleAfterWorkspacePreparationFailure();
|
|
throw workspaceInaccessibleError(rawProjectDir, error);
|
|
}
|
|
|
|
// Reuse a healthy server instead of tearing it down. During boot the
|
|
// main process kicks off bootRuntimeForSelectedWorkspace while renderer
|
|
// routes independently call ensureDesktopLocalOpenworkConnection. Both go
|
|
// through this serialized path; without this guard the second call runs
|
|
// prepareFreshRuntime (killing the freshly bound server) and then rebinds
|
|
// the sticky preferred port, racing the not-yet-released socket into
|
|
// EADDRINUSE and leaving the runtime in error -> boot screen.
|
|
// resolveOpenworkServerReuse also spans workspace switches: requesting a
|
|
// different projectDir retargets the running runtime instead of killing
|
|
// the process and every in-flight run with it.
|
|
const reuseDecision = resolveOpenworkServerReuse({
|
|
forceRestart: options.forceRestart,
|
|
inProcess: openworkServerState.inProcess,
|
|
lifecycleState,
|
|
remoteAccessEnabled: openworkServerState.remoteAccessEnabled,
|
|
requestedRemoteAccess: options.openworkRemoteAccess,
|
|
currentProjectDir: engineState.projectDir,
|
|
requestedProjectDir: safeProjectDir,
|
|
platform: workspacePlatform,
|
|
});
|
|
if (reuseDecision.reuse) {
|
|
const existing = snapshotOpenworkServerState(openworkServerState);
|
|
if (existing.running && existing.baseUrl && (existing.ownerToken || existing.clientToken)) {
|
|
if (reuseDecision.retarget) {
|
|
try {
|
|
safeProjectDir = await prepareRuntimeWorkspaceRoot(safeProjectDir, {
|
|
platform: workspacePlatform,
|
|
mkdirImpl: workspaceMkdir,
|
|
ensureConfig: ensureOpencodeConfig,
|
|
});
|
|
} catch (error) {
|
|
settleAfterWorkspacePreparationFailure();
|
|
throw error;
|
|
}
|
|
engineState.projectDir = safeProjectDir;
|
|
await persistPreferredOpenworkPort(safeProjectDir, openworkServerState.port);
|
|
}
|
|
return snapshotEngineState(engineState);
|
|
}
|
|
}
|
|
|
|
lifecycleState = "starting";
|
|
try {
|
|
safeProjectDir = await prepareRuntimeWorkspaceRoot(safeProjectDir, {
|
|
platform: workspacePlatform,
|
|
mkdirImpl: workspaceMkdir,
|
|
ensureConfig: ensureOpencodeConfig,
|
|
});
|
|
} catch (error) {
|
|
settleAfterWorkspacePreparationFailure();
|
|
throw error;
|
|
}
|
|
await prepareFreshRuntime();
|
|
|
|
const workspacePaths = prioritizeWorkspacePaths(safeProjectDir, options.workspacePaths, {
|
|
platform: workspacePlatform,
|
|
});
|
|
const runtime = DIRECT_RUNTIME;
|
|
|
|
try {
|
|
lifecycleState = "starting";
|
|
engineState.runtime = runtime;
|
|
engineState.projectDir = safeProjectDir;
|
|
engineState.child = null;
|
|
engineState.childExited = true;
|
|
|
|
await ensureOpenwork({
|
|
projectDir: safeProjectDir,
|
|
workspacePaths,
|
|
remoteAccessEnabled: options.openworkRemoteAccess === true,
|
|
manageOpencode: true,
|
|
opencodeBinPath: options.opencodeBinPath,
|
|
});
|
|
|
|
lifecycleState = "healthy";
|
|
return snapshotEngineState(engineState);
|
|
} catch (error) {
|
|
lifecycleState = "error";
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function engineStop() {
|
|
lifecycleState = "stopping";
|
|
await stopAllRuntimeChildren();
|
|
lifecycleState = "idle";
|
|
return snapshotEngineState(engineState);
|
|
}
|
|
|
|
async function engineRestart(options = {}) {
|
|
const projectDir = engineState.projectDir;
|
|
if (!projectDir) {
|
|
throw new Error("OpenCode is not configured for a local workspace");
|
|
}
|
|
const openworkRemoteAccess = typeof options.openworkRemoteAccess === "boolean"
|
|
? options.openworkRemoteAccess
|
|
: openworkServerState.remoteAccessEnabled;
|
|
return engineStart(projectDir, {
|
|
runtime: engineState.runtime,
|
|
workspacePaths: [projectDir],
|
|
opencodeEnableExa: options.opencodeEnableExa,
|
|
openworkRemoteAccess,
|
|
forceRestart: true,
|
|
});
|
|
}
|
|
|
|
async function engineInfo() {
|
|
if (inProcessServer?.managedOpencode) {
|
|
engineState.managedPid = inProcessServer.managedOpencode.pid ?? null;
|
|
}
|
|
return { ...snapshotEngineState(engineState), lifecycleState };
|
|
}
|
|
|
|
async function runtimeStatus() {
|
|
return {
|
|
lifecycleState,
|
|
engine: await engineInfo(),
|
|
enginePool: inProcessServer?.managedOpencodePool?.() ?? null,
|
|
openworkServer: snapshotOpenworkServerState(openworkServerState),
|
|
};
|
|
}
|
|
|
|
async function openworkServerInfo() {
|
|
return snapshotOpenworkServerState(openworkServerState);
|
|
}
|
|
|
|
async function openworkServerRestart(options = {}) {
|
|
const workspacePaths = prioritizeWorkspacePaths(engineState.projectDir, await listLocalWorkspacePaths(), {
|
|
platform: workspacePlatform,
|
|
});
|
|
const shouldManageOpencode = Boolean(
|
|
openworkServerState.managedOpencodeBinPath || engineState.opencodeBinPath || !engineState.baseUrl,
|
|
);
|
|
return startOpenworkServer({
|
|
workspacePaths,
|
|
opencodeBaseUrl: shouldManageOpencode ? null : engineState.baseUrl,
|
|
opencodeUsername: shouldManageOpencode ? null : engineState.opencodeUsername,
|
|
opencodePassword: shouldManageOpencode ? null : engineState.opencodePassword,
|
|
remoteAccessEnabled: options.remoteAccessEnabled === true,
|
|
manageOpencode: shouldManageOpencode,
|
|
opencodeBinPath: engineState.opencodeBinPath ?? openworkServerState.managedOpencodeBinPath,
|
|
});
|
|
}
|
|
|
|
async function engineInstall() {
|
|
if (process.platform === "win32") {
|
|
return {
|
|
ok: false,
|
|
status: -1,
|
|
stdout: "",
|
|
stderr:
|
|
"Guided install is not supported on Windows yet. Install the OpenWork-pinned OpenCode version manually, then restart OpenWork.",
|
|
};
|
|
}
|
|
|
|
const installDir = path.join(app.getPath("home"), ".opencode", "bin");
|
|
const command = await pinnedOpencodeInstallCommand();
|
|
const result = await runShellCommand("bash", ["-lc", command], {
|
|
env: { ...(await buildChildEnv()), OPENCODE_INSTALL_DIR: installDir },
|
|
timeoutMs: 180_000,
|
|
});
|
|
return {
|
|
ok: result.status === 0,
|
|
status: result.status,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
};
|
|
}
|
|
|
|
async function opencodeMcpAuth(projectDir, serverName) {
|
|
const safeProjectDir = String(projectDir ?? "").trim();
|
|
const safeServerName = String(serverName ?? "").trim();
|
|
if (!safeProjectDir) {
|
|
throw new Error("project_dir is required");
|
|
}
|
|
if (!safeServerName) {
|
|
throw new Error("server_name is required");
|
|
}
|
|
|
|
const program = resolveBinary("opencode");
|
|
if (!program) {
|
|
throw new Error("Failed to locate opencode.");
|
|
}
|
|
|
|
const result = await runShellCommand(program, ["mcp", "auth", safeServerName], {
|
|
cwd: safeProjectDir,
|
|
env: await buildChildEnv(),
|
|
timeoutMs: 120_000,
|
|
});
|
|
return {
|
|
ok: result.status === 0,
|
|
status: result.status,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
};
|
|
}
|
|
|
|
async function sandboxCleanupOpenworkContainers() {
|
|
const candidates = await listOpenworkManagedContainers().catch((error) => {
|
|
throw error;
|
|
});
|
|
const removed = [];
|
|
const errors = [];
|
|
|
|
for (const name of candidates) {
|
|
try {
|
|
const result = runDockerCommandDetailed(["rm", "-f", name], 20_000);
|
|
if (result.status === 0) {
|
|
removed.push(name);
|
|
} else {
|
|
errors.push(`${name}: exit ${result.status}: ${(result.stdout + "\n" + result.stderr).trim()}`);
|
|
}
|
|
} catch (error) {
|
|
errors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}
|
|
|
|
return { candidates, removed, errors };
|
|
}
|
|
|
|
return {
|
|
systemCaCertificates: async () => (await systemCa()).trustedCertificates,
|
|
engineStart: (projectDir, options) => withRuntimeLifecycle(() => engineStart(projectDir, options)),
|
|
engineStop: () => withRuntimeLifecycle(() => engineStop()),
|
|
engineRestart: (options) => withRuntimeLifecycle(() => engineRestart(options)),
|
|
prepareFreshRuntime: () => withRuntimeLifecycle(() => prepareFreshRuntime()),
|
|
dispose: () => withRuntimeLifecycle(() => stopAllRuntimeChildren()),
|
|
runtimeStatus,
|
|
engineInfo,
|
|
engineDoctor,
|
|
engineInstall,
|
|
openworkServerInfo,
|
|
openworkServerRestart: (options) => withRuntimeLifecycle(() => openworkServerRestart(options)),
|
|
opencodeMcpAuth,
|
|
sandboxCleanupOpenworkContainers,
|
|
};
|
|
}
|