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
20 changes: 19 additions & 1 deletion crates/openloops-desktop/src/app_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ struct ScanJobInputs {
progress: Arc<crate::review_model::ScanProgress>,
}

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 {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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() {
Expand Down
52 changes: 50 additions & 2 deletions crates/openloops-desktop/src/slint_ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
});
{
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions crates/openloops-desktop/ui/app.slint
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export component AppWindow inherits Window {
in property <bool> store-available;
in property <string> settings-status;
in property <bool> settings-succeeded;
in property <bool> shipped-registration-present;
in property <bool> shared-registration-active;
in property <string> admin-consent-url;
in-out property <string> client-id;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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(); }
Expand Down
22 changes: 20 additions & 2 deletions crates/openloops-desktop/ui/sources.slint
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export component SourcesScreen {
in property <bool> store-available: true;
in property <string> settings-status;
in property <bool> settings-succeeded;
in property <bool> shipped-registration-present;
in property <bool> shared-registration-active;
in property <string> admin-consent-url;
in-out property <string> client-id;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions crates/openloops-desktop/ui/widgets.slint
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ export component Field inherits Rectangle {
in property <bool> reveal-password: false;
in property <bool> multiline: false;
in property <bool> monospace: false;
in property <bool> read-only: false;
in property <int> rows: 1;
in property <bool> enabled: true;
callback edited(string);
Expand Down Expand Up @@ -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;
Expand All @@ -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); }
Expand Down
33 changes: 33 additions & 0 deletions docs/adr/ADR-012-distribution-and-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 8 additions & 4 deletions docs/live-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions docs/native-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
Loading
Loading