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 @@ -346,11 +346,12 @@ describe('buildRenderItems — key stability', () => {
additions: 0,
deletions: 0,
};
// Only "we might not have seen everything" reasons: review pane would be empty.
// Only "we might not have seen everything" or out-of-tracking-scope reasons:
// review pane would be empty.
const noEvidence: TurnChangeSetSummary = {
...base,
id: 'cs-noise',
incompleteReasons: ['opaque-tool', 'turn-failed', 'concurrent-workspace'],
incompleteReasons: ['opaque-tool', 'turn-failed', 'concurrent-workspace', 'outside-workspace'],
};
// Proof that real changes existed but were not recorded: card must stay.
const truncated: TurnChangeSetSummary = {
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/renderer/__tests__/generatedFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,40 @@ describe('collectGeneratedFiles', () => {
expect(files[0].source).toBe('tool');
});

it('collapses doubled backslashes so escaped wrapper commands do not duplicate chips', () => {
// 实测场景(pr-watch session):powershell 包一层 node -e,落库命令文本里的
// 路径带转义残留(`C:\\Users\\...`);同轮另一条命令用正斜杠形态。fs 层它们
// 是同一文件(Windows 归并重复分隔符),不折叠会出两个同名 chip。
const files = collectGeneratedFiles(
[
toolUse('Bash', {
command: "powershell -Command '$p = 'C:\\\\Users\\\\U\\\\pr-watch\\\\registry.json'; Get-Content $p'",
}),
toolUse('Bash', {
command: "node -e \"const p='C:/Users/U/pr-watch/registry.json'; console.log(p);\"",
}),
],
'C:\\Users\\U\\pr-watch',
);
const registry = files.filter((f) => f.name === 'registry.json');
expect(registry).toHaveLength(1);
// 画布路径本身也折叠成单反斜杠本机形态,不带转义残留。
expect(registry[0].path).toBe('C:\\Users\\U\\pr-watch\\registry.json');
});

it('keeps the UNC leading double backslash while collapsing inner separator runs', () => {
const files = collectGeneratedFiles(
[
toolUse('Bash', { command: "copy out '\\\\server\\share\\\\dir\\report.csv'" }),
toolUse('Bash', { command: "open '\\\\server\\share\\dir\\report.csv'" }),
],
'C:\\work',
);
const reports = files.filter((f) => f.name === 'report.csv');
expect(reports).toHaveLength(1);
expect(reports[0].path.startsWith('\\\\server\\')).toBe(true);
});

it('excludes command mentions of files edited by file tools this turn', () => {
// 编码会话形态:Edit 改了源码文件,随后命令引用它(跑测试)。它是编辑不是
// 新建,不能因 mtime 落在本轮窗口就被当成产物。
Expand Down
Loading