From b869e83654670f66784e2fd7294ebbb3f452de9e Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sat, 29 Aug 2026 11:04:14 +0800 Subject: [PATCH 1/2] feat(studio-cp,oab-mcp,src-tauri): add deploy_provision_agent (studio#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Fleet wizard direction (Brett, this thread): drop the compose-library Template/Overlay model, replace with a vendor + chat-platform + ACP flow that composes config.toml directly. Confirmed earlier (#128 investigation) that provision_manifest/provision_k8s don't require a Bundle produced by compose_named — a hand-built Bundle{image_tag, files} works identically. This PR adds the backend capability only (all 3 layers: MCP tool, Tauri bridge, studio-cp core) — purely additive, doesn't touch the existing deploy_provision/compose-library path at all. Console wiring (replacing the Template/Overlay UI with the new wizard) is a separate follow-up PR. - studio-cp: provision_agent / provision_agent_k8s — near-duplicates of provision_from_library[_k8s] from "resolve the bucket" onward (same create-vs-redeploy branch, same pre_seed hook injection, same bundle upload), except the Bundle's config.toml comes from the caller directly instead of studio_compose::compose_named(library, template, overlay). Deliberate duplication over a shared refactor, matching the tradeoff provision_from_library_k8s's own doc comment already made for the same reason (avoid risking the already-landed functions' shape). - oab-mcp: new deploy_provision_agent tool (config_toml + image + name, same provider/context/expected_principal/fleet/cluster args as deploy_provision minus library/template/overlay), dispatches to the new studio-cp functions. Tool-count test updated (17 -> 18). - src-tauri: deploy_provision_agent bridge command, mirroring deploy_provision's shape, registered in generate_handler!. Ref #128. --- crates/oab-mcp/src/lib.rs | 101 ++++++++++++++++++++++++++- crates/studio-cp/src/lib.rs | 135 ++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 51 ++++++++++++++ 3 files changed, 286 insertions(+), 1 deletion(-) diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 48503da..f82d716 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -165,6 +165,25 @@ pub fn tools() -> Vec { "required": ["library", "template", "name"] })), ), + Tool::new( + "deploy_provision_agent", + "Provision an agent directly from a caller-supplied config.toml — no compose library, no template ⊕ overlay (studio#128: the New Fleet wizard's vendor/chat-platform/ACP flow composes config.toml itself and has no template to point at). Same create-vs-redeploy behavior as deploy_provision: patches an existing stored manifest's image/bundle if this agent already has one, otherwise builds a fresh manifest with sensible defaults. `provider` (default \"aws\") selects the target, same as deploy_provision.", + as_map(json!({ + "type": "object", + "properties": { + "config_toml": { "type": "string", "description": "Full config.toml text for the agent." }, + "image": { "type": "string", "description": "Container image (e.g. ghcr.io/openabdev/openab:-)." }, + "name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." }, + "namespace": { "type": "string", "description": "Namespace (default \"default\")." }, + "provider": { "type": "string", "description": "\"aws\" (default) or \"k8s\" — which driver applies the result." }, + "fleet": { "type": "string", "description": "AWS only. Fleet name (see fleet_config): targets the fleet's cluster and managing credential; a write to a service outside the fleet's members is refused. Overrides the cluster arg." }, + "cluster": { "type": "string", "description": "AWS only. ECS cluster (defaults to the server's configured cluster)." }, + "context": { "type": "string", "description": "k8s only. Kubeconfig context to apply through. Omit to use the kubeconfig's current-context." }, + "expected_principal": { "type": "string", "description": "k8s only, optional. `system:serviceaccount::` to set the pod's service account; unset uses the namespace's default." } + }, + "required": ["config_toml", "image", "name"] + })), + ), Tool::new( "deploy_delete", "Delete a control-plane resource (e.g. an OABService).", @@ -399,6 +418,7 @@ impl OabMcp { "deploy_events" => self.t_events(args).await, "deploy_apply" => self.t_apply(args).await, "deploy_provision" => self.t_provision(args).await, + "deploy_provision_agent" => self.t_provision_agent(args).await, "deploy_scale" => self.t_scale(args).await, "deploy_delete" => self.t_delete(args).await, "runtime_context" => self.t_runtime_context(args).await, @@ -673,6 +693,84 @@ impl OabMcp { })) } + /// [`t_provision`], but for `deploy_provision_agent` (studio#128) — no + /// `library`/`template`/`overlay` args, `config_toml` is used as-is. + async fn t_provision_agent(&self, args: &Map) -> Result { + let namespace = args + .get("namespace") + .and_then(Value::as_str) + .unwrap_or("default"); + let name = args + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: name"))?; + let image = args + .get("image") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: image"))?; + let config_toml = args + .get("config_toml") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: config_toml"))? + .as_bytes() + .to_vec(); + + if args.get("provider").and_then(Value::as_str) == Some("k8s") { + let context = args.get("context").and_then(Value::as_str); + let expected_principal = args.get("expected_principal").and_then(Value::as_str); + let outcome = scp::provision_agent_k8s( + &self.aws, + context, + namespace, + name, + image, + config_toml, + expected_principal, + ) + .await?; + return Ok(json!({ + "ok": true, + "context": context, + "namespace": namespace, + "name": name, + "image": outcome.image, + "digest": outcome.digest, + "objects": outcome.objects, + "action": outcome.action, + "services_applied": outcome.services_applied, + })); + } + + let t = self.target(args)?; + let cluster = t.cluster.clone(); + + let service_name = format!("oab-{namespace}-{name}"); + if !t.includes(&service_name, name) { + anyhow::bail!("service {service_name:?} is not a member of the named fleet"); + } + + let outcome = scp::provision_agent( + &self.aws_for(&cluster).await, + &cluster, + namespace, + name, + image, + config_toml, + ) + .await?; + Ok(json!({ + "ok": true, + "cluster": cluster, + "namespace": namespace, + "name": name, + "image": outcome.image, + "digest": outcome.digest, + "objects": outcome.objects, + "action": outcome.action, + "services_applied": outcome.services_applied, + })) + } + async fn t_apply(&self, args: &Map) -> Result { let cluster = self.target(args)?.cluster; let manifest = args @@ -1005,7 +1103,7 @@ mod tests { .iter() .map(|t| t["name"].as_str().expect("tool has a name").to_string()) .collect(); - assert_eq!(names.len(), 17); + assert_eq!(names.len(), 18); for expected in [ "deploy_list", "deploy_get", @@ -1013,6 +1111,7 @@ mod tests { "deploy_events", "deploy_apply", "deploy_provision", + "deploy_provision_agent", "deploy_scale", "deploy_delete", "runtime_context", diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index aef6aaa..2bb377f 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -1230,6 +1230,141 @@ pub async fn provision_from_library( }) } +/// [`provision_from_library`], but for the studio#128 wizard's direct path — +/// no compose library, no `template ⊕ overlay`: the caller (the New Fleet +/// wizard) has already composed `config_toml` itself from its own inputs +/// (vendor, chat platform, ACP toggle) and just wants it provisioned. +/// Deliberately a near-duplicate of `provision_from_library` (from "resolve +/// the bucket" onward) rather than a shared refactor of that already-landed +/// function — same tradeoff `provision_from_library_k8s`'s own doc comment +/// already made for the same reason. +pub async fn provision_agent( + aws_config: &aws_config::SdkConfig, + cluster: &str, + namespace: &str, + name: &str, + image: &str, + config_toml: Vec, +) -> anyhow::Result { + let mut bundle = studio_compose::Bundle { + image_tag: image.to_string(), + files: std::collections::BTreeMap::from([("config.toml".to_string(), config_toml)]), + }; + + let bucket = oabctl::resolve_bucket(aws_config, None).await?; + let zip_uri = oabctl::studio_api::bundle_zip_uri(&bucket, namespace, name); + match bundle.files.get_mut("config.toml") { + Some(bytes) => *bytes = oabctl::studio_api::inject_pre_seed_hook(bytes, &zip_uri)?, + None => unreachable!("just inserted above"), + } + + let mut objects = bundle.artifact_objects(namespace, name); + let zip_key = format!( + "{}/{}", + studio_compose::artifacts_prefix(namespace, name), + oabctl::studio_api::BUNDLE_ZIP_FILENAME + ); + objects.push((zip_key, bundle.zip_bytes())); + + let digest = bundle.digest(); + + let existing_manifest = + oabctl::studio_api::load_manifest(aws_config, namespace, name, Some(&bucket)).await?; + let report = match existing_manifest { + Some(_) => { + oabctl::studio_api::redeploy( + aws_config, + cluster, + namespace, + name, + Some(image), + &objects, + Some(&bucket), + ) + .await? + } + None => { + let mut manifest = build_default_manifest(aws_config, namespace, name, image, &bucket).await?; + manifest.spec.bundle_from = Some(oabctl::studio_api::bundle_from_uri(&bucket, namespace, name)); + oabctl::studio_api::provision_manifest(aws_config, cluster, &manifest, &objects, Some(&bucket)) + .await? + } + }; + + Ok(ProvisionOutcome { + image: image.to_string(), + digest, + objects: objects.len(), + services_applied: report.services.len(), + action: report + .services + .first() + .map(|s| format!("{:?}", s.action)) + .unwrap_or_default(), + }) +} + +/// [`provision_agent`], but for a k8s-driven fleet — the studio#128 +/// counterpart of `provision_from_library_k8s`, same relationship +/// `provision_agent` has to `provision_from_library`. +pub async fn provision_agent_k8s( + aws_config: &aws_config::SdkConfig, + context: Option<&str>, + namespace: &str, + name: &str, + image: &str, + config_toml: Vec, + expected_principal: Option<&str>, +) -> anyhow::Result { + let mut bundle = studio_compose::Bundle { + image_tag: image.to_string(), + files: std::collections::BTreeMap::from([("config.toml".to_string(), config_toml)]), + }; + + let bucket = oabctl::resolve_bucket(aws_config, None).await?; + let zip_uri = oabctl::studio_api::bundle_zip_uri(&bucket, namespace, name); + match bundle.files.get_mut("config.toml") { + Some(bytes) => *bytes = oabctl::studio_api::inject_pre_seed_hook(bytes, &zip_uri)?, + None => unreachable!("just inserted above"), + } + + let mut objects = bundle.artifact_objects(namespace, name); + let zip_key = format!( + "{}/{}", + studio_compose::artifacts_prefix(namespace, name), + oabctl::studio_api::BUNDLE_ZIP_FILENAME + ); + objects.push((zip_key, bundle.zip_bytes())); + + let digest = bundle.digest(); + + let existing_manifest = + oabctl::studio_api::load_manifest(aws_config, namespace, name, Some(&bucket)).await?; + let mut manifest = match existing_manifest { + Some(mut stored) => { + stored.spec.image = image.to_string(); + stored + } + None => build_default_k8s_manifest(context, namespace, name, image, &bucket, expected_principal).await?, + }; + manifest.spec.bundle_from = Some(oabctl::studio_api::bundle_from_uri(&bucket, namespace, name)); + + let report = + oabctl::studio_api::provision_k8s(aws_config, context, &manifest, &objects, Some(&bucket)).await?; + + Ok(ProvisionOutcome { + image: image.to_string(), + digest, + objects: objects.len(), + services_applied: report.services.len(), + action: report + .services + .first() + .map(|s| format!("{:?}", s.action)) + .unwrap_or_default(), + }) +} + /// Extract the bare service-account name from an `expected_principal` string /// in `system:serviceaccount::` form — the format /// `K8sFleetBinding.expected_principal` holds when the "+ New fleet" wizard's diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6b379f..c02e17a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -178,6 +178,56 @@ async fn deploy_provision( } } +/// [`deploy_provision`], but for `deploy_provision_agent` (studio#128) — the +/// New Fleet wizard's direct path (vendor/chat-platform/ACP composed into +/// `config_toml` client-side, no compose library involved). +#[tauri::command] +async fn deploy_provision_agent( + core: tauri::State<'_, Core>, + config_toml: String, + image: String, + name: String, + namespace: Option, + cluster: Option, + provider: Option, + context: Option, + expected_principal: Option, +) -> Result { + let cluster = cluster.unwrap_or_else(default_cluster); + let client = { + let guard = core.0.lock().await; + guard + .as_ref() + .cloned() + .ok_or_else(|| "core not started yet".to_string())? + }; + let mut params = json!({ + "config_toml": config_toml, + "image": image, + "name": name, + "cluster": cluster, + }); + if let Some(ns) = namespace { + params["namespace"] = json!(ns); + } + if let Some(p) = provider.filter(|s| !s.is_empty()) { + params["provider"] = json!(p); + } + if let Some(c) = context.filter(|s| !s.is_empty()) { + params["context"] = json!(c); + } + if let Some(ep) = expected_principal.filter(|s| !s.is_empty()) { + params["expected_principal"] = json!(ep); + } + match client.call_tool("deploy_provision_agent", params).await { + Ok(v) => Ok(v), + Err(e) => { + client.log("error", &format!("deploy_provision_agent: {e}")); + Err(e) + } + } +} + /// List services (`deploy_list`) then fetch each one's per-instance 6-state /// (`deploy_get`), all over MCP — the two-step the in-process bridge used, /// now over the wire. Console view-model shape is unchanged. @@ -728,6 +778,7 @@ pub fn run() { compose_library_set, compose_preview, deploy_provision, + deploy_provision_agent, deploy_list, runtime_context, fleet_config, From 456e6eadd03138daf7c8b24f28bf08cbb3bf9055 Mon Sep 17 00:00:00 2001 From: Brett Chien Date: Sat, 29 Aug 2026 12:00:00 +0800 Subject: [PATCH 2/2] refactor(studio-cp,oab-mcp,src-tauri): move config.toml generation server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brett's catch: this form's output is ultimately a config.toml for the created agent, and that file can also be produced by an admin agent calling the same tool directly (not through Studio's UI) — for those to stay in sync, the actual TOML-rendering logic can't live in the console (TypeScript), it has to be the single server-side source of truth both callers go through. deploy_provision_agent's config_toml:string param is replaced with structured fields (api_key, chat_platform, chat_bot_token, chat_channel_secret) — studio-cp's new generate_agent_config() renders the actual text (the structured-input counterpart of oabctl create's generate_config, generalized from Discord-only to discord/telegram/line). Any caller sending the same fields — the wizard or a future admin agent — gets byte-identical config.toml by construction, not by convention. provision_agent_secrets() stores the secret-bearing fields in the same oab/{namespace}/{name} Secrets Manager convention oabctl create already uses for the Discord token — a separate secret from #127's ACP auth key, since these feed config.toml's [secrets.refs]/${secrets.x} (openab's own resolution, aws-sm:// only) while the ACP key is a container-level env var injected via spec.secrets, a different delivery path entirely. k8s deploys refuse a non-empty chat_platform rather than silently deploying something broken: config.toml's secret resolution only understands aws-sm://, and k8s_driver.rs's build_deployment injects no AWS credentials into the pod at all (confirmed by reading it) — so a k8s pod has no way to actually resolve that URI at runtime. ACP-only k8s deploys are unaffected (already-working, different mechanism). api_key is captured/stored but not yet wired into config.toml — which env var a given vendor's CLI expects it under needs vendor-specific research this round didn't do. Flagged in the tool schema and struct doc comment rather than fabricating a config key nothing reads. Ref #128. --- crates/oab-mcp/src/lib.rs | 28 +++-- crates/studio-cp/src/lib.rs | 214 ++++++++++++++++++++++++++++++++++-- src-tauri/src/lib.rs | 23 +++- 3 files changed, 241 insertions(+), 24 deletions(-) diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index f82d716..0d34b5d 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -167,21 +167,24 @@ pub fn tools() -> Vec { ), Tool::new( "deploy_provision_agent", - "Provision an agent directly from a caller-supplied config.toml — no compose library, no template ⊕ overlay (studio#128: the New Fleet wizard's vendor/chat-platform/ACP flow composes config.toml itself and has no template to point at). Same create-vs-redeploy behavior as deploy_provision: patches an existing stored manifest's image/bundle if this agent already has one, otherwise builds a fresh manifest with sensible defaults. `provider` (default \"aws\") selects the target, same as deploy_provision.", + "Provision an agent directly from structured inputs — no compose library, no template ⊕ overlay (studio#128: the New Fleet wizard's vendor/chat-platform/ACP flow has no template to point at). config.toml is rendered server-side from these fields (the single source of truth — any caller, wizard or otherwise, that sends the same fields gets byte-identical config.toml, no drift between generators). Same create-vs-redeploy behavior as deploy_provision: patches an existing stored manifest's image/bundle if this agent already has one, otherwise builds a fresh manifest with sensible defaults. `provider` (default \"aws\") selects the target, same as deploy_provision. k8s deploys refuse a non-empty `chat_platform`: config.toml's secret resolution only supports aws-sm:// (AWS Secrets Manager), which a k8s pod has no credential chain to reach — ACP is the k8s connection path today.", as_map(json!({ "type": "object", "properties": { - "config_toml": { "type": "string", "description": "Full config.toml text for the agent." }, "image": { "type": "string", "description": "Container image (e.g. ghcr.io/openabdev/openab:-)." }, "name": { "type": "string", "description": "Agent / service name (service = oab-{namespace}-{name})." }, "namespace": { "type": "string", "description": "Namespace (default \"default\")." }, + "api_key": { "type": "string", "description": "Optional vendor API key. Captured and stored as a secret; not yet wired into config.toml (which env var a given vendor's CLI expects it under needs vendor-specific follow-up)." }, + "chat_platform": { "type": "string", "description": "Optional: \"discord\" | \"telegram\" | \"line\". Omit for no chat platform (connect via ACP directly). AWS only — refused for k8s deploys." }, + "chat_bot_token": { "type": "string", "description": "Discord/Telegram bot token, or LINE's channel access token." }, + "chat_channel_secret": { "type": "string", "description": "LINE only." }, "provider": { "type": "string", "description": "\"aws\" (default) or \"k8s\" — which driver applies the result." }, "fleet": { "type": "string", "description": "AWS only. Fleet name (see fleet_config): targets the fleet's cluster and managing credential; a write to a service outside the fleet's members is refused. Overrides the cluster arg." }, "cluster": { "type": "string", "description": "AWS only. ECS cluster (defaults to the server's configured cluster)." }, "context": { "type": "string", "description": "k8s only. Kubeconfig context to apply through. Omit to use the kubeconfig's current-context." }, "expected_principal": { "type": "string", "description": "k8s only, optional. `system:serviceaccount::` to set the pod's service account; unset uses the namespace's default." } }, - "required": ["config_toml", "image", "name"] + "required": ["image", "name"] })), ), Tool::new( @@ -708,12 +711,15 @@ impl OabMcp { .get("image") .and_then(Value::as_str) .ok_or_else(|| anyhow::anyhow!("missing required arg: image"))?; - let config_toml = args - .get("config_toml") - .and_then(Value::as_str) - .ok_or_else(|| anyhow::anyhow!("missing required arg: config_toml"))? - .as_bytes() - .to_vec(); + let input = scp::AgentWizardInput { + api_key: args.get("api_key").and_then(Value::as_str).map(str::to_string), + chat_platform: args.get("chat_platform").and_then(Value::as_str).map(str::to_string), + chat_bot_token: args.get("chat_bot_token").and_then(Value::as_str).map(str::to_string), + chat_channel_secret: args + .get("chat_channel_secret") + .and_then(Value::as_str) + .map(str::to_string), + }; if args.get("provider").and_then(Value::as_str) == Some("k8s") { let context = args.get("context").and_then(Value::as_str); @@ -724,7 +730,7 @@ impl OabMcp { namespace, name, image, - config_toml, + input, expected_principal, ) .await?; @@ -755,7 +761,7 @@ impl OabMcp { namespace, name, image, - config_toml, + input, ) .await?; Ok(json!({ diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index 2bb377f..030c388 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -1230,22 +1230,195 @@ pub async fn provision_from_library( }) } +/// New Fleet wizard inputs (studio#128) that determine the generated +/// config.toml + which secrets get provisioned alongside it. Deliberately +/// its own UI-facing shape, not the manifest/Bundle types — those are the +/// on-disk/API contract, this is what the wizard actually collects. +/// +/// `api_key` is captured and stored as a secret, but **not yet wired into +/// config.toml** — which env var name a given vendor's CLI actually expects +/// its API key under (`ANTHROPIC_API_KEY`? something else?) needs +/// vendor-specific research this round didn't do (matches the "no generic +/// auth-flow abstraction exists yet" scoping already agreed for vendor auth +/// v1). Fabricating a config key nothing reads would be silently worse than +/// not wiring it at all, so: captured, not yet consumed. Follow-up. +#[derive(Debug, Clone, Default)] +pub struct AgentWizardInput { + pub api_key: Option, + /// "discord" | "telegram" | "line"; `None` (or any other value) means no + /// chat platform — ACP is the connection path (Brett: "we can use acp + /// to do connection directly"). + pub chat_platform: Option, + /// Discord/Telegram bot token, or LINE's channel access token. + pub chat_bot_token: Option, + /// LINE only. + pub chat_channel_secret: Option, +} + +/// Stores [`AgentWizardInput`]'s secret-bearing fields in the same +/// `oab/{namespace}/{name}` Secrets Manager convention `oabctl create`'s +/// CLI wizard already uses for the Discord bot token (studio#128) — reuses +/// `oabctl::create::store_secret`, a single fresh JSON blob for the same +/// "first deploy, nothing else has written here yet" reason +/// `provision_acp_auth_secret` (studio#119) documents. A **separate** +/// secret from the ACP auth key: that one is a container-level env var the +/// gateway reads directly, this one feeds config.toml's `[secrets.refs]` → +/// `${secrets.x}` substitution, openab's own resolution mechanism — two +/// different delivery paths, kept as two different secrets rather than +/// conflating them into one. +async fn provision_agent_secrets( + aws_config: &aws_config::SdkConfig, + namespace: &str, + name: &str, + input: &AgentWizardInput, +) -> anyhow::Result<()> { + let mut obj = serde_json::Map::new(); + if let Some(key) = &input.api_key { + obj.insert("VENDOR_API_KEY".to_string(), serde_json::Value::String(key.clone())); + } + match input.chat_platform.as_deref() { + Some("discord") => { + if let Some(t) = &input.chat_bot_token { + obj.insert("DISCORD_BOT_TOKEN".to_string(), serde_json::Value::String(t.clone())); + } + } + Some("telegram") => { + if let Some(t) = &input.chat_bot_token { + obj.insert("TELEGRAM_BOT_TOKEN".to_string(), serde_json::Value::String(t.clone())); + } + } + Some("line") => { + if let Some(t) = &input.chat_bot_token { + obj.insert( + "LINE_CHANNEL_ACCESS_TOKEN".to_string(), + serde_json::Value::String(t.clone()), + ); + } + if let Some(s) = &input.chat_channel_secret { + obj.insert("LINE_CHANNEL_SECRET".to_string(), serde_json::Value::String(s.clone())); + } + } + _ => {} + } + if obj.is_empty() { + return Ok(()); + } + let sm = aws_sdk_secretsmanager::Client::new(aws_config); + let secret_name = format!("oab/{namespace}/{name}"); + oabctl::create::store_secret(&sm, &secret_name, &serde_json::Value::Object(obj).to_string()).await?; + Ok(()) +} + +/// Renders config.toml for a wizard-composed agent (studio#128) — the +/// structured-input counterpart of `oabctl create`'s `generate_config`, +/// generalized from Discord-only to any of the wizard's chat platforms (or +/// none — ACP-only). Doesn't touch ACP: that's `Spec.acp_enabled`, a +/// container-level env var, not a config.toml key (studio#119 confirmed +/// openab-gateway reads `OPENAB_ACP_ENABLED` from process env only). +/// +/// Single source of truth by construction, not just convention: this is the +/// *only* place that ever renders this text — the console wizard and any +/// other MCP caller (an "admin agent") both go through `deploy_provision_agent`, +/// which calls this, so the same structured input always produces the same +/// file regardless of who's driving. +fn generate_agent_config(namespace: &str, name: &str, input: &AgentWizardInput) -> String { + let secret_ref = |key: &str| format!("aws-sm://oab/{namespace}/{name}#{key}"); + let mut secrets_refs = String::new(); + let mut platform_section = String::new(); + + match input.chat_platform.as_deref() { + Some("discord") => { + secrets_refs.push_str(&format!( + "discord_bot_token = \"{}\"\n", + secret_ref("DISCORD_BOT_TOKEN") + )); + platform_section = r#" +[discord] +bot_token = "${secrets.discord_bot_token}" +allow_all_channels = true +allow_all_users = true +allowed_channels = [] +allowed_users = [] +allow_bot_messages = "mentions" +max_bot_turns = 1000 +message_processing_mode = "per-thread" +"# + .to_string(); + } + Some("telegram") => { + secrets_refs.push_str(&format!( + "telegram_bot_token = \"{}\"\n", + secret_ref("TELEGRAM_BOT_TOKEN") + )); + platform_section = r#" +[telegram] +bot_token = "${secrets.telegram_bot_token}" +"# + .to_string(); + } + Some("line") => { + secrets_refs.push_str(&format!( + "line_channel_access_token = \"{}\"\n", + secret_ref("LINE_CHANNEL_ACCESS_TOKEN") + )); + secrets_refs.push_str(&format!( + "line_channel_secret = \"{}\"\n", + secret_ref("LINE_CHANNEL_SECRET") + )); + platform_section = r#" +[line] +channel_access_token = "${secrets.line_channel_access_token}" +channel_secret = "${secrets.line_channel_secret}" +"# + .to_string(); + } + _ => {} + } + + let secrets_block = if secrets_refs.is_empty() { + String::new() + } else { + format!("[secrets.refs]\n{secrets_refs}\n") + }; + + format!( + r#"{secrets_block}{platform_section} +[agent] +inherit_env = ["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_DEFAULT_REGION", "AWS_EXECUTION_ENV", "AWS_REGION"] + +[pool] +max_sessions = 5 +session_ttl_hours = 1 + +[reactions] +enabled = true +remove_after_reply = false + +[cron] +usercron_enabled = true +usercron_path = "cronjob.toml" +"# + ) +} + /// [`provision_from_library`], but for the studio#128 wizard's direct path — -/// no compose library, no `template ⊕ overlay`: the caller (the New Fleet -/// wizard) has already composed `config_toml` itself from its own inputs -/// (vendor, chat platform, ACP toggle) and just wants it provisioned. -/// Deliberately a near-duplicate of `provision_from_library` (from "resolve -/// the bucket" onward) rather than a shared refactor of that already-landed -/// function — same tradeoff `provision_from_library_k8s`'s own doc comment -/// already made for the same reason. +/// no compose library, no `template ⊕ overlay`: builds config.toml itself +/// from [`AgentWizardInput`] (via [`generate_agent_config`]) instead of +/// composing `template ⊕ overlay`. Deliberately a near-duplicate of +/// `provision_from_library` (from "resolve the bucket" onward) rather than +/// a shared refactor of that already-landed function — same tradeoff +/// `provision_from_library_k8s`'s own doc comment already made for the same +/// reason. pub async fn provision_agent( aws_config: &aws_config::SdkConfig, cluster: &str, namespace: &str, name: &str, image: &str, - config_toml: Vec, + input: AgentWizardInput, ) -> anyhow::Result { + provision_agent_secrets(aws_config, namespace, name, &input).await?; + let config_toml = generate_agent_config(namespace, name, &input).into_bytes(); let mut bundle = studio_compose::Bundle { image_tag: image.to_string(), files: std::collections::BTreeMap::from([("config.toml".to_string(), config_toml)]), @@ -1307,15 +1480,38 @@ pub async fn provision_agent( /// [`provision_agent`], but for a k8s-driven fleet — the studio#128 /// counterpart of `provision_from_library_k8s`, same relationship /// `provision_agent` has to `provision_from_library`. +/// +/// **`chat_platform` is refused, not silently dropped.** config.toml's +/// `[secrets.refs]` only understands `aws-sm://` (and `exec://`) — +/// `crates/openab-core/src/secrets.rs`'s own resolver — and a k8s pod +/// (confirmed: `k8s_driver.rs::build_deployment` injects no AWS +/// credentials/region at all, unlike the ECS path's `AWS_REGION` + +/// task-role-via-`AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`) has no AWS +/// credential chain to resolve that URI with at runtime. Wiring a chat +/// platform token through `[secrets.refs]` for a k8s deploy would compose +/// and deploy "successfully" and then fail silently inside the running +/// container — worse than refusing up front. ACP-only k8s deploys are +/// unaffected (that key is delivered as a container-level env var via +/// `spec.secrets`/`k8s-secret://`, a completely different, already-working +/// mechanism — see `provision_acp_auth_k8s_secret`). pub async fn provision_agent_k8s( aws_config: &aws_config::SdkConfig, context: Option<&str>, namespace: &str, name: &str, image: &str, - config_toml: Vec, + input: AgentWizardInput, expected_principal: Option<&str>, ) -> anyhow::Result { + if input.chat_platform.is_some() { + anyhow::bail!( + "chat platform integration isn't available for k8s deploys yet — config.toml's \ + secret resolution needs AWS credentials the pod doesn't have. Use ACP to connect \ + directly, or deploy this agent to ECS instead." + ); + } + provision_agent_secrets(aws_config, namespace, name, &input).await?; + let config_toml = generate_agent_config(namespace, name, &input).into_bytes(); let mut bundle = studio_compose::Bundle { image_tag: image.to_string(), files: std::collections::BTreeMap::from([("config.toml".to_string(), config_toml)]), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c02e17a..62c03ca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -179,15 +179,19 @@ async fn deploy_provision( } /// [`deploy_provision`], but for `deploy_provision_agent` (studio#128) — the -/// New Fleet wizard's direct path (vendor/chat-platform/ACP composed into -/// `config_toml` client-side, no compose library involved). +/// New Fleet wizard's direct path. Structured fields, not a pre-rendered +/// `config_toml` — the sidecar renders it server-side (see the MCP tool's +/// own doc comment for why: single source of truth regardless of caller). #[tauri::command] async fn deploy_provision_agent( core: tauri::State<'_, Core>, - config_toml: String, image: String, name: String, namespace: Option, + api_key: Option, + chat_platform: Option, + chat_bot_token: Option, + chat_channel_secret: Option, cluster: Option, provider: Option, context: Option, @@ -202,7 +206,6 @@ async fn deploy_provision_agent( .ok_or_else(|| "core not started yet".to_string())? }; let mut params = json!({ - "config_toml": config_toml, "image": image, "name": name, "cluster": cluster, @@ -210,6 +213,18 @@ async fn deploy_provision_agent( if let Some(ns) = namespace { params["namespace"] = json!(ns); } + if let Some(k) = api_key.filter(|s| !s.is_empty()) { + params["api_key"] = json!(k); + } + if let Some(p) = chat_platform.filter(|s| !s.is_empty()) { + params["chat_platform"] = json!(p); + } + if let Some(t) = chat_bot_token.filter(|s| !s.is_empty()) { + params["chat_bot_token"] = json!(t); + } + if let Some(s) = chat_channel_secret.filter(|s| !s.is_empty()) { + params["chat_channel_secret"] = json!(s); + } if let Some(p) = provider.filter(|s| !s.is_empty()) { params["provider"] = json!(p); }