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
@@ -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>