diff --git a/.env.example b/.env.example index 016e81df..126017d3 100644 --- a/.env.example +++ b/.env.example @@ -203,15 +203,17 @@ AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true # EGRESS_PROXY_SALES_BOT=http://sales.proxy.internal:8080 -# The managed coworker AG-UI endpoint. Required: use an HTTP(S) URL. +# The managed coworker AG-UI endpoint. Optional: use an HTTP(S) URL, and set MANAGED_AGENT_TOKEN +# with it. A URL with no token refuses to start; a leftover token with no URL is ignored. # -# Defaults to agent-langgraph on 4201, which runs a real framework and its own tool loop. The -# proof-of-concept on 4200 hand-writes the protocol and leaves the loop to whatever is watching, so -# it is a reference rather than something to build a deployment on. -MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui +# Unset in the one-container image, which does not carry a Bot. `scripts/start.sh` points this at +# agent-langgraph on 4201 for a laptop. The proof-of-concept on 4200 hand-writes the protocol and +# leaves the loop to whatever is watching, so it is a reference rather than something to build a +# deployment on. +# MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui # Shared secret sent by the server on every call to a managed Bot, as the `x-openbot-agent-token` -# header. Both Bots refuse to start without it, and so does the server. +# header. Required together with the URL above; the server starts without either. # # `scripts/start.sh` generates one and writes it back here on first run, so leaving this empty is # fine locally. Set it yourself for a deployment: openssl rand -base64 32 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14225907..959a1271 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -165,10 +165,8 @@ jobs: -e EMBEDDED_POSTGRES=on \ -e KEY_ENCRYPTION_KEY="$(openssl rand -base64 32)" \ -e TRUSTED_ORIGINS=http://localhost:3001 \ - -e OPENBOT_SINGLE_USER=true \ - -e MANAGED_AGENT_AG_UI_URL=http://127.0.0.1:4201/ag-ui \ - -e MANAGED_AGENT_TOKEN=ci-not-a-real-token \ - -e INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai \ + -e OPENBOT_SINGLE_USER=true \ + -e INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai \ -e INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai \ -e INTELLIGENCE_API_KEY=ci-not-a-real-key \ -e COPILOTKIT_LICENSE_TOKEN=ci-not-a-real-licence \ diff --git a/CHANGELOG.md b/CHANGELOG.md index d6bc8424..a6d3ca08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,14 @@ digit. The same rule container and volume names have always followed. A deployme `AUDIT_RETENTION_DAYS` is new and unset, which keeps the audit trail forever, as before. Set it to a whole number of days to have old rows removed. +`MANAGED_AGENT_AG_UI_URL` is no longer required to start. The one-container image does not carry a +Bot, so requiring it registered the shipped Risk Analyst against a host that was not there and every +conversation with it failed. Leave it unset for that image. A laptop `scripts/start.sh` still points +it at `agent-langgraph`. A URL with no `MANAGED_AGENT_TOKEN` still refuses to start; a leftover +token with no URL is ignored. + +A `.env` copied from an older `.env.example` still has `MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui`. +Unset it before `docker run --env-file .env`, or the coworker comes back. The built-in Bot refuses to start without `OPENAI_API_KEY`. It used to start, report healthy, and then fail every conversation, so a missing key looked like a working deployment. The LangGraph Bot already refused the same way. @@ -114,6 +122,10 @@ Sessions survive and nobody signs in again. is unavailable never blocks a sign-in. ### Fixed +- **The one-container image registered a coworker it could not run.** `MANAGED_AGENT_AG_UI_URL` + defaulted to `localhost:4201` and was required, so Risk Analyst appeared on the roster and every + conversation with it failed. The URL is optional; the package omits that coworker when it is + unset. `scripts/start.sh` still points it at `agent-langgraph` on a laptop. - **A boundary rule applied on one server out of N.** The policy is read from memory on every action, which is right, but memory was only ever filled at boot. An administrator's new deny rule was enforced by whichever process served the request and roughly one action in N went through it, while diff --git a/README.md b/README.md index 855a9ad1..26de4ccc 100644 --- a/README.md +++ b/README.md @@ -168,7 +168,7 @@ From `/agents`, create a coworker with: - optional AG-UI endpoint; - optional write-only authorization header. -The server validates agent endpoints with the same target checks used for browser navigation. If no custom endpoint is set, product-created coworkers use `MANAGED_AGENT_AG_UI_URL`. +The server validates agent endpoints with the same target checks used for browser navigation. If no custom endpoint is set, product-created coworkers use `MANAGED_AGENT_AG_UI_URL` when it is configured, and are refused when it is not. Tenant package agents are declared in `agents.yaml` as either: @@ -183,8 +183,6 @@ See [docs/configuration.md](docs/configuration.md) and [docs/coworkers.md](docs/ - `DATABASE_URL` - `KEY_ENCRYPTION_KEY` -- `MANAGED_AGENT_AG_UI_URL` -- `MANAGED_AGENT_TOKEN` - `INTELLIGENCE_API_URL` - `INTELLIGENCE_GATEWAY_WS_URL` - `INTELLIGENCE_API_KEY` diff --git a/docs/configuration.md b/docs/configuration.md index dee24242..7a3ea8d8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -20,8 +20,6 @@ bash scripts/start.sh | ----------------------------- | ----------------------------------------------------------------------------------------------------- | | `DATABASE_URL` | PostgreSQL connection string. | | `KEY_ENCRYPTION_KEY` | Base64-encoded 32-byte key for encrypted stored credentials. Generate with `openssl rand -base64 32`. | -| `MANAGED_AGENT_AG_UI_URL` | Default AG-UI endpoint for coworkers created in the product. Must be HTTP(S). | -| `MANAGED_AGENT_TOKEN` | Secret sent only to the managed AG-UI endpoint. Generate with `openssl rand -base64 32`. | | `INTELLIGENCE_API_URL` | CopilotKit Intelligence API URL. | | `INTELLIGENCE_GATEWAY_WS_URL` | CopilotKit Intelligence realtime gateway URL. | | `INTELLIGENCE_API_KEY` | Runtime key for the Intelligence project. | @@ -29,6 +27,13 @@ bash scripts/start.sh All four Intelligence values are required together. Missing any of them stops server startup. +`MANAGED_AGENT_AG_UI_URL` names the Bot in the box: the default endpoint for coworkers created in +the product. It needs `MANAGED_AGENT_TOKEN` beside it, or the server refuses to start. Unset, the +server starts without a managed Bot, the shipped Risk Analyst coworker is omitted, and creating a +coworker without its own endpoint is refused. A leftover token with no URL is ignored. The +one-container image has no Bot process, so leave the URL unset there. `scripts/start.sh` points it +at `agent-langgraph` on a laptop. + ## General variables | Variable | Default | Meaning | diff --git a/docs/coworkers.md b/docs/coworkers.md index db1a7e63..37a40e80 100644 --- a/docs/coworkers.md +++ b/docs/coworkers.md @@ -61,7 +61,10 @@ That is `agent-langgraph`, which runs a real framework and its own tool loop. Th `4200` hand-writes the protocol and leaves the loop to whatever is watching, so it is a reference rather than something to build a deployment on. -The server requires this setting at startup. Package-provided agents use their own `agents.yaml` configuration. +The URL is optional. Set it with `MANAGED_AGENT_TOKEN`, or leave it unset: product-created coworkers +then need their own endpoint, and a package agent whose endpoint expands to nothing is omitted +rather than registered against a missing host. A leftover token with no URL is ignored. +Package-provided agents otherwise use their own `agents.yaml` configuration. ## Register an external AG-UI agent diff --git a/docs/deployment.md b/docs/deployment.md index 6dfc167f..60bf2802 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -68,12 +68,17 @@ and the fix would not be available. | `KEY_ENCRYPTION_KEY` | base64 32 bytes. `openssl rand -base64 32`. The example key is refused in production | | `INTELLIGENCE_API_URL`, `INTELLIGENCE_GATEWAY_WS_URL`, `INTELLIGENCE_API_KEY` | CopilotKit Intelligence. A free plan is available and it can be self-hosted | | `COPILOTKIT_LICENSE_TOKEN` | from `npx copilotkit@latest license --write` | -| `MANAGED_AGENT_AG_UI_URL` | the AG-UI endpoint for the example remote Bot | | a model key | `OPENAI_API_KEY`, or the provider you configured | `COMPUTER_TOKEN` is generated at start if you do not set one. Both processes that need it are inside the container, so there is nothing to share it with. +`MANAGED_AGENT_AG_UI_URL` is not required here. The image does not carry `agent-langgraph` or +`agent-bot`. Leave it unset and the shipped Risk Analyst coworker is omitted rather than registered +against a host that is not there. Set it, with `MANAGED_AGENT_TOKEN`, only when a Bot is actually +reachable from this container. Unset it if your `.env` still has the laptop default +`http://localhost:4201/ag-ui`. + **Authentication is required.** With no identity provider configured the deployment refuses to start, because a public URL where every visitor is an administrator fails silently: it looks like it works. Configure Google, Microsoft or Okta, or set `OPENBOT_SINGLE_USER=true` to say you meant an open @@ -98,12 +103,11 @@ docker run --rm --env-file .env openbot \ sh -c "cd /app/server && bun x drizzle-kit migrate --config=drizzle.config.ts" ``` -## One replica, for now +## Replicas -Run one. The gateway still caches the page snapshot a Bot resolves element references against in -process memory, so a second replica answers a click with a snapshot it never took. The symptom is an -element that cannot be found, intermittently, which reads as a flaky Bot rather than as a -configuration problem. Pin the platform's maximum instance count until that moves to the database. +The page snapshot a Bot resolves element references against lives in Postgres, so a second replica +can answer a click the first one snapshotted. Run more than one if the platform wants it. The +supervisor is still not in this image, so every replica shares the one browser inside it. ## Platform notes diff --git a/examples/fintech/agents.yaml b/examples/fintech/agents.yaml index 3c76cab8..a41507e8 100644 --- a/examples/fintech/agents.yaml +++ b/examples/fintech/agents.yaml @@ -24,4 +24,4 @@ agents: role_description: Investigate policies, transaction monitoring, and control evidence. avatar_seed: risk-analyst type: remote-ag-ui - endpoint: ${MANAGED_AGENT_AG_UI_URL:-http://localhost:4201/ag-ui} + endpoint: ${MANAGED_AGENT_AG_UI_URL:-} diff --git a/scripts/start.sh b/scripts/start.sh index 6cf05bbd..986727bd 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -45,6 +45,12 @@ COMPUTER_TOKEN="$(setting COMPUTER_TOKEN openbot-dev-computer-token)" # # Written into .env rather than exported for this run alone, so `docker compose up` by hand later # sees the same value the script used. +# The laptop stack runs agent-langgraph on LANGGRAPH_PORT. The one-container image does not, so +# this default stays in the script rather than in .env: a `docker run --env-file .env` must not +# inherit a URL that points at a process the image does not contain. +MANAGED_AGENT_AG_UI_URL="$(setting MANAGED_AGENT_AG_UI_URL "http://localhost:${LANGGRAPH_PORT}/ag-ui")" +export MANAGED_AGENT_AG_UI_URL + MANAGED_AGENT_TOKEN="$(setting MANAGED_AGENT_TOKEN "")" if [ -z "$MANAGED_AGENT_TOKEN" ]; then MANAGED_AGENT_TOKEN="$(openssl rand -base64 32)" diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index 98eaf565..5b055fdd 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -96,6 +96,15 @@ export class ProtectedAgentError extends Error { } } +export class ManagedAgentUnavailableError extends Error { + constructor() { + super( + "This deployment has no managed Bot. Give the coworker its own AG-UI endpoint.", + ); + this.name = "ManagedAgentUnavailableError"; + } +} + const joinedProjection = { id: agents.id, name: agents.name, @@ -258,16 +267,16 @@ async function findByTokenHash( export function createAgentProfileStore( database: Database, - managedAgentAgUiUrl: URL, + managedAgentAgUiUrl: URL | undefined, /** * Where a customer agent's key is kept. Optional so a deployment without a vault still runs; an * agent with a key then simply cannot be created, which is better than storing it in the clear. */ vault?: { store: CredentialStore; encryptionKey: string }, ): AgentProfileStore { - const managedConfiguration = { - endpoint: managedAgentAgUiUrl.toString(), - }; + const managedConfiguration = managedAgentAgUiUrl + ? { endpoint: managedAgentAgUiUrl.toString() } + : undefined; return { async list(actor, hidden = false) { @@ -295,6 +304,12 @@ export function createAgentProfileStore( create(actor, input) { return database.transaction(async (transaction) => { const id = newAgentId(); + const endpoint = input.endpoint + ? { endpoint: input.endpoint } + : managedConfiguration; + if (!endpoint) { + throw new ManagedAgentUnavailableError(); + } await transaction.insert(agents).values({ id, name: input.name, @@ -305,9 +320,7 @@ export function createAgentProfileStore( // The key, if there is one, goes to the vault and only its reference is stored here. See // auth-header.ts for why a bearer token must not sit next to the endpoint. configuration: { - ...(input.endpoint - ? { endpoint: input.endpoint } - : managedConfiguration), + ...endpoint, ...(input.auth && vault ? { auth: await storeAgentAuth({ @@ -420,6 +433,9 @@ export function createAgentProfileStore( const source = await findAccessibleProfile(transaction, actor, id); if (!source) throw new AgentNotFoundError(id); + if (!managedConfiguration) { + throw new ManagedAgentUnavailableError(); + } const duplicateId = newAgentId(); await transaction.insert(agents).values({ id: duplicateId, diff --git a/server/src/agents/routes.ts b/server/src/agents/routes.ts index 4beb5ee5..144a8c9b 100644 --- a/server/src/agents/routes.ts +++ b/server/src/agents/routes.ts @@ -10,6 +10,7 @@ import { AgentNotFoundError, AgentNotManageableError, type AgentProfileStore, + ManagedAgentUnavailableError, ProtectedAgentError, } from "./profile-store"; import type { @@ -476,5 +477,8 @@ function mapStoreError(context: Context, error: unknown): Response { if (error instanceof ProtectedAgentError) { return context.json({ error: "System-owned agents are protected." }, 403); } + if (error instanceof ManagedAgentUnavailableError) { + return context.json({ error: error.message }, 400); + } throw error; } diff --git a/server/src/config.ts b/server/src/config.ts index 2981b1dd..dfb68dfd 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -86,12 +86,23 @@ export function configuredAuthProviders( return providers; } +export type ManagedAgentConfig = { + endpoint: URL; + /** Secret sent only to the managed Bot endpoint. Never stored in an agent row. */ + token: string; +}; + export type DeploymentConfig = { databaseUrl: string; keyEncryptionKey: string; - managedAgentAgUiUrl: URL; - /** Secret sent only to the managed Bot endpoint. Never stored in an agent row. */ - managedAgentToken: string; + /** + * The Bot in the box, when this deployment has one. + * + * Absent is the one-container image: it carries no AG-UI process, and a required URL would + * register a coworker against a host that is not there. Set both the URL and the token together + * when a remote Bot is actually running. + */ + managedAgent?: ManagedAgentConfig; /** * What this deployment calls itself, when more than one shares an Intelligence project. * @@ -220,8 +231,14 @@ function url(environment: Environment, name: string): string | undefined { return value; } -function requiredHttpUrl(environment: Environment, name: string): URL { - const value = required(environment, name); +function optionalHttpUrl( + environment: Environment, + name: string, +): URL | undefined { + const value = optional(environment, name); + if (!value) { + return undefined; + } let parsed: URL; try { @@ -237,6 +254,29 @@ function requiredHttpUrl(environment: Environment, name: string): URL { return parsed; } +/** + * The Bot in the box, if this deployment has one. + * + * A URL with no token would send unauthenticated calls to a Bot that refuses them, so that half + * alone refuses to start. A token with no URL is the leftover `scripts/start.sh` writes into + * `.env`; it names nothing and is ignored, so a one-container image can boot from that file. + */ +function managedAgentConfig( + environment: Environment, +): ManagedAgentConfig | undefined { + const endpoint = optionalHttpUrl(environment, "MANAGED_AGENT_AG_UI_URL"); + const token = optional(environment, "MANAGED_AGENT_TOKEN"); + if (endpoint && !token) { + throw new Error( + "MANAGED_AGENT_TOKEN must be set when MANAGED_AGENT_AG_UI_URL is set", + ); + } + if (!endpoint || !token) { + return undefined; + } + return { endpoint, token }; +} + function oauthClient( environment: Environment, provider: "GOOGLE" | "MICROSOFT" | "OKTA", @@ -542,15 +582,12 @@ export function loadConfig( ): DeploymentConfig { const google = oauthClient(environment, "GOOGLE"); const auth = authConfig(environment, google); + const managedAgent = managedAgentConfig(environment); return { databaseUrl: required(environment, "DATABASE_URL"), keyEncryptionKey: keyEncryptionKey(environment), - managedAgentAgUiUrl: requiredHttpUrl( - environment, - "MANAGED_AGENT_AG_UI_URL", - ), - managedAgentToken: required(environment, "MANAGED_AGENT_TOKEN"), + ...(managedAgent ? { managedAgent } : {}), deploymentId: optional(environment, "DEPLOYMENT_ID"), tenantPackageDirectory: optional(environment, "TENANT_PACKAGE_DIR") ?? "../examples/fintech", diff --git a/server/src/index.ts b/server/src/index.ts index 462334c9..5f6262e0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -127,7 +127,7 @@ const agentVault = { }; const agentProfileStore = createAgentProfileStore( database, - config.managedAgentAgUiUrl, + config.managedAgent?.endpoint, agentVault, ); // Read here rather than beside the synchronise below, because the package names the deployment and @@ -158,10 +158,11 @@ const channelActivityListener = await startChannelActivityListener( channelEvents, ); const roleRepository = createRoleRepository(database); -const loadAgentsForActor = createRuntimeAgentLoader(database, agentVault, { - endpoint: config.managedAgentAgUiUrl, - token: config.managedAgentToken, -}); +const loadAgentsForActor = createRuntimeAgentLoader( + database, + agentVault, + config.managedAgent, +); await synchronizeTenantPackage(database, tenantPackage); /* * Built before `auth`, because the deny list is consulted during sign-in and the store is what diff --git a/server/src/tenant-package.ts b/server/src/tenant-package.ts index 7f56e24d..1aba4baa 100644 --- a/server/src/tenant-package.ts +++ b/server/src/tenant-package.ts @@ -244,7 +244,8 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage { const tenant = asRecord(brand.tenant, "brand.tenant"); const skin = brand.skin === undefined ? undefined : asRecord(brand.skin, "brand.skin"); - const agents = asList(agentsYaml.agents, "agents.yaml agents").map( + const omittedAgentIds = new Set(); + const agents = asList(agentsYaml.agents, "agents.yaml agents").flatMap( (value) => { const agent = asRecord(value, "agent"); const type: TenantAgent["type"] | undefined = @@ -256,29 +257,42 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage { if (!type) { throw new Error("agent.type must be built-in or remote-ag-ui"); } - return { - id: requiredString(agent.id, "agent.id"), - name: requiredString(agent.name, "agent.name"), - title: requiredString(agent.title, "agent.title"), - roleDescription: requiredString( - agent.role_description, - "agent.role_description", - ), - avatarSeed: - agent.avatar_seed === undefined - ? undefined - : requiredString(agent.avatar_seed, "agent.avatar_seed"), - type, - configuration: - type === "built_in" - ? { - systemPrompt: requiredString( - agent.system_prompt, - "agent.system_prompt", - ), - } - : { endpoint: requiredString(agent.endpoint, "agent.endpoint") }, - }; + const id = requiredString(agent.id, "agent.id"); + if (type === "remote_ag_ui") { + const endpoint = + typeof agent.endpoint === "string" ? agent.endpoint.trim() : ""; + if (!endpoint) { + omittedAgentIds.add(id); + return []; + } + } + return [ + { + id, + name: requiredString(agent.name, "agent.name"), + title: requiredString(agent.title, "agent.title"), + roleDescription: requiredString( + agent.role_description, + "agent.role_description", + ), + avatarSeed: + agent.avatar_seed === undefined + ? undefined + : requiredString(agent.avatar_seed, "agent.avatar_seed"), + type, + configuration: + type === "built_in" + ? { + systemPrompt: requiredString( + agent.system_prompt, + "agent.system_prompt", + ), + } + : { + endpoint: requiredString(agent.endpoint, "agent.endpoint"), + }, + }, + ]; }, ); const agentIds = new Set(agents.map((agent) => agent.id)); @@ -288,7 +302,7 @@ export function validateTenantPackage(files: PackageFiles): TenantPackage { const permittedAgents = stringArray( channel.permitted_agents, "channel.permitted_agents", - ); + ).filter((agentId) => !omittedAgentIds.has(agentId)); for (const agentId of permittedAgents) { if (!agentIds.has(agentId)) { throw new Error(`channel references unknown agent "${agentId}"`); diff --git a/server/tests/agent-profile-store.integration.test.ts b/server/tests/agent-profile-store.integration.test.ts index a3d0691a..92c82908 100644 --- a/server/tests/agent-profile-store.integration.test.ts +++ b/server/tests/agent-profile-store.integration.test.ts @@ -6,6 +6,7 @@ import { AgentNotManageableError, type AgentProfileStore, createAgentProfileStore, + ManagedAgentUnavailableError, ProtectedAgentError, } from "../src/agents/profile-store"; import type { @@ -220,6 +221,20 @@ async function racePackageAttachment( } describe("agent profile store integration", () => { + test("refuses to create a coworker with no endpoint when this deployment has no managed Bot", async () => { + const owner = await createUser(); + const withoutManaged = createAgentProfileStore(database, undefined); + + await expect( + withoutManaged.create(owner, { + name: "No Endpoint", + title: "Needs an address", + roleDescription: "Should not land on a missing Bot.", + visibility: "private", + }), + ).rejects.toBeInstanceOf(ManagedAgentUnavailableError); + }); + test("lets an owner and admin get and list a private profile but hides it from another user", async () => { const owner = await createUser(); const other = await createUser(); diff --git a/server/tests/config.test.ts b/server/tests/config.test.ts index 5e470996..9deec2fa 100644 --- a/server/tests/config.test.ts +++ b/server/tests/config.test.ts @@ -50,9 +50,10 @@ describe("deployment configuration", () => { licenseToken: "license-token", }, }); - expect(config.managedAgentAgUiUrl).toEqual( - new URL("http://localhost:4200/ag-ui"), - ); + expect(config.managedAgent).toEqual({ + endpoint: new URL("http://localhost:4200/ag-ui"), + token: "managed-agent-token", + }); expect(config.tenantPackageDirectory).toBe("../examples/fintech"); }); @@ -116,22 +117,34 @@ describe("deployment configuration", () => { ); }); - test("refuses to start when MANAGED_AGENT_AG_UI_URL is missing", () => { + test("starts without a managed Bot when neither half is set", () => { const environment: Record = { ...baseEnvironment, }; delete environment.MANAGED_AGENT_AG_UI_URL; + delete environment.MANAGED_AGENT_TOKEN; - expect(() => loadConfig(environment)).toThrow("MANAGED_AGENT_AG_UI_URL"); + expect(loadConfig(environment).managedAgent).toBeUndefined(); }); - test("refuses to start when MANAGED_AGENT_TOKEN is missing", () => { + test("refuses a URL with no token", () => { const environment: Record = { ...baseEnvironment, }; delete environment.MANAGED_AGENT_TOKEN; - expect(() => loadConfig(environment)).toThrow("MANAGED_AGENT_TOKEN"); + expect(() => loadConfig(environment)).toThrow( + "MANAGED_AGENT_TOKEN must be set when MANAGED_AGENT_AG_UI_URL is set", + ); + }); + + test("ignores a leftover token when no URL is set", () => { + const environment: Record = { + ...baseEnvironment, + }; + delete environment.MANAGED_AGENT_AG_UI_URL; + + expect(loadConfig(environment).managedAgent).toBeUndefined(); }); test("refuses a non-HTTP MANAGED_AGENT_AG_UI_URL", () => { diff --git a/server/tests/tenant-package.test.ts b/server/tests/tenant-package.test.ts index 3c4e3243..5e1dc3c8 100644 --- a/server/tests/tenant-package.test.ts +++ b/server/tests/tenant-package.test.ts @@ -294,6 +294,40 @@ describe("tenant YAML validation", () => { }), ).toThrow('references unknown agent "missing"'); }); + + test("omits a remote coworker whose endpoint expanded to nothing", () => { + const tenantPackage = validateTenantPackage({ + brand: "tenant: { id: fintech, product_name: Ledgerline }", + agents: `agents: + - id: knowledge + name: Knowledge + title: Company Knowledge + role_description: Answer company questions. + type: built-in + system_prompt: Answer from knowledge. + - id: risk-analyst + name: Risk Analyst + title: Risk + role_description: Investigate policies. + type: remote-ag-ui + endpoint: ""`, + channels: `channels: + - id: risk-and-compliance + name: Risk + description: Investigate. + permitted_agents: [knowledge, risk-analyst] + allowed_groups: [all]`, + model: + "model: { provider: openai, credential_secret_ref: openai-key, default_model: gpt-4.1 }", + knowledge: "sources: []", + themeCss: "", + }); + + expect(tenantPackage.agents.map((agent) => agent.id)).toEqual([ + "knowledge", + ]); + expect(tenantPackage.channels[0]?.permittedAgents).toEqual(["knowledge"]); + }); }); describe("tenant package agent profile synchronization", () => {