Subprocess Safety
SkillFiles & storageGuidelines for safe subprocess calls in opencode-swarm. Load before adding, modifying, or reviewing any file that calls spawn, spawnSync, bunSpawn, or child_process. Covers the six required properties, Windows portability, _internals DI seam pattern, and verification grep.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Subprocess Safety skill
What this skill tells your AI
The instructions your AI receives, as published by zaxbyhub/opencode-swarm in .agents/skills/subprocess-safety/SKILL.md and read by ahel’s review.
Read, in order:
AGENTS.md(Invariant 3: subprocesses)docs/engineering-invariants.md(subsection 3).agents/skills/writing-tests/SKILL.mdif tests are touched.opencode/skills/generated/mock-to-internals-migration/SKILL.mdif converting mock.module to _internals
Codex-specific execution notes:
- This skill consolidates AGENTS.md Invariant 3 into an actionable checklist.
- The canonical spawn shape and six required properties are non-negotiable per AGENTS.md.
- The CI quality job enforces these via
bun run check:invariants(Check 1: subprocess timeout). - Violations are advisory in CI but blocking in code review.
When to use this skill
- You are adding, modifying, or reviewing a subprocess call (
bunSpawn,spawn,spawnSync,child_process.execFile, etc.) - You are writing or updating tests that exercise subprocess-dependent code
- A PR review flags a subprocess call missing timeout, cwd, or cleanup
Scope
This skill applies to all files that spawn child processes:
src/utils/git*.tssrc/hooks/*.tssrc/tools/*.tssrc/services/*.tssrc/plugins/*.tssrc/index.ts(init-path subprocesses)- Any test file (
tests/**) that stubs or exercises subprocess code
Canonical spawn shape
Every subprocess call MUST follow this pattern:
const PER_CALL_TIMEOUT_MS = 10_000; // module-level constant (choose an appropriate value)
const proc = bunSpawn(['git', '-C', dir, 'rev-parse', '--show-toplevel'], {
stdin: 'ignore',
cwd: dir,
timeout: PER_CALL_TIMEOUT_MS,
// stdout/stderr: piped, bounded, or ignored
});
try {
const result = await proc;
// process result
} finally {
proc.kill(); // best-effort cleanup
}
Six required properties
| Property | Required | Rationale |
|---|---|---|
| Array-form args | Yes | No shell-string commands (injection risk, quoting hell) |
cwd or git -C | Yes | Never rely on inherited process.cwd() |
stdin: 'ignore' | Yes | A never-closed stdin pipe under Bun/Windows can block child exit (v7.3.3) |
timeout: <ms> | Yes | No subprocess is "always fast" on every platform |
| stdout/stderr bounded | Yes | Never leave piped stream unattended on long-running child |
proc.kill() in finally | Yes | Outer withTimeout lets awaiter proceed but doesn't abort child |
execFile callback vs execFileSync distinction
child_process.execFile (callback form) and child_process.execFileSync have different
default stdio behavior:
| API | Default stdin | Risk |
|---|---|---|
execFileSync | 'inherit' | Child inherits parent stdin — v7.3.3 vector on Windows/Bun if stdin is never closed |
execFile (callback) | 'pipe' | Child gets an internal pipe — lower risk but still not ideal for defense-in-depth |
Key differences from the canonical spawn pattern:
-
proc.kill()infinally(line 69): Applicable to callback-formexecFile. The function returns aChildProcessreference (matching the canonical spawn pattern per Node.js docs). The child reference enableskill()before that point for timeout safety, and failing to callproc.kill()infinallycan leave orphaned children when combined with an outerwithTimeout. Thetimeoutoption triggers internalSIGTERM, but is not a substitute for explicit kill infinally— always kill the child infinally. -
stdin: 'ignore'(line 66): Technically default-safe for callbackexecFile(stdin is piped, not inherited). However, always addstdio: ['ignore', 'pipe', 'pipe']for defense-in-depth and consistency withexecFileSynccalls. Note: Bun's TypeScript definitions do not includestdioinExecFileOptions— useexecOpts as anywhen passing stdio to callback-formexecFile. -
execFileSyncshould always usestdio: ['ignore', 'pipe', 'pipe']to prevent the stdin-inheritance hang on Windows/Bun (v7.3.3).
Windows-specific notes
.cmdextensions: npm/bun binaries on Windows are.cmdwrappers. Resolve the executable path explicitly usingwhich/whereor the project's cross-platform helper. Do NOT enableshell: trueor shell-mediated execution to work around PATH resolution.- PATH differences:
cmd.exeand PowerShell resolve PATH differently. Test on Windows, not just macOS/Linux. child_process.spawn('bin', ...)does not behave identically to running undercmd.exe. Use array-form args and explicitcwd.fs.renameSynccannot overwrite existing directories on Windows. Use a remove-then-rename pattern orfs.renamewith error handling.
gh CLI Subprocess Patterns
The gh CLI is a common subprocess in this repo (scripts/release-notes-fragments.mjs, CI workflows). It follows the same six required properties as all subprocesses, plus several gh-specific patterns.
gh api --paginate requires --slurp
Bug pattern (PR #1762 F-002): gh api --paginate without --slurp produces concatenated JSON arrays on stdout. JSON.parse() can only parse the first array — subsequent arrays cause a parse error or are silently lost.
Correct pattern:
const raw = execFileSync('gh', ['api', '--paginate', '--slurp', 'repos/.../pulls', ...], {
encoding: 'utf8',
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'], // required for execFileSync (AGENTS.md §3)
});
// --slurp wraps paginated results as [[page1], [page2], ...]
const pages = JSON.parse(raw);
const allItems = pages.flat(); // flatten to single array
Without --slurp: stdout is [item1, item2][item3, item4] — invalid JSON after the first array. This is a silent data loss bug that only manifests when results span multiple pages (>30 items by default).
stdin: 'ignore' for gh calls
gh subprocess calls must include stdin: 'ignore' (or stdio: ['ignore', 'pipe', 'pipe'] for execFileSync). This is the same invariant as all subprocesses (AGENTS.md §3). For example, scripts/release-notes-fragments.mjs defines ghJson() and ghText() helpers using execFileSync — these must include stdio: ['ignore', 'pipe', 'pipe'] per the six required properties. A PR review (pre-merge) identified this gap.
Number.isInteger() for API response validation
When validating integer IDs from API responses (PR numbers, issue numbers, run IDs), use Number.isInteger(), not Number.isFinite(). Number.isFinite() accepts floats like 1.5, which are never valid IDs.
// Correct
function isValidPrNumber(n) {
return Number.isInteger(n) && n > 0;
}
// Wrong — accepts 1.5, NaN, Infinity
function isValidPrNumber(n) {
return Number.isFinite(n) && n > 0;
}
Note: This is a stricter pattern. Some existing code uses
Number.isFinite()afterparseInt()— while technically safe for parsed integers,Number.isInteger()is the correct guard for all ID validation going forward.
maxBuffer for large API responses
gh api can return large payloads. Set maxBuffer: 16 * 1024 * 1024 (16 MiB) to prevent silent truncation. This is especially important for --paginate calls that aggregate multiple pages.
Note:
maxBufferis specific to Node.jschild_process.execFile/execFileSync. For Bun'sbunSpawn, use the equivalent output bounding option.
Testing pattern: _internals DI seam, NOT mock.module
mock.module(...) leaks across test files in Bun's shared test-runner process.
Use dependency injection instead:
// --- source file (e.g. src/utils/gitignore-warning.ts) ---
import { bunSpawn } from './bun-compat';
export const _internals: { bunSpawn: typeof bunSpawn } = { bunSpawn };
// In production code, call _internals.bunSpawn(...) instead of bunSpawn(...)
// --- test file ---
import { _internals } from '../../src/utils/gitignore-warning';
const real = _internals.bunSpawn;
beforeEach(() => { _internals.bunSpawn = stub; });
afterEach(() => { _internals.bunSpawn = real; });
For the full migration protocol, load the mock-to-internals-migration skill.
Verification grep
After changing any file with subprocess calls, run:
grep -n "bunSpawn\|spawn(\|spawnSync(" src/<changed>/*.ts
Every match MUST have all of:
timeoutset to a concrete millisecond valuestdin: 'ignore'(unless intentionally interactive; note: callback-formexecFileusesstdio: ['ignore', 'pipe', 'pipe']instead)cwdorgit -C <directory>for explicit working directoryproc.kill()in afinallyblock or equivalent cleanup path (exception: callback-formexecFilemanages cleanup internally viatimeoutoption)
Historical failures
- v7.0.3 (#704): repo-graph Desktop hang -- unbounded filesystem scan on plugin init. No timeout, no kill path. Result: "no agents in TUI/GUI" with no error message.
- v7.3.3 (#732): Git-hygiene startup regression --
ensureSwarmGitExcludedcalled git without timeout, stdin, or kill. Result: same silent failure on Windows.
Both caused OpenCode to silently drop the plugin manifest. Users saw no agents and no error. Every subprocess call is a potential repeat of these failures unless all six properties are enforced.
Signals
- GitHub stars
- 467
- Forks
- 51
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
subprocess-safety- Source
- github.com/zaxbyhub/opencode-swarm