Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
936c100
feat(desktop): move persisted secrets from JSON into keyring via gen-…
Aug 10, 2026
ebda26b
fix(desktop): wire fail-closed secrets + legacy dir cleanup + Phase 2…
Aug 10, 2026
a787d83
fix(desktop): add ManagedAgentRecord.secrets_unavailable + spawn refu…
Aug 10, 2026
de0b477
refactor(desktop): split secret-projection modules to clear file-size…
Aug 10, 2026
6184291
fix(desktop): address Thufir r2 — fail-closed tiers, GC lock, dev mar…
Aug 10, 2026
fe2b5df
fix(desktop): resolve Thufir r2 — one secret-availability gate, confl…
Aug 11, 2026
06f2ce9
fix(desktop): harden keyring secret projection fail-closed + cross-pr…
Aug 11, 2026
5adee0c
chore: merge origin/main into keyring-secret-projection
Aug 11, 2026
68e2fa1
chore(desktop): extract persona_drift_state to runtime/drift sibling
Aug 11, 2026
5406fb0
fix(desktop): stop boot migration and instance saves from clearing ke…
Aug 12, 2026
a6d1c40
chore: merge origin/main into keyring-secret-projection
Aug 12, 2026
3f7e1b6
refactor(desktop): split log helpers out of managed_agents storage
Aug 12, 2026
1d3c27c
fix(desktop): harden blob lockfile against tmp-cleaner unlink race
Aug 13, 2026
07ef044
perf(desktop): batch secret projection into one blob mutation with ge…
Aug 13, 2026
762a3ee
fix(desktop): serialize identity persist against agent saves on the t…
Aug 13, 2026
a3dd40a
Merge remote-tracking branch 'origin/main' into duncan/keyring-secret…
Aug 13, 2026
4f362f0
refactor(desktop): split custom_harnesses tests into sibling file
Aug 13, 2026
76ceb42
refactor(desktop): split databricks reconcile out of migration.rs
Aug 13, 2026
f048f3b
feat(desktop): project custom-harness env secrets into the keyring
Aug 13, 2026
67da23b
docs(desktop): correct harness .bak safety comments for inline fallback
Aug 13, 2026
532d9ad
refactor(desktop): unify the definition-secrets-unavailable spawn gate
Aug 13, 2026
243a39d
test(desktop): replace vacuous keyring-gate coverage with production-…
Aug 13, 2026
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
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/commands/agent_config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ fn agent_record() -> ManagedAgentRecord {
agent_command_override: None,
persona_source_version: None,
provider: None,
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}

Expand All @@ -144,6 +148,7 @@ fn persona_with_model(model: &str) -> AgentDefinition {
parallelism: None,
created_at: "".to_string(),
updated_at: "".to_string(),
secrets_unavailable: false,
}
}

Expand Down
102 changes: 26 additions & 76 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ pub async fn get_agent_models(
// so model discovery runs against the persona's current harness, not the
// frozen record snapshot. An explicit per-agent override wins.
let personas = load_personas(&app).unwrap_or_default();
let global = load_global_agent_config(&app).unwrap_or_default();
// Fail closed on unavailable effective secrets BEFORE any credentialed
// provider request or model subprocess (mirrors the spawn/deploy gates).
let global = crate::managed_agents::effective_config::require_effective_secrets_available(
record,
&personas,
load_global_agent_config(&app),
)?;

// Single pure helper — descriptor + authoritative model/provider
// resolver, packaged so the linked-agent regression test binds the
Expand Down Expand Up @@ -368,81 +374,11 @@ fn openai_compatible_models_url_for_discovery(env: &BTreeMap<String, String>) ->
format!("{}/models", base_url.trim_end_matches('/'))
}

fn is_agent_text_model_id(id: &str) -> bool {
let lower = id.to_ascii_lowercase();
if [
"audio",
"dall-e",
"embedding",
"image",
"moderation",
"realtime",
"speech",
"transcribe",
"tts",
"whisper",
]
.iter()
.any(|needle| lower.contains(needle))
{
return false;
}

lower.starts_with("gpt-") || lower.starts_with('o') || lower.starts_with("chatgpt-")
}

