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) <noreply@anthropic.com>
This commit is contained in:
Declan Carroll
2026-08-14 08:49:50 +00:00
committed by GitHub
parent 7025284404
commit 1ef82d5081
9 changed files with 197 additions and 33 deletions
+17 -4
View File
@@ -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://<codespace-name>-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://<codespace-name>-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 <name>` restarts the codespace (~3060 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
+18 -6
View File
@@ -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));
+4 -3
View File
@@ -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.
+10 -2
View File
@@ -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
+7 -5
View File
@@ -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
+6 -5
View File
@@ -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:<mapped-port>` | testing email flows |
+38
View File
@@ -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';
+79
View File
@@ -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');
});
});
+18 -8
View File
@@ -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)`,
);