Skip to content

feat(registry): read the registry password from stdin - #329

Open
justinwlin wants to merge 4 commits into
mainfrom
feat/registry-password-stdin
Open

feat(registry): read the registry password from stdin#329
justinwlin wants to merge 4 commits into
mainfrom
feat/registry-password-stdin

Conversation

@justinwlin

@justinwlin justinwlin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes #327

Context

registry create stores 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

--password was 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 in argv, 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, matching docker login.
  • A no-echo prompt when stdin is a terminal and neither flag is given.
  • --password is unchanged and stays a first-class option — no warning, no deprecation. Whether argv exposure matters is the caller's call.
  • The two password flags are mutually exclusive.
$ echo "$TOKEN" | runpodctl registry create --name ghcr --username me --password-stdin
$ runpodctl registry create --name ghcr --username me --password-stdin < token.txt
$ runpodctl registry create --name ghcr --username me      # prompts, no echo

Two things I'd like a second opinion on

1. I removed MarkFlagRequired("password"). Cobra enforces required flags before RunE, 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 into resolvePassword, which is hand-written and so can be wrong in a way the framework could not. I traced every branch and registry_test.go now 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 first MarkFlagsMutuallyExclusive site, so cobra's ValidateFlagGroups message reached the central error classifier unrecognised and reported cli_error ("something broke") instead of usage_error ("you used it wrong"). Adding it to usageErrorPrefixes fixes 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/term moves 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 shape docker login --password-stdin takes. 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-stdin exists for. Removed, with a regression test.

Ctrl-C at the prompt left the terminal broken. term.ReadPassword disables 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 until stty sane. Reproduced on a pty (ECHO still 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). resolvePassword takes an io.Reader/io.Writer, and the tty detector and prompt are package vars, so the whole matrix runs without a terminal: a trailing \n/\r\n is 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); --password asserted 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.

path result
--password-stdin via pipe created ok
--password flag created ok, stdout parseable json, stderr empty
--password-stdin < file created ok
interactive prompt on a real pty created ok, prompt shown, typed secret absent from the pty transcript (echo genuinely off)
multi-line _json_key json on stdin created ok — the regression the self-review fix was for

Error paths return before any api call, because resolvePassword runs first. Each returns usage_error and exits 1:

  • both --password and --password-stdin together
  • empty stdin with --password-stdin
  • no password source with stdin not a terminal

--name/--username remain required. Multi-line stdin is accepted, not an error.

Every auth created was deleted afterwards, and registry list confirmed 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.

PR Repo Change
#328 runpodctl README.md — fix the command, and install to PATH
runpod/docs#805 docs runpodctl/overview.mdx — same fix on docs.runpod.io
runpod/runpod-plugins-official#49 plugins delete reference/install.md, the third copy

Group B — secure registry passwords (#327)

Order matters. #51 documents a flag that does not exist on main until #329 merges, so it stays draft until then.

# PR Repo Change
1 #329this PR runpodctl --password-stdin, no-echo prompt, mutually exclusive flags
2 runpod/runpod-plugins-official#51 plugins skill guidance + regression eval (draft until #329 lands)

The 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.

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.
Comment thread cmd/registry/create.go
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/registry/create.go
return "", fmt.Errorf("failed to read the terminal state: %w", err)
}
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/registry/create.go
}

func runCreate(cmd *cobra.Command, args []string) error {
password, err := resolvePassword(cmd.InOrStdin(), cmd.ErrOrStderr(), createPassword, createPasswordStdin)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 lukepiette left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The inline comments are non-blocking follow-up improvements; the core implementation is sound.

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.

runpodctl registry create accepts a registry password only as a command-line flag

2 participants