Skip to content

feat: warn before apify mcp install embeds an expiring OAuth token - #1529

Merged
l2ysho merged 8 commits into
masterfrom
claude/keen-brown-qd7jqh
Oct 29, 2026
Merged

l2ysho merged 8 commits into
masterfrom
claude/keen-brown-qd7jqh

Conversation

@l2ysho

@l2ysho l2ysho commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

One item off the "Not in this PR" list of #1402:

apify mcp install embeds the resolved token in the client config; with an OAuth login that token dies after an hour, so it should warn or ask for --token.

apify mcp install copies the resolved API token into the MCP client's config file. Nothing refreshes that copy, so since OAuth became the apify login default, the configured server stops working within the hour and the user has no idea why.

What changed

When the resolved token belongs to an oauth2 profile with under a day of life left, the command now:

  • warns with the remaining lifetime and points at --token with a long-lived Console token. The link is account-scoped through getConsoleIntegrationsUrl(), so an organization login lands on its own /organization/<id> route;
  • asks before writing in a terminal, default No;
  • exits InvalidInput with the same explanation when there's no terminal to ask on;
  • writes without asking under --yes, warning first. --yes is handled before the prompt rather than through preAnswered, so the warning still prints.

--token, APIFY_TOKEN, plain API-token logins and the tokenless codex client all skip the check.

Release note: the non-interactive abort and exit code 5 are new. Nothing released produces an oauth2 profile yet (#1402 landed after 1.10.0), and both scripted paths that exist today are immune — APIFY_TOKEN resolves with source: 'env' and no profile, --token skips resolveAuth entirely — so this shouldn't break anyone, but it's a new failure mode.

Two fixes it dragged in

--yes was ignored on a TTY. stdinCheckWrapper only read the fallback answer when stdin couldn't be prompted, so --yes was dropped in a terminal, contradicting YesFlag's own Answer "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 a preAnswered option and moved 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, both mcp install prompts. That left providedConfirmFromStdin with one caller passing a literal false, which was never an answer from stdin, so it's now defaultWhenNonInteractive.

Splitting the seven rm commands into their own PR would leave two contradictory --yes semantics across a release, which is why they're here.

The prompt could hang outside CI. useStdin sets hasData for any stdin that isn't a terminal, /dev/null and 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.hasData is the root of that hang, and it's latent in every other useYesNoConfirm caller. The !stdinStream.isTTY || disjunct means !isTTY implies hasData, so !isTTY && !hasData is only reachable after readStdin() drains stdin. Dropping that disjunct fixes it for everyone; echo y | apify actors rm X keeps working and < /dev/null fails with the hint instead of hanging. Two consumers. Deliberately not done here — create.ts:287 already carries the same command-local workaround, so this PR is the third instance and the convention is wrong.
  • The expiry derivation now exists three times: auth/token.ts, run.ts, and mcp/install.ts, each with its own wording. The differing thresholds are correct (durability differs), the differing wording isn't. Adding expiresAt to ResolvedAuth and exporting describeExpiry() collapses all three.
  • run.ts still 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 list still 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 new install-non-tty.test.ts that leaves isCI false (the case that hung), and test/local/lib/hooks/stdin-check-wrapper.test.ts. docs/reference.md regenerated. test:api not run, nothing here touches the Apify API. The 17 failures in test:local are identical on master (python/git/npm fixtures).


Generated by Claude Code

`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
@l2ysho
l2ysho requested a review from apify-factory October 29, 2026 11:49

@apify-factory apify-factory 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.

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 draft
  • max-files: 15 changed files, limit 10
  • max-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
l2ysho marked this pull request as ready for review October 29, 2026 15:08
@apify-service-account apify-service-account added the tested Temporary label used only programatically for some analytics. label Oct 29, 2026
@apify-service-account apify-service-account added the t-builders Issues owned by the Builders team. label Oct 29, 2026
@l2ysho l2ysho added the adhoc Ad-hoc unplanned task added during the sprint. label Oct 29, 2026

@DaveHanns DaveHanns 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.

The idea makes sense. Code reviewed partially. The Apify factory review to be taken into account.

@l2ysho
l2ysho merged commit 556263b into master Oct 29, 2026
31 of 34 checks passed
@l2ysho
l2ysho deleted the claude/keen-brown-qd7jqh branch October 29, 2026 15:25
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.`,

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.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adhoc Ad-hoc unplanned task added during the sprint. t-builders Issues owned by the Builders team. tested Temporary label used only programatically for some analytics.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants