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
528 changes: 466 additions & 62 deletions apps/buzz-gpui/crates/app/src/agent_plugins/remote_workspace/enroll.rs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
//! The Enroll profile form in Settings: the developer profile the Sandbox
//! manager stores, sent as the signed-in owner. Its tokens are masked,
//! manager stores, sent as the signed-in owner. It shows what is on file,
//! and once a profile is, sends only what changes. Its tokens are masked,
//! cleared once sent, and never saved.

use super::enroll::{Field, Outcome, Values};
use super::enroll::{Field, OnFile, Outcome, Values};
use super::settings::SettingsTab;
use super::signing::Registration;
use crate::agent_plugins::tabs::field;
use crate::community::{AppState, IdentityState};
use buzz_identity::Identity;
use buzz_relay_client::{RelayOrigin, Session};
use buzz_ui::{
Button, ButtonSize, Colors, Surface, TextArea, TextField, TypeStyled, tokens::space,
Button, ButtonSize, Checkbox, Colors, Surface, TextArea, TextField, TypeStyled, tokens::space,
};
use gpui_kit::component::input::{InputState, TextareaState};
use gpui_kit::*;
Expand All @@ -24,6 +26,27 @@ pub struct EnrollForm {
pub status: Option<Result<Outcome, String>>,
/// How the last enrollment's signing key reached GitHub.
pub registration: Option<Registration>,
/// What the manager holds.
pub on_file: OnFile,
/// Replace the stored signing key instead of keeping it.
pub replace_key: bool,
/// Stored model credentials to remove, by models key.
pub removed: Vec<&'static str>,
}

/// The placeholder for `field`, by whether a value is on file.
fn placeholder(field: Field, held: bool) -> &'static str {
if held && field.secret() || held && field == Field::ViaProfile {
return "On file · leave blank to keep";
}
match field {
Field::GitEmail => "A verified email on your GitHub account",
Field::ViaProfile => "via profile TOML",
Field::CodexAccessToken => "Optional · at-…",
Field::ClaudeOauthToken => "Optional · sk-ant-oat01-…",
Field::OpenaiApiKey | Field::AnthropicApiKey | Field::OpenrouterApiKey => "Optional",
_ => "",
}
}

