Repository navigation
feat(step): store and refresh an account session beside the API key - #261
Merged
Merged
Conversation
`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.
liuedcson
requested review from
MelodyVAR,
Uking-xxx and
ZouR-Ma
as code owners
October 19, 2026 07:46
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.
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.
背景
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展示会话状态验证
pnpm run check全绿(类型检查 + Biome + 13 项边界检查)step login status→ 刷新 → 账号信息查询,uid 与 token 内的账号标识一致