npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

xrxs-dsh-auth

v0.4.1

Published

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

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,本节下方「消费者契约」是摘要。

本次改动起,本机可以同时授权多家公司(授权记录格式升到 4)。 一次授权 = 一家公司(哪一家由人在验证页上选),再授权一次就多一条记录,而不是把上一条顶掉。列表、逐条撤销、以及「哪一家是首选」的切换都在面板上;credentials() / session() 默认答首选那家,也可以点名要某一家(credentials({ companyId }))——老调用一个字都不用改。授权完成后插件会再调一次系统域的 ajax-get-predata-v2 读公司名与 logo,logo 缓存在本机。

服务地址:新授权去哪一家部署

一个包在任何部署上都能用。授权去哪不再由打包时写死(XRXS_ENVIRONMENT 打包参数、src/environment.ts 的环境表、build-info.gen.ts 都已删除),而是由三样东西共同决定:内置部署表、面板里的服务地址列表、profile 配置。

内置部署表

src/settings.ts 的 APPLICATIONS 记着本插件认识的每一个部署(host + 标签 + 部署词 + clientId):

| 部署词 | 标签 | api域名(host) | clientId | | :--- | :--- | :--- | :--- | | 线上 | 生产环境 | api.xinrenxinshi.com | appoH7i65pnX5M6FzT48MDFKcIUwjMm2 | | 灰度 | 生产环境(灰度) | grey-api.xinrenxinshi.com | 同上(同一个应用,第二个 host) | | 测试 | 测试环境 | 47.93.57.14:9964 | appmh1BFyxjQIA6BoTJRzXZuWaGod7Ol |

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

这张表只回答两件事:这个 host 是哪个部署(→ 用哪个 appKey 签名、公司列表那一列写 线上 / 灰度 / 测试 里的哪个词),以及给面板当提示(settings().known)。它不决定授权去哪——那是下面那张列表的事。

按 host 匹配(new URL(apiURL).host),所以同一个部署写成 http://、带路径或带端口,只要 host 对得上就认得出来。表里没有的 host 一律按测试环境的 appKey 签名(UNKNOWN_HOST_CLIENT_ID):自建网关、刚搭好的部署,报一个「测试环境不认」比在生产上弹出真登录页安全得多。

session 铸造的端点不是表里的字段,而是一个路径常量(SESSION_PATH,见 src/settings.ts):本插件认识的每个部署都把这个路径挂在它自己那个 API 域名上。session.endpoint 仍然能盖掉它,给「网页层不在同一台机器上」的部署用。

服务地址列表(面板 → 设置)

面板右下角那个齿轮(提示语 设置服务地址)打开的是一张卡片,里面是服务地址列表:

  • 第一行永远是内置的 线上,挂一个 默认 徽标,不可编辑、不可删除——它就是「没有选用任何自定义地址」这件事本身,而不是一条记录。
  • 下面是人自己加的若干行,每行是一个环境名 + api域名 + 薪人薪事系统域名,可以 选用 / 编辑 / 删除。
  • 选中的那行挂一个 使用中 徽标;选用的若不是内置行,令牌首页会多显示一行 当前使用环境:<名字>(内置行不显示,那是常态)。
  • 点 添加服务地址 时,表单在同一张卡片里就地展开,不是再叠一个弹窗:环境名 + 两个地址,保存后收回列表。任何一次改动都直接写回整张列表,所以没有「保存设置」这一步,也不会出现「要关好几个弹窗」。

规则:

  • 只决定下一次授权去哪里。 已经授权过的公司各自记着自己的地址(见「多家公司」),所以改这张列表不动任何已有凭据,也不会让人重新登录——面板上的说明也照实这么写,而不是吓唬人说要重新登录。
  • 地址必须填全。 环境名不能为空、不能重复、也不能占用内置名 线上;两个地址都必须填,且必须是完整的 http(s) 地址。一次写入整体接受或整体拒绝,不会静默丢掉那一行。
  • 末尾斜杠会被吃掉:填 https://s.xinrenxinshi.com/ 存下来还是 https://s.xinrenxinshi.com。
  • 选用 的那行被删掉之后,使用中 回到内置 线上,不会指向一个已经不存在的名字。
  • 两个输入框的提示语是两句话:api域名举 https://api.xinrenxinshi.com,系统域名举 https://s.xinrenxinshi.com。它们是两个 host(一个是机器地址,一个是 UI 地址),共用一个例子只会把人带错一半。

地址怎么定下来

api域名与薪人薪事系统域名走同一条链,从前往后第一个有值的赢:

  1. 在用的那一行服务地址(面板 → 设置里选中的那行;选中的是内置行时退到第 2 步)
  2. profile 配置:oauth.baseURL / system.url
  3. 内置默认:https://api.xinrenxinshi.com / https://s.xinrenxinshi.com

