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
13 changes: 13 additions & 0 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,19 @@
explicit auth error, not a silent drift.
</p>
</form>
<div class="config-folder">
<label>Config folder</label>
<div class="config-folder-row">
<span class="config-folder-path" id="config-folder-path">— not set —</span>
<button class="cfg-btn cfg-btn-ghost" id="config-folder-pick" type="button">Choose folder…</button>
</div>
<p class="config-hint">
Local folder the New Fleet wizard mirrors each agent's generated
config.toml into (<code>&lt;folder&gt;/&lt;agent-name&gt;/config.toml</code>),
alongside the S3 copy it deploys from — this is what "view an
agent's config" reads from, no S3 round-trip.
</p>
</div>
</div>
</aside>
</main>
Expand Down
41 changes: 41 additions & 0 deletions console/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,47 @@ const mcp = mcpEl ? createPane(mcpEl, () => flag("mcpio")) : null;
});
})();

// Local "Config folder" (studio#128) — where the New Fleet wizard mirrors
// each agent's generated config.toml (alongside the S3 copy it deploys
// from) and where the "view an agent's config" screen reads from, no S3
// round-trip. Persisted like the theme/log-level settings above
// (localStorage), not Tauri's app-config-dir — this setting *points at*
// other local files, storing it in the hidden config dir would just be an
// extra layer of indirection for no reason.
(function setupConfigFolder(): void {
const pathEl = document.getElementById("config-folder-path");
const pickBtn = document.getElementById("config-folder-pick");
if (!pathEl || !pickBtn) return;
const KEY = "oab-studio.configFolder";

const render = (): void => {
let saved: string | null = null;
try {
saved = localStorage.getItem(KEY);
} catch {
/* storage unavailable — falls through to the placeholder */
}
pathEl.textContent = saved || "— not set —";
};
render();

pickBtn.addEventListener("click", () => {
const invoke = tauriInvoke();
if (!invoke) return;
invoke<string | null>("plugin:dialog|open", { options: { directory: true } })
.then((path) => {
if (!path) return;
try {
localStorage.setItem(KEY, path);
} catch {
/* storage unavailable — the choice still applies this session */
}
render();
})
.catch((e) => note("error", `config folder pick failed: ${errText(e)}`));
});
})();

// Build stamp (injected by vite) — shown under the brand and logged on launch,
// so it's obvious which commit this build is.
const BUILD = `v${__APP_VERSION__} · ${__BUILD_SHA__}`;
Expand Down
32 changes: 32 additions & 0 deletions console/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,38 @@ button.act:disabled {
color: var(--muted);
}

.config-folder {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 420px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.config-folder > label {
font-size: 12px;
color: var(--muted);
}
.config-folder-row {
display: flex;
align-items: center;
gap: 12px;
}
.config-folder-path {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--text);
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
}

/* Compose (agent-deployment ADR, slice 1): the deploy panel's bundle-preview
step (`deploy.ts`'s `#deploy-compose`) — the standalone authoring tab this
used to also style is gone, see compose.ts. */
Expand Down
26 changes: 25 additions & 1 deletion crates/oab-mcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,17 @@ pub fn tools() -> Vec<Tool> {
"properties": {}
})),
),
Tool::new(
"resolve_vendor_image_tags",
"Resolve a vendor's real, currently-published Stable/Beta image tags on ghcr.io/openabdev/openab (studio#128 — backs the New Fleet wizard's Vendor + Image tag fields). \"Beta\" is the hourly rolling pre-beta-<vendor> build; \"Stable\" is the newest openab release whose matching <version>-<vendor> image is confirmed to actually exist (a release existing doesn't guarantee a matching image was ever built — the build workflow is a manual, disconnected step). Anonymous GHCR/GitHub access, no auth needed (public package/repo). Either or both fields come back null if nothing verified — not an error; the caller should fall back to a plain editable text field.",
as_map(json!({
"type": "object",
"properties": {
"vendor": { "type": "string", "description": "Vendor name, e.g. \"claude\", \"codex\", \"cursor\", \"kiro\", \"antigravity\"." }
},
"required": ["vendor"]
})),
),
Tool::new(
"list_namespaces",
"List namespaces in the cluster a kubeconfig context resolves to. Read-only; backs the New Fleet wizard's namespace <select> (with a manual-entry fallback for a namespace that doesn't exist yet — this can only list what's already there).",
Expand Down Expand Up @@ -422,6 +433,7 @@ impl OabMcp {
"deploy_apply" => self.t_apply(args).await,
"deploy_provision" => self.t_provision(args).await,
"deploy_provision_agent" => self.t_provision_agent(args).await,
"resolve_vendor_image_tags" => self.t_resolve_vendor_image_tags(args).await,
"deploy_scale" => self.t_scale(args).await,
"deploy_delete" => self.t_delete(args).await,
"runtime_context" => self.t_runtime_context(args).await,
Expand Down Expand Up @@ -777,6 +789,17 @@ impl OabMcp {
}))
}

