refactor(process): route bounded commands through Execa#106495
Conversation
727c1ec to
90414ac
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3879c6ce7c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5c5ad4bca1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Land-ready proof for final head
Known proof gap: no live Windows host in the final pass. Dedicated Windows invocation, codepage, batch-command, and taskkill behavior remains covered by focused tests and hosted Windows CI from the reviewed pre-rebase head. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fd8320e8d3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| result = await runCommandWithTimeout([secureCommandPath, ...(policy.exec.args ?? [])], { | ||
| baseEnv: {}, | ||
| cwd, | ||
| env: childEnv, |
There was a problem hiding this comment.
Preserve exact policy command environments
When security.installPolicy.exec is configured with env/passEnv, this new runCommandWithTimeout path goes through spawnCommand, whose resolveCommandEnv() always calls markOpenClawExecEnv() and injects OPENCLAW_CLI=1. That violates the policy surface documented in docs/tools/skills-config.md where only literal env entries and named passEnv variables are passed, so strict policy wrappers that validate an allowlisted environment (or treat OPENCLAW_* as control input) now see an unexpected variable. Please use an exact-env/no-marker path for policy commands (and the same exec-provider pattern) instead of the generic child-process marker path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 699cfe0fc5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| baseEnv: {}, | ||
| cwd: path.dirname(secureCommandPath), | ||
| env: childEnv, |
There was a problem hiding this comment.
Preserve exact exec-provider environments
When an exec SecretRef provider is configured with env/passEnv, this now runs through the generic command wrapper, whose resolveCommandEnv() always adds OPENCLAW_CLI=1 even with baseEnv: {}. That violates the documented minimal exec-provider environment contract (docs/gateway/configuration-reference.md says required variables must be passed explicitly with passEnv), so strict secret resolver wrappers or providers treating OPENCLAW_* as control input can start failing after this refactor. Use an exact-env/no-marker execution path for exec SecretRef providers, like the old spawn(..., { env: childEnv }) behavior.
Useful? React with 👍 / 👎.
d502d74 to
f7948f9
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f7948f98ba
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const resolvedTimeoutMs = | ||
| typeof timeoutMs === "number" ? resolveTimerTimeoutMs(timeoutMs, 1) : undefined; | ||
| const hasInput = input !== undefined; | ||
| const stdio = resolveCommandStdio({ hasInput, preferInherit: true }); |
There was a problem hiding this comment.
Keep bounded commands off inherited stdin
When callers do not provide input, this now makes every bounded command inherit the gateway process stdin, even though many migrated sites previously used stdio: ["ignore", ...]. A concrete affected path is the SSH node-pairing probe in src/gateway/node-pairing-ssh-verify.runtime.ts:73: its own comments treat the target as untrusted until identity verification succeeds, and OpenSSH documents -n as redirecting stdin from /dev/null (https://man.openbsd.org/ssh#n), but this invocation supplies neither -n nor ignored stdin. As a result, a pairing target can read or hold the gateway's terminal/stdin until timeout; please make ignored stdin the default for no-input bounded commands or require an explicit opt-in to inherit it.
Useful? React with 👍 / 👎.
) * refactor(process): centralize bounded command execution * refactor(process): migrate core one-shot commands * refactor(plugins): migrate one-shot commands * fix(process): await Windows tree termination * chore(plugin-sdk): refresh process runtime surface * refactor(process): migrate remaining bounded commands * refactor(process): normalize command result handling * refactor(process): split execution responsibilities * chore(plugin-sdk): refresh API baseline * chore(process): remove release-owned changelog entry * fix(process): narrow binary command input checks * fix(process): cap sandbox command output * fix(qa-lab): preserve exact node probe env * chore(ci): refresh dead export baseline * fix(process): preserve force-kill command deadlines * fix(process): avoid post-exit timeout reclassification * test(process): update scp staging wrapper mock * test(process): update remaining wrapper mocks * refactor(qa-lab): preserve Execa tar execution
) * refactor(process): centralize bounded command execution * refactor(process): migrate core one-shot commands * refactor(plugins): migrate one-shot commands * fix(process): await Windows tree termination * chore(plugin-sdk): refresh process runtime surface * refactor(process): migrate remaining bounded commands * refactor(process): normalize command result handling * refactor(process): split execution responsibilities * chore(plugin-sdk): refresh API baseline * chore(process): remove release-owned changelog entry * fix(process): narrow binary command input checks * fix(process): cap sandbox command output * fix(qa-lab): preserve exact node probe env * chore(ci): refresh dead export baseline * fix(process): preserve force-kill command deadlines * fix(process): avoid post-exit timeout reclassification * test(process): update scp staging wrapper mock * test(process): update remaining wrapper mocks * refactor(qa-lab): preserve Execa tar execution
trimTruncatedUtf8Boundary in src/process/exec-output.ts skipped UTF-8
boundary trimming entirely when process.platform === "win32". This left
partial UTF-8 code points at the truncation boundary of captured command
output, causing Buffer.toString("utf8") downstream to emit U+FFFD
replacement characters in truncated doctor/cron/plugin command output on
Windows.
The win32 skip was introduced in openclaw#106495 (Execa refactor) without
documented rationale. Node.js stdout is UTF-8 on Windows by default, so
the same boundary-trimming logic applies. Remove the platform guard so
truncated output is always trimmed on a valid UTF-8 code point boundary.
Regression tests in src/process/exec.test.ts mock process.platform to
"win32" and verify that finalizeCapturedOutput trims leading
continuation bytes in tail mode and trailing partial code points in
head mode, with no U+FFFD in the result.
Related: #105920
Follow-up to #105939
AI-assisted. I understand the execution, cancellation, output, and platform behavior changed here.
What Problem This Solves
Resolves a problem where bounded one-shot commands across core and bundled plugins still bypassed OpenClaw's canonical Execa-backed execution layer, leaving inconsistent timeout, environment, output-cap, error, and process-tree behavior.
Why This Change Was Made
This routes suitable bounded command sites through the canonical process wrapper, adds a buffered SDK seam for plugin-owned callers, and splits the execution implementation into focused spawn, output, result, termination, and runner modules behind the existing
src/process/exec.tsfacade. OpenClaw-specific contracts remain owned by the wrapper: argv-only Windows batch safety, console-codepage decoding, capped capture, abort/timeout semantics, inherited-pipe draining, and process-tree termination. Specialized interactive, streaming, supervised, daemon, tunnel, watcher, IPC, and detached-restart processes remain direct spawns by design.The Windows termination path now waits for bounded
taskkillownership to settle before aborting Execa, avoiding a live-root PID reuse race.Docker and SSH sandbox commands use one explicit 100 MiB output cap, so their full-buffer contract neither inherits Execa's hidden default nor permits unbounded gateway heap growth.
QA Lab node probing passes restricted environments as the exact base environment, preserving the old
execFile({ env })isolation contract.User Impact
No intended user-visible behavior change. Operators and plugin developers get more consistent bounded-command behavior, while long-lived and interactive process owners keep their specialized lifecycle paths.
Evidence
run_ac5c7b9ee846:pnpm check:changedandpnpm buildpassed, including LOC, formatting, dependency guards, SDK API/surface checks, five TypeScript lanes, core and extension lint, import-cycle checks, runtime guards, core bundle, SDK declarations, Control UI build, and performance budgets.run_11269723e625: focusedsrc/processplussrc/node-host/invoke.run-command.test.tsproof passed 211 tests with 1 platform skip across three Vitest shards, including Windows invocation, timeout, output-cap, descendant-pipe, and command-tree termination coverage.tbx_01kxe2ktcvyghmnpdx3f4k821x/ Actions29263749854: final integrationcheck:changedpassed core and core-test typechecks plus guards; focused process, worker-tunnel, and node-host proof passed 222 tests with 1 platform skip across four Vitest shards.tbx_01kxe3fzzq3razeqqkdjfteyyf/ Actions29264821473: sandbox-capcheck:changedpassed; focused Docker and SSH sandbox proof passed 50 tests.tbx_01kxe3xd4c5b6nhjy2aqf25zx6/ Actions29265336998: QA Lab extension and test typechecks, lint, guards, and 6 focused node-probe tests passed.git diff --check origin/main...HEADpassed.51442e188c9. The diff removes more duplicated child-process lifecycle code than it adds across more than 90 files.No screenshots: runtime-only process refactor.