From ffac562630ef1c843ec8008c6da4050d229d391b Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Wed, 20 May 2026 17:14:33 +0200 Subject: [PATCH 1/7] Harden packaged runtime startup --- apps/backend/src/lib/sqlite.ts | 7 +- apps/backend/src/services/aap/apps.service.ts | 21 +- apps/backend/src/services/aap/lifecycle.ts | 27 +- apps/backend/src/services/agent/commands.ts | 19 +- apps/backend/src/services/pty.service.ts | 4 +- apps/backend/test/integration/aap.test.ts | 3 +- .../test/unit/runtime/agent-process.test.ts | 12 +- .../test/unit/services/aap/lifecycle.test.ts | 68 ++++ apps/desktop/main/backend-process.ts | 3 +- apps/desktop/main/cli-tools.ts | 26 +- apps/desktop/main/runtime-env.ts | 2 +- apps/runtime/index.ts | 93 ++++- docs/runtime-agent-cli-ownership.md | 128 +++++++ electron-builder.yml | 2 + package.json | 8 +- .../native/Sources/SimInspector/build.sh | 1 + packages/device-use/scripts/build-ts.ts | 1 + packages/device-use/src/cli/commands/serve.ts | 6 +- scripts/prepare-device-use.mjs | 78 ++++- scripts/runtime/agent-clis.ts | 35 +- scripts/runtime/dev.ts | 39 +-- .../runtime/electron-builder-before-pack.cjs | 51 ++- scripts/runtime/lib/device-use-payloads.cjs | 59 ++++ scripts/runtime/prepare-dev-agent-clis.ts | 24 ++ scripts/runtime/smoke-packaged-app.cjs | 98 +++++- scripts/runtime/smoke-packaged-runtime.cjs | 319 +++++++++++++++++- scripts/runtime/smoke-source-runtime.cjs | 6 + shared/lib/cli-path.ts | 5 +- shared/runtime.ts | 1 + .../runtime/smoke-packaged-runtime.test.ts | 21 ++ 30 files changed, 1045 insertions(+), 122 deletions(-) create mode 100644 docs/runtime-agent-cli-ownership.md create mode 100644 scripts/runtime/lib/device-use-payloads.cjs create mode 100644 scripts/runtime/prepare-dev-agent-clis.ts create mode 100644 test/unit/runtime/smoke-packaged-runtime.test.ts diff --git a/apps/backend/src/lib/sqlite.ts b/apps/backend/src/lib/sqlite.ts index 783798a3d..b9265a263 100644 --- a/apps/backend/src/lib/sqlite.ts +++ b/apps/backend/src/lib/sqlite.ts @@ -1,4 +1,7 @@ import type BetterSqlite3 from "better-sqlite3"; +import { createRequire } from "node:module"; + +const requireModule = createRequire(import.meta.url); type BetterSqlite3Constructor = new ( filename: string, @@ -21,7 +24,7 @@ function isBunRuntime(): boolean { } function loadBetterSqlite3(): BetterSqlite3Constructor { - const mod = require("better-sqlite3") as + const mod = requireModule("better-sqlite3") as | BetterSqlite3Constructor | { default?: BetterSqlite3Constructor }; if (typeof mod === "function") return mod; @@ -30,7 +33,7 @@ function loadBetterSqlite3(): BetterSqlite3Constructor { } function loadBunSqlite(): BunSqliteDatabaseConstructor { - const mod = require("bun:sqlite") as { Database?: BunSqliteDatabaseConstructor }; + const mod = requireModule("bun:sqlite") as { Database?: BunSqliteDatabaseConstructor }; if (!mod.Database) { throw new Error("Unable to load bun:sqlite"); } diff --git a/apps/backend/src/services/aap/apps.service.ts b/apps/backend/src/services/aap/apps.service.ts index e6b77417e..d098b6c07 100644 --- a/apps/backend/src/services/aap/apps.service.ts +++ b/apps/backend/src/services/aap/apps.service.ts @@ -170,6 +170,7 @@ async function runPrefetch(installed: InstalledAppEntry): Promise { const rawCwd = prefetch.cwd ? substituteTemplate(prefetch.cwd, vars) : packageRoot; const cwd = isAbsolute(rawCwd) ? rawCwd : resolvePath(packageRoot, rawCwd); const command = resolveCommand(prefetch.command, packageRoot); + const args = substituteArgs(prefetch.args, vars); if (!canSpawnResolvedCommand(command)) { console.log(`[AAP] Prefetch skipped: ${manifest.id}`, { command: prefetch.command, @@ -177,7 +178,15 @@ async function runPrefetch(installed: InstalledAppEntry): Promise { }); return; } - const args = substituteArgs(prefetch.args, vars); + const missingEntrypoint = findMissingPrefetchEntrypoint(args, cwd); + if (missingEntrypoint) { + console.log(`[AAP] Prefetch skipped: ${manifest.id}`, { + command: prefetch.command, + reason: "entrypoint unavailable", + entrypoint: missingEntrypoint, + }); + return; + } const env = createBackendChildEnv({ ...substituteEnv(prefetch.env, vars), DEUS_APP_ID: manifest.id, @@ -233,6 +242,16 @@ async function runPrefetch(installed: InstalledAppEntry): Promise { }); } +function findMissingPrefetchEntrypoint(args: string[], cwd: string): string | null { + const [firstArg] = args; + if (!firstArg) return null; + if (firstArg.startsWith("-")) return null; + if (!firstArg.includes("/") && !firstArg.includes("\\")) return null; + + const entrypoint = isAbsolute(firstArg) ? firstArg : resolvePath(cwd, firstArg); + return existsSync(entrypoint) ? null : entrypoint; +} + function canSpawnResolvedCommand(command: string): boolean { if (isAbsolute(command) || command.includes("/") || command.includes("\\")) { return existsSync(command); diff --git a/apps/backend/src/services/aap/lifecycle.ts b/apps/backend/src/services/aap/lifecycle.ts index 453958f98..157a1f5f1 100644 --- a/apps/backend/src/services/aap/lifecycle.ts +++ b/apps/backend/src/services/aap/lifecycle.ts @@ -57,6 +57,11 @@ export interface Spawned { * chatty child while still preserving the most recent crash context. */ const RING_MAX_CHUNKS = 50; +interface ResolvedLaunchCommand { + command: string; + argsPrefix: string[]; +} + export function spawnApp(args: SpawnArgs): Spawned { const { manifest, vars, packageRoot, onExit, onError } = args; const { launch } = manifest; @@ -67,7 +72,7 @@ export function spawnApp(args: SpawnArgs): Spawned { // relative to the package they live in. const rawCwd = launch.cwd ? substituteTemplate(launch.cwd, vars) : packageRoot; const cwd = isAbsolute(rawCwd) ? rawCwd : resolvePath(packageRoot, rawCwd); - const resolvedCommand = resolveCommand(launch.command, packageRoot); + const resolvedCommand = resolveLaunchCommand(launch.command, packageRoot); const env = createBackendChildEnv({ ...substituteEnv(launch.env, vars), @@ -76,7 +81,7 @@ export function spawnApp(args: SpawnArgs): Spawned { DEUS_PORT: String(vars.port), }); - const child = spawn(resolvedCommand, cmdArgs, { + const child = spawn(resolvedCommand.command, [...resolvedCommand.argsPrefix, ...cmdArgs], { cwd, env, stdio: ["ignore", "pipe", "pipe"], @@ -319,6 +324,24 @@ export function resolveCommand(command: string, packageRoot: string): string { return command; } +export function resolveLaunchCommand(command: string, packageRoot: string): ResolvedLaunchCommand { + if (command === "device-use") { + const runtimeExecutable = process.env.DEUS_RUNTIME_EXECUTABLE; + const hasBundledRuntime = process.env.DEUS_PACKAGED === "1" || process.env.DEUS_RUNTIME === "1"; + if (hasBundledRuntime && runtimeExecutable && existsSync(runtimeExecutable)) { + return { + command: runtimeExecutable, + argsPrefix: ["device-use"], + }; + } + } + + return { + command: resolveCommand(command, packageRoot), + argsPrefix: [], + }; +} + // ---------------------------------------------------------------------------- // orphan check // ---------------------------------------------------------------------------- diff --git a/apps/backend/src/services/agent/commands.ts b/apps/backend/src/services/agent/commands.ts index 09977b064..8b638273c 100644 --- a/apps/backend/src/services/agent/commands.ts +++ b/apps/backend/src/services/agent/commands.ts @@ -11,6 +11,8 @@ // contain business logic directly. import { match } from "ts-pattern"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { getDatabase } from "../../lib/database"; import { getSessionRaw, getWorkspaceForMiddleware } from "../../db"; import { computeWorkspacePath } from "../../middleware/workspace-loader"; @@ -39,6 +41,8 @@ interface CommandResult { [key: string]: unknown; } +const execFileAsync = promisify(execFile); + // ---- Command Dispatch ---- export async function runCommand( @@ -268,10 +272,7 @@ export async function runCommand( const bundleId = requireParam(params, "bundleId", "sim:launchApp"); const session = simulator.getContextForWorkspace(workspaceId); if (!session) throw new Error("No active simulator session"); - await import("child_process").then(({ execFile }) => { - const { promisify } = require("util"); - return promisify(execFile)("xcrun", ["simctl", "launch", session.udid, bundleId]); - }); + await execFileAsync("xcrun", ["simctl", "launch", session.udid, bundleId]); return {}; }) .with("sim:terminateApp", async () => { @@ -279,10 +280,7 @@ export async function runCommand( const bundleId = requireParam(params, "bundleId", "sim:terminateApp"); const session = simulator.getContextForWorkspace(workspaceId); if (!session) throw new Error("No active simulator session"); - await import("child_process").then(({ execFile }) => { - const { promisify } = require("util"); - return promisify(execFile)("xcrun", ["simctl", "terminate", session.udid, bundleId]); - }); + await execFileAsync("xcrun", ["simctl", "terminate", session.udid, bundleId]); return {}; }) .with("sim:uninstallApp", async () => { @@ -290,10 +288,7 @@ export async function runCommand( const bundleId = requireParam(params, "bundleId", "sim:uninstallApp"); const session = simulator.getContextForWorkspace(workspaceId); if (!session) throw new Error("No active simulator session"); - await import("child_process").then(({ execFile }) => { - const { promisify } = require("util"); - return promisify(execFile)("xcrun", ["simctl", "uninstall", session.udid, bundleId]); - }); + await execFileAsync("xcrun", ["simctl", "uninstall", session.udid, bundleId]); return {}; }) // ---- AAP (agentic apps protocol) commands ---- diff --git a/apps/backend/src/services/pty.service.ts b/apps/backend/src/services/pty.service.ts index 9ce8688d3..4e38a9beb 100644 --- a/apps/backend/src/services/pty.service.ts +++ b/apps/backend/src/services/pty.service.ts @@ -6,14 +6,16 @@ */ import type * as Pty from "node-pty"; +import { createRequire } from "node:module"; import { broadcast } from "./ws.service"; // Active PTY sessions, keyed by client-provided ID const sessions = new Map(); let ptyModule: typeof Pty | null = null; +const requireModule = createRequire(import.meta.url); function getPtyModule(): typeof Pty { - ptyModule ??= require("node-pty") as typeof Pty; + ptyModule ??= requireModule("node-pty") as typeof Pty; return ptyModule; } diff --git a/apps/backend/test/integration/aap.test.ts b/apps/backend/test/integration/aap.test.ts index b4ab4acc0..cc0b14889 100644 --- a/apps/backend/test/integration/aap.test.ts +++ b/apps/backend/test/integration/aap.test.ts @@ -196,7 +196,8 @@ describe("aap/apps.service (integration, in-memory)", () => { prefetchInstalledAppAssets(); await waitForCondition( () => existsSync(fakePrefetchMarker), - (exists) => exists + (exists) => exists, + 10_000 ); expect(readFileSync(fakePrefetchMarker, "utf8")).toBe("test.fake-app:1"); }); diff --git a/apps/backend/test/unit/runtime/agent-process.test.ts b/apps/backend/test/unit/runtime/agent-process.test.ts index ecd1a6467..1d9d74cd3 100644 --- a/apps/backend/test/unit/runtime/agent-process.test.ts +++ b/apps/backend/test/unit/runtime/agent-process.test.ts @@ -1,4 +1,12 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -127,7 +135,7 @@ describe("managed agent-server process", () => { await expect(startManagedAgentServer()).resolves.toBe("ws://127.0.0.1:7890"); expect(readFileSync(argsPath, "utf8").trim()).toBe("agent-server"); - expect(readFileSync(cwdPath, "utf8").trim()).toBe(root); + expect(readFileSync(cwdPath, "utf8").trim()).toBe(realpathSync(root)); expect(readFileSync(envPath, "utf8").trimEnd()).toBe( [ "AUTH_TOKEN=", diff --git a/apps/backend/test/unit/services/aap/lifecycle.test.ts b/apps/backend/test/unit/services/aap/lifecycle.test.ts index b8d25fccf..ce7051f68 100644 --- a/apps/backend/test/unit/services/aap/lifecycle.test.ts +++ b/apps/backend/test/unit/services/aap/lifecycle.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { isProcessAlive, killByPid, + resolveLaunchCommand, spawnApp, stopChild, waitForReady, @@ -48,7 +49,74 @@ async function startProbeServer(options: { healthStatus: number } = { healthStat return { server, port, close: () => new Promise((r) => server.close(() => r())) }; } +function withEnv(overrides: Record, test: () => void): void { + const original = new Map(Object.keys(overrides).map((key) => [key, process.env[key]] as const)); + + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + + try { + test(); + } finally { + for (const [key, value] of original) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + describe("aap/lifecycle", () => { + describe("resolveLaunchCommand", () => { + it("routes packaged device-use launches through the bundled Deus runtime", () => { + withEnv( + { + DEUS_PACKAGED: "1", + DEUS_RUNTIME: undefined, + DEUS_RUNTIME_EXECUTABLE: process.execPath, + }, + () => { + expect(resolveLaunchCommand("device-use", process.cwd())).toEqual({ + command: process.execPath, + argsPrefix: ["device-use"], + }); + } + ); + }); + + it("routes standalone runtime device-use launches through the bundled Deus runtime", () => { + withEnv( + { + DEUS_PACKAGED: undefined, + DEUS_RUNTIME: "1", + DEUS_RUNTIME_EXECUTABLE: process.execPath, + }, + () => { + expect(resolveLaunchCommand("device-use", process.cwd())).toEqual({ + command: process.execPath, + argsPrefix: ["device-use"], + }); + } + ); + }); + + it("keeps source device-use launches on the package bin path", () => { + withEnv( + { + DEUS_PACKAGED: undefined, + DEUS_RUNTIME: undefined, + DEUS_RUNTIME_EXECUTABLE: undefined, + }, + () => { + const resolved = resolveLaunchCommand("device-use", process.cwd()); + expect(resolved.argsPrefix).toEqual([]); + expect(resolved.command.endsWith("device-use")).toBe(true); + } + ); + }); + }); + describe("spawnApp", () => { it("spawns a child, fires onExit with the exit code", async () => { const exit = new Promise<{ code: number | null }>((resolve) => { diff --git a/apps/desktop/main/backend-process.ts b/apps/desktop/main/backend-process.ts index 4176bbc85..cf9a69cc8 100644 --- a/apps/desktop/main/backend-process.ts +++ b/apps/desktop/main/backend-process.ts @@ -3,7 +3,7 @@ import { existsSync, statSync, writeFileSync } from "fs"; import { delimiter, extname, join } from "path"; import { app, BrowserWindow } from "electron"; import crypto from "crypto"; -import { DEUS_DB_FILENAME } from "../../../shared/runtime"; +import { DEUS_DB_FILENAME, PACKAGED_SYSTEM_PATHS } from "../../../shared/runtime"; import { extendCliPath, getDevStagedCliDirectory } from "../../../shared/lib/cli-path"; import { PACKAGED_RUNTIME_ENV_DENYLIST } from "./runtime-env"; @@ -15,7 +15,6 @@ let restartAttempt = 0; let restartTimer: ReturnType | null = null; const MAX_RESTART_ATTEMPTS = 5; const STARTUP_TIMEOUT_MS = 30_000; -const PACKAGED_SYSTEM_PATHS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; const WINDOWS_EXECUTABLE_EXTENSIONS = new Set([".exe", ".cmd", ".bat", ".ps1", ".com"]); export interface BackendSpawnHooks { diff --git a/apps/desktop/main/cli-tools.ts b/apps/desktop/main/cli-tools.ts index e5a04f5f2..2174618d4 100644 --- a/apps/desktop/main/cli-tools.ts +++ b/apps/desktop/main/cli-tools.ts @@ -1,36 +1,20 @@ import { execFile } from "child_process"; +import { delimiter } from "path"; import { promisify } from "util"; import { extendCliPath, getBundledCliDirectory, resolveBundledCliPath, } from "../../../shared/lib/cli-path"; +import { PACKAGED_SYSTEM_PATHS } from "../../../shared/runtime"; +import { PACKAGED_RUNTIME_ENV_DENYLIST } from "./runtime-env"; import { syncShellEnvironment } from "./shell-env"; const execFileAsync = promisify(execFile); const CLI_TOOL_NAME_PATTERN = /^[a-zA-Z0-9._+-]+$/; const PACKAGED_BUNDLED_TOOLS = new Set(["codex", "claude", "gh", "rg", "agent-browser"]); -const PACKAGED_SYSTEM_PATHS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; -const CLI_CHILD_ENV_DENYLIST = [ - "AGENT_SERVER_CWD", - "AGENT_SERVER_ENTRY", - "AUTH_TOKEN", - "BUN_OPTIONS", - "DATABASE_PATH", - "DEUS_AUTH_TOKEN", - "DEUS_BACKEND_PORT", - "DEUS_BUNDLED_BIN_DIR", - "DEUS_DATA_DIR", - "DEUS_PACKAGED", - "DEUS_RESOURCES_PATH", - "DEUS_RUNTIME", - "DEUS_RUNTIME_COMMAND", - "DEUS_RUNTIME_EXECUTABLE", - "ELECTRON_RUN_AS_NODE", - "NODE_PATH", - "PORT", -] as const; +const CLI_CHILD_ENV_DENYLIST = PACKAGED_RUNTIME_ENV_DENYLIST; export interface CliToolStatus { installed: boolean; @@ -45,7 +29,7 @@ export function getCliLookupEnv(): NodeJS.ProcessEnv { if (isPackagedRuntime()) { const bundledDir = getBundledCliDirectory(); return cliChildEnv({ - PATH: [bundledDir, ...PACKAGED_SYSTEM_PATHS].filter(Boolean).join(":"), + PATH: [bundledDir, ...PACKAGED_SYSTEM_PATHS].filter(Boolean).join(delimiter), }); } return cliChildEnv({ PATH: extendCliPath(process.env.PATH) }); diff --git a/apps/desktop/main/runtime-env.ts b/apps/desktop/main/runtime-env.ts index f98fa8037..ae1d420c3 100644 --- a/apps/desktop/main/runtime-env.ts +++ b/apps/desktop/main/runtime-env.ts @@ -1,6 +1,6 @@ import { delimiter, join } from "path"; +import { PACKAGED_SYSTEM_PATHS } from "../../../shared/runtime"; -const PACKAGED_SYSTEM_PATHS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; export const PACKAGED_RUNTIME_ENV_DENYLIST = [ "AGENT_SERVER_CWD", "AGENT_SERVER_ENTRY", diff --git a/apps/runtime/index.ts b/apps/runtime/index.ts index cb892ee99..662496496 100644 --- a/apps/runtime/index.ts +++ b/apps/runtime/index.ts @@ -5,13 +5,15 @@ import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs"; import { Module as NodeModule } from "node:module"; import { tmpdir } from "node:os"; import { basename, delimiter, dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; import packageJson from "../../package.json"; +import { PACKAGED_SYSTEM_PATHS } from "../../shared/runtime"; const VERSION = packageJson.version; const RUNTIME_NAME = "deus-runtime"; const STAGED_RUNTIME_KEYS = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]); -const PACKAGED_SYSTEM_PATHS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; const REQUIRED_BINARIES = ["deus-runtime", "codex", "claude", "gh", "rg", "agent-browser"] as const; +const REQUIRED_SIMULATOR_HELPERS = ["simbridge", "siminspector.dylib"] as const; const REQUIRED_RUNTIME_IMPORTS = [ { name: "@anthropic-ai/claude-agent-sdk", @@ -42,11 +44,12 @@ const REQUIRED_RUNTIME_IMPORTS = [ }, ] as const; -type RuntimeCommand = "agent-server" | "backend" | "self-test"; +type RuntimeCommand = "agent-server" | "backend" | "device-use" | "self-test"; interface ParsedArgs { command: RuntimeCommand | "version" | "help"; dataDir?: string; + passthroughArgs?: string[]; } function usage(): string { @@ -58,6 +61,7 @@ function usage(): string { ` ${RUNTIME_NAME} self-test`, ` ${RUNTIME_NAME} agent-server`, ` ${RUNTIME_NAME} backend [--data-dir ]`, + ` ${RUNTIME_NAME} device-use [...args]`, ].join("\n"); } @@ -70,6 +74,7 @@ function parseArgs(argv: string[]): ParsedArgs { return { command: "version" }; } if (first === "self-test") return { command: "self-test" }; + if (first === "device-use") return { command: "device-use", passthroughArgs: rest }; if (first !== "agent-server" && first !== "backend") { throw new Error(`Unknown command: ${first}`); } @@ -213,6 +218,16 @@ function inspectBundledBinary(binDir: string, name: (typeof REQUIRED_BINARIES)[n return { path: filePath, exists, executable }; } +function inspectSimulatorHelper( + resourcesPath: string, + name: (typeof REQUIRED_SIMULATOR_HELPERS)[number] +) { + const filePath = join(resourcesPath, "simulator", name); + const exists = existsSync(filePath); + const executable = exists ? (statSync(filePath).mode & 0o111) !== 0 : false; + return { path: filePath, exists, executable }; +} + async function inspectRuntimeImports() { const results: Record = {}; @@ -339,9 +354,41 @@ function configureRuntimeEnv(command: RuntimeCommand, dataDir?: string): void { process.env.DATABASE_PATH = join(resolve(dataDir), "deus.db"); } } + + if (command === "device-use") { + const simbridge = join(layout.resourcesPath, "simulator", "simbridge"); + const siminspector = join(layout.resourcesPath, "simulator", "siminspector.dylib"); + if (isNativeRuntimeExecutable) { + process.env.DEVICE_USE_SIMBRIDGE = simbridge; + process.env.DEVICE_USE_SIMINSPECTOR = siminspector; + } else { + process.env.DEVICE_USE_SIMBRIDGE ??= simbridge; + process.env.DEVICE_USE_SIMINSPECTOR ??= siminspector; + } + } +} + +function resolveDeviceUseCli(layout: ReturnType): string { + const candidates = [ + join(layout.resourcesPath, "agentic-apps", "device-use", "dist", "cli.js"), + layout.projectRoot + ? join(layout.projectRoot, "packages", "device-use", "dist", "cli.js") + : null, + ]; + for (const candidate of candidates) { + if (candidate && existsSync(candidate)) return candidate; + } + + throw new Error( + `Unable to find packaged device-use CLI. Checked: ${candidates.filter(Boolean).join(", ")}` + ); } -async function run(command: RuntimeCommand, dataDir?: string): Promise { +async function run( + command: RuntimeCommand, + dataDir?: string, + passthroughArgs: string[] = [] +): Promise { configureRuntimeEnv(command, dataDir); if (command === "self-test") { @@ -349,17 +396,33 @@ async function run(command: RuntimeCommand, dataDir?: string): Promise { const binaries = Object.fromEntries( REQUIRED_BINARIES.map((name) => [name, inspectBundledBinary(layout.bundledBinDir, name)]) ); + const simulatorHelpers = Object.fromEntries( + REQUIRED_SIMULATOR_HELPERS.map((name) => [ + name, + inspectSimulatorHelper(layout.resourcesPath, name), + ]) + ); + const requireSimulatorHelpers = existsSync( + join(layout.resourcesPath, "agentic-apps", "device-use", "agentic-app.json") + ); const imports = await inspectRuntimeImports(); const sqlite = await inspectSqliteContract(); const missing = Object.entries(binaries) .filter(([, result]) => !result.exists || !result.executable) .map(([name]) => name); + const missingSimulatorHelpers = Object.entries(simulatorHelpers) + .filter(([, result]) => requireSimulatorHelpers && (!result.exists || !result.executable)) + .map(([name]) => name); const failedImports = Object.entries(imports) .filter(([, result]) => !result.ok) .map(([name]) => name); console.log( JSON.stringify({ - ok: missing.length === 0 && failedImports.length === 0 && sqlite.ok, + ok: + missing.length === 0 && + missingSimulatorHelpers.length === 0 && + failedImports.length === 0 && + sqlite.ok, version: VERSION, executable: layout.executablePath, execPath: process.execPath, @@ -373,13 +436,23 @@ async function run(command: RuntimeCommand, dataDir?: string): Promise { nodeGlobalPaths: NodeModule.globalPaths, runtimeKey: getRuntimeKey(), binaries, + simulatorHelpers, imports, sqlite, + requireSimulatorHelpers, missing, + missingSimulatorHelpers, failedImports, }) ); - if (missing.length > 0 || failedImports.length > 0 || !sqlite.ok) process.exit(1); + if ( + missing.length > 0 || + missingSimulatorHelpers.length > 0 || + failedImports.length > 0 || + !sqlite.ok + ) { + process.exit(1); + } return; } @@ -388,6 +461,14 @@ async function run(command: RuntimeCommand, dataDir?: string): Promise { return; } + if (command === "device-use") { + const layout = resolveRuntimeLayout(); + const cliPath = resolveDeviceUseCli(layout); + process.argv = [layout.executablePath, "device-use", ...passthroughArgs]; + await import(pathToFileURL(cliPath).href); + return; + } + await import("../backend/src/server"); } @@ -411,7 +492,7 @@ async function main(): Promise { return; } - await run(args.command, args.dataDir); + await run(args.command, args.dataDir, args.passthroughArgs); } main().catch((error) => { diff --git a/docs/runtime-agent-cli-ownership.md b/docs/runtime-agent-cli-ownership.md new file mode 100644 index 000000000..3c0777249 --- /dev/null +++ b/docs/runtime-agent-cli-ownership.md @@ -0,0 +1,128 @@ +# Runtime and Agent CLI Ownership + +This note records the current ownership boundaries for Deus runtime packaging and +agent CLI discovery. It is based on the source paths below, not on generated +`dist/runtime` output. + +## Production Ownership + +- `scripts/runtime/build.ts` is the release staging entrypoint for runtime + payloads. It composes backend/agent-server bundles, native `deus-runtime` + compilation, bundled Claude/Codex/agent-browser CLI staging, and GitHub CLI + staging. +- `scripts/runtime/stage.ts` owns common runtime bundle staging and the top-level + runtime manifest shape. +- `scripts/runtime/native-runtime.ts` owns native `deus-runtime` builds and + their manifest. +- `scripts/runtime/agent-clis.ts` owns production Claude/Codex/agent-browser + binary staging, static inspection, hashing, and the full `agent-clis.json` + matrix. `scripts/runtime/prepare-dev-agent-clis.ts` is only a dev preflight: + it may copy the host runtime key but must not rewrite the full packaging + manifest. +- `scripts/prepare-gh-cli.mjs` owns GitHub CLI staging. Runtime validation checks + it from `scripts/runtime/validate.ts`. +- `apps/runtime/index.ts` owns the compiled runtime executable command surface: + `backend`, `agent-server`, `device-use`, and `self-test`. It also owns runtime + env normalization for native runtime children. +- `shared/runtime.ts` owns shared runtime contracts that production and tests can + depend on without importing packaging scripts: app ids, data filenames, staged + runtime paths, runtime dependency list, and deterministic packaged system PATH + entries. +- `shared/lib/cli-path.ts` owns runtime CLI path resolution at app runtime. It + finds explicit bundled bin dirs, packaged resources, or dev-staged host bins, + and intentionally refuses PATH fallback in packaged runtime mode. +- `apps/agent-server/agents/environment/cli-discovery.ts` owns agent-server CLI + discovery behavior. It verifies explicit override paths, accepts bundled + runtime binaries without re-running version checks, and reports usable paths to + Claude/Codex harnesses. +- `apps/backend/src/runtime/agent-process.ts` owns backend-managed agent-server + launch. In packaged runtime mode it launches `deus-runtime agent-server` and + scrubs inherited runtime/dev env. +- `apps/desktop/main/runtime-env.ts` and `apps/desktop/main/backend-process.ts` + own Electron main-process packaged runtime env and backend launch. Packaged + desktop launches `deus-runtime backend`; development launches the built backend + CJS bundle with host-staged CLIs. +- `apps/backend/src/services/aap/lifecycle.ts` owns AAP child process spawning. + It routes packaged `device-use` launches through `deus-runtime device-use` so + app manifests do not depend on Bun or global PATH. +- `scripts/prepare-device-use.mjs`, `electron-builder.yml`, + `scripts/runtime/electron-builder-before-pack.cjs`, and + `scripts/runtime/lib/device-use-payloads.cjs` own device-use package payload + readiness and macOS helper assertions. + +## Verification Ownership + +- `scripts/runtime/validate.ts` is the static staging gate before packaging. It + validates runtime manifests, native runtime binaries, staged agent CLIs, staged + GitHub CLI payloads, and freshness of staged bundles. +- `scripts/runtime/smoke-source-runtime.cjs`, + `scripts/runtime/smoke-native-runtime.cjs`, + `scripts/runtime/smoke-packaged-runtime.cjs`, + `scripts/runtime/smoke-packaged-app.cjs`, + `scripts/runtime/smoke-packaged-desktop.cjs`, and helpers under + `scripts/runtime/lib/` are verification harnesses. They should share + assertion constants when that removes duplication, but they are not production + ownership layers. +- Packaged runtime smoke must not load host native addons to prepare backend + state. It creates temporary repos/workspaces through the packaged backend HTTP + API so SQLite stays inside the runtime under test and does not depend on + whether `better-sqlite3` was last rebuilt for Node or Electron. +- `scripts/prune-pencil-cli-binaries.cjs` is a packaging verifier/pruner for + Electron app contents and native runtime payloads. + +## Cleanup Boundaries + +- Keep production payload staging under `scripts/runtime/`. Agent-server should + discover and use CLIs, not stage or hash release binaries. +- Keep shared runtime contracts in `shared/runtime.ts` or `shared/lib/cli-path.ts` + when they are consumed by production code across desktop, backend, runtime, and + agent-server. +- Keep smoke-only helpers in `scripts/runtime/lib/`. Do not move them into + production modules just to reduce lines. +- Keep script-local assertion copies, such as the smoke harness packaged PATH + and env denylist, when they intentionally verify the production contract from + the outside. Production runtime constants that are consumed by multiple app + layers belong in `shared/runtime.ts`; Node-only smoke harnesses stay plain CJS + so release checks do not need a TypeScript loader or a prior app build. +- Keep public package script names stable. If a script moves, leave a wrapper at + the old command path. +- Do not reason from `dist/runtime` or `dist-electron` layout alone. Those are + generated artifacts; source ownership lives in `shared/`, `apps/`, and + `scripts/runtime/`. + +## Structural Deferrals + +- `apps/runtime/index.ts` remains a single runtime executable entrypoint for + now. It owns argument parsing, environment normalization, self-test + inspection, and command dispatch in one place; splitting before another + command grows would add indirection without reducing release risk. The future + split point is `apps/runtime/commands/` if `backend`, `agent-server`, + `device-use`, or `self-test` need independent tests or materially larger + command-specific logic. +- `scripts/runtime/agent-clis.ts` remains one module because target metadata, + staging, manifest writing, and validation share one release matrix. The future + split point is a small target catalog plus separate `stage-agent-clis` and + `validate-agent-clis` modules if another bundled agent family or platform + makes the file hard to review. +- `scripts/runtime/` remains a flat command directory with reusable helpers under + `scripts/runtime/lib/`. The command names map directly to package scripts, and + moving them now would require compatibility wrappers without improving the + release path. + +## Applied Cleanup + +- Dev agent CLI preflight stages only the host runtime key and leaves the full + `agent-clis.json` manifest unchanged. The full manifest remains owned by + `bun run build:runtime`. +- Device-use packaged payload constants are shared by packaging guards and + packaged app smoke checks via `scripts/runtime/lib/device-use-payloads.cjs`. +- Desktop CLI lookup now reuses `shared/runtime.ts` packaged PATH entries and + the packaged runtime env denylist from `apps/desktop/main/runtime-env.ts` + instead of carrying a production-local copy. +- Packaged `device-use` runtime invocations force bundled helper paths, while + source runtime invocations may still honor explicit helper overrides. +- AAP prefetch skips missing path-form entrypoints as optional prefetch work + instead of logging an alarming startup failure for unbuilt optional apps. +- Packaged runtime smoke seeds AAP state through backend HTTP routes instead of + host `better-sqlite3`, removing Node/Electron native ABI ordering from the + smoke harness. diff --git a/electron-builder.yml b/electron-builder.yml index 8359c5051..bec04c4a3 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -57,6 +57,8 @@ mac: - "Contents/Resources/bin/gh" - "Contents/Resources/bin/rg" - "Contents/Resources/bin/agent-browser" + - "Contents/Resources/simulator/simbridge" + - "Contents/Resources/simulator/siminspector.dylib" extraResources: - from: "dist/runtime/electron/bin/deus-runtime.json" to: "bin/deus-runtime.json" diff --git a/package.json b/package.json index 708063031..c2117061f 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,8 @@ "packages/*" ], "scripts": { - "dev": "bun run native:electron && ([ -f apps/agent-server/dist/index.bundled.cjs ] || bun run build:agent-server) && electron-vite dev", - "dev:web": "bun run native:node && bun scripts/runtime/dev.ts", + "dev": "bun run native:electron && bun scripts/runtime/prepare-dev-agent-clis.ts && ([ -f apps/agent-server/dist/index.bundled.cjs ] || bun run build:agent-server) && electron-vite dev", + "dev:web": "bun run native:node && bun scripts/runtime/prepare-dev-agent-clis.ts && bun scripts/runtime/dev.ts", "dev:frontend": "vite --config apps/web/vite.config.ts", "dev:backend": "node apps/backend/server.cjs", "build": "node node_modules/electron-vite/bin/electron-vite.js build", @@ -29,14 +29,14 @@ "deploy:relay": "cd apps/cloud-relay && wrangler deploy", "build:web": "vite build --config apps/web/vite.config.ts --outDir dist/web", "build:cli": "bun run build:runtime && bun apps/cli/build.ts", - "build:all": "bun run build:runtime && bun run build:pencil && bun run build", + "build:all": "bun run prepare:device-use && bun run build:runtime && bun run build:pencil && bun run build", "package:mac": "bun run build:all && electron-builder --mac", "package:mac:dir": "node scripts/runtime/package-mac-dir.cjs", "package:win": "node scripts/runtime/unsupported-packaged-platform.cjs Windows", "package:linux": "bun run build:all && electron-builder --linux", "postinstall": "bun run prepare:device-use", "native:electron": "electron-builder install-app-deps", - "native:node": "cd node_modules/better-sqlite3 && node ../node-gyp/bin/node-gyp.js rebuild", + "native:node": "cd node_modules/better-sqlite3 && PYTHON=${PYTHON:-/usr/bin/python3} node ../node-gyp/bin/node-gyp.js rebuild", "prepare:device-use": "node scripts/prepare-device-use.mjs", "prepare:gh-cli": "node scripts/prepare-gh-cli.mjs", "prepare:agent-clis": "bun scripts/runtime/prepare-agent-clis.ts", diff --git a/packages/device-use/native/Sources/SimInspector/build.sh b/packages/device-use/native/Sources/SimInspector/build.sh index 69078da09..7ac0978a3 100755 --- a/packages/device-use/native/Sources/SimInspector/build.sh +++ b/packages/device-use/native/Sources/SimInspector/build.sh @@ -14,6 +14,7 @@ for ARCH in arm64 x86_64; do -isysroot "$SDK_PATH" \ -arch "$ARCH" \ -mios-simulator-version-min=15.0 \ + -Wl,-install_name,@rpath/siminspector.dylib \ -framework Foundation \ -framework UIKit \ "$SCRIPT_DIR/Inspector.m" \ diff --git a/packages/device-use/scripts/build-ts.ts b/packages/device-use/scripts/build-ts.ts index a42a45c58..69d86667e 100644 --- a/packages/device-use/scripts/build-ts.ts +++ b/packages/device-use/scripts/build-ts.ts @@ -15,6 +15,7 @@ if (existsSync(distDir)) rmSync(distDir, { recursive: true }); const entries = [ { entry: "src/cli/index.ts", out: "dist/cli.js", shebang: true }, { entry: "src/engine/index.ts", out: "dist/engine.js", shebang: false }, + { entry: "src/server/index.ts", out: "dist/server/index.js", shebang: false }, ]; for (const { entry, out, shebang } of entries) { diff --git a/packages/device-use/src/cli/commands/serve.ts b/packages/device-use/src/cli/commands/serve.ts index a56ee542c..a2d8a1419 100644 --- a/packages/device-use/src/cli/commands/serve.ts +++ b/packages/device-use/src/cli/commands/serve.ts @@ -29,10 +29,12 @@ export const serveCommand: CommandDefinition = { const here = path.dirname(fileURLToPath(import.meta.url)); // Locate the server entrypoint across dev + bundled layouts. // From /src/cli/commands/serve.{ts,js}: ../../server/index.ts (dev) - // From /dist/cli.js (bundled): ../src/server/index.ts - // From /dist/cli/<…>/serve.js (future split build): ../../../server/index.js + // From /dist/cli.js (bundled): server/index.js + // From /dist/cli/<...>/serve.js (future split build): ../../server/index.js const moduleCandidates = [ path.resolve(here, "../../server/index.ts"), + path.resolve(here, "server/index.js"), + path.resolve(here, "../../server/index.js"), path.resolve(here, "../src/server/index.ts"), path.resolve(here, "../../../server/index.js"), ]; diff --git a/scripts/prepare-device-use.mjs b/scripts/prepare-device-use.mjs index cb0dfaae7..9dc19f3b0 100644 --- a/scripts/prepare-device-use.mjs +++ b/scripts/prepare-device-use.mjs @@ -4,11 +4,12 @@ // 3. Copies simbridge + siminspector into packages/device-use/bin/ (where // runtime code looks for it) // -// Idempotent for expensive builds, but helper binaries are always refreshed -// from the latest native output to avoid stale packaged artifacts. +// Idempotent for expensive builds. Fresh installs can use already-staged +// helpers without requiring Swift; when helpers are missing, we build or copy +// from the current native output. import { execFileSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -28,10 +29,14 @@ if (!existsSync(pkgDir)) { process.exit(0); } -// 1. TypeScript build +// 1. TypeScript/server build const distDir = join(pkgDir, "dist"); -const distEngine = join(distDir, "engine.js"); -if (!existsSync(distEngine)) { +const distOutputs = [ + join(distDir, "cli.js"), + join(distDir, "engine.js"), + join(distDir, "server", "index.js"), +]; +if (distOutputs.some((output) => !existsSync(output))) { log("building TypeScript (dist/)..."); try { run("bun", ["run", "build:ts"], pkgDir); @@ -43,11 +48,27 @@ if (!existsSync(distEngine)) { log("dist/ already built, skipping"); } -// 2. Native build (macOS only, Xcode CLT required) +const frontendIndex = join(distDir, "frontend", "index.html"); +if (!existsSync(frontendIndex)) { + log("building frontend (dist/frontend/)..."); + try { + run("bun", ["run", "build:frontend"], pkgDir); + } catch (err) { + log(`frontend build failed: ${err.message}`); + process.exit(1); + } +} else { + log("dist/frontend/ already built, skipping"); +} + +// 2. Native helpers (macOS only, Xcode CLT required) const nativeDir = join(pkgDir, "native"); const releaseBinary = join(nativeDir, ".build", "release", "simbridge"); const universalBinary = join(nativeDir, ".build", "apple", "Products", "Release", "simbridge"); const releaseInspector = join(nativeDir, ".build", "release", "siminspector.dylib"); +const binDir = join(pkgDir, "bin"); +const binSimbridge = join(binDir, "simbridge"); +const binSiminspector = join(binDir, "siminspector.dylib"); function hasRequiredMacArchitectures(binary) { if (process.platform !== "darwin") return true; @@ -86,11 +107,23 @@ function swiftAvailable() { } } -if (!findSwiftBuildOutput() || !existsSync(releaseInspector)) { +function helperReady(filePath) { + return existsSync(filePath) && hasRequiredMacArchitectures(filePath); +} + +const stagedHelpersReady = helperReady(binSimbridge) && helperReady(binSiminspector); + +if (stagedHelpersReady) { + log("native simulator helpers already staged, skipping"); +} else if (!findSwiftBuildOutput() || !existsSync(releaseInspector)) { if (process.platform !== "darwin") { - log("not on macOS, skipping native build"); + log( + "not on macOS and staged simulator helpers are missing; packaged macOS builds must stage them on macOS" + ); } else if (!swiftAvailable()) { - log("Swift not found (install Xcode CLT: xcode-select --install). Skipping."); + log( + "Swift not found (install Xcode CLT: xcode-select --install). Using any existing staged helpers." + ); } else { log("building native simulator helpers..."); try { @@ -105,10 +138,7 @@ if (!findSwiftBuildOutput() || !existsSync(releaseInspector)) { } // 3. Copy native helpers into packages/device-use/bin/ for stable runtime path -const binDir = join(pkgDir, "bin"); -const binSimbridge = join(binDir, "simbridge"); -const binSiminspector = join(binDir, "siminspector.dylib"); -const source = findSwiftBuildOutput(); +const source = stagedHelpersReady ? null : findSwiftBuildOutput(); if (source) { mkdirSync(binDir, { recursive: true }); @@ -116,27 +146,41 @@ if (source) { try { // Preserve executable bit const mode = statSync(source).mode; - execFileSync("chmod", [(mode & 0o777).toString(8), binSimbridge]); + chmodSync(binSimbridge, mode & 0o777); } catch { /* best effort */ } log(`copied simbridge → ${binSimbridge}`); +} else if (helperReady(binSimbridge)) { + log(`using staged simbridge at ${binSimbridge}`); } else { log("simbridge build output not found; runtime will report a clear error if needed"); } -if (existsSync(releaseInspector)) { +if (!stagedHelpersReady && existsSync(releaseInspector)) { mkdirSync(binDir, { recursive: true }); copyFileSync(releaseInspector, binSiminspector); try { const mode = statSync(releaseInspector).mode; - execFileSync("chmod", [(mode & 0o777).toString(8), binSiminspector]); + chmodSync(binSiminspector, mode & 0o777); } catch { /* best effort */ } log(`copied siminspector → ${binSiminspector}`); +} else if (helperReady(binSiminspector)) { + log(`using staged siminspector at ${binSiminspector}`); } else { log("siminspector build output not found; runtime will report a clear error if needed"); } +if (process.platform === "darwin" && existsSync(binSiminspector)) { + try { + execFileSync("install_name_tool", ["-id", "@rpath/siminspector.dylib", binSiminspector], { + stdio: "ignore", + }); + } catch { + log("could not normalize siminspector install name; packaged smoke will verify it"); + } +} + log("done"); diff --git a/scripts/runtime/agent-clis.ts b/scripts/runtime/agent-clis.ts index e8180b863..7c29f5863 100644 --- a/scripts/runtime/agent-clis.ts +++ b/scripts/runtime/agent-clis.ts @@ -92,7 +92,9 @@ export interface AgentCliManifest { export interface PrepareAgentCliOptions { log?: (line: string) => void; projectRoot?: string; + runtimeKeys?: readonly AgentCliTarget["runtimeKey"][]; verifyRunnable?: boolean; + writeManifest?: boolean; } export interface ValidateAgentCliOptions { @@ -455,9 +457,9 @@ async function verifyVersionBounded( const hint = macExecutionPolicyHint(diagnostics); fail( new Error( - `${path.basename(executablePath)} ${args.join(" ")} failed to spawn: error=${ - spawnErrorCode(error) - } stdout=${stdout.trim().slice(-4000)} stderr=${stderr.trim().slice(-4000)}${ + `${path.basename(executablePath)} ${args.join(" ")} failed to spawn: error=${spawnErrorCode( + error + )} stdout=${stdout.trim().slice(-4000)} stderr=${stderr.trim().slice(-4000)}${ diagnostics ? `\n${diagnostics}` : "" }${hint}` ) @@ -542,10 +544,7 @@ function assertVersionOutput(tool: AgentCliName, output: string, executablePath: } } -export function verifyStagedAgentCliVersion( - tool: AgentCliName, - executablePath: string -): string { +export function verifyStagedAgentCliVersion(tool: AgentCliName, executablePath: string): string { const binDir = path.dirname(executablePath); const env = versionCheckEnv(binDir); const output = verifyVersion( @@ -628,9 +627,13 @@ export async function prepareAgentClis( const log = options.log ?? console.log; const projectRoot = options.projectRoot ?? defaultProjectRoot; const verifyRunnable = options.verifyRunnable === true; + const writeManifest = options.writeManifest !== false; const manifestTargets: StagedAgentCli[] = []; + const runtimeKeys = options.runtimeKeys ? new Set(options.runtimeKeys) : null; for (const target of AGENT_CLI_TARGETS) { + if (runtimeKeys && !runtimeKeys.has(target.runtimeKey)) continue; + const targetDir = path.dirname( resolveStagedAgentCliPath(projectRoot, target.runtimeKey, "codex") ); @@ -788,9 +791,17 @@ export async function prepareAgentClis( targets: manifestTargets, }; const manifestPath = resolveAgentCliManifestPath(projectRoot); - mkdirSync(path.dirname(manifestPath), { recursive: true }); - writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); - log(`✓ Agent CLIs staged at ${relativeFromProjectRoot(projectRoot, path.dirname(manifestPath))}`); + if (writeManifest) { + mkdirSync(path.dirname(manifestPath), { recursive: true }); + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); + } + const manifestLabel = writeManifest ? "manifest updated" : "manifest unchanged"; + log( + `✓ Agent CLIs staged at ${relativeFromProjectRoot( + projectRoot, + path.dirname(manifestPath) + )} (${manifestLabel})` + ); return manifest; } @@ -830,9 +841,7 @@ function getExecutableFileOutput( return fileOutput; } -export function validateStagedAgentClis( - options: ValidateAgentCliOptions = {} -): AgentCliManifest { +export function validateStagedAgentClis(options: ValidateAgentCliOptions = {}): AgentCliManifest { const log = options.log ?? console.log; const projectRoot = options.projectRoot ?? defaultProjectRoot; const manifestPath = resolveAgentCliManifestPath(projectRoot); diff --git a/scripts/runtime/dev.ts b/scripts/runtime/dev.ts index 3124cc657..4909eb82c 100644 --- a/scripts/runtime/dev.ts +++ b/scripts/runtime/dev.ts @@ -7,6 +7,7 @@ import { getDevStagedCliDirectory } from "../../shared/lib/cli-path"; const runtimeDir = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(runtimeDir, "../.."); let stopping = false; +const nodeExecutable = process.env.NODE_EXECUTABLE || "node"; function log(message: string): void { console.log(`[dev] ${message}`); @@ -64,27 +65,23 @@ async function main(): Promise { function startBackend(): Promise<{ process: ChildProcess; port: number } | null> { return new Promise((resolve) => { - const child = spawn( - process.execPath, - [path.join(projectRoot, "apps", "backend", "server.cjs")], - { - cwd: path.join(projectRoot, "apps", "backend"), - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - PORT: "0", - AGENT_SERVER_ENTRY: path.join( - projectRoot, - "apps", - "agent-server", - "dist", - "index.bundled.cjs" - ), - AGENT_SERVER_CWD: path.join(projectRoot, "apps", "agent-server"), - DEUS_BUNDLED_BIN_DIR: getDevStagedCliDirectory(projectRoot) ?? "", - }, - } - ); + const child = spawn(nodeExecutable, [path.join(projectRoot, "apps", "backend", "server.cjs")], { + cwd: path.join(projectRoot, "apps", "backend"), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + PORT: "0", + AGENT_SERVER_ENTRY: path.join( + projectRoot, + "apps", + "agent-server", + "dist", + "index.bundled.cjs" + ), + AGENT_SERVER_CWD: path.join(projectRoot, "apps", "agent-server"), + DEUS_BUNDLED_BIN_DIR: getDevStagedCliDirectory(projectRoot) ?? "", + }, + }); let settled = false; let buffer = ""; diff --git a/scripts/runtime/electron-builder-before-pack.cjs b/scripts/runtime/electron-builder-before-pack.cjs index 411c6f59f..fc9dab6e1 100644 --- a/scripts/runtime/electron-builder-before-pack.cjs +++ b/scripts/runtime/electron-builder-before-pack.cjs @@ -13,6 +13,12 @@ const SUPPORTED_PACKAGED_RUNTIME_KEYS = new Map([ ["darwin", new Set(["arm64", "x64"])], ["linux", new Set(["x64"])], ]); +const { + DEVICE_USE_HELPER_NAMES, + DEVICE_USE_PACKAGE_FILES, + assertNoBuildLocalInstallName, + deviceUsePackageRoot, +} = require("./lib/device-use-payloads.cjs"); const SOURCE_EXTENSIONS = new Set([ ".cjs", @@ -223,10 +229,49 @@ function assertPackagedRuntimePlatform(context) { throw new Error( `Packaged Deus native runtime is staged for ${[...SUPPORTED_PACKAGED_RUNTIME_KEYS.entries()] .map(([platform, arches]) => `${platform}-${[...arches].join("|")}`) - .join(", ")}. Refusing to build ${platformName}${arch ? `-${arch}` : ""} artifacts until Resources/bin/deus-runtime and bundled native CLIs are staged for that platform.` + .join( + ", " + )}. Refusing to build ${platformName}${arch ? `-${arch}` : ""} artifacts until Resources/bin/deus-runtime and bundled native CLIs are staged for that platform.` ); } +function assertExecutableFile(filePath, label) { + if (!existsSync(filePath)) { + throw new Error(`Missing ${label}: ${filePath}`); + } + const stat = statSync(filePath); + if (!stat.isFile()) { + throw new Error(`${label} is not a regular file: ${filePath}`); + } + if ((stat.mode & 0o111) === 0) { + throw new Error(`${label} is not executable: ${filePath}`); + } +} + +function assertUniversalMacHelper(filePath, label) { + assertExecutableFile(filePath, label); + execFileSync("lipo", [filePath, "-verify_arch", "arm64", "x86_64"], { + stdio: "ignore", + }); +} + +function assertDeviceUsePayloads(projectRoot) { + const packageRoot = deviceUsePackageRoot(projectRoot); + + for (const [label, relativePath] of DEVICE_USE_PACKAGE_FILES) { + const filePath = path.join(packageRoot, relativePath); + if (!existsSync(filePath) || !statSync(filePath).isFile()) { + throw new Error(`Missing ${label}: ${filePath}. Run \`bun run prepare:device-use\`.`); + } + } + + const simbridge = path.join(packageRoot, "bin", DEVICE_USE_HELPER_NAMES.simbridge); + const siminspector = path.join(packageRoot, "bin", DEVICE_USE_HELPER_NAMES.siminspector); + assertUniversalMacHelper(simbridge, "device-use simbridge"); + assertUniversalMacHelper(siminspector, "device-use siminspector"); + assertNoBuildLocalInstallName(siminspector, projectRoot, "device-use siminspector"); +} + module.exports = function beforePack(context) { const projectRoot = path.resolve(__dirname, "../.."); @@ -270,6 +315,10 @@ module.exports = function beforePack(context) { ); } } + + if (platformName === "darwin") { + assertDeviceUsePayloads(projectRoot); + } }; module.exports.assertPackagedMainRuntimeContract = assertPackagedMainRuntimeContract; diff --git a/scripts/runtime/lib/device-use-payloads.cjs b/scripts/runtime/lib/device-use-payloads.cjs new file mode 100644 index 000000000..109511244 --- /dev/null +++ b/scripts/runtime/lib/device-use-payloads.cjs @@ -0,0 +1,59 @@ +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +const DEVICE_USE_PACKAGE_FILES = Object.freeze([ + ["device-use manifest", "agentic-app.json"], + ["device-use package metadata", "package.json"], + ["device-use CLI bundle", "dist/cli.js"], + ["device-use engine bundle", "dist/engine.js"], + ["device-use server bundle", "dist/server/index.js"], + ["device-use frontend bundle", "dist/frontend/index.html"], + ["device-use skill", "skills/device-use/SKILL.md"], +]); + +const DEVICE_USE_HELPER_NAMES = Object.freeze({ + simbridge: "simbridge", + siminspector: "siminspector.dylib", +}); + +function deviceUsePackageRoot(projectRoot) { + return path.join(projectRoot, "packages", "device-use"); +} + +function packagedDeviceUseRoot(resourcesDir) { + return path.join(resourcesDir, "agentic-apps", "device-use"); +} + +function packagedSimulatorDir(resourcesDir) { + return path.join(resourcesDir, "simulator"); +} + +function parseInstallNames(output) { + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.endsWith(":")); +} + +function assertNoBuildLocalInstallName(filePath, projectRoot, label) { + const output = execFileSync("otool", ["-D", filePath], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const installNames = parseInstallNames(output); + const hasBuildLocalName = installNames.some( + (line) => line.includes(projectRoot) || line.includes(".build") + ); + if (hasBuildLocalName) { + throw new Error(`${label} has a build-local install name: ${output.trim()}`); + } +} + +module.exports = { + DEVICE_USE_HELPER_NAMES, + DEVICE_USE_PACKAGE_FILES, + assertNoBuildLocalInstallName, + deviceUsePackageRoot, + packagedDeviceUseRoot, + packagedSimulatorDir, +}; diff --git a/scripts/runtime/prepare-dev-agent-clis.ts b/scripts/runtime/prepare-dev-agent-clis.ts new file mode 100644 index 000000000..f535f24b7 --- /dev/null +++ b/scripts/runtime/prepare-dev-agent-clis.ts @@ -0,0 +1,24 @@ +import { prepareAgentClis } from "./agent-clis"; + +type AgentCliRuntimeKey = "darwin-arm64" | "darwin-x64" | "linux-x64"; + +function getHostRuntimeKey(): AgentCliRuntimeKey | null { + if (process.platform === "darwin" && process.arch === "arm64") return "darwin-arm64"; + if (process.platform === "darwin" && process.arch === "x64") return "darwin-x64"; + if (process.platform === "linux" && process.arch === "x64") return "linux-x64"; + return null; +} + +const runtimeKey = getHostRuntimeKey(); +if (!runtimeKey) { + console.warn( + `[dev] Unsupported platform for bundled agent CLI staging: ${process.platform}-${process.arch}` + ); + process.exit(0); +} + +await prepareAgentClis({ + runtimeKeys: [runtimeKey], + verifyRunnable: process.env.DEUS_VERIFY_AGENT_CLI_RUNNABLE === "1", + writeManifest: false, +}); diff --git a/scripts/runtime/smoke-packaged-app.cjs b/scripts/runtime/smoke-packaged-app.cjs index 3d20abdc4..18865891e 100644 --- a/scripts/runtime/smoke-packaged-app.cjs +++ b/scripts/runtime/smoke-packaged-app.cjs @@ -6,9 +6,7 @@ const { verifyCodeSignaturePageSize, verifyPackagedAgentClis, } = require("../prune-pencil-cli-binaries.cjs"); -const { - assertPackagedMainRuntimeContents, -} = require("./electron-builder-before-pack.cjs"); +const { assertPackagedMainRuntimeContents } = require("./electron-builder-before-pack.cjs"); const { PROJECT_ROOT, RUNTIME_BINARIES, @@ -19,6 +17,13 @@ const { assertRegularFile, resolveDefaultAppPath, } = require("./lib/smoke-helpers.cjs"); +const { + DEVICE_USE_HELPER_NAMES, + DEVICE_USE_PACKAGE_FILES, + assertNoBuildLocalInstallName, + packagedDeviceUseRoot, + packagedSimulatorDir, +} = require("./lib/device-use-payloads.cjs"); const DEFAULT_APP_PATH = resolveDefaultAppPath(); const REQUIRED_BINARIES = RUNTIME_BINARIES; @@ -201,7 +206,11 @@ function verifyAsarRuntimeContract(asarPath) { assert(fs.existsSync(asarPath), `Missing packaged app.asar: ${asarPath}`); const entries = new Set(asar.listPackage(asarPath)); - for (const entry of ["/out/main/index.js", "/out/preload/index.mjs", "/out/renderer/index.html"]) { + for (const entry of [ + "/out/main/index.js", + "/out/preload/index.mjs", + "/out/renderer/index.html", + ]) { assert(entries.has(entry), `Packaged app.asar is missing ${entry}`); } @@ -217,12 +226,8 @@ function verifyNoDuplicateRuntimeCliPackages(resourcesDir) { const isForbiddenAsarEntry = (entry) => FORBIDDEN_RUNTIME_PACKAGE_PREFIXES.some((prefix) => entry.startsWith(prefix)) || - FORBIDDEN_RUNTIME_PACKAGE_ROOTS.some( - (root) => entry === root || entry.startsWith(`${root}/`) - ); - const duplicateAsarEntries = asar - .listPackage(asarPath) - .filter(isForbiddenAsarEntry); + FORBIDDEN_RUNTIME_PACKAGE_ROOTS.some((root) => entry === root || entry.startsWith(`${root}/`)); + const duplicateAsarEntries = asar.listPackage(asarPath).filter(isForbiddenAsarEntry); assert( duplicateAsarEntries.length === 0, "Packaged app.asar contains duplicate runtime CLI package payloads outside Resources/bin:\n" + @@ -270,6 +275,78 @@ function verifyNoDuplicateRuntimeCliPackages(resourcesDir) { console.log("[runtime-smoke] duplicate runtime CLI package payloads absent"); } +function verifyMacUniversalBinary(filePath, label, options) { + assertRegularExecutable(filePath, label); + const output = fileOutput(filePath); + assert( + output.includes("Mach-O") && output.includes("arm64") && output.includes("x86_64"), + `${label} is not a universal arm64/x86_64 Mach-O binary: ${output}` + ); + execFileSync("lipo", [filePath, "-verify_arch", "arm64", "x86_64"], { + encoding: "utf8", + timeout: 20_000, + stdio: ["ignore", "ignore", "pipe"], + }); + + if (!options.skipAppSignature) { + execFileSync("codesign", ["--verify", "--verbose=2", filePath], { + encoding: "utf8", + timeout: 20_000, + stdio: ["ignore", "ignore", "pipe"], + }); + verifyCodeSignaturePageSize(filePath, label); + } + console.log(`[runtime-smoke] ${label}: ${output}`); +} + +function verifyPackagedDeviceUse(resourcesDir, options) { + const simulatorDir = packagedSimulatorDir(resourcesDir); + const appRoot = packagedDeviceUseRoot(resourcesDir); + + assertDirectory(simulatorDir, "packaged simulator helper directory"); + verifyMacUniversalBinary( + path.join(simulatorDir, DEVICE_USE_HELPER_NAMES.simbridge), + "packaged simulator simbridge", + options + ); + const simulatorInspector = path.join(simulatorDir, DEVICE_USE_HELPER_NAMES.siminspector); + verifyMacUniversalBinary(simulatorInspector, "packaged simulator siminspector", options); + assertNoBuildLocalInstallName(simulatorInspector, PROJECT_ROOT, "packaged simulator siminspector"); + + assertDirectory(appRoot, "packaged device-use app"); + for (const [, relativePath] of DEVICE_USE_PACKAGE_FILES) { + assertRegularFile(path.join(appRoot, relativePath), `packaged device-use ${relativePath}`); + } + verifyMacUniversalBinary( + path.join(appRoot, "bin", DEVICE_USE_HELPER_NAMES.simbridge), + "packaged device-use app simbridge", + options + ); + const appInspector = path.join(appRoot, "bin", DEVICE_USE_HELPER_NAMES.siminspector); + verifyMacUniversalBinary(appInspector, "packaged device-use app siminspector", options); + assertNoBuildLocalInstallName(appInspector, PROJECT_ROOT, "packaged device-use app siminspector"); + + for (const forbidden of [ + path.join(appRoot, "native", ".build"), + path.join(appRoot, "native", ".swiftpm"), + ]) { + assert( + !fs.existsSync(forbidden), + `Packaged device-use contains generated native build output: ${forbidden}` + ); + } + + const manifest = readJsonFile( + path.join(appRoot, "agentic-app.json"), + "packaged device-use manifest" + ); + assert( + manifest.launch?.command === "device-use", + `Packaged device-use manifest has unexpected launch command: ${manifest.launch?.command}` + ); + console.log("[runtime-smoke] packaged device-use runtime payload verified"); +} + async function verifyPackagedApp(options) { const appPath = options.appPath; assertDirectory(appPath, "packaged app bundle"); @@ -322,6 +399,7 @@ async function verifyPackagedApp(options) { ); verifyAsarRuntimeContract(path.join(resourcesDir, "app.asar")); verifyNoDuplicateRuntimeCliPackages(resourcesDir); + verifyPackagedDeviceUse(resourcesDir, options); console.log(`[runtime-smoke] packaged app verified: ${appPath}`); } diff --git a/scripts/runtime/smoke-packaged-runtime.cjs b/scripts/runtime/smoke-packaged-runtime.cjs index 96e538e0d..e124c3b65 100644 --- a/scripts/runtime/smoke-packaged-runtime.cjs +++ b/scripts/runtime/smoke-packaged-runtime.cjs @@ -1,17 +1,24 @@ const fs = require("node:fs"); +const http = require("node:http"); const os = require("node:os"); const path = require("node:path"); -const { execFileSync } = require("node:child_process"); +const { execFileSync, spawn, spawnSync } = require("node:child_process"); +const { createServer } = require("node:net"); +const WebSocket = require("ws"); const { assertInitializedAgents, readAgentServerListenUrl } = require("./runtime-smoke-rpc.cjs"); const { PROJECT_ROOT, + PACKAGED_SYSTEM_PATHS, assertBackendDbRouteFromOutput, assertExecutable, assertHostRunnableArch, assertRuntimeSelfTest, backendBundledAgentCliPatterns, bundledAgentCliPatterns, + getJson, resolveDefaultAppPath, + runtimeEnv, + stopChild, runRuntimeCommand, waitForRuntimePatterns, } = require("./lib/smoke-helpers.cjs"); @@ -83,6 +90,250 @@ async function assertInitializedAgentsFromOutput(output, message) { await assertInitializedAgents(listenUrl); } +function readBackendPort(output) { + const match = output.match(/^\[BACKEND_PORT\](\d+)/m); + if (!match) throw new Error("Packaged backend runtime output did not include [BACKEND_PORT]"); + return Number(match[1]); +} + +function isCliAvailable(command) { + const result = spawnSync("sh", ["-c", `command -v ${JSON.stringify(command)}`], { + stdio: "ignore", + timeout: 2_000, + }); + return result.status === 0; +} + +function sendWsCommand(port, command, params, timeoutMs = 30_000) { + return new Promise((resolve, reject) => { + const id = `${command}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + let settled = false; + let sent = false; + + const cleanup = () => { + clearTimeout(timeout); + ws.removeAllListeners(); + try { + ws.close(); + } catch { + // Best-effort cleanup. + } + }; + const finish = (fn, value) => { + if (settled) return; + settled = true; + cleanup(); + fn(value); + }; + const timeout = setTimeout(() => { + finish(reject, new Error(`Timed out waiting for q:command_ack ${command}`)); + }, timeoutMs); + const send = () => { + if (sent) return; + sent = true; + ws.send(JSON.stringify({ type: "q:command", id, command, params })); + }; + + ws.on("message", (data) => { + let message; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + if (message.type === "connected") { + send(); + return; + } + if (message.type === "ping") { + ws.send(JSON.stringify({ type: "pong" })); + return; + } + if (message.type !== "q:command_ack" || message.id !== id) return; + if (message.accepted) { + finish(resolve, message); + } else { + finish(reject, new Error(`q:command ${command} rejected: ${message.error}`)); + } + }); + ws.on("open", () => { + // Localhost backends immediately send a connected frame, which triggers + // the command. Keep this handler only so websocket errors before open are + // clearly separated from a missing connected frame timeout. + }); + ws.on("error", (error) => finish(reject, error)); + ws.on("close", (code, reason) => { + if (!settled) { + finish( + reject, + new Error(`WebSocket closed before q:command_ack ${command}: ${code} ${reason}`) + ); + } + }); + }); +} + +function requestJson(port, method, route, body, timeoutMs = 30_000) { + return new Promise((resolve, reject) => { + const payload = body === undefined ? null : JSON.stringify(body); + const request = http.request( + { + host: "127.0.0.1", + port, + path: route, + method, + headers: payload + ? { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload), + } + : undefined, + }, + (response) => { + let responseBody = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + responseBody += chunk; + }); + response.on("end", () => { + const statusCode = response.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + reject( + new Error( + `${method} ${route} failed with ${statusCode}: ${responseBody.slice(0, 1000)}` + ) + ); + return; + } + try { + resolve(responseBody ? JSON.parse(responseBody) : null); + } catch (error) { + reject(new Error(`${method} ${route} returned invalid JSON: ${error.message}`)); + } + }); + } + ); + request.setTimeout(timeoutMs, () => { + request.destroy(new Error(`Timed out waiting for ${method} ${route}`)); + }); + request.on("error", reject); + if (payload) request.write(payload); + request.end(); + }); +} + +function createSmokeGitRepo(dataDir) { + const repoRoot = path.join(dataDir, "aap-repo"); + fs.mkdirSync(repoRoot, { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "README.md"), "# Packaged runtime smoke\n"); + execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); + execFileSync( + "git", + [ + "-c", + "user.name=Deus Runtime Smoke", + "-c", + "user.email=runtime-smoke@deus.local", + "commit", + "-m", + "Initial smoke repo", + ], + { cwd: repoRoot, stdio: "ignore" } + ); + return repoRoot; +} + +async function waitForWorkspaceReady(port, workspaceId, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const workspace = await requestJson( + port, + "GET", + `/api/workspaces/${encodeURIComponent(workspaceId)}` + ); + if (workspace?.state === "ready") return workspace; + if (workspace?.state === "error") { + throw new Error(`Smoke workspace initialization failed: ${JSON.stringify(workspace)}`); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for smoke workspace ${workspaceId} to become ready`); +} + +async function createAapWorkspace(port, dataDir) { + // Keep DB writes inside the packaged backend process. Loading host + // better-sqlite3 here makes the smoke depend on Node/Electron rebuild order. + const repoRoot = createSmokeGitRepo(dataDir); + const repo = await requestJson(port, "POST", "/api/repos", { root_path: repoRoot }); + if (!repo?.id) { + throw new Error(`POST /api/repos returned unexpected payload: ${JSON.stringify(repo)}`); + } + + const workspace = await requestJson(port, "POST", "/api/workspaces", { + repository_id: repo.id, + pr_title: "Mobile Use smoke", + }); + if (!workspace?.id) { + throw new Error( + `POST /api/workspaces returned unexpected payload: ${JSON.stringify(workspace)}` + ); + } + const readyWorkspace = await waitForWorkspaceReady(port, workspace.id); + + return { workspaceId: readyWorkspace.id }; +} + +async function smokeBackendCommands(port, dataDir) { + if (process.platform !== "darwin") { + console.log("[runtime-smoke] packaged backend simulator/AAP command smoke skipped off macOS"); + return; + } + if (!isCliAvailable("xcrun")) { + console.log("[runtime-smoke] packaged backend simulator/AAP command smoke skipped: xcrun unavailable"); + return; + } + + const devicesAck = await sendWsCommand(port, "sim:listDevices", {}, 30_000); + if (!Array.isArray(devicesAck.devices)) { + throw new Error(`sim:listDevices returned unexpected payload: ${JSON.stringify(devicesAck)}`); + } + console.log( + `[runtime-smoke] packaged backend simulator q:command listed ${devicesAck.devices.length} devices` + ); + + const { workspaceId } = await createAapWorkspace(port, dataDir); + let runningAppId = null; + try { + const launchAck = await sendWsCommand( + port, + "launchApp", + { appId: "deus.mobile-use", workspaceId }, + 60_000 + ); + runningAppId = launchAck.runningAppId; + if (!runningAppId || typeof launchAck.url !== "string") { + throw new Error(`launchApp returned unexpected payload: ${JSON.stringify(launchAck)}`); + } + + const launchUrl = new URL(launchAck.url); + const health = await getJson(Number(launchUrl.port), "/health"); + if (health.statusCode !== 200) { + throw new Error( + `Mobile Use health check failed: ${health.statusCode} ${health.body.slice(0, 500)}` + ); + } + console.log( + "[runtime-smoke] packaged backend AAP launch started Mobile Use through bundled runtime" + ); + } finally { + if (runningAppId) { + await sendWsCommand(port, "stopApp", { runningAppId }, 15_000); + } + } +} + async function smokeAgentServer(runtimeBin, binDir) { await waitForRuntimePatterns( runtimeBin, @@ -118,11 +369,13 @@ async function smokeBackend(runtimeBin, binDir) { { obsoleteLabel: "Packaged runtime smoke", onReady: async (output) => { + const port = readBackendPort(output); await assertBackendDbRouteFromOutput(output); await assertInitializedAgentsFromOutput( output, "Packaged backend runtime output did not include agent-server LISTEN_URL" ); + await smokeBackendCommands(port, dataDir); }, } ); @@ -134,6 +387,63 @@ async function smokeBackend(runtimeBin, binDir) { ); } +function allocatePort() { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + server.close((error) => (error ? reject(error) : resolve(port))); + }); + }); +} + +async function smokeDeviceUseServe(runtimeBin) { + const port = await allocatePort(); + const child = spawn(runtimeBin, ["device-use", "serve", "--port", String(port)], { + cwd: path.dirname(runtimeBin), + detached: process.platform !== "win32", + env: runtimeEnv(null, { + PATH: PACKAGED_SYSTEM_PATHS.join(path.delimiter), + }), + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data) => { + stdout += data.toString(); + }); + child.stderr.on("data", (data) => { + stderr += data.toString(); + }); + + try { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `device-use exited before readiness: code=${child.exitCode} signal=${child.signalCode} stdout=${stdout} stderr=${stderr}` + ); + } + try { + const response = await getJson(port, "/health"); + if (response.statusCode === 200) { + console.log("[runtime-smoke] packaged runtime device-use serve reached /health"); + return; + } + } catch { + // Not ready yet. + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Timed out waiting for device-use /health stdout=${stdout} stderr=${stderr}`); + } finally { + await stopChild(child); + } +} + async function smokePackagedRuntime(options) { const appPath = options.appPath; const resourcesDir = path.join(appPath, "Contents", "Resources"); @@ -159,8 +469,15 @@ async function smokePackagedRuntime(options) { }); console.log(`[runtime-smoke] packaged runtime self-test binDir: ${selfTest.binDir}`); + const deviceUseVersion = await runRuntimeCommand(runtimeBin, ["device-use", "--version"], binDir); + if (!/^device-use \d+\.\d+\.\d+/.test(deviceUseVersion)) { + throw new Error(`Unexpected packaged device-use version output: ${deviceUseVersion}`); + } + console.log(`[runtime-smoke] packaged runtime device-use command: ${deviceUseVersion}`); + await smokeAgentServer(runtimeBin, binDir); await smokeBackend(runtimeBin, binDir); + await smokeDeviceUseServe(runtimeBin); } smokePackagedRuntime(parseArgs(process.argv.slice(2))).catch((error) => { diff --git a/scripts/runtime/smoke-source-runtime.cjs b/scripts/runtime/smoke-source-runtime.cjs index 779c59a60..8f68806cc 100644 --- a/scripts/runtime/smoke-source-runtime.cjs +++ b/scripts/runtime/smoke-source-runtime.cjs @@ -155,6 +155,12 @@ async function main() { } console.log(`[runtime-source-smoke] self-test binDir: ${selfTest.binDir}`); + const deviceUseVersion = runRuntime(["device-use", "--version"]); + if (!/^device-use \d+\.\d+\.\d+/.test(deviceUseVersion)) { + throw new Error(`Unexpected source runtime device-use output: ${deviceUseVersion}`); + } + console.log(`[runtime-source-smoke] device-use command: ${deviceUseVersion}`); + const listenUrl = await waitForRuntimeLine( ["agent-server"], (line) => { diff --git a/shared/lib/cli-path.ts b/shared/lib/cli-path.ts index d0dd52c6b..08ae1ee0d 100644 --- a/shared/lib/cli-path.ts +++ b/shared/lib/cli-path.ts @@ -1,8 +1,8 @@ import { existsSync, statSync } from "node:fs"; import { delimiter, join } from "node:path"; +import { PACKAGED_SYSTEM_PATHS } from "../runtime"; const CLI_TOOL_NAME_PATTERN = /^[a-zA-Z0-9._+-]+$/; -const PACKAGED_SYSTEM_PATHS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; function getElectronResourcesPath(): string | null { return ( @@ -82,7 +82,8 @@ function isExecutableFile(filePath: string): boolean { export function resolveCliExecutable(tool: string): string { const bundledCliPath = resolveBundledCliPath(tool); if (bundledCliPath) return bundledCliPath; - if (isPackagedRuntime()) return getBundledCliPathCandidates(tool)[0] ?? missingPackagedCliPath(tool); + if (isPackagedRuntime()) + return getBundledCliPathCandidates(tool)[0] ?? missingPackagedCliPath(tool); return tool; } diff --git a/shared/runtime.ts b/shared/runtime.ts index 700b09d64..a82916595 100644 --- a/shared/runtime.ts +++ b/shared/runtime.ts @@ -4,6 +4,7 @@ export const DEUS_APP_ID = "com.deus.app"; export const DEUS_DB_FILENAME = "deus.db"; export const DEUS_PREFERENCES_FILENAME = "preferences.json"; export const RUNTIME_MANIFEST_VERSION = 1; +export const PACKAGED_SYSTEM_PATHS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] as const; export const CLI_RUNTIME_DEPENDENCIES = [ "@anthropic-ai/claude-agent-sdk", diff --git a/test/unit/runtime/smoke-packaged-runtime.test.ts b/test/unit/runtime/smoke-packaged-runtime.test.ts new file mode 100644 index 000000000..2eadf5a73 --- /dev/null +++ b/test/unit/runtime/smoke-packaged-runtime.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(__dirname, "../../.."); + +describe("packaged runtime smoke harness", () => { + it("does not load host better-sqlite3 to seed backend state", () => { + const script = readFileSync( + path.join(projectRoot, "scripts", "runtime", "smoke-packaged-runtime.cjs"), + "utf8" + ); + + expect(script).not.toMatch(/require\(["']better-sqlite3["']\)/); + expect(script).not.toMatch(/new\s+Database\s*\(/); + expect(script).toContain('POST", "/api/repos"'); + expect(script).toContain('POST", "/api/workspaces"'); + }); +}); From bea158615b34d99fb734fe08aac1610d87c99762 Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Wed, 20 May 2026 17:35:31 +0200 Subject: [PATCH 2/7] Fix device-use runtime CLI import --- apps/runtime/index.ts | 10 +++---- packages/device-use/scripts/build-ts.ts | 33 ++++++++++++--------- scripts/prepare-device-use.mjs | 1 + scripts/runtime/lib/device-use-payloads.cjs | 1 + 4 files changed, 26 insertions(+), 19 deletions(-) diff --git a/apps/runtime/index.ts b/apps/runtime/index.ts index 662496496..336bcb788 100644 --- a/apps/runtime/index.ts +++ b/apps/runtime/index.ts @@ -368,11 +368,11 @@ function configureRuntimeEnv(command: RuntimeCommand, dataDir?: string): void { } } -function resolveDeviceUseCli(layout: ReturnType): string { +function resolveDeviceUseRuntimeEntry(layout: ReturnType): string { const candidates = [ - join(layout.resourcesPath, "agentic-apps", "device-use", "dist", "cli.js"), + join(layout.resourcesPath, "agentic-apps", "device-use", "dist", "cli-runtime.js"), layout.projectRoot - ? join(layout.projectRoot, "packages", "device-use", "dist", "cli.js") + ? join(layout.projectRoot, "packages", "device-use", "dist", "cli-runtime.js") : null, ]; for (const candidate of candidates) { @@ -380,7 +380,7 @@ function resolveDeviceUseCli(layout: ReturnType): s } throw new Error( - `Unable to find packaged device-use CLI. Checked: ${candidates.filter(Boolean).join(", ")}` + `Unable to find packaged device-use runtime CLI. Checked: ${candidates.filter(Boolean).join(", ")}` ); } @@ -463,7 +463,7 @@ async function run( if (command === "device-use") { const layout = resolveRuntimeLayout(); - const cliPath = resolveDeviceUseCli(layout); + const cliPath = resolveDeviceUseRuntimeEntry(layout); process.argv = [layout.executablePath, "device-use", ...passthroughArgs]; await import(pathToFileURL(cliPath).href); return; diff --git a/packages/device-use/scripts/build-ts.ts b/packages/device-use/scripts/build-ts.ts index 69d86667e..c13fd9a93 100644 --- a/packages/device-use/scripts/build-ts.ts +++ b/packages/device-use/scripts/build-ts.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { readFileSync, rmSync, existsSync } from "node:fs"; +import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -13,12 +13,21 @@ const version = pkg.version as string; if (existsSync(distDir)) rmSync(distDir, { recursive: true }); const entries = [ - { entry: "src/cli/index.ts", out: "dist/cli.js", shebang: true }, - { entry: "src/engine/index.ts", out: "dist/engine.js", shebang: false }, - { entry: "src/server/index.ts", out: "dist/server/index.js", shebang: false }, + { entry: "src/cli/index.ts", out: "dist/cli.js", executable: true }, + { entry: "src/cli/index.ts", out: "dist/cli-runtime.js", executable: false }, + { entry: "src/engine/index.ts", out: "dist/engine.js", executable: false }, + { entry: "src/server/index.ts", out: "dist/server/index.js", executable: false }, ]; -for (const { entry, out, shebang } of entries) { +function makeExecutableCli(filePath: string): void { + const source = readFileSync(filePath, "utf-8") + .replace(/^\uFEFF/, "") + .replace(/^#!.*\r?\n/, ""); + writeFileSync(filePath, `#!/usr/bin/env bun\n${source}`); + chmodSync(filePath, 0o755); +} + +for (const { entry, out, executable } of entries) { const result = await Bun.build({ entrypoints: [join(root, entry)], outdir: join(root, dirname(out)), @@ -28,7 +37,6 @@ for (const { entry, out, shebang } of entries) { splitting: false, sourcemap: "external", define: { __VERSION__: JSON.stringify(version) }, - banner: shebang ? "#!/usr/bin/env bun" : undefined, }); if (!result.success) { @@ -36,15 +44,12 @@ for (const { entry, out, shebang } of entries) { for (const log of result.logs) console.error(log); process.exit(1); } - console.log(` ✓ ${out}`); -} -// Make cli.js executable -try { - const { chmodSync } = await import("node:fs"); - chmodSync(join(root, "dist/cli.js"), 0o755); -} catch { - // ignore + if (executable) { + makeExecutableCli(join(root, out)); + } + + console.log(` ✓ ${out}`); } console.log("\nTS build complete."); diff --git a/scripts/prepare-device-use.mjs b/scripts/prepare-device-use.mjs index 9dc19f3b0..f0cd55c0c 100644 --- a/scripts/prepare-device-use.mjs +++ b/scripts/prepare-device-use.mjs @@ -33,6 +33,7 @@ if (!existsSync(pkgDir)) { const distDir = join(pkgDir, "dist"); const distOutputs = [ join(distDir, "cli.js"), + join(distDir, "cli-runtime.js"), join(distDir, "engine.js"), join(distDir, "server", "index.js"), ]; diff --git a/scripts/runtime/lib/device-use-payloads.cjs b/scripts/runtime/lib/device-use-payloads.cjs index 109511244..7922a323e 100644 --- a/scripts/runtime/lib/device-use-payloads.cjs +++ b/scripts/runtime/lib/device-use-payloads.cjs @@ -5,6 +5,7 @@ const DEVICE_USE_PACKAGE_FILES = Object.freeze([ ["device-use manifest", "agentic-app.json"], ["device-use package metadata", "package.json"], ["device-use CLI bundle", "dist/cli.js"], + ["device-use runtime CLI bundle", "dist/cli-runtime.js"], ["device-use engine bundle", "dist/engine.js"], ["device-use server bundle", "dist/server/index.js"], ["device-use frontend bundle", "dist/frontend/index.html"], From b2724d695c8b134854be4fa03d9fd264b2e6a7cb Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Wed, 20 May 2026 17:53:20 +0200 Subject: [PATCH 3/7] Address device-use packaging review --- package.json | 2 +- packages/device-use/agentic-app.json | 2 +- packages/device-use/scripts/build-native.ts | 10 ++++-- scripts/prepare-device-use.mjs | 36 +++++++++++++++++---- scripts/runtime/smoke-packaged-app.cjs | 12 ++++++- 5 files changed, 50 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 33c0fbcac..18865569d 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "deploy:relay": "cd apps/cloud-relay && wrangler deploy", "build:web": "vite build --config apps/web/vite.config.ts --outDir dist/web", "build:cli": "bun run build:runtime && bun apps/cli/build.ts", - "build:all": "bun run prepare:device-use && bun run build:runtime && bun run build:pencil && bun run build", + "build:all": "bun run prepare:device-use --force && bun run build:runtime && bun run build:pencil && bun run build", "package:mac": "bun run build:all && electron-builder --mac", "package:mac:dir": "node scripts/runtime/package-mac-dir.cjs", "package:win": "node scripts/runtime/unsupported-packaged-platform.cjs Windows", diff --git a/packages/device-use/agentic-app.json b/packages/device-use/agentic-app.json index 9c9478f9a..59f4f752e 100644 --- a/packages/device-use/agentic-app.json +++ b/packages/device-use/agentic-app.json @@ -9,7 +9,7 @@ "launch": { "command": "device-use", - "args": ["serve", "--port", "{port}"], + "args": ["serve", "--host", "127.0.0.1", "--port", "{port}"], "cwd": "{workspace}", "env": { "DEUS_STORAGE": "{storage.workspace}", diff --git a/packages/device-use/scripts/build-native.ts b/packages/device-use/scripts/build-native.ts index 9f6e58ea6..8efd618fa 100644 --- a/packages/device-use/scripts/build-native.ts +++ b/packages/device-use/scripts/build-native.ts @@ -22,6 +22,7 @@ const releaseBinary = join(releaseDir, "simbridge"); const universalBinary = join(buildDir, "apple", "Products", "Release", "simbridge"); const inspectorBinary = join(releaseDir, "siminspector.dylib"); const inspectorBuildScript = join(nativeDir, "Sources", "SimInspector", "build.sh"); +const force = process.argv.includes("--force"); function hasRequiredMacArchitectures(binary: string): boolean { if (process.platform !== "darwin") return true; @@ -50,11 +51,13 @@ function findSwiftBuildOutput(): string | null { } const bridgeReady = + !force && existsSync(releaseBinary) && !lstatSync(releaseDir).isSymbolicLink() && !lstatSync(releaseBinary).isSymbolicLink() && hasRequiredMacArchitectures(releaseBinary); -const inspectorReady = existsSync(inspectorBinary) && hasRequiredMacArchitectures(inspectorBinary); +const inspectorReady = + !force && existsSync(inspectorBinary) && hasRequiredMacArchitectures(inspectorBinary); if (bridgeReady && inspectorReady) { console.log("[build-native] simbridge already built, skipping."); @@ -69,7 +72,7 @@ try { } catch { console.warn("[build-native] Swift not found. simbridge will not be available."); console.warn(" Install Xcode Command Line Tools: xcode-select --install"); - process.exit(0); + process.exit(force ? 1 : 0); } if (!bridgeReady) { @@ -79,7 +82,7 @@ if (!bridgeReady) { console.log("[build-native] Built simbridge successfully."); } catch (error) { console.warn("[build-native] simbridge build failed:", error); - process.exit(0); + process.exit(force ? 1 : 0); } } @@ -113,6 +116,7 @@ if (!inspectorReady) { console.log("[build-native] Built siminspector successfully."); } catch (error) { console.warn("[build-native] siminspector build failed:", error); + if (force) process.exit(1); } } diff --git a/scripts/prepare-device-use.mjs b/scripts/prepare-device-use.mjs index f0cd55c0c..9e33850a8 100644 --- a/scripts/prepare-device-use.mjs +++ b/scripts/prepare-device-use.mjs @@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url"; const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); const pkgDir = join(rootDir, "packages", "device-use"); +const force = process.argv.includes("--force"); function log(msg) { console.log(`[prepare-device-use] ${msg}`); @@ -37,8 +38,8 @@ const distOutputs = [ join(distDir, "engine.js"), join(distDir, "server", "index.js"), ]; -if (distOutputs.some((output) => !existsSync(output))) { - log("building TypeScript (dist/)..."); +if (force || distOutputs.some((output) => !existsSync(output))) { + log(force ? "rebuilding TypeScript (dist/)..." : "building TypeScript (dist/)..."); try { run("bun", ["run", "build:ts"], pkgDir); } catch (err) { @@ -50,8 +51,8 @@ if (distOutputs.some((output) => !existsSync(output))) { } const frontendIndex = join(distDir, "frontend", "index.html"); -if (!existsSync(frontendIndex)) { - log("building frontend (dist/frontend/)..."); +if (force || !existsSync(frontendIndex)) { + log(force ? "rebuilding frontend (dist/frontend/)..." : "building frontend (dist/frontend/)..."); try { run("bun", ["run", "build:frontend"], pkgDir); } catch (err) { @@ -112,9 +113,23 @@ function helperReady(filePath) { return existsSync(filePath) && hasRequiredMacArchitectures(filePath); } -const stagedHelpersReady = helperReady(binSimbridge) && helperReady(binSiminspector); +const stagedHelpersReady = !force && helperReady(binSimbridge) && helperReady(binSiminspector); -if (stagedHelpersReady) { +if (force && process.platform === "darwin") { + if (!swiftAvailable()) { + log( + "Swift not found (install Xcode CLT: xcode-select --install). Cannot force-refresh native helpers." + ); + process.exit(1); + } + log("rebuilding native simulator helpers..."); + try { + run("bun", ["run", "scripts/build-native.ts", "--force"], pkgDir); + } catch (err) { + log(`native build failed: ${err.message}`); + process.exit(1); + } +} else if (stagedHelpersReady) { log("native simulator helpers already staged, skipping"); } else if (!findSwiftBuildOutput() || !existsSync(releaseInspector)) { if (process.platform !== "darwin") { @@ -184,4 +199,13 @@ if (process.platform === "darwin" && existsSync(binSiminspector)) { } } +if ( + force && + process.platform === "darwin" && + (!helperReady(binSimbridge) || !helperReady(binSiminspector)) +) { + log("forced native helper refresh did not stage both simulator helpers"); + process.exit(1); +} + log("done"); diff --git a/scripts/runtime/smoke-packaged-app.cjs b/scripts/runtime/smoke-packaged-app.cjs index 18865891e..bb3cbd97b 100644 --- a/scripts/runtime/smoke-packaged-app.cjs +++ b/scripts/runtime/smoke-packaged-app.cjs @@ -311,7 +311,11 @@ function verifyPackagedDeviceUse(resourcesDir, options) { ); const simulatorInspector = path.join(simulatorDir, DEVICE_USE_HELPER_NAMES.siminspector); verifyMacUniversalBinary(simulatorInspector, "packaged simulator siminspector", options); - assertNoBuildLocalInstallName(simulatorInspector, PROJECT_ROOT, "packaged simulator siminspector"); + assertNoBuildLocalInstallName( + simulatorInspector, + PROJECT_ROOT, + "packaged simulator siminspector" + ); assertDirectory(appRoot, "packaged device-use app"); for (const [, relativePath] of DEVICE_USE_PACKAGE_FILES) { @@ -344,6 +348,12 @@ function verifyPackagedDeviceUse(resourcesDir, options) { manifest.launch?.command === "device-use", `Packaged device-use manifest has unexpected launch command: ${manifest.launch?.command}` ); + assert( + Array.isArray(manifest.launch?.args) && + manifest.launch.args.includes("--host") && + manifest.launch.args.includes("127.0.0.1"), + "Packaged device-use manifest must bind AAP-launched Mobile Use to loopback" + ); console.log("[runtime-smoke] packaged device-use runtime payload verified"); } From d744949f566bffc825bd7f222da0cf9ffc8c8775 Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Wed, 20 May 2026 18:14:23 +0200 Subject: [PATCH 4/7] Address AAP prefetch review --- apps/backend/src/services/aap/apps.service.ts | 12 ++- apps/backend/test/integration/aap.test.ts | 89 ++++++++++++++++--- package.json | 2 +- scripts/runtime/rebuild-node-native.cjs | 59 ++++++++++++ 4 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 scripts/runtime/rebuild-node-native.cjs diff --git a/apps/backend/src/services/aap/apps.service.ts b/apps/backend/src/services/aap/apps.service.ts index d098b6c07..31263b9d6 100644 --- a/apps/backend/src/services/aap/apps.service.ts +++ b/apps/backend/src/services/aap/apps.service.ts @@ -167,10 +167,7 @@ async function runPrefetch(installed: InstalledAppEntry): Promise { if (!prefetch) return; const vars: TemplateVars = {}; - const rawCwd = prefetch.cwd ? substituteTemplate(prefetch.cwd, vars) : packageRoot; - const cwd = isAbsolute(rawCwd) ? rawCwd : resolvePath(packageRoot, rawCwd); const command = resolveCommand(prefetch.command, packageRoot); - const args = substituteArgs(prefetch.args, vars); if (!canSpawnResolvedCommand(command)) { console.log(`[AAP] Prefetch skipped: ${manifest.id}`, { command: prefetch.command, @@ -178,6 +175,10 @@ async function runPrefetch(installed: InstalledAppEntry): Promise { }); return; } + + const rawCwd = prefetch.cwd ? substituteTemplate(prefetch.cwd, vars) : packageRoot; + const cwd = isAbsolute(rawCwd) ? rawCwd : resolvePath(packageRoot, rawCwd); + const args = substituteArgs(prefetch.args, vars); const missingEntrypoint = findMissingPrefetchEntrypoint(args, cwd); if (missingEntrypoint) { console.log(`[AAP] Prefetch skipped: ${manifest.id}`, { @@ -246,12 +247,17 @@ function findMissingPrefetchEntrypoint(args: string[], cwd: string): string | nu const [firstArg] = args; if (!firstArg) return null; if (firstArg.startsWith("-")) return null; + if (isUriLikeArg(firstArg)) return null; if (!firstArg.includes("/") && !firstArg.includes("\\")) return null; const entrypoint = isAbsolute(firstArg) ? firstArg : resolvePath(cwd, firstArg); return existsSync(entrypoint) ? null : entrypoint; } +function isUriLikeArg(value: string): boolean { + return /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value) || value.startsWith("file:"); +} + function canSpawnResolvedCommand(command: string): boolean { if (isAbsolute(command) || command.includes("/") || command.includes("\\")) { return existsSync(command); diff --git a/apps/backend/test/integration/aap.test.ts b/apps/backend/test/integration/aap.test.ts index cc0b14889..4271c6cde 100644 --- a/apps/backend/test/integration/aap.test.ts +++ b/apps/backend/test/integration/aap.test.ts @@ -10,7 +10,7 @@ // it, then asserts on the Map's observable state via getRunningApps. import { spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -45,6 +45,8 @@ const fakeAppDir = mkdtempSync(join(tmpdir(), "aap-integration-")); const fakeAppServer = join(fakeAppDir, "server.js"); const fakePrefetchScript = join(fakeAppDir, "prefetch.js"); const fakePrefetchMarker = join(fakeAppDir, "prefetched.txt"); +const urlPrefetchScript = join(fakeAppDir, "url-prefetch.js"); +const urlPrefetchMarker = join(fakeAppDir, "url-prefetched.txt"); writeFileSync( fakeAppServer, ` @@ -70,6 +72,15 @@ fs.writeFileSync(process.argv[2], process.env.DEUS_APP_ID + ":" + process.env.DE `, "utf8" ); +writeFileSync( + urlPrefetchScript, + `#!/usr/bin/env bun +import { writeFileSync } from "node:fs"; +writeFileSync(process.argv[3], process.argv[2], "utf8"); +`, + "utf8" +); +chmodSync(urlPrefetchScript, 0o755); const fakeManifest = { $schema: "https://agenticapps.dev/schema/v1.json", @@ -100,6 +111,33 @@ writeFileSync(fakeManifestPath, JSON.stringify(fakeManifest, null, 2), "utf8"); const fakeManifestWithoutPrefetch = { ...fakeManifest }; delete (fakeManifestWithoutPrefetch as { prefetch?: unknown }).prefetch; +const missingPrefetchManifest = { + ...fakeManifestWithoutPrefetch, + id: "test.prefetch-missing-command", + prefetch: { + command: "this-prefetch-command-does-not-exist-xyz123", + args: ["{workspace}"], + cwd: "{workspace}", + }, +}; +const missingPrefetchManifestPath = join(fakeAppDir, "missing-prefetch-manifest.json"); +writeFileSync( + missingPrefetchManifestPath, + JSON.stringify(missingPrefetchManifest, null, 2), + "utf8" +); + +const urlPrefetchManifest = { + ...fakeManifestWithoutPrefetch, + id: "test.prefetch-url-operand", + prefetch: { + command: urlPrefetchScript, + args: ["https://example.com/mobile-use/prefetch.js", urlPrefetchMarker], + }, +}; +const urlPrefetchManifestPath = join(fakeAppDir, "url-prefetch-manifest.json"); +writeFileSync(urlPrefetchManifestPath, JSON.stringify(urlPrefetchManifest, null, 2), "utf8"); + // Second manifest for the ENOENT test — a command that doesn't exist on PATH. const bogusManifest = { ...fakeManifestWithoutPrefetch, @@ -126,7 +164,13 @@ const needsCliManifestPath = join(fakeAppDir, "needs-cli-manifest.json"); writeFileSync(needsCliManifestPath, JSON.stringify(needsCliManifest, null, 2), "utf8"); vi.mock("../../src/config/installed-apps", () => ({ - INSTALLED_APP_MANIFESTS: [fakeManifestPath, bogusManifestPath, needsCliManifestPath], + INSTALLED_APP_MANIFESTS: [ + fakeManifestPath, + missingPrefetchManifestPath, + urlPrefetchManifestPath, + bogusManifestPath, + needsCliManifestPath, + ], })); // Point the PID journal at a per-run tmp file so tests don't stomp on @@ -188,18 +232,43 @@ describe("aap/apps.service (integration, in-memory)", () => { "test.bogus-command", "test.fake-app", "test.needs-missing-cli", + "test.prefetch-missing-command", + "test.prefetch-url-operand", ]); }); - it("runs app prefetch commands in the background", async () => { + it("runs app prefetch commands in the background and skips unavailable optional commands", async () => { rmSync(fakePrefetchMarker, { force: true }); - prefetchInstalledAppAssets(); - await waitForCondition( - () => existsSync(fakePrefetchMarker), - (exists) => exists, - 10_000 - ); - expect(readFileSync(fakePrefetchMarker, "utf8")).toBe("test.fake-app:1"); + rmSync(urlPrefetchMarker, { force: true }); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + prefetchInstalledAppAssets(); + await waitForCondition( + () => existsSync(fakePrefetchMarker) && existsSync(urlPrefetchMarker), + (exists) => exists, + 10_000 + ); + expect(readFileSync(fakePrefetchMarker, "utf8")).toBe("test.fake-app:1"); + expect(readFileSync(urlPrefetchMarker, "utf8")).toBe( + "https://example.com/mobile-use/prefetch.js" + ); + + await waitForCondition( + () => + logSpy.mock.calls.find( + ([message]) => message === "[AAP] Prefetch skipped: test.prefetch-missing-command" + ), + (call) => Boolean(call), + 2_000 + ); + const skipped = logSpy.mock.calls.find( + ([message]) => message === "[AAP] Prefetch skipped: test.prefetch-missing-command" + ); + expect(skipped?.[1]).toMatchObject({ reason: "command unavailable" }); + } finally { + logSpy.mockRestore(); + } }); it("launches, becomes ready, and is reachable on /health", async () => { diff --git a/package.json b/package.json index 18865569d..ec8c0def1 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "package:linux": "bun run build:all && electron-builder --linux", "postinstall": "bun run prepare:device-use", "native:electron": "electron-builder install-app-deps", - "native:node": "cd node_modules/better-sqlite3 && PYTHON=${PYTHON:-/usr/bin/python3} node ../node-gyp/bin/node-gyp.js rebuild", + "native:node": "node scripts/runtime/rebuild-node-native.cjs", "prepare:device-use": "node scripts/prepare-device-use.mjs", "prepare:gh-cli": "node scripts/prepare-gh-cli.mjs", "prepare:agent-clis": "bun scripts/runtime/prepare-agent-clis.ts", diff --git a/scripts/runtime/rebuild-node-native.cjs b/scripts/runtime/rebuild-node-native.cjs new file mode 100644 index 000000000..01acdc77d --- /dev/null +++ b/scripts/runtime/rebuild-node-native.cjs @@ -0,0 +1,59 @@ +const path = require("node:path"); +const { execFileSync, spawnSync } = require("node:child_process"); + +const PROJECT_ROOT = path.resolve(__dirname, "../.."); +const SQLITE_ROOT = path.join(PROJECT_ROOT, "node_modules", "better-sqlite3"); +const NODE_GYP = path.join(PROJECT_ROOT, "node_modules", "node-gyp", "bin", "node-gyp.js"); + +function commandCandidates(name) { + const result = spawnSync("which", ["-a", name], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0) return []; + return result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); +} + +function unique(items) { + return [...new Set(items.filter(Boolean))]; +} + +function pythonCandidates() { + return unique([ + process.env.PYTHON, + "/usr/bin/python3", + ...commandCandidates("python3"), + ...commandCandidates("python"), + ]); +} + +function isUsablePython(candidate) { + try { + execFileSync(candidate, ["-c", "import plistlib; import xml.parsers.expat"], { + stdio: "ignore", + timeout: 5_000, + }); + return true; + } catch { + return false; + } +} + +const selectedPython = pythonCandidates().find(isUsablePython); +const env = { ...process.env }; +if (selectedPython) { + env.PYTHON = selectedPython; + console.log(`[native:node] using Python: ${selectedPython}`); +} else { + delete env.PYTHON; + console.log("[native:node] no validated Python found; falling back to node-gyp discovery"); +} + +execFileSync(process.execPath, [NODE_GYP, "rebuild"], { + cwd: SQLITE_ROOT, + env, + stdio: "inherit", +}); From e70ab8fb695ffa8fe882cf96c2662f9ba94e3414 Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Wed, 20 May 2026 18:37:30 +0200 Subject: [PATCH 5/7] Tighten AAP runtime packaging guards --- apps/backend/src/services/aap/apps.service.ts | 13 +- apps/backend/src/services/aap/lifecycle.ts | 13 +- apps/backend/test/integration/aap.test.ts | 25 +++- .../test/unit/services/aap/lifecycle.test.ts | 117 ++++++++++++------ .../runtime/electron-builder-before-pack.cjs | 66 +++++++++- scripts/runtime/package-mac-dir.cjs | 12 +- 6 files changed, 198 insertions(+), 48 deletions(-) diff --git a/apps/backend/src/services/aap/apps.service.ts b/apps/backend/src/services/aap/apps.service.ts index 31263b9d6..2649eefdb 100644 --- a/apps/backend/src/services/aap/apps.service.ts +++ b/apps/backend/src/services/aap/apps.service.ts @@ -248,12 +248,23 @@ function findMissingPrefetchEntrypoint(args: string[], cwd: string): string | nu if (!firstArg) return null; if (firstArg.startsWith("-")) return null; if (isUriLikeArg(firstArg)) return null; - if (!firstArg.includes("/") && !firstArg.includes("\\")) return null; + if (!isFilesystemEntrypointArg(firstArg)) return null; const entrypoint = isAbsolute(firstArg) ? firstArg : resolvePath(cwd, firstArg); return existsSync(entrypoint) ? null : entrypoint; } +function isFilesystemEntrypointArg(value: string): boolean { + return ( + isAbsolute(value) || + value.startsWith("./") || + value.startsWith("../") || + value.startsWith(".\\") || + value.startsWith("..\\") || + /^[a-zA-Z]:[\\/]/.test(value) + ); +} + function isUriLikeArg(value: string): boolean { return /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value) || value.startsWith("file:"); } diff --git a/apps/backend/src/services/aap/lifecycle.ts b/apps/backend/src/services/aap/lifecycle.ts index 157a1f5f1..9d1302c4a 100644 --- a/apps/backend/src/services/aap/lifecycle.ts +++ b/apps/backend/src/services/aap/lifecycle.ts @@ -324,8 +324,19 @@ export function resolveCommand(command: string, packageRoot: string): string { return command; } +function isDeviceUsePackageRoot(packageRoot: string): boolean { + try { + const pj = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { + name?: string; + }; + return pj.name === "device-use"; + } catch { + return false; + } +} + export function resolveLaunchCommand(command: string, packageRoot: string): ResolvedLaunchCommand { - if (command === "device-use") { + if (command === "device-use" && isDeviceUsePackageRoot(packageRoot)) { const runtimeExecutable = process.env.DEUS_RUNTIME_EXECUTABLE; const hasBundledRuntime = process.env.DEUS_PACKAGED === "1" || process.env.DEUS_RUNTIME === "1"; if (hasBundledRuntime && runtimeExecutable && existsSync(runtimeExecutable)) { diff --git a/apps/backend/test/integration/aap.test.ts b/apps/backend/test/integration/aap.test.ts index 4271c6cde..7237b1cd8 100644 --- a/apps/backend/test/integration/aap.test.ts +++ b/apps/backend/test/integration/aap.test.ts @@ -47,6 +47,7 @@ const fakePrefetchScript = join(fakeAppDir, "prefetch.js"); const fakePrefetchMarker = join(fakeAppDir, "prefetched.txt"); const urlPrefetchScript = join(fakeAppDir, "url-prefetch.js"); const urlPrefetchMarker = join(fakeAppDir, "url-prefetched.txt"); +const scopedPrefetchMarker = join(fakeAppDir, "scoped-prefetched.txt"); writeFileSync( fakeAppServer, ` @@ -138,6 +139,21 @@ const urlPrefetchManifest = { const urlPrefetchManifestPath = join(fakeAppDir, "url-prefetch-manifest.json"); writeFileSync(urlPrefetchManifestPath, JSON.stringify(urlPrefetchManifest, null, 2), "utf8"); +const scopedPackagePrefetchManifest = { + ...fakeManifestWithoutPrefetch, + id: "test.prefetch-scoped-package", + prefetch: { + command: urlPrefetchScript, + args: ["@scope/tool", scopedPrefetchMarker], + }, +}; +const scopedPackagePrefetchManifestPath = join(fakeAppDir, "scoped-package-prefetch-manifest.json"); +writeFileSync( + scopedPackagePrefetchManifestPath, + JSON.stringify(scopedPackagePrefetchManifest, null, 2), + "utf8" +); + // Second manifest for the ENOENT test — a command that doesn't exist on PATH. const bogusManifest = { ...fakeManifestWithoutPrefetch, @@ -167,6 +183,7 @@ vi.mock("../../src/config/installed-apps", () => ({ INSTALLED_APP_MANIFESTS: [ fakeManifestPath, missingPrefetchManifestPath, + scopedPackagePrefetchManifestPath, urlPrefetchManifestPath, bogusManifestPath, needsCliManifestPath, @@ -233,23 +250,29 @@ describe("aap/apps.service (integration, in-memory)", () => { "test.fake-app", "test.needs-missing-cli", "test.prefetch-missing-command", + "test.prefetch-scoped-package", "test.prefetch-url-operand", ]); }); it("runs app prefetch commands in the background and skips unavailable optional commands", async () => { rmSync(fakePrefetchMarker, { force: true }); + rmSync(scopedPrefetchMarker, { force: true }); rmSync(urlPrefetchMarker, { force: true }); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { prefetchInstalledAppAssets(); await waitForCondition( - () => existsSync(fakePrefetchMarker) && existsSync(urlPrefetchMarker), + () => + existsSync(fakePrefetchMarker) && + existsSync(scopedPrefetchMarker) && + existsSync(urlPrefetchMarker), (exists) => exists, 10_000 ); expect(readFileSync(fakePrefetchMarker, "utf8")).toBe("test.fake-app:1"); + expect(readFileSync(scopedPrefetchMarker, "utf8")).toBe("@scope/tool"); expect(readFileSync(urlPrefetchMarker, "utf8")).toBe( "https://example.com/mobile-use/prefetch.js" ); diff --git a/apps/backend/test/unit/services/aap/lifecycle.test.ts b/apps/backend/test/unit/services/aap/lifecycle.test.ts index ce7051f68..29b2e9bfa 100644 --- a/apps/backend/test/unit/services/aap/lifecycle.test.ts +++ b/apps/backend/test/unit/services/aap/lifecycle.test.ts @@ -1,4 +1,7 @@ import { createServer } from "node:http"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { @@ -67,53 +70,91 @@ function withEnv(overrides: Record, test: () => void } } +function withPackageJson( + name: string, + bin: Record, + test: (packageRoot: string) => void +): void { + const packageRoot = mkdtempSync(join(tmpdir(), "aap-lifecycle-package-")); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ name, bin }), "utf8"); + try { + test(packageRoot); + } finally { + rmSync(packageRoot, { recursive: true, force: true }); + } +} + describe("aap/lifecycle", () => { describe("resolveLaunchCommand", () => { it("routes packaged device-use launches through the bundled Deus runtime", () => { - withEnv( - { - DEUS_PACKAGED: "1", - DEUS_RUNTIME: undefined, - DEUS_RUNTIME_EXECUTABLE: process.execPath, - }, - () => { - expect(resolveLaunchCommand("device-use", process.cwd())).toEqual({ - command: process.execPath, - argsPrefix: ["device-use"], - }); - } - ); + withPackageJson("device-use", { "device-use": "./dist/cli.js" }, (packageRoot) => { + withEnv( + { + DEUS_PACKAGED: "1", + DEUS_RUNTIME: undefined, + DEUS_RUNTIME_EXECUTABLE: process.execPath, + }, + () => { + expect(resolveLaunchCommand("device-use", packageRoot)).toEqual({ + command: process.execPath, + argsPrefix: ["device-use"], + }); + } + ); + }); }); it("routes standalone runtime device-use launches through the bundled Deus runtime", () => { - withEnv( - { - DEUS_PACKAGED: undefined, - DEUS_RUNTIME: "1", - DEUS_RUNTIME_EXECUTABLE: process.execPath, - }, - () => { - expect(resolveLaunchCommand("device-use", process.cwd())).toEqual({ - command: process.execPath, - argsPrefix: ["device-use"], - }); - } - ); + withPackageJson("device-use", { "device-use": "./dist/cli.js" }, (packageRoot) => { + withEnv( + { + DEUS_PACKAGED: undefined, + DEUS_RUNTIME: "1", + DEUS_RUNTIME_EXECUTABLE: process.execPath, + }, + () => { + expect(resolveLaunchCommand("device-use", packageRoot)).toEqual({ + command: process.execPath, + argsPrefix: ["device-use"], + }); + } + ); + }); }); it("keeps source device-use launches on the package bin path", () => { - withEnv( - { - DEUS_PACKAGED: undefined, - DEUS_RUNTIME: undefined, - DEUS_RUNTIME_EXECUTABLE: undefined, - }, - () => { - const resolved = resolveLaunchCommand("device-use", process.cwd()); - expect(resolved.argsPrefix).toEqual([]); - expect(resolved.command.endsWith("device-use")).toBe(true); - } - ); + withPackageJson("device-use", { "device-use": "./dist/cli.js" }, (packageRoot) => { + withEnv( + { + DEUS_PACKAGED: undefined, + DEUS_RUNTIME: undefined, + DEUS_RUNTIME_EXECUTABLE: undefined, + }, + () => { + const resolved = resolveLaunchCommand("device-use", packageRoot); + expect(resolved.argsPrefix).toEqual([]); + expect(resolved.command).toBe(join(packageRoot, "dist/cli.js")); + } + ); + }); + }); + + it("keeps unrelated package-local device-use commands on the package bin path", () => { + withPackageJson("other-aap-app", { "device-use": "./local-device-use.js" }, (packageRoot) => { + withEnv( + { + DEUS_PACKAGED: "1", + DEUS_RUNTIME: undefined, + DEUS_RUNTIME_EXECUTABLE: process.execPath, + }, + () => { + expect(resolveLaunchCommand("device-use", packageRoot)).toEqual({ + command: join(packageRoot, "local-device-use.js"), + argsPrefix: [], + }); + } + ); + }); }); }); diff --git a/scripts/runtime/electron-builder-before-pack.cjs b/scripts/runtime/electron-builder-before-pack.cjs index fc9dab6e1..f109f7e12 100644 --- a/scripts/runtime/electron-builder-before-pack.cjs +++ b/scripts/runtime/electron-builder-before-pack.cjs @@ -24,11 +24,18 @@ const SOURCE_EXTENSIONS = new Set([ ".cjs", ".css", ".html", + ".h", ".js", ".json", ".jsx", + ".m", ".mjs", + ".mm", + ".plist", + ".resolved", + ".sh", ".svg", + ".swift", ".ts", ".tsx", ]); @@ -66,11 +73,13 @@ function latestSourceMtime(projectRoot, sourceRelatives) { return latest; } -function assertBuildOutputFresh(projectRoot, label, outputRelative, sourceRelatives) { +function assertBuildOutputFresh(projectRoot, label, outputRelative, sourceRelatives, options = {}) { + const kind = options.kind ?? "Electron"; + const rebuildCommand = options.rebuildCommand ?? "bun run build"; const outputPath = path.join(projectRoot, outputRelative); if (!existsSync(outputPath)) { throw new Error( - `Missing Electron ${label} build output: ${outputRelative}. Run \`bun run build\`.` + `Missing ${kind} ${label} build output: ${outputRelative}. Run \`${rebuildCommand}\`.` ); } @@ -78,10 +87,10 @@ function assertBuildOutputFresh(projectRoot, label, outputRelative, sourceRelati const latestSource = latestSourceMtime(projectRoot, sourceRelatives); if (latestSource.path && outputStat.mtimeMs < latestSource.mtimeMs) { throw new Error( - `Stale Electron ${label} build output: ${outputRelative} is older than ${relativeFromProjectRoot( + `Stale ${kind} ${label} build output: ${outputRelative} is older than ${relativeFromProjectRoot( projectRoot, latestSource.path - )}. Run \`bun run build\` before packaging.` + )}. Run \`${rebuildCommand}\` before packaging.` ); } } @@ -270,6 +279,55 @@ function assertDeviceUsePayloads(projectRoot) { assertUniversalMacHelper(simbridge, "device-use simbridge"); assertUniversalMacHelper(siminspector, "device-use siminspector"); assertNoBuildLocalInstallName(siminspector, projectRoot, "device-use siminspector"); + + const tsSources = [ + "packages/device-use/src/cli", + "packages/device-use/src/engine", + "packages/device-use/src/server", + "packages/device-use/package.json", + "packages/device-use/scripts/build-ts.ts", + ]; + const frontendSources = [ + "packages/device-use/src/frontend", + "packages/device-use/vite.config.ts", + "packages/device-use/package.json", + ]; + const nativeSources = [ + "packages/device-use/native/Sources", + "packages/device-use/native/Package.swift", + "packages/device-use/native/Package.resolved", + "packages/device-use/scripts/build-native.ts", + ]; + const freshnessOptions = { + kind: "device-use", + rebuildCommand: "bun run prepare:device-use --force", + }; + + for (const [, relativePath] of DEVICE_USE_PACKAGE_FILES) { + if (!relativePath.startsWith("dist/")) continue; + const sources = relativePath.startsWith("dist/frontend/") ? frontendSources : tsSources; + assertBuildOutputFresh( + projectRoot, + relativePath, + path.join("packages/device-use", relativePath), + sources, + freshnessOptions + ); + } + assertBuildOutputFresh( + projectRoot, + "bin/simbridge", + "packages/device-use/bin/simbridge", + nativeSources, + freshnessOptions + ); + assertBuildOutputFresh( + projectRoot, + "bin/siminspector.dylib", + "packages/device-use/bin/siminspector.dylib", + nativeSources, + freshnessOptions + ); } module.exports = function beforePack(context) { diff --git a/scripts/runtime/package-mac-dir.cjs b/scripts/runtime/package-mac-dir.cjs index 1fa304d0c..34f160044 100644 --- a/scripts/runtime/package-mac-dir.cjs +++ b/scripts/runtime/package-mac-dir.cjs @@ -11,9 +11,7 @@ const SUPPORTED_ARCHES = new Set(["arm64", "x64"]); function parseArgs(argv) { const options = { arch: - process.platform === "darwin" && SUPPORTED_ARCHES.has(process.arch) - ? process.arch - : "arm64", + process.platform === "darwin" && SUPPORTED_ARCHES.has(process.arch) ? process.arch : "arm64", }; for (let index = 0; index < argv.length; index++) { @@ -130,10 +128,18 @@ function installIconResolver() { }; } +function prepareDeviceUsePayloads() { + execFileSync("bun", ["run", "prepare:device-use", "--force"], { + cwd: PROJECT_ROOT, + stdio: "inherit", + }); +} + async function main() { assertHostPlatform(); const options = parseArgs(process.argv.slice(2)); assertElectronDistArch(options.arch); + prepareDeviceUsePayloads(); installIconResolver(); const { Arch, Platform, build } = require("electron-builder"); From 463ed715bb2e60ef88e9200d64a159c4fdc59e09 Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Wed, 20 May 2026 20:17:26 +0200 Subject: [PATCH 6/7] chore: organize runtime smoke scripts --- .../desktop-main-runtime.cjs} | 0 scripts/runtime/{ => smoke}/lib/smoke-helpers.cjs | 0 .../{smoke-native-runtime.cjs => smoke/native-runtime.cjs} | 0 .../runtime/{smoke-packaged-app.cjs => smoke/packaged-app.cjs} | 0 .../{smoke-packaged-desktop.cjs => smoke/packaged-desktop.cjs} | 0 .../runtime/{smoke-packaged-dmgs.cjs => smoke/packaged-dmgs.cjs} | 0 .../packaged-resources.cjs} | 0 .../{smoke-packaged-runtime.cjs => smoke/packaged-runtime.cjs} | 0 scripts/runtime/{ => smoke}/run-version-check.cjs | 0 scripts/runtime/{runtime-smoke-rpc.cjs => smoke/runtime-rpc.cjs} | 0 .../{smoke-source-runtime.cjs => smoke/source-runtime.cjs} | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename scripts/runtime/{smoke-desktop-main-runtime.cjs => smoke/desktop-main-runtime.cjs} (100%) rename scripts/runtime/{ => smoke}/lib/smoke-helpers.cjs (100%) rename scripts/runtime/{smoke-native-runtime.cjs => smoke/native-runtime.cjs} (100%) rename scripts/runtime/{smoke-packaged-app.cjs => smoke/packaged-app.cjs} (100%) rename scripts/runtime/{smoke-packaged-desktop.cjs => smoke/packaged-desktop.cjs} (100%) rename scripts/runtime/{smoke-packaged-dmgs.cjs => smoke/packaged-dmgs.cjs} (100%) rename scripts/runtime/{smoke-packaged-resources.cjs => smoke/packaged-resources.cjs} (100%) rename scripts/runtime/{smoke-packaged-runtime.cjs => smoke/packaged-runtime.cjs} (100%) rename scripts/runtime/{ => smoke}/run-version-check.cjs (100%) rename scripts/runtime/{runtime-smoke-rpc.cjs => smoke/runtime-rpc.cjs} (100%) rename scripts/runtime/{smoke-source-runtime.cjs => smoke/source-runtime.cjs} (100%) diff --git a/scripts/runtime/smoke-desktop-main-runtime.cjs b/scripts/runtime/smoke/desktop-main-runtime.cjs similarity index 100% rename from scripts/runtime/smoke-desktop-main-runtime.cjs rename to scripts/runtime/smoke/desktop-main-runtime.cjs diff --git a/scripts/runtime/lib/smoke-helpers.cjs b/scripts/runtime/smoke/lib/smoke-helpers.cjs similarity index 100% rename from scripts/runtime/lib/smoke-helpers.cjs rename to scripts/runtime/smoke/lib/smoke-helpers.cjs diff --git a/scripts/runtime/smoke-native-runtime.cjs b/scripts/runtime/smoke/native-runtime.cjs similarity index 100% rename from scripts/runtime/smoke-native-runtime.cjs rename to scripts/runtime/smoke/native-runtime.cjs diff --git a/scripts/runtime/smoke-packaged-app.cjs b/scripts/runtime/smoke/packaged-app.cjs similarity index 100% rename from scripts/runtime/smoke-packaged-app.cjs rename to scripts/runtime/smoke/packaged-app.cjs diff --git a/scripts/runtime/smoke-packaged-desktop.cjs b/scripts/runtime/smoke/packaged-desktop.cjs similarity index 100% rename from scripts/runtime/smoke-packaged-desktop.cjs rename to scripts/runtime/smoke/packaged-desktop.cjs diff --git a/scripts/runtime/smoke-packaged-dmgs.cjs b/scripts/runtime/smoke/packaged-dmgs.cjs similarity index 100% rename from scripts/runtime/smoke-packaged-dmgs.cjs rename to scripts/runtime/smoke/packaged-dmgs.cjs diff --git a/scripts/runtime/smoke-packaged-resources.cjs b/scripts/runtime/smoke/packaged-resources.cjs similarity index 100% rename from scripts/runtime/smoke-packaged-resources.cjs rename to scripts/runtime/smoke/packaged-resources.cjs diff --git a/scripts/runtime/smoke-packaged-runtime.cjs b/scripts/runtime/smoke/packaged-runtime.cjs similarity index 100% rename from scripts/runtime/smoke-packaged-runtime.cjs rename to scripts/runtime/smoke/packaged-runtime.cjs diff --git a/scripts/runtime/run-version-check.cjs b/scripts/runtime/smoke/run-version-check.cjs similarity index 100% rename from scripts/runtime/run-version-check.cjs rename to scripts/runtime/smoke/run-version-check.cjs diff --git a/scripts/runtime/runtime-smoke-rpc.cjs b/scripts/runtime/smoke/runtime-rpc.cjs similarity index 100% rename from scripts/runtime/runtime-smoke-rpc.cjs rename to scripts/runtime/smoke/runtime-rpc.cjs diff --git a/scripts/runtime/smoke-source-runtime.cjs b/scripts/runtime/smoke/source-runtime.cjs similarity index 100% rename from scripts/runtime/smoke-source-runtime.cjs rename to scripts/runtime/smoke/source-runtime.cjs From 339ec844be82cdb43e0be70e7d0ffad688eb54f2 Mon Sep 17 00:00:00 2001 From: Adam Zvada Date: Wed, 20 May 2026 20:17:44 +0200 Subject: [PATCH 7/7] chore: document runtime package flow --- .github/workflows/release.yml | 8 +-- .github/workflows/test.yml | 6 +-- docs/deus-runtime-completion-audit.md | 44 ++++++++-------- docs/deus-runtime-verification.md | 10 ++-- docs/runtime-agent-cli-ownership.md | 52 +++++++++++++------ package.json | 15 +++--- scripts/runtime/native-runtime.ts | 16 +++--- .../runtime/smoke/desktop-main-runtime.cjs | 4 +- scripts/runtime/smoke/lib/smoke-helpers.cjs | 2 +- scripts/runtime/smoke/native-runtime.cjs | 4 +- scripts/runtime/smoke/packaged-app.cjs | 8 +-- scripts/runtime/smoke/packaged-desktop.cjs | 8 +-- scripts/runtime/smoke/packaged-dmgs.cjs | 8 +-- scripts/runtime/smoke/packaged-resources.cjs | 2 +- scripts/runtime/smoke/packaged-runtime.cjs | 8 +-- scripts/runtime/smoke/source-runtime.cjs | 2 +- .../runtime/smoke-packaged-runtime.test.ts | 2 +- 17 files changed, 110 insertions(+), 89 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efd301a9b..6f3382f51 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -232,7 +232,7 @@ jobs: while IFS= read -r app_path; do echo "Verifying signature for $app_path" codesign --verify --deep --strict --verbose=2 "$app_path" - node scripts/runtime/smoke-packaged-app.cjs --app "$app_path" --require-gatekeeper + bun run smoke:packaged-app -- --app "$app_path" --require-gatekeeper done < <(find dist-electron -maxdepth 2 -path '*/Deus.app' -type d | sort) while IFS= read -r dmg_path; do @@ -240,7 +240,7 @@ jobs: xcrun stapler validate "$dmg_path" done < <(find dist-electron -maxdepth 1 -name '*.dmg' -type f | sort) - node scripts/runtime/smoke-packaged-dmgs.cjs \ + bun run smoke:packaged-dmgs -- \ --require-gatekeeper \ $(find dist-electron -maxdepth 1 -name '*.dmg' -type f | sort) @@ -293,11 +293,11 @@ jobs: test -f "$resources_dir/simulator/siminspector.dylib" test -x "$app_bin" - node scripts/runtime/smoke-packaged-runtime.cjs \ + bun run smoke:packaged-runtime -- \ --app "$copied_app" \ --require-gatekeeper - node scripts/runtime/smoke-packaged-desktop.cjs \ + bun run smoke:packaged-desktop -- \ --app "$copied_app" \ --home "$copied_home" \ --launch-in-place \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c53a3f9e7..44599137e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -122,7 +122,7 @@ jobs: - name: Smoke packaged macOS app bundle run: > - node scripts/runtime/smoke-packaged-app.cjs + bun run smoke:packaged-app -- --app "$MAC_APP_PATH" --arch "$MAC_EXPECTED_ARCH" --skip-app-signature @@ -130,13 +130,13 @@ jobs: - name: Smoke packaged runtime executable run: > - node scripts/runtime/smoke-packaged-runtime.cjs + bun run smoke:packaged-runtime -- --app "$MAC_APP_PATH" --skip-app-check - name: Smoke packaged Electron desktop run: > - node scripts/runtime/smoke-packaged-desktop.cjs + bun run smoke:packaged-desktop -- --app "$MAC_APP_PATH" --skip-app-check diff --git a/docs/deus-runtime-completion-audit.md b/docs/deus-runtime-completion-audit.md index a410dc727..f8f21b1fa 100644 --- a/docs/deus-runtime-completion-audit.md +++ b/docs/deus-runtime-completion-audit.md @@ -19,22 +19,22 @@ the notarized DMG/Gatekeeper checks before shipping public artifacts. ## Objective Mapping -| Requirement | Current artifact/evidence | Status | -| --- | --- | --- | -| Packaged macOS app starts backend through `Resources/bin/deus-runtime` | `apps/desktop/main/backend-process.ts` resolves packaged runtime to `process.resourcesPath/bin/deus-runtime` and spawns it with `["backend"]`. CI `smoke-packaged-desktop` reached backend readiness from the packaged app. | Verified | -| Backend starts agent-server through the same runtime | `apps/backend/src/runtime/agent-process.ts` uses `DEUS_RUNTIME_EXECUTABLE` with `["agent-server"]`; packaged backend refuses the old Electron-as-Node fallback when the runtime executable is absent. CI packaged desktop logs include backend-relayed `LISTEN_URL`. | Verified | -| `deus-runtime` is a real Bun-compiled native executable | `apps/runtime/index.ts` implements dispatch; `scripts/runtime/native-runtime.ts` builds Darwin arm64/x64 with `bun build --compile`; CI validated Mach-O architecture, signatures, entitlements, page size, and dylibs. | Verified | -| `deus-runtime --version` works | CI native smoke printed `deus-runtime 0.3.6 darwin-arm64`; packaged runtime smoke printed the same from `Deus.app/Contents/Resources/bin/deus-runtime`. | Verified | -| `deus-runtime agent-server` reaches `LISTEN_URL` | CI native and packaged runtime smokes waited for `LISTEN_URL`, then asserted initialized agents over JSON-RPC. | Verified | -| `deus-runtime backend` reaches `[BACKEND_PORT]` and owns agent-server startup | CI native and packaged runtime smokes waited for `[BACKEND_PORT]`, asserted agent-server readiness, and hit the backend DB route. | Verified | -| Bundle `deus-runtime`, `codex`, `claude`, `gh`, and `rg` into `Resources/bin` | `electron-builder.yml` lists the binaries under macOS `extraResources`; CI package smoke verified all five in `Deus.app/Contents/Resources/bin` and ran binary version checks. | Verified | -| Use bundled native agent CLIs by default | `shared/lib/cli-path.ts` and `apps/agent-server/agents/environment/cli-discovery.ts` resolve packaged defaults from the bundled bin directory. CI logs include `BUNDLED_CLI_PATH` for packaged `claude` and `codex`. | Verified | -| Preserve explicit developer/user overrides | `cli-discovery.ts` still checks configured override paths before bundled candidates and verifies custom overrides with version flags; unit and runtime CI passed. | Verified | -| Remove packaged global/shell CLI discovery fallback | `cli-discovery.ts` no longer accepts bare packaged commands; packaged env setup uses `Resources/bin` plus system paths only; CI greps found no `global CLI`, `spawn codex ENOENT`, or `spawn claude ENOENT` fallback logs. | Verified | -| Remove obsolete packaged Electron-as-Node backend path plumbing | `backend-process.ts` uses `process.execPath` only for dev. CI bundle guards reject stale `resources/backend`, `AGENT_SERVER_ENTRY`, and `ELECTRON_RUN_AS_NODE` packaged paths. | Verified | -| Preserve dev and web mode | Dev path remains Electron-as-Node/source-entry based, while packaged mode switches to `deus-runtime`; web/dev scripts remain unchanged. Typecheck and backend/agent-server tests passed in CI. | Verified | -| Keep Linux/Windows packaged behavior explicit | `package:linux` and `package:win` route to `scripts/runtime/unsupported-packaged-platform.cjs` and fail with explicit unsupported-platform messages. | Verified | -| CUA or packaged Electron smoke | No separate CUA harness exists in this repo. The available packaged desktop smoke launches packaged Electron, waits for runtime readiness, verifies bundled CLI paths, asserts initialized agents, hits the backend DB route, and rejects fallback log patterns. | Verified by automated packaged desktop smoke | +| Requirement | Current artifact/evidence | Status | +| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| Packaged macOS app starts backend through `Resources/bin/deus-runtime` | `apps/desktop/main/backend-process.ts` resolves packaged runtime to `process.resourcesPath/bin/deus-runtime` and spawns it with `["backend"]`. CI `smoke-packaged-desktop` reached backend readiness from the packaged app. | Verified | +| Backend starts agent-server through the same runtime | `apps/backend/src/runtime/agent-process.ts` uses `DEUS_RUNTIME_EXECUTABLE` with `["agent-server"]`; packaged backend refuses the old Electron-as-Node fallback when the runtime executable is absent. CI packaged desktop logs include backend-relayed `LISTEN_URL`. | Verified | +| `deus-runtime` is a real Bun-compiled native executable | `apps/runtime/index.ts` implements dispatch; `scripts/runtime/native-runtime.ts` builds Darwin arm64/x64 with `bun build --compile`; CI validated Mach-O architecture, signatures, entitlements, page size, and dylibs. | Verified | +| `deus-runtime --version` works | CI native smoke printed `deus-runtime 0.3.6 darwin-arm64`; packaged runtime smoke printed the same from `Deus.app/Contents/Resources/bin/deus-runtime`. | Verified | +| `deus-runtime agent-server` reaches `LISTEN_URL` | CI native and packaged runtime smokes waited for `LISTEN_URL`, then asserted initialized agents over JSON-RPC. | Verified | +| `deus-runtime backend` reaches `[BACKEND_PORT]` and owns agent-server startup | CI native and packaged runtime smokes waited for `[BACKEND_PORT]`, asserted agent-server readiness, and hit the backend DB route. | Verified | +| Bundle `deus-runtime`, `codex`, `claude`, `gh`, and `rg` into `Resources/bin` | `electron-builder.yml` lists the binaries under macOS `extraResources`; CI package smoke verified all five in `Deus.app/Contents/Resources/bin` and ran binary version checks. | Verified | +| Use bundled native agent CLIs by default | `shared/lib/cli-path.ts` and `apps/agent-server/agents/environment/cli-discovery.ts` resolve packaged defaults from the bundled bin directory. CI logs include `BUNDLED_CLI_PATH` for packaged `claude` and `codex`. | Verified | +| Preserve explicit developer/user overrides | `cli-discovery.ts` still checks configured override paths before bundled candidates and verifies custom overrides with version flags; unit and runtime CI passed. | Verified | +| Remove packaged global/shell CLI discovery fallback | `cli-discovery.ts` no longer accepts bare packaged commands; packaged env setup uses `Resources/bin` plus system paths only; CI greps found no `global CLI`, `spawn codex ENOENT`, or `spawn claude ENOENT` fallback logs. | Verified | +| Remove obsolete packaged Electron-as-Node backend path plumbing | `backend-process.ts` uses `process.execPath` only for dev. CI bundle guards reject stale `resources/backend`, `AGENT_SERVER_ENTRY`, and `ELECTRON_RUN_AS_NODE` packaged paths. | Verified | +| Preserve dev and web mode | Dev path remains Electron-as-Node/source-entry based, while packaged mode switches to `deus-runtime`; web/dev scripts remain unchanged. Typecheck and backend/agent-server tests passed in CI. | Verified | +| Keep Linux/Windows packaged behavior explicit | `package:linux` and `package:win` route to `scripts/runtime/unsupported-packaged-platform.cjs` and fail with explicit unsupported-platform messages. | Verified | +| CUA or packaged Electron smoke | No separate CUA harness exists in this repo. The available packaged desktop smoke launches packaged Electron, waits for runtime readiness, verifies bundled CLI paths, asserts initialized agents, hits the backend DB route, and rejects fallback log patterns. | Verified by automated packaged desktop smoke | ## CI Evidence @@ -47,9 +47,9 @@ Latest successful macOS runtime CI job included these successful steps: - `bun run smoke:runtime-native -- --skip-validate` - `bun run smoke:runtime-resources` - `bun run package:mac:dir -- --arch "$MAC_BUILDER_ARCH"` -- `node scripts/runtime/smoke-packaged-app.cjs` -- `node scripts/runtime/smoke-packaged-runtime.cjs` -- `node scripts/runtime/smoke-packaged-desktop.cjs` +- `bun run smoke:packaged-app` +- `bun run smoke:packaged-runtime` +- `bun run smoke:packaged-desktop` Important log evidence from run `25871120854`: @@ -109,9 +109,9 @@ Before public distribution, run the release/notarization path and require: ```bash bun run package:mac -node scripts/runtime/smoke-packaged-app.cjs --app --require-gatekeeper -node scripts/runtime/smoke-packaged-runtime.cjs --app --require-gatekeeper -node scripts/runtime/smoke-packaged-desktop.cjs --app --require-gatekeeper +bun run smoke:packaged-app -- --app --require-gatekeeper +bun run smoke:packaged-runtime -- --app --require-gatekeeper +bun run smoke:packaged-desktop -- --app --require-gatekeeper ``` Those release checks should prove the notarized artifact passes Gatekeeper and diff --git a/docs/deus-runtime-verification.md b/docs/deus-runtime-verification.md index f9bb32926..35da54ce1 100644 --- a/docs/deus-runtime-verification.md +++ b/docs/deus-runtime-verification.md @@ -19,7 +19,7 @@ bun run typecheck:agent-server bun run smoke:runtime-resources bun run smoke:desktop-main-runtime bun run package:mac:dir -- --arch -node scripts/runtime/smoke-packaged-app.cjs --app dist-electron/mac-arm64/Deus.app +bun run smoke:packaged-app -- --app dist-electron/mac-arm64/Deus.app ``` For unsigned pull-request package-dir builds, use the same packaged-app smoke @@ -93,7 +93,7 @@ When `bun run build` is blocked on this host, `out/main` and any existing `dist- The release workflow runs staged, packaged, and notarized checks on macOS: - Before packaging, `bun run smoke:runtime-native` directly verifies the staged host-arch `deus-runtime`. -- After packaging, every produced `.app` is inspected with `node scripts/runtime/smoke-packaged-app.cjs --app "$app_path" --require-gatekeeper`. -- After DMG notarization, every produced DMG is mounted and inspected with `node scripts/runtime/smoke-packaged-dmgs.cjs --require-gatekeeper `. -- After DMG/ZIP notarization, the release workflow copies the host-arch app out of the DMG and runs `node scripts/runtime/smoke-packaged-runtime.cjs --app "$copied_app" --require-gatekeeper`. -- The same copied app is then launched through `node scripts/runtime/smoke-packaged-desktop.cjs --app "$copied_app" --require-gatekeeper`. +- After packaging, every produced `.app` is inspected with `bun run smoke:packaged-app -- --app "$app_path" --require-gatekeeper`. +- After DMG notarization, every produced DMG is mounted and inspected with `bun run smoke:packaged-dmgs -- --require-gatekeeper `. +- After DMG/ZIP notarization, the release workflow copies the host-arch app out of the DMG and runs `bun run smoke:packaged-runtime -- --app "$copied_app" --require-gatekeeper`. +- The same copied app is then launched through `bun run smoke:packaged-desktop -- --app "$copied_app" --require-gatekeeper`. diff --git a/docs/runtime-agent-cli-ownership.md b/docs/runtime-agent-cli-ownership.md index 3c0777249..e2b4a271b 100644 --- a/docs/runtime-agent-cli-ownership.md +++ b/docs/runtime-agent-cli-ownership.md @@ -55,14 +55,9 @@ agent CLI discovery. It is based on the source paths below, not on generated - `scripts/runtime/validate.ts` is the static staging gate before packaging. It validates runtime manifests, native runtime binaries, staged agent CLIs, staged GitHub CLI payloads, and freshness of staged bundles. -- `scripts/runtime/smoke-source-runtime.cjs`, - `scripts/runtime/smoke-native-runtime.cjs`, - `scripts/runtime/smoke-packaged-runtime.cjs`, - `scripts/runtime/smoke-packaged-app.cjs`, - `scripts/runtime/smoke-packaged-desktop.cjs`, and helpers under - `scripts/runtime/lib/` are verification harnesses. They should share - assertion constants when that removes duplication, but they are not production - ownership layers. +- `scripts/runtime/smoke/` contains runtime smoke harnesses and smoke-only + helpers. These scripts verify the production contract from the outside; they + are not production ownership layers. - Packaged runtime smoke must not load host native addons to prepare backend state. It creates temporary repos/workspaces through the packaged backend HTTP API so SQLite stays inside the runtime under test and does not depend on @@ -77,19 +72,45 @@ agent CLI discovery. It is based on the source paths below, not on generated - Keep shared runtime contracts in `shared/runtime.ts` or `shared/lib/cli-path.ts` when they are consumed by production code across desktop, backend, runtime, and agent-server. -- Keep smoke-only helpers in `scripts/runtime/lib/`. Do not move them into +- Keep smoke-only helpers in `scripts/runtime/smoke/lib/`. Do not move them into production modules just to reduce lines. - Keep script-local assertion copies, such as the smoke harness packaged PATH and env denylist, when they intentionally verify the production contract from the outside. Production runtime constants that are consumed by multiple app layers belong in `shared/runtime.ts`; Node-only smoke harnesses stay plain CJS so release checks do not need a TypeScript loader or a prior app build. -- Keep public package script names stable. If a script moves, leave a wrapper at - the old command path. +- Keep public package script names stable. Internal script file paths are not an + API; CI and docs should call `bun run smoke:*` where practical. - Do not reason from `dist/runtime` or `dist-electron` layout alone. Those are generated artifacts; source ownership lives in `shared/`, `apps/`, and `scripts/runtime/`. +## Script Map + +| Path | Class | Responsibility | +| --------------------------------------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts/runtime/build.ts` | build/stage | Canonical runtime payload entrypoint for release and packaging. | +| `scripts/runtime/stage.ts` | stage | Common backend and agent-server runtime bundle staging. | +| `scripts/runtime/native-runtime.ts` | build/validate | Native `deus-runtime` binary build, manifest, architecture, signature, and optional runnable validation. | +| `scripts/runtime/agent-clis.ts` | stage/validate | Full bundled Claude/Codex/agent-browser CLI matrix, hashes, and manifest validation. | +| `scripts/runtime/prepare-agent-clis.ts` | stage wrapper | CLI wrapper for the production agent CLI matrix. | +| `scripts/runtime/prepare-dev-agent-clis.ts` | dev-only stage | Host-runtime-key agent CLI preflight for local dev; does not write the full packaging manifest. | +| `scripts/runtime/validate.ts` | validate | Static package-boundary gate for staged runtime, agent CLIs, native runtime, GitHub CLI, and freshness. | +| `scripts/runtime/electron-builder-before-pack.cjs` | package/validate | Electron builder boundary guard for runtime staging, Electron output freshness, packaged main contract, and device-use payload readiness. | +| `scripts/runtime/package-mac-dir.cjs` | package/smoke support | Local macOS `.app` directory package command for smoke verification without producing release DMGs. | +| `scripts/runtime/dev.ts` | dev-only | Development backend/frontend startup orchestration. | +| `scripts/runtime/rebuild-node-native.cjs` | dev/test support | Rebuilds Node ABI native modules before backend/dev tests. | +| `scripts/runtime/unsupported-packaged-platform.cjs` | package guard | Explicit unsupported-platform package command failure. | +| `scripts/runtime/gh-cli-contract.json` | package contract | Expected GitHub CLI version, archive hashes, and target matrix. | +| `scripts/runtime/lib/device-use-payloads.cjs` | package/smoke helper | Shared device-use payload and simulator helper contract used by packaging guards and smokes. | +| `scripts/runtime/smoke/*.cjs` | smoke | Source, native runtime, packaged resource, packaged app, packaged desktop, packaged runtime, and DMG smoke harnesses. | +| `scripts/runtime/smoke/lib/smoke-helpers.cjs` | smoke helper | Smoke-only process, runtime env, readiness, and packaged resource assertions. | +| `scripts/runtime/smoke/runtime-rpc.cjs` | smoke helper | Agent-server JSON-RPC assertions used by source/native/packaged smokes. | +| `scripts/runtime/smoke/run-version-check.cjs` | smoke helper | Isolated executable version probe used by native runtime validation and packaged app smoke. | +| `scripts/prepare-device-use.mjs` | build/stage | Builds and stages `packages/device-use` bundles, frontend payload, skill, and native helpers. | +| `scripts/prepare-gh-cli.mjs` | stage | Stages GitHub CLI binaries and manifest for packaged runtime bins. | +| `scripts/prune-pencil-cli-binaries.cjs` | package/validate | Prunes duplicate package payloads and verifies packaged app/native runtime contents after pack. | + ## Structural Deferrals - `apps/runtime/index.ts` remains a single runtime executable entrypoint for @@ -104,10 +125,9 @@ agent CLI discovery. It is based on the source paths below, not on generated split point is a small target catalog plus separate `stage-agent-clis` and `validate-agent-clis` modules if another bundled agent family or platform makes the file hard to review. -- `scripts/runtime/` remains a flat command directory with reusable helpers under - `scripts/runtime/lib/`. The command names map directly to package scripts, and - moving them now would require compatibility wrappers without improving the - release path. +- Production build/stage/validate commands stay directly under + `scripts/runtime/`; smoke harnesses live under `scripts/runtime/smoke/` so the + package path and verification path are visually separate. ## Applied Cleanup @@ -116,6 +136,8 @@ agent CLI discovery. It is based on the source paths below, not on generated `bun run build:runtime`. - Device-use packaged payload constants are shared by packaging guards and packaged app smoke checks via `scripts/runtime/lib/device-use-payloads.cjs`. +- Runtime smoke harnesses moved under `scripts/runtime/smoke/`; public + `bun run smoke:*` commands remain the supported entrypoints. - Desktop CLI lookup now reuses `shared/runtime.ts` packaged PATH entries and the packaged runtime env denylist from `apps/desktop/main/runtime-env.ts` instead of carrying a production-local copy. diff --git a/package.json b/package.json index ec8c0def1..37f4578d0 100644 --- a/package.json +++ b/package.json @@ -40,13 +40,14 @@ "prepare:device-use": "node scripts/prepare-device-use.mjs", "prepare:gh-cli": "node scripts/prepare-gh-cli.mjs", "prepare:agent-clis": "bun scripts/runtime/prepare-agent-clis.ts", - "smoke:runtime-source": "node scripts/runtime/smoke-source-runtime.cjs", - "smoke:runtime-native": "node scripts/runtime/smoke-native-runtime.cjs", - "smoke:runtime-resources": "node scripts/runtime/smoke-packaged-resources.cjs", - "smoke:desktop-main-runtime": "node scripts/runtime/smoke-desktop-main-runtime.cjs", - "smoke:packaged-app": "node scripts/runtime/smoke-packaged-app.cjs", - "smoke:packaged-runtime": "node scripts/runtime/smoke-packaged-runtime.cjs", - "smoke:packaged-desktop": "node scripts/runtime/smoke-packaged-desktop.cjs", + "smoke:runtime-source": "node scripts/runtime/smoke/source-runtime.cjs", + "smoke:runtime-native": "node scripts/runtime/smoke/native-runtime.cjs", + "smoke:runtime-resources": "node scripts/runtime/smoke/packaged-resources.cjs", + "smoke:desktop-main-runtime": "node scripts/runtime/smoke/desktop-main-runtime.cjs", + "smoke:packaged-app": "node scripts/runtime/smoke/packaged-app.cjs", + "smoke:packaged-runtime": "node scripts/runtime/smoke/packaged-runtime.cjs", + "smoke:packaged-desktop": "node scripts/runtime/smoke/packaged-desktop.cjs", + "smoke:packaged-dmgs": "node scripts/runtime/smoke/packaged-dmgs.cjs", "preview": "vite preview", "test": "bun run test:backend && node node_modules/vitest/vitest.mjs run --config apps/agent-server/vitest.config.ts && node node_modules/vitest/vitest.mjs run --config test/vitest.config.ts", "test:simulator": "node node_modules/vitest/vitest.mjs run --config test/vitest.config.ts", diff --git a/scripts/runtime/native-runtime.ts b/scripts/runtime/native-runtime.ts index 76a034ce2..d9b5ed6e2 100644 --- a/scripts/runtime/native-runtime.ts +++ b/scripts/runtime/native-runtime.ts @@ -455,10 +455,9 @@ export function buildDeusRuntime(options: BuildDeusRuntimeOptions = {}): DeusRun const manifestCommandPath = relativeFromProjectRoot(projectRoot, output); const fileOutput = execOutput("file", [manifestCommandPath], projectRoot); assertFileArch(fileOutput, target, output); - const otoolOutput = - canInspectMacBinary(target) - ? execOutput("otool", ["-L", manifestCommandPath], projectRoot) - : undefined; + const otoolOutput = canInspectMacBinary(target) + ? execOutput("otool", ["-L", manifestCommandPath], projectRoot) + : undefined; if (otoolOutput) verifyMacSystemDylibs(otoolOutput, output); if (canInspectMacBinary(target)) { verifyMacCodeSignature(output); @@ -496,7 +495,7 @@ export function buildDeusRuntime(options: BuildDeusRuntimeOptions = {}): DeusRun } export function verifyStagedDeusRuntimeVersion(executablePath: string): string { - const helperPath = path.join(runtimeDir, "run-version-check.cjs"); + const helperPath = path.join(runtimeDir, "smoke", "run-version-check.cjs"); const env: NodeJS.ProcessEnv = { ...process.env }; for (const key of VERSION_CHECK_ENV_DENYLIST) { delete env[key]; @@ -585,10 +584,9 @@ export function validateDeusRuntime(options: ValidateDeusRuntimeOptions = {}): D const manifestCommandPath = relativeFromProjectRoot(projectRoot, executablePath); const fileOutput = execOutput("file", [manifestCommandPath], projectRoot); assertFileArch(fileOutput, target, executablePath); - const otoolOutput = - canInspectMacBinary(target) - ? execOutput("otool", ["-L", manifestCommandPath], projectRoot) - : undefined; + const otoolOutput = canInspectMacBinary(target) + ? execOutput("otool", ["-L", manifestCommandPath], projectRoot) + : undefined; if (otoolOutput) verifyMacSystemDylibs(otoolOutput, executablePath); if (manifestEntry.size !== statSync(executablePath).size) { throw new Error(`Native runtime manifest size mismatch for ${runtimeKey}`); diff --git a/scripts/runtime/smoke/desktop-main-runtime.cjs b/scripts/runtime/smoke/desktop-main-runtime.cjs index 34eca86a5..c44cf41ce 100644 --- a/scripts/runtime/smoke/desktop-main-runtime.cjs +++ b/scripts/runtime/smoke/desktop-main-runtime.cjs @@ -2,9 +2,9 @@ const { execFileSync } = require("node:child_process"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -const { assertPackagedMainRuntimeContract } = require("./electron-builder-before-pack.cjs"); +const { assertPackagedMainRuntimeContract } = require("../electron-builder-before-pack.cjs"); -const PROJECT_ROOT = path.resolve(__dirname, "../.."); +const PROJECT_ROOT = path.resolve(__dirname, "../../.."); const EXTERNALS = [ "electron", "better-sqlite3", diff --git a/scripts/runtime/smoke/lib/smoke-helpers.cjs b/scripts/runtime/smoke/lib/smoke-helpers.cjs index a527f435e..9da9a722e 100644 --- a/scripts/runtime/smoke/lib/smoke-helpers.cjs +++ b/scripts/runtime/smoke/lib/smoke-helpers.cjs @@ -3,7 +3,7 @@ const http = require("node:http"); const path = require("node:path"); const { execFileSync, spawn, spawnSync } = require("node:child_process"); -const PROJECT_ROOT = path.resolve(__dirname, "../../.."); +const PROJECT_ROOT = path.resolve(__dirname, "../../../.."); const DEFAULT_RUNTIME_TIMEOUT_MS = 45_000; const DEFAULT_STOP_TIMEOUT_MS = 5_000; const PACKAGED_SYSTEM_PATHS = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; diff --git a/scripts/runtime/smoke/native-runtime.cjs b/scripts/runtime/smoke/native-runtime.cjs index bb70e1a42..acde8812e 100644 --- a/scripts/runtime/smoke/native-runtime.cjs +++ b/scripts/runtime/smoke/native-runtime.cjs @@ -2,7 +2,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); -const { assertInitializedAgents, readAgentServerListenUrl } = require("./runtime-smoke-rpc.cjs"); +const { assertInitializedAgents, readAgentServerListenUrl } = require("./runtime-rpc.cjs"); const { PROJECT_ROOT, assertBackendDbRouteFromOutput, @@ -50,7 +50,7 @@ function parseArgs(argv) { } function printUsage() { - console.log(`Usage: node scripts/runtime/smoke-native-runtime.cjs [options] + console.log(`Usage: bun run smoke:runtime-native -- [options] Options: --runtime-key Staged runtime key, defaults to host key diff --git a/scripts/runtime/smoke/packaged-app.cjs b/scripts/runtime/smoke/packaged-app.cjs index bb3cbd97b..b97e529b3 100644 --- a/scripts/runtime/smoke/packaged-app.cjs +++ b/scripts/runtime/smoke/packaged-app.cjs @@ -5,8 +5,8 @@ const asar = require("@electron/asar"); const { verifyCodeSignaturePageSize, verifyPackagedAgentClis, -} = require("../prune-pencil-cli-binaries.cjs"); -const { assertPackagedMainRuntimeContents } = require("./electron-builder-before-pack.cjs"); +} = require("../../prune-pencil-cli-binaries.cjs"); +const { assertPackagedMainRuntimeContents } = require("../electron-builder-before-pack.cjs"); const { PROJECT_ROOT, RUNTIME_BINARIES, @@ -23,7 +23,7 @@ const { assertNoBuildLocalInstallName, packagedDeviceUseRoot, packagedSimulatorDir, -} = require("./lib/device-use-payloads.cjs"); +} = require("../lib/device-use-payloads.cjs"); const DEFAULT_APP_PATH = resolveDefaultAppPath(); const REQUIRED_BINARIES = RUNTIME_BINARIES; @@ -93,7 +93,7 @@ function parseArgs(argv) { } function printUsage() { - console.log(`Usage: node scripts/runtime/smoke-packaged-app.cjs [app-path] + console.log(`Usage: bun run smoke:packaged-app -- [app-path] Options: --app Path to the packaged .app bundle diff --git a/scripts/runtime/smoke/packaged-desktop.cjs b/scripts/runtime/smoke/packaged-desktop.cjs index 96d88cea7..ab5dd67d1 100644 --- a/scripts/runtime/smoke/packaged-desktop.cjs +++ b/scripts/runtime/smoke/packaged-desktop.cjs @@ -2,7 +2,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync, spawn } = require("node:child_process"); -const { assertInitializedAgents, readAgentServerListenUrl } = require("./runtime-smoke-rpc.cjs"); +const { assertInitializedAgents, readAgentServerListenUrl } = require("./runtime-rpc.cjs"); const { PROJECT_ROOT, appDiagnostics, @@ -71,14 +71,14 @@ function parseArgs(argv) { } function printUsage() { - console.log(`Usage: node scripts/runtime/smoke-packaged-desktop.cjs [app-path] + console.log(`Usage: bun run smoke:packaged-desktop -- [app-path] Options: --app Path to the packaged .app bundle --home HOME to use while launching the packaged app --launch-in-place Launch --app directly instead of copying it to a temp Applications dir --require-gatekeeper Require spctl execute assessment in the app check - --skip-app-check Skip smoke-packaged-app.cjs + --skip-app-check Skip the packaged app smoke This smoke launches the packaged Electron app with an isolated temporary HOME. It copies Deus.app to that HOME's Applications directory so the packaged @@ -145,7 +145,7 @@ function runAppCheck(appPath, options) { if (options.skipAppCheck) return; const args = [ - path.join(PROJECT_ROOT, "scripts", "runtime", "smoke-packaged-app.cjs"), + path.join(PROJECT_ROOT, "scripts", "runtime", "smoke", "packaged-app.cjs"), "--app", appPath, ]; diff --git a/scripts/runtime/smoke/packaged-dmgs.cjs b/scripts/runtime/smoke/packaged-dmgs.cjs index bbcbc5307..4d775d4cb 100644 --- a/scripts/runtime/smoke/packaged-dmgs.cjs +++ b/scripts/runtime/smoke/packaged-dmgs.cjs @@ -3,7 +3,7 @@ const os = require("node:os"); const path = require("node:path"); const { execFileSync } = require("node:child_process"); -const PROJECT_ROOT = path.resolve(__dirname, "../.."); +const PROJECT_ROOT = path.resolve(__dirname, "../../.."); function parseArgs(argv) { const options = { @@ -33,12 +33,12 @@ function parseArgs(argv) { } function printUsage() { - console.log(`Usage: node scripts/runtime/smoke-packaged-dmgs.cjs [options] + console.log(`Usage: bun run smoke:packaged-dmgs -- [options] Options: --require-gatekeeper Require spctl execute assessment for each mounted app -Mounts each macOS DMG, runs smoke-packaged-app.cjs against the contained +Mounts each macOS DMG, runs the packaged app smoke against the contained Deus.app with an inferred architecture, then detaches the image.`); } @@ -77,7 +77,7 @@ function smokeDmg(dmgPath, options) { const appPath = path.join(mountDir, "Deus.app"); const args = [ - path.join(PROJECT_ROOT, "scripts", "runtime", "smoke-packaged-app.cjs"), + path.join(PROJECT_ROOT, "scripts", "runtime", "smoke", "packaged-app.cjs"), "--app", appPath, "--arch", diff --git a/scripts/runtime/smoke/packaged-resources.cjs b/scripts/runtime/smoke/packaged-resources.cjs index aa3f72090..14e478d0e 100644 --- a/scripts/runtime/smoke/packaged-resources.cjs +++ b/scripts/runtime/smoke/packaged-resources.cjs @@ -2,7 +2,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { execFileSync, spawnSync } = require("node:child_process"); -const afterPack = require("../prune-pencil-cli-binaries.cjs"); +const afterPack = require("../../prune-pencil-cli-binaries.cjs"); const { verifyPackagedAgentClis } = afterPack; const { PROJECT_ROOT, RUNTIME_BINARIES, RUNTIME_MANIFESTS } = require("./lib/smoke-helpers.cjs"); diff --git a/scripts/runtime/smoke/packaged-runtime.cjs b/scripts/runtime/smoke/packaged-runtime.cjs index e124c3b65..dcd44401f 100644 --- a/scripts/runtime/smoke/packaged-runtime.cjs +++ b/scripts/runtime/smoke/packaged-runtime.cjs @@ -5,7 +5,7 @@ const path = require("node:path"); const { execFileSync, spawn, spawnSync } = require("node:child_process"); const { createServer } = require("node:net"); const WebSocket = require("ws"); -const { assertInitializedAgents, readAgentServerListenUrl } = require("./runtime-smoke-rpc.cjs"); +const { assertInitializedAgents, readAgentServerListenUrl } = require("./runtime-rpc.cjs"); const { PROJECT_ROOT, PACKAGED_SYSTEM_PATHS, @@ -55,12 +55,12 @@ function parseArgs(argv) { } function printUsage() { - console.log(`Usage: node scripts/runtime/smoke-packaged-runtime.cjs [app-path] + console.log(`Usage: bun run smoke:packaged-runtime -- [app-path] Options: --app Path to the packaged .app bundle --require-gatekeeper Require spctl execute assessment in the app check - --skip-app-check Skip smoke-packaged-app.cjs and only run runtime commands + --skip-app-check Skip the packaged app smoke and only run runtime commands This smoke executes the packaged Resources/bin/deus-runtime. It should be run on notarized release artifacts or hosts that allow generated/copied Mach-O @@ -71,7 +71,7 @@ function runAppCheck(appPath, options) { if (options.skipAppCheck) return; const args = [ - path.join(PROJECT_ROOT, "scripts", "runtime", "smoke-packaged-app.cjs"), + path.join(PROJECT_ROOT, "scripts", "runtime", "smoke", "packaged-app.cjs"), "--app", appPath, "--run-version-checks", diff --git a/scripts/runtime/smoke/source-runtime.cjs b/scripts/runtime/smoke/source-runtime.cjs index 8f68806cc..759160dd9 100644 --- a/scripts/runtime/smoke/source-runtime.cjs +++ b/scripts/runtime/smoke/source-runtime.cjs @@ -2,7 +2,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { spawn, spawnSync } = require("node:child_process"); -const { assertInitializedAgents, readAgentServerListenUrl } = require("./runtime-smoke-rpc.cjs"); +const { assertInitializedAgents, readAgentServerListenUrl } = require("./runtime-rpc.cjs"); const { PROJECT_ROOT, assertBackendDbRoute, diff --git a/test/unit/runtime/smoke-packaged-runtime.test.ts b/test/unit/runtime/smoke-packaged-runtime.test.ts index 2eadf5a73..cbe8b1ed6 100644 --- a/test/unit/runtime/smoke-packaged-runtime.test.ts +++ b/test/unit/runtime/smoke-packaged-runtime.test.ts @@ -9,7 +9,7 @@ const projectRoot = path.resolve(__dirname, "../../.."); describe("packaged runtime smoke harness", () => { it("does not load host better-sqlite3 to seed backend state", () => { const script = readFileSync( - path.join(projectRoot, "scripts", "runtime", "smoke-packaged-runtime.cjs"), + path.join(projectRoot, "scripts", "runtime", "smoke", "packaged-runtime.cjs"), "utf8" );