// Desktop workspace persistence and bootstrap configuration. This module owns // on-disk workspace state, per-workspace openwork.json files, remote workspace // normalization/discovery, and the workspace-facing command operations. import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync, statSync } from "node:fs"; import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { desktopBootstrapPath as resolveDesktopBootstrapPath, legacyDesktopBootstrapPath as resolveLegacyDesktopBootstrapPath, normalizeWorkspaceRootPath, openworkServerConfigPath as resolveOpenworkServerConfigPath, } from "@openwork/paths"; import { openworkWorkspaceDisplayName, selectOpenworkWorkspaceForConnection } from "./remote-workspace.mjs"; import { exportWorkspaceConfig, importWorkspaceConfig } from "./workspace-archive.mjs"; const EMPTY_WORKSPACE_LIST = Object.freeze({ selectedId: "", watchedId: null, activeId: null, workspaces: [], }); function execResult(ok, stdout = "", stderr = "", status = ok ? 0 : 1) { return { ok, status, stdout, stderr }; } async function pathExists(targetPath) { try { await stat(targetPath); return true; } catch { return false; } } function parseFirstJsonObject(raw) { let inString = false; let escaped = false; let depth = 0; let start = -1; for (let index = 0; index < raw.length; index += 1) { const char = raw[index]; if (inString) { if (escaped) { escaped = false; } else if (char === "\\") { escaped = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; continue; } if (char === "{") { if (depth === 0) start = index; depth += 1; continue; } if (char === "}") { depth -= 1; if (depth === 0 && start >= 0) { try { return { ok: true, value: JSON.parse(raw.slice(start, index + 1)) }; } catch { return { ok: false, value: null }; } } } } return { ok: false, value: null }; } async function writeJsonFileAtomic(outputPath, value) { const content = `${JSON.stringify(value, null, 2)}\n`; JSON.parse(content); await mkdir(path.dirname(outputPath), { recursive: true }); const tempPath = `${outputPath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; await writeFile(tempPath, content, "utf8"); await rename(tempPath, outputPath); } async function readJsonFile(targetPath, fallback) { try { const raw = await readFile(targetPath, "utf8"); try { return JSON.parse(raw); } catch (error) { const recovered = parseFirstJsonObject(raw); if (recovered.ok) { console.warn(`[json] recovered ${targetPath} from trailing invalid data`, error); await writeJsonFileAtomic(targetPath, recovered.value); return recovered.value; } throw error; } } catch { return fallback; } } // Resolved once at module load so a mid-session process.env mutation // (runtime.mjs buildChildEnv -> Object.assign(process.env)) can never retarget // reads to a different file. const DEFAULT_DESKTOP_BOOTSTRAP_PATH = resolveDesktopBootstrapPath({ homeDir: os.homedir() }); // Older builds resolved the default as ~/.config on every OS, ignoring // LOCALAPPDATA and XDG_CONFIG_HOME. Keep reading that file when the canonical one // is missing so existing installs keep their deployment config. const LEGACY_DESKTOP_BOOTSTRAP_PATH = resolveLegacyDesktopBootstrapPath({ homeDir: os.homedir() }); const HOSTED_DESKTOP_WEB_URL = "https://app.openworklabs.com"; const HOSTED_DESKTOP_API_URL = "https://api.openworklabs.com"; function bootstrapUrlOrigin(value) { if (typeof value !== "string" || !value.trim()) return ""; try { return new URL(value.trim()).origin; } catch { return value.trim().replace(/\/+$/, ""); } } function isHostedDesktopBootstrapConfig(config) { const baseUrlOrigin = bootstrapUrlOrigin(config?.baseUrl); return baseUrlOrigin === HOSTED_DESKTOP_WEB_URL || baseUrlOrigin === HOSTED_DESKTOP_API_URL; } export function createWorkspaceStore({ app, defaultDenBaseUrl, defaultRequireSignin, forceRequireSignin, }) { function desktopBootstrapPath() { if (process.env.OPENWORK_DESKTOP_BOOTSTRAP_PATH?.trim()) { return resolveDesktopBootstrapPath({ env: process.env, homeDir: os.homedir(), userDataDir: app.getPath("userData") }); } // Dev mode swaps process.env.HOME to the sandboxed dev-data home midway // through startup (runtime.mjs buildChildEnv -> Object.assign(process.env)), // which changes what os.homedir() returns. Resolve the dev-data home // deterministically so early and late IPC reads target the same file. if (process.env.OPENWORK_DEV_MODE === "1") { return resolveDesktopBootstrapPath({ env: process.env, homeDir: os.homedir(), userDataDir: app.getPath("userData") }); } return DEFAULT_DESKTOP_BOOTSTRAP_PATH; } function legacyDesktopBootstrapPath() { // An explicit bootstrap path defines an isolated installation boundary. // Never let a legacy global config cross that boundary: it may contain a // completed activation from another distribution or deployment. if (process.env.OPENWORK_DESKTOP_BOOTSTRAP_PATH?.trim()) return null; const primary = desktopBootstrapPath(); if (primary === DEFAULT_DESKTOP_BOOTSTRAP_PATH && LEGACY_DESKTOP_BOOTSTRAP_PATH !== primary) { return LEGACY_DESKTOP_BOOTSTRAP_PATH; } return null; } function workspaceStatePath() { const override = process.env.OPENWORK_DESKTOP_WORKSPACE_STATE_PATH?.trim(); if (override) return path.resolve(override); return path.join(app.getPath("userData"), "openwork-workspaces.json"); } function openworkServerTokenStorePath() { const override = process.env.OPENWORK_SERVER_TOKEN_STORE_PATH?.trim(); if (override) return path.resolve(override); return path.join(app.getPath("userData"), "openwork-server-tokens.json"); } function openworkServerConfigPath() { return resolveOpenworkServerConfigPath({ env: process.env, homeDir: os.homedir() }); } // Earlier Electron alpha builds copied Tauri's openwork-workspaces.json into // an Electron-only workspace-state.json. Keep importing that file when the // shared canonical file is missing, but write openwork-workspaces.json going // forward so Tauri rollback and Electron both read the same desktop state. function legacyElectronWorkspaceStatePath() { return path.join(app.getPath("userData"), "workspace-state.json"); } async function migrateLegacyElectronWorkspaceStateIfNeeded() { const current = workspaceStatePath(); const legacy = legacyElectronWorkspaceStatePath(); try { if (existsSync(current)) return false; if (!existsSync(legacy)) return false; await mkdir(path.dirname(current), { recursive: true }); const raw = await readFile(legacy, "utf8"); await writeFile(current, raw, "utf8"); console.info("[migration] copied workspace-state.json to openwork-workspaces.json"); return true; } catch (error) { console.warn("[migration] legacy Electron workspace-state copy failed", error); return false; } } function normalizeDesktopBootstrapConfig(input) { const baseUrl = typeof input?.baseUrl === "string" ? input.baseUrl.trim() : ""; if (!baseUrl) { throw new Error("baseUrl is required"); } // The handoff grant is a one-time, short-lived (~5 min) desktop sign-in // token written to this machine-local config by the bootstrap CLI. The app // exchanges it once on boot and then rewrites this file with `handoff: null` // (see den-auth-provider) so it is never reusable. It is intentionally kept // in plaintext here because it is single-use and local-only; do not persist // long-lived secrets in this file. const handoffInput = input?.handoff; const handoff = handoffInput && typeof handoffInput === "object" ? { grant: typeof handoffInput.grant === "string" ? handoffInput.grant.trim() : "", denBaseUrl: typeof handoffInput.denBaseUrl === "string" ? handoffInput.denBaseUrl.trim() : "", orgId: typeof handoffInput.orgId === "string" ? handoffInput.orgId.trim() : "", orgName: typeof handoffInput.orgName === "string" ? handoffInput.orgName.trim() : "", orgSlug: typeof handoffInput.orgSlug === "string" ? handoffInput.orgSlug.trim() : "", skillId: typeof handoffInput.skillId === "string" ? handoffInput.skillId.trim() : "", skillTitle: typeof handoffInput.skillTitle === "string" ? handoffInput.skillTitle.trim() : "", createdAt: typeof handoffInput.createdAt === "string" ? handoffInput.createdAt.trim() : "", } : null; const normalizedHandoff = handoff?.grant && handoff.denBaseUrl && handoff.orgId && handoff.orgName && handoff.skillId && handoff.skillTitle ? handoff : null; const preparedInput = input?.prepared; const prepared = preparedInput && typeof preparedInput === "object" ? { orgId: typeof preparedInput.orgId === "string" ? preparedInput.orgId.trim() : "", orgName: typeof preparedInput.orgName === "string" ? preparedInput.orgName.trim() : "", orgSlug: typeof preparedInput.orgSlug === "string" ? preparedInput.orgSlug.trim() : "", skillId: typeof preparedInput.skillId === "string" ? preparedInput.skillId.trim() : "", skillTitle: typeof preparedInput.skillTitle === "string" ? preparedInput.skillTitle.trim() : "", skillsDir: typeof preparedInput.skillsDir === "string" ? preparedInput.skillsDir.trim() : "", skillPath: typeof preparedInput.skillPath === "string" ? preparedInput.skillPath.trim() : "", preparedAt: typeof preparedInput.preparedAt === "string" ? preparedInput.preparedAt.trim() : "", } : null; const normalizedPrepared = prepared?.orgId && prepared.orgName && prepared.skillId && prepared.skillTitle && prepared.skillPath ? prepared : null; const claimLinksInput = Array.isArray(input?.claimLinks) ? input.claimLinks : []; const claimLinks = claimLinksInput.flatMap((link) => { if (!link || typeof link !== "object") return []; const id = typeof link.id === "string" ? link.id.trim() : ""; const role = typeof link.role === "string" ? link.role.trim() : ""; const token = typeof link.token === "string" ? link.token.trim() : ""; const url = typeof link.url === "string" ? link.url.trim() : ""; const expiresAt = typeof link.expiresAt === "string" ? link.expiresAt.trim() : ""; return id && role && url && expiresAt ? [{ id, role, ...(token ? { token } : {}), url, expiresAt }] : []; }); const writtenAt = typeof input?.writtenAt === "string" ? input.writtenAt.trim() : ""; const apiBaseUrl = typeof input?.apiBaseUrl === "string" ? input.apiBaseUrl.trim() : ""; const brandAppName = typeof input?.brandAppName === "string" ? input.brandAppName.trim().slice(0, 64) : ""; const brandLogoUrl = typeof input?.brandLogoUrl === "string" ? input.brandLogoUrl.trim() : ""; const brandIconUrl = typeof input?.brandIconUrl === "string" ? input.brandIconUrl.trim() : ""; const enterpriseActivationInput = input?.enterpriseActivation; const enterpriseActivation = enterpriseActivationInput && typeof enterpriseActivationInput === "object" ? { activatedAt: typeof enterpriseActivationInput.activatedAt === "string" ? enterpriseActivationInput.activatedAt.trim() : "", denBaseUrl: typeof enterpriseActivationInput.denBaseUrl === "string" ? enterpriseActivationInput.denBaseUrl.trim() : "", } : null; const normalizedEnterpriseActivation = enterpriseActivation?.activatedAt && enterpriseActivation.denBaseUrl ? enterpriseActivation : null; return { baseUrl, ...(apiBaseUrl ? { apiBaseUrl } : {}), requireSignin: forceRequireSignin || input?.requireSignin === true, // Only an explicit policy is carried. The artifact default is never // materialized here: desktop-bootstrap.json is shared by both flavors // (one application identifier, one user-data directory), so persisting // the enterprise default would gate the public artifact on the same // machine. Consumers fall back to their own build default when the key // is absent, which is exactly the documented precedence. ...(typeof input?.requireActivation === "boolean" ? { requireActivation: input.requireActivation } : {}), ...(brandAppName ? { brandAppName } : {}), ...(brandLogoUrl ? { brandLogoUrl } : {}), ...(brandIconUrl ? { brandIconUrl } : {}), ...(writtenAt ? { writtenAt } : {}), ...(claimLinks.length > 0 ? { claimLinks } : {}), ...(normalizedHandoff ? { handoff: normalizedHandoff } : {}), ...(normalizedPrepared ? { prepared: normalizedPrepared } : {}), ...(normalizedEnterpriseActivation ? { enterpriseActivation: normalizedEnterpriseActivation } : {}), }; } function desktopBootstrapCandidateTimeMs(candidate) { const writtenAt = typeof candidate.parsed?.writtenAt === "string" ? candidate.parsed.writtenAt.trim() : ""; const writtenAtMs = writtenAt ? Date.parse(writtenAt) : Number.NaN; return Number.isFinite(writtenAtMs) ? writtenAtMs : candidate.mtimeMs; } function compareDesktopBootstrapCandidates(left, right) { const classDifference = Number(!isHostedDesktopBootstrapConfig(left.normalized)) - Number(!isHostedDesktopBootstrapConfig(right.normalized)); return classDifference || desktopBootstrapCandidateTimeMs(left) - desktopBootstrapCandidateTimeMs(right); } async function readDesktopBootstrapCandidate(candidatePath) { let exists = false; let mtimeMs = 0; try { const stats = await stat(candidatePath); exists = true; mtimeMs = stats.mtimeMs; } catch { // Missing paths are normal; keep the read error below for diagnostics. } try { const raw = await readFile(candidatePath, "utf8"); const parsed = JSON.parse(raw); return { ok: true, path: candidatePath, exists: true, raw, parsed, normalized: normalizeDesktopBootstrapConfig(parsed), mtimeMs, error: null, }; } catch (error) { return { ok: false, path: candidatePath, exists, raw: null, parsed: null, normalized: null, mtimeMs, error, }; } } function readDesktopBootstrapCandidateSync(candidatePath) { let exists = false; let mtimeMs = 0; try { const stats = statSync(candidatePath); exists = true; mtimeMs = stats.mtimeMs; } catch { // Missing paths are normal; keep the read error below for diagnostics. } try { const raw = readFileSync(candidatePath, "utf8"); const parsed = JSON.parse(raw); return { ok: true, path: candidatePath, exists: true, raw, parsed, normalized: normalizeDesktopBootstrapConfig(parsed), mtimeMs, error: null, }; } catch (error) { return { ok: false, path: candidatePath, exists, raw: null, parsed: null, normalized: null, mtimeMs, error, }; } } async function migrateLegacyDesktopBootstrapConfig(configPath, legacyCandidate) { try { await mkdir(path.dirname(configPath), { recursive: true }); await writeFile(configPath, legacyCandidate.raw, "utf8"); console.info("[desktop-bootstrap] migrated legacy config", { from: legacyCandidate.path, to: configPath, }); } catch (migrationError) { console.warn("[desktop-bootstrap] legacy config migration failed", migrationError); } } async function getDesktopBootstrapConfig() { const configPath = desktopBootstrapPath(); const primary = await readDesktopBootstrapCandidate(configPath); const legacyPath = legacyDesktopBootstrapPath(); const legacy = legacyPath ? await readDesktopBootstrapCandidate(legacyPath) : null; if (primary.ok && legacy?.ok) { if (compareDesktopBootstrapCandidates(legacy, primary) > 0) { await migrateLegacyDesktopBootstrapConfig(configPath, legacy); return { ...legacy.normalized, fromFile: true }; } return { ...primary.normalized, fromFile: true }; } if (primary.ok) return { ...primary.normalized, fromFile: true }; if (legacy?.ok) { await migrateLegacyDesktopBootstrapConfig(configPath, legacy); return { ...legacy.normalized, fromFile: true }; } console.warn("[desktop-bootstrap] falling back to defaults", { path: configPath, error: primary.error instanceof Error ? primary.error.message : String(primary.error), }); return { baseUrl: defaultDenBaseUrl, requireSignin: defaultRequireSignin, fromFile: false, }; } function readDesktopBootstrapConfigSync() { const configPath = desktopBootstrapPath(); const primary = readDesktopBootstrapCandidateSync(configPath); const legacyPath = legacyDesktopBootstrapPath(); const legacy = legacyPath ? readDesktopBootstrapCandidateSync(legacyPath) : null; if (primary.ok && legacy?.ok) { return { ...(compareDesktopBootstrapCandidates(legacy, primary) > 0 ? legacy.normalized : primary.normalized), fromFile: true, }; } if (primary.ok) return { ...primary.normalized, fromFile: true }; if (legacy?.ok) return { ...legacy.normalized, fromFile: true }; return { baseUrl: defaultDenBaseUrl, requireSignin: defaultRequireSignin, fromFile: false, }; } async function debugDesktopBootstrapConfig() { const configPath = desktopBootstrapPath(); const legacyPath = legacyDesktopBootstrapPath(); const result = { path: configPath, legacyPath, legacyExists: legacyPath ? existsSync(legacyPath) : false, home: os.homedir(), envHome: process.env.HOME ?? null, envOverride: process.env.OPENWORK_DESKTOP_BOOTSTRAP_PATH ?? null, exists: existsSync(configPath), raw: null, parsed: null, normalized: null, error: null, }; try { result.raw = await readFile(configPath, "utf8"); result.parsed = JSON.parse(result.raw); result.normalized = normalizeDesktopBootstrapConfig(result.parsed); } catch (error) { result.error = error instanceof Error ? error.message : String(error); } return result; } async function setDesktopBootstrapConfig(config) { const normalized = normalizeDesktopBootstrapConfig(config); const outputPath = desktopBootstrapPath(); const stamped = { ...normalized, writtenAt: new Date().toISOString() }; await writeJsonFileAtomic(outputPath, stamped); return stamped; } async function clearDesktopBootstrapFiles() { await rm(desktopBootstrapPath(), { force: true }); const legacyPath = legacyDesktopBootstrapPath(); if (legacyPath) { await rm(legacyPath, { force: true }); } } async function clearDesktopBootstrapConfig() { await clearDesktopBootstrapFiles(); return undefined; } function defaultWorkspaceOpenworkConfig(workspacePath, preset = null) { return { version: 1, workspace: workspacePath ? { name: path.basename(workspacePath) || "Workspace", createdAt: Date.now(), preset: preset || null, } : null, authorizedRoots: workspacePath ? [workspacePath] : [], reload: null, }; } async function normalizeLocalWorkspacePath(rawPath) { const trimmed = String(rawPath ?? "").trim(); if (!trimmed) return ""; const expanded = trimmed === "~" ? os.homedir() : trimmed.startsWith("~/") || trimmed.startsWith("~\\") ? path.join(os.homedir(), trimmed.slice(2)) : trimmed; const normalized = normalizeWorkspaceRootPath(expanded, { platform: process.platform }); const resolved = path.resolve(normalized); return realpath(resolved).catch(() => resolved); } function normalizeWorkspacePathKey(value) { try { const normalized = normalizeWorkspaceRootPath(value, { platform: process.platform }); return normalized ? path.resolve(normalized).replace(/\\/g, "/").toLowerCase() : ""; } catch { return ""; } } function normalizeRecoveredWorkspacePath(value) { try { return normalizeWorkspaceRootPath(value, { platform: process.platform }); } catch { return ""; } } function isRecord(value) { return typeof value === "object" && value !== null; } async function recoverWorkspacesFromTokenStore() { const store = await readJsonFile(openworkServerTokenStorePath(), null); if (!isRecord(store) || !isRecord(store.workspaces)) return []; const candidates = []; for (const [rawPath, entry] of Object.entries(store.workspaces)) { const normalizedInput = normalizeRecoveredWorkspacePath(rawPath); if (!normalizedInput) continue; const workspacePath = await normalizeLocalWorkspacePath(normalizedInput); if (!(await pathExists(workspacePath))) continue; candidates.push({ path: workspacePath, updatedAt: isRecord(entry) && typeof entry.updatedAt === "number" ? entry.updatedAt : 0, }); } candidates.sort((left, right) => right.updatedAt - left.updatedAt); const seen = new Set(); return candidates.flatMap((candidate) => { const key = normalizeWorkspacePathKey(candidate.path); if (!key || seen.has(key)) return []; seen.add(key); return [normalizeWorkspaceEntry({ id: localWorkspaceId(candidate.path), name: path.basename(candidate.path) || "Workspace", displayName: path.basename(candidate.path) || "Workspace", path: candidate.path, preset: "starter", workspaceType: "local", })]; }); } async function forgetWorkspaceToken(workspacePath) { const workspaceKey = normalizeWorkspacePathKey(workspacePath); if (!workspaceKey) return; const store = await readJsonFile(openworkServerTokenStorePath(), null); if (!isRecord(store) || !isRecord(store.workspaces)) return; const workspaces = { ...store.workspaces }; let changed = false; for (const storedPath of Object.keys(workspaces)) { if (normalizeWorkspacePathKey(normalizeRecoveredWorkspacePath(storedPath)) !== workspaceKey) continue; delete workspaces[storedPath]; changed = true; } if (changed) { await writeJsonFileAtomic(openworkServerTokenStorePath(), { ...store, workspaces }); } } async function recoverWorkspacesFromServerConfig() { const config = await readJsonFile(openworkServerConfigPath(), null); if (!isRecord(config) || !Array.isArray(config.workspaces)) return []; const seen = new Set(); const workspaces = []; for (const entry of config.workspaces) { if (!isRecord(entry)) continue; const workspaceType = entry.workspaceType === "remote" ? "remote" : "local"; const rawPath = typeof entry.path === "string" ? entry.path.trim() : ""; const normalizedPath = workspaceType === "local" ? await normalizeLocalWorkspacePath(normalizeRecoveredWorkspacePath(rawPath)) : rawPath; if (workspaceType === "local" && (!normalizedPath || !(await pathExists(normalizedPath)))) continue; const baseUrl = typeof entry.baseUrl === "string" ? entry.baseUrl.trim() : ""; const directory = typeof entry.directory === "string" && entry.directory.trim() ? entry.directory.trim() : null; const remoteType = entry.remoteType === "opencode" ? "opencode" : "openwork"; const openworkWorkspaceId = typeof entry.openworkWorkspaceId === "string" ? entry.openworkWorkspaceId.trim() : ""; const id = typeof entry.id === "string" && entry.id.trim() ? entry.id.trim() : workspaceType === "remote" ? remoteType === "openwork" ? openworkRemoteWorkspaceId(baseUrl, openworkWorkspaceId) : remoteWorkspaceId(baseUrl, directory) : localWorkspaceId(normalizedPath); const key = workspaceType === "remote" ? id : normalizeWorkspacePathKey(normalizedPath); if (!key || seen.has(key)) continue; seen.add(key); workspaces.push(normalizeWorkspaceEntry({ ...entry, id, path: normalizedPath, name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : path.basename(normalizedPath) || "Workspace", displayName: typeof entry.displayName === "string" ? entry.displayName : undefined, preset: typeof entry.preset === "string" && entry.preset.trim() ? entry.preset.trim() : "starter", workspaceType, ...(workspaceType === "remote" ? { remoteType, baseUrl, directory } : {}), })); } return workspaces; } async function recoverWorkspacesFromKnownState() { const fromServerConfig = await recoverWorkspacesFromServerConfig(); if (fromServerConfig.length > 0) return fromServerConfig; return recoverWorkspacesFromTokenStore(); } function stableWorkspaceId(value) { return `ws_${createHash("sha256").update(String(value)).digest("hex").slice(0, 12)}`; } function localWorkspaceId(workspacePath) { return stableWorkspaceId(workspacePath); } function remoteWorkspaceId(baseUrl, directory) { const key = String(directory ?? "").trim() ? `remote::${baseUrl}::${String(directory).trim()}` : `remote::${baseUrl}`; return stableWorkspaceId(key); } function parseOpenworkWorkspaceIdFromUrl(input) { const raw = String(input ?? "").trim(); 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; return mountIndex >= 0 && segments[mountIndex + 1] ? decodeURIComponent(segments[mountIndex + 1]) : null; } catch { const match = raw.match(/\/(?:workspace|w)\/([^/?#]+)/); if (!match?.[1]) return null; try { return decodeURIComponent(match[1]); } catch { return match[1]; } } } function stripOpenworkWorkspaceMount(input) { const raw = String(input ?? "").trim(); 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; } } function openworkRemoteWorkspaceId(hostUrl, workspaceId) { const remoteWorkspaceId = String(workspaceId ?? "").trim() || parseOpenworkWorkspaceIdFromUrl(hostUrl); if (remoteWorkspaceId) return `rem_${remoteWorkspaceId}`; return `rem_${createHash("sha256").update(`openwork::${hostUrl}`).digest("hex").slice(0, 12)}`; } async function fetchOpenworkWorkspaceList(hostUrl, token, hostToken) { const url = `${String(hostUrl ?? "").replace(/\/+$/, "")}/workspaces`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 8_000); const headers = new Headers(); const bearerToken = String(token ?? "").trim(); const hostAuthToken = String(hostToken ?? "").trim(); if (bearerToken) headers.set("Authorization", `Bearer ${bearerToken}`); if (hostAuthToken) headers.set("X-OpenWork-Host-Token", hostAuthToken); try { const electron = await import("electron").catch(() => null); const fetcher = typeof electron?.net?.fetch === "function" ? electron.net.fetch.bind(electron.net) : fetch; const response = await fetcher(url, { headers, signal: controller.signal, credentials: "omit", cache: "no-store", }); if (!response.ok) { throw new Error(`OpenWork workspace discovery failed (${response.status} ${response.statusText || "HTTP error"})`); } return await response.json(); } finally { clearTimeout(timeout); } } async function discoverOpenworkWorkspace({ hostUrl, token, hostToken, directory }) { const list = await fetchOpenworkWorkspaceList(hostUrl, token, hostToken); return selectOpenworkWorkspaceForConnection(list, directory); } function normalizeWorkspaceEntry(input) { return { id: String(input.id), name: String(input.name ?? "Workspace"), path: String(input.path ?? ""), preset: String(input.preset ?? "starter"), workspaceType: input.workspaceType === "remote" ? "remote" : "local", remoteType: input.remoteType ?? null, baseUrl: input.baseUrl ?? null, directory: input.directory ?? null, displayName: input.displayName ?? null, openworkHostUrl: input.openworkHostUrl ?? null, openworkToken: input.openworkToken ?? null, openworkClientToken: input.openworkClientToken ?? null, openworkHostToken: input.openworkHostToken ?? null, openworkWorkspaceId: input.openworkWorkspaceId ?? null, openworkWorkspaceName: input.openworkWorkspaceName ?? null, sandboxBackend: input.sandboxBackend ?? null, sandboxRunId: input.sandboxRunId ?? null, sandboxContainerName: input.sandboxContainerName ?? null, }; } async function readWorkspaceOpenworkConfig(workspacePath) { const openworkPath = path.join(workspacePath, ".opencode", "openwork.json"); if (!(await pathExists(openworkPath))) { return defaultWorkspaceOpenworkConfig(workspacePath); } const raw = await readFile(openworkPath, "utf8"); return JSON.parse(raw); } async function writeWorkspaceOpenworkConfig(workspacePath, config) { const openworkPath = path.join(workspacePath, ".opencode", "openwork.json"); await mkdir(path.dirname(openworkPath), { recursive: true }); await writeFile(openworkPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); return execResult(true, `Wrote ${openworkPath}`); } async function writeWorkspaceState(nextState) { const outputPath = workspaceStatePath(); const selectedId = String(nextState?.selectedId ?? nextState?.activeId ?? ""); const watchedId = typeof nextState?.watchedId === "string" ? nextState.watchedId : ""; const output = { ...nextState, // Tauri's Rust state uses selectedWorkspaceId/watchedWorkspaceId on disk // with activeId as a legacy alias. Keep Electron's selectedId/watchedId // too so older Electron builds can still read the same file. selectedId, selectedWorkspaceId: selectedId, watchedId: watchedId || null, watchedWorkspaceId: watchedId, activeId: selectedId || null, }; await writeJsonFileAtomic(outputPath, output); return output; } async function readWorkspaceState() { const workspaceStateExists = existsSync(workspaceStatePath()); const state = await readJsonFile(workspaceStatePath(), EMPTY_WORKSPACE_LIST); let selectedId = typeof state?.selectedId === "string" ? state.selectedId : typeof state?.selectedWorkspaceId === "string" ? state.selectedWorkspaceId : typeof state?.activeId === "string" ? state.activeId : ""; let watchedId = typeof state?.watchedId === "string" ? state.watchedId : typeof state?.watchedWorkspaceId === "string" ? state.watchedWorkspaceId : null; let activeId = typeof state?.activeId === "string" ? state.activeId : null; let workspaces = Array.isArray(state?.workspaces) ? state.workspaces : []; let changed = false; if (!workspaceStateExists && process.env.OPENWORK_DESKTOP_DISABLE_WORKSPACE_RECOVERY !== "1") { const recoveredWorkspaces = await recoverWorkspacesFromKnownState(); if (recoveredWorkspaces.length > 0) { const selectedWorkspace = recoveredWorkspaces[0]; console.info("[migration] recovered desktop workspaces from persisted OpenWork state", { count: recoveredWorkspaces.length, selectedWorkspaceId: selectedWorkspace.id, }); selectedId = selectedWorkspace.id; watchedId = selectedWorkspace.id; activeId = selectedWorkspace.id; workspaces = recoveredWorkspaces; changed = true; } } const idMap = new Map(); const migratedWorkspaces = workspaces.map((entry) => { const workspace = entry && typeof entry === "object" ? entry : normalizeWorkspaceEntry(entry ?? {}); if (workspace.workspaceType !== "remote" || workspace.remoteType !== "openwork") return workspace; const remoteWorkspaceId = String(workspace.openworkWorkspaceId ?? "").trim() || parseOpenworkWorkspaceIdFromUrl(workspace.openworkHostUrl) || parseOpenworkWorkspaceIdFromUrl(workspace.baseUrl); if (!remoteWorkspaceId) return workspace; const hostUrl = stripOpenworkWorkspaceMount(workspace.openworkHostUrl) || stripOpenworkWorkspaceMount(workspace.baseUrl); const nextId = openworkRemoteWorkspaceId(hostUrl ?? workspace.baseUrl, remoteWorkspaceId); idMap.set(workspace.id, nextId); const nextWorkspace = { ...workspace, id: nextId, baseUrl: hostUrl, openworkWorkspaceId: remoteWorkspaceId, openworkHostUrl: hostUrl, }; if (workspace.id !== nextWorkspace.id || workspace.baseUrl !== nextWorkspace.baseUrl || workspace.openworkWorkspaceId !== nextWorkspace.openworkWorkspaceId || workspace.openworkHostUrl !== nextWorkspace.openworkHostUrl) { changed = true; } return nextWorkspace; }); // Older desktop state can contain multiple OpenWork remote entries that // normalize to the same rem_ after stripping worker mounts. // Collapse them here so React never receives duplicate workspace keys. const workspaceIndexById = new Map(); const dedupedWorkspaces = []; for (const workspace of migratedWorkspaces) { const workspaceId = String(workspace?.id ?? "").trim(); if (!workspaceId) { dedupedWorkspaces.push(workspace); continue; } const existingIndex = workspaceIndexById.get(workspaceId); if (existingIndex === undefined) { workspaceIndexById.set(workspaceId, dedupedWorkspaces.length); dedupedWorkspaces.push(workspace); continue; } // Keep the later entry: normal mutations replace-then-push refreshed // remote workspaces, and there is no persisted updatedAt to compare. dedupedWorkspaces[existingIndex] = workspace; changed = true; } const migratedSelectedId = idMap.get(selectedId) ?? selectedId; const migratedWatchedId = watchedId ? idMap.get(watchedId) ?? watchedId : null; const migratedActiveId = activeId ? idMap.get(activeId) ?? activeId : null; if (migratedSelectedId !== selectedId || migratedWatchedId !== watchedId || migratedActiveId !== activeId) changed = true; const nextState = { selectedId: migratedSelectedId, watchedId: migratedWatchedId, activeId: migratedActiveId, workspaces: dedupedWorkspaces, }; if (changed) { return writeWorkspaceState(nextState); } return nextState; } async function mutateWorkspaceState(mutator) { const current = await readWorkspaceState(); const next = await mutator({ ...current, workspaces: [...current.workspaces] }); return writeWorkspaceState(next); } async function listLocalWorkspacePaths() { return (await readWorkspaceState()) .workspaces .filter((entry) => entry?.workspaceType !== "remote") .map((entry) => String(entry?.path ?? "").trim()) .filter(Boolean); } // Binary transfers must derive authority only from app-owned state in // userData, never from workspace-writable configuration, so this list is // intentionally not exposed to that surface (see listLocalWorkspacePaths). async function listRemoteWorkspaceUrlPrefixes() { const prefixes = new Set(); for (const workspace of (await readWorkspaceState()).workspaces) { if (workspace?.workspaceType !== "remote") continue; for (const value of [workspace.baseUrl, workspace.openworkHostUrl]) { const raw = typeof value === "string" ? value.trim() : ""; if (raw) prefixes.add(raw); } } return [...prefixes]; } function workspacePathKey(workspace) { return normalizeWorkspacePathKey(workspace.path); } async function setSelectedWorkspace(workspaceId) { return mutateWorkspaceState((state) => { state.selectedId = workspaceId; state.activeId = workspaceId || null; return state; }); } async function setRuntimeActiveWorkspace(workspaceId) { return mutateWorkspaceState((state) => { state.watchedId = typeof workspaceId === "string" && workspaceId.trim() ? workspaceId : null; return state; }); } async function createWorkspace(input = {}) { const rawFolderPath = String(input.folderPath ?? "").trim(); if (!rawFolderPath) throw new Error("folderPath is required"); const folderPath = await normalizeLocalWorkspacePath(rawFolderPath); await mkdir(folderPath, { recursive: true }); const preset = String(input.preset ?? "starter"); const workspace = normalizeWorkspaceEntry({ id: localWorkspaceId(folderPath), name: String(input.name ?? (path.basename(folderPath) || "Workspace")), displayName: String(input.name ?? (path.basename(folderPath) || "Workspace")), path: folderPath, preset, workspaceType: "local", }); await mkdir(path.join(folderPath, ".opencode"), { recursive: true }); await writeWorkspaceOpenworkConfig(folderPath, defaultWorkspaceOpenworkConfig(folderPath, preset)); return mutateWorkspaceState((state) => { const key = workspacePathKey(workspace); state.workspaces = state.workspaces.filter( (entry) => entry.id !== workspace.id && normalizeWorkspacePathKey(entry.path) !== key, ); state.workspaces.push(workspace); state.selectedId = workspace.id; state.activeId = workspace.id; state.watchedId = workspace.id; return state; }); } async function createRemoteWorkspace(input = {}) { const baseUrl = String(input.baseUrl ?? "").trim(); if (!baseUrl) throw new Error("baseUrl is required"); if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) { throw new Error("baseUrl must start with http:// or https://"); } const remoteType = input.remoteType === "opencode" ? "opencode" : "openwork"; const directory = typeof input.directory === "string" && input.directory.trim() ? input.directory.trim() : null; const rawOpenworkHostUrl = typeof input.openworkHostUrl === "string" && input.openworkHostUrl.trim() ? input.openworkHostUrl.trim() : null; const openworkHostUrl = remoteType === "openwork" ? stripOpenworkWorkspaceMount(rawOpenworkHostUrl ?? baseUrl) : rawOpenworkHostUrl; const openworkWorkspaceId = typeof input.openworkWorkspaceId === "string" && input.openworkWorkspaceId.trim() ? input.openworkWorkspaceId.trim() : remoteType === "openwork" ? parseOpenworkWorkspaceIdFromUrl(rawOpenworkHostUrl) || parseOpenworkWorkspaceIdFromUrl(baseUrl) : null; let resolvedOpenworkWorkspaceId = openworkWorkspaceId; let resolvedOpenworkWorkspaceName = input.openworkWorkspaceName ?? null; if (remoteType === "openwork" && !resolvedOpenworkWorkspaceId) { const discovered = await discoverOpenworkWorkspace({ hostUrl: openworkHostUrl ?? baseUrl, token: input.openworkToken, hostToken: input.openworkHostToken, directory, }); if (!discovered?.id) { throw new Error( directory ? `OpenWork server has no workspace matching ${directory}.` : "OpenWork server returned no workspaces.", ); } resolvedOpenworkWorkspaceId = String(discovered.id).trim(); resolvedOpenworkWorkspaceName = openworkWorkspaceDisplayName(discovered); } const id = remoteType === "openwork" ? openworkRemoteWorkspaceId(openworkHostUrl ?? baseUrl, resolvedOpenworkWorkspaceId) : remoteWorkspaceId(baseUrl, directory); const workspace = normalizeWorkspaceEntry({ id, name: String(input.displayName ?? resolvedOpenworkWorkspaceName ?? "Remote workspace"), displayName: input.displayName ?? null, path: directory ?? "", preset: "remote", workspaceType: "remote", remoteType, baseUrl: remoteType === "openwork" ? (openworkHostUrl ?? baseUrl) : baseUrl, directory, openworkHostUrl, openworkToken: input.openworkToken ?? null, openworkClientToken: input.openworkClientToken ?? null, openworkHostToken: input.openworkHostToken ?? null, openworkWorkspaceId: resolvedOpenworkWorkspaceId, openworkWorkspaceName: resolvedOpenworkWorkspaceName, sandboxBackend: input.sandboxBackend ?? null, sandboxRunId: input.sandboxRunId ?? null, sandboxContainerName: input.sandboxContainerName ?? null, }); return mutateWorkspaceState((state) => { state.workspaces = state.workspaces.filter((entry) => entry.id !== workspace.id); state.workspaces.push(workspace); state.selectedId = workspace.id; state.activeId = workspace.id; return state; }); } async function updateRemoteWorkspace(input = {}) { const workspaceId = String(input.workspaceId ?? "").trim(); if (!workspaceId) throw new Error("workspaceId is required"); const { workspaceId: _workspaceId, ...patch } = input; return mutateWorkspaceState(async (state) => { const existing = state.workspaces.find((entry) => entry.id === workspaceId); if (!existing) return state; let nextWorkspace = { ...existing, ...patch }; const nextRemoteType = nextWorkspace.remoteType === "opencode" ? "opencode" : "openwork"; if (nextRemoteType === "openwork") { const rawHostUrl = typeof nextWorkspace.openworkHostUrl === "string" && nextWorkspace.openworkHostUrl.trim() ? nextWorkspace.openworkHostUrl.trim() : null; const nextBaseUrl = String(nextWorkspace.baseUrl ?? "").trim(); const hostUrl = stripOpenworkWorkspaceMount(rawHostUrl ?? nextBaseUrl); const directory = typeof nextWorkspace.directory === "string" && nextWorkspace.directory.trim() ? nextWorkspace.directory.trim() : null; const parsedWorkspaceId = parseOpenworkWorkspaceIdFromUrl(rawHostUrl) || parseOpenworkWorkspaceIdFromUrl(nextBaseUrl); let remoteWorkspaceId = parsedWorkspaceId || ( typeof nextWorkspace.openworkWorkspaceId === "string" && nextWorkspace.openworkWorkspaceId.trim() ? nextWorkspace.openworkWorkspaceId.trim() : null ); let remoteWorkspaceName = nextWorkspace.openworkWorkspaceName ?? null; if (!remoteWorkspaceId) { const discovered = await discoverOpenworkWorkspace({ hostUrl: hostUrl ?? nextBaseUrl, token: nextWorkspace.openworkToken, hostToken: nextWorkspace.openworkHostToken, directory, }); if (!discovered?.id) { throw new Error( directory ? `OpenWork server has no workspace matching ${directory}.` : "OpenWork server returned no workspaces.", ); } remoteWorkspaceId = String(discovered.id).trim(); remoteWorkspaceName = openworkWorkspaceDisplayName(discovered); } const nextId = openworkRemoteWorkspaceId(hostUrl ?? nextBaseUrl, remoteWorkspaceId); nextWorkspace = normalizeWorkspaceEntry({ ...nextWorkspace, id: nextId, baseUrl: hostUrl ?? nextBaseUrl, openworkHostUrl: hostUrl, directory, remoteType: "openwork", openworkWorkspaceId: remoteWorkspaceId, openworkWorkspaceName: remoteWorkspaceName, }); if (nextId !== workspaceId) { if (state.selectedId === workspaceId) state.selectedId = nextId; if (state.activeId === workspaceId) state.activeId = nextId; if (state.watchedId === workspaceId) state.watchedId = nextId; } } state.workspaces = state.workspaces.map((entry) => entry.id === workspaceId ? nextWorkspace : entry, ); return state; }); } async function updateWorkspaceDisplayName(input = {}) { const workspaceId = String(input.workspaceId ?? "").trim(); if (!workspaceId) throw new Error("workspaceId is required"); return mutateWorkspaceState((state) => { state.workspaces = state.workspaces.map((entry) => entry.id === workspaceId ? { ...entry, displayName: input.displayName ?? null } : entry, ); return state; }); } async function forgetWorkspace(workspaceId) { if (!workspaceId) throw new Error("workspaceId is required"); let workspacePath = ""; const nextState = await mutateWorkspaceState((state) => { const workspace = state.workspaces.find((entry) => entry.id === workspaceId); if (workspace?.workspaceType !== "remote") workspacePath = String(workspace?.path ?? ""); state.workspaces = state.workspaces.filter((entry) => entry.id !== workspaceId); if (state.selectedId === workspaceId) state.selectedId = ""; if (state.activeId === workspaceId) state.activeId = null; if (state.watchedId === workspaceId) state.watchedId = null; return state; }); await forgetWorkspaceToken(workspacePath); return nextState; } async function addAuthorizedRoot(input = {}) { const workspacePath = String(input.workspacePath ?? "").trim(); const authorizedRoot = String(input.folderPath ?? input.authorizedRoot ?? "").trim(); if (!workspacePath || !authorizedRoot) { throw new Error("workspacePath and folderPath are required"); } const config = await readWorkspaceOpenworkConfig(workspacePath); if (!Array.isArray(config.authorizedRoots)) { config.authorizedRoots = []; } if (!config.authorizedRoots.includes(authorizedRoot)) { config.authorizedRoots.push(authorizedRoot); } return writeWorkspaceOpenworkConfig(workspacePath, config); } async function exportConfig(input = {}) { const workspaceId = String(input.workspaceId ?? "").trim(); const outputPath = String(input.outputPath ?? "").trim(); if (!workspaceId) throw new Error("workspaceId is required"); if (!outputPath) throw new Error("outputPath is required"); const state = await readWorkspaceState(); const workspace = state.workspaces.find((entry) => entry.id === workspaceId); if (!workspace) throw new Error("Unknown workspaceId"); return exportWorkspaceConfig({ workspace, outputPath }); } async function importConfig(input = {}) { const archivePath = String(input.archivePath ?? "").trim(); const targetDirRaw = String(input.targetDir ?? "").trim(); if (!archivePath) throw new Error("archivePath is required"); if (!targetDirRaw) throw new Error("targetDir is required"); const targetDir = await normalizeLocalWorkspacePath(targetDirRaw); const imported = await importWorkspaceConfig({ archivePath, targetDir, name: input.name ?? null, }); const workspace = normalizeWorkspaceEntry({ id: localWorkspaceId(targetDir), name: imported.workspaceName, displayName: null, path: targetDir, preset: imported.preset, workspaceType: "local", }); return mutateWorkspaceState((state) => { const key = workspacePathKey(workspace); state.workspaces = state.workspaces.filter( (entry) => entry.id !== workspace.id && normalizeWorkspacePathKey(entry.path) !== key, ); state.workspaces.push(workspace); state.selectedId = workspace.id; state.activeId = workspace.id; state.watchedId = workspace.id; return state; }); } async function resetOpenworkState() { await rm(workspaceStatePath(), { force: true }); await clearDesktopBootstrapFiles(); return undefined; } return { addAuthorizedRoot, createRemoteWorkspace, createWorkspace, clearDesktopBootstrapConfig, debugDesktopBootstrapConfig, defaultWorkspaceOpenworkConfig, exportConfig, forgetWorkspace, getDesktopBootstrapConfig, importConfig, listLocalWorkspacePaths, listRemoteWorkspaceUrlPrefixes, migrateLegacyElectronWorkspaceStateIfNeeded, readDesktopBootstrapConfigSync, readWorkspaceOpenworkConfig, readWorkspaceState, resetOpenworkState, setDesktopBootstrapConfig, setRuntimeActiveWorkspace, setSelectedWorkspace, updateRemoteWorkspace, updateWorkspaceDisplayName, writeWorkspaceOpenworkConfig, writeWorkspaceState, }; }