Skip to content
Merged
23 changes: 21 additions & 2 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -1293,7 +1293,11 @@ built-in default such as `hack.local`, they stop with a usage message that names
activation shows the exact scoped claim and requires interactive confirmation
and a native administrator prompt. Run it from a terminal; the bounded privileged
steps preserve that terminal's sudo authorization and report the underlying
command failure if a step fails. Before changing files, the candidate checks that
command failure if a step fails. If you approve but the administrator prompt
does not complete (it times out after 120 seconds, is interrupted, rejects the
password, or Hack is not the terminal's foreground job), activation fails during
authorization with that cause, says nothing changed, and suggests running
`sudo -v` in the same terminal first. Only answering no is reported as denied. Before changing files, the candidate checks that
the root-owned Homebrew system launchd job matches the inspected dnsmasq command
and configuration. It restarts that verified job directly through launchctl, so
activation does not need Homebrew metadata downloads as root. Missing, changed or
Expand Down Expand Up @@ -1333,6 +1337,16 @@ trust repair does not set up DNS or a browser's Local Network permission.
Both authorization and installation retain the caller's controlling terminal,
so terminal-scoped sudo authorization remains valid during the bounded repair.

Doctor's progress spinner stops before any repair asks a question. It stays off
through the native administrator prompt and the privileged steps that follow,
then resumes, so sudo's `Password:` prompt (or the Touch ID sheet) stays
visible. Doctor runs repairs one at a time. The administrator prompt (`sudo -v`)
runs in the terminal's foreground process group before any `sudo -n` step. If it
does not complete, the CA repair reports an error that names the cause and
suggests running `sudo -v` in the same terminal first. sudo itself prints
`a password is required` when its first password read is interrupted or times
out.

`hack.local` stays the default dev-host root. An older Docker-era install routes
it to Docker Caddy with `address=/.hack.local/172.30.0.2` in Homebrew's
`dnsmasq.conf`, which the native loopback relay cannot serve. On macOS, native
Expand Down Expand Up @@ -1361,7 +1375,12 @@ Each unmet condition is a closed refusal reason: `relay-not-verified`,
`dnsmasq-config-missing`, `unreadable-config`, `uninspected-config-source`,
`no-managed-line`, `unmanaged-line`, `foreign-target`, `foreign-resolver`,
`old-target-live`, `pending-receipt`, `invalid-receipt`, `non-interactive`,
`declined`, `busy`, `restart-unavailable` or `inputs-changed`. Refusal details
`declined`, `authorization-failed`, `busy`, `restart-unavailable` or
`inputs-changed`. `declined` means you answered no to the confirmation.
`authorization-failed` means you approved, but the native administrator prompt
did not complete: it timed out after 120 seconds, was interrupted, rejected the
password, or Hack was not the terminal's foreground job. Run `sudo -v` in the
same terminal, then rerun `hack doctor --fix` (or the rollback command). Refusal details
name files and lines by number and never repeat file contents. The repair needs interactive confirmation and the native macOS
administrator prompt; without a terminal it refuses and names `hack doctor --fix`
to run in one. Only one repair or rollback runs at a time; a second one refuses
Expand Down
88 changes: 69 additions & 19 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ import {
} from "../lib/mutagen.ts";
import {
inspectNativeCaTrust,
type NativeCaDoctorDependencies,
repairNativeCaTrust,
} from "../lib/native-ca-doctor.ts";
import {
Expand Down Expand Up @@ -161,6 +162,11 @@ import {
import { readProjectsRegistry } from "../lib/projects-registry.ts";
import { readRuntimeProjects } from "../lib/runtime-projects.ts";
import { exec, findExecutableInPath, run } from "../lib/shell.ts";
import {
type PausableProgress,
pausableSpinner,
withTerminalHandoff,
} from "../lib/terminal-handoff.ts";
import { resolveSessionsMuxMode } from "../mux/mux-config.ts";
import {
renderGlobalCaddyCompose,
Expand Down Expand Up @@ -1050,22 +1056,11 @@ async function handleNativeDoctor(opts: {
message: "System-keychain repair requires macOS.",
};
}
const repaired = await repairNativeCaTrust({
return await repairNativeCaTrustCheck({
progress,
runtime: opts.runtime,
hostname,
});
const messages = {
installed:
"Verified live native root installed in the System keychain.",
"already-trusted": "Verified live native root was already trusted.",
declined:
"Trust repair requires an interactive confirmation and native administrator prompt.",
} as const;
return {
name: "native ca repair",
status: repaired === "declined" ? "warn" : "ok",
message: messages[repaired],
};
})
);
}
Expand Down Expand Up @@ -1188,6 +1183,46 @@ async function handleNativeDoctor(opts: {
return hasError ? 1 : 0;
}

/**
* The `native ca repair` check body. The spinner is released at the trust
* confirmation and stays off through the administrator prompt and the sudo -n
* install, so nothing erases the password prompt.
*/
export async function repairNativeCaTrustCheck(opts: {
readonly progress: DoctorProgress;
readonly runtime: NativeRuntimeSelection;
readonly hostname: string | undefined;
readonly dependencies?: Partial<NativeCaDoctorDependencies>;
}): Promise<CheckResult> {
const confirm = opts.dependencies?.confirm ?? confirmSafe;
const repaired = await withTerminalHandoff(
opts.progress,
async (claim) =>
await repairNativeCaTrust({
runtime: opts.runtime,
hostname: opts.hostname,
dependencies: {
...opts.dependencies,
confirm: async (prompt) => {
claim();
return await confirm(prompt);
},
},
})
);
const messages = {
installed: "Verified live native root installed in the System keychain.",
"already-trusted": "Verified live native root was already trusted.",
declined:
"Trust repair requires an interactive confirmation and native administrator prompt.",
} as const;
return {
name: "native ca repair",
status: repaired === "declined" ? "warn" : "ok",
message: messages[repaired],
};
}

/**
* Report, and with --fix authorize and apply, the native hack.local route
* repair. Refusals are warnings with a closed reason; only an unexpected
Expand Down Expand Up @@ -1217,7 +1252,23 @@ export async function checkNativeHackLocalRoute(opts: {
if (opts.fix) {
results.push(
await runCheck(opts.progress, HACK_LOCAL_REPAIR_CHECK, async () => {
const repaired = await repairHackLocalRoute(deps);
// Release the terminal before the first prompt and keep it released
// through the administrator prompt and every later sudo -n step.
const repaired = await withTerminalHandoff(
opts.progress,
async (claim) =>
await repairHackLocalRoute({
...deps,
authorize: async (message) => {
claim();
return await deps.authorize(message);
},
confirm: async (message) => {
claim();
return await deps.confirm(message);
},
})
);
if (repaired.outcome === "repaired") {
return {
name: HACK_LOCAL_REPAIR_CHECK,
Expand Down Expand Up @@ -1341,10 +1392,7 @@ export function assertDoctorOptionCompatibility(opts: {
}
}

type DoctorProgress = {
start(message?: string): void;
stop(message?: string): void;
};
type DoctorProgress = PausableProgress;

/**
* Spinner wrapper that stays silent on non-TTY stdout (piped/automation) so
Expand All @@ -1359,13 +1407,15 @@ function createDoctorProgress(opts: {
process.stdout.isTTY === true &&
(process.env.NO_COLOR ?? "").trim().length === 0;
if (interactive) {
return spinner();
return pausableSpinner(spinner());
}

return {
start: (_message?: string) => {
// Quiet in non-TTY/JSON mode.
},
pause: () => undefined,
resume: () => undefined,
stop: (message?: string) => {
if (!opts.json && message) {
process.stdout.write(`${message}\n`);
Expand Down
36 changes: 22 additions & 14 deletions src/commands/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ import { getLaunchdServiceStatus } from "../daemon/launchd.ts";
import { resolveDaemonPaths } from "../daemon/paths.ts";
import { isProcessRunning } from "../daemon/process.ts";
import { readDaemonStatus } from "../daemon/status.ts";
import {
ADMINISTRATOR_PROMPT_HINT,
describeAdministratorAuthorizationFailure,
requestAdministratorAuthorization,
} from "../lib/admin-authorization.ts";
import {
resolveGlobalConfigPath,
resolveGlobalHackDir,
Expand Down Expand Up @@ -543,13 +548,7 @@ async function handleGlobalDnsActivate({
if (!accepted) {
return false;
}
return (
(await run(["sudo", "-v"], {
stdin: "inherit",
timeoutMs: 120_000,
forwardSignals: true,
})) === 0
);
return await requireNativeAdministratorAuthorization();
};
const deps = createNativeDnsHostDependencies({
receiptPath: initial.receiptPath,
Expand Down Expand Up @@ -668,13 +667,7 @@ async function handleGlobalDnsDeactivate({
if (!accepted) {
return false;
}
return (
(await run(["sudo", "-v"], {
stdin: "inherit",
timeoutMs: 120_000,
forwardSignals: true,
})) === 0
);
return await requireNativeAdministratorAuthorization();
};
const deps = createNativeDnsHostDependencies({
receiptPath: initial.receiptPath,
Expand Down Expand Up @@ -2345,6 +2338,21 @@ async function runMacPrivilegedCommand(opts: {
});
}

/**
* Native administrator prompt after an approved DNS change. A failed prompt
* throws, so it is reported as an authorization failure and never as the
* user's decline.
*/
async function requireNativeAdministratorAuthorization(): Promise<true> {
const result = await requestAdministratorAuthorization();
if (result.status === "failed") {
throw new Error(
`macOS administrator authorization did not complete (${describeAdministratorAuthorizationFailure(result)}); nothing changed. ${ADMINISTRATOR_PROMPT_HINT}`
);
}
return true;
}

function isInteractiveTerminal(): boolean {
return process.stdin.isTTY === true;
}
Expand Down
116 changes: 116 additions & 0 deletions src/lib/admin-authorization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { run } from "./shell.ts";
import { openTerminalControl } from "./tty-process-group.ts";

/**
* Native macOS administrator authorization (`sudo -v`) shared by native
* repairs and DNS activation.
*
* Contract:
* - The caller has already obtained the human confirmation and released the
* terminal. No spinner or prompt may draw while sudo shows its password
* prompt; a redrawing spinner erases the prompt before the user sees it.
* - Hack must own the terminal's foreground process group. `run` then hands the
* foreground to the sudo child's own group and restores it afterwards. A
* background invocation is refused before sudo runs.
* - Later privileged steps use `sudo -n` with the ticket this call caches, so
* no `sudo -n` step may run before this call succeeds.
* - Any non-zero exit is an authorization failure, never a decline: the user
* already approved the change. sudo reports an interrupted or timed-out first
* password read as "a password is required".
*/
export const ADMINISTRATOR_PROMPT_TIMEOUT_MS = 120_000;

/** Remediation shown with every authorization failure. */
export const ADMINISTRATOR_PROMPT_HINT =
'Run "sudo -v" in this terminal first, then retry.';

export type AdministratorAuthorizationFailure =
/** Hack is not the terminal's foreground job, so sudo cannot read a password. */
| "not-foreground"
| "timed-out"
| "interrupted"
/** sudo exited non-zero, for example after three wrong passwords. */
| "rejected";

export type AdministratorAuthorization =
| { readonly status: "authorized" }
| {
readonly status: "failed";
readonly failure: AdministratorAuthorizationFailure;
/** The sudo exit code; null when sudo never ran. */
readonly exitCode: number | null;
};

export interface AdministratorAuthorizationDependencies {
readonly runCommand: typeof run;
/** True when this process group owns the controlling terminal's foreground. */
readonly ownsTerminalForeground: () => boolean;
}

/**
* Whether this process may hand the terminal to a prompting child. An
* unavailable terminal-control library is unknown, not a refusal: sudo then
* reports its own failure.
*/
export function ownsTerminalForeground(): boolean {
let terminal: ReturnType<typeof openTerminalControl>;
try {
terminal = openTerminalControl();
} catch {
return true;
}
try {
const foreground = terminal.foreground();
return foreground > 0 && foreground === terminal.group();
} finally {
terminal.close();
}
}

const DEFAULTS: AdministratorAuthorizationDependencies = {
runCommand: run,
ownsTerminalForeground,
};

/** Show the native administrator prompt once and classify its outcome. */
export async function requestAdministratorAuthorization(
opts: {
readonly dependencies?: Partial<AdministratorAuthorizationDependencies>;
} = {}
): Promise<AdministratorAuthorization> {
const deps = { ...DEFAULTS, ...opts.dependencies };
if (!deps.ownsTerminalForeground()) {
return { status: "failed", failure: "not-foreground", exitCode: null };
}
const exitCode = await deps.runCommand(["sudo", "-v"], {
stdin: "inherit",
timeoutMs: ADMINISTRATOR_PROMPT_TIMEOUT_MS,
forwardSignals: true,
});
if (exitCode === 0) {
return { status: "authorized" };
}
let failure: AdministratorAuthorizationFailure = "rejected";
if (exitCode === 124) {
failure = "timed-out";
} else if (exitCode === 130 || exitCode === 143) {
failure = "interrupted";
}
return { status: "failed", failure, exitCode };
}

/** Control-free phrase naming why authorization did not complete. */
export function describeAdministratorAuthorizationFailure(
result: Extract<AdministratorAuthorization, { readonly status: "failed" }>
): string {
switch (result.failure) {
case "not-foreground":
return "Hack is not the terminal's foreground job, so the macOS administrator prompt cannot read a password";
case "timed-out":
return `the macOS administrator prompt timed out after ${ADMINISTRATOR_PROMPT_TIMEOUT_MS / 1000} seconds`;
case "interrupted":
return "the macOS administrator prompt was interrupted";
default:
return `sudo -v did not accept the administrator password (exit ${result.exitCode ?? "unknown"})`;
}
}
Loading
Loading