fn openai_dated_snapshot_alias(id: &str) -> Option<String> {
let (base, date) = id.rsplit_once('-')?;
if date.len() != 2 || !date.chars().all(|character| character.is_ascii_digit()) {
return None;
}
let (base, month) = base.rsplit_once('-')?;
if month.len() != 2 || !month.chars().all(|character| character.is_ascii_digit()) {
return None;
}
let (base, year) = base.rsplit_once('-')?;
if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) {
return None;
}

Some(base.to_string())
}

fn openai_model_display_name(id: &str) -> String {
let canonical = openai_dated_snapshot_alias(id).unwrap_or_else(|| id.to_string());
if let Some(rest) = canonical.strip_prefix("chatgpt-") {
return format!("ChatGPT {}", title_case_model_suffix(rest));
}
if let Some(rest) = canonical.strip_prefix("gpt-") {
return format!("GPT-{}", title_case_model_suffix(rest));
}

canonical
}

fn title_case_model_suffix(value: &str) -> String {
value
.split('-')
.enumerate()
.map(|(index, part)| {
let part = if part.eq_ignore_ascii_case("pro") {
"Pro".to_string()
} else if part.eq_ignore_ascii_case("mini") {
"mini".to_string()
} else if part.eq_ignore_ascii_case("nano") {
"nano".to_string()
} else {
part.to_string()
};

if index == 0 {
part
} else {
format!(" {part}")
}
})
.collect::<String>()
}
/// OpenAI model-id → display-name helpers, split to a sibling to keep this
/// file under the desktop file-size ratchet.
#[path = "agent_models_naming.rs"]
mod naming;
use naming::{is_agent_text_model_id, openai_dated_snapshot_alias, openai_model_display_name};

fn normalize_openai_compatible_models(
response: OpenAiModelListResponse,
Expand Down Expand Up @@ -826,6 +762,20 @@ pub async fn update_managed_agent(

record.updated_at = now_iso();

// A rename re-publishes a signed kind:0 profile. Refuse when the
// agent's effective secrets are unavailable: a failed `auth_tag_ref`
// leaves `secrets_unavailable` set and `auth_tag` empty, so signing
// would publish WITHOUT the NIP-OA tag. Gate before the save so a
// refused rename changes neither disk nor relay.
if name_changed {
let personas = load_personas(&app).unwrap_or_default();
crate::managed_agents::effective_config::require_effective_secrets_available(
record,
&personas,
load_global_agent_config(&app),
)?;
}

save_managed_agents(&app, &records)?;

let record = records
Expand Down
79 changes: 79 additions & 0 deletions desktop/src-tauri/src/commands/agent_models_naming.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//! OpenAI model-id → display-name helpers for `get_agent_models`, split out
//! of `agent_models.rs` to keep it under the desktop file-size ratchet. Wired
//! via `#[path = "agent_models_naming.rs"] mod naming;` and used through it.

pub(super) fn is_agent_text_model_id(id: &str) -> bool {
let lower = id.to_ascii_lowercase();
if [
"audio",
"dall-e",
"embedding",
"image",
"moderation",
"realtime",
"speech",
"transcribe",
"tts",
"whisper",
]
.iter()
.any(|needle| lower.contains(needle))
{
return false;
}

lower.starts_with("gpt-") || lower.starts_with('o') || lower.starts_with("chatgpt-")
}

pub(super) fn openai_dated_snapshot_alias(id: &str) -> Option<String> {
let (base, date) = id.rsplit_once('-')?;
if date.len() != 2 || !date.chars().all(|character| character.is_ascii_digit()) {
return None;
}
let (base, month) = base.rsplit_once('-')?;
if month.len() != 2 || !month.chars().all(|character| character.is_ascii_digit()) {
return None;
}
let (base, year) = base.rsplit_once('-')?;
if year.len() != 4 || !year.chars().all(|character| character.is_ascii_digit()) {
return None;
}

Some(base.to_string())
}

pub(super) fn openai_model_display_name(id: &str) -> String {
let canonical = openai_dated_snapshot_alias(id).unwrap_or_else(|| id.to_string());
if let Some(rest) = canonical.strip_prefix("chatgpt-") {
return format!("ChatGPT {}", title_case_model_suffix(rest));
}
if let Some(rest) = canonical.strip_prefix("gpt-") {
return format!("GPT-{}", title_case_model_suffix(rest));
}

canonical
}

pub(super) fn title_case_model_suffix(value: &str) -> String {
value
.split('-')
.enumerate()
.map(|(index, part)| {
let part = if part.eq_ignore_ascii_case("pro") {
"Pro".to_string()
} else if part.eq_ignore_ascii_case("mini") {
"mini".to_string()
} else if part.eq_ignore_ascii_case("nano") {
"nano".to_string()
} else {
part.to_string()
};

if index == 0 {
part
} else {
format!(" {part}")
}
})
.collect::<String>()
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agent_models_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ fn model_discovery_ignores_stale_record_for_linked_agent() {
parallelism: None,
created_at: "".to_string(),
updated_at: "".to_string(),
secrets_unavailable: false,
};

// agent_model_discovery_config is the single helper get_agent_models
Expand Down
10 changes: 5 additions & 5 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,10 @@ pub async fn create_managed_agent(
} else {
relay_mesh.clone()
},
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
};

records.push(record);
Expand Down Expand Up @@ -1349,12 +1353,10 @@ pub async fn delete_managed_agent(
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))?
}

