From f4c5d22f22cea00a08043e1ea10255a3ee845022 Mon Sep 17 00:00:00 2001 From: Denis Redozubov Date: Sat, 30 May 2026 18:57:04 +0200 Subject: [PATCH] fix(daemon): confine sandbox project roots and host discovery (#3243) * fix(daemon): confine sandbox project and host discovery * fix(daemon): resolve sandbox data dir for toolchain discovery * fix(daemon): resolve sandbox data dir for agent env * fix(daemon): fail fast for sandbox imported folders * test(daemon): assert sandbox imported folder rejection * fix(daemon): keep sandbox import guard at run start * fix(daemon): reject sandbox imported project file roots * fix(daemon): preserve imported project detail roots * test(daemon): expect sandbox profiles to stay scoped * fix(daemon): bypass proxies for agent tool callbacks * test(daemon): isolate media policy route memory extraction * fix(daemon): keep loopback no-proxy scoped to sandbox --- apps/daemon/src/import-export-routes.ts | 14 +++ apps/daemon/src/media-config.ts | 4 + apps/daemon/src/project-root.ts | 23 ++++ apps/daemon/src/project-routes.ts | 22 +++- apps/daemon/src/projects.ts | 42 ++++++- apps/daemon/src/runtimes/env.ts | 13 ++- apps/daemon/src/runtimes/local-profiles.ts | 6 +- apps/daemon/src/server.ts | 33 +++--- apps/daemon/tests/agent-runtime-env.test.ts | 13 +++ .../tests/folder-import-projects.test.ts | 35 +++++- apps/daemon/tests/folder-import-route.test.ts | 105 ++++++++++++++++++ apps/daemon/tests/media-config.test.ts | 31 ++++++ apps/daemon/tests/media-policy-routes.test.ts | 22 +++- apps/daemon/tests/projects-routes.test.ts | 40 +++++++ .../tests/runtimes/env-and-detection.test.ts | 30 +++++ .../daemon/tests/runtimes/executables.test.ts | 49 +++++++- .../tests/runtimes/registry-and-args.test.ts | 25 +++++ 17 files changed, 474 insertions(+), 33 deletions(-) create mode 100644 apps/daemon/src/project-root.ts diff --git a/apps/daemon/src/import-export-routes.ts b/apps/daemon/src/import-export-routes.ts index 15f9c6c90..5e615f570 100644 --- a/apps/daemon/src/import-export-routes.ts +++ b/apps/daemon/src/import-export-routes.ts @@ -6,6 +6,7 @@ import { inlineRelativeAssets, type InlineAssetReader, } from './inline-assets.js'; +import { isSandboxModeEnabled } from './sandbox-mode.js'; export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {} @@ -28,6 +29,11 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps const { insertConversation } = ctx.conversations; const { setTabs } = ctx.projectFiles; const { validateProjectDesignSystemId } = ctx.validation; + const rejectSandboxFolderImport = () => + isSandboxModeEnabled(process.env) + ? 'folder imports are disabled when OD_SANDBOX_MODE is enabled' + : null; + app.post( '/api/import/claude-design', importUpload.single('file'), @@ -107,6 +113,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps if (typeof baseDir !== 'string' || !baseDir.trim()) { return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required'); } + const sandboxReason = rejectSandboxFolderImport(); + if (sandboxReason) { + return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason); + } let trustedPickerImport = false; if (isDesktopAuthGateActive()) { const secret = desktopAuthSecret(); @@ -204,6 +214,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps if (typeof baseDir !== 'string' || !baseDir.trim()) { return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required'); } + const sandboxReason = rejectSandboxFolderImport(); + if (sandboxReason) { + return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason); + } let trustedPickerImport = false; if (isDesktopAuthGateActive()) { const secret = desktopAuthSecret(); diff --git a/apps/daemon/src/media-config.ts b/apps/daemon/src/media-config.ts index cb35381b2..466f77b99 100644 --- a/apps/daemon/src/media-config.ts +++ b/apps/daemon/src/media-config.ts @@ -41,6 +41,7 @@ import path from 'node:path'; import { MEDIA_PROVIDERS } from './media-models.js'; import { expandHomePrefix } from './home-expansion.js'; import { resolveXAIBearer } from './xai-credentials.js'; +import { isSandboxModeEnabled } from './sandbox-mode.js'; const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id); type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string }; @@ -291,6 +292,7 @@ function apiKeyFromCodexAuth(data: unknown): string { } async function resolveOpenAIAuthFileCredential(): Promise { + if (isSandboxModeEnabled(process.env)) return null; const home = os.homedir(); const codexAuth = await readJsonIfPresent( path.join(home, '.codex', 'auth.json'), @@ -318,6 +320,8 @@ async function resolveXAIOAuthCredential( }; } + if (isSandboxModeEnabled(process.env)) return null; + // 2. Borrow the xAI OAuth token Hermes wrote to ~/.hermes/auth.json // when the user ran `hermes auth add xai-oauth`. A user who has already authorized // Hermes doesn't have to run a second OAuth dance inside OD. diff --git a/apps/daemon/src/project-root.ts b/apps/daemon/src/project-root.ts new file mode 100644 index 000000000..26693047e --- /dev/null +++ b/apps/daemon/src/project-root.ts @@ -0,0 +1,23 @@ +import path from 'node:path'; + +export function resolveProjectRoot(moduleDir: string): string { + const base = path.basename(moduleDir); + const daemonDir = + base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir; + return path.resolve(daemonDir, '../..'); +} + +export function resolveProjectRootFromNestedModule(moduleDir: string): string { + let current = path.resolve(moduleDir); + while (true) { + const base = path.basename(current); + if (base === 'dist' || base === 'src') { + return resolveProjectRoot(current); + } + const parent = path.dirname(current); + if (parent === current) { + return resolveProjectRoot(moduleDir); + } + current = parent; + } +} diff --git a/apps/daemon/src/project-routes.ts b/apps/daemon/src/project-routes.ts index 48fbc8e7f..c8bafd70e 100644 --- a/apps/daemon/src/project-routes.ts +++ b/apps/daemon/src/project-routes.ts @@ -1,4 +1,5 @@ import type { Express } from 'express'; +import path from 'node:path'; import { defaultScenarioPluginIdForProjectMetadata, type PluginManifest, @@ -21,6 +22,25 @@ import { auditDesignSystemPackage } from './tools-connectors-cli.js'; export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {} +function projectDetailResolvedDir( + projectsRoot: string, + project: any, + resolveProjectDir: ( + projectsRoot: string, + projectId: string, + metadata?: unknown, + opts?: { allowUnavailableSandboxImportedProject?: boolean }, + ) => string, +): string { + const baseDir = typeof project?.metadata?.baseDir === 'string' + ? path.normalize(project.metadata.baseDir) + : null; + if (baseDir && path.isAbsolute(baseDir)) return baseDir; + return resolveProjectDir(projectsRoot, project.id, project.metadata, { + allowUnavailableSandboxImportedProject: true, + }); +} + const URL_PREVIEW_SCROLL_BRIDGE = `