diff --git a/.gitignore b/.gitignore index a168421..dc89c00 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ packages/react-console/gen /app/.generated/ /app/test/browser/.generated/ /app/test/browser/test-output/* + +# Local checkout symlink +/runme diff --git a/app/README.md b/app/README.md index d85ab76..cad566c 100644 --- a/app/README.md +++ b/app/README.md @@ -131,14 +131,19 @@ go run ./ agent --config=${HOME}/.runme-agent/config.dev.yaml serve # OIDC callback handling should happen in the browser. clientExchange: true google: - # Need to set the clientID to validate the audience tokens + # Runtime config applies Google defaults automatically when this block is present. clientID: "44661292282-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" googleDrive: clientID: "44661292282-bqhl39ugf2kn7r8vv4f6766jt0a7tom9.apps.googleusercontent.com" clientSecret: "" + + chatkit: + domainKey: "" ``` + If you need to override the discovery URL, redirect URL, or scopes explicitly, add an `oidc.generic` block alongside `oidc.google`. + * Then in the web in the console you can do. diff --git a/app/assets/about.html b/app/assets/about.html new file mode 100644 index 0000000..9bdb014 --- /dev/null +++ b/app/assets/about.html @@ -0,0 +1,57 @@ + + + + + + About Runme Web + + + +
+

About Runme Web

+

+ Runme Web is a browser-based notebook application for viewing, editing, + and running Runme notebooks. It provides an interface for working with + notebook cells, connecting to execution runners, and integrating with + services such as Google Drive for notebook storage. +

+

+ Privacy Policy: + + http://web.runme.dev/policies/privacypolicy.html + +

+

+ Runme: + https://runme.dev/ +

+

+ GitHub: + https://github.com/runmedev/runme +

+

+ Web repository: + https://github.com/runmedev/web +

+
+ + diff --git a/app/assets/configs/app-configs.yaml b/app/assets/configs/app-configs.yaml new file mode 100644 index 0000000..aacde09 --- /dev/null +++ b/app/assets/configs/app-configs.yaml @@ -0,0 +1,17 @@ +# app-configs.yaml is a file that will be served by the server as a static asset. +# It will be used by the frontend to configure itself. +# This is the configuration for web.runme.dev +oidc: + # OIDC callback handling should happen in the browser. + clientExchange: true + + # Use google as the OIDC provider + google: + clientID: "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" + clientSecret: "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" + +googleDrive: + clientID: "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" + +chatkit: + domainKey: "domain_pk_69a4995c6a28819687dae60b7fff88150c50b644747c389a" diff --git a/app/assets/policies/index.html b/app/assets/policies/index.html new file mode 100644 index 0000000..5e5cfa3 --- /dev/null +++ b/app/assets/policies/index.html @@ -0,0 +1,41 @@ + + + + + + Runme Web Policies + + + +
+

Runme Web Policies

+

+ Privacy Policy: + /policies/privacypolicy.html +

+

+ Linux Foundation policies: + https://lfprojects.org/policies/ +

+
+ + diff --git a/app/assets/policies/privacypolicy.html b/app/assets/policies/privacypolicy.html new file mode 100644 index 0000000..388aa3f --- /dev/null +++ b/app/assets/policies/privacypolicy.html @@ -0,0 +1,97 @@ + + + + + + Runme Web Privacy Policy + + + +
+

Runme Web Privacy Policy

+

Last updated: March 1, 2026

+ +

+ This Privacy Policy applies to the Runme Web application hosted at + https://web.runme.dev/. +

+ +

Information We Access

+

+ If you choose to sign in with Google, the application may access Google + account information and tokens needed to authenticate you and enable the + Google features you request. This may include your email address, basic + account identifier information, and OAuth or OpenID Connect tokens. +

+ +

How We Use Google User Data

+

+ We use Google user data only to authenticate you and to provide the + user-facing functionality you request. We do not use Google user data + for advertising, profiling, sale to third parties, or training general + machine learning or AI models. +

+ +

How We Store Data

+

+ Runme Web is a static website. We do not operate an application backend + that stores your Google user data. Authentication data used by the site + is processed in your browser and may be stored locally in your browser + storage on your device so the sign-in flow can complete and your session + can continue. +

+ +

How We Share Data

+

+ We do not sell, transfer, or disclose Google user data to third + parties, except when your browser sends requests directly to Google or + another service you choose to use in order to provide the functionality + you requested. +

+ +

Security

+

+ We use HTTPS to deliver the application. Because authentication data is + handled in the browser, you should protect access to your device and + sign out or clear site data if you no longer want authentication data + stored locally in your browser. +

+ +

Retention and Deletion

+

+ We do not retain Google user data on application servers. Data stored in + your browser remains there until it expires, you sign out, or you clear + your browser's local site data. +

+ +

Changes to This Policy

+

+ If our data handling practices change, we will update this Privacy + Policy on this page. +

+
+ + diff --git a/app/index.html b/app/index.html index 051a565..af0760a 100644 --- a/app/index.html +++ b/app/index.html @@ -12,6 +12,6 @@
- + diff --git a/app/src/App.tsx b/app/src/App.tsx index 3f81b28..622f423 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { Helmet } from "react-helmet"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import Callback from "./routes/callback"; import RunRoute from "./routes/run"; import RunsRoute from "./routes/runs"; @@ -58,6 +58,7 @@ import { getConfiguredAgentEndpoint, getConfiguredDefaultRunnerEndpoint, } from "./lib/appConfig"; +import { APP_ROUTE_PATHS, getAppRouterBasename } from "./lib/appBase"; const queryClient = new QueryClient(); @@ -73,10 +74,15 @@ export interface AppProps { } function AppRouter() { + const basename = getAppRouterBasename(); return ( - + } /> + } + /> } /> } /> } /> diff --git a/app/src/auth/oidcConfig.ts b/app/src/auth/oidcConfig.ts index c26d7ea..2433147 100644 --- a/app/src/auth/oidcConfig.ts +++ b/app/src/auth/oidcConfig.ts @@ -1,4 +1,5 @@ import { googleClientManager } from "../lib/googleClientManager"; +import { getOidcCallbackUrl } from "../lib/appBase"; export type OidcConfig = { discoveryUrl: string; @@ -97,14 +98,18 @@ export class OidcConfigManager { } setGoogleDefaults(): OidcConfig { - return this.setConfig({ + const extraAuthParams = this.sanitizeExtraAuthParams({ + access_type: "offline", + prompt: "consent", + }); + this.config = { + ...this.config, discoveryUrl: "https://accounts.google.com/.well-known/openid-configuration", scope: "openid https://www.googleapis.com/auth/userinfo.email", - extraAuthParams: { - access_type: "offline", - prompt: "consent", - }, - }); + extraAuthParams, + }; + this.persistConfig(this.config); + return this.config; } private mergeConfig(stored?: StoredOidcConfig): OidcConfig { @@ -118,9 +123,7 @@ export class OidcConfigManager { clientId: sanitizeString(stored?.clientId) ?? "", clientSecret: sanitizeString(stored?.clientSecret), scope: sanitizeString(stored?.scope) ?? "", - redirectUri: - sanitizeString(stored?.redirectUri) ?? - new URL("/oidc/callback", window.location.origin).toString(), + redirectUri: sanitizeString(stored?.redirectUri) ?? getOidcCallbackUrl(), extraAuthParams: mergedExtra && Object.keys(mergedExtra).length > 0 ? mergedExtra diff --git a/app/src/components/ChatKit/ChatKitPanel.tsx b/app/src/components/ChatKit/ChatKitPanel.tsx index 7dca205..f91e4ac 100644 --- a/app/src/components/ChatKit/ChatKitPanel.tsx +++ b/app/src/components/ChatKit/ChatKitPanel.tsx @@ -19,6 +19,7 @@ import { ChatkitStateSchema, } from "../../protogen/oaiproto/aisre/notebooks_pb.js"; import { useAgentEndpointSnapshot } from "../../lib/agentEndpointManager"; +import { getConfiguredChatKitDomainKey } from "../../lib/appConfig"; class UserNotLoggedInError extends Error { constructor(message = "You must log in to use runme chat.") { @@ -27,20 +28,6 @@ class UserNotLoggedInError extends Error { } } -const CHATKIT_DOMAIN_KEY = (() => { - const envValue = import.meta.env.VITE_CHATKIT_DOMAIN_KEY; - if (envValue) { - return envValue; - } - if ( - typeof window !== "undefined" && - window.location.hostname === "localhost" - ) { - return "domain_pk_localhost_dev"; - } - return "domain_pk_68f8054e7da081908cc1972e9167ec270895bf04413e753b"; -})(); - const CHATKIT_GREETING = "How can runme help you today?"; const CHATKIT_PLACEHOLDER = @@ -227,6 +214,7 @@ const useAuthorizedFetch = ( function ChatKitPanel() { const [showLoginPrompt, setShowLoginPrompt] = useState(false); + const chatkitDomainKey = getConfiguredChatKitDomainKey(); const agentEndpoint = useAgentEndpointSnapshot(); const { getChatkitState, setChatkitState } = useCell(); const { getNotebookData, useNotebookSnapshot } = useNotebookContext(); @@ -330,7 +318,7 @@ function ChatKitPanel() { const chatkit = useChatKit({ api: { url: chatkitApiUrl, - domainKey: CHATKIT_DOMAIN_KEY, + domainKey: chatkitDomainKey, fetch: authorizedFetch, }, theme: { diff --git a/app/src/components/NotFound.tsx b/app/src/components/NotFound.tsx index bcf538a..600e47d 100644 --- a/app/src/components/NotFound.tsx +++ b/app/src/components/NotFound.tsx @@ -1,4 +1,5 @@ import { Container, Heading, Link, Text } from "@radix-ui/themes"; +import { Link as RouterLink } from "react-router-dom"; const NotFound = () => { return ( @@ -14,8 +15,8 @@ const NotFound = () => { Click{" "} - - here + + here {" "} to go back home. diff --git a/app/src/lib/appBase.test.ts b/app/src/lib/appBase.test.ts new file mode 100644 index 0000000..84b824e --- /dev/null +++ b/app/src/lib/appBase.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { + APP_ROUTE_PATHS, + deriveAppBasePath, + getAppPath, + getOidcCallbackUrl, + normalizeAppIndexUrl, + resolveAppUrl, +} from "./appBase"; + +describe("deriveAppBasePath", () => { + it("uses the bundled asset location outside development", () => { + expect( + deriveAppBasePath({ + dev: false, + pathname: "/runme-dev-assets/runs", + moduleUrl: + "https://storage.googleapis.com/runme-dev-assets/index.BdN4INbO.js", + }), + ).toBe("/runme-dev-assets/"); + }); + + it("falls back to the current document path in development", () => { + expect( + deriveAppBasePath({ + dev: true, + pathname: "/runme-dev-assets/index.html", + }), + ).toBe("/runme-dev-assets/"); + }); +}); + +describe("app base URL helpers", () => { + it("resolves app-relative paths beneath the mounted base path", () => { + window.history.replaceState(null, "", "/runme-dev-assets/index.html"); + const origin = window.location.origin; + + expect(getAppPath(APP_ROUTE_PATHS.oidcCallback)).toBe( + "/runme-dev-assets/oidc/callback", + ); + expect(resolveAppUrl("configs/app-configs.yaml").toString()).toBe( + `${origin}/runme-dev-assets/configs/app-configs.yaml`, + ); + expect(getOidcCallbackUrl()).toBe( + `${origin}/runme-dev-assets/oidc/callback`, + ); + }); + + it("normalizes index.html entry URLs to the directory path", () => { + window.history.replaceState( + null, + "", + "/runme-dev-assets/index.html?doc=foo#section-1", + ); + + normalizeAppIndexUrl(); + + expect(window.location.pathname).toBe("/runme-dev-assets/"); + expect(window.location.search).toBe("?doc=foo"); + expect(window.location.hash).toBe("#section-1"); + }); +}); diff --git a/app/src/lib/appBase.ts b/app/src/lib/appBase.ts new file mode 100644 index 0000000..d64798b --- /dev/null +++ b/app/src/lib/appBase.ts @@ -0,0 +1,121 @@ +const ROOT_PATH = "/"; + +export const APP_ROUTE_PATHS = { + home: "/", + indexEntry: "/index.html", + authStatus: "/auth/status", + oidcCallback: "/oidc/callback", + runs: "/runs", + run: (runName: string) => `/runs/${runName}`, + editRun: (runName: string) => `/runs/${runName}/edit`, +} as const; + +function ensureTrailingSlash(pathname: string): string { + if (!pathname || pathname === ROOT_PATH) { + return ROOT_PATH; + } + return pathname.endsWith("/") ? pathname : `${pathname}/`; +} + +function stripTrailingSlash(pathname: string): string { + if (!pathname || pathname === ROOT_PATH) { + return ROOT_PATH; + } + return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; +} + +function getBasePathFromModuleUrl(moduleUrl: string): string { + const url = new URL(moduleUrl); + if (!["http:", "https:"].includes(url.protocol)) { + return ROOT_PATH; + } + return ensureTrailingSlash(new URL(".", url).pathname); +} + +function getBasePathFromLocation(pathname: string): string { + if (!pathname || pathname === ROOT_PATH) { + return ROOT_PATH; + } + + const lastSlashIndex = pathname.lastIndexOf("/"); + const lastSegment = pathname.slice(lastSlashIndex + 1); + if (lastSegment === "index.html") { + return ensureTrailingSlash(pathname.slice(0, -lastSegment.length)); + } + if (lastSegment.includes(".")) { + return ensureTrailingSlash(pathname.slice(0, lastSlashIndex + 1)); + } + return ROOT_PATH; +} + +export function deriveAppBasePath(options?: { + dev?: boolean; + moduleUrl?: string; + pathname?: string; +}): string { + if (options?.dev) { + return getBasePathFromLocation(options.pathname ?? ROOT_PATH); + } + if (options?.moduleUrl) { + const fromModuleUrl = getBasePathFromModuleUrl(options.moduleUrl); + if (fromModuleUrl !== ROOT_PATH) { + return fromModuleUrl; + } + } + return getBasePathFromLocation(options?.pathname ?? ROOT_PATH); +} + +let cachedAppBasePath: string | null = null; + +export function getAppBasePath(): string { + if (cachedAppBasePath) { + return cachedAppBasePath; + } + + if (typeof window === "undefined") { + return ROOT_PATH; + } + + cachedAppBasePath = deriveAppBasePath({ + dev: import.meta.env.DEV, + moduleUrl: import.meta.url, + pathname: window.location.pathname, + }); + return cachedAppBasePath; +} + +export function getAppRouterBasename(): string { + return stripTrailingSlash(getAppBasePath()); +} + +export function resolveAppUrl(path = ""): URL { + if (typeof window === "undefined") { + return new URL(path || ROOT_PATH, "http://localhost"); + } + const baseUrl = new URL(getAppBasePath(), window.location.origin); + const normalizedPath = path.replace(/^\/+/, ""); + return new URL(normalizedPath, baseUrl); +} + +export function getAppPath(path = ""): string { + const url = resolveAppUrl(path); + return `${url.pathname}${url.search}${url.hash}`; +} + +export function getOidcCallbackUrl(): string { + return resolveAppUrl(APP_ROUTE_PATHS.oidcCallback).toString(); +} + +export function normalizeAppIndexUrl(): void { + if (typeof window === "undefined") { + return; + } + + const { pathname, search, hash } = window.location; + if (!pathname.endsWith("/index.html")) { + return; + } + + const normalizedPath = pathname.slice(0, -"/index.html".length) || ROOT_PATH; + window.history.replaceState(null, "", `${ensureTrailingSlash(normalizedPath)}${search}${hash}`); +} diff --git a/app/src/lib/appConfig.test.ts b/app/src/lib/appConfig.test.ts new file mode 100644 index 0000000..426d1e2 --- /dev/null +++ b/app/src/lib/appConfig.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +async function loadModules() { + vi.resetModules(); + const appConfig = await import("./appConfig"); + const oidcConfig = await import("../auth/oidcConfig"); + return { + ...appConfig, + ...oidcConfig, + }; +} + +describe("appConfig OIDC Google shorthand", () => { + beforeEach(() => { + window.localStorage.clear(); + window.history.replaceState(null, "", "/index.html"); + }); + + it("applies Google defaults when oidc.google is configured", async () => { + const { applyAppConfig } = await loadModules(); + + const result = applyAppConfig( + { + agent: { + endpoint: "http://localhost:9977", + }, + oidc: { + google: { + clientID: "client-id.apps.googleusercontent.com", + }, + }, + }, + "http://localhost/configs/app-configs.yaml", + ); + + expect(result.warnings).toEqual([]); + expect(result.oidc).toMatchObject({ + discoveryUrl: + "https://accounts.google.com/.well-known/openid-configuration", + clientId: "client-id.apps.googleusercontent.com", + scope: "openid https://www.googleapis.com/auth/userinfo.email", + extraAuthParams: { + access_type: "offline", + prompt: "consent", + }, + }); + expect(result.oidc?.redirectUri).toMatch( + /^http:\/\/localhost(:\d+)?\/oidc\/callback$/, + ); + }); + + it("allows setGoogleDefaults before the client ID is set", async () => { + const { oidcConfigManager } = await loadModules(); + + expect(() => oidcConfigManager.setGoogleDefaults()).not.toThrow(); + + const config = oidcConfigManager.setClientId( + "client-id.apps.googleusercontent.com", + ); + + expect(config.discoveryUrl).toBe( + "https://accounts.google.com/.well-known/openid-configuration", + ); + expect(config.scope).toBe( + "openid https://www.googleapis.com/auth/userinfo.email", + ); + expect(config.clientId).toBe("client-id.apps.googleusercontent.com"); + expect(config.extraAuthParams).toEqual({ + access_type: "offline", + prompt: "consent", + }); + }); + + it("stores the ChatKit domain key from app config in local storage", async () => { + const { applyAppConfig, getConfiguredChatKitDomainKey } = await loadModules(); + + const result = applyAppConfig( + { + agent: { + endpoint: "http://localhost:9977", + }, + chatkit: { + domainKey: "domain_pk_configured", + }, + }, + "http://localhost/configs/app-configs.yaml", + ); + + expect(result.warnings).toEqual([]); + expect(result.chatkitDomainKey).toBe("domain_pk_configured"); + expect(getConfiguredChatKitDomainKey()).toBe("domain_pk_configured"); + + const stored = JSON.parse( + window.localStorage.getItem("cloudAssistantSettings") ?? "{}", + ); + expect(stored.chatkit).toEqual({ + domainKey: "domain_pk_configured", + }); + }); + + it("falls back to the existing ChatKit localhost default when no config is stored", async () => { + const { getConfiguredChatKitDomainKey } = await loadModules(); + + expect(getConfiguredChatKitDomainKey()).toBe("domain_pk_localhost_dev"); + }); +}); diff --git a/app/src/lib/appConfig.ts b/app/src/lib/appConfig.ts index 717175a..a43bf40 100644 --- a/app/src/lib/appConfig.ts +++ b/app/src/lib/appConfig.ts @@ -2,6 +2,7 @@ import YAML from "yaml"; import type { OidcConfig } from "../auth/oidcConfig"; import { OIDC_STORAGE_KEY, oidcConfigManager } from "../auth/oidcConfig"; +import { getOidcCallbackUrl, resolveAppUrl } from "./appBase"; import type { GoogleOAuthClientConfig } from "./googleClientManager"; import { GOOGLE_CLIENT_STORAGE_KEY, @@ -22,7 +23,7 @@ const LEGACY_RUNNERS_STORAGE_KEY = "aisre/runners"; const DEFAULT_RUNNER_NAME_STORAGE_KEY = "runme/defaultRunner"; const LEGACY_DEFAULT_RUNNER_NAME_STORAGE_KEY = "aisre/defaultRunner"; const DEFAULT_RUNNER_NAME = "default"; -export const APP_CONFIG_PATH_DEFAULT = "/configs/app-configs.yaml"; +export const APP_CONFIG_PATH_DEFAULT = "configs/app-configs.yaml"; export interface OidcGenericRuntimeConfig { clientId: string; @@ -33,9 +34,15 @@ export interface OidcGenericRuntimeConfig { scopes: string[]; } +export interface OidcGoogleRuntimeConfig { + clientId: string; + clientSecret: string; +} + export interface OidcRuntimeConfig { clientExchange: boolean; generic: OidcGenericRuntimeConfig; + google: OidcGoogleRuntimeConfig; } export interface GoogleDriveRuntimeConfig { @@ -44,6 +51,10 @@ export interface GoogleDriveRuntimeConfig { baseUrl: string; } +export interface ChatkitRuntimeConfig { + domainKey: string; +} + export interface AgentRuntimeConfig { endpoint: string; defaultRunnerEndpoint: string; @@ -53,6 +64,7 @@ export interface RuntimeAppConfig { agent: AgentRuntimeConfig; oidc: OidcRuntimeConfig; googleDrive: GoogleDriveRuntimeConfig; + chatkit: ChatkitRuntimeConfig; } export type AppliedAppConfig = { @@ -61,6 +73,7 @@ export type AppliedAppConfig = { googleOAuth?: GoogleOAuthClientConfig; agentEndpoint?: string; defaultRunnerEndpoint?: string; + chatkitDomainKey?: string; warnings: string[]; }; @@ -246,6 +259,37 @@ export function getConfiguredDefaultRunnerEndpoint(): string { ); } +export function resolveDefaultChatKitDomainKeyFallback(): string { + const envValue = normalizeString(import.meta.env.VITE_CHATKIT_DOMAIN_KEY); + if (envValue) { + return envValue; + } + if ( + typeof window !== "undefined" && + window.location.hostname === "localhost" + ) { + return "domain_pk_localhost_dev"; + } + return "domain_pk_68f8054e7da081908cc1972e9167ec270895bf04413e753b"; +} + +function readStoredChatKitDomainKey(storage: Storage): string | undefined { + const settings = readSettingsFromStorage(storage); + const settingsChatkit = asRecord(settings.chatkit); + return normalizeString(settingsChatkit?.domainKey); +} + +export function getConfiguredChatKitDomainKey(): string { + if (typeof window === "undefined" || !window.localStorage) { + return resolveDefaultChatKitDomainKeyFallback(); + } + + return ( + readStoredChatKitDomainKey(window.localStorage) ?? + resolveDefaultChatKitDomainKeyFallback() + ); +} + function pickString( source: Record, keys: string[], @@ -270,6 +314,13 @@ function createDefaultOidcGenericRuntimeConfig(): OidcGenericRuntimeConfig { }; } +function createDefaultOidcGoogleRuntimeConfig(): OidcGoogleRuntimeConfig { + return { + clientId: "", + clientSecret: "", + }; +} + function createDefaultRuntimeAppConfig(): RuntimeAppConfig { return { agent: { @@ -279,12 +330,16 @@ function createDefaultRuntimeAppConfig(): RuntimeAppConfig { oidc: { clientExchange: false, generic: createDefaultOidcGenericRuntimeConfig(), + google: createDefaultOidcGoogleRuntimeConfig(), }, googleDrive: { clientId: "", clientSecret: "", baseUrl: "", }, + chatkit: { + domainKey: "", + }, }; } @@ -303,7 +358,9 @@ export class RuntimeAppConfigSchema { const agent = asRecord(root.agent); const oidc = asRecord(root.oidc); const oidcGeneric = asRecord(oidc?.generic); + const oidcGoogle = asRecord(oidc?.google); const drive = asRecord(root.googleDrive); + const chatkit = asRecord(root.chatkit); parsed.agent = { endpoint: @@ -330,6 +387,12 @@ export class RuntimeAppConfigSchema { scopes: asStringArray(oidcGeneric.scopes), }; } + if (oidcGoogle) { + parsed.oidc.google = { + clientId: pickString(oidcGoogle, ["clientId", "clientID"]), + clientSecret: pickString(oidcGoogle, ["clientSecret", "client_secret"]), + }; + } if (drive) { parsed.googleDrive = { @@ -339,6 +402,12 @@ export class RuntimeAppConfigSchema { }; } + if (chatkit) { + parsed.chatkit = { + domainKey: pickString(chatkit, ["domainKey", "domain_key"]), + }; + } + return parsed; } } @@ -347,7 +416,7 @@ export function getDefaultAppConfigUrl(): string { if (typeof window === "undefined") { return APP_CONFIG_PATH_DEFAULT; } - return new URL(APP_CONFIG_PATH_DEFAULT, window.location.origin).toString(); + return resolveAppUrl(APP_CONFIG_PATH_DEFAULT).toString(); } export function applyAppConfig( @@ -359,23 +428,36 @@ export function applyAppConfig( } const parsed = RuntimeAppConfigSchema.fromUnknown(rawConfig); const hasOidcBlock = isRecord(rawConfig.oidc); + const rawOidc = asRecord(rawConfig.oidc); + const hasOidcGoogleBlock = isRecord(rawOidc?.google); const hasGoogleDriveBlock = isRecord(rawConfig.googleDrive); + const hasChatkitBlock = isRecord(rawConfig.chatkit); const warnings: string[] = []; let oidc: OidcConfig | undefined; let googleOAuth: GoogleOAuthClientConfig | undefined; let agentEndpoint: string | undefined; let defaultRunnerEndpoint: string | undefined; + let chatkitDomainKey: string | undefined; const oidcConfig: Partial = {}; const genericOidcConfig = parsed.oidc.generic; + const googleOidcConfig = parsed.oidc.google; + const googleOidcClientId = normalizeString(googleOidcConfig.clientId); + const googleOidcClientSecret = normalizeString(googleOidcConfig.clientSecret); const oidcScope = genericOidcConfig.scopes.length > 0 ? genericOidcConfig.scopes.join(" ") : undefined; const discoveryUrl = normalizeString(genericOidcConfig.discoveryUrl); - const clientId = normalizeString(genericOidcConfig.clientId); - const clientSecret = normalizeString(genericOidcConfig.clientSecret); + const clientId = + googleOidcClientId ?? normalizeString(genericOidcConfig.clientId); + const clientSecret = + googleOidcClientSecret ?? normalizeString(genericOidcConfig.clientSecret); const redirectUri = normalizeString(genericOidcConfig.redirectUrl); + const configuredChatkitDomainKey = normalizeString(parsed.chatkit.domainKey); + if (hasOidcGoogleBlock) { + oidcConfigManager.setGoogleDefaults(); + } if (discoveryUrl) { oidcConfig.discoveryUrl = discoveryUrl; } @@ -393,16 +475,15 @@ export function applyAppConfig( } if (Object.keys(oidcConfig).length > 0) { if (!oidcConfig.redirectUri && typeof window !== "undefined") { - oidcConfig.redirectUri = new URL( - "/oidc/callback", - window.location.origin, - ).toString(); + oidcConfig.redirectUri = getOidcCallbackUrl(); } try { oidc = oidcConfigManager.setConfig(oidcConfig); } catch (error) { warnings.push(`OIDC config not applied: ${String(error)}`); } + } else if (hasOidcGoogleBlock) { + warnings.push("OIDC Google config missing clientID/clientId"); } else if (hasOidcBlock) { warnings.push("OIDC config present but no applicable generic values found"); } @@ -426,6 +507,7 @@ export function applyAppConfig( const storage = window.localStorage; const settings = readSettingsFromStorage(storage); const settingsWebApp = asRecord(settings.webApp); + const settingsChatkit = asRecord(settings.chatkit); const configAgentEndpoint = normalizeString(parsed.agent.endpoint); const hadAgentOverride = agentEndpointManager.hasOverride(); agentEndpointManager.setDefaultEndpoint(configAgentEndpoint); @@ -454,6 +536,25 @@ export function applyAppConfig( } else if (hasStoredRunnerEndpoint) { defaultRunnerEndpoint = readStoredRunnerEndpoint(storage); } + + const storedChatkitDomainKey = readStoredChatKitDomainKey(storage); + if (!storedChatkitDomainKey && configuredChatkitDomainKey) { + chatkitDomainKey = configuredChatkitDomainKey; + settings.chatkit = { + ...(settingsChatkit ?? {}), + domainKey: configuredChatkitDomainKey, + }; + writeSettingsToStorage(storage, settings); + } else { + chatkitDomainKey = storedChatkitDomainKey; + } + } + + if (!chatkitDomainKey) { + chatkitDomainKey = configuredChatkitDomainKey; + } + if (hasChatkitBlock && !configuredChatkitDomainKey) { + warnings.push("ChatKit config missing domainKey"); } return { @@ -462,6 +563,7 @@ export function applyAppConfig( googleOAuth, agentEndpoint, defaultRunnerEndpoint, + chatkitDomainKey, warnings, }; } @@ -487,11 +589,18 @@ export async function maybeSetAppConfig(): Promise { const storage = window.localStorage; const hasOidcConfig = Boolean(storage.getItem(OIDC_STORAGE_KEY)); const hasDriveConfig = Boolean(storage.getItem(GOOGLE_CLIENT_STORAGE_KEY)); + const hasChatkitDomainKey = Boolean(readStoredChatKitDomainKey(storage)); const settings = readSettingsFromStorage(storage); const hasAgentEndpoint = agentEndpointManager.hasOverride(); const hasRunnerEndpoint = hasConfiguredRunners(storage); - if (hasOidcConfig && hasDriveConfig && hasAgentEndpoint && hasRunnerEndpoint) { + if ( + hasOidcConfig && + hasDriveConfig && + hasChatkitDomainKey && + hasAgentEndpoint && + hasRunnerEndpoint + ) { appLogger.info("App config values already set; skipping app config load.", { attrs: { scope: "config.app", code: "APP_CONFIG_PRELOAD_SKIPPED" }, }); @@ -518,6 +627,18 @@ export async function maybeSetAppConfig(): Promise { attrs: { scope: "config.app", code: "APP_CONFIG_DRIVE_PRESENT" }, }); } + if (!hasChatkitDomainKey) { + appLogger.info( + "ChatKit domain key missing; attempting to load from app config.", + { + attrs: { scope: "config.app", code: "APP_CONFIG_CHATKIT_MISSING" }, + }, + ); + } else { + appLogger.info("ChatKit domain key already set; skipping.", { + attrs: { scope: "config.app", code: "APP_CONFIG_CHATKIT_PRESENT" }, + }); + } if (!hasAgentEndpoint) { appLogger.info("Agent endpoint missing; attempting to load from app config.", { attrs: { scope: "config.app", code: "APP_CONFIG_AGENT_MISSING" }, diff --git a/app/src/main.tsx b/app/src/main.tsx index b06a6a5..e52adfa 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -15,6 +15,7 @@ import { maybeSetAppConfig, setAppConfig, } from "./lib/appConfig"; +import { normalizeAppIndexUrl } from "./lib/appBase"; type AppConfigApi = { getDefaultConfigUrl: () => string; @@ -48,6 +49,8 @@ const noopBridge: RendererContext = { }; setContext(noopBridge); +normalizeAppIndexUrl(); + // Initialize auth, then render maybeSetAppConfig().finally(() => { getBrowserAdapter() diff --git a/app/src/routes/callback.tsx b/app/src/routes/callback.tsx index e402171..5737aab 100644 --- a/app/src/routes/callback.tsx +++ b/app/src/routes/callback.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { getBrowserAdapter } from "../browserAdapter.client"; +import { APP_ROUTE_PATHS } from "../lib/appBase"; /** * Apps running in-browser auth still need to implement a handler at a callback URL. The @@ -18,7 +19,7 @@ export default function Callback() { if (controller.signal.aborted) return; // Navigate back to the main page after handling the callback - navigate("/"); + navigate(APP_ROUTE_PATHS.home); }); // If the user navigates away on their own, cancel the post-callback navigation above diff --git a/app/url-map.yaml b/app/url-map.yaml new file mode 100644 index 0000000..a5e0711 --- /dev/null +++ b/app/url-map.yaml @@ -0,0 +1,20 @@ +# This is the url map used with hosting of web.runme.dev +# We need a url map because we need https://web.runme.dev/oidc/callback to route to gs://runme-hosted/index.html +# so that we load our SPA +defaultService: https://www.googleapis.com/compute/v1/projects/runme-lewi-dev/global/backendBuckets/runme-backend +name: runme-url-map +hostRules: +- hosts: + - web.runme.dev + pathMatcher: web-runme-matcher +pathMatchers: +- name: web-runme-matcher + defaultService: https://www.googleapis.com/compute/v1/projects/runme-lewi-dev/global/backendBuckets/runme-backend + pathRules: + # Rewrite /oidc/callback -> /index.html (backend fetch) + - paths: + - /oidc/callback + routeAction: + urlRewrite: + pathPrefixRewrite: /index.html + service: https://www.googleapis.com/compute/v1/projects/runme-lewi-dev/global/backendBuckets/runme-backend \ No newline at end of file diff --git a/app/vite.config.mts b/app/vite.config.mts index 5da30b5..82a3712 100644 --- a/app/vite.config.mts +++ b/app/vite.config.mts @@ -5,6 +5,8 @@ import svgr from "vite-plugin-svgr"; // https://vite.dev/config/ export default defineConfig({ + // Use root-relative assets so LB rewrites to /index.html still load bundles from /. + base: "/", optimizeDeps: { exclude: ["@runmedev/renderers"], }, diff --git a/docs-dev/architecture/configuration.md b/docs-dev/architecture/configuration.md index 0fc7eac..a703d2e 100644 --- a/docs-dev/architecture/configuration.md +++ b/docs-dev/architecture/configuration.md @@ -32,7 +32,24 @@ not be used going forward. For integration testing, serve an app config that points the client at test doubles (for example fake Google Drive server). This keeps test setup declarative and close to production configuration behavior. -Example (illustrative): +Examples (illustrative): + +Google shortcut: + +```yaml +oidc: + clientExchange: true + google: + clientID: "" +googleDrive: + clientID: "" + clientSecret: "" + baseUrl: "http://127.0.0.1:9090" +chatkit: + domainKey: "" +``` + +Generic OIDC: ```yaml oidc: @@ -41,6 +58,7 @@ oidc: clientID: "" clientSecret: "" discoveryURL: "https://accounts.example.com/.well-known/openid-configuration" + redirectURL: "https://web.runme.dev/oidc/callback" scopes: - "openid" - "email" @@ -48,8 +66,12 @@ googleDrive: clientID: "" clientSecret: "" baseUrl: "http://127.0.0.1:9090" +chatkit: + domainKey: "" ``` +When `oidc.google` is present, runtime config loading applies the same Google OIDC defaults as `oidc.setGoogleDefaults()` and then sets the configured client ID. Use `oidc.generic` when you need to override the discovery URL, redirect URL, or scopes explicitly. + ## Vite Dev Server Support Yes, this works with Vite.