// Remote agent shutdown is handled entirely by the frontend:
// 1. Frontend sends "!shutdown" @mention via WebSocket (signed by user's key)
// 2. Harness sees it, exits gracefully, sets presence to "offline"
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically
// No backend Tauri command needed. Presence IS the status.
// 3. Desktop's existing presence polling sees "offline" — UI updates automatically. No backend command needed; presence IS the status.
#[path = "agents_deploy.rs"]
mod deploy;
pub(super) mod provider_access;
Expand All @@ -1363,13 +1365,11 @@ use deploy::build_deploy_payload;
use deploy::{deploy_payload_json, DeployProjections};
#[cfg(test)]
use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider};

#[path = "agents_profile.rs"]
mod profile;
#[cfg(test)]
use profile::{profile_needs_sync, resolve_legacy_avatar};
pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData};

#[cfg(test)]
#[path = "agents_tests.rs"]
mod tests;
17 changes: 16 additions & 1 deletion desktop/src-tauri/src/commands/agents_deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,23 @@ pub(super) fn build_deploy_payload(
return Err(err);
}

let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default();
// Fail closed on an unresolvable global env_vars ref: refuse the deploy
// rather than shipping a payload with a silently-incomplete env (mirrors
// the `spawn_key_refusal` record-level gate above, for the global tier).
let global = crate::managed_agents::load_global_agent_config(app)?;
let personas = load_personas(app).unwrap_or_default();
// Fail closed on a definition with unavailable secrets: refuse deploy if
// the linked definition's env_vars could not be hydrated (definition tier,
// mirrors the instance-tier `spawn_key_refusal` and global-tier gates above).
if let Some(pid) = crate::managed_agents::unavailable_definition_id(record, &personas) {
return Err(format!(
"agent {} cannot be deployed: its definition ({pid}) has one or more secrets \
that could not be loaded from the keyring. \
Refusing to deploy with missing definition secrets; \
retry once the keyring is reachable.",
record.pubkey
));
}
let teams = crate::managed_agents::load_teams(app).unwrap_or_default();
let persona_env =
crate::managed_agents::live_persona_env(&personas, record.persona_id.as_deref());
Expand Down
5 changes: 5 additions & 0 deletions desktop/src-tauri/src/commands/agents_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ fn bare_agent_record(
definition_respond_to: None,
definition_respond_to_allowlist: vec![],
definition_parallelism: None,
auth_tag_ref: None,
env_vars_ref: None,
provider_config_ref: None,
secrets_unavailable: false,
}
}
fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefinition {
Expand All @@ -87,6 +91,7 @@ fn persona_record(id: &str, model: Option<&str>, provider: Option<&str>) -> Agen
parallelism: None,
created_at: "".to_string(),
updated_at: "".to_string(),
secrets_unavailable: false,
}
}

Expand Down
Loading
Loading