feat(registry): read the registry password from stdin - #329
Conversation
registry create accepted the password only as --password, which cobra also marked required, so there was no way to create a registry auth without putting a long-lived credential in argv (readable from the process table) and in shell history. The reporter created the auth in the console instead and read back only the id, which is the workaround the command exists to avoid. Adds --password-stdin, matching docker login, plus a no-echo prompt when stdin is a terminal and neither flag is given. --password still works and now warns, since scripts already depend on it. --password is no longer MarkFlagRequired: cobra validates required flags before RunE, which would reject the stdin and prompt paths before they can supply a value. resolvePassword enforces it instead, and the two flags are mutually exclusive. This is the repo's first MarkFlagsMutuallyExclusive site, so cobra's ValidateFlagGroups message reached the error sink unclassified and reported cli_error; it is added to usageErrorPrefixes alongside required flag(s). golang.org/x/term moves from indirect to direct. No new dependency. Closes #327
--password is a supported choice, not a mistake, so it should not nag at runtime. The help text still points at --password-stdin for callers who want the credential kept out of argv.
…inal on ctrl-c Two defects found in self-review, both verified against the live api and a pty. The resolver rejected any password containing a newline, on the assumption that a credential never spans lines. A gcr.io / artifact registry credential is an entire service-account json key passed as the password with username _json_key -- the same shape 'docker login --password-stdin' takes -- and the api accepts it. The guard rejected input that works, breaking the workflow --password-stdin exists to serve. Removed, with a regression test. term.ReadPassword disables echo and restores it from a deferred ioctl, which SIGINT's default disposition never reaches, so a ctrl-c at the prompt exited with echo still off and left the caller's shell typing blind until 'stty sane'. The prompt now restores the saved terminal state from a signal handler and exits 130. Verified on a pty: echo returns to on, exit code is 130.
| an interactive prompt when neither flag is given. --password-stdin keeps the | ||
| credential out of the process table and your shell history.`, | ||
| Example: ` # read the password from a pipe | ||
| echo "$REGISTRY_TOKEN" | runpodctl registry create --name ghcr --username me --password-stdin |
There was a problem hiding this comment.
Could we use printenv REGISTRY_TOKEN here, matching the companion plugin PR? echo treats some valid credential values as options—for example, a token equal to -n produces zero bytes in both bash and zsh—so runpodctl receives an empty password. printenv puts only the environment-variable name in the producer's argv and avoids shell-specific echo behavior.
| return "", fmt.Errorf("failed to read the terminal state: %w", err) | ||
| } | ||
| sigCh := make(chan os.Signal, 1) | ||
| signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) |
There was a problem hiding this comment.
Please include syscall.SIGQUIT in the terminal-restoration handler and add the expected exit-code-131 test. On Unix, Ctrl-\ sends SIGQUIT; the default Go handling terminates without running ReadPassword's deferred restore, so terminal echo can remain disabled—the same failure this block prevents for Ctrl-C.
| } | ||
|
|
||
| func runCreate(cmd *cobra.Command, args []string) error { | ||
| password, err := resolvePassword(cmd.InOrStdin(), cmd.ErrOrStderr(), createPassword, createPasswordStdin) |
There was a problem hiding this comment.
Non-blocking: consider constructing api.NewClient() before reading or prompting for the registry password. That lets a missing Runpod API key fail immediately instead of consuming a one-shot stdin secret or asking the user to enter a password that cannot be submitted.
lukepiette
left a comment
There was a problem hiding this comment.
Approving. The inline comments are non-blocking follow-up improvements; the core implementation is sound.
Closes #327
Context
registry createstores a container-registry credential so Runpod can pull private images. It needs a username and a password or access token:$ runpodctl registry create --name ghcr --username me --password <secret>The issue
--passwordwas the only way to supply the secret, and cobra marked it required — so there was no way to run the command without putting a long-lived credential inargv, where it is readable from the process table and written to shell history. No--password-stdin, no file, no prompt.The reporter did not use the command at all: they created the registry auth in the console and read back only the id with
registry list, specifically to avoid the flag. So the CLI was unusable for the one workflow it exists to serve, by anyone who treats registry tokens as secrets.The fix
--password-stdin, matchingdocker login.--passwordis unchanged and stays a first-class option — no warning, no deprecation. Whether argv exposure matters is the caller's call.Two things I'd like a second opinion on
1. I removed
MarkFlagRequired("password"). Cobra enforces required flags beforeRunE, so leaving it would reject the stdin and prompt paths before they could supply a value — the new features cannot work with that line present. The guarantee therefore moves from the framework intoresolvePassword, which is hand-written and so can be wrong in a way the framework could not. I traced every branch andregistry_test.gonow pins the required/not-required split per flag, but "I checked my own logic" is the claim worth verifying rather than accepting.2. I added four lines to
cmd/root.go, outside the registry package. This is the repo's firstMarkFlagsMutuallyExclusivesite, so cobra'sValidateFlagGroupsmessage reached the central error classifier unrecognised and reportedcli_error("something broke") instead ofusage_error("you used it wrong"). Adding it tousageErrorPrefixesfixes the label.Two caveats. That file classifies errors for the whole CLI, so it is the only part of this PR that can affect commands I did not touch. And it works by matching cobra's English message text — five entries already do this, with a comment warning to re-check them on cobra upgrades, but this adds a sixth hostage to that fragility. Additive and narrow, but worth agreeing to rather than inheriting.
golang.org/x/termmoves from indirect to direct. No new dependency.Two defects I found and fixed in self-review
Multi-line stdin was rejected. I had guarded on the assumption that a credential never spans lines. A gcr.io / Artifact Registry credential is an entire service-account json key passed as the password with username
_json_key— the same shapedocker login --password-stdintakes. I confirmed against the live api that it accepts a multi-line password, so the guard rejected valid input and broke the exact workflow--password-stdinexists for. Removed, with a regression test.Ctrl-C at the prompt left the terminal broken.
term.ReadPassworddisables echo and restores it from a deferred ioctl, which SIGINT's default disposition never reaches — the process died with echo still off, leaving the caller's shell typing blind untilstty sane. Reproduced on a pty (ECHOstill false after SIGINT). The prompt now restores saved terminal state from a signal handler and exits 130. Re-verified on a pty: echo returns to on, exit code is 130, normal path unaffected.Testing
10 new tests in
cmd/registry/create_password_test.go(13 top-level in the package, 9 subtests).resolvePasswordtakes anio.Reader/io.Writer, and the tty detector and prompt are package vars, so the whole matrix runs without a terminal: a trailing\n/\r\nis stripped while leading, inner and pre-newline whitespace survive; empty stdin rejected while a multi-line credential is preserved; a broken pipe stays uncoded (environment failure, not usage);--passwordasserted to print nothing and leave stdin unconsumed; no-source-no-tty fails fast rather than blocking on a read that never arrives.Live e2e against the production api, resources cleaned up
Per AGENTS.md, built the binary and ran every path against the live api.
--password-stdinvia pipe--passwordflag--password-stdin < file_json_keyjson on stdinError paths return before any api call, because
resolvePasswordruns first. Each returnsusage_errorand exits 1:--passwordand--password-stdintogether--password-stdin--name/--usernameremain required. Multi-line stdin is accepted, not an error.Every auth created was deleted afterwards, and
registry listconfirmed clean.Related PRs
Two independent groups. Nothing in one blocks anything in the other.
Group A — the Windows install command (#311)
One broken command that had been copy-pasted into three repos. Each PR fixes its own copy; any order, no dependencies.
README.md— fix the command, and install toPATHrunpodctl/overview.mdx— same fix on docs.runpod.ioreference/install.md, the third copyGroup B — secure registry passwords (#327)
Order matters. #51 documents a flag that does not exist on
mainuntil #329 merges, so it stays draft until then.--password-stdin, no-echo prompt, mutually exclusive flagsThe only thing the two groups share is that both touched the runpodctl skill, which is what prompted Group A's deletion: the skill had been keeping its own copy of install instructions the runpodctl README already owned.