高级治理阅读约 24 分钟最后核对 2026-09-20

凭据与密钥

SparkWork 凭据与密钥管理完整指南:密钥实际有四种落点(共享 keystore 的 macOS 集中 vault、第二个 keytar 实现 TokenStore、SQLite app_settings 明文、CLI 的 credentials.json),逐层讲清各自的读写路径与保护边界。覆盖 keystore 的常量与五个 API、critical 的 legacyChecked 惰性迁移与 deleteSecret 语义、keystore_ref 列清单与 tool_package_config 的 DB 级互斥约束、ref 命名规则总表、两份同名 maskSecret 的差异、Provider 凭据从表单到真实请求的全链路(空串写库的语义坑、provider:get-api-key 的明文回显与受管 Provider 硬拒、导出会把明文 Key 写盘、测试连接会真的发请求)、平台账号与 MCP OAuth 的 TokenStore 细节(含备份文件被两个命名空间共用导致取消 MCP 授权会删掉登录备份)、三处明文落点(IM 机器人凭据还会明文回显到普通输入框、会话与项目环境变量、MCP config_json)、声明与填值分离的三条面(自定义工具 secretRefs 全生命周期、工具包 secret 环境变量与一次性安全输入、子应用连接槽)、脱敏覆盖面、删除与清理边界、备份导出与账号同步各路径的密钥行为,附 24 行排查表与事实边界。

凭据与密钥管理是 Spark Work 里最容易被误解的一块:官方文档、MCP 工具描述、界面文案 三处都在说「密钥进系统 Keychain」,但真实代码里一共有四种互不相同的落点, 其中两种是明文的。这一页按代码把每个密钥「谁写的、写到哪、谁能读、什么时候删」 逐个讲清楚。

先给四条最容易被写错的结论:

  • keystore 不是唯一的 keytar 入口。包注释写着「唯一合法的 keytar 调用入口」, 但登录态用的是另一个独立实现TokenStore,它自己直连 keytar, service 名是 SparkAgent.CloudAuth,与集中 vault 完全不相干。
  • macOS 上「系统 Keychain」的说法不准确。macOS 独有一条集中 vault 路径: 所有 ref 合并成一个 JSON,由 ElectronsafeStorage加密后写成credential-vault-v1.enc(权限 0600)。Keychain 只在首次导入时被读一次。
  • 有两处密钥是明文存进 SQLite 的:IM 机器人凭据(存app_settings)和会话/项目环境变量(也存app_settings)。 前者还会通过 IPC 明文回传给渲染进程,并在设置页用普通输入框展示。
  • Spark CLI 与桌面端完全不共用凭据。CLI 把账号 token 与 refresh token 以明文 JSON 写在~/.spark/credentials.json,只靠 POSIX 0600 保护;spark-engine里没有任何 keytar / safeStorage 代码。

如果你只想知道「我的某一个密钥在哪」,直接跳第 2 节的落点总表; 想知道「为什么删了工具密钥还在」,看第 12 节;想排查具体故障,看第 14 节的 24 行排查表。

1. 四种落点:先建立正确的心理模型

代码里出现的「凭据」实际上落在四个地方。分清它们,后面所有疑问都能自解。

落点典型内容保护方式代码位置
① 共享 keystoreProvider API Key、连接器账号、自定义工具密钥、工具包 secret、快照库主密钥、平台模型令牌macOS:safeStorage 加密的集中 vault 文件(0600);其他平台:keytar 后端packages/shared/src/keystore/index.ts
② TokenStore(另一个 keytar 实现)Spark 账号 access / refresh token、userId、每个 MCP 服务器的 OAuth 令牌与 client secretkeytar + 一份 safeStorage 加密的备份文件cloud-auth-session.encapps/desktop/src/main/services/Auth/TokenStore.ts
③ SQLiteapp_settings明文IM 机器人凭据(bot token / app secret)、会话与项目环境变量、MCP 服务器 config_json无加密,与其它设置项同样以 JSON 文本入库packages/storage/src/repositories/settings.repository.ts
④ CLI 的 credentials.jsonCLI 登录态(token + refreshToken + userId)无加密,仅文件权限 0600 + 目录 0700spark-engine/src/platform/credentials.ts

1.1 为什么会有四种,而不是一种

这不是疏漏叠加,而是三个真实约束的产物:

  • 凭据形态不同。Provider 是「一个渠道一个 Key」,适合按 ref 分片; 账号登录态是「一次登录、一个会话」,天然是一个整体,所以走独立的 TokenStore。
  • 进程边界不同。CLI 在终端里跑,没有 Electron,也没有 keytar 的可用保证 (spark-engine全目录无 keytar 引用),所以只能退到 0600 的明文文件。
  • 历史选择不同。IM 机器人凭据是跟着「设置项」这套机制长出来的, 而设置项统一明文入库;后来接入的连接器才改用 keystore 引用。

这三条不是「设计就这样」的托词——第 9 节会把明文落点的具体风险面写清楚, 你可以据此决定要不要把环境变量里的密钥迁到别处。

1.2 我怎么知道自己某个密钥在哪

按「你在哪填的」反查最快:

你在哪里填的落在哪
模型页 → 添加 Provider → API Key① 共享 keystore,ref 形如openai-<uuid>
扩展中心 → 连接器 → 连接账号① 共享 keystore,ref 形如 plugin-runtime-<pluginId>-<runtimeId>-<base64url>
自定义工具 Studio → 本机密钥① 共享 keystore,ref 形如custom-tool:<工具 id>:<密钥名>
工具包密钥 → 「配置工具密钥」弹窗① 共享 keystore,ref 形如 tool-package:<包 id>:<变量名>:<sha256 前 20 位>
设置 → 生态 → 远程连接 → Bot Token / App Secret③app_settings明文
会话配置面板 / 项目配置 → 环境变量③app_settings明文
MCP 服务器配置里的 headers / env(明文写)③mcp_servers.config_json明文
终端里spark login④~/.spark/credentials.json明文

2. 共享 keystore:唯一入口与它的真实边界

packages/shared/src/keystore/index.ts只有 244 行,但它是绝大多数密钥的落点。 它的模块头注释写着一句需要修正的话:

Spark Work 凭证存储模块 — 唯一合法的 keytar 调用入口。 macOS 敏感凭据集中存入一个 vault,并可由桌面端注入加密应用存储, 避免按 Provider 条目或每次启动重复请求 Keychain 授权。

「唯一合法的 keytar 调用入口」在模块自身的意义上成立(这个包是集中 vault 的唯一实现), 但它不是全应用唯一:TokenStore用动态 import('keytar')绕开了它(第 6 节)。写文档时若照抄这句,会让读者以为「所有凭据都在这一个 vault 里」, 而实际上登录态和 MCP OAuth 令牌都不在。

2.1 常量与平台分支

常量值作用
SERVICE_PREFIXspark-agent非 macOS 平台 keytar 的 service 名
VAULT_ACCOUNTcredential-vault-v1集中 vault 在 Keychain 里的条目名(仅作一次性导入源)
VAULT_VERSION1vault JSON 的version;不匹配直接抛错
USE_CONSOLIDATED_VAULTprocess.platform === 'darwin'只有 macOS 走集中 vault

所以「Windows 上密钥在凭据管理器、macOS 上在一个加密文件里」这个区分是从USE_CONSOLIDATED_VAULT这一行直接推出来的,不是平台惯例的猜测。

