Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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