Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions server/src/agents/profile-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,15 +437,20 @@ 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();
await transaction.insert(agents).values({
id: duplicateId,
name: source.name,
type: "remote_ag_ui",
configuration: managedConfiguration,
configuration,
});
await transaction.insert(agentProfiles).values({
agentId: duplicateId,
Expand Down
80 changes: 80 additions & 0 deletions server/tests/agent-profile-store.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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" });
Expand Down