Skip to content

fix(context-drop): TCC-stable AX trust + flipped priority + region-capture fallback - #902

Merged
sonichi merged 1 commit into
mainfrom
fix/context-drop-electron-via-proxy
May 19, 2026
Merged

sonichi merged 1 commit into
mainfrom
fix/context-drop-electron-via-proxy

Conversation

@sonichi

@sonichi sonichi commented May 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

Three changes on top of PR #891's ax-read subprocess groundwork to make ⌃C reliable:

  1. AXIsProcessTrustedWithOptions(prompt:true) at startup — re-binds TCC Accessibility to the current binary after codesign identity changes. Without this, the prior grant orphans silently and AX queries fail with no re-prompt. The 2026-05-19 incident: I re-signed Sutando.app (ad-hoc → "Sutando Dev" cert) and AX queries started returning empty for every app (Discord and native TextEdit) despite the System Settings toggle still appearing on. AXIsProcessTrusted=false at startup proved it.
  2. Reorder dropContext: all text paths before all image paths. Lingering screenshots were pre-empting text-selection drops — ⌃C with selected text in Discord would drop yesterday's PNG. New chain: ax-read → legacy AX → legacy Cmd+C with changeCount guard → clipboard PNG → clipboard TIFF → screen-region capture.
  3. screencapture -i -c last-resort fallback. When nothing matched anywhere, instead of "Nothing selected" macOS now draws the crosshair so the user can drag-select a region to drop. Esc cancels cleanly. Runs on a background DispatchQueue so the main run loop stays responsive while the user drags. screencaptureInFlight instance bool guards against stacked crosshairs from rapid ⌃C.

Why subprocess invokeAxRead works from the menu-bar agent now

The earlier failure mode in PR #891 (subprocess AX returning empty when spawned from Sutando.app LSUIElement=YES process context) was a TCC attribution issue, not a fundamental macOS quirk. The OS attributes the child's AX queries up the responsible-process chain to Sutando.app. When the bundle's TCC Accessibility entry didn't match the running binary's signature (cdhash mismatch from the prior ad-hoc sign), the AX queries silently returned empty for every target app — Discord, TextEdit, all of them. The startup AXIsProcessTrustedWithOptions call now forces a re-bind on first launch after any signature change, so the spawned ax-read inherits a current grant. Verified locally: with AXIsProcessTrusted=true, the subprocess returns path=ax for Discord selections.

Out-of-band: codesign DR for cdhash stability

The startup AX-trust prompt only helps if the grant persists across rebuilds. For self-signed certs that aren't in the system trust store, TCC falls back to cdhash matching — every rebuild orphans the grant. Fix is to embed a permissive designated requirement at sign time:

codesign --force --sign <ident> \
  --requirements '=designated => identifier "com.sutando.menubar"' \
  Sutando.app

Then TCC stores the requirement at grant time and future rebuilds (same identifier, different cdhash) keep working. src/startup.sh now detects a "Sutando Dev" cert in the keychain and applies this requirement automatically; without that cert it falls back to ad-hoc (legacy behavior). Verified locally: rebuilt with different cdhash, AXIsProcessTrusted=true on first run.

A bare identifier-only DR is permissive — any binary signed with the same identifier inherits the grant. Stronger would be identifier + anchor apple generic + cert leaf[Y]. The permissive form is fine for personal-use Sutando but flagged here in case a future user installs an Apple Dev cert and wants tighter binding.

Test plan

  • ⌃C with text selected in Discord → drops via ax-read (path=ax)
  • ⌃C with image in clipboard + text selected in Discord → text wins (regression of original image-pre-emption fixed)
  • ⌃C with nothing selected anywhere → screencapture crosshair fires
  • Esc during screencapture → "Cancelled — nothing dropped"
  • Rebuild stability: different cdhash + same identifier → AX trust persists
  • CI green (tsc + tests, license/cla)

🤖 Generated with Claude Code

@sonichi
sonichi force-pushed the fix/context-drop-electron-via-proxy branch from 2900730 to ef62646 Compare May 19, 2026 19:45
@sonichi sonichi changed the title fix(context-drop): route AX read through web-client to fix Electron host bug fix(context-drop): TCC-stable AX trust + flipped priority + region-capture fallback May 19, 2026
@sonichi
sonichi force-pushed the fix/context-drop-electron-via-proxy branch from ef62646 to 5cd2ad4 Compare May 19, 2026 19:59
…pture fallback

Folds three changes onto the ax-read subprocess approach (PR #891
groundwork) to make ⌃C reliable across rebuilds:

