From bef56f1462196e2c91a1239eabbdf11c9fd5b75b Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 1 Sep 2026 09:34:23 +0200 Subject: [PATCH 1/2] fix(persona-kit): spawn opencode through a shell exec (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawned directly by the runtime, `opencode run` fails in ~1.2s with an opaque error and writes nothing: Error: {"name":"UnknownError","data":{"message":"Unexpected server error. Check server logs for details.","ref":"err_bcc367a8"}} Its own log file (~/.local/share/opencode/log/opencode.log) is created but stays 0 bytes. With one shell between Node and the binary — same binary, args, cwd, env, stdin, model and credential — the identical invocation runs normally. Verified end to end on daily-ship: the opaque failure without the shell, real digests posted to Slack with it (49s and 54s, first attempt). This is empirically derived, and deliberately narrow rather than "wrap it and hope". Ruled out by direct experiment in a live sandbox: - PATH (the runner's already resolves opencode) - prompt size and content (119KB synthetic and a real 72KB both fine) - provider flakiness (6/6 consecutive clean runs) - NODE_OPTIONS (the throttle-retry hook) - HOME / XDG_DATA_HOME, USER / LOGNAME, cwd, FD limits (`ulimit -n 64`) - stdin write race (stdin delayed 3s is still read correctly) - inherited SIGPIPE disposition — re-ignoring it deliberately (`trap "" PIPE`) still SUCCEEDED, so signal inheritance is not it - the process group — `timeout`'s setpgid is not needed either; bash exec with no timeout also succeeded The shell alone is what matters. The underlying reason opencode behaves differently under a direct execve is still unknown and worth an upstream report; this makes the harness usable meanwhile. `exec` replaces the shell, so the process count is unchanged, the caller still waits on the real opencode process, and its exit code and output are reported verbatim. Only the opencode branch is routed this way — claude, codex and grok spawn directly and are untouched. Refs #330 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RNKZhUhEazLwGSxCG2w8KT --- .../persona-kit/src/interactive-spec.test.ts | 12 +++++++-- packages/persona-kit/src/interactive-spec.ts | 27 +++++++++++++++++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/persona-kit/src/interactive-spec.test.ts b/packages/persona-kit/src/interactive-spec.test.ts index e99123fc..d07bf406 100644 --- a/packages/persona-kit/src/interactive-spec.test.ts +++ b/packages/persona-kit/src/interactive-spec.test.ts @@ -324,7 +324,7 @@ test('opencode configFiles carries a well-formed opencode.json with the agent de }); }); -test('opencode non-interactive spec omits cwd/model flags and normalizes the agent model', () => { +test('opencode non-interactive spec execs through a shell, omits cwd/model flags, and normalizes the agent model', () => { const result = buildNonInteractiveSpec({ harness: 'opencode', personaId: 'daily-ship', @@ -335,8 +335,16 @@ test('opencode non-interactive spec omits cwd/model flags and normalizes the age workingDirectory: '/tmp/project' }); - assert.equal(result.bin, 'opencode'); + // workforce#330: opencode is spawned through `sh -c 'exec "$0" "$@"'`. + // Spawned directly by the runtime it fails in ~1.2s with an opaque + // `UnknownError: Unexpected server error`; with one shell in between the + // identical invocation runs normally. `exec` means the shell is replaced, so + // the caller still waits on the real opencode process. + assert.equal(result.bin, '/bin/sh'); assert.deepEqual(result.args, [ + '-c', + 'exec "$0" "$@"', + 'opencode', 'run', '--agent', 'daily-ship', diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index 41c863d0..4c648bb3 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -738,9 +738,32 @@ export function buildNonInteractiveSpec( // separately, and `opencode run` does not support `--dir`. const args = ['run', ...interactive.args, '--format', 'default']; if (input.name) args.push('--title', input.name); + // Spawn opencode through `sh -c 'exec "$0" "$@"'` rather than exec'ing the + // binary directly (workforce#330). Spawned directly by the runtime, + // opencode fails in ~1.2s with an opaque + // Error: {"name":"UnknownError","data":{"message":"Unexpected server + // error. Check server logs for details.","ref":"..."}} + // and its own log file is created but stays 0 bytes. With one shell + // between Node and the binary — same binary, args, cwd, env, stdin, + // model and credential — it runs normally. Verified end to end on + // daily-ship: opaque failure without, real digests posted with. + // + // Ruled out by direct experiment in a live sandbox, so this is narrower + // than "wrap it and hope": PATH; prompt size and content (119KB synthetic + // and a real 72KB prompt both fine); provider flakiness (6/6 clean); + // NODE_OPTIONS; HOME / XDG_DATA_HOME; USER / LOGNAME; cwd; FD limits down + // to `ulimit -n 64`; a stdin write race (delayed stdin is read fine); + // inherited SIGPIPE disposition (deliberately re-ignoring it still + // succeeded); and the process group (`timeout`'s setpgid — bash exec with + // no timeout also succeeded). The shell alone is what matters. + // + // `exec` keeps the process count unchanged, so the caller still waits on + // the real opencode process and its exit code is reported verbatim. Only + // the opencode branch is routed this way; claude / codex / grok spawn + // directly and are unaffected. return { - bin: interactive.bin, - args, + bin: '/bin/sh', + args: ['-c', 'exec "$0" "$@"', interactive.bin, ...args], prompt: { mode: 'stdin', contents: input.task }, configFiles: interactive.configFiles, warnings: interactive.warnings From 1ab335cbf1f4d471245b6b6932e31e96e2055883 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Tue, 1 Sep 2026 09:47:35 +0200 Subject: [PATCH 2/2] fix(persona-kit): keep the opencode shell exec off Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that hardcoding `/bin/sh` breaks win32: that path does not exist on a standard Windows install, so every non-interactive opencode spawn would ENOENT before reaching the binary. That reaches real consumers — the CLI persona improver passes `spec.bin` straight to spawnNonInteractiveAndCapture — and the repo supports Windows elsewhere (persona-kit/detect.ts, cli/persona-install.ts, cli/invoke/prepare-target.ts). Gate the wrapper on `process.platform !== 'win32'`, the same split the sandbox exec path already uses (runtime/src/cloud-defaults.ts:185). Windows keeps the direct spawn deliberately, not as a fallback of convenience: the failure this works around is observed in the Linux cloud sandboxes, and `cmd.exe` has no exec-replacement semantics, so wrapping there would add a process rather than replace one and break exit-code and signal passthrough. The spec test now derives the expected shape from the platform, so it asserts the wrapper on POSIX and the direct spawn on win32 instead of hardcoding one. Co-Authored-By: Claude Opus 5 --- packages/persona-kit/src/interactive-spec.test.ts | 8 ++++---- packages/persona-kit/src/interactive-spec.ts | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/persona-kit/src/interactive-spec.test.ts b/packages/persona-kit/src/interactive-spec.test.ts index d07bf406..40ec4ebd 100644 --- a/packages/persona-kit/src/interactive-spec.test.ts +++ b/packages/persona-kit/src/interactive-spec.test.ts @@ -340,11 +340,11 @@ test('opencode non-interactive spec execs through a shell, omits cwd/model flags // `UnknownError: Unexpected server error`; with one shell in between the // identical invocation runs normally. `exec` means the shell is replaced, so // the caller still waits on the real opencode process. - assert.equal(result.bin, '/bin/sh'); + // Windows has no /bin/sh, so the wrapper is POSIX-only (see the spec builder). + const expectShell = process.platform !== 'win32'; + assert.equal(result.bin, expectShell ? '/bin/sh' : 'opencode'); assert.deepEqual(result.args, [ - '-c', - 'exec "$0" "$@"', - 'opencode', + ...(expectShell ? ['-c', 'exec "$0" "$@"', 'opencode'] : []), 'run', '--agent', 'daily-ship', diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index 4c648bb3..1e957913 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -761,9 +761,19 @@ export function buildNonInteractiveSpec( // the real opencode process and its exit code is reported verbatim. Only // the opencode branch is routed this way; claude / codex / grok spawn // directly and are unaffected. + // + // Windows has no `/bin/sh`, and `cmd.exe` has no exec-replacement + // semantics — wrapping there would ADD a process rather than replace one, + // breaking exit-code and signal passthrough. The failure this works around + // is observed in the Linux cloud sandboxes, so win32 keeps the direct + // spawn. Same platform split the sandbox exec path already uses + // (runtime/src/cloud-defaults.ts). + const execThroughShell = process.platform !== 'win32'; return { - bin: '/bin/sh', - args: ['-c', 'exec "$0" "$@"', interactive.bin, ...args], + bin: execThroughShell ? '/bin/sh' : interactive.bin, + args: execThroughShell + ? ['-c', 'exec "$0" "$@"', interactive.bin, ...args] + : args, prompt: { mode: 'stdin', contents: input.task }, configFiles: interactive.configFiles, warnings: interactive.warnings