diff --git a/apps/desktop/src-tauri/src/commands/oauth.rs b/apps/desktop/src-tauri/src/commands/oauth.rs index 0f45cd13..73972f07 100644 --- a/apps/desktop/src-tauri/src/commands/oauth.rs +++ b/apps/desktop/src-tauri/src/commands/oauth.rs @@ -37,6 +37,7 @@ use tracing::{debug, error, info, warn}; use url::Url; use super::gateway::GatewayAppState; +use crate::state::AppState; // ============================================================================ // Deep Link Handling @@ -900,6 +901,7 @@ pub async fn update_oauth_client( #[tauri::command] pub async fn delete_oauth_client( gateway_state: State<'_, Arc>>, + app: State<'_, AppState>, client_id: String, ) -> Result<(), String> { let app_state = gateway_state.read().await; @@ -921,6 +923,23 @@ pub async fn delete_oauth_client( info!("[OAuth] Deleted client: {}", client_id); + // Best-effort: remove the auto-mapped clientId id-binding so a deleted + // client doesn't leave an orphan " → Starter" mapping behind in + // the Mapping tab. Only API-key clients have such a binding; for DCR + // clients this is a no-op. + if let Ok(Some(b)) = app + .workspace_binding_repository + .find_by_id_key(&client_id) + .await + { + if let Err(e) = app.workspace_binding_repository.delete(&b.id).await { + warn!( + "[OAuth] failed to remove clientId mapping for {}: {}", + client_id, e + ); + } + } + // Emit domain event state.emit_domain_event(mcpmux_core::DomainEvent::ClientDeleted { client_id }); @@ -943,6 +962,7 @@ pub async fn delete_oauth_client( pub struct RegisteredApiKeyClient { pub client_id: String, pub client_name: String, + pub locked_space_id: Option, pub api_key: String, pub key_prefix: String, } @@ -973,12 +993,14 @@ fn generate_api_key() -> (String, String, String) { (key_id, plaintext, key_prefix) } -/// Register a new pre-approved client authenticated by an API key. The returned -/// `api_key` is shown once and never stored. +/// Register a new pre-approved client authenticated by an API key, optionally +/// locked to a Space. The returned `api_key` is shown once and never stored. #[tauri::command] pub async fn register_api_key_client( gateway_state: State<'_, Arc>>, + app: State<'_, AppState>, name: String, + locked_space_id: Option, ) -> Result { let app_state = gateway_state.read().await; let Some(ref gw_state) = app_state.gateway_state else { @@ -1024,6 +1046,12 @@ pub async fn register_api_key_client( .await .map_err(|e| format!("Failed to create client: {}", e))?; + if let Some(ref space) = locked_space_id { + repo.set_locked_space(&client_id, Some(space)) + .await + .map_err(|e| format!("Failed to lock client to space: {}", e))?; + } + let (key_id, plaintext, key_prefix) = generate_api_key(); repo.create_api_key(&key_id, &client_id, &plaintext, &key_prefix, None, None) .await @@ -1034,14 +1062,61 @@ pub async fn register_api_key_client( trimmed, client_id ); + // Best-effort: auto-create a clientId-keyed mapping → the (locked or + // default) Space's Starter, so the client routes sensibly out of the box + // and the mapping is visible + editable in the Mapping tab. A failure here + // must not undo the registration — without an explicit mapping the resolver + // still falls back to the default Starter. + if let Err(e) = auto_map_api_key_client(&app, &client_id, locked_space_id.as_deref()).await { + warn!( + "[OAuth] auto-map for {} failed (non-fatal): {}", + client_id, e + ); + } + Ok(RegisteredApiKeyClient { client_id, client_name: trimmed.to_string(), + locked_space_id, api_key: plaintext, key_prefix, }) } +/// Auto-create a clientId-keyed `id` mapping pointing at the (locked or +/// default) Space's Starter FeatureSet, so a freshly-registered API-key client +/// routes somewhere sensible by default and the operator can retarget it from +/// the Mapping tab. +async fn auto_map_api_key_client( + app: &AppState, + client_id: &str, + locked_space_id: Option<&str>, +) -> Result<(), String> { + let space_id = match locked_space_id { + Some(s) => uuid::Uuid::parse_str(s).map_err(|e| e.to_string())?, + None => { + app.space_service + .get_default() + .await + .map_err(|e| e.to_string())? + .ok_or("no default Space configured")? + .id + } + }; + let starter = app + .feature_set_repository + .get_starter_for_space(&space_id.to_string()) + .await + .map_err(|e| e.to_string())? + .ok_or("Space has no Starter FeatureSet")?; + let binding = + mcpmux_core::WorkspaceBinding::new_id(client_id.to_string(), space_id, vec![starter.id]); + app.workspace_binding_repository + .create(&binding) + .await + .map_err(|e| e.to_string()) +} + /// Issue an additional API key for an existing client (rotation). Returns the /// new key plaintext once. #[tauri::command] @@ -1079,9 +1154,15 @@ pub async fn create_client_api_key( .await .map_err(|e| format!("Failed to create API key: {}", e))?; + let locked_space_id = repo + .get_locked_space(&client_id) + .await + .map_err(|e| format!("Failed to read client: {}", e))?; + Ok(RegisteredApiKeyClient { client_id, client_name: client.client_name, + locked_space_id, api_key: plaintext, key_prefix, }) diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index f62cc07e..4879d131 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -8,8 +8,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use mcpmux_core::{ - validate_workspace_root as validate_root, DomainEvent, FeatureSet, FeatureSetType, MemberMode, - MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation, + validate_workspace_root as validate_root, BindingType, DomainEvent, FeatureSet, FeatureSetType, + MemberMode, MemberType, ServerFeature, WorkspaceBinding, WorkspaceRootValidation, }; use serde::{Deserialize, Serialize}; use tauri::State; @@ -54,6 +54,8 @@ async fn emit_binding_changed( pub struct WorkspaceBindingDto { pub id: String, pub workspace_root: String, + /// `path` (folder, normalized) or `id` (arbitrary exact-match key). + pub binding_type: String, pub space_id: String, pub feature_set_ids: Vec, pub created_at: String, @@ -65,6 +67,7 @@ impl From for WorkspaceBindingDto { Self { id: b.id.to_string(), workspace_root: b.workspace_root, + binding_type: b.binding_type.as_str().to_string(), space_id: b.space_id.to_string(), feature_set_ids: b.feature_set_ids, created_at: b.created_at.to_rfc3339(), @@ -83,6 +86,10 @@ pub struct WorkspaceBindingInput { pub workspace_root: String, pub space_id: String, pub feature_set_ids: Vec, + /// `path` (default — folder, normalized + validated) or `id` (arbitrary + /// exact-match key, taken verbatim). Optional for backward compatibility. + #[serde(default)] + pub binding_type: Option, } fn parse_space_id(input: &WorkspaceBindingInput) -> Result { @@ -229,6 +236,26 @@ fn normalize_and_validate(raw: &str) -> Result { } } +/// Resolve the storage key + type from the input. `path` bindings are +/// normalized + validated (rejecting relative paths, filesystem roots, …); +/// `id` bindings take the raw string verbatim (any non-empty label a headless +/// client sends in `X-Mcpmux-Workspace`, e.g. a client id or machine name). +fn resolve_key_and_type(input: &WorkspaceBindingInput) -> Result<(String, BindingType), String> { + match input.binding_type.as_deref() { + Some("id") => { + let key = input.workspace_root.trim(); + if key.is_empty() { + return Err("Mapping id cannot be empty".into()); + } + Ok((key.to_string(), BindingType::Id)) + } + _ => Ok(( + normalize_and_validate(&input.workspace_root)?, + BindingType::Path, + )), + } +} + /// Create a binding. Path is normalized + validated server-side so the UI /// can pass raw input (Windows paths, file:// URIs, trailing slashes). #[tauri::command] @@ -239,9 +266,9 @@ pub async fn create_workspace_binding( ) -> Result { let space_id = parse_space_id(&input)?; let feature_set_ids = validate_fs_list(&input)?; - let normalized = normalize_and_validate(&input.workspace_root)?; + let (key, binding_type) = resolve_key_and_type(&input)?; - // Reject a duplicate folder up front with a readable message. The schema + // Reject a duplicate key up front with a readable message. The schema // already enforces `UNIQUE(workspace_root)`, but that surfaces an opaque // SQLite constraint error — this gives the UI something a user can act on. let existing = state @@ -249,13 +276,16 @@ pub async fn create_workspace_binding( .list() .await .map_err(|e| e.to_string())?; - if existing.iter().any(|b| b.workspace_root == normalized) { + if existing.iter().any(|b| b.workspace_root == key) { return Err(format!( - "A mapping already exists for {normalized}. Edit the existing mapping instead of adding a second one." + "A mapping already exists for {key}. Edit the existing mapping instead of adding a second one." )); } - let binding = WorkspaceBinding::new_multi(normalized.clone(), space_id, feature_set_ids); + let binding = match binding_type { + BindingType::Id => WorkspaceBinding::new_id(key.clone(), space_id, feature_set_ids), + BindingType::Path => WorkspaceBinding::new_multi(key.clone(), space_id, feature_set_ids), + }; state .workspace_binding_repository @@ -292,9 +322,9 @@ pub async fn update_workspace_binding( let id_uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; let space_id = parse_space_id(&input)?; let feature_set_ids = validate_fs_list(&input)?; - let normalized = normalize_and_validate(&input.workspace_root)?; + let (key, binding_type) = resolve_key_and_type(&input)?; - // If the edit moved the folder onto a path another mapping already owns, + // If the edit moved the mapping onto a key another mapping already owns, // reject with a readable message rather than tripping the DB UNIQUE // constraint. Exclude this binding's own row. let all = state @@ -304,10 +334,10 @@ pub async fn update_workspace_binding( .map_err(|e| e.to_string())?; if all .iter() - .any(|b| b.id != id_uuid && b.workspace_root == normalized) + .any(|b| b.id != id_uuid && b.workspace_root == key) { return Err(format!( - "Another mapping already uses {normalized}. Pick a different folder." + "Another mapping already uses {key}. Pick a different key." )); } @@ -321,7 +351,8 @@ pub async fn update_workspace_binding( let updated = WorkspaceBinding { id: existing.id, - workspace_root: normalized, + workspace_root: key, + binding_type, space_id, feature_set_ids, created_at: existing.created_at, diff --git a/apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx b/apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx index f55ff91d..e207143e 100644 --- a/apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx +++ b/apps/desktop/src/features/clients/RegisterApiKeyClientModal.tsx @@ -10,10 +10,11 @@ * never display it again — if lost, revoke it and issue a new one. */ -import { useState } from 'react'; -import { AlertTriangle, Check, Copy, KeyRound, Loader2, ShieldCheck, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { AlertTriangle, Check, Copy, KeyRound, Loader2, Lock, ShieldCheck, X } from 'lucide-react'; import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@mcpmux/ui'; import { registerApiKeyClient, type RegisteredApiKeyClient } from '@/lib/api/gateway'; +import { listSpaces, type Space } from '@/lib/api/spaces'; interface RegisterApiKeyClientModalProps { onClose: () => void; @@ -26,11 +27,23 @@ export function RegisterApiKeyClientModal({ onRegistered, }: RegisterApiKeyClientModalProps) { const [name, setName] = useState(''); + const [lockedSpaceId, setLockedSpaceId] = useState(''); + const [spaces, setSpaces] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const [result, setResult] = useState(null); const [copied, setCopied] = useState(false); + useEffect(() => { + listSpaces() + .then(setSpaces) + .catch(() => setSpaces([])); + }, []); + + const lockedSpaceName = result?.lockedSpaceId + ? (spaces.find((s) => s.id === result.lockedSpaceId)?.name ?? 'a Space') + : null; + const handleGenerate = async () => { const trimmed = name.trim(); if (!trimmed) { @@ -40,7 +53,7 @@ export function RegisterApiKeyClientModal({ setIsSubmitting(true); setError(null); try { - const client = await registerApiKeyClient(trimmed); + const client = await registerApiKeyClient(trimmed, lockedSpaceId || null); setResult(client); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -130,6 +143,13 @@ export function RegisterApiKeyClientModal({ Authorization: Bearer {result.keyPrefix}… + {lockedSpaceName && ( +

