Skip to content

feat(step): store and refresh an account session beside the API key - #261

Merged
liuedcson merged 6 commits into
mainfrom
feat/cli-login-token-forwarding
Oct 19, 2026
Merged

liuedcson merged 6 commits into
mainfrom
feat/cli-login-token-forwarding

Conversation

@liuedcson

Copy link
Copy Markdown
Collaborator

背景

step login 只兑换账号的 API Key。API Key 足够发模型请求,但任何需要知道"用户是谁"的能力都没有依据——Web 端和桌面端即将到来,它们需要用户身份。

本 PR 让登录流程额外兑换一份账号会话:CLI 存本地并按需刷新,供需要身份的宿主持有。

设计要点

API Key 与账号会话分离。 API Key 仍是模型请求的唯一凭据,位置不变。账号会话是独立文件、独立生命周期——两者失效方式不同,会话失效绝不能牵连 Key。

按需刷新,不设守护进程。 getStepLoginToken() 是唯一入口:临近过期才刷新,跨进程锁内做 read-modify-write。一次性进程(print 模式)不会触发任何网络调用。

失效判定只认一个信号。 服务端对无法兑换的会话返回 200 + 匿名态而非报错,因此只有"明确报告匿名态"才判定会话已死;其余(任何 HTTP 失败、异常响应体)一律按临时失败处理——凭猜测删除长期凭据的代价是用户被迫重新登录,而保留它最多只是一次重试。

并发安全。 刷新的网络调用在锁外,锁只保护 read-modify-write,提交前重读并比对 refresh token。登出或换账号登录落在刷新期间时,旧结果被丢弃而不是写回。

完全向后兼容。 后端未下发会话字段时只交付 API Key,行为与此前一致;从未重新登录的老用户没有任何变化。

改动

  • packages/providers/src/step-provider/login-client.ts — 从登录轮询解析可选的账号会话
  • packages/providers/src/step-provider/login-refresh.ts(新增)— 刷新调用:用两个已存 token 重建 Oasis cookie,从响应体读取新 token 对
  • packages/coding-agent/src/step/login-state.ts(新增)— 存储、刷新策略、getStepLoginToken() 入口
  • packages/coding-agent/src/step/login-flow.ts — 登录成功后持久化会话
  • packages/coding-agent/src/step/auth.ts — 登出时一并清除会话
  • packages/coding-agent/src/step/login-status.ts、apps/cli/src/main.ts — step login status 展示会话状态
  • 测试 — cookie 拼接与顺序、匿名态、各类瞬时失败、token 不进 URL、明文 HTTP 拒绝、畸形会话拒绝、登出/换账号并发、端到端落盘

验证

  • pnpm run check 全绿(类型检查 + Biome + 13 项边界检查)
  • 相关测试 129 项全部通过
  • 真实环境端到端通过:登录 → 落盘 → step login status → 刷新 → 账号信息查询,uid 与 token 内的账号标识一致

`step login` redeemed only the account's API key, which is enough for model
requests but not for anything that needs to know who the user is. The web and
desktop clients need that, so the login flow now also redeems an account
session, keeps it on disk, and renews it on demand.

The API key stays the only credential for model requests and lives where it
always did. The session is a separate file with a separate lifetime, because
the two fail differently: a dead session must never cost the user their key.

Providers gains the protocol half — parsing the optional session out of the
login poll, and the refresh call that exchanges it for a new one. The refresh
rebuilds the Oasis cookie from the two stored tokens, because a CLI has no
cookie jar, and reads the new pair from the response body.

Coding-agent gains the product half — storage, the refresh policy, and the
entry point hosts will call. Refreshing moves the network call outside the
cross-process lock and re-reads under it before committing, so a sign-out or a
sign-in with another account that lands mid-refresh discards the stale result
instead of resurrecting a session the user already replaced.

Only an answer that positively reports an anonymous session counts as dead.
Every HTTP failure and every unexpected body is treated as transient, since
deleting a long-lived credential on a guess costs a full re-login while keeping
it costs one retried request.

Backends that predate the session field deliver the API key alone, which is the
behaviour the CLI has always had; nothing changes for a user who never signs in
again.
Review of the previous commit found the session could still be written or
removed concurrently with a refresh, and that it could reach auth.json after
all.

