import Config
# Development may use a local, uncommitted dotenv file. Production and release
# configuration always comes from the deployment environment.
if config_env() == :dev do
env_file = Path.expand("../.env", __DIR__)
if File.regular?(env_file) do
env_file
|> File.stream!()
|> Stream.map(&String.trim/1)
|> Stream.reject(&(&1 == "" or String.starts_with?(&1, "#")))
|> Enum.each(fn line ->
case String.split(line, "=", parts: 2) do
[key, value] when key != "" ->
if System.get_env(key) == nil do
System.put_env(key, String.trim(value, "\"'"))
end
_invalid ->
:ok
end
end)
end
end
fetch_raw = fn name ->
case System.fetch_env(name) do
{:ok, value} -> String.trim(value)
:error -> raise "environment variable #{name} is required"
end
end
required_text = fn name ->
case fetch_raw.(name) do
"" -> raise "environment variable #{name} must not be empty"
value -> value
end
end
optional_text = fn name ->
case System.get_env(name) do
nil -> nil
value -> if String.trim(value) == "", do: nil, else: String.trim(value)
end
end
parse_boolean = fn name ->
case fetch_raw.(name) do
"true" -> true
"false" -> false
_invalid -> raise "environment variable #{name} must be true or false"
end
end
parse_optional_boolean = fn name ->
case System.get_env(name) do
nil -> false
"true" -> true
"false" -> false
_invalid -> raise "environment variable #{name} must be true or false when set"
end
end
parse_integer = fn name, range ->
case Integer.parse(required_text.(name)) do
{value, ""} ->
if value in range,
do: value,
else: raise("environment variable #{name} is outside its admitted range")
_invalid ->
raise "environment variable #{name} must be an integer"
end
end
parse_csv = fn name ->
name
|> fetch_raw.()
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
runtime_role =
case System.get_env("OPENAGENTS_RUNTIME_ROLE", "web") do
"web" -> :web
"scv" -> :scv
"builder" -> :builder
_invalid -> raise "environment variable OPENAGENTS_RUNTIME_ROLE is not admitted"
end
config :openagents, :runtime_role, runtime_role
capacity_config = Application.get_env(:openagents, OpenAgents.Capacity, [])
capacity_buyer =
case optional_text.("OPENAGENTS_CAPACITY_BUYER_JSON") do
nil ->
nil
encoded ->
case Jason.decode(encoded) do
{:ok, buyer} when is_map(buyer) ->
buyer
_invalid ->
raise "environment variable OPENAGENTS_CAPACITY_BUYER_JSON must be a JSON object"
end
end
config :openagents,
OpenAgents.Capacity,
Keyword.merge(capacity_config,
broker_url: optional_text.("OPENAGENTS_CAPACITY_BROKER_URL"),
broker_token: optional_text.("OPENAGENTS_CAPACITY_BROKER_TOKEN"),
buyer: capacity_buyer
)
# The thread admission cap from config.exs, tunable per deployment. The cap
# mechanism (THREAD-001: counted under the owner lock, refused with
# `thread_quota_reached`) is unchanged; this only sets the number, for
# fleet-scale testing and deployments whose concurrency profile differs from
# the default. Unset means the config.exs default.
if maximum_open_threads = System.get_env("OPENAGENTS_MAX_OPEN_THREADS") do
config :openagents,
maximum_open_threads_per_account: String.to_integer(maximum_open_threads)
end
continual_learning_config = Application.get_env(:openagents, OpenAgents.ContinualLearning, [])
# The admitted base models are a JSON object of model reference to exact model
# digest. A model that is not listed here cannot be trained from, so widening
# the lane is an explicit operator act.
admitted_base_models =
case optional_text.("OPENAGENTS_CONTINUAL_LEARNING_BASE_MODELS_JSON") do
nil ->
Keyword.get(continual_learning_config, :admitted_base_models, %{})
encoded ->
case Jason.decode(encoded) do
{:ok, models} when is_map(models) ->
models
_invalid ->
raise "environment variable OPENAGENTS_CONTINUAL_LEARNING_BASE_MODELS_JSON must be a JSON object"
end
end
config :openagents,
OpenAgents.ContinualLearning,
Keyword.merge(continual_learning_config,
enabled: System.get_env("OPENAGENTS_CONTINUAL_LEARNING_ENABLED") == "true",
buyer_ref: optional_text.("OPENAGENTS_CONTINUAL_LEARNING_BUYER_REF"),
training_code_digest:
optional_text.("OPENAGENTS_CONTINUAL_LEARNING_TRAINING_CODE_DIGEST"),
admitted_base_models: admitted_base_models
)
if config_env() == :dev do
config :openagents, :openai_api_key, optional_text.("OPENAI_API_KEY")
config :openagents, :openrouter_api_key, optional_text.("OPENROUTER_API_KEY")
config :openagents, :gemini_api_key, optional_text.("GEMINI_API_KEY")
config :openagents, :vercel_gateway_api_key, optional_text.("AI_GATEWAY_API_KEY")
config :openagents, :box_api_key, optional_text.("BOX_API_KEY")
end
# The changelog seed. Idempotent and off the boot path. It was gated behind a
# flag that nothing ever set, so the seed sat unused and the public page said
# "Nothing yet" from the day it shipped.
if config_env() == :prod do
config :openagents, :changelog_backfill_on_boot, true
end
# The mail provider for the notification email channel (#141). Absent by
# default: a deployment that sets no provider keeps `deliverable: false` from
# `config/prod.exs`, the settings surface declines to collect an address, and
# nothing is queued for a channel that cannot send.
#
# The API key is read here and nowhere else, per RELEASE-002: it enters from the
# environment at boot and is absent from source, the build context, and image
# arguments. All three providers are HTTP APIs served by `Swoosh.ApiClient.Req`,
# so none of them adds a dependency or an SMTP credential.
if config_env() == :prod and optional_text.("OPENAGENTS_MAIL_PROVIDER") != nil do
mail_from_address = required_text.("OPENAGENTS_MAIL_FROM")
mail_api_key = required_text.("OPENAGENTS_MAIL_API_KEY")
mailer_config =
case required_text.("OPENAGENTS_MAIL_PROVIDER") do
"resend" ->
[adapter: Swoosh.Adapters.Resend, api_key: mail_api_key]
"postmark" ->
[adapter: Swoosh.Adapters.Postmark, api_key: mail_api_key]
"mailgun" ->
[
adapter: Swoosh.Adapters.Mailgun,
api_key: mail_api_key,
domain: required_text.("OPENAGENTS_MAIL_DOMAIN")
]
_invalid ->
raise "environment variable OPENAGENTS_MAIL_PROVIDER must be resend, postmark, or mailgun"
end
config :openagents, OpenAgents.Mailer, mailer_config
config :openagents, OpenAgents.Notifications.EmailChannel,
from: {"OpenAgents", mail_from_address},
deliverable: true
end
if config_env() == :prod and runtime_role == :web do
runtime_environment =
case required_text.("OPENAGENTS_ENVIRONMENT") do
"staging" -> :staging
"production" -> :production
_invalid -> raise "environment variable OPENAGENTS_ENVIRONMENT is not admitted"
end
staging_gate = parse_integer.("OPENAGENTS_STAGING_GATE", 0..16)
production_deploy_enabled = parse_boolean.("OPENAGENTS_PRODUCTION_DEPLOY_ENABLED")
staging_cleanup_enabled = parse_boolean.("OPENAGENTS_STAGING_CLEANUP_ENABLED")
secure_cookies = parse_boolean.("OPENAGENTS_SECURE_COOKIES")
migrate_on_boot = parse_boolean.("OPENAGENTS_MIGRATE_ON_BOOT")
host = required_text.("PHX_HOST")
allowed_origins = parse_csv.("OPENAGENTS_ALLOWED_ORIGINS")
https_aliases = parse_csv.("OPENAGENTS_HTTPS_ALIASES")
ecto_ipv6? = parse_boolean.("OPENAGENTS_DATABASE_IPV6")
pool_size = parse_integer.("POOL_SIZE", 1..200)
admin_github_ids =
"OPENAGENTS_ADMIN_GITHUB_IDS"
|> optional_text.()
|> OpenAgents.Accounts.OperatorConfig.parse_github_ids!()
repo_config =
case required_text.("OPENAGENTS_DATABASE_MODE") do
"url" ->
[
url: required_text.("DATABASE_URL"),
pool_size: pool_size,
socket_options: if(ecto_ipv6?, do: [:inet6], else: [])
]
"socket" ->
[
username: required_text.("DB_USER"),
password: required_text.("DB_PASSWORD"),
database: required_text.("DB_NAME"),
socket_dir: required_text.("INSTANCE_UNIX_SOCKET"),
pool_size: pool_size,
socket_options: if(ecto_ipv6?, do: [:inet6], else: [])
]
_invalid ->
raise "environment variable OPENAGENTS_DATABASE_MODE must be url or socket"
end
feature = fn name -> parse_boolean.("OPENAGENTS_FEATURE_#{name}") end
tools_enabled = feature.("TOOLS")
voice_enabled = feature.("VOICE")
recording_enabled = feature.("VOICE_RECORDING")
work_enabled = feature.("WORK")
semantic_enabled = feature.("SEMANTIC_MEMORY")
experience_enabled = feature.("EXPERIENCE_MEMORY")
graph_enabled = feature.("GRAPH_MEMORY")
portability_enabled = feature.("MEMORY_PORTABILITY")
shadow_enabled = feature.("SHADOW_PROGRAMS")
tool_embeddings_enabled = feature.("TOOL_EMBEDDINGS")
memory_embeddings_enabled = feature.("MEMORY_EMBEDDINGS")
system_memory_recall_enabled = feature.("SYSTEM_MEMORY_RECALL")
computers_enabled = feature.("COMPUTERS")
conversation_reset_enabled = feature.("CONVERSATION_RESET")
incident_fixer_enabled = feature.("INCIDENT_FIXER")
turn_recovery_enabled = feature.("TURN_RECOVERY")
voice_retention_enabled = feature.("VOICE_RETENTION")
deployment_control_plane_enabled = feature.("DEPLOYMENT_CONTROL_PLANE")
forge_enabled = feature.("FORGE")
forge_deploy_enabled = feature.("FORGE_DEPLOY")
boot_convergence_enabled = feature.("BOOT_CONVERGENCE")
ra_enabled = feature.("RA")
horde_enabled = feature.("HORDE")
scv_codex_enabled = parse_optional_boolean.("OPENAGENTS_FEATURE_SCV_CODEX")
scv_codex_credential_store =
case optional_text.("OPENAGENTS_SCV_CODEX_CREDENTIAL_STORE") do
nil ->
OpenAgents.SCV.CodexCredentialStore.File
"file" ->
OpenAgents.SCV.CodexCredentialStore.File
"gcp_secret_manager" ->
OpenAgents.SCV.CodexCredentialStore.GcpSecretManager
_invalid ->
raise "environment variable OPENAGENTS_SCV_CODEX_CREDENTIAL_STORE is not admitted"
end
scv_codex_credential_refs =
case optional_text.("OPENAGENTS_SCV_CODEX_CREDENTIAL_REFS") do
nil -> []
_configured -> parse_csv.("OPENAGENTS_SCV_CODEX_CREDENTIAL_REFS")
end
if scv_codex_enabled and scv_codex_credential_refs == [] do
raise "environment variable OPENAGENTS_SCV_CODEX_CREDENTIAL_REFS is required when Codex SCV accounts are enabled"
end
scv_deploy_enabled = parse_optional_boolean.("OPENAGENTS_FEATURE_SCV_DEPLOY")
scv_deploy_defaults = Application.fetch_env!(:openagents, :scv_deploy)
scv_deploy = [
enabled: scv_deploy_enabled,
model: optional_text.("OPENAGENTS_SCV_DEPLOY_MODEL") || scv_deploy_defaults[:model],
reasoning_effort: scv_deploy_defaults[:reasoning_effort],
opencode_api_key: optional_text.("OPENAGENTS_SCV_DEPLOY_OPENCODE_API_KEY"),
executable:
optional_text.("OPENAGENTS_SCV_DEPLOY_OPENCODE_BIN") || scv_deploy_defaults[:executable],
concurrency_limit: scv_deploy_defaults[:concurrency_limit],
wall_clock_ms: scv_deploy_defaults[:wall_clock_ms],
maximum_output_bytes: scv_deploy_defaults[:maximum_output_bytes],
output_root:
optional_text.("OPENAGENTS_SCV_DEPLOY_OUTPUT_ROOT") || scv_deploy_defaults[:output_root]
]
scv_codex = [
enabled: scv_codex_enabled,
execution_reaper_enabled: scv_codex_enabled,
executable:
optional_text.("OPENAGENTS_SCV_CODEX_BIN") ||
Application.fetch_env!(:openagents, :scv_codex)[:executable],
credential_store: scv_codex_credential_store,
credential_refs: scv_codex_credential_refs,
file_root:
optional_text.("OPENAGENTS_SCV_CODEX_FILE_ROOT") ||
Application.fetch_env!(:openagents, :scv_codex)[:file_root],
temporary_root:
optional_text.("OPENAGENTS_SCV_TEMPORARY_ROOT") ||
Application.fetch_env!(:openagents, :scv_codex)[:temporary_root],
client_options: []
]
voice =
:openagents
|> Application.fetch_env!(:voice)
|> Keyword.put(:enabled, voice_enabled)
voice_recording =
:openagents
|> Application.fetch_env!(:voice_recording)
|> Keyword.put(:enabled, recording_enabled)
work =
:openagents
|> Application.fetch_env!(:work)
|> Keyword.put(:enabled, work_enabled)
semantic_index =
:openagents
|> Application.fetch_env!(:semantic_index)
|> Keyword.put(:enabled, semantic_enabled)
experience_memory =
:openagents
|> Application.fetch_env!(:experience_memory)
|> Keyword.put(:enabled, experience_enabled)
graph_memory =
:openagents
|> Application.fetch_env!(:graph_memory)
|> Keyword.put(:enabled, graph_enabled)
memory_portability =
:openagents
|> Application.fetch_env!(:memory_portability)
|> Keyword.put(:enabled, portability_enabled)
shadow_programs =
:openagents
|> Application.fetch_env!(:shadow_programs)
|> Keyword.put(:enabled, shadow_enabled)
tool_discovery =
:openagents
|> Application.fetch_env!(:tool_discovery)
|> Keyword.put(:embeddings_enabled, tool_embeddings_enabled)
# `system_bucket_enabled` is the one switch in this file that widens who a
# row can reach rather than how well it is ranked. It is declared `false` in
# `ops/deploy/fleet-startup.template.sh`, so production reads the `user` and
# `learned` buckets and nothing else until an operator turns it on.
memory_recall =
:openagents
|> Application.fetch_env!(:memory_recall)
|> Keyword.put(:embeddings_enabled, memory_embeddings_enabled)
|> Keyword.put(:system_bucket_enabled, system_memory_recall_enabled)
forge_repos = parse_csv.("OPENAGENTS_FORGE_REPOSITORIES")
forge_owner = required_text.("OPENAGENTS_FORGE_OWNER")
github_oauth_scopes = parse_csv.("GITHUB_OAUTH_SCOPES")
forge_wal_adapter =
case required_text.("OPENAGENTS_FORGE_WAL_ADAPTER") do
"local" -> OpenAgents.Forge.WAL.Local
"gcs" -> OpenAgents.Forge.WAL.Gcs
_invalid -> raise "environment variable OPENAGENTS_FORGE_WAL_ADAPTER is not admitted"
end
forge_artifact_store =
case required_text.("OPENAGENTS_FORGE_ARTIFACT_STORE") do
"local" -> :local
"gcs" -> :gcs
_invalid -> raise "environment variable OPENAGENTS_FORGE_ARTIFACT_STORE is not admitted"
end
forge_build_executor =
case required_text.("OPENAGENTS_FORGE_BUILD_EXECUTOR") do
"sidecar" -> OpenAgents.Forge.BuildExecutor.Sidecar
_invalid -> raise "environment variable OPENAGENTS_FORGE_BUILD_EXECUTOR is not admitted"
end
forge_rolling_provider =
case optional_text.("OPENAGENTS_FORGE_ROLLING_PROVIDER") do
nil -> nil
"gcp" -> OpenAgents.Forge.RollingProvider.Gcp
_invalid -> raise "environment variable OPENAGENTS_FORGE_ROLLING_PROVIDER is not admitted"
end
# Optional one-way GitHub mirrors, keyed by bare repository name. URLs must
# stay credential-free (RuntimeConfig enforces it): authentication belongs
# to the nodes' git credential helper or SSH key, never to this value.
forge_mirror_urls =
case optional_text.("OPENAGENTS_FORGE_MIRROR_URLS_JSON") do
nil ->
%{}
encoded ->
case Jason.decode(encoded) do
{:ok, urls} when is_map(urls) and map_size(urls) >= 0 ->
valid? = Enum.all?(urls, fn {repo, url} -> is_binary(repo) and is_binary(url) end)
if valid? do
Map.new(urls, fn {repo, url} -> {repo, url} end)
else
raise "environment variable OPENAGENTS_FORGE_MIRROR_URLS_JSON must map repository names to credential-free git URL strings"
end
_invalid ->
raise "environment variable OPENAGENTS_FORGE_MIRROR_URLS_JSON must be a JSON object from repository name to credential-free git URL"
end
end
rolling_instances =
case optional_text.("OPENAGENTS_GCP_ROLLING_INSTANCES_JSON") do
nil ->
%{}
encoded ->
case Jason.decode(encoded) do
{:ok, instances} when is_map(instances) ->
instances
_invalid ->
raise "environment variable OPENAGENTS_GCP_ROLLING_INSTANCES_JSON must be a JSON object"
end
end
distribution_enabled = System.get_env("RELEASE_DISTRIBUTION") in ["name", "longnames"]
release_node = optional_text.("RELEASE_NODE")
release_cookie = optional_text.("RELEASE_COOKIE")
node_configured =
is_binary(release_node) and
Regex.match?(~r/\Aopenagents@[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}\z/, release_node)
distribution = [
enabled: distribution_enabled,
node_configured: node_configured,
cookie_configured: is_binary(release_cookie) and byte_size(release_cookie) >= 32,
port_min: parse_integer.("OPENAGENTS_DIST_PORT_MIN", 1_024..65_535),
port_max: parse_integer.("OPENAGENTS_DIST_PORT_MAX", 1_024..65_535)
]
config :openagents,
runtime_environment: runtime_environment,
staging_gate: staging_gate,
staging_cleanup_enabled: staging_cleanup_enabled,
production_deploy_enabled: production_deploy_enabled,
build_revision: OpenAgents.BuildInfo.revision(),
admin_github_ids: admin_github_ids,
image_digest: optional_text.("OPENAGENTS_IMAGE_DIGEST"),
secure_cookies: secure_cookies,
https_aliases: https_aliases,
migrate_on_boot: migrate_on_boot,
tools_enabled: tools_enabled,
voice: voice,
voice_recording: voice_recording,
voice_recording_encryption_key: optional_text.("VOICE_RECORDING_ENCRYPTION_KEY"),
# The content vault's own key. VAULT-001 forbids the bridge that #192
# found and #253 repeated, so there is deliberately no fallback here: an
# unset key is an unset key, and RuntimeConfig refuses the boot.
content_encryption_key: optional_text.("CONTENT_ENCRYPTION_KEY"),
voice_recovery_worker_enabled: voice_enabled,
voice_retention_worker_enabled: voice_retention_enabled,
voice_retention_enabled: voice_retention_enabled,
work: work,
work_workers_enabled: work_enabled,
# The outbox drains wherever work workers run: the effects it carries today
# are the launches those workers are owed.
effects:
Keyword.put(
Application.fetch_env!(:openagents, :effects),
:worker_enabled,
work_enabled
),
scv_codex: scv_codex,
scv_deploy: scv_deploy,
semantic_index: semantic_index,
experience_memory: experience_memory,
graph_memory: graph_memory,
memory_portability: memory_portability,
shadow_programs: shadow_programs,
tool_discovery: tool_discovery,
memory_recall: memory_recall,
computer_controller_enabled: computers_enabled,
machine_token_ttl_seconds:
parse_integer.("OPENAGENTS_MACHINE_TOKEN_TTL_SECONDS", 300..2_592_000),
coding_jobs_dir: required_text.("OPENAGENTS_CODING_JOBS_DIR"),
conversation_reset_enabled: conversation_reset_enabled,
incident_fixer_enabled: incident_fixer_enabled,
turn_recovery_enabled: turn_recovery_enabled,
deployment_control_plane_enabled: deployment_control_plane_enabled,
github_oauth_scopes: github_oauth_scopes,
openai_api_key: required_text.("OPENAI_API_KEY"),
openrouter_api_key: optional_text.("OPENROUTER_API_KEY"),
gemini_api_key: optional_text.("GEMINI_API_KEY"),
# The gateway lane the model catalog's default routes through. It was
# wired in the dev branch only, so in production the credential stayed nil
# and the catalog's first entry — what a caller naming no model gets —
# refused every call with `model_unavailable`. That is the published CLI's
# default path, so the omission took the default lane down rather than a
# corner of it.
vercel_gateway_api_key: optional_text.("AI_GATEWAY_API_KEY"),
box_api_key: optional_text.("BOX_API_KEY"),
inference_proxy_url: optional_text.("OPENAGENTS_INFERENCE_PROXY_URL"),
forge_enabled: forge_enabled,
forge_deploy_lane_enabled: forge_deploy_enabled,
repository_provisioner_enabled: forge_enabled,
forge_boot_converge_enabled: boot_convergence_enabled,
forge_rolling_provider: forge_rolling_provider,
forge_repos: forge_repos,
forge_repo_owners: Map.new(forge_repos, &{&1, forge_owner}),
forge_public_visibility: Map.new(forge_repos, &{&1, :l3}),
forge_public_paths: Map.new(forge_repos, &{&1, []}),
forge_internal_git_url: required_text.("OPENAGENTS_FORGE_INTERNAL_GIT_URL"),
forge_operator_token: optional_text.("OPENAGENTS_FORGE_OPERATOR_TOKEN"),
forge_mirror_urls: forge_mirror_urls,
forge_data_dir: required_text.("OPENAGENTS_FORGE_DATA_DIR"),
forge_build_dir: required_text.("OPENAGENTS_FORGE_BUILD_DIR"),
forge_build_queue_dir: required_text.("OPENAGENTS_FORGE_BUILD_QUEUE_DIR"),
forge_artifact_dir: required_text.("OPENAGENTS_FORGE_ARTIFACT_DIR"),
forge_build_timeout_ms:
parse_integer.("OPENAGENTS_FORGE_BUILD_TIMEOUT_MS", 30_000..1_800_000),
forge_build_output_retention_ms:
parse_integer.("OPENAGENTS_FORGE_BUILD_OUTPUT_RETENTION_MS", 86_400_000..2_592_000_000),
forge_deploy_timeout_ms: parse_integer.("OPENAGENTS_FORGE_DEPLOY_TIMEOUT_MS", 1_000..120_000),
forge_deploy_token_ttl_ms:
parse_integer.("OPENAGENTS_FORGE_DEPLOY_TOKEN_TTL_MS", 30_000..1_800_000),
forge_boot_retry_min_ms: parse_integer.("OPENAGENTS_FORGE_BOOT_RETRY_MIN_MS", 100..60_000),
forge_boot_retry_max_ms: parse_integer.("OPENAGENTS_FORGE_BOOT_RETRY_MAX_MS", 1_000..300_000),
forge_artifact_store: forge_artifact_store,
forge_build_executor: forge_build_executor,
forge_expected_fleet_size: parse_integer.("OPENAGENTS_FORGE_EXPECTED_FLEET_SIZE", 1..100),
forge_wal_adapter: forge_wal_adapter,
forge_wal_dir: required_text.("OPENAGENTS_FORGE_WAL_DIR"),
forge_wal_bucket: optional_text.("OPENAGENTS_FORGE_WAL_BUCKET"),
releases_bucket:
optional_text.("OPENAGENTS_RELEASES_BUCKET") ||
Application.fetch_env!(:openagents, :releases_bucket),
ra_enabled: ra_enabled,
ra_data_dir: required_text.("OPENAGENTS_RA_DATA_DIR"),
ra_expected_size: parse_integer.("OPENAGENTS_RA_EXPECTED_SIZE", 1..100),
horde_enabled: horde_enabled,
dns_cluster_query: optional_text.("DNS_CLUSTER_QUERY"),
distribution: distribution
config :openagents, OpenAgents.Forge.RollingProvider.Gcp,
project_id: optional_text.("OPENAGENTS_GCP_ROLLING_PROJECT_ID"),
production_project_id: optional_text.("OPENAGENTS_PRODUCTION_PROJECT_ID"),
zone: optional_text.("OPENAGENTS_GCP_ROLLING_ZONE"),
instances: rolling_instances,
image_repository: optional_text.("OPENAGENTS_GCP_IMAGE_REPOSITORY"),
deployer_node: :"openagents-deployer@openagents-deployer.staging.internal",
rpc_timeout_ms: parse_integer.("OPENAGENTS_GCP_ROLLING_RPC_TIMEOUT_MS", 1_000..120_000),
compute_timeout_ms: parse_integer.("OPENAGENTS_GCP_COMPUTE_TIMEOUT_MS", 30_000..600_000)
config :openagents, OpenAgents.Repo, repo_config
# The load balancer reuses idle backend connections for up to 610 seconds.
# The server's keepalive timeout must exceed that window, or the server
# closes a connection the load balancer is about to reuse and the client
# receives an intermittent 502.
config :openagents, OpenAgentsWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
check_origin: allowed_origins,
http: [
ip: {0, 0, 0, 0, 0, 0, 0, 0},
thousand_island_options: [read_timeout: 660_000]
],
secret_key_base: required_text.("SECRET_KEY_BASE")
end
if config_env() == :prod and runtime_role == :scv do
runtime_environment =
case required_text.("OPENAGENTS_ENVIRONMENT") do
"staging" -> :staging
_invalid -> raise "an SCV worker is admitted only in staging"
end
config :openagents,
runtime_environment: runtime_environment,
openai_api_key: required_text.("OPENAI_API_KEY")
end
if runtime_role == :web do
# Product analytics. An absent token disables capture entirely: no PostHog
# supervision tree starts and OpenAgents.Analytics becomes a no-op.
posthog_project_token = optional_text.("OPENAGENTS_POSTHOG_PROJECT_TOKEN")
posthog_api_host = optional_text.("OPENAGENTS_POSTHOG_API_HOST")
config :openagents,
posthog_project_token: posthog_project_token,
posthog_api_host: posthog_api_host || "https://us.i.posthog.com"
config :posthog, api_key: posthog_project_token
if posthog_api_host do
config :posthog, api_host: posthog_api_host
end
# The operator analytics surface reads computed results back from the
# PostHog REST API. Absent credentials disable the read path only; capture
# above is independent of it.
config :openagents, :posthog_analytics,
personal_api_key: optional_text.("OPENAGENTS_POSTHOG_PERSONAL_API_KEY"),
project_id: optional_text.("OPENAGENTS_POSTHOG_PROJECT_ID"),
app_host:
optional_text.("OPENAGENTS_POSTHOG_APP_HOST") ||
Application.get_env(:openagents, :posthog_analytics)[:app_host]
github_oauth = Application.get_env(:openagents, :github_oauth, [])
github_oauth =
github_oauth
|> Keyword.merge(
client_id: System.get_env("GITHUB_CLIENT_ID") || github_oauth[:client_id],
client_secret: System.get_env("GITHUB_CLIENT_SECRET") || github_oauth[:client_secret],
redirect_uri: System.get_env("GITHUB_REDIRECT_URI") || github_oauth[:redirect_uri]
)
|> OpenAgents.GitHubOAuth.RuntimeConfig.load!(config_env(),
public_host: System.get_env("PHX_HOST")
)
config :openagents, :github_oauth, github_oauth
token_encryption_key = optional_text.("GITHUB_TOKEN_ENCRYPTION_KEY")
token_encryption_key_id = optional_text.("GITHUB_TOKEN_ENCRYPTION_KEY_ID")
token_decryption_keys =
case optional_text.("GITHUB_TOKEN_DECRYPTION_KEYS_JSON") do
nil ->
%{}
encoded ->
case Jason.decode(encoded) do
{:ok, keys} when is_map(keys) ->
keys
_invalid ->
raise "environment variable GITHUB_TOKEN_DECRYPTION_KEYS_JSON must be a JSON object"
end
end
valid_token_key? =
is_binary(token_encryption_key) and
match?({:ok, key} when byte_size(key) == 32, Base.decode64(token_encryption_key))
valid_token_key_id? =
is_binary(token_encryption_key_id) and
String.match?(token_encryption_key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/)
valid_decryption_keys? =
map_size(token_decryption_keys) <= 16 and
not Map.has_key?(token_decryption_keys, token_encryption_key_id) and
Enum.all?(token_decryption_keys, fn {key_id, encoded_key} ->
is_binary(key_id) and String.match?(key_id, ~r/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/) and
is_binary(encoded_key) and
match?({:ok, key} when byte_size(key) == 32, Base.decode64(encoded_key))
end)
if config_env() == :prod and
not (valid_token_key? and valid_token_key_id? and valid_decryption_keys?) do
raise "GitHub token keyring environment variables are invalid"
end
if valid_token_key? and valid_token_key_id? and valid_decryption_keys? do
config :openagents,
github_token_encryption_key: token_encryption_key,
github_token_encryption_key_id: token_encryption_key_id,
github_token_decryption_keys: token_decryption_keys
end
# The machine pairing vault's own key (#192, VAULT-001). A set-but-invalid
# value fails startup; an unset value bridges to the GitHub vault's active
# key so the deploy that introduces this variable boots unattended, before
# the operator provisions the secret. The bridge is this expression and
# nothing else — `OpenAgents.Machines.TokenVault` refuses to read the GitHub
# key itself — and it ends the moment `MACHINE_TOKEN_ENCRYPTION_KEY` is set.
# While the bridge is active, a GitHub key rotation moves the pairing
# vault's key too; the vault's decrypt-side GitHub-keyring fallback is what
# keeps the at-most-ten-minute pairing population readable across that.
machine_token_encryption_key = optional_text.("MACHINE_TOKEN_ENCRYPTION_KEY")
valid_machine_token_key? =
is_binary(machine_token_encryption_key) and
match?(
{:ok, key} when byte_size(key) == 32,
Base.decode64(machine_token_encryption_key)
)
if config_env() == :prod and machine_token_encryption_key != nil and
not valid_machine_token_key? do
raise "environment variable MACHINE_TOKEN_ENCRYPTION_KEY must be a base64-encoded 32-byte key"
end
cond do
valid_machine_token_key? ->
config :openagents, machine_token_encryption_key: machine_token_encryption_key
valid_token_key? ->
config :openagents, machine_token_encryption_key: token_encryption_key
true ->
:ok
end
end
if parse_optional_boolean.("PHX_SERVER") do
config :openagents, OpenAgentsWeb.Endpoint, server: true
end
port =
case Integer.parse(System.get_env("PORT", "4000")) do
{value, ""} when value in 1..65_535 -> value
_invalid -> raise "environment variable PORT must be a valid port"
end
config :openagents, OpenAgentsWeb.Endpoint, http: [port: port]
if config_env() == :dev do
config :openagents, OpenAgentsWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$"E,
~r"priv/gettext/.*\.po$"E,
~r"lib/openagents_web/router\.ex$"E,
~r"lib/openagents_web/(controllers|live|components)/.*\.(ex|heex)$"E
]
]
end
# Forum tips: the operator's own self-custodial wallet service moves the sats.
# Without a URL the adapter reports the service as unavailable, and the forum
# keeps ranking what already settled.
forum_tips_wallet_url = System.get_env("FORUM_TIPS_WALLET_URL")
if is_binary(forum_tips_wallet_url) and forum_tips_wallet_url != "" do
config :openagents, :forum_tips,
enabled: true,
adapter: OpenAgents.Forum.Tips.PaymentService.MoneyDevKit,
base_url: forum_tips_wallet_url,
token: System.get_env("FORUM_TIPS_WALLET_TOKEN")
end