Cross-Repo Worker Context Isolation
Playbook: What happens when a worker spawned for a foreign repo (e.g. radieu/et-operational-platform) runs in the wrong codebase and CLAUDE.md context — and how to fix it.
1. Current Behavior (as-of investigation, 2026-06-25)
spawn-worker.sh
# Line 81 — hardcoded, unconditional:
cd /opt/p24-infraThis is the only cd in the script. The worker process is always launched with /opt/p24-infra as its working directory, regardless of what REPO is passed as $4.
The prompt injected into the worker (line 78) includes repo=${REPO} so the Claude session knows the target repo, but it cannot do anything about the working directory — that is set by the shell before claude is invoked.
queue-dispatcher-loop.py
The repo field from dev_r_worker_queue is correctly extracted (line 225) and passed as the 4th positional argument to spawn-worker.sh (line 232). So the dispatcher does the right thing: it propagates the repo slug. The failure is entirely in spawn-worker.sh.
worker-issue.md (Step 1)
Step 1 instructs the worker to run:
git fetch origin
git checkout -b $BRANCH origin/devWith CWD = /opt/p24-infra, this creates the branch inside the p24-infra repo. For an et-operational-platform issue, the worker ends up:
- On a new branch of p24-infra instead of the target repo
- Editing files in
/opt/p24-infra/instead of the target repo’s codebase - With no access to the target repo’s
src/pages/,src/components/, etc.
What CLAUDE.md the worker actually sees
Claude Code auto-loads CLAUDE.md from the working directory and every parent directory up to the filesystem root. With CWD = /opt/p24-infra, the worker loads:
| File loaded | Role granted |
|---|---|
/opt/p24-infra/CLAUDE.md | Server admin, DevOps, Security Officer, MongoDB Admin |
/opt/CLAUDE.md (if exists) | — |
/CLAUDE.md (if exists) | — |
For an et-operational-platform issue, the worker should see:
“Next.js App Developer — No server access — infra issues escalate to a p24-infra session”
Instead it sees:
“Full SSH to all VPSes, Docker, Ansible, SOPS secrets, MongoDB rs0 admin…“
2. The Problem
| Dimension | Should see | Actually sees |
|---|---|---|
| CLAUDE.md role | Next.js App Developer | Server admin + SOPS secrets |
| Working directory | /tmp/worker-N-radieu-et-operational-platform | /opt/p24-infra |
| Codebase | src/pages/, src/components/ | monitoring/, scripts/, ansible/ |
| Git branch target | radieu/et-operational-platform | radieu/p24-infra |
| Type checker | npx tsc in Next.js project | npx tsc in infra repo (no tsconfig.json) |
Security implication: The worker handling a Next.js feature issue has full visibility into p24-infra’s operational patterns: SOPS file names, age key paths, VPS IPs, MongoDB credentials structure, SSH patterns. This violates principle of least privilege. If the worker makes a mistake (or is manipulated via prompt injection in the issue body), it could attempt server-side operations it should not have access to.
3. Recommended Fix: Option A — Clone Target Repo
Before spawning the worker, clone the target repo to a temp directory on the runner. Set CWD to the clone. The worker’s CLAUDE.md auto-load then sees the correct repo.
Rejected alternatives:
- Option B (worktree in existing clone): Requires all target repos pre-cloned on every runner (
/opt/et-operational-platform, etc.). Not currently the case; adds provisioning complexity. - Option C (inject CLAUDE.md as prefix): Partial fix only. Worker still runs in p24-infra codebase, finds wrong file paths, wrong
tsconfig.json, wrong tests. - Option D (separate runner pools): Complex multi-month effort; correct long-term direction but not the fix needed now.
4. Implementation
4a. scripts/spawn-worker.sh changes
After the RAM check (line 69) and before the PROMPT_FILE creation (line 76), add a conditional clone block. Replace the hardcoded cd /opt/p24-infra (line 81) with a conditional cd "$WORKDIR".
Key changes (applied in this PR):
-
Derive
WORKDIR:- Any repo →
/tmp/worker-${ISSUE_NUM}-${REPO_SLUG}with a per-issuegit clone
Update (#1689, 2026-06-27): the original split routed
radieu/p24-infrato the shared/opt/p24-infraclone (no clone). Concurrent p24-infra workers then raced on that single working tree — eachgit checkout -bswitched the active branch under the other (observed #1679 ↔ #1675). The special-case was removed: every repo, including p24-infra, now clones into its own/tmp/worker-*dir. The clone usesgit clone --depth=1 --no-single-branchsoorigin/dev(workers branch from it) andorigin/<branch>(resume path) are present — a plain single-branch--depth=1clone only fetches origin’s default branch (main)./opt/p24-infrais left untouched as the dispatcher’s reference clone. - Any repo →
-
Pass
GH_TOKENto the clone URL for private repos (already available on runners). -
In the systemd-run command, add
cd "${WORKDIR}" &&beforenice -n ... claudeso the Claude process starts in the correct directory. -
Add cleanup:
[ "${WORKDIR}" != "/opt/p24-infra" ] && rm -rf "${WORKDIR}"after claude exits.
4b. infra/agent-prompts/worker-issue.md — no changes required
The worker-issue.md Step 1 already uses $REPO for all GitHub API calls and git push origin $BRANCH. The git checkout commands run in CWD. Once CWD is the correct cloned repo, Step 1 will correctly create the branch in the target repo. No edits needed.
4c. GH_TOKEN availability on runners
GH_TOKEN is used in queue-dispatcher-loop.py for OOM escalation, confirmed exported. The spawn-worker.sh runs as claude-runner via sudo. Verify GH_TOKEN is in the environment accessible to bash invoked by systemd-run, or pass it explicitly from the runner’s env file (same pattern as MEZMO_SERVICE_KEY).
5. Verification Steps
# 1. Insert a test cross-repo row
# (run from any machine with Supabase credentials)
psql $SUPABASE_DB_URL -c "
INSERT INTO dev_r_worker_queue (issue_number, repo, job_type, weight, status, priority)
VALUES (1, 'radieu/et-operational-platform', 'dev-issue', 'light', 'queued', 99)
RETURNING id;
"
# 2. Let dispatcher run (or trigger manually)
# Wait for claimed → running transition
# 3. SSH to runner
ssh claude-runner@<runner-ip>
tail -f /var/log/p24-infra-workers/dev-issue-1.log
# 4. Confirm log shows:
# [clone] Cloning radieu/et-operational-platform to /tmp/worker-1-radieu-et-operational-platform
# (no SSH, no SOPS, no Docker operations in worker output)
# 5. Confirm CLAUDE.md loaded is et-operational-platform's
# (check worker session output for "Next.js App Developer" role indicators)
# 6. After worker exits, confirm temp dir cleaned up
ls /tmp/worker-1-* 2>/dev/null && echo "FAIL: not cleaned up" || echo "OK: cleaned up"6. Security Implications
Before fix: A worker for any cross-repo issue sees:
- Full p24-infra CLAUDE.md: SOPS structure, VPS IPs, SSH patterns, MongoDB topology, secret file names
- The
secrets/*.env.sopsfile listing (filesystem) - Ansible playbooks, monitoring config
After fix: A cross-repo worker sees:
- Only the target repo’s CLAUDE.md and codebase
- No access to p24-infra filesystem beyond what the
claudebinary itself needs - For et-operational-platform: “No server access — infra issues escalate to a p24-infra session”
This means a prompt-injection attack via issue body content cannot cause a Next.js worker to run SSH or SOPS commands — the CLAUDE.md guardrails are repo-correct.
7. Related Issues and PRs
- **PR 1436 — meta-dispatcher that inserts cross-repo issues into queue (prerequisite context)
dev_r_worker_queue.repocolumn — already stores target repo slug; fix uses this correctly- **Issue 1443 — this review/plan issue
8. Prevention
Once this fix is merged:
- Any new repo added to the worker routing must have its own
CLAUDE.mddefining the correct agent role. - The
spawn-worker.shclone logic ensures the role is always loaded from the correct repo. - When adding a new runner host: ensure
GH_TOKEN(withreporead scope) is in the runner’s environment, sourced by the same env file asMEZMO_SERVICE_KEY.
9. job_profile routing (PR #1849)
When spawn-worker.sh receives a job, it now selects the agent prompt based on job_profile (arg 9):
job_profile | Agent prompt | Repos |
|---|---|---|
nextjs-supabase-vercel | worker-nextjs-dev.md | et-operational-platform, brandpilot, Art-Agency |
python-fastapi | worker-python-dev.md | audit-engine, scripts |
content-automation | worker-content.md | radekkonarski-personal-brand |
kdp-python | worker-python-dev.md | amazon-kdp-tango |
infra-ops | worker-issue.md | p24-infra |
generic | worker-issue.md | unknown repos (safe fallback) |
job_profile is auto-derived from the repo name by the CF Worker (p24-meta-dispatcher) and stored in dev_r_worker_queue.job_profile. Callers can override by passing job_profile explicitly in the POST body.
SOPS routing: spawn-worker.sh now has graceful degradation for unknown repos (warn+continue instead of exit 1). Unknown repos start without secrets; workers will fail at the first secrets-dependent step with a clear error message.