Local shell

Local shell (로컬 셸)

로컬 셸(local shell) 도구는 이제 구식이에요. 새 용도에서는 GPT-5.1과 함께 shell 도구를 사용하세요. 자세히 알아보기.

로컬 셸은 여러분이나 사용자가 제공한 머신에서 에이전트가 로컬로 셸 명령을 실행하게 해주는 도구예요. Codex CLI와 codex-mini-latest와 함께 동작하도록 설계됐어요. 명령은 여러분의 런타임 안에서 실행되므로 실제로 어떤 명령이 실행되는지는 전적으로 여러분이 통제해요. API는 지시만 돌려주며 OpenAI 인프라에서는 실행하지 않아요.

로컬 셸은 codex-mini-latest와 함께 쓰기 위해 Responses API를 통해 제공돼요. 다른 모델이나 Chat Completions API에서는 사용할 수 없어요.

임의의 셸 명령을 실행하는 것은 위험할 수 있어요. 명령을 시스템 셸로 전달하기 전에 항상 실행을 샌드박싱하거나 엄격한 허용 목록(allowlist)·차단 목록(deny list)을 추가하세요.

참조 구현은 Codex CLI를 보세요.

출처: 문서

본문

동작 방식

로컬 셸 도구는 에이전트가 터미널에 접근한 상태로 연속 루프를 돌 수 있게 해줘요.

모델이 셸 명령을 보내면, 여러분의 코드가 그 명령을 로컬 머신에서 실행하고 결과를 모델로 돌려줘요. 이 루프를 통해 모델은 추가 사용자 개입 없이 build-test-run 사이클을 완료할 수 있어요.

여러분의 코드는 local_shell_call 출력 항목을 듣고 그 안의 명령을 실행하는 루프를 구현해야 해요. 예기치 않은 명령이 실행되지 않도록 실행을 샌드박싱하는 것을 강력히 권장해요.

로컬 셸 도구 통합

애플리케이션에 로컬 셸 도구를 통합하려면 따라야 할 높은 수준의 단계는 다음과 같아요.

  1. 모델에 요청 보내기: 사용 가능한 도구에 local_shell 도구를 포함시켜요.
  2. 모델 응답 받기: 응답에 local_shell_call 항목이 있는지 확인해요. 이 도구 호출에는 exec 같은 action과 실행할 명령이 담겨 있어요.
  3. 요청된 액션 실행: 여러분이 통제하는 로컬 환경에서 명령을 실행해요.
  4. 액션 출력 반환: 액션을 실행한 뒤 명령 출력을 모델로 돌려줘요.
  5. 반복: 업데이트된 상태를 local_shell_call_output으로 담아 새 요청을 보내고, 모델이 액션을 요청하지 않거나 여러분이 중단하기로 할 때까지 이 루프를 반복해요.

예제 워크플로

아래는 요청/응답 루프를 보여주는 최소 예제예요. 언어를 선택하면 해당 SDK의 동등한 워크플로를 볼 수 있어요. 간결성을 위해 프로덕션급 샌드박싱과 보안 검사는 생략했어요. 추가 안전장치 없이 프로덕션에서 신뢰할 수 없는 명령을 실행하지 마세요.

import { spawn } from "node:child_process";
import process from "node:process";
import OpenAI from "openai";

const client = new OpenAI();
const MAX_TIMEOUT_MS = 10_000;

function runCommand(command, options) {
  return new Promise((resolve) => {
    let stdout = "";
    let stderr = "";
    let settled = false;
    let groupPoll;
    const child = spawn(command[0], command.slice(1), {
      ...options,
      detached: process.platform !== "win32",
      stdio: ["ignore", "pipe", "pipe"],
    });
    const finish = (suffix = "") => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      clearTimeout(groupPoll);
      resolve(stdout + stderr + suffix);
    };
    const processGroupIsRunning = () => {
      if (process.platform === "win32" || !child.pid) return false;
      try {
        process.kill(-child.pid, 0);
        return true;
      } catch {
        return false;
      }
    };
    const finishAfterProcessGroup = (suffix) => {
      if (settled) return;
      if (processGroupIsRunning()) {
        groupPoll = setTimeout(() => finishAfterProcessGroup(suffix), 10);
      } else {
        finish(suffix);
      }
    };
    const killProcessTree = () => {
      try {
        if (process.platform !== "win32" && child.pid) {
          process.kill(-child.pid, "SIGKILL");
        } else {
          child.kill("SIGKILL");
        }
      } catch {
        child.kill("SIGKILL");
      }
      child.stdout?.destroy();
      child.stderr?.destroy();
    };
    const timer = setTimeout(() => {
      killProcessTree();
      finish("Command timed out.\n");
    }, options.timeout);

    child.stdout?.on("data", (chunk) => {
      stdout += chunk;
    });
    child.stderr?.on("data", (chunk) => {
      stderr += chunk;
    });
    child.on("error", (error) => {
      finish(`Command failed: ${error.message}.\n`);
    });
    child.on("close", (code, signal) => {
      if (signal) {
        finishAfterProcessGroup(`Command failed with signal ${signal}.\n`);
      } else if (code !== 0) {
        finishAfterProcessGroup(`Command failed with exit code ${code}.\n`);
      } else {
        finishAfterProcessGroup("");
      }
    });
  });
}

let response = await client.responses.create({
  model: "codex-mini-latest",
  tools: [{ type: "local_shell" }],
  parallel_tool_calls: false,
  input: "List files in the current directory.",
});

while (true) {
  const shellCall = response.output.find(
    (item) => item.type === "local_shell_call"
  );
  if (!shellCall) break;

  const { command, env, timeout_ms, user, working_directory } =
    shellCall.action;
  let output;
  if (user) {
    output = `Unsupported execution user: ${user}.\n`;
  } else if (command.length === 0) {
    output = "Command is empty.\n";
  } else {
    const timeout =
      timeout_ms && timeout_ms > 0
        ? Math.min(timeout_ms, MAX_TIMEOUT_MS)
        : MAX_TIMEOUT_MS;
    try {
      output = await runCommand(command, {
        cwd: working_directory ?? process.cwd(),
        env: { PATH: process.env.PATH ?? "", ...env },
        timeout,
      });
    } catch (error) {
      output = `Command failed: ${error instanceof Error ? error.message : String(error)}.\n`;
    }
  }

  response = await client.responses.create({
    model: "codex-mini-latest",
    tools: [{ type: "local_shell" }],
    parallel_tool_calls: false,
    previous_response_id: response.id,
    input: [
      {
        type: "local_shell_call_output",
        id: shellCall.call_id,
        output,
      },
    ],
  });
}

console.log(response.output_text);
import os
import signal
import subprocess
import time
from contextlib import suppress
from openai import OpenAI

client = OpenAI()
MAX_TIMEOUT_MS = 10_000


def output_text(value):
    if isinstance(value, bytes):
        return value.decode(errors="replace")
    return value or ""


def process_group_is_running(pid):
    if os.name == "nt":
        return False
    try:
        os.killpg(pid, 0)
        return True
    except ProcessLookupError:
        return False
    except PermissionError:
        return True


response = client.responses.create(
    model="codex-mini-latest",
    tools=[{"type": "local_shell"}],
    parallel_tool_calls=False,
    input="List files in the current directory.",
)

while True:
    shell_call = next(
        (item for item in response.output if item.type == "local_shell_call"),
        None,
    )
    if shell_call is None:
        break

    action = shell_call.action
    if action.user:
        output = f"Unsupported execution user: {action.user}.\n"
    elif not action.command:
        output = "Command is empty.\n"
    else:
        timeout_ms = (
            min(action.timeout_ms, MAX_TIMEOUT_MS)
            if action.timeout_ms and action.timeout_ms > 0
            else MAX_TIMEOUT_MS
        )
        deadline = time.monotonic() + timeout_ms / 1000
        try:
            process = subprocess.Popen(
                action.command,
                cwd=action.working_directory or os.getcwd(),
                env={"PATH": os.environ.get("PATH", ""), **action.env},
                stdin=subprocess.DEVNULL,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                errors="replace",
                start_new_session=True,
            )
            stdout, stderr = process.communicate(
                timeout=max(deadline - time.monotonic(), 0)
            )
            while process_group_is_running(process.pid):
                remaining = deadline - time.monotonic()
                if remaining <= 0:
                    raise subprocess.TimeoutExpired(action.command, timeout_ms / 1000)
                time.sleep(min(remaining, 0.01))
            output = stdout + stderr
            if process.returncode:
                output += f"Command failed with exit code {process.returncode}.\n"
        except subprocess.TimeoutExpired as error:
            if os.name == "nt":
                process.kill()
            else:
                with suppress(ProcessLookupError):
                    os.killpg(process.pid, signal.SIGKILL)
            try:
                stdout, stderr = process.communicate(
                    timeout=max(deadline - time.monotonic(), 0)
                )
            except subprocess.TimeoutExpired as drain_error:
                if process.stdout:
                    process.stdout.close()
                if process.stderr:
                    process.stderr.close()
                stdout = output_text(
                    drain_error.stdout
                    if drain_error.stdout is not None
                    else error.stdout
                )
                stderr = output_text(
                    drain_error.stderr
                    if drain_error.stderr is not None
                    else error.stderr
                )
            output = output_text(stdout) + output_text(stderr) + "Command timed out.\n"
        except (OSError, TypeError, ValueError) as error:
            output = f"Command failed: {error}.\n"

    output_item = {
        "type": "local_shell_call_output",
        "id": shell_call.call_id,
        "output": output,
    }

    response = client.responses.create(
        model="codex-mini-latest",
        tools=[{"type": "local_shell"}],
        parallel_tool_calls=False,
        previous_response_id=response.id,
        input=[output_item],
    )