Every writer and remover of the session file now takes the same cross-process
lock, so a sign-in, a sign-out and an in-flight refresh cannot interleave. The
lock only guards the read-modify-write; the network call stays outside it, so a
slow refresh cannot block a sign-out.

Deleting the session no longer swallows errors. Reporting a successful sign-out
while a long-lived refresh token is still on disk is worse than failing, so only
"already gone" is ignored and anything else surfaces.

The login flow rides the account session along on the credential it returns.
Credentials are persisted verbatim, so that field is stripped in the provider
adapter before it reaches auth.json — a copy there would never be refreshed and
would outlive the session it belongs to.

A previous session is now dropped when a new sign-in is persisted. Signing in
again as another account, or with a profile that carries no session, would
otherwise leave the old account's session behind for a host to read as the
current identity.

The session path follows the credential path when only that is configured, so a
host that isolates auth.json isolates the session too instead of sharing one
file between instances.

A session that arrives unusable is reported and dropped instead of failing the
poll. The API key is already in hand at that point, and a session problem must
never cost the user their key; the reason is surfaced so contract drift stays
visible.

The refresh deadline is recomputed from the token the server returned rather
than inherited from the one it replaced, which matters as soon as refresh tokens
start rotating. Both warnings are shown when a plan check and a failed session
save happen together, instead of one hiding the other. An already-aborted signal
now cancels the refresh instead of letting it run to its timeout.
When the server answered a refresh with an anonymous session but the discard
could not proceed — because another process held the lock — the caller fell back
to the stored token and reported it as ready. That hands out a credential the
server has just rejected, and the caller's request fails instead of the caller
learning the session is gone.

The two outcomes are now separate: a session that is positively dead reports
expired whether or not it could be removed, while a session that was *replaced*
during the request still reports the replacement.

The refresh deadline also gets an hour of clock slack before it retires a
session. That deadline is read from a server-issued token, so a machine whose
clock runs fast would otherwise discard a session the server still honours;
waiting costs one refresh the server answers honestly.
Adding the lock reintroduced a bare catch that treated every acquisition failure
as "another process won". That is only true for lock contention. A read-only
directory or a permission problem failed the same way and silently did nothing,
so a sign-in could report success while the previous account's session was still
on disk, and a sign-out could report a removal that never happened.

Only contention is tolerated now; anything else surfaces. Both operations return
whether they took effect so callers can report it honestly, and
`logoutStepCredentials` derives its result from that instead of from whether the
file existed beforehand.

A failed session update during sign-in is a warning rather than a failed login.
The API key is already written by then, and reporting "Could not save
credentials" for a session problem misrepresents what happened and skips the
endpoint sync.

The refresh deadline is decoded once instead of twice.
Two of the previous fixes traded one silent path for another.

Discarding a session could itself fail — a read-only directory, a permission
problem — and that error escaped the code paths that were supposed to report an
explicit outcome. In one it reached the caller's catch, which fell back to
handing out a token the server had just rejected; in the other it turned a
definitive "expired" into a rejection. Discarding is now quiet: a session that
cannot be removed is still a session the server refused.

Sign-out cleared the session before removing the API key, so a failure there
aborted the whole logout and left the key in place. It is cleared last, its
failure cannot stop the key, and whether anything survived is reported — which
the two call sites now act on instead of ignoring, since reporting a session
still on disk is the difference between a user knowing and not.

A written session has already replaced whatever preceded it, so an unsuccessful
clear alongside a successful write is no longer reported as a stale session. The
reason the server rejected a session is also no longer dropped when another
warning is present.
…it runs on

Two of the previous round's fixes reported the session in the wrong place.

`step logout` decided whether to print "Signed out" from the session result
instead of the credential result, so a user who had never signed in saw a
sign-out confirmation and the credential path printed twice. The credential
result decides that message again; a session that survived is a separate note.

The TUI path wrote that note to stderr, which lands underneath the running
terminal and is then cleared by shutdown. The logout result now carries whether
the session was removed, and the host shows it as a warning like every other
message it presents.

The sign-in warning conflated two different failures. A session that could not
be written and a previous session that could not be cleared are separate
problems with different consequences, and reporting one as the other sent the
user looking for the wrong thing. They are reported independently now, and the
reason the server rejected a session survives alongside either of them.
@liuedcson
liuedcson merged commit 7b7c2d1 into main Oct 19, 2026
2 checks passed
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.

1 participant