From 28a2240c82e6ffbd98fd7bc4a850702ae68ad753 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 4 Jul 2026 23:33:52 -0400 Subject: [PATCH] SMOODEV-1863: Route `th api agents` through the user JWT (like `th api crm`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `th api agents show/update` 403'd on child orgs because the command authenticated with the org-locked M2M `client_credentials` token (`require_authed()`), and M2M tokens are intentionally bound to a single org. `th api crm` already avoids this by authenticating as the logged-in user (Supabase JWT via `UserClient::from_user_session()`), whose org membership carries cross-org (parent-org admin) access. Mirror that exactly: `th api agents` now builds a `UserClient` at the top of `cmd` and every verb (list/show/update/delete/summary/regenerate/ knowledge/mint/create/generate-config) runs on the user session. Drops the M2M `require_authed()` gate; org resolution moves to `active_org::resolve` (the arg `require_active_org` ignored anyway). `UserClient` gains `put` (set-knowledge) and `post_empty` (the no-body `regenerate-*` posts) so it covers the agent verbs' method surface — `post`'s signature is unchanged, so `th api crm` call sites are untouched. Paired with the monorepo PR (#2531) that org-locks the native agents LIST handler against M2M. They MUST land together: the monorepo PR makes M2M stricter (closing the LIST leak), this one restores + extends Brent's access (list+show+update) via his user membership. Strictly tighter M2M, never looser. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/agents-user-jwt.md | 5 +++ crates/smooth-cli/src/smooai/agents.rs | 50 +++++++++++---------- crates/smooth-cli/src/smooai/user_client.rs | 28 ++++++++++++ 3 files changed, 60 insertions(+), 23 deletions(-) create mode 100644 .changeset/agents-user-jwt.md diff --git a/.changeset/agents-user-jwt.md b/.changeset/agents-user-jwt.md new file mode 100644 index 00000000..3300c380 --- /dev/null +++ b/.changeset/agents-user-jwt.md @@ -0,0 +1,5 @@ +--- +"@smooai/smooth": patch +--- + +`th api agents` now authenticates as the logged-in **user** (Supabase JWT), the same way `th api crm` does, instead of the org-locked M2M `client_credentials` token. M2M tokens are strictly bound to one org, so `th api agents show`/`update` 403'd on **child** orgs; a parent-org admin's user session carries cross-org access via membership, so every agent verb (list/show/update/delete/summary/regenerate/knowledge/mint/create/generate-config) now works on the orgs the user belongs to. Paired with the monorepo PR that org-locks the native agents LIST handler against M2M (SMOODEV-1863). diff --git a/crates/smooth-cli/src/smooai/agents.rs b/crates/smooth-cli/src/smooai/agents.rs index 9646bea2..274f96d5 100644 --- a/crates/smooth-cli/src/smooai/agents.rs +++ b/crates/smooth-cli/src/smooai/agents.rs @@ -7,7 +7,8 @@ use clap::{Subcommand, ValueEnum}; use owo_colors::OwoColorize; use serde_json::{json, Value}; -use super::{print_json, print_list_envelope, read_body, require_active_org, require_authed}; +use super::user_client::UserClient; +use super::{print_json, print_list_envelope, read_body}; #[derive(Subcommand)] pub enum Cmd { @@ -248,19 +249,25 @@ impl MintVisibility { } pub async fn cmd(cmd: Cmd) -> Result<()> { - let client = require_authed().await?; + // SMOODEV-1863 — authenticate as the logged-in USER (Supabase JWT), the + // same way `th api crm` does, NOT the org-locked M2M `client_credentials` + // token. A master-org M2M can't act on a child org (api-prime enforces the + // org-lock — see the paired monorepo PR), so `show`/`update` 403'd on child + // orgs. A parent-org admin's user session carries cross-org access via + // membership, so every agent verb works on the orgs the user belongs to. + let client = UserClient::from_user_session().await?; match cmd { Cmd::List { org } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; let body = client.get(&format!("/organizations/{org}/agents")).await.context("GET agents")?; print_list_envelope(&body, "agents"); } Cmd::Show { agent_id, org } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; print_json(&client.get(&format!("/organizations/{org}/agents/{agent_id}")).await.context("GET agent")?); } Cmd::Summary { agent_id, org } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; print_json( &client .get(&format!("/organizations/{org}/agents/{agent_id}/summary")) @@ -269,9 +276,9 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { ); } Cmd::Create { body, org } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; let body = read_body(&body)?; - print_json(&client.post(&format!("/organizations/{org}/agents"), Some(&body)).await.context("POST agent")?); + print_json(&client.post(&format!("/organizations/{org}/agents"), &body).await.context("POST agent")?); } Cmd::Mint { name, @@ -292,7 +299,7 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { require_email, org, } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; let prompt = instructions.map(read_flag_or_file).transpose()?; let color_map = parse_colors(&colors)?; let personality = personality.map(personality_value).transpose()?; @@ -318,12 +325,9 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { require_email, )?; - // Creating an agent is a user-attributed admin write. The M2M - // `client_credentials` token is org-locked, so it can't create in a - // child org; a parent-org admin's user session acts cross-org. Use - // the user client (like the CRM/keys commands) for the write. - let user = super::user_client::UserClient::from_user_session().await?; - let created = user.post(&format!("/organizations/{org}/agents"), &body).await.context("POST agent (mint)")?; + // Creating an agent is a user-attributed admin write; the whole + // command already runs on the user session (see top of `cmd`). + let created = client.post(&format!("/organizations/{org}/agents"), &body).await.context("POST agent (mint)")?; let agent_id = created.get("id").and_then(Value::as_str).unwrap_or("?").to_string(); println!(); @@ -334,7 +338,7 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { // is never lost just because the extractor is unreachable. let mut applied_colors = color_map; if let Some(url) = brand_from_url { - match extract_and_apply_palette(&user, &org, &agent_id, &url).await { + match extract_and_apply_palette(&client, &org, &agent_id, &url).await { Ok(palette) => { println!(" {} applied brand palette from {}", "✓".green(), url.dimmed()); applied_colors = palette; @@ -371,7 +375,7 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { extension_config, org, } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; let has_flags = instructions.is_some() || greeting.is_some() || personality.is_some() @@ -403,7 +407,7 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { ); } Cmd::Delete { agent_id, org } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; print_json( &client .delete(&format!("/organizations/{org}/agents/{agent_id}")) @@ -412,7 +416,7 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { ); } Cmd::Regenerate { agent_id, slot, org } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; let suffix = match slot { RegenerateSlot::Prompts => "regenerate-prompts", RegenerateSlot::Summary => "regenerate-summary", @@ -422,13 +426,13 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { }; print_json( &client - .post(&format!("/organizations/{org}/agents/{agent_id}/{suffix}"), None) + .post_empty(&format!("/organizations/{org}/agents/{agent_id}/{suffix}")) .await .context("POST regenerate")?, ); } Cmd::ListKnowledge { agent_id, org } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; print_json( &client .get(&format!("/organizations/{org}/agents/{agent_id}/knowledge")) @@ -437,7 +441,7 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { ); } Cmd::SetKnowledge { agent_id, body, org } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; let body = read_body(&body)?; print_json( &client @@ -447,11 +451,11 @@ pub async fn cmd(cmd: Cmd) -> Result<()> { ); } Cmd::GenerateConfig { body, org } => { - let org = require_active_org(&client, org)?; + let org = crate::active_org::resolve(org)?; let body = read_body(&body)?; print_json( &client - .post(&format!("/organizations/{org}/agents/generate-config"), Some(&body)) + .post(&format!("/organizations/{org}/agents/generate-config"), &body) .await .context("POST generate-config")?, ); diff --git a/crates/smooth-cli/src/smooai/user_client.rs b/crates/smooth-cli/src/smooai/user_client.rs index 0319bec9..9380796b 100644 --- a/crates/smooth-cli/src/smooai/user_client.rs +++ b/crates/smooth-cli/src/smooai/user_client.rs @@ -98,6 +98,34 @@ impl UserClient { Self::body(resp, "PATCH", &url).await } + pub async fn put(&self, path: &str, body: &Value) -> Result { + let url = format!("{}{path}", self.base); + let resp = self + .http + .put(&url) + .bearer_auth(&self.bearer) + .json(body) + .send() + .await + .with_context(|| format!("PUT {url}"))?; + Self::body(resp, "PUT", &url).await + } + + /// POST with no request body — for the fire-and-forget endpoints (agent + /// `regenerate-*`) that take no input. Mirrors the M2M client's + /// `post(path, None)`: no `Content-Type`, no JSON payload. + pub async fn post_empty(&self, path: &str) -> Result { + let url = format!("{}{path}", self.base); + let resp = self + .http + .post(&url) + .bearer_auth(&self.bearer) + .send() + .await + .with_context(|| format!("POST {url}"))?; + Self::body(resp, "POST", &url).await + } + pub async fn delete(&self, path: &str) -> Result { let url = format!("{}{path}", self.base); let resp = self