🤖 GitHub Copilot CLI on headless servers¶
GitHub Copilot CLI wants to store its token in a secure OS vault (Keychain on macOS, Secret Service / gnome-keyring on Linux). On headless Linux servers there is no desktop session to unlock such a vault, so Copilot falls back to either storing the token in a plaintext config file or keeping it in memory (re-login every start).
Instead, we authenticate non-interactively with the COPILOT_GITHUB_TOKEN
environment variable (and GH_TOKEN for the gh CLI),
sourced from 1Password on your workstation and forwarded over SSH to the
server. No secret is ever written to the server or committed to a repository.
macOS and other workstations are unaffected — Copilot uses the native Keychain automatically. This flow only matters for the headless Linux dev servers.
How it works¶
workstation headless server (svldev, …)
─────────── ───────────────────────────
1Password Environment
COPILOT_GITHUB_TOKEN
GH_TOKEN (optional)
│ op run --environment <ID>
▼
copilot-ssh svldev
│ ssh -o SendEnv=COPILOT_GITHUB_TOKEN -o SendEnv=GH_TOKEN
▼ (encrypted channel)
sshd AcceptEnv COPILOT_GITHUB_TOKEN GH_TOKEN
│
▼
session env: COPILOT_GITHUB_TOKEN, GH_TOKEN
│
▼
copilot ← COPILOT_GITHUB_TOKEN
gh ← GH_TOKEN
- Token source: a 1Password Environment with a variable named
exactly
COPILOT_GITHUB_TOKEN(required) and, optionally,GH_TOKENfor theghCLI. Same names end-to-end, so nothing is remapped. Keeping them separate lets each tool's token be scoped and rotated independently. - Workstation: the
copilot-ssh(bash/zsh) /copilot_ssh(fish) /copilot-ssh(PowerShell) helper reads the token(s) viaop runand forwards them with SSHSendEnv. - Server:
sshdopts in withAcceptEnv COPILOT_GITHUB_TOKEN GH_TOKEN. SSH's secure default is to drop all client-sent env vars, so this server-side allow-list is required — it is managed by thesystem_setuprole in thedockerrepo. - Tools: Copilot CLI reads
COPILOT_GITHUB_TOKEN(it takes precedence overGH_TOKEN); theghCLI readsGH_TOKEN. They do not interfere with each other.
One-time setup¶
- Create the token(s). A fine-grained
Personal Access Token
with the "Copilot Requests" permission for Copilot. Optionally create a
second fine-grained PAT scoped to what you need
ghto do on the servers (e.g. repository contents, pull requests). Give them an expiry and rotate periodically. Keeping them separate keeps each token least-privilege. - Store them in 1Password. In the desktop app: Developer → View
Environments → New environment (e.g. "Development Machine"), then add a
variable named
COPILOT_GITHUB_TOKENwith the Copilot PAT as its value. To also authenticategh, add a second variable namedGH_TOKENwith theghPAT (optional — omit it if you only want Copilot). - Get the Environment ID. Open the Environment → Manage environment → Copy environment ID. This ID is not a secret.
- Tell chezmoi (usually nothing to do). The Environment ID is a non-secret
identifier, so a shared default is hardcoded in
.chezmoi.yaml.tmpland exported asOP_COPILOT_ENVIRONMENT_ID. To use a different Environment, overrideopCopilotEnvironmentIdin your local chezmoi config or at the interactive init prompt (chezmoi init --data=falseto re-enter). - 1Password CLI. Install the 1Password CLI beta ≥ 2.33.0-beta.02
and enable the desktop-app integration (so
op rununlocks with biometrics — no service-account token needed). Environment support is beta.
The server side needs no manual steps — the docker repo's Ansible pull adds
AcceptEnv COPILOT_GITHUB_TOKEN GH_TOKEN to sshd automatically.
Usage¶
copilot-ssh svldev # bash / zsh
copilot_ssh svldev # fish
copilot-ssh svldev # PowerShell (Windows workstation)
Then run copilot (and gh, if you added GH_TOKEN) on the server as usual —
they pick up the forwarded tokens.
On the bash/zsh/fish helpers, extra ssh arguments are passed through
directly (e.g. copilot-ssh -A svldev). On the PowerShell helper the host
name is a real parameter that tab-completes from your ~/.ssh/config Host
entries; because ssh flags such as -p/-o collide with PowerShell's parameter
binder, pass any extra ssh options after a -- separator:
copilot-ssh svldev # host name tab-completes
copilot-ssh svldev -- -A -p 2222 # extra ssh flags after --
If op or OP_COPILOT_ENVIRONMENT_ID is unavailable, the bash/zsh/fish
helpers fall back to a plain ssh (you connect, but the tools won't receive a
token). The PowerShell helper instead runs fatal pre-flight checks and
aborts if ssh or op is missing, or the Environment ID is unset — it
never opens a token-less session. When the 1Password CLI is not found it tells
you to (1) install it and (2) enable it in
1Password → Settings → Developer → "Integrate with 1Password CLI".
Reachability pre-flight (and stopped Azure VMs)¶
Before unlocking 1Password, all three helpers run a fast reachability check so an unreachable host fails in about three seconds instead of hanging on ssh's own connect timeout:
- The destination is resolved with
ssh -G <args>, so~/.ssh/configaliases,HostName/Portoverrides and-oflags are all honoured. - Its TCP port is probed with a hard timeout (
nc, or bash/dev/tcp, or a .NETTcpClienton Windows). If nothing can probe, the check is skipped andsshdecides. - When the probe fails and the Azure CLI (
az) is installed, the host is looked up withaz vm list -d: first by VM name — matching both the full host and its short form, sovm01.example.comalso matches a VM namedvm01— then by the VM's public/private IP addresses. - If that VM is stopped or deallocated, you are asked whether to start
it. On yes, the helper runs
az vm start, waits for the SSH port to answer and then connects as usual. On no (or in a non-interactive shell, which always answers no) it aborts. - If the VM is running but the port is closed, it says so and points at
NSG rules, the VPN/network path or
sshd— no VM is touched.
Ambiguous matches (several VMs with the same name in different resource groups) are listed and the helper refuses to guess.
Environment variables:
| Variable | Default | Effect |
|---|---|---|
COPILOT_SSH_SKIP_PREFLIGHT |
unset | 1 skips the reachability check entirely |
COPILOT_SSH_PREFLIGHT_TIMEOUT |
3 |
TCP probe timeout in seconds |
COPILOT_SSH_START_TIMEOUT |
180 |
How long to wait for SSH after az vm start |
COPILOT_SSH_ASSUME_YES |
unset | 1 auto-confirms starting a stopped VM |
COPILOT_SSH_ASSUME_NO |
unset | 1 never starts a VM, even on a TTY |
Local authentication with the 1Password shell plugin¶
copilot-ssh solves the remote case: forwarding a token to a headless
server. On your workstation you don't need to forward anything — the
1Password Copilot shell plugin authenticates the local copilot
command with biometrics, injecting the same COPILOT_GITHUB_TOKEN for the
duration of each command.
This repo wraps copilot (and gh) automatically; see
1password-shell-plugins.md. The two mechanisms
are complementary and use the same variable name:
| Where | Mechanism | Token source |
|---|---|---|
| Workstation | op plugin run -- copilot |
1Password item, per command |
| Headless server | copilot-ssh + SSH SendEnv |
1Password Environment |
Note that the plugin wrappers are not applied on WSL, where shell plugins are
unsupported — copilot-ssh still works there.
They cannot collide¶
The wrappers deliberately do not activate in an SSH session (they are
guarded on SSH_CONNECTION). op plugin run needs the 1Password desktop app
for biometric unlock, which a headless server does not have, so a wrapper on
the far side of copilot-ssh would replace a working CLI with one that always
fails — exactly the situation copilot-ssh exists to avoid.
| Session | copilot resolves to |
Credential |
|---|---|---|
| Local workstation | op plugin run -- copilot |
1Password, per command |
Inside copilot-ssh |
the real copilot binary |
forwarded COPILOT_GITHUB_TOKEN |
In practice the remote host usually has no op installed either, which is a
second, independent guard. The same reasoning applies to gh and GH_TOKEN.
Security notes¶
- The tokens live only in 1Password (at rest), transiently in the helper's memory, the encrypted SSH channel, and the server session's environment for that session's lifetime. Nothing is persisted on the server.
AcceptEnvis scoped to the specific variablesCOPILOT_GITHUB_TOKENandGH_TOKEN— never a wildcard, which the OpenSSH docs warn can be used to bypass restricted environments.- During a live session the tokens are readable via
/proc/<pid>/environby same-user processes and root on that server — the same trust boundary as the logged-in user. Use least-privilege, expiring PATs (separate ones for Copilot andgh) to limit blast radius.