From d08b01efca967f2eb303322fe52d7852b638ec2f Mon Sep 17 00:00:00 2001 From: SAN Date: Wed, 5 Aug 2026 02:40:51 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(ghost):=20=E6=8C=89=20endpoint=20?= =?UTF-8?q?=E6=94=B6=E7=AA=84=E5=87=AD=E8=AF=81=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为 network.secrets[].inject 增加可选 paths 与 methods 白名单;凭证仅在 host、pathname、method 同时命中时注入。缺省字段保持既有 host-only 行为。 初始请求、401 重试和每一跳重定向均按实际 URL 与 method 重新匹配;未命中 endpoint 时仍会剥除插件伪造的 Host 托管凭证头。同步 Forge 编写手册及四语 权限文案,并覆盖 manifest、运行时与重定向路径。 注意:这是 Draft 预览提交,尚不可合并/发布。旧 schema-v2 客户端会静默丢弃 未知 paths/methods,退回整域注入(fail-open)。必须先由维护者确定兼容发布 机制,并待 cindy-protocol#30 合入默认分支后再单独升级 submodule 指针。 验证: - pnpm test:unit(通过本地缓存 pnpm 10.26.2 运行;所有适用 workspace 通过) - desktop tsc --noEmit → 通过 - ghost/networkSlot/forge 定向测试 → 284 passed - check:endpoints / check:i18n / check:brand-terminology / check:i18n-glossary → 通过 Co-Authored-By: Claude Signed-off-by: SAN --- .../main/cindy-brain/__tests__/forge.test.ts | 4 + .../cindy-brain/__tests__/networkSlot.test.ts | 110 +++++++++++++++ apps/desktop/src/main/cindy-brain/forge.ts | 15 +- .../src/main/cindy-brain/networkSlot.ts | 59 +++++--- .../src/renderer/i18n/locales/en/common.json | 2 +- .../src/renderer/i18n/locales/ja/common.json | 2 +- .../src/renderer/i18n/locales/ko/common.json | 2 +- .../renderer/i18n/locales/zh-CN/common.json | 2 +- .../src/shared/__tests__/ghost.test.ts | 64 +++++++++ apps/desktop/src/shared/ghost.ts | 130 +++++++++++++++++- 10 files changed, 357 insertions(+), 33 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts index 7ea61f71e9a..f846acdef2b 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts @@ -786,8 +786,12 @@ describe('FORGE_GUIDE', () => { // 2026-07-31 快问快答(cindy.text.oneshot)与派活取件(agent.errand)。 'oneshot_text', 'NO_CANDIDATE', +<<<<<<< HEAD // 2026-08-05 快问快答偏好模型声明(目录模型 id;用户钉档 > 插件声明 > 默认链)。 'oneshotModel', + '"paths": ["/v1/convert"]', + '"methods": ["POST"]', + '三者是 AND 关系', 'expectJson', // 2026-08-04 文本转向量(cindy.embed.text):作者最容易踩的是"换模型 = // 换向量空间",手册必须讲到 model + dim 要跟向量一起存。 diff --git a/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts index 1cd6992e546..11f8d48e0f7 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts @@ -243,6 +243,57 @@ describe('networkSlot · headers 消毒与凭证注入', () => { expect(JSON.stringify(r2)).not.toContain('tvly-secret'); }); + it('凭证 endpoint allowlist 同时匹配精确 pathname 与 method;query 不参与', async () => { + const network: GhostNetworkNeeds = { + hosts: ['api.example.com'], + secrets: [{ + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/v1/convert'], + methods: ['POST'], + }, + }], + }; + const scopedReadSecret = vi.fn(() => 'scoped-secret'); + const { slot, fetchImpl } = makeSlot({ + getGhost: () => fakeGhost({ network }), + readSecret: scopedReadSecret, + }); + + expect((await slot.handleFetchRequest('web-search', { url: 'https://api.example.com/mcp' })).ok).toBe(true); + expect((fetchImpl.mock.calls[0][1].headers as Record).Authorization).toBeUndefined(); + expect(scopedReadSecret).not.toHaveBeenCalled(); + + expect((await slot.handleFetchRequest('web-search', { url: 'https://api.example.com/v1/convert', method: 'GET' })).ok).toBe(true); + expect((fetchImpl.mock.calls[1][1].headers as Record).Authorization).toBeUndefined(); + + expect((await slot.handleFetchRequest('web-search', { url: 'https://api.example.com/v1/convert?output=pdf', method: 'POST', body: '{}' })).ok).toBe(true); + expect((fetchImpl.mock.calls[2][1].headers as Record).Authorization).toBe('Bearer scoped-secret'); + expect(scopedReadSecret).toHaveBeenCalledTimes(1); + }); + + it('endpoint 未命中仍剥除意识伪造的主机托管凭证头', async () => { + const network: GhostNetworkNeeds = { + hosts: ['api.example.com'], + secrets: [{ + key: 'api_key', + label: 'API Key', + inject: { header: 'Authorization', format: 'Bearer {value}', paths: ['/private'] }, + }], + }; + const { slot, fetchImpl } = makeSlot({ getGhost: () => fakeGhost({ network }) }); + const r = await slot.handleFetchRequest('web-search', { + url: 'https://api.example.com/public', + headers: { authorization: 'Bearer forged' }, + }); + expect(r.ok).toBe(true); + expect(Object.keys(fetchImpl.mock.calls[0][1].headers as Record) + .some((key) => key.toLowerCase() === 'authorization')).toBe(false); + }); + it('命中域名的凭证未配置 → 快速失败并指引设置页,不发请求', async () => { const { slot, fetchImpl } = makeSlot({ readSecret: () => null }); const r = await slot.handleFetchRequest('web-search', { url: BRAVE_URL }); @@ -328,6 +379,65 @@ describe('networkSlot · 重定向逐跳守门', () => { expect(hop2['X-Api-Key']).toBe('Bearer tvly-secret'); }); + it('同域重定向逐跳按 path/method 重算凭证', async () => { + const network: GhostNetworkNeeds = { + hosts: ['api.example.com'], + secrets: [{ + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/private'], + methods: ['GET'], + }, + }], + }; + const { slot, fetchImpl } = makeSlot({ + getGhost: () => fakeGhost({ network }), + readSecret: () => 'scoped-secret', + }); + fetchImpl + .mockResolvedValueOnce(redirectTo('https://api.example.com/public')) + .mockResolvedValueOnce(fakeResponse()); + await slot.handleFetchRequest('web-search', { url: 'https://api.example.com/private' }); + expect((fetchImpl.mock.calls[0][1].headers as Record).Authorization).toBe('Bearer scoped-secret'); + expect((fetchImpl.mock.calls[1][1].headers as Record).Authorization).toBeUndefined(); + + fetchImpl.mockReset(); + fetchImpl + .mockResolvedValueOnce(redirectTo('https://api.example.com/private')) + .mockResolvedValueOnce(fakeResponse()); + await slot.handleFetchRequest('web-search', { url: 'https://api.example.com/public' }); + expect((fetchImpl.mock.calls[0][1].headers as Record).Authorization).toBeUndefined(); + expect((fetchImpl.mock.calls[1][1].headers as Record).Authorization).toBe('Bearer scoped-secret'); + }); + + it('302 把 POST 降为 GET 后按下一跳实际 method 匹配 endpoint', async () => { + const network: GhostNetworkNeeds = { + hosts: ['api.example.com'], + secrets: [{ + key: 'api_key', + label: 'API Key', + inject: { header: 'Authorization', format: 'Bearer {value}', paths: ['/private'], methods: ['GET'] }, + }], + }; + const { slot, fetchImpl } = makeSlot({ + getGhost: () => fakeGhost({ network }), + readSecret: () => 'scoped-secret', + }); + fetchImpl + .mockResolvedValueOnce(redirectTo('https://api.example.com/private')) + .mockResolvedValueOnce(fakeResponse()); + await slot.handleFetchRequest('web-search', { + url: 'https://api.example.com/public', + method: 'POST', + body: '{}', + }); + expect(fetchImpl.mock.calls[1][1].method).toBe('GET'); + expect((fetchImpl.mock.calls[1][1].headers as Record).Authorization).toBe('Bearer scoped-secret'); + }); + it('重定向次数超上限阻断', async () => { const { slot, fetchImpl } = makeSlot(); fetchImpl.mockResolvedValue(redirectTo('https://api.tavily.com/loop')); diff --git a/apps/desktop/src/main/cindy-brain/forge.ts b/apps/desktop/src/main/cindy-brain/forge.ts index d3ab6277f88..0594f387f20 100644 --- a/apps/desktop/src/main/cindy-brain/forge.ts +++ b/apps/desktop/src/main/cindy-brain/forge.ts @@ -1291,7 +1291,9 @@ node 详单**不接受** \`command\` / \`args\` / \`shell\` / \`env\` 或其它 "inject": { // 必填:这条凭证怎么进请求 "header": "Authorization", // 注入的请求头名(Host/Cookie 等协议关键头禁用) "format": "Bearer {value}", // 恰含一个 {value} 占位,其余静态文本 - "hosts": ["api.example.com"] // 可选:注入范围(hosts 声明条目的子集,逐字);缺省=全部 + "hosts": ["api.example.com"], // 可选:注入范围(hosts 声明条目的子集,逐字);缺省=全部 + "paths": ["/v1/convert"], // 可选:精确 URL.pathname 白名单(大小写/尾斜杠敏感,不含 query);缺省=全部路径 + "methods": ["POST"] // 可选:GET/POST/PUT/PATCH/DELETE 白名单;缺省=全部支持的方法 }, "exchange": { // 可选:key 换令牌二段式(服务要求先拿 key 换临时令牌时声明,主机照单代办,见 §4.7;与 oauth 互斥) "url": "https://api.example.com/token", // 交换端点(https;域名必须命中 hosts 白名单) @@ -2245,9 +2247,14 @@ settingsHtml,校验强制):你在 settingsHtml 里画输入框供用户主动添 \`[{key, saved, tail?}]\` 状态、**永远拿不回值**(tail 是主机截存的**尾 4 位 指纹**,仅够用户回忆"填的是哪个 key";值不足 12 字符时不产——UI 要按没有 tail 也能画来写),DELETE 清除。红线:收单即交,不许把 key 落进 /kv、 -BroadcastChannel、日志或任何自存路径(review 必查)。凭证只会注入到它 -\`inject.hosts\` 声明的域名请求,重定向出域也不会跟着走。用户没填时 cindy.fetch -返回结构化错误,把 message 原样告诉用户即可(里面带了去哪填的指引)。 +BroadcastChannel、日志或任何自存路径(review 必查)。凭证只会在 +\`inject.hosts\` 命中,并且可选的 \`inject.paths\`(精确 URL.pathname)与 +\`inject.methods\` 同时命中时注入;三者是 AND 关系。省略 paths/methods 保持旧语义: +该域名下全部路径、全部支持的方法。paths 大小写与尾斜杠敏感,query/fragment 不参与; +初始请求、每次重定向和 401 重试都会按目标 URL 与实际 method 重新判断。未命中只是不带 +该凭证,仍可无凭证访问白名单 host;上一跳和插件自带的同名请求头都会先被主机清除。 +用户没填时,只有请求命中完整注入范围才会返回结构化错误;把 message 原样告诉用户即可 +(里面带了去哪填的指引)。 无论走 Setup 卡还是 settingsHtml,入库成功时主机会自动弹一条「凭证已保存」的系统提示(带你的身份头, 文案跟随用户语言;无需声明 notify 槽)——设置页里画个就地的轻反馈即可, 不用自己想办法做全局提示。 diff --git a/apps/desktop/src/main/cindy-brain/networkSlot.ts b/apps/desktop/src/main/cindy-brain/networkSlot.ts index 9460b3b0814..bbfe8afe50a 100644 --- a/apps/desktop/src/main/cindy-brain/networkSlot.ts +++ b/apps/desktop/src/main/cindy-brain/networkSlot.ts @@ -52,6 +52,7 @@ import { GHOST_MEDIA_HASH_RE, GHOST_SECRET_EXCHANGE_TTL_DEFAULT_S, ghostNetworkHostMatches, + ghostSecretInjectMatches, type GhostConnectionDecl, type GhostFetchMethod, type GhostSecretOauthDecl, @@ -1043,12 +1044,14 @@ export class GhostNetworkSlot { // ── 凭证注入:命中本次目标域名的每条声明凭证,保险库现读明文拼头 // (交换型凭证在此换取/取缓存令牌)。未配置的凭证快速失败(带清晰 // 指引),不发一个注定 401 的请求。 - const inject0 = await this.injectSecrets(ghostId, net.secrets ?? [], connectionDecls, url.hostname, net.hosts, requestHeaders, authAccount); + const inject0 = await this.injectSecrets(ghostId, net.secrets ?? [], connectionDecls, url, method, net.hosts, requestHeaders, authAccount); if (inject0.error) return { ok: false, message: inject0.error }; - let usedExchange = inject0.usedExchange; - const oauthInjected = new Map(inject0.oauthInjected); - const connectionInjected = new Map(inject0.connectionInjected); + let initialUsedExchange = inject0.usedExchange; + let initialOauthInjected = new Map(inject0.oauthInjected); let initialConnectionInjected = new Map(inject0.connectionInjected); + let retryUsedExchange = inject0.usedExchange; + let retryOauthInjected = new Map(inject0.oauthInjected); + let retryConnectionInjected = new Map(inject0.connectionInjected); // ── 在途并发闸(常量硬顶,防死循环刷单;不是配额)────────────────── const inflight = this.inflight.get(ghostId) ?? 0; @@ -1107,21 +1110,21 @@ export class GhostNetworkSlot { const originalRequestMethod = method; for (let attempt = 0; attempt < 2; attempt++) { if (attempt > 0) { - this.invalidateExchangedTokens(ghostId, net.secrets ?? []); - for (const [secretKey, accountId] of oauthInjected) { + if (retryUsedExchange) this.invalidateExchangedTokens(ghostId, net.secrets ?? []); + for (const [secretKey, accountId] of retryOauthInjected) { this.deps.oauthTokens?.invalidateAccessToken(ghostId, secretKey, accountId); } - for (const input of connectionInjected.values()) { + for (const input of retryConnectionInjected.values()) { this.deps.connectionTokens?.invalidate({ membershipId: input.membershipId, audience: input.audience, }); } - const reInject = await this.injectSecrets(ghostId, net.secrets ?? [], connectionDecls, url.hostname, net.hosts, requestHeaders, authAccount); + const reInject = await this.injectSecrets(ghostId, net.secrets ?? [], connectionDecls, url, method, net.hosts, requestHeaders, authAccount); if (reInject.error) return { ok: false, message: reInject.error }; + initialUsedExchange = reInject.usedExchange; + initialOauthInjected = new Map(reInject.oauthInjected); initialConnectionInjected = new Map(reInject.connectionInjected); - for (const [k, v] of reInject.oauthInjected) oauthInjected.set(k, v); - for (const [k, v] of reInject.connectionInjected) connectionInjected.set(k, v); this.deps.log?.info('ghost fetch-request 401 → re-auth retry', { ghostId, callId, host: url.hostname, }); @@ -1130,9 +1133,13 @@ export class GhostNetworkSlot { let currentMethod: string = method; let currentBody = body; let bodyDropped = false; + let responseUsedExchange = initialUsedExchange; + let responseOauthInjected = new Map(initialOauthInjected); let responseConnectionInjected = new Map(initialConnectionInjected); let responseMethod = currentMethod; response = null; + let currentUsedExchange = initialUsedExchange; + let currentOauthInjected = initialOauthInjected; let currentConnectionInjected = initialConnectionInjected; for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { const hopHeaders = { ...requestHeaders }; @@ -1142,12 +1149,11 @@ export class GhostNetworkSlot { if (hop > 0) { // 换了域名的跳转:上一跳注入的凭证不能跟着走,按新 host 重算 // (injectSecrets 开头会先把所有声明凭证头的大小写变体删干净)。 - const hopInject = await this.injectSecrets(ghostId, net.secrets ?? [], connectionDecls, currentUrl.hostname, net.hosts, hopHeaders, authAccount); + const hopInject = await this.injectSecrets(ghostId, net.secrets ?? [], connectionDecls, currentUrl, currentMethod, net.hosts, hopHeaders, authAccount); if (hopInject.error) return { ok: false, message: hopInject.error }; + currentUsedExchange = hopInject.usedExchange; + currentOauthInjected = hopInject.oauthInjected; currentConnectionInjected = hopInject.connectionInjected; - usedExchange ||= hopInject.usedExchange; - for (const [k, v] of hopInject.oauthInjected) oauthInjected.set(k, v); - for (const [k, v] of hopInject.connectionInjected) connectionInjected.set(k, v); } if (currentConnectionInjected.size > 0) { let current: { @@ -1183,6 +1189,8 @@ export class GhostNetworkSlot { signal: controller.signal, redirect: 'manual', }); + responseUsedExchange = currentUsedExchange; + responseOauthInjected = currentOauthInjected; responseConnectionInjected = currentConnectionInjected; responseMethod = currentMethod; if (![301, 302, 303, 307, 308].includes(response.status)) break; @@ -1232,9 +1240,16 @@ export class GhostNetworkSlot { }); break; } + retryUsedExchange = responseUsedExchange; + retryOauthInjected = responseOauthInjected; + retryConnectionInjected = responseConnectionInjected; if ( response.status === 401 - && (usedExchange || oauthInjected.size > 0 || responseConnectionInjected.size > 0) + && ( + responseUsedExchange + || responseOauthInjected.size > 0 + || responseConnectionInjected.size > 0 + ) && attempt === 0 ) { // 丢弃本次响应体(best-effort),换新令牌整链重试一次。 @@ -1467,7 +1482,8 @@ export class GhostNetworkSlot { ghostId: string, secrets: readonly GhostSecretDecl[], connectionDecls: readonly GhostConnectionDecl[], - hostname: string, + url: URL, + method: string, allHosts: readonly string[], headers: Record, authAccount?: string, @@ -1500,9 +1516,8 @@ export class GhostNetworkSlot { { membershipId: string; audience: string; hostname: string } >(); for (const secret of secrets) { - const scope = secret.inject.hosts ?? allHosts; - if (!scope.some((pattern) => ghostNetworkHostMatches(pattern, hostname))) continue; - const resolved = await this.resolveSecretValue(ghostId, secret, hostname, authAccount); + if (!ghostSecretInjectMatches(secret.inject, url, method, allHosts)) continue; + const resolved = await this.resolveSecretValue(ghostId, secret, url.hostname, authAccount); if ('error' in resolved) { return { error: resolved.error, usedExchange, oauthInjected, connectionInjected }; } @@ -1520,11 +1535,11 @@ export class GhostNetworkSlot { // 与 secrets 同款快速失败,不发一个注定 401 的请求。 if (connectionDecls.length > 0 && this.deps.connections) { const connHosts = this.deps.connections.hostsFor(ghostId); - if (connHosts.includes(hostname)) { - const tok = this.deps.connections.tokenFor(ghostId, hostname); + if (connHosts.includes(url.hostname)) { + const tok = this.deps.connections.tokenFor(ghostId, url.hostname); if (!tok) { return { - error: `连接地址 ${hostname} 的凭证读取失败——请到主界面侧边栏「插件」的本插件详情页重新添加该连接`, + error: `连接地址 ${url.hostname} 的凭证读取失败——请到主界面侧边栏「插件」的本插件详情页重新添加该连接`, usedExchange, oauthInjected, connectionInjected, diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 6177927fbbc..bcc6731523e 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -3484,7 +3484,7 @@ "fsWriteDetail": "Its own private data folder is always writable. Writes into the current session's working directory follow the session's permission mode (auto-approve modes write directly; per-action modes ask you first). Any other folder always requires your confirmation. Files are always written by the host — the plugin itself never touches your file system.", "networkHost": "Accesses the network domain {{host}}", "networkSecret": "Needs a credential from you: \"{{name}}\"", - "networkSecretGhostInputDetail": "Collected by this plugin's own settings UI — the plugin page sees the value at entry time. It is then handed to the host in one step for encrypted storage; once stored, the plugin can never read it back, and the host injects it only for the domains it declares.", + "networkSecretGhostInputDetail": "Collected by this plugin's own settings UI — the plugin page sees the value at entry time. It is then handed to the host in one step for encrypted storage; once stored, the plugin can never read it back, and the host injects it only when a request matches its declared domains and any optional path/method scope.", "networkSecretIdentity": "Will use your login email as the credential \"{{name}}\"", "networkSecretIdentityDetail": "The value comes from your signed-in account's email, derived and injected by the host at request time; nothing to fill in. The plugin can read this email to show the current identity in its settings UI.", "networkSecretGhCli": "Will prefer your local GitHub CLI login for the credential \"{{name}}\"", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 5ddc402983b..00bb20151b9 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -3483,7 +3483,7 @@ "fsWriteDetail": "専用データフォルダにはいつでも書き込めます。現在のセッションの作業ディレクトリへの書き込みはセッションの権限モードに従います(自動承認モードでは直接書き込み、逐次確認モードでは事前に承認を求めます)。それ以外のフォルダへの書き込みは毎回確認が必要です。ファイルは常にホストが代理で書き込み、プラグイン自体があなたのファイルシステムに直接アクセスすることはありません。", "networkHost": "ネットワークドメイン {{host}} にアクセス", "networkSecret": "認証情報「{{name}}」の入力が必要", - "networkSecretGhostInputDetail": "この資格情報はプラグイン自身の設定画面で入力を受け付けるため、入力時にはプラグインのページを経由します。その後ホストへ一度だけ渡されて暗号化保存され、保存後はプラグインから読み出せません。注入は宣言されたドメインへのリクエスト時のみホストが行います。", + "networkSecretGhostInputDetail": "この資格情報はプラグイン自身の設定画面で入力を受け付けるため、入力時にはプラグインのページを経由します。その後ホストへ一度だけ渡されて暗号化保存され、保存後はプラグインから読み出せません。ホストは、宣言されたドメインと任意のパス/メソッド範囲にリクエストが一致する場合にのみ注入します。", "networkSecretIdentity": "ログインメールアドレスを認証情報「{{name}}」として使用します", "networkSecretIdentityDetail": "値はログイン中のアカウントのメールアドレスから取得され、リクエスト時にホストが自動的に導出・注入します。入力は不要です。プラグインは設定画面に現在の身元を表示する目的で、このメールアドレスを読み取れます。", "networkSecretGhCli": "認証情報「{{name}}」にはローカルの GitHub CLI ログインを優先して使用します", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index f94e4643c43..8db32d2a117 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -3483,7 +3483,7 @@ "fsWriteDetail": "전용 데이터 폴더에는 언제든지 쓸 수 있습니다. 현재 세션의 작업 디렉터리 쓰기는 세션의 권한 모드를 따릅니다(자동 승인 모드에서는 바로 쓰고, 개별 확인 모드에서는 먼저 승인을 요청합니다). 그 외 폴더에 쓰려면 매번 확인이 필요합니다. 파일은 항상 호스트가 대신 기록하며 플러그인 자체는 파일 시스템에 직접 접근할 수 없습니다.", "networkHost": "네트워크 도메인 {{host}}에 접근", "networkSecret": "자격 증명 \"{{name}}\" 입력 필요", - "networkSecretGhostInputDetail": "이 자격 증명은 플러그인 자체 설정 화면에서 입력받으므로 입력 시점에 플러그인 페이지를 거칩니다. 이후 호스트에 한 번만 전달되어 암호화 저장되며, 저장 후에는 플러그인이 다시 읽을 수 없습니다. 주입은 선언된 도메인 요청 시에만 호스트가 수행합니다.", + "networkSecretGhostInputDetail": "이 자격 증명은 플러그인 자체 설정 화면에서 입력받으므로 입력 시점에 플러그인 페이지를 거칩니다. 이후 호스트에 한 번만 전달되어 암호화 저장되며, 저장 후에는 플러그인이 다시 읽을 수 없습니다. 호스트는 요청이 선언된 도메인과 선택적 경로/메서드 범위에 일치할 때만 주입합니다.", "networkSecretIdentity": "로그인 이메일을 자격 증명 \"{{name}}\"(으)로 사용합니다", "networkSecretIdentityDetail": "값은 로그인된 계정의 이메일에서 가져오며, 요청 시 호스트가 자동으로 파생하여 주입합니다. 입력할 필요가 없으며, 플러그인은 설정 화면에 현재 신원을 표시하기 위해 이 이메일을 읽을 수 있습니다.", "networkSecretGhCli": "자격 증명 \"{{name}}\"에는 로컬 GitHub CLI 로그인을 우선 사용합니다", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 5b0e01d1a3d..e54dba007e5 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -3483,7 +3483,7 @@ "fsWriteDetail": "它自己的专属数据目录随时可写;当前任务的工作目录跟随任务的权限模式(免批模式直接写,逐条确认模式先征求你同意);其它目录每次都需要你确认。文件始终由主机代写,插件本身无法直接访问你的文件系统。", "networkHost": "访问网络域名 {{host}}", "networkSecret": "需要你提供凭证「{{name}}」", - "networkSecretGhostInputDetail": "凭证由这个插件自己的设置界面收集,录入时会经过插件页面;随后一次性交给主机加密保管,存入后插件无法读回,主机只在请求它声明的域名时注入。", + "networkSecretGhostInputDetail": "凭证由这个插件自己的设置界面收集,录入时会经过插件页面;随后一次性交给主机加密保管,存入后插件无法读回,主机只在请求命中它声明的域名及可选路径/方法范围时注入。", "networkSecretIdentity": "将使用你的登录邮箱作为凭证「{{name}}」", "networkSecretIdentityDetail": "值取自当前登录账号的邮箱,请求时由主机自动派生注入;无需填写。插件可读取该邮箱,用于在它的设置界面里展示当前身份。", "networkSecretGhCli": "将优先使用本机 GitHub CLI 登录作为凭证「{{name}}」", diff --git a/apps/desktop/src/shared/__tests__/ghost.test.ts b/apps/desktop/src/shared/__tests__/ghost.test.ts index eb6503c9f82..8ca42cb29f3 100644 --- a/apps/desktop/src/shared/__tests__/ghost.test.ts +++ b/apps/desktop/src/shared/__tests__/ghost.test.ts @@ -2516,6 +2516,70 @@ describe('ghost · network 详单校验', () => { expect(dup.ok).toBe(false); }); + it('secrets.inject.paths/methods 精确限制凭证范围并稳定归一化', () => { + const r = validateGhostManifest( + withNet({ + hosts: ['api.example.com'], + secrets: [{ + ...goodSecret(), + inject: { + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/v1/z', '/v1/convert'], + methods: ['POST', 'GET'], + }, + }], + }), + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.manifest.network?.secrets?.[0]?.inject).toEqual({ + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/v1/convert', '/v1/z'], + methods: ['GET', 'POST'], + }); + expect( + ghostPermissionItems(r.manifest).find((i) => i.key === 'network:secret:api_token')?.detail, + ).toContain('Paths: /v1/convert, /v1/z'); + }); + + it('secrets.inject.paths/methods 非空、无重复且只接受规范精确值', () => { + const inject = (extra: Record) => + validateGhostManifest(withNet({ + hosts: ['api.example.com'], + secrets: [{ ...goodSecret(), inject: { header: 'X-Token', format: '{value}', ...extra } }], + })); + for (const paths of [[], ['v1/convert'], ['/v1/convert?x=1'], ['/a/../b'], ['/%2Fsecret'], ['/x', '/x']]) { + expect(inject({ paths }).ok, JSON.stringify(paths)).toBe(false); + } + for (const methods of [[], ['post'], ['HEAD'], ['POST', 'POST']]) { + expect(inject({ methods }).ok, JSON.stringify(methods)).toBe(false); + } + }); + + it('旧 host-only secret 不改变权限 baseline;显式 endpoint 范围变化进入更新 diff', () => { + const before = validateGhostManifest( + withNet({ hosts: ['api.example.com'], secrets: [goodSecret()] }), + ); + const scoped = validateGhostManifest( + withNet({ + hosts: ['api.example.com'], + secrets: [{ + ...goodSecret(), + inject: { header: 'Authorization', format: 'Bearer {value}', paths: ['/v1/convert'], methods: ['POST'] }, + }], + }), + ); + expect(before.ok && scoped.ok).toBe(true); + if (!before.ok || !scoped.ok) return; + expect(ghostPermissionItems(before.manifest).find((i) => i.key === 'network:secret:api_token')?.detail).toBeUndefined(); + const narrowed = diffGhostPermissionItems(before.manifest, scoped.manifest); + expect(narrowed.added.map((i) => i.key)).toContain('network:secret:api_token'); + const expanded = diffGhostPermissionItems(scoped.manifest, before.manifest); + expect(expanded.added.map((i) => i.key)).toContain('network:secret:api_token'); + }); + it('secrets.inject.hosts 必须是 hosts 声明条目的子集(逐字)', () => { const ok = validateGhostManifest( withNet({ diff --git a/apps/desktop/src/shared/ghost.ts b/apps/desktop/src/shared/ghost.ts index 8865618fb7f..fa7077b9523 100644 --- a/apps/desktop/src/shared/ghost.ts +++ b/apps/desktop/src/shared/ghost.ts @@ -585,6 +585,51 @@ export function ghostNetworkHostMatches(pattern: string, hostname: string): bool return hostname === pattern; } +/** 凭证注入的精确 pathname 条数与单项长度上限。 */ +export const GHOST_SECRET_INJECT_MAX_PATHS = 16; +export const GHOST_SECRET_INJECT_PATH_MAX_CHARS = 1024; + +/** + * 第一版 endpoint allowlist 只接受规范化的绝对 pathname:精确、大小写与 + * 尾斜杠敏感,query / fragment 不参与;有歧义的编码分隔符 fail closed。 + */ +export function isValidGhostSecretInjectPath(pathname: unknown): pathname is string { + if ( + typeof pathname !== 'string' + || pathname.length === 0 + || pathname.length > GHOST_SECRET_INJECT_PATH_MAX_CHARS + || !pathname.startsWith('/') + || pathname.includes('?') + || pathname.includes('#') + || pathname.includes('\\') + // eslint-disable-next-line no-control-regex -- 控制字符是显式清洗目标 + || /[\x00-\x1f\x7f]/.test(pathname) + || /%(?:2f|5c)/i.test(pathname) + || /%(?![0-9a-f]{2})/i.test(pathname) + ) { + return false; + } + try { + return new URL(pathname, 'https://ghost.invalid').pathname === pathname; + } catch { + return false; + } +} + +/** host + 可选精确 pathname + 可选 method 三项同时命中才允许注入。 */ +export function ghostSecretInjectMatches( + inject: GhostSecretInjectDecl, + url: URL, + method: string, + allHosts: readonly string[], +): boolean { + const hosts = inject.hosts ?? allHosts; + if (!hosts.some((pattern) => ghostNetworkHostMatches(pattern, url.hostname))) return false; + if (inject.paths !== undefined && !inject.paths.includes(url.pathname)) return false; + if (inject.methods !== undefined && !inject.methods.includes(method as GhostFetchMethod)) return false; + return true; +} + /** * 凭证注入声明:该凭证以什么形态、进哪些域名的请求头。绑定在 secret 上 * (而非独立 auth 模板)是刻意的——结构上保证"key 只流向它声明的域名", @@ -596,6 +641,10 @@ export interface GhostSecretInjectDecl { * 缺省 = 详单里的全部域名。 */ hosts?: string[]; + /** 精确 URL.pathname 白名单;缺省 = 该 host 下全部路径。 */ + paths?: string[]; + /** HTTP method 白名单;缺省 = 代理 fetch 支持的全部方法。 */ + methods?: GhostFetchMethod[]; /** 注入的请求头名(如 Authorization / X-Subscription-Token)。 */ header: string; /** 头值模板:恰含一个 `{value}` 占位,其余为静态文本(如 `Bearer {value}`)。 */ @@ -1769,6 +1818,17 @@ export function ghostPermissionItems(manifest: GhostManifest): GhostPermissionIt }); } for (const secret of manifest.network?.secrets ?? []) { + // 旧 host-only manifest 不新增 detail,避免存量权限 baseline 集体变化;作者 + // 显式声明 endpoint 范围时才把规范化事实写进 detail,后续扩大、替换或 + // 删除限制都会被现有 key+detail diff 识别并要求复核。 + const endpointScope = + secret.inject.paths !== undefined || secret.inject.methods !== undefined + ? [ + `Hosts: ${(secret.inject.hosts ?? manifest.network?.hosts ?? []).join(', ')}`, + `Paths: ${secret.inject.paths?.join(', ') ?? '*'}`, + `Methods: ${secret.inject.methods?.join(', ') ?? '*'}`, + ].join('\n') + : undefined; // 来源分档文案:登录邮箱派生 vs 用户自填(意识 settingsHtml 收单——宿主 // 凭证渲染已退役,user 凭证只剩这一档)。收单档文案不许说"意识代码无法 // 读取"这种过头话:录入瞬间明文经过意识页面,知情同意面要如实。 @@ -1790,9 +1850,13 @@ export function ghostPermissionItems(manifest: GhostManifest): GhostPermissionIt labelKey: 'networkSecretOauth', labelArgs: { name: secret.label, host: authorizeHost }, detailKey: 'networkSecretOauthDetail', - ...(secret.oauth.scopes && secret.oauth.scopes.length > 0 - ? { detail: secret.oauth.scopes.join('\n') } - : {}), + ...( + secret.oauth.scopes && secret.oauth.scopes.length > 0 + ? { detail: [secret.oauth.scopes.join('\n'), endpointScope].filter(Boolean).join('\n') } + : endpointScope !== undefined + ? { detail: endpointScope } + : {} + ), }); continue; } @@ -1803,6 +1867,7 @@ export function ghostPermissionItems(manifest: GhostManifest): GhostPermissionIt labelKey: 'networkSecretOrganizationIdentity', labelArgs: { name: secret.label }, detailKey: 'networkSecretOrganizationIdentityDetail', + ...(endpointScope !== undefined ? { detail: endpointScope } : {}), }); continue; } @@ -1823,6 +1888,7 @@ export function ghostPermissionItems(manifest: GhostManifest): GhostPermissionIt labelKey: identity ? 'networkSecretIdentity' : 'networkSecret', labelArgs: { name: secret.label }, detailKey: identity ? 'networkSecretIdentityDetail' : 'networkSecretGhostInputDetail', + ...(endpointScope !== undefined ? { detail: endpointScope } : {}), }); } // fs 槽:写文件是仅次于出网的敏感能力,紧随 network 之后展示。三档目的地 @@ -3824,6 +3890,62 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { injectHosts.push(ihNorm); } } + let injectPaths: string[] | undefined; + if (inj.paths !== undefined) { + if ( + !Array.isArray(inj.paths) + || inj.paths.length === 0 + || inj.paths.length > GHOST_SECRET_INJECT_MAX_PATHS + ) { + return { + ok: false, + reason: `network.secrets[].inject.paths 必须是 1–${GHOST_SECRET_INJECT_MAX_PATHS} 条精确 pathname 的数组(或省略 = 全部路径)`, + }; + } + injectPaths = []; + for (const path of inj.paths) { + if (!isValidGhostSecretInjectPath(path)) { + return { + ok: false, + reason: `network.secrets[].inject.paths 含非法条目 ${JSON.stringify(path)}(必须是规范化的绝对 pathname;不含 query/fragment/反斜杠/编码分隔符)`, + }; + } + if (injectPaths.includes(path)) { + return { ok: false, reason: `network.secrets[].inject.paths 含重复条目 ${JSON.stringify(path)}` }; + } + injectPaths.push(path); + } + injectPaths.sort(); + } + let injectMethods: GhostFetchMethod[] | undefined; + if (inj.methods !== undefined) { + if (!Array.isArray(inj.methods) || inj.methods.length === 0) { + return { + ok: false, + reason: 'network.secrets[].inject.methods 必须是非空数组(或省略 = 全部支持的方法)', + }; + } + injectMethods = []; + for (const method of inj.methods) { + if ( + typeof method !== 'string' + || !(GHOST_FETCH_METHODS as readonly string[]).includes(method) + ) { + return { + ok: false, + reason: `network.secrets[].inject.methods 含未知项 ${JSON.stringify(method)}(可用:${GHOST_FETCH_METHODS.join(' / ')})`, + }; + } + const typed = method as GhostFetchMethod; + if (injectMethods.includes(typed)) { + return { ok: false, reason: `network.secrets[].inject.methods 含重复条目 ${JSON.stringify(method)}` }; + } + injectMethods.push(typed); + } + injectMethods.sort( + (a, b) => GHOST_FETCH_METHODS.indexOf(a) - GHOST_FETCH_METHODS.indexOf(b), + ); + } if (source === 'oidc-token') { if (inj.header !== 'Authorization' || inj.format !== 'Bearer {value}') { return { @@ -4257,6 +4379,8 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { header: inj.header, format: inj.format, ...(injectHosts !== undefined ? { hosts: injectHosts } : {}), + ...(injectPaths !== undefined ? { paths: injectPaths } : {}), + ...(injectMethods !== undefined ? { methods: injectMethods } : {}), }, ...(exchange !== undefined ? { exchange } : {}), ...(oauth !== undefined ? { oauth } : {}), From f2077791ea24f5171ee1406f89b124f60aa0d549 Mon Sep 17 00:00:00 2001 From: SAN Date: Thu, 6 Aug 2026 19:31:42 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(ghost):=20=E6=8C=89=20Copilot=20review?= =?UTF-8?q?=20=E6=94=B6=E5=8F=A3=20401=20=E9=87=8D=E8=AF=95=E4=B8=8E?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 401 整链重试仅当最终响应 method 与原始请求 method 一致时进行: 3xx 把 POST 降级为 GET 并丢 body 后不再重放原始副作用请求。 - 每跳注入注释更新为按 host / pathname / method 重算的实际行为。 - inject.hosts 排序归一化,与 paths / methods 同款,权限 detail / diff 不再随声明顺序抖动。 Co-Authored-By: Claude Signed-off-by: SAN --- .../cindy-brain/__tests__/networkSlot.test.ts | 33 +++++++++++++++++++ .../src/main/cindy-brain/networkSlot.ts | 7 ++-- apps/desktop/src/shared/ghost.ts | 3 ++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts index 11f8d48e0f7..1a31f55855a 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts @@ -1770,6 +1770,39 @@ describe('networkSlot · 凭证交换(key 换令牌二段式)', () => { expect(fetchImpl).toHaveBeenCalledTimes(1); }); + it('POST 经 302 降级为 GET 后 401:不重放原始 POST(副作用请求不重复)', async () => { + // 链路:POST /submit → 302 Location /result(降级 GET、丢 body)→ GET /result 401。 + // 交换型凭证收到 401 本会作废重换后整链重试,但降级后重试会把原始 POST + // 再发一遍,违背"降级成 GET 后不得在 401 后重放副作用请求"的意图,因此 + // 只有当最终响应 method 与原始 method 一致时才允许重试。 + const { slot, fetchImpl } = makeExchangeSlot({ + tokenResponses: [ + () => fakeResponse({ body: '{"session":"tok-1"}' }), + () => fakeResponse({ body: '{"session":"tok-2"}' }), + ], + apiResponses: [ + () => fakeResponse({ status: 302, headers: { location: 'https://aigc.example.com/result' } }), + () => fakeResponse({ status: 401, body: '{"error":"expired"}' }), + ], + }); + const r = await slot.handleFetchRequest('web-search', { + url: 'https://aigc.example.com/submit', + method: 'POST', + body: 'payload=1', + }); + expect(r.ok).toBe(true); + if (r.ok && 'body' in r) expect(r.status).toBe(401); + const api = apiCalls(fetchImpl); + // 只走一跳:原始 POST → 降级后的 GET /result(401)即止,没有第二次整链重试。 + expect(api).toHaveLength(2); + expect(api[0][1].method).toBe('POST'); + expect(api[0][1].body).toBe('payload=1'); + expect(api[1][1].method).toBe('GET'); + expect(api[1][1].body).toBeUndefined(); + // 未重放原始 POST,令牌也不重换。 + expect(exchangeCalls(fetchImpl)).toHaveLength(1); + }); + it('交换端点非 2xx:整单结构化失败,错误带状态码与摘录、不发业务请求、不泄 key', async () => { const { slot, fetchImpl } = makeExchangeSlot({ tokenResponses: [() => fakeResponse({ status: 403, body: 'invalid subscriber' })], diff --git a/apps/desktop/src/main/cindy-brain/networkSlot.ts b/apps/desktop/src/main/cindy-brain/networkSlot.ts index bbfe8afe50a..91a82af825a 100644 --- a/apps/desktop/src/main/cindy-brain/networkSlot.ts +++ b/apps/desktop/src/main/cindy-brain/networkSlot.ts @@ -1147,8 +1147,10 @@ export class GhostNetworkSlot { // headers 语义;multipart 的 boundary 头留着会误导服务端)。 if (bodyDropped) deleteHeaderVariants(hopHeaders, 'Content-Type'); if (hop > 0) { - // 换了域名的跳转:上一跳注入的凭证不能跟着走,按新 host 重算 - // (injectSecrets 开头会先把所有声明凭证头的大小写变体删干净)。 + // 每一跳都按实际 host / pathname / method 重新匹配凭证注入:换了 + // 域名的跳转上一跳注入的凭证不能跟着走,同域不同 endpoint 的跳转 + // 也要重新判断(injectSecrets 开头会先把所有声明凭证头的大小写 + // 变体删干净)。 const hopInject = await this.injectSecrets(ghostId, net.secrets ?? [], connectionDecls, currentUrl, currentMethod, net.hosts, hopHeaders, authAccount); if (hopInject.error) return { ok: false, message: hopInject.error }; currentUsedExchange = hopInject.usedExchange; @@ -1245,6 +1247,7 @@ export class GhostNetworkSlot { retryConnectionInjected = responseConnectionInjected; if ( response.status === 401 + && responseMethod === originalRequestMethod && ( responseUsedExchange || responseOauthInjected.size > 0 diff --git a/apps/desktop/src/shared/ghost.ts b/apps/desktop/src/shared/ghost.ts index fa7077b9523..cb4e21a22d3 100644 --- a/apps/desktop/src/shared/ghost.ts +++ b/apps/desktop/src/shared/ghost.ts @@ -3889,6 +3889,9 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { } injectHosts.push(ihNorm); } + // 与 paths / methods 同款排序归一化:host 顺序的无意义变动不该引起 + // 权限 detail / diff 抖动。 + injectHosts.sort(); } let injectPaths: string[] | undefined; if (inj.paths !== undefined) { From 3df6c6910995ce94eb1ae0bd18cbdace8d80512c Mon Sep 17 00:00:00 2001 From: SAN Date: Thu, 6 Aug 2026 20:50:57 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(ghost):=20inject.hosts=20=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E5=8F=AA=E5=AF=B9=E6=96=B0=E5=AD=97=E6=AE=B5=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E7=94=9F=E6=95=88,=E4=BF=9D=E6=8A=A4=E6=97=A7?= =?UTF-8?q?=E6=B8=85=E5=8D=95=20digest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifestDigest 按数组原始顺序计算(canonicalJson 不排序);对旧 host-only 清单排序会让已装插件的账本摘要永久失配,触发市场所有权 检查与 OIDC 签发拒绝(AGENTS.md 存量插件兼容红线)。 改为:仅当同一凭证声明 inject.paths / inject.methods 时排序 hosts (此时权限 detail 需要稳定);旧清单归一化输出与升级前逐字节一致。 权限 detail 的 Hosts 列表同样排序(Greptile P2),避免省略 hosts 时 按 network.hosts 声明顺序抖动。 Co-Authored-By: Claude Signed-off-by: SAN --- .../main/cindy-brain/__tests__/forge.test.ts | 1 - .../src/shared/__tests__/ghost.test.ts | 47 +++++++++++++++++++ apps/desktop/src/shared/ghost.ts | 12 +++-- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts index f846acdef2b..807632d4084 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts @@ -786,7 +786,6 @@ describe('FORGE_GUIDE', () => { // 2026-07-31 快问快答(cindy.text.oneshot)与派活取件(agent.errand)。 'oneshot_text', 'NO_CANDIDATE', -<<<<<<< HEAD // 2026-08-05 快问快答偏好模型声明(目录模型 id;用户钉档 > 插件声明 > 默认链)。 'oneshotModel', '"paths": ["/v1/convert"]', diff --git a/apps/desktop/src/shared/__tests__/ghost.test.ts b/apps/desktop/src/shared/__tests__/ghost.test.ts index 8ca42cb29f3..064a6f0083b 100644 --- a/apps/desktop/src/shared/__tests__/ghost.test.ts +++ b/apps/desktop/src/shared/__tests__/ghost.test.ts @@ -2580,6 +2580,53 @@ describe('ghost · network 详单校验', () => { expect(expanded.added.map((i) => i.key)).toContain('network:secret:api_token'); }); + it('旧 host-only 清单的 inject.hosts 保持声明顺序(manifestDigest 兼容,不排序)', () => { + // manifestDigest 按数组原始顺序计算;对旧插件排序会让已装插件的账本摘要 + // 永久失配,触发市场所有权检查与 OIDC 签发拒绝(AGENTS.md 存量兼容红线)。 + const r = validateGhostManifest( + withNet({ + hosts: ['api.example.com', 'cdn.example.com'], + secrets: [{ + ...goodSecret(), + inject: { header: 'X-Token', format: '{value}', hosts: ['cdn.example.com', 'api.example.com'] }, + }], + }), + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.manifest.network?.secrets?.[0]?.inject.hosts).toEqual([ + 'cdn.example.com', + 'api.example.com', + ]); + }); + + it('声明 paths/methods 时 inject.hosts 排序归一化(权限 detail 稳定)', () => { + const r = validateGhostManifest( + withNet({ + hosts: ['api.example.com', 'cdn.example.com'], + secrets: [{ + ...goodSecret(), + inject: { + header: 'X-Token', + format: '{value}', + hosts: ['cdn.example.com', 'api.example.com'], + paths: ['/v1/convert'], + }, + }], + }), + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.manifest.network?.secrets?.[0]?.inject.hosts).toEqual([ + 'api.example.com', + 'cdn.example.com', + ]); + // 权限 detail 里的 Hosts 列表同样稳定排序(省略 hosts 时按 network.hosts 排序)。 + const items = ghostPermissionItems(r.manifest); + const detail = items.find((i) => i.key === 'network:secret:api_token')?.detail; + expect(detail).toContain('Hosts: api.example.com, cdn.example.com'); + }); + it('secrets.inject.hosts 必须是 hosts 声明条目的子集(逐字)', () => { const ok = validateGhostManifest( withNet({ diff --git a/apps/desktop/src/shared/ghost.ts b/apps/desktop/src/shared/ghost.ts index cb4e21a22d3..a1e4e4ef26f 100644 --- a/apps/desktop/src/shared/ghost.ts +++ b/apps/desktop/src/shared/ghost.ts @@ -1824,7 +1824,7 @@ export function ghostPermissionItems(manifest: GhostManifest): GhostPermissionIt const endpointScope = secret.inject.paths !== undefined || secret.inject.methods !== undefined ? [ - `Hosts: ${(secret.inject.hosts ?? manifest.network?.hosts ?? []).join(', ')}`, + `Hosts: ${[...(secret.inject.hosts ?? manifest.network?.hosts ?? [])].sort().join(', ')}`, `Paths: ${secret.inject.paths?.join(', ') ?? '*'}`, `Methods: ${secret.inject.methods?.join(', ') ?? '*'}`, ].join('\n') @@ -3889,9 +3889,6 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { } injectHosts.push(ihNorm); } - // 与 paths / methods 同款排序归一化:host 顺序的无意义变动不该引起 - // 权限 detail / diff 抖动。 - injectHosts.sort(); } let injectPaths: string[] | undefined; if (inj.paths !== undefined) { @@ -3949,6 +3946,13 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { (a, b) => GHOST_FETCH_METHODS.indexOf(a) - GHOST_FETCH_METHODS.indexOf(b), ); } + // 排序归一化只对声明了新字段(inject.paths / inject.methods)的凭证生效: + // 旧 host-only 清单的归一化输出必须与升级前逐字节一致(manifestDigest 按 + // 数组原始顺序计算,排序会让已装插件的账本摘要永久失配,触发市场所有权 + // 检查与 OIDC 签发拒绝)。新字段一经声明,权限 detail 就要稳定。 + if (injectHosts !== undefined && (injectPaths !== undefined || injectMethods !== undefined)) { + injectHosts.sort(); + } if (source === 'oidc-token') { if (inj.header !== 'Authorization' || inj.format !== 'Bearer {value}') { return { From de7d31906469079942624b7eb5d88271037408af Mon Sep 17 00:00:00 2001 From: SAN Date: Sat, 8 Aug 2026 10:50:23 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(ghost):=20endpoint-scoped=20secret=20?= =?UTF-8?q?=E6=B3=A8=E5=85=A5=E8=A6=81=E6=B1=82=20schemaVersion=203(mixed-?= =?UTF-8?q?version=20fail-closed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 清单声明 inject.paths/methods 直接拒装:旧客户端不识别这两个字段, 放行会让收窄静默退化为整域注入(fail-open)。声明新字段即升级版本, 旧客户端对 v3 整包拒装(schemaVersion 严格相等检查),形成 fail-closed 边界。normalize 输出保留输入版本,v3 清单 manifestDigest 与打包时一致。 Co-Authored-By: Claude Signed-off-by: SAN --- .../src/shared/__tests__/ghost.test.ts | 115 ++++++++++++------ apps/desktop/src/shared/ghost.ts | 23 +++- 2 files changed, 95 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/shared/__tests__/ghost.test.ts b/apps/desktop/src/shared/__tests__/ghost.test.ts index 064a6f0083b..512aa2c467a 100644 --- a/apps/desktop/src/shared/__tests__/ghost.test.ts +++ b/apps/desktop/src/shared/__tests__/ghost.test.ts @@ -144,11 +144,11 @@ describe('ghost · 清单校验', () => { expect((v as { ok: true; manifest: GhostManifest }).manifest.panel).toBeUndefined(); }); - it('非对象 / schemaVersion 不是 2 → 拒绝(v1 声明型已移除)', () => { + it('非对象 / schemaVersion 非 2 或 3 → 拒绝(v1 声明型已移除)', () => { expect(validateGhostManifest(null).ok).toBe(false); expect(validateGhostManifest([]).ok).toBe(false); expect(validateGhostManifest({ ...goodManifest(), schemaVersion: 1 }).ok).toBe(false); - expect(validateGhostManifest({ ...goodManifest(), schemaVersion: 3 }).ok).toBe(false); + expect(validateGhostManifest({ ...goodManifest(), schemaVersion: 4 }).ok).toBe(false); }); it('id / name / version 的边界', () => { @@ -2516,20 +2516,23 @@ describe('ghost · network 详单校验', () => { expect(dup.ok).toBe(false); }); - it('secrets.inject.paths/methods 精确限制凭证范围并稳定归一化', () => { + it('secrets.inject.paths/methods 精确限制凭证范围并稳定归一化(schemaVersion 3)', () => { const r = validateGhostManifest( - withNet({ - hosts: ['api.example.com'], - secrets: [{ - ...goodSecret(), - inject: { - header: 'Authorization', - format: 'Bearer {value}', - paths: ['/v1/z', '/v1/convert'], - methods: ['POST', 'GET'], - }, - }], - }), + withNet( + { + hosts: ['api.example.com'], + secrets: [{ + ...goodSecret(), + inject: { + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/v1/z', '/v1/convert'], + methods: ['POST', 'GET'], + }, + }], + }, + { schemaVersion: 3 }, + ), ); expect(r.ok).toBe(true); if (!r.ok) return; @@ -2546,10 +2549,13 @@ describe('ghost · network 详单校验', () => { it('secrets.inject.paths/methods 非空、无重复且只接受规范精确值', () => { const inject = (extra: Record) => - validateGhostManifest(withNet({ - hosts: ['api.example.com'], - secrets: [{ ...goodSecret(), inject: { header: 'X-Token', format: '{value}', ...extra } }], - })); + validateGhostManifest(withNet( + { + hosts: ['api.example.com'], + secrets: [{ ...goodSecret(), inject: { header: 'X-Token', format: '{value}', ...extra } }], + }, + { schemaVersion: 3 }, + )); for (const paths of [[], ['v1/convert'], ['/v1/convert?x=1'], ['/a/../b'], ['/%2Fsecret'], ['/x', '/x']]) { expect(inject({ paths }).ok, JSON.stringify(paths)).toBe(false); } @@ -2558,18 +2564,48 @@ describe('ghost · network 详单校验', () => { } }); + it('schemaVersion 2 声明 paths/methods 拒装(旧客户端会静默忽略 → fail-open,强制升级 v3)', () => { + // 旧客户端不识别 paths/methods,放行会让收窄退化为整域注入;因此声明新字段 + // 必须升级 schemaVersion 3,旧客户端对 v3 整包拒装(schemaVersion 严格相等)。 + for (const extra of [ + { paths: ['/v1/convert'] }, + { methods: ['POST'] }, + { paths: ['/v1/convert'], methods: ['POST'] }, + ]) { + const r = validateGhostManifest(withNet({ + hosts: ['api.example.com'], + secrets: [{ ...goodSecret(), inject: { header: 'X-Token', format: '{value}', ...extra } }], + })); + expect(r.ok, JSON.stringify(extra)).toBe(false); + if (!r.ok) expect(r.reason).toContain('schemaVersion'); + } + }); + + it('schemaVersion 3 的 host-only 清单照常放行(向后兼容)', () => { + const r = validateGhostManifest( + withNet({ hosts: ['api.example.com'], secrets: [goodSecret()] }, { schemaVersion: 3 }), + ); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.manifest.schemaVersion).toBe(3); + expect(r.manifest.network?.secrets?.[0]?.inject.hosts).toBeUndefined(); + }); + it('旧 host-only secret 不改变权限 baseline;显式 endpoint 范围变化进入更新 diff', () => { const before = validateGhostManifest( withNet({ hosts: ['api.example.com'], secrets: [goodSecret()] }), ); const scoped = validateGhostManifest( - withNet({ - hosts: ['api.example.com'], - secrets: [{ - ...goodSecret(), - inject: { header: 'Authorization', format: 'Bearer {value}', paths: ['/v1/convert'], methods: ['POST'] }, - }], - }), + withNet( + { + hosts: ['api.example.com'], + secrets: [{ + ...goodSecret(), + inject: { header: 'Authorization', format: 'Bearer {value}', paths: ['/v1/convert'], methods: ['POST'] }, + }], + }, + { schemaVersion: 3 }, + ), ); expect(before.ok && scoped.ok).toBe(true); if (!before.ok || !scoped.ok) return; @@ -2602,18 +2638,21 @@ describe('ghost · network 详单校验', () => { it('声明 paths/methods 时 inject.hosts 排序归一化(权限 detail 稳定)', () => { const r = validateGhostManifest( - withNet({ - hosts: ['api.example.com', 'cdn.example.com'], - secrets: [{ - ...goodSecret(), - inject: { - header: 'X-Token', - format: '{value}', - hosts: ['cdn.example.com', 'api.example.com'], - paths: ['/v1/convert'], - }, - }], - }), + withNet( + { + hosts: ['api.example.com', 'cdn.example.com'], + secrets: [{ + ...goodSecret(), + inject: { + header: 'X-Token', + format: '{value}', + hosts: ['cdn.example.com', 'api.example.com'], + paths: ['/v1/convert'], + }, + }], + }, + { schemaVersion: 3 }, + ), ); expect(r.ok).toBe(true); if (!r.ok) return; diff --git a/apps/desktop/src/shared/ghost.ts b/apps/desktop/src/shared/ghost.ts index a1e4e4ef26f..178f2319252 100644 --- a/apps/desktop/src/shared/ghost.ts +++ b/apps/desktop/src/shared/ghost.ts @@ -1311,8 +1311,8 @@ export function isGhostSetupErrorCode(value: unknown): value is GhostSetupErrorC /** ghost.json 清单(不变量由 validateGhostManifest 保证)。 */ export interface GhostManifest { - /** 清单格式版本,恒 2(v1 声明型已于 2026-07-12 移除,无存量不留兼容)。 */ - schemaVersion: 2; + /** 清单格式版本:2 = 基线(v1 声明型已于 2026-07-12 移除);3 = endpoint-scoped 凭证注入。 */ + schemaVersion: 2 | 3; /** 唯一标识,同时是安装目录名与 panelKind 后缀。 */ id: string; /** 展示名。 */ @@ -2800,8 +2800,8 @@ export function resolveGhostManifestLocale( export function validateGhostManifest(raw: unknown): ManifestValidation { if (!isPlainObject(raw)) return { ok: false, reason: '清单不是对象' }; - if (raw.schemaVersion !== 2) { - return { ok: false, reason: `schemaVersion 必须是 2,得到 ${JSON.stringify(raw.schemaVersion)}(v1 声明型已于 2026-07-12 移除)` }; + if (raw.schemaVersion !== 2 && raw.schemaVersion !== 3) { + return { ok: false, reason: `schemaVersion 必须是 2 或 3,得到 ${JSON.stringify(raw.schemaVersion)}(v1 声明型已于 2026-07-12 移除)` }; } if (!isValidGhostId(raw.id)) { return { ok: false, reason: 'id 必须是 1–32 位小写字母/数字/连字符(不能以连字符开头)' }; @@ -3890,6 +3890,18 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { injectHosts.push(ihNorm); } } + // endpoint-scoped 注入(paths / methods)必须声明 schemaVersion 3: + // 旧客户端不识别这两个字段,若放行会让收窄静默退化为整域注入(fail-open)。 + // 声明新字段即升级版本,旧客户端对 v3 整包拒装——mixed-version fail-closed。 + if ( + (inj.paths !== undefined || inj.methods !== undefined) + && raw.schemaVersion !== 3 + ) { + return { + ok: false, + reason: `network.secrets[].inject 声明了 paths/methods,必须使用 schemaVersion 3(旧版本客户端会忽略这些字段并退化为整域注入,故强制升级清单版本)`, + }; + } let injectPaths: string[] | undefined; if (inj.paths !== undefined) { if ( @@ -4633,7 +4645,8 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { return { ok: true, manifest: { - schemaVersion: 2, + // 保留输入版本(v3 清单不得被降级回 v2,否则 manifestDigest 与打包时失配)。 + schemaVersion: raw.schemaVersion as 2 | 3, id: raw.id, name: raw.name, version: raw.version, From 04c8b72432cdc9c3eb253cfd1efb58ee05bcefc2 Mon Sep 17 00:00:00 2001 From: SAN Date: Sat, 8 Aug 2026 13:42:37 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(ghost):=20gh-cli=20=E5=87=AD=E8=AF=81?= =?UTF-8?q?=20endpoint=20=E8=8C=83=E5=9B=B4=E8=BF=9B=E5=85=A5=E6=9D=83?= =?UTF-8?q?=E9=99=90=20detail;FORGE=5FGUIDE=20=E5=90=8C=E6=AD=A5=20v3=20?= =?UTF-8?q?=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gh-cli 分支此前丢弃 endpointScope,扩大/替换限制不会进入权限 diff 与 更新确认(P1);现与其它来源一致把规范化事实写进 detail。 - FORGE_GUIDE:paths 注明 1–16 条上限;声明 paths/methods 必须 schemaVersion 3(主示例、network 段、拒装速查三处同步)。 Co-Authored-By: Claude Signed-off-by: SAN --- apps/desktop/src/main/cindy-brain/forge.ts | 8 +++-- .../src/shared/__tests__/ghost.test.ts | 35 +++++++++++++++++++ apps/desktop/src/shared/ghost.ts | 3 ++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/cindy-brain/forge.ts b/apps/desktop/src/main/cindy-brain/forge.ts index 0594f387f20..2c81402df6c 100644 --- a/apps/desktop/src/main/cindy-brain/forge.ts +++ b/apps/desktop/src/main/cindy-brain/forge.ts @@ -1052,7 +1052,7 @@ my-ghost/ \`\`\`json { - "schemaVersion": 2, + "schemaVersion": 2, // 2 = 基线;声明了 inject.paths/methods(凭证端点收窄)时必须是 3,见 §4.7 "id": "my-ghost", // 小写字母/数字/连字符,1–32 位,全局唯一 "name": "我的意识", // 展示名 "description": "一句话说清这段意识是干嘛的(给人看:装入确认框/详情页)", // 1–${GHOST_MANIFEST_SUMMARY_MAX_CHARS} 字 @@ -1292,8 +1292,9 @@ node 详单**不接受** \`command\` / \`args\` / \`shell\` / \`env\` 或其它 "header": "Authorization", // 注入的请求头名(Host/Cookie 等协议关键头禁用) "format": "Bearer {value}", // 恰含一个 {value} 占位,其余静态文本 "hosts": ["api.example.com"], // 可选:注入范围(hosts 声明条目的子集,逐字);缺省=全部 - "paths": ["/v1/convert"], // 可选:精确 URL.pathname 白名单(大小写/尾斜杠敏感,不含 query);缺省=全部路径 + "paths": ["/v1/convert"], // 可选:精确 URL.pathname 白名单,1–16 条(大小写/尾斜杠敏感,不含 query);缺省=全部路径 "methods": ["POST"] // 可选:GET/POST/PUT/PATCH/DELETE 白名单;缺省=全部支持的方法 + }, // 声明了 paths 或 methods 时,顶层 schemaVersion 必须写 3(旧客户端不认识这两个字段,会整包拒装而非静默放开) }, "exchange": { // 可选:key 换令牌二段式(服务要求先拿 key 换临时令牌时声明,主机照单代办,见 §4.7;与 oauth 互斥) "url": "https://api.example.com/token", // 交换端点(https;域名必须命中 hosts 白名单) @@ -3529,7 +3530,8 @@ if (r.ok && r.confirmed) { - 声明了 tool 槽但缺 tools(或反之)· panel.html 声明了但 slots 没有 "panel" - settingsHtml 路径不合法/文件不在包里 · settingsHeight 越界(160–800)或没配 settingsHtml 单独声明 - panel.systemButtons 格式错(不是对象、未知键、值非布尔,或 position:"tab" 时声明——插件页内面板没有标准头) -- keywords(已废弃字段,旧包兼容保留,新意识别写)有单字词 · kind 写了但不是 "chip"(可省略) · schemaVersion 不是 2 +- keywords(已废弃字段,旧包兼容保留,新意识别写)有单字词 · kind 写了但不是 "chip"(可省略) · schemaVersion 不是 2 或 3 +- inject.paths/methods 格式错(空数组、重复项、非法 pathname/方法、paths 超过 16 条) · 声明了 paths/methods 但 schemaVersion 不是 3(旧客户端会忽略收窄字段,故强制升级) - cindy 详单格式错(未知类目/动作、空数组、有详单但 slots 没有 "cindy") - agent 详单格式错(有详单但 slots 没有 "agent",或 background / errand / schedule 都不是 true;只需点击触发时应省略 agent 字段) - node 详单格式错(槽/详单不成对、entry 不是包内 CommonJS .js/.cjs、protocol 不在 json-rpc-stdio / mcp-stdio、 diff --git a/apps/desktop/src/shared/__tests__/ghost.test.ts b/apps/desktop/src/shared/__tests__/ghost.test.ts index 512aa2c467a..ba44a668949 100644 --- a/apps/desktop/src/shared/__tests__/ghost.test.ts +++ b/apps/desktop/src/shared/__tests__/ghost.test.ts @@ -2422,6 +2422,41 @@ describe('ghost · network 详单校验', () => { } } + // gh-cli 凭证声明 endpoint 范围时,detail 必须带上收窄事实(与其它来源一致), + // 否则后续扩大/替换限制不会进入权限 diff 与更新确认。 + const scoped = validateGhostManifest({ + ...goodManifest(), + id: 'cindy-github', + slots: ['panel', 'network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.github.com'], + secrets: [ + { + key: 'github_pat', + label: 'GitHub authentication', + source: 'gh-cli', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['api.github.com'], + paths: ['/v1/convert'], + methods: ['POST'], + }, + }, + ], + }, + schemaVersion: 3, + }); + expect(scoped.ok, scoped.ok ? '' : scoped.reason).toBe(true); + if (scoped.ok) { + const scopedItem = ghostPermissionItems(scoped.manifest).find( + (entry) => entry.key === 'network:secret:github_pat', + ); + expect(scopedItem?.detail).toContain('Paths: /v1/convert'); + expect(scopedItem?.detail).toContain('Methods: POST'); + } + for (const fixture of [ { id: 'github-helper' }, { header: 'X-GitHub-Token' }, diff --git a/apps/desktop/src/shared/ghost.ts b/apps/desktop/src/shared/ghost.ts index 178f2319252..69854dda916 100644 --- a/apps/desktop/src/shared/ghost.ts +++ b/apps/desktop/src/shared/ghost.ts @@ -1878,6 +1878,9 @@ export function ghostPermissionItems(manifest: GhostManifest): GhostPermissionIt labelKey: 'networkSecretGhCli', labelArgs: { name: secret.label }, detailKey: 'networkSecretGhCliDetail', + // 与其它来源一致:声明了 endpoint 范围就把规范化事实写进 detail, + // 后续扩大/替换/删除限制都会被现有 key+detail diff 识别并要求复核。 + ...(endpointScope !== undefined ? { detail: endpointScope } : {}), }); continue; } From 502cde7089465fe324dc36640ad76c6b836764d5 Mon Sep 17 00:00:00 2001 From: SAN Date: Sat, 8 Aug 2026 13:56:57 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(ghost):=20401=20=E4=B8=94=20method=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E6=97=B6=E4=BB=8D=E5=A4=B1=E6=95=88=E8=A2=AB?= =?UTF-8?q?=E6=8B=92=E4=BB=A4=E7=89=8C=E7=BC=93=E5=AD=98;FORGE=5FGUIDE=20?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E6=8B=AC=E5=8F=B7=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1:POST 经 3xx 降级为 GET 后 401,重放分支被 responseMethod === originalRequestMethod 拦截,但 exchange/OAuth 令牌缓存此前不清,后续 相同调用会一直复用被拒令牌、永远 401 无法刷新。新增对称失效分支 (与 Connection 分支同语义),并补两次调用回归测试:第二次必须重新 走交换端点取新令牌。 - P2:上轮 FORGE_GUIDE 加注释时多留了一个 }},导致示例 JSON 非法; 删除重复闭合括号。 Co-Authored-By: Claude Signed-off-by: SAN --- .../cindy-brain/__tests__/networkSlot.test.ts | 34 +++++++++++++++++++ apps/desktop/src/main/cindy-brain/forge.ts | 1 - .../src/main/cindy-brain/networkSlot.ts | 18 ++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts index 1a31f55855a..2b7dc47c65d 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts @@ -1803,6 +1803,40 @@ describe('networkSlot · 凭证交换(key 换令牌二段式)', () => { expect(exchangeCalls(fetchImpl)).toHaveLength(1); }); + it('POST 经 302 降级为 GET 后 401:被拒令牌的缓存仍失效(下次调用重换而非复用)', async () => { + // 修复回归:method 降级抑制重放的同时,被拒令牌的本地缓存必须失效; + // 否则后续相同调用会一直复用被拒令牌、永远 401 且无法刷新。 + const { slot, fetchImpl } = makeExchangeSlot({ + tokenResponses: [ + () => fakeResponse({ body: '{"session":"tok-1"}' }), + () => fakeResponse({ body: '{"session":"tok-2"}' }), + ], + apiResponses: [ + () => fakeResponse({ status: 302, headers: { location: 'https://aigc.example.com/result' } }), + () => fakeResponse({ status: 401, body: '{"error":"expired"}' }), + () => fakeResponse({ status: 302, headers: { location: 'https://aigc.example.com/result' } }), + () => fakeResponse({ status: 401, body: '{"error":"expired"}' }), + ], + }); + for (let i = 0; i < 2; i++) { + const r = await slot.handleFetchRequest('web-search', { + url: 'https://aigc.example.com/submit', + method: 'POST', + body: 'payload=1', + }); + expect(r.ok).toBe(true); + if (r.ok && 'body' in r) expect(r.status).toBe(401); + } + const api = apiCalls(fetchImpl); + // 两次调用各只走一跳(原始 POST → 降级后的 GET /result 401),均未重放。 + expect(api).toHaveLength(4); + expect(api[1][1].method).toBe('GET'); + expect(api[3][1].method).toBe('GET'); + // 第二次调用重新走交换端点(缓存已被失效),取到的是新令牌。 + const ex = exchangeCalls(fetchImpl); + expect(ex).toHaveLength(2); + }); + it('交换端点非 2xx:整单结构化失败,错误带状态码与摘录、不发业务请求、不泄 key', async () => { const { slot, fetchImpl } = makeExchangeSlot({ tokenResponses: [() => fakeResponse({ status: 403, body: 'invalid subscriber' })], diff --git a/apps/desktop/src/main/cindy-brain/forge.ts b/apps/desktop/src/main/cindy-brain/forge.ts index 2c81402df6c..63df5b64c58 100644 --- a/apps/desktop/src/main/cindy-brain/forge.ts +++ b/apps/desktop/src/main/cindy-brain/forge.ts @@ -1295,7 +1295,6 @@ node 详单**不接受** \`command\` / \`args\` / \`shell\` / \`env\` 或其它 "paths": ["/v1/convert"], // 可选:精确 URL.pathname 白名单,1–16 条(大小写/尾斜杠敏感,不含 query);缺省=全部路径 "methods": ["POST"] // 可选:GET/POST/PUT/PATCH/DELETE 白名单;缺省=全部支持的方法 }, // 声明了 paths 或 methods 时,顶层 schemaVersion 必须写 3(旧客户端不认识这两个字段,会整包拒装而非静默放开) - }, "exchange": { // 可选:key 换令牌二段式(服务要求先拿 key 换临时令牌时声明,主机照单代办,见 §4.7;与 oauth 互斥) "url": "https://api.example.com/token", // 交换端点(https;域名必须命中 hosts 白名单) "bodyFormat": "{\\"sub\\":\\"{value}\\"}", // POST 请求体模板,恰含一个 {value}(原始 key 落点,主机按 contentType 转义) diff --git a/apps/desktop/src/main/cindy-brain/networkSlot.ts b/apps/desktop/src/main/cindy-brain/networkSlot.ts index 91a82af825a..d786c1b754b 100644 --- a/apps/desktop/src/main/cindy-brain/networkSlot.ts +++ b/apps/desktop/src/main/cindy-brain/networkSlot.ts @@ -1242,6 +1242,24 @@ export class GhostNetworkSlot { }); break; } + // 401 且 method 已降级(如 POST 经 3xx 变 GET)时,重放分支被 + // responseMethod === originalRequestMethod 拦截;但被拒令牌的本地缓存 + // 仍必须失效,否则后续相同调用会一直复用被拒令牌、永远 401 且无法 + // 通过重试刷新(与上方 Connection 分支同一语义)。 + if ( + response.status === 401 + && responseMethod !== originalRequestMethod + && (responseUsedExchange || responseOauthInjected.size > 0) + ) { + if (responseUsedExchange) this.invalidateExchangedTokens(ghostId, net.secrets ?? []); + for (const [secretKey, accountId] of responseOauthInjected) { + this.deps.oauthTokens?.invalidateAccessToken(ghostId, secretKey, accountId); + } + this.deps.log?.info('ghost fetch-request 401 exchange/oauth cache invalidated without replay', { + ghostId, callId, method: responseMethod, originalMethod: originalRequestMethod, host: url.hostname, + }); + break; + } retryUsedExchange = responseUsedExchange; retryOauthInjected = responseOauthInjected; retryConnectionInjected = responseConnectionInjected;