mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-11 22:50:06 +00:00
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:
co-authored by
Claude Opus 5.5
parent
3220953fb4
commit
529761ff03
@@ -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.
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user