Make runtime configuration fail closed

5351e62b3b1a · Christopher David · · parent 383d356fa997

Make runtime configuration fail closed

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 .env.example
  • modified README.md
  • modified config/config.exs
  • modified config/dev.exs
  • modified config/prod.exs
  • modified config/runtime.exs
  • modified config/test.exs
  • modified docs/architecture.md
  • added docs/runtime-configuration.md
  • added lib/mix/tasks/openagents.config.readiness.ex
  • modified lib/openagents/application.ex
  • modified lib/openagents/cluster/ra_bootstrap.ex
  • modified lib/openagents/forge/repos.ex
  • modified lib/openagents/forge/wal/local.ex
  • modified lib/openagents/github_oauth.ex
  • modified lib/openagents/memory/open_ai_embeddings.ex
  • modified lib/openagents/providers/open_ai.ex
  • added lib/openagents/runtime_config.ex
  • modified lib/openagents/runtime_supervisor.ex
  • modified lib/openagents/shadow_programs/open_ai.ex
  • modified lib/openagents/tools/repository.ex
  • modified lib/openagents/voice/config.ex
  • modified lib/openagents/voice/open_ai/call_client.ex
  • modified lib/openagents/voice/open_ai/sideband.ex
  • modified ops/ci/release-smoke.sh
  • added ops/staging/gate-5-profile.sh
  • modified rel/env.sh.eex
  • added rel/overlays/bin/config-readiness
  • modified rel/overlays/bin/migrate
  • added rel/overlays/bin/prepare-config
  • added test/openagents/runtime_config_test.exs

Diff

31 files changed, +1724 -204

.env.example modified +4

@@ -5,3 +5,7 @@

5 5
GITHUB_CLIENT_ID=
6 6
GITHUB_CLIENT_SECRET=
7 7
GITHUB_REDIRECT_URI=http://localhost:4000/auth/github/callback
8
9
# Required by the default local text provider. RuntimeConfig keeps it out of
10
# reports and diagnostics.
11
OPENAI_API_KEY=
README.md modified +2

@@ -9,6 +9,8 @@ The current architecture and trust boundaries are documented in

9 9
[docs/architecture.md](docs/architecture.md). The staged hardening work is
10 10
tracked in the
11 11
[integration hardening plan](docs/2026-08-20-integration-hardening-and-staging-readiness-recommendations.md).
12
The typed settings, safe feature profile, and redacted readiness command are in
13
[docs/runtime-configuration.md](docs/runtime-configuration.md).
12 14
13 15
## Capability status
14 16
config/config.exs modified +33 -7

@@ -11,14 +11,21 @@ config :openagents,

11 11
  namespace: OpenAgents,
12 12
  ecto_repos: [OpenAgents.Repo],
13 13
  generators: [timestamp_type: :utc_datetime],
14
  runtime_environment: :development,
15
  staging_gate: 0,
16
  production_deploy_enabled: false,
14 17
  migrate_on_boot: false,
18
  secure_cookies: false,
19
  https_aliases: [],
15 20
  conversation_page_size: 40,
16 21
  maximum_message_bytes: 8_000,
17 22
  turn_rate_limit: 50,
18 23
  admin_github_ids: [],
19 24
  computer_controller_enabled: false,
25
  coding_jobs_dir: "/var/lib/openagents/coding-jobs",
20 26
  work_workers_enabled: false,
21 27
  work: [enabled: false],
28
  tools_enabled: true,
22 29
  voice: [
23 30
    enabled: false,
24 31
    architecture: :openai_realtime,

@@ -38,6 +45,7 @@ config :openagents,

38 45
  voice_compaction_input_token_threshold: 16_000,
39 46
  provider: OpenAgents.Providers.OpenAI,
40 47
  openai_model: "gpt-5.6-luna",
48
  openai_api_key: nil,
41 49
  shadow_programs: [
42 50
    enabled: false,
43 51
    provider: OpenAgents.ShadowPrograms.OpenAI,

@@ -107,8 +115,9 @@ config :openagents,

107 115
    base_url: "https://api.github.com",
108 116
    request_options: []
109 117
  ],
118
  github_oauth_scopes: ["read:user", "repo"],
110 119
  voice_recording: [
111
    enabled: true,
120
    enabled: false,
112 121
    timeslice_ms: 5_000,
113 122
    maximum_chunk_bytes: 1_048_576,
114 123
    maximum_chunks: 1_024,

@@ -119,13 +128,21 @@ config :openagents,

119 128
  leaderboard_limit: 100,
120 129
  leaderboard_refresh_interval_ms: 1_000,
121 130
  leaderboard_auto_refresh_enabled: true,
122
  voice_recovery_worker_enabled: true,
123
  voice_retention_worker_enabled: true,
131
  voice_recovery_worker_enabled: false,
132
  voice_retention_worker_enabled: false,
124 133
  turn_recovery_enabled: false,
125 134
  voice_retention_enabled: false,
126
  ra_enabled: true,
127
  ra_data_dir: "/tmp/openagents_ra",
135
  ra_enabled: false,
136
  ra_data_dir: "/var/lib/openagents/ra",
128 137
  ra_expected_size: 3,
138
  horde_enabled: true,
139
  distribution: [
140
    enabled: false,
141
    node_configured: false,
142
    cookie_configured: false,
143
    port_min: 9_100,
144
    port_max: 9_115
145
  ],
129 146
  incident_fixer_enabled: false,
130 147
  github_oauth: [
131 148
    client_id: nil,

@@ -145,9 +162,14 @@ config :openagents,

145 162
  inference_output_price_microusd_per_ktoken: 10_000,
146 163
  forge_enabled: false,
147 164
  forge_boot_converge_enabled: false,
148
  forge_deploy_lane_enabled: true,
149
  forge_data_dir: nil,
165
  forge_deploy_lane_enabled: false,
166
  forge_data_dir: "/var/lib/openagents/forge",
150 167
  forge_build_dir: "/var/lib/openagents/workspace/build",
168
  forge_build_queue_dir: "/var/lib/openagents/workspace/build-queue",
169
  forge_artifact_dir: "/var/lib/openagents/artifacts",
170
  forge_artifact_store: :local,
171
  forge_build_executor: OpenAgents.Forge.BuildExecutor.Sidecar,
172
  forge_expected_fleet_size: 1,
151 173
  forge_repos: ["openagents.com"],
152 174
  forge_internal_git_url: "http://127.0.0.1:8080/git",
153 175
  forge_operator_token: nil,

@@ -168,6 +190,10 @@ config :openagents,

168 190
    "OpenAgents.Scratch.",
169 191
    "OpenAgents.BuildInfo"
170 192
  ],
193
  forge_hot_load_examples: %{
194
    "OpenAgentsWeb.ChatLive" => true,
195
    "OpenAgents.Accounts" => false
196
  },
171 197
  forge_public_visibility: %{"openagents.com" => :l3},
172 198
  forge_repo_owners: %{"openagents.com" => "OpenAgentsInc"},
173 199
  forge_public_paths: %{"openagents.com" => []},
config/dev.exs modified +2

@@ -1,5 +1,7 @@

1 1
import Config
2 2
3
config :openagents, :runtime_environment, :development
4
3 5
# Configure your database
4 6
config :openagents, OpenAgents.Repo,
5 7
  username: System.get_env("USER") || "christopherdavid",
config/prod.exs modified +2

@@ -1,5 +1,7 @@

1 1
import Config
2 2
3
config :openagents, :secure_cookies, true
4
3 5
# Note we also include the path to a cache manifest
4 6
# containing the digested version of static files. This
5 7
# manifest is generated by the `mix assets.deploy` task,
config/runtime.exs modified +289 -158

@@ -1,5 +1,7 @@

1 1
import Config
2 2
3
# Development may use a local, uncommitted dotenv file. Production and release
4
# configuration always comes from the deployment environment.
3 5
if config_env() == :dev do
4 6
  env_file = Path.expand("../.env", __DIR__)
5 7

@@ -15,13 +17,289 @@ if config_env() == :dev do

15 17
            System.put_env(key, String.trim(value, "\"'"))
16 18
          end
17 19
18
        _other ->
20
        _invalid ->
19 21
          :ok
20 22
      end
21 23
    end)
22 24
  end
23 25
end
24 26
27
fetch_raw = fn name ->
28
  case System.fetch_env(name) do
29
    {:ok, value} -> String.trim(value)
30
    :error -> raise "environment variable #{name} is required"
31
  end
32
end
33
34
required_text = fn name ->
35
  case fetch_raw.(name) do
36
    "" -> raise "environment variable #{name} must not be empty"
37
    value -> value
38
  end
39
end
40
41
optional_text = fn name ->
42
  case System.get_env(name) do
43
    nil -> nil
44
    value -> if String.trim(value) == "", do: nil, else: String.trim(value)
45
  end
46
end
47
48
parse_boolean = fn name ->
49
  case fetch_raw.(name) do
50
    "true" -> true
51
    "false" -> false
52
    _invalid -> raise "environment variable #{name} must be true or false"
53
  end
54
end
55
56
parse_optional_boolean = fn name ->
57
  case System.get_env(name) do
58
    nil -> false
59
    "true" -> true
60
    "false" -> false
61
    _invalid -> raise "environment variable #{name} must be true or false when set"
62
  end
63
end
64
65
parse_integer = fn name, range ->
66
  case Integer.parse(required_text.(name)) do
67
    {value, ""} ->
68
      if value in range,
69
        do: value,
70
        else: raise("environment variable #{name} is outside its admitted range")
71
72
    _invalid ->
73
      raise "environment variable #{name} must be an integer"
74
  end
75
end
76
77
parse_csv = fn name ->
78
  name
79
  |> fetch_raw.()
80
  |> String.split(",", trim: true)
81
  |> Enum.map(&String.trim/1)
82
  |> Enum.reject(&(&1 == ""))
83
end
84
85
if config_env() == :dev do
86
  config :openagents, :openai_api_key, optional_text.("OPENAI_API_KEY")
87
end
88
89
if config_env() == :prod do
90
  runtime_environment =
91
    case required_text.("OPENAGENTS_ENVIRONMENT") do
92
      "staging" -> :staging
93
      "production" -> :production
94
      _invalid -> raise "environment variable OPENAGENTS_ENVIRONMENT is not admitted"
95
    end
96
97
  staging_gate = parse_integer.("OPENAGENTS_STAGING_GATE", 0..16)
98
  production_deploy_enabled = parse_boolean.("OPENAGENTS_PRODUCTION_DEPLOY_ENABLED")
99
  secure_cookies = parse_boolean.("OPENAGENTS_SECURE_COOKIES")
100
  migrate_on_boot = parse_boolean.("OPENAGENTS_MIGRATE_ON_BOOT")
101
  host = required_text.("PHX_HOST")
102
  allowed_origins = parse_csv.("OPENAGENTS_ALLOWED_ORIGINS")
103
  https_aliases = parse_csv.("OPENAGENTS_HTTPS_ALIASES")
104
  ecto_ipv6? = parse_boolean.("OPENAGENTS_DATABASE_IPV6")
105
  pool_size = parse_integer.("POOL_SIZE", 1..200)
106
107
  repo_config =
108
    case required_text.("OPENAGENTS_DATABASE_MODE") do
109
      "url" ->
110
        [
111
          url: required_text.("DATABASE_URL"),
112
          pool_size: pool_size,
113
          socket_options: if(ecto_ipv6?, do: [:inet6], else: [])
114
        ]
115
116
      "socket" ->
117
        [
118
          username: required_text.("DB_USER"),
119
          password: required_text.("DB_PASSWORD"),
120
          database: required_text.("DB_NAME"),
121
          socket_dir: required_text.("INSTANCE_UNIX_SOCKET"),
122
          pool_size: pool_size,
123
          socket_options: if(ecto_ipv6?, do: [:inet6], else: [])
124
        ]
125
126
      _invalid ->
127
        raise "environment variable OPENAGENTS_DATABASE_MODE must be url or socket"
128
    end
129
130
  feature = fn name -> parse_boolean.("OPENAGENTS_FEATURE_#{name}") end
131
132
  tools_enabled = feature.("TOOLS")
133
  voice_enabled = feature.("VOICE")
134
  recording_enabled = feature.("VOICE_RECORDING")
135
  work_enabled = feature.("WORK")
136
  semantic_enabled = feature.("SEMANTIC_MEMORY")
137
  experience_enabled = feature.("EXPERIENCE_MEMORY")
138
  graph_enabled = feature.("GRAPH_MEMORY")
139
  portability_enabled = feature.("MEMORY_PORTABILITY")
140
  shadow_enabled = feature.("SHADOW_PROGRAMS")
141
  tool_embeddings_enabled = feature.("TOOL_EMBEDDINGS")
142
  computers_enabled = feature.("COMPUTERS")
143
  conversation_reset_enabled = feature.("CONVERSATION_RESET")
144
  incident_fixer_enabled = feature.("INCIDENT_FIXER")
145
  turn_recovery_enabled = feature.("TURN_RECOVERY")
146
  voice_retention_enabled = feature.("VOICE_RETENTION")
147
  forge_enabled = feature.("FORGE")
148
  forge_deploy_enabled = feature.("FORGE_DEPLOY")
149
  boot_convergence_enabled = feature.("BOOT_CONVERGENCE")
150
  ra_enabled = feature.("RA")
151
  horde_enabled = feature.("HORDE")
152
153
  voice =
154
    :openagents
155
    |> Application.fetch_env!(:voice)
156
    |> Keyword.put(:enabled, voice_enabled)
157
158
  voice_recording =
159
    :openagents
160
    |> Application.fetch_env!(:voice_recording)
161
    |> Keyword.put(:enabled, recording_enabled)
162
163
  work =
164
    :openagents
165
    |> Application.fetch_env!(:work)
166
    |> Keyword.put(:enabled, work_enabled)
167
168
  semantic_index =
169
    :openagents
170
    |> Application.fetch_env!(:semantic_index)
171
    |> Keyword.put(:enabled, semantic_enabled)
172
173
  experience_memory =
174
    :openagents
175
    |> Application.fetch_env!(:experience_memory)
176
    |> Keyword.put(:enabled, experience_enabled)
177
178
  graph_memory =
179
    :openagents
180
    |> Application.fetch_env!(:graph_memory)
181
    |> Keyword.put(:enabled, graph_enabled)
182
183
  memory_portability =
184
    :openagents
185
    |> Application.fetch_env!(:memory_portability)
186
    |> Keyword.put(:enabled, portability_enabled)
187
188
  shadow_programs =
189
    :openagents
190
    |> Application.fetch_env!(:shadow_programs)
191
    |> Keyword.put(:enabled, shadow_enabled)
192
193
  tool_discovery =
194
    :openagents
195
    |> Application.fetch_env!(:tool_discovery)
196
    |> Keyword.put(:embeddings_enabled, tool_embeddings_enabled)
197
198
  forge_repos = parse_csv.("OPENAGENTS_FORGE_REPOSITORIES")
199
  forge_owner = required_text.("OPENAGENTS_FORGE_OWNER")
200
  github_oauth_scopes = parse_csv.("GITHUB_OAUTH_SCOPES")
201
202
  forge_wal_adapter =
203
    case required_text.("OPENAGENTS_FORGE_WAL_ADAPTER") do
204
      "local" -> OpenAgents.Forge.WAL.Local
205
      "gcs" -> OpenAgents.Forge.WAL.Gcs
206
      _invalid -> raise "environment variable OPENAGENTS_FORGE_WAL_ADAPTER is not admitted"
207
    end
208
209
  forge_artifact_store =
210
    case required_text.("OPENAGENTS_FORGE_ARTIFACT_STORE") do
211
      "local" -> :local
212
      "gcs" -> :gcs
213
      _invalid -> raise "environment variable OPENAGENTS_FORGE_ARTIFACT_STORE is not admitted"
214
    end
215
216
  forge_build_executor =
217
    case required_text.("OPENAGENTS_FORGE_BUILD_EXECUTOR") do
218
      "sidecar" -> OpenAgents.Forge.BuildExecutor.Sidecar
219
      _invalid -> raise "environment variable OPENAGENTS_FORGE_BUILD_EXECUTOR is not admitted"
220
    end
221
222
  distribution_enabled = System.get_env("RELEASE_DISTRIBUTION") in ["name", "longnames"]
223
  release_node = optional_text.("RELEASE_NODE")
224
  release_cookie = optional_text.("RELEASE_COOKIE")
225
226
  node_configured =
227
    is_binary(release_node) and
228
      Regex.match?(~r/\Aopenagents@[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}\z/, release_node)
229
230
  distribution = [
231
    enabled: distribution_enabled,
232
    node_configured: node_configured,
233
    cookie_configured: is_binary(release_cookie) and byte_size(release_cookie) >= 32,
234
    port_min: parse_integer.("OPENAGENTS_DIST_PORT_MIN", 1_024..65_535),
235
    port_max: parse_integer.("OPENAGENTS_DIST_PORT_MAX", 1_024..65_535)
236
  ]
237
238
  config :openagents,
239
    runtime_environment: runtime_environment,
240
    staging_gate: staging_gate,
241
    production_deploy_enabled: production_deploy_enabled,
242
    secure_cookies: secure_cookies,
243
    https_aliases: https_aliases,
244
    migrate_on_boot: migrate_on_boot,
245
    tools_enabled: tools_enabled,
246
    voice: voice,
247
    voice_recording: voice_recording,
248
    voice_recording_encryption_key: optional_text.("VOICE_RECORDING_ENCRYPTION_KEY"),
249
    voice_recovery_worker_enabled: voice_enabled,
250
    voice_retention_worker_enabled: voice_retention_enabled,
251
    voice_retention_enabled: voice_retention_enabled,
252
    work: work,
253
    work_workers_enabled: work_enabled,
254
    semantic_index: semantic_index,
255
    experience_memory: experience_memory,
256
    graph_memory: graph_memory,
257
    memory_portability: memory_portability,
258
    shadow_programs: shadow_programs,
259
    tool_discovery: tool_discovery,
260
    computer_controller_enabled: computers_enabled,
261
    coding_jobs_dir: required_text.("OPENAGENTS_CODING_JOBS_DIR"),
262
    conversation_reset_enabled: conversation_reset_enabled,
263
    incident_fixer_enabled: incident_fixer_enabled,
264
    turn_recovery_enabled: turn_recovery_enabled,
265
    github_oauth_scopes: github_oauth_scopes,
266
    openai_api_key: required_text.("OPENAI_API_KEY"),
267
    inference_proxy_url: optional_text.("OPENAGENTS_INFERENCE_PROXY_URL"),
268
    forge_enabled: forge_enabled,
269
    forge_deploy_lane_enabled: forge_deploy_enabled,
270
    forge_boot_converge_enabled: boot_convergence_enabled,
271
    forge_repos: forge_repos,
272
    forge_repo_owners: Map.new(forge_repos, &{&1, forge_owner}),
273
    forge_public_visibility: Map.new(forge_repos, &{&1, :l3}),
274
    forge_public_paths: Map.new(forge_repos, &{&1, []}),
275
    forge_internal_git_url: required_text.("OPENAGENTS_FORGE_INTERNAL_GIT_URL"),
276
    forge_operator_token: optional_text.("OPENAGENTS_FORGE_OPERATOR_TOKEN"),
277
    forge_data_dir: required_text.("OPENAGENTS_FORGE_DATA_DIR"),
278
    forge_build_dir: required_text.("OPENAGENTS_FORGE_BUILD_DIR"),
279
    forge_build_queue_dir: required_text.("OPENAGENTS_FORGE_BUILD_QUEUE_DIR"),
280
    forge_artifact_dir: required_text.("OPENAGENTS_FORGE_ARTIFACT_DIR"),
281
    forge_artifact_store: forge_artifact_store,
282
    forge_build_executor: forge_build_executor,
283
    forge_expected_fleet_size: parse_integer.("OPENAGENTS_FORGE_EXPECTED_FLEET_SIZE", 1..100),
284
    forge_wal_adapter: forge_wal_adapter,
285
    forge_wal_dir: required_text.("OPENAGENTS_FORGE_WAL_DIR"),
286
    forge_wal_bucket: optional_text.("OPENAGENTS_FORGE_WAL_BUCKET"),
287
    ra_enabled: ra_enabled,
288
    ra_data_dir: required_text.("OPENAGENTS_RA_DATA_DIR"),
289
    ra_expected_size: parse_integer.("OPENAGENTS_RA_EXPECTED_SIZE", 1..100),
290
    horde_enabled: horde_enabled,
291
    dns_cluster_query: optional_text.("DNS_CLUSTER_QUERY"),
292
    distribution: distribution
293
294
  config :openagents, OpenAgents.Repo, repo_config
295
296
  config :openagents, OpenAgentsWeb.Endpoint,
297
    url: [host: host, port: 443, scheme: "https"],
298
    check_origin: allowed_origins,
299
    http: [ip: {0, 0, 0, 0, 0, 0, 0, 0}],
300
    secret_key_base: required_text.("SECRET_KEY_BASE")
301
end
302
25 303
github_oauth = Application.get_env(:openagents, :github_oauth, [])
26 304
27 305
github_oauth =

@@ -37,188 +315,41 @@ github_oauth =

37 315
38 316
config :openagents, :github_oauth, github_oauth
39 317
40
token_encryption_key =
41
  case System.get_env("GITHUB_TOKEN_ENCRYPTION_KEY") do
42
    nil -> nil
43
    value -> String.trim(value)
44
  end
318
token_encryption_key = optional_text.("GITHUB_TOKEN_ENCRYPTION_KEY")
45 319
46 320
valid_token_key? =
47 321
  is_binary(token_encryption_key) and
48 322
    match?({:ok, key} when byte_size(key) == 32, Base.decode64(token_encryption_key))
49 323
50 324
if config_env() == :prod and not valid_token_key? do
51
  raise """
52
  environment variable GITHUB_TOKEN_ENCRYPTION_KEY is missing or invalid.
53
  It must be a base64-encoded 32-byte key, for example generated with:
54
55
      openssl rand -base64 32
56
  """
325
  raise "environment variable GITHUB_TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key"
57 326
end
58 327
59 328
if valid_token_key? do
60 329
  config :openagents, :github_token_encryption_key, token_encryption_key
61 330
end
62 331
63
# config/runtime.exs is executed for all environments, including
64
# during releases. It is executed after compilation and before the
65
# system starts, so it is typically used to load production configuration
66
# and secrets from environment variables or elsewhere. Do not define
67
# any compile-time configuration in here, as it won't be applied.
68
# The block below contains prod specific runtime configuration.
69
70
# ## Using releases
71
#
72
# If you use `mix release`, you need to explicitly enable the server
73
# by passing the PHX_SERVER=true when you start it:
74
#
75
#     PHX_SERVER=true bin/openagents start
76
#
77
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
78
# script that automatically sets the env var above.
79
if System.get_env("PHX_SERVER") do
332
if parse_optional_boolean.("PHX_SERVER") do
80 333
  config :openagents, OpenAgentsWeb.Endpoint, server: true
81 334
end
82 335
83
config :openagents, OpenAgentsWeb.Endpoint,
84
  http: [port: String.to_integer(System.get_env("PORT", "4000"))]
336
port =
337
  case Integer.parse(System.get_env("PORT", "4000")) do
338
    {value, ""} when value in 1..65_535 -> value
339
    _invalid -> raise "environment variable PORT must be a valid port"
340
  end
341
342
config :openagents, OpenAgentsWeb.Endpoint, http: [port: port]
85 343
86 344
if config_env() == :dev do
87
  # Reload browser tabs when matching files change.
88 345
  config :openagents, OpenAgentsWeb.Endpoint,
89 346
    live_reload: [
90 347
      web_console_logger: true,
91 348
      patterns: [
92
        # Static assets, except user uploads
93 349
        ~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$"E,
94
        # Gettext translations
95 350
        ~r"priv/gettext/.*\.po$"E,
96
        # Router, Controllers, LiveViews and LiveComponents
97 351
        ~r"lib/openagents_web/router\.ex$"E,
98 352
        ~r"lib/openagents_web/(controllers|live|components)/.*\.(ex|heex)$"E
99 353
      ]
100 354
    ]
101 355
end
102
103
if config_env() == :prod do
104
  maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
105
  pool_size = String.to_integer(System.get_env("POOL_SIZE") || "10")
106
107
  repo_config =
108
    case System.get_env("DATABASE_URL") do
109
      nil ->
110
        # Cloud Run + Cloud SQL socket configuration used by staging.
111
        user = System.get_env("DB_USER") || raise("environment variable DB_USER is missing")
112
113
        password =
114
          System.get_env("DB_PASSWORD") || raise("environment variable DB_PASSWORD is missing")
115
116
        database = System.get_env("DB_NAME") || raise("environment variable DB_NAME is missing")
117
118
        socket_dir =
119
          System.get_env("INSTANCE_UNIX_SOCKET") ||
120
            raise("environment variable INSTANCE_UNIX_SOCKET is missing")
121
122
        [
123
          username: user,
124
          password: password,
125
          database: database,
126
          socket_dir: socket_dir,
127
          pool_size: pool_size,
128
          socket_options: maybe_ipv6
129
        ]
130
131
      database_url ->
132
        [
133
          # ssl: true,
134
          url: database_url,
135
          pool_size: pool_size,
136
          socket_options: maybe_ipv6
137
        ]
138
    end
139
140
  config :openagents, OpenAgents.Repo, repo_config
141
142
  # Always migrate on boot in production so the schema precedes traffic
143
  # (RELEASE-001). Ecto.Migrator.with_repo takes the advisory lock, so
144
  # concurrent fleet nodes serialize safely and an already-migrated DB is a
145
  # no-op.
146
  config :openagents, :migrate_on_boot, true
147
148
  # The secret key base is used to sign/encrypt cookies and other secrets.
149
  # A default value is used in config/dev.exs and config/test.exs but you
150
  # want to use a different value for prod and you most likely don't want
151
  # to check this value into version control, so we use an environment
152
  # variable instead.
153
  secret_key_base =
154
    System.get_env("SECRET_KEY_BASE") ||
155
      raise """
156
      environment variable SECRET_KEY_BASE is missing.
157
      You can generate one by calling: mix phx.gen.secret
158
      """
159
160
  host = System.get_env("PHX_HOST") || "example.com"
161
162
  config :openagents, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
163
164
  config :openagents, OpenAgentsWeb.Endpoint,
165
    url: [host: host, port: 443, scheme: "https"],
166
    http: [
167
      # Enable IPv6 and bind on all interfaces.
168
      # Set it to  {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
169
      # See the documentation on https://bandit.hexdocs.pm/Bandit.html#t:options/0
170
      # for details about using IPv6 vs IPv4 and loopback vs public addresses.
171
      ip: {0, 0, 0, 0, 0, 0, 0, 0}
172
    ],
173
    secret_key_base: secret_key_base
174
175
  # ## SSL Support
176
  #
177
  # To get SSL working, you will need to add the `https` key
178
  # to your endpoint configuration:
179
  #
180
  #     config :openagents, OpenAgentsWeb.Endpoint,
181
  #       https: [
182
  #         ...,
183
  #         port: 443,
184
  #         cipher_suite: :strong,
185
  #         keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
186
  #         certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
187
  #       ]
188
  #
189
  # The `cipher_suite` is set to `:strong` to support only the
190
  # latest and more secure SSL ciphers. This means old browsers
191
  # and clients may not be supported. You can set it to
192
  # `:compatible` for wider support.
193
  #
194
  # `:keyfile` and `:certfile` expect an absolute path to the key
195
  # and cert in disk or a relative path inside priv, for example
196
  # "priv/ssl/server.key". For all supported SSL configuration
197
  # options, see https://plug.hexdocs.pm/Plug.SSL.html#configure/1
198
  #
199
  # We also recommend setting `force_ssl` in your config/prod.exs,
200
  # ensuring no data is ever sent via http, always redirecting to https:
201
  #
202
  #     config :openagents, OpenAgentsWeb.Endpoint,
203
  #       force_ssl: [hsts: true]
204
  #
205
  # Check `Plug.SSL` for all available options in `force_ssl`.
206
207
  # ## Configuring the mailer
208
  #
209
  # In production you need to configure the mailer to use a different adapter.
210
  # Here is an example configuration for Mailgun:
211
  #
212
  #     config :openagents, OpenAgents.Mailer,
213
  #       adapter: Swoosh.Adapters.Mailgun,
214
  #       api_key: System.get_env("MAILGUN_API_KEY"),
215
  #       domain: System.get_env("MAILGUN_DOMAIN")
216
  #
217
  # Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
218
  # and Finch out-of-the-box. This configuration is typically done at
219
  # compile-time in your config/prod.exs:
220
  #
221
  #     config :swoosh, :api_client, Swoosh.ApiClient.Req
222
  #
223
  # See https://swoosh.hexdocs.pm/Swoosh.html#module-installation for details.
224
end
config/test.exs modified +11

@@ -1,5 +1,7 @@

1 1
import Config
2 2
3
config :openagents, :runtime_environment, :test
4
3 5
# Configure your database
4 6
#
5 7
# The MIX_TEST_PARTITION environment variable can be used

@@ -74,6 +76,15 @@ config :openagents, :computer_controller_enabled, true

74 76
75 77
config :openagents, :voice_recording_encryption_key, Base.encode64(:crypto.strong_rand_bytes(32))
76 78
79
config :openagents, :voice_recording,
80
  enabled: true,
81
  timeslice_ms: 5_000,
82
  maximum_chunk_bytes: 1_048_576,
83
  maximum_chunks: 1_024,
84
  maximum_bytes: 25_165_824,
85
  late_chunk_grace_seconds: 120,
86
  retention_days: 30
87
77 88
config :openagents, :tools, [
78 89
  OpenAgents.Tools.ModuleDiscover,
79 90
  OpenAgents.Tools.GitHubRepoList,
docs/architecture.md modified +6

@@ -174,6 +174,12 @@ accounts, and a PostgreSQL instance that does not share a production failure

174 174
domain. Production is out of scope until all hardening gates, the complete
175 175
staging matrix, failure injection, and the 48-hour soak pass.
176 176
177
`OpenAgents.RuntimeConfig` validates the complete behavior-changing settings
178
boundary before migrations or traffic. The
179
[runtime configuration contract](runtime-configuration.md) defines the safe
180
feature profile, durable storage requirements, staging-gate admission, and
181
content-free readiness report.
182
177 183
## Source control transition
178 184
179 185
GitHub remains the repository's temporary canonical remote during staging
docs/runtime-configuration.md added +152

@@ -0,0 +1,152 @@

1
# Runtime configuration
2
3
Date: 2026-08-20
4
5
Status: Current
6
7
`OpenAgents.RuntimeConfig` is the single typed boundary for behavior-changing
8
runtime settings. It runs before migrations, workers, or the endpoint. Invalid
9
configuration raises a diagnostic containing only the setting name and its
10
requirement; it never echoes a value.
11
12
Production releases require `OPENAGENTS_ENVIRONMENT=staging` or `production`.
13
Production remains locked: `OPENAGENTS_ENVIRONMENT=production` is refused while
14
`OPENAGENTS_PRODUCTION_DEPLOY_ENABLED=false`. Do not change that setting until
15
the staging matrix and soak in the hardening plan are complete and an operator
16
records a separate production decision.
17
18
## Readiness report
19
20
Print the report without starting the application:
21
22
```console
23
MIX_ENV=test mix openagents.config.readiness
24
```
25
26
For an assembled release, run:
27
28
```console
29
bin/config-readiness
30
```
31
32
The JSON report contains only the environment class, staging-gate number,
33
feature booleans, and ready status for the endpoint, database, GitHub,
34
providers, features, forge, and cluster groups. It never contains credentials,
35
URLs, hosts, repository paths, bucket names, node names, or tokens.
36
37
Application startup performs two additional behavioral checks before the
38
endpoint starts:
39
40
- If tools are enabled, the installed executable tool catalog must be nonempty.
41
- Every `forge_hot_load_examples` entry must produce its configured allow or
42
  deny result under the actual hot-load allowlist.
43
44
## Required release settings
45
46
All settings in this section are mandatory in a production release unless
47
marked conditional. An empty value is accepted only where the table explicitly
48
says "empty disables." Secrets come from the staging secret manager through
49
the runtime identity; never put them in images, build arguments, repository
50
URLs, receipts, or checked-in environment files.
51
52
| Group | Environment setting | Requirement |
53
| --- | --- | --- |
54
| Release | `OPENAGENTS_ENVIRONMENT` | `staging`; `production` remains separately locked |
55
| Release | `OPENAGENTS_STAGING_GATE` | Integer `0` through `16`; feature admission is tied to it |
56
| Release | `OPENAGENTS_PRODUCTION_DEPLOY_ENABLED` | `false` until a later production decision |
57
| Endpoint | `PHX_HOST` | Exactly `stage.openagents.com` in staging |
58
| Endpoint | `OPENAGENTS_ALLOWED_ORIGINS` | Comma-separated exact HTTPS origins including `https://stage.openagents.com` |
59
| Endpoint | `OPENAGENTS_HTTPS_ALIASES` | Comma-separated hostnames; empty means no aliases |
60
| Endpoint | `OPENAGENTS_SECURE_COOKIES` | `true` in staging and production |
61
| Endpoint | `SECRET_KEY_BASE` | Staging-only secret |
62
| Endpoint | `PORT` | Port `1` through `65535` |
63
| Database | `OPENAGENTS_DATABASE_MODE` | `url` or `socket` |
64
| Database | `DATABASE_URL` | Required only in `url` mode |
65
| Database | `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `INSTANCE_UNIX_SOCKET` | Required only in `socket` mode |
66
| Database | `OPENAGENTS_DATABASE_IPV6` | Explicit `true` or `false` |
67
| Database | `POOL_SIZE` | Integer `1` through `200` |
68
| Database | `OPENAGENTS_MIGRATE_ON_BOOT` | `true` in staging and production |
69
| GitHub | `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | Staging OAuth application credentials |
70
| GitHub | `GITHUB_REDIRECT_URI` | Exact HTTPS callback on `PHX_HOST` |
71
| GitHub | `GITHUB_OAUTH_SCOPES` | Exactly `read:user,repo` for the retained-token tool model |
72
| GitHub | `GITHUB_TOKEN_ENCRYPTION_KEY` | Base64-encoded 32-byte staging key |
73
| Providers | `OPENAI_API_KEY` | Staging-only provider secret; required by the current text provider |
74
| Providers | `OPENAGENTS_INFERENCE_PROXY_URL` | HTTPS URL without credentials when computers are enabled; empty disables |
75
| Recording | `VOICE_RECORDING_ENCRYPTION_KEY` | Base64-encoded 32-byte key when recording is enabled; empty disables recording storage |
76
77
`PHX_SERVER` is optional for evaluation commands. If set, it must be exactly
78
`true` or `false`.
79
80
## Feature profile
81
82
Every feature flag is a required literal `true` or `false` in a release. The
83
compile-time default and the Gate 5 staging override are deliberately the same
84
safe value unless noted. Advancing `OPENAGENTS_STAGING_GATE` admits a feature;
85
it does not enable it automatically.
86
87
| Feature | Environment setting | Default | Gate 5 staging | Earliest staging gate |
88
| --- | --- | --- | --- | --- |
89
| Tool catalog | `OPENAGENTS_FEATURE_TOOLS` | On | On | 5 |
90
| Voice | `OPENAGENTS_FEATURE_VOICE` | Off | Off | 14 |
91
| Voice recording | `OPENAGENTS_FEATURE_VOICE_RECORDING` | Off | Off | 14 |
92
| Voice retention | `OPENAGENTS_FEATURE_VOICE_RETENTION` | Off | Off | 14 |
93
| Work | `OPENAGENTS_FEATURE_WORK` | Off | Off | 14 |
94
| Computers | `OPENAGENTS_FEATURE_COMPUTERS` | Off | Off | 14 |
95
| Semantic memory | `OPENAGENTS_FEATURE_SEMANTIC_MEMORY` | Off | Off | 14 |
96
| Experience memory | `OPENAGENTS_FEATURE_EXPERIENCE_MEMORY` | Off | Off | 14 |
97
| Graph memory | `OPENAGENTS_FEATURE_GRAPH_MEMORY` | Off | Off | 14 |
98
| Memory portability | `OPENAGENTS_FEATURE_MEMORY_PORTABILITY` | Off | Off | 14 |
99
| Shadow programs | `OPENAGENTS_FEATURE_SHADOW_PROGRAMS` | Off | Off | 14 |
100
| Tool embeddings | `OPENAGENTS_FEATURE_TOOL_EMBEDDINGS` | Off | Off | 14 |
101
| Conversation reset | `OPENAGENTS_FEATURE_CONVERSATION_RESET` | Off | Off | 14 |
102
| Incident fixer | `OPENAGENTS_FEATURE_INCIDENT_FIXER` | Off | Off | 14 |
103
| Turn recovery | `OPENAGENTS_FEATURE_TURN_RECOVERY` | Off | Off | 8 |
104
| Forge Git service | `OPENAGENTS_FEATURE_FORGE` | Off | Off | 12 |
105
| Forge deployment | `OPENAGENTS_FEATURE_FORGE_DEPLOY` | Off | Off | 13 |
106
| Boot convergence | `OPENAGENTS_FEATURE_BOOT_CONVERGENCE` | Off | Off | 13 |
107
| Ra authority | `OPENAGENTS_FEATURE_RA` | Off | Off | 12 |
108
| Horde runtime | `OPENAGENTS_FEATURE_HORDE` | On | On | 5 |
109
110
Invalid combinations fail closed. Recording requires voice and its encryption
111
key; retention requires recording; work and its recovery worker move together;
112
the incident fixer requires computers; deployment requires the forge; boot
113
convergence requires deployment; and distributed features require Horde,
114
discovery, node identity, cookie, and bounded distribution ports.
115
116
## Forge, storage, and cluster settings
117
118
| Environment setting | Gate 5 staging value or rule |
119
| --- | --- |
120
| `OPENAGENTS_FORGE_REPOSITORIES` | Exactly `openagents.com` |
121
| `OPENAGENTS_FORGE_OWNER` | Exactly `OpenAgentsInc` |
122
| `OPENAGENTS_FORGE_INTERNAL_GIT_URL` | HTTP(S) service URL with no embedded credentials |
123
| `OPENAGENTS_FORGE_OPERATOR_TOKEN` | Secret required when the forge is enabled; empty while disabled |
124
| `OPENAGENTS_FORGE_BUILD_EXECUTOR` | `sidecar` |
125
| `OPENAGENTS_FORGE_ARTIFACT_STORE` | `local` until the durable store gate replaces it |
126
| `OPENAGENTS_FORGE_WAL_ADAPTER` | `local` or `gcs` |
127
| `OPENAGENTS_FORGE_WAL_BUCKET` | Required by the GCS WAL; empty for local |
128
| `OPENAGENTS_FORGE_EXPECTED_FLEET_SIZE` | `1` while deployment is disabled; at least `2` for deployment |
129
| `OPENAGENTS_FORGE_DATA_DIR` | Absolute durable path outside `/tmp` |
130
| `OPENAGENTS_FORGE_WAL_DIR` | Absolute durable path outside `/tmp` for the local WAL |
131
| `OPENAGENTS_FORGE_BUILD_DIR` | Absolute durable path outside `/tmp` |
132
| `OPENAGENTS_FORGE_BUILD_QUEUE_DIR` | Absolute durable path outside `/tmp` |
133
| `OPENAGENTS_FORGE_ARTIFACT_DIR` | Absolute durable path outside `/tmp` |
134
| `OPENAGENTS_CODING_JOBS_DIR` | Absolute durable path outside `/tmp` when work or computers are enabled |
135
| `OPENAGENTS_RA_DATA_DIR` | Absolute durable path outside `/tmp` when Ra is enabled |
136
| `OPENAGENTS_RA_EXPECTED_SIZE` | At least `3` when Ra is enabled |
137
| `DNS_CLUSTER_QUERY` | Required when Ra or fleet deployment is enabled; empty otherwise |
138
| `OPENAGENTS_DIST_PORT_MIN`, `OPENAGENTS_DIST_PORT_MAX` | Bounded range; the release wrapper applies the same values |
139
140
Ra and fleet deployment additionally require a stable `RELEASE_NODE`, a
141
`RELEASE_COOKIE` of at least 32 bytes, and `RELEASE_DISTRIBUTION=name` or
142
`longnames`. The readiness report records only whether those settings passed;
143
it never prints their values.
144
145
## Local defaults
146
147
Development and test remain sanctioned degraded modes: the database may use a
148
loopback Unix socket, the endpoint may use loopback HTTP, external features are
149
off, and test fakes may replace provider modules. Runtime service fallbacks no
150
longer use inherited `/tmp/openagents_*` locations. Ra, forge data, forge WAL,
151
build queues, artifacts, and coding jobs default beneath `/var/lib/openagents`;
152
tests that exercise filesystem behavior provide their own disposable paths.
lib/mix/tasks/openagents.config.readiness.ex added +10

@@ -0,0 +1,10 @@

1
defmodule Mix.Tasks.Openagents.Config.Readiness do
2
  use Mix.Task
3
4
  @shortdoc "Print the redacted runtime configuration readiness report"
5
6
  @impl Mix.Task
7
  def run(_arguments) do
8
    OpenAgents.RuntimeConfig.print_readiness!()
9
  end
10
end
lib/openagents/application.ex modified +11 -2

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

7 7
8 8
  @impl true
9 9
  def start(_type, _args) do
10
    runtime_config = OpenAgents.RuntimeConfig.install!()
11
10 12
    # Releases migrate on boot (RELEASE-001): the schema must precede traffic.
11 13
    # Ecto.Migrator.with_repo takes the migration advisory lock, so concurrent
12 14
    # fleet nodes serialize safely and an already-migrated DB is a no-op.

@@ -22,8 +24,15 @@ defmodule OpenAgents.Application do

22 24
23 25
    # Build and install the tool catalog; this snapshot is passed to the
24 26
    # embedding warmer and the turn supervisor below.
25
    tool_snapshot =
26
      OpenAgents.Tools.Registry.install!(Application.fetch_env!(:openagents, :tools))
27
    tool_modules =
28
      if OpenAgents.RuntimeConfig.feature_enabled?(runtime_config, :tools) do
29
        Application.fetch_env!(:openagents, :tools)
30
      else
31
        []
32
      end
33
34
    tool_snapshot = OpenAgents.Tools.Registry.install!(tool_modules)
35
    :ok = OpenAgents.RuntimeConfig.verify_startup!(runtime_config, tool_snapshot)
27 36
28 37
    children = [
29 38
      OpenAgentsWeb.Telemetry,
lib/openagents/cluster/ra_bootstrap.ex modified +1 -1

@@ -33,7 +33,7 @@ defmodule OpenAgents.Cluster.RaBootstrap do

33 33
34 34
  @impl true
35 35
  def init(_opts) do
36
    data_dir = Application.get_env(:openagents, :ra_data_dir, "/tmp/openagents_ra")
36
    data_dir = Application.get_env(:openagents, :ra_data_dir, "/var/lib/openagents/ra")
37 37
    expected = Application.get_env(:openagents, :ra_expected_size, 3)
38 38
39 39
    Ra.start_in(data_dir)
lib/openagents/forge/repos.ex modified +1 -1

@@ -13,7 +13,7 @@ defmodule OpenAgents.Forge.Repos do

13 13
  @doc "The forge data directory (bare repos + WAL cache + beam artifacts)."
14 14
  def data_dir do
15 15
    Application.get_env(:openagents, :forge_data_dir) ||
16
      Path.join(System.tmp_dir!(), "openagents_forge_data")
16
      "/var/lib/openagents/forge"
17 17
  end
18 18
19 19
  @doc "Repositories this forge serves. Bounded, config-owned."
lib/openagents/forge/wal/local.ex modified +3 -3

@@ -2,8 +2,8 @@ defmodule OpenAgents.Forge.WAL.Local do

2 2
  @moduledoc """
3 3
  Filesystem adapter for `OpenAgents.Forge.WAL` — the development and test backend.
4 4
5
  Layout under the base directory (`Application.get_env(:openagents, :forge_wal_dir)`,
6
  defaulting to `openagents_forge_wal` inside `System.tmp_dir!/0`):
5
  Layout under the durable base directory
6
  (`Application.get_env(:openagents, :forge_wal_dir)`).
7 7
8 8
      <base>/<repo>/index.json
9 9
      <base>/<repo>/entries/<key>

@@ -118,6 +118,6 @@ defmodule OpenAgents.Forge.WAL.Local do

118 118
119 119
  defp base_dir do
120 120
    Application.get_env(:openagents, :forge_wal_dir) ||
121
      Path.join(System.tmp_dir!(), "openagents_forge_wal")
121
      "/var/lib/openagents/forge-wal"
122 122
  end
123 123
end
lib/openagents/github_oauth.ex modified +7 -1

@@ -33,7 +33,7 @@ defmodule OpenAgents.GitHubOAuth do

33 33
          "code_challenge" => challenge,
34 34
          "code_challenge_method" => "S256",
35 35
          "redirect_uri" => config.redirect_uri,
36
          "scope" => "read:user repo",
36
          "scope" => oauth_scope(),
37 37
          "state" => state
38 38
        })
39 39

@@ -205,6 +205,12 @@ defmodule OpenAgents.GitHubOAuth do

205 205
    ]
206 206
  end
207 207
208
  defp oauth_scope do
209
    :openagents
210
    |> Application.fetch_env!(:github_oauth_scopes)
211
    |> Enum.join(" ")
212
  end
213
208 214
  defp api_headers do
209 215
    [
210 216
      {"accept", "application/vnd.github+json"},
lib/openagents/memory/open_ai_embeddings.ex modified +2 -2

@@ -4,7 +4,7 @@ defmodule OpenAgents.Memory.OpenAIEmbeddings do

4 4
5 5
  @impl true
6 6
  def embed(text, config) do
7
    with api_key when is_binary(api_key) <- System.get_env("OPENAI_API_KEY"),
7
    with {:ok, api_key} <- OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key),
8 8
         {:ok, response} <-
9 9
           Req.post("https://api.openai.com/v1/embeddings",
10 10
             auth: {:bearer, api_key},

@@ -21,7 +21,7 @@ defmodule OpenAgents.Memory.OpenAIEmbeddings do

21 21
         embedding when is_list(embedding) <- first["embedding"] do
22 22
      {:ok, embedding}
23 23
    else
24
      nil -> {:error, :embedding_provider_unconfigured}
24
      {:error, :not_configured} -> {:error, :embedding_provider_unconfigured}
25 25
      _failure -> {:error, :embedding_provider_failed}
26 26
    end
27 27
  end
lib/openagents/providers/open_ai.ex modified +3 -3

@@ -28,9 +28,9 @@ defmodule OpenAgents.Providers.OpenAI do

28 28
  end
29 29
30 30
  defp fetch_api_key do
31
    case System.get_env("OPENAI_API_KEY") do
32
      key when is_binary(key) and byte_size(key) > 0 -> {:ok, key}
33
      _missing -> {:error, :missing_api_key}
31
    case OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key) do
32
      {:ok, key} -> {:ok, key}
33
      {:error, :not_configured} -> {:error, :missing_api_key}
34 34
    end
35 35
  end
36 36
lib/openagents/runtime_config.ex added +841

@@ -0,0 +1,841 @@

1
defmodule OpenAgents.RuntimeConfig do
2
  @moduledoc """
3
  Typed, fail-closed boundary for behavior-changing runtime configuration.
4
5
  The boundary validates application configuration before migrations, workers,
6
  or the endpoint start. Its readiness report contains only environment,
7
  feature booleans, and group status; secret and infrastructure values never
8
  enter the report or validation errors.
9
  """
10
11
  alias OpenAgents.Forge.HotLoader
12
  alias OpenAgents.Tools.Snapshot
13
14
  @persistent_key {__MODULE__, :current}
15
  @target_repository "openagents.com"
16
  @target_owner "OpenAgentsInc"
17
  @environments [:development, :test, :staging, :production]
18
  @group_names ~w(endpoint database github providers features forge cluster)a
19
20
  @enforce_keys [:environment, :staging_gate, :features, :groups]
21
  defstruct @enforce_keys ++ [:hot_load_allowlist, :hot_load_examples]
22
23
  @type t :: %__MODULE__{
24
          environment: :development | :test | :staging | :production,
25
          staging_gate: 0..16,
26
          features: %{required(atom()) => boolean()},
27
          groups: %{required(atom()) => :ready},
28
          hot_load_allowlist: [String.t()],
29
          hot_load_examples: %{required(String.t()) => boolean()}
30
        }
31
32
  @spec install!() :: t()
33
  def install! do
34
    config = load!() |> verify_compiled_settings!()
35
    :persistent_term.put(@persistent_key, config)
36
    config
37
  end
38
39
  @spec current!() :: t()
40
  def current! do
41
    case :persistent_term.get(@persistent_key, :not_installed) do
42
      %__MODULE__{} = config -> config
43
      :not_installed -> raise "runtime configuration is not installed"
44
    end
45
  end
46
47
  @spec load!(keyword() | map()) :: t()
48
  def load!(settings \\ Application.get_all_env(:openagents)) do
49
    case validate(settings) do
50
      {:ok, config} ->
51
        config
52
53
      {:error, %{setting: setting, reason: reason}} ->
54
        raise ArgumentError, "runtime configuration invalid: #{setting} #{reason}"
55
    end
56
  end
57
58
  @spec validate(keyword() | map()) :: {:ok, t()} | {:error, map()}
59
  def validate(settings) when is_list(settings) or is_map(settings) do
60
    settings = Map.new(settings)
61
62
    with {:ok, environment} <- required_enum(settings, :runtime_environment, @environments),
63
         {:ok, staging_gate} <- required_integer(settings, :staging_gate, 0..16),
64
         :ok <- validate_production_lock(settings, environment),
65
         :ok <- validate_endpoint(settings, environment),
66
         :ok <- validate_database(settings, environment),
67
         :ok <- validate_github(settings, environment),
68
         {:ok, features} <- validate_features(settings, environment, staging_gate),
69
         :ok <- validate_providers(settings, features),
70
         {:ok, allowlist, examples} <- validate_forge(settings, environment, features),
71
         :ok <- validate_cluster(settings, environment, features) do
72
      {:ok,
73
       %__MODULE__{
74
         environment: environment,
75
         staging_gate: staging_gate,
76
         features: features,
77
         groups: Map.new(@group_names, &{&1, :ready}),
78
         hot_load_allowlist: allowlist,
79
         hot_load_examples: examples
80
       }}
81
    end
82
  end
83
84
  def validate(_settings), do: error(:application_environment, "must be a map or keyword list")
85
86
  @spec feature_enabled?(atom()) :: boolean()
87
  def feature_enabled?(feature), do: feature_enabled?(current!(), feature)
88
89
  @spec feature_enabled?(t(), atom()) :: boolean()
90
  def feature_enabled?(%__MODULE__{features: features}, feature),
91
    do: Map.fetch!(features, feature)
92
93
  @spec fetch_secret(:openai_api_key) :: {:ok, String.t()} | {:error, :not_configured}
94
  def fetch_secret(:openai_api_key) do
95
    case Application.fetch_env(:openagents, :openai_api_key) do
96
      {:ok, value} when is_binary(value) and byte_size(value) > 0 -> {:ok, value}
97
      _missing -> {:error, :not_configured}
98
    end
99
  end
100
101
  @spec readiness_report(t()) :: map()
102
  def readiness_report(config \\ current!()) do
103
    %{
104
      "schema" => "openagents.runtime_configuration.v1",
105
      "status" => "ready",
106
      "environment" => Atom.to_string(config.environment),
107
      "staging_gate" => config.staging_gate,
108
      "groups" =>
109
        Map.new(config.groups, fn {name, status} -> {to_string(name), to_string(status)} end),
110
      "features" =>
111
        Map.new(config.features, fn {name, enabled?} -> {to_string(name), enabled?} end)
112
    }
113
  end
114
115
  @spec print_readiness!() :: :ok
116
  def print_readiness! do
117
    load!()
118
    |> verify_compiled_settings!()
119
    |> readiness_report()
120
    |> Jason.encode!()
121
    |> IO.puts()
122
  end
123
124
  @spec verify_startup!(t(), Snapshot.t()) :: :ok
125
  def verify_startup!(%__MODULE__{} = config, %Snapshot{} = snapshot) do
126
    if feature_enabled?(config, :tools) and map_size(snapshot.tools) == 0 do
127
      raise ArgumentError, "runtime self-test failed: tools catalog is empty"
128
    end
129
130
    Enum.each(config.hot_load_examples, fn {module_name, expected?} ->
131
      if HotLoader.allowlisted?(module_name, config.hot_load_allowlist) != expected? do
132
        raise ArgumentError,
133
              "runtime self-test failed: forge_hot_load_examples classification mismatch"
134
      end
135
    end)
136
137
    :ok
138
  end
139
140
  defp validate_production_lock(settings, :production) do
141
    case Map.fetch(settings, :production_deploy_enabled) do
142
      {:ok, true} -> :ok
143
      _locked -> error(:production_deploy_enabled, "must be explicitly true for production")
144
    end
145
  end
146
147
  defp validate_production_lock(_settings, _environment), do: :ok
148
149
  defp verify_compiled_settings!(%__MODULE__{environment: environment} = config)
150
       when environment in [:staging, :production] do
151
    if keyword_value(OpenAgentsWeb.Endpoint.session_options(), :secure) == true do
152
      config
153
    else
154
      raise ArgumentError, "runtime configuration invalid: secure_cookies must be compiled true"
155
    end
156
  end
157
158
  defp verify_compiled_settings!(%__MODULE__{} = config), do: config
159
160
  defp validate_endpoint(settings, environment) do
161
    endpoint = Map.get(settings, OpenAgentsWeb.Endpoint, [])
162
    url = keyword_value(endpoint, :url, [])
163
    host = keyword_value(url, :host)
164
    check_origin = keyword_value(endpoint, :check_origin)
165
    secure_cookies = Map.get(settings, :secure_cookies)
166
    aliases = Map.get(settings, :https_aliases)
167
168
    cond do
169
      not hostname?(host) ->
170
        error(:endpoint_host, "must be a valid hostname")
171
172
      not (is_list(aliases) and Enum.all?(aliases, &hostname?/1)) ->
173
        error(:https_aliases, "must be a list of hostnames")
174
175
      environment in [:staging, :production] and secure_cookies != true ->
176
        error(:secure_cookies, "must be true")
177
178
      environment == :staging and host != "stage.openagents.com" ->
179
        error(:endpoint_host, "must be the staging hostname")
180
181
      environment in [:staging, :production] and
182
          not valid_origins?(check_origin, host, aliases) ->
183
        error(:allowed_origins, "must contain every exact HTTPS public origin")
184
185
      true ->
186
        :ok
187
    end
188
  end
189
190
  defp validate_database(settings, environment) do
191
    repo = Map.get(settings, OpenAgents.Repo, [])
192
    migrate? = Map.get(settings, :migrate_on_boot)
193
194
    connection? =
195
      case keyword_value(repo, :url) do
196
        value when is_binary(value) -> database_url?(value)
197
        nil -> socket_database?(repo)
198
        _invalid -> false
199
      end
200
201
    cond do
202
      not connection? ->
203
        error(:database_connection, "must be a PostgreSQL URL or complete socket configuration")
204
205
      environment in [:staging, :production] and migrate? != true ->
206
        error(:migrate_on_boot, "must be true")
207
208
      true ->
209
        :ok
210
    end
211
  end
212
213
  defp validate_github(settings, environment) do
214
    oauth = Map.get(settings, :github_oauth, [])
215
    client_id = keyword_value(oauth, :client_id)
216
    client_secret = keyword_value(oauth, :client_secret)
217
    redirect_uri = keyword_value(oauth, :redirect_uri)
218
    scopes = Map.get(settings, :github_oauth_scopes)
219
    token_key = Map.get(settings, :github_token_encryption_key)
220
221
    with :ok <- ensure(present?(client_id), :github_oauth_client_id, "is required"),
222
         :ok <- ensure(present?(client_secret), :github_oauth_client_secret, "is required"),
223
         :ok <- validate_redirect(redirect_uri, environment),
224
         :ok <-
225
           ensure(
226
             scopes == ["read:user", "repo"],
227
             :github_oauth_scopes,
228
             "must match the retained-token tool model"
229
           ),
230
         :ok <-
231
           ensure(
232
             encryption_key?(token_key),
233
             :github_token_encryption_key,
234
             "must be a base64-encoded 32-byte key"
235
           ) do
236
      :ok
237
    end
238
  end
239
240
  defp validate_features(settings, environment, staging_gate) do
241
    with {:ok, tools?} <- required_boolean(settings, :tools_enabled),
242
         {:ok, voice?} <- nested_boolean(settings, :voice, :enabled),
243
         {:ok, recording?} <- nested_boolean(settings, :voice_recording, :enabled),
244
         {:ok, work?} <- nested_boolean(settings, :work, :enabled),
245
         {:ok, semantic?} <- nested_boolean(settings, :semantic_index, :enabled),
246
         {:ok, experience?} <- nested_boolean(settings, :experience_memory, :enabled),
247
         {:ok, graph?} <- nested_boolean(settings, :graph_memory, :enabled),
248
         {:ok, portability?} <- nested_boolean(settings, :memory_portability, :enabled),
249
         {:ok, shadow?} <- nested_boolean(settings, :shadow_programs, :enabled),
250
         {:ok, tool_embeddings?} <-
251
           nested_boolean(settings, :tool_discovery, :embeddings_enabled),
252
         {:ok, forge?} <- required_boolean(settings, :forge_enabled),
253
         {:ok, forge_deploy?} <- required_boolean(settings, :forge_deploy_lane_enabled),
254
         {:ok, boot_convergence?} <- required_boolean(settings, :forge_boot_converge_enabled),
255
         {:ok, turn_recovery?} <- required_boolean(settings, :turn_recovery_enabled),
256
         {:ok, voice_retention?} <- required_boolean(settings, :voice_retention_enabled),
257
         {:ok, voice_recovery?} <-
258
           required_boolean(settings, :voice_recovery_worker_enabled),
259
         {:ok, work_workers?} <- required_boolean(settings, :work_workers_enabled),
260
         {:ok, computers?} <- required_boolean(settings, :computer_controller_enabled),
261
         {:ok, conversation_reset?} <-
262
           required_boolean(settings, :conversation_reset_enabled),
263
         {:ok, incident_fixer?} <- required_boolean(settings, :incident_fixer_enabled),
264
         {:ok, ra?} <- required_boolean(settings, :ra_enabled) do
265
      features = %{
266
        tools: tools?,
267
        voice: voice?,
268
        voice_recording: recording?,
269
        voice_retention: voice_retention?,
270
        work: work?,
271
        computers: computers?,
272
        semantic_memory: semantic?,
273
        experience_memory: experience?,
274
        graph_memory: graph?,
275
        memory_portability: portability?,
276
        shadow_programs: shadow?,
277
        tool_embeddings: tool_embeddings?,
278
        forge: forge?,
279
        forge_deploy: forge_deploy?,
280
        boot_convergence: boot_convergence?,
281
        turn_recovery: turn_recovery?,
282
        voice_recovery: voice_recovery?,
283
        work_workers: work_workers?,
284
        conversation_reset: conversation_reset?,
285
        incident_fixer: incident_fixer?,
286
        ra: ra?
287
      }
288
289
      case validate_feature_combinations(features, settings, environment, staging_gate) do
290
        :ok -> {:ok, features}
291
        {:error, _details} = error -> error
292
      end
293
    end
294
  end
295
296
  defp validate_feature_combinations(features, settings, environment, staging_gate) do
297
    early_staging_features =
298
      Enum.filter(
299
        [
300
          :voice,
301
          :voice_recording,
302
          :work,
303
          :computers,
304
          :semantic_memory,
305
          :experience_memory,
306
          :graph_memory,
307
          :memory_portability,
308
          :shadow_programs,
309
          :tool_embeddings,
310
          :conversation_reset,
311
          :incident_fixer
312
        ],
313
        &features[&1]
314
      )
315
316
    cond do
317
      not valid_feature_domains?(settings) ->
318
        error(:feature_configuration, "contains invalid limits or provider settings")
319
320
      environment in [:staging, :production] and features.voice_recording and
321
          not features.voice ->
322
        error(:voice_recording, "cannot be enabled while voice is disabled")
323
324
      features.voice_recording and
325
          not encryption_key?(Map.get(settings, :voice_recording_encryption_key)) ->
326
        error(:voice_recording_encryption_key, "is required when recording is enabled")
327
328
      features.voice_retention and not features.voice_recording ->
329
        error(:voice_retention_enabled, "requires voice recording")
330
331
      features.voice_recovery and not features.voice ->
332
        error(:voice_recovery_worker_enabled, "requires voice")
333
334
      features.work_workers != features.work ->
335
        error(:work_workers_enabled, "must match the work feature")
336
337
      environment in [:staging, :production] and features.computers and
338
          not clean_service_url?(Map.get(settings, :inference_proxy_url)) ->
339
        error(:inference_proxy_url, "must be an HTTPS URL without credentials")
340
341
      environment in [:staging, :production] and
342
        (features.work or features.computers) and
343
          not durable_path?(Map.get(settings, :coding_jobs_dir)) ->
344
        error(:coding_jobs_dir, "must be durable when work or computers are enabled")
345
346
      features.incident_fixer and not features.computers ->
347
        error(:incident_fixer_enabled, "requires computers")
348
349
      features.forge_deploy and not features.forge ->
350
        error(:forge_deploy_lane_enabled, "requires the forge")
351
352
      features.boot_convergence and not features.forge_deploy ->
353
        error(:forge_boot_converge_enabled, "requires the forge deployment lane")
354
355
      environment == :staging and staging_gate < 14 and early_staging_features != [] ->
356
        error(
357
          :staging_gate,
358
          "does not admit advanced product features"
359
        )
360
361
      environment == :staging and staging_gate < 13 and
362
          (features.forge_deploy or features.boot_convergence) ->
363
        error(:staging_gate, "does not admit forge deployment or boot convergence")
364
365
      environment == :staging and staging_gate < 12 and features.ra ->
366
        error(:staging_gate, "does not admit Ra")
367
368
      true ->
369
        :ok
370
    end
371
  end
372
373
  defp validate_providers(settings, features) do
374
    provider = Map.get(settings, :provider)
375
    model = Map.get(settings, :openai_model)
376
    openai_key = Map.get(settings, :openai_api_key)
377
    voice = Map.get(settings, :voice, [])
378
    shadow = Map.get(settings, :shadow_programs, [])
379
    semantic = Map.get(settings, :semantic_index, [])
380
381
    needs_openai? =
382
      provider == OpenAgents.Providers.OpenAI or features.voice or features.shadow_programs or
383
        features.semantic_memory or features.tool_embeddings
384
385
    cond do
386
      not is_atom(provider) ->
387
        error(:provider, "must be a module")
388
389
      not (is_binary(model) and byte_size(model) in 1..128) ->
390
        error(:openai_model, "must be a bounded model identifier")
391
392
      needs_openai? and not present?(openai_key) ->
393
        error(:openai_api_key, "is required by an enabled OpenAI provider")
394
395
      not valid_voice_config?(voice) ->
396
        error(:voice, "has invalid admission settings")
397
398
      not valid_shadow_config?(shadow) ->
399
        error(:shadow_programs, "has invalid settings")
400
401
      not valid_semantic_config?(semantic) ->
402
        error(:semantic_index, "has invalid settings")
403
404
      true ->
405
        :ok
406
    end
407
  end
408
409
  defp validate_forge(settings, environment, features) do
410
    repos = Map.get(settings, :forge_repos)
411
    owners = Map.get(settings, :forge_repo_owners)
412
    visibility = Map.get(settings, :forge_public_visibility)
413
    paths = Map.get(settings, :forge_public_paths)
414
    internal_url = Map.get(settings, :forge_internal_git_url)
415
    allowlist = Map.get(settings, :forge_hot_load_allowlist)
416
    examples = Map.get(settings, :forge_hot_load_examples)
417
    expected_fleet_size = Map.get(settings, :forge_expected_fleet_size)
418
    artifact_store = Map.get(settings, :forge_artifact_store)
419
    build_executor = Map.get(settings, :forge_build_executor)
420
    operator_token = Map.get(settings, :forge_operator_token)
421
    durable_required? = environment in [:staging, :production] and features.forge
422
423
    with :ok <-
424
           ensure(valid_repositories?(repos), :forge_repos, "must list valid repository names"),
425
         :ok <-
426
           ensure(
427
             target_repository?(repos, owners, visibility, paths),
428
             :forge_target_repository,
429
             "must be OpenAgentsInc/openagents.com"
430
           ),
431
         :ok <-
432
           ensure(
433
             clean_internal_url?(internal_url),
434
             :forge_internal_git_url,
435
             "must be an HTTP URL without credentials"
436
           ),
437
         :ok <-
438
           ensure(
439
             valid_allowlist?(allowlist),
440
             :forge_hot_load_allowlist,
441
             "must contain module names or prefixes"
442
           ),
443
         :ok <-
444
           ensure(
445
             valid_examples?(examples, allowlist),
446
             :forge_hot_load_examples,
447
             "must classify every example as configured"
448
           ),
449
         :ok <-
450
           ensure(
451
             is_integer(expected_fleet_size) and expected_fleet_size in 1..100,
452
             :forge_expected_fleet_size,
453
             "must be between 1 and 100"
454
           ),
455
         :ok <-
456
           ensure(
457
             artifact_store in [:local, :gcs],
458
             :forge_artifact_store,
459
             "must be a supported store"
460
           ),
461
         :ok <-
462
           ensure(
463
             is_atom(build_executor),
464
             :forge_build_executor,
465
             "must be a module"
466
           ),
467
         :ok <-
468
           ensure(
469
             not features.forge_deploy or expected_fleet_size >= 2,
470
             :forge_expected_fleet_size,
471
             "must include a canary and peer for deployment"
472
           ),
473
         :ok <- validate_forge_secrets(operator_token, durable_required? or features.forge_deploy),
474
         :ok <- validate_forge_paths(settings, durable_required? or features.forge_deploy),
475
         :ok <- validate_wal(settings, durable_required? or features.forge_deploy) do
476
      {:ok, allowlist, examples}
477
    end
478
  end
479
480
  defp validate_forge_secrets(operator_token, true) do
481
    if present?(operator_token),
482
      do: :ok,
483
      else: error(:forge_operator_token, "is required while the forge is enabled")
484
  end
485
486
  defp validate_forge_secrets(_operator_token, false), do: :ok
487
488
  defp validate_forge_paths(settings, true) do
489
    keys = [:forge_data_dir, :forge_build_dir, :forge_build_queue_dir, :forge_artifact_dir]
490
491
    if Enum.all?(keys, &durable_path?(Map.get(settings, &1))) do
492
      :ok
493
    else
494
      error(:forge_storage_paths, "must be absolute and outside temporary storage")
495
    end
496
  end
497
498
  defp validate_forge_paths(_settings, false), do: :ok
499
500
  defp validate_wal(settings, true) do
501
    case Map.get(settings, :forge_wal_adapter) do
502
      OpenAgents.Forge.WAL.Local ->
503
        if durable_path?(Map.get(settings, :forge_wal_dir)),
504
          do: :ok,
505
          else: error(:forge_wal_dir, "must be durable for the local WAL")
506
507
      OpenAgents.Forge.WAL.Gcs ->
508
        if present?(Map.get(settings, :forge_wal_bucket)) and
509
             is_atom(Map.get(settings, :forge_gcs_token_provider)),
510
           do: :ok,
511
           else: error(:forge_wal, "requires a bucket and token provider")
512
513
      _invalid ->
514
        error(:forge_wal_adapter, "must be a supported adapter")
515
    end
516
  end
517
518
  defp validate_wal(_settings, false), do: :ok
519
520
  defp validate_cluster(settings, environment, features) do
521
    horde? = Map.get(settings, :horde_enabled)
522
    expected = Map.get(settings, :ra_expected_size)
523
    data_dir = Map.get(settings, :ra_data_dir)
524
    dns_query = Map.get(settings, :dns_cluster_query)
525
    distribution = Map.get(settings, :distribution, [])
526
    distributed_feature? = features.ra or features.forge_deploy
527
528
    cond do
529
      not is_boolean(horde?) ->
530
        error(:horde_enabled, "must be boolean")
531
532
      not (is_integer(expected) and expected in 1..100) ->
533
        error(:ra_expected_size, "must be between 1 and 100")
534
535
      features.ra and expected < 3 ->
536
        error(:ra_expected_size, "must admit a quorum")
537
538
      features.ra and environment in [:staging, :production] and not durable_path?(data_dir) ->
539
        error(:ra_data_dir, "must be durable")
540
541
      features.ra and not present?(dns_query) ->
542
        error(:dns_cluster_query, "is required when Ra is enabled")
543
544
      distributed_feature? and not horde? ->
545
        error(:horde_enabled, "is required for distributed features")
546
547
      features.forge_deploy and not present?(dns_query) ->
548
        error(:dns_cluster_query, "is required for fleet deployment")
549
550
      distributed_feature? and not valid_distribution?(distribution) ->
551
        error(:distribution, "must configure node name, cookie, and bounded ports")
552
553
      true ->
554
        :ok
555
    end
556
  end
557
558
  defp required_enum(settings, key, allowed) do
559
    case Map.fetch(settings, key) do
560
      {:ok, value} ->
561
        if value in allowed,
562
          do: {:ok, value},
563
          else: error(key, "must be one of the admitted values")
564
565
      _invalid ->
566
        error(key, "must be one of the admitted values")
567
    end
568
  end
569
570
  defp required_integer(settings, key, range) do
571
    case Map.fetch(settings, key) do
572
      {:ok, value} when is_integer(value) ->
573
        if value in range,
574
          do: {:ok, value},
575
          else: error(key, "must be within the admitted range")
576
577
      _invalid ->
578
        error(key, "must be within the admitted range")
579
    end
580
  end
581
582
  defp required_boolean(settings, key) do
583
    case Map.fetch(settings, key) do
584
      {:ok, value} when is_boolean(value) -> {:ok, value}
585
      _invalid -> error(key, "must be boolean")
586
    end
587
  end
588
589
  defp nested_boolean(settings, key, nested_key) do
590
    case Map.fetch(settings, key) do
591
      {:ok, values} when is_list(values) ->
592
        case Keyword.fetch(values, nested_key) do
593
          {:ok, value} when is_boolean(value) -> {:ok, value}
594
          _invalid -> error(key, "must contain a boolean #{nested_key}")
595
        end
596
597
      _invalid ->
598
        error(key, "must be a keyword list")
599
    end
600
  end
601
602
  defp validate_redirect(redirect_uri, environment) do
603
    case URI.new(redirect_uri) do
604
      {:ok,
605
       %URI{
606
         scheme: scheme,
607
         host: host,
608
         path: "/auth/github/callback",
609
         query: nil,
610
         fragment: nil,
611
         userinfo: nil
612
       }}
613
      when is_binary(host) and
614
             (scheme == "https" or
615
                (environment in [:development, :test] and scheme == "http" and
616
                   host in ["127.0.0.1", "localhost"])) ->
617
        :ok
618
619
      _invalid ->
620
        error(:github_oauth_redirect_uri, "must be an exact admitted callback")
621
    end
622
  end
623
624
  defp valid_origins?(origins, host, aliases) when is_list(origins) and is_list(aliases) do
625
    required = Enum.map([host | aliases], &"https://#{&1}")
626
    Enum.all?(origins, &https_origin?/1) and Enum.all?(required, &(&1 in origins))
627
  end
628
629
  defp valid_origins?(_origins, _host, _aliases), do: false
630
631
  defp https_origin?(origin) do
632
    case URI.new(origin) do
633
      {:ok,
634
       %URI{scheme: "https", host: host, path: path, query: nil, fragment: nil, userinfo: nil}}
635
      when is_binary(host) and path in [nil, "", "/"] ->
636
        true
637
638
      _invalid ->
639
        false
640
    end
641
  end
642
643
  defp database_url?(url) do
644
    case URI.new(url) do
645
      {:ok, %URI{scheme: scheme, host: host}} when scheme in ["ecto", "postgres", "postgresql"] ->
646
        present?(host)
647
648
      _invalid ->
649
        false
650
    end
651
  end
652
653
  defp socket_database?(settings) do
654
    Enum.all?([:username, :database, :socket_dir], &present?(keyword_value(settings, &1))) and
655
      is_integer(keyword_value(settings, :pool_size))
656
  end
657
658
  defp valid_voice_config?(settings) when is_list(settings) do
659
    architecture = keyword_value(settings, :architecture)
660
    provider = keyword_value(settings, :provider)
661
    model = keyword_value(settings, :model)
662
    voice = keyword_value(settings, :voice)
663
    effort = keyword_value(settings, :reasoning_effort)
664
    duration = keyword_value(settings, :maximum_session_seconds)
665
666
    architecture == :openai_realtime and provider == "openai" and model == "gpt-realtime-2.1" and
667
      voice in ["marin", "cedar"] and effort in ["low", "medium", "high"] and
668
      is_integer(duration) and duration in 1..3_300
669
  end
670
671
  defp valid_voice_config?(_settings), do: false
672
673
  defp valid_shadow_config?(settings) when is_list(settings) do
674
    is_boolean(keyword_value(settings, :enabled)) and is_atom(keyword_value(settings, :provider)) and
675
      is_integer(keyword_value(settings, :timeout_ms)) and
676
      keyword_value(settings, :timeout_ms) in 100..60_000
677
  end
678
679
  defp valid_shadow_config?(_settings), do: false
680
681
  defp valid_semantic_config?(settings) when is_list(settings) do
682
    is_boolean(keyword_value(settings, :enabled)) and is_atom(keyword_value(settings, :provider)) and
683
      present?(keyword_value(settings, :model_id)) and
684
      present?(keyword_value(settings, :model_version)) and
685
      is_integer(keyword_value(settings, :dimensions)) and
686
      keyword_value(settings, :dimensions) in 1..4_096 and
687
      is_integer(keyword_value(settings, :batch_size)) and
688
      keyword_value(settings, :batch_size) in 1..1_000 and
689
      is_integer(keyword_value(settings, :poll_interval_ms)) and
690
      keyword_value(settings, :poll_interval_ms) in 100..60_000
691
  end
692
693
  defp valid_semantic_config?(_settings), do: false
694
695
  defp valid_feature_domains?(settings) do
696
    recording = Map.get(settings, :voice_recording, [])
697
    experience = Map.get(settings, :experience_memory, [])
698
    graph = Map.get(settings, :graph_memory, [])
699
    discovery = Map.get(settings, :tool_discovery, [])
700
    maximum_chunk_bytes = keyword_value(recording, :maximum_chunk_bytes)
701
    maximum_bytes = keyword_value(recording, :maximum_bytes)
702
703
    keyword_integer_in?(recording, :timeslice_ms, 250..60_000) and
704
      keyword_integer_in?(recording, :maximum_chunk_bytes, 1..10_485_760) and
705
      keyword_integer_in?(recording, :maximum_chunks, 1..4_096) and
706
      is_integer(maximum_bytes) and is_integer(maximum_chunk_bytes) and
707
      maximum_bytes in maximum_chunk_bytes..10_737_418_240 and
708
      keyword_integer_in?(recording, :late_chunk_grace_seconds, 1..3_600) and
709
      keyword_integer_in?(recording, :retention_days, 1..365) and
710
      keyword_integer_in?(experience, :maximum_records, 1..50) and
711
      keyword_integer_in?(experience, :maximum_patterns, 1..20) and
712
      keyword_integer_in?(experience, :maximum_bytes, 256..65_536) and
713
      keyword_integer_in?(graph, :maximum_nodes, 1..10_000) and
714
      keyword_integer_in?(graph, :maximum_depth, 1..20) and
715
      keyword_integer_in?(graph, :maximum_export_artifacts, 1..100_000) and
716
      is_boolean(keyword_value(discovery, :embeddings_enabled)) and
717
      is_atom(keyword_value(discovery, :provider)) and
718
      present?(keyword_value(discovery, :model_id)) and
719
      present?(keyword_value(discovery, :model_version)) and
720
      keyword_integer_in?(discovery, :dimensions, 1..4_096) and
721
      keyword_integer_in?(discovery, :top_k, 1..64) and
722
      integer_setting_in?(settings, :conversation_page_size, 1..200) and
723
      integer_setting_in?(settings, :maximum_message_bytes, 1..1_048_576) and
724
      integer_setting_in?(settings, :turn_rate_limit, 1..10_000) and
725
      integer_setting_in?(settings, :inference_grant_max_total_tokens, 1..100_000_000) and
726
      integer_setting_in?(settings, :inference_grant_max_calls, 1..10_000) and
727
      integer_setting_in?(settings, :inference_grant_max_cost_microusd, 1..1_000_000_000) and
728
      integer_setting_in?(settings, :inference_grant_ttl_seconds, 1..86_400)
729
  end
730
731
  defp keyword_integer_in?(settings, key, range) do
732
    value = keyword_value(settings, key)
733
    is_integer(value) and value in range
734
  end
735
736
  defp integer_setting_in?(settings, key, range) do
737
    value = Map.get(settings, key)
738
    is_integer(value) and value in range
739
  end
740
741
  defp valid_repositories?(repos) when is_list(repos) and repos != [] do
742
    Enum.all?(repos, &Regex.match?(~r/\A[a-z0-9][a-z0-9._-]{0,63}\z/, &1)) and
743
      length(repos) == length(Enum.uniq(repos))
744
  end
745
746
  defp valid_repositories?(_repos), do: false
747
748
  defp target_repository?(repos, owners, visibility, paths)
749
       when is_list(repos) and is_map(owners) and is_map(visibility) and is_map(paths) do
750
    @target_repository in repos and owners[@target_repository] == @target_owner and
751
      Map.has_key?(visibility, @target_repository) and Map.has_key?(paths, @target_repository)
752
  end
753
754
  defp target_repository?(_repos, _owners, _visibility, _paths), do: false
755
756
  defp clean_internal_url?(url) do
757
    case URI.new(url) do
758
      {:ok, %URI{scheme: scheme, host: host, userinfo: nil}}
759
      when scheme in ["http", "https"] and is_binary(host) ->
760
        true
761
762
      _invalid ->
763
        false
764
    end
765
  end
766
767
  defp clean_service_url?(url) do
768
    case URI.new(url) do
769
      {:ok, %URI{scheme: "https", host: host, userinfo: nil}} when is_binary(host) -> true
770
      _invalid -> false
771
    end
772
  end
773
774
  defp valid_allowlist?(allowlist) when is_list(allowlist) and allowlist != [] do
775
    Enum.all?(allowlist, fn entry ->
776
      is_binary(entry) and Regex.match?(~r/\A[A-Z][A-Za-z0-9_.]*\z/, entry) and
777
        not String.contains?(entry, ["/", "\\"])
778
    end)
779
  end
780
781
  defp valid_allowlist?(_allowlist), do: false
782
783
  defp valid_examples?(examples, allowlist) when is_map(examples) do
784
    map_size(examples) >= 2 and
785
      Enum.all?(examples, fn {module_name, expected?} ->
786
        is_binary(module_name) and is_boolean(expected?) and
787
          HotLoader.allowlisted?(module_name, allowlist) == expected?
788
      end)
789
  end
790
791
  defp valid_examples?(_examples, _allowlist), do: false
792
793
  defp valid_distribution?(settings) when is_list(settings) do
794
    min = keyword_value(settings, :port_min)
795
    max = keyword_value(settings, :port_max)
796
797
    keyword_value(settings, :enabled) == true and
798
      keyword_value(settings, :node_configured) == true and
799
      keyword_value(settings, :cookie_configured) == true and is_integer(min) and
800
      is_integer(max) and min in 1_024..65_535 and max in min..65_535 and max - min <= 100
801
  end
802
803
  defp valid_distribution?(_settings), do: false
804
805
  defp hostname?(value) when is_binary(value) do
806
    byte_size(value) in 1..253 and
807
      Regex.match?(
808
        ~r/\A(?=.{1,253}\z)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\z/,
809
        value
810
      )
811
  end
812
813
  defp hostname?(_value), do: false
814
815
  defp encryption_key?(value) when is_binary(value) do
816
    match?({:ok, key} when byte_size(key) == 32, Base.decode64(value))
817
  end
818
819
  defp encryption_key?(_value), do: false
820
821
  defp durable_path?(path) when is_binary(path) do
822
    Path.type(path) == :absolute and path != "/" and
823
      not (path == "/tmp" or String.starts_with?(path, "/tmp/"))
824
  end
825
826
  defp durable_path?(_path), do: false
827
828
  defp present?(value), do: is_binary(value) and String.trim(value) != ""
829
830
  defp keyword_value(values, key, default \\ nil)
831
832
  defp keyword_value(values, key, default) when is_list(values),
833
    do: Keyword.get(values, key, default)
834
835
  defp keyword_value(_values, _key, default), do: default
836
837
  defp ensure(true, _setting, _reason), do: :ok
838
  defp ensure(false, setting, reason), do: error(setting, reason)
839
840
  defp error(setting, reason), do: {:error, %{setting: setting, reason: reason}}
841
end
lib/openagents/runtime_supervisor.ex modified +8 -8

@@ -50,7 +50,7 @@ defmodule OpenAgents.RuntimeSupervisor do

50 50
  end
51 51
52 52
  defp maybe_forge do
53
    if Application.get_env(:openagents, :forge_enabled, false) do
53
    if OpenAgents.RuntimeConfig.feature_enabled?(:forge) do
54 54
      [OpenAgents.Forge.Supervisor]
55 55
    else
56 56
      []

@@ -58,7 +58,7 @@ defmodule OpenAgents.RuntimeSupervisor do

58 58
  end
59 59
60 60
  defp maybe_semantic_worker do
61
    if Application.get_env(:openagents, :semantic_index, enabled: false)[:enabled] do
61
    if OpenAgents.RuntimeConfig.feature_enabled?(:semantic_memory) do
62 62
      [OpenAgents.Memory.SemanticWorker]
63 63
    else
64 64
      []

@@ -66,7 +66,7 @@ defmodule OpenAgents.RuntimeSupervisor do

66 66
  end
67 67
68 68
  defp maybe_turn_recovery do
69
    if Application.get_env(:openagents, :turn_recovery_enabled, false) do
69
    if OpenAgents.RuntimeConfig.feature_enabled?(:turn_recovery) do
70 70
      [OpenAgents.TurnRecovery]
71 71
    else
72 72
      []

@@ -74,7 +74,7 @@ defmodule OpenAgents.RuntimeSupervisor do

74 74
  end
75 75
76 76
  defp maybe_work_recovery do
77
    if Application.get_env(:openagents, :work, enabled: false)[:enabled] do
77
    if OpenAgents.RuntimeConfig.feature_enabled?(:work_workers) do
78 78
      [OpenAgents.WorkRecovery]
79 79
    else
80 80
      []

@@ -82,7 +82,7 @@ defmodule OpenAgents.RuntimeSupervisor do

82 82
  end
83 83
84 84
  defp maybe_voice_recovery do
85
    if Application.get_env(:openagents, :voice, enabled: false)[:enabled] do
85
    if OpenAgents.RuntimeConfig.feature_enabled?(:voice_recovery) do
86 86
      [OpenAgents.VoiceRecovery]
87 87
    else
88 88
      []

@@ -90,8 +90,8 @@ defmodule OpenAgents.RuntimeSupervisor do

90 90
  end
91 91
92 92
  defp maybe_voice_retention do
93
    if Application.get_env(:openagents, :voice, enabled: false)[:enabled] and
94
         Application.get_env(:openagents, :voice_retention_enabled, false) do
93
    if OpenAgents.RuntimeConfig.feature_enabled?(:voice) and
94
         OpenAgents.RuntimeConfig.feature_enabled?(:voice_retention) do
95 95
      [OpenAgents.Voice.Retention]
96 96
    else
97 97
      []

@@ -99,7 +99,7 @@ defmodule OpenAgents.RuntimeSupervisor do

99 99
  end
100 100
101 101
  defp maybe_ra_bootstrap do
102
    if Application.get_env(:openagents, :ra_enabled, false) do
102
    if OpenAgents.RuntimeConfig.feature_enabled?(:ra) do
103 103
      [OpenAgents.Cluster.RaBootstrap]
104 104
    else
105 105
      []
lib/openagents/shadow_programs/open_ai.ex modified +3 -3

@@ -47,9 +47,9 @@ defmodule OpenAgents.ShadowPrograms.OpenAI do

47 47
  end
48 48
49 49
  defp fetch_api_key do
50
    case System.get_env("OPENAI_API_KEY") do
51
      key when is_binary(key) and byte_size(key) > 0 -> {:ok, key}
52
      _missing -> {:error, :missing_api_key}
50
    case OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key) do
51
      {:ok, key} -> {:ok, key}
52
      {:error, :not_configured} -> {:error, :missing_api_key}
53 53
    end
54 54
  end
55 55
lib/openagents/tools/repository.ex modified +1 -1

@@ -31,7 +31,7 @@ defmodule OpenAgents.Tools.Repository do

31 31
    Application.get_env(
32 32
      :openagents,
33 33
      :coding_jobs_dir,
34
      Path.join(System.tmp_dir!(), "openagents-coding-jobs")
34
      "/var/lib/openagents/coding-jobs"
35 35
    )
36 36
  end
37 37
lib/openagents/voice/config.ex modified +8 -1

@@ -50,7 +50,7 @@ defmodule OpenAgents.Voice.Config do

50 50
  end
51 51
52 52
  @spec validate_runtime!(t(), (String.t() -> String.t() | nil)) :: :ok
53
  def validate_runtime!(config, get_environment \\ &System.get_env/1)
53
  def validate_runtime!(config, get_environment \\ &configured_secret/1)
54 54
55 55
  def validate_runtime!(%__MODULE__{enabled?: false}, _get_environment), do: :ok
56 56

@@ -132,6 +132,13 @@ defmodule OpenAgents.Voice.Config do

132 132
    end
133 133
  end
134 134
135
  defp configured_secret("OPENAI_API_KEY") do
136
    case OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key) do
137
      {:ok, value} -> value
138
      {:error, :not_configured} -> nil
139
    end
140
  end
141
135 142
  defp maybe_put(payload, _key, nil), do: payload
136 143
  defp maybe_put(payload, key, value), do: Map.put(payload, key, value)
137 144
lib/openagents/voice/open_ai/call_client.ex modified +9 -3

@@ -43,9 +43,15 @@ defmodule OpenAgents.Voice.OpenAI.CallClient do

43 43
  defp validate_safety_identifier(_identifier), do: {:error, :invalid_safety_identifier}
44 44
45 45
  defp fetch_api_key(options) do
46
    case Keyword.get(options, :api_key) || System.get_env("OPENAI_API_KEY") do
47
      key when is_binary(key) and byte_size(key) > 0 -> {:ok, key}
48
      _missing -> {:error, :missing_api_key}
46
    case Keyword.fetch(options, :api_key) do
47
      {:ok, key} when is_binary(key) and byte_size(key) > 0 ->
48
        {:ok, key}
49
50
      _not_supplied ->
51
        case OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key) do
52
          {:ok, key} -> {:ok, key}
53
          {:error, :not_configured} -> {:error, :missing_api_key}
54
        end
49 55
    end
50 56
  end
51 57
lib/openagents/voice/open_ai/sideband.ex modified +3 -3

@@ -86,9 +86,9 @@ defmodule OpenAgents.Voice.OpenAI.Sideband do

86 86
  end
87 87
88 88
  defp fetch_api_key do
89
    case System.get_env("OPENAI_API_KEY") do
90
      key when is_binary(key) and byte_size(key) > 0 -> {:ok, key}
91
      _missing -> {:error, :missing_api_key}
89
    case OpenAgents.RuntimeConfig.fetch_secret(:openai_api_key) do
90
      {:ok, key} -> {:ok, key}
91
      {:error, :not_configured} -> {:error, :missing_api_key}
92 92
    end
93 93
  end
94 94
ops/ci/release-smoke.sh modified +25 -5

@@ -4,6 +4,8 @@ set -eu

4 4
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5 5
repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd)
6 6
release_bin="$repo_root/_build/prod/rel/openagents/bin/openagents"
7
release_readiness="$repo_root/_build/prod/rel/openagents/bin/config-readiness"
8
staging_profile="$repo_root/ops/staging/gate-5-profile.sh"
7 9
database_url=${OPENAGENTS_RELEASE_SMOKE_DATABASE_URL:-}
8 10
disposable=${OPENAGENTS_RELEASE_SMOKE_DISPOSABLE:-}
9 11
port=${OPENAGENTS_RELEASE_SMOKE_PORT:-$((40000 + ($$ % 20000)))}

@@ -22,7 +24,7 @@ cleanup() {

22 24
    (cd "$repo_root" && MIX_ENV=prod mix phx.digest.clean --all >/dev/null 2>&1) || true
23 25
  fi
24 26
25
  rm -rf -- "$smoke_root"
27
  find "$smoke_root" -depth -delete
26 28
}
27 29
28 30
trap cleanup EXIT INT TERM

@@ -42,7 +44,7 @@ if [ -z "$database_url" ]; then

42 44
  exit 1
43 45
fi
44 46
45
for command_name in curl openssl; do
47
for command_name in curl jq openssl; do
46 48
  if ! command -v "$command_name" >/dev/null 2>&1; then
47 49
    echo "$command_name is required for the release smoke" >&2
48 50
    exit 1

@@ -59,19 +61,37 @@ MIX_ENV=prod mix release --overwrite

59 61
secret_key_base=$(openssl rand -base64 64 | tr -d '\n')
60 62
github_token_key=$(openssl rand -base64 32 | tr -d '\n')
61 63
64
echo "Checking the release configuration profile"
65
readiness_report=$(env \
66
  DATABASE_URL="$database_url" \
67
  GITHUB_CLIENT_ID="release-smoke-client" \
68
  GITHUB_CLIENT_SECRET="release-smoke-secret" \
69
  GITHUB_TOKEN_ENCRYPTION_KEY="$github_token_key" \
70
  OPENAI_API_KEY="release-smoke-openai-key" \
71
  POOL_SIZE="2" \
72
  PORT="$port" \
73
  SECRET_KEY_BASE="$secret_key_base" \
74
  "$staging_profile" "$release_readiness")
75
76
echo "$readiness_report" | jq -e '
77
  .schema == "openagents.runtime_configuration.v1" and
78
  .status == "ready" and
79
  .environment == "staging" and
80
  .staging_gate == 5
81
' >/dev/null
82
62 83
echo "Starting release against the disposable database"
63 84
env \
64 85
  DATABASE_URL="$database_url" \
65 86
  GITHUB_CLIENT_ID="release-smoke-client" \
66 87
  GITHUB_CLIENT_SECRET="release-smoke-secret" \
67
  GITHUB_REDIRECT_URI="https://127.0.0.1/auth/github/callback" \
68 88
  GITHUB_TOKEN_ENCRYPTION_KEY="$github_token_key" \
69
  PHX_HOST="127.0.0.1" \
89
  OPENAI_API_KEY="release-smoke-openai-key" \
70 90
  PHX_SERVER="true" \
71 91
  POOL_SIZE="2" \
72 92
  PORT="$port" \
73 93
  SECRET_KEY_BASE="$secret_key_base" \
74
  "$release_bin" start >"$release_log" 2>&1 &
94
  "$staging_profile" "$release_bin" start >"$release_log" 2>&1 &
75 95
release_pid=$!
76 96
77 97
health_url="http://127.0.0.1:$port/healthz"
ops/staging/gate-5-profile.sh added +68

@@ -0,0 +1,68 @@

1
#!/bin/sh
2
set -eu
3
4
# Content-free, fail-closed staging profile for Gate 5. Secrets and the database
5
# URL must already be present in the runtime identity's environment.
6
: "${DATABASE_URL:?DATABASE_URL is required}"
7
: "${GITHUB_CLIENT_ID:?GITHUB_CLIENT_ID is required}"
8
: "${GITHUB_CLIENT_SECRET:?GITHUB_CLIENT_SECRET is required}"
9
: "${GITHUB_TOKEN_ENCRYPTION_KEY:?GITHUB_TOKEN_ENCRYPTION_KEY is required}"
10
: "${OPENAI_API_KEY:?OPENAI_API_KEY is required}"
11
: "${SECRET_KEY_BASE:?SECRET_KEY_BASE is required}"
12
13
export GITHUB_OAUTH_SCOPES="read:user,repo"
14
export GITHUB_REDIRECT_URI="https://stage.openagents.com/auth/github/callback"
15
export OPENAGENTS_ALLOWED_ORIGINS="https://stage.openagents.com"
16
export OPENAGENTS_CODING_JOBS_DIR="/var/lib/openagents/coding-jobs"
17
export OPENAGENTS_DATABASE_IPV6="false"
18
export OPENAGENTS_DATABASE_MODE="url"
19
export OPENAGENTS_DIST_PORT_MAX="9115"
20
export OPENAGENTS_DIST_PORT_MIN="9100"
21
export OPENAGENTS_ENVIRONMENT="staging"
22
export OPENAGENTS_FEATURE_BOOT_CONVERGENCE="false"
23
export OPENAGENTS_FEATURE_COMPUTERS="false"
24
export OPENAGENTS_FEATURE_CONVERSATION_RESET="false"
25
export OPENAGENTS_FEATURE_EXPERIENCE_MEMORY="false"
26
export OPENAGENTS_FEATURE_FORGE="false"
27
export OPENAGENTS_FEATURE_FORGE_DEPLOY="false"
28
export OPENAGENTS_FEATURE_GRAPH_MEMORY="false"
29
export OPENAGENTS_FEATURE_HORDE="true"
30
export OPENAGENTS_FEATURE_INCIDENT_FIXER="false"
31
export OPENAGENTS_FEATURE_MEMORY_PORTABILITY="false"
32
export OPENAGENTS_FEATURE_RA="false"
33
export OPENAGENTS_FEATURE_SEMANTIC_MEMORY="false"
34
export OPENAGENTS_FEATURE_SHADOW_PROGRAMS="false"
35
export OPENAGENTS_FEATURE_TOOL_EMBEDDINGS="false"
36
export OPENAGENTS_FEATURE_TOOLS="true"
37
export OPENAGENTS_FEATURE_TURN_RECOVERY="false"
38
export OPENAGENTS_FEATURE_VOICE="false"
39
export OPENAGENTS_FEATURE_VOICE_RECORDING="false"
40
export OPENAGENTS_FEATURE_VOICE_RETENTION="false"
41
export OPENAGENTS_FEATURE_WORK="false"
42
export OPENAGENTS_FORGE_ARTIFACT_DIR="/var/lib/openagents/artifacts"
43
export OPENAGENTS_FORGE_ARTIFACT_STORE="local"
44
export OPENAGENTS_FORGE_BUILD_DIR="/var/lib/openagents/workspace/build"
45
export OPENAGENTS_FORGE_BUILD_EXECUTOR="sidecar"
46
export OPENAGENTS_FORGE_BUILD_QUEUE_DIR="/var/lib/openagents/workspace/build-queue"
47
export OPENAGENTS_FORGE_DATA_DIR="/var/lib/openagents/forge"
48
export OPENAGENTS_FORGE_EXPECTED_FLEET_SIZE="1"
49
export OPENAGENTS_FORGE_INTERNAL_GIT_URL="http://127.0.0.1:8080/git"
50
export OPENAGENTS_FORGE_OPERATOR_TOKEN=""
51
export OPENAGENTS_FORGE_OWNER="OpenAgentsInc"
52
export OPENAGENTS_FORGE_REPOSITORIES="openagents.com"
53
export OPENAGENTS_FORGE_WAL_ADAPTER="local"
54
export OPENAGENTS_FORGE_WAL_BUCKET=""
55
export OPENAGENTS_FORGE_WAL_DIR="/var/lib/openagents/forge-wal"
56
export OPENAGENTS_HTTPS_ALIASES=""
57
export OPENAGENTS_INFERENCE_PROXY_URL=""
58
export OPENAGENTS_MIGRATE_ON_BOOT="true"
59
export OPENAGENTS_PRODUCTION_DEPLOY_ENABLED="false"
60
export OPENAGENTS_RA_DATA_DIR="/var/lib/openagents/ra"
61
export OPENAGENTS_RA_EXPECTED_SIZE="3"
62
export OPENAGENTS_SECURE_COOKIES="true"
63
export OPENAGENTS_STAGING_GATE="5"
64
export PHX_HOST="stage.openagents.com"
65
export POOL_SIZE="${POOL_SIZE:-10}"
66
export VOICE_RECORDING_ENCRYPTION_KEY=""
67
68
exec "$@"
rel/env.sh.eex modified +2 -2

@@ -7,10 +7,10 @@

7 7
# a shared RELEASE_COOKIE + a DNS_CLUSTER_QUERY that resolves to the peers is
8 8
# all that is needed to form a BEAM cluster.
9 9
10
if [ -n "$OPENAGENTS_NODE_HOST" ]; then
10
if [ -n "${OPENAGENTS_NODE_HOST:-}" ]; then
11 11
  export RELEASE_DISTRIBUTION="name"
12 12
  export RELEASE_NODE="${RELEASE_NODE:-openagents@${OPENAGENTS_NODE_HOST}}"
13 13
  # Pin Erlang distribution ports to a fixed range so firewall rules and
14 14
  # peer discovery stay stable across restarts.
15
  export ERL_AFLAGS="${ERL_AFLAGS} -kernel inet_dist_listen_min 9100 inet_dist_listen_max 9115"
15
  export ERL_AFLAGS="${ERL_AFLAGS:-} -kernel inet_dist_listen_min ${OPENAGENTS_DIST_PORT_MIN} inet_dist_listen_max ${OPENAGENTS_DIST_PORT_MAX}"
16 16
fi
rel/overlays/bin/config-readiness added +6

@@ -0,0 +1,6 @@

1
#!/bin/sh
2
set -eu
3
4
cd -P -- "$(dirname -- "$0")"
5
./prepare-config >/dev/null
6
exec ./openagents eval OpenAgents.RuntimeConfig.print_readiness!
rel/overlays/bin/migrate modified +1

@@ -2,4 +2,5 @@

2 2
set -eu
3 3
4 4
cd -P -- "$(dirname -- "$0")"
5
./prepare-config >/dev/null
5 6
exec ./openagents eval OpenAgents.Release.migrate
rel/overlays/bin/prepare-config added +24

@@ -0,0 +1,24 @@

1
#!/bin/sh
2
set -eu
3
4
release_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)
5
release_vsn=${RELEASE_VSN:-$(cut -d ' ' -f 2 "$release_root/releases/start_erl.data")}
6
release_vsn_dir="$release_root/releases/$release_vsn"
7
8
export RELEASE_ROOT="$release_root"
9
export RELEASE_VSN="$release_vsn"
10
export RELEASE_NAME="${RELEASE_NAME:-openagents}"
11
export RELEASE_COMMAND="prepare-config"
12
13
. "$release_vsn_dir/env.sh"
14
15
release_cookie=${RELEASE_COOKIE:-$(cat "$release_root/releases/COOKIE")}
16
release_vm_args=${RELEASE_VM_ARGS:-$release_vsn_dir/vm.args}
17
18
exec "$release_vsn_dir/elixir" \
19
  --cookie "$release_cookie" \
20
  --erl-config "$release_vsn_dir/build" \
21
  --boot "$release_vsn_dir/preboot" \
22
  --boot-var RELEASE_LIB "$release_root/lib" \
23
  --vm-args "$release_vm_args" \
24
  --eval "Castle.generate(~s($release_vsn));Castle.make_releases()"
test/openagents/runtime_config_test.exs added +186

@@ -0,0 +1,186 @@

1
defmodule OpenAgents.RuntimeConfigTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.RuntimeConfig
5
  alias OpenAgents.Tools.Snapshot
6
7
  test "the test runtime is valid and produces a content-free report" do
8
    config = RuntimeConfig.load!()
9
    report = RuntimeConfig.readiness_report(config)
10
11
    assert report["status"] == "ready"
12
    assert report["environment"] == "test"
13
14
    assert report["groups"] == %{
15
             "cluster" => "ready",
16
             "database" => "ready",
17
             "endpoint" => "ready",
18
             "features" => "ready",
19
             "forge" => "ready",
20
             "github" => "ready",
21
             "providers" => "ready"
22
           }
23
24
    assert is_boolean(report["features"]["voice"])
25
    assert is_boolean(report["features"]["forge_deploy"])
26
  end
27
28
  test "a Gate 5 staging profile is accepted and its secrets never enter the report" do
29
    secret = "openai-readiness-secret-sentinel"
30
    oauth_secret = "oauth-readiness-secret-sentinel"
31
32
    settings =
33
      staging_settings()
34
      |> Map.put(:openai_api_key, secret)
35
      |> update_oauth(:client_secret, oauth_secret)
36
37
    config = RuntimeConfig.load!(settings)
38
    encoded = config |> RuntimeConfig.readiness_report() |> Jason.encode!()
39
40
    assert config.environment == :staging
41
    assert config.staging_gate == 5
42
    refute encoded =~ secret
43
    refute encoded =~ oauth_secret
44
    refute encoded =~ "stage.openagents.com"
45
    refute encoded =~ "ecto://"
46
  end
47
48
  test "staging gates refuse features before their admission gate" do
49
    settings = staging_settings() |> put_nested(:voice, :enabled, true)
50
51
    assert {:error, %{setting: :staging_gate}} = RuntimeConfig.validate(settings)
52
  end
53
54
  test "enabled OpenAI features require the centralized provider secret" do
55
    settings =
56
      staging_settings()
57
      |> Map.put(:staging_gate, 14)
58
      |> put_nested(:voice, :enabled, true)
59
      |> Map.put(:openai_api_key, nil)
60
61
    assert {:error, %{setting: :openai_api_key}} = RuntimeConfig.validate(settings)
62
  end
63
64
  test "recording cannot start without its encryption key" do
65
    settings =
66
      staging_settings()
67
      |> Map.put(:staging_gate, 14)
68
      |> put_nested(:voice, :enabled, true)
69
      |> put_nested(:voice_recording, :enabled, true)
70
      |> Map.put(:voice_recording_encryption_key, nil)
71
72
    assert {:error, %{setting: :voice_recording_encryption_key}} =
73
             RuntimeConfig.validate(settings)
74
  end
75
76
  test "hot-load examples are executable startup policy, not prose" do
77
    settings =
78
      staging_settings()
79
      |> Map.put(:forge_hot_load_examples, %{
80
        "OpenAgentsWeb.ChatLive" => false,
81
        "OpenAgents.Accounts" => false
82
      })
83
84
    assert {:error, %{setting: :forge_hot_load_examples}} = RuntimeConfig.validate(settings)
85
  end
86
87
  test "production remains locked until an explicit later decision" do
88
    settings = staging_settings() |> Map.put(:runtime_environment, :production)
89
90
    assert {:error, %{setting: :production_deploy_enabled}} = RuntimeConfig.validate(settings)
91
  end
92
93
  test "validation diagnostics name settings without echoing their values" do
94
    sentinel = "secret-host-value.invalid/path"
95
    settings = staging_settings() |> put_endpoint(:url, host: sentinel)
96
97
    error =
98
      assert_raise ArgumentError, fn ->
99
        RuntimeConfig.load!(settings)
100
      end
101
102
    assert error.message =~ "endpoint_host"
103
    refute error.message =~ sentinel
104
  end
105
106
  test "startup refuses an empty tool catalog when tools are enabled" do
107
    config = RuntimeConfig.load!(staging_settings())
108
109
    snapshot = %Snapshot{
110
      schema: "test",
111
      digest: "test",
112
      tools: %{},
113
      all_tools: %{},
114
      modules: %{}
115
    }
116
117
    assert_raise ArgumentError, ~r/tools catalog is empty/, fn ->
118
      RuntimeConfig.verify_startup!(config, snapshot)
119
    end
120
  end
121
122
  defp staging_settings do
123
    current = Map.new(Application.get_all_env(:openagents))
124
125
    current
126
    |> Map.merge(%{
127
      runtime_environment: :staging,
128
      staging_gate: 5,
129
      production_deploy_enabled: false,
130
      secure_cookies: true,
131
      https_aliases: [],
132
      migrate_on_boot: true,
133
      provider: OpenAgents.Providers.OpenAI,
134
      openai_api_key: "staging-openai-secret",
135
      forge_enabled: false,
136
      forge_deploy_lane_enabled: false,
137
      forge_boot_converge_enabled: false,
138
      turn_recovery_enabled: false,
139
      voice_retention_enabled: false,
140
      computer_controller_enabled: false,
141
      ra_enabled: false,
142
      forge_repos: ["openagents.com"],
143
      forge_repo_owners: %{"openagents.com" => "OpenAgentsInc"},
144
      forge_public_visibility: %{"openagents.com" => :l3},
145
      forge_public_paths: %{"openagents.com" => []},
146
      forge_operator_token: nil,
147
      dns_cluster_query: nil,
148
      distribution: [
149
        enabled: false,
150
        node_configured: false,
151
        cookie_configured: false,
152
        port_min: 9_100,
153
        port_max: 9_115
154
      ]
155
    })
156
    |> Map.put(OpenAgents.Repo,
157
      url: "ecto://runtime-user:runtime-password@database/openagents",
158
      pool_size: 10
159
    )
160
    |> Map.put(OpenAgentsWeb.Endpoint,
161
      url: [host: "stage.openagents.com", port: 443, scheme: "https"],
162
      check_origin: ["https://stage.openagents.com"]
163
    )
164
    |> update_oauth(:redirect_uri, "https://stage.openagents.com/auth/github/callback")
165
    |> put_nested(:voice, :enabled, false)
166
    |> put_nested(:voice_recording, :enabled, false)
167
    |> put_nested(:work, :enabled, false)
168
    |> put_nested(:semantic_index, :enabled, false)
169
    |> put_nested(:experience_memory, :enabled, false)
170
    |> put_nested(:graph_memory, :enabled, false)
171
    |> put_nested(:memory_portability, :enabled, false)
172
    |> put_nested(:shadow_programs, :enabled, false)
173
  end
174
175
  defp update_oauth(settings, key, value) do
176
    Map.update!(settings, :github_oauth, &Keyword.put(&1, key, value))
177
  end
178
179
  defp put_nested(settings, group, key, value) do
180
    Map.update!(settings, group, &Keyword.put(&1, key, value))
181
  end
182
183
  defp put_endpoint(settings, key, value) do
184
    Map.update!(settings, OpenAgentsWeb.Endpoint, &Keyword.put(&1, key, value))
185
  end
186
end

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