diff --git a/AGENTS.md b/AGENTS.md index b7ec382..d7fde78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ Repo-local guide for agents working in PickLab. - MCP screenshot `out` is confined under the project dir; the CLI `--out` stays unrestricted. - `android adb` only falls back to a raw, untargeted call when there is no running android session; ambiguous sessions fail closed (exit 1). - MCP tools never invoke sudo. +- Provisioning planners emit raw commands; the executor owns consent, dry-run, + privileged routing, and redacted public plans. ## Testing notes diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index 0ce96e3..9df5fbc 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -32,6 +32,9 @@ release description, then reset it after the release is published. - Centralized session lifecycle composition in core and routed dead-session reaping through typed desktop, Android, and browser teardown owners, removing duplicate CLI/MCP orchestration and core PID/profile stop implementations. +- Centralized CLI provisioning policy for plan classification, consent, + dry-runs, ordered preflight failures/skips, adapter-owned sudo routing, + cancellation, redacted presentation plans, and partial results. - Added a framing-aware DevTools NDJSON relay with protocol validation, backpressure, bounded diagnostics, redacted failures, and evidence hooks. - Added atomic, crash-recoverable evidence journals, active-run ownership, diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 5e671f9..69a47d5 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -5,17 +5,19 @@ import { type DoctorCheck, } from "../provision/checks.js"; import { collectSnapshot, type DetectionSnapshot } from "../provision/detect.js"; -import { executePlan, type StepResult } from "../provision/executor.js"; import { - planHasCommandSteps, - type ProvisioningStep, -} from "../provision/plan.js"; + executeProvisioning, + type ProvisioningSection, + type StepResult, +} from "../provision/executor.js"; +import type { ProvisioningStep } from "../provision/plan.js"; import { + labUserPrivilegeUnavailableMessage, planCreateAvd, planLabUser, planPicklabHome, } from "../provision/planner.js"; -import { confirm } from "../provision/prompts.js"; +import { confirm, toConsentDecision } from "../provision/prompts.js"; export interface DoctorCliOptions { json?: boolean; @@ -39,32 +41,21 @@ export interface DoctorReport { fix?: DoctorFixReport; } -async function consentToRepair( - question: string, - opts: DoctorCliOptions, -): Promise { - if (opts.dryRun === true) { - return true; - } - const answer = await confirm(question, { yes: opts.yes }); - return answer === "yes"; -} - async function buildFixPlan( snapshot: DetectionSnapshot, opts: DoctorCliOptions, -): Promise<{ steps: ProvisioningStep[]; skipped: string[] }> { - const steps: ProvisioningStep[] = []; - const skipped: string[] = []; +): Promise { + const sections: ProvisioningSection[] = []; const consentHint = "skipped (requires consent; re-run with --yes or confirm interactively)"; - steps.push( - ...planPicklabHome({ + sections.push({ + kind: "plan", + plan: planPicklabHome({ path: snapshot.picklabHome.path, exists: snapshot.picklabHome.exists, - }).steps, - ); + }), + }); if (!snapshot.android.avdExists) { const result = planCreateAvd({ @@ -75,17 +66,29 @@ async function buildFixPlan( existingAvds: snapshot.android.avds, }); if (!result.ok) { - skipped.push(`avd: ${result.error}`); - } else if ( - !planHasCommandSteps(result.plan) || - (await consentToRepair( - `Create AVD "${snapshot.android.avdName}" (runs avdmanager)?`, - opts, - )) - ) { - steps.push(...result.plan.steps); + sections.push({ + kind: "blocked", + action: "skip", + reason: `avd: ${result.error}`, + }); } else { - skipped.push(`avd: ${consentHint}`); + sections.push({ + kind: "plan", + plan: result.plan, + consent: { + onDenied: "skip", + decide: async () => { + const answer = await confirm( + `Create AVD "${snapshot.android.avdName}" (runs avdmanager)?`, + { yes: opts.yes }, + ); + return toConsentDecision(answer, { + declined: `avd: ${consentHint}`, + cancelled: `avd: ${consentHint}`, + }); + }, + }, + }); } } @@ -96,25 +99,41 @@ async function buildFixPlan( userExists: snapshot.labUser.exists, homeExists: snapshot.labUser.homeExists, kvmPresent: snapshot.android.kvm.exists, - sudoPath: snapshot.sudo, - nonInteractive: process.stdin.isTTY !== true, }); if (!result.ok) { - skipped.push(`lab-user: ${result.error}`); - } else if ( - !planHasCommandSteps(result.plan) || - (await consentToRepair( - `Create lab user "${snapshot.labUser.name}" (privileged, runs sudo)?`, - opts, - )) - ) { - steps.push(...result.plan.steps); + sections.push({ + kind: "blocked", + action: "skip", + reason: `lab-user: ${result.error}`, + }); } else { - skipped.push(`lab-user: ${consentHint}`); + sections.push({ + kind: "plan", + plan: result.plan, + privilegeUnavailable: { + action: "skip", + reason: `lab-user: ${labUserPrivilegeUnavailableMessage( + snapshot.labUser.name, + )}`, + }, + consent: { + onDenied: "skip", + decide: async () => { + const answer = await confirm( + `Create lab user "${snapshot.labUser.name}" (privileged, runs sudo)?`, + { yes: opts.yes }, + ); + return toConsentDecision(answer, { + declined: `lab-user: ${consentHint}`, + cancelled: `lab-user: ${consentHint}`, + }); + }, + }, + }); } } - return { steps, skipped }; + return sections; } export async function runDoctor( @@ -138,21 +157,30 @@ export async function runDoctor( let exitCode = 0; if (opts.fix === true) { - const { steps, skipped } = await buildFixPlan(snapshot, opts); + const sections = await buildFixPlan(snapshot, opts); const log = opts.json === true ? () => {} : (line: string) => console.log(line); - const execution = await executePlan( - { steps }, - { dryRun: opts.dryRun, env, projectDir, log }, + const execution = await executeProvisioning( + sections, + { + dryRun: opts.dryRun, + env, + projectDir, + log, + privilege: { + sudoPath: snapshot.sudo, + nonInteractive: process.stdin.isTTY !== true, + }, + }, ); report.fix = { dryRun: opts.dryRun === true, - steps, - skipped, + steps: execution.plan.steps, + skipped: execution.skipped, results: execution.results, }; if (!execution.ok) { - report.errors.push(execution.error ?? "repairs failed"); + report.errors.push(...execution.errors); exitCode = 1; } } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index ed3aaf9..6b603ff 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -7,17 +7,19 @@ import { type DoctorCheck, } from "../provision/checks.js"; import { collectSnapshot, type DetectionSnapshot } from "../provision/detect.js"; -import { executePlan, type StepResult } from "../provision/executor.js"; import { - planHasCommandSteps, - type ProvisioningStep, -} from "../provision/plan.js"; + executeProvisioning, + type ProvisioningSection, + type StepResult, +} from "../provision/executor.js"; +import type { ProvisioningStep } from "../provision/plan.js"; import { + labUserPrivilegeUnavailableMessage, planCreateAvd, planLabUser, planPicklabHome, } from "../provision/planner.js"; -import { confirm } from "../provision/prompts.js"; +import { confirm, toConsentDecision } from "../provision/prompts.js"; export interface InitCliOptions { profile?: PicklabProfile; @@ -44,32 +46,21 @@ async function consentTo( what: string, opts: InitCliOptions, remediation: string, -): Promise<{ granted: boolean; error?: string }> { - if (opts.yes === true || opts.dryRun === true) { - return { granted: true }; - } - const answer = await confirm(`Provision ${what}?`, {}); - if (answer === "yes") { - return { granted: true }; - } - if (answer === "no") { - return { granted: false, error: `Required ${what} was declined. ${remediation}` }; - } - return { - granted: false, - error: +) { + const answer = await confirm(`Provision ${what}?`, { yes: opts.yes }); + return toConsentDecision(answer, { + declined: `Required ${what} was declined. ${remediation}`, + cancelled: `Refusing to provision ${what} without consent in a non-interactive ` + `session. ${remediation}`, - }; + }); } -async function planAvdProvisioning( +function planAvdProvisioning( snapshot: DetectionSnapshot, opts: InitCliOptions, - steps: ProvisioningStep[], - errors: string[], - handledCheckIds: Set, -): Promise { + sections: ProvisioningSection[], +): void { const result = planCreateAvd({ avdName: snapshot.android.avdName, sdkRoot: snapshot.android.sdkRoot, @@ -78,57 +69,55 @@ async function planAvdProvisioning( existingAvds: snapshot.android.avds, }); if (!result.ok) { - errors.push(result.error); + sections.push({ kind: "blocked", reason: result.error }); return; } - if (planHasCommandSteps(result.plan)) { - const consent = await consentTo( - `dedicated AVD "${snapshot.android.avdName}" (runs avdmanager)`, - opts, - "Re-run with --yes --create-avd or run: picklab setup android --create-avd", - ); - if (!consent.granted) { - errors.push(consent.error ?? "AVD provisioning was not approved"); - return; - } - } - steps.push(...result.plan.steps); - handledCheckIds.add("avd"); + sections.push({ + kind: "plan", + plan: result.plan, + satisfies: "avd", + consent: { + decide: () => + consentTo( + `dedicated AVD "${snapshot.android.avdName}" (runs avdmanager)`, + opts, + "Re-run with --yes --create-avd or run: picklab setup android --create-avd", + ), + }, + }); } -async function planLabUserProvisioning( +function planLabUserProvisioning( snapshot: DetectionSnapshot, opts: InitCliOptions, - steps: ProvisioningStep[], - errors: string[], - handledCheckIds: Set, -): Promise { + sections: ProvisioningSection[], +): void { const result = planLabUser({ name: snapshot.labUser.name, home: snapshot.labUser.home, userExists: snapshot.labUser.exists, homeExists: snapshot.labUser.homeExists, kvmPresent: snapshot.android.kvm.exists, - sudoPath: snapshot.sudo, - nonInteractive: process.stdin.isTTY !== true, }); if (!result.ok) { - errors.push(result.error); + sections.push({ kind: "blocked", reason: result.error }); return; } - if (planHasCommandSteps(result.plan)) { - const consent = await consentTo( - `lab user "${snapshot.labUser.name}" (privileged, runs sudo)`, - opts, - "Re-run with --yes --create-lab-user or run: picklab setup lab-user", - ); - if (!consent.granted) { - errors.push(consent.error ?? "Lab user provisioning was not approved"); - return; - } - } - steps.push(...result.plan.steps); - handledCheckIds.add("lab-user"); + sections.push({ + kind: "plan", + plan: result.plan, + privilegeUnavailable: { + reason: labUserPrivilegeUnavailableMessage(snapshot.labUser.name), + }, + consent: { + decide: () => + consentTo( + `lab user "${snapshot.labUser.name}" (privileged, runs sudo)`, + opts, + "Re-run with --yes --create-lab-user or run: picklab setup lab-user", + ), + }, + }); } export async function runInit( @@ -148,15 +137,20 @@ export async function runInit( } } - const errors: string[] = []; - const handledCheckIds = new Set(); - const steps: ProvisioningStep[] = [ + const sections: ProvisioningSection[] = [ { - id: "project-config", - title: `Write project config (profile: ${profile})`, - kind: "write-project-config", - privileged: false, - config: { profile }, + kind: "plan", + plan: { + steps: [ + { + id: "project-config", + title: `Write project config (profile: ${profile})`, + kind: "write-project-config", + privileged: false, + config: { profile }, + }, + ], + }, }, ]; const homePlan = planPicklabHome({ @@ -164,70 +158,72 @@ export async function runInit( exists: snapshot.picklabHome.exists, }); if (homePlan.steps.length > 0) { - steps.push(...homePlan.steps); - handledCheckIds.add("picklab-home"); + sections.push({ kind: "plan", plan: homePlan, satisfies: "picklab-home" }); } const avdRequired = requiredIds.includes("avd"); if (!snapshot.android.avdExists && (avdRequired || opts.createAvd === true)) { - await planAvdProvisioning(snapshot, opts, steps, errors, handledCheckIds); + planAvdProvisioning(snapshot, opts, sections); } if (!snapshot.labUser.exists && opts.createLabUser === true) { - await planLabUserProvisioning( - snapshot, - opts, - steps, - errors, - handledCheckIds, - ); + planLabUserProvisioning(snapshot, opts, sections); } for (const check of checks) { if (check.status !== "missing") continue; - if (handledCheckIds.has(check.id)) continue; - errors.push( - `Required check "${check.id}" failed: ${check.detail}.` + + sections.push({ + kind: "blocked", + unlessSatisfied: check.id, + reason: + `Required check "${check.id}" failed: ${check.detail}.` + (check.hint === undefined ? "" : ` Hint: ${check.hint}`), - ); + }); } const report: InitReport = { - ok: errors.length === 0, + ok: false, profile, projectDir, dryRun: opts.dryRun === true, checks, - plan: steps, + plan: [], results: [], - errors, + errors: [], }; - if (errors.length > 0) { - emit(report, opts); - return 1; - } - const log = opts.json === true ? () => {} : (line: string) => console.log(line); - const execution = await executePlan( - { steps }, - { dryRun: opts.dryRun, env, projectDir, log }, + const execution = await executeProvisioning( + sections, + { + dryRun: opts.dryRun, + env, + projectDir, + log, + privilege: { + sudoPath: snapshot.sudo, + nonInteractive: process.stdin.isTTY !== true, + }, + }, ); + report.plan = execution.plan.steps; report.results = execution.results; + report.ok = execution.ok; if (!execution.ok) { - report.ok = false; - const error = execution.error ?? "provisioning failed"; + const errors = + execution.errors.length > 0 ? execution.errors : ["provisioning failed"]; if ( execution.results.some( (result) => result.id === "project-config" && result.ok, ) ) { report.errors.push( - `${error}. Project config was written; fix the failed dependency and ` + + `${errors[0]}. Project config was written; fix the failed dependency and ` + `re-run picklab init (idempotent), or check picklab doctor.`, ); + report.errors.push(...errors.slice(1)); } else { - report.errors.push(error); + report.errors.push(...errors); } } emit(report, opts); diff --git a/packages/cli/src/commands/setup-android.ts b/packages/cli/src/commands/setup-android.ts index aa73fc4..610e1c3 100644 --- a/packages/cli/src/commands/setup-android.ts +++ b/packages/cli/src/commands/setup-android.ts @@ -1,16 +1,16 @@ import { sdkmanagerInstallCommand } from "@pickforge/picklab-android"; import type { EnvLike } from "@pickforge/picklab-core"; import { collectSnapshot, type DetectionSnapshot } from "../provision/detect.js"; -import { executePlan, type StepResult } from "../provision/executor.js"; import { - planHasCommandSteps, - type ProvisioningStep, -} from "../provision/plan.js"; + executeProvisioning, + type StepResult, +} from "../provision/executor.js"; +import type { ProvisioningStep } from "../provision/plan.js"; import { planCreateAvd, RECOMMENDED_SYSTEM_IMAGE, } from "../provision/planner.js"; -import { confirm } from "../provision/prompts.js"; +import { confirm, toConsentDecision } from "../provision/prompts.js"; export interface SetupAndroidCliOptions { createAvd?: boolean; @@ -115,37 +115,35 @@ export async function runSetupAndroid( } report.plan = result.plan.steps; - if (planHasCommandSteps(result.plan) && opts.dryRun !== true) { - const answer = await confirm(`Create AVD "${android.avdName}"?`, { - yes: opts.yes, - }); - if (answer === "non-interactive") { - report.ok = false; - report.errors.push( - "Refusing to create the AVD without consent in a non-interactive " + - "session. Re-run with --yes.", - ); - emit(report, opts.json === true); - return 1; - } - if (answer === "no") { - report.ok = false; - report.errors.push("Aborted: AVD creation was declined."); - emit(report, opts.json === true); - return 1; - } - } - const log = opts.json === true ? () => {} : (line: string) => console.log(line); if (opts.json !== true && android.avdExists) { console.log(`AVD "${android.avdName}" already exists.`); } - const execution = await executePlan(result.plan, { - dryRun: opts.dryRun, - env, - log, - }); + const execution = await executeProvisioning( + [ + { + kind: "plan", + plan: result.plan, + consent: { + retainPlanOnDenied: true, + decide: async () => { + const answer = await confirm(`Create AVD "${android.avdName}"?`, { + yes: opts.yes, + }); + return toConsentDecision(answer, { + declined: "Aborted: AVD creation was declined.", + cancelled: + "Refusing to create the AVD without consent in a " + + "non-interactive session. Re-run with --yes.", + }); + }, + }, + }, + ], + { dryRun: opts.dryRun, env, log }, + ); + report.plan = execution.plan.steps; report.results = execution.results; report.ok = execution.ok; if (!execution.ok) { diff --git a/packages/cli/src/commands/setup-lab-user.ts b/packages/cli/src/commands/setup-lab-user.ts index 2488d6a..b1d590f 100644 --- a/packages/cli/src/commands/setup-lab-user.ts +++ b/packages/cli/src/commands/setup-lab-user.ts @@ -1,12 +1,15 @@ import type { EnvLike } from "@pickforge/picklab-core"; import { collectSnapshot } from "../provision/detect.js"; -import { executePlan, type StepResult } from "../provision/executor.js"; import { - planHasCommandSteps, - type ProvisioningStep, -} from "../provision/plan.js"; -import { planLabUser } from "../provision/planner.js"; -import { confirm } from "../provision/prompts.js"; + executeProvisioning, + type StepResult, +} from "../provision/executor.js"; +import type { ProvisioningStep } from "../provision/plan.js"; +import { + labUserPrivilegeUnavailableMessage, + planLabUser, +} from "../provision/planner.js"; +import { confirm, toConsentDecision } from "../provision/prompts.js"; export interface SetupLabUserCliOptions { name?: string; @@ -63,8 +66,6 @@ export async function runSetupLabUser( userExists: snapshot.labUser.exists, homeExists: snapshot.labUser.homeExists, kvmPresent: snapshot.android.kvm.exists, - sudoPath: snapshot.sudo, - nonInteractive: process.stdin.isTTY !== true, }); if (!result.ok) { report.errors.push(result.error); @@ -73,37 +74,50 @@ export async function runSetupLabUser( } report.plan = result.plan.steps; - if (planHasCommandSteps(result.plan) && opts.dryRun !== true) { - const answer = await confirm( - `Create system user "${snapshot.labUser.name}" with home ` + - `${snapshot.labUser.home} (privileged, runs sudo)?`, - { yes: opts.yes }, - ); - if (answer === "non-interactive") { - report.errors.push( - "Refusing to provision the lab user without consent in a " + - "non-interactive session. Re-run with --yes.", - ); - emit(report, opts.json === true); - return 1; - } - if (answer === "no") { - report.errors.push("Aborted: lab user provisioning was declined."); - emit(report, opts.json === true); - return 1; - } - } - const log = opts.json === true ? () => {} : (line: string) => console.log(line); - if (opts.json !== true && snapshot.labUser.exists) { - console.log(`User "${snapshot.labUser.name}" already exists.`); - } - const execution = await executePlan(result.plan, { - dryRun: opts.dryRun, - env, - log, - }); + const execution = await executeProvisioning( + [ + { + kind: "plan", + plan: result.plan, + privilegeUnavailable: { + reason: labUserPrivilegeUnavailableMessage(snapshot.labUser.name), + }, + consent: { + retainPlanOnDenied: true, + decide: async () => { + const answer = await confirm( + `Create system user "${snapshot.labUser.name}" with home ` + + `${snapshot.labUser.home} (privileged, runs sudo)?`, + { yes: opts.yes }, + ); + return toConsentDecision(answer, { + declined: "Aborted: lab user provisioning was declined.", + cancelled: + "Refusing to provision the lab user without consent in a " + + "non-interactive session. Re-run with --yes.", + }); + }, + }, + }, + ], + { + dryRun: opts.dryRun, + env, + log, + privilege: { + sudoPath: snapshot.sudo, + nonInteractive: process.stdin.isTTY !== true, + }, + beforeExecute: () => { + if (opts.json !== true && snapshot.labUser.exists) { + console.log(`User "${snapshot.labUser.name}" already exists.`); + } + }, + }, + ); + report.plan = execution.plan.steps; report.results = execution.results; report.ok = execution.ok; if (!execution.ok) { diff --git a/packages/cli/src/provision/executor.ts b/packages/cli/src/provision/executor.ts index cf22a1e..f4e0101 100644 --- a/packages/cli/src/provision/executor.ts +++ b/packages/cli/src/provision/executor.ts @@ -4,6 +4,7 @@ import { globalConfigPath, projectConfigPath, readConfigFile, + redactSecrets, runCommand, saveGlobalConfig, saveProjectConfig, @@ -14,12 +15,65 @@ import { formatStep, type ProvisioningPlan, type ProvisioningStep } from "./plan const DEFAULT_STEP_TIMEOUT_MS = 180_000; -export interface ExecutePlanOptions { +export type PlanClassification = + | "empty" + | "automatic" + | "unprivileged" + | "privileged" + | "mixed"; + +export type ConsentDecision = + | { kind: "approved" } + | { kind: "declined"; reason: string } + | { kind: "cancelled"; reason: string }; + +interface DenialPolicy { + onDenied?: "cancel" | "skip"; + retainPlanOnDenied?: boolean; +} + +export interface ProvisioningPlanSection { + kind: "plan"; + plan: ProvisioningPlan; + satisfies?: string; + consent?: DenialPolicy & { + decide: (classification: PlanClassification) => Promise; + }; + privilegeUnavailable?: { + action?: "error" | "skip"; + reason: string; + }; +} + +export interface BlockedProvisioningSection { + kind: "blocked"; + action?: "error" | "skip"; + reason: string; + unlessSatisfied?: string; +} + +export type ProvisioningSection = + | ProvisioningPlanSection + | BlockedProvisioningSection; + +export interface ProvisioningExecutionAdapter { + materialize(step: ProvisioningStep): ProvisioningStep; + execute(step: ProvisioningStep): Promise; + executePrivileged(step: ProvisioningStep): Promise; +} + +export interface ExecuteProvisioningOptions { dryRun?: boolean; env?: EnvLike; projectDir?: string; log?: (line: string) => void; timeoutMs?: number; + adapter?: ProvisioningExecutionAdapter; + privilege?: { + sudoPath: string | null; + nonInteractive?: boolean; + }; + beforeExecute?: (plan: ProvisioningPlan) => void | Promise; } export interface StepResult { @@ -28,13 +82,52 @@ export interface StepResult { detail: string; } -export interface ExecutePlanResult { +export interface ExecuteProvisioningResult { ok: boolean; + status: "completed" | "declined" | "cancelled" | "failed"; + plan: ProvisioningPlan; + skipped: string[]; results: StepResult[]; + errors: string[]; error?: string; } -export async function patchGlobalConfig( +interface PreparedStep { + materialized: ProvisioningStep; +} + +type PreparedSection = + | { + kind: "plan"; + classification: PlanClassification; + section: ProvisioningPlanSection; + steps: PreparedStep[]; + } + | { + kind: "unavailable"; + section: ProvisioningPlanSection; + reason: string; + } + | { + kind: "blocked"; + section: BlockedProvisioningSection; + }; + +class PrivilegeUnavailableError extends Error {} + +export function classifyPlan(plan: ProvisioningPlan): PlanClassification { + if (plan.steps.length === 0) return "empty"; + const consentSteps = plan.steps.filter( + (step) => step.kind === "command" || step.privileged, + ); + if (consentSteps.length === 0) return "automatic"; + const hasPrivileged = consentSteps.some((step) => step.privileged); + const hasUnprivileged = consentSteps.some((step) => !step.privileged); + if (hasPrivileged && hasUnprivileged) return "mixed"; + return hasPrivileged ? "privileged" : "unprivileged"; +} + +async function patchGlobalConfig( patch: PicklabConfig, env: EnvLike = process.env, ): Promise { @@ -42,7 +135,7 @@ export async function patchGlobalConfig( await saveGlobalConfig(deepMerge(existing, patch) as PicklabConfig, env); } -export async function patchProjectConfig( +async function patchProjectConfig( projectDir: string, patch: PicklabConfig, ): Promise { @@ -53,9 +146,9 @@ export async function patchProjectConfig( ); } -async function executeStep( +async function executeLocalStep( step: ProvisioningStep, - opts: ExecutePlanOptions, + opts: ExecuteProvisioningOptions, ): Promise { switch (step.kind) { case "mkdir": { @@ -94,28 +187,234 @@ async function executeStep( } } -export async function executePlan( - plan: ProvisioningPlan, - opts: ExecutePlanOptions = {}, -): Promise { +function materializePrivilegedStep( + step: ProvisioningStep, + opts: ExecuteProvisioningOptions, +): ProvisioningStep { + const sudoPath = opts.privilege?.sudoPath; + if (sudoPath === undefined || sudoPath === null) { + throw new PrivilegeUnavailableError("sudo not found on PATH"); + } + if (step.kind !== "command") { + throw new Error(`Unsupported privileged provisioning step: ${step.kind}`); + } + return { + ...step, + command: { + ...step.command, + cmd: sudoPath, + args: [ + ...(opts.privilege?.nonInteractive === true ? ["-n"] : []), + step.command.cmd, + ...step.command.args, + ], + }, + }; +} + +export function createLocalExecutionAdapter( + opts: ExecuteProvisioningOptions = {}, +): ProvisioningExecutionAdapter { + return { + materialize: (step) => + step.privileged ? materializePrivilegedStep(step, opts) : step, + execute: async (step) => executeLocalStep(step, opts), + executePrivileged: async (step) => executeLocalStep(step, opts), + }; +} + +function publicPlan(steps: readonly PreparedStep[]): ProvisioningPlan { + return JSON.parse( + redactSecrets( + JSON.stringify({ steps: steps.map((step) => step.materialized) }), + ), + ) as ProvisioningPlan; +} + +function executionResult( + status: ExecuteProvisioningResult["status"], + steps: readonly PreparedStep[], + skipped: string[], + results: StepResult[], + errors: string[], +): ExecuteProvisioningResult { + const redactedErrors = errors.map((error) => redactSecrets(error)); + return { + ok: status === "completed", + status, + plan: publicPlan(steps), + skipped: skipped.map((entry) => redactSecrets(entry)), + results, + errors: redactedErrors, + ...(redactedErrors.length === 0 ? {} : { error: redactedErrors[0] }), + }; +} + +function prepareSections( + sections: readonly ProvisioningSection[], + adapter: ProvisioningExecutionAdapter, +): PreparedSection[] { + return sections.map((section): PreparedSection => { + if (section.kind === "blocked") { + return { kind: "blocked", section }; + } + try { + return { + kind: "plan", + classification: classifyPlan(section.plan), + section, + steps: section.plan.steps.map((step) => ({ + materialized: adapter.materialize(step), + })), + }; + } catch (error) { + if (error instanceof PrivilegeUnavailableError) { + return { + kind: "unavailable", + section, + reason: section.privilegeUnavailable?.reason ?? error.message, + }; + } + return { + kind: "blocked", + section: { + kind: "blocked", + reason: `Provisioning preflight failed: ${(error as Error).message}`, + }, + }; + } + }); +} + +export async function executeProvisioning( + sections: readonly ProvisioningSection[], + opts: ExecuteProvisioningOptions = {}, +): Promise { + const adapter = opts.adapter ?? createLocalExecutionAdapter(opts); + const prepared = prepareSections(sections, adapter); + const selected: PreparedStep[] = []; + const satisfied = new Set(); + const skipped: string[] = []; + const errors: string[] = []; + let errorStatus: "declined" | "cancelled" | "failed" = "failed"; + + const addError = ( + reason: string, + status: "declined" | "cancelled" | "failed" = "failed", + ): void => { + if (errors.length === 0) errorStatus = status; + errors.push(reason); + }; + + for (const entry of prepared) { + if (entry.kind === "blocked") { + if ( + entry.section.unlessSatisfied !== undefined && + satisfied.has(entry.section.unlessSatisfied) + ) { + continue; + } + if (entry.section.action === "skip") { + skipped.push(entry.section.reason); + } else { + addError(entry.section.reason); + } + continue; + } + if (entry.kind === "unavailable") { + if (entry.section.privilegeUnavailable?.action === "skip") { + skipped.push(entry.reason); + } else { + addError(entry.reason); + } + continue; + } + + const { classification, section } = entry; + if ( + opts.dryRun !== true && + classification !== "empty" && + classification !== "automatic" + ) { + if (section.consent === undefined) { + addError( + "Refusing to execute provisioning commands without a consent decision.", + "cancelled", + ); + continue; + } + let decision: ConsentDecision; + try { + decision = await section.consent.decide(classification); + } catch (error) { + addError(`Provisioning consent failed: ${(error as Error).message}`); + continue; + } + if (decision.kind !== "approved") { + if (section.consent.onDenied === "skip") { + skipped.push(decision.reason); + } else { + addError(decision.reason, decision.kind); + if (section.consent.retainPlanOnDenied === true) { + selected.push(...entry.steps); + } + } + continue; + } + } + + selected.push(...entry.steps); + if (section.satisfies !== undefined) { + satisfied.add(section.satisfies); + } + } + + if (errors.length > 0) { + return executionResult(errorStatus, selected, skipped, [], errors); + } + + const plan = publicPlan(selected); + if (opts.beforeExecute !== undefined) { + try { + await opts.beforeExecute(plan); + } catch (error) { + return executionResult( + "failed", + selected, + skipped, + [], + [`Provisioning pre-execution hook failed: ${(error as Error).message}`], + ); + } + } + const log = opts.log ?? (() => {}); const results: StepResult[] = []; - for (const step of plan.steps) { + for (let index = 0; index < selected.length; index += 1) { + const preparedStep = selected[index]!; + const presentation = plan.steps[index]!; + const formatted = formatStep(presentation); + const title = presentation.title; if (opts.dryRun === true) { - log(`[dry-run] ${step.title}: ${formatStep(step)}`); - results.push({ id: step.id, ok: true, detail: "dry-run" }); + log(`[dry-run] ${title}: ${formatted}`); + results.push({ id: presentation.id, ok: true, detail: "dry-run" }); continue; } try { - await executeStep(step, opts); - log(`[done] ${step.title}`); - results.push({ id: step.id, ok: true, detail: formatStep(step) }); + if (preparedStep.materialized.privileged) { + await adapter.executePrivileged(preparedStep.materialized); + } else { + await adapter.execute(preparedStep.materialized); + } + log(`[done] ${title}`); + results.push({ id: presentation.id, ok: true, detail: formatted }); } catch (error) { - const message = `Step "${step.id}" failed: ${(error as Error).message}`; - log(`[failed] ${step.title}: ${(error as Error).message}`); - results.push({ id: step.id, ok: false, detail: message }); - return { ok: false, results, error: message }; + const detail = redactSecrets((error as Error).message); + const message = `Step "${presentation.id}" failed: ${detail}`; + log(`[failed] ${title}: ${detail}`); + results.push({ id: presentation.id, ok: false, detail: message }); + return executionResult("failed", selected, skipped, results, [message]); } } - return { ok: true, results }; + return executionResult("completed", selected, skipped, results, []); } diff --git a/packages/cli/src/provision/plan.ts b/packages/cli/src/provision/plan.ts index fa685dd..899d259 100644 --- a/packages/cli/src/provision/plan.ts +++ b/packages/cli/src/provision/plan.ts @@ -49,10 +49,6 @@ export type PlanResult = | { ok: true; plan: ProvisioningPlan } | { ok: false; error: string }; -export function planHasCommandSteps(plan: ProvisioningPlan): boolean { - return plan.steps.some((step) => step.kind === "command"); -} - export function formatStep(step: ProvisioningStep): string { switch (step.kind) { case "command": diff --git a/packages/cli/src/provision/planner.ts b/packages/cli/src/provision/planner.ts index 588e6f7..eb1f03a 100644 --- a/packages/cli/src/provision/planner.ts +++ b/packages/cli/src/provision/planner.ts @@ -21,8 +21,14 @@ export interface LabUserPlanInput { userExists: boolean; homeExists: boolean; kvmPresent: boolean; - sudoPath: string | null; - nonInteractive?: boolean; +} + +export function labUserPrivilegeUnavailableMessage(name: string): string { + return ( + `sudo not found on PATH; cannot provision lab user "${name}". ` + + `Install sudo, or create the user manually as root: ` + + `useradd -r -M -s ${NOLOGIN_SHELL} ${name}` + ); } export function planLabUser(input: LabUserPlanInput): PlanResult { @@ -41,23 +47,26 @@ export function planLabUser(input: LabUserPlanInput): PlanResult { }; } - const sudoArgs = (args: string[]): string[] => - input.nonInteractive === true ? ["-n", ...args] : args; - - const privilegedSpecs: Array<{ id: string; title: string; args: string[] }> = - []; + const privilegedSpecs: Array<{ + id: string; + title: string; + cmd: string; + args: string[]; + }> = []; if (!input.userExists) { privilegedSpecs.push({ id: "useradd", title: `Create locked service user ${input.name}`, - args: ["useradd", "-r", "-M", "-s", NOLOGIN_SHELL, input.name], + cmd: "useradd", + args: ["-r", "-M", "-s", NOLOGIN_SHELL, input.name], }); } if (!input.homeExists) { privilegedSpecs.push({ id: "mkdir-home", title: `Create lab home ${input.home}`, - args: ["mkdir", "-p", input.home], + cmd: "mkdir", + args: ["-p", input.home], }); } if (!input.userExists || !input.homeExists) { @@ -65,12 +74,14 @@ export function planLabUser(input: LabUserPlanInput): PlanResult { { id: "chown-home", title: `Own lab home by ${input.name}`, - args: ["chown", `${input.name}:${input.name}`, input.home], + cmd: "chown", + args: [`${input.name}:${input.name}`, input.home], }, { id: "chmod-home", title: "Restrict lab home permissions to 750", - args: ["chmod", "750", input.home], + cmd: "chmod", + args: ["750", input.home], }, ); } @@ -78,22 +89,13 @@ export function planLabUser(input: LabUserPlanInput): PlanResult { privilegedSpecs.push({ id: "kvm-group", title: `Grant ${input.name} access to /dev/kvm`, - args: ["usermod", "-aG", "kvm", input.name], + cmd: "usermod", + args: ["-aG", "kvm", input.name], }); } const steps: ProvisioningStep[] = []; if (privilegedSpecs.length > 0) { - const sudoPath = input.sudoPath; - if (sudoPath === null) { - return { - ok: false, - error: - `sudo not found on PATH; cannot provision lab user "${input.name}". ` + - `Install sudo, or create the user manually as root: ` + - `useradd -r -M -s ${NOLOGIN_SHELL} ${input.name}`, - }; - } steps.push( ...privilegedSpecs.map( (spec): ProvisioningStep => ({ @@ -101,7 +103,7 @@ export function planLabUser(input: LabUserPlanInput): PlanResult { title: spec.title, kind: "command", privileged: true, - command: { cmd: sudoPath, args: sudoArgs(spec.args) }, + command: { cmd: spec.cmd, args: spec.args }, }), ), ); diff --git a/packages/cli/src/provision/prompts.ts b/packages/cli/src/provision/prompts.ts index 1782cde..8b64de4 100644 --- a/packages/cli/src/provision/prompts.ts +++ b/packages/cli/src/provision/prompts.ts @@ -1,4 +1,5 @@ import readline from "node:readline/promises"; +import type { ConsentDecision } from "./executor.js"; export type ConfirmAnswer = "yes" | "no" | "non-interactive"; @@ -28,3 +29,14 @@ export async function confirm( rl.close(); } } + +export function toConsentDecision( + answer: ConfirmAnswer, + reasons: { declined: string; cancelled: string }, +): ConsentDecision { + if (answer === "yes") return { kind: "approved" }; + return { + kind: answer === "no" ? "declined" : "cancelled", + reason: answer === "no" ? reasons.declined : reasons.cancelled, + }; +} diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 2b17104..f834054 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -197,6 +197,13 @@ describe("picklab doctor", () => { const skipped = (report.fix.skipped as string[]).join("\n"); expect(skipped).toContain("avd:"); expect(skipped).toContain("lab-user:"); + expect(skipped).toContain( + 'sudo not found on PATH; cannot provision lab user "picklab-lab". ' + + "Install sudo, or create the user manually as root: " + + "useradd -r -M -s /usr/sbin/nologin picklab-lab", + ); + expect((report.fix.skipped as string[]).map((entry) => entry.split(":")[0])) + .toEqual(["avd", "lab-user"]); }); it("skips AVD creation under --fix without consent in a non-interactive session", async () => { @@ -210,6 +217,8 @@ describe("picklab doctor", () => { const skipped = (report.fix.skipped as string[]).join("\n"); expect(skipped).toContain("avd: skipped (requires consent"); expect(skipped).toContain("--yes"); + expect((report.fix.skipped as string[]).map((entry) => entry.split(":")[0])) + .toEqual(["avd", "lab-user"]); expect( (report.fix.steps as Array<{ id: string }>).map((step) => step.id), ).not.toContain("create-avd"); @@ -320,6 +329,27 @@ describe("picklab init", () => { expect(fs.existsSync(env.PICKLAB_HOME!)).toBe(false); }); + it("reports required AVD consent refusal before the required-check error", async () => { + const sdk = makeFakeSdk(path.join(tmpDir, "sdk"), { images: [IMAGE] }); + const env = makeEnv(tmpDir, { sdk }); + const projectDir = path.join(tmpDir, "project"); + fs.mkdirSync(projectDir); + const result = await runCli( + ["init", "--profile", "android", "--create-avd", "--json"], + env, + projectDir, + ); + expect(result.code).toBe(1); + const report = parseJson(result); + expect(report.errors).toHaveLength(2); + expect(report.errors[0]).toContain("without consent"); + expect(report.errors[1]).toContain('Required check "avd" failed'); + expect(report.plan.map((step: { id: string }) => step.id)).toEqual([ + "project-config", + "picklab-home", + ]); + }); + it("initializes flutter-desktop without planning lab-user sudo steps", async () => { const sudoLog = path.join(tmpDir, "sudo.log"); const env = makeEnv(tmpDir, { @@ -491,10 +521,102 @@ describe("picklab init", () => { expect(result.code).toBe(1); const report = parseJson(result); expect((report.errors as string[]).join("\n")).toContain("sudo not found"); + expect((report.plan as Array<{ id: string }>).map((step) => step.id)).toEqual( + ["project-config", "picklab-home"], + ); expect(fs.existsSync(path.join(projectDir, ".picklab"))).toBe(false); expect(fs.existsSync(env.PICKLAB_HOME!)).toBe(false); }); + it("retains earlier AVD sections when later lab-user privilege is unavailable", async () => { + const sdk = makeFakeSdk(path.join(tmpDir, "sdk"), { images: [IMAGE] }); + const env = makeEnv(tmpDir, { sdk }); + const projectDir = path.join(tmpDir, "project"); + fs.mkdirSync(projectDir); + const result = await runCli( + [ + "init", + "--profile", + "generic", + "--create-avd", + "--create-lab-user", + "--yes", + "--json", + ], + env, + projectDir, + ); + expect(result.code).toBe(1); + const report = parseJson(result); + expect(report.plan.map((step: { id: string }) => step.id)).toEqual([ + "project-config", + "picklab-home", + "create-avd", + "persist-avd", + ]); + expect(report.errors.join("\n")).toContain("sudo not found"); + expect(fs.existsSync(path.join(sdk, "avdmanager.log"))).toBe(false); + expect(fs.existsSync(path.join(projectDir, ".picklab"))).toBe(false); + expect(fs.existsSync(env.PICKLAB_HOME!)).toBe(false); + }); + + it("reports an unrelated planning error and missing privilege support", async () => { + const sdk = makeFakeSdk(path.join(tmpDir, "sdk")); + const env = makeEnv(tmpDir, { sdk }); + const projectDir = path.join(tmpDir, "project"); + fs.mkdirSync(projectDir); + const result = await runCli( + [ + "init", + "--profile", + "generic", + "--create-avd", + "--create-lab-user", + "--yes", + "--json", + ], + env, + projectDir, + ); + expect(result.code).toBe(1); + const report = parseJson(result); + expect(report.errors[0]).toContain("sdkmanager"); + expect(report.errors[1]).toContain("sudo not found"); + expect(fs.existsSync(path.join(projectDir, ".picklab"))).toBe(false); + expect(fs.existsSync(env.PICKLAB_HOME!)).toBe(false); + }); + + it("materializes selected sudo steps even when another section blocks execution", async () => { + const sudoLog = path.join(tmpDir, "sudo.log"); + const sdk = makeFakeSdk(path.join(tmpDir, "sdk")); + const env = makeEnv(tmpDir, { + sdk, + bins: { sudo: `printf '%s\\n' "$*" >> ${sudoLog}\nexit 0` }, + }); + const projectDir = path.join(tmpDir, "project"); + fs.mkdirSync(projectDir); + const result = await runCli( + [ + "init", + "--profile", + "generic", + "--create-avd", + "--create-lab-user", + "--yes", + "--json", + ], + env, + projectDir, + ); + expect(result.code).toBe(1); + const report = parseJson(result); + const useradd = report.plan.find( + (step: { id: string }) => step.id === "useradd", + ); + expect(useradd.command.args.slice(0, 2)).toEqual(["-n", "useradd"]); + expect(fs.existsSync(sudoLog)).toBe(false); + }); + it("prints the full provisioning plan under --dry-run for android", async () => { const sdk = makeFakeSdk(path.join(tmpDir, "sdk"), { images: [IMAGE] }); const env = makeEnv(tmpDir, { sdk }); @@ -560,6 +682,42 @@ describe("picklab init", () => { ) as Record; expect(globalConfig.android.avdName).toBe("picklab-avd"); }); + + it("preserves unprivileged and privileged step order in one init", async () => { + const sequenceLog = path.join(tmpDir, "sequence.log"); + const sdk = makeFakeSdk(path.join(tmpDir, "sdk"), { images: [IMAGE] }); + writeScript( + path.join(sdk, "cmdline-tools", "latest", "bin", "avdmanager"), + `echo avdmanager >> ${sequenceLog}\nexit 0`, + ); + const env = makeEnv(tmpDir, { + sdk, + bins: { sudo: `echo "sudo:$*" >> ${sequenceLog}\nexit 0` }, + }); + const projectDir = path.join(tmpDir, "project"); + fs.mkdirSync(projectDir); + + const result = await runCli( + [ + "init", + "--profile", + "generic", + "--create-avd", + "--create-lab-user", + "--yes", + "--json", + ], + env, + projectDir, + ); + + expect(result.code).toBe(0); + const sequence = fs.readFileSync(sequenceLog, "utf8").trim().split("\n"); + expect(sequence[0]).toBe("avdmanager"); + expect(sequence.slice(1).every((line) => line.startsWith("sudo:-n "))).toBe( + true, + ); + }); }); describe("picklab setup lab-user", () => { @@ -602,6 +760,55 @@ describe("picklab setup lab-user", () => { expect(result.code).toBe(1); const report = parseJson(result); expect((report.errors as string[]).join("\n")).toContain("--yes"); + expect((report.plan as Array)[0].command.args).toEqual([ + "-n", + "useradd", + "-r", + "-M", + "-s", + "/usr/sbin/nologin", + "picklab-lab", + ]); + }); + + it("does not print the already-existing user before consent", async () => { + const home = path.join(tmpDir, "missing-home"); + const env = makeEnv(tmpDir, { + bins: { + getent: "echo 'picklab-lab:x:999:999::/var/empty:/bin/false'", + sudo: "exit 0", + }, + }); + const result = await runCli( + ["setup", "lab-user", "--home", home], + env, + tmpDir, + ); + expect(result.code).toBe(1); + expect(result.stdout).not.toContain('User "picklab-lab" already exists.'); + expect(fs.existsSync(home)).toBe(false); + }); + + it("prints the already-existing user before approved dry-run logs", async () => { + const home = path.join(tmpDir, "missing-home"); + const env = makeEnv(tmpDir, { + bins: { + getent: "echo 'picklab-lab:x:999:999::/var/empty:/bin/false'", + sudo: "exit 0", + }, + }); + const result = await runCli( + ["setup", "lab-user", "--home", home, "--yes", "--dry-run"], + env, + tmpDir, + ); + expect(result.code).toBe(0); + const existsIndex = result.stdout.indexOf( + 'User "picklab-lab" already exists.', + ); + expect(existsIndex).toBeGreaterThanOrEqual(0); + expect(result.stdout.indexOf("[dry-run]")).toBeGreaterThan(existsIndex); + expect(fs.existsSync(home)).toBe(false); }); it("fails closed when sudo is unavailable", async () => { @@ -614,6 +821,7 @@ describe("picklab setup lab-user", () => { expect(result.code).toBe(1); const report = parseJson(result); expect((report.errors as string[]).join("\n")).toContain("sudo not found"); + expect(report.plan).toEqual([]); }); it("runs each provisioning step through sudo and persists the config", async () => { @@ -645,6 +853,41 @@ describe("picklab setup lab-user", () => { home: "/var/lib/picklab/lab-home", }); }); + + it("returns redacted partial results when a privileged step fails", async () => { + const sudoLog = path.join(tmpDir, "sudo.log"); + const env = makeEnv(tmpDir, { + bins: { + sudo: + `printf '%s\\n' "$*" >> ${sudoLog}\n` + + `if [ "$2" = "chmod" ]; then ` + + `echo 'API_TOKEN=planted-secret' >&2; exit 7; fi\nexit 0`, + }, + }); + const result = await runCli( + ["setup", "lab-user", "--yes", "--json"], + env, + tmpDir, + ); + expect(result.code).toBe(1); + const report = parseJson(result); + expect( + (report.results as Array<{ id: string; ok: boolean }>).map((step) => [ + step.id, + step.ok, + ]), + ).toEqual([ + ["useradd", true], + ["mkdir-home", true], + ["chown-home", true], + ["chmod-home", false], + ]); + expect(JSON.stringify(report)).toContain("API_TOKEN=[REDACTED]"); + expect(JSON.stringify(report)).not.toContain("planted-secret"); + expect(fs.existsSync(path.join(env.PICKLAB_HOME!, "config.json"))).toBe( + false, + ); + }); }); describe("picklab setup android", () => { diff --git a/packages/cli/test/executor.test.ts b/packages/cli/test/executor.test.ts index 57358bd..149f0a3 100644 --- a/packages/cli/test/executor.test.ts +++ b/packages/cli/test/executor.test.ts @@ -2,8 +2,13 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { executePlan } from "../src/provision/executor.js"; -import type { ProvisioningPlan } from "../src/provision/plan.js"; +import { + classifyPlan, + executeProvisioning, + type ProvisioningExecutionAdapter, + type ProvisioningSection, +} from "../src/provision/executor.js"; +import type { ProvisioningPlan, ProvisioningStep } from "../src/provision/plan.js"; let tmpDir: string; @@ -15,165 +20,518 @@ afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); -describe("executePlan", () => { - it("only logs in dry-run mode and touches nothing", async () => { +function command( + id: string, + privileged = false, + args: string[] = ["-e", "process.exit(0)"], +): ProvisioningStep { + return { + id, + title: id, + kind: "command", + privileged, + command: { cmd: process.execPath, args }, + }; +} + +function approved(plan: ProvisioningPlan): ProvisioningSection { + return { + kind: "plan", + plan, + consent: { decide: async () => ({ kind: "approved" }) }, + }; +} + +describe("classifyPlan", () => { + it("classifies empty, automatic, unprivileged, privileged, and mixed plans", () => { + expect(classifyPlan({ steps: [] })).toBe("empty"); + expect( + classifyPlan({ + steps: [ + { + id: "config", + title: "config", + kind: "write-global-config", + privileged: false, + config: { profile: "generic" }, + }, + ], + }), + ).toBe("automatic"); + expect(classifyPlan({ steps: [command("normal")] })).toBe("unprivileged"); + expect(classifyPlan({ steps: [command("root", true)] })).toBe("privileged"); + expect( + classifyPlan({ steps: [command("normal"), command("root", true)] }), + ).toBe("mixed"); + }); +}); + +describe("executeProvisioning", () => { + it("only logs redacted plan details in dry-run mode", async () => { const target = path.join(tmpDir, "home", ".picklab"); - const plan: ProvisioningPlan = { - steps: [ - { - id: "picklab-home", - title: "Create PickLab home", - kind: "mkdir", - privileged: false, - dir: target, - }, - { - id: "persist", - title: "Persist config", - kind: "write-global-config", - privileged: false, - config: { android: { avdName: "picklab-avd" } }, - }, + const lines: string[] = []; + let adapterCalls = 0; + const result = await executeProvisioning( + [ { - id: "boom", - title: "Never runs", - kind: "command", - privileged: false, - command: { cmd: "/nonexistent/bin", args: ["x"] }, + kind: "plan", + plan: { + steps: [ + { + id: "picklab-home", + title: "Create PickLab home", + kind: "mkdir", + privileged: false, + dir: target, + }, + command("secret", true, ["--token=planted-secret"]), + ], + }, }, ], - }; - const lines: string[] = []; - const result = await executePlan(plan, { - dryRun: true, - env: { PICKLAB_HOME: path.join(tmpDir, "home", ".picklab") }, - log: (line) => lines.push(line), - }); + { + dryRun: true, + log: (line) => lines.push(line), + adapter: { + materialize: (step) => step, + execute: async () => { + adapterCalls += 1; + }, + executePrivileged: async () => { + adapterCalls += 1; + }, + }, + }, + ); expect(result.ok).toBe(true); expect(result.results.map((step) => step.detail)).toEqual([ "dry-run", "dry-run", - "dry-run", ]); - expect(lines).toHaveLength(3); - expect(lines[0]).toContain("[dry-run]"); + expect(lines).toHaveLength(2); expect(lines[0]).toContain(`mkdir -p ${target}`); + expect(lines.join("\n")).toContain("--token=[REDACTED]"); + expect(lines.join("\n")).not.toContain("planted-secret"); + expect(adapterCalls).toBe(0); expect(fs.existsSync(path.join(tmpDir, "home"))).toBe(false); }); - it("creates directories for mkdir steps", async () => { - const target = path.join(tmpDir, "a", "b"); - const result = await executePlan({ - steps: [ + it("fails closed before mutation when command consent is absent", async () => { + const target = path.join(tmpDir, "should-not-exist"); + const result = await executeProvisioning([ + { + kind: "plan", + plan: { + steps: [ + { + id: "mk", + title: "mk", + kind: "mkdir", + privileged: false, + dir: target, + }, + ], + }, + }, + { kind: "plan", plan: { steps: [command("needs-consent")] } }, + ]); + expect(result.status).toBe("cancelled"); + expect(result.results).toEqual([]); + expect(fs.existsSync(target)).toBe(false); + }); + + it("preflights cancellation before earlier automatic steps mutate", async () => { + const target = path.join(tmpDir, "should-not-exist"); + const result = await executeProvisioning([ + { + kind: "plan", + plan: { + steps: [ + { + id: "mk", + title: "mk", + kind: "mkdir", + privileged: false, + dir: target, + }, + ], + }, + }, + { + kind: "plan", + plan: { steps: [command("declined")] }, + consent: { + decide: async () => ({ + kind: "declined", + reason: "user declined", + }), + }, + }, + ]); + expect(result.status).toBe("declined"); + expect(result.error).toBe("user declined"); + expect(result.plan.steps.map((step) => step.id)).toEqual(["mk"]); + expect(result.results).toEqual([]); + expect(fs.existsSync(target)).toBe(false); + }); + + it("skips declined sections and executes the remaining plan", async () => { + const calls: string[] = []; + const adapter: ProvisioningExecutionAdapter = { + materialize: (step) => step, + execute: async (step) => { + calls.push(step.id); + }, + executePrivileged: async (step) => { + calls.push(step.id); + }, + }; + const result = await executeProvisioning( + [ { - id: "mk", - title: "mk", - kind: "mkdir", - privileged: false, - dir: target, + kind: "plan", + plan: { steps: [command("skip")] }, + consent: { + onDenied: "skip", + decide: async () => ({ kind: "declined", reason: "skipped item" }), + }, }, + approved({ steps: [command("run")] }), ], - }); + { adapter }, + ); expect(result.ok).toBe(true); - expect(fs.statSync(target).isDirectory()).toBe(true); + expect(result.skipped).toEqual(["skipped item"]); + expect(result.plan.steps.map((step) => step.id)).toEqual(["run"]); + expect(calls).toEqual(["run"]); }); - it("merges global config patches into an existing file", async () => { - const home = path.join(tmpDir, ".picklab"); - fs.mkdirSync(home, { recursive: true }); - fs.writeFileSync( - path.join(home, "config.json"), - JSON.stringify({ profile: "android", android: { extra: true } }), - ); - const result = await executePlan( + it("routes mixed plans sequentially according to step privilege", async () => { + const calls: string[] = []; + const classifications: string[] = []; + const plan = { + steps: [command("first"), command("root", true), command("last")], + }; + const result = await executeProvisioning( + [ + { + kind: "plan", + plan, + consent: { + decide: async (classification) => { + classifications.push(classification); + return { kind: "approved" }; + }, + }, + }, + ], { - steps: [ - { - id: "persist", - title: "persist", - kind: "write-global-config", - privileged: false, - config: { android: { avdName: "picklab-avd" } }, + adapter: { + materialize: (step) => step, + execute: async (step) => { + calls.push(`normal:${step.id}`); }, - ], + executePrivileged: async (step) => { + calls.push(`privileged:${step.id}`); + }, + }, }, - { env: { PICKLAB_HOME: home } }, ); expect(result.ok).toBe(true); - const saved = JSON.parse( - fs.readFileSync(path.join(home, "config.json"), "utf8"), - ) as Record; - expect(saved).toEqual({ - profile: "android", - android: { extra: true, avdName: "picklab-avd" }, - }); + expect(classifications).toEqual(["mixed"]); + expect(calls).toEqual([ + "normal:first", + "privileged:root", + "normal:last", + ]); }); - it("writes project config patches", async () => { - const projectDir = path.join(tmpDir, "project"); - fs.mkdirSync(projectDir); - const result = await executePlan( + it("executes the exact materialized step through the selected route", async () => { + const rawNormal = command("normal"); + const rawPrivileged = command("root", true); + const materialized = new Map(); + const routed: Array<{ route: string; step: ProvisioningStep }> = []; + const adapter: ProvisioningExecutionAdapter = { + materialize: (step) => { + const value = { + ...step, + title: `${step.title}-materialized`, + } as ProvisioningStep; + materialized.set(step, value); + return value; + }, + execute: async (step) => { + routed.push({ route: "normal", step }); + }, + executePrivileged: async (step) => { + routed.push({ route: "privileged", step }); + }, + }; + + await executeProvisioning( + [approved({ steps: [rawNormal, rawPrivileged] })], + { adapter }, + ); + + expect(routed).toEqual([ + { route: "normal", step: materialized.get(rawNormal) }, + { route: "privileged", step: materialized.get(rawPrivileged) }, + ]); + }); + + it("stops on failure with redacted partial results", async () => { + const calls: string[] = []; + const lines: string[] = []; + const result = await executeProvisioning( + [approved({ steps: [command("first"), command("root", true), command("last")] })], { - steps: [ - { - id: "project", - title: "project", - kind: "write-project-config", - privileged: false, - config: { profile: "generic" }, + log: (line) => lines.push(line), + adapter: { + materialize: (step) => step, + execute: async (step) => { + calls.push(step.id); }, - ], + executePrivileged: async (step) => { + calls.push(step.id); + throw new Error("API_TOKEN=planted-secret"); + }, + }, }, - { projectDir }, ); - expect(result.ok).toBe(true); - const saved = JSON.parse( - fs.readFileSync(path.join(projectDir, ".picklab", "config.json"), "utf8"), - ) as Record; - expect(saved).toEqual({ profile: "generic" }); + expect(result.status).toBe("failed"); + expect(result.results.map((step) => [step.id, step.ok])).toEqual([ + ["first", true], + ["root", false], + ]); + expect(calls).toEqual(["first", "root"]); + expect(result.error).toContain("API_TOKEN=[REDACTED]"); + expect(JSON.stringify(result) + lines.join("\n")).not.toContain( + "planted-secret", + ); }); - it("runs command steps and reports success", async () => { - const result = await executePlan({ - steps: [ + it("returns the local sudo presentation when consent is declined", async () => { + const raw: ProvisioningStep = { + id: "useradd", + title: "useradd", + kind: "command", + privileged: true, + command: { + cmd: "useradd", + args: ["-r", "-M", "-s", "/usr/sbin/nologin", "picklab-lab"], + }, + }; + const result = await executeProvisioning( + [ { - id: "ok", - title: "ok", - kind: "command", - privileged: false, - command: { cmd: process.execPath, args: ["-e", "process.exit(0)"] }, + kind: "plan", + plan: { steps: [raw] }, + consent: { + retainPlanOnDenied: true, + decide: async () => ({ kind: "declined", reason: "declined" }), + }, }, ], + { + privilege: { sudoPath: "/usr/bin/sudo", nonInteractive: true }, + }, + ); + expect(result.status).toBe("declined"); + expect(result.plan.steps[0]).toMatchObject({ + privileged: true, + command: { + cmd: "/usr/bin/sudo", + args: [ + "-n", + "useradd", + "-r", + "-M", + "-s", + "/usr/sbin/nologin", + "picklab-lab", + ], + }, }); - expect(result.ok).toBe(true); + expect(raw).toMatchObject({ command: { cmd: "useradd" } }); + if (raw.kind !== "command") throw new Error("expected command step"); + expect(raw.command.args).toEqual([ + "-r", + "-M", + "-s", + "/usr/sbin/nologin", + "picklab-lab", + ]); + }); + + it("fails privilege preflight before any mutation", async () => { + const target = path.join(tmpDir, "should-not-exist"); + let consentCalls = 0; + const result = await executeProvisioning( + [ + { + kind: "plan", + plan: { + steps: [ + { + id: "mk", + title: "mk", + kind: "mkdir", + privileged: false, + dir: target, + }, + ], + }, + }, + { + kind: "plan", + plan: { steps: [command("root", true)] }, + privilegeUnavailable: { reason: "same missing sudo message" }, + consent: { + decide: async () => { + consentCalls += 1; + return { kind: "approved" }; + }, + }, + }, + ], + { privilege: { sudoPath: null, nonInteractive: true } }, + ); + expect(result.status).toBe("failed"); + expect(result.error).toBe("same missing sudo message"); + expect(result.plan.steps.map((step) => step.id)).toEqual(["mk"]); + expect(result.results).toEqual([]); + expect(consentCalls).toBe(0); + expect(fs.existsSync(target)).toBe(false); }); - it("stops at the first failing command with stderr detail", async () => { - const marker = path.join(tmpDir, "should-not-exist"); - const result = await executePlan({ - steps: [ + it("retains prior prepared sections and ordered policy results", async () => { + const target = path.join(tmpDir, "should-not-exist"); + const result = await executeProvisioning( + [ { - id: "fail", - title: "fail", - kind: "command", - privileged: false, - command: { - cmd: process.execPath, - args: ["-e", 'console.error("boom"); process.exit(3)'], + kind: "plan", + plan: { + steps: [ + { + id: "mk", + title: "mk", + kind: "mkdir", + privileged: false, + dir: target, + }, + ], }, }, + { kind: "blocked", action: "skip", reason: "first skip" }, { - id: "after", - title: "after", - kind: "mkdir", - privileged: false, - dir: marker, + kind: "plan", + plan: { steps: [command("root", true)] }, + privilegeUnavailable: { action: "skip", reason: "second skip" }, }, + { kind: "blocked", reason: "later error" }, ], + { privilege: { sudoPath: null, nonInteractive: true } }, + ); + + expect(result.plan.steps.map((step) => step.id)).toEqual(["mk"]); + expect(result.skipped).toEqual(["first skip", "second skip"]); + expect(result.errors).toEqual(["later error"]); + expect(result.results).toEqual([]); + expect(fs.existsSync(target)).toBe(false); + }); + + it("redacts the entire public plan while routing unredacted materialized data", async () => { + const secret = "API_TOKEN=planted-secret"; + const routed: ProvisioningStep[] = []; + const lines: string[] = []; + const raw: ProvisioningStep = { + id: secret, + title: secret, + kind: "command", + privileged: true, + command: { + cmd: "tool", + args: [secret], + env: { API_TOKEN: "planted-secret" }, + input: secret, + }, + }; + const config: ProvisioningStep = { + id: "config", + title: "config", + kind: "write-global-config", + privileged: false, + config: { labUser: { name: secret, home: secret } }, + }; + const result = await executeProvisioning( + [approved({ steps: [raw, config] })], + { + log: (line) => lines.push(line), + adapter: { + materialize: (step) => step, + execute: async (step) => { + routed.push(step); + }, + executePrivileged: async (step) => { + routed.push(step); + }, + }, + }, + ); + + expect(routed).toEqual([raw, config]); + expect(JSON.stringify(result.plan) + lines.join("\n")).not.toContain( + "planted-secret", + ); + expect(JSON.stringify(result.plan)).toContain("[REDACTED]"); + }); + + it("merges global and project config through the local adapter", async () => { + const home = path.join(tmpDir, ".picklab"); + const projectDir = path.join(tmpDir, "project"); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(projectDir); + fs.writeFileSync( + path.join(home, "config.json"), + JSON.stringify({ profile: "android", android: { extra: true } }), + ); + const result = await executeProvisioning( + [ + { + kind: "plan", + plan: { + steps: [ + { + id: "global", + title: "global", + kind: "write-global-config", + privileged: false, + config: { android: { avdName: "picklab-avd" } }, + }, + { + id: "project", + title: "project", + kind: "write-project-config", + privileged: false, + config: { profile: "generic" }, + }, + ], + }, + }, + ], + { env: { PICKLAB_HOME: home }, projectDir }, + ); + expect(result.ok).toBe(true); + expect( + JSON.parse(fs.readFileSync(path.join(home, "config.json"), "utf8")), + ).toEqual({ + profile: "android", + android: { extra: true, avdName: "picklab-avd" }, }); - expect(result.ok).toBe(false); - expect(result.error).toContain("boom"); - expect(result.results).toHaveLength(1); - expect(fs.existsSync(marker)).toBe(false); + expect( + JSON.parse( + fs.readFileSync(path.join(projectDir, ".picklab", "config.json"), "utf8"), + ), + ).toEqual({ profile: "generic" }); }); }); diff --git a/packages/cli/test/planner.test.ts b/packages/cli/test/planner.test.ts index 1cb151a..30f75d2 100644 --- a/packages/cli/test/planner.test.ts +++ b/packages/cli/test/planner.test.ts @@ -44,7 +44,6 @@ const baseLabUser = { userExists: false, homeExists: false, kvmPresent: true, - sudoPath: "/usr/bin/sudo", }; describe("planLabUser", () => { @@ -63,16 +62,14 @@ describe("planLabUser", () => { const useradd = result.plan.steps[0]!; expect(useradd.privileged).toBe(true); expect(commandOf(useradd)).toEqual({ - cmd: "/usr/bin/sudo", - args: ["useradd", "-r", "-M", "-s", "/usr/sbin/nologin", "picklab-lab"], + cmd: "useradd", + args: ["-r", "-M", "-s", "/usr/sbin/nologin", "picklab-lab"], }); expect(commandOf(result.plan.steps[3]!).args).toEqual([ - "chmod", "750", "/var/lib/picklab/lab-home", ]); expect(commandOf(result.plan.steps[4]!).args).toEqual([ - "usermod", "-aG", "kvm", "picklab-lab", @@ -94,11 +91,21 @@ describe("planLabUser", () => { ); }); - it("prefixes sudo with -n in non-interactive mode", () => { - const result = planLabUser({ ...baseLabUser, nonInteractive: true }); + it("keeps every privileged command free of sudo routing", () => { + const result = planLabUser(baseLabUser); expect(result.ok).toBe(true); if (!result.ok) return; - expect(commandOf(result.plan.steps[0]!).args[0]).toBe("-n"); + const commands = result.plan.steps + .filter((step) => step.kind === "command") + .map(commandOf); + expect(commands.map((command) => command.cmd)).toEqual([ + "useradd", + "mkdir", + "chown", + "chmod", + "usermod", + ]); + expect(JSON.stringify(commands)).not.toContain("sudo"); }); it("is a config-only no-op when user and home already exist", () => { @@ -125,14 +132,12 @@ describe("planLabUser", () => { "chmod-home", "persist-lab-user", ]); - }); - - it("fails closed when sudo is unavailable", () => { - const result = planLabUser({ ...baseLabUser, sudoPath: null }); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.error).toContain("sudo not found"); - expect(result.error).toContain("useradd -r -M -s /usr/sbin/nologin"); + expect( + result.plan.steps + .filter((step) => step.kind === "command") + .every((step) => step.privileged), + ).toBe(true); + expect(result.plan.steps.at(-1)?.privileged).toBe(false); }); it("rejects invalid user names", () => {