2.2 五个函数与它们的语义差异

函数macOS 行为非 macOS 行为
setSecret(ref, secret)在副本上修改 vault,持久化成功后才替换内存缓存;值与旧值相同且已迁移过则直接返回直写 keytar,并写进程内directSecretCache
getSecret(ref)先读 vault;未命中且该 ref 未探测过旧条目时,按需从旧 Keychain 条目迁移一次读缓存 → 未命中读 keytar,并对并发同 ref 读取做去重
deleteSecret(ref)删 vault 条目,同时真的调keytar.deletePassword清理旧条目删 keytar 条目并清缓存
hasSecret(ref)(await getSecret(ref)) !== null—— 注意这会触发迁移逻辑
preloadSecrets(refs)只加载一次集中 vault,绝不逐条探测旧 Keychain并发预读所有 ref

mutateVault有个容易被忽略的细节:注释明确写了「始终在副本上修改。 只有 persistVault成功后才会替换vaultCache, 避免持久化失败时内存暴露未提交的凭据或意外丢失旧值」。 这意味着写失败不会污染内存态,但反过来也意味着你必须检查setSecret 是否抛错—— 它是会抛的。

2.3 集中 vault 的文件格式与写入协议

vault 本体是一个极简 JSON:

{ "version": 1, "secrets": { "<ref>": "<明文密钥>" }, "legacyChecked": ["<已探测过的旧 ref>"] }

legacyChecked是一个容易被忽略但很关键的设计:它记录「这个 ref 已经探测过旧 Keychain 条目了」, 避免每次启动都去读不存在的条目、反复触发 macOS 授权弹窗。

落盘实现是CredentialVaultPersistence.ts,它的门控条件写得非常显式:

if (process.platform !== 'darwin' || !safeStorage.isEncryptionAvailable()) return null

返回null意味着退回纯 Keychain(即每个 ref 一条 keytar 条目), 也就是旧布局。写入过程是:

  • 文件路径:{userData}/credential-vault-v1.enc;
  • 先写<file>.<pid>.tmp,mode: 0o600,再 rename覆盖 —— 原子替换;
  • finally里无条件rm临时文件(force: true+ catch吞错);
  • 读不到文件时只在ENOENT返回null,其他错误照抛(不会静默把一个损坏的 vault 当空的)。

注入点是registerAuthIpc.ts,注释写明「应在第一次凭据访问前调用」。 另外安装版首次会弹一次KEYCHAIN_DISCLOSURE_VERSION = 1的说明框—— 这就是你可能见过的那次「为什么会访问钥匙串」的解释。

2.4 从「一个 Provider 一条 Keychain 项」迁移

迁移是惰性的,只在真正读某个 ref 时发生:

  1. 启动时preloadSecrets只加载集中 vault,不探测旧条目;
  2. 某次getSecret(ref)在 vault 里没命中、且legacyChecked里也没有该 ref 时, 才去读旧 Keychain 条目;
  3. 读到就写进 vault,并把 ref 记入legacyChecked(即使没读到也记,避免重复探测);
  4. 旧条目保留不删——注释说得很直白:「旧条目保留但不再访问,避免删除动作再次触发系统授权窗口」;
  5. 只有显式删除(deleteSecret)或用户退出登录时,才真正清掉旧条目。

这解释了两个常见现象:为什么升级后第一次用某个 Provider 会弹一次授权(迁移), 以及为什么「用了新版本之后旧条目还在钥匙串里」(设计如此)。

2.5 十三个消费者

共享 keystore 的 import 点共 13 处,覆盖了除登录态与 CLI 之外的全部密钥:

消费者用途
provider.service.tsProvider API Key 的增删改查、健康检查、导出
provider-credential-resolver.ts运行时统一取 Key(含受管 Provider 的恢复)
PlatformModel/PlatformCredentialStore.ts平台账号 access token / api key / 待支付态
custom-tool.service.ts自定义工具密钥位的读写删
tool-package.service.ts工具包 secret 环境变量、一次性安全输入
plugin-runtime/token-service.ts连接器账号 OAuth 令牌包
plugins/plugin-manager.service.ts卸载插件时清理密钥
github-connector.service.ts平台 GitHub 连接器的 PAT
team-registry-config.ts团队注册表凭据
platform-bridge.service.tsAgent 侧 Provider 工具的密钥解析
SubAppNetworkGateway.ts子应用连接槽背后的凭据注入
computer-use/SnapshotVaultKeyProvider.ts截图快照库的加密主密钥
main/ipc/index.ts预读与集中访问入口

3. 引用与明文:SQLite 里到底存了什么

这一节回答一个非常具体的问题:**数据库里有没有可能读到我的密钥?**对走 keystore 的那批,答案是「没有,只有引用」;但有三处例外(第 9 节)。

3.1 keystore_ref 列清单

表列迁移文件里的原话
provider_profileskeystore_ref TEXT001_initial_schema.sql注释:「API Key 不在此表,通过 keychain_ref 引用系统 Keychain」
connector_connectionskeystore_ref TEXT039_connector_connections.sql:3:「明文 secret 绝不入库,只保存 keystore_ref。」
plugin_runtime_accountscredential_ref TEXT069_plugin_runtime_accounts.sql:1-2:「Secrets remain in the OS keystore; SQLite stores only credential_ref.」
tool_package_configkeystore_ref TEXT/value_json TEXT090_tool_packages.sql用 CHECK 约束强制二者互斥(见 3.2)
custom_tools存secretRefs声明(JSON)密钥值不落库;表注释与secretNames协议注释都写明「值永不外出」

注意provider_profiles.keystore_ref本身是会下发给渲染进程的:rowToProfile里keystoreRef: row.keystore_ref ?? '', 协议字段注释是「Keychain 引用 ID(非明文 Key)」。 渲染端把它当「有没有配 Key」的判据用(例如快速创建面板)。 这不是泄漏——ref 只是 <providerType>-<uuid>这类字符串,猜不出密钥。

3.2 唯一一处数据库级强制:tool_package_config

整个 schema 里只有这张表用 CHECK 约束把「引用」与「明文」钉死:

CHECK ( (is_secret = 0 AND value_json IS NOT NULL AND keystore_ref IS NULL) OR (is_secret = 1 AND value_json IS NULL AND keystore_ref IS NOT NULL) )

读法:非密钥行只能有value_json,密钥行只能有keystore_ref, 两者不可能同时存在,也不可能都为空。这是全仓最强的一道「密钥不入库」保证—— 它不依赖应用层自觉,写错直接 INSERT 失败。 对照之下,provider_profiles 的「不入库」只是靠代码不放字段,没有 DB 兜底。

3.3 ref 命名规则总表

来源ref 形态构造代码
普通 Provider<providerType>-<uuid>makeKeystoreRef(providerType, id),即 '${provider}-${profileId}'
平台受管 Providernewapi-spark-user-<userId>-api-key唯一非 uuid 形态;PlatformCredentialStore.ref(kind)生成
平台账号其它字段newapi-spark-user-<userId>-access-tokenkind ∈ {access-token, api-key, pending-payment}
连接器账号plugin-runtime-<pluginId>-<runtimeId>-<base64url>token-service.createRef(),外部账号 id 先 base64url 再截 80 字符
自定义工具custom-tool:<工具 id>:<密钥名>secretKeystoreRef(id, name);用 id+name 重新拼,不读secretRefs的值
工具包 secrettool-package:<包 id>:<变量名>:<sha256 前 20 位>哈希输入含scope/scopeId/toolName,所以不同作用域是不同槽位

两个值得记住的推论:

  • 自定义工具的secretRefs值只是「声明留痕」—— 它用于 Agent 侧校验(必须是 canonical 形态)和界面展示,运行期解析走的是重新拼出来的 ref。 所以改 secretRefs里的值不会改变真实读取位置。
  • 工具包的 ref 带scope哈希,意味着同一个密钥名在不同作用域是不同密钥。 「我在 package 作用域填了,为什么没生效」通常是因为配置写在 session 作用域。

4. 脱敏:两份同名函数,和它们各自泄露了什么

仓里有两个都叫maskSecret的函数,行为不同,用途也不同。混淆它们会写错文档。

实现规则用途
packages/shared/src/keystore/index.ts长度 ≤ 4 →****;否则前 4 个字符原样+ 最多 12 个星号只用在日志:Provider 创建/更新时各一行 info 日志
packages/agent-runtime/src/services/runtime-composition.service.ts空 →(空);长度 < 4 →****;否则 首字***尾字 (N 字符)构建注入系统提示词的「环境变量」段

两者都会泄露一部分明文:前者泄露前 4 位(sk-a****这种), 后者泄露首尾各 1 位以及长度。写进日志的那份意味着日志文件里能读到密钥前缀—— 如果日志被收集或分享,要考虑这一点。

4.1 日志器本身还有一层脱敏

packages/shared/src/logger/index.ts在写入前对消息文本与结构化参数做两层处理:

  • 文本清洗:Bearer &lt;8+位&gt;→Bearer [redacted];sk-&lt;8+位&gt;→sk-[redacted]; 以及 authorization / api_key / secret / token / password / pwd后跟 : 或 =的值 →[redacted]。
  • 结构化参数:对象里键名命中/^(authorization|api[_-]?key|secret|token|password|pwd|bearer)$/i的值替换为'****'。

代码里自己写了边界:「仅覆盖最常见场景,不做递归深扫」—— 只处理顶层键,嵌套对象里的密钥不会被这一层拦住。 另外spark-engine自己的 CLI 日志器(observability/logger.ts)完全不做脱敏,终端里看到的就是原文。

5. Provider 凭据:从表单到真实请求

这是最完整的一条链路,看懂它就理解了其余所有 keystore 用法。

5.1 写入:什么进 keystore,什么进数据库

在「模型」页添加或编辑 Provider 时,API Key 的落点是:

ref = keystore.makeKeystoreRef(providerType, id) // 形如 openai-&lt;uuid&gt; await keystore.setSecret(ref, apiKey) // 明文唯一落点 // provider_profiles 只写 config_json / keystore_ref / enabled…

三条容易踩错的语义:

  • 空串不等于「不传」。createProvider用apiKey != null &amp;&amp; apiKey.length &gt; 0判断,空串走「没配 Key」分支 (日志是Created provider without API key (local CLI / pending key)); 但 updateProvider只判断apiKey !== undefined, 所以传''会把空串写进 keystore 并把keystore_ref落库, 造成「界面显示有 Key、实际发请求时无凭据」的状态。渲染端靠apiKeyDirty避免误传空串,但 MCP 桥路径没有这层保护。
  • 更新时复用旧 ref:existing.keystore_ref || makeKeystoreRef(...), 所以轮换 Key 不会换 ref,旧值被覆盖。
  • 本地 CLI 类型的 Provider 不写 keystore:keystoreRef 留空字符串, 走本机已登录的 Claude Code / Codex。

5.2 读取:resolver 的真实返回值

provider-credential-resolver.ts只有 73 行,但它是所有运行期取 Key 的唯一入口 (会话执行、媒体路由、标题抽取、provider-vision 工具、工具包内建能力都走它)。逻辑是:

  1. 有 ref 就getSecret(ref)?.trim() || null,否则null;
  2. 解析config_json判断是不是平台受管(managed+ managedType:'newapi');
  3. 非受管:直接返回currentSecret ?? ''——不抛错;
  4. 受管且有恢复器:调恢复器,拿到新值就回写 keystore,最后返回 recovered?.trim() ?? ''。

第 3 步的「不抛错」很关键:缺 Key 不会在 resolver 报错,而是层层向上, 由各调用方自己决定怎么处理,而它们的行为并不统一:

调用方缺 Key 时的行为
会话执行器抛错:API key not found for provider &lt;id&gt;(ref 都缺时更早抛 has no keystore ref)
媒体路由catch 后跳过该 Provider并 warn,不中断其它渠道
会话内图片工具上下文只判空返回null,不 try/catch

第 4 步有个实现细节值得知道:返回的是recovered ?? '',不是recovered ?? currentSecret ?? ''。所以平台受管 Provider 在恢复器返回 null 时, 即使 keystore 里有一份可用值也会被当成空——这解释了「平台模型偶发报凭据不可用,重试又好」的一部分现象。

5.3 明文回显:provider:get-api-key 是刻意的

这是最需要准确表述的一点。渲染进程确实能拿到明文 Key:

typedIpcHandle('provider:get-api-key', async (req) =&gt; { const apiKey = await getProviderService().getProviderApiKey(req.id) return { apiKey } })

服务层注释写明了用途与约束: 「按需返回单个可编辑 Provider 的明文 Key,供受信任的编辑界面回显。调用方不得记录、持久化或批量请求返回值。」 协议层注释同样限定:「仅供 Provider 编辑界面按需回显当前凭据。不得并入provider:list」。

也就是说:provider:list只回keystoreRef(引用,不是密钥), 只有打开某个 Provider 的编辑抽屉时才会单独拉一次明文,用来回填输入框 (渲染端把它存在 React state 里,靠apiKeyDirty决定是否回写, 模块注释写明「明文只作为返回值短暂进入编辑表单状态,不在本模块持久化」)。

两条边界:

  • 受管 Provider 被硬拒:if (isManagedProviderRow(row)) throw new Error('平台官方 Provider 由系统管理,不能读取凭据'), 且受管 Provider 也禁止手动编辑与删除。测试把这三条都锁死了。
  • 但渲染端不是唯一能调到它的地方。V1 子应用默认trusted, 其 IPC 桥在 checkPermission里对 trusted 直接放行、并把任意通道转发给宿主window.spark.invoke;仓里的测试用例正是拿provider:get-api-key 做的示例。 换言之,一个 V1 子应用跑起来就能读到 Provider 明文 Key—— 这条链路两段都有代码证据,是否算问题取决于你怎么看「V1 子应用 = 一等内部应用」这个前提。

5.4 导出会把明文 Key 写进普通文件

exportProviders的文档注释直说:「apiKey 从 Keychain 读取后附带到导出 profile 中」。 导出 schema 里apiKey是合法字段,导出格式版本 PROVIDER_EXPORT_VERSION = 2, 注释标注「v2(含 apiKey)」。

「导出到文件」的 IPC 处理是:弹保存对话框(默认文件名spark-agent-providers-YYYY-MM-DD.json,标题「导出 Provider 配置」)→JSON.stringify(payload, null, 2)→fs.writeFile。整个过程没有任何「该文件包含明文密钥」的提示,界面上只 toast 一句「已导出 N 个 Provider」。

所以:把 Provider 配置导出成 JSON 再发给别人 / 提交进仓库 / 放进网盘,等于直接泄露 API Key。 要分享配置,用第 13 节的.sparkflow工作流包(它会把密钥替换成占位符), 而不是 Provider 导出。受管 Provider 会被导出逻辑跳过,不会进文件。

