Skip to content
Merged
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
26 changes: 26 additions & 0 deletions .github/workflows/package-win.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,32 @@ jobs:
echo "--- app-update.yml ---"; cat "$res/app-update.yml" 2>/dev/null || true
exit $fail

# index.js EXISTING is not the same as index.js RUNNING. 0.1.24 shipped a
# server that died instantly on ERR_MODULE_NOT_FOUND because tsc left a
# bare `zod` import in a tree that carries no node_modules — and the
# existence check above passed it. Start the real packaged copy here,
# where node_modules genuinely is not present.
- name: start the packaged server
shell: bash
run: |
res=release/win-unpacked/resources
HOME="$RUNNER_TEMP/omb-smoke" USERPROFILE="$RUNNER_TEMP/omb-smoke" \
OMB_PORT=21987 node "$res/server/index.js" > "$RUNNER_TEMP/server.log" 2>&1 &
pid=$!
for _ in $(seq 1 90); do
if curl -fsS --max-time 2 http://127.0.0.1:21987/api/health >/dev/null 2>&1; then
echo "packaged server answered /api/health ✓"
kill $pid 2>/dev/null || true
exit 0
fi
kill -0 $pid 2>/dev/null || break
sleep 1
done
echo "::error::the packaged server never served /api/health"
cat "$RUNNER_TEMP/server.log" || true
kill $pid 2>/dev/null || true
exit 1

- uses: actions/upload-artifact@v4
with:
name: windows-installer
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@
"dev:desktop": "electron .",
"build": "tsc -b && tsc -p tsconfig.server.json && vite build",
"typecheck": "tsc -b && tsc -p tsconfig.server.json",
"test": "vitest run && pnpm test:updater",
"test": "vitest run && pnpm test:updater && pnpm test:packaged-server",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"test:updater": "node --test electron/updater-coordinator.node-test.mjs",
"bench:observation": "node --experimental-strip-types scripts/bench-observation.ts",
"test:watch": "vitest",
"test:cua": "pnpm build:cua && node scripts/smoke-cua.mjs",
"test:cua-container": "node scripts/smoke-cua-container.mjs",
"check:electron": "node --check electron/main.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua.mjs && node --check electron/speech.mjs",
"preview": "vite preview",
"build:server": "tsc -p tsconfig.server.build.json",
"build:server": "tsc -p tsconfig.server.build.json && node scripts/bundle-server.mjs",
"build:speech": "node electron/build-speech-helper.mjs",
"build:cua": "node scripts/prepare-cua.mjs",
"build:updater": "node scripts/bundle-updater.mjs",
Expand All @@ -45,7 +45,8 @@
"package:win": "pnpm package:prepare && electron-builder --win --publish never",
"package:linux": "pnpm package:prepare && electron-builder --linux --x64 --publish never",
"package:linux:dir": "pnpm package:prepare && electron-builder --linux dir --x64 --publish never",
"package": "pnpm package:mac"
"package": "pnpm package:mac",
"test:packaged-server": "pnpm build:server && node scripts/smoke-packaged-server.mjs"
},
"dependencies": {
"@trycua/cua-driver": "0.20.0",
Expand Down
48 changes: 48 additions & 0 deletions scripts/bundle-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Bundle each harness-server entry point into a self-contained ESM file.
//
// Why this exists: the packaged app ships ZERO node_modules (see the files:
// exclusion in electron-builder.yml), so anything the server imports by bare
// specifier has to be inlined — `tsc` only transpiles, it leaves
// `import { z } from "zod"` verbatim and the packaged server dies at startup
// with ERR_MODULE_NOT_FOUND. That shipped once, in 0.1.24.
//
// Bundling every entry point rather than only index.ts is deliberate: the
// proxies are spawned as their own processes and today import nothing from
// node_modules, but nothing stops the next one from doing so, and the failure
// is invisible until a packaged build is actually launched.
//
// Entry points must keep their exact relative paths under dist-server — the
// server locates each proxy by path (server/index.ts:108,
// container-computer.ts:773, drivers/acp/core.ts:43), preferring the .ts in
// dev and falling back to the sibling .js in the packaged tree. outbase keeps
// drivers/ nested; import.meta.url still resolves to the same location, so
// that lookup is unaffected.
import { build } from "esbuild";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const server = join(root, "server");

// Every file run as its own process. Keep in sync with the spawn sites above.
const ENTRY_POINTS = [
"index.ts",
"computer-proxy.ts",
"container-mcp.ts",
"permission-proxy.ts",
"drivers/agents-proxy.ts",
"drivers/dweb-proxy.ts",
];

await build({
entryPoints: ENTRY_POINTS.map((entry) => join(server, entry)),
bundle: true,
platform: "node",
target: "node20",
format: "esm",
outbase: server,
outdir: join(root, "dist-server"),
// Written after tsc, replacing its output for these entry points.
allowOverwrite: true,
logLevel: "info",
});
83 changes: 83 additions & 0 deletions scripts/smoke-packaged-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Prove the built server actually STARTS with no node_modules in reach.
//
// 0.1.24 shipped a server that died on every launch with
// ERR_MODULE_NOT_FOUND: Cannot find package 'zod'
// because `tsc` leaves bare imports verbatim and the packaged app carries no
// node_modules. Every existing gate passed it: the unit suite runs in the repo
// (where zod resolves), and the packaging check only asserts index.js EXISTS.
//
// So this copies dist-server OUT of the repo before running it. Inside the
// repo a bare import still resolves by walking up to ./node_modules and the
// test passes on a build that would be dead in the field — which is precisely
// how the bug escaped. The copy is the whole point; do not "simplify" it away.
import { spawn } from "node:child_process";
import { cpSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const staging = mkdtempSync(join(tmpdir(), "omb-smoke-"));
const home = mkdtempSync(join(tmpdir(), "omb-smoke-home-"));
const port = 21000 + Math.floor(Math.random() * 9000);

cpSync(join(root, "dist-server"), join(staging, "server"), { recursive: true });

const child = spawn(process.execPath, [join(staging, "server", "index.js")], {
cwd: staging,
env: {
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
HOME: home,
USERPROFILE: home,
OMB_PORT: String(port),
},
stdio: ["ignore", "pipe", "pipe"],
});

let output = "";
child.stdout.on("data", (chunk) => (output += chunk));
child.stderr.on("data", (chunk) => (output += chunk));

// Best-effort by design. Windows holds file handles open a little longer than
// the process that owned them, so removing the scratch dir immediately after
// the kill raises EPERM; Linux runners can raise EACCES the same way. Scratch
// cleanup must never decide whether the build is good — it failed a green run
// on Windows once already, and see f66d30f for the same lesson on Linux.
const cleanup = () => {
child.kill("SIGKILL");
for (const dir of [staging, home]) {
try {
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
} catch {
/* the OS will reap it; the assertion below is what matters */
}
}
};

const deadline = Date.now() + 45_000;
let listening = false;
while (Date.now() < deadline) {
if (child.exitCode !== null) break;
try {
const res = await fetch(`http://127.0.0.1:${port}/api/health`);
if (res.ok) {
listening = true;
break;
}
} catch {
/* not up yet */
}
await new Promise((resolve) => setTimeout(resolve, 300));
}

cleanup();

if (!listening) {
console.error(`the packaged server never served /api/health on port ${port}.`);
console.error(`exit code: ${child.exitCode}`);
console.error(output.trim() || "(no output)");
process.exit(1);
}

console.log(`packaged server started with no node_modules in reach (port ${port}) ✓`);
Loading