diff --git a/console/src/deploy.ts b/console/src/deploy.ts index e87710e..a0c107a 100644 --- a/console/src/deploy.ts +++ b/console/src/deploy.ts @@ -27,6 +27,22 @@ function errText(e: unknown): string { return e instanceof Error ? e.message : String(e); } +// studio#135: same localStorage key main.ts's Config-folder setting uses +// (kept as a duplicated literal, not a shared export — every module in +// this console reads its own localStorage keys independently, matching +// the existing theme/log-level/config-folder settings' own pattern). +// Brett's explicit ordering ("write to local first, write to s3 if +// needed") can only actually be guaranteed inside the sidecar — passing +// the folder through lets provision_agent[_k8s] write it before touching +// S3 at all, rather than the console writing a copy after the fact. +function localConfigFolder(): string | undefined { + try { + return localStorage.getItem("oab-studio.configFolder") ?? undefined; + } catch { + return undefined; + } +} + function escapeHtml(s: string): string { return s .replace(/&/g, "&") @@ -487,6 +503,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null chat_bot_token: chatTokenInput.value.trim() || undefined, chat_channel_secret: chatSecretInput.value.trim() || undefined, acp_enabled: acpCheckbox.checked, + local_config_folder: localConfigFolder(), ...(isK8s ? { provider: "k8s", context, expected_principal: expectedPrincipal } : {}), }); } catch (e) { diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 87e0c04..72ceedc 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -179,6 +179,7 @@ pub fn tools() -> Vec { "chat_bot_token": { "type": "string", "description": "Discord/Telegram bot token, or LINE's channel access token." }, "chat_channel_secret": { "type": "string", "description": "LINE only." }, "acp_enabled": { "type": "boolean", "description": "Enable the reverse-MCP-over-ACP tunnel on this agent. Defaults to true when omitted (studio#119: Studio-deployed agents default to ACP on). Not honorable for every vendor — the caller is responsible for not setting this true for a vendor that can't support it (e.g. agy, whose bridge bypasses /acp entirely)." }, + "local_config_folder": { "type": "string", "description": "Optional local directory (studio#135) — when set, config.toml is written to //config.toml *before* anything touches S3. Omit to skip the local mirror entirely." }, "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)." }, @@ -737,6 +738,11 @@ impl OabMcp { // studio#128 made it caller-controlled. acp_enabled: args.get("acp_enabled").and_then(Value::as_bool).unwrap_or(true), }; + // studio#135: Brett's explicit ordering — write local first, S3 + // (via provision_agent[_k8s]'s existing upload) after. Optional: + // the console only ever sends this when the operator has a local + // Config folder set at all (its own opt-in setting). + let local_config_folder = args.get("local_config_folder").and_then(Value::as_str); if args.get("provider").and_then(Value::as_str) == Some("k8s") { let context = args.get("context").and_then(Value::as_str); @@ -749,6 +755,7 @@ impl OabMcp { image, input, expected_principal, + local_config_folder, ) .await?; return Ok(json!({ @@ -779,6 +786,7 @@ impl OabMcp { name, image, input, + local_config_folder, ) .await?; Ok(json!({ diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index ba71823..80e1158 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -1417,6 +1417,27 @@ usercron_path = "cronjob.toml" ) } +/// Mirrors a wizard-generated config.toml into the operator's local "Config +/// folder" (studio#135 — `//config.toml`), *before* anything +/// touches S3. Brett's explicit ordering: "Wizard should write to local +/// first, write to s3 if needed" — the only place that ordering can +/// actually be guaranteed is here, inside the sidecar, since the sidecar +/// (not the console) is what does the S3 upload; if the console wrote the +/// local copy itself after the fact (from a config_toml value handed back +/// in the response), S3 would always have already happened first no matter +/// what. Written before `inject_pre_seed_hook` runs — that mutation wires +/// in an S3 zip URI that's meaningless for a local reference copy, so the +/// local file is the clean, human-authored text the operator actually +/// configured, not an ECS/k8s-specific artifact. +fn write_local_agent_config(folder: &str, name: &str, config_toml: &[u8]) -> anyhow::Result<()> { + let dir = std::path::Path::new(folder).join(name); + std::fs::create_dir_all(&dir) + .map_err(|e| anyhow::anyhow!("failed to create {}: {e}", dir.display()))?; + let path = dir.join("config.toml"); + std::fs::write(&path, config_toml) + .map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display())) +} + /// [`provision_from_library`], but for the studio#128 wizard's direct path — /// no compose library, no `template ⊕ overlay`: builds config.toml itself /// from [`AgentWizardInput`] (via [`generate_agent_config`]) instead of @@ -1432,9 +1453,13 @@ pub async fn provision_agent( name: &str, image: &str, input: AgentWizardInput, + local_config_folder: Option<&str>, ) -> anyhow::Result { provision_agent_secrets(aws_config, namespace, name, &input).await?; let config_toml = generate_agent_config(namespace, name, &input).into_bytes(); + if let Some(folder) = local_config_folder { + write_local_agent_config(folder, name, &config_toml)?; + } let mut bundle = studio_compose::Bundle { image_tag: image.to_string(), files: std::collections::BTreeMap::from([("config.toml".to_string(), config_toml)]), @@ -1519,6 +1544,7 @@ pub async fn provision_agent_k8s( image: &str, input: AgentWizardInput, expected_principal: Option<&str>, + local_config_folder: Option<&str>, ) -> anyhow::Result { if input.chat_platform.is_some() { anyhow::bail!( @@ -1529,6 +1555,9 @@ pub async fn provision_agent_k8s( } provision_agent_secrets(aws_config, namespace, name, &input).await?; let config_toml = generate_agent_config(namespace, name, &input).into_bytes(); + if let Some(folder) = local_config_folder { + write_local_agent_config(folder, name, &config_toml)?; + } 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 6e551da..5b72108 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -193,6 +193,7 @@ async fn deploy_provision_agent( chat_bot_token: Option, chat_channel_secret: Option, acp_enabled: Option, + local_config_folder: Option, cluster: Option, provider: Option, context: Option, @@ -229,6 +230,9 @@ async fn deploy_provision_agent( if let Some(a) = acp_enabled { params["acp_enabled"] = json!(a); } + if let Some(f) = local_config_folder.filter(|s| !s.is_empty()) { + params["local_config_folder"] = json!(f); + } if let Some(p) = provider.filter(|s| !s.is_empty()) { params["provider"] = json!(p); }