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
This commit is contained in:
Denis Redozubov 2026-05-30 18:57:04 +02:00 committed by GitHub
parent 9a3424d68c
commit f4c5d22f22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 474 additions and 33 deletions

View file

@ -6,6 +6,7 @@ import {
inlineRelativeAssets, inlineRelativeAssets,
type InlineAssetReader, type InlineAssetReader,
} from './inline-assets.js'; } 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'> {} 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 { insertConversation } = ctx.conversations;
const { setTabs } = ctx.projectFiles; const { setTabs } = ctx.projectFiles;
const { validateProjectDesignSystemId } = ctx.validation; const { validateProjectDesignSystemId } = ctx.validation;
const rejectSandboxFolderImport = () =>
isSandboxModeEnabled(process.env)
? 'folder imports are disabled when OD_SANDBOX_MODE is enabled'
: null;
app.post( app.post(
'/api/import/claude-design', '/api/import/claude-design',
importUpload.single('file'), importUpload.single('file'),
@ -107,6 +113,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
if (typeof baseDir !== 'string' || !baseDir.trim()) { if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required'); return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
} }
const sandboxReason = rejectSandboxFolderImport();
if (sandboxReason) {
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
}
let trustedPickerImport = false; let trustedPickerImport = false;
if (isDesktopAuthGateActive()) { if (isDesktopAuthGateActive()) {
const secret = desktopAuthSecret(); const secret = desktopAuthSecret();
@ -204,6 +214,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
if (typeof baseDir !== 'string' || !baseDir.trim()) { if (typeof baseDir !== 'string' || !baseDir.trim()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required'); return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
} }
const sandboxReason = rejectSandboxFolderImport();
if (sandboxReason) {
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
}
let trustedPickerImport = false; let trustedPickerImport = false;
if (isDesktopAuthGateActive()) { if (isDesktopAuthGateActive()) {
const secret = desktopAuthSecret(); const secret = desktopAuthSecret();

View file

@ -41,6 +41,7 @@ import path from 'node:path';
import { MEDIA_PROVIDERS } from './media-models.js'; import { MEDIA_PROVIDERS } from './media-models.js';
import { expandHomePrefix } from './home-expansion.js'; import { expandHomePrefix } from './home-expansion.js';
import { resolveXAIBearer } from './xai-credentials.js'; import { resolveXAIBearer } from './xai-credentials.js';
import { isSandboxModeEnabled } from './sandbox-mode.js';
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id); const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string }; type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string };
@ -291,6 +292,7 @@ function apiKeyFromCodexAuth(data: unknown): string {
} }
async function resolveOpenAIAuthFileCredential(): Promise<OAuthCredential | null> { async function resolveOpenAIAuthFileCredential(): Promise<OAuthCredential | null> {
if (isSandboxModeEnabled(process.env)) return null;
const home = os.homedir(); const home = os.homedir();
const codexAuth = await readJsonIfPresent( const codexAuth = await readJsonIfPresent(
path.join(home, '.codex', 'auth.json'), 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 // 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 // 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. // Hermes doesn't have to run a second OAuth dance inside OD.

View file

@ -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;
}
}

View file

@ -1,4 +1,5 @@
import type { Express } from 'express'; import type { Express } from 'express';
import path from 'node:path';
import { import {
defaultScenarioPluginIdForProjectMetadata, defaultScenarioPluginIdForProjectMetadata,
type PluginManifest, 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'> {} 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 = `<script data-od-url-scroll-bridge> const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
(function(){ (function(){
if (window.__odUrlScrollBridge) return; if (window.__odUrlScrollBridge) return;
@ -419,7 +439,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
const project = getProject(db, req.params.id); const project = getProject(db, req.params.id);
if (!project) if (!project)
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found'); return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata); const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
/** @type {import('@open-design/contracts').ProjectResponse} */ /** @type {import('@open-design/contracts').ProjectResponse} */
const body = { project, resolvedDir }; const body = { project, resolvedDir };
res.json(body); res.json(body);

View file

@ -26,6 +26,7 @@ import {
isPublicationGuardedArtifactKind, isPublicationGuardedArtifactKind,
} from './artifact-publication-guard.js'; } from './artifact-publication-guard.js';
import { isIgnoredProjectDirName } from './project-ignored-dirs.js'; import { isIgnoredProjectDirName } from './project-ignored-dirs.js';
import { isSandboxModeEnabled } from './sandbox-mode.js';
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/; const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']); const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
@ -40,13 +41,42 @@ export function projectDir(projectsRoot, projectId) {
return path.join(projectsRoot, projectId); return path.join(projectsRoot, projectId);
} }
export class SandboxImportedProjectError extends Error {
code = 'SANDBOX_IMPORTED_PROJECT_UNAVAILABLE';
constructor() {
super(
'Imported-folder projects are not available in OD_SANDBOX_MODE until their files are mirrored into the managed project directory.',
);
this.name = 'SandboxImportedProjectError';
}
}
function hasExternalProjectRoot(metadata?) {
if (typeof metadata?.baseDir !== 'string') return false;
return path.isAbsolute(path.normalize(metadata.baseDir));
}
export function assertSandboxProjectRootAvailable(metadata?) {
if (isSandboxModeEnabled(process.env) && hasExternalProjectRoot(metadata)) {
throw new SandboxImportedProjectError();
}
}
function usesExternalProjectRoot(metadata?) {
if (isSandboxModeEnabled(process.env)) return false;
return hasExternalProjectRoot(metadata);
}
// Returns the folder a project's files live in. For git-linked projects // Returns the folder a project's files live in. For git-linked projects
// (metadata.baseDir set), this is the user's own folder. Otherwise falls // (metadata.baseDir set), this is the user's own folder. Otherwise falls
// back to the standard computed path under projectsRoot. // back to the standard computed path under projectsRoot.
export function resolveProjectDir(projectsRoot, projectId, metadata?) { export function resolveProjectDir(projectsRoot, projectId, metadata?, opts = {}) {
if (typeof metadata?.baseDir === 'string') { if (!opts.allowUnavailableSandboxImportedProject) {
const p = path.normalize(metadata.baseDir); assertSandboxProjectRootAvailable(metadata);
if (path.isAbsolute(p)) return p; }
if (usesExternalProjectRoot(metadata)) {
return path.normalize(metadata.baseDir);
} }
if (!isSafeId(projectId)) throw new Error('invalid project id'); if (!isSafeId(projectId)) throw new Error('invalid project id');
return path.join(projectsRoot, projectId); return path.join(projectsRoot, projectId);
@ -55,7 +85,7 @@ export function resolveProjectDir(projectsRoot, projectId, metadata?) {
export async function ensureProject(projectsRoot, projectId, metadata?) { export async function ensureProject(projectsRoot, projectId, metadata?) {
const dir = resolveProjectDir(projectsRoot, projectId, metadata); const dir = resolveProjectDir(projectsRoot, projectId, metadata);
// Git-linked folders already exist; skip mkdir to avoid side-effects. // Git-linked folders already exist; skip mkdir to avoid side-effects.
if (typeof metadata?.baseDir !== 'string') { if (!usesExternalProjectRoot(metadata)) {
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
} }
return dir; return dir;
@ -67,7 +97,7 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
const out = []; const out = [];
// Skip build/install dirs for linked folders so node_modules doesn't stall // Skip build/install dirs for linked folders so node_modules doesn't stall
// the walk on large repos. // the walk on large repos.
const skipDirs = metadata?.baseDir ? isIgnoredProjectDirName : undefined; const skipDirs = usesExternalProjectRoot(metadata) ? isIgnoredProjectDirName : undefined;
await collectFiles(dir, '', out, skipDirs, dir); await collectFiles(dir, '', out, skipDirs, dir);
// Newest first — matches the visual order users expect after generating. // Newest first — matches the visual order users expect after generating.
out.sort((a, b) => b.mtime - a.mtime); out.sort((a, b) => b.mtime - a.mtime);

View file

@ -1,9 +1,12 @@
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform'; import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
import { resolveProjectRelativePath } from '../home-expansion.js';
import { expandConfiguredEnv } from './paths.js'; import { expandConfiguredEnv } from './paths.js';
import { resolveAmrOpenCodeExecutable } from './executables.js'; import { resolveAmrOpenCodeExecutable } from './executables.js';
import { amrVelaProfileEnv } from '../integrations/vela-profile.js'; import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
import { resolveProjectRootFromNestedModule } from '../project-root.js';
import { import {
applySandboxRuntimeEnv, applySandboxRuntimeEnv,
isSandboxModeEnabled, isSandboxModeEnabled,
@ -13,6 +16,10 @@ import {
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>; type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
path.dirname(fileURLToPath(import.meta.url)),
);
// Build the env passed to spawn() for a given agent adapter. // Build the env passed to spawn() for a given agent adapter.
// //
// The claude adapter strips ANTHROPIC_API_KEY so Claude Code's own auth // The claude adapter strips ANTHROPIC_API_KEY so Claude Code's own auth
@ -87,7 +94,11 @@ function sandboxRuntimeConfigForBaseEnv(
if (!isSandboxModeEnabled(baseEnv)) return null; if (!isSandboxModeEnabled(baseEnv)) return null;
const dataDir = baseEnv.OD_DATA_DIR?.trim(); const dataDir = baseEnv.OD_DATA_DIR?.trim();
if (!dataDir) return null; if (!dataDir) return null;
return resolveSandboxRuntimeConfig(true, dataDir); const resolvedDataDir = resolveProjectRelativePath(
dataDir,
RUNTIME_MODULE_PROJECT_ROOT,
);
return resolveSandboxRuntimeConfig(true, resolvedDataDir);
} }
function reapplySandboxRuntimeEnv( function reapplySandboxRuntimeEnv(

View file

@ -192,11 +192,11 @@ function createLocalAgentDef(
export function readLocalAgentProfileDefs( export function readLocalAgentProfileDefs(
baseDefs: RuntimeAgentDef[], baseDefs: RuntimeAgentDef[],
): RuntimeAgentDef[] { ): RuntimeAgentDef[] {
const profilesFile = localAgentProfilesFile();
if (profilesFile == null) return [];
let parsed: unknown; let parsed: unknown;
try { try {
const file = localAgentProfilesFile(); parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
if (!file) return [];
parsed = JSON.parse(readFileSync(file, 'utf8'));
} catch { } catch {
return []; return [];
} }

View file

@ -25,7 +25,10 @@ import {
shouldRenderCodexImagegenOverride, shouldRenderCodexImagegenOverride,
} from './prompts/system.js'; } from './prompts/system.js';
import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js'; import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js';
import { resolveProjectRoot } from './project-root.js';
import { userFacingAgentLabel } from './user-facing-agent-label.js'; import { userFacingAgentLabel } from './user-facing-agent-label.js';
export { resolveProjectRoot };
import { createCommandInvocation } from '@open-design/platform'; import { createCommandInvocation } from '@open-design/platform';
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto'; import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
import { import {
@ -258,6 +261,7 @@ import {
type ObservabilityEventRequest, type ObservabilityEventRequest,
} from '@open-design/contracts/analytics'; } from '@open-design/contracts/analytics';
import { import {
mergeNoProxyWithLoopbackDefaults,
redactSecrets, redactSecrets,
testAgentConnection, testAgentConnection,
testProviderConnection, testProviderConnection,
@ -341,6 +345,7 @@ import {
buildBatchArchive, buildBatchArchive,
decodeMultipartFilename, decodeMultipartFilename,
deleteProjectFile, deleteProjectFile,
assertSandboxProjectRootAvailable,
detectEntryFile, detectEntryFile,
ensureProject, ensureProject,
isSafeId, isSafeId,
@ -352,6 +357,7 @@ import {
renameProjectFile, renameProjectFile,
removeProjectDir, removeProjectDir,
resolveProjectDir, resolveProjectDir,
SandboxImportedProjectError,
sanitizeName, sanitizeName,
searchProjectFiles, searchProjectFiles,
resolveProjectDir, resolveProjectDir,
@ -482,13 +488,6 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const DAEMON_CLI_PATH_ENV = 'OD_DAEMON_CLI_PATH'; const DAEMON_CLI_PATH_ENV = 'OD_DAEMON_CLI_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, '../..');
}
function cleanOptionalPath(value: string | undefined): string | null { function cleanOptionalPath(value: string | undefined): string | null {
return typeof value === 'string' && value.trim().length > 0 return typeof value === 'string' && value.trim().length > 0
? path.resolve(value) ? path.resolve(value)
@ -1653,6 +1652,13 @@ export function createAgentRuntimeEnv(
if (typeof sidecarIpcPath === 'string' && sidecarIpcPath.length > 0) { if (typeof sidecarIpcPath === 'string' && sidecarIpcPath.length > 0) {
env[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath; env[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
} }
if (SANDBOX_RUNTIME.enabled) {
const noProxy = mergeNoProxyWithLoopbackDefaults(env.NO_PROXY ?? env.no_proxy);
if (noProxy) {
env.NO_PROXY = noProxy;
if (process.platform !== 'win32') env.no_proxy = noProxy;
}
}
// Ensure the node binary directory is on PATH so agent sub-processes — // Ensure the node binary directory is on PATH so agent sub-processes —
// in particular npm .cmd shims on Windows that run `"node" script.js` — // in particular npm .cmd shims on Windows that run `"node" script.js` —
@ -10777,14 +10783,13 @@ export async function startServer({
try { try {
const chatProject = getProject(db, projectId); const chatProject = getProject(db, projectId);
const chatMeta = chatProject?.metadata; const chatMeta = chatProject?.metadata;
if (chatMeta?.baseDir) { assertSandboxProjectRootAvailable(chatMeta);
cwd = path.normalize(chatMeta.baseDir); cwd = await ensureProject(PROJECTS_DIR, projectId, chatMeta);
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta }); existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta });
} else { } catch (err) {
cwd = await ensureProject(PROJECTS_DIR, projectId); if (err instanceof SandboxImportedProjectError) {
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId); return design.runs.fail(run, 'BAD_REQUEST', err.message);
} }
} catch {
cwd = null; cwd = null;
} }
} }

View file

@ -87,6 +87,19 @@ describe('agent runtime tool environment', () => {
expect(env.OD_DATA_DIR).toBe(process.env.OD_DATA_DIR); expect(env.OD_DATA_DIR).toBe(process.env.OD_DATA_DIR);
}); });
it('keeps non-sandbox NO_PROXY behavior unchanged', () => {
const env = createAgentRuntimeEnv(
{ PATH: '/bin', HTTP_PROXY: 'http://127.0.0.1:9', NO_PROXY: '' },
'http://127.0.0.1:7456',
{ token: 'fresh-token' },
'/opt/open-design/bin/node',
);
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:9');
expect(env.NO_PROXY).toBe('');
expect(env.no_proxy).toBeUndefined();
});
it('passes the daemon sidecar IPC path from the explicit base env into agent wrapper sessions', () => { it('passes the daemon sidecar IPC path from the explicit base env into agent wrapper sessions', () => {
const env = createAgentRuntimeEnv( const env = createAgentRuntimeEnv(
{ PATH: '/bin', [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/daemon.sock' }, { PATH: '/bin', [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/daemon.sock' },

View file

@ -4,7 +4,24 @@ import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { detectEntryFile, listFiles, resolveProjectDir } from '../src/projects.js'; import {
assertSandboxProjectRootAvailable,
detectEntryFile,
listFiles,
resolveProjectDir,
SandboxImportedProjectError,
} from '../src/projects.js';
function withSandboxMode<T>(run: () => T): T {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
describe('resolveProjectDir', () => { describe('resolveProjectDir', () => {
const projectsRoot = '/var/od/projects'; const projectsRoot = '/var/od/projects';
@ -50,6 +67,22 @@ describe('resolveProjectDir', () => {
}), }),
).not.toThrow(); ).not.toThrow();
}); });
it('rejects metadata.baseDir in sandbox mode before resolving a project file root', () => {
withSandboxMode(() => {
const baseDir = '/Users/me/projects/site';
expect(
() => resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }),
).toThrowError(SandboxImportedProjectError);
expect(() =>
assertSandboxProjectRootAvailable({ kind: 'prototype', baseDir }),
).toThrowError(SandboxImportedProjectError);
expect(() => resolveProjectDir(projectsRoot, '../escape', {
kind: 'prototype',
baseDir,
})).toThrowError();
});
});
}); });
describe('detectEntryFile', () => { describe('detectEntryFile', () => {

View file

@ -45,6 +45,37 @@ describe('POST /api/import/folder', () => {
}); });
} }
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}
async function waitForRunStatus(
runId: string,
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
let lastStatus = 'unknown';
for (let attempt = 0; attempt < 200; attempt += 1) {
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
const statusBody = (await statusResponse.json()) as {
status: string;
error?: string | null;
errorCode?: string | null;
};
lastStatus = statusBody.status;
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
return statusBody;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
}
it('creates a project rooted at the submitted folder', async () => { it('creates a project rooted at the submitted folder', async () => {
const folder = makeFolder(); const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>'); await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
@ -62,6 +93,80 @@ describe('POST /api/import/folder', () => {
expect(body.entryFile).toBe('index.html'); expect(body.entryFile).toBe('index.html');
}); });
it('rejects folder imports in sandbox mode', async () => {
await withSandboxMode(async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const resp = await importFolder({ baseDir: folder });
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/OD_SANDBOX_MODE/i);
});
});
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const runResp = await fetch(`${baseUrl}/api/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: 'claude',
projectId: project.id,
message: 'Inspect the imported project.',
}),
});
expect(runResp.status).toBe(202);
const { runId } = (await runResp.json()) as { runId: string };
const status = await waitForRunStatus(runId);
expect(status.status).toBe('failed');
expect(status.errorCode).toBe('BAD_REQUEST');
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
it('still opens an imported-folder project record in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}`);
expect(resp.status).toBe(200);
const body = (await resp.json()) as {
project?: { id?: string; metadata?: { baseDir?: string } };
};
expect(body.project?.id).toBe(project.id);
expect(body.project?.metadata?.baseDir).toBeTruthy();
});
});
it('rejects imported-folder project file listing in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await importFolder({ baseDir: folder });
expect(importResp.status).toBe(200);
const { project } = (await importResp.json()) as { project: { id: string } };
await withSandboxMode(async () => {
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/files`);
expect(resp.status).toBe(400);
const body = (await resp.json()) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
});
});
it('auto-detects the entry file when present', async () => { it('auto-detects the entry file when present', async () => {
const folder = makeFolder(); const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), ''); await writeFile(path.join(folder, 'index.html'), '');

View file

@ -30,6 +30,7 @@ describe('media-config OpenAI auth-file fallback', () => {
); );
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR; const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
const originalDataDir = process.env.OD_DATA_DIR; const originalDataDir = process.env.OD_DATA_DIR;
const originalSandboxMode = process.env.OD_SANDBOX_MODE;
let homedirSpy: ReturnType<typeof vi.spyOn>; let homedirSpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => { beforeEach(async () => {
@ -42,6 +43,7 @@ describe('media-config OpenAI auth-file fallback', () => {
} }
delete process.env.OD_MEDIA_CONFIG_DIR; delete process.env.OD_MEDIA_CONFIG_DIR;
delete process.env.OD_DATA_DIR; delete process.env.OD_DATA_DIR;
delete process.env.OD_SANDBOX_MODE;
}); });
afterEach(async () => { afterEach(async () => {
@ -67,6 +69,11 @@ describe('media-config OpenAI auth-file fallback', () => {
} else { } else {
process.env.OD_DATA_DIR = originalDataDir; process.env.OD_DATA_DIR = originalDataDir;
} }
if (originalSandboxMode == null) {
delete process.env.OD_SANDBOX_MODE;
} else {
process.env.OD_SANDBOX_MODE = originalSandboxMode;
}
homedirSpy.mockRestore(); homedirSpy.mockRestore();
await rm(homeDir, { recursive: true, force: true }); await rm(homeDir, { recursive: true, force: true });
await rm(projectRoot, { recursive: true, force: true }); await rm(projectRoot, { recursive: true, force: true });
@ -124,6 +131,30 @@ describe('media-config OpenAI auth-file fallback', () => {
}); });
}); });
it('does not read host OpenAI auth files in sandbox mode', async () => {
process.env.OD_SANDBOX_MODE = '1';
await writeHomeJson('.hermes/auth.json', {
providers: {
'openai-codex': {
tokens: { access_token: 'hermes-oauth-token' },
},
},
});
await writeHomeJson('.codex/auth.json', {
tokens: { access_token: 'codex-oauth-token' },
OPENAI_API_KEY: 'host-codex-api-key',
});
const resolved = await resolveProviderConfig(projectRoot, 'openai');
const masked = await readMaskedConfig(projectRoot);
expect(resolved.apiKey).toBe('');
expect(openaiProvider(masked)).toMatchObject({
configured: false,
source: 'unset',
});
});
it('uses explicit OPENAI_API_KEY from Codex auth files', async () => { it('uses explicit OPENAI_API_KEY from Codex auth files', async () => {
await writeHomeJson('.codex/auth.json', { await writeHomeJson('.codex/auth.json', {
tokens: { access_token: 'codex-oauth-token' }, tokens: { access_token: 'codex-oauth-token' },

View file

@ -1,11 +1,12 @@
import type http from 'node:http'; import type http from 'node:http';
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { startServer } from '../src/server.js'; import { startServer } from '../src/server.js';
import { memoryDir, writeMemoryConfig } from '../src/memory.js';
type FakeMediaEndpoint = 'tool' | 'legacy'; type FakeMediaEndpoint = 'tool' | 'legacy';
@ -19,6 +20,7 @@ describe('run-scoped media policy routes', () => {
let binDir: string; let binDir: string;
let oldPath: string | undefined; let oldPath: string | undefined;
let oldCapture: string | undefined; let oldCapture: string | undefined;
let oldMemoryConfigRaw: string | null = null;
let server: http.Server | null = null; let server: http.Server | null = null;
let shutdown: (() => Promise<void> | void) | undefined; let shutdown: (() => Promise<void> | void) | undefined;
@ -28,6 +30,12 @@ describe('run-scoped media policy routes', () => {
oldPath = process.env.PATH; oldPath = process.env.PATH;
oldCapture = process.env.OD_CAPTURE_MEDIA_RESPONSE; oldCapture = process.env.OD_CAPTURE_MEDIA_RESPONSE;
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ''}`; process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ''}`;
const memoryConfig = memoryConfigPath();
oldMemoryConfigRaw = await readFile(memoryConfig, 'utf8').catch(() => null);
await writeMemoryConfig(process.env.OD_DATA_DIR!, {
chatExtractionEnabled: false,
extraction: null,
});
}); });
afterEach(async () => { afterEach(async () => {
@ -41,6 +49,14 @@ describe('run-scoped media policy routes', () => {
else process.env.PATH = oldPath; else process.env.PATH = oldPath;
if (oldCapture === undefined) delete process.env.OD_CAPTURE_MEDIA_RESPONSE; if (oldCapture === undefined) delete process.env.OD_CAPTURE_MEDIA_RESPONSE;
else process.env.OD_CAPTURE_MEDIA_RESPONSE = oldCapture; else process.env.OD_CAPTURE_MEDIA_RESPONSE = oldCapture;
const memoryConfig = memoryConfigPath();
if (oldMemoryConfigRaw === null) {
await rm(memoryConfig, { force: true });
} else {
await mkdir(path.dirname(memoryConfig), { recursive: true });
await writeFile(memoryConfig, oldMemoryConfigRaw);
}
oldMemoryConfigRaw = null;
await rm(tempDir, { recursive: true, force: true }); await rm(tempDir, { recursive: true, force: true });
await rm(binDir, { recursive: true, force: true }); await rm(binDir, { recursive: true, force: true });
}); });
@ -468,6 +484,10 @@ describe('run-scoped media policy routes', () => {
}; };
} }
function memoryConfigPath(): string {
return path.join(memoryDir(process.env.OD_DATA_DIR!), '.config.json');
}
async function writeFakeAgent( async function writeFakeAgent(
capturePath: string, capturePath: string,
requestBody: unknown, requestBody: unknown,

View file

@ -77,6 +77,35 @@ describe('GET /api/projects/:id resolvedDir', () => {
expect(detail.resolvedDir).toBe(baseDir); expect(detail.resolvedDir).toBe(baseDir);
}); });
it('keeps imported-folder resolvedDir stable in sandbox mode', async () => {
const folder = makeFolder();
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
const importResp = await fetch(`${baseUrl}/api/import/folder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseDir: folder }),
});
expect(importResp.status).toBe(200);
const importBody = (await importResp.json()) as {
project: { id: string; metadata?: { baseDir?: string } };
};
const projectId = importBody.project.id;
const baseDir = importBody.project.metadata?.baseDir;
expect(baseDir).toBeTruthy();
await withSandboxMode(async () => {
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
expect(detailResp.status).toBe(200);
const detail = (await detailResp.json()) as {
project: { id: string };
resolvedDir: string;
};
expect(detail.project.id).toBe(projectId);
expect(detail.resolvedDir).toBe(baseDir);
});
});
it('returns resolvedDir under <projects root>/<id> for a native project', async () => { it('returns resolvedDir under <projects root>/<id> for a native project', async () => {
const projectId = `proj-routes-${Date.now()}`; const projectId = `proj-routes-${Date.now()}`;
const createResp = await fetch(`${baseUrl}/api/projects`, { const createResp = await fetch(`${baseUrl}/api/projects`, {
@ -269,3 +298,14 @@ describe('GET /api/projects/:id resolvedDir', () => {
expect(body.error?.message).toMatch(/fromTrustedPicker/i); expect(body.error?.message).toMatch(/fromTrustedPicker/i);
}); });
}); });
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
const previous = process.env.OD_SANDBOX_MODE;
process.env.OD_SANDBOX_MODE = '1';
try {
return await run();
} finally {
if (previous == null) delete process.env.OD_SANDBOX_MODE;
else process.env.OD_SANDBOX_MODE = previous;
}
}

View file

@ -1,6 +1,8 @@
import { symlinkSync } from 'node:fs'; import { symlinkSync } from 'node:fs';
import { test, vi } from 'vitest'; import { test, vi } from 'vitest';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as platform from '@open-design/platform'; import * as platform from '@open-design/platform';
import { import {
assert, chmodSync, detectAgents, inspectAgentExecutableResolution, join, minimalAgentDef, mkdirSync, mkdtempSync, opencode, resolveAgentExecutable, rmSync, spawnEnvForAgent, tmpdir, withEnvSnapshot, withPlatform, writeFileSync, assert, chmodSync, detectAgents, inspectAgentExecutableResolution, join, minimalAgentDef, mkdirSync, mkdtempSync, opencode, resolveAgentExecutable, rmSync, spawnEnvForAgent, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
@ -8,6 +10,7 @@ import {
import { isCursorAuthFailureText } from '../../src/runtimes/auth.js'; import { isCursorAuthFailureText } from '../../src/runtimes/auth.js';
const fsTest = process.platform === 'win32' ? test.skip : test; const fsTest = process.platform === 'win32' ? test.skip : test;
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login` // Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
// credentials, silently billing API usage. Strip it for the claude // credentials, silently billing API usage. Strip it for the claude
@ -135,6 +138,33 @@ test('spawnEnvForAgent keeps sandbox roots pinned to the base OD_DATA_DIR', () =
} }
}); });
test('spawnEnvForAgent resolves relative OD_DATA_DIR before applying sandbox roots', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-relative-'));
try {
const relativeDataDir = relative(repoRoot, dataDir);
const env = spawnEnvForAgent(
'codex',
{
OD_DATA_DIR: relativeDataDir,
OD_SANDBOX_MODE: '1',
PATH: '/usr/bin',
},
{
CODEX_HOME: '/Users/test/.codex-host',
},
);
assert.equal(
env.CODEX_HOME,
join(dataDir, 'sandbox', 'agent-home', '.codex'),
);
assert.equal(env.CLAUDE_CONFIG_DIR, join(dataDir, 'sandbox', 'config', 'claude'));
assert.equal(env.HOME, join(dataDir, 'sandbox', 'agent-home'));
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test('spawnEnvForAgent applies system proxy env to all agent runtimes before base env overrides', () => { test('spawnEnvForAgent applies system proxy env to all agent runtimes before base env overrides', () => {
const env = spawnEnvForAgent( const env = spawnEnvForAgent(
'gemini', 'gemini',

View file

@ -1,4 +1,5 @@
import { test } from 'vitest'; import { test } from 'vitest';
import { relative, resolve } from 'node:path';
import { import {
assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync, assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js'; } from './helpers/test-helpers.js';
@ -408,40 +409,76 @@ fsTest(
); );
fsTest( fsTest(
'OD_SANDBOX_MODE derives agent-home isolation from OD_DATA_DIR during detection', 'OD_SANDBOX_MODE scopes fallback toolchain discovery to OD_DATA_DIR',
() => { () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-agents-sandbox-data-')); const dataDir = mkdtempSync(join(tmpdir(), 'od-agents-sandbox-data-'));
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
const realPrefix = mkdtempSync(join(tmpdir(), 'od-agents-real-prefix-')); const realPrefix = mkdtempSync(join(tmpdir(), 'od-agents-real-prefix-'));
const realPrefixBin = join(realPrefix, 'bin'); const realPrefixBin = join(realPrefix, 'bin');
try { try {
return withEnvSnapshot( return withEnvSnapshot(
['OD_SANDBOX_MODE', 'OD_DATA_DIR', 'OD_AGENT_HOME', 'PATH', 'NPM_CONFIG_PREFIX'], ['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
() => { () => {
mkdirSync(realPrefixBin, { recursive: true }); mkdirSync(realPrefixBin, { recursive: true });
writeFileSync(join(realPrefixBin, 'gemini'), ''); writeFileSync(join(realPrefixBin, 'gemini'), '');
chmodSync(join(realPrefixBin, 'gemini'), 0o755); chmodSync(join(realPrefixBin, 'gemini'), 0o755);
process.env.OD_SANDBOX_MODE = '1';
process.env.OD_DATA_DIR = dataDir;
delete process.env.OD_AGENT_HOME; delete process.env.OD_AGENT_HOME;
process.env.PATH = '/usr/bin:/bin'; process.env.OD_DATA_DIR = dataDir;
process.env.OD_SANDBOX_MODE = '1';
process.env.PATH = emptyPath;
process.env.NPM_CONFIG_PREFIX = realPrefix; process.env.NPM_CONFIG_PREFIX = realPrefix;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' })); const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal( assert.equal(
resolved, resolved,
null, null,
`sandbox mode must not see the real $NPM_CONFIG_PREFIX bin; got ${resolved}`, `sandbox mode must not see the host $NPM_CONFIG_PREFIX bin; got ${resolved}`,
); );
}, },
); );
} finally { } finally {
rmSync(dataDir, { recursive: true, force: true }); rmSync(dataDir, { recursive: true, force: true });
rmSync(emptyPath, { recursive: true, force: true });
rmSync(realPrefix, { recursive: true, force: true }); rmSync(realPrefix, { recursive: true, force: true });
} }
}, },
); );
fsTest(
'OD_SANDBOX_MODE resolves relative OD_DATA_DIR before fallback toolchain discovery',
() => {
const projectRoot = resolve(process.cwd(), '../..');
const parent = mkdtempSync(join(tmpdir(), 'od-agents-relative-data-parent-'));
const dataDir = join(parent, 'data');
const sandboxBin = join(dataDir, 'sandbox', 'agent-home', '.local', 'bin');
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
try {
return withEnvSnapshot(
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
() => {
mkdirSync(sandboxBin, { recursive: true });
const geminiPath = join(sandboxBin, 'gemini');
writeFileSync(geminiPath, '');
chmodSync(geminiPath, 0o755);
delete process.env.OD_AGENT_HOME;
delete process.env.NPM_CONFIG_PREFIX;
process.env.OD_DATA_DIR = relative(projectRoot, dataDir);
process.env.OD_SANDBOX_MODE = '1';
process.env.PATH = emptyPath;
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
assert.equal(resolved, geminiPath);
},
);
} finally {
rmSync(parent, { recursive: true, force: true });
rmSync(emptyPath, { recursive: true, force: true });
}
},
);
fsTest( fsTest(
'OD_AGENT_HOME isolates resolution from $VP_HOME leakage', 'OD_AGENT_HOME isolates resolution from $VP_HOME leakage',
() => { () => {

View file

@ -102,6 +102,31 @@ test('local agent profiles skip explicit unknown baseAgent without falling back'
} }
}); });
test('sandbox mode ignores implicit and host explicit local agent profiles', async () => {
const dir = mkdtempSync(join(tmpdir(), 'od-local-agent-profiles-sandbox-'));
try {
await withEnvSnapshot(['OD_AGENT_PROFILES_CONFIG', 'OD_SANDBOX_MODE', 'OD_DATA_DIR'], async () => {
const config = join(dir, 'agents.local.json');
writeFileSync(
config,
JSON.stringify({
agents: [{ id: 'explicit-wrapper', bin: 'explicit-wrapper' }],
}),
);
process.env.OD_SANDBOX_MODE = '1';
delete process.env.OD_DATA_DIR;
delete process.env.OD_AGENT_PROFILES_CONFIG;
assert.deepEqual(readLocalAgentProfileDefs(), []);
process.env.OD_AGENT_PROFILES_CONFIG = config;
assert.deepEqual(readLocalAgentProfileDefs(), []);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => { test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => {
process.env.OD_CODEX_DISABLE_PLUGINS = '1'; process.env.OD_CODEX_DISABLE_PLUGINS = '1';