Skip to content

xrxs-dsh-auth

Verified

xrxs-dsh-auth · v0.2.9 · MIT · Web UI

薪人薪事(Xinrenxinshi)授权插件(DeepSeek Harness 外部 bundle):机器认证授权(设备码流程)拿 token、access_token 生命周期管理,并向其他插件提供校验有效的 sessionId / ua / csrf。

Install

dsh plugin add xrxs-dsh-auth

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Published to npm without a public repository. Inspect the package contents before installing.

Tags

Creators

Readme

xrxs-dsh-auth

薪人薪事(Xinrenxinshi)授权插件,作为 DeepSeek Harness 外部组合包(bundle) 分发。它把「拿到一份可用的薪人薪事登录态」这件事,从每个业务插件各自拼凑,收敛成一个共享能力:ctx.xrxsAuth

本项目不是 harness 的源码,也不在 harness 仓库里开发。它以 npm 包的身份被 dsh plugin add 安装进某个 profile。

0.2.0 起改用机器认证授权(设备码流程)。 插件不再走浏览器重定向 + loopback 回调的 OAuth 授权码流程,改为 POST /authorize/oauth/device_authorization 申请设备码、由人在验证页确认、客户端按 interval 轮询 /authorize/oauth/device_token 换 token。被替换掉的整套 OAuth 实现(含 PKCE、回调监听、粘贴兜底、client_credentials 分支)完整归档在 bak/,不参与编译,可随时回退。

0.2.4 起支持按需授权:消费者调用 credentials() / session() 而手上没有可用授权时,本插件会自己把设备码流程发起起来。0.2.6 起那一次调用会等人确认(上限 grant.awaitMs,默认 120 秒),确认完把凭证在同一次调用里交回——NOT_AUTHORIZED 因此从「终局」变成「人还没确认完」。给消费者看的完整说明在 docs/consuming-xrxs-auth.md,本节下方「消费者契约」是摘要。

打包参数:这个产物面向哪个部署

一个 tarball 面向一个部署。environment 是构建期参数,写进产物:

XRXS_ENVIRONMENT=prod npm run build    # 或 npm run build --environment=prod
npm run pack:prod                      # = XRXS_ENVIRONMENT=prod npm pack
npm pack                               # 普通包,默认 environment=test

解析顺序:XRXS_ENVIRONMENT > npm_config_environment > --environment= > 默认 test。名字须匹配 ^[a-z][a-z0-9-]*$,且必须是内置表里的一个——不在表里的名字会在加载期直接抛错,因为打包打错字是构建事故,不该拖到运行时才发作。

内置两个环境(src/environment.tsENVIRONMENTS,每个环境是一组「域名 + clientId + session 端点」):

环境 标签 api域名 clientId 获取 session
prod 生产环境 https://api.xinrenxinshi.com appoH7i65pnX5M6FzT48MDFKcIUwjMm2 https://api.xinrenxinshi.com/cli/support/service/support/cli/ajax-get-cli-session-info
test 测试环境 http://47.93.57.14:9964 appmh1BFyxjQIA6BoTJRzXZuWaGod7Ol http://47.93.57.14:9964/cli/support/service/support/cli/ajax-get-cli-session-info

clientId 就是 appKey,不是密钥,所以可以随产物分发;appSecret 仍只从凭据存储解析,永远不进产物

薪人薪事系统域名system.url随环境打包,它默认 https://s.xinrenxinshi.com,需要改的话在 profile 配置里写。

环境层在配置层之前

环境声明了 api域名(字段名 baseURL)/ clientId / sessionEndpoint,profile 配置是这三项背后的一层——只在环境没声明时才轮到。两者不一致时环境赢,同时在 stderr 打一行「忽略了哪个字段、环境声明了什么」。这不是多话:「我改了地址但什么都没变」是那种要花一小时才找得出来的事,说一句就变成一行日志。

system.url(薪人薪事系统域名)是反过来的:它默认 https://s.xinrenxinshi.com,配置写了就用配置的。因为这只是给消费者的一个「跳转提示」,不参与任何请求寻址,所以允许在 profile 里自定义。

所以只挂载、不写任何配置,configured 就是 true:地址和身份都由产物给出。注意 configured: true 说的是能用,不是已授权——全新安装仍然要人授权一次。

切换环境

prod 产物在面板上多一行环境切换。切换 = 换域名 + 换 clientId,做三件事:

  1. 先把旧凭据还给旧服务端(对旧环境调一次 revoke),再记下新环境。
  2. 落盘一条环境戳。授权记录自己也带 environment 戳,所以另一个部署留下的 grant 在这儿根本读不出来
  3. 因此切回去要重新授权:那份 grant 已经还给服务端了。

第 2 条是安全边界,不是整洁癖:把测试环境的 token 递给生产会被服务端拒,而比「被拒」更糟的是它看起来能用——环境戳把这种可能直接掐掉。

prod 产物是钉死的:不注册任何切换路由,setEnvironment() 自己也拒绝,面板上根本没有那一行。不是「藏起来」,是没有可走的路——手搓一个请求也一样到不了。

test 产物切换是一个「打包时可选、运行时可选」的开关,prod 产物则把这个开关焊死。判断依据只有一条:allowsEnvironmentSwitch(打包环境),生产环境返回 false

编辑环境地址

prod 产物在面板环境行的右侧有一个 「编辑」 按钮,可以改当前环境的两个地址:

字段 含义 改完后的效果
api域名 机器授权 API 的根地址(配置里叫 baseURL 设备码、token、revoke、session 铸造就都用这个新域名;本机凭据会被清掉(见下)
薪人薪事系统域名 返回给消费者插件的 systemURL credentials() / session() 返回值里的 systemURL 会跟着变;不动任何凭据

规则:

  • 只改当前环境。切到别的环境,这份修改留在原环境;切回来还在。
  • 清空即恢复默认值。api域名回到环境表里的默认值,系统域名回到 https://s.xinrenxinshi.com(或被 profile 配置覆盖后的值)。
  • 改 api域名 = 换部署,所以会退出当前登录:token 只对签发它的域名有效,所以和切换环境一样,先对旧地址 revoke,再写新地址。只改系统域名不碰凭据,不用重新授权。
  • prod 产物没有编辑入口,和环境切换一样被焊死。
  • profile 里的 system.url 仍然有效,但面板编辑会覆盖它(面板编辑是 per-environment 的最新交互)。

它解决的四件事

  1. 授权:走机器认证授权(设备码)流程拿到 access_token / refresh_token,再用 access_token 换取 sessionId / ua / csrf
  2. 生命周期access_tokensessionId 三元组各自的有效期管理。access_token 距过期不足 5 分钟renewal.tokenLeadMs,可配)即视为已失效,自动用 refresh_token 续期;access_token 已过期同样续期。session 三元组按 4 小时寿命计,铸造满 3 小时renewal.sessionLeadMs 提前 1 小时)就在下一次取用时重新铸一份。两条规则都不需要调用方维护任何时钟。
  3. 消费者接口:另一个插件(例如会议纪要插件)通过 ctx.xrxsAuth.credentials()accessToken、通过 ctx.xrxsAuth.session() 取用已被校验sessionId / ua / csrf 三元组。两者的返回值都带有效期信息lifetimeMs 寿命、remainingMs 剩余、lifetimeSource 来源),所以调用方既不用自己判断该不该续期,也不用猜这张凭证能撑多久。它不需要、也不应该去读配置文件或凭据文件——那份文件里可能躺着一份已经失效的登录态。
  4. 按需授权(0.2.4 起,默认开):调用方手上没有可用授权时,credentials() / session()自己把设备码流程发起起来(写日志 + 弹浏览器),然后等人确认(上限 grant.awaitMs,默认 120 秒),确认完把凭证在同一次调用里交回去。于是 NOT_AUTHORIZED 不再是一个「调用方无法行动」的终点。注意触发它的是那一次调用——调用方若因为「没授权」而干脆不调,就什么都不会发生(见下文「消费者契约」)。

客户端界面:左侧栏「薪人薪事令牌」

安装后,dsh 客户端左侧边栏底部会出现一个 薪人薪事令牌 按钮,位于「有更新」按钮的正上方(Settings 更靠下,在它下面)。点击后弹出一个面板,里面做授权操作。第 1 条能力里「需要人」的那一半,就落在这里。

界面由浏览器半区src/client/index.ts)提供,它向客户端注册一项贡献:

注册项 槽位 作用
id: xrxs-auth sidebar.footer.action 左侧栏底部、「有更新」上方的操作按钮

sidebar.footer.action 是官方侧边栏外壳声明的列表槽位。要说清位置,得分两层:

  1. 「在 Settings 上方」不由 order 决定。 Settings 按钮挂在另一个槽位 sidebar.settings 上, 外壳把它固定渲染在 footer 区块下面(见 ui-sidebarSidebarRoot:先 renderSlot('sidebar.footer.action')renderSlot('sidebar.settings'))。所以任何 footer action 都天然在 Settings 之上,改不改 order 都一样。
  2. order 决定的是 footer actions 之间谁更靠上。 list 槽位的排序是先比 priority、再比 order都升序、缺省都是 0(见 ui-slots 里那一行 sort)。外壳自带的「有更新」 (id: cordis-panel)注册时没传 order,于是按 0 算。

由此得到本插件取 FOOTER_ORDER = -10:要压在一个缺省为 0 的常驻项上面,只能是负数; 留一格余量则是为了让别的插件还能插到我们中间。注意 0不行的——它会打平, 然后由注册顺序决胜负,而注册顺序不由我们控制。

这一项走 slots.inject,因此侧边栏后挂载、或整页重载,这个按钮仍然会出现;侧边栏卸载时它随之消失。

按钮在侧栏展开时显示图标 + 文案,收起时只显示图标。图标右上角带一个状态点,不开面板就能看出是否已授权:

