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
1 change: 0 additions & 1 deletion .claude/scheduled_tasks.lock

This file was deleted.

2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ node_modules
*.png
!docs/**/*.png
!apps/web/public/**/*.png
!apps/ios/**/*.png

# Python cache
__pycache__/
Expand Down Expand Up @@ -45,6 +46,7 @@ release-stable/
# Xcode user data & derived data
xcuserdata/
*.xcuserstate
/.derived-data/
apps/ios/.dry-run-derived-data/
apps/ios/build/
ios-signing/
Expand Down
6 changes: 2 additions & 4 deletions apps/ade-cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 28 additions & 3 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
upsertProjectRow,
} from "./services/projects/projectService";
import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary";
import { resolveProjectIcon } from "./services/projects/projectIconResolver";
import { createAdeProjectService } from "./services/projects/adeProjectService";
import { createConfigReloadService } from "./services/projects/configReloadService";
import { IPC } from "../shared/ipc";
Expand Down Expand Up @@ -955,6 +956,10 @@ app.whenReady().then(async () => {
});
if (activeProjectRoot) {
projectLastActivatedAt.set(activeProjectRoot, Date.now());
const activeCtx = projectContexts.get(activeProjectRoot);
if (activeCtx) {
persistRecentProject(activeCtx.project, { recordLastProject: false });
}
try {
adeArtifactAllowedDir =
resolveAdeLayout(activeProjectRoot).artifactsDir;
Expand Down Expand Up @@ -3938,6 +3943,7 @@ app.whenReady().then(async () => {
rootPath: ctx.project.rootPath,
defaultBaseRef: ctx.project.baseRef,
lastOpenedAt: recent?.summary.lastOpenedAt ?? null,
iconDataUrl: mobileProjectIconDataUrl(ctx.project.rootPath),
laneCount,
isAvailable: fs.existsSync(ctx.project.rootPath),
isCached: false,
Expand All @@ -3953,13 +3959,30 @@ app.whenReady().then(async () => {
rootPath: recent.summary.rootPath,
defaultBaseRef: recent.defaultBaseRef,
lastOpenedAt: recent.summary.lastOpenedAt,
iconDataUrl: mobileProjectIconDataUrl(recent.summary.rootPath),
laneCount: recent.summary.laneCount ?? 0,
isAvailable: recent.summary.exists,
isCached: false,
isOpen: false,
};
}

function mobileProjectIconDataUrl(projectRoot: string): string | null {
try {
const icon = resolveProjectIcon(projectRoot);
if (!icon.sourcePath) return null;

const image = nativeImage.createFromPath(icon.sourcePath);
if (!image.isEmpty()) {
return image.resize({ width: 64, height: 64, quality: "best" }).toDataURL();
}

return icon.mimeType === "image/png" ? icon.dataUrl : null;
} catch {
return null;
}
}

async function listMobileSyncProjects(): Promise<{ projects: SyncMobileProjectSummary[] }> {
const recentProjects = (readGlobalState(globalStatePath).recentProjects ?? [])
.map(inspectRecentProject);
Expand All @@ -3971,9 +3994,11 @@ app.whenReady().then(async () => {
byRoot.set(normalizeProjectRoot(recent.summary.rootPath), mobileProjectSummaryForRecent(recent));
}
const contextSummaries = await Promise.all(
[...projectContexts.entries()].map(async ([root, ctx]) =>
[root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null)] as const
),
[...projectContexts.entries()]
.filter(([root]) => recentByRoot.has(root))
.map(async ([root, ctx]) =>
[root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null)] as const
),
);
for (const [root, summary] of contextSummaries) {
byRoot.set(root, summary);
Expand Down
182 changes: 181 additions & 1 deletion apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron";
import { app, BrowserWindow, clipboard, dialog, ipcMain, nativeImage, shell } from "electron";
import { createEmptyAutoUpdateSnapshot, type createAutoUpdateService } from "../updates/autoUpdateService";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
Expand All @@ -13,6 +13,7 @@ import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../
import { launchRebaseResolutionChat } from "../prs/prRebaseResolver";
import { browseProjectDirectories } from "../projects/projectBrowserService";
import { getProjectDetail } from "../projects/projectDetailService";
import { resolveProjectIcon } from "../projects/projectIconResolver";
import { runGit } from "../git/git";
import type { AdeCleanupResult, AdeProjectSnapshot } from "../../../shared/types";
import { toRecentProjectSummary } from "../projects/recentProjectSummary";
Expand Down Expand Up @@ -258,6 +259,7 @@ import type {
ProjectBrowseInput,
ProjectBrowseResult,
ProjectDetail,
ProjectIcon,
ProjectInfo,
RecentProjectSummary,
PtyCreateArgs,
Expand Down Expand Up @@ -1890,6 +1892,107 @@ export function registerIpc({
return path.resolve(path.isAbsolute(inputPath) ? inputPath : path.join(projectRoot, inputPath));
};

const resolveAllowedRendererPath = (rawPath: string): string => {
const raw = typeof rawPath === "string" ? rawPath.trim() : "";
if (!raw) throw new Error("Missing path.");
const ctx = getCtx();
const normalized = resolveRendererSuppliedPath(raw, ctx.project.rootPath);
const allowedDirs = getAllowedDirs(getCtx);
// resolvePathWithinRoot follows symlinks via fs.realpath while validating
// containment, so we both reject symlinks pointing outside the allowlist
// *and* return the canonical real path for callers to read from. Returning
// the lexical path would still be safe because the check resolved real
// paths, but handing back the realpath avoids any TOCTOU-adjacent surprises
// and keeps file I/O pinned to the validated target.
let resolved: string | null = null;
for (const dir of allowedDirs) {
try {
resolved = resolvePathWithinRoot(dir, normalized);
break;
} catch {
// try next allowed dir
}
}
if (!resolved) {
throw new Error("Path is outside allowed directories.");
}
return resolved;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Sniff the first bytes of a buffer for known image magic numbers and
* return the corresponding MIME type. Returns null if the buffer doesn't
* match any supported image format.
*
* We deliberately do NOT trust the file extension here — extension-only
* inference would let a renderer hand us any allow-listed file (text,
* binary, etc.) and get it back as a base64 `image/png` data URL.
*/
const sniffImageMimeType = (buffer: Buffer): string | null => {
if (buffer.length >= 8
&& buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47
&& buffer[4] === 0x0D && buffer[5] === 0x0A && buffer[6] === 0x1A && buffer[7] === 0x0A) {
return "image/png";
}
if (buffer.length >= 3
&& buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return "image/jpeg";
}
if (buffer.length >= 6
&& buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38
&& (buffer[4] === 0x37 || buffer[4] === 0x39) && buffer[5] === 0x61) {
return "image/gif";
}
if (buffer.length >= 12
&& buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46
&& buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
return "image/webp";
}
if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4D) {
return "image/bmp";
}
if (buffer.length >= 4
&& buffer[0] === 0x00 && buffer[1] === 0x00
&& buffer[2] === 0x01 && buffer[3] === 0x00) {
return "image/x-icon";
}
// SVG/XML: scan a small prefix as text so leading whitespace, BOM, or an
// <?xml ... ?> declaration before <svg ...> are tolerated.
const head = buffer.slice(0, Math.min(buffer.length, 1024)).toString("utf8");
const stripped = head.replace(/^/, "").trimStart();
if (/^<\?xml\b/i.test(stripped) && /<svg\b/i.test(head)) {
return "image/svg+xml";
}
if (/^<svg\b/i.test(stripped)) {
return "image/svg+xml";
}
return null;
};

const MAX_IMAGE_BYTES = 10 * 1024 * 1024;

/**
* Read an allow-listed image file from disk after a stat-based size check,
* sniff its bytes for a known image magic, and return both the bytes and
* the sniffed MIME type. Throws if the file is too large, isn't a regular
* file, or doesn't look like a supported image.
*/
const readImageFileAndSniffMime = async (filePath: string): Promise<{ data: Buffer; mimeType: string }> => {
const stat = await fs.promises.stat(filePath);
if (!stat.isFile()) {
throw new Error("Path is not a file.");
}
if (stat.size > MAX_IMAGE_BYTES) {
throw new Error("Image must be 10 MB or smaller.");
}
const data = await fs.promises.readFile(filePath);
const mimeType = sniffImageMimeType(data);
if (!mimeType) {
throw new Error("Path is not an image.");
}
return { data, mimeType };
};

ipcMain.handle(IPC.appRevealPath, async (_event, arg: { path: string }): Promise<void> => {
const raw = typeof arg?.path === "string" ? arg.path.trim() : "";
if (!raw) return;
Expand Down Expand Up @@ -1940,6 +2043,35 @@ export function registerIpc({
clipboard.writeText(text);
});

ipcMain.handle(IPC.appGetImageDataUrl, async (_event, arg: { path: string }): Promise<{ dataUrl: string }> => {
const filePath = resolveAllowedRendererPath(arg?.path);
// Use async fs APIs and a size pre-check so a 10 MB image read never
// blocks the main process event loop (input dispatch, IPC, window
// animations all share that loop). The MIME type is derived from the
// file's *bytes*, not its extension, so a renderer can't smuggle
// arbitrary text/binary back as a base64 `image/png` data URL.
const { data, mimeType } = await readImageFileAndSniffMime(filePath);
return {
dataUrl: `data:${mimeType};base64,${data.toString("base64")}`,
};
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

ipcMain.handle(IPC.appWriteClipboardImage, async (_event, arg: { path: string }): Promise<void> => {
const filePath = resolveAllowedRendererPath(arg?.path);
// Apply the same size + magic-byte preflight as `appGetImageDataUrl` so
// we can't hand `nativeImage.createFromPath` a giant or non-image file
// (which would otherwise silently produce an empty image, or worse,
// attempt a sync read of a 100 MB binary on the main process). We then
// hand the already-read buffer to `nativeImage.createFromBuffer` so the
// file isn't read a second time off the main thread.
const { data } = await readImageFileAndSniffMime(filePath);
const image = nativeImage.createFromBuffer(data);
if (image.isEmpty()) {
throw new Error("Unable to read image.");
}
clipboard.writeImage(image);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

ipcMain.handle(
IPC.appOpenPathInEditor,
async (
Expand Down Expand Up @@ -2146,6 +2278,54 @@ export function registerIpc({
}
);

// Project-root allowlist for icon resolution. Tab/catalog icons are
// resolved for the *current* project root and any *recently opened*
// project root — including ones that live outside Downloads/Documents/Temp
// (the generic `getAllowedDirs` set). Using `resolveAllowedRendererPath`
// here would silently strip icons for any project in `~/code/*` etc.
const getAllowedProjectRoots = (): string[] => {
const state = readGlobalState(globalStatePath);
return Array.from(new Set([
getCtx().project.rootPath,
...(state.recentProjects ?? [])
.map((entry) => entry.rootPath)
.filter((root): root is string => typeof root === "string" && root.trim().length > 0),
]));
};

const resolveAllowedProjectRoot = (rawPath: string): string => {
const raw = typeof rawPath === "string" ? rawPath.trim() : "";
if (!raw) throw new Error("Missing root path.");
const normalized = resolveRendererSuppliedPath(raw, getCtx().project.rootPath);
for (const dir of getAllowedProjectRoots()) {
try {
return resolvePathWithinRoot(dir, normalized);
} catch {
// try next known project root
}
}
throw new Error("rootPath is outside known project roots.");
};

ipcMain.handle(
IPC.projectResolveIcon,
async (_event, args: { rootPath: string }): Promise<ProjectIcon> => {
const rootPath = typeof args?.rootPath === "string" ? args.rootPath.trim() : "";
if (!rootPath) return { dataUrl: null, sourcePath: null, mimeType: null };
// Validate the renderer-supplied root against the project-root
// allowlist (current + recent projects) so a compromised renderer
// can't probe arbitrary directories for icons, while still serving
// icons for projects that live outside the generic file allowlist.
let validatedRoot: string;
try {
validatedRoot = resolveAllowedProjectRoot(rootPath);
} catch {
return { dataUrl: null, sourcePath: null, mimeType: null };
}
return resolveProjectIcon(validatedRoot);
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

ipcMain.handle(IPC.projectOpenAdeFolder, async (): Promise<void> => {
const ctx = getCtx();
await shell.openPath(ctx.adeDir);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3278,9 +3278,11 @@ describe("aiOrchestratorService", () => {
}) as typeof fixture.orchestratorService.onTrackedSessionEnded;

const firstSweep = fixture.aiOrchestratorService.runHealthSweep("overlap-owner");
// CI runners can be heavily loaded; give the first sweep up to ~5s to reach
// the gated `onTrackedSessionEnded` call before we assert it was invoked.
for (let tries = 0; tries < 200 && reconcileCalls === 0; tries += 1) {
// CI runners can be heavily loaded; give the first sweep up to ~15s to
// reach the gated `onTrackedSessionEnded` call before we assert it was
// invoked. The test passes immediately when the callback fires, so the
// generous ceiling only kicks in for slow/contended runners.
for (let tries = 0; tries < 600 && reconcileCalls === 0; tries += 1) {
await new Promise((resolve) => setTimeout(resolve, 25));
}
expect(reconcileCalls).toBe(1);
Expand All @@ -3295,7 +3297,7 @@ describe("aiOrchestratorService", () => {
releaseFirstSweep();
fixture.dispose();
}
}, 15_000);
}, 30_000);

it("skips background health sweeps for runs blocked on open interventions", async () => {
const fixture = await createFixture();
Expand Down
Loading
Loading