fix: enforce 1:1 compose path mapping for Pilot agent mounts (#1516)

* fix: enforce 1:1 compose path mapping for Pilot agent mounts

Pilot enrollment now generates validated 1:1 bind mounts so every
agent path maps to a unique compose directory. Persisted agent paths
reconcile during startup to catch drift. Unsafe relative-bind redeploys
are blocked before container removal to prevent path escapes.

- Add composePathMapping utility with strict path validation
- Generate COMPOSE_DIR and validated mounts during Pilot enrollment
- Reconcile persisted agent paths during startup bootstrap
- Block redeploy when a relative-bind mount would escape the compose root
- Default Pilot UI path to /opt/docker/sencho
- Update multi-node and pilot-agent documentation
- Add regression tests for enrollment, bootstrap, compose-service,
  and environment-check paths

* fix: update E2E enrollment regexes for YAML-quoted token values
This commit is contained in:
Anso
2026-06-29 14:35:50 -04:00
committed by GitHub
parent 9ff678a7bb
commit 41dc339c26
14 changed files with 481 additions and 53 deletions
+50
View File
@@ -0,0 +1,50 @@
import path from 'path';
export interface BindPathMapping {
source: string;
destination: string;
}
function normalizePath(value: string): string {
if (value === '/' || /^[A-Za-z]:[\\/]?$/.test(value)) return value;
return value.replace(/[\\/]+$/, '');
}
function isAtOrBelow(candidate: string, base: string): boolean {
const resolvedCandidate = path.resolve(candidate);
const resolvedBase = path.resolve(base);
// After resolution, verify the candidate still starts with the base followed
// by a separator (or is an exact match). This catches `..` segments that
// would pass a naive string-prefix check but escape the intended directory.
if (resolvedCandidate === resolvedBase) return true;
// Filesystem root: any resolved absolute path is at or below it.
if (resolvedBase === path.resolve('/')) return path.isAbsolute(resolvedCandidate);
if (/^[A-Za-z]:[\\/]?$/.test(resolvedBase)) {
return resolvedCandidate.toLowerCase().startsWith(resolvedBase.slice(0, 2).toLowerCase());
}
return resolvedCandidate.startsWith(resolvedBase + path.sep);
}
/** Resolve a container-visible path to the corresponding host bind path. */
export function resolveHostBindPath(
containerPath: string,
mounts: ReadonlyArray<BindPathMapping>,
): string | null {
const target = normalizePath(containerPath);
const match = mounts
.filter((mount) => isAtOrBelow(target, normalizePath(mount.destination)))
.sort((a, b) => normalizePath(b.destination).length - normalizePath(a.destination).length)[0];
if (!match) return null;
const destination = normalizePath(match.destination);
const source = normalizePath(match.source);
const suffix = target.slice(destination.length).replace(/^[\\/]+/, '');
if (!suffix) return source;
const separator = source.endsWith('/') || source.endsWith('\\') ? '' : '/';
return `${source}${separator}${suffix}`;
}
export function pathsMatch(left: string, right: string): boolean {
return normalizePath(left) === normalizePath(right);
}