Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,62 @@ describe('setContext / routeCommand', () => {
});
});

it('跨会话 open-turn-review 按 hostSessionId(lead 桶)裁决,worker sessionId 不拒发', async () => {
// 协同面板审查 worker 轮次:command.sessionId 是取数目标 worker,可见桶是
// lead(hostSessionId)。detached context 停在 lead 上,按 sessionId 裁决会
// 误判 stale-context,worker 审查入口在 detached 形态下点了没反应。
const h = makeHarness({ detached: true });
h.controller.setContext(ctx);
h.controller.open();
h.controller.markReady();

const command = {
type: 'open-turn-review' as const,
sessionId: 'worker-1',
changeSetIds: ['c1'],
selectedDiffId: null,
selectedPath: null,
requestNonce: 1,
hostSessionId: 's1',
};
await expect(
h.controller.routeCommand({ command, allowOpen: true }),
).resolves.toBe('routed');
expect(h.sends.at(-1)).toEqual({ channel: 'cmd-channel', payload: command });

// hostSessionId 与当前 context 不符时仍是 stale-context(不能放宽成任意会话)。
await expect(
h.controller.routeCommand({
command: { ...command, hostSessionId: 'other-lead' },
allowOpen: true,
}),
).resolves.toBe('stale-context');
});

it('跨会话 open-turn-review 延迟命令按 lead 桶入队,lead ready 后派发', async () => {
const h = makeHarness({ detached: true });
h.controller.setContext(ctx);

const command = {
type: 'open-turn-review' as const,
sessionId: 'worker-1',
changeSetIds: ['c1'],
selectedDiffId: null,
selectedPath: null,
requestNonce: 2,
hostSessionId: 's1',
};
await expect(
h.controller.routeCommand({ command, allowOpen: false }),
).resolves.toBe('queued');
expect(h.windows).toHaveLength(0);

// context 一直是 lead(s1),不会切到 worker;flush 必须按 lead 桶命中。
h.controller.open();
h.controller.markReady();
expect(h.sends.at(-1)).toEqual({ channel: 'cmd-channel', payload: command });
});

