Repository navigation
feat: warn before apify mcp install embeds an expiring OAuth token - #1529
Merged
Merged
Conversation
`apify mcp install` copies the resolved API token into the MCP client's config file. Nothing refreshes that copy, so after the OAuth device-code login (#1402) the configured server silently stops working within the hour. The command now detects a resolved token that belongs to an `oauth2` profile, warns with the actual expiry and points at `--token` with a long-lived Apify Console token. Interactive runs confirm before writing; non-interactive runs abort unless `--yes` is passed. `--token` and `APIFY_TOKEN` are unaffected, and so is the tokenless `codex` client. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca
… minute Review follow-ups: - the token hint went to a hardcoded production Console path; it now uses `getConsoleIntegrationsUrl()`, so `APIFY_CONSOLE_URL` works and an organization login is sent to its own `/organization/<id>` route. - `--yes` now really answers both of this command's prompts. The `stdinCheckWrapper` fallback only applies in CI or without a TTY, so `--yes` in a terminal still asked. - "expires in about 1 minutes" -> "1 minute". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca
A refresh that can't reach the token endpoint hands back the stored token, which may have seconds left. Rounding that to "expires in about 0 minutes" read as reassurance; it's now "expires in under a minute", or "has already expired" when there's nothing left. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca
Backs out the `if (yes) return true` guards. `--yes` being ignored on a TTY comes from `stdinCheckWrapper` and affects `actors rm`, `datasets rm`, `runs rm`, `key-value-stores rm`, `key-value-stores delete-value`, `builds rm` and `builds remove-tag` too; fixing it in two of nine places only makes the CLI inconsistent. Left for a separate change, and the --yes description no longer promises more than it delivers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca
`stdinCheckWrapper` only consulted `providedConfirmFromStdin` when stdin could not be prompted (CI, or no TTY), so `--yes` was silently dropped in a terminal. The new `apify mcp install` expiry prompt made that visible: unlike the overwrite prompt it fires on every OAuth-login install, so `apify mcp install cursor --yes` hung on a question it had already been answered, and its default was No. Adds a `preAnswered` option that means "the user answered on the command line" and skips the prompt everywhere, and moves every `--yes` call site onto it: `actors rm`, `datasets rm`, `runs rm`, `key-value-stores rm`, `key-value-stores delete-value`, `builds rm`, `builds remove-tag` and both `mcp install` prompts. `cli-management/install` keeps `providedConfirmFromStdin`, which it uses as a non-interactive default rather than an answer. Also bounds the expiry warning: it only fires under a day of remaining lifetime, and reports hours or days rather than a three-digit minute count, since the authorization server picks the token lifetime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca
… prompt `useStdin` sets `hasData` for any stdin that is not a terminal — /dev/null and a closed descriptor included — so the hook's non-interactive branch never fired outside CI and the prompt waited on input that never came. A Docker build or a cron job running `apify mcp install cursor` with an OAuth login would have hung where it used to succeed. The expiry confirmation now checks for a terminal itself and exits with InvalidInput and the --token hint when there isn't one. Covered by a test file that leaves `isCI` false, which is the case that hung. The same latent hang is in the overwrite prompt and in every other `useYesNoConfirm` caller; the root cause is `useStdin.hasData`, left for a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca
Follow-ups from the principal review: - after the `preAnswered` migration, `providedConfirmFromStdin` had one caller left, passing a literal `false`. It never was an answer from stdin; it's what to assume when nobody can be asked, so it's now `defaultWhenNonInteractive`. - the non-interactive expiry path printed the warning paragraph and then threw a second message repeating the expiry and the --token hint. It's one message now, since there's no prompt to follow it up with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca
apify-factory
left a comment
Contributor
There was a problem hiding this comment.
Looks good, nothing blocking.
🏭 Factory review — ℹ️ not auto-approved
This change was reviewed but cannot be auto-approved: a gate failed, so the review cannot end in an approval.
| check | result |
|---|---|
| Static gates | ❌ 13/16 — pr-open-and-ready, max-files, max-lines |
| Review findings | ✅ none |
| Reviewer escalation | ❌ Changes how auth tokens are handled and adds a new non-interactive failure (exit code 5), and it changes --yes across seven destructive rm/delete commands so they no longer prompt on a TTY, which is a public CLI contract change beyond what the title says. |
Juror 1 — claude-sonnet-5-5, adversarial |
⏭️ skipped — a gate failed |
Why it was not auto-approved
Failed gates:
pr-open-and-ready: PR is a draftmax-files: 15 changed files, limit 10max-lines: 430 changed lines (+402/-28), limit 200
The comments above are the review itself; this section only explains the approval decision. Push a change and request @apify-factory again to re-run it, or ask a human to review.
Reviewed 91a1dde4b · workflow run
Keep both sides in `stdinCheckWrapper`: the `preAnswered` early return runs before `useStdin()`, then master's TTY-only gate decides the non-interactive path from `defaultWhenNonInteractive`. `hasData` is gone, so piped stdin no longer counts as an answer source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
l2ysho
marked this pull request as ready for review
October 29, 2026 15:08
DaveHanns
approved these changes
Oct 29, 2026
DaveHanns
left a comment
Contributor
There was a problem hiding this comment.
The idea makes sense. Code reviewed partially. The Apify factory review to be taken into account.
szaganek
reviewed
Oct 30, 2026
| token: Flags.string({ | ||
| char: 't', | ||
| description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'.`, | ||
| description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'. A token from an OAuth 'apify login' expires after about an hour, so pass a long-lived one from Apify Console.`, |
Contributor
There was a problem hiding this comment.
Suggested change
| description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'. A token from an OAuth 'apify login' expires after about an hour, so pass a long-lived one from Apify Console.`, | |
| description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'. A token from an OAuth 'apify login' expires after about an hour, so pass a personal one from Apify Console.`, |
Long-lived sounds wonky
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
One item off the "Not in this PR" list of #1402:
apify mcp installcopies the resolved API token into the MCP client's config file. Nothing refreshes that copy, so since OAuth became theapify logindefault, the configured server stops working within the hour and the user has no idea why.What changed
When the resolved token belongs to an
oauth2profile with under a day of life left, the command now:--tokenwith a long-lived Console token. The link is account-scoped throughgetConsoleIntegrationsUrl(), so an organization login lands on its own/organization/<id>route;InvalidInputwith the same explanation when there's no terminal to ask on;--yes, warning first.--yesis handled before the prompt rather than throughpreAnswered, so the warning still prints.--token,APIFY_TOKEN, plain API-token logins and the tokenlesscodexclient all skip the check.Release note: the non-interactive abort and exit code 5 are new. Nothing released produces an
oauth2profile yet (#1402 landed after 1.10.0), and both scripted paths that exist today are immune —APIFY_TOKENresolves withsource: 'env'and no profile,--tokenskipsresolveAuthentirely — so this shouldn't break anyone, but it's a new failure mode.Two fixes it dragged in
--yeswas ignored on a TTY.stdinCheckWrapperonly read the fallback answer when stdin couldn't be prompted, so--yeswas dropped in a terminal, contradictingYesFlag's ownAnswer "yes" to all prompts.The overwrite prompt has always had this, but it only fires on re-install; the new prompt fires on every OAuth-login install, which made it a hang rather than a wart. Added apreAnsweredoption and moved every--yescall site onto it:actors rm,datasets rm,runs rm,key-value-stores rm,key-value-stores delete-value,builds rm,builds remove-tag, bothmcp installprompts. That leftprovidedConfirmFromStdinwith one caller passing a literalfalse, which was never an answer from stdin, so it's nowdefaultWhenNonInteractive.Splitting the seven
rmcommands into their own PR would leave two contradictory--yessemantics across a release, which is why they're here.The prompt could hang outside CI.
useStdinsetshasDatafor any stdin that isn't a terminal,/dev/nulland a closed descriptor included, so the hook's non-interactive branch never fired outside CI. A Docker build or cron job would have hung where it used to succeed. The expiry confirmation checks for a terminal itself now.Follow-ups
useStdin.hasDatais the root of that hang, and it's latent in every otheruseYesNoConfirmcaller. The!stdinStream.isTTY ||disjunct means!isTTYimplieshasData, so!isTTY && !hasDatais only reachable afterreadStdin()drains stdin. Dropping that disjunct fixes it for everyone;echo y | apify actors rm Xkeeps working and< /dev/nullfails with the hint instead of hanging. Two consumers. Deliberately not done here —create.ts:287already carries the same command-local workaround, so this PR is the third instance and the convention is wrong.auth/token.ts,run.ts, andmcp/install.ts, each with its own wording. The differing thresholds are correct (durability differs), the differing wording isn't. AddingexpiresAttoResolvedAuthand exportingdescribeExpiry()collapses all three.run.tsstill has the "0 minutes" bug this PR fixed in its own copy:Math.max(0, Math.round(remainingMs / 60_000))reports a token with 30 seconds left as "expires in about 0 minutes". Folds into the above for free.apify auth liststill doesn't show how an account logged in or when its session expires (also from feat: log in through OAuth 2.0 device code by default #1402).Numbers
No dependency changes, so no install-size impact. 16 new tests:
test/local/commands/mcp/install.test.ts, a newinstall-non-tty.test.tsthat leavesisCIfalse (the case that hung), andtest/local/lib/hooks/stdin-check-wrapper.test.ts.docs/reference.mdregenerated.test:apinot run, nothing here touches the Apify API. The 17 failures intest:localare identical onmaster(python/git/npm fixtures).Generated by Claude Code