Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'bun:test';
import { canUseDefaultSessionClipboard } from '../default-session-permissions';
Comment on lines +1 to +2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This new test file is unreachable from every test command this repository runs: packages/desktop is excluded from the npm workspaces ("!packages/desktop" in the root package.json), and no CI workflow in this repo executes the desktop bun suite (verified at the base commit). The test passes when run manually, but nothing in this repo gates the change it covers (also applies to copy-response.test.ts). — Failure scenario: a future commit reverts or breaks the clipboard permission wiring, and response copy silently stops working again — every CI check on this repo stays green because none of them collects this test; it is gated only if the upstream openwork repo's CI happens to run it.

Suggested fix: if desktop validation intentionally lives in the upstream openwork repo, confirm its CI runs this suite; otherwise wire a desktop bun-test job into this repo's CI.

中文说明

这个新测试文件对本仓库运行的所有测试命令都不可达:packages/desktop 被排除在 npm workspaces 之外(根 package.json 中的 "!packages/desktop"),且本仓库没有任何 CI 工作流执行 desktop 的 bun 测试套件(已在 base 提交上核实)。手动运行该测试可以通过,但本仓库没有任何门禁覆盖它所保护的改动(同样适用于 copy-response.test.ts)。失败场景:未来某个提交还原或破坏了剪贴板权限接线,回复复制再次静默失效——本仓库的所有 CI 检查依然全绿,因为没有任何一个会收集这个测试;只有当上游 openwork 仓库的 CI 恰好运行它时才有门禁。

建议修复:如果 desktop 验证有意放在上游 openwork 仓库,请确认其 CI 会运行该套件;否则在本仓库 CI 中接入一个 desktop bun-test 任务。

— qwen3.8-max via Qwen Code /review (v0.21.5)


const trustedRequest = {
permission: 'clipboard-sanitized-write',
isMainFrame: true,
isWorkspaceWindow: true,
requestingUrl: 'file:///app/index.html',
devServerUrl: undefined,
};

describe('default session permissions', () => {
it('allows clipboard writes from the packaged app renderer', () => {
expect(canUseDefaultSessionClipboard(trustedRequest)).toBe(true);
});

it('allows clipboard writes from the configured Vite dev origin', () => {
expect(
canUseDefaultSessionClipboard({
...trustedRequest,
requestingUrl: 'http://localhost:5173/chat',
devServerUrl: 'http://localhost:5173',
}),
).toBe(true);
});

it.each([
['clipboard reads', { permission: 'clipboard-read' }],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The rejection matrix never pins the distinction between clipboard-sanitized-write and the unsanitized clipboard-write, so any mutation broadening the permission comparison survives the suite. Probe-verified during this review: applying permission === 'clipboard-sanitized-write' || permission === 'clipboard-write' keeps 9/9 tests green while granting the raw write permission; adding the row below catches the mutation. — Failure scenario: a one-line broadening grants the renderer the raw, unsanitized pasteboard-write permission, and every current test still passes — the sanitized variant was chosen precisely to avoid that grant, but the suite does not encode it.

Suggested change
['clipboard reads', { permission: 'clipboard-read' }],
['clipboard reads', { permission: 'clipboard-read' }],
['unsanitized clipboard writes', { permission: 'clipboard-write' }],
中文说明

拒绝矩阵没有钉住 clipboard-sanitized-write 与未净化的 clipboard-write 之间的区别,因此任何放宽权限比较的变异都能通过整个测试套件。本次审查中已用探针验证:应用 permission === 'clipboard-sanitized-write' || permission === 'clipboard-write' 变异后,9/9 测试仍然全绿,但实际上已授予了原始写入权限;加上下面这一行即可捕获该变异。失败场景:一行放宽就会让 renderer 获得未净化的原始粘贴板写入权限,而现有测试全部通过——选择净化变体正是为了避免这种授权,但测试套件没有把它固定下来。

— qwen3.8-max via Qwen Code /review (v0.21.5)

['unrelated permissions', { permission: 'geolocation' }],
['subframes', { isMainFrame: false }],
['unregistered windows', { isWorkspaceWindow: false }],
['external pages', { requestingUrl: 'https://example.com' }],
[
'a different dev port',
{
requestingUrl: 'http://localhost:5174/chat',
devServerUrl: 'http://localhost:5173',
},
],
['requests without a URL', { requestingUrl: undefined }],
])('rejects %s', (_label, overrides) => {
expect(
canUseDefaultSessionClipboard({
...trustedRequest,
...overrides,
}),
).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { isTrustedRendererFrameUrl } from './voice/frame-trust';

export interface DefaultSessionClipboardRequest {
permission: string;
isMainFrame: boolean;
isWorkspaceWindow: boolean;
requestingUrl: string | undefined;
devServerUrl: string | undefined;
}

export function canUseDefaultSessionClipboard({
permission,
isMainFrame,
isWorkspaceWindow,
requestingUrl,
devServerUrl,
}: DefaultSessionClipboardRequest): boolean {
return (
permission === 'clipboard-sanitized-write' &&
isMainFrame &&
isWorkspaceWindow &&
isTrustedRendererFrameUrl(requestingUrl, devServerUrl)
);
}
21 changes: 21 additions & 0 deletions packages/desktop/apps/electron/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import { initNotificationService, initBadgeIcon, initInstanceBadge, updateBadgeC
import { checkForUpdatesOnLaunch, setAutoUpdateEventSink, isUpdating } from './auto-update'
import type { EventSink } from '@craft-agent/server-core/transport'
import { validateGitBashPath, checkVCRedistInstalled } from '@craft-agent/server-core/services'
import { canUseDefaultSessionClipboard } from './default-session-permissions'

// Initialize electron-log for renderer process support
log.initialize()
Expand Down Expand Up @@ -457,8 +458,25 @@ app.whenReady().then(async () => {
isAudioOnlyMediaRequest(permission, details) &&
windowManager?.getWorkspaceForWindow(wc.id) != null,
)
const canWriteClipboard = (
wc: { id: number } | null | undefined,
permission: string,
details: { isMainFrame?: boolean; requestingUrl?: string } | undefined,
) => canUseDefaultSessionClipboard({
permission,
isMainFrame: details?.isMainFrame === true,
isWorkspaceWindow: Boolean(
wc && windowManager?.getWorkspaceForWindow(wc.id) != null,
),
requestingUrl: details?.requestingUrl,
devServerUrl: process.env.VITE_DEV_SERVER_URL,
})
session.defaultSession.setPermissionRequestHandler(
(wc, permission, callback, details) => {
if (canWriteClipboard(wc, permission, details)) {
callback(true)
return
Comment on lines +476 to +478

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The default-session handler wiring — both setPermissionRequestHandler and setPermissionCheckHandler delegating to canWriteClipboard before the non-voice deny path — has no test; only the extracted pure policy function canUseDefaultSessionClipboard is unit-tested. — Failure scenario: a future refactor that deletes or reorders either early return (or drops the check handler's while keeping the request handler's) lets clipboard-sanitized-write fall into the !VOICE_PERMISSIONS.has(permission) deny path — response copying silently fails on Windows again, the exact bug this PR fixes, while both new test files stay green.

Suggested fix: extract the handler decision logic into a testable form (mirroring the frame-trust extraction pattern) and test that a trusted workspace main frame is granted before the voice gate, that the check handler agrees, and that a non-workspace window is denied.

中文说明

默认会话处理器的接线部分——setPermissionRequestHandlersetPermissionCheckHandler 在非语音拒绝路径之前都委托给 canWriteClipboard——没有任何测试;目前只有被提取出来的纯策略函数 canUseDefaultSessionClipboard 有单元测试。失败场景:如果未来重构删除或调换了其中任一提前返回(或只保留了请求处理器的分支而删掉了检查处理器的),clipboard-sanitized-write 就会落入 !VOICE_PERMISSIONS.has(permission) 拒绝路径——回复复制会在 Windows 上再次静默失败(正是本 PR 修复的 bug),而两个新测试文件依然全绿。

建议修复:将处理器决策逻辑提取为可测试的形式(参照 frame-trust 的提取模式),并测试可信的工作区顶层 frame 在语音门之前被授权、检查处理器与请求处理器结论一致、未登记窗口被拒绝。

— qwen3.8-max via Qwen Code /review (v0.21.5)

Comment on lines +476 to +478

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The default-session scoping comment above these handlers ("Scope the grant to mic/media only — do NOT broaden the default session to every permission", lines 417-421) is now stale: this diff adds the clipboard-sanitized-write grant beneath it without updating it, so the only written statement of the default-session policy no longer matches the code. — Failure scenario: a maintainer auditing the handlers reads the stale comment and either concludes the clipboard grant is an unreviewed policy violation and removes it (re-breaking response copy), or treats the stale rule as authoritative and rejects a future narrow grant consistent with this precedent.

Suggested fix: update the comment alongside the grant — e.g. note that sanitized clipboard writes are likewise scoped to the trusted registered main-frame renderer (see canUseDefaultSessionClipboard) while every other default-session permission stays denied.

中文说明

这两个处理器上方的默认会话授权范围注释("Scope the grant to mic/media only — do NOT broaden the default session to every permission",第 417-421 行)现已过时:本 diff 在其下方新增了 clipboard-sanitized-write 授权却没有更新该注释,导致默认会话策略的唯一书面声明与代码不再一致。失败场景:维护者审计处理器时读到这条过时注释,要么认为剪贴板授权是未经审查的策略违规而将其移除(再次破坏回复复制),要么把过时的规则当作权威,拒绝未来与本先例一致的窄授权。

建议修复:在添加授权的同时更新注释——例如说明净化剪贴板写入同样限定于可信的已登记顶层 renderer frame(见 canUseDefaultSessionClipboard),其他所有默认会话权限仍保持拒绝。

— qwen3.8-max via Qwen Code /review (v0.21.5)

}
if (!VOICE_PERMISSIONS.has(permission)) {
mainLog.debug(`defaultSession: denied non-voice permission '${permission}'`)
callback(false)
Expand All @@ -472,6 +490,9 @@ app.whenReady().then(async () => {
},
)
session.defaultSession.setPermissionCheckHandler((wc, permission, _origin, details) => {
if (canWriteClipboard(wc, permission, details)) {
return true
}
if (!VOICE_PERMISSIONS.has(permission)) {
mainLog.debug(`defaultSession: denied non-voice permission check '${permission}'`)
return false
Expand Down
32 changes: 21 additions & 11 deletions packages/desktop/packages/ui/src/components/chat/TurnCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip'
import { parseDiffFromFile, type FileContents } from '@pierre/diffs'
import { getDiffStats, getUnifiedDiffStats } from '../code-viewer'
import { TurnCardActionsMenu } from './TurnCardActionsMenu'
import { copyResponseText } from './copy-response'
import { computeLastChildSet, groupActivitiesByParent, isActivityGroup, formatDuration, formatTokens, deriveTurnPhase, shouldShowThinkingIndicator, type ActivityGroup, type AssistantTurn } from './turn-utils'
import {
buildTurnTimelineItems,
Expand Down Expand Up @@ -1815,7 +1816,7 @@ export function ResponseCard({
const [displayedText, setDisplayedText] = useState(text)
const lastUpdateRef = useRef(Date.now())
// Copy to clipboard state
const [copied, setCopied] = useState(false)
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'failed'>('idle')
// Fullscreen state
const [isFullscreen, setIsFullscreen] = useState(false)
// Dark mode detection - scroll fade only shown in dark mode
Expand Down Expand Up @@ -1910,12 +1911,14 @@ export function ResponseCard({
})

const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy:', err)
const result = await copyResponseText(
text,
(value) => navigator.clipboard.writeText(value),
)
setCopyStatus(result.status)
setTimeout(() => setCopyStatus('idle'), 2000)
if (result.status === 'failed') {
console.error('Failed to copy response:', result.error)
}
}, [text])

Expand Down Expand Up @@ -2650,15 +2653,20 @@ export function ResponseCard({
onClick={handleCopy}
className={cn(
"turn-action-btn flex items-center gap-1.5 transition-colors select-none",
copied ? "text-success" : "text-muted-foreground hover:text-foreground",
copyStatus === 'copied' ? "text-success" : copyStatus === 'failed' ? "text-destructive" : "text-muted-foreground hover:text-foreground",
"focus:outline-none focus-visible:underline",
)}
>
{copied ? (
{copyStatus === 'copied' ? (
<>
<Check className={SIZE_CONFIG.iconSize} />
<span>{t('common.copied')}</span>
</>
) : copyStatus === 'failed' ? (
<>
<XCircle className={SIZE_CONFIG.iconSize} />
<span>{t('toast.copyFailed')}</span>
</>
) : (
<>
<Copy className={SIZE_CONFIG.iconSize} />
Expand Down Expand Up @@ -2712,13 +2720,15 @@ export function ResponseCard({
{!compactMode && !isPlan && showResponseActions && (
<div className="flex h-5 items-center justify-start gap-1.5 pl-[22px] pr-0.5 text-[11px] font-medium text-muted-foreground/70 opacity-0 pointer-events-none transition-opacity duration-150 select-none group-hover/response:pointer-events-auto group-hover/response:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100">
<ResponseActionButton
label={copied ? t('common.copied') : t('common.copy')}
label={copyStatus === 'copied' ? t('common.copied') : copyStatus === 'failed' ? t('toast.copyFailed') : t('common.copy')}
onClick={() => {
void handleCopy()
}}
>
{copied ? (
{copyStatus === 'copied' ? (
<Check className="size-3.5 text-success" />
) : copyStatus === 'failed' ? (
<XCircle className="size-3.5 text-destructive" />
) : (
<Copy className="size-3.5" />
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it, mock } from 'bun:test';
import { copyResponseText } from '../copy-response';
Comment on lines +1 to +2

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Same unreachability as the permission-policy test: this file sits outside every npm workspace and no CI workflow in this repo runs the desktop bun suite, so the copy-failure status path it covers has no gate in this repository's pipeline (also applies to default-session-permissions.test.ts). — Failure scenario: if copyResponseText regresses — e.g. a refactor rethrows clipboard errors instead of returning { status: 'failed' } — TurnCard's localized copy-failure state disappears and copy clicks surface unhandled rejections, while this repo's CI remains green because nothing here ever executes the test.

Suggested fix: confirm the desktop suite runs in the upstream openwork repo's CI, or add a desktop job here.

中文说明

与权限策略测试相同的不可达问题:该文件位于所有 npm workspaces 之外,本仓库没有任何 CI 工作流运行 desktop 的 bun 套件,因此它所覆盖的复制失败状态路径在本仓库流水线中没有门禁(同样适用于 default-session-permissions.test.ts)。失败场景:如果 copyResponseText 退化——例如重构后重新抛出剪贴板错误而不是返回 { status: 'failed' }——TurnCard 的本地化复制失败状态会消失,复制点击会出现未处理的 rejection,而本仓库 CI 依然全绿,因为这里没有任何环节执行该测试。

建议修复:确认上游 openwork 仓库的 CI 会运行 desktop 套件,或在本仓库添加 desktop 任务。

— qwen3.8-max via Qwen Code /review (v0.21.5)


describe('copyResponseText', () => {
it('writes the complete response and reports success', async () => {
const writeText = mock(async () => {});

await expect(
copyResponseText('complete response', writeText),
).resolves.toEqual({ status: 'copied' });
expect(writeText).toHaveBeenCalledWith('complete response');
});

it('reports clipboard failures', async () => {
const writeText = mock(async () => {
throw new Error('permission denied');
});

await expect(
copyResponseText('response', writeText),
).resolves.toMatchObject({
status: 'failed',
error: expect.any(Error),
});
});
});
11 changes: 11 additions & 0 deletions packages/desktop/packages/ui/src/components/chat/copy-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export async function copyResponseText(
text: string,
writeText: (value: string) => Promise<void>,
): Promise<{ status: 'copied' } | { status: 'failed'; error: unknown }> {
try {
await writeText(text);
return { status: 'copied' };
} catch (error) {
return { status: 'failed', error };
}
}
Loading