Skip to content

fix(driver-owner): add OPC_DISABLE_OWNERSHIP=1 kill switch for non-Claude call sites#31

Open
WdBlink wants to merge 1 commit into
iamtouchskyer:mainfrom
WdBlink:fix/ownership-disable-env
Open

fix(driver-owner): add OPC_DISABLE_OWNERSHIP=1 kill switch for non-Claude call sites#31
WdBlink wants to merge 1 commit into
iamtouchskyer:mainfrom
WdBlink:fix/ownership-disable-env

Conversation

@WdBlink

@WdBlink WdBlink commented Jul 11, 2026

Copy link
Copy Markdown

fix(driver-owner): add OPC_DISABLE_OWNERSHIP=1 kill switch for non-Claude call sites

What

Adds an opt-out env var (OPC_DISABLE_OWNERSHIP=1) that restores the
pre-c304800 legacy behavior of checkOwnership() and resolveCallerIdentity().
When set, both functions return immediately with a non-stamped / allow result
without invoking findClaudeAncestorPid() or any fail-closed logic.

Why

c304800 ("Bind OPC loop to its owning Claude session", 2026-07-07) walks the
parent-process chain via ps -o ppid=,args= and treats any ancestor whose
argv matches /\bclaude\b/i as the owning Claude session. The fail-closed
design is correct for the case it targets: preventing a second Claude session
from driving a loop concurrently (the compaction double-drive bug).

It misfires on hosts where:

  1. Multiple Claude Code CLI instances run in parallel. ps may walk into
    a sibling process tree (or briefly expose a short-lived child of an
    unrelated Claude process during fork/exec) and stamp the loop with a
    claude_pid that is genuinely alive but not the caller's session.
    Every subsequent complete-tick then refuses with:

    not the loop owner — loop is owned by a live Claude session (pid 72118) — refusing to drive it
    
  2. The caller is not running under Claude Code at all (CI, batch harness,
    bash -c, for loop, manual opc-harness invocation from a shell that
    didn't go through Claude's fork). caller.claude_pid is null. The
    current logic then fail-closes if the recorded owner happens to still be
    alive — which is almost always the case in practice, because some other
    Claude session IS alive on the box. The pre-c304800 behavior was to
    allow in this situation (correctly: we cannot prove the caller is NOT
    the owner).

This is reproducible on a stock macOS dev box running 3+ Claude CLI
instances (a Yoda + a ttys015 + a ttys017 session in my case). Run
bash test/run-all.sh from a for-loop wrapper and watch 40 of 125 test
files flip from pass to fail with the same not the loop owner error.
Run the same test directly with bash test/<file>.sh and it passes.
The 40 failures are the second category; the first category is what makes
the test suite (and any other batch-style invocation) unusable in practice.

The fix

OPC_DISABLE_OWNERSHIP=1 opt-out. The kill switch is the minimum change
that:

  • preserves c304800's compaction-double-drive protection for the normal case
    (Claude Code invoking /opc <task>),
  • restores batch / CI / multi-Claude-host usability,
  • keeps the fail-closed default for everyone else.

The kill switch is the user's explicit opt-in. Without the env var, the
c304800 logic is unchanged.

I considered alternatives and rejected them:

  • Tighten findClaudeAncestorPid() to require claude_pid to be the
    direct parent of the harness's process.
    Doesn't fix the fork/exec
    race during which a transient child of an unrelated Claude process
    appears in the chain.
  • Track only the caller's own session identifier, ignore ps ancestry.
    Breaks the compaction-double-drive invariant the original commit was
    added to fix.
  • Whitelist by cwd / dir argument. Doesn't match the model: the
    cwd argument is about which loop to drive, not which session is
    allowed to drive it.

Verification

bash test/run-all.sh (125 test files, single host, macOS arm64):

v0.10.5 tag (backup) unpatched main patched main + OPC_DISABLE_OWNERSHIP=1
passed 85 85 (run-all wrapper) / 125 (per-file solo) 104
failed 40 40 (run-all wrapper) / 0 (per-file solo) 21
net baseline no regression vs v0.10.5 +19 pass, -19 fail

The 21 remaining failures on patched main are pre-existing on the
v0.10.5 tag (same count, same files, same lines when run with the
backup bin/opc-harness.mjs). They are not regressions introduced by
c304800 or by this patch; they are independent test bugs in opc's
own test suite. I verified by re-running each failing file with
HARNESS="node /path/to/backup/bin/opc-harness.mjs" — 21/21 reproduce
identically.

Without the env var, normal /opc <task> usage is unaffected (the
caller's claude_pid is resolved normally, the kill switch is not
consulted). Verified via a fresh init-loop → next-tick → complete-tick
sequence with the same plan format the opc test suite uses — passes
with verdict: FAIL as expected.

Patch

--- a/bin/lib/driver-owner.mjs
+++ b/bin/lib/driver-owner.mjs
@@ -110,6 +110,13 @@
 // ── Caller identity ─────────────────────────────────────────────
 export function resolveCallerIdentity() {
+  // Kill switch: OPC_DISABLE_OWNERSHIP=1 → pretend caller has no Claude ancestor
+  // (legacy behavior). Use for test harnesses, batch scripts, and hosts where
+  // `ps` walks the parent chain to an unrelated Claude process (race / multi-
+  // session hosts). checkOwnership() also short-circuits on the same env var.
+  if (process.env.OPC_DISABLE_OWNERSHIP === "1") {
+    return { claude_pid: null, claude_started_at: null, host: hostname() };
+  }
   const claude_pid = findClaudeAncestorPid();
   return {
     claude_pid,
@@ -162,6 +169,12 @@
 }

 export function checkOwnership(state, caller, opts = {}) {
   const owner = state && state._owner;
+
+  // Kill switch: OPC_DISABLE_OWNERSHIP=1 → legacy behavior (allow all). Use for
+  // test harnesses, batch scripts, and environments where `ps` walks the parent
+  // chain to a different, unrelated Claude process (race / multi-session hosts).
+  if (process.env.OPC_DISABLE_OWNERSHIP === "1") {
+    return { decision: "OWNER", reason: "ownership check disabled via OPC_DISABLE_OWNERSHIP=1" };
+  }

   // Legacy loop (pre-ownership) — no stamp to enforce.
   if (!owner || owner.claude_pid == null) {

How to test this PR

# 1. Run the test suite without the env var — should reproduce the bug
bash test/run-all.sh 2>&1 | tail -1
# Expected: "Suite: 85 files passed, 40 files failed"

# 2. Run with the kill switch — should pass
OPC_DISABLE_OWNERSHIP=1 bash test/run-all.sh 2>&1 | tail -1
# Expected: "Suite: 104 files passed, 21 files failed"

# 3. The 21 remaining failures should reproduce on v0.10.5 tag identically.
#    If they don't, this PR has introduced a regression.
git checkout v0.10.5
bash test/test-mandatory-role.sh  # same ❌ "mandatoryMissing still populated"

I considered documenting OPC_DISABLE_OWNERSHIP in the README's
"Environment variables" section but left that out — happy to add it
in a follow-up if you'd like.

c30480 walks the parent-process chain via `ps` and treats any
ancestor whose argv matches `/\bclaude\b/i` as the owning Claude
session. On hosts that run multiple Claude Code CLI instances in
parallel, or when opc-harness is invoked through a bash wrapper
(`for` loop, `bash -c`, function call), `ps` may briefly expose a
short-lived child of an unrelated Claude process in the parent
chain. The loop is then stamped with the wrong claude_pid, and
every subsequent complete-tick / next-tick fails closed with
"not the loop owner".

Add an opt-out env var that restores the pre-c30480 legacy
behavior for non-Claude call sites (CI, batch harness, manual
admin). Real `/opc <task>` use from Claude Code is unaffected —
the kill switch is only consulted when the env var is set.

Verified: bash test/run-all.sh goes from 85 pass / 40 fail
(unpatched) to 104 pass / 21 fail with the kill switch. The
21 remaining failures are pre-existing on the v0.10.5 tag and
are not regressions introduced by c30480 or by this patch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant