Freestyle Docs

Freestyle / Guides

How to Run GitHub Copilot in a Sandbox

Run the GitHub Copilot CLI headless inside a sandbox and drive it from the Copilot SDK over a private VPC — or from your own machine over a WireGuard VPN. The runtime link is raw-TCP JSON-RPC, so it needs VPC reachability and a fine-grained Copilot token, not an HTTPS domain or a classic PAT.

Run the GitHub Copilot CLI headless inside a sandbox VM and drive it from the Copilot SDK running somewhere else. The SDK talks to its runtime over JSON-RPC on a raw TCP socket, so the client has to reach the VM on a private network: another VM on the same VPC, or your own machine joined to the VPC over a WireGuard VPN.

Requirements

Before you start, make sure you have:

  • A Freestyle API key — to create the VM, VPC, and VPN.
  • A GitHub Copilot subscription, and a fine-grained personal access token with the Copilot requests: read permission. Classic ghp_ tokens do not work — Copilot rejects them outright (more on this below).
  • Node.js 22+ wherever the SDK client runs (the base sandbox image already ships Node).
  • WireGuard tools (wireguard-tools) on your machine only if you want to connect from your laptop rather than from a second VM.

Why this needs a VPC

The Copilot SDK speaks Content-Length-framed JSON-RPC to its runtime — over the runtime’s stdio when it spawns it locally, or over a raw TCP socket when you point it at an already-running server (copilot --headless --port). It is not HTTP.

That rules out a Freestyle domain: domains are an HTTP reverse proxy and can’t carry a raw-TCP JSON-RPC stream. The client instead has to reach the VM over its private address, which means one of:

  • Your own machine over a WireGuard VPN — join the VPC from your laptop, then connect to the private IP.
  • Another VM on the same VPC — VMs on a VPC reach each other by private IP, no VPN needed.

This guide does both.

Install the SDK

Install freestyle wherever you orchestrate VMs:

pnpm add freestyle
bun add freestyle
npm install freestyle
yarn add freestyle

Set your API key:

export FREESTYLE_API_KEY="your-api-key"

The Copilot SDK (@github/copilot-sdk) is installed separately, wherever the client runs — the second VM, or your laptop. We’ll get to it below.

Get a Copilot token

The headless runtime authenticates to Copilot with a GitHub token in COPILOT_GITHUB_TOKEN (or GH_TOKEN / GITHUB_TOKEN). It must be a fine-grained personal access token with the Copilot requests: read permission.

A classic ghp_ token will not work, even one that has the copilot scope — the CLI refuses it:

Error: Classic Personal Access Tokens (ghp_) are not supported by Copilot.
Please use a Fine-Grained Personal Access Token or another authentication method.

Create a fine-grained token at github.com/settings/personal-access-tokens, grant it Copilot requests: read, and export it:

export COPILOT_GITHUB_TOKEN="github_pat_..."

In forUri mode (below) the SDK client cannot supply auth — the server holds it. So this token lives on the VM that runs copilot --headless, not on the client.

Create a VPC and the agent VM

Create a VPC, then attach a VM to it with a routed interface at a fixed private IP. Use idleTimeoutSeconds: null — the idle monitor only watches network traffic, and a headless Copilot server generates none, so it would otherwise be suspended out from under you.

import { freestyle } from "freestyle";

const { vpcId, vpc } = await freestyle.vpc.create({
  name: "copilot-vpc",
  cidr: "192.168.10.0/24",
});

const { vm: agent } = await freestyle.vms.create({
  idleTimeoutSeconds: null,
  nics: [{ default: true, vpc: vpcId, mode: "routed", ipv4: "192.168.10.10" }],
});

The VPC’s routed NIC still has internet egress, so the VM can npm install even though its default route is the VPC.

Install and launch the headless runtime

Install the Copilot CLI and run it as a systemd service — systemd is PID 1 in the VM, so it supervises the process and restarts it if it dies. The server binds 0.0.0.0 so VPC peers can reach it, and reads its fine-grained token plus a connection token (a shared secret it requires from every client) from a root-only environment file.

npm installs the CLI under nvm, so resolve its absolute path for the unit’s ExecStart — systemd doesn’t search PATH.

await agent.exec("npm install -g @github/copilot");
const copilot = (await agent.exec("echo $(npm prefix -g)/bin/copilot")).stdout!.trim();

// A shared secret every client must present (used by both "drive it" sections).
const connectionToken = "a-long-random-shared-secret";

// Secrets go in a root-only env file, kept out of the unit so they don't show up
// in `systemctl cat`.
await agent.fs.writeTextFile(
  "/etc/copilot.env",
  `COPILOT_GITHUB_TOKEN=${process.env.COPILOT_GITHUB_TOKEN}
COPILOT_CONNECTION_TOKEN=${connectionToken}
COPILOT_AUTO_UPDATE=false`,
);
await agent.exec("chmod 600 /etc/copilot.env");

