Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 60 additions & 61 deletions apps/desktop/electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";

import { app, BrowserWindow, Menu, WebContentsView, clipboard, dialog, ipcMain, nativeImage, nativeTheme, session, shell, systemPreferences } from "electron";
import { configureFakeMediaForTests, installMediaPermissionHandlers } from "./media-permissions.mjs";
Expand All @@ -43,7 +43,6 @@ 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 BROWSER_PLUGIN = "opencode-chrome-devtools";
const COMPUTER_USE_HELPER_APP_NAME = "OpenWork Computer Use.app";
const COMPUTER_USE_HELPER_EXECUTABLE = "ComputerUse";

Expand Down Expand Up @@ -1424,43 +1423,46 @@ function validateSkillName(raw) {
return trimmed;
}

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,
};
}
// Workspace config helpers (openwork.json schema + opencode.json defaults) live
// in the openwork-server library so the Electron main process and the in-process
// server share one source of truth. The compiled bundle is imported lazily by
// absolute path, mirroring runtime.mjs's dev-vs-packaged candidate resolution.
let serverConfigHelpersPromise = null;

async function workspaceOpencodeConfigPath(workspacePath) {
const candidates = [
path.join(workspacePath, "opencode.jsonc"),
path.join(workspacePath, "opencode.json"),
path.join(workspacePath, ".opencode", "opencode.jsonc"),
path.join(workspacePath, ".opencode", "opencode.json"),
function serverDistCandidates(file) {
const devPath = path.resolve(__dirname, "..", "..", "server", "dist", file);
const packagedPaths = [
path.resolve(__dirname, "..", "server", "dist", file),
...(process.resourcesPath ? [path.resolve(process.resourcesPath, "server", "dist", file)] : []),
];
for (const candidate of candidates) {
if (await pathExists(candidate)) return candidate;
}
return candidates[0];
}

async function ensureDefaultWorkspaceOpencodeConfig(workspacePath) {
const configPath = await workspaceOpencodeConfigPath(workspacePath);
if (await pathExists(configPath)) return false;
await writeJsonFileAtomic(configPath, {
$schema: "https://opencode.ai/config.json",
default_agent: "openwork",
plugin: [BROWSER_PLUGIN],
});
return true;
return process.env.OPENWORK_DEV_MODE === "1"
? [devPath, ...packagedPaths]
: [...packagedPaths, devPath];
}

function loadServerConfigHelpers() {

@cubic-dev-ai cubic-dev-ai Bot May 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If import() rejects (e.g., the bundle file exists but fails to load), the rejected promise is cached forever and never retried. Wrap the async body in try/catch and reset serverConfigHelpersPromise = null on any failure, not just the file-existence check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/electron/main.mjs, line 1443:

<comment>If `import()` rejects (e.g., the bundle file exists but fails to load), the rejected promise is cached forever and never retried. Wrap the async body in try/catch and reset `serverConfigHelpersPromise = null` on any failure, not just the file-existence check.</comment>

<file context>
@@ -1424,43 +1423,46 @@ function validateSkillName(raw) {
+    : [...packagedPaths, devPath];
+}
+
+function loadServerConfigHelpers() {
+  if (serverConfigHelpersPromise) return serverConfigHelpersPromise;
+  serverConfigHelpersPromise = (async () => {
</file context>
Fix with Cubic

if (serverConfigHelpersPromise) return serverConfigHelpersPromise;
serverConfigHelpersPromise = (async () => {
const filesCandidates = serverDistCandidates("workspace-files.js");
const initCandidates = serverDistCandidates("workspace-init.js");
const filesPath = filesCandidates.find((candidate) => existsSync(candidate));
const initPath = initCandidates.find((candidate) => existsSync(candidate));
if (!filesPath || !initPath) {
serverConfigHelpersPromise = null;
throw new Error(
`Cannot find openwork-server config bundle. Checked: ${[...filesCandidates, ...initCandidates].join(", ")}`,
);
}
const files = await import(pathToFileURL(filesPath).href);
const init = await import(pathToFileURL(initPath).href);
return {
defaultWorkspaceOpenworkConfig: files.defaultWorkspaceOpenworkConfig,
readWorkspaceOpenworkConfig: files.readWorkspaceOpenworkConfig,
writeWorkspaceOpenworkConfig: files.writeWorkspaceOpenworkConfig,
ensureOpencodeConfig: init.ensureOpencodeConfig,
};
})();
return serverConfigHelpersPromise;
}

async function normalizeLocalWorkspacePath(rawPath) {
Expand Down Expand Up @@ -1569,22 +1571,6 @@ async function discoverOpenworkWorkspace({ hostUrl, token, hostToken, directory
return selectOpenworkWorkspaceForConnection(list, directory);
}

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 readWorkspaceState() {
const state = await readJsonFile(workspaceStatePath(), EMPTY_WORKSPACE_LIST);
const selectedId =
Expand Down Expand Up @@ -2174,8 +2160,14 @@ async function handleDesktopInvoke(event, command, ...args) {
workspaceType: "local",
});
await mkdir(path.join(folderPath, ".opencode"), { recursive: true });
await ensureDefaultWorkspaceOpencodeConfig(folderPath);
await writeWorkspaceOpenworkConfig(folderPath, defaultWorkspaceOpenworkConfig(folderPath, preset));
{
const helpers = await loadServerConfigHelpers();
await helpers.ensureOpencodeConfig(folderPath);
await helpers.writeWorkspaceOpenworkConfig(
folderPath,
helpers.defaultWorkspaceOpenworkConfig(folderPath, preset),
);
}

return mutateWorkspaceState((state) => {
const workspacePathKey = normalizeWorkspacePathKey(workspace.path);
Expand Down Expand Up @@ -2356,22 +2348,29 @@ async function handleDesktopInvoke(event, command, ...args) {
if (!workspacePath || !authorizedRoot) {
throw new Error("workspacePath and folderPath are required");
}
const config = await readWorkspaceOpenworkConfig(workspacePath);
const helpers = await loadServerConfigHelpers();
const config = await helpers.readWorkspaceOpenworkConfig(workspacePath);
if (!Array.isArray(config.authorizedRoots)) {
config.authorizedRoots = [];
}
if (!config.authorizedRoots.includes(authorizedRoot)) {
config.authorizedRoots.push(authorizedRoot);
}
return writeWorkspaceOpenworkConfig(workspacePath, config);
const written = await helpers.writeWorkspaceOpenworkConfig(workspacePath, config);
return execResult(true, `Wrote ${written}`);
}
case "workspaceOpenworkRead":
return readWorkspaceOpenworkConfig(String(args[0]?.workspacePath ?? "").trim());
case "workspaceOpenworkWrite":
return writeWorkspaceOpenworkConfig(
return (await loadServerConfigHelpers()).readWorkspaceOpenworkConfig(
String(args[0]?.workspacePath ?? "").trim(),
args[0]?.config ?? defaultWorkspaceOpenworkConfig(""),
);
case "workspaceOpenworkWrite": {
const helpers = await loadServerConfigHelpers();
const written = await helpers.writeWorkspaceOpenworkConfig(
String(args[0]?.workspacePath ?? "").trim(),
args[0]?.config ?? helpers.defaultWorkspaceOpenworkConfig(""),
);
return execResult(true, `Wrote ${written}`);
}
case "workspaceExportConfig": {
const input = args[0] ?? {};
const workspaceId = String(input.workspaceId ?? "").trim();
Expand Down
61 changes: 60 additions & 1 deletion apps/server/src/workspace-files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";

export function opencodeConfigPath(workspaceRoot: string): string {
const jsoncPath = join(workspaceRoot, "opencode.jsonc");
Expand Down Expand Up @@ -28,3 +29,61 @@ export function projectCommandsDir(workspaceRoot: string): string {
export function projectPluginsDir(workspaceRoot: string): string {
return join(workspaceRoot, ".opencode", "plugins");
}

/**
* Shape of the per-workspace `openwork.json` metadata file. This is the single
* source of truth for the schema; the desktop Electron main process imports
* these helpers from the compiled server bundle rather than redefining them.
*/
export type WorkspaceOpenworkConfig = {
version: number;
workspace?: {
name?: string | null;
createdAt?: number | null;
preset?: string | null;
} | null;
authorizedRoots: string[];
reload?: {
auto?: boolean;
resume?: boolean;
} | null;
};

export function defaultWorkspaceOpenworkConfig(
workspaceRoot: string,
preset: string | null = null,
): WorkspaceOpenworkConfig {
return {
version: 1,
workspace: workspaceRoot
? {
name: basename(workspaceRoot) || "Workspace",
createdAt: Date.now(),
preset: preset || null,
}
: null,
authorizedRoots: workspaceRoot ? [workspaceRoot] : [],
reload: null,
};
}

export async function readWorkspaceOpenworkConfig(
workspaceRoot: string,
): Promise<WorkspaceOpenworkConfig> {
const configPath = openworkConfigPath(workspaceRoot);
if (!existsSync(configPath)) {
return defaultWorkspaceOpenworkConfig(workspaceRoot);
}
const raw = await readFile(configPath, "utf8");
return JSON.parse(raw) as WorkspaceOpenworkConfig;
Comment on lines +77 to +78

@cubic-dev-ai cubic-dev-ai Bot May 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Wrap JSON.parse(raw) in a try-catch and fall back to defaults on parse failure. The file is user-editable, and a malformed openwork.json will propagate an unhandled SyntaxError to callers that don't guard against it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/server/src/workspace-files.ts, line 77:

<comment>Wrap `JSON.parse(raw)` in a try-catch and fall back to defaults on parse failure. The file is user-editable, and a malformed `openwork.json` will propagate an unhandled `SyntaxError` to callers that don't guard against it.</comment>

<file context>
@@ -28,3 +29,61 @@ export function projectCommandsDir(workspaceRoot: string): string {
+  if (!existsSync(configPath)) {
+    return defaultWorkspaceOpenworkConfig(workspaceRoot);
+  }
+  const raw = await readFile(configPath, "utf8");
+  return JSON.parse(raw) as WorkspaceOpenworkConfig;
+}
</file context>
Suggested change
const raw = await readFile(configPath, "utf8");
return JSON.parse(raw) as WorkspaceOpenworkConfig;
try {
const raw = await readFile(configPath, "utf8");
return JSON.parse(raw) as WorkspaceOpenworkConfig;
} catch {
return defaultWorkspaceOpenworkConfig(workspaceRoot);
}
Fix with Cubic

}

export async function writeWorkspaceOpenworkConfig(
workspaceRoot: string,
config: WorkspaceOpenworkConfig,
): Promise<string> {
const configPath = openworkConfigPath(workspaceRoot);
await mkdir(dirname(configPath), { recursive: true });
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
return configPath;
}
42 changes: 10 additions & 32 deletions apps/server/src/workspace-init.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { basename, join } from "node:path";
import { join } from "node:path";
import { readFile, writeFile } from "node:fs/promises";

import { ensureDir, exists } from "./utils.js";
import { ApiError } from "./errors.js";
import { openworkConfigPath, opencodeConfigPath } from "./workspace-files.js";
import {
openworkConfigPath,
opencodeConfigPath,
defaultWorkspaceOpenworkConfig,
writeWorkspaceOpenworkConfig,
} from "./workspace-files.js";
import { readJsoncFile, updateJsoncPath, updateJsoncTopLevel, writeJsoncFile } from "./jsonc.js";
import type { ReloadReason } from "./types.js";

Expand Down Expand Up @@ -69,20 +74,6 @@ Hard rule: never copy private memory into repo files. Store only redacted summar
${OPENWORK_ARTIFACT_GUIDANCE}
`;

type WorkspaceOpenworkConfig = {
version: number;
workspace?: {
name?: string | null;
createdAt?: number | null;
preset?: string | null;
} | null;
authorizedRoots: string[];
reload?: {
auto?: boolean;
resume?: boolean;
} | null;
};

type EnsureWorkspaceFilesResult = {
changed: boolean;
reloadReasons: ReloadReason[];
Expand All @@ -99,25 +90,12 @@ function isSchemaOnlyOpencodeConfig(config: Record<string, unknown>): boolean {
}

async function ensureWorkspaceOpenworkConfig(workspaceRoot: string, preset: string): Promise<boolean> {
const path = openworkConfigPath(workspaceRoot);
if (await exists(path)) return false;
const now = Date.now();
const config: WorkspaceOpenworkConfig = {
version: 1,
workspace: {
name: basename(workspaceRoot) || "Workspace",
createdAt: now,
preset,
},
authorizedRoots: [workspaceRoot],
reload: null,
};
await ensureDir(join(workspaceRoot, ".opencode"));
await writeFile(path, JSON.stringify(config, null, 2) + "\n", "utf8");
if (await exists(openworkConfigPath(workspaceRoot))) return false;
await writeWorkspaceOpenworkConfig(workspaceRoot, defaultWorkspaceOpenworkConfig(workspaceRoot, preset));
return true;
}

async function ensureOpencodeConfig(workspaceRoot: string): Promise<boolean> {
export async function ensureOpencodeConfig(workspaceRoot: string): Promise<boolean> {
const path = opencodeConfigPath(workspaceRoot);
if (await exists(path)) {
await readJsoncFile<Record<string, unknown>>(path, {});
Expand Down
Loading