+ + Locked to {lockedSpaceName} — this key can + only ever reach that Space. +

+ )}
@@ -159,6 +179,30 @@ export function RegisterApiKeyClientModal({ />
+
+ + +

+ Locking confines this client to one Space — a leaked key can never reach the + others. Leave unlocked to route it later from the Workspaces tab. +

+
+

diff --git a/apps/desktop/src/features/home/HomePage.tsx b/apps/desktop/src/features/home/HomePage.tsx index ada34911..e73c159f 100644 --- a/apps/desktop/src/features/home/HomePage.tsx +++ b/apps/desktop/src/features/home/HomePage.tsx @@ -163,8 +163,9 @@ function GetStartedStrip() { /** * Per-folder setup entry point. The ConnectionCard above connects an app to - * the gateway globally; this routes into the Workspaces walkthrough to map a - * specific project and write its per-folder config. + * the gateway globally; this routes into the Mapping walkthrough (the create + * wizard, in folder mode) to map a specific project and write its per-folder + * config. */ function SetUpFolderCard() { const navigateTo = useNavigateTo(); @@ -179,7 +180,7 @@ function SetUpFolderCard() { data-testid="home-setup-folder" className="group flex w-full items-center gap-3 rounded-xl border border-[rgb(var(--border-subtle))] bg-[rgb(var(--card))] p-4 text-left shadow transition-all duration-200 hover:-translate-y-0.5 hover:border-[rgb(var(--border))] hover:shadow-md" > - + @@ -275,7 +276,7 @@ export function HomePage() { pending-approval nudge. */} - {/* Per-folder setup — opens the Workspaces walkthrough. */} + {/* Per-folder setup — opens the Mapping walkthrough (folder mode). */} {/* Stat tiles — each is a shortcut into the page that manages it. */} diff --git a/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx index d0e88f57..f81ab160 100644 --- a/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx +++ b/apps/desktop/src/features/workspaces/WorkspaceSetupWizard.tsx @@ -5,6 +5,7 @@ import { ArrowLeft, ArrowRight, Check, + Copy, FolderOpen, FolderSearch, Layers, @@ -53,6 +54,11 @@ export function WorkspaceSetupWizard({ onError: (msg: string) => void; }) { const [step, setStep] = useState<1 | 2 | 3>(1); + // A mapping is keyed by a folder PATH (the default) or an arbitrary ID/label + // (a client id, machine name, … — for headless/remote clients). `folder` + // holds whichever value the user enters. + const [bindingType, setBindingType] = useState<'path' | 'id'>('path'); + const isId = bindingType === 'id'; const [folder, setFolder] = useState(''); const [validating, setValidating] = useState(false); const [saving, setSaving] = useState(false); @@ -127,6 +133,7 @@ export function WorkspaceSetupWizard({ workspace_root: folder, space_id: spaceId, feature_set_ids: Array.from(fsIds), + binding_type: bindingType, }); // The parent transitions to the new mapping's inspector (which shows its // effective features) — don't close here, or that view would be lost. @@ -136,11 +143,13 @@ export function WorkspaceSetupWizard({ } }; - const TITLES = ['Choose a folder', 'Connect your apps', 'Choose its tools'] as const; + const TITLES = isId + ? (['Choose an id', 'How clients connect', 'Choose its tools'] as const) + : (['Choose a folder', 'Connect your apps', 'Choose its tools'] as const); return (

{/* Header + progress */} @@ -148,7 +157,7 @@ export function WorkspaceSetupWizard({
- Set up a folder · Step {step} of 3 + {isId ? 'Set up an ID mapping' : 'Set up a folder'} · Step {step} of 3

{TITLES[step - 1]}

@@ -175,80 +184,154 @@ export function WorkspaceSetupWizard({
{step === 1 && (
-

- Which project folder do you want to map? Pick one, or choose a folder an app already - opened. -

- + {/* Folder vs ID — a folder routes editors by the path they open; an + id routes a headless/remote client by an exact label it sends. */} +
+ {(['path', 'id'] as const).map((t) => ( + + ))} +
- {folder && ( -
- {alreadyMapped ? ( - - ) : ( - + {isId ? ( + <> +

+ Enter an id or label — a client id, machine name, or any string. A headless or + remote client that sends this exact value in the{' '} + X-Mcpmux-Workspace header gets the + tools you choose next. +

+ setFolder(e.target.value)} + placeholder="e.g. a client id or machine name" + className="focus:ring-primary-500 w-full rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))] px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2" + data-testid="wizard-id-input" + /> + {alreadyMapped && ( +

+ That id is already mapped — edit it from the Mapping list instead. +

)} - - {folder} - -
- )} - {alreadyMapped && ( -

- This folder is already mapped — edit it from the Workspaces list instead. -

- )} + + ) : ( + <> +

+ Which project folder do you want to map? Pick one, or choose a folder an app + already opened. +

+ - {unmappedRoots.length > 0 && ( -
-
- - Detected workspaces -
-
- {unmappedRoots.slice(0, 6).map((r, i) => ( - - ))} -
-
+ {folder && ( +
+ {alreadyMapped ? ( + + ) : ( + + )} + + {folder} + +
+ )} + {alreadyMapped && ( +

+ This folder is already mapped — edit it from the Workspaces list instead. +

+ )} + + {unmappedRoots.length > 0 && ( +
+
+ + Detected workspaces +
+
+ {unmappedRoots.slice(0, 6).map((r, i) => ( + + ))} +
+
+ )} + )}
)} {step === 2 && (
- -

- Optional — you can connect apps later from this folder's mapping. -

+ {isId ? ( +
+

+ A headless or remote client routes here by sending this id in the{' '} + X-Mcpmux-Workspace header. There's + no folder to auto-write app config for — copy the value into your client. +

+
+ + {folder || '—'} + + +
+
+ ) : ( + <> + +

+ Optional — you can connect apps later from this folder's mapping. +

+ + )}
)} @@ -299,9 +382,9 @@ export function WorkspaceSetupWizard({ type="checkbox" checked={fsIds.has(fs.id)} onChange={() => toggleFs(fs.id)} - className="h-4 w-4 flex-shrink-0 accent-primary-500" + className="accent-primary-500 h-4 w-4 flex-shrink-0" /> - + {fs.name} {isStarterFeatureSet(fs) && ( @@ -354,7 +437,11 @@ export function WorkspaceSetupWizard({ disabled={saving || fsIds.size === 0 || !folder} data-testid="wizard-finish" > - {saving ? : } + {saving ? ( + + ) : ( + + )} Finish )} diff --git a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx index 0c677aa6..9835791b 100644 --- a/apps/desktop/src/features/workspaces/WorkspacesPage.tsx +++ b/apps/desktop/src/features/workspaces/WorkspacesPage.tsx @@ -23,14 +23,7 @@ import { Wrench, X, } from 'lucide-react'; -import { - Button, - Card, - CardContent, - useToast, - ToastContainer, - useConfirm, -} from '@mcpmux/ui'; +import { Button, Card, CardContent, useToast, ToastContainer, useConfirm } from '@mcpmux/ui'; import { clearUnmappedReportedRoots, createWorkspaceBinding, @@ -45,11 +38,7 @@ import { type WorkspaceBindingInput, type WorkspaceEffectiveFeatures, } from '@/lib/api/workspaceBindings'; -import { - isStarterFeatureSet, - listFeatureSets, - type FeatureSet, -} from '@/lib/api/featureSets'; +import { isStarterFeatureSet, listFeatureSets, type FeatureSet } from '@/lib/api/featureSets'; import { WorkspaceInstallPanel } from './WorkspaceInstallPanel'; import { WorkspaceSetupWizard } from './WorkspaceSetupWizard'; import { useSpaces, usePendingWorkspaceNew, useSetPendingWorkspaceNew } from '@/stores'; @@ -220,11 +209,9 @@ export function WorkspacesPage() { if (filter === 'mapped' && !e.binding) return false; if (filter === 'unmapped' && e.kind !== 'unmapped-live') return false; if (!q) return true; - const spaceName = e.binding ? spaceById.get(e.binding.space_id)?.name ?? '' : ''; + const spaceName = e.binding ? (spaceById.get(e.binding.space_id)?.name ?? '') : ''; const fsNames = e.binding - ? e.binding.feature_set_ids - .map((id) => fsById.get(id)?.name ?? '') - .join(' ') + ? e.binding.feature_set_ids.map((id) => fsById.get(id)?.name ?? '').join(' ') : ''; return ( e.root.toLowerCase().includes(q) || @@ -247,7 +234,7 @@ export function WorkspacesPage() { }, [entries]); const selectedEntry: Entry | null = - selected?.mode === 'entry' ? entries.find((e) => e.id === selected.id) ?? null : null; + selected?.mode === 'entry' ? (entries.find((e) => e.id === selected.id) ?? null) : null; const selectedIsNew = selected?.mode === 'new'; const panelOpen = selected !== null; @@ -311,32 +298,27 @@ export function WorkspacesPage() { cleared > 0 ? "You'll be asked to map them again next time." : undefined ); } catch (e) { - showError( - 'Could not clear unmapped folders', - e instanceof Error ? e.message : String(e) - ); + showError('Could not clear unmapped folders', e instanceof Error ? e.message : String(e)); } }; return ( -
-
-
-
+
+
+
+

Workspaces

-

- Map a folder to the tools it should get. When you open that - folder in a connected app — Cursor, VS Code, Claude — McpMux - serves exactly the tools you chose for it. Folders you - haven't mapped fall back to your default Starter set, so - they work out of the box — map one only when it should see - something different. +

+ Map a folder to the tools it should get. When you open that folder in a connected + app — Cursor, VS Code, Claude — McpMux serves exactly the tools you chose for it. + Folders you haven't mapped fall back to your default Starter set, so they work + out of the box — map one only when it should see something different.

-
+
-
-
- +
+
+ setSearchQuery(e.target.value)} - className="w-full pl-12 pr-4 py-3 text-base bg-[rgb(var(--surface))] border border-[rgb(var(--border))] rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-all" + className="focus:ring-primary-500 focus:border-primary-500 w-full rounded-xl border border-[rgb(var(--border))] bg-[rgb(var(--surface))] py-3 pl-12 pr-4 text-base transition-all focus:outline-none focus:ring-2" data-testid="workspace-binding-search" />
@@ -391,7 +373,7 @@ export function WorkspacesPage() { className="whitespace-nowrap text-amber-600 hover:bg-amber-50 hover:text-amber-700 dark:text-amber-400 dark:hover:bg-amber-900/20" data-testid="workspaces-clear-unmapped" > - + Clear unmapped )} @@ -401,17 +383,17 @@ export function WorkspacesPage() { {error && (
-
+
{error}
)}
-
+
{isLoading ? ( -
- +
+
) : filtered.length === 0 ? ( setSelected({ mode: 'new' })} /> ) : ( -
+
{filtered.map((entry) => { - const isSelected = - selected?.mode === 'entry' && selected.id === entry.id; + const isSelected = selected?.mode === 'entry' && selected.id === entry.id; // Mapped entries show their bound Space + FeatureSet names. // Unmapped entries read "Not mapped" — they fall back to the // default Starter set rather than to an explicit binding. @@ -431,9 +412,7 @@ export function WorkspacesPage() { ? spaceById.get(entry.binding.space_id)?.name : undefined; const fsNames = entry.binding - ? entry.binding.feature_set_ids.map( - (id) => fsById.get(id)?.name ?? id - ) + ? entry.binding.feature_set_ids.map((id) => fsById.get(id)?.name ?? id) : []; return (
setSelected(null)} /> {selectedIsNew ? ( @@ -548,7 +527,7 @@ function SegmentedFilter({ options: Array<{ value: T; label: string; count?: number }>; }) { return ( -
+
{options.map((o) => { const active = o.value === value; return ( @@ -559,7 +538,7 @@ function SegmentedFilter({ data-testid={`workspace-filter-${o.value}`} aria-pressed={active} className={[ - 'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all', + 'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all', active ? 'bg-[rgb(var(--background))] text-[rgb(var(--foreground))] shadow-sm' : 'text-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))]', @@ -568,7 +547,7 @@ function SegmentedFilter({ {o.label} {typeof o.count === 'number' && ( void; }) { const tone = - entry.kind === 'unmapped-live' - ? 'amber' - : entry.kind === 'mapped-live' - ? 'emerald' - : 'neutral'; + entry.kind === 'unmapped-live' ? 'amber' : entry.kind === 'mapped-live' ? 'emerald' : 'neutral'; const t = CARD_TONES[tone]; const name = folderName(entry.root); return ( - {entry.isLive ? ( - - ) : ( - - )} + {entry.isLive ? : }
{entry.isLive && ( {name} -

+

{entry.root}

@@ -704,7 +672,7 @@ function EntryCard({ {entry.binding ? (
- + {fsNames.length > 1 && ( {fsNames.length} @@ -752,27 +720,21 @@ function Pill({ : 'bg-[rgb(var(--surface))] text-[rgb(var(--muted))] border-[rgb(var(--border-subtle))]'; return ( {children} ); } -function Chip({ - children, - tone, -}: { - children: React.ReactNode; - tone: 'primary' | 'neutral'; -}) { +function Chip({ children, tone }: { children: React.ReactNode; tone: 'primary' | 'neutral' }) { const styles = tone === 'primary' ? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 border-primary-200 dark:border-primary-800/60' : 'bg-[rgb(var(--surface))] border-[rgb(var(--border-subtle))] text-[rgb(var(--foreground))]'; return ( {children} @@ -804,8 +766,7 @@ const SECTION_TONES: Record = { primary: { gradientOpen: 'bg-gradient-to-r from-primary-50 to-primary-100/50 dark:from-primary-900/20 dark:to-primary-800/10', - iconQuiet: - 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400', + iconQuiet: 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400', iconActive: 'bg-primary-500 text-white shadow-sm shadow-primary-500/30', badgeOpen: 'bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border border-primary-300/70 dark:border-primary-700/70', @@ -813,8 +774,7 @@ const SECTION_TONES: Record = { purple: { gradientOpen: 'bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-900/20 dark:to-pink-900/15', - iconQuiet: - 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400', + iconQuiet: 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400', iconActive: 'bg-purple-500 text-white shadow-sm shadow-purple-500/30', badgeOpen: 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border border-purple-300/70 dark:border-purple-700/70', @@ -848,41 +808,37 @@ function CollapsibleSection({ return (
{open && ( -
+
{children}
)} @@ -958,14 +912,8 @@ function InspectorPanel({ : isMapped ? 'edit' : 'create-from-live'; - const title = isNew - ? 'New mapping' - : isMapped - ? 'Workspace mapping' - : 'Map this folder'; - const subtitle = isNew - ? 'Choose the tools a folder should get.' - : entry?.root ?? ''; + const title = isNew ? 'New mapping' : isMapped ? 'Workspace mapping' : 'Map this folder'; + const subtitle = isNew ? 'Choose the tools a folder should get.' : (entry?.root ?? ''); // Auto-save status drives the small pill in the Mapping section header. const [saveStatus, setSaveStatus] = useState({ kind: 'idle' }); @@ -975,22 +923,24 @@ function InspectorPanel({ const [effectiveTotal, setEffectiveTotal] = useState(null); return ( -
-
+
+
-
-
+
+
-
-
+
+
{!isNew && entry?.isLive && Live} {!isNew && entry && !isMapped && Unmapped} - {!isNew && entry && isMapped && !entry.isLive && Offline} + {!isNew && entry && isMapped && !entry.isLive && ( + Offline + )}
-

{title}

+

{title}

{subtitle} @@ -999,7 +949,7 @@ function InspectorPanel({

-
+
} tone="primary" @@ -1024,9 +974,7 @@ function InspectorPanel({ (id) => featureSets.find((f) => f.id === id)?.name ?? id ) ) || '—' - } from ${ - spaces.find((s) => s.id === entry.binding!.space_id)?.name ?? '—' - }` + } from ${spaces.find((s) => s.id === entry.binding!.space_id)?.name ?? '—'}` : 'Edit what this folder sees, then press Apply.' } defaultOpen={isNew || !isMapped} @@ -1070,24 +1018,21 @@ function InspectorPanel({ badge={effectiveTotal ?? undefined} testId="workspace-effective-features-section" > - + )}
{entry?.binding && ( -
+
@@ -1103,7 +1048,7 @@ function SaveStatusPill({ status }: { status: SaveStatus }) { if (status.kind === 'saving') { return ( Saving @@ -1113,7 +1058,7 @@ function SaveStatusPill({ status }: { status: SaveStatus }) { if (status.kind === 'saved') { return ( Saved @@ -1122,7 +1067,7 @@ function SaveStatusPill({ status }: { status: SaveStatus }) { } return ( @@ -1160,9 +1105,7 @@ function buildServerGroups(data: WorkspaceEffectiveFeatures): ServerGroup[] { let g = map.get(item.server_id); if (!g) { const totals = data.server_totals[item.server_id]; - const server_total = totals - ? totals.tools + totals.prompts + totals.resources - : 0; + const server_total = totals ? totals.tools + totals.prompts + totals.resources : 0; g = { server_id: item.server_id, server_alias: item.server_alias ?? item.server_id, @@ -1298,8 +1241,8 @@ function EffectiveFeaturesContent({ } if (error) { return ( -
- +
+ {error}
); @@ -1313,12 +1256,12 @@ function EffectiveFeaturesContent({
{/* Resolution summary — bold pills showing what this folder resolves to, plus a progress bar for availability. */} -
-
+
+
Resolves to - + {formatFsList(data.feature_sets.map((fs) => fs.name)) || '—'} in @@ -1332,10 +1275,10 @@ function EffectiveFeaturesContent({ : 'No binding matches this folder, so it falls back to the default Starter set shown here. Map it to give this folder a different set.' } className={[ - 'ml-auto text-[10px] px-2 py-0.5 rounded-full font-bold uppercase tracking-wider border', + 'ml-auto rounded-full border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider', data.source === 'binding' - ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 border-purple-300/70 dark:border-purple-700/70' - : 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 border-amber-300/70 dark:border-amber-700/70', + ? 'border-purple-300/70 bg-purple-100 text-purple-700 dark:border-purple-700/70 dark:bg-purple-900/30 dark:text-purple-300' + : 'border-amber-300/70 bg-amber-100 text-amber-700 dark:border-amber-700/70 dark:bg-amber-900/30 dark:text-amber-300', ].join(' ')} > {data.source === 'binding' ? 'binding' : 'unbound'} @@ -1346,7 +1289,7 @@ function EffectiveFeaturesContent({ are connected, leans amber when some are dim. */}
- + {availableCount} of {totalCount} @@ -1367,7 +1310,7 @@ function EffectiveFeaturesContent({ )}
-
+
- +
+

No features configured in this feature set yet.

) : ( -
+
{groups.map((g) => (
-
+
{open ? ( - + ) : ( - + )} - -
-
+ +
+
{prefix && ( - - {prefix}. - + {prefix}. )} - - {displayName} - + {displayName} {group.mapped}/{denominator} @@ -1479,12 +1414,12 @@ function ServerGroupRow({ {issue && ( {issue.label} @@ -1493,7 +1428,7 @@ function ServerGroupRow({
{/* Per-server progress bar — same treatment as FeatureSetPanel's server rows so the visual language is consistent. */} -
+
0 - ? `${(availableCount / group.mapped) * 100}%` - : '0%', + width: group.mapped > 0 ? `${(availableCount / group.mapped) * 100}%` : '0%', }} />
@@ -1518,7 +1450,7 @@ function ServerGroupRow({
{open && ( -
+
@@ -1547,33 +1479,33 @@ function FeatureSubGroup({
{getFeatureTypeIcon(label)} -
-
- +
+
+ {item.display_name || item.feature_name} {label} {!item.available && ( - + unavailable )}
{item.description && ( -

+

{item.description}

)} @@ -1587,11 +1519,11 @@ function FeatureSubGroup({ function getFeatureTypeIcon(type: 'tool' | 'prompt' | 'resource') { switch (type) { case 'tool': - return ; + return ; case 'prompt': - return ; + return ; case 'resource': - return ; + return ; } } @@ -1676,6 +1608,13 @@ function BindingForm({ // their members into one allow set). Order is preserved so the operator // can rank a "primary" FS first; the resolver itself doesn't care. const [fsIds, setFsIds] = useState(initial?.feature_set_ids ?? []); + // A mapping is keyed by a folder path OR an arbitrary id/label. The type is + // chosen at create time and fixed thereafter (an id never becomes a folder). + // Mapping type is chosen in the create wizard and fixed thereafter; here + // (edit / create-from-live) we only read it so an id mapping isn't + // re-validated as a filesystem path. + const bindingType = initial?.binding_type ?? 'path'; + const isId = bindingType === 'id'; const [fsSearch, setFsSearch] = useState(''); const [submitting, setSubmitting] = useState(false); const isEdit = mode === 'edit'; @@ -1708,6 +1647,11 @@ function BindingForm({ setRootValidation({ state: 'ok', normalized: root }); return; } + if (isId) { + // Id keys are matched verbatim — skip filesystem-path validation. + setRootValidation(root.trim() ? { state: 'ok', normalized: root.trim() } : { state: 'idle' }); + return; + } if (!root.trim()) { setRootValidation({ state: 'idle' }); return; @@ -1724,15 +1668,11 @@ function BindingForm({ .catch((e: unknown) => { if (validationSeq.current !== seq) return; const reason = typeof e === 'string' ? e : String(e); - setRootValidation( - reason === '' - ? { state: 'idle' } - : { state: 'error', reason } - ); + setRootValidation(reason === '' ? { state: 'idle' } : { state: 'error', reason }); }); }, 180); return () => clearTimeout(handle); - }, [root, rootEditable]); + }, [root, rootEditable, isId]); useEffect(() => { if (mode === 'create') rootRef.current?.focus(); @@ -1779,17 +1719,14 @@ function BindingForm({ }, [availableFs]); const toggleFs = (id: string) => { - setFsIds((prev) => - prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] - ); + setFsIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); }; const trimmedRoot = root.trim(); // The canonical form the server will store. We prefer the validator's // normalized output (drive-letter case, slash direction, trailing slash // all settled) so the duplicate check matches exactly what a save writes. - const effectiveRoot = - rootValidation.state === 'ok' ? rootValidation.normalized : trimmedRoot; + const effectiveRoot = rootValidation.state === 'ok' ? rootValidation.normalized : trimmedRoot; // Has this folder already been mapped? Compare against every saved mapping // (case-insensitively, the app's notion of "same folder"), excluding the @@ -1830,15 +1767,19 @@ function BindingForm({ const handleSubmit = async () => { if (!root.trim()) { - onError('Pick a folder first.'); + onError(isId ? 'Enter an id or label.' : 'Pick a folder first.'); return; } - if (rootValidation.state === 'error') { + if (!isId && rootValidation.state === 'error') { onError(rootValidation.reason); return; } if (duplicate) { - onError(`That folder is already mapped. Open the existing mapping to change it.`); + onError( + isId + ? 'That id is already mapped. Open its existing mapping to change it.' + : `That folder is already mapped. Open the existing mapping to change it.` + ); return; } if (!spaceId) { @@ -1856,6 +1797,7 @@ function BindingForm({ workspace_root: root.trim(), space_id: spaceId, feature_set_ids: fsIds, + binding_type: bindingType, }); onSaveStatusChange?.({ kind: 'saved' }); savedTimerRef.current = setTimeout(() => { @@ -1887,15 +1829,13 @@ function BindingForm({ {/* Plain-language primer for anyone who's never seen McpMux. Explains the whole flow in two sentences before the fields. */}
- - What is a mapping? - {' '} - Pick a folder, then choose the tools it should get. Whenever you open - that folder in a connected app — Cursor, VS Code, Claude — McpMux hands - it exactly the tools you choose here, and nothing else. + What is a mapping?{' '} + {isId + ? 'Enter an id or label (a client id, machine name, or any string), then choose the tools it gets. A headless or remote client that sends this exact value in the X-Mcpmux-Workspace header receives exactly those tools.' + : 'Pick a folder, then choose the tools it should get. Whenever you open that folder in a connected app — Cursor, VS Code, Claude — McpMux hands it exactly the tools you choose here, and nothing else.'}
- +
setRoot(e.target.value)} readOnly={!rootEditable} - placeholder="Browse for a folder, or paste an absolute path" + placeholder={ + isId + ? 'Any exact-match label — a client id, machine name, etc.' + : 'Browse for a folder, or paste an absolute path' + } className={[ - 'flex-1 min-w-0 px-3 py-2 rounded-lg text-sm font-mono focus:outline-none focus:ring-2', + 'min-w-0 flex-1 rounded-lg px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2', !rootEditable - ? 'bg-[rgb(var(--background))] border border-[rgb(var(--border-subtle))] text-[rgb(var(--muted))] cursor-not-allowed focus:ring-primary-500' + ? 'focus:ring-primary-500 cursor-not-allowed border border-[rgb(var(--border-subtle))] bg-[rgb(var(--background))] text-[rgb(var(--muted))]' : rootValidation.state === 'error' - ? 'bg-[rgb(var(--background))] border border-red-500/60 focus:ring-red-500 focus:border-red-500' - : 'bg-[rgb(var(--background))] border border-[rgb(var(--border))] focus:ring-primary-500 focus:border-primary-500', + ? 'border border-red-500/60 bg-[rgb(var(--background))] focus:border-red-500 focus:ring-red-500' + : 'focus:ring-primary-500 focus:border-primary-500 border border-[rgb(var(--border))] bg-[rgb(var(--background))]', ].join(' ')} data-testid="workspace-binding-root-input" /> - {rootEditable && ( + {rootEditable && !isId && (
{duplicate ? (

- + - This folder is already mapped. Open its existing mapping to change - what it sees instead of adding a second one. + This folder is already mapped. Open its existing mapping to change what it sees + instead of adding a second one.

+ ) : isId ? ( +

+ Matched exactly (case-sensitive). A client sends this value in the{' '} + X-Mcpmux-Workspace header. +

) : ( - + )}
@@ -1982,19 +1927,13 @@ function BindingForm({
1 - ? `Feature set (${fsIds.length} selected)` - : 'Feature set' - } + label={fsIds.length > 1 ? `Feature set (${fsIds.length} selected)` : 'Feature set'} hint="A feature set is a curated list of tools, prompts, and resources from that Space — exactly what this folder is allowed to use. Pick one, or combine several into a single set." > {!spaceId ? ( -

- Pick a Space first. -

+

Pick a Space first.

) : availableFs.length === 0 ? ( -

+

No feature sets in that Space yet.

) : ( @@ -2002,19 +1941,19 @@ function BindingForm({ className="rounded-lg border border-[rgb(var(--border))] bg-[rgb(var(--background))]" data-testid="workspace-binding-fs" > -
+
setFsSearch(e.target.value)} placeholder={`Search ${availableFs.length} feature set${availableFs.length === 1 ? '' : 's'}…`} - className="w-full px-2.5 py-1.5 text-xs bg-[rgb(var(--surface))] border border-[rgb(var(--border-subtle))] rounded focus:outline-none focus:ring-2 focus:ring-primary-500" + className="focus:ring-primary-500 w-full rounded border border-[rgb(var(--border-subtle))] bg-[rgb(var(--surface))] px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2" data-testid="workspace-binding-fs-search" />
-
+
{filteredFs.length === 0 ? ( -

+

No feature sets match “{fsSearch}”.

) : ( @@ -2027,7 +1966,7 @@ function BindingForm({ type="button" onClick={() => toggleFs(f.id)} className={[ - 'w-full flex items-center gap-2.5 px-2.5 py-1.5 rounded text-left text-sm transition-colors', + 'flex w-full items-center gap-2.5 rounded px-2.5 py-1.5 text-left text-sm transition-colors', isSelected ? 'bg-primary-500/10 hover:bg-primary-500/15' : 'hover:bg-[rgb(var(--surface-hover))]', @@ -2036,30 +1975,25 @@ function BindingForm({ >
{isSelected ? ( - + ) : null}
{f.icon && ( - - {f.icon} - + {f.icon} )} -
+
-

{f.name}

+

{f.name}

{isStarterFeatureSet(f) && ( starter @@ -2067,14 +2001,14 @@ function BindingForm({ )}
{f.description && ( -

+

{f.description}

)}
{order !== null && fsIds.length > 1 && ( {order} @@ -2086,7 +2020,7 @@ function BindingForm({ )}
{fsSearch && filteredFs.length > 0 && filteredFs.length < availableFs.length && ( -
+
{filteredFs.length} of {availableFs.length} shown
)} @@ -2099,13 +2033,12 @@ function BindingForm({ state. In edit mode the button stays disabled until something actually changes. An empty feature-set selection is valid and savable. */} -
+
{spaceId && fsIds.length === 0 && ( // Empty is allowed — explain what it means rather than blocking.

- No feature sets selected — this folder gets no tools{' '} - from this Space. Built-in servers still apply per Space (see Built-in - Servers). + No feature sets selected — this folder gets no tools from this Space. + Built-in servers still apply per Space (see Built-in Servers).

)} {isEdit && dirty && !duplicate && ( @@ -2123,9 +2056,9 @@ function BindingForm({ data-testid="workspace-binding-submit" > {submitting ? ( - + ) : ( - + )} {submitLabel} @@ -2164,8 +2097,8 @@ function RootValidationHint({ if (!editable) { return (

- This folder was reported by the app that's open in it, so the path - is fixed — just choose its tools below. + This folder was reported by the app that's open in it, so the path is fixed — just + choose its tools below.

); } @@ -2179,35 +2112,24 @@ function RootValidationHint({ } if (state.state === 'checking') { return ( -

+

Checking…

); } if (state.state === 'error') { - return ( -

- {state.reason} -

- ); + return

{state.reason}

; } // ok const changed = state.normalized !== originalValue.trim(); if (!changed) { - return ( -

- Ready to save. -

- ); + return

Ready to save.

; } return (

Will be saved as{' '} - - {state.normalized} - - . + {state.normalized}.

); } @@ -2223,7 +2145,7 @@ function FormField({ }) { return (
-
); } @@ -2284,11 +2206,11 @@ function EmptyState({ }) { if (hasFilter && hasAny) { return ( - + - -

No workspaces match

-

+ +

No workspaces match

+

Try adjusting the search or filter.

@@ -2296,19 +2218,18 @@ function EmptyState({ ); } return ( - + -
- +
+
-

No folders mapped yet

-

- When you open a folder in a connected app, it shows up here so you can - choose its tools. You can also map a folder ahead of time — add one - now to get started. +

No folders mapped yet

+

+ When you open a folder in a connected app, it shows up here so you can choose its tools. + You can also map a folder ahead of time — add one now to get started.

diff --git a/apps/desktop/src/lib/api/gateway.ts b/apps/desktop/src/lib/api/gateway.ts index da96fda8..9eba1d3f 100644 --- a/apps/desktop/src/lib/api/gateway.ts +++ b/apps/desktop/src/lib/api/gateway.ts @@ -350,6 +350,7 @@ export async function revokeOAuthClientFeatureSet( export interface RegisteredApiKeyClient { clientId: string; clientName: string; + lockedSpaceId: string | null; /** The full key — shown once; afterwards only its hash is kept. */ apiKey: string; keyPrefix: string; @@ -366,11 +367,14 @@ export interface ApiKeyInfo { } /** - * Register a pre-approved client authenticated by an API key. The returned key - * is shown once and never retrievable again. + * Register a pre-approved client authenticated by an API key, optionally locked + * to a space. The returned key is shown once and never retrievable again. */ -export async function registerApiKeyClient(name: string): Promise { - return invoke('register_api_key_client', { name }); +export async function registerApiKeyClient( + name: string, + lockedSpaceId?: string | null +): Promise { + return invoke('register_api_key_client', { name, lockedSpaceId: lockedSpaceId ?? null }); } /** Issue an additional API key for an existing client (rotation). Shown once. */ diff --git a/apps/desktop/src/lib/api/workspaceBindings.ts b/apps/desktop/src/lib/api/workspaceBindings.ts index ebe8cdf2..8c463096 100644 --- a/apps/desktop/src/lib/api/workspaceBindings.ts +++ b/apps/desktop/src/lib/api/workspaceBindings.ts @@ -10,6 +10,8 @@ import { invoke } from '@tauri-apps/api/core'; export interface WorkspaceBinding { id: string; workspace_root: string; + /** `path` (a normalized folder) or `id` (an arbitrary exact-match key). */ + binding_type: 'path' | 'id'; space_id: string; /** * Non-empty by construction. Order is the operator-chosen rendering @@ -26,6 +28,8 @@ export interface WorkspaceBindingInput { workspace_root: string; space_id: string; feature_set_ids: string[]; + /** `path` (default — folder, normalized) or `id` (verbatim exact-match key). */ + binding_type?: 'path' | 'id'; } /** List every binding (sorted by workspace_root). */ @@ -69,9 +73,7 @@ export async function validateWorkspaceRoot(path: string): Promise { } /** List bindings whose target Space is the given one. */ -export async function listWorkspaceBindingsForSpace( - spaceId: string -): Promise { +export async function listWorkspaceBindingsForSpace(spaceId: string): Promise { return invoke('list_workspace_bindings_for_space', { spaceId }); } diff --git a/crates/mcpmux-core/src/domain/mod.rs b/crates/mcpmux-core/src/domain/mod.rs index 83d97def..092d6c0f 100644 --- a/crates/mcpmux-core/src/domain/mod.rs +++ b/crates/mcpmux-core/src/domain/mod.rs @@ -39,5 +39,5 @@ pub use server_log::*; pub use space::*; pub use workspace_binding::{ longest_matching_base, normalize_workspace_root, path_is_within, validate_workspace_root, - WorkspaceBinding, WorkspaceRootValidation, + BindingType, WorkspaceBinding, WorkspaceRootValidation, }; diff --git a/crates/mcpmux-core/src/domain/workspace_binding.rs b/crates/mcpmux-core/src/domain/workspace_binding.rs index 8260dfc8..60a33079 100644 --- a/crates/mcpmux-core/src/domain/workspace_binding.rs +++ b/crates/mcpmux-core/src/domain/workspace_binding.rs @@ -25,6 +25,38 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; +/// How a binding's `workspace_root` key is matched against a request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum BindingType { + /// A normalized absolute folder path (the original behaviour). Matched + /// case-/separator-insensitively via [`normalize_workspace_root`]. + #[default] + Path, + /// An arbitrary exact-match string — a client id, machine name, or any + /// label a headless/remote client sends in `X-Mcpmux-Workspace`. Matched + /// verbatim (no path normalization). + Id, +} + +impl BindingType { + pub fn as_str(&self) -> &'static str { + match self { + BindingType::Path => "path", + BindingType::Id => "id", + } + } + + /// Parse from storage; unknown values fall back to `Path` (the default for + /// pre-`binding_type` rows). + pub fn parse(s: &str) -> Self { + match s { + "id" => BindingType::Id, + _ => BindingType::Path, + } + } +} + /// A binding between a normalized workspace root and the FeatureSet(s) it /// resolves to. `feature_set_ids` MAY be empty — an empty list is a valid /// "no Space tools" mapping (the folder still routes to this Space; built-in @@ -33,6 +65,8 @@ use uuid::Uuid; pub struct WorkspaceBinding { pub id: Uuid, pub workspace_root: String, + /// Whether `workspace_root` is a filesystem path or an arbitrary id key. + pub binding_type: BindingType, pub space_id: Uuid, /// Order matters for UI rendering only — the resolver treats them as /// a set. Stored in the `workspace_binding_feature_sets` junction @@ -63,12 +97,23 @@ impl WorkspaceBinding { Self { id: Uuid::new_v4(), workspace_root: workspace_root.into(), + binding_type: BindingType::Path, space_id, feature_set_ids, created_at: now, updated_at: now, } } + + /// Construct an **id-keyed** binding: `key` is matched verbatim (exact + /// string, no path normalization) rather than as a folder. Used to route + /// headless/remote clients by a client id or an arbitrary label. + pub fn new_id(key: impl Into, space_id: Uuid, feature_set_ids: Vec) -> Self { + Self { + binding_type: BindingType::Id, + ..Self::new_multi(key, space_id, feature_set_ids) + } + } } // ============================================================================ diff --git a/crates/mcpmux-core/src/repository/mod.rs b/crates/mcpmux-core/src/repository/mod.rs index 5d73e2de..a5afe96e 100644 --- a/crates/mcpmux-core/src/repository/mod.rs +++ b/crates/mcpmux-core/src/repository/mod.rs @@ -276,6 +276,12 @@ pub trait WorkspaceBindingRepository: Send + Sync { &self, candidate_roots: &[String], ) -> RepoResult>; + + /// Resolve an **id-keyed** binding by exact-string match (no path + /// normalization). Used to route headless/remote clients by a client id or + /// an arbitrary label sent in `X-Mcpmux-Workspace`. Only `BindingType::Id` + /// bindings are considered, so a folder path can never collide with a label. + async fn find_by_id_key(&self, key: &str) -> RepoResult>; } /// Credential repository trait (local-only, never synced) diff --git a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs index 7bad3a75..e7ef5776 100644 --- a/crates/mcpmux-gateway/src/services/feature_set_resolver.rs +++ b/crates/mcpmux-gateway/src/services/feature_set_resolver.rs @@ -95,7 +95,8 @@ use std::time::Duration; use anyhow::Result; use mcpmux_core::{ - FeatureSetRepository, SpaceBaseDirRepository, SpaceRepository, WorkspaceBindingRepository, + FeatureSetRepository, SpaceBaseDirRepository, SpaceRepository, WorkspaceBinding, + WorkspaceBindingRepository, }; use mcpmux_storage::InboundClientRepository; use serde::Serialize; @@ -281,6 +282,95 @@ impl FeatureSetResolverService { }) } + /// Resolve a binding from header/root candidate keys: a **path** binding + /// (exact normalized match) or, failing that, an **id** binding (exact + /// verbatim match — a client id or arbitrary label). First hit wins. Path + /// and id bindings live in disjoint namespaces in the repo, so this never + /// double-matches. + async fn mapping_binding_for_roots( + &self, + roots: &[String], + ) -> Result> { + if let Some(b) = self.binding_repo.find_exact_for_roots(roots).await? { + return Ok(Some(b)); + } + for r in roots { + if let Some(b) = self.binding_repo.find_by_id_key(r).await? { + return Ok(Some(b)); + } + } + Ok(None) + } + + /// Resolve a client locked to Space `locked`. The Space is fixed to + /// `locked`; the header/roots — or the client's own retargeted clientId + /// mapping — may still pick the FeatureSet, but only when that binding lives + /// in `locked`. A binding that resolves to a different Space (or no binding + /// at all) falls back to `locked`'s Starter. A locked client never touches + /// the roots-pending or client-grant tiers. + async fn resolve_locked( + &self, + session_id: Option<&str>, + client_id: Option<&str>, + locked: Uuid, + ) -> Result { + if let Some(sid) = session_id { + if let Some(roots) = self.session_roots.get(sid) { + if !roots.is_empty() { + if let Some(binding) = self.mapping_binding_for_roots(&roots).await? { + if binding.space_id == locked { + debug!( + %locked, + workspace_root = %binding.workspace_root, + "[FeatureSetResolver] locked client — header binding within locked Space", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: binding.feature_set_ids, + space_id: Some(locked), + source: ResolutionSource::WorkspaceBinding, + }); + } + debug!( + %locked, + binding_space = %binding.space_id, + "[FeatureSetResolver] locked client — header binding in a different Space; ignored", + ); + } + } + } + } + // A locked client may still have its clientId-keyed `id` mapping + // retargeted from the Mapping tab. Honor that mapping's FeatureSet — but + // only when it stays within the locked Space, preserving + // lock-confinement. A mapping pointing out of the Space (or none) falls + // through to the locked Starter. + if let Some(cid) = client_id { + if let Some(binding) = self.binding_repo.find_by_id_key(cid).await? { + if binding.space_id == locked { + debug!( + %locked, + client_id = %cid, + "[FeatureSetResolver] locked client — clientId mapping within locked Space", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: binding.feature_set_ids, + space_id: Some(locked), + source: ResolutionSource::WorkspaceBinding, + }); + } + debug!( + %locked, + client_id = %cid, + binding_space = %binding.space_id, + "[FeatureSetResolver] locked client — clientId mapping in a different Space; ignored", + ); + } + } + + // No header/mapping within the locked Space → locked Starter. + self.default_fallback(locked).await + } + /// Borrow the session-roots registry. The notifier uses this to GC /// dead sessions out of the registry when reaping the corresponding /// peer entries — keeping both stores in sync. @@ -311,6 +401,27 @@ impl FeatureSetResolverService { } }; + // Lock-confine: a client locked to Space L only ever resolves to L. The + // header/roots may still pick a FeatureSet *within* L; a binding that + // resolves to a different Space — or no header — yields L's Starter. + // Bypasses the roots-pending and grant tiers entirely. + if let Some(cid) = client_id { + if let Some(locked) = self.client_repo.get_locked_space(cid).await? { + match locked.parse::() { + Ok(locked_uuid) => { + return self + .resolve_locked(session_id, client_id, locked_uuid) + .await + } + Err(e) => warn!( + client_id = %cid, + locked_space = %locked, + "[FeatureSetResolver] client locked to unparseable space id: {e}", + ), + } + } + } + // Tier 1 / 1b / 1c — branches on roots-capable + roots-arrived state. if let Some(sid) = session_id { let roots = self.session_roots.get(sid); @@ -345,11 +456,9 @@ impl FeatureSetResolverService { // (no ancestor inheritance). if has_roots { let reported_roots = roots.expect("has_roots implies Some"); - if let Some(binding) = self - .binding_repo - .find_exact_for_roots(&reported_roots) - .await? - { + // Exact binding match: a path binding (normalized folder) or an + // id binding (verbatim label / client-id sent in the header). + if let Some(binding) = self.mapping_binding_for_roots(&reported_roots).await? { debug!( workspace_root = %binding.workspace_root, space_id = %binding.space_id, @@ -433,6 +542,23 @@ impl FeatureSetResolverService { // (the desktop UI's preview HTTP path lands here too). Consult the // per-client grant table. if let Some(cid) = client_id { + // clientId-keyed mapping (auto-created for API-key clients): when no + // header/roots selected a binding above, route by the client's own + // id. Editor clients have no such binding and fall through to grants. + if let Some(binding) = self.binding_repo.find_by_id_key(cid).await? { + debug!( + client_id = %cid, + workspace_root = %binding.workspace_root, + space_id = %binding.space_id, + "[FeatureSetResolver] resolved via clientId mapping", + ); + return Ok(ResolvedFeatureSet { + feature_set_ids: binding.feature_set_ids, + space_id: Some(binding.space_id), + source: ResolutionSource::WorkspaceBinding, + }); + } + // Propagate storage errors instead of treating them as "no // grants": a transient DB failure must surface as a request // error, not a silent deny (which would also record a `None` diff --git a/crates/mcpmux-storage/src/database.rs b/crates/mcpmux-storage/src/database.rs index 93fc27d7..1b5dffd7 100644 --- a/crates/mcpmux-storage/src/database.rs +++ b/crates/mcpmux-storage/src/database.rs @@ -133,6 +133,16 @@ const MIGRATIONS: &[Migration] = &[ name: "inbound_client_api_keys", sql: include_str!("migrations/020_inbound_client_api_keys.sql"), }, + Migration { + version: 21, + name: "binding_type", + sql: include_str!("migrations/021_binding_type.sql"), + }, + Migration { + version: 22, + name: "inbound_client_locked_space", + sql: include_str!("migrations/022_inbound_client_locked_space.sql"), + }, ]; /// SQLite database wrapper. diff --git a/crates/mcpmux-storage/src/migrations/021_binding_type.sql b/crates/mcpmux-storage/src/migrations/021_binding_type.sql new file mode 100644 index 00000000..90379850 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/021_binding_type.sql @@ -0,0 +1,11 @@ +-- Migration 021: workspace binding type (path vs id) +-- +-- Generalizes mappings beyond filesystem folders. A binding's `workspace_root` +-- is now a routing KEY that is either: +-- * 'path' — a normalized absolute folder path (the original behaviour), or +-- * 'id' — an arbitrary exact-match string (a client id, machine name, or +-- any label a headless/remote client sends in X-Mcpmux-Workspace). +-- Existing rows are folder paths, so they default to 'path' (backward +-- compatible). Path keys are normalized + case-folded before comparison; +-- id keys are matched verbatim. +ALTER TABLE workspace_bindings ADD COLUMN binding_type TEXT NOT NULL DEFAULT 'path'; diff --git a/crates/mcpmux-storage/src/migrations/022_inbound_client_locked_space.sql b/crates/mcpmux-storage/src/migrations/022_inbound_client_locked_space.sql new file mode 100644 index 00000000..afd91a31 --- /dev/null +++ b/crates/mcpmux-storage/src/migrations/022_inbound_client_locked_space.sql @@ -0,0 +1,9 @@ +-- Migration 022: lock an inbound client to a single Space +-- +-- An API-key (or any pre-registered) client may be confined to one Space. When +-- set, the FeatureSet resolver always resolves this client to `locked_space_id`, +-- ignoring an X-Mcpmux-Workspace header that points at a *different* Space (the +-- header may still select a FeatureSet *within* the locked Space). NULL = +-- unlocked (the default) — the client routes freely by header / clientId +-- mapping / default Space. +ALTER TABLE inbound_clients ADD COLUMN locked_space_id TEXT; diff --git a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs index f20b353a..30d00023 100644 --- a/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs +++ b/crates/mcpmux-storage/src/repositories/inbound_client_repository.rs @@ -707,6 +707,36 @@ impl InboundClientRepository { Ok(()) } + /// Set (or clear, with `None`) the Space a client is locked to. A locked + /// client is confined to that Space during resolution (see the gateway + /// FeatureSet resolver). + pub async fn set_locked_space(&self, client_id: &str, space_id: Option<&str>) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let db = self.db.lock().await; + let conn = db.connection(); + conn.execute( + "UPDATE inbound_clients SET locked_space_id = ?1, updated_at = ?2 WHERE client_id = ?3", + params![space_id, now, client_id], + )?; + Ok(()) + } + + /// The Space a client is locked to, if any. + pub async fn get_locked_space(&self, client_id: &str) -> Result> { + let db = self.db.lock().await; + let conn = db.connection(); + let result = conn.query_row( + "SELECT locked_space_id FROM inbound_clients WHERE client_id = ?1", + params![client_id], + |r| r.get::<_, Option>(0), + ); + match result { + Ok(v) => Ok(v), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + /// Save a token record pub async fn save_token(&self, record: &TokenRecord) -> Result<()> { let db = self.db.lock().await; diff --git a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs index 25f07268..c9848103 100644 --- a/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs +++ b/crates/mcpmux-storage/src/repositories/workspace_binding_repository.rs @@ -32,7 +32,7 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mcpmux_core::{WorkspaceBinding, WorkspaceBindingRepository}; +use mcpmux_core::{BindingType, WorkspaceBinding, WorkspaceBindingRepository}; use rusqlite::params; use tokio::sync::Mutex; use uuid::Uuid; @@ -67,10 +67,12 @@ impl SqliteWorkspaceBindingRepository { let space_id_str: String = row.get(2)?; let created_at: String = row.get(3)?; let updated_at: String = row.get(4)?; + let binding_type: String = row.get(5)?; Ok(WorkspaceBinding { id: id_str.parse().unwrap_or_else(|_| Uuid::new_v4()), workspace_root, + binding_type: BindingType::parse(&binding_type), space_id: space_id_str.parse().unwrap_or_else(|_| Uuid::nil()), feature_set_ids: Vec::new(), // filled in by caller created_at: Self::parse_datetime(&created_at), @@ -149,7 +151,8 @@ impl SqliteWorkspaceBindingRepository { Ok(()) } - const SELECT_COLS: &'static str = "id, workspace_root, space_id, created_at, updated_at"; + const SELECT_COLS: &'static str = + "id, workspace_root, space_id, created_at, updated_at, binding_type"; /// Fetch bindings + their FeatureSet lists in two queries. /// `where_clause` is appended to the binding SELECT (use `""` for none); @@ -221,14 +224,15 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let tx = conn.unchecked_transaction()?; tx.execute( "INSERT INTO workspace_bindings - (id, workspace_root, space_id, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5)", + (id, workspace_root, space_id, created_at, updated_at, binding_type) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ binding.id.to_string(), binding.workspace_root, binding.space_id.to_string(), binding.created_at.to_rfc3339(), binding.updated_at.to_rfc3339(), + binding.binding_type.as_str(), ], )?; Self::rewrite_fs_for_binding(&tx, &binding.id.to_string(), &binding.feature_set_ids)?; @@ -248,13 +252,14 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let tx = conn.unchecked_transaction()?; let rows_affected = tx.execute( "UPDATE workspace_bindings - SET workspace_root = ?2, space_id = ?3, updated_at = ?4 + SET workspace_root = ?2, space_id = ?3, updated_at = ?4, binding_type = ?5 WHERE id = ?1", params![ binding.id.to_string(), binding.workspace_root, binding.space_id.to_string(), binding.updated_at.to_rfc3339(), + binding.binding_type.as_str(), ], )?; @@ -292,14 +297,29 @@ impl WorkspaceBindingRepository for SqliteWorkspaceBindingRepository { let bindings = self.list().await?; // Exact match only — no ancestor/prefix inheritance. A folder resolves - // to a binding for THAT exact root, or to nothing. + // to a binding for THAT exact root, or to nothing. Only PATH bindings + // participate here; id-keyed bindings are matched via `find_by_id_key` + // so a folder root can never collide with an arbitrary id label. for root in candidate_roots { - if let Some(b) = bindings.iter().find(|b| &b.workspace_root == root) { + if let Some(b) = bindings + .iter() + .find(|b| b.binding_type == BindingType::Path && &b.workspace_root == root) + { return Ok(Some(b.clone())); } } Ok(None) } + + async fn find_by_id_key(&self, key: &str) -> Result> { + if key.is_empty() { + return Ok(None); + } + let bindings = self.list().await?; + Ok(bindings + .into_iter() + .find(|b| b.binding_type == BindingType::Id && b.workspace_root == key)) + } } #[cfg(test)] @@ -506,4 +526,45 @@ mod tests { .expect("exact match"); assert_eq!(hit.workspace_root, exact); } + + #[tokio::test] + async fn test_id_binding_matches_by_exact_key_not_as_path() { + // An id-keyed binding is matched verbatim via find_by_id_key and is + // invisible to the path-based find_exact_for_roots (and vice versa) so a + // folder root and an arbitrary id label can never collide. + let (repo, space_id, fs_id) = fixture().await; + let key = "mcp_abc12345"; // e.g. a client id + let binding = WorkspaceBinding::new_id(key, space_id, vec![fs_id.clone()]); + repo.create(&binding).await.unwrap(); + + // Exact id lookup hits and round-trips the type + key. + let got = repo.find_by_id_key(key).await.unwrap().expect("id match"); + assert_eq!(got.binding_type, BindingType::Id); + assert_eq!(got.workspace_root, key); + assert_eq!(got.feature_set_ids, vec![fs_id.clone()]); + + // The path resolver ignores id bindings. + assert!(repo + .find_exact_for_roots(&[key.to_string()]) + .await + .unwrap() + .is_none()); + + // A path binding is invisible to the id lookup, but visible to the + // path resolver. + let path_root = if cfg!(windows) { + "d:\\idtest" + } else { + "/idtest" + }; + repo.create(&WorkspaceBinding::new(path_root, space_id, fs_id)) + .await + .unwrap(); + assert!(repo.find_by_id_key(path_root).await.unwrap().is_none()); + assert!(repo + .find_exact_for_roots(&[path_root.to_string()]) + .await + .unwrap() + .is_some()); + } } diff --git a/tests/rust/tests/database/migrations.rs b/tests/rust/tests/database/migrations.rs index e8ea4aac..c3afa417 100644 --- a/tests/rust/tests/database/migrations.rs +++ b/tests/rust/tests/database/migrations.rs @@ -151,3 +151,82 @@ fn test_018_rewrites_stale_starter_description_only() { "operator-customized copy must be preserved" ); } + +// --------------------------------------------------------------------------- +// Upgrade path — applying NEW migrations to an EXISTING (older) on-disk DB. +// +// Every other test here uses a FRESH in-memory DB, where all migrations run at +// once — so they never catch a migration that fails to apply when a real user +// opens a database created by a previous release. These two do. +// --------------------------------------------------------------------------- + +fn table_exists(db: &Database, name: &str) -> bool { + db.connection() + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name=?1", + [name], + |r| r.get::<_, bool>(0), + ) + .unwrap_or(false) +} + +fn column_exists(db: &Database, table: &str, column: &str) -> bool { + let sql = format!("SELECT COUNT(*) > 0 FROM pragma_table_info('{table}') WHERE name=?1"); + db.connection() + .query_row(&sql, [column], |r| r.get::<_, bool>(0)) + .unwrap_or(false) +} + +#[test] +fn test_new_schema_objects_exist_after_migration() { + // A fresh migrate must produce every object the API-key + mapping features + // depend on — a regression guard against a migration being dropped or broken. + let db = Database::open_in_memory().expect("open"); + assert!( + table_exists(&db, "inbound_client_api_keys"), + "migration 020 must create inbound_client_api_keys" + ); + assert!( + column_exists(&db, "workspace_bindings", "binding_type"), + "migration 021 must add workspace_bindings.binding_type" + ); + assert!( + column_exists(&db, "inbound_clients", "locked_space_id"), + "migration 022 must add inbound_clients.locked_space_id" + ); +} + +#[test] +fn test_pending_migrations_apply_to_an_existing_older_database() { + // Reproduce the real upgrade that surfaced "no such table: + // inbound_client_api_keys" in the field: a DB created before 020/021/022 + // existed, reopened by a newer build. The pending migrations MUST apply. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("mcpmux.db"); + + // 1. Fully migrate, then roll the schema back to a pre-020 state. + { + let db = Database::open(&path).expect("open"); + db.connection() + .execute_batch( + "DELETE FROM schema_migrations WHERE version >= 20; + DROP TABLE IF EXISTS inbound_client_api_keys; + ALTER TABLE workspace_bindings DROP COLUMN binding_type; + ALTER TABLE inbound_clients DROP COLUMN locked_space_id;", + ) + .expect("roll schema back to pre-020"); + assert!( + !table_exists(&db, "inbound_client_api_keys"), + "precondition: the rolled-back DB is missing the table" + ); + } + + // 2. Reopen — run_migrations() must re-apply 020/021/022. + let db = Database::open(&path).expect("reopen older DB"); + assert!( + table_exists(&db, "inbound_client_api_keys"), + "reopening an older DB must re-create inbound_client_api_keys" + ); + assert!(column_exists(&db, "workspace_bindings", "binding_type")); + assert!(column_exists(&db, "inbound_clients", "locked_space_id")); +} diff --git a/tests/rust/tests/integration/feature_set_resolver.rs b/tests/rust/tests/integration/feature_set_resolver.rs index b9bf33e5..d56b0a84 100644 --- a/tests/rust/tests/integration/feature_set_resolver.rs +++ b/tests/rust/tests/integration/feature_set_resolver.rs @@ -808,3 +808,242 @@ async fn pinned_header_root_without_binding_falls_back_to_space_default() { assert_eq!(r.feature_set_ids, vec![f.starter_fs_id.clone()]); assert_eq!(r.space_id, Some(f.space_id)); } + +// --------------------------------------------------------------------------- +// Generalized mappings (id-keyed bindings) + clientId routing + lock-confine +// (P2). Precedence (unlocked): header > clientId-binding > Space default. +// Locked: Space is ALWAYS the locked one; the header only picks the FeatureSet +// when its binding lives in that Space, else the locked Space's Starter. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn id_binding_routes_by_header_value() { + // A header that is an arbitrary label (not a folder) routes via an id-keyed + // binding: the pinned header shadows roots and matches the id verbatim. + let f = Fixture::new().await; + f.binding_repo + .create(&WorkspaceBinding::new_id( + "team-x", + f.space_id, + vec![f.fs_a_id.clone()], + )) + .await + .unwrap(); + f.session_roots.set_pinned("s", "team-x"); + let r = f + .resolver + .resolve(Some("s"), Some("client-1")) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); +} + +#[tokio::test] +async fn client_id_binding_routes_when_no_header() { + // No header/roots: an unlocked client routes by its own clientId via an + // id-binding keyed by the client id (auto-created on registration). + let f = Fixture::new().await; + f.make_client("mcp_abc").await; + f.binding_repo + .create(&WorkspaceBinding::new_id( + "mcp_abc", + f.space_id, + vec![f.fs_b_id.clone()], + )) + .await + .unwrap(); + // Explicitly rootless so we skip the pending grace and reach the clientId tier. + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some("mcp_abc")) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.feature_set_ids, vec![f.fs_b_id]); +} + +#[tokio::test] +async fn header_beats_client_id_binding_when_both_present() { + // Unlocked precedence: an explicit header outranks the clientId binding. + let f = Fixture::new().await; + f.make_client("mcp_abc").await; + f.binding_repo + .create(&WorkspaceBinding::new_id( + "mcp_abc", + f.space_id, + vec![f.fs_b_id.clone()], + )) + .await + .unwrap(); + f.binding_repo + .create(&WorkspaceBinding::new_id( + "team-x", + f.space_id, + vec![f.fs_a_id.clone()], + )) + .await + .unwrap(); + f.session_roots.set_pinned("s", "team-x"); + let r = f + .resolver + .resolve(Some("s"), Some("mcp_abc")) + .await + .unwrap(); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); // header's FS, not clientId's +} + +#[tokio::test] +async fn locked_client_uses_header_fs_when_binding_is_in_locked_space() { + // A locked client whose header binding lives IN the locked Space uses that + // binding's FeatureSet — the Space stays locked, the FeatureSet is selectable. + let f = Fixture::new().await; + f.make_client("locked-2").await; + f.client_repo + .set_locked_space("locked-2", Some(&f.space_id.to_string())) + .await + .unwrap(); + f.binding_repo + .create(&WorkspaceBinding::new_id( + "inhouse", + f.space_id, + vec![f.fs_a_id.clone()], + )) + .await + .unwrap(); + f.session_roots.set_pinned("s", "inhouse"); + let r = f + .resolver + .resolve(Some("s"), Some("locked-2")) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); +} + +#[tokio::test] +async fn locked_client_ignores_foreign_header_and_uses_locked_starter() { + // A client locked to Space L, sending a header whose binding lives in a + // DIFFERENT Space, is confined to L and falls back to L's Starter. + let f = Fixture::new().await; + f.make_client("locked-1").await; + let other_base = if cfg!(windows) { "d:\\other" } else { "/other" }; + let (other_space, other_starter) = f.make_space_with_base_dir("Other", other_base).await; + f.client_repo + .set_locked_space("locked-1", Some(&f.space_id.to_string())) + .await + .unwrap(); + // Header binding points at the OTHER space. + f.binding_repo + .create(&WorkspaceBinding::new_id( + "foreign", + other_space, + vec![other_starter], + )) + .await + .unwrap(); + f.session_roots.set_pinned("s", "foreign"); + let r = f + .resolver + .resolve(Some("s"), Some("locked-1")) + .await + .unwrap(); + // Confined to the locked (default) Space; the foreign header is ignored. + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id]); +} + +#[tokio::test] +async fn locked_client_with_no_header_gets_locked_space_starter() { + let f = Fixture::new().await; + f.make_client("locked-3").await; + f.client_repo + .set_locked_space("locked-3", Some(&f.space_id.to_string())) + .await + .unwrap(); + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some("locked-3")) + .await + .unwrap(); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id]); +} + +#[tokio::test] +async fn locked_client_honors_in_space_retargeted_client_id_mapping() { + // A locked client with no header: its clientId-keyed mapping was retargeted + // (from the auto-created Starter) to another FeatureSet that still lives in + // the locked Space. The resolver must honor that mapping — the Space stays + // locked, but the operator's FeatureSet choice is respected. + let f = Fixture::new().await; + f.make_client("locked-4").await; + f.client_repo + .set_locked_space("locked-4", Some(&f.space_id.to_string())) + .await + .unwrap(); + // Retargeted clientId mapping → a non-Starter FS within the locked Space. + f.binding_repo + .create(&WorkspaceBinding::new_id( + "locked-4", + f.space_id, + vec![f.fs_a_id.clone()], + )) + .await + .unwrap(); + // No header; explicitly rootless so we reach the clientId tier. + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some("locked-4")) + .await + .unwrap(); + assert_eq!(r.source, ResolutionSource::WorkspaceBinding); + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.feature_set_ids, vec![f.fs_a_id]); +} + +#[tokio::test] +async fn locked_client_ignores_out_of_space_client_id_mapping() { + // A locked client whose clientId mapping points at a DIFFERENT Space must be + // confined to the locked Space: the out-of-Space mapping is ignored and the + // client falls back to the locked Space's Starter (lock-confinement holds). + let f = Fixture::new().await; + f.make_client("locked-5").await; + let other_base = if cfg!(windows) { + "d:\\elsewhere" + } else { + "/elsewhere" + }; + let (other_space, other_starter) = f.make_space_with_base_dir("Elsewhere", other_base).await; + f.client_repo + .set_locked_space("locked-5", Some(&f.space_id.to_string())) + .await + .unwrap(); + // clientId mapping points OUT of the locked Space. + f.binding_repo + .create(&WorkspaceBinding::new_id( + "locked-5", + other_space, + vec![other_starter], + )) + .await + .unwrap(); + // No header; explicitly rootless so we reach the clientId tier. + f.session_roots.set_roots_capable("s", false); + let r = f + .resolver + .resolve(Some("s"), Some("locked-5")) + .await + .unwrap(); + // Confined to the locked (default) Space; the foreign mapping is ignored. + assert_eq!(r.space_id, Some(f.space_id)); + assert_eq!(r.source, ResolutionSource::SpaceDefault); + assert_eq!(r.feature_set_ids, vec![f.starter_fs_id]); +} diff --git a/tests/ts/components/WorkspaceSetupWizard.test.tsx b/tests/ts/components/WorkspaceSetupWizard.test.tsx index d19241e9..ccc0d675 100644 --- a/tests/ts/components/WorkspaceSetupWizard.test.tsx +++ b/tests/ts/components/WorkspaceSetupWizard.test.tsx @@ -81,6 +81,7 @@ describe('WorkspaceSetupWizard', () => { workspace_root: '/proj/app', space_id: 's1', feature_set_ids: ['fs_starter'], + binding_type: 'path', }); // The parent navigates to the new mapping's inspector (effective features); // the wizard itself does not close.