Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ developers don't need to self-host a server: sign in with your own Cindy
account in a dev build and develop / test directly against the official
servers.

Skill Hub migration uses two separate manifest fields: released clients keep
using `skillhubApiBaseUrl` (the deprecated XD proxy), while current clients use
`cindySkillHubApiBaseUrl`. The current client deliberately does not fall back to
the legacy field when the new endpoint is absent.

## Architecture

- [`DESIGN.md`](DESIGN.md) — visual design system, color tokens, and UI conventions
Expand Down
49 changes: 44 additions & 5 deletions apps/desktop/src/main/__tests__/clientEndpointsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ vi.mock('electron', () => ({
app: {
getPath: vi.fn(),
getAppPath: vi.fn(() => '/repo/apps/desktop'),
getPreferredSystemLanguages: vi.fn(() => ['en-US']),
isPackaged: false,
exit: vi.fn(),
},
Expand Down Expand Up @@ -69,6 +70,7 @@ import {
getClientEndpoint,
getClientEndpointForRealm,
getResolvedClientEndpoints,
initClientEndpoints,
loadClientEndpointsForRealm,
isUsingCachedClientEndpoints,
registerClientEndpointsIpc,
Expand Down Expand Up @@ -104,6 +106,7 @@ const FULL_MANIFEST = JSON.stringify({
voiceApiBaseUrl: 'https://voice.remote.example.com',
githubApiBaseUrl: 'https://github-api.remote.example.com',
skillhubApiBaseUrl: 'https://skillhub.remote.example.com',
cindySkillHubApiBaseUrl: 'https://cindy-skillhub.remote.example.com',
pluginApiBaseUrl: 'https://plugin.remote.example.com',
cdnBaseUrl: 'https://cdn.remote.example.com/app',
mobileUpdateBaseUrl: 'https://mobile-update.remote.example.com',
Expand Down Expand Up @@ -257,6 +260,46 @@ describe('resolveEndpointSource(清单来源三选一)', () => {
});
});

describe('localhost 开发端点的 realm 固定', () => {
it('登录恢复切换 realm 时仍复用本地文件清单,不加载线上清单', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'client-endpoints-local-realm-'));
const manifestPath = path.join(root, 'endpoint.local.json');
const previousMode = process.env.XDT_DESKTOP_DEV_MODE;
const previousManifestFile = process.env.XDT_ENDPOINT_MANIFEST_FILE;
fs.writeFileSync(
manifestPath,
JSON.stringify({
...(JSON.parse(LOCAL_MANIFEST) as Record<string, unknown>),
cindySkillHubApiBaseUrl: 'http://localhost:3345',
}),
);
process.env.XDT_DESKTOP_DEV_MODE = 'local';
process.env.XDT_ENDPOINT_MANIFEST_FILE = manifestPath;

try {
await expect(initClientEndpoints()).resolves.toBe(true);

activateClientEndpointRealm('cn');
expect(getClientEndpoint('cindySkillHubApiBaseUrl')).toBe('http://localhost:3345');
activateClientEndpointRealm('global');
expect(getClientEndpoint('cindySkillHubApiBaseUrl')).toBe('http://localhost:3345');
await expect(loadClientEndpointsForRealm('cn')).resolves.toMatchObject({
cindySkillHubApiBaseUrl: 'http://localhost:3345',
});
await expect(loadClientEndpointsForRealm('global')).resolves.toMatchObject({
cindySkillHubApiBaseUrl: 'http://localhost:3345',
});
expect(netRequest).not.toHaveBeenCalled();
} finally {
if (previousMode === undefined) delete process.env.XDT_DESKTOP_DEV_MODE;
else process.env.XDT_DESKTOP_DEV_MODE = previousMode;
if (previousManifestFile === undefined) delete process.env.XDT_ENDPOINT_MANIFEST_FILE;
else process.env.XDT_ENDPOINT_MANIFEST_FILE = previousManifestFile;
fs.rmSync(root, { recursive: true, force: true });
}
});
});

/** 自动重试预算关掉的公共 deps 片段(测"一轮一次尝试"的原语义)。 */
const NO_AUTO_RETRY = { autoRetryDelaysMs: [] as readonly number[] };

Expand Down Expand Up @@ -1405,11 +1448,7 @@ describe('netlog 产物事后核对(verifyEndpointNetLogCapture)', () => {
const captureDir = path.dirname(capture.file);
const elsewhere = fs.mkdtempSync(path.join(logDir, 'elsewhere-'));
fs.rmSync(captureDir, { recursive: true, force: true });
fs.symlinkSync(
elsewhere,
captureDir,
process.platform === 'win32' ? 'junction' : 'dir',
);
fs.symlinkSync(elsewhere, captureDir, process.platform === 'win32' ? 'junction' : 'dir');
fs.writeFileSync(capture.file, '{}', 'utf8');
expect(verifyEndpointNetLogCapture(capture)).toBe(false);
});
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/bootstrap-electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1122,7 +1122,7 @@ async function attemptStartSchedulerOnce(): Promise<void> {
startLearnHost({
maker,
broadcast: broadcastLearnEvent,
fetchHubSkill: (slug) => fetchHubSkillReference(learnMarketService, slug),
fetchHubSkill: (slug, catalogScope) => fetchHubSkillReference(learnMarketService, slug, catalogScope),
...automationGitBaselineHooks,
});
} catch (err) {
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/main/clientEndpointsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1153,7 +1153,18 @@ export async function initClientEndpoints(): Promise<boolean> {
// 缓存在构建区域,不能同时塞进两区,否则升级后留下的跨区 token 会被误发。
activeSessionRealm = resolvedRegion ?? BUILD_AUTH_REGION;
realmEndpointCache.clear();
realmEndpointCache.set(activeSessionRealm, endpoints);
// localhost 开发模式是一套本机服务,不存在跨区端点。登录恢复仍会按凭证 realm
// 调用 loadClientEndpointsForRealm/activateClientEndpointRealm;若只缓存启动区域,
// 另一 realm 会重新拉线上清单,把本地服务悄悄替换掉。仅对明确的 local + file
// 启动把同一份清单固定到两个 realm;remote/CDN 与普通文件覆写仍保持区域隔离。
const pinLocalEndpointsToAllRealms =
!app.isPackaged && process.env.XDT_DESKTOP_DEV_MODE === 'local' && source.kind === 'file';
if (pinLocalEndpointsToAllRealms) {
realmEndpointCache.set('cn', endpoints);
realmEndpointCache.set('global', endpoints);
} else {
realmEndpointCache.set(activeSessionRealm, endpoints);
}
log.info(
'resolved from %s (%s): auth=%s cdn=%s',
startedFromCachedManifest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,22 @@ describe('/learn 远程路由', () => {
]);
});

it('deviceId + hub:<scope>:<slug> → 保留目录作用域', async () => {
const { remoteInvoke, registry } = makeHarness({
remoteInvoke: async () => ({ runId: 'r-scope' }),
});
await registry.execute('learn', { sessionId: 'rs', deviceId: 'dev-1', args: 'hub:team:my-skill 精简点' });
expect(remoteInvoke).toHaveBeenCalledWith('dev-1', 'learn:start', [
{
input: '精简点',
sourceKind: 'hub',
hubSlug: 'my-skill',
hubCatalogScope: 'team',
originSessionId: 'rs',
},
]);
});

it('隧道 [LEARN_BUSY] 编码 → learn-busy(与本机 err.code 同分类)', async () => {
const { registry } = makeHarness({
remoteInvoke: async () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/commands/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,12 +541,13 @@ export function registerBuiltinDesktopCommands(
}
// `/learn hub:<slug> [补充要求]` —— skill hub「学习此技能」预填的形态,
// 用户可在输入框改要求、换模型后再发。slug 规则与市场一致([a-z0-9-])。
const hubMatch = /^hub:([a-z0-9][a-z0-9-]*)\s*/.exec(arg);
const hubMatch = /^hub:(?:(market|team):)?([a-z0-9][a-z0-9-]*)\s*/.exec(arg);
const req = hubMatch
? {
input: arg.slice(hubMatch[0].length).trim(),
sourceKind: 'hub' as const,
hubSlug: hubMatch[1],
hubSlug: hubMatch[2],
...(hubMatch[1] ? { hubCatalogScope: hubMatch[1] as 'market' | 'team' } : {}),
...(ctx.sessionId ? { originSessionId: ctx.sessionId } : {}),
}
: {
Expand Down
20 changes: 14 additions & 6 deletions apps/desktop/src/main/learn-host/__tests__/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1322,19 +1322,27 @@ describe('LearnController 状态机', () => {

it('hub 源命中同名本地 skill:本地 SKILL.md 注入 prompt 前过 redaction', async () => {
const secret = 'sk-abcdef1234567890abcdef1234567890';
const fetchHubSkill = vi.fn(async () => ({
name: 'my-skill',
description: 'upstream',
content: '# upstream skill',
}));
const h = makeHarness({
fetchHubSkill: async () => ({
name: 'my-skill',
description: 'upstream',
content: '# upstream skill',
}),
fetchHubSkill,
dirExists: async (dir) =>
dir.startsWith(path.join('/', 'fake-staging')) || dir === path.join('/', 'installed', 'my-skill'),
readFileText: async () => `# local skill\napi key: ${secret}\n`,
});
h.setScan(goodScan());
const { runId } = await h.controller.startLearn({ input: '', sourceKind: 'hub', hubSlug: 'my-skill' });
const { runId } = await h.controller.startLearn({
input: '',
sourceKind: 'hub',
hubSlug: 'my-skill',
hubCatalogScope: 'team',
});
await h.waitForStatus(runId, 'distilling');
expect(fetchHubSkill).toHaveBeenCalledWith('my-skill', 'team');
expect(h.store.get(runId)?.hubCatalogScope).toBe('team');
expect(h.session.sent[0]).toContain('# local skill');
expect(h.session.sent[0]).not.toContain(secret);
});
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/learn-host/__tests__/hubReference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,25 @@ describe('fetchHubSkillReference', () => {
expect(reader.readPublishedFile).not.toHaveBeenCalledWith({ name: 'demo-skill', path: 'scripts/large.py' });
});

it('keeps the originating catalog scope on every Hub read', async () => {
const reader = makeReader();

await fetchHubSkillReference(reader, 'demo-skill', 'team');

expect(reader.info).toHaveBeenCalledWith('demo-skill', 'team');
expect(reader.getPublishedFiles).toHaveBeenCalledWith({ name: 'demo-skill', catalogScope: 'team' });
expect(reader.readPublishedFile).toHaveBeenCalledWith({
name: 'demo-skill',
path: 'SKILL.md',
catalogScope: 'team',
});
expect(reader.readPublishedFile).toHaveBeenCalledWith({
name: 'demo-skill',
path: 'scripts/run.py',
catalogScope: 'team',
});
});

it('surfaces files omitted by the reference file cap', async () => {
const auxFiles = Array.from({ length: 42 }, (_, i) => ({
path: `scripts/${i}.py`,
Expand Down
12 changes: 8 additions & 4 deletions apps/desktop/src/main/learn-host/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export interface LearnControllerDeps {
/** 已装 skill 清单块("改 vs 加"决策依据;无 skill 返空串)。 */
getInstalledSkillsIndex(): Promise<string>;
/** hub 源:拉市场 skill 详情 + 全部已发布文件(PR3 注入;未注入时 hub 源报 INVALID_PARAMS)。 */
fetchHubSkill?: (slug: string) => Promise<{
fetchHubSkill?: (slug: string, catalogScope?: 'market' | 'team') => Promise<{
name: string;
description: string;
content: string;
Expand Down Expand Up @@ -337,6 +337,9 @@ export class LearnController {
if (req.sourceKind === 'hub' && req.hubSlug && !/^[a-z0-9][a-z0-9-]*$/.test(req.hubSlug)) {
throw new LearnError('INVALID_PARAMS', `invalid hubSlug: ${req.hubSlug}`);
}
if (req.sourceKind === 'hub' && req.hubCatalogScope && req.hubCatalogScope !== 'market' && req.hubCatalogScope !== 'team') {
throw new LearnError('INVALID_PARAMS', `invalid hubCatalogScope: ${String(req.hubCatalogScope)}`);
}
if (req.sourceKind === 'hub' && !this.deps.fetchHubSkill) {
throw new LearnError('INVALID_PARAMS', 'hub source is not available');
}
Expand All @@ -361,6 +364,7 @@ export class LearnController {
...(dataOwnerId ? { dataOwnerId } : {}),
input,
...(req.hubSlug ? { hubSlug: req.hubSlug } : {}),
...(req.hubCatalogScope ? { hubCatalogScope: req.hubCatalogScope } : {}),
...(req.originSessionId ? { originSessionId: req.originSessionId } : {}),
usedSessionEvidence: false,
createdAt: this.now(),
Expand Down Expand Up @@ -393,7 +397,7 @@ export class LearnController {
let referenceFilesOmissions: Array<{ path: string; reason: string }> | undefined;
let evidenceQuery = run.input;
if (run.sourceKind === 'hub' && run.hubSlug && this.deps.fetchHubSkill) {
const hub = await this.deps.fetchHubSkill(run.hubSlug);
const hub = await this.deps.fetchHubSkill(run.hubSlug, run.hubCatalogScope);
if (!hub) throw new LearnError('NOT_FOUND', `hub skill ${run.hubSlug} not found`);
// fetch 的网络 await 期间可能被 cancel(cleanup 已删 staging):此处不设门
// 的话 writeReferenceFiles 会把 _reference/ 整个重建成孤儿目录(自查)。
Expand Down Expand Up @@ -516,7 +520,7 @@ export class LearnController {

const cleanMessage =
run.sourceKind === 'hub'
? `/learn hub:${run.hubSlug}`
? `/learn hub:${run.hubCatalogScope ? `${run.hubCatalogScope}:` : ''}${run.hubSlug}`
: run.sourceKind === 'session'
? '/learn (distill current conversation)'
: `/learn ${run.input}`;
Expand Down Expand Up @@ -960,7 +964,7 @@ export class LearnController {
const provenance: LearnProvenance = {
method: 'learn',
sourceKind: run.sourceKind,
...(run.hubSlug ? { sourceRef: run.hubSlug } : {}),
...(run.hubSlug ? { sourceRef: `${run.hubCatalogScope ? `${run.hubCatalogScope}:` : ''}${run.hubSlug}` } : {}),
usedSessionEvidence: run.usedSessionEvidence,
personal: run.usedSessionEvidence, // 硬规则:含 session 证据 ⇒ personal,不可配置
learnedAt: Math.floor(this.now() / 1000),
Expand Down
16 changes: 9 additions & 7 deletions apps/desktop/src/main/learn-host/hubReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { createLogger } from '../logger';
import type { SkillhubCatalogScope } from '../../shared/skillhubCatalog';

const log = createLogger('learn-host:hub-reference');
const MAX_REFERENCE_FILES = 40;
Expand Down Expand Up @@ -33,9 +34,9 @@ interface HubFilesResult {
}

export interface HubSkillReferenceReader {
info(slug: string): Promise<HubInfoResult>;
readPublishedFile(params: { name: string; path: string }): Promise<HubFileResult>;
getPublishedFiles(params: { name: string }): Promise<HubFilesResult>;
info(slug: string, catalogScope?: SkillhubCatalogScope): Promise<HubInfoResult>;
readPublishedFile(params: { name: string; path: string; catalogScope?: SkillhubCatalogScope }): Promise<HubFileResult>;
getPublishedFiles(params: { name: string; catalogScope?: SkillhubCatalogScope }): Promise<HubFilesResult>;
}

export interface HubSkillReferenceOmission {
Expand All @@ -54,12 +55,13 @@ export interface HubSkillReference {
export async function fetchHubSkillReference(
marketService: HubSkillReferenceReader,
slug: string,
catalogScope?: SkillhubCatalogScope,
): Promise<HubSkillReference | null> {
const infoRes = await marketService.info(slug);
const infoRes = await marketService.info(slug, catalogScope);
if (!('info' in infoRes) || !infoRes.info) return null;

const fileRes = await marketService
.readPublishedFile({ name: slug, path: 'SKILL.md' })
.readPublishedFile({ name: slug, path: 'SKILL.md', catalogScope })
.catch(() => null);
if (!fileRes || fileRes.file.truncated) return null;
// 主 SKILL.md 同样吃单文件上限(Greptile review):server 未标 truncated 但超
Expand All @@ -83,7 +85,7 @@ export async function fetchHubSkillReference(
}
};
try {
const listing = await marketService.getPublishedFiles({ name: slug });
const listing = await marketService.getPublishedFiles({ name: slug, catalogScope });
let consideredReferenceFiles = 0;
for (const meta of listing.files) {
if (meta.path === 'SKILL.md') continue;
Expand All @@ -101,7 +103,7 @@ export async function fetchHubSkillReference(
continue;
}
const one = await marketService
.readPublishedFile({ name: slug, path: meta.path })
.readPublishedFile({ name: slug, path: meta.path, catalogScope })
.catch(() => null);
if (!one) {
pushOmission(meta.path, 'file read failed');
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/learn-host/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export interface StartLearnHostDeps {
onUndispatchedUserTurn?: (sessionId: string) => void;
/** hub 源:拉市场 skill 详情 + 可用已发布文件(bootstrap 注入,/learn hub:<slug>
* 与 skill hub「学习此技能」共用)。未注入时 hub 源请求报 INVALID_PARAMS(兜底)。 */
fetchHubSkill?: (slug: string) => Promise<{
fetchHubSkill?: (slug: string, catalogScope?: 'market' | 'team') => Promise<{
name: string;
description: string;
content: string;
Expand Down
37 changes: 33 additions & 4 deletions apps/desktop/src/main/skillhub/__tests__/hubApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';

const mocks = vi.hoisted(() => ({ serverApiFetch: vi.fn() }));

vi.mock('../../serverApiClient', () => ({ serverApiFetch: mocks.serverApiFetch }));
vi.mock('../../clientEndpointsService', () => ({
getClientEndpoint: () => 'https://skills.example.com',
vi.mock('../../serverApiClient', () => ({
ServerApiError: class ServerApiError extends Error {
constructor(
public readonly code: string,
public readonly statusCode: number,
message: string,
) {
super(message);
this.name = 'ServerApiError';
}
},
serverApiFetch: mocks.serverApiFetch,
}));
const endpoint = vi.hoisted(() => vi.fn(() => 'https://skills.example.com'));
vi.mock('../../clientEndpointsService', () => ({ getClientEndpoint: endpoint }));
vi.mock('../../appCapabilities.js', () => ({ requireAppCapability: () => undefined }));

import { skillhubApiFetch } from '../hubApi';

describe('skillhubApiFetch', () => {
beforeEach(() => mocks.serverApiFetch.mockReset());
beforeEach(() => {
mocks.serverApiFetch.mockReset();
endpoint.mockClear();
});

it('给 serverApiFetch 传 logLabel=/api/skills-hub(不外泄 skill 身份)', async () => {
mocks.serverApiFetch.mockResolvedValueOnce({ ok: true });
Expand All @@ -25,6 +39,21 @@ describe('skillhubApiFetch', () => {
expect(opts.logLabel).toBe('/api/skills-hub');
// 不设 redactErrorDetails:SkillHub 依赖 ServerApiError.code 做业务分支,不能把 code 压成通用码。
expect(opts.redactErrorDetails).toBeUndefined();
expect(endpoint).toHaveBeenCalledTimes(1);
expect(opts.baseUrl?.()).toBe('https://skills.example.com');
expect(endpoint).toHaveBeenCalledTimes(2);
expect(endpoint).toHaveBeenCalledWith('cindySkillHubApiBaseUrl');
});

it('缺失 Cindy Skill Hub 端点时关闭云端能力且不发起相对请求', async () => {
endpoint.mockReturnValueOnce('');

await expect(skillhubApiFetch('/api/skills-hub/skills')).rejects.toMatchObject({
name: 'ServerApiError',
code: 'UNSUPPORTED_CAPABILITY',
statusCode: 0,
});
expect(mocks.serverApiFetch).not.toHaveBeenCalled();
});

it('调用方显式传的 logLabel 优先', async () => {
Expand Down
Loading