5.5 测试连接会真的发请求

testConnection会从 keystore 取真实 Key 并发一次最小请求:

  • Anthropic 系:POST {endpoint}/v1/messages,头带 x-api-key与anthropic-version: 2023-06-01,body 是 {model, max_tokens:1, messages:[{role:'user',content:'ping'}]};
  • OpenAI 系:Authorization: Bearer &lt;key&gt;,按codexApiKind走 /chat/completions//responses//embeddings;
  • 超时PROVIDER_CONNECTION_TIMEOUT_MS = 15_000,超时文案「连接测试超时(>15s)…」,其余错误经describeNetworkError归一;
  • 受管 Provider不发请求,只判断解析出的 Key 是否非空,失败文案是「平台模型凭据尚未就绪」。

「获取模型」走同一套凭据解析(无 Key 抛API Key is required to fetch models), 失败时会把响应体里出现的 Key 替换成[REDACTED]。 IPC 层日志只打 provider / id / model / healthy / latency,不打 Key。

6. TokenStore:第二个 keytar 实现

Auth/TokenStore.ts是「唯一 keytar 入口」这句话的实际例外。 它用 import('keytar')动态引入,自己定义 service 与三个条目,不走共享 keystore。

项值
keytar service(登录态)SparkAgent.CloudAuth,可用环境变量SPARK_AUTH_KEYTAR_SERVICE 覆盖(E2E 实例借此隔离)
keytar service(MCP OAuth)spark-mcp-oauth:&lt;serverId&gt;
keytar accountauth_token/refresh_token/user_id(三条独立条目,不是一条 JSON)
加密备份文件{userData}/cloud-auth-session.enc,safeStorage 加密
keytar 操作超时3_000ms,超时按「keytar 不可用」处理

6.1 读写顺序与文件头注释相反

文件头注释说「Primary storage is keytar」,但实现是:

  • load()先读加密备份,命中即返回;只有备份不存在/校验失败才回落 keytar;
  • save()先写备份,再写 keytar;keytar 不可用时直接 return;
  • clear()先删备份,再删 keytar 三条。

这个顺序的实际后果是好的(keytar 出问题时仍能保住登录态),但要知道keytar 与备份可能不一致,而读优先级站在备份这边。 另外全新安装会传 allowLegacyKeytarFallback: databaseExistedBeforeInitialization, 避免为不存在的旧条目弹一次钥匙串授权。

6.2 备份文件被两个命名空间共用(含一个可复现的缺陷)

备份文件名是常量cloud-auth-session.enc,getEncryptedBackupPersistence()只用它拼路径,不带 service。而load()又要求payload.service === this.service才算命中。两者叠加产生两个后果:

  1. 互相覆盖:登录态的 TokenStore 与 MCP OAuth 的 TokenStore 写的是同一个文件,后写的赢;被覆盖的一方下次启动读回来是「service 不匹配 → null」, 静默退回 keytar。
  2. 删除会误伤:deleteEncryptedBackup()直接rm(join(userData, 'cloud-auth-session.enc')),不校验 service。 而mcp:deauthorize→ McpOAuthService.deauthorize→DesktopMcpOAuthStore.clearAll→ TokenStore.clear()。 也就是取消授权一个 MCP 服务器,会连带删掉云端登录会话的加密备份。

影响范围有限(登录态通常同时在 keytar 里,且下次save()会重建备份), 但如果恰好叠加 keytar 不可用,就会走到「登录态只剩内存、重启即丢」。 另外这个备份文件的写入没有 mode、也没有 tmp+rename 原子替换—— 与同目录的 vault 文件(0o600+ rename)标准不一致。

6.3 续期与「一条没人收的明文广播」

客户端没有过期时间判定(会话结构里就没有 expiresAt),续期完全由 401 驱动: 401 → 刷新并重试 → 再 401 允许再刷一次;刷新有模块级单飞锁避免并发。

刷新成功后handleTokenRefreshed会发一条流式事件:

this.emitStream('stream:auth:token-refreshed', { token: session.token, refreshToken: session.refreshToken, userId: session.userId, })

这条广播携带完整明文 token 与 refreshToken,而渲染端与 preload 里没有任何订阅者(全仓检索该通道名,只有主进程的定义与发射点)。 目前它不构成泄露(没人接收),但它的存在意味着任何后续添加的订阅者都会直接拿到明文凭据—— 要接这条流的话,应当先把它改成只发「已刷新」信号。

6.4 退出登录删了什么、没删什么

AuthService.logout()会先尝试 POST/auth/logout(失败继续), 再跑 logout hooks,最后tokenStore.clear()。 但全仓addLogoutHook 只有一个调用方:PlatformModelService。

密钥退出登录时
Spark 账号 access / refresh token删除(备份 + keytar 三条)
平台模型凭据(newapi-spark-user-&lt;id&gt;-access-token/ -api-key)删除(唯一的 logout hook)
Provider API Key保留——没有对应 hook
平台 GitHub 连接器 PAT保留
连接器账号 OAuth 令牌包保留
MCP OAuth 令牌保留

还有两个边界情况:如果 keytar 在本进程曾被判定为不可用,clear()会在删完备份后提前 return,把 keytar 里那三条留在系统中; 另外keytarUnavailable一旦置位,本进程内不会再重试写 keytar。

7. 账号类凭据:连接器、平台连接器、团队注册表

7.1 连接器账号(plugin-runtime)

在扩展中心 →「连接器」里连接 GitHub / Notion / Google / Obsidian 账号时, 凭据的写法与 Provider 不同:渲染端把明文 secret 通过 IPC 交给主进程(plugin-runtime:accounts:connect的request.secrets), 主进程侧再校验长度上限(最多 32 条、名 ≤120、值 ≤16_000)后写 keystore。 唯一的例外是 OAuth 路径(accounts:authorize)——它在主进程里跑 PKCE + loopback,请求结构里根本没有 secrets 字段,token 不经过渲染进程。

落库的只有一个引用:plugin_runtime_accounts.credential_ref。 连接成功后返回的账号对象不含任何密钥字段(toAccount()只映射token_expires_at等非敏感列,完全不碰credential_ref)。

存储内容是一个 JSON 字符串(StoredCredentialBundle:accessToken必填,refreshToken/tokenType/expiresAt/clientId/scopes可选)。 取用时若 expiresAt距现在不足 30 秒会先刷新,刷新有同 ref 单飞锁; JSON 损坏会被当成「需要重新授权」(AUTH_REQUIRED)。

两个边界:断开账号只删本地密文,不做 provider 侧撤销(四个内置 adapter 都没有实现可选的disconnect), 所以「断开」后你仍应去 GitHub 侧手动吊销那个 token; 另外如果 adapter 的 disconnect 抛错,删除密文这一步不会执行, 会出现「界面提示断开失败但账号行还在、密文也还在」的状态。

7.2 平台 GitHub 连接器

这是与 7.1 同名但完全不同的另一套(第 3 轮「连接器」页讲过两套实现并存)。 它的 ref 是固定形态 github-connector-&lt;id&gt;(id 默认github-primary,即常态是github-connector-github-primary), 已存在则复用旧 ref,所以轮换 PAT 不换 ref。

  • 写入:connect()先setSecret再 upsert DB;
  • 更新不碰 token:updateConnection的参数类型里根本没有 token 字段, 换 PAT 走的是connect(界面文案「更新 PAT 并重新验证」与实现一致);
  • 读取:每次工具调用前取一次,错误分三档——ref 缺失 →KEYSTORE_KEY_NOT_FOUND(「缺少凭证引用」)、 读抛错 →KEYSTORE_UNAVAILABLE(「系统凭证库不可用」)、读到空 → 「GitHub PAT 不存在,请重新连接」;
  • 没有掩码字段:协议里没有 token / mask 字段,界面就是一个空白的密码输入框 + 提示文案, 不存在「回显已保存 PAT」的路径。

7.3 团队注册表(Nacos)

存的是团队注册中心的账号密码,ref 是字面量形态team-registry-nacos-password(服务器地址 / namespace / username 走 settings,只有密码进 keystore)。 保存语义有个细节:password仅在显式提供时才动—— 传空串等于「清除密码」(调deleteSecret),不传则保持原值。 界面侧只能拿到一个布尔hasPassword, 「测试连接」用的是未保存的表单值直接试连,不会把密码写进去。

8. 「只声明引用、不填值」的三条面

这三处是官方设计里最讲究的部分:Agent 只能声明需要一个密钥, 值必须由人在受保护的界面里输入。它们各自用不同的机制实现同一目标。

8.1 自定义工具:secretRefs

协议层的声明约束是:

const CustomToolSecretRefsSchema = z .record(z.string().regex(/^[A-Za-z0-9_-]{1,64}$/), z.string().min(1).max(512)) .refine((refs) =&gt; Object.keys(refs).length &lt;= 16, '密钥引用数量超过上限 16')

拆开读:键(逻辑密钥名)受^[A-Za-z0-9_-]{1,64}$ 约束;值只要求 1–512 字符、无正则——因为规范化后的值形如custom-tool:&lt;id&gt;:&lt;name&gt;,含冒号,作为「值」合法但作为敏感头的secretRef就非法(那里同样要求^[A-Za-z0-9_-]{1,64}$)。

Agent 侧只接受规范化引用:authoring facade 会逐条比对ref !== canonicalSecretRef(id, name),不符即报 「Agent 接口只接受规范化密钥引用,不接受密钥值或自定义存储位置」。 它的模块注释写明了设计意图:「It deliberately exposes no secret-write API. Agents may declare secretRef slots, while the actual values must still be entered through the trusted desktop Keychain form.」

类型专属封禁两条:code工具不允许secretRefs(「代码工具不接收 Keychain 明文;请通过受管 HTTP 工具组合外部能力」),provider-vision同样不允许(「图像处理工具复用 Provider Keychain 凭据,不允许另存工具密钥」)。 另外「声明了必须用上」也被强制:声明了却没有任何请求头引用的密钥名会报 「密钥 <name> 未被任何请求头引用」。

还有一道明文形态黑名单:spec 模板里出现sk-…/AKIA…/ghp_…/gho_…/xox[baprs]-…/ 硬编码Bearer …/ PEM 私钥,直接拒绝, 提示「模板中检测到疑似密钥明文,请改用密钥库(secretRefs)存储」。 敏感请求头(键名命中 token/secret/password/authorization/cookie/credential/api_key/private_key这类) 必须绑定secretRef,且不能与valueTemplate 同时出现。

生命周期门控(都基于hasSecret()判断):

时机行为
创建声明了密钥的工具默认禁用(enabled: !hasToolSecrets),等界面写完密钥再开
发布缺密钥直接抛SECRET_MISSING:「工具 <id> 缺少密钥:<名字>」
启用同样拒绝;判据用的是已发布版本的声明,未发布草稿新增的密钥不影响当前版本启停
回滚目标历史版本缺密钥则拒绝:「工具 <id> 的历史版本 v<n> 缺少密钥:…」
草稿引入新密钥若工具当前已启用,自动停用,防止未填值的版本进入工具面
执行期取不到值报「工具 <id> 的密钥 <name> 尚未写入密钥库」

写入的唯一入口是custom-tools:write-secret,服务层用if (!(name in refs))把可写范围钉死在该工具已声明的密钥位上—— 它不能被当成通用 keystore 写接口滥用。 界面在 Studio 的「本机密钥」区块,用InputPassword, 旁注文案是「密钥写入系统 Keychain,不进入 SQLite、导出文件或工具描述」, 已保存时 placeholder 变成「留空则保持原值」。 密钥名不是单独添加的,而是从请求头声明里倒推(secretNamesFromHeaders)。

一个值得知道的角落:发布新版本时移除了某个密钥位 X, 孤儿清理会把它从 keystore 删掉。此后你要回滚到旧版本会先被missingSecrets拦住(报SECRET_MISSING), 而重填 X 的入口又要求「X 必须在当前声明里」——于是报 「工具 <id> 未声明密钥位 <X>」。**解法是先在草稿里把 X 重新声明出来,再补值。**另外孤儿清理失败只记 warn,不阻断已完成的发布/回滚,所以 keystore 里可能残留不再被引用的密钥位。

8.2 工具包:环境变量与一次性安全输入

工具包没有secretRefs字段——它的声明面是spark-tool.json的environment[]:

{ "name": "EXTERNAL_API_TOKEN", // 必须 ^[A-Z_][A-Z0-9_]{0,127}$ "title": "外部接口令牌", "type": "string", "required": true, "secret": true, // ← 关键位 "agentConfigurable": false }

schema 对 secret 变量有两条额外约束:不能声明default(「Secret environment variables cannot declare defaults」)、只能用 string 类型(「must use string type」)。

值落在tool_package_config,受第 3.2 节那条 CHECK 约束保护。 Agent 侧结构性地无法传明文,双重保证:

  1. tool_packages_request_secret的 inputSchema 里根本没有 value 属性, 且additionalProperties: false,工具描述写明「密钥由用户在应用内受保护表单填写, 绝不进入 Agent 参数或消息记录」;
  2. 即便走tool_packages_configure_environment的value通道, 服务层硬拒:if (variable.secret) throw new Error('... is secret and requires secure input'), 并且对actor === 'agent'还要求agentConfigurable 为真。

一次性安全输入的完整机制:

项值
状态枚举pending/completed/cancelled/ expired
TTL默认 15 分钟,钳制在 1 分钟 ~ 1 小时之间
过期判定惰性——枚举时把过期行置为expired并跳过,没有定时器
同目标重复请求复用同一条 pending 请求,不重复创建
完成时的写入顺序先写 keystore → 再写 DB;DB 失败则把 keystore 回滚到旧值(或删除)
并发完成单飞去重,重复完成报Secure input request is already being fulfilled

界面是ToolPackageSecretRequestHost(挂在 App 根部),弹窗标题 「配置工具密钥」,按钮「保存到 Keychain」/「取消」,输入用InputPassword, 说明文案:「密钥不会发送给 Agent,也不会写入会话记录或 SQLite; 主进程收到后会直接保存到系统 Keychain。请求将在 <到期时间> 失效。」 它有一个值得点赞的细节:切换请求时会清空输入框, 注释写着*「Never carry a secret typed for one request into another request.」*刷新走事件流而非轮询。工具包面板里也有入口,secret 变量带Keychain标签, 按钮文案是「安全配置」/「更新密钥」。

卸载时会清理密钥,但顺序与边界要知道:先停用 → 收集 ref(在删库之前)→ 删包目录 → 删 DB 行 → 逐个删 keystore,返回removedSecrets计数; 删除失败是静默的(.catch(() =&gt; false),只影响计数,卸载仍报成功)。 另外删单个版本不会删密钥——只有整体卸载才删, 所以delete_version 之后 package 级的 secret 会留在 keystore 里。

子进程侧还有一层保护:注入 secret 变量后,工具进程的 stderr 会把已知明文替换成[REDACTED](按值做split/join替换,不是靠正则猜)。 检查器也会警告「敏感环境变量不会从工具包读取,必须由安全配置流程写入 Keychain」。

8.3 子应用连接槽:只能声明槽,不能声明值

V2 子应用 manifest 里的连接声明是:

export interface SubAppConnectionDeclaration { kind: 'http-api' | 'provider' displayName: string allowedOrigins: string[] allowPrivateNetwork?: boolean }

没有 secret / token / value 字段,而且 schema 是.strict()—— 往连接槽里塞"secret": "..."会被 zod 拒绝,转成 level:'error'的MANIFEST_INVALID诊断,并阻断发布与预览。

绑定工具spark_app_connections_bind只接受引用 id(bindingKind+bindingId,没有 secret 参数), 相邻的 spark_app_connections_list描述写着「不返回密钥」。 落库的 sub_app_connection_bindings表也只有引用列。 委托的 origin 必须落在槽声明的 allowedOrigins内,服务端会校验 「授权 origin 超出 manifest 声明范围」。allowPrivateNetwork是AND 语义:声明为真且请求也为真才生效——声明能否决请求,请求不能放宽声明。

运行时真正的密钥注入在主进程:SubAppNetworkGateway按绑定取出凭据, 从连接行或 Provider 解析,然后在宿主侧注入请求头。 子应用提交的头会先过黑名单(authorization/x-api-key/x-goog-api-key/ api-key/cookie/host… 一律丢弃), 允许注入的凭据头被限制在四项, 响应体还会做按值替换脱敏、响应头只回传少数几个白名单项。 也就是说受管请求路径本身不会把密钥交给子应用—— 除非子应用是 V1 trusted(那就是另一回事,见第 14 节)。

9. 明文落点:三处密钥真的没加密

前面所有机制都建立在 keystore 之上。这一节讲清楚三处不走 keystore的落点, 以及它们各自的真实暴露面。

9.1 IM 机器人凭据:明文入库,且明文回显

设置 → 生态 → 远程连接里填的 Bot Token / App Secret,落点是app_settings表的 (category='remote-connections', key='data'), 值是一整棵 { global, connections }的 JSON。 逐跳证据链是:

  1. 凭据原样保留:sanitizeConnection里 credentials: isRecord(input.credentials) ? { ...input.credentials } : {},不做任何掩码;
  2. 保存时 credentials: { ...base.credentials, ...(patch.credentials ?? {}) };
  3. writeStore→ settingsService.set('remote-connections', 'data', store);
  4. SettingsService是纯转发到SettingsRepository.set;
  5. 仓储层this.toJson(value)后写进app_settings.value文本列——没有任何加密。

涉及字段(RemoteConnectionCredentials):botToken、appId、appSecret、webhookUrl、qqBotAppId、qqBotToken、qqBotSecret、clawEndpoint、clawAccessToken。

它与 Provider 的两点差别,比「加密与否」更值得注意:

  • 明文会回传到渲染进程:remote:list直接返回store.connections(就是readStore(),只做形状归一),credentials原样带出去。渲染端把它塞进draft。
  • 界面用的是普通输入框,不是密码框:RemoteCredentialFields里 Bot Token / App Secret / 机器人 AppSecret / Access Token 用的都是Input,整个 SettingsView.tsx里InputPassword与type="password" 的出现次数是0。 所以已保存的 bot token 会以明文显示在设置页输入框里。

需要更正既有文档:connectors、desktop-guide与 remote-connections三页原先都写了 「远程连接凭据是app_settings 里的明文 JSON,界面只做掩码显示」 (其中remote-connections 出现了两处)。 前半句正确,后半句与代码不符——本轮已把这四处措辞统一改为如实描述。

怎么降低风险:这个通道本来就是给本机 bot 用的,建议用权限最小化的 bot(只给必要的群/命令),并注意userData目录与数据库备份会被完整复制 (第 13 节)——那不是「只有密钥文件」的问题,而是整库带走。

9.2 环境变量:明文入库 + 脱敏后进系统提示词

会话配置面板与项目配置里的「环境变量」是另一处明文落点。 存储走 RuntimeCompositionService→settingsRepo:

const ENV_CATEGORY = 'runtime.env' settingsRepo.set(ENV_CATEGORY, `${scope}:${scopeRef}`, layer)

即app_settings里project:&lt;workspaceId&gt;与session:&lt;sessionId&gt;两行,值含真实明文。 合并规则是「会话级覆盖项目级,同名键后者胜」。

这些真实值有两个去向:

  • 注入子进程 env(result.customEnv = effectiveEnv)—— 这是它存在的目的,也意味着你在这里放的 API Key 会进入 Agent 起的所有工具子进程;
  • 脱敏后进系统提示词:buildEnvSystemPrompt把键名、描述与maskSecret后的值(首字***尾字 (N 字符))拼成[Environment Variables]段落,并明确要求模型 「通过变量名引用真实值……不要在回复或日志中打印真实值,也不要要求用户重新提供这些敏感信息」。

所以模型的系统提示词里能看到「存在 KEY_A,长度 40」,但看不到值。 这比「什么都不告诉模型」更好用,但长度与首尾字符确实进了上下文—— 如果你介意,就别把密钥放在这里,改用自定义工具的 secretRefs 或 Provider。

9.3 MCP 配置:明文入库,并随工作流包被脱敏

MCP 服务器的config_json在mcp_servers表里是明文 TEXT, 你手写的 headers/env原样入库。 这一处是唯一有自动脱敏保护的明文落点:导出工作流包时,redactMcpConfig会把

  • headers.*与env.*下的所有字符串值,以及
  • 任意层级里键名命中敏感模式的字符串值 (/(token|secret|password|credential|authorization|(?:api|access|private)[_.-]?key)/i)

替换成{{secret:&lt;路径&gt;}}占位符,并把路径登记进requiredSecrets。注释写着「安全优先,宁多勿漏」。 但要注意它只覆盖 MCP 配置:同一份工作流包里的技能文件与 Agent 提示词是原样打包的, 没有扫描——把 Key 写进SKILL.md或脚本里,导出时会一起带走。

9.4 Spark CLI:完全独立的一套(明文)

CLI 与桌面端不共用任何凭据存储。两个方向都能证明:spark-engine全目录没有keytar/safeStorage/credential-vault的任何引用(CLI 打不开桌面端 vault), 而 credentials.json这个名字在apps/desktop里零命中 (桌面端从不读 CLI 文件)。

CLI 的账号会话落在~/.spark/credentials.json(SPARK_HOME可改):

{ "version": 1, "serverUrl": "https://…", "session": { "token": "…", "refreshToken": "…", // 注意:refresh token 也是明文 "userId": "…" }, "account": { "id": 1, "account": "…", "nickname": "…", "role": "…" }, "updatedAt": "2026-…" }

保护措施只有文件系统层面,但做得比较扎实:

  • 文件0o600、目录0o700;
  • 原子写:先写.credentials.json.&lt;pid&gt;.tmp再 rename,写入失败不会截断一个可用会话;
  • 读时修复权限:发现组/其他可读会 chmod 回 0600 并 warn 「platform credentials at <path> were group/world readable; tightening to 0600」;
  • 首次写入失败抛PlatformCredentialError(不会静默丢会话)。

三个子命令的行为边界:

命令行为注意
spark login浏览器 PKCE 登录 → 存文件;--json只回 {authenticated, userId, serverUrl, loginUrl, openedInBrowser, credentialPath, account}不打印 token;切换服务器会提示「Note: replacing the stored session for <old> with a session for <new>」
spark logout只删本地文件不做服务端撤销——CLI 的 server client 里没有任何 logout / revoke 端点。所以被复制走的 refresh token 在 logout 后仍然有效,需要去账号侧吊销
spark whoami显示身份;会话过期则删除本地文件并提示「Your Spark account session expired. Runspark loginagain.」存储的 serverUrl 与当前配置不一致会提示归属错位

CLI 不存模型 API Key。它只记「该去哪个环境变量取 Key」:~/.spark/config.toml里写api_key_env = "MY_KEY_VAR", 变量名必须匹配^[A-Z_][A-Z0-9_]*$,否则报 「Invalid credential environment variable …: use UPPER_SNAKE_CASE (the key itself is never stored)」。 运行时从process.env取,缺失时报 「Provider <x> requires credential environment variable <VAR>」。 这一条与桌面端的差异值得记住:桌面端把 Key 存起来,CLI 要求你自己 export 出来。

当 CLI 用桌面端托管的渠道时,它的「api key」是桌面 bridge 的一次性 token: 32 字节随机 base64url,写在~/.spark/hosts/sparkwork/bridge-&lt;实例 id&gt;.json(0600,目录 0700,原子写,bridge 停止时删除)。 它不会被复制进config.toml,也被测试锁定不得出现在配置快照里。

10. 脱敏覆盖面:哪些地方会打码,哪些不会

「会不会被脱敏」这个问题没有统一答案,下表是逐处核实的结果。

位置是否脱敏机制
桌面端控制台 / 文件日志是logger 的sanitizeLogText+sanitizeArgs(Bearer / sk- / key=value/ 敏感键名)
Provider 创建与更新的日志部分maskSecret,保留前 4 个字符
会话环境变量进系统提示词是保留首尾各 1 字 + 长度
Hooks 运行记录是hook-redaction.ts:敏感键名的值替换为***,字符串截断到 512 字符,深度 6
媒体调试日志是键名正则 + URL 只留?redacted=1+ 请求体变成 [REDACTED content chars=N]
工具包子进程 stderr是按已知明文值做split/join替换为[REDACTED]
子应用网络网关的响应体是按已知明文值替换为[REDACTED]
自定义工具的测试输出是redactTestOutput:先按 keystore 真值替换,再叠加Bearer/sk-等正则
媒体渠道诊断返回值是sanitizeDiagnosticValue+redactHeaders+ redactUrl+redactKnownSecrets
会话记录里的工具调用入参未发现脱敏层会话/事件持久化路径上没有对应的脱敏包装
CLI 终端日志否observability/logger.ts直接写 [spark:&lt;ns&gt;] &lt;level&gt;: &lt;message&gt;,无任何替换
Provider 错误信息(CLI 侧)部分error-detail.ts按键名脱敏且只保留白名单字段,但不对字符串内的sk-…做子串清洗

最后两行的写法是刻意的:「未发现脱敏层」不等于「已被证明不脱敏」。 我们的检索覆盖了会话持久化、事件写入与 logger,没有找到包装点; 但如果你要把密钥打印进工具输出,最安全的假设仍然是「它会原样留在会话记录里」。

11. 删除与清理:谁能删掉密钥

操作会删什么边界
删除 Provider先deleteSecret(row.keystore_ref),再删 DB 行受管 Provider 与内置 Provider / Auto Router 直接拒绝删除
退出登录账号 token(keytar 三条 + 备份)、平台模型凭据不动Provider Key / GitHub PAT / 连接器 OAuth / MCP OAuth;keytar 曾不可用时会提前 return,把 keytar 里的条目留下
删除自定义工具先禁用(跨 SQLite/Keychain 边界前的保护),再删该工具声明的全部密钥删除失败时报「工具已停用,但 Keychain 密钥清理失败;请稍后重试删除」——工具仍可见可重试
自定义工具移除密钥位孤儿清理删掉不再被引用的 ref失败静默(只 warn),且会造成第 8.1 节那个「无法直接回滚」的角落
卸载工具包逐条删tool-package:*,返回删除计数必须先停用;删除失败静默;删单个版本不删密钥
断开连接器账号删connect_accounts.credential_ref指向的密文不做 provider 侧撤销;adapter 抛错时删除不执行
平台 GitHub 连接器断开deleteSecret(keystore_ref)后删行同样不做 GitHub 侧吊销
卸载插件遍历该插件的账号删各自credential_ref,再删资源行与安装目录无事务:删密钥抛错会中断卸载;rm 失败则密钥已不可恢复。且不清理spark-mcp-oauth:*与 connector_connections.keystore_ref
删除 MCP 服务器只删 DB 行不清理spark-mcp-oauth:&lt;serverId&gt;的 token → 孤儿密钥长期留存
显式删某个 refmacOS 下同时删 vault 条目与旧 Keychain 条目这是唯一会真正清掉旧条目的路径;自动迁移阶段故意保留旧条目以免反复弹授权

12. 备份、导出与同步:密钥会不会跟着走

这是最实际的一组问题——「我把配置分享/备份出去,会带上密钥吗?」

路径会带密钥吗说明
Provider 导出 JSON / 导出到文件会,明文从 keystore 读出并写入普通 JSON;无任何「含密钥」提示。分享配置请改用工作流包
工作流包.sparkflowMCP 配置不会;技能与 Agent 提示词会MCP 的headers.*/env.*与敏感键名被替换为 {{secret:路径}},但技能文件与提示词原样打包、不扫描
团队包不会(同套脱敏)且比工作流包更好的一点:更新已存在 MCP 时会保留接收方已填的密钥(mergePreserveSecrets),不会用占位符覆盖回去
子应用分享包.sparkappProvider Key 不会;源码/data 里的明文会设计如此:疑似明文密钥只做「位置提示」(secretHints只含 location,不含值),包内保留原文以保证可用性
Agent / 团队资产导出hookConfig原样写出没有扫描或清理;hook 动作类型本身只有builtin.sound/ tool.invoke,所以是否藏密钥取决于你写了什么
自定义工具导出不会只导出secretNames(名字)。注意导出物里含secretRefs的规范化引用字符串,那不是密钥值
账号同步不会三重过滤,见下
整个userData目录 / 数据库备份会,且包含明文那几处升级前自动备份是整库拷贝,因此app_settings里的 IM 凭据、环境变量、MCPconfig_json都会进备份目录;keystore 里的密钥不会(它们不在库里)
CLIcredentials.json会,明文含 refresh token;备份/同步~/.spark等于带走登录态

账号同步的过滤值得单独说,因为它是全仓最严格的一处。三重机制:

  • 分类白名单:只同步customCommands、prompts、memory、assistants、workflows、appearance、promptLibrary七类;
  • 字段白名单:每个分类只允许列出的顶层字段(assistants明确排除providerProfileId/modelId/agentAdapter/mcpServerIds/hookConfig等本地字段),metadata只允许 avatar一个键;
  • 键名与值双重扫描:键名命中/(api.?key|access.?token|refresh.?token|token|password|secret|credential|authorization|cookie|headers?|env…|keystore|provider.?profile|mcp|hooks?)/i即拒;值命中 PEM 私钥 /Bearer &lt;16+&gt;/ JWT /sk-/rk-/pk-/ghp_/xox…/https://user:pass@host即拒。 违规项整条跳过而不是上传。

一个副作用值得知道:那条键名正则包含裸mcp, 而工作流图的节点可以合法携带 mcpServerIds,图又是整体上传的—— 于是绑定了 MCP 服务器的工作流会被静默跳过同步(原因码 SYNC_FORBIDDEN_FIELD), 并被加入保护列表以免云端版本覆盖本地。这是安全过滤的连带效果,不是 bug,但你会觉得「这个工作流怎么没同步」。

13. 排查表

现象最可能的原因怎么确认 / 怎么办
保存 Provider 后提示「有 Key」但调用报无凭据更新时传了空串:updateProvider({apiKey: ''})会把空串写进 keystore 并把keystore_ref落库重新填一次 Key 并保存;判断依据是hasApiKey = Boolean(keystoreRef),它只看 ref 存在与否
平台官方 Provider 读取 Key 报「不能读取凭据」受管 Provider 被硬拒——这是设计受管 Provider 也不能手改、不能删除,只能在偏好弹窗里调模型
平台模型偶发「凭据尚未就绪」受管恢复器返回 null 时会丢弃 keystore 里已有的值(返回recovered ?? '')触发一次平台模型 bootstrap / 重新验证即可回写 keystore
macOS 上每次启动都弹钥匙串授权vault 文件写入失败(vault 无法缓存),于是每次都回落到读 Keychain检查{userData}/credential-vault-v1.enc 是否存在、可写;日志里会有failed to cache credential vault in app storage
升级后第一次用某个 Provider 弹了一次授权旧 Keychain 条目的惰性迁移(正常行为)迁移后该 ref 记入legacyChecked,不会重复弹;旧条目会在显式删除时清掉
钥匙串里还能看到旧的spark-agent条目迁移策略故意保留旧条目,避免删除动作反复触发授权要清就显式删除该 ref,或退出登录
自定义工具发布报SECRET_MISSING声明的密钥位还没写值(hasSecret为假)在 Studio 的「本机密钥」里填;判据用的是已发布版本的声明
回滚自定义工具报「未声明密钥位 <X>」旧版本用过的密钥位 X 已被孤儿清理删掉,而当前声明里没有 X先在草稿里重新声明 X 并补值,再回滚
新建的自定义工具默认是禁用的声明了密钥的工具创建时enabled: !hasToolSecrets写完密钥后手动启用,这是刻意的安全默认
工具包密钥「填了但没生效」ref 带作用域哈希,不同 scope 是不同槽位确认你填的是哪个 scope(package / tool / project / agent / workflow / session)
Agent 试图用工具包传密钥值被拒服务层硬拒:is secret and requires secure input正确路径是 Agent 调tool_packages_request_secret发起请求,你在弹窗里填
工具包密钥弹窗一直不出现请求可能已过期(TTL 默认 15 分钟,惰性判定)或已被完成/取消重新发起请求;列表只在secret-requested/configured 事件时刷新
卸载工具包后密钥还在删除失败是静默的(只影响removedSecrets计数)看卸载返回的removedSecrets是否符合预期
只删了工具包的一个版本,密钥还在钥匙串设计如此:只有整体卸载才删密钥要清就走完整卸载
断开连接器账号后,GitHub 侧的授权仍然有效断开只删本地密文,四个内置 adapter 都没实现 provider 侧 revoke去 GitHub / Notion 控制台手动吊销那个 token
点「断开」提示失败但账号还在adapter.disconnect抛错时,删除密文这一步不会执行重试断开;必要时先修好网络/权限
删除 MCP 服务器后钥匙串里还有 tokenmcp:delete只删 DB 行,不清理spark-mcp-oauth:&lt;serverId&gt;先mcp:deauthorize再删除,可避免孤儿密钥
重启后要求重新登录,但钥匙串里明明有凭据可能踩到备份文件的跨命名空间问题(取消 MCP 授权删掉了登录态备份)+ keytar 同时不可用重新登录即可;若频繁出现,检查是否有 MCP OAuth 的取消授权操作
退出登录后 Provider Key 还在只有平台模型注册了 logout hook,Provider 密钥不随退出登录清除这是现状;要清就逐个删除 Provider
IM 机器人 token 在设置页明文显示该表单用的是普通Input,且remote:list明文回传现状如此(本轮已更正官网旧文案);不要在有旁观者/录屏时打开该页面
导出的 Provider JSON 里有明文 KeyexportProviders会从 keystore 读出并附带不要分享该文件;分享配置用工作流包
工作流包导入后 MCP 连不上占位符{{secret:…}}还没补;导入时 MCP 是禁用状态在工作流包面板点「激活」,弹窗里逐项补密钥,按钮是「补齐并激活」;未补全时激活会被拒
spark logout后旧 token 仍能用CLI 的 logout 只删本地文件,不做服务端撤销去账号侧吊销;并注意credentials.json是明文,别同步到云盘
CLI 报「requires credential environment variable <VAR>」CLI 不存模型 Key,只记环境变量名在 shell 里 export 该变量,或在config.toml改api_key_env 指向已 export 的变量

14. 事实边界与本次未核实项

按「实事求是」的要求,以下内容本轮没有在代码里找到证据,已从结论中排除:

  • 不存在apiKeyMask字段或函数。 全仓检索apiKeyMask/apiKey_masked/ apiKeyPreview零命中。 外部资料里「列表接口只返回apiKeyMask」的说法与当前代码不符—— Provider 链路给渲染端的要么是明文(编辑回显)、要么是布尔(hasApiKey)、 要么是引用(keystoreRef)。
  • macOS 上的「系统 Keychain」不是最终落点,而是安全存储加密后的 vault 文件; 只有 Windows / Linux 才直达 keytar 后端。多处工具描述(例如「API Key 进入系统 Keychain」) 在 macOS 上是不准确的简化说法,本文按代码描述。
  • 「不进消息记录」这类承诺不成立: Provider / 媒体渠道的apiKey 是 MCP 工具的入参, 它会随 tool call 参数进入会话与模型上下文。成立的说法只有「不出现在返回值与日志中」。
  • 无法验证服务端是否回传 token:微信轮询接口的响应类型里允许token/refreshToken字段且主进程原样透传, 但服务端实现不在本仓,因此不能断言它一定会或一定不会返回凭据。
  • 「会话记录一定不脱敏」不是已被证明的结论: 我们是在会话持久化路径上没有找到脱敏包装,而不是验证了它不可能存在。 涉及具体合规判断时请按更保守的假设处理。
  • 快照库主密钥的丢失语义:getSecret返回 null 时会直接生成一把新钥并覆写 ref (旧.svb全部解不开,且解密失败统一报 Snapshot blob authentication failed,无法与「被篡改」区分)。 该密钥是 32 字节随机值、非派生,全程只在 keytar / vault 里以 base64 保存。

关于「哪些密钥算敏感、要不要迁移」这类判断,本文只陈述代码事实与影响面,不替你决定。 如果你的威胁模型包含「同机其它进程」或「userData会被备份到别处」, 优先关注第 9 节的三处明文落点与第 12 节的导出路径。