Add runnable SCV worker boundary

d247a8a3821c · AtlantisPleb · · parent fa9cad5a96fb

Add runnable SCV worker boundary

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 config/config.exs
  • modified config/runtime.exs
  • modified docs/architecture.md
  • added docs/operations/scv-staging-qualification.md
  • modified docs/runtime-configuration.md
  • modified docs/scv-planning.md
  • modified lib/mix/tasks/openagents.scv.opencode.ex
  • modified lib/openagents/application.ex
  • added lib/openagents/scv.ex
  • added lib/openagents/scv/driver.ex
  • added lib/openagents/scv/driver/open_code.ex
  • added lib/openagents/scv/environment.ex
  • modified lib/openagents/scv/executor/open_code.ex
  • added lib/openagents/scv/run.ex
  • added lib/openagents/scv/runner.ex
  • added lib/openagents/scv/runner/local.ex
  • added lib/openagents/scv/worker.ex
  • modified ops/scv/images/build-opencode-core.sh
  • modified ops/scv/images/opencode-core/Dockerfile
  • modified ops/scv/images/versions.env
  • added test/openagents/scv/run_test.exs

Diff

21 files changed, +1131 -112

config/config.exs modified +1

@@ -32,6 +32,7 @@ config :openagents,

32 32
  ecto_repos: [OpenAgents.Repo],
33 33
  generators: [timestamp_type: :utc_datetime],
34 34
  runtime_environment: :development,
35
  runtime_role: :web,
35 36
  staging_gate: 0,
36 37
  staging_cleanup_enabled: false,
37 38
  production_deploy_enabled: false,
config/runtime.exs modified +75 -52

@@ -82,11 +82,20 @@ parse_csv = fn name ->

82 82
  |> Enum.reject(&(&1 == ""))
83 83
end
84 84
85
runtime_role =
86
  case System.get_env("OPENAGENTS_RUNTIME_ROLE", "web") do
87
    "web" -> :web
88
    "scv" -> :scv
89
    _invalid -> raise "environment variable OPENAGENTS_RUNTIME_ROLE is not admitted"
90
  end
91
92
config :openagents, :runtime_role, runtime_role
93
85 94
if config_env() == :dev do
86 95
  config :openagents, :openai_api_key, optional_text.("OPENAI_API_KEY")
87 96
end
88 97
89
if config_env() == :prod do
98
if config_env() == :prod and runtime_role == :web do
90 99
  runtime_environment =
91 100
    case required_text.("OPENAGENTS_ENVIRONMENT") do
92 101
      "staging" -> :staging

@@ -348,66 +357,80 @@ if config_env() == :prod do

348 357
    secret_key_base: required_text.("SECRET_KEY_BASE")
349 358
end
350 359
351
github_oauth = Application.get_env(:openagents, :github_oauth, [])
360
if config_env() == :prod and runtime_role == :scv do
361
  runtime_environment =
362
    case required_text.("OPENAGENTS_ENVIRONMENT") do
363
      "staging" -> :staging
364
      _invalid -> raise "an SCV worker is admitted only in staging"
365
    end
352 366
353
github_oauth =
354
  github_oauth
355
  |> Keyword.merge(
356
    client_id: System.get_env("GITHUB_CLIENT_ID") || github_oauth[:client_id],
357
    client_secret: System.get_env("GITHUB_CLIENT_SECRET") || github_oauth[:client_secret],
358
    redirect_uri: System.get_env("GITHUB_REDIRECT_URI") || github_oauth[:redirect_uri]
359
  )
360
  |> OpenAgents.GitHubOAuth.RuntimeConfig.load!(config_env(),
361
    public_host: System.get_env("PHX_HOST")
362
  )
367
  config :openagents,
368
    runtime_environment: runtime_environment,
369
    openai_api_key: required_text.("OPENAI_API_KEY")
370
end
363 371
364
config :openagents, :github_oauth, github_oauth
372
if runtime_role == :web do
373
  github_oauth = Application.get_env(:openagents, :github_oauth, [])
365 374
366
token_encryption_key = optional_text.("GITHUB_TOKEN_ENCRYPTION_KEY")
367
token_encryption_key_id = optional_text.("GITHUB_TOKEN_ENCRYPTION_KEY_ID")
375
  github_oauth =
376
    github_oauth
377
    |> Keyword.merge(
378
      client_id: System.get_env("GITHUB_CLIENT_ID") || github_oauth[:client_id],
379
      client_secret: System.get_env("GITHUB_CLIENT_SECRET") || github_oauth[:client_secret],
380
      redirect_uri: System.get_env("GITHUB_REDIRECT_URI") || github_oauth[:redirect_uri]
381
    )
382
    |> OpenAgents.GitHubOAuth.RuntimeConfig.load!(config_env(),
383
      public_host: System.get_env("PHX_HOST")
384
    )
368 385
369
token_decryption_keys =
370
  case optional_text.("GITHUB_TOKEN_DECRYPTION_KEYS_JSON") do
371
    nil ->
372
      %{}
386
  config :openagents, :github_oauth, github_oauth
373 387
374
    encoded ->
375
      case Jason.decode(encoded) do
376
        {:ok, keys} when is_map(keys) ->
377
          keys
388
  token_encryption_key = optional_text.("GITHUB_TOKEN_ENCRYPTION_KEY")
389
  token_encryption_key_id = optional_text.("GITHUB_TOKEN_ENCRYPTION_KEY_ID")
378 390
379
        _invalid ->
380
          raise "environment variable GITHUB_TOKEN_DECRYPTION_KEYS_JSON must be a JSON object"
381
      end
382
  end
391
  token_decryption_keys =
392
    case optional_text.("GITHUB_TOKEN_DECRYPTION_KEYS_JSON") do
393
      nil ->
394
        %{}
383 395
384
valid_token_key? =
385
  is_binary(token_encryption_key) and
386
    match?({:ok, key} when byte_size(key) == 32, Base.decode64(token_encryption_key))
387
388
valid_token_key_id? =
389
  is_binary(token_encryption_key_id) and
390
    String.match?(token_encryption_key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/)
391
392
valid_decryption_keys? =
393
  map_size(token_decryption_keys) <= 16 and
394
    not Map.has_key?(token_decryption_keys, token_encryption_key_id) and
395
    Enum.all?(token_decryption_keys, fn {key_id, encoded_key} ->
396
      is_binary(key_id) and String.match?(key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/) and
397
        is_binary(encoded_key) and
398
        match?({:ok, key} when byte_size(key) == 32, Base.decode64(encoded_key))
399
    end)
396
      encoded ->
397
        case Jason.decode(encoded) do
398
          {:ok, keys} when is_map(keys) ->
399
            keys
400 400
401
if config_env() == :prod and
402
     not (valid_token_key? and valid_token_key_id? and valid_decryption_keys?) do
403
  raise "GitHub token keyring environment variables are invalid"
404
end
401
          _invalid ->
402
            raise "environment variable GITHUB_TOKEN_DECRYPTION_KEYS_JSON must be a JSON object"
403
        end
404
    end
405 405
406
if valid_token_key? and valid_token_key_id? and valid_decryption_keys? do
407
  config :openagents,
408
    github_token_encryption_key: token_encryption_key,
409
    github_token_encryption_key_id: token_encryption_key_id,
410
    github_token_decryption_keys: token_decryption_keys
406
  valid_token_key? =
407
    is_binary(token_encryption_key) and
408
      match?({:ok, key} when byte_size(key) == 32, Base.decode64(token_encryption_key))
409
410
  valid_token_key_id? =
411
    is_binary(token_encryption_key_id) and
412
      String.match?(token_encryption_key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/)
413
414
  valid_decryption_keys? =
415
    map_size(token_decryption_keys) <= 16 and
416
      not Map.has_key?(token_decryption_keys, token_encryption_key_id) and
417
      Enum.all?(token_decryption_keys, fn {key_id, encoded_key} ->
418
        is_binary(key_id) and String.match?(key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/) and
419
          is_binary(encoded_key) and
420
          match?({:ok, key} when byte_size(key) == 32, Base.decode64(encoded_key))
421
      end)
422
423
  if config_env() == :prod and
424
       not (valid_token_key? and valid_token_key_id? and valid_decryption_keys?) do
425
    raise "GitHub token keyring environment variables are invalid"
426
  end
427
428
  if valid_token_key? and valid_token_key_id? and valid_decryption_keys? do
429
    config :openagents,
430
      github_token_encryption_key: token_encryption_key,
431
      github_token_encryption_key_id: token_encryption_key_id,
432
      github_token_decryption_keys: token_decryption_keys
433
  end
411 434
end
412 435
413 436
if parse_optional_boolean.("PHX_SERVER") do
docs/architecture.md modified +25

@@ -160,6 +160,31 @@ Development code reloading is not a production deployment strategy. Keep every

160 160
deployment capability disabled by default until its local proof and isolated
161 161
staging drill pass.
162 162
163
## SCV boundary
164
165
An SCV is the durable coding-execution and supervision contract. It is not a
166
container, model, OpenCode session, or tool catalog. The internal agent runtime
167
deploys SCV runs and each run binds these parts:
168
169
- A driver adapts one coding implementation, such as OpenCode or a native
170
  Elixir tool loop.
171
- An environment supplies a digest-addressed image or owned host with declared
172
  language and system capabilities.
173
- A runner starts and supervises the driver in that environment.
174
- The SCV owns policy, lifecycle, budgets, events, cancellation, artifacts,
175
  receipts, and the handoff to Forge.
176
177
This boundary lets the runtime deploy several SCVs with different drivers and
178
environments. Native coding tools belong to a native driver. OpenCode keeps its
179
own protocol behind the same SCV policy and event boundary; OpenCode does not
180
become the SCV or the environment.
181
182
The first qualification image combines the Elixir SCV worker release with the
183
OpenCode driver and the `opencode-core` polyglot environment. Its staging
184
process role accepts only read-only runs and starts no Phoenix endpoint, Repo,
185
Forge service, or deployment coordinator. Forge remains the only deployment
186
authority.
187
163 188
## Runtime and staging topology
164 189
165 190
The accepted target has two isolated staging lanes:
docs/operations/scv-staging-qualification.md added +74

@@ -0,0 +1,74 @@

1
# Qualify an SCV in staging
2
3
Date: 2026-08-20
4
5
Status: Read-only qualification procedure
6
7
Use this procedure to prove the first complete SCV image in staging. This lane
8
qualifies one OpenCode-driven SCV run. It does not admit repository writes,
9
candidate pushes, Forge promotion, autonomous deployment, or production use.
10
11
## Safety boundary
12
13
The current `staging.openagents.com` service and production resources share the
14
`openagentsgemini` Google Cloud project. This does not satisfy the isolated
15
staging topology required for SCV autonomy. Until an isolated project exists,
16
run this qualification as a separate Cloud Run job with all of these controls:
17
18
- Use a dedicated service account with no project-level roles.
19
- Grant the service account access to only the staging OpenAI secret.
20
- Do not attach a database, VPC connector, GitHub credential, Forge credential,
21
  release cookie, deployment credential, or cloud API credential.
22
- Deploy an immutable image digest from a dedicated immutable staging
23
  repository.
24
- Set `OPENAGENTS_RUNTIME_ROLE=scv`, `OPENAGENTS_ENVIRONMENT=staging`, and
25
  `SCV_PERMISSION_PROFILE=read_only`.
26
- Run one task with no retries and a bounded timeout.
27
28
The image starts only the SCV worker supervision tree. The worker admits the
29
`opencode` driver and `opencode-core` environment, streams versioned JSON events
30
to Cloud Logging, writes one terminal result, and exits.
31
32
## Build and deploy
33
34
1. Commit and push the candidate. Record the exact Git SHA.
35
2. Build `ops/scv/images/opencode-core/Dockerfile` for `linux/amd64` with that
36
   SHA as `SCV_SOURCE_REVISION`.
37
3. Push the image to the dedicated immutable Artifact Registry repository and
38
   resolve its manifest digest.
39
4. Deploy or update the dedicated Cloud Run job by digest. Configure these
40
   nonsecret values:
41
42
   - `SCV_OBJECTIVE` with a bounded read-only inspection request.
43
   - `SCV_MODEL` with the admitted model identifier.
44
   - `SCV_REPOSITORY_REVISION` with the exact committed Git SHA.
45
   - `SCV_TIMEOUT_MS` and `SCV_HEARTBEAT_INTERVAL_MS` with bounded values.
46
   - `SCV_DIAGNOSTIC_LOGS=false`.
47
48
5. Map `OPENAI_API_KEY` from the staging secret at runtime. Do not print or
49
   persist its value.
50
6. Execute one job task and wait for its terminal state.
51
52
## Verify the execution
53
54
The Cloud Logging stream must contain these records for one run ID:
55
56
- `run_preparing`;
57
- `process_started`;
58
- at least one `resource_heartbeat` during a long-enough run;
59
- normalized `opencode_event` records;
60
- `process_finished`;
61
- `run_finished`;
62
- one `openagents.scv.worker.result.v1` terminal result.
63
64
Verify that the result reports the `opencode` driver, `opencode-core`
65
environment, `read_only` permission profile, exact source SHA, successful exit
66
status, bounded duration, resource measurements, event counts, token totals,
67
and no output truncation. Verify the deployed job uses the dedicated service
68
account and contains no prohibited environment or secret references.
69
70
Treat a successful execution as an environment qualification receipt only. Do
71
not call it an isolated-staging pass or enable writes. Before an SCV can write,
72
add the durable coordinator and worker protocol, process-tree or cgroup
73
enforcement, run-scoped inference grants, persistent per-effect barriers,
74
artifact storage, cancellation, and the isolated Forge staging lane.
docs/runtime-configuration.md modified +34

@@ -41,6 +41,40 @@ endpoint starts:

41 41
- Every `forge_hot_load_examples` entry must produce its configured allow or
42 42
  deny result under the actual hot-load allowlist.
43 43
44
## Process roles
45
46
`OPENAGENTS_RUNTIME_ROLE` selects the supervision tree in an assembled release.
47
The setting accepts these values:
48
49
| Value | Behavior |
50
| --- | --- |
51
| `web` | Default. Validates the complete web release configuration and starts the endpoint, Repo, Forge, and enabled application services. |
52
| `scv` | Staging-only qualification role. Requires a provider credential and starts one temporary `OpenAgents.SCV.Worker` task. It starts no endpoint, Repo, Forge service, or deployment coordinator. |
53
54
The current SCV process role admits only the `opencode` driver,
55
`opencode-core` environment, and `read_only` permission profile. Configure one
56
run with these settings:
57
58
| Environment setting | Requirement |
59
| --- | --- |
60
| `SCV_REPOSITORY` | Absolute repository path inside the environment; defaults to `/workspace/openagents` in the first image |
61
| `SCV_OBJECTIVE` | Bounded objective for one run; required |
62
| `SCV_DRIVER` | `opencode` |
63
| `SCV_ENVIRONMENT` | `opencode-core` |
64
| `SCV_PERMISSION_PROFILE` | `read_only` |
65
| `SCV_MODEL` | Admitted OpenCode model identifier |
66
| `SCV_REPOSITORY_REVISION` | Exact 40-character lowercase Git SHA baked into the image |
67
| `SCV_RUN_ID` | Optional externally assigned UUID; the worker generates one when omitted |
68
| `SCV_TIMEOUT_MS` | Wall-clock limit for the OpenCode process |
69
| `SCV_HEARTBEAT_INTERVAL_MS` | Resource and liveness event interval |
70
| `SCV_DIAGNOSTIC_LOGS` | `true` to emit bounded redacted diagnostic records; otherwise `false` |
71
72
The process writes versioned SCV events and one terminal worker result as JSON
73
lines. Do not mount database, GitHub, Forge, release-cookie, deployment, or
74
general cloud credentials into this role. A provider key is a temporary
75
qualification mechanism; replace it with a run-scoped inference grant before
76
admitting repository writes.
77
44 78
## Required release settings
45 79
46 80
All settings in this section are mandatory in a production release unless
docs/scv-planning.md modified +99 -38

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

2 2
3 3
Date: 2026-08-20
4 4
5
Status: Initial local OpenCode qualification adapter implemented; isolated
6
worker scheduling, durable tool effects, and autonomous deployment remain
7
disabled
5
Status: First complete OpenCode SCV environment implemented and locally proven;
6
staging qualification in progress; durable coordination, durable tool effects,
7
and autonomous deployment remain disabled
8 8
9 9
## Outcome
10 10

@@ -43,12 +43,58 @@ 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
46
## SCV runtime boundary
47
48
Define an SCV as the durable execution and supervision contract. Do not define
49
an SCV as a container, OpenCode session, model, or tool catalog. The internal
50
runtime deploys an SCV run. Each run selects one implementation driver and one
51
execution environment.
47 52
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:
53
| Boundary | Responsibility |
54
| --- | --- |
55
| SCV | Owns identity, objective, policy, capabilities, lifecycle, budgets, events, receipts, cancellation, artifacts, and Forge handoff |
56
| Driver | Adapts one coding implementation, such as OpenCode or a native Elixir tool loop, to the SCV contract |
57
| Environment | Supplies one digest-addressed runtime image or owned host with declared language and system capabilities |
58
| Runner | Starts and supervises the selected driver inside the environment |
59
| Tool catalog | Defines the typed effects available to a native driver; an external driver may retain its protocol only when the SCV maps it to the same policy and event boundary |
60
61
An SCV run normally binds one driver to one worker. A durable SCV campaign may
62
coordinate several runs with different drivers or environments. Do not place
63
several independent repository writers inside one worker and call the container
64
an SCV.
65
66
Expose two tool surfaces:
67
68
- The internal runtime uses SCV control tools to start, inspect, cancel, and
69
  collect artifacts from SCV runs.
70
- A native SCV driver uses admitted coding tools for workspace inspection,
71
  edits, commands, tests, and Git operations. OpenCode retains its own protocol
72
  behind the same capability policy until the durable sidecar replaces its
73
  direct effects.
74
75
This split gives every implementation one operational contract without forcing
76
OpenCode, a native Elixir driver, and future coding runtimes to share one model
77
or tool-loop implementation.
51 78
79
## Local implementation checkpoint
80
81
The repository now contains a complete direct-process SCV boundary and the
82
first container environment. This implementation proves driver dispatch and
83
worker execution without enabling a durable coordinator, repository write
84
authority in staging, worker registration, Forge promotion, or deployment:
85
86
- `OpenAgents.SCV.Run` binds one objective to an admitted driver, environment,
87
  permission profile, capability set, and runner.
88
- `OpenAgents.SCV.Driver.OpenCode` adapts OpenCode to the common SCV run and
89
  event contract.
90
- `OpenAgents.SCV.Environment` declares the `opencode-core` capabilities
91
  separately from the driver.
92
- `OpenAgents.SCV.Runner.Local` supervises the driver as a direct process in the
93
  current environment. A container scheduler may place this runner inside a
94
  digest-addressed worker.
95
- `OpenAgents.SCV.Worker` accepts the staging environment contract, admits only
96
  read-only OpenCode runs, streams JSON events, writes one terminal result, and
97
  exits with the run status.
52 98
- `OpenAgents.SCV.Executor.OpenCode` starts one bounded OpenCode process with an
53 99
  isolated home, XDG roots, SQLite database, operator-owned configuration, and
54 100
  explicit permission profile.

@@ -57,8 +103,9 @@ write authority, worker registration, Forge promotion, or deployment:

57 103
- `OpenAgents.SCV.ResourceSampler` observes the direct OpenCode process from the
58 104
  host and records RSS and CPU samples.
59 105
- `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.
106
- `ops/scv/images/opencode-core/Dockerfile` defines the first complete
107
  multi-architecture environment with a pinned Debian runtime, Elixir release,
108
  Node.js, Bun, Python, Git, native build tools, and OpenCode.
62 109
63 110
The executor emits `openagents.scv.event.v1` records while the run is active.
64 111
Callers can supply an `event_sink` function, and the executor also emits the

@@ -147,24 +194,31 @@ construction.

147 194
### Proven image build
148 195
149 196
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.
197
`openagents/scv-opencode-core:local`. The build pins its Debian and Elixir base
198
digests, Debian snapshot, Hex, Rebar3, Node.js, Bun, and OpenCode. It produces a
199
self-contained Elixir release and the OpenCode toolchain in one image.
200
201
On 2026-08-20, the complete ARM64 image ran as UID and GID `10001` through the
202
Elixir SCV process role. The SCV selected the `opencode` driver and
203
`opencode-core` environment, inspected the source baked into the image, and
204
made no changes. The worker emitted lifecycle records, two-second heartbeats,
205
normalized OpenCode events, two completed `read` calls, resource samples, and
206
one terminal worker result.
207
208
The terminal result recorded:
209
210
- `succeeded` in 9,387 milliseconds;
211
- eight normalized OpenCode events and no tool errors;
212
- 9,060 metered tokens and an estimated cost of `$0.00657015`;
213
- 706,650,112 bytes of peak direct-process RSS and 188% maximum sampled CPU;
214
- 16,908 captured bytes with no truncation.
215
216
The image contains no Docker client or socket. The SCV process role starts no
217
Phoenix endpoint, application Repo, Forge service, or deployment coordinator.
218
It accepts only the staging environment and read-only permission profile. This
219
proof does not admit repository writes or autonomous deployment. Add the
220
durable worker protocol, process-tree or cgroup enforcement, run-scoped
221
credential proxy, and effect-persistence sidecar before enabling writes.
168 222
169 223
## Goals
170 224

@@ -275,8 +329,10 @@ server provider adapter capability scheduler

275 329
                 +-----------------+-----------------+
276 330
                 |                 |                 |
277 331
                 v                 v                 v
278
          OpenCode core      browser worker      Rust worker
279
          SCV worker         and benchmarks      and native build
332
          OpenCode driver    OpenCode driver     native driver
333
                 |                 |                 |
334
                 v                 v                 v
335
          core environment   browser environment Rust environment
280 336
                 |                 |                 |
281 337
                 +-----------------+-----------------+
282 338
                                   |

@@ -335,7 +391,7 @@ Candidate code is untrusted during evaluation even though the worker runs in an

335 391
owned environment. Tests and Mix tasks can execute arbitrary repository code.
336 392
Do not mount any credential that candidate code could read or transmit.
337 393
338
## OpenCode as the first execution runtime
394
## OpenCode as the first driver
339 395
340 396
Use OpenCode for the first end-to-end SCV worker implementation. This validates
341 397
non-Elixir execution, long model-driven runs, structured events, permission

@@ -344,13 +400,13 @@ the SCV targets `openagents.com` itself.

344 400
345 401
Keep these milestones separate:
346 402
347
1. **Runtime qualification:** Build the OpenCode worker image, compile and test
403
1. **Environment qualification:** Build the OpenCode worker image, compile and test
348 404
   the inspected OpenCode source, exercise one bounded OpenCode session, and
349 405
   collect resource and benchmark evidence without pushing a candidate.
350
2. **Self-targeting proof:** Use the read-only admitted OpenCode runtime to fix
406
2. **Self-targeting proof:** Use the read-only admitted OpenCode driver to fix
351 407
   a seeded OpenCode defect in the separate target checkout. Stop at a
352 408
   propose-only run ref.
353
3. **Product pilot:** Use the qualified OpenCode runtime to improve
409
3. **Product pilot:** Use the qualified OpenCode driver and environment to improve
354 410
   `openagents.com`, pass its Elixir and Forge gates, and keep promotion human
355 411
   controlled.
356 412

@@ -390,10 +446,10 @@ tag or let repository code select the worker image.

390 446
391 447
### Trust boundary
392 448
393
OpenCode is an execution runtime inside a worker. It is not the SCV coordinator,
394
lease authority, policy engine, receipt store, or promotion authority. The
395
Elixir control plane owns those responsibilities even when OpenCode manages the
396
model and tool loop for one run.
449
OpenCode is a driver inside an SCV worker. It is not an environment, SCV
450
coordinator, lease authority, policy engine, receipt store, or promotion
451
authority. The Elixir control plane owns those responsibilities even when
452
OpenCode manages the model and tool loop for one run.
397 453
398 454
When an SCV works on OpenCode, keep two separate copies:
399 455

@@ -414,7 +470,7 @@ the first executable SCV milestone and should contain:

414 470
415 471
| Layer | Pinned contents |
416 472
| --- | --- |
417
| Operating system | Ubuntu 24.04 for parity with OpenCode's primary Linux tests |
473
| Operating system | Digest-pinned Debian Trixie from one dated snapshot for the build and runtime stages; use a separate Ubuntu qualification environment when exact OpenCode CI parity matters |
418 474
| JavaScript runtimes | Bun `1.3.14` baseline build, Node.js `24.15`, and Corepack |
419 475
| Native support | Python 3, `setuptools`, `build-essential`, `pkg-config`, `libgcc`, and `libstdc++` |
420 476
| Repository tools | Git, OpenSSH client without credentials, `curl`, certificates, `jq`, `ripgrep`, `unzip`, `xz-utils`, and `zip` |

@@ -1499,6 +1555,9 @@ Keep one module per file.

1499 1555
1500 1556
```text
1501 1557
lib/openagents/scv.ex
1558
lib/openagents/scv/driver.ex
1559
lib/openagents/scv/driver/open_code.ex
1560
lib/openagents/scv/environment.ex
1502 1561
lib/openagents/scv/instance.ex
1503 1562
lib/openagents/scv/work_item.ex
1504 1563
lib/openagents/scv/run.ex

@@ -1509,6 +1568,8 @@ lib/openagents/scv/worker.ex

1509 1568
lib/openagents/scv/worker_supervisor.ex
1510 1569
lib/openagents/scv/worker_client.ex
1511 1570
lib/openagents/scv/worker_runner.ex
1571
lib/openagents/scv/runner.ex
1572
lib/openagents/scv/runner/local.ex
1512 1573
lib/openagents/scv/execution.ex
1513 1574
lib/openagents/scv/benchmark_definition.ex
1514 1575
lib/openagents/scv/benchmark_run.ex
lib/mix/tasks/openagents.scv.opencode.ex modified +9 -5

@@ -14,7 +14,7 @@ defmodule Mix.Tasks.Openagents.Scv.Opencode do

14 14
15 15
  use Mix.Task
16 16
17
  alias OpenAgents.SCV.Executor.OpenCode
17
  alias OpenAgents.SCV
18 18
19 19
  @shortdoc "Run one bounded local SCV execution through OpenCode"
20 20

@@ -55,7 +55,7 @@ defmodule Mix.Tasks.Openagents.Scv.Opencode do

55 55
    timeout_ms = Keyword.get(options, :timeout_seconds, 300) * 1_000
56 56
    permissions = if Keyword.get(options, :write, false), do: :workspace_write, else: :read_only
57 57
58
    executor_options =
58
    driver_options =
59 59
      [
60 60
        api_key: System.get_env("OPENAI_API_KEY"),
61 61
        executable: Keyword.get(options, :opencode, default_executable()),

@@ -64,11 +64,15 @@ defmodule Mix.Tasks.Openagents.Scv.Opencode do

64 64
        output_root: output_root,
65 65
        timeout_ms: timeout_ms,
66 66
        diagnostic_logs: Keyword.get(options, :diagnostic_logs, false),
67
        event_sink: &print_live_event/1,
68
        permissions: permissions
67
        event_sink: &print_live_event/1
69 68
      ]
70 69
71
    case OpenCode.run(repository, prompt, executor_options) do
70
    case SCV.run(repository, prompt,
71
           driver: :opencode,
72
           environment: :opencode_core,
73
           permission_profile: permissions,
74
           driver_options: driver_options
75
         ) do
72 76
      {:ok, result} ->
73 77
        print_result(result, Keyword.get(options, :json, false))
74 78
lib/openagents/application.ex modified +31

@@ -7,6 +7,13 @@ defmodule OpenAgents.Application do

7 7
8 8
  @impl true
9 9
  def start(_type, _args) do
10
    case Application.get_env(:openagents, :runtime_role, :web) do
11
      :scv -> start_scv_worker()
12
      :web -> start_web()
13
    end
14
  end
15
16
  defp start_web do
10 17
    runtime_config = OpenAgents.RuntimeConfig.install!()
11 18
12 19
    # Releases migrate on boot (RELEASE-001): the schema must precede traffic.

@@ -74,6 +81,30 @@ defmodule OpenAgents.Application do

74 81
    result
75 82
  end
76 83
84
  defp start_scv_worker do
85
    child = %{
86
      id: OpenAgents.SCV.Worker,
87
      start: {Task, :start_link, [fn -> run_scv_worker() end]},
88
      restart: :temporary
89
    }
90
91
    Supervisor.start_link([child], strategy: :one_for_one, name: OpenAgents.SCV.Supervisor)
92
  end
93
94
  defp run_scv_worker do
95
    exit_status =
96
      try do
97
        OpenAgents.SCV.Worker.run_from_env!()
98
        0
99
      rescue
100
        _error -> 1
101
      catch
102
        _kind, _reason -> 1
103
      end
104
105
    System.stop(exit_status)
106
  end
107
77 108
  # Tell Phoenix to update the endpoint configuration
78 109
  # whenever the application is updated.
79 110
  @impl true
lib/openagents/scv.ex added +21

@@ -0,0 +1,21 @@

1
defmodule OpenAgents.SCV do
2
  @moduledoc """
3
  Runs a bounded SCV through its selected runner and driver.
4
5
  The SCV owns the run contract. A driver supplies one coding implementation,
6
  such as OpenCode, and an environment supplies the admitted runtime
7
  capabilities. Callers deploy SCVs rather than deploying drivers directly.
8
  """
9
10
  alias OpenAgents.SCV.Run
11
12
  @spec run(Run.t()) :: {:ok, map()} | {:error, term()}
13
  def run(%Run{runner_module: runner} = run), do: runner.run(run)
14
15
  @spec run(Path.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
16
  def run(repository, objective, options \\ []) do
17
    with {:ok, run} <- Run.new(repository, objective, options) do
18
      run(run)
19
    end
20
  end
21
end
lib/openagents/scv/driver.ex added +20

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

1
defmodule OpenAgents.SCV.Driver do
2
  @moduledoc """
3
  Defines the execution adapter selected for an SCV run.
4
5
  Drivers may use different tool protocols, but every driver runs inside the
6
  same SCV lifecycle, capability, event, and receipt boundary.
7
  """
8
9
  alias OpenAgents.SCV.Run
10
11
  @callback id() :: String.t()
12
  @callback required_capabilities(:read_only | :workspace_write) :: [atom()]
13
  @callback run(Run.t()) :: {:ok, map()} | {:error, term()}
14
15
  @spec fetch(atom() | String.t()) :: {:ok, module()} | {:error, :driver_not_admitted}
16
  def fetch(driver) when driver in [:opencode, "opencode"],
17
    do: {:ok, OpenAgents.SCV.Driver.OpenCode}
18
19
  def fetch(_driver), do: {:error, :driver_not_admitted}
20
end
lib/openagents/scv/driver/open_code.ex added +39

@@ -0,0 +1,39 @@

1
defmodule OpenAgents.SCV.Driver.OpenCode do
2
  @moduledoc "Runs the OpenCode implementation inside an SCV run."
3
4
  @behaviour OpenAgents.SCV.Driver
5
6
  alias OpenAgents.SCV.Executor.OpenCode
7
  alias OpenAgents.SCV.Run
8
9
  @impl true
10
  def id, do: "opencode"
11
12
  @impl true
13
  def required_capabilities(:read_only),
14
    do: [:model_inference, :network_egress, :process_execute, :workspace_read]
15
16
  def required_capabilities(:workspace_write),
17
    do: required_capabilities(:read_only) ++ [:workspace_write]
18
19
  @impl true
20
  def run(%Run{} = run) do
21
    context = %{
22
      driver: id(),
23
      environment: run.environment.id,
24
      runner: run.runner_id,
25
      capabilities: Enum.map(run.capabilities, &Atom.to_string/1)
26
    }
27
28
    options =
29
      Keyword.merge(run.driver_options,
30
        run_id: run.id,
31
        permissions: run.permission_profile,
32
        repository_revision: run.repository_revision,
33
        event_context: context,
34
        run_context: context
35
      )
36
37
    OpenCode.run(run.repository, run.objective, options)
38
  end
39
end
lib/openagents/scv/environment.ex added +42

@@ -0,0 +1,42 @@

1
defmodule OpenAgents.SCV.Environment do
2
  @moduledoc """
3
  Describes the runtime capabilities available to an SCV driver.
4
5
  The environment ID selects an immutable image at deployment time. It does not
6
  identify the SCV or the driver that runs inside the image.
7
  """
8
9
  @enforce_keys [:id, :image_name, :capabilities, :runtimes]
10
  defstruct [:id, :image_name, :capabilities, :runtimes]
11
12
  @type t :: %__MODULE__{
13
          id: String.t(),
14
          image_name: String.t(),
15
          capabilities: [atom()],
16
          runtimes: [String.t()]
17
        }
18
19
  @spec fetch(atom() | String.t()) :: {:ok, t()} | {:error, :environment_not_admitted}
20
  def fetch(environment) when environment in [:opencode_core, "opencode-core"] do
21
    {:ok,
22
     %__MODULE__{
23
       id: "opencode-core",
24
       image_name: "scv-opencode-core",
25
       capabilities: [
26
         :model_inference,
27
         :network_egress,
28
         :process_execute,
29
         :workspace_read,
30
         :workspace_write
31
       ],
32
       runtimes: ["elixir", "git", "node", "bun", "python"]
33
     }}
34
  end
35
36
  def fetch(_environment), do: {:error, :environment_not_admitted}
37
38
  @spec supports?(t(), [atom()]) :: boolean()
39
  def supports?(%__MODULE__{capabilities: available}, required) do
40
    MapSet.subset?(MapSet.new(required), MapSet.new(available))
41
  end
42
end
lib/openagents/scv/executor/open_code.ex modified +39 -10

@@ -55,6 +55,9 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

55 55
    executable = Keyword.get(options, :executable, default_executable())
56 56
    config_seed = Keyword.get(options, :config_seed)
57 57
    diagnostic_logs = Keyword.get(options, :diagnostic_logs, false)
58
    repository_revision = Keyword.get(options, :repository_revision)
59
    event_context = Keyword.get(options, :event_context, %{})
60
    run_context = Keyword.get(options, :run_context, %{})
58 61
59 62
    heartbeat_interval_ms =
60 63
      Keyword.get(options, :heartbeat_interval_ms, @default_heartbeat_interval_ms)

@@ -86,6 +89,9 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

86 89
             :heartbeat_interval_invalid
87 90
           ),
88 91
         :ok <- validate_event_sink(event_sink),
92
         :ok <- validate_repository_revision(repository_revision),
93
         :ok <- validate_context(event_context, :event_context_invalid),
94
         :ok <- validate_context(run_context, :run_context_invalid),
89 95
         {:ok, config_seed} <- validate_config_seed(config_seed),
90 96
         {:ok, executable} <- validate_executable(executable) do
91 97
      {:ok,

@@ -104,6 +110,9 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

104 110
         executable: executable,
105 111
         config_seed: config_seed,
106 112
         diagnostic_logs: diagnostic_logs,
113
         repository_revision: repository_revision,
114
         event_context: event_context,
115
         run_context: run_context,
107 116
         heartbeat_interval_ms: heartbeat_interval_ms,
108 117
         event_sink: event_sink
109 118
       }}

@@ -550,8 +559,9 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

550 559
      duration_ms: duration_ms,
551 560
      repository: %{
552 561
        path: input.repository,
553
        git_sha: git_sha(input.repository)
562
        git_sha: input.repository_revision || git_sha(input.repository)
554 563
      },
564
      scv: input.run_context,
555 565
      runtime: %{
556 566
        adapter: "opencode",
557 567
        executable: input.executable,

@@ -786,6 +796,26 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

786 796
  defp validate_event_sink(event_sink) when is_function(event_sink, 1), do: :ok
787 797
  defp validate_event_sink(_event_sink), do: {:error, :event_sink_invalid}
788 798
799
  defp validate_repository_revision(nil), do: :ok
800
801
  defp validate_repository_revision(revision) when is_binary(revision) do
802
    if Regex.match?(~r/\A[0-9a-f]{40}\z/, revision),
803
      do: :ok,
804
      else: {:error, :repository_revision_invalid}
805
  end
806
807
  defp validate_repository_revision(_revision), do: {:error, :repository_revision_invalid}
808
809
  defp validate_context(context, error) when is_map(context) and map_size(context) <= 16 do
810
    allowed = MapSet.new([:driver, :environment, :runner, :capabilities])
811
812
    if context |> Map.keys() |> MapSet.new() |> MapSet.subset?(allowed),
813
      do: :ok,
814
      else: {:error, error}
815
  end
816
817
  defp validate_context(_context, error), do: {:error, error}
818
789 819
  defp validate_permissions(permissions) when permissions in [:read_only, :workspace_write],
790 820
    do: :ok
791 821

@@ -860,15 +890,14 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

860 890
861 891
  defp emit_event(input, type, data) do
862 892
    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
      )
893
      %{
894
        schema: "openagents.scv.event.v1",
895
        run_id: input.run_id,
896
        type: type,
897
        emitted_at: DateTime.utc_now() |> DateTime.to_iso8601()
898
      }
899
      |> Map.merge(input.event_context)
900
      |> Map.merge(data)
872 901
873 902
    try do
874 903
      input.event_sink.(event)
lib/openagents/scv/run.ex added +146

@@ -0,0 +1,146 @@

1
defmodule OpenAgents.SCV.Run do
2
  @moduledoc "Defines one bounded SCV execution request."
3
4
  alias OpenAgents.SCV.Driver
5
  alias OpenAgents.SCV.Environment
6
  alias OpenAgents.SCV.Runner.Local
7
8
  @derive {Inspect, except: [:objective, :driver_options]}
9
  @enforce_keys [
10
    :id,
11
    :repository,
12
    :objective,
13
    :driver_module,
14
    :environment,
15
    :runner_module,
16
    :runner_id,
17
    :permission_profile,
18
    :capabilities,
19
    :driver_options
20
  ]
21
  defstruct [
22
    :id,
23
    :repository,
24
    :repository_revision,
25
    :objective,
26
    :driver_module,
27
    :environment,
28
    :runner_module,
29
    :runner_id,
30
    :permission_profile,
31
    :capabilities,
32
    :driver_options
33
  ]
34
35
  @type permission_profile :: :read_only | :workspace_write
36
  @type t :: %__MODULE__{
37
          id: Ecto.UUID.t(),
38
          repository: Path.t(),
39
          repository_revision: String.t() | nil,
40
          objective: String.t(),
41
          driver_module: module(),
42
          environment: Environment.t(),
43
          runner_module: module(),
44
          runner_id: String.t(),
45
          permission_profile: permission_profile(),
46
          capabilities: [atom()],
47
          driver_options: keyword()
48
        }
49
50
  @spec new(Path.t(), String.t(), keyword()) :: {:ok, t()} | {:error, atom()}
51
  def new(repository, objective, options \\ [])
52
53
  def new(repository, objective, options) when is_list(options) do
54
    driver = Keyword.get(options, :driver, :opencode)
55
    environment = Keyword.get(options, :environment, :opencode_core)
56
    permission_profile = Keyword.get(options, :permission_profile, :read_only)
57
    runner = Keyword.get(options, :runner, Local)
58
    run_id = Keyword.get(options, :run_id, Ecto.UUID.generate())
59
    repository_revision = Keyword.get(options, :repository_revision)
60
    driver_options = Keyword.get(options, :driver_options, [])
61
62
    with {:ok, repository} <- validate_repository(repository),
63
         :ok <- validate_objective(objective),
64
         {:ok, driver_module} <- Driver.fetch(driver),
65
         {:ok, environment} <- Environment.fetch(environment),
66
         :ok <- validate_permission_profile(permission_profile),
67
         {:ok, runner_module, runner_id} <- validate_runner(runner),
68
         :ok <- validate_run_id(run_id),
69
         :ok <- validate_repository_revision(repository_revision),
70
         :ok <- validate_driver_options(driver_options),
71
         capabilities <- driver_module.required_capabilities(permission_profile),
72
         true <-
73
           Environment.supports?(environment, capabilities) or {:error, :capability_mismatch} do
74
      {:ok,
75
       %__MODULE__{
76
         id: run_id,
77
         repository: repository,
78
         repository_revision: repository_revision,
79
         objective: objective,
80
         driver_module: driver_module,
81
         environment: environment,
82
         runner_module: runner_module,
83
         runner_id: runner_id,
84
         permission_profile: permission_profile,
85
         capabilities: capabilities,
86
         driver_options: driver_options
87
       }}
88
    end
89
  end
90
91
  def new(_repository, _objective, _options), do: {:error, :options_invalid}
92
93
  defp validate_repository(repository) when is_binary(repository) do
94
    expanded = Path.expand(repository)
95
96
    cond do
97
      Path.type(repository) != :absolute -> {:error, :repository_not_absolute}
98
      not File.dir?(expanded) -> {:error, :repository_not_found}
99
      true -> {:ok, expanded}
100
    end
101
  end
102
103
  defp validate_repository(_repository), do: {:error, :repository_invalid}
104
105
  defp validate_objective(objective)
106
       when is_binary(objective) and byte_size(objective) in 1..32_768 do
107
    if String.trim(objective) == "", do: {:error, :objective_empty}, else: :ok
108
  end
109
110
  defp validate_objective(_objective), do: {:error, :objective_invalid}
111
112
  defp validate_permission_profile(profile) when profile in [:read_only, :workspace_write],
113
    do: :ok
114
115
  defp validate_permission_profile(_profile), do: {:error, :permission_profile_not_admitted}
116
117
  defp validate_runner(Local), do: {:ok, Local, Local.id()}
118
  defp validate_runner(:local), do: {:ok, Local, Local.id()}
119
  defp validate_runner("local"), do: {:ok, Local, Local.id()}
120
  defp validate_runner(_runner), do: {:error, :runner_not_admitted}
121
122
  defp validate_run_id(run_id) when is_binary(run_id) do
123
    case Ecto.UUID.cast(run_id) do
124
      {:ok, ^run_id} -> :ok
125
      _invalid -> {:error, :run_id_invalid}
126
    end
127
  end
128
129
  defp validate_run_id(_run_id), do: {:error, :run_id_invalid}
130
131
  defp validate_repository_revision(nil), do: :ok
132
133
  defp validate_repository_revision(revision) when is_binary(revision) do
134
    if Regex.match?(~r/\A[0-9a-f]{40}\z/, revision),
135
      do: :ok,
136
      else: {:error, :repository_revision_invalid}
137
  end
138
139
  defp validate_repository_revision(_revision), do: {:error, :repository_revision_invalid}
140
141
  defp validate_driver_options(options) when is_list(options) do
142
    if Keyword.keyword?(options), do: :ok, else: {:error, :driver_options_invalid}
143
  end
144
145
  defp validate_driver_options(_options), do: {:error, :driver_options_invalid}
146
end
lib/openagents/scv/runner.ex added +8

@@ -0,0 +1,8 @@

1
defmodule OpenAgents.SCV.Runner do
2
  @moduledoc "Defines how an admitted environment executes an SCV driver."
3
4
  alias OpenAgents.SCV.Run
5
6
  @callback id() :: String.t()
7
  @callback run(Run.t()) :: {:ok, map()} | {:error, term()}
8
end
lib/openagents/scv/runner/local.ex added +19

@@ -0,0 +1,19 @@

1
defmodule OpenAgents.SCV.Runner.Local do
2
  @moduledoc """
3
  Runs an SCV driver as a direct child process in the current environment.
4
5
  A container platform may place this runner inside an isolated worker image.
6
  The runner name describes the in-environment process boundary, not the host
7
  that scheduled the container.
8
  """
9
10
  @behaviour OpenAgents.SCV.Runner
11
12
  alias OpenAgents.SCV.Run
13
14
  @impl true
15
  def id, do: "local"
16
17
  @impl true
18
  def run(%Run{driver_module: driver} = run), do: driver.run(run)
19
end
lib/openagents/scv/worker.ex added +213

@@ -0,0 +1,213 @@

1
defmodule OpenAgents.SCV.Worker do
2
  @moduledoc """
3
  Starts one read-only SCV from a worker environment.
4
5
  The staging entry point accepts only the OpenCode driver, the
6
  `opencode-core` environment, and the read-only permission profile. It writes
7
  structured events as JSON lines while the SCV runs and writes one terminal
8
  result after completion.
9
  """
10
11
  alias OpenAgents.SCV
12
  alias OpenAgents.SCV.Run
13
14
  @default_model "openai/gpt-5.4-mini"
15
  @default_timeout_ms 300_000
16
  @default_output_root "/workspace/runs"
17
18
  @spec run(map(), keyword()) :: {:ok, map()} | {:error, term()}
19
  def run(environment \\ System.get_env(), options \\ [])
20
21
  def run(environment, options) when is_map(environment) and is_list(options) do
22
    with {:ok, input} <- parse_environment(environment),
23
         {:ok, run} <- build_run(input, options) do
24
      SCV.run(run)
25
    end
26
  end
27
28
  def run(_environment, _options), do: {:error, :worker_input_invalid}
29
30
  @doc "Runs an SCV and emits Cloud Logging-compatible JSON lines."
31
  @spec run_from_env!() :: :ok
32
  def run_from_env! do
33
    sink = &write_json/1
34
35
    case run(System.get_env(), event_sink: sink) do
36
      {:ok, %{status: "succeeded"} = result} ->
37
        write_json(worker_result(result))
38
        :ok
39
40
      {:ok, result} ->
41
        write_json(worker_result(result))
42
        raise "SCV worker finished with status #{result.status}"
43
44
      {:error, reason} ->
45
        write_json(%{
46
          schema: "openagents.scv.worker.result.v1",
47
          type: "worker_failed",
48
          emitted_at: DateTime.utc_now() |> DateTime.to_iso8601(),
49
          error_code: error_code(reason)
50
        })
51
52
        raise "SCV worker could not start: #{error_code(reason)}"
53
    end
54
  end
55
56
  defp parse_environment(environment) do
57
    with {:ok, repository} <- fetch_required(environment, "SCV_REPOSITORY"),
58
         {:ok, objective} <- fetch_required(environment, "SCV_OBJECTIVE"),
59
         {:ok, api_key} <- fetch_required(environment, "OPENAI_API_KEY"),
60
         {:ok, driver} <- fetch_value(environment, "SCV_DRIVER", "opencode", ["opencode"]),
61
         {:ok, scv_environment} <-
62
           fetch_value(environment, "SCV_ENVIRONMENT", "opencode-core", ["opencode-core"]),
63
         {:ok, permission_profile} <-
64
           fetch_value(environment, "SCV_PERMISSION_PROFILE", "read_only", ["read_only"]),
65
         {:ok, timeout_ms} <-
66
           fetch_integer(environment, "SCV_TIMEOUT_MS", @default_timeout_ms, 1..3_600_000),
67
         {:ok, heartbeat_interval_ms} <-
68
           fetch_integer(environment, "SCV_HEARTBEAT_INTERVAL_MS", 5_000, 250..60_000),
69
         {:ok, diagnostic_logs} <-
70
           fetch_boolean(environment, "SCV_DIAGNOSTIC_LOGS", false),
71
         {:ok, repository_revision} <-
72
           fetch_optional_revision(environment, "SCV_REPOSITORY_REVISION") do
73
      {:ok,
74
       %{
75
         repository: repository,
76
         repository_revision: repository_revision,
77
         objective: objective,
78
         api_key: api_key,
79
         driver: driver,
80
         environment: scv_environment,
81
         permission_profile: permission_profile,
82
         model: Map.get(environment, "SCV_MODEL", @default_model),
83
         output_root: Map.get(environment, "SCV_OUTPUT_ROOT", @default_output_root),
84
         executable: Map.get(environment, "OPENCODE_BIN"),
85
         config_seed: optional_value(environment, "OPENCODE_CONFIG_SEED"),
86
         run_id: optional_value(environment, "SCV_RUN_ID"),
87
         timeout_ms: timeout_ms,
88
         heartbeat_interval_ms: heartbeat_interval_ms,
89
         diagnostic_logs: diagnostic_logs
90
       }}
91
    end
92
  end
93
94
  defp build_run(input, options) do
95
    event_sink = Keyword.get(options, :event_sink, fn _event -> :ok end)
96
    overrides = Keyword.get(options, :driver_options, [])
97
98
    driver_options =
99
      [
100
        api_key: input.api_key,
101
        executable: input.executable,
102
        config_seed: input.config_seed,
103
        model: input.model,
104
        output_root: input.output_root,
105
        timeout_ms: input.timeout_ms,
106
        heartbeat_interval_ms: input.heartbeat_interval_ms,
107
        diagnostic_logs: input.diagnostic_logs,
108
        event_sink: event_sink
109
      ]
110
      |> Keyword.merge(overrides)
111
112
    run_options = [
113
      driver: input.driver,
114
      environment: input.environment,
115
      permission_profile: String.to_existing_atom(input.permission_profile),
116
      repository_revision: input.repository_revision,
117
      driver_options: driver_options
118
    ]
119
120
    run_options =
121
      if input.run_id, do: Keyword.put(run_options, :run_id, input.run_id), else: run_options
122
123
    Run.new(input.repository, input.objective, run_options)
124
  end
125
126
  defp fetch_required(environment, name) do
127
    case optional_value(environment, name) do
128
      nil -> {:error, {:environment_missing, name}}
129
      value -> {:ok, value}
130
    end
131
  end
132
133
  defp fetch_value(environment, name, default, admitted) do
134
    value = optional_value(environment, name) || default
135
136
    if value in admitted,
137
      do: {:ok, value},
138
      else: {:error, {:environment_value_not_admitted, name}}
139
  end
140
141
  defp fetch_integer(environment, name, default, range) do
142
    value = optional_value(environment, name) || Integer.to_string(default)
143
144
    case Integer.parse(value) do
145
      {integer, ""} ->
146
        if integer in range,
147
          do: {:ok, integer},
148
          else: {:error, {:environment_integer_invalid, name}}
149
150
      _invalid ->
151
        {:error, {:environment_integer_invalid, name}}
152
    end
153
  end
154
155
  defp fetch_boolean(environment, name, default) do
156
    case optional_value(environment, name) do
157
      nil -> {:ok, default}
158
      "true" -> {:ok, true}
159
      "false" -> {:ok, false}
160
      _invalid -> {:error, {:environment_boolean_invalid, name}}
161
    end
162
  end
163
164
  defp fetch_optional_revision(environment, name) do
165
    case optional_value(environment, name) do
166
      nil ->
167
        {:ok, nil}
168
169
      revision ->
170
        if Regex.match?(~r/\A[0-9a-f]{40}\z/, revision),
171
          do: {:ok, revision},
172
          else: {:error, {:environment_revision_invalid, name}}
173
    end
174
  end
175
176
  defp optional_value(environment, name) do
177
    case Map.get(environment, name) do
178
      value when is_binary(value) ->
179
        case String.trim(value) do
180
          "" -> nil
181
          trimmed -> trimmed
182
        end
183
184
      _value ->
185
        nil
186
    end
187
  end
188
189
  defp worker_result(result) do
190
    %{
191
      schema: "openagents.scv.worker.result.v1",
192
      type: "worker_finished",
193
      emitted_at: DateTime.utc_now() |> DateTime.to_iso8601(),
194
      run_id: result.run_id,
195
      status: result.status,
196
      driver: result.scv.driver,
197
      environment: result.scv.environment,
198
      repository_revision: result.repository.git_sha,
199
      duration_ms: result.duration_ms,
200
      event_count: result.events.event_count,
201
      tool_calls: result.events.tool_calls,
202
      usage: result.events.usage,
203
      resources: result.resources,
204
      artifact_digest: result.artifacts.events_digest
205
    }
206
  end
207
208
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
209
  defp error_code({reason, _detail}) when is_atom(reason), do: Atom.to_string(reason)
210
  defp error_code(_reason), do: "worker_failed"
211
212
  defp write_json(value), do: IO.puts(Jason.encode!(value))
213
end
ops/scv/images/build-opencode-core.sh modified +21 -2

@@ -5,16 +5,35 @@ repo_root=$(git rev-parse --show-toplevel)

5 5
source "${repo_root}/ops/scv/images/versions.env"
6 6
7 7
image=${SCV_IMAGE:-openagents/scv-opencode-core:local}
8
platform=${SCV_IMAGE_PLATFORM:-}
9
git_sha=${SCV_BUILD_REVISION:-$(git -C "${repo_root}" rev-parse HEAD)}
10
source_date_epoch=$(git -C "${repo_root}" show -s --format=%ct "${git_sha}")
8 11
9
docker build \
12
build_image() {
13
  docker build "$@" \
10 14
  --file "${repo_root}/ops/scv/images/opencode-core/Dockerfile" \
11 15
  --tag "${image}" \
12
  --build-arg "UBUNTU_IMAGE=${SCV_UBUNTU_IMAGE}" \
16
  --build-arg "RUNTIME_IMAGE=${SCV_RUNTIME_IMAGE}" \
17
  --build-arg "ELIXIR_IMAGE=${SCV_ELIXIR_IMAGE}" \
18
  --build-arg "DEBIAN_SNAPSHOT=${SCV_DEBIAN_SNAPSHOT}" \
19
  --build-arg "HEX_VERSION=${SCV_HEX_VERSION}" \
20
  --build-arg "REBAR3_VERSION=${SCV_REBAR3_VERSION}" \
21
  --build-arg "REBAR3_SHA512=${SCV_REBAR3_SHA512}" \
13 22
  --build-arg "NODE_VERSION=${SCV_NODE_VERSION}" \
14 23
  --build-arg "BUN_VERSION=${SCV_BUN_VERSION}" \
15 24
  --build-arg "OPENCODE_VERSION=${SCV_OPENCODE_VERSION}" \
25
  --build-arg "OPENAGENTS_BUILD_REVISION=${git_sha}" \
26
  --build-arg "SOURCE_DATE_EPOCH=${source_date_epoch}" \
16 27
  --label "com.openagents.scv.image=opencode-core" \
17 28
  --label "com.openagents.scv.opencode.version=${SCV_OPENCODE_VERSION}" \
29
  --label "org.opencontainers.image.revision=${git_sha}" \
18 30
  "${repo_root}"
31
}
32
33
if [[ -n "${platform}" ]]; then
34
  build_image --platform "${platform}"
35
else
36
  build_image
37
fi
19 38
20 39
docker image inspect "${image}" --format '{{json .RepoDigests}} {{.Id}}'
ops/scv/images/opencode-core/Dockerfile modified +77 -4

@@ -1,21 +1,80 @@

1
ARG UBUNTU_IMAGE=ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517
2
FROM ${UBUNTU_IMAGE}
1
ARG RUNTIME_IMAGE=docker.io/debian:trixie-20260803-slim@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258
2
ARG ELIXIR_IMAGE=docker.io/hexpm/elixir:1.20.3-erlang-29.0.5-debian-trixie-20260803-slim@sha256:ae38be7cb19bffa78adedb04732d9e6ba83a507b4cfb06983cbe711edb49da54
3
4
FROM ${ELIXIR_IMAGE} AS scv-release-builder
5
6
ARG DEBIAN_SNAPSHOT=20260803T000000Z
7
ARG HEX_VERSION=2.5.1
8
ARG REBAR3_VERSION=3.25.1
9
ARG REBAR3_SHA512=69073f6ad163f74971545015238614c327893960c1b3f26df5377df135c773a0716b48b65c2a48cef878f185dd92805abc69894adfa3fd27a90c62a64ba371e2
10
ARG OPENAGENTS_BUILD_REVISION=image
11
ARG SOURCE_DATE_EPOCH=0
12
13
ENV MIX_ENV=prod
14
ENV OPENAGENTS_BUILD_REVISION=${OPENAGENTS_BUILD_REVISION}
15
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}
16
17
RUN sed -i \
18
      "s|URIs: http://deb.debian.org/debian$|URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|" \
19
      /etc/apt/sources.list.d/debian.sources \
20
  && sed -i \
21
      "s|URIs: http://deb.debian.org/debian-security$|URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|" \
22
      /etc/apt/sources.list.d/debian.sources \
23
  && printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99snapshot \
24
  && apt-get update \
25
  && apt-get install -y --no-install-recommends build-essential ca-certificates git \
26
  && rm -rf /var/lib/apt/lists/*
27
28
WORKDIR /app
29
30
RUN mix local.hex "${HEX_VERSION}" --force \
31
  && mix local.rebar rebar3 \
32
      "https://github.com/erlang/rebar3/releases/download/${REBAR3_VERSION}/rebar3" \
33
      --sha512 "${REBAR3_SHA512}" \
34
      --force
35
36
COPY mix.exs mix.lock ./
37
RUN mix deps.get --only prod \
38
  && mkdir config
39
40
COPY config/config.exs config/prod.exs config/
41
RUN mix deps.compile
42
43
COPY priv priv
44
COPY lib lib
45
COPY rel rel
46
RUN mix compile --warnings-as-errors
47
48
COPY config/runtime.exs config/
49
RUN mix release
50
51
FROM ${RUNTIME_IMAGE} AS opencode-toolchain
3 52
4 53
ARG TARGETARCH
5 54
ARG DEBIAN_FRONTEND=noninteractive
55
ARG DEBIAN_SNAPSHOT=20260803T000000Z
6 56
ARG NODE_VERSION=24.15.0
7 57
ARG BUN_VERSION=1.3.14
8 58
ARG OPENCODE_VERSION=1.18.5
59
ARG OPENAGENTS_BUILD_REVISION=image
9 60
10 61
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
11 62
12
RUN apt-get update \
63
RUN sed -i \
64
      "s|URIs: http://deb.debian.org/debian$|URIs: http://snapshot.debian.org/archive/debian/${DEBIAN_SNAPSHOT}|" \
65
      /etc/apt/sources.list.d/debian.sources \
66
  && sed -i \
67
      "s|URIs: http://deb.debian.org/debian-security$|URIs: http://snapshot.debian.org/archive/debian-security/${DEBIAN_SNAPSHOT}|" \
68
      /etc/apt/sources.list.d/debian.sources \
69
  && printf 'Acquire::Check-Valid-Until "false";\n' > /etc/apt/apt.conf.d/99snapshot \
70
  && apt-get update \
13 71
  && apt-get install -y --no-install-recommends \
14 72
    build-essential \
15 73
    ca-certificates \
16 74
    curl \
17 75
    git \
18 76
    jq \
77
    libsctp1 \
19 78
    openssh-client \
20 79
    pkg-config \
21 80
    procps \

@@ -91,9 +150,23 @@ ENV XDG_CACHE_HOME=/home/scv/.cache

91 150
ENV XDG_CONFIG_HOME=/home/scv/.config
92 151
ENV XDG_DATA_HOME=/home/scv/.local/share
93 152
ENV XDG_STATE_HOME=/home/scv/.local/state
153
ENV ELIXIR_ERL_OPTIONS=+fnu
154
ENV OPENAGENTS_BUILD_REVISION=${OPENAGENTS_BUILD_REVISION}
155
ENV OPENAGENTS_ENVIRONMENT=staging
156
ENV OPENAGENTS_RUNTIME_ROLE=scv
157
ENV OPENCODE_BIN=/opt/scv/opencode/bin/opencode
158
ENV SCV_DRIVER=opencode
159
ENV SCV_ENVIRONMENT=opencode-core
160
ENV SCV_OUTPUT_ROOT=/workspace/runs
161
ENV SCV_PERMISSION_PROFILE=read_only
162
ENV SCV_REPOSITORY=/workspace/openagents
163
ENV SCV_REPOSITORY_REVISION=${OPENAGENTS_BUILD_REVISION}
164
165
COPY --from=scv-release-builder --chown=scv:scv /app/_build/prod/rel/openagents /opt/scv/openagents
166
COPY --chown=scv:scv . /workspace/openagents
94 167
95 168
USER scv
96 169
WORKDIR /workspace
97 170
98 171
ENTRYPOINT ["/usr/bin/tini", "--"]
99
CMD ["opencode", "--version"]
172
CMD ["/opt/scv/openagents/bin/openagents", "start"]
ops/scv/images/versions.env modified +6 -1

@@ -1,4 +1,9 @@

1
SCV_UBUNTU_IMAGE=ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517
1
SCV_RUNTIME_IMAGE=docker.io/debian:trixie-20260803-slim@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258
2
SCV_ELIXIR_IMAGE=docker.io/hexpm/elixir:1.20.3-erlang-29.0.5-debian-trixie-20260803-slim@sha256:ae38be7cb19bffa78adedb04732d9e6ba83a507b4cfb06983cbe711edb49da54
3
SCV_DEBIAN_SNAPSHOT=20260803T000000Z
4
SCV_HEX_VERSION=2.5.1
5
SCV_REBAR3_VERSION=3.25.1
6
SCV_REBAR3_SHA512=69073f6ad163f74971545015238614c327893960c1b3f26df5377df135c773a0716b48b65c2a48cef878f185dd92805abc69894adfa3fd27a90c62a64ba371e2
2 7
SCV_NODE_VERSION=24.15.0
3 8
SCV_BUN_VERSION=1.3.14
4 9
SCV_OPENCODE_VERSION=1.18.5
test/openagents/scv/run_test.exs added +132

@@ -0,0 +1,132 @@

1
defmodule OpenAgents.SCV.RunTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.SCV
5
  alias OpenAgents.SCV.Run
6
  alias OpenAgents.SCV.Worker
7
8
  setup do
9
    root = Path.join(System.tmp_dir!(), "scv-run-#{System.unique_integer([:positive])}")
10
    repository = Path.join(root, "repository")
11
    output = Path.join(root, "runs")
12
    executable = Path.join(root, "fake-opencode")
13
14
    File.mkdir_p!(repository)
15
    File.write!(Path.join(repository, "README.md"), "SCV fixture")
16
    File.write!(executable, fake_executable())
17
    File.chmod!(executable, 0o700)
18
19
    on_exit(fn -> File.rm_rf(root) end)
20
21
    %{executable: executable, output: output, repository: repository}
22
  end
23
24
  test "runs OpenCode as an SCV driver inside an admitted environment", context do
25
    test_pid = self()
26
    revision = String.duplicate("a", 40)
27
28
    assert {:ok, result} =
29
             SCV.run(context.repository, "Inspect the fixture.",
30
               driver: :opencode,
31
               environment: :opencode_core,
32
               repository_revision: revision,
33
               driver_options: [
34
                 api_key: "fixture-secret-key",
35
                 executable: context.executable,
36
                 model: "openai/test-model",
37
                 output_root: context.output,
38
                 timeout_ms: 1_000,
39
                 event_sink: fn event -> send(test_pid, {:event, event}) end
40
               ]
41
             )
42
43
    assert result.status == "succeeded"
44
    assert result.repository.git_sha == revision
45
46
    assert result.scv == %{
47
             driver: "opencode",
48
             environment: "opencode-core",
49
             runner: "local",
50
             capabilities: [
51
               "model_inference",
52
               "network_egress",
53
               "process_execute",
54
               "workspace_read"
55
             ]
56
           }
57
58
    assert_receive {:event,
59
                    %{
60
                      schema: "openagents.scv.event.v1",
61
                      driver: "opencode",
62
                      environment: "opencode-core",
63
                      runner: "local",
64
                      type: "run_preparing"
65
                    }}
66
  end
67
68
  test "keeps objectives and driver credentials out of inspected run values", context do
69
    assert {:ok, run} =
70
             Run.new(context.repository, "sensitive objective",
71
               driver_options: [api_key: "sensitive credential"]
72
             )
73
74
    inspected = inspect(run)
75
    refute inspected =~ "sensitive objective"
76
    refute inspected =~ "sensitive credential"
77
  end
78
79
  test "rejects unadmitted drivers, environments, and repository revisions", context do
80
    assert {:error, :driver_not_admitted} =
81
             Run.new(context.repository, "objective", driver: :unknown)
82
83
    assert {:error, :environment_not_admitted} =
84
             Run.new(context.repository, "objective", environment: :unknown)
85
86
    assert {:error, :repository_revision_invalid} =
87
             Run.new(context.repository, "objective", repository_revision: "main")
88
  end
89
90
  test "starts the read-only staging worker from bounded environment values", context do
91
    test_pid = self()
92
93
    environment = %{
94
      "SCV_REPOSITORY" => context.repository,
95
      "SCV_REPOSITORY_REVISION" => String.duplicate("b", 40),
96
      "SCV_OBJECTIVE" => "Inspect the fixture.",
97
      "SCV_DRIVER" => "opencode",
98
      "SCV_ENVIRONMENT" => "opencode-core",
99
      "SCV_PERMISSION_PROFILE" => "read_only",
100
      "SCV_MODEL" => "openai/test-model",
101
      "SCV_OUTPUT_ROOT" => context.output,
102
      "OPENAI_API_KEY" => "fixture-secret-key"
103
    }
104
105
    assert {:ok, result} =
106
             Worker.run(environment,
107
               event_sink: fn event -> send(test_pid, {:worker_event, event}) end,
108
               driver_options: [executable: context.executable, timeout_ms: 1_000]
109
             )
110
111
    assert result.status == "succeeded"
112
    assert result.runtime.permission_profile == "read_only"
113
    assert_receive {:worker_event, %{type: "process_started", driver: "opencode"}}
114
115
    assert {:error, {:environment_value_not_admitted, "SCV_PERMISSION_PROFILE"}} =
116
             Worker.run(Map.put(environment, "SCV_PERMISSION_PROFILE", "workspace_write"))
117
118
    assert {:error, {:environment_missing, "OPENAI_API_KEY"}} =
119
             Worker.run(Map.delete(environment, "OPENAI_API_KEY"))
120
  end
121
122
  defp fake_executable do
123
    """
124
    #!/bin/sh
125
    if [ "${OPENAI_API_KEY:-}" != "fixture-secret-key" ]; then exit 21; fi
126
    prompt=$(cat)
127
    if [ -z "$prompt" ]; then exit 22; fi
128
    printf '%s\n' '{"type":"step_start","timestamp":1,"sessionID":"ses_scv","part":{"type":"step-start"}}'
129
    printf '%s\n' '{"type":"step_finish","timestamp":2,"sessionID":"ses_scv","part":{"type":"step-finish","cost":0.001,"tokens":{"input":4,"output":2,"reasoning":1,"cache":{"read":0,"write":0}}}}'
130
    """
131
  end
132
end

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