From 320b5a18b0029ebe6795be223f34991e221aa4f4 Mon Sep 17 00:00:00 2001 From: David McKay Date: Fri, 2 Oct 2026 15:08:53 -0700 Subject: [PATCH] Hide the self-host banner from deployments that pay for Intelligence The banner offering help self-hosting OpenBot stays on by default, but a deployment whose Intelligence entitlement is active on a paid plan (pro, team, team_self_hosted, enterprise) or comes from an AWS Marketplace licence no longer shows it. Free, developer, inactive and unreadable entitlements still show it. The server reads the entitlement through the Intelligence client it already holds, keeps the answer for ten minutes, refreshes it in the background when stale, waits at most a second for the first answer, and falls back to showing the bar on any error, logged once per run of failures. The changelog's upgrade note also names the banner and the malformed egress range change from #706, which refuses a saved network policy that contains such a range as a whole. --- .env.example | 3 +- CHANGELOG.md | 16 +- app/src/lib/deployment/queries.ts | 3 +- docs/configuration.md | 11 +- server/src/app.ts | 14 +- server/src/config.ts | 3 +- server/src/index.ts | 16 +- server/src/self-host-banner.ts | 137 +++++++++++++++ server/tests/self-host-banner.test.ts | 243 ++++++++++++++++++++++++++ 9 files changed, 435 insertions(+), 11 deletions(-) create mode 100644 server/src/self-host-banner.ts create mode 100644 server/tests/self-host-banner.test.ts diff --git a/.env.example b/.env.example index d814c862b..478ddab20 100644 --- a/.env.example +++ b/.env.example @@ -34,7 +34,8 @@ TENANT_PACKAGE_DIR=../examples/fintech # deployment that must not reach the public internet from a browser tab should set this to false. # OPENBOT_GENERATIVE_UI=false # The signed-in app shows a bar offering CopilotKit's help self-hosting OpenBot, until each person -# closes it. A fork running OpenBot for its own organization can turn it off for everybody. +# closes it. A deployment on a paid Intelligence plan never shows it. A fork running OpenBot for its +# own organization can turn it off for everybody. # OPENBOT_SELF_HOST_BANNER=false # What this deployment calls itself, when more than one shares an Intelligence project. A copy of a # deployment made for development uses the same project key, and threads are listed per Bot with diff --git a/CHANGELOG.md b/CHANGELOG.md index 1062725c8..11667df4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -**Before upgrading.** Four things change for an existing deployment: +**Before upgrading.** Six things change for an existing deployment: - Automatic Learning is on unless an administrator saved it off. It does nothing until a Learning container is assigned; see below. - A Bot's computer refuses the network until the server pushes its policy. A computer run without @@ -19,12 +19,22 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `0051_routine_enabled_at`. - An existing Windows clone checks text files out with LF only after `git rm -r --cached . && git reset --hard` on a clean tree. +- The signed-in app shows a bar offering CopilotKit's help self-hosting OpenBot, unless the + deployment is on a paid Intelligence plan. Set `OPENBOT_SELF_HOST_BANNER=false` to remove it for + everybody. +- An egress rule with a malformed IP range, such as `10.0.0.5/`, used to be read as `/0` and allow + every IPv4 address. It is now refused, and a saved network policy that contains one is refused as + a whole: the Bots under it fall back to an allowlist with nothing on it, so they reach nothing + until the rule is corrected under Admin → Enterprise. ### The app offers help self-hosting OpenBot, until you close it A slim bar at the top of the signed-in app links to CopilotKit's engineers for help self-hosting -OpenBot. Closing it is saved to your preferences, so it stays closed on every device. A fork running -OpenBot for its own organization hides it for everybody with `OPENBOT_SELF_HOST_BANNER=false`. +OpenBot. Closing it is saved to your preferences, so it stays closed on every device. A deployment +on a paid Intelligence plan (`pro`, `team`, `team_self_hosted` or `enterprise`, or a licence bought +through AWS Marketplace) never shows it; any other plan, or an entitlement that cannot be read, +shows it. A fork running OpenBot for its own organization hides it for everybody with +`OPENBOT_SELF_HOST_BANNER=false`. ### A request to the approvals API that is not JSON answers 400 diff --git a/app/src/lib/deployment/queries.ts b/app/src/lib/deployment/queries.ts index 48bda770e..7e9687687 100644 --- a/app/src/lib/deployment/queries.ts +++ b/app/src/lib/deployment/queries.ts @@ -20,7 +20,8 @@ export type DeploymentCapabilities = { generativeUi: boolean; /** * Whether to show the banner offering help self-hosting OpenBot. A fork that runs OpenBot for its - * own organization turns it off with OPENBOT_SELF_HOST_BANNER=false. + * own organization turns it off with OPENBOT_SELF_HOST_BANNER=false, and the server answers false + * for a deployment on a paid Intelligence plan. */ selfHostBanner: boolean; transcription?: boolean; diff --git a/docs/configuration.md b/docs/configuration.md index 3a989058d..2d49de5d9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -63,7 +63,7 @@ at `agent-langgraph` on a laptop. | `AUDIT_RETENTION_DAYS` | unset | Whole number of days to keep audit rows; older ones are removed. Unset keeps the trail forever. | | `WORKER_SHARED_SECRET` | unset; `start.sh` uses a fixed local default | The secret the routines worker presents to fire a due routine. Without it the server refuses every handoff, whether or not a worker exists to send one. | | `OPENBOT_GENERATIVE_UI` | unset (capability on) | Set `false` or `0` to stop Bots from answering with generated interfaces. | -| `OPENBOT_SELF_HOST_BANNER` | unset (banner on) | Set `false` or `0` to hide the bar offering help self-hosting OpenBot, for everybody. Each person can also close it for themselves. | +| `OPENBOT_SELF_HOST_BANNER` | unset (banner on) | Set `false` or `0` to hide the bar offering help self-hosting OpenBot, for everybody. It never shows on a paid Intelligence plan, and each person can also close it for themselves. | | `OPENBOT_ACCESSIBILITY_DISABLED` | unset | `true` or `1` stops naming OpenBot on the analytics the runtime already sends. | | `COMPOSIO_API_KEY` | unset | One key for the whole deployment, for the broker that holds people's accounts for a few hundred apps. Unset, there is nothing to connect, nothing to grant and no Composio tool for a Bot to call; what remains is one row that goes nowhere, under **More apps** on the admin Plugins page, naming this variable. See [Composio](plugins/composio.md). | @@ -78,7 +78,14 @@ setting through `/api/capabilities` to the browser. CopilotKit's help self-hosting OpenBot, linking to `https://copilotkit.ai/talk-to-an-engineer` with `ref=openbot_app`. Closing it is saved to that person's preferences, so it stays closed on every device they sign in from. Set `false` or `0` to hide it for everybody, which suits a fork running -OpenBot for its own organization. Any other value, or none, leaves it on. In Helm, set it through +OpenBot for its own organization. Any other value, or none, leaves it on. + +Left on, it still never shows on a deployment that pays for Intelligence. The server reads the +deployment's Intelligence entitlement and hides the bar when it is active on a paid plan (`pro`, +`team`, `team_self_hosted` or `enterprise`) or comes from an AWS Marketplace licence. A free or +developer plan, an inactive entitlement, and one that cannot be read all show it. The answer is +kept for ten minutes, so a plan bought today hides the bar within ten minutes, and a page waits at +most a second for the first answer after the server starts. In Helm, set the variable through `config.extraEnv`. The component catalogue has separate per-Bot grants. Its sortable data table (`showTable`), diff --git a/server/src/app.ts b/server/src/app.ts index ce45c43ba..8e39653bd 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -81,6 +81,7 @@ import { createDictationRoutes } from "./dictation/routes"; import type { HostAccessBroker } from "./host-access/broker"; import { createHostAccessRoutes } from "./host-access/routes"; import { createIntelligenceClient } from "./intelligence-client"; +import type { SelfHostBanner } from "./self-host-banner"; import { createLearningRoutes, type LearningAdminDependencies, @@ -413,6 +414,12 @@ export function createApp( /** Pause, reset, Activity and attention for a person's own Bots. See agents/lifecycle.ts. */ lifecycle?: BotLifecycleServices; }, + /** + * Whether to offer help self-hosting, which also hides it from a deployment that pays for + * Intelligence. Absent falls back to the operator's switch alone, so a test or a build without the + * resolver still answers from `config.selfHostBanner` rather than asking the network. + */ + selfHostBanner?: SelfHostBanner, ) { const app = new Hono<{ Variables: AppVariables }>(); mountDesktopConnectionFailure(app, desktopHostToken); @@ -436,8 +443,11 @@ export function createApp( * both halves, so off means off. */ generativeUi: config.generativeUi, - // Whether to offer help self-hosting OpenBot. A fork running it for its own people turns it off. - selfHostBanner: config.selfHostBanner, + // Whether to offer help self-hosting OpenBot. A fork running it for its own people turns it off, + // and a deployment that pays for Intelligence never sees it. See self-host-banner.ts. + selfHostBanner: selfHostBanner + ? await selfHostBanner.shown() + : config.selfHostBanner, transcription: Boolean(config.transcription), voice: Boolean(config.voice), /* diff --git a/server/src/config.ts b/server/src/config.ts index 604c562fe..34e28a70e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -306,7 +306,8 @@ export type DeploymentConfig = { * * On by default, because a fresh clone is somebody evaluating the template. A fork that runs * OpenBot for its own organization turns it off with OPENBOT_SELF_HOST_BANNER=false or - * OPENBOT_SELF_HOST_BANNER=0, since its people have nothing to self-host. + * OPENBOT_SELF_HOST_BANNER=0, since its people have nothing to self-host. This is the operator's + * switch only; a deployment on a paid Intelligence plan hides the bar too (self-host-banner.ts). */ selfHostBanner: boolean; /** diff --git a/server/src/index.ts b/server/src/index.ts index 42db137d3..972f672f0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -172,7 +172,11 @@ import { HostAccessRefusedError, } from "./host-access/broker"; import { hostAccessTools } from "./host-access/tools"; -import { observeIntelligenceAuthentication } from "./intelligence-client"; +import { + createIntelligenceClient, + observeIntelligenceAuthentication, +} from "./intelligence-client"; +import { createSelfHostBanner } from "./self-host-banner"; import { clearLearningRevisionFallback } from "./learning/runtime"; import { createLearningSettingsStore } from "./learning/settings"; import { createMemoryIngestion } from "./memory/ingestion"; @@ -2859,6 +2863,12 @@ guardHostAccess( ); auditRoutineStore(routineStore, bootAuditStore); +// Asked only whether this deployment pays for Intelligence, for the self-host banner. Its own client +// rather than the runtime's, the same as the thread reader: the constructor opens nothing. +const selfHostBannerIntelligence = createIntelligenceClient( + config.runtime.intelligence, +); + const app = createApp( config, auth, @@ -3038,6 +3048,10 @@ const app = createApp( auditStore: bootAuditStore, }, }, + createSelfHostBanner({ + enabled: config.selfHostBanner, + entitlements: () => selfHostBannerIntelligence.getRuntimeEntitlements(), + }), ); /** diff --git a/server/src/self-host-banner.ts b/server/src/self-host-banner.ts new file mode 100644 index 000000000..a88f81d7b --- /dev/null +++ b/server/src/self-host-banner.ts @@ -0,0 +1,137 @@ +import type { CopilotKitIntelligence } from "@copilotkit/runtime/v2"; + +/** What Intelligence says this deployment is entitled to. Typed from the client OpenBot already uses. */ +export type RuntimeEntitlementResponse = Awaited< + ReturnType +>; + +/** + * The Intelligence plans that pay for it. + * + * `free` and `developer` are the no-cost tiers (the license verifier's `LicenseTier`), so they are + * not here. A self-hosted licence reports its `plan_code`, or its tier when it has none, as the + * plan code, so the same names cover managed and self-hosted deployments. + */ +const PAID_PLAN_CODES: ReadonlySet = new Set([ + "pro", + "team", + "team_self_hosted", + "enterprise", +]); + +/** + * Whether this deployment pays for Intelligence, read from its runtime entitlement. + * + * Only a ready, active entitlement on a paid plan counts, or one bought through AWS Marketplace, + * which carries no plan code of ours. Everything else, including a free plan, an inactive one, a + * missing plan code and every non-ready status, is not paying, so the banner shows. Being wrong in + * that direction costs a dismissable bar; being wrong the other way hides it from the people it is + * for. + */ +export function paysForIntelligence( + response: RuntimeEntitlementResponse, +): boolean { + if (response.status !== "ready" || !response.entitlement.active) return false; + if (response.entitlement.source === "awsMarketplaceDeploymentLicense") + return true; + const plan = response.entitlement.planCode; + return plan !== undefined && PAID_PLAN_CODES.has(plan); +} + +export type SelfHostBanner = { + /** Whether to offer help self-hosting. Never throws and never waits longer than `waitMs`. */ + shown: () => Promise; +}; + +/** + * How long a known answer is reused. A plan changes rarely and the bar is promotional, so ten + * minutes keeps this to six entitlement reads an hour per replica while a plan bought today still + * hides it within ten minutes. + */ +const ANSWER_TTL_MS = 10 * 60_000; +/** How long a failed read is held before trying again: soon enough to recover, not a hot loop. */ +const FAILURE_TTL_MS = 60_000; +/** + * The longest the first page load waits for an answer nobody has yet. The SDK's own request gives + * up at 1.5 seconds; a page should not wait that long for a promotional bar, so after this the bar + * shows and the answer, when it lands, applies to the next load. + */ +const COLD_WAIT_MS = 1_000; + +/** + * Decide, per deployment, whether the self-host banner shows. + * + * Off when the operator switched it off, which never asks Intelligence anything. Otherwise on unless + * the deployment pays for Intelligence. The answer is cached in this process and refreshed in the + * background when it goes stale, so a page load reads memory, not the network, after the first. + */ +export function createSelfHostBanner(options: { + enabled: boolean; + entitlements: () => Promise; + now?: () => number; + ttlMs?: number; + failureTtlMs?: number; + waitMs?: number; + log?: (message: string) => void; +}): SelfHostBanner { + const now = options.now ?? Date.now; + const ttlMs = options.ttlMs ?? ANSWER_TTL_MS; + const failureTtlMs = options.failureTtlMs ?? FAILURE_TTL_MS; + const waitMs = options.waitMs ?? COLD_WAIT_MS; + const log = options.log ?? ((message: string) => console.warn(message)); + + let answer: { shown: boolean; expiresAt: number } | undefined; + let inFlight: Promise | undefined; + // Logged once per run of failures, not once per request: a page load every second against an + // unreachable Intelligence would otherwise fill the log with the same line. + let failureLogged = false; + + function refresh(): Promise { + if (inFlight) return inFlight; + const request = options + .entitlements() + .then((response) => { + const shown = !paysForIntelligence(response); + answer = { shown, expiresAt: now() + ttlMs }; + failureLogged = false; + return shown; + }) + .catch((error: unknown) => { + if (!failureLogged) { + failureLogged = true; + log( + JSON.stringify({ + type: "self-host-banner-entitlement-unavailable", + reason: error instanceof Error ? error.message : String(error), + }), + ); + } + answer = { shown: true, expiresAt: now() + failureTtlMs }; + return true; + }) + .finally(() => { + if (inFlight === request) inFlight = undefined; + }); + inFlight = request; + return request; + } + + return { + async shown() { + if (!options.enabled) return false; + if (answer) { + if (now() >= answer.expiresAt) void refresh(); + return answer.shown; + } + let timer: ReturnType | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(true), waitMs); + }); + try { + return await Promise.race([refresh(), timedOut]); + } finally { + clearTimeout(timer); + } + }, + }; +} diff --git a/server/tests/self-host-banner.test.ts b/server/tests/self-host-banner.test.ts new file mode 100644 index 000000000..db0b22f72 --- /dev/null +++ b/server/tests/self-host-banner.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "bun:test"; +import { createApp } from "../src/app"; +import { loadConfig } from "../src/config"; +import { + createSelfHostBanner, + paysForIntelligence, + type RuntimeEntitlementResponse, +} from "../src/self-host-banner"; +import { testEnvironment } from "./support/environment"; + +type Source = + | "managedOrgSubscription" + | "selfHostedDeploymentLicense" + | "awsMarketplaceDeploymentLicense"; + +function ready( + planCode: string | undefined, + options: { active?: boolean; source?: Source } = {}, +): RuntimeEntitlementResponse { + return { + status: "ready", + entitlement: { + active: options.active ?? true, + source: options.source ?? "managedOrgSubscription", + features: {}, + limits: {}, + ...(planCode === undefined ? {} : { planCode }), + }, + }; +} + +function notReady( + status: "degraded" | "misconfigured" | "unavailable", +): RuntimeEntitlementResponse { + return { + status, + error: { code: "X", message: "not ready", retryable: true }, + }; +} + +describe("whether a deployment pays for Intelligence", () => { + test.each(["pro", "team", "team_self_hosted", "enterprise"])( + "%s is a paid plan", + (plan) => { + expect(paysForIntelligence(ready(plan))).toBe(true); + expect( + paysForIntelligence( + ready(plan, { source: "selfHostedDeploymentLicense" }), + ), + ).toBe(true); + }, + ); + + test.each(["free", "developer", "something_new"])( + "%s is not a paid plan", + (plan) => { + expect(paysForIntelligence(ready(plan))).toBe(false); + }, + ); + + test("an entitlement with no plan code is not paying", () => { + expect(paysForIntelligence(ready(undefined))).toBe(false); + }); + + test("an AWS Marketplace licence pays, with or without a plan code", () => { + expect( + paysForIntelligence( + ready(undefined, { source: "awsMarketplaceDeploymentLicense" }), + ), + ).toBe(true); + }); + + test("an inactive paid entitlement is not paying", () => { + expect(paysForIntelligence(ready("enterprise", { active: false }))).toBe( + false, + ); + expect( + paysForIntelligence( + ready(undefined, { + active: false, + source: "awsMarketplaceDeploymentLicense", + }), + ), + ).toBe(false); + }); + + test.each(["degraded", "misconfigured", "unavailable"] as const)( + "a %s entitlement is not paying", + (status) => { + expect(paysForIntelligence(notReady(status))).toBe(false); + }, + ); +}); + +describe("the self-host banner", () => { + test("is off when the operator switched it off, without asking Intelligence", async () => { + let asked = 0; + const banner = createSelfHostBanner({ + enabled: false, + entitlements: async () => { + asked += 1; + return ready("free"); + }, + }); + expect(await banner.shown()).toBe(false); + expect(asked).toBe(0); + }); + + test("shows on a free plan and hides on a paid one", async () => { + const free = createSelfHostBanner({ + enabled: true, + entitlements: async () => ready("free"), + }); + const paid = createSelfHostBanner({ + enabled: true, + entitlements: async () => ready("team"), + }); + expect(await free.shown()).toBe(true); + expect(await paid.shown()).toBe(false); + }); + + test("shows when the entitlement cannot be read, and says so once", async () => { + const logged: string[] = []; + const banner = createSelfHostBanner({ + enabled: true, + failureTtlMs: 0, + entitlements: async () => { + throw new Error("Intelligence is unreachable"); + }, + log: (line) => logged.push(line), + }); + expect(await banner.shown()).toBe(true); + expect(await banner.shown()).toBe(true); + await Bun.sleep(0); + expect(await banner.shown()).toBe(true); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain("Intelligence is unreachable"); + }); + + test("does not hold a page load longer than it allows", async () => { + const banner = createSelfHostBanner({ + enabled: true, + waitMs: 20, + entitlements: () => new Promise(() => {}), + }); + const started = performance.now(); + expect(await banner.shown()).toBe(true); + expect(performance.now() - started).toBeLessThan(500); + }); + + test("reuses a known answer instead of asking again", async () => { + let asked = 0; + const banner = createSelfHostBanner({ + enabled: true, + entitlements: async () => { + asked += 1; + return ready("enterprise"); + }, + }); + expect(await banner.shown()).toBe(false); + expect(await banner.shown()).toBe(false); + expect(await banner.shown()).toBe(false); + expect(asked).toBe(1); + }); + + test("answers from memory when stale and refreshes behind it", async () => { + let clock = 0; + let plan = "pro"; + let asked = 0; + const banner = createSelfHostBanner({ + enabled: true, + ttlMs: 100, + now: () => clock, + entitlements: async () => { + asked += 1; + return ready(plan); + }, + }); + expect(await banner.shown()).toBe(false); + plan = "free"; + clock = 200; + // The stale answer is returned at once; the plan change applies once the refresh lands. + expect(await banner.shown()).toBe(false); + await Bun.sleep(0); + expect(asked).toBe(2); + expect(await banner.shown()).toBe(true); + }); + + test("asks once for many page loads that arrive together", async () => { + let asked = 0; + const banner = createSelfHostBanner({ + enabled: true, + entitlements: async () => { + asked += 1; + await Bun.sleep(5); + return ready("free"); + }, + }); + const answers = await Promise.all([ + banner.shown(), + banner.shown(), + banner.shown(), + ]); + expect(answers).toEqual([true, true, true]); + expect(asked).toBe(1); + }); +}); + +describe("the capabilities endpoint", () => { + const config = loadConfig(testEnvironment()); + + // createApp takes its services positionally, and the banner is the last of them. + function appWith(banner: ReturnType) { + const args: unknown[] = new Array(createApp.length).fill(undefined); + args[0] = config; + args[args.length - 1] = banner; + return (createApp as (...input: unknown[]) => ReturnType)( + ...args, + ); + } + + test("hides the banner from a deployment that pays for Intelligence", async () => { + const app = appWith( + createSelfHostBanner({ + enabled: true, + entitlements: async () => ready("enterprise"), + }), + ); + const response = await app.request("/api/capabilities"); + expect((await response.json()).selfHostBanner).toBe(false); + }); + + test("shows it on a free plan", async () => { + const app = appWith( + createSelfHostBanner({ + enabled: true, + entitlements: async () => ready("free"), + }), + ); + const response = await app.request("/api/capabilities"); + expect((await response.json()).selfHostBanner).toBe(true); + }); +});