diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index a49ba7fd5eb..1796d975d34 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -121,9 +121,15 @@ The private marketplace uses the codespace's own GitHub auth — no extra token. prompts for it, then remembers). Both repos are in the same org, which is what lets this work. -If a user does not authorize it, the clone fails and the skills step is skipped -(the worker still starts). Existing codespaces created before this change need a -recreate to get the prompt. The log is at `/tmp/post-start.log`. +The codespace authenticates git over HTTPS and has no SSH key, so `post-start.mjs` +sets `CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1` — Claude Code's plugin loader otherwise +clones `owner/repo` shorthand over SSH and the private clone fails. + +If a user does not authorize the grant, the clone fails and the skills step is +skipped (the worker still starts). Existing codespaces created before this change +need a recreate to get the prompt. The log is at `/tmp/post-start.log`: a failed +`skills repo reachable` line means the grant was not authorized; a failed +`marketplace add` after a reachable repo means a loader-auth problem. ## Viewing the dev UI locally @@ -203,7 +209,7 @@ After a stop, `pnpm session ` restarts the codespace (~30–60 s); run ## Costs -Compute bills only while the codespace runs: ~$0.36/hr (4-core) / ~$0.72/hr -(8-core), ~nothing stopped. Usage draws from a shared monthly org budget, so +Compute bills only while the codespace runs: ~$0.72/hr for the 8-core box, +~nothing stopped. Usage draws from a shared monthly org budget, so `pnpm session stop` when you leave; the idle timeout is the backstop, not the plan. diff --git a/.devcontainer/codespaces/agent-worker.mjs b/.devcontainer/codespaces/agent-worker.mjs index 30713714ae0..5c716a6ad5d 100644 --- a/.devcontainer/codespaces/agent-worker.mjs +++ b/.devcontainer/codespaces/agent-worker.mjs @@ -10,9 +10,9 @@ // Env: // N8N_DEQUEUE_URL n8n webhook that hands back one pending turn (required) // AGENT_WORKER_TOKEN shared bearer sent on every dequeue (required) -// GITHUB_USER box owner's login; turns are addressed to it (codespaces set this) -// AGENT_WORKER_ROOT confine turn cwd under here (default /workspaces) -// POLL_INTERVAL_MS delay between empty polls (default 3000) +// GITHUB_USER box owner's login; the bootstrap route for a new thread (codespaces set this) +// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (codespaces set this) +// TURN_TIMEOUT_MS per-turn limit; keep below the n8n Wait limit (default 25 min) import { execFile } from 'node:child_process'; import { resolve as resolvePath, sep } from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; @@ -20,10 +20,13 @@ import { setTimeout as sleep } from 'node:timers/promises'; const DEQUEUE_URL = process.env.N8N_DEQUEUE_URL; const TOKEN = process.env.AGENT_WORKER_TOKEN; const GITHUB_USER = process.env.GITHUB_USER; -const ROOT = resolvePath(process.env.AGENT_WORKER_ROOT ?? '/workspaces'); +const BOX_ID = process.env.CODESPACE_NAME; +const ROOT = '/workspaces'; -// Read a positive-number env var. Fall back to the default (and warn) on a bad -// value, so a config mistake cannot change the poll rate or the turn limit. +const POLL_INTERVAL_MS = 3000; + +// Warn and use the default on a bad value, so a config mistake cannot silently +// disable the turn limit. function posNum(name, fallback) { const raw = process.env[name]; if (raw === undefined) return fallback; @@ -33,7 +36,6 @@ function posNum(name, fallback) { return fallback; } -const POLL_INTERVAL_MS = posNum('POLL_INTERVAL_MS', 3000); // Keep this below the n8n Wait-node limit. Then the worker reports a slow turn // before n8n's Wait ends with a generic message. const TURN_TIMEOUT_MS = posNum('TURN_TIMEOUT_MS', 25 * 60_000); @@ -88,7 +90,7 @@ async function post(url, body) { } async function dequeue() { - const res = await post(DEQUEUE_URL, { githubUser: GITHUB_USER, token: TOKEN }); + const res = await post(DEQUEUE_URL, { githubUser: GITHUB_USER, boxId: BOX_ID, token: TOKEN }); if (!res.ok) throw new Error(`dequeue HTTP ${res.status}`); const text = await res.text(); if (!text.trim()) return null; // no pending turn @@ -105,6 +107,7 @@ async function handle(turn) { status: 'done', output: r.result ?? '', sessionId: r.session_id ?? turn.sessionId ?? '', + boxId: BOX_ID, }; } catch (error) { result = { @@ -112,6 +115,7 @@ async function handle(turn) { status: 'error', output: error.message, sessionId: turn.sessionId ?? '', + boxId: BOX_ID, }; } // Send the result to the turn's resume URL. This continues the waiting n8n @@ -137,7 +141,9 @@ for (;;) { try { const turn = await dequeue(); if (turn) { - console.log(`turn ${turn.turnId}: ${turn.sessionId ? 'resume' : 'new'}`); + console.log( + `${new Date().toISOString()} turn ${turn.turnId} by ${turn.author ?? 'unknown'}: ${turn.sessionId ? 'resume' : 'new'}`, + ); await handle(turn); continue; // get the next turn now, with no delay } diff --git a/.devcontainer/codespaces/post-start.mjs b/.devcontainer/codespaces/post-start.mjs index fdb1d09c6ac..30230e6d40e 100644 --- a/.devcontainer/codespaces/post-start.mjs +++ b/.devcontainer/codespaces/post-start.mjs @@ -1,18 +1,15 @@ #!/usr/bin/env node -// Runs on each codespace start. It installs the private skills marketplace into -// Claude Code (best-effort), then starts the agent worker. -// -// The codespace's own GitHub token is granted read access to the marketplace -// repo via customizations.codespaces.repositories in devcontainer.json (each -// user authorizes it once at codespace creation). So git — and Claude Code's -// clone — read it with the codespace's normal auth, no extra token. If a user -// did not authorize it, the clone fails and the skills step is skipped. The -// worker always starts. +// Runs on each codespace start. Installs the skills marketplace, starts the worker. import { execFileSync } from 'node:child_process'; const MARKETPLACE = 'n8n-io/n8n-claude-skills'; const PLUGIN = 'quality@n8n-claude-skills'; +// The codespace clones over HTTPS and has no SSH key. The loader defaults to SSH +// for "owner/repo", so it must prefer HTTPS or the private clone fails. +process.env.CLAUDE_CODE_PLUGIN_PREFER_HTTPS = '1'; +process.env.CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE = '1'; + function tryRun(label, cmd, args) { try { execFileSync(cmd, args, { stdio: 'inherit' }); @@ -23,14 +20,15 @@ function tryRun(label, cmd, args) { } } +tryRun('skills repo reachable', 'git', ['ls-remote', `https://github.com/${MARKETPLACE}`, 'HEAD']); + if (tryRun('marketplace add', 'claude', ['plugin', 'marketplace', 'add', MARKETPLACE])) tryRun('plugin install', 'claude', ['plugin', 'install', PLUGIN]); -// Start the poll worker, detached, so it survives this script exiting. tryRun('worker start', 'tmux', [ 'new-session', '-d', '-s', 'agent-worker', - 'bash -lc "node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"', + 'bash -lc "export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1; node /workspaces/n8n/.devcontainer/codespaces/agent-worker.mjs >> /tmp/agent-worker.log 2>&1"', ]); diff --git a/scripts/cloud-session.mjs b/scripts/cloud-session.mjs index 1d893bdab0a..35447943b0f 100644 --- a/scripts/cloud-session.mjs +++ b/scripts/cloud-session.mjs @@ -13,8 +13,7 @@ import { execFileSync, spawnSync } from 'node:child_process'; const REPO = 'n8n-io/n8n'; const DEVCONTAINER = '.devcontainer/codespaces/devcontainer.json'; -const MACHINE = process.env.CODESPACE_MACHINE ?? 'premiumLinux'; // 8-core/32GB -const FALLBACK_MACHINE = 'standardLinux32gb'; // 4-core/16GB, until org policy allows bigger +const MACHINE = 'premiumLinux'; // 8-core/32GB — the only size we use const gh = (...args) => execFileSync('gh', args, { encoding: 'utf8' }).trim(); const ghTty = (...args) => spawnSync('gh', args, { stdio: 'inherit' }); @@ -39,21 +38,32 @@ function requestCodespaceScope() { } function ensureCodespace() { - let cs = findCodespace(); - if (!cs) { - console.log(`No codespace on ${REPO} — creating one (first build takes a while)…`); - const create = (machine) => - gh('codespace', 'create', '-R', REPO, '--devcontainer-path', DEVCONTAINER, '-m', machine); - let name; - try { - name = create(MACHINE); - } catch { - console.log(`Machine type ${MACHINE} unavailable — falling back to ${FALLBACK_MACHINE}`); - name = create(FALLBACK_MACHINE); - } - cs = { name }; + const existing = findCodespace(); + if (existing) return existing.name; + + console.log(`No codespace on ${REPO} — creating one (first build takes a while)…`); + // Run interactive. The devcontainer requests access to the private skills repo, + // so gh prints an authorization URL and waits. A captured run cannot answer it. + const { status } = ghTty( + 'codespace', + 'create', + '-R', + REPO, + '--devcontainer-path', + DEVCONTAINER, + '-m', + MACHINE, + ); + if (status !== 0) { + console.error('Codespace create failed. Authorize the permissions prompt above, then retry.'); + process.exit(1); } - return cs.name; + const created = findCodespace(); + if (!created) { + console.error('Created a codespace but could not find it — run `pnpm session ls`.'); + process.exit(1); + } + return created.name; } // The preludes go inside tmux's '…' argument: do not use single quotes in them.