Skip to content

fix: deliver harness prompts off argv#274

Merged
khaliqgant merged 2 commits into
mainfrom
fix/off-argv-harness-prompts
Jul 16, 2026
Merged

fix: deliver harness prompts off argv#274
khaliqgant merged 2 commits into
mainfrom
fix/off-argv-harness-prompts

Conversation

@khaliqgant

@khaliqgant khaliqgant commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

  • keep one-shot persona tasks out of argv for Claude, Codex, OpenCode, and Grok
  • deliver Claude/Codex/OpenCode prompts through stdin and Grok prompts through a private temporary --prompt-file
  • share the process launcher between cloud runtime and the CLI persona improver, including timeout and loud stdin/EPIPE handling
  • cover the off-argv invariant, a byte-for-byte 200 KB stdin round trip, and Grok prompt-file cleanup on child failure

Breaking API change

buildNonInteractiveSpec() no longer includes the task in NonInteractiveSpec.args; it returns a required prompt delivery descriptor instead. External consumers that previously spawned only spec.bin and spec.args must now deliver spec.prompt through stdin or a prompt file as directed. Ignoring the new field produces a prompt-less command. This is also called out under [Unreleased] in the persona-kit changelog.

Why

Linux applies two different exec limits: ARG_MAX caps argv + envp in aggregate, while MAX_ARG_STRLEN caps each individual argv element at 32 pages (131,072 bytes on the production sandbox). The prior implementation placed the complete per-run task in one argv element, so a daily-ship prompt containing PR facts could fail with spawn E2BIG even while the aggregate argv + env block remained below ARG_MAX.

On the probe host, getconf ARG_MAX was 1,048,576 bytes and the representative environment was about 7.1 KB, so prompt size dominated. macOS does not reproduce Linux's 128 KiB single-element boundary; this PR therefore tests the portable invariant directly: the 200 KB task marker never appears in argv and the remaining per-argument and total argv byte budgets stay below 128 KiB.

Empirical CLI probe

These canaries were run on macOS with the locally installed versions below. Each prompt asked the model to return a unique MAGIC_TOKEN; success means the token appeared in model stdout, not merely that the command exited successfully.

Harness Host version Off-argv delivery Canary result
Claude 2.1.211 bare stdin with --print --output-format text passed
Codex 0.144.0 stdin with codex exec ... - passed
OpenCode 1.17.18 bare stdin with opencode run --format default passed
Grok 0.2.99 real temp file via --prompt-file <path> passed

Negative evidence: OpenCode -f/--file is an attachment mechanism, not a prompt replacement (You must provide a message or a command without a positional message). Grok bare stdin failed with Device not configured, so it uses a real temporary prompt file which is deleted in finally.

Scope of evidence: the real CLI canaries above used small prompts on macOS and the host versions shown. The automated 200 KB tests use trivial node -e children and prove only that our launcher delivers bytes intact, converts an early stdin close to a loud failure, and cleans up files. They do not prove that the production versions of Claude, Codex, or OpenCode consume a 200 KB stdin prompt without early-closing inside the Linux sandbox. That far-side E2E remains for the post-deploy real trigger.

Production/version evidence

agentworkforce deployments logs daily-ship --tail 200 --json showed the large-prompt spawn E2BIG. Inspection of the affected sandbox showed @agentworkforce/runtime and persona-kit 4.1.17 on disk even though snapshot metadata said runtime-4.1.24; the cloud tick was reinstalling the pinned 4.1.17 package. That stale pin also explains the separate OpenCode help dump (the OpenCode flag fix shipped in 4.1.20). This PR addresses the structural argv delivery bug across all four harnesses; deployment/version correction is tracked separately.

Validation

  • persona-kit: 290/290 passing
  • focused runtime suites: 21/21 passing (process helper, cloud defaults, runner)
  • runner suite independently: 12/12 passing; exact relocated prompt content is asserted for Claude, Codex, and Grok
  • CLI typecheck and build passing
  • full runtime comparison with the exact same Node 22 binary:
    • clean origin/main: 117/130 passing, 13 failing
    • this branch: 120/133 passing, 13 failing
    • the same 13 patched-Node local-preview gate tests fail in both; the three added process tests account for the delta

No npm publish is included.

Review in cubic

@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Non-interactive harness prompts are now delivered through stdin or temporary prompt files instead of embedding task text in argv. Shared process capture handles output, exit codes, timeouts, delivery errors, and cleanup across runtime and CLI execution paths.

Changes

Non-interactive harness execution

