← back to Terminal Status
Narrow the CHILD_SESSION paint guard so a bridge session that owns its own tty can paint (TK-12167)
5933ea13f0a4f98480f51f7cbddec57aeade1cb1 · 2026-09-24 14:22:01 -0700 · Steve Abrams
owner_for_paint() refused ANY caller carrying CLAUDE_CODE_CHILD_SESSION
unconditionally -- correct for a true Agent-tool subagent (TK-11791's
same-PID mirror bug) but also caught a Remote Control (bridge) session
that genuinely owns its own pane, since that flag is carried in both
cases. Live evidence: pid 33946 on ttys003 resolved owner ==
{tty: ttys003, pid: 33946} (itself) via current_owner(), yet every
dot-script and `ticket` bind was refused for an hour, leaving the tab
on a stale ticket label.
Fix: add _owns_tty_directly(), which reuses the existing
ancestors()/current_owner() machinery to verify the resolved owner is
our own closest runtime ancestor with no second claude/codex process
in between (always true today once current_owner() succeeds -- kept
explicit for defense-in-depth). Rescue a CHILD_SESSION-tagged paint
only when that check passes AND CLAUDE_CODE_BRIDGE_SESSION_ID is also
present -- a marker set only for a genuine bridge connection, never a
plain internal subagent call, so TK-11791's existing "subagent
REFUSES" fixture (byte-identical ancestry, no bridge id) is unchanged.
Undeterminable ownership still fails closed regardless of the bridge
marker.
Tests: extended both `selftest` and test_terminal_status.py with the
three required cases -- (a) CHILD_SESSION + bridge id + direct
ownership -> paints (confirmed this fails against the pre-fix guard
via a scratch copy), (b) CHILD_SESSION + bridge id + a genuine
intermediate claude/codex process -> still refused, (c) undeterminable
ownership -> still refused. Full selftest 14/14 and the unittest suite
(test_terminal_status.py + test_stop_verdict.py +
test_tk_required_fp_metric.py, 114 tests) all green.
Live-verified from the actual affected bridge session (pid 33946,
ttys003): `current --paintable` now exits 0, `greendot.sh` paints
successfully, and `terminal_status.py ticket 12167` binds directly
with no --tty/--force workaround.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V29xaoBwcFwJyR4KC43ptn
Files touched
M terminal_status.pyM test_terminal_status.py
Diff
commit 5933ea13f0a4f98480f51f7cbddec57aeade1cb1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Sep 24 14:22:01 2026 -0700
Narrow the CHILD_SESSION paint guard so a bridge session that owns its own tty can paint (TK-12167)
owner_for_paint() refused ANY caller carrying CLAUDE_CODE_CHILD_SESSION
unconditionally -- correct for a true Agent-tool subagent (TK-11791's
same-PID mirror bug) but also caught a Remote Control (bridge) session
that genuinely owns its own pane, since that flag is carried in both
cases. Live evidence: pid 33946 on ttys003 resolved owner ==
{tty: ttys003, pid: 33946} (itself) via current_owner(), yet every
dot-script and `ticket` bind was refused for an hour, leaving the tab
on a stale ticket label.
Fix: add _owns_tty_directly(), which reuses the existing
ancestors()/current_owner() machinery to verify the resolved owner is
our own closest runtime ancestor with no second claude/codex process
in between (always true today once current_owner() succeeds -- kept
explicit for defense-in-depth). Rescue a CHILD_SESSION-tagged paint
only when that check passes AND CLAUDE_CODE_BRIDGE_SESSION_ID is also
present -- a marker set only for a genuine bridge connection, never a
plain internal subagent call, so TK-11791's existing "subagent
REFUSES" fixture (byte-identical ancestry, no bridge id) is unchanged.
Undeterminable ownership still fails closed regardless of the bridge
marker.
Tests: extended both `selftest` and test_terminal_status.py with the
three required cases -- (a) CHILD_SESSION + bridge id + direct
ownership -> paints (confirmed this fails against the pre-fix guard
via a scratch copy), (b) CHILD_SESSION + bridge id + a genuine
intermediate claude/codex process -> still refused, (c) undeterminable
ownership -> still refused. Full selftest 14/14 and the unittest suite
(test_terminal_status.py + test_stop_verdict.py +
test_tk_required_fp_metric.py, 114 tests) all green.
Live-verified from the actual affected bridge session (pid 33946,
ttys003): `current --paintable` now exits 0, `greendot.sh` paints
successfully, and `terminal_status.py ticket 12167` binds directly
with no --tty/--force workaround.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V29xaoBwcFwJyR4KC43ptn
---
terminal_status.py | 122 ++++++++++++++++++++++++++++++++++++++++++------
test_terminal_status.py | 68 ++++++++++++++++++++++++++-
2 files changed, 176 insertions(+), 14 deletions(-)
diff --git a/terminal_status.py b/terminal_status.py
index 8ae94fe..e538186 100644
--- a/terminal_status.py
+++ b/terminal_status.py
@@ -457,6 +457,25 @@ def paint_forced():
return os.environ.get("CLAUDE_COLORDOTS_FORCE") == "1"
+def _owns_tty_directly(rows, owner):
+ """True iff OUR OWN process ancestry resolves `owner` directly, with no
+ second claude/codex process anywhere between us and it.
+
+ current_owner()'s ancestors() walk only ever inspects the CLOSEST
+ runtime-having ancestor: it returns on that ancestor's first match (a
+ verified sole live tty owner) or raises (a headless/nested-through-a-
+ separate-process agent) -- it never walks PAST a non-owning claude/codex
+ to find a further one. So whenever current_owner() succeeds at all, the
+ returned owner is, by construction, that single closest ancestor -- this
+ is therefore always true after a successful current_owner() call today.
+ Kept as an explicit, defensive re-check (not inferred from "it didn't
+ raise") so the invariant stays enforced even if current_owner()'s
+ implementation changes later, per the TK-12167 fix below.
+ """
+ runtime_ancestors = [p for p in ancestors(os.getpid(), rows) if p.runtime]
+ return len(runtime_ancestors) == 1 and runtime_ancestors[0].owner() == owner
+
+
def owner_for_paint(rows, force=False, boot=False, refetch=processes):
# CANONICAL paintability guard — the SINGLE decision both engines defer to
# (color.sh's /color hue painter AND this engine's dot setters), so they
@@ -467,11 +486,32 @@ def owner_for_paint(rows, force=False, boot=False, refetch=processes):
# bug: an Agent-tool subagent / bridge session runs INSIDE the parent claude
# process, so its ancestry is byte-identical to the parent's — same
# CLAUDE_PID, same ttys — and current_owner happily resolves the PARENT's
- # tty (proven live TK-11791). Painting then corrupts Steve's live pane. The
- # only bash-level signal that a run is a child/bridge context is
- # CLAUDE_CODE_CHILD_SESSION, which a normally-launched top-level tab leaves
- # UNSET (memory colordots-sweeps-refuse-bridge-sessions). So the rail is:
- # refuse an own-session paint whenever that flag is set.
+ # tty (proven live TK-11791). Painting a genuine same-PID subagent then
+ # corrupts Steve's live pane, so CLAUDE_CODE_CHILD_SESSION (set on any
+ # tool-spawned shell that is NOT the top-level's own direct turn) has to
+ # stay part of the rail for that class.
+ #
+ # TK-12167: that same flag is ALSO carried by a Remote Control (bridge)
+ # session that genuinely OWNS its own pane — alongside
+ # CLAUDE_CODE_BRIDGE_SESSION_ID and CLAUDE_CODE_ENTRYPOINT — so the flag
+ # alone over-refused it too (proven live: pid 33946 on ttys003, `current`
+ # already resolves owner == {tty: ttys003, pid: 33946}, the session's OWN
+ # process, yet every dot-script paint was refused for an hour). Narrow the
+ # refusal from "the flag is set" to "the flag is set AND we are not a
+ # verified direct-owning bridge": a bridge session is identified by
+ # CLAUDE_CODE_BRIDGE_SESSION_ID (set only for a genuine Remote Control
+ # connection, never for a plain internal Agent-tool subagent call), and
+ # "direct-owning" is the _owns_tty_directly() ownership check above —
+ # current_owner() having resolved OUR OWN ancestry straight to its live
+ # sole tty owner, with no other claude/codex process sitting in between.
+ # A true subagent or a genuinely nested/headless child claude process
+ # fails one or both of those: it carries no CLAUDE_CODE_BRIDGE_SESSION_ID
+ # of its own (same-PID mirror-bug case — TK-11791's own selftest fixture),
+ # or current_owner() has already raised on a real intermediate process
+ # before this guard is even reached (the nested-through-a-separate-process
+ # case) — so it is still refused either way. Ownership that cannot be
+ # determined at all (current_owner() raising) is always refused, never
+ # rescued by the bridge marker — fail CLOSED.
#
# boot=True bypasses the rail: a SessionStart/UserPromptSubmit/Stop HOOK only
# ever fires for a real pane-owning session, NEVER for a subagent, so a
@@ -481,11 +521,14 @@ def owner_for_paint(rows, force=False, boot=False, refetch=processes):
# KNOWN to own its pane. Neither loosens the headless/nested refusal above.
owner = current_owner(rows, refetch=refetch)
if not boot and not force and not paint_forced() and os.environ.get("CLAUDE_CODE_CHILD_SESSION"):
- raise StatusError(
- "subagent/bridge context; refusing to paint (it would write to the "
- "parent session's live pane). Use --tty for an external verified "
- "target, --boot from a session hook, or --force if this session owns "
- "its pane.")
+ is_verified_bridge = (bool(os.environ.get("CLAUDE_CODE_BRIDGE_SESSION_ID"))
+ and _owns_tty_directly(rows, owner))
+ if not is_verified_bridge:
+ raise StatusError(
+ "subagent/bridge context; refusing to paint (it would write to the "
+ "parent session's live pane). Use --tty for an external verified "
+ "target, --boot from a session hook, or --force if this session owns "
+ "its pane.")
return owner
@@ -509,8 +552,14 @@ def selftest():
print((" PASS " if ok else " FAIL ") + name)
# 1. Top-level (flag UNSET) → PAINTS the resolved owner.
- env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CODE_CHILD_SESSION"
- and k != "CLAUDE_COLORDOTS_FORCE"}
+ # TK-12167: also scrub CLAUDE_CODE_BRIDGE_SESSION_ID from the baseline so
+ # this selftest is hermetic when it happens to run INSIDE a real bridge
+ # session's own shell (it does, routinely) — without this, cases 1-4 below
+ # would silently inherit a real bridge id from the ambient environment and
+ # the new bridge-only cases 5-7 would prove nothing.
+ env = {k: v for k, v in os.environ.items()
+ if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+ "CLAUDE_CODE_BRIDGE_SESSION_ID")}
with patch_environ(env):
try:
got = owner_for_paint(rows, refetch=refetch)
@@ -518,7 +567,11 @@ def selftest():
except StatusError as exc:
case("top-level interactive session PAINTS (got refusal: %s)" % exc, False)
- # 2. Subagent/bridge (flag SET) → REFUSES, even though ancestry resolves the parent.
+ # 2. Subagent/bridge (flag SET, NO bridge id) → REFUSES, even though ancestry
+ # resolves the parent. This fixture is deliberately identical in ps
+ # ancestry to case 1 -- it models TK-11791's real same-PID mirror bug (an
+ # Agent-tool subagent carries CLAUDE_CODE_CHILD_SESSION but never its own
+ # CLAUDE_CODE_BRIDGE_SESSION_ID), so it must keep failing after TK-12167.
with patch_environ(dict(env, CLAUDE_CODE_CHILD_SESSION="1")):
try:
got = owner_for_paint(rows, refetch=refetch)
@@ -540,6 +593,49 @@ def selftest():
except StatusError as exc:
case("--force opt-in PAINTS (got refusal: %s)" % exc, False)
+ # TK-12167 (a): CHILD_SESSION set + a genuine bridge id + the caller owns the
+ # tty DIRECTLY (same rows as cases 1/2 -- pid 33946 on ttys003 live evidence)
+ # → PAINTS. This is the exact case that regressed before the fix above: with
+ # only the guard's ORIGINAL unconditional flag check, this assertion fails.
+ with patch_environ(dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+ CLAUDE_CODE_BRIDGE_SESSION_ID="session_test")):
+ try:
+ got = owner_for_paint(rows, refetch=refetch)
+ case("bridge session owning its tty directly PAINTS", got == want)
+ except StatusError as exc:
+ case("bridge session owning its tty directly PAINTS (got refusal: %s)" % exc, False)
+
+ # TK-12167 (b): CHILD_SESSION set + a genuine bridge id, but a real
+ # INTERMEDIATE claude process (its own separate pid, detached tty) sits
+ # between the caller and the tty owner → still REFUSED. The bridge marker
+ # never rescues a genuinely nested/headless child process.
+ b_owner = Process(4242, 1, "ttys098", "Wed Sep 9 08:35:08 2026", "/usr/bin/claude")
+ b_intermediate = Process(9000, 4242, "??", "Wed Sep 9 08:35:08 2026", "/usr/bin/claude")
+ b_me = Process(pid, 9000, "??", "Wed Sep 9 08:35:08 2026", "python3")
+ b_rows = {4242: b_owner, 9000: b_intermediate, pid: b_me}
+ b_refetch = lambda **_: b_rows
+ with patch_environ(dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+ CLAUDE_CODE_BRIDGE_SESSION_ID="session_test")):
+ try:
+ got = owner_for_paint(b_rows, refetch=b_refetch)
+ case("intermediate claude process REFUSES (painted %s instead!)" % (got.tty,), False)
+ except StatusError:
+ case("intermediate claude process REFUSES", True)
+
+ # TK-12167 (c): CHILD_SESSION set + a genuine bridge id, but ownership is
+ # UNDETERMINABLE (no claude/codex anywhere in ancestry at all) → REFUSED.
+ # Fails CLOSED: the bridge marker alone is never sufficient.
+ c_me = Process(pid, 1, "??", "Wed Sep 9 08:35:08 2026", "python3")
+ c_rows = {pid: c_me}
+ c_refetch = lambda **_: c_rows
+ with patch_environ(dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+ CLAUDE_CODE_BRIDGE_SESSION_ID="session_test")):
+ try:
+ got = owner_for_paint(c_rows, refetch=c_refetch)
+ case("undeterminable ownership REFUSES (painted %s instead!)" % (got.tty,), False)
+ except StatusError:
+ case("undeterminable ownership REFUSES", True)
+
# 5. TK-11870 backfill: (a) repaints an owner WITH a valid record, (b) NEVER
# flattens that valid gated dot to green (the key safety invariant — the
# injected fault is a purple/gated tab that must survive a sweep), and (c)
diff --git a/test_terminal_status.py b/test_terminal_status.py
index f354487..f48befe 100644
--- a/test_terminal_status.py
+++ b/test_terminal_status.py
@@ -527,13 +527,18 @@ class StatusTests(unittest.TestCase):
# contexts (a subagent runs inside the parent process); only the
# CLAUDE_CODE_CHILD_SESSION flag distinguishes them, so the flag is the
# rail. Mirrors the CLI `selftest`, kept here so the standard suite covers it.
+ # TK-12167: CLAUDE_CODE_BRIDGE_SESSION_ID is also scrubbed from the
+ # baseline `env` so this stays hermetic when the suite runs INSIDE a
+ # real bridge session's own shell (routine) -- otherwise the ambient
+ # bridge id would leak into the "subagent" case below and flip it.
owner_proc = ts.Process(4242, 1, "ttys099", self.owner.started, "/usr/bin/claude")
me = ts.Process(os.getpid(), 4242, "??", self.owner.started, "python3")
rows = {4242: owner_proc, os.getpid(): me}
refetch = lambda **_: rows
want = owner_proc.owner()
env = {k: v for k, v in os.environ.items()
- if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE")}
+ if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+ "CLAUDE_CODE_BRIDGE_SESSION_ID")}
with patch.dict(os.environ, env, clear=True):
self.assertEqual(ts.owner_for_paint(rows, refetch=refetch), want)
with patch.dict(os.environ, dict(env, CLAUDE_CODE_CHILD_SESSION="1"), clear=True):
@@ -546,6 +551,67 @@ class StatusTests(unittest.TestCase):
clear=True):
self.assertEqual(ts.owner_for_paint(rows, refetch=refetch), want)
+ def test_owner_for_paint_bridge_session_paints_when_it_owns_its_tty_directly(self):
+ # TK-12167 (a). Live-evidenced bug: pid 33946 on ttys003, a Remote
+ # Control bridge session, resolves owner == its own process directly
+ # (same rows shape as the mirror-bug fixture above) yet was refused
+ # solely because CLAUDE_CODE_CHILD_SESSION was set. The additional
+ # CLAUDE_CODE_BRIDGE_SESSION_ID marker (never carried by a plain
+ # internal Agent-tool subagent) now lets a session that verifiably
+ # owns its own pane paint.
+ owner_proc = ts.Process(4242, 1, "ttys099", self.owner.started, "/usr/bin/claude")
+ me = ts.Process(os.getpid(), 4242, "??", self.owner.started, "python3")
+ rows = {4242: owner_proc, os.getpid(): me}
+ refetch = lambda **_: rows
+ want = owner_proc.owner()
+ env = {k: v for k, v in os.environ.items()
+ if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+ "CLAUDE_CODE_BRIDGE_SESSION_ID")}
+ with patch.dict(os.environ,
+ dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+ CLAUDE_CODE_BRIDGE_SESSION_ID="session_test"),
+ clear=True):
+ self.assertEqual(ts.owner_for_paint(rows, refetch=refetch), want)
+
+ def test_owner_for_paint_bridge_marker_never_rescues_an_intermediate_agent(self):
+ # TK-12167 (b). A genuinely nested/headless child claude process (its
+ # own separate pid, no tty of its own) sitting between the caller and
+ # the real tty owner must stay refused even with a bridge id present --
+ # the marker identifies bridge IDENTITY, not a free pass past a real
+ # intermediate agent. current_owner() already raises for this shape
+ # (a headless/nested-through-a-separate-process agent), before
+ # owner_for_paint's bridge check is ever reached.
+ owner_proc = ts.Process(4242, 1, "ttys098", self.owner.started, "/usr/bin/claude")
+ intermediate = ts.Process(9000, 4242, "??", self.owner.started, "/usr/bin/claude")
+ me = ts.Process(os.getpid(), 9000, "??", self.owner.started, "python3")
+ rows = {4242: owner_proc, 9000: intermediate, os.getpid(): me}
+ refetch = lambda **_: rows
+ env = {k: v for k, v in os.environ.items()
+ if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+ "CLAUDE_CODE_BRIDGE_SESSION_ID")}
+ with patch.dict(os.environ,
+ dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+ CLAUDE_CODE_BRIDGE_SESSION_ID="session_test"),
+ clear=True):
+ with self.assertRaises(ts.StatusError):
+ ts.owner_for_paint(rows, refetch=refetch)
+
+ def test_owner_for_paint_bridge_marker_never_rescues_undeterminable_ownership(self):
+ # TK-12167 (c). No claude/codex anywhere in ancestry at all (ownership
+ # cannot be determined) → refused, fail CLOSED, even with a bridge id.
+ me = ts.Process(os.getpid(), 1, "??", self.owner.started, "python3")
+ rows = {os.getpid(): me}
+ refetch = lambda **_: rows
+ env = {k: v for k, v in os.environ.items()
+ if k not in ("CLAUDE_CODE_CHILD_SESSION", "CLAUDE_COLORDOTS_FORCE",
+ "CLAUDE_CODE_BRIDGE_SESSION_ID")}
+ with patch.dict(os.environ,
+ dict(env, CLAUDE_CODE_CHILD_SESSION="1",
+ CLAUDE_CODE_BRIDGE_SESSION_ID="session_test"),
+ clear=True):
+ with self.assertRaises(ts.StatusError):
+ ts.owner_for_paint(rows, refetch=refetch)
+
def test_paint_failure_is_reported_and_auditable(self):
def fail(*args):
raise OSError("fixture: unavailable terminal")
← 199e156 Fix --all broadcast footgun when label precedes --all flag (
·
back to Terminal Status
·
Auto-bind a ticket for unbound sessions from the UserPromptS 6d68776 →