Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/desktop-quote-open-launch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@e2b/desktop': patch
'@e2b/desktop-python': patch
---

`open()` and `launch()` now shell-quote the file, URL, application and URI they pass to `xdg-open` / `gtk-launch`. Before, a URL with `&` in its query string was cut at the `&` (the rest ran as a separate shell command), and a path with spaces was split into several arguments.
23 changes: 13 additions & 10 deletions packages/desktop-js/src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,9 +471,12 @@ export class Sandbox extends SandboxBase {
* @param fileOrUrl - The file or URL to open.
*/
async open(fileOrUrl: string): Promise<void> {
const handle = await this.commands.run(`xdg-open ${fileOrUrl}`, {
background: true,
})
const handle = await this.commands.run(
`xdg-open ${this.quoteString(fileOrUrl)}`,
{
background: true,
}
)
await handle.disconnect()
}

Expand Down Expand Up @@ -516,13 +519,13 @@ export class Sandbox extends SandboxBase {
* @param uri - The URI to open in the application.
*/
async launch(application: string, uri?: string): Promise<void> {
const handle = await this.commands.run(
`gtk-launch ${application} ${uri ?? ''}`,
{
background: true,
timeoutMs: 0,
}
)
const args = uri
? `${this.quoteString(application)} ${this.quoteString(uri)}`
: this.quoteString(application)
const handle = await this.commands.run(`gtk-launch ${args}`, {
background: true,
timeoutMs: 0,
})
await handle.disconnect()
}

Expand Down
78 changes: 78 additions & 0 deletions packages/desktop-js/tests/openLaunch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { execFileSync } from 'node:child_process'
import { expect, test } from 'vitest'

import { Sandbox } from '../src'

// Commands run in the sandbox through `/bin/bash -l -c <cmd>`. Run the command
// the SDK builds through bash with `xdg-open` / `gtk-launch` replaced by a
// function that prints each argument it receives, one per line.
function argvSeenBy(program: string, command: string): string[] {
const stub = `${program}() { printf '%s\\n' "$@"; }; `
return execFileSync('bash', ['-c', stub + command], { encoding: 'utf8' })

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep Bash subprocess tests out of the Cloudflare suite

The existing Cloudflare matrix leg in .github/workflows/desktop_js_tests.yml:82-91 runs the desktop suite inside workerd, and vitest.cloudflare.config.mts:75-76 includes this new file—the desktop Cloudflare config excludes only client.test.ts. Workerd cannot spawn Bash through node:child_process, and the Windows-only skip does not exclude that runtime, so these tests make the Cloudflare CI leg fail independently of the quoting behavior. Add this file to the desktop Cloudflare config's exclusions, or use Worker-compatible assertions.

Useful? React with 👍 / 👎.

.split('\n')
.filter((line) => line.length > 0)
}

function recordingSandbox(): { sandbox: Sandbox; commands: string[] } {
const commands: string[] = []
const sandbox = Object.create(Sandbox.prototype) as Sandbox
Object.defineProperty(sandbox, 'commands', {
value: {
run: async (cmd: string) => {
commands.push(cmd)
return { disconnect: async () => {} }
},
},
})
return { sandbox, commands }
}

const skipOnWindows = test.skipIf(process.platform === 'win32')

skipOnWindows(
'open passes a URL with a query string as one argument',
async () => {
const { sandbox, commands } = recordingSandbox()
const url = 'https://example.com/search?q=e2b&page=2#results'

await sandbox.open(url)

expect(commands).toHaveLength(1)
expect(argvSeenBy('xdg-open', commands[0])).toEqual([url])
}
)

skipOnWindows(
'open passes a file path with spaces and quotes as one argument',
async () => {
const { sandbox, commands } = recordingSandbox()
const path = "/home/user/Q3 report (Bob's copy).pdf"

await sandbox.open(path)

expect(argvSeenBy('xdg-open', commands[0])).toEqual([path])
}
)

skipOnWindows(
'launch passes the application and the URI as separate arguments',
async () => {
const { sandbox, commands } = recordingSandbox()
const uri = 'https://example.com/?a=1&b=2'

await sandbox.launch('google-chrome', uri)

expect(argvSeenBy('gtk-launch', commands[0])).toEqual([
'google-chrome',
uri,
])
}
)

skipOnWindows('launch without a URI passes only the application', async () => {
const { sandbox, commands } = recordingSandbox()

await sandbox.launch('google-chrome')

expect(argvSeenBy('gtk-launch', commands[0])).toEqual(['google-chrome'])
})
11 changes: 7 additions & 4 deletions packages/desktop-python/e2b_desktop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,9 @@ def open(self, file_or_url: str):

:param file_or_url: The file or URL to open.
"""
handle = self.commands.run(f"xdg-open {file_or_url}", background=True)
handle = self.commands.run(
f"xdg-open {quote_string(file_or_url)}", background=True
)
handle.disconnect()

def get_current_window_id(self) -> str:
Expand Down Expand Up @@ -579,7 +581,8 @@ def launch(self, application: str, uri: Optional[str] = None):
"""
Launch an application.
"""
handle = self.commands.run(
f"gtk-launch {application} {uri or ''}", background=True, timeout=0
)
args = quote_string(application)
if uri:
args += f" {quote_string(uri)}"
handle = self.commands.run(f"gtk-launch {args}", background=True, timeout=0)
handle.disconnect()
80 changes: 80 additions & 0 deletions packages/desktop-python/tests/test_open_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import subprocess
import sys

import pytest

from e2b_desktop import Sandbox

# Commands run in the sandbox through `/bin/bash -l -c <cmd>`. Run the command
# the SDK builds through bash with `xdg-open` / `gtk-launch` replaced by a
# function that prints each argument it receives, one per line.
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="needs bash")


def _argv_seen_by(program: str, command: str) -> list[str]:
stub = f"{program}() {{ printf '%s\\n' \"$@\"; }}; "
out = subprocess.run(
["bash", "-c", stub + command], capture_output=True, text=True, check=True
).stdout
return [line for line in out.split("\n") if line]


class _Handle:
def disconnect(self):
pass


class _RecordingCommands:
def __init__(self):
self.commands: list[str] = []

def run(self, cmd, **kwargs):
self.commands.append(cmd)
return _Handle()


def _recording_sandbox() -> tuple[Sandbox, _RecordingCommands]:
class _FakeSandbox(Sandbox):
@property
def commands(self):
return self._fake_commands

sandbox = _FakeSandbox.__new__(_FakeSandbox)
sandbox._fake_commands = _RecordingCommands()
return sandbox, sandbox._fake_commands


def test_open_passes_a_url_with_a_query_string_as_one_argument():
sandbox, commands = _recording_sandbox()
url = "https://example.com/search?q=e2b&page=2#results"

sandbox.open(url)

assert len(commands.commands) == 1
assert _argv_seen_by("xdg-open", commands.commands[0]) == [url]


def test_open_passes_a_file_path_with_spaces_and_quotes_as_one_argument():
sandbox, commands = _recording_sandbox()
path = "/home/user/Q3 report (Bob's copy).pdf"

sandbox.open(path)

assert _argv_seen_by("xdg-open", commands.commands[0]) == [path]


def test_launch_passes_the_application_and_the_uri_as_separate_arguments():
sandbox, commands = _recording_sandbox()
uri = "https://example.com/?a=1&b=2"

sandbox.launch("google-chrome", uri)

assert _argv_seen_by("gtk-launch", commands.commands[0]) == ["google-chrome", uri]


def test_launch_without_a_uri_passes_only_the_application():
sandbox, commands = _recording_sandbox()

sandbox.launch("google-chrome")

assert _argv_seen_by("gtk-launch", commands.commands[0]) == ["google-chrome"]