diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b8684503a..fcdee485c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,6 +27,11 @@ updates: prefix: chore include: scope open-pull-requests-limit: 5 + ignore: + # rails-paradedb 0.10+ requires ParadeDB 0.25+, while Console runs 0.23. + - dependency-name: rails-paradedb + versions: + - ">= 0.10.0" groups: ruby-dependencies: patterns: @@ -59,6 +64,11 @@ updates: prefix: chore include: scope open-pull-requests-limit: 5 + ignore: + # Starlark 0.13 uses hashbrown 0.14, which allocative 0.3.6+ no longer supports. + - dependency-name: allocative + versions: + - ">= 0.3.6" groups: harness-server-dependencies: patterns: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25ae53482..85e3c2693 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -140,7 +140,7 @@ jobs: with: persist-credentials: false - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: toolchain: stable components: rustfmt, clippy @@ -183,7 +183,7 @@ jobs: working-directory: services/console bundler-cache: true - - uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1 + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 with: toolchain: stable components: rustfmt, clippy @@ -322,7 +322,7 @@ jobs: with: python-version: "3.11" - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true prune-cache: true @@ -354,7 +354,7 @@ jobs: with: python-version: "3.11" - - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: enable-cache: true prune-cache: true diff --git a/.github/workflows/close-stale-draft-prs.yml b/.github/workflows/close-stale-draft-prs.yml index 212175e3b..1e390c81e 100644 --- a/.github/workflows/close-stale-draft-prs.yml +++ b/.github/workflows/close-stale-draft-prs.yml @@ -15,7 +15,7 @@ jobs: timeout-minutes: 5 steps: - name: Close inactive draft pull requests - uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const inactivityCutoff = Date.now() - 14 * 24 * 60 * 60 * 1000; diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index d023d0979..4d1da58c5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -61,12 +61,8 @@ jobs: - name: Build docs run: npm run build - - name: Skip Cloudflare deploy - if: ${{ env.CLOUDFLARE_ACCOUNT_ID == '' || env.CLOUDFLARE_API_TOKEN == '' }} - run: echo "Cloudflare credentials are not configured; docs build completed without deployment." - - name: Deploy - if: github.event_name != 'pull_request' + if: ${{ github.event_name != 'pull_request' && env.CLOUDFLARE_ACCOUNT_ID != '' && env.CLOUDFLARE_API_TOKEN != '' }} uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 938aef849..ae23a2317 100644 --- a/contrib/chart/Chart.yaml +++ b/contrib/chart/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: centaur description: Helm chart for the trusted Centaur control plane type: application -version: 0.1.119 +version: 0.1.124 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/apirs.yaml b/contrib/chart/templates/apirs.yaml index 3f49ff7af..e33909116 100644 --- a/contrib/chart/templates/apirs.yaml +++ b/contrib/chart/templates/apirs.yaml @@ -254,8 +254,6 @@ spec: {{- end }} - name: RUN_MIGRATIONS value: {{ .Values.apiRs.runMigrations | quote }} - - name: SESSION_CODEX_NANOCODEX_ROLLOUT_PERCENT - value: {{ .Values.apiRs.codexNanocodexRolloutPercent | quote }} - name: IRON_CONTROL_SYNC_INFRA_SECRETS value: {{ .Values.apiRs.syncInfraSecrets | quote }} - name: RUST_LOG @@ -429,6 +427,12 @@ spec: value: {{ .Values.sandbox.codexAuthMode | quote }} - name: CLAUDE_CODE_AUTH_MODE value: {{ .Values.sandbox.claudeCodeAuthMode | quote }} +{{- if .Values.codex.customProviders }} + # Shared provider catalog: api-rs derives hostname-scoped proxy + # credentials from the same JSON sandboxes use for Codex config. + - name: CODEX_CUSTOM_PROVIDERS + value: {{ .Values.codex.customProviders | toJson | quote }} +{{- end }} # Which harness's auth fragment iron-proxy registers (and therefore # which placeholder credential lands in the sandbox env). Must agree # with the harness the sandbox image CMD actually runs. @@ -447,6 +451,9 @@ spec: {{- if not (hasKey .Values.sandbox.extraEnv "COMPANY_CONTEXT_EMBEDDINGS_MODEL") }} {{- $sandboxEnvList = append $sandboxEnvList (dict "name" "COMPANY_CONTEXT_EMBEDDINGS_MODEL" "value" $companyContextEmbeddingsModel) }} {{- end }} +{{- if .Values.codex.customProviders }} +{{- $sandboxEnvList = append $sandboxEnvList (dict "name" "CODEX_CUSTOM_PROVIDERS" "value" (.Values.codex.customProviders | toJson)) }} +{{- end }} {{- range $k, $v := .Values.sandbox.extraEnv }} {{- $sandboxEnvList = append $sandboxEnvList (dict "name" $k "value" ($v | toString)) }} {{- end }} diff --git a/contrib/chart/templates/console.yaml b/contrib/chart/templates/console.yaml index 1d7fe9847..69c94d89e 100644 --- a/contrib/chart/templates/console.yaml +++ b/contrib/chart/templates/console.yaml @@ -127,6 +127,10 @@ spec: name: {{ $secretEnv }} key: {{ printf "%sSLACK_BOT_TOKEN" $prefix }} optional: true +{{- if .Values.codex.customProviders }} + - name: CODEX_CUSTOM_PROVIDERS + value: {{ .Values.codex.customProviders | toJson | quote }} +{{- end }} {{- range $name := tuple "CLAUDE_MODEL" "CODEX_MODEL" }} {{- if hasKey $.Values.sandbox.extraEnv $name }} # Mirror the deployer's harness default-model override @@ -186,6 +190,10 @@ spec: - name: CENTAUR_CONSOLE_PUBLIC_URL value: {{ $console.publicUrl | quote }} {{- end }} +{{- with $console.allowedHosts }} + - name: CENTAUR_CONSOLE_ALLOWED_HOSTS + value: {{ join "," . | quote }} +{{- end }} {{- with $console.sentryDsn }} - name: SENTRY_DSN value: {{ . | quote }} diff --git a/contrib/chart/templates/githubbot.yaml b/contrib/chart/templates/githubbot.yaml index d291d6b7f..2b1e75e07 100644 --- a/contrib/chart/templates/githubbot.yaml +++ b/contrib/chart/templates/githubbot.yaml @@ -126,6 +126,10 @@ spec: # final flush + exit still fit before the pod is force-killed. - name: GITHUBBOT_SHUTDOWN_DRAIN_MS value: {{ mul (sub (int .Values.githubbot.terminationGracePeriodSeconds) 10) 1000 | quote }} +{{- if .Values.codex.customProviders }} + - name: CODEX_CUSTOM_PROVIDERS + value: {{ .Values.codex.customProviders | toJson | quote }} +{{- end }} {{- range $name, $value := .Values.githubbot.extraEnv }} - name: {{ $name }} value: {{ $value | quote }} diff --git a/contrib/chart/templates/linearbot.yaml b/contrib/chart/templates/linearbot.yaml index 5092b8c9d..b90f78f37 100644 --- a/contrib/chart/templates/linearbot.yaml +++ b/contrib/chart/templates/linearbot.yaml @@ -63,6 +63,10 @@ spec: key: {{ printf "%sDATABASE_URL" .Values.secretManager.envPrefix }} - name: LINEARBOT_USER_NAME value: {{ .Values.linearbot.userName | quote }} +{{- if .Values.codex.customProviders }} + - name: CODEX_CUSTOM_PROVIDERS + value: {{ .Values.codex.customProviders | toJson | quote }} +{{- end }} {{- range $name, $value := .Values.linearbot.extraEnv }} - name: {{ $name }} value: {{ $value | quote }} diff --git a/contrib/chart/templates/repo-cache.yaml b/contrib/chart/templates/repo-cache.yaml index 474e2448d..f457321bb 100644 --- a/contrib/chart/templates/repo-cache.yaml +++ b/contrib/chart/templates/repo-cache.yaml @@ -89,6 +89,10 @@ spec: {{ include "centaur.componentSelectorLabels" (dict "root" . "component" "repo-cache") | nindent 6 }} template: metadata: +{{- if $repoCacheHasGithubToken }} + annotations: + checksum/github-token: {{ include "centaur.secretResourceVersion" (dict "root" . "name" (include "centaur.repoCacheGithubTokenSecretName" .)) | sha256sum }} +{{- end }} labels: {{ include "centaur.componentSelectorLabels" (dict "root" . "component" "repo-cache") | nindent 8 }} spec: diff --git a/contrib/chart/templates/slackbotv2.yaml b/contrib/chart/templates/slackbotv2.yaml index 0242b1290..9fc2ec314 100644 --- a/contrib/chart/templates/slackbotv2.yaml +++ b/contrib/chart/templates/slackbotv2.yaml @@ -43,10 +43,11 @@ spec: - name: CENTAUR_API_URL value: {{ printf "http://%s:%v" $apiRsName .Values.apiRs.port | quote }} # New threads without an explicit --claude/--amp/--codex/ - # --nanocodex flag run the deployment's default harness. api-rs - # applies any configured Codex/Nanocodex rollout. + # --nanocodex flag run the deployment's default harness. - name: SLACKBOTV2_DEFAULT_HARNESS value: {{ .Values.sandbox.harnessEngine | quote }} + - name: SLACKBOTV2_CODEX_NANOCODEX_ROLLOUT_PERCENT + value: {{ .Values.slackbotv2.codexNanocodexRolloutPercent | quote }} - name: SLACK_BOT_TOKEN valueFrom: secretKeyRef: @@ -122,6 +123,10 @@ spec: - name: SLACKBOTV2_CHANNEL_DEFAULTS value: {{ .Values.slackbotv2.channelDefaults | toJson | quote }} {{- end }} +{{- if .Values.codex.customProviders }} + - name: CODEX_CUSTOM_PROVIDERS + value: {{ .Values.codex.customProviders | toJson | quote }} +{{- end }} {{- range $name := tuple "CLAUDE_MODEL" "CODEX_MODEL" "CODEX_MODEL_REASONING_EFFORT" }} {{- if and (hasKey $.Values.sandbox.extraEnv $name) (not (hasKey $.Values.slackbotv2.extraEnv $name)) }} # Mirror the deployer's harness default override (sandbox.extraEnv) diff --git a/contrib/chart/values.schema.json b/contrib/chart/values.schema.json index 95a6c0878..b61f459c7 100644 --- a/contrib/chart/values.schema.json +++ b/contrib/chart/values.schema.json @@ -7,6 +7,11 @@ "properties": { "replicaCount": { "type": "integer" }, "publicUrl": { "type": "string" }, + "allowedHosts": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, "sentryDsn": { "type": "string" }, "railsEnv": { "type": "string" }, "image": { @@ -193,6 +198,26 @@ } } }, + "codex": { + "type": "object", + "properties": { + "customProviders": { + "type": "object", + "propertyNames": { "pattern": "^[a-z][a-z0-9_-]*$" }, + "additionalProperties": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "baseUrl": { "type": "string", "pattern": "^https://" }, + "apiKeyEnv": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }, + "defaultModel": { "type": "string", "minLength": 1 } + }, + "required": ["name", "baseUrl", "apiKeyEnv"], + "additionalProperties": false + } + } + } + }, "sandbox": { "type": "object", "properties": { @@ -403,6 +428,11 @@ "type": "object", "properties": { "mcpPublicUrl": { "type": "string" }, + "codexNanocodexRolloutPercent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, "responseMetadataMode": { "type": "string", "enum": ["first", "always", "never"] diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index d8a9a6509..318db1d03 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -107,6 +107,10 @@ console: # When set, the slackbotv2 deployment also links the first assistant message # in a Slack thread to the Console session view; leave empty to omit the link. publicUrl: "" + # Additional exact Host headers accepted by Rails Host Authorization. Use for + # service DNS names that differ from the chart's short in-cluster URL; + # publicUrl and the short service host are already included. + allowedHosts: [] # Optional Sentry DSN for Console web requests and background jobs. Empty by # default, so the Sentry SDK remains disabled unless explicitly configured. sentryDsn: "" @@ -267,6 +271,21 @@ overlays: # toolsSubdir: "" # skillsSubdir: "" +codex: + # Operator-defined OpenAI-compatible Responses providers. Each provider is + # added to sandbox Codex config, exposed through `--provider `, and given + # a hostname-scoped iron-proxy bearer-token replacement. `apiKeyEnv` names a + # field in the configured secret source; sandboxes receive only a placeholder. + # Example: + # customProviders: + # private_responses: + # name: Private Responses + # baseUrl: https://inference.example.com/v1 + # apiKeyEnv: PRIVATE_RESPONSES_API_KEY + # # Optional; callers must pass --model when omitted. + # defaultModel: example-model + customProviders: {} + sandbox: controller: pod image: @@ -396,10 +415,6 @@ apiRs: tag: latest pullPolicy: Always port: 8080 - # Sticky percentage of session requests for Codex assigned to Nanocodex. - # The API hashes the thread key once and persists the resolved harness on the - # session. Set to 0 to keep new Codex requests on Codex. - codexNanocodexRolloutPercent: 50 # Public/local MCP endpoint advertised through OAuth protected-resource # metadata. Empty falls back to slackbotv2.mcpPublicUrl. mcpPublicUrl: "" @@ -485,6 +500,7 @@ apiRs: activitySummary: enabled: false model: gpt-5.4-nano + # Deprecated compatibility fallback. Prefer OPENAI_BASE_URL in apiRs.extraEnv. openaiBaseUrl: https://api.openai.com/v1 minIntervalSecs: 20 timeoutSecs: 5 @@ -531,6 +547,9 @@ slackbotv2: # Join newly-created public channels after subscribed channel_created events. # Requires the Slack app to grant channels:read and channels:join. autoJoinCreatedChannels: false + # Sticky percentage of otherwise-default Codex Slack threads assigned to + # Nanocodex. A non-default model selection bypasses the rollout. + codexNanocodexRolloutPercent: 0 # Append model, harness, and reasoning effort to the first live streamed # Slack assistant response, every response, or no responses. Independent of # the optional Console link below. diff --git a/contrib/scripts/bootstrap-k8s-secrets.sh b/contrib/scripts/bootstrap-k8s-secrets.sh index b1cb61abe..b5f0a22ca 100755 --- a/contrib/scripts/bootstrap-k8s-secrets.sh +++ b/contrib/scripts/bootstrap-k8s-secrets.sh @@ -81,6 +81,12 @@ Console bootstrap: initial admin email (default admin@centaur.local) The initial password, API key, the three ActiveRecord encryption keys, and SECRET_KEY_BASE are auto-generated when absent (never rotated in place). + +Note: harness access-token modes (sandbox.codexAuthMode / claudeCodeAuthMode +set to access_token) also need a console broker credential (openai-codex / +anthropic-claude) created out of band with `centaur-perms broker create`; +without it api-rs fails registration at startup. This script cannot seed it. +See the Codex/Claude Auth Modes sections in docs/pages/deploying-in-production.mdx. EOF } @@ -198,12 +204,6 @@ if secret_exists centaur-infra-env; then if [[ -n "${OP_CONNECT_TOKEN:-}" ]]; then patch_data+=("\"OP_CONNECT_TOKEN\":\"$(printf '%s' "$OP_CONNECT_TOKEN" | base64 | tr -d '\n')\"") fi - # Top-up IRON_BROKER_TOKEN for clusters bootstrapped before iron-token-broker - # support landed. Only generated when absent so we don't rotate it out from - # under cached iron-proxy access tokens on every script run. - if ! secret_key_present IRON_BROKER_TOKEN; then - patch_data+=("\"IRON_BROKER_TOKEN\":\"$(rand_hex | base64 | tr -d '\n')\"") - fi # GITHUB_TOKEN for the repo-cache DaemonSet. Set whenever present so it can be # rotated; harmless when repoCache is disabled. if [[ -n "${GITHUB_TOKEN:-}" ]]; then @@ -315,8 +315,6 @@ else secret_args=( -n "$NAMESPACE" create secret generic centaur-infra-env --from-literal=IRON_MANAGEMENT_API_KEY="$(rand_hex)" - --from-literal=IRON_BROKER_TOKEN="$(rand_hex)" - --from-literal=SANDBOX_SIGNING_KEY="$(rand_hex)" --from-literal=OP_SERVICE_ACCOUNT_TOKEN="$OP_SERVICE_ACCOUNT_TOKEN" --from-literal=OP_VAULT="$OP_VAULT" --from-literal=SLACK_BOT_TOKEN="$SLACK_BOT_TOKEN" diff --git a/crates/harness-server/Cargo.lock b/crates/harness-server/Cargo.lock index 94cf82acf..4e9483f5e 100644 --- a/crates/harness-server/Cargo.lock +++ b/crates/harness-server/Cargo.lock @@ -165,7 +165,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -404,7 +404,7 @@ dependencies = [ "cached_proc_macro_types", "hashbrown 0.15.5", "once_cell", - "thiserror 2.0.19", + "thiserror 2.0.20", "web-time", ] @@ -574,7 +574,7 @@ dependencies = [ "serde_json", "serde_with", "strum_macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "ts-rs", "uuid", @@ -603,7 +603,7 @@ dependencies = [ "serde_json", "shlex 1.3.0", "starlark", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -639,7 +639,7 @@ dependencies = [ "rama-unix", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -675,7 +675,7 @@ dependencies = [ "strum", "strum_macros", "sys-locale", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "ts-rs", @@ -742,7 +742,7 @@ dependencies = [ "codex-utils-cache", "image", "mime_guess", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", ] @@ -1732,7 +1732,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "url", "uuid", @@ -1823,7 +1823,7 @@ dependencies = [ "once_cell", "rand 0.9.4", "ring", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tokio", "tracing", @@ -1846,7 +1846,7 @@ dependencies = [ "rand 0.9.4", "resolv-conf", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -2359,7 +2359,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2472,7 +2472,7 @@ checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" dependencies = [ "enumflags2", "libc", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2746,7 +2746,7 @@ dependencies = [ "serde_json", "sha1 0.11.0", "sha2 0.10.9", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tower", "tracing", @@ -2765,7 +2765,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "web-time", ] @@ -2796,7 +2796,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -2816,7 +2816,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tower", @@ -2846,7 +2846,7 @@ dependencies = [ "serde", "serde_json", "sha1 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "wasm-bindgen", @@ -3061,7 +3061,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3088,7 +3088,7 @@ dependencies = [ "percent-encoding", "portable-atomic", "rand 0.9.4", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3383,7 +3383,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -3406,7 +3406,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -3874,7 +3874,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4070,7 +4070,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", @@ -5066,11 +5066,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -5086,9 +5086,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -5463,7 +5463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" dependencies = [ "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "ts-rs-macros", "uuid", ] @@ -5495,7 +5495,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha1 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5573,9 +5573,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -5811,7 +5811,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -6196,7 +6196,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] diff --git a/crates/harness-server/Cargo.toml b/crates/harness-server/Cargo.toml index 22c6abda9..c9af0f362 100644 --- a/crates/harness-server/Cargo.toml +++ b/crates/harness-server/Cargo.toml @@ -9,6 +9,8 @@ name = "harness-server" path = "src/main.rs" [dependencies] +# Starlark 0.13 uses hashbrown 0.14, which is no longer supported by +# allocative 0.3.6's hashbrown integration. allocative = "=0.3.4" base64 = "0.23" clap = { version = "4.5", features = ["derive"] } diff --git a/docs/pages/deploying-in-production.mdx b/docs/pages/deploying-in-production.mdx index 319744bd5..7d91c4c06 100644 --- a/docs/pages/deploying-in-production.mdx +++ b/docs/pages/deploying-in-production.mdx @@ -62,7 +62,6 @@ Minimum keys: | `DATABASE_URL` | API | Postgres connection string. Make sure the password is URL-encoded. | | `POSTGRES_PASSWORD` | Bundled Postgres | Password used when the chart manages Postgres. | | `IRON_MANAGEMENT_API_KEY` | [iron-proxy](https://docs.iron.sh) management API | Generate with `openssl rand -hex 32`. | -| `SANDBOX_SIGNING_KEY` | Sandbox API tokens | Generate with `openssl rand -hex 32`; keeps sandbox tokens valid across API restarts. | | `SLACK_BOT_TOKEN` | Slackbot/API | Bot User OAuth Token from the Slack app. | | `SLACK_SIGNING_SECRET` | Slackbot/API | Used to verify Slack webhook signatures. | | `SLACKBOT_API_KEY` | Slackbot to API | Dedicated static service token restricted to Slack session routes and workflow event emission. | @@ -103,6 +102,7 @@ Store one secret per enabled harness credential: | Codex default | `codex` | none or `--codex` | `OPENAI_API_KEY` | `api.openai.com` | | Codex with OpenRouter provider | `codex` | none or `--codex` | `OPENROUTER_API_KEY` | `openrouter.ai` | | Codex with Meta AI direct | `codex` | `--meta` | `META_AI_API_KEY` | `api.ai.meta.com` | +| Codex with a custom provider | `codex` | `--provider ` | Provider `apiKeyEnv` | Provider `baseUrl` host | | Amp | `amp` | `--amp` | `AMP_API_KEY` | `ampcode.com` | | Claude Code | `claude-code` | `--claude` | `ANTHROPIC_API_KEY` | `api.anthropic.com` | | pi-mono | `pi-mono` | `--pi` | `ANTHROPIC_API_KEY` | `api.anthropic.com` | @@ -130,6 +130,27 @@ To run Codex through Meta AI direct, store `META_AI_API_KEY` and select the provider with `--meta`. Pair it with `--model ` when choosing a provider-specific model for a turn. +To register a private OpenAI-compatible Responses provider, add it once under +`codex.customProviders`: + +```yaml +codex: + customProviders: + private_responses: + name: Private Responses + baseUrl: https://inference.example.com/v1 + apiKeyEnv: PRIVATE_RESPONSES_API_KEY + defaultModel: example-model +``` + +Store the named key in the configured iron-proxy secret source. The chart sends +the non-secret provider catalog to api-rs, sandboxes, chat ingresses, and the +Console. api-rs registers a bearer-token replacement restricted to the +`baseUrl` DNS host; sandboxes receive only the `apiKeyEnv` placeholder. Select +the provider with `--provider private_responses`; `--model` overrides its +configured default for that turn. If `defaultModel` is omitted, callers must +pass `--model`; the Console lists only custom providers that define a default. + The backing vault can be shared across the deployment, but access is not. The Centaur Console grants each user, channel, issue, or workflow principal only the roles and secrets it should use. The `infra` role is assigned to new @@ -149,38 +170,67 @@ token family is revoked, logging both sides out at random. Use a separate ChatGPT account for any non-Centaur Codex work. ::: -Codex supports two authentication modes, selected per deployment with the -`CODEX_AUTH_MODE` env var on the sandbox (set it via `sandbox.extraEnv`): +Codex supports two authentication modes, selected per deployment with +`sandbox.codexAuthMode` in the chart values. api-rs reads the resulting +`CODEX_AUTH_MODE` env var to register the matching proxy credential with the +console and propagates it into each sandbox, so the agent's `auth.json` and +the injected credential always agree. Do not set `CODEX_AUTH_MODE` through +`sandbox.extraEnv`: that reaches sandbox pods but not api-rs, which is the +component that acts on the mode. -| Mode | Upstream | Secrets required | -|------|----------|------------------| -| `api_key` (default) | `api.openai.com` | `OPENAI_API_KEY` | -| `access_token` | `chatgpt.com` | `OPENAI_CODEX_CLIENT_ID`, `OPENAI_CODEX_BLOB`, `OPENAI_CODEX_ACCOUNT_ID` | +| Mode | Upstream | Credentials required | +|------|----------|----------------------| +| `api_key` (default) | `api.openai.com` | `OPENAI_API_KEY` in the secrets backend | +| `access_token` | `chatgpt.com` | `OPENAI_CODEX_ACCOUNT_ID` in the secrets backend, plus the `openai-codex` broker credential in the console | `access_token` mode routes Codex through a ChatGPT account rather than a raw -API key. [iron-token-broker](https://docs.iron.sh) holds the refresh token -and mints short-lived access tokens, which iron-proxy injects on outbound -requests so the sandbox never sees them. - -Store these three items in your secrets backend (1Password vault, Kubernetes -Secret, etc.) when running in `access_token` mode: - -- `OPENAI_CODEX_CLIENT_ID`: the Codex CLI's OAuth client id. This is a - fixed, publicly known constant: `app_EMoamEEZ73f0CkXaXp7hrann`. It is - the same for every Codex install and never rotates, but the broker - still resolves it through your secrets backend, so store the literal - value as-is. -- `OPENAI_CODEX_BLOB`: a JSON document `{"refresh_token": "..."}`. The - broker rotates this in place on every refresh, so the backing item must - be writable. -- `OPENAI_CODEX_ACCOUNT_ID`: the ChatGPT account UUID the credential is - bound to. It is static, but iron-proxy injects it as the - `chatgpt-account-id` header so the backend can route to the right - workspace. Store it alongside the other two, not in code. - -To bootstrap, run `codex login` locally, then copy the refresh token and -account id from `~/.codex/auth.json` into the matching secret items. Use -the constant above for `OPENAI_CODEX_CLIENT_ID`. +API key. The console owns the refresh token as the `openai-codex` broker +credential: its background worker refreshes it and mints short-lived access +tokens, which the per-sandbox proxy injects on outbound requests so the +sandbox never sees them. The refresh token is stored encrypted in the +console's own database. It is not read from or synced to your secrets +backend. + +The commands below assume you are at the root of a Centaur checkout and have +configured `IRON_CONTROL_URL` and `IRON_CONTROL_API_KEY` as described in +[Configure the Operator CLI](/secrets/advanced-permissioning#configure-the-operator-cli). + +To bootstrap `access_token` mode: + +1. Log in locally with the dedicated ChatGPT account and force this login to + use a file-backed credential store: + + ```bash + codex login -c 'cli_auth_credentials_store="file"' + CODEX_AUTH_FILE="${CODEX_HOME:-$HOME/.codex}/auth.json" + export OPENAI_CODEX_ACCOUNT_ID="$(jq -er '.tokens.account_id' "$CODEX_AUTH_FILE")" + export OPENAI_CODEX_REFRESH_TOKEN="$(jq -er '.tokens.refresh_token' "$CODEX_AUTH_FILE")" + ``` + + The credential-store override is intentional. Codex can otherwise use the + operating system keyring, which leaves no `auth.json` file to read. +2. Store `OPENAI_CODEX_ACCOUNT_ID` in your secrets backend (1Password vault, + Kubernetes Secret, etc.). iron-proxy injects this ChatGPT account UUID as + the `chatgpt-account-id` header so the backend routes to the right + workspace. +3. Create the broker credential with the refresh token from the same login: + + ```bash + cargo run --manifest-path services/api-rs/Cargo.toml -p centaur-perms -- \ + broker create --foreign-id openai-codex \ + --token-endpoint https://auth.openai.com/oauth/token \ + --client-id app_EMoamEEZ73f0CkXaXp7hrann \ + --refresh-token "$OPENAI_CODEX_REFRESH_TOKEN" + unset OPENAI_CODEX_REFRESH_TOKEN + ``` + + The client id is the Codex CLI's fixed, publicly known OAuth client id: + the same for every Codex install; it is passed here, not stored in the + secrets backend. +4. Start (or restart) api-rs. At startup it registers the access-token + fragment with the console; if the `openai-codex` broker credential does + not exist yet, the console rejects the registration with a 422 and api-rs + fails fast, so create the credential first. ### Claude Auth Modes @@ -194,38 +244,62 @@ entire token family is revoked, logging both sides out at random. Use a separate Claude.ai account for any non-Centaur Claude Code work. ::: -Claude Code supports two authentication modes, selected per deployment -with the `CLAUDE_CODE_AUTH_MODE` env var on the sandbox (set it via -`sandbox.extraEnv`): +Claude Code supports two authentication modes, selected per deployment with +`sandbox.claudeCodeAuthMode` in the chart values. It has the same contract as +`sandbox.codexAuthMode` above: api-rs registers the matching proxy credential +and propagates `CLAUDE_CODE_AUTH_MODE` into each sandbox, so do not set the +env var through `sandbox.extraEnv`. -| Mode | Upstream | Secrets required | -|------|----------|------------------| -| `api_key` (default) | `api.anthropic.com` | `ANTHROPIC_API_KEY` | -| `access_token` | `api.anthropic.com` | `CLAUDE_CODE_CLIENT_ID`, `CLAUDE_CODE_BLOB` | +| Mode | Upstream | Credentials required | +|------|----------|----------------------| +| `api_key` (default) | `api.anthropic.com` | `ANTHROPIC_API_KEY` in the secrets backend | +| `access_token` | `api.anthropic.com` | the `anthropic-claude` broker credential in the console | `access_token` mode routes Claude Code through a Claude.ai Pro or Max -subscription rather than a raw API key. [iron-token-broker](https://docs.iron.sh) -holds the refresh token and mints short-lived access tokens, which iron-proxy -injects on outbound requests so the sandbox never sees them. The entrypoint -plants a dummy `~/.claude/.credentials.json` so the CLI emits OAuth-shaped -requests; the broker overwrites the Bearer at request time. - -Store these two items in your secrets backend (1Password vault, Kubernetes -Secret, etc.) when running in `access_token` mode: - -- `CLAUDE_CODE_CLIENT_ID`: the Claude Code CLI's OAuth client id. This - is a fixed, publicly known constant: - `9d1c250a-e61b-44d9-88ed-5944d1962f5e`. It is the same for every Claude - Code install and never rotates, but the broker still resolves it through - your secrets backend, so store the literal value as-is. -- `CLAUDE_CODE_BLOB`: a JSON document `{"refresh_token": "..."}`. The - broker rotates this in place on every refresh, so the backing item must be - writable. - -To bootstrap, run `claude login` locally, then copy the refresh token from -`~/.claude/.credentials.json` (or from the `Claude Code-credentials` keychain -item on macOS) into `CLAUDE_CODE_BLOB`. Use the constant above for -`CLAUDE_CODE_CLIENT_ID`. +subscription rather than a raw API key. The console owns the refresh token as +the `anthropic-claude` broker credential and mints short-lived access tokens, +which the per-sandbox proxy injects as the Bearer on outbound requests so the +sandbox never sees them. The sandbox entrypoint plants a dummy +`~/.claude/.credentials.json` so the CLI emits OAuth-shaped requests; the +proxy overwrites the Bearer at request time. This mode needs no +secrets-backend items. + +The commands below assume you are at the root of a Centaur checkout and have +configured `IRON_CONTROL_URL` and `IRON_CONTROL_API_KEY` as described in +[Configure the Operator CLI](/secrets/advanced-permissioning#configure-the-operator-cli). + +To bootstrap, run `claude login` locally with the dedicated Claude.ai +account, then export the refresh token. On systems where Claude Code writes a +credentials file, run: + +```bash +export CLAUDE_CODE_REFRESH_TOKEN="$( + jq -er '.claudeAiOauth.refreshToken' "$HOME/.claude/.credentials.json" +)" +``` + +If Claude Code used the macOS keychain instead, run: + +```bash +export CLAUDE_CODE_REFRESH_TOKEN="$( + security find-generic-password -s 'Claude Code-credentials' -w | + jq -er '.claudeAiOauth.refreshToken' +)" +``` + +Create the broker credential from the root of the checkout: + +```bash +cargo run --manifest-path services/api-rs/Cargo.toml -p centaur-perms -- \ + broker create --foreign-id anthropic-claude \ + --token-endpoint https://platform.claude.com/v1/oauth/token \ + --client-id 9d1c250a-e61b-44d9-88ed-5944d1962f5e \ + --refresh-token "$CLAUDE_CODE_REFRESH_TOKEN" +unset CLAUDE_CODE_REFRESH_TOKEN +``` + +The client id is Claude Code's fixed, publicly known OAuth client id. As with +Codex, api-rs fails fast at startup if the broker credential is missing. ## 4. Configure Advanced Permissioning diff --git a/docs/pages/extend/tools.mdx b/docs/pages/extend/tools.mdx index 2be4df45d..a0098636f 100644 --- a/docs/pages/extend/tools.mdx +++ b/docs/pages/extend/tools.mdx @@ -67,17 +67,18 @@ Each entry in `secrets` declares one credential the tool can request with auth). For `jwt_bearer` (RFC 7523), supply `issuer`, `subject`, and `private_key` (an RSA PEM) in `fields`, plus a top-level `audience`; an optional `private_key_id` field is emitted as the JWT `kid` header. -- `type = "brokered_token"` routes OAuth2 refresh-token rotation through - iron-token-broker instead of iron-proxy. Use this when the upstream IdP - rotates refresh tokens with strict reuse detection (OpenAI Codex, Anthropic - Claude Code OAuth, modern Okta or Auth0 with rotation enabled) and more - than one proxy shares the credential. Required `fields`: `client_id`, - `refresh_token`. Optional: `client_secret`. The `refresh_token` field names - the writable credential blob the broker rewrites on every rotation; the - other fields are read-only. Read-side fields and `token_endpoint_headers` - entries accept `json_key` to pluck a value out of a JSON-encoded secret; - the `refresh_token` field does not (the broker rewrites the whole - document). +- `type = "brokered_token"` routes OAuth2 refresh-token rotation through the + Console's broker credential system instead of through each iron-proxy. Use + this when the upstream IdP rotates refresh tokens with strict reuse detection + (OpenAI Codex, Anthropic Claude Code OAuth, modern Okta or Auth0 with rotation + enabled) and more than one proxy shares the credential. The entry is only the + consumer side: `name` identifies the injected secret, `hosts` defines its + request rules, and optional `credential` names the Console broker credential + by foreign ID (defaulting to `name`). Create that credential separately in + the Console or with `centaur-perms broker create`. Optional `inject_header` + and `inject_formatter` override the default Bearer authorization injection. + Legacy `fields`, `token_endpoint`, and `scopes` keys are ignored and should + not be declared. - `type = "gcp_auth"` is for Google service-account JSON. iron-proxy resolves the keyfile, mints Google OAuth tokens for `scopes`, and injects them for the configured Google API `hosts`. If omitted, hosts default to diff --git a/docs/pages/extend/workflows-v2.mdx b/docs/pages/extend/workflows-v2.mdx index 95a736142..cb7349c7c 100644 --- a/docs/pages/extend/workflows-v2.mdx +++ b/docs/pages/extend/workflows-v2.mdx @@ -59,6 +59,7 @@ Supported v2 primitives: | `handler(inp, ctx)` | Supported | | `ctx.step(name, fn)` | Supported | | `ctx.agent_turn(...)` / `ctx.run_agent(...)` | Supported | +| `ctx.run_agents(...)` | Supported for bounded concurrent agent turns with ordered outcomes | | `ctx.call_tool(...)` | Supported through the generated `centaur-tools call` bridge in the workflow-host sandbox | | `ctx.post_to_slack(...)` | Supported | | `ctx._pool` | Supported when the workflow-host sandbox receives `DATABASE_URL` | @@ -97,11 +98,20 @@ be wrapped in `ctx.step(...)` so the result is checkpointed: async def handler(inp: dict, ctx: WorkflowContext) -> dict: posted = await ctx.step( "post_summary", - lambda: ctx.post_to_slack(inp["channel"], inp["summary"]), + lambda: ctx.post_to_slack( + inp["channel"], + inp["summary"], + username="Summary Bot", + icon_emoji=":memo:", + ), ) return {"posted": posted} ``` +`username` and `icon_emoji` are optional. They customize the Slack app identity +for that message and require the app installation to grant +`chat:write.customize`. Omitting them preserves the app's default identity. + ### Make agent turns explicit Use `ctx.agent_turn(...)` when the workflow needs an agent sandbox: @@ -134,6 +144,22 @@ session runtime. } ``` +Use `ctx.run_agents(...)` for independent work that should run concurrently: + +```python +reviews = await ctx.run_agents( + [ + {"name": "correctness", "text": "Review the PR for correctness."}, + {"name": "security", "text": "Review the PR for security issues."}, + ], + max_concurrency=2, +) +``` + +Each item uses a separate workflow-owned session. Results stay in input order. +An individual agent failure produces an item with `ok: false`; it does not fail +the whole batch or discard successful results. + #### Declare Workflow-Host Permissions When a workflow calls tools directly from the workflow host with @@ -154,9 +180,17 @@ cargo run -p centaur-perms -- \ --tool slack ``` -The principal id is always `workflow-` plus the slugged `WORKFLOW_NAME`. -Workflow code cannot choose another principal id, display name, or labels. -`WORKFLOW_PRINCIPAL = True` requires `apiRs.workflowHostSandbox=true`, which +To use an existing principal instead, declare its foreign ID or `prn_`-prefixed +OID: + +```python +WORKFLOW_PRINCIPAL = "finance-automation" +``` + +The string form resolves the existing principal by foreign ID or OID and fails +startup when it does not exist. It does not create or update the principal. The +`True` form remains `workflow-` plus the slugged `WORKFLOW_NAME`. Any +`WORKFLOW_PRINCIPAL` value requires `apiRs.workflowHostSandbox=true`, which renders `WORKFLOW_HOST_SANDBOX=true`; startup fails if a workflow declares a principal while workflow-host sandboxing is disabled. @@ -188,6 +222,21 @@ Keep `harness` and `model` together — a model is only meaningful within its harness, and because kwargs override `AGENT_DEFAULTS` key by key, overriding one without the other can strand a model on the wrong harness. +Agent turns can also select an existing principal by foreign ID. This applies +to `ctx.agent_turn(...)`, its aliases, and each `ctx.run_agents(...)` item: + +```python +result = await ctx.agent_turn( + "Prepare the finance report.", + principal="finance-automation", +) +``` + +Principal lookup failures fail the turn. Put `principal` in `AGENT_DEFAULTS` +when every agent turn in the workflow should use the same principal. An +existing session bound to another principal returns a conflict and is not +rebound. + ### Declare webhook metadata in the workflow Expose a workflow through `WEBHOOKS`: diff --git a/docs/pages/extend/workflows.mdx b/docs/pages/extend/workflows.mdx index e349e4132..397e6e5b0 100644 --- a/docs/pages/extend/workflows.mdx +++ b/docs/pages/extend/workflows.mdx @@ -66,10 +66,12 @@ async def handler(inp: Input, ctx: WorkflowContext) -> dict[str, Any]: `WORKFLOW_PRINCIPAL` is optional. Use it when the workflow host calls tools directly with `ctx.call_tool(...)` and should have its own credential boundary. -The API derives and registers the `workflow-nightly-report` principal from -`WORKFLOW_NAME` and runs that workflow-host sandbox under it. Workflow code -cannot choose another principal id, display name, or labels. Grant the required -tool roles or secrets to the derived principal. `WORKFLOW_PRINCIPAL = True` +Set it to `True` to have the API derive and register the +`workflow-nightly-report` principal from `WORKFLOW_NAME`. Set it to an existing +principal foreign ID, such as `WORKFLOW_PRINCIPAL = "finance-automation"`, or +to a `prn_`-prefixed OID to run the workflow-host sandbox under that principal +instead. An unknown principal reference fails startup. Grant the required tool +roles or secrets to the selected principal. Any `WORKFLOW_PRINCIPAL` value requires `apiRs.workflowHostSandbox=true`, which renders `WORKFLOW_HOST_SANDBOX=true`; startup fails if workflow-host sandboxing is disabled. @@ -83,10 +85,8 @@ disabled. | `ctx.sleep_until(name, when)` | Resume at a specific time. | | `ctx.wait_for_event(name, event_type, correlation_id)` | Wait for an external event. | | `ctx.start_workflow(...)` | Start a child workflow and continue immediately. | -| `ctx.wait_for_workflow(...)` | Wait for a child workflow to finish. | -| `ctx.run_workflow(...)` | Start and wait in one call. | -| `ctx.start_agent(...)` | Start an agent turn. | -| `ctx.run_agent(...)` | Start an agent turn and wait for the result. | +| `ctx.agent_turn(...)` / `ctx.run_agent(...)` / `ctx.start_agent(...)` | Run one agent turn and wait for the result. | +| `ctx.run_agents(...)` | Run a bounded group of named agent turns concurrently and wait for every outcome. | The handler may re-execute after a restart. Put external side effects behind `ctx.step(...)` so completed work is not repeated. @@ -99,11 +99,67 @@ These primitives compose into larger automations: billing state, deploy health, or vendor exports. - **Event-driven flows**: wait for a webhook, approval, upload, or callback and continue from the last checkpoint. -- **Fan-out/fan-in orchestration**: start child workflows for independent work - and wait for all of them before producing a final result. +- **Fan-out/fan-in orchestration**: run independent named agents concurrently, + then combine their successful results into one final result. - **Agent orchestration**: use agents for judgment-heavy steps while the workflow owns timing, retries, state, and final delivery. +### Run Agents Concurrently + +Use `ctx.run_agents(...)` when independent reviewers or researchers should run +at the same time: + +```python +reviews = await ctx.run_agents( + [ + {"name": "correctness", "text": "Review the PR for correctness."}, + {"name": "security", "text": "Review the PR for security issues."}, + {"name": "tests", "text": "Review the PR's test coverage."}, + ], + max_concurrency=3, +) +``` + +Every agent needs a unique, non-empty `name`. Centaur assigns each one a +separate workflow-owned session and stable idempotency keys. The maximum +concurrency defaults to 4 and may be set from 1 through 16. A batch supports up +to 32 agents. + +The result preserves input order and reports individual failures without +discarding successful reviews: + +```json +{ + "results": [ + {"index": 0, "name": "correctness", "ok": true, "result": {"result_text": "..."}}, + {"index": 1, "name": "security", "ok": false, "error": "agent unavailable"}, + {"index": 2, "name": "tests", "ok": true, "result": {"result_text": "..."}} + ], + "succeeded": 2, + "failed": 1 +} +``` + +Batch items accept the same model, provider, reasoning, harness, persona, +principal, prompt, content, idle timeout, maximum duration, and metadata +options as `ctx.agent_turn(...)`. Session and idempotency fields are reserved +because the batch runtime assigns them independently for each agent. + +Set `principal` to an existing principal foreign ID when an agent turn needs a +specific credential boundary: + +```python +result = await ctx.agent_turn( + "Prepare the finance report.", + principal="finance-automation", +) +``` + +The principal is resolved before the session is created. An unknown or empty +foreign ID fails the turn instead of using the thread-derived principal. If the +session already exists under another principal, the turn returns a conflict +instead of rebinding it. + ## Run a workflow Direct administrative API calls need a short-lived Console service token. Mint @@ -275,8 +331,37 @@ that content type is listed in `allowed_content_types`. Webhook requests do not use Centaur API keys. The API verifies the provider signature before creating workflow state. `HmacAuth.github(...)` verifies `X-Hub-Signature-256`; a plain `HmacAuth(...)` can be used for other -SHA-256 HMAC providers. During local development or for trusted internal -routes, `auth="none"` is allowed. +SHA-256 HMAC providers that sign the raw request body. During local development +or for trusted internal routes, `auth="none"` is allowed. + +For providers that implement the [Standard Webhooks +specification](https://www.standardwebhooks.com/), use the explicit +`standard_webhooks` auth type: + +```python +WEBHOOKS = [ + { + "slug": "feed-ingest", + "provider": "feed-provider", + "auth": { + "type": "standard_webhooks", + "secret_ref": "FEED_WEBHOOK_SECRET", + }, + "trigger_key": {"type": "header", "header": "webhook-id"}, + "allowed_methods": ["POST"], + "allowed_content_types": ["application/json"], + } +] +``` + +Set `FEED_WEBHOOK_SECRET` in the API deployment to the provider's signing +secret (normally a `whsec_`-prefixed, base64-encoded value). Centaur requires +the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers, verifies +the `v1` signature over the exact raw body, supports space-separated signatures +for key rotation, and rejects timestamps outside a five-minute window. Using +`webhook-id` as the trigger key also gives retries the same durable workflow +identity. Auth schemes are selected by `auth.type`; Centaur does not infer one +from the headers. The workflow receives input in this shape: diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index bc7a42cf5..20165cdaa 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -37,9 +37,7 @@ These must exist for the normal Helm deployment. For local development, | `SLACK_SIGNING_SECRET` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Slack request signature verification. | | `SLACKBOT_API_KEY` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Dedicated Slackbot key accepted by api-rs for the Slack session namespace and workflow events. | | `SLACK_BOT_TOKEN` | `secretManager.existingSecretName`; local bootstrap reads shell env. | Slack Web API access for Slackbot and api-rs Slack helpers. | -| `SANDBOX_SIGNING_KEY` | `secretManager.existingSecretName`; local bootstrap generates it. | Signing key for short-lived sandbox API tokens. | | `IRON_MANAGEMENT_API_KEY` | `secretManager.existingSecretName`; local bootstrap generates it. | Management key for API-created iron-proxy pods. | -| `IRON_BROKER_TOKEN` | `secretManager.existingSecretName`; required when `tokenBroker.enabled=true`. | Bearer token iron-proxy presents to iron-token-broker and the broker enforces on its HTTP API. | | `OP_SERVICE_ACCOUNT_TOKEN` | Local shell, then `centaur-infra-env`; production Secret. | 1Password service-account auth when using `onepassword` secret source. | | `OP_VAULT` | Local shell, then `centaur-infra-env`; defaults to `ai-agents` in code. | 1Password vault used for `op://...` secret refs. | @@ -119,6 +117,8 @@ created. Changing a default does not rewrite existing principals. | `apiRs.activitySummary.*` | Helm values, default disabled. | Enables API-RS to summarize live session activity into durable `session.activity_summary` events. | | `SLACK_BOT_TOKEN` | Explicit `secretKeyRef` from `secretManager.existingSecretName`. | Slack Web API access for api-rs Slack proxy and workflow Slack helpers. | | `OPENAI_API_KEY` | Secret mounted into api-rs, or `apiRs.extraEnv` for local/dev overrides. | OpenAI credential for activity summaries; the feature stays disabled when no key is present. | +| `OPENAI_BASE_URL` | `apiRs.extraEnv`; default `https://api.openai.com/v1`. | OpenAI-compatible Responses API base URL for every api-rs OpenAI caller: Codex, generated session titles, and activity summaries. API-RS passes it into Codex sandboxes and derives iron-proxy's `OPENAI_API_KEY` host scope from it. | +| `SESSION_ACTIVITY_SUMMARY_OPENAI_BASE_URL` | Deprecated `apiRs.activitySummary.openaiBaseUrl` compatibility setting. | Existing activity-summary endpoint override. `OPENAI_BASE_URL` takes precedence when both are set. | | `SESSION_ACTIVITY_SUMMARY_MODEL` | `apiRs.activitySummary.model`, default `gpt-5.4-nano`. | Model used for the short live activity sentence. | Sandbox lifecycle: @@ -167,7 +167,7 @@ Execution tuning: | `SLACKBOT_EXTERNAL_ORG_ALLOWLIST` | `slackbot.extraEnv`. | Slack team ids allowed for external org handoff. | | `SLACKBOTV2_AUTO_JOIN_CREATED_CHANNELS` | `slackbotv2.autoJoinCreatedChannels`. | Joins newly-created public channels after subscribed `channel_created` events. Requires `channels:read` and `channels:join`. Defaults to `false`. | | `SLACKBOTV2_DEFAULT_HARNESS` | `sandbox.harnessEngine`. | Base harness for new Slack threads without an explicit flag or channel default. | -| `SESSION_CODEX_NANOCODEX_ROLLOUT_PERCENT` | `apiRs.codexNanocodexRolloutPercent`. | Percentage of API session requests for Codex assigned to Nanocodex. Assignment is deterministic by thread key and persisted as the session harness. The API response and structured `session_harness_rollout_resolved` log identify the experiment and cohort; session and execution metadata retain the assignment for later analysis. Slack keeps the cohort unobtrusive by showing `Codex*` in the first response footer. Defaults to `50`; set to `0` to keep new Codex requests on Codex. | +| `SLACKBOTV2_CODEX_NANOCODEX_ROLLOUT_PERCENT` | `slackbotv2.codexNanocodexRolloutPercent`. | Percentage of otherwise-default Codex Slack threads assigned to Nanocodex. Assignment is deterministic by thread key and recorded in session and execution metadata. Selecting a non-default model bypasses the rollout. When response metadata is enabled, Slack shows the resolved harness name. Defaults to `0`; increase it to enroll new Codex Slack threads. | | `SLACKBOTV2_CHANNEL_DEFAULTS` | `slackbotv2.channelDefaults`. | Per-channel default harness / model / provider / reasoning as a JSON object keyed by Slack conversation id, where each value is an object of optional `harness`/`model`/`provider`/`reasoning` fields (same vocabulary as the inline flags, so `harness: claude`, `provider: bedrock`, and Claude model aliases like `opus` all work), e.g. `{"C0ENG":{"harness":"claude","model":"opus","reasoning":"high"},"C0TRIAGE":{"reasoning":"low"}}`. A model is only meaningful within a harness, so name the harness alongside it. Applied when a message in that channel carries no explicit/sticky per-thread flag (below such a flag, above the deployment/baked default) and forwarded onto the harness input line so it takes effect; setting the harness restarts a thread onto it like a `--claude`/`--codex` flag. `reasoning` affects the Codex and Nanocodex harnesses. Malformed JSON and unrecognized field values are logged and ignored. | | `SLACK_TEAM_ID` | `slackbot.extraEnv`. | Workspace team ID (e.g. `T01ABCD2EFG`) used to rewrite `https://*.slack.com/archives/...` URLs in final-delivery messages into native `slack://channel?team=...` deep links that open in the Slack app. Leave unset to keep archive URLs unchanged. | | `COMMIT_SHA` | Build/deploy env. | Commit shown in Slackbot metadata. | @@ -224,9 +224,7 @@ Kubernetes backend: | `KUBERNETES_FIREWALL_CA_SECRET_NAME`, `KUBERNETES_FIREWALL_CA_KEY_SECRET_NAME` | `firewall.existingCa*` or generated CA Secrets. | CA material for sandbox/proxy TLS interception. | | `KUBERNETES_SECRET_ENV_NAME`, `KUBERNETES_SECRET_ENV_PREFIX`, `KUBERNETES_BOOTSTRAP_SECRET_NAME` | `secretManager.*`, `secrets.bootstrapSecretName`. | Secrets read by API-created proxy/sandbox pods. | | `KUBERNETES_IRON_PROXY_IMAGE`, `KUBERNETES_IRON_PROXY_IMAGE_PULL_POLICY`, `KUBERNETES_IRON_PROXY_PORT`, `KUBERNETES_IRON_PROXY_MANAGEMENT_PORT`, `KUBERNETES_IRON_PROXY_HEALTH_PORT` | `ironProxy.*`. | Per-sandbox iron-proxy image and ports. | -| `FIREWALL_MANAGER_SECRET_SOURCE`, `FIREWALL_MANAGER_SECRET_TTL`, `KUBERNETES_FIREWALL_MANAGER_SECRET_SOURCE` | `ironProxy.secretSource`, `ironProxy.secretTtl`. | Secret source and cache TTL for rendered proxy config. | -| `FIREWALL_MANAGER_TOKEN_BROKER_TTL` | `tokenBroker.ttl`. | Proxy-side cache TTL for access tokens minted by iron-token-broker. Applied to every `brokered_token` secret. | -| `KUBERNETES_TOKEN_BROKER_NAME`, `KUBERNETES_TOKEN_BROKER_URL` | `tokenBroker.*`. | iron-token-broker Deployment name and ClusterIP URL. The chart owns the broker Deployment, Service, and NetworkPolicies; the API reconciles its ConfigMap and triggers a rolling restart when the rendered content changes. | +| `FIREWALL_MANAGER_SECRET_SOURCE`, `FIREWALL_MANAGER_SECRET_TTL` | `ironProxy.secretSource`, `ironProxy.secretTtl`. | Secret source and cache TTL for rendered proxy config. | | `KUBERNETES_OP_CONNECT_HOST`, `KUBERNETES_OP_CONNECT_APP_NAME`, `KUBERNETES_OP_CONNECT_PORT` | Chart helper or `api.extraEnv`. | 1Password Connect endpoint details. | | `KUBERNETES_API_POD_LABEL_SELECTOR` | Chart-rendered labels or `api.extraEnv`. | API pod selector for API-managed proxy policies. | | `KUBERNETES_EGRESS_DISCOVERY_ENABLED`, `KUBERNETES_EGRESS_SERVICE_NAMESPACE`, `KUBERNETES_CLUSTER_DOMAIN`, `KUBERNETES_EGRESS_TAILNET_FQDN_ANNOTATION` | `api.egressDiscovery.*`. | Egress service discovery for sandbox NetworkPolicies. | @@ -241,12 +239,14 @@ Sandbox entrypoint and wrappers: | `AGENT_REPO`, `AGENT_PERSONA` | Runtime assignment metadata. | Workspace repo clone and persona prompt. | | `GOOGLE_APPLICATION_CREDENTIALS` | Sandbox entrypoint or `sandbox.extraEnv`. | Google ADC path; entrypoint creates a local stub when unset. | | `CODEX_API_KEY`, `CODEX_HOME`, `CODEX_CONTINUE_THREAD_ID` | `sandbox.extraEnv` or runtime resume. | Codex auth/config/resume behavior. | -| `CODEX_AUTH_MODE` | `sandbox.extraEnv`. | Codex auth flow: `api_key` (default, hits `api.openai.com`) or `access_token` (hits `chatgpt.com` via the brokered ChatGPT login). See [Codex Auth Modes](/deploying-in-production#codex-auth-modes). | +| `CODEX_AUTH_MODE` | `sandbox.codexAuthMode`. | Codex auth flow: `api_key` (default, hits `api.openai.com`) or `access_token` (hits `chatgpt.com` via the brokered ChatGPT login). The chart sets this on api-rs, which propagates it into sandboxes. See [Codex Auth Modes](/deploying-in-production#codex-auth-modes). | | `META_AI_API_KEY` | Secret mounted into api-rs. | Meta AI direct credential for Codex provider `responses` and Slack or Linear `--meta` selection. | +| `CODEX_CUSTOM_PROVIDERS` | Chart-rendered from `codex.customProviders`. | JSON provider catalog consumed by api-rs, sandboxes, chat ingresses, and Console. Prefer the chart value over setting this environment variable directly. Each entry supplies `name`, HTTPS `baseUrl`, `apiKeyEnv`, and an optional `defaultModel`; select it with `--provider `. | +| Custom provider `apiKeyEnv` | The configured iron-proxy secret source. | Bearer token for that provider. Sandboxes receive only a same-name placeholder, and replacement is restricted to the provider's `baseUrl` DNS host. | | `CODEX_MODEL_REASONING_SUMMARY` | `sandbox.extraEnv`. | Sets `model_reasoning_summary` in the Codex config (`auto`, `concise`, `detailed`, `none`). Codex >= 0.139 emits no reasoning summaries unless this is set, so renderers show no thinking trace. | | `CODEX_MODEL_REASONING_EFFORT` | `sandbox.extraEnv`. | Overrides the Codex `model_reasoning_effort` (baked into `harness/codex/config.toml`) and Nanocodex's default thinking effort. It is mirrored into Slackbot so the first response footer displays the effective level. One of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`; an unknown value is ignored (the config default stands). | | `CLAUDE_MODEL`, `CLAUDE_CONTINUE_SESSION_ID` | `sandbox.extraEnv` or runtime resume. | Claude model and resume behavior. | -| `CLAUDE_CODE_AUTH_MODE` | `sandbox.extraEnv`. | Claude Code auth flow: `api_key` (default, uses `ANTHROPIC_API_KEY`) or `access_token` (Claude.ai Pro or Max via the brokered OAuth login). See [Claude Auth Modes](/deploying-in-production#claude-auth-modes). | +| `CLAUDE_CODE_AUTH_MODE` | `sandbox.claudeCodeAuthMode`. | Claude Code auth flow: `api_key` (default, uses `ANTHROPIC_API_KEY`) or `access_token` (Claude.ai Pro or Max via the brokered OAuth login). The chart sets this on api-rs, which propagates it into sandboxes. See [Claude Auth Modes](/deploying-in-production#claude-auth-modes). | | `DEPLOY_ENV`, `ENVIRONMENT`, `TRACEPARENT` | Deployment env or wrapper-generated. | Runtime environment and trace context. | | `CALL_TIMEOUT_SECONDS` | Sandbox env before running `call`. | Curl watchdog for API tool calls. | | `SLACK_CHANNEL`, `SLACK_THREAD_TS` | Sandbox env. | File-upload helper target. | @@ -259,7 +259,7 @@ Sandbox entrypoint and wrappers: | `WORKFLOW_RECONCILE_INTERVAL_S`, `WORKFLOW_RESUSPEND_BACKOFF_S` | `api.extraEnv`. | Workflow claim/reclaim cadence. | | `WORKFLOW_SCHEDULE_TICK_INTERVAL_S`, `WORKFLOW_SCHEDULE_CATCHUP_LIMIT`, `WORKFLOW_SCHEDULE_MISFIRE_GRACE_S` | `api.extraEnv`. | Scheduled workflow timing and catch-up behavior. | | `MY_THREAD_KEY`, `_THREAD_KEY`, `_SLACK_CHANNEL` | Workflow-specific env. | Fallback thread/channel targets for workflow agent steps. | -| `` | API env or Secret named by a workflow `WebhookSpec`. | HMAC secret for public workflow webhooks, for example `GITHUB_WEBHOOK_SECRET`. | +| `` | API env or Secret named by a workflow `WebhookSpec`. | Signing secret or bearer token for public workflow webhooks, for example `GITHUB_WEBHOOK_SECRET` or a Standard Webhooks `whsec_...` value. | Slack ETL workflows: diff --git a/docs/pages/secrets/advanced-permissioning.mdx b/docs/pages/secrets/advanced-permissioning.mdx index 2eefbbe4e..ea42405e6 100644 --- a/docs/pages/secrets/advanced-permissioning.mdx +++ b/docs/pages/secrets/advanced-permissioning.mdx @@ -63,6 +63,7 @@ The stable principal ids follow these rules: | Teams personal chat | `teams-user-` | That Teams user | | Teams channel or group conversation | `teams-conversation-` | Everyone using Centaur in that conversation | | Workflow with `WORKFLOW_PRINCIPAL = True` | `workflow-` | Runs of that workflow | +| Workflow with `WORKFLOW_PRINCIPAL = ""` | The selected existing principal | Runs of that workflow | | Other session key | `thread-` | That session key | Values are lowercased and converted to URL-safe slugs. The team scope is @@ -405,9 +406,12 @@ credential. Grant that wrapper secret to a user, channel, or role like any other secret. See [OAuth Apps](/secrets/oauth-apps) for registration and consent. -Workflows opt into isolated permissions with `WORKFLOW_PRINCIPAL = True`. -Centaur derives `workflow-` and does not let workflow code choose -another identity. Grant the workflow only the roles or secrets it needs. See +Workflows opt into isolated permissions with `WORKFLOW_PRINCIPAL = True`, which +derives `workflow-`. They can instead set `WORKFLOW_PRINCIPAL` to +an existing principal foreign ID or `prn_`-prefixed OID. Agent turns can select +an existing principal with `ctx.agent_turn(..., principal="")`. +Unknown foreign IDs fail instead of falling back to a broader identity. Grant +workflows and agent turns only the roles or secrets they need. See [Creating Workflows](/extend/workflows#define-a-workflow). ## Operational Checklist diff --git a/docs/pages/secrets/environment.mdx b/docs/pages/secrets/environment.mdx index b632c68f8..46ef11323 100644 --- a/docs/pages/secrets/environment.mdx +++ b/docs/pages/secrets/environment.mdx @@ -34,7 +34,6 @@ kubectl create secret generic centaur-infra-env \ --from-literal=SLACKBOT_API_KEY='...' \ --from-literal=SLACK_BOT_TOKEN='xoxb-...' \ --from-literal=SLACK_SIGNING_SECRET='...' \ - --from-literal=SANDBOX_SIGNING_KEY="$(openssl rand -hex 32)" \ --from-literal=IRON_MANAGEMENT_API_KEY="$(openssl rand -hex 32)" \ --from-literal=OPENAI_API_KEY='...' \ --from-literal=AMP_API_KEY='...' \ diff --git a/docs/pages/secrets/onepassword.mdx b/docs/pages/secrets/onepassword.mdx index 0b3766953..a61765dc0 100644 --- a/docs/pages/secrets/onepassword.mdx +++ b/docs/pages/secrets/onepassword.mdx @@ -75,7 +75,6 @@ CENTAUR_JWT_SIGNING_SECRET SLACKBOT_API_KEY SLACK_BOT_TOKEN SLACK_SIGNING_SECRET -SANDBOX_SIGNING_KEY IRON_MANAGEMENT_API_KEY ``` diff --git a/packages/harness-events/src/index.ts b/packages/harness-events/src/index.ts index 9726393d3..019c88b11 100644 --- a/packages/harness-events/src/index.ts +++ b/packages/harness-events/src/index.ts @@ -1,5 +1,3 @@ -export { splitThreadKey, normalizeThreadKey } from "./thread-key"; - export type JsonValue = | null | boolean diff --git a/packages/harness-events/src/thread-key.ts b/packages/harness-events/src/thread-key.ts deleted file mode 100644 index 2c313028d..000000000 --- a/packages/harness-events/src/thread-key.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function splitThreadKey(threadKey: string): { channel: string; threadTs: string } { - const parts = threadKey.trim().split(":"); - if (parts.length === 2 && parts[0] && parts[1]) { - return { channel: parts[0], threadTs: parts[1] }; - } - if (parts.length === 3 && parts[1] && parts[2]) { - return { channel: parts[1], threadTs: parts[2] }; - } - throw new Error(`Invalid thread key format (expected :): ${threadKey}`); -} - -export function normalizeThreadKey(threadKey: string): string { - const { channel, threadTs } = splitThreadKey(threadKey); - return `${channel}:${threadTs}`; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c19d83b6..2e06abc7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,8 +253,8 @@ importers: specifier: ^7.0.0-dev.20260512.1 version: 7.0.0-dev.20260616.1 emulate: - specifier: ^0.5.0 - version: 0.5.0(hono@4.12.25) + specifier: ^0.9.0 + version: 0.9.0 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -392,12 +392,6 @@ packages: peerDependencies: graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -1044,8 +1038,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - emulate@0.5.0: - resolution: {integrity: sha512-2LrOE8sqa1ITQ1aRR3kZhAhOCNz8hu+Kea9oBKdG/jEK6I/RYlMgHbsvQmbTTtr+nx6+fOOWkL6wqvfLeF5vuA==} + emulate@0.9.0: + resolution: {integrity: sha512-wgOFQSrjAt0NqPDnYUFyCcCwAVw01aNS3M24SZ2C5ViLQW8ECi0qIyUsW+y2uvflBD48JFqomB+jRox+ktKK/w==} hasBin: true encodeurl@2.0.0: @@ -2113,10 +2107,6 @@ snapshots: dependencies: graphql: 17.0.0 - '@hono/node-server@1.19.14(hono@4.12.25)': - dependencies: - hono: 4.12.25 - '@jridgewell/sourcemap-codec@1.5.5': {} '@linear/sdk@76.0.0(graphql@17.0.0)': @@ -2830,14 +2820,11 @@ snapshots: ee-first@1.1.1: {} - emulate@0.5.0(hono@4.12.25): + emulate@0.9.0: dependencies: - '@hono/node-server': 1.19.14(hono@4.12.25) commander: 14.0.3 picocolors: 1.1.1 yaml: 2.9.0 - transitivePeerDependencies: - - hono encodeurl@2.0.0: {} diff --git a/services/api-rs/Cargo.lock b/services/api-rs/Cargo.lock index ea31e6f9f..3c741df3a 100644 --- a/services/api-rs/Cargo.lock +++ b/services/api-rs/Cargo.lock @@ -11,7 +11,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", ] @@ -144,9 +144,9 @@ checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -609,9 +609,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647" dependencies = [ "base64-simd", "bytes", @@ -715,6 +715,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -904,8 +910,9 @@ dependencies = [ "serde_yaml", "sha2 0.11.0", "sqlx", + "standardwebhooks", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "toml", @@ -926,7 +933,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "urlencoding", ] @@ -936,9 +943,11 @@ name = "centaur-iron-proxy" version = "0.1.0" dependencies = [ "serde", + "serde_json", "serde_yaml", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", + "url", ] [[package]] @@ -981,7 +990,7 @@ version = "0.1.0" dependencies = [ "async-trait", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", ] @@ -1020,7 +1029,7 @@ dependencies = [ "centaur-session-sqlx", "centaur-telemetry", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -1048,7 +1057,7 @@ dependencies = [ "serde", "serde_json", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -1071,7 +1080,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-util", @@ -1087,7 +1096,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "uuid", @@ -1105,7 +1114,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "tracing-opentelemetry", @@ -1131,7 +1140,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -1558,7 +1567,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1745,7 +1754,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1803,10 +1812,11 @@ dependencies = [ [[package]] name = "eyre" -version = "0.6.12" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" dependencies = [ + "autocfg", "indenter", "once_cell", ] @@ -1918,9 +1928,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1933,9 +1943,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1943,15 +1953,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1971,38 +1981,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2233,6 +2243,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + [[package]] name = "home" version = "0.5.12" @@ -2703,7 +2719,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2771,7 +2787,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2810,7 +2826,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2853,7 +2869,7 @@ dependencies = [ "serde", "serde-saphyr", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tokio-util", @@ -2877,7 +2893,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3127,7 +3143,7 @@ dependencies = [ "metrics", "metrics-util", "quanta", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3206,7 +3222,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3309,7 +3325,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -3339,7 +3355,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3366,7 +3382,7 @@ dependencies = [ "percent-encoding", "portable-atomic", "rand 0.9.4", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3760,7 +3776,7 @@ dependencies = [ "rustc-hash", "rustls 0.23.43", "socket2 0.6.4", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -3782,7 +3798,7 @@ dependencies = [ "rustls 0.23.43", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -3799,7 +3815,7 @@ dependencies = [ "once_cell", "socket2 0.6.4", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3932,7 +3948,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -4194,7 +4210,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4265,7 +4281,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4789,7 +4805,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", @@ -4874,7 +4890,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "whoami", @@ -4913,7 +4929,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "whoami", @@ -4939,7 +4955,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "url", @@ -4951,6 +4967,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "standardwebhooks" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "747264efdc09640f0a838c03b220810a0b89930cb50e3a30f19379d1a90284ac" +dependencies = [ + "base64 0.21.7", + "hmac-sha256", + "http 1.4.2", + "thiserror 1.0.69", + "time", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -5174,11 +5203,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -5194,9 +5223,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -5569,7 +5598,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1 0.10.6", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5690,9 +5719,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "atomic", "getrandom 0.4.2", @@ -6037,7 +6066,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6123,15 +6152,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.61.2" diff --git a/services/api-rs/Cargo.toml b/services/api-rs/Cargo.toml index c0354323d..2d330b35f 100644 --- a/services/api-rs/Cargo.toml +++ b/services/api-rs/Cargo.toml @@ -87,8 +87,10 @@ metrics-exporter-prometheus = { version = "0.18.3", default-features = false } sqlx = { version = "0.8.6", default-features = false, features = ["derive", "json", "macros", "migrate", "postgres", "runtime-tokio-rustls", "time"] } strum = { version = "0.28", features = ["derive"] } subtle = "2" +standardwebhooks = "1.0.1" test-case = "3.3.1" thiserror = "2" +url = "2" time = { version = "0.3", features = ["serde", "serde-well-known"] } tokio = "1" tokio-util = "0.7" diff --git a/services/api-rs/crates/centaur-api-server/Cargo.toml b/services/api-rs/crates/centaur-api-server/Cargo.toml index 62b07a426..84df7a41d 100644 --- a/services/api-rs/crates/centaur-api-server/Cargo.toml +++ b/services/api-rs/crates/centaur-api-server/Cargo.toml @@ -35,6 +35,7 @@ serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true sha2.workspace = true +standardwebhooks.workspace = true subtle.workspace = true sqlx.workspace = true thiserror.workspace = true diff --git a/services/api-rs/crates/centaur-api-server/src/activity_summary.rs b/services/api-rs/crates/centaur-api-server/src/activity_summary.rs index 6f63dff11..df255cb60 100644 --- a/services/api-rs/crates/centaur-api-server/src/activity_summary.rs +++ b/services/api-rs/crates/centaur-api-server/src/activity_summary.rs @@ -865,7 +865,7 @@ impl ActivitySummaryClient { .timeout(config.timeout) .build() .map_err(ActivitySummaryError::Http)?; - let responses_url = format!("{}/responses", config.base_url.trim_end_matches('/')); + let responses_url = format!("{}/responses", config.base_url); Ok(Self { api_key: config.api_key.clone(), client, diff --git a/services/api-rs/crates/centaur-api-server/src/args.rs b/services/api-rs/crates/centaur-api-server/src/args.rs index 014063013..da160b45a 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -20,7 +20,8 @@ use centaur_iron_control::{ SessionRegistrar, register_role, }; use centaur_iron_proxy::{ - ProxyFragment, SourceKind, SourcePolicy, bedrock_enabled, harness_auth_fragment, infra_fragment, + ProxyFragment, SourceKind, SourcePolicy, bedrock_enabled, custom_provider_auth_fragments, + harness_auth_fragment, infra_fragment, }; use centaur_sandbox_agent_k8s::{ AgentSandboxBackend, AgentSandboxConfig, GitHubTokenRef, IronControlSettings, IronProxyConfig, @@ -115,10 +116,6 @@ impl Args { Duration::from_secs(self.server.shutdown_execution_drain_timeout_secs) } - pub(crate) fn codex_nanocodex_rollout_percent(&self) -> u8 { - self.server.codex_nanocodex_rollout_percent - } - pub(crate) fn execution_adoption_interval(&self) -> Option { (self.server.execution_adoption_interval_secs > 0) .then(|| Duration::from_secs(self.server.execution_adoption_interval_secs)) @@ -148,6 +145,8 @@ struct ActivitySummaryArgs { default_value = "gpt-5.4-nano" )] model: String, + /// Deprecated activity-summary-specific endpoint. `OPENAI_BASE_URL` takes + /// precedence when set, but this remains supported for existing deployments. #[arg( long = "session-activity-summary-openai-base-url", env = "SESSION_ACTIVITY_SUMMARY_OPENAI_BASE_URL", @@ -196,8 +195,11 @@ impl ActivitySummaryArgs { ); return None; }; + let base_url = clean_optional_value(env::var("OPENAI_BASE_URL").ok().as_deref()) + .map(|value| value.trim_end_matches('/').to_owned()) + .unwrap_or_else(|| self.openai_base_url.trim_end_matches('/').to_owned()); Some(ActivitySummaryConfig { - base_url: self.openai_base_url.clone(), + base_url, api_key, max_facts: usize::try_from(self.max_facts).unwrap_or(usize::MAX), max_output_tokens: u16::try_from(self.max_output_tokens).unwrap_or(u16::MAX), @@ -469,16 +471,6 @@ pub(crate) struct ServerArgs { pub(crate) bind_addr: SocketAddr, #[arg(long, env = "RUN_MIGRATIONS", default_value_t = false)] pub(crate) run_migrations: bool, - /// Percentage of sessions requesting Codex that are assigned to - /// Nanocodex. The assignment is deterministic by thread key and the - /// resolved harness is persisted on the session. - #[arg( - long = "session-codex-nanocodex-rollout-percent", - env = "SESSION_CODEX_NANOCODEX_ROLLOUT_PERCENT", - default_value_t = 0, - value_parser = clap::value_parser!(u8).range(0..=100) - )] - codex_nanocodex_rollout_percent: u8, /// How long shutdown waits for in-flight executions to finish before /// releasing their stdout-owner leases for adoption by a peer. Keep /// below the pod's terminationGracePeriodSeconds (35s in the chart) so @@ -1012,6 +1004,12 @@ impl SandboxArgs { let codex_auth_mode = clean_optional_value(env::var("CODEX_AUTH_MODE").ok().as_deref()) .unwrap_or_else(|| "api_key".to_owned()); envs.push(("CODEX_AUTH_MODE".to_owned(), codex_auth_mode.clone())); + if codex_auth_mode == "api_key" + && let Some(base_url) = + clean_optional_value(env::var("OPENAI_BASE_URL").ok().as_deref()) + { + envs.push(("OPENAI_BASE_URL".to_owned(), base_url)); + } if let Some(mode) = clean_optional_value(env::var("CLAUDE_CODE_AUTH_MODE").ok().as_deref()) { envs.push(("CLAUDE_CODE_AUTH_MODE".to_owned(), mode)); @@ -1020,8 +1018,8 @@ impl SandboxArgs { // Inject the infra/harness placeholder credentials so env-based // consumers send the proxy_value iron-proxy replaces with the real // secret: codex's OPENAI_API_KEY (api_key mode -> codex logs in and - // hits api.openai.com instead of falling back to the ChatGPT - // auth.json), git/gh's GITHUB_TOKEN, the slack tool's + // hits OPENAI_BASE_URL (api.openai.com by default) instead of falling + // back to the ChatGPT auth.json), git/gh's GITHUB_TOKEN, the slack tool's // SLACK_BOT_TOKEN, and the rest of the infra set. for (name, value) in self.iron_proxy.sandbox_placeholder_env()? { if !envs.iter().any(|(existing, _)| existing == &name) { @@ -1967,6 +1965,9 @@ impl IronProxyHarnessArgs { if let Some(fragment) = harness_auth_fragment("meta-ai", "api_key")? { fragments.push(fragment); } + if let Ok(raw) = env::var("CODEX_CUSTOM_PROVIDERS") { + fragments.extend(custom_provider_auth_fragments(&raw)?); + } // Bedrock is opt-in (not the default codex provider): only register its // SigV4 re-signing fragment when the operator has set CODEX_BEDROCK_REGION, // since the fragment expects AWS keys in the secrets backend. @@ -2193,6 +2194,52 @@ mod tests { assert_eq!(config.api_key, "sk-test"); } + #[test] + fn activity_summary_preserves_legacy_base_url_when_global_url_is_unset() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("OPENAI_API_KEY", "sk-test"), + ("OPENAI_BASE_URL", ""), + ( + "SESSION_ACTIVITY_SUMMARY_OPENAI_BASE_URL", + "https://legacy-compatible.example/v1/", + ), + ]); + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + "--session-activity-summary-enabled", + "true", + ]) + .unwrap(); + + let config = args.activity_summary_config().unwrap(); + assert_eq!(config.base_url, "https://legacy-compatible.example/v1"); + } + + #[test] + fn activity_summary_global_base_url_overrides_legacy_base_url() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[ + ("OPENAI_API_KEY", "sk-test"), + ("OPENAI_BASE_URL", "https://global-compatible.example/v1/"), + ]); + let args = Args::try_parse_from([ + "centaur-api-server", + "--database-url", + "postgres://postgres:postgres@localhost/centaur", + "--session-activity-summary-enabled", + "true", + "--session-activity-summary-openai-base-url", + "https://legacy-compatible.example/v1", + ]) + .unwrap(); + + let config = args.activity_summary_config().unwrap(); + assert_eq!(config.base_url, "https://global-compatible.example/v1"); + } + #[test] fn activity_summary_uses_mounted_openai_key_even_with_onepassword_connect_source() { let _lock = ENV_LOCK.lock().unwrap(); @@ -2247,33 +2294,6 @@ mod tests { assert_eq!(args.sandbox.k8s_context.as_deref(), Some("kind-test")); } - #[test] - fn parses_codex_nanocodex_rollout_percent() { - let args = Args::try_parse_from([ - "centaur-api-server", - "--database-url", - "postgres://postgres:postgres@localhost/centaur", - "--session-codex-nanocodex-rollout-percent", - "50", - ]) - .unwrap(); - - assert_eq!(args.codex_nanocodex_rollout_percent(), 50); - } - - #[test] - fn rejects_invalid_codex_nanocodex_rollout_percent() { - let result = Args::try_parse_from([ - "centaur-api-server", - "--database-url", - "postgres://postgres:postgres@localhost/centaur", - "--session-codex-nanocodex-rollout-percent", - "101", - ]); - - assert!(result.is_err()); - } - #[test] fn execution_adoption_rescans_every_fifteen_seconds_by_default() { let args = Args::try_parse_from([ @@ -2652,6 +2672,8 @@ mod tests { #[test] fn codex_app_server_env_template_injects_auth_mode_and_placeholder() { + let _lock = ENV_LOCK.lock().unwrap(); + let _env = EnvGuard::set(&[("OPENAI_BASE_URL", "https://compatible-api.example/v1")]); let args = Args::try_parse_from([ "centaur-api-server", "--database-url", @@ -2675,6 +2697,9 @@ mod tests { // The codex auth mode is propagated so the sandbox agent matches the // proxy's registered credential. assert!(env.iter().any(|(name, _)| name == "CODEX_AUTH_MODE")); + assert!(env.iter().any(|(name, value)| { + name == "OPENAI_BASE_URL" && value == "https://compatible-api.example/v1" + })); // api_key mode (the default) injects the placeholder the egress proxy // replaces, so codex logs in and hits api.openai.com instead of // falling back to the ChatGPT auth.json. diff --git a/services/api-rs/crates/centaur-api-server/src/auth.rs b/services/api-rs/crates/centaur-api-server/src/auth.rs index f1fb4b899..ab3b09a8e 100644 --- a/services/api-rs/crates/centaur-api-server/src/auth.rs +++ b/services/api-rs/crates/centaur-api-server/src/auth.rs @@ -65,7 +65,7 @@ pub(crate) struct AuthenticatedCaller { class: CallerClass, identity: String, capabilities: BTreeSet, - platform_prefix: Option<&'static str>, + platform_prefixes: Option<&'static [&'static str]>, principal_subject: Option, } @@ -82,8 +82,8 @@ impl AuthenticatedCaller { &self.identity } - pub(crate) const fn platform_prefix(&self) -> Option<&'static str> { - self.platform_prefix + pub(crate) const fn platform_prefixes(&self) -> Option<&'static [&'static str]> { + self.platform_prefixes } pub(crate) fn principal_subject(&self) -> Option<&str> { @@ -123,38 +123,7 @@ impl ApiAuthConfig { None, )); } - for spec in [ - IngressSpec { - env_var: "SLACKBOT_API_KEY", - identity: "slackbot", - platform_prefix: "slack:", - workflow_events: true, - }, - IngressSpec { - env_var: "DISCORDBOT_API_KEY", - identity: "discordbot", - platform_prefix: "discord:", - workflow_events: false, - }, - IngressSpec { - env_var: "GITHUBBOT_API_KEY", - identity: "githubbot", - platform_prefix: "github:", - workflow_events: true, - }, - IngressSpec { - env_var: "LINEARBOT_API_KEY", - identity: "linearbot", - platform_prefix: "linear:", - workflow_events: false, - }, - IngressSpec { - env_var: "TEAMSBOT_API_KEY", - identity: "teamsbot", - platform_prefix: "teams:", - workflow_events: false, - }, - ] { + for spec in INGRESS_SPECS { let Some(token) = optional_env(spec.env_var) else { continue; }; @@ -167,7 +136,7 @@ impl ApiAuthConfig { CallerClass::Ingress, token, capabilities, - Some(spec.platform_prefix), + Some(spec.platform_prefixes), )); } @@ -203,7 +172,7 @@ impl ApiAuthConfig { Capability::SessionsWrite, Capability::WorkflowsEvents, ], - Some("slack:"), + Some(&["slack:"]), )]; Self { static_callers: Arc::new(callers), @@ -246,7 +215,7 @@ impl ApiAuthConfig { class: CallerClass::Console, identity: subject, capabilities: Capability::ALL.into_iter().collect(), - platform_prefix: None, + platform_prefixes: None, principal_subject: None, }) } @@ -278,7 +247,7 @@ impl ApiAuthConfig { class: CallerClass::Principal, identity: subject.clone(), capabilities, - platform_prefix: None, + platform_prefixes: None, principal_subject: Some(subject), }) } @@ -317,10 +286,53 @@ enum ApiJwtTokenUse { ConsoleService, } +/// Every ingress key and the session thread-key families it is allowed to +/// touch. The platform scoping in `authorize_api_request` denies any +/// `/api/session/*` call outside them, so a family missing here locks a bot +/// out of its own sessions. +const INGRESS_SPECS: &[IngressSpec] = &[ + IngressSpec { + env_var: "SLACKBOT_API_KEY", + identity: "slackbot", + platform_prefixes: &["slack:"], + workflow_events: true, + }, + IngressSpec { + env_var: "DISCORDBOT_API_KEY", + identity: "discordbot", + platform_prefixes: &["discord:"], + workflow_events: false, + }, + IngressSpec { + env_var: "GITHUBBOT_API_KEY", + identity: "githubbot", + platform_prefixes: &[ + "github:", + "github-issue:", + "github-manage:", + "github-review:", + ], + workflow_events: true, + }, + IngressSpec { + env_var: "LINEARBOT_API_KEY", + identity: "linearbot", + platform_prefixes: &["linear:"], + workflow_events: false, + }, + IngressSpec { + env_var: "TEAMSBOT_API_KEY", + identity: "teamsbot", + platform_prefixes: &["teams:"], + workflow_events: false, + }, +]; + struct IngressSpec { env_var: &'static str, identity: &'static str, - platform_prefix: &'static str, + /// Every session thread-key prefix this ingress mints. + platform_prefixes: &'static [&'static str], workflow_events: bool, } @@ -329,7 +341,7 @@ fn static_caller( class: CallerClass, token: String, capabilities: impl IntoIterator, - platform_prefix: Option<&'static str>, + platform_prefixes: Option<&'static [&'static str]>, ) -> StaticCaller { StaticCaller { token_digest: Sha256::digest(token.as_bytes()).into(), @@ -337,7 +349,7 @@ fn static_caller( class, identity: identity.to_owned(), capabilities: capabilities.into_iter().collect(), - platform_prefix, + platform_prefixes, principal_subject: None, }, } @@ -428,7 +440,7 @@ mod tests { .into_iter() .all(|capability| caller.has_capability(capability)) ); - assert_eq!(caller.platform_prefix(), None); + assert_eq!(caller.platform_prefixes(), None); assert_eq!(caller.principal_subject(), None); } @@ -519,6 +531,40 @@ mod tests { assert!(caller.has_capability(Capability::WorkflowsWrite)); } + #[test] + fn githubbot_ingress_covers_every_thread_key_family_it_mints() { + // Mirrors services/githubbot/src: chat threads (body-mention.ts), issue + // work (issue-manager.ts), owned-PR management (pr-manager.ts) and review + // runs (review.ts). githubbot/test/thread-keys.test.ts pins the producer + // side; dropping a family here 403s the bot out of its own sessions. + let githubbot = INGRESS_SPECS + .iter() + .find(|spec| spec.identity == "githubbot") + .expect("githubbot ingress spec"); + + assert_eq!( + githubbot.platform_prefixes, + [ + "github:", + "github-issue:", + "github-manage:", + "github-review:" + ] + .as_slice() + ); + } + + #[test] + fn every_ingress_scopes_itself_to_at_least_one_prefix() { + for spec in INGRESS_SPECS { + assert!( + !spec.platform_prefixes.is_empty(), + "{} would be scoped to no session at all", + spec.identity + ); + } + } + #[test] fn duplicate_tokens_are_rejected() { let callers = vec![ @@ -527,14 +573,14 @@ mod tests { CallerClass::Ingress, "same".to_owned(), [Capability::SessionsWrite], - Some("slack:"), + Some(&["slack:"]), ), static_caller( "githubbot", CallerClass::Ingress, "same".to_owned(), [Capability::SessionsWrite], - Some("github:"), + Some(&["github:"]), ), ]; diff --git a/services/api-rs/crates/centaur-api-server/src/error.rs b/services/api-rs/crates/centaur-api-server/src/error.rs index 5bc5af127..beb43ba95 100644 --- a/services/api-rs/crates/centaur-api-server/src/error.rs +++ b/services/api-rs/crates/centaur-api-server/src/error.rs @@ -67,6 +67,9 @@ impl IntoResponse for ApiError { Self::Runtime(SessionRuntimeError::Store(SessionStoreError::PersonaConflict { .. })) => StatusCode::CONFLICT, + Self::Runtime(SessionRuntimeError::Store(SessionStoreError::PrincipalConflict { + .. + })) => StatusCode::CONFLICT, Self::Runtime(SessionRuntimeError::IronControl( centaur_iron_control::IronControlError::PrincipalDerivation(_), )) => StatusCode::BAD_REQUEST, @@ -109,6 +112,16 @@ impl IntoResponse for ApiError { body["existing_harness"] = json!(existing); body["requested_harness"] = json!(requested); } + if let Self::Runtime(SessionRuntimeError::Store(SessionStoreError::PrincipalConflict { + existing, + requested, + .. + })) = &self + { + body["code"] = json!("principal_conflict"); + body["existing_principal"] = json!(existing); + body["requested_principal"] = json!(requested); + } let mut response = (status, Json(body)).into_response(); if status == StatusCode::UNAUTHORIZED { response @@ -138,6 +151,7 @@ pub(crate) fn error_chain(error: &dyn std::error::Error) -> String { #[cfg(test)] mod tests { use super::*; + use axum::body::to_bytes; use centaur_iron_control::{IronControlError, PrincipalDerivationError}; #[test] @@ -149,4 +163,25 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + + #[tokio::test] + async fn principal_conflicts_include_structured_details() { + let response = ApiError::Runtime(SessionRuntimeError::Store( + SessionStoreError::PrincipalConflict { + thread_key: "workflow:report".to_owned(), + existing: "prn_finance".to_owned(), + requested: "prn_support".to_owned(), + }, + )) + .into_response(); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read response body"); + let body: serde_json::Value = serde_json::from_slice(&body).expect("decode response body"); + assert_eq!(body["code"], json!("principal_conflict")); + assert_eq!(body["existing_principal"], json!("prn_finance")); + assert_eq!(body["requested_principal"], json!("prn_support")); + } } diff --git a/services/api-rs/crates/centaur-api-server/src/main.rs b/services/api-rs/crates/centaur-api-server/src/main.rs index 74b9cbb94..f567778c0 100644 --- a/services/api-rs/crates/centaur-api-server/src/main.rs +++ b/services/api-rs/crates/centaur-api-server/src/main.rs @@ -26,8 +26,7 @@ async fn main() -> Result<(), ServerError> { "starting centaur api-rs server" ); - let app_state = AppState::unready(api_auth) - .with_codex_nanocodex_rollout_percent(args.codex_nanocodex_rollout_percent()); + let app_state = AppState::unready(api_auth); let app = build_router_with_app_state(app_state.clone()); let shutdown_state = app_state.clone(); let drain_timeout = args.shutdown_execution_drain_timeout(); diff --git a/services/api-rs/crates/centaur-api-server/src/mcp.rs b/services/api-rs/crates/centaur-api-server/src/mcp.rs index aaa92d51e..691c8947c 100644 --- a/services/api-rs/crates/centaur-api-server/src/mcp.rs +++ b/services/api-rs/crates/centaur-api-server/src/mcp.rs @@ -13,7 +13,7 @@ use axum::{ response::{IntoResponse, Response}, }; use base64::{Engine as _, engine::general_purpose}; -use centaur_session_runtime::{SessionRuntime, ToolHostCallInput}; +use centaur_session_runtime::{SessionRuntime, ToolHostCallInput, ToolHostCallOutput}; use hmac::{Hmac, KeyInit, Mac}; use serde::Deserialize; use serde_json::{Value, json}; @@ -433,8 +433,10 @@ async fn run_tool_host_centaur_tool( if output.timed_out { return Ok(mcp_text_result( format!( - "centaur tool {}.{method} timed out in sandbox {}: {}", - tool.name, output.sandbox_id, output.stderr + "centaur tool {}.{method} timed out in {}: {}", + tool.name, + tool_host_error_context(&output), + output.stderr ), true, )); @@ -448,8 +450,11 @@ async fn run_tool_host_centaur_tool( let detail = mcp_tool_failure_detail(raw); return Ok(mcp_text_result( format!( - "centaur tool {}.{method} failed in sandbox {} with status {:?}: {detail}\n\nCall the {} tool with method \"help\" to list available methods and their signatures.", - tool.name, output.sandbox_id, output.exit_status, tool.name + "centaur tool {}.{method} failed in {} with status {:?}: {detail}\n\nCall the {} tool with method \"help\" to list available methods and their signatures.", + tool.name, + tool_host_error_context(&output), + output.exit_status, + tool.name ), true, )); @@ -465,14 +470,37 @@ async fn run_tool_host_centaur_tool( )), Err(error) => Ok(mcp_text_result( format!( - "centaur tool {}.{method} returned non-json output in sandbox {}: {error}: {stdout}", - tool.name, output.sandbox_id + "centaur tool {}.{method} returned non-json output in {}: {error}: {stdout}", + tool.name, + tool_host_error_context(&output) ), true, )), } } +fn tool_host_error_context(output: &ToolHostCallOutput) -> String { + let mut parts = Vec::new(); + let sandbox_id = output.sandbox_id.trim(); + parts.push(if sandbox_id.is_empty() { + "sandbox unknown".to_owned() + } else { + format!("sandbox {sandbox_id}") + }); + + let execution_id = output.execution_id.trim(); + if !execution_id.is_empty() { + parts.push(format!("execution {execution_id}")); + } + + let request_id = output.request_id.trim(); + if !request_id.is_empty() { + parts.push(format!("request {request_id}")); + } + + parts.join(", ") +} + /// Reduce a Python traceback to its final exception message: agents act on /// the error line, not on stack frames or build noise, so keep everything /// from the last traceback's exception message to the end. @@ -994,6 +1022,42 @@ RuntimeError: X API error: 401 - { assert_eq!(mcp_tool_failure_detail(plain), plain); } + #[test] + fn mcp_tool_host_error_context_includes_correlation_ids() { + let output = ToolHostCallOutput { + request_id: "mcp-call-123".to_owned(), + execution_id: "exe-456".to_owned(), + sandbox_id: "sbx-789".to_owned(), + stdout: String::new(), + stderr: "boom".to_owned(), + exit_status: Some(1), + timed_out: false, + }; + + assert_eq!( + tool_host_error_context(&output), + "sandbox sbx-789, execution exe-456, request mcp-call-123" + ); + } + + #[test] + fn mcp_tool_host_error_context_handles_missing_sandbox_id() { + let output = ToolHostCallOutput { + request_id: "mcp-call-123".to_owned(), + execution_id: "exe-456".to_owned(), + sandbox_id: String::new(), + stdout: String::new(), + stderr: "boom".to_owned(), + exit_status: None, + timed_out: true, + }; + + assert_eq!( + tool_host_error_context(&output), + "sandbox unknown, execution exe-456, request mcp-call-123" + ); + } + #[tokio::test] async fn mcp_unknown_method_returns_available_methods_without_running_tool() { let temp = temp_dir("centaur-api-rs-mcp-unknown-method"); diff --git a/services/api-rs/crates/centaur-api-server/src/routes.rs b/services/api-rs/crates/centaur-api-server/src/routes.rs index 80e91314e..27555bcf3 100644 --- a/services/api-rs/crates/centaur-api-server/src/routes.rs +++ b/services/api-rs/crates/centaur-api-server/src/routes.rs @@ -27,7 +27,7 @@ use axum::{ routing::{any, get, post}, }; use base64::{Engine as _, engine::general_purpose}; -use centaur_session_core::{ChatDestination, HarnessType, ThreadKey}; +use centaur_session_core::{ChatDestination, ThreadKey}; use centaur_session_runtime::{ ExecuteSessionInput, HarnessConflictPolicy, SandboxRuntime, SessionPrincipalRegistrar, SessionRuntime, thread_trace_id, thread_trace_parent_span_id, @@ -61,10 +61,10 @@ use crate::{ types::{ AppendMessagesRequest, AppendMessagesResponse, CreateSessionRequest, CreateSessionResponse, DiscordThreadContext, EmitWorkflowEventRequest, EventsQuery, ExecuteSessionRequest, - ExecuteSessionResponse, GithubThreadContext, HarnessAssignment, - InterruptSessionExecutionRequest, InterruptSessionExecutionResponse, LinearThreadContext, - ListWorkflowRunsQuery, OnHarnessConflict, SessionContextResponse, SessionSseEvent, - SlackThreadContext, stream_error_sse, + ExecuteSessionResponse, GithubThreadContext, InterruptSessionExecutionRequest, + InterruptSessionExecutionResponse, LinearThreadContext, ListWorkflowRunsQuery, + OnHarnessConflict, SessionContextResponse, SessionSseEvent, SlackThreadContext, + stream_error_sse, }, }; @@ -72,7 +72,6 @@ use crate::{ pub struct AppState { initialized: Arc>>, metrics: PrometheusHandle, - codex_nanocodex_rollout_percent: u8, auth: ApiAuthConfig, } @@ -89,16 +88,10 @@ impl AppState { Self { initialized: Arc::new(RwLock::new(None)), metrics: prometheus_handle().expect("failed to initialize Prometheus metrics recorder"), - codex_nanocodex_rollout_percent: 0, auth, } } - pub fn with_codex_nanocodex_rollout_percent(mut self, percent: u8) -> Self { - self.codex_nanocodex_rollout_percent = percent; - self - } - pub fn ready( runtime: SessionRuntime, workflows: Option, @@ -215,6 +208,7 @@ const REDACTED_WEBHOOK_HEADERS: &[&str] = &[ "x-hub-signature", "x-hub-signature-256", "x-slack-signature", + "webhook-signature", "stripe-signature", ]; @@ -512,10 +506,10 @@ async fn authorize_api_request( .into_response(); } - if let Some(prefix) = caller.platform_prefix() + if let Some(prefixes) = caller.platform_prefixes() && route.starts_with("/api/session/") && let Some(thread_key) = session_thread_key_from_request(&request) - && !thread_key.as_str().starts_with(prefix) + && !thread_key_matches_platform(prefixes, thread_key.as_str()) { record_api_authentication(caller.class().as_str(), "forbidden"); tracing::warn!( @@ -525,7 +519,7 @@ async fn authorize_api_request( } else { caller.identity() }, - expected_thread_prefix = prefix, + expected_thread_prefixes = ?prefixes, "ingress caller denied for another platform's session" ); return ApiError::Forbidden("caller is not authorized for this session".to_owned()) @@ -609,6 +603,13 @@ fn session_thread_key_from_request(request: &Request) -> Option session_thread_key_from_path(request.uri().path()) } +/// Whether an ingress caller scoped to `prefixes` may touch this session. +/// A bot can mint several thread-key families (githubbot: `github:`, +/// `github-manage:`, `github-review:`), so the caller carries them all. +fn thread_key_matches_platform(prefixes: &[&str], thread_key: &str) -> bool { + prefixes.iter().any(|prefix| thread_key.starts_with(prefix)) +} + fn session_thread_key_from_path(path: &str) -> Option { let rest = path.strip_prefix("/api/session/")?; let raw_thread_key = rest.split('/').next()?; @@ -625,45 +626,8 @@ async fn create_or_get_session( Json(request): Json, ) -> Result, ApiError> { let thread_key = ThreadKey::try_from(raw_thread_key)?; - let requested_harness = request.harness_type; + let harness_type = request.harness_type; let runtime = state.runtime()?; - let existing_rollout_harness = if requested_harness == HarnessType::Codex { - runtime - .existing_session_harness(&thread_key) - .await? - .filter(|harness| matches!(harness, HarnessType::Codex | HarnessType::Nanocodex)) - } else { - None - }; - let harness_type = existing_rollout_harness.clone().unwrap_or_else(|| { - rollout_harness_for_thread( - &thread_key, - &requested_harness, - state.codex_nanocodex_rollout_percent, - ) - }); - let harness_assignment = codex_nanocodex_assignment( - &requested_harness, - &harness_type, - state.codex_nanocodex_rollout_percent, - ); - tracing::info!( - component = "api_server", - event = "session_harness_rollout_resolved", - thread_key = %thread_key, - requested_harness = %requested_harness, - resolved_harness = %harness_type, - ab_test = harness_assignment.is_some(), - ab_test_experiment = harness_assignment - .as_ref() - .map_or("", |assignment| assignment.experiment), - ab_test_cohort = harness_assignment - .as_ref() - .map_or("", |assignment| assignment.cohort.as_ref()), - existing_rollout_harness_preserved = existing_rollout_harness.is_some(), - codex_nanocodex_rollout_percent = state.codex_nanocodex_rollout_percent, - "resolved requested session harness" - ); let on_harness_conflict = match request.on_harness_conflict { Some(OnHarnessConflict::Restart) => HarnessConflictPolicy::Restart, Some(OnHarnessConflict::Reject) | None => HarnessConflictPolicy::Reject, @@ -673,165 +637,16 @@ async fn create_or_get_session( &thread_key, &harness_type, request.persona_id.as_deref(), - session_metadata_with_harness_assignment(request.metadata, harness_assignment.as_ref()), + request.metadata, on_harness_conflict, ) .await?; Ok(Json(CreateSessionResponse { session: outcome.session, harness_switched: outcome.harness_switched, - harness_assignment, })) } -const CODEX_NANOCODEX_AB_EXPERIMENT: &str = "codex_nanocodex_ab"; - -fn codex_nanocodex_assignment( - requested_harness: &HarnessType, - cohort: &HarnessType, - rollout_percent: u8, -) -> Option { - (*requested_harness == HarnessType::Codex && (1..100).contains(&rollout_percent)).then(|| { - HarnessAssignment { - experiment: CODEX_NANOCODEX_AB_EXPERIMENT, - requested_harness: requested_harness.clone(), - cohort: cohort.clone(), - rollout_percent, - } - }) -} - -fn session_metadata_with_harness_assignment( - metadata: Option, - assignment: Option<&HarnessAssignment>, -) -> Option { - let Some(assignment) = assignment else { - return metadata; - }; - let mut metadata = metadata.unwrap_or_else(|| json!({})); - if let Value::Object(object) = &mut metadata { - object.insert( - "harness_assignment".to_owned(), - json!({ - "experiment": assignment.experiment, - "requested_harness": assignment.requested_harness, - "cohort": assignment.cohort, - "rollout_percent": assignment.rollout_percent, - }), - ); - } - Some(metadata) -} - -fn rollout_harness_for_thread( - thread_key: &ThreadKey, - requested_harness: &HarnessType, - nanocodex_percent: u8, -) -> HarnessType { - if *requested_harness != HarnessType::Codex || nanocodex_percent == 0 { - return requested_harness.clone(); - } - if nanocodex_percent >= 100 { - return HarnessType::Nanocodex; - } - - let digest = Sha256::digest(thread_key.as_str().as_bytes()); - let bucket = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]); - let threshold = (u64::from(nanocodex_percent) * (u64::from(u32::MAX) + 1)) / 100; - if u64::from(bucket) < threshold { - HarnessType::Nanocodex - } else { - HarnessType::Codex - } -} - -#[cfg(test)] -mod harness_rollout_tests { - use super::*; - - #[test] - fn codex_rollout_is_sticky_and_split_by_thread_key() { - let codex_thread = ThreadKey::try_from("slack:C1:1700000000.000100".to_owned()).unwrap(); - let nanocodex_thread = - ThreadKey::try_from("slack:C1:1700000000.000104".to_owned()).unwrap(); - - assert_eq!( - rollout_harness_for_thread(&codex_thread, &HarnessType::Codex, 50), - HarnessType::Codex - ); - assert_eq!( - rollout_harness_for_thread(&nanocodex_thread, &HarnessType::Codex, 50), - HarnessType::Nanocodex - ); - assert_eq!( - rollout_harness_for_thread(&nanocodex_thread, &HarnessType::Codex, 50), - HarnessType::Nanocodex - ); - } - - #[test] - fn codex_rollout_honors_boundaries_and_other_harnesses() { - let thread_key = ThreadKey::try_from("cli:rollout-boundaries".to_owned()).unwrap(); - - assert_eq!( - rollout_harness_for_thread(&thread_key, &HarnessType::Codex, 0), - HarnessType::Codex - ); - assert_eq!( - rollout_harness_for_thread(&thread_key, &HarnessType::Codex, 100), - HarnessType::Nanocodex - ); - assert_eq!( - rollout_harness_for_thread(&thread_key, &HarnessType::ClaudeCode, 50), - HarnessType::ClaudeCode - ); - assert_eq!( - rollout_harness_for_thread(&thread_key, &HarnessType::Nanocodex, 50), - HarnessType::Nanocodex - ); - } - - #[test] - fn codex_rollout_is_balanced_across_many_thread_keys() { - let nanocodex = (0..10_000) - .filter(|index| { - let thread_key = ThreadKey::try_from(format!("cli:rollout-{index}")).unwrap(); - rollout_harness_for_thread(&thread_key, &HarnessType::Codex, 50) - == HarnessType::Nanocodex - }) - .count(); - - assert!( - (4_900..=5_100).contains(&nanocodex), - "nanocodex={nanocodex}" - ); - } - - #[test] - fn codex_rollout_assignment_is_explicit_and_persistable() { - let assignment = - codex_nanocodex_assignment(&HarnessType::Codex, &HarnessType::Nanocodex, 50).unwrap(); - let metadata = session_metadata_with_harness_assignment( - Some(json!({"source": "slackbotv2"})), - Some(&assignment), - ) - .unwrap(); - - assert_eq!(assignment.experiment, CODEX_NANOCODEX_AB_EXPERIMENT); - assert_eq!(assignment.cohort, HarnessType::Nanocodex); - assert_eq!( - metadata.pointer("/harness_assignment/cohort"), - Some(&json!("nanocodex")) - ); - assert_eq!(metadata.get("source"), Some(&json!("slackbotv2"))); - assert!(codex_nanocodex_assignment(&HarnessType::Codex, &HarnessType::Codex, 0).is_none()); - assert!( - codex_nanocodex_assignment(&HarnessType::Nanocodex, &HarnessType::Nanocodex, 50) - .is_none() - ); - } -} - async fn get_session_context( State(state): State, Extension(caller): Extension, @@ -1072,7 +887,36 @@ fn principal_subject_owns_session(subject: Option<&str>, session_principal: Opti #[cfg(test)] mod session_authorization_tests { - use super::principal_subject_owns_session; + use super::{principal_subject_owns_session, thread_key_matches_platform}; + + #[test] + fn ingress_scope_covers_every_family_the_bot_mints() { + let github = [ + "github:", + "github-issue:", + "github-manage:", + "github-review:", + ]; + assert!(thread_key_matches_platform(&github, "github:acme/repo:12")); + assert!(thread_key_matches_platform( + &github, + "github-issue:acme/repo:12" + )); + assert!(thread_key_matches_platform( + &github, + "github-manage:acme/repo:12" + )); + assert!(thread_key_matches_platform( + &github, + "github-review:acme/repo:12" + )); + assert!(!thread_key_matches_platform(&github, "slack:C123:1.2")); + // `github-anything:` outside the listed families stays denied. + assert!(!thread_key_matches_platform( + &github, + "githubx:acme/repo:12" + )); + } #[test] fn principal_session_reads_require_exact_persisted_owner() { @@ -3828,6 +3672,9 @@ fn verify_webhook_auth( headers, raw_body, ), + WorkflowWebhookAuth::StandardWebhooks { secret_ref } => { + verify_standard_webhook_signature(secret_ref, headers, raw_body) + } WorkflowWebhookAuth::Hmac { secret_ref, signature_header, @@ -3845,6 +3692,33 @@ fn verify_webhook_auth( } } +fn verify_standard_webhook_signature( + secret_ref: &str, + headers: &HeaderMap, + raw_body: &[u8], +) -> Result<(), ApiError> { + let secret = env::var(secret_ref).map_err(|_| { + ApiError::Internal(format!( + "webhook auth secret {secret_ref} is not configured" + )) + })?; + let secret = secret.trim(); + let encoded_secret = secret.strip_prefix("whsec_").unwrap_or(secret); + if encoded_secret.is_empty() { + return Err(ApiError::Internal(format!( + "webhook auth secret {secret_ref} is not valid Standard Webhooks key material" + ))); + } + let webhook = standardwebhooks::Webhook::new(secret).map_err(|_| { + ApiError::Internal(format!( + "webhook auth secret {secret_ref} is not valid Standard Webhooks key material" + )) + })?; + webhook + .verify(raw_body, headers) + .map_err(|_| ApiError::Unauthorized("invalid webhook signature".to_owned())) +} + fn verify_hmac_signature( signature_header: &str, signature_prefix: &str, @@ -3896,6 +3770,7 @@ fn signature_header_name(auth: &WorkflowWebhookAuth) -> Option<&str> { match auth { WorkflowWebhookAuth::None | WorkflowWebhookAuth::Bearer { .. } => None, WorkflowWebhookAuth::Github { .. } => Some("X-Hub-Signature-256"), + WorkflowWebhookAuth::StandardWebhooks { .. } => Some("webhook-signature"), WorkflowWebhookAuth::Hmac { signature_header, .. } => Some(signature_header), @@ -4261,6 +4136,35 @@ mod webhook_tests { assert_eq!(safe, json!({"x-test-delivery": "delivery-1"})); } + #[test] + fn redacts_standard_webhooks_signature_header() { + let mut headers = HeaderMap::new(); + headers.insert("webhook-signature", "v1,c2lnbmF0dXJl".parse().unwrap()); + headers.insert("webhook-id", "msg_test".parse().unwrap()); + headers.insert("webhook-timestamp", "1700000000".parse().unwrap()); + let spec = WorkflowWebhookSpec { + slug: "unit".to_owned(), + provider: None, + auth: WorkflowWebhookAuth::StandardWebhooks { + secret_ref: "TEST_WEBHOOK_SECRET".to_owned(), + }, + trigger_key: None, + allowed_methods: vec!["POST".to_owned()], + allowed_content_types: vec!["application/json".to_owned()], + filter: None, + }; + + let safe = safe_webhook_headers(&headers, &spec); + + assert_eq!( + safe, + json!({ + "webhook-id": "msg_test", + "webhook-timestamp": "1700000000" + }) + ); + } + #[test] fn derives_header_trigger_key() { let mut headers = HeaderMap::new(); @@ -4305,6 +4209,97 @@ mod webhook_tests { .unwrap(); } + fn standard_webhook_headers( + secret: &str, + message_id: &str, + timestamp: i64, + raw_body: &[u8], + ) -> HeaderMap { + let encoded_secret = secret.strip_prefix("whsec_").unwrap_or(secret); + let key = general_purpose::STANDARD.decode(encoded_secret).unwrap(); + let mut signed_content = Vec::new(); + signed_content.extend_from_slice(message_id.as_bytes()); + signed_content.push(b'.'); + signed_content.extend_from_slice(timestamp.to_string().as_bytes()); + signed_content.push(b'.'); + signed_content.extend_from_slice(raw_body); + let mut mac = Hmac::::new_from_slice(&key).unwrap(); + mac.update(&signed_content); + let signature = general_purpose::STANDARD.encode(mac.finalize().into_bytes()); + + let mut headers = HeaderMap::new(); + headers.insert("webhook-id", message_id.parse().unwrap()); + headers.insert("webhook-timestamp", timestamp.to_string().parse().unwrap()); + headers.insert( + "webhook-signature", + format!("v2,ignored v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= v1,{signature}") + .parse() + .unwrap(), + ); + headers + } + + #[test] + fn verifies_standard_webhooks_signature() { + let raw_body = br#"{"hello":"signed"}"#; + let secret_ref = "CENTRAUR_TEST_STANDARD_WEBHOOK_SECRET"; + let secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + let timestamp = OffsetDateTime::now_utc().unix_timestamp(); + unsafe { + env::set_var(secret_ref, secret); + } + let headers = standard_webhook_headers(secret, "msg_test", timestamp, raw_body); + + verify_standard_webhook_signature(secret_ref, &headers, raw_body).unwrap(); + } + + #[test] + fn rejects_invalid_or_stale_standard_webhooks_signature() { + let raw_body = br#"{"hello":"signed"}"#; + let secret_ref = "CENTRAUR_TEST_STANDARD_WEBHOOK_SECRET_REJECT"; + let secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"; + unsafe { + env::set_var(secret_ref, secret); + } + + let timestamp = OffsetDateTime::now_utc().unix_timestamp(); + let headers = standard_webhook_headers(secret, "msg_test", timestamp, raw_body); + let error = + verify_standard_webhook_signature(secret_ref, &headers, br#"{"hello":"tampered"}"#) + .unwrap_err(); + assert!(matches!(error, ApiError::Unauthorized(_))); + + let stale_headers = standard_webhook_headers(secret, "msg_test", timestamp - 301, raw_body); + let error = + verify_standard_webhook_signature(secret_ref, &stale_headers, raw_body).unwrap_err(); + assert!(matches!(error, ApiError::Unauthorized(_))); + } + + #[test] + fn malformed_or_empty_standard_webhooks_secret_is_internal_error() { + let secret_ref = "CENTRAUR_TEST_STANDARD_WEBHOOK_SECRET_INVALID"; + unsafe { + env::set_var(secret_ref, "whsec_not-base64"); + } + let headers = standard_webhook_headers( + "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + "msg_test", + OffsetDateTime::now_utc().unix_timestamp(), + b"{}", + ); + + let error = verify_standard_webhook_signature(secret_ref, &headers, b"{}").unwrap_err(); + + assert!(matches!(error, ApiError::Internal(_))); + + unsafe { + env::set_var(secret_ref, "whsec_"); + } + let error = verify_standard_webhook_signature(secret_ref, &headers, b"{}").unwrap_err(); + + assert!(matches!(error, ApiError::Internal(_))); + } + fn webhook_filter(value: Value) -> WebhookFilter { serde_json::from_value(value).unwrap() } diff --git a/services/api-rs/crates/centaur-api-server/src/types.rs b/services/api-rs/crates/centaur-api-server/src/types.rs index 0b051989f..8bd5b7876 100644 --- a/services/api-rs/crates/centaur-api-server/src/types.rs +++ b/services/api-rs/crates/centaur-api-server/src/types.rs @@ -31,17 +31,6 @@ pub struct CreateSessionResponse { pub session: Session, /// True when this request restarted the thread onto a different harness. pub harness_switched: bool, - /// Present when the API assigned this Codex request through an experiment. - #[serde(skip_serializing_if = "Option::is_none")] - pub harness_assignment: Option, -} - -#[derive(Clone, Debug, Serialize)] -pub struct HarnessAssignment { - pub experiment: &'static str, - pub requested_harness: HarnessType, - pub cohort: HarnessType, - pub rollout_percent: u8, } #[derive(Clone, Debug, Serialize)] diff --git a/services/api-rs/crates/centaur-iron-proxy/Cargo.toml b/services/api-rs/crates/centaur-iron-proxy/Cargo.toml index d0741eae3..c50a3e3de 100644 --- a/services/api-rs/crates/centaur-iron-proxy/Cargo.toml +++ b/services/api-rs/crates/centaur-iron-proxy/Cargo.toml @@ -7,9 +7,11 @@ repository.workspace = true [dependencies] serde.workspace = true +serde_json.workspace = true serde_yaml.workspace = true strum.workspace = true thiserror.workspace = true +url.workspace = true [lints] workspace = true diff --git a/services/api-rs/crates/centaur-iron-proxy/src/error.rs b/services/api-rs/crates/centaur-iron-proxy/src/error.rs index a4c689106..fb4f7910d 100644 --- a/services/api-rs/crates/centaur-iron-proxy/src/error.rs +++ b/services/api-rs/crates/centaur-iron-proxy/src/error.rs @@ -9,6 +9,15 @@ pub enum IronProxyConfigError { path: PathBuf, source: serde_yaml::Error, }, + #[error("failed to parse CODEX_CUSTOM_PROVIDERS: {source}")] + ParseCustomProviders { source: serde_json::Error }, + #[error("invalid custom Codex provider {provider:?}: {reason}")] + InvalidCustomProvider { + provider: String, + reason: &'static str, + }, + #[error("invalid OPENAI_BASE_URL {value:?}: {reason}")] + InvalidOpenAiBaseUrl { value: String, reason: String }, } pub type Result = std::result::Result; diff --git a/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs b/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs index eafb52612..3999efb96 100644 --- a/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs +++ b/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs @@ -1,5 +1,7 @@ use std::{collections::BTreeMap, path::PathBuf}; +use serde::Deserialize; + use crate::{IronProxyConfigError, ProxyFragment, Result}; /// The shared infra secrets, embedded at compile time so the binary carries no @@ -14,6 +16,143 @@ pub fn load_fragment_str(contents: &str) -> Result { }) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CustomProviderConfig { + name: String, + base_url: String, + api_key_env: String, + default_model: Option, +} + +/// Build hostname-scoped bearer-token replacements for operator-configured +/// OpenAI-compatible Codex providers. The same JSON is consumed by the sandbox +/// entrypoint, so provider config and proxy credentials share one source of +/// truth without putting real keys in a sandbox. +pub fn custom_provider_auth_fragments(raw: &str) -> Result> { + if raw.trim().is_empty() { + return Ok(Vec::new()); + } + let providers: BTreeMap = serde_json::from_str(raw) + .map_err(|source| IronProxyConfigError::ParseCustomProviders { source })?; + providers + .into_iter() + .map(|(provider, config)| custom_provider_auth_fragment(&provider, config)) + .collect() +} + +fn custom_provider_auth_fragment( + provider: &str, + config: CustomProviderConfig, +) -> Result { + if !valid_provider_id(provider) { + return Err(invalid_custom_provider( + provider, + "id must match [a-z][a-z0-9_-]*", + )); + } + if config.name.trim().is_empty() { + return Err(invalid_custom_provider(provider, "name must not be empty")); + } + if config + .default_model + .as_ref() + .is_some_and(|model| model.trim().is_empty()) + { + return Err(invalid_custom_provider( + provider, + "defaultModel must not be empty when set", + )); + } + if !valid_env_name(&config.api_key_env) { + return Err(invalid_custom_provider( + provider, + "apiKeyEnv must match [A-Z][A-Z0-9_]*", + )); + } + let host = https_dns_host(&config.base_url).ok_or_else(|| { + invalid_custom_provider( + provider, + "baseUrl must be an HTTPS URL with a DNS hostname and no explicit port", + ) + })?; + load_fragment_str(&format!( + r#" +transforms: + - name: secrets + config: + secrets: + - id: CODEX_CUSTOM_PROVIDER_{provider}_AUTHORIZATION + replace: + proxy_value: {api_key_env} + match_headers: ["Authorization"] + rules: [{{ host: {host} }}] +"#, + api_key_env = config.api_key_env, + )) +} + +fn invalid_custom_provider(provider: &str, reason: &'static str) -> IronProxyConfigError { + IronProxyConfigError::InvalidCustomProvider { + provider: provider.to_owned(), + reason, + } +} + +fn valid_provider_id(value: &str) -> bool { + let mut characters = value.chars(); + characters + .next() + .is_some_and(|character| character.is_ascii_lowercase()) + && characters.all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '_' + || character == '-' + }) +} + +fn valid_env_name(value: &str) -> bool { + let mut characters = value.chars(); + characters + .next() + .is_some_and(|character| character.is_ascii_uppercase()) + && characters.all(|character| { + character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_' + }) +} + +fn https_dns_host(base_url: &str) -> Option<&str> { + let authority = base_url + .trim() + .strip_prefix("https://")? + .split('/') + .next()?; + let valid_dns_name = authority.len() <= 253 + && authority + .chars() + .any(|character| character.is_ascii_alphabetic()) + && authority.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + && label + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && label + .chars() + .last() + .is_some_and(|character| character.is_ascii_alphanumeric()) + }); + if !valid_dns_name { + return None; + } + Some(authority) +} + /// The harness auth fragment for ``engine`` and ``auth_mode``. These are infra /// — known in advance — so they are baked in rather than discovered from disk. /// Returns ``None`` for an unknown engine/mode pair. @@ -28,8 +167,10 @@ pub fn harness_auth_fragment(engine: &str, auth_mode: &str) -> Result CODEX_API_KEY_FRAGMENT, ("codex", "access_token") => CODEX_ACCESS_TOKEN_FRAGMENT, ("hermes", "api_key") => HERMES_API_KEY_FRAGMENT, ("openrouter", "api_key") => OPENROUTER_API_KEY_FRAGMENT, @@ -41,6 +182,39 @@ pub fn harness_auth_fragment(engine: &str, auth_mode: &str) -> Result Result { + codex_api_key_fragment_for_base_url(std::env::var("OPENAI_BASE_URL").ok().as_deref()) +} + +fn codex_api_key_fragment_for_base_url(configured_base_url: Option<&str>) -> Result { + let base_url = configured_base_url + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("https://api.openai.com/v1"); + let parsed = + url::Url::parse(base_url).map_err(|error| IronProxyConfigError::InvalidOpenAiBaseUrl { + value: base_url.to_owned(), + reason: error.to_string(), + })?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(IronProxyConfigError::InvalidOpenAiBaseUrl { + value: base_url.to_owned(), + reason: "scheme must be http or https".to_owned(), + }); + } + let host = parsed + .host_str() + .ok_or_else(|| IronProxyConfigError::InvalidOpenAiBaseUrl { + value: base_url.to_owned(), + reason: "URL must include a host".to_owned(), + })?; + + let mut fragment = load_fragment_str(CODEX_API_KEY_FRAGMENT)?; + fragment.transforms[0].config.secrets[0].rules[0]["host"] = + serde_yaml::Value::String(host.to_owned()); + Ok(fragment) +} + /// The deployment's Bedrock region. iron-proxy re-signs Bedrock requests for /// this region only, and codex's `amazon-bedrock` provider talks to the /// region-specific `bedrock-mantle..api.aws` endpoint, so both the @@ -167,7 +341,7 @@ transforms: replace: proxy_value: OPENAI_API_KEY match_headers: ["Authorization"] - rules: [{ host: api.openai.com }] + rules: [{ host: OPENAI_API_HOST }] "#; const OPENROUTER_API_KEY_FRAGMENT: &str = r#" @@ -362,3 +536,25 @@ mod bedrock_tests { ))); } } + +#[cfg(test)] +mod openai_tests { + use super::*; + + #[test] + fn codex_api_key_fragment_derives_host_from_configured_base_url() { + let fragment = + codex_api_key_fragment_for_base_url(Some(" https://us.api.openai.com/v1/ ")).unwrap(); + assert_eq!( + fragment.transforms[0].config.secrets[0].rules[0]["host"].as_str(), + Some("us.api.openai.com") + ); + } + + #[test] + fn codex_api_key_fragment_rejects_invalid_base_url() { + let error = + codex_api_key_fragment_for_base_url(Some("api.example.com\n- injected")).unwrap_err(); + assert!(error.to_string().contains("invalid OPENAI_BASE_URL")); + } +} diff --git a/services/api-rs/crates/centaur-iron-proxy/src/lib.rs b/services/api-rs/crates/centaur-iron-proxy/src/lib.rs index d59cdb2b0..a9533c9e0 100644 --- a/services/api-rs/crates/centaur-iron-proxy/src/lib.rs +++ b/services/api-rs/crates/centaur-iron-proxy/src/lib.rs @@ -5,8 +5,8 @@ mod source; pub use error::{IronProxyConfigError, Result}; pub use fragment::{ - bedrock_enabled, bedrock_region, bedrock_sandbox_env, harness_auth_fragment, infra_fragment, - load_fragment_str, pg_sandbox_dsns, placeholder_env, + bedrock_enabled, bedrock_region, bedrock_sandbox_env, custom_provider_auth_fragments, + harness_auth_fragment, infra_fragment, load_fragment_str, pg_sandbox_dsns, placeholder_env, }; pub use model::{ PgDsnSetting, PgDsnSettingValueFrom, PostgresClient, PostgresListener, PostgresUpstream, diff --git a/services/api-rs/crates/centaur-iron-proxy/src/tests.rs b/services/api-rs/crates/centaur-iron-proxy/src/tests.rs index 75cf4c940..c591f58ad 100644 --- a/services/api-rs/crates/centaur-iron-proxy/src/tests.rs +++ b/services/api-rs/crates/centaur-iron-proxy/src/tests.rs @@ -73,6 +73,67 @@ fn harness_auth_fragments_are_baked_in() { } } +#[test] +fn custom_provider_fragments_are_scoped_and_declare_placeholders() { + let fragments = custom_provider_auth_fragments( + r#"{ + "private_responses": { + "name": "Private Responses", + "baseUrl": "https://inference.example.com/v1", + "apiKeyEnv": "PRIVATE_RESPONSES_API_KEY", + "defaultModel": "example-model" + } + }"#, + ) + .unwrap(); + let placeholders = placeholder_env(&fragments); + assert_eq!( + placeholders + .get("PRIVATE_RESPONSES_API_KEY") + .map(String::as_str), + Some("PRIVATE_RESPONSES_API_KEY") + ); + + let yaml = serde_yaml::to_string(&fragments[0]).unwrap(); + assert!(yaml.contains("host: inference.example.com")); + assert!(yaml.contains("proxy_value: PRIVATE_RESPONSES_API_KEY")); + assert!(!yaml.contains("https://")); +} + +#[test] +fn custom_provider_default_model_is_optional() { + let fragments = custom_provider_auth_fragments( + r#"{ + "private_responses": { + "name": "Private Responses", + "baseUrl": "https://inference.example.com/v1", + "apiKeyEnv": "PRIVATE_RESPONSES_API_KEY" + } + }"#, + ) + .unwrap(); + assert_eq!(fragments.len(), 1); +} + +#[test] +fn custom_provider_fragments_reject_unsafe_or_malformed_config() { + for raw in [ + r#"{"bad.id":{"name":"Bad","baseUrl":"https://inference.example.com/v1","apiKeyEnv":"BAD_API_KEY","defaultModel":"model"}}"#, + r#"{"private":{"name":"Bad","baseUrl":"http://inference.example.com/v1","apiKeyEnv":"BAD_API_KEY","defaultModel":"model"}}"#, + r#"{"private":{"name":"Bad","baseUrl":"https://user@inference.example.com/v1","apiKeyEnv":"BAD_API_KEY","defaultModel":"model"}}"#, + r#"{"private":{"name":"Bad","baseUrl":"https://inference.example.com:443/v1","apiKeyEnv":"BAD_API_KEY","defaultModel":"model"}}"#, + r#"{"private":{"name":"Bad","baseUrl":"https://127.0.0.1/v1","apiKeyEnv":"BAD_API_KEY","defaultModel":"model"}}"#, + r#"{"private":{"name":"Bad","baseUrl":"https://inference.example.com/v1","apiKeyEnv":"bad-key","defaultModel":"model"}}"#, + r#"{"private":{"name":"Bad","baseUrl":"https://inference.example.com/v1","apiKeyEnv":"BAD_API_KEY","defaultModel":""}}"#, + "not-json", + ] { + assert!( + custom_provider_auth_fragments(raw).is_err(), + "accepted {raw}" + ); + } +} + #[test] fn pg_sandbox_dsns_reads_name_and_database_from_fragments() { // A listener with a sandbox_env (the api-rs-internal annotation api-rs diff --git a/services/api-rs/crates/centaur-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 59f8b878e..922e4f513 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -383,6 +383,8 @@ pub struct ToolHostCallInput { #[derive(Debug)] pub struct ToolHostCallOutput { + pub request_id: String, + pub execution_id: String, pub sandbox_id: String, pub stdout: String, pub stderr: String, @@ -874,20 +876,6 @@ impl SessionRuntime { Ok(self.store.get_session(thread_key).await?) } - /// Returns the harness already persisted for a thread, if the session - /// exists. API policy uses this to keep rollout assignments sticky across - /// configuration changes without exposing the session store itself. - pub async fn existing_session_harness( - &self, - thread_key: &ThreadKey, - ) -> Result, SessionRuntimeError> { - match self.store.get_session(thread_key).await { - Ok(session) => Ok(Some(session.harness_type)), - Err(SessionStoreError::NotFound { .. }) => Ok(None), - Err(error) => Err(error.into()), - } - } - fn resolve_persona_for_create( &self, requested_persona_id: Option<&str>, @@ -1051,7 +1039,7 @@ impl SessionRuntime { idempotency_key: Some(request_id.clone()), metadata: Some(json!({ "mcp_tool_host_call": true, - "request_id": request_id, + "request_id": request_id.clone(), "tool": tool_name, "method": method, "timeout_ms": duration_millis_u64(timeout), @@ -1062,8 +1050,13 @@ impl SessionRuntime { }, ) .await?; - self.wait_for_tool_host_call(thread_key, &execution.execution_id, response_timeout) - .await + self.wait_for_tool_host_call( + thread_key, + &execution.execution_id, + &request_id, + response_timeout, + ) + .await } async fn create_or_get_tool_host_session( @@ -1093,6 +1086,7 @@ impl SessionRuntime { &self, thread_key: &ThreadKey, execution_id: &str, + request_id: &str, response_timeout: Duration, ) -> Result { let events = self @@ -1104,10 +1098,19 @@ impl SessionRuntime { let event = event?; match event.event_type.as_str() { "session.execution_completed" => { - return self.tool_host_completed_output(thread_key, &event).await; + return self + .tool_host_completed_output( + thread_key, + &event, + execution_id, + request_id, + ) + .await; } "session.execution_failed" => { - return self.tool_host_failed_output(thread_key, &event).await; + return self + .tool_host_failed_output(thread_key, &event, execution_id, request_id) + .await; } _ => {} } @@ -1122,6 +1125,8 @@ impl SessionRuntime { // Best-effort sandbox id: a store error must not replace the // timeout result with an internal error. Err(_) => Ok(ToolHostCallOutput { + request_id: request_id.to_owned(), + execution_id: execution_id.to_owned(), sandbox_id: self .current_sandbox_id(thread_key) .await @@ -1141,10 +1146,14 @@ impl SessionRuntime { &self, thread_key: &ThreadKey, event: &SessionEvent, + execution_id: &str, + request_id: &str, ) -> Result { let sandbox_id = self.current_sandbox_id(thread_key).await?; let Some(result_text) = event.payload.get("result_text").and_then(Value::as_str) else { return Ok(ToolHostCallOutput { + request_id: request_id.to_owned(), + execution_id: execution_id.to_owned(), sandbox_id, stdout: String::new(), stderr: String::new(), @@ -1159,6 +1168,8 @@ impl SessionRuntime { )) })?; Ok(ToolHostCallOutput { + request_id: request_id.to_owned(), + execution_id: execution_id.to_owned(), sandbox_id, stdout: response.stdout, stderr: response.stderr, @@ -1171,6 +1182,8 @@ impl SessionRuntime { &self, thread_key: &ThreadKey, event: &SessionEvent, + execution_id: &str, + request_id: &str, ) -> Result { let error = event .payload @@ -1184,6 +1197,8 @@ impl SessionRuntime { .and_then(Value::as_str) .is_some_and(|reason| reason == "max_duration_exceeded"); Ok(ToolHostCallOutput { + request_id: request_id.to_owned(), + execution_id: execution_id.to_owned(), sandbox_id: self.current_sandbox_id(thread_key).await?, stdout: String::new(), stderr: error, @@ -1356,6 +1371,38 @@ impl SessionRuntime { metadata: Option, on_harness_conflict: HarnessConflictPolicy, ) -> Result { + self.create_or_get_session_with_principal( + thread_key, + harness_type, + persona_id, + metadata, + on_harness_conflict, + None, + ) + .await + } + + /// Create or load a session and bind it to an existing iron-control + /// principal selected by foreign ID. When no foreign ID is supplied, the + /// session keeps the normal principal derived from its thread key. + pub async fn create_or_get_session_with_principal( + &self, + thread_key: &ThreadKey, + harness_type: &HarnessType, + persona_id: Option<&str>, + metadata: Option, + on_harness_conflict: HarnessConflictPolicy, + principal_foreign_id: Option<&str>, + ) -> Result { + let principal_foreign_id = match principal_foreign_id { + Some(foreign_id) if foreign_id.trim().is_empty() => { + return Err(SessionRuntimeError::BadRequest( + "principal must be a non-empty foreign ID".to_owned(), + )); + } + Some(foreign_id) => Some(foreign_id.trim()), + None => None, + }; let span = info_span!( "centaur.api_rs.session.create_or_get", component = COMPONENT_SESSION_RUNTIME, @@ -1382,17 +1429,21 @@ impl SessionRuntime { let mut harness_switched = false; let mut session_metadata = default_metadata(metadata); let proxy_labels = proxy_labels_from_session_metadata(thread_key, &session_metadata); - let registered_principal = self - .iron_control - .register_session(thread_key.as_str(), Some(&session_metadata)) - .await?; + let registered_principal = match principal_foreign_id { + Some(foreign_id) => self.iron_control.get_principal(foreign_id).await?, + None => { + self.iron_control + .register_session(thread_key.as_str(), Some(&session_metadata)) + .await? + } + }; let desired_capabilities = sandbox_capabilities_from_principal(®istered_principal); let persona_resolution = self.resolve_persona_for_create(persona_id, &desired_capabilities)?; if let Some(context) = persona_resolution.context.as_ref() { add_persona_metadata(&mut session_metadata, context); } - let session = match self + match self .store .create_or_get_session( thread_key, @@ -1428,6 +1479,14 @@ impl SessionRuntime { } Err(error) => return Err(error.into()), }; + // Persist the principal OID on the session row so a resumed session + // can recreate its sandbox after a restart without re-deriving it. + // Existing sessions are immutable at this boundary: changing their + // credential identity requires a different session. + let session = self + .store + .bind_iron_control_principal(thread_key, ®istered_principal.id) + .await?; if let Some(context) = self.resolve_stored_persona( session.persona_id.as_deref(), harness_type, @@ -1446,12 +1505,6 @@ impl SessionRuntime { ) .await?; } - // Persist the principal OID on the session row so a resumed session - // can recreate its sandbox after a restart without re-deriving it. - let session = self - .store - .set_iron_control_principal(thread_key, Some(®istered_principal.id)) - .await?; info!( component = COMPONENT_SESSION_RUNTIME, event = "session_create_or_get_completed", @@ -9173,6 +9226,64 @@ mod adoption_tests { ) } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn create_session_can_select_principal_by_foreign_id() { + let Some(store) = test_store().await else { + return; + }; + let _serial = TEST_LOCK.lock().await; + let thread_key = + ThreadKey::parse(format!("test:principal-{}", uuid::Uuid::new_v4())).unwrap(); + let backend = Arc::new(MockBackend::new(SandboxStatus::Running, Vec::new())); + let runtime = runtime_with(&store, backend); + + let outcome = runtime + .create_or_get_session_with_principal( + &thread_key, + &HarnessType::Codex, + None, + Some(json!({})), + HarnessConflictPolicy::Reject, + Some(" finance-automation "), + ) + .await + .expect("create session with selected principal"); + + assert_eq!( + outcome.session.iron_control_principal.as_deref(), + Some("finance-automation") + ); + + let error = runtime + .create_or_get_session_with_principal( + &thread_key, + &HarnessType::Codex, + None, + Some(json!({})), + HarnessConflictPolicy::Reject, + Some("support-automation"), + ) + .await + .expect_err("existing session principal must not be rebound"); + assert!(matches!( + error, + SessionRuntimeError::Store(SessionStoreError::PrincipalConflict { + existing, + requested, + .. + }) if existing == "finance-automation" && requested == "support-automation" + )); + assert_eq!( + store + .get_session(&thread_key) + .await + .expect("get session after conflict") + .iron_control_principal + .as_deref(), + Some("finance-automation") + ); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn enqueue_returns_after_durable_commit_without_waiting_for_sandbox() { let Some(store) = test_store().await else { diff --git a/services/api-rs/crates/centaur-session-runtime/src/title_generator.rs b/services/api-rs/crates/centaur-session-runtime/src/title_generator.rs index f8c80c16e..d10767d36 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/title_generator.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/title_generator.rs @@ -11,6 +11,7 @@ const SESSION_TITLE_REQUEST_TIMEOUT: Duration = Duration::from_secs(4); #[derive(Clone)] pub(crate) struct OpenAiSessionTitleGenerator { api_key: Arc, + responses_url: Arc, client: reqwest::Client, } @@ -21,12 +22,14 @@ impl OpenAiSessionTitleGenerator { if api_key.is_empty() || api_key == "OPENAI_API_KEY" { return None; } + let responses_url = format!("{}/responses", openai_base_url()); let client = reqwest::Client::builder() .timeout(SESSION_TITLE_REQUEST_TIMEOUT) .build() .ok()?; Some(Self { api_key: Arc::from(api_key.to_owned()), + responses_url: Arc::from(responses_url), client, }) } @@ -43,7 +46,7 @@ impl OpenAiSessionTitleGenerator { }); let response = self .client - .post("https://api.openai.com/v1/responses") + .post(self.responses_url.as_ref()) .bearer_auth(self.api_key.as_ref()) .json(&body) .send() @@ -57,6 +60,14 @@ impl OpenAiSessionTitleGenerator { } } +pub fn openai_base_url() -> String { + env::var("OPENAI_BASE_URL") + .ok() + .map(|value| value.trim().trim_end_matches('/').to_owned()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "https://api.openai.com/v1".to_owned()) +} + pub(crate) fn session_title_source_from_parts(parts: &[Value]) -> Option { let mut text_blocks = Vec::new(); let mut slack_thread_source = None; diff --git a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs index a71bc44b3..fc97d8f11 100644 --- a/services/api-rs/crates/centaur-session-sqlx/src/lib.rs +++ b/services/api-rs/crates/centaur-session-sqlx/src/lib.rs @@ -1372,6 +1372,47 @@ impl PgSessionStore { row.try_into() } + /// Bind a principal to a session without allowing an existing binding to + /// change. The conditional update makes concurrent first bindings atomic: + /// one caller wins, and a caller selecting a different principal receives + /// a conflict instead of rebinding the session. + pub async fn bind_iron_control_principal( + &self, + thread_key: &ThreadKey, + iron_control_principal: &str, + ) -> Result { + let row = sqlx::query_as::<_, SessionRow>( + r#" + update sessions + set iron_control_principal = $2, updated_at = now() + where thread_key = $1 + and (iron_control_principal is null or iron_control_principal = $2) + returning thread_key, title, sandbox_id, sandbox_repo_cache_enabled, sandbox_repo_cache_access, sandbox_observability_enabled, harness_type, harness_thread_id, persona_id, status, iron_control_principal, proxy_labels, sandbox_last_active_at, created_at, updated_at + "#, + ) + .bind(thread_key.as_str()) + .bind(iron_control_principal) + .fetch_optional(&self.pool) + .await?; + + if let Some(row) = row { + return row.try_into(); + } + + let session = self.get_session(thread_key).await?; + match session.iron_control_principal { + Some(existing) => Err(SessionStoreError::PrincipalConflict { + thread_key: thread_key.as_str().to_owned(), + existing, + requested: iron_control_principal.to_owned(), + }), + None => Err(SessionStoreError::InvalidPersistedValue(format!( + "session {} remained unbound after principal binding", + thread_key.as_str() + ))), + } + } + pub async fn insert_ready_warm_sandbox( &self, sandbox_id: &str, @@ -1650,6 +1691,12 @@ pub enum SessionStoreError { existing: Option, requested: Option, }, + #[error("session {thread_key} already exists with principal {existing}, requested {requested}")] + PrincipalConflict { + thread_key: String, + existing: String, + requested: String, + }, #[error("invalid persisted value: {0}")] InvalidPersistedValue(String), #[error("session execution not found for execution_id {execution_id}")] diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index f6887ba02..96c55a88a 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -1,6 +1,7 @@ use std::{ collections::{BTreeMap, BTreeSet}, env, + future::Future, path::PathBuf, str::FromStr, sync::{Arc, RwLock}, @@ -22,7 +23,7 @@ use centaur_session_sqlx::PgSessionStore; use chrono::{DateTime, Utc}; use chrono_tz::Tz; use cron::Schedule; -use futures_util::{TryStreamExt, pin_mut}; +use futures_util::{StreamExt, TryStreamExt, pin_mut, stream}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sqlx::Row; @@ -47,6 +48,10 @@ const PYTHON_HOST_INTERPRETER_ENV: &str = "PYTHON_WORKFLOW_HOST_PYTHON"; const WORKFLOW_TOOL_API_URL_ENV: &str = "WORKFLOW_TOOL_API_URL"; const DEFAULT_AGENT_IDLE_TIMEOUT_MS: u64 = 60_000; const DEFAULT_AGENT_MAX_DURATION_MS: u64 = 30 * 60 * 1_000; +const DEFAULT_AGENT_BATCH_CONCURRENCY: usize = 4; +const MAX_AGENT_BATCH_CONCURRENCY: usize = 16; +const MAX_AGENT_BATCH_SIZE: usize = 32; +const MAX_AGENT_BATCH_NAME_BYTES: usize = 128; const WORKFLOW_HOST_CLAIM_EXTENSION: Duration = Duration::from_secs(5 * 60); const WORKFLOW_HOST_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60); const WORKFLOW_RECONCILE_INTERVAL_SECS_ENV: &str = "WORKFLOW_RECONCILE_INTERVAL_SECS"; @@ -190,7 +195,7 @@ impl WorkflowEnablement { }); metadata .principals - .retain(|workflow_name| self.is_enabled(workflow_name)); + .retain(|workflow_name, _| self.is_enabled(workflow_name)); } } @@ -224,6 +229,12 @@ struct WorkflowPrincipalAssignments { registered: BTreeMap, } +#[derive(Clone, Debug, Eq, PartialEq)] +enum WorkflowPrincipalDeclaration { + Managed, + Existing(String), +} + impl WorkflowPrincipalAssignments { fn principal_for_workflow( &self, @@ -293,24 +304,30 @@ impl WorkflowPrincipalRegistrar { async fn register_workflow_principals( &self, - principals: &BTreeSet, + principals: &BTreeMap, ) -> Result, WorkflowRuntimeError> { let mut registered = BTreeMap::new(); - for workflow_name in principals { - let foreign_id = canonical_workflow_principal_foreign_id(workflow_name); - let record = self - .client - .upsert_principal(&PrincipalInput { - foreign_id, - name: format!("Workflow {workflow_name}"), - labels: workflow_principal_labels(workflow_name), - kind: Some("workflow".to_owned()), - slack_user_id: None, - slack_channel_id: None, - slack_team_id: None, - slack_email: None, - }) - .await?; + for (workflow_name, declaration) in principals { + let record = match declaration { + WorkflowPrincipalDeclaration::Managed => { + let foreign_id = canonical_workflow_principal_foreign_id(workflow_name); + self.client + .upsert_principal(&PrincipalInput { + foreign_id, + name: format!("Workflow {workflow_name}"), + labels: workflow_principal_labels(workflow_name), + kind: Some("workflow".to_owned()), + slack_user_id: None, + slack_channel_id: None, + slack_team_id: None, + slack_email: None, + }) + .await? + } + WorkflowPrincipalDeclaration::Existing(reference) => { + self.client.get_principal(reference).await? + } + }; registered.insert(workflow_name.clone(), record.id); } Ok(registered) @@ -435,6 +452,9 @@ pub enum WorkflowWebhookAuth { Github { secret_ref: String, }, + StandardWebhooks { + secret_ref: String, + }, Bearer { secret_ref: String, }, @@ -1250,7 +1270,9 @@ fn normalize_webhook(webhook: &mut RegisteredWorkflowWebhook) -> Result<(), Work )); } } - WorkflowWebhookAuth::Github { secret_ref } | WorkflowWebhookAuth::Bearer { secret_ref } => { + WorkflowWebhookAuth::Github { secret_ref } + | WorkflowWebhookAuth::StandardWebhooks { secret_ref } + | WorkflowWebhookAuth::Bearer { secret_ref } => { if secret_ref.trim().is_empty() { return Err(WorkflowRuntimeError::BadRequest(format!( "workflow webhook {:?} auth requires secret_ref", @@ -1652,7 +1674,14 @@ struct PythonWorkflowDiscovery { #[serde(default)] schedule: Option, #[serde(default)] - principal: Option, + principal: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PythonWorkflowPrincipal { + Enabled(bool), + Reference(String), } #[derive(Debug, Deserialize)] @@ -1665,7 +1694,7 @@ struct PythonWorkflowMetadata { webhooks: Vec, schedules: Vec, workflow_names: BTreeSet, - principals: BTreeSet, + principals: BTreeMap, } fn metadata_from_discovery_payload( @@ -1688,8 +1717,20 @@ fn metadata_from_discovery_payload( } metadata.schedules.push(schedule); } - if workflow.principal.unwrap_or(false) { - metadata.principals.insert(workflow.workflow_name); + match workflow.principal { + Some(PythonWorkflowPrincipal::Enabled(true)) => { + metadata.principals.insert( + workflow.workflow_name, + WorkflowPrincipalDeclaration::Managed, + ); + } + Some(PythonWorkflowPrincipal::Reference(reference)) if !reference.trim().is_empty() => { + metadata.principals.insert( + workflow.workflow_name, + WorkflowPrincipalDeclaration::Existing(reference.trim().to_owned()), + ); + } + _ => {} } } metadata @@ -1705,7 +1746,7 @@ async fn prepare_workflow_host_sandbox( if !discovery.principals.is_empty() { let workflow_names = discovery .principals - .iter() + .keys() .cloned() .collect::>() .join(", "); @@ -1732,15 +1773,16 @@ async fn reconcile_workflow_principals( enablement: &WorkflowEnablement, ) -> Result<(), WorkflowRuntimeError> { let mut principals = discovery.principals.clone(); - principals.retain(|workflow_name| enablement.is_enabled(workflow_name)); + principals.retain(|workflow_name, _| enablement.is_enabled(workflow_name)); + let required = principals.keys().cloned().collect(); let registered = match registrar.register_workflow_principals(&principals).await { Ok(registered) => registered, Err(error) => { - sandbox.update_workflow_principals(BTreeMap::new(), principals); + sandbox.update_workflow_principals(BTreeMap::new(), required); return Err(error); } }; - sandbox.update_workflow_principals(registered, principals); + sandbox.update_workflow_principals(registered, required); Ok(()) } @@ -2656,6 +2698,7 @@ async fn run_centaur_workflow_inner( .get("max_duration_ms") .and_then(Value::as_u64) .unwrap_or(DEFAULT_AGENT_MAX_DURATION_MS); + let principal_foreign_id = parse_agent_principal(&input.input).map_err(absurd_error)?; let agent = ctx .step("agent_turn", || { let session_runtime = session_runtime.clone(); @@ -2678,6 +2721,7 @@ async fn run_centaur_workflow_inner( thread_key, harness_type, persona_id: None, + principal_foreign_id, parts: vec![json!({"type": "text", "text": prompt})], client_message_id: client_message_id.clone(), session_metadata: metadata.clone(), @@ -3348,7 +3392,22 @@ async fn handle_python_context_request( } Some("ctx.agent_turn") => { let args = message.get("args").cloned().unwrap_or_else(|| json!({})); - match run_python_agent_turn(session_runtime.clone(), ctx, input, args, &request_id) + match run_python_agent_turn( + session_runtime.clone(), + ctx, + input, + args, + &request_id, + None, + ) + .await + { + Ok(value) => Ok(value), + Err(error) => Err(error.to_string()), + } + } + Some("ctx.run_agents") => { + match run_python_agent_batch(session_runtime.clone(), ctx, input, message, &request_id) .await { Ok(value) => Ok(value), @@ -3505,6 +3564,7 @@ async fn run_python_agent_turn( input: &WorkflowTaskInput, args: Value, request_id: &str, + default_thread_key: Option, ) -> Result { let text = args .get("text") @@ -3535,11 +3595,13 @@ async fn run_python_agent_turn( .map(ToOwned::to_owned); let workflow_owned_thread = explicit_thread_key.is_none(); let thread_key = explicit_thread_key.unwrap_or_else(|| { - format!( - "wf:{}:agent:{}", - ctx.task_id().replace('-', ""), - input.workflow_name - ) + default_thread_key.unwrap_or_else(|| { + format!( + "wf:{}:agent:{}", + ctx.task_id().replace('-', ""), + input.workflow_name + ) + }) }); let harness_type = parse_agent_harness(&args)?.unwrap_or_else(|| input.harness_type.clone()); let persona_id = args @@ -3547,6 +3609,7 @@ async fn run_python_agent_turn( .or_else(|| args.get("persona")) .and_then(Value::as_str) .map(ToOwned::to_owned); + let principal_foreign_id = parse_agent_principal(&args)?; let client_message_id = args .get("message_id") .or_else(|| args.get("client_message_id")) @@ -3576,6 +3639,13 @@ async fn run_python_agent_turn( if let Some(engine) = args.get("engine").and_then(Value::as_str) { object_insert(&mut execution_metadata, "engine", json!(engine)); } + if let Some(principal) = principal_foreign_id.as_deref() { + object_insert( + &mut execution_metadata, + "principal_foreign_id", + json!(principal), + ); + } let idle_timeout_ms = args .get("idle_timeout_ms") .and_then(Value::as_u64) @@ -3607,6 +3677,7 @@ async fn run_python_agent_turn( thread_key, harness_type, persona_id, + principal_foreign_id, parts, client_message_id, session_metadata, @@ -3625,6 +3696,214 @@ async fn run_python_agent_turn( serde_json::to_value(result).map_err(WorkflowRuntimeError::from) } +#[derive(Debug, Clone, PartialEq)] +struct PythonAgentBatchItem { + index: usize, + name: String, + args: Value, +} + +fn parse_python_agent_batch( + message: &Value, + request_id: &str, +) -> Result<(Vec, usize), WorkflowRuntimeError> { + let raw_agents = message + .get("agents") + .and_then(Value::as_array) + .ok_or_else(|| { + WorkflowRuntimeError::BadRequest("ctx.run_agents requires an agents array".to_owned()) + })?; + if raw_agents.is_empty() { + return Err(WorkflowRuntimeError::BadRequest( + "ctx.run_agents requires at least one agent".to_owned(), + )); + } + if raw_agents.len() > MAX_AGENT_BATCH_SIZE { + return Err(WorkflowRuntimeError::BadRequest(format!( + "ctx.run_agents supports at most {MAX_AGENT_BATCH_SIZE} agents" + ))); + } + + let max_concurrency = match message.get("max_concurrency") { + Some(value) => value.as_u64().ok_or_else(|| { + WorkflowRuntimeError::BadRequest( + "ctx.run_agents max_concurrency must be an integer".to_owned(), + ) + })? as usize, + None => DEFAULT_AGENT_BATCH_CONCURRENCY, + }; + if !(1..=MAX_AGENT_BATCH_CONCURRENCY).contains(&max_concurrency) { + return Err(WorkflowRuntimeError::BadRequest(format!( + "ctx.run_agents max_concurrency must be between 1 and {MAX_AGENT_BATCH_CONCURRENCY}" + ))); + } + + let reserved_fields = [ + "thread_key", + "message_id", + "client_message_id", + "idempotency_key", + "execution_idempotency_key", + ]; + let mut names = BTreeSet::new(); + let mut agents = Vec::with_capacity(raw_agents.len()); + for (index, raw_agent) in raw_agents.iter().enumerate() { + let mut args = raw_agent.as_object().cloned().ok_or_else(|| { + WorkflowRuntimeError::BadRequest(format!( + "ctx.run_agents agent at index {index} must be an object" + )) + })?; + let name = args + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + WorkflowRuntimeError::BadRequest(format!( + "ctx.run_agents agent at index {index} requires a non-empty name" + )) + })? + .to_owned(); + if name.len() > MAX_AGENT_BATCH_NAME_BYTES { + return Err(WorkflowRuntimeError::BadRequest(format!( + "ctx.run_agents agent name at index {index} must be at most {MAX_AGENT_BATCH_NAME_BYTES} bytes" + ))); + } + if !names.insert(name.clone()) { + return Err(WorkflowRuntimeError::BadRequest(format!( + "ctx.run_agents agent names must be unique; duplicate {name:?}" + ))); + } + if let Some(field) = reserved_fields + .iter() + .find(|field| args.contains_key(**field)) + { + return Err(WorkflowRuntimeError::BadRequest(format!( + "ctx.run_agents agent {name:?} cannot set reserved field {field:?}" + ))); + } + + let metadata = args + .entry("metadata".to_owned()) + .or_insert_with(|| json!({})); + if !metadata.is_object() { + *metadata = json!({}); + } + object_insert(metadata, "workflow_agent_batch_name", json!(name)); + object_insert(metadata, "workflow_agent_batch_index", json!(index)); + object_insert( + metadata, + "workflow_agent_batch_request_id", + json!(request_id), + ); + + agents.push(PythonAgentBatchItem { + index, + name, + args: Value::Object(args), + }); + } + Ok((agents, max_concurrency)) +} + +async fn run_bounded_ordered( + items: Vec, + max_concurrency: usize, + mut run: F, +) -> Vec +where + F: FnMut(T) -> Fut, + Fut: Future, +{ + let item_count = items.len(); + let futures = items + .into_iter() + .enumerate() + .map(|(index, item)| { + let future = run(item); + async move { (index, future.await) } + }) + .collect::>(); + let completed = stream::iter(futures) + .buffer_unordered(max_concurrency) + .collect::>() + .await; + let mut ordered = (0..item_count).map(|_| None).collect::>(); + for (index, result) in completed { + ordered[index] = Some(result); + } + ordered + .into_iter() + .map(|result| result.expect("every bounded batch future must produce one result")) + .collect() +} + +async fn run_python_agent_batch( + session_runtime: SessionRuntime, + ctx: &TaskContext, + input: &WorkflowTaskInput, + message: &Value, + request_id: &str, +) -> Result { + let (agents, max_concurrency) = parse_python_agent_batch(message, request_id)?; + let task_id = ctx.task_id().replace('-', ""); + let batch_request_id = request_id.to_owned(); + let outcomes = run_bounded_ordered(agents, max_concurrency, |agent| { + let session_runtime = session_runtime.clone(); + let agent_slug = slugify(&agent.name); + let default_thread_key = format!( + "wf:{task_id}:agent-batch:{batch_request_id}:{}:{agent_slug}", + agent.index, + ); + let agent_request_id = format!("{batch_request_id}:{}", agent.index); + async move { + let result = run_python_agent_turn( + session_runtime, + ctx, + input, + agent.args.clone(), + &agent_request_id, + Some(default_thread_key), + ) + .await; + (agent, result) + } + }) + .await; + + let mut succeeded = 0; + let mut failed = 0; + let results = outcomes + .into_iter() + .map(|(agent, result)| match result { + Ok(result) => { + succeeded += 1; + json!({ + "index": agent.index, + "name": agent.name, + "ok": true, + "result": result, + }) + } + Err(error) => { + failed += 1; + json!({ + "index": agent.index, + "name": agent.name, + "ok": false, + "error": error.to_string(), + }) + } + }) + .collect::>(); + + Ok(json!({ + "results": results, + "succeeded": succeeded, + "failed": failed, + })) +} + /// Returns the first arg key that holds a non-empty (trimmed) string, owned. fn first_str_arg(args: &Value, keys: &[&str]) -> Option { keys.iter() @@ -3634,6 +3913,26 @@ fn first_str_arg(args: &Value, keys: &[&str]) -> Option { .map(ToOwned::to_owned) } +fn parse_agent_principal(args: &Value) -> Result, WorkflowRuntimeError> { + let Some(value) = args + .get("principal") + .or_else(|| args.get("principal_foreign_id")) + else { + return Ok(None); + }; + let foreign_id = value.as_str().map(str::trim).ok_or_else(|| { + WorkflowRuntimeError::BadRequest( + "ctx.agent_turn principal must be a non-empty foreign ID".to_owned(), + ) + })?; + if foreign_id.is_empty() { + return Err(WorkflowRuntimeError::BadRequest( + "ctx.agent_turn principal must be a non-empty foreign ID".to_owned(), + )); + } + Ok(Some(foreign_id.to_owned())) +} + fn parse_agent_harness(args: &Value) -> Result, WorkflowRuntimeError> { let Some(raw) = args .get("harness_type") @@ -3853,6 +4152,12 @@ fn python_slack_message_payload( if let Some(blocks) = args.get("blocks") { payload["blocks"] = blocks.clone(); } + if let Some(username) = args.get("username").and_then(Value::as_str) { + payload["username"] = json!(username); + } + if let Some(icon_emoji) = args.get("icon_emoji").and_then(Value::as_str) { + payload["icon_emoji"] = json!(icon_emoji); + } if let Some(no_attribution) = args.get("no_attribution").and_then(Value::as_bool) { payload["no_attribution"] = json!(no_attribution); } @@ -3899,6 +4204,7 @@ struct AgentTurnRequest { thread_key: String, harness_type: HarnessType, persona_id: Option, + principal_foreign_id: Option, parts: Vec, client_message_id: String, session_metadata: Value, @@ -3954,6 +4260,7 @@ async fn run_agent_session_turn( thread_key, harness_type, persona_id, + principal_foreign_id, parts, client_message_id, session_metadata, @@ -3973,12 +4280,13 @@ async fn run_agent_session_turn( object_insert(&mut session_metadata, "workflow_owned_thread", json!(true)); } session_runtime - .create_or_get_session( + .create_or_get_session_with_principal( &thread_key, &harness_type, persona_id.as_deref(), Some(session_metadata), HarnessConflictPolicy::Reject, + principal_foreign_id.as_deref(), ) .await?; session_runtime @@ -4200,6 +4508,153 @@ mod tests { assert_eq!(first_str_arg(&json!({"model": " "}), &["model"]), None); } + #[test] + fn parse_agent_principal_accepts_foreign_id_and_rejects_invalid_values() { + assert_eq!( + parse_agent_principal(&json!({"principal": " finance-automation "})).unwrap(), + Some("finance-automation".to_owned()) + ); + assert_eq!( + parse_agent_principal(&json!({"principal_foreign_id": "support"})).unwrap(), + Some("support".to_owned()) + ); + assert_eq!(parse_agent_principal(&json!({})).unwrap(), None); + assert!(parse_agent_principal(&json!({"principal": " "})).is_err()); + assert!(parse_agent_principal(&json!({"principal": true})).is_err()); + } + + #[test] + fn parse_agent_batch_requires_unique_names_and_adds_metadata() { + let message = json!({ + "type": "ctx.run_agents", + "max_concurrency": 2, + "agents": [ + { + "name": "correctness", + "text": "Review correctness", + "principal": "security-reviewers", + "metadata": {"pr": 42} + }, + {"name": "security", "text": "Review security"} + ] + }); + + let (agents, max_concurrency) = parse_python_agent_batch(&message, "7").unwrap(); + + assert_eq!(max_concurrency, 2); + assert_eq!( + agents + .iter() + .map(|agent| agent.name.as_str()) + .collect::>(), + vec!["correctness", "security"] + ); + assert_eq!(agents[0].args.pointer("/metadata/pr"), Some(&json!(42))); + assert_eq!( + agents[0].args.get("principal"), + Some(&json!("security-reviewers")) + ); + assert_eq!( + agents[0] + .args + .pointer("/metadata/workflow_agent_batch_name"), + Some(&json!("correctness")) + ); + assert_eq!( + agents[1] + .args + .pointer("/metadata/workflow_agent_batch_index"), + Some(&json!(1)) + ); + assert_eq!( + agents[1] + .args + .pointer("/metadata/workflow_agent_batch_request_id"), + Some(&json!("7")) + ); + } + + #[test] + fn parse_agent_batch_rejects_duplicate_names_and_identity_overrides() { + let duplicate = json!({ + "agents": [ + {"name": "security", "text": "first"}, + {"name": "security", "text": "second"} + ] + }); + let error = parse_python_agent_batch(&duplicate, "1").unwrap_err(); + assert!(error.to_string().contains("names must be unique")); + + let overridden_identity = json!({ + "agents": [{ + "name": "security", + "text": "review", + "thread_key": "workflow:shared" + }] + }); + let error = parse_python_agent_batch(&overridden_identity, "1").unwrap_err(); + assert!(error.to_string().contains("reserved field \"thread_key\"")); + } + + #[test] + fn parse_agent_batch_enforces_size_and_concurrency_bounds() { + let empty = json!({"agents": []}); + let error = parse_python_agent_batch(&empty, "1").unwrap_err(); + assert!(error.to_string().contains("at least one agent")); + + for max_concurrency in [0, MAX_AGENT_BATCH_CONCURRENCY + 1] { + let message = json!({ + "agents": [{"name": "correctness", "text": "review"}], + "max_concurrency": max_concurrency, + }); + let error = parse_python_agent_batch(&message, "1").unwrap_err(); + assert!( + error + .to_string() + .contains("max_concurrency must be between") + ); + } + + let too_many = json!({ + "agents": (0..=MAX_AGENT_BATCH_SIZE) + .map(|index| json!({"name": format!("reviewer-{index}"), "text": "review"})) + .collect::>(), + }); + let error = parse_python_agent_batch(&too_many, "1").unwrap_err(); + assert!(error.to_string().contains("supports at most")); + } + + #[tokio::test] + async fn bounded_batch_limits_concurrency_and_restores_input_order() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let active = Arc::new(AtomicUsize::new(0)); + let peak = Arc::new(AtomicUsize::new(0)); + let results = run_bounded_ordered(vec![0_u64, 1, 2, 3], 2, { + let active = active.clone(); + let peak = peak.clone(); + move |item| { + let active = active.clone(); + let peak = peak.clone(); + async move { + let now_active = active.fetch_add(1, Ordering::SeqCst) + 1; + peak.fetch_max(now_active, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(4 * (4 - item))).await; + active.fetch_sub(1, Ordering::SeqCst); + if item == 2 { + Err("review failed") + } else { + Ok(item) + } + } + } + }) + .await; + + assert_eq!(results, vec![Ok(0), Ok(1), Err("review failed"), Ok(3)]); + assert_eq!(peak.load(Ordering::SeqCst), 2); + } + #[test] fn parse_worker_concurrency_uses_override_or_default() { // Override wins. @@ -4372,6 +4827,8 @@ mod tests { "reply_broadcast": true, "unfurl_links": true, "unfurl_media": true, + "username": "The Date Goblin", + "icon_emoji": ":female_mage:", }), ); @@ -4382,6 +4839,16 @@ mod tests { assert_eq!(payload["reply_broadcast"], json!(true)); assert_eq!(payload["unfurl_links"], json!(true)); assert_eq!(payload["unfurl_media"], json!(true)); + assert_eq!(payload["username"], json!("The Date Goblin")); + assert_eq!(payload["icon_emoji"], json!(":female_mage:")); + } + + #[test] + fn python_slack_payload_omits_custom_identity_by_default() { + let payload = python_slack_message_payload("C123", "hello", "client-1", &json!({})); + + assert!(payload.get("username").is_none()); + assert!(payload.get("icon_emoji").is_none()); } #[test] @@ -4478,6 +4945,7 @@ mod tests { { "workflow_name": "manual_workflow", "source_path": "workflows/manual_workflow.py", + "principal": "finance-automation", }, ], })) @@ -4495,7 +4963,37 @@ mod tests { metadata.schedules[0].get("workflow_name"), Some(&json!("scheduled_workflow")) ); - assert!(metadata.principals.contains("scheduled_workflow")); + assert_eq!( + metadata.principals.get("scheduled_workflow"), + Some(&WorkflowPrincipalDeclaration::Managed) + ); + assert_eq!( + metadata.principals.get("manual_workflow"), + Some(&WorkflowPrincipalDeclaration::Existing( + "finance-automation".to_owned() + )) + ); + } + + #[test] + fn discovery_metadata_preserves_workflow_principal_oid() { + let payload: PythonWorkflowDiscoveryPayload = serde_json::from_value(json!({ + "workflows": [{ + "workflow_name": "oid_workflow", + "source_path": "workflows/oid_workflow.py", + "principal": " prn_01k2m3n4p5 ", + }], + })) + .unwrap(); + + let metadata = metadata_from_discovery_payload(payload); + + assert_eq!( + metadata.principals.get("oid_workflow"), + Some(&WorkflowPrincipalDeclaration::Existing( + "prn_01k2m3n4p5".to_owned() + )) + ); } #[test] @@ -4553,7 +5051,10 @@ mod tests { #[tokio::test] async fn workflow_principal_requires_workflow_host_sandbox() { let discovery = PythonWorkflowMetadata { - principals: BTreeSet::from(["nightly_report".to_owned()]), + principals: BTreeMap::from([( + "nightly_report".to_owned(), + WorkflowPrincipalDeclaration::Managed, + )]), workflow_names: BTreeSet::from(["nightly_report".to_owned()]), ..PythonWorkflowMetadata::default() }; @@ -4628,6 +5129,52 @@ mod tests { assert_eq!(all[1].key.as_deref(), Some("repository.full_name")); } + #[test] + fn discovery_metadata_preserves_standard_webhooks_auth() { + let payload: PythonWorkflowDiscoveryPayload = serde_json::from_value(json!({ + "workflows": [ + { + "workflow_name": "feed_ingest", + "source_path": "workflows/feed_ingest.py", + "webhooks": [ + { + "workflow_name": "feed_ingest", + "source_path": "workflows/feed_ingest.py", + "spec": { + "slug": "feed-ingest", + "auth": { + "type": "standard_webhooks", + "secret_ref": "FEED_WEBHOOK_SECRET" + }, + "trigger_key": { + "type": "header", + "header": "webhook-id" + } + } + } + ] + } + ], + })) + .unwrap(); + + let metadata = metadata_from_discovery_payload(payload); + let registry = + build_webhook_registry(&metadata, &WorkflowEnablement::allowlist("feed_ingest")) + .unwrap(); + let webhook = registry.get("feed-ingest").unwrap(); + + assert!(matches!( + &webhook.spec.auth, + WorkflowWebhookAuth::StandardWebhooks { secret_ref } + if secret_ref == "FEED_WEBHOOK_SECRET" + )); + assert!(matches!( + &webhook.spec.trigger_key, + Some(WorkflowWebhookTriggerKey::Header { header }) if header == "webhook-id" + )); + } + fn webhook_with_filter(filter: Value) -> RegisteredWorkflowWebhook { RegisteredWorkflowWebhook { workflow_name: "github_issue_triage".to_owned(), @@ -4765,7 +5312,7 @@ mod tests { assert_eq!(metadata.webhooks.len(), 1); assert_eq!(metadata.webhooks[0].workflow_name, "allowed_workflow"); assert_eq!( - metadata.principals.iter().cloned().collect::>(), + metadata.principals.keys().cloned().collect::>(), vec!["allowed_workflow".to_owned()] ); } diff --git a/services/api-rs/rfcs/0003-python-workflow-host.md b/services/api-rs/rfcs/0003-python-workflow-host.md index 3b01e93b8..f74e68293 100644 --- a/services/api-rs/rfcs/0003-python-workflow-host.md +++ b/services/api-rs/rfcs/0003-python-workflow-host.md @@ -96,6 +96,7 @@ Absurd queue: centaur_workflows | +--> ctx.step -> api-rs / Absurd checkpoint RPC +--> ctx.agent_turn -> api-rs SessionRuntime + +--> ctx.run_agents -> bounded parallel SessionRuntime turns +--> ctx.call_tool -> api-rs tool route +--> ctx.post_to_slack +--> ctx._pool -> direct Postgres @@ -200,9 +201,10 @@ While a workflow is running, the host may send requests: {"type":"ctx.step.get","request_id":"1","step":"load_state"} {"type":"ctx.step.put","request_id":"2","step":"load_state","value":{}} {"type":"ctx.agent_turn","request_id":"3","args":{}} -{"type":"ctx.call_tool","request_id":"4","tool":"slack","method":"send_message","args":{}} -{"type":"ctx.post_to_slack","request_id":"5","channel":"C123","text":"hello","args":{}} -{"type":"ctx.log","request_id":"6","message":"workflow_event","fields":{}} +{"type":"ctx.run_agents","request_id":"4","agents":[{"name":"security","text":"Review security"}],"max_concurrency":4} +{"type":"ctx.call_tool","request_id":"5","tool":"slack","method":"send_message","args":{}} +{"type":"ctx.post_to_slack","request_id":"6","channel":"C123","text":"hello","args":{}} +{"type":"ctx.log","request_id":"7","message":"workflow_event","fields":{}} ``` api-rs responds: @@ -229,6 +231,7 @@ class WorkflowContext: async def step(self, name, fn, *, retry=None, timeout=None): ... async def agent_turn(self, text=None, **kwargs): ... + async def run_agents(self, agents, *, max_concurrency=None): ... async def call_tool(self, tool, method, args=None): ... async def post_to_slack(self, channel, text, **kwargs): ... def log(self, message, **fields): ... @@ -275,6 +278,15 @@ Rules: - wait for terminal session result and return the same result shape existing workflows expect +### `ctx.run_agents` + +api-rs handles a named batch as bounded concurrent `ctx.agent_turn` operations. +Each item receives its own workflow-owned thread key, message id, execution +idempotency key, and batch metadata. Results preserve input order and report +individual failures without failing the entire batch. The runtime rejects +duplicate names and caller-supplied session or idempotency fields so two batch +items cannot accidentally serialize through the same session. + ### `ctx.call_tool` api-rs should call the tool runtime and return JSON output. If api-rs tool @@ -300,7 +312,7 @@ The route should: - look up the registered slug - enforce allowed methods and content types -- verify HMAC, GitHub HMAC, or bearer auth +- verify raw-body HMAC, GitHub HMAC, Standard Webhooks, or bearer auth - redact sensitive headers - parse JSON or form payloads - preserve the Python-compatible input envelope diff --git a/services/console/Gemfile b/services/console/Gemfile index 501856ea8..c880b88fb 100644 --- a/services/console/Gemfile +++ b/services/console/Gemfile @@ -8,8 +8,9 @@ gem "propshaft" gem "tailwindcss-rails", "4.6.0" # Use pg as the database for Active Record gem "pg", "~> 1.5" -# Preserve ParadeDB indexes in Rails schema dumps and migrations. -gem "rails-paradedb", "0.7.0", require: "parade_db" +# Preserve ParadeDB indexes in Rails schema dumps and migrations. Versions 0.10+ +# require ParadeDB 0.25+, while Console currently runs ParadeDB 0.23. +gem "rails-paradedb", "0.9.0", require: "parade_db" # Opaque ID encoding (bigint <-> short string) for externally-exposed IDs gem "sqids", "~> 0.2" # JSON Schema validation for jsonb config columns diff --git a/services/console/Gemfile.lock b/services/console/Gemfile.lock index 49af28d27..85c9a8ae4 100644 --- a/services/console/Gemfile.lock +++ b/services/console/Gemfile.lock @@ -82,8 +82,8 @@ GEM bcrypt (3.1.22) bigdecimal (4.1.2) bindex (0.8.1) - bootsnap (1.24.6) - msgpack (~> 1.2) + bootsnap (1.25.0) + msgpack (~> 1.5) brakeman (8.0.6) racc builder (3.3.0) @@ -107,7 +107,7 @@ GEM irb (~> 1.10) reline (>= 0.3.8) drb (2.2.3) - erb (6.0.6) + erb (6.0.7) erubi (1.13.1) et-orbi (1.4.1) tzinfo @@ -131,7 +131,7 @@ GEM actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) - io-console (0.8.2) + io-console (0.9.2) irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) @@ -172,7 +172,7 @@ GEM drb (~> 2.0) prism (~> 1.5) minitest-mock (5.27.0) - msgpack (1.8.3) + msgpack (1.8.4) net-imap (0.6.6) date net-protocol @@ -220,7 +220,7 @@ GEM nio4r (~> 2.0) raabro (1.5.0) racc (1.8.1) - rack (3.2.6) + rack (3.2.7) rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -249,7 +249,7 @@ GEM rails-html-sanitizer (1.7.1) loofah (~> 2.25, >= 2.25.2) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails-paradedb (0.7.0) + rails-paradedb (0.9.0) activerecord (>= 7.2, < 9) activesupport (>= 7.2, < 9) pg (~> 1.5) @@ -265,7 +265,7 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.4.2) - rbs (4.1.2) + rbs (4.1.3) logger prism (>= 1.6.0) tsort @@ -275,7 +275,7 @@ GEM rbs (>= 4.0.0) tsort regexp_parser (2.12.0) - reline (0.6.3) + reline (0.7.0) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) @@ -314,7 +314,7 @@ GEM logger rubyzip (3.4.1) securerandom (0.4.1) - selenium-webdriver (4.46.0) + selenium-webdriver (4.47.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) @@ -357,10 +357,10 @@ GEM tailwindcss-ruby (4.3.3-x86_64-linux-gnu) tailwindcss-ruby (4.3.3-x86_64-linux-musl) thor (1.5.0) - thruster (0.1.23) - thruster (0.1.23-aarch64-linux) - thruster (0.1.23-arm64-darwin) - thruster (0.1.23-x86_64-linux) + thruster (0.1.25) + thruster (0.1.25-aarch64-linux) + thruster (0.1.25-arm64-darwin) + thruster (0.1.25-x86_64-linux) timeout (0.6.1) tsort (0.2.0) turbo-rails (2.0.23) @@ -419,7 +419,7 @@ DEPENDENCIES propshaft puma (>= 5.0) rails (~> 8.1.3) - rails-paradedb (= 0.7.0) + rails-paradedb (= 0.9.0) rubocop-rails-omakase ruby-vips (~> 2.2) selenium-webdriver diff --git a/services/console/app/controllers/concerns/console/slack_channel_permission_management.rb b/services/console/app/controllers/concerns/console/slack_channel_permission_management.rb index 4edbd7831..af4c7ecf4 100644 --- a/services/console/app/controllers/concerns/console/slack_channel_permission_management.rb +++ b/services/console/app/controllers/concerns/console/slack_channel_permission_management.rb @@ -8,10 +8,7 @@ def load_slack_channel_permission_form(owner) @slack_channel_catalog = SlackChannelCatalogProvider.fetch @slack_channel_names = @slack_channel_catalog.channels.to_h { |channel| [ channel.id, channel.name ] } @slack_channel_permissions = owner.slack_channel_permissions.ordered - @slack_channel_options = @slack_channel_catalog.channels.map do |channel| - label = "##{channel.name} (#{channel.id}) #{channel.private ? "Private" : "Public"}" - [ label, channel.id ] - end + @slack_channel_options_url = slack_channel_options_url(owner) end def update_slack_channel_permissions_from_form(owner, path, preserve_api_managed_direct_messages: false) @@ -85,5 +82,13 @@ def api_managed_direct_message_rows(owner) .select { |permission| permission.channel_id.to_s.start_with?("D") } .map(&:as_permission_json) end + + def slack_channel_options_url(owner) + case owner + when Principal then console_principal_slack_channel_options_path(owner.oid) + when Role then slack_channel_options_console_role_path(owner.oid) + else raise ArgumentError, "unsupported Slack channel permission owner" + end + end end end diff --git a/services/console/app/controllers/console/slack_channel_options_controller.rb b/services/console/app/controllers/console/slack_channel_options_controller.rb new file mode 100644 index 000000000..7c4c468cd --- /dev/null +++ b/services/console/app/controllers/console/slack_channel_options_controller.rb @@ -0,0 +1,38 @@ +module Console + class SlackChannelOptionsController < ApplicationController + MAX_RESULTS = 20 + + before_action :require_admin + + def index + response.headers["Cache-Control"] = "no-store" + owner = find_owner + result = SlackChannelCatalogProvider.search( + query: params[:q], + limit: MAX_RESULTS, + exclude_ids: owner.slack_channel_permissions.pluck(:channel_id) + ) + + render json: { + options: result.channels.map do |channel| + { + value: channel.id, + label: "##{channel.name}", + description: "#{channel.id} · #{channel.private ? "Private" : "Public"}" + } + end, + error: result.error + } + end + + private + + def find_owner + case params[:owner_type] + when "principal" then Principal.find_by_oid!(params[:id]) + when "role" then Role.find_by_oid!(params[:id]) + else raise ActiveRecord::RecordNotFound + end + end + end +end diff --git a/services/console/app/controllers/console/threads_controller.rb b/services/console/app/controllers/console/threads_controller.rb index 3023e40ab..33e596f6c 100644 --- a/services/console/app/controllers/console/threads_controller.rb +++ b/services/console/app/controllers/console/threads_controller.rb @@ -90,7 +90,7 @@ class Console::ThreadsController < ApplicationController # Pseudo thread key that opens a new-chat composer pane in the split view. NEW_PANE_KEY = "new".freeze - # The composer's model selector, in display order. Each entry pins the + # The composer's built-in model selector, in display order. Each entry pins the # harness the choice runs on (wire values match api-rs's HarnessType enum, # serde lowercase); the model ids are the ones the bots' --model flags # expand to (services/slackbotv2/src/overrides.ts). Amp appears as a plain @@ -99,7 +99,7 @@ class Console::ThreadsController < ApplicationController # Claude Opus 5's `fast` choice, which selects OpenRouter's native fast model # variant. Codex's enum lives in crates/harness-server/src/codex.rs, with # `max` being 5.6-specific. - ComposerAgent = Struct.new(:value, :label, :harness, :model, :efforts, keyword_init: true) + ComposerAgent = Struct.new(:value, :label, :harness, :model, :provider, :efforts, keyword_init: true) CODEX_EFFORTS = [ %w[minimal Minimal], %w[low Low], @@ -111,8 +111,9 @@ class Console::ThreadsController < ApplicationController [ "claude-opus-5", "fast" ] => "claude-opus-5-fast" }.freeze # First entry doubles as the default pick (unless the deploy's default-model - # resolution for its harness names another listed model). - COMPOSER_AGENTS = [ + # resolution for its harness names another listed model). Operator-configured + # Codex providers are appended by .composer_agents at runtime. + BASE_COMPOSER_AGENTS = [ ComposerAgent.new(value: "gpt-5.6-sol", label: "GPT-5.6 Sol", harness: "codex", model: "gpt-5.6-sol", efforts: CODEX_EFFORTS + [ %w[max Max] ]), @@ -136,6 +137,30 @@ class Console::ThreadsController < ApplicationController harness: "amp", model: nil, efforts: []) ].freeze + def self.custom_provider_agents(raw) + providers = JSON.parse(raw.presence || "{}") + return [] unless providers.is_a?(Hash) + + providers.sort.filter_map do |provider_id, config| + next unless provider_id.match?(/\A[a-z][a-z0-9_-]*\z/) && config.is_a?(Hash) + + label = config["name"].to_s.strip + model = config["defaultModel"].to_s.strip + next if label.blank? || model.blank? + + ComposerAgent.new( + value: "provider:#{provider_id}", label: label, + harness: "codex", model: model, provider: provider_id, efforts: [] + ) + end + rescue JSON::ParserError + [] + end + + def self.composer_agents + BASE_COMPOSER_AGENTS + custom_provider_agents(ENV["CODEX_CUSTOM_PROVIDERS"]) + end + helper_method :thread_title, :thread_source_icon, :thread_source_label, @@ -145,6 +170,7 @@ class Console::ThreadsController < ApplicationController :thread_message_text, :thread_text_preview, :thread_status_classes, + :composer_agents, :composer_agent_choices, :composer_default_agent_value, :composer_agents_json, @@ -304,21 +330,21 @@ def thread_writable?(session) # claims a default the sandbox would not actually run. def composer_agent_choices default_value = composer_default_agent_value - COMPOSER_AGENTS + composer_agents .sort_by.with_index { |agent, index| agent.value == default_value ? -1 : index } .map { |agent| [ agent.label, agent.value ] } end def composer_default_agent_value - default = default_model_for_harness(COMPOSER_AGENTS.first.harness) - COMPOSER_AGENTS.find { |agent| agent.value == default }&.value || - COMPOSER_AGENTS.first.value + default = default_model_for_harness(composer_agents.first.harness) + composer_agents.find { |agent| agent.value == default }&.value || + composer_agents.first.value end # Per-agent metadata the picker script needs to rebuild the effort submenu # when the model changes: { value => { label:, efforts: [[value, label]] } }. def composer_agents_json - COMPOSER_AGENTS.to_h do |agent| + composer_agents.to_h do |agent| [ agent.value, { label: agent.label, efforts: agent.efforts } ] end.to_json end @@ -337,7 +363,11 @@ def composer_model_for(agent, effort) def composer_agent_for(raw) value = raw.to_s.strip value = composer_default_agent_value if value.blank? - COMPOSER_AGENTS.find { |agent| agent.value == value } + composer_agents.find { |agent| agent.value == value } + end + + def composer_agents + self.class.composer_agents end def start_thread(prompt) @@ -357,9 +387,14 @@ def start_thread(prompt) api_client.create_session( thread_key: thread_key, harness_type: agent.harness, - metadata: console_actor_metadata.merge(model.present? ? { model: model } : {}) + metadata: console_actor_metadata + .merge(model.present? ? { model: model } : {}) + .merge(agent.provider.present? ? { provider: agent.provider } : {}) + ) + send_prompt( + thread_key, prompt, + model: model, provider: agent.provider, effort: reasoning ) - send_prompt(thread_key, prompt, model: model, effort: reasoning) # A new-chat pane in a split view swaps the sentinel for the created # thread so the other panes stay open. open_keys = params[:open_threads].to_s.split(",").map(&:strip).reject(&:blank?) @@ -380,7 +415,7 @@ def reply_to_thread(thread_key, prompt) return end - send_prompt(session.thread_key, prompt, model: reply_model_for(session)) + send_prompt(session.thread_key, prompt, **reply_overrides_for(session)) redirect_to console_threads_path(thread: reply_redirect_keys(session.thread_key)) rescue CentaurApiClient::Error => e redirect_to console_threads_path(thread: reply_redirect_keys(thread_key)), @@ -393,7 +428,7 @@ def reply_to_thread(thread_key, prompt) # Append persists the turn in conversation history; execute runs it. The # shared client_message_id lets api-rs dedupe the copy of the message the # harness echoes back. - def send_prompt(thread_key, prompt, model: nil, effort: nil) + def send_prompt(thread_key, prompt, model: nil, provider: nil, effort: nil) message_id = SecureRandom.uuid api_client.append_session_messages( @@ -410,6 +445,7 @@ def send_prompt(thread_key, prompt, model: nil, effort: nil) execute_metadata = console_actor_metadata.merge(action: "execute") execute_metadata[:model] = model if model.present? + execute_metadata[:provider] = provider if provider.present? execute_metadata[:reasoning] = effort if effort.present? api_client.execute_session( thread_key: thread_key, @@ -418,7 +454,7 @@ def send_prompt(thread_key, prompt, model: nil, effort: nil) input_lines: [ composer_input_line( thread_key, prompt, - model: model, effort: effort, client_message_id: message_id + model: model, provider: provider, effort: effort, client_message_id: message_id ) ] ) @@ -429,7 +465,7 @@ def send_prompt(thread_key, prompt, model: nil, effort: nil) # for Amp) the harness runs its own default. `reasoning` is the per-turn # codex effort; other harnesses discard it, and validation upstream only # accepts it for codex models anyway. - def composer_input_line(thread_key, prompt, model:, effort:, client_message_id:) + def composer_input_line(thread_key, prompt, model:, provider:, effort:, client_message_id:) line = { type: "user", thread_key: thread_key, @@ -444,6 +480,7 @@ def composer_input_line(thread_key, prompt, model:, effort:, client_message_id:) } } line[:model] = model if model.present? + line[:provider] = provider if provider.present? line[:reasoning] = effort if effort.present? line.to_json end @@ -478,10 +515,14 @@ def console_requester_context # Follow-ups reuse the model the chat has been running on (mirrors the # display resolution in thread_model_label, minus the upcasing): last # execution's recorded model, session metadata, then the deploy default. - def reply_model_for(session) - recorded_model(latest_executions_for([ session.thread_key ])[session.thread_key]&.metadata) || - recorded_model(session.metadata_hash) || - default_model_for_harness(session.harness_type.to_s) + def reply_overrides_for(session) + execution_metadata = latest_executions_for([ session.thread_key ])[session.thread_key]&.metadata + { + model: recorded_model(execution_metadata) || + recorded_model(session.metadata_hash) || + default_model_for_harness(session.harness_type.to_s), + provider: recorded_provider(execution_metadata) || recorded_provider(session.metadata_hash) + } end # Keeps split-view panes open across a composer submit: the form carries the @@ -1503,6 +1544,12 @@ def recorded_model(metadata) metadata["model"].presence end + def recorded_provider(metadata) + return unless metadata.is_a?(Hash) + + metadata["provider"].presence + end + def default_model_for_harness(harness_type) env_name = HARNESS_DEFAULT_MODEL_ENVS[harness_type] return unless env_name diff --git a/services/console/app/javascript/controllers/slack_channel_autocomplete_controller.js b/services/console/app/javascript/controllers/slack_channel_autocomplete_controller.js new file mode 100644 index 000000000..7dd048f70 --- /dev/null +++ b/services/console/app/javascript/controllers/slack_channel_autocomplete_controller.js @@ -0,0 +1,182 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "value", "list", "status", "submit"] + static values = { url: String } + + connect() { + this.options = [] + this.activeIndex = -1 + this.opened = false + this.selectedDisplay = null + this.updateSubmitState() + } + + disconnect() { + clearTimeout(this.searchTimer) + clearTimeout(this.blurTimer) + this.abortController?.abort() + } + + open() { + clearTimeout(this.blurTimer) + this.opened = true + if (this.inputTarget.value === this.selectedDisplay) this.inputTarget.select() + this.search() + } + + input() { + this.selectedDisplay = null + this.opened = true + this.syncManualChannelId() + clearTimeout(this.searchTimer) + this.searchTimer = setTimeout(() => this.search(), 200) + } + + keydown(event) { + if (event.key === "ArrowDown") { + event.preventDefault() + this.moveActive(1) + } else if (event.key === "ArrowUp") { + event.preventDefault() + this.moveActive(-1) + } else if (event.key === "Enter" && this.activeIndex >= 0) { + event.preventDefault() + this.select(this.options[this.activeIndex]) + } else if (event.key === "Escape") { + this.hide() + } + } + + blur() { + this.blurTimer = setTimeout(() => { + this.opened = false + this.hide() + }, 150) + } + + async search() { + this.abortController?.abort() + this.abortController = new AbortController() + + const preservingSelection = this.inputTarget.value === this.selectedDisplay + if (!preservingSelection) this.setStatus("Loading channels…") + + const query = preservingSelection ? "" : this.inputTarget.value.trim() + this.currentQuery = query + const url = new URL(this.urlValue, window.location.origin) + url.searchParams.set("q", query) + + try { + const response = await fetch(url, { + credentials: "same-origin", + headers: { "Accept": "application/json" }, + signal: this.abortController.signal, + }) + const body = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(body.error || `Request failed with HTTP ${response.status}`) + + this.options = body.options || [] + this.renderOptions() + if (body.error) { + this.setStatus(body.error) + } else if (!preservingSelection) { + this.setStatus(this.resultStatus()) + } + } catch (error) { + if (error.name === "AbortError") return + this.options = [] + this.renderOptions() + this.setStatus(error.message || "Could not load Slack channels.", true) + } + } + + renderOptions() { + this.listTarget.replaceChildren() + this.activeIndex = -1 + + this.options.forEach((option, index) => { + const button = document.createElement("button") + button.type = "button" + button.id = `${this.listTarget.id}_option_${index}` + button.className = "block min-h-11 w-full px-3 py-2 text-left transition-colors hover:bg-centaur-500/[0.08] focus:bg-centaur-500/[0.08] focus:outline-none" + button.setAttribute("role", "option") + button.setAttribute("aria-selected", "false") + button.addEventListener("pointerdown", (event) => event.preventDefault()) + button.addEventListener("click", () => this.select(option)) + + const label = document.createElement("div") + label.className = "text-sm text-zinc-100" + label.textContent = option.label + const description = document.createElement("div") + description.className = "mt-0.5 text-xs text-zinc-500" + description.textContent = option.description + button.append(label, description) + this.listTarget.append(button) + }) + + const visible = this.opened && this.options.length > 0 + this.listTarget.hidden = !visible + this.inputTarget.setAttribute("aria-expanded", String(visible)) + } + + moveActive(delta) { + if (this.options.length === 0) return + this.activeIndex = (this.activeIndex + delta + this.options.length) % this.options.length + + Array.from(this.listTarget.children).forEach((element, index) => { + const active = index === this.activeIndex + element.setAttribute("aria-selected", String(active)) + element.classList.toggle("bg-centaur-500/[0.08]", active) + if (active) { + this.inputTarget.setAttribute("aria-activedescendant", element.id) + element.scrollIntoView({ block: "nearest" }) + } + }) + } + + select(option) { + this.inputTarget.value = `${option.label} (${option.value})` + this.valueTarget.value = option.value + this.selectedDisplay = this.inputTarget.value + this.updateSubmitState() + this.setStatus(`Selected ${option.label}.`) + this.opened = false + this.hide() + } + + syncManualChannelId() { + const value = this.inputTarget.value.trim().toUpperCase() + this.valueTarget.value = /^[CDG][A-Z0-9]{8,}$/.test(value) ? value : "" + this.updateSubmitState() + } + + updateSubmitState() { + const hasInput = this.inputTarget.value.trim() !== "" + const hasChannelId = this.valueTarget.value.trim() !== "" + this.submitTarget.disabled = hasInput && !hasChannelId + } + + resultStatus() { + if (this.options.length === 0) return "No matching channels. You can enter a channel ID directly." + if (this.options.length === 20) { + return this.currentQuery === "" + ? "Showing the first 20 channels. Type to search all channels." + : "Showing the first 20 matching channels. Keep typing to narrow the results." + } + return `${this.options.length} matching channel${this.options.length === 1 ? "" : "s"}.` + } + + setStatus(message, error = false) { + this.statusTarget.textContent = message + this.statusTarget.classList.toggle("text-red-300", error) + this.statusTarget.classList.toggle("text-zinc-500", !error) + } + + hide() { + this.listTarget.hidden = true + this.inputTarget.setAttribute("aria-expanded", "false") + this.inputTarget.removeAttribute("aria-activedescendant") + this.activeIndex = -1 + } +} diff --git a/services/console/app/jobs/slack_dm/sync_credential_job.rb b/services/console/app/jobs/slack_dm/sync_credential_job.rb index c23d74397..735f07b3a 100644 --- a/services/console/app/jobs/slack_dm/sync_credential_job.rb +++ b/services/console/app/jobs/slack_dm/sync_credential_job.rb @@ -1,6 +1,6 @@ module SlackDm class SyncCredentialJob < ApplicationJob - MAX_RATE_LIMIT_EXECUTIONS = 2 + MAX_RETRYABLE_EXECUTIONS = 2 queue_as :default @@ -15,10 +15,10 @@ def perform(credential_id) return unless SlackDm::SyncCredential.required_scopes_granted?(credential.scopes) SlackDm::SyncCredential.new(credential).call - rescue SlackDm::SyncCredential::RateLimitedError => e - if executions >= MAX_RATE_LIMIT_EXECUTIONS + rescue SlackDm::SyncCredential::RetryableApiError => e + if executions >= MAX_RETRYABLE_EXECUTIONS Rails.logger.warn do - "Slack sync job dropped after repeated rate limits: " \ + "Slack sync job dropped after repeated retryable API failures: " \ "credential_id=#{credential_id} executions=#{executions}" end return diff --git a/services/console/app/services/slack_channel_catalog_provider.rb b/services/console/app/services/slack_channel_catalog_provider.rb index 06646fce6..bcca2240c 100644 --- a/services/console/app/services/slack_channel_catalog_provider.rb +++ b/services/console/app/services/slack_channel_catalog_provider.rb @@ -1,8 +1,7 @@ require "digest" class SlackChannelCatalogProvider - FRESH_TTL = 5.minutes - STALE_TTL = 24.hours + FRESH_TTL = 1.hour ERROR_TTL = 30.seconds REFRESH_LOCK_TTL = 1.minute @@ -18,6 +17,24 @@ def fetch payload ? deserialize_result(payload) : loading_result end + def search(query:, limit:, exclude_ids: []) + result = fetch + excluded = Array(exclude_ids).index_with(true) + needle = query.to_s.strip.downcase + channels = result.channels.reject { |channel| excluded.key?(channel.id) } + if needle.present? + channels = channels.select do |channel| + channel.name.downcase.include?(needle) || channel.id.downcase.include?(needle) + end + end + + SlackChannelCatalog::Result.new( + channels: channels.first(limit), + error: result.error, + configured: result.configured + ) + end + def refresh(cache_key:) config = configuration return unless config && cache_key == self.cache_key(**config) @@ -25,7 +42,7 @@ def refresh(cache_key:) cached = Rails.cache.read(cache_key) result = SlackChannelCatalog.new(**config).fetch if result.ok? - Rails.cache.write(cache_key, serialize_result(result), expires_in: STALE_TTL) + Rails.cache.write(cache_key, serialize_result(result)) elsif cached.nil? Rails.cache.write(cache_key, serialize_result(result), expires_in: ERROR_TTL) end diff --git a/services/console/app/services/slack_dm/sync_credential.rb b/services/console/app/services/slack_dm/sync_credential.rb index a58cc9de9..94e0c961e 100644 --- a/services/console/app/services/slack_dm/sync_credential.rb +++ b/services/console/app/services/slack_dm/sync_credential.rb @@ -13,17 +13,22 @@ class SyncCredential CONVERSATIONS_HISTORY_ENDPOINT = "https://slack.com/api/conversations.history" CONVERSATIONS_REPLIES_ENDPOINT = "https://slack.com/api/conversations.replies" API_READ_TIMEOUT_SECONDS = 120 + TRANSIENT_API_ERRORS = %w[fatal_error internal_error].freeze + TRANSIENT_API_RETRY_AFTER_SECONDS = 30 SlackApiError = Class.new(StandardError) - class RateLimitedError < SlackApiError + class RetryableApiError < SlackApiError attr_reader :retry_after def initialize(retry_after:) @retry_after = retry_after - super("Slack API rate limited; retry after #{retry_after} seconds") + super("Slack API request is retryable after #{retry_after} seconds") end end + class RateLimitedError < RetryableApiError; end + class TransientApiError < RetryableApiError; end + class << self attr_accessor :slack_api_http @@ -71,7 +76,7 @@ def call sync_history(conversation, home_team_id, checkpoints[conversation.fetch("id")], batch) batch[:run][:conversations_synced] += 1 rescue StandardError => e - raise if e.is_a?(RateLimitedError) + raise if e.is_a?(RetryableApiError) raise if Rails.env.test? batch[:run][:conversations_failed] += 1 @@ -347,6 +352,9 @@ def slack_api(endpoint, params = {}) parsed = response.json raise SlackApiError, "Slack API returned HTTP #{response.status}" unless response.success? + if TRANSIENT_API_ERRORS.include?(parsed["error"]) + raise TransientApiError.new(retry_after: TRANSIENT_API_RETRY_AFTER_SECONDS) + end raise SlackApiError, "Slack API returned #{parsed['error']}" unless parsed["ok"] == true parsed diff --git a/services/console/app/views/console/shared/_slack_channel_permissions.html.erb b/services/console/app/views/console/shared/_slack_channel_permissions.html.erb index cd2ec4d4d..721f0d309 100644 --- a/services/console/app/views/console/shared/_slack_channel_permissions.html.erb +++ b/services/console/app/views/console/shared/_slack_channel_permissions.html.erb @@ -3,14 +3,14 @@ <% inherited_permissions = local_assigns.fetch(:inherited_permissions, []) %> <% api_managed_direct_messages = local_assigns.fetch(:api_managed_direct_messages, false) %> <% checkbox_class = "mt-1 h-4 w-4 rounded border-ink-500 bg-ink-800 text-centaur-500 focus:ring-centaur-500" %> -<% channel_options = @slack_channel_options || [] %> <% permissions = @slack_channel_permissions || [] %> +<% picker_id = dom_id(owner, :slack_channel_picker) %>

Slack Channel Permissions

-
+
<%= form_with model: owner, url: url, method: :patch, @@ -99,30 +99,61 @@ <% end %> <% new_permission = SlackChannelPermission.new(SlackChannelPermission::DEFAULT_ENABLED_ATTRIBUTES) %> - <%= form.fields_for :slack_channel_permissions, new_permission do |permission_fields| %> -
- - <% SlackChannelPermission::PERMISSION_FLAGS.each do |flag| %> -