From cb56203139f89b8f634eeb04b7aea553afed8ccf Mon Sep 17 00:00:00 2001 From: Vaibhav Zope Date: Wed, 2 Sep 2026 12:51:32 +0530 Subject: [PATCH] Give a duplicated coworker the endpoint it was copied from --- CHANGELOG.md | 14 ++++ server/src/agents/profile-store.ts | 9 ++- .../agent-profile-store.integration.test.ts | 80 +++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d446e0438..c2503242f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Duplicating a coworker keeps the endpoint it was copied from + +Duplicate used to point every copy at this deployment's own managed Bot, whatever the coworker being +copied ran on. Duplicating one you host yourself gave back something that looked identical on every +screen, carried the same name, title and role, and answered from a different process. The copy's +connection tab then said it ran here and stopped showing an endpoint at all, so the swap was +invisible in the one place you would have checked. A copy now runs where its original ran, and the +managed Bot is used only when the coworker being copied had no endpoint of its own, which is the +same fallback that applies when you create one without an endpoint. It does not inherit the +original's key: that is a reference into the vault, and two coworkers sharing one credential would +mean rotating either one's key silently changed the other's, so a copy starts without one. On a +deployment with no managed Bot configured, duplicating a coworker that brought its own endpoint now +works instead of being refused with advice to give it an endpoint it already had. + ### Coworkers are made in a wizard and managed in a dialog Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs, diff --git a/server/src/agents/profile-store.ts b/server/src/agents/profile-store.ts index 8d14bdd48..315d6ee73 100644 --- a/server/src/agents/profile-store.ts +++ b/server/src/agents/profile-store.ts @@ -437,7 +437,12 @@ export function createAgentProfileStore( const source = await findAccessibleProfile(transaction, actor, id); if (!source) throw new AgentNotFoundError(id); - if (!managedConfiguration) { + // The endpoint alone: `auth` is a vault reference, and copying it shares one credential. + const configuration = source.endpoint + ? { endpoint: source.endpoint } + : managedConfiguration; + // After the source read, so a source with its own endpoint needs no managed Bot to fall back to. + if (!configuration) { throw new ManagedAgentUnavailableError(); } const duplicateId = newAgentId(); @@ -445,7 +450,7 @@ export function createAgentProfileStore( id: duplicateId, name: source.name, type: "remote_ag_ui", - configuration: managedConfiguration, + configuration, }); await transaction.insert(agentProfiles).values({ agentId: duplicateId, diff --git a/server/tests/agent-profile-store.integration.test.ts b/server/tests/agent-profile-store.integration.test.ts index 01dc052e2..b4be7310f 100644 --- a/server/tests/agent-profile-store.integration.test.ts +++ b/server/tests/agent-profile-store.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, describe, expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { and, eq, sql } from "drizzle-orm"; +import { authFromConfiguration } from "../src/agents/auth-header"; import { AgentNotFoundError, AgentNotManageableError, @@ -591,6 +592,85 @@ describe("agent profile store integration", () => { expect(duplicateMappings).toHaveLength(0); }); + test("copies the source's own endpoint rather than repointing the copy at the managed Bot", async () => { + const owner = await createUser(); + const source = await createProfileFixture({ + owner, + configuration: { endpoint: "https://hosted.example.test/ag-ui" }, + }); + + const duplicate = await store.duplicate(owner, source.agentId); + createdAgentIds.push(duplicate.id); + + expect(duplicate.endpoint).toBe("https://hosted.example.test/ag-ui"); + expect(duplicate.endpoint).not.toBe(managedAgentAgUiUrl.toString()); + }); + + test("gives a copy of an endpoint-less source the managed Bot, as its source had", async () => { + const owner = await createUser(); + const created = await store.create(owner, { + name: `Created ${randomUUID()}`, + title: "Created Title", + roleDescription: "Created role description.", + visibility: "private", + } as CreateAgentInput); + createdAgentIds.push(created.id); + + const duplicate = await store.duplicate(owner, created.id); + createdAgentIds.push(duplicate.id); + + expect(duplicate.endpoint).toBe(managedAgentAgUiUrl.toString()); + }); + + test("does not carry the source's stored key onto the copy", async () => { + const owner = await createUser(); + const source = await createProfileFixture({ + owner, + configuration: { + endpoint: "https://hosted.example.test/ag-ui", + auth: { header: "Authorization", credentialId: "credential-1" }, + }, + }); + expect((await profileById(owner, source.agentId)).hasAuth).toBe(true); + + const duplicate = await store.duplicate(owner, source.agentId); + createdAgentIds.push(duplicate.id); + + expect(duplicate.hasAuth).toBe(false); + const [row] = await database + .select({ configuration: agents.configuration }) + .from(agents) + .where(eq(agents.id, duplicate.id)); + expect(authFromConfiguration(row?.configuration)).toBeNull(); + }); + + test("duplicates a coworker with its own endpoint on a deployment with no managed Bot", async () => { + const unmanagedStore = createAgentProfileStore(database, undefined); + const owner = await createUser(); + const source = await createProfileFixture({ + owner, + configuration: { endpoint: "https://hosted.example.test/ag-ui" }, + }); + + const duplicate = await unmanagedStore.duplicate(owner, source.agentId); + createdAgentIds.push(duplicate.id); + + expect(duplicate.endpoint).toBe("https://hosted.example.test/ag-ui"); + }); + + test("refuses to duplicate an endpoint-less coworker with no managed Bot to fall back to", async () => { + const unmanagedStore = createAgentProfileStore(database, undefined); + const owner = await createUser(); + const source = await createProfileFixture({ + owner, + configuration: {}, + }); + + await expect( + unmanagedStore.duplicate(owner, source.agentId), + ).rejects.toBeInstanceOf(ManagedAgentUnavailableError); + }); + test("soft deletes a profile from reads and lists while retaining its raw rows", async () => { const owner = await createUser(); const source = await createProfileFixture({ owner, visibility: "public" });