Skip to content
dsh.fish
Bundle

dsh-account-switch

DeepSeek Harness 多账号插件:一次登录多个账号并随时切换活跃账号(热切换,无需重启)

Source
chenzi666
License
MIT
Updated
Updated 8 days ago

Readme

# dsh-account-switch

**DeepSeek Harness 多账号插件 —— 一次登录多个账号,随时热切换,不重启、不重新登录。**

> **这个插件解决的是最后一环。** DeepSeek Harness 官方活动:**新用户首次登录送 6 元额度**;接码平台一个手机号 **0.2–0.3 元**。账号多得起来、便宜得下来,但官方 UI 一次只认一个账号,退出还会注销 token —— 所以你需要一个东西把这些账号管住,让它们变成**一个连续可用的额度池**。那就是它。

切换后下一个模型请求立即用新账号。已登录的账号不会被顶掉——它们在凭证库里各自有一个存档槽位。

额度烧干时自动换到下一个还有钱的账号,并让失败的那一步**原地重跑**:会话不断、上下文不丢、工具状态不动。

```
切换前  default ──▶ 账号 A(已烧干)
切换后  default ──▶ 账号 B            ← 下一个请求直接生效,进程没重启过
```

---

## 功能

| 功能 | 说明 |
| --- | --- |
| **多账号共存** | 任意多个账号的 token 同时存在凭证库里,互不覆盖、互不顶号 |
| **热切换** | `dsh-account use <账号>` 立即生效,无需重启、无需重新登录 |
| **额度自动换号** | 当前账号额度用尽时自动切到下一个可用账号,并让失败的那一步原地重跑 |
| **账号冷却** | 刚被判没钱的账号进冷却(默认 5 分钟)不再被选中,避免来回横跳;充值后自动回到候选 |
| **切换留痕** | 每次自动换号写一行 JSON 日志(切到谁、什么错、哪个会话的第几步) |
| **指纹化存档** | 账号 id 由 `sha256(token)` 前缀派生,同一 grant 永远映射同一槽位,归档天然幂等 |
| **昵称管理** | 给账号起中文名,`use 主号` 比 `use acct-af3bded5` 好记 |
| **CLI + 插件 API** | `dsh-account` 命令行,另有 `ctx.accountSwitch` 供其它插件调用 |
| **零内核改动** | 全部走公开 seam,不 patch 官方包,Harness 升级不会把它冲掉 |
| **不额外存秘密** | `registry.json` 只有指纹;`list --json` 不输出 token |

---

## 先说清它做不到什么

**切换是全局的。**

Harness 的账号是一个进程级的单例服务:`ctx.deepseekAccount`(实现为 `PlatformAccount`)持有唯一的账号状态,`deepseek-account` 这个模型 provider 每次请求都向它要 token。官方把活跃账号硬编码在 `deepseek-account-platform/default` 这一个槽位上,没有任何按会话区分的维度。

所以:

- ✅ 多个账号的 token 可以共存,切换是瞬时的、热生效的
- ❌ **不能**让 A 会话用账号 1、B 会话用账号 2 同时跑——切了就是全切
- ❌ 额度用尽时的自动换号切的是**同一个全局槽位**。A 会话先撞上额度打满而换号,B 会话的下一个请求也会跟着用新账号。这是全局单例的语义,不是本插件的取舍

要「同时并行」只能开第二个 Harness 实例,让它用另一个 `DSH_HOME`(于是另一份 `.credentials.yaml`)。但 Desktop 的 Electron 单实例锁是否会放行第二份实例,需要实测,`--user-data-dir` 未必够。本插件不尝试绕过这一层。

如果哪天需要真正的按会话绑定,那要动的是内核里 `resolveToken()` 的取用维度——那是另一件事,不是插件能做的。

---

## 为什么走「注册新用户」这条路

**因为 DeepSeek Harness 官方活动:新用户首次登录送 6 元额度。**

这不是钻空子,是官方自己掏的拉新预算。**一个账号就是一个 6 元额度的容器**,而拿到它只需要一个能收验证码的手机号。

于是整条路的成本结构简单到离谱:

| 项目 | 金额 |
| --- | --- |
| 接码平台收一条验证码(一个手机号) | **0.2 – 0.3 元** |
| 新账号首次登录,官方赠送 | **6 元额度** |
| **净得** | **≈ 5.7 元额度** |
| **投入产出比** | **约 20 – 30 倍** |

注册 10 个号:成本约 2.5 元,到手约 57 元额度。
注册 30 个号:成本约 7.5 元,到手约 171 元额度。

**但账号越多,官方 UI 越不够用。**

它一次只认一个账号,已登录状态下只给一个「退出登录」按钮——而退出还会向平台**注销 token**,那个账号就废了。想切回去?重新走一遍登录。

所以最后一环必须有人补上:

1. **接码平台**解决「账号从哪来」——0.2–0.3 元一个,成本可以忽略
2. **dsh-account-switch**解决「账号怎么用」——一次登录全部存档,随时热切换,额度烧干自动换下一个并**原地重跑**

两者接起来,官方送的那 6 元额度才真正变成你可以连续使用的算力:额度用尽的那一刻,会话不会断,插件自己就换号了。

### 📱 接码:好助码 · 在线接码平台

| 项目 | 说明 |
| --- | --- |
| **单价** | **0.2 – 0.3 元 / 条**(按号段和项目浮动,下单页实时显示) |
| 覆盖 | 国内外主流号段,注册/登录类短信验证码 |
| 到货 | 网页实时收码,不用等、不用抢 |
| **注册入口** | **<https://h5.haozhuma.com/reg.html?action=weichen666>** |

**完整流程:**

1. 打开 <https://h5.haozhuma.com/reg.html?action=weichen666> 注册并充值(几十块够用很久)
2. 选号 → 拿到手机号 → 在 Harness 官方 UI 里用它登录新账号 → **6 元额度到账**
3. 插件自动把新账号归档入库(`autoCaptureOnSignIn: true`,无需手工操作)
4. `dsh-account park` 撤下它腾出位置,回到第 2 步换下一个号;重复 N 次
5. `dsh-account list` 看总账,`dsh-account use <昵称>` 随时切到任意一个

> 插件本身与接码平台无技术耦合——它只负责管账号,不负责造账号。手机号从哪来由你决定,这里给的是最省事的一条路。

---

## 它怎么做到的

全部走公开 seam,**不改内核、不 patch 官方包**,所以 Harness 升级不会把它冲掉。

三层事实:

1. **凭证服务是通用的。** `ctx.credentials` 按 `<scope>/<id>` 寻址,id 段只要求匹配 `/^[a-z][a-z0-9-]*$/`。官方只用了 `default` 和 `device`,剩下的 id 段没人占——`account-<指纹>` 就是我们的地盘。

2. **目录变了会自动广播。** `dsh-credentials-local` 带 fs watcher:`.credentials.yaml` 一变就 `reconcileFromDisk()`,对每条变化的记录发 `credentials/record-updated`。而 `PlatformAccount` 自己就监听这个事件。所以写文件 = 通知到位。

3. **token 没有跨请求缓存。** `PlatformAccount.resolveToken()` 每个请求都重读 `default`。所以覆盖那一行,下一个请求就是新账号。

于是切换的全部动作就是:**把目标槽位的内容写回 `default`**。

### 存档槽位

```yaml
version: 1
records:
  client-connection/browser-session: { ... }          # 官方持有,本插件不碰
  deepseek-account-platform/device:  { ... }          # 官方持有,本插件不碰
  deepseek-account-platform/default:                  # ← 活跃账号,官方唯一的读写口
    kind: grant
    payload: { version: 1, token: <当前账号>, issuer: https://platform.deepseek.com }
  deepseek-account-platform/account-acct-af3bded5:    # ← 本插件的存档槽位
    kind: grant
    payload: { version: 1, token: <账号 A>, issuer: ... }
  deepseek-account-platform/account-acct-f6b06859:    # ← 又一个存档槽位
    kind: grant
    payload: { version: 1, token: <账号 B>, issuer: ... }
refs:
  DEEPSEEK_API_KEY: sk-...                            # 官方持有,本插件不碰
```

账号 id 直接由 `sha256(token)` 的前缀派生,所以同一个 grant 永远映射到同一个 id——归档天然幂等,不需要一张需要维护的映射表。

昵称等元数据存在 `$DSH_HOME/accounts/registry.json`,**只放指纹,不放 token 明文**。之所以不塞进 `.credentials.yaml`:官方解析器对文档做严格白名单校验,顶层只允许 `version`/`refs`/`records`、每条记录只允许 `kind`/`payload`,多一个字段就让整篇解析失败——那是启动级故障。

---

## 安装

### 1. 把包放进 profile 的 node_modules

这个 profile 的 `node_modules` 是「解压出来的真实目录」结构——`.pnpm` 里只有一个 `lock.yaml`,`dsh-proxy-boot`、`dsh-web-search-ddg` 全是这么放的。所以**不要跑 pnpm / npm**:它们会按依赖树重建整个目录,把那些不在 lock 里的包清掉。

用脚本复制成**真实目录**:

```powershell
powershell -NoProfile -File scripts/sync-install.ps1
```

**必须是真实目录,不能用目录联接。** 这是实测结论,不是偏好:同一个包以 junction 形态放进 `node_modules` 时,Harness 根本不会加载它——连插件自己的 `apply()` 都不会被调用,而且不报任何错。把完全相同的文件复制成真实目录后立刻正常。当时靠三个对照探针(真实目录 / junction / 完整插件)一次重启锁死了这个结论:真实目录那个写出了留痕,另外两个什么都没写。原因在 `dsh-app-boot` 的 `linkedProfileRoots` 那一层,没有继续深挖。

代价是改了源码要重跑脚本。脚本会连同 `node_modules/yaml`(唯一的运行时依赖)一起复制——Harness 的解析层不会替插件装依赖。

卸载:`Remove-Item -Recurse` 掉那个目录,再从 patch 里删掉条目。

### 2. 在 profile 的 patch 里挂上插件行

`$DSH_HOME/profiles/desktop/cordis.patch.yml` 末尾追加:

```yaml
- insert:
    - id: dsh-account-switch
      name: dsh-account-switch
      config:
        enabled: true
        autoArchiveOnStart: true
        autoCaptureOnSignIn: true
        verbose: false
```

多个 `- insert:` 块都会被处理(`dsh-app-boot` 里是 `for (const patch of patches) patch.insert?.forEach(visit)`),所以它和 profile 里原有的 `proxy-boot` / `web-search-ddg` 那个 insert 块互不干扰。

### 3. 重启 Harness

实测改完 patch 后插件**没有**自动挂载——`patchReload: live` 不覆盖新增的插件行。需要重启一次。

### 关于 `@deepseek-ai/*`

插件里 `import '@deepseek-ai/cordis'` 和 `'@deepseek-ai/schemastery'` 由 **Harness 运行时提供**,部署时插件的 `node_modules` 里**不该**有 `@deepseek-ai` 目录。

这里踩过一个坑:npm 7+ 默认会自动安装 peerDependencies,而 `@deepseek-ai/cordis` 在公共 registry 上有包,`npm install` 就在插件下塞进了第二份 cordis。那意味着插件的 `Service` 基类来自另一个模块实例,跟宿主 ctx 不是同一个——挂载时才炸,而且只在运行时暴露。所以本仓库带了 `.npmrc`(`legacy-peer-deps=true`),插件目录里只应存在 `yaml`。

### 命令行

```
node bin/dsh-account.js list
```

安装时动过的东西:

| 路径 | 备份 |
| --- | --- |
| `$DSH_HOME/.credentials.yaml` | 同目录 `.pre-account-switch.bak` |
| `$DSH_HOME/profiles/desktop/cordis.patch.yml` | 同目录 `.pre-account-switch.bak` |
| `$DSH_HOME/profiles/desktop/package.json` | 未改动(不需要改) |
| `$DSH_HOME/profiles/desktop/node_modules/dsh-account-switch` | 新增真实目录 |

---

## 用法

### 加第二个账号

官方 UI 在已登录状态下只会给「退出登录」,而**退出会向平台注销 token**(`revokeAccount`),把存档一起废掉。所以用 `park`:

```
dsh-account list                       # 看看现在有什么
dsh-account park --label 主号          # 撤下当前账号,官方 UI 回到未登录态
                                       # token 未注销,存档完好
… 在 UI 里登录第二个账号 …             # 插件自动把它归档进账号库
dsh-account park --label 小号          # 想再加就继续
dsh-account list                       # 两个都在
```

`park` 之后插件也会在 default 变化时自动捕获新账号,不需要手工 `capture`。

### 切换

```
dsh-account use 主号          # 按昵称切(昵称必须唯一)
dsh-account use acct-af3bded5 # 按 id 切
```

切完下一个模型请求就用新账号。**不需要重启任何东西。**

### 额度用尽自动换号

不用手动盯着。当前账号额度烧干时,插件会自动切到下一个还有钱的账号,**并让失败的那一步原地重跑**——会话不断,上下文不丢,工具状态不动。

```
[account-switch] 账号额度用尽,已切到 acct-f6b06859(小号),本步将自动重跑
```

怎么做到的:官方 `dsh-agent-loop` 在模型请求失败后广播 `agent/request-error`,把「这次要不要重来」交给插件链决定——`dsh-llm-retry` 的退避重试就挂在这个点上。我们挂同一处,听到额度错误就换号,然后回一个 `{ kind: 'retry' }`,agent-loop 于是 `continue` 重跑这一步;而 `resolveToken()` 每次请求都重读 `default`,新账号的 token 自然生效。全程只用公开 seam,不改内核、不 patch 官方包。

认两个错误码,都由官方定义:`ACCOUNT_QUOTA`(账号侧专用)和 `QUOTA`(通用码,`status === 402` 或响应文本命中 `insufficient balance` / `quota exceeded` 一类措辞)。

冷却:**按账号记时间戳**。某个账号刚被判没钱,就会在 `quotaChainResetMs`(默认 5 分钟)内不再被选中——这同时解决两件事:所有账号都烧干时不会来回横跳(试完一轮就如实报错),以及充值之后过了冷却它又会自动回到候选里。

留痕在 `$DSH_HOME/accounts/quota-switch.log`,一行一条 JSON(切到谁、什么错、哪个会话的第几步)。换号是**你看不见的副作用**,不留痕的话事后查「账号怎么自己变了」会毫无头绪。

配置(`cordis.patch.yml` 的 `dsh-account-switch` 行里):

```yaml
- id: dsh-account-switch
  config:
    autoSwitchOnQuota: true     # 关掉它就回到「额度烧干即中断」的官方行为
    quotaChainResetMs: 300000   # 单个账号的冷却时长
```

### 添加账号:弹窗,而不是一点就跳浏览器

点击「添加账号」弹出一个对话框,三个按钮:`复制链接` / `打开浏览器` / `取消`。

链接只能由宿主向平台申请(PKCE 挑战、state、本地回调地址都在宿主侧),没有第二条路凭空造一条链接,所以两个按钮都会调同一个 `startSignIn`;区别只在拿到链接之后——一个进剪贴板,一个显式 `window.open` 交给系统浏览器。

**但 Desktop 外壳有一段常驻 watch,授权链接一出现就直接 `shell.openExternal`**(`app/lib/main.js` 里对账号状态的订阅:无条件、无开关、随进程存活)。那段代码不在插件里,插件层拦不住它。所以这套交互要真正成立,得给外壳加一个 21 字节的补丁:

```diff
- if (attempt?.phase === "waiting-browser" && attempt.authorizeUrl !== void 0 && openedAttempt !== attempt.id) {
+ if (attempt?.phase === "waiting-browser" && attempt.authorizeUrl !== void 0 && openedAttempt !== attempt.id && !enteredWorkspace) {
```

`enteredWorkspace` 是同作用域里已有的变量(进工作区时置 true,退回 welcome 时复位),所以这个补丁**不引入任何新状态、不需要新 import**;而且**首次引导的体验原样保留**——还没进工作区时,登录依然会自动打开浏览器。

装法(**必须先关掉 Harness**:归档被运行中的进程独占,替换会报「另一个进程正在使用此文件」):

```
powershell -NoProfile -File scripts/swap-desktop-asar.ps1
powershell -NoProfile -File scripts/swap-desktop-asar.ps1 -Revert   # 回滚
```

脚本会校验 SHA256、保留 `app.asar.pre-autoswitch.bak`、失败自动回滚。归档本身只长了 24 字节(21 字节补丁 + 对齐),其余 11470 个条目逐字节未动。

**官方更新会换掉 asar,补丁随之失效**——重打一次即可。

### 其它

```
dsh-account status                       # 状态与存储位置
dsh-account rename acct-af3bded5 工作号  # 改昵称
dsh-account remove 工作号                # 移除(活跃账号需要 --force)
dsh-account identify                     # 补全平台身份并合并重复记录
dsh-account list --json                  # 给脚本用
```

### 插件内使用

宿主侧暴露了 `ctx.accountSwitch`,任何插件都能调:

```js
await ctx.accountSwitch.overview()        // { accounts, activeId, warnings, ... }
await ctx.accountSwitch.use('主号')       // 切换
await ctx.accountSwitch.park()            // 撤下当前
await ctx.accountSwitch.capture()         // 归档当前
```

---

## 验证

```
node --test                    # 36 项单元测试,全部在隔离 tmp 目录里跑
node scripts/drill.mjs <隔离 home> --i-know-this-writes-files
```

`scripts/drill.mjs` 是拿一份真格式的凭证库副本跑完整流程(归档 → 幂等 → 模拟登录第二个 → 切换 → 校验官方持有的部分逐字未动)。它默认拒绝在 `$DSH_HOME` 上运行——这类工具最容易犯的错就是把真人正在用的凭证库当演练场。

另有一项只在装配时做的验证:用**内核自己那份** cordis / schemastery 加载插件,并让内核的 schemastery 实例去解析插件声明的 `Config`。这能一次性证伪「插件自带第二份 cordis、`Service` 基类跟宿主不是同一个」这类只在运行时才暴露的问题——`.npmrc` 里的 `legacy-peer-deps=true` 就是为了防它复发。

---

## 踩过的坑:目录联接会让包整个不被加载

一度把插件以 junction(`New-Item -ItemType Junction`)挂进 profile 的 `node_modules`,图的是改代码立即生效、不用同步。结果是:**Harness 完全不加载它**,连插件自己的 `apply()` 都不会被调用,全程没有任何报错——从外部看和「插件根本没装」一模一样。

定位靠三个对照探针,一次重启:

| 探针 | 形态 | 结果 |
| --- | --- | --- |
| `probe-min-dir` | 真实目录 | 写出了留痕 |
| `probe-min-link` | 目录联接 | 什么都没写 |
| 完整插件 | 目录联接 | 什么都没写 |

三个包除了形态之外没有别的差别。改成真实目录后立刻正常。

