Skip to content

feat(exec): support JSON-RPC 2.0 communication with extension plugins#19

Merged
fohte merged 5 commits intomainfrom
fohte/impl-runok-init-extension-jsonrpc
Feb 10, 2026
Merged

feat(exec): support JSON-RPC 2.0 communication with extension plugins#19
fohte merged 5 commits intomainfrom
fohte/impl-runok-init-extension-jsonrpc

Conversation

@fohte
Copy link
Owner

@fohte fohte commented Feb 10, 2026

Why

  • Allow users to implement custom validation logic as external processes and invoke them from runok rules

What

  • Add ExtensionRunner trait and ProcessExtensionRunner implementation for communicating with external processes via JSON-RPC 2.0 over stdio
  • Include request serialization, response deserialization, timeout-based polling, and error handling

Open with Devin

fohte and others added 3 commits February 11, 2026 01:33
…ication

Extensions allow users to write custom command validators in any language
(Ruby, Python, Deno, etc.) as external processes. The runner spawns the
extension process, sends an ExtensionRequest via stdin in JSON-RPC 2.0
format, and parses the ExtensionResponse from stdout.

- ExtensionRunner trait with validate() method
- ProcessExtensionRunner: spawns external process, communicates via stdio
- JSON-RPC 2.0 request serialization (method: "validateCommand")
- JSON-RPC 2.0 response deserialization with error handling
- Configurable timeout (kills child process on expiry)
- ExtensionError variants: Spawn, Timeout, InvalidResponse

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Individual test functions for each response variant and error case
made the test file unnecessarily verbose. Consolidating them into
parametrized rstest cases reduces duplication while keeping the same
coverage.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
These fields were defined solely for JSON-RPC 2.0 compliance but never
read after deserialization. serde ignores unknown fields by default, so
keeping them added unnecessary dead_code suppressions with no benefit.
@gemini-code-assist
Copy link

Summary of Changes

Hello @fohte, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the system's extensibility by integrating a mechanism for external processes to act as validation plugins. By leveraging JSON-RPC 2.0 over standard I/O, it allows for custom, language-agnostic validation logic to be executed outside the main application, thereby increasing flexibility and modularity without tightly coupling the validation rules to the core codebase.

Highlights

  • External Plugin Communication: Introduced support for communicating with external extension plugins using JSON-RPC 2.0 over standard I/O, enabling custom validation logic to be run as external processes.
  • ExtensionRunner Trait and Implementation: Added an ExtensionRunner trait and a ProcessExtensionRunner implementation to manage the lifecycle and communication with these external processes.
  • JSON-RPC Handling: Implemented robust handling for JSON-RPC 2.0 requests (serialization) and responses (deserialization), including error handling and timeout-based polling for process execution.
  • Dependency Updates: Updated Cargo.toml and Cargo.lock to include serde_json for JSON serialization/deserialization and ryu as a transitive dependency.
Changelog
  • Cargo.lock
    • Updated serde_json dependency to version 1.0.140.
    • Added ryu as a new transitive dependency.
  • Cargo.toml
    • Added serde_json dependency at version 1.0.140.
  • src/exec/extension_runner.rs
    • Added new file for ExtensionRunner trait and ProcessExtensionRunner implementation.
    • Defined ExtensionRequest and ExtensionResponse structs for JSON-RPC payloads.
    • Implemented methods for building and parsing JSON-RPC 2.0 messages.
    • Included logic for spawning external processes, writing to stdin, reading from stdout, and handling timeouts.
  • src/exec/mod.rs
    • Exported the new extension_runner module.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

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

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 introduces a mechanism for communicating with external extension plugins using JSON-RPC 2.0. However, a critical command injection vulnerability has been identified where the executor_cmd string is parsed insecurely, potentially allowing arbitrary command execution. Additionally, there are areas for improvement in error handling and the robustness of command parsing, particularly concerning serialization, I/O errors, and handling complex command lines. Addressing this vulnerability is crucial before merging.

Comment on lines +94 to +99
fn parse_command(executor_cmd: &str) -> (String, Vec<String>) {
let mut parts = executor_cmd.split_whitespace();
let program = parts.next().unwrap_or("").to_string();
let args: Vec<String> = parts.map(String::from).collect();
(program, args)
}

Choose a reason for hiding this comment

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

security-high high

The parse_command function's use of split_whitespace is insecure and leads to a command injection vulnerability. It fails to correctly handle arguments with spaces or shell metacharacters, enabling arbitrary command execution if executor_cmd is user-controlled (e.g., sh -c 'reboot'). This also makes the parsing non-robust for legitimate commands with quoted arguments. Using a crate like shlex is recommended for secure and reliable shell-style word splitting.

Suggested change
fn parse_command(executor_cmd: &str) -> (String, Vec<String>) {
let mut parts = executor_cmd.split_whitespace();
let program = parts.next().unwrap_or("").to_string();
let args: Vec<String> = parts.map(String::from).collect();
(program, args)
}
fn parse_command(executor_cmd: &str) -> (String, Vec<String>) {
let parts: Vec<String> = shlex::split(executor_cmd).unwrap_or_default();
let program = parts.get(0).cloned().unwrap_or_default();
let args = parts.into_iter().skip(1).collect();
(program, args)
}

Copy link
Owner Author

Choose a reason for hiding this comment

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

executor_cmd comes from the config file authored by the user who runs the process, so command injection is not a threat here. shlex support for quoted arguments could be added later as a feature enhancement if needed.

Comment on lines +62 to +70
pub fn build_jsonrpc_request(&self, request: &ExtensionRequest) -> String {
let envelope = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "validateCommand",
"params": request,
});
serde_json::to_string(&envelope).unwrap_or_default()
}

Choose a reason for hiding this comment

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

medium

The use of unwrap_or_default() can hide serialization errors. If serde_json::to_string fails, an empty string will be sent to the extension, which will likely cause it to fail or hang without a clear error message in runok. It's better to propagate the error by returning a Result.

You'll also need to update the call site in validate to handle the Result, for example:

let json_request = self.build_jsonrpc_request(request)
    .map_err(|e| ExtensionError::InvalidResponse(format!("Request serialization failed: {e}")))?;
Suggested change
pub fn build_jsonrpc_request(&self, request: &ExtensionRequest) -> String {
let envelope = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "validateCommand",
"params": request,
});
serde_json::to_string(&envelope).unwrap_or_default()
}
pub fn build_jsonrpc_request(&self, request: &ExtensionRequest) -> Result<String, serde_json::Error> {
let envelope = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "validateCommand",
"params": request,
});
serde_json::to_string(&envelope)
}

// Write JSON-RPC request to stdin, then close the pipe
let json_request = self.build_jsonrpc_request(request);
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(json_request.as_bytes());

Choose a reason for hiding this comment

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

medium

The result of write_all is ignored. If writing to the child's stdin fails, the error is silently dropped. This can lead to the child process hanging and eventually timing out, masking the real issue. The io::Error should be propagated.

Suggested change
let _ = stdin.write_all(json_request.as_bytes());
stdin.write_all(json_request.as_bytes())?;

Comment on lines +136 to +137
let _ = child.kill();
let _ = child.wait();

Choose a reason for hiding this comment

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

medium

The results of child.kill() and child.wait() are ignored. These operations can fail, and ignoring the errors can hide underlying issues. It's better to handle or propagate these errors.

Suggested change
let _ = child.kill();
let _ = child.wait();
child.kill()?;
child.wait()?;

Copy link
Owner Author

Choose a reason for hiding this comment

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

kill()/wait() here are best-effort cleanup after a timeout. Propagating their errors would replace the Timeout error with a Spawn(io::Error), which is less informative for the caller.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +127 to +144
loop {
match child.try_wait() {
Ok(Some(_status)) => {
let output = child.wait_with_output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
return Self::parse_jsonrpc_response(&stdout);
}
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Err(ExtensionError::Timeout(timeout));
}
std::thread::sleep(Duration::from_millis(10));
}
Err(e) => return Err(ExtensionError::Spawn(e)),
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Potential deadlock: stdout pipe buffer fills before process exit

The polling loop on lines 127-144 waits for the child process to exit (via try_wait) before reading any stdout (via wait_with_output on line 130). If the extension process writes a response larger than the OS pipe buffer (typically 64KB on Linux), the child will block on its write() to stdout, unable to exit. Meanwhile, the parent is stuck in the polling loop waiting for exit, never reading stdout.

Root Cause

The code uses a poll-then-read pattern:

  1. Parent writes to stdin and closes it (lines 119-123)
  2. Parent enters try_wait() polling loop (line 128)
  3. Only after try_wait() returns Ok(Some(_)) does it call wait_with_output() (line 130) to read stdout

If the child's output exceeds the pipe buffer capacity, the child blocks on writing to stdout. Since the parent never reads stdout until after the child exits, this is a classic deadlock. The timeout on line 135 acts as a safety net, but the user gets a misleading "timeout" error instead of receiving the valid response.

The correct approach is to read stdout concurrently with waiting — e.g., by using wait_with_output() directly (it internally handles concurrent reading) with a separate timeout mechanism, or by spawning a reader thread for stdout before entering the wait loop.

Impact: Extensions that produce large responses (>64KB) will always hit the timeout and be killed, even though their output is valid.

Prompt for agents
Refactor the validate method in ProcessExtensionRunner (src/exec/extension_runner.rs lines 108-145) to avoid the deadlock. Instead of polling with try_wait() and then calling wait_with_output(), you should read stdout concurrently with waiting for the child. One approach: spawn a thread to read stdout to completion, then use the try_wait polling loop (or wait()) with the timeout for the exit status. Another approach: call wait_with_output() directly on a separate thread and use the main thread to enforce the timeout. The key constraint is that stdout must be drained while the child is still running, not after it exits.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Owner Author

Choose a reason for hiding this comment

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

The extension response is a small JSON (status, message, fix_suggestion), so it will not reach the 64KB pipe buffer limit in practice. Async stdout reading would add significant complexity for no real benefit here.

`build_jsonrpc_request` silently returned an empty string on
serialization failure via `unwrap_or_default()`, and `write_all` errors
were discarded with `let _ =`. Both cases would cause the extension
process to receive no input, leading to a confusing timeout instead of
an immediate error.
Reviewers raised questions about parse_command's split_whitespace,
ignored kill/wait errors, and the blocking stdout read pattern. Added
inline comments documenting why each is acceptable for the current use
case to prevent the same questions from recurring.
@fohte fohte merged commit d30c9ab into main Feb 10, 2026
2 checks passed
@fohte fohte deleted the fohte/impl-runok-init-extension-jsonrpc branch February 10, 2026 17:39
@fohte-bot fohte-bot bot mentioned this pull request Feb 10, 2026
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