From 41dc339c26ccf950d42b813ff10a10623848751a Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 29 Jun 2026 14:35:50 -0400 Subject: [PATCH] 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 --- backend/src/__tests__/compose-service.test.ts | 91 ++++++++++++++++++- .../environment-check-service.test.ts | 8 ++ .../pilot-agent-bootstrap-secret.test.ts | 31 ++++++- .../src/__tests__/pilot-enrollment.test.ts | 86 +++++++++++++++++- backend/src/bootstrap/startup.ts | 17 ++++ backend/src/routes/nodes.ts | 42 ++++++++- backend/src/services/ComposeService.ts | 60 ++++++++++-- .../src/services/EnvironmentCheckService.ts | 20 +--- backend/src/utils/composePathMapping.ts | 50 ++++++++++ docs/features/multi-node.mdx | 2 +- docs/features/pilot-agent.mdx | 22 +++-- e2e/pilot-agent-enrollment.spec.ts | 6 +- .../nodes/__tests__/useNodeActions.test.tsx | 54 +++++++++++ .../src/components/nodes/useNodeActions.tsx | 45 ++++++++- 14 files changed, 481 insertions(+), 53 deletions(-) create mode 100644 backend/src/utils/composePathMapping.ts create mode 100644 frontend/src/components/nodes/__tests__/useNodeActions.test.tsx diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index 638dc3fb..e5506574 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -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([]); diff --git a/backend/src/__tests__/environment-check-service.test.ts b/backend/src/__tests__/environment-check-service.test.ts index fe87de82..fef96d7c 100644 --- a/backend/src/__tests__/environment-check-service.test.ts +++ b/backend/src/__tests__/environment-check-service.test.ts @@ -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', diff --git a/backend/src/__tests__/pilot-agent-bootstrap-secret.test.ts b/backend/src/__tests__/pilot-agent-bootstrap-secret.test.ts index 0fa3a8c1..202b9fec 100644 --- a/backend/src/__tests__/pilot-agent-bootstrap-secret.test.ts +++ b/backend/src/__tests__/pilot-agent-bootstrap-secret.test.ts @@ -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', () => { diff --git a/backend/src/__tests__/pilot-enrollment.test.ts b/backend/src/__tests__/pilot-enrollment.test.ts index ffd4099a..4d6e8f6c 100644 --- a/backend/src/__tests__/pilot-enrollment.test.ts +++ b/backend/src/__tests__/pilot-enrollment.test.ts @@ -23,7 +23,11 @@ interface ComposeService { image: string; container_name: string; restart: string; - volumes: string[]; + volumes: Array; environment: Record; } @@ -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(); diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index f7a78b69..08bc3156 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -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 { ); } + reconcilePilotComposeDir(); + try { console.log('Running stack migration check...'); const defaultFsService = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()); diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index c040e849..9ba7d245 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -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) { diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 15b1388b..44dce70c 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -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 { + private async assertRequiredEnvPresent(stackName: string): Promise { 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 { - await this.assertRequiredEnvPresent(stackName); + } + + private async assertSafePilotBindMapping(stackName: string): Promise { + 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 { + 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 { - await this.assertRequiredEnvPresent(stackName); + async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise { + await this.assertRequiredEnvPresent(stackName); + await this.assertSafePilotBindMapping(stackName); const stackDir = path.join(this.baseDir, stackName); const debug = isDebugEnabled(); const t0 = Date.now(); diff --git a/backend/src/services/EnvironmentCheckService.ts b/backend/src/services/EnvironmentCheckService.ts index 80a2f3d4..930b1a1e 100644 --- a/backend/src/services/EnvironmentCheckService.ts +++ b/backend/src/services/EnvironmentCheckService.ts @@ -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', diff --git a/backend/src/utils/composePathMapping.ts b/backend/src/utils/composePathMapping.ts new file mode 100644 index 00000000..80d945c2 --- /dev/null +++ b/backend/src/utils/composePathMapping.ts @@ -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, +): 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); +} diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index 7cf718ea..34889f83 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -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**. diff --git a/docs/features/pilot-agent.mdx b/docs/features/pilot-agent.mdx index 7f48bb11..3dbe1ced 100644 --- a/docs/features/pilot-agent.mdx +++ b/docs/features/pilot-agent.mdx @@ -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:///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: + 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:///api/pilot/tunnel` carrying the enrollment token in an `Authorization: Bearer` header. +The agent boots, reads its configuration, and dials `wss:///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: 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 - 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. diff --git a/e2e/pilot-agent-enrollment.spec.ts b/e2e/pilot-agent-enrollment.spec.ts index 486c090a..adb5debd 100644 --- a/e2e/pilot-agent-enrollment.spec.ts +++ b/e2e/pilot-agent-enrollment.spec.ts @@ -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); }); diff --git a/frontend/src/components/nodes/__tests__/useNodeActions.test.tsx b/frontend/src/components/nodes/__tests__/useNodeActions.test.tsx new file mode 100644 index 00000000..7ecd6d67 --- /dev/null +++ b/frontend/src/components/nodes/__tests__/useNodeActions.test.tsx @@ -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 ( + <> + + {NodeActionModals} + + ); +} + +describe('useNodeActions Pilot defaults', () => { + it('starts Pilot enrollment with the 1:1 host compose path', () => { + render(); + 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(); + 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(); + 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'); + }); +}); diff --git a/frontend/src/components/nodes/useNodeActions.tsx b/frontend/src/components/nodes/useNodeActions.tsx index 1a1812cb..590fda95 100644 --- a/frontend/src/components/nodes/useNodeActions.tsx +++ b/frontend/src/components/nodes/useNodeActions.tsx @@ -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 setFormData({ ...formData, compose_dir: e.target.value })} />

- 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.'}