先前读代码时判断 junction 应该没问题(`canonicalPath` 走 `realpathSync`、`linkedProfileRoots` 会扫符号链接),是实验推翻了它。所以现在一律用 `scripts/sync-install.ps1` 复制真实目录,改完代码重跑一次。

---

## 踩过的坑:`export default` 会让插件静默失效

第一版装上去之后**什么都没发生**——没有产物、没有报错、凭证库纹丝不动。

原因在 `cordis-plugin-loader` 的 `unwrapExports()`:

```js
unwrapExports(exports) {
    if (isNullable(exports)) return exports;
    exports = exports.default ?? exports;   // ← default 优先于 apply
    if (!exports.__esModule) return exports;
    return exports.default ?? exports;
}
```

当时插件同时导出了 `apply` 和 `export default AccountSwitch`。加载器取走了那个**类**并把它当插件实例化:`super(ctx, 'accountSwitch')` 照常注册服务、**不报任何错**,而 `apply()` 从头到尾没被调用。对照 `dsh-proxy-boot`——它只有 `name` + `apply`,所以一切正常。

所以这个插件**不导出 `default`**,也不导出静态 `inject`(静态 inject 会让插件在依赖不就绪时静默挂起,连「我到底有没有被加载」都无从判断)。credentials 的等待改成 `apply` 里显式 `ctx.inject()`。

为了下次不用靠推理:`apply()` 的第一件事是写 `$DSH_HOME/accounts/load-probe.json`。它存在就等于 `apply` 真的被 cordis 调用了;不存在就说明插件压根没进加载列表——这两种失败在外部表现上一模一样,留痕是唯一能一刀切开它们的东西。

---

## 已知边界

| 情况 | 结果 |
| --- | --- |
| 官方 UI 点「退出登录」 | default 被删,**槽位不受影响**;但那次 `revoke` 会让该账号的 token 在服务端失效,切回去会 401 |
| token 过期或被平台拒绝 | 官方 `rejectToken()` 只清 default,槽位里的存档仍在(内容已失效,需要重新登录) |
| 切换时目标槽位为空 | 拒绝切换并保持原状——清掉 default 等于当场把登录弄丢 |
| default 里是坏记录 | 拒绝归档,只告警;坏记录交给官方处理,插件不替它做决定 |
| 有人在 Harness 之外改了凭证库 | 下次 `list` 会点名报出「槽位存在但注册表没有对应条目」 |
| 并发写 | CLI 与 Harness 抢的是**同一把锁**(`<file>.lock`、`wx` 创建、内容为 pid,与官方 `@deepseek-ai/dsh-atomic-write` 约定一致) |
| 账号库里只有一个账号 | 自动换号无事可做,额度错误照常报出——它不会凭空造一个账号出来 |
| 所有账号都烧干 | 一轮试完即收手,把原始错误如实交给官方;冷却期内不会重复切换,不会来回横跳 |
| 充值之后 | 过了 `quotaChainResetMs` 冷却,那个账号自动回到候选里,不需要重启也不需要手工清状态 |

---

## 安全

`.credentials.yaml` 里是 token 明文,跟官方行为一致(官方就是这么存的),所以保护它的手段就是文件权限。

**装之前值得知道的一件事**:这个文件的 ACL 里可能有 `CodexSandboxUsers: ReadAndExecute`。也就是说跑在 Harness 沙箱里的任何进程都能直接读走里面的 token 和 API key。要收紧就把那条继承来的 ACE 摘掉。

本插件自己不额外复制秘密:`registry.json` 只有指纹,`list --json` 也不输出 token。

**本仓库的忽略规则**:`.credentials.yaml`、`accounts/`、`registry.json`、`quota-switch.log`、`key_pool.json`、`*.token` 等一律在 `.gitignore` 里。克隆下来放到你自己的机器上,账号池是你自己的,不会跟着仓库走。

---

## License

MIT

Install

dsh plugin --profile web add github:chenzi666/dsh-account-switch

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source