diff --git a/.env.example b/.env.example index a792e87..bd32787 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,6 @@ PORT=3000 DATABASE_URL=postgres://boocode:CHANGE_ME@boocode_db:5432/boocode LLAMA_SWAP_URL=http://100.101.41.16:8401 PROJECT_ROOT_WHITELIST=/opt +BOOTSTRAP_ROOT=/opt/projects DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4 POSTGRES_PASSWORD=CHANGE_ME diff --git a/CLAUDE.md b/CLAUDE.md index 6f0fd5c..6fc5049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ Position-shift pattern for panes (legacy `session_panes` table): negate-and-rest ## Environment -Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt), `DEFAULT_MODEL`, `LOG_LEVEL`. +Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`. ## Workflow diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index bc30e48..5350741 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -7,6 +7,7 @@ const ConfigSchema = z.object({ DATABASE_URL: z.string().url(), LLAMA_SWAP_URL: z.string().url(), PROJECT_ROOT_WHITELIST: z.string().default('/opt'), + BOOTSTRAP_ROOT: z.string().default('/opt/projects'), DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'), LOG_LEVEL: z.string().default('info'), GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'), diff --git a/apps/server/src/services/project_bootstrap.ts b/apps/server/src/services/project_bootstrap.ts index 7762ce3..c080549 100644 --- a/apps/server/src/services/project_bootstrap.ts +++ b/apps/server/src/services/project_bootstrap.ts @@ -82,11 +82,13 @@ export async function bootstrapProject( throw new BootstrapNameError(`invalid name after sanitization: "${folder}"`); } - // Whitelist resolution - const whitelistReal = await realpath(config.PROJECT_ROOT_WHITELIST); - const fullPath = resolve(whitelistReal, folder); - if (!fullPath.startsWith(whitelistReal + sep)) { - throw new BootstrapPathError('path escapes whitelist'); + // Bootstrap target resolution. Uses BOOTSTRAP_ROOT (writable), not + // PROJECT_ROOT_WHITELIST (which may be a wider read-only scope for + // add-existing flow). + const bootstrapReal = await realpath(config.BOOTSTRAP_ROOT); + const fullPath = resolve(bootstrapReal, folder); + if (!fullPath.startsWith(bootstrapReal + sep)) { + throw new BootstrapPathError('path escapes bootstrap root'); } if (existsSync(fullPath)) { throw new BootstrapCollisionError(`path already exists: ${fullPath}`); diff --git a/docker-compose.yml b/docker-compose.yml index 2ca91e7..45dd681 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,11 @@ services: environment: DATABASE_URL: postgres://boocode:${POSTGRES_PASSWORD}@boocode_db:5432/boocode volumes: - - /opt:/opt:rw + # Read-only mount for legacy/existing project add-existing flow. + - /opt:/opt:ro + # Writable mount only for the create-new-project bootstrap target. + # Host must `mkdir -p /opt/projects` before container start. + - /opt/projects:/opt/projects:rw depends_on: - boocode_db networks: