diff --git a/.dockerignore b/.dockerignore index 333d6134f2..d4441fb599 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,14 @@ .git .github .opencode +.codenomad node_modules **/node_modules tmp dist **/dist +artifacts +apps/desktop/src-tauri/target +**/target .env .env.* diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index 47049fee49..b5d766cb49 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -16,6 +16,7 @@ import { } from "./den-session-events"; import { desktopFetch, + desktopFetchViaMain, getDesktopBootstrapConfig as getDesktopBootstrapConfigFromShell, setDesktopBootstrapConfig as setDesktopBootstrapConfigInShell, type DesktopBootstrapConfig as ShellDesktopBootstrapConfig, @@ -111,6 +112,20 @@ export type DenWorkerTokens = { workspaceId: string | null; }; +export type DenStaticWorkerAttachInput = { + name: string; + description?: string | null; + url: string; + clientToken: string; + hostToken: string; + activityToken?: string | null; +}; + +export type DenWorkerLaunchInput = { + name: string; + source?: "manual" | "signup_auto"; +}; + export type DenMcpToken = { token: string; expiresAt: string; @@ -1786,17 +1801,16 @@ async function requestJsonRaw( headers["Content-Type"] = "application/json"; } - const response = await fetchWithTimeout( - resolveFetch(), - url, - { - method: options.method ?? "GET", - headers, - body: options.body === undefined ? undefined : JSON.stringify(options.body), - credentials: "include", - }, - options.timeoutMs ?? DEFAULT_DEN_TIMEOUT_MS, - ); + const requestInit = { + method: options.method ?? "GET", + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + credentials: "include", + } satisfies RequestInit; + const timeoutMs = options.timeoutMs ?? DEFAULT_DEN_TIMEOUT_MS; + const response = isDesktopRuntime() + ? await desktopFetchViaMain(url, requestInit, timeoutMs) + : await fetchWithTimeout(resolveFetch(), url, requestInit, timeoutMs); const text = await response.text(); let json: T | null = null; @@ -1979,6 +1993,31 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string return getWorkers(payload); }, + async createWorker(orgId: string, input: DenWorkerLaunchInput): Promise { + const payload = await requestJson(baseUrls, "/v1/workers", { + method: "POST", + token, + organizationId: orgId, + body: { + name: input.name, + destination: "cloud", + source: input.source ?? "manual", + }, + }); + const workers = getWorkers({ + workers: isRecord(payload) && isRecord(payload.worker) + ? [{ ...payload.worker, instance: isRecord(payload.instance) ? payload.instance : null }] + : isRecord(payload) + ? [payload] + : [], + }); + const worker = workers[0]; + if (!worker) { + throw new DenApiError(500, "invalid_worker_create_payload", "Worker launch response was missing worker details."); + } + return worker; + }, + async mintMcpToken(orgId: string): Promise { const payload = await requestJson(baseUrls, "/v1/mcp/token", { method: "POST", @@ -2007,6 +2046,32 @@ export function createDenClient(options: { baseUrl: string; apiBaseUrl?: string return tokens; }, + async attachStaticWorker(orgId: string, input: DenStaticWorkerAttachInput): Promise { + const payload = await requestJson(baseUrls, "/v1/workers/static-attach", { + method: "POST", + token, + organizationId: orgId, + body: { + name: input.name, + description: input.description ?? undefined, + url: input.url, + clientToken: input.clientToken, + hostToken: input.hostToken, + activityToken: input.activityToken ?? undefined, + }, + }); + const workers = getWorkers({ + workers: isRecord(payload) && isRecord(payload.worker) + ? [{ ...payload.worker, instance: isRecord(payload.instance) ? payload.instance : null }] + : [], + }); + const worker = workers[0]; + if (!worker) { + throw new DenApiError(500, "invalid_worker_attach_payload", "Static worker attach response was missing worker details."); + } + return worker; + }, + async listOrgSkills(orgId: string): Promise { const payload = await requestJson(baseUrls, "/v1/skills", { method: "GET", diff --git a/apps/app/src/react-app/domains/settings/cloud/sections.tsx b/apps/app/src/react-app/domains/settings/cloud/sections.tsx index cf0b0ff90a..d243849b0a 100644 --- a/apps/app/src/react-app/domains/settings/cloud/sections.tsx +++ b/apps/app/src/react-app/domains/settings/cloud/sections.tsx @@ -776,19 +776,23 @@ export function MarketplacePluginsSection({ } export interface CloudWorkersSectionProps { + launchBusy: boolean; openingWorkerId: string | null; workers: CloudWorker[]; workersBusy: boolean; workersError: string | null; + onLaunchWorker: () => void | Promise; onOpenWorker: (workerId: string, workerName: string) => void | Promise; onRefreshWorkers: () => void | Promise; } export function CloudWorkersSection({ + launchBusy, openingWorkerId, workers, workersBusy, workersError, + onLaunchWorker, onOpenWorker, onRefreshWorkers, }: CloudWorkersSectionProps) { @@ -823,6 +827,13 @@ export function CloudWorkersSection({ {t("den.cloud_workers_hint")} + {workersError} : null} {!workersBusy && workers.length === 0 ? ( - {t("den.no_cloud_workers")} + + No cloud workers are visible for this org yet. Launch one here, then open it from this tab. + ) : null} {workers.length > 0 ? ( diff --git a/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx b/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx index e2b08b5207..e7f0eb13d5 100644 --- a/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/cloud-workers-view.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { toast } from "@/components/ui/sonner"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { t } from "@/i18n"; import { useCloudSession } from "@/react-app/domains/settings/cloud/cloud-session-provider"; @@ -13,6 +14,11 @@ export type CloudWorkersViewProps = { connectRemoteWorkspace: (input: { openworkHostUrl?: string | null; openworkToken?: string | null; + openworkClientToken?: string | null; + openworkHostToken?: string | null; + openworkDenBaseUrl?: string | null; + openworkDenOrgId?: string | null; + openworkDenWorkerId?: string | null; directory?: string | null; displayName?: string | null; }) => Promise; @@ -23,11 +29,19 @@ export function CloudWorkersView({ connectRemoteWorkspace, onOpenAccount, }: CloudWorkersViewProps) { - const { activeOrganization: activeOrg, authToken, client, isSignedIn, user } = useCloudSession(); + const { activeOrganization: activeOrg, authToken, baseUrl, client, isSignedIn, user } = useCloudSession(); const [workersBusy, setWorkersBusy] = React.useState(false); + const [launchBusy, setLaunchBusy] = React.useState(false); const [openingWorkerId, setOpeningWorkerId] = React.useState(null); + const [attachBusy, setAttachBusy] = React.useState(false); const [workers, setWorkers] = React.useState([]); const [workersError, setWorkersError] = React.useState(null); + const [staticWorkerForm, setStaticWorkerForm] = React.useState({ + name: "LAN static worker", + url: "", + clientToken: "", + hostToken: "", + }); const activeOrgId = activeOrg?.id ?? ""; const refreshWorkers = React.useCallback( @@ -69,6 +83,29 @@ export function CloudWorkersView({ void refreshWorkers(true); }, [activeOrgId, refreshWorkers, user]); + const launchWorker = React.useCallback(async () => { + if (!activeOrgId) { + setWorkersError(t("den.error_choose_org")); + return; + } + + setLaunchBusy(true); + setWorkersError(null); + try { + const worker = await client.createWorker(activeOrgId, { + name: "OpenWork workspace", + source: "manual", + }); + setWorkers((current) => [worker, ...current.filter((entry) => entry.workerId !== worker.workerId)]); + toast.success(`Launching ${worker.workerName}`); + void refreshWorkers(true); + } catch (error) { + setWorkersError(error instanceof Error ? error.message : "Cloud worker launch failed."); + } finally { + setLaunchBusy(false); + } + }, [activeOrgId, client, refreshWorkers]); + const openWorker = React.useCallback( async (workerId: string, workerName: string) => { if (!activeOrgId) { @@ -82,7 +119,7 @@ export function CloudWorkersView({ try { const tokens = await client.getWorkerTokens(workerId, activeOrgId); const openworkUrl = tokens.openworkUrl?.trim() ?? ""; - const accessToken = tokens.ownerToken?.trim() || tokens.clientToken?.trim() || ""; + const accessToken = tokens.clientToken?.trim() || tokens.ownerToken?.trim() || ""; if (!openworkUrl || !accessToken) { throw new Error(t("den.error_worker_not_ready")); } @@ -90,6 +127,11 @@ export function CloudWorkersView({ const ok = await connectRemoteWorkspace({ openworkHostUrl: openworkUrl, openworkToken: accessToken, + openworkClientToken: tokens.clientToken?.trim() || null, + openworkHostToken: tokens.hostToken?.trim() || null, + openworkDenBaseUrl: baseUrl, + openworkDenOrgId: activeOrgId, + openworkDenWorkerId: workerId, directory: null, displayName: workerName, }); @@ -108,9 +150,47 @@ export function CloudWorkersView({ setOpeningWorkerId(null); } }, - [activeOrgId, client, connectRemoteWorkspace], + [activeOrgId, baseUrl, client, connectRemoteWorkspace], ); + const attachStaticWorker = React.useCallback(async () => { + if (!activeOrgId) { + setWorkersError(t("den.error_choose_org")); + return; + } + + const name = staticWorkerForm.name.trim(); + const url = staticWorkerForm.url.trim(); + const clientToken = staticWorkerForm.clientToken.trim(); + const hostToken = staticWorkerForm.hostToken.trim(); + if (!name || !url || !clientToken || !hostToken) { + setWorkersError("Name, URL, client token, and host token are required to attach a static worker."); + return; + } + + setAttachBusy(true); + setWorkersError(null); + try { + const worker = await client.attachStaticWorker(activeOrgId, { + name, + url, + clientToken, + hostToken, + }); + setWorkers((current) => [worker, ...current.filter((entry) => entry.workerId !== worker.workerId)]); + setStaticWorkerForm((current) => ({ ...current, url: "", clientToken: "", hostToken: "" })); + toast.success(`Attached ${worker.workerName}`); + void refreshWorkers(true); + } catch (error) { + const status = typeof error === "object" && error !== null && "status" in error ? Number((error as { status?: unknown }).status) : null; + setWorkersError(status === 403 + ? "Only organization owners and admins can attach static workers. Ask an operator to register this worker." + : error instanceof Error ? error.message : "Static worker attach failed."); + } finally { + setAttachBusy(false); + } + }, [activeOrgId, client, refreshWorkers, staticWorkerForm]); + if (!isSignedIn) { return ( @@ -130,11 +210,52 @@ export function CloudWorkersView({ return ( + +
+
+
Admin/operator: attach LAN static worker
+
+ Organization owners and admins can register a pre-running OpenWork worker without manual database changes. The URL and tokens must match the worker container environment. +
+
+
+ setStaticWorkerForm((current) => ({ ...current, name: event.currentTarget.value }))} + placeholder="Worker name" + /> + setStaticWorkerForm((current) => ({ ...current, url: event.currentTarget.value }))} + placeholder="http://192.168.1.50:8787" + /> + setStaticWorkerForm((current) => ({ ...current, clientToken: event.currentTarget.value }))} + placeholder="OPENWORK_TOKEN" + type="password" + /> + setStaticWorkerForm((current) => ({ ...current, hostToken: event.currentTarget.value }))} + placeholder="OPENWORK_HOST_TOKEN" + type="password" + /> +
+
+ +
+
+
diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index b073d5d044..95019f0977 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -26,6 +26,8 @@ import { createRuntimeManager } from "./runtime.mjs"; import { registerUpdaterIpc } from "./updater.mjs"; import { exportWorkspaceConfig, importWorkspaceConfig } from "./workspace-archive.mjs"; import { + isDesktopFetchAllowedForDenBootstrap, + isDesktopFetchAllowedForWorkspaces, openworkWorkspaceDisplayName, selectOpenworkWorkspaceForConnection, } from "./remote-workspace.mjs"; @@ -47,6 +49,7 @@ const APP_IDENTIFIER = isDevMode ? DEV_APP_IDENTIFIER : TAURI_APP_IDENTIFIER; const RELEASE_DOWNLOAD_BASE_URL = "https://github.com/different-ai/openwork/releases/latest/download"; const RELEASE_PAGE_URL = "https://github.com/different-ai/openwork/releases/latest"; const DOCS_PAGE_URL = "https://openworklabs.com/docs"; +const PRODUCT_DESKTOP_FETCH_ORIGINS = new Set(["https://api.openai.com", "https://github.com"]); const COMPUTER_USE_HELPER_APP_NAME = "OpenWork Computer Use.app"; const COMPUTER_USE_HELPER_EXECUTABLE = "ComputerUse"; const terminalProcesses = new Map(); @@ -1871,7 +1874,7 @@ async function fetchOpenworkWorkspaceList(hostUrl, token, hostToken) { if (hostAuthToken) headers.set("X-OpenWork-Host-Token", hostAuthToken); try { - const response = await fetch(url, { headers, signal: controller.signal }); + const response = await fetch(url, { headers, redirect: "manual", signal: controller.signal }); if (!response.ok) { throw new Error(`OpenWork workspace discovery failed (${response.status} ${response.statusText || "HTTP error"})`); } @@ -2995,11 +2998,26 @@ async function handleDesktopInvoke(event, command, ...args) { const url = String(args[0] ?? "").trim(); const init = args[1] ?? {}; if (!url) throw new Error("URL is required."); + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error("Desktop fetch only supports HTTP(S) URLs."); + } + const state = await readWorkspaceState(); + const bootstrap = await getDesktopBootstrapConfig(); + if (!isDesktopFetchAllowedForWorkspaces(parsed.toString(), state.workspaces) + && !isDesktopFetchAllowedForDenBootstrap(parsed.toString(), bootstrap) + && !PRODUCT_DESKTOP_FETCH_ORIGINS.has(parsed.origin)) { + throw new Error("Desktop fetch is limited to configured remote workspace origins."); + } const timeoutMs = Number(init.timeoutMs); + const headers = init.headers && typeof init.headers === "object" ? init.headers : undefined; + const method = typeof init.method === "string" ? init.method : "GET"; + const hasBody = typeof init.body === "string"; const response = await fetch(url, { - method: typeof init.method === "string" ? init.method : undefined, - headers: init.headers && typeof init.headers === "object" ? init.headers : undefined, - body: typeof init.body === "string" ? init.body : undefined, + method, + redirect: "manual", + headers, + body: hasBody ? init.body : undefined, signal: Number.isFinite(timeoutMs) && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined, }); return { diff --git a/apps/desktop/electron/remote-workspace.mjs b/apps/desktop/electron/remote-workspace.mjs index bd2e873878..bab340e5e8 100644 --- a/apps/desktop/electron/remote-workspace.mjs +++ b/apps/desktop/electron/remote-workspace.mjs @@ -44,3 +44,66 @@ export function openworkWorkspaceDisplayName(workspace) { null ); } + +function stripOpenworkWorkspaceMount(input) { + const raw = trim(input); + if (!raw) return null; + try { + const url = new URL(raw); + const segments = url.pathname.split("/").filter(Boolean); + const workspaceIndex = segments.indexOf("workspace"); + const legacyIndex = segments.indexOf("w"); + const mountIndex = workspaceIndex >= 0 ? workspaceIndex : legacyIndex; + if (mountIndex >= 0 && segments[mountIndex + 1]) { + const prefix = segments.slice(0, mountIndex).join("/"); + url.pathname = prefix ? `/${prefix}` : "/"; + } + return url.toString().replace(/\/+$/, ""); + } catch { + return raw.replace(/\/(?:workspace|w)\/[^/?#]+.*$/, "").replace(/\/+$/, "") || raw; + } +} + +export function isDesktopFetchAllowedForWorkspaces(url, workspaces) { + let parsed; + try { + parsed = new URL(trim(url)); + } catch { + return false; + } + if (!["http:", "https:"].includes(parsed.protocol)) return false; + + for (const workspace of Array.isArray(workspaces) ? workspaces : []) { + if (workspace?.workspaceType !== "remote") continue; + for (const candidate of [workspace.baseUrl, workspace.openworkHostUrl]) { + const stripped = stripOpenworkWorkspaceMount(candidate); + if (!stripped) continue; + try { + if (new URL(stripped).origin === parsed.origin) return true; + } catch { + // Ignore malformed persisted values; they are not valid fetch targets. + } + } + } + return false; +} + +export function isDesktopFetchAllowedForDenBootstrap(url, bootstrapConfig) { + let parsed; + try { + parsed = new URL(trim(url)); + } catch { + return false; + } + if (!["http:", "https:"].includes(parsed.protocol)) return false; + + for (const candidate of [bootstrapConfig?.baseUrl, bootstrapConfig?.apiBaseUrl]) { + if (!candidate) continue; + try { + if (new URL(trim(candidate)).origin === parsed.origin) return true; + } catch { + // Ignore malformed bootstrap values; they are not valid fetch targets. + } + } + return false; +} diff --git a/apps/desktop/electron/remote-workspace.test.mjs b/apps/desktop/electron/remote-workspace.test.mjs index c7ba555332..4aa1979a48 100644 --- a/apps/desktop/electron/remote-workspace.test.mjs +++ b/apps/desktop/electron/remote-workspace.test.mjs @@ -2,6 +2,8 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { + isDesktopFetchAllowedForDenBootstrap, + isDesktopFetchAllowedForWorkspaces, openworkWorkspaceDisplayName, selectOpenworkWorkspaceForConnection, } from "./remote-workspace.mjs"; @@ -87,6 +89,45 @@ describe("selectOpenworkWorkspaceForConnection", () => { }); }); +describe("isDesktopFetchAllowedForWorkspaces", () => { + const workspaces = [ + { + workspaceType: "remote", + baseUrl: "https://worker.example.com/w/rem_ws_123", + openworkHostUrl: "https://worker.example.com", + }, + { workspaceType: "local", baseUrl: "https://ignored.example.com" }, + ]; + + it("allows configured remote workspace origins", () => { + assert.equal(isDesktopFetchAllowedForWorkspaces("https://worker.example.com/workspaces", workspaces), true); + }); + + it("rejects unconfigured origins and non-HTTP protocols", () => { + assert.equal(isDesktopFetchAllowedForWorkspaces("https://attacker.example.com/workspaces", workspaces), false); + assert.equal(isDesktopFetchAllowedForWorkspaces("file:///etc/passwd", workspaces), false); + }); +}); + +describe("isDesktopFetchAllowedForDenBootstrap", () => { + it("allows configured Den web and API origins before a workspace exists", () => { + const bootstrap = { + baseUrl: "https://den.company.local", + apiBaseUrl: "https://den-api.company.local", + }; + + assert.equal(isDesktopFetchAllowedForDenBootstrap("https://den.company.local/api/auth/get-session", bootstrap), true); + assert.equal(isDesktopFetchAllowedForDenBootstrap("https://den-api.company.local/v1/workers", bootstrap), true); + }); + + it("rejects unconfigured Den origins and non-HTTP protocols", () => { + const bootstrap = { baseUrl: "https://den.company.local", apiBaseUrl: null }; + + assert.equal(isDesktopFetchAllowedForDenBootstrap("https://attacker.example.com/v1/workers", bootstrap), false); + assert.equal(isDesktopFetchAllowedForDenBootstrap("file:///etc/passwd", bootstrap), false); + }); +}); + describe("openworkWorkspaceDisplayName", () => { it("prefers display fields before id", () => { assert.equal( diff --git a/apps/orchestrator/src/cli.ts b/apps/orchestrator/src/cli.ts index 34e1e4bac3..877f60c6e2 100644 --- a/apps/orchestrator/src/cli.ts +++ b/apps/orchestrator/src/cli.ts @@ -3579,10 +3579,30 @@ async function waitForOpencodeHealthy( client: ReturnType, timeoutMs = 10_000, pollMs = 250, + fallbackUrl?: string, + fallbackHeaders?: Record, ) { const start = Date.now(); let lastError: string | null = null; while (Date.now() - start < timeoutMs) { + if (fallbackUrl) { + try { + const response = await fetch(`${fallbackUrl.replace(/\/$/, "")}/health`, { + headers: fallbackHeaders, + }); + if (response.status < 500) { + return { healthy: true, degraded: true, reason: lastError ?? undefined }; + } + lastError = `HTTP ${response.status}`; + } catch (error) { + if (!lastError) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { healthy: true, degraded: true, reason: lastError ?? undefined }; + } + try { const health = unwrap(await client.global.health()); if (health?.healthy) return health; @@ -4866,12 +4886,14 @@ async function verifyOpenworkServer(input: { } if ( input.expectedOpencodeUsername && + opencode?.username && opencode?.username !== input.expectedOpencodeUsername ) { throw new Error("OpenWork server OpenCode username mismatch."); } if ( input.expectedOpencodePassword && + opencode?.password && opencode?.password !== input.expectedOpencodePassword ) { throw new Error("OpenWork server OpenCode password mismatch."); @@ -6104,7 +6126,7 @@ async function runRouterDaemon(args: ParsedArgs) { headers: authHeaders, }); logger.info("Waiting for health", { url: baseUrl }, "opencode"); - await waitForOpencodeHealthy(client); + await waitForOpencodeHealthy(client, undefined, undefined, baseUrl, authHeaders); logger.info("Healthy", { url: baseUrl }, "opencode"); state.opencode = { pid: child.pid ?? 0, @@ -8233,7 +8255,7 @@ async function runStart(args: ParsedArgs) { }); logger.info("Waiting for health", { url: opencodeBaseUrl }, "opencode"); - await waitForOpencodeHealthy(opencodeClient); + await waitForOpencodeHealthy(opencodeClient, undefined, undefined, opencodeBaseUrl, authHeaders); logger.info("Healthy", { url: opencodeBaseUrl }, "opencode"); tui?.updateService("opencode", { status: "healthy" }); diff --git a/ee/apps/den-api/package.json b/ee/apps/den-api/package.json index 830ec53628..23d53a5b7e 100644 --- a/ee/apps/den-api/package.json +++ b/ee/apps/den-api/package.json @@ -9,7 +9,7 @@ "build:email": "pnpm --filter @openwork/email build", "build:den-db": "pnpm --filter @openwork-ee/den-db build", "backfill:desktop-policies": "pnpm run build:den-db && tsx scripts/backfill-desktop-policies.ts", - "seed:demo-org": "pnpm run build:den-db && sh -lc 'DEN_WEB_PORT=${DEN_WEB_PORT:-3005}; OPENWORK_DEV_MODE=${OPENWORK_DEV_MODE:-1} DATABASE_URL=${DATABASE_URL:-mysql://root:password@127.0.0.1:3306/openwork_den} DEN_DB_ENCRYPTION_KEY=${DEN_DB_ENCRYPTION_KEY:-local-dev-db-encryption-key-please-change-1234567890} BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-local-dev-secret-not-for-production-use!!} BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:$DEN_WEB_PORT} tsx scripts/seed-demo-org.ts'", + "seed:demo-org": "pnpm run build:den-db && node --import tsx scripts/seed-demo-org-runner.mjs", "start": "node dist/server.js" }, "dependencies": { @@ -38,12 +38,12 @@ "nanoid": "^5.1.11", "openapi-types": "^12.1.3", "stripe": "^22.1.1", + "tsx": "^4.15.7", "zod": "^4.3.6" }, "devDependencies": { "@types/json-schema": "^7.0.15", "@types/node": "^20.11.30", - "tsx": "^4.15.7", "typescript": "^5.5.4" } } diff --git a/ee/apps/den-api/scripts/seed-demo-org-runner.mjs b/ee/apps/den-api/scripts/seed-demo-org-runner.mjs new file mode 100644 index 0000000000..96037bc953 --- /dev/null +++ b/ee/apps/den-api/scripts/seed-demo-org-runner.mjs @@ -0,0 +1,9 @@ +const denWebPort = process.env.DEN_WEB_PORT?.trim() || "3005" + +process.env.OPENWORK_DEV_MODE ??= "1" +process.env.DATABASE_URL ??= "mysql://root:password@127.0.0.1:3306/openwork_den" +process.env.DEN_DB_ENCRYPTION_KEY ??= "local-dev-db-encryption-key-please-change-1234567890" +process.env.BETTER_AUTH_SECRET ??= "local-dev-secret-not-for-production-use!!" +process.env.BETTER_AUTH_URL ??= `http://localhost:${denWebPort}` + +await import("./seed-demo-org.ts") diff --git a/ee/apps/den-api/scripts/seed-demo-org.ts b/ee/apps/den-api/scripts/seed-demo-org.ts index e611cad669..47d2c0249b 100644 --- a/ee/apps/den-api/scripts/seed-demo-org.ts +++ b/ee/apps/den-api/scripts/seed-demo-org.ts @@ -1085,7 +1085,8 @@ async function main() { log("✓", `done in ${elapsedSeconds}s`) log(" ", `${memberIdsByEmail.size} members · ${teamIdsByName.size} teams · ${seededPlugins} plugins · ${seededObjects} config objects`) console.log() - log("→", `login: ${DEMO_OWNER_EMAIL} / ${DEMO_OWNER_PASSWORD}`) + log("→", `login email: ${DEMO_OWNER_EMAIL}`) + log("→", "login password: use the DEN_DEMO_OWNER_PASSWORD value supplied to this seed run") log("→", "open: /organization or /dashboard") console.log() } diff --git a/ee/apps/den-api/src/db.ts b/ee/apps/den-api/src/db.ts index bf48dfb132..a8fa313c07 100644 --- a/ee/apps/den-api/src/db.ts +++ b/ee/apps/den-api/src/db.ts @@ -1,8 +1,9 @@ import { createDenDb } from "@openwork-ee/den-db" import { env } from "./env.js" -export const { db } = createDenDb({ +export const denDb = createDenDb({ databaseUrl: env.databaseUrl, mode: env.dbMode, planetscale: env.planetscale, }) +export const { client: dbClient, db } = denDb diff --git a/ee/apps/den-api/src/env.ts b/ee/apps/den-api/src/env.ts index 74a4f72acc..4c4547ad5a 100644 --- a/ee/apps/den-api/src/env.ts +++ b/ee/apps/den-api/src/env.ts @@ -35,8 +35,17 @@ const EnvSchema = z.object({ PORT: z.string().optional(), CORS_ORIGINS: z.string().optional(), WORKER_PROXY_PORT: z.string().optional(), - PROVISIONER_MODE: z.enum(["stub", "render", "daytona"]).optional(), + PROVISIONER_MODE: z.enum(["stub", "render", "daytona", "static"]).optional(), WORKER_URL_TEMPLATE: z.string().optional(), + STATIC_WORKER_URLS: z.string().optional(), + STATIC_WORKER_HEALTH_PATH: z.string().optional(), + STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS: z.string().optional(), + STATIC_WORKER_HEALTHCHECK_INTERVAL_MS: z.string().optional(), + STATIC_WORKER_RESERVATION_TTL_MS: z.string().optional(), + STATIC_WORKER_TOKEN_MAP_JSON: z.string().optional(), + STATIC_WORKER_ATTACH_ALLOW_PRIVATE: z.string().optional(), + STATIC_WORKER_ATTACH_ALLOWED_HOSTS: z.string().optional(), + STATIC_WORKER_ATTACH_ALLOWED_CIDRS: z.string().optional(), WORKER_ACTIVITY_BASE_URL: z.string().optional(), OPENWORK_DAYTONA_ENV_PATH: z.string().optional(), RENDER_API_BASE: z.string().optional(), @@ -124,6 +133,17 @@ const EnvSchema = z.object({ } } } + + if (value.PROVISIONER_MODE === "static") { + const staticConfig = parseStaticWorkersEnv(value) + for (const issue of staticConfig.issues) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: issue.message, + path: [issue.path], + }) + } + } }) const parsed = EnvSchema.parse(process.env) @@ -148,6 +168,214 @@ function normalizeOrigin(origin: string) { return value.replace(/\/+$/, "") } +type StaticWorkersEnvInput = { + STATIC_WORKER_URLS?: string + STATIC_WORKER_HEALTH_PATH?: string + STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS?: string + STATIC_WORKER_HEALTHCHECK_INTERVAL_MS?: string + STATIC_WORKER_RESERVATION_TTL_MS?: string + STATIC_WORKER_TOKEN_MAP_JSON?: string + STATIC_WORKER_ATTACH_ALLOW_PRIVATE?: string + STATIC_WORKER_ATTACH_ALLOWED_HOSTS?: string + STATIC_WORKER_ATTACH_ALLOWED_CIDRS?: string +} + +type StaticWorkersEnvIssue = { + path: keyof StaticWorkersEnvInput + message: string +} + +function parsePositiveInteger(value: string | undefined, fallback: number) { + const raw = value?.trim() + if (!raw) { + return fallback + } + const parsedValue = Number(raw) + return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null +} + +function normalizeStaticWorkerUrl(value: string) { + const parsedUrl = new URL(value.trim()) + parsedUrl.hash = "" + parsedUrl.search = "" + parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, "") + const serialized = parsedUrl.toString().replace(/\/+$/, "") + return serialized +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function parseStaticWorkerTokenPair(value: unknown) { + if (!isRecord(value)) { + return null + } + const clientToken = typeof value.clientToken === "string" ? value.clientToken.trim() : "" + const hostToken = typeof value.hostToken === "string" ? value.hostToken.trim() : "" + return clientToken && hostToken ? { clientToken, hostToken } : null +} + +export function parseStaticWorkersEnv(input: StaticWorkersEnvInput) { + const issues: StaticWorkersEnvIssue[] = [] + const urls: string[] = [] + const seenUrls = new Set() + const tokenMap: Record = {} + + for (const rawUrl of splitCsv(input.STATIC_WORKER_URLS)) { + let normalizedUrl: string + try { + const parsedUrl = new URL(rawUrl) + if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") { + issues.push({ + path: "STATIC_WORKER_URLS", + message: "STATIC_WORKER_URLS entries must use http or https URLs", + }) + continue + } + normalizedUrl = normalizeStaticWorkerUrl(rawUrl) + } catch { + issues.push({ + path: "STATIC_WORKER_URLS", + message: "STATIC_WORKER_URLS entries must be valid URLs", + }) + continue + } + + if (seenUrls.has(normalizedUrl)) { + issues.push({ + path: "STATIC_WORKER_URLS", + message: `STATIC_WORKER_URLS contains duplicate URL ${normalizedUrl}`, + }) + continue + } + + seenUrls.add(normalizedUrl) + urls.push(normalizedUrl) + } + + if (urls.length === 0) { + issues.push({ + path: "STATIC_WORKER_URLS", + message: "STATIC_WORKER_URLS is required when PROVISIONER_MODE=static", + }) + } + + const rawTokenMap = optionalString(input.STATIC_WORKER_TOKEN_MAP_JSON) + if (!rawTokenMap) { + issues.push({ + path: "STATIC_WORKER_TOKEN_MAP_JSON", + message: "STATIC_WORKER_TOKEN_MAP_JSON is required when PROVISIONER_MODE=static", + }) + } else { + try { + const parsedTokenMap = JSON.parse(rawTokenMap) as unknown + if (!isRecord(parsedTokenMap)) { + issues.push({ + path: "STATIC_WORKER_TOKEN_MAP_JSON", + message: "STATIC_WORKER_TOKEN_MAP_JSON must be a JSON object keyed by worker URL", + }) + } else { + for (const [rawUrl, rawPair] of Object.entries(parsedTokenMap)) { + let normalizedUrl: string + try { + normalizedUrl = normalizeStaticWorkerUrl(rawUrl) + } catch { + issues.push({ + path: "STATIC_WORKER_TOKEN_MAP_JSON", + message: "STATIC_WORKER_TOKEN_MAP_JSON keys must be valid worker URLs", + }) + continue + } + + const pair = parseStaticWorkerTokenPair(rawPair) + if (!pair) { + issues.push({ + path: "STATIC_WORKER_TOKEN_MAP_JSON", + message: `STATIC_WORKER_TOKEN_MAP_JSON entry for ${normalizedUrl} must include non-empty clientToken and hostToken`, + }) + continue + } + tokenMap[normalizedUrl] = pair + } + + for (const url of urls) { + if (!tokenMap[url]) { + issues.push({ + path: "STATIC_WORKER_TOKEN_MAP_JSON", + message: `STATIC_WORKER_TOKEN_MAP_JSON is missing token pair for ${url}`, + }) + } + } + + const configuredUrls = new Set(urls) + for (const url of Object.keys(tokenMap)) { + if (!configuredUrls.has(url)) { + issues.push({ + path: "STATIC_WORKER_TOKEN_MAP_JSON", + message: `STATIC_WORKER_TOKEN_MAP_JSON contains token pair for unconfigured URL ${url}`, + }) + } + } + } + } catch { + issues.push({ + path: "STATIC_WORKER_TOKEN_MAP_JSON", + message: "STATIC_WORKER_TOKEN_MAP_JSON must be valid JSON", + }) + } + } + + const healthPath = optionalString(input.STATIC_WORKER_HEALTH_PATH) ?? "/health" + if (!healthPath.startsWith("/") || healthPath.startsWith("//") || healthPath.includes("?")) { + issues.push({ + path: "STATIC_WORKER_HEALTH_PATH", + message: "STATIC_WORKER_HEALTH_PATH must be an absolute path such as /health", + }) + } + + const healthcheckTimeoutMs = parsePositiveInteger(input.STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS, 10000) + if (healthcheckTimeoutMs === null) { + issues.push({ + path: "STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS", + message: "STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS must be a positive integer", + }) + } + + const healthcheckIntervalMs = parsePositiveInteger(input.STATIC_WORKER_HEALTHCHECK_INTERVAL_MS, 1000) + if (healthcheckIntervalMs === null) { + issues.push({ + path: "STATIC_WORKER_HEALTHCHECK_INTERVAL_MS", + message: "STATIC_WORKER_HEALTHCHECK_INTERVAL_MS must be a positive integer", + }) + } + + const reservationTtlMs = parsePositiveInteger(input.STATIC_WORKER_RESERVATION_TTL_MS, 300000) + if (reservationTtlMs === null) { + issues.push({ + path: "STATIC_WORKER_RESERVATION_TTL_MS", + message: "STATIC_WORKER_RESERVATION_TTL_MS must be a positive integer", + }) + } + + const allowPrivateAttach = (input.STATIC_WORKER_ATTACH_ALLOW_PRIVATE ?? "false").trim().toLowerCase() === "true" + const attachAllowedHosts = splitCsv(input.STATIC_WORKER_ATTACH_ALLOWED_HOSTS).map((host) => host.toLowerCase()) + const attachAllowedCidrs = splitCsv(input.STATIC_WORKER_ATTACH_ALLOWED_CIDRS) + + return { + urls, + healthPath, + healthcheckTimeoutMs: healthcheckTimeoutMs ?? 10000, + healthcheckIntervalMs: healthcheckIntervalMs ?? 1000, + reservationTtlMs: reservationTtlMs ?? 300000, + tokenMap, + allowPrivateAttach, + attachAllowedHosts, + attachAllowedCidrs, + issues, + } +} + const corsOrigins = splitCsv(parsed.CORS_ORIGINS).map((origin) => normalizeOrigin(origin)) const betterAuthTrustedOrigins = splitCsv(parsed.DEN_BETTER_AUTH_TRUSTED_ORIGINS) .map((origin) => normalizeOrigin(origin)) @@ -160,6 +388,7 @@ const requireEmailVerification = parsed.DEN_REQUIRE_EMAIL_VERIFICATION === undef ? !devMode : parsed.DEN_REQUIRE_EMAIL_VERIFICATION.trim().toLowerCase() !== "false" const port = Number(parsed.PORT ?? "8790") +const staticWorkers = parseStaticWorkersEnv(parsed) const daytonaSandboxPublic = (parsed.DAYTONA_SANDBOX_PUBLIC ?? "false").toLowerCase() === "true" @@ -228,6 +457,17 @@ export const env = { corsOrigins, provisionerMode: parsed.PROVISIONER_MODE ?? "daytona", workerUrlTemplate: parsed.WORKER_URL_TEMPLATE, + staticWorkers: { + urls: staticWorkers.urls, + healthPath: staticWorkers.healthPath, + healthcheckTimeoutMs: staticWorkers.healthcheckTimeoutMs, + healthcheckIntervalMs: staticWorkers.healthcheckIntervalMs, + reservationTtlMs: staticWorkers.reservationTtlMs, + tokenMap: staticWorkers.tokenMap, + allowPrivateAttach: staticWorkers.allowPrivateAttach, + attachAllowedHosts: staticWorkers.attachAllowedHosts, + attachAllowedCidrs: staticWorkers.attachAllowedCidrs, + }, workerActivityBaseUrl: optionalString(parsed.WORKER_ACTIVITY_BASE_URL) ?? parsed.BETTER_AUTH_URL.trim().replace(/\/+$/, ""), diff --git a/ee/apps/den-api/src/routes/workers/core.ts b/ee/apps/den-api/src/routes/workers/core.ts index 2d0be1fe80..906323bbc5 100644 --- a/ee/apps/den-api/src/routes/workers/core.ts +++ b/ee/apps/den-api/src/routes/workers/core.ts @@ -1,17 +1,23 @@ -import { desc, eq } from "@openwork-ee/den-db/drizzle" -import { WorkerTable, WorkerTokenTable } from "@openwork-ee/den-db/schema" +import { and, desc, eq, inArray } from "@openwork-ee/den-db/drizzle" +import { WorkerInstanceTable, WorkerTable, WorkerTokenTable } from "@openwork-ee/den-db/schema" import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" import type { Hono } from "hono" +import type { MiddlewareHandler } from "hono" import { describeRoute } from "hono-openapi" import { z } from "zod" import { db } from "../../db.js" -import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveUserOrganizationsMiddleware } from "../../middleware/index.js" +import { env } from "../../env.js" +import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveOrganizationContextMiddleware, resolveUserOrganizationsMiddleware } from "../../middleware/index.js" import { denTypeIdSchema, emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js" import { getOrganizationLimitStatus } from "../../organization-limits.js" import { getRequiredUserEmail } from "../../user.js" +import { fetchStaticHttpTarget } from "../../workers/static-fetch.js" import type { WorkerRouteVariables } from "./shared.js" import { continueCloudProvisioning, + attachStaticWorkerSchema, + canAttachStaticWorkerForMember, + canReadStaticWorkerTokensForMember, createWorkerSchema, deleteWorkerCascade, getLatestWorkerInstance, @@ -19,11 +25,18 @@ import { getWorkerTokensAndConnect, listWorkersQuerySchema, parseWorkerIdParam, + reserveStaticWorkerForCreatedWorker, requireCloudAccessOrPayment, + shouldUseSignupAutoExistingWorker, toInstanceResponse, toWorkerResponse, token, updateWorkerSchema, + type ValidatedStaticWorkerAttachUrl, + validateResolvedStaticWorkerAttachUrl, + verifyReservedStaticWorker, + withStaticAssignmentLock, + withStaticAssignmentMutex, workerIdParamSchema, } from "./shared.js" @@ -79,6 +92,15 @@ const workerCreateResponseSchema = z.object({ }), }).meta({ ref: "WorkerCreateResponse" }) +const staticWorkerAttachResponseSchema = z.object({ + worker: workerSchema, + instance: workerInstanceSchema, + launch: z.object({ + mode: z.literal("attached"), + pollAfterMs: z.literal(0), + }), +}).meta({ ref: "StaticWorkerAttachResponse" }) + const workerTokensResponseSchema = z.object({ tokens: z.object({ owner: z.string(), @@ -91,6 +113,8 @@ const workerTokensResponseSchema = z.object({ }).nullable(), }).meta({ ref: "WorkerTokensResponse" }) +type WorkerRow = typeof WorkerTable.$inferSelect + const organizationUnavailableSchema = z.object({ error: z.literal("organization_unavailable"), }).meta({ ref: "OrganizationUnavailableError" }) @@ -124,6 +148,310 @@ const workerRuntimeUnavailableSchema = z.object({ message: z.string(), })).meta({ ref: "WorkerConnectionError" }) +async function getExistingSignupAutoWorkerResponse(input: { + orgId: WorkerRow["org_id"] + userId: NonNullable +}) { + const rows = await db + .select() + .from(WorkerTable) + .where(and( + eq(WorkerTable.org_id, input.orgId), + eq(WorkerTable.created_by_user_id, input.userId), + eq(WorkerTable.destination, "cloud"), + inArray(WorkerTable.status, ["provisioning", "healthy"]), + )) + .orderBy(desc(WorkerTable.created_at)) + .limit(1) + + const existingWorker = rows[0] + if (!existingWorker) { + return null + } + + const instance = await getLatestWorkerInstance(existingWorker.id) + const tokensAndConnect = await getWorkerTokensAndConnect(existingWorker) + const tokens = "tokens" in tokensAndConnect ? tokensAndConnect.tokens : undefined + if (!tokens) { + return null + } + + return { + worker: toWorkerResponse(existingWorker, input.userId), + tokens, + instance: toInstanceResponse(instance), + launch: { mode: "existing", pollAfterMs: 0 }, + } +} + +export async function fetchStaticWorker(url: string, path: string, headers: Record) { + return fetch(`${url}${path}`, { + method: "GET", + redirect: "manual", + headers: { Accept: "application/json", ...headers }, + signal: AbortSignal.timeout(env.staticWorkers.healthcheckTimeoutMs), + }) +} + +function formatIpForUrl(address: string) { + return address.includes(":") ? `[${address}]` : address +} + +export async function fetchPinnedStaticWorker(target: ValidatedStaticWorkerAttachUrl, path: string, headers: Record) { + const original = new URL(target.url) + const resolvedAddress = original.protocol === "http:" ? target.resolvedAddresses[0]?.address : undefined + const pinned = resolvedAddress ? new URL(target.url) : original + if (resolvedAddress) { + pinned.hostname = formatIpForUrl(resolvedAddress) + } + const hostHeader = original.port ? `${original.hostname}:${original.port}` : original.hostname + return fetchStaticHttpTarget(`${pinned.toString().replace(/\/+$/, "")}${path}`, { + method: "GET", + redirect: "manual", + headers: { + Accept: "application/json", + ...(resolvedAddress && original.protocol === "http:" ? { Host: hostHeader } : {}), + ...headers, + }, + signal: AbortSignal.timeout(env.staticWorkers.healthcheckTimeoutMs), + }) +} + +export async function assertStaticWorkerReachable(url: string | ValidatedStaticWorkerAttachUrl, clientToken: string, hostToken: string) { + const fetchWorker = typeof url === "string" + ? (path: string, headers: Record) => fetchStaticWorker(url, path, headers) + : (path: string, headers: Record) => fetchPinnedStaticWorker(url, path, headers) + + const clientResponse = await fetchWorker("/workspaces", { + Authorization: `Bearer ${clientToken}`, + }) + + if (!clientResponse.ok) { + throw new Error(`Worker rejected the provided client token with HTTP ${clientResponse.status}`) + } + + const hostResponse = await fetchWorker("/env/keys", { + "X-OpenWork-Host-Token": hostToken, + }) + + if (!hostResponse.ok) { + throw new Error(`Worker rejected the provided host token with HTTP ${hostResponse.status}`) + } +} + +type StaticAttachTx = Pick +type StaticAttachInput = z.infer +type StaticAttachRouteDeps = { + middlewares?: MiddlewareHandler<{ Variables: WorkerRouteVariables }>[] + data?: StaticAttachTx + lookup?: Parameters[2] + fetchReachable?: (url: ValidatedStaticWorkerAttachUrl, clientToken: string, hostToken: string) => Promise + lock?: (run: (tx: StaticAttachTx) => Promise) => Promise + getWorkerLimit?: typeof getOrganizationLimitStatus +} + +async function findActiveStaticWorkerByUrl(tx: Pick, normalizedUrl: string) { + return tx + .select({ id: WorkerInstanceTable.id }) + .from(WorkerInstanceTable) + .where( + and( + eq(WorkerInstanceTable.provider, "static"), + eq(WorkerInstanceTable.url, normalizedUrl), + inArray(WorkerInstanceTable.status, ["provisioning", "healthy"]), + ), + ) + .limit(1) +} + +function staticAttachDuplicateResponse() { + return { + error: "worker_url_already_attached", + message: "This static worker URL is already attached to an active Den worker.", + } +} + +export function registerStaticWorkerAttachRoute(app: Hono<{ Variables: WorkerRouteVariables }>, deps: StaticAttachRouteDeps = {}) { + const routeMiddlewares = deps.middlewares ?? [requireUserMiddleware, resolveOrganizationContextMiddleware, jsonValidator(attachStaticWorkerSchema)] + const data = deps.data ?? db + const fetchReachable = deps.fetchReachable ?? assertStaticWorkerReachable + const lock = deps.lock ?? ((run) => withStaticAssignmentLock(run)) + const getWorkerLimit = deps.getWorkerLimit ?? getOrganizationLimitStatus + + app.post( + "/v1/workers/static-attach", + describeRoute({ + tags: ["Workers"], + summary: "Attach static worker", + description: "Registers a pre-running LAN/OpenWork worker for the active organization using its existing runtime URL and tokens.", + responses: { + 201: jsonResponse("Static worker attached successfully.", staticWorkerAttachResponseSchema), + 400: jsonResponse("The static worker attach payload was invalid.", invalidRequestSchema), + 401: jsonResponse("The caller must be signed in to attach workers.", unauthorizedSchema), + 403: jsonResponse("Only organization owners and admins can attach static workers.", forbiddenSchema), + 409: jsonResponse("The organization has reached its worker limit or the URL is already attached.", orgLimitReachedSchema.or(z.object({ error: z.literal("worker_url_already_attached"), message: z.string() }))), + }, + }), + ...(routeMiddlewares as never[]), + async (c) => { + const user = c.get("user") + const orgId = c.get("activeOrganizationId") + const organizationContext = c.get("organizationContext") + const input = c.req.valid("json" as never) as StaticAttachInput + + if (!user?.id) { + return c.json({ error: "unauthorized" }, 401) + } + + if (!orgId) { + return c.json({ error: "organization_unavailable" }, 400) + } + + const normalizedOrgId = normalizeDenTypeId("organization", orgId) + const normalizedUserId = normalizeDenTypeId("user", user.id) + + if (!organizationContext || !canAttachStaticWorkerForMember(organizationContext)) { + return c.json({ + error: "forbidden", + message: "Only organization owners and admins can attach static workers.", + }, 403) + } + + const validatedUrl = await validateResolvedStaticWorkerAttachUrl(input.url, { + allowPrivate: env.staticWorkers.allowPrivateAttach, + allowedHosts: env.staticWorkers.attachAllowedHosts, + allowedCidrs: env.staticWorkers.attachAllowedCidrs, + }, deps.lookup) + if (!validatedUrl.ok) { + return c.json({ error: "invalid_request", message: validatedUrl.message }, 400) + } + + const normalizedUrl = validatedUrl.url + const existing = await findActiveStaticWorkerByUrl(data, normalizedUrl) + if (existing.length > 0) { + return c.json(staticAttachDuplicateResponse(), 409) + } + + try { + await fetchReachable(validatedUrl, input.clientToken.trim(), input.hostToken.trim()) + } catch (error) { + return c.json({ + error: "invalid_request", + message: "Static worker verification failed with the provided URL and tokens.", + }, 400) + } + + const workerId = createDenTypeId("worker") + const instanceId = createDenTypeId("workerInstance") + const activityToken = input.activityToken?.trim() || token() + const now = new Date() + + const insertResult = await lock(async (tx) => { + const duplicateRows = await findActiveStaticWorkerByUrl(tx, normalizedUrl) + if (duplicateRows.length > 0) { + return { status: "duplicate" as const } + } + + const workerLimit = await getWorkerLimit(normalizedOrgId, "workers") + if (workerLimit.exceeded) { + return { status: "limit" as const, workerLimit } + } + + await tx.insert(WorkerTable).values({ + id: workerId, + org_id: normalizedOrgId, + created_by_user_id: normalizedUserId, + name: input.name, + description: input.description?.trim() || null, + destination: "cloud", + status: "healthy", + image_version: null, + workspace_path: null, + sandbox_backend: "static", + } as never) + + await tx.insert(WorkerTokenTable).values([ + { + id: createDenTypeId("workerToken"), + worker_id: workerId, + scope: "host", + token: input.hostToken.trim(), + }, + { + id: createDenTypeId("workerToken"), + worker_id: workerId, + scope: "client", + token: input.clientToken.trim(), + }, + { + id: createDenTypeId("workerToken"), + worker_id: workerId, + scope: "activity", + token: activityToken, + }, + ] as never) + + await tx.insert(WorkerInstanceTable).values({ + id: instanceId, + worker_id: workerId, + provider: "static", + region: "on-prem", + url: normalizedUrl, + status: "healthy", + } as never) + return { status: "inserted" as const } + }) + + if (insertResult.status === "duplicate") { + return c.json(staticAttachDuplicateResponse(), 409) + } + + if (insertResult.status === "limit") { + return c.json({ + error: "org_limit_reached", + limitType: "workers", + limit: insertResult.workerLimit.limit, + currentCount: insertResult.workerLimit.currentCount, + message: `This workspace currently supports up to ${insertResult.workerLimit.limit} workers. Contact support to increase the limit.`, + }, 409) + } + + return c.json({ + worker: toWorkerResponse( + { + id: workerId, + org_id: normalizedOrgId, + created_by_user_id: normalizedUserId, + name: input.name, + description: input.description?.trim() || null, + destination: "cloud", + status: "healthy", + image_version: null, + workspace_path: null, + sandbox_backend: "static", + last_heartbeat_at: null, + last_active_at: null, + created_at: now, + updated_at: now, + }, + normalizedUserId, + ), + instance: toInstanceResponse({ + id: instanceId, + worker_id: workerId, + provider: "static", + region: "on-prem", + url: normalizedUrl, + status: "healthy", + created_at: now, + updated_at: now, + }), + launch: { mode: "attached", pollAfterMs: 0 }, + }, 201) + }, + ) +} + export function registerWorkerCoreRoutes(app: Hono) { app.get( "/v1/workers", @@ -182,7 +510,7 @@ export function registerWorkerCoreRoutes { + const workerLimit = await getOrganizationLimitStatus(orgId, "workers") + if (workerLimit.exceeded) { + return { response: c.json({ + error: "org_limit_reached", + limitType: "workers", + limit: workerLimit.limit, + currentCount: workerLimit.currentCount, + message: `This workspace currently supports up to ${workerLimit.limit} workers. Contact support to increase the limit.`, + }, 409) } + } + + const workerId = createDenTypeId("worker") + const hostToken = token() + const clientToken = token() + const activityToken = token() + const workerRow: WorkerRow = { + id: workerId, + org_id: orgId, + created_by_user_id: user.id, + name: input.name, + description: input.description ?? null, + destination: input.destination, + status: "provisioning", + image_version: input.imageVersion ?? null, + workspace_path: input.workspacePath ?? null, + sandbox_backend: input.sandboxBackend ?? null, + last_heartbeat_at: null, + last_active_at: null, + created_at: new Date(), + updated_at: new Date(), + } + + try { + await db.insert(WorkerTable).values({ + id: workerId, + org_id: orgId, + created_by_user_id: user.id, + name: input.name, + description: input.description, + destination: input.destination, + status: "provisioning", + image_version: input.imageVersion, + workspace_path: input.workspacePath, + sandbox_backend: input.sandboxBackend, + }) + + await db.insert(WorkerTokenTable).values([ + { id: createDenTypeId("workerToken"), worker_id: workerId, scope: "host", token: hostToken }, + { id: createDenTypeId("workerToken"), worker_id: workerId, scope: "client", token: clientToken }, + { id: createDenTypeId("workerToken"), worker_id: workerId, scope: "activity", token: activityToken }, + ]) + + const reservation = await reserveStaticWorkerForCreatedWorker({ workerId, orgId }) + return { workerId, workerRow, hostToken, clientToken, reservation } + } catch (error) { + await deleteWorkerCascade(workerRow) + throw error + } + }) + + if ("response" in prepared) { + return prepared.response + } + + try { + await verifyReservedStaticWorker({ workerId: prepared.workerId, reservation: prepared.reservation }) + } catch { + await deleteWorkerCascade(prepared.workerRow) + return c.json({ + error: "worker_runtime_unavailable", + message: "Static worker provisioning failed. Check the static worker URL policy, health endpoint, and configured tokens.", + }, 409) + } + + const latestWorkerRows = await db + .select() + .from(WorkerTable) + .where(eq(WorkerTable.id, prepared.workerId)) + .limit(1) + const latestWorker = latestWorkerRows[0] ?? prepared.workerRow + const tokensAndConnect = await getWorkerTokensAndConnect(latestWorker) + const responseTokens = "tokens" in tokensAndConnect + ? tokensAndConnect.tokens + : { owner: prepared.hostToken, host: prepared.hostToken, client: prepared.clientToken } + return c.json({ + worker: toWorkerResponse(latestWorker, user.id), + tokens: responseTokens, + instance: toInstanceResponse(await getLatestWorkerInstance(prepared.workerId)), + launch: { mode: "instant", pollAfterMs: 0 }, + }, 201) + } + const workerLimit = await getOrganizationLimitStatus(orgId, "workers") if (workerLimit.exceeded) { return c.json({ @@ -272,36 +704,66 @@ export function registerWorkerCoreRoutes) + app.get( "/v1/workers/:id", describeRoute({ @@ -431,15 +895,18 @@ export function registerWorkerCoreRoutes { + const user = c.get("user") const orgId = c.get("activeOrganizationId") + const organizationContext = c.get("organizationContext") const params = c.req.valid("param") if (!orgId) { @@ -458,6 +925,15 @@ export function registerWorkerCoreRoutes { + const user = c.get("user") const orgId = c.get("activeOrganizationId") + const organizationContext = c.get("organizationContext") const params = c.req.valid("param") if (!orgId) { @@ -508,6 +987,15 @@ export function registerWorkerCoreRoutes { + const user = c.get("user") const orgId = c.get("activeOrganizationId") + const organizationContext = c.get("organizationContext") const params = c.req.valid("param") if (!orgId) { @@ -45,9 +48,19 @@ export function registerWorkerRuntimeRoutes { + const user = c.get("user") const orgId = c.get("activeOrganizationId") + const organizationContext = c.get("organizationContext") const params = c.req.valid("param") const body = c.req.valid("json") @@ -97,6 +113,15 @@ export function registerWorkerRuntimeRoutes + & Partial type WorkerRow = typeof WorkerTable.$inferSelect type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect export type WorkerId = WorkerRow["id"] type OrgId = typeof MemberTable.$inferSelect.organizationId type UserId = typeof AuthUserTable.$inferSelect.id +type StaticAssignmentDb = Pick +type MySqlLockConnection = { + query: (statement: string, values?: unknown[]) => Promise + release: () => void +} +type MySqlLockPool = { + getConnection: () => Promise +} export const token = () => randomBytes(32).toString("hex") @@ -63,6 +101,286 @@ export function parseWorkerIdParam(value: string): WorkerId { return normalizeDenTypeId("worker", value) } +export function normalizeWorkerRuntimeUrl(value: string): string { + const parsed = new URL(value.trim()) + parsed.hash = "" + parsed.search = "" + parsed.username = "" + parsed.password = "" + parsed.pathname = parsed.pathname.replace(/\/+$/, "") + return parsed.toString().replace(/\/+$/, "") +} + +export type StaticWorkerAttachUrlPolicy = { + allowPrivate: boolean + allowedHosts: readonly string[] + allowedCidrs: readonly string[] +} + +export type DnsLookupAddress = { + address: string + family: 4 | 6 +} + +export type StaticWorkerDnsLookup = (hostname: string) => Promise + +export type ValidatedStaticWorkerAttachUrl = { + ok: true + url: string + resolvedAddresses: DnsLookupAddress[] +} + +export function canAttachStaticWorkerForMember(payload: { currentMember: { isOwner: boolean; role: string } }) { + return payload.currentMember.isOwner || payload.currentMember.role.split(",").map((role) => role.trim()).includes("admin") +} + +export function canReadStaticWorkerTokensForMember(payload: { + worker: Pick + userId: UserId + currentMember?: { isOwner: boolean; role: string } | null +}) { + return payload.worker.created_by_user_id === payload.userId || (payload.currentMember ? canAttachStaticWorkerForMember({ currentMember: payload.currentMember }) : false) +} + +function parseIpv4(value: string) { + const parts = value.split(".") + if (parts.length !== 4) { + return null + } + let result = 0 + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) { + return null + } + const octet = Number(part) + if (octet < 0 || octet > 255) { + return null + } + result = (result << 8) + octet + } + return result >>> 0 +} + +function ipv4InCidr(host: string, cidr: string) { + const [base, prefixRaw] = cidr.split("/") + const ip = parseIpv4(host) + const baseIp = parseIpv4(base ?? "") + const prefix = Number(prefixRaw) + if (ip === null || baseIp === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) { + return false + } + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0 + return (ip & mask) === (baseIp & mask) +} + +function parseIpv6(value: string): bigint | null { + const normalized = value.toLowerCase() + const zoneIndex = normalized.indexOf("%") + const withoutZone = zoneIndex === -1 ? normalized : normalized.slice(0, zoneIndex) + const [headRaw, tailRaw, extra] = withoutZone.split("::") + if (extra !== undefined) { + return null + } + + const parsePart = (part: string) => { + if (!part) { + return [] as number[] + } + const entries = part.split(":") + const words: number[] = [] + for (const entry of entries) { + if (!entry) { + return null + } + if (entry.includes(".")) { + const ipv4 = parseIpv4(entry) + if (ipv4 === null) { + return null + } + words.push((ipv4 >>> 16) & 0xffff, ipv4 & 0xffff) + continue + } + if (!/^[0-9a-f]{1,4}$/.test(entry)) { + return null + } + words.push(Number.parseInt(entry, 16)) + } + return words + } + + const head = parsePart(headRaw ?? "") + const tail = parsePart(tailRaw ?? "") + if (!head || !tail) { + return null + } + + const missing = tailRaw === undefined ? 0 : 8 - head.length - tail.length + if (missing < 0) { + return null + } + const words = [...head, ...Array.from({ length: missing }, () => 0), ...tail] + if (words.length !== 8) { + return null + } + + return words.reduce((result, word) => (result << 16n) + BigInt(word), 0n) +} + +function ipv6InCidr(host: string, cidr: string) { + const [base, prefixRaw] = cidr.split("/") + const ip = parseIpv6(host) + const baseIp = parseIpv6(base ?? "") + const prefix = Number(prefixRaw) + if (ip === null || baseIp === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 128) { + return false + } + const hostBits = 128 - prefix + const mask = prefix === 0 ? 0n : ((1n << 128n) - 1n) ^ ((1n << BigInt(hostBits)) - 1n) + return (ip & mask) === (baseIp & mask) +} + +function ipInCidr(host: string, cidr: string) { + return isIP(host) === 4 ? ipv4InCidr(host, cidr) : isIP(host) === 6 ? ipv6InCidr(host, cidr) : false +} + +function isPrivateIpv4(hostname: string) { + const ip = parseIpv4(hostname) + if (ip === null) { + return false + } + return ipv4InCidr(hostname, "10.0.0.0/8") + || ipv4InCidr(hostname, "172.16.0.0/12") + || ipv4InCidr(hostname, "192.168.0.0/16") + || ipv4InCidr(hostname, "127.0.0.0/8") + || ipv4InCidr(hostname, "169.254.0.0/16") + || ip === 0 +} + +function isUnsafeIpv6(hostname: string) { + const ip = parseIpv6(hostname) + if (ip === null) { + return false + } + return ip === 0n + || ip === 1n + || ipv6InCidr(hostname, "fc00::/7") + || ipv6InCidr(hostname, "fe80::/10") + || ipv6InCidr(hostname, "::ffff:0:0/96") +} + +function isUnsafeAddress(hostname: string) { + return isPrivateIpv4(hostname) || isUnsafeIpv6(hostname) +} + +function isLocalHostname(hostname: string) { + const normalized = hostname.toLowerCase() + return normalized === "localhost" || normalized.endsWith(".local") || normalized.endsWith(".localhost") +} + +function normalizeUrlHostname(hostname: string) { + const normalized = hostname.toLowerCase() + return normalized.startsWith("[") && normalized.endsWith("]") ? normalized.slice(1, -1) : normalized +} + +export function validateStaticWorkerAttachUrl(value: string, policy: StaticWorkerAttachUrlPolicy) { + let parsed: URL + try { + parsed = new URL(value.trim()) + } catch { + return { ok: false as const, message: "Worker URL must be a valid URL." } + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { ok: false as const, message: "Worker URL must use http or https." } + } + if (parsed.username || parsed.password) { + return { ok: false as const, message: "Worker URL must not include credentials." } + } + if (parsed.search || parsed.hash) { + return { ok: false as const, message: "Worker URL must not include query parameters or fragments." } + } + + const hostname = normalizeUrlHostname(parsed.hostname) + const allowedHosts = new Set(policy.allowedHosts.map((host) => host.trim().toLowerCase()).filter(Boolean)) + const hostExplicitlyAllowed = allowedHosts.has(hostname) + const cidrAllowed = policy.allowedCidrs.some((cidr) => ipInCidr(hostname, cidr.trim())) + const cidrPolicyPresent = policy.allowedCidrs.some((cidr) => cidr.trim()) + const privateOrLocal = isUnsafeAddress(hostname) || isLocalHostname(hostname) + + if (privateOrLocal && !policy.allowPrivate && !hostExplicitlyAllowed && !cidrAllowed && !(isLocalHostname(hostname) && cidrPolicyPresent)) { + return { + ok: false as const, + message: "Private and LAN worker URLs require explicit on-prem attach policy or an allowed host/CIDR.", + } + } + + return { ok: true as const, url: normalizeWorkerRuntimeUrl(value), resolvedAddresses: [] as DnsLookupAddress[] } +} + +async function defaultDnsLookup(hostname: string) { + const normalized = normalizeUrlHostname(hostname) + if (isIP(normalized)) { + return [{ address: normalized, family: isIP(normalized) as 4 | 6 }] + } + return dnsLookup(normalized, { all: true }) as Promise +} + +export async function validateResolvedStaticWorkerAttachUrl( + value: string, + policy: StaticWorkerAttachUrlPolicy, + lookup: StaticWorkerDnsLookup = defaultDnsLookup, +) { + const basic = validateStaticWorkerAttachUrl(value, policy) + if (!basic.ok) { + return basic + } + + const parsed = new URL(basic.url) + const hostname = normalizeUrlHostname(parsed.hostname) + const allowedHosts = new Set(policy.allowedHosts.map((host) => host.trim().toLowerCase()).filter(Boolean)) + const hostExplicitlyAllowed = allowedHosts.has(hostname) + + let addresses: DnsLookupAddress[] + try { + addresses = await lookup(hostname) + } catch { + return { ok: false as const, message: "Worker URL hostname could not be resolved." } + } + + if (addresses.length === 0) { + return { ok: false as const, message: "Worker URL hostname could not be resolved." } + } + + if (parsed.protocol === "https:" && !isIP(hostname) && !hostExplicitlyAllowed) { + return { + ok: false as const, + message: "HTTPS static worker attach hostnames must be explicitly allowed by on-prem attach policy.", + } + } + + for (const entry of addresses) { + const address = entry.address.toLowerCase() + const cidrAllowed = policy.allowedCidrs.some((cidr) => ipInCidr(address, cidr.trim())) + if (isUnsafeAddress(address) && !policy.allowPrivate && !cidrAllowed) { + return { + ok: false as const, + message: "Worker URL resolves to a private, loopback, link-local, or metadata address that is not explicitly allowed.", + } + } + } + + const resolvedCidrAllowed = addresses.some((entry) => policy.allowedCidrs.some((cidr) => ipInCidr(entry.address.toLowerCase(), cidr.trim()))) + const hasResolvedAddressOutsideAllowedCidrs = addresses.some((entry) => !policy.allowedCidrs.some((cidr) => ipInCidr(entry.address.toLowerCase(), cidr.trim()))) + if (parsed.protocol === "http:" && !policy.allowPrivate && !hostExplicitlyAllowed && (!resolvedCidrAllowed || hasResolvedAddressOutsideAllowedCidrs)) { + return { + ok: false as const, + message: "HTTP static worker URLs must be explicitly allowed by on-prem attach host or CIDR policy before tokens are verified.", + } + } + + return { ...basic, resolvedAddresses: addresses } +} + export function parseUserId(value: string): UserId { return normalizeDenTypeId("user", value) } @@ -71,9 +389,8 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } -function normalizeUrl(value: string): string { - return value.trim().replace(/\/+$/, "") -} +const normalizeUrl = normalizeWorkerRuntimeUrl +const STATIC_PROVISIONING_LOCK_NAME = "den_static_provisioner_assignment" function parseWorkspaceSelection(payload: unknown): { workspaceId: string; openworkUrl: string } | null { if (!isRecord(payload) || !Array.isArray(payload.items)) { @@ -103,17 +420,24 @@ function parseWorkspaceSelection(payload: unknown): { workspaceId: string; openw } } -async function resolveConnectUrlFromWorker(instanceUrl: string, clientToken: string) { +async function resolveConnectUrlFromWorker(instanceUrl: string, clientToken: string, staticWorker: boolean) { const baseUrl = normalizeUrl(instanceUrl) if (!baseUrl || !clientToken.trim()) { return null } try { - const response = await fetch(`${baseUrl}/workspaces`, { + const staticTarget = staticWorker ? await resolveStaticRuntimeFetchTarget(baseUrl) : null + if (staticWorker && !staticTarget) { + return null + } + const response = await fetchStaticHttpTarget(`${staticTarget?.url ?? baseUrl}/workspaces`, { method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(env.staticWorkers.healthcheckTimeoutMs), headers: { Accept: "application/json", + ...(staticTarget?.headers ?? {}), Authorization: `Bearer ${clientToken.trim()}`, }, }) @@ -180,10 +504,10 @@ export function newerDate(current: Date | null | undefined, candidate: Date | nu return candidate.getTime() > current.getTime() ? candidate : current } -async function resolveConnectUrlFromCandidates(workerId: WorkerId, instanceUrl: string | null, clientToken: string) { +async function resolveConnectUrlFromCandidates(workerId: WorkerId, instanceUrl: string | null, clientToken: string, staticWorker: boolean) { const candidates = getConnectUrlCandidates(workerId, instanceUrl) for (const candidate of candidates) { - const resolved = await resolveConnectUrlFromWorker(candidate, clientToken) + const resolved = await resolveConnectUrlFromWorker(candidate, clientToken, staticWorker) if (resolved) { return resolved } @@ -192,6 +516,15 @@ async function resolveConnectUrlFromCandidates(workerId: WorkerId, instanceUrl: } async function getWorkerRuntimeAccess(workerId: WorkerId) { + const workerRows = await db + .select({ status: WorkerTable.status }) + .from(WorkerTable) + .where(eq(WorkerTable.id, workerId)) + .limit(1) + if (workerRows[0] && workerRows[0].status !== "healthy") { + return null + } + const instance = await getLatestWorkerInstance(workerId) const tokenRows = await db .select() @@ -200,22 +533,54 @@ async function getWorkerRuntimeAccess(workerId: WorkerId) { .orderBy(asc(WorkerTokenTable.created_at)) const hostToken = tokenRows.find((entry) => entry.scope === "host")?.token ?? null - if (!instance?.url || !hostToken) { + const clientToken = tokenRows.find((entry) => entry.scope === "client")?.token ?? null + if (!instance?.url || !hostToken || !clientToken) { return null } return { instance, hostToken, + clientToken, candidates: getConnectUrlCandidates(workerId, instance.url), } } +function formatIpForUrl(address: string) { + return address.includes(":") ? `[${address}]` : address +} + +async function resolveStaticRuntimeFetchTarget(url: string) { + const validated = await validateResolvedStaticWorkerAttachUrl(url, { + allowPrivate: env.staticWorkers.allowPrivateAttach, + allowedHosts: env.staticWorkers.attachAllowedHosts, + allowedCidrs: env.staticWorkers.attachAllowedCidrs, + }) + if (!validated.ok) { + return null + } + + const original = new URL(validated.url) + const resolvedAddress = original.protocol === "http:" ? validated.resolvedAddresses[0]?.address : undefined + if (!resolvedAddress) { + return { url: validated.url, headers: {} as Record } + } + + const pinned = new URL(validated.url) + pinned.hostname = formatIpForUrl(resolvedAddress) + const hostHeader = original.port ? `${original.hostname}:${original.port}` : original.hostname + return { + url: pinned.toString().replace(/\/+$/, ""), + headers: { Host: hostHeader }, + } +} + export async function fetchWorkerRuntimeJson(input: { workerId: WorkerId path: string method?: "GET" | "POST" body?: unknown + auth?: "client" | "host" }) { const access = await getWorkerRuntimeAccess(input.workerId) if (!access) { @@ -234,12 +599,25 @@ export async function fetchWorkerRuntimeJson(input: { for (const candidate of access.candidates) { try { - const response = await fetch(`${normalizeUrl(candidate)}${input.path}`, { + const staticTarget = access.instance.provider === "static" + ? await resolveStaticRuntimeFetchTarget(candidate) + : null + if (access.instance.provider === "static" && !staticTarget) { + lastPayload = { message: "Static worker runtime URL failed attach policy validation." } + continue + } + const baseUrl = staticTarget?.url ?? normalizeUrl(candidate) + const response = await fetchStaticHttpTarget(`${baseUrl}${input.path}`, { method: input.method ?? "GET", + redirect: "manual", + signal: AbortSignal.timeout(env.staticWorkers.healthcheckTimeoutMs), headers: { Accept: "application/json", "Content-Type": "application/json", - "X-OpenWork-Host-Token": access.hostToken, + ...(staticTarget?.headers ?? {}), + ...(input.auth === "client" + ? { Authorization: `Bearer ${access.clientToken}` } + : { "X-OpenWork-Host-Token": access.hostToken }), }, body: input.body === undefined ? undefined : JSON.stringify(input.body), }) @@ -284,6 +662,261 @@ export async function getLatestWorkerInstance(workerId: WorkerId) { return rows[0] ?? null } +async function getUnavailableStaticWorkerUrls() { + if (env.provisionerMode !== "static") { + return [] + } + + const rows = await db + .select({ url: WorkerInstanceTable.url }) + .from(WorkerInstanceTable) + .where( + and( + eq(WorkerInstanceTable.provider, "static"), + inArray(WorkerInstanceTable.status, ["provisioning", "healthy"]), + ), + ) + + return rows.map((row) => normalizeUrl(row.url)).filter(Boolean) +} + +function staticReservationStaleBefore() { + return new Date(Date.now() - env.staticWorkers.reservationTtlMs) +} + +async function markStaleStaticReservationsFailed(tx: StaticAssignmentDb) { + const staleRows = await tx + .select({ workerId: WorkerInstanceTable.worker_id }) + .from(WorkerInstanceTable) + .where( + and( + eq(WorkerInstanceTable.provider, "static"), + eq(WorkerInstanceTable.status, "provisioning"), + sql`${WorkerInstanceTable.updated_at} < ${staticReservationStaleBefore()}`, + ), + ) + const staleWorkerIds = [...new Set(staleRows.map((row) => row.workerId))] + if (staleWorkerIds.length > 0) { + await tx + .delete(WorkerTokenTable) + .where(inArray(WorkerTokenTable.worker_id, staleWorkerIds)) + + await tx + .delete(WorkerInstanceTable) + .where(inArray(WorkerInstanceTable.worker_id, staleWorkerIds)) + + await tx + .delete(WorkerTable) + .where(inArray(WorkerTable.id, staleWorkerIds)) + } + + await tx + .update(WorkerInstanceTable) + .set({ status: "failed" }) + .where( + and( + eq(WorkerInstanceTable.provider, "static"), + eq(WorkerInstanceTable.status, "provisioning"), + sql`${WorkerInstanceTable.updated_at} < ${staticReservationStaleBefore()}`, + ), + ) +} + +export function readMySqlLockAcquired(result: unknown) { + const rows = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : result + if (!Array.isArray(rows)) { + return 0 + } + const first = rows[0] + if (first && typeof first === "object" && "acquired" in first) { + return Number((first as { acquired: unknown }).acquired) + } + return 0 +} + +function getMySqlLockPool(): MySqlLockPool { + if (dbClient && typeof dbClient === "object" && "getConnection" in dbClient && typeof dbClient.getConnection === "function") { + return dbClient as MySqlLockPool + } + throw new Error("Static worker assignment locking requires MySQL DB_MODE") +} + +export async function withStaticAssignmentLockUsing(input: { + pool: MySqlLockPool + transaction: (run: (tx: StaticAssignmentDb) => Promise) => Promise + run: (tx: StaticAssignmentDb) => Promise +}) { + const connection = await input.pool.getConnection() + let lockAcquired = false + + try { + const lockRows = await connection.query(`SELECT GET_LOCK(?, 10) AS acquired`, [STATIC_PROVISIONING_LOCK_NAME]) + const acquired = readMySqlLockAcquired(lockRows) + + if (acquired !== 1) { + throw new Error("Timed out waiting for static worker assignment lock") + } + + lockAcquired = true + return await input.transaction(input.run) + } finally { + try { + if (lockAcquired) { + await connection.query(`SELECT RELEASE_LOCK(?)`, [STATIC_PROVISIONING_LOCK_NAME]) + } + } finally { + connection.release() + } + } +} + +export async function withStaticAssignmentMutex(run: () => Promise) { + const connection = await getMySqlLockPool().getConnection() + let lockAcquired = false + + try { + const lockRows = await connection.query(`SELECT GET_LOCK(?, 10) AS acquired`, [STATIC_PROVISIONING_LOCK_NAME]) + const acquired = readMySqlLockAcquired(lockRows) + + if (acquired !== 1) { + throw new Error("Timed out waiting for static worker assignment lock") + } + + lockAcquired = true + return await run() + } finally { + try { + if (lockAcquired) { + await connection.query(`SELECT RELEASE_LOCK(?)`, [STATIC_PROVISIONING_LOCK_NAME]) + } + } finally { + connection.release() + } + } +} + +export async function withStaticAssignmentLock(run: (tx: StaticAssignmentDb) => Promise) { + return withStaticAssignmentLockUsing({ + pool: getMySqlLockPool(), + transaction: (callback) => db.transaction(callback), + run, + }) +} + +async function reserveStaticWorkerInstance(input: { + workerId: WorkerId + orgId: OrgId + staticLockHeld?: boolean +}) { + const reserve = async (tx: StaticAssignmentDb) => { + await markStaleStaticReservationsFailed(tx) + + const workerLimit = await getOrganizationLimitStatus(input.orgId, "workers") + if (workerLimit.currentCount > workerLimit.limit) { + throw new Error("Organization worker limit exceeded") + } + + const rows = await tx + .select({ url: WorkerInstanceTable.url }) + .from(WorkerInstanceTable) + .where( + and( + eq(WorkerInstanceTable.provider, "static"), + inArray(WorkerInstanceTable.status, ["provisioning", "healthy"]), + ), + ) + + const url = normalizeStaticWorkerUrl(selectStaticWorkerUrlFromPool(input.workerId, { + ...env.staticWorkers, + unavailableUrls: rows.map((row) => row.url), + })) + const tokens = getStaticWorkerTokenPairForUrl(url, env.staticWorkers) + const instanceId = createDenTypeId("workerInstance") + + await tx.insert(WorkerInstanceTable).values({ + id: instanceId, + worker_id: input.workerId, + provider: "static", + region: "on-prem", + url, + status: "provisioning", + }) + + await tx + .update(WorkerTokenTable) + .set({ token: tokens.hostToken }) + .where(and(eq(WorkerTokenTable.worker_id, input.workerId), eq(WorkerTokenTable.scope, "host"))) + + await tx + .update(WorkerTokenTable) + .set({ token: tokens.clientToken }) + .where(and(eq(WorkerTokenTable.worker_id, input.workerId), eq(WorkerTokenTable.scope, "client"))) + + return { instanceId, url, tokens } + } + + return input.staticLockHeld ? db.transaction(reserve) : withStaticAssignmentLock(reserve) +} + +export async function reserveStaticWorkerForCreatedWorker(input: { + workerId: WorkerId + orgId: OrgId +}) { + return reserveStaticWorkerInstance({ workerId: input.workerId, orgId: input.orgId, staticLockHeld: true }) +} + +export async function verifyReservedStaticWorker(input: { + workerId: WorkerId + reservation: Awaited> +}) { + try { + const staticTarget = await resolveStaticRuntimeFetchTarget(input.reservation.url) + if (!staticTarget) { + throw new Error("Static worker URL failed attach policy validation") + } + await checkStaticWorkerHealth(staticTarget, env.staticWorkers) + await verifyStaticWorkerRuntimeAccess(staticTarget, input.reservation.tokens, env.staticWorkers) + + await db.transaction(async (tx) => { + await tx + .update(WorkerTable) + .set({ status: "healthy" }) + .where(eq(WorkerTable.id, input.workerId)) + + await tx + .update(WorkerInstanceTable) + .set({ status: "healthy" }) + .where(eq(WorkerInstanceTable.id, input.reservation.instanceId)) + }) + } catch (error) { + await db.transaction(async (tx) => { + await tx + .update(WorkerTable) + .set({ status: "failed" }) + .where(eq(WorkerTable.id, input.workerId)) + + await tx + .update(WorkerInstanceTable) + .set({ status: "failed" }) + .where(eq(WorkerInstanceTable.id, input.reservation.instanceId)) + }) + throw error + } +} + +async function continueStaticCloudProvisioning(input: { + workerId: WorkerId + name: string + orgId: OrgId + staticLockHeld?: boolean + hostToken: string + clientToken: string + activityToken: string +}) { + const reservation = await reserveStaticWorkerInstance({ workerId: input.workerId, orgId: input.orgId, staticLockHeld: input.staticLockHeld }) + await verifyReservedStaticWorker({ workerId: input.workerId, reservation }) +} + export function toInstanceResponse(instance: WorkerInstanceRow | null) { if (!instance) { return null @@ -322,17 +955,25 @@ export function toWorkerResponse(row: WorkerRow, userId: string) { export async function continueCloudProvisioning(input: { workerId: WorkerId name: string + orgId: OrgId + staticLockHeld?: boolean hostToken: string clientToken: string activityToken: string }) { try { + if (env.provisionerMode === "static") { + await continueStaticCloudProvisioning(input) + return + } + const provisioned = await provisionWorker({ workerId: input.workerId, name: input.name, hostToken: input.hostToken, clientToken: input.clientToken, activityToken: input.activityToken, + unavailableStaticWorkerUrls: await getUnavailableStaticWorkerUrls(), }) await db @@ -356,6 +997,9 @@ export async function continueCloudProvisioning(input: { const message = error instanceof Error ? error.message : "provisioning_failed" console.error(`[workers] provisioning failed for ${input.workerId}: ${message}`) + if (env.provisionerMode === "static") { + throw error + } } } @@ -390,7 +1034,7 @@ export async function getWorkerTokensAndConnect(worker: WorkerRow) { } const instance = await getLatestWorkerInstance(worker.id) - const connect = await resolveConnectUrlFromCandidates(worker.id, instance?.url ?? null, clientToken) + const connect = await resolveConnectUrlFromCandidates(worker.id, instance?.url ?? null, clientToken, instance?.provider === "static") return { tokens: { @@ -405,7 +1049,7 @@ export async function getWorkerTokensAndConnect(worker: WorkerRow) { export async function deleteWorkerCascade(worker: WorkerRow) { const instance = await getLatestWorkerInstance(worker.id) - if (worker.destination === "cloud") { + if (worker.destination === "cloud" && instance?.provider !== "static") { try { await deprovisionWorker({ workerId: worker.id, diff --git a/ee/apps/den-api/src/workers/provisioner.ts b/ee/apps/den-api/src/workers/provisioner.ts index dcf952a34e..11b306317f 100644 --- a/ee/apps/den-api/src/workers/provisioner.ts +++ b/ee/apps/den-api/src/workers/provisioner.ts @@ -8,6 +8,7 @@ import { customDomainForWorker, ensureVercelDnsRecord, } from "./vanity-domain.js" +import { fetchStaticHttpTarget } from "./static-fetch.js" type WorkerId = typeof WorkerTable.$inferSelect.id @@ -17,6 +18,7 @@ export type ProvisionInput = { hostToken: string clientToken: string activityToken: string + unavailableStaticWorkerUrls?: string[] } export type ProvisionedInstance = { @@ -26,6 +28,26 @@ export type ProvisionedInstance = { region?: string } +export type StaticWorkerTokenPair = { + clientToken: string + hostToken: string +} + +export type StaticWorkerConfig = { + urls: string[] + healthPath: string + healthcheckTimeoutMs: number + healthcheckIntervalMs: number + reservationTtlMs?: number + unavailableUrls?: string[] + tokenMap?: Record +} + +type StaticWorkerReservation = { + workerId: string + expiresAt: number +} + type RenderService = { id: string name?: string @@ -54,6 +76,7 @@ const terminalDeployStates = new Set([ ]) const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) +const staticWorkerReservations = new Map() const slug = (value: string) => value @@ -135,25 +158,234 @@ async function waitForDeployLive(serviceId: string) { async function waitForHealth( url: string, timeoutMs = env.render.healthcheckTimeoutMs, + intervalMs = env.render.pollIntervalMs, + healthPath = "/health", + headers: Record = {}, ) { - const healthUrl = `${url.replace(/\/$/, "")}/health` + const normalizedPath = healthPath.startsWith("/") ? healthPath : `/${healthPath}` + const healthUrl = `${url.replace(/\/$/, "")}${normalizedPath}` const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { + const remainingMs = timeoutMs - (Date.now() - startedAt) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), remainingMs) + try { - const response = await fetch(healthUrl, { method: "GET" }) + const response = await fetchStaticHttpTarget(healthUrl, { + method: "GET", + redirect: "manual", + headers, + signal: controller.signal, + }) if (response.ok) { return } } catch { // ignore transient network failures while the instance boots + } finally { + clearTimeout(timeout) + } + + const elapsedMs = Date.now() - startedAt + const sleepMs = Math.min(intervalMs, Math.max(timeoutMs - elapsedMs, 0)) + if (sleepMs > 0) { + await sleep(sleepMs) } - await sleep(env.render.pollIntervalMs) } throw new Error(`Timed out waiting for worker health endpoint ${healthUrl}`) } +export function normalizeStaticWorkerUrl(value: string) { + const parsedUrl = new URL(value.trim()) + parsedUrl.hash = "" + parsedUrl.search = "" + parsedUrl.pathname = parsedUrl.pathname.replace(/\/+$/, "") + return parsedUrl.toString().replace(/\/+$/, "") +} + +function safeNormalizeWorkerUrl(value: string) { + try { + return normalizeStaticWorkerUrl(value) + } catch { + return "" + } +} + +export function getStaticWorkerTokenPairForUrl(url: string, config: StaticWorkerConfig) { + const normalizedUrl = normalizeStaticWorkerUrl(url) + const pair = config.tokenMap?.[normalizedUrl] + if (!pair?.clientToken?.trim() || !pair.hostToken?.trim()) { + throw new Error(`STATIC_WORKER_TOKEN_MAP_JSON is missing token pair for ${normalizedUrl}`) + } + return { + clientToken: pair.clientToken.trim(), + hostToken: pair.hostToken.trim(), + } +} + +export type StaticWorkerRuntimeTarget = string | { url: string; headers: Record } + +function staticWorkerRuntimeTarget(input: StaticWorkerRuntimeTarget) { + return typeof input === "string" ? { url: input, headers: {} } : input +} + +async function fetchStaticWorkerRuntime(targetInput: StaticWorkerRuntimeTarget, path: string, headers: Record, timeoutMs: number) { + const target = staticWorkerRuntimeTarget(targetInput) + return fetchStaticHttpTarget(`${target.url.replace(/\/$/, "")}${path}`, { + method: "GET", + redirect: "manual", + headers: { Accept: "application/json", ...target.headers, ...headers }, + signal: AbortSignal.timeout(timeoutMs), + }) +} + +export async function verifyStaticWorkerRuntimeAccess( + url: StaticWorkerRuntimeTarget, + tokens: StaticWorkerTokenPair, + config: Pick, +) { + const clientResponse = await fetchStaticWorkerRuntime(url, "/workspaces", { + Authorization: `Bearer ${tokens.clientToken}`, + }, config.healthcheckTimeoutMs) + if (!clientResponse.ok) { + throw new Error(`Worker rejected configured static client token with HTTP ${clientResponse.status}`) + } + + const hostResponse = await fetchStaticWorkerRuntime(url, "/env/keys", { + "X-OpenWork-Host-Token": tokens.hostToken, + }, config.healthcheckTimeoutMs) + if (!hostResponse.ok) { + throw new Error(`Worker rejected configured static host token with HTTP ${hostResponse.status}`) + } +} + +function pruneExpiredStaticWorkerReservations(now = Date.now()) { + for (const [url, reservation] of staticWorkerReservations) { + if (reservation.expiresAt <= now) { + staticWorkerReservations.delete(url) + } + } +} + +function selectStaticWorkerUrl(workerId: string, config: StaticWorkerConfig) { + pruneExpiredStaticWorkerReservations() + + return selectStaticWorkerUrlFromPool(workerId, config, true) +} + +export function selectStaticWorkerUrlFromPool( + workerId: string, + config: StaticWorkerConfig, + includeInProcessReservations = false, +) { + if (includeInProcessReservations) { + pruneExpiredStaticWorkerReservations() + } + + const urls = config.urls.map(safeNormalizeWorkerUrl).filter(Boolean) + if (urls.length === 0) { + throw new Error("STATIC_WORKER_URLS is required when PROVISIONER_MODE=static") + } + + const unavailableUrls = new Set( + (config.unavailableUrls ?? []).map(safeNormalizeWorkerUrl).filter(Boolean), + ) + if (includeInProcessReservations) { + for (const [url, reservation] of staticWorkerReservations) { + if (reservation.workerId !== workerId) { + unavailableUrls.add(url) + } + } + } + + const availableUrls = urls.filter((url) => !unavailableUrls.has(url)) + + if (availableUrls.length === 0) { + throw new Error( + "No available static worker URL remains; all configured STATIC_WORKER_URLS are already assigned to active workers", + ) + } + + let hash = 0 + for (const char of workerId) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0 + } + + return availableUrls[hash % availableUrls.length]! +} + +function reserveStaticWorkerUrl(workerId: string, url: string, ttlMs: number) { + if (ttlMs <= 0) { + return + } + staticWorkerReservations.set(url, { + workerId, + expiresAt: Date.now() + ttlMs, + }) +} + +export function releaseStaticWorkerUrl(workerId: string, url: string) { + const reservation = staticWorkerReservations.get(url) + if (reservation?.workerId === workerId) { + staticWorkerReservations.delete(url) + } +} + +export async function provisionStaticWorker( + input: ProvisionInput, + config: StaticWorkerConfig = env.staticWorkers, +): Promise { + const reservationTtlMs = config.reservationTtlMs ?? 300000 + const url = selectStaticWorkerUrl(input.workerId, { + ...config, + unavailableUrls: [ + ...(config.unavailableUrls ?? []), + ...(input.unavailableStaticWorkerUrls ?? []), + ], + }) + reserveStaticWorkerUrl(input.workerId, url, reservationTtlMs) + + try { + await waitForHealth( + url, + config.healthcheckTimeoutMs, + config.healthcheckIntervalMs, + config.healthPath, + ) + const tokens = config.tokenMap?.[url] + if (tokens) { + await verifyStaticWorkerRuntimeAccess(url, tokens, config) + } + } catch (error) { + releaseStaticWorkerUrl(input.workerId, url) + throw error + } + + if (reservationTtlMs <= 0) { + releaseStaticWorkerUrl(input.workerId, url) + } + + return { + provider: "static", + url, + status: "healthy", + region: "on-prem", + } +} + +export async function checkStaticWorkerHealth(url: StaticWorkerRuntimeTarget, config: StaticWorkerConfig) { + const target = staticWorkerRuntimeTarget(url) + await waitForHealth( + target.url, + config.healthcheckTimeoutMs, + config.healthcheckIntervalMs, + config.healthPath, + target.headers, + ) +} + async function listRenderServices(limit = 200) { const rows: RenderService[] = [] let cursor: string | undefined @@ -343,6 +575,10 @@ export async function provisionWorker( return provisionWorkerOnDaytona(input) } + if (env.provisionerMode === "static") { + return provisionStaticWorker(input) + } + const template = env.workerUrlTemplate ?? "https://workers.local/{workerId}" const url = template.replace("{workerId}", input.workerId) return { @@ -356,6 +592,13 @@ export async function deprovisionWorker(input: { workerId: WorkerId instanceUrl: string | null }) { + if (env.provisionerMode === "static") { + if (input.instanceUrl) { + releaseStaticWorkerUrl(input.workerId, safeNormalizeWorkerUrl(input.instanceUrl)) + } + return + } + if (env.provisionerMode === "daytona") { await deprovisionWorkerOnDaytona(input.workerId) return diff --git a/ee/apps/den-api/src/workers/static-fetch.ts b/ee/apps/den-api/src/workers/static-fetch.ts new file mode 100644 index 0000000000..df1112107d --- /dev/null +++ b/ee/apps/den-api/src/workers/static-fetch.ts @@ -0,0 +1,64 @@ +import { request as httpRequest } from "node:http" + +function headersToRecord(headers: HeadersInit | undefined) { + return Object.fromEntries(new Headers(headers).entries()) +} + +function responseHeadersToEntries(headers: Record) { + const entries: [string, string][] = [] + for (const [key, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + for (const item of value) entries.push([key, item]) + } else if (value !== undefined) { + entries.push([key, value]) + } + } + return entries +} + +function concatChunks(chunks: Uint8Array[]) { + const totalLength = chunks.reduce((total, chunk) => total + chunk.byteLength, 0) + const output = new Uint8Array(totalLength) + let offset = 0 + for (const chunk of chunks) { + output.set(chunk, offset) + offset += chunk.byteLength + } + return output +} + +export async function fetchStaticHttpTarget(url: string, init: RequestInit = {}) { + const parsed = new URL(url) + const headers = headersToRecord(init.headers) + const hostHeader = headers.host ?? headers.Host + if (parsed.protocol !== "http:" || !hostHeader) { + return fetch(url, init) + } + + return new Promise((resolve, reject) => { + const request = httpRequest({ + hostname: parsed.hostname, + port: parsed.port, + path: `${parsed.pathname}${parsed.search}`, + method: init.method ?? "GET", + headers: { ...headers, Host: hostHeader }, + signal: init.signal ?? undefined, + }, (response) => { + const chunks: Uint8Array[] = [] + response.on("data", (chunk: Uint8Array) => chunks.push(chunk)) + response.on("end", () => { + resolve(new Response(concatChunks(chunks), { + status: response.statusCode ?? 502, + statusText: response.statusMessage, + headers: responseHeadersToEntries(response.headers), + })) + }) + }) + + request.on("error", reject) + if (typeof init.body === "string") { + request.write(init.body) + } + request.end() + }) +} diff --git a/ee/apps/den-api/test/provisioner-static.test.ts b/ee/apps/den-api/test/provisioner-static.test.ts new file mode 100644 index 0000000000..238c392d7b --- /dev/null +++ b/ee/apps/den-api/test/provisioner-static.test.ts @@ -0,0 +1,1032 @@ +import { afterAll, beforeAll, expect, mock, test } from "bun:test" +import { Hono } from "hono" +import { jsonValidator } from "../src/middleware/validation.js" +import { WorkerInstanceTable, WorkerTable, WorkerTokenTable } from "@openwork-ee/den-db/schema" +import { createDenTypeId } from "@openwork-ee/utils/typeid" +import type { ProvisionedInstance, StaticWorkerConfig } from "../src/workers/provisioner.js" + +function seedRequiredEnv() { + process.env.DATABASE_URL = process.env.DATABASE_URL ?? "mysql://root:password@127.0.0.1:3306/openwork_test" + process.env.DEN_DB_ENCRYPTION_KEY = process.env.DEN_DB_ENCRYPTION_KEY ?? "x".repeat(32) + process.env.BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? "y".repeat(32) + process.env.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://127.0.0.1:8790" + process.env.CORS_ORIGINS = process.env.CORS_ORIGINS ?? "http://127.0.0.1:8790" + process.env.PROVISIONER_MODE = "static" + process.env.STATIC_WORKER_URLS = process.env.STATIC_WORKER_URLS ?? "http://127.0.0.1:8787" + process.env.STATIC_WORKER_TOKEN_MAP_JSON = process.env.STATIC_WORKER_TOKEN_MAP_JSON ?? '{"http://127.0.0.1:8787":{"clientToken":"static-client-token","hostToken":"static-host-token"}}' + process.env.STATIC_WORKER_ATTACH_ALLOW_PRIVATE = "true" +} + +let provisionerModule: typeof import("../src/workers/provisioner.js") +let envModule: typeof import("../src/env.js") +let workersSharedModule: typeof import("../src/routes/workers/shared.js") +let workersCoreModule: typeof import("../src/routes/workers/core.js") +let server: ReturnType +let staticWorkerUrl: string +let tokenQueryRows: Array> = [] +let instanceQueryRows: Array> = [] + +function dbQueryFor(table: unknown) { + const rows = table === WorkerTokenTable ? tokenQueryRows : table === WorkerInstanceTable ? instanceQueryRows : [] + const chain = { + from: () => chain, + where: () => chain, + orderBy: () => chain, + limit: () => rows, + then: (resolve: (value: Array>) => unknown) => resolve(rows), + } + return chain +} + +mock.module("../src/db.js", () => ({ + dbClient: { end: () => Promise.resolve() }, + denDb: {}, + db: { + select: () => ({ from: (table: unknown) => dbQueryFor(table) }), + }, +})) + +function staticWorkerConfig(overrides: Partial = {}): StaticWorkerConfig { + return { + urls: [staticWorkerUrl], + healthPath: "/health", + healthcheckTimeoutMs: 1000, + healthcheckIntervalMs: 10, + reservationTtlMs: 0, + ...overrides, + } +} + +function createFakeStaticAttachStore() { + const instances: Array> = [] + const workers: Array> = [] + const tokens: Array> = [] + const selectedUrl = { value: "" } + + const data = { + select() { + return { + from(table: unknown) { + return { + where() { + return { + async limit() { + if (table !== WorkerInstanceTable) { + return [] + } + return instances + .filter((entry) => entry.provider === "static" + && entry.url === selectedUrl.value + && (entry.status === "provisioning" || entry.status === "healthy")) + .map((entry) => ({ id: entry.id })) + }, + } + }, + } + }, + } + }, + insert(table: unknown) { + return { + async values(value: unknown) { + const values = Array.isArray(value) ? value : [value] + if (table === WorkerInstanceTable) { + instances.push(...values as Record[]) + selectedUrl.value = String((values[0] as Record).url ?? "") + } else if (table === WorkerTokenTable) { + tokens.push(...values as Record[]) + } else if (table === WorkerTable) { + workers.push(...values as Record[]) + } + }, + } + }, + } + + return { data, instances, workers, tokens, selectedUrl } +} + +function createStaticAttachRouteApp(input: { + role?: string + isOwner?: boolean + store?: ReturnType + fetchReachable?: typeof workersCoreModule.assertStaticWorkerReachable + lookup?: Parameters[2] +}) { + const app = new Hono() + const store = input.store ?? createFakeStaticAttachStore() + const userId = createDenTypeId("user") + const orgId = createDenTypeId("organization") + const memberId = createDenTypeId("member") + + workersCoreModule.registerStaticWorkerAttachRoute(app as never, { + data: store.data as never, + lookup: input.lookup ?? (async () => [{ address: "203.0.113.10", family: 4 }]), + fetchReachable: input.fetchReachable ?? (async () => undefined), + getWorkerLimit: async () => ({ exceeded: false, limit: 10, currentCount: 0 }), + lock: async (run) => run(store.data as never), + middlewares: [ + async (c, next) => { + c.set("user", { id: userId, email: "admin@example.com", name: "Admin" }) + c.set("activeOrganizationId", orgId) + c.set("organizationContext", { + organization: { id: orgId }, + currentMember: { + id: memberId, + userId, + role: input.role ?? "admin", + createdAt: new Date(), + isOwner: input.isOwner ?? false, + }, + }) + await next() + }, + jsonValidator(workersSharedModule.attachStaticWorkerSchema), + ] as never, + }) + return { app, store } +} + +async function postStaticAttach(app: Hono, overrides: Record = {}) { + return app.request("http://den.local/v1/workers/static-attach", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Static Attach Route Worker", + url: "http://worker.example.com", + clientToken: "valid-client-token", + hostToken: "valid-host-token", + ...overrides, + }), + }) +} + +beforeAll(async () => { + seedRequiredEnv() + server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + const authorization = request.headers.get("authorization") + const hostToken = request.headers.get("x-openwork-host-token") + if (url.pathname === "/health") { + return Response.json({ ok: true }) + } + if (url.pathname === "/workspaces") { + return authorization === "Bearer valid-client-token" + ? Response.json({ items: [], activeId: null }) + : Response.json({ error: "unauthorized" }, { status: 401 }) + } + if (url.pathname === "/env/keys") { + return hostToken === "valid-host-token" + ? Response.json({ keys: [] }) + : Response.json({ error: "forbidden" }, { status: 403 }) + } + if (url.pathname === "/redirect-workspaces") { + return new Response(null, { status: 302, headers: { location: "/workspaces" } }) + } + if (url.pathname === "/hang-health") { + return new Promise(() => {}) + } + return new Response("not found", { status: 404 }) + }, + }) + staticWorkerUrl = `http://127.0.0.1:${server.port}` + process.env.STATIC_WORKER_URLS = staticWorkerUrl + process.env.STATIC_WORKER_TOKEN_MAP_JSON = JSON.stringify({ + [staticWorkerUrl]: { clientToken: "valid-client-token", hostToken: "valid-host-token" }, + }) + envModule = await import("../src/env.js") + provisionerModule = await import("../src/workers/provisioner.js") + workersSharedModule = await import("../src/routes/workers/shared.js") + workersCoreModule = await import("../src/routes/workers/core.js") +}) + +afterAll(() => { + server.stop(true) +}) + +test("static provisioner assigns a configured healthy worker URL", async () => { + const provisioned = await provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_health_123", + name: "Static Health", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + staticWorkerConfig(), + ) + + expect(provisioned).toEqual({ + provider: "static", + region: "on-prem", + status: "healthy", + url: staticWorkerUrl, + }) +}) + +test("static provisioner verifies configured token-map tokens against worker runtime", async () => { + const provisioned = await provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_token_map_auth_123", + name: "Static Token Map Auth", + hostToken: "generated-host-token-not-used", + clientToken: "generated-client-token-not-used", + activityToken: "activity-token", + }, + staticWorkerConfig({ + tokenMap: { + [staticWorkerUrl]: { clientToken: "valid-client-token", hostToken: "valid-host-token" }, + }, + }), + ) + + expect(provisioned.url).toBe(staticWorkerUrl) + expect(provisioned.status).toBe("healthy") + + await expect(provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_token_map_reject_123", + name: "Static Token Map Reject", + hostToken: "generated-host-token-not-used", + clientToken: "generated-client-token-not-used", + activityToken: "activity-token", + }, + staticWorkerConfig({ + tokenMap: { + [staticWorkerUrl]: { clientToken: "invalid-client-token", hostToken: "valid-host-token" }, + }, + }), + )).rejects.toThrow("Worker rejected configured static client token") +}) + +test("static provisioner skips URLs already assigned to active workers", async () => { + const provisioned = await provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_available_url_123", + name: "Static Available URL", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + unavailableStaticWorkerUrls: ["http://127.0.0.1:1/"], + }, + staticWorkerConfig({ + urls: ["http://127.0.0.1:1/", staticWorkerUrl], + }), + ) + + expect(provisioned.url).toBe(staticWorkerUrl) + expect(provisioned.status).toBe("healthy") +}) + +test("static provisioner fails clearly when every configured URL is already active", async () => { + await expect(provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_exhausted_123", + name: "Static Exhausted", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + unavailableStaticWorkerUrls: [staticWorkerUrl], + }, + staticWorkerConfig({ + urls: [staticWorkerUrl], + }), + )).rejects.toThrow("No available static worker URL remains") +}) + +test("static provisioner combines configured and runtime unavailable URLs", async () => { + await expect(provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_combined_unavailable_123", + name: "Static Combined Unavailable", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + unavailableStaticWorkerUrls: [staticWorkerUrl], + }, + staticWorkerConfig({ + urls: ["http://127.0.0.1:1", staticWorkerUrl], + unavailableUrls: ["http://127.0.0.1:1"], + }), + )).rejects.toThrow("No available static worker URL remains") +}) + +test("static selector exhausts against DB-recomputed active normalized URLs", () => { + expect(() => provisionerModule.selectStaticWorkerUrlFromPool( + "worker_static_db_active_exhausted_123", + staticWorkerConfig({ + urls: [staticWorkerUrl], + unavailableUrls: [`${staticWorkerUrl}/`], + }), + )).toThrow("No available static worker URL remains") +}) + +test("static selector allows failed or stopped URLs when DB active set excludes them", () => { + const selected = provisionerModule.selectStaticWorkerUrlFromPool( + "worker_static_db_failed_reuse_123", + staticWorkerConfig({ + urls: [staticWorkerUrl], + unavailableUrls: [], + }), + ) + + expect(selected).toBe(staticWorkerUrl) +}) + +test("static DB assignment lock releases only after reservation transaction completes", async () => { + const events: string[] = [] + const result = await workersSharedModule.withStaticAssignmentLockUsing({ + pool: { + async getConnection() { + return { + async query(statement: string) { + if (statement.includes("GET_LOCK")) { + events.push("lock:acquired") + return [[{ acquired: 1 }], []] + } + if (statement.includes("RELEASE_LOCK")) { + events.push("lock:released") + return [[{}], []] + } + throw new Error(`unexpected query: ${statement}`) + }, + release() { + events.push("connection:released") + }, + } + }, + }, + async transaction(run) { + events.push("transaction:started") + const value = await run({} as never) + events.push("transaction:committed") + return value + }, + async run() { + events.push("reservation:inserted") + return "reserved" + }, + }) + + expect(result).toBe("reserved") + expect(events).toEqual([ + "lock:acquired", + "transaction:started", + "reservation:inserted", + "transaction:committed", + "lock:released", + "connection:released", + ]) +}) + +test("static attach permission gate allows owners and admins only", () => { + expect(workersSharedModule.canAttachStaticWorkerForMember({ currentMember: { isOwner: true, role: "member" } })).toBe(true) + expect(workersSharedModule.canAttachStaticWorkerForMember({ currentMember: { isOwner: false, role: "admin" } })).toBe(true) + expect(workersSharedModule.canAttachStaticWorkerForMember({ currentMember: { isOwner: false, role: "member" } })).toBe(false) +}) + +test("static attach route requires authentication", async () => { + const app = new Hono() + workersCoreModule.registerWorkerCoreRoutes(app) + + const response = await app.request("http://den.local/v1/workers/static-attach", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Static", url: staticWorkerUrl, clientToken: "valid-client-token", hostToken: "valid-host-token" }), + }) + + expect(response.status).toBe(401) + await expect(response.json()).resolves.toEqual({ error: "unauthorized" }) +}) + +test("static attach route succeeds for organization admin without token echo", async () => { + const { app, store } = createStaticAttachRouteApp({ role: "admin" }) + const response = await postStaticAttach(app) + const payload = await response.json() as Record + + expect(response.status).toBe(201) + expect(payload.worker).toBeTruthy() + expect(payload.instance).toBeTruthy() + expect(JSON.stringify(payload).includes("valid-client-token")).toBe(false) + expect(JSON.stringify(payload).includes("valid-host-token")).toBe(false) + expect(store.tokens.map((entry) => entry.scope).sort()).toEqual(["activity", "client", "host"]) +}) + +test("static attach route rejects ordinary organization members", async () => { + const { app } = createStaticAttachRouteApp({ role: "member", isOwner: false }) + const response = await postStaticAttach(app) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ error: "forbidden" }) +}) + +test("static attach route rejects duplicate URLs before verification", async () => { + const store = createFakeStaticAttachStore() + store.instances.push({ id: "existing", provider: "static", url: "http://worker.example.com", status: "healthy" }) + store.selectedUrl.value = "http://worker.example.com" + const { app } = createStaticAttachRouteApp({ store }) + const response = await postStaticAttach(app) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ error: "worker_url_already_attached" }) +}) + +test("static attach route re-checks duplicate URL inside lock before insert", async () => { + const store = createFakeStaticAttachStore() + const app = new Hono() + workersCoreModule.registerStaticWorkerAttachRoute(app as never, { + data: store.data as never, + lookup: async () => [{ address: "203.0.113.10", family: 4 }], + fetchReachable: async () => undefined, + getWorkerLimit: async () => ({ exceeded: false, limit: 10, currentCount: 0 }), + lock: async (run) => { + store.instances.push({ id: "raced", provider: "static", url: "http://worker.example.com", status: "healthy" }) + store.selectedUrl.value = "http://worker.example.com" + return run(store.data as never) + }, + middlewares: [ + async (c, next) => { + const userId = createDenTypeId("user") + const orgId = createDenTypeId("organization") + c.set("user", { id: userId, email: "admin@example.com" }) + c.set("activeOrganizationId", orgId) + c.set("organizationContext", { currentMember: { id: createDenTypeId("member"), userId, role: "admin", createdAt: new Date(), isOwner: false } }) + await next() + }, + jsonValidator(workersSharedModule.attachStaticWorkerSchema), + ] as never, + }) + + const response = await postStaticAttach(app) + expect(response.status).toBe(409) + expect(store.workers).toHaveLength(0) +}) + +test("static attach route checks worker quota inside lock before insert", async () => { + const store = createFakeStaticAttachStore() + let lockActive = false + let quotaCheckedInsideLock = false + const app = new Hono() + workersCoreModule.registerStaticWorkerAttachRoute(app as never, { + data: store.data as never, + lookup: async () => [{ address: "203.0.113.10", family: 4 }], + fetchReachable: async () => undefined, + getWorkerLimit: async () => { + quotaCheckedInsideLock = lockActive + return { exceeded: true, limit: 1, currentCount: 1 } + }, + lock: async (run) => { + lockActive = true + try { + return await run(store.data as never) + } finally { + lockActive = false + } + }, + middlewares: [ + async (c, next) => { + const userId = createDenTypeId("user") + const orgId = createDenTypeId("organization") + c.set("user", { id: userId, email: "admin@example.com" }) + c.set("activeOrganizationId", orgId) + c.set("organizationContext", { currentMember: { id: createDenTypeId("member"), userId, role: "admin", createdAt: new Date(), isOwner: false } }) + await next() + }, + jsonValidator(workersSharedModule.attachStaticWorkerSchema), + ] as never, + }) + + const response = await postStaticAttach(app) + expect(response.status).toBe(409) + await expect(response.json()).resolves.toMatchObject({ error: "org_limit_reached" }) + expect(quotaCheckedInsideLock).toBe(true) + expect(store.workers).toHaveLength(0) +}) + +test("static attach route rejects invalid URL", async () => { + const { app } = createStaticAttachRouteApp({}) + const response = await postStaticAttach(app, { url: "ftp://worker.example.com" }) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: "invalid_request" }) +}) + +test("static attach route rejects invalid client and host tokens", async () => { + const clientFailure = createStaticAttachRouteApp({ + fetchReachable: async () => { throw new Error("arbitrary upstream text valid-client-token") }, + }) + const clientResponse = await postStaticAttach(clientFailure.app) + const clientPayload = await clientResponse.json() as Record + expect(clientResponse.status).toBe(400) + expect(clientPayload.message).toBe("Static worker verification failed with the provided URL and tokens.") + expect(JSON.stringify(clientPayload).includes("valid-client-token")).toBe(false) + + const hostFailure = createStaticAttachRouteApp({ + fetchReachable: async () => { throw new Error("arbitrary upstream text valid-host-token") }, + }) + const hostResponse = await postStaticAttach(hostFailure.app) + const hostPayload = await hostResponse.json() as Record + expect(hostResponse.status).toBe(400) + expect(hostPayload.message).toBe("Static worker verification failed with the provided URL and tokens.") + expect(JSON.stringify(hostPayload).includes("valid-host-token")).toBe(false) +}) + +test("static attach URL policy rejects unsafe URLs and allows explicit on-prem hosts", () => { + const defaultPolicy = { allowPrivate: false, allowedHosts: [], allowedCidrs: [] } + + expect(workersSharedModule.validateStaticWorkerAttachUrl("ftp://worker.example.com", defaultPolicy)).toMatchObject({ ok: false }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://user:pass@worker.example.com", defaultPolicy)).toMatchObject({ ok: false }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://worker.example.com/?token=abc", defaultPolicy)).toMatchObject({ ok: false }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://127.0.0.1:8787", defaultPolicy)).toMatchObject({ ok: false }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://127.0.0.1:8787", { ...defaultPolicy, allowedCidrs: ["127.0.0.0/8"] })).toMatchObject({ + ok: true, + url: "http://127.0.0.1:8787", + }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://lan-worker.local:8787", { ...defaultPolicy, allowedHosts: ["lan-worker.local"] })).toMatchObject({ ok: true }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://lan-worker.local:8787", { ...defaultPolicy, allowedCidrs: ["192.168.0.0/16"] })).toMatchObject({ ok: true }) + expect(workersSharedModule.validateStaticWorkerAttachUrl("http://[fd00::10]:8787", { ...defaultPolicy, allowedCidrs: ["fd00::/8"] })).toMatchObject({ + ok: true, + url: "http://[fd00::10]:8787", + }) +}) + +test("static attach URL policy blocks DNS names resolving to unsafe IPv4 and IPv6 addresses", async () => { + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://public-name.example.com", + { allowPrivate: false, allowedHosts: [], allowedCidrs: [] }, + async () => [{ address: "127.0.0.1", family: 4 }], + )).resolves.toMatchObject({ ok: false }) + + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://public-name.example.com", + { allowPrivate: false, allowedHosts: [], allowedCidrs: [] }, + async () => [{ address: "fe80::1", family: 6 }], + )).resolves.toMatchObject({ ok: false }) + + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://public-name.example.com", + { allowPrivate: false, allowedHosts: [], allowedCidrs: ["127.0.0.0/8"] }, + async () => [{ address: "127.0.0.1", family: 4 }], + )).resolves.toMatchObject({ ok: true }) + + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://allowed-host.example.com", + { allowPrivate: false, allowedHosts: ["allowed-host.example.com"], allowedCidrs: [] }, + async () => [{ address: "::1", family: 6 }], + )).resolves.toMatchObject({ ok: false }) +}) + +test("static attach URL policy allows explicitly allow-listed HTTPS hostnames", async () => { + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "https://worker.example.com", + { allowPrivate: false, allowedHosts: ["worker.example.com"], allowedCidrs: [] }, + async () => [{ address: "203.0.113.10", family: 4 }], + )).resolves.toMatchObject({ ok: true }) +}) + +test("static attach URL policy rejects public HTTP hosts unless explicitly allowed", async () => { + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://worker.example.com:8787", + { allowPrivate: false, allowedHosts: [], allowedCidrs: [] }, + async () => [{ address: "203.0.113.10", family: 4 }], + )).resolves.toMatchObject({ ok: false }) + + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://worker.example.com:8787", + { allowPrivate: false, allowedHosts: ["worker.example.com"], allowedCidrs: [] }, + async () => [{ address: "203.0.113.10", family: 4 }], + )).resolves.toMatchObject({ ok: true }) + + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "http://worker.example.com:8787", + { allowPrivate: false, allowedHosts: [], allowedCidrs: ["10.0.0.0/8"] }, + async () => [{ address: "203.0.113.10", family: 4 }, { address: "10.1.2.3", family: 4 }], + )).resolves.toMatchObject({ ok: false }) +}) + +test("static attach URL policy rejects non-allow-listed HTTPS hostnames", async () => { + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "https://worker.example.com", + { allowPrivate: false, allowedHosts: [], allowedCidrs: [] }, + async () => [{ address: "203.0.113.10", family: 4 }], + )).resolves.toMatchObject({ ok: false }) +}) + +test("static attach URL policy still rejects allow-listed HTTPS hostnames resolving to unsafe addresses", async () => { + await expect(workersSharedModule.validateResolvedStaticWorkerAttachUrl( + "https://worker.example.com", + { allowPrivate: false, allowedHosts: ["worker.example.com"], allowedCidrs: [] }, + async () => [{ address: "127.0.0.1", family: 4 }], + )).resolves.toMatchObject({ ok: false }) +}) + +test("static attach verification fetch uses the validated IP address instead of re-resolving hostname", async () => { + const seenHosts: string[] = [] + const pinServer = Bun.serve({ + port: 0, + fetch(request) { + seenHosts.push(request.headers.get("host") ?? "") + const url = new URL(request.url) + if (url.pathname === "/workspaces") { + return Response.json({ items: [] }) + } + if (url.pathname === "/env/keys") { + return Response.json({ keys: [] }) + } + return new Response("not found", { status: 404 }) + }, + }) + try { + const target = await workersSharedModule.validateResolvedStaticWorkerAttachUrl( + `http://rebinding-worker.test:${pinServer.port}`, + { allowPrivate: false, allowedHosts: [], allowedCidrs: ["127.0.0.0/8"] }, + async () => [{ address: "127.0.0.1", family: 4 }], + ) + expect(target.ok).toBe(true) + if (target.ok) { + await workersCoreModule.assertStaticWorkerReachable(target, "valid-client-token", "valid-host-token") + } + expect(seenHosts).toEqual([`rebinding-worker.test:${pinServer.port}`, `rebinding-worker.test:${pinServer.port}`]) + } finally { + pinServer.stop(true) + } +}) + +test("static worker token discovery pins the validated address before sending client token", async () => { + const seenHosts: string[] = [] + const pinServer = Bun.serve({ + port: 0, + fetch(request) { + seenHosts.push(request.headers.get("host") ?? "") + const authorization = request.headers.get("authorization") + const url = new URL(request.url) + if (url.pathname === "/workspaces" && authorization === "Bearer valid-client-token") { + return Response.json({ items: [{ id: "workspace_static_pin", active: true }] }) + } + return new Response("not found", { status: 404 }) + }, + }) + try { + tokenQueryRows = [ + { scope: "host", token: "valid-host-token" }, + { scope: "client", token: "valid-client-token" }, + ] + instanceQueryRows = [{ provider: "static", url: `http://localhost:${pinServer.port}` }] + + const resolved = await workersSharedModule.getWorkerTokensAndConnect({ id: "worker_static_token_pin_123" } as never) + + expect("tokens" in resolved).toBe(true) + expect("connect" in resolved ? resolved.connect?.workspaceId : null).toBe("workspace_static_pin") + expect("connect" in resolved ? resolved.connect?.openworkUrl : null).toBe(`http://localhost:${pinServer.port}/w/workspace_static_pin`) + expect(seenHosts).toEqual([`localhost:${pinServer.port}`]) + } finally { + tokenQueryRows = [] + instanceQueryRows = [] + pinServer.stop(true) + } +}) + +test("static runtime fetch does not forward host token across redirects", async () => { + const redirectedRequests: string[] = [] + const redirectTarget = Bun.serve({ + port: 0, + fetch(request) { + redirectedRequests.push(request.headers.get("x-openwork-host-token") ?? "") + return Response.json({ ok: true }) + }, + }) + const redirectSource = Bun.serve({ + port: 0, + fetch() { + return new Response(null, { + status: 307, + headers: { location: `http://127.0.0.1:${redirectTarget.port}/sink` }, + }) + }, + }) + try { + tokenQueryRows = [ + { scope: "host", token: "valid-host-token" }, + { scope: "client", token: "valid-client-token" }, + ] + instanceQueryRows = [{ provider: "static", url: `http://localhost:${redirectSource.port}` }] + + const response = await workersSharedModule.fetchWorkerRuntimeJson({ + workerId: "worker_static_runtime_redirect_123" as never, + path: "/runtime/upgrade", + method: "POST", + body: { providerToken: "secret" }, + }) + + expect(response.ok).toBe(false) + expect(response.status).toBe(307) + expect(redirectedRequests).toEqual([]) + } finally { + tokenQueryRows = [] + instanceQueryRows = [] + redirectSource.stop(true) + redirectTarget.stop(true) + } +}) + +test("static runtime versions uses the client bearer token", async () => { + const seenAuthorization: string[] = [] + const runtimeServer = Bun.serve({ + port: 0, + fetch(request) { + seenAuthorization.push(request.headers.get("authorization") ?? "") + return Response.json({ versions: [] }) + }, + }) + try { + tokenQueryRows = [ + { scope: "host", token: "valid-host-token" }, + { scope: "client", token: "valid-client-token" }, + ] + instanceQueryRows = [{ provider: "static", url: `http://localhost:${runtimeServer.port}` }] + + const response = await workersSharedModule.fetchWorkerRuntimeJson({ + workerId: "worker_static_runtime_versions_123" as never, + path: "/runtime/versions", + auth: "client", + }) + + expect(response.ok).toBe(true) + expect(seenAuthorization).toEqual(["Bearer valid-client-token"]) + } finally { + tokenQueryRows = [] + instanceQueryRows = [] + runtimeServer.stop(true) + } +}) + +test("static token reads are limited to creator, owners, and admins", () => { + const creatorId = createDenTypeId("user") + const otherUserId = createDenTypeId("user") + const worker = { created_by_user_id: creatorId } + + expect(workersSharedModule.canReadStaticWorkerTokensForMember({ worker, userId: creatorId, currentMember: { isOwner: false, role: "member" } })).toBe(true) + expect(workersSharedModule.canReadStaticWorkerTokensForMember({ worker, userId: otherUserId, currentMember: { isOwner: true, role: "member" } })).toBe(true) + expect(workersSharedModule.canReadStaticWorkerTokensForMember({ worker, userId: otherUserId, currentMember: { isOwner: false, role: "admin" } })).toBe(true) + expect(workersSharedModule.canReadStaticWorkerTokensForMember({ worker, userId: otherUserId, currentMember: { isOwner: false, role: "member" } })).toBe(false) +}) + +test("static pool health checks preserve pinned host headers", async () => { + const seenHosts: string[] = [] + const pinServer = Bun.serve({ + port: 0, + fetch(request) { + seenHosts.push(request.headers.get("host") ?? "") + return Response.json({ ok: true }) + }, + }) + try { + await provisionerModule.checkStaticWorkerHealth({ + url: `http://127.0.0.1:${pinServer.port}`, + headers: { Host: `worker-static.test:${pinServer.port}` }, + }, staticWorkerConfig()) + + expect(seenHosts).toEqual([`worker-static.test:${pinServer.port}`]) + } finally { + pinServer.stop(true) + } +}) + +test("static attach verification keeps HTTPS hostname for certificate validation", async () => { + const originalFetch = globalThis.fetch + const requestedUrls: string[] = [] + globalThis.fetch = ((input: RequestInfo | URL) => { + requestedUrls.push(String(input)) + return Promise.resolve(Response.json({ ok: true })) + }) as typeof fetch + try { + await workersCoreModule.assertStaticWorkerReachable({ + ok: true, + url: "https://worker.example.com:8787", + resolvedAddresses: [{ address: "203.0.113.10", family: 4 }], + }, "valid-client-token", "valid-host-token") + expect(requestedUrls).toEqual([ + "https://worker.example.com:8787/workspaces", + "https://worker.example.com:8787/env/keys", + ]) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("static attach worker token verification succeeds without following redirects", async () => { + await expect(workersCoreModule.assertStaticWorkerReachable(staticWorkerUrl, "valid-client-token", "valid-host-token")).resolves.toBeUndefined() + + const redirectResponse = await workersCoreModule.fetchStaticWorker(staticWorkerUrl, "/redirect-workspaces", {}) + expect(redirectResponse.status).toBe(302) +}) + +test("static attach worker token verification rejects invalid client and host tokens without echoing tokens", async () => { + await expect(workersCoreModule.assertStaticWorkerReachable(staticWorkerUrl, "invalid-client-token", "valid-host-token")) + .rejects.toThrow("Worker rejected the provided client token with HTTP 401") + await expect(workersCoreModule.assertStaticWorkerReachable(staticWorkerUrl, "valid-client-token", "invalid-host-token")) + .rejects.toThrow("Worker rejected the provided host token with HTTP 403") + + try { + await workersCoreModule.assertStaticWorkerReachable(staticWorkerUrl, "invalid-client-token", "valid-host-token") + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + expect(message.includes("invalid-client-token")).toBe(false) + expect(message.includes("valid-host-token")).toBe(false) + } +}) + +test("static provisioner fails clearly when no worker URLs are configured", async () => { + await expect(provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_missing_url_123", + name: "Static Missing URL", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + staticWorkerConfig({ + urls: [], + }), + )).rejects.toThrow("STATIC_WORKER_URLS is required when PROVISIONER_MODE=static") +}) + +test("static provisioner fails clearly when health check does not pass", async () => { + await expect(provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_unhealthy_123", + name: "Static Unhealthy", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + staticWorkerConfig({ + urls: [`${staticWorkerUrl}/missing`], + healthcheckTimeoutMs: 50, + }), + )).rejects.toThrow("Timed out waiting for worker health endpoint") +}) + +test("static provisioner aborts a hanging health check within the configured timeout", async () => { + const startedAt = performance.now() + + await expect(provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_hanging_health_123", + name: "Static Hanging Health", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + staticWorkerConfig({ + healthPath: "/hang-health", + healthcheckTimeoutMs: 75, + }), + )).rejects.toThrow("Timed out waiting for worker health endpoint") + + expect(performance.now() - startedAt).toBeLessThan(1000) +}) + +test("static env validation requires config only when static mode is enabled", () => { + expect(envModule.parseStaticWorkersEnv({ STATIC_WORKER_URLS: undefined }).issues).toContainEqual({ + path: "STATIC_WORKER_URLS", + message: "STATIC_WORKER_URLS is required when PROVISIONER_MODE=static", + }) + expect(envModule.parseStaticWorkersEnv({ STATIC_WORKER_URLS: undefined }).issues).toContainEqual({ + path: "STATIC_WORKER_TOKEN_MAP_JSON", + message: "STATIC_WORKER_TOKEN_MAP_JSON is required when PROVISIONER_MODE=static", + }) +}) + +test("static env validation normalizes URLs and rejects duplicate normalized URLs", () => { + const parsed = envModule.parseStaticWorkersEnv({ + STATIC_WORKER_URLS: "https://Worker.Example.com/, https://worker.example.com", + STATIC_WORKER_TOKEN_MAP_JSON: JSON.stringify({ + "https://worker.example.com": { clientToken: "client-token", hostToken: "host-token" }, + }), + }) + + expect(parsed.urls).toEqual(["https://worker.example.com"]) + expect(parsed.issues.some((issue) => issue.message.includes("duplicate URL https://worker.example.com"))).toBe(true) +}) + +test("static env validation rejects invalid URL, protocol, health path, and timeout values", () => { + const parsed = envModule.parseStaticWorkersEnv({ + STATIC_WORKER_URLS: "not-a-url, ftp://worker.example.com", + STATIC_WORKER_HEALTH_PATH: "health?ready=1", + STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS: "0", + STATIC_WORKER_HEALTHCHECK_INTERVAL_MS: "NaN", + STATIC_WORKER_RESERVATION_TTL_MS: "-1", + STATIC_WORKER_TOKEN_MAP_JSON: "not-json", + }) + + expect(parsed.issues.some((issue) => issue.message === "STATIC_WORKER_URLS entries must be valid URLs")).toBe(true) + expect(parsed.issues.some((issue) => issue.message === "STATIC_WORKER_URLS entries must use http or https URLs")).toBe(true) + expect(parsed.issues.some((issue) => issue.path === "STATIC_WORKER_HEALTH_PATH")).toBe(true) + expect(parsed.issues.some((issue) => issue.path === "STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS")).toBe(true) + expect(parsed.issues.some((issue) => issue.path === "STATIC_WORKER_HEALTHCHECK_INTERVAL_MS")).toBe(true) + expect(parsed.issues.some((issue) => issue.path === "STATIC_WORKER_RESERVATION_TTL_MS")).toBe(true) + expect(parsed.issues.some((issue) => issue.message === "STATIC_WORKER_TOKEN_MAP_JSON must be valid JSON")).toBe(true) +}) + +test("static env validation requires one token-map pair per configured worker URL", () => { + const parsed = envModule.parseStaticWorkersEnv({ + STATIC_WORKER_URLS: "http://worker-a.example.com,http://worker-b.example.com", + STATIC_WORKER_TOKEN_MAP_JSON: JSON.stringify({ + "http://worker-a.example.com/": { clientToken: "client-a", hostToken: "host-a" }, + "http://worker-extra.example.com": { clientToken: "client-extra", hostToken: "host-extra" }, + }), + }) + + expect(parsed.tokenMap["http://worker-a.example.com"]).toEqual({ clientToken: "client-a", hostToken: "host-a" }) + expect(parsed.issues.some((issue) => issue.message.includes("missing token pair for http://worker-b.example.com"))).toBe(true) + expect(parsed.issues.some((issue) => issue.message.includes("unconfigured URL http://worker-extra.example.com"))).toBe(true) +}) + +test("static provisioner in-process reservations prevent concurrent duplicate assignment", async () => { + const config = staticWorkerConfig({ reservationTtlMs: 1000 }) + const first = provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_concurrent_a_123", + name: "Static Concurrent A", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + config, + ) + const second = provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_concurrent_b_123", + name: "Static Concurrent B", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + config, + ) + + const results = await Promise.allSettled([first, second]) + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1) + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1) + + const fulfilled = results.find((result): result is PromiseFulfilledResult => result.status === "fulfilled") + expect(fulfilled?.value.url).toBe(staticWorkerUrl) + await provisionerModule.deprovisionWorker({ workerId: "worker_static_concurrent_a_123", instanceUrl: staticWorkerUrl }) + await provisionerModule.deprovisionWorker({ workerId: "worker_static_concurrent_b_123", instanceUrl: staticWorkerUrl }) +}) + +test("static provisioner releases failed health reservations for reuse", async () => { + await expect(provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_failed_cleanup_123", + name: "Static Failed Cleanup", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + staticWorkerConfig({ healthPath: "/missing", healthcheckTimeoutMs: 50, reservationTtlMs: 1000 }), + )).rejects.toThrow("Timed out waiting for worker health endpoint") + + const provisioned = await provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_failed_cleanup_reuse_123", + name: "Static Failed Cleanup Reuse", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + staticWorkerConfig(), + ) + expect(provisioned.url).toBe(staticWorkerUrl) +}) + +test("static provisioner recovers stale reservations for reuse", async () => { + const first = await provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_stale_a_123", + name: "Static Stale A", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + staticWorkerConfig({ reservationTtlMs: 1 }), + ) + expect(first.url).toBe(staticWorkerUrl) + + await new Promise((resolve) => setTimeout(resolve, 5)) + + const second = await provisionerModule.provisionStaticWorker( + { + workerId: "worker_static_stale_b_123", + name: "Static Stale B", + hostToken: "host-token", + clientToken: "client-token", + activityToken: "activity-token", + }, + staticWorkerConfig(), + ) + expect(second.url).toBe(staticWorkerUrl) +}) diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile index 62df069914..f0b565c870 100644 --- a/packaging/docker/Dockerfile +++ b/packaging/docker/Dockerfile @@ -1,6 +1,11 @@ FROM node:22-bookworm-slim -ARG OPENWORK_ORCHESTRATOR_VERSION=0.11.22 +ARG TARGETARCH +ARG BUN_VERSION=1.3.8 +ARG BUN_DOWNLOAD_URL= +ARG BUN_SHA256= + +WORKDIR /repo RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -9,13 +14,75 @@ RUN apt-get update \ git \ tar \ unzip \ + && npm install -g pnpm@11.4.0 \ + && case "${TARGETARCH:-amd64}" in \ + amd64) bun_artifact=bun-linux-x64.zip; bun_sha=0322b17f0722da76a64298aad498225aedcbf6df1008a1dee45e16ecb226a3f1 ;; \ + arm64) bun_artifact=bun-linux-aarch64.zip; bun_sha=4e9deb6814a7ec7f68725ddd97d0d7b4065bcda9a850f69d497567e995a7fa33 ;; \ + *) echo "Unsupported Bun TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ + esac \ + && bun_url="${BUN_DOWNLOAD_URL:-https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${bun_artifact}}" \ + && bun_sha="${BUN_SHA256:-$bun_sha}" \ + && curl -fsSLo "/tmp/${bun_artifact}" "$bun_url" \ + && echo "$bun_sha /tmp/${bun_artifact}" | sha256sum -c - \ + && unzip -q "/tmp/${bun_artifact}" -d /tmp \ + && install -m 0755 "/tmp/${bun_artifact%.zip}/bun" /usr/local/bin/bun \ + && bun --version | grep -qx "$BUN_VERSION" \ + && rm -rf "/tmp/${bun_artifact%.zip}" "/tmp/${bun_artifact}" \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g "openwork-orchestrator@${OPENWORK_ORCHESTRATOR_VERSION}" +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN mkdir -p \ + apps/app \ + apps/desktop \ + apps/opencode-router \ + apps/orchestrator \ + apps/server \ + apps/ui-demo \ + ee/apps/den-api \ + ee/apps/den-web \ + ee/apps/den-worker-proxy \ + ee/apps/inference \ + ee/apps/landing \ + ee/packages/den-db \ + ee/packages/utils \ + packages/email \ + packages/handsfree \ + packages/openwork-ui-mcp \ + packages/types \ + packages/ui +COPY apps/app/package.json apps/app/package.json +COPY apps/desktop/package.json apps/desktop/package.json +COPY apps/opencode-router/package.json apps/opencode-router/package.json +COPY apps/orchestrator/package.json apps/orchestrator/package.json +COPY apps/server/package.json apps/server/package.json +COPY apps/ui-demo/package.json apps/ui-demo/package.json +COPY ee/apps/den-api/package.json ee/apps/den-api/package.json +COPY ee/apps/den-web/package.json ee/apps/den-web/package.json +COPY ee/apps/den-worker-proxy/package.json ee/apps/den-worker-proxy/package.json +COPY ee/apps/inference/package.json ee/apps/inference/package.json +COPY ee/apps/landing/package.json ee/apps/landing/package.json +COPY ee/packages/den-db/package.json ee/packages/den-db/package.json +COPY ee/packages/utils/package.json ee/packages/utils/package.json +COPY packages/email/package.json packages/email/package.json +COPY packages/handsfree/package.json packages/handsfree/package.json +COPY packages/openwork-ui-mcp/package.json packages/openwork-ui-mcp/package.json +COPY packages/types/package.json packages/types/package.json +COPY packages/ui/package.json packages/ui/package.json +RUN pnpm install --frozen-lockfile + +COPY . . + +# Build the OpenWork server from this source tree. The Docker worker should not +# fall back to a downloaded sidecar when the image is built from an unreleased +# branch/hotfix. +RUN pnpm --dir apps/server build:bin # Persistent directories (mount volumes here on PaaS/SSH). ENV OPENWORK_DATA_DIR=/data/openwork-orchestrator ENV OPENWORK_SIDECAR_DIR=/data/sidecars +ENV OPENWORK_ALLOW_EXTERNAL=1 +ENV OPENWORK_SIDECAR_SOURCE=external +ENV OPENWORK_SERVER_BIN=/repo/apps/server/dist/bin/openwork-server # The workspace is mounted from the host/volume. ENV OPENWORK_WORKSPACE=/workspace @@ -33,16 +100,8 @@ VOLUME ["/workspace", "/data"] # - OpenCode stays internal (127.0.0.1:4096) # - OpenWork server proxies OpenCode via localhost # - OpenCode Router disabled by default -CMD [ - "openwork", - "serve", - "--workspace", "/workspace", - "--remote-access", - "--openwork-port", "8787", - "--opencode-host", "127.0.0.1", - "--opencode-port", "4096", - "--connect-host", "127.0.0.1", - "--cors", "*", - "--approval", "manual", - "--no-opencode-router" +CMD [ \ + "sh", \ + "-lc", \ + "TOKEN_FILE=/data/openwork-worker.env; ENV_OPENWORK_TOKEN=\"${OPENWORK_TOKEN:-}\"; ENV_OPENWORK_HOST_TOKEN=\"${OPENWORK_HOST_TOKEN:-}\"; FILE_OPENWORK_TOKEN=; FILE_OPENWORK_HOST_TOKEN=; if [ -f \"$TOKEN_FILE\" ]; then . \"$TOKEN_FILE\"; FILE_OPENWORK_TOKEN=\"${OPENWORK_TOKEN:-}\"; FILE_OPENWORK_HOST_TOKEN=\"${OPENWORK_HOST_TOKEN:-}\"; fi; OPENWORK_TOKEN=\"${ENV_OPENWORK_TOKEN:-$FILE_OPENWORK_TOKEN}\"; OPENWORK_HOST_TOKEN=\"${ENV_OPENWORK_HOST_TOKEN:-$FILE_OPENWORK_HOST_TOKEN}\"; GENERATED_TOKEN=0; if [ -z \"$OPENWORK_TOKEN\" ]; then OPENWORK_TOKEN=owc_$(node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"); FILE_OPENWORK_TOKEN=\"$OPENWORK_TOKEN\"; GENERATED_TOKEN=1; fi; if [ -z \"$OPENWORK_HOST_TOKEN\" ]; then OPENWORK_HOST_TOKEN=owh_$(node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"); FILE_OPENWORK_HOST_TOKEN=\"$OPENWORK_HOST_TOKEN\"; GENERATED_TOKEN=1; fi; export OPENWORK_TOKEN OPENWORK_HOST_TOKEN; if [ \"$GENERATED_TOKEN\" = \"1\" ] || [ ! -f \"$TOKEN_FILE\" ]; then umask 077; { if [ -z \"$ENV_OPENWORK_TOKEN\" ]; then printf 'OPENWORK_TOKEN=%s\\n' \"$FILE_OPENWORK_TOKEN\"; fi; if [ -z \"$ENV_OPENWORK_HOST_TOKEN\" ]; then printf 'OPENWORK_HOST_TOKEN=%s\\n' \"$FILE_OPENWORK_HOST_TOKEN\"; fi; } > \"$TOKEN_FILE\"; fi; if [ -n \"$ENV_OPENWORK_TOKEN$ENV_OPENWORK_HOST_TOKEN\" ]; then echo \"OpenWork worker env-supplied tokens are active and were not persisted\"; else echo \"OpenWork worker fallback tokens are stored in $TOKEN_FILE\"; fi; OPENWORK_CORS_ORIGINS=\"${OPENWORK_CORS_ORIGINS:-http://localhost:${OPENWORK_PORT:-8787},http://127.0.0.1:${OPENWORK_PORT:-8787}}\"; exec bun apps/orchestrator/src/cli.ts serve --workspace /workspace --remote-access --openwork-port \"${OPENWORK_PORT:-8787}\" --opencode-host 127.0.0.1 --opencode-port 4096 --connect-host \"${OPENWORK_CONNECT_HOST:-127.0.0.1}\" --cors \"$OPENWORK_CORS_ORIGINS\" --approval \"${OPENWORK_APPROVAL_MODE:-manual}\" --no-opencode-router" \ ] diff --git a/packaging/docker/Dockerfile.den b/packaging/docker/Dockerfile.den index 48fe0d0f7e..17196d5a92 100644 --- a/packaging/docker/Dockerfile.den +++ b/packaging/docker/Dockerfile.den @@ -8,14 +8,16 @@ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/ COPY patches /app/patches COPY packages/types/package.json /app/packages/types/package.json COPY packages/email/package.json /app/packages/email/package.json +COPY apps/desktop/package.json /app/apps/desktop/package.json COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json COPY ee/apps/den-api/package.json /app/ee/apps/den-api/package.json RUN pnpm install --frozen-lockfile -COPY packages/types /app/packages/types COPY packages/email /app/packages/email +COPY packages/types /app/packages/types +COPY apps/desktop/package.json /app/apps/desktop/package.json COPY ee/packages/utils /app/ee/packages/utils COPY ee/packages/den-db /app/ee/packages/den-db COPY ee/apps/den-api /app/ee/apps/den-api diff --git a/packaging/docker/ONPREM_DEN_STATIC_RUNBOOK.md b/packaging/docker/ONPREM_DEN_STATIC_RUNBOOK.md new file mode 100644 index 0000000000..b7aaba3049 --- /dev/null +++ b/packaging/docker/ONPREM_DEN_STATIC_RUNBOOK.md @@ -0,0 +1,226 @@ +# On-prem Den static runbook + +Use this runbook to deploy Den on your own network when the worker runtimes already exist and Den should allocate them from a fixed pool. + +This runbook uses: + +- `packaging/docker/docker-compose.yml` for the worker runtime +- `packaging/docker/docker-compose.den-static.yml` for the Den stack + +In `static` mode: + +- you start and manage the OpenWork worker runtimes yourself +- Den allocates a free worker URL from `DEN_STATIC_WORKER_URLS` +- Den verifies worker health and the configured token pair before marking a worker `healthy` +- Den does not create Docker containers, worker VMs, or worker hosts for you + +The Den web URL is the normal browser-facing entrypoint. The Den API should remain internal to the Den host unless you intentionally expose it to another trusted client. +The production static Compose file publishes only the Den web port by default; MySQL, the Den API, and the worker-proxy are reachable on the Compose network for dependent services but are not bound to host ports unless an operator adds an explicit override. + +Use HTTPS for the browser-facing Den web URL whenever possible. If you intentionally use HTTP on a private LAN, use that exact HTTP origin consistently for `DEN_WEB_ORIGIN`, `DEN_BETTER_AUTH_URL`, trusted origins, and any client configuration. + +## Prerequisites + +- One host for Den and one or more hosts for OpenWork workers. These can be separate machines or the same machine if your deployment model allows it. +- Docker Engine with Compose v2 on the Den host and on every worker host. +- The OpenWork repository checkout or a release artifact on each target host. Use the exact source checkout or artifact you intend to deploy. +- A browser-facing Den web URL, for example `https://den.company.local`. +- One stable URL per worker runtime, for example `http://worker-01.company.local:8787`. +- A persistent workspace directory for each worker, mounted at `/workspace` inside the worker container. +- A persistent data directory for each worker, mounted at `/data` inside the worker container. +- One `OPENWORK_TOKEN` and one `OPENWORK_HOST_TOKEN` for each worker runtime. +- One `DEN_BETTER_AUTH_SECRET` for Den. +- One `DEN_DB_ENCRYPTION_KEY` for Den. +- One `DEN_STATIC_WORKER_TOKEN_MAP_JSON` that maps each worker URL to its `clientToken` and `hostToken`. +- Network reachability from: + - browsers to the Den web URL + - Den to every worker URL on port `8787` +- A production email provider for normal sign-up, invitation, and verification flows. +- `DEN_MYSQL_ROOT_PASSWORD` for the Den database container. + +All commands below assume the OpenWork repository root is available on the target host. Replace `/path/to/openwork` with the real path on your host. + +## Inputs To Collect + +Prepare these values before launching anything: + +- `DEN_WEB_ORIGIN`, for example `https://den.company.local` +- One worker URL per runtime, for example `http://worker-01.company.local:8787` +- One workspace path per worker +- One data path per worker +- `OPENWORK_TOKEN` per worker +- `OPENWORK_HOST_TOKEN` per worker +- `DEN_BETTER_AUTH_SECRET` +- `DEN_DB_ENCRYPTION_KEY` +- `DEN_MYSQL_ROOT_PASSWORD` +- `DEN_STATIC_WORKER_URLS`, containing all worker URLs as a comma-separated list +- `DEN_STATIC_WORKER_TOKEN_MAP_JSON`, containing the token pair for each worker URL + +The browser-facing Den web URL must match the value used for `DEN_BETTER_AUTH_URL`. If users open Den at `https://den.company.local`, then `DEN_BETTER_AUTH_URL` must be exactly `https://den.company.local`. If you intentionally use HTTP on a private LAN, then use that exact HTTP origin consistently. + +## Start One Worker + +Run this on the worker host from `packaging/docker`: + +```bash +cd /path/to/openwork/packaging/docker +export OPENWORK_HOST_PORT=8787 +export OPENWORK_CONNECT_HOST=worker-01.company.local +export OPENWORK_WORKSPACE_DIR=/srv/openwork/worker-01/workspace +export OPENWORK_DATA_DIR_HOST=/srv/openwork/worker-01/data +export OPENWORK_TOKEN='' +export OPENWORK_HOST_TOKEN='' +export OPENWORK_CORS_ORIGINS=https://den.company.local +docker compose -p openwork-worker-1 up --build -d +docker compose -p openwork-worker-1 ps +curl http://worker-01.company.local:8787/health +curl -H "Authorization: Bearer $OPENWORK_TOKEN" http://worker-01.company.local:8787/workspaces +curl -H "X-OpenWork-Host-Token: $OPENWORK_HOST_TOKEN" http://worker-01.company.local:8787/env/keys +``` + +Expected result: + +- the container is running +- `curl` returns HTTP 200 JSON from the OpenWork server +- `/workspaces` returns at least one selectable workspace for the configured client token +- `/env/keys` returns HTTP 200 for the configured host token + +This worker URL is what Den will later use in `DEN_STATIC_WORKER_URLS`. + +## Start Additional Workers + +If you need more than one worker runtime, repeat the worker launch with: + +- a different Compose project name +- a different worker URL or host port +- a different workspace path +- a different data path +- a different `OPENWORK_TOKEN` +- a different `OPENWORK_HOST_TOKEN` + +On separate hosts, you can keep the container port at `8787` on each host and vary only the hostname, for example: + +- `http://worker-01.company.local:8787` +- `http://worker-02.company.local:8787` + +If multiple workers share one host, use a unique host port per worker and a unique Compose project per worker. + +## Start Den In Static Mode + +Run this on the Den host from the repository root. + +Export the variables and run `docker compose` in the same shell session. + +In Bash, export `DEN_STATIC_WORKER_TOKEN_MAP_JSON` as a single-quoted JSON string so it reaches the container unchanged. + +Keep these `DEN_*` exports in the current shell until you finish `docker compose ps`, `logs`, and the health checks below. If you open a new shell, re-export the same values before running follow-up Compose commands. + +```bash +cd /path/to/openwork +export DEN_WEB_ORIGIN=https://den.company.local +export DEN_PROVISIONER_MODE=static +export DEN_BETTER_AUTH_URL=$DEN_WEB_ORIGIN +export DEN_BETTER_AUTH_TRUSTED_ORIGINS=$DEN_WEB_ORIGIN +export DEN_CORS_ORIGINS=$DEN_WEB_ORIGIN +export DEN_STATIC_WORKER_URLS=http://worker-01.company.local:8787,http://worker-02.company.local:8787 +export DEN_STATIC_WORKER_HEALTH_PATH=/health +export DEN_STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS=10000 +# Required for LAN/private static worker URLs used by the static pool or admin static-attach. +# Prefer the narrowest CIDR that covers the resolved worker IPs. +export DEN_STATIC_WORKER_ATTACH_ALLOW_PRIVATE=false +export DEN_STATIC_WORKER_ATTACH_ALLOWED_HOSTS=worker-01.company.local,worker-02.company.local +export DEN_STATIC_WORKER_ATTACH_ALLOWED_CIDRS=10.0.0.0/8 +export DEN_BETTER_AUTH_SECRET='' +export DEN_DB_ENCRYPTION_KEY='' +export DEN_MYSQL_ROOT_PASSWORD='' +# Lab/no-SMTP only. Keep verification enabled for production users. +export DEN_REQUIRE_EMAIL_VERIFICATION=false +# DATABASE_URL is separate from DEN_MYSQL_ROOT_PASSWORD so URL-special password characters can be percent-encoded. +# Example for password p@ss:word: mysql://root:p%40ss%3Aword@mysql:3306/openwork_den +export DEN_DATABASE_URL='mysql://root:@mysql:3306/openwork_den' +export DEN_EMAIL_FROM='OpenWork Den ' +export DEN_STATIC_WORKER_TOKEN_MAP_JSON='{"http://worker-01.company.local:8787":{"clientToken":"","hostToken":""},"http://worker-02.company.local:8787":{"clientToken":"","hostToken":""}}' +docker compose -p openwork-den-static -f packaging/docker/docker-compose.den-static.yml up --build -d +docker compose -p openwork-den-static -f packaging/docker/docker-compose.den-static.yml ps +``` + +`DEN_STATIC_WORKER_URLS` is the pool of worker runtimes that Den can allocate. + +`DEN_STATIC_WORKER_TOKEN_MAP_JSON` must contain one entry for every worker URL that Den is allowed to attach as a shared worker. + +When worker containers receive `OPENWORK_TOKEN` and `OPENWORK_HOST_TOKEN` from environment variables or a secret manager, those supplied values remain runtime-only and are not persisted by the image. Only generated fallback token values are written to `/data/openwork-worker.env`, and that fallback should be used only for development or an operator-approved bootstrap. + +`DEN_STATIC_WORKER_ATTACH_ALLOW_PRIVATE=true` allows LAN/private worker URLs broadly. Prefer `DEN_STATIC_WORKER_ATTACH_ALLOWED_CIDRS` when you only need to allow specific internal worker networks. `DEN_STATIC_WORKER_ATTACH_ALLOWED_HOSTS` is still useful for explicit HTTPS hostnames, but private resolved IPs require either a matching CIDR or `DEN_STATIC_WORKER_ATTACH_ALLOW_PRIVATE=true`. + +## Verify Deployment + +Run these checks after the worker and Den are up: + +```bash +curl http://worker-01.company.local:8787/health +docker compose -p openwork-den-static -f packaging/docker/docker-compose.den-static.yml ps +curl $DEN_WEB_ORIGIN/api/den/health +``` + +Expected result: + +- the worker health endpoint returns HTTP 200 JSON +- the `den` and `web` services are `healthy` in `docker compose ps` +- the Den web health endpoint returns HTTP 200 JSON + +At this point, the deployment is up. + +## First Use + +Open the Den web URL in a browser: + +- `https://` + +Create the first account and complete email verification. + +Configure SMTP or Resend before the first real sign-up so the verification email is delivered normally. + +For a closed lab with no SMTP, set `DEN_REQUIRE_EMAIL_VERIFICATION=false` before creating the first account and recreate the Den containers with the same Compose command below. Do not use that setting for production sign-ups. + +After the first account is verified, create the first organization. + +When a shared/static worker is created in `static` mode and `DEN_STATIC_WORKER_URLS` is not empty, Den allocates one configured worker URL for that organization. + +Expected behavior: + +- Den picks one currently free worker URL from `DEN_STATIC_WORKER_URLS` using deterministic worker-id based selection +- Den calls `/health` on that worker URL +- Den verifies the configured client token against `/workspaces` using `Authorization: Bearer ` +- Den verifies the configured host token against `/env/keys` using `X-OpenWork-Host-Token: ` +- Den marks the worker `healthy` only after the runtime contract succeeds + +You can also add another shared worker from the Den UI later. In `static` mode, the UI can allocate a free worker URL from the pre-provisioned pool, but it cannot create a new runtime worker. + +If you need more capacity than the remaining free URLs: + +1. start another runtime worker +2. add its URL to `DEN_STATIC_WORKER_URLS` +3. add its token pair to `DEN_STATIC_WORKER_TOKEN_MAP_JSON` +4. re-export the full Den env in the shell, then recreate Den services so the container receives the new env: + +```bash +docker compose -p openwork-den-static -f packaging/docker/docker-compose.den-static.yml up -d --force-recreate den web worker-proxy +``` + +If the source tree or Docker image changed, rebuild as well: + +```bash +docker compose -p openwork-den-static -f packaging/docker/docker-compose.den-static.yml up --build -d --force-recreate den web worker-proxy +``` + +## Minimal Troubleshooting + +- `Invalid origin` during sign-up or email verification: + - `DEN_BETTER_AUTH_URL`, `DEN_BETTER_AUTH_TRUSTED_ORIGINS`, and `DEN_CORS_ORIGINS` do not match the real browser-facing Den URL +- worker stays `Starting` or becomes `failed`: + - run `curl http://:8787/health` + - inspect Den logs with `docker compose -p openwork-den-static -f packaging/docker/docker-compose.den-static.yml logs den` +- `No available static worker URL remains`: + - every URL in `DEN_STATIC_WORKER_URLS` is already in use, so add another pre-running worker runtime, update `DEN_STATIC_WORKER_URLS` and `DEN_STATIC_WORKER_TOKEN_MAP_JSON`, then recreate Den with `up -d --force-recreate den web worker-proxy` +- `STATIC_WORKER_TOKEN_MAP_JSON must be valid JSON`: + - export it as a single-quoted JSON string in Bash diff --git a/packaging/docker/README.md b/packaging/docker/README.md index 52afe42c46..54fa9339e4 100644 --- a/packaging/docker/README.md +++ b/packaging/docker/README.md @@ -1,5 +1,7 @@ # OpenWork Host (Docker) +For production LAN/on-prem Den deployments, start with [`ONPREM_DEN_STATIC_RUNBOOK.md`](./ONPREM_DEN_STATIC_RUNBOOK.md). It covers Den `PROVISIONER_MODE=static`, operator-managed worker secrets, real OpenWork worker containers, multiple worker URLs, validation, cleanup, decommissioning, and troubleshooting. + ## Den local stack (Docker) One command for the Den control plane, local MySQL, and the cloud web app. @@ -62,8 +64,53 @@ Optional env vars (via `.env` or `export`): - `DEN_MCP_RESOURCE_URL` — API-facing MCP resource URL (defaults to `http://localhost:/mcp`) - `DEN_BETTER_AUTH_TRUSTED_ORIGINS` — trusted origins for Better Auth (defaults to `DEN_CORS_ORIGINS`) - `DEN_CORS_ORIGINS` — trusted origins for Express CORS (defaults include hostname, localhost, `127.0.0.1`, `0.0.0.0`, and detected LAN IPv4) -- `DEN_PROVISIONER_MODE` — `stub` or `render` (defaults to `stub`) +- `DEN_PROVISIONER_MODE` — `stub`, `static`, `render`, or `daytona` (defaults to `stub`) - `DEN_WORKER_URL_TEMPLATE` — stub worker URL template with `{workerId}` placeholder +- `DEN_STATIC_WORKER_URLS` — comma-separated LAN/local OpenWork worker URLs used when `DEN_PROVISIONER_MODE=static`; each URL is assigned to at most one active static worker instance +- `DEN_STATIC_WORKER_TOKEN_MAP_JSON` — JSON map of each static worker URL to `{ "clientToken": "...", "hostToken": "..." }`; required in static mode so Den validates `/workspaces` and `/env/keys` before marking workers healthy +- `DEN_STATIC_WORKER_HEALTH_PATH` — health path checked for static workers (defaults to `/health`) +- `DEN_STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS` — static worker health timeout (defaults to `10000`) + +### On-prem/static worker mode + +Use static mode when Den is self-hosted on a LAN and workers are already running on local infrastructure. Den does not launch those workers; it assigns one configured URL that is not already used by an active static `worker_instance` to each cloud/shared worker request, checks the worker health endpoint, records a `worker_instance`, and marks the worker `healthy` only when the endpoint responds successfully. + +The real worker container path is the production container in this directory (`Dockerfile` + `docker-compose.yml`). The image builds the worker from the source checkout used as the Docker build context; use an approved checkout or release artifact for the version you intend to support. + +Run Den against a real LAN worker: + +```bash +export DEN_PROVISIONER_MODE=static +export DEN_STATIC_WORKER_URLS=http://192.168.1.50:8787 +export DEN_STATIC_WORKER_TOKEN_MAP_JSON='{"http://192.168.1.50:8787":{"clientToken":"","hostToken":""}}' +export DEN_STATIC_WORKER_ATTACH_ALLOWED_CIDRS=192.168.1.0/24 +./packaging/docker/den-dev-up.sh +``` + +`DEN_STATIC_WORKER_TOKEN_MAP_JSON` is required for real LAN workers. The URL keys must exactly match `DEN_STATIC_WORKER_URLS` after trimming trailing slashes, and the values must contain the worker's client token for `/workspaces` plus host token for `/env/keys`. LAN/private static worker URLs also need `DEN_STATIC_WORKER_ATTACH_ALLOWED_CIDRS` or `DEN_STATIC_WORKER_ATTACH_ALLOW_PRIVATE=true` so Den can pin and validate runtime fetches. Without this map Den must fail the static reservation instead of marking an unreachable or unauthenticated worker healthy. + +If you need a non-production compose-only smoke test before wiring a real OpenWork runtime, start the bundled health-only worker simulation: + +```bash +export DEN_PROVISIONER_MODE=static +export DEN_STATIC_WORKER_URLS=http://static-worker-smoke:8787 +export DEN_STATIC_WORKER_TOKEN_MAP_JSON='{"http://static-worker-smoke:8787":{"clientToken":"static-smoke-client-token","hostToken":"static-smoke-host-token"}}' +export DEN_STATIC_WORKER_ATTACH_ALLOW_PRIVATE=true +docker compose --profile static-worker-smoke -p openwork-den-static \ + -f packaging/docker/docker-compose.den-dev.yml up --build +``` + +Validate the sample endpoint from the host: + +```bash +curl http://127.0.0.1:${DEN_STATIC_WORKER_SMOKE_PORT:-8787}/health +curl -H "Authorization: Bearer static-smoke-client-token" http://127.0.0.1:${DEN_STATIC_WORKER_SMOKE_PORT:-8787}/workspaces +curl -H "X-OpenWork-Host-Token: static-smoke-host-token" http://127.0.0.1:${DEN_STATIC_WORKER_SMOKE_PORT:-8787}/env/keys +``` + +Then create a cloud/shared worker in the Den web UI. With a reachable static worker URL, the worker should move from `provisioning` to `healthy` and show a `static` instance. If `DEN_STATIC_WORKER_URLS` is empty or the health check fails, Den marks the worker `failed` and logs a clear provisioning error instead of leaving it stuck on `Starting`. + +The `static-worker-smoke` service is intentionally only a provisioning-contract simulation. It serves `/health`, `/workspaces`, and `/env/keys` with coherent smoke-test tokens so Den static provisioning validates token mapping, but it is not a production OpenWork runtime and will not satisfy session APIs. ### Faster inner-loop alternative @@ -144,7 +191,7 @@ Useful overrides: ## Production container -This is a minimal packaging template to run the OpenWork Host contract in a single container. +This is a minimal packaging template to run the OpenWork Host contract in a single container. In Den static deployments, each instance of this container is a real worker URL for `DEN_STATIC_WORKER_URLS`. It runs: @@ -163,22 +210,56 @@ Then open: - `http://127.0.0.1:8787/ui` +For LAN use, set a stable host port and connect host before launch: + +```bash +OPENWORK_HOST_PORT=8787 \ +OPENWORK_CONNECT_HOST=192.168.1.50 \ +OPENWORK_WORKSPACE_DIR=./workspace-worker-1 \ +OPENWORK_DATA_DIR_HOST=./data-worker-1 \ +docker compose -p openwork-worker-1 up --build -d +``` + +On Windows PowerShell: + +```powershell +$env:OPENWORK_HOST_PORT = "8787" +$env:OPENWORK_CONNECT_HOST = "192.168.1.50" +$env:OPENWORK_WORKSPACE_DIR = "./workspace-worker-1" +$env:OPENWORK_DATA_DIR_HOST = "./data-worker-1" +docker compose -p openwork-worker-1 up --build -d +``` + +Validate the worker before adding it to Den: + +```bash +curl http://192.168.1.50:8787/health +``` + +For production, set `OPENWORK_TOKEN` and `OPENWORK_HOST_TOKEN` from a secret manager or equivalent secure operator channel. Env/secret-manager supplied tokens take precedence over `/data/openwork-worker.env` and are not written back to disk by the container. If a token is unset, the image can generate a stable per-worker fallback token and persist only generated fallback values in `/data/openwork-worker.env`; use that fallback path only for development or an operator-approved bootstrap. Treat `/data/openwork-worker.env` as sensitive bearer-secret material. + ### Config -Recommended env vars: +Required secret inputs for production secret management: - `OPENWORK_TOKEN` (client token) - `OPENWORK_HOST_TOKEN` (host/owner token) +- `OPENWORK_CORS_ORIGINS` set to the exact Den/app browser origins that may call this worker. Do not use `*` with bearer or `X-OpenWork-Host-Token` traffic in production. Optional: +- `OPENWORK_HOST_PORT=8787` (host port mapped to container port 8787) +- `OPENWORK_CONNECT_HOST=` (host embedded in pairing/connect output) +- `OPENWORK_WORKSPACE_DIR=./workspace-worker-1` (host workspace mount) +- `OPENWORK_DATA_DIR_HOST=./data-worker-1` (host data mount) - `OPENWORK_APPROVAL_MODE=auto|manual` - `OPENWORK_APPROVAL_TIMEOUT_MS=30000` +- `OPENWORK_CORS_ORIGINS=http://localhost:8787,http://127.0.0.1:8787` (local-safe default; override for production origins) Persistence: - Workspace is mounted at `/workspace` -- Host data dir is mounted at `/data` (OpenCode caches + OpenWork server config/tokens) +- Host data dir is mounted at `/data` (persistent sidecar and OpenWork server state, including fallback tokens when generated) ### Notes diff --git a/packaging/docker/den-dev-up.sh b/packaging/docker/den-dev-up.sh index ff95c7986a..01b4f7e93f 100755 --- a/packaging/docker/den-dev-up.sh +++ b/packaging/docker/den-dev-up.sh @@ -241,6 +241,11 @@ if ! DEN_API_PORT="$DEN_API_PORT" \ DEN_BETTER_AUTH_TRUSTED_ORIGINS="$DEN_BETTER_AUTH_TRUSTED_ORIGINS" \ DEN_PROVISIONER_MODE="$DEN_PROVISIONER_MODE" \ DEN_WORKER_URL_TEMPLATE="$DEN_WORKER_URL_TEMPLATE" \ + DEN_STATIC_WORKER_URLS="${DEN_STATIC_WORKER_URLS:-}" \ + DEN_STATIC_WORKER_TOKEN_MAP_JSON="${DEN_STATIC_WORKER_TOKEN_MAP_JSON:-}" \ + DEN_STATIC_WORKER_HEALTH_PATH="${DEN_STATIC_WORKER_HEALTH_PATH:-}" \ + DEN_STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS="${DEN_STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS:-}" \ + DEN_STATIC_WORKER_HEALTHCHECK_INTERVAL_MS="${DEN_STATIC_WORKER_HEALTHCHECK_INTERVAL_MS:-}" \ DEN_DAYTONA_WORKER_PROXY_BASE_URL="$DEN_DAYTONA_WORKER_PROXY_BASE_URL" \ DAYTONA_API_URL="${DAYTONA_API_URL:-}" \ DAYTONA_API_KEY="${DAYTONA_API_KEY:-}" \ diff --git a/packaging/docker/docker-compose.den-dev.yml b/packaging/docker/docker-compose.den-dev.yml index 9b0b89caeb..77b04a0fc0 100644 --- a/packaging/docker/docker-compose.den-dev.yml +++ b/packaging/docker/docker-compose.den-dev.yml @@ -18,8 +18,21 @@ # DEN_BETTER_AUTH_URL — browser-facing auth origin (default: http://:) # DEN_BETTER_AUTH_TRUSTED_ORIGINS — Better Auth trusted origins (defaults to DEN_CORS_ORIGINS) # DEN_CORS_ORIGINS — comma-separated trusted origins for Better Auth + CORS -# DEN_PROVISIONER_MODE — stub, render, or daytona (default: stub) +# DEN_PROVISIONER_MODE — stub, static, render, or daytona (default: stub) # DEN_WORKER_URL_TEMPLATE — worker URL template used by stub provisioning +# DEN_STATIC_WORKER_URLS — comma-separated LAN/OpenWork worker URLs for static mode +# — each URL is assigned to at most one active worker_instance +# — for the smoke-test service below, use http://static-worker-smoke:8787 +# DEN_STATIC_WORKER_TOKEN_MAP_JSON +# — JSON map of static worker URLs to {clientToken,hostToken}; required for static mode +# DEN_STATIC_WORKER_HEALTH_PATH / DEN_STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS +# — optional static worker health-check overrides +# DEN_ENTRA_TENANT_ID / DEN_ENTRA_CLIENT_ID / DEN_ENTRA_CLIENT_SECRET +# — optional Microsoft Entra ID SSO provider config +# DEN_ENTRA_AUTO_JOIN_ENABLED / DEN_ENTRA_AUTO_JOIN_ORG_ID +# — optional SSO organization auto-join target +# DEN_ENTRA_ADMIN_GROUP_IDS / DEN_ENTRA_MEMBER_GROUP_IDS +# — comma-separated Entra group object IDs mapped to roles # DAYTONA_API_URL / DAYTONA_API_KEY / DAYTONA_TARGET / DAYTONA_SNAPSHOT # — optional Daytona passthrough vars when DEN_PROVISIONER_MODE=daytona # POLAR_FEATURE_GATE_ENABLED / POLAR_API_BASE / POLAR_ACCESS_TOKEN @@ -81,6 +94,29 @@ services: CORS_ORIGINS: ${DEN_CORS_ORIGINS:-http://localhost:3005,http://127.0.0.1:3005,http://0.0.0.0:3005,http://localhost:5173,http://127.0.0.1:5173,http://localhost:8788,http://127.0.0.1:8788} PROVISIONER_MODE: ${DEN_PROVISIONER_MODE:-stub} WORKER_URL_TEMPLATE: ${DEN_WORKER_URL_TEMPLATE:-} + STATIC_WORKER_URLS: ${DEN_STATIC_WORKER_URLS:-} + STATIC_WORKER_TOKEN_MAP_JSON: ${DEN_STATIC_WORKER_TOKEN_MAP_JSON:-} + STATIC_WORKER_HEALTH_PATH: ${DEN_STATIC_WORKER_HEALTH_PATH:-/health} + STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS: ${DEN_STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS:-10000} + STATIC_WORKER_HEALTHCHECK_INTERVAL_MS: ${DEN_STATIC_WORKER_HEALTHCHECK_INTERVAL_MS:-1000} + STATIC_WORKER_ATTACH_ALLOW_PRIVATE: ${DEN_STATIC_WORKER_ATTACH_ALLOW_PRIVATE:-false} + STATIC_WORKER_ATTACH_ALLOWED_HOSTS: ${DEN_STATIC_WORKER_ATTACH_ALLOWED_HOSTS:-} + STATIC_WORKER_ATTACH_ALLOWED_CIDRS: ${DEN_STATIC_WORKER_ATTACH_ALLOWED_CIDRS:-} + DEN_ENTRA_TENANT_ID: ${DEN_ENTRA_TENANT_ID:-} + DEN_ENTRA_CLIENT_ID: ${DEN_ENTRA_CLIENT_ID:-} + DEN_ENTRA_CLIENT_SECRET: ${DEN_ENTRA_CLIENT_SECRET:-} + DEN_ENTRA_AUTO_JOIN_ENABLED: ${DEN_ENTRA_AUTO_JOIN_ENABLED:-false} + DEN_ENTRA_AUTO_JOIN_ORG_ID: ${DEN_ENTRA_AUTO_JOIN_ORG_ID:-} + DEN_ENTRA_AUTO_JOIN_ORG_SLUG: ${DEN_ENTRA_AUTO_JOIN_ORG_SLUG:-} + DEN_ENTRA_ADMIN_GROUP_IDS: ${DEN_ENTRA_ADMIN_GROUP_IDS:-} + DEN_ENTRA_MEMBER_GROUP_IDS: ${DEN_ENTRA_MEMBER_GROUP_IDS:-} + EMAIL_FROM: ${DEN_EMAIL_FROM:-OpenWork Den } + RESEND_API_KEY: ${DEN_RESEND_API_KEY:-} + SMTP_HOST: ${DEN_SMTP_HOST:-} + SMTP_PORT: ${DEN_SMTP_PORT:-} + SMTP_USER: ${DEN_SMTP_USER:-} + SMTP_PASS: ${DEN_SMTP_PASS:-} + SMTP_SECURE: ${DEN_SMTP_SECURE:-false} POLAR_FEATURE_GATE_ENABLED: ${POLAR_FEATURE_GATE_ENABLED:-false} POLAR_API_BASE: ${POLAR_API_BASE:-} POLAR_ACCESS_TOKEN: ${POLAR_ACCESS_TOKEN:-} @@ -146,5 +182,55 @@ services: DEN_AUTH_ORIGIN: ${DEN_BETTER_AUTH_URL:-http://localhost:3005} NEXT_PUBLIC_OPENWORK_AUTH_CALLBACK_URL: ${DEN_BETTER_AUTH_URL:-http://localhost:3005} + static-worker-smoke: + <<: *shared + image: node:20-alpine + profiles: ["static-worker-smoke"] + command: + - node + - -e + - | + const clientToken = process.env.OPENWORK_TOKEN || 'static-smoke-client-token'; + const hostToken = process.env.OPENWORK_HOST_TOKEN || 'static-smoke-host-token'; + const json = (res, status, body) => { + res.writeHead(status, {'content-type':'application/json'}); + res.end(JSON.stringify(body)); + }; + require('http').createServer((req,res)=>{ + if (req.url === '/health') { + json(res, 200, {ok:true, mode:'static-worker-smoke'}); + return; + } + if (req.url === '/workspaces') { + const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim(); + if (bearer !== clientToken) { + json(res, 401, {error:'invalid_client_token'}); + return; + } + json(res, 200, {workspaces:[{id:'static-smoke-workspace', name:'Static Smoke Workspace', path:'/workspace'}]}); + return; + } + if (req.url === '/env/keys') { + if ((req.headers['x-openwork-host-token'] || '').trim() !== hostToken) { + json(res, 401, {error:'invalid_host_token'}); + return; + } + json(res, 200, {keys:[], source:'static-worker-smoke'}); + return; + } + json(res, 404, {error:'not_found'}); + }).listen(8787, '0.0.0.0') + environment: + OPENWORK_TOKEN: ${DEN_STATIC_WORKER_SMOKE_OPENWORK_TOKEN:-static-smoke-client-token} + OPENWORK_HOST_TOKEN: ${DEN_STATIC_WORKER_SMOKE_OPENWORK_HOST_TOKEN:-static-smoke-host-token} + ports: + - "${DEN_STATIC_WORKER_SMOKE_PORT:-8787}:8787" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + volumes: den-mysql-data: diff --git a/packaging/docker/docker-compose.den-static.yml b/packaging/docker/docker-compose.den-static.yml new file mode 100644 index 0000000000..ad158b5b76 --- /dev/null +++ b/packaging/docker/docker-compose.den-static.yml @@ -0,0 +1,154 @@ +# docker-compose.den-static.yml — Den static deployment stack +# +# Use this file for self-hosted Den deployments where worker runtimes already +# exist and Den should allocate them from a fixed pool. + +x-shared: &shared + restart: unless-stopped + +services: + mysql: + image: mysql:8.4 + restart: unless-stopped + command: + - --performance_schema=OFF + - --innodb-buffer-pool-size=64M + - --innodb-log-buffer-size=8M + - --tmp-table-size=16M + - --max-heap-table-size=16M + environment: + MYSQL_ROOT_PASSWORD: ${DEN_MYSQL_ROOT_PASSWORD:?DEN_MYSQL_ROOT_PASSWORD is required} + MYSQL_DATABASE: openwork_den + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=\"$${MYSQL_ROOT_PASSWORD}\" mysqladmin ping -h 127.0.0.1 --silent"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 10s + expose: + - "3306" + volumes: + - den-mysql-data:/var/lib/mysql + + den: + <<: *shared + build: + context: ../../ + dockerfile: packaging/docker/Dockerfile.den + depends_on: + mysql: + condition: service_healthy + expose: + - "8788" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8788/health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 120s + environment: + CI: "true" + OPENWORK_DEV_MODE: ${OPENWORK_DEV_MODE:-0} + # Provide a complete URL so passwords with URL-special characters can be percent-encoded. + # Example: mysql://root:p%40ss%3Aword@mysql:3306/openwork_den + DATABASE_URL: ${DEN_DATABASE_URL:?DEN_DATABASE_URL is required; percent-encode URL-special password characters} + BETTER_AUTH_SECRET: ${DEN_BETTER_AUTH_SECRET:?DEN_BETTER_AUTH_SECRET is required} + DEN_DB_ENCRYPTION_KEY: ${DEN_DB_ENCRYPTION_KEY:?DEN_DB_ENCRYPTION_KEY is required} + BETTER_AUTH_URL: ${DEN_BETTER_AUTH_URL:?DEN_BETTER_AUTH_URL is required} + DEN_REQUIRE_EMAIL_VERIFICATION: ${DEN_REQUIRE_EMAIL_VERIFICATION:-} + DEN_MCP_RESOURCE_URL: ${DEN_MCP_RESOURCE_URL:-} + DEN_BETTER_AUTH_TRUSTED_ORIGINS: ${DEN_BETTER_AUTH_TRUSTED_ORIGINS:?DEN_BETTER_AUTH_TRUSTED_ORIGINS is required} + PORT: "8788" + CORS_ORIGINS: ${DEN_CORS_ORIGINS:?DEN_CORS_ORIGINS is required} + PROVISIONER_MODE: ${DEN_PROVISIONER_MODE:?DEN_PROVISIONER_MODE is required} + WORKER_URL_TEMPLATE: ${DEN_WORKER_URL_TEMPLATE:-} + STATIC_WORKER_URLS: ${DEN_STATIC_WORKER_URLS:?DEN_STATIC_WORKER_URLS is required} + STATIC_WORKER_TOKEN_MAP_JSON: ${DEN_STATIC_WORKER_TOKEN_MAP_JSON:?DEN_STATIC_WORKER_TOKEN_MAP_JSON is required} + STATIC_WORKER_HEALTH_PATH: ${DEN_STATIC_WORKER_HEALTH_PATH:-/health} + STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS: ${DEN_STATIC_WORKER_HEALTHCHECK_TIMEOUT_MS:-10000} + STATIC_WORKER_HEALTHCHECK_INTERVAL_MS: ${DEN_STATIC_WORKER_HEALTHCHECK_INTERVAL_MS:-1000} + STATIC_WORKER_ATTACH_ALLOW_PRIVATE: ${DEN_STATIC_WORKER_ATTACH_ALLOW_PRIVATE:-false} + STATIC_WORKER_ATTACH_ALLOWED_HOSTS: ${DEN_STATIC_WORKER_ATTACH_ALLOWED_HOSTS:-} + STATIC_WORKER_ATTACH_ALLOWED_CIDRS: ${DEN_STATIC_WORKER_ATTACH_ALLOWED_CIDRS:-} + DEN_ENTRA_TENANT_ID: ${DEN_ENTRA_TENANT_ID:-} + DEN_ENTRA_CLIENT_ID: ${DEN_ENTRA_CLIENT_ID:-} + DEN_ENTRA_CLIENT_SECRET: ${DEN_ENTRA_CLIENT_SECRET:-} + DEN_ENTRA_AUTO_JOIN_ENABLED: ${DEN_ENTRA_AUTO_JOIN_ENABLED:-false} + DEN_ENTRA_AUTO_JOIN_ORG_ID: ${DEN_ENTRA_AUTO_JOIN_ORG_ID:-} + DEN_ENTRA_AUTO_JOIN_ORG_SLUG: ${DEN_ENTRA_AUTO_JOIN_ORG_SLUG:-} + DEN_ENTRA_ADMIN_GROUP_IDS: ${DEN_ENTRA_ADMIN_GROUP_IDS:-} + DEN_ENTRA_MEMBER_GROUP_IDS: ${DEN_ENTRA_MEMBER_GROUP_IDS:-} + EMAIL_FROM: ${DEN_EMAIL_FROM:?DEN_EMAIL_FROM is required} + RESEND_API_KEY: ${DEN_RESEND_API_KEY:-} + SMTP_HOST: ${DEN_SMTP_HOST:-} + SMTP_PORT: ${DEN_SMTP_PORT:-} + SMTP_USER: ${DEN_SMTP_USER:-} + SMTP_PASS: ${DEN_SMTP_PASS:-} + SMTP_SECURE: ${DEN_SMTP_SECURE:-false} + POLAR_FEATURE_GATE_ENABLED: ${POLAR_FEATURE_GATE_ENABLED:-false} + POLAR_API_BASE: ${POLAR_API_BASE:-} + POLAR_ACCESS_TOKEN: ${POLAR_ACCESS_TOKEN:-} + POLAR_PRODUCT_ID: ${POLAR_PRODUCT_ID:-} + POLAR_BENEFIT_ID: ${POLAR_BENEFIT_ID:-} + POLAR_SUCCESS_URL: ${POLAR_SUCCESS_URL:-} + POLAR_RETURN_URL: ${POLAR_RETURN_URL:-} + DAYTONA_API_URL: ${DAYTONA_API_URL:-} + DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} + DAYTONA_TARGET: ${DAYTONA_TARGET:-} + DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-} + DAYTONA_WORKER_PROXY_BASE_URL: ${DEN_DAYTONA_WORKER_PROXY_BASE_URL:-http://worker-proxy:8789} + + worker-proxy: + <<: *shared + build: + context: ../../ + dockerfile: packaging/docker/Dockerfile.den-worker-proxy + depends_on: + mysql: + condition: service_healthy + expose: + - "8789" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8789/unknown').then((res)=>process.exit([404,502].includes(res.status)?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 90s + environment: + CI: "true" + DATABASE_URL: ${DEN_DATABASE_URL:?DEN_DATABASE_URL is required; percent-encode URL-special password characters} + PORT: "8789" + OPENWORK_DAYTONA_ENV_PATH: ${OPENWORK_DAYTONA_ENV_PATH:-} + DAYTONA_API_URL: ${DAYTONA_API_URL:-} + DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} + DAYTONA_TARGET: ${DAYTONA_TARGET:-} + DAYTONA_OPENWORK_PORT: ${DAYTONA_OPENWORK_PORT:-8787} + DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS: ${DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS:-86400} + + web: + <<: *shared + build: + context: ../../ + dockerfile: packaging/docker/Dockerfile.den-web + command: ["sh", "-lc", "npm run build && npm run start"] + depends_on: + den: + condition: service_healthy + ports: + - "${DEN_WEB_PORT:-3005}:3005" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3005/api/den/health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"] + interval: 5s + timeout: 10s + retries: 30 + start_period: 180s + environment: + CI: "true" + OPENWORK_DEV_MODE: ${OPENWORK_DEV_MODE:-0} + DEN_API_BASE: http://den:8788 + DEN_AUTH_FALLBACK_BASE: http://den:8788 + DEN_AUTH_ORIGIN: ${DEN_BETTER_AUTH_URL:?DEN_BETTER_AUTH_URL is required} + NEXT_PUBLIC_OPENWORK_AUTH_CALLBACK_URL: ${DEN_BETTER_AUTH_URL:?DEN_BETTER_AUTH_URL is required} + +volumes: + den-mysql-data: diff --git a/packaging/docker/docker-compose.yml b/packaging/docker/docker-compose.yml index 5e50de5fc2..6a4ff623f4 100644 --- a/packaging/docker/docker-compose.yml +++ b/packaging/docker/docker-compose.yml @@ -1,23 +1,34 @@ services: openwork-host: build: - context: . - dockerfile: Dockerfile - args: - # Keep this in sync with apps/orchestrator/package.json if you want a pinned release. - OPENWORK_ORCHESTRATOR_VERSION: 0.11.22 + context: ../../ + dockerfile: packaging/docker/Dockerfile ports: - - "8787:8787" + - "${OPENWORK_HOST_PORT:-8787}:8787" environment: - # Set these explicitly for stable sharing. - # OPENWORK_TOKEN: "..." - # OPENWORK_HOST_TOKEN: "..." + # Optional. If unset, the image generates stable per-worker tokens in /data/openwork-worker.env. + OPENWORK_TOKEN: ${OPENWORK_TOKEN:-} + OPENWORK_HOST_TOKEN: ${OPENWORK_HOST_TOKEN:-} + # Set to the LAN IP/DNS name clients and Den should use for this worker. + OPENWORK_CONNECT_HOST: ${OPENWORK_CONNECT_HOST:-127.0.0.1} # Optional: OPENWORK_APPROVAL_MODE: "auto" # Optional: OPENWORK_APPROVAL_TIMEOUT_MS: "30000" + OPENWORK_APPROVAL_MODE: ${OPENWORK_APPROVAL_MODE:-manual} + OPENWORK_APPROVAL_TIMEOUT_MS: ${OPENWORK_APPROVAL_TIMEOUT_MS:-30000} + # Set this to the exact browser/app origins that may call this worker. + # Wildcard CORS with bearer/host-token headers is intentionally not the default. + OPENWORK_CORS_ORIGINS: "${OPENWORK_CORS_ORIGINS:-http://localhost:8787,http://127.0.0.1:8787}" + OPENWORK_PORT: 8787 OPENWORK_DATA_DIR: /data/openwork-orchestrator OPENWORK_SIDECAR_DIR: /data/sidecars volumes: # Mount an existing project/workspace here. - - ./workspace:/workspace + - ${OPENWORK_WORKSPACE_DIR:-./workspace}:/workspace # Persistent host data (OpenCode caches, server config, tokens). - - ./data:/data + - ${OPENWORK_DATA_DIR_HOST:-./data}:/data + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8787/health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s diff --git a/packaging/docker/microsandbox-entrypoint.sh b/packaging/docker/microsandbox-entrypoint.sh index 40045d37a0..bec0f5a436 100755 --- a/packaging/docker/microsandbox-entrypoint.sh +++ b/packaging/docker/microsandbox-entrypoint.sh @@ -9,7 +9,7 @@ OPENWORK_OPENCODE_PORT="${OPENWORK_OPENCODE_PORT:-4096}" OPENWORK_TOKEN="${OPENWORK_TOKEN:-microsandbox-token}" OPENWORK_HOST_TOKEN="${OPENWORK_HOST_TOKEN:-microsandbox-host-token}" OPENWORK_APPROVAL_MODE="${OPENWORK_APPROVAL_MODE:-auto}" -OPENWORK_CORS_ORIGINS="${OPENWORK_CORS_ORIGINS:-*}" +OPENWORK_CORS_ORIGINS="${OPENWORK_CORS_ORIGINS:-http://localhost:$OPENWORK_PORT,http://127.0.0.1:$OPENWORK_PORT}" OPENWORK_CONNECT_HOST="${OPENWORK_CONNECT_HOST:-127.0.0.1}" HOME="${HOME:-/root}" USER="${USER:-root}" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdd7a3ed91..6565c1ee2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -480,6 +480,9 @@ importers: stripe: specifier: ^22.1.1 version: 22.1.1(@types/node@20.12.12) + tsx: + specifier: ^4.15.7 + version: 4.21.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -490,9 +493,6 @@ importers: '@types/node': specifier: ^20.11.30 version: 20.12.12 - tsx: - specifier: ^4.15.7 - version: 4.21.0 typescript: specifier: ^5.5.4 version: 5.9.3