一个名字一旦是人自己起的(第 1 步里的自定义行),profile 就没有发言权了——名字是人做的决定,配置不该盖掉它。配置是「没人表态时的那一层」,不是「可以推翻人的那一层」。这与 appKey 的优先级正好相反(oauth.clientId 压过表里的值),故意的:人在面板上决定的是自己想登哪儿,profile 决定的是无人值守时该默认走哪儿。

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

这份列表存在哪

一条 xrxs-settings 记录(存在凭据存储里,与授权记录同一个 scope,靠 payload 里的 kind 区分),格式版本 2:

{ "kind": "xrxs-settings", "version": 2,
  "environments": [{ "name": "我的环境", "apiURL": "…", "systemURL": "…" }],
  "active": "我的环境" }

active 缺省就是内置 线上——不是「没设置」,而是一个确定的答案。它指向一个不存在的名字时会被读成缺省,所以永远不会悬空。

旧的两框版本({ "version": 1, "apiURL", "systemURL" })照读不误:升级时它变成一个在用的命名环境,名字取自内置表给那个 host 的标签(认不出的 host 用它的 hostname)。这很重要——那两框表达的就是「下次登这儿」,按「没设置」读会让每一个改过地址的人在升级后静默挪回线上。

为什么现在没有「切换环境」和「编辑环境地址」了

0.3.x 之前,这个插件是按部署打包的:test 包里多一行环境切换、多一个「编辑」按钮,prod 包把两者焊死。那套东西连同 src/environment.ts、build-info.gen.ts 一起删掉了,因为同一个问题现在只有一个答案:服务地址列表。

  • 「切换环境」= 在列表里 选用 另一行。区别是它不再 revoke 任何东西:凭据是按公司各存各的地址(见下),换一行只影响下一次授权,已经授权过的公司一个都不动——所以也不再需要「切回去要重新授权」。
  • 「编辑环境地址」= 编辑 那一行。同样不碰凭据。
  • 「prod 产物没有这个入口」这条也不再成立:每个包都有这张列表。想锁死它,请用 profile 配置把地址钉住(配置在第 2 层,面板第 1 层仍然可改——真要不让人改,就别给那个 profile 开 web server,面板本来就挂不上去)。

它解决的五件事

  1. 授权:走机器认证授权(设备码)流程拿到 access_token / refresh_token,再用 access_token 换取 sessionId / ua / csrf。一次授权对应一家公司——人在验证页上选的那一家;再授权一家就多一条记录,互不影响。
  2. 生命周期:access_token 与 sessionId 三元组各自的有效期管理。access_token 距过期不足 5 分钟(renewal.tokenLeadMs,可配)即视为已失效,自动用 refresh_token 续期;access_token 已过期同样续期。session 三元组按 4 小时寿命计,铸造满 3 小时(renewal.sessionLeadMs 提前 1 小时)就在下一次取用时重新铸一份。两条规则都不需要调用方维护任何时钟,且按公司各算各的:两家的 refresh token 是两个东西,一家的 token 失效与另一家无关。
  3. 消费者接口:另一个插件(例如会议纪要插件)通过 ctx.xrxsAuth.credentials() 取 accessToken、通过 ctx.xrxsAuth.session() 取用已被校验的 sessionId / ua / csrf 三元组。两者的返回值都带有效期信息(lifetimeMs 寿命、remainingMs 剩余、lifetimeSource 来源),所以调用方既不用自己判断该不该续期,也不用猜这张凭证能撑多久。它不需要、也不应该去读配置文件或凭据文件——那份文件里可能躺着一份已经失效的登录态,或者好几家公司的登录态。
  4. 多家公司:一台机器上同时持有几家的授权,credentials() / session() 默认答当前选中的那家(老调用因此完全不用改),需要指定时把公司 id 传进去(credentials({ companyId }))。面板上能看到都有哪几家、逐条撤销、切换「当前」;status().companies 与 companies() 是给界面读的那份列表——按部署排好序(线上 → 灰度 → 测试 → 人自己命名的那几个,按人写的顺序),每一项带一个 environment。
  5. 按需授权(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-sidebar 的 SidebarRoot:先 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() 时在后台发起的。那种情形下,这个点要等本插件的面板或按钮再被读一次才会跟上。这是「点不需要轮询」唯一不成立的地方,也是它仍然值得留一句注释的原因。

面板内容刻意收得很紧(卡片宽 400px,这个宽度是按一行要装下的东西定的:头像 + 公司名 + 最多两个小标签 + 两个控制;352px 时被挤掉的是公司名):

  • 不做说明。打开就是已授权公司的列表,和一个动作按钮:没授权过时是 去授权,已经授权过时变成 授权新公司(再授权一家——一次授权只加一家)。没有「这个插件是干嘛的」那一段。
  • 授权后列公司。一家一行:左侧是缓存的 logo(没有 logo 就画公司名首字母),然后是公司名,再是授权日期(2026-09-28 这种本地日期)。公司名太长时截断成省略号,鼠标停上去显示全名——注册名可能是一长串英文,宽度不够时截断比换行整齐,而省略号截掉的东西总得有地方还给人。不显示到期日期——access token 只有 2 小时且自动续期,写一个「到期时间」只会让人以为那张凭证会失效。
  • 一行两个动作:设为首选(把这一家变成 credentials() / session() 默认答的那家;已经是首选的那行不显示这个按钮,改为挂一个 首选 徽标)和 撤销(只撤这一家)。列表底部另有 撤销全部授权,只在已经授权过的时候出现——它比旁边两个按钮小一号,而且要点两下:第一下换成 确认撤销全部 + 取消 两个按钮,第二下才真撤。
  • 环境这一列只在合并了几个部署时才画。公司名旁边一个小标签写着这个地址的名字——值是从该公司自己记着的 apiURL 读出来的,人给它起过名就写那个名字,否则是内置词 线上 / 灰度 / 测试,两者都没有才退到它的 host(见「服务地址」一节)。同一部署的几家公司不画(每行同一个词等于没说),所以判据是「这一列有几种值」而不是「有几家公司」;一家公司因此也不画。认不出的 host——正是这种行最需要被区分开。顺序也由主机半区定好(线上 → 灰度 → 测试 → 人命名的那几个 → 认不出的),面板不排序,两个界面才不会给出两种顺序。
  • 已授权时没有状态行。列表本身就是答案,所以卡片里没有 已授权 那句话,也没有 access token 到期 <本地时间>——access token 只有 2 小时且自动续期,写出来只会让人以为那张凭证会失效。这一行只在一家公司都没有的时候画,写着 未授权(外加界面上那个绿/灰状态点)。账号 id / session 到期投影里有,面板一律不渲染。
  • 成功和失败各有一句话。一次授权结束时会显示 授权完成,凭据已保存。(绿)或 授权未完成:<code> <message>(红)。成功那句不是多余的:流程要按服务端的 interval 轮询,人在浏览器里点下确认、到这边有反应之间隔着几秒,一句话都没有的几秒会被读成「它没动」——公司出现在列表里是证据,这句话是回执。
  • 进行中时只显示最后一条进度。设备码流程会把同一步反复播报,堆成一片就是噪音。
  • 右下角常驻一个设置齿轮(只有图标,没有文字;鼠标停上去显示 设置服务地址)。点开的是一张卡片,不是一串弹窗:上面是服务地址列表,第一行是内置 线上(默认 徽标,不可编辑/删除),下面是人加的若干行(每行 选用 / 编辑 / 删除)。点 添加服务地址 时表单在同一张卡片里展开(环境名 + api域名 + 薪人薪事系统域名),保存后收回;每次改动都写回整张列表,所以没有「保存设置」这一步。选中哪一行决定下一次授权去哪里;已经授权过的公司各自记着自己的地址,所以改这里不会影响它们——面板上的说明也照实这么写,而不是吓唬人说要重新登录。
  • 进行中时不画验证码。只剩 打开授权页 / 复制授权地址 / 取消 三个动作:验证码就在那个地址的查询串里,人不需要抄,也没地方抄错。

一家公司可不可点、logo 有没有,都不是面板自己判断的:status().companies[] 里每一项带 id / apiURL / systemURL / environment / companyName / authorizedAt / active / hasLogo,logo 图片走的是一条独立路由(/xrxs-auth/logo/<id>)。hasLogo 由主机半区问文件系统得出,而不是读记录里的字段——用户手动删掉那个文件之后,记录仍然说有,画出来就是一只碎图。

面板本身不持有任何凭据,也不自行推断什么算「有效」——它显示的每个事实都来自主机半区的一条路由,因此面板不可能和服务对「这份 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 上的路由通信——固定 7 条,加上公司面(1 条 logo 读取 + 每家公司各一条撤销、一条设为首选)与设置面(1 条,GET 读 / POST 写):

这 7 条里,/credentials 与 /session 不是给面板的(面板拿不到、也不该拿到令牌):它们是程序面,给跑在同一个 DSH 会话里的 CLI 读,地址从环境变量来(见「让 CLI 直接取已授权的凭据」)。

| 路由 | 方法 | 作用 | | :--- | :--- | :--- | | /xrxs-auth/status | GET | auth.status() 的生命周期视图(含 companies 与 activeCompanyId) | | /xrxs-auth/credentials | GET | auth.credentials() 的答复原样返回;?company=<id> 指定一家,省略 = 首选那家。程序面 | | /xrxs-auth/session | GET | auth.session() 的答复原样返回;参数同上。程序面 | | /xrxs-auth/attempt | GET | 当前这次尝试的视图(面板轮询它) | | /xrxs-auth/authorize | POST | 发起一次尝试(单飞:已在跑就返回同一个) | | /xrxs-auth/cancel | POST | 撤回本次尝试 | | /xrxs-auth/revoke | POST | 注销全部:先逐家告诉服务端,再丢弃全部凭据 | | /xrxs-auth/companies/<id>/revoke | GET | 只撤销 <id> 这一家;答复是刷新后的 status | | /xrxs-auth/companies/<id>/active | GET | 把「首选」切到 <id>;答复是刷新后的 status | | /xrxs-auth/logo/<id> | GET | 该公司的 logo 图片字节;没有缓存就 404 | | /xrxs-auth/settings | GET | 设置视图:内置行 builtin、人加的 environments、在用的 active、生效的 apiURL / systemURL、识别出的应用名 application、以及内置部署表 known(面板据此给每一行标环境) | | /xrxs-auth/settings | POST | 写 { environments: [{ name, apiURL, systemURL }], active } —— 整表替换,active 缺省 = 内置 线上。只决定下一次授权去哪,不动任何已有凭据 |

公司面这三条是前缀路由:一条 /xrxs-auth/companies/<id>/<action> 的注册就够覆盖所有公司,公司 id 是路径里的一段。revoke / active 用 GET 而不是 POST,是因为它们没有请求体、也不需要 CSRF,且面板只需一次 fetch——这里唯一带 JSON body 的写路由是 /settings 的 POST。撤销与切换都回一份刷新后的 status,面板据此重画,不用再补一次读。

切换用路径段而不是 query 或请求体:读和切请求原本都没有 body,而 query 是同一句话的第二种说法。带 JSON body 的只有 /settings 的 POST——它写的是一整张列表,不是一个可以放进路径的名字。

为什么要绕一圈 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 只会多一个泄露的东西。只有 /settings 的 POST 读一个小的 JSON body,其余路由都不读请求体。

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

让 CLI 直接取已授权的凭据

面板之外的第二个消费者是命令行程序:一个由模型通过 bash 工具跑起来的 CLI,希望直接用这台机器上已经授权好的令牌,而不是自己再走一遍设备码流程。它拿到的必须是同一份凭据,所以本插件不另开一条取凭据的路——/credentials 和 /session 就是 ctx.xrxsAuth 那两个方法的 HTTP 形态,同一个服务、同一套「什么算可用」的判断,含按需授权(未授权时那次调用会弹浏览器、等人确认、拿到令牌再返回)。

问题只剩一个:CLI 怎么知道端口。它不是固定的——port: 0 的 profile 由内核分配,桌面端发现首选端口被占用还会往上走一位。所以插件把地址交给 harness,由 harness 交给子进程:

| 名字 | 值 | 谁读 | | :--- | :--- | :--- | | DSH_XRXS_AUTH_ENDPOINT | http://127.0.0.1:<实际端口>/xrxs-auth | 模型 shell 调用里的任何进程 |

curl -fsS "$DSH_XRXS_AUTH_ENDPOINT/credentials" | jq -r .credentials.accessToken

这条变量通过 ctx.shellEnv 注册(src/shell-env.ts),harness 会把它的当前快照注入每次模型 shell 调用的环境(与官方 DSH_WEB_URL 同一机制)。端口是每次调用现读的,不是挂载时记下的——所以端口挪过、或 web server 比插件晚绑定,报出来的都还是真值。

它只在「这个进程由 DSH 启动」时存在。 用户在系统终端里手敲的 CLI 继承不到(那个 shell 从来不是 harness 的 shell),此时 CLI 应当走自己的授权——这不是缺口,而是这条变量的正确读法:「有 DSH 会话在服务这个进程」。headless profile 没有 web server,也就没有这条变量。

地址硬编码回环(不取 webServer.host):绑 0.0.0.0 时那个字面量是「每个接口」,不是一个能连的地址;而且这几条路由本来就只放行回环调用。本机 CLI 一律访问 127.0.0.1。

机器认证授权协议

三个端点,全部挂在 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。

按多家公司撤销时,逐家都试一遍:一家被服务端拒了不会让后面几家留着——用户要的是「全都登出」,停在第一个服务端不收的 token 上,只会让剩下的几家在服务端继续活着,而用户看不出来。

E. 读公司资料(公司名 + logo)

GET {systemURL}/support/service/storm/ajax-get-predata-v2?ssotoken={sessionId}

这一步在授权流程最后跑,且是纯装饰:它拿到的公司名和 logo 只用于面板上那一行,失败不影响授权(只往 stderr 打一行,列表里那家公司就显示自己的 id、首字母)。公司名优先用 data.company.companyName,没有则退到 data.headName(总部名,同一法人实体);不用 shortCompanyName——那是给窄布局用的简称,把「测试简称111」当公司名画出来比画总部名更糟。

三个实测出来的要求,与 session 那个端点不一样,少一个都是 200 + {"code":2006,"message":"请先登录"}:

  • session 走 cookie,不叫 sessionId:QJYDSID=<sessionId>; WAVESSID=<sessionId>(两个名字都要带)。
  • 必须带 x-csrf-token(就是所持 session 的 csrf)。
  • user-agent 必须是所持 session 自己的 ua,原样回放,空串也要回放空串。发一个像浏览器的 UA(这个端点平时服务的确实是浏览器,所以这是最自然的写法)会被回 请先登录。

拿到 companyLogoUrl 后,logo 会下载到本机缓存:$DSH_HOME/plugin-data/xrxs-dsh-auth/logos/<公司id>.<扩展名>(目录 0700、文件 0600、上限 2 MiB;扩展名取自响应自己的 content-type,没有才看地址后缀)。缓存放在插件自己的数据目录而不是凭据记录里:凭证那份文件有自己的锁和自己存在的理由,把图片混进去只会让一个无关的关切进入 token 的写入路径。没有 logo、下载失败、用户手删了那个文件——面板都画公司名首字母,都不算故障。撤销某一家会连带删掉它的 logo;invalidate() / 撤销全部会清掉整个目录。

安装

# 在包含本项目 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
npm run pack: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 层,它在所有组合包层之后应用。

通常什么都不用写。 地址由内置部署表和面板里的服务地址列表给出(见「服务地址」一节),所以这一节只在两种情况下用得上:部署是内置表没听说过的那个——指向自建网关、带路径前缀的路由;或者想在没人打开面板时就钉住一个地址(无人值守的机器)。

# $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 / system.url 被跳过;选用的如果是内置 线上(默认),才轮到配置,最后兜底内置常量。这与 oauth.clientId 正好相反——appKey 只要内置表认得那个 host 就由表决定,配置只在表不认时才生效(不一致时 stderr 说一声)。两条不同向都是故意的:地址是「人想登哪儿」,appKey 是「那个 host 必须用哪张身份证」。

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

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

根地址会做归一化——末尾斜杠会被去掉,路径前缀会被保留。configured 在这个设计里永远是 true,而且这是故意的:地址的链一定落到一个值(最差是公开的生产环境),session 端点也一定能从那个地址推出来,所以没有任何东西是「必须先配好才能授权」的。它留成字段而不是常量,是因为面板在给出授权按钮之前要分支一次——哪天这个包不再随带默认值,那里就是唯一需要开口的地方。

测试环境(联调)

测试坐标就是内置表里的 测试 一行(见「服务地址」一节的表):47.93.57.14:9964 + 测试 appKey。怎么用它取决于你想验哪一半:

  • 想验协议(设备码 → token → session):用下面的 live-check,它直接吃环境变量,跟插件、profile 都无关。
  • 想验插件在测试部署上的行为:在面板 → 设置里加一行服务地址(环境名随便起,api域名 http://47.93.57.14:9964,系统域名 https://s120.devtest.vip)再 选用。不要再写进 profile——下面这条老写法现在有两个毛病:clientId 会被内置表盖掉(表认得这个 host),session.endpoint 也没必要(它由 api域名 推出来)。
# 别写这个:这个 host 内置表认得,clientId 会被表里的值盖掉,session 端点也能推出来
- 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 是必填的。
  • haveAdminAccount / haveEmployeeAccount 不是必填。 服务端会给这两个布尔(这个账号在部署上是否还有管理端 / 员工端账号),但**「没给」不等于 false:老记录、以及还没上线该字段的部署都给不出来。把「缺」当 false 用,会在换环境或读到旧记录后给出跟事实相反的结论;这两个字段只在确为 true** 时才可以拿来显示对应入口。
  • 不带 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 小时会在下一次调用时自动重铸
  //    另有服务端给的账号判定 haveAdminAccount / haveEmployeeAccount(可选,见下)
  const info: XrxsSessionInfo = await ctx.xrxsAuth.session()
  const { sessionId, ua, csrf, apiURL, systemURL } = info
}

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

多家公司(本次改动起)

一台机器上可以同时授权多家公司。你不写任何东西就已经是对的:credentials() / session() 答的是「当前」那一家,而这就等于以前只有一家时的行为。

需要指定时,把公司 id 传进去(两个方法签名一样):

// 指定公司:答这一家,且不改变「当前」是哪一家
const token = await ctx.xrxsAuth.credentials({ companyId: '67890' })
const info = await ctx.xrxsAuth.session({ companyId: '67890' })

// 老写法仍然有效,一个信号位不会被误认为公司
const again = await ctx.xrxsAuth.credentials(controller.signal)

// 我这儿有几家?谁在生效?——给界面用
const { companies, activeCompanyId } = await ctx.xrxsAuth.status()
// companies: [{ id, apiURL, systemURL, environment, companyId, companyName, authorizedAt, active, hasLogo }, ...]

规则:

  • companyId 是 XrxsCompanyView.id(一般就是公司 id 本身;老记录迁移过来的可能是账号 id,最简单是直接读 status().companies[].id)。
  • 点名一家本机没有的 → NOT_AUTHORIZED,不会退回到「当前」那一家:静默换一家等于把别人家的凭证交给你,而你的答案里看不出来。同理也不会为它发起按需授权——验证页上由人选的那一家不会恰好是你点名的那一家,发起只会白等一场。
  • 名单只有一份,status().companies 与 companies() 内容等价;activeCompanyId 是「当前」那家的 id(一家都没有时为 undefined)。
  • 列表已经排好序:按部署分组,线上 → 灰度 → 测试,认不出的 host 排最后,同一部署内保持授权顺序。照着画就行,别再自己排一遍——插件只排一次,两个界面各排一次就可能给出两种顺序。
  • environment 可能不在:它是从该项自己的 apiURL 读出来的(线上 / 灰度 / 测试),认不出的 host 没有这个字段。要显示就按「这一列有几种值」决定,只有一家或都在一起时不必画。
  • 老记录的迁移是自动的:格式版本 2(单公司)与 3(整份记录一个部署戳)都照读,不登出任何人;下一次写入就变成版本 4(每条记录自带 apiURL / systemURL)。

管理面的方法(面板在用,消费者一般不需要):companies()、revokeCompany(id)、setActiveCompany(id)、revoke()(注销全部,逐家通知服务端),以及 authorize() / cancelAuthorize() / invalidate()。撤销一家不会动其他家,并且会把「当前」挪到一个还活着的公司上。

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

默认(grant.authorizeOnDemand: true、grant.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域名:这份凭据自己的部署地址——当初在哪家授权的就永远是那个,之后再改服务地址列表也不会变。发请求、或要跟人解释「这个 token 是哪来的」时用它,不用自己猜 | | systemURL | 薪人薪事系统域名,调用方需要把人跳到薪人薪事页面时使用。与 apiURL 属于同一份授权,从该公司自己的记录里取——所以它俩永远指向同一个部署,不会一个新一个旧 | | expiresAt | 绝对到期时刻(epoch 毫秒) | | lifetimeMs | 这张凭证总共能活多久 —— 也就是你要问我"还剩多久就重新获取"时该看的那个数 | | remainingMs | 返回那一刻还剩多久;快照,拿到就旧了,别存 | | issuedAt | 签发时刻(epoch 毫秒) | | lifetimeSource | server = 服务端亲口给的寿命;assumed = 服务端没给、由插件按配置兜的假设值 | | haveAdminAccount / haveEmployeeAccount | 服务端对这个账号的判定:在部署上是否还有管理端 / 员工端账号。可选——老记录、以及还没上线该字段的部署都给不出来;缺 = 没观察到,不等于 false,只有 true 才能当「有」用 |

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(target?, signal?) | 一个至少还有 renewal lead 有效期的 Bearer token(含 accountId / companyId / companyName / apiURL / systemURL,以及 lifetimeMs / remainingMs / issuedAt / lifetimeSource 这组有效期信息)。target 是 { companyId },不传 = 当前那一家;老写法 credentials(signal) 照样有效。没有可用授权时按需发起一次授权并等人确认(默认开,见 grant.authorizeOnDemand / grant.awaitMs) | | session(target?, signal?) | 一份校验过的 sessionId / ua / csrf 三元组 + apiURL / systemURL(XrxsSessionInfo,同样带那组有效期信息)。target 同上。按需授权的规则同上 | | status() | 无敏感值的生命周期视图,可安全渲染到配置界面(多公司列表在 companies,当前那家在 activeCompanyId)。纯读:不发网络、不发起任何流程 | | companies() | 授权过的公司列表(XrxsCompanyView[]),与 status().companies 同一份内容,给只要列表的调用方省一次拆包 | | revokeCompany(id, signal?) | 只撤销 id 这一家:先把它那个 token 还给服务端,再从本机删掉它(含缓存的 logo)。服务端不收也照样删;不存在的 id 是空操作(两个面板点同一个按钮不该让第二个报错) | | setActiveCompany(id) | 把「当前」切到 id:之后不点名的 credentials() / session() 答的就是它。id 不存在时抛 NOT_AUTHORIZED | | authorize(interaction, signal?) | 发起一次完整授权(设备码流程,需要人)。由「能和人对话的一方」调用。哪一家公司在服务端的验证页上由人决定,客户端既选不了也猜不到 | | revoke(signal?) | 告诉服务端后丢弃全部凭据(逐家通知,一家失败不挡其他家);服务端失败也照样丢弃本地 | | invalidate() | 只做本地丢弃全部已持有凭据(连同缓存的 logo);下一次 credentials() 会重新走一遍授权(或按配置抛 NOT_AUTHORIZED) | | cancelAuthorize() | 撤回一次按需授权替你发起的流程,返回是否真的撤回了(false = 当时没有这种流程在跑)。消费者唯一需要主动用到的「取消」入口 |

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

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

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

两个「取消」不是一个东西。 递给 credentials(signal) / session(signal) 的 signal 只结束你的等待,流程照跑;cancelAuthorize() 才是撤回流程本身。为什么只有后者能撤:authorize(interaction, signal) 那条路径下流程归调用方,撤回走它自己的 signal;按需授权却没人持有句柄(grant.awaitMs 限制的是等待,不是流程),所以它的 abort controller 留在插件里,只由 cancelAuthorize() 触达——且只触达这一种,别人(例如面板)自己发起的尝试它碰不到,一律返回 false。撤回后流程以 DECLINED 结束,仍在你 awaitMs 窗口里等的那次调用会立刻收到 DECLINED 而不是等到超时。⚠️ 撤回不等于登出:它不清理任何已存凭据;要「登出并顺手停掉在跑的授权」,两步都要做(先 cancelAuthorize(),再 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)、有人调了 cancelAuthorize()、或调用方的 signal 结束了等待(最后一种流程仍在后台继续) |

生命周期语义

  • 续期提前量: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)不会,服务端抖动不该消耗一次续期。

已知限制与待核对项

  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 字符 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,避免服务端故障消耗一次续期。

  3. 授权记录格式版本升到 4(逐公司记地址)。 版本 4 的每条 companies[] 自带 apiURL / systemURL——当初是哪家部署给的,就永远记着那家,所以之后再改服务地址也不会让已有凭据换到别的域名上去(这正是「面板里改地址不必重新授权」能成立的原因)。active 指向 credentials() / session() 默认答的那家。前两个版本都照读:版本 3(公司列表 + 整份记录一个部署戳)与版本 2(单公司)都在内存里升级成版本 4,写回去时才是 4——把它们当成「没有授权」会让每一个已授权的人因为一次格式变化而掉线,这是这份改动唯一不允许发生的结局。旧的戳靠一张冻结的对照表(src/grant.ts 的 LEGACY_ADDRESSES:prod / test)翻成逐公司地址,故意不读 src/settings.ts 那张活表——迁移不能跟着一个搬过家的部署跑。旧版本(1)记录的是授权码流程的 token,本版本既无法续期也无法寻址,因此按「没有授权」读——两者对调用方的含义相同:重新授权一次。几条配套的事实:授权日期取记录自己的 authorizedAt,没有就退到 token 的签发时间(那是一个下界而不是断言);公司键取 tokens.companyId,旧记录没有就退到 accountId,再没有就是占位符 legacy;companies[] 的解析是全有或全无——一条读不出来就当整份读不出来,因为把「一条坏条目」读成「少了一家公司」等于在一次解析失败之上替用户宣称他撤销过一家公司;haveAdminAccount / haveEmployeeAccount 读不到就记作缺省而不是 false。

  4. 本插件不挂载任何 tool,模型没有可直接调的工具。人侧入口是左侧栏底部「薪人薪事令牌」按钮;程序侧入口有两条:同 profile 的其他插件调 ctx.xrxsAuth,以及模型 shell 调用里的 CLI 读 DSH_XRXS_AUTH_ENDPOINT 再打那几个路由(见「让 CLI 直接取已授权的凭据」)。第二条是唯一一条「模型起进程、进程自己取凭据」的路——凭据落在那个进程里,不由模型搬运;CLI 那边应当把令牌直接喂给自己的请求,不要打印到 stdout。

  5. 浏览器半区没有单测,只有产物冒烟。 scripts/smoke-client.mjs 按客户端 shell 的方式加载构建产物 lib/client.js,断言它注册的那个 sidebar.footer.action 按钮(槽位名、id、label、order、组件),以及它向模块表索取的每个 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/cordis、dsh-client-store、dsh-client-ui-slots、dsh-client-ui-primitives、dsh-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 是后来新增的可选字段,旧记录照常可读——但读回来的投影里它们会是 undefined,因为那份记录确实不知道自己当初被给了多长的寿命。它在下一次续期(token 至多 2 小时)之后就被带全字段的新记录取代。判定「是否该续期」的逻辑一个字都没改,仍然只看 expiresAt。(版本 2 的单公司记录同样照常可读,见第 3 条。)

  12. 服务地址列表管的是「下一次授权去哪」,不是「这一份凭据属于谁」。 它自己是一条 xrxs-settings 记录(格式版本 2),只有 POST /xrxs-auth/settings 会写它;每家公司自己的 apiURL / systemURL 记在授权记录里(格式版本 4),没有任何读取路径会拿列表去覆盖它们——所以编辑列表对已授权的公司既不是「迁移」也不是「失效」,它压根不碰凭据。格式版本 1 的两框记录({apiURL, systemURL})会被照读成一个在用的命名环境,升级不会把下一次登录挪回线上。

  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 不但不该被取用,而且本来就用不了——每一家都记着自己当初是在哪个域名授权的,之后再怎么改服务地址列表,请求也只会回到那个域名上,所以不会出现「拿 A 家的 token 去问 B 家」。顺便:两个部署连验证页域名都不同(s120.devtest.vip vs s.xinrenxinshi.com),这个地址来自服务端答复,插件不配置它,所以你在浏览器里看到哪个域名,就知道自己在给哪个环境确认。

  14. 生产环境的坐标已实测可用,但 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 那一半)。

  15. 换了 profile 配置要重启客户端才会生效。 客户端载入的是 profile 里的包产物,改代码后必须重新 npm pack(同时 bump version)再装一次;改 profile 自己的 cordis.patch.yml(地址、凭据引用)要重启。但面板里改服务地址列表不用重装也不用重启——它写的是运行时记录,下一次授权就读新的。

  16. 两个域名都跟着公司走,别写死、也别互相代替。 credentials() / session() 都返回 apiURL(api域名 = 机器授权地址)和 systemURL(薪人薪事系统域名 = 给人和页面用的地址)。二者都记在公司自己那条记录里(授权记录格式 4),所以:同一台机器上两家的凭据可以分属两个部署;session() 铸出来的三元组永远属于那份授权自己的部署,不会被设置里的改动带走。要发请求就用返回值里的 apiURL,要给人跳页面就用 systemURL——不要拿服务地址列表里那一行当答案,那是「下一次」去哪。唯一的缺口是直接改 profile 里的 oauth.baseURL 再重启:那只影响新授权,已有凭据照旧(它们记着自己的地址),所以换过地址想换部署,办法是重新授权一次,而不是等它自己迁移。


