From 1ef82d50814543fadcfb2fcbc2ed0e61738249c7 Mon Sep 17 00:00:00 2001 From: Declan Carroll Date: Fri, 14 Aug 2026 08:49:50 +0000 Subject: [PATCH] fix: Resolve the codespace box name from disk and fix the dev-loop guidance (no-changelog) (#36254) Co-authored-by: Claude Opus 5 (1M context) --- .devcontainer/codespaces/README.md | 21 ++++-- .devcontainer/codespaces/agent-worker.mjs | 24 +++++-- .devcontainer/codespaces/docker-compose.yml | 7 +- AGENTS.md | 12 +++- CONTRIBUTING.md | 12 ++-- packages/testing/playwright/README.md | 11 +-- scripts/codespace-env.mjs | 38 ++++++++++ scripts/codespace-env.test.mjs | 79 +++++++++++++++++++++ scripts/dev-up.mjs | 26 ++++--- 9 files changed, 197 insertions(+), 33 deletions(-) create mode 100644 scripts/codespace-env.mjs create mode 100644 scripts/codespace-env.test.mjs diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index 1796d975d34..f7afd9d7098 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -80,10 +80,15 @@ session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow - **Bring the app up with one command: `pnpm dev:up`.** It installs missing dependencies, starts the backend, waits for health, and prints the URL. Add `--build` only when a frontend change must appear (see below). -- **Open the app** at `https://-5678.app.github.dev`. The port - is private. It opens for you in a browser that is signed in to GitHub. You do - not need a tunnel. An anonymous or server caller gets a 302. That is why the - worker polls outward instead. +- **Open the app** at `https://-5678.app.github.dev`. `dev:up` + makes that port visible to the org, thus any n8n member who is signed into + GitHub can open it. You do not need a tunnel. GitHub makes every forwarded port + private again at each container start, so `dev:up` shares it again on each run. + To see the current state, run `gh codespace ports`. The share command needs `gh` + with the codespace scope (see the one-time setup above). If it fails, `dev:up` + starts the app, prints the reason, and gives you the command to try again. A + private port opens for you only, in a browser that is signed in to GitHub. An + anonymous or server caller gets a 302. That is why the worker polls outward. - **`pnpm dev` no longer exists.** Use `pnpm dev:be` for the backend (on 5678). Use `pnpm dev:fe:editor` for the editor UI with hot reload (on 8080). - **`dev:be` serves the editor from the `dist` build.** So a frontend edit does @@ -187,6 +192,14 @@ After a stop, `pnpm session ` restarts the codespace (~30–60 s); run 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. +- **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 + process that tmux starts can get an empty copy: tmux keeps the environment of + its own start, and `update-environment` does not refresh these keys. A worker + 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 the clipboard of the machine where `claude` runs — the codespace, not your laptop. Drag the file into the VS Code explorer (or diff --git a/.devcontainer/codespaces/agent-worker.mjs b/.devcontainer/codespaces/agent-worker.mjs index 65ad2d556e2..e99e3858015 100644 --- a/.devcontainer/codespaces/agent-worker.mjs +++ b/.devcontainer/codespaces/agent-worker.mjs @@ -10,17 +10,20 @@ // 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; 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) +// GITHUB_USER box owner's login; the bootstrap route for a new thread (see codespace-env.mjs) +// CODESPACE_NAME stable box id; routes a thread back to the box holding its session (same source) // 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'; +import { codespaceEnv } from '../../scripts/codespace-env.mjs'; + const DEQUEUE_URL = process.env.N8N_DEQUEUE_URL; const TOKEN = process.env.AGENT_WORKER_TOKEN; -const GITHUB_USER = process.env.GITHUB_USER; -const BOX_ID = process.env.CODESPACE_NAME; +// Read both identities from the codespace. tmux can give an empty copy of either. +const GITHUB_USER = codespaceEnv('GITHUB_USER'); +const BOX_ID = codespaceEnv('CODESPACE_NAME'); const ROOT = '/workspaces'; const POLL_INTERVAL_MS = 3000; @@ -53,7 +56,16 @@ for (const [k, v] of Object.entries({ // Not fatal, but box pinning needs it: without a box id every turn routes by // owner, so a thread cannot follow the box holding its session. -if (!BOX_ID) console.error('CODESPACE_NAME is not set — box pinning disabled; turns route by githubUser only.'); +if (!BOX_ID) + console.error( + 'CODESPACE_NAME did not resolve — box pinning disabled; turns route by githubUser only.', + ); + +// A turn gets a copy of this environment. Put the correct values in it, because +// `pnpm dev:up` and `gh -c $CODESPACE_NAME` in the session need them. +const TURN_ENV = { ...process.env }; +if (BOX_ID) TURN_ENV.CODESPACE_NAME = BOX_ID; +if (GITHUB_USER) TURN_ENV.GITHUB_USER = GITHUB_USER; function runClaude({ message, sessionId, cwd }) { const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`); @@ -66,7 +78,7 @@ function runClaude({ message, sessionId, cwd }) { execFile( 'claude', args, - { cwd: safeCwd, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }, + { cwd: safeCwd, env: TURN_ENV, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => { try { res(JSON.parse(stdout)); diff --git a/.devcontainer/codespaces/docker-compose.yml b/.devcontainer/codespaces/docker-compose.yml index cab52a55f1d..06839ed4e59 100644 --- a/.devcontainer/codespaces/docker-compose.yml +++ b/.devcontainer/codespaces/docker-compose.yml @@ -28,6 +28,7 @@ services: DB_POSTGRESDB_PASSWORD: password # Prompt-cache TTL in seconds. 1 hour keeps the cache warm between turns. ANTHROPIC_PROMPT_CACHE_TTL: "3600" - # Compose does not inherit the codespace env. Pass the box name through so - # the worker can stamp boxId and dev:up can print the forwarded URL. - CODESPACE_NAME: ${CODESPACE_NAME:-} + # Do not add CODESPACE_NAME here. Codespaces does not give it to compose, so + # this file can set an empty value only. An empty value is worse than no + # value, because tmux keeps it for each new pane. The consumers read the name + # with scripts/codespace-env.mjs. diff --git a/AGENTS.md b/AGENTS.md index c9d560ae49e..6a669a3a3ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -255,8 +255,16 @@ What we use for testing and writing tests: - For E2E tests we use Playwright. Run with `pnpm --filter=n8n-playwright test:local`. See `packages/testing/playwright/README.md` for details. - **To iterate on a feature without docker rebuilds**, boot service containers - and run `pnpm dev` locally — `pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy` - then `pnpm dev`. See [Develop against running containers](packages/testing/playwright/README.md#develop-against-running-containers-avoid-docker-rebuilds). + and run the dev servers locally — `pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy` + then `pnpm dev:be` (backend on 5678). For frontend hot reload, also run + `pnpm dev:fe:editor` (8080). The root `pnpm dev` does not exist: it prints a + notice and exits with code 0, thus `pnpm dev && …` looks successful but no + server runs. See + [Develop against running containers](packages/testing/playwright/README.md#develop-against-running-containers-avoid-docker-rebuilds). +- **In a codespace agent session**, use `pnpm dev:up`. It installs the missing + dependencies, starts the backend, waits for health, shares the port with the + org, and prints the URL. See + [.devcontainer/codespaces/README.md](.devcontainer/codespaces/README.md). - **For Playwright test maintenance/cleanup**, see `packages/testing/playwright/AGENTS.md` (includes janitor tool for static analysis, dead code removal, architecture enforcement, and TCR workflows). ### Common Development Tasks diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12a2b1d63f1..77e898171c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -224,9 +224,10 @@ pnpm exec dotenvx run -f .env.local -- pnpm dev:be ## Development cycle -While iterating on n8n modules code, you can run `pnpm dev`. It will then -automatically build your code, restart the backend and refresh the frontend -(editor-ui) on every change you make. +While iterating on n8n modules code, run `pnpm dev:be` for the backend and +`pnpm dev:fe:editor` for the editor UI. They build your code, restart the +backend, and refresh the frontend on each change you make. The root `pnpm dev` +does not exist: it prints a notice and exits with code 0. Given the size of the code base and the number of modules, we recommend only watching the modules you're actively working on. @@ -337,7 +338,7 @@ packages/cli$ N8N_USER_FOLDER=~/.n8n4/ pnpm run dev When developing custom nodes or credentials, you can enable hot reload to automatically detect changes without restarting the server by setting ```bash -N8N_DEV_RELOAD=true pnpm dev +N8N_DEV_RELOAD=true pnpm dev:be ``` **Performance considerations:** @@ -417,7 +418,8 @@ For manual testing of the event bus with syslog (TCP or UDP), see [packages/cli/ ### Performance Considerations -The full development mode (`pnpm dev`) runs multiple processes in parallel: +Full development mode (`pnpm dev:be` with `pnpm dev:fe:editor`) runs multiple +processes in parallel: 1. **TypeScript compilation** for each package 2. **File watchers** monitoring source files diff --git a/packages/testing/playwright/README.md b/packages/testing/playwright/README.md index 881708379a6..9bf1fbfd27b 100644 --- a/packages/testing/playwright/README.md +++ b/packages/testing/playwright/README.md @@ -16,8 +16,8 @@ N8N_BASE_URL=localhost:5068 pnpm test:local # Runs the E2E tests against the r ## Develop against running containers (avoid docker rebuilds) Iterating on a feature that needs postgres/redis/SMTP/an HTTP proxy? You don't -need `pnpm build:docker` each time. Boot only the services your local `pnpm dev` -needs, and let dev mode pick them up. +need `pnpm build:docker` each time. Boot only the services your local dev +servers need, and let dev mode pick them up. **Two-terminal workflow:** @@ -27,14 +27,15 @@ needs, and let dev mode pick them up. pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy # Terminal 2 — run n8n locally as usual. It picks up the .env automatically. -pnpm dev +# Add `pnpm dev:fe:editor` in a third terminal for frontend hot reload. +pnpm dev:be ``` Scope the `--services` list to what you actually need — booting fewer containers makes startup faster. -| Service | What `pnpm dev` gets | Use when… | -|---------|----------------------|-----------| +| Service | What dev mode gets | Use when… | +|---------|--------------------|-----------| | `postgres` | `DB_*` vars → PostgreSQL backend | testing migrations or PG-specific queries | | `redis` | `QUEUE_*`/`N8N_CACHE_*` → queue mode + cache | testing queue mode or distributed cache | | `mailpit` | `N8N_SMTP_*` → captured SMTP at `http://localhost:` | testing email flows | diff --git a/scripts/codespace-env.mjs b/scripts/codespace-env.mjs new file mode 100644 index 00000000000..52574f44114 --- /dev/null +++ b/scripts/codespace-env.mjs @@ -0,0 +1,38 @@ +// Read the codespace identity from the files Codespaces writes, not from the env. +// Codespaces gives CODESPACE_NAME and GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN to +// VS Code sessions only. In this compose-based config, other processes can get an +// empty copy. The files in /workspaces/.codespaces/shared are always correct. +import { readFileSync } from 'node:fs'; + +const SHARED = '/workspaces/.codespaces/shared'; + +function readShared(dir, file) { + try { + return readFileSync(`${dir}/${file}`, 'utf8'); + } catch { + return ''; + } +} + +/** Returns a codespace variable, or undefined if it is missing or empty. */ +export function codespaceEnv(name, sharedDir = SHARED) { + if (process.env[name]) return process.env[name]; + + try { + const fromJson = JSON.parse(readShared(sharedDir, 'environment-variables.json') || '{}')[name]; + if (fromJson) return fromJson; + } catch (error) { + console.warn(`Ignoring ${sharedDir}/environment-variables.json: ${error.message}`); + } + + // The file has KEY=VALUE lines. Use the last line, as a shell does. + const line = readShared(sharedDir, '.env') + .split('\n') + .findLast((l) => l.startsWith(`${name}=`)); + return line?.slice(name.length + 1).trim() || undefined; +} + +export const codespaceName = (sharedDir) => codespaceEnv('CODESPACE_NAME', sharedDir); + +export const forwardingDomain = (sharedDir) => + codespaceEnv('GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN', sharedDir) ?? 'app.github.dev'; diff --git a/scripts/codespace-env.test.mjs b/scripts/codespace-env.test.mjs new file mode 100644 index 00000000000..3955f0aaffa --- /dev/null +++ b/scripts/codespace-env.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { after, describe, it } from 'node:test'; + +import { codespaceEnv, codespaceName, forwardingDomain } from './codespace-env.mjs'; + +const NAME = 'psychic-umbrella-wqj9pvw9p939vp6'; + +function shared(files) { + const dir = mkdtempSync(join(tmpdir(), 'codespace-env-')); + for (const [file, content] of Object.entries(files)) writeFileSync(join(dir, file), content); + return dir; +} + +const originalEnv = { ...process.env }; +after(() => { + process.env = originalEnv; +}); + +describe('codespaceEnv', () => { + it('prefers a non-empty env value', () => { + process.env.CODESPACE_NAME = NAME; + const dir = shared({ '.env': 'CODESPACE_NAME=from-file\n' }); + assert.equal(codespaceName(dir), NAME); + }); + + // The bug this helper exists for: compose injects an empty value and tmux keeps it. + it('treats an empty env value as missing', () => { + process.env.CODESPACE_NAME = ''; + const dir = shared({ 'environment-variables.json': JSON.stringify({ CODESPACE_NAME: NAME }) }); + assert.equal(codespaceName(dir), NAME); + }); + + it('falls back to .env when the JSON has no such key', () => { + process.env.CODESPACE_NAME = ''; + const dir = shared({ + 'environment-variables.json': JSON.stringify({ ACTION_NAME: 'createFromPrebuild' }), + '.env': `CODESPACE_NAME=stale\nGITHUB_USER=someone\nCODESPACE_NAME=${NAME}\n`, + }); + assert.equal(codespaceName(dir), NAME, 'the last line wins, as a shell does'); + assert.equal(codespaceEnv('GITHUB_USER', dir), 'someone'); + }); + + it('falls back to .env when the JSON is malformed', () => { + process.env.CODESPACE_NAME = ''; + const dir = shared({ + 'environment-variables.json': '{ not json', + '.env': `CODESPACE_NAME=${NAME}\n`, + }); + assert.equal(codespaceName(dir), NAME); + }); + + it('returns undefined off a codespace', () => { + process.env.CODESPACE_NAME = ''; + assert.equal(codespaceName(shared({})), undefined); + }); + + it('ignores an empty value in .env', () => { + process.env.CODESPACE_NAME = ''; + assert.equal(codespaceName(shared({ '.env': 'CODESPACE_NAME=\n' })), undefined); + }); +}); + +describe('forwardingDomain', () => { + it('reads the domain Codespaces reports', () => { + process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN = ''; + const dir = shared({ + '.env': 'GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN=preview.app.github.dev\n', + }); + assert.equal(forwardingDomain(dir), 'preview.app.github.dev'); + }); + + it('defaults to app.github.dev', () => { + process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN = ''; + assert.equal(forwardingDomain(shared({})), 'app.github.dev'); + }); +}); diff --git a/scripts/dev-up.mjs b/scripts/dev-up.mjs index cdd97f42d34..7e6f89a042e 100644 --- a/scripts/dev-up.mjs +++ b/scripts/dev-up.mjs @@ -12,6 +12,8 @@ import { execFileSync, spawn } from 'node:child_process'; import { existsSync, openSync } from 'node:fs'; import { setTimeout as sleep } from 'node:timers/promises'; +import { codespaceName, forwardingDomain } from './codespace-env.mjs'; + const build = process.argv.includes('--build'); const port = process.env.N8N_PORT ?? '5678'; // Probe the same health path the backend serves (defaults to /healthz). @@ -57,24 +59,28 @@ while (Date.now() < deadline) { await sleep(3000); } -const name = process.env.CODESPACE_NAME; -const url = name ? `https://${name}-${port}.app.github.dev` : `http://localhost:${port}`; +const name = codespaceName(); +const url = name ? `https://${name}-${port}.${forwardingDomain()}` : `http://localhost:${port}`; if (!healthy) { console.error(`\nBackend did not answer ${healthPath} within 2 min — check ${LOG}`); process.exit(1); } -// In a codespace, share the port with the org so any n8n member can open the -// URL. Visibility resets to private on each start, so re-apply it here. gh needs -// the codespace scope; skip with a note if the call fails, never block the run. +// In a codespace, share the port with the org. Then any n8n member can open the +// URL. GitHub makes every port private again at each start, so set it here. If +// `gh` fails, print the reason and continue. let orgShared = false; +let shareError; if (name) { try { execFileSync('gh', ['codespace', 'ports', 'visibility', `${port}:org`, '-c', name], { - stdio: 'ignore', + stdio: ['ignore', 'ignore', 'pipe'], }); orgShared = true; - } catch {} + } catch (error) { + const stderr = error.stderr?.toString().trim(); + shareError = (stderr || error.message).split('\n').pop(); + } } console.log(`\nUp: ${url}`); @@ -82,5 +88,9 @@ if (name) console.log( orgShared ? '(org-visible — any n8n member signed into GitHub can open it)' - : `(still private — run \`gh codespace ports visibility ${port}:org -c $CODESPACE_NAME\` to share with the org)`, + : `(still private: ${shareError} — retry with \`gh codespace ports visibility ${port}:org -c ${name}\`)`, + ); +else if (existsSync('/workspaces/.codespaces')) + console.log( + `(on a codespace but the box name did not resolve, so ${port} was not shared with the org — see .devcontainer/codespaces/README.md)`, );