Add the initial OpenCode SCV executor

6389cbdc1edb · AtlantisPleb · · parent 1c210c0d2e0e

Add the initial OpenCode SCV executor

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/scv-planning.md
  • added lib/mix/tasks/openagents.scv.opencode.ex
  • added lib/openagents/scv/executor/open_code.ex
  • added lib/openagents/scv/open_code_events.ex
  • added lib/openagents/scv/resource_sampler.ex
  • added ops/scv/images/build-opencode-core.sh
  • added ops/scv/images/opencode-core/Dockerfile
  • added ops/scv/images/versions.env
  • added test/openagents/scv/open_code_events_test.exs
  • added test/openagents/scv/open_code_executor_test.exs

Diff

10 files changed, +1827 -3

docs/scv-planning.md modified +126 -3

@@ -2,9 +2,9 @@

2 2
3 3
Date: 2026-08-20
4 4
5
Status: Proposed architecture; OpenCode is the designated first execution
6
runtime and qualification workload;
7
implementation and autonomous deployment remain disabled
5
Status: Initial local OpenCode qualification adapter implemented; isolated
6
worker scheduling, durable tool effects, and autonomous deployment remain
7
disabled
8 8
9 9
## Outcome
10 10

@@ -43,6 +43,129 @@ The recommended first autonomous milestone is staging-only deployment of a

43 43
narrow, low-risk change class. Production autonomy is a later admission, not a
44 44
configuration toggle hidden inside the first release.
45 45
46
## Local implementation checkpoint
47
48
The repository now contains a local, coarse-effect OpenCode adapter that proves
49
the first runtime integration without enabling an SCV coordinator, repository
50
write authority, worker registration, Forge promotion, or deployment:
51
52
- `OpenAgents.SCV.Executor.OpenCode` starts one bounded OpenCode process with an
53
  isolated home, XDG roots, SQLite database, operator-owned configuration, and
54
  explicit permission profile.
55
- `OpenAgents.SCV.OpenCodeEvents` normalizes content-free event counts, tool
56
  outcomes, token classes, and estimated cost.
57
- `OpenAgents.SCV.ResourceSampler` observes the direct OpenCode process from the
58
  host and records RSS and CPU samples.
59
- `mix openagents.scv.opencode` exposes the adapter for local qualification.
60
- `ops/scv/images/opencode-core/Dockerfile` defines the first multi-architecture
61
  worker toolchain with pinned Ubuntu, Node.js, Bun, and OpenCode inputs.
62
63
The executor emits `openagents.scv.event.v1` records while the run is active.
64
Callers can supply an `event_sink` function, and the executor also emits the
65
same records through the `[:openagents, :scv, :event]` telemetry event. The Mix
66
task prints lifecycle events, five-second resource heartbeats, and normalized
67
OpenCode events to standard error. `--diagnostic-logs` also prints redacted
68
OpenCode logs as OpenCode produces them. Final JSON remains on standard output,
69
so an operator or process can consume the receipt without waiting blindly for
70
the command to finish.
71
72
The adapter writes the bounded prompt to a mode `0600` scratch file and gives
73
that finite file to OpenCode as standard input. This keeps prompt content out of
74
the process argument list and delivers EOF after the prompt. OpenCode reads a
75
non-terminal standard input stream as additional prompt content and otherwise
76
waits indefinitely when an Erlang port keeps that stream open. Keep the finite
77
input wrapper as part of the adapter contract and retain its regression test.
78
79
OpenCode treats `XDG_CONFIG_HOME` as a parent directory and
80
`OPENCODE_CONFIG_DIR` as the OpenCode configuration directory itself. The
81
adapter uses `<XDG_CONFIG_HOME>/opencode` for the latter. Pointing both variables
82
at the parent creates two dependency locations and can trigger an unseeded
83
background install. A trusted local `config_seed` may copy only dependency and
84
lock files into the isolated directory; it never copies OpenCode configuration
85
or authentication state.
86
87
### Proven local run
88
89
On 2026-08-20, the adapter ran installed OpenCode `1.18.5` against the inspected
90
OpenCode `dev` commit `b155b15694dbcc6768f11d2f25cc2bdd1f738ab4` with model
91
`openai/gpt-5.4-mini` and read-only permissions. The fixed task read
92
`package.json` and `README.md` without changing the checkout.
93
94
The terminal receipt recorded:
95
96
- `succeeded` with exit status `0` in 7,845 milliseconds;
97
- eight structured events across two model steps;
98
- two completed `read` tool calls and no tool errors;
99
- 7,981 input, 144 output, 74 reasoning, and 3,584 cache-read tokens;
100
- an estimated cost of `$0.00723555`;
101
- 622,215,168 bytes of peak direct-process RSS and 141.9% maximum sampled CPU;
102
- 40,855 captured output bytes with no truncation.
103
104
The run streamed lifecycle, diagnostic, tool, and resource events before it
105
wrote the terminal summary. The summary and redacted event artifact use mode
106
`0600`; the executor deletes its scratch home after termination. The supplied
107
provider credential entered through silent terminal input and did not appear in
108
the command arguments, summary, or artifact.
109
110
A separate `workspace_write` proof ran only against a disposable Git fixture.
111
It changed `message.txt` from `before` to `after`, emitted one completed
112
`apply_patch` tool event, produced no malformed event lines, and left every
113
other file unchanged. This validates local edit mechanics but does not admit the
114
write profile for a durable or autonomous SCV.
115
116
This proof does not satisfy the final worker boundary. It runs the Elixir
117
controller and OpenCode process on the trusted development host, measures only
118
the direct OpenCode process, terminates only that direct process, and treats the
119
whole OpenCode session as one uncertain external effect. Before an SCV receives
120
write authority, move this adapter into the admitted worker, enforce process
121
groups or cgroups, replace reusable provider credentials with a run-scoped
122
inference grant, and persist each tool effect before execution.
123
124
### Run the local adapter
125
126
Set `OPENAI_API_KEY` in the process environment without adding it to shell
127
history, then run:
128
129
```console
130
OPENCODE_BIN=/absolute/path/to/opencode \
131
OPENCODE_CONFIG_SEED=/absolute/path/to/trusted/opencode-config \
132
mix openagents.scv.opencode \
133
  --repo /absolute/path/to/target \
134
  --model openai/gpt-5.4-mini \
135
  --timeout-seconds 180 \
136
  --diagnostic-logs \
137
  --prompt 'Inspect the requested files without changing them.' \
138
  --json
139
```
140
141
Omit `OPENCODE_CONFIG_SEED` when the isolated OpenCode installation can resolve
142
its dependencies during an admitted setup phase. Do not use `--write` against a
143
valuable checkout. The current write profile enables only OpenCode's edit tool;
144
it does not yet provide the durable per-effect barrier required for candidate
145
construction.
146
147
### Proven image build
148
149
Run `ops/scv/images/build-opencode-core.sh` to build the native architecture as
150
`openagents/scv-opencode-core:local`. On 2026-08-20, the ARM64 build produced
151
local image digest
152
`sha256:d19e17c36f40cfa9dfb8123a9bfb93aec5d09deb1513ac0ceaf26b80a29360e3`
153
and size 313,619,978 bytes. This local digest is evidence, not an admitted or
154
published worker identity.
155
156
The smoke test ran as UID and GID `10001` and verified OpenCode `1.18.5`, Bun
157
`1.3.14`, Node.js `24.15.0`, npm `11.12.1`, Python `3.12.3`, Git `2.43.0`, and
158
ripgrep `14.1.0`. The image contains neither a Docker client nor a mounted
159
Docker socket. A live OpenCode session inside the image returned
160
`SCV_IMAGE_OK`, emitted a start, text, and finish event, used 3,296 tokens, and
161
reported `$0.00258825` estimated cost.
162
163
The image currently supplies the first execution toolchain, not the final
164
Elixir worker release or sidecar. The local Elixir adapter remains outside the
165
container. Add the process-role-specific worker release, protocol client,
166
cgroup collector, credential proxy, and read-only runtime mount before Forge
167
admits the image.
168
46 169
## Goals
47 170
48 171
An SCV should:
lib/mix/tasks/openagents.scv.opencode.ex added +172

@@ -0,0 +1,172 @@

1
defmodule Mix.Tasks.Openagents.Scv.Opencode do
2
  @moduledoc """
3
  Runs one bounded local SCV execution through OpenCode.
4
5
  Set `OPENAI_API_KEY` in the process environment, then run:
6
7
      mix openagents.scv.opencode \
8
        --repo /absolute/path/to/repository \
9
        --prompt "Inspect README.md and return a one-sentence description."
10
11
  The task uses read-only repository permissions by default. Pass `--write` only
12
  for a disposable workspace. Run artifacts default to `_build/scv/runs`.
13
  """
14
15
  use Mix.Task
16
17
  alias OpenAgents.SCV.Executor.OpenCode
18
19
  @shortdoc "Run one bounded local SCV execution through OpenCode"
20
21
  @switches [
22
    repo: :string,
23
    prompt: :string,
24
    prompt_file: :string,
25
    model: :string,
26
    opencode: :string,
27
    config_seed: :string,
28
    output: :string,
29
    timeout_seconds: :integer,
30
    diagnostic_logs: :boolean,
31
    write: :boolean,
32
    json: :boolean
33
  ]
34
35
  @aliases [r: :repo, m: :model, o: :output]
36
37
  @impl Mix.Task
38
  def run(arguments) do
39
    {options, positionals, invalid} =
40
      OptionParser.parse(arguments, strict: @switches, aliases: @aliases)
41
42
    if invalid != [] do
43
      Mix.raise("Unknown or invalid options: #{format_invalid(invalid)}")
44
    end
45
46
    if positionals != [] do
47
      Mix.raise(
48
        "Unexpected positional arguments. Pass the objective with --prompt or --prompt-file."
49
      )
50
    end
51
52
    repository = options |> Keyword.get(:repo, File.cwd!()) |> Path.expand()
53
    prompt = read_prompt!(options)
54
    output_root = options |> Keyword.get(:output, "_build/scv/runs") |> Path.expand()
55
    timeout_ms = Keyword.get(options, :timeout_seconds, 300) * 1_000
56
    permissions = if Keyword.get(options, :write, false), do: :workspace_write, else: :read_only
57
58
    executor_options =
59
      [
60
        api_key: System.get_env("OPENAI_API_KEY"),
61
        executable: Keyword.get(options, :opencode, default_executable()),
62
        config_seed: config_seed(options),
63
        model: Keyword.get(options, :model, "openai/gpt-5.4-mini"),
64
        output_root: output_root,
65
        timeout_ms: timeout_ms,
66
        diagnostic_logs: Keyword.get(options, :diagnostic_logs, false),
67
        event_sink: &print_live_event/1,
68
        permissions: permissions
69
      ]
70
71
    case OpenCode.run(repository, prompt, executor_options) do
72
      {:ok, result} ->
73
        print_result(result, Keyword.get(options, :json, false))
74
75
        if result.status != "succeeded" do
76
          Mix.raise("SCV execution ended with status #{result.status}.")
77
        end
78
79
      {:error, reason} ->
80
        Mix.raise("SCV execution could not start: #{inspect(reason)}")
81
    end
82
  end
83
84
  defp read_prompt!(options) do
85
    case {Keyword.get(options, :prompt), Keyword.get(options, :prompt_file)} do
86
      {prompt, nil} when is_binary(prompt) ->
87
        prompt
88
89
      {nil, path} when is_binary(path) ->
90
        case File.read(Path.expand(path)) do
91
          {:ok, prompt} ->
92
            prompt
93
94
          {:error, reason} ->
95
            Mix.raise("Could not read the prompt file: #{:file.format_error(reason)}")
96
        end
97
98
      {nil, nil} ->
99
        Mix.raise("Pass exactly one of --prompt or --prompt-file.")
100
101
      {_prompt, _path} ->
102
        Mix.raise("Pass exactly one of --prompt or --prompt-file.")
103
    end
104
  end
105
106
  defp print_result(result, true) do
107
    Mix.shell().info(Jason.encode!(result, pretty: true))
108
  end
109
110
  defp print_result(result, false) do
111
    session = List.first(result.events.session_ids) || "none"
112
113
    Mix.shell().info("SCV run: #{result.run_id}")
114
    Mix.shell().info("Status: #{result.status}")
115
    Mix.shell().info("OpenCode session: #{session}")
116
    Mix.shell().info("Duration: #{result.duration_ms} ms")
117
    Mix.shell().info("Events: #{result.events.event_count}")
118
    Mix.shell().info("Peak OpenCode RSS: #{result.resources.peak_rss_bytes} bytes")
119
    Mix.shell().info("Estimated cost: $#{format_cost(result.events.usage.cost_usd)}")
120
    Mix.shell().info("Event artifact: #{result.artifacts.events_path}")
121
    Mix.shell().info("Summary: #{result.summary_path}")
122
  end
123
124
  defp default_executable do
125
    System.get_env("OPENCODE_BIN") || System.find_executable("opencode")
126
  end
127
128
  defp config_seed(options) do
129
    case Keyword.get(options, :config_seed, System.get_env("OPENCODE_CONFIG_SEED")) do
130
      nil -> nil
131
      path -> Path.expand(path)
132
    end
133
  end
134
135
  defp format_invalid(invalid) do
136
    Enum.map_join(invalid, ", ", fn {name, value} -> "#{name}=#{inspect(value)}" end)
137
  end
138
139
  defp format_cost(cost) when is_number(cost), do: :erlang.float_to_binary(cost / 1, decimals: 6)
140
141
  defp print_live_event(%{type: "opencode_diagnostic", line: line}) do
142
    IO.puts(:stderr, "[SCV][OpenCode] #{line}")
143
  end
144
145
  defp print_live_event(%{type: "opencode_event"} = event) do
146
    details =
147
      [
148
        event.event_type,
149
        event.tool && "tool=#{event.tool}",
150
        event.tool_status && "status=#{event.tool_status}",
151
        event.text_bytes > 0 && "text_bytes=#{event.text_bytes}",
152
        event.error_name && "error=#{event.error_name}"
153
      ]
154
      |> Enum.filter(& &1)
155
      |> Enum.join(" ")
156
157
    IO.puts(:stderr, "[SCV] OpenCode event: #{details}")
158
  end
159
160
  defp print_live_event(%{type: "heartbeat"} = event) do
161
    rss_mib = Float.round(event.peak_rss_bytes / 1_048_576, 1)
162
163
    IO.puts(
164
      :stderr,
165
      "[SCV] Running: #{event.elapsed_ms} ms, RSS #{rss_mib} MiB, max CPU #{event.maximum_cpu_percent}%"
166
    )
167
  end
168
169
  defp print_live_event(%{type: type}) do
170
    IO.puts(:stderr, "[SCV] #{String.replace(type, "_", " ")}")
171
  end
172
end
lib/openagents/scv/executor/open_code.ex added +926

@@ -0,0 +1,926 @@

1
defmodule OpenAgents.SCV.Executor.OpenCode do
2
  @moduledoc """
3
  Runs one bounded OpenCode process under the local SCV control boundary.
4
5
  This initial executor treats the OpenCode process as one coarse effect. It
6
  isolates OpenCode state, denies unsafe capabilities, records JSON events, and
7
  collects host-observed measurements. A later worker transport persists each
8
  individual OpenCode tool request before execution.
9
  """
10
11
  alias OpenAgents.SCV.OpenCodeEvents
12
  alias OpenAgents.SCV.ResourceSampler
13
14
  @schema "openagents.scv.opencode.run.v1"
15
  @default_model "openai/gpt-5.4-mini"
16
  @default_timeout_ms 5 * 60 * 1_000
17
  @maximum_timeout_ms 60 * 60 * 1_000
18
  @default_maximum_output_bytes 16 * 1_024 * 1_024
19
  @maximum_output_bytes 64 * 1_024 * 1_024
20
  @default_sample_interval_ms 250
21
  @default_heartbeat_interval_ms 5_000
22
  @maximum_prompt_bytes 32_768
23
  @maximum_model_bytes 128
24
  @model_pattern ~r/\A[a-zA-Z0-9_.:-]+\/[a-zA-Z0-9_.:-]+\z/
25
26
  @spec run(Path.t(), String.t(), keyword()) :: {:ok, map()} | {:error, atom() | tuple()}
27
  def run(repository, prompt, options \\ []) do
28
    with {:ok, input} <- validate_input(repository, prompt, options),
29
         :ok <-
30
           emit_event(input, "run_preparing", %{
31
             model: input.model,
32
             permission_profile: Atom.to_string(input.permissions)
33
           }),
34
         {:ok, paths} <- prepare_paths(input.output_root, input.run_id, input.config_seed) do
35
      try do
36
        execute(input, paths, options)
37
      after
38
        File.rm_rf(paths.scratch)
39
      end
40
    end
41
  end
42
43
  defp validate_input(repository, prompt, options) do
44
    model = Keyword.get(options, :model, @default_model)
45
    timeout_ms = Keyword.get(options, :timeout_ms, @default_timeout_ms)
46
47
    maximum_output_bytes =
48
      Keyword.get(options, :maximum_output_bytes, @default_maximum_output_bytes)
49
50
    sample_interval_ms = Keyword.get(options, :sample_interval_ms, @default_sample_interval_ms)
51
    permissions = Keyword.get(options, :permissions, :read_only)
52
    run_id = Keyword.get(options, :run_id, Ecto.UUID.generate())
53
    output_root = Keyword.get(options, :output_root, default_output_root())
54
    api_key = Keyword.get(options, :api_key, System.get_env("OPENAI_API_KEY"))
55
    executable = Keyword.get(options, :executable, default_executable())
56
    config_seed = Keyword.get(options, :config_seed)
57
    diagnostic_logs = Keyword.get(options, :diagnostic_logs, false)
58
59
    heartbeat_interval_ms =
60
      Keyword.get(options, :heartbeat_interval_ms, @default_heartbeat_interval_ms)
61
62
    event_sink = Keyword.get(options, :event_sink, fn _event -> :ok end)
63
64
    with {:ok, repository} <- validate_directory(repository),
65
         :ok <- validate_prompt(prompt),
66
         :ok <- validate_model(model),
67
         :ok <- validate_integer(timeout_ms, 1, @maximum_timeout_ms, :timeout_invalid),
68
         :ok <-
69
           validate_integer(
70
             maximum_output_bytes,
71
             1,
72
             @maximum_output_bytes,
73
             :maximum_output_invalid
74
           ),
75
         :ok <- validate_integer(sample_interval_ms, 10, 60_000, :sample_interval_invalid),
76
         :ok <- validate_permissions(permissions),
77
         :ok <- validate_run_id(run_id),
78
         {:ok, output_root} <- validate_output_root(output_root),
79
         :ok <- validate_api_key(api_key),
80
         :ok <- validate_boolean(diagnostic_logs, :diagnostic_logs_invalid),
81
         :ok <-
82
           validate_integer(
83
             heartbeat_interval_ms,
84
             250,
85
             60_000,
86
             :heartbeat_interval_invalid
87
           ),
88
         :ok <- validate_event_sink(event_sink),
89
         {:ok, config_seed} <- validate_config_seed(config_seed),
90
         {:ok, executable} <- validate_executable(executable) do
91
      {:ok,
92
       %{
93
         repository: repository,
94
         prompt: prompt,
95
         prompt_bytes: byte_size(prompt),
96
         model: model,
97
         timeout_ms: timeout_ms,
98
         maximum_output_bytes: maximum_output_bytes,
99
         sample_interval_ms: sample_interval_ms,
100
         permissions: permissions,
101
         run_id: run_id,
102
         output_root: output_root,
103
         api_key: api_key,
104
         executable: executable,
105
         config_seed: config_seed,
106
         diagnostic_logs: diagnostic_logs,
107
         heartbeat_interval_ms: heartbeat_interval_ms,
108
         event_sink: event_sink
109
       }}
110
    end
111
  end
112
113
  defp prepare_paths(output_root, run_id, config_seed) do
114
    run_dir = Path.join(output_root, run_id)
115
116
    paths = %{
117
      run_dir: run_dir,
118
      artifacts: Path.join(run_dir, "artifacts"),
119
      events: Path.join([run_dir, "artifacts", "events.jsonl"]),
120
      summary: Path.join(run_dir, "summary.json"),
121
      scratch: Path.join(run_dir, "scratch"),
122
      home: Path.join([run_dir, "scratch", "home"]),
123
      config_root: Path.join([run_dir, "scratch", "xdg", "config"]),
124
      config: Path.join([run_dir, "scratch", "xdg", "config", "opencode"]),
125
      data: Path.join([run_dir, "scratch", "xdg", "data"]),
126
      state: Path.join([run_dir, "scratch", "xdg", "state"]),
127
      cache: Path.join([run_dir, "scratch", "xdg", "cache"]),
128
      tmp: Path.join([run_dir, "scratch", "tmp"]),
129
      database: Path.join([run_dir, "scratch", "opencode.db"]),
130
      prompt: Path.join([run_dir, "scratch", "prompt.txt"])
131
    }
132
133
    directories = [
134
      paths.artifacts,
135
      paths.home,
136
      paths.config,
137
      paths.data,
138
      paths.state,
139
      paths.cache,
140
      paths.tmp
141
    ]
142
143
    if File.exists?(run_dir) do
144
      {:error, :run_exists}
145
    else
146
      with :ok <- File.mkdir_p(run_dir),
147
           :ok <- File.chmod(run_dir, 0o700),
148
           :ok <- create_directories(directories),
149
           :ok <- seed_config(paths.config, config_seed) do
150
        {:ok, paths}
151
      end
152
    end
153
  end
154
155
  defp execute(input, paths, options) do
156
    started_at = DateTime.utc_now()
157
    started_ms = monotonic_ms()
158
    permission_map = permission_map(input.permissions)
159
    config = open_code_config()
160
    environment = command_environment(input, paths, config, permission_map)
161
    arguments = command_arguments(input)
162
    sample_fun = Keyword.get(options, :sample_fun, &ResourceSampler.sample/1)
163
164
    with :ok <- write_prompt(paths.prompt, input.prompt),
165
         {:ok, events_io} <- File.open(paths.events, [:write, :binary]),
166
         :ok <- File.chmod(paths.events, 0o600) do
167
      :ok = emit_event(input, "process_starting", %{executable: input.executable})
168
169
      execution =
170
        try do
171
          run_port(input, arguments, environment, events_io, sample_fun)
172
        after
173
          File.close(events_io)
174
        end
175
176
      result =
177
        build_result(
178
          input,
179
          paths,
180
          execution,
181
          started_at,
182
          started_ms,
183
          config,
184
          permission_map
185
        )
186
187
      with :ok <- write_summary(paths.summary, result) do
188
        :ok =
189
          emit_event(input, "run_finished", %{
190
            status: result.status,
191
            duration_ms: result.duration_ms,
192
            summary_path: paths.summary
193
          })
194
195
        {:ok, Map.put(result, :summary_path, paths.summary)}
196
      end
197
    else
198
      {:error, {:prompt_write_failed, _reason}} = error -> error
199
      {:error, reason} -> {:error, {:artifact_open_failed, reason}}
200
    end
201
  end
202
203
  defp run_port(input, arguments, environment, events_io, sample_fun) do
204
    shell = System.find_executable("sh") || "/bin/sh"
205
206
    shell_arguments = [
207
      "-c",
208
      "exec \"$@\" < \"$SCV_PROMPT_FILE\"",
209
      "scv-opencode",
210
      input.executable | arguments
211
    ]
212
213
    port =
214
      Port.open(
215
        {:spawn_executable, String.to_charlist(shell)},
216
        [
217
          :binary,
218
          :exit_status,
219
          :stderr_to_stdout,
220
          args: Enum.map(shell_arguments, &String.to_charlist/1),
221
          cd: String.to_charlist(input.repository),
222
          env: port_environment(environment)
223
        ]
224
      )
225
226
    {:os_pid, os_pid} = Port.info(port, :os_pid)
227
    now = monotonic_ms()
228
229
    :ok = emit_event(input, "process_started", %{os_pid: os_pid})
230
231
    state = %{
232
      events: OpenCodeEvents.new(),
233
      line_buffer: "",
234
      observed_output_bytes: 0,
235
      captured_output_bytes: 0,
236
      output_truncated?: false,
237
      sample_count: 0,
238
      sample_error_count: 0,
239
      peak_rss_bytes: 0,
240
      maximum_cpu_percent: 0.0,
241
      started_ms: now,
242
      next_heartbeat_ms: now + input.heartbeat_interval_ms,
243
      heartbeat_interval_ms: input.heartbeat_interval_ms,
244
      input: input
245
    }
246
247
    collect_port(
248
      port,
249
      os_pid,
250
      events_io,
251
      state,
252
      monotonic_ms() + input.timeout_ms,
253
      now,
254
      input.sample_interval_ms,
255
      input.maximum_output_bytes,
256
      sample_fun,
257
      [input.api_key]
258
    )
259
  rescue
260
    error ->
261
      %{
262
        status: "failed",
263
        exit_status: nil,
264
        error_code: "process_start_failed",
265
        error_detail: Exception.message(error),
266
        events: OpenCodeEvents.new(),
267
        observed_output_bytes: 0,
268
        captured_output_bytes: 0,
269
        output_truncated?: false,
270
        sample_count: 0,
271
        sample_error_count: 0,
272
        peak_rss_bytes: 0,
273
        maximum_cpu_percent: 0.0
274
      }
275
  end
276
277
  defp collect_port(
278
         port,
279
         os_pid,
280
         events_io,
281
         state,
282
         deadline_ms,
283
         next_sample_ms,
284
         sample_interval_ms,
285
         maximum_output_bytes,
286
         sample_fun,
287
         redactions
288
       ) do
289
    now = monotonic_ms()
290
291
    cond do
292
      now >= deadline_ms ->
293
        terminate_port(port, os_pid)
294
        finish_collection(state, events_io, redactions, "timeout", nil, "command_timeout")
295
296
      now >= next_sample_ms ->
297
        sampled = sample_resources(state, os_pid, sample_fun)
298
299
        collect_port(
300
          port,
301
          os_pid,
302
          events_io,
303
          sampled,
304
          deadline_ms,
305
          now + sample_interval_ms,
306
          sample_interval_ms,
307
          maximum_output_bytes,
308
          sample_fun,
309
          redactions
310
        )
311
312
      true ->
313
        wait_ms = max(min(deadline_ms, next_sample_ms) - now, 1)
314
315
        receive do
316
          {^port, {:data, data}} when is_binary(data) ->
317
            {updated, limit_reached?} =
318
              capture_output(state, data, events_io, maximum_output_bytes, redactions)
319
320
            if limit_reached? do
321
              terminate_port(port, os_pid)
322
323
              finish_collection(
324
                updated,
325
                events_io,
326
                redactions,
327
                "output_limit",
328
                nil,
329
                "output_limit"
330
              )
331
            else
332
              collect_port(
333
                port,
334
                os_pid,
335
                events_io,
336
                updated,
337
                deadline_ms,
338
                next_sample_ms,
339
                sample_interval_ms,
340
                maximum_output_bytes,
341
                sample_fun,
342
                redactions
343
              )
344
            end
345
346
          {^port, {:exit_status, exit_status}} ->
347
            status = if exit_status == 0, do: "succeeded", else: "failed"
348
            error_code = if exit_status == 0, do: nil, else: "command_failed"
349
350
            finish_collection(
351
              state,
352
              events_io,
353
              redactions,
354
              status,
355
              exit_status,
356
              error_code
357
            )
358
        after
359
          wait_ms ->
360
            collect_port(
361
              port,
362
              os_pid,
363
              events_io,
364
              state,
365
              deadline_ms,
366
              next_sample_ms,
367
              sample_interval_ms,
368
              maximum_output_bytes,
369
              sample_fun,
370
              redactions
371
            )
372
        end
373
    end
374
  end
375
376
  defp capture_output(state, data, events_io, maximum_output_bytes, redactions) do
377
    observed = state.observed_output_bytes + byte_size(data)
378
    available = max(maximum_output_bytes - state.captured_output_bytes, 0)
379
    captured = binary_part(data, 0, min(byte_size(data), available))
380
    updated = append_event_bytes(state, captured, events_io, redactions)
381
382
    updated = %{
383
      updated
384
      | observed_output_bytes: observed,
385
        captured_output_bytes: updated.captured_output_bytes + byte_size(captured),
386
        output_truncated?: byte_size(captured) < byte_size(data)
387
    }
388
389
    {updated, updated.output_truncated? or updated.captured_output_bytes >= maximum_output_bytes}
390
  end
391
392
  defp append_event_bytes(state, "", _events_io, _redactions), do: state
393
394
  defp append_event_bytes(state, data, events_io, redactions) do
395
    pieces = :binary.split(state.line_buffer <> data, "\n", [:global])
396
    {buffer, complete_lines} = List.pop_at(pieces, -1)
397
398
    events =
399
      Enum.reduce(complete_lines, state.events, fn line, events ->
400
        redacted = redact(line, redactions)
401
        :ok = IO.binwrite(events_io, redacted <> "\n")
402
        :ok = observe_output_line(state.input, redacted)
403
        ingest_nonempty(events, redacted)
404
      end)
405
406
    %{state | events: events, line_buffer: buffer || ""}
407
  end
408
409
  defp finish_collection(state, events_io, redactions, status, exit_status, error_code) do
410
    {events, line_buffer} =
411
      flush_line(state.input, state.events, state.line_buffer, events_io, redactions)
412
413
    :ok = :file.sync(events_io)
414
415
    :ok =
416
      emit_event(state.input, "process_finished", %{
417
        status: status,
418
        exit_status: exit_status,
419
        error_code: error_code
420
      })
421
422
    state
423
    |> Map.put(:events, events)
424
    |> Map.put(:line_buffer, line_buffer)
425
    |> Map.put(:status, status)
426
    |> Map.put(:exit_status, exit_status)
427
    |> Map.put(:error_code, error_code)
428
    |> Map.put(:error_detail, nil)
429
  end
430
431
  defp flush_line(_input, events, "", _events_io, _redactions), do: {events, ""}
432
433
  defp flush_line(input, events, line, events_io, redactions) do
434
    redacted = redact(line, redactions)
435
    :ok = IO.binwrite(events_io, redacted)
436
    :ok = observe_output_line(input, redacted)
437
    {ingest_nonempty(events, redacted), ""}
438
  end
439
440
  defp ingest_nonempty(events, ""), do: events
441
  defp ingest_nonempty(events, line), do: OpenCodeEvents.ingest(events, line)
442
443
  defp observe_output_line(_input, ""), do: :ok
444
445
  defp observe_output_line(input, line) do
446
    case Jason.decode(line) do
447
      {:ok, event} when is_map(event) ->
448
        emit_event(input, "opencode_event", live_open_code_event(event))
449
450
      _invalid when input.diagnostic_logs ->
451
        emit_event(input, "opencode_diagnostic", %{line: line})
452
453
      _invalid ->
454
        :ok
455
    end
456
  end
457
458
  defp live_open_code_event(event) do
459
    part = if is_map(event["part"]), do: event["part"], else: %{}
460
    state = if is_map(part["state"]), do: part["state"], else: %{}
461
462
    %{
463
      event_type: bounded_value(event["type"], 64, "unknown"),
464
      session_id: bounded_value(event["sessionID"], 128, nil),
465
      tool: bounded_value(part["tool"], 128, nil),
466
      tool_status: bounded_value(state["status"], 32, nil),
467
      text_bytes: byte_count(part["text"]),
468
      error_name: bounded_value(get_in(event, ["error", "name"]), 128, nil)
469
    }
470
  end
471
472
  defp sample_resources(state, os_pid, sample_fun) do
473
    sampled =
474
      case sample_fun.(os_pid) do
475
        {:ok, %{rss_bytes: rss_bytes, cpu_percent: cpu_percent}}
476
        when is_integer(rss_bytes) and rss_bytes >= 0 and is_number(cpu_percent) and
477
               cpu_percent >= 0 ->
478
          %{
479
            state
480
            | sample_count: state.sample_count + 1,
481
              peak_rss_bytes: max(state.peak_rss_bytes, rss_bytes),
482
              maximum_cpu_percent: max(state.maximum_cpu_percent, cpu_percent)
483
          }
484
485
        _error ->
486
          %{state | sample_error_count: state.sample_error_count + 1}
487
      end
488
489
    maybe_emit_heartbeat(sampled)
490
  end
491
492
  defp maybe_emit_heartbeat(state) do
493
    now = monotonic_ms()
494
495
    if now >= state.next_heartbeat_ms do
496
      :ok =
497
        emit_event(state.input, "heartbeat", %{
498
          elapsed_ms: max(now - state.started_ms, 0),
499
          peak_rss_bytes: state.peak_rss_bytes,
500
          maximum_cpu_percent: state.maximum_cpu_percent,
501
          observed_output_bytes: state.observed_output_bytes,
502
          sample_count: state.sample_count
503
        })
504
505
      %{state | next_heartbeat_ms: now + state.heartbeat_interval_ms}
506
    else
507
      state
508
    end
509
  end
510
511
  defp terminate_port(port, os_pid) do
512
    terminate_os_process(os_pid)
513
514
    if Port.info(port) do
515
      Port.close(port)
516
    end
517
518
    :ok
519
  rescue
520
    ArgumentError -> :ok
521
  end
522
523
  defp terminate_os_process(os_pid) when is_integer(os_pid) and os_pid > 0 do
524
    case System.find_executable("kill") do
525
      nil ->
526
        :ok
527
528
      executable ->
529
        System.cmd(executable, ["-KILL", Integer.to_string(os_pid)], stderr_to_stdout: true)
530
    end
531
532
    :ok
533
  rescue
534
    _error -> :ok
535
  end
536
537
  defp build_result(input, paths, execution, started_at, started_ms, config, permissions) do
538
    finished_at = DateTime.utc_now()
539
    duration_ms = max(monotonic_ms() - started_ms, 0)
540
    events_bytes = File.read!(paths.events)
541
542
    %{
543
      schema: @schema,
544
      run_id: input.run_id,
545
      status: execution.status,
546
      error_code: execution.error_code,
547
      exit_status: execution.exit_status,
548
      started_at: DateTime.to_iso8601(started_at),
549
      finished_at: DateTime.to_iso8601(finished_at),
550
      duration_ms: duration_ms,
551
      repository: %{
552
        path: input.repository,
553
        git_sha: git_sha(input.repository)
554
      },
555
      runtime: %{
556
        adapter: "opencode",
557
        executable: input.executable,
558
        model: input.model,
559
        diagnostic_logs: input.diagnostic_logs,
560
        config_seeded: not is_nil(input.config_seed),
561
        permission_profile: Atom.to_string(input.permissions),
562
        permission_digest: digest(Jason.encode!(permissions)),
563
        config_digest: digest(Jason.encode!(config))
564
      },
565
      request: %{
566
        prompt_bytes: input.prompt_bytes,
567
        timeout_ms: input.timeout_ms,
568
        maximum_output_bytes: input.maximum_output_bytes
569
      },
570
      events: OpenCodeEvents.summary(execution.events),
571
      resources: %{
572
        wall_time_ms: duration_ms,
573
        sample_count: execution.sample_count,
574
        sample_error_count: execution.sample_error_count,
575
        peak_rss_bytes: execution.peak_rss_bytes,
576
        maximum_cpu_percent: execution.maximum_cpu_percent,
577
        observed_output_bytes: execution.observed_output_bytes,
578
        captured_output_bytes: execution.captured_output_bytes,
579
        output_truncated: execution.output_truncated?
580
      },
581
      artifacts: %{
582
        events_path: paths.events,
583
        events_bytes: byte_size(events_bytes),
584
        events_digest: digest(events_bytes)
585
      }
586
    }
587
  end
588
589
  defp write_summary(path, result) do
590
    temporary = path <> ".tmp-" <> Integer.to_string(System.unique_integer([:positive]))
591
    bytes = Jason.encode!(result, pretty: true)
592
593
    with :ok <- File.write(temporary, bytes, [:binary, :exclusive]),
594
         :ok <- File.chmod(temporary, 0o600),
595
         :ok <- File.rename(temporary, path) do
596
      :ok
597
    else
598
      {:error, reason} ->
599
        File.rm(temporary)
600
        {:error, {:summary_write_failed, reason}}
601
    end
602
  end
603
604
  defp write_prompt(path, prompt) do
605
    with :ok <- File.write(path, prompt, [:binary, :exclusive]),
606
         :ok <- File.chmod(path, 0o600) do
607
      :ok
608
    else
609
      {:error, reason} -> {:error, {:prompt_write_failed, reason}}
610
    end
611
  end
612
613
  defp command_arguments(input) do
614
    arguments = [
615
      "run",
616
      "--pure",
617
      "--format",
618
      "json",
619
      "--model",
620
      input.model,
621
      "--dir",
622
      input.repository
623
    ]
624
625
    if input.diagnostic_logs do
626
      ["--print-logs", "--log-level", "DEBUG" | arguments]
627
    else
628
      arguments
629
    end
630
  end
631
632
  defp command_environment(input, paths, config, permissions) do
633
    safe = %{
634
      "CI" => "1",
635
      "GIT_CONFIG_NOSYSTEM" => "1",
636
      "GIT_TERMINAL_PROMPT" => "0",
637
      "HOME" => paths.home,
638
      "LANG" => "C.UTF-8",
639
      "LC_ALL" => "C.UTF-8",
640
      "LOGNAME" => "scv",
641
      "NO_COLOR" => "1",
642
      "OPENAI_API_KEY" => input.api_key,
643
      "OPENCODE_CLIENT" => "scv",
644
      "OPENCODE_CONFIG_CONTENT" => Jason.encode!(config),
645
      "OPENCODE_CONFIG_DIR" => paths.config,
646
      "OPENCODE_DB" => paths.database,
647
      "OPENCODE_DISABLE_AUTOUPDATE" => "1",
648
      "OPENCODE_DISABLE_CLAUDE_CODE" => "1",
649
      "OPENCODE_DISABLE_DEFAULT_PLUGINS" => "1",
650
      "OPENCODE_DISABLE_EMBEDDED_WEB_UI" => "1",
651
      "OPENCODE_DISABLE_EXTERNAL_SKILLS" => "1",
652
      "OPENCODE_DISABLE_LSP_DOWNLOAD" => "1",
653
      "OPENCODE_DISABLE_MODELS_FETCH" => "1",
654
      "OPENCODE_DISABLE_PROJECT_CONFIG" => "1",
655
      "OPENCODE_DISABLE_SHARE" => "1",
656
      "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER" => "1",
657
      "OPENCODE_PERMISSION" => Jason.encode!(permissions),
658
      "OPENCODE_PURE" => "1",
659
      "PATH" => System.get_env("PATH", "/usr/local/bin:/usr/bin:/bin"),
660
      "PWD" => input.repository,
661
      "SCV_PROMPT_FILE" => paths.prompt,
662
      "SHELL" => "/bin/sh",
663
      "TERM" => "dumb",
664
      "TMPDIR" => paths.tmp,
665
      "USER" => "scv",
666
      "XDG_CACHE_HOME" => paths.cache,
667
      "XDG_CONFIG_HOME" => paths.config_root,
668
      "XDG_DATA_HOME" => paths.data,
669
      "XDG_STATE_HOME" => paths.state
670
    }
671
672
    System.get_env()
673
    |> Map.new(fn {key, _value} -> {key, false} end)
674
    |> Map.merge(safe)
675
  end
676
677
  defp port_environment(environment) do
678
    Enum.map(environment, fn
679
      {key, false} -> {String.to_charlist(key), false}
680
      {key, value} -> {String.to_charlist(key), String.to_charlist(value)}
681
    end)
682
  end
683
684
  defp permission_map(:read_only) do
685
    %{
686
      "bash" => "deny",
687
      "edit" => "deny",
688
      "external_directory" => "deny",
689
      "glob" => "allow",
690
      "grep" => "allow",
691
      "list" => "allow",
692
      "lsp" => "deny",
693
      "question" => "deny",
694
      "read" => "allow",
695
      "skill" => "deny",
696
      "task" => "deny",
697
      "webfetch" => "deny",
698
      "websearch" => "deny"
699
    }
700
  end
701
702
  defp permission_map(:workspace_write) do
703
    permission_map(:read_only)
704
    |> Map.put("edit", "allow")
705
  end
706
707
  defp open_code_config do
708
    %{
709
      "$schema" => "https://opencode.ai/config.json",
710
      "autoupdate" => false,
711
      "share" => "disabled",
712
      "username" => "scv"
713
    }
714
  end
715
716
  defp create_directories(directories) do
717
    Enum.reduce_while(directories, :ok, fn directory, :ok ->
718
      case File.mkdir_p(directory) do
719
        :ok ->
720
          case File.chmod(directory, 0o700) do
721
            :ok -> {:cont, :ok}
722
            {:error, reason} -> {:halt, {:error, {:directory_chmod_failed, reason}}}
723
          end
724
725
        {:error, reason} ->
726
          {:halt, {:error, {:directory_create_failed, reason}}}
727
      end
728
    end)
729
  end
730
731
  defp seed_config(_destination, nil), do: :ok
732
733
  defp seed_config(destination, source) do
734
    entries = ["node_modules", "package.json", "bun.lock", "bun.lockb", "package-lock.json"]
735
736
    Enum.reduce_while(entries, :ok, fn entry, :ok ->
737
      source_path = Path.join(source, entry)
738
      destination_path = Path.join(destination, entry)
739
740
      if File.exists?(source_path) do
741
        case File.cp_r(source_path, destination_path) do
742
          {:ok, _copied} -> {:cont, :ok}
743
          {:error, _path, reason} -> {:halt, {:error, {:config_seed_failed, reason}}}
744
        end
745
      else
746
        {:cont, :ok}
747
      end
748
    end)
749
  end
750
751
  defp validate_directory(repository) when is_binary(repository) do
752
    expanded = Path.expand(repository)
753
754
    cond do
755
      Path.type(repository) != :absolute -> {:error, :repository_not_absolute}
756
      not File.dir?(expanded) -> {:error, :repository_not_found}
757
      true -> {:ok, expanded}
758
    end
759
  end
760
761
  defp validate_directory(_repository), do: {:error, :repository_invalid}
762
763
  defp validate_prompt(prompt)
764
       when is_binary(prompt) and byte_size(prompt) in 1..@maximum_prompt_bytes do
765
    if String.trim(prompt) == "", do: {:error, :prompt_empty}, else: :ok
766
  end
767
768
  defp validate_prompt(_prompt), do: {:error, :prompt_invalid}
769
770
  defp validate_model(model)
771
       when is_binary(model) and byte_size(model) in 1..@maximum_model_bytes do
772
    if Regex.match?(@model_pattern, model), do: :ok, else: {:error, :model_invalid}
773
  end
774
775
  defp validate_model(_model), do: {:error, :model_invalid}
776
777
  defp validate_integer(value, minimum, maximum, _error)
778
       when is_integer(value) and value >= minimum and value <= maximum,
779
       do: :ok
780
781
  defp validate_integer(_value, _minimum, _maximum, error), do: {:error, error}
782
783
  defp validate_boolean(value, _error) when is_boolean(value), do: :ok
784
  defp validate_boolean(_value, error), do: {:error, error}
785
786
  defp validate_event_sink(event_sink) when is_function(event_sink, 1), do: :ok
787
  defp validate_event_sink(_event_sink), do: {:error, :event_sink_invalid}
788
789
  defp validate_permissions(permissions) when permissions in [:read_only, :workspace_write],
790
    do: :ok
791
792
  defp validate_permissions(_permissions), do: {:error, :permissions_invalid}
793
794
  defp validate_run_id(run_id) when is_binary(run_id) do
795
    case Ecto.UUID.cast(run_id) do
796
      {:ok, ^run_id} -> :ok
797
      _invalid -> {:error, :run_id_invalid}
798
    end
799
  end
800
801
  defp validate_run_id(_run_id), do: {:error, :run_id_invalid}
802
803
  defp validate_output_root(output_root) when is_binary(output_root) do
804
    if Path.type(output_root) == :absolute,
805
      do: {:ok, Path.expand(output_root)},
806
      else: {:error, :output_root_not_absolute}
807
  end
808
809
  defp validate_output_root(_output_root), do: {:error, :output_root_invalid}
810
811
  defp validate_api_key(api_key) when is_binary(api_key) and byte_size(api_key) in 8..16_384,
812
    do: :ok
813
814
  defp validate_api_key(_api_key), do: {:error, :openai_api_key_missing}
815
816
  defp validate_config_seed(nil), do: {:ok, nil}
817
818
  defp validate_config_seed(config_seed) when is_binary(config_seed) do
819
    expanded = Path.expand(config_seed)
820
821
    cond do
822
      Path.type(config_seed) != :absolute ->
823
        {:error, :config_seed_not_absolute}
824
825
      not File.dir?(expanded) ->
826
        {:error, :config_seed_not_found}
827
828
      not File.dir?(Path.join(expanded, "node_modules")) ->
829
        {:error, :config_seed_dependencies_missing}
830
831
      true ->
832
        {:ok, expanded}
833
    end
834
  end
835
836
  defp validate_config_seed(_config_seed), do: {:error, :config_seed_invalid}
837
838
  defp validate_executable(executable) when is_binary(executable) do
839
    expanded = Path.expand(executable)
840
841
    with true <- Path.type(executable) == :absolute or {:error, :executable_not_absolute},
842
         {:ok, %{type: :regular, mode: mode}} <- File.stat(expanded),
843
         true <- Bitwise.band(mode, 0o111) != 0 or {:error, :executable_not_executable} do
844
      {:ok, expanded}
845
    else
846
      {:error, _reason} = error -> error
847
      _invalid -> {:error, :executable_invalid}
848
    end
849
  end
850
851
  defp validate_executable(_executable), do: {:error, :executable_missing}
852
853
  defp default_executable do
854
    System.get_env("OPENCODE_BIN") || System.find_executable("opencode")
855
  end
856
857
  defp default_output_root do
858
    Path.expand("_build/scv/runs", File.cwd!())
859
  end
860
861
  defp emit_event(input, type, data) do
862
    event =
863
      Map.merge(
864
        %{
865
          schema: "openagents.scv.event.v1",
866
          run_id: input.run_id,
867
          type: type,
868
          emitted_at: DateTime.utc_now() |> DateTime.to_iso8601()
869
        },
870
        data
871
      )
872
873
    try do
874
      input.event_sink.(event)
875
    rescue
876
      _error -> :ok
877
    catch
878
      _kind, _reason -> :ok
879
    end
880
881
    try do
882
      :telemetry.execute([:openagents, :scv, :event], %{count: 1}, event)
883
    rescue
884
      _error -> :ok
885
    end
886
887
    :ok
888
  end
889
890
  defp bounded_value(value, maximum_bytes, _fallback)
891
       when is_binary(value) and byte_size(value) >= 1 and byte_size(value) <= maximum_bytes,
892
       do: value
893
894
  defp bounded_value(_value, _maximum_bytes, fallback), do: fallback
895
896
  defp byte_count(value) when is_binary(value), do: byte_size(value)
897
  defp byte_count(_value), do: 0
898
899
  defp redact(binary, redactions) do
900
    Enum.reduce(redactions, binary, fn
901
      value, output when is_binary(value) and value != "" ->
902
        :binary.replace(output, value, "[REDACTED]", [:global])
903
904
      _value, output ->
905
        output
906
    end)
907
  end
908
909
  defp git_sha(repository) do
910
    case System.find_executable("git") do
911
      nil ->
912
        nil
913
914
      executable ->
915
        case System.cmd(executable, ["-C", repository, "rev-parse", "HEAD"],
916
               stderr_to_stdout: true
917
             ) do
918
          {sha, 0} -> String.trim(sha)
919
          _error -> nil
920
        end
921
    end
922
  end
923
924
  defp digest(bytes), do: "sha256:" <> Base.encode16(:crypto.hash(:sha256, bytes), case: :lower)
925
  defp monotonic_ms, do: System.monotonic_time(:millisecond)
926
end
lib/openagents/scv/open_code_events.ex added +164

@@ -0,0 +1,164 @@

1
defmodule OpenAgents.SCV.OpenCodeEvents do
2
  @moduledoc """
3
  Aggregates content-free usage data from OpenCode JSON event lines.
4
5
  The raw event artifact remains the source for operator diagnosis. This module
6
  retains only bounded event names, identifiers, counters, token totals, costs,
7
  and tool outcomes in the run summary.
8
  """
9
10
  @maximum_event_type_bytes 64
11
  @maximum_session_id_bytes 128
12
  @maximum_tool_name_bytes 128
13
  @maximum_status_bytes 32
14
15
  @type t :: %{
16
          event_count: non_neg_integer(),
17
          diagnostic_line_count: non_neg_integer(),
18
          invalid_event_count: non_neg_integer(),
19
          event_types: %{optional(String.t()) => pos_integer()},
20
          session_ids: MapSet.t(String.t()),
21
          text_event_count: non_neg_integer(),
22
          error_event_count: non_neg_integer(),
23
          tool_calls: %{optional(String.t()) => pos_integer()},
24
          tool_outcomes: %{optional(String.t()) => pos_integer()},
25
          usage: %{
26
            input_tokens: number(),
27
            output_tokens: number(),
28
            reasoning_tokens: number(),
29
            cache_read_tokens: number(),
30
            cache_write_tokens: number(),
31
            cost_usd: number()
32
          }
33
        }
34
35
  @spec new() :: t()
36
  def new do
37
    %{
38
      event_count: 0,
39
      diagnostic_line_count: 0,
40
      invalid_event_count: 0,
41
      event_types: %{},
42
      session_ids: MapSet.new(),
43
      text_event_count: 0,
44
      error_event_count: 0,
45
      tool_calls: %{},
46
      tool_outcomes: %{},
47
      usage: %{
48
        input_tokens: 0,
49
        output_tokens: 0,
50
        reasoning_tokens: 0,
51
        cache_read_tokens: 0,
52
        cache_write_tokens: 0,
53
        cost_usd: 0
54
      }
55
    }
56
  end
57
58
  @spec ingest(t(), binary()) :: t()
59
  def ingest(state, line) when is_map(state) and is_binary(line) do
60
    case Jason.decode(line) do
61
      {:ok, event} when is_map(event) ->
62
        ingest_event(state, event)
63
64
      _invalid ->
65
        if diagnostic_line?(line) do
66
          Map.update!(state, :diagnostic_line_count, &(&1 + 1))
67
        else
68
          Map.update!(state, :invalid_event_count, &(&1 + 1))
69
        end
70
    end
71
  end
72
73
  @spec summary(t()) :: map()
74
  def summary(state) when is_map(state) do
75
    %{
76
      event_count: state.event_count,
77
      diagnostic_line_count: state.diagnostic_line_count,
78
      invalid_event_count: state.invalid_event_count,
79
      event_types: state.event_types,
80
      session_ids: state.session_ids |> MapSet.to_list() |> Enum.sort(),
81
      text_event_count: state.text_event_count,
82
      error_event_count: state.error_event_count,
83
      tool_calls: state.tool_calls,
84
      tool_outcomes: state.tool_outcomes,
85
      usage: state.usage
86
    }
87
  end
88
89
  defp ingest_event(state, event) do
90
    type = bounded_identifier(event["type"], @maximum_event_type_bytes, "unknown")
91
    session_id = bounded_identifier(event["sessionID"], @maximum_session_id_bytes, nil)
92
93
    state
94
    |> Map.update!(:event_count, &(&1 + 1))
95
    |> update_counter(:event_types, type)
96
    |> maybe_add_session(session_id)
97
    |> maybe_count_text(type)
98
    |> maybe_count_error(type)
99
    |> maybe_count_tool(type, event["part"])
100
    |> maybe_add_usage(type, event["part"])
101
  end
102
103
  defp maybe_add_session(state, nil), do: state
104
105
  defp maybe_add_session(state, session_id) do
106
    Map.update!(state, :session_ids, &MapSet.put(&1, session_id))
107
  end
108
109
  defp maybe_count_text(state, "text"), do: Map.update!(state, :text_event_count, &(&1 + 1))
110
  defp maybe_count_text(state, _type), do: state
111
112
  defp maybe_count_error(state, "error"), do: Map.update!(state, :error_event_count, &(&1 + 1))
113
  defp maybe_count_error(state, _type), do: state
114
115
  defp maybe_count_tool(state, "tool_use", part) when is_map(part) do
116
    tool = bounded_identifier(part["tool"], @maximum_tool_name_bytes, "unknown")
117
118
    status =
119
      bounded_identifier(get_in(part, ["state", "status"]), @maximum_status_bytes, "unknown")
120
121
    state
122
    |> update_counter(:tool_calls, tool)
123
    |> update_counter(:tool_outcomes, tool <> ":" <> status)
124
  end
125
126
  defp maybe_count_tool(state, _type, _part), do: state
127
128
  defp maybe_add_usage(state, "step_finish", part) when is_map(part) do
129
    tokens = if is_map(part["tokens"]), do: part["tokens"], else: %{}
130
    cache = if is_map(tokens["cache"]), do: tokens["cache"], else: %{}
131
132
    additions = %{
133
      input_tokens: non_negative_number(tokens["input"]),
134
      output_tokens: non_negative_number(tokens["output"]),
135
      reasoning_tokens: non_negative_number(tokens["reasoning"]),
136
      cache_read_tokens: non_negative_number(cache["read"]),
137
      cache_write_tokens: non_negative_number(cache["write"]),
138
      cost_usd: non_negative_number(part["cost"])
139
    }
140
141
    Map.update!(state, :usage, fn usage ->
142
      Map.new(usage, fn {key, value} -> {key, value + Map.fetch!(additions, key)} end)
143
    end)
144
  end
145
146
  defp maybe_add_usage(state, _type, _part), do: state
147
148
  defp update_counter(state, key, value) do
149
    Map.update!(state, key, &Map.update(&1, value, 1, fn count -> count + 1 end))
150
  end
151
152
  defp bounded_identifier(value, maximum_bytes, _fallback)
153
       when is_binary(value) and byte_size(value) >= 1 and byte_size(value) <= maximum_bytes,
154
       do: value
155
156
  defp bounded_identifier(_value, _maximum_bytes, fallback), do: fallback
157
158
  defp non_negative_number(value) when is_number(value) and value >= 0, do: value
159
  defp non_negative_number(_value), do: 0
160
161
  defp diagnostic_line?(line) do
162
    String.starts_with?(line, "timestamp=") and String.contains?(line, " level=")
163
  end
164
end
lib/openagents/scv/resource_sampler.ex added +33

@@ -0,0 +1,33 @@

1
defmodule OpenAgents.SCV.ResourceSampler do
2
  @moduledoc """
3
  Reads bounded process measurements from the worker host.
4
5
  The initial local implementation samples the admitted OpenCode process with
6
  `ps`. Container workers replace this adapter with cgroup measurements while
7
  preserving the result shape.
8
  """
9
10
  @maximum_ps_output_bytes 1_024
11
12
  @spec sample(pos_integer()) :: {:ok, map()} | {:error, atom()}
13
  def sample(os_pid) when is_integer(os_pid) and os_pid > 0 do
14
    with executable when is_binary(executable) <- System.find_executable("ps"),
15
         {output, 0} <-
16
           System.cmd(executable, ["-o", "rss=", "-o", "%cpu=", "-p", Integer.to_string(os_pid)],
17
             stderr_to_stdout: true
18
           ),
19
         true <- byte_size(output) <= @maximum_ps_output_bytes,
20
         [rss, cpu] <- String.split(output, ~r/\s+/, trim: true),
21
         {rss_kib, ""} <- Integer.parse(rss),
22
         {cpu_percent, ""} <- Float.parse(cpu) do
23
      {:ok, %{rss_bytes: rss_kib * 1_024, cpu_percent: max(cpu_percent, 0.0)}}
24
    else
25
      nil -> {:error, :ps_missing}
26
      {_output, _status} -> {:error, :sample_failed}
27
      false -> {:error, :sample_too_large}
28
      _invalid -> {:error, :sample_invalid}
29
    end
30
  end
31
32
  def sample(_os_pid), do: {:error, :invalid_pid}
33
end
ops/scv/images/build-opencode-core.sh added +20

@@ -0,0 +1,20 @@

1
#!/usr/bin/env bash
2
set -euo pipefail
3
4
repo_root=$(git rev-parse --show-toplevel)
5
source "${repo_root}/ops/scv/images/versions.env"
6
7
image=${SCV_IMAGE:-openagents/scv-opencode-core:local}
8
9
docker build \
10
  --file "${repo_root}/ops/scv/images/opencode-core/Dockerfile" \
11
  --tag "${image}" \
12
  --build-arg "UBUNTU_IMAGE=${SCV_UBUNTU_IMAGE}" \
13
  --build-arg "NODE_VERSION=${SCV_NODE_VERSION}" \
14
  --build-arg "BUN_VERSION=${SCV_BUN_VERSION}" \
15
  --build-arg "OPENCODE_VERSION=${SCV_OPENCODE_VERSION}" \
16
  --label "com.openagents.scv.image=opencode-core" \
17
  --label "com.openagents.scv.opencode.version=${SCV_OPENCODE_VERSION}" \
18
  "${repo_root}"
19
20
docker image inspect "${image}" --format '{{json .RepoDigests}} {{.Id}}'
ops/scv/images/opencode-core/Dockerfile added +99

@@ -0,0 +1,99 @@

1
ARG UBUNTU_IMAGE=ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517
2
FROM ${UBUNTU_IMAGE}
3
4
ARG TARGETARCH
5
ARG DEBIAN_FRONTEND=noninteractive
6
ARG NODE_VERSION=24.15.0
7
ARG BUN_VERSION=1.3.14
8
ARG OPENCODE_VERSION=1.18.5
9
10
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
11
12
RUN apt-get update \
13
  && apt-get install -y --no-install-recommends \
14
    build-essential \
15
    ca-certificates \
16
    curl \
17
    git \
18
    jq \
19
    openssh-client \
20
    pkg-config \
21
    procps \
22
    python3 \
23
    python3-setuptools \
24
    ripgrep \
25
    tini \
26
    unzip \
27
    xz-utils \
28
    zip \
29
  && rm -rf /var/lib/apt/lists/*
30
31
RUN set -euo pipefail; \
32
  case "${TARGETARCH}" in \
33
    amd64) node_arch=x64; checksum=472655581fb851559730c48763e0c9d3bc25975c59d518003fc0849d3e4ba0f6 ;; \
34
    arm64) node_arch=arm64; checksum=f3d5a797b5d210ce8e2cb265544c8e482eaedcb8aa409a8b46da7e8595d0dda0 ;; \
35
    *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
36
  esac; \
37
  archive="node-v${NODE_VERSION}-linux-${node_arch}.tar.xz"; \
38
  curl -fsSL --retry 3 -o "/tmp/${archive}" "https://nodejs.org/dist/v${NODE_VERSION}/${archive}"; \
39
  echo "${checksum}  /tmp/${archive}" | sha256sum --check --strict; \
40
  tar -xJf "/tmp/${archive}" -C /usr/local --strip-components=1; \
41
  rm "/tmp/${archive}"; \
42
  corepack enable; \
43
  node --version; \
44
  npm --version
45
46
RUN set -euo pipefail; \
47
  case "${TARGETARCH}" in \
48
    amd64) bun_arch=x64; checksum=951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f ;; \
49
    arm64) bun_arch=aarch64; checksum=a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b ;; \
50
    *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
51
  esac; \
52
  archive="bun-linux-${bun_arch}.zip"; \
53
  curl -fsSL --retry 3 -o "/tmp/${archive}" \
54
    "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/${archive}"; \
55
  echo "${checksum}  /tmp/${archive}" | sha256sum --check --strict; \
56
  unzip -q "/tmp/${archive}" -d /tmp/bun; \
57
  install -D -m 0755 "/tmp/bun/bun-linux-${bun_arch}/bun" /opt/bun/bin/bun; \
58
  ln -s /opt/bun/bin/bun /opt/bun/bin/bunx; \
59
  rm -rf "/tmp/${archive}" /tmp/bun; \
60
  /opt/bun/bin/bun --version
61
62
RUN set -euo pipefail; \
63
  case "${TARGETARCH}" in \
64
    amd64) opencode_arch=x64; checksum=cd4a2557a3d6550f27cb5c0257ebe8d73388bb34beda8b6121e6428a74c1eae2 ;; \
65
    arm64) opencode_arch=arm64; checksum=18b643362fdf0b8d5b8711b3e160dafb4e68d0bfc00288f56fd1298fd72da69d ;; \
66
    *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
67
  esac; \
68
  archive="opencode-linux-${opencode_arch}.tar.gz"; \
69
  curl -fsSL --retry 3 -o "/tmp/${archive}" \
70
    "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/${archive}"; \
71
  echo "${checksum}  /tmp/${archive}" | sha256sum --check --strict; \
72
  tar -xzf "/tmp/${archive}" -C /tmp; \
73
  install -D -m 0755 /tmp/opencode /opt/scv/opencode/bin/opencode; \
74
  rm "/tmp/${archive}" /tmp/opencode; \
75
  /opt/scv/opencode/bin/opencode --version
76
77
RUN groupadd --gid 10001 scv \
78
  && useradd --uid 10001 --gid 10001 --create-home --home-dir /home/scv --shell /bin/bash scv \
79
  && install -d -o scv -g scv -m 0700 \
80
    /workspace \
81
    /home/scv/.cache \
82
    /home/scv/.config \
83
    /home/scv/.local/share \
84
    /home/scv/.local/state \
85
  && chown -R scv:scv /home/scv
86
87
ENV BUN_INSTALL=/opt/bun
88
ENV HOME=/home/scv
89
ENV PATH=/opt/scv/opencode/bin:/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
90
ENV XDG_CACHE_HOME=/home/scv/.cache
91
ENV XDG_CONFIG_HOME=/home/scv/.config
92
ENV XDG_DATA_HOME=/home/scv/.local/share
93
ENV XDG_STATE_HOME=/home/scv/.local/state
94
95
USER scv
96
WORKDIR /workspace
97
98
ENTRYPOINT ["/usr/bin/tini", "--"]
99
CMD ["opencode", "--version"]
ops/scv/images/versions.env added +4

@@ -0,0 +1,4 @@

1
SCV_UBUNTU_IMAGE=ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517
2
SCV_NODE_VERSION=24.15.0
3
SCV_BUN_VERSION=1.3.14
4
SCV_OPENCODE_VERSION=1.18.5
test/openagents/scv/open_code_events_test.exs added +87

@@ -0,0 +1,87 @@

1
defmodule OpenAgents.SCV.OpenCodeEventsTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.SCV.OpenCodeEvents
5
6
  test "aggregates bounded usage and tool outcomes without retaining text" do
7
    events =
8
      OpenCodeEvents.new()
9
      |> OpenCodeEvents.ingest(
10
        Jason.encode!(%{
11
          "type" => "tool_use",
12
          "sessionID" => "ses_test",
13
          "part" => %{
14
            "tool" => "read",
15
            "state" => %{"status" => "completed", "output" => "private source"}
16
          }
17
        })
18
      )
19
      |> OpenCodeEvents.ingest(
20
        Jason.encode!(%{
21
          "type" => "step_finish",
22
          "sessionID" => "ses_test",
23
          "part" => %{
24
            "cost" => 0.0125,
25
            "tokens" => %{
26
              "input" => 11,
27
              "output" => 7,
28
              "reasoning" => 3,
29
              "cache" => %{"read" => 5, "write" => 2}
30
            }
31
          }
32
        })
33
      )
34
      |> OpenCodeEvents.ingest(
35
        Jason.encode!(%{
36
          "type" => "text",
37
          "sessionID" => "ses_test",
38
          "part" => %{"text" => "private response"}
39
        })
40
      )
41
      |> OpenCodeEvents.ingest(
42
        "timestamp=2026-08-20T13:00:00.000Z level=INFO message=bootstrapping"
43
      )
44
      |> OpenCodeEvents.ingest("not-json")
45
      |> OpenCodeEvents.summary()
46
47
    assert events.event_count == 3
48
    assert events.diagnostic_line_count == 1
49
    assert events.invalid_event_count == 1
50
    assert events.event_types == %{"step_finish" => 1, "text" => 1, "tool_use" => 1}
51
    assert events.session_ids == ["ses_test"]
52
    assert events.text_event_count == 1
53
    assert events.tool_calls == %{"read" => 1}
54
    assert events.tool_outcomes == %{"read:completed" => 1}
55
56
    assert events.usage == %{
57
             input_tokens: 11,
58
             output_tokens: 7,
59
             reasoning_tokens: 3,
60
             cache_read_tokens: 5,
61
             cache_write_tokens: 2,
62
             cost_usd: 0.0125
63
           }
64
65
    refute inspect(events) =~ "private source"
66
    refute inspect(events) =~ "private response"
67
  end
68
69
  test "bounds event-controlled identifiers" do
70
    oversized = String.duplicate("x", 256)
71
72
    events =
73
      OpenCodeEvents.new()
74
      |> OpenCodeEvents.ingest(
75
        Jason.encode!(%{
76
          "type" => oversized,
77
          "sessionID" => oversized,
78
          "part" => %{"tool" => oversized, "state" => %{"status" => oversized}}
79
        })
80
      )
81
      |> OpenCodeEvents.summary()
82
83
    assert events.event_types == %{"unknown" => 1}
84
    assert events.session_ids == []
85
    assert events.tool_calls == %{}
86
  end
87
end
test/openagents/scv/open_code_executor_test.exs added +196

@@ -0,0 +1,196 @@

1
defmodule OpenAgents.SCV.OpenCodeExecutorTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.SCV.Executor.OpenCode
5
6
  setup do
7
    root = Path.join(System.tmp_dir!(), "scv-opencode-#{System.unique_integer([:positive])}")
8
    repository = Path.join(root, "repository")
9
    output = Path.join(root, "runs")
10
    executable = Path.join(root, "fake-opencode")
11
    File.mkdir_p!(repository)
12
    File.write!(Path.join(repository, "README.md"), "fixture repository")
13
    File.write!(executable, fake_executable())
14
    File.chmod!(executable, 0o700)
15
16
    previous_probe = System.get_env("SCV_PRIVATE_PROBE")
17
    System.put_env("SCV_PRIVATE_PROBE", "must-not-leak")
18
19
    on_exit(fn ->
20
      restore_env("SCV_PRIVATE_PROBE", previous_probe)
21
      File.rm_rf(root)
22
    end)
23
24
    %{executable: executable, output: output, repository: repository}
25
  end
26
27
  test "runs OpenCode with isolated state and writes redacted evidence", context do
28
    test_pid = self()
29
30
    sample_fun = fn os_pid ->
31
      assert is_integer(os_pid) and os_pid > 0
32
      {:ok, %{rss_bytes: 12_345, cpu_percent: 4.5}}
33
    end
34
35
    assert {:ok, result} =
36
             OpenCode.run(
37
               context.repository,
38
               "READ_ONLY",
39
               shared_options(context) ++
40
                 [
41
                   sample_interval_ms: 10,
42
                   sample_fun: sample_fun,
43
                   event_sink: fn event -> send(test_pid, {:scv_event, event}) end
44
                 ]
45
             )
46
47
    assert result.status == "succeeded"
48
    assert result.exit_status == 0
49
    assert result.runtime.permission_profile == "read_only"
50
    assert result.events.event_count == 4
51
    assert result.events.diagnostic_line_count == 0
52
    assert result.events.invalid_event_count == 0
53
    assert result.events.session_ids == ["ses_fixture"]
54
    assert result.events.tool_calls == %{"read" => 1}
55
    assert result.events.tool_outcomes == %{"read:completed" => 1}
56
    assert result.events.text_event_count == 1
57
58
    assert result.events.usage == %{
59
             input_tokens: 13,
60
             output_tokens: 8,
61
             reasoning_tokens: 2,
62
             cache_read_tokens: 3,
63
             cache_write_tokens: 1,
64
             cost_usd: 0.00125
65
           }
66
67
    assert result.resources.sample_count > 0
68
    assert result.resources.peak_rss_bytes == 12_345
69
    assert result.resources.maximum_cpu_percent == 4.5
70
    assert File.exists?(result.artifacts.events_path)
71
    assert File.exists?(result.summary_path)
72
    refute File.read!(result.artifacts.events_path) =~ "fixture-secret-key"
73
    assert File.read!(result.artifacts.events_path) =~ "[REDACTED]"
74
    refute File.read!(result.summary_path) =~ "READ_ONLY"
75
    refute File.exists?(Path.join([Path.dirname(result.summary_path), "scratch"]))
76
77
    assert {:ok, %{mode: event_mode}} = File.stat(result.artifacts.events_path)
78
    assert {:ok, %{mode: summary_mode}} = File.stat(result.summary_path)
79
    assert Bitwise.band(event_mode, 0o777) == 0o600
80
    assert Bitwise.band(summary_mode, 0o777) == 0o600
81
82
    assert_receive {:scv_event, %{type: "run_preparing"}}
83
    assert_receive {:scv_event, %{type: "process_starting"}}
84
    assert_receive {:scv_event, %{type: "process_started", os_pid: os_pid}}
85
    assert is_integer(os_pid) and os_pid > 0
86
87
    for event_type <- ["step_start", "tool_use", "step_finish", "text"] do
88
      assert_receive {:scv_event,
89
                      %{
90
                        type: "opencode_event",
91
                        event_type: ^event_type,
92
                        session_id: "ses_fixture"
93
                      }}
94
    end
95
96
    assert_receive {:scv_event, %{type: "process_finished", status: "succeeded"}}
97
    assert_receive {:scv_event, %{type: "run_finished", status: "succeeded"}}
98
  end
99
100
  test "fails closed at the wall-clock limit", context do
101
    assert {:ok, result} =
102
             OpenCode.run(
103
               context.repository,
104
               "TIMEOUT",
105
               context
106
               |> shared_options()
107
               |> Keyword.put(:timeout_ms, 500)
108
               |> Keyword.put(:sample_interval_ms, 10)
109
             )
110
111
    assert result.status == "timeout"
112
    assert result.error_code == "command_timeout"
113
    assert result.exit_status == nil
114
    assert result.duration_ms < 1_500
115
116
    timeout_pid = context.repository |> Path.join("timeout.pid") |> File.read!() |> String.trim()
117
    {_output, status} = System.cmd("kill", ["-0", timeout_pid], stderr_to_stdout: true)
118
    assert status != 0
119
  end
120
121
  test "stops capture at the output bound", context do
122
    assert {:ok, result} =
123
             OpenCode.run(
124
               context.repository,
125
               "OUTPUT_LIMIT",
126
               shared_options(context) ++ [maximum_output_bytes: 64]
127
             )
128
129
    assert result.status == "output_limit"
130
    assert result.error_code == "output_limit"
131
    assert result.resources.captured_output_bytes == 64
132
    assert result.resources.output_truncated
133
    assert File.stat!(result.artifacts.events_path).size <= 64
134
  end
135
136
  test "validates authority and execution bounds before creating a run", context do
137
    assert {:error, :repository_not_absolute} =
138
             OpenCode.run("relative", "prompt", shared_options(context))
139
140
    assert {:error, :permissions_invalid} =
141
             OpenCode.run(
142
               context.repository,
143
               "prompt",
144
               shared_options(context) ++ [permissions: :unbounded]
145
             )
146
147
    assert {:error, :openai_api_key_missing} =
148
             OpenCode.run(
149
               context.repository,
150
               "prompt",
151
               Keyword.put(shared_options(context), :api_key, nil)
152
             )
153
  end
154
155
  defp shared_options(context) do
156
    [
157
      api_key: "fixture-secret-key",
158
      executable: context.executable,
159
      model: "openai/test-model",
160
      output_root: context.output,
161
      timeout_ms: 1_000
162
    ]
163
  end
164
165
  defp fake_executable do
166
    """
167
    #!/bin/sh
168
    if [ "${OPENAI_API_KEY:-}" != "fixture-secret-key" ]; then exit 21; fi
169
    if [ "${OPENCODE_DISABLE_PROJECT_CONFIG:-}" != "1" ]; then exit 22; fi
170
    if [ "${OPENCODE_DISABLE_LSP_DOWNLOAD:-}" != "1" ]; then exit 23; fi
171
    if [ "${OPENCODE_PURE:-}" != "1" ]; then exit 24; fi
172
    if [ "${OPENCODE_DISABLE_SHARE:-}" != "1" ]; then exit 29; fi
173
    if [ "${SCV_PRIVATE_PROBE+x}" = "x" ]; then exit 25; fi
174
    prompt=$(cat)
175
    if [ -z "$prompt" ]; then exit 30; fi
176
    if [ "$1" != "run" ]; then exit 26; fi
177
    case " $* " in *" --auto "*) exit 27;; esac
178
    case " $* " in *" $prompt "*) exit 31;; esac
179
    if [ "${OPENCODE_CONFIG_DIR:-}" != "${XDG_CONFIG_HOME:-}/opencode" ]; then exit 28; fi
180
181
    case "$prompt" in
182
      *TIMEOUT*) echo $$ > "$PWD/timeout.pid"; while :; do :; done;;
183
      *OUTPUT_LIMIT*) printf '%0200d' 0; exit 0;;
184
    esac
185
186
    printf '%s\n' '{"type":"step_start","timestamp":1,"sessionID":"ses_fixture","part":{"type":"step-start"}}'
187
    printf '%s\n' '{"type":"tool_use","timestamp":2,"sessionID":"ses_fixture","part":{"tool":"read","state":{"status":"completed","output":"fixture-secret-key"}}}'
188
    printf '%s\n' '{"type":"step_finish","timestamp":3,"sessionID":"ses_fixture","part":{"type":"step-finish","cost":0.00125,"tokens":{"input":13,"output":8,"reasoning":2,"cache":{"read":3,"write":1}}}}'
189
    printf '%s\n' '{"type":"text","timestamp":4,"sessionID":"ses_fixture","part":{"type":"text","text":"done"}}'
190
    sleep 0.05
191
    """
192
  end
193
194
  defp restore_env(key, nil), do: System.delete_env(key)
195
  defp restore_env(key, value), do: System.put_env(key, value)
196
end

This page updates live while a promote is in flight · changelog