1. **AXIsProcessTrustedWithOptions(prompt:true) at startup.**
   When Sutando.app's codesign identity changes (ad-hoc → cert, rename,
   rotate), the prior TCC Accessibility grant orphans silently — macOS
   returns AX errors without re-prompting. Empirical case 2026-05-19:
   Sutando.app was re-signed; AXIsProcessTrusted=false on first
   launch despite the prior grant still appearing toggled-on in
   System Settings. Adding the prompt call at startup forces macOS
   to re-bind the entry to the current binary on first AX use, or
   pop the standard "Sutando wants Accessibility" dialog if no
   matching entry exists. Idempotent when already trusted. Result
   logged so future drift is grep-able from session 0.

2. **Flip dropContext priority: ax-read FIRST, clipboard image second.**
   Original order checked clipboard PNG/TIFF BEFORE attempting AX read.
   Result: any lingering screenshot in the clipboard pre-empted text-
   selection drops, so ⌃C in Discord with a selection would silently
   drop yesterday's screenshot instead. The dominant ⌃C use case is
   "drop the highlighted text" — so ax-read runs first; only when it
   returns empty (`path=none`) do we fall through to the clipboard
   image branches.

3. **screencapture -i -c last-resort fallback.**
   When ax-read returns empty AND clipboard has no image AND legacy
   AX returns empty AND simulated Cmd+C doesn't advance pasteboard
   changeCount, instead of giving up with "Nothing selected" we now
   launch `screencapture -i -c`. macOS draws the crosshair, user
   drag-selects a region (or Esc to cancel). On completion the
   resulting PNG/TIFF clipboard content is captured as an image
   drop. Per Chi 2026-05-19: every ⌃C now has SOME path to
   producing a drop instead of dead-ending at "Nothing selected."

Also requires (out-of-band, not in this PR) codesigning Sutando.app
with an embedded designated requirement so TCC keys on bundle
identifier alone, surviving cdhash churn across rebuilds:

    codesign --requirements '=designated => identifier "com.sutando.menubar"'

Verified: rebuilt binary with different cdhash + same identifier passes
`AXIsProcessTrusted=true` on first run after one initial grant.

Tested locally:
- ⌃C with text selected in Discord → drops via ax-read (path=ax)
- ⌃C with image in clipboard, text selected → text wins (was: image won)
- ⌃C with nothing selected anywhere → screencapture crosshair fires
- Esc during screencapture → "Cancelled — nothing dropped"

Supersedes previous proxy-via-web-client approach on this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonichi
sonichi force-pushed the fix/context-drop-electron-via-proxy branch from 5cd2ad4 to 58d9e02 Compare May 19, 2026 20:04
@sonichi
sonichi merged commit a677dfb into main May 19, 2026
2 checks passed
@sonichi
sonichi deleted the fix/context-drop-electron-via-proxy branch May 19, 2026 20:04
qingyun-wu added a commit that referenced this pull request May 19, 2026
…904)

* fix(bridges): team/other codex output via staging path + atomic move

The current team/other tier system-instructions inject:

    codex exec --sandbox read-only -o {RESULTS_DIR}/task-{id}.txt -- ...

…and tell the agent: "if codex exits non-zero OR the output file does not
appear after the command finishes, treat as a failure and apply the fallback
rule." That check is racy. Sequence observed today:

1. Codex completes, writes results/task-{id}.txt (exit 0).
2. Bridge polls results/, picks the file up, delivers to channel, moves to
   results/archive/.
3. Agent's post-exec `ls results/task-{id}.txt` happens AFTER step 2, sees
   "no such file," writes the 'Sandbox unavailable; refusing non-owner task.'
   fallback to results/task-{id}.txt.
4. Bridge delivers that second message → user receives both the real codex
   reply AND the spurious fallback.

