Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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,219 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

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

import {
applyGhostSetupChangeToRejections,
createGhostCredentialRejectionsStore,
ghostConnectionRejectionRef,
} from '../ghostCredentialRejections';
import { foldRejectedSecretsIntoAssessment } from '../ghostSetupStatus';

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.clearSecret('web-search', 'brave_api_key')).toBe(true);
expect(reloaded.rejectedKeys('web-search')).toEqual(['tavily_api_key']);
expect(reloaded.clearSecret('web-search', 'brave_api_key')).toBe(false);

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']);
});

it('使用 own-key 查找,并能按连接 identity 清账', () => {
const store = createGhostCredentialRejectionsStore({ filePath });
expect(store.rejectedKeys('constructor')).toEqual([]);
expect(store.markRejected('constructor', 'connection:gitlab:connection-1')).toBe(true);
expect(store.rejectedKeys('constructor')).toEqual(['connection:gitlab:connection-1']);

expect(store.clearConnection('constructor', 'gitlab', 'connection-1')).toBe(true);
expect(store.rejectedKeys('constructor')).toEqual([]);
});

it('读取结果不会泄露内部缓存数组', () => {
const store = createGhostCredentialRejectionsStore({ filePath });
expect(store.markRejected('web-search', 'brave_api_key')).toBe(true);

const returnedKeys = store.rejectedKeys('web-search') as string[];
returnedKeys.push('injected_key');

expect(store.rejectedKeys('web-search')).toEqual(['brave_api_key']);
});
});

describe('applyGhostSetupChangeToRejections 兜底清账', () => {
it('secret 事件按 ref 精确清账,不动同插件其它被拒 key', () => {
const store = createGhostCredentialRejectionsStore({ filePath });
store.markRejected('web-search', 'brave_api_key');
store.markRejected('web-search', 'tavily_api_key');

expect(
applyGhostSetupChangeToRejections(store, {
ghostId: 'web-search',
source: 'secret',
ref: 'brave_api_key',
}),
).toBe(true);
expect(store.rejectedKeys('web-search')).toEqual(['tavily_api_key']);

// 幂等:同一事件重放不再产生变化
expect(
applyGhostSetupChangeToRejections(store, {
ghostId: 'web-search',
source: 'secret',
ref: 'brave_api_key',
}),
).toBe(false);
});

it('connection 事件带 connectionId 时按连接 identity 清账', () => {
const store = createGhostCredentialRejectionsStore({ filePath });
store.markRejected('cindy-gitlab', ghostConnectionRejectionRef('gitlab', 'conn-1'));
store.markRejected('cindy-gitlab', ghostConnectionRejectionRef('gitlab', 'conn-2'));

expect(
applyGhostSetupChangeToRejections(store, {
ghostId: 'cindy-gitlab',
source: 'connection',
ref: 'gitlab:conn-1',
}),
).toBe(true);
expect(store.rejectedKeys('cindy-gitlab')).toEqual([
ghostConnectionRejectionRef('gitlab', 'conn-2'),
]);
});

it('connection 事件只带 declKey 时不清账(定位不到具体连接)', () => {
const store = createGhostCredentialRejectionsStore({ filePath });
const ref = ghostConnectionRejectionRef('gitlab', 'conn-1');
store.markRejected('cindy-gitlab', ref);

expect(
applyGhostSetupChangeToRejections(store, {
ghostId: 'cindy-gitlab',
source: 'connection',
ref: 'gitlab',
}),
).toBe(false);
expect(store.rejectedKeys('cindy-gitlab')).toEqual([ref]);
});

it('emitAll 的空 ghostId 唤醒信号不触碰任何台账', () => {
const store = createGhostCredentialRejectionsStore({ filePath });
store.markRejected('', 'stray_key'); // 就算历史上存在空 id 的账也不该被它清
store.markRejected('web-search', 'brave_api_key');

expect(
applyGhostSetupChangeToRejections(store, {
ghostId: '',
source: 'secret',
ref: 'brave_api_key',
}),
).toBe(false);
expect(store.rejectedKeys('')).toEqual(['stray_key']);
expect(store.rejectedKeys('web-search')).toEqual(['brave_api_key']);
});

it('无 ref 或非凭证类来源不清账', () => {
const store = createGhostCredentialRejectionsStore({ filePath });
store.markRejected('web-search', 'brave_api_key');

expect(
applyGhostSetupChangeToRejections(store, { ghostId: 'web-search', source: 'secret' }),
).toBe(false);
expect(
applyGhostSetupChangeToRejections(store, {
ghostId: 'web-search',
source: 'host_config',
ref: 'brave_api_key',
}),
).toBe(false);
expect(store.rejectedKeys('web-search')).toEqual(['brave_api_key']);
});
});

/**
* 存量安装升级契约(plugin-security-and-authoring.md 第 5 节红线)。
*
* 本台账是这条链路唯一新增的落盘物。老版本的 userData 里没有这个文件,升级后
* 用户什么都不做时必须仍然照旧可用——空账、不降级、不要求重新配置。
*/
describe('存量安装升级:台账缺失即空账,不降级已配置的插件', () => {
it('旧布局(台账文件不存在)→ 空账,已满足的判定原样放行', () => {
expect(fs.existsSync(filePath)).toBe(false); // 老版本 userData 的真实形态
const store = createGhostCredentialRejectionsStore({ filePath });
expect(store.rejectedKeys('web-search')).toEqual([]);
// 读一次不会顺手把文件创建出来(老版本回退后看到的目录形态不变)
expect(fs.existsSync(filePath)).toBe(false);
});

it('无台账时折算是恒等变换:ready 不会被折成 required', () => {
const store = createGhostCredentialRejectionsStore({ filePath });
const assessment = {
state: 'ready' as const,
revision: 0,
groups: [
{
id: 'manifest:1',
mode: 'any_of' as const,
items: [
{
ref: 'secret:brave_api_key',
kind: 'secret' as const,
label: 'Brave API Key',
state: 'satisfied' as const,
actions: [],
},
],
},
],
};
expect(
foldRejectedSecretsIntoAssessment(assessment, store.rejectedKeys('web-search')),
).toBe(assessment);
});

it('未知字段被忽略而不判损坏(新版写出的台账回退到旧版仍可读)', () => {
fs.writeFileSync(
filePath,
JSON.stringify({ ghosts: { 'web-search': ['brave_api_key'] }, futureField: { v: 2 } }),
'utf8',
);
const store = createGhostCredentialRejectionsStore({ filePath });
expect(store.rejectedKeys('web-search')).toEqual(['brave_api_key']);
});
});
Loading