xrxs-dsh-auth
Verifiedxrxs-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.ts 的 ENVIRONMENTS,每个环境是一组「域名 + 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,做三件事:
- 先把旧凭据还给旧服务端(对旧环境调一次 revoke),再记下新环境。
- 落盘一条环境戳。授权记录自己也带
environment戳,所以另一个部署留下的 grant 在这儿根本读不出来。 - 因此切回去要重新授权:那份 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 的最新交互)。
它解决的四件事
- 授权:走机器认证授权(设备码)流程拿到
access_token/refresh_token,再用access_token换取sessionId/ua/csrf。 - 生命周期:
access_token与sessionId三元组各自的有效期管理。access_token距过期不足 5 分钟(renewal.tokenLeadMs,可配)即视为已失效,自动用refresh_token续期;access_token已过期同样续期。session 三元组按 4 小时寿命计,铸造满 3 小时(renewal.sessionLeadMs提前 1 小时)就在下一次取用时重新铸一份。两条规则都不需要调用方维护任何时钟。 - 消费者接口:另一个插件(例如会议纪要插件)通过
ctx.xrxsAuth.credentials()取accessToken、通过ctx.xrxsAuth.session()取用已被校验的sessionId/ua/csrf三元组。两者的返回值都带有效期信息(lifetimeMs寿命、remainingMs剩余、lifetimeSource来源),所以调用方既不用自己判断该不该续期,也不用猜这张凭证能撑多久。它不需要、也不应该去读配置文件或凭据文件——那份文件里可能躺着一份已经失效的登录态。 - 按需授权(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 是官方侧边栏外壳声明的列表槽位。要说清位置,得分两层:
- 「在 Settings 上方」不由
order决定。 Settings 按钮挂在另一个槽位sidebar.settings上, 外壳把它固定渲染在 footer 区块下面(见ui-sidebar的SidebarRoot:先renderSlot('sidebar.footer.action')再renderSlot('sidebar.settings'))。所以任何 footer action 都天然在 Settings 之上,改不改order都一样。 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.body的data-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 | 环境视图(当前环境、可选项、switchable、editable)。所有产物都注册,钉死的产物也回,只是 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 里,不是空字符串。
答复 data 即 DeviceAuthorizationResponse:deviceCode、userCode、verificationUri、verificationUriComplete、expiresIn、interval。插件对返回值的处理:
| 情况 | 行为 |
|---|---|
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,加 deviceCode、clientId、clientSecret(可选)。
调用节奏:先等一个 interval 再发第一次轮询(契约要求「至少等 interval 秒」;立刻问只会花掉一次请求去换一个还不可能变化的答案,而把它当成滥用的服务端会回你 slow_down),之后按 interval 秒一次,整体超时 = expiresIn 秒。未完成授权时 code != 0,状态放在 message 里:
message |
客户端行为 |
|---|---|
authorization_pending |
继续轮询 |
slow_down |
轮询间隔 +5 秒,且此后保持加宽 |
access_denied |
终止,DECLINED → authorization denied |
expired_token |
终止,DEVICE_EXPIRED → device code expired |
| 其他 | 终止,报 authorization error: {message} |
授权成功后 code == 0,data 为 DeviceTokenResponse(accessToken、tokenType、expiresIn、refreshToken、scope、accountId、companyId、companyName)。
落盘映射(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;因此只有sessionId和csrf是必填的。- 不带
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 就是薪人薪事的 appKey,clientSecret 是 appSecret——由客户在薪人薪事侧创建应用时取得,不是动态注册出来的(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: true、grant.awaitMs: 120000):这一次调用会自己发起授权,并等人确认。
- 本插件把设备码流程跑起来——验证码与验证页写进插件日志,浏览器被打开;
- 你的调用挂在那儿等人,上限
grant.awaitMs(默认 2 分钟); - 人确认完 → 凭证在这一次调用里返回给你,你不需要再调第二次;
- 窗口用完还没人确认 → 抛
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 / systemURL(XrxsSessionInfo,同样带那组有效期信息)。按需授权的规则同上 |
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()接受数字字符串),于是lifetimeSource为server;服务端万一不给,插件兜底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)不会,服务端抖动不该消耗一次续期。
已知限制与待核对项
设备码流程按《薪人薪事机器认证授权接口》实现。 三个端点路径(
/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微调。session 铸造接口:
GET <session.endpoint>+Authorization: Bearer <accessToken>,已实测可用(真 token 铸出 118 字符sessionId+ 32 字符csrf,ua为空串)。它不属于机器认证授权契约,因此读得宽容(信封可有可无,字段认sessionId/session_id、ua/userAgent/user_agent、csrf/csrfToken/csrf_token,有效期认expiresIn/expires_in/expiresAt/expires_at)。四个实测要点:动词必须是GET(POST一律 500);拒绝藏在200里({"msg":"token invalid or expired","code":401}),因此判定必须读信封code而非 HTTP 状态;ua可能是空串,故只有sessionId与csrf是必填;5xx 归REQUEST_FAILED而非SESSION_REJECTED,避免服务端故障消耗一次续期。授权记录格式版本升到 2。 旧版本(1)记录的是授权码流程的 token,本版本既无法续期也无法寻址,因此按「没有授权」读——两者对调用方的含义相同:重新授权一次。记录上另有一个可选的
environment戳(本次新增):带戳的记录只在戳与当前环境一致时可读;不带戳的记录按「属于当前环境」接受,否则一次升级会把所有已授权的人踢下线。本插件不挂载任何 tool,没有 model-visible 入口。人侧入口是左侧栏底部「薪人薪事令牌」按钮;程序侧入口是别的插件调
ctx.xrxsAuth。两条路都不经过模型。浏览器半区没有单测,只有产物冒烟。
scripts/smoke-client.mjs按客户端 shell 的方式加载构建产物lib/client.js,断言它注册的那个sidebar.footer.action按钮(槽位名、id、label、order、组件),以及它向模块表索取的每个 specifier 都在基线表内。理由与npm run smoke相同:打包后产物与源码可能分叉,源码级测试会通过而产物已经坏掉。没有覆盖率门禁。当前用例覆盖了各分支语义(含轮询节奏、
slow_down加宽、设备码超时),但未接 100% per-file 门禁。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。浏览器半区只允许
require基线的 9 个模块(react、react/jsx-runtime、react-dom、react-dom/client、@deepseek-ai/cordis、dsh-client-store、dsh-client-ui-slots、dsh-client-ui-primitives、dsh-client-ui-dockkit)。当前产物只用react;多要一个会在浏览器里、在一个没人看的 stack trace 里炸掉,所以smoke:client把「向模块表索取过什么」变成了断言。槽位名以客户端实际声明的为准。 浏览器半区注册到
sidebar.footer.action——这是官方侧边栏外壳声明的列表槽位,位于sidebar.settings上方。升级 dsh 客户端后若按钮不再出现,先在客户端产物里查该槽位是否仍在,再改本插件。「复制验证码」依赖 Clipboard API,失败时只是按钮没有反馈,不影响授权(验证码本身始终可选中复制)。
0.2.1 及更早写入的授权记录没有有效期字段。
issuedAt/lifetimeMs/lifetimeSource是本次新增的可选字段,格式版本仍是 2,旧记录照常可读——但读回来的投影里它们会是undefined,因为那份记录确实不知道自己当初被给了多长的寿命。它在下一次续期(token 至多 2 小时)之后就被带全字段的新记录取代。判定「是否该续期」的逻辑一个字都没改,仍然只看expiresAt。打包环境是产物的属性,不是配置的属性。
environment在构建期写进src/build-info.gen.ts(该文件随仓库提交,所以 checkout 里也有一个确定的值),运行时只读。prod产物的切换能力不是「藏起来」而是不存在:不注册切换路由、setEnvironment()自己拒绝、面板不画那一行。环境戳落在两条记录上(授权记录 + 一条xrxs-environment选择记录),因此另一个部署留下的 grant 读不出来——把测试环境的 token 递到生产会被服务端拒,而「看起来能用」比被拒更糟。环境之间的隔离是服务端强制的,不只是本插件的约定。 2026-09-15 实测两个 host 各只认自己的 clientId,交叉使用一律被拒:
请求 答复 生产 host + 生产 clientId code:0,发码,验证页https://s.xinrenxinshi.com/parrot/authorization生产 host + 测试 clientId code:401invalid client测试 host + 生产 clientId code:401invalid client测试 host + 测试 clientId code:0,发码,验证页https://s120.devtest.vip/parrot/authorization这就是环境戳存在的理由:另一部署留下的 token 不但不该被取用,而且本来就用不了——但「用不了」只在真正发请求时才暴露(而那时你已经把凭据递出去了),戳把这件事提前到读取那一刻。顺便:两个环境连验证页域名都不同(
s120.devtest.vipvss.xinrenxinshi.com),这个地址来自服务端答复,插件不配置它,所以你在浏览器里看到哪个域名,就知道自己在给哪个环境确认。生产环境的坐标已实测可用,但 session 合并那一步仍未实测。 用
appoH7i65pnX5M6FzT48MDFKcIUwjMm2打生产POST /authorize/oauth/device_authorization回code: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 那一半)。environment参数改了不会自动更新已装的插件。 客户端载入的是 profile 里的 tarball 产物,换环境必须重新npm pack(同时 bump version)再装一次;build:env只在构建期生效,改 profile 配置不会移动环境。两个域名都是「现取」的,别写死、也别互相代替。
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:host(tsc -p tsconfig.json,含 tests)+ typecheck:client |
npm test |
vitest |
npm run build:env |
把 environment 参数写进 src/build-info.gen.ts(build / 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 基线模块表 |
浏览器面刻意不安装 react:src/client/ambient.d.ts 只声明真正用到的几个成员,因此 typecheck:client 与 build: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 时的约定