xrxs-dsh-auth
v0.4.1
Published
薪人薪事(Xinrenxinshi)授权插件(DeepSeek Harness 外部 bundle):机器认证授权(设备码流程)拿 token、access_token 生命周期管理,并向其他插件提供校验有效的 sessionId / ua / csrf。
Maintainers
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域名与薪人薪事系统域名走同一条链,从前往后第一个有值的赢:
- 在用的那一行服务地址(面板 → 设置里选中的那行;选中的是内置行时退到第 2 步)
- profile 配置:
oauth.baseURL/system.url - 内置默认:
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,面板本来就挂不上去)。
它解决的五件事
- 授权:走机器认证授权(设备码)流程拿到
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 小时)就在下一次取用时重新铸一份。两条规则都不需要调用方维护任何时钟,且按公司各算各的:两家的 refresh token 是两个东西,一家的 token 失效与另一家无关。 - 消费者接口:另一个插件(例如会议纪要插件)通过
ctx.xrxsAuth.credentials()取accessToken、通过ctx.xrxsAuth.session()取用已被校验的sessionId/ua/csrf三元组。两者的返回值都带有效期信息(lifetimeMs寿命、remainingMs剩余、lifetimeSource来源),所以调用方既不用自己判断该不该续期,也不用猜这张凭证能撑多久。它不需要、也不应该去读配置文件或凭据文件——那份文件里可能躺着一份已经失效的登录态,或者好几家公司的登录态。 - 多家公司:一台机器上同时持有几家的授权,
credentials()/session()默认答当前选中的那家(老调用因此完全不用改),需要指定时把公司 id 传进去(credentials({ companyId }))。面板上能看到都有哪几家、逐条撤销、切换「当前」;status().companies与companies()是给界面读的那份列表——按部署排好序(线上 → 灰度 → 测试 → 人自己命名的那几个,按人写的顺序),每一项带一个environment。 - 按需授权(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()时在后台发起的。那种情形下,这个点要等本插件的面板或按钮再被读一次才会跟上。这是「点不需要轮询」唯一不成立的地方,也是它仍然值得留一句注释的原因。
面板内容刻意收得很紧(卡片宽 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_tokenbody: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.scopeClientID / 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-authdsh 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):这一次调用会自己发起授权,并等人确认。
- 本插件把设备码流程跑起来——验证码与验证页写进插件日志,浏览器被打开;
- 你的调用挂在那儿等人,上限
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域名:这份凭据自己的部署地址——当初在哪家授权的就永远是那个,之后再改服务地址列表也不会变。发请求、或要跟人解释「这个 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)不会,服务端抖动不该消耗一次续期。
已知限制与待核对项
设备码流程按《薪人薪事机器认证授权接口》实现。 三个端点路径(
/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,避免服务端故障消耗一次续期。授权记录格式版本升到 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。本插件不挂载任何 tool,模型没有可直接调的工具。人侧入口是左侧栏底部「薪人薪事令牌」按钮;程序侧入口有两条:同 profile 的其他插件调
ctx.xrxsAuth,以及模型 shell 调用里的 CLI 读DSH_XRXS_AUTH_ENDPOINT再打那几个路由(见「让 CLI 直接取已授权的凭据」)。第二条是唯一一条「模型起进程、进程自己取凭据」的路——凭据落在那个进程里,不由模型搬运;CLI 那边应当把令牌直接喂给自己的请求,不要打印到 stdout。浏览器半区没有单测,只有产物冒烟。
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是后来新增的可选字段,旧记录照常可读——但读回来的投影里它们会是undefined,因为那份记录确实不知道自己当初被给了多长的寿命。它在下一次续期(token 至多 2 小时)之后就被带全字段的新记录取代。判定「是否该续期」的逻辑一个字都没改,仍然只看expiresAt。(版本 2 的单公司记录同样照常可读,见第 3 条。)服务地址列表管的是「下一次授权去哪」,不是「这一份凭据属于谁」。 它自己是一条
xrxs-settings记录(格式版本 2),只有POST /xrxs-auth/settings会写它;每家公司自己的apiURL/systemURL记在授权记录里(格式版本 4),没有任何读取路径会拿列表去覆盖它们——所以编辑列表对已授权的公司既不是「迁移」也不是「失效」,它压根不碰凭据。格式版本 1 的两框记录({apiURL, systemURL})会被照读成一个在用的命名环境,升级不会把下一次登录挪回线上。环境之间的隔离是服务端强制的,不只是本插件的约定。 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 不但不该被取用,而且本来就用不了——每一家都记着自己当初是在哪个域名授权的,之后再怎么改服务地址列表,请求也只会回到那个域名上,所以不会出现「拿 A 家的 token 去问 B 家」。顺便:两个部署连验证页域名都不同(
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 那一半)。换了 profile 配置要重启客户端才会生效。 客户端载入的是 profile 里的包产物,改代码后必须重新
npm pack(同时 bump version)再装一次;改 profile 自己的cordis.patch.yml(地址、凭据引用)要重启。但面板里改服务地址列表不用重装也不用重启——它写的是运行时记录,下一次授权就读新的。两个域名都跟着公司走,别写死、也别互相代替。
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 时的约定