guides/tools-local-shell.md +0 −876 deleted
File Deleted View Diff
1# Local shell
2
3> For the complete documentation index, see [llms.txt](/llms.txt). Markdown versions of documentation pages are available by appending `.md` to the page URL.
4
5The [deprecation
6 notice](https://developers.openai.com/api/docs/deprecations#2025-11-17-codex-mini-latest-model-snapshot)
7 lists February 12, 2026 as the end-of-support date for `codex-mini-latest` and
8 the legacy local shell tool. For new use cases, use the current
9 [`shell`](https://developers.openai.com/api/docs/guides/tools-shell) tool. Its request and execution
10 workflow differs from the legacy examples retained below.
11
12Local shell is a tool that allows agents to run shell commands locally on a machine you or the user provides. It's designed to work with [Codex CLI](https://github.com/openai/codex) and [`codex-mini-latest`](https://developers.openai.com/api/docs/models/codex-mini-latest). Commands are executed inside your own runtime, so **you are fully in control of which commands actually run**. The API only returns instructions; it does not execute them on OpenAI infrastructure.
13
14Local shell is available through the [Responses API](https://developers.openai.com/api/docs/guides/migrate-to-responses) for use with [`codex-mini-latest`](https://developers.openai.com/api/docs/models/codex-mini-latest). It is not available on other models or via the Chat Completions API.
15
16Running arbitrary shell commands can be dangerous. Always sandbox execution
17or add strict allowlists or deny lists before forwarding a command to the system
18shell.
19
20
21
22See [Codex CLI](https://github.com/openai/codex) for reference implementation.
23
24## How it works
25
26The local shell tool enables agents to run in a continuous loop with access to a terminal.
27
28The model sends shell commands, which your code executes on a local machine before returning the output to the model. This loop allows the model to complete the build-test-run loop without additional user intervention.
29
30Your code must implement a loop that listens for `local_shell_call` output items and executes the commands they contain. We strongly recommend sandboxing execution to prevent unexpected commands from running.
31
32
33
34Integrating the local shell tool
35
36
37
38These are the high-level steps you need to follow to integrate the local shell tool in your application:
39
401. **Send a request to the model**:
41 Include the `local_shell` tool as part of the available tools.
42
432. **Receive a response from the model**:
44 Check if the response has any `local_shell_call` items.
45 This tool call contains an action like `exec` with a command to execute.
46
473. **Execute the requested action**:
48 Run the command in the local environment you control.
49
504. **Return the action output**:
51 After executing the action, return the command output to the model.
52
535. **Repeat**:
54 Send a new request with the updated state as a `local_shell_call_output`, and repeat this loop until the model stops requesting actions or you decide to stop.
55
56## Example workflow
57
58Below is a minimal example showing the request/response loop. Choose a language
59to see the equivalent workflow for its SDK. For brevity, production-grade
60sandboxing and security checks are omitted—**do not execute untrusted commands
61in production without additional safeguards**.
62
63```javascript
64import { spawn } from "node:child_process";
65import process from "node:process";
66import OpenAI from "openai";
67
68const client = new OpenAI();
69const MAX_TIMEOUT_MS = 10_000;
70
71function runCommand(command, options) {
72 return new Promise((resolve) => {
73 let stdout = "";
74 let stderr = "";
75 let settled = false;
76 let groupPoll;
77 const child = spawn(command[0], command.slice(1), {
78 ...options,
79 detached: process.platform !== "win32",
80 stdio: ["ignore", "pipe", "pipe"],
81 });
82 const finish = (suffix = "") => {
83 if (settled) return;
84 settled = true;
85 clearTimeout(timer);
86 clearTimeout(groupPoll);
87 resolve(stdout + stderr + suffix);
88 };
89 const processGroupIsRunning = () => {
90 if (process.platform === "win32" || !child.pid) return false;
91 try {
92 process.kill(-child.pid, 0);
93 return true;
94 } catch {
95 return false;
96 }
97 };
98 const finishAfterProcessGroup = (suffix) => {
99 if (settled) return;
100 if (processGroupIsRunning()) {
101 groupPoll = setTimeout(() => finishAfterProcessGroup(suffix), 10);
102 } else {
103 finish(suffix);
104 }
105 };
106 const killProcessTree = () => {
107 try {
108 if (process.platform !== "win32" && child.pid) {
109 process.kill(-child.pid, "SIGKILL");
110 } else {
111 child.kill("SIGKILL");
112 }
113 } catch {
114 child.kill("SIGKILL");
115 }
116 child.stdout?.destroy();
117 child.stderr?.destroy();
118 };
119 const timer = setTimeout(() => {
120 killProcessTree();
121 finish("Command timed out.\n");
122 }, options.timeout);
123
124 child.stdout?.on("data", (chunk) => {
125 stdout += chunk;
126 });
127 child.stderr?.on("data", (chunk) => {
128 stderr += chunk;
129 });
130 child.on("error", (error) => {
131 finish(`Command failed: ${error.message}.\n`);
132 });
133 child.on("close", (code, signal) => {
134 if (signal) {
135 finishAfterProcessGroup(`Command failed with signal ${signal}.\n`);
136 } else if (code !== 0) {
137 finishAfterProcessGroup(`Command failed with exit code ${code}.\n`);
138 } else {
139 finishAfterProcessGroup("");
140 }
141 });
142 });
143}
144
145let response = await client.responses.create({
146 model: "codex-mini-latest",
147 tools: [{ type: "local_shell" }],
148 parallel_tool_calls: false,
149 input: "List files in the current directory.",
150});
151
152while (true) {
153 const shellCall = response.output.find(
154 (item) => item.type === "local_shell_call"
155 );
156 if (!shellCall) break;
157
158 const { command, env, timeout_ms, user, working_directory } =
159 shellCall.action;
160 let output;
161 if (user) {
162 output = `Unsupported execution user: ${user}.\n`;
163 } else if (command.length === 0) {
164 output = "Command is empty.\n";
165 } else {
166 const timeout =
167 timeout_ms && timeout_ms > 0
168 ? Math.min(timeout_ms, MAX_TIMEOUT_MS)
169 : MAX_TIMEOUT_MS;
170 try {
171 output = await runCommand(command, {
172 cwd: working_directory ?? process.cwd(),
173 env: { PATH: process.env.PATH ?? "", ...env },
174 timeout,
175 });
176 } catch (error) {
177 output = `Command failed: ${error instanceof Error ? error.message : String(error)}.\n`;
178 }
179 }
180
181 response = await client.responses.create({
182 model: "codex-mini-latest",
183 tools: [{ type: "local_shell" }],
184 parallel_tool_calls: false,
185 previous_response_id: response.id,
186 input: [
187 {
188 type: "local_shell_call_output",
189 id: shellCall.call_id,
190 output,
191 },
192 ],
193 });
194}
195
196console.log(response.output_text);
197```
198
199```python
200import os
201import signal
202import subprocess
203import time
204from contextlib import suppress
205from openai import OpenAI
206
207client = OpenAI()
208MAX_TIMEOUT_MS = 10_000
209
210
211def output_text(value):
212 if isinstance(value, bytes):
213 return value.decode(errors="replace")
214 return value or ""
215
216
217def process_group_is_running(pid):
218 if os.name == "nt":
219 return False
220 try:
221 os.killpg(pid, 0)
222 return True
223 except ProcessLookupError:
224 return False
225 except PermissionError:
226 return True
227
228
229response = client.responses.create(
230 model="codex-mini-latest",
231 tools=[{"type": "local_shell"}],
232 parallel_tool_calls=False,
233 input="List files in the current directory.",
234)
235
236while True:
237 shell_call = next(
238 (item for item in response.output if item.type == "local_shell_call"),
239 None,
240 )
241 if shell_call is None:
242 break
243
244 action = shell_call.action
245 if action.user:
246 output = f"Unsupported execution user: {action.user}.\n"
247 elif not action.command:
248 output = "Command is empty.\n"
249 else:
250 timeout_ms = (
251 min(action.timeout_ms, MAX_TIMEOUT_MS)
252 if action.timeout_ms and action.timeout_ms > 0
253 else MAX_TIMEOUT_MS
254 )
255 deadline = time.monotonic() + timeout_ms / 1000
256 try:
257 process = subprocess.Popen(
258 action.command,
259 cwd=action.working_directory or os.getcwd(),
260 env={"PATH": os.environ.get("PATH", ""), **action.env},
261 stdin=subprocess.DEVNULL,
262 stdout=subprocess.PIPE,
263 stderr=subprocess.PIPE,
264 text=True,
265 errors="replace",
266 start_new_session=True,
267 )
268 stdout, stderr = process.communicate(
269 timeout=max(deadline - time.monotonic(), 0)
270 )
271 while process_group_is_running(process.pid):
272 remaining = deadline - time.monotonic()
273 if remaining <= 0:
274 raise subprocess.TimeoutExpired(action.command, timeout_ms / 1000)
275 time.sleep(min(remaining, 0.01))
276 output = stdout + stderr
277 if process.returncode:
278 output += f"Command failed with exit code {process.returncode}.\n"
279 except subprocess.TimeoutExpired as error:
280 if os.name == "nt":
281 process.kill()
282 else:
283 with suppress(ProcessLookupError):
284 os.killpg(process.pid, signal.SIGKILL)
285 try:
286 stdout, stderr = process.communicate(
287 timeout=max(deadline - time.monotonic(), 0)
288 )
289 except subprocess.TimeoutExpired as drain_error:
290 if process.stdout:
291 process.stdout.close()
292 if process.stderr:
293 process.stderr.close()
294 stdout = output_text(
295 drain_error.stdout
296 if drain_error.stdout is not None
297 else error.stdout
298 )
299 stderr = output_text(
300 drain_error.stderr
301 if drain_error.stderr is not None
302 else error.stderr
303 )
304 output = output_text(stdout) + output_text(stderr) + "Command timed out.\n"
305 except (OSError, TypeError, ValueError) as error:
306 output = f"Command failed: {error}.\n"
307
308 output_item = {
309 "type": "local_shell_call_output",
310 "id": shell_call.call_id,
311 "output": output,
312 }
313
314 response = client.responses.create(
315 model="codex-mini-latest",
316 tools=[{"type": "local_shell"}],
317 parallel_tool_calls=False,
318 previous_response_id=response.id,
319 input=[output_item],
320 )
321
322print(response.output_text)
323```
324
325```go
326package main
327
328import (
329 "bytes"
330 "context"
331 "errors"
332 "fmt"
333 "io"
334 "os"
335 "os/exec"
336 "path/filepath"
337 "sync"
338 "sync/atomic"
339 "syscall"
340 "time"
341
342 "github.com/openai/openai-go/v3"
343 "github.com/openai/openai-go/v3/responses"
344)
345
346const maxCommandTimeout = 10 * time.Second
347
348func main() {
349 client := openai.NewClient()
350 tool := responses.ToolUnionParam{OfLocalShell: &responses.ToolLocalShellParam{}}
351 response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
352 Model: "codex-mini-latest",
353 Tools: []responses.ToolUnionParam{tool},
354 ParallelToolCalls: openai.Bool(false),
355 Input: responses.ResponseNewParamsInputUnion{
356 OfString: openai.String("List files in the current directory."),
357 },
358 })
359 if err != nil {
360 panic(err)
361 }
362
363 for {
364 var shellCall *responses.ResponseOutputItemLocalShellCall
365 for _, item := range response.Output {
366 if item.Type == "local_shell_call" {
367 call := item.AsLocalShellCall()
368 shellCall = &call
369 break
370 }
371 }
372 if shellCall == nil {
373 break
374 }
375
376 action := shellCall.Action
377 var output []byte
378 if action.User != "" {
379 output = []byte(fmt.Sprintf("Unsupported execution user: %s.\n", action.User))
380 } else if len(action.Command) == 0 {
381 output = []byte("Command is empty.\n")
382 } else {
383 path := os.Getenv("PATH")
384 if actionPath, ok := action.Env["PATH"]; ok {
385 path = actionPath
386 }
387 executable, pathErr := commandPath(action.Command[0], path, action.WorkingDirectory)
388 if pathErr != nil {
389 output = []byte(fmt.Sprintf("Command failed: %v\n", pathErr))
390 } else {
391 timeout := maxCommandTimeout
392 if action.TimeoutMs > 0 && action.TimeoutMs < maxCommandTimeout.Milliseconds() {
393 timeout = time.Duration(action.TimeoutMs) * time.Millisecond
394 }
395 deadline := time.Now().Add(timeout)
396 ctx, cancel := context.WithTimeout(context.Background(), timeout)
397 command := exec.CommandContext(ctx, executable, action.Command[1:]...)
398 command.Args[0] = action.Command[0]
399 command.Dir = action.WorkingDirectory
400 command.Env = []string{"PATH=" + path}
401 command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
402 for key, value := range action.Env {
403 if key == "PATH" {
404 continue
405 }
406 command.Env = append(command.Env, key+"="+value)
407 }
408 killProcessGroup := func() {
409 if command.Process != nil {
410 _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
411 }
412 }
413 processGroupIsRunning := func() bool {
414 return command.Process != nil && syscall.Kill(-command.Process.Pid, 0) == nil
415 }
416 stdout, stdoutWriter, stdoutErr := os.Pipe()
417 stderr, stderrWriter, stderrErr := os.Pipe()
418 if stdoutErr != nil || stderrErr != nil {
419 if stdout != nil {
420 _ = stdout.Close()
421 }
422 if stdoutWriter != nil {
423 _ = stdoutWriter.Close()
424 }
425 if stderr != nil {
426 _ = stderr.Close()
427 }
428 if stderrWriter != nil {
429 _ = stderrWriter.Close()
430 }
431 output = []byte(fmt.Sprintf("Command failed: %v%v\n", stdoutErr, stderrErr))
432 } else {
433 command.Stdout = stdoutWriter
434 command.Stderr = stderrWriter
435 var combinedOutput bytes.Buffer
436 var outputLock sync.Mutex
437 var readers sync.WaitGroup
438 readOutput := func(reader io.ReadCloser) {
439 defer readers.Done()
440 data, _ := io.ReadAll(reader)
441 outputLock.Lock()
442 _, _ = combinedOutput.Write(data)
443 outputLock.Unlock()
444 }
445 var commandErr error
446 commandErr = command.Start()
447 if commandErr == nil {
448 _ = stdoutWriter.Close()
449 _ = stderrWriter.Close()
450 readers.Add(2)
451 go readOutput(stdout)
452 go readOutput(stderr)
453 var timedOut atomic.Bool
454 remaining := time.Until(deadline)
455 if remaining < 0 {
456 remaining = 0
457 }
458 markTimedOut := func() {
459 if timedOut.Swap(true) {
460 return
461 }
462 killProcessGroup()
463 _ = stdout.Close()
464 _ = stderr.Close()
465 }
466 timer := time.AfterFunc(remaining, markTimedOut)
467 commandErr = command.Wait()
468 if !time.Now().Before(deadline) ||
469 errors.Is(commandErr, context.DeadlineExceeded) ||
470 errors.Is(ctx.Err(), context.DeadlineExceeded) {
471 markTimedOut()
472 }
473 for processGroupIsRunning() && !timedOut.Load() {
474 time.Sleep(10 * time.Millisecond)
475 }
476 readers.Wait()
477 if !timer.Stop() || !time.Now().Before(deadline) {
478 markTimedOut()
479 }
480 output = combinedOutput.Bytes()
481 if timedOut.Load() || errors.Is(ctx.Err(), context.DeadlineExceeded) {
482 killProcessGroup()
483 output = append(output, "Command timed out.\n"...)
484 } else if commandErr != nil {
485 output = append(output, fmt.Sprintf("Command failed: %v\n", commandErr)...)
486 }
487 } else {
488 _ = stdout.Close()
489 _ = stderr.Close()
490 _ = stdoutWriter.Close()
491 _ = stderrWriter.Close()
492 output = append(output, fmt.Sprintf("Command failed: %v\n", commandErr)...)
493 }
494 }
495 cancel()
496 }
497 }
498
499 response, err = client.Responses.New(context.Background(), responses.ResponseNewParams{
500 Model: "codex-mini-latest",
501 Tools: []responses.ToolUnionParam{tool},
502 ParallelToolCalls: openai.Bool(false),
503 PreviousResponseID: openai.String(response.ID),
504 Input: responses.ResponseNewParamsInputUnion{
505 OfInputItemList: []responses.ResponseInputItemUnionParam{{
506 OfLocalShellCallOutput: &responses.ResponseInputItemLocalShellCallOutputParam{
507 ID: shellCall.CallID,
508 Output: string(output),
509 },
510 }},
511 },
512 })
513 if err != nil {
514 panic(err)
515 }
516 }
517
518 fmt.Println(response.OutputText())
519}
520
521func commandPath(command string, path string, workingDirectory string) (string, error) {
522 if filepath.Base(command) != command {
523 return command, nil
524 }
525 baseDirectory, err := filepath.Abs(workingDirectory)
526 if err != nil {
527 return "", err
528 }
529 directories := filepath.SplitList(path)
530 if len(directories) == 0 {
531 directories = []string{""}
532 }
533 for _, directory := range directories {
534 if directory == "" {
535 directory = "."
536 }
537 if !filepath.IsAbs(directory) {
538 directory = filepath.Join(baseDirectory, directory)
539 }
540 candidate := filepath.Join(directory, command)
541 info, err := os.Stat(candidate)
542 if err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
543 return candidate, nil
544 }
545 }
546 return "", fmt.Errorf("command %q not found in PATH", command)
547}
548```
549
550```java
551import com.openai.client.OpenAIClient;
552import com.openai.client.okhttp.OpenAIOkHttpClient;
553import com.openai.core.JsonValue;
554import com.openai.models.responses.ResponseCreateParams;
555import com.openai.models.responses.ResponseInputItem;
556import java.io.IOException;
557import java.io.UncheckedIOException;
558import java.nio.charset.StandardCharsets;
559import java.nio.file.Files;
560import java.nio.file.Path;
561import java.util.ArrayList;
562import java.util.LinkedHashMap;
563import java.util.List;
564import java.util.Locale;
565import java.util.Map;
566import java.util.concurrent.CompletableFuture;
567import java.util.concurrent.TimeUnit;
568import java.util.concurrent.TimeoutException;
569
570ResponseCreateParams.Builder request =
571 ResponseCreateParams.builder()
572 .model("codex-mini-latest")
573 .input("List files in the current directory.")
574 .parallelToolCalls(false)
575 .putAdditionalBodyProperty(
576 "tools", JsonValue.from(List.of(Map.of("type", "local_shell"))));
577var response = client.responses().create(request.build());
578
579while (true) {
580 var shellCall =
581 response.output().stream()
582 .flatMap(item -> item.localShellCall().stream())
583 .findFirst()
584 .orElse(null);
585 if (shellCall == null) {
586 break;
587 }
588
589 var action = shellCall.action();
590 String output;
591 if (action.user().isPresent()) {
592 output = "Unsupported execution user: " + action.user().get() + ".\n";
593 } else if (action.command().isEmpty()) {
594 output = "Command is empty.\n";
595 } else {
596 try {
597 boolean usesShellSupervisor =
598 !System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("win");
599 String hostPath = System.getenv("PATH");
600 String childPath = hostPath;
601 Map<String, String> actionEnvironment = new LinkedHashMap<>();
602 for (Map.Entry<String, com.openai.core.JsonValue> variable :
603 action.env()._additionalProperties().entrySet()) {
604 String value = (String) variable.getValue().asString().orElseThrow();
605 actionEnvironment.put(variable.getKey(), value);
606 if (variable.getKey().equals("PATH")) {
607 childPath = value;
608 }
609 }
610 List<String> command;
611 if (usesShellSupervisor) {
612 String supervisorShell =
613 Files.isExecutable(Path.of("/bin/bash")) ? "/bin/bash" : "/bin/sh";
614 command =
615 new ArrayList<>(
616 List.of(
617 supervisorShell,
618 "-c",
619 "set -m; child=; "
620 + "cleanup() { test -z \"$child\" || "
621 + "kill -KILL -- \"-$child\" 2>/dev/null; }; "
622 + "trap cleanup TERM INT HUP; \"$@\" & child=$!; set +m; "
623 + "wait \"$child\" 2>/dev/null; status=$?; "
624 + "while kill -0 -- \"-$child\" 2>/dev/null; do sleep 0.01; done; "
625 + "exit \"$status\"",
626 "local-shell",
627 "/usr/bin/env",
628 "-i"));
629 if (childPath != null) {
630 command.add("PATH=" + childPath);
631 }
632 for (Map.Entry<String, String> variable : actionEnvironment.entrySet()) {
633 if (!variable.getKey().equals("PATH")) {
634 command.add(variable.getKey() + "=" + variable.getValue());
635 }
636 }
637 command.addAll(action.command());
638 } else {
639 command = new ArrayList<>(action.command());
640 }
641 ProcessBuilder processBuilder = new ProcessBuilder(command);
642 processBuilder.directory(action.workingDirectory().map(java.io.File::new).orElse(null));
643 processBuilder.environment().clear();
644 if (hostPath != null) {
645 processBuilder.environment().put("PATH", hostPath);
646 }
647 if (!usesShellSupervisor) {
648 processBuilder.environment().putAll(actionEnvironment);
649 }
650 Process process = processBuilder.redirectErrorStream(true).start();
651 process.getOutputStream().close();
652 var outputFuture =
653 CompletableFuture.supplyAsync(
654 () -> {
655 try {
656 return new String(
657 process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
658 } catch (IOException error) {
659 throw new UncheckedIOException(error);
660 }
661 });
662 long timeoutMillis =
663 action
664 .timeoutMs()
665 .filter(timeout -> timeout > 0)
666 .map(timeout -> Math.min(timeout, MAX_TIMEOUT_MILLIS))
667 .orElse(MAX_TIMEOUT_MILLIS);
668 long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
669 boolean finished = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS);
670 if (!finished) {
671 destroyProcessTree(process, usesShellSupervisor);
672 }
673 try {
674 long remainingNanos = Math.max(1, deadlineNanos - System.nanoTime());
675 output = outputFuture.get(remainingNanos, TimeUnit.NANOSECONDS);
676 if (!finished) {
677 output = "Command timed out.\n" + output;
678 } else if (process.exitValue() != 0) {
679 output += "Command failed with exit code " + process.exitValue() + ".\n";
680 }
681 } catch (TimeoutException error) {
682 destroyProcessTree(process, usesShellSupervisor);
683 process.getInputStream().close();
684 output = "Command timed out.\n";
685 } catch (java.util.concurrent.ExecutionException error) {
686 output = "Command failed: " + error.getCause().getMessage() + ".\n";
687 }
688 } catch (IOException | IllegalArgumentException error) {
689 output = "Command failed: " + error.getMessage() + ".\n";
690 }
691 }
692
693 response =
694 client
695 .responses()
696 .create(
697 request
698 .previousResponseId(response.id())
699 .inputOfResponse(
700 List.of(
701 ResponseInputItem.ofLocalShellCallOutput(
702 ResponseInputItem.LocalShellCallOutput.builder()
703 .id(shellCall.callId())
704 .output(output)
705 .build())))
706 .build());
707}
708
709response.output().stream()
710 .flatMap(item -> item.message().stream())
711 .flatMap(message -> message.content().stream())
712 .flatMap(content -> content.outputText().stream())
713 .forEach(text -> System.out.println(text.text()));
714
715private static void destroyProcessTree(Process process, boolean usesShellSupervisor) {
716 process.descendants().forEach(ProcessHandle::destroyForcibly);
717 if (usesShellSupervisor) {
718 process.destroy();
719 } else {
720 process.destroyForcibly();
721 }
722}
723```
724
725```ruby
726require "open3"
727require "openai"
728require "timeout"
729
730client = OpenAI::Client.new
731MAX_TIMEOUT_MS = 10_000
732response = client.responses.create(
733 model: "codex-mini-latest",
734 tools: [{ type: :local_shell }],
735 parallel_tool_calls: false,
736 input: "List files in the current directory."
737)
738
739loop do
740 shell_call = response.output.find do |item|
741 item.is_a?(OpenAI::Models::Responses::ResponseOutputItem::LocalShellCall)
742 end
743 break unless shell_call.is_a?(
744 OpenAI::Models::Responses::ResponseOutputItem::LocalShellCall
745 )
746
747 action = shell_call.action
748 stdout = +""
749 stderr = +""
750 if action.user
751 stderr << "Unsupported execution user: #{action.user}.\n"
752 elsif action.command.empty?
753 stderr << "Command is empty.\n"
754 else
755 begin
756 executable = action.command.fetch(0)
757 environment = { "PATH" => ENV.fetch("PATH", "") }.merge(action.env.transform_keys(&:to_s))
758 status, timed_out = Open3.popen3(
759 environment,
760 [executable, executable],
761 *action.command.drop(1),
762 chdir: action.working_directory || Dir.pwd,
763 pgroup: true,
764 unsetenv_others: true
765 ) do |stdin, child_stdout, child_stderr, wait_thread|
766 stdin.close
767 stdout_reader = Thread.new {
768 begin
769 child_stdout.read
770 rescue
771 ""
772 end
773 }
774 stderr_reader = Thread.new {
775 begin
776 child_stderr.read
777 rescue
778 ""
779 end
780 }
781 timeout_ms = action.timeout_ms
782 timeout = if timeout_ms&.positive?
783 [timeout_ms, MAX_TIMEOUT_MS].min / 1000.0
784 else
785 MAX_TIMEOUT_MS / 1000.0
786 end
787 deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
788
789 command_timed_out = false
790 wait_status = begin
791 status = Timeout.timeout(timeout) { wait_thread.value }
792 remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
793 raise Timeout::Error if remaining <= 0
794
795 stdout << Timeout.timeout(remaining) { stdout_reader.value }
796 remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
797 raise Timeout::Error if remaining <= 0
798
799 stderr << Timeout.timeout(remaining) { stderr_reader.value }
800 group_running = proc do
801 Process.kill(0, -wait_thread.pid)
802 true
803 rescue Errno::ESRCH
804 false
805 rescue Errno::EPERM
806 true
807 end
808 while group_running.call
809 remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
810 raise Timeout::Error if remaining <= 0
811
812 sleep [remaining, 0.01].min
813 end
814 status
815 rescue Timeout::Error
816 command_timed_out = true
817 begin
818 Process.kill("TERM", -wait_thread.pid)
819 Process.kill("KILL", -wait_thread.pid)
820 rescue Errno::ESRCH
821 nil
822 end
823 child_stdout.close
824 child_stderr.close
825 stdout_reader.kill
826 stderr_reader.kill
827 stderr << "Command timed out.\n"
828 wait_thread.value
829 end
830 [wait_status, command_timed_out]
831 end
832 exit_status = status.exitstatus
833 if exit_status && !status.success? && !timed_out
834 stderr << "Command failed with exit code #{exit_status}.\n"
835 elsif status.signaled? && !timed_out
836 stderr << "Command failed with signal #{status.termsig}.\n"
837 end
838 rescue SystemCallError, ArgumentError, TypeError => error
839 stderr << "Command failed: #{error.message}.\n"
840 end
841 end
842
843 response = client.responses.create(
844 model: "codex-mini-latest",
845 tools: [{ type: :local_shell }],
846 parallel_tool_calls: false,
847 previous_response_id: response.id,
848 input: [
849 {
850 type: :local_shell_call_output,
851 id: shell_call.call_id,
852 output: (stdout + stderr).encode("UTF-8", invalid: :replace, undef: :replace)
853 }
854 ]
855 )
856end
857
858puts(response.output_text)
859```
860
861
862## Best practices
863
864- **Sandbox or containerize** execution. Consider using Docker or a jailed user
865 account.
866- **Impose resource limits** (time, memory, network). The `timeout_ms`
867 provided by the model is only a hint—you should enforce your own limits.
868- **Filter or scrutinize** high-risk commands (for example, `rm`, `curl`, network
869 utilities).
870- **Log every command and its output** for auditing and debugging.
871
872### Error handling
873
874If the command fails on your side, for example, with a non-zero exit code or timeout, you can still send a `local_shell_call_output`; include the error message in the `output` field.
875
876The model can choose to recover or try executing a different command. If you send malformed data (for example, a missing `id`) the API returns a standard `400` validation error.