diff --git a/.github/workflows/fork-identity-qa.yml b/.github/workflows/fork-identity-qa.yml new file mode 100644 index 000000000000..f9472f8af8da --- /dev/null +++ b/.github/workflows/fork-identity-qa.yml @@ -0,0 +1,65 @@ +name: Fork identity browser QA + +on: + pull_request: + branches: [fork] + paths: + - "apps/web/**" + - "packages/client-runtime/**" + - "packages/fork-core/**" + - "scripts/fork/mic-identity-browser-qa.mjs" + - ".github/workflows/fork-identity-qa.yml" + - "pnpm-lock.yaml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: fork-identity-qa-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + browser: + name: Identity browser acceptance and screenshots + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: true + run-install: true + + - name: Install Chromium + run: vp exec --filter @t3tools/web -- playwright install --with-deps chromium + + - name: Verify identity UI with synthetic service fixtures + env: + Q1_IDENTITY_QA_OUTPUT: ${{ runner.temp }}/mic-identity-qa + run: node scripts/fork/mic-identity-browser-qa.mjs + + - name: Upload screenshots and acceptance results + id: evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: mic-identity-browser-qa-${{ github.sha }} + path: ${{ runner.temp }}/mic-identity-qa/ + if-no-files-found: warn + retention-days: 14 + + - name: Link review evidence + if: always() && steps.evidence.outputs.artifact-url != '' + env: + ARTIFACT_URL: ${{ steps.evidence.outputs.artifact-url }} + run: | + printf '### mic.sc identity UI evidence\n\n[Screenshots and acceptance results](%s)\n\nReal Chromium using synthetic Clerk, authority, gateway and provider fixtures. This does not certify live Clerk or production backend access.\n' "$ARTIFACT_URL" >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0719fe1865f8..cb587e152aaa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,7 +16,6 @@ "@clerk/electron-passkeys": "catalog:", "@effect/platform-node": "catalog:", "@napi-rs/keyring": "^1.3.0", - "@q1code/core": "workspace:*", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", diff --git a/apps/desktop/src/app/DesktopStatePaths.ts b/apps/desktop/src/app/DesktopStatePaths.ts index 49147967c036..9789a6239bc7 100644 --- a/apps/desktop/src/app/DesktopStatePaths.ts +++ b/apps/desktop/src/app/DesktopStatePaths.ts @@ -1,4 +1,4 @@ -import { BRAND } from "@q1code/core/brand"; // fork: base +import { BRAND } from "@t3tools/client-runtime/fork/brand"; // fork: base import * as Option from "effect/Option"; export type JoinPath = (first: string, ...segments: string[]) => string; diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index fe22befb10de..7b497ddbc0c8 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -58,7 +58,7 @@ import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRoute import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; -import { prismSettingsStackScreen } from "./fork/prism/PrismSettingsScreen"; // fork: prism +import { prismSettingsStackScreen, withMicPrismIdentity } from "./fork/prism/PrismSettingsScreen"; // fork: prism import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; import { SettingsLegalDocumentCloseHeaderButton, @@ -449,7 +449,7 @@ function NotFoundScreen() { export const RootStack = createNativeStackNavigator({ initialRouteName: "Home", - layout: RootStackLayout, + layout: withMicPrismIdentity(RootStackLayout), // fork: prism screenOptions: { headerShown: false, }, diff --git a/apps/mobile/src/fork/prism/MicPrismInferenceSection.tsx b/apps/mobile/src/fork/prism/MicPrismInferenceSection.tsx new file mode 100644 index 000000000000..55209903a101 --- /dev/null +++ b/apps/mobile/src/fork/prism/MicPrismInferenceSection.tsx @@ -0,0 +1,223 @@ +import { completeMicPrismChat, listMicPrismModels } from "@t3tools/client-runtime/fork"; +import type { MicIdentityClientInput } from "@t3tools/client-runtime/fork"; +import * as Effect from "effect/Effect"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { SettingsSection } from "../../features/settings/components/SettingsSection"; +import { runtime } from "../../lib/runtime"; + +/** A small inference surface; these service grants never start an environment coding agent. */ +export function MicPrismInferenceSection(props: { + readonly input: MicIdentityClientInput; + readonly enabled: boolean; +}) { + const [models, setModels] = useState>([]); + const [model, setModel] = useState(""); + const [prompt, setPrompt] = useState(""); + const [response, setResponse] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [modelPickerOpen, setModelPickerOpen] = useState(false); + const request = useRef(null); + const catalogRequest = useRef(null); + const currentInput = useRef(props.input); + useLayoutEffect(() => { + currentInput.current = props.input; + return () => { + currentInput.current = null; + request.current?.abort(); + }; + }, [props.input]); + + const loadModels = useCallback(() => { + catalogRequest.current?.abort(); + const abort = new AbortController(); + catalogRequest.current = abort; + void runtime + .runPromise(listMicPrismModels(props.input).pipe(Effect.result), { signal: abort.signal }) + .then((result) => { + if ( + abort.signal.aborted || + currentInput.current !== props.input || + props.input.isCurrent?.() === false + ) + return; + if (result._tag === "Success") { + setModels(result.success); + setError(null); + setModel((previous) => + result.success.includes(previous) ? previous : (result.success[0] ?? ""), + ); + } else { + setModels([]); + setModel(""); + setError("Could not load Prism models. Refresh access and try again."); + } + }) + .catch(() => { + if (!abort.signal.aborted) setError("Could not load Prism models. Try again."); + }); + return abort; + }, [props.input]); + useEffect(() => { + const abort = loadModels(); + return () => { + abort.abort(); + catalogRequest.current?.abort(); + }; + }, [loadModels]); + + const send = async () => { + if (!props.enabled || request.current || !model || !prompt.trim()) return; + const input = props.input; + const abort = new AbortController(); + request.current = abort; + setBusy(true); + setResponse(""); + setError(null); + try { + const result = await runtime.runPromise( + completeMicPrismChat({ + ...input, + model, + messages: [{ role: "user", content: prompt.trim() }], + }).pipe(Effect.result), + { signal: abort.signal }, + ); + if (abort.signal.aborted || currentInput.current !== input || input.isCurrent?.() === false) + return; + if (result._tag === "Success") setResponse(result.success); + else { + if ( + result.failure._tag === "MicIdentityUnauthorizedError" || + result.failure._tag === "MicIdentityForbiddenError" + ) { + setModels([]); + setModel(""); + setResponse(""); + setError("Prism access was denied. Refresh your mic.sc sign-in to continue."); + } else + setError( + "Prism could not complete this request. Check model availability and try again.", + ); + } + } catch { + if (!abort.signal.aborted) setError("The request did not complete. Try again."); + } finally { + if (request.current === abort) { + request.current = null; + setBusy(false); + } + } + }; + + return ( + + + + Send a prompt through your paired Prism service. Listed models may be unavailable when + provider capacity changes. + + setModelPickerOpen(true)} + className="rounded-xl bg-subtle px-4 py-3" + > + {model || "Models unavailable"} + + { + loadModels(); + }} + className="self-start rounded-full bg-subtle px-4 py-2" + > + Refresh models + + + { + if (busy) { + request.current?.abort(); + setError("Request cancelled."); + } else void send(); + }} + className="self-start rounded-full bg-subtle px-4 py-2" + > + + {busy ? "Cancel request" : "Send to Prism"} + + + {busy ? ( + + Waiting for a response… + + ) : null} + {error ? ( + + {error} + + ) : null} + {response ? ( + + {response} + + ) : null} + + setModelPickerOpen(false)} + > + + Choose a model + setModelPickerOpen(false)} + className="self-start rounded-full bg-subtle px-4 py-2" + > + Done + + {models.map((name) => ( + { + setModel(name); + setModelPickerOpen(false); + }} + className="rounded-xl bg-subtle p-4" + > + + {name === model ? "✓ " : ""} + {name} + + + ))} + + + + ); +} diff --git a/apps/mobile/src/fork/prism/MicPrismThreadSection.tsx b/apps/mobile/src/fork/prism/MicPrismThreadSection.tsx new file mode 100644 index 000000000000..882ad685f62e --- /dev/null +++ b/apps/mobile/src/fork/prism/MicPrismThreadSection.tsx @@ -0,0 +1,106 @@ +import { useAtomValue } from "@effect/atom-react"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useContext, useState } from "react"; +import { Modal, Pressable, ScrollView, View } from "react-native"; +import { AppText as Text } from "../../components/AppText"; +import { SettingsSection } from "../../features/settings/components/SettingsSection"; +import { environmentSession } from "../../state/session"; +import { useThreadShells } from "../../state/entities"; +import { MicPrismThreadBridgeContext } from "./PersistentMicPrismIdentity"; + +export function MicPrismThreadSection(props: { readonly environmentId: EnvironmentId }) { + const bridge = useContext(MicPrismThreadBridgeContext); + const session = useAtomValue(environmentSession.sessionStateAtom(props.environmentId)); + const access = Option.getOrNull(AsyncResult.value(session)); + const canRead = + session._tag !== "Failure" && + access?.authenticated === true && + access.scopes?.includes("orchestration:read"); + const canOperate = canRead && access?.scopes?.includes("orchestration:operate"); + const threads = useThreadShells().filter( + (thread) => canRead && thread.environmentId === props.environmentId && !thread.archivedAt, + ); + const [selected, setSelected] = useState(null); + const [choosing, setChoosing] = useState(false); + const [busy, setBusy] = useState(false); + if (!bridge) return null; + const thread = threads.find((thread) => thread.id === selected); + const binding = selected ? bridge.bindings.get(`${props.environmentId}/${selected}`) : undefined; + const change = async () => { + if (!thread || busy || !canOperate) return; + setBusy(true); + try { + if (binding) await bridge.disconnect(props.environmentId, thread.id); + else await bridge.connect(props.environmentId, thread.id); + } finally { + setBusy(false); + } + }; + return ( + + + + Enable Prism for a thread you can already access. Access renews while this app is active + and ends when you sign out. Choose Prism routing in the thread before sending. + + setChoosing(true)} + className="rounded-xl bg-subtle p-3" + > + + {thread?.title ?? (threads.length ? "Choose a thread" : "No accessible threads")} + + + void change()} + className="self-start rounded-full bg-subtle px-4 py-2" + > + + {busy ? "Updating…" : binding ? "Disconnect Prism" : "Enable Prism for thread"} + + + {binding ? ( + + Prism connected. Access expires {new Date(binding.expiresAt).toLocaleTimeString()}{" "} + unless renewed. + + ) : null} + {bridge.error ? ( + {bridge.error} + ) : null} + + setChoosing(false)}> + + Choose a coding thread + setChoosing(false)} className="p-3"> + Done + + {threads.map((thread) => ( + { + setSelected(thread.id); + setChoosing(false); + }} + className="rounded-xl bg-subtle p-3" + > + {thread.title} + + ))} + + + + ); +} diff --git a/apps/mobile/src/fork/prism/PersistentMicPrismIdentity.tsx b/apps/mobile/src/fork/prism/PersistentMicPrismIdentity.tsx new file mode 100644 index 000000000000..0a8a817636ad --- /dev/null +++ b/apps/mobile/src/fork/prism/PersistentMicPrismIdentity.tsx @@ -0,0 +1,321 @@ +import { ClerkProvider, useAuth } from "@clerk/expo"; +import type { MicIdentityPublicConfig } from "@q1code/core/micIdentityApi"; +import { readForkFlag } from "@t3tools/client-runtime/fork"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as SecureStore from "expo-secure-store"; +import { + createContext, + type ComponentType, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { AppState } from "react-native"; + +import { resolveCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { useServerConfigs } from "../../state/entities"; +import { useEnvironments } from "../../state/environments"; +import { freshMicMobileToken, resolveMicMobileIdentityMode } from "./micIdentity.logic"; +import { MicPrismTokenContext } from "./micIdentityContext"; +import { type PrismApi, usePrismApi } from "./usePrismApi"; + +type Binding = { + readonly environmentId: EnvironmentId; + readonly threadId: string; + readonly expiresAt: number; +}; +type ThreadBridge = { + readonly config: MicIdentityPublicConfig; + readonly bindings: ReadonlyMap; + readonly error: string | null; + readonly active: boolean; + readonly connect: (environmentId: EnvironmentId, threadId: string) => Promise; + readonly disconnect: (environmentId: EnvironmentId, threadId: string) => Promise; + readonly invalidate: () => void; +}; +export const MicPrismThreadBridgeContext = createContext(null); +export const MicPrismRootPresentContext = createContext(false); + +const tokenCache = { + getToken: (key: string) => SecureStore.getItemAsync(`q1code.mic-sc.${key}`), + saveToken: (key: string, value: string) => + SecureStore.setItemAsync(`q1code.mic-sc.${key}`, value), + clearToken: (key: string) => SecureStore.deleteItemAsync(`q1code.mic-sc.${key}`), +}; +const bindingKey = (environmentId: EnvironmentId, threadId: string) => + `${environmentId}/${threadId}`; + +/** Existing navigation layout seam keeps credentials and renewal alive while changing screens. */ +export function withMicPrismIdentity

(Layout: ComponentType

) { + return function MicPrismLayout(props: P) { + return ( + + + + + + ); + }; +} + +function PersistentIdentity(props: { readonly children: ReactNode }) { + const { environments } = useEnvironments(); + const configs = useServerConfigs(); + const targets = environments + .filter(({ environmentId }) => + readForkFlag(configs.get(environmentId)?.environment.capabilities, "mic-identity"), + ) + .map(({ environmentId }) => environmentId) + .sort(); + return targets[0] ? ( + + {props.children} + + ) : ( + props.children + ); +} + +function ConfiguredIdentity(props: { + readonly environmentId: EnvironmentId; + readonly targets: ReadonlyArray; + readonly children: ReactNode; +}) { + const api = usePrismApi(props.environmentId); + const [loaded, setLoaded] = useState<{ + readonly source: PrismApi; + readonly environmentId: EnvironmentId; + readonly config: MicIdentityPublicConfig; + } | null>(null); + const config = + loaded?.source === api && loaded.environmentId === props.environmentId ? loaded.config : null; + useEffect(() => { + let current = true; + if (api) + void api.identityConfig().then((result) => { + if (current) + setLoaded( + result._tag === "ok" + ? { source: api, environmentId: props.environmentId, config: result.value } + : null, + ); + }); + return () => { + current = false; + }; + }, [api, props.environmentId]); + if (!config) return props.children; + const cloud = resolveCloudPublicConfig(); + const mode = resolveMicMobileIdentityMode( + config, + cloud.relay.url ? cloud.clerk.publishableKey : null, + true, + ); + if (mode !== "local" && mode !== "shared") return props.children; + const content = ( + + {props.children} + + ); + return mode === "shared" ? ( + content + ) : ( + + {content} + + ); +} + +function IdentitySession(props: { + readonly config: MicIdentityPublicConfig; + readonly targets: ReadonlyArray; + readonly children: ReactNode; +}) { + const { isSignedIn, sessionId, userId, getToken } = useAuth(); + const key = isSignedIn && sessionId && userId ? `${userId}:${sessionId}` : "signed-out"; + return ( + + {props.children} + + ); +} + +function ThreadBridgeProvider(props: { + readonly identityKey: string; + readonly config: MicIdentityPublicConfig; + readonly targets: ReadonlyArray; + readonly getToken: (options: { skipCache: boolean }) => Promise; + readonly children: ReactNode; +}) { + const apis = useRef(new Map()); + const bindings = useRef(new Map()); + const pending = useRef(new Set()); + const generations = useRef(new Map()); + const valid = useRef(props.identityKey !== "signed-out"); + const invalidated = useRef(false); + const [active, setActive] = useState(props.identityKey !== "signed-out"); + const [snapshot, setSnapshot] = useState>(() => new Map()); + const [error, setError] = useState(null); + const isCurrent = useCallback(() => valid.current, []); + const source = useCallback( + () => freshMicMobileToken(props.getToken, isCurrent)(), + [props.getToken, isCurrent], + ); + const disconnect = useCallback(async (environmentId: EnvironmentId, threadId: string) => { + const key = bindingKey(environmentId, threadId); + generations.current.set(key, (generations.current.get(key) ?? 0) + 1); + bindings.current.delete(key); + setSnapshot(new Map(bindings.current)); + const result = await apis.current.get(environmentId)?.disconnectIdentityThread(threadId); + if (result?._tag === "error") + setError("Could not confirm disconnection. Prism access will expire unless renewed."); + }, []); + const connect = useCallback( + async (environmentId: EnvironmentId, threadId: string) => { + const api = apis.current.get(environmentId); + const key = bindingKey(environmentId, threadId); + if (!valid.current || !api || pending.current.has(key)) return; + pending.current.add(key); + const generation = generations.current.get(key) ?? 0; + try { + const config = await api.identityConfig(); + if ( + config._tag !== "ok" || + config.value.authorityUrl !== props.config.authorityUrl || + config.value.clerkPublishableKey !== props.config.clerkPublishableKey + ) { + await disconnect(environmentId, threadId); + setError( + "The mic.sc authority changed. Refresh sign-in before reconnecting this thread.", + ); + return; + } + if (!valid.current || generation !== (generations.current.get(key) ?? 0)) return; + const result = await api.connectIdentityThread(threadId); + if (!valid.current || generation !== (generations.current.get(key) ?? 0)) { + await api.disconnectIdentityThread(threadId); + return; + } + if (result._tag === "error") { + await disconnect(environmentId, threadId); + setError( + "Prism access could not be renewed for this thread. Reconnect after checking sign-in and environment permissions.", + ); + return; + } + bindings.current.set(key, { environmentId, threadId, expiresAt: result.value.expiresAt }); + setSnapshot(new Map(bindings.current)); + setError(null); + } finally { + pending.current.delete(key); + } + }, + [disconnect, props.config.authorityUrl, props.config.clerkPublishableKey], + ); + const invalidate = useCallback(() => { + invalidated.current = true; + valid.current = false; + setActive(false); + for (const binding of bindings.current.values()) + void disconnect(binding.environmentId, binding.threadId); + }, [disconnect]); + useLayoutEffect(() => { + valid.current = !invalidated.current && props.identityKey !== "signed-out"; + return () => { + valid.current = false; + }; + }, [props.identityKey]); + useEffect(() => { + valid.current = !invalidated.current && props.identityKey !== "signed-out"; + const renew = () => { + if (valid.current && AppState.currentState === "active") + for (const binding of bindings.current.values()) + void connect(binding.environmentId, binding.threadId); + }; + const timer = setInterval(renew, 45_000); + const listener = AppState.addEventListener("change", (state) => { + if (state === "active") renew(); + }); + return () => { + valid.current = false; + clearInterval(timer); + listener.remove(); + for (const binding of bindings.current.values()) + void apis.current.get(binding.environmentId)?.disconnectIdentityThread(binding.threadId); + }; + }, [connect, props.identityKey]); + const register = useCallback((environmentId: EnvironmentId, api: PrismApi | null) => { + if (api) { + apis.current.set(environmentId, api); + return; + } + const previous = apis.current.get(environmentId); + apis.current.delete(environmentId); + for (const key of pending.current) { + if (key.startsWith(`${environmentId}/`)) + generations.current.set(key, (generations.current.get(key) ?? 0) + 1); + } + let changed = false; + for (const [key, binding] of bindings.current) { + if (binding.environmentId !== environmentId) continue; + generations.current.set(key, (generations.current.get(key) ?? 0) + 1); + bindings.current.delete(key); + changed = true; + void previous?.disconnectIdentityThread(binding.threadId); + } + if (changed) setSnapshot(new Map(bindings.current)); + }, []); + const value = useMemo( + () => ({ + config: props.config, + bindings: snapshot, + active, + error, + connect, + disconnect, + invalidate, + }), + [props.config, snapshot, active, error, connect, disconnect, invalidate], + ); + return ( + + + {props.targets.map((environmentId) => ( + + ))} + {props.children} + + + ); +} + +function BridgeEnvironment(props: { + readonly environmentId: EnvironmentId; + readonly register: (environmentId: EnvironmentId, api: PrismApi | null) => void; +}) { + const { environmentId, register } = props; + const api = usePrismApi(environmentId); + useLayoutEffect(() => { + register(environmentId, api); + }, [api, environmentId, register]); + useLayoutEffect(() => () => register(environmentId, null), [environmentId, register]); + return null; +} diff --git a/apps/mobile/src/fork/prism/PrismIdentitySection.tsx b/apps/mobile/src/fork/prism/PrismIdentitySection.tsx new file mode 100644 index 000000000000..7a1580910279 --- /dev/null +++ b/apps/mobile/src/fork/prism/PrismIdentitySection.tsx @@ -0,0 +1,453 @@ +import { ClerkProvider, useAuth } from "@clerk/expo"; +import { AuthView } from "@clerk/expo/native"; +import type { MicIdentityAccess, MicIdentityPublicConfig } from "@q1code/core/micIdentityApi"; +import type { PrismRoutingStrategy } from "@q1code/core/config"; +import { + getMicIdentityAccess, + getMicPrismStatus, + getMicPrismRouting, + setMicPrismRouting, +} from "@t3tools/client-runtime/fork"; +import { useIsFocused } from "@react-navigation/native"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as SecureStore from "expo-secure-store"; +import { + type ReactNode, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { AppState, Modal, Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { resolveCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { SettingsSection } from "../../features/settings/components/SettingsSection"; +import { runtime } from "../../lib/runtime"; +import { freshMicMobileToken, resolveMicMobileIdentityMode } from "./micIdentity.logic"; +import { + MicPrismRootPresentContext, + MicPrismThreadBridgeContext, +} from "./PersistentMicPrismIdentity"; +import { MicPrismThreadSection } from "./MicPrismThreadSection"; +import { MicPrismInferenceSection } from "./MicPrismInferenceSection"; +import { MicPrismTokenContext } from "./micIdentityContext"; +import { describePrismError, PRISM_ROUTING_OPTIONS } from "./prismSettings.logic"; +import { usePrismApi } from "./usePrismApi"; + +const micTokenCache = { + getToken: (key: string) => SecureStore.getItemAsync(`q1code.mic-sc.${key}`), + saveToken: (key: string, value: string) => + SecureStore.setItemAsync(`q1code.mic-sc.${key}`, value), + clearToken: (key: string) => SecureStore.deleteItemAsync(`q1code.mic-sc.${key}`), +}; + +export function PrismIdentityBoundary(props: { + readonly environmentId: EnvironmentId; + readonly enabled: boolean; + readonly allowLocalProvider: boolean; + readonly children: ReactNode; +}) { + return props.enabled ? : props.children; +} + +function ConfiguredBoundary(props: Parameters[0]) { + const persistent = useContext(MicPrismThreadBridgeContext); + const rootPresent = useContext(MicPrismRootPresentContext); + const api = usePrismApi(props.environmentId); + const [config, setConfig] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { + if (!api) return; + let cancelled = false; + void api.identityConfig().then((result) => { + if (cancelled) return; + if (result._tag === "ok") { + setConfig(result.value); + setError(null); + } else setError(describePrismError(result.error)); + }); + return () => { + cancelled = true; + }; + }, [api]); + + if (config === null) { + return ; + } + const cloud = resolveCloudPublicConfig(); + const mode = persistent + ? "shared" + : resolveMicMobileIdentityMode( + config, + cloud.relay.url ? cloud.clerk.publishableKey : null, + props.allowLocalProvider, + ); + if (mode === "off") return props.children; + if (mode === "unconfigured") + return ; + if (mode === "incompatible") + return ( + + ); + if ( + persistent && + (persistent.config.authorityUrl !== config.authorityUrl || + persistent.config.clerkPublishableKey !== config.clerkPublishableKey) + ) + return ( + + ); + if (rootPresent && !persistent) return ; + const content = ( + + ); + return persistent || mode === "shared" ? ( + content + ) : ( + + {content} + + ); +} + +function SignedIdentity(props: { + readonly config: MicIdentityPublicConfig; + readonly environmentId: EnvironmentId; +}) { + const persistent = useContext(MicPrismThreadBridgeContext); + const { isLoaded, isSignedIn, userId, sessionId, getToken, signOut } = useAuth(); + const [authOpen, setAuthOpen] = useState(false); + const [locallySignedOutSession, setLocallySignedOutSession] = useState(null); + const locallySignedOut = + (sessionId != null && locallySignedOutSession === sessionId) || persistent?.active === false; + const [signOutError, setSignOutError] = useState(null); + const identityKey = + !locallySignedOut && isSignedIn && userId && sessionId + ? `${userId}:${sessionId}` + : "signed-out"; + const currentIdentity = useRef(identityKey); + useLayoutEffect(() => { + currentIdentity.current = identityKey; + return () => { + currentIdentity.current = "unmounted"; + }; + }, [identityKey]); + const isCurrent = useCallback( + () => identityKey !== "signed-out" && currentIdentity.current === identityKey, + [identityKey], + ); + const source = useCallback( + () => freshMicMobileToken(getToken, isCurrent)(), + [getToken, isCurrent], + ); + return ( + + + + + mic.sc gives you access to the shared Prism service. Environment connections keep their + own permissions. + + {isSignedIn ? ( + { + persistent?.invalidate(); + currentIdentity.current = "signed-out"; + setLocallySignedOutSession(sessionId ?? null); + setSignOutError(null); + void signOut().catch(() => + setSignOutError( + "Could not finish signing out. Retry when the account service is available.", + ), + ); + }} + /> + ) : ( + setAuthOpen(true)} + /> + )} + {signOutError ? ( + {signOutError} + ) : null} + + + setAuthOpen(false)} + > + + setAuthOpen(false)} + onHostBack={() => setAuthOpen(false)} + /> + + + {isSignedIn && !locallySignedOut ? ( + + ) : null} + {isSignedIn && !locallySignedOut ? ( + + ) : null} + + ); +} + +function MicService(props: { + readonly config: MicIdentityPublicConfig; + readonly source: ReturnType; + readonly isCurrent: () => boolean; +}) { + const focused = useIsFocused(); + const [access, setAccess] = useState(null); + const [gateway, setGateway] = useState + > | null>(null); + const [strategy, setStrategy] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const refreshGeneration = useRef(0); + const changingRouting = useRef(false); + const refreshInFlight = useRef(false); + const [refreshing, setRefreshing] = useState(false); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const input = useMemo( + () => ({ + baseUrl: props.config.authorityUrl!, + getToken: props.source, + isCurrent: props.isCurrent, + }), + [props.config.authorityUrl, props.source, props.isCurrent], + ); + const serviceId = access?.discovery.service?.id; + const servicePairingRevision = access?.discovery.service?.pairingRevision; + const serviceApiUrl = access?.discovery.service?.apiUrl; + const serviceInferenceUrl = access?.discovery.service?.inferenceUrl; + const boundInput = useMemo( + () => ({ + ...input, + ...(serviceId && servicePairingRevision !== undefined + ? { + expectedService: { + id: serviceId, + pairingRevision: servicePairingRevision, + apiUrl: serviceApiUrl, + inferenceUrl: serviceInferenceUrl, + }, + } + : {}), + }), + [input, serviceId, servicePairingRevision, serviceApiUrl, serviceInferenceUrl], + ); + const refresh = useCallback(async () => { + if (changingRouting.current || refreshInFlight.current) return; + refreshInFlight.current = true; + const generation = ++refreshGeneration.current; + const current = () => + mounted.current && generation === refreshGeneration.current && input.isCurrent(); + setRefreshing(true); + const fail = (failure: { readonly _tag: string }) => { + if ( + failure._tag === "MicIdentityUnauthorizedError" || + failure._tag === "MicIdentityForbiddenError" + ) { + setAccess(null); + setGateway(null); + setStrategy(null); + } + }; + try { + const result = await runtime.runPromise(getMicIdentityAccess(input).pipe(Effect.result)); + if (!current()) return; + if (result._tag === "Failure") { + fail(result.failure); + setRefreshing(false); + setError(describePrismError(result.failure)); + return; + } + setAccess(result.success); + const status = await runtime.runPromise(getMicPrismStatus(input).pipe(Effect.result)); + if (!current()) return; + if (status._tag === "Failure") { + fail(status.failure); + setRefreshing(false); + setError(describePrismError(status.failure)); + return; + } + setGateway(status.success); + setError(null); + if (result.success.session.permissions.includes("prism:routing:read")) { + const routing = await runtime.runPromise(getMicPrismRouting(input).pipe(Effect.result)); + if (!current()) return; + if (routing._tag === "Success") setStrategy(routing.success.strategy); + else { + fail(routing.failure); + setError(describePrismError(routing.failure)); + } + } else setStrategy(null); + } finally { + refreshInFlight.current = false; + if (current()) setRefreshing(false); + } + }, [input]); + useEffect(() => { + if (!focused) return; + void refresh(); + const timer = setInterval(() => { + if (AppState.currentState === "active") void refresh(); + }, 10_000); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") void refresh(); + }); + return () => { + clearInterval(timer); + subscription.remove(); + }; + }, [refresh, focused]); + const canRoute = + error === null && + !refreshing && + gateway !== null && + access !== null && + access.session.permissions.includes("prism:routing:write"); + useEffect(() => { + if (!access) return; + const expires = setTimeout( + () => setError("Prism access needs to be refreshed."), + Math.max(0, access.session.authorizationExpiresAt - Date.now()), + ); + return () => clearTimeout(expires); + }, [access]); + const changeRouting = async (next: PrismRoutingStrategy) => { + if (!canRoute || changingRouting.current) return; + changingRouting.current = true; + ++refreshGeneration.current; + setBusy(true); + const result = await runtime.runPromise( + setMicPrismRouting({ ...boundInput, strategy: next }).pipe(Effect.result), + ); + changingRouting.current = false; + if (!mounted.current || !input.isCurrent()) return; + setBusy(false); + if (result._tag === "Failure") { + if ( + result.failure._tag === "MicIdentityUnauthorizedError" || + result.failure._tag === "MicIdentityForbiddenError" + ) { + setAccess(null); + setGateway(null); + setStrategy(null); + } + setError(describePrismError(result.failure)); + return; + } + setStrategy(result.success.strategy); + await refresh(); + }; + return ( + <> + + + {access ? ( + <> + {access.session.subject} + + {access.discovery.service?.label ?? "No paired service"} + + + {gateway && !error + ? "Access verified. Engine health is not reported by this gateway." + : "Service health has not been verified."} + + + ) : ( + + {error ? "Prism access unavailable" : "Checking Prism access…"} + + )} + {error ? ( + + {access ? "Last known service. " : ""} + {error} + + ) : null} + void refresh()} + /> + {access?.session.permissions.includes("prism:routing:read") ? ( + + {PRISM_ROUTING_OPTIONS.map((option) => ( + void changeRouting(option.value)} + /> + ))} + + ) : null} + + + {access && gateway ? ( + + ) : null} + + ); +} + +function IdentityNotice(props: { readonly message: string }) { + return ( + + {props.message} + + ); +} + +function IdentityButton(props: { + readonly label: string; + readonly disabled?: boolean; + readonly onPress: () => void; +}) { + return ( + + + {props.label} + + + ); +} diff --git a/apps/mobile/src/fork/prism/PrismSettingsRow.tsx b/apps/mobile/src/fork/prism/PrismSettingsRow.tsx index 7ba2c3ac5250..982a2ec16317 100644 --- a/apps/mobile/src/fork/prism/PrismSettingsRow.tsx +++ b/apps/mobile/src/fork/prism/PrismSettingsRow.tsx @@ -4,6 +4,7 @@ * on. With the flag off everywhere this renders nothing, so Settings matches * upstream row for row. */ +import { readForkFlag } from "@t3tools/client-runtime/fork"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -38,24 +39,37 @@ export function PrismSettingsRow() { if (targets.length === 0) return null; - const value = summarizePrismOverviews( - targets.map((target) => overviews.get(target.environmentId) ?? { _tag: "loading" }), + const usesMicIdentity = targets.some((target) => + readForkFlag(configs.get(target.environmentId)?.environment.capabilities, "mic-identity"), ); + const value = usesMicIdentity + ? null + : summarizePrismOverviews( + targets.map((target) => overviews.get(target.environmentId) ?? { _tag: "loading" }), + ); return ( - {targets.map((target) => ( - - ))} + {targets + .filter( + (target) => + !readForkFlag( + configs.get(target.environmentId)?.environment.capabilities, + "mic-identity", + ), + ) + .map((target) => ( + + ))} ); } @@ -71,7 +85,14 @@ function OverviewLoader(props: { useEffect(() => { if (!api) return; let cancelled = false; - void Promise.all([api.status(), api.listAccounts()]).then(([status, accounts]) => { + void (async () => { + const status = await api.status(); + const accounts = + status._tag === "ok" && + status.value.state === "ready" && + status.value.capabilities?.accountDetails !== false + ? await api.listAccounts() + : null; if (cancelled) return; onOverview( environmentId, @@ -79,11 +100,11 @@ function OverviewLoader(props: { ? { _tag: "loaded", state: status.value.state, - accountCount: accounts._tag === "ok" ? accounts.value.length : null, + accountCount: accounts?._tag === "ok" ? accounts.value.length : null, } : { _tag: "error" }, ); - }); + })(); return () => { cancelled = true; }; diff --git a/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx b/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx index 2c29e427752d..ada998c936c6 100644 --- a/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx +++ b/apps/mobile/src/fork/prism/PrismSettingsScreen.tsx @@ -6,6 +6,15 @@ * everywhere; it then explains how to turn Prism on. */ import type { PrismAccount, PrismLoginProvider, PrismStatus } from "@q1code/core/prismApi"; +import { useAtomValue } from "@effect/atom-react"; +import { + INITIAL_PRISM_HEALTH, + readForkFlag, + reducePrismHealth, + resolvePrismAccess, +} from "@t3tools/client-runtime/fork"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; import type { PrismRoutingStrategy } from "@q1code/core/config"; import { useIsFocused, useNavigation } from "@react-navigation/native"; import { createNativeStackScreen } from "@react-navigation/native-stack"; @@ -14,6 +23,7 @@ import { type ComponentProps, useCallback, useEffect, + useLayoutEffect, useMemo, useReducer, useRef, @@ -44,6 +54,7 @@ import { relativeTime } from "../../lib/time"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useServerConfigs } from "../../state/entities"; import { useEnvironments } from "../../state/environments"; +import { environmentSession } from "../../state/session"; import { PRISM_LOGIN_POLL_MS, PRISM_LOGIN_PROVIDERS, @@ -70,6 +81,7 @@ import { shouldPollPrismStatus, } from "./prismSettings.logic"; import { type PrismApi, usePrismApi } from "./usePrismApi"; +import { PrismIdentityBoundary } from "./PrismIdentitySection"; type Reloader = () => Promise; @@ -139,12 +151,22 @@ export function PrismSettingsScreen() { ) : ( targets.map((target) => ( - 1 ? target.label : null} - registerReloader={registerReloader} - /> + enabled={readForkFlag( + configs.get(target.environmentId)?.environment.capabilities, + "mic-identity", + )} + allowLocalProvider={targets.length === 1} + > + 1 ? target.label : null} + registerReloader={registerReloader} + /> + )) )} @@ -176,6 +198,13 @@ function EnvironmentPanel(props: { }) { const { environmentId, registerReloader } = props; const api = usePrismApi(environmentId); + const { environments } = useEnvironments(); + const connected = + api !== null && + environments.some( + (environment) => + environment.environmentId === environmentId && environment.connection.phase === "connected", + ); const mounted = useRef(true); useEffect( () => () => { @@ -184,8 +213,20 @@ function EnvironmentPanel(props: { [], ); - const [status, setStatus] = useState(null); - const [statusError, setStatusError] = useState(null); + const [health, dispatchHealth] = useReducer(reducePrismHealth, INITIAL_PRISM_HEALTH); + const { status, error: statusError } = health; + const session = useAtomValue(environmentSession.sessionStateAtom(environmentId)); + const sessionData = Option.getOrNull(AsyncResult.value(session)); + const access = resolvePrismAccess({ + health, + connected, + session: sessionData, + sessionError: session._tag === "Failure", + }); + const accessRef = useRef(access); + useLayoutEffect(() => { + accessRef.current = access; + }, [access]); const [restart, setRestart] = useState({ running: false, note: null }); const [accounts, dispatchAccounts] = useReducer(reducePrismAccounts, INITIAL_ACCOUNTS_STATE); const [routing, setRouting] = useState(null); @@ -202,13 +243,14 @@ function EnvironmentPanel(props: { const result = await api.status(); if (!mounted.current) return; if (result._tag === "ok") { - setStatus(result.value); + dispatchHealth({ type: "received", status: result.value, receivedAt: Date.now() }); dispatchUsageSource({ type: "status", status: result.value }); - setStatusError(null); + return result.value; } else { - setStatusError(describePrismError(result.error)); + dispatchHealth({ type: "failed", error: describePrismError(result.error) }); + return null; } - }, [api]); + }, [api, dispatchHealth]); const loadAccounts = useCallback(async () => { if (!api) return; @@ -234,7 +276,10 @@ function EnvironmentPanel(props: { }, [api]); const loadAll = useCallback(async () => { - await Promise.all([loadStatus(), loadAccounts(), loadRouting()]); + const latest = await loadStatus(); + if (latest?.state === "ready" && latest.capabilities?.accountDetails !== false) { + await Promise.all([loadAccounts(), loadRouting()]); + } }, [loadAccounts, loadRouting, loadStatus]); useEffect(() => { @@ -249,6 +294,7 @@ function EnvironmentPanel(props: { useStatusPolling(api !== null && !restart.running, loadStatus); const confirmRestart = () => { + if (!accessRef.current.configure) return; Alert.alert( "Restart the proxy?", "Provider CLIs lose their connection until it is ready again.", @@ -260,7 +306,7 @@ function EnvironmentPanel(props: { }; const runRestart = async () => { - if (!api) return; + if (!api || !accessRef.current.configure) return; setRestart({ running: true, note: null }); const result = await api.restart(); if (!mounted.current) return; @@ -268,7 +314,7 @@ function EnvironmentPanel(props: { setRestart({ running: false, note: describePrismError(result.error) }); return; } - setStatus(result.value); + dispatchHealth({ type: "received", status: result.value, receivedAt: Date.now() }); const startedAt = Date.now(); let step = nextRestartStep({ state: result.value.state, elapsedMs: 0 }); while (step === "poll") { @@ -276,7 +322,13 @@ function EnvironmentPanel(props: { if (!mounted.current) return; const polled = await api.status(); if (!mounted.current) return; - if (polled._tag === "ok") setStatus(polled.value); + if (polled._tag === "ok") { + dispatchHealth({ type: "received", status: polled.value, receivedAt: Date.now() }); + } else { + dispatchHealth({ type: "failed", error: describePrismError(polled.error) }); + setRestart({ running: false, note: describePrismError(polled.error) }); + return; + } step = nextRestartStep({ state: polled._tag === "ok" ? polled.value.state : "starting", elapsedMs: Date.now() - startedAt, @@ -290,12 +342,12 @@ function EnvironmentPanel(props: { }; const toggleUsageSource = (enabled: boolean) => { - if (!api) return; + if (!api || !accessRef.current.configure || usageSource.rollback !== null) return; dispatchUsageSource({ type: "toggle", enabled }); void api.setUsageSource(enabled).then((result) => { if (!mounted.current) return; if (result._tag === "ok") { - setStatus(result.value); + dispatchHealth({ type: "received", status: result.value, receivedAt: Date.now() }); dispatchUsageSource({ type: "saved", status: result.value }); } else { dispatchUsageSource({ type: "saveFailed", error: describePrismError(result.error) }); @@ -304,7 +356,7 @@ function EnvironmentPanel(props: { }; const toggleAccount = (account: PrismAccount, enabled: boolean) => { - if (!api) return; + if (!api || !accessRef.current.accounts || accounts.pending[account.id]) return; dispatchAccounts({ type: "toggle", id: account.id, disabled: !enabled }); void api.patchAccount(account.id, { disabled: !enabled }).then((result) => { if (!mounted.current) return; @@ -317,6 +369,7 @@ function EnvironmentPanel(props: { }; const confirmRemove = (account: PrismAccount) => { + if (!accessRef.current.accounts) return; Alert.alert( `Remove ${account.email ?? account.label}?`, "The auth file is deleted from the proxy and the removal syncs to the other environments.", @@ -326,7 +379,7 @@ function EnvironmentPanel(props: { text: "Remove", style: "destructive", onPress: () => { - if (!api) return; + if (!api || !accessRef.current.accounts) return; dispatchAccounts({ type: "remove", id: account.id }); void api.deleteAccount(account.id).then((result) => { if (!mounted.current) return; @@ -347,7 +400,7 @@ function EnvironmentPanel(props: { }; const selectRouting = (strategy: PrismRoutingStrategy) => { - if (!api || routingBusy || strategy === routing) return; + if (!api || !accessRef.current.routing || routingBusy || strategy === routing) return; const previous = routing; setRouting(strategy); setRoutingError(null); @@ -364,44 +417,50 @@ function EnvironmentPanel(props: { }); }; - if (!api) { - return ( - - - Connecting… - - - ); - } - return ( - void loadAccounts()} - /> - void loadAccounts()} - /> - + {access.accountDetails ? ( + void loadAccounts()} + /> + ) : ( + + + Pooled account details require administrative access. + + + )} + {access.accountDetails && api !== null ? ( + <> + void loadAccounts()} + /> + + + ) : null} ); } @@ -420,7 +479,7 @@ function PanelFrame(props: { readonly label: string | null; readonly children: R } /** Status polls only while this screen is on top and the app is in the foreground. */ -function useStatusPolling(enabled: boolean, poll: () => Promise) { +function useStatusPolling(enabled: boolean, poll: () => Promise) { const focused = useIsFocused(); const [appState, setAppState] = useState(AppState.currentState ?? "active"); useEffect(() => { @@ -439,6 +498,7 @@ function StatusSection(props: { readonly error: string | null; readonly restart: RestartState; readonly usageSource: PrismUsageSourceState; + readonly writable: boolean; readonly onRestart: () => void; readonly onUsageSourceChange: (enabled: boolean) => void; }) { @@ -454,13 +514,19 @@ function StatusSection(props: { ) : ( - + {props.error ? ( + + ) : ( + + )} - + {status.capabilities?.manage !== false ? ( + + ) : null} {describePrismStatus(status, relativeTime).map((line) => ( @@ -470,9 +536,17 @@ function StatusSection(props: { ))} - {props.error ? {props.error} : null} + {props.error ? ( + {`Last known state: ${status.state}. ${props.error}`} + ) : null} {props.restart.note ? {props.restart.note} : null} - + {status.capabilities?.manage !== false ? ( + + ) : null} )} @@ -481,6 +555,7 @@ function StatusSection(props: { /** Whether Prism publishes its pooled accounts to Usage → Limits; flips at once and rolls back on failure. */ function UsageSourceRow(props: { + readonly writable: boolean; readonly state: PrismUsageSourceState; readonly onChange: (enabled: boolean) => void; }) { @@ -492,7 +567,7 @@ function UsageSourceRow(props: { {PRISM_USAGE_SOURCE_LABEL} @@ -503,6 +578,7 @@ function UsageSourceRow(props: { } function AccountsSection(props: { + readonly writable: boolean; readonly state: ReturnType; readonly onToggle: (account: PrismAccount, enabled: boolean) => void; readonly onRemove: (account: PrismAccount) => void; @@ -536,14 +612,14 @@ function AccountsSection(props: { key={account.id} account={account} first={index === 0 && !state.error} - pending={account.id in state.pending} + pending={!props.writable || account.id in state.pending} error={state.rowErrors[account.id] ?? null} onToggle={(enabled) => props.onToggle(account, enabled)} onRemove={() => props.onRemove(account)} /> )) )} - {state.accounts !== null && state.accounts.length > 0 ? ( + {props.writable && state.accounts !== null && state.accounts.length > 0 ? ( Long-press an account to remove it. @@ -592,6 +668,7 @@ function AccountRow(props: { } function AddAccountSection(props: { + readonly writable: boolean; readonly api: PrismApi; readonly login: ReturnType; readonly dispatch: (event: Parameters[1]) => void; @@ -602,7 +679,7 @@ function AddAccountSection(props: { const pendingSession = pendingPrismLoginSession(login); useEffect(() => { - if (!pendingSession) return; + if (!pendingSession || !props.writable) return; let cancelled = false; const tick = async () => { const result = await api.loginStatus(pendingSession); @@ -615,9 +692,10 @@ function AddAccountSection(props: { cancelled = true; clearInterval(interval); }; - }, [api, dispatch, onCompleted, pendingSession]); + }, [api, dispatch, onCompleted, pendingSession, props.writable]); const start = (provider: PrismLoginProvider) => { + if (!props.writable) return; dispatch({ type: "start", provider }); setRedirectDraft(""); void api.startLogin(provider).then((result) => { @@ -631,7 +709,7 @@ function AddAccountSection(props: { }; const submitRedirect = () => { - if (login._tag !== "pending") return; + if (!props.writable || login._tag !== "pending" || login.submittingRedirect) return; const redirectUrl = redirectDraft.trim(); if (redirectUrl.length === 0) return; const { sessionId } = login; @@ -647,6 +725,7 @@ function AddAccountSection(props: { }; const cancel = () => { + if (!props.writable) return; if (login._tag === "pending") void api.cancelLogin(login.sessionId); dispatch({ type: "cancel" }); }; @@ -662,6 +741,7 @@ function AddAccountSection(props: { start(provider.value)} /> ))} @@ -693,7 +773,7 @@ function AddAccountSection(props: { label="Open browser" onPress={() => void Linking.openURL(login.authUrl).catch(() => undefined)} /> - + If the browser cannot reach the server, paste the URL it redirected to: @@ -710,11 +790,13 @@ function AddAccountSection(props: { onChangeText={setRedirectDraft} onSubmitEditing={submitRedirect} returnKeyType="send" - editable={!login.submittingRedirect} + editable={props.writable && !login.submittingRedirect} /> @@ -741,6 +823,7 @@ function AddAccountSection(props: { } function RoutingSection(props: { + readonly writable: boolean; readonly strategy: PrismRoutingStrategy | null; readonly error: string | null; readonly busy: boolean; @@ -755,7 +838,7 @@ function RoutingSection(props: { key={option.value} label={option.label} selected={option.value === props.strategy} - disabled={props.busy || props.strategy === null} + disabled={!props.writable || props.busy || props.strategy === null} onPress={() => props.onSelect(option.value)} /> ))} @@ -836,3 +919,5 @@ function SkeletonRows(props: { readonly count: number }) { ); } + +export { withMicPrismIdentity } from "./PersistentMicPrismIdentity"; diff --git a/apps/mobile/src/fork/prism/micIdentity.logic.test.ts b/apps/mobile/src/fork/prism/micIdentity.logic.test.ts new file mode 100644 index 000000000000..b75a158060c8 --- /dev/null +++ b/apps/mobile/src/fork/prism/micIdentity.logic.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { freshMicMobileToken, resolveMicMobileIdentityMode } from "./micIdentity.logic"; + +const config = { + enabled: true, + clerkPublishableKey: "mic-key", + authorityUrl: "https://identity.example.test", +}; + +describe("mobile mic.sc identity boundary", () => { + it("leaves an off feature inert, even with a different existing identity", () => { + expect(resolveMicMobileIdentityMode({ enabled: false }, "t3-key", true)).toBe("off"); + }); + + it("reuses only the exact mic.sc account service configured by the authenticated environment", () => { + expect(resolveMicMobileIdentityMode(config, "mic-key", false)).toBe("shared"); + expect(resolveMicMobileIdentityMode(config, "t3-key", true)).toBe("incompatible"); + }); + + it("permits a local provider only when it cannot replace another Clerk singleton", () => { + expect(resolveMicMobileIdentityMode(config, null, true)).toBe("local"); + expect(resolveMicMobileIdentityMode(config, null, false)).toBe("incompatible"); + expect(resolveMicMobileIdentityMode({ enabled: true }, null, true)).toBe("unconfigured"); + }); + + it.effect("mints a fresh default session JWT for each operation", () => + Effect.gen(function* () { + const options: Array<{ skipCache: boolean }> = []; + const source = freshMicMobileToken( + async (input) => { + options.push(input); + return `token-${options.length}`; + }, + () => true, + ); + expect(yield* source()).toBe("token-1"); + expect(yield* source()).toBe("token-2"); + expect(options).toEqual([{ skipCache: true }, { skipCache: true }]); + }), + ); + + it.effect("does not ask Clerk for a previous account's token after sign-out", () => + Effect.gen(function* () { + let reads = 0; + const source = freshMicMobileToken( + async () => { + reads += 1; + return "token"; + }, + () => false, + ); + expect(yield* source()).toBeNull(); + expect(reads).toBe(0); + }), + ); + + it.effect("discards a token minted while an account switch was in progress", () => + Effect.gen(function* () { + let current = true; + const source = freshMicMobileToken( + async () => { + current = false; + return "previous-account-token"; + }, + () => current, + ); + expect(yield* source()).toBeNull(); + }), + ); + + it.effect("reports sign-in failure without carrying SDK errors or credentials", () => + Effect.gen(function* () { + const source = freshMicMobileToken( + async () => { + throw new Error("private-sdk-detail"); + }, + () => true, + ); + const result = yield* source().pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") + expect(result.failure.message).toBe("Sign in to mic.sc to continue."); + }), + ); +}); diff --git a/apps/mobile/src/fork/prism/micIdentity.logic.ts b/apps/mobile/src/fork/prism/micIdentity.logic.ts new file mode 100644 index 000000000000..e376342fdb83 --- /dev/null +++ b/apps/mobile/src/fork/prism/micIdentity.logic.ts @@ -0,0 +1,33 @@ +import { MicIdentityUnauthorizedError } from "@q1code/core/micIdentity"; +import type { MicIdentityPublicConfig } from "@q1code/core/micIdentityApi"; +import type { MicIdentityTokenSource } from "@t3tools/client-runtime/fork"; +import * as Effect from "effect/Effect"; + +/** Clerk's native singleton cannot serve two issuers at once. */ +export function resolveMicMobileIdentityMode( + config: MicIdentityPublicConfig, + existingProviderKey: string | null, + allowLocalProvider: boolean, +): "off" | "unconfigured" | "shared" | "local" | "incompatible" { + if (!config.enabled) return "off"; + if (!config.clerkPublishableKey || !config.authorityUrl) return "unconfigured"; + if (existingProviderKey === config.clerkPublishableKey) return "shared"; + if (existingProviderKey !== null || !allowLocalProvider) return "incompatible"; + return "local"; +} + +/** The authority requires the default session JWT (including sid), never a Convex template. */ +export function freshMicMobileToken( + read: (options: { skipCache: boolean }) => Promise, + isCurrent: () => boolean, +): MicIdentityTokenSource { + return () => + Effect.tryPromise({ + try: async () => { + if (!isCurrent()) return null; + const token = await read({ skipCache: true }); + return isCurrent() ? token : null; + }, + catch: () => new MicIdentityUnauthorizedError({ reason: "sign-in-required" }), + }); +} diff --git a/apps/mobile/src/fork/prism/micIdentityContext.ts b/apps/mobile/src/fork/prism/micIdentityContext.ts new file mode 100644 index 000000000000..a21fdf3c44a6 --- /dev/null +++ b/apps/mobile/src/fork/prism/micIdentityContext.ts @@ -0,0 +1,4 @@ +import type { MicIdentityTokenSource } from "@t3tools/client-runtime/fork"; +import { createContext } from "react"; + +export const MicPrismTokenContext = createContext(undefined); diff --git a/apps/mobile/src/fork/prism/prismSettings.logic.test.ts b/apps/mobile/src/fork/prism/prismSettings.logic.test.ts index 1034b94ff52d..2d8897b210f6 100644 --- a/apps/mobile/src/fork/prism/prismSettings.logic.test.ts +++ b/apps/mobile/src/fork/prism/prismSettings.logic.test.ts @@ -31,7 +31,7 @@ const environment = (id: string, phase: string) => ({ }); describe("selectPrismEnvironments", () => { - it("keeps only connected environments whose flag is on", () => { + it("keeps known Prism environments visible through reconnection", () => { const flags: Record = { a: true, b: false, c: true, d: true }; const selected = selectPrismEnvironments( [ @@ -43,10 +43,18 @@ describe("selectPrismEnvironments", () => { ], (id) => (flags[id] === undefined ? null : { forkFlags: { prism: flags[id] } }), ); - expect(selected.map((entry) => entry.environmentId)).toEqual(["a", "d"]); + expect(selected.map((entry) => entry.environmentId)).toEqual(["a", "c", "d"]); expect(selected[0]?.label).toBe("env a"); }); + it("exposes mic.sc Prism when the local sidecar flag is off", () => { + expect( + selectPrismEnvironments([environment("a", "connected")], () => ({ + forkFlags: { prism: false, "mic-identity": true }, + })), + ).toHaveLength(1); + }); + it("reads the registry default (off) against upstream servers with no forkFlags", () => { expect(selectPrismEnvironments([environment("a", "connected")], () => ({}))).toEqual([]); }); diff --git a/apps/mobile/src/fork/prism/prismSettings.logic.ts b/apps/mobile/src/fork/prism/prismSettings.logic.ts index 2ac044d928d4..8a23dcd7a85b 100644 --- a/apps/mobile/src/fork/prism/prismSettings.logic.ts +++ b/apps/mobile/src/fork/prism/prismSettings.logic.ts @@ -28,7 +28,7 @@ export interface PrismEnvironmentRef { readonly label: string; } -/** Connected environments whose `prism` flag is on, in catalog order. */ +/** Keep known Prism environments visible when disconnected, so their last state stays inspectable. */ export function selectPrismEnvironments( environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; @@ -40,11 +40,10 @@ export function selectPrismEnvironments( ) => Pick | null | undefined, ): ReadonlyArray { return environments - .filter( - (environment) => - environment.connection.phase === "connected" && - readForkFlag(capabilitiesOf(environment.environmentId), "prism"), - ) + .filter((environment) => { + const capabilities = capabilitiesOf(environment.environmentId); + return readForkFlag(capabilities, "prism") || readForkFlag(capabilities, "mic-identity"); + }) .map((environment) => ({ environmentId: environment.environmentId, label: environment.label, diff --git a/apps/mobile/src/fork/prism/usePrismApi.ts b/apps/mobile/src/fork/prism/usePrismApi.ts index f1f705a56e27..1a3b8ba86b81 100644 --- a/apps/mobile/src/fork/prism/usePrismApi.ts +++ b/apps/mobile/src/fork/prism/usePrismApi.ts @@ -12,10 +12,13 @@ import { type PrismClientInput, type PrismLoginProvider, completePrismLogin, + connectMicPrismThread, + disconnectMicPrismThread, deletePrismAccount, getPrismLoginStatus, getPrismRouting, getPrismStatus, + getPrismIdentityConfig, listPrismAccounts, patchPrismAccount, restartPrism, @@ -28,11 +31,12 @@ import type { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import type { HttpClient } from "effect/unstable/http"; -import { useMemo } from "react"; +import { useContext, useMemo } from "react"; import { runtime } from "../../lib/runtime"; import { usePreparedConnection } from "../../state/session"; import type { PrismCallError } from "./prismSettings.logic"; +import { MicPrismTokenContext } from "./micIdentityContext"; export type PrismResult = | { readonly _tag: "ok"; readonly value: A } @@ -42,13 +46,16 @@ type Call = ( input: PrismClientInput, ) => Effect.Effect; -export function bindPrismCalls(prepared: PrismClientInput["prepared"]) { +export function bindPrismCalls( + prepared: PrismClientInput["prepared"], + micScToken?: PrismClientInput["micScToken"], +) { const run = (call: Call): Promise> => runtime .runPromise( Effect.gen(function* () { const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); - return yield* call({ prepared, signer }); + return yield* call({ prepared, signer, ...(micScToken ? { micScToken } : {}) }); }).pipe( Effect.match({ onFailure: (error): PrismResult => ({ _tag: "error", error }), @@ -59,6 +66,11 @@ export function bindPrismCalls(prepared: PrismClientInput["prepared"]) { .catch((): PrismResult => ({ _tag: "error", error: { _tag: "UnknownError" } })); return { + identityConfig: () => run(getPrismIdentityConfig), + connectIdentityThread: (threadId: string) => + run((input) => connectMicPrismThread({ ...input, threadId })), + disconnectIdentityThread: (threadId: string) => + run((input) => disconnectMicPrismThread({ ...input, threadId })), status: () => run(getPrismStatus), restart: () => run(restartPrism), listAccounts: () => run(listPrismAccounts), @@ -85,5 +97,9 @@ export type PrismApi = ReturnType; /** `null` until the environment has a prepared connection. */ export function usePrismApi(environmentId: EnvironmentId): PrismApi | null { const prepared = Option.getOrNull(usePreparedConnection(environmentId)); - return useMemo(() => (prepared ? bindPrismCalls(prepared) : null), [prepared]); + const micScToken = useContext(MicPrismTokenContext); + return useMemo( + () => (prepared ? bindPrismCalls(prepared, micScToken) : null), + [prepared, micScToken], + ); } diff --git a/apps/server/src/fork/mic-identity/MicIdentityAccess.ts b/apps/server/src/fork/mic-identity/MicIdentityAccess.ts new file mode 100644 index 000000000000..e30412e65bd8 --- /dev/null +++ b/apps/server/src/fork/mic-identity/MicIdentityAccess.ts @@ -0,0 +1,151 @@ +import { + MIC_IDENTITY_API_PATHS, + MIC_IDENTITY_SESSION_HEADER, + MicIdentityForbiddenError, + MicIdentityServiceUrl, + MicIdentityWire, + MicIdentityUnauthorizedError, + MicIdentityUnavailableError, + MicPrismDiscoveryWire, + normalizeMicIdentity, + normalizeMicPrismDiscovery, + type MicPrismCapability, + type MicPrismPermission, + hasMicPrismPermission, +} from "@q1code/core/micIdentity"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as Headers from "effect/unstable/http/Headers"; + +import { ForkFlagsService } from "../ForkFlags.ts"; + +const isServiceUrl = Schema.is(MicIdentityServiceUrl); + +/** Injection point for isolated authority contract tests; no session or grant cache. */ +export const MicIdentityFetch = Context.Reference("q1code/MicIdentityFetch", { + defaultValue: () => globalThis.fetch, +}); + +export const micIdentityPublicConfig = Effect.gen(function* () { + const flags = yield* ForkFlagsService; + if (!(yield* flags.current)["mic-identity"]) return { enabled: false }; + const config = (yield* flags.config)["mic-identity"]; + if (!config || !isServiceUrl(config.authorityUrl)) { + return yield* new MicIdentityUnavailableError({ reason: "configuration" }); + } + return { + enabled: true, + clerkPublishableKey: config.clerkPublishableKey, + authorityUrl: config.authorityUrl, + }; +}); + +/** A current authority response supplements, and never replaces, environment authentication. */ +export const requireMicIdentity = ( + permission: MicPrismCapability | MicPrismPermission = "inference", +) => + Effect.gen(function* () { + const capability: MicPrismCapability = + permission === "inference" || permission === "prism:inference" + ? "inference" + : permission === "accountDetails" || permission.endsWith(":read") + ? "accountDetails" + : "manage"; + const flags = yield* ForkFlagsService; + if (!(yield* flags.current)["mic-identity"]) return undefined; + const config = (yield* flags.config)["mic-identity"]; + if (!config || !isServiceUrl(config.authorityUrl)) { + return yield* new MicIdentityUnavailableError({ reason: "configuration" }); + } + const request = yield* HttpServerRequest.HttpServerRequest; + const token = request.headers[MIC_IDENTITY_SESSION_HEADER]; + if (!token || /[\r\n]/.test(token)) { + return yield* new MicIdentityUnauthorizedError({ reason: "sign-in-required" }); + } + const fetchAuthority = yield* MicIdentityFetch; + const read = >( + path: string, + schema: S, + ) => + Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetchAuthority(`${config.authorityUrl.replace(/\/$/, "")}${path}`, { + headers: { authorization: `Bearer ${token}` }, + redirect: "error", + cache: "no-store", + signal, + }), + catch: () => new MicIdentityUnavailableError({ reason: "transport" }), + }); + if (response.status === 401) { + return yield* new MicIdentityUnauthorizedError({ reason: "invalid-session" }); + } + if (response.status === 403) + return yield* new MicIdentityForbiddenError({ capability: permission }); + if (!response.ok) return yield* new MicIdentityUnavailableError({ reason: "transport" }); + const body = yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: () => new MicIdentityUnavailableError({ reason: "invalid-response" }), + }); + return yield* Schema.decodeUnknownEffect(schema)(body).pipe( + Effect.mapError(() => new MicIdentityUnavailableError({ reason: "invalid-response" })), + ); + }).pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.fail(new MicIdentityUnavailableError({ reason: "transport" })), + }), + ); + const session = normalizeMicIdentity( + yield* read(MIC_IDENTITY_API_PATHS.session, MicIdentityWire), + ); + if (session.state !== "active") { + return yield* new MicIdentityUnauthorizedError({ reason: "revoked-session" }); + } + if (Date.parse(session.expiresAt) <= DateTime.toEpochMillis(yield* DateTime.now)) { + return yield* new MicIdentityUnauthorizedError({ reason: "expired-session" }); + } + if ( + !(permission.startsWith("prism:") + ? hasMicPrismPermission(session, permission as MicPrismPermission) + : session.capabilities[capability]) + ) { + return yield* new MicIdentityForbiddenError({ capability: permission }); + } + const discovery = normalizeMicPrismDiscovery( + yield* read(MIC_IDENTITY_API_PATHS.prismService, MicPrismDiscoveryWire), + ); + if (!discovery.service) { + return yield* new MicIdentityUnavailableError({ reason: "unpaired-service" }); + } + if (discovery.service.status !== "paired") { + return yield* new MicIdentityUnavailableError({ reason: "revoked-service" }); + } + if (Date.parse(session.expiresAt) <= DateTime.toEpochMillis(yield* DateTime.now)) { + return yield* new MicIdentityUnauthorizedError({ reason: "expired-session" }); + } + return { session, discovery }; + }).pipe( + Effect.provideServiceEffect( + Headers.CurrentRedactedNames, + Effect.map(Headers.CurrentRedactedNames, (names) => [...names, MIC_IDENTITY_SESSION_HEADER]), + ), + ); + +/** Legacy management is allowed only for the authority's paired external service during migration. */ +export const requirePairedPrismTarget = (apiUrl: string) => + Effect.gen(function* () { + const flags = yield* ForkFlagsService; + const prism = (yield* flags.config).prism; + if ( + prism?.mode !== "external" || + !prism.external || + prism.external.baseUrl.replace(/\/$/, "") !== apiUrl.replace(/\/$/, "") + ) { + return yield* new MicIdentityUnavailableError({ reason: "configuration" }); + } + }); diff --git a/apps/server/src/fork/mic-identity/MicIdentityHttpApi.test.ts b/apps/server/src/fork/mic-identity/MicIdentityHttpApi.test.ts new file mode 100644 index 000000000000..3342cfc0eff5 --- /dev/null +++ b/apps/server/src/fork/mic-identity/MicIdentityHttpApi.test.ts @@ -0,0 +1,618 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { DEFAULT_FORK_FLAGS } from "@q1code/core/flags"; +import type { ForkConfig } from "@q1code/core/config"; +import { + MIC_IDENTITY_API_PATHS, + MIC_IDENTITY_SESSION_HEADER, + MIC_PRISM_PERMISSIONS, + normalizeMicIdentity, + type MicIdentityWire, + type MicPrismDiscoveryWire, +} from "@q1code/core/micIdentity"; +import { PrismHttpApi } from "@q1code/core/prismApi"; +import { + AuthAdministrativeScopes, + AuthSessionId, + AuthStandardClientScopes, + EnvironmentAuthenticatedAuth, + EnvironmentAuthenticatedPrincipal, + EnvironmentAuthInvalidError, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { + Etag, + HttpClientRequest, + HttpClientResponse, + HttpPlatform, + HttpServerRequest, +} from "effect/unstable/http"; +import * as HttpApiTest from "effect/unstable/httpapi/HttpApiTest"; + +import * as ServerConfig from "../../config.ts"; +import { ForkFlagsService } from "../ForkFlags.ts"; +import { prismHttpApiLayer } from "../prism/PrismHttpApi.ts"; +import { PrismService, type PrismStatus } from "../prism/PrismService.ts"; +import { PrismSyncNotConfigured, PrismSyncService } from "../prism/PrismSync.ts"; +import { MicIdentityFetch } from "./MicIdentityAccess.ts"; + +const apiUrl = "https://prism.example.test"; +const SESSION: MicIdentityWire = { + contractVersion: 1, + subject: "user-1", + role: "member", + permissions: ["prism:inference"], + authorizationRevision: "revision-1", + authorizationExpiresAt: 4_070_908_800_000, +}; +const MANAGER: MicIdentityWire = { + ...SESSION, + permissions: [...MIC_PRISM_PERMISSIONS], +}; +const DISCOVERY: MicPrismDiscoveryWire = { + contractVersion: 1, + selectionRevision: 1, + service: { + serviceInstanceId: "prism-pc", + displayName: "Paired PC", + apiOrigin: apiUrl, + inferenceOrigin: apiUrl, + pairingRevision: 1, + protocolVersion: 1, + publicKey: "A".repeat(43), + status: "paired", + }, +}; +const READY: PrismStatus = { + state: "ready", + mode: "external", + port: 8317, + since: "2026-09-04T00:00:00.000Z", + restarts: 0, + version: "7.2.151-prism.1", + baseUrl: apiUrl, + usageSource: true, +}; +const signedIn = { + headers: { authorization: "Bearer read", [MIC_IDENTITY_SESSION_HEADER]: "test-mic-session" }, +}; +const environmentAdmin = { + headers: { ...signedIn.headers, authorization: "Bearer admin" }, +}; + +const assertUnsupported = (error: { readonly _tag: string; readonly reason?: unknown }) => { + assert.equal(error._tag, "MicIdentityUnavailableError"); + assert.equal(error.reason, "unsupported-operation"); +}; + +// Environment auth is intentionally independent of the scripted mic.sc authority. +const authLayer = Layer.succeed(EnvironmentAuthenticatedAuth, (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const scopes = + request.headers.authorization === "Bearer admin" + ? AuthAdministrativeScopes + : request.headers.authorization === "Bearer read" + ? AuthStandardClientScopes + : undefined; + if (!scopes) + return yield* new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "missing_credential", + traceId: "test", + }); + return yield* httpEffect.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + sessionId: AuthSessionId.make("environment-session"), + subject: "environment-user", + method: "bearer-access-token", + scopes: new Set(scopes), + }), + ); + }), +); + +const makeFixture = ( + options: { + session?: MicIdentityWire; + identityEnabled?: boolean; + configuredApiUrl?: string; + } = {}, +) => { + const state = { + session: { status: 200, body: options.session ?? (SESSION as unknown) }, + discovery: { status: 200, body: DISCOVERY as unknown }, + transportFailure: false, + stalledBody: false, + restarts: 0, + syncExports: 0, + syncPushes: 0, + flagWrites: 0, + }; + const bodyStarted = Promise.withResolvers(); + const authorityCalls: Array<{ path: string; authorization: string | null }> = []; + const authorityFetch: typeof fetch = Object.assign( + async (input: Parameters[0], init?: Parameters[1]) => { + const path = new URL(input instanceof Request ? input.url : String(input)).pathname; + authorityCalls.push({ path, authorization: new Headers(init?.headers).get("authorization") }); + if (state.transportFailure) throw new Error("Authority offline"); + const response = path === MIC_IDENTITY_API_PATHS.session ? state.session : state.discovery; + const result = Response.json(response.body, { status: response.status }); + return state.stalledBody + ? Object.assign(result, { + json: () => { + bodyStarted.resolve(); + return new Promise(() => {}); + }, + }) + : result; + }, + { preconnect: () => {} }, + ); + const engineCalls: Array = []; + const proxyLayer = Layer.succeed( + PrismService, + PrismService.of({ + status: Effect.succeed(READY), + changes: Stream.empty, + endpoint: Effect.succeed(Option.none()), + restart: Effect.sync(() => { + state.restarts++; + return READY; + }), + reloadUsageSource: Effect.succeed(READY), + codexProxyHomePath: "/unused", + management: { + request: (path, request) => + Effect.sync(() => { + engineCalls.push(`${request?.method ?? "GET"} ${path}`); + const body = + path === "/auth-files" + ? { + files: [ + { + name: "account.json", + type: "codex", + email: "private@example.test", + disabled: true, + }, + ], + } + : path === "/routing/strategy" + ? { strategy: "round-robin" } + : {}; + return HttpClientResponse.fromWeb( + HttpClientRequest.get(`${apiUrl}${path}`), + Response.json(body), + ); + }), + }, + }), + ); + const config: ForkConfig = { + prism: { mode: "external", external: { baseUrl: options.configuredApiUrl ?? apiUrl } }, + "mic-identity": { + authorityUrl: "https://identity.example.test", + clerkPublishableKey: "pk_test_fixture", + }, + }; + const flags = { + ...DEFAULT_FORK_FLAGS, + prism: true, + "mic-identity": options.identityEnabled ?? true, + }; + const flagsLayer = Layer.succeed( + ForkFlagsService, + ForkFlagsService.of({ + current: Effect.succeed(flags), + reload: Effect.succeed(flags), + changes: Stream.empty, + config: Effect.succeed(config), + update: () => + Effect.sync(() => { + state.flagWrites++; + return config; + }), + }), + ); + const syncLayer = Layer.succeed( + PrismSyncService, + PrismSyncService.of({ + status: Effect.succeed({ role: "standalone" }), + changes: Stream.empty, + exportBundle: Effect.sync(() => { + state.syncExports++; + }).pipe( + Effect.andThen(Effect.fail(new PrismSyncNotConfigured({ message: "not configured" }))), + ), + applyPush: () => + Effect.sync(() => { + state.syncPushes++; + }).pipe( + Effect.andThen(Effect.fail(new PrismSyncNotConfigured({ message: "not configured" }))), + ), + syncNow: Effect.void, + recordTombstone: () => Effect.void, + }), + ); + const client = HttpApiTest.groups(PrismHttpApi, ["prism"]).pipe( + Effect.provide( + prismHttpApiLayer.pipe( + Layer.provide(proxyLayer), + Layer.provide(syncLayer), + Layer.provide(flagsLayer), + Layer.provide(Layer.succeed(MicIdentityFetch, authorityFetch)), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "q1code-mic-identity-http-" }), + ), + Layer.provideMerge(authLayer), + Layer.provideMerge(Layer.mergeAll(HttpPlatform.layer, Etag.layerWeak)), + ), + ), + ); + return { client, state, authorityCalls, engineCalls, bodyStarted: bodyStarted.promise }; +}; + +it.layer(NodeServices.layer, { excludeTestServices: true })( + "mic.sc Prism HTTP authorization", + (it) => { + it.effect( + "ordinary users see redacted status but cannot read account details or mutate the pool", + () => + Effect.gen(function* () { + const fixture = makeFixture(); + const client = yield* fixture.client; + const status = yield* client.prism.status(signedIn); + assert.deepEqual(status, { + state: "ready", + port: 0, + role: "standalone", + capabilities: normalizeMicIdentity(SESSION).capabilities, + }); + const details = yield* client.prism.listAccounts(signedIn).pipe(Effect.flip); + assert.equal(details._tag, "MicIdentityForbiddenError"); + const mutation = yield* client.prism + .deleteAccount({ ...environmentAdmin, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(mutation._tag, "MicIdentityForbiddenError"); + assert.deepEqual(fixture.engineCalls, []); + assert.isTrue( + fixture.authorityCalls.every( + (call) => call.authorization === "Bearer test-mic-session", + ), + ); + }), + ); + + it.effect( + "a Prism manager cannot use a mic.sc grant through the shared-key legacy account proxy", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const error = yield* client.prism + .patchAccount({ + ...signedIn, + params: { id: "account.json" }, + payload: { disabled: true }, + }) + .pipe(Effect.flip); + assertUnsupported(error); + assert.deepEqual(fixture.engineCalls, []); + assert.equal(MANAGER.role, "member"); + }), + ); + + it.effect( + "even combined Prism and environment administration cannot reach legacy management", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const login = { ...environmentAdmin, params: { sessionId: "fixture-login" } }; + const failures = [ + yield* client.prism.listAccounts(environmentAdmin).pipe(Effect.flip), + yield* client.prism + .startLogin({ ...environmentAdmin, payload: { provider: "codex" } }) + .pipe(Effect.flip), + yield* client.prism.loginStatus(login).pipe(Effect.flip), + yield* client.prism.cancelLogin(login).pipe(Effect.flip), + yield* client.prism + .loginCallback({ ...login, payload: { redirectUrl: "http://localhost/callback" } }) + .pipe(Effect.flip), + yield* client.prism + .deleteAccount({ ...environmentAdmin, params: { id: "account.json" } }) + .pipe(Effect.flip), + yield* client.prism.restart(environmentAdmin).pipe(Effect.flip), + yield* client.prism.syncExport(environmentAdmin).pipe(Effect.flip), + yield* client.prism + .syncPush({ ...environmentAdmin, payload: { entries: [] } }) + .pipe(Effect.flip), + yield* client.prism.syncStatus(environmentAdmin).pipe(Effect.flip), + yield* client.prism + .setUsageSource({ ...environmentAdmin, payload: { enabled: false } }) + .pipe(Effect.flip), + yield* client.prism.getUsage(environmentAdmin).pipe(Effect.flip), + ]; + for (const error of failures) assertUnsupported(error); + assert.deepEqual(fixture.engineCalls, []); + assert.equal(fixture.state.restarts, 0); + assert.equal(fixture.state.syncExports, 0); + assert.equal(fixture.state.syncPushes, 0); + assert.equal(fixture.state.flagWrites, 0); + }), + ); + + it.effect( + "Prism management grants do not authorize environment restart, sync, or local settings", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const restart = yield* client.prism.restart(signedIn).pipe(Effect.flip); + const syncExport = yield* client.prism.syncExport(signedIn).pipe(Effect.flip); + const syncPush = yield* client.prism + .syncPush({ ...signedIn, payload: { entries: [] } }) + .pipe(Effect.flip); + const usageSource = yield* client.prism + .setUsageSource({ ...signedIn, payload: { enabled: false } }) + .pipe(Effect.flip); + for (const error of [restart, syncExport, syncPush, usageSource]) + assert.equal(error._tag, "EnvironmentScopeRequiredError"); + assert.equal(fixture.state.restarts, 0); + assert.equal(fixture.state.syncExports, 0); + assert.equal(fixture.state.syncPushes, 0); + assert.equal(fixture.state.flagWrites, 0); + }), + ); + + it.effect( + "granted legacy routing is unsupported while missing account grants remain forbidden", + () => + Effect.gen(function* () { + const fixture = makeFixture({ + session: { + ...SESSION, + permissions: ["prism:inference", "prism:routing:read", "prism:routing:write"], + }, + }); + const client = yield* fixture.client; + assertUnsupported(yield* client.prism.getRouting(signedIn).pipe(Effect.flip)); + assertUnsupported( + yield* client.prism + .setRouting({ ...signedIn, payload: { strategy: "round-robin" } }) + .pipe(Effect.flip), + ); + const before = fixture.engineCalls.length; + const error = yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityForbiddenError"); + assert.equal(before, 0); + assert.equal(fixture.engineCalls.length, 0); + }), + ); + + it.effect( + "account read and write grants remain distinct from each other and from routing", + () => + Effect.gen(function* () { + const fixture = makeFixture({ + session: { ...SESSION, permissions: ["prism:inference", "prism:accounts:write"] }, + }); + const client = yield* fixture.client; + assertUnsupported( + yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip), + ); + assert.equal( + (yield* client.prism.listAccounts(signedIn).pipe(Effect.flip))._tag, + "MicIdentityForbiddenError", + ); + fixture.state.session.body = { + ...SESSION, + permissions: ["prism:inference", "prism:accounts:read"], + }; + assertUnsupported(yield* client.prism.listAccounts(signedIn).pipe(Effect.flip)); + assert.equal( + (yield* client.prism.getRouting(signedIn).pipe(Effect.flip))._tag, + "MicIdentityForbiddenError", + ); + assert.equal( + (yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip))._tag, + "MicIdentityForbiddenError", + ); + assert.deepEqual(fixture.engineCalls, []); + }), + ); + + it.effect("mic.sc identity alone never grants environment access", () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const error = yield* client.prism + .status({ headers: { [MIC_IDENTITY_SESSION_HEADER]: "test-mic-session" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentAuthInvalidError"); + assert.deepEqual(fixture.authorityCalls, []); + assert.deepEqual(fixture.engineCalls, []); + }), + ); + + it.effect( + "a missing identity session rejects an environment administrator before authority or engine access", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + const error = yield* client.prism + .listAccounts({ headers: { authorization: "Bearer admin" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityUnauthorizedError"); + assert.deepEqual(fixture.authorityCalls, []); + assert.deepEqual(fixture.engineCalls, []); + }), + ); + + it.effect( + "grant revocation is checked on the next request with the same identity and environment tokens", + () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + assertUnsupported( + yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip), + ); + fixture.state.session.body = { ...SESSION, authorizationRevision: "revision-2" }; + const error = yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityForbiddenError"); + assert.equal( + fixture.authorityCalls.filter((call) => call.path === MIC_IDENTITY_API_PATHS.session) + .length, + 2, + ); + assert.equal(fixture.engineCalls.length, 0); + }), + ); + + it.effect("expired and malformed authority sessions fail closed", () => + Effect.gen(function* () { + for (const [body, expected] of [ + [{ ...MANAGER, authorizationExpiresAt: 1 }, "MicIdentityUnauthorizedError"], + [{ ...MANAGER, authorizationExpiresAt: "tomorrow" }, "MicIdentityUnavailableError"], + [{ ...MANAGER, permissions: [true] }, "MicIdentityUnavailableError"], + [{ ...MANAGER, contractVersion: 2 }, "MicIdentityUnavailableError"], + ] as const) { + const fixture = makeFixture(); + fixture.state.session.body = body; + const client = yield* fixture.client; + const error = yield* client.prism.listAccounts(signedIn).pipe(Effect.flip); + assert.equal(error._tag, expected); + assert.deepEqual(fixture.engineCalls, []); + assert.equal(fixture.authorityCalls.length, 1); + } + }), + ); + + it.effect("authority rejection and transport failures never reuse earlier permissions", () => + Effect.gen(function* () { + for (const status of [401, 403, 503]) { + const fixture = makeFixture({ session: MANAGER }); + const client = yield* fixture.client; + yield* client.prism.status(signedIn); + fixture.state.session.status = status; + const error = yield* client.prism.listAccounts(signedIn).pipe(Effect.flip); + assert.equal( + error._tag, + status === 401 + ? "MicIdentityUnauthorizedError" + : status === 403 + ? "MicIdentityForbiddenError" + : "MicIdentityUnavailableError", + ); + assert.equal(fixture.engineCalls.length, 0); + fixture.state.transportFailure = true; + const offline = yield* client.prism.listAccounts(signedIn).pipe(Effect.flip); + assert.equal(offline._tag, "MicIdentityUnavailableError"); + assert.equal(fixture.engineCalls.length, 0); + } + }), + ); + + it.effect("a service association cannot authorize a different configured Prism host", () => + Effect.gen(function* () { + const fixture = makeFixture({ + session: MANAGER, + configuredApiUrl: "https://other.example.test", + }); + const client = yield* fixture.client; + const error = yield* client.prism.status(signedIn).pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityUnavailableError"); + if (error._tag === "MicIdentityUnavailableError") + assert.equal(error.reason, "configuration"); + assert.deepEqual(fixture.engineCalls, []); + }), + ); + + it.effect("an authority response whose body stalls times out without reaching Prism", () => + Effect.gen(function* () { + const fixture = makeFixture({ session: MANAGER }); + fixture.state.stalledBody = true; + const client = yield* fixture.client; + const request = yield* client.prism + .listAccounts(signedIn) + .pipe(Effect.flip, Effect.forkChild); + yield* Effect.promise(() => fixture.bodyStarted); + yield* TestClock.adjust("11 seconds"); + const error = yield* Fiber.join(request); + assert.equal(error._tag, "MicIdentityUnavailableError"); + assert.deepEqual(fixture.engineCalls, []); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect( + "missing, revoked, and malformed service discovery never reach the configured gateway", + () => + Effect.gen(function* () { + for (const body of [ + { contractVersion: 1, selectionRevision: 1, service: null }, + { ...DISCOVERY, service: { ...DISCOVERY.service, status: "revoked" } }, + { + ...DISCOVERY, + service: { ...DISCOVERY.service, apiOrigin: "http://public.example.test" }, + }, + ]) { + const fixture = makeFixture({ session: MANAGER }); + fixture.state.discovery.body = body; + const client = yield* fixture.client; + const error = yield* client.prism + .deleteAccount({ ...signedIn, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "MicIdentityUnavailableError"); + assert.deepEqual(fixture.engineCalls, []); + } + }), + ); + + it.effect( + "identity off preserves the existing status response and environment authorization", + () => + Effect.gen(function* () { + const fixture = makeFixture({ identityEnabled: false }); + fixture.state.transportFailure = true; + const client = yield* fixture.client; + const read = { headers: { authorization: "Bearer read" } }; + assert.deepEqual(yield* client.prism.status(read), { + state: "ready", + mode: "external", + port: 8317, + since: "2026-09-04T00:00:00.000Z", + restarts: 0, + version: "7.2.151-prism.1", + baseUrl: apiUrl, + usageSource: true, + role: "standalone", + }); + const accounts = yield* client.prism.listAccounts(read); + assert.equal(accounts.accounts.length, 1); + const error = yield* client.prism + .deleteAccount({ ...read, params: { id: "account.json" } }) + .pipe(Effect.flip); + assert.equal(error._tag, "EnvironmentScopeRequiredError"); + assert.deepEqual(fixture.authorityCalls, []); + }), + ); + }, +); diff --git a/apps/server/src/fork/mic-identity/MicPrismThreadHttp.test.ts b/apps/server/src/fork/mic-identity/MicPrismThreadHttp.test.ts new file mode 100644 index 000000000000..c399651dd5a7 --- /dev/null +++ b/apps/server/src/fork/mic-identity/MicPrismThreadHttp.test.ts @@ -0,0 +1,354 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { EMPTY_FORK_CONFIG } from "@q1code/core/config"; +import { DEFAULT_FORK_FLAGS } from "@q1code/core/flags"; +import { MIC_IDENTITY_SESSION_HEADER } from "@q1code/core/micIdentity"; +import { + AuthOrchestrationOperateScope, + EnvironmentAuthenticatedPrincipal, + EnvironmentId, + type AuthEnvironmentScope, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { HttpServerRequest, HttpClient, FetchHttpClient } from "effect/unstable/http"; +import * as ServerConfig from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as SessionStore from "../../auth/SessionStore.ts"; +import { ForkFlagsService } from "../ForkFlags.ts"; +import { publishPrismEnabled, publishPrismIdentityRequired } from "../prism/PrismEnvironment.ts"; +import { MicIdentityFetch } from "./MicIdentityAccess.ts"; +import { + connectMicPrismThreadRequest, + disconnectMicPrismThreadRequest, +} from "./MicPrismThreadHttp.ts"; +import { + authorizeMicPrismThread, + closeAllMicPrismThreads, + getMicPrismThreadEndpoint, +} from "./MicPrismThreads.ts"; + +const sessionLayer = () => + SessionStore.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("prism-http-test-environment")), + }), + ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "q1-prism-thread-http-test-" })), + Layer.provide(NodeServices.layer), + ); +const config = { + ...EMPTY_FORK_CONFIG, + "mic-identity": { authorityUrl: "https://authority.example.test", clerkPublishableKey: "" }, +}; +const flags = { ...DEFAULT_FORK_FLAGS, "mic-identity": true, prism: true }; +const flagService = ForkFlagsService.of({ + current: Effect.succeed(flags), + reload: Effect.succeed(flags), + changes: Stream.empty, + config: Effect.succeed(config), + update: () => Effect.succeed(config), +}); +const fakeToken = "fixture-clerk-session-jwt"; +const fakeInferenceToken = "msp1.fixture.signature"; +let sequence = 0; +const fixture = Effect.fn("test.prismThreadHttp.fixture")(function* ( + options: { + readonly scopes?: ReadonlyArray; + readonly missingSid?: boolean; + readonly credential?: Readonly>; + readonly stallCredential?: boolean; + } = {}, +) { + yield* Effect.acquireRelease( + Effect.sync(() => { + publishPrismEnabled(true); + publishPrismIdentityRequired(true); + }), + () => + Effect.promise(async () => { + await closeAllMicPrismThreads(); + publishPrismEnabled(false); + publishPrismIdentityRequired(false); + }), + ); + const sessions = yield* SessionStore.SessionStore; + const issued = yield* sessions.issue({ + subject: "environment-actor", + method: "bearer-access-token", + scopes: options.scopes ?? [AuthOrchestrationOperateScope], + }); + const verified = yield* sessions.verify(issued.token); + const threadId = `thread-http-${++sequence}`; + const now = DateTime.toEpochMillis(yield* DateTime.now); + const expiresAt = now + 60_000; + const calls: { + path: string; + method: string; + correctAuthorization: boolean; + hasEnvironmentToken: boolean; + body: string | null; + }[] = []; + const credentialStarted = Promise.withResolvers(); + const credentialAborted = Promise.withResolvers(); + const authorityFetch: typeof fetch = Object.assign( + async (input: Parameters[0], init?: Parameters[1]) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const headers = new Headers(init?.headers); + calls.push({ + path: url.pathname, + method: init?.method ?? "GET", + correctAuthorization: headers.get("authorization") === `Bearer ${fakeToken}`, + hasEnvironmentToken: Array.from(headers.values()).some((value) => + value.includes(issued.token), + ), + body: typeof init?.body === "string" ? init.body : null, + }); + if (url.pathname === "/v1/identity") + return Response.json({ + contractVersion: 1, + subject: "mic-member", + ...(options.missingSid ? {} : { sessionId: "clerk-session-one" }), + role: "member", + permissions: ["prism:inference"], + authorizationRevision: "revision-current", + authorizationExpiresAt: expiresAt, + }); + if (url.pathname === "/v1/prism/discovery") + return Response.json({ + contractVersion: 1, + selectionRevision: 1, + service: { + serviceInstanceId: "prism-fixture", + displayName: "PC fixture", + apiOrigin: "https://prism.example.test", + inferenceOrigin: "https://prism.example.test", + pairingRevision: 1, + protocolVersion: 1, + publicKey: "MCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + status: "paired", + }, + }); + if (url.pathname !== "/v1/prism/credentials") return new Response(null, { status: 404 }); + if (options.stallCredential) { + credentialStarted.resolve(); + return new Promise((_resolve, reject) => { + const abort = () => { + credentialAborted.resolve(); + reject(new Error("fixture request cancelled")); + }; + init?.signal?.addEventListener("abort", abort, { once: true }); + if (init?.signal?.aborted) abort(); + }); + } + return Response.json({ + version: 1, + tokenType: "Bearer", + token: fakeInferenceToken, + expiresAt, + serviceInstanceId: "prism-fixture", + pairingRevision: 1, + ...options.credential, + }); + }, + { preconnect: () => {} }, + ); + const provide = (operation: Effect.Effect) => + operation.pipe( + Effect.provideService(EnvironmentAuthenticatedPrincipal, { + sessionId: verified.sessionId, + subject: verified.subject, + method: verified.method, + scopes: new Set(verified.scopes), + }), + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb( + new Request("http://localhost/api/fork/prism/identity/threads/test", { + headers: { [MIC_IDENTITY_SESSION_HEADER]: fakeToken }, + }), + ), + ), + Effect.provideService(ForkFlagsService, flagService), + Effect.provideService(MicIdentityFetch, authorityFetch), + ); + return { + threadId, + sessions, + issued, + expiresAt, + calls, + credentialStarted, + credentialAborted, + connect: provide(connectMicPrismThreadRequest(threadId)), + disconnect: provide(disconnectMicPrismThreadRequest(threadId)), + }; +}); + +it.live( + "registers against a real environment session store without returning or forwarding its secrets", + () => + Effect.gen(function* () { + const h = yield* fixture(); + const receipt = yield* h.connect; + expect(receipt).toEqual({ threadId: h.threadId, expiresAt: h.expiresAt }); + expect(Object.keys(receipt).sort()).toEqual(["expiresAt", "threadId"]); + expect(h.calls.map((call) => call.path)).toEqual([ + "/v1/identity", + "/v1/prism/discovery", + "/v1/prism/credentials", + ]); + expect(h.calls.every((call) => call.correctAuthorization && !call.hasEnvironmentToken)).toBe( + true, + ); + expect(h.calls[2]?.body).toBe('{"serviceInstanceId":"prism-fixture","pairingRevision":1}'); + expect( + Boolean( + yield* Effect.promise(async () => + getMicPrismThreadEndpoint( + h.threadId, + await authorizeMicPrismThread(h.threadId, h.issued.sessionId), + ), + ), + ), + ).toBe(true); + yield* h.disconnect; + expect( + yield* Effect.promise(async () => + getMicPrismThreadEndpoint( + h.threadId, + await authorizeMicPrismThread(h.threadId, h.issued.sessionId), + ), + ), + ).toBeUndefined(); + }).pipe(Effect.provide(sessionLayer())), +); + +it.live("denies an environment reader before contacting the mic.sc authority", () => + Effect.gen(function* () { + const h = yield* fixture({ scopes: ["orchestration:read"] }); + const error = yield* h.connect.pipe(Effect.flip); + expect(error._tag).toBe("EnvironmentScopeRequiredError"); + expect(h.calls).toHaveLength(0); + expect( + yield* Effect.promise(async () => + getMicPrismThreadEndpoint( + h.threadId, + await authorizeMicPrismThread(h.threadId, h.issued.sessionId), + ), + ), + ).toBeUndefined(); + }).pipe(Effect.provide(sessionLayer())), +); + +it.live("requires a verified Clerk session ID, not an unbound template identity", () => + Effect.gen(function* () { + const h = yield* fixture({ missingSid: true }); + const error = yield* h.connect.pipe(Effect.flip); + expect(error._tag).toBe("MicIdentityUnauthorizedError"); + expect(h.calls.some((call) => call.path.endsWith("/credentials"))).toBe(false); + expect( + yield* Effect.promise(async () => + getMicPrismThreadEndpoint( + h.threadId, + await authorizeMicPrismThread(h.threadId, h.issued.sessionId), + ), + ), + ).toBeUndefined(); + }).pipe(Effect.provide(sessionLayer())), +); + +for (const credential of [ + { serviceInstanceId: "wrong-host" }, + { pairingRevision: 2 }, + { expiresAt: 0 }, +]) { + it.live(`rejects a mismatched or expired credential: ${Object.keys(credential)[0]}`, () => + Effect.gen(function* () { + const h = yield* fixture({ credential }); + const error = yield* h.connect.pipe(Effect.flip); + expect(error._tag).toBe("MicIdentityUnavailableError"); + expect( + yield* Effect.promise(async () => + getMicPrismThreadEndpoint( + h.threadId, + await authorizeMicPrismThread(h.threadId, h.issued.sessionId), + ), + ), + ).toBeUndefined(); + }).pipe(Effect.provide(sessionLayer())), + ); +} + +it.live("rechecks the environment store after mic.sc authorization before registering", () => + Effect.gen(function* () { + const h = yield* fixture(); + yield* h.sessions.revoke(h.issued.sessionId); + const error = yield* h.connect.pipe(Effect.flip); + expect(error._tag).toBe("MicIdentityForbiddenError"); + expect( + yield* Effect.promise(async () => + getMicPrismThreadEndpoint( + h.threadId, + await authorizeMicPrismThread(h.threadId, h.issued.sessionId), + ), + ), + ).toBeUndefined(); + }).pipe(Effect.provide(sessionLayer())), +); + +it.live("rejects broker inference immediately after its real environment session is revoked", () => + Effect.gen(function* () { + const h = yield* fixture(); + yield* h.connect; + const endpoint = yield* Effect.promise(async () => + getMicPrismThreadEndpoint( + h.threadId, + await authorizeMicPrismThread(h.threadId, h.issued.sessionId), + ), + ); + expect(Boolean(endpoint)).toBe(true); + if (!endpoint) return; + yield* h.sessions.revoke(h.issued.sessionId); + const response = yield* HttpClient.get(`${endpoint.baseUrl}/v1/models`, { + headers: { authorization: `Bearer ${endpoint.apiKey}` }, + }).pipe(Effect.provide(FetchHttpClient.layer)); + expect(response.status).toBe(403); + expect(response.headers["x-prism-fallback-allowed"]).toBe("false"); + yield* h.disconnect; + expect( + yield* Effect.promise(async () => + getMicPrismThreadEndpoint( + h.threadId, + await authorizeMicPrismThread(h.threadId, h.issued.sessionId), + ), + ), + ).toBeUndefined(); + }).pipe(Effect.provide(sessionLayer())), +); + +it.live("cancels pending credential exchange without retaining a thread endpoint", () => + Effect.gen(function* () { + const h = yield* fixture({ stallCredential: true }); + const fiber = yield* h.connect.pipe(Effect.forkChild); + yield* Effect.promise(() => h.credentialStarted.promise); + yield* Fiber.interrupt(fiber); + yield* Effect.promise(() => h.credentialAborted.promise); + expect( + yield* Effect.promise(async () => + getMicPrismThreadEndpoint( + h.threadId, + await authorizeMicPrismThread(h.threadId, h.issued.sessionId), + ), + ), + ).toBeUndefined(); + }).pipe(Effect.provide(sessionLayer())), +); diff --git a/apps/server/src/fork/mic-identity/MicPrismThreadHttp.ts b/apps/server/src/fork/mic-identity/MicPrismThreadHttp.ts new file mode 100644 index 000000000000..ea11a442b9ef --- /dev/null +++ b/apps/server/src/fork/mic-identity/MicPrismThreadHttp.ts @@ -0,0 +1,134 @@ +import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; +import { + MIC_IDENTITY_SESSION_HEADER, + MicIdentityForbiddenError, + MicIdentityUnauthorizedError, + MicIdentityUnavailableError, +} from "@q1code/core/micIdentity"; +import { MicPrismCredential } from "@q1code/core/micPrismApi"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; +import { HttpServerRequest } from "effect/unstable/http"; +import { requireEnvironmentScope } from "../../auth/http.ts"; +import { SessionStore } from "../../auth/SessionStore.ts"; +import { ForkFlagsService } from "../ForkFlags.ts"; +import { MicIdentityFetch, requireMicIdentity } from "./MicIdentityAccess.ts"; +import { registerMicPrismThread, revokeMicPrismThread } from "./MicPrismThreads.ts"; + +const encodeCredentialRequest = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ serviceInstanceId: Schema.String, pairingRevision: Schema.Int }), + ), +); +const decodeCredential = Schema.decodeUnknownEffect(MicPrismCredential); + +/** Environment authority admits execution; mic.sc authority admits only inference. */ +export const connectMicPrismThreadRequest = Effect.fn("connectMicPrismThreadRequest")( + function* (threadId: string) { + const principal = yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + const flags = yield* ForkFlagsService; + const activeFlags = yield* flags.current; + if (!activeFlags["mic-identity"] || !activeFlags.prism) + return yield* new MicIdentityUnavailableError({ reason: "configuration" }); + const access = yield* requireMicIdentity("prism:inference"); + if (!access?.session.sessionId || !access.discovery.service) + return yield* new MicIdentityUnauthorizedError({ reason: "invalid-session" }); + const service = access.discovery.service; + const config = (yield* flags.config)["mic-identity"]!; + const request = yield* HttpServerRequest.HttpServerRequest; + const fetchAuthority = yield* MicIdentityFetch; + const body = yield* encodeCredentialRequest({ + serviceInstanceId: service.id, + pairingRevision: service.pairingRevision, + }).pipe(Effect.mapError(() => new MicIdentityUnavailableError({ reason: "invalid-response" }))); + const response = yield* Effect.tryPromise({ + try: (signal) => + fetchAuthority(`${config.authorityUrl.replace(/\/$/, "")}/v1/prism/credentials`, { + method: "POST", + redirect: "error", + cache: "no-store", + signal, + headers: { + authorization: `Bearer ${request.headers[MIC_IDENTITY_SESSION_HEADER]}`, + "content-type": "application/json", + }, + body, + }), + catch: () => new MicIdentityUnavailableError({ reason: "transport" }), + }); + if (response.status === 401) + return yield* new MicIdentityUnauthorizedError({ reason: "invalid-session" }); + if (response.status === 403) + return yield* new MicIdentityForbiddenError({ capability: "prism:inference" }); + if (!response.ok) return yield* new MicIdentityUnavailableError({ reason: "transport" }); + const raw = yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: () => new MicIdentityUnavailableError({ reason: "invalid-response" }), + }); + const credential = yield* decodeCredential(raw).pipe( + Effect.mapError(() => new MicIdentityUnavailableError({ reason: "invalid-response" })), + ); + const now = DateTime.toEpochMillis(yield* DateTime.now); + if ( + credential.serviceInstanceId !== service.id || + credential.pairingRevision !== service.pairingRevision || + credential.expiresAt <= now || + credential.expiresAt > now + 930_000 + ) + return yield* new MicIdentityUnavailableError({ reason: "invalid-response" }); + const optionalSessions = yield* Effect.serviceOption(SessionStore); + if (Option.isNone(optionalSessions)) + return yield* new MicIdentityUnavailableError({ reason: "configuration" }); + const sessions = optionalSessions.value; + const verify = sessions.listActive().pipe( + Effect.map((all) => + all.some( + (session) => + session.sessionId === principal.sessionId && + session.scopes.includes(AuthOrchestrationOperateScope), + ), + ), + Effect.orElseSucceed(() => false), + ); + const binding = { + environmentSessionId: principal.sessionId, + subject: access.session.subject, + sessionId: access.session.sessionId, + threadId, + serviceInstanceId: service.id, + pairingRevision: service.pairingRevision, + inferenceOrigin: service.inferenceUrl, + }; + return yield* Effect.tryPromise({ + try: (signal) => + registerMicPrismThread({ + binding, + credential: { binding, token: credential.token, expiresAt: credential.expiresAt }, + verifyEnvironment: () => Effect.runPromise(verify), + signal, + }), + catch: () => new MicIdentityForbiddenError({ capability: "prism:inference" }), + }); + }, + (effect) => + effect.pipe( + Effect.timeoutOrElse({ + duration: "15 seconds", + orElse: () => Effect.fail(new MicIdentityUnavailableError({ reason: "transport" })), + }), + ), +); + +/** A failed Clerk sign-out must still be able to discard its environment-local broker. */ +export const disconnectMicPrismThreadRequest = Effect.fn("disconnectMicPrismThreadRequest")( + function* (threadId: string) { + const principal = yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + yield* Effect.tryPromise({ + try: () => revokeMicPrismThread(threadId, principal.sessionId), + catch: () => new MicIdentityForbiddenError({ capability: "prism:inference" }), + }); + return { threadId, expiresAt: 0 }; + }, +); diff --git a/apps/server/src/fork/mic-identity/MicPrismThreads.test.ts b/apps/server/src/fork/mic-identity/MicPrismThreads.test.ts new file mode 100644 index 000000000000..eb51bee12483 --- /dev/null +++ b/apps/server/src/fork/mic-identity/MicPrismThreads.test.ts @@ -0,0 +1,702 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { OrchestrationCommand, ModelSelection } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import type { OrchestrationEngineShape } from "../../orchestration/Services/OrchestrationEngine.ts"; +import { guardMicPrismDispatch, makeMicPrismThreadRegistry } from "./MicPrismThreads.ts"; +import type { PrismInferenceBinding, PrismInferenceBrokerOptions } from "./PrismInferenceBroker.ts"; + +const binding: PrismInferenceBinding = { + environmentSessionId: "env-a", + subject: "member-a", + sessionId: "clerk-a", + threadId: "thread-a", + serviceInstanceId: "host-a", + pairingRevision: 1, + inferenceOrigin: "https://prism.example.test", +}; +const credential = { binding, token: "msp1.fixture.signature", expiresAt: 50_000 }; +function gate() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} +const registries: ReturnType[] = []; +afterEach(async () => { + await Promise.all(registries.splice(0).map((registry) => registry.closeAll())); +}); +function harness(changes?: Stream.Stream, emitChange = () => {}, createGate?: Promise) { + let enabled = true; + let now = 1_000; + let closed = 0; + const closing = gate(); + const creating = gate(); + const calls: PrismInferenceBrokerOptions[] = []; + const registry = makeMicPrismThreadRegistry({ + enabled: () => enabled, + now: () => now, + ...(changes ? { changes } : {}), + createBroker: async (options) => { + calls.push(options); + creating.resolve(); + if (createGate) await createGate; + return { + binding: options.binding, + endpoint: { baseUrl: "http://127.0.0.1:1234", apiKey: "local-fixture" }, + revoke: () => {}, + close: async () => { + closed++; + closing.resolve(); + }, + }; + }, + }); + registries.push(registry); + return { + registry, + calls, + closing, + creating, + closed: () => closed, + setNow: (value: number) => { + now = value; + }, + disable: () => { + enabled = false; + emitChange(); + }, + }; +} +const registerInput = { binding, credential, verifyEnvironment: async () => true }; +const decodeCommand = Schema.decodeUnknownSync(OrchestrationCommand); +const encodeCommandJson = Schema.encodeEffect(Schema.fromJsonString(OrchestrationCommand)); +const turn = decodeCommand({ + type: "thread.turn.start", + commandId: "command-a", + threadId: binding.threadId, + message: { messageId: "message-a", role: "user", text: "hello", attachments: [] }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-09-05T00:00:00.000Z", +}); +function engine() { + const dispatched: OrchestrationCommand[] = []; + const value: OrchestrationEngineShape = { + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("not used"), + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }; + return { value, dispatched }; +} + +describe("mic.sc thread credential ownership", () => { + it("renews only the same complete binding and never rolls a newer credential back", async () => { + const h = harness(); + await h.registry.register(registerInput); + const next = { ...credential, token: "msp1.renewed.signature", expiresAt: 80_000 }; + await h.registry.register({ ...registerInput, credential: next }); + expect(await h.registry.register(registerInput)).toEqual({ + threadId: binding.threadId, + expiresAt: next.expiresAt, + }); + expect(await h.calls[0]!.getCredential(binding, new AbortController().signal)).toEqual(next); + expect(h.calls).toHaveLength(1); + }); + for (const patch of [ + { subject: "member-b" }, + { sessionId: "clerk-b" }, + { serviceInstanceId: "host-b" }, + { pairingRevision: 2 }, + { inferenceOrigin: "https://other.example.test" }, + ]) { + it(`rejects binding replacement ${JSON.stringify(patch)}`, async () => { + const h = harness(); + await h.registry.register(registerInput); + const changed = { ...binding, ...patch }; + await expect( + h.registry.register({ + ...registerInput, + binding: changed, + credential: { ...credential, binding: changed }, + }), + ).rejects.toThrow("Prism thread authorization"); + expect(h.calls).toHaveLength(1); + }); + } + it("reserves ownership before concurrent verification and cannot resurrect a revoked registration", async () => { + const h = harness(); + const pending = gate(); + const registering = h.registry.register({ + ...registerInput, + verifyEnvironment: () => pending.promise, + }); + const changed = { ...binding, subject: "other" }; + await expect( + h.registry.register({ + ...registerInput, + binding: changed, + credential: { ...credential, binding: changed }, + }), + ).rejects.toThrow(); + const rejected = expect(registering).rejects.toThrow(); + const revoke = h.registry.revoke(binding.threadId, binding.environmentSessionId); + pending.resolve(true); + await Promise.all([revoke, rejected]); + expect( + h.registry.endpoint( + binding.threadId, + (await h.registry.authorize(binding.threadId, binding.environmentSessionId)) ?? "", + ), + ).toBeUndefined(); + expect(h.calls).toHaveLength(0); + }); + it("denies unauthorized deletion and expired credentials without releasing ownership", async () => { + const h = harness(); + await h.registry.register(registerInput); + await h.registry.revoke(binding.threadId, "env-b"); + expect(await h.registry.owns(binding.threadId, binding.environmentSessionId)).toBe(true); + h.setNow(credential.expiresAt); + expect( + h.registry.endpoint( + binding.threadId, + (await h.registry.authorize(binding.threadId, binding.environmentSessionId)) ?? "", + ), + ).toBeUndefined(); + expect(h.registry.has(binding.threadId)).toBe(true); + await expect(h.registry.register(registerInput)).rejects.toThrow(); + await expect( + h.calls[0]!.getCredential(binding, new AbortController().signal), + ).rejects.toThrow(); + }); + it.effect("closes brokers when flags turn off without another inference request", () => + Effect.gen(function* () { + const changes = yield* PubSub.unbounded(); + const h = harness(Stream.fromPubSub(changes), () => { + PubSub.publishUnsafe(changes, undefined); + }); + yield* Effect.promise(() => h.registry.register(registerInput)); + h.disable(); + yield* Effect.promise(() => h.closing.promise); + expect(h.registry.has(binding.threadId)).toBe(true); + expect(h.closed()).toBeGreaterThan(0); + yield* Effect.promise(() => expect(h.registry.register(registerInput)).rejects.toThrow()); + }), + ); + it("keeps an immutable owner after manual revoke while allowing the same actor to reconnect", async () => { + const h = harness(); + await h.registry.register(registerInput); + await h.registry.revoke(binding.threadId, binding.environmentSessionId); + expect( + h.registry.endpoint( + binding.threadId, + (await h.registry.authorize(binding.threadId, binding.environmentSessionId)) ?? "", + ), + ).toBeUndefined(); + expect(await h.registry.owns(binding.threadId, binding.environmentSessionId)).toBe(false); + const other = { ...binding, subject: "other" }; + await expect( + h.registry.register({ + ...registerInput, + binding: other, + credential: { ...credential, binding: other }, + }), + ).rejects.toThrow(); + await h.registry.register(registerInput); + expect( + h.registry.endpoint( + binding.threadId, + (await h.registry.authorize(binding.threadId, binding.environmentSessionId)) ?? "", + ), + ).toBeDefined(); + expect(h.calls).toHaveLength(2); + }); + for (const patch of [ + { sessionId: "clerk-renewed" }, + { + serviceInstanceId: "host-b", + pairingRevision: 2, + inferenceOrigin: "https://replacement.example.test", + }, + ]) { + it(`reconnects the same stable actor after explicit revoke ${JSON.stringify(patch)}`, async () => { + const h = harness(); + await h.registry.register(registerInput); + const changed = { ...binding, ...patch }; + const reconnect = { + ...registerInput, + binding: changed, + credential: { ...credential, binding: changed }, + }; + await expect(h.registry.register(reconnect)).rejects.toThrow(); + await h.registry.revoke(binding.threadId, binding.environmentSessionId); + await expect(h.registry.register(reconnect)).resolves.toEqual({ + threadId: binding.threadId, + expiresAt: credential.expiresAt, + }); + expect(h.calls.at(-1)!.binding).toEqual(changed); + }); + } + it("permits the same mic.sc user to connect from another independently authorized environment session", async () => { + const h = harness(); + await h.registry.register(registerInput); + const firstId = await h.registry.authorize(binding.threadId, binding.environmentSessionId); + const second = { + ...binding, + environmentSessionId: "env-device-b", + sessionId: "clerk-device-b", + }; + await h.registry.register({ + ...registerInput, + binding: second, + credential: { ...credential, binding: second }, + }); + const secondId = await h.registry.authorize(second.threadId, second.environmentSessionId); + expect(firstId).toBeDefined(); + expect(secondId).toBeDefined(); + expect(secondId).not.toBe(firstId); + await h.registry.revoke(binding.threadId, binding.environmentSessionId); + expect(h.registry.endpoint(binding.threadId, firstId)).toBeUndefined(); + expect(h.registry.endpoint(second.threadId, secondId)).toBeDefined(); + }); + it("rolls back a cancelled registration and closes its late broker without revoking a subsequent connection", async () => { + const opening = gate(); + const h = harness(undefined, undefined, opening.promise); + const controller = new AbortController(); + const registering = h.registry.register({ ...registerInput, signal: controller.signal }); + await h.creating.promise; + const failed = expect(registering).rejects.toThrow(); + controller.abort(); + await failed; + expect( + h.registry.endpoint( + binding.threadId, + (await h.registry.authorize(binding.threadId, binding.environmentSessionId)) ?? "", + ), + ).toBeUndefined(); + const reconnecting = h.registry.register(registerInput); + opening.resolve(); + await reconnecting; + await h.closing.promise; + expect( + h.registry.endpoint( + binding.threadId, + (await h.registry.authorize(binding.threadId, binding.environmentSessionId)) ?? "", + ), + ).toBeDefined(); + expect(h.closed()).toBe(1); + }); + it("cancels pending environment verification without allocating a broker", async () => { + const h = harness(); + const verifying = gate(); + const permission = gate(); + const controller = new AbortController(); + const registering = h.registry.register({ + ...registerInput, + signal: controller.signal, + verifyEnvironment: () => { + verifying.resolve(); + return permission.promise; + }, + }); + await verifying.promise; + const failed = expect(registering).rejects.toThrow(); + controller.abort(); + await failed; + permission.resolve(true); + expect( + h.registry.endpoint( + binding.threadId, + (await h.registry.authorize(binding.threadId, binding.environmentSessionId)) ?? "", + ), + ).toBeUndefined(); + expect(h.calls).toHaveLength(0); + }); + it("does not roll back a successful concurrent renewal when an older renewal is cancelled", async () => { + const h = harness(); + await h.registry.register(registerInput); + const permission = gate(); + const verifying = gate(); + const controller = new AbortController(); + const cancelled = h.registry.register({ + ...registerInput, + credential: { ...credential, expiresAt: 90_000 }, + signal: controller.signal, + verifyEnvironment: () => { + verifying.resolve(); + return permission.promise; + }, + }); + await verifying.promise; + await h.registry.register({ + ...registerInput, + credential: { ...credential, expiresAt: 80_000 }, + }); + const failed = expect(cancelled).rejects.toThrow(); + controller.abort(); + await failed; + permission.resolve(true); + expect((await h.calls[0]!.getCredential(binding, new AbortController().signal)).expiresAt).toBe( + 80_000, + ); + }); + it("environment removal closes every owned broker and preserves unrelated sessions", async () => { + const h = harness(); + await h.registry.register(registerInput); + const second = { ...binding, threadId: "thread-b" }; + await h.registry.register({ + ...registerInput, + binding: second, + credential: { ...credential, binding: second }, + }); + const unrelated = { ...binding, threadId: "thread-c", environmentSessionId: "env-other" }; + await h.registry.register({ + ...registerInput, + binding: unrelated, + credential: { ...credential, binding: unrelated }, + }); + await h.registry.revokeEnvironment(binding.environmentSessionId); + expect( + h.registry.endpoint( + binding.threadId, + (await h.registry.authorize(binding.threadId, binding.environmentSessionId)) ?? "", + ), + ).toBeUndefined(); + expect( + h.registry.endpoint( + second.threadId, + (await h.registry.authorize(second.threadId, second.environmentSessionId)) ?? "", + ), + ).toBeUndefined(); + expect( + h.registry.endpoint( + unrelated.threadId, + (await h.registry.authorize(unrelated.threadId, unrelated.environmentSessionId)) ?? "", + ), + ).toBeDefined(); + expect(h.closed()).toBe(2); + }); + it("does not return successful registration when authorization verification outlives the credential", async () => { + const h = harness(); + await expect( + h.registry.register({ + ...registerInput, + verifyEnvironment: async () => { + h.setNow(credential.expiresAt); + return true; + }, + }), + ).rejects.toThrow(); + expect( + h.registry.endpoint( + binding.threadId, + (await h.registry.authorize(binding.threadId, binding.environmentSessionId)) ?? "", + ), + ).toBeUndefined(); + }); + it("does not retain provider activation state with identity integration disabled", () => { + const h = harness(); + h.disable(); + h.registry.publishActive(binding.threadId, "direct", selection); + expect(h.registry.activeBinding(binding.threadId)).toBeUndefined(); + expect(h.registry.activeSelection(binding.threadId)).toBeUndefined(); + }); + it("rechecks environment revocation before admitting execution", async () => { + const h = harness(); + let allowed = true; + await h.registry.register({ ...registerInput, verifyEnvironment: async () => allowed }); + expect(await h.registry.owns(binding.threadId, "env-a")).toBe(true); + allowed = false; + expect(await h.registry.owns(binding.threadId, "env-a")).toBe(false); + }); +}); + +const selection = Schema.decodeUnknownSync(ModelSelection)({ + instanceId: "custom-codex", + model: "example", +}); +const selectedTurn = (value: ModelSelection) => decodeCommand({ ...turn, modelSelection: value }); +const withHandle = (id: string, route = "prism") => ({ + ...selection, + options: [ + { id: "prism-route", value: route }, + { id: "q1.mic-binding", value: id }, + ], +}); +const approval = decodeCommand({ + ...turn, + type: "thread.approval.respond", + requestId: "request-a", + decision: "accept", +}); +function guarded( + h: ReturnType, + e: ReturnType, + env = "env-a", + persisted = selection, + enabled = true, +) { + return guardMicPrismDispatch(e.value, env, { + enabled: () => enabled, + registry: h.registry, + resolveSelection: (command) => + Effect.succeed({ + selection: + ("modelSelection" in command ? command.modelSelection : undefined) ?? + h.registry.activeSelection(binding.threadId) ?? + persisted, + routed: true, + }), + }); +} + +describe("authenticated Prism dispatch handles", () => { + it.effect( + "stays inert with flags off without reading ownership or changing command metadata", + () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + const wrapped = guardMicPrismDispatch(e.value, "env-b", { + enabled: () => false, + registry: h.registry, + resolveSelection: () => Effect.die("must stay inert"), + }); + const forged = selectedTurn(withHandle("client-value")); + yield* wrapped.dispatch(forged); + expect(e.dispatched).toEqual([forged]); + }), + ); + it.effect( + "rejects unregistered actors even with omitted model selection or a forged handle", + () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + const ownerId = yield* Effect.promise(() => + h.registry.authorize(binding.threadId, "env-a"), + ); + for (const command of [turn, selectedTurn(withHandle(ownerId!))]) { + const error = yield* guarded(h, e, "env-b").dispatch(command).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).not.toContain(binding.subject); + } + expect(e.dispatched).toEqual([]); + }), + ); + it.effect( + "stamps a noncredential handle onto resolved omitted selections and replaces client values", + () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + const handle = yield* Effect.promise(() => h.registry.authorize(binding.threadId, "env-a")); + for (const command of [turn, selectedTurn(withHandle("client-forged"))]) + yield* guarded(h, e).dispatch(command); + for (const command of e.dispatched) { + expect( + "modelSelection" in command && + command.modelSelection?.options?.find((option) => option.id === "q1.mic-binding") + ?.value, + ).toBe(handle); + const persisted = yield* encodeCommandJson(command); + expect(persisted).not.toContain(credential.token); + expect(persisted).not.toContain(binding.subject); + expect(persisted).not.toContain(binding.environmentSessionId); + } + }), + ); + it.effect( + "old queued commands cannot use a new broker after same-user session or device replacement", + () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + yield* guarded(h, e).dispatch(turn); + const oldHandle = yield* Effect.promise(() => + h.registry.authorize(binding.threadId, "env-a"), + ); + yield* Effect.promise(() => h.registry.revoke(binding.threadId, "env-a")); + const replacement = { ...binding, sessionId: "new-clerk-session" }; + yield* Effect.promise(() => + h.registry.register({ + ...registerInput, + binding: replacement, + credential: { ...credential, binding: replacement }, + }), + ); + const newHandle = yield* Effect.promise(() => + h.registry.authorize(binding.threadId, "env-a"), + ); + expect(newHandle).not.toBe(oldHandle); + expect(h.registry.endpoint(binding.threadId, oldHandle)).toBeUndefined(); + expect(h.registry.endpoint(binding.threadId, newHandle)).toBeDefined(); + }), + ); + it.effect( + "supports independently authorized turns from two devices of the same mic.sc user", + () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + const otherDevice = { ...binding, environmentSessionId: "env-b", sessionId: "clerk-b" }; + yield* Effect.promise(() => + h.registry.register({ + ...registerInput, + binding: otherDevice, + credential: { ...credential, binding: otherDevice }, + }), + ); + yield* guarded(h, e, "env-a").dispatch(turn); + yield* guarded(h, e, "env-b").dispatch(turn); + const first = e.dispatched[0]!; + const second = e.dispatched[1]!; + expect("modelSelection" in first && first.modelSelection).not.toEqual( + "modelSelection" in second && second.modelSelection, + ); + }), + ); + it.effect( + "keeps approvals on their initiating device until a new turn establishes another binding", + () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + const firstId = yield* Effect.promise(() => + h.registry.authorize(binding.threadId, "env-a"), + ); + const otherDevice = { ...binding, environmentSessionId: "env-b", sessionId: "clerk-b" }; + yield* Effect.promise(() => + h.registry.register({ + ...registerInput, + binding: otherDevice, + credential: { ...credential, binding: otherDevice }, + }), + ); + h.registry.publishActive(binding.threadId, firstId, withHandle(firstId!)); + yield* guarded(h, e, "env-a", selection).dispatch(approval); + const error = yield* guarded(h, e, "env-b", withHandle(firstId!)) + .dispatch(approval) + .pipe(Effect.flip); + expect(error.message).toContain("initiating device"); + expect(e.dispatched).toEqual([approval]); + }), + ); + it.effect("stamps explicit direct recovery and permits its subsequent native approvals", () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + yield* Effect.promise(() => h.registry.revoke(binding.threadId, "env-a")); + const direct = selectedTurn(withHandle("forged", "direct")); + yield* guarded(h, e, "env-b").dispatch(direct); + expect("modelSelection" in e.dispatched[0]! && e.dispatched[0].modelSelection).toEqual( + withHandle("direct", "direct"), + ); + expect((yield* guarded(h, e, "env-b").dispatch(approval).pipe(Effect.flip))._tag).toBe( + "OrchestrationCommandInvariantError", + ); + h.registry.publishActive(binding.threadId, "direct", withHandle("direct", "direct")); + yield* guarded(h, e, "env-b", selection).dispatch(approval); + expect(e.dispatched).toHaveLength(2); + }), + ); + it.effect( + "strips fake direct proof from metadata updates and refuses a continuation without server proof", + () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + const metadata = decodeCommand({ + ...turn, + type: "thread.meta.update", + modelSelection: withHandle("direct", "direct"), + }); + yield* guarded(h, e, "env-b").dispatch(metadata); + const clean = e.dispatched[0]!; + const persisted = "modelSelection" in clean ? clean.modelSelection! : selection; + expect(persisted.options?.some((option) => option.id === "q1.mic-binding")).toBe(false); + expect( + (yield* guarded(h, e, "env-b", persisted).dispatch(approval).pipe(Effect.flip))._tag, + ).toBe("OrchestrationCommandInvariantError"); + }), + ); + it.effect( + "an accepted queued turn does not transfer active approvals until provider activation", + () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + const a = yield* Effect.promise(() => h.registry.authorize(binding.threadId, "env-a")); + h.registry.publishActive(binding.threadId, a, withHandle(a!)); + const device = { ...binding, environmentSessionId: "env-b", sessionId: "clerk-b" }; + yield* Effect.promise(() => + h.registry.register({ + ...registerInput, + binding: device, + credential: { ...credential, binding: device }, + }), + ); + const b = yield* Effect.promise(() => h.registry.authorize(binding.threadId, "env-b")); + yield* guarded(h, e, "env-b").dispatch(turn); + expect(h.registry.activeBinding(binding.threadId)).toBe(a); + expect((yield* guarded(h, e, "env-b").dispatch(approval).pipe(Effect.flip))._tag).toBe( + "OrchestrationCommandInvariantError", + ); + h.registry.publishActive(binding.threadId, b, withHandle(b!)); + yield* guarded(h, e, "env-b").dispatch(approval); + expect((yield* guarded(h, e, "env-a").dispatch(approval).pipe(Effect.flip))._tag).toBe( + "OrchestrationCommandInvariantError", + ); + }), + ); + it.effect( + "omitted turn selections preserve the active direct route despite a stale Prism shell", + () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + yield* Effect.promise(() => h.registry.revoke(binding.threadId, "env-a")); + h.registry.publishActive(binding.threadId, "direct", withHandle("direct", "direct")); + yield* guarded(h, e, "env-a", selection).dispatch(turn); + expect("modelSelection" in e.dispatched[0]! && e.dispatched[0].modelSelection).toEqual( + withHandle("direct", "direct"), + ); + h.registry.publishActive(binding.threadId, undefined); + expect((yield* guarded(h, e, "env-a").dispatch(approval).pipe(Effect.flip))._tag).toBe( + "OrchestrationCommandInvariantError", + ); + }), + ); + it.effect("denies default Prism turns with an expired credential", () => + Effect.gen(function* () { + const h = harness(); + const e = engine(); + yield* Effect.promise(() => h.registry.register(registerInput)); + h.setNow(credential.expiresAt); + expect((yield* guarded(h, e).dispatch(turn).pipe(Effect.flip))._tag).toBe( + "OrchestrationCommandInvariantError", + ); + expect(e.dispatched).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/fork/mic-identity/MicPrismThreads.ts b/apps/server/src/fork/mic-identity/MicPrismThreads.ts new file mode 100644 index 000000000000..aacae019a6f6 --- /dev/null +++ b/apps/server/src/fork/mic-identity/MicPrismThreads.ts @@ -0,0 +1,477 @@ +// @effect-diagnostics globalDate:off - Broker credential expiry uses the native HTTP clock. +import * as NodeCrypto from "node:crypto"; +import type { ModelSelection, OrchestrationCommand } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { OrchestrationCommandInvariantError } from "../../orchestration/Errors.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderInstanceRegistry } from "../../provider/Services/ProviderInstanceRegistry.ts"; +import { + isPrismEnabled, + isPrismIdentityRequired, + prismEndpointChanges, +} from "../prism/PrismEnvironment.ts"; +import { prismRoute, MIC_PRISM_BINDING_OPTION } from "../prism/PrismRouting.ts"; +import { + createPrismInferenceBroker, + type PrismBrokerCredential, + type PrismInferenceBinding, +} from "./PrismInferenceBroker.ts"; + +const fields = [ + "environmentSessionId", + "subject", + "sessionId", + "threadId", + "serviceInstanceId", + "pairingRevision", + "inferenceOrigin", +] as const; +const sameBinding = (a: PrismInferenceBinding, b: PrismInferenceBinding) => + fields.every((key) => a[key] === b[key]); +const unavailable = () => + new Error("Prism thread authorization is unavailable. Reconnect your mic.sc session."); +async function waitForRegistration(operation: Promise, signal?: AbortSignal): Promise { + if (!signal) return operation; + let abort = () => {}; + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(unavailable()); + signal.addEventListener("abort", abort, { once: true }); + if (signal.aborted) abort(); + }); + try { + return await Promise.race([operation, cancelled]); + } finally { + signal.removeEventListener("abort", abort); + } +} +type Broker = Awaited>; +type Registration = { + readonly binding: PrismInferenceBinding; + readonly credential: PrismBrokerCredential; + readonly verifyEnvironment: () => Promise; + readonly signal?: AbortSignal; +}; +type Entry = { + readonly bindingId: string; + readonly binding: PrismInferenceBinding; + credential: PrismBrokerCredential; + readonly verifyEnvironment: () => Promise; + broker?: Broker; + ready: Promise; +}; + +/** Memory-only ownership survives credential expiry, preventing another actor from renewing a thread. */ +export function makeMicPrismThreadRegistry(options: { + readonly enabled: () => boolean; + readonly changes?: Stream.Stream; + readonly now?: () => number; + readonly createBroker?: typeof createPrismInferenceBroker; +}) { + const entries = new Map(); + const handles = new Map(); + const active = new Map< + string, + { readonly bindingId: string; readonly selection?: ModelSelection } + >(); + const entryKey = (threadId: string, environmentSessionId: string) => + JSON.stringify([threadId, environmentSessionId]); + // A queued command may outlive revocation. Never attach another actor's credential to its thread. + const owners = new Map(); + const now = options.now ?? Date.now; + const createBroker = options.createBroker ?? createPrismInferenceBroker; + let watcher: { controller: AbortController; ready: Promise } | undefined; + const closeAll = async () => { + const current = [...entries.values()]; + entries.clear(); + handles.clear(); + active.clear(); + const previous = watcher; + watcher = undefined; + previous?.controller.abort(); + await Promise.all( + current.map(async (entry) => { + entry.broker?.revoke(); + await entry.ready.catch(() => {}); + await entry.broker?.close(); + }), + ); + }; + const watch = () => { + if (!watcher && options.changes) { + const controller = new AbortController(); + let subscribed!: () => void; + const ready = new Promise((resolve) => { + subscribed = resolve; + }); + watcher = { controller, ready }; + void Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const pull = yield* Stream.toPull(options.changes!); + subscribed(); + while (true) { + yield* pull; + if (!options.enabled()) yield* Effect.promise(closeAll); + } + }), + ), + { signal: controller.signal }, + ).catch(() => { + subscribed(); + }); + } + return watcher?.ready ?? Promise.resolve(); + }; + const valid = (credential: PrismBrokerCredential, binding: PrismInferenceBinding) => + sameBinding(credential.binding, binding) && + Number.isSafeInteger(credential.expiresAt) && + credential.expiresAt > now() && + credential.expiresAt <= now() + 930_000 && + credential.token.length <= 8192 && + /^msp1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(credential.token); + const register = async (input: Registration) => { + if (input.signal?.aborted || !options.enabled() || !valid(input.credential, input.binding)) + throw unavailable(); + const owner = owners.get(input.binding.threadId); + if (owner && owner !== input.binding.subject) throw unavailable(); + if ( + [...entries.values()].some( + (entry) => + entry.binding.threadId === input.binding.threadId && + entry.binding.subject !== input.binding.subject, + ) + ) + throw unavailable(); + const key = entryKey(input.binding.threadId, input.binding.environmentSessionId); + const existing = entries.get(key); + if (existing && !sameBinding(existing.binding, input.binding)) throw unavailable(); + if (existing) { + if ( + !(await waitForRegistration( + input.verifyEnvironment().catch(() => false), + input.signal, + )) + ) + throw unavailable(); + await waitForRegistration(existing.ready, input.signal); + if ( + input.signal?.aborted || + entries.get(key) !== existing || + !options.enabled() || + !valid(input.credential, input.binding) + ) + throw unavailable(); + if (input.credential.expiresAt > existing.credential.expiresAt) + existing.credential = { ...input.credential, binding: existing.binding }; + return { threadId: existing.binding.threadId, expiresAt: existing.credential.expiresAt }; + } + const binding = Object.freeze({ ...input.binding }); + const entry: Entry = { + bindingId: NodeCrypto.randomUUID(), + binding, + credential: { ...input.credential, binding }, + verifyEnvironment: input.verifyEnvironment, + ready: Promise.resolve(), + }; + // Reserve synchronously before any verification or socket allocation can yield. + entries.set(key, entry); + const cancel = () => { + if (entries.get(key) !== entry) return; + entries.delete(key); + handles.delete(entry.bindingId); + entry.broker?.revoke(); + void entry.broker?.close(); + }; + input.signal?.addEventListener("abort", cancel, { once: true }); + if (input.signal?.aborted) cancel(); + entry.ready = (async () => { + await waitForRegistration(watch(), input.signal); + if ( + !(await waitForRegistration( + entry.verifyEnvironment().catch(() => false), + input.signal, + )) + ) + throw unavailable(); + if (entries.get(key) !== entry || !options.enabled()) throw unavailable(); + const establishedOwner = owners.get(binding.threadId); + if (establishedOwner && establishedOwner !== binding.subject) throw unavailable(); + owners.set(binding.threadId, binding.subject); + const broker = await waitForRegistration( + createBroker({ + binding, + verifyBinding: async () => + options.enabled() && + entries.get(key) === entry && + (await entry.verifyEnvironment().catch(() => false)), + getCredential: async () => { + if ( + !options.enabled() || + entries.get(key) !== entry || + !valid(entry.credential, binding) + ) + throw unavailable(); + return entry.credential; + }, + }).then(async (created) => { + if (input.signal?.aborted || entries.get(key) !== entry || !options.enabled()) { + await created.close(); + throw unavailable(); + } + return created; + }), + input.signal, + ); + entry.broker = broker; + handles.set(entry.bindingId, entry); + if (entries.get(key) !== entry || !options.enabled()) { + await broker.close(); + throw unavailable(); + } + })(); + try { + await entry.ready; + if (input.signal?.aborted || !valid(entry.credential, binding)) throw unavailable(); + return { threadId: binding.threadId, expiresAt: entry.credential.expiresAt }; + } catch { + if (entries.get(key) === entry) entries.delete(key); + handles.delete(entry.bindingId); + await entry.broker?.close(); + throw unavailable(); + } finally { + input.signal?.removeEventListener("abort", cancel); + } + }; + const revoke = async (threadId: string, environmentSessionId: string) => { + const key = entryKey(threadId, environmentSessionId); + const entry = entries.get(key); + if (!entry) return; + entries.delete(key); + handles.delete(entry.bindingId); + entry.broker?.revoke(); + await entry.ready.catch(() => {}); + await entry.broker?.close(); + }; + const revokeEnvironment = async (environmentSessionId: string) => { + await Promise.all( + [...entries.values()] + .filter((entry) => entry.binding.environmentSessionId === environmentSessionId) + .map((entry) => revoke(entry.binding.threadId, environmentSessionId)), + ); + }; + const endpoint = (threadId: string, bindingId: string | undefined) => { + if (!bindingId) return undefined; + if (!options.enabled()) { + void closeAll(); + return undefined; + } + const entry = handles.get(bindingId); + return entry && + entry.binding.threadId === threadId && + entries.get(entryKey(threadId, entry.binding.environmentSessionId)) === entry && + valid(entry.credential, entry.binding) + ? entry.broker?.endpoint + : undefined; + }; + const authorize = async (threadId: string, environmentSessionId: string) => { + const entry = entries.get(entryKey(threadId, environmentSessionId)); + if (!entry || !endpoint(threadId, entry.bindingId)) return undefined; + const verified = await entry.verifyEnvironment().catch(() => false); + return verified && + options.enabled() && + entries.get(entryKey(threadId, environmentSessionId)) === entry && + endpoint(threadId, entry.bindingId) + ? entry.bindingId + : undefined; + }; + return { + publishActive: ( + threadId: string, + bindingId: string | undefined, + selection?: ModelSelection, + ) => { + if (!options.enabled() || bindingId === undefined) active.delete(threadId); + else active.set(threadId, { bindingId, ...(selection ? { selection } : {}) }); + }, + activeBinding: (threadId: string) => active.get(threadId)?.bindingId, + activeSelection: (threadId: string) => active.get(threadId)?.selection, + register, + revoke, + revokeEnvironment, + endpoint, + authorize, + owns: async (threadId: string, environmentSessionId: string) => + Boolean(await authorize(threadId, environmentSessionId)), + has: (threadId: string) => + owners.has(threadId) || + [...entries.values()].some((entry) => entry.binding.threadId === threadId), + closeAll, + }; +} + +const registry = makeMicPrismThreadRegistry({ + enabled: () => isPrismEnabled() && isPrismIdentityRequired(), + changes: prismEndpointChanges, +}); +export const registerMicPrismThread = registry.register; +/** Server-only actor check; the returned handle conveys no authority outside this process. */ +export const authorizeMicPrismThread = registry.authorize; +export const revokeMicPrismThread = registry.revoke; +export const getMicPrismThreadEndpoint = registry.endpoint; +export const revokeMicPrismEnvironmentSession = registry.revokeEnvironment; +export const closeAllMicPrismThreads = registry.closeAll; +export const publishMicPrismActiveBinding = registry.publishActive; + +const executes = new Set([ + "thread.turn.start", + "thread.approval.respond", + "thread.user-input.respond", + "thread.checkpoint.revert", + "thread.runtime-mode.set", +]); + +type ResolvedSelection = { + readonly selection: ModelSelection | undefined; + readonly routed: boolean; +}; +const stripBinding = (selection: ModelSelection): ModelSelection => ({ + ...selection, + ...(selection.options + ? { options: selection.options.filter((option) => option.id !== MIC_PRISM_BINDING_OPTION) } + : {}), +}); +const stampedSelection = (selection: ModelSelection, bindingId: string): ModelSelection => ({ + ...selection, + options: [ + ...(selection.options ?? []).filter((option) => option.id !== MIC_PRISM_BINDING_OPTION), + { id: MIC_PRISM_BINDING_OPTION, value: bindingId }, + ], +}); +function stripClientBindings(command: OrchestrationCommand): OrchestrationCommand { + let clean = + "modelSelection" in command && command.modelSelection + ? { ...command, modelSelection: stripBinding(command.modelSelection) } + : command; + if (clean.type === "thread.turn.start" && clean.bootstrap?.createThread) { + clean = { + ...clean, + bootstrap: { + ...clean.bootstrap, + createThread: { + ...clean.bootstrap.createThread, + modelSelection: stripBinding(clean.bootstrap.createThread.modelSelection), + }, + }, + }; + } + return clean; +} + +/** Stamp a noncredential handle before persistence; queued commands can never borrow a replacement broker. */ +export const withMicPrismDispatch = Effect.fn("withMicPrismDispatch")(function* ( + engine: OrchestrationEngineShape, + environmentSessionId: string, +) { + const snapshots = yield* ProjectionSnapshotQuery; + const providers = yield* ProviderInstanceRegistry; + return guardMicPrismDispatch(engine, environmentSessionId, { + enabled: () => isPrismEnabled() && isPrismIdentityRequired(), + registry, + resolveSelection: (command) => + Effect.gen(function* () { + if (!("threadId" in command)) return { selection: undefined, routed: false }; + let selection: ModelSelection | undefined = + "modelSelection" in command ? command.modelSelection : undefined; + if (!selection && command.type === "thread.turn.start") + selection = command.bootstrap?.createThread?.modelSelection; + if (!selection) selection = registry.activeSelection(command.threadId); + if (!selection) { + const shell = yield* snapshots + .getThreadShellById(command.threadId) + .pipe(Effect.mapError(() => unavailable())); + selection = Option.isSome(shell) ? shell.value.modelSelection : undefined; + } + const provider = selection ? yield* providers.getInstance(selection.instanceId) : undefined; + return { + selection, + routed: provider?.driverKind === "codex" || provider?.driverKind === "claudeAgent", + }; + }), + }); +}); + +export function guardMicPrismDispatch( + engine: OrchestrationEngineShape, + environmentSessionId: string, + options: { + readonly enabled: () => boolean; + readonly registry: Pick< + ReturnType, + "has" | "authorize" | "activeBinding" + >; + readonly resolveSelection: ( + command: OrchestrationCommand, + ) => Effect.Effect; + }, +): OrchestrationEngineShape { + return { + ...engine, + dispatch: (input, dispatchOptions) => + Effect.gen(function* () { + if (!options.enabled()) return yield* engine.dispatch(input, dispatchOptions); + const command = stripClientBindings(input); + if (!("threadId" in command) || !executes.has(command.type)) + return yield* engine.dispatch(command, dispatchOptions); + const reject = (continuation = false) => + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: continuation + ? "Continue this approval or session action from its initiating device, or start a new turn with Prism connected on this device." + : "Prism authorization required for this environment session. Reconnect mic.sc for this thread.", + }); + const resolved = yield* options + .resolveSelection(command) + .pipe(Effect.mapError(() => reject())); + if (command.type === "thread.turn.start") { + if (!resolved.routed || !resolved.selection) + return yield* engine.dispatch(command, dispatchOptions); + if (prismRoute(resolved.selection) === "direct") { + return yield* engine.dispatch( + { ...command, modelSelection: stampedSelection(resolved.selection, "direct") }, + dispatchOptions, + ); + } + const bindingId = yield* Effect.tryPromise({ + try: () => options.registry.authorize(command.threadId, environmentSessionId), + catch: () => reject(), + }); + if (!bindingId) return yield* reject(); + return yield* engine.dispatch( + { ...command, modelSelection: stampedSelection(resolved.selection, bindingId) }, + dispatchOptions, + ); + } + if (options.registry.has(command.threadId) || resolved.routed) { + const activeHandle = options.registry.activeBinding(command.threadId); + if (activeHandle === "direct") return yield* engine.dispatch(command, dispatchOptions); + const bindingId = yield* Effect.tryPromise({ + try: () => options.registry.authorize(command.threadId, environmentSessionId), + catch: () => reject(true), + }); + if (!bindingId || bindingId !== activeHandle) return yield* reject(true); + } + return yield* engine.dispatch(command, dispatchOptions); + }), + }; +} + +export const micPrismEngineForSession = (environmentSessionId: string) => + Effect.flatMap(OrchestrationEngineService, (engine) => + withMicPrismDispatch(engine, environmentSessionId), + ); diff --git a/apps/server/src/fork/mic-identity/PrismInferenceBroker.test.ts b/apps/server/src/fork/mic-identity/PrismInferenceBroker.test.ts new file mode 100644 index 000000000000..ad50696a961c --- /dev/null +++ b/apps/server/src/fork/mic-identity/PrismInferenceBroker.test.ts @@ -0,0 +1,320 @@ +// @effect-diagnostics globalDate:off globalFetch:off - Exercises the real loopback HTTP boundary and epoch-based credential expiry. +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { + createPrismInferenceBroker, + type PrismInferenceBinding, + type PrismInferenceBrokerOptions, +} from "./PrismInferenceBroker.ts"; + +const binding: PrismInferenceBinding = { + environmentSessionId: "env-session", + subject: "member", + sessionId: "clerk-session", + threadId: "thread-one", + serviceInstanceId: "prism-one", + pairingRevision: 1, + inferenceOrigin: "https://prism.example.test", +}; +const token = "msp1.fixture.signature"; +const brokers: Awaited>[] = []; +afterEach(async () => { + await Promise.all(brokers.splice(0).map((broker) => broker.close())); +}); +async function make(overrides: Partial = {}) { + const broker = await createPrismInferenceBroker({ + binding, + verifyBinding: async () => true, + getCredential: async () => ({ binding, token, expiresAt: Date.now() + 60_000 }), + fetch: async () => + new Response('{"ok":true}', { headers: { "content-type": "application/json" } }), + ...overrides, + }); + brokers.push(broker); + return broker; +} +function call(broker: Awaited>, path = "/v1/models", init?: RequestInit) { + return fetch(broker.endpoint.baseUrl + path, { + ...init, + headers: { authorization: `Bearer ${broker.endpoint.apiKey}`, ...init?.headers }, + }); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe("Prism per-thread inference broker", () => { + it("renews each request, forwards exact inference bytes, and strips unrelated credentials", async () => { + const calls: { url: string; headers: Headers; body?: string }[] = []; + let renewals = 0; + const broker = await make({ + getCredential: async () => ({ + binding, + token: `msp1.fixture${++renewals}.signature`, + expiresAt: Date.now() + 60_000, + }), + fetch: async (url, init) => { + calls.push({ + url: String(url), + headers: new Headers(init?.headers), + ...(init?.body ? { body: Buffer.from(init.body as Uint8Array).toString() } : {}), + }); + expect(init?.redirect).toBe("error"); + return new Response("data: complete\n\n", { + headers: { + "content-type": "text/event-stream", + "set-cookie": "private=fixture", + location: "https://elsewhere.test", + }, + }); + }, + }); + const payload = + '{"model":"chosen-model","messages":[{"role":"tool","content":"exact result"}]}'; + const first = await call(broker, "/v1/messages?beta=true", { + method: "POST", + body: payload, + headers: { + "content-type": "application/json", + cookie: "environment=fixture", + "x-forwarded-for": "attacker", + "anthropic-version": "2023-06-01", + }, + }); + expect(await first.text()).toBe("data: complete\n\n"); + expect(first.headers.get("set-cookie")).toBeNull(); + expect(first.headers.get("location")).toBeNull(); + expect(first.headers.get("x-prism-fallback-allowed")).toBe("false"); + await (await call(broker)).text(); + expect(calls).toHaveLength(2); + expect(calls[0]?.url).toBe(binding.inferenceOrigin + "/v1/messages?beta=true"); + expect(calls[0]?.body).toBe(payload); + expect(calls[0]?.headers.get("authorization")).toBe("Bearer msp1.fixture1.signature"); + expect(calls[1]?.headers.get("authorization")).toBe("Bearer msp1.fixture2.signature"); + expect(calls[0]?.headers.get("cookie")).toBeNull(); + expect(calls[0]?.headers.get("x-forwarded-for")).toBeNull(); + }); + + it("denies management, alternate paths, browser origins and missing/wrong local keys before authority", async () => { + let verified = 0; + const broker = await make({ + verifyBinding: async () => { + verified++; + return true; + }, + }); + for (const path of [ + "/prism/v1/routing", + "/v0/management/auth-files", + "/v1/models?url=elsewhere", + "/v1/%6dodels", + "/v1/responses/", + ]) { + expect((await call(broker, path)).status).toBe(404); + } + expect((await fetch(broker.endpoint.baseUrl + "/v1/models")).status).toBe(401); + expect( + (await call(broker, undefined, { headers: { authorization: "Bearer wrong" } })).status, + ).toBe(401); + expect( + (await call(broker, undefined, { headers: { origin: "https://browser.test" } })).status, + ).toBe(403); + expect(verified).toBe(0); + }); + + it("never substitutes another environment, actor, session, thread or host", async () => { + let forwarded = 0; + for (const change of [ + { environmentSessionId: "different" }, + { subject: "different" }, + { sessionId: "different" }, + { threadId: "different" }, + { serviceInstanceId: "different" }, + { pairingRevision: 2 }, + { inferenceOrigin: "https://different.test" }, + ]) { + const broker = await make({ + getCredential: async () => ({ + binding: { ...binding, ...change }, + token, + expiresAt: Date.now() + 60_000, + }), + fetch: async () => { + forwarded++; + return new Response("bad"); + }, + }); + expect((await call(broker)).status).toBe(403); + } + expect(forwarded).toBe(0); + }); + + it("rejects expired credentials and failed authority without a retry or token-bearing errors", async () => { + let forwarded = 0; + const expired = await make({ + getCredential: async () => ({ binding, token, expiresAt: Date.now() - 1 }), + fetch: async () => { + forwarded++; + return new Response("bad"); + }, + }); + expect((await call(expired)).status).toBe(403); + let renewed = 0; + const failed = await make({ + getCredential: async () => { + renewed++; + throw new Error(token); + }, + }); + const response = await call(failed); + expect(response.status).toBe(503); + expect(await response.text()).not.toContain(token); + expect(renewed).toBe(1); + expect(forwarded).toBe(0); + const denied = await make({ + verifyBinding: async () => false, + getCredential: async () => { + renewed++; + throw new Error("must not renew"); + }, + }); + expect((await call(denied)).status).toBe(403); + expect(renewed).toBe(1); + }); + + it("preserves quota errors and refuses automatic fallback", async () => { + let calls = 0; + const broker = await make({ + fetch: async () => { + calls++; + return new Response('{"error":{"code":"quota_exhausted"}}', { + status: 429, + headers: { "retry-after": "60" }, + }); + }, + }); + const response = await call(broker); + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe("60"); + expect(response.headers.get("x-prism-fallback-allowed")).toBe("false"); + expect(await response.json()).toEqual({ error: { code: "quota_exhausted" } }); + expect(calls).toBe(1); + }); + + it("redacts known credentials split across streamed chunks", async () => { + const broker = await make({ + fetch: async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("prefix msp1.fix")); + controller.enqueue(new TextEncoder().encode("ture.signature suffix")); + controller.close(); + }, + }), + ), + }); + expect(await (await call(broker)).text()).toBe("prefix [redacted] suffix"); + }); + + it("revocation aborts active upstream and downstream streams and rejects new requests", async () => { + const cancelled = deferred(); + const broker = await make({ + fetch: async (_url, init) => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: first\n\n")); + init?.signal?.addEventListener( + "abort", + () => { + controller.error(new Error("revoked")); + cancelled.resolve(); + }, + { once: true }, + ); + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ), + }); + const response = await call(broker); + const reader = response.body!.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toBe("data: first\n\n"); + broker.revoke(); + await cancelled.promise; + await expect(reader.read()).rejects.toThrow(); + expect((await call(broker)).status).toBe(403); + await broker.close(); + await broker.close(); + }); + + it("propagates provider disconnect upstream without replay", async () => { + const cancelled = deferred(); + let calls = 0; + const broker = await make({ + fetch: async (_url, init) => { + calls++; + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("data: first\n\n")); + init.signal?.addEventListener( + "abort", + () => { + controller.error(new Error("disconnected")); + cancelled.resolve(); + }, + { once: true }, + ); + }, + }), + ); + }, + }); + const response = await call(broker); + const reader = response.body!.getReader(); + await reader.read(); + await reader.cancel(); + await cancelled.promise; + expect(calls).toBe(1); + }); + + it("cancels an in-flight renewal on close without starting inference", async () => { + const entered = deferred(); + const cancelled = deferred(); + let calls = 0; + const broker = await make({ + getCredential: async (_binding, signal) => { + signal.addEventListener("abort", () => cancelled.resolve(), { once: true }); + entered.resolve(); + return new Promise(() => {}); + }, + fetch: async () => { + calls++; + return new Response("bad"); + }, + }); + const result = call(broker).then( + (response) => response.status, + () => 0, + ); + await entered.promise; + await broker.close(); + await cancelled.promise; + expect([0, 403]).toContain(await result); + expect(calls).toBe(0); + }); + + it("isolates provider bearer keys between concurrent thread brokers", async () => { + const one = await make(); + const two = await make({ binding: { ...binding, threadId: "thread-two" } }); + const response = await fetch(two.endpoint.baseUrl + "/v1/models", { + headers: { authorization: `Bearer ${one.endpoint.apiKey}` }, + }); + expect(response.status).toBe(401); + }); +}); diff --git a/apps/server/src/fork/mic-identity/PrismInferenceBroker.ts b/apps/server/src/fork/mic-identity/PrismInferenceBroker.ts new file mode 100644 index 000000000000..d2945ba58342 --- /dev/null +++ b/apps/server/src/fork/mic-identity/PrismInferenceBroker.ts @@ -0,0 +1,346 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off - This isolated Node HTTP adapter owns socket and native-fetch cancellation deadlines. +import * as NodeCrypto from "node:crypto"; +import * as NodeHttp from "node:http"; + +/** Established only after independent environment and mic.sc authorization. */ +export interface PrismInferenceBinding { + readonly environmentSessionId: string; + readonly subject: string; + readonly sessionId: string; + readonly threadId: string; + readonly serviceInstanceId: string; + readonly pairingRevision: number; + readonly inferenceOrigin: string; +} + +export interface PrismBrokerCredential { + readonly binding: PrismInferenceBinding; + readonly token: string; + readonly expiresAt: number; +} + +export interface PrismInferenceBrokerOptions { + readonly binding: PrismInferenceBinding; + /** Verify the original environment session/actor still owns this thread binding. */ + readonly verifyBinding: (binding: PrismInferenceBinding, signal: AbortSignal) => Promise; + /** Renew through the connected client's fresh Clerk session; never substitute another actor. */ + readonly getCredential: ( + binding: PrismInferenceBinding, + signal: AbortSignal, + ) => Promise; + readonly fetch?: (url: string, init: RequestInit) => Promise; +} + +const fields = [ + "environmentSessionId", + "subject", + "sessionId", + "threadId", + "serviceInstanceId", + "pairingRevision", + "inferenceOrigin", +] as const; +const postPaths = new Set([ + "/v1/chat/completions", + "/v1/completions", + "/v1/messages", + "/v1/messages?beta=true", + "/v1/messages/count_tokens", + "/v1/messages/count_tokens?beta=true", + "/v1/responses", + "/v1/responses/compact", +]); +const requestHeaders = [ + "content-type", + "accept", + "anthropic-version", + "anthropic-beta", + "openai-beta", +]; +const responseHeaders = ["content-type", "retry-after", "x-request-id", "request-id"]; +const MAX_BODY_BYTES = 16 * 1024 * 1024; + +function equalSecret(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + return a.length === b.length && NodeCrypto.timingSafeEqual(a, b); +} + +function fail(response: NodeHttp.ServerResponse, status: number, code: string) { + if (response.destroyed) return; + if (response.headersSent) return response.destroy(); + response.writeHead(status, { + "content-type": "application/json", + "cache-control": "no-store", + "x-prism-fallback-allowed": "false", + }); + response.end(JSON.stringify({ error: { code }, prism: { fallbackAllowed: false } })); +} + +async function readBody(request: NodeHttp.IncomingMessage, signal: AbortSignal): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const value of request) { + signal.throwIfAborted(); + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + size += chunk.length; + if (size > MAX_BODY_BYTES) throw new Error("body-too-large"); + chunks.push(chunk); + } + return Buffer.concat(chunks); +} + +/** Stream bytes unchanged except exact known credentials, including across chunk boundaries. */ +async function relay( + body: ReadableStream, + response: NodeHttp.ServerResponse, + secrets: readonly string[], + signal: AbortSignal, +) { + const needles = secrets.map((secret) => Buffer.from(secret)); + const reader = body.getReader(); + let pending = Buffer.alloc(0); + const write = async (bytes: Buffer) => { + if (bytes.length && !response.write(bytes)) + await new Promise((resolve, reject) => { + const cleanup = () => { + response.off("drain", drained); + signal.removeEventListener("abort", aborted); + }; + const drained = () => { + cleanup(); + resolve(); + }; + const aborted = () => { + cleanup(); + reject(new Error("Prism request cancelled.")); + }; + response.once("drain", drained); + signal.addEventListener("abort", aborted, { once: true }); + if (signal.aborted) aborted(); + }); + }; + try { + while (true) { + signal.throwIfAborted(); + const next = await abortable(reader.read(), signal); + pending = Buffer.concat([pending, next.done ? Buffer.alloc(0) : Buffer.from(next.value)]); + for (const needle of needles) { + let at = pending.indexOf(needle); + while (at !== -1) { + pending = Buffer.concat([ + pending.subarray(0, at), + Buffer.from("[redacted]"), + pending.subarray(at + needle.length), + ]); + at = pending.indexOf(needle, at + 10); + } + } + let keep = 0; + if (!next.done) { + for (const needle of needles) { + for (let n = Math.min(needle.length - 1, pending.length); n > keep; n--) { + if (pending.subarray(pending.length - n).equals(needle.subarray(0, n))) { + keep = n; + break; + } + } + } + } + await write(pending.subarray(0, pending.length - keep)); + pending = pending.subarray(pending.length - keep); + if (next.done) break; + } + response.end(); + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +/** + * One process-local provider endpoint per authorized thread. Its random key grants + * only this binding; it is not a Prism credential and must not be persisted. + * The caller must revoke when its environment/session/identity binding ends. + * Gateway stream leases independently enforce live mic.sc and host revocation. + */ +export async function createPrismInferenceBroker(options: PrismInferenceBrokerOptions) { + const binding = Object.freeze({ ...options.binding }); + const origin = new URL(binding.inferenceOrigin); + if ( + origin.origin !== binding.inferenceOrigin || + origin.username || + origin.password || + !( + origin.protocol === "https:" || + (origin.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(origin.hostname)) + ) || + !Number.isSafeInteger(binding.pairingRevision) || + binding.pairingRevision < 1 || + fields.some( + (field) => field !== "pairingRevision" && (!binding[field] || /[\r\n]/.test(binding[field])), + ) + ) { + throw new Error("Invalid Prism inference binding."); + } + const apiKey = NodeCrypto.randomBytes(32).toString("base64url"); + const fetchGateway = options.fetch ?? globalThis.fetch; + const lifetime = new AbortController(); + let closed = false; + let expectedHost = ""; + const server = NodeHttp.createServer((request, response) => { + const run = async () => { + if (closed) return fail(response, 403, "prism_binding_revoked"); + if (request.headers.origin || request.headers.host !== expectedHost) + return fail(response, 403, "prism_origin_denied"); + const target = request.url ?? ""; + if ( + !( + (request.method === "GET" && target === "/v1/models") || + (request.method === "POST" && postPaths.has(target)) + ) + ) + return fail(response, 404, "prism_route_unavailable"); + const bearer = request.headers.authorization; + const apiHeader = request.headers["x-api-key"]; + if ( + (!bearer && !apiHeader) || + (bearer && !equalSecret(bearer, `Bearer ${apiKey}`)) || + (apiHeader && (typeof apiHeader !== "string" || !equalSecret(apiHeader, apiKey))) + ) + return fail(response, 401, "prism_authentication_required"); + const disconnected = new AbortController(); + const abort = () => disconnected.abort(); + request.on("aborted", abort); + response.on("close", abort); + const signal = AbortSignal.any([lifetime.signal, disconnected.signal]); + // Verification and renewal may involve browser RPC; abandoned callbacks + // must not hold a request open or authorize after their deadline. + const admission = AbortSignal.any([signal, AbortSignal.timeout(10_000)]); + try { + const checked = await abortable(options.verifyBinding(binding, admission), admission); + if (!checked) { + closed = true; + lifetime.abort(); + return fail(response, 403, "prism_binding_revoked"); + } + const credential = await abortable(options.getCredential(binding, admission), admission); + admission.throwIfAborted(); + if ( + fields.some((field) => credential.binding[field] !== binding[field]) || + credential.token.length > 8192 || + !/^msp1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(credential.token) || + !Number.isSafeInteger(credential.expiresAt) || + credential.expiresAt <= Date.now() || + credential.expiresAt > Date.now() + 930_000 + ) + return fail(response, 403, "prism_credential_invalid"); + const headers = new Headers({ authorization: `Bearer ${credential.token}` }); + for (const name of requestHeaders) { + const value = request.headers[name]; + if (typeof value === "string") headers.set(name, value); + } + const body = + request.method === "POST" + ? await abortable(readBody(request, admission), admission) + : undefined; + admission.throwIfAborted(); + if (credential.expiresAt <= Date.now()) + return fail(response, 401, "prism_credential_expired"); + const headerDeadline = new AbortController(); + const timeout = setTimeout(() => headerDeadline.abort(), 30_000); + let upstream: Response; + try { + const upstreamSignal = AbortSignal.any([signal, headerDeadline.signal]); + upstream = await abortable( + fetchGateway(`${binding.inferenceOrigin}${target}`, { + method: request.method, + headers, + ...(body ? { body } : {}), + redirect: "error", + credentials: "omit", + cache: "no-store", + signal: upstreamSignal, + }), + upstreamSignal, + ); + } finally { + clearTimeout(timeout); + } + signal.throwIfAborted(); + response.statusCode = upstream.status; + response.setHeader("cache-control", "no-store"); + response.setHeader("x-prism-fallback-allowed", "false"); + for (const name of responseHeaders) { + const value = upstream.headers.get(name); + if (value && !value.includes(apiKey) && !value.includes(credential.token)) + response.setHeader(name, value); + } + if (upstream.body) await relay(upstream.body, response, [apiKey, credential.token], signal); + else response.end(); + } catch { + fail( + response, + closed ? 403 : 503, + closed ? "prism_binding_revoked" : "prism_inference_unavailable", + ); + } finally { + request.off("aborted", abort); + response.off("close", abort); + } + }; + void run().catch(() => fail(response, 503, "prism_inference_unavailable")); + }); + server.requestTimeout = 30_000; + server.headersTimeout = 10_000; + server.on("clientError", (_error, socket) => socket.destroy()); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Prism broker did not bind loopback."); + expectedHost = `127.0.0.1:${address.port}`; + let closing: Promise | undefined; + const revoke = () => { + closed = true; + lifetime.abort(); + }; + const close = () => { + if (!closing) { + revoke(); + closing = new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); + } + return closing; + }; + return { + binding, + endpoint: Object.freeze({ baseUrl: `http://${expectedHost}`, apiKey }), + revoke, + close, + }; +} + +async function abortable(operation: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + let onAbort: () => void = () => {}; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + onAbort = () => reject(new Error("Prism request cancelled.")); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }), + ]); + } finally { + signal.removeEventListener("abort", onAbort); + } +} diff --git a/apps/server/src/fork/prism/CodexProxyHome.ts b/apps/server/src/fork/prism/CodexProxyHome.ts index 6891f939ea59..ebbfd7d31c93 100644 --- a/apps/server/src/fork/prism/CodexProxyHome.ts +++ b/apps/server/src/fork/prism/CodexProxyHome.ts @@ -18,7 +18,9 @@ import * as Schema from "effect/Schema"; import type { PrismEndpoint } from "./PrismEnvironment.ts"; /** What the Codex home needs from the proxy handoff: the origin and the client key, never the management secret. */ -export type CodexProxyEndpoint = Pick; +export type CodexProxyEndpoint = Pick & { + readonly apiKeyEnv?: string; +}; export const CODEX_PROXY_PROVIDER_ID = "q1code"; @@ -45,7 +47,9 @@ export const renderCodexProxyConfigToml = (endpoint: CodexProxyEndpoint): string `name = ${tomlString("q1code CLIProxyAPI")}`, `base_url = ${tomlString(`${endpoint.baseUrl}/v1`)}`, 'wire_api = "responses"', - `http_headers = { Authorization = ${tomlString(`Bearer ${endpoint.apiKey}`)} }`, + endpoint.apiKeyEnv + ? `env_key = ${tomlString(endpoint.apiKeyEnv)}` + : `http_headers = { Authorization = ${tomlString(`Bearer ${endpoint.apiKey}`)} }`, "", ].join("\n"); diff --git a/apps/server/src/fork/prism/PrismEnvironment.test.ts b/apps/server/src/fork/prism/PrismEnvironment.test.ts index e04df1bec0d0..4cb6987ebbb8 100644 --- a/apps/server/src/fork/prism/PrismEnvironment.test.ts +++ b/apps/server/src/fork/prism/PrismEnvironment.test.ts @@ -11,6 +11,8 @@ import { prismUsageLimitSource, prismUsageSourceChanges, publishPrismEndpoint, + publishPrismIdentityRequired, + currentPrismEndpoint, withPrismUsageLimitSource, } from "./PrismEnvironment.ts"; @@ -34,6 +36,46 @@ const prismEntry = { enabled: true, } as const; +it.effect( + "identity mode hides shared serving and management access and restores flags-off behavior", + () => + Effect.gen(function* () { + publishPrismIdentityRequired(false); + publishPrismEndpoint(endpoint); + const entries = [hub("https://hub.example")]; + const collected = yield* prismUsageSourceChanges.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + Effect.tap(() => Effect.yieldNow), + ); + try { + assert.strictEqual(currentPrismEndpoint(), endpoint); + publishPrismIdentityRequired(true); + assert.isUndefined(currentPrismEndpoint()); + assert.isUndefined(prismUsageLimitSource()); + assert.strictEqual(withPrismUsageLimitSource(entries), entries); + + // Re-publication cannot expose a key while human authorization is required. + const renewed = { ...endpoint, apiKey: "renewed-fixture-key" }; + publishPrismEndpoint(renewed); + assert.isUndefined(currentPrismEndpoint()); + assert.isUndefined(prismUsageLimitSource()); + publishPrismIdentityRequired(false); + assert.strictEqual(currentPrismEndpoint(), renewed); + assert.deepEqual(prismUsageLimitSource(), [UsageLimitSourceId.make("prism"), prismEntry]); + const events = yield* Fiber.join(collected); + assert.deepEqual( + events.map((entry) => entry?.[1].url), + [undefined, endpoint.baseUrl], + ); + } finally { + publishPrismEndpoint(undefined); + publishPrismIdentityRequired(false); + } + }), +); + it("publishes no usage-limit source while off or while the toggle is off", () => { publishPrismEndpoint(undefined); const entries = [hub("https://hub.example")]; diff --git a/apps/server/src/fork/prism/PrismEnvironment.ts b/apps/server/src/fork/prism/PrismEnvironment.ts index a1a546f8d0ff..c9582938c8b8 100644 --- a/apps/server/src/fork/prism/PrismEnvironment.ts +++ b/apps/server/src/fork/prism/PrismEnvironment.ts @@ -32,9 +32,20 @@ export type PrismUsageLimitSourceEntry = readonly [UsageLimitSourceId, UsageLimi let published: PrismEndpoint | undefined; let enabled = false; +let identityRequired = false; const endpointEvents = Effect.runSync(PubSub.unbounded()); export const prismEndpointChanges = Stream.fromPubSub(endpointEvents); export const isPrismEnabled = () => enabled; +export const isPrismIdentityRequired = () => identityRequired; +/** Identity mode cannot publish shared serving or management keys to provider/usage consumers. */ +export const publishPrismIdentityRequired = (value: boolean) => { + if (identityRequired === value) return; + const before = prismUsageLimitSource(); + identityRequired = value; + PubSub.publishUnsafe(endpointEvents, undefined); + const after = prismUsageLimitSource(); + if (!sameEntry(before, after)) PubSub.publishUnsafe(usageSourcePubSub, after); +}; export const publishPrismEnabled = (value: boolean) => { if (enabled === value) return; enabled = value; @@ -64,7 +75,8 @@ export const publishPrismEndpoint = (endpoint: PrismEndpoint | undefined): void if (!sameEntry(before, after)) PubSub.publishUnsafe(usageSourcePubSub, after); }; -export const currentPrismEndpoint = (): PrismEndpoint | undefined => published; +export const currentPrismEndpoint = (): PrismEndpoint | undefined => + identityRequired ? undefined : published; /** * Fires whenever the Prism usage-limit source appears, disappears, or points @@ -83,7 +95,7 @@ export const refreshOnPrismUsageSourceChange = ( /** The `usageLimitSources` entry for the proxy; defined only while it is ready and `prism.usageSource` is on. */ export const prismUsageLimitSource = (): PrismUsageLimitSourceEntry | undefined => { const endpoint = published; - if (endpoint === undefined || !endpoint.usageSource) return undefined; + if (identityRequired || endpoint === undefined || !endpoint.usageSource) return undefined; return [ UsageLimitSourceId.make(PRISM_USAGE_SOURCE_ID), { diff --git a/apps/server/src/fork/prism/PrismHttpApi.ts b/apps/server/src/fork/prism/PrismHttpApi.ts index 5a7d860671bf..2411f5affb9f 100644 --- a/apps/server/src/fork/prism/PrismHttpApi.ts +++ b/apps/server/src/fork/prism/PrismHttpApi.ts @@ -28,6 +28,9 @@ import { } from "@q1code/core/prismApi"; import { PrismRoutingStrategy } from "@q1code/core/config"; import { AuthAccessWriteScope, AuthOrchestrationReadScope } from "@t3tools/contracts"; +import type { MicIdentityAccess } from "@q1code/core/micIdentityApi"; +import { MIC_IDENTITY_SESSION_HEADER, MicIdentityUnavailableError } from "@q1code/core/micIdentity"; +import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -36,8 +39,12 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as Schema from "effect/Schema"; import { HttpClientResponse } from "effect/unstable/http"; +import * as Headers from "effect/unstable/http/Headers"; +import * as HttpEffect from "effect/unstable/http/HttpEffect"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { @@ -46,12 +53,31 @@ import { requireEnvironmentScope, } from "../../auth/http.ts"; import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; +import * as SessionStore from "../../auth/SessionStore.ts"; +import { + connectMicPrismThreadRequest, + disconnectMicPrismThreadRequest, +} from "../mic-identity/MicPrismThreadHttp.ts"; +import { + closeAllMicPrismThreads, + revokeMicPrismEnvironmentSession, +} from "../mic-identity/MicPrismThreads.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import * as ForkFlags from "../ForkFlags.ts"; import { prismAuthsDir, prismDirectories } from "./PrismConfig.ts"; import * as Prism from "./PrismService.ts"; import * as PrismSync from "./PrismSync.ts"; +import { + micIdentityPublicConfig, + requireMicIdentity, + requirePairedPrismTarget, +} from "../mic-identity/MicIdentityAccess.ts"; + +const CurrentMicIdentityAccess = Context.Reference( + "q1code/CurrentMicIdentityAccess", + { defaultValue: () => undefined }, +); /** What we read from the sidecar; everything else it returns is ignored. */ const AuthFileEntry = Schema.Struct({ @@ -149,6 +175,17 @@ export const prismHttpApiLayer = HttpApiBuilder.group( const proxy = yield* Prism.PrismService; const sync = yield* PrismSync.PrismSyncService; const flags = yield* ForkFlags.ForkFlagsService; + const sessions = yield* Effect.serviceOption(SessionStore.SessionStore); + if (Option.isSome(sessions)) + yield* sessions.value.streamChanges.pipe( + Stream.runForEach((change) => + change.type === "clientRemoved" + ? Effect.promise(() => revokeMicPrismEnvironmentSession(change.sessionId)) + : Effect.void, + ), + Effect.forkScoped, + ); + yield* Effect.addFinalizer(() => Effect.promise(closeAllMicPrismThreads)); const config = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -354,55 +391,136 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ), ); - const withRead = (name: string, body: Effect.Effect) => - annotateEnvironmentRequest(name).pipe( - Effect.andThen(requireEnvironmentScope(AuthOrchestrationReadScope)), - Effect.andThen(body), + const privateResponse = HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "cache-control", "no-store")), + ); + const withAccess = (name: string, body: Effect.Effect, write: boolean) => + Effect.gen(function* () { + yield* annotateEnvironmentRequest(name); + const identityEnabled = (yield* flags.current)["mic-identity"]; + yield* requireEnvironmentScope( + write && !identityEnabled ? AuthAccessWriteScope : AuthOrchestrationReadScope, + ); + yield* privateResponse; + const permission = + name === "status" + ? "prism:inference" + : name === "getRouting" + ? "prism:routing:read" + : name === "setRouting" + ? "prism:routing:write" + : write + ? "prism:accounts:write" + : "prism:accounts:read"; + const access = yield* requireMicIdentity(permission).pipe( + Effect.provideService(ForkFlags.ForkFlagsService, flags), + ); + const localOperation = ["restart", "setUsageSource", "syncExport", "syncPush"].includes( + name, + ); + if (write && (!access || localOperation)) + yield* requireEnvironmentScope(AuthAccessWriteScope); + if (access && name !== "status") + return yield* new MicIdentityUnavailableError({ reason: "unsupported-operation" }); + if (access) + yield* requirePairedPrismTarget(access.discovery.service!.apiUrl).pipe( + Effect.provideService(ForkFlags.ForkFlagsService, flags), + ); + return yield* body.pipe(Effect.provideService(CurrentMicIdentityAccess, access)); + }).pipe( + Effect.provideServiceEffect( + Headers.CurrentRedactedNames, + Effect.map(Headers.CurrentRedactedNames, (names) => [ + ...names, + MIC_IDENTITY_SESSION_HEADER, + ]), + ), ); + const withRead = (name: string, body: Effect.Effect) => + withAccess(name, body, false); const withWrite = (name: string, body: Effect.Effect) => - annotateEnvironmentRequest(name).pipe( - Effect.andThen(requireEnvironmentScope(AuthAccessWriteScope)), - Effect.andThen(body), - ); + withAccess(name, body, true); /** The proxy status plus the sync status, in the wire shape. */ const fullStatus = (status: Prism.PrismStatus) => - sync.status.pipe( - Effect.map( - (syncStatus) => - ({ - state: status.state, - port: status.port, - ...(status.version !== undefined ? { version: status.version } : {}), - role: syncStatus.role, - ...(syncStatus.lastSyncAt !== undefined ? { lastSyncAt: syncStatus.lastSyncAt } : {}), - ...(syncStatus.lastSyncError !== undefined - ? { lastSyncError: syncStatus.lastSyncError } - : {}), - mode: status.mode, - ...(status.baseUrl !== undefined ? { baseUrl: status.baseUrl } : {}), - ...(status.lastError !== undefined ? { lastError: status.lastError } : {}), - restarts: status.restarts, - since: status.since, - usageSource: status.usageSource, - }) satisfies PrismStatus, - ), - ); + Effect.gen(function* () { + const syncStatus = yield* sync.status; + const identity = yield* CurrentMicIdentityAccess; + if (identity && !identity.session.capabilities.accountDetails) { + return { + state: status.state, + port: 0, + role: "standalone" as const, + capabilities: identity.session.capabilities, + } satisfies PrismStatus; + } + return { + state: status.state, + port: status.port, + ...(status.version !== undefined ? { version: status.version } : {}), + role: syncStatus.role, + ...(syncStatus.lastSyncAt !== undefined ? { lastSyncAt: syncStatus.lastSyncAt } : {}), + ...(syncStatus.lastSyncError !== undefined + ? { lastSyncError: syncStatus.lastSyncError } + : {}), + mode: status.mode, + ...(status.baseUrl !== undefined ? { baseUrl: status.baseUrl } : {}), + ...(status.lastError !== undefined ? { lastError: status.lastError } : {}), + restarts: status.restarts, + since: status.since, + usageSource: status.usageSource, + ...(identity ? { capabilities: identity.session.capabilities } : {}), + } satisfies PrismStatus; + }); return handlers - .handle("status", (args) => - withRead(args.endpoint.name, proxy.status.pipe(Effect.flatMap(fullStatus))), + .handle("connectIdentityThread", ({ params }) => + annotateEnvironmentRequest("connectIdentityThread").pipe( + Effect.andThen(privateResponse), + Effect.andThen(connectMicPrismThreadRequest(params.threadId)), + Effect.provideService(ForkFlags.ForkFlagsService, flags), + ), + ) + .handle("disconnectIdentityThread", ({ params }) => + annotateEnvironmentRequest("disconnectIdentityThread").pipe( + Effect.andThen(privateResponse), + Effect.andThen(disconnectMicPrismThreadRequest(params.threadId)), + ), + ) + .handle("identityConfig", () => + annotateEnvironmentRequest("identityConfig").pipe( + Effect.andThen(requireEnvironmentScope(AuthOrchestrationReadScope)), + Effect.andThen(privateResponse), + Effect.andThen( + micIdentityPublicConfig.pipe(Effect.provideService(ForkFlags.ForkFlagsService, flags)), + ), + ), + ) + .handle("identityAccess", () => + annotateEnvironmentRequest("identityAccess").pipe( + Effect.andThen(requireEnvironmentScope(AuthOrchestrationReadScope)), + Effect.andThen(privateResponse), + Effect.andThen( + requireMicIdentity().pipe(Effect.provideService(ForkFlags.ForkFlagsService, flags)), + ), + Effect.flatMap((access) => + access + ? Effect.succeed(access) + : Effect.fail(new MicIdentityUnavailableError({ reason: "configuration" })), + ), + ), ) - .handle("restart", (args) => + .handle("status", () => withRead("status", proxy.status.pipe(Effect.flatMap(fullStatus)))) + .handle("restart", () => withWrite( - args.endpoint.name, + "restart", requireFlag.pipe(Effect.andThen(proxy.restart), Effect.flatMap(fullStatus)), ), ) .handle("setUsageSource", (args) => withWrite( - args.endpoint.name, + "setUsageSource", requireFlag.pipe( Effect.andThen(persistPrismSection({ usageSource: args.payload.enabled })), Effect.andThen(proxy.reloadUsageSource), @@ -410,9 +528,9 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ), ), ) - .handle("listAccounts", (args) => + .handle("listAccounts", () => withRead( - args.endpoint.name, + "listAccounts", requireReady.pipe( Effect.andThen(listAccounts), Effect.map((accounts) => ({ accounts })), @@ -421,7 +539,7 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("startLogin", (args) => withWrite( - args.endpoint.name, + "startLogin", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -445,13 +563,13 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("loginStatus", (args) => withRead( - args.endpoint.name, + "loginStatus", requireReady.pipe(Effect.andThen(loginStatus(args.params.sessionId))), ), ) .handle("loginCallback", (args) => withWrite( - args.endpoint.name, + "loginCallback", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -469,7 +587,7 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("cancelLogin", (args) => withWrite( - args.endpoint.name, + "cancelLogin", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -489,7 +607,7 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("patchAccount", (args) => withWrite( - args.endpoint.name, + "patchAccount", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -515,7 +633,7 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("deleteAccount", (args) => withWrite( - args.endpoint.name, + "deleteAccount", Effect.gen(function* () { yield* requireReady; yield* requireAccountOwner; @@ -536,12 +654,12 @@ export const prismHttpApiLayer = HttpApiBuilder.group( }), ), ) - .handle("getRouting", (args) => - withRead(args.endpoint.name, requireReady.pipe(Effect.andThen(getRouting))), + .handle("getRouting", () => + withRead("getRouting", requireReady.pipe(Effect.andThen(getRouting))), ) .handle("setRouting", (args) => withWrite( - args.endpoint.name, + "setRouting", requireReady.pipe( Effect.andThen( call(Ignored, "/routing/strategy", json("PUT", { value: args.payload.strategy })), @@ -551,9 +669,9 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ), ), ) - .handle("getUsage", (args) => + .handle("getUsage", () => withRead( - args.endpoint.name, + "getUsage", requireReady.pipe( Effect.andThen(call(UsageResponse, "/api-key-usage")), Effect.map((usage): PrismUsage => @@ -576,9 +694,9 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ), ), ) - .handle("syncExport", (args) => + .handle("syncExport", () => withWrite( - args.endpoint.name, + "syncExport", requireFlag.pipe( Effect.andThen(sync.exportBundle), Effect.catchTag("PrismSyncNotConfigured", () => unavailable("sync-not-configured")), @@ -587,14 +705,14 @@ export const prismHttpApiLayer = HttpApiBuilder.group( ) .handle("syncPush", (args) => withWrite( - args.endpoint.name, + "syncPush", requireFlag.pipe( Effect.andThen(sync.applyPush(args.payload.entries, args.payload.tombstones ?? [])), Effect.catchTag("PrismSyncNotConfigured", () => unavailable("sync-not-configured")), ), ), ) - .handle("syncStatus", (args) => withRead(args.endpoint.name, sync.status)); + .handle("syncStatus", () => withRead("syncStatus", sync.status)); }), ); diff --git a/apps/server/src/fork/prism/PrismProviderDriver.ts b/apps/server/src/fork/prism/PrismProviderDriver.ts index eff0309055fd..c8ab85d2f8cb 100644 --- a/apps/server/src/fork/prism/PrismProviderDriver.ts +++ b/apps/server/src/fork/prism/PrismProviderDriver.ts @@ -1,5 +1,5 @@ import { expandHomePath } from "../../pathExpansion.ts"; -import type { ServerProvider } from "@t3tools/contracts"; +import type { ModelSelection, ThreadId, ServerProvider } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Path from "effect/Path"; @@ -11,8 +11,17 @@ import type { ProviderDriver } from "../../provider/ProviderDriver.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { ServerConfig } from "../../config.ts"; import { materializeCodexProxyHome } from "./CodexProxyHome.ts"; -import { currentPrismEndpoint, isPrismEnabled, prismEndpointChanges } from "./PrismEnvironment.ts"; -import { withPrismRouteOption } from "./PrismRouting.ts"; +import { + currentPrismEndpoint, + isPrismEnabled, + isPrismIdentityRequired, + prismEndpointChanges, +} from "./PrismEnvironment.ts"; +import { + getMicPrismThreadEndpoint, + publishMicPrismActiveBinding, +} from "../mic-identity/MicPrismThreads.ts"; +import { micPrismBinding, withMicPrismReadiness, withPrismRouteOption } from "./PrismRouting.ts"; import { makePrismRoutedAdapter } from "./PrismRoutedAdapter.ts"; /** Decorate existing Claude/Codex drivers; retain their native auth and maintenance paths. */ @@ -32,12 +41,25 @@ export const withPrismProvider = + const cache = new Map(); + const proxy = (threadId: ThreadId, selection: ModelSelection | undefined) => lock.withPermits(1)( Effect.gen(function* () { - const endpoint = currentPrismEndpoint(); - if (!endpoint) return undefined; + const identity = isPrismIdentityRequired(); + const endpoint = identity + ? getMicPrismThreadEndpoint(threadId, micPrismBinding(selection)) + : currentPrismEndpoint(); + if (!endpoint) { + if (!identity) return undefined; + return yield* new ProviderAdapterRequestError({ + provider: driver.driverKind, + method: "prism.setup", + detail: + "Prism authorization required: connect this thread to your mic.sc account before sending a turn.", + }); + } + const cacheKey = identity ? threadId : "legacy"; + const cached = cache.get(cacheKey); if (cached?.baseUrl === endpoint.baseUrl && cached.apiKey === endpoint.apiKey) return cached.instance.adapter; let config = input.config; @@ -57,35 +79,56 @@ export const withPrismProvider = - new ProviderAdapterRequestError({ - provider: driver.driverKind, - method: "prism.setup", - detail: "Could not prepare the Prism provider connection.", - }), + Effect.mapError((error) => + error._tag === "ProviderAdapterRequestError" + ? error + : new ProviderAdapterRequestError({ + provider: driver.driverKind, + method: "prism.setup", + detail: "Could not prepare the Prism provider connection.", + }), ), ), ); const adapter = yield* makePrismRoutedAdapter({ direct: direct.adapter, enabled: isPrismEnabled, + allowDirectFallback: () => !isPrismIdentityRequired(), + onSessionRoute: (threadId, selection, usingPrism) => + publishMicPrismActiveBinding( + threadId, + usingPrism ? micPrismBinding(selection) : "direct", + selection, + ), + onSessionStopped: (threadId) => publishMicPrismActiveBinding(threadId, undefined), proxy, }); const decorate = (snapshot: ServerProvider) => @@ -95,9 +138,11 @@ export const withPrismProvider = Effect.succeed({ threadId: id, turns: [] }), rollbackThread: (id) => Effect.succeed({ threadId: id, turns: [] }), }; - const complete = (state: "completed" | "failed" | "cancelled" = "completed") => + const complete = ( + state: "completed" | "failed" | "cancelled" | "interrupted" = "completed", + errorMessage?: string, + ) => PubSub.publish(events, { eventId: EventId.make(`${name}-${state}`), provider, @@ -91,9 +94,10 @@ const fake = Effect.fn("test.fakeAdapter")(function* (name: string, reject = fal createdAt: at, turnId: TurnId.make(name), type: "turn.completed", - payload: { state }, + payload: { state, ...(errorMessage ? { errorMessage } : {}) }, }); - return { adapter, starts, turns, sent, complete, stops: () => stops }; + const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event); + return { adapter, starts, turns, sent, complete, emit, stops: () => stops }; }); const observe = Effect.fn("test.observe")(function* ( @@ -107,6 +111,78 @@ const observe = Effect.fn("test.observe")(function* ( }); it.layer(NodeServices.layer)("Prism routed adapter", (it) => { + it.effect("legacy startup fallback survives the failed proxy's exit event", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const exitObserved = Promise.withResolvers(); + const selected: boolean[] = []; + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + onSessionStopped: () => exitObserved.resolve(), + onSessionRoute: (_id, _selection, pooled) => selected.push(pooled), + proxy: () => + Effect.succeed({ + ...proxy.adapter, + startSession: () => + proxy + .emit({ + type: "session.exited", + eventId: EventId.make("startup-exit"), + provider, + threadId, + createdAt: at, + payload: { reason: "startup failed" }, + }) + .pipe( + Effect.andThen(Effect.promise(() => exitObserved.promise)), + Effect.andThen(Effect.fail(failure)), + ), + }), + }); + yield* adapter.startSession(start); + assert.equal(direct.starts.length, 1); + assert.equal(direct.stops(), 0); + assert.deepEqual(selected, [false]); + }), + ); + it.effect("identity policy stays fixed when flags change during proxy setup", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + let identity = true; + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + allowDirectFallback: () => !identity, + proxy: () => + Effect.sync(() => { + identity = false; + }).pipe(Effect.andThen(Effect.fail(failure))), + }); + yield* adapter.startSession(start).pipe(Effect.flip); + assert.equal(direct.starts.length, 0); + }), + ); + it.effect( + "identity routing never falls back to local credentials after an unclassified failure", + () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const pooled = yield* fake("pooled", true); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + allowDirectFallback: () => false, + proxy: () => Effect.succeed(pooled.adapter), + }); + yield* adapter.startSession(start); + const error = yield* adapter.sendTurn({ threadId, input: "hello" }).pipe(Effect.flip); + assert.equal(error._tag, "ProviderAdapterRequestError"); + assert.equal(direct.turns.length, 0); + assert.equal(direct.starts.length, 0); + }), + ); it.effect( "flags off and explicit direct both avoid creating a proxy and strip the routing option", () => @@ -243,6 +319,271 @@ it.layer(NodeServices.layer)("Prism routed adapter", (it) => { assert.equal(proxy.turns.length, 1); }), ); + + it.effect("quota and access failures never bypass Prism through setup, start, or send", () => + Effect.gen(function* () { + for (const detail of [ + "quota_exhausted", + "soft-reserve avoidance", + "quota_stale", + "rate_limit_error", + "usageLimitReached", + "insufficient_credits", + "HTTP 429 Too Many Requests", + "HTTP 401", + "HTTP 403", + "permission_error", + "identity_revoked", + "model_unavailable", + ]) { + for (const phase of ["setup", "start", "send"] as const) { + const denied = new ProviderAdapterRequestError({ provider, method: phase, detail }); + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => + phase === "setup" + ? Effect.fail(denied) + : Effect.succeed({ + ...proxy.adapter, + ...(phase === "start" ? { startSession: () => Effect.fail(denied) } : {}), + ...(phase === "send" ? { sendTurn: () => Effect.fail(denied) } : {}), + }), + }); + if (phase === "send") yield* adapter.startSession(start); + const rejected = yield* phase === "send" + ? adapter.sendTurn({ threadId, input: "hello" }).pipe(Effect.flip) + : adapter.startSession(start).pipe(Effect.flip); + assert.equal(rejected, denied); + assert.equal(direct.starts.length, 0, `${phase}: ${detail}`); + assert.equal(direct.turns.length, 0, `${phase}: ${detail}`); + yield* adapter.stopAll(); + } + } + }), + ); + + it.effect( + "a nested native policy refusal stays blocked through later generic failure events", + () => + Effect.gen(function* () { + for (const detail of [ + { error: { code: "prism_soft_reserve" } }, + { error: { codexErrorInfo: "usageLimitExceeded" } }, + { error: { httpStatusCode: 403 } }, + { error: { codexErrorInfo: { httpStreamConnectionFailed: { httpStatusCode: 429 } } } }, + { error: { message: "Authentication required" } }, + { error: { type: "prism_upstream_unavailable" }, prism: { fallbackAllowed: false } }, + { headers: { "x-prism-fallback-allowed": "false" } }, + ]) { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + const rejected: ProviderRuntimeEvent = { + eventId: EventId.make("policy-refusal"), + provider, + threadId, + createdAt: at, + turnId: TurnId.make("proxy"), + type: "runtime.error", + payload: { message: "Request refused", class: "provider_error", detail }, + }; + yield* proxy.emit(rejected); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + assert.deepEqual(yield* Queue.take(events), rejected); + yield* proxy.complete("failed"); + assert.equal((yield* Queue.take(events)).type, "turn.completed"); + assert.equal(direct.turns.length, 0); + assert.equal(direct.starts.length, 0); + yield* adapter.sendTurn({ + threadId, + input: "explicit direct", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "test-model", + options: [{ id: PRISM_ROUTE_OPTION, value: "direct" }], + }, + }); + assert.equal(direct.turns.length, 1); + yield* adapter.stopAll(); + } + }), + ); + + it.effect("a quota failure at completion preserves the actual error and model selection", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "chosen-model", + }; + yield* adapter.startSession({ ...start, modelSelection }); + const result = yield* adapter.sendTurn({ threadId, input: "hello", modelSelection }); + yield* proxy.complete("failed", "No eligible accounts: soft reserve reached"); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + const completion = yield* Queue.take(events); + assert.equal(completion.turnId, result.turnId); + assert.equal(completion.type, "turn.completed"); + if (completion.type === "turn.completed") { + assert.equal(completion.payload.errorMessage, "No eligible accounts: soft reserve reached"); + } + assert.deepEqual(proxy.turns[0]?.modelSelection, modelSelection); + assert.equal(direct.turns.length, 0); + }), + ); + + it.effect("a provider quota warning cannot be bypassed when its own retries fail", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + const nativeWarning: ProviderRuntimeEvent = { + eventId: EventId.make("native-retry"), + provider, + threadId, + createdAt: at, + turnId: TurnId.make("proxy"), + type: "runtime.warning", + payload: { message: "Retrying after quota rejection", detail: { willRetry: true } }, + }; + yield* proxy.emit(nativeWarning); + assert.deepEqual(yield* Queue.take(events), nativeWarning); + yield* proxy.complete("failed", "Request failed"); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + assert.equal((yield* Queue.take(events)).type, "turn.completed"); + assert.equal(direct.turns.length, 0); + }), + ); + + it.effect("streamed output and tool execution cannot be replayed after a failure", () => + Effect.gen(function* () { + const eventBase = { + eventId: EventId.make("activity"), + provider, + threadId, + createdAt: at, + turnId: TurnId.make("proxy"), + }; + const activity: ReadonlyArray = [ + { + ...eventBase, + type: "content.delta", + payload: { streamKind: "assistant_text", delta: "Partial response" }, + }, + { + ...eventBase, + type: "content.delta", + payload: { streamKind: "reasoning_text", delta: "Working" }, + }, + { ...eventBase, type: "item.started", payload: { itemType: "command_execution" } }, + { ...eventBase, type: "item.started", payload: { itemType: "file_change" } }, + { ...eventBase, type: "item.started", payload: { itemType: "mcp_tool_call" } }, + ]; + for (const produced of activity) { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + yield* proxy.emit(produced); + assert.equal((yield* Queue.take(events)).type, produced.type); + yield* proxy.complete("failed", "Connection reset"); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + assert.equal((yield* Queue.take(events)).type, "turn.completed"); + assert.equal(direct.starts.length, 0); + assert.equal(direct.turns.length, 0); + yield* adapter.stopAll(); + } + }), + ); + + it.effect("a provider interruption does not start a direct retry", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => Effect.succeed(proxy.adapter), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + yield* adapter.sendTurn({ threadId, input: "hello" }); + yield* proxy.complete("interrupted"); + assert.equal((yield* Queue.take(events)).type, "turn.completed"); + assert.equal(direct.turns.length, 0); + }), + ); + + it.effect("a send rejection after streamed output cannot replay the turn", () => + Effect.gen(function* () { + const direct = yield* fake("direct"); + const proxy = yield* fake("proxy"); + const release = yield* Deferred.make(); + const adapter = yield* makePrismRoutedAdapter({ + direct: direct.adapter, + enabled: () => true, + proxy: () => + Effect.succeed({ + ...proxy.adapter, + sendTurn: (input: ProviderSendTurnInput) => + proxy.adapter.sendTurn(input).pipe( + Effect.andThen( + proxy.emit({ + eventId: EventId.make("partial"), + provider, + threadId, + createdAt: at, + turnId: TurnId.make("proxy"), + type: "content.delta", + payload: { streamKind: "assistant_text", delta: "Partial answer" }, + }), + ), + Effect.andThen(Deferred.await(release)), + Effect.andThen(Effect.fail(failure)), + ), + }), + }); + const events = yield* observe(adapter); + yield* adapter.startSession(start); + const sending = yield* adapter + .sendTurn({ threadId, input: "hello" }) + .pipe(Effect.flip, Effect.forkChild); + assert.equal((yield* Queue.take(events)).type, "content.delta"); + yield* Deferred.succeed(release, undefined); + assert.equal(yield* Fiber.join(sending), failure); + assert.equal((yield* Queue.take(events)).type, "runtime.warning"); + assert.equal(direct.turns.length, 0); + }), + ); it.effect("an early failed event and send rejection share one direct retry", () => Effect.gen(function* () { const direct = yield* fake("direct"); diff --git a/apps/server/src/fork/prism/PrismRoutedAdapter.ts b/apps/server/src/fork/prism/PrismRoutedAdapter.ts index 8dbe73c153b8..0b4d0d3411cb 100644 --- a/apps/server/src/fork/prism/PrismRoutedAdapter.ts +++ b/apps/server/src/fork/prism/PrismRoutedAdapter.ts @@ -1,5 +1,6 @@ import { EventId, + isToolLifecycleItemType, type ThreadId, type TurnId, type ProviderRuntimeEvent, @@ -12,6 +13,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as PubSub from "effect/PubSub"; +import * as Predicate from "effect/Predicate"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { @@ -25,22 +27,109 @@ type Adapter = ProviderAdapterShape; interface SessionRoute { adapter: Adapter; start: ProviderSessionStartInput; + lifecycle: number; turn?: { input: ProviderSendTurnInput; logicalId?: TurnId; nativeId?: TurnId; retried: boolean; + allowDirectFallback: boolean; cancelled: boolean; + fallbackBlocked: boolean; + policyRefused: boolean; + hasOutput: boolean; retryDone: Deferred.Deferred; }; } +// Native CLIs sometimes retain structured errors and sometimes only their message. +// Inspect error fields only, never arbitrary request/tool data, and bound nested causes. +const blocksDirectFallback = (error: unknown, depth = 0): boolean => { + if (depth > 5) return false; + if (typeof error === "number") return [401, 403, 429].includes(error); + if (typeof error === "string") { + const normalized = error.replace(/[_-]/g, " ").toLowerCase(); + return /quota|reserve|exhaust|rate.?limit|usage.?limit|insufficient.?credits|billing|unauth|forbidden|permission|revok|access denied|inference.?denied|authentication|authoriz|invalid.?grant|invalid.?api.?key|invalid.?token|expired.?token|token.?expired|sign.?in.?required|no eligible|model.?unavailable|model.?not.?found|validation.?error|fallbackallowed"?\s*:\s*false|\b(?:401|403|429)\b/.test( + normalized, + ); + } + if (!Predicate.isObject(error)) return false; + if ( + ("fallbackAllowed" in error && error.fallbackAllowed === false) || + ("x-prism-fallback-allowed" in error && error["x-prism-fallback-allowed"] === "false") + ) + return true; + return [ + "code", + "type", + "class", + "message", + "reason", + "detail", + "error", + "cause", + "prism", + "headers", + "status", + "statusCode", + "httpStatusCode", + "http_status_code", + "codexErrorInfo", + "httpConnectionFailed", + "httpStreamConnectionFailed", + "errorMessage", + "stopReason", + ].some((key) => key in error && blocksDirectFallback(error[key], depth + 1)); +}; + +const producesOutput = (event: ProviderRuntimeEvent): boolean => { + switch (event.type) { + case "content.delta": + return event.payload.delta.length > 0; + case "item.started": + case "item.updated": + case "item.completed": + return ( + isToolLifecycleItemType(event.payload.itemType) || + (["assistant_message", "reasoning", "plan"].includes(event.payload.itemType) && + (event.type !== "item.started" || event.payload.detail !== undefined)) + ); + case "turn.proposed.delta": + case "turn.proposed.completed": + case "turn.diff.updated": + case "files.persisted": + case "request.opened": + case "user-input.requested": + case "tool.progress": + case "tool.summary": + case "task.started": + case "task.progress": + case "task.updated": + case "task.completed": + return true; + default: + return false; + } +}; + /** One provider instance, with routing kept at the adapter boundary and one logical turn across fallback. */ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* (input: { direct: Adapter; enabled: () => boolean; - proxy: () => Effect.Effect; + allowDirectFallback?: () => boolean; + onSessionRoute?: ( + threadId: ThreadId, + selection: ProviderSessionStartInput["modelSelection"], + usingPrism: boolean, + ) => void; + onSessionStopped?: (threadId: ThreadId) => void; + proxy: ( + threadId: ThreadId, + selection: ProviderSessionStartInput["modelSelection"], + ) => Effect.Effect; }) { + const blocksFallback = (error: unknown) => + input.allowDirectFallback?.() === false || blocksDirectFallback(error); const scope = yield* Scope.Scope; const crypto = yield* Crypto.Crypto; const nextEventId = crypto.randomUUIDv4.pipe(Effect.orDie, Effect.map(EventId.make)); @@ -69,6 +158,7 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* const move = Effect.fn("prism.moveSession")(function* (route: SessionRoute, adapter: Adapter) { if (route.adapter === adapter) return; + const lifecycle = ++route.lifecycle; const old = route.adapter; const current = (yield* old.listSessions()).find( (session) => session.threadId === route.start.threadId, @@ -82,8 +172,14 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* ...(current?.resumeCursor !== undefined ? { resumeCursor: current.resumeCursor } : {}), }), ); - if (sessions.get(route.start.threadId) !== route) + if (sessions.get(route.start.threadId) !== route || route.lifecycle !== lifecycle) yield* adapter.stopSession(route.start.threadId); + else + input.onSessionRoute?.( + route.start.threadId, + route.start.modelSelection, + adapter !== input.direct, + ); }); const warning = Effect.fn("prism.warning")(function* (route: SessionRoute, message: string) { @@ -101,10 +197,29 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* }); }); + const blockRetry = (route: SessionRoute) => { + if (!route.turn || route.turn.fallbackBlocked) return Effect.void; + route.turn.fallbackBlocked = true; + return warning( + route, + route.turn.hasOutput + ? "Prism failed after producing output or starting work. Automatic retry is disabled to avoid repeating it." + : "Prism rejected this request. Automatic direct retry is disabled to preserve its access, model, and quota rules.", + ); + }; + const retryDirect = Effect.fn("prism.retryDirect")(function* (route: SessionRoute) { const turn = route.turn; if (!turn || turn.cancelled) return undefined; if (turn.retried) return yield* Deferred.await(turn.retryDone); + if ( + !turn.allowDirectFallback || + input.allowDirectFallback?.() === false || + turn.fallbackBlocked || + turn.policyRefused || + turn.hasOutput + ) + return undefined; if (route.adapter === input.direct) return undefined; turn.retried = true; return yield* Effect.gen(function* () { @@ -129,18 +244,45 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* ) { const route = sessions.get(event.threadId); if (!route || route.adapter !== source) return; + if (event.type === "session.exited") { + route.lifecycle++; + input.onSessionStopped?.(event.threadId); + } const turn = route.turn; if (turn && event.turnId !== undefined) { if (turn.nativeId !== undefined && turn.nativeId !== event.turnId) return; turn.logicalId ??= event.turnId; turn.nativeId = event.turnId; } + if (turn && producesOutput(event)) turn.hasOutput = true; + // A provider can announce its own retry before later emitting a generic failure. + if (turn && event.type === "runtime.warning" && blocksFallback(event.payload)) { + turn.policyRefused = true; + } const failed = event.type === "runtime.error" || event.type === "session.exited" || - (event.type === "turn.completed" && - (event.payload.state === "failed" || event.payload.state === "interrupted")); - if (failed && turn && !turn.cancelled && !turn.retried && source !== input.direct) { + (event.type === "turn.completed" && event.payload.state === "failed"); + if ( + failed && + turn && + source !== input.direct && + !turn.fallbackBlocked && + (!turn.allowDirectFallback || + turn.hasOutput || + turn.policyRefused || + blocksFallback(event.payload)) + ) { + yield* blockRetry(route); + } + if ( + failed && + turn && + !turn.cancelled && + !turn.retried && + !turn.fallbackBlocked && + source !== input.direct + ) { const retried = yield* retryDirect(route).pipe(Effect.catch(() => Effect.succeed(undefined))); if (retried !== undefined) return; // A failed direct start still produces a terminal event for the original turn. @@ -175,9 +317,10 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* yield* subscribe(input.direct); const select = Effect.fn("prism.selectAdapter")(function* ( selection: ProviderSessionStartInput["modelSelection"], + threadId: ThreadId, ) { if (!input.enabled() || prismRoute(selection) === "direct") return input.direct; - const proxy = yield* input.proxy(); + const proxy = yield* input.proxy(threadId, selection); if (!proxy) return input.direct; yield* subscribe(proxy); return proxy; @@ -187,11 +330,16 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* ...input.direct, streamEvents: Stream.fromPubSub(events), startSession: Effect.fn("prism.startSession")(function* (start) { - const selected = yield* select(start.modelSelection).pipe( - Effect.catch(() => Effect.succeed(input.direct)), + const allowDirectFallback = input.allowDirectFallback?.() !== false; + const selected = yield* select(start.modelSelection, start.threadId).pipe( + Effect.catch((error) => + !allowDirectFallback || blocksFallback(error) + ? Effect.fail(error) + : Effect.succeed(input.direct), + ), ); // Install before starting: native adapters can emit their first event during startSession. - const route: SessionRoute = { adapter: selected, start }; + const route: SessionRoute = { adapter: selected, start, lifecycle: 0 }; sessions.set(start.threadId, route); if ( selected === input.direct && @@ -200,10 +348,13 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* ) { yield* warning(route, "Prism is unavailable. Using local direct-provider credentials."); } + let startupLifecycle = route.lifecycle; const result = yield* selected.startSession(cleanStart(start)).pipe( Effect.catch((error) => { - if (selected === input.direct) return Effect.fail(error); + if (selected === input.direct || !allowDirectFallback || blocksFallback(error)) + return Effect.fail(error); route.adapter = input.direct; + startupLifecycle = ++route.lifecycle; return selected .stopSession(start.threadId) .pipe(Effect.ignore, Effect.andThen(input.direct.startSession(cleanStart(start)))); @@ -214,18 +365,31 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* }), ), ); + if (sessions.get(start.threadId) === route && route.lifecycle === startupLifecycle) + input.onSessionRoute?.( + start.threadId, + start.modelSelection, + route.adapter !== input.direct, + ); + else yield* route.adapter.stopSession(start.threadId); return result; }), sendTurn: Effect.fn("prism.sendTurn")(function* (turnInput) { + const allowDirectFallback = input.allowDirectFallback?.() !== false; const route = yield* routeFor(turnInput.threadId); const selection = turnInput.modelSelection ?? route.start.modelSelection; route.start = { ...route.start, modelSelection: selection }; - const selected = yield* select(selection).pipe( - Effect.catch(() => Effect.succeed(input.direct)), + const selected = yield* select(selection, turnInput.threadId).pipe( + Effect.catch((error) => + !allowDirectFallback || blocksFallback(error) + ? Effect.fail(error) + : Effect.succeed(input.direct), + ), ); yield* move(route, selected).pipe( Effect.catch((error) => { - if (selected === input.direct) return Effect.fail(error); + if (selected === input.direct || !allowDirectFallback || blocksFallback(error)) + return Effect.fail(error); return warning( route, "Prism could not start. Using local direct-provider credentials.", @@ -235,27 +399,46 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* const turn: NonNullable = { input: turnInput, retried: false, + allowDirectFallback, cancelled: false, + fallbackBlocked: false, + policyRefused: false, + hasOutput: false, retryDone: yield* Deferred.make< ProviderTurnStartResult | undefined, ProviderAdapterError >(), }; route.turn = turn; - const result = yield* route.adapter - .sendTurn(cleanTurn(turnInput)) - .pipe( - Effect.catch((error) => - retryDirect(route).pipe( - Effect.flatMap((result) => (result ? Effect.succeed(result) : Effect.fail(error))), - ), - ), - ); + const lifecycle = route.lifecycle; + const result = yield* route.adapter.sendTurn(cleanTurn(turnInput)).pipe( + Effect.catch((error) => { + if ( + route.adapter !== input.direct && + !turn.fallbackBlocked && + (!turn.allowDirectFallback || + turn.hasOutput || + turn.policyRefused || + blocksFallback(error)) + ) { + return blockRetry(route).pipe(Effect.andThen(Effect.fail(error))); + } + return retryDirect(route).pipe( + Effect.flatMap((result) => (result ? Effect.succeed(result) : Effect.fail(error))), + ); + }), + ); const completedStart = turn.retried ? ((yield* Deferred.await(turn.retryDone)) ?? result) : result; turn.logicalId ??= completedStart.turnId; if (!turn.retried) turn.nativeId = result.turnId; + if (sessions.get(turnInput.threadId) === route && route.lifecycle === lifecycle) + input.onSessionRoute?.( + turnInput.threadId, + route.start.modelSelection, + route.adapter !== input.direct, + ); return { ...completedStart, turnId: turn.logicalId }; }), interruptTurn: (id) => @@ -270,6 +453,7 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* Effect.flatMap((route) => { if (route.turn) route.turn.cancelled = true; sessions.delete(id); + input.onSessionStopped?.(id); return route.adapter.stopSession(id); }), ), @@ -277,6 +461,7 @@ export const makePrismRoutedAdapter = Effect.fn("prism.routedAdapter")(function* Effect.gen(function* () { for (const route of sessions.values()) { if (route.turn) route.turn.cancelled = true; + input.onSessionStopped?.(route.start.threadId); } sessions.clear(); yield* Effect.forEach(subscribed, (adapter) => adapter.stopAll(), { discard: true }); diff --git a/apps/server/src/fork/prism/PrismRouting.test.ts b/apps/server/src/fork/prism/PrismRouting.test.ts new file mode 100644 index 000000000000..44901c205f25 --- /dev/null +++ b/apps/server/src/fork/prism/PrismRouting.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { micPrismBinding, withMicPrismReadiness, withoutPrismRoute } from "./PrismRouting.ts"; + +describe("identity provider readiness", () => { + const signedOut = { + enabled: true, + installed: true, + status: "error", + auth: { status: "unauthenticated" }, + message: "Log in locally", + }; + it("makes an installed pooled provider selectable without direct-provider login", () => { + expect(withMicPrismReadiness(signedOut, true)).toMatchObject({ + status: "ready", + auth: { status: "authenticated", type: "prism" }, + }); + }); + it("retains flags-off, disabled and not-installed states", () => { + expect(withMicPrismReadiness(signedOut, false)).toBe(signedOut); + const missing = { ...signedOut, installed: false }; + expect(withMicPrismReadiness(missing, true)).toBe(missing); + const disabled = { ...signedOut, enabled: false }; + expect(withMicPrismReadiness(disabled, true)).toBe(disabled); + }); + it("reads the server handle but never sends it or the routing option to a provider", () => { + const selection = { + instanceId: ProviderInstanceId.make("test"), + model: "model", + options: [ + { id: "q1.mic-binding", value: "opaque-handle" }, + { id: "prism-route", value: "prism" }, + { id: "effort", value: "high" }, + ], + }; + expect(micPrismBinding(selection)).toBe("opaque-handle"); + expect(withoutPrismRoute(selection)?.options).toEqual([{ id: "effort", value: "high" }]); + }); +}); diff --git a/apps/server/src/fork/prism/PrismRouting.ts b/apps/server/src/fork/prism/PrismRouting.ts index e05831631840..1e0479bd7480 100644 --- a/apps/server/src/fork/prism/PrismRouting.ts +++ b/apps/server/src/fork/prism/PrismRouting.ts @@ -16,14 +16,39 @@ export const prismRoute = (selection: ModelSelection | undefined): "prism" | "di ? "direct" : "prism"; +/** An opaque server-stamped reference, never an inference or environment credential. */ +export const MIC_PRISM_BINDING_OPTION = "q1.mic-binding"; +export const micPrismBinding = (selection: ModelSelection | undefined): string | undefined => { + const value = selection?.options?.find((option) => option.id === MIC_PRISM_BINDING_OPTION)?.value; + return typeof value === "string" ? value : undefined; +}; + +/** Installation is required; the environment checks the signed-in thread at execution time. */ +export function withMicPrismReadiness< + A extends { readonly enabled: boolean; readonly installed: boolean }, +>(snapshot: A, enabled: boolean) { + return enabled && snapshot.enabled && snapshot.installed + ? { + ...snapshot, + status: "ready" as const, + auth: { status: "authenticated" as const, type: "prism", label: "mic.sc Prism" }, + message: "Prism available. Connect this thread with mic.sc to authorize inference.", + } + : snapshot; +} + /** This option controls q1code routing and must never be sent to a provider CLI. */ export const withoutPrismRoute = ( selection: ModelSelection | undefined, ): ModelSelection | undefined => - selection?.options?.some((option) => option.id === PRISM_ROUTE_OPTION) + selection?.options?.some( + (option) => option.id === PRISM_ROUTE_OPTION || option.id === MIC_PRISM_BINDING_OPTION, + ) ? { ...selection, - options: selection.options.filter((option) => option.id !== PRISM_ROUTE_OPTION), + options: selection.options.filter( + (option) => option.id !== PRISM_ROUTE_OPTION && option.id !== MIC_PRISM_BINDING_OPTION, + ), } : selection; diff --git a/apps/server/src/fork/prism/PrismService.ts b/apps/server/src/fork/prism/PrismService.ts index 146cccef693b..48664e9e9bf9 100644 --- a/apps/server/src/fork/prism/PrismService.ts +++ b/apps/server/src/fork/prism/PrismService.ts @@ -67,6 +67,7 @@ import { type PrismEndpoint, publishPrismEndpoint, publishPrismEnabled, + publishPrismIdentityRequired, } from "./PrismEnvironment.ts"; import { materializeCodexProxyHome } from "./CodexProxyHome.ts"; @@ -353,9 +354,10 @@ const make = Effect.gen(function* () { const lifecycle = yield* Semaphore.make(1); const runScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => - Effect.sync(() => publishPrismEnabled(false)).pipe( - Effect.andThen(Scope.close(runScope, Exit.void)), - ), + Effect.sync(() => { + publishPrismEnabled(false); + publishPrismIdentityRequired(false); + }).pipe(Effect.andThen(Scope.close(runScope, Exit.void))), ); /** Apply a patch derived from the current status; entering a new state stamps `since`. */ @@ -627,11 +629,12 @@ const make = Effect.gen(function* () { yield* Effect.logInfo("prism: stopped"); }); - const apply = (values: { readonly prism: boolean }) => + const apply = (values: { readonly prism: boolean; readonly "mic-identity": boolean }) => lifecycle.withPermits(1)( - Effect.sync(() => publishPrismEnabled(values.prism)).pipe( - Effect.andThen(values.prism ? start : stop), - ), + Effect.sync(() => { + publishPrismEnabled(values.prism); + publishPrismIdentityRequired(values["mic-identity"]); + }).pipe(Effect.andThen(values.prism && !values["mic-identity"] ? start : stop)), ); yield* apply(yield* flags.current); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5ecdd341c952..01dbb0c1df1f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,4 @@ +import { micPrismEngineForSession } from "./fork/mic-identity/MicPrismThreads.ts"; // fork: mic-identity import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -467,7 +468,7 @@ const makeWsRpcLayer = ( const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; - const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const orchestrationEngine = yield* micPrismEngineForSession(currentSessionId); // fork: mic-identity const threadDeletionReactor = yield* ThreadDeletionReactor; const analytics = yield* AnalyticsService.AnalyticsService; // Every command dispatched on this connection carries the connecting diff --git a/apps/swift-ios/App/Cloud/T3ConnectCapability.swift b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift index 353df8937720..a4beaa50a5e5 100644 --- a/apps/swift-ios/App/Cloud/T3ConnectCapability.swift +++ b/apps/swift-ios/App/Cloud/T3ConnectCapability.swift @@ -157,6 +157,28 @@ public final class T3ConnectController: T3ConnectDeviceManaging { var clerk: Clerk? { auth?.client } + func micPrismSessionID(expectedPublishableKey: String) -> String? { + guard resolution.configuration?.clerkPublishableKey == expectedPublishableKey, + !isLocalAuthorizationInvalidated, !isSignOutInProgress else { return nil } + return clerk?.session?.id + } + + /// mic.sc credentials never substitute for a relay or environment credential. + /// Use the default Clerk session JWT: the authority checks its sid against the live session. + func micPrismToken(expectedPublishableKey: String) async throws -> String { + guard resolution.configuration?.clerkPublishableKey == expectedPublishableKey, + !isLocalAuthorizationInvalidated, !isSignOutInProgress, + let clerk, let session = clerk.session else { + throw FeatureCapabilityUnavailable("Sign in with the configured mic.sc account service") + } + let token = try await session.getToken(.init(skipCache: true)) + guard clerk.session?.id == session.id, !isLocalAuthorizationInvalidated, + !isSignOutInProgress, let token, !token.isEmpty else { + throw FeatureCapabilityUnavailable("mic.sc sign-in") + } + return token + } + public func refresh() async { guard let auth, let relay else { return } guard !isLocalAuthorizationInvalidated else { return } diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 5ee47a5d1068..bda4f19b8fff 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -25,7 +25,7 @@ private struct T3ConnectManagedCleanupError: LocalizedError { @MainActor final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, FeatureProjectCreationClient, FeatureWorkspaceAssetResolving, - FeatureFeedbackSubmitting, T3ConnectCapable + FeatureFeedbackSubmitting, T3ConnectCapable, MicPrismThreadCapable { private static let maximumRetainedThreadDetails = 6 private static let t3ConnectLogger = Logger( @@ -38,6 +38,17 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, private static let projectFaviconFallbackMarker = "project-favicon-missing" private let runtime: EnvironmentRuntime + private let micPrismClient = MicPrismClient() + lazy var micPrismThreads = MicPrismThreadController(identity: { [weak self] in + guard let controller = self?.t3ConnectController, let key = controller.resolution.configuration?.clerkPublishableKey else { return nil } + return controller.micPrismSessionID(expectedPublishableKey: key) + }, attach: { [weak self] environmentID, threadID, authorityURL in + guard let self else { throw MicPrismError.unavailable } + return try await self.prism(PrismRequest("/identity/threads/" + PrismRequest.component(threadID), method: "PUT", identityAuthorityUrl: authorityURL), environmentID: environmentID) + }, detach: { [weak self] environmentID, threadID in + guard let self else { throw MicPrismError.unavailable } + _ = try await self.prism(PrismRequest("/identity/threads/" + PrismRequest.component(threadID), method: "DELETE"), environmentID: environmentID) + }) let t3ConnectController: T3ConnectController private let t3ConnectDeviceManager: any T3ConnectDeviceManaging private let hasMatchingT3ConnectController: Bool @@ -342,6 +353,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, } func signOutT3Connect() async { + await micPrismThreads.disconnectAll() // Clear the account and relay-token cache even when Clerk's remote // sign-out fails, then revoke every locally minted managed credential. // Manual pairings are device-owned and deliberately survive sign-out. @@ -420,7 +432,48 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, func prism(_ input: PrismRequest, environmentID: String) async throws -> PrismResponse { let client = try await projectCreationClient(environmentID: environmentID) - return try await client.prism(input) + let isThreadBinding = input.path.hasPrefix("/identity/threads/") && + input.path.split(separator: "/").count == 3 && input.body == nil && + ["PUT", "DELETE"].contains(input.method) + // Disconnect remains environment-authorized when mic.sc sign-out or renewal fails. + if isThreadBinding && input.method == "DELETE" { return try await client.prism(input) } + let configuration = try await prismIdentityConfiguration(environmentID: environmentID) + guard configuration.enabled else { return try await client.prism(input) } + guard let key = configuration.clerkPublishableKey, + key == t3ConnectController.resolution.configuration?.clerkPublishableKey else { + throw FeatureCapabilityUnavailable("This build is not configured for this mic.sc sign-in service") + } + let controller = t3ConnectController + guard let sessionID = controller.micPrismSessionID(expectedPublishableKey: key) else { + throw MicPrismError.signedOut + } + if isThreadBinding { + if let authority = input.identityAuthorityUrl, authority != configuration.authorityUrl { throw MicPrismError.unavailable } + let result = try await client.prism(input, micScToken: { + guard await controller.micPrismSessionID(expectedPublishableKey: key) == sessionID else { throw MicPrismError.signedOut } + let token = try await controller.micPrismToken(expectedPublishableKey: key) + guard await controller.micPrismSessionID(expectedPublishableKey: key) == sessionID else { throw MicPrismError.signedOut } + return token + }) + guard controller.micPrismSessionID(expectedPublishableKey: key) == sessionID else { throw MicPrismError.signedOut } + return result + } + return try await micPrismClient.call(input, configuration: configuration, token: { + try await controller.micPrismToken(expectedPublishableKey: key) + }, isCurrent: { + await controller.micPrismSessionID(expectedPublishableKey: key) == sessionID + }) + } + + func prismIdentityConfiguration(environmentID: String) async throws -> MicPrismIdentityConfiguration { + guard serverConfigsByEnvironmentID[environmentID]?.environment?.capabilities.forkFlags?["mic-identity"] == true else { return .disabled } + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.prismIdentityConfiguration() + } + + func prismSession(environmentID: String) async throws -> AuthSessionState { + let client = try await projectCreationClient(environmentID: environmentID) + return try await client.authSession() } func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] { @@ -4344,7 +4397,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, connectionDetail: environment.isEnabled ? environmentConnectionDetails[environment.id] : nil, - prismEnabled: serverConfigsByEnvironmentID[environment.id]?.environment?.capabilities.forkFlags?["prism"] + prismEnabled: serverConfigsByEnvironmentID[environment.id]?.environment?.capabilities.forkFlags?["prism"] == true || serverConfigsByEnvironmentID[environment.id]?.environment?.capabilities.forkFlags?["mic-identity"] == true ) } diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift index f85ad8de0d6e..b35d6093644c 100644 --- a/apps/swift-ios/Core/HTTP.swift +++ b/apps/swift-ios/Core/HTTP.swift @@ -58,7 +58,10 @@ public struct URLSessionHTTPTransport: HTTPTransport { // URLSession transparently decodes gzip responses before returning // their body. Applying the policy here is a final guard for requests // constructed outside EnvironmentAPI. - let (data, response) = try await session.data(for: HTTPRequestPolicy.prepare(request)) + let (data, response) = try await session.data( + for: HTTPRequestPolicy.prepare(request), + delegate: request.value(forHTTPHeaderField: "x-mic-sc-session") == nil ? nil : PrismRequestRedirectPolicy() + ) guard let httpResponse = response as? HTTPURLResponse else { throw HTTPError.invalidResponse } @@ -79,6 +82,13 @@ public struct URLSessionHTTPTransport: HTTPTransport { } } +/// A mic.sc token is bound to the requested environment and must never follow its redirect. +private final class PrismRequestRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) { + completionHandler(nil) + } +} + /// Shared wire-level defaults for HTTP requests. /// /// Foundation's URL loading system transparently decompresses gzip response @@ -376,7 +386,11 @@ public actor EnvironmentAPI { ) } - public func prism(_ input: PrismRequest, environment: Environment) async throws -> PrismResponse { + public func prismIdentityConfiguration(environment: Environment) async throws -> MicPrismIdentityConfiguration { + try await authorized(environment: environment, path: "/api/fork/prism/identity/config", method: "GET", as: MicPrismIdentityConfiguration.self) + } + + public func prism(_ input: PrismRequest, environment: Environment, micScToken: MicPrismTokenSource? = nil) async throws -> PrismResponse { guard input.path.hasPrefix("/"), !input.path.contains(".."), !input.path.contains("?"), !input.path.contains("#") else { throw HTTPError.invalidResponse @@ -387,6 +401,7 @@ public actor EnvironmentAPI { path: "/api/fork/prism" + input.path, method: input.method, body: body, + micScToken: micScToken, as: PrismResponse.self ) } @@ -398,6 +413,7 @@ public actor EnvironmentAPI { method: String, body: Data? = nil, timeoutInterval: TimeInterval? = nil, + micScToken: MicPrismTokenSource? = nil, as type: Result.Type ) async throws -> Result { guard let credential = try await credentials.credential(for: environment.id) else { @@ -423,6 +439,9 @@ public actor EnvironmentAPI { "Bearer \(credential.accessToken)", forHTTPHeaderField: "Authorization" ) + if let micScToken { + request.setValue(try await micScToken(), forHTTPHeaderField: "x-mic-sc-session") + } return try await send(request, as: type) case .managedDPoP: @@ -459,6 +478,9 @@ public actor EnvironmentAPI { if let timeoutInterval { request.timeoutInterval = timeoutInterval } + if let micScToken { + request.setValue(try await micScToken(), forHTTPHeaderField: "x-mic-sc-session") + } do { return try await send(request, as: type) } catch let error as HTTPError where error.isRejectedAuthorization { @@ -489,6 +511,9 @@ public actor EnvironmentAPI { if let timeoutInterval { retry.timeoutInterval = timeoutInterval } + if let micScToken { + retry.setValue(try await micScToken(), forHTTPHeaderField: "x-mic-sc-session") + } return try await send(retry, as: type) } } diff --git a/apps/swift-ios/Core/MicPrismClient.swift b/apps/swift-ios/Core/MicPrismClient.swift new file mode 100644 index 000000000000..cc6156e73a93 --- /dev/null +++ b/apps/swift-ios/Core/MicPrismClient.swift @@ -0,0 +1,238 @@ +import Foundation + +public enum MicPrismError: LocalizedError, Sendable { + case signedOut, denied, unavailable, invalidResponse, unpaired, unsupported + + public var errorDescription: String? { + switch self { + case .signedOut: "Sign in with mic.sc to continue." + case .denied: "Your mic.sc account no longer has access to this Prism operation." + case .unavailable: "Prism could not verify access. Try again when the service is available." + case .invalidResponse: "Prism returned an unexpected response. Refresh access and try again." + case .unpaired: "No Prism service is paired with mic.sc." + case .unsupported: "This Prism service does not support this operation yet." + } + } +} + +/// Direct service calls carry only the human credential, never environment or relay access. +public struct MicPrismClient: Sendable { + private let transport: any HTTPTransport + + public init(transport: any HTTPTransport = MicPrismHTTPTransport()) { + self.transport = transport + } + + public func call( + _ input: PrismRequest, + configuration: MicPrismIdentityConfiguration, + token: @escaping MicPrismTokenSource, + isCurrent: @escaping @Sendable () async -> Bool + ) async throws -> PrismResponse { + let permission: String + switch (input.method, input.path) { + case ("GET", "/identity/access"), ("GET", "/status"), ("GET", "/models"), ("POST", "/chat"): permission = "prism:inference" + case ("GET", "/routing"): permission = "prism:routing:read" + case ("PUT", "/routing"): permission = "prism:routing:write" + default: throw MicPrismError.unsupported + } + guard configuration.enabled, let origin = configuration.authorityUrl else { + throw MicPrismError.unavailable + } + if let expected = input.identityAuthorityUrl, expected != origin { throw MicPrismError.unavailable } + let identity: Identity = try await request(origin, "/v1/identity", token: token, isCurrent: isCurrent) + guard identity.contractVersion == 1, !identity.subject.isEmpty, + ["global_admin", "member"].contains(identity.role), !identity.authorizationRevision.isEmpty else { + throw MicPrismError.invalidResponse + } + try identity.require(permission) + let discovery: Discovery = try await request(origin, "/v1/prism/discovery", token: token, isCurrent: isCurrent) + guard discovery.contractVersion == 1, discovery.selectionRevision >= 0 else { throw MicPrismError.invalidResponse } + try identity.require(permission) + if let service = discovery.service { + guard service.status == "paired", service.protocolVersion == 1, + service.pairingRevision > 0, !service.serviceInstanceId.isEmpty, + !service.displayName.isEmpty, !service.publicKey.isEmpty else { throw MicPrismError.invalidResponse } + _ = try Self.url(service.apiOrigin, path: "", originOnly: true) + _ = try Self.url(service.inferenceOrigin, path: "", originOnly: true) + } + if let expected = input.expectedService { + guard let service = discovery.service, expected.id == service.serviceInstanceId, + expected.pairingRevision == service.pairingRevision, expected.apiUrl == service.apiOrigin, + expected.inferenceUrl == nil || expected.inferenceUrl == service.inferenceOrigin else { throw MicPrismError.unavailable } + } + if input.path == "/identity/access" { + return try Self.response([ + "session": .object([ + "subject": .string(identity.subject), + "permissions": .array(identity.permissions.map(JSONValue.string)), + "authorizationExpiresAt": .number(identity.authorizationExpiresAt), + ]), + "discovery": .object(["service": discovery.service.map { service in .object([ + "id": .string(service.serviceInstanceId), "label": .string(service.displayName), + "apiUrl": .string(service.apiOrigin), "inferenceUrl": .string(service.inferenceOrigin), "pairingRevision": .number(Double(service.pairingRevision)), + ]) } ?? .null]), + ]) + } + guard let service = discovery.service else { throw MicPrismError.unpaired } + if input.path == "/status" { + let status: Status = try await request(service.apiOrigin, "/prism/v1/status", token: token, isCurrent: isCurrent) + guard status.serviceInstanceId == service.serviceInstanceId, + status.pairingRevision == service.pairingRevision, + status.authorization == "current", status.engineHealth == "unknown" else { throw MicPrismError.invalidResponse } + try identity.require(permission) + // Verified access is distinct from engine readiness or provider eligibility. + return try Self.response(["state": .string("access-verified"), "capabilities": .object([ + "inference": .bool(true), "manage": .bool(false), "accountDetails": .bool(false), + ])]) + } + if input.path == "/models" || input.path == "/chat" { + let credential: Credential = try await request(origin, "/v1/prism/credentials", method: "POST", body: [ + "serviceInstanceId": .string(service.serviceInstanceId), "pairingRevision": .number(Double(service.pairingRevision)), + ], token: token, isCurrent: isCurrent) + let now = Date().timeIntervalSince1970 * 1000 + guard credential.version == 1, credential.tokenType == "Bearer", + credential.serviceInstanceId == service.serviceInstanceId, + credential.pairingRevision == service.pairingRevision, + credential.expiresAt > now, credential.expiresAt <= now + 930_000, + credential.token.range(of: #"^msp1\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil else { throw MicPrismError.invalidResponse } + try identity.require(permission) + if input.path == "/models" { + let catalog: Models = try await request(service.inferenceOrigin, "/v1/models", token: { credential.token }, isCurrent: isCurrent) + guard catalog.data.count <= 4096, catalog.data.allSatisfy({ !$0.id.isEmpty && $0.id.count <= 256 }) else { throw MicPrismError.invalidResponse } + let models = Set(catalog.data.map(\.id)).sorted() + return try Self.response(["models": .array(models.map(JSONValue.string))]) + } + guard let model = input.body?["model"]?.stringValue, !model.isEmpty, model.count <= 256, + let prompt = input.body?["prompt"]?.stringValue, !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + prompt.count <= 8000, input.body?.count == 2 else { throw MicPrismError.unsupported } + let result: Chat = try await request(service.inferenceOrigin, "/v1/chat/completions", method: "POST", body: [ + "model": .string(model), "stream": .bool(false), + "messages": .array([.object(["role": .string("user"), "content": .string(prompt)])]), + ], token: { credential.token }, isCurrent: isCurrent, timeout: 60) + guard let response = result.choices.first?.message.content else { throw MicPrismError.invalidResponse } + return try Self.response(["response": .string(response)]) + } + if input.method == "PUT" { + guard let strategy = input.body?["strategy"]?.stringValue, + ["round-robin", "weighted-round-robin", "fill-first"].contains(strategy), input.body?.count == 1 else { + throw MicPrismError.unsupported + } + } + let routing: Routing = try await request(service.apiOrigin, "/prism/v1/routing", method: input.method, body: input.body, token: token, isCurrent: isCurrent) + guard ["round-robin", "weighted-round-robin", "fill-first"].contains(routing.strategy), + input.method != "PUT" || routing.strategy == input.body?["strategy"]?.stringValue else { throw MicPrismError.invalidResponse } + try identity.require(permission) + return try Self.response(["strategy": .string(routing.strategy)]) + } + + private func request( + _ origin: String, _ path: String, method: String = "GET", body: [String: JSONValue]? = nil, + token: MicPrismTokenSource, isCurrent: @Sendable () async -> Bool, timeout: TimeInterval = 15 + ) async throws -> T { + try Task.checkCancellation() + guard await isCurrent() else { throw MicPrismError.signedOut } + let credential: String + do { credential = try await token() } catch { throw MicPrismError.signedOut } + try Task.checkCancellation() + guard await isCurrent(), !credential.isEmpty else { throw MicPrismError.signedOut } + var request = URLRequest(url: try Self.url(origin, path: path), cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: timeout) + request.httpMethod = method + request.setValue("Bearer \(credential)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let body { + request.httpBody = try JSONEncoder().encode(body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + let data: Data + let response: HTTPURLResponse + do { (data, response) = try await transport.data(for: request) } + catch is CancellationError { throw CancellationError() } + catch { throw MicPrismError.unavailable } + try Task.checkCancellation() + guard await isCurrent() else { throw MicPrismError.signedOut } + switch response.statusCode { + case 200..<300: break + case 401: throw MicPrismError.signedOut + case 403: throw MicPrismError.denied + case 404, 405, 501: throw MicPrismError.unsupported + default: throw MicPrismError.unavailable + } + do { return try JSONDecoder().decode(T.self, from: data) } + catch { throw MicPrismError.invalidResponse } + } + + private static func url(_ origin: String, path: String, originOnly: Bool = false) throws -> URL { + guard var parts = URLComponents(string: origin), let host = parts.host, + parts.scheme == "https" || (parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]", "::1"].contains(host)), + parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil, + !originOnly || parts.path.isEmpty || parts.path == "/" else { throw MicPrismError.invalidResponse } + parts.path = parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + parts.path = (parts.path.isEmpty ? "" : "/" + parts.path) + path + guard let url = parts.url else { throw MicPrismError.invalidResponse } + return url + } + + private static func response(_ object: [String: JSONValue]) throws -> PrismResponse { + try JSONDecoder().decode(PrismResponse.self, from: JSONEncoder().encode(object)) + } + + private struct Identity: Decodable { + let contractVersion: Int, subject: String, role: String, permissions: [String] + let authorizationExpiresAt: Double, authorizationRevision: String + func require(_ permission: String) throws { + guard authorizationExpiresAt > Date().timeIntervalSince1970 * 1000 else { throw MicPrismError.signedOut } + guard permissions.contains(permission) else { throw MicPrismError.denied } + } + } + private struct Discovery: Decodable { + let contractVersion: Int, selectionRevision: Int + let service: Service? + } + private struct Service: Decodable { + let serviceInstanceId: String, displayName: String, apiOrigin: String, inferenceOrigin: String + let pairingRevision: Int, protocolVersion: Int + let publicKey: String, status: String + } + private struct Status: Decodable { + let serviceInstanceId: String, pairingRevision: Int, authorization: String, engineHealth: String + } + private struct Routing: Decodable { let strategy: String } + private struct Credential: Decodable, Sendable { + let version: Int, tokenType: String, token: String, serviceInstanceId: String, pairingRevision: Int, expiresAt: Double + } + private struct Models: Decodable { + struct Model: Decodable { let id: String } + let data: [Model] + } + private struct Chat: Decodable { + struct Choice: Decodable { + struct Message: Decodable { let content: String? } + let message: Message + } + let choices: [Choice] + } +} + +/// No ambient browser cookies, response cache, redirects, or environment authorization. +public struct MicPrismHTTPTransport: HTTPTransport { + private let session: URLSession + public init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.urlCache = nil + session = URLSession(configuration: configuration) + } + public func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response) = try await session.data(for: request, delegate: MicPrismRedirectPolicy()) + guard let response = response as? HTTPURLResponse else { throw MicPrismError.invalidResponse } + return (data, response) + } +} + +private final class MicPrismRedirectPolicy: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) { + completionHandler(nil) + } +} diff --git a/apps/swift-ios/Core/PrismWireModels.swift b/apps/swift-ios/Core/PrismWireModels.swift index bf345c46beb2..e3423ae54f2e 100644 --- a/apps/swift-ios/Core/PrismWireModels.swift +++ b/apps/swift-ios/Core/PrismWireModels.swift @@ -23,6 +23,7 @@ public struct PrismAccountLifecycle: Decodable, Sendable { /// Additive responses keep carried clients compatible with older gateways. public struct PrismResponse: Decodable, Sendable { + public let capabilities: PrismCapabilities? public let state: String? public let role: String? public let version: String? @@ -35,17 +36,81 @@ public struct PrismResponse: Decodable, Sendable { public let userCode: String? public let status: String? public let strategy: String? + public let usageSource: Bool? + public let session: MicPrismIdentitySession? + public let discovery: MicPrismDiscovery? + public let models: [String]? + public let response: String? + public let threadId: String? + public let expiresAt: Double? +} + +public struct MicPrismIdentityConfiguration: Decodable, Sendable { + public let enabled: Bool + public let clerkPublishableKey: String? + public let authorityUrl: String? + + public static let disabled = MicPrismIdentityConfiguration(enabled: false, clerkPublishableKey: nil, authorityUrl: nil) +} + +public struct MicPrismIdentitySession: Decodable, Sendable { + public let subject: String + public let permissions: [String] + public let authorizationExpiresAt: Double +} + +public struct MicPrismDiscovery: Decodable, Sendable { + public let service: MicPrismDiscoveredService? +} + +public struct MicPrismDiscoveredService: Decodable, Sendable { + public let id: String + public let label: String + public let apiUrl: String + public let inferenceUrl: String? + public let pairingRevision: Int +} + +public typealias MicPrismTokenSource = @Sendable () async throws -> String + +public struct PrismCapabilities: Decodable, Sendable { + public let inference: Bool + public let manage: Bool + public let accountDetails: Bool +} + +/// Shared native decisions for management controls and their action handlers. +public struct PrismAccess: Sendable { + public let accountDetails: Bool + public let accounts: Bool + public let routing: Bool + public let configure: Bool + + public init(status: PrismResponse?, stale: Bool, connected: Bool, session: AuthSessionState?) { + let scopes = session?.authenticated == true ? session?.scopes ?? [] : [] + let write = scopes.contains("access:write") + let manage = status?.capabilities?.manage ?? write + let live = status != nil && !stale && connected + accountDetails = status?.capabilities?.accountDetails ?? scopes.contains("orchestration:read") + accounts = live && status?.state == "ready" && manage && accountDetails && status?.role != "replica" + routing = live && status?.state == "ready" && manage + configure = live && manage && write + } } public struct PrismRequest: Sendable { public let path: String public let method: String public let body: [String: JSONValue]? + public let expectedService: MicPrismDiscoveredService? + public let identityAuthorityUrl: String? - public init(_ path: String, method: String = "GET", body: [String: JSONValue]? = nil) { + public init(_ path: String, method: String = "GET", body: [String: JSONValue]? = nil, expectedService: MicPrismDiscoveredService? = nil, identityAuthorityUrl: String? = nil) { self.path = path self.method = method self.body = body + self.expectedService = expectedService + self.identityAuthorityUrl = identityAuthorityUrl } public static func component(_ value: String) -> String { diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index e331b114c3e4..07e9217dacec 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -180,8 +180,12 @@ public actor T3Client { return config } - public func prism(_ input: PrismRequest) async throws -> PrismResponse { - try await api.prism(input, environment: environment) + public func prismIdentityConfiguration() async throws -> MicPrismIdentityConfiguration { + try await api.prismIdentityConfiguration(environment: environment) + } + + public func prism(_ input: PrismRequest, micScToken: MicPrismTokenSource? = nil) async throws -> PrismResponse { + try await api.prism(input, environment: environment, micScToken: micScToken) } public func usageSummary(_ input: UsageSummaryInput) async throws -> UsageSummary { diff --git a/apps/swift-ios/Features/Prism/MicPrismInferenceView.swift b/apps/swift-ios/Features/Prism/MicPrismInferenceView.swift new file mode 100644 index 000000000000..942741a09c26 --- /dev/null +++ b/apps/swift-ios/Features/Prism/MicPrismInferenceView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +/// Inference uses service authorization only; it does not create an environment agent session. +struct MicPrismInferenceView: View { + let client: any FeatureClient + let environmentID: String + let enabled: Bool + let service: MicPrismDiscoveredService + let authorityUrl: String? + @State private var models: [String] = [] + @State private var model = "" + @State private var prompt = "" + @State private var response = "" + @State private var errorMessage: String? + @State private var loadingModels = false + @State private var request: Task? + @State private var requestGeneration = 0 + + var body: some View { + Section("Try a model") { + Text("Send a prompt through your paired Prism service. Listed models may be unavailable when provider capacity changes.") + .foregroundStyle(.secondary) + Picker("Model", selection: $model) { + if model.isEmpty { Text("Models unavailable").tag("") } + ForEach(models, id: \.self) { Text($0).tag($0) } + }.disabled(!enabled || request != nil || loadingModels) + Button(loadingModels ? "Loading models…" : "Refresh models") { Task { await loadModels() } } + .disabled(!enabled || request != nil || loadingModels) + TextEditor(text: $prompt) + .frame(minHeight: 100) + .accessibilityLabel("Prompt for Prism") + .disabled(!enabled || request != nil) + if request != nil { + Text("Waiting for a response…").foregroundStyle(.secondary) + Button("Cancel request", role: .cancel) { cancel() } + } else { + Button("Send to Prism") { send() } + .disabled(!enabled || model.isEmpty || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || prompt.count > 8000) + } + if let errorMessage { Text(errorMessage).foregroundStyle(.red) } + if !response.isEmpty { Text(response).textSelection(.enabled) } + } + .task { await loadModels() } + .onDisappear { request?.cancel(); request = nil; requestGeneration += 1 } + } + + @MainActor private func loadModels() async { + guard enabled, !loadingModels, request == nil else { return } + loadingModels = true + defer { loadingModels = false } + do { + let result = try await client.prism(PrismRequest("/models", expectedService: service, identityAuthorityUrl: authorityUrl), environmentID: environmentID) + guard !Task.isCancelled else { return } + models = result.models ?? [] + if !models.contains(model) { model = models.first ?? "" } + errorMessage = models.isEmpty ? "No models are listed by this Prism service." : nil + } catch is CancellationError { } + catch { report(error) } + } + + @MainActor private func send() { + guard enabled, request == nil, !model.isEmpty, !prompt.isEmpty, prompt.count <= 8000 else { return } + response = ""; errorMessage = nil + requestGeneration += 1 + let generation = requestGeneration + let input = PrismRequest("/chat", method: "POST", body: ["model": .string(model), "prompt": .string(prompt)], expectedService: service, identityAuthorityUrl: authorityUrl) + request = Task { + defer { if generation == requestGeneration { request = nil } } + do { + let result = try await client.prism(input, environmentID: environmentID) + guard !Task.isCancelled, generation == requestGeneration else { return } + response = result.response ?? "" + } catch is CancellationError { } + catch { + guard !Task.isCancelled, generation == requestGeneration else { return } + report(error) + } + } + } + + @MainActor private func cancel() { + request?.cancel(); request = nil; requestGeneration += 1 + errorMessage = "Request cancelled." + } + + @MainActor private func report(_ error: any Error) { + if let error = error as? MicPrismError { + errorMessage = error.localizedDescription + if case .denied = error { response = ""; models = []; model = "" } + if case .signedOut = error { response = ""; models = []; model = "" } + } else { errorMessage = "Prism could not complete the request. Refresh access and try again." } + } +} diff --git a/apps/swift-ios/Features/Prism/MicPrismThreadController.swift b/apps/swift-ios/Features/Prism/MicPrismThreadController.swift new file mode 100644 index 000000000000..4bafb6f41a60 --- /dev/null +++ b/apps/swift-ios/Features/Prism/MicPrismThreadController.swift @@ -0,0 +1,99 @@ +import Foundation +import Observation + +@MainActor +protocol MicPrismThreadCapable: AnyObject { + var micPrismThreads: MicPrismThreadController { get } +} + +@MainActor +@Observable +final class MicPrismThreadController { + struct Binding: Identifiable { + var id: String { environmentID + "/" + threadID } + let environmentID: String, threadID: String, authorityURL: String + var expiresAt: Double + let sessionID: String + } + private(set) var bindings: [String: Binding] = [:] + private(set) var errorMessage: String? + private let identity: () -> String? + private let attach: (String, String, String) async throws -> PrismResponse + private let detach: (String, String) async throws -> Void + private var tasks: [String: Task] = [:] + private var generations: [String: Int] = [:] + private var pending: Set = [] + private var blockedSessionID: String? + + init(identity: @escaping () -> String?, attach: @escaping (String, String, String) async throws -> PrismResponse, detach: @escaping (String, String) async throws -> Void) { + self.identity = identity; self.attach = attach; self.detach = detach + } + + deinit { tasks.values.forEach { $0.cancel() } } + + func connect(environmentID: String, threadID: String, authorityURL: String) async { + let key = environmentID + "/" + threadID + guard let sessionID = identity(), sessionID != blockedSessionID, !pending.contains(key) else { return } + let generation = generations[key] ?? 0 + pending.insert(key) + defer { pending.remove(key) } + do { + let receipt = try await attach(environmentID, threadID, authorityURL) + guard generation == (generations[key] ?? 0), sessionID == identity(), sessionID != blockedSessionID else { + let cleanup = Task { try await detach(environmentID, threadID) } + _ = try? await cleanup.value + return + } + guard receipt.threadId == threadID, let expiresAt = receipt.expiresAt, + expiresAt > Date().timeIntervalSince1970 * 1000 else { throw MicPrismError.invalidResponse } + bindings[key] = Binding(environmentID: environmentID, threadID: threadID, authorityURL: authorityURL, expiresAt: expiresAt, sessionID: sessionID) + errorMessage = nil + if tasks[key] == nil { + tasks[key] = Task { [weak self] in + var elapsed = 0 + while !Task.isCancelled { + do { try await Task.sleep(for: .seconds(1)) } catch { return } + guard await self?.checkSession(key) == true else { return } + elapsed += 1 + if elapsed >= 45 { + elapsed = 0 + await self?.renew(key) + } + } + } + } + } catch { + await disconnect(environmentID: environmentID, threadID: threadID) + errorMessage = "Prism access could not be renewed for this thread. Check sign-in and environment permissions, then reconnect." + } + } + + private func checkSession(_ key: String) async -> Bool { + guard let binding = bindings[key] else { return false } + guard identity() == binding.sessionID else { + await disconnect(environmentID: binding.environmentID, threadID: binding.threadID) + return false + } + return true + } + + private func renew(_ key: String) async { + guard let binding = bindings[key] else { return } + await connect(environmentID: binding.environmentID, threadID: binding.threadID, authorityURL: binding.authorityURL) + } + + func disconnect(environmentID: String, threadID: String) async { + let key = environmentID + "/" + threadID + generations[key, default: 0] += 1 + tasks.removeValue(forKey: key)?.cancel() + bindings.removeValue(forKey: key) + let teardown = Task { try await detach(environmentID, threadID) } + do { try await teardown.value } + catch { errorMessage = "Could not confirm disconnection. Access will expire unless renewed." } + } + + func disconnectAll() async { + blockedSessionID = identity() + for binding in Array(bindings.values) { await disconnect(environmentID: binding.environmentID, threadID: binding.threadID) } + } +} diff --git a/apps/swift-ios/Features/Prism/MicPrismThreadView.swift b/apps/swift-ios/Features/Prism/MicPrismThreadView.swift new file mode 100644 index 000000000000..0e8358a82d2e --- /dev/null +++ b/apps/swift-ios/Features/Prism/MicPrismThreadView.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct MicPrismThreadView: View { + @Bindable var controller: MicPrismThreadController + let client: any FeatureClient + let environmentID: String + let authorityURL: String? + let threads: [FeatureThread] + @State private var selectedID = "" + @State private var pending = false + @State private var environmentAccess: AuthSessionState? + + private var canRead: Bool { environmentAccess?.authenticated == true && environmentAccess?.scopes?.contains("orchestration:read") == true } + private var canOperate: Bool { canRead && environmentAccess?.scopes?.contains("orchestration:operate") == true } + private var selectedThread: FeatureThread? { canRead ? threads.first { $0.id == selectedID } : nil } + private var wireID: String? { selectedThread.map { $0.wireID ?? $0.id } } + private var binding: MicPrismThreadController.Binding? { wireID.flatMap { controller.bindings[environmentID + "/" + $0] } } + + var body: some View { + Section("Coding threads") { + Text("Enable Prism for a thread you can already access. Access renews while this app is active and ends when you sign out. Choose Prism routing in the thread before sending.") + .foregroundStyle(.secondary) + Picker("Thread", selection: $selectedID) { + Text("Choose a thread").tag("") + ForEach(canRead ? threads : []) { Text($0.title).tag($0.id) } + }.disabled(pending) + Button(pending ? "Updating…" : binding == nil ? "Enable Prism for thread" : "Disconnect Prism") { + guard canOperate, let wireID, let authorityURL else { return } + pending = true + Task { + defer { pending = false } + if binding != nil { await controller.disconnect(environmentID: environmentID, threadID: wireID) } + else { await controller.connect(environmentID: environmentID, threadID: wireID, authorityURL: authorityURL) } + } + }.disabled(pending || !canOperate || wireID == nil || authorityURL == nil) + if let binding { + Text("Prism connected. Access expires \(Date(timeIntervalSince1970: binding.expiresAt / 1000).formatted(date: .omitted, time: .shortened)) unless renewed.") + .foregroundStyle(.secondary) + } + if let message = controller.errorMessage { Text(message).foregroundStyle(.red) } + } + .task(id: environmentID) { environmentAccess = try? await client.prismSession(environmentID: environmentID) } + } +} diff --git a/apps/swift-ios/Features/Prism/PrismView.swift b/apps/swift-ios/Features/Prism/PrismView.swift index 60da6b9aa680..000c6464db4e 100644 --- a/apps/swift-ios/Features/Prism/PrismView.swift +++ b/apps/swift-ios/Features/Prism/PrismView.swift @@ -1,11 +1,18 @@ import SwiftUI +import ClerkKit +import ClerkKitUI public struct PrismView: View { private let client: any FeatureClient private let environments: [FeatureEnvironment] + private let threads: [FeatureThread] @Environment(\.scenePhase) private var scenePhase @State private var environmentID = "" @State private var status: PrismResponse? + @State private var session: AuthSessionState? + @State private var loadedEnvironmentID = "" + @State private var stale = false + @State private var strategy = "" @State private var accounts: [PrismAccount] = [] @State private var errorMessage: String? @State private var pending = false @@ -14,74 +21,186 @@ public struct PrismView: View { @State private var callback = "" @State private var loginCheck = 0 @State private var removing: PrismAccount? + @State private var confirmingRestart = false + @State private var identityConfiguration = MicPrismIdentityConfiguration.disabled + @State private var identity: PrismResponse? + @State private var showingIdentitySignIn = false + @State private var loadGeneration = 0 - public init(client: any FeatureClient, environments: [FeatureEnvironment]) { + public init(client: any FeatureClient, environments: [FeatureEnvironment], threads: [FeatureThread] = []) { self.client = client + self.threads = threads self.environments = environments.filter { $0.isEnabled && $0.prismEnabled == true } } - private var writable: Bool { status?.state == "ready" && status?.role != "replica" && !pending } + private var connected: Bool { + guard let environment = environments.first(where: { $0.id == environmentID }) else { return false } + return environment.connectionState == nil || environment.connectionState == .connected + } + + private var currentStatus: PrismResponse? { loadedEnvironmentID == environmentID ? status : nil } + private var access: PrismAccess { PrismAccess(status: currentStatus, stale: stale, connected: connected, session: loadedEnvironmentID == environmentID ? session : nil) } + private var writable: Bool { access.accounts && !pending } + private var identityController: T3ConnectController? { + guard let controller = (client as? any T3ConnectCapable)?.t3ConnectController, + let expectedKey = identityConfiguration.clerkPublishableKey, + controller.resolution.configuration?.clerkPublishableKey == expectedKey else { return nil } + return controller + } + + private var identityRoutingRead: Bool { identity?.session?.permissions.contains("prism:routing:read") == true } + private var identityRoutingWrite: Bool { + identityConfiguration.enabled && !stale && !pending && identityRoutingRead && + identity?.session?.permissions.contains("prism:routing:write") == true && + (identity?.session?.authorizationExpiresAt ?? 0) > Date().timeIntervalSince1970 * 1000 + } public var body: some View { Form { + if identityConfiguration.enabled { + Section("mic.sc account") { + if let controller = identityController, controller.clerk != nil { + if let knownIdentity = identity { + if let subject = knownIdentity.session?.subject { Text(subject) } + if let service = knownIdentity.discovery?.service { LabeledContent("Paired Prism", value: service.label) } + Button("Sign out of mic.sc") { + loadGeneration += 1 + identity = nil; status = nil; accounts = []; strategy = ""; login = nil; callback = ""; stale = true + Task { await (client as? any T3ConnectCapable)?.signOutT3Connect() } + } + } else { + Button("Sign in with mic.sc") { showingIdentitySignIn = true } + } + Text("Prism access keeps the permissions of your environment connections separate.") + .foregroundStyle(.secondary) + } else { + Text("This build uses another sign-in service. Use a build configured for this mic.sc account service, or open q1code in a browser.") + .foregroundStyle(.secondary) + } + } + } Section { Picker("Environment", selection: $environmentID) { ForEach(environments) { environment in Text(environment.name).tag(environment.id) } } .disabled(login != nil || pending) - LabeledContent("Gateway", value: status?.state ?? "Checking…") - if let role = status?.role { LabeledContent("Pool role", value: role) } - if let version = status?.version { LabeledContent("Engine", value: version) } - if status?.role == "replica" { + LabeledContent("Gateway", value: stale ? "Unavailable" : currentStatus?.state == "access-verified" ? "Access verified" : !connected ? "Offline" : currentStatus?.state ?? "Checking…") + if (stale || !connected), let state = currentStatus?.state { + Text("Last known state: \(state). Management is unavailable until the connection recovers.") + .foregroundStyle(.secondary) + } + if let role = currentStatus?.role { LabeledContent("Pool role", value: role) } + if let version = currentStatus?.version { LabeledContent("Engine", value: version) } + if currentStatus?.role == "replica" { Text("Manage accounts on the primary environment. This gateway receives serving credentials and cannot refresh them.") } if let errorMessage { Text(errorMessage).foregroundStyle(.red) } - if status?.lastSyncError != nil { Text("Account sync needs attention on this environment.").foregroundStyle(.red) } + if currentStatus?.lastSyncError != nil { Text("Account sync needs attention on this environment.").foregroundStyle(.red) } } header: { Text("Prism") } - Section("Accounts") { - if accounts.isEmpty { Text("No accounts available. Sign in on the primary environment to add one.") } - ForEach(accounts) { account in - VStack(alignment: .leading, spacing: 6) { - Text(account.email ?? account.label).font(.headline) - Text(account.provider).font(.caption).foregroundStyle(.secondary) - if account.lifecycle?.requiresLogin == true { - Label("Sign-in required", systemImage: "exclamationmark.circle").foregroundStyle(.red) - } else if account.lifecycle?.unavailable == true { - Text("Unavailable").foregroundStyle(.secondary) - } - Text(account.lifecycle?.expiresAt.map { "Token expiry: \($0)" } ?? "Token expiry unknown") - .font(.caption).foregroundStyle(.secondary) - Toggle("Enabled", isOn: Binding(get: { !account.disabled }, set: { enabled in - Task { await change(PrismRequest("/accounts/" + PrismRequest.component(account.id), method: "PATCH", body: ["disabled": .bool(!enabled)])) } - })).disabled(!writable) - Button("Remove account", role: .destructive) { removing = account }.disabled(!writable) + if identityConfiguration.enabled { + if let controller = (client as? any MicPrismThreadCapable)?.micPrismThreads, identity != nil { + MicPrismThreadView(controller: controller, client: client, environmentID: environmentID, authorityURL: identityConfiguration.authorityUrl, threads: threads.filter { $0.environmentID == environmentID }) + } + if let identity, let service = identity.discovery?.service, currentStatus != nil { + MicPrismInferenceView(client: client, environmentID: environmentID, enabled: !stale, service: service, authorityUrl: identityConfiguration.authorityUrl) + .id(environmentID + (identityConfiguration.authorityUrl ?? "") + service.id + String(service.pairingRevision) + service.apiUrl + (service.inferenceUrl ?? "") + (identity.session?.subject ?? "") + (identityController?.clerk?.session?.id ?? "")) + } + if identityRoutingRead { + Section("Pool routing") { + Picker("Routing strategy", selection: Binding(get: { strategy }, set: { value in + Task { await change(PrismRequest("/routing", method: "PUT", body: ["strategy": .string(value)])) } + })) { + Text("Unknown").tag("") + Text("Round robin").tag("round-robin") + Text("Weighted round robin").tag("weighted-round-robin") + Text("Fill first").tag("fill-first") + }.disabled(!identityRoutingWrite || strategy.isEmpty) } } - } + Section("Prism access") { + Text("Service access is verified separately from engine health and model availability.") + .foregroundStyle(.secondary) + if identity?.session?.permissions.contains("prism:accounts:read") == true { + Text("Remote account management is not available from this service yet.") + .foregroundStyle(.secondary) + } + } + } else if access.accountDetails { + Section("Gateway settings") { + Toggle("Show pooled accounts on Usage → Limits", isOn: Binding(get: { currentStatus?.usageSource ?? true }, set: { enabled in + Task { await change(PrismRequest("/usage-source", method: "PUT", body: ["enabled": .bool(enabled)])) } + })).disabled(!access.configure || pending) + Button("Restart") { confirmingRestart = true }.disabled(!access.configure || pending) + Picker("Routing strategy", selection: Binding(get: { strategy }, set: { value in + Task { await change(PrismRequest("/routing", method: "PUT", body: ["strategy": .string(value)])) } + })) { + Text("Unknown").tag("") + Text("Round robin").tag("round-robin") + Text("Weighted round robin").tag("weighted-round-robin") + Text("Fill first").tag("fill-first") + }.disabled(!access.routing || pending || strategy.isEmpty) + } - Section("Add account") { - if let login { - if let rawURL = login.authUrl, let url = URL(string: rawURL) { - Link("Continue sign-in", destination: url) + Section("Accounts") { + if accounts.isEmpty { Text("No accounts available. Sign in on the primary environment to add one.") } + ForEach(accounts) { account in + VStack(alignment: .leading, spacing: 6) { + Text(account.email ?? account.label).font(.headline) + Text(account.provider).font(.caption).foregroundStyle(.secondary) + if account.lifecycle?.requiresLogin == true { + Label("Sign-in required", systemImage: "exclamationmark.circle").foregroundStyle(.red) + } else if account.lifecycle?.unavailable == true { + Text("Unavailable").foregroundStyle(.secondary) + } + Text(account.lifecycle?.expiresAt.map { "Token expiry: \($0)" } ?? "Token expiry unknown") + .font(.caption).foregroundStyle(.secondary) + Toggle("Enabled", isOn: Binding(get: { !account.disabled }, set: { enabled in + Task { await change(PrismRequest("/accounts/" + PrismRequest.component(account.id), method: "PATCH", body: ["disabled": .bool(!enabled)])) } + })).disabled(!writable) + Button("Remove account", role: .destructive) { removing = account }.disabled(!writable) + } } - if let code = login.userCode { Text(code).font(.body.monospaced()).textSelection(.enabled) } - if login.flow != "device" { - TextField("Completed callback URL", text: $callback) - .textInputAutocapitalization(.never).autocorrectionDisabled() - Button("Submit callback") { Task { await submitCallback() } } - .disabled(callback.isEmpty || pending) + } + + Section("Add account") { + if let login { + if let rawURL = login.authUrl, let url = URL(string: rawURL) { + Link("Continue sign-in", destination: url) + } + if let code = login.userCode { Text(code).font(.body.monospaced()).textSelection(.enabled) } + if login.flow != "device" { + TextField("Completed callback URL", text: $callback) + .textInputAutocapitalization(.never).autocorrectionDisabled() + .disabled(!writable) + Button("Submit callback") { Task { await submitCallback() } } + .disabled(callback.isEmpty || !writable) + } + Button("Check sign-in") { loginCheck += 1 }.disabled(!writable) + Button("Cancel sign-in", role: .cancel) { Task { await cancelLogin() } }.disabled(!writable) + } else { + Button("Sign in to Claude") { Task { await beginLogin("anthropic") } }.disabled(!writable) + Button("Sign in to ChatGPT / Codex") { Task { await beginLogin("codex") } }.disabled(!writable) + Button("Sign in to Grok") { Task { await beginLogin("xai") } }.disabled(!writable) } - Button("Check sign-in") { loginCheck += 1 }.disabled(pending) - Button("Cancel sign-in", role: .cancel) { Task { await cancelLogin() } }.disabled(pending) - } else { - Button("Sign in to Claude") { Task { await beginLogin("anthropic") } }.disabled(!writable) - Button("Sign in to ChatGPT / Codex") { Task { await beginLogin("codex") } }.disabled(!writable) - Button("Sign in to Grok") { Task { await beginLogin("xai") } }.disabled(!writable) + } + } else { + Section("Prism access") { + Text("Pooled account details and management require administrative access.") } } } .navigationTitle("Prism") + .onChange(of: environmentID) { _, _ in + loadGeneration += 1 + identityConfiguration = .disabled; identity = nil; session = nil + status = nil; accounts = []; strategy = ""; stale = false + } + .onReceive(NotificationCenter.default.publisher(for: .t3ConnectSessionChanged)) { _ in + loadGeneration += 1 + identity = nil; status = nil; accounts = []; strategy = ""; login = nil; callback = ""; stale = true + Task { await load() } + } .refreshable { await load() } .task { environmentID = environments.first(where: \.isActive)?.id ?? environments.first?.id ?? "" @@ -93,8 +212,8 @@ public struct PrismView: View { do { try await Task.sleep(for: .seconds(10)) } catch { return } } while !Task.isCancelled } - .task(id: (login?.sessionId ?? "") + String(loginCheck)) { - guard let id = login?.sessionId else { return } + .task(id: (login?.sessionId ?? "") + String(loginCheck) + String(access.accounts) + String(describing: scenePhase)) { + guard access.accounts, scenePhase == .active, let id = login?.sessionId else { return } while !Task.isCancelled { do { let result = try await client.prism(PrismRequest("/accounts/login/" + PrismRequest.component(id)), environmentID: loginEnvironmentID) @@ -116,30 +235,102 @@ public struct PrismView: View { Task { await change(PrismRequest("/accounts/" + PrismRequest.component(account.id), method: "DELETE")) } } } + .confirmationDialog("Restart Prism? Requests will fail until it is ready again.", isPresented: $confirmingRestart) { + Button("Restart", role: .destructive) { Task { await change(PrismRequest("/restart", method: "POST")) } } + } + .sheet(isPresented: $showingIdentitySignIn, onDismiss: { + Task { + if identityController?.clerk?.session != nil { await identityController?.refreshAfterAuthentication() } + await load() + } + }) { + if let clerk = identityController?.clerk { + AuthView(mode: .signInOrUp).environment(clerk) + } + } } @MainActor private func load() async { + guard !environmentID.isEmpty, !pending else { return } + loadGeneration += 1 + let generation = loadGeneration let selected = environmentID - guard !selected.isEmpty else { return } do { + let config = try await client.prismIdentityConfiguration(environmentID: selected) + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + identityConfiguration = config + if config.enabled { + let nextIdentity = try await client.prism(PrismRequest("/identity/access"), environmentID: selected) + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + identity = nextIdentity + } else { identity = nil } let nextStatus = try await client.prism(PrismRequest("/status"), environmentID: selected) - let nextAccounts = nextStatus.state == "ready" - ? try await client.prism(PrismRequest("/accounts"), environmentID: selected).accounts ?? [] : [] - guard selected == environmentID, !Task.isCancelled else { return } - status = nextStatus; accounts = nextAccounts; errorMessage = nil + let nextSession: AuthSessionState? + if config.enabled { nextSession = nil } + else { nextSession = try? await client.prismSession(environmentID: selected) } + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + if loadedEnvironmentID != selected { accounts = []; strategy = "" } + loadedEnvironmentID = selected + status = nextStatus; session = nextSession; stale = false; errorMessage = nil + if config.enabled { + if identityRoutingRead { + let routing = try await client.prism(PrismRequest("/routing"), environmentID: selected) + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + strategy = routing.strategy ?? "" + } else { strategy = "" } + return + } } catch is CancellationError { } catch { - guard selected == environmentID else { return } - status = nil; accounts = []; errorMessage = "Prism is unavailable. Check the connection and whether Prism is enabled on this environment." + guard selected == environmentID, generation == loadGeneration else { return } + stale = true + if let micError = error as? MicPrismError { + errorMessage = micError.localizedDescription + if case .signedOut = micError { identity = nil; status = nil; accounts = []; strategy = "" } + if case .denied = micError { identity = nil; status = nil; accounts = []; strategy = "" } + } else { errorMessage = "Prism is unavailable. Check the connection and whether Prism is enabled on this environment." } + return + } + guard selected == environmentID, generation == loadGeneration, access.accountDetails, currentStatus?.state == "ready", !Task.isCancelled else { return } + do { + let nextAccounts = try await client.prism(PrismRequest("/accounts"), environmentID: selected).accounts ?? [] + let nextRouting = try await client.prism(PrismRequest("/routing"), environmentID: selected).strategy ?? "" + guard selected == environmentID, generation == loadGeneration, !Task.isCancelled else { return } + accounts = nextAccounts; strategy = nextRouting + } catch is CancellationError { } + catch { + guard selected == environmentID, generation == loadGeneration else { return } + errorMessage = "Could not load Prism management details. Check your account permissions." } } @MainActor private func change(_ request: PrismRequest) async { - guard !pending else { return } + let allowed: Bool + if identityConfiguration.enabled { allowed = request.path == "/routing" && identityRoutingWrite } + else if request.path == "/restart" || request.path == "/usage-source" { allowed = access.configure } + else if request.path == "/routing" { allowed = access.routing } + else { allowed = access.accounts } + guard allowed, !pending else { return } pending = true + loadGeneration += 1 + let generation = loadGeneration + let selected = environmentID + let boundRequest = identityConfiguration.enabled ? PrismRequest(request.path, method: request.method, body: request.body, expectedService: identity?.discovery?.service, identityAuthorityUrl: identityConfiguration.authorityUrl) : request defer { pending = false } - do { _ = try await client.prism(request, environmentID: environmentID); await load() } - catch { errorMessage = "The account change failed. Manage pooled accounts on the primary environment." } + do { + _ = try await client.prism(boundRequest, environmentID: selected) + guard selected == environmentID, generation == loadGeneration else { return } + pending = false + await load() + } catch { + guard selected == environmentID, generation == loadGeneration else { return } + stale = true + if let micError = error as? MicPrismError { + errorMessage = micError.localizedDescription + if case .signedOut = micError { identity = nil; status = nil; strategy = "" } + if case .denied = micError { identity = nil; status = nil; strategy = "" } + } else { errorMessage = "The Prism change failed. Refresh access and try again." } + } } @MainActor private func beginLogin(_ provider: String) async { @@ -152,7 +343,7 @@ public struct PrismView: View { } @MainActor private func submitCallback() async { - guard let id = login?.sessionId else { return } + guard writable, let id = login?.sessionId else { return } pending = true defer { pending = false } do { @@ -162,7 +353,7 @@ public struct PrismView: View { } @MainActor private func cancelLogin() async { - guard let id = login?.sessionId else { return } + guard writable, let id = login?.sessionId else { return } pending = true defer { pending = false } do { diff --git a/apps/swift-ios/Features/Shared/FeatureClient.swift b/apps/swift-ios/Features/Shared/FeatureClient.swift index bfe1a3bf89f1..91c1db878c7b 100644 --- a/apps/swift-ios/Features/Shared/FeatureClient.swift +++ b/apps/swift-ios/Features/Shared/FeatureClient.swift @@ -106,6 +106,8 @@ public protocol FeatureClient: AnyObject { ) async throws -> FeatureAutomaticSettlementSettings func prism(_ input: PrismRequest, environmentID: String) async throws -> PrismResponse + func prismSession(environmentID: String) async throws -> AuthSessionState + func prismIdentityConfiguration(environmentID: String) async throws -> MicPrismIdentityConfiguration func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] func pullRequestLists(_ input: PullRequestListInput) async throws -> [FeaturePullRequestEnvironmentList] @@ -255,6 +257,12 @@ public extension FeatureClient { func prism(_ input: PrismRequest, environmentID: String) async throws -> PrismResponse { throw FeatureCapabilityUnavailable("Prism") } + func prismSession(environmentID: String) async throws -> AuthSessionState { + throw FeatureCapabilityUnavailable("Prism session") + } + func prismIdentityConfiguration(environmentID: String) async throws -> MicPrismIdentityConfiguration { + .disabled + } func usageSummaries(_ input: UsageSummaryInput) async throws -> [FeatureEnvironmentUsage] { [] } diff --git a/apps/swift-ios/Features/Workspace/WorkspaceView.swift b/apps/swift-ios/Features/Workspace/WorkspaceView.swift index b7771c2c0a7f..104546818280 100644 --- a/apps/swift-ios/Features/Workspace/WorkspaceView.swift +++ b/apps/swift-ios/Features/Workspace/WorkspaceView.swift @@ -173,7 +173,7 @@ public struct WorkspaceView: View { } .sheet(isPresented: $showingPrism) { NavigationStack { - PrismView(client: model.client, environments: model.snapshot.environments) + PrismView(client: model.client, environments: model.snapshot.environments, threads: model.snapshot.threads) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingPrism = false } } } } } diff --git a/apps/swift-ios/Tests/CoreTests/MicPrismClientTests.swift b/apps/swift-ios/Tests/CoreTests/MicPrismClientTests.swift new file mode 100644 index 000000000000..9e28a97e85eb --- /dev/null +++ b/apps/swift-ios/Tests/CoreTests/MicPrismClientTests.swift @@ -0,0 +1,165 @@ +import XCTest +@testable import T3Code + +@MainActor +final class MicPrismClientTests: XCTestCase { + private let configuration = MicPrismIdentityConfiguration(enabled: true, clerkPublishableKey: "fixture-key", authorityUrl: "https://identity.example.test") + + func testOrdinaryUserDiscoversServiceWithoutEnvironmentCredentialsOrAccountDetails() async throws { + let transport = MicPrismFixtureTransport() + let api = MicPrismClient(transport: transport) + let identity = try await api.call(PrismRequest("/identity/access"), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTAssertEqual(identity.discovery?.service?.label, "Shared Prism") + let status = try await api.call(PrismRequest("/status"), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTAssertEqual(status.state, "access-verified") + XCTAssertEqual(status.capabilities?.accountDetails, false) + let requests = await transport.requests + XCTAssertTrue(requests.allSatisfy { $0.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-session" }) + XCTAssertTrue(requests.allSatisfy { $0.value(forHTTPHeaderField: "x-mic-sc-session") == nil }) + XCTAssertFalse(requests.contains { $0.url?.path.hasPrefix("/api/") == true }) + } + + func testRoutingWriteRequiresExactPermissionBeforeGatewayRequest() async throws { + let transport = MicPrismFixtureTransport() + do { + _ = try await MicPrismClient(transport: transport).call(PrismRequest("/routing", method: "PUT", body: ["strategy": .string("fill-first")]), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTFail("Expected missing routing grant") + } catch MicPrismError.denied { } + let requests = await transport.requests + XCTAssertEqual(requests.count, 1) + XCTAssertEqual(requests.first?.url?.path, "/v1/identity") + } + + func testAuthorizedRoutingUsesDirectGatewayAndAcknowledgesSavedValue() async throws { + let transport = MicPrismFixtureTransport(routingAllowed: true) + let result = try await MicPrismClient(transport: transport).call(PrismRequest("/routing", method: "PUT", body: ["strategy": .string("fill-first")]), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTAssertEqual(result.strategy, "fill-first") + let requests = await transport.requests + XCTAssertEqual(requests.last?.url?.absoluteString, "https://prism.example.test/prism/v1/routing") + XCTAssertEqual(requests.last?.httpMethod, "PUT") + } + + func testRevocationDoesNotRetryOrExposeAuthorityErrorBody() async throws { + let transport = MicPrismFixtureTransport(revoked: true) + do { + _ = try await MicPrismClient(transport: transport).call(PrismRequest("/status"), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTFail("Expected revocation") + } catch MicPrismError.denied { } + let requests = await transport.requests + XCTAssertEqual(requests.count, 1) + } + + func testAccountSwitchDuringTokenRefreshStopsBeforeNetwork() async throws { + let transport = MicPrismFixtureTransport() + let binding = MicPrismFixtureBinding() + do { + _ = try await MicPrismClient(transport: transport).call(PrismRequest("/status"), configuration: configuration, token: { + await binding.invalidate() + return "previous-fixture-session" + }, isCurrent: { await binding.current }) + XCTFail("Expected account-switch rejection") + } catch MicPrismError.signedOut { } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } + + func testModelCatalogUsesFreshInferenceCredentialWithoutExposingAccountDetails() async throws { + let transport = MicPrismFixtureTransport() + let result = try await MicPrismClient(transport: transport).call(PrismRequest("/models"), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTAssertEqual(result.models, ["fixture-model"]) + XCTAssertNil(result.accounts) + let requests = await transport.requests + XCTAssertEqual(requests.last?.value(forHTTPHeaderField: "Authorization"), "Bearer msp1.fixture.inference") + XCTAssertEqual(requests.dropLast().last?.value(forHTTPHeaderField: "Authorization"), "Bearer fixture-session") + XCTAssertEqual(requests.dropLast().last?.url?.path, "/v1/prism/credentials") + } + + func testCredentialAllowsBoundedAuthorityClockSkew() async throws { + let transport = MicPrismFixtureTransport(credentialOffsetMs: 901_000) + let result = try await MicPrismClient(transport: transport).call(PrismRequest("/models"), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTAssertEqual(result.models, ["fixture-model"]) + } + + func testCredentialBeyondSkewAllowanceIsRejectedBeforeInference() async throws { + let transport = MicPrismFixtureTransport(credentialOffsetMs: 931_000) + do { + _ = try await MicPrismClient(transport: transport).call(PrismRequest("/models"), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTFail("Expected excessive credential lifetime rejection") + } catch MicPrismError.invalidResponse { } + let requests = await transport.requests + XCTAssertFalse(requests.contains { $0.url?.path == "/v1/models" }) + } + + func testOrdinaryInferenceDoesNotCreateAnEnvironmentSession() async throws { + let transport = MicPrismFixtureTransport() + let result = try await MicPrismClient(transport: transport).call(PrismRequest("/chat", method: "POST", body: ["model": .string("fixture-model"), "prompt": .string("Hello")]), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTAssertEqual(result.response, "Hello from Prism") + let requests = await transport.requests + XCTAssertEqual(requests.last?.url?.path, "/v1/chat/completions") + XCTAssertEqual(requests.last?.value(forHTTPHeaderField: "Authorization"), "Bearer msp1.fixture.inference") + XCTAssertFalse(requests.contains { $0.url?.path.hasPrefix("/api/") == true }) + } + + func testStaleHostIntentCannotRetargetAnInferenceRequest() async throws { + let transport = MicPrismFixtureTransport() + let service = try JSONDecoder().decode(MicPrismDiscoveredService.self, from: Data(#"{"id":"previous-instance","label":"Previous Prism","apiUrl":"https://previous.example.test","pairingRevision":1}"#.utf8)) + do { + _ = try await MicPrismClient(transport: transport).call(PrismRequest("/models", expectedService: service), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTFail("Expected stale-host rejection") + } catch MicPrismError.unavailable { } + let requests = await transport.requests + XCTAssertEqual(requests.count, 2) + XCTAssertFalse(requests.contains { $0.url?.path == "/v1/prism/credentials" }) + } + + func testUnsupportedAccountManagementNeverSendsRequest() async throws { + let transport = MicPrismFixtureTransport(routingAllowed: true) + do { + _ = try await MicPrismClient(transport: transport).call(PrismRequest("/accounts"), configuration: configuration, token: { "fixture-session" }, isCurrent: { true }) + XCTFail("Expected unsupported operation") + } catch MicPrismError.unsupported { } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } +} + +private actor MicPrismFixtureBinding { + var current = true + func invalidate() { current = false } +} + +private actor MicPrismFixtureTransport: HTTPTransport { + var requests: [URLRequest] = [] + let routingAllowed: Bool + let revoked: Bool + let credentialOffsetMs: Double + init(routingAllowed: Bool = false, revoked: Bool = false, credentialOffsetMs: Double = 60_000) { + self.routingAllowed = routingAllowed + self.revoked = revoked + self.credentialOffsetMs = credentialOffsetMs + } + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + requests.append(request) + let body: String + switch request.url!.path { + case "/v1/identity": + let permissions = routingAllowed ? #"["prism:inference","prism:routing:read","prism:routing:write"]"# : #"["prism:inference"]"# + body = """ + {"contractVersion":1,"subject":"fixture-member","role":"member","permissions":\(permissions),"authorizationExpiresAt":\((Date().timeIntervalSince1970 + 60) * 1000),"authorizationRevision":"fixture-revision"} + """ + case "/v1/prism/discovery": + body = #"{"contractVersion":1,"selectionRevision":1,"service":{"serviceInstanceId":"fixture-prism","displayName":"Shared Prism","apiOrigin":"https://prism.example.test","inferenceOrigin":"https://prism.example.test","pairingRevision":1,"protocolVersion":1,"publicKey":"fixture-public-key","status":"paired"}}"# + case "/prism/v1/status": + body = #"{"serviceInstanceId":"fixture-prism","pairingRevision":1,"authorization":"current","engineHealth":"unknown"}"# + case "/prism/v1/routing": body = #"{"strategy":"fill-first"}"# + case "/v1/prism/credentials": + body = """ + {"version":1,"tokenType":"Bearer","token":"msp1.fixture.inference","serviceInstanceId":"fixture-prism","pairingRevision":1,"expiresAt":\(Date().timeIntervalSince1970 * 1000 + credentialOffsetMs)} + """ + case "/v1/models": body = #"{"data":[{"id":"fixture-model","owned_by":"private-provider"}]}"# + case "/v1/chat/completions": body = #"{"choices":[{"message":{"content":"Hello from Prism"}}]}"# + default: throw MicPrismError.unsupported + } + return (Data(body.utf8), HTTPURLResponse(url: request.url!, statusCode: revoked ? 403 : 200, httpVersion: nil, headerFields: nil)!) + } +} diff --git a/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift index d2e347fe8ae2..e33cd3ae6448 100644 --- a/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift +++ b/apps/swift-ios/Tests/CoreTests/NativeContractExpansionTests.swift @@ -3,6 +3,62 @@ import XCTest @MainActor final class NativeContractExpansionTests: XCTestCase { + func testPrismRenewsMicIdentityWithoutReplacingEnvironmentAuthorization() async throws { + let environment = Environment(id: "prism-env", label: "Prism", httpBaseURL: URL(string: "https://prism.example")!, webSocketBaseURL: URL(string: "wss://prism.example")!) + let credentials = InMemoryCredentialStore(credentials: [environment.id: EnvironmentCredential(accessToken: "environment-credential", scopes: ["orchestration:read"])]) + let transport = AccessHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: credentials) + let source = PrismTestTokenSource() + _ = try await api.prism(PrismRequest("/status"), environment: environment, micScToken: { await source.next() }) + _ = try await api.prism(PrismRequest("/status"), environment: environment, micScToken: { await source.next() }) + _ = try await api.prism(PrismRequest("/status"), environment: environment) + let requests = await transport.requests + XCTAssertEqual(requests.map { $0.value(forHTTPHeaderField: "Authorization") }, Array(repeating: "Bearer environment-credential", count: 3)) + XCTAssertEqual(requests.map { $0.value(forHTTPHeaderField: "x-mic-sc-session") }, ["mic-token-1", "mic-token-2", nil]) + } + + func testPrismRejectsBeforeSendingWhenMicIdentityRenewalFails() async throws { + let environment = Environment(id: "prism-env", label: "Prism", httpBaseURL: URL(string: "https://prism.example")!, webSocketBaseURL: URL(string: "wss://prism.example")!) + let credentials = InMemoryCredentialStore(credentials: [environment.id: EnvironmentCredential(accessToken: "environment-credential", scopes: ["orchestration:read"])]) + let transport = AccessHTTPTransport() + let api = EnvironmentAPI(transport: transport, credentials: credentials) + do { + _ = try await api.prism(PrismRequest("/status"), environment: environment, micScToken: { throw HTTPError.missingCredential }) + XCTFail("Expected failed mic.sc renewal to stop the request") + } catch { } + let requests = await transport.requests + XCTAssertTrue(requests.isEmpty) + } + func testPrismScopeAndStaleStatusDecisions() throws { + let status = try JSONDecoder().decode(PrismResponse.self, from: Data(#"{"state":"ready","role":"primary"}"#.utf8)) + let session = try JSONDecoder().decode(AuthSessionState.self, from: Data(#"{"authenticated":true,"scopes":["orchestration:read","access:write"]}"#.utf8)) + let live = PrismAccess(status: status, stale: false, connected: true, session: session) + XCTAssertTrue(live.accounts) + XCTAssertTrue(live.configure) + let offline = PrismAccess(status: status, stale: true, connected: true, session: session) + XCTAssertTrue(offline.accountDetails) + XCTAssertFalse(offline.accounts) + XCTAssertFalse(offline.routing) + XCTAssertFalse(offline.configure) + let unpaired = PrismAccess(status: status, stale: false, connected: true, session: nil) + XCTAssertFalse(unpaired.accounts) + XCTAssertFalse(unpaired.configure) + } + + func testPrismIdentityCapabilitiesDoNotGrantLocalConfigurationAccess() throws { + let status = try JSONDecoder().decode(PrismResponse.self, from: Data(#"{"state":"ready","role":"primary","capabilities":{"inference":true,"manage":true,"accountDetails":true}}"#.utf8)) + let session = try JSONDecoder().decode(AuthSessionState.self, from: Data(#"{"authenticated":true,"scopes":["orchestration:read"]}"#.utf8)) + let access = PrismAccess(status: status, stale: false, connected: true, session: session) + XCTAssertTrue(access.accounts) + XCTAssertTrue(access.routing) + XCTAssertFalse(access.configure) + let revoked = try JSONDecoder().decode(PrismResponse.self, from: Data(#"{"state":"ready","role":"primary","capabilities":{"inference":true,"manage":false,"accountDetails":false}}"#.utf8)) + let restricted = PrismAccess(status: revoked, stale: false, connected: true, session: session) + XCTAssertFalse(restricted.accountDetails) + XCTAssertFalse(restricted.accounts) + XCTAssertFalse(restricted.routing) + } + func testAdministrativeClientSessionContractsAndRequests() async throws { let environment = Environment( id: "environment-1", @@ -448,6 +504,11 @@ final class NativeContractExpansionTests: XCTestCase { } } +private actor PrismTestTokenSource { + private var sequence = 0 + func next() -> String { sequence += 1; return "mic-token-\(sequence)" } +} + private actor AccessHTTPTransport: HTTPTransport { private(set) var requests: [URLRequest] = [] diff --git a/apps/swift-ios/Tests/FeatureTests/MicPrismThreadControllerTests.swift b/apps/swift-ios/Tests/FeatureTests/MicPrismThreadControllerTests.swift new file mode 100644 index 000000000000..9b4b26afaeca --- /dev/null +++ b/apps/swift-ios/Tests/FeatureTests/MicPrismThreadControllerTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import T3Code + +@MainActor +final class MicPrismThreadControllerTests: XCTestCase { + func testDisconnectDuringConnectionCannotRestoreTheBinding() async throws { + let gate = MicPrismAttachGate() + var disconnects = 0 + let controller = MicPrismThreadController(identity: { "session-a" }, attach: { _, _, _ in try await gate.attach() }, detach: { _, _ in disconnects += 1 }) + let connect = Task { await controller.connect(environmentID: "env", threadID: "thread", authorityURL: "https://identity.example.test") } + await gate.waitUntilAttached() + await controller.disconnect(environmentID: "env", threadID: "thread") + await gate.finish(try receipt()) + await connect.value + XCTAssertTrue(controller.bindings.isEmpty) + XCTAssertEqual(disconnects, 2) + } + + func testAccountSwitchDuringConnectionDisconnectsTheOldBroker() async throws { + let gate = MicPrismAttachGate() + var session = "session-a" + var disconnects = 0 + let controller = MicPrismThreadController(identity: { session }, attach: { _, _, _ in try await gate.attach() }, detach: { _, _ in disconnects += 1 }) + let connect = Task { await controller.connect(environmentID: "env", threadID: "thread", authorityURL: "https://identity.example.test") } + await gate.waitUntilAttached() + session = "session-b" + await gate.finish(try receipt()) + await connect.value + XCTAssertTrue(controller.bindings.isEmpty) + XCTAssertEqual(disconnects, 1) + } + + func testSignOutPreventsReconnectUsingTheSameSession() async throws { + var connects = 0 + var disconnects = 0 + let value = try receipt() + let controller = MicPrismThreadController(identity: { "session-a" }, attach: { _, _, _ in connects += 1; return value }, detach: { _, _ in disconnects += 1 }) + await controller.connect(environmentID: "env", threadID: "thread", authorityURL: "https://identity.example.test") + XCTAssertEqual(controller.bindings.count, 1) + await controller.disconnectAll() + await controller.connect(environmentID: "env", threadID: "thread", authorityURL: "https://identity.example.test") + XCTAssertTrue(controller.bindings.isEmpty) + XCTAssertEqual(connects, 1) + XCTAssertEqual(disconnects, 1) + } + + func testCancelledRenewalStillSendsIndependentDisconnect() async throws { + var disconnects = 0 + let value = try receipt() + let controller = MicPrismThreadController(identity: { "session-a" }, attach: { _, _, _ in value }, detach: { _, _ in + try Task.checkCancellation() + disconnects += 1 + }) + await controller.connect(environmentID: "env", threadID: "thread", authorityURL: "https://identity.example.test") + let cancelled = Task { await controller.disconnect(environmentID: "env", threadID: "thread") } + cancelled.cancel() + await cancelled.value + XCTAssertEqual(disconnects, 1) + XCTAssertTrue(controller.bindings.isEmpty) + } + + private func receipt() throws -> PrismResponse { + let body = "{\"threadId\":\"thread\",\"expiresAt\":\((Date().timeIntervalSince1970 + 900) * 1000)}" + return try JSONDecoder().decode(PrismResponse.self, from: Data(body.utf8)) + } +} + +private actor MicPrismAttachGate { + private var started = false + private var waiter: CheckedContinuation? + private var response: CheckedContinuation? + func attach() async throws -> PrismResponse { + try await withCheckedThrowingContinuation { continuation in + response = continuation; started = true; waiter?.resume(); waiter = nil + } + } + func waitUntilAttached() async { + if started { return } + await withCheckedContinuation { waiter = $0 } + } + func finish(_ value: PrismResponse) { response?.resume(returning: value); response = nil } +} diff --git a/apps/web/package.json b/apps/web/package.json index e767081c7369..21aa861f6d3a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -67,6 +67,7 @@ "@vitejs/plugin-react": "^6.0.0", "babel-plugin-react-compiler": "1.0.0", "compression": "^1.8.1", + "playwright": "1.60.0", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", "vite": "catalog:", diff --git a/apps/web/src/fork/mic-identity/BrowserMicIdentity.tsx b/apps/web/src/fork/mic-identity/BrowserMicIdentity.tsx new file mode 100644 index 000000000000..3a704bf3ae92 --- /dev/null +++ b/apps/web/src/fork/mic-identity/BrowserMicIdentity.tsx @@ -0,0 +1,15 @@ +import { ClerkProvider } from "@clerk/react"; +import { clerkAppearance } from "~/components/clerk/clerkAppearance"; +import { MicIdentitySession } from "./MicIdentitySession"; + +export default function BrowserMicIdentity({ + publishableKey, +}: { + readonly publishableKey: string; +}) { + return ( + + + + ); +} diff --git a/apps/web/src/fork/mic-identity/ElectronMicIdentity.tsx b/apps/web/src/fork/mic-identity/ElectronMicIdentity.tsx new file mode 100644 index 000000000000..1d34eaa1e6bd --- /dev/null +++ b/apps/web/src/fork/mic-identity/ElectronMicIdentity.tsx @@ -0,0 +1,16 @@ +import { ClerkProvider } from "@clerk/electron/react"; +import { passkeys } from "@clerk/electron/passkeys"; +import { clerkAppearance } from "~/components/clerk/clerkAppearance"; +import { MicIdentitySession } from "./MicIdentitySession"; + +export default function ElectronMicIdentity({ + publishableKey, +}: { + readonly publishableKey: string; +}) { + return ( + + + + ); +} diff --git a/apps/web/src/fork/mic-identity/MicIdentityNavigation.tsx b/apps/web/src/fork/mic-identity/MicIdentityNavigation.tsx new file mode 100644 index 000000000000..7b2a8e09dc1f --- /dev/null +++ b/apps/web/src/fork/mic-identity/MicIdentityNavigation.tsx @@ -0,0 +1,53 @@ +import { lazy, Suspense } from "react"; +import { AlertCircleIcon } from "lucide-react"; +import { SidebarMenuButton, SidebarMenuItem } from "~/components/ui/sidebar"; +import { isElectron } from "~/env"; +import { hasCloudPublicConfig, resolveCloudPublicConfig } from "~/cloud/publicConfig"; +import { useMicIdentityConfig } from "./useMicIdentityConfig"; + +const Identity = lazy(() => + isElectron ? import("./ElectronMicIdentity") : import("./BrowserMicIdentity"), +); +const SharedIdentity = lazy(() => + import("./MicIdentitySession").then((module) => ({ default: module.MicIdentitySession })), +); + +/** Hosted builds or a paired environment supply the mic.sc issuer/key. */ +export function MicIdentityNavigation() { + const { config, error, retry } = useMicIdentityConfig(); + const key = config?.enabled ? config.clerkPublishableKey : null; + const existingKey = hasCloudPublicConfig() + ? resolveCloudPublicConfig().clerkPublishableKey + : undefined; + const incompatible = existingKey && key && existingKey !== key; + if (error || incompatible) + return ( + + + + mic.sc unavailable + + + ); + return key ? ( + + + Loading mic.sc sign-in… + + + } + > + {existingKey ? : } + + ) : null; +} diff --git a/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx b/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx new file mode 100644 index 000000000000..cdc2990d0567 --- /dev/null +++ b/apps/web/src/fork/mic-identity/MicIdentityPanel.tsx @@ -0,0 +1,354 @@ +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { CheckIcon, CloudOffIcon, LogInIcon, ShieldCheckIcon, ServerIcon } from "lucide-react"; +import * as Effect from "effect/Effect"; +import { + getMicIdentityAccess, + getMicPrismStatus, + getMicPrismRouting, + setMicPrismRouting, +} from "@t3tools/client-runtime/fork"; +import type { MicIdentityAccess } from "@q1code/core/micIdentityApi"; +import type { PrismRoutingStrategy } from "@q1code/core/config"; +import { Button } from "~/components/ui/button"; +import { + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "~/components/settings/settingsLayout"; +import { runtime } from "~/lib/runtime"; +import { useMicIdentityConfig } from "./useMicIdentityConfig"; +import { useDocumentVisible } from "../prism/prismUi"; +import { + micIdentityGeneration, + micIdentitySessionSnapshot, + readMicIdentityToken, + subscribeMicIdentity, +} from "./micIdentitySession"; +import { MicPrismPairing } from "./MicPrismPairing"; +import { MicPrismChat } from "./MicPrismChat"; + +type View = { + authorityUrl: string; + generation: number; + access: MicIdentityAccess; + routing: PrismRoutingStrategy | null; + receivedAt: number; +}; + +export function MicIdentityPanel() { + const { config, error: configError, retry, revision } = useMicIdentityConfig(); + const generation = useSyncExternalStore(subscribeMicIdentity, micIdentityGeneration); + const session = useSyncExternalStore(subscribeMicIdentity, micIdentitySessionSnapshot); + const visible = useDocumentVisible(); + const [view, setView] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [saved, setSaved] = useState(null); + const operation = useRef(0); + const mutating = useRef(false); + const current = + session.status === "signed-in" && + view?.generation === generation && + view.authorityUrl === config?.authorityUrl + ? view + : null; + const service = current?.access.discovery.service; + + useEffect(() => { + const authorityUrl = config?.authorityUrl; + if (!visible || !authorityUrl || session.status !== "signed-in") return; + let cancelled = false; + let loading = false; + const controller = new AbortController(); + const tick = async () => { + if (loading || mutating.current) return; + loading = true; + const ticket = ++operation.current; + try { + const result = await runtime.runPromise( + Effect.gen(function* () { + const input = { + baseUrl: authorityUrl, + getToken: readMicIdentityToken, + isCurrent: () => micIdentityGeneration() === generation, + }; + const access = yield* getMicIdentityAccess({ ...input, allowUnpaired: true }); + if (!access.discovery.service) return { access, routing: null }; + yield* getMicPrismStatus(input); + const routing = access.session.permissions.includes("prism:routing:read") + ? (yield* getMicPrismRouting(input)).strategy + : null; + return { access, routing }; + }).pipe(Effect.result), + { signal: controller.signal }, + ); + if (cancelled || ticket !== operation.current) return; + if (result._tag === "Failure") { + setError(result.failure.message); + if ( + result.failure._tag === "MicIdentityUnauthorizedError" || + result.failure._tag === "MicIdentityForbiddenError" + ) + setView(null); + } else { + setError(null); + setView({ ...result.success, authorityUrl, generation, receivedAt: Date.now() }); + } + } catch { + if (!cancelled) setError("Prism could not be reached. Try again."); + } finally { + loading = false; + } + }; + void tick(); + const timer = window.setInterval(() => void tick(), 10_000); + return () => { + cancelled = true; + operation.current++; + controller.abort(); + window.clearInterval(timer); + }; + }, [config?.authorityUrl, generation, session.status, visible, revision]); + + const changeRouting = async (strategy: PrismRoutingStrategy) => { + if ( + !current || + !service || + error || + mutating.current || + !config?.authorityUrl || + !current.access.session.permissions.includes("prism:routing:write") + ) + return; + const ticket = ++operation.current; + const target = service; + setBusy(true); + setSaved(null); + mutating.current = true; + try { + const result = await runtime.runPromise( + Effect.gen(function* () { + const input = { + baseUrl: config.authorityUrl!, + expectedService: target, + getToken: readMicIdentityToken, + isCurrent: () => micIdentityGeneration() === generation, + }; + const access = yield* getMicIdentityAccess(input); + if ( + access.discovery.service?.id !== target.id || + access.discovery.service.pairingRevision !== target.pairingRevision + ) + return { changedHost: true as const }; + yield* setMicPrismRouting({ ...input, strategy }); + return { + changedHost: false as const, + routing: (yield* getMicPrismRouting(input)).strategy, + }; + }).pipe(Effect.result), + ); + if (ticket !== operation.current || micIdentityGeneration() !== generation) return; + if (result._tag === "Failure") { + setError(result.failure.message); + if ( + result.failure._tag === "MicIdentityUnauthorizedError" || + result.failure._tag === "MicIdentityForbiddenError" + ) + setView(null); + } else if (result.success.changedHost) { + setError("Your paired host changed. Refresh before changing routing."); + } else { + const confirmed = { ...current, routing: result.success.routing, receivedAt: Date.now() }; + setView(confirmed); + setSaved(confirmed); + } + } catch { + if (micIdentityGeneration() === generation) + setError("The routing change could not be confirmed. Refresh to check its current value."); + } finally { + mutating.current = false; + setBusy(false); + } + }; + + const signedIn = session.status === "signed-in"; + const message = session.error ?? configError ?? (signedIn ? error : null); + return ( + +

+
+
+
+ + mic.sc +
+

Your Prism connection

+

+ Use the shared model pool with your mic.sc account. Your environments and workspaces + keep their own access controls. +

+
+ {signedIn && session.signOut ? ( + + ) : null} +
+ {!signedIn ? ( +
+ +

Sign in to mic.sc

+

+ Find your paired host and use Prism. You can get started without connecting a coding + environment. +

+ + {session.error && session.signOut ? ( + + ) : null} +
+ ) : current ? ( + <> +
+
+ {error ? ( + + ) : ( + + )} +
+
+
+

{service?.label ?? "No paired host yet"}

+ {service ? ( + + {error ? "Offline" : "Access verified"} + + ) : null} +
+

+ {error + ? "Showing the last verified host. Changes and new requests are paused." + : service + ? "Your mic.sc session is authorized for this host." + : "You're signed in. A Prism administrator needs to pair and select a host before you can send requests."} +

+
+ + {current.access.session.globalAdmin || current.access.session.capabilities.manage + ? "Administrator" + : current.access.session.permissions.some( + (permission) => permission !== "prism:inference", + ) + ? "Scoped access" + : "Inference access"} + +
+ {service && config?.authorityUrl ? ( + + ) : null} + {current.routing !== null ? ( + + + + + {saved && + saved.generation === generation && + saved.authorityUrl === current.authorityUrl && + saved.access.discovery.service?.id === service?.id && + saved.access.discovery.service?.pairingRevision === service?.pairingRevision && + saved.routing === current.routing ? ( +

+ + Confirmed by Prism +

+ ) : null} +
+ ) : null} + {config?.authorityUrl ? ( + + ) : null} + {current.access.session.capabilities.accountDetails ? ( + + + + ) : null} + + ) : ( +
+ {error ? "Prism access could not be verified." : "Finding your Prism host…"} +
+ )} + {message ? ( +
+

{message}

+ +
+ ) : null} +
+ + ); +} diff --git a/apps/web/src/fork/mic-identity/MicIdentitySession.tsx b/apps/web/src/fork/mic-identity/MicIdentitySession.tsx new file mode 100644 index 000000000000..39ea89dc0842 --- /dev/null +++ b/apps/web/src/fork/mic-identity/MicIdentitySession.tsx @@ -0,0 +1,53 @@ +import { useAuth, useClerk } from "@clerk/react"; +import { useEffect, useSyncExternalStore } from "react"; +import { LogInIcon, LogOutIcon } from "lucide-react"; +import { SidebarMenuButton, SidebarMenuItem } from "~/components/ui/sidebar"; +import { + bindMicIdentitySession, + micIdentitySessionSnapshot, + subscribeMicIdentity, +} from "./micIdentitySession"; + +export function MicIdentitySession() { + const { getToken, isLoaded, isSignedIn, userId, sessionId } = useAuth(); + const clerk = useClerk(); + const session = useSyncExternalStore(subscribeMicIdentity, micIdentitySessionSnapshot); + useEffect( + () => + bindMicIdentitySession( + isLoaded && isSignedIn && userId && sessionId + ? () => getToken({ skipCache: true }) + : undefined, + { + loaded: Boolean(isLoaded), + signIn: () => clerk.openSignIn(), + signOut: () => clerk.signOut(), + }, + ), + [getToken, isLoaded, isSignedIn, userId, sessionId, clerk], + ); + const action = session.signOut ?? session.signIn; + const label = + session.status === "signing-out" + ? "Signing out of mic.sc…" + : session.status === "signed-in" || (session.error && session.signOut) + ? "Sign out of mic.sc" + : "Sign in to mic.sc"; + return ( + + void action?.()} + tooltip={session.error ?? label} + > + {session.status === "signed-in" ? : } + {label} + + {session.error ? ( +

+ {session.error} +

+ ) : null} +
+ ); +} diff --git a/apps/web/src/fork/mic-identity/MicPrismChat.tsx b/apps/web/src/fork/mic-identity/MicPrismChat.tsx new file mode 100644 index 000000000000..f2b8cb5577bf --- /dev/null +++ b/apps/web/src/fork/mic-identity/MicPrismChat.tsx @@ -0,0 +1,239 @@ +import { useEffect, useRef, useState } from "react"; +import { ArrowUpIcon, SquareIcon, RefreshCwIcon } from "lucide-react"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import { listMicPrismModels, streamMicPrismChat } from "@t3tools/client-runtime/fork"; +import type { MicPrismService } from "@q1code/core/micIdentity"; +import { Button } from "~/components/ui/button"; +import { runtime } from "~/lib/runtime"; +import { micIdentityGeneration, readMicIdentityToken } from "./micIdentitySession"; + +/** A service-only conversation; no environment, files or provider credentials are required. */ +export function MicPrismChat({ + authorityUrl, + service, + generation, + disabled, +}: { + authorityUrl: string; + service: MicPrismService; + generation: number; + disabled: boolean; +}) { + const [models, setModels] = useState([]); + const [model, setModel] = useState(""); + const [prompt, setPrompt] = useState(""); + const [answer, setAnswer] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [running, setRunning] = useState(false); + const [revision, setRevision] = useState(0); + const active = useRef(null); + const input = () => ({ + baseUrl: authorityUrl, + expectedService: service, + getToken: readMicIdentityToken, + isCurrent: () => micIdentityGeneration() === generation, + }); + + useEffect(() => { + const controller = new AbortController(); + void runtime + .runPromise( + listMicPrismModels({ + baseUrl: authorityUrl, + expectedService: service, + getToken: readMicIdentityToken, + isCurrent: () => micIdentityGeneration() === generation, + }).pipe(Effect.result), + { signal: controller.signal }, + ) + .then((result) => { + if (controller.signal.aborted) return; + setLoading(false); + if (result._tag === "Failure") { + setModels([]); + setError(result.failure.message); + } else { + setModels(result.success); + setModel((selected) => selected || result.success[0] || ""); + setError(null); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setLoading(false); + setError("Model discovery failed. Try again."); + } + }); + return () => controller.abort(); + }, [ + authorityUrl, + generation, + revision, + service.id, + service.pairingRevision, + service.apiUrl, + service.inferenceUrl, + ]); + useEffect(() => () => active.current?.abort(), []); + useEffect(() => { + if (disabled) active.current?.abort(); + }, [disabled]); + + const send = async () => { + if (disabled || loading || active.current || !models.includes(model) || !prompt.trim()) return; + const controller = new AbortController(); + active.current = controller; + setRunning(true); + setAnswer(""); + setError(null); + try { + const result = await runtime.runPromise( + streamMicPrismChat({ + ...input(), + model, + messages: [{ role: "user", content: prompt.trim() }], + }).pipe( + Stream.runForEach((text) => + Effect.sync(() => { + if (!controller.signal.aborted && micIdentityGeneration() === generation) + setAnswer((value) => value + text); + }), + ), + Effect.result, + ), + { signal: controller.signal }, + ); + if (!controller.signal.aborted && result._tag === "Failure") setError(result.failure.message); + } catch { + if (!controller.signal.aborted) setError("The response was interrupted. Please try again."); + } finally { + if (active.current === controller) { + active.current = null; + setRunning(false); + } + } + }; + + return ( +
+
+
+

+ Try a model +

+

+ A one-off prompt, not saved in q1code. +

+
+
+ + +
+
+
+

+ Models are listed by the service. Capacity and usable account counts are not reported yet; + Prism checks each request. +

+
{ + event.preventDefault(); + void send(); + }} + className="space-y-3" + > + +