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
+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);
}