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
+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();