feat(admin): copy an agent between orgs with its connections and credentials - #5238
Open
viktormarinho wants to merge 1 commit into
Open
feat(admin): copy an agent between orgs with its connections and credentials#5238viktormarinho wants to merge 1 commit into
viktormarinho wants to merge 1 commit into
Conversation
…entials Adds a deployment-admin surface to copy an agent (Virtual MCP) from one organization into another, carrying its system prompt, the connections it aggregates, and those connections' credentials. Doing this by hand meant re-creating every connection, re-doing every OAuth dance, and re-typing the system prompt — with no way to verify the result matched. This makes it one operation with a report. What travels: - the agent row (title, description, icon, metadata.instructions and the rest of its metadata, rewritten for the target org) - every non-virtual connection it aggregates, WITH credentials — storage decrypts on read and re-encrypts on write, so plaintext only exists in memory during the copy - the vault secrets its sandbox runtime env references - its kickstart prompts (org-fs) and per-plugin configs Built-in org-scoped connections (`<org>_self`, `_registry`, `_dev-assets`, ...) are REMAPPED to the target org's own instance rather than copied — copying `<org>_self` would hand the target a live pointer at the source org's management API. What deliberately does not travel is reported to the operator rather than dropped silently, since a copy is routinely partial: nested agents, knowledge files, sandbox handles, the dev/live pairing, a linked site's tenancy, and any metadata entry whose connection or secret had no counterpart. `pinned` is forced false (pinning is an org-admin choice) and an existing same-name secret in the target is reused, never overwritten. Copies are audited like impersonation — a durable stdout line plus a PostHog event, attributed to the real admin rather than an impersonated identity. Refactors: - `routes/admin.ts` -> `routes/admin/index.ts` so the surface can hold more than one module - extracts `buildOrgFs(ctx, orgId)` from `buildPublicOrgFs`, which was the same four lines with a hardcoded org Tests: unit coverage for the metadata rewrite (the pure, branch-heavy part) and e2e coverage that proves credentials actually arrive — the target org reads its own copy of a connection back over MCP and gets the original token verbatim — plus the `_self` remap, the skip reporting, input validation, the Studio Pack refusal, non-admin rejection, and one test that drives the whole flow in the browser. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a deployment-admin surface (Admin Dashboard → Copy agent) to copy an agent from one organization into another, carrying its system prompt, the connections it aggregates, and those connections' credentials.
Today this is a manual slog: re-create every connection, re-do every OAuth dance, re-type the system prompt — with no way to verify the result matches the original. Now it's one operation that reports exactly what landed and what didn't.
POST /api/_admin/agents/:agentId/copy { targetOrgId }andGET /api/_admin/orgs/:orgId/agents(the picker), both behind the existingrequireDeploymentAdmin+ Origin-check middleware.Design decisions
Credentials ride the storage layer, not the vault.
connections.findByIdalready decrypts andcreateNewre-encrypts, so passing the entity through copies the token / OAuth config / configuration state / STDIO env vars without this feature touchingctx.vaultfor connections at all. Plaintext exists only in memory for the duration of the call. New ids are minted — reusing the source id would collide on the shared primary key.Built-in org-scoped connections are remapped, not copied.
<org>_self,_registry,_community-registry,_dev-assets,_commerce-discovery,site-diagnostics_<org>all embed the org id. Copying<org>_selfwould hand the target org a live, stale pointer at the source org's full management API. They're remapped to the target's own instance, and if the target has no counterpart that's reported rather than left dangling.Partial copies are reported, never silent. A copy is routinely incomplete, and an agent that looks configured but resolves to nothing is worse than one that's visibly missing pieces. Everything below comes back in a
skipped: string[]rendered as a warning panel:sandboxMapliveAgentIdsiteSlug,productionUrlknowledgegithubRepo.connectionId+installationIdsubAgentsentry / env var whose connection or secret had no counterpartMetadata is rewritten by an allow-through / drop-known-bad rule, not an allowlist.
metadatais a.loose()schema that gains fields regularly, so unknown keys pass through untouched (fidelity is the point) and only the org-bound ones are named explicitly. The result isVirtualMCPCreateDataSchema.parsed rather than cast — a schema violation fails loudly at the boundary instead of landing a malformed row.Two semantic traps handled deliberately:
metadata.ui.layout.defaultMainView.idis a connection id only for a pinned-view default (ext-app/ext-appswith atoolName); otherwise it names a declared tab. Remapping it unconditionally would corrupt every non-pinned default.subAgentsresult is preserved, not deleted.[]means "itself only";null/absent means "every active target in the org". Deleting the field to be helpful would silently widen what the copy may delegate to. A visibly-broken allowlist beats a silently-widened one, and the report says so.Secrets: reuse over overwrite; user-scope becomes org-scope.
secretsis uniquely indexed on(organization_id, lower(name))for org scope, so a name that already exists in the target is reused — clobbering a customer's live credential to make a copy tidy isn't a trade worth making. Reading the source row goes directly toctx.db, bypassingSecretStorage's per-caller visibility check (which would reject another user's user-scoped secret); that's the intended privilege of a surface that already grants org membership and impersonates users. A user-scoped secret necessarily becomes org-scoped in the target — the admin usually isn't a member there, so a user-scoped copy would be invisible to the people who need it and the agent wouldn't boot. Reported.pinnedis forced false. Pinning to the sidebar is gated on org-admin (requireOrgAdminForPinnedField); a copy shouldn't quietly plant itself in a customer's sidebar. Reported.Audited like impersonation. Copying credentials across tenants is the highest-blast-radius action on this surface after impersonation, so it gets the same treatment: a durable stdout line (the one sink present in every self-host) plus a PostHog event, attributed to the real admin via
session.impersonatedBy, not an impersonated identity.Not transactional. Connections and secrets are separate writes; a mid-way failure leaves them as orphans in the target org for an admin to delete. Threading one Kysely transaction through five storage classes and two object stores isn't worth it for a hand-triggered operation.
Refactors
routes/admin.ts→routes/admin/index.tsso the surface can hold more than one module (imports use@/, so nothing else moved).buildOrgFs(ctx, orgId)frombuildPublicOrgFs, which was the same four lines with a hardcoded org. The copy needs twoOrgFsinstances at once (source read, target write), so the S3-or-dev-storage fallback now lives in one place.Testing
Unit (
bun test) — 11 cases over the metadata rewrite, the pure branch-heavy part: prompt and unknown keys survive; each org-bound key is dropped and reported; absent/null keys are not reported; connection ids remap across pinned views, both home-tile shapes, layout tabs andgithubRepo; unresolvable entries are dropped rather than pointed at nothing; secret-backed env vars remap while literals are untouched; thedefaultMainViewdistinction in both directions;subAgentsnarrowing and the empty-result warning.E2E (
packages/e2e) — 6 specs, all passing locally:_selfremapped (not copied) while the real upstream is copied; prompt intact; connection list contains target-org ids only; source org can't read the copy; both rows exist under their own org.The three panes are labelled
regions with labelledradiogroups — needed to disambiguate two identical org pickers for the test driver, and correct a11y regardless.bun run check,bun run lint(0 errors),bun run knip,bun run fmt:checkall clean.Note on local flakes:
deployment-admin.spec.tsfails locally at test 4 with a signup429; verified identical on a stashed clean tree, so it's a local rate-limit condition, not this branch. Oneapps/apiS3 integration test fails withECONNREFUSED(no local minio). My own spec mints its two tenant orgs once per describe rather than per test specifically to stay under that signup limiter — noted in the file header as a deliberate, serial-mode-safe exception to the per-test-tenant doctrine.Follow-ups
metadata.githubRepo(not in the aggregation list) are currently never copied, so a copied code agent loses its repo credentials. Intentional for v1 — a GitHub App install token is scoped to the source org's repos — but worth revisiting with a provision-in-target flow.🤖 Generated with Claude Code
Summary by cubic
Adds an admin tool to copy an agent between organizations, including its system prompt, connections, and credentials, in one step. Replaces manual re-setup and shows a clear report of what was copied or skipped.
New Features
POST /api/_admin/agents/:agentId/copy { targetOrgId }andGET /api/_admin/orgs/:orgId/agents, behind the existing deployment-admin + Origin checks.<org>_self,_registry,_community-registry,_dev-assets,_commerce-discovery,site-diagnostics_<org>).skipped[]report. Forcespinned=false.Refactors
routes/admin.tsmoved toroutes/admin/index.ts.buildOrgFs(ctx, orgId)and updated public OrgFs builder to use it; added/_admin/copy-agentroute and minimal UI/i18n plumbing.Written for commit f7d44ab. Summary will update on new commits.