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-infra

This 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/dev

With 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 loadedRole granted
/opt/p24-infra/CLAUDE.mdServer 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

DimensionShould seeActually sees
CLAUDE.md roleNext.js App DeveloperServer admin + SOPS secrets
Working directory/tmp/worker-N-radieu-et-operational-platform/opt/p24-infra
Codebasesrc/pages/, src/components/monitoring/, scripts/, ansible/
Git branch targetradieu/et-operational-platformradieu/p24-infra
Type checkernpx tsc in Next.js projectnpx 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.


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):

  1. Derive WORKDIR:

    • Any repo → /tmp/worker-${ISSUE_NUM}-${REPO_SLUG} with a per-issue git clone

    Update (#1689, 2026-06-27): the original split routed radieu/p24-infra to the shared /opt/p24-infra clone (no clone). Concurrent p24-infra workers then raced on that single working tree — each git checkout -b switched 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 uses git clone --depth=1 --no-single-branch so origin/dev (workers branch from it) and origin/<branch> (resume path) are present — a plain single-branch --depth=1 clone only fetches origin’s default branch (main). /opt/p24-infra is left untouched as the dispatcher’s reference clone.

  2. Pass GH_TOKEN to the clone URL for private repos (already available on runners).

  3. In the systemd-run command, add cd "${WORKDIR}" && before nice -n ... claude so the Claude process starts in the correct directory.

  4. 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.sops file 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 claude binary 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.


  • **PR 1436 — meta-dispatcher that inserts cross-repo issues into queue (prerequisite context)
  • dev_r_worker_queue.repo column — already stores target repo slug; fix uses this correctly
  • **Issue 1443 — this review/plan issue

8. Prevention

Once this fix is merged:

  1. Any new repo added to the worker routing must have its own CLAUDE.md defining the correct agent role.
  2. The spawn-worker.sh clone logic ensures the role is always loaded from the correct repo.
  3. When adding a new runner host: ensure GH_TOKEN (with repo read scope) is in the runner’s environment, sourced by the same env file as MEZMO_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_profileAgent promptRepos
nextjs-supabase-vercelworker-nextjs-dev.mdet-operational-platform, brandpilot, Art-Agency
python-fastapiworker-python-dev.mdaudit-engine, scripts
content-automationworker-content.mdradekkonarski-personal-brand
kdp-pythonworker-python-dev.mdamazon-kdp-tango
infra-opsworker-issue.mdp24-infra
genericworker-issue.mdunknown 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.