Skip to content

Instantly share code, notes, and snippets.

@rgarcia
Last active February 6, 2026 21:06
Show Gist options
  • Select an option

  • Save rgarcia/0184d47618b7a9ef0797866a4e1546c4 to your computer and use it in GitHub Desktop.

Select an option

Save rgarcia/0184d47618b7a9ef0797866a4e1546c4 to your computer and use it in GitHub Desktop.
Kernel: SCP files to/from browser VMs via kernel browsers ssh

SCP Files to/from Kernel Browser VMs

The Kernel CLI's kernel browsers ssh command (kernel/cli#103) provisions an SSH server on a browser VM and tunnels the connection over WebSockets. Because it sets up a standard OpenSSH server, you can use scp (and rsync, sftp, etc.) with the same tunnel — not just interactive shells.

Prerequisites

  1. Kernel CLI v0.14.5+ (with the browsers ssh subcommand and -o json support)

  2. websocat — WebSocket-to-TCP bridge used as the SSH ProxyCommand

    # macOS
    brew install websocat
    
    # Linux
    curl -fsSL https://github.com/vi/websocat/releases/download/v1.13.0/websocat.x86_64-unknown-linux-musl \
      -o /usr/local/bin/websocat && chmod +x /usr/local/bin/websocat
  3. jq — for parsing JSON output in scripts

    # macOS
    brew install jq
    
    # Linux
    apt-get install jq
  4. An SSH key pair (ed25519 recommended). If you don't already have one:

    ssh-keygen -t ed25519 -f ~/.ssh/kernel_scp -N ""

Step-by-step: SCP a file to a browser VM

1. Create a browser session

export KERNEL_API_KEY="sk_..."
export KERNEL_BASE_URL="https://api.onkernel.com"

kernel browsers create --timeout 300
# Note the Session ID from the output, e.g. zujjxiinpe43b85g7574wlxa

2. Set up SSH on the VM

Use --setup-only -o json with your SSH key so the command provisions the SSH server and returns machine-readable connection details without opening an interactive session:

kernel browsers ssh <SESSION_ID> -i ~/.ssh/kernel_scp --setup-only -o json

This outputs a JSON object with everything you need:

{
  "vm_domain": "aged-frog-c2zy61hq.prod-iad-ukp-browsers-0.onkernel.app",
  "session_id": "zujjxiinpe43b85g7574wlxa",
  "ssh_key_file": "/home/user/.ssh/kernel_scp",
  "proxy_command": "websocat --binary wss://aged-frog-c2zy61hq.prod-iad-ukp-browsers-0.onkernel.app:2222",
  "ssh_command": "ssh -o 'ProxyCommand=websocat --binary wss://...:2222' -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i /home/user/.ssh/kernel_scp root@localhost"
}

3. SCP files to the VM

Use the proxy_command from the JSON output:

VM_DOMAIN=$(echo "$SETUP" | jq -r '.vm_domain')

scp \
  -o "ProxyCommand=websocat --binary wss://${VM_DOMAIN}:2222" \
  -o StrictHostKeyChecking=no \
  -o UserKnownHostsFile=/dev/null \
  -i ~/.ssh/kernel_scp \
  /path/to/local/file.bin \
  root@localhost:/tmp/file.bin

4. SCP files from the VM

Download works the same way — just swap the arguments:

scp \
  -o "ProxyCommand=websocat --binary wss://${VM_DOMAIN}:2222" \
  -o StrictHostKeyChecking=no \
  -o UserKnownHostsFile=/dev/null \
  -i ~/.ssh/kernel_scp \
  root@localhost:/tmp/remote-file.bin \
  /path/to/local/download.bin

5. Clean up

kernel browsers delete <SESSION_ID>

Full example (copy-paste ready)

export KERNEL_API_KEY="sk_..."
export KERNEL_BASE_URL="https://api.onkernel.com"

# Generate a one-time key
ssh-keygen -t ed25519 -f /tmp/kernel-scp-key -N "" -q

# Create a browser (grab the session ID)
SESSION_ID=$(kernel browsers create --timeout 300 -o json | jq -r '.session_id')
echo "Session: $SESSION_ID"

# Provision SSH on the VM and extract the VM domain
SETUP=$(kernel browsers ssh "$SESSION_ID" -i /tmp/kernel-scp-key --setup-only -o json)
VM_DOMAIN=$(echo "$SETUP" | jq -r '.vm_domain')
echo "VM domain: $VM_DOMAIN"

# Upload a file
scp -o "ProxyCommand=websocat --binary wss://${VM_DOMAIN}:2222" \
    -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
    -i /tmp/kernel-scp-key \
    ./my-large-file.tar.gz root@localhost:/tmp/my-large-file.tar.gz

# Download a file
scp -o "ProxyCommand=websocat --binary wss://${VM_DOMAIN}:2222" \
    -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
    -i /tmp/kernel-scp-key \
    root@localhost:/tmp/result.zip ./result.zip

# Clean up
kernel browsers delete "$SESSION_ID"
rm -f /tmp/kernel-scp-key /tmp/kernel-scp-key.pub

Using rsync instead

Since full SSH is available, rsync works too — useful for syncing directories or resumable transfers:

rsync -avz --progress \
  -e "ssh -o 'ProxyCommand=websocat --binary wss://<VM_DOMAIN>:2222' \
          -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
          -i ~/.ssh/kernel_scp" \
  ./local-dir/ root@localhost:/tmp/remote-dir/

Tips for large files

  • SSH setup is persistent: once you run --setup-only, SSH stays running for the lifetime of the browser session. You can scp as many times as you want without re-running setup.
  • Timeout: set --timeout high enough on kernel browsers create to cover your transfer time. The maximum is 259200 (72 hours).
  • Performance: in testing, a 50 MB file transfers in ~1.5 seconds over the WebSocket tunnel. Throughput is roughly comparable to a direct SSH connection since websocat is just bridging TCP over a WebSocket.
  • Integrity: checksums match between source and destination — the WebSocket tunnel is binary-safe.
  • Headless mode: SSH works with both headless (--headless) and non-headless browser sessions.

How it works

Local Machine                          Kernel Browser VM
┌──────────┐    WebSocket (wss)    ┌────────────────────┐
│ scp      │◄──────────────────────►│ websocat (:2222)   │
│  └─ssh   │   via ProxyCommand    │  └─► sshd (:22)    │
│    └─key │                       │       └─► filesystem│
└──────────┘                       └────────────────────┘

kernel browsers ssh --setup-only installs and starts sshd + websocat on the VM via the Kernel process exec API. websocat listens on port 2222 and bridges WebSocket connections to sshd on port 22. The local scp/ssh command connects through websocat as a ProxyCommand.

Batch computer controls smoke test

This repo also contains smoke-test.ts, a TypeScript smoke test for the new batch computer controls API in @onkernel/sdk v0.31.0. Run it with:

KERNEL_API_KEY="sk_..." KERNEL_BASE_URL="https://api.onkernel.com" npx tsx smoke-test.ts
import Kernel from "@onkernel/sdk";
const client = new Kernel({
apiKey: process.env.KERNEL_API_KEY,
baseURL: process.env.KERNEL_BASE_URL,
});
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function main() {
// 1. Create a browser session
console.log("Creating browser session...");
const browser = await client.browsers.create({ headless: true });
const id = browser.session_id;
console.log(` session_id: ${id}`);
// Wait for the browser to be fully ready
console.log(" waiting for browser to be ready...");
await sleep(3000);
try {
// 2. Test getMousePosition (new endpoint)
console.log("\nTesting getMousePosition...");
const pos = await client.browsers.computer.getMousePosition(id);
console.log(` mouse position: x=${pos.x}, y=${pos.y}`);
// 3. Test batch with a series of actions
console.log("\nTesting batch (move_mouse -> click_mouse -> type_text -> sleep -> press_key)...");
await client.browsers.computer.batch(id, {
actions: [
{
type: "move_mouse",
move_mouse: { x: 500, y: 400 },
},
{
type: "click_mouse",
click_mouse: { x: 500, y: 400, button: "left" },
},
{
type: "type_text",
type_text: { text: "hello from batch", delay: 10 },
},
{
type: "sleep",
sleep: { duration_ms: 200 },
},
{
type: "press_key",
press_key: { keys: ["Return"] },
},
],
});
console.log(" batch completed successfully");
// 4. Verify mouse moved by checking position again
console.log("\nVerifying mouse position after batch...");
const pos2 = await client.browsers.computer.getMousePosition(id);
console.log(` mouse position: x=${pos2.x}, y=${pos2.y}`);
// 5. Test batch with scroll action
console.log("\nTesting batch with scroll...");
await client.browsers.computer.batch(id, {
actions: [
{
type: "scroll",
scroll: { x: 500, y: 400, delta_y: 3 },
},
],
});
console.log(" scroll batch completed successfully");
// 6. Test batch with set_cursor (may fail on headless — unclutter not available)
console.log("\nTesting batch with set_cursor...");
try {
await client.browsers.computer.batch(id, {
actions: [
{
type: "set_cursor",
set_cursor: { hidden: true },
},
{
type: "sleep",
sleep: { duration_ms: 100 },
},
{
type: "set_cursor",
set_cursor: { hidden: false },
},
],
});
console.log(" set_cursor batch completed successfully");
} catch (err: any) {
if (err.status === 500 && err.error?.message?.includes("unclutter")) {
console.log(" set_cursor skipped (expected failure on headless: unclutter not available)");
} else {
throw err;
}
}
// 7. Test batch with drag_mouse
console.log("\nTesting batch with drag_mouse...");
await client.browsers.computer.batch(id, {
actions: [
{
type: "drag_mouse",
drag_mouse: {
path: [
[100, 100],
[300, 300],
],
},
},
],
});
console.log(" drag_mouse batch completed successfully");
console.log("\n--- All smoke tests passed! ---");
} finally {
// Clean up: delete the browser session
console.log("\nCleaning up browser session...");
await client.browsers.deleteByID(id);
console.log(" deleted");
}
}
main().catch((err) => {
console.error("Smoke test failed:", err);
process.exit(1);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment