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
6 changes: 6 additions & 0 deletions apps/desktop/src/main/maker-ipc/collabProjectPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export interface CollabProjectPolicyContext {
* per-query http 注入 + persistent token 与 ?session= 路由),cindy_orca
* 在两端都可用。
*
* device-link(跨设备远程控制)不需要在这里开任何例外:控制端的 enable-orca
* 经隧道路由到**被控端**执行,到了那一侧它就是一个普通本地会话
* (remoteHostId 为空、workingDir 是被控端真实路径),自然走下面的本机项目级
* 分支。控制端 renderer 会先隧道读被控端的 collab 开关来置灰入口(见
* makerTransport.pluginEnableStateFor),那只是体验层的提前告知。
*
* 这是主进程的最终授权边界;Renderer 的入口状态只是用户体验层,
* 不能替代这里的校验。
*/
Expand Down
136 changes: 136 additions & 0 deletions apps/desktop/src/renderer/__tests__/collabEntryPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* collabEntryPolicy 单测:协同入口的**单一判定口径**。
*
* 存在的理由(issue #1170):新建草稿与已创建会话曾各自写一份 eligible 判据,于是同一个
* device-link 项目在草稿里没有协同开关、发出第一条消息进会话页后开关又冒出来。判据分叉
* 没有任何编译/测试信号,所以这里把五类场景钉死,并另有一条守卫防止两个调用点再各写一份。
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

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

import { resolveCollabEntryPolicy } from '@/features/cc-agent/collabEntryPolicy';

const R = resolve(__dirname, '..');
// Windows checkout(core.autocrlf)下源码是 CRLF;统一归一成 LF。
const read = (rel: string) => readFileSync(resolve(R, rel), 'utf8').replace(/\r\n/g, '\n');

describe('resolveCollabEntryPolicy 五类场景', () => {
it('本地项目:可挂入口,查本机项目级', () => {
expect(
resolveCollabEntryPolicy({ workspaceKind: 'project', workingDir: '/Users/me/proj' }),
).toEqual({ eligible: true, skipProjectQuery: false });
});

it('SSH 远端项目:可挂入口,但跳过项目级(远端路径在本机查无意义)', () => {
const scope = resolveCollabEntryPolicy({
workspaceKind: 'project',
workingDir: '/home/me/proj',
remoteHostId: 'host-1',
});
expect(scope.eligible).toBe(true);
expect(scope.skipProjectQuery).toBe(true);
expect(scope.policyDeviceId).toBeUndefined();
});

it('device-link 项目:可挂入口,项目级查询隧道到被控设备', () => {
const scope = resolveCollabEntryPolicy({
workspaceKind: 'project',
workingDir: '/Users/other/proj',
deviceLinkDeviceId: 'dev-1',
});
expect(scope.eligible).toBe(true);
expect(scope.policyDeviceId).toBe('dev-1');
// device-link 的项目级配置在被控端**真实存在**,不能跳过 —— 跳过就退化成只看
// 用户级,项目里单独关掉 collab 的设置会被无视。
expect(scope.skipProjectQuery).toBe(false);
});

it('对话模式(无项目目录):不挂入口', () => {
// dialogue 会话在 main 侧也会拿到一个自动分配的运行目录,所以必须靠 workspaceKind
// 判定,不能从 workingDir 反推。
expect(
resolveCollabEntryPolicy({
workspaceKind: 'dialogue',
workingDir: '/Users/me/Library/.../dialogues/2026-07-31/s1',
}).eligible,
).toBe(false);
expect(
resolveCollabEntryPolicy({ workspaceKind: 'project', workingDir: null }).eligible,
).toBe(false);
expect(
resolveCollabEntryPolicy({ workspaceKind: 'project', workingDir: ' ' }).eligible,
).toBe(false);
});

it('Orca Worker 子会话:不挂入口(worker 自己不能再开协同)', () => {
expect(
resolveCollabEntryPolicy({
workspaceKind: 'project',
workingDir: '/Users/me/proj',
orcaRole: 'worker',
}).eligible,
).toBe(false);
});

it('Orca Lead 会话本身仍 eligible(ON 态的 pill 要能渲染成关闭按钮)', () => {
expect(
resolveCollabEntryPolicy({
workspaceKind: 'project',
workingDir: '/Users/me/proj',
orcaRole: 'lead',
}).eligible,
).toBe(true);
});

it('被控设备上的 SSH 远端项目:两个维度同时成立,不是二选一', () => {
const scope = resolveCollabEntryPolicy({
workspaceKind: 'project',
workingDir: '/home/me/proj',
remoteHostId: 'host-1',
deviceLinkDeviceId: 'dev-1',
});
// 隧道到被控端执行查询,并在被控端那侧也跳过项目级(路径属于再远一层的 SSH 主机)。
expect(scope).toEqual({ eligible: true, policyDeviceId: 'dev-1', skipProjectQuery: true });
});

it('空串 deviceId / remoteHostId 当作没有(不产生 policyDeviceId 与 skip)', () => {
expect(
resolveCollabEntryPolicy({
workspaceKind: 'project',
workingDir: '/Users/me/proj',
deviceLinkDeviceId: '',
remoteHostId: '',
}),
).toEqual({ eligible: true, skipProjectQuery: false });
});
});

describe('drift 守卫:两个入口共用同一份判定', () => {
it('草稿路由与会话视图都调 resolveCollabEntryPolicy,不再各自内联判据', () => {
for (const f of [
'features/cc-agent/NewMakerDraftRoute.tsx',
'features/cc-agent/CCAgentSessionView.tsx',
]) {
const src = read(f);
expect(src, f).toContain('resolveCollabEntryPolicy({');
expect(src, f).toContain('collabEntry.eligible');
// 内联判据的两个历史形态:草稿的 `effectiveDeviceLinkDeviceId == null`(把
// device-link 整个排除掉)与会话页的 `orcaRole !== 'worker'` 链。
expect(src, f).not.toContain('effectiveDeviceLinkDeviceId == null');
expect(src, f).not.toContain("orcaRole !== 'worker'");
}
});

it('两个入口的项目级查询都按 collabEntry 的归属传参(不再写死本机)', () => {
for (const f of [
'features/cc-agent/NewMakerDraftRoute.tsx',
'features/cc-agent/CCAgentSessionView.tsx',
]) {
const src = read(f);
expect(src, f).toContain('skipQuery: collabEntry.skipProjectQuery');
expect(src, f).toContain('deviceId: collabEntry.policyDeviceId ?? null');
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function stubElectron() {
closeSession: vi.fn(),
enableOrca: vi.fn(),
disableOrca: vi.fn(),
plugins: { getState: vi.fn().mockResolvedValue({ effectiveEnabled: true }) },
input: { clearSession: vi.fn(), compact: vi.fn() },
};
const orcaWorkflows = {
Expand Down Expand Up @@ -201,6 +202,74 @@ describe('makerApiFor 路由(完整对等会话级操作)', () => {
expect(localMessages.estimatedSessionValue).toHaveBeenCalledWith('local-sess');
expect(invoke).not.toHaveBeenCalled();
});

// issue #1170 codex P2:协同 mutation 在 relay 瞬时重连清空注册表的窗口内若退回本机,
// 会在**控制端**建出或销毁一个 team —— 与「入口按粘滞归属渲染」直接矛盾。
it('makerApiForSticky:注册表被清空后仍走隧道,不退回本机', async () => {
const { makerSpies, invoke } = stubElectron();
const { makerApiFor, makerApiForSticky } = await import('@/lib/makerTransport');
const { remoteProjectsStore } = await import('@/features/device-link/remoteProjectsStore');
const { getStickySessionDeviceId } = await import(
'@/features/device-link/stickySessionOrigin'
);

remoteProjectsStore.setDeviceSessions('dev-1', 'Mac', [sess('lead')]);
// 先解析一次,让粘滞归属记住 dev-1(与真实链路一致:视图渲染时已解析过)。
expect(getStickySessionDeviceId('lead')).toBe('dev-1');

// relay 瞬时重连:注册表被清空,非粘滞判定这一刻会解析成「本机」。
remoteProjectsStore.setDeviceSessions('dev-1', 'Mac', []);
await makerApiFor('lead').enableOrca('lead', { workerAgent: 'codex' });
expect(makerSpies.enableOrca).toHaveBeenCalled(); // 非粘滞:确实退回了本机(问题本体)

invoke.mockClear();
makerSpies.enableOrca.mockClear();
await makerApiForSticky('lead').enableOrca('lead', { workerAgent: 'codex' });
expect(invoke).toHaveBeenCalledWith('dev-1', 'maker:session:enable-orca', [
'lead',
{ workerAgent: 'codex' },
]);
expect(makerSpies.enableOrca).not.toHaveBeenCalled();
});

it('makerApiForSticky:从未解析过归属的本机会话仍走本机(零回归)', async () => {
const { makerSpies, invoke } = stubElectron();
const { makerApiForSticky } = await import('@/lib/makerTransport');

await makerApiForSticky('local-only').disableOrca('local-only');
expect(makerSpies.disableOrca).toHaveBeenCalledWith('local-only');
expect(invoke).not.toHaveBeenCalled();
});

// issue #1170:协同入口的项目级 collab 开关此前一律查控制端本机 —— 拿被控端的路径查
// 自己的 fs,读到的是控制端自己的用户级开关,与被控端 main 的权威授权可能相反。
it('pluginEnableStateFor:传了 deviceId 就隧道查被控端;没传才查本机', async () => {
const { makerSpies, invoke } = stubElectron();
invoke.mockResolvedValue({ effectiveEnabled: false });
const { pluginEnableStateFor } = await import('@/lib/makerTransport');

await expect(pluginEnableStateFor('dev-1', 'collab', '/host/proj')).resolves.toEqual({
effectiveEnabled: false,
});
expect(invoke).toHaveBeenCalledWith('dev-1', 'maker:plugins:get-state', [
'collab',
'/host/proj',
]);
expect(makerSpies.plugins.getState).not.toHaveBeenCalled();

invoke.mockClear();
await pluginEnableStateFor(null, 'collab', '/local/proj');
expect(makerSpies.plugins.getState).toHaveBeenCalledWith('collab', '/local/proj');
expect(invoke).not.toHaveBeenCalled();

// skipQuery 档(SSH 远端):不传 workingDir → 落用户级/全局级,两条路由都要保持原样透传。
invoke.mockClear();
makerSpies.plugins.getState.mockClear();
await pluginEnableStateFor('dev-1', 'collab', undefined);
expect(invoke).toHaveBeenCalledWith('dev-1', 'maker:plugins:get-state', ['collab', undefined]);
await pluginEnableStateFor(undefined, 'collab', undefined);
expect(makerSpies.plugins.getState).toHaveBeenCalledWith('collab', undefined);
});
});

describe('drift 守卫:makerTransport 隧道的每个 channel 都在 REMOTE_INVOKE_ALLOWLIST 内', () => {
Expand Down
86 changes: 85 additions & 1 deletion apps/desktop/src/renderer/__tests__/newMakerDraft.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,11 +508,95 @@ describe('newMakerDraft store', () => {
expect(getDraft().deviceLinkDeviceName).toBeNull();
});

it('device-link 草稿禁用协同的既有不变量不受影响', async () => {
// issue #1170:device-link 草稿曾被硬编码禁用协同,而同一个项目建成会话后入口又出现。
// 现在草稿与会话页共用同一份判定(resolveCollabEntryPolicy),device-link **项目**可开协同;
// 关掉协同的只剩「对话模式」这一条(workingDir == null)。
it('选设备但没选项目(对话模式)→ 协同仍然关闭', async () => {
const { getDraft, patchDraft } = await loadModule();
patchDraft({ workingDir: '/local/proj' });
patchDraft({ collab: { enabled: true, worker: 'cc' } });
patchDraft({ deviceLinkDeviceId: 'dev-a', deviceLinkDeviceName: 'Studio Mac', workingDir: null });
expect(getDraft().collab.enabled).toBe(false);
});

it('选设备上的项目 → 协同开关保留(不再被 device-link 一刀切关掉)', async () => {
const { getDraft, patchDraft } = await loadModule();
patchDraft({ workingDir: '/local/proj' });
patchDraft({ collab: { enabled: true, worker: 'cc' } });
patchDraft({
deviceLinkDeviceId: 'dev-a',
deviceLinkDeviceName: 'Studio Mac',
workingDir: '/host/proj',
});
expect(getDraft().collab.enabled).toBe(true);
expect(getDraft().collab.worker).toBe('cc');
});

// model / providerId / effort / fast 都是设备作用域:原样带到另一台机器会撞被控端的
// 精确 preflight,协同静默降级成单会话 —— 正是 #1170 抱怨的「入口能点但走不完」。
it('换目标设备 → 清掉 Worker 富配置,但保留协同开关与 worker 类型', async () => {
const { getDraft, patchDraft } = await loadModule();
patchDraft({ workingDir: '/local/proj' });
patchDraft({
collab: {
enabled: true,
worker: 'codex',
workerConfig: {
role: 'developer',
model: 'codex/gpt-5.5',
effort: 'high',
fast: false,
providerId: 'prov-local',
initialTask: '先跑一遍测试',
},
},
});
patchDraft({
deviceLinkDeviceId: 'dev-a',
deviceLinkDeviceName: 'Studio Mac',
workingDir: '/host/proj',
});
expect(getDraft().collab.enabled).toBe(true);
expect(getDraft().collab.worker).toBe('codex');
expect(getDraft().collab.workerConfig).toBeUndefined();
});

it('本机 → 设备 A → 设备 B 的每一跳都清 Worker 配置', async () => {
const { getDraft, patchDraft, patchCollab } = await loadModule();
patchDraft({
deviceLinkDeviceId: 'dev-a',
deviceLinkDeviceName: 'Studio Mac',
workingDir: '/host-a/proj',
});
patchCollab({
enabled: true,
worker: 'cc',
workerConfig: { role: 'reviewer', model: 'claude-opus-4-7' },
});
expect(getDraft().collab.workerConfig?.model).toBe('claude-opus-4-7');
patchDraft({
deviceLinkDeviceId: 'dev-b',
deviceLinkDeviceName: 'Laptop',
workingDir: '/host-b/proj',
});
expect(getDraft().collab.workerConfig).toBeUndefined();
expect(getDraft().collab.enabled).toBe(true);
});

it('同一台设备内换项目 → Worker 配置保留(模型目录没变)', async () => {
const { getDraft, patchDraft, patchCollab } = await loadModule();
patchDraft({
deviceLinkDeviceId: 'dev-a',
deviceLinkDeviceName: 'Studio Mac',
workingDir: '/host-a/proj',
});
patchCollab({
enabled: true,
worker: 'cc',
workerConfig: { role: 'reviewer', model: 'claude-opus-4-7' },
});
patchDraft({ deviceLinkDeviceId: 'dev-a', deviceLinkDeviceName: 'Studio Mac', workingDir: '/host-a/other' });
expect(getDraft().collab.workerConfig?.model).toBe('claude-opus-4-7');
});
});
});
Loading
Loading