Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
29 changes: 27 additions & 2 deletions apps/backend/src/services/aap/apps.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,6 @@ async function runPrefetch(installed: InstalledAppEntry): Promise<void> {
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);
if (!canSpawnResolvedCommand(command)) {
console.log(`[AAP] Prefetch skipped: ${manifest.id}`, {
Expand All @@ -177,7 +175,19 @@ async function runPrefetch(installed: InstalledAppEntry): Promise<void> {
});
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}`, {
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 +243,21 @@ 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 (isUriLikeArg(firstArg)) 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 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);
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
88 changes: 79 additions & 9 deletions apps/backend/test/integration/aap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
`
Expand All @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -188,17 +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
);
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 () => {
Expand Down
Loading
Loading