mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
9a3424d68c
commit
f4c5d22f22
17 changed files with 474 additions and 33 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<OAuthCredential | null> {
|
||||
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.
|
||||
|
|
|
|||
23
apps/daemon/src/project-root.ts
Normal file
23
apps/daemon/src/project-root.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = `<script data-od-url-scroll-bridge>
|
||||
(function(){
|
||||
if (window.__odUrlScrollBridge) return;
|
||||
|
|
@ -419,7 +439,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
const project = getProject(db, req.params.id);
|
||||
if (!project)
|
||||
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} */
|
||||
const body = { project, resolvedDir };
|
||||
res.json(body);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
isPublicationGuardedArtifactKind,
|
||||
} from './artifact-publication-guard.js';
|
||||
import { isIgnoredProjectDirName } from './project-ignored-dirs.js';
|
||||
import { isSandboxModeEnabled } from './sandbox-mode.js';
|
||||
|
||||
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
|
||||
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
|
||||
|
|
@ -40,13 +41,42 @@ export function projectDir(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
|
||||
// (metadata.baseDir set), this is the user's own folder. Otherwise falls
|
||||
// back to the standard computed path under projectsRoot.
|
||||
export function resolveProjectDir(projectsRoot, projectId, metadata?) {
|
||||
if (typeof metadata?.baseDir === 'string') {
|
||||
const p = path.normalize(metadata.baseDir);
|
||||
if (path.isAbsolute(p)) return p;
|
||||
export function resolveProjectDir(projectsRoot, projectId, metadata?, opts = {}) {
|
||||
if (!opts.allowUnavailableSandboxImportedProject) {
|
||||
assertSandboxProjectRootAvailable(metadata);
|
||||
}
|
||||
if (usesExternalProjectRoot(metadata)) {
|
||||
return path.normalize(metadata.baseDir);
|
||||
}
|
||||
if (!isSafeId(projectId)) throw new Error('invalid project id');
|
||||
return path.join(projectsRoot, projectId);
|
||||
|
|
@ -55,7 +85,7 @@ export function resolveProjectDir(projectsRoot, projectId, metadata?) {
|
|||
export async function ensureProject(projectsRoot, projectId, metadata?) {
|
||||
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
|
||||
// Git-linked folders already exist; skip mkdir to avoid side-effects.
|
||||
if (typeof metadata?.baseDir !== 'string') {
|
||||
if (!usesExternalProjectRoot(metadata)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
|
|
@ -67,7 +97,7 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
|
|||
const out = [];
|
||||
// Skip build/install dirs for linked folders so node_modules doesn't stall
|
||||
// the walk on large repos.
|
||||
const skipDirs = metadata?.baseDir ? isIgnoredProjectDirName : undefined;
|
||||
const skipDirs = usesExternalProjectRoot(metadata) ? isIgnoredProjectDirName : undefined;
|
||||
await collectFiles(dir, '', out, skipDirs, dir);
|
||||
// Newest first — matches the visual order users expect after generating.
|
||||
out.sort((a, b) => b.mtime - a.mtime);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
|
||||
import { resolveProjectRelativePath } from '../home-expansion.js';
|
||||
import { expandConfiguredEnv } from './paths.js';
|
||||
import { resolveAmrOpenCodeExecutable } from './executables.js';
|
||||
import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
|
||||
import { resolveProjectRootFromNestedModule } from '../project-root.js';
|
||||
import {
|
||||
applySandboxRuntimeEnv,
|
||||
isSandboxModeEnabled,
|
||||
|
|
@ -13,6 +16,10 @@ import {
|
|||
|
||||
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.
|
||||
//
|
||||
// The claude adapter strips ANTHROPIC_API_KEY so Claude Code's own auth
|
||||
|
|
@ -87,7 +94,11 @@ function sandboxRuntimeConfigForBaseEnv(
|
|||
if (!isSandboxModeEnabled(baseEnv)) return null;
|
||||
const dataDir = baseEnv.OD_DATA_DIR?.trim();
|
||||
if (!dataDir) return null;
|
||||
return resolveSandboxRuntimeConfig(true, dataDir);
|
||||
const resolvedDataDir = resolveProjectRelativePath(
|
||||
dataDir,
|
||||
RUNTIME_MODULE_PROJECT_ROOT,
|
||||
);
|
||||
return resolveSandboxRuntimeConfig(true, resolvedDataDir);
|
||||
}
|
||||
|
||||
function reapplySandboxRuntimeEnv(
|
||||
|
|
|
|||
|
|
@ -192,11 +192,11 @@ function createLocalAgentDef(
|
|||
export function readLocalAgentProfileDefs(
|
||||
baseDefs: RuntimeAgentDef[],
|
||||
): RuntimeAgentDef[] {
|
||||
const profilesFile = localAgentProfilesFile();
|
||||
if (profilesFile == null) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const file = localAgentProfilesFile();
|
||||
if (!file) return [];
|
||||
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
||||
parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,10 @@ import {
|
|||
shouldRenderCodexImagegenOverride,
|
||||
} from './prompts/system.js';
|
||||
import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js';
|
||||
import { resolveProjectRoot } from './project-root.js';
|
||||
import { userFacingAgentLabel } from './user-facing-agent-label.js';
|
||||
|
||||
export { resolveProjectRoot };
|
||||
import { createCommandInvocation } from '@open-design/platform';
|
||||
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
|
||||
import {
|
||||
|
|
@ -258,6 +261,7 @@ import {
|
|||
type ObservabilityEventRequest,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import {
|
||||
mergeNoProxyWithLoopbackDefaults,
|
||||
redactSecrets,
|
||||
testAgentConnection,
|
||||
testProviderConnection,
|
||||
|
|
@ -341,6 +345,7 @@ import {
|
|||
buildBatchArchive,
|
||||
decodeMultipartFilename,
|
||||
deleteProjectFile,
|
||||
assertSandboxProjectRootAvailable,
|
||||
detectEntryFile,
|
||||
ensureProject,
|
||||
isSafeId,
|
||||
|
|
@ -352,6 +357,7 @@ import {
|
|||
renameProjectFile,
|
||||
removeProjectDir,
|
||||
resolveProjectDir,
|
||||
SandboxImportedProjectError,
|
||||
sanitizeName,
|
||||
searchProjectFiles,
|
||||
resolveProjectDir,
|
||||
|
|
@ -482,13 +488,6 @@ const __filename = fileURLToPath(import.meta.url);
|
|||
const __dirname = path.dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
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 {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
? path.resolve(value)
|
||||
|
|
@ -1653,6 +1652,13 @@ export function createAgentRuntimeEnv(
|
|||
if (typeof sidecarIpcPath === 'string' && sidecarIpcPath.length > 0) {
|
||||
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 —
|
||||
// in particular npm .cmd shims on Windows that run `"node" script.js` —
|
||||
|
|
@ -10777,14 +10783,13 @@ export async function startServer({
|
|||
try {
|
||||
const chatProject = getProject(db, projectId);
|
||||
const chatMeta = chatProject?.metadata;
|
||||
if (chatMeta?.baseDir) {
|
||||
cwd = path.normalize(chatMeta.baseDir);
|
||||
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta });
|
||||
} else {
|
||||
cwd = await ensureProject(PROJECTS_DIR, projectId);
|
||||
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId);
|
||||
assertSandboxProjectRootAvailable(chatMeta);
|
||||
cwd = await ensureProject(PROJECTS_DIR, projectId, chatMeta);
|
||||
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta });
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxImportedProjectError) {
|
||||
return design.runs.fail(run, 'BAD_REQUEST', err.message);
|
||||
}
|
||||
} catch {
|
||||
cwd = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,19 @@ describe('agent runtime tool environment', () => {
|
|||
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', () => {
|
||||
const env = createAgentRuntimeEnv(
|
||||
{ PATH: '/bin', [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/daemon.sock' },
|
||||
|
|
|
|||
|
|
@ -4,7 +4,24 @@ import { tmpdir } from 'node:os';
|
|||
import path from 'node:path';
|
||||
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', () => {
|
||||
const projectsRoot = '/var/od/projects';
|
||||
|
|
@ -50,6 +67,22 @@ describe('resolveProjectDir', () => {
|
|||
}),
|
||||
).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', () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
|
@ -62,6 +93,80 @@ describe('POST /api/import/folder', () => {
|
|||
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 () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '');
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
);
|
||||
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
||||
const originalDataDir = process.env.OD_DATA_DIR;
|
||||
const originalSandboxMode = process.env.OD_SANDBOX_MODE;
|
||||
let homedirSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -42,6 +43,7 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
}
|
||||
delete process.env.OD_MEDIA_CONFIG_DIR;
|
||||
delete process.env.OD_DATA_DIR;
|
||||
delete process.env.OD_SANDBOX_MODE;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -67,6 +69,11 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
} else {
|
||||
process.env.OD_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalSandboxMode == null) {
|
||||
delete process.env.OD_SANDBOX_MODE;
|
||||
} else {
|
||||
process.env.OD_SANDBOX_MODE = originalSandboxMode;
|
||||
}
|
||||
homedirSpy.mockRestore();
|
||||
await rm(homeDir, { 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 () => {
|
||||
await writeHomeJson('.codex/auth.json', {
|
||||
tokens: { access_token: 'codex-oauth-token' },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
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 os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
import { memoryDir, writeMemoryConfig } from '../src/memory.js';
|
||||
|
||||
type FakeMediaEndpoint = 'tool' | 'legacy';
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ describe('run-scoped media policy routes', () => {
|
|||
let binDir: string;
|
||||
let oldPath: string | undefined;
|
||||
let oldCapture: string | undefined;
|
||||
let oldMemoryConfigRaw: string | null = null;
|
||||
let server: http.Server | null = null;
|
||||
let shutdown: (() => Promise<void> | void) | undefined;
|
||||
|
||||
|
|
@ -28,6 +30,12 @@ describe('run-scoped media policy routes', () => {
|
|||
oldPath = process.env.PATH;
|
||||
oldCapture = process.env.OD_CAPTURE_MEDIA_RESPONSE;
|
||||
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 () => {
|
||||
|
|
@ -41,6 +49,14 @@ describe('run-scoped media policy routes', () => {
|
|||
else process.env.PATH = oldPath;
|
||||
if (oldCapture === undefined) delete process.env.OD_CAPTURE_MEDIA_RESPONSE;
|
||||
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(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(
|
||||
capturePath: string,
|
||||
requestBody: unknown,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,35 @@ describe('GET /api/projects/:id resolvedDir', () => {
|
|||
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 () => {
|
||||
const projectId = `proj-routes-${Date.now()}`;
|
||||
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||
|
|
@ -269,3 +298,14 @@ describe('GET /api/projects/:id resolvedDir', () => {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { symlinkSync } from 'node:fs';
|
||||
import { test, vi } from 'vitest';
|
||||
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 {
|
||||
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';
|
||||
|
||||
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`
|
||||
// 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', () => {
|
||||
const env = spawnEnvForAgent(
|
||||
'gemini',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test } from 'vitest';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import {
|
||||
assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
|
||||
} from './helpers/test-helpers.js';
|
||||
|
|
@ -408,40 +409,76 @@ 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 emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
|
||||
const realPrefix = mkdtempSync(join(tmpdir(), 'od-agents-real-prefix-'));
|
||||
const realPrefixBin = join(realPrefix, 'bin');
|
||||
try {
|
||||
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 });
|
||||
writeFileSync(join(realPrefixBin, 'gemini'), '');
|
||||
chmodSync(join(realPrefixBin, 'gemini'), 0o755);
|
||||
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
process.env.OD_DATA_DIR = dataDir;
|
||||
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;
|
||||
|
||||
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
|
||||
assert.equal(
|
||||
resolved,
|
||||
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 {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(emptyPath, { 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(
|
||||
'OD_AGENT_HOME isolates resolution from $VP_HOME leakage',
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
process.env.OD_CODEX_DISABLE_PLUGINS = '1';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue