Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/persona-kit/src/interactive-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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.
// 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, [
...(expectShell ? ['-c', 'exec "$0" "$@"', 'opencode'] : []),
'run',
'--agent',
'daily-ship',
Expand Down
37 changes: 35 additions & 2 deletions packages/persona-kit/src/interactive-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,9 +738,42 @@ 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.
//
// 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: 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
Expand Down
Loading