print(response.output_text)
package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"sync"
	"sync/atomic"
	"syscall"
	"time"

	"github.com/openai/openai-go/v3"
	"github.com/openai/openai-go/v3/responses"
)

const maxCommandTimeout = 10 * time.Second

func main() {
	client := openai.NewClient()
	tool := responses.ToolUnionParam{OfLocalShell: &responses.ToolLocalShellParam{}}
	response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
		Model:             "codex-mini-latest",
		Tools:             []responses.ToolUnionParam{tool},
		ParallelToolCalls: openai.Bool(false),
		Input: responses.ResponseNewParamsInputUnion{
			OfString: openai.String("List files in the current directory."),
		},
	})
	if err != nil {
		panic(err)
	}

	for {
		var shellCall *responses.ResponseOutputItemLocalShellCall
		for _, item := range response.Output {
			if item.Type == "local_shell_call" {
				call := item.AsLocalShellCall()
				shellCall = &call
				break
			}
		}
		if shellCall == nil {
			break
		}

		action := shellCall.Action
		var output []byte
		if action.User != "" {
			output = []byte(fmt.Sprintf("Unsupported execution user: %s.\n", action.User))
		} else if len(action.Command) == 0 {
			output = []byte("Command is empty.\n")
		} else {
			path := os.Getenv("PATH")
			if actionPath, ok := action.Env["PATH"]; ok {
				path = actionPath
			}
			executable, pathErr := commandPath(action.Command[0], path, action.WorkingDirectory)
			if pathErr != nil {
				output = []byte(fmt.Sprintf("Command failed: %v\n", pathErr))
			} else {
				timeout := maxCommandTimeout
				if action.TimeoutMs > 0 && action.TimeoutMs < maxCommandTimeout.Milliseconds() {
					timeout = time.Duration(action.TimeoutMs) * time.Millisecond
				}
				deadline := time.Now().Add(timeout)
				ctx, cancel := context.WithTimeout(context.Background(), timeout)
				command := exec.CommandContext(ctx, executable, action.Command[1:]...)
				command.Args[0] = action.Command[0]
				command.Dir = action.WorkingDirectory
				command.Env = []string{"PATH=" + path}
				command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
				for key, value := range action.Env {
					if key == "PATH" {
						continue
					}
					command.Env = append(command.Env, key+"="+value)
				}
				killProcessGroup := func() {
					if command.Process != nil {
						_ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
					}
				}
				processGroupIsRunning := func() bool {
					return command.Process != nil && syscall.Kill(-command.Process.Pid, 0) == nil
				}
				stdout, stdoutWriter, stdoutErr := os.Pipe()
				stderr, stderrWriter, stderrErr := os.Pipe()
				if stdoutErr != nil || stderrErr != nil {
					if stdout != nil {
						_ = stdout.Close()
					}
					if stdoutWriter != nil {
						_ = stdoutWriter.Close()
					}
					if stderr != nil {
						_ = stderr.Close()
					}
					if stderrWriter != nil {
						_ = stderrWriter.Close()
					}
					output = []byte(fmt.Sprintf("Command failed: %v%v\n", stdoutErr, stderrErr))
				} else {
					command.Stdout = stdoutWriter
					command.Stderr = stderrWriter
					var combinedOutput bytes.Buffer
					var outputLock sync.Mutex
					var readers sync.WaitGroup
					readOutput := func(reader io.ReadCloser) {
						defer readers.Done()
						data, _ := io.ReadAll(reader)
						outputLock.Lock()
						_, _ = combinedOutput.Write(data)
						outputLock.Unlock()
					}
					var commandErr error
					commandErr = command.Start()
					if commandErr == nil {
						_ = stdoutWriter.Close()
						_ = stderrWriter.Close()
						readers.Add(2)
						go readOutput(stdout)
						go readOutput(stderr)
						var timedOut atomic.Bool
						remaining := time.Until(deadline)
						if remaining < 0 {
							remaining = 0
						}
						markTimedOut := func() {
							if timedOut.Swap(true) {
								return
							}
							killProcessGroup()
							_ = stdout.Close()
							_ = stderr.Close()
						}
						timer := time.AfterFunc(remaining, markTimedOut)
						commandErr = command.Wait()
						if !time.Now().Before(deadline) ||
							errors.Is(commandErr, context.DeadlineExceeded) ||
							errors.Is(ctx.Err(), context.DeadlineExceeded) {
							markTimedOut()
						}
						for processGroupIsRunning() && !timedOut.Load() {
							time.Sleep(10 * time.Millisecond)
						}
						readers.Wait()
						if !timer.Stop() || !time.Now().Before(deadline) {
							markTimedOut()
						}
						output = combinedOutput.Bytes()
						if timedOut.Load() || errors.Is(ctx.Err(), context.DeadlineExceeded) {
							killProcessGroup()
							output = append(output, "Command timed out.\n"...)
						} else if commandErr != nil {
							output = append(output, fmt.Sprintf("Command failed: %v\n", commandErr)...)
						}
					} else {
						_ = stdout.Close()
						_ = stderr.Close()
						_ = stdoutWriter.Close()
						_ = stderrWriter.Close()
						output = append(output, fmt.Sprintf("Command failed: %v\n", commandErr)...)
					}
				}
				cancel()
			}
		}

		response, err = client.Responses.New(context.Background(), responses.ResponseNewParams{
			Model:              "codex-mini-latest",
			Tools:              []responses.ToolUnionParam{tool},
			ParallelToolCalls:  openai.Bool(false),
			PreviousResponseID: openai.String(response.ID),
			Input: responses.ResponseNewParamsInputUnion{
				OfInputItemList: []responses.ResponseInputItemUnionParam{{
					OfLocalShellCallOutput: &responses.ResponseInputItemLocalShellCallOutputParam{
						ID:     shellCall.CallID,
						Output: string(output),
					},
				}},
			},
		})
		if err != nil {
			panic(err)
		}
	}

	fmt.Println(response.OutputText())
}

