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
+51 -9
View File
@@ -10,7 +10,8 @@ import { MeshService } from './MeshService';
import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
import { RegistryService } from './RegistryService';
import { DriftLedgerService } from './DriftLedgerService';
import { DriftLedgerService } from './DriftLedgerService';
import SelfIdentityService from './SelfIdentityService';
import { parseEffectiveModel } from './preflight/effectiveModel';
import { deriveStackExposure } from './preflight/exposure';
@@ -21,7 +22,8 @@ import { describeSpawnError } from '../utils/spawnErrors';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
import { parseMissingRequiredVars } from '../helpers/envVarParse';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
export class ComposeRollbackError extends Error {
public readonly rollbackAttempted: boolean;
@@ -395,7 +397,7 @@ export class ComposeService {
* no env value is materialized. Default off and any settings-read failure both
* fall through without blocking.
*/
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
private async assertRequiredEnvPresent(stackName: string): Promise<void> {
let enabled = false;
try {
enabled = DatabaseService.getInstance().getGlobalSettings()['env_block_deploy_on_missing_required'] === '1';
@@ -411,10 +413,49 @@ export class ComposeService {
`Deploy blocked: required environment variable${plural ? 's' : ''} ${missing.join(', ')} ` +
`${plural ? 'are' : 'is'} missing. Define ${plural ? 'them' : 'it'} in a .env or env_file, then deploy again.`,
);
}
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
await this.assertRequiredEnvPresent(stackName);
}
private async assertSafePilotBindMapping(stackName: string): Promise<void> {
if (process.env.SENCHO_MODE !== 'pilot') return;
let mounts: Array<{ source: string; destination: string }> | null;
try {
mounts = await SelfIdentityService.getInstance().getBindMounts();
} catch (error) {
console.warn('[ComposeService] Could not verify pilot compose path mapping:', sanitizeForLog(getErrorMessage(error, 'unknown')));
return;
}
if (mounts === null) return;
const composeDir = path.resolve(this.baseDir);
const hostComposeDir = resolveHostBindPath(composeDir, mounts);
if (!hostComposeDir || pathsMatch(hostComposeDir, composeDir)) return;
const rendered = await this.renderConfig(stackName);
if (rendered.rendered === null) return;
let parsed: unknown;
try {
parsed = JSON.parse(rendered.rendered);
} catch (error) {
console.warn('[ComposeService] Could not inspect rendered binds for pilot path safety:', sanitizeForLog(getErrorMessage(error, 'unknown')));
return;
}
const model = parseEffectiveModel(parsed, stackName);
const unsafeBind = model.services
.flatMap((service) => service.binds)
.find((bind) => isPathWithinBase(path.resolve(bind.source), composeDir));
if (!unsafeBind) return;
throw new Error(
`Deploy blocked: relative bind mounts resolve under ${composeDir}, but the host path is ${hostComposeDir}. ` +
`Use a 1:1 mount with the same absolute path on the host and in the Pilot Agent, then retry.`,
);
}
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
await this.assertRequiredEnvPresent(stackName);
await this.assertSafePilotBindMapping(stackName);
const stackDir = path.join(this.baseDir, stackName);
const debug = isDebugEnabled();
const t0 = Date.now();
@@ -606,8 +647,9 @@ export class ComposeService {
startStream();
}
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
await this.assertRequiredEnvPresent(stackName);
async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
await this.assertRequiredEnvPresent(stackName);
await this.assertSafePilotBindMapping(stackName);
const stackDir = path.join(this.baseDir, stackName);
const debug = isDebugEnabled();
const t0 = Date.now();
@@ -26,6 +26,7 @@ import DockerController from './DockerController';
import { NodeRegistry } from './NodeRegistry';
import SelfIdentityService from './SelfIdentityService';
import { withTimeout } from '../utils/withTimeout';
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
const execFileAsync = promisify(execFile);
@@ -222,17 +223,8 @@ function checkPathMapping(dir: string, mounts: BindMounts): EnvironmentCheck {
detail: 'Sencho is not running in a container; host and container paths are the same.',
};
}
const target = normPath(dir);
// The bind mount covering the compose dir is the one whose destination is the
// longest path prefix of it, so a parent bind (-v /opt:/opt) covers
// COMPOSE_DIR=/opt/compose just as a direct -v /opt/compose:/opt/compose does.
const match = mounts
.filter(m => {
const d = normPath(m.destination);
return target === d || target.startsWith(d + '/') || target.startsWith(d + '\\');
})
.sort((a, b) => normPath(b.destination).length - normPath(a.destination).length)[0];
if (!match) {
const hostPath = resolveHostBindPath(dir, mounts);
if (!hostPath) {
return {
...base,
status: 'warn',
@@ -242,11 +234,7 @@ function checkPathMapping(dir: string, mounts: BindMounts): EnvironmentCheck {
+ `bind mounts in your stacks resolve against the container filesystem instead of the host.`,
};
}
// The host path the daemon resolves for the compose dir: the mount source
// plus the compose dir's path below the mount destination.
const relative = target.slice(normPath(match.destination).length);
const hostPath = normPath(normPath(match.source) + relative);
if (hostPath !== target) {
if (!pathsMatch(hostPath, dir)) {
return {
...base,
status: 'warn',