Let an operator deploy SCVs that run on our own capacity

8b44c5a96df1 · AtlantisPleb · · parent af9e3c688b03

Let an operator deploy SCVs that run on our own capacity

Every way the agent runs code today ends on a machine the person paired
and powers, so the worst case is bounded by something we do not control.
An SCV ends on hardware we own and pay for, so this lane writes its
ceiling down instead of assuming one.

The two halves were already built and facing away from each other. The
work-job lane carries durability, the generation fence, recovery,
cancellation, and the report that lands back in the conversation; the SCV
contract carries capability admission, a sandboxed driver, sanitized
events, and per-run measurement. Rather than grow a second job system,
this adds an `scv` kind alongside `deep_work`, `delegation`, and `coding`,
and a worker shaped like `DelegationServer` that runs one bounded
`SCV.run/1` instead of one remote agent call. Metering goes into the same
`inference_grants` ledger the coding kind already uses, so "what did an
SCV cost" stays one query rather than two.

Admission lives in `SCV.Deployments.start/2`, the single entry point every
surface uses, so operator authority, the repository's identity, the exact
revision, the objective bound, and the concurrency ceiling cannot drift
between callers. A non-operator is refused there — in the code that spends
the capacity, not only in whatever advertised the tool — and the approval
receipt the surface policy demands is minted only for operators, so the
same call is refused twice, independently.

The model is the free OpenCode Zen model, `opencode/x-preview-f-free`. Two
executor changes follow from it: an OpenAI key is a per-provider
credential rather than a precondition of running OpenCode, so a model
served by another gateway is no longer blocked on a key it never reads;
and the model catalog fetch becomes an option, because that gateway
publishes its catalog rather than baking it into the binary, so a run with
the fetch disabled resolves no model at all. Tool permissions stay denied
either way. The release image now carries the OpenCode binary, pinned by
the same version and checksum the SCV worker image uses, next to the Codex
binary that the other SCV lane already runs as a child of this node.

Off by default, admitted only with the work lane, the tool catalog, and
bounds the runtime configuration validates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o8HwTaqLKEWCHTjsjFtrB
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

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 Dockerfile
  • modified INVARIANTS.md
  • modified config/config.exs
  • modified config/runtime.exs
  • modified docs/runtime-configuration.md
  • modified lib/openagents/runtime_config.ex
  • added lib/openagents/scv/deployments.ex
  • modified lib/openagents/scv/executor/open_code.ex
  • added lib/openagents/tools/scv_deploy.ex
  • modified lib/openagents/turns/turn_server.ex
  • modified lib/openagents/work.ex
  • modified lib/openagents/work/job.ex
  • modified lib/openagents/work/job_server.ex
  • added lib/openagents/work/scv.ex
  • added lib/openagents/work/scv_server.ex
  • added test/openagents/scv/deployments_test.exs
  • modified test/openagents/scv/open_code_executor_test.exs

Diff

17 files changed, +1567 -17

Dockerfile modified +21

@@ -18,6 +18,7 @@ ARG ESBUILD_VERSION=0.25.4

18 18
ARG ESBUILD_SHA256=93433b456cac3a454ee27403d3de9adce88d83e5439ba37e1471af54730c9ca7
19 19
ARG NODE_VERSION=24.15.0
20 20
ARG CODEX_VERSION=0.147.0
21
ARG OPENCODE_VERSION=1.18.5
21 22
22 23
ARG BUILDER_IMAGE="docker.io/hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}@sha256:ae38be7cb19bffa78adedb04732d9e6ba83a507b4cfb06983cbe711edb49da54"
23 24
ARG RUNNER_IMAGE="docker.io/debian:${DEBIAN_VERSION}@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3bb48c7f8e94f258"

@@ -140,6 +141,7 @@ FROM ${RUNNER_IMAGE} AS final

140 141
# resolve and the checksum still guards the result.
141 142
ARG TARGETARCH
142 143
ARG CODEX_VERSION
144
ARG OPENCODE_VERSION
143 145
ARG DEBIAN_SNAPSHOT
144 146
ARG SOURCE_DATE_EPOCH=0
145 147
ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}

@@ -175,6 +177,25 @@ RUN set -eu; \

175 177
  codex --version; \
176 178
  codex-code-mode-host --help >/dev/null
177 179
180
# OpenCode, for the SCV deployment lane (SCV-001). The Codex SCV lane already
181
# runs its binary as a child of this node; the OpenCode lane needs the same,
182
# pinned by version and checksum from the same release the SCV worker image
183
# uses, so both images run identical bytes.
184
RUN set -eu; \
185
  case "${TARGETARCH:-$(dpkg --print-architecture)}" in \
186
    amd64) opencode_arch=x64; checksum=cd4a2557a3d6550f27cb5c0257ebe8d73388bb34beda8b6121e6428a74c1eae2 ;; \
187
    arm64) opencode_arch=arm64; checksum=18b643362fdf0b8d5b8711b3e160dafb4e68d0bfc00288f56fd1298fd72da69d ;; \
188
    *) echo "Unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
189
  esac; \
190
  archive="opencode-linux-${opencode_arch}.tar.gz"; \
191
  curl -fsSL --retry 3 -o "/tmp/${archive}" \
192
    "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/${archive}"; \
193
  echo "${checksum}  /tmp/${archive}" | sha256sum --check --strict; \
194
  tar -xzf "/tmp/${archive}" -C /tmp; \
195
  install -D -m 0755 /tmp/opencode /usr/local/bin/opencode; \
196
  rm "/tmp/${archive}" /tmp/opencode; \
197
  opencode --version
198
178 199
# Set the locale
179 200
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \
180 201
  && locale-gen
INVARIANTS.md modified +64

@@ -1045,6 +1045,69 @@ typed refusals), `OpenAgents.Work.Coding`, `OpenAgents.Forge.Pushes` /

1045 1045
`OpenAgents.CodingJobTest`, and the repository tool tests in
1046 1046
`test/openagents/tools/repository_mutation_tools_test.exs`.
1047 1047
1048
### SCV-001 — An SCV spends our capacity only under operator authority and fixed bounds
1049
1050
Status: Current
1051
1052
An SCV deployment is the one lane where OpenAgents runs a coding agent on
1053
hardware we own and pay for, rather than on a machine the person paired and
1054
powers. Every other execution path is bounded by something outside our
1055
control; this one is not, so its ceiling is written down and enforced rather
1056
than assumed.
1057
1058
- **One entry point, and it is operator-only.** Every surface that starts an
1059
  SCV enters `OpenAgents.SCV.Deployments.start/2`, which refuses any account
1060
  that is not an OpenAgents operator with `:operator_required` before a row is
1061
  written or a process is spawned. The refusal lives in the code that starts
1062
  the run, not in whatever advertised it, so a model that calls the tool on
1063
  behalf of a signed-in non-operator is refused exactly as an unauthenticated
1064
  caller is. `sarah.tool.scv_deploy.v1` declares `external_effect` under the
1065
  `explicit_operator_approval` class, and the matching receipt is minted only
1066
  for operators, so `OpenAgents.Modules.SurfacePolicy` refuses the same call a
1067
  second time and independently.
1068
- **It is a work job, not a second job system.** The durable unit is a
1069
  `work_jobs` row of kind `scv`, so an SCV inherits the seven statuses, the
1070
  PostgreSQL transition triggers, the Horde cluster singleton, the
1071
  `owner_node`/`generation` fence, the startup recovery sweep, cancellation,
1072
  and the bounded report that lands in the conversation as a durable assistant
1073
  message. An interrupted SCV is finished honestly rather than resumed: a
1074
  killed coding-agent process has no session to re-attach, so a worker that
1075
  adopts a row at a bumped generation ends it `interrupted` instead of paying
1076
  for the same objective twice.
1077
- **Four bounds, fixed at admission.** The objective is capped at 2,000 bytes;
1078
  the wall clock and the captured-output ceiling are snapshotted onto the row
1079
  when the run is admitted, so a configuration change mid-run cannot widen a
1080
  run already in flight; the executor enforces the wall clock and this
1081
  application independently backstops it; and the number of SCVs queued or
1082
  running across the whole application is capped by configuration. A tripped
1083
  concurrency ceiling refuses the call with `:scv_capacity_reached` rather
1084
  than queueing unbounded work.
1085
- **It reads; it does not write.** The run is admitted only under the
1086
  `read_only` permission profile in the `opencode-core` environment, against a
1087
  disposable clone of a forge repository at an exact 40-character revision
1088
  resolved by the application. The caller names a repository the operator may
1089
  read as `owner/name`; a filesystem path from a caller never reaches an SCV.
1090
  The workspace is removed on every terminal path, including the one that runs
1091
  when the worker died.
1092
- **No job may deploy one.** `scv.deploy` is a turn authority only. Job
1093
  authorities never include it, so neither a deep-work job, a delegation, a
1094
  coding job, nor an SCV can start another SCV.
1095
- **It is metered and visible.** Token usage is recorded into the shared
1096
  `inference_grants` ledger, the same one the coding kind uses, so "how much
1097
  did an SCV spend" is a query. Each run's lifecycle events reach the
1098
  content-free public projection on the status page through the existing
1099
  `[:openagents, :scv, :event]` telemetry, and every non-completed terminal is
1100
  recorded as a typed incident.
1101
- **It is off by default.** The lane is admitted only when the `scv_deploy`
1102
  feature is enabled, which `OpenAgents.RuntimeConfig` accepts only alongside
1103
  the work lane and tools, only with an admitted model slug and bounds, and
1104
  only above the staging gate that admits advanced product features.
1105
1106
Evidence: `OpenAgents.SCV.Deployments`, `OpenAgents.Work.Scv`,
1107
`OpenAgents.Work.ScvServer`, `OpenAgents.Tools.ScvDeploy`, `OpenAgents.Work.Job`
1108
(the `scv` kind), `OpenAgents.RuntimeConfig`, and
1109
`test/openagents/scv/deployments_test.exs`.
1110
1048 1111
## Interface and release
1049 1112
1050 1113
### VOICE-001 — Spoken identity is admitted before media

@@ -1889,6 +1952,7 @@ contract; the invariant prose above defines the assertion, not the filename.

1889 1952
| DEGRADE-002 | `test/openagents/tools/registry_and_runner_test.exs`, `test/openagents/tools/conversation_recall_tools_test.exs` |
1890 1953
| WORK-001 | `test/openagents/work_job_test.exs`, `test/openagents/deep_work_tool_loop_test.exs` |
1891 1954
| SELF-EDIT-001 | `test/openagents/tools/repository_mutation_tools_test.exs`, `test/openagents/coding_job_test.exs` |
1955
| SCV-001 | `test/openagents/scv/deployments_test.exs` |
1892 1956
| VOICE-001 | `test/openagents/voice/config_test.exs` |
1893 1957
| VOICE-002 | `test/openagents_web/controllers/voice_call_controller_test.exs` |
1894 1958
| VOICE-003 | `test/openagents/voice_test.exs`, `test/openagents/voice_sessions_test.exs` |
config/config.exs modified +13 -1

@@ -68,6 +68,17 @@ config :openagents,

68 68
    temporary_root: System.tmp_dir!(),
69 69
    client_options: []
70 70
  ],
71
  scv_deploy: [
72
    enabled: false,
73
    model: "opencode/x-preview-f-free",
74
    reasoning_effort: "low",
75
    opencode_api_key: nil,
76
    executable: nil,
77
    concurrency_limit: 2,
78
    wall_clock_ms: 900_000,
79
    maximum_output_bytes: 16_777_216,
80
    output_root: "/var/lib/openagents/scv/opencode-runs"
81
  ],
71 82
  tools_enabled: true,
72 83
  voice: [
73 84
    enabled: false,

@@ -153,7 +164,8 @@ config :openagents,

153 164
    OpenAgents.Tools.CodeCheck,
154 165
    OpenAgents.Tools.RepoEdit,
155 166
    OpenAgents.Tools.RepoWrite,
156
    OpenAgents.Tools.RepoCommitPush
167
    OpenAgents.Tools.RepoCommitPush,
168
    OpenAgents.Tools.ScvDeploy
157 169
  ],
158 170
  conversation_reset_enabled: false,
159 171
  github_api: [
config/runtime.exs modified +19

@@ -199,6 +199,24 @@ if config_env() == :prod and runtime_role == :web do

199 199
    raise "environment variable OPENAGENTS_SCV_CODEX_CREDENTIAL_REFS is required when Codex SCV accounts are enabled"
200 200
  end
201 201
202
  scv_deploy_enabled = parse_optional_boolean.("OPENAGENTS_FEATURE_SCV_DEPLOY")
203
204
  scv_deploy_defaults = Application.fetch_env!(:openagents, :scv_deploy)
205
206
  scv_deploy = [
207
    enabled: scv_deploy_enabled,
208
    model: optional_text.("OPENAGENTS_SCV_DEPLOY_MODEL") || scv_deploy_defaults[:model],
209
    reasoning_effort: scv_deploy_defaults[:reasoning_effort],
210
    opencode_api_key: optional_text.("OPENAGENTS_SCV_DEPLOY_OPENCODE_API_KEY"),
211
    executable:
212
      optional_text.("OPENAGENTS_SCV_DEPLOY_OPENCODE_BIN") || scv_deploy_defaults[:executable],
213
    concurrency_limit: scv_deploy_defaults[:concurrency_limit],
214
    wall_clock_ms: scv_deploy_defaults[:wall_clock_ms],
215
    maximum_output_bytes: scv_deploy_defaults[:maximum_output_bytes],
216
    output_root:
217
      optional_text.("OPENAGENTS_SCV_DEPLOY_OUTPUT_ROOT") || scv_deploy_defaults[:output_root]
218
  ]
219
202 220
  scv_codex = [
203 221
    enabled: scv_codex_enabled,
204 222
    execution_reaper_enabled: scv_codex_enabled,

@@ -342,6 +360,7 @@ if config_env() == :prod and runtime_role == :web do

342 360
    work: work,
343 361
    work_workers_enabled: work_enabled,
344 362
    scv_codex: scv_codex,
363
    scv_deploy: scv_deploy,
345 364
    semantic_index: semantic_index,
346 365
    experience_memory: experience_memory,
347 366
    graph_memory: graph_memory,
docs/runtime-configuration.md modified +22 -1

@@ -101,6 +101,25 @@ identity access to unrelated SCV, Forge, or deployment credentials.

101 101
Implement ChatGPT service accounts after this individual operator flow passes
102 102
qualification. Service accounts are available only for pay-as-you-go plans.
103 103
104
## SCV deployment settings
105
106
The `scv_deploy` feature admits one bounded OpenCode run per SCV on OpenAgents
107
capacity, started only by an operator through `OpenAgents.SCV.Deployments`. See
108
INVARIANTS.md SCV-001. Bounds live in configuration rather than in a caller's
109
arguments, and the compiled defaults are the safe values.
110
111
| Environment setting | Requirement |
112
| --- | --- |
113
| `OPENAGENTS_FEATURE_SCV_DEPLOY` | `true` to admit the lane; otherwise `false` or empty |
114
| `OPENAGENTS_SCV_DEPLOY_MODEL` | Model slug as `provider/model`; defaults to the free OpenCode Zen model `opencode/x-preview-f-free` |
115
| `OPENAGENTS_SCV_DEPLOY_OPENCODE_BIN` | Absolute path to the pinned OpenCode executable; the release image uses `/usr/local/bin/opencode` |
116
| `OPENAGENTS_SCV_DEPLOY_OPENCODE_API_KEY` | Optional OpenCode gateway key; the default model runs without one |
117
| `OPENAGENTS_SCV_DEPLOY_OUTPUT_ROOT` | Durable directory for run artifacts; must not be under `/tmp` |
118
119
The compiled defaults cap concurrency at two simultaneous SCVs, the wall clock
120
at 15 minutes, and captured output at 16 MB. The lane runs read-only against a
121
disposable clone of a forge repository at an exact revision.
122
104 123
## Required release settings
105 124
106 125
All settings in this section are mandatory in a production release unless

@@ -166,6 +185,7 @@ it does not enable it automatically.

166 185
| Tool embeddings | `OPENAGENTS_FEATURE_TOOL_EMBEDDINGS` | Off | Off | 14 |
167 186
| Conversation reset | `OPENAGENTS_FEATURE_CONVERSATION_RESET` | Off | Off | 14 |
168 187
| Incident fixer | `OPENAGENTS_FEATURE_INCIDENT_FIXER` | Off | Off | 14 |
188
| SCV deployment | `OPENAGENTS_FEATURE_SCV_DEPLOY` | Off | Off | 14 |
169 189
| Turn recovery | `OPENAGENTS_FEATURE_TURN_RECOVERY` | Off | Off | 8 |
170 190
| Forge Git service | `OPENAGENTS_FEATURE_FORGE` | Off | Off | 12 |
171 191
| Forge deployment | `OPENAGENTS_FEATURE_FORGE_DEPLOY` | Off | Off | 13 |

@@ -175,7 +195,8 @@ it does not enable it automatically.

175 195
176 196
Invalid combinations fail closed. Recording requires voice and its encryption
177 197
key; retention requires recording; work and its recovery worker move together;
178
the incident fixer requires computers; deployment requires the forge; boot
198
the incident fixer requires computers; SCV deployment requires the work lane,
199
the tool catalog, and admitted bounds; deployment requires the forge; boot
179 200
convergence requires deployment; and distributed features require Horde,
180 201
discovery, node identity, cookie, and bounded distribution ports.
181 202
lib/openagents/runtime_config.ex modified +32 -1

@@ -359,6 +359,7 @@ defmodule OpenAgents.RuntimeConfig do

359 359
         {:ok, conversation_reset?} <-
360 360
           required_boolean(settings, :conversation_reset_enabled),
361 361
         {:ok, incident_fixer?} <- required_boolean(settings, :incident_fixer_enabled),
362
         {:ok, scv_deploy?} <- nested_boolean(settings, :scv_deploy, :enabled),
362 363
         {:ok, ra?} <- required_boolean(settings, :ra_enabled) do
363 364
      features = %{
364 365
        tools: tools?,

@@ -381,6 +382,7 @@ defmodule OpenAgents.RuntimeConfig do

381 382
        work_workers: work_workers?,
382 383
        conversation_reset: conversation_reset?,
383 384
        incident_fixer: incident_fixer?,
385
        scv_deploy: scv_deploy?,
384 386
        ra: ra?
385 387
      }
386 388

@@ -406,7 +408,8 @@ defmodule OpenAgents.RuntimeConfig do

406 408
          :shadow_programs,
407 409
          :tool_embeddings,
408 410
          :conversation_reset,
409
          :incident_fixer
411
          :incident_fixer,
412
          :scv_deploy
410 413
        ],
411 414
        &features[&1]
412 415
      )

@@ -444,6 +447,18 @@ defmodule OpenAgents.RuntimeConfig do

444 447
      features.incident_fixer and not features.computers ->
445 448
        error(:incident_fixer_enabled, "requires computers")
446 449
450
      # An SCV deployment IS a work job (SCV-001): without the work lane there
451
      # is no durable row, no recovery sweep, and no report back into the
452
      # conversation, so the run would spend our capacity unaccountably.
453
      features.scv_deploy and not features.work ->
454
        error(:scv_deploy, "requires the work lane")
455
456
      features.scv_deploy and not features.tools ->
457
        error(:scv_deploy, "requires tools")
458
459
      features.scv_deploy and not valid_scv_deploy?(Map.get(settings, :scv_deploy)) ->
460
        error(:scv_deploy, "requires an admitted model, bounds, and output root")
461
447 462
      features.forge_deploy and not features.forge ->
448 463
        error(:forge_deploy_lane_enabled, "requires the forge")
449 464

@@ -995,6 +1010,22 @@ defmodule OpenAgents.RuntimeConfig do

995 1010
996 1011
  defp encryption_key?(_value), do: false
997 1012
1013
  # The SCV deploy lane spends our own capacity, so its ceiling is configuration
1014
  # rather than a runtime argument: a bounded model slug, a bounded concurrency
1015
  # limit, a wall clock, an output cap, and a durable place to keep artifacts.
1016
  defp valid_scv_deploy?(settings) when is_list(settings) do
1017
    model = keyword_value(settings, :model)
1018
1019
    is_binary(model) and byte_size(model) in 3..128 and
1020
      Regex.match?(~r{\A[a-zA-Z0-9_.:-]+/[a-zA-Z0-9_.:-]+\z}, model) and
1021
      keyword_integer_in?(settings, :concurrency_limit, 1..8) and
1022
      keyword_integer_in?(settings, :wall_clock_ms, 60_000..3_600_000) and
1023
      keyword_integer_in?(settings, :maximum_output_bytes, 65_536..67_108_864) and
1024
      durable_path?(keyword_value(settings, :output_root))
1025
  end
1026
1027
  defp valid_scv_deploy?(_settings), do: false
1028
998 1029
  defp durable_path?(path) when is_binary(path) do
999 1030
    Path.type(path) == :absolute and path != "/" and
1000 1031
      not (path == "/tmp" or String.starts_with?(path, "/tmp/"))
lib/openagents/scv/deployments.ex added +206

@@ -0,0 +1,206 @@

1
defmodule OpenAgents.SCV.Deployments do
2
  @moduledoc """
3
  The one admitted entry point for deploying an SCV on our own capacity
4
  (SCV-001).
5
6
  Every surface that can start an SCV — Sarah's `scv_deploy` tool today, an
7
  operator surface tomorrow — enters here, so operator authority, the
8
  repository's identity, the exact revision, the objective bound, and the
9
  concurrency ceiling cannot drift apart between callers. This mirrors
10
  `OpenAgents.ComputerAgentJobs`, which does the same job for delegations to a
11
  person's own machine.
12
13
  Two facts make this lane different from every other tool Sarah holds, and
14
  both are enforced here rather than described:
15
16
  - **It spends our capacity, not the caller's.** A delegation ends on hardware
17
    the person owns and powers; an SCV ends on ours. So the authority required
18
    is operator authority — `OpenAgents.Accounts.admin?/1` — checked against the
19
    account behind the conversation, in the code that starts the run, not only
20
    in whatever advertised the tool.
21
  - **It is bounded before it starts.** The objective is capped, the wall clock
22
    and output ceiling are snapshotted onto the row at admission, and the number
23
    of SCVs running at once across the whole application is capped, so a model
24
    that decides to deploy in a loop is refused at the second or third call
25
    rather than at the invoice.
26
27
  The run itself is a `work_jobs` row of kind `scv`; nothing here is a second
28
  job system.
29
  """
30
31
  import Ecto.Query
32
33
  alias OpenAgents.Accounts
34
  alias OpenAgents.Accounts.User
35
  alias OpenAgents.Forge.Repos
36
  alias OpenAgents.Repo
37
  alias OpenAgents.Repositories.Membership
38
  alias OpenAgents.Repositories.Repository
39
  alias OpenAgents.Work
40
  alias OpenAgents.Work.Job
41
  alias OpenAgents.Work.Scv
42
43
  @active_statuses ~w(queued running)
44
45
  @doc """
46
  Start one bounded SCV deployment for an operator.
47
48
  Returns `{:ok, job}` with a queued-or-running `work_jobs` row, or a typed
49
  refusal. The caller acknowledges the job reference immediately; the run
50
  reports back into the conversation when it ends.
51
  """
52
  @spec start(User.t(), map()) :: {:ok, Job.t()} | {:error, atom()}
53
  def start(%User{} = user, attributes) when is_map(attributes) do
54
    with :ok <- feature_enabled(),
55
         :ok <- operator(user),
56
         {:ok, objective} <- objective(attributes),
57
         {:ok, conversation_id} <- identifier(attributes, :conversation_id),
58
         {:ok, owner_visitor_id} <- identifier(attributes, :owner_visitor_id),
59
         {:ok, repository} <- repository(user, attributes),
60
         {:ok, revision} <- revision(repository),
61
         :ok <- capacity() do
62
      Work.start_scv(%{
63
        conversation_id: conversation_id,
64
        owner_visitor_id: owner_visitor_id,
65
        surface: surface(attributes),
66
        goal: objective,
67
        delegation: %{
68
          "objective" => objective,
69
          "repository_path" => "#{repository.owner}/#{repository.name}"
70
        },
71
        authority_snapshot:
72
          Scv.authority_snapshot(%{owner: user, repository: repository, revision: revision}),
73
        budget_snapshot: Scv.budget_snapshot()
74
      })
75
    end
76
  end
77
78
  def start(_user, _attributes), do: {:error, :operator_required}
79
80
  @doc "How many SCV deployments are queued or running right now."
81
  @spec active_count() :: non_neg_integer()
82
  def active_count do
83
    Repo.aggregate(
84
      from(job in Job, where: job.kind == ^Scv.kind() and job.status in ^@active_statuses),
85
      :count
86
    )
87
  end
88
89
  @doc """
90
  The approval receipts that admit the SCV deployment module for one operator.
91
92
  Operating the SCV lane is an operator act, so the receipt carries
93
  `explicit_operator_approval` and points at the operator account. A
94
  non-operator receives no receipt at all, which is what makes
95
  `OpenAgents.Modules.SurfacePolicy` refuse the call a second time,
96
  independently of the check in `start/2`.
97
  """
98
  @spec approval_receipts(User.t() | nil, String.t()) :: [map()]
99
  def approval_receipts(user, scope_ref) when is_binary(scope_ref) do
100
    if Accounts.admin?(user) do
101
      [
102
        %{
103
          "schema" => "sarah.module_approval.v1",
104
          "approval_class" => "explicit_operator_approval",
105
          "module_id" => "sarah.tool.scv_deploy.v1",
106
          "version" => 1,
107
          "scope_ref" => scope_ref,
108
          "explicit" => true,
109
          "actor_type" => "operator",
110
          "receipt_ref" => "operator:#{user.id}"
111
        }
112
      ]
113
    else
114
      []
115
    end
116
  end
117
118
  # ── admission ──────────────────────────────────────────────────────────────
119
120
  defp feature_enabled do
121
    if Scv.enabled?(), do: :ok, else: {:error, :scv_deploy_disabled}
122
  end
123
124
  defp operator(user) do
125
    if Accounts.admin?(user), do: :ok, else: {:error, :operator_required}
126
  end
127
128
  defp objective(attributes) do
129
    case Map.get(attributes, :objective) do
130
      value when is_binary(value) ->
131
        trimmed = String.trim(value)
132
133
        if trimmed != "" and byte_size(trimmed) <= Scv.maximum_objective_bytes(),
134
          do: {:ok, trimmed},
135
          else: {:error, :scv_objective_invalid}
136
137
      _missing ->
138
        {:error, :scv_objective_invalid}
139
    end
140
  end
141
142
  defp identifier(attributes, key) do
143
    case Map.get(attributes, key) do
144
      value when is_binary(value) -> {:ok, value}
145
      _missing -> {:error, :scope_refused}
146
    end
147
  end
148
149
  defp surface(attributes) do
150
    case Map.get(attributes, :surface) do
151
      value when value in ["text", "voice"] -> value
152
      _other -> "text"
153
    end
154
  end
155
156
  # The repository is named the way a person names it, and resolved to a row
157
  # the operator may actually read. An SCV never reaches a repository through a
158
  # filesystem path the caller supplied.
159
  defp repository(user, attributes) do
160
    with path when is_binary(path) <- Map.get(attributes, :repository),
161
         [owner, name] <- String.split(String.trim(path), "/", parts: 2),
162
         %Repository{lifecycle_state: "ready"} = repository <- readable(user, owner, name) do
163
      {:ok, repository}
164
    else
165
      _unavailable -> {:error, :scv_repository_not_found}
166
    end
167
  end
168
169
  defp readable(%User{id: user_id}, owner, name) do
170
    owner_key = String.downcase(owner)
171
    name_key = String.downcase(name)
172
173
    Repo.one(
174
      from repository in Repository,
175
        left_join: membership in Membership,
176
        on: membership.repository_id == repository.id and membership.user_id == ^user_id,
177
        where:
178
          repository.owner_key == ^owner_key and repository.name_key == ^name_key and
179
            (repository.visibility == "public" or not is_nil(membership.user_id))
180
    )
181
  end
182
183
  defp revision(%Repository{} = repository) do
184
    if Repos.valid_storage_key?(repository.storage_key) do
185
      refs = Repos.refs(repository.storage_key)
186
187
      case Map.get(refs, "refs/heads/#{repository.default_branch}") do
188
        sha when is_binary(sha) ->
189
          if Regex.match?(~r/\A[0-9a-f]{40}\z/, sha),
190
            do: {:ok, sha},
191
            else: {:error, :scv_repository_revision_unavailable}
192
193
        _missing ->
194
          {:error, :scv_repository_revision_unavailable}
195
      end
196
    else
197
      {:error, :scv_repository_not_found}
198
    end
199
  end
200
201
  defp capacity do
202
    if active_count() < Scv.concurrency_limit(),
203
      do: :ok,
204
      else: {:error, :scv_capacity_reached}
205
  end
206
end
lib/openagents/scv/executor/open_code.ex modified +39 -7

@@ -57,6 +57,8 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

57 57
    run_id = Keyword.get(options, :run_id, Ecto.UUID.generate())
58 58
    output_root = Keyword.get(options, :output_root, default_output_root())
59 59
    api_key = Keyword.get(options, :api_key, System.get_env("OPENAI_API_KEY"))
60
    opencode_api_key = Keyword.get(options, :opencode_api_key)
61
    models_fetch = Keyword.get(options, :models_fetch, false)
60 62
    executable = Keyword.get(options, :executable, default_executable())
61 63
    config_seed = Keyword.get(options, :config_seed)
62 64
    diagnostic_logs = Keyword.get(options, :diagnostic_logs, false)

@@ -85,7 +87,9 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

85 87
         :ok <- validate_permissions(permissions),
86 88
         :ok <- validate_run_id(run_id),
87 89
         {:ok, output_root} <- validate_output_root(output_root),
88
         :ok <- validate_api_key(api_key),
90
         :ok <- validate_api_key(model, api_key),
91
         :ok <- validate_optional_api_key(opencode_api_key),
92
         :ok <- validate_boolean(models_fetch, :models_fetch_invalid),
89 93
         :ok <- validate_boolean(diagnostic_logs, :diagnostic_logs_invalid),
90 94
         :ok <-
91 95
           validate_integer(

@@ -114,6 +118,8 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

114 118
         run_id: run_id,
115 119
         output_root: output_root,
116 120
         api_key: api_key,
121
         opencode_api_key: opencode_api_key,
122
         models_fetch: models_fetch,
117 123
         executable: executable,
118 124
         config_seed: config_seed,
119 125
         diagnostic_logs: diagnostic_logs,

@@ -271,7 +277,7 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

271 277
      input.sample_interval_ms,
272 278
      input.maximum_output_bytes,
273 279
      sample_fun,
274
      [input.api_key]
280
      [input.api_key, input.opencode_api_key]
275 281
    )
276 282
  rescue
277 283
    error ->

@@ -593,6 +599,7 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

593 599
        executable: input.executable,
594 600
        model: input.model,
595 601
        reasoning_effort: input.reasoning_effort,
602
        models_fetch: input.models_fetch,
596 603
        diagnostic_logs: input.diagnostic_logs,
597 604
        config_seeded: not is_nil(input.config_seed),
598 605
        permission_profile: Atom.to_string(input.permissions),

@@ -679,7 +686,6 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

679 686
      "LC_ALL" => "C.UTF-8",
680 687
      "LOGNAME" => "scv",
681 688
      "NO_COLOR" => "1",
682
      "OPENAI_API_KEY" => input.api_key,
683 689
      "OPENCODE_CLIENT" => "scv",
684 690
      "OPENCODE_CONFIG_CONTENT" => Jason.encode!(config),
685 691
      "OPENCODE_CONFIG_DIR" => paths.config,

@@ -690,7 +696,7 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

690 696
      "OPENCODE_DISABLE_EMBEDDED_WEB_UI" => "1",
691 697
      "OPENCODE_DISABLE_EXTERNAL_SKILLS" => "1",
692 698
      "OPENCODE_DISABLE_LSP_DOWNLOAD" => "1",
693
      "OPENCODE_DISABLE_MODELS_FETCH" => "1",
699
      "OPENCODE_DISABLE_MODELS_FETCH" => if(input.models_fetch, do: "0", else: "1"),
694 700
      "OPENCODE_DISABLE_PROJECT_CONFIG" => "1",
695 701
      "OPENCODE_DISABLE_SHARE" => "1",
696 702
      "OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER" => "1",

@@ -712,8 +718,17 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

712 718
    System.get_env()
713 719
    |> Map.new(fn {key, _value} -> {key, false} end)
714 720
    |> Map.merge(safe)
721
    |> put_credential("OPENAI_API_KEY", input.api_key)
722
    |> put_credential("OPENCODE_API_KEY", input.opencode_api_key)
715 723
  end
716 724
725
  # A credential this run does not hold stays scrubbed to `false` rather than
726
  # exported empty, so the child process cannot mistake "" for a usable key.
727
  defp put_credential(environment, key, value) when is_binary(value) and value != "",
728
    do: Map.put(environment, key, value)
729
730
  defp put_credential(environment, _key, _value), do: environment
731
717 732
  defp port_environment(environment) do
718 733
    Enum.map(environment, fn
719 734
      {key, false} -> {String.to_charlist(key), false}

@@ -874,10 +889,27 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

874 889
875 890
  defp validate_output_root(_output_root), do: {:error, :output_root_invalid}
876 891
877
  defp validate_api_key(api_key) when is_binary(api_key) and byte_size(api_key) in 8..16_384,
878
    do: :ok
892
  # The OpenAI key is a per-provider credential, not a precondition of running
893
  # OpenCode. An `openai/...` model cannot answer without it; a model served by
894
  # any other provider (the OpenCode Zen gateway, for instance) must not be
895
  # blocked on a credential it never reads.
896
  defp validate_api_key("openai/" <> _model, api_key), do: validate_provider_key(api_key)
897
  defp validate_api_key(_model, nil), do: :ok
898
  defp validate_api_key(_model, api_key), do: validate_provider_key(api_key)
899
900
  defp validate_provider_key(api_key)
901
       when is_binary(api_key) and byte_size(api_key) in 8..16_384,
902
       do: :ok
903
904
  defp validate_provider_key(_api_key), do: {:error, :openai_api_key_missing}
905
906
  defp validate_optional_api_key(nil), do: :ok
907
908
  defp validate_optional_api_key(api_key)
909
       when is_binary(api_key) and byte_size(api_key) in 8..16_384,
910
       do: :ok
879 911
880
  defp validate_api_key(_api_key), do: {:error, :openai_api_key_missing}
912
  defp validate_optional_api_key(_api_key), do: {:error, :opencode_api_key_invalid}
881 913
882 914
  defp validate_config_seed(nil), do: {:ok, nil}
883 915
lib/openagents/tools/scv_deploy.ex added +146

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

1
defmodule OpenAgents.Tools.ScvDeploy do
2
  @moduledoc """
3
  First-party `scv_deploy.v1`: deploys an OpenCode SCV on OpenAgents capacity.
4
5
  Every other way Sarah runs code ends on a machine the person owns. This one
6
  ends on ours: a bounded OpenCode run against an exact revision of a
7
  repository in our own forge, under the read-only permission profile, on the
8
  admitted model. Because it spends our capacity rather than the caller's, it
9
  is operator-only, and the refusal lives in
10
  `OpenAgents.SCV.Deployments.start/2` — the code that starts the run — not in
11
  this tool's description.
12
13
  The call returns immediately with a job reference. The SCV works in a durable
14
  background job and its bounded report lands back in the conversation as a
15
  message when it ends.
16
  """
17
18
  @behaviour OpenAgents.Tools.Tool
19
20
  alias OpenAgents.Modules.Metadata
21
  alias OpenAgents.SCV.Deployments
22
  alias OpenAgents.Tools.{ExecutionResult, OwnerContext, Tool}
23
  alias OpenAgents.Work.Scv
24
25
  @impl true
26
  def specification do
27
    %Tool{
28
      module_id: "sarah.tool.scv_deploy.v1",
29
      name: "scv_deploy",
30
      version: 1,
31
      description:
32
        "Deploys an SCV — a bounded OpenCode coding agent that runs on OpenAgents " <>
33
          "capacity rather than on anyone's own computer — against a repository in " <>
34
          "the OpenAgents forge. OPERATOR ONLY: a request from anyone who is not an " <>
35
          "OpenAgents operator is refused. Name the repository as owner/name; it runs " <>
36
          "read-only against the current head of its default branch. Returns " <>
37
          "immediately with a job reference: acknowledge briefly, do NOT wait, and " <>
38
          "the SCV's report posts back into this conversation when it finishes. " <>
39
          "Prefer computer_agent when the person wants work on their own machine.",
40
      input_schema: input_schema(),
41
      output_schema: output_schema(),
42
      side_effect: :external_effect,
43
      required_scope: "browser_conversation",
44
      required_authority: "scv.deploy",
45
      executor: %{
46
        id: "sarah.scv.opencode",
47
        disclosure: "A bounded OpenCode SCV running on OpenAgents capacity"
48
      },
49
      maintainer: "OpenAgents",
50
      attribution: ["OpenAgentsInc/openagents.com", "anomalyco/opencode"],
51
      policy_facets: %{
52
        "privacy" => "signed_browser_owner",
53
        "residency" => "openagents_capacity",
54
        "consent" => "operator_authority"
55
      },
56
      module_metadata:
57
        Metadata.first_party("scv.deploy", "browser_conversation",
58
          effect: :external_effect,
59
          approval_class: "explicit_operator_approval",
60
          privacy: "signed_browser_owner",
61
          residency: "openagents_capacity"
62
        ),
63
      timeout_ms: 15_000,
64
      maximum_input_bytes: 4_096,
65
      maximum_output_bytes: 4_096,
66
      implementation: __MODULE__,
67
      tags: ~w(scv deploy opencode coding agent capacity operator admin)
68
    }
69
  end
70
71
  @impl true
72
  def execute(%{"repository" => repository, "objective" => objective}, context)
73
      when is_binary(repository) and is_binary(objective) do
74
    # The owner behind this conversation is resolved from the application's own
75
    # records, never from an argument the model supplied.
76
    with {:ok, user} <- OwnerContext.resolve(context) do
77
      case Deployments.start(user, %{
78
             conversation_id: context.conversation_id,
79
             owner_visitor_id: context.owner_visitor_id,
80
             surface: context.surface,
81
             repository: repository,
82
             objective: objective
83
           }) do
84
        {:ok, job} ->
85
          {:ok,
86
           %ExecutionResult{
87
             result: %{
88
               "schema" => "sarah.scv_deploy_started.v1",
89
               "job_ref" => "work-job:#{job.id}",
90
               "status" => "started",
91
               "repository" => repository,
92
               "model" => Scv.model()
93
             },
94
             target_receipt_refs: ["work-job:#{job.id}"]
95
           }}
96
97
        {:error, reason} when is_atom(reason) ->
98
          {:error, reason}
99
100
        {:error, _changeset} ->
101
          {:error, :scv_deploy_start_failed}
102
      end
103
    end
104
  end
105
106
  def execute(_arguments, _context), do: {:error, :scv_deploy_request_invalid}
107
108
  defp input_schema do
109
    %{
110
      "type" => "object",
111
      "properties" => %{
112
        "repository" => %{
113
          "type" => "string",
114
          "maxLength" => 128,
115
          "description" =>
116
            "The forge repository to work in, as owner/name, for example OpenAgentsInc/openagents.com"
117
        },
118
        "objective" => %{
119
          "type" => "string",
120
          "maxLength" => Scv.maximum_objective_bytes(),
121
          "description" =>
122
            "The complete objective for the SCV, self-contained enough to run without this conversation"
123
        }
124
      },
125
      "required" => ["repository", "objective"],
126
      "additionalProperties" => false
127
    }
128
  end
129
130
  defp output_schema do
131
    %{
132
      "type" => "object",
133
      "properties" => %{
134
        "schema" => string(64),
135
        "job_ref" => string(128),
136
        "status" => string(16),
137
        "repository" => string(128),
138
        "model" => string(128)
139
      },
140
      "required" => ["schema", "job_ref", "status", "repository", "model"],
141
      "additionalProperties" => false
142
    }
143
  end
144
145
  defp string(maximum), do: %{"type" => "string", "maxLength" => maximum}
146
end
lib/openagents/turns/turn_server.ex modified +15 -4

@@ -802,13 +802,23 @@ defmodule OpenAgents.Turns.TurnServer do

802 802
    }
803 803
  end
804 804
805
  # Approval receipts admitted for this turn's owner: one per paired machine
806
  # (the pairing IS the operator approval), plus the SCV deployment receipt
807
  # when — and only when — this account is an OpenAgents operator. A
808
  # non-operator turn simply carries no SCV receipt, so `SurfacePolicy` refuses
809
  # the call independently of the check inside `SCV.Deployments.start/2`.
805 810
  defp machine_approval_receipts(state) do
806
    Machines.approval_receipts(
807
      state.owner.user_id,
808
      "conversation:#{state.turn.conversation_id}"
809
    )
811
    scope_ref = "conversation:#{state.turn.conversation_id}"
812
813
    Machines.approval_receipts(state.owner.user_id, scope_ref) ++
814
      OpenAgents.SCV.Deployments.approval_receipts(owner_account(state), scope_ref)
810 815
  end
811 816
817
  defp owner_account(%{owner: %{user_id: user_id}}) when is_binary(user_id),
818
    do: OpenAgents.Repo.get(OpenAgents.Accounts.User, user_id)
819
820
  defp owner_account(_state), do: nil
821
812 822
  defp execution_authorities,
813 823
    do:
814 824
      MapSet.new([

@@ -818,6 +828,7 @@ defmodule OpenAgents.Turns.TurnServer do

818 828
        "memory.read",
819 829
        "memory.write",
820 830
        "module.discover",
831
        "scv.deploy",
821 832
        "work.delegate"
822 833
      ])
823 834
lib/openagents/work.ex modified +51 -1

@@ -110,6 +110,28 @@ defmodule OpenAgents.Work do

110 110
    start_job(Map.put(attributes, :kind, "coding"))
111 111
  end
112 112
113
  @doc """
114
  Start a durable SCV deployment (SCV-001): one bounded OpenCode run on our own
115
  capacity, driven by `OpenAgents.Work.ScvServer` rather than by the model
116
  loop, sharing the same row, statuses, fence, recovery sweep, and
117
  report-into-conversation ending as every other kind.
118
119
  Admission belongs to `OpenAgents.SCV.Deployments.start/2`; this only creates
120
  the row and starts the worker.
121
  """
122
  def start_scv(attributes) when is_map(attributes) do
123
    with {:ok, job} <- create_job(Map.put(attributes, :kind, "scv")) do
124
      case start_worker(OpenAgents.Work.ScvServer, job.id) do
125
        {:ok, _pid} ->
126
          {:ok, job}
127
128
        {:error, reason} ->
129
          _failure = finish_job(job.id, "failed", error_code: "worker_start_failed")
130
          {:error, reason}
131
      end
132
    end
133
  end
134
113 135
  # Start a job's worker as a cluster-wide singleton under Horde. Horde routes
114 136
  # the child to whichever member `choose_node` picks and relocates it to a
115 137
  # survivor if that node dies. `{:already_started, pid}` is success: the

@@ -510,6 +532,9 @@ defmodule OpenAgents.Work do

510 532
      |> Multi.run(:coding_grant, fn _repo, %{job: job} ->
511 533
        OpenAgents.Work.Coding.settle_grant(job)
512 534
      end)
535
      |> Multi.run(:scv_grant, fn _repo, %{job: job} ->
536
        OpenAgents.Work.Scv.settle_grant(job)
537
      end)
513 538
      |> Repo.transaction()
514 539
515 540
    case result do

@@ -518,8 +543,10 @@ defmodule OpenAgents.Work do

518 543
        broadcast_job(job)
519 544
        _injection = deliver_live_voice_report(job, message)
520 545
        # Kind-specific terminal cleanup (this path runs even when the worker
521
        # died): a coding job removes its clone and settles its grant.
546
        # died): a coding job removes its clone and settles its grant, and an
547
        # SCV deployment removes its disposable workspace.
522 548
        :ok = OpenAgents.Work.Coding.on_terminal(job)
549
        :ok = OpenAgents.Work.Scv.on_terminal(job)
523 550
        {:ok, job}
524 551
525 552
      {:error, :admission, {:already_terminal, job}, _changes} ->

@@ -634,6 +661,7 @@ defmodule OpenAgents.Work do

634 661
  defp recovery_error_code(_reason), do: "worker_start_failed"
635 662
636 663
  defp worker_module("delegation"), do: OpenAgents.Work.DelegationServer
664
  defp worker_module("scv"), do: OpenAgents.Work.ScvServer
637 665
  defp worker_module(_kind), do: OpenAgents.Work.JobServer
638 666
639 667
  @doc """

@@ -779,6 +807,28 @@ defmodule OpenAgents.Work do

779 807
    end
780 808
  end
781 809
810
  # An SCV deployment has no LLM steps of its own either — its work happened in
811
  # an OpenCode process on our capacity — so it gets a report that names the
812
  # repository and the outcome rather than a step summary that would be empty.
813
  defp fallback_report(_repo, %Job{kind: "scv"} = locked_job, status) do
814
    authority = locked_job.authority_snapshot || %{}
815
    path = authority["repository_path"] || "the repository"
816
817
    case status do
818
      "interrupted" ->
819
        "SCV deployment on #{path} was interrupted by a server restart before it " <>
820
          "finished. An SCV has no session to resume; deploy it again to continue. " <>
821
          "Objective: #{locked_job.goal}"
822
823
      "cancelled" ->
824
        "SCV deployment on #{path} was cancelled. Objective: #{locked_job.goal}"
825
826
      _other ->
827
        "SCV deployment on #{path} ended #{status} before reporting a result. " <>
828
          "Objective: #{locked_job.goal}"
829
    end
830
  end
831
782 832
  defp fallback_report(repo, locked_job, status) do
783 833
    steps =
784 834
      repo.all(
lib/openagents/work/job.ex modified +1 -1

@@ -15,7 +15,7 @@ defmodule OpenAgents.Work.Job do

15 15
  @statuses ~w(queued running completed failed interrupted budget_exhausted cancelled)
16 16
  @terminal_statuses ~w(completed failed interrupted budget_exhausted cancelled)
17 17
  @surfaces ~w(text voice)
18
  @kinds ~w(deep_work delegation coding)
18
  @kinds ~w(deep_work delegation coding scv)
19 19
  @machine_tiers ~w(probe curated shell)
20 20
  @maximum_goal_bytes 2_000
21 21
  @maximum_context_hint_bytes 2_000
lib/openagents/work/job_server.ex modified +2 -1

@@ -579,7 +579,8 @@ defmodule OpenAgents.Work.JobServer do

579 579
580 580
  # The same read-oriented text authorities as a turn, minus `memory.write`
581 581
  # (a job has no current user message to satisfy MEMORY-005 consent) and
582
  # minus `work.delegate` (no recursion).
582
  # minus `work.delegate` and `scv.deploy` (no recursion, and no job may spend
583
  # our own capacity on a second runtime).
583 584
  defp execution_authorities(job) do
584 585
    base =
585 586
      MapSet.new([
lib/openagents/work/scv.ex added +197

@@ -0,0 +1,197 @@

1
defmodule OpenAgents.Work.Scv do
2
  @moduledoc """
3
  The `scv` job kind's lifecycle edges (SCV-001): the bounds an SCV run is
4
  admitted under, the runtime options handed to the OpenCode driver, the
5
  disposable workspace's terminal cleanup, and metering the run's token usage
6
  into the same grant ledger as every other kind (`inference_grants`).
7
8
  The job itself is an ordinary `work_jobs` row driven by
9
  `OpenAgents.Work.ScvServer`, exactly as a computer delegation is driven by
10
  `OpenAgents.Work.DelegationServer`. This module only answers the
11
  kind-specific questions that loop asks, the way `OpenAgents.Work.Coding`
12
  does for the coding kind.
13
  """
14
15
  require Logger
16
17
  alias OpenAgents.Inference
18
  alias OpenAgents.Repo
19
  alias OpenAgents.SCV.Workspace
20
  alias OpenAgents.Work.Job
21
22
  @kind "scv"
23
24
  # An objective is a prompt, not a corpus. The bound is the same one a
25
  # delegated goal carries, so an SCV cannot smuggle in a larger instruction
26
  # than any other job kind.
27
  @maximum_objective_bytes 2_000
28
29
  @doc "Whether a job row is an SCV deployment."
30
  def scv?(%Job{kind: @kind}), do: true
31
  def scv?(_job), do: false
32
33
  @doc "The job kind string."
34
  def kind, do: @kind
35
36
  @doc "The largest admitted objective, in bytes."
37
  def maximum_objective_bytes, do: @maximum_objective_bytes
38
39
  @doc "The configured SCV deployment settings."
40
  def settings, do: Application.fetch_env!(:openagents, :scv_deploy)
41
42
  @doc "Whether the SCV deployment lane is admitted in this runtime."
43
  def enabled?, do: Keyword.fetch!(settings(), :enabled) == true
44
45
  @doc "The admitted OpenCode model slug, `provider/model`."
46
  def model, do: Keyword.fetch!(settings(), :model)
47
48
  @doc "How many SCV deployments may run at once across the whole application."
49
  def concurrency_limit, do: Keyword.fetch!(settings(), :concurrency_limit)
50
51
  @doc "The wall clock one SCV deployment is admitted for, in milliseconds."
52
  def wall_clock_ms, do: Keyword.fetch!(settings(), :wall_clock_ms)
53
54
  @doc "The largest process output one SCV deployment may capture, in bytes."
55
  def maximum_output_bytes, do: Keyword.fetch!(settings(), :maximum_output_bytes)
56
57
  @doc """
58
  The bounded execution budget recorded on the job row at admission.
59
60
  The job reads its wall clock from this immutable snapshot rather than from
61
  configuration, so a configuration change mid-run cannot widen a run that was
62
  already admitted.
63
  """
64
  def budget_snapshot do
65
    %{
66
      "wall_clock_ms" => wall_clock_ms(),
67
      "maximum_objective_bytes" => @maximum_objective_bytes,
68
      "maximum_output_bytes" => maximum_output_bytes(),
69
      "maximum_report_bytes" => Job.maximum_report_bytes()
70
    }
71
  end
72
73
  @doc "The runtime authority recorded on the job row at admission."
74
  def authority_snapshot(%{owner: owner, repository: repository, revision: revision}) do
75
    %{
76
      "driver" => "opencode",
77
      "environment" => "opencode-core",
78
      "permission_profile" => "read_only",
79
      "model" => model(),
80
      "repository_id" => repository.id,
81
      "repository_path" => "#{repository.owner}/#{repository.name}",
82
      "repository_revision" => revision,
83
      "operator_user_id" => owner.id
84
    }
85
  end
86
87
  @doc "The wall clock this job was admitted under."
88
  def wall_clock_ms(%Job{budget_snapshot: %{"wall_clock_ms" => value}})
89
      when is_integer(value) and value > 0,
90
      do: value
91
92
  def wall_clock_ms(%Job{}), do: wall_clock_ms()
93
94
  @doc "The output ceiling this job was admitted under."
95
  def maximum_output_bytes(%Job{budget_snapshot: %{"maximum_output_bytes" => value}})
96
      when is_integer(value) and value > 0,
97
      do: value
98
99
  def maximum_output_bytes(%Job{}), do: maximum_output_bytes()
100
101
  @doc """
102
  The driver options for one admitted run.
103
104
  `models_fetch` is deliberately on: the admitted model is served by a gateway
105
  whose catalog is published rather than baked into the OpenCode binary, so a
106
  run with the catalog fetch disabled would resolve no model at all. Tool
107
  permissions stay denied either way — this widens what the process may read
108
  about models, not what it may do.
109
  """
110
  def driver_options(%Job{} = job, event_sink) when is_function(event_sink, 1) do
111
    configured = settings()
112
113
    [
114
      model: model(),
115
      reasoning_effort: Keyword.fetch!(configured, :reasoning_effort),
116
      models_fetch: true,
117
      api_key: Application.get_env(:openagents, :openai_api_key),
118
      opencode_api_key: Keyword.get(configured, :opencode_api_key),
119
      output_root: Keyword.fetch!(configured, :output_root),
120
      timeout_ms: wall_clock_ms(job),
121
      maximum_output_bytes: maximum_output_bytes(job),
122
      event_sink: event_sink
123
    ]
124
    |> maybe_put(:executable, Keyword.get(configured, :executable))
125
  end
126
127
  @doc """
128
  Mint the run's inference grant at start; its id rides in the job's
129
  `delegation` map. The token is discarded — the meter is internal, and
130
  nothing external redeems it.
131
  """
132
  def on_start(%Job{kind: @kind} = job) do
133
    case Inference.mint(%{
134
           owner_visitor_id: job.owner_visitor_id,
135
           conversation_id: job.conversation_id,
136
           machine_id: nil
137
         }) do
138
      {:ok, grant, _token} ->
139
        job
140
        |> Ecto.Changeset.change(%{
141
          delegation: Map.put(job.delegation || %{}, "inference_grant_id", grant.id)
142
        })
143
        |> Repo.update()
144
145
      {:error, reason} ->
146
        Logger.warning("scv_job_grant_mint_failed code=#{OpenAgents.OperationalLog.code(reason)}")
147
148
        {:ok, job}
149
    end
150
  end
151
152
  def on_start(job), do: {:ok, job}
153
154
  @doc """
155
  Terminal filesystem cleanup, called from `Work.finish_job/3` post-commit —
156
  the one path that runs even when the worker died, so a workspace outlives
157
  neither the run nor the node that held it.
158
  """
159
  def on_terminal(%Job{kind: @kind} = job) do
160
    case job.delegation do
161
      %{"workspace_path" => path} when is_binary(path) -> Workspace.destroy(path)
162
      _absent -> :ok
163
    end
164
165
    :ok
166
  end
167
168
  def on_terminal(_job), do: :ok
169
170
  @doc "Settle an SCV run's metered grant inside the terminal job transaction."
171
  def settle_grant(%Job{kind: @kind, delegation: %{"inference_grant_id" => grant_id}} = job)
172
      when is_binary(grant_id) do
173
    case Repo.get(Inference.Grant, grant_id) do
174
      nil ->
175
        {:error, :scv_grant_missing}
176
177
      grant ->
178
        usage = job.usage || %{}
179
180
        with {:ok, metered} <-
181
               Inference.record_usage(grant, %{
182
                 "input_tokens" => Map.get(usage, "input_tokens", 0),
183
                 "output_tokens" => Map.get(usage, "output_tokens", 0),
184
                 "total_tokens" => Map.get(usage, "total_tokens", 0)
185
               }),
186
             {:ok, settled} <- Inference.revoke(metered) do
187
          {:ok, settled}
188
        end
189
    end
190
  end
191
192
  def settle_grant(%Job{kind: @kind}), do: {:ok, :no_grant}
193
  def settle_grant(_job), do: {:ok, :not_scv}
194
195
  defp maybe_put(options, _key, nil), do: options
196
  defp maybe_put(options, key, value), do: Keyword.put(options, key, value)
197
end
lib/openagents/work/scv_server.ex added +319

@@ -0,0 +1,319 @@

1
defmodule OpenAgents.Work.ScvServer do
2
  @moduledoc """
3
  Supervised worker for one durable SCV deployment (SCV-001).
4
5
  Like `OpenAgents.Work.DelegationServer`, and unlike `OpenAgents.Work.JobServer`,
6
  this drives no model loop of its own: it runs one bounded
7
  `OpenAgents.SCV.run/1` to completion in its own process, so the turn that
8
  requested it returns immediately and the SCV works in the background. The
9
  difference from a delegation is where the work lands — a delegation ends on
10
  hardware the person owns, an SCV deployment ends on ours.
11
12
  Everything durable is the ordinary work-job machinery: the row, the seven
13
  statuses, the Horde singleton, the generation fence, the recovery sweep, and
14
  the bounded report posted back into the conversation. The run itself is
15
  bounded three ways at once — a wall clock the executor enforces and this
16
  server independently backstops, an output ceiling, and an objective cap
17
  fixed at admission.
18
19
  An interrupted SCV is NOT resumed. A coding agent's process died with its
20
  node; there is no session to re-attach, so recovery finishes the job
21
  honestly and the operator starts a new one.
22
  """
23
24
  # :transient — a crash on the same node is retried, and Horde relocates the
25
  # singleton to a survivor when its node dies. A clean finish is not restarted.
26
  use GenServer, restart: :transient
27
28
  alias OpenAgents.Incidents
29
  alias OpenAgents.Repo
30
  alias OpenAgents.Repositories.Repository
31
  alias OpenAgents.SCV
32
  alias OpenAgents.SCV.Workspace
33
  alias OpenAgents.Work
34
  alias OpenAgents.Work.Scv
35
36
  # The report is a chat message, not a terminal window: the run's prose, the
37
  # tools it used, and how it ended. The event artifact stays on disk for an
38
  # operator, and the content-free projection stays on the status page.
39
  @maximum_report_output 4_000
40
41
  # The executor owns the wall clock; this is the backstop for a port that
42
  # somehow outlives it, so a stuck run cannot hold a slot forever.
43
  @deadline_grace_ms 60_000
44
45
  def start_link(job_id) do
46
    GenServer.start_link(__MODULE__, job_id, name: via(job_id))
47
  end
48
49
  @impl true
50
  def init(job_id) do
51
    # claim_for_run adopts a job left `running` by a now-dead node and refuses a
52
    # terminal one, so a relocated worker never re-runs finished work.
53
    case Work.claim_for_run(job_id) do
54
      {:ok, %{generation: generation} = claimed} when generation > 1 ->
55
        # A previous owner already spent capacity on this objective. An SCV has
56
        # no resumable session, so adopting it means finishing it honestly
57
        # rather than paying for the same work twice.
58
        _finished =
59
          Work.finish_job(claimed.id, "interrupted", error_code: "scv_run_interrupted")
60
61
        {:stop, :normal}
62
63
      {:ok, claimed} ->
64
        {:ok, %{job: claimed}, {:continue, :deploy}}
65
66
      {:error, _reason} ->
67
        {:stop, :normal}
68
    end
69
  end
70
71
  @impl true
72
  def handle_continue(:deploy, %{job: job}) do
73
    {:ok, job} = Scv.on_start(job)
74
75
    case prepare(job) do
76
      {:ok, prepared, workspace} ->
77
        deadline =
78
          Process.send_after(self(), :deadline, Scv.wall_clock_ms(prepared) + @deadline_grace_ms)
79
80
        {:noreply,
81
         %{
82
           job: prepared,
83
           workspace: workspace,
84
           deadline: deadline,
85
           task: start_run(prepared, workspace)
86
         }}
87
88
      {:error, reason} ->
89
        _report =
90
          Work.append_report_delta(job, "SCV deployment could not start: #{reason}.")
91
92
        _incident = report_incident(job, to_string(reason))
93
        _finished = Work.finish_job(job.id, "failed", error_code: to_string(reason))
94
        {:stop, :normal, %{job: job}}
95
    end
96
  end
97
98
  @impl true
99
  def handle_cast(:cancel, state) do
100
    _shutdown = Task.shutdown(state.task, :brutal_kill)
101
    _report = Work.append_report_delta(state.job, "SCV deployment cancelled by the operator.")
102
    _incident = report_incident(state.job, "cancelled")
103
    _finished = Work.finish_job(state.job.id, "cancelled", error_code: "cancelled")
104
    {:stop, :normal, state}
105
  end
106
107
  # The run returned: compose a bounded report, meter its usage, finish the job.
108
  @impl true
109
  def handle_info({reference, result}, %{task: %{ref: reference}} = state) do
110
    Process.demonitor(reference, [:flush])
111
    _timer = cancel_deadline(state)
112
    {status, report, usage, code} = summarize(result, state.job)
113
    _report = Work.append_report_delta(state.job, report)
114
    if code, do: report_incident(state.job, code)
115
    _finished = Work.finish_job(state.job.id, status, error_code: code, usage: usage)
116
    {:stop, :normal, state}
117
  end
118
119
  def handle_info(:deadline, %{task: task} = state) do
120
    _shutdown = Task.shutdown(task, :brutal_kill)
121
122
    _report =
123
      Work.append_report_delta(
124
        state.job,
125
        "SCV deployment exceeded its admitted wall clock and was stopped."
126
      )
127
128
    _incident = report_incident(state.job, "scv_run_timeout")
129
    _finished = Work.finish_job(state.job.id, "failed", error_code: "scv_run_timeout")
130
    {:stop, :normal, state}
131
  end
132
133
  # The run task crashed: still finish the job honestly.
134
  def handle_info({:DOWN, reference, :process, _pid, _reason}, %{task: %{ref: reference}} = state) do
135
    _timer = cancel_deadline(state)
136
    _report = Work.append_report_delta(state.job, "The SCV worker stopped unexpectedly.")
137
    _incident = report_incident(state.job, "scv_worker_exited")
138
    _finished = Work.finish_job(state.job.id, "failed", error_code: "scv_worker_exited")
139
    {:stop, :normal, state}
140
  end
141
142
  def handle_info(_message, state), do: {:noreply, state}
143
144
  # ── internal ───────────────────────────────────────────────────────────────
145
146
  # Clone the admitted repository at the admitted revision into a disposable
147
  # workspace and record its path on the row, so `Work.finish_job/3` can remove
148
  # it even if this process never runs again.
149
  defp prepare(job) do
150
    authority = job.authority_snapshot || %{}
151
152
    with %Repository{} = repository <- Repo.get(Repository, authority["repository_id"]),
153
         revision when is_binary(revision) <- authority["repository_revision"],
154
         {:ok, workspace} <- Workspace.prepare(repository, revision, job.id),
155
         {:ok, recorded} <- record_workspace(job, workspace) do
156
      {:ok, recorded, workspace}
157
    else
158
      {:error, reason} -> {:error, reason}
159
      _missing -> {:error, :scv_repository_unavailable}
160
    end
161
  end
162
163
  defp record_workspace(job, workspace) do
164
    job
165
    |> Ecto.Changeset.change(%{
166
      delegation: Map.put(job.delegation || %{}, "workspace_path", workspace)
167
    })
168
    |> Repo.update()
169
  end
170
171
  defp start_run(job, workspace) do
172
    objective = job.goal
173
    options = Scv.driver_options(job, event_sink(job))
174
175
    Task.Supervisor.async_nolink(OpenAgents.ProviderTaskSupervisor, fn ->
176
      SCV.run(workspace, objective,
177
        driver: :opencode,
178
        environment: :opencode_core,
179
        permission_profile: :read_only,
180
        run_id: job.id,
181
        driver_options: options
182
      )
183
    end)
184
  end
185
186
  # The executor already publishes each event as `[:openagents, :scv, :event]`
187
  # telemetry, which `OpenAgents.SCV.Activity` turns into the content-free
188
  # public projection on the status page. The sink is where an operator-facing
189
  # trace would attach; it stays a no-op so no event content is duplicated.
190
  defp event_sink(_job), do: fn _event -> :ok end
191
192
  defp cancel_deadline(%{deadline: reference}) when is_reference(reference),
193
    do: Process.cancel_timer(reference)
194
195
  defp cancel_deadline(_state), do: :ok
196
197
  defp summarize({:ok, %{status: "succeeded"} = result}, job),
198
    do: {"completed", report_text(result, job), usage(result), nil}
199
200
  defp summarize({:ok, %{status: "timeout"} = result}, job),
201
    do: {"failed", report_text(result, job), usage(result), "scv_run_timeout"}
202
203
  defp summarize({:ok, %{status: status} = result}, job),
204
    do: {"failed", report_text(result, job), usage(result), "scv_run_#{status}"}
205
206
  defp summarize({:error, reason}, job),
207
    do: {"failed", failure_text(reason, job), nil, error_code(reason)}
208
209
  defp summarize(_other, job),
210
    do: {"failed", failure_text(:unknown, job), nil, "scv_run_failed"}
211
212
  defp report_text(result, job) do
213
    authority = job.authority_snapshot || %{}
214
    path = authority["repository_path"] || "the repository"
215
    model = authority["model"] || "the admitted model"
216
217
    header =
218
      "SCV deployment on #{path} — #{human_status(result.status)}. " <>
219
        "Model: #{model}. Runtime: #{div(result.duration_ms, 1_000)}s."
220
221
    body =
222
      [tool_line(result), bound(prose(result)), truncation_line(result)]
223
      |> Enum.reject(&(&1 in [nil, ""]))
224
      |> Enum.join("\n\n")
225
226
    if body == "", do: header, else: "#{header}\n\n#{body}"
227
  end
228
229
  defp failure_text(reason, job) do
230
    authority = job.authority_snapshot || %{}
231
    path = authority["repository_path"] || "the repository"
232
    "SCV deployment on #{path} could not run: #{error_code(reason)}."
233
  end
234
235
  defp prose(%{report: %{text: text}}) when is_binary(text), do: text
236
  defp prose(_result), do: ""
237
238
  defp truncation_line(%{report: %{truncated: true}}),
239
    do: "[the SCV's report was truncated at its admitted bound]"
240
241
  defp truncation_line(_result), do: nil
242
243
  defp tool_line(%{events: %{tool_calls: calls}}) when is_map(calls) and map_size(calls) > 0 do
244
    total = calls |> Map.values() |> Enum.sum()
245
    names = calls |> Map.keys() |> Enum.sort() |> Enum.join(", ")
246
    "The SCV ran #{total} tool #{pluralize(total)} (#{names})."
247
  end
248
249
  defp tool_line(_result), do: nil
250
251
  defp pluralize(1), do: "call"
252
  defp pluralize(_count), do: "calls"
253
254
  defp usage(%{events: %{usage: usage}}) when is_map(usage) do
255
    input = round_count(Map.get(usage, :input_tokens, 0))
256
    output = round_count(Map.get(usage, :output_tokens, 0))
257
258
    %{
259
      "input_tokens" => input,
260
      "output_tokens" => output,
261
      "total_tokens" => input + output
262
    }
263
  end
264
265
  defp usage(_result), do: nil
266
267
  defp round_count(value) when is_integer(value) and value >= 0, do: value
268
  defp round_count(value) when is_float(value) and value >= 0, do: round(value)
269
  defp round_count(_value), do: 0
270
271
  defp bound(text) when is_binary(text) do
272
    if String.length(text) <= @maximum_report_output,
273
      do: text,
274
      else: String.slice(text, 0, @maximum_report_output) <> "\n\n[report truncated]"
275
  end
276
277
  defp human_status("succeeded"), do: "completed"
278
  defp human_status("timeout"), do: "timed out"
279
  defp human_status("failed"), do: "failed"
280
  defp human_status(other), do: "ended (#{other})"
281
282
  defp error_code(reason) when is_atom(reason), do: Atom.to_string(reason)
283
  defp error_code({reason, _detail}) when is_atom(reason), do: Atom.to_string(reason)
284
  defp error_code(_reason), do: "scv_run_failed"
285
286
  # A completed run is not an incident. Every other terminal is recorded so
287
  # "why did that SCV fail?" reads a typed code rather than a guess.
288
  defp report_incident(job, code) do
289
    authority = job.authority_snapshot || %{}
290
291
    Incidents.report(%{
292
      conversation_id: job.conversation_id,
293
      owner_user_id: incident_owner_user_id(job),
294
      owner_visitor_id: job.owner_visitor_id,
295
      surface: "scv",
296
      origin: "scv_server",
297
      correlation_ref: job.id,
298
      code: code,
299
      summary: "SCV deployment ended: #{code}",
300
      context: %{
301
        "repository_path" => authority["repository_path"] || "",
302
        "repository_revision" => authority["repository_revision"] || "",
303
        "model" => authority["model"] || "",
304
        "driver" => authority["driver"] || ""
305
      }
306
    })
307
  rescue
308
    _error -> :ok
309
  end
310
311
  defp incident_owner_user_id(job) do
312
    case Work.get_job_owner!(job) do
313
      %{user_id: user_id} -> user_id
314
      _absent -> nil
315
    end
316
  end
317
318
  defp via(job_id), do: {:via, Horde.Registry, {OpenAgents.HordeRegistry, {:work_job, job_id}}}
319
end
test/openagents/scv/deployments_test.exs added +400

@@ -0,0 +1,400 @@

1
defmodule OpenAgents.SCV.DeploymentsTest do
2
  @moduledoc """
3
  SCV-001: the lane that spends OpenAgents capacity.
4
5
  These tests exercise the refusals first — a non-operator, a disabled feature,
6
  an oversized objective, a full concurrency ceiling — and then run one
7
  deployment end to end against a fake OpenCode binary, so the admitted model
8
  slug is proved to reach the process invocation rather than only the
9
  configuration.
10
  """
11
12
  use OpenAgents.DataCase, async: false
13
14
  alias OpenAgents.AccountsFixtures
15
  alias OpenAgents.Conversations
16
  alias OpenAgents.Forge.Repos
17
  alias OpenAgents.Repositories
18
  alias OpenAgents.RuntimeConfig
19
  alias OpenAgents.SCV.Deployments
20
  alias OpenAgents.Tools.{ExecutionContext, ScvDeploy}
21
  alias OpenAgents.Work.{Job, Scv}
22
23
  @model "opencode/x-preview-f-free"
24
25
  setup do
26
    Ecto.Adapters.SQL.Sandbox.mode(OpenAgents.Repo, {:shared, self()})
27
28
    root = Path.join(System.tmp_dir!(), "scv-deploy-#{System.unique_integer([:positive])}")
29
    executable = Path.join(root, "fake-opencode")
30
    File.mkdir_p!(root)
31
    File.write!(executable, fake_opencode())
32
    File.chmod!(executable, 0o700)
33
34
    previous =
35
      for key <- [:forge_data_dir, :scv_deploy, :admin_github_ids] do
36
        {key, Application.get_env(:openagents, key)}
37
      end
38
39
    Application.put_env(:openagents, :forge_data_dir, Path.join(root, "forge"))
40
41
    Application.put_env(:openagents, :scv_deploy,
42
      enabled: true,
43
      model: @model,
44
      reasoning_effort: "low",
45
      opencode_api_key: nil,
46
      executable: executable,
47
      concurrency_limit: 2,
48
      wall_clock_ms: 60_000,
49
      maximum_output_bytes: 65_536,
50
      output_root: Path.join(root, "runs")
51
    )
52
53
    on_exit(fn ->
54
      for {key, value} <- previous do
55
        if is_nil(value),
56
          do: Application.delete_env(:openagents, key),
57
          else: Application.put_env(:openagents, key, value)
58
      end
59
60
      File.rm_rf(root)
61
    end)
62
63
    %{root: root}
64
  end
65
66
  describe "operator authority" do
67
    test "a signed-in non-operator is refused by the code that starts the run" do
68
      %{user: user, conversation: conversation} = account("scv-non-operator")
69
70
      assert {:error, :operator_required} =
71
               Deployments.start(user, %{
72
                 conversation_id: conversation.id,
73
                 owner_visitor_id: conversation.visitor_id,
74
                 surface: "text",
75
                 repository: "OpenAgentsInc/openagents.com",
76
                 objective: "Describe the README."
77
               })
78
79
      # Nothing was written and nothing was spawned.
80
      assert Deployments.active_count() == 0
81
    end
82
83
    test "the tool refuses a non-operator even though the catalog advertises it" do
84
      %{user: user, conversation: conversation} = account("scv-tool-non-operator")
85
      _repository = seed_repository!(user, "scvtool", "sample")
86
87
      assert {:error, :operator_required} =
88
               ScvDeploy.execute(
89
                 %{"repository" => "scvtool/sample", "objective" => "Describe the README."},
90
                 context(conversation)
91
               )
92
93
      assert Deployments.active_count() == 0
94
    end
95
96
    test "only an operator receives the approval receipt the surface policy demands" do
97
      %{user: user} = account("scv-receipts")
98
      %{user: operator} = operator_account("scv-receipts-operator")
99
100
      assert Deployments.approval_receipts(user, "conversation:abc") == []
101
      assert Deployments.approval_receipts(nil, "conversation:abc") == []
102
103
      assert [receipt] = Deployments.approval_receipts(operator, "conversation:abc")
104
      assert receipt["schema"] == "sarah.module_approval.v1"
105
      assert receipt["approval_class"] == "explicit_operator_approval"
106
      assert receipt["module_id"] == "sarah.tool.scv_deploy.v1"
107
      assert receipt["actor_type"] == "operator"
108
      assert receipt["explicit"] == true
109
      assert receipt["receipt_ref"] == "operator:#{operator.id}"
110
    end
111
  end
112
113
  describe "bounds" do
114
    test "a disabled lane refuses before authority is even considered" do
115
      settings = Application.fetch_env!(:openagents, :scv_deploy)
116
      Application.put_env(:openagents, :scv_deploy, Keyword.put(settings, :enabled, false))
117
118
      %{user: operator, conversation: conversation} = operator_account("scv-disabled")
119
120
      assert {:error, :scv_deploy_disabled} =
121
               Deployments.start(operator, %{
122
                 conversation_id: conversation.id,
123
                 owner_visitor_id: conversation.visitor_id,
124
                 surface: "text",
125
                 repository: "scvbounds/sample",
126
                 objective: "Describe the README."
127
               })
128
    end
129
130
    test "an objective past its bound is refused" do
131
      %{user: operator, conversation: conversation} = operator_account("scv-objective")
132
133
      for objective <- ["", "   ", String.duplicate("a", Scv.maximum_objective_bytes() + 1)] do
134
        assert {:error, :scv_objective_invalid} =
135
                 Deployments.start(operator, %{
136
                   conversation_id: conversation.id,
137
                   owner_visitor_id: conversation.visitor_id,
138
                   surface: "text",
139
                   repository: "scvbounds/sample",
140
                   objective: objective
141
                 })
142
      end
143
    end
144
145
    test "an unknown repository is refused before any process starts" do
146
      %{user: operator, conversation: conversation} = operator_account("scv-repository")
147
148
      assert {:error, :scv_repository_not_found} =
149
               Deployments.start(operator, %{
150
                 conversation_id: conversation.id,
151
                 owner_visitor_id: conversation.visitor_id,
152
                 surface: "text",
153
                 repository: "nobody/nothing",
154
                 objective: "Describe the README."
155
               })
156
157
      # A filesystem path is not a repository name, and never becomes one.
158
      assert {:error, :scv_repository_not_found} =
159
               Deployments.start(operator, %{
160
                 conversation_id: conversation.id,
161
                 owner_visitor_id: conversation.visitor_id,
162
                 surface: "text",
163
                 repository: "/etc",
164
                 objective: "Describe the README."
165
               })
166
    end
167
168
    test "the concurrency ceiling refuses the run rather than queueing it" do
169
      %{user: operator, conversation: conversation} = operator_account("scv-capacity")
170
      repository = seed_repository!(operator, "scvcap", "sample")
171
172
      settings = Application.fetch_env!(:openagents, :scv_deploy)
173
      Application.put_env(:openagents, :scv_deploy, Keyword.put(settings, :concurrency_limit, 1))
174
175
      # One job already occupies the single admitted slot.
176
      {:ok, _running} =
177
        OpenAgents.Work.create_job(%{
178
          conversation_id: conversation.id,
179
          owner_visitor_id: conversation.visitor_id,
180
          surface: "text",
181
          goal: "an SCV already holding the slot",
182
          kind: "scv"
183
        })
184
185
      assert Deployments.active_count() == 1
186
187
      assert {:error, :scv_capacity_reached} =
188
               Deployments.start(operator, %{
189
                 conversation_id: conversation.id,
190
                 owner_visitor_id: conversation.visitor_id,
191
                 surface: "text",
192
                 repository: "#{repository.owner}/#{repository.name}",
193
                 objective: "Describe the README."
194
               })
195
    end
196
197
    test "the runtime configuration refuses the lane without the work lane or bounds" do
198
      settings = Application.get_all_env(:openagents) |> Map.new()
199
200
      enabled =
201
        Map.put(settings, :scv_deploy, Keyword.put(base_deploy_settings(), :enabled, true))
202
203
      assert {:error, %{setting: :scv_deploy, reason: "requires the work lane"}} =
204
               RuntimeConfig.validate(
205
                 enabled
206
                 |> Map.put(:work, enabled: false)
207
                 |> Map.put(:work_workers_enabled, false)
208
               )
209
210
      unbounded =
211
        Map.put(
212
          enabled,
213
          :scv_deploy,
214
          base_deploy_settings()
215
          |> Keyword.put(:enabled, true)
216
          |> Keyword.put(:concurrency_limit, 100)
217
        )
218
219
      work_enabled =
220
        unbounded
221
        |> Map.put(:work, enabled: true)
222
        |> Map.put(:work_workers_enabled, true)
223
224
      assert {:error, %{setting: :scv_deploy, reason: reason}} =
225
               RuntimeConfig.validate(work_enabled)
226
227
      assert reason == "requires an admitted model, bounds, and output root"
228
    end
229
  end
230
231
  describe "an admitted deployment" do
232
    test "runs the admitted model on our capacity and reports back into the conversation" do
233
      %{user: operator, conversation: conversation} = operator_account("scv-run")
234
      repository = seed_repository!(operator, "scvrun", "sample")
235
236
      assert {:ok, job} =
237
               Deployments.start(operator, %{
238
                 conversation_id: conversation.id,
239
                 owner_visitor_id: conversation.visitor_id,
240
                 surface: "text",
241
                 repository: "#{repository.owner}/#{repository.name}",
242
                 objective: "Describe the README."
243
               })
244
245
      assert job.kind == "scv"
246
      assert job.machine_id == nil
247
248
      # The authority is snapshotted at admission, not read at run time.
249
      assert job.authority_snapshot["model"] == @model
250
      assert job.authority_snapshot["permission_profile"] == "read_only"
251
      assert job.authority_snapshot["driver"] == "opencode"
252
      assert job.authority_snapshot["repository_id"] == repository.id
253
      assert job.authority_snapshot["operator_user_id"] == operator.id
254
      assert Regex.match?(~r/\A[0-9a-f]{40}\z/, job.authority_snapshot["repository_revision"])
255
      assert job.budget_snapshot["wall_clock_ms"] == 60_000
256
      assert job.budget_snapshot["maximum_output_bytes"] == 65_536
257
258
      terminal = await_terminal(job.id)
259
      assert terminal.status == "completed"
260
261
      # The fake binary echoes what it was invoked with, so this asserts the
262
      # slug reached the process, not merely the configuration.
263
      assert terminal.report =~ "model=#{@model}"
264
      assert terminal.report =~ "fetch=0"
265
      assert terminal.report =~ "openai_key=absent"
266
      assert terminal.report =~ "SCV deployment on scvrun/sample"
267
268
      # The report is a durable assistant message in the conversation.
269
      assert terminal.report_message_id != nil
270
271
      # The disposable workspace does not outlive the run. Cleanup happens after
272
      # the terminal row commits, so this waits for it rather than racing it.
273
      assert await_removed(terminal.delegation["workspace_path"])
274
    end
275
  end
276
277
  # ── helpers ────────────────────────────────────────────────────────────────
278
279
  defp base_deploy_settings do
280
    [
281
      model: @model,
282
      reasoning_effort: "low",
283
      concurrency_limit: 2,
284
      wall_clock_ms: 900_000,
285
      maximum_output_bytes: 16_777_216,
286
      output_root: "/var/lib/openagents/scv/opencode-runs"
287
    ]
288
  end
289
290
  defp account(login) do
291
    user = AccountsFixtures.repository_user_fixture(login)
292
    {:ok, conversation} = Conversations.ensure_conversation(user)
293
    %{user: user, conversation: conversation}
294
  end
295
296
  defp operator_account(login) do
297
    %{user: user} = built = account(login)
298
    configured = Application.get_env(:openagents, :admin_github_ids, [])
299
    Application.put_env(:openagents, :admin_github_ids, [user.github_id | configured])
300
    built
301
  end
302
303
  defp context(conversation) do
304
    %ExecutionContext{
305
      scope: "browser_conversation",
306
      scope_ref: "conversation:#{conversation.id}",
307
      authorities: MapSet.new(["scv.deploy"]),
308
      surface: "text",
309
      conversation_id: conversation.id,
310
      owner_visitor_id: conversation.visitor_id
311
    }
312
  end
313
314
  defp seed_repository!(user, owner, name) do
315
    {:ok, repository} =
316
      Repositories.create_repository(%{
317
        owner: owner,
318
        name: name,
319
        visibility: "public",
320
        default_branch: "main",
321
        created_by_user_id: user.id
322
      })
323
324
    path = Repos.ensure_repo!(repository.storage_key, "main")
325
    {blob, 0} = plumb(path, ["hash-object", "-w", "--stdin"], "an SCV fixture repository\n")
326
    {tree, 0} = plumb(path, ["mktree"], "100644 blob #{String.trim(blob)}\tREADME.md\n")
327
328
    {commit, 0} =
329
      plumb(path, ["commit-tree", String.trim(tree), "-m", "seed"], "",
330
        env: [
331
          {"GIT_AUTHOR_NAME", "t"},
332
          {"GIT_AUTHOR_EMAIL", "t@t"},
333
          {"GIT_COMMITTER_NAME", "t"},
334
          {"GIT_COMMITTER_EMAIL", "t@t"}
335
        ]
336
      )
337
338
    {_output, 0} = Repos.git(path, ["update-ref", "refs/heads/main", String.trim(commit)])
339
    repository
340
  end
341
342
  defp plumb(path, args, stdin, options \\ []) do
343
    input = Path.join(System.tmp_dir!(), "plumb-#{System.unique_integer([:positive])}")
344
    File.write!(input, stdin)
345
346
    try do
347
      System.cmd(
348
        "sh",
349
        ["-c", ~s(exec git --git-dir "$GD" "$@" < "$IN"), "sh"] ++ args,
350
        env: [{"GD", path}, {"IN", input}] ++ Keyword.get(options, :env, [])
351
      )
352
    after
353
      File.rm(input)
354
    end
355
  end
356
357
  defp await_terminal(job_id) do
358
    Enum.reduce_while(1..200, nil, fn _attempt, _accumulator ->
359
      job = Repo.get!(Job, job_id)
360
361
      if job.status in Job.terminal_statuses() do
362
        {:halt, job}
363
      else
364
        Process.sleep(50)
365
        {:cont, job}
366
      end
367
    end)
368
  end
369
370
  defp await_removed(path) when is_binary(path) do
371
    Enum.reduce_while(1..100, false, fn _attempt, _accumulator ->
372
      if File.exists?(path) do
373
        Process.sleep(20)
374
        {:cont, false}
375
      else
376
        {:halt, true}
377
      end
378
    end)
379
  end
380
381
  # Echoes the invocation back as one OpenCode text event, so the test can
382
  # assert on what the process actually received.
383
  defp fake_opencode do
384
    """
385
    #!/bin/sh
386
    model=""
387
    while [ $# -gt 0 ]; do
388
      case "$1" in
389
        --model) model="$2"; shift 2 ;;
390
        *) shift ;;
391
      esac
392
    done
393
    prompt=$(cat)
394
    if [ -z "$prompt" ]; then exit 30; fi
395
    if [ "${OPENAI_API_KEY+x}" = "x" ]; then openai_key=present; else openai_key=absent; fi
396
    printf '{"type":"text","timestamp":1,"sessionID":"ses_scv","part":{"type":"text","text":"model=%s fetch=%s openai_key=%s"}}\\n' \\
397
      "$model" "${OPENCODE_DISABLE_MODELS_FETCH}" "$openai_key"
398
    """
399
  end
400
end
test/openagents/scv/open_code_executor_test.exs modified +20

@@ -161,6 +161,26 @@ defmodule OpenAgents.SCV.OpenCodeExecutorTest do

161 161
               Keyword.put(shared_options(context), :api_key, nil)
162 162
             )
163 163
164
    # SCV-001: an OpenAI key is a per-provider credential, so a model served by
165
    # another provider is not blocked on one it never reads. This gets past the
166
    # credential check and fails on the missing binary instead.
167
    assert {:error, :enoent} =
168
             OpenCode.run(
169
               context.repository,
170
               "prompt",
171
               shared_options(context)
172
               |> Keyword.put(:api_key, nil)
173
               |> Keyword.put(:model, "opencode/x-preview-f-free")
174
               |> Keyword.put(:executable, Path.join(context.output, "absent-binary"))
175
             )
176
177
    assert {:error, :opencode_api_key_invalid} =
178
             OpenCode.run(
179
               context.repository,
180
               "prompt",
181
               Keyword.put(shared_options(context), :opencode_api_key, "short")
182
             )
183
164 184
    assert {:error, :reasoning_effort_invalid} =
165 185
             OpenCode.run(
166 186
               context.repository,

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