Fix remote paste and workspace activity (#3521)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-30 03:35:56 -04:00 committed by GitHub
parent 5dad1721cd
commit fba8297e40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 987 additions and 67 deletions

View File

@ -0,0 +1,108 @@
# Remote Web Paste And Activity Fixes
## Problem
- Paired browser image paste reads a clipboard image in `src/renderer/src/web/web-preload-api.ts:120`, converts it to PNG base64, and sends the whole string through `clipboard.saveImageAsTempFile` at `src/renderer/src/web/web-preload-api.ts:1613`. `src/main/runtime/rpc/ws-transport.ts` sets `maxPayload` to 1 MiB, and the browser RPC client encrypts JSON requests into base64 text frames. A screenshot well below the existing 24 MiB clipboard schema limit can still exceed the encrypted WebSocket frame cap and close the socket as `Remote Orca runtime connection interrupted`.
- Paired browser workspace activity is inflated. `src/renderer/src/lib/worktree-status.ts` and `src/renderer/src/lib/worktree-activity-state.ts` currently treat any mirrored web terminal surface ID as active, even when `src/renderer/src/runtime/web-session-tabs-sync.ts` only has a pending host terminal with no ready PTY handle.
## Root Cause
- The clipboard RPC accepts up to 24 MiB of base64 in `src/main/runtime/rpc/methods/clipboard.ts`, but the web transport limit applies before the server can validate RPC params. Because the web client encrypts JSON and base64-encodes the encrypted bytes, the safe plaintext chunk is materially smaller than 1 MiB. The existing 512 KiB file-upload chunk size in `src/renderer/src/runtime/runtime-file-client.ts` is safe after JSON plus encryption expansion; a chunk close to 1 MiB is not.
- The file-upload RPCs cannot be reused directly. They write worktree-relative files and then commit a rename; clipboard paste must call `saveClipboardImageBufferAsTempFile`, return a local or SSH temp path, and preserve the terminal repo's `connectionId`.
- Web session-tab sync intentionally mirrors pending host terminal surfaces so the tab model stays in parity, but it writes `ptyIdsByTabId[mirroredTabId]` only from `status: "ready"` surfaces. The status/filter helpers then bypass that liveness map with `isWebTerminalSurfaceTabId`, so pending mirrors look active.
## Non-Goals
- Do not raise the global WebSocket `maxPayload`; it protects all runtime traffic, including pre-auth and mobile sockets.
- Do not change sidebar grouping, labels, counts, pairing flow, terminal UI, or clipboard permission UI.
- Do not change local Electron clipboard behavior or the existing one-shot RPC contract.
## Design
1. Add bounded chunked clipboard-image RPCs.
- Keep `clipboard.saveImageAsTempFile` for small/old clients.
- Add `clipboard.startImageUpload`, `clipboard.appendImageUploadChunk`, `clipboard.commitImageUpload`, and `clipboard.abortImageUpload`.
- `start` takes `expectedBase64Length` and `connectionId`, rejects values over the existing 24 MiB base64 limit, records the target connection, and returns an unguessable upload ID.
- `append` takes `uploadId`, `offset`, and `contentBase64`; reject unknown IDs, out-of-order offsets, chunks above 512 KiB, invalid base64 characters, and cumulative length beyond `expectedBase64Length`.
- `commit` verifies the received length equals `expectedBase64Length`, validates the full base64 payload with the same rules as the one-shot RPC, calls `saveClipboardImageBufferAsTempFile` with the recorded `connectionId`, and deletes the upload state in `finally`.
- `abort` deletes the upload state and is idempotent.
- Bound server memory with a small max concurrent upload count and a TTL cleanup for abandoned uploads. Upload state is process-local; reconnects restart the paste instead of resuming.
2. Switch paired browser image paste to the chunked path.
- After `navigator.clipboard.read()` and PNG conversion, return `null` for no image as today.
- Preflight `contentBase64.length` against the 24 MiB limit before starting an upload.
- Send 512 KiB base64 slices. This is below the 1 MiB encrypted frame cap after JSON and E2EE base64 expansion.
- Abort best-effort on append or commit failure. If `start` returns `method_not_found`, fall back to `clipboard.saveImageAsTempFile` only when the payload is below a conservative single-frame threshold; never send a large fallback frame.
3. Tighten workspace activity liveness.
- Treat terminal workspaces as active only when `tabHasLivePty(ptyIdsByTabId, tab.id)` is true.
- Remove the blanket `isWebTerminalSurfaceTabId` active shortcut from `getWorktreeStatus` and `hasActiveWorkspaceActivity`.
- Keep browser tabs active without terminals.
- Keep fresh/retained explicit agent rows able to promote status to `permission`, `working`, or `done`; this is separate from terminal liveness.
## Data Flow
- Paste image:
- Browser paste command -> `navigator.clipboard.read()` -> PNG base64 in web preload memory.
- `clipboard.startImageUpload({ expectedBase64Length, connectionId })` -> upload ID.
- Repeated `clipboard.appendImageUploadChunk({ uploadId, offset, contentBase64 })`.
- `clipboard.commitImageUpload({ uploadId })` -> runtime saves temp image locally or on the SSH target -> terminal receives the temp path.
- Workspace activity:
- Host `session.tabs.listAll` or subscription snapshot includes terminal surfaces.
- Web sync mirrors tabs but writes live PTY handles only for ready surfaces.
- Sidebar status/filter reads `ptyIdsByTabId` and browser tabs.
- Pending mirrored terminals with no PTY remain visible but do not count as active.
## Edge Cases
- Clipboard has no image or browser lacks `navigator.clipboard.read`: return `null`, no upload session.
- Clipboard read/permission/conversion fails: existing terminal paste error path reports the failure; the runtime socket should stay open.
- Non-PNG clipboard images may grow during PNG conversion; validate the post-conversion base64 length.
- Image exceeds 24 MiB base64: reject before upload and enforce again on the server.
- Chunk boundaries must preserve base64 validity; the 512 KiB chunk size is divisible by 4, and the final full payload validation catches padding errors.
- Append retry or concurrent append with the same upload ID: offset validation rejects duplicate, skipped, or out-of-order data.
- Multiple paired browser clients or windows paste at once: upload IDs isolate sessions and the concurrent-upload cap bounds memory.
- Append or commit fails: browser aborts best-effort; server TTL cleans abandoned state.
- SSH connection missing or drops during commit: commit fails, upload state is still deleted, and the paste path reports the error.
- Runtime restarts during upload: pending RPC fails; no resume is attempted.
- Pending host terminal surface later becomes ready: the next snapshot writes PTY handles and the workspace becomes active.
- Host closes a terminal or browser tab: the next snapshot removes the mirrored tab and stale PTY/browser handles before activity is recomputed.
## Test Plan
- Unit: `src/main/runtime/rpc/methods/clipboard.test.ts` for start/append/commit/abort, offset validation, invalid base64, size limit, TTL cleanup, commit cleanup on save failure, SSH `connectionId` forwarding, and concurrent upload isolation.
- Unit: `src/renderer/src/web/web-preload-api.test.ts` for chunk sequencing, 512 KiB max chunks, `method_not_found` small-payload fallback, no large fallback frame, and abort on append/commit failure.
- Unit: `src/renderer/src/lib/worktree-status.test.ts` and `src/renderer/src/lib/worktree-activity-state.test.ts` for pending mirrored terminal inactivity, ready mirrored terminal activity, browser-only activity, and explicit agent-row promotion.
- Focused run: `pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/clipboard.test.ts src/renderer/src/web/web-preload-api.test.ts src/renderer/src/lib/worktree-status.test.ts src/renderer/src/lib/worktree-activity-state.test.ts`.
- Type/lint: `pnpm typecheck`, `pnpm lint`.
- Paired-client validation: restart the host app, pair a real browser client, paste a screenshot large enough that the old one-shot RPC exceeded the 1 MiB encrypted frame cap into a local terminal and an SSH-backed terminal, confirm the runtime socket stays connected, then compare browser and host sidebar activity.
## UI Quality Bar
No layout changes. The paired browser sidebar should show active dots only for workspaces with live terminal PTYs, browser tabs, or explicit agent rows. Image paste should insert the generated temp image path into the terminal without connection-error toasts or DevTools runtime disconnect errors.
## Review Screenshots
1. Paired browser sidebar after hydration with inactive Done/Todo workspaces visible but not marked active.
2. Paired browser terminal after image paste, showing the generated temp image path inserted.
3. Host sidebar for the same session, showing matching active workspace state.
## Rollout
1. Add chunked clipboard RPC implementation and tests.
2. Switch web preload image paste to chunked upload and tests.
3. Tighten activity/status liveness helpers and tests.
4. Run focused tests, typecheck, and lint.
5. Validate in a paired Electron/browser session, including SSH paste, and capture the review screenshots.
## Lightweight Eng Review
- Scope: narrow to web clipboard transport and activity liveness. No global WebSocket limit, sidebar redesign, or local Electron clipboard changes.
- Architecture/data flow: web preload owns browser clipboard read and chunk sequencing; main runtime clipboard RPC owns upload session state and final temp-file save; sidebar helpers stay pure and consume the existing live-PTY map.
- Failure modes: oversized images must fail before a large frame is sent; abandoned uploads expire; failed append/commit paths clean up; pending host terminal mirrors do not inflate activity; SSH commit failures do not leak upload state.
- Tests: cover RPC lifecycle and bounds, web preload sequencing/fallback/abort, status/filter liveness, and real paired-client paste/sidebar parity.
- Performance/blast radius: chunking is not free. A max-size image is dozens of serialized runtime RPC calls and duplicates base64 in browser and main memory. The impact is limited to image paste by TTL, size, and concurrency caps. Activity changes affect sidebar filters, jump palette activity, and status dots.
- UI quality: no new chrome. Judge only sidebar parity, paste result, and absence of runtime disconnect/toast regressions.
- Screenshots: browser sidebar, browser terminal after paste, and matching host sidebar.
- Residual risk: clipboard permission and image conversion behavior are browser-dependent, so paired browser validation is required in addition to unit tests.

View File

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
@ -11,23 +11,38 @@ vi.mock('../../../window/clipboard-image-temp-file', () => ({
saveClipboardImageBufferAsTempFile
}))
import { CLIPBOARD_METHODS } from './clipboard'
import {
CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT,
CLIPBOARD_METHODS,
resetClipboardImageUploadsForTest
} from './clipboard'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
function makeDispatcher(): RpcDispatcher {
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
return new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS })
}
describe('clipboard RPC methods', () => {
beforeEach(() => {
saveClipboardImageBufferAsTempFile.mockReset()
resetClipboardImageUploadsForTest()
})
afterEach(() => {
vi.useRealTimers()
resetClipboardImageUploadsForTest()
})
it('saves browser-provided clipboard image bytes on the runtime host', async () => {
saveClipboardImageBufferAsTempFile.mockResolvedValue(
'C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-image.png'
)
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS })
const dispatcher = makeDispatcher()
const response = await dispatcher.dispatch(
makeRequest('clipboard.saveImageAsTempFile', {
@ -46,8 +61,7 @@ describe('clipboard RPC methods', () => {
})
it('rejects non-base64 clipboard image payloads', async () => {
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: CLIPBOARD_METHODS })
const dispatcher = makeDispatcher()
const response = await dispatcher.dispatch(
makeRequest('clipboard.saveImageAsTempFile', {
@ -58,4 +72,264 @@ describe('clipboard RPC methods', () => {
expect(response.ok).toBe(false)
expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled()
})
it('accepts chunked uploads and forwards the recorded connectionId on commit', async () => {
saveClipboardImageBufferAsTempFile.mockResolvedValue('/tmp/orca-paste-image.png')
const dispatcher = makeDispatcher()
const contentBase64 = Buffer.from('png-bytes').toString('base64')
const start = await dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: contentBase64.length,
connectionId: 'ssh-1'
})
)
expect(start.ok).toBe(true)
const uploadId = (start.ok ? start.result : null) as { uploadId: string }
const firstChunk = contentBase64.slice(0, 4)
const secondChunk = contentBase64.slice(4)
await expect(
dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 0,
contentBase64: firstChunk
})
)
).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: 4 } })
await expect(
dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: firstChunk.length,
contentBase64: secondChunk
})
)
).resolves.toMatchObject({ ok: true, result: { receivedBase64Length: contentBase64.length } })
await expect(
dispatcher.dispatch(
makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId })
)
).resolves.toMatchObject({ ok: true, result: '/tmp/orca-paste-image.png' })
expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledWith(Buffer.from('png-bytes'), {
connectionId: 'ssh-1'
})
})
it('rejects out-of-order chunk offsets', async () => {
const dispatcher = makeDispatcher()
const start = await dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: 8,
connectionId: null
})
)
const uploadId = (start.ok ? start.result : null) as { uploadId: string }
const response = await dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 4,
contentBase64: 'AAAA'
})
)
expect(response.ok).toBe(false)
expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled()
})
it('rejects invalid base64 chunks and oversized chunks', async () => {
const dispatcher = makeDispatcher()
const start = await dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4,
connectionId: null
})
)
const uploadId = (start.ok ? start.result : null) as { uploadId: string }
await expect(
dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 0,
contentBase64: 'not base64!'
})
)
).resolves.toMatchObject({ ok: false })
await expect(
dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 0,
contentBase64: 'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS + 4)
})
)
).resolves.toMatchObject({ ok: false })
})
it('rejects uploads beyond the existing total clipboard image limit', async () => {
const dispatcher = makeDispatcher()
const response = await dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: 24 * 1024 * 1024 + 1,
connectionId: null
})
)
expect(response.ok).toBe(false)
})
it('rejects commit until all expected bytes arrive', async () => {
const dispatcher = makeDispatcher()
const start = await dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: 8,
connectionId: null
})
)
const uploadId = (start.ok ? start.result : null) as { uploadId: string }
await dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 0,
contentBase64: 'AAAA'
})
)
const response = await dispatcher.dispatch(
makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId })
)
expect(response.ok).toBe(false)
expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled()
})
it('validates the complete base64 payload before saving', async () => {
const dispatcher = makeDispatcher()
const start = await dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: 8,
connectionId: null
})
)
const uploadId = (start.ok ? start.result : null) as { uploadId: string }
await dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 0,
contentBase64: 'AA=='
})
)
await dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 4,
contentBase64: 'AAAA'
})
)
const response = await dispatcher.dispatch(
makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId })
)
expect(response.ok).toBe(false)
expect(saveClipboardImageBufferAsTempFile).not.toHaveBeenCalled()
})
it('deletes upload state after abort and treats repeated aborts as success', async () => {
const dispatcher = makeDispatcher()
const start = await dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: 4,
connectionId: null
})
)
const uploadId = (start.ok ? start.result : null) as { uploadId: string }
await expect(
dispatcher.dispatch(
makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId })
)
).resolves.toMatchObject({ ok: true, result: { aborted: true } })
await expect(
dispatcher.dispatch(
makeRequest('clipboard.abortImageUpload', { uploadId: uploadId.uploadId })
)
).resolves.toMatchObject({ ok: true, result: { aborted: true } })
await expect(
dispatcher.dispatch(
makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId })
)
).resolves.toMatchObject({ ok: false })
})
it('deletes upload state when saving fails during commit', async () => {
saveClipboardImageBufferAsTempFile.mockRejectedValue(new Error('ssh write failed'))
const dispatcher = makeDispatcher()
const contentBase64 = Buffer.from('png-bytes').toString('base64')
const start = await dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: contentBase64.length,
connectionId: 'ssh-1'
})
)
const uploadId = (start.ok ? start.result : null) as { uploadId: string }
await dispatcher.dispatch(
makeRequest('clipboard.appendImageUploadChunk', {
uploadId: uploadId.uploadId,
offset: 0,
contentBase64
})
)
await expect(
dispatcher.dispatch(
makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId })
)
).resolves.toMatchObject({ ok: false })
await expect(
dispatcher.dispatch(
makeRequest('clipboard.commitImageUpload', { uploadId: uploadId.uploadId })
)
).resolves.toMatchObject({ ok: false })
expect(saveClipboardImageBufferAsTempFile).toHaveBeenCalledTimes(1)
})
it('bounds concurrent uploads and releases slots through TTL cleanup', async () => {
vi.useFakeTimers()
const dispatcher = makeDispatcher()
for (let index = 0; index < CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT; index++) {
await expect(
dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: 4,
connectionId: null
})
)
).resolves.toMatchObject({ ok: true })
}
await expect(
dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: 4,
connectionId: null
})
)
).resolves.toMatchObject({ ok: false })
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
await expect(
dispatcher.dispatch(
makeRequest('clipboard.startImageUpload', {
expectedBase64Length: 4,
connectionId: null
})
)
).resolves.toMatchObject({ ok: true })
})
})

