跳到主要内容

xrxs-dsh-auth

已验证

xrxs-dsh-auth · v0.4.2 · MIT · Web 界面

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

安装

dsh plugin add xrxs-dsh-auth

用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。

源码

发布到 npm 但没有公开仓库。安装前请检查包内容。

标签

作者

说明文档

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() 默认答的那家;已经是默认的那行不显示这个按钮,改为挂一个 默认 徽标)和 解绑(只解这一家)。点「⋮」展开,再点一次、点面板别处、或按 Esc 收起,选完某个动作也会自动收起——常驻会把公司名挤掉,而悬停既不提示(不划过去就不知道有动作),又会让 解绑 在指针底下突然冒出来,容易点错。列表底部另有 解绑全部授权,只在已经授权过的时候出现——它比旁边两个按钮小一号,而且要点两下:第一下换成 确认解绑全部 + 取消 两个按钮,第二下才真解绑。
  • 环境这一列只在合并了几个部署时才画。公司名旁边一个小标签写着这个地址的名字——值是从该公司自己记着的 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 → check:peer
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 行不支持热挂载)。

内核版本兼容:peer 为什么写成 >=

DSH 内核(@deepseek-ai/dsh-app-boot)在装载 profile 时会逐个 bundle 检查它声明的 @deepseek-ai/dsh* peer 是否满足当前运行时版本,不满足就静默丢弃——不报错、不留日志。 症状是「设置→插件里显示已安装,重启后生效,但重启多少次入口都不出现」。

所以本包给 dsh-credentials / dsh-llm 写的是只有下界的 >=0.1.2-alpha.1,而不是 ^0.1.2-alpha.1:带 ^ 的范围自带一个上界(<0.2.0-0),内核一升到 0.2 就被拦; 「补一条线」写成 ^0.1.2-alpha.1 || ^0.2.0-rc.2 也只是把这一天推后到 0.3。只有下界则 任何未来版本都进得来。代价是内核真有破坏性改动时本插件会照常加载、然后自己报错—— 比「静默消失」好排查得多。

npm run check:peer(已挂在 npm run check 末尾)直接调用内核自己的判定函数,在打包前 拿本包的 package.json 验一遍,被拦就报红;本机没装 DSH Desktop 时自动跳过(CI 不会误报)。 动 peer 之前先跑它。

(只有 @deepseek-ai/dsh 与 @deepseek-ai/dsh-* 前缀的 peer 受这套检查;cordis、 schemastery 不受。)

配置写在哪

不要改本包里的 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 时的约定