状态点 含义
绿色(--dsw-alias-state-success-primary 已存有可用的授权
灰色(--dsw-static-neutral-bluish-400 未授权

状态点和面板读的是同一份 status() 投影,所以两者不可能各说各话。点来自侧栏按钮自己那次读取——本插件的动作会顺手重读,所以自己点出来的授权立刻反映在点上。

⚠️ 但按需授权不是本插件的动作:它是别的插件调 credentials() / session() 时在后台发起的。那种情形下,这个点要等本插件的面板或按钮再被读一次才会跟上。这是「点不需要轮询」唯一不成立的地方,也是它仍然值得留一句注释的原因。

面板内容刻意收得很紧:

  • 不做说明。打开就是状态和一个 去授权 按钮,没有「这个插件是干嘛的」那一段。
  • 不显示账号 / 公司 / session 到期。投影里有这些 id,但面板不渲染它们——只有 已授权 / 未授权access token 到期 <本地时间>。人要的是一眼判断能不能用,不是对着 id 核对。
  • 进行中时只显示最后一条进度。设备码流程会把同一步反复播报,堆成一片就是噪音。
  • 多一行环境(只有非 prod 产物有)。环境:测试环境 加一个 切换到生产环境 的按钮;prod 产物上这一行整个不存在,不是变灰。
  • 验证码大字等宽显示、可一键复制;打开授权页 / 取消 两个动作;授权页地址不再原样铺在面板里(按钮已经能打开它)。

面板本身不持有任何凭据,也不自行推断什么算「有效」——它显示的每个事实都来自主机半区的一条路由,因此面板不可能和服务对「这份 grant 还能不能用」产生分歧。环境那一行也一样:切没切成功以路由的答复为准,面板不自己记一份。

卡片背景必须是字面色,这是踩出来的。 面板的文字/边框/间距都可以用外壳 token,但表面不行:桌面壳里主题的表面 token 在运行时不解析,用 var(--dsw-specific-sidebar-fill, #fff) 画出来的卡片全透明——外壳自己的侧栏在那儿就是没有填充色,于是背后的会话列表直接透上来,看起来像文字叠在一起的乱码。

这个不对称是 CSS 的,不是 token 的:color 会继承,解析不出来的 token 退化成周围文字色;而 background 不继承var() 解析成空在 computed-value 阶段整个属性失效,退回 transparent。所以表面只能是字面色——明暗两套写死在 LIGHT_PALETTE / DARK_PALETTE 里,按 document.bodydata-ds-dark-theme 选。状态点的绿/灰同理(#22c55e / #b3b7bd)。

面板怎么和主机半区说话

凭据在主机进程里,面板在浏览器里。两边靠主机半区挂在 harness web server 上的路由通信——固定 5 条,外加环境面(1 条读,非 prod 产物每个环境一条切换 + 一条编辑):

路由 方法 作用
/xrxs-auth/status GET auth.status() 的生命周期视图
/xrxs-auth/attempt GET 当前这次尝试的视图(面板轮询它)
/xrxs-auth/authorize POST 发起一次尝试(单飞:已在跑就返回同一个)
/xrxs-auth/cancel POST 撤回本次尝试
/xrxs-auth/revoke POST 注销:先告诉服务端,再丢弃全部凭据
/xrxs-auth/environment GET 环境视图(当前环境、可选项、switchableeditable)。所有产物都注册,钉死的产物也回,只是 switchable: false / editable: false
/xrxs-auth/environment/<name> GET 切到 <name>只有可切换的产物注册——prod 产物上这条路径压根不存在(404)
/xrxs-auth/environment/edit POST 改当前环境的 api域名(baseURL)与薪人薪事系统域名(systemURL)。改 api域名会先 revoke 再写。只有可切换的产物注册prod 产物上不存在

切换用路径段而不是 query 或请求体:读和切请求原本都没有 body,而 query 是同一句话的第二种说法。编辑路由是这里唯一带 JSON body 的请求。读路由在钉死的产物上也存在,是因为面板若要把「自己的路由不存在」当成正常答复,就会每次打开都报一次故障;而切换 / 编辑路由按打包参数生成,这才让那个参数是规则而不是提示——prod 产物没有任何可走的路,setEnvironment() / editEnvironment() 另外还会自己拒绝一次。

为什么要绕一圈 HTTP:authorize() 是写给「能在 await 期间和人对话」的界面的——它调 notify 报进度,而设备码流程里这一次调用可能持续到设备码窗口关闭为止(默认 10 分钟)。浏览器面板拿不住这个调用:它从一条 HTTP 请求进来、中途还可能被关掉。于是主机半区用 src/console.ts 把这次尝试跑到后台,把最新视图(含验证码与验证页)留给 GET /attempt 读;面板只管 fetch 轮询。这份状态是按进程、单飞的:两个面板(或同一个面板开两次)共用一次尝试。

安全边界:这几条路由能发起一次授权、也能删掉已存的那一份,所以每条都先过 isLoopbackRequest() 的四道校验(socket 地址是回环 + Host 是回环 + 不是 sec-fetch-site: cross-site + Origin 存在时须与 Host 同源),任一不过直接 403。这里刻意不放 token:能到达 127.0.0.1 本身就是凭据,再加一个 token 只会多一个泄露的东西。除 /environment/edit 需要读一个小的 JSON body 外,其余路由都不读请求体。

webServer可选注入(不在 inject 里声明):没有 web server 的 headless profile 照常工作,ctx.xrxsAuth 一样可用,只是没有面板。

机器认证授权协议

三个端点,全部挂在 oauth.baseURL 之下(默认 https://api.xinrenxinshi.com/)。路径是协议常量,写在 src/transport.ts 里,不单独配置。

公共请求头(三个端点一致):

Content-Type: application/json; charset=utf-8
Accept: application/json

所有答复都是 {code, message, data} 信封。不是信封的答复(nginx 5xx HTML、SSO 跳转页)会被报成 http {status}: {原始 body},而不是被当成一个空的成功——那正是用户无法自行诊断的那类失败。

A. 申请设备码

POST {baseURL}/authorize/oauth/device_authorization
字段 必填 说明
clientId appKey
clientSecret appSecret,有就带上
scope 逗号分隔,如 employee:employee:read

clientSecret / scope 为空时整个字段不出现在 body 里,不是空字符串。

答复 dataDeviceAuthorizationResponsedeviceCodeuserCodeverificationUriverificationUriCompleteexpiresIninterval。插件对返回值的处理:

情况 行为
code != 0 device authorization failed: {message}
data.deviceCode 报错(缺字段,RESPONSE_INVALID
userCode / verificationUri 报错——没有可展示给人的东西,授权无从完成
verificationUriComplete 退回 verificationUri
interval <= 0 或缺失 兜底按 5 秒轮询
expiresIn <= 0 或缺失 兜底按 600 秒
body 不是合法信封 http {statusCode}: {原始 body}

B. 轮询换 token

POST {baseURL}/authorize/oauth/device_token

body:grantType 固定 urn:ietf:params:oauth:grant-type:device_code,加 deviceCodeclientIdclientSecret(可选)。

调用节奏:先等一个 interval 再发第一次轮询(契约要求「至少等 interval 秒」;立刻问只会花掉一次请求去换一个还不可能变化的答案,而把它当成滥用的服务端会回你 slow_down),之后按 interval 秒一次,整体超时 = expiresIn 秒。未完成授权时 code != 0,状态放在 message 里:

message 客户端行为
authorization_pending 继续轮询
slow_down 轮询间隔 +5 秒,且此后保持加宽
access_denied 终止,DECLINEDauthorization denied
expired_token 终止,DEVICE_EXPIREDdevice code expired
其他 终止,报 authorization error: {message}

授权成功后 code == 0dataDeviceTokenResponseaccessTokentokenTypeexpiresInrefreshTokenscopeaccountIdcompanyIdcompanyName)。

落盘映射expiresAt 是本地算出来的绝对时间,不是服务端返回的):

AccessToken  = data.accessToken
RefreshToken = data.refreshToken
ExpiresAt    = now + data.expiresIn(秒)
AccountID    = data.accountId
CompanyID    = data.companyId
CompanyName  = data.companyName
ScopeGranted = data.scope

ClientID / ClientSecret 不写进授权记录:本插件已有凭据存储,两者每次按引用解析。一份密钥落两处,是白白多一个可能过期的副本。

C. 刷新 token

同一个端点,grantType 换成 refresh_token,凭证字段换成 refreshToken

{ "grantType": "refresh_token", "refreshToken": "rt-xxxx", "clientId": "your-app-key" }

clientId 必须传(缺了服务端直接拒绝;本插件在发请求前就报 INVALID_CONFIG 并点名 clientId)。答复结构与 B 完全一致,用同一个解析器;code != 0 → 报 refresh rejected: {message}(失败码 REFRESH_REJECTED,调用方据此判定「必须重新授权」)。

D. 注销

POST {baseURL}/authorize/oauth/revoke
{ "token": "at-xxxx" }

期望 code == 0,否则 revoke rejected: {message}服务端失败不影响本地:只往 stderr 打一行 服务端注销失败(本地凭证仍会清除): ...,然后照样删掉本地授权记录(这在本插件的等价物就是清除凭据存储里的那份记录)。用户要求登出,就必须在本机登出,无论网络通不通、服务端认不认这个 token。

安装

# 在包含本项目 checkout 的目录下
dsh plugin --profile <profile> add ./xrxs-dsh-auth
# 或从 npm / git / tarball
dsh plugin --profile <profile> add xrxs-dsh-auth
dsh plugin --profile <profile> add github:<you>/xrxs-dsh-auth

dsh plugin 会在 profile 目录里转发给 pnpm,并因为本包声明了 dsh.bundle,把它追加进 dsh.profile.bundles。验证某一层是否生效:

dsh --profile <profile> --dump-config | grep xrxs-dsh-auth

打包(npm pack / pnpm pack)时 prepack 会先构建两个半区,所以 tarball 里始终是当次源码的产物——不要绕过它直接打 lib/。本机装到 DSH Desktop 客户端:

npm run check          # typecheck → test → build → smoke → smoke:client
npm pack               # 产出 xrxs-dsh-auth-<version>.tgz(默认 environment=test)
npm run pack:prod      # 生产包(XRXS_ENVIRONMENT=prod npm pack)
dsh plugin --profile desktop add "$(pwd)/xrxs-dsh-auth-<version>.tgz"

改了代码必须 bump version,否则锁文件里的 integrity 会让同名 tarball 不被重新取用(profile 是 hoisted 布局,node_modules/<pkg> 是真实目录,直接换目录内容也走同一条约束)。

完整重启客户端后分区才会出现(带 config 的 bundle 行不支持热挂载)。

配置写在哪

不要改本包里的 cordis.patch.yml——它是随包分发的默认层,只负责把插件挂上去(且刻意不写任何 endpoint)。部署相关的配置写在 profile 自己的 patch 层,它在所有组合包层之后应用。

通常什么都不用写。 地址与身份由产物携带的 environment 给出(见「打包参数」一节),所以这一节只在一种情况下用得上:部署是产物没听说过的那个——比如指向自建网关、带路径前缀的路由,或者一个内置表里没有的环境。

# $DSH_HOME/profiles/<profile>/cordis.patch.yml
- id: xrxs-auth
  config:
    oauth:
      baseURL: https://my-gateway.example.com/xrxs   # 只在产物没声明时生效
      clientIdRef: XRXS_CLIENT_ID                    # appKey
      clientSecretRef: XRXS_CLIENT_SECRET            # appSecret,没有就留空
      scope: employee:employee:read                  # 逗号分隔
    session:
      endpoint: https://my-gateway.example.com/xrxs/cli/.../ajax-get-cli-session-info
    system:
      url: https://s.xinrenxinshi.com                 # 薪人薪事系统域名,消费者需要跳转到薪人薪事页面时使用
    grant:                                            # 按需授权(0.2.4 起)。默认值就是下面这两个
      authorizeOnDemand: true                         # 消费者的调用允许发起设备码授权
      awaitMs: 120000                                 # 发起之后「这一次调用」等人确认的上限
      # cooldownMs: 60000                             # 失败后多久内不再自动重开,防轮询弹窗

grant 这一节不是为「自建网关」准备的,它跟部署地址无关:无人值守的 profile(构建机、 纯 headless)应该显式写 authorizeOnDemand: false,否则一次后台调用会静默弹出一个浏览器 窗口。字段含义与反模式见「消费者契约」一节。

环境层在配置层之前oauth.baseURL / oauth.clientId / session.endpoint 只要产物里的环境声明了,写在这里就是被忽略的(并在 stderr 说一声)。要换地址,正解是重新打包,不是改这一行。system.url 例外:它是配置优先,默认值 https://s.xinrenxinshi.com

clientId / clientSecret 也支持直接写字面量(此时优先于引用)。字面量 clientId 在生产产物里就是这么用的——appKey 不是密钥;字面量 clientSecret 已在 schema 上标了 role('secret'),配置界面会做结构脱敏,但它本来就不该出现在产物或配置里

密钥值不放这里,放凭据存储($DSH_HOME/.credentials.yaml,0600)或进程环境变量;配置里只写引用名。这样轮换密钥不需要动组合配置,也不会有人把密钥粘进聊天里。

根地址会做归一化——末尾斜杠会被去掉,路径前缀会被保留。若某个 profile 确实既没配 session.endpoint、产物环境也没声明它,status() 返回 configured: false 而不是让整个 profile 起不来。

测试环境(联调)

测试坐标就是内置的 test 环境(见「打包参数」一节的表),而 test默认打包环境——所以 npm pack 出来的产物本来就指向它,profile 里一行配置都不用写。下面只列一条不要再写进 profile 的老写法,免得看旧文档照抄:

# 别写这个:environment=test 的产物已经声明了这三项,写在这里会被忽略并打一行警告
- id: xrxs-auth
  config:
    oauth:
      baseURL: http://47.93.57.14:9964
      clientId: appmh1BFyxjQIA6BoTJRzXZuWaGod7Ol
    session:
      endpoint: http://47.93.57.14:9964/cli/support/service/support/cli/ajax-get-cli-session-info

不挂客户端也能验一遍协议(live-check 直接吃环境变量,与插件的环境表是两回事——它只是一个无依赖探针):

npm run build
XRXS_BASE_URL=http://47.93.57.14:9964 \
XRXS_CLIENT_ID=appmh1BFyxjQIA6BoTJRzXZuWaGod7Ol \
node scripts/live-check.mjs

脚本会打印验证码和授权页,等你在浏览器里确认,然后轮询换 token、再用 token 铸一个 session。加 --refresh / --revoke 可顺带验 C / D 两步。手上已经有 token 时用 XRXS_ACCESS_TOKEN=<真 token> 可以跳过设备码那两步,只铸一次 session——验 session 那一半最快的路径。

这台测试服务器实测到的六个坑,每一个都能让 session 铸造「看起来对、实际不通」:

  • 动词必须是 GET 该端点不接受请求体。用 POST 打它(无论带不带 body、无论 JSON 还是表单、无论带不带查询参数)一律 HTTP 500 + {"message":"服务内部错误","code":110105000,"status":false}
  • 拒绝藏在 200 里。 无效 token 回 HTTP 200 + {"msg":"token invalid or expired","code":401};HTTP 状态不携带判决,判决只在体里。只看状态码会把「token 已死」误判成「响应畸形」,丢掉 SESSION_REJECTED 触发的那条自动续期自救路径。
  • ua 可能是空串。 刚铸出的好 session 长这样:{"code":0,"message":"成功","status":true,"data":{"sessionId":"7e2a…(118 字符)","ua":"","csrfToken":"d3HM…(32 字符)"},"lanDic":null}。把空白读成「缺字段」会拒掉服务端发出的每一个 session;因此只有 sessionIdcsrf 是必填的。
  • 不带 Authorization 头会被拒在路由之外。 现行 support-centre 端点回 500「服务内部错误」,旧 account-centre 端点回 Spring 的 404。两条路都不是 401,所以必须总是Bearer(哪怕 token 为空)。
  • 5xx 归 REQUEST_FAILED,不触发续期。 只有 401/403 或信封里的非零 code 才算「凭证被拒」,才触发「强刷 token 重放一次」。把服务端故障读成拒绝,会白烧一次 refresh,还会让用户以为自己的授权死了。
  • 拿无效 token 去探路等于没探。 无效 token 会在路由之前被 token 检查短路成 200 + code:401,于是任何路径、任何动词都"看起来活着"——旧端点下线了也一样"活着"。只有带有效 token 才能走到路由层,区分出 404(没有这个路由)/ 500(动词或鉴权头不对)/ 200(通)。上面这条 GET 结论正是用有效 token 才试出来的。

应用凭证(appKey / appSecret)从哪来

机器认证授权的 clientId 就是薪人薪事的 appKeyclientSecretappSecret——由客户在薪人薪事侧创建应用时取得,不是动态注册出来的(0.1.x 的 POST /oauth/register 流程已随授权码流程一并归档到 bak/)。

拿到之后写进凭据存储,配置里只写引用名:

# $DSH_HOME/.credentials.yaml
refs:
  XRXS_CLIENT_ID: <appKey>
  XRXS_CLIENT_SECRET: <appSecret>

字段名别写混:设备码流程的三个端点统一用 camelCase JSON(clientId / deviceCode / grantType),不要与授权码流程的 snake_case 表单参数(client_id / device_code / grant_type)混用。

消费者契约(其他插件怎么调)

其他插件只做两件事:拿 accessToken拿 sessionInfo。两个都是挂在本插件服务上的异步函数(ctx.xrxsAuth.credentials() / ctx.xrxsAuth.session()),都自带续期——调用方既不需要判断"是否快过期",也不需要缓存任何东西。

import type { Context } from '@deepseek-ai/cordis'
import type { XrxsCredentials, XrxsSessionInfo } from 'xrxs-dsh-auth' // 拿到 Context.xrxsAuth 的类型增强

// 声明依赖,cordis 会等本插件挂载完成再执行 apply
export const inject = ['xrxsAuth']

export async function apply(ctx: Context): Promise<void> {
  // 1) accessToken:剩余不足 renewal.tokenLeadMs(默认 5 分钟)会自动续期后再返回
  const token: XrxsCredentials = await ctx.xrxsAuth.credentials()
  const { accessToken, apiURL, systemURL, lifetimeMs, remainingMs, expiresAt, lifetimeSource } = token

  // 2) sessionInfo:sessionId / ua / csrf / apiURL / systemURL,铸造满 3 小时会在下一次调用时自动重铸
  const info: XrxsSessionInfo = await ctx.xrxsAuth.session()
  const { sessionId, ua, csrf, apiURL, systemURL } = info
}

两个调用是并发安全的:同一实例内并发的多次调用会合并成一次网络请求,不会各刷各的。

没有可用授权时会发生什么(0.2.4 起)

默认(grant.authorizeOnDemand: truegrant.awaitMs: 120000):这一次调用会自己发起授权,并等人确认

  1. 本插件把设备码流程跑起来——验证码与验证页写进插件日志,浏览器被打开;
  2. 你的调用挂在那儿等人,上限 grant.awaitMs(默认 2 分钟);
  3. 人确认完 → 凭证在这一次调用里返回给你,你不需要再调第二次;
  4. 窗口用完还没人确认 → 抛 NOT_AUTHORIZED(和你压根没发起时同一个失败码), 但流程仍在后台继续跑,你下一次调用(或用户点一下重试)就能拿到。

所以 NOT_AUTHORIZED 的含义变了:以前它是「终局」,现在它是「人还没确认完」。 调用方该做的是提示 + 重试,不是放弃。

把它做成 awaitMs: 0 就是「发起完立刻返回、不等」(适合手上自带 loading 界面的调用方); 把 authorizeOnDemand 关掉则退回「什么都不发起,只抛 NOT_AUTHORIZED」——无人值守的 构建机 / 后台任务 / CI 应该这么做。

配置 默认 含义
grant.authorizeOnDemand true 允许消费者的调用发起设备码授权。false = 什么都不发起,只抛 NOT_AUTHORIZED;无人值守部署显式关掉(否则会静默弹出一个浏览器窗口)
grant.awaitMs 120000(2 分钟) 发起之后这一次调用最多等多久。默认等人确认并把凭证交回;0 = 不等,发起完立刻返回 NOT_AUTHORIZED,凭证留给下一次调用。窗口用完不影响流程本身,它照样在后台跑完
grant.cooldownMs 60000 一次失败的按需授权之后,多久内不再自动重开。防的是「调用方轮询 → 每轮弹一个浏览器窗口」。只管按需授权,人在面板上点的授权永远不受限

⚠️ 最容易踩的坑:按需授权被「你的调用」触发,不是被「面板打开」触发。

如果你的插件在未授权时压根不去调 credentials() / session()——典型写法是读到 status().ready === false 就直接渲染一句「请先授权」、顺手把按钮全 disabled——那么 按需授权永远不会发生。用户看到的是一句「未授权」,而且是个死胡同:插件里没有 任何一条路能把流程唤起来。(这个坑真实发生过:消费者插件的侧栏面板拿 ready 当闸门, 唯一会调 credentials() 的地方藏在「自检」按钮后面。)

status() 救不了你:它按契约是纯读,不发网络、不发起任何流程——这正是它便宜到 可以在挂载时调的原因。所以入口必须你自己给,而且闸门要用 configured(「有没有 得试」)而不是 ready(「此刻是不是已经好了」)。

返回值里的字段

字段 含义
accessToken / sessionId / ua / csrf 凭证本身
apiURL api域名:这次凭据属于哪个部署。当前环境表里的默认值、面板里改过的值、或 profile 覆盖后的值——反正就是此刻真正在用的那个地址。发请求、或要跟人解释「这个 token 是哪来的」时用它,不用自己猜
systemURL 薪人薪事系统域名,调用方需要把人跳到薪人薪事页面时使用;默认 https://s.xinrenxinshi.com,可在 profile 配置里改
expiresAt 绝对到期时刻(epoch 毫秒)
lifetimeMs 这张凭证总共能活多久 —— 也就是你要问我"还剩多久就重新获取"时该看的那个数
remainingMs 返回那一刻还剩多久;快照,拿到就旧了,别存
issuedAt 签发时刻(epoch 毫秒)
lifetimeSource server = 服务端亲口给的寿命;assumed = 服务端没给、由插件按配置兜的假设值

lifetimeSource 不是装饰。token 端点会回 expiresIn(于是是 server),而 session 端点什么都不回(实测),所以 session 的 lifetimeMs 永远是插件按 session.ttlMs(默认 4 小时)兜出来的假设值,标成 assumed——调用方据此知道哪些数字是服务的承诺、哪些是本插件的猜测。

「还剩多久就重新获取」是配置,不是调用参数

配置 默认 含义
renewal.tokenLeadMs 300000(5 分钟) token 剩余不足这个数就续期
renewal.sessionLeadMs 3600000(1 小时) session 剩余不足这个数就重铸;配上 4 小时寿命,等价于「铸满 3 小时就换」
session.ttlMs 14400000(4 小时) 服务端没给 session 有效期时按这个算

调用方一行都不用改——续期策略变了,接口返回值自动跟上。

三条必须遵守的规则:

规则 原因
ua 必须原样回放 三元组绑定在铸造时的 UA 上,改写即失效。它可能是空串——服务端在刚铸出的 session 上就回 "ua": "",空串是「绑定了一个空 UA」,不是缺字段,照原样回放即可
每个写操作都要带上 csrf 服务端按 session 校验 CSRF
不要缓存三元组,更不要落盘 每次用之前调 session()。缓存下来的那一份已经脱离了插件的有效期判断

session() 不是一个读文件的 getter。它返回的三元组只有两种来路:刚从服务端铸造的(由服务端裁定有效),或者仍在有效期窗口内的。任何「已失效但还躺在存储里」的三元组都不会被交出去。

完整接口

方法 用途
credentials(signal?) 一个至少还有 renewal lead 有效期的 Bearer token(含 accountId / companyId / companyName / apiURL / systemURL,以及 lifetimeMs / remainingMs / issuedAt / lifetimeSource 这组有效期信息)。没有可用授权时按需发起一次授权并等人确认(默认开,见 grant.authorizeOnDemand / grant.awaitMs
session(signal?) 一份校验过的 sessionId / ua / csrf 三元组 + apiURL / systemURLXrxsSessionInfo,同样带那组有效期信息)。按需授权的规则同上
status() 无敏感值的生命周期视图,可安全渲染到配置界面。纯读:不发网络、不发起任何流程
authorize(interaction, signal?) 发起一次完整授权(设备码流程,需要人)。由「能和人对话的一方」调用
revoke(signal?) 告诉服务端后丢弃全部凭据;服务端失败也照样丢弃本地
invalidate() 只做本地丢弃全部已持有凭据;下一次 credentials() 会重新走一遍授权(或按配置抛 NOT_AUTHORIZED

credentials() / session()authorize() 的分界不是「会不会有人的事」,而是谁来主持这件事:前者面向「只想拿凭证、但不该管界面」的调用方,于是它把设备码流程跑在后台(验证码写日志、浏览器替你打开),调用方只需要等或重试;后者面向真正有界面的那一方,把验证码和验证页交给调用方去呈现。

authorize() 那条路径持续到设备码过期为止(最长 10 分钟),所以别塞在无人值守的路径里。要「发起但不等人」,把 grant.awaitMs 设成 0

revoke()invalidate() 的区别就是「登出」与「本地忘记」:前者会打一次网络,后者不会。面板上的「撤销授权」调的是 revoke()

失败码

全部抛 XrxsAuthorizationError(继承 HarnessError,因此 code 在工具结果与回放中保持稳定)。按 code 分支,不要解析 message。

code 调用方应当怎么做
INVALID_CONFIG 配置不全;消息里点名缺哪一项(含「没有 client id 可解析」)
NOT_AUTHORIZED 没有可用 grant。默认情况下本插件已经替你发起了授权并等过 grant.awaitMs,所以这个错的含义是「人还没确认完」,不是终局:提示用户去确认,然后重试。想区分「在等」还是「压根没发起」,读 status().authorizing
REFRESH_REJECTED 服务端拒绝了 refresh token;必须重新授权
SESSION_REJECTED 服务端拒绝铸造 session——HTTP 401/403、信封 code != 0(含藏在 200 里的 code: 401)、或信封里压根没有 sessionId/csrf,都算拒绝;已自动强刷 token 重放一次仍失败
DEVICE_EXPIRED 设备码窗口关闭前人没确认;重开一次授权即可
REQUEST_FAILED 传输 / HTTP / 超时 / 服务端拒绝等;可重试(session 端点的 5xx 也归这里,触发续期)
RESPONSE_INVALID 服务端答复了,但读不出需要的字段(含「不是信封」的答复)
DECLINED 人拒绝授权(access_denied),或本次尝试被撤回

生命周期语义

  • 续期提前量:token 默认 5 分钟DEFAULT_TOKEN_LEAD_MS,与协议「剩余不足 5 分钟主动刷新」同值),session 默认 1 小时DEFAULT_SESSION_LEAD_MS),配合 session 默认 4 小时寿命(DEFAULT_SESSION_TTL_MS)即「铸满 3 小时就换」。
  • 有效期由谁给access_token 的寿命是服务端给的——规范文档写「有效期为 2 小时」,答复示例 "expires_in": "7199"(注意是字符串,所以 pickNumber() 接受数字字符串),于是 lifetimeSourceserver;服务端万一不给,插件兜底 DEFAULT_TOKEN_TTL_MS(7 199 000ms,同样约 2 小时),此时标记为 assumed。session 的寿命服务端一个字都不说,所以永远来自 session.ttlMs(默认 4 小时),永远是 assumed。调用方要拿 lifetimeMs 去定自己的排期,这个区别就是它必须看的。
  • 有效期是记录的一部分,不是每次重算的:铸造时把 issuedAt / lifetimeMs / lifetimeSource 一起落盘,所以重启后、另一个进程里读到的 token 仍然说得清自己「本来能活多久」;而 remainingMs 只在交付那一刻算,XrxsSession 这个落盘形状里刻意没有它。
  • 续期提前量必须短于有效期renewal.sessionLeadMs >= session.ttlMs 会在加载期就被 TypeError 拒掉(这组前瞻默认值是 1 小时 / 4 小时,天然满足)。否则「新鲜」永远不成立,每一次调用都会去铸一份新的 session——结果对、但线上每隔一次请求就打一轮网络,且静默无声。
  • 快路径不发网络请求:存储里的 token 仍在窗口外就直接返回。
  • 续期在凭据中间件的跨进程写锁内进行:第二个进程会在锁内重读记录,发现别人已经换好了就直接采纳,不会用同一个 refresh token 再刷一次(服务端若轮换 refresh token,重复刷新会毁掉 grant)。
  • 服务端没轮换 refresh token 时,旧的会被保留:否则一次续期之后那份 grant 就再也没有续期手段了。
  • token 续期会连带丢弃已存的 session:用旧 access token 铸出来的三元组正是本插件承诺「绝不交出」的东西,不能在续期后存活。
  • 单飞(single-flight):同一实例内并发的 credentials() / session() / authorize() / revoke() 各自合并成一次网络调用 / 一次人工流程。
  • 业务请求侧的两条刷新规则:请求前剩余有效期 < 5 分钟主动刷新;或收到 HTTP 401 且是首次尝试时刷新并重放一次。后者落在 session 铸造上——它是本插件唯一的「业务请求」,SESSION_REJECTED 会强刷一次再重试一次,然后如实上抛;REQUEST_FAILED(含 5xx)不会,服务端抖动不该消耗一次续期。

已知限制与待核对项

  1. 设备码流程按《薪人薪事机器认证授权接口》实现。 三个端点路径(/authorize/oauth/device_authorization/authorize/oauth/device_token/authorize/oauth/revoke)、JSON 字段名、{code, message, data} 信封、以及 authorization_pending / slow_down / access_denied / expired_token 四个状态都按该规范实现。2026-09-14 对测试环境实测全部吻合:设备码答复给出 deviceCode/userCode/verificationUri/verificationUriComplete/expiresIn: 600/interval: 5,未确认时轮询回 code: 400, message: "authorization_pending",刷新用坏 token 回 invalid_grant。生产环境若字段名或有效期不同,只需在 src/transport.ts 微调。

  2. session 铸造接口GET <session.endpoint> + Authorization: Bearer <accessToken>已实测可用(真 token 铸出 118 字符 sessionId + 32 字符 csrfua 为空串)。它不属于机器认证授权契约,因此读得宽容(信封可有可无,字段认 sessionId/session_idua/userAgent/user_agentcsrf/csrfToken/csrf_token,有效期认 expiresIn/expires_in/expiresAt/expires_at)。四个实测要点:动词必须是 GETPOST 一律 500);拒绝藏在 200{"msg":"token invalid or expired","code":401}),因此判定必须读信封 code 而非 HTTP 状态;ua 可能是空串,故只有 sessionIdcsrf 是必填;5xx 归 REQUEST_FAILED 而非 SESSION_REJECTED,避免服务端故障消耗一次续期。

  3. 授权记录格式版本升到 2。 旧版本(1)记录的是授权码流程的 token,本版本既无法续期也无法寻址,因此按「没有授权」读——两者对调用方的含义相同:重新授权一次。记录上另有一个可选environment 戳(本次新增):带戳的记录只在戳与当前环境一致时可读;不带戳的记录按「属于当前环境」接受,否则一次升级会把所有已授权的人踢下线。

  4. 本插件不挂载任何 tool,没有 model-visible 入口。人侧入口是左侧栏底部「薪人薪事令牌」按钮;程序侧入口是别的插件调 ctx.xrxsAuth。两条路都不经过模型。

  5. 浏览器半区没有单测,只有产物冒烟。 scripts/smoke-client.mjs 按客户端 shell 的方式加载构建产物 lib/client.js,断言它注册的那个 sidebar.footer.action 按钮(槽位名、idlabelorder、组件),以及它向模块表索取的每个 specifier 都在基线表内。理由与 npm run smoke 相同:打包后产物与源码可能分叉,源码级测试会通过而产物已经坏掉。

  6. 没有覆盖率门禁。当前用例覆盖了各分支语义(含轮询节奏、slow_down 加宽、设备码超时),但未接 100% per-file 门禁。

  7. cordis / dsh-credentials / dsh-llm / schemastery 都是 peerDependency,符合 harness 的包约定(两份 cordis 会破坏服务同一性)。它们由 profile 中已有的 @deepseek-ai/dsh-base 提供;若某个 profile 用 autoInstallPeers: false 且未提升它们,需额外 dsh plugin --profile <p> add @deepseek-ai/cordis 等。本仓库同时在 devDependencies 里保留它们,只为本地 typecheck / test

  8. 浏览器半区只允许 require 基线的 9 个模块(react、react/jsx-runtime、react-dom、react-dom/client、@deepseek-ai/cordisdsh-client-storedsh-client-ui-slotsdsh-client-ui-primitivesdsh-client-ui-dockkit)。当前产物只用 react;多要一个会在浏览器里、在一个没人看的 stack trace 里炸掉,所以 smoke:client 把「向模块表索取过什么」变成了断言。

  9. 槽位名以客户端实际声明的为准。 浏览器半区注册到 sidebar.footer.action——这是官方侧边栏外壳声明的列表槽位,位于 sidebar.settings 上方。升级 dsh 客户端后若按钮不再出现,先在客户端产物里查该槽位是否仍在,再改本插件。

  10. 「复制验证码」依赖 Clipboard API,失败时只是按钮没有反馈,不影响授权(验证码本身始终可选中复制)。

  11. 0.2.1 及更早写入的授权记录没有有效期字段。 issuedAt / lifetimeMs / lifetimeSource 是本次新增的可选字段,格式版本仍是 2,旧记录照常可读——但读回来的投影里它们会是 undefined,因为那份记录确实不知道自己当初被给了多长的寿命。它在下一次续期(token 至多 2 小时)之后就被带全字段的新记录取代。判定「是否该续期」的逻辑一个字都没改,仍然只看 expiresAt

  12. 打包环境是产物的属性,不是配置的属性。 environment 在构建期写进 src/build-info.gen.ts(该文件随仓库提交,所以 checkout 里也有一个确定的值),运行时只读。prod 产物的切换能力不是「藏起来」而是不存在:不注册切换路由、setEnvironment() 自己拒绝、面板不画那一行。环境戳落在两条记录上(授权记录 + 一条 xrxs-environment 选择记录),因此另一个部署留下的 grant 读不出来——把测试环境的 token 递到生产会被服务端拒,而「看起来能用」比被拒更糟。

  13. 环境之间的隔离是服务端强制的,不只是本插件的约定。 2026-09-15 实测两个 host 各只认自己的 clientId,交叉使用一律被拒:

    请求 答复
    生产 host + 生产 clientId code:0,发码,验证页 https://s.xinrenxinshi.com/parrot/authorization
    生产 host + 测试 clientId code:401 invalid client
    测试 host + 生产 clientId code:401 invalid client
    测试 host + 测试 clientId code:0,发码,验证页 https://s120.devtest.vip/parrot/authorization

    这就是环境戳存在的理由:另一部署留下的 token 不但不该被取用,而且本来就用不了——但「用不了」只在真正发请求时才暴露(而那时你已经把凭据递出去了),戳把这件事提前到读取那一刻。顺便:两个环境连验证页域名都不同s120.devtest.vip vs s.xinrenxinshi.com),这个地址来自服务端答复,插件不配置它,所以你在浏览器里看到哪个域名,就知道自己在给哪个环境确认。

  14. 生产环境的坐标已实测可用,但 session 合并那一步仍未实测。appoH7i65pnX5M6FzT48MDFKcIUwjMm2 打生产 POST /authorize/oauth/device_authorizationcode:0(见上表),坐标是对的。session 端点路径也确认在生产已被路由:2026-09-15 复核 GET /cli/support/service/support/cli/ajax-get-cli-session-info 不带 Authorization 头回 HTTP 500 + 应用自己的 {"code":110105000}(请求进到了应用),而旧的 /cli/account-center/service/account-center/... 回 Spring 的 404 Not Found(那条路在生产同样已下线)——与测试环境逐字一致。也没有生产真 token,所以**「真 token 能在生产铸出 sessionId/csrf」仍是推断**。第一次上生产请亲手验一遍:XRXS_BASE_URL=https://api.xinrenxinshi.com XRXS_CLIENT_ID=appoH7i65pnX5M6FzT48MDFKcIUwjMm2 node scripts/live-check.mjs(手上有生产 token 时加 XRXS_ACCESS_TOKEN=<token> 可跳过设备码,只验 session 那一半)。

  15. environment 参数改了不会自动更新已装的插件。 客户端载入的是 profile 里的 tarball 产物,换环境必须重新 npm pack同时 bump version)再装一次;build:env 只在构建期生效,改 profile 配置不会移动环境。

  16. 两个域名都是「现取」的,别写死、也别互相代替。 credentials() / session() 都返回 apiURL(api域名 = 机器授权地址)和 systemURL(薪人薪事系统域名 = 给人和页面用的地址)。二者都不落盘apiURL 在交付那一刻由当前寻址算出,systemURL 由配置或内置默认值兜底——所以换环境、或在面板里改过地址之后,读到的值立刻跟着变。它可信的前提是「换部署就清 grant」:面板编辑 api域名走的是「先 revoke 再写」(token 只对签发它的域名有效)。唯一的缺口是直接改 profile 里的 oauth.baseURL 再重启——记录上的 environment 戳仍然匹配,旧 token 会照常交出来。这条路径没有自动清理,换过地址请重新授权一次。


