Skip to content

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

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

l2ysho wants to merge 8 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 — TBD

Stacked on #1417 (Stage-0). Base branch is claude/github-issue-1387-d39d52, not master.

Checklist

  • Check skills/apify/SKILL.md after the implementation lands

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

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

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_IS_AT_HOME=1 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

A misconfigured APIFY_TOKEN fails instead of guessing

Command Before Now
APIFY_TOKEN=undefined apify actors ls silently uses the stored login Error: APIFY_TOKEN is set to "undefined", which is not an API token. exit 5
APIFY_TOKEN= apify actors ls silently uses the stored login unchanged — an unset variable is not worth a message
APIFY_TOKEN=<revoked> apify actors ls silently succeeds as account-a Error: The API token in APIFY_TOKEN was rejected.
APIFY_TOKEN=$B apify login --token $A Success: logged in as account-a, while every later command ran as account-b Error: exit 5. Same token stays silent — that is the CI idiom
APIFY_TOKEN=undefined apify login --token $A success, then every later command failed warns and proceeds: login is the only way to fix the shell
apify login --token <bad> exit 0login && push ran on exit 1

What changed

  • One resolveAuth() in the new src/lib/auth.ts. getApifyTokenFromEnvOrAuthFile and resolveApifyToken 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.
  • Resolve is split from persist. loginWithToken() is the only credential writer, and it replaces the stored account rather than merging into it — including the keyring proxy password, which used to survive a re-login and leak the wrong account's credential into apify run's child.
  • resolveAuth is single-flighted. It ran 2–4× per command, each an uncached OS keyring read.
  • One reader for APIFY_TOKEN, returning unset / invalid / token, so login and logout see a value every other command rejects.
  • Commands that address resources by username moved to getCurrentUserInfo(), which reads the resolved account rather than auth.json.
  • --token stays on login and mcp install only. No global flag — APIFY_TOKEN already does that job, and two mechanisms for one thing is worse.

Closes #1418. Closes #720.

Verification

  • pnpm run test:local — 621 passed, 4 skipped.
  • Same with APIFY_TOKEN set to a bogus value, which is deliberate: a host token must not decide which account the tests run as.
  • Lint, format, build, update-docs clean.
  • pnpm run test:api not run — no token in this environment.
  • The before/after table above was run by hand against two real accounts.

Upgrade note

A stale APIFY_TOKEN in a shell profile or CI job now decides which account commands run as. If it holds a placeholder, commands fail rather than falling back. Worth a release note.

🤖 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>
l2ysho and others added 4 commits September 16, 2026 13:05
Review feedback: a warning is easily lost in noisy CI and Actor logs, and
both of these almost always mean a broken script or a misconfigured
workflow, so an error is what the caller can act on.

- A placeholder `APIFY_TOKEN` fails with exit code `InvalidInput` rather
  than quietly falling back to the stored login. This supersedes the
  fallback added two commits ago: a placeholder and a revoked token are
  the same failure, the operator meant to act as one account and the CLI
  was about to act as another, and only one of them stopped.
- Blank and whitespace-only values still fall back without a word. They
  mean the variable was never set, not that it was set wrong.
- `apify login` fails when `APIFY_TOKEN` holds a different token, because
  the success line would name an account no later command uses. The same
  token is the CI idiom of setting `APIFY_TOKEN` and also running
  `apify login --token $APIFY_TOKEN`, where nothing is wrong, so that
  stays silent. The guard sits at the top of `run()`: the browser flow
  only learns its token from the Console callback, and failing there
  would mean failing after the user has already authorized.
- `apify logout` keeps its warning. It did what it says.

`resolveAuth` keeps its early returns, so `--token` still short-circuits
ahead of the env var and a broken `APIFY_TOKEN` cannot lock you out of
logging in to fix it. Both escape hatches have tests.

Splits `getApifyClientOptions` in two. Four call sites resolved a token
and then handed it back to the resolver, which returned it unchanged as
`source: 'flag'` and discarded the source. They now take
`getApifyClientOptionsForToken`, which resolves nothing. The old form had
no remaining caller and is gone, along with three tests of it that
`auth.test.ts` already covers; the fourth moves to `resolveAuth`, being
the only check that resolving still triggers the keyring migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`loginWithToken` stored a proxy password when the account had one and did
nothing when it did not, so logging in as an account without one left the
previous account's in the OS keyring. `getLocalUserInfo` re-attaches it,
and `apify run` exports it to the child as `APIFY_PROXY_PASSWORD` — the
Actor ran with the wrong account's proxy credential.

auth.json is replaced wholesale on login, so the metadata could not
linger this way; the keyring outlives that rewrite and needed clearing of
its own. Adds `deleteProxyPassword()` for it, backend-aware so the file
path stays correct too, called only when there is nothing to store —
deleting on every login would cost a keyring write, which is what
`skipIfUnchanged` exists to avoid.

Pre-existing, identical on the base branch, but the write guard's own
comment claims fields the new account lacks cannot linger. Now that holds
for both backends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Findings from a reuse/simplification/efficiency/altitude pass.

`getEnvToken()` mapped both "unset" and "placeholder" to undefined, so
`login` and `logout` could not see a value every other command rejects.
`APIFY_TOKEN=undefined apify login --token <real>` therefore reported
success, and the next command hard-failed on the same variable.
`readEnvToken()` returns unset / invalid / token instead, so all three
callers branch on the same cases. That also retires the raw second read
of `process.env` the resolver used to recover the distinction it had just
discarded.

`login` warns rather than aborting on an invalid value: every other
command fails, so it is the only way back, and refusing it would lock
someone out of the fix.

`resolveAuth` is now single-flighted like `getBackend`. It ran two to
four times per command, each re-reading an uncached OS keyring entry —
measured at 3 reads for `apify info`, 4 for `apify run`. One resolution
per process also removes the once-per-notice boolean and its test-only
reset, which existed only to make repeated resolution look single, and
takes the `existsSync` behind it off the hot path.

- `describeAuthFailure` takes the `ResolvedAuth` its caller already held
  instead of resolving again, so it is synchronous and the message is
  worded from the resolution that actually failed.
- `resolveAuth` drops its token parameter. `mcp install` was the only
  caller passing one, and nothing read the resulting `'flag'` source, so
  the union member, its label and its switch branch were unreachable.
  That precedence now sits at the one call site that wants it.
- `getApifyStorageClient` routes through `describeAuthFailure` rather
  than carrying a fourth wording for "no token".
- The three test-only cache resets become one `resetAuthCaches()`, called
  at the three hook sites instead of nine calls.
- `run.ts` drops a `{} as AuthJSON` cast that asserted a shape the value
  did not have; every field is optional, so the catch is typed instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `stripProxyPassword` in `credentials.ts` replaces three copies of "drop
  `proxy.password`, drop `proxy` when that was all it held" — two added by
  this branch, one pre-existing in `ensureMigrated`.
- `loginWithToken` returns the account it fetched, so `login` stops
  re-reading auth.json for a username it was just handed. That also drops
  its exposure to `getLocalUserInfo`'s stale-credentials throw on a path
  that had just succeeded.
- `auth token` and `info` lose `if (auth)` guards that could not be false:
  the `getLoggedClientOrThrow()` above each proves a token resolved. `info`
  no longer builds an optional-valued map and loops its keys to print three
  fixed rows.
- The fake `apify-client` lived verbatim in both auth test files and had
  already drifted by a field. It moves to
  `test/__setup__/apify-client-mock.ts`, next to `keyring-mock.ts`, which
  is the pattern this repo already uses for a shared module mock.
- The single-assertion `resolveAuth()` describe left in `credentials.test.ts`
  folds into the `ensureMigrated()` suite, where the behavior it checks
  belongs.

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