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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/ade-cli/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
allowBuilds:
esbuild: set this to true or false
node-pty: set this to true or false
opencode-ai: set this to true or false
sqlite3: set this to true or false
esbuild: true
node-pty: true
opencode-ai: true
sqlite3: true
1 change: 1 addition & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1749,6 +1749,7 @@ app.whenReady().then(async () => {
currentVersion: app.getVersion(),
globalStatePath,
updaterCacheDir: app.isPackaged ? resolveAutoUpdaterCacheDir() : undefined,
autoCheckEnabled: app.isPackaged,
});

const initContextForProjectRoot = async ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
commandForwardsAppControlDebug,
commandLooksLikeDirectElectronLaunch,
commandLooksLikePackageScriptLaunch,
insertDebugFlagsIntoDirectElectronCommand,
rewritePackageScriptElectronLaunch,
} from "./appControlLaunchCommand";

const DEBUG_FLAGS = ["--remote-debugging-port=9222"];

describe("appControlLaunchCommand", () => {
it("detects direct Electron launches and injects debug flags after electron", () => {
expect(commandLooksLikeDirectElectronLaunch("FOO=bar npx electron .")).toBe(true);

expect(insertDebugFlagsIntoDirectElectronCommand("FOO=bar npx electron .", DEBUG_FLAGS))
.toBe("FOO=bar npx electron --remote-debugging-port=9222 .");
});

it("detects package script launches and rewrites direct Electron scripts", () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-app-control-launch-"));
try {
fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({
scripts: {
dev: "electron .",
},
}), "utf8");

expect(commandLooksLikePackageScriptLaunch("npm run dev")).toBe(true);
expect(rewritePackageScriptElectronLaunch("npm run dev", DEBUG_FLAGS, projectRoot))
.toBe(`PATH=${path.join(projectRoot, "node_modules", ".bin")}:$PATH electron --remote-debugging-port=9222 .`);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});

it("does not rewrite scripts that already forward App Control flags", () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-app-control-launch-"));
try {
fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({
scripts: {
dev: "electron . {ADE_APP_CONTROL_DEBUG_FLAGS}",
},
}), "utf8");

expect(commandForwardsAppControlDebug("electron . {ADE_APP_CONTROL_DEBUG_FLAGS}")).toBe(true);
expect(rewritePackageScriptElectronLaunch("npm run dev", DEBUG_FLAGS, projectRoot)).toBeNull();
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});

it("resolves the last cd segment before reading package.json", () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-app-control-launch-"));
const appDir = path.join(projectRoot, "apps", "desktop");
try {
fs.mkdirSync(appDir, { recursive: true });
fs.writeFileSync(path.join(appDir, "package.json"), JSON.stringify({
scripts: {
dev: "electron .",
},
}), "utf8");

expect(rewritePackageScriptElectronLaunch("cd apps/desktop && npm run dev", DEBUG_FLAGS, projectRoot))
.toBe(`cd apps/desktop && PATH=${path.join(appDir, "node_modules", ".bin")}:$PATH electron --remote-debugging-port=9222 .`);
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import fs from "node:fs";
import path from "node:path";

export function shellQuote(value: string): string {
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value;
return `'${value.replace(/'/g, "'\\''")}'`;
}

export function commandForwardsAppControlDebug(command: string): boolean {
return /\{ADE_APP_CONTROL_DEBUG_FLAGS\}|\bADE_APP_CONTROL_(?:DEBUG_FLAGS|CDP_PORT|REMOTE_DEBUGGING_PORT)\b|--remote-debugging-port\b/.test(command);
}

export function commandLooksLikePackageScriptLaunch(command: string): boolean {
return /(?:^|[;&|]\s*)(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?[A-Za-z0-9:_./-]+)\s*$/.test(command.trim());
}

export function commandLooksLikeDirectElectronLaunch(command: string): boolean {
return /(?:^|[;&|]\s*)(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*(?:npx\s+)?electron(?:\s+[^;&|]*)?\s*$/.test(command.trim());
}

export function unquoteShellValue(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|| (trimmed.startsWith("\"") && trimmed.endsWith("\""))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}

export function insertDebugFlagsIntoDirectElectronCommand(command: string, debugFlags: string[]): string {
const flags = debugFlags.map(shellQuote).join(" ");
return command.replace(
/((?:^|[;&|]\s*)(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*(?:npx\s+)?electron)(?=\s|$)/,
`$1 ${flags}`,
);
}

function prependEnvToShellSegments(script: string, envPrefix: string): string {
const trimmedEnv = envPrefix.trim();
if (!trimmedEnv) return script;
return script
.split(/(\s*&&\s*)/)
.map((segment) => {
if (/^\s*&&\s*$/.test(segment)) return segment;
const trimmed = segment.trim();
return trimmed ? `${trimmedEnv} ${trimmed}` : segment;
})
.join("");
}

export function rewritePackageScriptElectronLaunch(command: string, debugFlags: string[], fallbackCwd: string): string | null {
const match = command.trim().match(/^(?<prefix>.*?)(?<env>(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*)(?<manager>npm|pnpm|yarn|bun)\s+(?:run\s+)?(?<script>[A-Za-z0-9:_./-]+)\s*$/);
const groups = match?.groups;
if (!groups) return null;
const prefix = groups.prefix ?? "";
const envPrefix = groups.env?.trim() ?? "";
const scriptName = groups.script;
if (!scriptName) return null;

let packageDir = fallbackCwd;
const cdMatches = Array.from(prefix.matchAll(/(?:^|[;&|]\s*)cd\s+((?:"[^"]+"|'[^']+'|[^\s;&|]+))\s*&&/g));
const lastCd = cdMatches.at(-1);
if (lastCd?.[1]) packageDir = path.resolve(fallbackCwd, unquoteShellValue(lastCd[1]));

try {
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) as {
scripts?: Record<string, unknown>;
};
const script = packageJson.scripts?.[scriptName];
if (typeof script !== "string") return null;
if (commandForwardsAppControlDebug(script) || !/\belectron(?:\s|$)/.test(script)) return null;
const rewrittenScript = insertDebugFlagsIntoDirectElectronCommand(script, debugFlags);
if (rewrittenScript === script) return null;
const packageBinPath = path.join(packageDir, "node_modules", ".bin");
const expandedEnvPrefix = [`PATH=${shellQuote(packageBinPath)}:$PATH`, envPrefix].filter(Boolean).join(" ");
return `${prefix}${prependEnvToShellSegments(rewrittenScript, expandedEnvPrefix)}`;
} catch {
return null;
}
}
132 changes: 10 additions & 122 deletions apps/desktop/src/main/services/appControl/appControlService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ import type {
} from "../../../shared/types";
import type { Logger } from "../logging/logger";
import type { createPtyService } from "../pty/ptyService";
import { imageDimensions } from "../shared/imageDimensions";
import {
commandForwardsAppControlDebug,
commandLooksLikeDirectElectronLaunch,
commandLooksLikePackageScriptLaunch,
insertDebugFlagsIntoDirectElectronCommand,
rewritePackageScriptElectronLaunch,
shellQuote,
unquoteShellValue,
} from "./appControlLaunchCommand";

const CDP_POLL_MS = 500;
const CDP_HEALTH_POLL_MS = 2_000;
Expand Down Expand Up @@ -159,86 +169,6 @@ function roundFrame(frame: AppControlFrame): AppControlFrame {
};
}

function shellQuote(value: string): string {
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value;
return `'${value.replace(/'/g, "'\\''")}'`;
}

function commandForwardsAppControlDebug(command: string): boolean {
return /\{ADE_APP_CONTROL_DEBUG_FLAGS\}|\bADE_APP_CONTROL_(?:DEBUG_FLAGS|CDP_PORT|REMOTE_DEBUGGING_PORT)\b|--remote-debugging-port\b/.test(command);
}

function commandLooksLikePackageScriptLaunch(command: string): boolean {
return /(?:^|[;&|]\s*)(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?[A-Za-z0-9:_./-]+)\s*$/.test(command.trim());
}

function commandLooksLikeDirectElectronLaunch(command: string): boolean {
return /(?:^|[;&|]\s*)(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*(?:npx\s+)?electron(?:\s+[^;&|]*)?\s*$/.test(command.trim());
}

function unquoteShellValue(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|| (trimmed.startsWith("\"") && trimmed.endsWith("\""))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}

function insertDebugFlagsIntoDirectElectronCommand(command: string, debugFlags: string[]): string {
const flags = debugFlags.map(shellQuote).join(" ");
return command.replace(
/((?:^|[;&|]\s*)(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*(?:npx\s+)?electron)(?=\s|$)/,
`$1 ${flags}`,
);
}

function prependEnvToShellSegments(script: string, envPrefix: string): string {
const trimmedEnv = envPrefix.trim();
if (!trimmedEnv) return script;
return script
.split(/(\s*&&\s*)/)
.map((segment) => {
if (/^\s*&&\s*$/.test(segment)) return segment;
const trimmed = segment.trim();
return trimmed ? `${trimmedEnv} ${trimmed}` : segment;
})
.join("");
}

function rewritePackageScriptElectronLaunch(command: string, debugFlags: string[], fallbackCwd: string): string | null {
const match = command.trim().match(/^(?<prefix>.*?)(?<env>(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*)(?<manager>npm|pnpm|yarn|bun)\s+(?:run\s+)?(?<script>[A-Za-z0-9:_./-]+)\s*$/);
const groups = match?.groups;
if (!groups) return null;
const prefix = groups.prefix ?? "";
const envPrefix = groups.env?.trim() ?? "";
const scriptName = groups.script;
if (!scriptName) return null;

let packageDir = fallbackCwd;
const cdMatches = Array.from(prefix.matchAll(/(?:^|[;&|]\s*)cd\s+((?:"[^"]+"|'[^']+'|[^\s;&|]+))\s*&&/g));
const lastCd = cdMatches.at(-1);
if (lastCd?.[1]) packageDir = path.resolve(fallbackCwd, unquoteShellValue(lastCd[1]));

try {
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) as {
scripts?: Record<string, unknown>;
};
const script = packageJson.scripts?.[scriptName];
if (typeof script !== "string") return null;
if (commandForwardsAppControlDebug(script) || !/\belectron(?:\s|$)/.test(script)) return null;
const rewrittenScript = insertDebugFlagsIntoDirectElectronCommand(script, debugFlags);
if (rewrittenScript === script) return null;
const packageBinPath = path.join(packageDir, "node_modules", ".bin");
const expandedEnvPrefix = [`PATH=${shellQuote(packageBinPath)}:$PATH`, envPrefix].filter(Boolean).join(" ");
return `${prefix}${prependEnvToShellSegments(rewrittenScript, expandedEnvPrefix)}`;
} catch {
return null;
}
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
Expand Down Expand Up @@ -449,48 +379,6 @@ class CdpClient {
}
}

function pngDimensions(buffer: Buffer): { width: number; height: number } | null {
if (buffer.length < 24) return null;
if (buffer.toString("ascii", 1, 4) !== "PNG") return null;
return {
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20),
};
}

function jpegDimensions(buffer: Buffer): { width: number; height: number } | null {
if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = buffer[offset + 1];
offset += 2;
if (marker === 0xd9 || marker === 0xda) break;
if (offset + 2 > buffer.length) return null;
const segmentLength = buffer.readUInt16BE(offset);
if (segmentLength < 2 || offset + segmentLength > buffer.length) return null;
const isStartOfFrame =
marker >= 0xc0
&& marker <= 0xcf
&& ![0xc4, 0xc8, 0xcc].includes(marker);
if (isStartOfFrame && segmentLength >= 7) {
return {
height: buffer.readUInt16BE(offset + 3),
width: buffer.readUInt16BE(offset + 5),
};
}
offset += segmentLength;
}
return null;
}

function imageDimensions(buffer: Buffer): { width: number; height: number } | null {
return pngDimensions(buffer) ?? jpegDimensions(buffer);
}

function cdpDomSnapshotScript(maxElements: number): string {
return `(() => {
const roleFor = (el) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,9 @@ describe("automationService integration", () => {
getLaneWorktreePath: () => projectRoot,
getLaneBaseAndBranch: () => ({ baseRef: "main", branchRef: "main", worktreePath: projectRoot })
} as any;
const budgetCapService = {
checkBudget: vi.fn(() => ({ allowed: false, reason: "Budget exceeded" })),
};

const service = createAutomationService({
db: db as any,
Expand All @@ -1200,13 +1203,17 @@ describe("automationService integration", () => {
agentChatService: {
createSession,
} as any,
budgetCapService: {
checkBudget: vi.fn(() => ({ allowed: false, reason: "Budget exceeded" })),
} as any,
budgetCapService: budgetCapService as any,
});

try {
await expect(service.triggerManually({ id: "agent-budget" })).rejects.toThrow("Budget exceeded");
expect(budgetCapService.checkBudget).toHaveBeenCalledWith(
"automation-rule",
"agent-budget",
expect.any(String),
{ runScopeId: expect.any(String) },
);
expect(createSession).not.toHaveBeenCalled();
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
Expand Down Expand Up @@ -1502,7 +1509,9 @@ describe("automationService integration", () => {

try {
await expect(service.triggerManually({ id: "agent-budget-provider" })).rejects.toThrow("Budget exceeded");
expect(checkBudget).toHaveBeenCalledWith("automation-rule", "agent-budget-provider", "codex");
expect(checkBudget).toHaveBeenCalledWith("automation-rule", "agent-budget-provider", "codex", {
runScopeId: expect.any(String),
});
expect(createSession).not.toHaveBeenCalled();
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
Expand Down
Loading
Loading