Skip to content

fix(install): ask for the root password before the work, not partway through - #394

Merged
ralyodio merged 1 commit into
mainfrom
prime-sudo-upfront
Aug 14, 2026
Merged

ralyodio merged 1 commit into
mainfrom
prime-sudo-upfront

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The problem

tailscale's official installer goes through the distro package manager, so it escalates on its own — after it has already spent time refreshing package lists. Tolerable when it is the only thing running. Inside moshcode update it is not, because that walks a plan: moshcode itself, then every installed engine, then every tool.

On a box with tailscale installed, that plan looks like this (real output from planUpgrade):

 1. opencode     engine  needsRoot=false
 …
16. turso        tool    needsRoot=false
17. tailscale    tool    needsRoot=true     ← the password prompt lands here
18. alpaca       tool    needsRoot=false

Step 17 of 18, after a self-update and fifteen downloads. The operator watches the first minute, looks away, and comes back to a run parked on a password prompt — or to the escalation helper's own timeout having failed the step. The work is unattended by design; the one interactive moment in it should not be buried where nobody is looking.

The fix

Ask before starting. primeEscalation() warms the credential up front, and because the helper caches against the terminal that every installer inherits, the escalation those installers do later finds it already there and never asks.

Wired into all three paths that hand off to an installer:

path where
moshcode install <target> bin/moshcode.mjs
the pit's /install <target> src/tui.mjs
moshcode update / upgrade src/upgrade.mjs, once before the loop

It stays quiet unless it has something to ask

A prompt nobody needed is its own bug, so priming is skipped entirely when:

  • no planned step needs root — the common moshcode update never asks
  • a credential is already cached, or the rule is NOPASSWD (silent, not "asking for your password" followed by no prompt)
  • there is no terminal to type into — a CI job must not stall on a password
  • the box has no sudo/doas
  • the process is already root

Platform-aware, because macOS does not escalate

needsRoot: { except: ["darwin"] } on tailscale. There its script delegates to the App Store, which does its own authorisation — so a blanket flag would ask every mac user for a password on behalf of a step that never escalates. needsRootHere() also accepts true (always) and an explicit platform list.

Failure is never fatal

A declined prompt, a wrong password, an operator not in sudoers, a failed spawn — all return { primed: false, reason } and the run carries on. The installer prompts for itself if it turns out to need to, which is exactly the old behaviour. Being unable to ask early is a missed convenience, not a reason to refuse to install.

Verification

  • node --test — 1735 pass, 0 fail (was 1717; +18 new).
  • test/escalate-prime.test.mjs (13): the -v validate-only flag, doas getting a real command since it has no -v, silence when cached, root, no-tty, no-escalator, MOSHCODE_ESCALATOR, declined, spawn failure, and the platform rules.
  • test/upgrade-prime-sudo.test.mjs (5): the ordering contract, asserted on one interleaved event log — prime:tailscale must be event 0, before any runCmd. Plus: a clean plan never asks, one prompt covers several privileged items, and a declined prompt does not cancel the run.

Every input is injected, because none of these cases — no tty, no sudo, a cancelled prompt, being root — can be produced from inside a test process. The live prompt itself was not exercised: this dev box blocks the escalation helper from the agent's shell, so the stubs are the coverage there.

🤖 Generated with Claude Code

…through

tailscale's official installer goes through the distro package manager, so it
escalates on its own after refreshing package lists. That is tolerable when it
is the only thing running and miserable inside `moshcode update`, which walks a
plan: moshcode itself, then every installed engine, then every tool. On a box
with tailscale installed the prompt lands at step 17 of 18 — the operator sees a
long stream of downloads, looks away, and comes back to a run parked on a
password prompt, or to the escalation helper's own timeout having failed the
step. The work is unattended by design; the one interactive moment in it should
not be buried where nobody is looking.

So ask first. New primeEscalation() warms the credential before the plan starts,
and because the helper caches against the terminal that every installer
inherits, the escalation they do later finds it already there and never asks.

It stays quiet unless it has something to ask: nothing happens when no planned
step needs root, when a credential is already cached (or the rule is NOPASSWD),
when there is no terminal to type into, when the box has no escalation helper,
or when already root. `needsRoot: { except: ["darwin"] }` on tailscale is why
macOS is spared — there the script delegates to the App Store, which does its
own authorisation, so a password prompt would be for a step that never
escalates.

A declined or failed prompt is not fatal anywhere it is called. The installer
prompts for itself if it turns out to need to, which is exactly the old
behaviour — being unable to ask early is a missed convenience, not a reason to
refuse to install.

Wired into all three paths that hand off to an installer: `moshcode install`,
the pit's `/install`, and `moshcode update`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

97 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 41 | LOW: 51

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:79
HIGH sh-remote-script-execution install.sh:83
HIGH tls-verification-disabled src/dns.mjs:741
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:61
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:75
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:101
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:265
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:269
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:314
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:499
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:675
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:677
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:736
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:782
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:852
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:955
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1063
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1199
MEDIUM js-unescaped-html-sink apps/pwa/src/routes/moshpit.mjs:1419
MEDIUM js-timing-unsafe-mac-compare apps/pwa/src/routes/settings-sync.mjs:192
MEDIUM js-dynamic-code-execution apps/pwa/test/apikey-mask.test.mjs:129
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:111
MEDIUM sql-template-interpolation apps/pwa/test/credits-webhook-event-match.test.mjs:131
MEDIUM sql-template-interpolation apps/pwa/test/moshpit-terms.test.mjs:192
MEDIUM sql-string-concatenation src/cli-schema.mjs:128
MEDIUM sql-string-concatenation src/cli-schema.mjs:417
MEDIUM sql-string-concatenation src/cli-schema.mjs:543
MEDIUM js-timing-unsafe-mac-compare src/settings-sync.mjs:325
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:93
MEDIUM insecure-temp-file test/dns-disable-restore.test.mjs:310
MEDIUM insecure-temp-file test/news.test.mjs:649
MEDIUM insecure-temp-file test/plugins.test.mjs:152
MEDIUM insecure-temp-file test/pty.test.mjs:28
MEDIUM insecure-temp-file test/pty.test.mjs:31
MEDIUM insecure-temp-file test/pty.test.mjs:40
MEDIUM insecure-temp-file test/pty.test.mjs:42
MEDIUM insecure-temp-file test/pty.test.mjs:47
MEDIUM insecure-temp-file test/pty.test.mjs:48
MEDIUM insecure-temp-file test/pty.test.mjs:49
MEDIUM insecure-temp-file test/tabs.test.mjs:8
MEDIUM insecure-temp-file test/tabs.test.mjs:13
MEDIUM insecure-temp-file test/tabs.test.mjs:14
MEDIUM insecure-temp-file test/tabs.test.mjs:22
MEDIUM insecure-temp-file test/trust.test.mjs:240
LOW secret-generic-credential apps/pwa/test/apikey-bearer-scheme.test.mjs:30
LOW secret-generic-credential apps/pwa/test/apikey-mask.test.mjs:38
LOW secret-generic-credential apps/pwa/test/apikey-reveal.test.mjs:35
LOW secret-generic-credential apps/pwa/test/approvals-context.test.mjs:28

…and 47 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit b952c73 into main Aug 14, 2026
4 checks passed
@ralyodio ralyodio mentioned this pull request Aug 14, 2026
@ralyodio
ralyodio deleted the prime-sudo-upfront branch August 14, 2026 04:59
This was referenced Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant