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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs/releases/UNRELEASED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
134 changes: 81 additions & 53 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,32 +41,21 @@ export interface DoctorReport {
fix?: DoctorFixReport;
}

async function consentToRepair(
question: string,
opts: DoctorCliOptions,
): Promise<boolean> {
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<ProvisioningSection[]> {
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({
Expand All @@ -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}`,
});
},
},
});
}
}

Expand All @@ -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(
Expand All @@ -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;
}
}
Expand Down
Loading
Loading