Skip to content

feat(admin): copy an agent between orgs with its connections and credentials - #5238

Open
viktormarinho wants to merge 1 commit into
mainfrom
viktormarinho/admin-copy-agent
Open

feat(admin): copy an agent between orgs with its connections and credentials#5238
viktormarinho wants to merge 1 commit into
mainfrom
viktormarinho/admin-copy-agent

Conversation

@viktormarinho

@viktormarinho viktormarinho commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

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 } and GET /api/_admin/orgs/:orgId/agents (the picker), both behind the existing requireDeploymentAdmin + Origin-check middleware.

Design decisions

Credentials ride the storage layer, not the vault. connections.findById already decrypts and createNew re-encrypts, so passing the entity through copies the token / OAuth config / configuration state / STDIO env vars without this feature touching ctx.vault for 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>_self would 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:

Not copied Why
sandboxMap handles/preview URLs belong to the source org's running sandboxes
liveAgentId the dev/live pair only exists in the source org
siteSlug, productionUrl the linked site's storage tenancy is the source org's
knowledge files live in the source org's Library (not copied in v1)
nested agents (VIRTUAL connections) copy them separately and re-add
githubRepo.connectionId + installationId a GitHub App install token is scoped to the source org's repos; the repo falls back to public-clone mode
any pinned view / home tile / layout tab / subAgents entry / env var whose connection or secret had no counterpart the individual entry is dropped, the rest survives

Metadata is rewritten by an allow-through / drop-known-bad rule, not an allowlist. metadata is 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 is VirtualMCPCreateDataSchema.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.id is a connection id only for a pinned-view default (ext-app/ext-apps with a toolName); otherwise it names a declared tab. Remapping it unconditionally would corrupt every non-pinned default.
  • An empty subAgents result 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. secrets is 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 to ctx.db, bypassing SecretStorage'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.

pinned is 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.tsroutes/admin/index.ts so the surface can hold more than one module (imports use @/, so nothing else moved).
  • Extracted buildOrgFs(ctx, orgId) from buildPublicOrgFs, which was the same four lines with a hardcoded org. The copy needs two OrgFs instances 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 and githubRepo; unresolvable entries are dropped rather than pointed at nothing; secret-backed env vars remap while literals are untouched; the defaultMainView distinction in both directions; subAgents narrowing and the empty-result warning.

E2E (packages/e2e) — 6 specs, all passing locally:

  • credentials actually arrive — the target org reads its own copy back over MCP and gets the original token verbatim. Asserting the ciphertext in Postgres couldn't prove this (random IV), and the suite can't import the vault, so the assertion goes through the same path the copied agent will use.
  • _self remapped (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.
  • skip reporting for a nested agent + org-bound metadata, and the dropped keys are really absent from the copy.
  • input validation (missing target 400, unknown agent/org 404, same org 400), Studio Pack refusal + absence from the picker, non-admin rejection on both routes.
  • one test drives the whole flow in the browser and then confirms via the API that the agent really landed in the target org.

The three panes are labelled regions with labelled radiogroups — 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:check all clean.

Note on local flakes: deployment-admin.spec.ts fails locally at test 4 with a signup 429; verified identical on a stashed clean tree, so it's a local rate-limit condition, not this branch. One apps/api S3 integration test fails with ECONNREFUSED (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

  • Knowledge files (org-fs subtree copy across tenants).
  • GitHub connections referenced only from 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.
  • Nested agents (recursive copy with cycle detection).

🤖 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

    • Admin Dashboard page “Copy agent” to pick source org → agent → target org; Studio Pack agents are excluded.
    • API: POST /api/_admin/agents/:agentId/copy { targetOrgId } and GET /api/_admin/orgs/:orgId/agents, behind the existing deployment-admin + Origin checks.
    • Copies non-virtual connections with credentials (decrypt on read, re-encrypt on write). Built-in org-scoped connections are remapped to the target org (<org>_self, _registry, _community-registry, _dev-assets, _commerce-discovery, site-diagnostics_<org>).
    • Copies referenced vault secrets: reuses existing same-name secrets in the target; otherwise copies and converts user-scoped to org-scoped (reported).
    • Rewrites metadata to remap/drop org-bound fields; returns a skipped[] report. Forces pinned=false.
    • Copies kickstart prompts and per‑plugin configs. Does not copy nested agents, knowledge files, sandbox handles, dev/live linkage, or other org-bound fields (reported). Audited and tracked (stdout + PostHog). Not transactional by design.
    • Tests: unit coverage for metadata remap and E2E proving credentials arrive intact, remaps work, validation and auth enforced, and full browser flow.
  • Refactors

    • routes/admin.ts moved to routes/admin/index.ts.
    • Extracted buildOrgFs(ctx, orgId) and updated public OrgFs builder to use it; added /_admin/copy-agent route and minimal UI/i18n plumbing.

Written for commit f7d44ab. Summary will update on new commits.

Review in cubic

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant