Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/backend/src/lib/sqlite.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type BetterSqlite3 from "better-sqlite3";
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
const requireModule = createRequire(import.meta.url);

type BetterSqlite3Constructor = new (
filename: string,
Expand All @@ -24,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;
Expand All @@ -33,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");
}
Expand Down
21 changes: 20 additions & 1 deletion apps/backend/src/services/aap/apps.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,23 @@ async function runPrefetch(installed: InstalledAppEntry): Promise<void> {
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Move prefetch arg templating after spawnability guard

runPrefetch now calls substituteArgs before checking canSpawnResolvedCommand, so a manifest with templated prefetch args (for example {workspace}) will throw immediately when vars is empty even in the “command unavailable” path that is supposed to be skipped. Because prefetchInstalledAppAssets() fire-and-forgets runPrefetch, this rejection is unhandled and can crash startup under Node’s default unhandled-rejection behavior instead of logging a benign skip.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d744949: prefetch now checks whether the resolved command is spawnable before expanding cwd/args/env templates, so unavailable optional prefetch commands skip cleanly instead of rejecting on empty boot-time vars. Added integration coverage for a missing prefetch command with templated cwd/args.

if (!canSpawnResolvedCommand(command)) {
console.log(`[AAP] Prefetch skipped: ${manifest.id}`, {
command: prefetch.command,
reason: "command unavailable",
});
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,
Expand Down Expand Up @@ -233,6 +242,16 @@ async function runPrefetch(installed: InstalledAppEntry): Promise<void> {
});
}

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude URL-like args from prefetch entrypoint checks

The new missing-entrypoint guard treats any first arg containing / or \ as a filesystem path, so valid commands like curl https://... (or any tool whose first operand is a URI) are incorrectly resolved against cwd and skipped as “entrypoint unavailable.” This silently disables legitimate prefetch commands for manifests that use URL-style operands.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d744949: the prefetch entrypoint guard now ignores URI-like first operands such as https://... and file:..., so valid URL operands are not treated as missing filesystem paths. Added an integration case that runs a prefetch command with an https:// first arg.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exempt package specifiers from prefetch path checks

Treating any first arg containing / or \\ as a filesystem entrypoint now breaks valid prefetch commands like bunx @scope/tool or npx @scope/tool, because scoped package names include / but are not paths. In those cases this guard resolves @scope/tool against cwd, marks it missing, and skips prefetch with entrypoint unavailable, so legitimate third-party prefetch flows never run.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in e70ab8f. The prefetch missing-entrypoint guard now only treats explicit filesystem entrypoints as paths (absolute paths, ./, ../, Windows drive paths), so scoped package arguments like @scope/tool are passed through. Added integration coverage for a scoped package-style prefetch argument.

}

function canSpawnResolvedCommand(command: string): boolean {
if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
return existsSync(command);
Expand Down
27 changes: 25 additions & 2 deletions apps/backend/src/services/aap/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand All @@ -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"],
Expand Down Expand Up @@ -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
// ----------------------------------------------------------------------------
Expand Down
19 changes: 7 additions & 12 deletions apps/backend/src/services/agent/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -39,6 +41,8 @@ interface CommandResult {
[key: string]: unknown;
}

const execFileAsync = promisify(execFile);

export interface CommandContext {
relayClient?: boolean;
}
Expand Down Expand Up @@ -279,32 +283,23 @@ 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 () => {
const workspaceId = requireParam(params, "workspaceId", "sim:terminateApp");
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 () => {
const workspaceId = requireParam(params, "workspaceId", "sim:uninstallApp");
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 ----
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/services/pty.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Pty.IPty>();
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;
}

Expand Down
3 changes: 2 additions & 1 deletion apps/backend/test/integration/aap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down
68 changes: 68 additions & 0 deletions apps/backend/test/unit/services/aap/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import {
isProcessAlive,
killByPid,
resolveLaunchCommand,
spawnApp,
stopChild,
waitForReady,
Expand Down Expand Up @@ -48,7 +49,74 @@ async function startProbeServer(options: { healthStatus: number } = { healthStat
return { server, port, close: () => new Promise<void>((r) => server.close(() => r())) };
}

function withEnv(overrides: Record<string, string | undefined>, 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) => {
Expand Down
3 changes: 1 addition & 2 deletions apps/desktop/main/backend-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -15,7 +15,6 @@ let restartAttempt = 0;
let restartTimer: ReturnType<typeof setTimeout> | 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 {
Expand Down
26 changes: 5 additions & 21 deletions apps/desktop/main/cli-tools.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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) });
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/main/runtime-env.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading