diff --git a/.devcontainer/codespaces/README.md b/.devcontainer/codespaces/README.md index a0de4be5592..df35b064bbb 100644 --- a/.devcontainer/codespaces/README.md +++ b/.devcontainer/codespaces/README.md @@ -127,12 +127,22 @@ 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) +## Quality and security skills (Claude plugins) -Claude sessions can also load the private quality skills from the -`n8n-io/n8n-agent-skills` repository (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. +Claude sessions can also load the private skills from the +`n8n-io/n8n-agent-skills` repository. `post-start.mjs` installs both plugins on +each container start, so every session gets the skills with no per-session step: + +- `quality` — bug insights, defect attribution, flaky test investigation, + mutation and property testing, PR council, and more. +- `security` — security code review, adversarial review of security-fix PRs, + regression test generation, and Security Hub report triage. + +Together they add roughly 5k always-on tokens to every session. Drop a plugin +from `PLUGINS` in `plugins.mjs` if that budget matters more than the skills. +`post-start.mjs` and the `pnpm session` prelude in `scripts/cloud-session.mjs` +both read that list, so a session that races the container start still gets +every plugin. The private marketplace uses the codespace's own GitHub auth — no extra token. `devcontainer.json` grants the codespace read access to @@ -147,9 +157,34 @@ 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. +need a recreate to get the prompt. + +### When the skills are missing + +`/tmp/post-start-status.json` lists what installed and what did not, and +`/tmp/post-start.log` has the detail. Reading the log: + +- A failed `skills repo reachable` line means the grant was not authorized. +- A `marketplace add` failure mentioning `File exists` is a clone that died + partway through `~/.claude/plugins/marketplaces/n8n-io-n8n-agent-skills`, the + path the loader stages into before renaming it to the cache. This has been + seen once as a transient failure, so the script removes that path and retries + the add once. +- Any other `marketplace add` failure after a reachable repo means a + loader-auth problem. + +A failure that survives the retry needs a human — the container still starts and +the worker still runs, only the skills are missing. + +Both `marketplace add` and `plugin install` are idempotent, so re-running the +script by hand is safe: + +```bash +node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs +``` + +Verify with `claude plugin list`, then restart the session (or `/reload-plugins`) +to pull the skills into context. ## Viewing the dev UI locally diff --git a/.devcontainer/codespaces/plugins.mjs b/.devcontainer/codespaces/plugins.mjs new file mode 100644 index 00000000000..5fab84f8a80 --- /dev/null +++ b/.devcontainer/codespaces/plugins.mjs @@ -0,0 +1,5 @@ +// Shared by post-start.mjs (installs at container start) and scripts/cloud-session.mjs +// (re-installs in the session prelude). Both must agree or a session boots with a +// partial skill set. +export const MARKETPLACE = 'n8n-io/n8n-agent-skills'; +export const PLUGINS = ['quality@n8n-agent-skills', 'security@n8n-agent-skills']; diff --git a/.devcontainer/codespaces/post-start.mjs b/.devcontainer/codespaces/post-start.mjs index 653e66c4f3b..5575911de10 100644 --- a/.devcontainer/codespaces/post-start.mjs +++ b/.devcontainer/codespaces/post-start.mjs @@ -1,9 +1,21 @@ #!/usr/bin/env node // Runs on each codespace start. Installs the skills marketplace, starts the worker. import { execFileSync } from 'node:child_process'; +import { rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { MARKETPLACE, PLUGINS } from './plugins.mjs'; -const MARKETPLACE = 'n8n-io/n8n-agent-skills'; -const PLUGIN = 'quality@n8n-agent-skills'; +const STATUS_FILE = '/tmp/post-start-status.json'; + +// Claude Code stages the marketplace clone here before renaming it to the cache dir. +// A clone that dies partway has been seen to leave the staging path in a state that +// fails the next clone ("cannot copy ... File exists"), so clear it before retrying. +const MARKETPLACE_STAGING = join( + homedir(), + '.claude/plugins/marketplaces', + MARKETPLACE.replace('/', '-'), +); // 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. @@ -20,10 +32,43 @@ function tryRun(label, cmd, args) { } } +// Both commands are idempotent and exit 0 when the marketplace or plugin is already +// present, so a healthy start re-runs them cheaply and only a real failure retries. +function addMarketplace() { + const add = (label) => tryRun(label, 'claude', ['plugin', 'marketplace', 'add', MARKETPLACE]); + if (add('marketplace add')) return true; + + console.error(`marketplace add: clearing ${MARKETPLACE_STAGING} and retrying once`); + try { + rmSync(MARKETPLACE_STAGING, { recursive: true, force: true }); + } catch (error) { + console.error(`marketplace staging cleanup: ${error.message}`); + return false; + } + return add('marketplace add (retry)'); +} + 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]); +const installed = []; +const failed = []; +if (addMarketplace()) { + for (const plugin of PLUGINS) { + const ok = tryRun(`plugin install ${plugin}`, 'claude', ['plugin', 'install', plugin]); + (ok ? installed : failed).push(plugin); + } +} else { + failed.push(...PLUGINS); +} + +writeFileSync(STATUS_FILE, JSON.stringify({ installed, failed }, null, 2)); + +// A skipped install is otherwise invisible until someone misses a skill mid-session. +if (failed.length > 0) { + console.error(`\n!! SKILLS NOT INSTALLED: ${failed.join(', ')}`); + console.error('!! Sessions start without them. Retry with:'); + console.error('!! node /workspaces/n8n/.devcontainer/codespaces/post-start.mjs\n'); +} tryRun('worker start', 'tmux', [ 'new-session', diff --git a/scripts/cloud-session.mjs b/scripts/cloud-session.mjs index c02cf0612ff..c808bf60423 100644 --- a/scripts/cloud-session.mjs +++ b/scripts/cloud-session.mjs @@ -11,6 +11,8 @@ // pnpm session rm delete the codespace import { execFileSync, spawnSync } from 'node:child_process'; +import { MARKETPLACE, PLUGINS } from '../.devcontainer/codespaces/plugins.mjs'; + const REPO = 'n8n-io/n8n'; const DEVCONTAINER = '.devcontainer/codespaces/devcontainer.json'; const MACHINE = 'premiumLinux'; // 8-core/32GB — the only size we use @@ -73,26 +75,28 @@ const SECRETS = '. /usr/local/lib/codespaces-env.sh 2>/dev/null || true'; // Worktrees share the pnpm store but not the turbo cache; a shared TURBO_CACHE_DIR // (seeded from the main checkout) keeps new-worktree builds at cache-hit speed. const CACHE = 'export TURBO_CACHE_DIR=/workspaces/.turbo-cache; [ -d "$TURBO_CACHE_DIR" ] || cp -r /workspaces/n8n/.turbo/cache "$TURBO_CACHE_DIR" 2>/dev/null || mkdir -p "$TURBO_CACHE_DIR"'; -// On a freshly created codespace, post-start.mjs installs the skills plugin via a +// On a freshly created codespace, post-start.mjs installs the skills plugins via a // network clone that takes tens of seconds. If `claude` boots first it builds its -// skill registry before the plugin exists on disk, and /reload-plugins can't -// recover it in-process — so the first session silently loses every quality skill. -// Run the idempotent install here to block until the plugin is on disk (a fast +// skill registry before a plugin exists on disk, and /reload-plugins can't +// recover it in-process — so the first session silently loses those skills. +// Run the idempotent installs here to block until every plugin is on disk (a fast // no-op once cached). Mirrors the env vars post-start.mjs sets for the private // HTTPS clone; failures are tolerated so a plugin hiccup never blocks the session. -const MARKETPLACE = 'n8n-io/n8n-agent-skills'; -const PLUGIN = 'quality@n8n-agent-skills'; -const ENSURE_PLUGIN = `export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1; claude plugin marketplace add ${MARKETPLACE} >/dev/null 2>&1 || true; claude plugin install ${PLUGIN} >/dev/null 2>&1 || true`; +const ENSURE_PLUGINS = [ + 'export CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1 CLAUDE_CODE_PLUGIN_KEEP_MARKETPLACE_ON_FAILURE=1', + `claude plugin marketplace add ${MARKETPLACE} >/dev/null 2>&1 || true`, + ...PLUGINS.map((plugin) => `claude plugin install ${plugin} >/dev/null 2>&1 || true`), +].join('; '); function remoteCommand(session, extraArgs) { const claude = `claude ${extraArgs}`.trim(); - if (session === 'agent') return `${SECRETS}; ${CACHE}; ${ENSURE_PLUGIN}; cd /workspaces/n8n && ${claude}`; + if (session === 'agent') return `${SECRETS}; ${CACHE}; ${ENSURE_PLUGINS}; cd /workspaces/n8n && ${claude}`; const wt = `/workspaces/wt-${session}`; const branch = `session/${session}`; return [ SECRETS, CACHE, - ENSURE_PLUGIN, + ENSURE_PLUGINS, `if [ ! -d "${wt}" ]; then echo "Setting up worktree ${wt}…"`, `git -C /workspaces/n8n worktree add "${wt}" -b "${branch}" 2>/dev/null || git -C /workspaces/n8n worktree add "${wt}" "${branch}"`, `(cd "${wt}" && pnpm install); fi`,