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
20 changes: 16 additions & 4 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,10 @@ import { createRegisteredSyncPeerGate } from "./services/state/syncPeerCompactio
import { ensureAdeDirs } from "./services/state/projectState";
import {
persistableRemoteProjectIconDataUrl,
persistableRemoteProjectBinding,
readGlobalState,
type RecentProject,
upsertRecentProject,
withPersistableRemoteProjectIcon,
writeGlobalState,
} from "./services/state/globalState";
import { createLaneService, type LaneDeleteTeardownDeps } from "./services/lanes/laneService";
Expand Down Expand Up @@ -113,13 +113,18 @@ import {
toProjectInfo,
upsertProjectRow,
} from "./services/projects/projectService";
import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary";
import {
inspectRecentProject,
readGitOriginUrl,
type RecentProjectInspection,
} from "./services/projects/recentProjectSummary";
import { browseProjectDirectories } from "./services/projects/projectBrowserService";
import { resolveMobileProjectIconDataUrl } from "./services/projects/projectIconThumbnail";
import { normalizeStartupProjectState, resolveStartupProject } from "./services/projects/startupProjectResolver";
import { createAdeProjectService } from "./services/projects/adeProjectService";
import { createConfigReloadService } from "./services/projects/configReloadService";
import { IPC } from "../shared/ipc";
import { remoteProjectBindingKey } from "../shared/projectIdentity";
import { resolveAdeLayout } from "../shared/adeLayout";
import { mobileProjectRepositoryIdentityFromGitOrigin } from "../shared/syncMobileProjectIdentity";
import type {
Expand Down Expand Up @@ -1178,13 +1183,16 @@ app.whenReady().then(async () => {
}
return {
kind: "remote",
key: readString(record, "key") ?? `remote:${targetId}:${projectId}`,
key: readString(record, "key") ?? remoteProjectBindingKey(targetId, projectId),
targetId,
runtimeName: readString(record, "runtimeName") ?? "Remote",
...(hostname ? { hostname } : {}),
projectId,
rootPath,
displayName: readString(record, "displayName") ?? path.basename(rootPath),
...(readString(record, "gitOriginUrl")
? { gitOriginUrl: readString(record, "gitOriginUrl") }
: {}),
// Restore the cached project logo so the tab shows it immediately on a
// cold start, before the remote reconnects and refreshes the icon.
iconDataUrl: remoteProjectIconDataUrlForPersistence(
Expand Down Expand Up @@ -1576,6 +1584,7 @@ app.whenReady().then(async () => {
key: `local:${project.rootPath}`,
rootPath: project.rootPath,
displayName: project.displayName,
gitOriginUrl: readGitOriginUrl(project.rootPath),
}
: null;

Expand Down Expand Up @@ -1791,7 +1800,7 @@ app.whenReady().then(async () => {
): void => {
const state = readGlobalState(globalStatePath);
const iconDataUrl = remoteProjectIconDataUrlForPersistence(binding.iconDataUrl);
const persistedBinding = withPersistableRemoteProjectIcon({
const persistedBinding = persistableRemoteProjectBinding({
...binding,
iconDataUrl,
});
Expand All @@ -1808,6 +1817,9 @@ app.whenReady().then(async () => {
projectId: binding.projectId,
runtimeName: binding.runtimeName,
hostname: binding.hostname || binding.runtimeName,
...(binding.gitOriginUrl
? { gitOriginUrl: binding.gitOriginUrl }
: {}),
...(iconDataUrl ? { iconDataUrl } : {}),
},
},
Expand Down
19 changes: 18 additions & 1 deletion apps/desktop/src/main/services/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ import type {
ProjectSecretSummary,
ProjectSecretValueResult,
} from "../../../shared/types";
import { toShallowRecentProjectSummary } from "../projects/recentProjectSummary";
import {
readGitOriginUrl,
toShallowRecentProjectSummary,
} from "../projects/recentProjectSummary";
import { authorizeRecentProjectRuntimeRoot } from "../projects/recentProjectRuntimeAuthorization";
import type {
ApplyConflictProposalArgs,
BatchAssessmentResult,
Expand Down Expand Up @@ -4109,6 +4113,19 @@ export function registerIpc({

const runtimeBridge = registerRuntimeBridge({
appVersion: app.getVersion(),
authorizeLocalRuntimeRoot: (session, requestedRootPath) => {
const binding = session?.binding;
const activeOrigin = binding?.gitOriginUrl
?? (binding?.kind === "local" ? readGitOriginUrl(binding.rootPath) : null)
?? (session?.project?.rootPath
? readGitOriginUrl(session.project.rootPath)
: null);
return authorizeRecentProjectRuntimeRoot({
requestedRootPath,
activeGitOriginUrl: activeOrigin,
localRecentProjects: listLocalRecentProjectSummaries(),
});
},
bindRemoteProject,
getGitHubTokenForRemoteClone: async () => {
try {
Expand Down
66 changes: 66 additions & 0 deletions apps/desktop/src/main/services/ipc/runtimeBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,70 @@ describe("registerRuntimeBridge", () => {
);
});

it("allows a caller-authorized unopened checkout without expanding path authority", async () => {
const localRuntimeConnectionPool = {
callActionForRoot: vi.fn(async () => ({
ok: true,
domain: "lane",
action: "list",
result: [],
statusHints: {},
})),
};
const authorizeLocalRuntimeRoot = vi.fn((_session, requestedRootPath: string) =>
requestedRootPath === "/same-repo" ? requestedRootPath : null
);
registerRuntimeBridge({
appVersion: "1.0.0",
globalStatePath: "/tmp/ade-state.json",
localRuntimeConnectionPool: localRuntimeConnectionPool as any,
authorizeLocalRuntimeRoot,
getWindowSession: () => ({
windowId: 7,
project: null,
binding: {
kind: "remote",
key: "remote:studio:ade",
targetId: "studio",
projectId: "ade",
rootPath: "/Users/arul/ADE",
displayName: "ADE",
runtimeName: "Studio",
hostname: "studio.local",
},
}),
});

await expect(
ipcHandlers.get(IPC.localRuntimeCallAction)?.(
eventForSender(sender(101)),
{
rootPath: "/same-repo",
request: { domain: "lane", action: "list", args: {} },
},
),
).resolves.toMatchObject({ result: [] });

expect(authorizeLocalRuntimeRoot).toHaveBeenCalledWith(
expect.objectContaining({ windowId: 7 }),
"/same-repo",
);
expect(localRuntimeConnectionPool.callActionForRoot).toHaveBeenCalledWith(
"/same-repo",
expect.objectContaining({ domain: "lane", action: "list" }),
);

await expect(
ipcHandlers.get(IPC.localRuntimeCallAction)?.(
eventForSender(sender(101)),
{
rootPath: "/different-repo",
request: { domain: "lane", action: "list", args: {} },
},
),
).rejects.toThrow(/not available/i);
});

it("rejects explicit local runtime roots that are not bound to the window session", async () => {
const localRuntimeConnectionPool = {
callActionForRoot: vi.fn(),
Expand Down Expand Up @@ -978,6 +1042,7 @@ describe("registerRuntimeBridge", () => {
projectId: "project-1",
rootPath: "/srv/ade",
displayName: "ADE",
gitOriginUrl: "git@github.com:example/ade.git",
iconDataUrl: null,
});

Expand All @@ -994,6 +1059,7 @@ describe("registerRuntimeBridge", () => {
projectId: "project-1",
rootPath: "/srv/ade",
displayName: "ADE",
gitOriginUrl: "git@github.com:example/ade.git",
iconDataUrl: null,
});
});
Expand Down
25 changes: 22 additions & 3 deletions apps/desktop/src/main/services/ipc/runtimeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { randomUUID as nodeRandomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { IPC } from "../../../shared/ipc";
import { remoteProjectBindingKey } from "../../../shared/projectIdentity";
import type {
CloneProjectInput,
CreateProjectInput,
Expand Down Expand Up @@ -88,6 +89,10 @@ type RuntimeBridgeArgs = {
openProjectTabs?: ProjectInfo[];
pendingLocalProjectRoots?: string[];
};
authorizeLocalRuntimeRoot?: (
session: WindowRuntimeSession | null | undefined,
requestedRootPath: string,
) => string | null;
bindRemoteProject?: (
windowId: number | null,
binding: OpenProjectBinding & { kind: "remote" },
Expand Down Expand Up @@ -242,11 +247,19 @@ function collectAuthorizedLocalRuntimeRoots(
function resolveAuthorizedLocalRuntimeRootPath(
session: WindowRuntimeSession | null | undefined,
requestedRootPath: string | null | undefined,
authorizeLocalRuntimeRoot?: RuntimeBridgeArgs["authorizeLocalRuntimeRoot"],
): string | null {
const roots = collectAuthorizedLocalRuntimeRoots(session);
const requested = normalizeLocalRuntimeRootPath(requestedRootPath);
if (requested) {
return roots.get(localRuntimeRootKey(requested)) ?? null;
const alreadyAuthorized = roots.get(localRuntimeRootKey(requested));
if (alreadyAuthorized) return alreadyAuthorized;
const additionallyAuthorized = authorizeLocalRuntimeRoot?.(session, requested);
const normalizedAdditional = normalizeLocalRuntimeRootPath(additionallyAuthorized);
return normalizedAdditional &&
localRuntimeRootKey(normalizedAdditional) === localRuntimeRootKey(requested)
? normalizedAdditional
: null;
}

const fallbackRoot =
Expand Down Expand Up @@ -333,6 +346,7 @@ export function getOrCreateLocalAccountMachineIdentity(args: {

export function registerRuntimeBridge({
appVersion,
authorizeLocalRuntimeRoot,
bindRemoteProject,
getGitHubTokenForRemoteClone,
getLocalMachineIdentity,
Expand Down Expand Up @@ -939,13 +953,14 @@ export function registerRuntimeBridge({

const binding: OpenProjectBinding & { kind: "remote" } = {
kind: "remote",
key: `remote:${target.id}:${project.projectId}`,
key: remoteProjectBindingKey(target.id, project.projectId),
targetId: target.id,
runtimeName: target.name,
hostname: target.hostname,
projectId: project.projectId,
rootPath: project.rootPath,
displayName: project.displayName || path.basename(project.rootPath),
gitOriginUrl: project.gitOriginUrl,
iconDataUrl: project.icon?.dataUrl ?? null,
};
if (
Expand Down Expand Up @@ -1103,6 +1118,7 @@ export function registerRuntimeBridge({
const rootPath = resolveAuthorizedLocalRuntimeRootPath(
session,
arg?.rootPath,
authorizeLocalRuntimeRoot,
);
if (!rootPath) {
throw new Error(
Expand Down Expand Up @@ -1140,6 +1156,7 @@ export function registerRuntimeBridge({
const rootPath = resolveAuthorizedLocalRuntimeRootPath(
session,
arg?.rootPath,
authorizeLocalRuntimeRoot,
);
if (!rootPath) {
throw new Error(
Expand Down Expand Up @@ -1178,6 +1195,7 @@ export function registerRuntimeBridge({
const rootPath = resolveAuthorizedLocalRuntimeRootPath(
session,
arg?.rootPath,
authorizeLocalRuntimeRoot,
);
if (!rootPath) {
throw new Error(
Expand Down Expand Up @@ -1208,6 +1226,7 @@ export function registerRuntimeBridge({
const rootPath = resolveAuthorizedLocalRuntimeRootPath(
session,
arg?.rootPath,
authorizeLocalRuntimeRoot,
);
if (!rootPath) {
throw new Error(
Expand Down Expand Up @@ -1280,7 +1299,7 @@ export function registerRuntimeBridge({
const target = remoteConnectionService.getTarget(id);
if (!target) throw new Error("Remote target was not found.");
const request = normalizeRuntimeStreamEventsRequest(arg?.request);
const bindingKey = `remote:${target.id}:${projectId}`;
const bindingKey = remoteProjectBindingKey(target.id, projectId);
const requestKey = `${bindingKey}:${request.category ?? "*"}:${request.replay === false ? "live" : "replay"}`;
const subscribe = (
onEvent: (event: RemoteRuntimeBufferedEvent, eventEpoch?: string | null) => void,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { authorizeRecentProjectRuntimeRoot } from "./recentProjectRuntimeAuthorization";

describe("authorizeRecentProjectRuntimeRoot", () => {
it("authorizes an existing recent checkout of the active repository", () => {
expect(authorizeRecentProjectRuntimeRoot({
requestedRootPath: "/Users/admin/ADE",
activeGitOriginUrl: "git@github.com:arul28/ADE.git",
localRecentProjects: [{
rootPath: "/Users/admin/ADE",
displayName: "ADE",
lastOpenedAt: "2026-07-28T00:00:00.000Z",
exists: true,
kind: "local",
gitOriginUrl: "https://github.com/arul28/ade.git",
}],
})).toBe("/Users/admin/ADE");
});

it("rejects a recent checkout from a different repository", () => {
expect(authorizeRecentProjectRuntimeRoot({
requestedRootPath: "/Users/admin/Versic",
activeGitOriginUrl: "git@github.com:arul28/ADE.git",
localRecentProjects: [{
rootPath: "/Users/admin/Versic",
displayName: "Versic",
lastOpenedAt: "2026-07-28T00:00:00.000Z",
exists: true,
kind: "local",
gitOriginUrl: "git@github.com:arul28/Versic.git",
}],
})).toBeNull();
});

it("rejects missing, origin-less, and non-recent paths", () => {
const localRecentProjects = [{
rootPath: "/Users/admin/ADE",
displayName: "ADE",
lastOpenedAt: "2026-07-28T00:00:00.000Z",
exists: false,
kind: "local" as const,
gitOriginUrl: "git@github.com:arul28/ADE.git",
}];
expect(authorizeRecentProjectRuntimeRoot({
requestedRootPath: "/Users/admin/ADE",
activeGitOriginUrl: "git@github.com:arul28/ADE.git",
localRecentProjects,
})).toBeNull();
expect(authorizeRecentProjectRuntimeRoot({
requestedRootPath: "/Users/admin/Other",
activeGitOriginUrl: "git@github.com:arul28/ADE.git",
localRecentProjects,
})).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import path from "node:path";
import { normalizeGitRemoteIdentity } from "../../../shared/crossMachineHandoff";
import type { RecentProjectSummary } from "../../../shared/types";

function runtimeRootKey(rootPath: string): string {
const resolved = path.resolve(rootPath);
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
}

export function authorizeRecentProjectRuntimeRoot(args: {
requestedRootPath: string;
activeGitOriginUrl: string | null | undefined;
localRecentProjects: RecentProjectSummary[];
}): string | null {
const requestedKey = runtimeRootKey(args.requestedRootPath);
const recent = args.localRecentProjects.find((entry) =>
entry.kind !== "remote" &&
entry.exists &&
runtimeRootKey(entry.rootPath) === requestedKey
);
const requestedIdentity = normalizeGitRemoteIdentity(recent?.gitOriginUrl);
const activeIdentity = normalizeGitRemoteIdentity(args.activeGitOriginUrl);
return recent && requestedIdentity && requestedIdentity === activeIdentity
? recent.rootPath
: null;
}
Loading