View File

@ -1,8 +1,75 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { saveClipboardImageBufferAsTempFile } from '../../../window/clipboard-image-temp-file'
import { randomUUID } from 'node:crypto'
const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = 24 * 1024 * 1024
export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
export const CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT = 8
const CLIPBOARD_IMAGE_UPLOAD_TTL_MS = 5 * 60 * 1000
const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/
type ClipboardImageUpload = {
expectedBase64Length: number
connectionId?: string | null
chunks: string[]
receivedBase64Length: number
expiresAt: number
ttlTimer: ReturnType<typeof setTimeout>
}
const clipboardImageUploads = new Map<string, ClipboardImageUpload>()
function isValidBase64(value: string): boolean {
return value.length % 4 !== 1 && BASE64_PATTERN.test(value)
}
function pruneExpiredUploads(now = Date.now()): void {
for (const [uploadId, upload] of clipboardImageUploads) {
if (upload.expiresAt <= now) {
deleteUpload(uploadId)
}
}
}
function scheduleUploadExpiry(uploadId: string): ReturnType<typeof setTimeout> {
const timer = setTimeout(() => {
clipboardImageUploads.delete(uploadId)
}, CLIPBOARD_IMAGE_UPLOAD_TTL_MS)
if (typeof timer === 'object' && 'unref' in timer) {
timer.unref()
}
return timer
}
function refreshUploadExpiry(uploadId: string, upload: ClipboardImageUpload): void {
clearTimeout(upload.ttlTimer)
upload.expiresAt = Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS
upload.ttlTimer = scheduleUploadExpiry(uploadId)
}
function deleteUpload(uploadId: string): void {
const upload = clipboardImageUploads.get(uploadId)
if (upload) {
clearTimeout(upload.ttlTimer)
}
clipboardImageUploads.delete(uploadId)
}
function getUpload(uploadId: string): ClipboardImageUpload {
pruneExpiredUploads()
const upload = clipboardImageUploads.get(uploadId)
if (!upload) {
throw new Error('Clipboard image upload was not found')
}
return upload
}
function assertValidBase64Content(value: string): void {
if (!isValidBase64(value)) {
throw new Error('Clipboard image content must be base64')
}
}
const SaveImageAsTempFile = z.object({
contentBase64: z
@ -11,13 +78,40 @@ const SaveImageAsTempFile = z.object({
.refine((value) => value.length <= MAX_CLIPBOARD_IMAGE_BASE64_CHARS, {
message: 'Clipboard image is too large'
})
.refine(
(value) => value.length % 4 !== 1 && /^[A-Za-z0-9+/]*={0,2}$/.test(value),
'Clipboard image content must be base64'
),
.refine(isValidBase64, 'Clipboard image content must be base64'),
connectionId: z.string().min(1).nullable().optional()
})
const StartImageUpload = z.object({
expectedBase64Length: z
.number()
.int()
.nonnegative()
.max(MAX_CLIPBOARD_IMAGE_BASE64_CHARS, 'Clipboard image is too large'),
connectionId: z.string().min(1).nullable().optional()
})
const AppendImageUploadChunk = z.object({
uploadId: z.string().min(1),
offset: z.number().int().nonnegative(),
contentBase64: z
.unknown()
.refine((v): v is string => typeof v === 'string', { message: 'Missing image content' })
.refine(
(value) => value.length <= CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
'Clipboard image chunk is too large'
)
.refine(isValidBase64, 'Clipboard image content must be base64')
})
const CommitImageUpload = z.object({
uploadId: z.string().min(1)
})
const AbortImageUpload = z.object({
uploadId: z.string().min(1)
})
export const CLIPBOARD_METHODS: RpcMethod[] = [
defineMethod({
name: 'clipboard.saveImageAsTempFile',
@ -26,5 +120,78 @@ export const CLIPBOARD_METHODS: RpcMethod[] = [
saveClipboardImageBufferAsTempFile(Buffer.from(params.contentBase64, 'base64'), {
connectionId: params.connectionId
})
}),
defineMethod({
name: 'clipboard.startImageUpload',
params: StartImageUpload,
handler: (params) => {
pruneExpiredUploads()
if (clipboardImageUploads.size >= CLIPBOARD_IMAGE_UPLOAD_MAX_CONCURRENT) {
throw new Error('Too many clipboard image uploads are in progress')
}
const uploadId = randomUUID()
clipboardImageUploads.set(uploadId, {
expectedBase64Length: params.expectedBase64Length,
connectionId: params.connectionId,
chunks: [],
receivedBase64Length: 0,
expiresAt: Date.now() + CLIPBOARD_IMAGE_UPLOAD_TTL_MS,
ttlTimer: scheduleUploadExpiry(uploadId)
})
return { uploadId }
}
}),
defineMethod({
name: 'clipboard.appendImageUploadChunk',
params: AppendImageUploadChunk,
handler: (params) => {
const upload = getUpload(params.uploadId)
if (params.offset !== upload.receivedBase64Length) {
throw new Error('Clipboard image chunk offset is out of order')
}
const nextLength = upload.receivedBase64Length + params.contentBase64.length
if (nextLength > upload.expectedBase64Length) {
throw new Error('Clipboard image upload exceeded expected size')
}
upload.chunks.push(params.contentBase64)
upload.receivedBase64Length = nextLength
refreshUploadExpiry(params.uploadId, upload)
return { receivedBase64Length: upload.receivedBase64Length }
}
}),
defineMethod({
name: 'clipboard.commitImageUpload',
params: CommitImageUpload,
handler: async (params) => {
const upload = getUpload(params.uploadId)
try {
if (upload.receivedBase64Length !== upload.expectedBase64Length) {
throw new Error('Clipboard image upload is incomplete')
}
const contentBase64 = upload.chunks.join('')
assertValidBase64Content(contentBase64)
return await saveClipboardImageBufferAsTempFile(Buffer.from(contentBase64, 'base64'), {
connectionId: upload.connectionId
})
} finally {
// Why: failed SSH or filesystem commits must not leave bounded upload
// memory pinned until TTL cleanup.
deleteUpload(params.uploadId)
}
}
}),
defineMethod({
name: 'clipboard.abortImageUpload',
params: AbortImageUpload,
handler: (params) => {
deleteUpload(params.uploadId)
return { aborted: true }
}
})
]
export function resetClipboardImageUploadsForTest(): void {
for (const uploadId of clipboardImageUploads.keys()) {
deleteUpload(uploadId)
}
}

View File

@ -144,7 +144,7 @@ describe('computeVisibleWorktreeIds', () => {
expect(result).toEqual([])
})
it('treats paired web host terminal mirrors as active while their stream handle is pending', () => {
it('hides paired web host terminal mirrors while their stream handle is pending', () => {
const wt = makeWorktree('wt-web-pending')
const result = computeVisibleWorktreeIds(
@ -157,6 +157,22 @@ describe('computeVisibleWorktreeIds', () => {
})
)
expect(result).toEqual([])
})
it('keeps paired web host terminal mirrors visible after their stream handle is ready', () => {
const wt = makeWorktree('wt-web-ready')
const result = computeVisibleWorktreeIds(
{ repo1: [wt] },
[wt.id],
visibleOptions({
showSleepingWorkspaces: false,
tabsByWorktree: { [wt.id]: [makeTab('web-terminal-host-tab-1', wt.id, null)] },
ptyIdsByTabId: { 'web-terminal-host-tab-1': ['pty-web-ready'] }
})
)
expect(result).toEqual([wt.id])
})

View File

@ -35,4 +35,32 @@ describe('worktree activity state', () => {
)
).toBe(false)
})
it('treats pending paired web host terminal mirrors as inactive without a live pty', () => {
expect(
hasActiveWorkspaceActivity('wt-1', { 'wt-1': [makeTab('web-terminal-host-tab-1')] }, {}, {})
).toBe(false)
})
it('treats ready paired web host terminal mirrors as active with a live pty', () => {
expect(
hasActiveWorkspaceActivity(
'wt-1',
{ 'wt-1': [makeTab('web-terminal-host-tab-1')] },
{ 'web-terminal-host-tab-1': ['pty-1'] },
{}
)
).toBe(true)
})
it('keeps browser-only workspaces active when mirrored terminals are pending', () => {
expect(
hasActiveWorkspaceActivity(
'wt-1',
{ 'wt-1': [makeTab('web-terminal-host-tab-1')] },
{},
{ 'wt-1': [{ id: 'browser-1' }] }
)
).toBe(true)
})
})

View File

@ -1,5 +1,4 @@
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
import type { TerminalTab } from '../../../shared/types'
type TerminalLikeTab = Pick<TerminalTab, 'id'>
@ -18,9 +17,8 @@ export function hasActiveWorkspaceActivity(
const tabs = tabsByWorktree?.[worktreeId] ?? []
const hasLiveTerminal =
ptyIdsByTabId != null && tabs.some((tab) => tabHasLivePty(ptyIdsByTabId, tab.id))
const hasHostMirroredTerminal = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id))
const hasBrowser = (browserTabsByWorktree?.[worktreeId] ?? []).length > 0
return hasLiveTerminal || hasHostMirroredTerminal || hasBrowser
return hasLiveTerminal || hasBrowser
}
export function isInactiveWorkspace(

View File

@ -108,7 +108,7 @@ describe('resolveWorktreeStatus', () => {
expect(status).toBe('done')
})
it('treats paired web host terminal mirrors as active while their stream handle is pending', () => {
it('treats pending paired web host terminal mirrors as inactive without a live pty', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'web-terminal-host-tab-1', title: 'Terminal 1' }],
browserTabs: [],
@ -119,6 +119,34 @@ describe('resolveWorktreeStatus', () => {
hasRetainedDone: false
})
expect(status).toBe('inactive')
})
it('treats ready paired web host terminal mirrors as active once they have a live pty', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'web-terminal-host-tab-1', title: 'Terminal 1' }],
browserTabs: [],
ptyIdsByTabId: { 'web-terminal-host-tab-1': ['pty-1'] },
hasPermission: false,
hasLiveWorking: false,
hasLiveDone: false,
hasRetainedDone: false
})
expect(status).toBe('active')
})
it('keeps browser-only paired workspaces active without terminal liveness', () => {
const status = resolveWorktreeStatus({
tabs: [{ id: 'web-terminal-host-tab-1', title: 'Terminal 1' }],
browserTabs: [{ id: 'browser-1' }],
ptyIdsByTabId: {},
hasPermission: false,
hasLiveWorking: false,
hasLiveDone: false,
hasRetainedDone: false
})
expect(status).toBe('active')
})

View File

@ -1,6 +1,5 @@
import { detectAgentStatusFromTitle } from '@/lib/agent-status'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
import type { TerminalTab } from '../../../shared/types'
export type WorktreeStatus = 'active' | 'working' | 'permission' | 'done' | 'inactive'
@ -51,12 +50,6 @@ export function getWorktreeStatus(
// that rule instead of showing a misleading inactive dot.
return 'active'
}
if (tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id))) {
// Why: paired web mirrors can know a host terminal tab exists before the
// runtime has issued a stream handle. Match desktop sidebar parity without
// treating ordinary slept wake-hint tabs as active.
return 'active'
}
return 'inactive'
}

View File

@ -94,6 +94,36 @@ function writeStoredRuntimeEnvironment(storage: Storage): void {
)
}
function installClipboardImageBase64(contentBase64: string): void {
vi.stubGlobal(
'FileReader',
class {
result: string | ArrayBuffer | null = null
error: DOMException | null = null
onload: (() => void) | null = null
onerror: (() => void) | null = null
readAsDataURL(blob: Blob): void {
this.result = `data:${blob.type};base64,${contentBase64}`
this.onload?.()
}
}
)
vi.stubGlobal('navigator', {
userAgent: 'Linux',
hardwareConcurrency: 8,
clipboard: {
readText: vi.fn().mockResolvedValue(''),
read: vi.fn().mockResolvedValue([
{
types: ['image/png'],
getType: vi.fn().mockResolvedValue(new Blob(['ignored'], { type: 'image/png' }))
}
])
}
})
}
describe('web keybindings preload API', () => {
beforeEach(() => {
vi.resetModules()
@ -181,12 +211,28 @@ describe('web UI preload API', () => {
vi.doUnmock('./web-runtime-client')
})
it('saves browser clipboard images through the paired host runtime', async () => {
it('saves browser clipboard images through bounded upload chunks', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
if (method === 'clipboard.startImageUpload') {
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { uploadId: 'upload-1' },
_meta: { runtimeId: 'runtime-1' }
})
}
if (method === 'clipboard.appendImageUploadChunk') {
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { receivedBase64Length: runtimeCalls.length },
_meta: { runtimeId: 'runtime-1' }
})
}
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
@ -198,51 +244,92 @@ describe('web UI preload API', () => {
close(): void {}
}
}))
vi.stubGlobal(
'FileReader',
class {
result: string | ArrayBuffer | null = null
error: DOMException | null = null
onload: (() => void) | null = null
onerror: (() => void) | null = null
readAsDataURL(blob: Blob): void {
void blob
.arrayBuffer()
.then((buffer) => {
this.result = `data:${blob.type};base64,${Buffer.from(buffer).toString('base64')}`
this.onload?.()
})
.catch((error: DOMException) => {
this.error = error
this.onerror?.()
})
}
}
)
const globals = installBrowserGlobals('Linux')
vi.stubGlobal('navigator', {
userAgent: 'Linux',
hardwareConcurrency: 8,
clipboard: {
readText: vi.fn().mockResolvedValue(''),
read: vi.fn().mockResolvedValue([
{
types: ['image/png'],
getType: vi.fn().mockResolvedValue(new Blob(['png-bytes'], { type: 'image/png' }))
}
])
}
})
writeStoredRuntimeEnvironment(globals.storage)
const { CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS, installWebPreloadApi } =
await import('./web-preload-api')
const contentBase64 = `${'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS)}AAAA`
installClipboardImageBase64(contentBase64)
installWebPreloadApi()
await expect(
globals.window.api.ui.saveClipboardImageAsTempFile({ connectionId: 'ssh-1' })
).resolves.toBe('C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-image.png')
expect(runtimeCalls).toEqual([
{
method: 'clipboard.startImageUpload',
params: {
expectedBase64Length: contentBase64.length,
connectionId: 'ssh-1'
}
},
{
method: 'clipboard.appendImageUploadChunk',
params: {
uploadId: 'upload-1',
offset: 0,
contentBase64: 'A'.repeat(CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS)
}
},
{
method: 'clipboard.appendImageUploadChunk',
params: {
uploadId: 'upload-1',
offset: CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS,
contentBase64: 'AAAA'
}
},
{
method: 'clipboard.commitImageUpload',
params: { uploadId: 'upload-1' }
}
])
})
it('falls back to one-shot clipboard save for small payloads when the host lacks upload RPCs', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
if (method === 'clipboard.startImageUpload') {
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
})
}
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: '/tmp/orca-paste-image.png',
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
installClipboardImageBase64(Buffer.from('png-bytes').toString('base64'))
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(
globals.window.api.ui.saveClipboardImageAsTempFile({ connectionId: null })
).resolves.toBe('C:\\Users\\alice\\AppData\\Local\\Temp\\orca-paste-image.png')
).resolves.toBe('/tmp/orca-paste-image.png')
expect(runtimeCalls).toEqual([
{
method: 'clipboard.startImageUpload',
params: {
expectedBase64Length: Buffer.from('png-bytes').toString('base64').length,
connectionId: null
}
},
{
method: 'clipboard.saveImageAsTempFile',
params: {
@ -253,6 +340,167 @@ describe('web UI preload API', () => {
])
})
it('does not send large one-shot fallback frames when upload RPCs are missing', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS, installWebPreloadApi } =
await import('./web-preload-api')
installClipboardImageBase64('A'.repeat(CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS + 4))
installWebPreloadApi()
await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow(
'Unknown method'
)
expect(runtimeCalls).toHaveLength(1)
expect(runtimeCalls[0]?.method).toBe('clipboard.startImageUpload')
})
it('aborts best-effort when append fails', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
if (method === 'clipboard.startImageUpload') {
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { uploadId: 'upload-1' },
_meta: { runtimeId: 'runtime-1' }
})
}
if (method === 'clipboard.appendImageUploadChunk') {
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: false,
error: { code: 'runtime_error', message: 'bad chunk' },
_meta: { runtimeId: 'runtime-1' }
})
}
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { aborted: true },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
installClipboardImageBase64('AAAA')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow('bad chunk')
expect(runtimeCalls.map((call) => call.method)).toEqual([
'clipboard.startImageUpload',
'clipboard.appendImageUploadChunk',
'clipboard.abortImageUpload'
])
})
it('aborts best-effort when commit fails', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
if (method === 'clipboard.startImageUpload') {
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { uploadId: 'upload-1' },
_meta: { runtimeId: 'runtime-1' }
})
}
if (method === 'clipboard.commitImageUpload') {
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: false,
error: { code: 'runtime_error', message: 'save failed' },
_meta: { runtimeId: 'runtime-1' }
})
}
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: { aborted: true },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
installClipboardImageBase64('AAAA')
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow(
'save failed'
)
expect(runtimeCalls.map((call) => call.method)).toEqual([
'clipboard.startImageUpload',
'clipboard.appendImageUploadChunk',
'clipboard.commitImageUpload',
'clipboard.abortImageUpload'
])
})
it('rejects oversized converted clipboard images before starting an upload', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: null,
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
installClipboardImageBase64('A'.repeat(24 * 1024 * 1024 + 4))
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(globals.window.api.ui.saveClipboardImageAsTempFile()).rejects.toThrow(
'Clipboard image is too large'
)
expect(runtimeCalls).toEqual([])
})
it('migrates missing right sidebar visibility from the effective web legacy default', async () => {
const { api } = await installApi('Linux')

View File

@ -70,6 +70,9 @@ const KEYBINDINGS_STORAGE_KEY = 'orca.web.keybindings.v1'
// Why: browser-paired clients need desktop parity for large dev sessions; the
// runtime's no-limit default remains capped for lower-level RPC callers.
const WEB_RUNTIME_WORKTREE_LIST_LIMIT = 10_000
const MAX_CLIPBOARD_IMAGE_BASE64_CHARS = 24 * 1024 * 1024
export const CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024
export const CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024
const CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS = 30_000
let activeEnvironment: StoredWebRuntimeEnvironment | null = readStoredWebRuntimeEnvironment()
@ -1618,14 +1621,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['ui']> {
if (!contentBase64) {
return null
}
return callRuntimeResult<string>(
'clipboard.saveImageAsTempFile',
{
contentBase64,
connectionId: args?.connectionId ?? null
},
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
)
return saveClipboardImageAsTempFileInRuntime(contentBase64, args)
},
writeClipboardText: (text) => navigator.clipboard?.writeText?.(text) ?? Promise.resolve(),
writeSelectionClipboardText: () =>
@ -2058,6 +2054,70 @@ async function callRuntimeResult<TResult>(
return response.result as TResult
}
async function saveClipboardImageAsTempFileInRuntime(
contentBase64: string,
args?: { connectionId?: string | null }
): Promise<string> {
if (contentBase64.length > MAX_CLIPBOARD_IMAGE_BASE64_CHARS) {
throw new Error('Clipboard image is too large')
}
const connectionId = args?.connectionId ?? null
const startResponse = await callRuntimeEnvelope<{ uploadId: string }>(
'clipboard.startImageUpload',
{ expectedBase64Length: contentBase64.length, connectionId },
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
)
if (!startResponse.ok) {
if (
startResponse.error.code === 'method_not_found' &&
contentBase64.length <= CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS
) {
return callRuntimeResult<string>(
'clipboard.saveImageAsTempFile',
{ contentBase64, connectionId },
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
)
}
throw new Error(startResponse.error.message)
}
const { uploadId } = startResponse.result
try {
for (
let offset = 0;
offset < contentBase64.length;
offset += CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS
) {
await callRuntimeResult(
'clipboard.appendImageUploadChunk',
{
uploadId,
offset,
contentBase64: contentBase64.slice(
offset,
offset + CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS
)
},
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
)
}
return await callRuntimeResult<string>(
'clipboard.commitImageUpload',
{ uploadId },
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
)
} catch (error) {
// Why: once chunked paste has created server-side state, failed append or
// commit must not wait for TTL cleanup before releasing the bounded slot.
await callRuntimeResult(
'clipboard.abortImageUpload',
{ uploadId },
CLIPBOARD_IMAGE_SAVE_TIMEOUT_MS
).catch(() => {})
throw error
}
}
async function getRemoteRuntimeStatus(): Promise<RuntimeStatus> {
return callRuntimeResult<RuntimeStatus>('status.get', undefined, 15_000)
}