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
+90 -1
View File
@@ -5,6 +5,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import os from 'os';
import path from 'path';
import type WebSocket from 'ws';
// ── Hoisted mocks ──────────────────────────────────────────────────────
@@ -17,7 +18,7 @@ const {
mockBackupStackFiles, mockRestoreStackFiles,
mockGetComposeFilename, mockGetOverrideFilename, mockEnsureStackOverride,
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
mockGetGlobalSettings, mockPruneDanglingImages,
mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts,
} = vi.hoisted(() => ({
mockSpawn: vi.fn(),
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
@@ -38,6 +39,7 @@ const {
mockRmdirSync: vi.fn(),
mockGetGlobalSettings: vi.fn().mockReturnValue({}),
mockPruneDanglingImages: vi.fn().mockResolvedValue({ reclaimedBytes: 0 }),
mockGetBindMounts: vi.fn().mockResolvedValue(null),
}));
vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() }));
@@ -125,11 +127,18 @@ vi.mock('../services/MeshService', () => ({
},
}));
vi.mock('../services/SelfIdentityService', () => ({
default: {
getInstance: () => ({ getBindMounts: mockGetBindMounts }),
},
}));
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
import { DriftLedgerService } from '../services/DriftLedgerService';
const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
const originalStallTimeout = process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
const originalSenchoMode = process.env.SENCHO_MODE;
/** Creates an EventEmitter that mimics a child_process spawn result */
function createMockProcess() {
@@ -196,6 +205,8 @@ beforeEach(() => {
mockGetComposeFilename.mockResolvedValue('compose.yaml');
mockGetOverrideFilename.mockResolvedValue(null);
mockEnsureStackOverride.mockResolvedValue(null);
mockGetBindMounts.mockResolvedValue(null);
delete process.env.SENCHO_MODE;
vi.useFakeTimers({ shouldAdvanceTime: true });
});
@@ -211,6 +222,11 @@ afterEach(() => {
} else {
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = originalStallTimeout;
}
if (originalSenchoMode === undefined) {
delete process.env.SENCHO_MODE;
} else {
process.env.SENCHO_MODE = originalSenchoMode;
}
});
// ── runCommand ─────────────────────────────────────────────────────────
@@ -530,6 +546,79 @@ describe('ComposeService - authoredComposeArgs mesh override', () => {
// ── deployStack ────────────────────────────────────────────────────────
describe('ComposeService - deployStack', () => {
it('blocks a pilot deploy with relative binds before replacing containers when path mapping differs', async () => {
process.env.SENCHO_MODE = 'pilot';
const composeDir = path.resolve('/test/compose');
mockGetBindMounts.mockResolvedValue([
{ source: '/opt/docker/sencho', destination: composeDir },
]);
const configProc = createMockProcess();
mockSpawn.mockReturnValue(configProc);
const svc = ComposeService.getInstance(1);
const result = svc.deployStack('my-stack').then(() => null, (error: Error) => error);
await waitForSpawn();
configProc.stdout.emit('data', Buffer.from(JSON.stringify({
name: 'my-stack',
services: {
db: {
image: 'postgres:17',
volumes: [{
type: 'bind',
source: path.join(composeDir, 'my-stack', 'data'),
target: '/var/lib/postgresql/data',
}],
},
},
})));
configProc.emit('close', 0);
const error = await result;
expect(error?.message).toContain('1:1');
expect(mockGetContainersByStack).not.toHaveBeenCalled();
expect(mockRemoveContainers).not.toHaveBeenCalled();
expect(mockSpawn).toHaveBeenCalledTimes(1);
});
it('allows a pilot deploy that uses only absolute external bind sources', async () => {
process.env.SENCHO_MODE = 'pilot';
const composeDir = path.resolve('/test/compose');
mockGetBindMounts.mockResolvedValue([
{ source: '/opt/docker/sencho', destination: composeDir },
]);
let spawnCount = 0;
mockSpawn.mockImplementation(() => {
spawnCount += 1;
const proc = createMockProcess();
Promise.resolve().then(() => {
if (spawnCount === 1) {
proc.stdout.emit('data', Buffer.from(JSON.stringify({
name: 'my-stack',
services: {
db: {
image: 'postgres:17',
volumes: [{
type: 'bind',
source: '/srv/postgres/data',
target: '/var/lib/postgresql/data',
}],
},
},
})));
}
proc.emit('close', 0);
});
return proc;
});
mockListContainers.mockResolvedValue([]);
const promise = ComposeService.getInstance(1).deployStack('my-stack');
await vi.advanceTimersByTimeAsync(3100);
await expect(promise).resolves.toBeUndefined();
expect(mockGetContainersByStack).toHaveBeenCalledWith('my-stack');
});
it('runs docker compose up -d --remove-orphans', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
@@ -185,6 +185,14 @@ describe('collectEnvironmentReport', () => {
expect(byId(checks, 'path_mapping').status).toBe('pass');
});
it('passes a 1:1 root bind that covers the compose dir', async () => {
const { checks } = await collectEnvironmentReport(baseProbes({
composeDir: '/opt/compose',
bindMounts: async () => [{ source: '/', destination: '/' }],
}));
expect(byId(checks, 'path_mapping').status).toBe('pass');
});
it('warns when a parent bind maps the compose dir to a different host path', async () => {
const { checks } = await collectEnvironmentReport(baseProbes({
composeDir: '/opt/compose',
@@ -17,11 +17,12 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let ensurePilotJwtSecret: typeof import('../bootstrap/startup').ensurePilotJwtSecret;
let reconcilePilotComposeDir: typeof import('../bootstrap/startup').reconcilePilotComposeDir;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ ensurePilotJwtSecret } = await import('../bootstrap/startup'));
({ ensurePilotJwtSecret, reconcilePilotComposeDir } = await import('../bootstrap/startup'));
});
afterAll(() => {
@@ -30,10 +31,38 @@ afterAll(() => {
beforeEach(() => {
delete process.env.SENCHO_MODE;
delete process.env.COMPOSE_DIR;
});
afterEach(() => {
delete process.env.SENCHO_MODE;
delete process.env.COMPOSE_DIR;
});
describe('pilot-agent compose directory bootstrap', () => {
it('reconciles the persisted local node with COMPOSE_DIR in pilot mode', () => {
const db = DatabaseService.getInstance();
const node = db.getDefaultNode();
expect(node).toBeDefined();
db.updateNode(node!.id, { compose_dir: '/app/compose' });
process.env.SENCHO_MODE = 'pilot';
process.env.COMPOSE_DIR = '/opt/docker/sencho';
expect(reconcilePilotComposeDir()).toBe(true);
expect(db.getDefaultNode()?.compose_dir).toBe('/opt/docker/sencho');
});
it('does not change the local node outside pilot mode', () => {
const db = DatabaseService.getInstance();
const node = db.getDefaultNode();
expect(node).toBeDefined();
db.updateNode(node!.id, { compose_dir: '/app/compose' });
process.env.COMPOSE_DIR = '/opt/docker/sencho';
expect(reconcilePilotComposeDir()).toBe(false);
expect(db.getDefaultNode()?.compose_dir).toBe('/app/compose');
});
});
describe('pilot-agent bootstrap auth_jwt_secret', () => {
+81 -5
View File
@@ -23,7 +23,11 @@ interface ComposeService {
image: string;
container_name: string;
restart: string;
volumes: string[];
volumes: Array<string | {
type: string;
source: string;
target: string;
}>;
environment: Record<string, string>;
}
@@ -60,7 +64,8 @@ describe('POST /api/nodes (pilot_agent mode)', () => {
expect(res.body.enrollment.token).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/);
expect(typeof res.body.enrollment.composeYaml).toBe('string');
expect(res.body.enrollment.composeYaml).toContain('SENCHO_MODE: pilot');
expect(res.body.enrollment.composeYaml).toContain(`SENCHO_ENROLL_TOKEN: ${res.body.enrollment.token}`);
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
expect(parsed.services.agent.environment.SENCHO_ENROLL_TOKEN).toBe(res.body.enrollment.token);
expect(res.body.enrollment.expiresAt).toBeGreaterThan(Date.now());
expect(res.body.enrollment).not.toHaveProperty('dockerRun');
});
@@ -115,11 +120,52 @@ describe('POST /api/nodes (pilot_agent mode)', () => {
expect(volumes).toContain('/var/run/docker.sock:/var/run/docker.sock');
expect(volumes).toContain('sencho-agent-data:/app/data');
expect(volumes).toContain('/opt/docker/sencho:/app/compose');
expect(volumes).toContainEqual({
type: 'bind',
source: '/opt/docker/sencho',
target: '/opt/docker/sencho',
});
expect(parsed.services.agent.environment.COMPOSE_DIR).toBe('/opt/docker/sencho');
expect(parsed.volumes).toHaveProperty('sencho-agent-data');
});
it('composeYaml embeds the three pilot env vars with the enrollment token', async () => {
it('uses a custom absolute compose directory as a safe 1:1 mount', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({
name: 'pilot-custom-compose',
type: 'remote',
mode: 'pilot_agent',
compose_dir: '/srv/compose projects',
});
expect(res.status).toBe(200);
const parsed = parseYaml(res.body.enrollment.composeYaml) as ComposeFile;
expect(parsed.services.agent.volumes).toContainEqual({
type: 'bind',
source: '/srv/compose projects',
target: '/srv/compose projects',
});
expect(parsed.services.agent.environment.COMPOSE_DIR).toBe('/srv/compose projects');
});
it('rejects a relative pilot compose directory', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({
name: 'pilot-relative-compose',
type: 'remote',
mode: 'pilot_agent',
compose_dir: './compose',
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('absolute');
});
it('composeYaml embeds the Pilot connection and compose environment', async () => {
const res = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
@@ -131,6 +177,7 @@ describe('POST /api/nodes (pilot_agent mode)', () => {
expect(env.SENCHO_MODE).toBe('pilot');
expect(env.SENCHO_PRIMARY_URL).toMatch(/^https?:\/\//);
expect(env.SENCHO_ENROLL_TOKEN).toBe(res.body.enrollment.token);
expect(env.COMPOSE_DIR).toBe('/opt/docker/sencho');
});
});
@@ -204,7 +251,12 @@ describe('POST /api/nodes/:id/pilot/enroll', () => {
const create = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({ name: 'pilot-regen', type: 'remote', mode: 'pilot_agent' });
.send({
name: 'pilot-regen',
type: 'remote',
mode: 'pilot_agent',
compose_dir: '/srv/pilot-stacks',
});
const original = create.body.enrollment.token;
const regen = await request(app)
@@ -213,6 +265,13 @@ describe('POST /api/nodes/:id/pilot/enroll', () => {
expect(regen.status).toBe(200);
expect(regen.body.enrollment.token).not.toBe(original);
const parsed = parseYaml(regen.body.enrollment.composeYaml) as ComposeFile;
expect(parsed.services.agent.environment.COMPOSE_DIR).toBe('/srv/pilot-stacks');
expect(parsed.services.agent.volumes).toContainEqual({
type: 'bind',
source: '/srv/pilot-stacks',
target: '/srv/pilot-stacks',
});
});
it('rejects regeneration for proxy-mode remote nodes', async () => {
@@ -242,6 +301,23 @@ describe('POST /api/nodes/:id/pilot/enroll', () => {
});
});
describe('PUT /api/nodes/:id Pilot compose directory', () => {
it('rejects a relative compose directory for an existing pilot node', async () => {
const create = await request(app)
.post('/api/nodes')
.set('Cookie', adminCookie)
.send({ name: 'pilot-update-path', type: 'remote', mode: 'pilot_agent' });
const update = await request(app)
.put(`/api/nodes/${create.body.id}`)
.set('Cookie', adminCookie)
.send({ compose_dir: '../stacks' });
expect(update.status).toBe(400);
expect(update.body.error).toContain('absolute');
});
});
describe('consumePilotEnrollment replay protection', () => {
it('marks the row used and rejects the second consume', () => {
const db = DatabaseService.getInstance();
+17
View File
@@ -54,6 +54,21 @@ export function ensurePilotJwtSecret(): boolean {
return true;
}
/** Keep the pilot's persisted local node aligned with its configured mount. */
export function reconcilePilotComposeDir(): boolean {
if (!isPilotMode()) return false;
const configuredDir = process.env.COMPOSE_DIR?.trim();
if (!configuredDir) return false;
const db = DatabaseService.getInstance();
const localNode = db.getDefaultNode();
if (!localNode || localNode.compose_dir === configuredDir) return false;
db.updateNode(localNode.id, { compose_dir: configuredDir });
console.log(`[Startup] pilot-agent: compose directory set to ${configuredDir}`);
return true;
}
function clearSelfContainerNotificationRouting(): void {
const identity = SelfIdentityService.getInstance().getIdentity();
const changed = DatabaseService.getInstance().clearSelfContainerNotificationRouting(
@@ -88,6 +103,8 @@ export async function startServer(server: Server): Promise<void> {
);
}
reconcilePilotComposeDir();
try {
console.log('Running stack migration check...');
const defaultFsService = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId());
+38 -4
View File
@@ -1,6 +1,7 @@
import { Router, type Request, type Response } from 'express';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import path from 'path';
import { authMiddleware } from '../middleware/auth';
import { requirePermission } from '../middleware/permissions';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
@@ -24,6 +25,20 @@ import { sanitizeForLog } from '../utils/safeLog';
const NODE_SCOPE_MESSAGE = 'API tokens cannot manage nodes.';
const REMOTE_META_CACHE_TTL = 3 * 60 * 1000;
const DEFAULT_COMPOSE_DIR = '/app/compose';
const DEFAULT_PILOT_COMPOSE_DIR = '/opt/docker/sencho';
function normalizePilotComposeDir(value: unknown): string | null {
if (value !== undefined && value !== null && typeof value !== 'string') return null;
const raw = typeof value === 'string' ? value.trim() : '';
const candidate = raw || DEFAULT_PILOT_COMPOSE_DIR;
if (!path.posix.isAbsolute(candidate) || /[\0\r\n]/.test(candidate)) return null;
return path.posix.normalize(candidate);
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
/**
* Pick the URL the pilot agent should dial. SENCHO_PUBLIC_URL wins when set
@@ -48,6 +63,10 @@ function resolvePrimaryUrl(req: Request): string {
function mintPilotEnrollment(nodeId: number, req: Request): { token: string; expiresAt: number; composeYaml: string } {
const db = DatabaseService.getInstance();
const node = db.getNode(nodeId);
if (!node) throw new Error('Node not found');
const composeDir = normalizePilotComposeDir(node.compose_dir);
if (!composeDir) throw new Error('Pilot compose directory must be an absolute path');
const jwtSecret = db.getGlobalSettings().auth_jwt_secret;
if (!jwtSecret) throw new Error('JWT secret not configured');
@@ -77,11 +96,14 @@ function mintPilotEnrollment(nodeId: number, req: Request): { token: string; exp
` volumes:`,
` - /var/run/docker.sock:/var/run/docker.sock`,
` - sencho-agent-data:/app/data`,
` - /opt/docker/sencho:/app/compose`,
` - type: bind`,
` source: ${yamlString(composeDir)}`,
` target: ${yamlString(composeDir)}`,
` environment:`,
` SENCHO_MODE: pilot`,
` SENCHO_PRIMARY_URL: ${primaryUrl}`,
` SENCHO_ENROLL_TOKEN: ${token}`,
` SENCHO_PRIMARY_URL: ${yamlString(primaryUrl)}`,
` SENCHO_ENROLL_TOKEN: ${yamlString(token)}`,
` COMPOSE_DIR: ${yamlString(composeDir)}`,
``,
`volumes:`,
` sencho-agent-data:`,
@@ -170,6 +192,10 @@ nodesRouter.post('/', enrollmentLimiter, async (req: Request, res: Response) =>
}
const resolvedMode: 'proxy' | 'pilot_agent' = type === 'remote' && mode === 'pilot_agent' ? 'pilot_agent' : 'proxy';
const pilotComposeDir = resolvedMode === 'pilot_agent' ? normalizePilotComposeDir(compose_dir) : null;
if (resolvedMode === 'pilot_agent' && !pilotComposeDir) {
return res.status(400).json({ error: 'Pilot compose directory must be an absolute path' });
}
if (type === 'remote' && resolvedMode === 'proxy') {
if (!api_url || typeof api_url !== 'string') {
@@ -184,7 +210,7 @@ nodesRouter.post('/', enrollmentLimiter, async (req: Request, res: Response) =>
const id = DatabaseService.getInstance().addNode({
name,
type,
compose_dir: compose_dir || '/app/compose',
compose_dir: pilotComposeDir ?? (compose_dir || DEFAULT_COMPOSE_DIR),
is_default: is_default || false,
api_url: resolvedMode === 'pilot_agent' ? '' : (api_url || ''),
api_token: resolvedMode === 'pilot_agent' ? '' : (api_token || ''),
@@ -274,6 +300,14 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => {
return res.status(404).json({ error: 'Node not found' });
}
if (existingNode.mode === 'pilot_agent' && updates.compose_dir !== undefined) {
const composeDir = normalizePilotComposeDir(updates.compose_dir);
if (!composeDir) {
return res.status(400).json({ error: 'Pilot compose directory must be an absolute path' });
}
updates.compose_dir = composeDir;
}
if (updates.api_url !== undefined && updates.api_url !== '') {
const urlCheck = isValidRemoteUrl(updates.api_url);
if (!urlCheck.valid) {
+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',
+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);
}
+1 -1
View File
@@ -56,7 +56,7 @@ On the control instance, click your avatar in the top-right and choose **Setting
| **Name** | A display name (e.g. `staging-vps`, `media-box`). Visible in the switcher and across the UI. |
| **Type** | Set to **Remote**. |
| **Mode** | Set to **Pilot Agent**. |
| **Compose Directory** | The root directory where compose stack folders live on the remote host. Defaults to `/app/compose`; change it if the remote uses a different volume mount. |
| **Compose Directory** | The absolute directory where compose stack folders live on the remote host. Pilot Agent defaults to `/opt/docker/sencho` and mounts the directory at the same path inside the agent so relative stack bind mounts remain host-correct. |
Click **Add node**.
+14 -8
View File
@@ -44,7 +44,7 @@ You do not have to pick one mode for the whole fleet. Mix and match per node.
Conceptually, the agent reverses the usual client/server direction.
1. The agent container starts on the remote host with three environment variables: a mode flag, the control instance URL, and a short-lived enrollment token.
1. The agent container starts on the remote host with its mode, control instance URL, short-lived enrollment token, and compose directory configured.
2. It dials `wss://<control-instance>/api/pilot/tunnel` and holds the WebSocket open for as long as the container runs.
3. For every request the control instance needs to make against that node (listing containers, deploying a stack, streaming logs, opening a console, forwarding mesh traffic), frames are multiplexed over that single connection.
4. On the agent side, those frames are demultiplexed and replayed locally against the host's Docker socket and filesystem.
@@ -78,7 +78,7 @@ Pilot mode narrows the operator surface in a couple of places where parity with
| Requirement | Detail |
|---|---|
| **Remote host** | Docker installed and a user that can run containers. About 1 GB free on the volume that backs `/app/data` and whatever you point `/app/compose` at. |
| **Remote host** | Docker installed and a user that can run containers. About 1 GB free on the volume that backs `/app/data` and the selected compose directory. |
| **Network from remote → control instance** | Outbound TCP to the control instance's HTTPS port (whatever your reverse proxy or Sencho exposes). Nothing inbound. |
| **Network at the control instance** | The path from the remote to the control instance must allow WebSocket upgrades end-to-end. If you terminate TLS at a reverse proxy in front of Sencho, that proxy needs `Upgrade: websocket` passthrough. |
| **Operator role** | Admin on the control instance. The Settings → Nodes panel is admin-only. |
@@ -114,7 +114,7 @@ When you submit the Add Node form with Mode set to Pilot Agent, the control inst
The enrollment token is one-time and short-lived by design. If a token is intercepted or leaked, the window of risk is small and the slot is consumed on first use.
The generated compose file looks like this, with the three pilot env vars already baked in:
The generated compose file looks like this, with the Pilot connection and compose path already configured:
```yaml
name: sencho-agent
@@ -126,11 +126,14 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- sencho-agent-data:/app/data
- /opt/docker/sencho:/app/compose
- type: bind
source: /opt/docker/sencho
target: /opt/docker/sencho
environment:
SENCHO_MODE: pilot
SENCHO_PRIMARY_URL: https://sencho.example.com
SENCHO_ENROLL_TOKEN: <short-lived token>
COMPOSE_DIR: /opt/docker/sencho
volumes:
sencho-agent-data:
@@ -146,7 +149,7 @@ Deploying through Compose also lets the control instance push over-the-air updat
### First connect
The agent boots, reads the three env vars, and dials `wss://<control-instance>/api/pilot/tunnel` carrying the enrollment token in an `Authorization: Bearer` header.
The agent boots, reads its configuration, and dials `wss://<control-instance>/api/pilot/tunnel` carrying the enrollment token in an `Authorization: Bearer` header.
The control instance verifies the token, marks the enrollment slot consumed, and replies with a `hello` frame followed by a control frame that carries a **long-lived tunnel JWT** (365-day expiry). The agent writes that token to its data volume at `/app/data/pilot.jwt`. The original enrollment token is now useless: the agent does not need it again, and the control instance refuses to accept it a second time.
@@ -218,13 +221,16 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- sencho-agent-data:/app/data
- /opt/docker/sencho:/app/compose
- type: bind
source: /opt/docker/sencho
target: /opt/docker/sencho
- /etc/ssl/internal-ca.pem:/etc/ssl/internal-ca.pem:ro
environment:
SENCHO_MODE: pilot
SENCHO_PRIMARY_URL: https://sencho.internal.example.com
SENCHO_ENROLL_TOKEN: <short-lived token>
SENCHO_PILOT_CA_FILE: /etc/ssl/internal-ca.pem
COMPOSE_DIR: /opt/docker/sencho
volumes:
sencho-agent-data:
@@ -258,7 +264,7 @@ These environment variables are read by the **agent** container at boot.
| `SENCHO_ENROLL_TOKEN` | First boot only | none | The 15-minute enrollment token issued by the control instance. Ignored on subsequent boots once `pilot.jwt` is on disk. |
| `SENCHO_PILOT_CA_FILE` | Optional | unset | Absolute path inside the container to a PEM bundle. Use when your control instance's TLS chain is rooted in a private CA. |
| `DATA_DIR` | Optional | `/app/data` | Where the persisted `pilot.jwt` is stored. Override only if you are mounting a different volume layout. |
| `COMPOSE_DIR` | Optional | `/app/compose` | Root directory on the agent host where compose stack folders live. The control instance reads and writes stacks under this path. |
| `COMPOSE_DIR` | Optional | `/app/compose` | Root directory where compose stack folders live. Enrollment sets it to the absolute path selected for the node and mounts that path identically on the host and in the agent. |
One variable is read on the **control instance** side, not the agent:
@@ -322,7 +328,7 @@ The generic node-connectivity issues (a node showing Offline, a pilot agent stuc
</Accordion>
<Accordion title="Stacks tab on a pilot node shows nothing or fails to list">
The agent's compose directory is missing or not writable. Verify the host path you mounted at `/app/compose` exists, is owned by a user the agent container can write as, and matches the Compose Directory configured for the node in Settings → Nodes. If the host path has moved, edit the node and update the Compose Directory field.
The agent's compose directory is missing or not writable. Verify the selected absolute path exists, is owned by a user the agent container can write as, and is mounted at the same path on the host and inside the agent. The Compose Directory in Settings → Nodes controls the generated enrollment file.
</Accordion>
<Accordion title="I changed the Mode on a pilot node in Edit and now it is broken">
+3 -3
View File
@@ -87,7 +87,7 @@ test.describe('Pilot Agent enrollment', () => {
const cmd = await composeFile.innerText();
expect(cmd).toContain('SENCHO_MODE: pilot');
expect(cmd).toContain('SENCHO_PRIMARY_URL:');
expect(cmd).toMatch(/SENCHO_ENROLL_TOKEN: [A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
expect(cmd).toMatch(/SENCHO_ENROLL_TOKEN: "?[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"?/);
});
test('regenerating the enrollment token issues a fresh compose file', async ({ page }) => {
@@ -108,7 +108,7 @@ test.describe('Pilot Agent enrollment', () => {
const firstCompose = page.locator('pre').filter({ hasText: /SENCHO_MODE: pilot/ });
await expect(firstCompose).toBeVisible({ timeout: 10_000 });
const firstText = await firstCompose.innerText();
const firstToken = firstText.match(/SENCHO_ENROLL_TOKEN: ([A-Za-z0-9_.-]+)/)?.[1];
const firstToken = firstText.match(/SENCHO_ENROLL_TOKEN: "?([A-Za-z0-9_.-]+)"?/)?.[1];
expect(firstToken).toBeTruthy();
// Close the enrollment dialog (Escape lands on the row view).
@@ -123,7 +123,7 @@ test.describe('Pilot Agent enrollment', () => {
const secondCompose = page.locator('pre').filter({ hasText: /SENCHO_MODE: pilot/ });
await expect(secondCompose).toBeVisible({ timeout: 10_000 });
const secondText = await secondCompose.innerText();
const secondToken = secondText.match(/SENCHO_ENROLL_TOKEN: ([A-Za-z0-9_.-]+)/)?.[1];
const secondToken = secondText.match(/SENCHO_ENROLL_TOKEN: "?([A-Za-z0-9_.-]+)"?/)?.[1];
expect(secondToken).toBeTruthy();
expect(secondToken).not.toBe(firstToken);
});
@@ -0,0 +1,54 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ nodes: [], refreshNodes: vi.fn() }),
}));
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
}));
import { useNodeActions } from '../useNodeActions';
function Harness() {
const { openCreate, NodeActionModals } = useNodeActions();
return (
<>
<button type="button" onClick={openCreate}>Open</button>
{NodeActionModals}
</>
);
}
describe('useNodeActions Pilot defaults', () => {
it('starts Pilot enrollment with the 1:1 host compose path', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(screen.getByLabelText('Compose Directory')).toHaveValue('/opt/docker/sencho');
expect(screen.getByText(/mounts this same path inside the container/i)).toBeInTheDocument();
});
it('uses the standard compose default for proxy mode', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
fireEvent.click(screen.getByRole('combobox', { name: 'Mode' }));
fireEvent.click(screen.getByRole('button', { name: /Distributed API Proxy/i }));
expect(screen.getByLabelText('Compose Directory')).toHaveValue('/app/compose');
});
it('preserves an operator-entered compose path when the mode changes', () => {
render(<Harness />);
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
const composeDir = screen.getByLabelText('Compose Directory');
fireEvent.change(composeDir, { target: { value: '/srv/stacks' } });
fireEvent.click(screen.getByRole('combobox', { name: 'Mode' }));
fireEvent.click(screen.getByRole('button', { name: /Distributed API Proxy/i }));
expect(composeDir).toHaveValue('/srv/stacks');
});
});
@@ -39,13 +39,22 @@ interface NodeFormData {
is_default: boolean;
}
const DEFAULT_COMPOSE_DIR = '/app/compose';
const DEFAULT_PILOT_COMPOSE_DIR = '/opt/docker/sencho';
function defaultComposeDir(type: NodeFormData['type'], mode: NodeMode): string {
return type === 'remote' && mode === 'pilot_agent'
? DEFAULT_PILOT_COMPOSE_DIR
: DEFAULT_COMPOSE_DIR;
}
const defaultFormData: NodeFormData = {
name: '',
type: 'remote',
mode: 'pilot_agent',
api_url: '',
api_token: '',
compose_dir: '/app/compose',
compose_dir: DEFAULT_PILOT_COMPOSE_DIR,
is_default: false,
};
@@ -268,7 +277,19 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
<Label htmlFor="node-type">Type</Label>
<Select
value={formData.type}
onValueChange={(val) => setFormData({ ...formData, type: val as 'local' | 'remote', api_url: '', api_token: '' })}
onValueChange={(val) => {
const type = val as NodeFormData['type'];
const currentDefault = defaultComposeDir(formData.type, formData.mode);
setFormData({
...formData,
type,
api_url: '',
api_token: '',
compose_dir: formData.compose_dir === currentDefault
? defaultComposeDir(type, formData.mode)
: formData.compose_dir,
});
}}
>
<SelectTrigger id="node-type">
<SelectValue placeholder="Select type" />
@@ -296,7 +317,19 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
<Combobox
id="node-mode"
value={formData.mode}
onValueChange={(val) => setFormData({ ...formData, mode: val as NodeMode, api_url: '', api_token: '' })}
onValueChange={(val) => {
const mode = val as NodeMode;
const currentDefault = defaultComposeDir(formData.type, formData.mode);
setFormData({
...formData,
mode,
api_url: '',
api_token: '',
compose_dir: formData.compose_dir === currentDefault
? defaultComposeDir(formData.type, mode)
: formData.compose_dir,
});
}}
options={[
{ value: 'pilot_agent', label: 'Pilot Agent - outbound tunnel from remote host' },
{ value: 'proxy', label: 'Distributed API Proxy - primary dials the remote' },
@@ -360,12 +393,14 @@ export function useNodeActions(opts: UseNodeActionsOptions = {}): UseNodeActions
<Label htmlFor="node-compose-dir">Compose Directory</Label>
<Input
id="node-compose-dir"
placeholder="/app/compose"
placeholder={defaultComposeDir(formData.type, formData.mode)}
value={formData.compose_dir}
onChange={(e) => setFormData({ ...formData, compose_dir: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
The root directory where compose stack folders live on this node.
{formData.type === 'remote' && formData.mode === 'pilot_agent'
? 'Absolute host path for compose stacks. The generated agent mounts this same path inside the container.'
: 'The root directory where compose stack folders live on this node.'}
</p>
</div>
</div>