Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

managed_shell.exe — Windows Job Object Shell Wrapper for OpenCode

A minimal SHELL/COMSPEC wrapper that places every shell invocation inside a per-call Windows Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. When the wrapper exits (normal or forced kill), Windows guarantees all descendant processes are terminated.

Designed for OpenCode's built-in bash execution, where orphaned child processes can otherwise accumulate and block subsequent commands.

Status: reference implementation / workaround.
This is not an official OpenCode feature. It may help investigate and mitigate symptoms related to OpenCode issue #29822 and similar orphan‑process problems on Windows.


Table of Contents


Problem

OpenCode's built-in bash on Windows works by creating a child shell process for each command. When a command times out or is interrupted, the child can leave behind orphaned processes. These orphans:

  • Hold ports or file locks.
  • Accumulate across successive commands.
  • Can prevent the next command from starting correctly.

The same issue can affect any tool that uses CreateProcess without a Job Object — child processes are not automatically terminated when their creator exits.

Why Windows Job Objects

Windows provides a kernel-level mechanism called Job Objects. A Job Object can be configured with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, which tells the kernel: "when this Job handle is closed, terminate every process assigned to it."

Key properties:

  • Kernel-guaranteed — cannot be bypassed by the child process.
  • No polling required — termination is instant when the handle closes.
  • Works on forced kill — even if managed_shell.exe is TerminateProcess'd, the Job handle closes and Windows cleans up the job tree.
  • Per-call isolation — each invocation creates its own Job Object, so killing one command never affects another.

Architecture

OpenCode built-in bash
        │  SHELL=managed_shell.exe  (or COMSPEC)
        ▼
┌─────────────────────────┐
│   managed_shell.exe     │
│                         │
│  1. Parse invocation    │
│     (-c / -lc → pwsh    │
│      /c      → cmd)     │
│                         │
│  2. Create Job Object   │
│     (KILL_ON_JOB_CLOSE) │
│                         │
│  3. CreateProcessW      │
│     (CREATE_SUSPENDED)   │
│                         │
│  4. AssignProcessTo     │
│     JobObject            │
│                         │
│  5. ResumeThread         │
│                         │
│  6. WaitForSingleObject  │
│     (INFINITE)           │
│                         │
│  7. Close Job handle     │
│     → Windows kills      │
│       all descendants    │
│                         │
│  8. Return exit code     │
└─────────────────────────┘
        │
        ▼
  pwsh.exe / cmd.exe
        │
        ▼
   Your command

The Job Object is created before the child process, ensuring there is never a race window where the child runs without job protection. The child starts in CREATE_SUSPENDED state, gets assigned to the Job, and only then resumes.

Requirements

Component Requirement
OS Windows 10/11 or Server 2019+ (x64)
.NET .NET Framework 4.8 (runtime only; ships with Windows)
Compiler csc.exe from .NET Framework 4.8 SDK (included with Windows SDK / Visual Studio Build Tools)
PowerShell PowerShell 7+ (pwsh.exe) for SHELL mode
OpenCode Tested with OpenCode 1.17.20 on Windows

The compiler path is typically:

C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe

Build

From the repository root:

scripts\build.bat

Or manually:

"%SystemRoot%\Microsoft.NET\Framework64\v4.0.30319\csc.exe" /nologo /target:exe /platform:anycpu /out:managed_shell.exe src\managed_shell.cs

The build script automatically runs a struct-layout validation after compiling.

Output: managed_shell.exe at the repository root.

OpenCode Setup

⚠️ The examples below set SHELL only for the current session (per-process environment inheritance). This is the recommended approach to avoid system-wide changes.

Per-session activation (PowerShell)

# Before starting OpenCode
$env:SHELL = "C:\full\path\to\managed_shell.exe"
# Optional: explicitly set pwsh path to skip PATH search
$env:MANAGED_SHELL_PWSH = "C:\Program Files\PowerShell\7\pwsh.exe"
# Then start OpenCode:
opencode web

Per-session activation (CMD)

set SHELL=C:\full\path\to\managed_shell.exe
set MANAGED_SHELL_PWSH=C:\Program Files\PowerShell\7\pwsh.exe
opencode web

