Skip to main content

agentmux_srv/migrations/
m0017_ambient_login_grandfather.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Grandfather the layer-3 `use_ambient_login` opt-in for pre-existing
5//! agents (spec §2.4 of SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md).
6//!
7//! The spawn gate (identity/resolver/inject.rs) FAILS a spawn when an
8//! oauth-class provider the agent is supposed to have credentials for has
9//! no resolvable account. When this migration was written, an explicit
10//! `use_ambient_login = 1` exempted the agent; that opt-out has since been
11//! retired (PLAN_LOGIN_SINGLE_PATH_CONSOLIDATION_2026_07_20.md §7), so the
12//! flag set here is now only logged, never gates. At the time, existing
13//! agents that relied on the (previously silent) ambient fallback must not
14//! all break on upgrade:
15//!
16//! - agents with NO **oauth-class** `db_agent_identity_links` rows at
17//!   migration time were de-facto ambient users for their CLI login →
18//!   `use_ambient_login = 1`. Api-key-class links (e.g. a github PAT) do
19//!   NOT count: they are never spawn-gated, so an agent whose only link is
20//!   a PAT was still relying on the ambient CLI login and must be
21//!   grandfathered, not broken (spec §2.4's rationale — "grandfather
22//!   de-facto ambient users" — keyed on the spawn-relevant provider class);
23//! - agents WITH an oauth-class link opted into managed CLI accounts →
24//!   `0` (honest failure is the correct new behavior for them).
25//!
26//! Channel-scoped: each pre-existing channel's `db_agent_definitions` /
27//! `db_agents` rows get the pass exactly once (fresh channels have no
28//! agent rows, so re-running on a new channel is a no-op — it can never
29//! flip an agent created after the upgrade). The live links table is the
30//! SHARED store's; the channel store's legacy copy is not consulted.
31//! Cross-channel registry records are handled by the Global-scoped
32//! `m0018_ambient_login_registry`.
33
34use std::collections::HashSet;
35use std::sync::Arc;
36
37use crate::backend::storage::store::Store;
38use super::{Migration, MigrationContext, MigrationError, MigrationScope};
39
40/// Agent ids whose links include at least one **oauth-class** provider —
41/// the only class the layer-3 spawn gate blocks on. Shared by m0017
42/// (channel rows) and m0018 (registry records) so the two passes can never
43/// disagree on the rule.
44pub(crate) fn oauth_linked_agent_ids(
45    shared: &Store,
46) -> Result<HashSet<String>, crate::backend::storage::error::StoreError> {
47    use crate::identity::resolver::{provider_class, ProviderClass};
48    Ok(shared
49        .agent_identity_link_provider_pairs()?
50        .into_iter()
51        .filter(|(_, provider)| matches!(provider_class(provider), Some(ProviderClass::OAuth { .. })))
52        .map(|(agent_id, _)| agent_id)
53        .collect())
54}
55
56pub struct M0017AmbientLoginGrandfather;
57
58impl Migration for M0017AmbientLoginGrandfather {
59    fn id(&self) -> &'static str { "0017_ambient_login_grandfather" }
60    fn scope(&self) -> MigrationScope { MigrationScope::Channel }
61    fn description(&self) -> &'static str {
62        "Grandfather use_ambient_login=1 for agents without identity links (layer-3 spawn gating)"
63    }
64
65    fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
66        if !ctx.channel_store_path.exists() {
67            return Ok(());
68        }
69        let mstore = Arc::new(
70            Store::open(&ctx.channel_store_path)
71                .map_err(|e| MigrationError(format!("ambient_login_grandfather: open mstore: {}", e)))?,
72        );
73        // The live links live in the shared store. A missing shared store
74        // (fresh install racing the bootstrap) means no links exist —
75        // every agent row (if any) is a de-facto ambient user.
76        let linked: HashSet<String> = if ctx.shared_store_path.exists() {
77            let shared = Store::open_shared(&ctx.shared_store_path)
78                .map_err(|e| MigrationError(format!("ambient_login_grandfather: open shared store: {}", e)))?;
79            oauth_linked_agent_ids(&shared)
80                .map_err(|e| MigrationError(format!("ambient_login_grandfather: read links: {}", e)))?
81        } else {
82            HashSet::new()
83        };
84        let (ambient, managed) = mstore
85            .agents_grandfather_ambient_login(&linked)
86            .map_err(|e| MigrationError(format!("ambient_login_grandfather: {}", e)))?;
87        tracing::info!(
88            target: "identity",
89            ambient,
90            managed,
91            "identity.spawn: ambient-login grandfather — {} linkless agent(s) set to ambient, {} linked agent(s) kept fail-by-default",
92            ambient,
93            managed,
94        );
95        Ok(())
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use crate::backend::storage::store::AgentDefinition;
103
104    fn ctx_for(channel: &std::path::Path, shared: &std::path::Path) -> MigrationContext {
105        MigrationContext {
106            home: std::env::temp_dir(),
107            data_dir: std::env::temp_dir(),
108            shared_store_path: shared.to_path_buf(),
109            channel_store_path: channel.to_path_buf(),
110        }
111    }
112
113    fn make_def(id: &str) -> AgentDefinition {
114        AgentDefinition {
115            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
116            id: id.to_string(),
117            slug: id.to_string(),
118            name: id.to_string(),
119            icon: "✦".to_string(),
120            provider: "claude".to_string(),
121            description: String::new(),
122            working_directory: String::new(),
123            shell: String::new(),
124            provider_flags: String::new(),
125            auto_start: 0,
126            restart_on_crash: 0,
127            idle_timeout_minutes: 0,
128            created_at: 0,
129            agent_type: String::new(),
130            environment: String::new(),
131            agent_bus_id: String::new(),
132            is_seeded: 0,
133            accounts: String::new(),
134            parent_id: String::new(),
135            branch_label: String::new(),
136            updated_at: 0,
137            user_hidden: 0,
138            container_image: String::new(),
139            container_volumes: "[]".to_string(),
140            container_name: String::new(),
141            use_ambient_login: 0,
142            auto_continue_enabled: 0,
143            model_vendor_base_url: String::new(),
144            memory_id: String::new(),
145        }
146    }
147
148    /// Spec §2.5 migration test: linkless agent → flag true; linked agent →
149    /// flag false. Links live in the SHARED store; the flag lands on the
150    /// channel store's rows (both tables).
151    #[test]
152    fn linkless_agent_gets_ambient_linked_agent_stays_fail_by_default() {
153        let channel = tempfile::NamedTempFile::new().unwrap();
154        let shared = tempfile::NamedTempFile::new().unwrap();
155
156        // Channel store: two user agents.
157        let mstore = Store::open(channel.path()).unwrap();
158        let mut linkless = make_def("agent-linkless");
159        mstore.agent_def_insert(&mut linkless).unwrap();
160        let mut linked = make_def("agent-linked");
161        mstore.agent_def_insert(&mut linked).unwrap();
162
163        // Shared store: one link for agent-linked. The account row is not
164        // required for the grandfather decision (only the link's presence),
165        // and links-without-accounts are exactly the post-delete shape.
166        let shared_store = Store::open_shared(shared.path()).unwrap();
167        let acct = crate::backend::storage::store::IdentityAccount {
168            id: "acct-1".to_string(),
169            name: "claude-acct-1".to_string(),
170            provider: "claude".to_string(),
171            kind: "oauth".to_string(),
172            display_name: String::new(),
173            secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir {
174                dir: "/tmp/nowhere".to_string(),
175            },
176            context: serde_json::json!({}),
177            status: "unknown".to_string(),
178            created_at: 0,
179            updated_at: 0,
180        };
181        shared_store.identity_upsert(&acct).unwrap();
182        shared_store
183            .agent_identity_link("agent-linked", "acct-1", "claude")
184            .unwrap();
185        drop(shared_store);
186        drop(mstore);
187
188        M0017AmbientLoginGrandfather
189            .up(&ctx_for(channel.path(), shared.path()))
190            .unwrap();
191
192        let mstore = Store::open(channel.path()).unwrap();
193        let after_linkless = mstore.agent_def_get("agent-linkless").unwrap().unwrap();
194        assert_eq!(
195            after_linkless.use_ambient_login, 1,
196            "linkless agent must be grandfathered to ambient"
197        );
198        let after_linked = mstore.agent_def_get("agent-linked").unwrap().unwrap();
199        assert_eq!(
200            after_linked.use_ambient_login, 0,
201            "linked agent must keep fail-by-default"
202        );
203        // The consolidated db_agents projection agrees (it's what the
204        // roster/modal read).
205        let listed = mstore.agent_def_list().unwrap();
206        assert_eq!(
207            listed.iter().find(|a| a.id == "agent-linkless").unwrap().use_ambient_login,
208            1,
209        );
210        assert_eq!(
211            listed.iter().find(|a| a.id == "agent-linked").unwrap().use_ambient_login,
212            0,
213        );
214    }
215
216    /// Spec §2.4 (as clarified): only OAUTH-CLASS links forfeit
217    /// grandfathering. An agent whose only link is an api-key-class github
218    /// PAT was still a de-facto ambient user for its CLI login — the spawn
219    /// gate never blocks on api-key providers, so counting that link would
220    /// break exactly the users grandfathering exists to protect.
221    #[test]
222    fn api_key_only_links_do_not_forfeit_grandfathering() {
223        let channel = tempfile::NamedTempFile::new().unwrap();
224        let shared = tempfile::NamedTempFile::new().unwrap();
225
226        let mstore = Store::open(channel.path()).unwrap();
227        let mut pat_only = make_def("agent-pat-only");
228        mstore.agent_def_insert(&mut pat_only).unwrap();
229        drop(mstore);
230
231        // Shared store: a github (api-key-class) link and nothing else.
232        let shared_store = Store::open_shared(shared.path()).unwrap();
233        let acct = crate::backend::storage::store::IdentityAccount {
234            id: "acct-gh".to_string(),
235            name: "asaf-github".to_string(),
236            provider: "github".to_string(),
237            kind: "pat".to_string(),
238            display_name: String::new(),
239            secret_ref: crate::backend::storage::store::SecretRef::Keychain {
240                service: "agentmux".to_string(),
241                account: "acct-gh".to_string(),
242            },
243            context: serde_json::json!({}),
244            status: "unknown".to_string(),
245            created_at: 0,
246            updated_at: 0,
247        };
248        shared_store.identity_upsert(&acct).unwrap();
249        shared_store
250            .agent_identity_link("agent-pat-only", "acct-gh", "github")
251            .unwrap();
252        drop(shared_store);
253
254        M0017AmbientLoginGrandfather
255            .up(&ctx_for(channel.path(), shared.path()))
256            .unwrap();
257
258        let mstore = Store::open(channel.path()).unwrap();
259        assert_eq!(
260            mstore.agent_def_get("agent-pat-only").unwrap().unwrap().use_ambient_login,
261            1,
262            "a PAT-only agent is a de-facto ambient CLI user and must be grandfathered"
263        );
264    }
265
266    #[test]
267    fn missing_shared_store_treats_every_agent_as_linkless() {
268        let channel = tempfile::NamedTempFile::new().unwrap();
269        let mstore = Store::open(channel.path()).unwrap();
270        let mut def = make_def("agent-solo");
271        mstore.agent_def_insert(&mut def).unwrap();
272        drop(mstore);
273
274        let missing_shared = std::env::temp_dir()
275            .join("agentmux-test-shared-store-definitely-missing-x9q.db");
276        let _ = std::fs::remove_file(&missing_shared);
277        M0017AmbientLoginGrandfather
278            .up(&ctx_for(channel.path(), &missing_shared))
279            .unwrap();
280
281        let mstore = Store::open(channel.path()).unwrap();
282        assert_eq!(
283            mstore.agent_def_get("agent-solo").unwrap().unwrap().use_ambient_login,
284            1,
285        );
286    }
287
288    #[test]
289    fn missing_channel_store_is_a_noop() {
290        let shared = tempfile::NamedTempFile::new().unwrap();
291        let missing_channel = std::env::temp_dir()
292            .join("agentmux-test-channel-store-definitely-missing-x9q.db");
293        let _ = std::fs::remove_file(&missing_channel);
294        M0017AmbientLoginGrandfather
295            .up(&ctx_for(&missing_channel, shared.path()))
296            .unwrap();
297        assert!(!missing_channel.exists(), "up() must not create the store");
298    }
299}