diff --git a/.changeset/desktop-quote-open-launch.md b/.changeset/desktop-quote-open-launch.md new file mode 100644 index 0000000000..58292a1428 --- /dev/null +++ b/.changeset/desktop-quote-open-launch.md @@ -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. diff --git a/packages/desktop-js/src/sandbox.ts b/packages/desktop-js/src/sandbox.ts index 4ee6fbea24..d4cead156e 100644 --- a/packages/desktop-js/src/sandbox.ts +++ b/packages/desktop-js/src/sandbox.ts @@ -471,9 +471,12 @@ export class Sandbox extends SandboxBase { * @param fileOrUrl - The file or URL to open. */ async open(fileOrUrl: string): Promise { - 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() } @@ -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 { - 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() } diff --git a/packages/desktop-js/tests/openLaunch.test.ts b/packages/desktop-js/tests/openLaunch.test.ts new file mode 100644 index 0000000000..528278479b --- /dev/null +++ b/packages/desktop-js/tests/openLaunch.test.ts @@ -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 `. 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' }) + .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']) +}) diff --git a/packages/desktop-python/e2b_desktop/main.py b/packages/desktop-python/e2b_desktop/main.py index 3ab7415bf4..d011e182d2 100644 --- a/packages/desktop-python/e2b_desktop/main.py +++ b/packages/desktop-python/e2b_desktop/main.py @@ -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: @@ -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() diff --git a/packages/desktop-python/tests/test_open_launch.py b/packages/desktop-python/tests/test_open_launch.py new file mode 100644 index 0000000000..7dd79d0173 --- /dev/null +++ b/packages/desktop-python/tests/test_open_launch.py @@ -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 `. 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"]