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,
|
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();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
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 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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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'), '');
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue