Skip to content
Merged
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
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gbasin/agentboard",
"version": "0.4.2",
"version": "0.4.3",
"type": "module",
"description": "Web GUI for tmux optimized for AI agent TUIs",
"author": "gbasin",
Expand All @@ -20,10 +20,10 @@
"bun": ">=1.3.14"
},
"optionalDependencies": {
"@gbasin/agentboard-darwin-arm64": "0.4.2",
"@gbasin/agentboard-darwin-x64": "0.4.2",
"@gbasin/agentboard-linux-x64": "0.4.2",
"@gbasin/agentboard-linux-arm64": "0.4.2"
"@gbasin/agentboard-darwin-arm64": "0.4.3",
"@gbasin/agentboard-darwin-x64": "0.4.3",
"@gbasin/agentboard-linux-x64": "0.4.3",
"@gbasin/agentboard-linux-arm64": "0.4.3"
},
"scripts": {
"dev": "concurrently -k \"bun run dev:server\" \"bun run dev:client\"",
Expand Down
46 changes: 46 additions & 0 deletions src/client/__tests__/paste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test'
import { bracketedPaste, imagePathInput, sanitizeImagePath } from '../utils/paste'

describe('bracketedPaste', () => {
test('wraps text in bracketed-paste markers', () => {
expect(bracketedPaste('/tmp/x.png')).toBe('\x1b[200~/tmp/x.png\x1b[201~')
})

test('handles empty text', () => {
expect(bracketedPaste('')).toBe('\x1b[200~\x1b[201~')
})
})

describe('imagePathInput', () => {
test('brackets the path for Claude so it attaches the image', () => {
expect(imagePathInput('/tmp/x.png', 'claude')).toBe('\x1b[200~/tmp/x.png\x1b[201~')
})

test('brackets the path for an unknown agent', () => {
expect(imagePathInput('/tmp/x.png', undefined)).toBe('\x1b[200~/tmp/x.png\x1b[201~')
})

test('sends the raw path for Codex (native clipboard paste)', () => {
expect(imagePathInput('/tmp/x.png', 'codex')).toBe('/tmp/x.png')
})

test('strips control characters before wrapping so a crafted path cannot break out', () => {
// A filename embedding the bracketed-paste end marker + escape sequence.
const malicious = '/tmp/a\x1b[201~\x1b[31mevil.png'
expect(imagePathInput(malicious, 'claude')).toBe('\x1b[200~/tmp/a[201~[31mevil.png\x1b[201~')
})

test('strips control characters for Codex raw paths too', () => {
expect(imagePathInput('/tmp/a\x07b.png', 'codex')).toBe('/tmp/ab.png')
})
})

describe('sanitizeImagePath', () => {
test('removes C0 control characters and DEL', () => {
expect(sanitizeImagePath('/tmp/\x00\x1b\x07\x7fok.png')).toBe('/tmp/ok.png')
})

test('leaves ordinary paths (incl. spaces) untouched', () => {
expect(sanitizeImagePath('/Users/me/My Screenshot.png')).toBe('/Users/me/My Screenshot.png')
})
})
41 changes: 40 additions & 1 deletion src/client/__tests__/terminalControls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe('TerminalControls', () => {
expect(sent).toEqual(['manual'])
})

test('paste button uploads clipboard image and sends the stored path', async () => {
test('paste button uploads clipboard image and sends a bracketed path for Claude', async () => {
const sent: string[] = []
const requests: Array<{ url: string; init?: RequestInit }> = []

Expand All @@ -223,6 +223,7 @@ describe('TerminalControls', () => {
onSendKey={(key) => sent.push(key)}
sessions={[{ id: 'session-1', name: 'alpha', status: 'working' }]}
currentSessionId="session-1"
agentType="claude"
onSelectSession={() => {}}
/>
)
Expand All @@ -238,6 +239,44 @@ describe('TerminalControls', () => {

expect(requests).toHaveLength(1)
expect(requests[0]?.url).toBe('/api/paste-image')
// Path wrapped in bracketed-paste markers so Claude attaches it ([Image #N]).
expect(sent).toEqual(['\x1b[200~/tmp/paste-test.png\x1b[201~'])
})

test('paste button sends the raw path for Codex (unchanged native behavior)', async () => {
const sent: string[] = []

globalAny.navigator = {
vibrate: () => true,
clipboard: clipboardWithImage(),
} as unknown as Navigator

globalAny.fetch = (async () =>
new Response(JSON.stringify({ path: '/tmp/paste-test.png' }), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as unknown as typeof fetch

const renderer = TestRenderer.create(
<TerminalControls
onSendKey={(key) => sent.push(key)}
sessions={[{ id: 'session-1', name: 'alpha', status: 'working' }]}
currentSessionId="session-1"
agentType="codex"
onSelectSession={() => {}}
/>
)

const pasteButton = findPasteButton(renderer)
if (!pasteButton) {
throw new Error('Expected paste button')
}

await act(async () => {
await pasteButton.props.onClick()
})

// Codex attaches via its own clipboard path, so the raw path is sent as-is.
expect(sent).toEqual(['/tmp/paste-test.png'])
})

Expand Down
Loading
Loading