func commandPath(command string, path string, workingDirectory string) (string, error) {
	if filepath.Base(command) != command {
		return command, nil
	}
	baseDirectory, err := filepath.Abs(workingDirectory)
	if err != nil {
		return "", err
	}
	directories := filepath.SplitList(path)
	if len(directories) == 0 {
		directories = []string{""}
	}
	for _, directory := range directories {
		if directory == "" {
			directory = "."
		}
		if !filepath.IsAbs(directory) {
			directory = filepath.Join(baseDirectory, directory)
		}
		candidate := filepath.Join(directory, command)
		info, err := os.Stat(candidate)
		if err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
			return candidate, nil
		}
	}
	return "", fmt.Errorf("command %q not found in PATH", command)
}
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.core.JsonValue;
import com.openai.models.responses.ResponseCreateParams;
import com.openai.models.responses.ResponseInputItem;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

ResponseCreateParams.Builder request =
    ResponseCreateParams.builder()
        .model("codex-mini-latest")
        .input("List files in the current directory.")
        .parallelToolCalls(false)
        .putAdditionalBodyProperty(
            "tools", JsonValue.from(List.of(Map.of("type", "local_shell"))));
var response = client.responses().create(request.build());

while (true) {
  var shellCall =
      response.output().stream()
          .flatMap(item -> item.localShellCall().stream())
          .findFirst()
          .orElse(null);
  if (shellCall == null) {
    break;
  }

  var action = shellCall.action();
  String output;
  if (action.user().isPresent()) {
    output = "Unsupported execution user: " + action.user().get() + ".\n";
  } else if (action.command().isEmpty()) {
    output = "Command is empty.\n";
  } else {
    try {
      boolean usesShellSupervisor =
          !System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("win");
      String hostPath = System.getenv("PATH");
      String childPath = hostPath;
      Map<String, String> actionEnvironment = new LinkedHashMap<>();
      for (Map.Entry<String, com.openai.core.JsonValue> variable :
          action.env()._additionalProperties().entrySet()) {
        String value = (String) variable.getValue().asString().orElseThrow();
        actionEnvironment.put(variable.getKey(), value);
        if (variable.getKey().equals("PATH")) {
          childPath = value;
        }
      }
      List<String> command;
      if (usesShellSupervisor) {
        String supervisorShell =
            Files.isExecutable(Path.of("/bin/bash")) ? "/bin/bash" : "/bin/sh";
        command =
            new ArrayList<>(
                List.of(
                    supervisorShell,
                    "-c",
                    "set -m; child=; "
                        + "cleanup() { test -z \"$child\" || "
                        + "kill -KILL -- \"-$child\" 2>/dev/null; }; "
                        + "trap cleanup TERM INT HUP; \"$@\" & child=$!; set +m; "
                        + "wait \"$child\" 2>/dev/null; status=$?; "
                        + "while kill -0 -- \"-$child\" 2>/dev/null; do sleep 0.01; done; "
                        + "exit \"$status\"",
                    "local-shell",
                    "/usr/bin/env",
                    "-i"));
        if (childPath != null) {
          command.add("PATH=" + childPath);
        }
        for (Map.Entry<String, String> variable : actionEnvironment.entrySet()) {
          if (!variable.getKey().equals("PATH")) {
            command.add(variable.getKey() + "=" + variable.getValue());
          }
        }
        command.addAll(action.command());
      } else {
        command = new ArrayList<>(action.command());
      }
      ProcessBuilder processBuilder = new ProcessBuilder(command);
      processBuilder.directory(action.workingDirectory().map(java.io.File::new).orElse(null));
      processBuilder.environment().clear();
      if (hostPath != null) {
        processBuilder.environment().put("PATH", hostPath);
      }
      if (!usesShellSupervisor) {
        processBuilder.environment().putAll(actionEnvironment);
      }
      Process process = processBuilder.redirectErrorStream(true).start();
      process.getOutputStream().close();
      var outputFuture =
          CompletableFuture.supplyAsync(
              () -> {
                try {
                  return new String(
                      process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
                } catch (IOException error) {
                  throw new UncheckedIOException(error);
                }
              });
      long timeoutMillis =
          action
              .timeoutMs()
              .filter(timeout -> timeout > 0)
              .map(timeout -> Math.min(timeout, MAX_TIMEOUT_MILLIS))
              .orElse(MAX_TIMEOUT_MILLIS);
      long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
      boolean finished = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS);
      if (!finished) {
        destroyProcessTree(process, usesShellSupervisor);
      }
      try {
        long remainingNanos = Math.max(1, deadlineNanos - System.nanoTime());
        output = outputFuture.get(remainingNanos, TimeUnit.NANOSECONDS);
        if (!finished) {
          output = "Command timed out.\n" + output;
        } else if (process.exitValue() != 0) {
          output += "Command failed with exit code " + process.exitValue() + ".\n";
        }
      } catch (TimeoutException error) {
        destroyProcessTree(process, usesShellSupervisor);
        process.getInputStream().close();
        output = "Command timed out.\n";
      } catch (java.util.concurrent.ExecutionException error) {
        output = "Command failed: " + error.getCause().getMessage() + ".\n";
      }
    } catch (IOException | IllegalArgumentException error) {
      output = "Command failed: " + error.getMessage() + ".\n";
    }
  }

  response =
      client
          .responses()
          .create(
              request
                  .previousResponseId(response.id())
                  .inputOfResponse(
                      List.of(
                          ResponseInputItem.ofLocalShellCallOutput(
                              ResponseInputItem.LocalShellCallOutput.builder()
                                  .id(shellCall.callId())
                                  .output(output)
                                  .build())))
                  .build());
}

response.output().stream()
    .flatMap(item -> item.message().stream())
    .flatMap(message -> message.content().stream())
    .flatMap(content -> content.outputText().stream())
    .forEach(text -> System.out.println(text.text()));

private static void destroyProcessTree(Process process, boolean usesShellSupervisor) {
  process.descendants().forEach(ProcessHandle::destroyForcibly);
  if (usesShellSupervisor) {
    process.destroy();
  } else {
    process.destroyForcibly();
  }
}
require "open3"
require "openai"
require "timeout"

client = OpenAI::Client.new
MAX_TIMEOUT_MS = 10_000
response = client.responses.create(
  model: "codex-mini-latest",
  tools: [{ type: :local_shell }],
  parallel_tool_calls: false,
  input: "List files in the current directory."
)

loop do
  shell_call = response.output.find do |item|
    item.is_a?(OpenAI::Models::Responses::ResponseOutputItem::LocalShellCall)
  end
  break unless shell_call.is_a?(
    OpenAI::Models::Responses::ResponseOutputItem::LocalShellCall
  )

  action = shell_call.action
  stdout = +""
  stderr = +""
  if action.user
    stderr << "Unsupported execution user: #{action.user}.\n"
  elsif action.command.empty?
    stderr << "Command is empty.\n"
  else
    begin
      executable = action.command.fetch(0)
      environment = { "PATH" => ENV.fetch("PATH", "") }.merge(action.env.transform_keys(&:to_s))
      status, timed_out = Open3.popen3(
        environment,
        [executable, executable],
        *action.command.drop(1),
        chdir: action.working_directory || Dir.pwd,
        pgroup: true,
        unsetenv_others: true
      ) do |stdin, child_stdout, child_stderr, wait_thread|
        stdin.close
        stdout_reader = Thread.new {
          begin
            child_stdout.read
          rescue
            ""
          end
        }
        stderr_reader = Thread.new {
          begin
            child_stderr.read
          rescue
            ""
          end
        }
        timeout_ms = action.timeout_ms
        timeout = if timeout_ms&.positive?
                    [timeout_ms, MAX_TIMEOUT_MS].min / 1000.0
                  else
                    MAX_TIMEOUT_MS / 1000.0
                  end
        deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout

        command_timed_out = false
        wait_status = begin
          status = Timeout.timeout(timeout) { wait_thread.value }
          remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
          raise Timeout::Error if remaining <= 0

          stdout << Timeout.timeout(remaining) { stdout_reader.value }
          remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
          raise Timeout::Error if remaining <= 0

          stderr << Timeout.timeout(remaining) { stderr_reader.value }
          group_running = proc do
            Process.kill(0, -wait_thread.pid)
            true
          rescue Errno::ESRCH
            false
          rescue Errno::EPERM
            true
          end
          while group_running.call
            remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
            raise Timeout::Error if remaining <= 0

            sleep [remaining, 0.01].min
          end
          status
        rescue Timeout::Error
          command_timed_out = true
          begin
            Process.kill("TERM", -wait_thread.pid)
            Process.kill("KILL", -wait_thread.pid)
          rescue Errno::ESRCH
            nil
          end
          child_stdout.close
          child_stderr.close
          stdout_reader.kill
          stderr_reader.kill
          stderr << "Command timed out.\n"
          wait_thread.value
        end
        [wait_status, command_timed_out]
      end
      exit_status = status.exitstatus
      if exit_status && !status.success? && !timed_out
        stderr << "Command failed with exit code #{exit_status}.\n"
      elsif status.signaled? && !timed_out
        stderr << "Command failed with signal #{status.termsig}.\n"
      end
    rescue SystemCallError, ArgumentError, TypeError => error
      stderr << "Command failed: #{error.message}.\n"
    end
  end

  response = client.responses.create(
    model: "codex-mini-latest",
    tools: [{ type: :local_shell }],
    parallel_tool_calls: false,
    previous_response_id: response.id,
    input: [
      {
        type: :local_shell_call_output,
        id: shell_call.call_id,
        output: (stdout + stderr).encode("UTF-8", invalid: :replace, undef: :replace)
      }
    ]
  )
end

puts(response.output_text)

모범 사례

  • 실행을 샌드박스 또는 컨테이너로 격리하세요. Docker나 제한된 사용자 계정(jailed user account)을 고려해보세요.
  • 리소스 한도를 적용하세요 (시간, 메모리, 네트워크). 모델이 주는 timeout_ms는 단지 힌트일 뿐이므로, 여러분만의 한도를 직접 적용해야 해요.
  • 고위험 명령(예: rm, curl, 네트워크 유틸리티)을 필터링하거나 면밀히 검토하세요.
  • 모든 명령과 출력을 기록해서 감사(auditing)와 디버깅에 사용하세요.

오류 처리

명령이 여러분 쪽에서 실패한 경우(예: 0이 아닌 종료 코드나 타임아웃)에도 여전히 local_shell_call_output을 보낼 수 있어요. 오류 메시지를 output 필드에 포함하면 돼요.

모델은 복구하거나 다른 명령 실행을 시도하도록 선택할 수 있어요. 잘못된 데이터(예: 누락된 id)를 보내면 API가 표준 400 검증 오류를 반환해요.

더 알아보기 (Learn more)