Skip to content

Commit 7712e09

Browse files
committed
fix(desktop): paste from main, and stop a reclaimed run dir printing into tmux
Fixes the two behavioural regressions rather than shipping them documented. The context-menu paste could be silently dropped. It read the clipboard with `await navigator.clipboard.readText()` and then wrote the text, so the write landed after an await — and if that read outran the input-recency window (a permission prompt, a slow read) the terminal-write gate refused it with no error and no log, on an action the user had just asked for. Reading in main removes the window entirely, and is the direction Electron itself took: the `clipboard` module was removed from renderers under RFC 0019 so page content cannot reach the clipboard, and the documented pattern is to use it in the main process behind a narrow contextBridge method. So `terminal:paste` is a gated invoke channel that reads the clipboard itself. It needs a real gesture (the Paste click), but not the write gate — the bytes are the user's clipboard rather than the caller's, so a compromised renderer can only replay what was already copied instead of choosing it. `paste` is optional on the bridge and the renderer falls back to the old path, so a shell that predates it is unaffected. The tmux status write is silenced. Closing a terminal tab reclaims the run's temp dir while the command keeps going in tmux. `tee` is unaffected — POSIX lets it write on to the unlinked inode, and the space is reclaimed when it exits — but the command's trailing `printf > .../status` then failed into the pipeline and printed `No such file or directory` into the user's own tmux window, minutes after they closed the tab. `2>/dev/null` on that one redirect keeps the reclaim and drops the noise.
1 parent 708dbb3 commit 7712e09

8 files changed

Lines changed: 97 additions & 9 deletions

File tree

apps/desktop/src/main/ipc.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ vi.mock('@/main/browser-agent/registry', () => ({
5959
}))
6060