开发

npm install
npm run check     # typecheck → test → build → smoke → smoke:client
命令 作用
npm run typecheck typecheck:hosttsc -p tsconfig.json,含 tests)+ typecheck:client
npm test vitest
npm run build:env environment 参数写进 src/build-info.gen.tsbuild / check 都会先跑它)
npm run build build:env + build:host(→ lib/)+ build:client(→ lib/client.js
npm run pack:prod 生产包:XRXS_ENVIRONMENT=prod npm pack
npm run smoke 主机产物在真实 cordis Context 上挂载并读回 ctx.xrxsAuth
npm run smoke:client 按客户端 shell 的方式加载浏览器产物,断言它注册了什么
npm run live 对真实部署跑一遍完整协议(需要 XRXS_BASE_URL / XRXS_CLIENT_ID,且要先 build

两个 smoke 都是必要的一环,理由相同:单测跑 src/,而 profile / 浏览器加载的是 lib/,两者可能分叉(漏掉的 re-export、被改写错的扩展名、exports 与产物不再对应、向模块表多要了一个模块)。源码级测试会全绿,而线上已经是坏的。

npm run live 补的是第三种分叉:产物与真实服务端。它按设备码流程真打四个端点,因此能发现单测不可能发现的差异——服务端换了个字段名、把拒绝藏在 200 里、或者某一步的语义和规范不一样。这一层没有断言,只有一份可读的流水账(见「测试环境(联调)」),因为它的价值在于把真实答复摊开给人看,而不是再断言一遍我们已经相信的东西。

两个编译面

插件有两个互不相干的编译面,各自一个 tsconfig:

tsconfig 入口 产物 运行环境
主机 tsconfig.build.json src/index.ts lib/*.js node,可 import 任意依赖
浏览器 tsconfig.client.json src/client/index.ts lib/client.js 客户端 shell,只能 require 基线模块表

浏览器面刻意不安装 reactsrc/client/ambient.d.ts 只声明真正用到的几个成员,因此 typecheck:clientbuild:client 在零依赖下也能跑。产物按 harness 的客户端产物契约生成——tsc 出 CommonJS,再由 scripts/build-client.mjs 套上 window.__ModuleLoader__.load({ id, factory }) 信封;shell 把它当 classic script 执行,并只回答那 9 个基线 specifier。

命名:主机侧的 HTTP 传输叫 src/transport.ts(不是 src/client.ts)。src/client/ 这个目录在 harness 约定里留给浏览器半区,而两个面若同名,tsc 会双双输出到 lib/client.js 互相覆盖——浏览器产物会盖掉主机产物,npm run smoke 当场炸。改名为 transport 同时消掉了源码面的歧义与产物面的碰撞。

目录

src/
  index.ts        插件入口:name / inject / Config / apply,以及公开面 re-export
  contract.ts     抽象服务 XrxsAuthorization + Context.xrxsAuth 类型增强
  service.ts      具体实现:设备码授权、轮询换 token、两条续期生命周期、单飞、跨进程写锁、环境寻址
  environment.ts  环境表(域名 + clientId + session 端点)、环境视图、是否可切换、落盘的环境戳
  build-info.gen.ts  构建期写入的打包环境名(由 scripts/build-env.mjs 生成,随仓库提交)
  transport.ts    无状态 wire 协议(设备码三端点 + session 铸造),transport 可注入
  console.ts      主机侧授权控制台:把一次尝试跑到后台,等面板来驱动
  routes.ts       挂在 webServer 上的路由(固定 5 条 + 环境面)+ 回环校验
  config.ts       配置面 + 默认值 + 校验
  grant.ts        自有的落盘格式与读取校验
  browser.ts      尽力而为地打开浏览器(永不抛错)
  types.ts        纯类型面(无 cordis 依赖),另有 `./types` 子路径
  error.ts        稳定失败码
  client/
    index.ts      浏览器半区:注册左侧栏的「薪人薪事令牌」按钮与面板(含环境行)
    ambient.d.ts  只声明真正用到的 react 成员,故无需安装 react
tests/            各分支语义用例,含真实 LocalCredentialProvider 的落盘往返
bak/              被替换掉的 OAuth 授权码流程实现(只读归档,不参与编译)
scripts/build-env.mjs    解析 environment 参数 → src/build-info.gen.ts
scripts/smoke.mjs        主机产物冒烟
scripts/smoke-client.mjs 浏览器产物冒烟
scripts/build-client.mjs tsc 产物 → window.__ModuleLoader__ 信封
scripts/live-check.mjs   对真实部署跑一遍设备码流程(需 XRXS_BASE_URL / XRXS_CLIENT_ID)
tsconfig.json        typecheck(含 tests)
tsconfig.build.json  主机面产物
tsconfig.client.json 浏览器面产物 + 类型
cordis.patch.yml     组合包默认层(只插入插件,不写配置)
pnpm-workspace.yaml  + .npmrc:dsh plugin add 转发给 pnpm 时的约定