/// `resolve_vendor_image_tags` (studio#128) — no fleet/cluster/AWS
/// credential resolution at all, this is pure GHCR/GitHub read access.
async fn t_resolve_vendor_image_tags(&self, args: &Map<String, Value>) -> Result<Value> {
let vendor = args
.get("vendor")
.and_then(Value::as_str)
.ok_or_else(|| anyhow::anyhow!("missing required arg: vendor"))?;
let tags = scp::resolve_vendor_image_tags(vendor).await;
Ok(json!({ "beta": tags.beta, "stable": tags.stable }))
}

async fn t_apply(&self, args: &Map<String, Value>) -> Result<Value> {
let cluster = self.target(args)?.cluster;
let manifest = args
Expand Down Expand Up @@ -1109,7 +1132,7 @@ mod tests {
.iter()
.map(|t| t["name"].as_str().expect("tool has a name").to_string())
.collect();
assert_eq!(names.len(), 18);
assert_eq!(names.len(), 19);
for expected in [
"deploy_list",
"deploy_get",
Expand All @@ -1118,6 +1141,7 @@ mod tests {
"deploy_apply",
"deploy_provision",
"deploy_provision_agent",
"resolve_vendor_image_tags",
"deploy_scale",
"deploy_delete",
"runtime_context",
Expand Down
1 change: 1 addition & 0 deletions crates/oabctl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ mod scale;
mod secrets;
pub mod status;
pub mod studio_api;
pub mod vendor_images;

pub use apply::{
apply_manifests, AppliedService, ApplyAction, ApplyError, ApplyErrorKind, ApplyOptions,
Expand Down
139 changes: 139 additions & 0 deletions crates/oabctl/src/vendor_images.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! Vendor image tag resolution (studio#128): resolves a vendor name (e.g.
//! `claude`, `codex`, `cursor`, `kiro`, `antigravity`) to real,
//! currently-published `ghcr.io/openabdev/openab` image tags — "Beta" (the
//! hourly rolling `pre-beta-<vendor>` build) and "Stable" (the newest
//! openab release whose matching `<version>-<vendor>` image is confirmed to
//! actually exist).
//!
//! A GitHub release tag existing does **not** guarantee a matching image
//! was ever published: the image-build workflow (`build-images.yml`) is a
//! manual `workflow_dispatch` step, completely disconnected from cutting a
//! release — confirmed by reading both workflows. So "stable" has to be
//! verified against GHCR directly, not inferred from the release list
//! alone.
//!
//! All access here is anonymous — no GitHub token needed. `ghcr.io` speaks
//! the standard OCI Distribution API, and `openabdev/openab` is a public
//! package: `GET /token?scope=repository:<repo>:pull` mints a scoped
//! anonymous pull token, the same flow `docker pull` uses against a public
//! image with no login. This is a different API from GitHub's Packages
//! REST API (`/orgs/.../packages/...`), which *does* require a
//! `read:packages`-scoped token even for public packages — deliberately
//! not used here for that reason.

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

const GHCR_REPO: &str = "openabdev/openab";

#[derive(Deserialize)]
struct TokenResponse {
token: String,
}

async fn ghcr_pull_token(client: &reqwest::Client) -> Result<String> {
let url = format!("https://ghcr.io/token?scope=repository:{GHCR_REPO}:pull");
let resp: TokenResponse = client
.get(&url)
.send()
.await
.context("failed to reach ghcr.io token endpoint")?
.error_for_status()
.context("ghcr.io token endpoint returned an error")?
.json()
.await
.context("ghcr.io token endpoint returned invalid JSON")?;
Ok(resp.token)
}

/// Does `ghcr.io/openabdev/openab:<tag>` actually exist? A manifest `HEAD`,
/// not a full pull — no image bytes transferred, just an existence check.
async fn ghcr_tag_exists(client: &reqwest::Client, token: &str, tag: &str) -> Result<bool> {
let url = format!("https://ghcr.io/v2/{GHCR_REPO}/manifests/{tag}");
let resp = client
.head(&url)
.bearer_auth(token)
.header(
"Accept",
"application/vnd.oci.image.index.v1+json, \
application/vnd.docker.distribution.manifest.list.v2+json, \
application/vnd.oci.image.manifest.v1+json, \
application/vnd.docker.distribution.manifest.v2+json",
)
.send()
.await
.with_context(|| format!("failed to check ghcr.io tag '{tag}'"))?;
Ok(resp.status().is_success())
}

#[derive(Deserialize)]
struct GhRelease {
tag_name: String,
prerelease: bool,
}

/// Real (non-beta) openab release version numbers, newest first — matches
/// GitHub's own default ordering for this endpoint. Filters on both the
/// `prerelease` flag *and* the tag name itself: at least one real release
/// (`openab-0.10.0-beta.3`) has `prerelease: false` despite its name, so
/// the flag alone isn't reliable.
async fn openab_release_versions(client: &reqwest::Client) -> Result<Vec<String>> {
let releases: Vec<GhRelease> = client
.get("https://api.github.com/repos/openabdev/openab/releases")
// GitHub's REST API rejects requests with no User-Agent.
.header("User-Agent", "openab-studio")
.send()
.await
.context("failed to reach GitHub releases API")?
.error_for_status()
.context("GitHub releases API returned an error")?
.json()
.await
.context("GitHub releases API returned invalid JSON")?;
Ok(releases
.into_iter()
.filter(|r| !r.prerelease && r.tag_name.starts_with("openab-") && !r.tag_name.contains("-beta"))
.map(|r| r.tag_name.trim_start_matches("openab-").to_string())
.collect())
}

#[derive(Debug, Clone, Default, Serialize)]
pub struct VendorImageTags {
/// `pre-beta-<vendor>` if the GHCR check confirms it exists.
pub beta: Option<String>,
/// The newest release version whose `<version>-<vendor>` image is
/// confirmed to exist on GHCR. `None` if no release has a matching
/// image yet (or the GitHub/GHCR calls themselves failed).
pub stable: Option<String>,
}

/// Resolves both channels for `vendor`. Never fails outright — a failed
/// GHCR/GitHub call just leaves the corresponding field (or both) `None`
/// rather than erroring the whole wizard; the console falls back to a
/// plain editable text field either way (Brett: "Image tag is allow to be
/// manually input by user").
pub async fn resolve_vendor_image_tags(vendor: &str) -> VendorImageTags {
let client = reqwest::Client::new();
let mut out = VendorImageTags::default();

let Ok(token) = ghcr_pull_token(&client).await else {
return out;
};

let beta_tag = format!("pre-beta-{vendor}");
if ghcr_tag_exists(&client, &token, &beta_tag).await.unwrap_or(false) {
out.beta = Some(beta_tag);
}

if let Ok(versions) = openab_release_versions(&client).await {
for version in versions {
let candidate = format!("{version}-{vendor}");
if ghcr_tag_exists(&client, &token, &candidate).await.unwrap_or(false) {
out.stable = Some(candidate);
break;
}
}
}

out
}
9 changes: 9 additions & 0 deletions crates/studio-cp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1776,6 +1776,15 @@ pub async fn delete_deployment(
oabctl::studio_api::delete(aws_config, resource, name, cluster, namespace, None).await
}

/// Resolves `vendor`'s real, currently-published Stable/Beta image tags on
/// GHCR (studio#128 — backs the New Fleet wizard's Vendor + Image tag
/// fields). No `aws_config` needed: this is pure GHCR/GitHub API access,
/// unauthenticated (see `oabctl::vendor_images` for why that's safe for a
/// public package).
pub async fn resolve_vendor_image_tags(vendor: &str) -> oabctl::vendor_images::VendorImageTags {
oabctl::vendor_images::resolve_vendor_image_tags(vendor).await
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ tauri-plugin-updater = "2"
# commit rather than the floating `dev` branch for reproducibility.
fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs", rev = "c4c45d503ea115a839aae718d02f79e7c7f0f673" }

# Native folder-picker dialog (studio#128) — lets the operator choose the
# local "Config folder" the New Fleet wizard mirrors generated config.toml
# files into, instead of a hidden hardcoded path.
tauri-plugin-dialog = "2"

# Its own workspace so the desktop build stays out of the root workspace's
# `cargo build --workspace` (kept to the small crates + CI).
[workspace]
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"core:default",
"shell:allow-stdin-write",
"shell:allow-kill",
"dialog:allow-open",
{
"identifier": "shell:allow-spawn",
"allow": [
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,30 @@ async fn list_k8s_contexts(core: tauri::State<'_, Core>) -> Result<Value, String
}
}

/// Bridge command: a vendor's real, currently-published Stable/Beta image
/// tags on GHCR (studio#128), via the sidecar's `resolve_vendor_image_tags`
/// tool — backs the New Fleet wizard's Vendor + Image tag fields.
#[tauri::command]
async fn resolve_vendor_image_tags(core: tauri::State<'_, Core>, vendor: String) -> Result<Value, String> {
let client = {
let guard = core.0.lock().await;
guard
.as_ref()
.cloned()
.ok_or_else(|| "core not started yet".to_string())?
};
match client
.call_tool("resolve_vendor_image_tags", json!({ "vendor": vendor }))
.await
{
Ok(v) => Ok(v),
Err(e) => {
client.log("error", &format!("resolve_vendor_image_tags: {e}"));
Err(e)
}
}
}

/// Bridge command: namespaces in a kubeconfig context (studio#104), via the
/// sidecar's `list_namespaces` tool — backs the New Fleet wizard's namespace
/// field's autocomplete.
Expand Down Expand Up @@ -773,6 +797,7 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
Expand Down Expand Up @@ -800,6 +825,7 @@ pub fn run() {
fleet_config_write,
list_aws_profiles,
list_k8s_contexts,
resolve_vendor_image_tags,
list_namespaces,
list_service_accounts,
k8s_fleet_config,
Expand Down
Loading