What "per-session" means

  • The environment variables affect only the command shell and its child processes.
  • Closing the terminal / logoff restores the default behaviour.
  • No system or user environment variables are permanently modified.

Using COMSPEC mode (alternative)

set COMSPEC=C:\full\path\to\managed_shell.exe

Note: On OpenCode 1.17.20, setting SHELL is the effective method — the built-in bash does not query COMSPEC. This may change in future versions.

MANAGED_SHELL_PWSH environment variable

If set, it must be an absolute path to a pwsh.exe that is not managed_shell.exe itself (to prevent recursion). If the path is relative, does not exist, or points to self, the wrapper fails closed (exit code 2) and does not fall back to PATH search.

If not set, the wrapper searches:

  1. PATH for pwsh.exe (major version >= 7, must not be self).
  2. %ProgramFiles%\PowerShell\7\pwsh.exe (major version >= 7).

Usage

managed_shell.exe -- SHELL/COMSPEC wrapper with Job Object lifecycle

SHELL mode (set SHELL=managed_shell.exe):
  managed_shell.exe -c "<command>"        -> pwsh -Command <command>
  managed_shell.exe -lc "<command>"       -> pwsh -Command <command>
  managed_shell.exe -l -c "<command>"     -> pwsh -Command <command>

COMSPEC mode (set COMSPEC=managed_shell.exe):
  managed_shell.exe /c "<command>"        -> C:\Windows\System32\cmd.exe /c "<command>"

Test mode:
  managed_shell.exe --managed-shell-test spawn <sec> [ec]
  managed_shell.exe --managed-shell-test validate-structs

The --managed-shell-test spawn command spawns a ping 127.0.0.1 loop for <sec> seconds (default 10), then exits with the optional exit code [ec]. This is used by the kill/isolation tests.

Supported Invocation Forms

SHELL mode (-c / -lc)

All of the following are accepted and equivalent:

Form Example
-c <cmd> -c "Write-Output hello"
-lc <cmd> -lc "Write-Output hello"
-lc -c <cmd> -lc -c "Write-Output hello"
-lc -l -c <cmd> -lc -l -c "Write-Output hello"
-l -c <cmd> -l -c "Write-Output hello"

Any unrecognised flag starting with - (e.g. -x) causes exit code 2. The wrapper does not silently fall through to COMSPEC mode.

COMSPEC mode (/c)

Form Example
/c <cmd> /c "echo hello"
/d /s /c <cmd> /d /s /c "echo hello"

Tests

Three test suites cover basic functionality, forced-kill lifecycle, and background-process cleanup.

# From repository root:
pwsh -NoProfile .\tests\test_basic.ps1
pwsh -NoProfile .\tests\test_kill.ps1
pwsh -NoProfile .\tests\test_background.ps1

test_basic.ps1 (~40 tests)

  • Empty args / help
  • COMSPEC mode: echo, spaces, unicode, pipe, chaining, exit codes
  • SHELL mode: basic output, pipeline, chaining, env vars, exit codes
  • PowerShell 7 && chaining, stderr+nonzero exit, long output (>1000 chars)
  • Python -c nested quoting (conditional on python availability)
  • git / rg smoke tests (conditional)
  • Struct layout validation (x64: IntPtr=8, JOBOBJECT_BASIC=64, JOBOBJECT_EXTENDED=144, STARTUPINFOW=104, PROCESS_INFORMATION=24)
  • MANAGED_SHELL_PWSH env var override (valid/relative/nonexistent/self)
  • SHELL protocol variants (5 forms)
  • Missing command → ec=2, unknown flags → ec=2
  • Log security: sensitive data not written to JSONL logs

test_kill.ps1 (~7 assertions)

  • Test 1: Spawn wrapper with 30s ping child → kill wrapper → verify child exits within 3s
  • Test 2: Spawn two wrappers (15s and 30s) → kill one → verify its child dies but the other survives → wait for surviving instance to exit normally

test_background.ps1 (~3 assertions)

  • Test 1: Wrapper runs PowerShell that starts a background Start-Sleep 60 process → wrapper returns → verify background is terminated by KILL_ON_JOB_CLOSE
  • Test 2: Two independent wrappers each start background processes → both wrappers exit → both backgrounds are terminated independently

CI (GitHub Actions)

The .github/workflows/windows.yml workflow builds and runs all three suites on windows-latest with timeouts to prevent hanging.

Logging / Privacy

The wrapper writes a minimal JSONL audit log to:

%TEMP%\managed_shell\managed_shell_YYYY-MM-DD.jsonl

Each line contains:

  • ts: ISO 8601 timestamp
  • pid: managed_shell's own PID
  • childPid: PID of the child process (pwsh or cmd)
  • event: "start", "exit", or "error"
  • mode: "powershell" or "cmd"
  • detail: integer count (argc or exit code) or error Win32 code

No command text, arguments, environment variables, or secrets are ever logged. The detail field only carries non-sensitive numeric data.

If the log directory cannot be created, logging is silently disabled — the wrapper still functions normally.

Limitations

  1. Only covers built-in bash — OpenCode's pty_spawn, Desktop Commander, and SSH/MCP remote commands use separate execution channels and are not affected by the SHELL environment variable.

  2. Windows x64 only — the struct layout and IntPtr assumptions are x64-specific. x86 or ARM64 may require different struct sizes.

  3. Requires PowerShell 7+ — PowerShell 5.1 (powershell.exe) is not supported in SHELL mode. COMSPEC mode uses cmd.exe and works on all modern Windows versions.

  4. Infinite waitWaitForSingleObject(INFINITE) means the wrapper waits forever for the command to complete. OpenCode's timeout mechanism (which kills the wrapper) handles the timeout case — the wrapper itself does not implement a timeout.

  5. Not an official OpenCode feature — this is a third-party workaround. It works with OpenCode 1.17.20 but may break or become unnecessary in future versions.

  6. Recursion guard — the wrapper refuses to use itself as the target shell (IsSelf check), but care should be taken not to chain wrappers through other means.

  7. Log accumulation — audit logs in %TEMP%\managed_shell\ accumulate one file per day with no automatic rotation or size limit. On long-running or frequently-used systems, periodic cleanup (e.g., a scheduled task deleting logs older than N days) is recommended.

Uninstall / Rollback

Since the setup only modifies per-session environment variables, no permanent changes are made. To restore the default:

  1. Close the terminal where SHELL / COMSPEC was set, or unset the variables:
    $env:SHELL = $null
    $env:COMSPEC = $null
    $env:MANAGED_SHELL_PWSH = $null
  2. Restart OpenCode — it will use its default shell behaviour.
  3. Delete managed_shell.exe and log directory %TEMP%\managed_shell\ if desired.

If you modified a startup script (not recommended), restore it from backup or remove the SHELL / MANAGED_SHELL_PWSH lines.

Security Notes

  • The wrapper uses JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE which gives the kernel authority to terminate all job processes when the handle closes. This is the intended behaviour — it is what makes orphan cleanup reliable.
  • bInheritHandles=false in CreateProcessW prevents the child from inheriting any handles (console, pipes, etc.) from the parent. The Job handle itself is also non-inheritable — CreateJobObjectW is called with NULL security attributes, so no child process can inherit it through CreateProcess. Once assigned to the job, a child process cannot remove itself because the job's KILL_ON_JOB_CLOSE limit is already set and the child lacks the required access to modify it.
  • CREATE_SUSPENDEDAssignProcessToJobObjectResumeThread ensures the child is assigned to the job before it executes a single instruction.
  • Log files contain only metadata (timestamps, PIDs, exit codes). No commands, arguments, or secrets are written to disk.
  • The wrapper does not use CREATE_NO_WINDOW. The child process inherits the caller's console, preserving stdout/stderr and interactivity.

Related Upstream Issues

  • OpenCode #29822 — Child processes not killed after timeout (the primary motivation for this project).

License

MIT — see LICENSE.

Copyright (c) 2026 Daniel Zhao

About

A Windows Job Object shell wrapper that cleans up child processes from OpenCode shell calls.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages