Skip to content

[AI-1390] Hosted-agent native OS containment (Job Object, PDEATHSIG shim, macOS incarnation identity)#330

Open
realtonyyoung wants to merge 27 commits into
mainfrom
worktree-ai-1390-native-containment
Open

[AI-1390] Hosted-agent native OS containment (Job Object, PDEATHSIG shim, macOS incarnation identity)#330
realtonyyoung wants to merge 27 commits into
mainfrom
worktree-ai-1390-native-containment

Conversation

@realtonyyoung

Copy link
Copy Markdown
Collaborator

Hosted review-flow reviewers are unattended and count against the daemon's --max-agents budget. Phase A (server, AI-1313) made the server reclaim leaked slots and Phase B (daemon self-defense) added the managed record/scan/quarantine backstop. This PR adds the missing layer underneath both: OS-level containment at spawn time, so a crash-surviving hosted agent is killed by the kernel wherever the OS supports it, and the managed layer only has to cover what the OS can't.

What this adds

  • Windows (W1) — every hosted PTY is created already bound to a KILL_ON_JOB_CLOSE Job Object via PROC_THREAD_ATTRIBUTE_JOB_LIST (creation-time, no breakaway): the OS kills the agent and every descendant the instant the last job handle closes — clean shutdown, daemon crash, or external kill. Launch fails closed if the job can't be created/joined. No survivor class remains to reap.
  • Linux (L1) — a native spawn shim (libpty_shim) forks the agent, arms PR_SET_PDEATHSIG, and execs via execveat on a dedicated daemon-lifetime thread (PDEATHSIG binds to the creating thread's lifetime, so a pool thread would be fatal). An async-signal-safe post-fork child sequence with an unforgeable exec-EOF error-pipe handshake reports failures; a getppid re-check closes the fork/arm race. Applies to launches the shim proves contained at initial exec (non-privileged, non-deep-shebang, kernel ≥ 3.19); everything else falls back to the managed layer.
  • macOS (M1-A) — no OS primitive exists (no PDEATHSIG, no job objects), so recovery stays eventual via the record layer, but the record now carries an exact mac:{bootsessionuuid}:{p_uniqueid} incarnation identity (vendored private ABI via proc_pidinfo flavor 17 + kern.bootsessionuuid, spare-shaped fail-safe). identity_kind on the PID record distinguishes a captured identity from a capture failure; OrphanReaper classifies identity_unavailable (Linux still marker-scan-recovers it) vs legacy_unresolvable/identity_unresolvable (macOS manual-kill-only, both logged each sweep, never a wrong kill).

Per-OS coverage matrix (spec §4.5)

Agent (leader) on daemon death Descendants on daemon death Crash-survivor recovery Residuals (accepted, logged)
Windows Immediate (job close) Immediate (job, no breakaway) Moot (no survivor class) Launch fails closed if the job can't be created/joined
Linux Immediate (PDEATHSIG) — initial-exec, non-privileged (fd-bound) launches PTY EOF + prior-epoch marker scan Records (+ env guard); an identity_unavailable record is still marker-scan-recovered here Uncontained-classified launches → managed-layer-only: privileged binary, unreadable/inspection-failed preflight, <3.19/probe-disabled kernel, multi-token/unresolvable shebang; post-exec privileged re-exec; group-escaped current-epoch descendant
macOS None (no primitive) None Eventual, leader-only: records + exact mac:{bootsessionuuid}:{p_uniqueid} incarnation identity Recordless-window survivor; descendants; legacy_unresolvable/identity_unresolvable records (manual-kill-only)

Reviews

  • Subagent-driven implementation (10 tasks, W1 → L1-shim → L1-thread → per-RID build → M1-A) with a per-task review gate.
  • Whole-branch adversarial review (thorough): no CRITICAL — async-signal-safety of the post-fork child, C↔C# mac: byte-parity, backward-compat JSON decode, and the Windows job binding were each confirmed correct. Two IMPORTANT findings fixed: (1) DaemonRunner now disposes the host on every post-spawner-start exit path (a non-nameInUse ConnectAsync failure previously parked the foreground spawner thread → hang); (2) the Windows UI-restricted-job fail-closed test was relocated out-of-process so it can't irreversibly poison the test-host process. Minor robustness items folded in: win-prefix RID guard, _Static_assert on the vendored macOS struct, Linux pipe2(O_CLOEXEC), poll errno reporting, raisekill(getpid()), dead-P/Invoke cleanup.

Testing & verification

kcap-cli unit suite (TUnit/MTP), isolated dummy processes only — no live daemon, no live flows. Native Linux (PDEATHSIG/execveat/pipe2) and Windows (Job Object) behavior executes only on CI (ci.yml ubuntu+windows; release.yml per-RID native builds compile + load-and-call-smoke each shim); the macOS-reachable parts (identity capture, handshake mechanics) run locally. The pre-CI whole-branch review is the safety net for the CI-only paths.

Deferred (follow-ups, non-blocking)

  • OOM-only cosmetic: native-ELF preflight returns launch-failure on allocation failure instead of degrading to uncontained (unreachable outside true OOM).
  • Test coverage: the anti-TOCTOU exec'd-inode==preflighted-inode assertion is currently prose-only; the §5 unreadable-xattr-uncontained fixture and the forced-capture-failure→restart marker-reap Linux test are not yet implemented (the core parity/containment/reaping tests all exist).

Stacking

Was stacked on #327 (AI-1313 Phase B); #327 has merged, so this is rebased onto main.

realtonyyoung and others added 27 commits July 18, 2026 21:32
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Windows

- ConPtyProcess.Spawn now creates a Job Object with
  JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and binds it to the child at process
  creation time via a second PROC_THREAD_ATTRIBUTE_JOB_LIST attribute
  (grown from 1 to 2 entries alongside the existing pseudoconsole
  attribute), so there is no suspended-then-assign window where the
  child could exist uncontained.
- ConPtyProcess now owns the job SafeHandle; disposing the process closes
  the job's last handle, which the OS uses to kill the leader and every
  descendant.
- Any failure after CreateProcessW succeeds (but before the ConPtyProcess
  is fully constructed) tears the child down via the job and confirms
  death before rethrowing, so callers never observe an ambiguous
  'maybe spawned' state.
- New ConPtyJobObjectTests (4 cases: dispose kills descendants, breakaway
  is denied, nesting inside an outer job still works, job-creation
  failure fails the spawn closed) — Windows-only behavior, so each test
  no-ops on non-Windows hosts; real coverage is windows-latest in CI.
- ConPtyJobObjectTestHelper backs the tests with raw Win32 calls (plain
  DllImport, since the test project isn't AOT-published and doesn't need
  AllowUnsafeBlocks for that).
…job handle on CreateProcessW failure

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d/pty_plan_free

Adds the parent-side, pre-fork execution-plan construction: PATH/shebang
resolution (direct and /usr/bin/env forms, at most one env.PATH resolution
hop), an fd-bound execveat/security.capability privilege preflight, and the
opaque pty_exec_plan lifecycle (build/contained/free). Linux-only kernel
behaviors (execveat, fgetxattr(security.capability)) are #ifdef __linux__
guarded so the same portable PATH/shebang logic still compiles and runs on
other POSIX platforms, always degrading to an uncontained plan there
(never a false proof of non-privilege).

Also fixes two off-by-one argv-allocation bugs found via a standalone
harness run under ASan/leaks against the initial transcription (the
direct-shebang and env-shebang rewritten argv arrays were both allocated
one pointer short of the NULL terminator, corrupting adjacent heap memory
once an optional shebang arg or an extra orig_argv element was present).

Tests + DummyProcess fixture helpers (shebang scripts, setuid/execute-only
copies, dual-PATH dirs) are Linux-gated per spec; Capacitor.Cli.Tests.Unit
now needs AllowUnsafeBlocks for the fixtures' own chmod LibraryImport.
…ncontained), NULL-check shebang allocs, guard empty argv

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pture

Implements pty_spawn: forkpty, an async-signal-safe child sequence
(PDEATHSIG arm + getppid recheck on Linux, chdir, execveat(fd)/execve(path)
per the plan, self-pipe failure reporting), and a parent-side bounded
poll+cancel_fd handshake that never returns success for an unobserved
child. start_identity is captured in the parent immediately after
forkpty returns, before anything can reap/recycle the pid.

Deviates from the initial draft in three places, each verified on this
macOS dev box:
- The child's failure-reporting helper is a top-level function, not a
  nested one -- GCC/Clang nested functions need trampoline support this
  build's plain `cc -shared` doesn't enable.
- The bounded handshake retries poll()/read()/waitpid() across EINTR
  against a monotonic deadline, and treats a partial errpipe read or an
  unexpected read() failure as a bounded failure rather than guessing.
- macOS's PROC_PIDUNIQIDENTIFIERINFO struct size is not the commonly
  documented 40/48 bytes -- empirically probed on this kernel via
  proc_pidinfo (self vs. a freshly forked child vs. pid 1) at 56 bytes,
  with the leading p_uuid/p_uniqueid/p_puniqueid prefix confirmed by the
  child's uniqueid incrementing from the parent's and matching parent's
  puniqueid. Reads only that fixed-offset prefix out of an oversized
  buffer instead of asserting an exact total size, so OS-version drift
  in the reserved tail doesn't require a code change.

Also fixes the test's default execveat_supported (was hardcoded 1,
which built an EXEC_FD-mode plan pty_spawn's exec step cannot run on
macOS) to probe via pty_probe_execveat() like a real caller would, and
swaps the "fast-exiting child" test off /bin/true (absent on this dev
box) onto /bin/sleep 0.
Every Linux pty_spawn call now runs on ONE dedicated, daemon-lifetime
native thread (UnixSpawnerThread) instead of a thread-pool thread, since
PR_SET_PDEATHSIG is a per-thread property: a retired pool thread would
otherwise SIGKILL every agent it ever spawned. Unexpected termination of
the spawner thread itself Environment.FailFasts the daemon process, so
children die WITH the daemon rather than being silently orphaned by a
half-broken supervisor.

Adds a NativeTestHost helper project (plain console Exe, no MTP/TUnit)
so the PDEATHSIG-kills-child and FailFast-terminates-host assertions can
run against a real, externally observable OS process; both are
Linux-CI-only (PDEATHSIG has no macOS/Windows equivalent). The in-process
pool-thread-churn test is portable and verified locally on macOS.
…managed fork/exec branch

Rewrite UnixPtyProcess.Spawn to resolve the executable pre-fork (parent-side,
against the same env/PATH the child gets), build an execution plan via
pty_preflight, and submit it to the dedicated UnixSpawnerThread instead of the
old forkpty + managed case-0 child branch (setenv/unsetenv/chdir/execvp,
deleted entirely). The captured StartIdentity from pty_spawn's handshake is
surfaced on IPtyProcess/IHostedAgentRuntime.

The spawner thread is DI-owned (threaded through UnixPtyProcessFactory's
constructor) rather than a static per-process singleton: UnixSpawnerThread
starts a non-background OS thread, and nothing could ever reach in and
Dispose() a static instance -- confirmed empirically, since a first-draft
static Lazy<UnixSpawnerThread> here hung the test host indefinitely. Widen
UnixSpawnerThread to public (SpawnOn stays internal) since DI's
constructor-injection resolution only considers a type's public constructor,
which by C#'s accessibility-consistency rule requires the parameter type to be
at least as accessible.

The new end-to-end spawn test's cross-check against ProcessStartToken only
holds on Linux (both read /proc/pid/stat + boot_id independently in the same
format); macOS's shim capture uses a different, more robust
bootsessionuuid+p_uniqueid scheme that ProcessStartToken.ForPid has no
knowledge of, so there is no live re-deriver to cross-check against there.
…nstead of re-capturing on Unix

PersistPidRecordOrThrow gains a capturedStartIdentity parameter. Non-null
(Unix, post-L1-managed(b)): consume the runtime's own IHostedAgentRuntime
.StartIdentity as-is -- never re-capture via ProcessIdentity.Capture, since
the shim already read it immediately post-forkpty (the capture-binding rule;
a post-hoc re-capture here could adopt an unrelated process if the pid was
already recycled). Empty string is a deliberate identity_unavailable record,
not a launch failure, and agent.StartIdentity is set to that same empty
string (not null) so CleanupAgentAsync's teardown check treats it as
permanently uncomparable rather than confirmed-gone (MatchesTri(pid, "")
always returns null -- no ':' scheme separator).

Null (Windows / the ACP runtime, neither of which capture this way):
unchanged legacy behavior, falling back to the original post-hoc
ProcessIdentity.Capture.

The persisted AgentPidRecord shape is intentionally left unchanged here --
an explicit identity_kind classification is a follow-up concern, not part of
this rewiring.
…spawner thread retires (fixes shutdown hang)
Adds a mac-identity-smoke mode to the Task 4 NativeTestHost (captures its
own pid's identity via a direct pty_capture_mac_identity call, plus a
spawned child's via the production pty_spawn capture path already
exercised by StartIdentity) and attaches it as an osx-arm64-only step in
release.yml's per-RID build job, per the design spec's per-RID macOS
packaging checks (there is no macOS runner in ci.yml to attach this to
instead).
…ry; macOS legacy/identity unresolvable classification
Once AgentOrchestrator is resolved, the Unix IPtyProcessFactory has started
UnixSpawnerThread's foreground (non-background) OS thread, which parks forever
until the ServiceProvider is disposed. Previously only the nameInUse catch and
the WaitForShutdownAsync finally called DisposeHostAsync; a throw out of
ReapOrphansOnceAsync, a non-nameInUse ConnectAsync failure, CleanupOrphanedAsync,
or EvalRunner resolution escaped without disposal, leaving the spawner thread
parked and hanging the process instead of exiting with the error.

Wrap everything from ReapOrphansOnceAsync through WaitForShutdownAsync in one
try whose finally disposes daemonLock/orchestrator/connection/host on every
exit path, and fold the nameInUse catch into it (return 3; unified finally does
the cleanup). Return-code semantics preserved. Structural fix; backed by the
existing DaemonHostDisposalTests that prove StopAsync-then-dispose retires the
DI-owned spawner thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Job_creation_failure_fails_the_spawn_closed assigned the shared Windows test
host to a UI-restricted job to block job nesting. That mutation is IRREVERSIBLE
(Windows has no un-assign), so once it ran it permanently blocked later job
nesting and would make Disposing_the_process_kills_child_and_grandchild /
Job_sets_no_breakaway_flag... throw if scheduled after it. [NotInParallel] +
ordering cannot fix an irreversible mutation.

Move the assertion into a disposable out-of-process NativeTestHost (the same
pattern the Linux PDEATHSIG/FailFast tests use): a new
win-ui-restricted-fail-closed mode assigns ITSELF to a UI-restricted job,
attempts a real ConPtyProcess.Spawn, and exits 0 iff Spawn failed closed
(threw, no uncontained child), 20 if it leaked, 30 if the poison job could not
be set up. The test launches that child and asserts exit code 0. Everything
compiles on macOS/Linux (Windows P/Invokes guarded, the test body early-returns
off-Windows); the fail-closed behavior itself is Windows-CI-verified only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Batched review fold-ins:

- csproj: replace the 'win-x64' RID equality guard with a win-prefix check
  (!RuntimeIdentifier.StartsWith('win')) on the shim-compile target and its
  sibling PropertyGroup/ItemGroup, so cross-publishing win-arm64/win-x86 from a
  non-Windows host no longer compiles a mislabeled Linux/macOS .so for a Windows
  RID. Keeps the !IsOSPlatform('Windows') conjunct.

- pty_shim.c: add compile-time _Static_assert on the vendored macOS
  proc_uniqidentifierinfo prefix (size==32, offsetof(p_uniqueid)==16) so ABI
  drift fails the build instead of mis-reading the identity at runtime.

- pty_shim.c: use pipe2(O_CLOEXEC) on Linux (atomic CLOEXEC — closes the
  fork/exec inherit race that could stall exec-EOF into the 30s handshake
  timeout and kill a healthy agent); keep pipe()+fcntl() on macOS/BSD but now
  check both fcntl returns and fail closed. Define _GNU_SOURCE (Linux-only,
  before the first system header) for the pipe2 declaration.

- pty_shim.c: report poll()'s errno on the non-EINTR poll-error path instead of
  discarding it (diagnostic only; still HANDSHAKE_TIMEOUT).

- pty_shim.c: replace raise(SIGKILL) with kill(getpid(), SIGKILL) in the forked
  child — raise() is not async-signal-safe; kill()/getpid() are. Same semantics.

- UnixPtyInterop.cs: delete the now-unused execvp/setenv/unsetenv/chdir
  P/Invokes (dead since the managed fork/exec branch was removed).

Linux pipe2 arm and the win-arm64/win-x86 cross-publish are CI-verified only;
macOS path compiles clean (-Wall -Wextra) and the static_asserts hold locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 19, 2026

Copy link
Copy Markdown

AI-1390

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Hosted-agent native OS containment: Windows Job Object, Linux PDEATHSIG shim, macOS identity

✨ Enhancement 🐞 Bug fix 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Enforce hosted-agent death on daemon exit via Windows Job Objects and Linux PDEATHSIG spawn shim.
• Add macOS exact incarnation identity and explicit identity-kind semantics for safe reaping.
• Extend build/release pipelines and unit tests for per-OS containment and shutdown disposal paths.
Diagram

graph TD
  DR["DaemonRunner"] --> PS["PTY spawn"]
  PS --> WJ["Windows Job Object"] --> K{{"OS kernel"}}
  PS --> UST["UnixSpawnerThread"] --> SH["libpty_shim"] --> K
  PS --> RS[("AgentPidRecordStore")] --> OR["OrphanReaper"] --> K

  subgraph Legend
    direction LR
    _svc["Service"] ~~~ _db[("Store")] ~~~ _ext{{"OS primitive"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Linux pidfd-based supervision (pidfd_open + poll)
  • ➕ Kernel-level handle to child; robust against PID reuse
  • ➕ No dedicated spawner thread requirement
  • ➖ Does not kill on daemon crash without an always-on watcher process
  • ➖ Kernel/version constraints and portability complexity; still needs fallback paths
2. Linux cgroup containment (systemd scope / cgroup.kill)
  • ➕ Can kill full descendant tree reliably
  • ➕ Operationally common in systemd environments
  • ➖ Often requires elevated privileges or systemd integration
  • ➖ Not viable for portable CLI/daemon installs; complicates packaging and UX
3. macOS launchd job supervision
  • ➕ Native lifecycle management in macOS environments
  • ➖ Requires daemonization via launchd; not applicable to current standalone daemon model
  • ➖ Still does not provide a simple 'kill on parent crash' primitive for arbitrary descendants

Recommendation: The PR’s approach is appropriate for the stated constraints: fail-closed creation-time Job Objects on Windows, a Linux PDEATHSIG-based shim (with explicit scoping and a dedicated thread to avoid false kills), and a macOS-safe identity scheme that never risks a wrong kill. Alternatives either require privileged/system-level integration (cgroups/launchd) or don’t meet the “kernel kills on daemon crash” goal without an additional supervisor process. Proceed with the current design, ensuring reviewers focus on the safety invariants (no false proofs, no PID-only actions, and correct host disposal).

Files changed (39) +6911 / -179

Enhancement (13) +1455 / -129
Models.csAdd PidIdentityKind and extend AgentPidRecord schema +29/-12

Add PidIdentityKind and extend AgentPidRecord schema

• Introduces PidIdentityKind (Present vs IdentityUnavailable) and adds it to AgentPidRecord for explicit record semantics. Registers the new enum for source-generated JSON serialization.

src/Capacitor.Cli.Core/Models.cs

ProcessStartToken.csImplement macOS mac:{bootsessionuuid}:{p_uniqueid} start token +121/-4

Implement macOS mac:{bootsessionuuid}:{p_uniqueid} start token

• Adds a macOS-specific identity scheme using proc_pidinfo flavor 17 and kern.bootsessionuuid, with fail-safe (spare-shaped) capture behavior. Keeps cross-implementation parity with the native shim by using prefix-only reads and matching buffer sizing.

src/Capacitor.Cli.Core/ProcessStartToken.cs

pty_shim.cAdd Unix native spawn shim: preflight plan + fork/handshake + Linux PDEATHSIG +698/-2

Add Unix native spawn shim: preflight plan + fork/handshake + Linux PDEATHSIG

• Implements a native execution plan model (fd vs path), Linux execveat probing, fd-bound privilege preflight, and shebang/env resolution with fail-closed classification. Adds pty_spawn to forkpty, arm PDEATHSIG (Linux), exec with an error-pipe handshake, and capture start-identity natively immediately after fork.

src/Capacitor.Cli.Daemon/Native/pty_shim.c

pty_shim.hIntroduce public header for opaque plan and pty_spawn API +83/-0

Introduce public header for opaque plan and pty_spawn API

• Adds an opaque pty_exec_plan handle and declares probe/preflight/contained/free/spawn APIs. Exposes macOS identity capture export for parity with managed ProcessStartToken.

src/Capacitor.Cli.Daemon/Native/pty_shim.h

IPtyProcess.csExpose native-captured StartIdentity on PTY processes +10/-0

Expose native-captured StartIdentity on PTY processes

• Adds an optional StartIdentity property to carry shim-captured identity from spawn time. Documents the capture-binding rule and platform differences (Unix non-null, Windows null).

src/Capacitor.Cli.Daemon/Pty/IPtyProcess.cs

UnixPtyInterop.csAdd P/Invokes for libpty_shim plan + spawn + mac identity capture +58/-16

Add P/Invokes for libpty_shim plan + spawn + mac identity capture

• Removes direct execvp/chdir/setenv fork-branch interop and adds bindings for pty_probe_execveat, pty_preflight, pty_spawn, and plan lifetime. Introduces the PtySpawnResult struct with embedded start-identity bytes and a string accessor.

src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyInterop.cs

UnixPtyProcess.csSwitch Unix spawn to shim preflight/spawn and propagate StartIdentity +102/-71

Switch Unix spawn to shim preflight/spawn and propagate StartIdentity

• Moves executable resolution/env construction pre-fork, calls into the shim for plan building and spawning, and logs when a launch is classified uncontained. Returns a UnixPtyProcess carrying StartIdentity captured natively at spawn time and wires UnixPtyProcessFactory to use DI-provided UnixSpawnerThread.

src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs

UnixSpawnerThread.csAdd dedicated Unix spawner thread with FailFast-on-unexpected-exit policy +120/-0

Add dedicated Unix spawner thread with FailFast-on-unexpected-exit policy

• Introduces a daemon-lifetime foreground thread that serializes all pty_spawn calls to satisfy PDEATHSIG thread-lifetime constraints. Adds a test-only crash seam and explicit disposal semantics to retire the thread on host disposal.

src/Capacitor.Cli.Daemon/Pty/Unix/UnixSpawnerThread.cs

ConPtyInterop.csAdd Job Object P/Invoke surface and structures +70/-0

Add Job Object P/Invoke surface and structures

• Adds kernel32 Job Object imports and structs/constants for KILL_ON_JOB_CLOSE and breakaway flags. Defines PROC_THREAD_ATTRIBUTE_JOB_LIST for creation-time job binding.

src/Capacitor.Cli.Daemon/Pty/Windows/ConPtyInterop.cs

ConPtyProcess.csBind ConPTY child to KILL_ON_JOB_CLOSE job at creation time (fail closed) +102/-10

Bind ConPTY child to KILL_ON_JOB_CLOSE job at creation time (fail closed)

• Creates a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and passes it into CreateProcessW via STARTUPINFOEX attributes so there is no uncontained window. Ensures post-CreateProcess failures terminate the job and confirm child death; disposing closes the last job handle to kill leader and descendants.

src/Capacitor.Cli.Daemon/Pty/Windows/ConPtyProcess.cs

AgentOrchestrator.csPersist PID records using runtime-provided native StartIdentity and identity kind +47/-9

Persist PID records using runtime-provided native StartIdentity and identity kind

• Extends PID record persistence to accept runtime-captured StartIdentity (Unix) without re-capturing (prevents PID-reuse adoption). Writes IdentityKind=IdentityUnavailable for empty tokens and keeps legacy capture for runtimes without shim identity.

src/Capacitor.Cli.Daemon/Services/AgentOrchestrator.cs

IHostedAgentRuntime.csAdd StartIdentity to hosted-agent runtime contract +9/-0

Add StartIdentity to hosted-agent runtime contract

• Adds StartIdentity defaulting to null, allowing Unix runtimes to provide shim-captured identity. Documents that Unix returns a real token or "" (identity_unavailable) and must not be re-derived post-hoc.

src/Capacitor.Cli.Daemon/Services/IHostedAgentRuntime.cs

PtyHostedAgentRuntime.csPlumb PTY StartIdentity through to orchestrator +6/-5

Plumb PTY StartIdentity through to orchestrator

• Exposes StartIdentity from the underlying IPtyProcess so the orchestrator can persist the spawn-time identity token.

src/Capacitor.Cli.Daemon/Services/PtyHostedAgentRuntime.cs

Bug fix (3) +125 / -24
DaemonRunner.csDI-own UnixSpawnerThread and ensure host disposal on all exit paths +75/-20

DI-own UnixSpawnerThread and ensure host disposal on all exit paths

• Registers UnixSpawnerThread as a singleton on Unix and adds unified shutdown logic to dispose the host (and thus the ServiceProvider) after StopAsync. Fixes a hang where non-nameInUse ConnectAsync failures could skip disposal, leaving a foreground spawner thread alive.

src/Capacitor.Cli.Daemon/DaemonRunner.cs

AgentPidRecordStore.csValidate identity_kind/start_identity consistency and quarantine corrupt records +17/-0

Validate identity_kind/start_identity consistency and quarantine corrupt records

• Adds schema-consistency checks so new-format inconsistent combinations are quarantined as .corrupt. Preserves backward compatibility: legacy records missing identity_kind decode as Present and are not rejected.

src/Capacitor.Cli.Daemon/Services/AgentPidRecordStore.cs

OrphanReaper.csHandle IdentityUnavailable records and macOS legacy/unresolvable cases explicitly +33/-4

Handle IdentityUnavailable records and macOS legacy/unresolvable cases explicitly

• Ensures IdentityUnavailable records are not marked handled so the Linux env-marker scan can still reap them. Adds logging for identity_unavailable and macOS legacy cross-scheme ambiguity; deletes records on confirmed marker-based kills to avoid PID-only later actions.

src/Capacitor.Cli.Daemon/Services/OrphanReaper.cs

Tests (16) +1501 / -5
Capacitor.Cli.Tests.Unit.NativeTestHost.csprojAdd out-of-process NativeTestHost test helper project +15/-0

Add out-of-process NativeTestHost test helper project

• Introduces a small executable project used to run irreversible/kill-the-host tests safely out-of-process (Windows UI-restricted job poisoning; Linux PDEATHSIG and spawner FailFast).

test/Capacitor.Cli.Tests.Unit.NativeTestHost/Capacitor.Cli.Tests.Unit.NativeTestHost.csproj

Program.csImplement NativeTestHost modes for shim smoke and OS-specific containment tests +209/-0

Implement NativeTestHost modes for shim smoke and OS-specific containment tests

• Adds modes for spawn-dummy (end-to-end Unix spawn path), crash-spawner (FailFast), mac-identity-smoke (runtime capture), and win-ui-restricted-fail-closed (job nesting failure). Provides direct mac identity capture via the shim export for self identity.

test/Capacitor.Cli.Tests.Unit.NativeTestHost/Program.cs

Capacitor.Cli.Tests.Unit.csprojWire new NativeTestHost and containment tests into unit test project +3/-0

Wire new NativeTestHost and containment tests into unit test project

• Updates unit test project configuration to include the new test host and any new files/refs required by containment tests.

test/Capacitor.Cli.Tests.Unit/Capacitor.Cli.Tests.Unit.csproj

AgentPidRecordStoreTests.csAdd coverage for identity_kind backward-compat and corruption quarantining +80/-1

Add coverage for identity_kind backward-compat and corruption quarantining

• Updates record constructors for the new schema and adds tests for legacy JSON decoding (missing identity_kind), identity_unavailable round-trip, and quarantining inconsistent new-shape records.

test/Capacitor.Cli.Tests.Unit/Daemon/AgentPidRecordStoreTests.cs

ConPtyJobObjectTestHelper.csAdd Windows Job Object test helpers for querying flags and job membership +155/-0

Add Windows Job Object test helpers for querying flags and job membership

• Introduces helper methods to query job limit flags safely, assign the test host to a non-killing job, and check IsProcessInJob. Supports the Job Object behavior assertions without risking killing the test host.

test/Capacitor.Cli.Tests.Unit/Daemon/ConPtyJobObjectTestHelper.cs

ConPtyJobObjectTests.csAssert Windows hosted PTY processes are kill-contained by Job Objects +141/-0

Assert Windows hosted PTY processes are kill-contained by Job Objects

• Adds tests proving dispose kills child + grandchild, breakaway flags are absent, nesting inside an outer job works, and job-creation failure fails closed. Moves the UI-restricted job poison scenario out-of-process via NativeTestHost.

test/Capacitor.Cli.Tests.Unit/Daemon/ConPtyJobObjectTests.cs

DaemonHostDisposalTests.csRegression test: host disposal retires DI-owned UnixSpawnerThread +66/-0

Regression test: host disposal retires DI-owned UnixSpawnerThread

• Adds a regression test ensuring StopAsync + host disposal occurs, preventing foreground spawner-thread hangs on daemon exit paths.

test/Capacitor.Cli.Tests.Unit/Daemon/DaemonHostDisposalTests.cs

DummyProcess.csExtend dummy-process utilities for containment test fixtures +70/-1

Extend dummy-process utilities for containment test fixtures

• Adds/adjusts helpers used by shim plan/spawn tests (e.g., execute-only binaries, setuid copies, shebang scripts, PATH fixtures). Includes small safety tweaks around process handling.

test/Capacitor.Cli.Tests.Unit/Daemon/DummyProcess.cs

OrphanReaperTests.csAdd tests for IdentityUnavailable behavior and macOS manual-only residuals +128/-1

Add tests for IdentityUnavailable behavior and macOS manual-only residuals

• Updates records to include IdentityKind and adds tests ensuring identity_unavailable records fall through to marker scan on Linux and self-delete on confirmed marker kills. Adds macOS expectations for identity_unresolvable and legacy tk: cross-scheme ambiguity requiring manual kill.

test/Capacitor.Cli.Tests.Unit/Daemon/OrphanReaperTests.cs

ProcessStartTokenTests.csAdd macOS ProcessStartToken scheme and native-parity tests +86/-0

Add macOS ProcessStartToken scheme and native-parity tests

• Adds tests validating mac: token shape, stability, uniqueness across processes, cross-scheme match behavior, and byte-parity against the shim’s pty_capture_mac_identity export.

test/Capacitor.Cli.Tests.Unit/Daemon/ProcessStartTokenTests.cs

PtyShimNativeTests.csTest shim preflight/classification contract (probe/preflight/contained/free) +206/-0

Test shim preflight/classification contract (probe/preflight/contained/free)

• Adds Linux-focused tests for execveat probing, contained vs uncontained classification (setuid, PATH edge cases, deep/multi-token shebang), and robustness requirements like null-terminated argv/envp.

test/Capacitor.Cli.Tests.Unit/Daemon/PtyShimNativeTests.cs

PtySpawnTests.csTest shim pty_spawn fork/exec/handshake contract in isolation +175/-0

Test shim pty_spawn fork/exec/handshake contract in isolation

• Adds tests covering successful spawn with captured identity, preflight failures that never fork, child-side exec/chdir failures, parent-died getppid recheck behavior, cancel-fd interruption, and capture-binding for fast-exiting children.

test/Capacitor.Cli.Tests.Unit/Daemon/PtySpawnTests.cs

StopAgentPidFallbackTests.csAdjust stop-agent PID fallback tests for updated identity schema +1/-1

Adjust stop-agent PID fallback tests for updated identity schema

• Updates a single assertion/input to align with the new AgentPidRecord/identity_kind behavior while preserving fallback-stop semantics.

test/Capacitor.Cli.Tests.Unit/Daemon/StopAgentPidFallbackTests.cs

UnixPtyProcessSpawnTests.csAdd/adjust UnixPtyProcess spawn tests for shim-based StartIdentity +48/-0

Add/adjust UnixPtyProcess spawn tests for shim-based StartIdentity

• Adds coverage that Unix PTY spawns return a running process with a captured start identity via the shim-based spawn path, and validates error handling consistent with the new preflight/spawn split.

test/Capacitor.Cli.Tests.Unit/Daemon/UnixPtyProcessSpawnTests.cs

UnixSpawnerThreadTests.csTest dedicated spawner thread invariants and PDEATHSIG behavior via NativeTestHost +117/-0

Test dedicated spawner thread invariants and PDEATHSIG behavior via NativeTestHost

• Adds Linux-only out-of-process tests proving PDEATHSIG kills the child when the spawner process dies and that unexpected spawner-thread termination fail-fasts. Adds an in-process test that unrelated thread-pool churn does not kill the agent.

test/Capacitor.Cli.Tests.Unit/Daemon/UnixSpawnerThreadTests.cs

LaunchCleanupTests.csUpdate launch cleanup test expectations for new identity schema +1/-1

Update launch cleanup test expectations for new identity schema

• Adjusts a single assertion to account for the updated PID record shape/identity semantics while keeping cleanup behavior coverage.

test/Capacitor.Cli.Tests.Unit/LaunchCleanupTests.cs

Documentation (4) +3706 / -2
README.mdDocument OS-level containment and updated crash-survivor semantics +6/-2

Document OS-level containment and updated crash-survivor semantics

• Reframes capacity defense as layered: OS containment at spawn plus managed record/scan/quarantine backstop. Documents Windows Job Object behavior, Linux PDEATHSIG shim scope, and macOS lack of primitives with eventual recovery.

README.md

2026-07-17-ai1390-native-os-containment-plan.mdAdd detailed implementation plan for AI-1390 native containment +3244/-0

Add detailed implementation plan for AI-1390 native containment

• Introduces a comprehensive plan covering Windows job binding, Linux shim + spawner-thread requirements, per-RID build/packaging, and macOS identity capture strategy with testability notes.

docs/superpowers/plans/2026-07-17-ai1390-native-os-containment-plan.md

2026-07-16-ai1313-reviewer-capacity-reaping-design.mdAdd design spec for layered capacity reaping and OS containment hooks +258/-0

Add design spec for layered capacity reaping and OS containment hooks

• Adds a full design document describing the managed reaping layers and how OS-level containment integrates per OS. Captures sharp-edge constraints (thread lifetime, fork safety, and scope limits) and acceptance criteria.

docs/superpowers/specs/2026-07-16-ai1313-reviewer-capacity-reaping-design.md

2026-07-17-ai1390-native-os-containment-design.mdAdd AI-1390 native OS containment design spec and coverage matrix +198/-0

Add AI-1390 native OS containment design spec and coverage matrix

• Defines the per-OS containment claims and residuals matrix, Linux plan/shim model, and macOS identity approach. Includes explicit scoping to avoid false guarantees and enumerates test coverage expectations.

docs/superpowers/specs/2026-07-17-ai1390-native-os-containment-design.md

Other (3) +124 / -19
release.ymlBuild/packaging gates for libpty_shim across Unix RIDs +92/-14

Build/packaging gates for libpty_shim across Unix RIDs

• Extends the release matrix to verify musl toolchains, ensure libpty_shim is built for all non-Windows RIDs, and add load-and-call smoke checks. Adds macOS runtime identity smoke via the NativeTestHost helper and copies .so/.dylib into npm/archive artifacts.

.github/workflows/release.yml

Capacitor.slnxInclude new NativeTestHost project in solution +1/-0

Include new NativeTestHost project in solution

• Updates the solution to include the new out-of-process native test host used by containment tests.

Capacitor.slnx

Capacitor.Cli.Daemon.csprojBuild libpty_shim for all non-Windows RIDs and expose internals to NativeTestHost +31/-5

Build libpty_shim for all non-Windows RIDs and expose internals to NativeTestHost

• Generalizes shim compilation from macOS-only to all non-Windows RIDs with correct toolchain selection (musl-gcc vs cc). Adds InternalsVisibleTo for the NativeTestHost test project and ensures the shim is copied to outputs/publish.

src/Capacitor.Cli.Daemon/Capacitor.Cli.Daemon.csproj

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. PATH CWD mismatch 🐞 Bug ≡ Correctness
Description
UnixPtyProcess.ResolveExecutableAbsolutePath resolves bare command names using PATH entries relative
to the daemon’s current directory and drops empty PATH components, but the child does chdir(cwd)
before exec. This can cause launching the wrong executable or failing to launch a command that
execvp would find under the child’s cwd.
Code

src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs[R32-40]

+    static string ResolveExecutableAbsolutePath(string command, string cwd, IReadOnlyDictionary<string, string> childEnv) {
+        if (Path.IsPathRooted(command)) return command;
+        if (command.Contains('/')) return Path.GetFullPath(command, cwd);
+
+        var path = childEnv.TryGetValue("PATH", out var p) ? p : Environment.GetEnvironmentVariable("PATH") ?? "";
+        foreach (var dir in path.Split(':', StringSplitOptions.RemoveEmptyEntries)) {
+            var candidate = Path.Combine(dir, command);
+            if (File.Exists(candidate)) return candidate;
+        }
Evidence
The managed resolver drops empty PATH fields and combines relative PATH entries without referencing
the child’s cwd, while the native child explicitly changes directory to cwd before exec, so
exec-time resolution differs from pre-fork resolution.

src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs[23-43]
src/Capacitor.Cli.Daemon/Native/pty_shim.c[581-596]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`UnixPtyProcess.ResolveExecutableAbsolutePath` resolves `command` by iterating `PATH.Split(':', RemoveEmptyEntries)` and doing `Path.Combine(dir, command)`, which (a) ignores empty PATH fields (which mean “current directory” on POSIX) and (b) resolves relative PATH entries against the daemon’s current working directory rather than the child’s `cwd`. Because the native child path explicitly `chdir(cwd)` before `exec*`, this changes which binary gets executed.

## Issue Context
The new Unix path resolves the executable pre-fork and then the native shim performs `chdir(cwd)` in the child before `execve/execveat`.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs[23-43]

### Concrete fix approach
- Split PATH with `StringSplitOptions.None` (not `RemoveEmptyEntries`) so empty fields are preserved.
- For each PATH entry:
 - If entry is empty: treat as `cwd`.
 - Else if entry is relative: resolve with `Path.GetFullPath(entry, cwd)`.
 - Else: use as-is.
- Combine the resulting base directory with `command` and only return an absolute, normalized path.
- Keep existing behavior for rooted commands and commands containing '/'.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Spawner dispose race 🐞 Bug ☼ Reliability
Description
UnixSpawnerThread.Dispose only waits 5 seconds for the foreground spawner thread to exit and then
disposes the BlockingCollection even if the thread is still running. Since pty_spawn can block for
~30 seconds, shutdown can be delayed and disposing the collection can trigger an exception escaping
the run loop during shutdown (the outer catch is disabled when _stopping is true).
Code

src/Capacitor.Cli.Daemon/Pty/Unix/UnixSpawnerThread.cs[R92-97]

+    public void Dispose() {
+        _stopping = true;
+        _queue.CompleteAdding();
+        _thread.Join(TimeSpan.FromSeconds(5));
+        _queue.Dispose();
+    }
Evidence
Dispose joins for only 5 seconds and then disposes the queue; the spawner thread is foreground and
can legitimately block inside pty_spawn for ~30 seconds, and the run loop’s outer catch won’t handle
exceptions when _stopping is true.

src/Capacitor.Cli.Daemon/Pty/Unix/UnixSpawnerThread.cs[33-36]
src/Capacitor.Cli.Daemon/Pty/Unix/UnixSpawnerThread.cs[59-87]
src/Capacitor.Cli.Daemon/Pty/Unix/UnixSpawnerThread.cs[90-97]
src/Capacitor.Cli.Daemon/Native/pty_shim.c[610-616]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`UnixSpawnerThread.Dispose()` calls `_thread.Join(TimeSpan.FromSeconds(5))` and then `_queue.Dispose()` regardless of whether the thread has actually stopped. Because the spawner thread is foreground (`IsBackground = false`) and can be inside `pty_spawn` for up to ~30s, the dispose path can (1) return while the process still has a live foreground thread (delayed termination), and (2) dispose the BlockingCollection while `GetConsumingEnumerable()` is still active, potentially throwing during shutdown.

## Issue Context
- The native shim’s `pty_spawn` handshake uses a ~30 second deadline.
- The run loop wraps `GetConsumingEnumerable()` in a try/catch that only triggers when `_stopping` is false; exceptions during shutdown are not handled.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Pty/Unix/UnixSpawnerThread.cs[59-97]
- src/Capacitor.Cli.Daemon/Native/pty_shim.c[610-616]

### Concrete fix approach
- Ensure the thread has exited before disposing `_queue`:
 - Prefer `_thread.Join()` with no timeout, or
 - Join with a timeout >= native worst-case (>= 30s) and if it elapses, fail fast / throw and do not dispose `_queue`.
- Only call `_queue.Dispose()` after a successful join.
- Optionally: if you intend bounded shutdown, add a cancellation mechanism wired to `cancelFd` and signal it during dispose, so in-flight `pty_spawn` can exit promptly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Null StartIdentity crash 🐞 Bug ☼ Reliability
Description
AgentPidRecordStore.ReadAll dereferences record.StartIdentity.Length outside the
JSON-deserialization try/catch. A parseable record with missing/null start_identity can throw
NullReferenceException and abort OrphanReaper’s record pass.
Code

src/Capacitor.Cli.Daemon/Services/AgentPidRecordStore.cs[R68-81]

+            // M1-A (spec §4.3): the only rejected shapes are NEW-schema-inconsistent combinations —
+            // Present claiming a comparable identity with an empty token, or IdentityUnavailable
+            // claiming NO comparable identity while still carrying a nonempty one. A LEGACY record
+            // (no identity_kind key at all) always decodes as Present (PidIdentityKind's zero value)
+            // and is never rejected here, however old its token scheme.
+            var inconsistent =
+                (record.IdentityKind == PidIdentityKind.Present && record.StartIdentity.Length == 0) ||
+                (record.IdentityKind == PidIdentityKind.IdentityUnavailable && record.StartIdentity.Length != 0);
+
+            if (inconsistent) {
+                logger.LogWarning(
+                    "AgentPidRecordStore: record {Path} has an inconsistent identity_kind/start_identity combination ({Kind}, token length {Len}); quarantining as .corrupt",
+                    path, record.IdentityKind, record.StartIdentity.Length);
+                TryQuarantine(path);
Evidence
ReadAll uses record.StartIdentity.Length after deserialization without null validation, and
OrphanReaper enumerates ReadAll() directly, so any NRE in ReadAll aborts the reap sweep.

src/Capacitor.Cli.Daemon/Services/AgentPidRecordStore.cs[52-83]
src/Capacitor.Cli.Daemon/Services/OrphanReaper.cs[30-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`AgentPidRecordStore.ReadAll()` does per-record schema validation using `record.StartIdentity.Length` without first validating `StartIdentity` is non-null. Because the deserialization try/catch only catches thrown exceptions, a parseable-but-incomplete JSON shape can produce `StartIdentity == null` and then crash the entire read/sweep.

## Issue Context
`OrphanReaper.ReapOnceAsync` iterates `store.ReadAll()` directly; an exception from `ReadAll()` aborts the orphan sweep.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/AgentPidRecordStore.cs[52-86]
- src/Capacitor.Cli.Daemon/Services/OrphanReaper.cs[30-36]

### Concrete fix approach
- Treat `StartIdentity == null` as corrupt and quarantine:
 - Before computing `inconsistent`, add `if (record.StartIdentity is null) { log; TryQuarantine(path); continue; }`
- Use the local non-null variable for `Length` checks (and for logging length) to avoid multiple dereferences.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +32 to +40
static string ResolveExecutableAbsolutePath(string command, string cwd, IReadOnlyDictionary<string, string> childEnv) {
if (Path.IsPathRooted(command)) return command;
if (command.Contains('/')) return Path.GetFullPath(command, cwd);

var path = childEnv.TryGetValue("PATH", out var p) ? p : Environment.GetEnvironmentVariable("PATH") ?? "";
foreach (var dir in path.Split(':', StringSplitOptions.RemoveEmptyEntries)) {
var candidate = Path.Combine(dir, command);
if (File.Exists(candidate)) return candidate;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Path cwd mismatch 🐞 Bug ≡ Correctness

UnixPtyProcess.ResolveExecutableAbsolutePath resolves bare command names using PATH entries relative
to the daemon’s current directory and drops empty PATH components, but the child does chdir(cwd)
before exec. This can cause launching the wrong executable or failing to launch a command that
execvp would find under the child’s cwd.
Agent Prompt
## Issue description
`UnixPtyProcess.ResolveExecutableAbsolutePath` resolves `command` by iterating `PATH.Split(':', RemoveEmptyEntries)` and doing `Path.Combine(dir, command)`, which (a) ignores empty PATH fields (which mean “current directory” on POSIX) and (b) resolves relative PATH entries against the daemon’s current working directory rather than the child’s `cwd`. Because the native child path explicitly `chdir(cwd)` before `exec*`, this changes which binary gets executed.

## Issue Context
The new Unix path resolves the executable pre-fork and then the native shim performs `chdir(cwd)` in the child before `execve/execveat`.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Pty/Unix/UnixPtyProcess.cs[23-43]

### Concrete fix approach
- Split PATH with `StringSplitOptions.None` (not `RemoveEmptyEntries`) so empty fields are preserved.
- For each PATH entry:
  - If entry is empty: treat as `cwd`.
  - Else if entry is relative: resolve with `Path.GetFullPath(entry, cwd)`.
  - Else: use as-is.
- Combine the resulting base directory with `command` and only return an absolute, normalized path.
- Keep existing behavior for rooted commands and commands containing '/'.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +92 to +97
public void Dispose() {
_stopping = true;
_queue.CompleteAdding();
_thread.Join(TimeSpan.FromSeconds(5));
_queue.Dispose();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Spawner dispose race 🐞 Bug ☼ Reliability

UnixSpawnerThread.Dispose only waits 5 seconds for the foreground spawner thread to exit and then
disposes the BlockingCollection even if the thread is still running. Since pty_spawn can block for
~30 seconds, shutdown can be delayed and disposing the collection can trigger an exception escaping
the run loop during shutdown (the outer catch is disabled when _stopping is true).
Agent Prompt
## Issue description
`UnixSpawnerThread.Dispose()` calls `_thread.Join(TimeSpan.FromSeconds(5))` and then `_queue.Dispose()` regardless of whether the thread has actually stopped. Because the spawner thread is foreground (`IsBackground = false`) and can be inside `pty_spawn` for up to ~30s, the dispose path can (1) return while the process still has a live foreground thread (delayed termination), and (2) dispose the BlockingCollection while `GetConsumingEnumerable()` is still active, potentially throwing during shutdown.

## Issue Context
- The native shim’s `pty_spawn` handshake uses a ~30 second deadline.
- The run loop wraps `GetConsumingEnumerable()` in a try/catch that only triggers when `_stopping` is false; exceptions during shutdown are not handled.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Pty/Unix/UnixSpawnerThread.cs[59-97]
- src/Capacitor.Cli.Daemon/Native/pty_shim.c[610-616]

### Concrete fix approach
- Ensure the thread has exited before disposing `_queue`:
  - Prefer `_thread.Join()` with no timeout, or
  - Join with a timeout >= native worst-case (>= 30s) and if it elapses, fail fast / throw and do not dispose `_queue`.
- Only call `_queue.Dispose()` after a successful join.
- Optionally: if you intend bounded shutdown, add a cancellation mechanism wired to `cancelFd` and signal it during dispose, so in-flight `pty_spawn` can exit promptly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +68 to +81
// M1-A (spec §4.3): the only rejected shapes are NEW-schema-inconsistent combinations —
// Present claiming a comparable identity with an empty token, or IdentityUnavailable
// claiming NO comparable identity while still carrying a nonempty one. A LEGACY record
// (no identity_kind key at all) always decodes as Present (PidIdentityKind's zero value)
// and is never rejected here, however old its token scheme.
var inconsistent =
(record.IdentityKind == PidIdentityKind.Present && record.StartIdentity.Length == 0) ||
(record.IdentityKind == PidIdentityKind.IdentityUnavailable && record.StartIdentity.Length != 0);

if (inconsistent) {
logger.LogWarning(
"AgentPidRecordStore: record {Path} has an inconsistent identity_kind/start_identity combination ({Kind}, token length {Len}); quarantining as .corrupt",
path, record.IdentityKind, record.StartIdentity.Length);
TryQuarantine(path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Null startidentity crash 🐞 Bug ☼ Reliability

AgentPidRecordStore.ReadAll dereferences record.StartIdentity.Length outside the
JSON-deserialization try/catch. A parseable record with missing/null start_identity can throw
NullReferenceException and abort OrphanReaper’s record pass.
Agent Prompt
## Issue description
`AgentPidRecordStore.ReadAll()` does per-record schema validation using `record.StartIdentity.Length` without first validating `StartIdentity` is non-null. Because the deserialization try/catch only catches thrown exceptions, a parseable-but-incomplete JSON shape can produce `StartIdentity == null` and then crash the entire read/sweep.

## Issue Context
`OrphanReaper.ReapOnceAsync` iterates `store.ReadAll()` directly; an exception from `ReadAll()` aborts the orphan sweep.

## Fix Focus Areas
- src/Capacitor.Cli.Daemon/Services/AgentPidRecordStore.cs[52-86]
- src/Capacitor.Cli.Daemon/Services/OrphanReaper.cs[30-36]

### Concrete fix approach
- Treat `StartIdentity == null` as corrupt and quarantine:
  - Before computing `inconsistent`, add `if (record.StartIdentity is null) { log; TryQuarantine(path); continue; }`
- Use the local non-null variable for `Length` checks (and for logging length) to avoid multiple dereferences.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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