Layer / File(s) Summary
Prompt delivery contract and harness specs
packages/persona-kit/src/interactive-spec.ts, packages/persona-kit/src/index.ts, packages/persona-kit/src/interactive-spec.test.ts
Adds NonInteractivePrompt, moves claude, codex, and opencode prompts to stdin, moves grok prompts to temporary files, and updates argv-size and harness-spec tests.
Shared process capture and prompt delivery
packages/runtime/src/harness-process.ts, packages/runtime/src/harness-process.test.ts, packages/runtime/src/index.ts
Adds shared process-spawning APIs with stdout/stderr capture, numeric exit codes, timeout handling, stdin delivery, prompt-file cleanup, and failure-mode tests.
Runtime and CLI harness integration
packages/runtime/src/cloud-defaults.ts, packages/cli/src/cli-impl.ts, packages/runtime/src/runner.test.ts
Routes cloud and persona-improver harness execution through shared capture, and verifies stdin, prompt-file arguments, prompt contents, and cleanup.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant PersonaSpec
  participant CloudHarnessRunner
  participant HarnessProcess
  participant ChildHarness
  PersonaSpec->>CloudHarnessRunner: return args and prompt delivery mode
  CloudHarnessRunner->>HarnessProcess: call spawnNonInteractiveAndCapture
  HarnessProcess->>ChildHarness: pass prompt through stdin or prompt file
  ChildHarness-->>HarnessProcess: emit output and exit status
  HarnessProcess-->>CloudHarnessRunner: return captured result
Loading

Possibly related PRs

Suggested reviewers: willwashburn

Poem

I’m a rabbit with prompts tucked safe in a stream,
No giant argv can disturb my dream.
Grok gets a file, while others read stdin,
Shared runners capture each outcome within.
Hop, hop—clean temp files vanish from sight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: moving harness prompts off argv.
Description check ✅ Passed The description matches the changeset and explains the prompt-delivery and shared launcher updates.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/off-argv-harness-prompts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the delivery of non-interactive tasks to harness processes by moving them off the command-line arguments (argv) to avoid Linux's 128 KiB single-argument limit. It introduces a NonInteractivePrompt type supporting stdin and file modes, and adds utility functions spawnAndCapture and spawnNonInteractiveAndCapture to handle the process execution and prompt delivery. Feedback on these changes suggests wrapping the temporary directory cleanup in a try-catch block to prevent cleanup errors from suppressing primary execution errors, and wrapping the synchronous spawn call in a try-catch block to handle synchronous spawn failures gracefully.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +150 to +152
} finally {
await rm(promptDir, { recursive: true, force: true });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In spawnNonInteractiveAndCapture, the finally block attempts to delete the temporary directory promptDir. However, if rm fails (for example, due to file locking on Windows or permission issues), the error will be thrown from the finally block, which will suppress and overwrite the actual process execution result or any primary error thrown in the try block. Wrapping the rm call in a try-catch block ensures that cleanup failures do not interfere with the primary execution outcome.

Suggested change
} finally {
await rm(promptDir, { recursive: true, force: true });
}
} finally {
try {
await rm(promptDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup; do not let cleanup failures suppress the actual process result or primary error.
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Keeping cleanup failures loud is intentional here: this file can contain the complete private task, so silently succeeding while leaving it behind is worse than surfacing cleanup failure. force: true handles the normal missing-path case. I agree preserving both causes would be useful if execution and cleanup both throw, but swallowing the cleanup error would weaken the security/cleanup contract, so I am leaving this behavior unchanged.

Comment on lines +30 to +35
const child = spawn(args.bin, args.args, {
cwd: args.cwd,
env: args.env,
stdio: [hasStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
shell: false
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The spawn function from node:child_process can throw synchronous errors (for example, if arguments are malformed or if certain synchronous errors occur on specific platforms). If spawn throws synchronously, the Promise executor will reject, causing spawnAndCapture to return a rejected promise instead of a resolved CapturedProcessResult with exitCode: 1 and the error message in stderr. Wrapping the spawn call in a try-catch block ensures that synchronous spawn failures are handled gracefully and consistently with asynchronous error events.

    let child;
    try {
      child = spawn(args.bin, args.args, {
        cwd: args.cwd,
        env: args.env,
        stdio: [hasStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
        shell: false
      });
    } catch (err) {
      resolve({
        output: '',
        stderr: `${err instanceof Error ? err.message : String(err)}\n`,
        exitCode: 1
      });
      return;
    }

@khaliqgant khaliqgant Jul 16, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

A synchronous throw inside the Promise executor already rejects the returned promise, so it is not lost or silent; spawnNonInteractiveAndCapture also still runs its finally cleanup on that rejection. That loud rejection is intentional for failures before a child process exists, while asynchronous child error events remain normalized into CapturedProcessResult. spawn E2BIG—the production failure this PR fixes—is the concrete case where preserving a distinguishable spawn exception is diagnostically better than flattening it into an ordinary harness { exitCode: 1 }. This also matches the prior callers’ synchronous-spawn behavior, so I am leaving it unchanged.

@khaliqgant
khaliqgant merged commit 72e2f87 into main Jul 16, 2026
2 checks passed
@khaliqgant
khaliqgant deleted the fix/off-argv-harness-prompts branch July 16, 2026 10:46
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