开发

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 | build:host(→ lib/)+ build:client(→ lib/client.js) | | npm run pack: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、两条续期生命周期、单飞、跨进程写锁、逐公司寻址
  settings.ts     内置部署表(host + 标签 + 部署词 + appKey)、服务地址列表的落盘格式与校验、session 端点路径
  transport.ts    无状态 wire 协议(设备码三端点 + session 铸造),transport 可注入
  console.ts      主机侧授权控制台:把一次尝试跑到后台,等面板来驱动
  routes.ts       挂在 webServer 上的路由(固定 7 条 + 设置面 + 公司面)+ 回环校验
  shell-env.ts    往 ctx.shellEnv 注册 DSH_XRXS_AUTH_ENDPOINT(端口每次调用现读)
  config.ts       配置面 + 默认值 + 校验
  grant.ts        自有的落盘格式与读取校验
  logo.ts         公司 logo 的本机缓存(目录 0700 / 文件 0600 / 上限 2 MiB)
  parse.ts        读 JSON 时那几个宽进严出的取值助手
  browser.ts      尽力而为地打开浏览器(永不抛错)
  types.ts        纯类型面(无 cordis 依赖),另有 `./types` 子路径
  error.ts        稳定失败码
  client/
    index.ts      浏览器半区:注册左侧栏的「薪人薪事令牌」按钮与面板(含服务地址列表卡片)
    ambient.d.ts  只声明真正用到的 react 成员,故无需安装 react
tests/            各分支语义用例,含真实 LocalCredentialProvider 的落盘往返
bak/              被替换掉的 OAuth 授权码流程实现(只读归档,不参与编译)
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 时的约定