Skip to content
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
e940a38
feat(plugins): add unified ghost lifecycle projection
xxxxxccc Jul 27, 2026
4c3c04a
feat(plugins): degraded exposure in agent discovery layer
xxxxxccc Jul 27, 2026
c1906ab
feat(plugins): enable-then-guide setup flow on ghosts:set-enabled
xxxxxccc Jul 27, 2026
aef7506
feat(plugins): route scheduler capability warnings through lifecycle …
xxxxxccc Jul 27, 2026
162d09e
feat(plugins): readiness badges on plugin cards and detail view
xxxxxccc Jul 27, 2026
9e31622
feat(plugins): clarify global enable switch semantics in detail view
xxxxxccc Jul 27, 2026
aeb5bad
feat(plugins): credential rejection ledger closes the needs_reauth loop
xxxxxccc Jul 27, 2026
d85f5de
feat(plugins): align roster filtering across Claude and Codex harnesses
xxxxxccc Jul 27, 2026
574267a
docs(plugins): align distribution and readiness rules with behavior
xxxxxccc Jul 27, 2026
b525db6
fix(plugins): lazy-init credential rejection ledger
xxxxxccc Jul 27, 2026
705a606
fix(plugins): align readiness badge with detail metadata baseline
xxxxxccc Jul 27, 2026
9a03773
fix(plugins): fold 422 token-rejection responses into credential ledger
xxxxxccc Jul 27, 2026
fad8ee7
chore: retrigger GitHub mergeability check
xxxxxccc Jul 27, 2026
77fc719
fix(plugins): address review feedback on lifecycle readiness
xxxxxccc Jul 27, 2026
8753169
feat(plugins): direct install from market card with permission review
xxxxxccc Jul 27, 2026
b260bff
fix(plugins): address second-round review on lifecycle readiness
xxxxxccc Jul 27, 2026
b724471
fix(plugins): address third-round review feedback
xxxxxccc Jul 27, 2026
59b17fb
fix(plugins): close lifecycle review gaps
xxxxxccc Jul 28, 2026
98ab560
fix(plugins): preserve lifecycle push ordering
xxxxxccc Jul 28, 2026
34b5886
fix(plugins): close latest lifecycle review gaps
xxxxxccc Jul 28, 2026
8310682
fix(plugins): address latest lifecycle review
xxxxxccc Jul 28, 2026
52a9248
fix(plugins): unify setup checks and setup-plan schema
xxxxxccc Jul 28, 2026
f6e8900
Merge upstream/main and address plugin lifecycle reviews
xxxxxccc Jul 28, 2026
ed865a3
fix(plugin): close lifecycle review gaps
xxxxxccc Jul 28, 2026
3167d30
fix(plugins): isolate credential rejection reads
xxxxxccc Jul 28, 2026
d6b2bea
fix(plugins): address lifecycle review feedback on broadcast, attribu…
xxxxxccc Jul 28, 2026
9f9331c
fix(plugins): address second-round lifecycle review feedback
xxxxxccc Jul 28, 2026
fa5d1e1
fix(plugins): encode setup-status probe failures and map readiness to…
xxxxxccc Jul 28, 2026
2288663
fix(plugins): close lifecycle attribution and projection review gaps
xxxxxccc Jul 28, 2026
5b880d9
fix(plugins): route host requirements, fix attribution count, and ord…
xxxxxccc Jul 28, 2026
3b214f6
fix(plugins): version connection tokens and bind rejections to data o…
xxxxxccc Jul 28, 2026
d8ee899
fix(plugins): version exchange-source rejections against in-flight ke…
xxxxxccc Jul 28, 2026
cd18f92
fix(plugins): clear inline-setup rejection before wake and guard setu…
xxxxxccc Jul 28, 2026
0256807
Merge branch 'main' into feat/plugin-lifecycle-readiness
xxxxxccc Jul 30, 2026
546dca3
Merge branch 'main' into feat/plugin-lifecycle-readiness
xxxxxccc Jul 31, 2026
b8f0d9d
Merge branch 'main' into feat/plugin-lifecycle-readiness
xxxxxccc Jul 31, 2026
2c79ad6
fix(plugins): align IPC bridge capability-state union with ghost-unknown
xxxxxccc Jul 31, 2026
24ecced
Merge upstream/main and address plugin lifecycle review notes
xxxxxccc Jul 31, 2026
79f8cb2
Merge upstream/main (revert of #1080) into feat/plugin-lifecycle-read…
xxxxxccc Jul 31, 2026
dccf963
Merge upstream/main into feat/plugin-lifecycle-readiness
xxxxxccc Jul 31, 2026
eb57a68
Merge remote-tracking branch 'upstream/main' into feat/plugin-lifecyc…
xxxxxccc Jul 31, 2026
a8fa233
Merge remote-tracking branch 'upstream/main' into feat/plugin-lifecyc…
xxxxxccc Aug 1, 2026
70e3440
Merge upstream/main into feat/plugin-lifecycle-readiness
xxxxxccc Aug 2, 2026
2081ebd
fix(plugins): 熔断态在授权副作用前拦下,不拦按需重启的崩溃态
xxxxxccc Aug 2, 2026
71da47f
fix(plugins): 401 归因的 host 兜底退回最终一跳,不退回初始请求 host
xxxxxccc Aug 2, 2026
025a91e
Merge upstream/main into feat/plugin-lifecycle-readiness
xxxxxccc Aug 3, 2026
a170ee2
test(plugins): align market install button query
xxxxxccc Aug 3, 2026
b1e2d04
fix(plugins): preserve crashed auto-recovery path
xxxxxccc Aug 3, 2026
d656cb0
fix(plugins): drop stale rejection callbacks at owner boundary
xxxxxccc Aug 3, 2026
297cc15
fix(plugins): scope rejection state and expire rejected oauth
xxxxxccc Aug 3, 2026
a8e2dfe
Merge remote-tracking branch 'upstream/main' into pr-668-review
xxxxxccc Aug 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { afterAll, beforeEach, describe, expect, it } from 'vitest';

import { createGhostCredentialRejectionsStore } from '../ghostCredentialRejections';

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-cred-rej-'));
const filePath = path.join(tmpDir, 'ledger.json');

afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

beforeEach(() => {
fs.rmSync(filePath, { force: true });
});

describe('ghostCredentialRejections 台账', () => {
it('记账 / 幂等 / 清账 / 跨实例持久化', () => {
const store = createGhostCredentialRejectionsStore({ filePath });
expect(store.rejectedKeys('web-search')).toEqual([]);

expect(store.markRejected('web-search', 'brave_api_key')).toBe(true);
expect(store.markRejected('web-search', 'brave_api_key')).toBe(false); // 幂等
expect(store.markRejected('web-search', 'tavily_api_key')).toBe(true);
expect(store.rejectedKeys('web-search')).toEqual(['brave_api_key', 'tavily_api_key']);

// 新实例读同一文件 = 持久化生效
const reloaded = createGhostCredentialRejectionsStore({ filePath });
expect(reloaded.rejectedKeys('web-search')).toEqual(['brave_api_key', 'tavily_api_key']);

expect(reloaded.clear('web-search')).toBe(true);
expect(reloaded.clear('web-search')).toBe(false);
expect(reloaded.rejectedKeys('web-search')).toEqual([]);
});

it('损坏的台账文件按空账处理(fail-open),不拖垮判定', () => {
fs.writeFileSync(filePath, '{not json', 'utf8');
const store = createGhostCredentialRejectionsStore({ filePath });
expect(store.rejectedKeys('any')).toEqual([]);
// 记账仍然可用(覆盖坏文件)
expect(store.markRejected('any', 'k')).toBe(true);
expect(store.rejectedKeys('any')).toEqual(['k']);
});
});
163 changes: 163 additions & 0 deletions apps/desktop/src/main/cindy-brain/__tests__/ghostLifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
Comment thread
xxxxxccc marked this conversation as resolved.
* ghostLifecycle.test.ts — 生命周期统一投影的纯函数测试。
*
* 覆盖:优先级链(blocked > degraded > unknown > reauth > setup > ready)、
* 评估失败不折叠成 ready、单插件失败不拖垮整份清单、派生视图口径。
*/

import { describe, expect, it, vi } from 'vitest';

import type { GhostSetupAssessment } from '../../../shared/ghost';
import {
isCallable,
isDiscoverable,
projectGhostLifecycle,
projectGhostLifecycles,
readinessSummary,
type LifecycleProbes,
} from '../ghostLifecycle';

const READY: GhostSetupAssessment = { state: 'ready', revision: 1, groups: [] };

function requiredAssessment(itemState: 'missing' | 'expired'): GhostSetupAssessment {
return {
state: 'required',
revision: 2,
groups: [
{
id: 'manifest:1',
mode: 'any_of',
items: [
{
ref: 'secret:api_key',
kind: 'secret',
label: 'API Key',
state: itemState,
actions: [],
},
],
},
],
};
}

describe('projectGhostLifecycle 优先级链', () => {
const base = {
id: 'web-search',
name: 'Web Search',
enabled: true,
accountAvailable: true,
assessment: READY,
};

it('ready:启用 + 评估通过', () => {
expect(projectGhostLifecycle(base).readiness).toBe('ready');
});

it('blocked 优先于一切(本地模式下的账号托管插件,不做评估)', () => {
const entry = projectGhostLifecycle({
...base,
accountAvailable: false,
runtimeState: 'crashed',
assessment: new Error('store unreadable'),
});
expect(entry.readiness).toBe('blocked');
expect(entry.setup).toBeUndefined();
});

it('degraded 优先于 setup 判定(熔断中不谈配置)', () => {
const entry = projectGhostLifecycle({
...base,
runtimeState: 'fused',
assessment: requiredAssessment('missing'),
});
expect(entry.readiness).toBe('degraded');
expect(entry.runtimeState).toBe('fused');
});

it('评估失败 → unknown(显式降级,不折叠成 ready)', () => {
const entry = projectGhostLifecycle({ ...base, assessment: new SyntaxError('bad store') });
expect(entry.readiness).toBe('unknown');
expect(entry.setup).toBeUndefined();
});

it('required + 全 missing → needs_setup', () => {
const entry = projectGhostLifecycle({ ...base, assessment: requiredAssessment('missing') });
expect(entry.readiness).toBe('needs_setup');
expect(entry.setup?.state).toBe('required');
});

it('required + 任一 expired → needs_reauth(修复动作是重新连接)', () => {
const entry = projectGhostLifecycle({ ...base, assessment: requiredAssessment('expired') });
expect(entry.readiness).toBe('needs_reauth');
});
});

describe('projectGhostLifecycles 批量投影', () => {
it('单插件评估抛错不拖垮整份清单', () => {
const onError = vi.fn();
const probes: LifecycleProbes = {
isAccountAvailable: () => true,
runtimeStateOf: () => undefined,
assess: (id) => {
if (id === 'broken') throw new SyntaxError('malformed store');
return READY;
},
};
const entries = projectGhostLifecycles(
[
{ id: 'broken', name: 'Broken', enabled: true },
{ id: 'healthy', name: 'Healthy', enabled: true },
],
probes,
onError,
);
expect(entries.map((e) => e.readiness)).toEqual(['unknown', 'ready']);
expect(onError).toHaveBeenCalledWith('broken', expect.any(SyntaxError));
});
});

describe('派生视图', () => {
const ready = projectGhostLifecycle({
id: 'a',
name: 'A',
enabled: true,
accountAvailable: true,
assessment: READY,
});
const needsSetup = projectGhostLifecycle({
id: 'b',
name: 'B',
enabled: true,
accountAvailable: true,
assessment: requiredAssessment('missing'),
});
const blocked = projectGhostLifecycle({
id: 'c',
name: 'C',
enabled: true,
accountAvailable: false,
assessment: READY,
});

it('discoverable = 启用 && 非 workdir 停用 && 非 blocked', () => {
expect(isDiscoverable(ready, false)).toBe(true);
expect(isDiscoverable(ready, true)).toBe(false);
expect(isDiscoverable({ ...ready, enabled: false }, false)).toBe(false);
expect(isDiscoverable(blocked, false)).toBe(false);
// needs_setup 仍可发现(降级暴露:列出但不派发工具)
expect(isDiscoverable(needsSetup, false)).toBe(true);
});

it('callable = discoverable && ready', () => {
expect(isCallable(ready, false)).toBe(true);
expect(isCallable(needsSetup, false)).toBe(false);
expect(isCallable(ready, true)).toBe(false);
});

it('readinessSummary:非 ready 给出处置指引,ready 无摘要', () => {
expect(readinessSummary(ready)).toBeNull();
expect(readinessSummary(needsSetup)).toContain('配置');
expect(readinessSummary(blocked)).toBeNull();
});
});
114 changes: 112 additions & 2 deletions apps/desktop/src/main/cindy-brain/__tests__/networkSlot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ function fakeResponse(params: {
} = {}): Response {
const { status = 200, headers = { 'content-type': 'application/json' }, body = '{"ok":1}' } = params;
const buf = typeof body === 'string' ? new TextEncoder().encode(body).buffer : body;
return {
const res = {
status,
headers: new Headers(headers),
arrayBuffer: async () => buf,
} as unknown as Response;
} as unknown as Response & { clone?: () => Response };
res.clone = () => fakeResponse(params);
return res as unknown as Response;
}

function mp3WithId3(tagSize = 0): Uint8Array {
Expand Down Expand Up @@ -129,6 +131,114 @@ function makeSlot(overrides: Partial<NetworkSlotDeps> = {}): {

const BRAVE_URL = 'https://api.search.brave.com/res/v1/web/search?q=hi';

describe('networkSlot · 凭证被拒记账(运行期 needs_reauth 事实源)', () => {
it('401/403 重试耗尽后按 user 源 secret key 上报;2xx/其它 4xx 不上报', async () => {
const noteCredentialRejected = vi.fn();
const { slot } = makeSlot({
fetchImpl: vi.fn(async () => fakeResponse({ status: 401 })) as never,
noteCredentialRejected,
});
const r = await slot.handleFetchRequest('web-search', { type: 'fetch-request', url: BRAVE_URL });
// 401 原样回给意识(代发成功,对方说不行),但台账已记
expect(r.ok).toBe(true);
expect(noteCredentialRejected).toHaveBeenCalledWith('web-search', 'brave_api_key');
expect(noteCredentialRejected).toHaveBeenCalledWith('web-search', 'tavily_api_key');

noteCredentialRejected.mockClear();
const { slot: okSlot } = makeSlot({ noteCredentialRejected });
await okSlot.handleFetchRequest('web-search', { type: 'fetch-request', url: BRAVE_URL });
expect(noteCredentialRejected).not.toHaveBeenCalled();

noteCredentialRejected.mockClear();
const { slot: s500 } = makeSlot({
fetchImpl: vi.fn(async () => fakeResponse({ status: 500 })) as never,
noteCredentialRejected,
});
await s500.handleFetchRequest('web-search', { type: 'fetch-request', url: BRAVE_URL });
expect(noteCredentialRejected).not.toHaveBeenCalled();
});

it('oauth 源凭证不重复记账(已有自己的 expired 链路)', async () => {
const noteCredentialRejected = vi.fn();
const { slot } = makeSlot({
getGhost: () =>
fakeGhost({
network: {
hosts: ['api.search.brave.com'],
secrets: [
{
key: 'google_token',
label: 'Google',
source: 'oauth',
inject: { header: 'Authorization', format: 'Bearer {value}', hosts: ['api.search.brave.com'] },
oauth: { provider: 'google', scopes: ['x'] } as never,
},
],
},
}),
fetchImpl: vi.fn(async () => fakeResponse({ status: 403 })) as never,
noteCredentialRejected,
oauthTokens: {
getFreshAccessToken: async () => ({ ok: true as const, accessToken: 'tok', accountId: 'a1' }),
invalidateAccessToken: () => {},
},
});
await slot.handleFetchRequest('web-search', { type: 'fetch-request', url: BRAVE_URL });
expect(noteCredentialRejected).not.toHaveBeenCalled();
});

it('422 + 令牌失效类 body 对纯 key 型 user 凭证记账;业务 422 不记', async () => {
// Brave 对无效订阅令牌回 422(非标),body 含 "subscription token is invalid"。
const noteCredentialRejected = vi.fn();
const { slot } = makeSlot({
fetchImpl: vi.fn(async () =>
fakeResponse({ status: 422, body: '{"error":"The provided subscription token is invalid"}' }),
) as never,
noteCredentialRejected,
});
await slot.handleFetchRequest('web-search', { type: 'fetch-request', url: BRAVE_URL });
expect(noteCredentialRejected).toHaveBeenCalledWith('web-search', 'brave_api_key');
expect(noteCredentialRejected).toHaveBeenCalledWith('web-search', 'tavily_api_key');

// 业务语义校验失败的 422(body 无凭证类关键词)不记账。
noteCredentialRejected.mockClear();
const { slot: bizSlot } = makeSlot({
fetchImpl: vi.fn(async () =>
fakeResponse({ status: 422, body: '{"error":"query must be at least 3 characters"}' }),
) as never,
noteCredentialRejected,
});
await bizSlot.handleFetchRequest('web-search', { type: 'fetch-request', url: BRAVE_URL });
expect(noteCredentialRejected).not.toHaveBeenCalled();
});

it('422 时带 exchange 的凭证不记账(交换链有自己的 401 语义)', async () => {
const noteCredentialRejected = vi.fn();
const { slot } = makeSlot({
getGhost: () =>
fakeGhost({
network: {
hosts: ['api.search.brave.com'],
secrets: [
{
key: 'ex_key',
label: 'Ex',
inject: { header: 'Authorization', format: 'Bearer {value}', hosts: ['api.search.brave.com'] },
exchange: { url: 'https://api.search.brave.com/exchange' } as never,
},
],
},
}),
fetchImpl: vi.fn(async () =>
fakeResponse({ status: 422, body: '{"error":"token is invalid"}' }),
) as never,
noteCredentialRejected,
});
await slot.handleFetchRequest('web-search', { type: 'fetch-request', url: BRAVE_URL });
expect(noteCredentialRejected).not.toHaveBeenCalled();
});
});

describe('networkSlot · 载荷与 URL 校验', () => {
it('url 缺失/非 https/带端口/内嵌凭证/不是绝对地址一律拒', async () => {
const { slot } = makeSlot();
Expand Down
Loading