Caught it after Lucy's PR-skill design question (full codex review delivered,
then a "Sandbox unavailable" follow-up at ~10s) and MacBook's PR #902 review
ping (same pattern, real review at 19:53 then fallback ~10s later in
#bot2bot-trio).

Fix: rewrite both team-tier and other-tier system-instructions to use a
two-stage flow:

  Stage 1: codex exec ... -o /tmp/sutando-codex-staging-{id}.txt -- ...
  Stage 2: if codex exit 0 AND staging non-empty:
              mv /tmp/sutando-codex-staging-{id}.txt {RESULTS_DIR}/task-{id}.txt
           else:
              write 'Sandbox unavailable; refusing non-owner task.' to
              {RESULTS_DIR}/task-{id}.txt directly.

The bridge poller only ever sees the final file (post-mv) — no in-flight
half-written results, no phantom-missing race. The two existing failure
modes (codex non-zero exit, codex produced no output) collapse into one
"empty/missing staging file" check evaluated BEFORE the bridge ever sees
anything in results/.

No bridge-side code changes; the poller and archive logic are unchanged.
The only diff is the system-instructions template injected into team/other
tier task files.

* fix(bridges): same-fs staging path for guaranteed atomic mv

Per qingyun-sutando review note #2 on #904: staging in /tmp/ + mv into
{RESULTS_DIR}/ is only atomic when both share a filesystem. Today both
live on the system disk so this holds. Future-proof if anyone moves
$SUTANDO_WORKSPACE to an external volume: stage to
{RESULTS_DIR}/.codex-staging-{id}.txt instead. Same-filesystem mv is
guaranteed atomic regardless of workspace mount point.

Leading dot keeps the staging file out of the results-poll glob
(bridge filters by 'task-' / 'proactive-' prefix; .codex-staging-*
doesn't match either).

---------

Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…pture fallback (sonichi#902)

Folds three changes onto the ax-read subprocess approach (PR sonichi#891
groundwork) to make ⌃C reliable across rebuilds:

1. **AXIsProcessTrustedWithOptions(prompt:true) at startup.**
   When Sutando.app's codesign identity changes (ad-hoc → cert, rename,
   rotate), the prior TCC Accessibility grant orphans silently — macOS
   returns AX errors without re-prompting. Empirical case 2026-05-19:
   Sutando.app was re-signed; AXIsProcessTrusted=false on first
   launch despite the prior grant still appearing toggled-on in
   System Settings. Adding the prompt call at startup forces macOS
   to re-bind the entry to the current binary on first AX use, or
   pop the standard "Sutando wants Accessibility" dialog if no
   matching entry exists. Idempotent when already trusted. Result
   logged so future drift is grep-able from session 0.

2. **Flip dropContext priority: ax-read FIRST, clipboard image second.**
   Original order checked clipboard PNG/TIFF BEFORE attempting AX read.
   Result: any lingering screenshot in the clipboard pre-empted text-
   selection drops, so ⌃C in Discord with a selection would silently
   drop yesterday's screenshot instead. The dominant ⌃C use case is
   "drop the highlighted text" — so ax-read runs first; only when it
   returns empty (`path=none`) do we fall through to the clipboard
   image branches.

3. **screencapture -i -c last-resort fallback.**
   When ax-read returns empty AND clipboard has no image AND legacy
   AX returns empty AND simulated Cmd+C doesn't advance pasteboard
   changeCount, instead of giving up with "Nothing selected" we now
   launch `screencapture -i -c`. macOS draws the crosshair, user
   drag-selects a region (or Esc to cancel). On completion the
   resulting PNG/TIFF clipboard content is captured as an image
   drop. Per Chi 2026-05-19: every ⌃C now has SOME path to
   producing a drop instead of dead-ending at "Nothing selected."

Also requires (out-of-band, not in this PR) codesigning Sutando.app
with an embedded designated requirement so TCC keys on bundle
identifier alone, surviving cdhash churn across rebuilds:

    codesign --requirements '=designated => identifier "com.sutando.menubar"'

Verified: rebuilt binary with different cdhash + same identifier passes
`AXIsProcessTrusted=true` on first run after one initial grant.

Tested locally:
- ⌃C with text selected in Discord → drops via ax-read (path=ax)
- ⌃C with image in clipboard, text selected → text wins (was: image won)
- ⌃C with nothing selected anywhere → screencapture crosshair fires
- Esc during screencapture → "Cancelled — nothing dropped"

Supersedes previous proxy-via-web-client approach on this branch.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…onichi#904)

* fix(bridges): team/other codex output via staging path + atomic move

The current team/other tier system-instructions inject:

    codex exec --sandbox read-only -o {RESULTS_DIR}/task-{id}.txt -- ...

…and tell the agent: "if codex exits non-zero OR the output file does not
appear after the command finishes, treat as a failure and apply the fallback
rule." That check is racy. Sequence observed today:

1. Codex completes, writes results/task-{id}.txt (exit 0).
2. Bridge polls results/, picks the file up, delivers to channel, moves to
   results/archive/.
3. Agent's post-exec `ls results/task-{id}.txt` happens AFTER step 2, sees
   "no such file," writes the 'Sandbox unavailable; refusing non-owner task.'
   fallback to results/task-{id}.txt.
4. Bridge delivers that second message → user receives both the real codex
   reply AND the spurious fallback.

Caught it after Lucy's PR-skill design question (full codex review delivered,
then a "Sandbox unavailable" follow-up at ~10s) and MacBook's PR sonichi#902 review
ping (same pattern, real review at 19:53 then fallback ~10s later in
#bot2bot-trio).

Fix: rewrite both team-tier and other-tier system-instructions to use a
two-stage flow:

  Stage 1: codex exec ... -o /tmp/sutando-codex-staging-{id}.txt -- ...
  Stage 2: if codex exit 0 AND staging non-empty:
              mv /tmp/sutando-codex-staging-{id}.txt {RESULTS_DIR}/task-{id}.txt
           else:
              write 'Sandbox unavailable; refusing non-owner task.' to
              {RESULTS_DIR}/task-{id}.txt directly.

The bridge poller only ever sees the final file (post-mv) — no in-flight
half-written results, no phantom-missing race. The two existing failure
modes (codex non-zero exit, codex produced no output) collapse into one
"empty/missing staging file" check evaluated BEFORE the bridge ever sees
anything in results/.

No bridge-side code changes; the poller and archive logic are unchanged.
The only diff is the system-instructions template injected into team/other
tier task files.

* fix(bridges): same-fs staging path for guaranteed atomic mv

Per qingyun-sutando review note sonichi#2 on sonichi#904: staging in /tmp/ + mv into
{RESULTS_DIR}/ is only atomic when both share a filesystem. Today both
live on the system disk so this holds. Future-proof if anyone moves
$SUTANDO_WORKSPACE to an external volume: stage to
{RESULTS_DIR}/.codex-staging-{id}.txt instead. Same-filesystem mv is
guaranteed atomic regardless of workspace mount point.

Leading dot keeps the staging file out of the results-poll glob
(bridge filters by 'task-' / 'proactive-' prefix; .codex-staging-*
doesn't match either).

---------

Co-authored-by: Qingyun Wu <qingyun0327@gmail.com>
sonichi added a commit that referenced this pull request Sep 10, 2026
…sk (#4140)

* feat(health-check): warn when a skills driver runs code older than disk

`live-tree-drift` covers the repo side -- running daemons boot from whatever is
on disk there. Nothing covered the SKILLS side, and the failure is quieter: a
skill pulled while its driver is running never reaches that driver, because the
process froze its code at launch. No probe could see it; the only evidence is the
`v=<sha>` stamp the driver writes into its own log, compared by hand.

Measured 2026-09-10 on Chis-Mac-mini: three pulls in one day each left the content
driver executing superseded code, including one where a merged fix (#902, "the
recovery delivery obeys the hold") sat on disk and ran nowhere. Each was caught
only because someone happened to compare the stamp.

The probe reads the newest `v=…@<sha>` from state/content-driver.log and compares
it with the skills checkout HEAD. Absent log, absent checkout, unreadable stamp
and unparsed HEAD all return ok, never warn: a driver that never ran has no drift,
and a broken probe must not manufacture one.

Both arms exercised against the live workspace rather than a fixture:

    matching stamp  -> ok    "running 4acf765, matches skills HEAD"
    planted stale   -> warn  "running 0000000 but skills HEAD is 4acf765"

Stand: Echo Act IV Mini

* test(health-check): exercise the skills-driver drift probe, and make it testable

The probe shipped with no test: diff coverage measured 45% on the change and
CI refused it. Covering it required making the workspace injectable, which
also removed a `repo_root` parameter the body never read -- the reason the
function could not be driven from a fixture in the first place.

Ten arms, including the two that source-reading cannot establish:
  - the NEWEST stamp decides, so an older matching stamp cannot mask a newer
    mismatching one (a `stamps[0]` read reports ok on a drifted driver)
  - inverting `running == head` in the source must break the healthy case,
    so the comparison is exercised rather than merely present

One behavioural fix the arms surfaced: an unreadable skills HEAD reported
"no stamp recorded yet", naming the wrong cause. It now names its own.

Coverage of the added lines measured with the repo's own rcfile: no line in
the probe's range remains missing, with 306 missing ranges elsewhere in the
file as the live-instrument control.

Stand: Echo Act IV Mini

* fix(health-check): the drift probe uses the repo's macOS-safe git resolver

Review finding (qingyun-wu, confirmed by yixuan-ag2 who withdrew an approval
over it): the probe invoked a bare `git`. On a clean macOS host that resolves to
the developer-tools stub and raises the install dialog on EVERY health pass, and
neither the `except Exception` nor `timeout=10` can suppress a dialog.

The probe now builds its argv with `git_argv` and degrades on `GitUnavailable`.
This was the local convention of the function's own neighbours, not a repo-wide
one I could reasonably have missed: `health-check.py` already calls the helper at
fifteen sites, and the new probe was the single outlier.

Two controls, because the existing arms could not have caught this — they pin
what the function DECIDES and the defect was in what it INVOKES:
  j)  spies the subprocess boundary and asserts no built argv starts with a bare
      "git". Verified to FAIL when the call is reverted to bare git, and to pass
      restored.
  j2) `git_argv` raising GitUnavailable -> ok degrade naming that cause.

Stand: Echo Act IV Mini
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