Skip to content

feat: one token resolution order across every command - #1431

Draft
l2ysho wants to merge 4 commits into
claude/github-issue-1387-d39d52from
claude/token-resolution-1418
Draft

feat: one token resolution order across every command#1431
l2ysho wants to merge 4 commits into
claude/github-issue-1387-d39d52from
claude/token-resolution-1418

Conversation

@l2ysho

@l2ysho l2ysho commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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 — only apify login writes 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, not master.

What changed

1. refactor: stop credential reads from writing credentials — the write guard.

  • New src/lib/auth.ts. resolveAuth() reads only; loginWithToken() authenticates and saves.
  • apify login is the only caller of loginWithToken(). 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.
  • The 15 call sites that read username/id to address API resources moved from getLocalUserInfo() to a new getCurrentUserInfo(). They relied on getLoggedClient() 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 order mcp install and the actor entrypoint already used. APIFY_TOKEN is how you run a command as a different account.
  • getApifyTokenFromEnvOrAuthFile (lib/actor.ts) and resolveApifyToken (mcp/install.ts) are gone. No special case 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. 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.
  • apify run passes the resolved token to the child and no longer overwrites an inherited APIFY_TOKEN.
  • A rejected token names its source. A 401/403 says the token was rejected; anything else says the API request failed, so an unreachable API is not reported as a bad token.
  • apify login ignores APIFY_TOKEN.
  • useAuthSetup, credentials.test.ts and the e2e run-cli helper pin APIFY_TOKEN empty. 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.

  • A placeholder value falls back to the stored login, with Warning: APIFY_TOKEN is invalid: "undefined". APIFY_TOKEN=$UNSET_VAR leaves an empty string and templating an absent value writes the literal undefined. The value is trimmed.
  • When APIFY_TOKEN overrides 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 login warns that APIFY_TOKEN keeps winning — otherwise its success message is a lie. apify logout warns that APIFY_TOKEN still authenticates.
  • All notices go to stderr, so auth token stays pipeable and --json stays parseable.
  • A token the API rejects with 401/403 does not fall back. APIFY_TOKEN=$CUSTOMER apify push with 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 --token

Issue #1418 scoped --token as a global flag in baseOptions. I built that, then dropped it.

--token stays where it already was, on login and mcp install — the two commands that write the token somewhere rather than just authenticating with it. Everywhere else, APIFY_TOKEN already does the job, so a global flag would be a second mechanism for one thing. It also parsed on ~40 commands that never authenticate, where apify auth logout --token X reads as "log out that token", and it added 445 lines to docs/reference.md documenting 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_TOKEN in PowerShell or set in 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_TOKEN no longer ignored

Command Before Now
APIFY_TOKEN=$B apify actors ls lists account-a's Actors lists account-b's Actors
APIFY_TOKEN=$B apify actors ls (logged out) Error: You are not logged in lists account-b's Actors
APIFY_TOKEN=$B apify info username: account-a username: account-b + token source: APIFY_TOKEN environment variable
APIFY_TOKEN=$B apify auth token prints account-a's token prints account-b's token
APIFY_TOKEN=$B apify actors info <name> resolves account-a/<name> → not found resolves account-b/<name> → found

The env var was ignored even when it was the only credential available.

apify run handed the child the wrong identity

Command Before Now
APIFY_TOKEN=$B apify run child gets account-a's token, user id and proxy password child gets account-b's

localEnvVars overwrote the inherited APIFY_TOKEN. This is the "Insufficient permissions for the Actor run" report, and why it started working after apify logout.

Reads overwrote the stored login

Command Before Now
APIFY_TOKEN=$B apify actor charge result-item errors on the missing run id — and auth.json now holds account-b. Unsetting APIFY_TOKEN does not restore account-a same error, auth.json untouched

The write landed before the run-id check, so it reproduced outside a real Actor run.

Junk values and dead tokens

Command Before Now
APIFY_TOKEN=undefined apify actors ls silently uses stored login Warning: APIFY_TOKEN is invalid: "undefined". then uses stored login
APIFY_TOKEN= apify actors ls silently uses stored login unchanged — an unset variable is not worth a message
APIFY_TOKEN=" $B " apify actors ls uses stored login trimmed, uses account-b
APIFY_TOKEN=<revoked> apify actors ls silently succeeds as account-a Error: The API token in APIFY_TOKEN was rejected. Unset it to use your stored login instead.

Silence that misled

Command Before Now
APIFY_TOKEN=$B apify login --token $A Success: logged in as account-a — while every later command ran as account-b same line + Warning: APIFY_TOKEN is set, so other commands keep using that token instead of this login.
APIFY_TOKEN=$B apify logout Success: logged out — while commands stayed authenticated same line + Warning: APIFY_TOKEN is still set, so commands stay authenticated with that token.
apify login --token <bad> exit 0apify login --token $BAD && apify push ran on exit 1

Verification

  • 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-docsdocs/reference.md regenerated and committed (11 lines: three description changes).
  • pnpm run test:api not run — no token in this environment.
  • 31 new tests: resolver precedence, the write guard on every read path, the four auth-failure messages, placeholder fallback, and every notice.

Requires Node ≥22 to run pnpm 11 locally.

Upgrade note

A stale APIFY_TOKEN in 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

`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
l2ysho force-pushed the claude/token-resolution-1418 branch from 9bde8e0 to da465e5 Compare September 11, 2026 10:15
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
l2ysho force-pushed the claude/token-resolution-1418 branch 2 times, most recently from 4f8bf65 to 09d33ff Compare September 11, 2026 12:39
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
l2ysho force-pushed the claude/token-resolution-1418 branch from 09d33ff to 1e7e56c Compare September 11, 2026 13:43
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>
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.

2 participants