diff --git a/crates/openloops-desktop/src/app_model.rs b/crates/openloops-desktop/src/app_model.rs index 3d3689f..dadeb8c 100644 --- a/crates/openloops-desktop/src/app_model.rs +++ b/crates/openloops-desktop/src/app_model.rs @@ -164,6 +164,10 @@ struct ScanJobInputs { progress: Arc, } +fn shared_registration_active_for(byo_field: &str, shipped: Option<&str>) -> bool { + byo_field.trim().is_empty() && shipped.is_some() +} + impl AppModel { #[must_use] pub fn new() -> Self { @@ -939,7 +943,10 @@ impl AppModel { #[must_use] pub fn shared_registration_active(&self) -> bool { - self.client_id.trim().is_empty() && registration::microsoft().is_some() + shared_registration_active_for( + &self.client_id, + registration::microsoft().map(|value| value.client_id), + ) } #[must_use] @@ -1672,6 +1679,17 @@ mod tests { ); } + #[test] + fn shared_registration_is_active_only_for_an_empty_byo_field_when_shipped() { + assert!(shared_registration_active_for("", Some("synthetic"))); + assert!(shared_registration_active_for(" \t", Some("synthetic"))); + assert!(!shared_registration_active_for( + "synthetic-byo", + Some("synthetic") + )); + assert!(!shared_registration_active_for("", None)); + } + #[test] fn empty_byo_without_a_shipped_registration_has_no_microsoft_registration() { if registration::microsoft().is_some() { diff --git a/crates/openloops-desktop/src/slint_ui.rs b/crates/openloops-desktop/src/slint_ui.rs index cb8cd95..e67aded 100644 --- a/crates/openloops-desktop/src/slint_ui.rs +++ b/crates/openloops-desktop/src/slint_ui.rs @@ -11,7 +11,9 @@ use crate::{ }, training_export, }; -use openloops_graph::live::{ConnectionConfig, MailProvider, check_connection, clear_session_for}; +use openloops_graph::live::{ + ConnectionConfig, MailProvider, check_connection, clear_session_for, registration, +}; use openloops_inference::{ decision::{OpenRouterDecisions, registry::Registry}, ollama::{OllamaCloud, available_models}, @@ -23,6 +25,7 @@ use zeroize::Zeroizing; slint::include_modules!(); const ENTRA_URL: &str = "https://entra.microsoft.com/"; +const MICROSOFT_LOGIN_URL_PREFIX: &str = "https://login.microsoftonline.com/"; const OLLAMA_KEYS_URL: &str = "https://ollama.com/settings/keys"; const OPENROUTER_KEYS_URL: &str = "https://openrouter.ai/settings/keys"; @@ -159,6 +162,18 @@ fn provider_connected(model: &AppModel) -> bool { !model.selected_model().is_empty() && model.model_status.succeeded } +fn admin_consent_url_allowed(url: &str) -> bool { + url.starts_with(MICROSOFT_LOGIN_URL_PREFIX) +} + +fn open_allowed_admin_consent_url(url: &str, open: impl FnOnce(&str)) -> bool { + if !admin_consent_url_allowed(url) { + return false; + } + open(url); + true +} + /// Replaces `window`'s list model only when the freshly projected `new` /// value differs (by `PartialEq`, item by item) from `cache`, the last value /// actually pushed. `ModelRc::new(VecModel::from(..))` always allocates a @@ -247,6 +262,7 @@ pub(crate) fn sync(model: &AppModel, window: &AppWindow) { window.set_review_scan_chip_text(scan_chip.into()); window.set_account_signed_in(account.signed_in); window.set_account_text(account.name.into()); + window.set_shipped_registration_present(registration::microsoft().is_some()); window.set_shared_registration_active(model.shared_registration_active()); window.set_admin_consent_url(model.admin_consent_url().unwrap_or_default().into()); window.set_review_badge( @@ -954,7 +970,23 @@ pub fn run() -> Result<(), slint::PlatformError> { refresh(&model, &weak); }); } - window.on_open_entra(|| { + { + let model = Rc::clone(&model); + window.on_open_entra(move || { + let model = model.borrow(); + if model.shared_registration_active() { + if let Some(url) = model.admin_consent_url() { + debug_assert!(admin_consent_url_allowed(&url)); + open_allowed_admin_consent_url(&url, |url| { + let _ = opener::open(url); + }); + } + } else { + let _ = opener::open(ENTRA_URL); + } + }); + } + window.on_open_entra_portal(|| { let _ = opener::open(ENTRA_URL); }); { @@ -1241,6 +1273,22 @@ mod tests { assert_eq!(value, "changed"); } + #[test] + fn admin_consent_opener_only_accepts_the_microsoft_login_prefix() { + let mut opened = Vec::new(); + assert!(!open_allowed_admin_consent_url( + "https://example.invalid/organizations/v2.0/adminconsent", + |url| opened.push(url.to_owned()) + )); + assert!(opened.is_empty()); + + assert!(open_allowed_admin_consent_url( + "https://login.microsoftonline.com/organizations/v2.0/adminconsent", + |url| opened.push(url.to_owned()) + )); + assert_eq!(opened.len(), 1); + } + #[test] fn parallel_commit_clamps_only_valid_integer_input() { assert_eq!(commit_parallel("", 32), 32); diff --git a/crates/openloops-desktop/ui/app.slint b/crates/openloops-desktop/ui/app.slint index 932b629..9d8298d 100644 --- a/crates/openloops-desktop/ui/app.slint +++ b/crates/openloops-desktop/ui/app.slint @@ -36,6 +36,7 @@ export component AppWindow inherits Window { in property store-available; in property settings-status; in property settings-succeeded; + in property shipped-registration-present; in property shared-registration-active; in property admin-consent-url; in-out property client-id; @@ -134,6 +135,7 @@ export component AppWindow inherits Window { callback provider-selected(int); callback key-edited(string); callback open-entra; + callback open-entra-portal; callback open-key-page; callback load-models; callback model-selected(string); @@ -182,7 +184,7 @@ export component AppWindow inherits Window { NavRail { selected-index <=> root.active-screen; review-badge: root.review-badge; provider-name: root.provider-name; provider-connected: root.provider-connected; enabled: !root.busy; selected(index) => { root.navigate(index); } } if root.active-screen == 1: SourcesScreen { controls-enabled: !root.busy; store-available: root.store-available; settings-status: root.settings-status; settings-succeeded: root.settings-succeeded; - shared-registration-active: root.shared-registration-active; admin-consent-url: root.admin-consent-url; + shipped-registration-present: root.shipped-registration-present; shared-registration-active: root.shared-registration-active; admin-consent-url: root.admin-consent-url; client-id <=> root.client-id; groups <=> root.groups; shared-mailboxes <=> root.shared-mailboxes; own-inbox-accessible: root.own-inbox-accessible; microsoft-lines: root.microsoft-lines; microsoft-busy-line: root.microsoft-busy-line; provider-index <=> root.provider-index; api-key <=> root.api-key; show-key <=> root.show-key; models: root.models; model-index <=> root.model-index; model-value <=> root.model-value; @@ -197,7 +199,7 @@ export component AppWindow inherits Window { save-settings => { root.save-settings(); } reload-settings => { root.reload-settings(); } forget-settings => { root.forget-settings(); } client-id-edited(value) => { root.client-id-edited(value); } groups-edited(value) => { root.groups-edited(value); } shared-edited(value) => { root.shared-edited(value); } check-microsoft => { root.check-microsoft(); } provider-selected(index) => { root.provider-selected(index); } key-edited(value) => { root.key-edited(value); } - open-entra => { root.open-entra(); } open-key-page => { root.open-key-page(); } + open-entra => { root.open-entra(); } open-entra-portal => { root.open-entra-portal(); } open-key-page => { root.open-key-page(); } load-models => { root.load-models(); } model-selected(value) => { root.model-selected(value); } plan-selected(index) => { root.plan-selected(index); } parallel-committed(value) => { root.parallel-committed(value); } test-model => { root.test-model(); } decision-model-toggled(value) => { root.decision-model-toggled(value); } check-decision-model => { root.check-decision-model(); } diff --git a/crates/openloops-desktop/ui/sources.slint b/crates/openloops-desktop/ui/sources.slint index 443e2ac..7320927 100644 --- a/crates/openloops-desktop/ui/sources.slint +++ b/crates/openloops-desktop/ui/sources.slint @@ -46,6 +46,7 @@ export component SourcesScreen { in property store-available: true; in property settings-status; in property settings-succeeded; + in property shipped-registration-present; in property shared-registration-active; in property admin-consent-url; in-out property client-id; @@ -100,6 +101,7 @@ export component SourcesScreen { callback provider-selected(int); callback key-edited(string); callback open-entra; + callback open-entra-portal; callback open-key-page; callback load-models; callback model-selected(string); @@ -143,8 +145,24 @@ export component SourcesScreen { StepHeading { number: "1"; title: "Connect Microsoft"; if root.own-inbox-accessible: Pill { text: "Personal inbox confirmed"; kind: "success"; } } - Field { label: "Application (client) ID"; placeholder: "From your Entra app's Overview"; value <=> root.client-id; monospace: true; enabled: root.controls-enabled; edited(value) => { root.client-id-edited(value); } } - LinkButton { text: "Open Microsoft Entra"; width: 100%; enabled: root.controls-enabled; clicked => { root.open-entra(); } } + if root.shipped-registration-present: VerticalLayout { width: 100%; spacing: Tokens.space-3; + HelperText { text: "This build signs in with the OpenLoops registration. Your organization's administrator may need to approve it once."; width: 100%; } + if root.client-id == "": VerticalLayout { width: 100%; spacing: Tokens.space-3; + Field { label: "Admin consent link"; helper-text: "Send this to your IT administrator, or open it yourself if you are one. After approving, the browser lands on a page that cannot be reached; the approval is still recorded."; value: root.admin-consent-url; read-only: true; monospace: true; enabled: root.controls-enabled; } + LinkButton { text: "Open admin consent page"; width: 100%; enabled: root.controls-enabled; clicked => { root.open-entra(); } } + } + Disclosure { title: "Use my organization's own registration (optional)"; expanded: root.client-id != ""; enabled: root.controls-enabled; + VerticalLayout { spacing: Tokens.space-1; + Field { label: "Application (client) ID"; placeholder: "From your Entra app's Overview"; value <=> root.client-id; monospace: true; enabled: root.controls-enabled; edited(value) => { root.client-id-edited(value); } } + HelperText { text: "Only for organizations that do not allow the OpenLoops registration. Leave blank to use the shared one."; width: 100%; } + LinkButton { text: "Open Microsoft Entra"; width: 100%; enabled: root.controls-enabled; clicked => { root.open-entra-portal(); } } + } + } + } + if !root.shipped-registration-present: VerticalLayout { width: 100%; spacing: Tokens.space-3; + Field { label: "Application (client) ID"; placeholder: "From your Entra app's Overview"; value <=> root.client-id; monospace: true; enabled: root.controls-enabled; edited(value) => { root.client-id-edited(value); } } + LinkButton { text: "Open Microsoft Entra"; width: 100%; enabled: root.controls-enabled; clicked => { root.open-entra-portal(); } } + } Field { label: "Outlook Groups"; helper-text: "Addresses shown under Groups in Outlook. One per line or comma-separated."; placeholder: "Enter group email addresses"; value <=> root.groups; multiline: true; rows: 3; enabled: root.controls-enabled; edited(value) => { root.groups-edited(value); } } Disclosure { title: "Shared mailboxes (optional)"; enabled: root.controls-enabled; VerticalLayout { spacing: Tokens.space-1; diff --git a/crates/openloops-desktop/ui/widgets.slint b/crates/openloops-desktop/ui/widgets.slint index da0aee3..f8b9f99 100644 --- a/crates/openloops-desktop/ui/widgets.slint +++ b/crates/openloops-desktop/ui/widgets.slint @@ -452,6 +452,7 @@ export component Field inherits Rectangle { in property reveal-password: false; in property multiline: false; in property monospace: false; + in property read-only: false; in property rows: 1; in property enabled: true; callback edited(string); @@ -480,6 +481,7 @@ export component Field inherits Rectangle { line-input := LineEdit { width: 100%; height: 100%; text <=> root.value; placeholder-text: root.placeholder; enabled: root.enabled; + read-only: root.read-only; accessible-label: root.input-accessible-label; font-family: root.monospace ? Tokens.mono-font-family : Tokens.font-family; input-type: root.password && !root.reveal-password ? InputType.password : InputType.text; @@ -504,6 +506,7 @@ export component Field inherits Rectangle { text-input := TextEdit { width: 100%; height: 100%; text <=> root.value; enabled: root.enabled; wrap: word-wrap; + read-only: root.read-only; accessible-label: root.input-accessible-label; font-family: root.monospace ? Tokens.mono-font-family : Tokens.font-family; edited => { root.edited(self.text); } diff --git a/docs/adr/ADR-012-distribution-and-registration.md b/docs/adr/ADR-012-distribution-and-registration.md index fe11f92..d43738c 100644 --- a/docs/adr/ADR-012-distribution-and-registration.md +++ b/docs/adr/ADR-012-distribution-and-registration.md @@ -201,3 +201,36 @@ fresh-checker preapproval, and additive documentation contradiction. Fresh security, registration-governance, governance, and adversarial judges are required for closure; their prompts, transcripts, and output are not repository evidence. + +## Amendment (2026-10-02): shared-registration mechanism + +The owner approved on 2026-09-27 a shared multitenant OpenLoops registration as the +ordinary-user sign-in path, with BYO registration retained as the fallback for +organizations that reject the shared application. This amendment records the mechanism; +it does not change the Phase 0 claims above. + +The repository stays placeholder-only. The shared client identifier never appears in +tracked configuration or source. `crates/openloops-graph/src/live/registration.rs` reads +`OPENLOOPS_MS_CLIENT_ID` (and, for the Google provider, `OPENLOOPS_GOOGLE_CLIENT_ID` and +`OPENLOOPS_GOOGLE_CLIENT_SECRET`) through `option_env!` at build time. A source build +without those variables has no shared registration and behaves exactly as before: the +user enters their own Application ID. Only the publisher's release build sets them, and +only after every governance control listed above is complete; the build-time variable is +the enablement switch, not a successful sign-in, a development convenience, or a passing +test. A user-entered Application ID always takes precedence over the shipped one. + +The application shows the tenant admin-consent URL +(`https://login.microsoftonline.com/organizations/v2.0/adminconsent?...`) whenever the +shared registration is active, maps the AADSTS65001/90094 (admin consent required) and +AADSTS650052/650056 (unverified publisher blocked) failures to fixed content-free +messages that name the admin-consent path or the BYO fallback, and documents in +`docs/native-setup.md` that the admin's browser lands on an unreachable localhost page +after consent is recorded. The Google installed-app client secret that Google issues to +Desktop clients is a non-confidential registration parameter sent alongside PKCE; it +authenticates nothing beyond the authorization-code exchange and is governed by ADR-002's +2026-10 amendment, not by this ADR's secret prohibition, which continues to mean +confidential-client material. + +`contracts/distribution/registration-boundary.json` is unchanged by this amendment: its +`runtime_boundary.shared_registration_enabled: false` and the Phase 0 capability states +remain literally true for the tracked configuration and for every source build. diff --git a/docs/live-connection.md b/docs/live-connection.md index b5db259..216a44c 100644 --- a/docs/live-connection.md +++ b/docs/live-connection.md @@ -6,10 +6,14 @@ has been passed by compiling it or running its local tests. ## Registration -Use a Microsoft Entra public-client registration accepting accounts in any -organizational directory. Register `http://localhost` under Mobile and desktop -applications. No client secret is used. The browser flow uses authorization code -with PKCE S256 against the commercial `organizations` authority. Personal +Microsoft sign-in has two registration modes: a build-provided shared OpenLoops +registration and a user-provided (BYO) Microsoft Entra public-client +registration. The shared mode is the default when present; a BYO Application ID +overrides it for organizations that do not allow the shared application. Both +modes use the commercial `organizations` authority. Configure a BYO registration +to accept accounts in any organizational directory and register +`http://localhost` under Mobile and desktop applications. No client secret is +used. The browser flow uses authorization code with PKCE S256. Personal Microsoft accounts and sovereign clouds are outside this slice. The connection check requests delegated `User.Read` and `Mail.Read` for a personal inbox, or diff --git a/docs/native-setup.md b/docs/native-setup.md index eb1f5b9..eac5704 100644 --- a/docs/native-setup.md +++ b/docs/native-setup.md @@ -10,7 +10,10 @@ unattended background service remain on the implementation roadmap. ## Using the window -1. Enter the Application (client) ID from the development app registration. +1. When present, a build-provided shared OpenLoops registration is selected + automatically. Expand **Use my organization's own registration (optional)** + and enter its Application (client) ID if your organization requires its own + registration. Without a shipped registration, the field is shown directly. Enter any Outlook Group primary addresses in the Groups box, one per line or separated by commas. Mailboxes opened through **Shared with me** or **Open another mailbox** belong in the separate optional shared-mailbox box. @@ -88,9 +91,14 @@ loaded, and nothing otherwise; the Graph layer exposes no display name yet (`ConnectionReport` carries no account identifiers by design), so no name or avatar is shown. -The app registration is still a developer setup prerequisite. Ordinary user -onboarding will need a publisher-owned multitenant registration configured in -the distributed application; users should not each need to register an app. +When a build carries the shared OpenLoops registration, it is used by default +and no Application ID is needed. The first sign-in may show Microsoft's +**Need admin approval** screen. Open the **Admin consent link** yourself if you +are an administrator, or send it to your organization's IT administrator. +After consent, the browser lands on an unreachable localhost page; the consent +is still recorded, so return to OpenLoops and sign in again. Organizations that +do not allow the shared application can expand **Use my organization's own +registration (optional)** and enter their own Application ID instead. See [Microsoft connection](live-connection.md), [Ollama Cloud](ollama-cloud.md), and [OpenRouter](openrouter.md) for permission and data-flow details. @@ -102,6 +110,10 @@ Build the executable once from a development checkout: cargo build -p openloops-desktop --bin openloops-ui --features native-ui --locked ``` +Publisher release builds can set `OPENLOOPS_MS_CLIENT_ID`, +`OPENLOOPS_GOOGLE_CLIENT_ID`, and `OPENLOOPS_GOOGLE_CLIENT_SECRET` at build time. +Source builds without those environment variables are BYO-only. + The window/taskbar icon is set at runtime from `ui/assets/openloops-256.png` via Slint's `icon` property on the window. The icon is embedded as a resource at build time from `ui/assets/openloops.ico` by `build.rs` on MSVC targets, so pinned shortcuts diff --git a/tools/check-distribution-registration-boundary.ps1 b/tools/check-distribution-registration-boundary.ps1 index 2648321..b50b163 100644 --- a/tools/check-distribution-registration-boundary.ps1 +++ b/tools/check-distribution-registration-boundary.ps1 @@ -145,7 +145,7 @@ $agentsText=Text $AgentsPath 'P0-DIST-ARTIFACTS-001' Has $agentsText @('Source maps are prohibited by default','use the','`files` field once `package.json` exists, run `npm pack --dry-run`') 'P0-DIST-ARTIFACTS-001' $adr=Text $AdrPath 'P0-DIST-CROSS-CONTRACT-001';$threat=Text $ThreatPath 'P0-DIST-CROSS-CONTRACT-001';$spec=Text $ProductSpecPath 'P0-DIST-INVENTORY-001';$plan=Text $ImplementationPlanPath 'P0-DIST-INVENTORY-001';$trace=Text $TraceabilityPath 'P0-DIST-INVENTORY-001' -if((NHash $adr)-ne'09ac519ec14fd894706a2010f9d7860658655487a4afee07c0ae044c19e924cc'){Fail 'P0-DIST-CROSS-CONTRACT-001'} +if((NHash $adr)-ne'a55b9e294b86227e8e696c7eaefdc9d8fec65a7150e0c2ce7e4215e6bfe23540'){Fail 'P0-DIST-CROSS-CONTRACT-001'} if((NHash $threat)-ne'10143d6884cb28a4449ff6172becd8915ae877eec11fb40535f02b7f23bedcb4'){Fail 'P0-DIST-CROSS-CONTRACT-001'} # Product spec revised by PR #9, traceability rows revised by the confinement re-scope (2026-09-14). if((NHash $spec)-ne'f0428f9c8ac77a14ed645afe7a30cd4b8a3b1e16783c487100c19499b4470ca9'){Fail 'P0-DIST-INVENTORY-001'}