Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@
<p class="config-hint" id="deploy-acp-agy-hint" hidden>
agy doesn't support ACP — disabled for this vendor.
</p>
<label id="deploy-acp-token-wrap"
>ACP token
<span class="compose-name-row">
<input id="deploy-acp-token" class="compose-input" type="password" spellcheck="false" autocomplete="off" />
<button type="button" id="deploy-acp-token-generate" class="cfg-btn cfg-btn-ghost" title="Generate a random token">Generate</button>
</span>
</label>
<label
>Agent name
<span class="compose-name-row">
Expand Down
26 changes: 26 additions & 0 deletions console/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
const chatSecretInput = document.getElementById("deploy-chat-secret") as HTMLInputElement | null;
const acpCheckbox = document.getElementById("deploy-acp-enabled") as HTMLInputElement | null;
const acpAgyHint = document.getElementById("deploy-acp-agy-hint");
const acpTokenWrap = document.getElementById("deploy-acp-token-wrap");
const acpTokenInput = document.getElementById("deploy-acp-token") as HTMLInputElement | null;
const acpTokenGenerateBtn = document.getElementById("deploy-acp-token-generate") as HTMLButtonElement | null;
const agentNameInput = document.getElementById("deploy-name") as HTMLInputElement | null;
const agentNameShuffleBtn = document.getElementById("deploy-name-shuffle") as HTMLButtonElement | null;
const deployBtn = document.getElementById("deploy-deploy-btn") as HTMLButtonElement | null;
Expand Down Expand Up @@ -190,6 +193,9 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
!chatSecretInput ||
!acpCheckbox ||
!acpAgyHint ||
!acpTokenWrap ||
!acpTokenInput ||
!acpTokenGenerateBtn ||
!agentNameInput ||
!agentNameShuffleBtn ||
!deployBtn
Expand Down Expand Up @@ -219,6 +225,16 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
}
};

// studio#136: the ACP token field only makes sense while ACP itself is
// on — hidden (not just left blank) when the checkbox is unchecked or
// disabled, same show/hide-on-selection pattern as the chat token
// fields above.
const applyAcpMode = (): void => {
const enabled = acpCheckbox.checked && !acpCheckbox.disabled;
acpTokenWrap.hidden = !enabled;
if (!enabled) acpTokenInput.value = "";
};

// studio#128: agy's bridge bypasses openab-gateway's /acp route entirely
// (confirmed by reading agy-acp/src/main.rs) — forced off, not just
// defaulted off, so a leftover checked state from a previous vendor can't
Expand All @@ -231,6 +247,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
acpCheckbox.disabled = isAgy;
acpAgyHint.hidden = !isAgy;
if (isAgy) acpCheckbox.checked = false;
applyAcpMode();
deviceAuthHint.hidden = !DEVICE_AUTH_VENDORS.has(vendor);
};

Expand Down Expand Up @@ -399,6 +416,14 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null
void loadVendorImage();
});
chatPlatformSel.addEventListener("change", applyChatPlatformMode);
acpCheckbox.addEventListener("change", applyAcpMode);
acpTokenGenerateBtn.addEventListener("click", () => {
// Same shape the sidecar generates itself (uuid v4) when this field is
// left blank — a convenience for operators who want to know the token
// before deploying (e.g. to hand it to a client ahead of time), not a
// requirement: an empty field still gets a server-generated one.
acpTokenInput.value = crypto.randomUUID();
});
agentNameShuffleBtn.addEventListener("click", () => {
agentNameInput.value = randomGreekName();
});
Expand Down Expand Up @@ -503,6 +528,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,
acp_token: acpCheckbox.checked ? acpTokenInput.value.trim() || undefined : undefined,
local_config_folder: localConfigFolder(),
...(isK8s ? { provider: "k8s", context, expected_principal: expectedPrincipal } : {}),
});
Expand Down
14 changes: 14 additions & 0 deletions console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1328,6 +1328,15 @@ button.act:disabled {
font-size: 12px;
color: var(--muted);
}
/* studio#128/#136 bug (Brett caught live): `.compose-form label`'s own
`display: flex` has equal-or-higher specificity than the UA stylesheet's
`[hidden] { display: none }`, so toggling `.hidden` on a <label> inside
`.compose-form` (chat token/secret wraps) did nothing — same CSS gotcha
already hit once in studio#83's implementation (see that memory). Force
the override explicitly rather than relying on cascade order. */
.compose-form label[hidden] {
display: none;
}
.compose-select {
padding: 6px 8px;
border: 1px solid var(--border);
Expand Down Expand Up @@ -1491,6 +1500,11 @@ button.act:disabled {
font-size: 12px;
color: var(--muted);
}
/* Same specificity gotcha as `.compose-form label[hidden]` above — applies
here too (the k8s "new namespace" field). */
.deploy-identity label[hidden] {
display: none;
}
.deploy-compose-head {
font-size: 12px;
font-weight: 600;
Expand Down
2 changes: 2 additions & 0 deletions crates/oab-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ pub fn tools() -> Vec<Tool> {
"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)." },
"acp_token": { "type": "string", "description": "Optional (studio#136), only meaningful when acp_enabled is true. The OPENAB_ACP_AUTH_KEY to use, instead of generating a random one." },
"local_config_folder": { "type": "string", "description": "Optional local directory (studio#135) — when set, config.toml is written to <local_config_folder>/<name>/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." },
Expand Down Expand Up @@ -737,6 +738,7 @@ impl OabMcp {
// build_default_manifest's original hardcoded behavior before
// studio#128 made it caller-controlled.
acp_enabled: args.get("acp_enabled").and_then(Value::as_bool).unwrap_or(true),
acp_token: args.get("acp_token").and_then(Value::as_str).map(str::to_string),
};
// studio#135: Brett's explicit ordering — write local first, S3
// (via provision_agent[_k8s]'s existing upload) after. Optional:
Expand Down
65 changes: 48 additions & 17 deletions crates/studio-cp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1058,22 +1058,25 @@ fn default_config_from_uri(bucket: &str, namespace: &str, name: &str) -> String
format!("s3://{bucket}/{}/config.toml", studio_compose::artifacts_prefix(namespace, name))
}

/// Generates a random `OPENAB_ACP_AUTH_KEY` and stores it in Secrets
/// Manager under the same `oab/{namespace}/{name}` convention `oabctl
/// create`'s CLI wizard uses for the Discord bot token (studio#119
/// follow-up — Studio-deployed agents default to ACP enabled). Fresh JSON
/// blob rather than a read-merge-write: this only ever runs from
/// Stores an `OPENAB_ACP_AUTH_KEY` in Secrets Manager under the same
/// `oab/{namespace}/{name}` convention `oabctl create`'s CLI wizard uses
/// for the Discord bot token (studio#119 follow-up — Studio-deployed
/// agents default to ACP enabled). Uses `token` if the caller supplied one
/// (studio#136 — the wizard's "Generate" button / operator-typed value),
/// otherwise generates a fresh one. Fresh JSON blob rather than a
/// read-merge-write: this only ever runs from
/// `build_default_manifest`/`build_default_k8s_manifest`, both exclusively
/// on the first-ever-deploy path, so nothing else has written to this
/// secret name yet at this point in the flow.
async fn provision_acp_auth_secret(
aws_config: &aws_config::SdkConfig,
namespace: &str,
name: &str,
token: Option<&str>,
) -> anyhow::Result<String> {
let sm = aws_sdk_secretsmanager::Client::new(aws_config);
let secret_name = format!("oab/{namespace}/{name}");
let key = uuid::Uuid::new_v4().to_string();
let key = token.map(str::to_string).unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let secret_obj = serde_json::json!({ "OPENAB_ACP_AUTH_KEY": key });
oabctl::create::store_secret(&sm, &secret_name, &secret_obj.to_string()).await?;
Ok(format!("aws-sm://{secret_name}#OPENAB_ACP_AUTH_KEY"))
Expand All @@ -1098,12 +1101,13 @@ async fn build_default_manifest(
image: &str,
bucket: &str,
acp_enabled: bool,
acp_token: Option<&str>,
) -> anyhow::Result<oabctl::manifest::OABServiceManifest> {
let net = oabctl::create::default_networking(aws_config, name).await?;
let config_from = default_config_from_uri(bucket, namespace, name);
let mut secrets = std::collections::HashMap::new();
if acp_enabled {
let acp_auth_ref = provision_acp_auth_secret(aws_config, namespace, name).await?;
let acp_auth_ref = provision_acp_auth_secret(aws_config, namespace, name, acp_token).await?;
secrets.insert("OPENAB_ACP_AUTH_KEY".to_string(), acp_auth_ref);
}
Ok(oabctl::manifest::OABServiceManifest {
Expand Down Expand Up @@ -1219,7 +1223,7 @@ pub async fn provision_from_library(
// (compose-library) path; the caller-controlled toggle is
// studio#128's wizard-only `provision_agent`.
let mut manifest =
build_default_manifest(aws_config, namespace, name, &image, &bucket, true).await?;
build_default_manifest(aws_config, namespace, name, &image, &bucket, true, None).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?
Expand Down Expand Up @@ -1269,6 +1273,12 @@ pub struct AgentWizardInput {
/// non-empty `chat_platform` regardless of this field — unrelated
/// constraints.
pub acp_enabled: bool,
/// studio#136: operator-supplied ACP auth key, wired through when
/// `acp_enabled` is true (ignored otherwise). `None`/empty falls back
/// to a freshly generated one, same as before this field existed —
/// this is a convenience for an operator who wants to know the token
/// ahead of deploy, not a requirement.
pub acp_token: Option<String>,
}

/// Stores [`AgentWizardInput`]'s secret-bearing fields in the same
Expand Down Expand Up @@ -1498,8 +1508,16 @@ pub async fn provision_agent(
.await?
}
None => {
let mut manifest =
build_default_manifest(aws_config, namespace, name, image, &bucket, input.acp_enabled).await?;
let mut manifest = build_default_manifest(
aws_config,
namespace,
name,
image,
&bucket,
input.acp_enabled,
input.acp_token.as_deref(),
)
.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?
Expand Down Expand Up @@ -1596,6 +1614,7 @@ pub async fn provision_agent_k8s(
&bucket,
expected_principal,
input.acp_enabled,
input.acp_token.as_deref(),
)
.await?
}
Expand Down Expand Up @@ -1634,9 +1653,10 @@ fn k8s_service_account_from_principal(expected_principal: Option<&str>) -> Optio
.map(|(_namespace, name)| name.to_string())
}

/// Generates a random `OPENAB_ACP_AUTH_KEY` and server-side-applies it into
/// a k8s `Secret` named `{name}-acp` in `namespace` (studio#119 follow-up —
/// k8s counterpart of [`provision_acp_auth_secret`]). `Patch::Apply` rather
/// Server-side-applies an `OPENAB_ACP_AUTH_KEY` into a k8s `Secret` named
/// `{name}-acp` in `namespace` (studio#119 follow-up — k8s counterpart of
/// [`provision_acp_auth_secret`]). Uses `token` if the caller supplied one
/// (studio#136), otherwise generates a fresh one. `Patch::Apply` rather
/// than a plain `create()`: this only ever runs from
/// `build_default_k8s_manifest` on the first-ever-deploy path, so a fresh
/// object is expected, but apply is idempotent if a retried deploy attempt
Expand All @@ -1646,14 +1666,15 @@ async fn provision_acp_auth_k8s_secret(
context: Option<&str>,
namespace: &str,
name: &str,
token: Option<&str>,
) -> anyhow::Result<String> {
use k8s_openapi::api::core::v1::Secret;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use kube::api::{Api, Patch, PatchParams};

let client = k8s_client_for(context).await?;
let secret_name = format!("{name}-acp");
let key = uuid::Uuid::new_v4().to_string();
let key = token.map(str::to_string).unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let mut string_data = std::collections::BTreeMap::new();
string_data.insert("OPENAB_ACP_AUTH_KEY".to_string(), key);
let secret = Secret {
Expand Down Expand Up @@ -1693,11 +1714,12 @@ async fn build_default_k8s_manifest(
bucket: &str,
expected_principal: Option<&str>,
acp_enabled: bool,
acp_token: Option<&str>,
) -> anyhow::Result<oabctl::manifest::OABServiceManifest> {
let config_from = default_config_from_uri(bucket, namespace, name);
let mut secrets = std::collections::HashMap::new();
if acp_enabled {
let acp_auth_ref = provision_acp_auth_k8s_secret(context, namespace, name).await?;
let acp_auth_ref = provision_acp_auth_k8s_secret(context, namespace, name, acp_token).await?;
secrets.insert("OPENAB_ACP_AUTH_KEY".to_string(), acp_auth_ref);
}
Ok(oabctl::manifest::OABServiceManifest {
Expand Down Expand Up @@ -1793,8 +1815,17 @@ pub async fn provision_from_library_k8s(
// (compose-library) path; the caller-controlled toggle is
// studio#128's wizard-only `provision_agent_k8s`.
None => {
build_default_k8s_manifest(context, namespace, name, &image, &bucket, expected_principal, true)
.await?
build_default_k8s_manifest(
context,
namespace,
name,
&image,
&bucket,
expected_principal,
true,
None,
)
.await?
}
};
manifest.spec.bundle_from = Some(oabctl::studio_api::bundle_from_uri(&bucket, namespace, name));
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ async fn deploy_provision_agent(
chat_bot_token: Option<String>,
chat_channel_secret: Option<String>,
acp_enabled: Option<bool>,
acp_token: Option<String>,
local_config_folder: Option<String>,
cluster: Option<String>,
provider: Option<String>,
Expand Down Expand Up @@ -230,6 +231,9 @@ async fn deploy_provision_agent(
if let Some(a) = acp_enabled {
params["acp_enabled"] = json!(a);
}
if let Some(t) = acp_token.filter(|s| !s.is_empty()) {
params["acp_token"] = json!(t);
}
if let Some(f) = local_config_folder.filter(|s| !s.is_empty()) {
params["local_config_folder"] = json!(f);
}
Expand Down
Loading