impl EnrollForm {
Expand All @@ -35,29 +58,65 @@ impl EnrollForm {
.filter(|field| *field != Field::ViaProfile)
.map(|field| {
let state = cx.new(|cx| {
let state = InputState::new(window, cx).masked(field.secret());
match field {
Field::GitEmail => state
.placeholder("A verified email on your GitHub account")
.default_value(email.clone()),
Field::CodexAccessToken => state.placeholder("Optional · at-…"),
Field::ClaudeOauthToken => state.placeholder("Optional · sk-ant-oat01-…"),
Field::OpenaiApiKey | Field::AnthropicApiKey | Field::OpenrouterApiKey => {
state.placeholder("Optional")
}
_ => state,
let state = InputState::new(window, cx)
.masked(field.secret())
.placeholder(placeholder(field, false));
if field == Field::GitEmail {
state.default_value(email.clone())
} else {
state
}
});
(field, state)
})
.collect();
let via = cx.new(|cx| TextareaState::new(window, cx).placeholder("via profile TOML"));
let via = cx.new(|cx| {
TextareaState::new(window, cx).placeholder(placeholder(Field::ViaProfile, false))
});
Self {
inputs,
via,
sending: false,
status: None,
registration: None,
on_file: OnFile::Loading,
replace_key: false,
removed: Vec::new(),
}
}

/// Shows what is on file: on-file tokens keep their stored value when
/// left blank, and the Git email starts as the stored one.
pub fn show_on_file(&mut self, on_file: OnFile, window: &mut Window, cx: &mut App) {
let summary = on_file.summary().cloned().unwrap_or_default();
let stored = on_file.summary().is_some();
for (field, input) in &self.inputs {
let held = stored && summary.holds(*field);
input.update(cx, |state, cx| {
state.set_placeholder(placeholder(*field, held), window, cx);
if *field == Field::GitEmail && held {
state.set_value(summary.git.email.clone(), window, cx);
}
});
}
let held = stored && summary.holds(Field::ViaProfile);
self.via.update(cx, |state, cx| {
state.set_placeholder(placeholder(Field::ViaProfile, held), window, cx)
});
self.removed
.retain(|key| summary.models.iter().any(|stored| stored == key));
if !summary.git.signing_key {
self.replace_key = false;
}
self.on_file = on_file;
}

/// Marks the stored model credential `key` for removal, or keeps it.
pub fn toggle_removed(&mut self, key: &'static str) {
if let Some(at) = self.removed.iter().position(|removed| *removed == key) {
self.removed.remove(at);
} else {
self.removed.push(key);
}
}

Expand Down Expand Up @@ -93,7 +152,13 @@ impl EnrollForm {
sent
}

pub fn render(&self, owner: Option<&str>, disabled: bool, c: &Colors) -> Div {
pub fn render(
&self,
owner: Option<&str>,
disabled: bool,
c: &Colors,
cx: &Context<SettingsTab>,
) -> Div {
let input = |which: Field| {
let (_, state) = self
.inputs
Expand Down Expand Up @@ -138,9 +203,13 @@ impl EnrollForm {
.child(div().text_body_sm().text_color(c.text_subtle).child(
"Once per developer: every one of your Remote workspace agents uses it. Enrolling \
creates a signing key for your agents' commits; the GitHub CLI (gh) adds it \
to your GitHub account, or the app shows you how. Sent encrypted to the \
Sandbox manager and signed as you; tokens are never saved here.",
to your GitHub account, or the app shows you how. With a profile on file, \
fill in only what changes: a blank field keeps what is stored. Sent \
encrypted to the Sandbox manager and signed as you; tokens are never saved \
here.",
))
.child(div().text_body_sm().child(self.on_file.text()))
.children(self.on_file_controls(disabled, c, cx))
.child(field(
"Buzz owner",
div()
Expand Down Expand Up @@ -176,6 +245,76 @@ impl EnrollForm {
}
}

impl EnrollForm {
/// Remove or keep each stored model credential, and replace the stored
/// signing key.
fn on_file_controls(
&self,
disabled: bool,
c: &Colors,
cx: &Context<SettingsTab>,
) -> Option<Div> {
let summary = self.on_file.summary()?;
let removals = summary.model_fields().into_iter().filter_map(|field| {
let key = field.model()?;
let removing = self.removed.contains(&key);
let label = if removing {
format!("Keep {}", field.label())
} else {
format!("Remove {}", field.label())
};
Some(
Button::new(
SharedString::from(format!("remote-workspace-remove-{key}")),
label,
)
.size(ButtonSize::Sm)
.outline()
.disabled(disabled)
.on_click(cx.listener(move |this, _, _, cx| {
this.enroll.toggle_removed(key);
cx.notify();
})),
)
});
let removing = (!self.removed.is_empty()).then(|| {
let names: Vec<_> = self
.removed
.iter()
.filter_map(|key| Field::of_model(key))
.map(Field::label)
.collect();
div()
.text_body_sm()
.text_color(c.text_subtle)
.child(format!("Enrolling removes: {}.", names.join(", ")))
});
let replace = summary.git.signing_key.then(|| {
let tab = cx.entity().downgrade();
Checkbox::new("remote-workspace-replace-signing-key", self.replace_key)
.label("Replace the signing key")
.description("Creates a new key for your agents' commits and adds it to GitHub.")
.surface(Surface::Floating)
.disabled(disabled)
.on_change(move |checked, _, cx| {
let _ = tab.update(cx, |this, cx| {
this.enroll.replace_key = checked;
cx.notify();
});
})
});
Some(
div()
.flex()
.flex_col()
.gap(space::S2)
.child(div().flex().flex_wrap().gap(space::S2).children(removals))
.children(removing)
.children(replace),
)
}
}

/// Where the agents' signing key went: added with `gh`, or the steps for the
/// developer to add it, so their agents' commits show as Verified.
fn registration_notice(registration: &Registration, c: &Colors) -> Div {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,21 @@ async fn the_done_reply_maps_to_enrolled_or_its_refusal() {
reply.clone()
})
.await
.and_then(|response| outcome(&response))
};
assert_eq!(
outcome_of(json!({"ok": true, "version_id": "v-2"})).await,
Ok(Outcome::Enrolled {
version_id: "v-2".into()
version_id: "v-2".into(),
summary: None,
})
);
// The manager's summary of the merged profile comes with it.
assert_eq!(
outcome_of(json!({"ok": true, "version_id": "v-3", "profile": summary_json()})).await,
Ok(Outcome::Enrolled {
version_id: "v-3".into(),
summary: Some(summary()),
})
);
for (code, _) in REFUSALS {
Expand Down Expand Up @@ -310,3 +320,138 @@ fn a_gh_failure_says_what_to_do() {
assert!(signing::gh_failure(stderr).contains(hint), "{stderr}");
}
}

/// The manager's summary of a stored profile, as it sends it.
fn summary_json() -> Value {
json!({
"version_id": "v-1",
"updated_at": "2026-10-23T12:00:00Z",
"git": {"name": "Tee Eight", "email": "tee@example.com", "signing_key": true},
"github": {"login": "tee8z", "token": true},
"via": {"profile": true, "op_token": true},
"models": ["claude_oauth_token"],
})
}

fn summary() -> Summary {
serde_json::from_value(summary_json()).unwrap()
}

/// With a profile on file, only filled fields are sent, a blank GitHub token
/// and the signing key are kept, and a removed credential is `null`.
#[test]
fn a_change_over_a_stored_profile_sends_only_what_changes() {
let mut values = Values::default();
values.set(Field::CodexAccessToken, " at-fixtureCodexToken ".into());
let sent = partial(
&values,
OWNER,
Changes {
removed: &["claude_oauth_token"],
..Changes::default()
},
)
.unwrap();
assert_eq!(
sent,
json!({
"version": 1,
"developer": "",
"owner_pubkey": OWNER,
"models": {"codex_access_token": "at-fixtureCodexToken", "claude_oauth_token": null},
})
);
// A new token sends its account and the Git name; a new key is sent too.
let mut values = Values::default();
values.set(Field::GithubToken, "ghp_fixtureGithubToken".into());
let account = github();
let sent = partial(
&values,
OWNER,
Changes {
github: Some(&account),
signing_key: Some(KEY),
removed: &[],
},
)
.unwrap();
assert_eq!(
sent["github"],
json!({"login": "tee8z", "token": "ghp_fixtureGithubToken"})
);
assert_eq!(
sent["git"],
json!({"name": "Tee Eight", "ssh_signing_key": KEY})
);
assert!(sent.get("via").is_none() && sent.get("models").is_none());
// Nothing filled and nothing removed is not sent.
assert_eq!(
partial(&Values::default(), OWNER, Changes::default()),
Err("Nothing to change: fill in a field, or remove a credential.".into())
);
}

/// Each field a change sends passes `profile.py`'s rule for it.
#[test]
fn a_change_is_refused_as_profile_py_refuses_its_fields() {
let refused = |field, text: &str| {
let mut values = Values::default();
values.set(field, text.into());
partial(&values, OWNER, Changes::default()).unwrap_err()
};
assert_eq!(
refused(Field::CodexAccessToken, "sk-notAnAccessToken"),
"Use a Codex access token (at-...)"
);
assert_eq!(
refused(Field::OpToken, "ops with space"),
"Invalid credential"
);
assert_eq!(
refused(Field::ViaProfile, "not = [toml"),
"Invalid via profile"
);
let mut both = Values::default();
both.set(Field::OpenaiApiKey, "sk-fixtureOpenaiKey".into());
both.set(Field::CodexAccessToken, "at-fixtureCodexToken".into());
assert_eq!(
partial(&both, OWNER, Changes::default()).unwrap_err(),
"Choose either an OpenAI API key or a Codex access token"
);
assert_eq!(
validate_partial(&json!({"version": 1, "owner_pubkey": OWNER,
"models": {"gemini_api_key": null}})),
Err("Unsupported model credential type".into())
);
}

/// The `profile` reply: the summary, nothing on file, or the refusal.
#[test]
fn the_profile_reply_names_what_is_on_file() {
assert_eq!(
on_file_reply(&json!({"ok": true, "profile": summary_json()})),
Ok(Some(summary()))
);
assert_eq!(
on_file_reply(&json!({"ok": true, "profile": null})),
Ok(None)
);
let refused =
on_file_reply(&json!({"ok": false, "code": "unauthorized_owner", "error": "No."}));
assert!(
refused
.unwrap_err()
.starts_with("Refused (unauthorized_owner). ")
);
assert!(on_file_reply(&json!("done")).is_err());
let summary = summary();
assert!(summary.holds(Field::GithubToken) && summary.holds(Field::ClaudeOauthToken));
assert!(!summary.holds(Field::CodexAccessToken));
assert_eq!(summary.model_fields(), [Field::ClaudeOauthToken]);
assert_eq!(
OnFile::Stored(summary).text(),
"On file: GitHub @tee8z · Git email tee@example.com · signing key · via profile · \
1Password token · Claude subscription token. Updated 2026-10-23 12:00 UTC."
);
assert_eq!(OnFile::Nothing.text(), "Nothing enrolled yet.");
}
Loading
Loading