6161
import type { WebContents } from 'electron'
62-
import { ipcMain, shell } from 'electron'
62+
import { clipboard, ipcMain, shell } from 'electron'
6363
import {
6464
copyCredential,
6565
credentialsAvailable,
@@ -833,6 +833,29 @@ describe('registerIpcHandlers', () => {
833833
expect(write).toHaveBeenCalledTimes(replies.length)
834834
})
835835

836+
it('pastes the clipboard from main rather than taking bytes from the caller', async () => {
837+
const { invoke } = collectHandlers()
838+
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
839+
vi.mocked(clipboard.readText).mockReturnValue('echo hi')
840+
841+
await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1')).resolves.toBe(true)
842+
843+
expect(write).toHaveBeenCalledWith('t1', 'echo hi')
844+
})
845+
846+
it('refuses a paste with no gesture behind it, and reports an empty clipboard', async () => {
847+
const { invoke } = collectHandlers()
848+
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})
849+
vi.mocked(clipboard.readText).mockReturnValue('echo hi')
850+
851+
expect(await invoke.get('terminal:paste')?.(inactiveAppEvent, 't1')).toBe(false)
852+
expect(write).not.toHaveBeenCalled()
853+
854+
vi.mocked(clipboard.readText).mockReturnValue('')
855+
expect(await invoke.get('terminal:paste')?.(activeAppEvent, 't1')).toBe(false)
856+
expect(write).not.toHaveBeenCalled()
857+
})
858+
836859
it('gates a command smuggled inside a fake OSC or DCS reply', () => {
837860
const { on } = collectHandlers()
838861
const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {})

apps/desktop/src/main/ipc.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '@sim/terminal-protocol'
1818
import { isRecordLike } from '@sim/utils/object'
1919
import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron'
20-
import { ipcMain } from 'electron'
20+
import { clipboard, ipcMain } from 'electron'
2121
import {
2222
clearBrowsingData,
2323
executeTool,
@@ -921,6 +921,24 @@ export function registerIpcHandlers(deps: IpcDeps): void {
921921
handler: (sender, focused) =>
922922
deps.terminal.setPanelFocused(focused === true, sender as WebContents),
923923
},
924+
'terminal:paste': {
925+
kind: 'invoke',
926+
gate: 'app-origin',
927+
requires: 'terminal',
928+
denied: false,
929+
// The bytes come from the clipboard here, not from the caller, so this
930+
// does not need the write gate: a compromised renderer can only replay
931+
// what the user already copied. It still needs a real gesture, because
932+
// the legitimate caller is a Paste click or ⌘V.
933+
needsUserActivation: true,
934+
handler: (terminalId) => {
935+
if (typeof terminalId !== 'string') return false
936+
const text = clipboard.readText()
937+
if (!text) return false
938+
deps.terminal.write(terminalId, text)
939+
return true
940+
},
941+
},
924942
'terminal:scrollback': {
925943
kind: 'invoke',
926944
gate: 'app-origin',

apps/desktop/src/main/terminal/tmux.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,13 @@ export async function startRun(
337337
// PIPESTATUS keeps the command's own exit code rather than tee's, which is
338338
// always 0. bash rather than the user's shell because PIPESTATUS is not
339339
// portable and this wrapper is ours, not something they have to read.
340-
const script = `${command}\nprintf %s "\${PIPESTATUS[0]}" > ${JSON.stringify(statusPath)}`
340+
// The status write is silenced because its directory may be gone by the time
341+
// it runs: closing the terminal tab reclaims the run's temp dir while the
342+
// command keeps going in tmux. `tee` is unaffected — POSIX lets it keep
343+
// writing to the unlinked inode — but an unredirected `printf` would fail
344+
// into the pipeline and print `No such file or directory` into the user's own
345+
// tmux window, minutes after they closed the tab.
346+
const script = `${command}\nprintf %s "\${PIPESTATUS[0]}" > ${JSON.stringify(statusPath)} 2>/dev/null`
341347
const wrapper = `bash -lc ${JSON.stringify(`{ ${script}; } 2>&1 | tee ${JSON.stringify(outPath)}`)}`
342348

343349
const args = ['new-window', '-d', '-P', '-F', '#{window_id}', '-t', session, '-n', 'sim-run']

apps/desktop/src/preload/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,8 @@ const api: SimDesktopApi = {
303303
write: (terminalId: string, data: string): void => {
304304
ipcRenderer.send('terminal:write', terminalId, data)
305305
},
306+
paste: (terminalId: string): Promise<boolean> =>
307+
ipcRenderer.invoke('terminal:paste', terminalId),
306308
resize: (terminalId: string, cols: number, rows: number): void => {
307309
ipcRenderer.send('terminal:resize', terminalId, cols, rows)
308310
},

apps/desktop/src/test/electron-mock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export const safeStorage = {
5353

5454
export const clipboard = {
5555
writeText: vi.fn(),
56+
readText: vi.fn(() => ''),
5657
}
5758

5859
export const nativeTheme = {

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
getTerminalScrollback,
3030
onTerminalData,
3131
openTerminal,
32+
pasteIntoTerminal,
3233
reportTerminalFocused,
3334
resizeTerminal,
3435
startTerminalSession,
@@ -508,20 +509,27 @@ const TerminalView = memo(function TerminalView({
508509
}, [])
509510

510511
const pasteClipboard = useCallback(() => {
511-
void navigator.clipboard
512-
.readText()
513-
.then((text) => {
512+
void (async () => {
513+
// Main-side first: it reads the clipboard synchronously, so the paste
514+
// cannot be refused for want of a recent gesture the way an awaited
515+
// renderer read can.
516+
if (await pasteIntoTerminal(terminalId)) {
517+
terminalRef.current?.focus()
518+
return
519+
}
520+
try {
521+
const text = await navigator.clipboard.readText()
514522
if (!text) return
515523
// Straight to the PTY: the shell echoes it, exactly like a real paste.
516524
writeToTerminal(terminalId, text)
517525
terminalRef.current?.focus()
518-
})
519-
.catch(() => {
526+
} catch {
520527
// Reading the clipboard needs a permission the shell grants to its own
521528
// origin; an older shell that predates that grant denies it. Keyboard
522529
// paste is a native paste event and keeps working either way.
523530
toast.error('Could not read the clipboard. Press ⌘V to paste.')
524-
})
531+
}
532+
})()
525533
}, [terminalId])
526534

527535
const clearScreen = useCallback(() => {

apps/sim/lib/terminal/transport.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,23 @@ export function writeToTerminal(terminalId: string, data: string): void {
125125
bridge()?.write(terminalId, data)
126126
}
127127

128+
/**
129+
* Pastes the system clipboard into a terminal, reading it in the main process
130+
* when the shell can.
131+
*
132+
* Returns false when this shell predates `paste`, so the caller can fall back to
133+
* reading the clipboard itself. Main-side is preferred for two reasons: the read
134+
* is synchronous there, so there is no window in which the paste can be refused
135+
* for want of a recent gesture, and the renderer never touches the clipboard —
136+
* which is the direction Electron itself took when it removed the `clipboard`
137+
* module from renderers.
138+
*/
139+
export async function pasteIntoTerminal(terminalId: string): Promise<boolean> {
140+
const paste = bridge()?.paste
141+
if (!paste) return false
142+
return paste(terminalId)
143+
}
144+
128145
export function resizeTerminal(terminalId: string, cols: number, rows: number): void {
129146
bridge()?.resize(terminalId, cols, rows)
130147
}

packages/desktop-bridge/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ export interface SimDesktopTerminalApi {
5252
): Promise<TerminalToolResponse>
5353
/** Forward the user's keystrokes to one terminal's PTY. */
5454
write(terminalId: string, data: string): void
55+
/**
56+
* Paste the system clipboard into one terminal's PTY.
57+
*
58+
* The text is read in the main process rather than handed over by the caller:
59+
* Electron removed the `clipboard` module from renderers precisely so page
60+
* content cannot reach the clipboard, and it means a compromised renderer can
61+
* only replay what the user already copied instead of choosing the bytes.
62+
* Resolves false when the clipboard held nothing to paste.
63+
*
64+
* Optional: shells that predate it fall back to reading the clipboard in the
65+
* renderer.
66+
*/
67+
paste?(terminalId: string): Promise<boolean>
5568
resize(terminalId: string, cols: number, rows: number): void
5669
/** Open an additional terminal and make it active. */
5770
openTerminal(cwd?: string): Promise<TerminalTabsState>

0 commit comments

Comments
 (0)