# dsh-git-credentials [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-plugin-4b32c3)](https://github.com/deepseek-ai/deepseek-harness) [English](README.md) · 简体中文 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 的独立外挂插件:管理 GitLab、GitHub、Gitee、Gitea 与 Bitbucket 的 API token,**token 值永不进入大模型的上下文**。 模型工具只携带**站点 id**(如 `corp`),该站点自己的 token 在每次调用时才从插件的加密存储中解密一次,只出现在发出的 HTTP 请求头里。修改站点或轮换 token 后下一次调用立即生效,无需重启。 ## 特性 - **token 不进入模型上下文**——工具参数、返回值、错误信息里只有业务数据(`site`、`project`、`path` 等) - **磁盘静态加密**——AES-256-GCM 整体加密的数据文件 + 独立的 32 字节随机密钥文件(`0600`、原子写入) - **按 provider 限定工具**——`gitlab_*` 只见 GitLab 站点,`github_*` 只见 GitHub 站点,`gitee_*` / `gitea_*` / `bitbucket_*` 同理;未配置的 site/token 响亮失败并列出合法值 - **Web 设置面板**——添加/编辑/删除站点、写入或清除 token 值;**任何响应都不携带 token 值**,只报配置状态 - **动态加载/卸载**——运行中的 GUI 直接热挂载/热卸载,无需重启 - **即时生效**——每次工具调用读一份解密快照,改动和轮换立即生效 ## 为什么不用 MCP server? GitHub 发布了官方 MCP server,harness 也原生支持 MCP 客户端——如果只需要 GitHub,接入官方 MCP server 是更主流的选择,本插件的 `github_*` 工具确实与它功能重叠。 本插件的价值在 MCP 覆盖不到的地方: | | 官方 GitHub MCP | 本插件 | |---|---|---| | 覆盖平台 | 只有 GitHub(GitLab 有官方 server;Gitee / Gitea / Bitbucket 依赖第三方 server,质量与维护参差) | 一个加密存储、一个设置面板、一套工具管 GitLab、GitHub、Gitee、Gitea、Bitbucket——含自托管 Gitea / GitLab | | token 处理 | 每台 server 环境变量明文配置,无管理界面 | AES-256-GCM 加密存储 + 一个站点一个 token + 设置页管理;token 值永不进入模型上下文 | | 集成形态 | 多一层 MCP 代理进程 | 工具直接注册进 harness 工具注册表 | 单一托管平台 + 标准 token 处理,走 MCP 即可;多 forge(尤其是 Gitee、自托管 Gitea)、或想要加密存储 + 产品内管理页面时,用本插件。 ## 安全模型 ### 存储 - `~/.dsh/git-credentials.json` — 数据文件,AES-256-GCM 整体加密(`0600`、原子写入) - `~/.dsh/git-credentials.key` — 32 字节随机密钥,独立文件存放(`0600`) ### 威胁模型 | 场景 | 是否防护 | |---|---| | 有人拷走/备份/同步**数据文件** | ✅ 是——只有密文,没有密钥文件解不开 | | **同 UID 进程**(如 agent 的 bash/fs 工具)读取两个文件 | ❌ 否——密钥与数据同权限,与产品自身密钥处理同级("discretion, not a boundary") | | **用户主动**让模型去读文件 | ❌ 否——不在防护范围内,任何系统都拦不住 | 密钥文件丢失 = 数据不可解(解密失败会响亮报错并提示密钥路径);数据文件单独被拷走 = 安全。 ## 安装 ### 方式 A:安装 release tarball(推荐) 从 [releases 页面](https://github.com/revive/dsh-git-credentials/releases) 下载 `dsh-git-credentials-.tgz`——tarball 自带构建好的浏览器 bundle,无需 harness 检出、无需构建——然后用 `dsh` CLI 装进 profile: ```sh dsh plugin --profile add ./dsh-git-credentials-0.4.0.tgz ``` 首次使用会初始化 profile、pnpm 链接包,`dsh` 自动把插件追加进 profile 的 bundle 层。不 boot 先验证层: ```sh dsh --profile --dump-config # 找 "# == dsh-git-credentials" ``` > 安装 bundle **不会**热挂载到运行中的 GUI:bundle 层在启动时组合(HMR 只热应用 patch 文件),`dsh plugin add` 之后必须**重启 GUI 进程**。重启后在 **设置 → Git 凭据** 即可看到插件分区。 ### 方式 B:从源码检出安装 插件是纯外挂,产品代码零改动,`~/.dsh` 下只需两处: 1. 符号链接插件目录,让所有 profile 都能解析包名: ```sh mkdir -p ~/.dsh/profiles/node_modules ln -s /path/to/dsh-git-credentials ~/.dsh/profiles/node_modules/dsh-git-credentials ``` 2. 在 home 层覆盖文件 `~/.dsh/cordis.patch.yml` 中追加插件行(对 web/headless 等所有 profile 生效): ```yaml - insert: - id: git-credentials name: 'dsh-git-credentials' ``` HMR watcher 监控 home 层:加行 = 热挂载(运行中的 GUI 直接生效),删行 / `disabled: true` = 热卸载,改配置 = 热重配。卸载即删掉这两处。 > 浏览器半(`lib/client.js`)是构建产物——克隆后先构建(见[开发](#开发));release tarball 已包含构建产物。 ## 用法 在 **设置 → Git 凭据** 中管理站点与 token: - **添加站点**:表单在列表下方独立的「新增站点」卡片里,含站点 id、provider(GitLab / GitHub / Gitee / Gitea / Bitbucket)、API 地址(各 provider 默认值:`https://api.github.com`、`https://gitee.com/api/v5`、`https://api.bitbucket.org/2.0`;GitLab 与 Gitea 是自托管,需自己填地址,如 `https://gitlab.example.com` / `https://gitea.example.com/api/v1`)、该站点的 token 与默认项目(可选)。一个「保存」把站点与 token 一起写入;字段规则(站点 id 字符集、http(s) 地址)直接写在输入框下方,草稿不合法时说明问题并禁用「保存」,不必等服务器报错 - **每个已保存的站点**:默认在自己的卡片里只读展示(provider、地址、默认项目、token 状态),操作为「编辑」与「删除站点」;编辑态显示各输入框及「保存 / 取消」,一次保存同时提交字段与新填的 token,清除 token 用 token 字段旁的「清除 token」;删除站点会连同它的 token 一起删除 - **一个站点一个 token**:token 属于保存它的那个站点,既不会比站点活得更久,也不会被误共享 - **token 帮助**:token 字段旁有「获取 token ↗」直达对应平台的创建页——GitHub 的 fine-grained PAT 页(预填名称)、Gitee 与 Bitbucket 的固定页面、GitLab 与 Gitea 由站点 API 地址推导(地址为空或不合法时隐藏链接);token 未配置时字段还会写明该平台的最低 scope/权限。链接在本地生成,不携带 token - 面板跟随 Harness 的界面语言:文案与导航标题都通过客户端 locale 服务注册了中文与英文 - 面板通过同源 `/git-credentials-admin/*` JSON 端点读写加密存储;任何响应都不携带 token 值 - 所有改动即时生效——每次工具调用读一份解密快照 ### 工具 每个平台一个资源工具,`action` 参数选择操作。list 类 action 返回该平台的规范摘要形状(仓库:`{id, path, name, webUrl, visibility}`;issue/PR:`{number|iid, title, state, webUrl, authorName}`;文件:`{path, ref, content, truncated}`)。`get` 完整读取单个 issue 或 MR/PR——在摘要之外附 `body`、`labels`、`createdAt`、`updatedAt`、`bodyTruncated`,MR/PR 另有 `sourceBranch`、`targetBranch`、`draft`;正文按配置的字节上限截断,被截断时会显式标记。`comments` 列出某个 issue/PR/MR 的评论,`comment` 发表评论;两者都返回 `{id, body, authorName, createdAt, updatedAt, webUrl, bodyTruncated}`,正文同样按上限截断。写操作会真实修改远端——模型调用前应与用户确认。 | 工具 | `action` | 参数 | |---|---|---| | `gitlab_projects` | `list`、`create` | list: `search?`、`membership?`、`perPage?` · create: `name`、`namespace?`、`description?`、`path?`、`visibility?` | | `gitlab_file` | `read` | `project`、`path`、`ref?` | | `gitlab_merge_requests` | `list`、`get`、`create`、`merge`、`close`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`sourceBranch`、`targetBranch`、`body?` | | `gitlab_issues` | `list`、`get`、`create`、`close`、`reopen`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`body?` | | `github_repos` | `list`、`create` | list: `search?`、`perPage?` · create: `name`、`description?`、`private?` | | `github_file` | `read` | `project`(owner/repo)、`path`、`ref?` | | `github_pull_requests` | `list`、`get`、`create`、`merge`、`close`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`head`、`base`、`body?` | | `github_issues` | `list`、`get`、`create`、`close`、`reopen`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`body?` | | `gitee_repos` | `list`、`create` | list: `search?`、`perPage?` · create: `name`、`description?`、`private?` | | `gitee_file` | `read` | `project`(owner/repo)、`path`、`ref?` | | `gitee_pull_requests` | `list`、`get`、`create`、`merge`、`close`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`head`、`base`、`body?` | | `gitee_issues` | `list`、`get`、`create`、`close`、`reopen`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`body?` | | `gitea_repos` | `list`、`create` | list: `search?`、`perPage?` · create: `name`、`description?`、`private?` | | `gitea_file` | `read` | `project`(owner/repo)、`path`、`ref?` | | `gitea_pull_requests` | `list`、`get`、`create`、`merge`、`close`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`head`、`base`、`body?` | | `gitea_issues` | `list`、`get`、`create`、`close`、`reopen`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`body?` | | `gitlab_releases` | `list`、`create`、`delete` | `project?`、`perPage?`、`tag`(create 必需;GitLab 按 tag 删除)、`name?`、`body?`、`draft?`、`prerelease?` | | `github_releases` | `list`、`create`、`delete` | `project?`、`perPage?`、`tag`、`number`(release id,delete 必需)、`name?`、`body?`、`draft?`、`prerelease?` | | `gitee_releases` | `list`、`create`、`delete` | `project?`、`perPage?`、`tag`、`number`(release id,delete 必需)、`name?`、`body?`、`draft?`、`prerelease?` | | `gitea_releases` | `list`、`create`、`delete` | `project?`、`perPage?`、`tag`、`number`(release id,delete 必需)、`name?`、`body?`、`draft?`、`prerelease?` | | `bitbucket_repos` | `list`、`create` | list: `search?`、`perPage?` · create: `name`、`description?`、`private?` | | `bitbucket_file` | `read` | `project`(workspace/repo)、`path`、`ref?` | | `bitbucket_pull_requests` | `list`、`get`、`create`、`merge`、`close`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`head`、`base`、`body?` | | `bitbucket_issues` | `list`、`get`、`create`、`close`、`reopen`、`comment`、`comments` | `project?`、`state?`、`perPage?`、`number`、`title`、`body?` | - `action` 默认读操作(`list`;file 为 `read`)——现有读调用不受影响 - `number` 是 issue/PR 编号(GitLab 为 iid);`get` / `close` / `reopen` / `comment` / `comments` / `merge` 必需 - Gitee 的 issue 编号是字符串(如 `IKRZSO`):`gitee_issues` 的 `number` 同时接受整数和这种字符串。Gitee 的 issue 创建/状态变更走 owner 级端点(客户端在 body 里带 `repo`) - `comments` 与 `comment` 同时覆盖 issue 和 PR/MR:GitHub、Gitea 的 PR 普通评论走 issue-comments 端点;GitLab 读 notes 并过滤 system note;Gitee 读 issue 或 PR 评论(PR 创建评论的响应很稀疏,会回读列表补全);Bitbucket 跳过已删除的评论 - 所有客户端都保留站点 API 地址中的路径段(Gitee `/api/v5`、Gitea `/api/v1`、Bitbucket `/2.0`、GitHub Enterprise `/api/v3`)——不能用绝对端点路径去解析 base,否则会丢掉这段前缀 - `state` 取值:GitLab `opened`/`closed`/`all`(MR 另有 `merged`),其余平台 `open`/`closed`/`all` - `file` 恒为读取:`project`、`path`、`ref?`(默认分支;超过字节上限截断并标记) - `bitbucket_repos` 的 create 需要站点 `defaultProject`(`workspace/repo`)才能确定 workspace - `gitlab_projects` 的 create 可传 `namespace`(组的完整路径,如 `pandax` 或 `team/sub`,或组的数字 id):客户端先把它解析成 `namespace_id` 再 `POST /projects`;不传则建在 token 所有者名下。这是唯一能指定归属的 create——其他平台的 create 始终落在 token 所有者名下(Bitbucket 为 `defaultProject` 的 workspace) - Bitbucket 没有 releases API,因此没有 `bitbucket_releases` 工具;release 的 delete 用 release `number`(GitLab 按 `tag` 删除) - 一个站点恰好拥有一个 token,以站点 id 为键存储:删除站点会连同 token 一起删除,token 也不会在站点之间共享。旧版本写下的存储(站点通过引用名共享 token)会在读取时自动迁移——每个站点引用到的值成为它自己的 token,没有任何站点引用的引用名会被丢弃 - GitLab 用 `PRIVATE-TOKEN` 头;GitHub、Gitee、Bitbucket 用 `Authorization: Bearer`(Gitee 在头被拒绝时自动兜底 `access_token` URL 参数);Gitea 用 `Authorization: token` - HTTP 走 Node 内置 `fetch` 直连——刻意不用 `ctx.web.fetch`(只收 URL、无 header) ## 工作原理 ``` ~/.dsh/git-credentials.json(AES-256-GCM 加密:站点 + token 值) → 工具执行时解密一份快照,按 provider 过滤站点,读取该站点自己的 token → fetch(baseUrl/, { headers: { PRIVATE-TOKEN | Bearer | token } }) → 工具参数/返回值/错误信息里只有 site、project、path 等业务数据 ``` ## 开发 前置条件:一份 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) 检出。开发工具链由 harness 提供:把 `DSH_REPO` 指向检出目录,并将其 `node_modules/.bin` 加入 `PATH`(`@deepseek-ai/*` 为私有包,通过 harness 的 tsconfig paths 解析)。 浏览器半边面向当前的客户端 slot 标准:面板是 `settings.section` 列表项,组件接收组合后的 section props;typecheck 程序通过 type-only import 引入 slot 契约。请针对实际运行的检出做类型检查——切换 harness 版本后重新生成 `tsconfig.json`。尚未构建客户端 face 的检出会回退到这些包的源码;如果报错指向 `packages/.../src`,先在检出里跑 `pnpm run build:lib:client` 构建该 face,再重新检查。 **面板自带控件实现与文案。** `src/client/panel-css.ts` 把面板旁边那些宿主页面的控件尺寸、focus 行为与列表节奏抄进插件(类名统一加 `dshgc-` 前缀),与宿主共享的只有 `--dsw-*` 主题 token,因此浅色/深色自动跟随。**刻意不以模块方式 import** `@deepseek-ai/dsh-client-ui-primitives` 等 Harness Client 包:它们会无预警地变动,而组件抛错会让整个 slot 条目变空白。`dsh.client.inject` 只用于排序激活,仍然允许。所有可见文案都通过客户端 locale 服务注册(`src/client/locales.ts`,中文 + 英文),组件里用注册项注入的 `t` 读取,因此面板正文与导航标题都跟随 Harness 的界面语言。 **harness 兼容性由 manifest 强制把关。** DSH 会读取本包的 `peerDependencies` 中所有 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-*` 声明,只要有一个 range 不匹配当前运行的 harness 版本(含预发布版),就拒绝应用该 bundle 层:被拒的 bundle 不贡献任何工具与设置页,harness 会报告被拒的 peer。`@deepseek-ai/dsh-tools` 的 range 有意放宽为 `>=0.1.7-rc.1 <1.0.0`,使 0.x 线内的 harness 升级本身不会导致 bundle 被拒(harness 各包与产品版本同步发布)。因此遇到新 harness 版本时的流程是:重新生成 `tsconfig.json`、跑 `pnpm typecheck` 与 `pnpm smoke`;只有当这些真的失败时才动 range,并把 `<1.0.0` 当作重新验证的边界。不安装也能校验——这个检查只读 manifest: ```sh node --input-type=module -e " import { readFileSync } from 'node:fs' const { evaluatePluginCompatibility, pluginCompatibilityWarning } = await import('$DSH_REPO/packages/boot/app-boot/lib/index.js') const manifest = JSON.parse(readFileSync('./package.json', 'utf8')) const issue = evaluatePluginCompatibility(manifest) console.log(issue === undefined ? 'compatible' : pluginCompatibilityWarning(issue)) " ``` ```sh export DSH_REPO=/path/to/deepseek-harness export PATH="$DSH_REPO/node_modules/.bin:$PATH" # 为当前检出重新生成 tsconfig.json paths(已被 gitignore——机器相关) pnpm gen:tsconfig # 类型检查(含 browser half) pnpm typecheck # keyless 冒烟:加密存储回读 + boot 断言 + 未配置 token 响亮失败(无网络、无模型 key) DSH_REPO="$DSH_REPO" TSX_TSCONFIG_PATH="$DSH_REPO/tsconfig.json" \ node --import "$DSH_REPO/node_modules/tsx/dist/esm/index.mjs" smoke.ts # 改过 src/client/ 后重建浏览器 bundle(运行中的 GUI 自动热替换) pnpm build ``` - **组合/配置层**:HMR 自动重组合,改完立即生效 - **client bundle**:webserver 轮询 + client-hmr 广播,重建后浏览器自动热替换 - **host 插件源码**:没有热更通道(Node 持有模块缓存,产品自身也没有 host 侧 watch);且包入口已是构建产物 `lib/index.js`,host 侧改动需先 `pnpm build` 再重启——或用「目录改名」技巧让模块 URL 全变,零重启热生效 `$DSH_REPO/node_modules/.bin/tsdown` 是 shell 包装脚本——直接执行(`pnpm build` 即如此),不要用 `node .../.bin/tsdown`。 ## 目录结构 ``` git-credentials/ package.json # dsh-git-credentials;peer: @deepseek-ai/{cordis,dsh-tools,dsh-schemastery} # dsh.client 清单 + exports["./client"](browser half) cordis.patch.yml # bundle 补丁层(dsh.bundle.patch)——同时也是开发用 --patch 覆盖层 tsdown.config.ts # 自包含构建(node 半 + 浏览器 bundle,不依赖 harness 检出) smoke.ts # keyless 启动冒烟(含加密存储回读断言) tools/gen-tsconfig.mjs # 重新生成 tsconfig.json paths(DSH_REPO 驱动) src/index.ts # 插件入口:24 个工具注册 + 管理路由接线 src/store.ts # AES-256-GCM 加密存储(独立密钥、原子写、0600) src/http.ts # 共享 HTTP 助手(token 解析、分页、错误明细) src/gitlab.ts # GitLabClient(PRIVATE-TOKEN 头) src/github.ts # GitHubClient(Bearer 头 + User-Agent) src/gitee.ts # GiteeClient(Bearer 头,access_token URL 兜底) src/gitea.ts # GiteaClient(token 头) src/bitbucket.ts # BitbucketClient(Bearer 头,2.0 API) src/admin.ts # /git-credentials-admin/* 管理端点 src/client/ # browser half:设置页 Git 凭据分区 lib/ # 构建产物(node 半 + client bundle,已 gitignore) CHANGELOG.md # 逐版本变更;每次 release body 的来源 ``` ## 发布 包已按 dsh **bundle** 形态组织:`dsh.bundle.patch` 指向 `cordis.patch.yml`,用户执行 `dsh plugin --profile add dsh-git-credentials` 即可安装并加入 profile 的 bundle 层。运行时通过安装自身的 flat fallback(`$DSH_HOME/profiles/node_modules`)解析插件的 `@deepseek-ai/*` 依赖,因此 peerDependencies 声明的是 **npm 已发布版本线**(`@deepseek-ai/cordis ^4.0.1-rc.1`、`@deepseek-ai/dsh-tools >=0.1.7-rc.1 <1.0.0`、`@deepseek-ai/schemastery ^3.18.1-rc.1`)——切勿用开发工作区的 `0.1.0-rc.5` 版本。harness 各包与产品版本同步发布,且 DSH 只在这些 range 与当前 harness 匹配时才准入该 bundle;`dsh-tools` 的 range 有意覆盖整条 0.x 线,因此单纯的 harness 升级不会迫使插件发版(见「开发」)。 两个渠道分发同一份打包产物: - **npm** —— `pnpm publish` 把构建好的包发布到公共 registry;`publishConfig` 已固定 `https://registry.npmjs.org/`,本地 npm 客户端即使配了镜像也不会发错地方。`lib/` 被 gitignore 但在 `files` 白名单里,所以**必须先构建**: ```sh pnpm build pnpm publish ``` 用户随后用 `dsh plugin --profile add dsh-git-credentials` 安装。 - **GitHub release** —— 推送 `v*` tag 会触发 [GitHub Actions](.github/workflows/release.yml) 在云端构建 node 半 + 浏览器 bundle、打包 tarball 并挂到 release。同一流程手动执行: ```sh pnpm build pnpm pack # -> dsh-git-credentials-.tgz ``` 把该 tarball 挂到 release,或本地直接安装: ```sh dsh plugin --profile add ./dsh-git-credentials-.tgz ``` **Release notes 来自 `CHANGELOG.md`。** 推送 `v*` tag 时,CI 会取该文件里对应版本段落的正文作为 release body,并追加统一的安装 footer(`.github/scripts/compose-release-notes.sh`);某版本没有段落时回退到 GitHub 自动生成的 notes,因此不会因为漏写 changelog 卡住发布。打 tag 之前,请在版本号提交里把 `[Unreleased]` 的内容移入 `## [] - <日期>` 段落,这样 tag 指向的树里就带着这份 notes。想本地预览某个版本的正文:`bash .github/scripts/compose-release-notes.sh `。 要重算**已经发布**的 release 的 notes(例如补齐早于这套流程的版本),手动运行 `release` workflow 并填 `version` 输入即可:它从默认分支读取 `CHANGELOG.md`、只重写 release body,不动已附带的 tarball 资产。 对外公布前先在本地验证产物:`dsh plugin --profile add `,确认 `dsh --profile --dump-config` 出现 `# == dsh-git-credentials` 层,再 boot profile 检查 24 个工具是否注册。 ## License [MIT](LICENSE)