stablyai / stablyai/orca

macOS: terminal daemon loses TCC access after two updates — the `severed` check inspects spawnerExecPath, which the update recreates

Open
#20,007 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
69.7k
Forks
4.5k
Avg merge
15h 28m
Merged PRs (30d)
471

Description

## Environment

- Orca **1.4.199** (app bundle); terminal daemon still running **1.4.197**
- macOS 26.6.2 (25G83), Apple Silicon
- Electron 43.6.0 on disk; processes left from the previous launch still report 43.4.1

## Related issues

- **#17600** — same user-visible symptom (EPERM on `~/Documents` for Orca-spawned processes,
appearing mid-session with no permission change on the user's side). The mechanism below is
a likely explanation for it.
- **#17696** — same symptom reported as an unreadable cwd while app and daemon versions are
mismatched.
- **#18831** — covers the live-session veto in the stale-bundle replacement path, which is
point (c) below. This report adds the TCC-severing mechanism and a separate defect in the
`severed` check itself.
- **#13921** — possibly related. That report says toggling the grant off and on restores
access without an app restart, which does not obviously fit the mechanism described here,
so it may be a different failure mode.

## Symptom

In any Orca terminal:

```
$ cd ~/Documents/
$ ls
ls: .: Operation not permitted
shell-init: error retrieving current directory: getcwd: cannot access parent directories: Operation not permitted
job-working-directory: error retrieving current directory: getcwd: cannot access parent directories: Operation not permitted
```

Only TCC-protected locations are affected (`~/Documents`, `~/Desktop`, `~/Downloads`).
Work under `~/orca/workspaces` keeps working, which makes the failure look arbitrary.

Orca itself holds the grant, so this is not a missing permission:

```
$ sqlite3 ~/Library/Application\ Support/com.apple.TCC/TCC.db \
"select service,client,auth_value from access where client='com.stablyai.orca';"
kTCCServiceSystemPolicyDocumentsFolder|com.stablyai.orca|2
kTCCServiceSystemPolicyDesktopFolder|com.stablyai.orca|2
kTCCServiceSystemPolicyDownloadsFolder|com.stablyai.orca|2
```

## Root cause

The terminal daemon outlives the app bundle it was exec'd from.

**1. The daemon is designed to survive app restarts.**

```
/Applications/Orca.app/Contents/Frameworks/Orca Helper.app/Contents/MacOS/Orca Helper
.../out/main/daemon-entry.js
--socket .../daemon/daemon-v36.sock
--app-version 1.4.197
--spawner-exec-path /Applications/Orca.app/Contents/MacOS/Orca
--login-session-watch
```

**2. ShipIt installs an update by renaming the running bundle out of /Applications.**

```
2026-09-09 13:25:36 Moving bundle from file:///Applications/Orca.app/
to file:///var/folders/.../T/com.stablyai.orca.ShipIt.wvfvUsm4/Orca.app
```

**3. The rename alone is harmless.** tccd can still read and validate the bundle at its new
location, so the daemon kept working for another day. Verified from session transcripts:
terminal sessions read `~/Documents` successfully at 09-09 19:22, 09-09 20:07 and 09-10 10:07,
all after the rename.

**4. The next update removes the previously parked copy**, and the daemon's executable
becomes unlinked:

```
$ lsof -p 3337 | awk '$4=="txt"'
.../T/com.stablyai.orca.ShipIt.wvfvUsm4/Orca.app/Contents/Frameworks/Orca Helper.app/Contents/MacOS/Orca Helper

$ ls -d /private/var/folders/.../T/com.stablyai.orca.ShipIt.*
...ShipIt.BlUZRM0f ...ShipIt.eiX9vreI # wvfvUsm4 is gone

$ codesign -vvv +3337
+3337: host has no guest with the requested attributes
```

**5. tccd can no longer resolve the daemon's code identity**, so the accessor falls back to
the shell binary itself:

```
tccd: Platform binary prompting is 'Deny' because: is Platform Binary
```

`/bin/zsh` is an Apple platform binary, so the request is denied without a prompt. Every
shell spawned by the daemon loses access, including ones spawned through `orca-tcc-login`.

## Why the existing guards did not catch it

The main bundle already contains `replaceSeveredMacTccDaemonBeforeNewPty()` and
`replaceStaleBundleDaemonOnce()`, with reason codes `severed_tcc_attribution`,
`stale_bundle` and `different_app_path`. None of them fired. The daemon's own identity
record shows why:

```json
// ~/Library/Application Support/orca/daemon/daemon-v36.pid
{
"pid": 3337,
"appVersion": "1.4.197",
"entryPath": "/Applications/Orca.app/Contents/Resources/app.asar.unpacked/out/main/daemon-entry.js",
"spawnerExecPath": "/Applications/Orca.app/Contents/MacOS/Orca"
}
```

**(a) The severed check inspects the wrong path.**

```js
i.spawnerExecPath ? (existsSync(i.spawnerExecPath) ? `intact` : `severed`) : `unknown`
```

`spawnerExecPath` is `/Applications/Orca.app/Contents/MacOS/Orca`. An in-place update
removes and recreates that path, so `existsSync` is true and the check returns `intact`.
The path that actually got severed is the daemon's **own** executable, which is never
inspected.

**(b) The entryPath check compares strings that never change.** `entryPath` is identical
across versions, so the comparison returns `match`.

**(c) The appVersion check is correct but suppressed.** The comparison of 1.4.197 against
1.4.199 correctly returns true, but replacement is then abandoned:

```js
let n = await this.getDaemonLiveSessionCount(),
r = Math.max(this.activeSessionIds.size, n ?? 0);
if (n === null || r > 0) {
console.warn(`[daemon] Packaged daemon is stale - preserving it because live…`);
return;
}
```

Replacement only proceeds when the live session count is zero. On a machine that keeps
agents running this is effectively never true. In this instance the daemon owned 123
descendant processes: 43 login sessions, 45 shells and **20 live `claude` sessions**, four
of which have their working directory inside `~/Documents` and are therefore unusable.
The daemon is preserved in a permanently broken state.

**(d) Nothing tells the user.** The warning is `console.warn` only. It appears in neither
`~/Library/Application Support/orca/logs/daemon.log` nor `main.trace.ndjson` (grep for
`severed`, `stale` and `attribution` returns zero hits in both), and there is no UI
indication. From the user's side the terminal simply stops being able to read
`~/Documents`, with nothing pointing at the daemon.

## Reproduction

1. Launch Orca and open terminals; keep at least one session alive.
2. Let an auto-update install. The running bundle is parked under
`$TMPDIR/com.stablyai.orca.ShipIt./`.
3. Let a second auto-update install, which removes the first parked copy.
4. In any Orca terminal, run `ls ~/Documents`.

Expected: a directory listing.
Actual: `ls: .: Operation not permitted`, plus `shell-init: error retrieving current
directory: getcwd: ...` from any bash that starts in that directory.

This is not a one-off. The same failure was recorded on this machine on 2026-08-10
(update at 07:53, first permission errors at 19:03), so it recurs whenever a daemon
spans two updates.

## Suggested fixes

**1. Base the severed check on the daemon's own executable.** Compare
`proc_pidpath(daemonPid)` against the current bundle and verify that path still exists, or
run a code-signature validity check against the running pid. That is the condition which
actually predicts the TCC failure; `spawnerExecPath` does not.

**2. When automatic replacement is declined, surface it.** A banner along the lines of
"The terminal daemon is running from a removed app bundle, so Documents, Desktop and
Downloads are blocked. Restart it?" with a one-click restart would turn a multi-hour
diagnosis into a click. Listing the affected sessions first would let the user save work.

**3. Consider preserving sessions across the replacement.** Passing the pty master
descriptors to the new daemon over `SCM_RIGHTS` before the old one exits would keep running
shells alive; they reparent to launchd while the ptys stay open. Node has no built-in API
for this, so it needs a native addon. Note this preserves the processes but not their TCC
attribution, which is fixed at exec time, so only newly spawned terminals would regain
access.

**4. Smaller mitigation.** Do not let ShipIt remove a parked bundle while a live process
still holds its executable open.

## Workaround for affected users

Restart the daemon, accepting the loss of every terminal session:

```bash
# identify Orca processes running from a removed bundle
for p in $(pgrep -f Orca); do
t=$(lsof -p "$p" 2>/dev/null | awk '$4=="txt"{print $NF; exit}')
case "$t" in *ShipIt*) echo "stale pid=$p $t";; esac
done

kill
```

Record the working directory and session id of every running agent first, since they have
to be resumed by hand afterwards. Keeping projects outside `~/Documents`, `~/Desktop` and
`~/Downloads` avoids the failure entirely.

Contributor guide

Open the contributing guide

Research direction

The relevant entry points are replaceSeveredMacTccDaemonBeforeNewPty(), replaceStaleBundleDaemonOnce(), and daemon-entry.js. Start by tracing how the daemon PID's executable and live-session count are obtained during the two-update reproduction. Done when a daemon whose executable was removed is detected and the chosen replacement or user-facing recovery path is verified.

Written by the indexing model from the issue text.

Assessment

Tech stack
electron, macos, typescript
Domain
desktop, devtools, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.