it('context mismatch / unavailable 返回 stale-context,不开窗也不派发', async () => {
const h = makeHarness({ detached: true });
h.controller.setContext({ ...ctx, sessionId: 's2' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,80 @@ describe('right-sidebar-window IPC', () => {
).rejects.toThrow(/searchJump/);
});

it('validates and forwards the turn-review host bucket session', async () => {
// 协同面板里 worker 流的审查入口带宿主(lead)桶。sanitizer 重建命令对象,
// 漏透传 hostSessionId 会让 detached 窗口路径退回 worker 的不可见桶。
const controller = makeController();
const { handler, mainWebContents } = registerController(controller);

await handler(
{ sender: mainWebContents },
{
command: {
type: 'open-turn-review',
sessionId: 'worker-1',
changeSetIds: ['change-1'],
requestNonce: 1,
hostSessionId: 'lead-1',
},
allowOpen: true,
},
);
await handler(
{ sender: mainWebContents },
{
command: {
type: 'open-turn-review',
sessionId: 'worker-1',
changeSetIds: ['change-1'],
requestNonce: 2,
},
allowOpen: true,
},
);

expect(controller.routeCommand).toHaveBeenNthCalledWith(1, {
command: {
type: 'open-turn-review',
sessionId: 'worker-1',
changeSetIds: ['change-1'],
selectedDiffId: null,
selectedPath: null,
requestNonce: 1,
hostSessionId: 'lead-1',
},
allowOpen: true,
});
expect(controller.routeCommand).toHaveBeenNthCalledWith(2, {
command: {
type: 'open-turn-review',
sessionId: 'worker-1',
changeSetIds: ['change-1'],
selectedDiffId: null,
selectedPath: null,
requestNonce: 2,
hostSessionId: null,
},
allowOpen: true,
});

await expect(
handler(
{ sender: mainWebContents },
{
command: {
type: 'open-turn-review',
sessionId: 'worker-1',
changeSetIds: ['change-1'],
requestNonce: 3,
hostSessionId: 42,
},
allowOpen: true,
},
),
).rejects.toThrow(/hostSessionId/);
});

it('validates and forwards external-file browser commands', async () => {
const controller = makeController();
const { handler, mainWebContents } = registerController(controller);
Expand Down
20 changes: 16 additions & 4 deletions apps/desktop/src/main/right-sidebar-window/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ export interface RsbWindowControllerDeps {
const READY_TIMEOUT_MS = 8000;
const MAX_DEFERRED_SESSIONS = 8;

/**
* command 的宿主桶 session —— 裁决可见性与 deferred 排队都以它为准。
* open-turn-review 可跨会话(协同面板审查 worker 轮次:sessionId 是取数目标
* worker,tab 落在 lead 的桶),其余命令宿主即自身 sessionId。
*/
function commandHostSessionId(cmd: RsbWindowCommand): string {
return cmd.type === 'open-turn-review' ? (cmd.hostSessionId ?? cmd.sessionId) : cmd.sessionId;
}

export class RsbWindowController {
private winRef: BrowserWindow | null = null;
/** BrowserWindow.close() 到 closed 事件之间仍未 destroyed,不能继续当活 host。 */
Expand Down Expand Up @@ -209,7 +218,7 @@ export class RsbWindowController {
return Boolean(
this.lastContext?.available &&
this.lastContext.sessionId &&
this.lastContext.sessionId === cmd.sessionId,
this.lastContext.sessionId === commandHostSessionId(cmd),
);
}

Expand Down Expand Up @@ -271,7 +280,10 @@ export class RsbWindowController {
}

private enqueueDeferredCommand(command: RsbWindowCommand): void {
const previous = this.deferredCommands.get(command.sessionId);
// 按宿主桶排队:跨会话 open-turn-review 属于 lead 的桶,须由 lead 上下文
// flush;按 worker sessionId 入队会在 context 保持 lead 时永远刷不出来。
const hostSessionId = commandHostSessionId(command);
const previous = this.deferredCommands.get(hostSessionId);
if (
command.type === 'ensure-orca-workers-tab' &&
previous?.type === 'ensure-orca-workers-tab' &&
Expand All @@ -281,13 +293,13 @@ export class RsbWindowController {
return;
}
if (
!this.deferredCommands.has(command.sessionId) &&
!this.deferredCommands.has(hostSessionId) &&
this.deferredCommands.size >= MAX_DEFERRED_SESSIONS
) {
const oldest = this.deferredCommands.keys().next().value as string | undefined;
if (oldest) this.deferredCommands.delete(oldest);
}
this.deferredCommands.set(command.sessionId, command);
this.deferredCommands.set(hostSessionId, command);
}

private flushDeferredCommandsToDetachedHost(): void {
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/right-sidebar-window/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,22 @@ function parseCommand(raw: unknown): RsbWindowCommand {
if (typeof r.requestNonce !== 'number' || !Number.isSafeInteger(r.requestNonce)) {
throwIpcError('INVALID_PARAMS', 'command.requestNonce must be an integer');
}
if (
r.hostSessionId !== undefined
&& r.hostSessionId !== null
&& (typeof r.hostSessionId !== 'string' || r.hostSessionId.length === 0 || r.hostSessionId.length > 256)
) {
throwIpcError('INVALID_PARAMS', 'command.hostSessionId must be string | null');
}
return {
type: 'open-turn-review',
sessionId: r.sessionId,
changeSetIds: r.changeSetIds as string[],
selectedDiffId: typeof r.selectedDiffId === 'string' ? r.selectedDiffId : null,
selectedPath: typeof r.selectedPath === 'string' ? r.selectedPath : null,
requestNonce: r.requestNonce,
// 协同面板里 worker 流的入口带宿主(lead)桶;缺省 null = tab 落 sessionId 自身桶。
hostSessionId: typeof r.hostSessionId === 'string' ? r.hostSessionId : null,
};
}
if (r.type === 'open-file-browser') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,103 @@ describe('turn change-set sidecar store', () => {
});
});

it('skips known writes literally outside the workspace without marking the capture incomplete', async () => {
const inWorkspace = path.join(workdir, 'kept.txt');
const outside = path.join(root, 'agent-temp.md');
await beginTurnChangeSet({
sessionId: 'session-1',
anchorClientId: 'user-1',
provider: 'claude-code',
cwd: workdir,
});
// Deliberate scope exclusion: temp files outside the workspace tree are not
// tracked and must not degrade the in-workspace capture to partial.
await captureKnownFileBefore({
sessionId: 'session-1',
provider: 'claude-code',
cwd: workdir,
targetPath: outside,
});
await fs.writeFile(outside, 'temp\n', 'utf8');
await captureKnownFileBefore({
sessionId: 'session-1',
provider: 'claude-code',
cwd: workdir,
targetPath: 'kept.txt',
});
await fs.writeFile(inWorkspace, 'kept\n', 'utf8');
await finalizeTurnChangeSet('session-1', null, 'complete');

const [summary] = await listTurnChangeSets('session-1');
expect(summary).toMatchObject({
state: 'complete',
incompleteReasons: [],
fileCount: 1,
});
expect(summary?.files[0]?.path).toBe('kept.txt');
});

it('drops a turn whose only known write was outside the workspace', async () => {
const outside = path.join(root, 'agent-temp.md');
await beginTurnChangeSet({
sessionId: 'session-1',
anchorClientId: 'user-1',
provider: 'claude-code',
cwd: workdir,
});
await captureKnownFileBefore({
sessionId: 'session-1',
provider: 'claude-code',
cwd: workdir,
targetPath: outside,
});
await fs.writeFile(outside, 'temp\n', 'utf8');
await finalizeTurnChangeSet('session-1', null, 'complete');

expect(await listTurnChangeSets('session-1')).toHaveLength(0);
});

it('still marks the capture incomplete when an in-workspace path escapes via symlink', async () => {
const escapeTarget = path.join(root, 'escape-target');
await fs.mkdir(escapeTarget);
await fs.writeFile(path.join(escapeTarget, 'secret.txt'), 'secret\n', 'utf8');
await fs.symlink(
escapeTarget,
path.join(workdir, 'link'),
process.platform === 'win32' ? 'junction' : 'dir',
);
await beginTurnChangeSet({
sessionId: 'session-1',
anchorClientId: 'user-1',
provider: 'claude-code',
cwd: workdir,
});
// Literally inside the workspace, resolves outside: the workspace tree looks
// touched, so the incomplete reason must stay.
await captureKnownFileBefore({
sessionId: 'session-1',
provider: 'claude-code',
cwd: workdir,
targetPath: 'link/secret.txt',
});
const inWorkspace = path.join(workdir, 'kept.txt');
await captureKnownFileBefore({
sessionId: 'session-1',
provider: 'claude-code',
cwd: workdir,
targetPath: 'kept.txt',
});
await fs.writeFile(inWorkspace, 'kept\n', 'utf8');
await finalizeTurnChangeSet('session-1', null, 'complete');

const [summary] = await listTurnChangeSets('session-1');
expect(summary).toMatchObject({
state: 'partial',
incompleteReasons: expect.arrayContaining(['outside-workspace']),
fileCount: 1,
});
});

it.each([
['absolute path', 'diff --git /outside.txt /outside.txt'],
['parent traversal', 'diff --git a/../escape.txt b/../escape.txt'],
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/main/turn-change-set/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,7 +918,13 @@ export async function captureKnownFileBefore(input: KnownFileWriteCapture): Prom
const pending = ensurePending(input.sessionId, input.provider, input.cwd);
const target = safeRelativeTarget(input.cwd, input.targetPath);
if (!target) {
addIncompleteReason(pending, 'outside-workspace');
// A known write target literally outside the workspace (agent temp files,
// scratchpad, OS temp dirs) is a deliberate scope exclusion, not a capture
// loss: turn change tracking only covers the workspace tree. Recording
// 'outside-workspace' here spawned a dead-end "+0 -0" partial card for
// turns that never touched the workspace at all. The realpath escape check
// below still records the reason — there the workspace tree appears
// touched, which is worth flagging.
return;
}
if (detectSensitivePath(target.relativePath, { allowEnvTemplates: true })) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,17 +360,26 @@ describe('buildRenderItems — key stability', () => {
createdAt: 3,
completedAt: 4,
};
// The store only records 'outside-workspace' for suspicious captures (symlink
// escapes, fail-closed provider diff blocks) — that evidence must stay visible.
const escaped: TurnChangeSetSummary = {
...base,
id: 'cs-escape',
incompleteReasons: ['outside-workspace'],
createdAt: 5,
completedAt: 6,
};

const { items } = buildRenderItems(
[mkUser('u1'), mkAssistant('a1'), mkUser('u2')],
undefined,
undefined,
{ turnChangeSets: [noEvidence, truncated] },
{ turnChangeSets: [noEvidence, truncated, escaped] },
);
const cards = items.filter(
(item): item is Extract<RenderItem, { type: 'turn_changes' }> => item.type === 'turn_changes',
);
expect(cards.map((card) => card.changeSet.id)).toEqual(['cs-too-large']);
expect(cards.map((card) => card.changeSet.id)).toEqual(['cs-too-large', 'cs-escape']);
});

it('keeps opaque command artifacts as fallback chips without duplicating exact files', () => {
Expand Down
Loading