diff --git a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts index c5e3445bf7f..c317c2c5723 100644 --- a/server/src/__tests__/teams-catalog-install-no-overrides.test.ts +++ b/server/src/__tests__/teams-catalog-install-no-overrides.test.ts @@ -149,4 +149,35 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides .map((row) => row.adapterType); expect(otherAdapters).toEqual(["paperclip_runner", "paperclip_runner"]); }); + it("derived catalog installs selected native runtime without caller overrides", async () => { + const repositoryRoot = path.resolve(import.meta.dirname, "../../.."); + const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), "team-runtime-derived-pg-")); + const oldDir = process.env.PAPERCLIP_TEAMS_CATALOG_DIR, oldDefault = process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE; + try { + const helper = await import("../../../tests/runner-e2e/chat-catalog-domains/external-team-runtime-fixture.js"); + Object.assign(process.env, await helper.teamRuntimeCatalogServerEnvironment({ repositoryRoot, temporaryRoot, executionIds: ["chat-features.runner-opencode.local.teams.install-team"] })); + const companyId = await seedEmptyCompany(); + const { secretService } = await import("../services/secrets.js"); + const account = await secretService(db).create(companyId, { name: "Derived package account", provider: "local_encrypted", value: "synthetic-model-free-account" }); + const manifest = JSON.parse(await fs.readFile(path.join(repositoryRoot, "packages/teams-catalog/generated/catalog.json"), "utf8")); + const team = manifest.teams.find((row: {slug: string}) => row.slug === "product-engineering"); + const config = { provider: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", timeoutSec: 0, lifecycleMode: "per_turn", opencodePermissionMode: "allow", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: account.id, version: "latest" } } }; + const derived = await helper.normalizeTeamRuntimeCatalog({ repositoryRoot, temporaryRoot, team, adapters: Object.fromEntries(team.agentSlugs.map((slug: string) => [slug, { adapterType: "paperclip_runner", adapterConfig: config }])) }); + vi.resetModules(); + const { teamsCatalogService: actualService, getCatalogTeamOrThrow } = await import("../services/teams-catalog.js"); + expect((await getCatalogTeamOrThrow(team.id)).contentHash).toBe(derived.effectiveContentHash); + const svc = actualService(db); + const preview = await svc.previewCatalogTeamImport(companyId, team.id, {}); + for (const member of preview.portabilityPreview.manifest.agents) expect(member.adapterConfig).toMatchObject(config); + await svc.installCatalogTeam(companyId, team.id, { collisionStrategy: "rename", include: { projects: false, issues: false } }); + const saved = await db.select().from(agents).where(eq(agents.companyId, companyId)); + expect(saved).toHaveLength(3); + for (const member of saved) { expect(member.adapterType).toBe("paperclip_runner"); expect(member.adapterConfig).toMatchObject(config); } + } finally { + if (oldDir === undefined) delete process.env.PAPERCLIP_TEAMS_CATALOG_DIR; else process.env.PAPERCLIP_TEAMS_CATALOG_DIR = oldDir; + if (oldDefault === undefined) delete process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE; else process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE = oldDefault; + await fs.rm(temporaryRoot, { recursive: true, force: true }); + } + }, 30_000); + }); diff --git a/tests/runner-e2e/chat-catalog-descriptors.json b/tests/runner-e2e/chat-catalog-descriptors.json index 29c754bc795..ee2b446c782 100644 --- a/tests/runner-e2e/chat-catalog-descriptors.json +++ b/tests/runner-e2e/chat-catalog-descriptors.json @@ -6024,6 +6024,9 @@ "sourceFiles": [ "chat-catalog-domains/external-team-install.ts", "chat-catalog-domains/external-team-conditional.ts", + "chat-catalog-domains/external-team-runtime-fixture.ts", + "server.ts", + "../../packages/teams-catalog/src/catalog-builder.ts", "../../server/src/services/teams-catalog.ts", "../../server/src/services/company-skills.ts", "../../server/src/services/company-portability.ts", @@ -6034,7 +6037,7 @@ "chat-flow.ts", "chat-scenarios/teams.yaml" ], - "oracleVersion": "paperclip.chat-catalog.team-install.v6", + "oracleVersion": "paperclip.chat-catalog.team-install.v7", "expectedRunCount": 2, "expectedHumanMessageCount": 2, "turnTimeoutMs": 120000, diff --git a/tests/runner-e2e/chat-catalog-domains/external-team-install.ts b/tests/runner-e2e/chat-catalog-domains/external-team-install.ts index d80d2c6c3f3..00d3a7427f5 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-team-install.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-team-install.ts @@ -1,3 +1,5 @@ +import path from "node:path"; +import { normalizeTeamRuntimeCatalog } from "./external-team-runtime-fixture.js"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; import { CHAT_OPENCODE_MODEL, CHAT_OPENCODE_PROFILE } from "../chat-runtime-profile.js"; import type { SecretReference } from "../types.js"; @@ -269,7 +271,7 @@ export const teamInstallExternalCases = [ async (ctx) => { const teams = await ctx.input.api.get("/api/teams/catalog"); - const team = teams.find( + let team = teams.find( (row) => row.slug === "product-engineering" && row.compatibility === "compatible", @@ -300,6 +302,12 @@ export const teamInstallExternalCases = [ ); const selectedRuntime = selectedTeamInstallRuntime(ctx, actor); const adapterOverrides = teamInstallOverrides(team, selectedRuntime); + const temporaryRoot = process.env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT; + if (!temporaryRoot) throw new Error("Team runtime fixture requires its disposable attempt root"); + const catalogRuntime = await normalizeTeamRuntimeCatalog({ temporaryRoot, repositoryRoot: path.resolve(import.meta.dirname, "../../.."), team, adapters: adapterOverrides }); + team = catalogRuntime.team; + const served = await ctx.input.api.get("/api/teams/catalog"); + if (served.find(row => row.id === team.id)?.contentHash !== catalogRuntime.effectiveContentHash) throw new Error("Server did not serve the derived runtime catalog"); const conditional = await prepareTeamConditions(ctx, team); const preview = conditional.conditionalPreview; if (preview.errors.length || preview.portabilityPreview.errors.length) @@ -314,7 +322,7 @@ export const teamInstallExternalCases = [ await ctx.input.api.post(`${root(ctx)}/chats/${actor.id}`); await ctx.refreshIssue(); await ctx.input.api.patch(`/api/issues/${ctx.issue()!.id}`, { - description: `The accepted website team preview is ${team.name}, catalog key ${team.key}, content hash ${team.contentHash}. Install the complete selected package under the current CEO using this company's existing eligible runtime account. No team resource is installed yet. The selected runtime uses ${actor.adapterType} in environment ${ctx.input.fixtures.environment.id}. The existing eligible runtime is OpenCode/OpenRouter ${selectedRuntime.model}; its credential stays in the existing same-company secret reference. The supported installer takes these per-slug adapterOverrides for this accepted runtime selection: ${JSON.stringify(adapterOverrides)}.`, + description: `The accepted website team preview is ${team.name}, catalog key ${team.key}, content hash ${team.contentHash}. Install the complete selected package under the current CEO using this company's existing eligible runtime account. No team resource is installed yet. This disposable catalog package already selects OpenCode/OpenRouter ${selectedRuntime.model}; its credential stays in the existing same-company secret reference.`, }); const session = await ctx.input.api.get<{ user: { id: string } }>("/api/auth/get-session"); if (!session.user?.id) throw new Error("Team installer requires its actual responsible human session"); @@ -323,6 +331,7 @@ export const teamInstallExternalCases = [ humanId: session.user.id, ...conditional, selectedRuntime, + catalogRuntime, team, before, companyPrefix: ctx.input.fixtures.company.issuePrefix, @@ -337,13 +346,16 @@ export const teamInstallExternalCases = [ gradeTeamInstall, { authentication: "human-session", - oracleVersion: "paperclip.chat-catalog.team-install.v6", + oracleVersion: "paperclip.chat-catalog.team-install.v7", expectedRunCount: 2, expectedHumanMessageCount: 2, attemptTimeoutMs: 480_000, sourceFiles: [ "chat-catalog-domains/external-team-install.ts", "chat-catalog-domains/external-team-conditional.ts", + "chat-catalog-domains/external-team-runtime-fixture.ts", + "server.ts", + "../../packages/teams-catalog/src/catalog-builder.ts", "../../server/src/services/teams-catalog.ts", "../../server/src/services/company-skills.ts", "../../server/src/services/company-portability.ts", diff --git a/tests/runner-e2e/chat-catalog-domains/external-team-runtime-fixture.test.ts b/tests/runner-e2e/chat-catalog-domains/external-team-runtime-fixture.test.ts new file mode 100644 index 00000000000..f92e22101fb --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-team-runtime-fixture.test.ts @@ -0,0 +1,27 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect, it } from "vitest"; +import YAML from "yaml"; +import { normalizeTeamRuntimeCatalog, teamRuntimeCatalogRoot, teamRuntimeCatalogServerEnvironment } from "./external-team-runtime-fixture.js"; +const repositoryRoot = path.resolve(import.meta.dirname, "../../.."); +it("serves a derived runtime-only catalog while preserving shipped roles, permissions, skills and other teams", async () => { + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "team-runtime-fixture-")); + try { + expect(await teamRuntimeCatalogServerEnvironment({ executionIds: ["unrelated"], temporaryRoot, repositoryRoot })).toEqual({}); + const env = await teamRuntimeCatalogServerEnvironment({ executionIds: ["chat-features.runner-opencode.local.teams.install-team"], temporaryRoot, repositoryRoot }); + expect(env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE).toBe("paperclip_runner"); + const original = JSON.parse(await readFile(path.join(repositoryRoot, "packages/teams-catalog/generated/catalog.json"), "utf8")); + const team = original.teams.find((row: { slug: string }) => row.slug === "product-engineering"); + const adapterConfig = { provider: "opencode", model: "openrouter/deepseek/deepseek-v4.1-flash-1017", timeoutSec: 0, lifecycleMode: "per_turn", opencodePermissionMode: "allow", env: { OPENROUTER_API_KEY: { type: "secret_ref", secretId: "test-account", version: "latest" } } }; + const adapters = Object.fromEntries(team.agentSlugs.map((slug: string) => [slug, { adapterType: "paperclip_runner", adapterConfig }])); + const result = await normalizeTeamRuntimeCatalog({ repositoryRoot, temporaryRoot, team, adapters }); + expect(result.originalContentHash).toBe(team.contentHash); + expect(result.effectiveContentHash).not.toBe(team.contentHash); + const sidecar = YAML.parse(await readFile(path.join(teamRuntimeCatalogRoot(temporaryRoot), team.path, ".paperclip.yaml"), "utf8")); + expect(sidecar.agents.cto.permissions).toEqual({ canCreateAgents: true }); + for (const slug of team.agentSlugs) expect(sidecar.agents[slug].adapter).toEqual({ type: "paperclip_runner", config: adapterConfig }); + expect(result.team.requiredSkills).toEqual(team.requiredSkills); + await expect(normalizeTeamRuntimeCatalog({ repositoryRoot, temporaryRoot, team: { ...team, contentHash: "foreign" }, adapters })).rejects.toThrow("exact unmodified"); + } finally { await rm(temporaryRoot, { recursive: true, force: true }); } +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-team-runtime-fixture.ts b/tests/runner-e2e/chat-catalog-domains/external-team-runtime-fixture.ts new file mode 100644 index 00000000000..7dfaeaa33be --- /dev/null +++ b/tests/runner-e2e/chat-catalog-domains/external-team-runtime-fixture.ts @@ -0,0 +1,56 @@ +import { parseFrontmatterMarkdown } from "../../../packages/shared/src/frontmatter.js"; +import YAML from "yaml"; +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import { buildCatalogManifest } from "../../../packages/teams-catalog/src/catalog-builder.js"; +import type { CatalogTeam } from "../../../packages/shared/src/types/teams-catalog.js"; +import { reviewedChatExecutionIds } from "../chat-runtime-profile.js"; + +const selectedIds = reviewedChatExecutionIds("teams.install-team"); +export const teamRuntimeCatalogRoot = (temporaryRoot: string) => path.join(temporaryRoot, "team-runtime-catalog"); + +/** Only the disposable installer fixture uses this catalog and native default. */ +export async function teamRuntimeCatalogServerEnvironment(input: { executionIds: string[]; temporaryRoot: string; repositoryRoot: string }) { + if (!input.executionIds.some(id => selectedIds.includes(id))) return {}; + if (input.executionIds.length !== 1) throw new Error("Team runtime catalog requires its isolated execution"); + const source = path.join(input.repositoryRoot, "packages/teams-catalog"), target = teamRuntimeCatalogRoot(input.temporaryRoot); + await mkdir(target, { recursive: true }); + for (const entry of ["package.json", "catalog", "generated"]) await cp(path.join(source, entry), path.join(target, entry), { recursive: true, force: false, errorOnExist: true }); + return { PAPERCLIP_TEAMS_CATALOG_DIR: target, PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE: "paperclip_runner" }; +} + +/** Add runtime/account metadata only; all shipped team files remain byte-identical. */ +export async function normalizeTeamRuntimeCatalog(input: { temporaryRoot: string; repositoryRoot: string; team: CatalogTeam; adapters: Record }> }) { + const packageDir = teamRuntimeCatalogRoot(input.temporaryRoot); + const original = JSON.parse(await readFile(path.join(input.repositoryRoot, "packages/teams-catalog/generated/catalog.json"), "utf8")); + const source = original.teams.find((team: CatalogTeam) => team.id === input.team.id) as CatalogTeam | undefined; + if (!source || source.contentHash !== input.team.contentHash || !isDeepStrictEqual(Object.keys(input.adapters).sort(), [...source.agentSlugs].sort())) throw new Error("Team runtime fixture must start from the exact unmodified accepted package"); + for (const file of source.files) { + const shipped = await readFile(path.join(input.repositoryRoot, "packages/teams-catalog", source.path, file.path)); + const derived = await readFile(path.join(packageDir, source.path, file.path)); + if (!shipped.equals(derived)) throw new Error("Team runtime fixture changed a shipped non-runtime file"); + } + const sidecarPath = path.join(packageDir, source.path, ".paperclip.yaml"); + const originalSidecar = YAML.parse(await readFile(sidecarPath, "utf8")); + const sidecar = structuredClone(originalSidecar); + sidecar.agents ??= {}; + for (const [slug, adapter] of Object.entries(input.adapters)) sidecar.agents[slug] = { ...sidecar.agents[slug], adapter: { type: adapter.adapterType, config: adapter.adapterConfig } }; + const serialized = YAML.stringify(sidecar, { aliasDuplicateObjects: false, lineWidth: 0 }); + if (!isDeepStrictEqual(parseFrontmatterMarkdown(`---\n${serialized}---\n`).frontmatter, sidecar)) throw new Error("Runtime sidecar is not supported by the actual portability parser"); + await writeFile(sidecarPath, serialized, { mode: 0o600 }); + const restored = structuredClone(sidecar); + for (const slug of Object.keys(input.adapters)) { + delete restored.agents[slug].adapter; + if (!Object.keys(restored.agents[slug]).length) delete restored.agents[slug]; + } + if (!isDeepStrictEqual(restored, originalSidecar)) throw new Error("Team runtime fixture changed non-runtime sidecar fields"); + const skills = JSON.parse(await readFile(path.join(input.repositoryRoot, "packages/skills-catalog/generated/catalog.json"), "utf8")); + const built = await buildCatalogManifest({ packageDir, generatedAt: original.generatedAt, catalogSkills: skills.skills }); + if (built.errors.length) throw new Error(`Derived runtime catalog is invalid: ${built.errors.join("; ")}`); + const effective = built.manifest.teams.find(team => team.id === source.id)!; + const withoutRuntime = (team: CatalogTeam) => ({ ...team, contentHash: "", fileCount: 0, files: team.files.filter(file => file.path !== ".paperclip.yaml") }); + if (!isDeepStrictEqual(withoutRuntime(effective), withoutRuntime(source)) || effective.contentHash === source.contentHash || built.manifest.teams.some(team => team.id !== source.id && !isDeepStrictEqual(team, original.teams.find((row: CatalogTeam) => row.id === team.id)))) throw new Error("Derived catalog changed non-runtime team metadata"); + await writeFile(path.join(packageDir, "generated/catalog.json"), JSON.stringify(built.manifest, null, 2) + "\n"); + return { originalContentHash: source.contentHash, effectiveContentHash: effective.contentHash, originalFilesUnchanged: true, team: effective }; +} diff --git a/tests/runner-e2e/server.ts b/tests/runner-e2e/server.ts index c46f047a14b..38522287417 100644 --- a/tests/runner-e2e/server.ts +++ b/tests/runner-e2e/server.ts @@ -1,3 +1,4 @@ +import { teamRuntimeCatalogServerEnvironment } from "./chat-catalog-domains/external-team-runtime-fixture.js"; import { stopSetupHealthIfRequested } from "./chat-setup-health.js"; import { publishInvitationSourceProof, invitationSourceProofPath } from "./chat-catalog-domains/identity-invite-source-proof.js"; import { readSelectedVaultServerEnvironment } from "./chat-vault-private-server.js"; @@ -72,6 +73,7 @@ const publicOriginReceipt = process.env[PUBLIC_ORIGIN_RECEIPT_ENV] ? await readV if (publicOriginReceipt && process.env.PAPERCLIP_RUNNER_E2E_PUBLIC_MCP !== "1") throw new Error("Owned public origin requires the exact authenticated selected case"); for (const key of [PUBLIC_ORIGIN_RECEIPT_ENV, PUBLIC_ORIGIN_CONTROL_ENV, "PAPERCLIP_PUBLIC_URL", "PAPERCLIP_AUTH_PUBLIC_BASE_URL", "PAPERCLIP_MANAGED_RUNTIME_PUBLIC_URL", "PAPERCLIP_ALLOWED_HOSTNAMES"]) delete archiveEnvironment.source[key]; const vaultEnvironment = await readSelectedVaultServerEnvironment({ executionIds, temporaryRoot, backendOrigin: `http://127.0.0.1:${port}`, env: process.env }); +const teamCatalogEnvironment = await teamRuntimeCatalogServerEnvironment({ executionIds, temporaryRoot, repositoryRoot }); const serverEnvironment = buildPaperclipServerEnvironment(installedRelease ? installedReleaseEnvironment(archiveEnvironment.source, repositoryRoot, path.join(temporaryRoot, "provider-bin")) : archiveEnvironment.source, { @@ -117,6 +119,7 @@ const serverEnvironment = buildPaperclipServerEnvironment(installedRelease // to Playwright in this harness, so never create a developer desktop tab. PAPERCLIP_OPEN_ON_LISTEN: "false", ...vaultEnvironment, + ...teamCatalogEnvironment, }); assertIsolatedServerEnvironment(serverEnvironment, { temporaryRoot,