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); + }); +});