feat: one token resolution order across every command - #1431
Draft
l2ysho wants to merge 4 commits into
Draft
Conversation
`getLoggedClient()` both resolved a token and persisted it. Any command given a token other than the stored one overwrote the stored login with it, and rewrote `username`/`id` to match, so a transient token replaced the saved account. Splits the pair: - `resolveAuth()` in the new `src/lib/auth.ts` reads only. Order is `--token` flag, then stored login. - `loginWithToken()` authenticates and saves. Only `apify login` calls it, and it now replaces the stored account instead of merging into it, so fields the new account lacks cannot linger from the old one. - `getLoggedClient()` verifies the token and returns a client. It writes nothing. Commands that read `username`/`id` to address API resources now go through `getCurrentUserInfo()`, which returns auth.json for a stored token and the account behind a one-off token otherwise. They previously relied on `getLoggedClient()` refreshing auth.json, which is the write that just went away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
l2ysho
force-pushed
the
claude/token-resolution-1418
branch
from
September 11, 2026 10:15
9bde8e0 to
da465e5
Compare
Three resolvers decided which token a command used, and they disagreed:
`resolveToken` ignored `APIFY_TOKEN`, `mcp install` honored it, the `actor`
entrypoint required it. The same shell gave three answers depending on the
command.
There is now one order, the one `mcp install` and the `actor` entrypoint already
used:
token the command was given -> APIFY_TOKEN -> stored login
`APIFY_TOKEN` is the way to run a command as a different account. Only `login`
and `mcp install` take a token of their own, through `--token`, because both
write it somewhere rather than just authenticating with it. Adding a second,
flag-shaped way to do what the env var already does would leave two mechanisms
for one thing.
- `getApifyTokenFromEnvOrAuthFile` and `resolveApifyToken` are gone, folded into
`resolveAuth`. No special case is needed for the `actor` entrypoint: inside a
platform run there is no stored login, so `APIFY_TOKEN` wins on its own.
- `apify auth token` prints the token that would be used, not the stored one. It
only looked right before because reads overwrote the stored token.
- `apify info` names the source, so an `APIFY_TOKEN` that overrides a stored
login is visible rather than silent.
- `apify run` passes the resolved token to the child instead of the stored one,
and no longer overwrites an inherited `APIFY_TOKEN`.
- A rejected token names its source. A 401 or 403 says the token was rejected;
any other failure says the API request failed, so an unreachable API is not
reported as a bad token.
- `apify login` ignores `APIFY_TOKEN`. Logging in stays explicit.
Closes #720
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
l2ysho
force-pushed
the
claude/token-resolution-1418
branch
2 times, most recently
from
September 11, 2026 12:39
4f8bf65 to
09d33ff
Compare
Now that `APIFY_TOKEN` beats the stored login, the override is silent, and a variable that was never really set is indistinguishable from a real token. - A placeholder value falls back to the stored login instead of failing against a token that does not exist. `APIFY_TOKEN=$UNSET_VAR` leaves an empty string and templating an absent value writes the literal "undefined"; both are now ignored, the first silently and the second with `APIFY_TOKEN is invalid: "undefined".` The value is also trimmed. - When `APIFY_TOKEN` overrides a stored login, the CLI says `Using the API token from APIFY_TOKEN.` once per command. It stays quiet when the env var is the only credential, which is the case in CI and inside a platform run, so this adds no noise there. - `apify login` warns that `APIFY_TOKEN` will keep winning. Without it the success message is a lie: commands would run as whoever owns the env token, not as the account just logged in. - `apify logout` warns that `APIFY_TOKEN` still authenticates. Logging out does not unset an env var. Every notice goes to stderr, so `apify auth token` stays pipeable and `--json` output stays parseable. A token the API rejects with 401 or 403 does not fall back. Running `APIFY_TOKEN=$CUSTOMER apify push` with an expired customer token would then push into your own account, which is the silent wrong-account write this branch removes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
l2ysho
force-pushed
the
claude/token-resolution-1418
branch
from
September 11, 2026 13:43
09d33ff to
1e7e56c
Compare
Review of the two commits above found `apify run` had gained a blocking, retried API call. `getCurrentUserInfo()` runs before the child Actor is spawned, and with `APIFY_TOKEN` set it reaches the API. apify-client defaults to 8 retries at 360s each, and `_get` passes no per-request timeout, so an unreachable API stalled the run: 208s measured against a refused connection, and roughly an hour against one that accepts the socket then hangs. The `.catch()` made that failure non-fatal but not fast. - The cold-path lookup gets `maxRetries: 1, timeoutSecs: 10`. Measured 208s to 3s. The child keeps the right token and only loses `APIFY_USER_ID` and the proxy password, which is the correct degradation. - `getCurrentUserInfo()` reads the cache before falling back to auth.json. `getLoggedClient()` already fetched fresh account data under the same token, and the stored path threw it away. This closes the staleness gap the previous commit accepted: a username change no longer breaks name resolution until the next login. The file fallback stays for a cold cache, so `apify run` offline on a stored login still works. - `actors pull` resolves the client before the account, like every other command. It was first, so a rejected token surfaced a raw `ApifyApiError` instead of the message naming the source. - `actors search` is deliberately anonymous. It resolved auth only to delete the token, which made it announce "Using the API token from APIFY_TOKEN." for a request that carries none, and cost a keyring read. It now takes `getAnonymousApifyClientOptions()`, which never resolves. - `describeAuthFailure` narrows on `instanceof ApifyApiError` instead of duck-typing `statusCode`. The tests built the error by hand, so nothing would have noticed if that field moved and every rejected token started reporting "the API request failed" — the misdiagnosis the branch exists to prevent. - `ensureMigrated()` runs again regardless of token source. It was reachable only on the stored fallthrough, so anyone always exporting `APIFY_TOKEN` would never migrate a legacy plaintext auth.json into the keyring. - Drops `delete fileContents.token`: `User` carries no token, and the file is replaced rather than merged. Adds the `getCurrentUserInfo()` tests that were missing: the stored path, the env path, the cache hit, and the rename it now picks up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Note
TL;DR — Three resolvers decided which token a command used and they disagreed, and
getLoggedClient()persisted whatever it resolved, so a one-off token overwrote the stored login. Now there is one order —APIFY_TOKEN→ stored — onlyapify loginwrites credentials, and the CLI says out loud when the env var is in charge.Stacked on #1417 (Stage-0). Base branch is
claude/github-issue-1387-d39d52, notmaster.What changed
1.
refactor: stop credential reads from writing credentials— the write guard.src/lib/auth.ts.resolveAuth()reads only;loginWithToken()authenticates and saves.apify loginis the only caller ofloginWithToken(). It now replaces the stored account instead of merging into it, so fields the new account lacks cannot linger from the old one.getLoggedClient()verifies the token and returns a client. It writes nothing, and is no longer exported —getLoggedClientOrThrow()is the only entry.username/idto address API resources moved fromgetLocalUserInfo()to a newgetCurrentUserInfo(). They relied ongetLoggedClient()refreshing auth.json, which is the write that went away.2.
feat: one token resolution order across every command— precedence.APIFY_TOKEN→ stored login, everywhere. This is the ordermcp installand theactorentrypoint already used.APIFY_TOKENis how you run a command as a different account.getApifyTokenFromEnvOrAuthFile(lib/actor.ts) andresolveApifyToken(mcp/install.ts) are gone. No special case for theactorentrypoint: inside a platform run there is no stored login, soAPIFY_TOKENwins on its own.apify auth tokenprints the token that would be used. It only looked right before because reads overwrote the stored token.apify infonames the source, so anAPIFY_TOKENthat overrides a stored login is visible.apify runpasses the resolved token to the child and no longer overwrites an inheritedAPIFY_TOKEN.apify loginignoresAPIFY_TOKEN.useAuthSetup,credentials.test.tsand the e2erun-clihelper pinAPIFY_TOKENempty. Without that, a token in the developer's shell decides which account the tests run as.3.
feat: say when APIFY_TOKEN overrides a login or is ignored— the notices.Warning: APIFY_TOKEN is invalid: "undefined".APIFY_TOKEN=$UNSET_VARleaves an empty string and templating an absent value writes the literalundefined. The value is trimmed.APIFY_TOKENoverrides a stored login,Warning: Using the API token from APIFY_TOKEN.— once per command. Silent when the env var is the only credential, so CI and platform runs get no noise.apify loginwarns thatAPIFY_TOKENkeeps winning — otherwise its success message is a lie.apify logoutwarns thatAPIFY_TOKENstill authenticates.auth tokenstays pipeable and--jsonstays parseable.APIFY_TOKEN=$CUSTOMER apify pushwith an expired customer token would otherwise push into your own account — the silent wrong-account write this branch removes.Closes #1418. Closes #720.
Deviation from the issue: no global
--tokenIssue #1418 scoped
--tokenas a global flag inbaseOptions. I built that, then dropped it.--tokenstays where it already was, onloginandmcp install— the two commands that write the token somewhere rather than just authenticating with it. Everywhere else,APIFY_TOKENalready does the job, so a global flag would be a second mechanism for one thing. It also parsed on ~40 commands that never authenticate, whereapify auth logout --token Xreads as "log out that token", and it added 445 lines todocs/reference.mddocumenting a no-op. The resolver still takes an explicit token, so re-adding the flag later is a one-line registration.Worth noting for anyone on Windows:
APIFY_TOKEN=x apify call ...needs$env:APIFY_TOKENin PowerShell orsetin cmd. If that turns out to be a real complaint, the flag comes back.The issue body should be updated to match.
Behavior, before and after
Run against two real accounts, account-a (logged in) and account-b (
$B), with 1.10.0 side by side.APIFY_TOKENno longer ignoredAPIFY_TOKEN=$B apify actors lsAPIFY_TOKEN=$B apify actors ls(logged out)Error: You are not logged inAPIFY_TOKEN=$B apify infousername: account-ausername: account-b+token source: APIFY_TOKEN environment variableAPIFY_TOKEN=$B apify auth tokenAPIFY_TOKEN=$B apify actors info <name>account-a/<name>→ not foundaccount-b/<name>→ foundThe env var was ignored even when it was the only credential available.
apify runhanded the child the wrong identityAPIFY_TOKEN=$B apify runlocalEnvVarsoverwrote the inheritedAPIFY_TOKEN. This is the "Insufficient permissions for the Actor run" report, and why it started working afterapify logout.Reads overwrote the stored login
APIFY_TOKEN=$B apify actor charge result-itemauth.jsonnow holds account-b. UnsettingAPIFY_TOKENdoes not restore account-aauth.jsonuntouchedThe write landed before the run-id check, so it reproduced outside a real Actor run.
Junk values and dead tokens
APIFY_TOKEN=undefined apify actors lsWarning: APIFY_TOKEN is invalid: "undefined".then uses stored loginAPIFY_TOKEN= apify actors lsAPIFY_TOKEN=" $B " apify actors lsAPIFY_TOKEN=<revoked> apify actors lsError: The API token in APIFY_TOKEN was rejected. Unset it to use your stored login instead.Silence that misled
APIFY_TOKEN=$B apify login --token $ASuccess: logged in as account-a— while every later command ran as account-bWarning: APIFY_TOKEN is set, so other commands keep using that token instead of this login.APIFY_TOKEN=$B apify logoutSuccess: logged out— while commands stayed authenticatedWarning: APIFY_TOKEN is still set, so commands stay authenticated with that token.apify login --token <bad>apify login --token $BAD && apify pushran onVerification
pnpm run test:local— 616 passed, 4 skipped (62 files).APIFY_TOKEN=<bogus> pnpm run test:local— 616 passed, 4 skipped. Catches tests that fall through to the real API.pnpm run lint,pnpm run format,pnpm run build— clean.pnpm run update-docs—docs/reference.mdregenerated and committed (11 lines: three description changes).pnpm run test:apinot run — no token in this environment.Requires Node ≥22 to run pnpm 11 locally.
Upgrade note
A stale
APIFY_TOKENin a shell profile or CI job now decides which account commands run as, where the CLI used to quietly ignore it. A junk value falls back with a message; a real but revoked token fails with one. Worth a release note.Left out, deliberately
🤖 Generated with Claude Code