Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,20 @@ export default function App() {

const handleResumePause = useCallback(async () => {
if (!activePause || !createState.runId) return
if (activePause.pause_id === 'setup-request') return
await resumePause(createState.runId, activePause.pause_id)
}, [activePause, createState.runId, resumePause])

const handleSetupRequestResume = useCallback(async (requestId: string) => {
if (!createState.runId) return
await resumePause(createState.runId, 'setup-request', { request_id: requestId })
}, [createState.runId, resumePause])

const handleVerifyPasskey = useCallback(async () => {
if (!createState.runId) return
await verifyPasskey(createState.runId)
}, [createState.runId, verifyPasskey])
if (activePause?.pause_id !== 'passkey' && activePause?.pause_id !== 'initial-passkey') return
await verifyPasskey(createState.runId, activePause.pause_id)
}, [activePause, createState.runId, verifyPasskey])

// Auto-start bootstrap exactly ONCE per deploy. The ref latch is essential:
// guarding on !runId alone re-fired the effect after every reducer reset and
Expand Down Expand Up @@ -313,6 +320,7 @@ export default function App() {
passkeyChecking={createFlow.passkeyChecking}
passkeyStatus={createFlow.passkeyStatus}
onResume={handleResumePause}
onSetupRequestResume={handleSetupRequestResume}
onVerifyPasskey={handleVerifyPasskey}
envId={renderedEnvId ?? ''}
runId={createState.runId ?? ''}
Expand Down Expand Up @@ -343,7 +351,7 @@ export default function App() {
}

// Sidebar sub-items for the active rail phase.
const PAUSE_IDS = ['workstation', 'passkey']
const PAUSE_IDS = ['workstation', 'passkey', 'initial-passkey', 'setup-request']
const wizardPages = ['Identity', 'Target node', 'Security', 'Review & Deploy']
const subItems: Record<string, RailSubItem[]> = {
configure: wizardPages.map((label, i) => ({
Expand Down
46 changes: 42 additions & 4 deletions frontend/src/create/ConnectStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type ConnectStepProps = {
passkeyChecking: boolean
passkeyStatus: string | null
onResume: () => void
onSetupRequestResume: (requestId: string) => void
onVerifyPasskey: () => void
envId: string
runId: string
Expand All @@ -49,12 +50,14 @@ export function ConnectStep({
passkeyChecking,
passkeyStatus,
onResume,
onSetupRequestResume,
onVerifyPasskey,
runId,
pollPasskey,
bundleSafe,
}: ConnectStepProps) {
const [passkeyCount, setPasskeyCount] = useState<{ confirmed: number; required: number } | null>(null)
const [setupRequestId, setSetupRequestId] = useState('')
const stationRef = useRef<HTMLDivElement>(null)

useEffect(() => {
Expand All @@ -64,7 +67,8 @@ export function ConnectStep({
}, [activePause?.pause_id])

useEffect(() => {
if (activePause?.pause_id !== 'passkey' || !runId) return
if (activePause?.pause_id !== 'passkey' && activePause?.pause_id !== 'initial-passkey') return
if (!runId) return
let cancelled = false
const interval = setInterval(async () => {
const result = await pollPasskey(runId)
Expand All @@ -76,6 +80,10 @@ export function ConnectStep({
return () => { cancelled = true; clearInterval(interval) }
}, [activePause?.pause_id, runId, pollPasskey])

useEffect(() => {
setSetupRequestId('')
}, [activePause?.pause_id])

function downloadCaCertificate(payload: CaCertPayload) {
const blob = new Blob([payload.pem], { type: 'application/x-pem-file' })
const objectUrl = URL.createObjectURL(blob)
Expand Down Expand Up @@ -165,7 +173,7 @@ export function ConnectStep({
)}

{/* Passkey station */}
{activePause.pause_id === 'passkey' && (
{(activePause.pause_id === 'passkey' || activePause.pause_id === 'initial-passkey') && (
<PasskeyStation
payload={activePause.payload as PasskeyPayload}
passkeyCount={passkeyCount}
Expand All @@ -175,6 +183,27 @@ export function ConnectStep({
/>
)}

{activePause.pause_id === 'setup-request' && (
<div className="grid gap-3">
<p className="text-sm leading-6 text-muted">
{(activePause.payload.message as string) ??
'Create the fixed born-inventory setup request in the DMF Console, then paste its request id here.'}
</p>
<label className="grid gap-1 text-sm">
<span>Setup request id</span>
<input
autoComplete="off"
spellCheck={false}
maxLength={32}
value={setupRequestId}
onChange={(event) => setSetupRequestId(event.target.value.trim())}
placeholder="32 lowercase hexadecimal characters"
className="rounded-md border border-border bg-bg px-3 py-2 font-mono text-sm"
/>
</label>
</div>
)}

{/* Export-gate station: block resume until recovery bundle is saved */}
{activePause.pause_id === 'checkpoint-2-export-gate' && (
<div className="grid gap-3">
Expand All @@ -200,7 +229,7 @@ export function ConnectStep({

{/* Continue button */}
<div className="mt-4 flex flex-wrap items-center gap-3 border-t border-border pt-3">
{activePause.pause_id === 'passkey' ? (
{activePause.pause_id === 'passkey' || activePause.pause_id === 'initial-passkey' ? (
<button
type="button"
onClick={onVerifyPasskey}
Expand All @@ -209,6 +238,15 @@ export function ConnectStep({
>
{passkeyChecking ? 'Checking…' : resumeBusy ? 'Continuing…' : 'Verify & Continue'}
</button>
) : activePause.pause_id === 'setup-request' ? (
<button
type="button"
onClick={() => onSetupRequestResume(setupRequestId)}
disabled={resumeBusy || !/^[0-9a-f]{32}$/.test(setupRequestId)}
className="rounded-lg border border-accent/30 bg-accent px-4 py-2 text-sm font-semibold text-bg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-60"
>
{resumeBusy ? 'Validating request…' : 'Continue to configure'}
</button>
) : (
<button
type="button"
Expand All @@ -220,7 +258,7 @@ export function ConnectStep({
</button>
)}
<span className="text-xs text-muted">
{activePause.pause_id === 'passkey'
{activePause.pause_id === 'passkey' || activePause.pause_id === 'initial-passkey'
? 'The station stays active until the passkey check confirms the required count.'
: 'The bootstrap will continue once resumed.'}
</span>
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/hooks/useCreateFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ type BootstrapCheckpoint = {
export type ActivePause =
| { pause_id: 'workstation'; title: string; payload: Record<string, unknown> }
| { pause_id: 'passkey'; title: string; payload: Record<string, unknown> }
| { pause_id: 'initial-passkey'; title: string; payload: Record<string, unknown> }
| { pause_id: 'setup-request'; title: string; payload: Record<string, unknown> }
| { pause_id: 'checkpoint-2-export-gate'; title: string; payload: Record<string, unknown> }

type TerminalState =
Expand Down Expand Up @@ -300,15 +302,15 @@ export function useCreateFlow() {

// resumePause
const resumePause = useCallback(
async (runId: string, pauseId: string) => {
async (runId: string, pauseId: string, pausePayload?: Record<string, unknown>) => {
setResumeBusy(true)
setResumeError(null)
try {
const response = await fetch('/api/bootstrap/resume', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ run_id: runId, pause_id: pauseId, payload: null }),
body: JSON.stringify({ run_id: runId, pause_id: pauseId, payload: pausePayload ?? null }),
})
if (flagIfUnauthorized(response)) return
// Only clear the pause locally if the server actually resumed. A 409
Expand All @@ -327,7 +329,7 @@ export function useCreateFlow() {

// verifyPasskey
const verifyPasskey = useCallback(
async (runId: string) => {
async (runId: string, pauseId: 'passkey' | 'initial-passkey' = 'passkey') => {
setPasskeyChecking(true)
setResumeError(null)
try {
Expand All @@ -341,7 +343,7 @@ export function useCreateFlow() {
const statusText = `${payload.confirmed}/${payload.required}`
setPasskeyStatus(statusText)
if (payload.confirmed >= payload.required) {
await resumePause(runId, 'passkey')
await resumePause(runId, pauseId)
return
}
setResumeError(`Passkey enrollment is not complete yet (${statusText}).`)
Expand Down
53 changes: 46 additions & 7 deletions src/dmf_init/bootstrap_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,8 +350,20 @@ def _run_passkey_helper(ctx: BootstrapContext) -> subprocess.CompletedProcess[st
)


def _command_step(ctx: BootstrapContext, step_id: str, argv: list[str]) -> CommandStep:
return CommandStep(id=step_id, argv=argv, cwd=ctx.repos_root / "dmf-env", env=ctx.command_env())
def _command_step(
ctx: BootstrapContext,
step_id: str,
argv: list[str],
*,
setup_request_pause_id: str | None = None,
) -> CommandStep:
return CommandStep(
id=step_id,
argv=argv,
cwd=ctx.repos_root / "dmf-env",
env=ctx.command_env(),
setup_request_pause_id=setup_request_pause_id,
)


# Only this profile is auto-unsealable: it uses a 1-of-1 Shamir key held in the
Expand Down Expand Up @@ -618,6 +630,10 @@ def build_passkey_payload(ctx: BootstrapContext) -> dict[str, Any]:
# on a *proven, current* download (enforced server-side in /api/bootstrap/resume).
CHECKPOINT_EXPORT_GATE_ID = "checkpoint-2-export-gate"

# The first passkey must exist before configure runs. The resume route checks
# the live passkey count itself rather than trusting the browser.
INITIAL_PASSKEY_GATE_ID = "initial-passkey"


def build_checkpoint_export_gate_payload(ctx: BootstrapContext) -> dict[str, Any]:
return {
Expand Down Expand Up @@ -671,16 +687,27 @@ def build_bootstrap_steps(ctx: BootstrapContext) -> list[Step]:
"post-seed",
ctx.run_playbook_argv("bootstrap-sandbox-provision-post-seed.yml"),
),
_command_step(
ctx,
"configure",
ctx.run_playbook_argv("bootstrap-sandbox-configure.yml"),
),
PauseStep(
id="workstation",
title="Prepare your workstation",
payload_fn=lambda run, ctx=ctx: build_workstation_payload(ctx),
),
PauseStep(
id=INITIAL_PASSKEY_GATE_ID,
title="Establish your first passkey",
payload_fn=lambda run, ctx=ctx: build_passkey_payload(ctx),
),
PauseStep(
id="setup-request",
title="Authorize setup inventory",
payload_fn=lambda run, ctx=ctx: build_setup_request_payload(ctx),
),
_command_step(
ctx,
"configure",
ctx.run_playbook_argv("bootstrap-sandbox-configure.yml"),
setup_request_pause_id="setup-request",
),
PauseStep(
id="passkey",
title="Complete passkey enrollment",
Expand All @@ -695,6 +722,18 @@ def build_bootstrap_steps(ctx: BootstrapContext) -> list[Step]:
]


def build_setup_request_payload(ctx: BootstrapContext) -> dict[str, Any]:
return {
"action": "born-inventory-secretid-issue",
"message": (
"In the DMF Console, create a born-inventory setup request with a "
"non-secret reason. Paste its request id below. The request expires "
"after 15 minutes and is consumed once; after a failed configure or "
"interruption, create a fresh request."
),
}


class CheckpointError(RuntimeError):
pass

Expand Down
64 changes: 64 additions & 0 deletions src/dmf_init/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,15 @@
from .bootstrap_steps import (
BAO_PREFLIGHT_ID,
CHECKPOINT_EXPORT_GATE_ID,
INITIAL_PASSKEY_GATE_ID,
BootstrapContext,
build_bao_preflight_step,
build_bootstrap_steps,
build_ca_cert_payload,
build_hosts_map_payload,
build_passkey_payload,
build_setup_request_payload,
build_workstation_payload,
ensure_runtime_repos,
make_checkpoint_fn,
seed_openbao_redactions,
Expand Down Expand Up @@ -74,8 +77,10 @@
build_action_run,
)
from .orchestrate import (
SETUP_REQUEST_PAUSE_ID,
BootstrapRun,
CheckpointStep,
PauseStep,
SubprocessExecutor,
run_worker,
stream_events,
Expand Down Expand Up @@ -529,6 +534,10 @@ def _manage_action_wrapper(
session.wipe_secrets()


# Pauses that resume only while the worker waits on them.
SERVER_GATED_PAUSE_IDS = frozenset({INITIAL_PASSKEY_GATE_ID, SETUP_REQUEST_PAUSE_ID})


def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or load_settings()
settings.data_root.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -916,6 +925,35 @@ def _clear_active() -> None:
detail=f"failed step '{failed_step_id}' not in build",
) from None
steps = all_steps[idx:]
# A configure retry starts after its original first-passkey and
# one-use receipt gates. Recreate only those gates, then rerun the
# failed configure command; do not replay post-seed provisioning.
configure_index = next(
(i for i, step in enumerate(steps) if getattr(step, "id", None) == "configure"),
None,
)
if configure_index is not None and not any(
isinstance(step, PauseStep)
and step.id == "setup-request"
for step in steps[:configure_index]
):
steps[configure_index:configure_index] = [
PauseStep(
id="workstation",
title="Prepare your workstation",
payload_fn=lambda run, ctx=ctx: build_workstation_payload(ctx),
),
PauseStep(
id=INITIAL_PASSKEY_GATE_ID,
title="Establish your first passkey",
payload_fn=lambda run, ctx=ctx: build_passkey_payload(ctx),
),
PauseStep(
id="setup-request",
title="Authorize setup inventory",
payload_fn=lambda run, ctx=ctx: build_setup_request_payload(ctx),
),
]

# ADR-0044 forced export gate also applies to retry/resume-from-cursor:
# if we'd resume *past* the gate (the gate step isn't in this slice),
Expand Down Expand Up @@ -1079,6 +1117,32 @@ async def bootstrap_resume(payload: BootstrapResumeRequest) -> JSONResponse:
"lost during the long unattended phases)"
),
)
# The first passkey and the setup request are checked here, not only in
# the browser: a direct call must not run configure early or without the
# first passkey. Both pauses resume only while the worker waits on them.
if payload.pause_id in SERVER_GATED_PAUSE_IDS:
if payload.pause_id in run.pauses and not run.is_waiting_on(payload.pause_id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"the run is not waiting on '{payload.pause_id}' yet",
)
if payload.pause_id == INITIAL_PASSKEY_GATE_ID:
if not run.env_id:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="run env_id unavailable"
)
ctx = BootstrapContext.from_data_root(settings.data_root, run.env_id, run.remotes)
status_payload = build_passkey_payload(ctx)
confirmed = int(status_payload.get("confirmed", 0) or 0)
required = int(status_payload.get("required", 0) or 0)
if status_payload.get("note") or required <= 0 or confirmed < required:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"your first passkey is not confirmed yet — enroll it, "
"then continue"
),
)
try:
run.resume(payload.pause_id, payload.payload)
except (KeyError, ValueError) as exc:
Expand Down
Loading
Loading