await agent.fs.writeTextFile(
  "/etc/systemd/system/copilot.service",
  `[Service]
Environment=HOME=/root
EnvironmentFile=/etc/copilot.env
ExecStart=${copilot} --headless --host 0.0.0.0 --port 4321
WorkingDirectory=/root
Restart=always
[Install]
WantedBy=multi-user.target`,
);

await agent.exec("systemctl daemon-reload && systemctl enable --now copilot");

Binding 0.0.0.0 sounds broad, but Freestyle exposes no public raw-TCP ingress — the port is only reachable on the VPC (and over the VPN). The COPILOT_CONNECTION_TOKEN is the actual gate: without it the server still starts, but warns that it accepts any client.

Wait until the service is serving before connecting (the base image has Node but not nc/ss):

const check =
  `node -e "const s=require('net').connect(4321,'127.0.0.1');` +
  `s.on('connect',()=>{console.log('OPEN');process.exit(0)});` +
  `s.on('error',()=>process.exit(1))"`;

for (let i = 0; i < 20; i++) {
  const r = await agent.exec({ command: check, timeoutMs: 5_000 });
  if (r.stdout?.includes("OPEN")) break;
  await agent.exec("sleep 2");
}

Inspect or follow it any time with systemctl status copilot or journalctl -u copilot.

Stream the Server’s Logs

vm.exec() buffers a command and only returns once it finishes, so it can’t show a long-running service’s output as it happens. To watch the logs live, open a PTY on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit’s journal. onData delivers the bytes as they arrive:

const session = await agent.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes), // live log lines
});

// Follow the service; new lines stream in until you detach.
session.write("journalctl -u copilot -f\n");

// session.detach() drops your handle — the service keeps running in the VM.

Drive it from your computer

Your machine isn’t on the VPC, so join it over a WireGuard VPN. Create an ephemeral session and write its config:

import { writeFile } from "node:fs/promises";

const connection = await vpc.wireguard.createEphemeral();
await writeFile("freestyle-vpc.conf", connection.clientConfig, { mode: 0o600 });

Install the WireGuard CLI:

brew install wireguard-tools
sudo apt-get update
sudo apt-get install -y wireguard-tools

Bring the tunnel up — this needs root. Its AllowedIPs is just the VPC CIDR, so only VPC traffic is routed; pick a CIDR that doesn’t overlap your LAN:

sudo wg-quick up ./freestyle-vpc.conf

Now your machine can reach the agent by its private IP. Connect with RuntimeConnection.forUri, passing the connection token — the client’s only auth input, since the GitHub token stays on the server:

import { CopilotClient, RuntimeConnection, approveAll } from "@github/copilot-sdk";

const client = new CopilotClient({
  connection: RuntimeConnection.forUri("192.168.10.10:4321", {
    connectionToken: process.env.COPILOT_CONNECTION_TOKEN,
  }),
});
await client.start();

// approveAll lets the agent run tools (shell, edits) inside the agent VM.
// To forbid tools, pass a handler that returns { kind: "reject" } instead.
const session = await client.createSession({ onPermissionRequest: approveAll });

const idle = new Promise((resolve) => {
  session.on("assistant.message", (event) => console.log(event.data.content));
  session.on("session.idle", () => resolve());
});

await session.send({ prompt: "Run `hostname` with the shell tool and reply with its output." });
await idle;

await session.disconnect();
await client.stop();

Install the SDK and run it from your machine:

npm install @github/copilot-sdk
COPILOT_CONNECTION_TOKEN="a-long-random-shared-secret" node client.mjs

The shell tool runs inside the agent VM, so the hostname it prints is the agent’s, not your laptop’s — the agent really ran there, driven from your computer over the tunnel. Tear the tunnel down and close the session when you’re done:

sudo wg-quick down ./freestyle-vpc.conf
await connection.close();

Drive it from another VM on the VPC

A VM that’s already on the VPC needs no VPN — it’s on the private network already. Add one, install the SDK, upload the same client.mjs, and run it there:

import { readFileSync } from "node:fs";

const { vm: peer } = await freestyle.vms.create({
  idleTimeoutSeconds: null,
  nics: [{ default: true, vpc: vpcId, mode: "routed", ipv4: "192.168.10.11" }],
});

await peer.exec("mkdir -p /root/app && cd /root/app && npm init -y && npm install @github/copilot-sdk");
await peer.fs.writeTextFile("/root/app/client.mjs", readFileSync("client.mjs", "utf8"));

const run = await peer.exec({
  command: `cd /root/app && COPILOT_CONNECTION_TOKEN='${connectionToken}' node client.mjs`,
  timeoutMs: 180_000,
});
console.log(run.stdout); // the agent VM's hostname — it ran the shell tool there

Same forUri("192.168.10.10:4321"), same connection token — only the network path differs.

Locking it down

Two independent gates protect the runtime:

  • The connection token (COPILOT_CONNECTION_TOKEN on the server, connectionToken on the client) authenticates the transport. A client without it is rejected with AUTHENTICATION_FAILED before any session is created.
  • onPermissionRequest on the client authorizes each tool call. approveAll lets the agent act; a handler returning { kind: "reject" } keeps it to text only. Because the client decides, you never need --allow-all on the server.
esc