mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat: Drive codespace agent sessions from n8n via an outbound poll worker (no-changelog) (#36141)
Co-authored-by: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
870099f6f1
commit
66b3d18d06
@@ -41,40 +41,62 @@ pnpm session rm # delete the codespace
|
||||
- First codespace creation takes ~20 min uncached (image + full build). After
|
||||
that, sessions attach instantly; new worktrees cost a `pnpm install` (~1–2 min).
|
||||
|
||||
## Agent broker (call the codespace over HTTP)
|
||||
## Agent worker (drive a session from n8n)
|
||||
|
||||
`agent-broker.mjs` runs one Claude turn per HTTP request, so an external caller
|
||||
(an n8n workflow, a script) can drive a session on the codespace. Each turn is a
|
||||
headless `claude -p`. State persists on disk, so `--resume` continues a
|
||||
conversation across turns and across a stop/start.
|
||||
`agent-worker.mjs` lets an n8n workflow drive a Claude Code session on the
|
||||
codespace. This is how Slack (the Flaky bot) steers a session that runs here.
|
||||
Each turn is one headless `claude -p`. The session state is on disk. So
|
||||
`--resume` continues a conversation across turns, and across a stop/start.
|
||||
|
||||
The broker starts on every container start (`postStartCommand`). It needs the
|
||||
`AGENT_BROKER_TOKEN` secret and refuses to start without it. Add the token at
|
||||
[github.com/settings/codespaces](https://github.com/settings/codespaces) the
|
||||
same way as `ANTHROPIC_API_KEY`. Logs are at `/tmp/agent-broker.log`; the tmux
|
||||
session is `broker`.
|
||||
**The worker polls outward. Nothing inbound is exposed.** GitHub sets every
|
||||
forwarded port to private on start. It gives no API to make a port public. So
|
||||
you cannot reach a codespace from outside reliably. The worker calls out
|
||||
instead. It asks n8n for a turn addressed to this box's owner (`$GITHUB_USER`).
|
||||
It runs the turn. It sends the result to the turn's resume URL. It uses no
|
||||
tunnel, no open port, and no domain.
|
||||
|
||||
The port is private by default. Make it reachable, then health-check it:
|
||||
The worker starts on each container start (`postStartCommand`). It needs two
|
||||
secrets. Add them at
|
||||
[github.com/settings/codespaces](https://github.com/settings/codespaces), the
|
||||
same way as `ANTHROPIC_API_KEY`:
|
||||
|
||||
```bash
|
||||
gh codespace ports visibility 8787:public -c <codespace-name>
|
||||
curl https://<codespace-name>-8787.app.github.dev/healthz # {"ok":true}
|
||||
```
|
||||
- `AGENT_WORKER_TOKEN` — the shared bearer token. The worker sends it on each poll.
|
||||
- `N8N_DEQUEUE_URL` — the n8n webhook that returns a pending turn.
|
||||
|
||||
A public port has no auth in front of it, so the bearer token is the only
|
||||
guard. Keep it secret.
|
||||
The log is at `/tmp/agent-worker.log`. The tmux session is `agent-worker`. To
|
||||
watch it, run `tmux attach -t agent-worker`. The worker does not start if a
|
||||
secret or `$GITHUB_USER` is missing.
|
||||
|
||||
Send a turn (omit `sessionId` to start one; pass it back to continue):
|
||||
A turn stops after about 25 minutes (`TURN_TIMEOUT_MS`). This limit is below the
|
||||
n8n Wait limit. So the worker reports a clear message before n8n reports a
|
||||
generic timeout. Keep the worker limit below the n8n limit if you change either.
|
||||
|
||||
```bash
|
||||
curl -s https://<codespace-name>-8787.app.github.dev/turns \
|
||||
-H "Authorization: Bearer $AGENT_BROKER_TOKEN" -H 'content-type: application/json' \
|
||||
-d '{"message":"what branch is this?"}'
|
||||
# reply carries .result.session_id — send it back as "sessionId" next turn
|
||||
```
|
||||
### Build and run the app in a session
|
||||
|
||||
Turns take minutes. Pass a `callbackUrl` to get `202 {turnId}` at once and the
|
||||
result by POST when the turn ends. See the file header for the full contract.
|
||||
The prebuild already installed the dependencies and warmed the build. So a
|
||||
session rarely needs a cold `pnpm install` or a full `pnpm build`. Both are slow
|
||||
(often 10–20 minutes cold). Both can outlast a turn's limit.
|
||||
|
||||
- **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.
|
||||
- **`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
|
||||
not hot-reload there. Run `pnpm dev:up --build` (or `pnpm build`) and restart
|
||||
to show it. For live frontend hot reload, use `pnpm dev:fe:editor`. That path
|
||||
needs `pnpm session tunnel 5678 8080` from your laptop. Its API base is set to
|
||||
`localhost:5678`, so the `-8080.app.github.dev` URL does not work on its own.
|
||||
- Run `pnpm install` only when the dependencies change. `pnpm build` reuses the
|
||||
turbo cache and is fast when warm.
|
||||
- To clear stale build outputs after a branch switch, run `pnpm reset`. Add
|
||||
`--full` if that does not clear it.
|
||||
- Give a long build its own turn. Do not chain an install and a full build
|
||||
behind other work in one turn.
|
||||
|
||||
## Flaky tools (MCP)
|
||||
|
||||
@@ -85,6 +107,24 @@ and repo investigation. Login registers it from the repo-level
|
||||
it. Tell the agent to call `get_flaky_context` first — it returns the rules
|
||||
the tools assume.
|
||||
|
||||
## Quality skills (Claude plugin)
|
||||
|
||||
Claude sessions can also load the private quality skills from the
|
||||
`n8n-io/n8n-claude-skills` plugin marketplace (bug insights, defect attribution,
|
||||
mutation testing, and more). `post-start.mjs` installs the `quality` plugin on
|
||||
each container start, so every session gets the skills with no per-session step.
|
||||
|
||||
The private marketplace uses the codespace's own GitHub auth — no extra token.
|
||||
`devcontainer.json` grants the codespace read access to
|
||||
`n8n-io/n8n-claude-skills` via `customizations.codespaces.repositories`, and
|
||||
**each user authorizes that access once when they create the codespace** (GitHub
|
||||
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`.
|
||||
|
||||
## Viewing the dev UI locally
|
||||
|
||||
Two terminal windows:
|
||||
@@ -94,14 +134,20 @@ pnpm session # window 1: attach the agent session
|
||||
pnpm session tunnel # window 2: forward 5678 + 8080 to localhost
|
||||
```
|
||||
|
||||
Attaching lands you in the agent (Claude), not a shell — open a shell in a
|
||||
new tmux window with `Ctrl-b c` (or ask the agent) and run `pnpm dev` there.
|
||||
Then open http://localhost:8080.
|
||||
Attaching lands you in the agent (Claude), not a shell — open a shell in a new
|
||||
tmux window with `Ctrl-b c` (or ask the agent), then pick a dev server and open
|
||||
the matching port:
|
||||
|
||||
- `pnpm dev:up` — backend on **5678** (editor served from the `dist` build).
|
||||
Open http://localhost:5678.
|
||||
- `pnpm dev:fe:editor` — editor UI with hot reload on **8080**. Open
|
||||
http://localhost:8080. It needs the backend on 5678 too, so run `pnpm dev:up`
|
||||
alongside it.
|
||||
|
||||
The tunnel prints nothing while forwarding — that's normal. `Connection
|
||||
refused` means nothing is listening on that port in the codespace yet; it
|
||||
starts serving as soon as `pnpm dev` is up, no restart needed. Pass ports to
|
||||
override the defaults (`pnpm session tunnel 5678 8080 5679`), but always
|
||||
starts serving as soon as the dev server is up, no restart needed. Pass ports
|
||||
to override the defaults (`pnpm session tunnel 5678 8080 5679`), but always
|
||||
forward the pair together with matching numbers: the Vite dev UI points its
|
||||
API base at `localhost:5678` (the `N8N_PORT` default), so an asymmetric or
|
||||
partial mapping breaks it.
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// HTTP broker for per-turn Claude Code conversations on a codespace.
|
||||
// Each turn is one `claude -p` exec. State persists on disk via --resume, so the
|
||||
// broker holds nothing between turns and survives a codespace stop/start.
|
||||
//
|
||||
// AGENT_BROKER_TOKEN=… node agent-broker.mjs
|
||||
//
|
||||
// GET /healthz no auth
|
||||
// POST /turns { message, sessionId?, cwd?, callbackUrl? } Bearer auth
|
||||
// with callbackUrl: reply 202 { turnId }, then POST the result to callbackUrl.
|
||||
// without callbackUrl: hold the connection, reply with the result. Short turns only.
|
||||
// GET /turns/<id> Bearer auth. Poll for a held result.
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { createServer } from 'node:http';
|
||||
import { resolve as resolvePath, sep } from 'node:path';
|
||||
|
||||
const PORT = Number(process.env.AGENT_BROKER_PORT ?? 8787);
|
||||
const TOKEN = process.env.AGENT_BROKER_TOKEN;
|
||||
// Confine workdirs to ROOT. Override for local tests outside a codespace.
|
||||
const ROOT = resolvePath(process.env.AGENT_BROKER_ROOT ?? '/workspaces');
|
||||
const DEFAULT_CWD = process.env.AGENT_BROKER_CWD ?? `${ROOT}/n8n`;
|
||||
const TURN_TIMEOUT_MS = 15 * 60_000;
|
||||
const MAX_TURNS_KEPT = 100;
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error('Refusing to start: set AGENT_BROKER_TOKEN (the bearer token n8n will send).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const turns = new Map();
|
||||
// Serialize turns on the same session. Turns on other sessions run in parallel.
|
||||
const queues = new Map();
|
||||
|
||||
function enqueue(key, task) {
|
||||
const tail = (queues.get(key) ?? Promise.resolve()).then(task, task);
|
||||
const settled = tail.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
queues.set(key, settled);
|
||||
// Drop the key once the chain drains, unless a newer turn already replaced it.
|
||||
settled.then(() => {
|
||||
if (queues.get(key) === settled) queues.delete(key);
|
||||
});
|
||||
return tail;
|
||||
}
|
||||
|
||||
function runClaude({ message, sessionId, cwd }) {
|
||||
const args = ['-p', '--output-format', 'json', '--dangerously-skip-permissions'];
|
||||
if (sessionId) args.push('--resume', sessionId);
|
||||
args.push(message);
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'claude',
|
||||
args,
|
||||
{ cwd, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch {
|
||||
reject(new Error(stderr?.trim() || err?.message || 'claude produced no output'));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function executeTurn(id, { message, sessionId, cwd, callbackUrl }) {
|
||||
const turn = turns.get(id);
|
||||
turn.status = 'running';
|
||||
try {
|
||||
const result = await enqueue(sessionId ?? id, () => runClaude({ message, sessionId, cwd }));
|
||||
Object.assign(turn, { status: 'done', result });
|
||||
} catch (error) {
|
||||
Object.assign(turn, { status: 'error', error: error.message });
|
||||
}
|
||||
if (turns.size > MAX_TURNS_KEPT) {
|
||||
for (const key of turns.keys()) {
|
||||
if (turns.size <= MAX_TURNS_KEPT) break;
|
||||
if (turns.get(key).status !== 'running') turns.delete(key);
|
||||
}
|
||||
}
|
||||
if (callbackUrl) {
|
||||
try {
|
||||
await fetch(callbackUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ turnId: id, ...turn }),
|
||||
});
|
||||
} catch (error) {
|
||||
// The result is still available from GET /turns/<id>.
|
||||
console.error(`turn ${id}: callback to ${callbackUrl} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function authorized(req) {
|
||||
const sent = req.headers.authorization?.replace(/^Bearer /, '') ?? '';
|
||||
const a = Buffer.from(sent);
|
||||
const b = Buffer.from(TOKEN);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
res.writeHead(status, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const { pathname } = new URL(req.url, 'http://localhost');
|
||||
if (req.method === 'GET' && pathname === '/healthz') return json(res, 200, { ok: true });
|
||||
if (!authorized(req)) return json(res, 401, { error: 'unauthorized' });
|
||||
|
||||
const turnMatch = pathname.match(/^\/turns\/([\w-]+)$/);
|
||||
if (req.method === 'GET' && turnMatch) {
|
||||
const turn = turns.get(turnMatch[1]);
|
||||
return turn ? json(res, 200, turn) : json(res, 404, { error: 'unknown turn' });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && pathname === '/turns') {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(Buffer.concat(chunks).toString());
|
||||
} catch {
|
||||
return json(res, 400, { error: 'body must be JSON' });
|
||||
}
|
||||
if (body === null || typeof body !== 'object' || Array.isArray(body))
|
||||
return json(res, 400, { error: 'body must be a JSON object' });
|
||||
const { message, sessionId, callbackUrl } = body;
|
||||
if (typeof message !== 'string' || !message.trim())
|
||||
return json(res, 400, { error: 'message (string) is required' });
|
||||
for (const [k, v] of Object.entries({ sessionId, callbackUrl, cwd: body.cwd }))
|
||||
if (v !== undefined && typeof v !== 'string')
|
||||
return json(res, 400, { error: `${k} must be a string` });
|
||||
// Resolve before the prefix check so `..` segments can't escape ROOT.
|
||||
const cwd = resolvePath(typeof body.cwd === 'string' ? body.cwd : DEFAULT_CWD);
|
||||
if (cwd !== ROOT && !cwd.startsWith(ROOT + sep))
|
||||
return json(res, 400, { error: `cwd must be under ${ROOT}` });
|
||||
|
||||
const id = randomUUID();
|
||||
// Hold the turn object across the await: eviction may delete the map entry,
|
||||
// but this reference still reflects the status executeTurn assigns to it.
|
||||
const turn = { status: 'queued' };
|
||||
turns.set(id, turn);
|
||||
const running = executeTurn(id, { message, sessionId, cwd, callbackUrl });
|
||||
if (callbackUrl) return json(res, 202, { turnId: id });
|
||||
await running;
|
||||
return json(res, turn.status === 'done' ? 200 : 500, { turnId: id, ...turn });
|
||||
}
|
||||
|
||||
json(res, 404, { error: 'not found' });
|
||||
} catch (error) {
|
||||
if (!res.headersSent) json(res, 500, { error: error.message });
|
||||
}
|
||||
}).listen(PORT, () => console.log(`agent-broker listening on :${PORT}`));
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
// Poll worker for per-turn Claude Code conversations on a codespace.
|
||||
// You cannot reach a codespace from outside. GitHub keeps forwarded ports
|
||||
// private. So this worker calls out. It polls n8n for a turn addressed to this
|
||||
// box's owner. It runs one `claude -p`. It sends the result to the turn's resume
|
||||
// URL. Every call is outbound HTTPS to n8n. It opens no inbound port.
|
||||
//
|
||||
// AGENT_WORKER_TOKEN=… N8N_DEQUEUE_URL=… node agent-worker.mjs
|
||||
//
|
||||
// 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)
|
||||
import { execFile } from 'node:child_process';
|
||||
import { resolve as resolvePath, sep } from 'node:path';
|
||||
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');
|
||||
|
||||
// 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.
|
||||
function posNum(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
console.error(`${name} is not a positive number ("${raw}"); using ${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);
|
||||
|
||||
for (const [k, v] of Object.entries({
|
||||
N8N_DEQUEUE_URL: DEQUEUE_URL,
|
||||
AGENT_WORKER_TOKEN: TOKEN,
|
||||
GITHUB_USER,
|
||||
})) {
|
||||
if (!v) {
|
||||
console.error(`Refusing to start: ${k} is not set.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function runClaude({ message, sessionId, cwd }) {
|
||||
const safeCwd = resolvePath(typeof cwd === 'string' && cwd ? cwd : `${ROOT}/n8n`);
|
||||
if (safeCwd !== ROOT && !safeCwd.startsWith(ROOT + sep))
|
||||
throw new Error(`cwd must be under ${ROOT}`);
|
||||
const args = ['-p', '--output-format', 'json', '--dangerously-skip-permissions'];
|
||||
if (sessionId) args.push('--resume', sessionId);
|
||||
args.push(message);
|
||||
return new Promise((res, rej) => {
|
||||
execFile(
|
||||
'claude',
|
||||
args,
|
||||
{ cwd: safeCwd, timeout: TURN_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
try {
|
||||
res(JSON.parse(stdout));
|
||||
} catch {
|
||||
if (err?.killed)
|
||||
rej(
|
||||
new Error(
|
||||
`The turn passed the ${Math.round(TURN_TIMEOUT_MS / 60_000)}-minute limit and stopped. It may have been in a build. Do a smaller step, or run a long build in its own turn.`,
|
||||
),
|
||||
);
|
||||
else rej(new Error(stderr?.trim() || err?.message || 'claude produced no output'));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function post(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
}
|
||||
|
||||
async function dequeue() {
|
||||
const res = await post(DEQUEUE_URL, { githubUser: GITHUB_USER, 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
|
||||
const turn = JSON.parse(text);
|
||||
return turn?.turnId ? turn : null;
|
||||
}
|
||||
|
||||
async function handle(turn) {
|
||||
let result;
|
||||
try {
|
||||
const r = await runClaude(turn);
|
||||
result = {
|
||||
turnId: turn.turnId,
|
||||
status: 'done',
|
||||
output: r.result ?? '',
|
||||
sessionId: r.session_id ?? turn.sessionId ?? '',
|
||||
};
|
||||
} catch (error) {
|
||||
result = {
|
||||
turnId: turn.turnId,
|
||||
status: 'error',
|
||||
output: error.message,
|
||||
sessionId: turn.sessionId ?? '',
|
||||
};
|
||||
}
|
||||
// Send the result to the turn's resume URL. This continues the waiting n8n
|
||||
// execution. Retry on a failed status or a network error, so a transient
|
||||
// failure does not drop the result and leave the turn to time out.
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const res = await post(turn.resumeUrl, result);
|
||||
if (res.ok) return;
|
||||
console.error(`turn ${turn.turnId}: result POST got HTTP ${res.status} (attempt ${attempt})`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`turn ${turn.turnId}: result POST failed (attempt ${attempt}): ${error.message}`,
|
||||
);
|
||||
}
|
||||
await sleep(2000 * attempt);
|
||||
}
|
||||
console.error(`turn ${turn.turnId}: result not delivered after 3 attempts`);
|
||||
}
|
||||
|
||||
console.log(`agent-worker polling as ${GITHUB_USER} every ${POLL_INTERVAL_MS}ms`);
|
||||
for (;;) {
|
||||
try {
|
||||
const turn = await dequeue();
|
||||
if (turn) {
|
||||
console.log(`turn ${turn.turnId}: ${turn.sessionId ? 'resume' : 'new'}`);
|
||||
await handle(turn);
|
||||
continue; // get the next turn now, with no delay
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`poll error: ${error.message}`);
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
@@ -17,21 +17,32 @@
|
||||
// needs `privileged: true` on the n8n service (compose-based config)
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
||||
},
|
||||
"forwardPorts": [8080, 5678, 8787],
|
||||
"portsAttributes": {
|
||||
"8787": { "label": "agent-broker" }
|
||||
"forwardPorts": [8080, 5678],
|
||||
// Grant the codespace's own GitHub token read access to the private skills
|
||||
// marketplace (same org), so Claude can install it with the codespace's normal
|
||||
// git auth — no extra PAT. Each user authorizes this once at codespace creation.
|
||||
"customizations": {
|
||||
"codespaces": {
|
||||
"repositories": {
|
||||
"n8n-io/n8n-claude-skills": { "permissions": { "contents": "read" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
// Runs in prebuilds, so a fresh codespace starts with deps installed and the build warm
|
||||
"onCreateCommand": "corepack install && pnpm install && (pnpm build > /tmp/build.log 2>&1 || tail -n 50 /tmp/build.log) && pnpm --filter=n8n-playwright exec playwright install chromium",
|
||||
// Start the broker on each container start. `bash -lc` loads the secrets shim, so the
|
||||
// broker gets AGENT_BROKER_TOKEN. tmux detaches it, so it does not block startup.
|
||||
"postStartCommand": "tmux new-session -d -s broker 'bash -lc \"node /workspaces/n8n/.devcontainer/codespaces/agent-broker.mjs >> /tmp/agent-broker.log 2>&1\"' || true",
|
||||
// On each container start: install the private skills marketplace into Claude
|
||||
// (best-effort) and start the poll worker. `bash -lc` loads the secrets shim,
|
||||
// so the worker gets its token. Logs: /tmp/post-start.log, /tmp/agent-worker.log.
|
||||
"postStartCommand": "bash -lc 'node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs >> /tmp/post-start.log 2>&1' || true",
|
||||
"secrets": {
|
||||
"ANTHROPIC_API_KEY": {
|
||||
"description": "Anthropic API key for Claude Code / OpenCode sessions"
|
||||
},
|
||||
"AGENT_BROKER_TOKEN": {
|
||||
"description": "Bearer token the agent broker requires on every request (see .devcontainer/codespaces/agent-broker.mjs)"
|
||||
"AGENT_WORKER_TOKEN": {
|
||||
"description": "The shared bearer token. The poll worker sends it to n8n on each dequeue. See .devcontainer/codespaces/agent-worker.mjs."
|
||||
},
|
||||
"N8N_DEQUEUE_URL": {
|
||||
"description": "n8n webhook the poll worker calls to claim a pending turn"
|
||||
},
|
||||
"CLAUDE_CODE_OAUTH_TOKEN": {
|
||||
"description": "Alternative to an API key: token from `claude setup-token` (Max subscription)"
|
||||
|
||||
@@ -26,5 +26,5 @@ services:
|
||||
DB_POSTGRESDB_HOST: postgres
|
||||
DB_TYPE: postgresdb
|
||||
DB_POSTGRESDB_PASSWORD: password
|
||||
# Prompt-cache TTL in seconds. 1 hour keeps the cache warm between broker turns.
|
||||
# Prompt-cache TTL in seconds. 1 hour keeps the cache warm between turns.
|
||||
ANTHROPIC_PROMPT_CACHE_TTL: "3600"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/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.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const MARKETPLACE = 'n8n-io/n8n-claude-skills';
|
||||
const PLUGIN = 'quality@n8n-claude-skills';
|
||||
|
||||
function tryRun(label, cmd, args) {
|
||||
try {
|
||||
execFileSync(cmd, args, { stdio: 'inherit' });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`${label}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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"',
|
||||
]);
|
||||
@@ -22,6 +22,7 @@
|
||||
"build:docker:test": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs && turbo run test:container:standard --filter=n8n-playwright",
|
||||
"typecheck": "turbo typecheck",
|
||||
"dev": "node scripts/dev-command-removed-notice.mjs",
|
||||
"dev:up": "node scripts/dev-up.mjs",
|
||||
"dev:be": "turbo run dev --env-mode=loose --log-prefix=none --filter=n8n",
|
||||
"dev:ai": "turbo run dev --concurrency=150 --env-mode=loose --filter=@n8n/n8n-nodes-langchain --filter=n8n --filter=n8n-core",
|
||||
"dev:fe": "run-p start dev:fe:editor",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
// Bring the app up with one command. This script installs missing dependencies,
|
||||
// builds if you ask, starts the backend, waits for health, and prints the URL.
|
||||
//
|
||||
// pnpm dev:up install if needed, start dev:be, print the URL
|
||||
// pnpm dev:up --build also run `pnpm build` first
|
||||
//
|
||||
// dev:be serves the editor from the `dist` build. A frontend change appears only
|
||||
// after `--build` and this restart. For live frontend hot reload, use
|
||||
// `pnpm dev:fe:editor` on 8080. That path needs `pnpm session tunnel 5678 8080`.
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { existsSync, openSync } from 'node:fs';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
const build = process.argv.includes('--build');
|
||||
const port = process.env.N8N_PORT ?? '5678';
|
||||
// Probe the same health path the backend serves (defaults to /healthz).
|
||||
const healthPath = process.env.N8N_ENDPOINT_HEALTH
|
||||
? `/${process.env.N8N_ENDPOINT_HEALTH.replace(/^\//, '')}`
|
||||
: '/healthz';
|
||||
const LOG = '/tmp/n8n-dev-be.log';
|
||||
const BUILD_LOG = '/tmp/dev-up-build.log';
|
||||
|
||||
if (!existsSync('node_modules') || !existsSync('packages/cli/node_modules')) {
|
||||
console.log('Installing dependencies…');
|
||||
execFileSync('pnpm', ['install'], { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
if (build) {
|
||||
console.log(`Building (turbo cache — fast when warm; log: ${BUILD_LOG})…`);
|
||||
const fd = openSync(BUILD_LOG, 'w');
|
||||
try {
|
||||
execFileSync('pnpm', ['build'], { stdio: ['ignore', fd, fd] });
|
||||
} catch {
|
||||
console.error(`Build failed — see ${BUILD_LOG}:`);
|
||||
execFileSync('tail', ['-n', '30', BUILD_LOG], { stdio: 'inherit' });
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Starting backend (pnpm dev:be; log: ${LOG})…`);
|
||||
const fd = openSync(LOG, 'a');
|
||||
spawn('pnpm', ['dev:be'], { detached: true, stdio: ['ignore', fd, fd] }).unref();
|
||||
|
||||
const deadline = Date.now() + 120_000;
|
||||
let healthy = false;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${healthPath}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
healthy = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
const name = process.env.CODESPACE_NAME;
|
||||
const url = name ? `https://${name}-${port}.app.github.dev` : `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.
|
||||
let orgShared = false;
|
||||
if (name) {
|
||||
try {
|
||||
execFileSync('gh', ['codespace', 'ports', 'visibility', `${port}:org`, '-c', name], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
orgShared = true;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
console.log(`\nUp: ${url}`);
|
||||
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)`,
|
||||
);
|
||||
Reference in New Issue
Block a user