chore: Connect the Claude Code desktop app to Codespaces (no-changelog) (#39524)

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Svetoslav Dekov
2026-09-29 08:04:54 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 3220953fb4
commit 529761ff03
7 changed files with 211 additions and 27 deletions
+65 -15
View File
@@ -2,7 +2,7 @@
Run long-lived, human-steered agent sessions (Claude Code / OpenCode) on a
GitHub Codespace instead of your laptop: start a task, close the lid, steer it
from anywhere with a terminal, resume tomorrow.
from anywhere, resume tomorrow.
This is a separate devcontainer config from the laptop one in
`.devcontainer/` — it ships both agent CLIs, `tmux` for session persistence,
@@ -11,17 +11,61 @@ Playwright system deps, and Docker-in-Docker (for testcontainers and
## One-time setup (~5 min)
1. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces).
Add `ANTHROPIC_API_KEY` for Claude Code. Add `OPENROUTER_API_KEY` for OpenCode.
Give both secrets access to `n8n-io/n8n`.
(Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`.)
2. Give the GitHub CLI the codespace scope:
1. Give the GitHub CLI the codespace scope:
```bash
gh auth refresh -h github.com -s codespace
```
## Daily flow
2. Add provider keys at [github.com/settings/codespaces](https://github.com/settings/codespaces)
if your client needs them. Give each secret access to `n8n-io/n8n`.
- `ANTHROPIC_API_KEY` for Claude Code in the terminal (`pnpm session`).
Alternative for Max subscriptions: `CLAUDE_CODE_OAUTH_TOKEN` from
`claude setup-token`. The desktop app does not need either secret.
- `OPENROUTER_API_KEY` for OpenCode.
## Choose a client
All clients run the agent, its tools, and builds in the Codespace. Your laptop
shows the conversation only.
| Client | Command | Use it when |
|---|---|---|
| Claude Code desktop app | `pnpm session ssh-config` once | You want the full desktop experience. |
| Claude Code in VS Code | Open the codespace in VS Code or the browser | You already work in VS Code. |
| Claude Code in a terminal | `pnpm session` | You want a plain terminal, or you are on a remote machine. |
| OpenCode | `pnpm session:opencode` | You use OpenCode. See [Local OpenCode clients](#local-opencode-clients). |
### Claude Code desktop app
1. From a local checkout, add an SSH host for your codespace:
```bash
pnpm session ssh-config
```
The command creates or starts the codespace. It writes the host
`n8n-codespace` to `~/.ssh/n8n-codespace.conf` and includes that file from
`~/.ssh/config`.
2. In the desktop app, add an SSH connection to the host `n8n-codespace`.
3. Open the folder `/workspaces/n8n`, or a worktree under `/workspaces`.
The desktop app installs its own Claude Code on the codespace and signs in
with your desktop account. Sessions continue when you close the app. They stop
when the codespace stops.
- **Run `pnpm session ssh-config` again after you recreate the codespace.**
- **If the connection times out**, the codespace is probably starting. Run
`pnpm session ssh-config`, then connect again.
- **Test the connection** with `ssh n8n-codespace`.
### Claude Code in VS Code
The dev container installs the Claude Code extension. Open the codespace in
VS Code or the browser, then open the Claude Code panel. Sign in from the
extension if you did not add `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`.
### Claude Code in a terminal
```bash
pnpm session # attach Claude Code (creates everything on first run)
@@ -33,6 +77,7 @@ pnpm session:opencode --legacy # use the remote TUI in tmux
pnpm session fix-flaky # Claude Code in a separate worktree
pnpm session ls # what's running
pnpm session tunnel # forward n8n ports (default 5678, 8080); Ctrl-C to stop
pnpm session ssh-config # add the n8n-codespace SSH host for the desktop app
pnpm session stop # end of day: billing stops, disk survives
pnpm session rm # delete the codespace
```
@@ -332,11 +377,15 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
Claude sessions and the headless OpenCode worker get the `flaky` MCP server automatically: Currents
flaky/quarantine data, the `qa_*` BigQuery dataset, Sentry RCA, live Linear,
and repo investigation. The worker keeps the token in its environment and puts
only an environment reference in the OpenCode config. Claude login registers
the same server without writing the token to disk. Forks have no secrets and
only an environment reference in the OpenCode config. Forks have no secrets and
skip it. Tell the agent to call `get_flaky_context` first — it returns the rules
the tools assume.
`post-start.mjs` registers the server for Claude Code on each container start.
Its `headersHelper` reads the token from the secrets file on each connect, so
the server works in every client. The token is not copied into the Claude Code
MCP configuration.
## Quality and security skills (Claude plugins)
Claude sessions can also load the private skills from the
@@ -398,7 +447,8 @@ to pull the skills into context.
## Viewing the dev UI locally
Two terminal windows:
`pnpm session tunnel` works with every client. With the terminal client, use
two terminal windows:
```bash
pnpm session # window 1: attach the agent session
@@ -448,10 +498,10 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
agent injects them into VS Code sessions only; they're delivered
base64-encoded to `/workspaces/.codespaces/shared/.env-secrets`. The image
sources `/usr/local/lib/codespaces-env.sh` in login shells (profile.d), in
interactive shells (bashrc), and in the `pnpm session` prelude. If Claude
Code shows `Missing environment variables: FLAKY_MCP_TOKEN`, the shell that
started Claude did not source the file. Run
`. /usr/local/lib/codespaces-env.sh` and start Claude again.
interactive shells (bashrc), and in the `pnpm session` prelude. Desktop app
sessions do not source it. `git`, `gh`, `scripts/codespace-env.mjs`, and the
`flaky` MCP entry read the file when they run, so they work everywhere. New
code that needs a secret must do the same.
- **Do not read `CODESPACE_NAME` or `GITHUB_USER` from the process env** — use
`scripts/codespace-env.mjs`. Codespaces gives these variables to VS Code
sessions only. Other processes read them from `codespaces-env.sh`, and a
@@ -460,7 +510,7 @@ After a stop, `pnpm session <name>` restarts the codespace (~30–60 s); run
polled correctly as its owner while `dev:up` in the same session saw an empty
box name, printed the localhost URL, and did not share the port. The helper
reads `/workspaces/.codespaces/shared`, which is always correct.
- **You cannot paste images into a remote Claude session.** Image paste reads
- **You cannot paste images into a terminal Claude session.** Image paste reads
the clipboard of the machine where `claude` runs — the codespace, not your
laptop. Drag the file into the VS Code explorer (or
`gh codespace cp shot.png remote:/workspaces/n8n/`) and give Claude the
@@ -12,14 +12,6 @@
'{"hasCompletedOnboarding":true,"theme":"dark","projects":{"/workspaces/n8n":{"hasTrustDialogAccepted":true}}}' \
>"$HOME/.claude.json"
# Register the Flaky MCP server for Claude Code. The config keeps a literal
# ${FLAKY_MCP_TOKEN}; Claude Code expands it at connect time, so the token is
# not written to disk. Forks have no repo secrets and skip this.
if [ -n "$FLAKY_MCP_URL" ] && ! grep -qs '"flaky"' "$HOME/.claude.json" && command -v claude >/dev/null 2>&1; then
claude mcp add --scope user --transport http flaky "$FLAKY_MCP_URL" \
--header 'Authorization: Bearer ${FLAKY_MCP_TOKEN}' >/dev/null 2>&1 || true
fi
# Register the credential helper in the user config, because Codespaces
# regenerates the managed /etc/gitconfig. When the env token is missing, the
# system helper exits 0 without output, and git falls through to ours.
+6 -2
View File
@@ -18,9 +18,13 @@
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"forwardPorts": [8080, 5678],
// Grant the Codespace token read access to the private skills and harness repositories.
// Each user authorizes this access once when they create the Codespace.
"customizations": {
// Claude Code in VS Code or the browser editor. It runs in the codespace.
"vscode": {
"extensions": ["anthropic.claude-code"]
},
// Grant the Codespace token read access to the private skills and harness repositories.
// Each user authorizes this access once when they create the Codespace.
"codespaces": {
"repositories": {
"n8n-io/n8n-agent-skills": { "permissions": { "contents": "read" } },
+38 -1
View File
@@ -1,10 +1,11 @@
#!/usr/bin/env node
// Runs on each Codespace start. Installs the skills and harness, then starts the worker.
import { execFileSync } from 'node:child_process';
import { rmSync, writeFileSync } from 'node:fs';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { installAgentHarness } from '../../scripts/agent-harness.mjs';
import { codespaceSecret } from '../../scripts/codespace-env.mjs';
import { MARKETPLACE, PLUGINS } from './plugins.mjs';
const STATUS_FILE = '/tmp/post-start-status.json';
@@ -49,6 +50,42 @@ function addMarketplace() {
return add('marketplace add (retry)');
}
// Claude Code reads the Flaky token when it connects. An env reference such as
// ${FLAKY_MCP_TOKEN} fails in processes that did not load the secrets file, for
// example the desktop app's SSH server. The helper reads the file on each connect,
// so it also gets a rotated token. The token is not copied into ~/.claude.json.
const FLAKY_HEADERS_HELPER =
'. /usr/local/lib/codespaces-env.sh; printf %s "{\\"Authorization\\":\\"Bearer $FLAKY_MCP_TOKEN\\"}"';
function registerFlakyMcp() {
// Forks have no repository secrets.
const url = codespaceSecret('FLAKY_MCP_URL');
if (!url || !codespaceSecret('FLAKY_MCP_TOKEN')) return;
let current;
try {
current = JSON.parse(readFileSync(join(homedir(), '.claude.json'), 'utf8')).mcpServers?.flaky;
} catch {
// No readable config yet: register the server below.
}
if (current?.url === url && current.headersHelper === FLAKY_HEADERS_HELPER && !current.headers) {
return;
}
if (current) tryRun('flaky mcp remove', 'claude', ['mcp', 'remove', '--scope', 'user', 'flaky']);
const config = { type: 'http', url, headersHelper: FLAKY_HEADERS_HELPER };
tryRun('flaky mcp add', 'claude', [
'mcp',
'add-json',
'--scope',
'user',
'flaky',
JSON.stringify(config),
]);
}
registerFlakyMcp();
tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']);
const installed = [];
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { aliasHostConfig, withInclude } from '../../scripts/cloud-session-ssh-config.mjs';
const GH_CONFIG = `Host cs.ominous-doodle.master
User node
ProxyCommand gh cs ssh -c ominous-doodle --stdio
UserKnownHostsFile=/dev/null
`;
test('replaces the gh host name with the fixed alias', () => {
assert.equal(
aliasHostConfig(GH_CONFIG),
GH_CONFIG.replace('cs.ominous-doodle.master', 'n8n-codespace'),
);
});
test('rejects gh output without exactly one proxied host', () => {
assert.throws(() => aliasHostConfig(''));
assert.throws(() => aliasHostConfig(`${GH_CONFIG}Host other\n`));
assert.throws(() => aliasHostConfig('Host cs.x\n\tUser node\n'));
});
test('adds the Include line at the top once', () => {
const include = 'Include ~/.ssh/n8n-codespace.conf';
assert.equal(withInclude(''), `${include}\n`);
const updated = withInclude('Host github.com\n\tUser git\n');
assert.equal(updated, `${include}\n\nHost github.com\n\tUser git\n`);
assert.equal(withInclude(updated), undefined);
});
+46
View File
@@ -0,0 +1,46 @@
// Writes an OpenSSH host entry for the session Codespace, so tools that use the
// system ssh (the Claude Code desktop app, VS Code Remote-SSH, plain `ssh`) can
// connect by a fixed name.
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
export const HOST_ALIAS = 'n8n-codespace';
const HOST_FILE = `${HOST_ALIAS}.conf`;
const INCLUDE_LINE = `Include ~/.ssh/${HOST_FILE}`;
/**
* Replaces the host name in `gh codespace ssh --config` output with a fixed alias.
* gh names the host `cs.<codespace>.<branch>`, so its name changes when the
* checkout changes branch. A saved connection must not depend on that.
*/
export function aliasHostConfig(ghConfig, alias = HOST_ALIAS) {
const hosts = ghConfig.match(/^Host .*$/gm) ?? [];
if (hosts.length !== 1 || !/^\s*ProxyCommand /m.test(ghConfig)) {
throw new Error(`Expected one host entry from gh codespace ssh --config, got ${hosts.length}`);
}
return ghConfig.replace(/^Host .*$/m, `Host ${alias}`).trimEnd() + '\n';
}
/**
* Returns the ssh config with the Include line at the top, or undefined if the line
* is already there. An Include after a Host block applies only to that block.
*/
export function withInclude(sshConfig, includeLine = INCLUDE_LINE) {
if (sshConfig.split('\n').some((line) => line.trim() === includeLine)) return undefined;
return `${includeLine}\n${sshConfig.length ? '\n' : ''}${sshConfig}`;
}
export function writeSshConfig(ghConfig, sshDir = join(homedir(), '.ssh')) {
mkdirSync(sshDir, { recursive: true, mode: 0o700 });
const hostPath = join(sshDir, HOST_FILE);
writeFileSync(hostPath, aliasHostConfig(ghConfig), { mode: 0o600 });
const configPath = join(sshDir, 'config');
const current = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
const updated = withInclude(current);
// The mode applies only when the file is new. An existing file keeps its mode.
if (updated !== undefined) writeFileSync(configPath, updated, { mode: 0o600 });
return { hostPath, configPath, includeAdded: updated !== undefined };
}
+24 -1
View File
@@ -9,6 +9,7 @@
// pnpm session:opencode [name] connect a local OpenCode client
// pnpm session ls list Codespaces and tmux sessions
// pnpm session tunnel [port…] forward ports to localhost
// pnpm session ssh-config add an `n8n-codespace` host to ~/.ssh/config
// pnpm session stop stop the Codespace
// pnpm session rm delete the Codespace
import { execFileSync, spawnSync } from 'node:child_process';
@@ -21,7 +22,10 @@ const DEVCONTAINER = '.devcontainer/codespaces/devcontainer.json';
const MACHINE = 'premiumLinux';
const gh = (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim();
const ghTty = (...args) => spawnSync('gh', args, { stdio: 'inherit' });
// On a TTY, gh queries the terminal background colour. The reply can reach the
// shell (or the remote session) as text. A fixed GLAMOUR_STYLE skips the query.
const ghTty = (...args) =>
spawnSync('gh', args, { stdio: 'inherit', env: { ...process.env, GLAMOUR_STYLE: 'dark' } });
// Check the remote terminal database before tmux starts. Older images can lack
// terminal definitions such as xterm-ghostty.
@@ -216,6 +220,25 @@ switch (cmd) {
process.exitCode = status ?? 1;
break;
}
case 'ssh-config': {
const name = ensureCodespace();
// A first connection starts a stopped codespace and creates gh's automatic
// key pair. The generated config names that key.
const { status } = ghTty('codespace', 'ssh', '-c', name, '--', 'true');
if (status !== 0) {
console.error(`Could not connect to ${name} over ssh.`);
process.exit(status ?? 1);
}
const { writeSshConfig, HOST_ALIAS } = await import('./cloud-session-ssh-config.mjs');
const { hostPath, configPath, includeAdded } = writeSshConfig(
gh('codespace', 'ssh', '--config', '-c', name),
);
console.log(`Wrote ${hostPath}${includeAdded ? ` and included it from ${configPath}` : ''}.`);
console.log(`Connect with \`ssh ${HOST_ALIAS}\`, or add the SSH host \`${HOST_ALIAS}\` in the`);
console.log('Claude Code desktop app and open /workspaces/n8n.');
console.log('Run this command again after you recreate the codespace.');
break;
}
case 'stop':
case 'rm': {
const cs = findCodespace();