Skip to content

Commit be3fdd1

Browse files
authored
feat(runtime-host): add remote project registration (#3145)
* feat(runtime-host): add remote project registration Allow remote Desktop targets to browse Host-published directory roots and register a selected directory without exposing arbitrary Host paths to the Client. Keep local project selection on the native OS picker and route remote registration through the existing Project Catalog authority. Generated-by: OpenAI Codex * fix(desktop): bind project actions to each host Render project actions inside their owning Host group so remote registration remains reachable even when another Host is selected. Keep the remote home view focused by omitting hidden directories from enumeration. Generated-by: OpenAI Codex * fix(desktop): align remote picker with Astryx Use Astryx controls for every directory and breadcrumb action, then register the new dialog in the generated surface inventory. This keeps the remote picker at zero surface blockers. Generated-by: OpenAI Codex * fix(runtime-host): keep hidden project directories reachable Enumerate every contained directory published by the Host, including dot-prefixed names. Visibility is a presentation concern and must not silently narrow Project registration authority. Generated-by: OpenAI Codex * feat(desktop): toggle hidden remote directories Keep all Host directories reachable while hiding dot-prefixed entries by default in the Desktop picker. Users can reveal them explicitly without changing the Host authorization boundary. Generated-by: OpenAI Codex * refactor(desktop): simplify hidden folder toggle Replace the labeled switch with an accessible eye icon button while preserving the same explicit show and hide behavior. Generated-by: OpenAI Codex * refactor(desktop): simplify remote project dialog Keep the picker focused on navigation and selection. Remove explanatory implementation details that do not help users choose a project directory. Generated-by: OpenAI Codex * feat(runtime-host): configure project directory roots Preserve the service user's home directory as the default while allowing operators to replace it with a bounded, named root allowlist. Remote Clients browse only opaque published roots, and Desktop can switch between them without learning unrestricted Host paths. Generated-by: OpenAI Codex * fix(runtime-host): harden remote project registration Validate the final canonical Project path against its published root and keep pre-commit directory failures out of the Host drain path. Bound directory traversal and keep picker retries and late completions scoped to their exact Host. Generated-by: OpenAI Codex * fix(runtime-host): bound remote directory browsing Keep optional directory browsing from blocking Host startup, make published root identifiers path-independent, and bound each enumeration before sorting. Align the Desktop picker’s navigation and retry behavior with the exact Host operation. Generated-by: OpenAI Codex * fix(runtime-host): clarify directory validation Report non-directory selections separately from root escapes, and keep root identity tests focused on the protocol's uniqueness contract instead of its current allocation format. Generated-by: OpenAI Codex
1 parent 5d9ce0d commit be3fdd1

40 files changed

Lines changed: 1534 additions & 66 deletions

‎apps/desktop/src/main/__tests__/app-shell-project-actions.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ test('remote Project capabilities do not dispatch Client-local actions', async (
4343
projects: [],
4444
projectCapabilities: {
4545
chooseClientDirectory: false,
46+
chooseHostDirectory: false,
4647
selectNoProject: false,
4748
setLocalDefault: false,
4849
viewClientPath: false,

‎apps/desktop/src/main/__tests__/project-management-service.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ import {
1515

1616
const LOCAL_CAPABILITIES = {
1717
chooseClientDirectory: true,
18+
chooseHostDirectory: false,
1819
selectNoProject: true,
1920
setLocalDefault: true,
2021
viewClientPath: true,
2122
} as const;
2223
const REMOTE_CAPABILITIES = {
2324
chooseClientDirectory: false,
25+
chooseHostDirectory: true,
2426
selectNoProject: false,
2527
setLocalDefault: false,
2628
viewClientPath: false,
@@ -215,6 +217,7 @@ test('does not silently replace a stale Project preference with another Project'
215217

216218
test('does not expose Client directory actions for a remote Host', async () => {
217219
let pickerCalls = 0;
220+
const directoryRequests: unknown[] = [];
218221
const service = createProjectManagementService({
219222
capabilities: REMOTE_CAPABILITIES,
220223
catalog: {
@@ -232,6 +235,17 @@ test('does not expose Client directory actions for a remote Host', async () => {
232235
archive: unexpected,
233236
restore: unexpected,
234237
},
238+
directoryCatalog: {
239+
listDirectoryRoots: async () => [{ id: 'home', label: '~' }],
240+
listDirectories: async (rootId, segments) => {
241+
directoryRequests.push({ rootId, segments });
242+
return [{ name: 'project' }];
243+
},
244+
registerDirectory: async (rootId, segments) => {
245+
directoryRequests.push({ rootId, segments, register: true });
246+
return { id: 'added', name: 'Added', locations: [], available: true };
247+
},
248+
},
235249
chooseDirectory: async () => {
236250
pickerCalls += 1;
237251
return '/client/path';
@@ -257,6 +271,19 @@ test('does not expose Client directory actions for a remote Host', async () => {
257271
path: '/host/project',
258272
});
259273
assert.equal(pickerCalls, 0);
274+
assert.deepEqual(await service.directoryRoots(), [{ id: 'home', label: '~' }]);
275+
assert.deepEqual(
276+
await service.listDirectory({ rootId: 'home', segments: ['work'] }),
277+
[{ name: 'project' }],
278+
);
279+
assert.equal(
280+
(await service.registerDirectory({ rootId: 'home', segments: ['work', 'project'] })).id,
281+
'added',
282+
);
283+
assert.deepEqual(directoryRequests, [
284+
{ rootId: 'home', segments: ['work'] },
285+
{ rootId: 'home', segments: ['work', 'project'], register: true },
286+
]);
260287
});
261288

262289
function managementCatalog(catalog: ProjectCatalog): ProjectManagementCatalog {

‎apps/desktop/src/main/app-ipc-main.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ export function registerAppIpc(
115115
);
116116
targetIpc.handle('projects:add', (_event, options?: { select?: unknown }) =>
117117
deps.projectManagement.add({ select: options?.select !== false }));
118+
handleReconnectableRead(targetIpc, 'projects:directoryRoots', () =>
119+
deps.projectManagement.directoryRoots());
120+
handleReconnectableRead(targetIpc, 'projects:listDirectory', (_event, input: unknown) =>
121+
deps.projectManagement.listDirectory(input));
122+
targetIpc.handle('projects:registerDirectory', (_event, input: unknown) =>
123+
deps.projectManagement.registerDirectory(input));
118124
targetIpc.handle('projects:select', (_event, projectId: unknown) =>
119125
deps.projectManagement.select(projectId));
120126
targetIpc.handle('projects:relink', (_event, projectId: unknown) =>

‎apps/desktop/src/main/project-management-service.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { findProjectByIdentity, type ProjectRecord } from '@maka/core/project';
22
import type {
33
DesktopProjectCapabilities,
4+
DesktopProjectDirectoryEntry,
5+
DesktopProjectDirectoryRoot,
46
DesktopProjectSnapshot,
57
} from '../preload/bridge-contract.js';
68
import type { CurrentProjectSelection } from './project-root-controller.js';
@@ -20,6 +22,9 @@ export interface ProjectManagementService {
2022
projectId: unknown,
2123
): Promise<{ project: ProjectRecord | null; path: string }>;
2224
relink(projectId: unknown): Promise<DirectoryActionResult>;
25+
directoryRoots(): Promise<readonly DesktopProjectDirectoryRoot[]>;
26+
listDirectory(input: unknown): Promise<readonly DesktopProjectDirectoryEntry[]>;
27+
registerDirectory(input: unknown): Promise<ProjectRecord>;
2328
/**
2429
* The on-disk path of a catalogued project, for surfaces that need to open
2530
* it. Returns null when the project is unknown, archived, or its folder is
@@ -42,8 +47,18 @@ export interface ProjectManagementCatalog {
4247
restore(projectId: string): Promise<ProjectRecord>;
4348
}
4449

50+
export interface ProjectDirectoryCatalog {
51+
listDirectoryRoots(): Promise<readonly DesktopProjectDirectoryRoot[]>;
52+
listDirectories(
53+
rootId: string,
54+
segments: readonly string[],
55+
): Promise<readonly DesktopProjectDirectoryEntry[]>;
56+
registerDirectory(rootId: string, segments: readonly string[]): Promise<ProjectRecord>;
57+
}
58+
4559
export function createProjectManagementService(deps: {
4660
catalog: ProjectManagementCatalog;
61+
directoryCatalog?: ProjectDirectoryCatalog;
4762
chooseDirectory(): Promise<string | undefined>;
4863
selection: {
4964
currentSelection(): Promise<CurrentProjectSelection>;
@@ -130,6 +145,26 @@ export function createProjectManagementService(deps: {
130145
return { ok: true, project };
131146
},
132147

148+
directoryRoots() {
149+
return requireHostDirectoryActions(deps).listDirectoryRoots();
150+
},
151+
152+
listDirectory(input) {
153+
const directory = requireDirectoryInput(input);
154+
return requireHostDirectoryActions(deps).listDirectories(
155+
directory.rootId,
156+
directory.segments,
157+
);
158+
},
159+
160+
registerDirectory(input) {
161+
const directory = requireDirectoryInput(input);
162+
return requireHostDirectoryActions(deps).registerDirectory(
163+
directory.rootId,
164+
directory.segments,
165+
);
166+
},
167+
133168
async pathFor(projectId) {
134169
if (!deps.capabilities.viewClientPath) return null;
135170
const id = requireProjectId(projectId);
@@ -155,6 +190,36 @@ export function createProjectManagementService(deps: {
155190
};
156191
}
157192

193+
function requireHostDirectoryActions(
194+
deps: {
195+
readonly capabilities: DesktopProjectCapabilities;
196+
readonly directoryCatalog?: ProjectDirectoryCatalog;
197+
},
198+
): ProjectDirectoryCatalog {
199+
if (!deps.capabilities.chooseHostDirectory || !deps.directoryCatalog) {
200+
throw new Error('This Runtime Host does not publish project directories');
201+
}
202+
return deps.directoryCatalog;
203+
}
204+
205+
function requireDirectoryInput(value: unknown): {
206+
readonly rootId: string;
207+
readonly segments: readonly string[];
208+
} {
209+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
210+
throw new TypeError('Invalid project directory');
211+
}
212+
const input = value as { rootId?: unknown; segments?: unknown };
213+
if (
214+
typeof input.rootId !== 'string' ||
215+
!Array.isArray(input.segments) ||
216+
!input.segments.every((segment) => typeof segment === 'string')
217+
) {
218+
throw new TypeError('Invalid project directory');
219+
}
220+
return { rootId: input.rootId, segments: input.segments };
221+
}
222+
158223
function requireLocalDirectoryActions(
159224
deps: { readonly capabilities: DesktopProjectCapabilities },
160225
): void {

‎apps/desktop/src/main/runtime-host-boot.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,6 +751,7 @@ function registerHostClientIpc(
751751
}));
752752
const targetProjectManagement = createProjectManagementService({
753753
catalog: targetProjectCatalog,
754+
directoryCatalog: targetProjectCatalog,
754755
chooseDirectory: async () => {
755756
const result = await mainWindowController.showOpenDialog({
756757
title: "Add project",
@@ -762,12 +763,14 @@ function registerHostClientIpc(
762763
capabilities: target.kind === "local"
763764
? {
764765
chooseClientDirectory: true,
766+
chooseHostDirectory: false,
765767
selectNoProject: true,
766768
setLocalDefault: true,
767769
viewClientPath: true,
768770
}
769771
: {
770772
chooseClientDirectory: false,
773+
chooseHostDirectory: true,
771774
selectNoProject: false,
772775
setLocalDefault: false,
773776
viewClientPath: false,

‎apps/desktop/src/main/runtime-host-client.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ import {
6666
type ProjectCatalogMutateResult,
6767
type ProjectCatalogProject,
6868
type ProjectCatalogProjectDetails,
69+
PROJECT_DIRECTORY_MAX_ENTRIES,
70+
type ProjectDirectoryEntry,
71+
type ProjectDirectoryRoot,
6972
type QueueRetractInput,
7073
type QueueRetractResult,
7174
type SessionCatalogFilter,
@@ -564,6 +567,52 @@ export class DesktopRuntimeHostClient {
564567
return this.#projectForMutation(result);
565568
}
566569

570+
async listProjectDirectoryRoots(): Promise<readonly ProjectDirectoryRoot[]> {
571+
const result = await this.request("project.catalog.query", { kind: "directory_roots" });
572+
if (result.kind !== "directory_roots") throw invalidProjection("Project directory roots");
573+
return result.roots;
574+
}
575+
576+
async listProjectDirectories(
577+
rootId: string,
578+
segments: readonly string[],
579+
): Promise<readonly ProjectDirectoryEntry[]> {
580+
const entries: ProjectDirectoryEntry[] = [];
581+
let result = await this.request(
582+
"project.catalog.query",
583+
{ kind: "directory_list_start", rootId, segments },
584+
);
585+
const cursors = new Set<string>();
586+
while (true) {
587+
if (result.kind !== "directory_page") throw invalidProjection("Project directory");
588+
if (result.rootId !== rootId || !sameSegments(result.segments, segments)) {
589+
throw invalidProjection("Project directory identity");
590+
}
591+
if (entries.length + result.entries.length > PROJECT_DIRECTORY_MAX_ENTRIES) {
592+
throw invalidProjection("Project directory has too many entries");
593+
}
594+
entries.push(...result.entries);
595+
if (result.nextCursor === null) return entries;
596+
if (cursors.has(result.nextCursor)) throw repeatedCursor("Project directory");
597+
cursors.add(result.nextCursor);
598+
result = await this.request("project.catalog.query", {
599+
kind: "directory_list_continue",
600+
rootId,
601+
segments,
602+
cursor: result.nextCursor,
603+
});
604+
}
605+
}
606+
607+
async registerProjectDirectory(
608+
rootId: string,
609+
segments: readonly string[],
610+
): Promise<ProjectCatalogProject> {
611+
return this.#projectForMutation(
612+
await this.#mutateProject({ kind: "register_directory", rootId, segments }),
613+
);
614+
}
615+
567616
async relinkProject(projectId: string, path: string): Promise<ProjectCatalogProject> {
568617
return this.#projectForMutation(
569618
await this.#mutateProject({ kind: "relink", projectId, path }),
@@ -1681,6 +1730,10 @@ function repeatedCursor(name: string): DesktopRuntimeHostClientError {
16811730
);
16821731
}
16831732

1733+
function sameSegments(left: readonly string[], right: readonly string[]): boolean {
1734+
return left.length === right.length && left.every((segment, index) => segment === right[index]);
1735+
}
1736+
16841737
function unstableProjection(
16851738
name: string,
16861739
sessionId: string,

‎apps/desktop/src/main/runtime-host-project-catalog.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,22 @@ import type {
33
ProjectCatalogProject,
44
ProjectCatalogProjectDetails,
55
} from "@maka/runtime-host/protocol";
6-
import type { ProjectManagementCatalog } from "./project-management-service.js";
6+
import type {
7+
ProjectDirectoryCatalog,
8+
ProjectManagementCatalog,
9+
} from "./project-management-service.js";
710
import type { DesktopRuntimeHostClient } from "./runtime-host-client.js";
811

9-
export interface DesktopProjectCatalog extends ProjectManagementCatalog {}
12+
export interface DesktopProjectCatalog extends ProjectManagementCatalog, ProjectDirectoryCatalog {}
1013

1114
type RuntimeHostProjectClient = Pick<
1215
DesktopRuntimeHostClient,
1316
| "archiveProject"
1417
| "listProjects"
18+
| "listProjectDirectories"
19+
| "listProjectDirectoryRoots"
1520
| "registerProject"
21+
| "registerProjectDirectory"
1622
| "relinkProject"
1723
| "renameProject"
1824
| "restoreProject"
@@ -33,6 +39,16 @@ export function createRuntimeHostProjectCatalog(
3339
const target = resolveTarget();
3440
return projectRecord(target, await target.client.registerProject(path));
3541
},
42+
listDirectoryRoots: async () => resolveTarget().client.listProjectDirectoryRoots(),
43+
listDirectories: async (rootId, segments) =>
44+
resolveTarget().client.listProjectDirectories(rootId, segments),
45+
registerDirectory: async (rootId, segments) => {
46+
const target = resolveTarget();
47+
return projectRecord(
48+
target,
49+
await target.client.registerProjectDirectory(rootId, segments),
50+
);
51+
},
3652
relink: async (projectId, path) => {
3753
const target = resolveTarget();
3854
return projectRecord(target, await target.client.relinkProject(projectId, path));

‎apps/desktop/src/preload/bridge-contract.d.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,21 @@ export type DesktopRuntimeHostSshTerminalSnapshot =
328328

329329
export interface DesktopProjectCapabilities {
330330
readonly chooseClientDirectory: boolean;
331+
readonly chooseHostDirectory: boolean;
331332
readonly selectNoProject: boolean;
332333
readonly setLocalDefault: boolean;
333334
readonly viewClientPath: boolean;
334335
}
335336

337+
export interface DesktopProjectDirectoryRoot {
338+
readonly id: string;
339+
readonly label: string;
340+
}
341+
342+
export interface DesktopProjectDirectoryEntry {
343+
readonly name: string;
344+
}
345+
336346
export interface DesktopProjectSnapshot {
337347
readonly projects: readonly ProjectRecord[];
338348
readonly capabilities: DesktopProjectCapabilities;
@@ -643,6 +653,15 @@ export interface MakaBridge {
643653
add(host?: DesktopRuntimeHostRef): Promise<
644654
{ ok: true; project: ProjectRecord; path: string } | { ok: false; reason: 'cancelled' }
645655
>;
656+
getDirectoryRoots(host: DesktopRuntimeHostRef): Promise<readonly DesktopProjectDirectoryRoot[]>;
657+
listDirectory(
658+
input: { readonly rootId: string; readonly segments: readonly string[] },
659+
host: DesktopRuntimeHostRef,
660+
): Promise<readonly DesktopProjectDirectoryEntry[]>;
661+
registerDirectory(
662+
input: { readonly rootId: string; readonly segments: readonly string[] },
663+
host: DesktopRuntimeHostRef,
664+
): Promise<ProjectRecord>;
646665
select(
647666
projectId: string | null,
648667
host?: DesktopRuntimeHostRef,

‎apps/desktop/src/preload/preload.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,6 +1735,21 @@ const makaBridge = {
17351735
> {
17361736
return invokeSelectedRuntimeHost(host, 'projects:add');
17371737
},
1738+
getDirectoryRoots(host: DesktopRuntimeHostRef) {
1739+
return invokeSelectedRuntimeHost(host, 'projects:directoryRoots');
1740+
},
1741+
listDirectory(
1742+
input: { readonly rootId: string; readonly segments: readonly string[] },
1743+
host: DesktopRuntimeHostRef,
1744+
) {
1745+
return invokeSelectedRuntimeHost(host, 'projects:listDirectory', input);
1746+
},
1747+
registerDirectory(
1748+
input: { readonly rootId: string; readonly segments: readonly string[] },
1749+
host: DesktopRuntimeHostRef,
1750+
) {
1751+
return invokeSelectedRuntimeHost(host, 'projects:registerDirectory', input);
1752+
},
17381753
select(
17391754
projectId: string | null,
17401755
host?: DesktopRuntimeHostRef,

0 commit comments

Comments
 (0)