From 5f2465afba2f87acb7fdf28282ed8dcecbe7900f Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Thu, 26 Feb 2026 09:02:30 -0800 Subject: [PATCH 1/9] fix(app): support subpath hosting Signed-off-by: Jeremy lewi --- app/index.html | 2 +- app/src/App.tsx | 4 +- app/src/auth/oidcConfig.ts | 5 +- app/src/components/NotFound.tsx | 5 +- app/src/lib/appBase.test.ts | 48 +++++++++++++++ app/src/lib/appBase.ts | 106 ++++++++++++++++++++++++++++++++ app/src/lib/appConfig.ts | 10 ++- app/src/routes/callback.tsx | 3 +- app/vite.config.mts | 1 + 9 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 app/src/lib/appBase.test.ts create mode 100644 app/src/lib/appBase.ts 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..5c91dd5 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -58,6 +58,7 @@ import { getConfiguredAgentEndpoint, getConfiguredDefaultRunnerEndpoint, } from "./lib/appConfig"; +import { getAppRouterBasename } from "./lib/appBase"; const queryClient = new QueryClient(); @@ -73,8 +74,9 @@ 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..80fa5f2 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; @@ -118,9 +119,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/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..c399092 --- /dev/null +++ b/app/src/lib/appBase.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { + APP_ROUTE_PATHS, + deriveAppBasePath, + getAppPath, + getOidcCallbackUrl, + 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`, + ); + }); +}); diff --git a/app/src/lib/appBase.ts b/app/src/lib/appBase.ts new file mode 100644 index 0000000..cbdeb53 --- /dev/null +++ b/app/src/lib/appBase.ts @@ -0,0 +1,106 @@ +const ROOT_PATH = "/"; + +export const APP_ROUTE_PATHS = { + home: "/", + 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(); +} diff --git a/app/src/lib/appConfig.ts b/app/src/lib/appConfig.ts index 717175a..e47bce1 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; @@ -347,7 +348,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( @@ -393,10 +394,7 @@ 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); 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/vite.config.mts b/app/vite.config.mts index 5da30b5..78468da 100644 --- a/app/vite.config.mts +++ b/app/vite.config.mts @@ -5,6 +5,7 @@ import svgr from "vite-plugin-svgr"; // https://vite.dev/config/ export default defineConfig({ + base: "./", optimizeDeps: { exclude: ["@runmedev/renderers"], }, From 310d0b6ad767eeeef4da33bfcb1bc770be60bdb8 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Thu, 26 Feb 2026 09:04:24 -0800 Subject: [PATCH 2/9] fix(app): normalize index entry URLs Signed-off-by: Jeremy lewi --- app/src/lib/appBase.test.ts | 15 +++++++++++++++ app/src/lib/appBase.ts | 14 ++++++++++++++ app/src/main.tsx | 3 +++ 3 files changed, 32 insertions(+) diff --git a/app/src/lib/appBase.test.ts b/app/src/lib/appBase.test.ts index c399092..84b824e 100644 --- a/app/src/lib/appBase.test.ts +++ b/app/src/lib/appBase.test.ts @@ -5,6 +5,7 @@ import { deriveAppBasePath, getAppPath, getOidcCallbackUrl, + normalizeAppIndexUrl, resolveAppUrl, } from "./appBase"; @@ -45,4 +46,18 @@ describe("app base URL helpers", () => { `${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 index cbdeb53..a07f351 100644 --- a/app/src/lib/appBase.ts +++ b/app/src/lib/appBase.ts @@ -104,3 +104,17 @@ export function getAppPath(path = ""): string { 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/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() From 656a963942fa912f7b48478246cfdb0ac7a32748 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Thu, 26 Feb 2026 09:08:20 -0800 Subject: [PATCH 3/9] fix(app): redirect index entry to home Signed-off-by: Jeremy lewi --- app/src/App.tsx | 8 ++++++-- app/src/lib/appBase.ts | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 5c91dd5..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,7 +58,7 @@ import { getConfiguredAgentEndpoint, getConfiguredDefaultRunnerEndpoint, } from "./lib/appConfig"; -import { getAppRouterBasename } from "./lib/appBase"; +import { APP_ROUTE_PATHS, getAppRouterBasename } from "./lib/appBase"; const queryClient = new QueryClient(); @@ -79,6 +79,10 @@ function AppRouter() { } /> + } + /> } /> } /> } /> diff --git a/app/src/lib/appBase.ts b/app/src/lib/appBase.ts index a07f351..d64798b 100644 --- a/app/src/lib/appBase.ts +++ b/app/src/lib/appBase.ts @@ -2,6 +2,7 @@ const ROOT_PATH = "/"; export const APP_ROUTE_PATHS = { home: "/", + indexEntry: "/index.html", authStatus: "/auth/status", oidcCallback: "/oidc/callback", runs: "/runs", From 27ac3d59e321cf8c3684b0ff7b60e8b8a4de03de Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Fri, 27 Feb 2026 17:34:39 -0800 Subject: [PATCH 4/9] feat(app): add hosted config and policy assets Signed-off-by: Jeremy lewi --- app/README.md | 4 +- app/assets/policies/app-configs.yaml | 14 ++ app/assets/policies/index.html | 21 ++ app/assets/terms-of-service.html | 263 +++++++++++++++++++++++++ app/src/auth/oidcConfig.ts | 16 +- app/src/lib/appConfig.test.ts | 73 +++++++ app/src/lib/appConfig.ts | 37 +++- app/url-map.yaml | 20 ++ docs-dev/architecture/configuration.md | 11 +- 9 files changed, 443 insertions(+), 16 deletions(-) create mode 100644 app/assets/policies/app-configs.yaml create mode 100644 app/assets/policies/index.html create mode 100644 app/assets/terms-of-service.html create mode 100644 app/src/lib/appConfig.test.ts create mode 100644 app/url-map.yaml diff --git a/app/README.md b/app/README.md index d85ab76..5da3f83 100644 --- a/app/README.md +++ b/app/README.md @@ -131,7 +131,7 @@ 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: @@ -139,6 +139,8 @@ go run ./ agent --config=${HOME}/.runme-agent/config.dev.yaml serve clientSecret: "" ``` + 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/policies/app-configs.yaml b/app/assets/policies/app-configs.yaml new file mode 100644 index 0000000..8978787 --- /dev/null +++ b/app/assets/policies/app-configs.yaml @@ -0,0 +1,14 @@ +# 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" + +googleDrive: + clientID: "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" + clientSecret: "" \ No newline at end of file diff --git a/app/assets/policies/index.html b/app/assets/policies/index.html new file mode 100644 index 0000000..91e0153 --- /dev/null +++ b/app/assets/policies/index.html @@ -0,0 +1,21 @@ + + + + + Redirecting... + + + + + +

+ Redirecting to + https://lfprojects.org/policies/. +

+ + diff --git a/app/assets/terms-of-service.html b/app/assets/terms-of-service.html new file mode 100644 index 0000000..e990ac7 --- /dev/null +++ b/app/assets/terms-of-service.html @@ -0,0 +1,263 @@ + + + + + + Runme Web Terms of Service + + + +
+
+

Last updated: February 27, 2026

+

Runme Web Terms of Service

+ +

+ These Terms of Service ("Terms") govern your access to and use of + Runme Web and related hosted services, features, websites, APIs, and + integrations (collectively, the "Service"). By accessing or using the + Service, you agree to these Terms. +

+ +
+ Contact. For questions about these Terms, contact the + Service operator at + support@runme.dev. +
+ +

1. Eligibility and Accounts

+

+ You may use the Service only if you can form a binding contract with + the Service operator and are authorized to do so on behalf of yourself + or your organization. You are responsible for activity that occurs + under your account or credentials. +

+ +

2. The Service

+

+ The Service provides browser-based notebook, automation, execution, + and integration features, including optional integrations with third + party identity providers and storage providers such as Google. +

+ +

+ If you connect a third party account, you authorize the Service to + access and use the data made available through that integration solely + to provide the requested product functionality, maintain the Service, + secure the Service, and comply with law. +

+ +

3. Acceptable Use

+

You agree not to:

+
    +
  • use the Service in violation of applicable law or regulation;
  • +
  • + interfere with, disrupt, probe, or attempt unauthorized access to + the Service or related systems; +
  • +
  • + upload, execute, or transmit malicious code, harmful content, or + material that infringes another party's rights; +
  • +
  • + use the Service to develop, distribute, or facilitate spam, fraud, + abusive automation, or credential theft; +
  • +
  • + misrepresent your identity, affiliation, or authorization to use any + connected account or data source. +
  • +
+ +

4. Customer Content and Connected Data

+

+ You retain ownership of notebooks, files, prompts, outputs, account + data, and other content you submit to or access through the Service + ("Customer Content"), subject to the rights needed for the Service + operator to run the Service. +

+ +

+ You grant the Service operator a limited, non-exclusive license to + host, copy, transmit, process, and display Customer Content only as + reasonably necessary to provide, secure, support, and improve the + Service. +

+ +

+ You represent that you have all rights and permissions necessary to + provide Customer Content and to connect any third party account used + with the Service. +

+ +

5. Google and Other Third Party Services

+

+ The Service may interoperate with Google APIs and other third party + services. Your use of those third party services is subject to their + separate terms and privacy policies. The Service operator is not + responsible for third party services, and availability of integrations + may change at any time. +

+ +

6. Fees

+

+ Unless the Service operator expressly states otherwise in a separate + order, subscription, or pricing page, the Service may be offered on a + free, trial, or evaluation basis and may later become subject to paid + terms. Any paid use is governed by the pricing and payment terms + presented at the time of purchase or in a separate written agreement. +

+ +

7. Suspension and Termination

+

+ The Service operator may suspend or terminate access to the Service if + necessary to investigate misuse, protect the Service, comply with law, + or enforce these Terms. You may stop using the Service at any time. +

+ +

8. Disclaimers

+

+ The Service is provided on an "as is" and "as available" basis to the + maximum extent permitted by law. The Service operator disclaims all + implied warranties, including warranties of merchantability, fitness + for a particular purpose, non-infringement, and uninterrupted or + error-free operation. +

+ +

9. Limitation of Liability

+

+ To the maximum extent permitted by law, the Service operator and its + affiliates, officers, employees, and suppliers will not be liable for + any indirect, incidental, special, consequential, exemplary, or + punitive damages, or any loss of profits, revenues, goodwill, data, or + business opportunity arising from or related to the Service or these + Terms. +

+ +

+ To the maximum extent permitted by law, the aggregate liability of the + Service operator for all claims arising out of or relating to the + Service or these Terms will not exceed the greater of one hundred U.S. + dollars (USD $100) or the amount you paid to use the Service during + the twelve months before the event giving rise to the claim. +

+ +

10. Indemnity

+

+ You agree to defend, indemnify, and hold harmless the Service operator + and its affiliates, officers, employees, and agents from and against + claims, liabilities, damages, losses, and expenses arising out of your + misuse of the Service, your Customer Content, or your violation of + these Terms or applicable law. +

+ +

11. Changes to the Service or Terms

+

+ The Service operator may modify the Service or these Terms from time + to time. Updated Terms become effective when posted. Your continued + use of the Service after the effective date of updated Terms + constitutes acceptance of the updated Terms. +

+ +

12. Governing Law

+

+ Unless a separate written agreement provides otherwise, these Terms are + governed by the laws of the State of California, excluding its + conflict of law rules. Any dispute arising out of or relating to these + Terms or the Service will be resolved exclusively in the state or + federal courts located in San Francisco County, California, and you + consent to those courts' personal jurisdiction. +

+ +

13. General

+

+ These Terms are the entire agreement between you and the Service + operator regarding the Service unless superseded by a separate written + agreement. If any provision is held unenforceable, the remaining + provisions will remain in effect. Failure to enforce any provision is + not a waiver. +

+
+
+ + diff --git a/app/src/auth/oidcConfig.ts b/app/src/auth/oidcConfig.ts index 80fa5f2..2433147 100644 --- a/app/src/auth/oidcConfig.ts +++ b/app/src/auth/oidcConfig.ts @@ -98,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 { diff --git a/app/src/lib/appConfig.test.ts b/app/src/lib/appConfig.test.ts new file mode 100644 index 0000000..2bb95d4 --- /dev/null +++ b/app/src/lib/appConfig.test.ts @@ -0,0 +1,73 @@ +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", + }); + }); +}); diff --git a/app/src/lib/appConfig.ts b/app/src/lib/appConfig.ts index e47bce1..3df8b15 100644 --- a/app/src/lib/appConfig.ts +++ b/app/src/lib/appConfig.ts @@ -34,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 { @@ -271,6 +277,13 @@ function createDefaultOidcGenericRuntimeConfig(): OidcGenericRuntimeConfig { }; } +function createDefaultOidcGoogleRuntimeConfig(): OidcGoogleRuntimeConfig { + return { + clientId: "", + clientSecret: "", + }; +} + function createDefaultRuntimeAppConfig(): RuntimeAppConfig { return { agent: { @@ -280,6 +293,7 @@ function createDefaultRuntimeAppConfig(): RuntimeAppConfig { oidc: { clientExchange: false, generic: createDefaultOidcGenericRuntimeConfig(), + google: createDefaultOidcGoogleRuntimeConfig(), }, googleDrive: { clientId: "", @@ -304,6 +318,7 @@ 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); parsed.agent = { @@ -331,6 +346,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 = { @@ -360,6 +381,8 @@ 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 warnings: string[] = []; let oidc: OidcConfig | undefined; @@ -369,14 +392,22 @@ export function applyAppConfig( 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); + if (hasOidcGoogleBlock) { + oidcConfigManager.setGoogleDefaults(); + } if (discoveryUrl) { oidcConfig.discoveryUrl = discoveryUrl; } @@ -401,6 +432,8 @@ export function applyAppConfig( } 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"); } 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/docs-dev/architecture/configuration.md b/docs-dev/architecture/configuration.md index 0fc7eac..988c93d 100644 --- a/docs-dev/architecture/configuration.md +++ b/docs-dev/architecture/configuration.md @@ -37,19 +37,16 @@ Example (illustrative): ```yaml oidc: clientExchange: true - generic: - clientID: "" - clientSecret: "" - discoveryURL: "https://accounts.example.com/.well-known/openid-configuration" - scopes: - - "openid" - - "email" + google: + clientID: "" googleDrive: clientID: "" clientSecret: "" baseUrl: "http://127.0.0.1:9090" ``` +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. From 585ca8fa9a31645780753d1ad51b00223dbb77d2 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sat, 28 Feb 2026 09:45:51 -0800 Subject: [PATCH 5/9] Use root-relative asset base for app build Signed-off-by: Jeremy lewi --- app/vite.config.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/vite.config.mts b/app/vite.config.mts index 78468da..82a3712 100644 --- a/app/vite.config.mts +++ b/app/vite.config.mts @@ -5,7 +5,8 @@ import svgr from "vite-plugin-svgr"; // https://vite.dev/config/ export default defineConfig({ - base: "./", + // Use root-relative assets so LB rewrites to /index.html still load bundles from /. + base: "/", optimizeDeps: { exclude: ["@runmedev/renderers"], }, From 2387dd53a87172176f4171ab1ccf7d1897d0bcc6 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sat, 28 Feb 2026 09:48:22 -0800 Subject: [PATCH 6/9] Add hosted app config Signed-off-by: Jeremy lewi --- .gitignore | 3 +++ app/assets/configs/app-configs.yaml | 14 ++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 app/assets/configs/app-configs.yaml 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/assets/configs/app-configs.yaml b/app/assets/configs/app-configs.yaml new file mode 100644 index 0000000..8978787 --- /dev/null +++ b/app/assets/configs/app-configs.yaml @@ -0,0 +1,14 @@ +# 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" + +googleDrive: + clientID: "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" + clientSecret: "" \ No newline at end of file From 2feb969f5692ca19fba292081f81da0c4f396038 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sat, 28 Feb 2026 16:38:47 -0800 Subject: [PATCH 7/9] Cleanup. --- app/assets/configs/app-configs.yaml | 2 +- app/assets/policies/app-configs.yaml | 14 -- app/assets/terms-of-service.html | 263 ------------------------- docs-dev/architecture/configuration.md | 23 ++- 4 files changed, 23 insertions(+), 279 deletions(-) delete mode 100644 app/assets/policies/app-configs.yaml delete mode 100644 app/assets/terms-of-service.html diff --git a/app/assets/configs/app-configs.yaml b/app/assets/configs/app-configs.yaml index 8978787..9d90a4a 100644 --- a/app/assets/configs/app-configs.yaml +++ b/app/assets/configs/app-configs.yaml @@ -8,7 +8,7 @@ oidc: # 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" - clientSecret: "" \ No newline at end of file diff --git a/app/assets/policies/app-configs.yaml b/app/assets/policies/app-configs.yaml deleted file mode 100644 index 8978787..0000000 --- a/app/assets/policies/app-configs.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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" - -googleDrive: - clientID: "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" - clientSecret: "" \ No newline at end of file diff --git a/app/assets/terms-of-service.html b/app/assets/terms-of-service.html deleted file mode 100644 index e990ac7..0000000 --- a/app/assets/terms-of-service.html +++ /dev/null @@ -1,263 +0,0 @@ - - - - - - Runme Web Terms of Service - - - -
-
-

Last updated: February 27, 2026

-

Runme Web Terms of Service

- -

- These Terms of Service ("Terms") govern your access to and use of - Runme Web and related hosted services, features, websites, APIs, and - integrations (collectively, the "Service"). By accessing or using the - Service, you agree to these Terms. -

- -
- Contact. For questions about these Terms, contact the - Service operator at - support@runme.dev. -
- -

1. Eligibility and Accounts

-

- You may use the Service only if you can form a binding contract with - the Service operator and are authorized to do so on behalf of yourself - or your organization. You are responsible for activity that occurs - under your account or credentials. -

- -

2. The Service

-

- The Service provides browser-based notebook, automation, execution, - and integration features, including optional integrations with third - party identity providers and storage providers such as Google. -

- -

- If you connect a third party account, you authorize the Service to - access and use the data made available through that integration solely - to provide the requested product functionality, maintain the Service, - secure the Service, and comply with law. -

- -

3. Acceptable Use

-

You agree not to:

-
    -
  • use the Service in violation of applicable law or regulation;
  • -
  • - interfere with, disrupt, probe, or attempt unauthorized access to - the Service or related systems; -
  • -
  • - upload, execute, or transmit malicious code, harmful content, or - material that infringes another party's rights; -
  • -
  • - use the Service to develop, distribute, or facilitate spam, fraud, - abusive automation, or credential theft; -
  • -
  • - misrepresent your identity, affiliation, or authorization to use any - connected account or data source. -
  • -
- -

4. Customer Content and Connected Data

-

- You retain ownership of notebooks, files, prompts, outputs, account - data, and other content you submit to or access through the Service - ("Customer Content"), subject to the rights needed for the Service - operator to run the Service. -

- -

- You grant the Service operator a limited, non-exclusive license to - host, copy, transmit, process, and display Customer Content only as - reasonably necessary to provide, secure, support, and improve the - Service. -

- -

- You represent that you have all rights and permissions necessary to - provide Customer Content and to connect any third party account used - with the Service. -

- -

5. Google and Other Third Party Services

-

- The Service may interoperate with Google APIs and other third party - services. Your use of those third party services is subject to their - separate terms and privacy policies. The Service operator is not - responsible for third party services, and availability of integrations - may change at any time. -

- -

6. Fees

-

- Unless the Service operator expressly states otherwise in a separate - order, subscription, or pricing page, the Service may be offered on a - free, trial, or evaluation basis and may later become subject to paid - terms. Any paid use is governed by the pricing and payment terms - presented at the time of purchase or in a separate written agreement. -

- -

7. Suspension and Termination

-

- The Service operator may suspend or terminate access to the Service if - necessary to investigate misuse, protect the Service, comply with law, - or enforce these Terms. You may stop using the Service at any time. -

- -

8. Disclaimers

-

- The Service is provided on an "as is" and "as available" basis to the - maximum extent permitted by law. The Service operator disclaims all - implied warranties, including warranties of merchantability, fitness - for a particular purpose, non-infringement, and uninterrupted or - error-free operation. -

- -

9. Limitation of Liability

-

- To the maximum extent permitted by law, the Service operator and its - affiliates, officers, employees, and suppliers will not be liable for - any indirect, incidental, special, consequential, exemplary, or - punitive damages, or any loss of profits, revenues, goodwill, data, or - business opportunity arising from or related to the Service or these - Terms. -

- -

- To the maximum extent permitted by law, the aggregate liability of the - Service operator for all claims arising out of or relating to the - Service or these Terms will not exceed the greater of one hundred U.S. - dollars (USD $100) or the amount you paid to use the Service during - the twelve months before the event giving rise to the claim. -

- -

10. Indemnity

-

- You agree to defend, indemnify, and hold harmless the Service operator - and its affiliates, officers, employees, and agents from and against - claims, liabilities, damages, losses, and expenses arising out of your - misuse of the Service, your Customer Content, or your violation of - these Terms or applicable law. -

- -

11. Changes to the Service or Terms

-

- The Service operator may modify the Service or these Terms from time - to time. Updated Terms become effective when posted. Your continued - use of the Service after the effective date of updated Terms - constitutes acceptance of the updated Terms. -

- -

12. Governing Law

-

- Unless a separate written agreement provides otherwise, these Terms are - governed by the laws of the State of California, excluding its - conflict of law rules. Any dispute arising out of or relating to these - Terms or the Service will be resolved exclusively in the state or - federal courts located in San Francisco County, California, and you - consent to those courts' personal jurisdiction. -

- -

13. General

-

- These Terms are the entire agreement between you and the Service - operator regarding the Service unless superseded by a separate written - agreement. If any provision is held unenforceable, the remaining - provisions will remain in effect. Failure to enforce any provision is - not a waiver. -

-
-
- - diff --git a/docs-dev/architecture/configuration.md b/docs-dev/architecture/configuration.md index 988c93d..3d432ea 100644 --- a/docs-dev/architecture/configuration.md +++ b/docs-dev/architecture/configuration.md @@ -32,7 +32,9 @@ 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: @@ -45,6 +47,25 @@ googleDrive: baseUrl: "http://127.0.0.1:9090" ``` +Generic OIDC: + +```yaml +oidc: + clientExchange: true + generic: + clientID: "" + clientSecret: "" + discoveryURL: "https://accounts.example.com/.well-known/openid-configuration" + redirectURL: "https://web.runme.dev/oidc/callback" + scopes: + - "openid" + - "email" +googleDrive: + clientID: "" + clientSecret: "" + baseUrl: "http://127.0.0.1:9090" +``` + 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 From f49efc7aad0f6ef4c1c4372adec7d71d946efab6 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sun, 1 Mar 2026 11:57:01 -0800 Subject: [PATCH 8/9] Add static about and privacy policy pages Signed-off-by: Jeremy lewi --- app/assets/about.html | 57 +++++++++++++++ app/assets/policies/index.html | 46 ++++++++---- app/assets/policies/privacypolicy.html | 97 ++++++++++++++++++++++++++ 3 files changed, 187 insertions(+), 13 deletions(-) create mode 100644 app/assets/about.html create mode 100644 app/assets/policies/privacypolicy.html 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/policies/index.html b/app/assets/policies/index.html index 91e0153..5e5cfa3 100644 --- a/app/assets/policies/index.html +++ b/app/assets/policies/index.html @@ -2,20 +2,40 @@ - Redirecting... - - - + + Runme Web Policies + -

- Redirecting to - https://lfprojects.org/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. +

+
+ + From b029c9f3c3aae8dc2999018a5f0fca9f7592b68d Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Sun, 1 Mar 2026 12:56:10 -0800 Subject: [PATCH 9/9] Add runtime ChatKit domain key config Signed-off-by: Jeremy lewi --- app/README.md | 3 + app/assets/configs/app-configs.yaml | 3 + app/src/components/ChatKit/ChatKitPanel.tsx | 18 +--- app/src/lib/appConfig.test.ts | 33 ++++++++ app/src/lib/appConfig.ts | 92 ++++++++++++++++++++- docs-dev/architecture/configuration.md | 4 + 6 files changed, 137 insertions(+), 16 deletions(-) diff --git a/app/README.md b/app/README.md index 5da3f83..cad566c 100644 --- a/app/README.md +++ b/app/README.md @@ -137,6 +137,9 @@ go run ./ agent --config=${HOME}/.runme-agent/config.dev.yaml serve 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`. diff --git a/app/assets/configs/app-configs.yaml b/app/assets/configs/app-configs.yaml index 9d90a4a..aacde09 100644 --- a/app/assets/configs/app-configs.yaml +++ b/app/assets/configs/app-configs.yaml @@ -12,3 +12,6 @@ oidc: googleDrive: clientID: "554943104515-bdt3on71kvc489nvi3l37gialolcnk0a.apps.googleusercontent.com" + +chatkit: + domainKey: "domain_pk_69a4995c6a28819687dae60b7fff88150c50b644747c389a" 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/lib/appConfig.test.ts b/app/src/lib/appConfig.test.ts index 2bb95d4..426d1e2 100644 --- a/app/src/lib/appConfig.test.ts +++ b/app/src/lib/appConfig.test.ts @@ -70,4 +70,37 @@ describe("appConfig OIDC Google shorthand", () => { 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 3df8b15..a43bf40 100644 --- a/app/src/lib/appConfig.ts +++ b/app/src/lib/appConfig.ts @@ -51,6 +51,10 @@ export interface GoogleDriveRuntimeConfig { baseUrl: string; } +export interface ChatkitRuntimeConfig { + domainKey: string; +} + export interface AgentRuntimeConfig { endpoint: string; defaultRunnerEndpoint: string; @@ -60,6 +64,7 @@ export interface RuntimeAppConfig { agent: AgentRuntimeConfig; oidc: OidcRuntimeConfig; googleDrive: GoogleDriveRuntimeConfig; + chatkit: ChatkitRuntimeConfig; } export type AppliedAppConfig = { @@ -68,6 +73,7 @@ export type AppliedAppConfig = { googleOAuth?: GoogleOAuthClientConfig; agentEndpoint?: string; defaultRunnerEndpoint?: string; + chatkitDomainKey?: string; warnings: string[]; }; @@ -253,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[], @@ -300,6 +337,9 @@ function createDefaultRuntimeAppConfig(): RuntimeAppConfig { clientSecret: "", baseUrl: "", }, + chatkit: { + domainKey: "", + }, }; } @@ -320,6 +360,7 @@ export class RuntimeAppConfigSchema { const oidcGeneric = asRecord(oidc?.generic); const oidcGoogle = asRecord(oidc?.google); const drive = asRecord(root.googleDrive); + const chatkit = asRecord(root.chatkit); parsed.agent = { endpoint: @@ -361,6 +402,12 @@ export class RuntimeAppConfigSchema { }; } + if (chatkit) { + parsed.chatkit = { + domainKey: pickString(chatkit, ["domainKey", "domain_key"]), + }; + } + return parsed; } } @@ -384,11 +431,13 @@ export function applyAppConfig( 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; @@ -405,6 +454,7 @@ export function applyAppConfig( const clientSecret = googleOidcClientSecret ?? normalizeString(genericOidcConfig.clientSecret); const redirectUri = normalizeString(genericOidcConfig.redirectUrl); + const configuredChatkitDomainKey = normalizeString(parsed.chatkit.domainKey); if (hasOidcGoogleBlock) { oidcConfigManager.setGoogleDefaults(); } @@ -457,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); @@ -485,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 { @@ -493,6 +563,7 @@ export function applyAppConfig( googleOAuth, agentEndpoint, defaultRunnerEndpoint, + chatkitDomainKey, warnings, }; } @@ -518,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" }, }); @@ -549,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/docs-dev/architecture/configuration.md b/docs-dev/architecture/configuration.md index 3d432ea..a703d2e 100644 --- a/docs-dev/architecture/configuration.md +++ b/docs-dev/architecture/configuration.md @@ -45,6 +45,8 @@ googleDrive: clientID: "" clientSecret: "" baseUrl: "http://127.0.0.1:9090" +chatkit: + domainKey: "" ``` Generic OIDC: @@ -64,6 +66,8 @@ 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.