Skip to content
Open
3 changes: 3 additions & 0 deletions apps/desktop/src/main/cindy-brain/__tests__/forge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,9 @@ describe('FORGE_GUIDE', () => {
'NO_CANDIDATE',
// 2026-08-05 快问快答偏好模型声明(目录模型 id;用户钉档 > 插件声明 > 默认链)。
'oneshotModel',
'"paths": ["/v1/convert"]',
'"methods": ["POST"]',
'三者是 AND 关系',
'expectJson',
// 2026-08-04 文本转向量(cindy.embed.text):作者最容易踩的是"换模型 =
// 换向量空间",手册必须讲到 model + dim 要跟向量一起存。
Expand Down
177 changes: 177 additions & 0 deletions apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>).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<string, string>).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<string, string>).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<string, string>)
.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 });
Expand Down Expand Up @@ -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<string, string>).Authorization).toBe('Bearer scoped-secret');
expect((fetchImpl.mock.calls[1][1].headers as Record<string, string>).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<string, string>).Authorization).toBeUndefined();
expect((fetchImpl.mock.calls[1][1].headers as Record<string, string>).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<string, string>).Authorization).toBe('Bearer scoped-secret');
});

it('重定向次数超上限阻断', async () => {
const { slot, fetchImpl } = makeSlot();
fetchImpl.mockResolvedValue(redirectTo('https://api.tavily.com/loop'));
Expand Down Expand Up @@ -1660,6 +1770,73 @@ 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('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' })],
Expand Down
22 changes: 15 additions & 7 deletions apps/desktop/src/main/cindy-brain/forge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} 字
Expand Down Expand Up @@ -1291,8 +1291,10 @@ 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 白名单,1–16 条(大小写/尾斜杠敏感,不含 query);缺省=全部路径
"methods": ["POST"] // 可选:GET/POST/PUT/PATCH/DELETE 白名单;缺省=全部支持的方法
}, // 声明了 paths 或 methods 时,顶层 schemaVersion 必须写 3(旧客户端不认识这两个字段,会整包拒装而非静默放开)
Comment thread
FIERsity marked this conversation as resolved.
"exchange": { // 可选:key 换令牌二段式(服务要求先拿 key 换临时令牌时声明,主机照单代办,见 §4.7;与 oauth 互斥)
"url": "https://api.example.com/token", // 交换端点(https;域名必须命中 hosts 白名单)
"bodyFormat": "{\\"sub\\":\\"{value}\\"}", // POST 请求体模板,恰含一个 {value}(原始 key 落点,主机按 contentType 转义)
Expand Down Expand Up @@ -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 槽)——设置页里画个就地的轻反馈即可,
不用自己想办法做全局提示。
Expand Down Expand Up @@ -3522,7 +3529,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、
Expand Down
Loading