From 8497953890239793ae0878603d2aac1dd6e1f7d1 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Tue, 18 Aug 2026 19:24:48 +0000 Subject: [PATCH 01/36] feat: run workflow agents in parallel (#1420) * feat: run workflow agents in parallel * docs: align parallel agent result example --- docs/pages/extend/workflows-v2.mdx | 17 + docs/pages/extend/workflows.mdx | 51 ++- .../crates/centaur-workflows/src/lib.rs | 372 +++++++++++++++++- .../api-rs/rfcs/0003-python-workflow-host.md | 18 +- .../workflow-python/api/workflow_engine.py | 24 ++ .../tests/test_workflow_host.py | 149 +++++++ 6 files changed, 615 insertions(+), 16 deletions(-) diff --git a/docs/pages/extend/workflows-v2.mdx b/docs/pages/extend/workflows-v2.mdx index 95a736142..8149ce104 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` | @@ -134,6 +135,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 diff --git a/docs/pages/extend/workflows.mdx b/docs/pages/extend/workflows.mdx index e349e4132..0f03e6966 100644 --- a/docs/pages/extend/workflows.mdx +++ b/docs/pages/extend/workflows.mdx @@ -83,10 +83,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 +97,52 @@ 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, +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. + ## Run a workflow Direct administrative API calls need a short-lived Console service token. Mint diff --git a/services/api-rs/crates/centaur-workflows/src/lib.rs b/services/api-rs/crates/centaur-workflows/src/lib.rs index f6887ba02..e1ba67302 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"; @@ -3348,7 +3353,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 +3525,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 +3556,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 @@ -3625,6 +3648,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() @@ -4200,6 +4431,133 @@ mod tests { assert_eq!(first_str_arg(&json!({"model": " "}), &["model"]), None); } + #[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", + "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 + .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. diff --git a/services/api-rs/rfcs/0003-python-workflow-host.md b/services/api-rs/rfcs/0003-python-workflow-host.md index 3b01e93b8..f7b1dfa00 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 diff --git a/services/workflow-python/api/workflow_engine.py b/services/workflow-python/api/workflow_engine.py index b9e9c5a61..9e6d426ce 100644 --- a/services/workflow-python/api/workflow_engine.py +++ b/services/workflow-python/api/workflow_engine.py @@ -138,6 +138,30 @@ async def run_agent(self, *args: Any, text: str | None = None, **kwargs: Any) -> async def start_agent(self, *args: Any, text: str | None = None, **kwargs: Any) -> Any: return await self.run_agent(*args, text=text, **kwargs) + async def run_agents( + self, + agents: list[dict[str, Any]], + *, + max_concurrency: int | None = None, + ) -> dict[str, Any]: + """Run named agent turns concurrently and return every outcome in input order.""" + if not isinstance(agents, list): + raise TypeError("run_agents agents must be a list") + + normalized_agents: list[dict[str, Any]] = [] + for index, agent in enumerate(agents): + if not isinstance(agent, dict): + raise TypeError(f"run_agents agent at index {index} must be a dict") + normalized_agents.append({**self._agent_defaults, **agent}) + + request: dict[str, Any] = { + "type": "ctx.run_agents", + "agents": normalized_agents, + } + if max_concurrency is not None: + request["max_concurrency"] = max_concurrency + return await self._rpc.request(request) + async def start_workflow( self, workflow_name: str, diff --git a/services/workflow-python/tests/test_workflow_host.py b/services/workflow-python/tests/test_workflow_host.py index a80a91f1c..590915e40 100644 --- a/services/workflow-python/tests/test_workflow_host.py +++ b/services/workflow-python/tests/test_workflow_host.py @@ -65,6 +65,20 @@ async def request(self, payload): } if message_type == "ctx.agent_turn": return payload["args"] + if message_type == "ctx.run_agents": + return { + "results": [ + { + "index": index, + "name": agent["name"], + "ok": True, + "result": agent, + } + for index, agent in enumerate(payload["agents"]) + ], + "succeeded": len(payload["agents"]), + "failed": 0, + } if message_type == "ctx.workflow.start": return { "workflow_name": payload["workflow_name"], @@ -298,6 +312,77 @@ def test_agent_turn_per_call_kwargs_override_agent_defaults(self) -> None: {"model": "claude-opus-4-8", "reasoning": "low", "text": "cheap step"}, ) + def test_run_agents_applies_defaults_and_preserves_input_order(self) -> None: + host = load_workflow_host() + rpc = RequestRpc() + ctx = host.WorkflowContext( + rpc, + run_id="run-123", + task_id="task-456", + workflow_name="sample", + agent_defaults={"harness": "codex", "reasoning": "high"}, + ) + + result = asyncio.run( + ctx.run_agents( + [ + {"name": "correctness", "text": "Review correctness"}, + { + "name": "security", + "text": "Review security", + "reasoning": "medium", + }, + ], + max_concurrency=2, + ) + ) + + self.assertEqual(rpc.requests[-1]["type"], "ctx.run_agents") + self.assertEqual(rpc.requests[-1]["max_concurrency"], 2) + self.assertEqual( + rpc.requests[-1]["agents"], + [ + { + "harness": "codex", + "reasoning": "high", + "name": "correctness", + "text": "Review correctness", + }, + { + "harness": "codex", + "reasoning": "medium", + "name": "security", + "text": "Review security", + }, + ], + ) + self.assertEqual( + [item["name"] for item in result["results"]], + ["correctness", "security"], + ) + + def test_run_agents_rejects_non_mapping_items_before_rpc(self) -> None: + host = load_workflow_host() + rpc = RequestRpc() + ctx = host.WorkflowContext( + rpc, + run_id="run-123", + task_id="task-456", + workflow_name="sample", + ) + + with self.assertRaisesRegex(TypeError, "agent at index 1 must be a dict"): + asyncio.run( + ctx.run_agents( + [ + {"name": "correctness", "text": "Review correctness"}, + "not-an-agent", # type: ignore[list-item] + ] + ) + ) + + self.assertEqual(rpc.requests, []) + def test_start_workflow_enqueues_durable_child_with_idempotency_key(self) -> None: host = load_workflow_host() rpc = RequestRpc() @@ -654,6 +739,70 @@ def test_workflow_host_returns_result_after_context_response(self) -> None: assert proc.stderr is not None self.assertEqual(proc.stderr.read(), "") + def test_workflow_host_round_trips_agent_batch_result(self) -> None: + source = ( + "WORKFLOW_NAME = 'review_workflow'\n" + "async def handler(inp, ctx):\n" + " return await ctx.run_agents([\n" + " {'name': 'correctness', 'text': 'Review correctness'},\n" + " {'name': 'security', 'text': 'Review security'},\n" + " ], max_concurrency=2)\n" + ) + with self.workflow_host(source) as proc: + self.send_host_message( + proc, + { + "type": "workflow.start", + "run_id": "run-123", + "task_id": "task-456", + "workflow_name": "review_workflow", + "input": {}, + }, + ) + + request = self.read_host_message(proc) + self.assertEqual(request["type"], "ctx.run_agents") + self.assertEqual(request["max_concurrency"], 2) + self.assertEqual( + [agent["name"] for agent in request["agents"]], + ["correctness", "security"], + ) + result = { + "results": [ + { + "index": 0, + "name": "correctness", + "ok": True, + "result": {"result_text": "looks good"}, + }, + { + "index": 1, + "name": "security", + "ok": False, + "error": "agent unavailable", + }, + ], + "succeeded": 1, + "failed": 1, + } + self.send_host_message( + proc, + { + "type": "ctx.response", + "request_id": request["request_id"], + "ok": True, + "value": result, + }, + ) + + response = self.read_host_message(proc) + self.assertEqual(response["type"], "workflow.result") + self.assertEqual(response["result"], result) + proc.wait(timeout=2) + self.assertEqual(proc.returncode, 0) + assert proc.stderr is not None + self.assertEqual(proc.stderr.read(), "") + def test_workflow_host_returns_error_after_failed_context_response(self) -> None: source = ( "WORKFLOW_NAME = 'agent_workflow'\n" From be75196a39a0e4dda4a487d872a8d7b9ba69015f Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:25:25 +0000 Subject: [PATCH 02/36] fix(slack): omit default upload comments (#1422) --- tools/productivity/slack/client.py | 2 -- tools/productivity/slack/tests/test_client.py | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tools/productivity/slack/client.py b/tools/productivity/slack/client.py index 9ef63125d..12f5e0f50 100644 --- a/tools/productivity/slack/client.py +++ b/tools/productivity/slack/client.py @@ -1991,8 +1991,6 @@ def upload_file( ) if comment: kwargs["initial_comment"] = comment - elif effective_filename: - kwargs["initial_comment"] = f"Uploaded `{effective_filename}`." if effective_thread_ts: kwargs["thread_ts"] = effective_thread_ts # We intentionally do NOT forward alt_text to Slack. Passing alt_txt diff --git a/tools/productivity/slack/tests/test_client.py b/tools/productivity/slack/tests/test_client.py index eef5c598b..3b94791e2 100644 --- a/tools/productivity/slack/tests/test_client.py +++ b/tools/productivity/slack/tests/test_client.py @@ -1355,6 +1355,22 @@ def test_upload_file_accepts_channel_id_alias_and_returns_preview() -> None: "csv_rows_sampled": 1, "csv_columns": 2, } + assert "initial_comment" not in fake_web_client.last_kwargs + + +def test_upload_file_preserves_explicit_comment() -> None: + client, fake_web_client = _make_client() + + client.upload_file( + channel_id="C123", + thread_ts="1780035646.228899", + content_base64="dGVzdA==", + filename="chart.png", + comment="Here is the chart.", + ) + + assert fake_web_client.last_kwargs is not None + assert fake_web_client.last_kwargs["initial_comment"] == "Here is the chart." def test_upload_file_uses_explicit_destination() -> None: From b28604bef17268ddd3d8aa7d8907712a2054fecd Mon Sep 17 00:00:00 2001 From: Oliver Ponder Date: Tue, 18 Aug 2026 19:28:17 +0000 Subject: [PATCH 03/36] feat(githubbot): ack review-feedback turns with reactions on the review (#1414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(githubbot): ack owned-PR management turns with a working reaction (PE-8082) Review-request and issue-work turns already ack instantly (eyes on the subject, settled to rocket/confused when the turn finishes), but owned-PR management turns — address-review, CI-fix, conflict resolution — gave no signal until the agent pushed or replied. A reviewer leaving feedback on a bot-owned PR saw silence while the turn ran. Fire the same subject-reaction lifecycle from fireManagementTurn, the choke point all management turns flow through: eyes before the turn starts (not awaited, so the ack can't delay the turn), settled in the background chain. Co-Authored-By: Claude Fable 5 * feat(githubbot): move the review ack onto the review itself via GraphQL Live testing showed the PR-top-post ack goes unnoticed — reviewers look at their own review, and a reaction on the PR description isn't clearly tied to anything. The REST reactions API has no endpoint for reviews, so the ack now goes through the GraphQL addReaction mutation with the review's node id: eyes lands on the reviewer's review, settled to rocket/confused there. Turns with no triggering review (CI-fix, conflict resolution) no longer react at all. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- services/githubbot/src/pr-manager.ts | 53 ++++++++++++--- services/githubbot/src/reactions.ts | 44 ++++++++++++ services/githubbot/test/pr-manager.test.ts | 79 ++++++++++++++++++++++ 3 files changed, 166 insertions(+), 10 deletions(-) diff --git a/services/githubbot/src/pr-manager.ts b/services/githubbot/src/pr-manager.ts index ebabc3cd0..98edf4743 100644 --- a/services/githubbot/src/pr-manager.ts +++ b/services/githubbot/src/pr-manager.ts @@ -1,6 +1,7 @@ import type { GitHubAdapter } from "@chat-adapter/github"; import type { StateAdapter } from "chat"; import { backgroundWaitUntil } from "./context"; +import { reactWorkingOnReview, settleReviewReaction } from "./reactions"; import { runTurnStream } from "./turn"; import { fetchCiEvaluation, @@ -369,7 +370,11 @@ export async function handleReviewEvent( return; } if (reviewState === "changes_requested" || reviewState === "commented") { - fireAddressReviewTurn(ctx, repo.owner, repo.repo, pr, reviewer ?? "the reviewer", reviewId); + fireAddressReviewTurn(ctx, repo.owner, repo.repo, pr, { + reviewer: reviewer ?? "the reviewer", + reviewId, + reviewNodeId: stringValue(reviewNode.node_id), + }); } } @@ -633,9 +638,9 @@ function fireAddressReviewTurn( owner: string, repo: string, pr: PullRequestSummary, - reviewer: string, - reviewId: number, + review: { reviewer: string; reviewId: number; reviewNodeId?: string }, ): void { + const { reviewer, reviewId, reviewNodeId } = review; const preamble = `A review was submitted on pull request ${owner}/${repo}#${pr.number} ` + `(head ${pr.headSha}). Address it as the PR author, working in your sandbox:\n` + @@ -647,11 +652,19 @@ function fireAddressReviewTurn( `explain why, briefly and respectfully. Resolve the threads you've addressed.\n` + `- Re-request review from @${reviewer} once you've pushed.\n` + `- If a request is unclear or you can't address it, say so in the thread and ask.`; - fireManagementTurn(ctx, owner, repo, pr, preamble, { - id: `review-resp-${owner}/${repo}#${pr.number}-${reviewId}`, - label: "address-review", - text: `Address the review on ${owner}/${repo}#${pr.number} from @${reviewer}.`, - }); + fireManagementTurn( + ctx, + owner, + repo, + pr, + preamble, + { + id: `review-resp-${owner}/${repo}#${pr.number}-${reviewId}`, + label: "address-review", + text: `Address the review on ${owner}/${repo}#${pr.number} from @${reviewer}.`, + }, + reviewNodeId, + ); } function fireConflictTurn( @@ -680,6 +693,7 @@ function fireManagementTurn( pr: PullRequestSummary, preamble: string, message: { id: string; label: string; text: string }, + reviewNodeId?: string, ): void { const threadKey = managementThreadKey(owner, repo, pr.number); const trace = makeTrace(threadKey, message.id); @@ -707,19 +721,38 @@ function fireManagementTurn( pr: `${owner}/${repo}#${pr.number}`, work: message.label, }); + // Review-triggered turns ack on the reviewer's own review — instant 👀, + // settled to 🚀/😕 when the turn finishes (same lifecycle as @-mention acks). + // Not awaited: the ack must not delay the turn, and a failed reaction is only + // a missing ack. Turns with no triggering review (CI-fix, conflicts) stay + // silent — a reaction on the PR's top post isn't clearly tied to anything. + if (reviewNodeId) { + void reactWorkingOnReview(ctx.octokit, reviewNodeId, logger(ctx)); + } backgroundWaitUntil( runTurnStream(ctx.options, forwardInput) - .then((result) => { + .then(async (result) => { traceLog(ctx.options, "githubbot_management_turn_complete", trace, { failed: result.failed, work: message.label, }); + if (reviewNodeId) { + await settleReviewReaction( + ctx.octokit, + reviewNodeId, + result.failed, + logger(ctx), + ); + } }) - .catch((error) => { + .catch(async (error) => { logger(ctx).warn("githubbot_management_turn_failed", { error: errorMessage(error), work: message.label, }); + if (reviewNodeId) { + await settleReviewReaction(ctx.octokit, reviewNodeId, true, logger(ctx)); + } }), ); } diff --git a/services/githubbot/src/reactions.ts b/services/githubbot/src/reactions.ts index 330fd432e..b5b38e183 100644 --- a/services/githubbot/src/reactions.ts +++ b/services/githubbot/src/reactions.ts @@ -57,3 +57,47 @@ export async function settleSubjectReaction( }); } } + +// The REST reactions API has no endpoint for PR reviews, so the review-feedback +// ack goes through GraphQL with the review's node id — the reaction lands on +// the reviewer's own review in the timeline, where they look for it. +const ADD_REACTION_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { + addReaction(input: { subjectId: $subjectId, content: $content }) { + clientMutationId + } +}`; + +export async function reactWorkingOnReview( + octokit: Octokit, + reviewNodeId: string, + logger?: Logger, +): Promise { + try { + await octokit.graphql(ADD_REACTION_MUTATION, { + subjectId: reviewNodeId, + content: "EYES", + }); + } catch (error) { + (logger ?? noopLogger).debug("githubbot_review_react_failed", { + error: errorMessage(error), + }); + } +} + +export async function settleReviewReaction( + octokit: Octokit, + reviewNodeId: string, + failed: boolean, + logger?: Logger, +): Promise { + try { + await octokit.graphql(ADD_REACTION_MUTATION, { + subjectId: reviewNodeId, + content: failed ? "CONFUSED" : "ROCKET", + }); + } catch (error) { + (logger ?? noopLogger).debug("githubbot_review_react_settle_failed", { + error: errorMessage(error), + }); + } +} diff --git a/services/githubbot/test/pr-manager.test.ts b/services/githubbot/test/pr-manager.test.ts index d9d618b59..d4606a528 100644 --- a/services/githubbot/test/pr-manager.test.ts +++ b/services/githubbot/test/pr-manager.test.ts @@ -871,3 +871,82 @@ describe("workflow event emission", () => { expect(attempts).toBe(2); }); }); + +describe("management turn reaction ack", () => { + const submittedReview = (state: string, nodeId?: string) => + JSON.stringify({ + action: "submitted", + repository: { full_name: "base/repo" }, + pull_request: { number: 7 }, + review: { + id: 55, + node_id: nodeId, + state, + user: { login: "reviewer" }, + }, + }); + + function reviewCtx(reactions: { subjectId: string; content: string }[]) { + return { + octokit: { + graphql: async ( + _query: string, + vars: { subjectId: string; content: string }, + ) => { + reactions.push({ subjectId: vars.subjectId, content: vars.content }); + return {}; + }, + rest: { + pulls: { + get: async () => ({ + data: prPayload({ headRepoFullName: "base/repo" }), + }), + merge: async () => ({ data: {} }), + }, + git: { deleteRef: async () => ({ data: {} }) }, + }, + }, + options: { + apiUrl: "http://localhost", + deleteBranchOnMerge: false, + logger: quietLogger, + // Non-retryable so the backgrounded turn settles off the network. + fetch: () => Promise.resolve(new Response("no", { status: 400 })), + }, + state: makeState(), + userName: "centaur-bot", + } as unknown as PrManagerContext; + } + + test("acks a changes-requested review with eyes on the review itself, settling when the turn fails", async () => { + const reactions: { subjectId: string; content: string }[] = []; + await handleReviewEvent( + reviewCtx(reactions), + submittedReview("changes_requested", "PRR_test55"), + ); + // The working ack lands before the management turn runs. + expect(reactions).toContainEqual({ subjectId: "PRR_test55", content: "EYES" }); + await drainBackgroundWork(5_000); + expect(reactions).toContainEqual({ + subjectId: "PRR_test55", + content: "CONFUSED", + }); + }); + + test("does not react on an approved review (deterministic merge, no work turn)", async () => { + const reactions: { subjectId: string; content: string }[] = []; + await handleReviewEvent( + reviewCtx(reactions), + submittedReview("approved", "PRR_test55"), + ); + await drainBackgroundWork(5_000); + expect(reactions).toEqual([]); + }); + + test("stays quiet when the payload carries no review node id", async () => { + const reactions: { subjectId: string; content: string }[] = []; + await handleReviewEvent(reviewCtx(reactions), submittedReview("changes_requested")); + await drainBackgroundWork(5_000); + expect(reactions).toEqual([]); + }); +}); From 7bc72e74d6391f03766cd9c27d097b8cf3acd9d9 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Tue, 18 Aug 2026 20:27:20 +0000 Subject: [PATCH 04/36] feat: allow workflows to select principals (#1423) * feat: allow workflows to select principals * fix: prevent session principal rebinding --- docs/pages/extend/workflows-v2.mdx | 28 ++- docs/pages/extend/workflows.mdx | 33 +++- docs/pages/secrets/advanced-permissioning.mdx | 10 +- .../crates/centaur-api-server/src/error.rs | 35 ++++ .../crates/centaur-session-runtime/src/lib.rs | 118 +++++++++++-- .../crates/centaur-session-sqlx/src/lib.rs | 47 ++++++ .../crates/centaur-workflows/src/lib.rs | 159 ++++++++++++++---- .../tests/test_workflow_host.py | 34 ++++ services/workflow-python/workflow_host.py | 9 +- 9 files changed, 417 insertions(+), 56 deletions(-) diff --git a/docs/pages/extend/workflows-v2.mdx b/docs/pages/extend/workflows-v2.mdx index 8149ce104..d0bddd89d 100644 --- a/docs/pages/extend/workflows-v2.mdx +++ b/docs/pages/extend/workflows-v2.mdx @@ -171,9 +171,16 @@ 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: + +```python +WORKFLOW_PRINCIPAL = "finance-automation" +``` + +The string form resolves the existing principal 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. @@ -205,6 +212,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 0f03e6966..c9f5ed1a2 100644 --- a/docs/pages/extend/workflows.mdx +++ b/docs/pages/extend/workflows.mdx @@ -66,11 +66,13 @@ 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` -requires `apiRs.workflowHostSandbox=true`, which renders +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"`, to +run the workflow-host sandbox under that principal instead. An unknown foreign +ID 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. @@ -139,9 +141,24 @@ discarding successful reviews: ``` Batch items accept the same model, provider, reasoning, harness, persona, -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. +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 diff --git a/docs/pages/secrets/advanced-permissioning.mdx b/docs/pages/secrets/advanced-permissioning.mdx index 2eefbbe4e..83064acf2 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. 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/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-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index 59f8b878e..ad3fbf9d9 100644 --- a/services/api-rs/crates/centaur-session-runtime/src/lib.rs +++ b/services/api-rs/crates/centaur-session-runtime/src/lib.rs @@ -1356,6 +1356,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 +1414,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 +1464,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 +1490,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 +9211,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-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 e1ba67302..b12fc6f10 100644 --- a/services/api-rs/crates/centaur-workflows/src/lib.rs +++ b/services/api-rs/crates/centaur-workflows/src/lib.rs @@ -195,7 +195,7 @@ impl WorkflowEnablement { }); metadata .principals - .retain(|workflow_name| self.is_enabled(workflow_name)); + .retain(|workflow_name, _| self.is_enabled(workflow_name)); } } @@ -229,6 +229,12 @@ struct WorkflowPrincipalAssignments { registered: BTreeMap, } +#[derive(Clone, Debug, Eq, PartialEq)] +enum WorkflowPrincipalDeclaration { + Managed, + Existing(String), +} + impl WorkflowPrincipalAssignments { fn principal_for_workflow( &self, @@ -298,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(foreign_id) => { + self.client.get_principal(foreign_id).await? + } + }; registered.insert(workflow_name.clone(), record.id); } Ok(registered) @@ -1657,7 +1669,14 @@ struct PythonWorkflowDiscovery { #[serde(default)] schedule: Option, #[serde(default)] - principal: Option, + principal: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PythonWorkflowPrincipal { + Enabled(bool), + ForeignId(String), } #[derive(Debug, Deserialize)] @@ -1670,7 +1689,7 @@ struct PythonWorkflowMetadata { webhooks: Vec, schedules: Vec, workflow_names: BTreeSet, - principals: BTreeSet, + principals: BTreeMap, } fn metadata_from_discovery_payload( @@ -1693,8 +1712,22 @@ 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::ForeignId(foreign_id)) + if !foreign_id.trim().is_empty() => + { + metadata.principals.insert( + workflow.workflow_name, + WorkflowPrincipalDeclaration::Existing(foreign_id.trim().to_owned()), + ); + } + _ => {} } } metadata @@ -1710,7 +1743,7 @@ async fn prepare_workflow_host_sandbox( if !discovery.principals.is_empty() { let workflow_names = discovery .principals - .iter() + .keys() .cloned() .collect::>() .join(", "); @@ -1737,15 +1770,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(()) } @@ -2661,6 +2695,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(); @@ -2683,6 +2718,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(), @@ -3570,6 +3606,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")) @@ -3599,6 +3636,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) @@ -3630,6 +3674,7 @@ async fn run_python_agent_turn( thread_key, harness_type, persona_id, + principal_foreign_id, parts, client_message_id, session_metadata, @@ -3865,6 +3910,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") @@ -4130,6 +4195,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, @@ -4185,6 +4251,7 @@ async fn run_agent_session_turn( thread_key, harness_type, persona_id, + principal_foreign_id, parts, client_message_id, session_metadata, @@ -4204,12 +4271,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 @@ -4431,6 +4499,21 @@ 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!({ @@ -4440,6 +4523,7 @@ mod tests { { "name": "correctness", "text": "Review correctness", + "principal": "security-reviewers", "metadata": {"pr": 42} }, {"name": "security", "text": "Review security"} @@ -4457,6 +4541,10 @@ mod tests { 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 @@ -4836,6 +4924,7 @@ mod tests { { "workflow_name": "manual_workflow", "source_path": "workflows/manual_workflow.py", + "principal": "finance-automation", }, ], })) @@ -4853,7 +4942,16 @@ 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] @@ -4911,7 +5009,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() }; @@ -5123,7 +5224,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/workflow-python/tests/test_workflow_host.py b/services/workflow-python/tests/test_workflow_host.py index 590915e40..f894a1a5b 100644 --- a/services/workflow-python/tests/test_workflow_host.py +++ b/services/workflow-python/tests/test_workflow_host.py @@ -312,6 +312,25 @@ def test_agent_turn_per_call_kwargs_override_agent_defaults(self) -> None: {"model": "claude-opus-4-8", "reasoning": "low", "text": "cheap step"}, ) + def test_agent_turn_forwards_principal_foreign_id(self) -> None: + host = load_workflow_host() + rpc = RequestRpc() + ctx = host.WorkflowContext( + rpc, + run_id="run-123", + task_id="task-456", + workflow_name="sample", + ) + + result = asyncio.run( + ctx.agent_turn("do the thing", principal="finance-automation") + ) + + self.assertEqual( + result, + {"principal": "finance-automation", "text": "do the thing"}, + ) + def test_run_agents_applies_defaults_and_preserves_input_order(self) -> None: host = load_workflow_host() rpc = RequestRpc() @@ -578,6 +597,21 @@ def test_load_workflow_file_reads_workflow_principal(self) -> None: assert registered is not None self.assertEqual(host.normalize_principal(registered), True) + def test_load_workflow_file_reads_workflow_principal_foreign_id(self) -> None: + host = load_workflow_host() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "principal_workflow.py" + path.write_text( + "WORKFLOW_NAME = 'principal_workflow'\n" + "WORKFLOW_PRINCIPAL = ' finance-automation '\n" + "def handler(inp, ctx):\n" + " return None\n" + ) + registered = host.load_workflow_file(path) + + assert registered is not None + self.assertEqual(host.normalize_principal(registered), "finance-automation") + def test_workflow_name_from_source_reads_string_constant(self) -> None: host = load_workflow_host() with tempfile.TemporaryDirectory() as tmp: diff --git a/services/workflow-python/workflow_host.py b/services/workflow-python/workflow_host.py index fae51bd2e..755e5c5ca 100644 --- a/services/workflow-python/workflow_host.py +++ b/services/workflow-python/workflow_host.py @@ -356,9 +356,14 @@ def normalize_schedule(workflow: RegisteredWorkflow) -> dict[str, Any] | None: return schedule -def normalize_principal(workflow: RegisteredWorkflow) -> bool | None: +def normalize_principal(workflow: RegisteredWorkflow) -> bool | str | None: raw = workflow.principal - return raw if isinstance(raw, bool) and raw else None + if isinstance(raw, bool): + return raw or None + if isinstance(raw, str): + foreign_id = raw.strip() + return foreign_id or None + return None async def run_workflow(message: dict[str, Any], rpc: RpcClient) -> dict[str, Any]: From 98fba269ce0b87a7642499d8d6dee2f909e949e5 Mon Sep 17 00:00:00 2001 From: Oliver Ponder Date: Tue, 18 Aug 2026 20:38:15 +0000 Subject: [PATCH 05/36] fix(sentry): expose read-only client commands through the CLI (#1419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(sentry): expose read-only client commands (fork patch) Re-applies the fork's one genuine change (#13) on top of upstream main b985a365 — the replace-with-upstream sync recipe from #15. Co-authored-by: Claude Fable 5 --- tools/infra/sentry/cli.py | 126 ++++++++++++++++++++++++++++++-- tools/infra/sentry/test_cli.py | 129 +++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 tools/infra/sentry/test_cli.py diff --git a/tools/infra/sentry/cli.py b/tools/infra/sentry/cli.py index 6dc939e1d..15cd2a664 100644 --- a/tools/infra/sentry/cli.py +++ b/tools/infra/sentry/cli.py @@ -1,15 +1,18 @@ -"""CLI for Sentry issues.""" +"""CLI for browsing Sentry issues and events (read-only).""" +import json +from collections.abc import Callable +from typing import Any + +import typer from dotenv import load_dotenv load_dotenv() -import json -import typer - app = typer.Typer( name="sentry", help="Sentry issues — list/search issues, issue details, events, stacktraces, and tag values (read-only)", + no_args_is_help=True, ) @@ -18,12 +21,30 @@ def main() -> None: """sentry CLI.""" +def get_client(): + from .client import _client + + return _client() + + +def _emit(result: Any) -> None: + print(json.dumps(result, indent=2, ensure_ascii=False, default=str)) + + +def _run(operation: Callable[[Any], Any]) -> None: + client = get_client() + try: + _emit(operation(client)) + finally: + close = getattr(client, "close", None) + if callable(close): + close() + + @app.command("health") def health(): """Assert sentry connectivity and auth with a safe read-only check.""" - from .client import _client - - client = _client() + client = get_client() try: details = client.list_organizations() payload = {"ok": True, "tool": "sentry", "error": None, "details": details} @@ -38,5 +59,96 @@ def health(): print(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) +@app.command("list-organizations") +def list_organizations() -> None: + """List Sentry organizations available to the configured token.""" + _run(lambda client: client.list_organizations()) + + +@app.command("list-projects") +def list_projects( + organization_slug: str = typer.Argument(..., help="Sentry organization slug"), +) -> None: + """List projects in a Sentry organization.""" + _run(lambda client: client.list_projects(organization_slug)) + + +@app.command("list-issues") +def list_issues( + organization_slug: str = typer.Argument(..., help="Sentry organization slug"), + project_slug: str | None = typer.Option( + None, "--project", "-p", help="Restrict results to this project slug" + ), + query: str = typer.Option( + "is:unresolved", "--query", "-q", help="Native Sentry issue search query" + ), + sort: str = typer.Option("date", "--sort", help="Sort by date, new, freq, user, or priority"), + stats_period: str = typer.Option( + "14d", "--stats-period", help="Relative search window, e.g. 24h, 14d, or 90d" + ), + limit: int = typer.Option(25, "--limit", "-n", min=1, help="Maximum issues to return"), +) -> None: + """List or search issues using Sentry's native query syntax.""" + _run( + lambda client: client.list_issues( + organization_slug=organization_slug, + project_slug=project_slug, + query=query, + sort=sort, + stats_period=stats_period, + limit=limit, + ) + ) + + +@app.command("get-issue") +def get_issue( + organization_slug: str = typer.Argument(..., help="Sentry organization slug"), + issue_id: str = typer.Argument(..., help="Numeric issue id or short id"), +) -> None: + """Get details for one Sentry issue.""" + _run(lambda client: client.get_issue(organization_slug, issue_id)) + + +@app.command("list-issue-events") +def list_issue_events( + organization_slug: str = typer.Argument(..., help="Sentry organization slug"), + issue_id: str = typer.Argument(..., help="Numeric issue id or short id"), + full: bool = typer.Option(False, "--full", help="Include each full event payload"), + limit: int = typer.Option(25, "--limit", "-n", min=1, help="Maximum events to return"), +) -> None: + """List individual events for a Sentry issue.""" + _run( + lambda client: client.list_issue_events( + organization_slug=organization_slug, + issue_id=issue_id, + full=full, + limit=limit, + ) + ) + + +@app.command("get-event") +def get_event( + organization_slug: str = typer.Argument(..., help="Sentry organization slug"), + issue_id: str = typer.Argument(..., help="Numeric issue id or short id"), + event_id: str = typer.Option( + "latest", "--event-id", "-e", help="Specific event id, latest, or oldest" + ), +) -> None: + """Get one full event, including stacktrace and breadcrumbs.""" + _run(lambda client: client.get_event(organization_slug, issue_id, event_id=event_id)) + + +@app.command("get-issue-tag-values") +def get_issue_tag_values( + organization_slug: str = typer.Argument(..., help="Sentry organization slug"), + issue_id: str = typer.Argument(..., help="Numeric issue id or short id"), + tag_key: str = typer.Argument(..., help="Tag key, e.g. release or browser"), +) -> None: + """Get the value distribution for one tag on a Sentry issue.""" + _run(lambda client: client.get_issue_tag_values(organization_slug, issue_id, tag_key)) + + if __name__ == "__main__": app() diff --git a/tools/infra/sentry/test_cli.py b/tools/infra/sentry/test_cli.py new file mode 100644 index 000000000..77cef000e --- /dev/null +++ b/tools/infra/sentry/test_cli.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import types +from pathlib import Path + +from typer.testing import CliRunner + +package = types.ModuleType("centaur_tool_sentry") +package.__path__ = [str(Path(__file__).parent)] +sys.modules.setdefault("centaur_tool_sentry", package) + +spec = importlib.util.spec_from_file_location( + "centaur_tool_sentry.cli", Path(__file__).with_name("cli.py") +) +assert spec and spec.loader +cli = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = cli +spec.loader.exec_module(cli) + + +class FakeClient: + def __init__(self) -> None: + self.calls: list[tuple[str, tuple, dict]] = [] + self.closed = 0 + + def _record(self, method: str, *args, **kwargs): + self.calls.append((method, args, kwargs)) + return {"method": method} + + def list_organizations(self): + return self._record("list_organizations") + + def list_projects(self, organization_slug: str): + return self._record("list_projects", organization_slug) + + def list_issues(self, **kwargs): + return self._record("list_issues", **kwargs) + + def get_issue(self, organization_slug: str, issue_id: str): + return self._record("get_issue", organization_slug, issue_id) + + def list_issue_events(self, **kwargs): + return self._record("list_issue_events", **kwargs) + + def get_event(self, organization_slug: str, issue_id: str, event_id: str): + return self._record("get_event", organization_slug, issue_id, event_id=event_id) + + def get_issue_tag_values(self, organization_slug: str, issue_id: str, tag_key: str): + return self._record("get_issue_tag_values", organization_slug, issue_id, tag_key) + + def close(self) -> None: + self.closed += 1 + + +def test_commands_wrap_existing_client_methods(monkeypatch) -> None: + client = FakeClient() + monkeypatch.setattr(cli, "get_client", lambda: client) + runner = CliRunner() + + invocations = [ + (["list-organizations"], "list_organizations"), + (["list-projects", "splits"], "list_projects"), + ( + [ + "list-issues", + "splits", + "--project", + "server", + "--query", + "is:resolved level:error", + "--sort", + "freq", + "--stats-period", + "24h", + "--limit", + "10", + ], + "list_issues", + ), + (["get-issue", "splits", "SERVER-1"], "get_issue"), + ( + ["list-issue-events", "splits", "SERVER-1", "--full", "--limit", "5"], + "list_issue_events", + ), + (["get-event", "splits", "SERVER-1", "--event-id", "deadbeef"], "get_event"), + ( + ["get-issue-tag-values", "splits", "SERVER-1", "release"], + "get_issue_tag_values", + ), + ] + + for args, expected_method in invocations: + result = runner.invoke(cli.app, args) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == {"method": expected_method} + + assert client.calls == [ + ("list_organizations", (), {}), + ("list_projects", ("splits",), {}), + ( + "list_issues", + (), + { + "organization_slug": "splits", + "project_slug": "server", + "query": "is:resolved level:error", + "sort": "freq", + "stats_period": "24h", + "limit": 10, + }, + ), + ("get_issue", ("splits", "SERVER-1"), {}), + ( + "list_issue_events", + (), + { + "organization_slug": "splits", + "issue_id": "SERVER-1", + "full": True, + "limit": 5, + }, + ), + ("get_event", ("splits", "SERVER-1"), {"event_id": "deadbeef"}), + ("get_issue_tag_values", ("splits", "SERVER-1", "release"), {}), + ] + assert client.closed == len(invocations) From 2514c4ece86c66ad4af3f3149881328160fe3fe7 Mon Sep 17 00:00:00 2001 From: Ksawery Date: Tue, 18 Aug 2026 20:39:30 +0000 Subject: [PATCH 06/36] feat: add an option to configure OpenAI base URL (#1370) * feat: add an option to configure openAI base url * fix: preserve activity summary endpoint overrides * fix: remove ineffective Codex base URL config * refactor: remove unused OpenAI URL re-export * chore(chart): bump chart version to 0.1.117 --------- Co-authored-by: Matthew Slipper --- contrib/chart/Chart.yaml | 2 +- contrib/chart/values.yaml | 1 + docs/pages/reference/configuration.mdx | 2 + services/api-rs/Cargo.lock | 1 + services/api-rs/Cargo.toml | 1 + .../src/activity_summary.rs | 2 +- .../crates/centaur-api-server/src/args.rs | 68 ++++++++++++++++++- .../crates/centaur-iron-proxy/Cargo.toml | 1 + .../crates/centaur-iron-proxy/src/error.rs | 2 + .../crates/centaur-iron-proxy/src/fragment.rs | 61 ++++++++++++++++- .../src/title_generator.rs | 13 +++- 11 files changed, 146 insertions(+), 8 deletions(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 938aef849..073bd07ef 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.120 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index d8a9a6509..0c97eb736 100644 --- a/contrib/chart/values.yaml +++ b/contrib/chart/values.yaml @@ -485,6 +485,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 diff --git a/docs/pages/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index bc7a42cf5..c9ed75475 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -119,6 +119,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: diff --git a/services/api-rs/Cargo.lock b/services/api-rs/Cargo.lock index ea31e6f9f..27dab10bc 100644 --- a/services/api-rs/Cargo.lock +++ b/services/api-rs/Cargo.lock @@ -939,6 +939,7 @@ dependencies = [ "serde_yaml", "strum", "thiserror 2.0.19", + "url", ] [[package]] diff --git a/services/api-rs/Cargo.toml b/services/api-rs/Cargo.toml index c0354323d..6631c1129 100644 --- a/services/api-rs/Cargo.toml +++ b/services/api-rs/Cargo.toml @@ -89,6 +89,7 @@ strum = { version = "0.28", features = ["derive"] } subtle = "2" 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/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..86393ce6e 100644 --- a/services/api-rs/crates/centaur-api-server/src/args.rs +++ b/services/api-rs/crates/centaur-api-server/src/args.rs @@ -148,6 +148,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 +198,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), @@ -1012,6 +1017,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 +1031,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) { @@ -2193,6 +2204,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(); @@ -2652,6 +2709,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 +2734,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-iron-proxy/Cargo.toml b/services/api-rs/crates/centaur-iron-proxy/Cargo.toml index d0741eae3..0e083ccb9 100644 --- a/services/api-rs/crates/centaur-iron-proxy/Cargo.toml +++ b/services/api-rs/crates/centaur-iron-proxy/Cargo.toml @@ -10,6 +10,7 @@ serde.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..6d9f902ab 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,8 @@ pub enum IronProxyConfigError { path: PathBuf, source: serde_yaml::Error, }, + #[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..c6a28d7f1 100644 --- a/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs +++ b/services/api-rs/crates/centaur-iron-proxy/src/fragment.rs @@ -28,8 +28,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 +43,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 +202,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 +397,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-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; From 31620b52166c18cf843cac21d1d419eaed61c9b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:24:48 +0000 Subject: [PATCH 07/36] chore(deps): bump the api-rs-dependencies group across 1 directory with 6 updates (#1424) Bumps the api-rs-dependencies group with 6 updates in the /services/api-rs directory: | Package | From | To | | --- | --- | --- | | [async-trait](https://github.com/dtolnay/async-trait) | `0.1.91` | `0.1.92` | | [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.6.1` | `1.6.2` | | [eyre](https://github.com/eyre-rs/eyre) | `0.6.12` | `0.6.14` | | [thiserror](https://github.com/dtolnay/thiserror) | `2.0.19` | `2.0.20` | | [uuid](https://github.com/uuid-rs/uuid) | `1.24.0` | `1.24.1` | | [futures](https://github.com/rust-lang/futures-rs) | `0.3.33` | `0.3.34` | Updates `async-trait` from 0.1.91 to 0.1.92 - [Release notes](https://github.com/dtolnay/async-trait/releases) - [Commits](https://github.com/dtolnay/async-trait/compare/0.1.91...0.1.92) Updates `aws-smithy-types` from 1.6.1 to 1.6.2 - [Release notes](https://github.com/smithy-lang/smithy-rs/releases) - [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md) - [Commits](https://github.com/smithy-lang/smithy-rs/commits) Updates `eyre` from 0.6.12 to 0.6.14 - [Commits](https://github.com/eyre-rs/eyre/compare/eyre-v0.6.12...v0.6.14) Updates `thiserror` from 2.0.19 to 2.0.20 - [Release notes](https://github.com/dtolnay/thiserror/releases) - [Commits](https://github.com/dtolnay/thiserror/compare/2.0.19...2.0.20) Updates `uuid` from 1.24.0 to 1.24.1 - [Release notes](https://github.com/uuid-rs/uuid/releases) - [Commits](https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1) Updates `futures` from 0.3.33 to 0.3.34 - [Release notes](https://github.com/rust-lang/futures-rs/releases) - [Changelog](https://github.com/rust-lang/futures-rs/blob/main/CHANGELOG.md) - [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34) --- updated-dependencies: - dependency-name: async-trait dependency-version: 0.1.92 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: api-rs-dependencies - dependency-name: aws-smithy-types dependency-version: 1.6.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: api-rs-dependencies - dependency-name: eyre dependency-version: 0.6.14 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: api-rs-dependencies - dependency-name: thiserror dependency-version: 2.0.20 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: api-rs-dependencies - dependency-name: uuid dependency-version: 1.24.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: api-rs-dependencies - dependency-name: futures dependency-version: 0.3.34 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: api-rs-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- services/api-rs/Cargo.lock | 144 ++++++++++++++++++------------------- 1 file changed, 68 insertions(+), 76 deletions(-) diff --git a/services/api-rs/Cargo.lock b/services/api-rs/Cargo.lock index 27dab10bc..8678d3087 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", @@ -905,7 +905,7 @@ dependencies = [ "sha2 0.11.0", "sqlx", "subtle", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "toml", @@ -926,7 +926,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "urlencoding", ] @@ -938,7 +938,7 @@ dependencies = [ "serde", "serde_yaml", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -982,7 +982,7 @@ version = "0.1.0" dependencies = [ "async-trait", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", ] @@ -1021,7 +1021,7 @@ dependencies = [ "centaur-session-sqlx", "centaur-telemetry", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -1049,7 +1049,7 @@ dependencies = [ "serde", "serde_json", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -1072,7 +1072,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-util", @@ -1088,7 +1088,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "uuid", @@ -1106,7 +1106,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "tracing-opentelemetry", @@ -1132,7 +1132,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tracing", @@ -1559,7 +1559,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]] @@ -1746,7 +1746,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]] @@ -1804,10 +1804,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", ] @@ -1919,9 +1920,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", @@ -1934,9 +1935,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", @@ -1944,15 +1945,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", @@ -1972,38 +1973,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", @@ -2704,7 +2705,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2772,7 +2773,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2811,7 +2812,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" dependencies = [ "hashbrown 0.16.1", "portable-atomic", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2854,7 +2855,7 @@ dependencies = [ "serde", "serde-saphyr", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", "tokio-util", @@ -2878,7 +2879,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3128,7 +3129,7 @@ dependencies = [ "metrics", "metrics-util", "quanta", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3207,7 +3208,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]] @@ -3310,7 +3311,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -3340,7 +3341,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3367,7 +3368,7 @@ dependencies = [ "percent-encoding", "portable-atomic", "rand 0.9.4", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -3761,7 +3762,7 @@ dependencies = [ "rustc-hash", "rustls 0.23.43", "socket2 0.6.4", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -3783,7 +3784,7 @@ dependencies = [ "rustls 0.23.43", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -3800,7 +3801,7 @@ dependencies = [ "once_cell", "socket2 0.6.4", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3933,7 +3934,7 @@ dependencies = [ "palette", "serde", "strum", - "thiserror 2.0.19", + "thiserror 2.0.20", "unicode-segmentation", "unicode-truncate", "unicode-width", @@ -4195,7 +4196,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4266,7 +4267,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4790,7 +4791,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", @@ -4875,7 +4876,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "whoami", @@ -4914,7 +4915,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "whoami", @@ -4940,7 +4941,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", "url", @@ -5175,11 +5176,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]] @@ -5195,9 +5196,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", @@ -5570,7 +5571,7 @@ dependencies = [ "log", "rand 0.9.4", "sha1 0.10.6", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5691,9 +5692,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", @@ -6038,7 +6039,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]] @@ -6124,15 +6125,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" From c05693fd60f983116d4ef5748f99ccf40ff7f77a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:25:06 +0000 Subject: [PATCH 08/36] chore(deps): bump the github-actions group with 3 updates (#1405) Bumps the github-actions group with 3 updates: [dtolnay/rust-toolchain](https://github.com/dtolnay/rust-toolchain), [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [actions/github-script](https://github.com/actions/github-script). Updates `dtolnay/rust-toolchain` from e97e2d8cc328f1b50210efc529dca0028893a2d9 to 6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 - [Commits](https://github.com/dtolnay/rust-toolchain/compare/e97e2d8cc328f1b50210efc529dca0028893a2d9...6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772) Updates `astral-sh/setup-uv` from 9.0.0 to 10.0.1 - [Commits](https://github.com/astral-sh/setup-uv/compare/c771a70e6277c0a99b617c7a806ffedaca235ff9...20cfd1bf945f4377ade1205e4dbc17946fc9a30d) Updates `actions/github-script` from d746ffe35508b1917358783b479e04febd2b8f71 to 3a2844b7e9c422d3c10d287c895573f7108da1b3 - [Commits](https://github.com/actions/github-script/compare/d746ffe35508b1917358783b479e04febd2b8f71...3a2844b7e9c422d3c10d287c895573f7108da1b3) --- updated-dependencies: - dependency-name: dtolnay/rust-toolchain dependency-version: 6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 dependency-type: direct:production dependency-group: github-actions - dependency-name: astral-sh/setup-uv dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/github-script dependency-version: 3a2844b7e9c422d3c10d287c895573f7108da1b3 dependency-type: direct:production dependency-group: github-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/close-stale-draft-prs.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) 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; From 0a3e82ca541df57b68692251f2f4d69e5921da26 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Tue, 18 Aug 2026 21:26:07 +0000 Subject: [PATCH 09/36] feat: add githubkit workflow dependency (#1426) --- services/sandbox/Dockerfile | 1 + services/workflow-python/pyproject.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/services/sandbox/Dockerfile b/services/sandbox/Dockerfile index e2f114643..56a509178 100644 --- a/services/sandbox/Dockerfile +++ b/services/sandbox/Dockerfile @@ -57,6 +57,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ "openai>=2.53.0" \ opentelemetry-proto==1.42.1 \ "psycopg[binary]>=3.2.0" \ + "githubkit>=0.16.1" \ pysocks>=1.7.1 \ rich>=13.0.0 \ slack-sdk==3.39.0 \ diff --git a/services/workflow-python/pyproject.toml b/services/workflow-python/pyproject.toml index 4f2eb2dfa..57685682a 100644 --- a/services/workflow-python/pyproject.toml +++ b/services/workflow-python/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "httpx>=0.28.0", "openai>=2.53.0", "psycopg[binary]>=3.2.0", + "githubkit>=0.16.1", "pysocks>=1.7.1", "rich>=13.0.0", "slack-sdk>=3.39.0", From 300b760c27fa7b08ed79275968c24d29d8015ba6 Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:11:50 +0000 Subject: [PATCH 10/36] fix(slack): stop injecting ETL token in CLI (#628) Co-authored-by: Matthew Slipper --- tools/productivity/slack/pyproject.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tools/productivity/slack/pyproject.toml b/tools/productivity/slack/pyproject.toml index 92c1c4ee3..c552c5960 100644 --- a/tools/productivity/slack/pyproject.toml +++ b/tools/productivity/slack/pyproject.toml @@ -28,9 +28,8 @@ packages = ["."] [tool.centaur] module = "client.py" secrets = [ - {type = "http", name = "SLACK_BOT_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com"]}, + {type = "http", name = "SLACK_BOT_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com", "files.slack.com"]}, ] optional_secrets = [ - {type = "http", name = "SLACK_SEARCH_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com"]}, - {type = "http", name = "SLACK_ETL_TOKEN", match_headers = ["Authorization"], hosts = ["slack.com", "files.slack.com"]}, + {type = "http", name = "SLACK_SEARCH_TOKEN", mode = "inject", inject_header = "Authorization", inject_formatter = "Bearer {{ .Value }}", hosts = ["files.slack.com"]} ] From e24bacf0a0b3456a5b8256e4a5c85c27ab77acc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Wed, 19 Aug 2026 03:54:38 +0000 Subject: [PATCH 11/36] fix(slack): send DMs without im:write scope (#1145) fix: send Slack DMs without im:write scope --- tools/productivity/slack/client.py | 10 +++++++--- tools/productivity/slack/tests/test_client.py | 15 ++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/tools/productivity/slack/client.py b/tools/productivity/slack/client.py index 12f5e0f50..2dc1868bc 100644 --- a/tools/productivity/slack/client.py +++ b/tools/productivity/slack/client.py @@ -586,7 +586,10 @@ def _open_dm_channel(self, user_id: str) -> str: def _resolve_message_destination(self, channel: str) -> str: """Resolve a send_message destination from channel, channel ID, or user ID.""" if self._looks_like_user_id(channel): - return self._open_dm_channel(channel) + # chat.postMessage accepts a user ID directly and opens the bot's + # one-on-one DM when needed. Calling conversations.open first adds + # an unnecessary im:write scope requirement. + return self._clean_user_ref(channel).upper() return self._resolve_channel(channel) def _resolve_mentions(self, text: str, user_cache: dict[str, str]) -> str: @@ -1848,10 +1851,11 @@ def send_message( if unfurl_media is not None: kwargs["unfurl_media"] = unfurl_media response = self._client.chat_postMessage(**kwargs) + response_channel = str(response.get("channel") or channel_id) return { - "channel": channel_id, + "channel": response_channel, "ts": response.get("ts", ""), - "permalink": f"https://slack.com/archives/{channel_id}/p{response.get('ts', '').replace('.', '')}", + "permalink": f"https://slack.com/archives/{response_channel}/p{response.get('ts', '').replace('.', '')}", } except SlackApiError as e: raise RuntimeError(f"Slack API error: {e.response['error']}") from e diff --git a/tools/productivity/slack/tests/test_client.py b/tools/productivity/slack/tests/test_client.py index 3b94791e2..f12a54ed6 100644 --- a/tools/productivity/slack/tests/test_client.py +++ b/tools/productivity/slack/tests/test_client.py @@ -49,7 +49,8 @@ def __init__(self) -> None: def chat_postMessage(self, **kwargs): self.last_kwargs = kwargs - return {"ts": "123.456"} + channel = "D123" if kwargs["channel"].startswith("U") else kwargs["channel"] + return {"channel": channel, "ts": "123.456"} def conversations_history(self, **kwargs): self.history_calls.append(kwargs) @@ -180,27 +181,27 @@ def test_send_message_normalizes_escaped_line_breaks() -> None: assert fake_web_client.last_kwargs["text"] == "*Title*\n- one\n- two" -def test_send_message_opens_dm_for_user_id_destination() -> None: +def test_send_message_posts_directly_to_user_id_without_im_write_scope() -> None: client, fake_web_client = _make_client() result = client.send_message("<@U123ABC>", "hello", no_attribution=True) - assert fake_web_client.open_calls == [{"users": "U123ABC"}] + assert fake_web_client.open_calls == [] assert fake_web_client.last_kwargs is not None - assert fake_web_client.last_kwargs["channel"] == "D123" + assert fake_web_client.last_kwargs["channel"] == "U123ABC" assert fake_web_client.last_kwargs["text"] == "hello" assert result["channel"] == "D123" assert result["permalink"] == "https://slack.com/archives/D123/p123456" -def test_send_dm_opens_dm_and_posts_message() -> None: +def test_send_dm_posts_directly_to_user_id() -> None: client, fake_web_client = _make_client() client.send_dm("U234ABC", "hello", no_attribution=True, unfurl_links=False) - assert fake_web_client.open_calls == [{"users": "U234ABC"}] + assert fake_web_client.open_calls == [] assert fake_web_client.last_kwargs is not None - assert fake_web_client.last_kwargs["channel"] == "D123" + assert fake_web_client.last_kwargs["channel"] == "U234ABC" assert fake_web_client.last_kwargs["unfurl_links"] is False From 46363a0d2fd5ac31c07776d146f2f05d5161b448 Mon Sep 17 00:00:00 2001 From: Osraka <98612432+Osraka@users.noreply.github.com> Date: Wed, 19 Aug 2026 03:59:14 +0000 Subject: [PATCH 12/36] fix(mcp): include tool-host correlation context in errors (#992) Co-authored-by: Matthew Slipper --- .../crates/centaur-api-server/src/mcp.rs | 78 +++++++++++++++++-- .../crates/centaur-session-runtime/src/lib.rs | 39 ++++++++-- 2 files changed, 105 insertions(+), 12 deletions(-) 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-session-runtime/src/lib.rs b/services/api-rs/crates/centaur-session-runtime/src/lib.rs index ad3fbf9d9..7a7f4f25d 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, @@ -1051,7 +1053,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 +1064,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 +1100,7 @@ impl SessionRuntime { &self, thread_key: &ThreadKey, execution_id: &str, + request_id: &str, response_timeout: Duration, ) -> Result { let events = self @@ -1104,10 +1112,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 +1139,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 +1160,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 +1182,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 +1196,8 @@ impl SessionRuntime { &self, thread_key: &ThreadKey, event: &SessionEvent, + execution_id: &str, + request_id: &str, ) -> Result { let error = event .payload @@ -1184,6 +1211,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, From e084069b9b5ab81b1c2d3957d4c08209d73faa65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Ribeiro?= Date: Wed, 19 Aug 2026 04:29:03 +0000 Subject: [PATCH 13/36] fix(chart): allow explicit Console service hosts (#1144) fix: allow explicit Console service hosts Co-authored-by: Matthew Slipper --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/console.yaml | 4 ++++ contrib/chart/values.schema.json | 5 +++++ contrib/chart/values.yaml | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index 073bd07ef..d4a0885e5 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.120 +version: 0.1.121 appVersion: "0.1.0" dependencies: - name: connect diff --git a/contrib/chart/templates/console.yaml b/contrib/chart/templates/console.yaml index 1d7fe9847..6b4cbb4ca 100644 --- a/contrib/chart/templates/console.yaml +++ b/contrib/chart/templates/console.yaml @@ -186,6 +186,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/values.schema.json b/contrib/chart/values.schema.json index 95a6c0878..4b66d6859 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": { diff --git a/contrib/chart/values.yaml b/contrib/chart/values.yaml index 0c97eb736..9e270efd1 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: "" From 80450d07b19d55b35cec3754abc2823954350641 Mon Sep 17 00:00:00 2001 From: akandic47 <57039429+akandic47@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:37:02 +0000 Subject: [PATCH 14/36] docs: drop dead secret keys; fix stale proxy-fallback comment (#998) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: drop dead secret keys; fix stale proxy-fallback comment SANDBOX_SIGNING_KEY, LOCAL_DEV_API_KEY, and IRON_BROKER_TOKEN have no consumers left on main — they are python-era / standalone-token-broker leftovers. Remove them from the secret tables and examples in the docs (and the matching lines in the docs/public/md mirrors) and stop seeding them in bootstrap-k8s-secrets.sh. Also fix the IRON_CONTROL_URL comment in the api-rs template: there is no static-proxy-config fallback; api-rs fails fast when iron-proxy is configured without iron-control. Closes #996 * docs: correct broker credential setup --------- Co-authored-by: Matthew Slipper --- contrib/scripts/bootstrap-k8s-secrets.sh | 8 --- docs/pages/deploying-in-production.mdx | 85 +++++++++++------------- docs/pages/extend/tools.mdx | 23 ++++--- docs/pages/reference/configuration.mdx | 6 +- docs/pages/secrets/environment.mdx | 1 - docs/pages/secrets/onepassword.mdx | 1 - services/sandbox/entrypoint.sh | 4 +- 7 files changed, 52 insertions(+), 76 deletions(-) diff --git a/contrib/scripts/bootstrap-k8s-secrets.sh b/contrib/scripts/bootstrap-k8s-secrets.sh index b1cb61abe..510f4140e 100755 --- a/contrib/scripts/bootstrap-k8s-secrets.sh +++ b/contrib/scripts/bootstrap-k8s-secrets.sh @@ -198,12 +198,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 +309,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/docs/pages/deploying-in-production.mdx b/docs/pages/deploying-in-production.mdx index 319744bd5..3431a804e 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. | @@ -155,32 +154,26 @@ Codex supports two authentication modes, selected per deployment with the | 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` | +| `access_token` | `chatgpt.com` | Console broker credential `openai-codex`; `OPENAI_CODEX_ACCOUNT_ID` | `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 holds the refresh token and its background worker mints +and refreshes short-lived access tokens. Proxy sync delivers the current token +to iron-proxy, which injects it on outbound requests so the sandbox never sees +it. + +Run `codex login` locally, then create a Console broker credential with: + +- Foreign ID: `openai-codex`. +- Token endpoint: `https://auth.openai.com/oauth/token`. +- Client ID: `app_EMoamEEZ73f0CkXaXp7hrann`. +- Refresh token: the value from `~/.codex/auth.json`. + +The Console encrypts the refresh token at rest and updates it after every +rotation. Store `OPENAI_CODEX_ACCOUNT_ID`, the ChatGPT account UUID from the +same login file, in the configured secrets backend. iron-proxy injects it as +the `chatgpt-account-id` header so the backend can route to the correct +workspace. ### Claude Auth Modes @@ -201,31 +194,27 @@ with the `CLAUDE_CODE_AUTH_MODE` env var on the sandbox (set it via | 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` | +| `access_token` | `api.anthropic.com` | Console broker credential `anthropic-claude` | `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 holds the refresh token and +its background worker mints and refreshes short-lived access tokens. Proxy sync +delivers the current token to iron-proxy, which injects it on outbound requests +so the sandbox never sees it. The entrypoint plants a dummy +`~/.claude/.credentials.json` so the CLI emits OAuth-shaped requests; +iron-proxy overwrites the Bearer at request time. + +Run `claude login` locally, then create a Console broker credential with: + +- Foreign ID: `anthropic-claude`. +- Token endpoint: `https://console.anthropic.com/v1/oauth/token`. +- Client ID: `9d1c250a-e61b-44d9-88ed-5944d1962f5e`. +- Refresh token: the value from `~/.claude/.credentials.json`, or from the + `Claude Code-credentials` keychain item on macOS. + +The Console encrypts the refresh token at rest and updates it after every +rotation. No Claude OAuth credential needs to be copied into the deployment's +environment or 1Password secret source. ## 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/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index c9ed75475..e87106f0c 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. | @@ -226,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. | 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/services/sandbox/entrypoint.sh b/services/sandbox/entrypoint.sh index 81998ad5c..a16720554 100644 --- a/services/sandbox/entrypoint.sh +++ b/services/sandbox/entrypoint.sh @@ -324,8 +324,8 @@ fi # - access_token: Claude Code runs as a Claude.ai Pro or Max subscription # user. We install a dummy ~/.claude/.credentials.json so the CLI emits # OAuth-shaped requests, unset the API-key stub so it does not fall back -# to X-Api-Key, and let iron-token-broker mint a real Bearer at request -# time via the anthropic-claude brokered_token secret. +# to X-Api-Key, and let iron-proxy inject the current Console-managed +# Bearer via the anthropic-claude brokered_token secret. CLAUDE_CODE_AUTH_MODE="${CLAUDE_CODE_AUTH_MODE:-api_key}" case "$CLAUDE_CODE_AUTH_MODE" in api_key) From 25bf7e26c27f9a09de6eae7176132d98b1070a0a Mon Sep 17 00:00:00 2001 From: Adam Nieslanik <101835132+kreplik@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:44:05 +0000 Subject: [PATCH 15/36] fix(chart): roll repo-cache when its GitHub token secret changes (#1322) * fix(chart): roll repo-cache when its GitHub token secret changes repo-cache authenticates to GitHub from a token file mounted off repoCache.githubToken, but its pod template carries no annotation tied to that secret. Every other component in the chart has checksum/infra-secrets, so repo-cache is the only workload that keeps running against a stale token after the secret is rotated or recreated. Recovering means noticing that repos have stopped syncing and deleting the pod by hand. Add checksum/github-token, built from the same centaur.secretResourceVersion helper the other components use. Recreating a Secret changes its resourceVersion, so the next upgrade rolls the pod. Rendered only when a token is configured, so token-less deployments see no change. * chore(chart): bump release to 0.1.122 --------- Co-authored-by: Matthew Slipper --- contrib/chart/Chart.yaml | 2 +- contrib/chart/templates/repo-cache.yaml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/contrib/chart/Chart.yaml b/contrib/chart/Chart.yaml index d4a0885e5..d783fe669 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.121 +version: 0.1.122 appVersion: "0.1.0" dependencies: - name: connect 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: From b6bea3b155e68544abe0efb1074f5d855dc8a17f Mon Sep 17 00:00:00 2001 From: akandic47 <57039429+akandic47@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:45:08 +0000 Subject: [PATCH 16/36] docs: document access-token broker credential bootstrap (#997) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: document the access-token broker credential bootstrap; hint at it from api-rs In access_token mode the harness proxy fragment references a console broker credential (openai-codex / anthropic-claude) that must be created out of band with 'centaur-perms broker create'. The production guide never mentioned it, and instead described the removed standalone token-broker flow (OPENAI_CODEX_BLOB / CLAUDE_CODE_BLOB items in the secrets backend that nothing reads anymore), so a fresh deployment ends in an api-rs crash-loop on the console's 422. - rewrite the Codex/Claude Auth Modes sections around the real bootstrap: codex/claude login, store OPENAI_CODEX_ACCOUNT_ID (codex only), create the broker credential, restart api-rs; keep the token-family reuse warnings - select the mode via sandbox.codexAuthMode / sandbox.claudeCodeAuthMode instead of sandbox.extraEnv — extraEnv only reaches sandbox pods, not api-rs, which is the component that acts on the mode - point at the out-of-band step from the bootstrap script usage text - api-rs: when the registration 422 names a missing broker credential, append a 'centaur-perms broker create' hint to the startup error Closes #995 * docs: correct broker bootstrap instructions --------- Co-authored-by: Matthew Slipper --- contrib/scripts/bootstrap-k8s-secrets.sh | 6 + docs/pages/deploying-in-production.mdx | 161 ++++++++++++++++------- docs/pages/reference/configuration.mdx | 4 +- 3 files changed, 120 insertions(+), 51 deletions(-) diff --git a/contrib/scripts/bootstrap-k8s-secrets.sh b/contrib/scripts/bootstrap-k8s-secrets.sh index 510f4140e..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 } diff --git a/docs/pages/deploying-in-production.mdx b/docs/pages/deploying-in-production.mdx index 3431a804e..294800a97 100644 --- a/docs/pages/deploying-in-production.mdx +++ b/docs/pages/deploying-in-production.mdx @@ -148,32 +148,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`): - -| Mode | Upstream | Secrets required | -|------|----------|------------------| -| `api_key` (default) | `api.openai.com` | `OPENAI_API_KEY` | -| `access_token` | `chatgpt.com` | Console broker credential `openai-codex`; `OPENAI_CODEX_ACCOUNT_ID` | +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 | 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. The Console holds the refresh token and its background worker mints -and refreshes short-lived access tokens. Proxy sync delivers the current token -to iron-proxy, which injects it on outbound requests so the sandbox never sees -it. - -Run `codex login` locally, then create a Console broker credential with: - -- Foreign ID: `openai-codex`. -- Token endpoint: `https://auth.openai.com/oauth/token`. -- Client ID: `app_EMoamEEZ73f0CkXaXp7hrann`. -- Refresh token: the value from `~/.codex/auth.json`. - -The Console encrypts the refresh token at rest and updates it after every -rotation. Store `OPENAI_CODEX_ACCOUNT_ID`, the ChatGPT account UUID from the -same login file, in the configured secrets backend. iron-proxy injects it as -the `chatgpt-account-id` header so the backend can route to the correct -workspace. +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 @@ -187,34 +222,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` | Console broker credential `anthropic-claude` | +| 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. The Console holds the refresh token and -its background worker mints and refreshes short-lived access tokens. Proxy sync -delivers the current token to iron-proxy, which injects it on outbound requests -so the sandbox never sees it. The entrypoint plants a dummy -`~/.claude/.credentials.json` so the CLI emits OAuth-shaped requests; -iron-proxy overwrites the Bearer at request time. - -Run `claude login` locally, then create a Console broker credential with: - -- Foreign ID: `anthropic-claude`. -- Token endpoint: `https://console.anthropic.com/v1/oauth/token`. -- Client ID: `9d1c250a-e61b-44d9-88ed-5944d1962f5e`. -- Refresh token: the value from `~/.claude/.credentials.json`, or from the - `Claude Code-credentials` keychain item on macOS. - -The Console encrypts the refresh token at rest and updates it after every -rotation. No Claude OAuth credential needs to be copied into the deployment's -environment or 1Password secret source. +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/reference/configuration.mdx b/docs/pages/reference/configuration.mdx index e87106f0c..abf705192 100644 --- a/docs/pages/reference/configuration.mdx +++ b/docs/pages/reference/configuration.mdx @@ -239,12 +239,12 @@ 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_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. | From ae185c2086f79dfe4ea43494f11d580b8a0118ee Mon Sep 17 00:00:00 2001 From: Jaume Alavedra Date: Wed, 19 Aug 2026 04:46:29 +0000 Subject: [PATCH 17/36] fix(sandbox): seed brokered Codex auth for Hermes (#1368) --- services/sandbox/Dockerfile | 1 + services/sandbox/entrypoint.sh | 1 + services/sandbox/seed_hermes_codex_auth.py | 49 +++++++++++++++++++ .../sandbox/test_seed_hermes_codex_auth.py | 37 ++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 services/sandbox/seed_hermes_codex_auth.py create mode 100644 services/sandbox/test_seed_hermes_codex_auth.py diff --git a/services/sandbox/Dockerfile b/services/sandbox/Dockerfile index 56a509178..f09624381 100644 --- a/services/sandbox/Dockerfile +++ b/services/sandbox/Dockerfile @@ -294,6 +294,7 @@ COPY --link --chmod=755 services/workflow-python/workflow_host.py /usr/local/bin COPY --link services/workflow-python/api/ /usr/local/bin/api/ COPY --link --chmod=755 services/sandbox/git-branch.sh /usr/local/bin/git-branch COPY --link --chmod=755 services/sandbox/install_tool_shims.py /usr/local/bin/install-tool-shims +COPY --link --chmod=755 services/sandbox/seed_hermes_codex_auth.py /usr/local/bin/seed-hermes-codex-auth COPY --link --chmod=755 services/sandbox/centaur_tool_host.py /usr/local/bin/centaur-tool-host COPY --link --chmod=755 services/sandbox/compose_system_prompt.py /usr/local/bin/compose-system-prompt COPY --link --chmod=755 services/sandbox/repo_cache_sync.py /usr/local/bin/repo-cache-sync diff --git a/services/sandbox/entrypoint.sh b/services/sandbox/entrypoint.sh index a16720554..479e31cd8 100644 --- a/services/sandbox/entrypoint.sh +++ b/services/sandbox/entrypoint.sh @@ -128,6 +128,7 @@ mkdir -p "$HOME_DIR/.codex" if [ "$CODEX_AUTH_MODE" = "access_token" ] && [ -f /etc/centaur/codex-auth.default.json ]; then cp /etc/centaur/codex-auth.default.json "$HOME_DIR/.codex/auth.json" chmod 600 "$HOME_DIR/.codex/auth.json" + seed-hermes-codex-auth "$HOME_DIR/.codex/auth.json" "$HOME_DIR/.hermes/auth.json" elif [ ! -f "$HOME_DIR/.codex/auth.json" ] && [ -f /etc/centaur/codex-auth.default.json ]; then cp /etc/centaur/codex-auth.default.json "$HOME_DIR/.codex/auth.json" chmod 600 "$HOME_DIR/.codex/auth.json" diff --git a/services/sandbox/seed_hermes_codex_auth.py b/services/sandbox/seed_hermes_codex_auth.py new file mode 100644 index 000000000..afb9115d3 --- /dev/null +++ b/services/sandbox/seed_hermes_codex_auth.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Seed Hermes with Centaur's broker-only Codex credential placeholder.""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path + + +def seed(codex_auth_path: Path, hermes_auth_path: Path) -> None: + codex = json.loads(codex_auth_path.read_text(encoding="utf-8")) + tokens = codex.get("tokens") + if not isinstance(tokens, dict) or not all(tokens.get(key) for key in ("access_token", "refresh_token")): + raise ValueError("Codex auth must contain access_token and refresh_token placeholders") + + try: + auth = json.loads(hermes_auth_path.read_text(encoding="utf-8")) + except FileNotFoundError: + auth = {"version": 1, "providers": {}} + if not isinstance(auth, dict) or not isinstance(auth.setdefault("providers", {}), dict): + raise ValueError("Hermes auth must be a JSON object with a providers object") + + auth["providers"]["openai-codex"] = { + "tokens": { + "access_token": tokens["access_token"], + "refresh_token": tokens["refresh_token"], + }, + "last_refresh": codex.get("last_refresh"), + "auth_mode": "chatgpt", + } + + hermes_auth_path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(dir=hermes_auth_path.parent, prefix="auth.json.") + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(auth, handle, indent=2) + handle.write("\n") + os.chmod(temporary, 0o600) + os.replace(temporary, hermes_auth_path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +if __name__ == "__main__": + seed(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/services/sandbox/test_seed_hermes_codex_auth.py b/services/sandbox/test_seed_hermes_codex_auth.py new file mode 100644 index 000000000..55959d929 --- /dev/null +++ b/services/sandbox/test_seed_hermes_codex_auth.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json +import stat +import tempfile +import unittest +from pathlib import Path + +from seed_hermes_codex_auth import seed + + +class SeedHermesCodexAuthTest(unittest.TestCase): + def test_seeds_placeholder_and_preserves_other_providers(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + codex = root / "codex.json" + hermes = root / ".hermes" / "auth.json" + codex.write_text(json.dumps({ + "tokens": {"access_token": "dummy-access", "refresh_token": "dummy-refresh"}, + "last_refresh": "2025-01-01T00:00:00Z", + })) + hermes.parent.mkdir() + hermes.write_text(json.dumps({"version": 1, "providers": {"other": {"api_key": "placeholder"}}})) + + seed(codex, hermes) + + auth = json.loads(hermes.read_text()) + self.assertEqual(auth["providers"]["other"]["api_key"], "placeholder") + self.assertEqual(auth["providers"]["openai-codex"]["tokens"], { + "access_token": "dummy-access", + "refresh_token": "dummy-refresh", + }) + self.assertEqual(stat.S_IMODE(hermes.stat().st_mode), 0o600) + + +if __name__ == "__main__": + unittest.main() From fdd87278acf261f6cb902a62a41c199f814069cd Mon Sep 17 00:00:00 2001 From: Derek Cofausper <256792747+decofe@users.noreply.github.com> Date: Wed, 19 Aug 2026 04:50:00 +0000 Subject: [PATCH 18/36] fix(attio): harden CRM mutations and pagination (#1306) * fix(attio): support replacing record values Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com> * fix(attio): harden CRM mutations and pagination Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com> * fix(attio): parse rate-limit reset dates Co-authored-by: Derek Cofausper <256792747+decofe@users.noreply.github.com> --- tools/business/attio/cli.py | 42 ++++++++++- tools/business/attio/client.py | 111 ++++++++++++++++++++++++--- tools/business/attio/test_client.py | 112 ++++++++++++++++++++++++++++ 3 files changed, 252 insertions(+), 13 deletions(-) diff --git a/tools/business/attio/cli.py b/tools/business/attio/cli.py index b5482e7f9..1d47a7a65 100644 --- a/tools/business/attio/cli.py +++ b/tools/business/attio/cli.py @@ -208,12 +208,17 @@ def records( limit: int = typer.Option(25, "--limit", "-n", help="Max results"), filter_json: str = typer.Option(None, "--filter", "-f", help="Filter as JSON"), json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"), + all_results: bool = typer.Option(False, "--all", help="Fetch all result pages"), ): """Query records for any object.""" client = _get_client() filter_obj = json.loads(filter_json) if filter_json else None - records_list = client.query_records(object_slug, filter_obj=filter_obj, limit=limit) + records_list = ( + client.query_all_records(object_slug, filter_obj=filter_obj, page_size=limit) + if all_results + else client.query_records(object_slug, filter_obj=filter_obj, limit=limit) + ) if json_output: print(json.dumps(records_list, indent=2, ensure_ascii=False), file=sys.stdout) @@ -280,19 +285,43 @@ def update( object_slug: str = typer.Argument(..., help="Object slug"), record_id: str = typer.Argument(..., help="Record ID"), values_json: str = typer.Argument(..., help="Values to update as JSON"), + json_output: bool = typer.Option(False, "--json", "-j", help="Output canonical response"), ): - """Update an existing record. + """Update a record, appending rather than replacing multiselect values. Examples: attio update people abc123 '{"email_addresses": [{"email_address": "new@email.com"}]}' """ client = _get_client() values = json.loads(values_json) - client.update_record(object_slug, record_id, values) + record = client.update_record(object_slug, record_id, values) + + if json_output: + print(json.dumps(record, indent=2, ensure_ascii=False), file=sys.stdout) + raise typer.Exit() console.print(f"[green]✓ Updated {object_slug} record {record_id[:8]}...[/]") +@app.command() +def replace( + object_slug: str = typer.Argument(..., help="Object slug"), + record_id: str = typer.Argument(..., help="Record ID"), + values_json: str = typer.Argument(..., help="Values to replace as JSON"), + json_output: bool = typer.Option(False, "--json", "-j", help="Output canonical response"), +): + """Replace supplied attribute values, including multi-value relationships.""" + client = _get_client() + values = json.loads(values_json) + record = client.replace_record_values(object_slug, record_id, values) + + if json_output: + print(json.dumps(record, indent=2, ensure_ascii=False), file=sys.stdout) + raise typer.Exit() + + console.print(f"[green]✓ Replaced values on {object_slug} record {record_id[:8]}...[/]") + + @app.command() def delete( object_slug: str = typer.Argument(..., help="Object slug"), @@ -390,10 +419,15 @@ def notes( object_slug: str = typer.Argument(..., help="Parent object slug"), record_id: str = typer.Argument(..., help="Parent record ID"), json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"), + all_results: bool = typer.Option(False, "--all", help="Fetch all result pages"), ): """List notes for a record.""" client = _get_client() - notes_list = client.list_notes(object_slug, record_id) + notes_list = ( + client.list_all_notes(object_slug, record_id) + if all_results + else client.list_notes(object_slug, record_id) + ) if json_output: print(json.dumps(notes_list, indent=2, ensure_ascii=False), file=sys.stdout) diff --git a/tools/business/attio/client.py b/tools/business/attio/client.py index fb40be70f..7480e6309 100644 --- a/tools/business/attio/client.py +++ b/tools/business/attio/client.py @@ -1,6 +1,9 @@ """Attio API client.""" import mimetypes +import time +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime from pathlib import Path from typing import Any @@ -12,9 +15,10 @@ class AttioClient: """Authenticated Attio CRM API client.""" - def __init__(self, api_key: str | None = None): + def __init__(self, api_key: str | None = None, max_rate_limit_retries: int = 2): self._api_key_override = api_key self._client: httpx.Client | None = None + self.max_rate_limit_retries = max_rate_limit_retries def _http(self) -> httpx.Client: """Return the cached HTTP client, building it after secrets are injected.""" @@ -37,7 +41,12 @@ def _http(self) -> httpx.Client: def _request(self, method: str, path: str, **kwargs) -> dict[str, Any]: """Make authenticated request to Attio API.""" - response = self._http().request(method, path, **kwargs) + for attempt in range(self.max_rate_limit_retries + 1): + response = self._http().request(method, path, **kwargs) + if response.status_code != 429 or attempt == self.max_rate_limit_retries: + break + retry_after = response.headers.get("Retry-After", "1") + time.sleep(self._retry_delay(retry_after)) if response.status_code >= 400: try: error = response.json() @@ -47,6 +56,20 @@ def _request(self, method: str, path: str, **kwargs) -> dict[str, Any]: raise RuntimeError(f"Attio API error ({response.status_code}): {msg}") return response.json() + def _retry_delay(self, retry_after: str, now: datetime | None = None) -> float: + """Parse Retry-After as delta seconds or an HTTP date, bounded to one minute.""" + try: + delay = float(retry_after) + except ValueError: + try: + retry_at = parsedate_to_datetime(retry_after) + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=UTC) + delay = (retry_at - (now or datetime.now(UTC))).total_seconds() + except (TypeError, ValueError, OverflowError): + delay = 1.0 + return min(max(delay, 0.0), 60.0) + def _clean_params(self, params: dict[str, Any]) -> dict[str, Any]: """Remove unset values and encode list query params the way Attio expects.""" cleaned: dict[str, Any] = {} @@ -140,6 +163,31 @@ def query_records( data = self._request("POST", f"/objects/{object_slug}/records/query", json=body) return data.get("data", []) + def query_all_records( + self, + object_slug: str, + filter_obj: dict | None = None, + sorts: list[dict] | None = None, + page_size: int = 500, + ) -> list[dict]: + """Query every matching record using Attio's limit/offset pagination.""" + if not 1 <= page_size <= 500: + raise ValueError("page_size must be between 1 and 500") + records: list[dict] = [] + offset = 0 + while True: + page = self.query_records( + object_slug, + filter_obj=filter_obj, + sorts=sorts, + limit=page_size, + offset=offset, + ) + records.extend(page) + if len(page) < page_size: + return records + offset += page_size + def get_record(self, object_slug: str, record_id: str) -> dict: """Get a specific record by ID.""" data = self._request("GET", f"/objects/{object_slug}/records/{record_id}") @@ -163,14 +211,30 @@ def create_record(self, object_slug: str, values: dict) -> dict: return data.get("data", {}) def update_record(self, object_slug: str, record_id: str, values: dict) -> dict: - """Update an existing record. + """Update a record, appending rather than replacing multiselect values. values format is the same as create_record — attribute slugs mapping to lists of typed value objects. """ body = {"data": {"values": values}} data = self._request("PATCH", f"/objects/{object_slug}/records/{record_id}", json=body) - return data.get("data", {}) + return self._validated_record_response(data, record_id) + + def replace_record_values(self, object_slug: str, record_id: str, values: dict) -> dict: + """Replace the supplied record attribute values instead of appending them.""" + body = {"data": {"values": values}} + data = self._request("PUT", f"/objects/{object_slug}/records/{record_id}", json=body) + return self._validated_record_response(data, record_id) + + def _validated_record_response(self, response: dict[str, Any], record_id: str) -> dict: + """Require Attio to return the record that was mutated.""" + record = response.get("data", {}) + returned_id = record.get("id", {}).get("record_id") + if returned_id != record_id: + raise RuntimeError( + f"Attio mutation response did not confirm record {record_id}; got {returned_id!r}" + ) + return record def delete_record(self, object_slug: str, record_id: str) -> bool: """Delete a record.""" @@ -217,9 +281,7 @@ def query_entries( data = self._request("POST", f"/lists/{list_id}/entries/query", json=body) return data.get("data", []) - def create_entry( - self, list_id: str, parent_record_id: str, values: dict | None = None - ) -> dict: + def create_entry(self, list_id: str, parent_record_id: str, values: dict | None = None) -> dict: """Create a new entry in a list.""" body: dict[str, Any] = {"data": {"parent_record_id": parent_record_id}} if values: @@ -227,15 +289,46 @@ def create_entry( data = self._request("POST", f"/lists/{list_id}/entries", json=body) return data.get("data", {}) - def list_notes(self, parent_object: str, parent_record_id: str) -> list[dict]: + def list_notes( + self, + parent_object: str, + parent_record_id: str, + limit: int = 50, + offset: int = 0, + ) -> list[dict]: """List notes for a record.""" data = self._request( "GET", "/notes", - params={"parent_object": parent_object, "parent_record_id": parent_record_id}, + params={ + "parent_object": parent_object, + "parent_record_id": parent_record_id, + "limit": limit, + "offset": offset, + }, ) return data.get("data", []) + def list_all_notes( + self, parent_object: str, parent_record_id: str, page_size: int = 50 + ) -> list[dict]: + """List every note for a record using Attio's limit/offset pagination.""" + if not 1 <= page_size <= 50: + raise ValueError("page_size must be between 1 and 50") + notes: list[dict] = [] + offset = 0 + while True: + page = self.list_notes( + parent_object, + parent_record_id, + limit=page_size, + offset=offset, + ) + notes.extend(page) + if len(page) < page_size: + return notes + offset += page_size + def upload_file( self, object_slug: str, diff --git a/tools/business/attio/test_client.py b/tools/business/attio/test_client.py index 1451126f8..c7491210d 100644 --- a/tools/business/attio/test_client.py +++ b/tools/business/attio/test_client.py @@ -1,4 +1,7 @@ +import json +from datetime import UTC, datetime from pathlib import Path +from unittest.mock import patch import httpx import pytest @@ -47,3 +50,112 @@ def test_upload_file_rejects_missing_path(tmp_path: Path) -> None: with pytest.raises(ValueError, match="does not exist"): client.upload_file("companies", "record-id", str(tmp_path / "missing.pdf")) + + +def test_replace_record_values_uses_put() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "PUT" + assert request.url.path == "/v2/objects/deals/records/deal-123" + assert request.read() == b'{"data":{"values":{"dependencies_4":[]}}}' + return httpx.Response(200, json={"data": {"id": {"record_id": "deal-123"}}}) + + client = AttioClient(api_key="test-key") + client._client = httpx.Client( + base_url="https://api.attio.com/v2", + headers={"Authorization": "Bearer test-key"}, + transport=httpx.MockTransport(handler), + ) + + result = client.replace_record_values("deals", "deal-123", {"dependencies_4": []}) + + assert result == {"id": {"record_id": "deal-123"}} + + +def test_update_record_rejects_unconfirmed_response() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"data": {"id": {"record_id": "wrong-record"}}}) + + client = AttioClient(api_key="test-key") + client._client = httpx.Client( + base_url="https://api.attio.com/v2", + headers={"Authorization": "Bearer test-key"}, + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(RuntimeError, match="did not confirm record deal-123"): + client.update_record("deals", "deal-123", {"name": "Example"}) + + +def test_query_all_records_paginates_to_short_page() -> None: + offsets: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.read()) + offsets.append(body["offset"]) + count = 2 if body["offset"] == 0 else 1 + return httpx.Response(200, json={"data": [{"page": body["offset"]}] * count}) + + client = AttioClient(api_key="test-key") + client._client = httpx.Client( + base_url="https://api.attio.com/v2", + headers={"Authorization": "Bearer test-key"}, + transport=httpx.MockTransport(handler), + ) + + result = client.query_all_records("deals", page_size=2) + + assert offsets == [0, 2] + assert len(result) == 3 + + +def test_list_all_notes_paginates_to_short_page() -> None: + offsets: list[int] = [] + + def handler(request: httpx.Request) -> httpx.Response: + offset = int(request.url.params["offset"]) + offsets.append(offset) + count = 2 if offset == 0 else 0 + return httpx.Response(200, json={"data": [{"page": offset}] * count}) + + client = AttioClient(api_key="test-key") + client._client = httpx.Client( + base_url="https://api.attio.com/v2", + headers={"Authorization": "Bearer test-key"}, + transport=httpx.MockTransport(handler), + ) + + result = client.list_all_notes("deals", "deal-123", page_size=2) + + assert offsets == [0, 2] + assert len(result) == 2 + + +def test_rate_limit_response_retries_then_succeeds() -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + return httpx.Response(429, headers={"Retry-After": "0"}, json={"message": "wait"}) + return httpx.Response(200, json={"data": []}) + + client = AttioClient(api_key="test-key") + client._client = httpx.Client( + base_url="https://api.attio.com/v2", + headers={"Authorization": "Bearer test-key"}, + transport=httpx.MockTransport(handler), + ) + + with patch("client.time.sleep") as sleep: + assert client.list_objects() == [] + + assert attempts == 2 + sleep.assert_called_once_with(0.0) + + +def test_retry_after_http_date_is_parsed() -> None: + client = AttioClient(api_key="test-key") + now = datetime(2026, 8, 8, 12, 0, tzinfo=UTC) + + assert client._retry_delay("Sat, 08 Aug 2026 12:00:05 GMT", now=now) == 5.0 From 80f19cfb03ddbed7865533d76c07bfe12682f443 Mon Sep 17 00:00:00 2001 From: Matthew Slipper Date: Wed, 19 Aug 2026 04:55:00 +0000 Subject: [PATCH 19/36] refactor: remove vestigial thread key helpers (#1427) --- packages/harness-events/src/index.ts | 2 -- packages/harness-events/src/thread-key.ts | 15 --------------- 2 files changed, 17 deletions(-) delete mode 100644 packages/harness-events/src/thread-key.ts 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}`; -} From 0357221229dbfe14b080562c871b67a770734ed9 Mon Sep 17 00:00:00 2001 From: Daniel Gorrie Date: Wed, 19 Aug 2026 05:01:25 +0000 Subject: [PATCH 20/36] feat(console): add Slack channel autocomplete (#1408) * feat(console): add Slack channel autocomplete * fix(console): allow editing existing Slack permissions --------- Co-authored-by: Matthew Slipper --- .../slack_channel_permission_management.rb | 13 +- .../slack_channel_options_controller.rb | 38 ++++ .../slack_channel_autocomplete_controller.js | 182 ++++++++++++++++++ .../slack_channel_catalog_provider.rb | 18 ++ .../_slack_channel_permissions.html.erb | 79 +++++--- services/console/config/routes.rb | 4 + .../slack_channel_options_controller_test.rb | 69 +++++++ .../controllers/console_controller_test.rb | 10 +- .../slack_channel_catalog_provider_test.rb | 22 +++ 9 files changed, 406 insertions(+), 29 deletions(-) create mode 100644 services/console/app/controllers/console/slack_channel_options_controller.rb create mode 100644 services/console/app/javascript/controllers/slack_channel_autocomplete_controller.js create mode 100644 services/console/test/controllers/console/slack_channel_options_controller_test.rb 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/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/services/slack_channel_catalog_provider.rb b/services/console/app/services/slack_channel_catalog_provider.rb index 06646fce6..3c2bed9aa 100644 --- a/services/console/app/services/slack_channel_catalog_provider.rb +++ b/services/console/app/services/slack_channel_catalog_provider.rb @@ -18,6 +18,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) 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| %> -