feat: add per-stack project env file selection for Docker Compose (#1457)

* feat: add per-stack project env file selection for Docker Compose

Allow users to configure an ordered list of env files per stack that serve
as the project environment file(s) for Docker Compose ${VAR} interpolation.
The selected files are passed via repeated --env-file flags during all
compose commands.

Backend:
- Add stack_project_env_files table (node-scoped, ordered)
- Extend authoredComposeEnvFileArgs to emit --env-file for configured files
- Add GET/PUT /stacks/:name/project-env-files and /candidates endpoints
- Update resolveStackEnvSources to use configured files as interpolation source
- Update resolveAllEnvFilePaths to merge injection + interpolation sources
- Add discoverStackLocalEnvFiles for candidate discovery
- Extend backupStackFiles and snapshotStackFiles for project env files
- Add project-env-files capability to CapabilityRegistry

Frontend:
- Add project env file selector to EnvironmentPanel (capability-gated)
- Update EditorView banner to generic "project environment file" language
- Add project-env-files capability to capabilities.ts

Issue: #1454

* fix: add realpath validation, clear all stale backup files, reject nested paths

- authoredComposeEnvFileArgs: use fsPromises.realpath + isPathWithinBase
  for symlink escape defense at use time
- backupStackFiles: clear ALL non-marker files from backup slot before
  writing, not just PROTECTED_STACK_FILES (handles stale old.env)
- PUT project-env-files: reject paths containing / or \ (root-level
  only, matching Compose auto-discovery behavior)

* fix: add getStackProjectEnvFiles to compose-service mock

The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles
on the DatabaseService singleton. The compose-service mesh-override
tests mock that singleton without the new method, causing 6 failures.
Add getStackProjectEnvFiles: () => [] (empty = fall back to legacy
behavior, which is what these tests exercise).

* fix: add getStackProjectEnvFiles to remaining service mocks

The new authoredComposeEnvFileArgs calls getStackProjectEnvFiles,
which is missing from the mock in compose-images.test.ts (6 failures)
and image-update-service.test.ts (proactive fix).

* fix: apply inline path-injection barrier at fs sink for CodeQL

The PUT project-env-files route resolved paths via isPathWithinBase
before calling fsp.stat, but CodeQL does not credit a containment check
separated from the sink. Apply the canonical inline barrier pattern
(path.resolve + startsWith at the sink) used throughout the codebase.

* fix: resolve stackDir from the same canonical root as safePath

Prevents a containment bypass when the compose base directory is
a symlink: stackDir was previously joined from the unresolved
baseDir while the inline barrier used path.resolve(baseDir),
which could differ for symlinked paths. Now both stackDir and
safePath are resolved from a single canonical root, then each is
containment-checked against it.

* fix: remove unused isPathWithinBase import

The inline path-injection barrier refactor replaced isPathWithinBase
with an inline startsWith check at the fs sink, so the import is now
unused and fails ESLint no-unused-vars.
This commit is contained in:
Anso
2026-06-25 18:03:05 -04:00
committed by GitHub
parent b7dd9dc1b0
commit a698aaa926
13 changed files with 550 additions and 95 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ vi.mock('../services/DockerController', () => ({
}));
vi.mock('../services/DatabaseService', () => ({
DatabaseService: { getInstance: () => ({ getRegistries: () => [], getGitSource: () => undefined }) },
DatabaseService: { getInstance: () => ({ getRegistries: () => [], getGitSource: () => undefined, getStackProjectEnvFiles: () => [] }) },
}));
vi.mock('../services/RegistryService', () => ({
@@ -87,6 +87,7 @@ vi.mock('../services/DatabaseService', () => ({
getRegistries: mockGetRegistries,
getGlobalSettings: mockGetGlobalSettings,
getGitSource: () => undefined,
getStackProjectEnvFiles: () => [],
}),
},
}));
@@ -44,6 +44,7 @@ vi.mock('../services/DatabaseService', () => ({
getGlobalSettings: mockGetGlobalSettings,
getNodes: () => [],
getGitSource: () => undefined,
getStackProjectEnvFiles: () => [],
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
getStackUpdateStatus: mockGetStackUpdateStatus,
clearStackUpdateStatus: mockClearStackUpdateStatus,
@@ -553,6 +554,7 @@ describe('ImageUpdateService - check() concurrency guard', () => {
getGlobalSettings: () => ({ developer_mode: developerMode }),
getNodes: () => [{ type: 'local', id: 1, name: 'local', mode: 'proxy', compose_dir: '/tmp/compose', is_default: true, status: 'online', created_at: 1 }],
getGitSource: () => undefined,
getStackProjectEnvFiles: () => [],
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
getStackUpdateStatus: mockGetStackUpdateStatus,
clearStackUpdateStatus: mockClearStackUpdateStatus,
+82 -13
View File
@@ -165,20 +165,52 @@ export async function resolveStackEnvSources(nodeId: number, stackName: string):
const composeFiles = await discoverAuthoredComposeFiles(fsService, stackName, stackDir);
// Physical env files, deduped by resolved absolute path. Seed with the project
// `.env`: always the interpolation source, regardless of any env_file entry.
// Physical env files, deduped by resolved absolute path.
// Seed the project `.env` as the fallback interpolation source. When the stack
// has configured project env files, they REPLACE `.env` as the interpolation
// source (matching Docker Compose behavior: explicit --env-file suppresses
// default .env auto-discovery). If the user wants `.env` in addition to custom
// files, they must include it in the configured list.
const configuredProjectFiles = DatabaseService.getInstance().getStackProjectEnvFiles(nodeId, stackName);
const hasConfiguredProjectFiles = configuredProjectFiles.length > 0;
const byPath = new Map<string, PhysicalEnvFile>();
const dotenvPath = path.resolve(stackDir, '.env');
const dotenv: PhysicalEnvFile = {
resolvedPath: dotenvPath,
rawPaths: ['.env'],
existence: await existenceOf(fsService, dotenvPath, baseDir),
required: false,
isInterpolationSource: true,
isInjectionSource: false,
declaringServices: [],
};
byPath.set(dotenvPath, dotenv);
if (hasConfiguredProjectFiles) {
for (const file of configuredProjectFiles) {
const resolvedPath = path.resolve(stackDir, file);
const exists = await existenceOf(fsService, resolvedPath, baseDir);
const existing = byPath.get(resolvedPath);
if (existing) {
existing.isInterpolationSource = true;
} else {
byPath.set(resolvedPath, {
resolvedPath,
rawPaths: [file],
existence: exists,
required: false,
isInterpolationSource: true,
isInjectionSource: false,
declaringServices: [],
});
}
}
}
// Seed the project `.env` as the fallback interpolation source when nothing is configured.
if (!hasConfiguredProjectFiles) {
const dotenvPath = path.resolve(stackDir, '.env');
const dotenv: PhysicalEnvFile = {
resolvedPath: dotenvPath,
rawPaths: ['.env'],
existence: await existenceOf(fsService, dotenvPath, baseDir),
required: false,
isInterpolationSource: true,
isInjectionSource: false,
declaringServices: [],
};
byPath.set(dotenvPath, dotenv);
}
const unresolved: PhysicalEnvFile[] = [];
const inlineEnvKeysByService: Record<string, string[]> = {};
@@ -251,3 +283,40 @@ export async function resolveStackEnvSources(nodeId: number, stackName: string):
interpolationRefs: parseInterpolationRefs(authoredText),
};
}
/**
* Scan the stack directory (non-recursive) for env-like files that could serve as
* project env files. Matches three patterns: `.env`, `*.env` (e.g. `stack.env`),
* and `.env.*` (e.g. `.env.production`, `.env.local`). Returns only regular files
* (directories named `*.env` are excluded). Results are stack-relative paths sorted
* alphabetically.
*/
export async function discoverStackLocalEnvFiles(nodeId: number, stackName: string): Promise<string[]> {
const fsService = FileSystemService.getInstance(nodeId);
const baseDir = fsService.getBaseDir();
let entries: { name: string; type: string }[];
try {
entries = await fsService.listStackDirectory(stackName, '');
} catch (err) {
console.warn('[EnvFileResolution] Failed to list stack directory for env file discovery:', (err as Error).message);
return [];
}
const stackDir = path.join(baseDir, stackName);
const candidates: string[] = [];
for (const entry of entries) {
const name = entry.name;
if (name === '.env' || name.endsWith('.env') || name.startsWith('.env.')) {
// Must be a regular file, not a directory.
if (entry.type !== 'file') continue;
// Validate containment (defense in depth).
const absPath = path.resolve(stackDir, name);
if (!isPathWithinBase(absPath, stackDir)) continue;
candidates.push(name);
}
}
candidates.sort();
return candidates;
}
+112 -12
View File
@@ -46,7 +46,7 @@ import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describeP
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
import { resolveStackEnvSources } from '../helpers/envFileResolution';
import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/envFileResolution';
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
@@ -147,20 +147,24 @@ async function requireStackExists(nodeId: number, stackName: string, res: Respon
}
// Thin wrapper over the shared env-source resolver. Returns the absolute paths of
// the env files Compose would consult for this stack: the existing declared
// `env_file:` paths when any are declared (no project `.env` fallback in that
// case), otherwise the project `.env` when it exists. The multi-file Git case and
// path validation live in resolveStackEnvSources so every consumer agrees.
// the env files Compose would consult for this stack: existing declared `env_file:`
// paths (injection sources) plus the project interpolation source(s). Configured
// project env files are included as interpolation sources. The multi-file Git case
// and path validation live in resolveStackEnvSources so every consumer agrees.
export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
const sources = await resolveStackEnvSources(nodeId, stackName);
const injection = sources.envFiles.filter(f => f.isInjectionSource);
if (injection.length > 0) {
return injection
.filter(f => f.existence === 'present' && f.resolvedPath)
.map(f => f.resolvedPath as string);
const present = sources.envFiles.filter(f => f.existence === 'present' && f.resolvedPath);
const injection = present.filter(f => f.isInjectionSource).map(f => f.resolvedPath as string);
const interpolation = present.filter(f => f.isInterpolationSource && !f.isInjectionSource).map(f => f.resolvedPath as string);
// Dedup: interpolation sources may also appear as injection sources.
const seen = new Set(injection);
for (const p of interpolation) {
if (!seen.has(p)) {
seen.add(p);
injection.push(p);
}
}
const dotenv = sources.envFiles.find(f => f.isInterpolationSource && f.existence === 'present' && f.resolvedPath);
return dotenv ? [dotenv.resolvedPath as string] : [];
return injection;
}
// Uploads spool to disk (not memory) so a 25 MB upload is never held in RAM.
@@ -665,6 +669,101 @@ stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => {
}
});
// Project env files: per-stack ordered list of env files that serve as the Docker
// Compose project environment file(s) for ${VAR} interpolation. An empty array means
// "use the default .env behavior".
stacksRouter.get('/:stackName/project-env-files', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
try {
const files = DatabaseService.getInstance().getStackProjectEnvFiles(req.nodeId, stackName);
res.json({ envFiles: files });
} catch (error) {
console.error('[Stacks] Failed to read project env files:', error);
res.status(500).json({ error: 'Failed to read project env files' });
}
});
stacksRouter.put('/:stackName/project-env-files', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
const { envFiles } = req.body as { envFiles?: unknown };
if (!Array.isArray(envFiles)) {
return res.status(400).json({ error: 'envFiles must be an array' });
}
const files = envFiles as string[];
const fsService = FileSystemService.getInstance(req.nodeId);
// Dedup while preserving order. Skip validation for duplicates: already
// confirmed on first occurrence and the file cannot change mid-handler.
const seen = new Set<string>();
const deduped: string[] = [];
for (const file of files) {
if (typeof file !== 'string' || file.length === 0) {
return res.status(400).json({ error: 'Each env file must be a non-empty string' });
}
if (seen.has(file)) continue;
if (path.isAbsolute(file) || file.includes('..') || file.startsWith('.git')) {
return res.status(400).json({ error: `Invalid env file path: "${file}"` });
}
if (file.includes('/') || file.includes('\\')) {
return res.status(400).json({ error: `Project env files must be at the stack root: "${file}"` });
}
if (!isValidRelativeStackPath(file)) {
return res.status(400).json({ error: `Invalid env file path: "${file}"` });
}
// Canonical js/path-injection barrier inline with the fs sink: resolve both
// the compose root and the stack directory from a single canonical root so
// containment is unambiguous even when the compose dir is a symlink. The
// pattern mirrors authoredComposeArgs.ts and every FileSystemService sink.
const baseResolved = path.resolve(fsService.getBaseDir());
const stackDir = path.resolve(baseResolved, stackName);
if (!stackDir.startsWith(baseResolved + path.sep)) {
return res.status(400).json({ error: `Stack directory escapes the compose directory: "${stackName}"` });
}
const safePath = path.resolve(stackDir, file);
if (!safePath.startsWith(baseResolved + path.sep)) {
return res.status(400).json({ error: `Env file path escapes the compose directory: "${file}"` });
}
try {
await fsService.access(safePath);
const stat = await fsp.stat(safePath);
if (!stat.isFile()) {
return res.status(400).json({ error: `Not a regular file: "${file}"` });
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return res.status(400).json({ error: `Env file not found: "${file}"` });
}
return res.status(400).json({ error: `Cannot access env file "${file}"` });
}
seen.add(file);
deduped.push(file);
}
DatabaseService.getInstance().setStackProjectEnvFiles(req.nodeId, stackName, deduped);
res.json({ envFiles: deduped });
} catch (error) {
console.error('[Stacks] Failed to save project env files:', error);
res.status(500).json({ error: 'Failed to save project env files' });
}
});
// Candidates for project env file selection: stack-local env-like files.
stacksRouter.get('/:stackName/project-env-files/candidates', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
try {
const files = await discoverStackLocalEnvFiles(req.nodeId, stackName);
res.json({ envFiles: files });
} catch (error) {
console.error('[Stacks] Failed to discover project env file candidates:', error);
res.status(500).json({ error: 'Failed to discover project env file candidates' });
}
});
// Stack Dossier: operator-authored documentation persisted per (node, stack).
// All fields default to '' so a PUT is a full-document save (an omitted field
// clears it) and a GET for a stack with no dossier yet returns a clean blank.
@@ -947,6 +1046,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
DatabaseService.getInstance().deleteStackDriftFindings(req.nodeId, stackName);
DatabaseService.getInstance().deleteStackExposureIntents(req.nodeId, stackName);
DatabaseService.getInstance().deleteStackExposure(req.nodeId, stackName);
DatabaseService.getInstance().deleteStackProjectEnvFiles(req.nodeId, stackName);
if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName });
} catch (dbErr) {
console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr);
@@ -39,6 +39,7 @@ export const CAPABILITIES = [
'update-guard',
'compose-networking',
'env-inventory',
'project-env-files',
'compose-storage',
] as const;
+43
View File
@@ -1875,6 +1875,22 @@ export class DatabaseService {
} catch (e) {
console.warn('[DatabaseService] mesh_stacks migration:', (e as Error).message);
}
try {
this.db.prepare(`
CREATE TABLE IF NOT EXISTS stack_project_env_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
env_file TEXT NOT NULL,
position INTEGER NOT NULL,
UNIQUE(node_id, stack_name, env_file),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
)
`).run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_stack_project_env_files_lookup ON stack_project_env_files(node_id, stack_name)').run();
} catch (e) {
console.warn('[DatabaseService] stack_project_env_files migration:', (e as Error).message);
}
// mesh_centrals was the peer-side cache of the reverse-callback JWT
// (central → peer bootstrap material). Peer→central traffic now
// multiplexes over the existing forward WS via `tcp_open_reverse`,
@@ -2025,6 +2041,33 @@ export class DatabaseService {
this.db.prepare('DELETE FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
// --- Project env files ---
public getStackProjectEnvFiles(nodeId: number, stackName: string): string[] {
const rows = this.db.prepare(
'SELECT env_file FROM stack_project_env_files WHERE node_id = ? AND stack_name = ? ORDER BY position ASC'
).all(nodeId, stackName) as Array<{ env_file: string }>;
return rows.map(r => r.env_file);
}
public setStackProjectEnvFiles(nodeId: number, stackName: string, files: string[]): void {
const deleteStmt = this.db.prepare('DELETE FROM stack_project_env_files WHERE node_id = ? AND stack_name = ?');
const insertStmt = this.db.prepare(
'INSERT INTO stack_project_env_files (node_id, stack_name, env_file, position) VALUES (?, ?, ?, ?)'
);
const tx = this.db.transaction((ordered: string[]) => {
deleteStmt.run(nodeId, stackName);
ordered.forEach((file, idx) => {
insertStmt.run(nodeId, stackName, file, idx);
});
});
tx(files);
}
public deleteStackProjectEnvFiles(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_project_env_files WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
public setNodeMeshEnabled(nodeId: number, enabled: boolean): void {
this.db.prepare('UPDATE nodes SET mesh_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, nodeId);
}
+60 -24
View File
@@ -6,6 +6,7 @@ import type { Dirent } from 'fs';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { NodeRegistry } from './NodeRegistry';
import { DatabaseService } from './DatabaseService';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { isBinaryBuffer } from '../utils/binaryDetect';
import { sanitizeForLog } from '../utils/safeLog';
@@ -953,24 +954,32 @@ export class FileSystemService {
}
await fsPromises.mkdir(backupDir, { recursive: true });
// Clear stale managed files from the backup slot before writing the current
// ones. The slot is reused across runs, so a managed file removed from the
// stack since the last backup (e.g. a deleted .env or a switched compose
// variant) would otherwise linger here and a later restore would resurrect
// it, breaking the faithful-revert guarantee. Scope is the protected set
// Sencho writes; .timestamp is rewritten below. A clear failure is logged but
// not fatal: it only risks a stale future rollback, so it should not block an
// otherwise valid deploy.
for (const file of PROTECTED_STACK_FILES) {
const stale = path.resolve(backupRoot, path.join(backupDir, file));
if (!stale.startsWith(backupRoot + path.sep)) continue;
try {
await fsPromises.unlink(stale);
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.warn(`[FileSystemService] Could not clear stale backup ${file}:`, (e as Error).message);
// Clear ALL non-marker files from the backup slot before writing the current
// set. The slot is reused across runs, so a file removed from the stack since
// the last backup (e.g. a deleted .env, a switched compose variant, or a
// removed project env file like old.env) would otherwise linger here and a
// later restore would resurrect it, breaking the faithful-revert guarantee.
// Marker files (.timestamp, .checksums) are preserved until rewritten below.
// A clear failure is logged but not fatal: it only risks a stale future
// rollback, so it should not block an otherwise valid deploy.
try {
const existing = await fsPromises.readdir(backupDir);
for (const item of existing) {
if (BACKUP_MARKER_FILES.has(item)) continue;
const stale = path.resolve(backupRoot, path.join(backupDir, item));
if (!stale.startsWith(backupRoot + path.sep)) continue;
try {
await fsPromises.unlink(stale);
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.warn(`[FileSystemService] Could not clear stale backup ${item}:`, (e as Error).message);
}
}
}
} catch (e: unknown) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') {
console.warn('[FileSystemService] Could not read backup directory for stale cleanup:', (e as Error).message);
}
}
// Copy each managed file by reading it into memory, writing it to the backup
@@ -1028,6 +1037,23 @@ export class FileSystemService {
}
await writeManagedBackupFile('.env', envSrc);
// Backup configured project env files (e.g. stack.env, .env.production).
// Dedup against .env (already in PROTECTED_STACK_FILES) so it is not
// duplicated. Stale dynamic files from a prior backup are cleared below so a
// previously-backed-up old.env does not linger.
let projectEnvFiles: string[] = [];
try {
projectEnvFiles = DatabaseService.getInstance().getStackProjectEnvFiles(this.nodeId, stackName);
} catch {
// DB read failure is not fatal to the deploy; skip project env file backup.
}
for (const file of projectEnvFiles) {
if (file === '.env') continue; // already handled above
const src = path.resolve(baseResolved, path.join(stackDir, file));
if (!src.startsWith(baseResolved + path.sep)) continue;
await writeManagedBackupFile(file, src);
}
// Write the integrity manifest before the timestamp marker, so a crash
// between the two leaves the checksums present (a backup that restore can
// verify) rather than a timestamp with no integrity data. Only files that
@@ -1142,9 +1168,10 @@ export class FileSystemService {
}
/**
* Capture the current managed stack files (PROTECTED_STACK_FILES) in memory and
* return a function that puts them back, faithfully (writing the captured
* contents and removing any managed file that did not exist when captured).
* Capture the current managed stack files (PROTECTED_STACK_FILES plus configured
* project env files) in memory and return a function that puts them back,
* faithfully (writing the captured contents and removing any managed file that
* did not exist when captured).
*
* Used by the rollback route to undo a restored backup when the policy gate
* blocks before the deploy commits: restoreStackFiles has already overwritten
@@ -1154,12 +1181,21 @@ export class FileSystemService {
async snapshotStackFiles(stackName: string): Promise<() => Promise<void>> {
const stackDir = this.resolveStackDir(stackName);
await this.assertRealWithinBase(stackDir);
// Canonical js/path-injection barrier inline with the read/write sinks, the
// same pattern restoreStackFiles uses: resolve against the base and confirm
// containment so static analysis credits the barrier.
const baseResolved = path.resolve(this.baseDir);
const snapshot = new Map<string, Buffer>();
for (const file of PROTECTED_STACK_FILES) {
// Collect the unified set: PROTECTED_STACK_FILES + configured project env files.
const files = new Set(PROTECTED_STACK_FILES);
try {
const projectEnvFiles = DatabaseService.getInstance().getStackProjectEnvFiles(this.nodeId, stackName);
for (const f of projectEnvFiles) {
if (f !== '.env') files.add(f); // .env already in PROTECTED_STACK_FILES
}
} catch {
// DB read failure: snapshot without project env files (safe fallback).
}
for (const file of files) {
const target = path.resolve(baseResolved, path.join(stackDir, file));
if (!target.startsWith(baseResolved + path.sep)) {
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
@@ -1171,7 +1207,7 @@ export class FileSystemService {
}
}
return async () => {
for (const file of PROTECTED_STACK_FILES) {
for (const file of files) {
const target = path.resolve(baseResolved, path.join(stackDir, file));
if (!target.startsWith(baseResolved + path.sep)) continue;
const saved = snapshot.get(file);
+69 -19
View File
@@ -58,32 +58,86 @@ export function authoredComposeFileArgs(stackName: string, nodeId?: number): str
}
/**
* Build the `--env-file <stackDir>/.env` flag a multi-file Git deploy needs when
* the applied spec sets a context dir, or `[]` otherwise.
* Build `--env-file` arguments for the stack's configured project env files.
*
* When `authoredComposeFileArgs` emits `--project-directory <contextDir>`, Docker
* Compose treats the context dir as the project directory and looks for `.env`
* there, not at the stack root where Sencho writes it. `validateCompose` passes
* the root `.env` explicitly with `--env-file` whenever the stack has env content,
* so without the same flag at deploy/render/scan time a Git source could validate
* with one effective config and deploy or render another. This flag makes every
* compose invocation resolve env from the same root `.env` the validator used.
* When the stack has one or more project env files configured (via the
* project-env-files API), each file is resolved against the stack directory,
* validated for containment and file type, and emitted as a repeated
* `--env-file <absPath>` flag. Configured files apply to ALL stack types
* (single-file, multi-file Git, non-Git).
*
* Scoped to the context-dir case on purpose: with no `--project-directory`, the
* project directory stays the stack dir (the compose command's cwd) and Compose
* auto-discovers the root `.env`, so single-file / no-context stacks need no flag
* and keep their existing behavior. An explicit `--env-file` to a missing file
* errors, so the flag is only added when a root `.env` actually exists.
* When no project env files are configured, fall back to the legacy behavior:
* pass `--env-file <stackDir>/.env` only for multi-file Git stacks whose
* deploy spec sets a contextDir and whose root `.env` actually exists. This
* preserves byte-identical behavior for existing stacks.
*/
export async function authoredComposeEnvFileArgs(stackName: string, nodeId?: number): Promise<string[]> {
const resolvedNodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
const db = DatabaseService.getInstance();
const configuredFiles = db.getStackProjectEnvFiles(resolvedNodeId, stackName);
if (configuredFiles.length > 0) {
const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(resolvedNodeId));
const stackDir = path.resolve(baseResolved, stackName);
if (!stackDir.startsWith(baseResolved + path.sep)) return [];
const args: string[] = [];
for (const file of configuredFiles) {
if (!file || !isValidRelativeStackPath(file)) {
throw new Error(`Invalid project env file path for stack "${stackName}": "${file}"`);
}
// Reject paths with directory separators: project env files live at the
// stack root, matching Compose's auto-discovery behavior.
if (file.includes('/') || file.includes('\\')) {
throw new Error(
`Project env file "${file}" for stack "${stackName}" must be at the stack root. ` +
`Update the project env file selection in the Environment tab.`
);
}
const envPath = path.resolve(stackDir, file);
if (!isPathWithinBase(envPath, stackDir)) {
throw new Error(`Project env file path escapes stack directory for stack "${stackName}": "${file}"`);
}
// Verify the real path stays within the stack directory, defending against
// symlinks that were created or swapped after configuration.
let realEnvPath: string;
try {
realEnvPath = await fsPromises.realpath(envPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new Error(
`Project env file "${file}" configured for stack "${stackName}" is missing. ` +
`Restore the file or update the project env file selection in the Environment tab.`
);
}
throw err;
}
if (!isPathWithinBase(realEnvPath, stackDir)) {
throw new Error(
`Project env file "${file}" for stack "${stackName}" resolves outside the stack directory. ` +
`Update the project env file selection in the Environment tab.`
);
}
const stat = await fsPromises.stat(realEnvPath);
if (!stat.isFile()) {
throw new Error(
`Project env file "${file}" configured for stack "${stackName}" is not a regular file. ` +
`Update the project env file selection in the Environment tab.`
);
}
args.push('--env-file', realEnvPath);
}
return args;
}
// Legacy fallback: --env-file .env only for multi-file Git stacks with contextDir.
const spec = DatabaseService.getInstance().getGitSource(stackName)?.applied_deploy_spec;
if (!spec || spec.files.length === 0 || !spec.contextDir) return [];
// Inline js/path-injection barrier at the fs sink: resolve against a known-safe
// base and assert containment with startsWith right here. CodeQL does not credit
// the wrapped isPathWithinBase helper or a check separated from the sink, matching
// the inline guards in renderConfig and validateCompose. `.env` is a fixed name.
// the inline guards in renderConfig and validateCompose.
const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(resolvedNodeId));
const stackDir = path.resolve(baseResolved, stackName);
if (!stackDir.startsWith(baseResolved + path.sep)) return [];
@@ -91,10 +145,6 @@ export async function authoredComposeEnvFileArgs(stackName: string, nodeId?: num
try {
await fsPromises.access(envPath);
} catch (err) {
// A missing `.env` is the normal "nothing to pass" case. Any other error
// (e.g. EACCES on an existing but unreadable `.env`) is a real fault: surface
// it rather than silently dropping the flag and deploying a different effective
// config than the one validated.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
@@ -515,7 +515,7 @@ export function EditorView(props: EditorViewProps) {
{activeTab === 'env' && (
<div className="bg-brand/8 border-b border-brand/20 px-4 py-2 flex items-center gap-2 text-xs text-brand">
<span>
Variables defined here are automatically available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, you must add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
Variables defined in the project environment file are available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
</span>
</div>
)}
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react';
import { Lock, Copy, Info, TriangleAlert, ShieldAlert } from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { Lock, Copy, Info, TriangleAlert, ShieldAlert, X, ChevronUp, ChevronDown } from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { copyToClipboard } from '@/lib/clipboard';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import {
buildEnvChecklistMarkdown,
SOURCE_LABELS,
@@ -14,6 +15,7 @@ import {
type EnvItemStatus,
type EnvFileExistence,
} from '@/lib/envChecklist';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
const ACTION_CLASS =
@@ -91,39 +93,114 @@ const STATUS_GROUPS: { status: EnvItemStatus; label: string }[] = [
];
export default function EnvironmentPanel({ stackName }: { stackName: string }) {
const { activeNode } = useNodes();
const { activeNode, hasCapability } = useNodes();
const { can } = useAuth();
const nodeId = activeNode?.id;
const [inventory, setInventory] = useState<EnvInventory | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [copying, setCopying] = useState(false);
// Project env file selection
const projectEnvCapable = hasCapability('project-env-files');
const canEdit = can('stack:edit', 'stack', stackName);
const [projectEnvFiles, setProjectEnvFiles] = useState<string[]>([]);
const [candidates, setCandidates] = useState<string[]>([]);
const [savingProjectEnv, setSavingProjectEnv] = useState(false);
const fetchJsonEnvFiles = useCallback(async (suffix: string, setter: (v: string[]) => void) => {
if (!projectEnvCapable) return;
try {
const res = await apiFetch(`/stacks/${stackName}/project-env-files${suffix}`);
if (res.ok) {
const data = await res.json() as { envFiles: string[] };
setter(data.envFiles);
}
} catch { /* silently skip on older nodes */ }
}, [stackName, projectEnvCapable]);
const fetchProjectEnvFiles = useCallback(
() => fetchJsonEnvFiles('', setProjectEnvFiles),
[fetchJsonEnvFiles],
);
const fetchCandidates = useCallback(
() => fetchJsonEnvFiles('/candidates', setCandidates),
[fetchJsonEnvFiles],
);
const fetchInventory = useCallback(async () => {
setLoading(true);
setLoadError(false);
try {
const res = await apiFetch(`/stacks/${stackName}/env-inventory`);
if (!res.ok) {
setLoadError(true);
toast.error('Failed to load the environment inventory.');
return;
}
setInventory((await res.json()) as EnvInventory);
} catch {
setLoadError(true);
toast.error('Failed to load the environment inventory.');
} finally {
setLoading(false);
}
}, [stackName]);
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
setLoadError(false);
try {
const res = await apiFetch(`/stacks/${stackName}/env-inventory`);
if (cancelled) return;
if (!res.ok) {
setLoadError(true);
toast.error('Failed to load the environment inventory.');
return;
}
setInventory((await res.json()) as EnvInventory);
} catch {
if (!cancelled) {
setLoadError(true);
toast.error('Failed to load the environment inventory.');
}
} finally {
if (!cancelled) setLoading(false);
}
await fetchInventory();
if (cancelled) return;
await fetchProjectEnvFiles();
if (cancelled) return;
await fetchCandidates();
};
void run();
return () => { cancelled = true; };
}, [stackName, nodeId]);
}, [stackName, nodeId]); // eslint-disable-line react-hooks/exhaustive-deps
const saveProjectEnvFiles = async (files: string[]) => {
setSavingProjectEnv(true);
try {
const res = await apiFetch(`/stacks/${stackName}/project-env-files`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ envFiles: files }),
});
if (!res.ok) {
const data = await res.json() as { error?: string };
toast.error(data.error ?? 'Failed to save project env file selection.');
return;
}
setProjectEnvFiles(files);
toast.success('Project env file selection saved.');
// Refresh inventory to reflect new interpolation source.
await fetchInventory();
} catch {
toast.error('Failed to save project env file selection.');
} finally {
setSavingProjectEnv(false);
}
};
const addProjectEnvFile = (file: string) => {
if (!file || projectEnvFiles.includes(file)) return;
saveProjectEnvFiles([...projectEnvFiles, file]);
};
const removeProjectEnvFile = (idx: number) => {
saveProjectEnvFiles(projectEnvFiles.filter((_, i) => i !== idx));
};
const moveProjectEnvFile = (idx: number, dir: -1 | 1) => {
const newFiles = [...projectEnvFiles];
const target = idx + dir;
if (target < 0 || target >= newFiles.length) return;
[newFiles[idx], newFiles[target]] = [newFiles[target], newFiles[idx]];
saveProjectEnvFiles(newFiles);
};
const copyChecklist = async () => {
if (!inventory) return;
@@ -154,11 +231,85 @@ export default function EnvironmentPanel({ stackName }: { stackName: string }) {
</div>
<p className="text-[11px] leading-relaxed text-stat-subtitle">
Compose reads <span className="font-mono">.env</span> and the shell for <span className="font-mono">{'${VAR}'}</span> interpolation,
Compose reads the project environment file and the shell for <span className="font-mono">{'${VAR}'}</span> interpolation,
while <span className="font-mono">env_file</span> and inline <span className="font-mono">environment</span> are injected into the container.
Values are never read or shown: likely secrets show presence only.
</p>
{projectEnvCapable && (
<section data-testid="project-env-section">
<div className={cn(LABEL_CLASS, 'mb-1.5')}>project environment file</div>
<div className="rounded-lg border border-muted bg-card/40 px-3 py-2">
{projectEnvFiles.length === 0 ? (
<p className="text-[11px] text-stat-subtitle py-1">
Using <span className="font-mono">.env</span> (default). Compose auto-discovers <span className="font-mono">.env</span> in the project directory.
</p>
) : (
<div className="flex flex-col gap-1">
{projectEnvFiles.map((file, idx) => (
<div key={file} className="flex items-center gap-1.5 py-1 border-t border-muted first:border-t-0">
<span className="font-mono text-[12px] text-foreground/90 flex-1">{file}</span>
{canEdit && (
<>
<button
type="button"
onClick={() => moveProjectEnvFile(idx, -1)}
disabled={idx === 0 || savingProjectEnv}
className="p-0.5 text-stat-subtitle hover:text-brand disabled:opacity-30"
title="Move up"
>
<ChevronUp className="h-3 w-3" strokeWidth={1.5} />
</button>
<button
type="button"
onClick={() => moveProjectEnvFile(idx, 1)}
disabled={idx === projectEnvFiles.length - 1 || savingProjectEnv}
className="p-0.5 text-stat-subtitle hover:text-brand disabled:opacity-30"
title="Move down"
>
<ChevronDown className="h-3 w-3" strokeWidth={1.5} />
</button>
<button
type="button"
onClick={() => removeProjectEnvFile(idx)}
disabled={savingProjectEnv}
className="p-0.5 text-stat-subtitle hover:text-destructive disabled:opacity-30"
title="Remove"
>
<X className="h-3 w-3" strokeWidth={1.5} />
</button>
</>
)}
</div>
))}
</div>
)}
{canEdit && candidates.length > 0 && (
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-muted">
<Select
value=""
onValueChange={addProjectEnvFile}
disabled={savingProjectEnv}
>
<SelectTrigger className="h-7 text-[11px] bg-muted border-none w-full">
<SelectValue placeholder="Add env file…" />
</SelectTrigger>
<SelectContent>
{candidates
.filter(f => !projectEnvFiles.includes(f))
.map(file => (
<SelectItem key={file} value={file} className="text-[11px]">
{file}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
</section>
)}
{loadError ? (
<div className="flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/[0.06] px-3 py-3">
<ShieldAlert className="h-4 w-4 text-destructive" strokeWidth={1.5} />
@@ -6,7 +6,8 @@ vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() },
}));
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' }, hasCapability: () => false }) }));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => ({ can: () => false }) }));
import { apiFetch } from '@/lib/api';
import { copyToClipboard } from '@/lib/clipboard';
+1
View File
@@ -27,6 +27,7 @@ export const CAPABILITIES = [
'update-guard',
'compose-networking',
'env-inventory',
'project-env-files',
'compose-storage',
] as const;