Accept team catalog secret values

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 03:00:04 +00:00
1 parent 3da1c6f3d7
commit 30dc2bbc60
12 files changed
+678 -38

No files matched your search

@@ -294,6 +294,7 @@ export interface CompanyPortabilityAdapterOverride {
export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest {
adapterOverrides?: Record<string, CompanyPortabilityAdapterOverride>;
secretValues?: Record<string, string>;
}
export interface CompanyPortabilityImportResult {
@@ -144,6 +144,7 @@ export interface CatalogTeamImportOptions {
export interface CatalogTeamInstallOptions extends CatalogTeamImportOptions {
adapterOverrides?: Record<string, CompanyPortabilityAdapterOverride>;
secretValues?: Record<string, string>;
}
export type CatalogTeamSkillPreparationAction =
@@ -256,6 +256,7 @@ export const portabilityAdapterOverrideSchema = z.object({
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema.extend({
adapterOverrides: z.record(z.string().min(1), portabilityAdapterOverrideSchema).optional(),
secretValues: z.record(z.string().min(1), z.string()).optional(),
});
export type CompanyPortabilityImport = z.infer<typeof companyPortabilityImportSchema>;
@@ -146,6 +146,7 @@ export const catalogTeamPreviewSchema = z.object({
export const catalogTeamInstallSchema = catalogTeamPreviewSchema.extend({
adapterOverrides: z.record(z.string().min(1), portabilityAdapterOverrideSchema).optional(),
secretValues: z.record(z.string().min(1), z.string()).optional(),
});
export const catalogTeamSkillPreparationSchema = z.object({
@@ -63,6 +63,7 @@ const assetSvc = {
};
const secretSvc = {
create: vi.fn(async () => ({ id: "secret-created" })),
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
normalizeEnvBindingsForPersistence: vi.fn(async (_companyId: string, env: Record<string, unknown>) => env),
syncEnvBindingsForTarget: vi.fn(async () => []),
@@ -131,6 +132,7 @@ describe("company portability", () => {
beforeEach(() => {
vi.clearAllMocks();
secretSvc.create.mockResolvedValue({ id: "secret-created" });
secretSvc.normalizeAdapterConfigForPersistence.mockImplementation(async (_companyId, config) => config);
secretSvc.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
secretSvc.syncEnvBindingsForTarget.mockResolvedValue([]);
@@ -1392,6 +1394,113 @@ describe("company portability", () => {
]);
});
it("materializes required agent env inputs from import secretValues as company secrets", async () => {
const portability = companyPortabilityService({} as any);
agentSvc.list.mockResolvedValue([]);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-imported",
name: input.name,
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
status: input.status,
}));
await portability.importBundle({
source: {
type: "inline",
files: {
"COMPANY.md": [
"---",
"name: Import",
"includes:",
" - agents/coder/AGENTS.md",
"---",
"",
].join("\n"),
"agents/coder/AGENTS.md": [
"---",
"name: Coder",
"slug: coder",
"kind: agent",
"---",
"",
"# Coder",
"",
].join("\n"),
".paperclip.yaml": [
"schema: paperclip/v1",
"agents:",
" coder:",
" adapter:",
" type: codex_local",
" config: {}",
" inputs:",
" env:",
" OPENAI_API_KEY:",
" kind: secret",
" requirement: required",
"",
].join("\n"),
},
},
include: {
company: false,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "existing_company",
companyId: "company-1",
},
collisionStrategy: "rename",
secretValues: {
"agent:coder:OPENAI_API_KEY": "sk-imported",
},
}, "user-1");
expect(secretSvc.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
provider: "local_encrypted",
value: "sk-imported",
description: expect.stringContaining("OPENAI_API_KEY"),
}),
{ userId: "user-1", agentId: null },
);
expect(secretSvc.normalizeAdapterConfigForPersistence).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
env: {
OPENAI_API_KEY: {
type: "secret_ref",
secretId: "secret-created",
version: "latest",
},
},
}),
{ strictMode: false },
);
expect(agentSvc.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
adapterConfig: expect.objectContaining({
env: {
OPENAI_API_KEY: {
type: "secret_ref",
secretId: "secret-created",
version: "latest",
},
},
}),
}));
expect(secretSvc.syncEnvBindingsForTarget).toHaveBeenCalledWith(
"company-1",
{ targetType: "agent", targetId: "agent-imported" },
expect.objectContaining({
OPENAI_API_KEY: expect.objectContaining({ secretId: "secret-created" }),
}),
);
});
it("exports project env as portable inputs without concrete values", async () => {
const portability = companyPortabilityService({} as any);
@@ -204,7 +204,10 @@ describe("teams catalog routes", () => {
const res = await request(app)
.post(`/api/companies/${companyId}/teams/catalog/product-engineering/install`)
.send({ collisionStrategy: "rename" });
.send({
collisionStrategy: "rename",
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" },
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockTeamsCatalogService.installCatalogTeam).toHaveBeenCalledWith(
@@ -212,6 +215,7 @@ describe("teams catalog routes", () => {
"product-engineering",
expect.objectContaining({
collisionStrategy: "rename",
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" },
actor: expect.objectContaining({
actorType: "agent",
actorId: "agent-1",
@@ -112,6 +112,22 @@ describe("teamsCatalogService", () => {
);
});
it("passes install secretValues through to company portability import", async () => {
const svc = teamsCatalogService({} as any);
await svc.installCatalogTeam("company-1", "core-exec-team", {
secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" },
});
expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(
expect.objectContaining({
secretValues: { "agent:ceo:OPENAI_API_KEY": "sk-imported" },
}),
null,
{ mode: "agent_safe", sourceCompanyId: "company-1" },
);
});
it("classifies unresolved and unsafe external skill requirements as blocked", () => {
const fakeTeam: CatalogTeam = {
id: "paperclipai:optional:test:unsafe",
+147 -1
View File
@@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import { execFile } from "node:child_process";
import path from "node:path";
@@ -70,6 +70,7 @@ import { issueService } from "./issues.js";
import { projectService } from "./projects.js";
import { routineService } from "./routines.js";
import { secretService } from "./secrets.js";
import { getConfiguredSecretProvider } from "../secrets/configured-provider.js";
import {
PORTABLE_CATALOG_PROVENANCE_STRING_KEYS,
readCatalogStringList,
@@ -2412,6 +2413,63 @@ function buildEnvInputMap(inputs: CompanyPortabilityEnvInput[]) {
return env;
}
function envInputScopedKey(input: CompanyPortabilityEnvInput) {
if (input.agentSlug) return `agent:${input.agentSlug}:${input.key}`;
if (input.projectSlug) return `project:${input.projectSlug}:${input.key}`;
return input.key;
}
function envInputValue(input: CompanyPortabilityEnvInput, values: Record<string, string> | null | undefined) {
if (!values) return null;
const scopedKey = envInputScopedKey(input);
if (Object.prototype.hasOwnProperty.call(values, scopedKey)) return values[scopedKey];
if (Object.prototype.hasOwnProperty.call(values, input.key)) return values[input.key];
return null;
}
function importSecretLabel(input: CompanyPortabilityEnvInput) {
const scope = input.agentSlug
? `agent ${input.agentSlug}`
: input.projectSlug
? `project ${input.projectSlug}`
: "company import";
return `${scope} ${input.key}`;
}
function importSecretKey(input: CompanyPortabilityEnvInput, suffix: string) {
const scope = input.agentSlug
? `agent-${input.agentSlug}`
: input.projectSlug
? `project-${input.projectSlug}`
: "company";
return `import-${scope}-${input.key}-${suffix}`;
}
function writeManifestEnvBinding(
manifest: CompanyPortabilityManifest,
input: CompanyPortabilityEnvInput,
binding: AgentEnvConfig[string],
) {
if (input.agentSlug) {
const agent = manifest.agents.find((entry) => entry.slug === input.agentSlug);
if (!agent) return;
const adapterConfig = isPlainRecord(agent.adapterConfig) ? agent.adapterConfig : {};
const env = isPlainRecord(adapterConfig.env) ? { ...adapterConfig.env } : {};
env[input.key] = binding;
agent.adapterConfig = { ...adapterConfig, env };
return;
}
if (input.projectSlug) {
const project = manifest.projects.find((entry) => entry.slug === input.projectSlug);
if (!project) return;
project.env = {
...(project.env ?? {}),
[input.key]: binding,
};
}
}
function readCompanyApprovalDefault(_frontmatter: Record<string, unknown>) {
return false;
}
@@ -2925,6 +2983,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const companySkills = companySkillService(db);
const secrets = secretService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
const defaultSecretProvider = getConfiguredSecretProvider();
function assertKnownImportAdapterType(type: string | null | undefined): string {
const adapterType = typeof type === "string" ? type.trim() : "";
@@ -2983,6 +3042,56 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
};
}
async function materializeImportEnvInputValues(
companyId: string,
manifest: CompanyPortabilityManifest,
envInputs: CompanyPortabilityEnvInput[],
secretValues: Record<string, string> | null | undefined,
actorUserId: string | null | undefined,
) {
if (envInputs.length === 0) return;
const missingRequired = envInputs.filter((input) => {
if (input.requirement !== "required") return false;
const value = envInputValue(input, secretValues);
return value === null || value.trim().length === 0;
});
if (missingRequired.length > 0) {
throw unprocessable(`Required environment values are missing: ${missingRequired.map(envInputScopedKey).join(", ")}`);
}
for (const input of envInputs) {
const value = envInputValue(input, secretValues);
if (value === null || value.trim().length === 0) continue;
if (input.kind === "plain") {
writeManifestEnvBinding(manifest, input, {
type: "plain",
value,
});
continue;
}
const suffix = randomUUID().slice(0, 8);
const label = importSecretLabel(input);
const secret = await secrets.create(
companyId,
{
name: `Imported ${label} ${suffix}`,
key: importSecretKey(input, suffix),
provider: defaultSecretProvider,
value,
description: input.description ?? `Imported ${input.key} for ${label}.`,
},
{ userId: actorUserId ?? null, agentId: null },
);
writeManifestEnvBinding(manifest, input, {
type: "secret_ref",
secretId: secret.id,
version: "latest",
});
}
}
function resolveImportedAssigneeAgentId(
assigneeSlug: string | null | undefined,
importedSlugToAgentId: Map<string, string>,
@@ -4235,6 +4344,33 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (!targetCompany) throw notFound("Target company not found");
const importedAgentEnvSlugs = new Set(
plan.preview.plan.agentPlans
.filter((entry) => entry.action !== "skip")
.map((entry) => entry.slug),
);
const importedProjectEnvSlugs = new Set(
plan.preview.plan.projectPlans
.filter((entry) => entry.action !== "skip")
.map((entry) => entry.slug),
);
const importEnvInputs = (sourceManifest.envInputs ?? []).filter((inputValue) => {
if (inputValue.agentSlug) {
return include.agents && importedAgentEnvSlugs.has(inputValue.agentSlug);
}
if (inputValue.projectSlug) {
return include.projects && importedProjectEnvSlugs.has(inputValue.projectSlug);
}
return true;
});
await materializeImportEnvInputValues(
targetCompany.id,
sourceManifest,
importEnvInputs,
input.secretValues,
actorUserId,
);
if (include.company) {
const logoPath = sourceManifest.company?.logoPath ?? null;
if (!logoPath) {
@@ -4412,6 +4548,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null);
await secrets.syncEnvBindingsForTarget?.(
targetCompany.id,
{ targetType: "agent", targetId: updated.id },
isPlainRecord(updated.adapterConfig) ? updated.adapterConfig.env : undefined,
);
importedSlugToAgentId.set(planAgent.slug, updated.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
resultAgents.push({
@@ -4448,6 +4589,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
agentStatusById.set(created.id, created.status ?? createdStatus);
await secrets.syncEnvBindingsForTarget?.(
targetCompany.id,
{ targetType: "agent", targetId: created.id },
isPlainRecord(created.adapterConfig) ? created.adapterConfig.env : undefined,
);
importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({
+2
View File
@@ -61,6 +61,7 @@ export interface CatalogTeamImportOptions {
nameOverrides?: Record<string, string>;
selectedFiles?: string[];
adapterOverrides?: CompanyPortabilityImport["adapterOverrides"];
secretValues?: CompanyPortabilityImport["secretValues"];
sourcePolicy?: CatalogTeamSourcePolicy;
actor?: CatalogTeamActorContext | null;
}
@@ -780,6 +781,7 @@ export function teamsCatalogService(db: Db) {
const importInput: CompanyPortabilityImport = {
...buildPortabilityInput(companyId, prepared.source, options),
adapterOverrides: options.adapterOverrides,
secretValues: options.secretValues,
};
const result = await portability.importBundle(
importInput,
+1 -1
View File
@@ -20,7 +20,7 @@ export interface TeamCatalogListQuery {
*
* The preview/install bodies mirror `catalogTeamPreviewSchema` /
* `catalogTeamInstallSchema` exactly. Several richer fields the Phase F design
* imagined (per-source policy maps, secret values, skill-plan overrides) are not
* imagined (per-source policy maps, skill-plan overrides) are not
* accepted by the shipped strict schema, so the UI derives those affordances
* client-side and degrades gracefully — see TeamCatalog.tsx.
*/
+60
View File
@@ -181,6 +181,12 @@ function makePreview(): CatalogTeamImportPreviewResult {
};
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function findButton(label: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll("button")).find((b) =>
(b.textContent ?? "").includes(label),
@@ -280,6 +286,60 @@ describe("TeamCatalog install preview path", () => {
expect(document.body.textContent).toContain("Team installed");
});
it("requires and submits Step 4 secret values", async () => {
const preview = makePreview();
preview.portabilityPreview.envInputs = [
{
key: "OPENAI_API_KEY",
description: "OpenAI API key",
agentSlug: "ceo",
projectSlug: null,
kind: "secret",
requirement: "required",
defaultValue: null,
portability: "portable",
},
];
mockTeamCatalogApi.preview.mockResolvedValue(preview);
await renderPage();
const installCta = findButton("Install team");
await act(async () => {
installCta!.click();
});
await flushReact();
const footerInstall = Array.from(document.querySelectorAll("button")).filter((b) =>
(b.textContent ?? "").includes("Install team"),
).at(-1) as HTMLButtonElement;
expect(footerInstall.disabled).toBe(true);
expect(document.body.textContent).toContain("Required secrets missing: 1");
const secretInput = document.querySelector('input[aria-label="OPENAI_API_KEY value"]') as HTMLInputElement;
expect(secretInput).toBeTruthy();
await act(async () => {
setInputValue(secretInput, "sk-imported");
});
await flushReact();
expect(footerInstall.disabled).toBe(false);
await act(async () => {
footerInstall.click();
});
await flushReact();
expect(mockTeamCatalogApi.install).toHaveBeenCalledWith(
"company-1",
"team-no-deps",
expect.objectContaining({
secretValues: {
"agent:ceo:OPENAI_API_KEY": "sk-imported",
},
}),
);
});
it("requires a target manager before continuing when the team has root agents", async () => {
currentRoute = "team-with-root";
mockTeamCatalogApi.catalogList.mockResolvedValue([
+334 -35
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
import { useMutation, useQuery } from "@tanstack/react-query";
import type {
@@ -11,6 +11,8 @@ import type {
CatalogTeamSourceRef,
CatalogTeamTrustLevel,
CatalogTeamCompatibility,
CatalogTeamImportOptions,
CatalogTeamInstallOptions,
CatalogTeamInstallResult,
CompanyPortabilityAdapterOverride,
CompanyPortabilityCollisionStrategy,
@@ -83,6 +85,8 @@ import {
Cpu,
Crown,
Download,
Eye,
EyeOff,
FileText,
Filter,
Folder,
@@ -452,7 +456,7 @@ function TeamFileTree({
// and group the remaining members — graceful degradation per design §7).
// ---------------------------------------------------------------------------
function TeamHierarchyPreview({ team }: { team: CatalogTeam }) {
export function TeamHierarchyPreview({ team }: { team: CatalogTeam }) {
const roots = new Set(team.rootAgentSlugs);
const members = team.agentSlugs.filter((slug) => !roots.has(slug));
const requiresManager = team.rootAgentSlugs.length > 0;
@@ -518,7 +522,7 @@ function MetricTile({
);
}
function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[] }) {
export function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[] }) {
if (skills.length === 0) return <p className="text-sm text-muted-foreground">No required skills.</p>;
return (
<ul className="space-y-1">
@@ -547,7 +551,7 @@ function RequiredSkillsList({ skills }: { skills: CatalogTeamSkillRequirement[]
);
}
function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) {
export function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) {
if (inputs.length === 0) return null;
return (
<div className="space-y-1.5">
@@ -581,7 +585,13 @@ function EnvInputsList({ inputs }: { inputs: CatalogTeamEnvInputSummary[] }) {
);
}
function ExternalSourcesList({ sources }: { sources: CatalogTeamSourceRef[] }) {
function envInputFormKey(input: CatalogTeamEnvInputSummary) {
if (input.agentSlug) return `agent:${input.agentSlug}:${input.key}`;
if (input.projectSlug) return `project:${input.projectSlug}:${input.key}`;
return input.key;
}
export function ExternalSourcesList({ sources }: { sources: CatalogTeamSourceRef[] }) {
const external = sources.filter((s) => s.type !== "include");
const [open, setOpen] = useState(false);
if (external.length === 0) return null;
@@ -802,10 +812,18 @@ const STEP_LABELS: Record<WizardStep, string> = {
preview: "Preview",
};
function computeSteps(team: CatalogTeam): WizardStep[] {
// `simplified` is the onboarding seam (design §6): the newly created company is
// treated as a full-company-equivalent target, so the target-manager step is
// dropped and source policy is never surfaced (onboarding only offers
// markdown_only/assets teams). The skill plan collapses to a count and the
// preview is minimal, but the skill step still renders when required skills
// exist so resolution stays honest.
function computeSteps(team: CatalogTeam, simplified = false): WizardStep[] {
const steps: WizardStep[] = [];
if (team.rootAgentSlugs.length > 0) steps.push("target_manager");
if (team.sourceRefs.some((s) => sourceWarningCode(s) !== "ok")) steps.push("source_policy");
if (!simplified && team.rootAgentSlugs.length > 0) steps.push("target_manager");
if (!simplified && team.sourceRefs.some((s) => sourceWarningCode(s) !== "ok")) {
steps.push("source_policy");
}
if (team.requiredSkills.length > 0) steps.push("skill_plan");
steps.push("preview");
return steps;
@@ -813,6 +831,174 @@ function computeSteps(team: CatalogTeam): WizardStep[] {
type ApplyPhase = "form" | "applying" | "done" | "error";
// ---------------------------------------------------------------------------
// Install hook — the onboarding seam (design §6 + §12.5).
//
// `useInstallTeamCatalogEntry` owns the preview/install engine: option building,
// the two API mutations, and the resolved result/phase state. The installer
// dialog drives it with operator-entered form state; a future onboarding step
// can drive the same hook with `{ simplified: true }` and default form state to
// run the collapsed, no-target-manager flow without any UI rework.
// ---------------------------------------------------------------------------
export interface TeamInstallFormState {
targetManagerAgentId: string | null;
fullCompany: boolean;
allowExternalSources: boolean;
allowUnpinnedOptionalSources: boolean;
allowLocalPathSources: boolean;
collisionStrategy: CompanyPortabilityCollisionStrategy;
/** slug -> renamed entity name */
nameOverrides: Record<string, string>;
/** slug -> adapterType override */
adapterOverrides: Record<string, string>;
/** scoped env input key -> operator-entered value */
secretValues: Record<string, string>;
}
export const EMPTY_INSTALL_FORM: TeamInstallFormState = {
targetManagerAgentId: null,
fullCompany: false,
allowExternalSources: false,
allowUnpinnedOptionalSources: false,
allowLocalPathSources: false,
collisionStrategy: "rename",
nameOverrides: {},
adapterOverrides: {},
secretValues: {},
};
export interface UseInstallTeamCatalogEntryOptions {
companyId: string;
team: CatalogTeam;
/**
* Run the simplified onboarding flow: no target-manager step, source policy
* never surfaced, collapsed preview. The company is treated as a
* full-company-equivalent target (`targetManagerAgentId: null`).
*/
simplified?: boolean;
onInstalled?: (result: CatalogTeamInstallResult) => void;
}
export interface UseInstallTeamCatalogEntryResult {
simplified: boolean;
steps: WizardStep[];
phase: ApplyPhase;
setPhase: (phase: ApplyPhase) => void;
previewResult: CatalogTeamImportPreviewResult | null;
previewError: string | null;
isPreviewing: boolean;
installResult: CatalogTeamInstallResult | null;
applyError: string | null;
runPreview: (form: TeamInstallFormState) => void;
runInstall: (form: TeamInstallFormState) => void;
buildPreviewOptions: (form: TeamInstallFormState) => CatalogTeamImportOptions;
buildInstallOptions: (form: TeamInstallFormState) => CatalogTeamInstallOptions;
reset: () => void;
}
export function useInstallTeamCatalogEntry({
companyId,
team,
simplified = false,
onInstalled,
}: UseInstallTeamCatalogEntryOptions): UseInstallTeamCatalogEntryResult {
const [phase, setPhase] = useState<ApplyPhase>("form");
const [previewResult, setPreviewResult] = useState<CatalogTeamImportPreviewResult | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [installResult, setInstallResult] = useState<CatalogTeamInstallResult | null>(null);
const [applyError, setApplyError] = useState<string | null>(null);
const steps = useMemo(() => computeSteps(team, simplified), [team, simplified]);
// Preview body — the preview schema is strict and does NOT accept adapterOverrides.
const buildPreviewOptions = useCallback(
(form: TeamInstallFormState): CatalogTeamImportOptions => ({
targetManagerAgentId: simplified || form.fullCompany ? null : form.targetManagerAgentId,
collisionStrategy: form.collisionStrategy,
nameOverrides:
Object.keys(form.nameOverrides).length > 0 ? form.nameOverrides : undefined,
sourcePolicy: {
allowExternalSources: form.allowExternalSources,
allowUnpinnedOptionalSources: form.allowUnpinnedOptionalSources,
allowLocalPathSources: form.allowLocalPathSources,
},
}),
[simplified],
);
// Install body extends the preview body with adapterOverrides.
const buildInstallOptions = useCallback(
(form: TeamInstallFormState): CatalogTeamInstallOptions => {
const overrides: Record<string, CompanyPortabilityAdapterOverride> = {};
for (const [slug, adapterType] of Object.entries(form.adapterOverrides)) {
if (adapterType) overrides[slug] = { adapterType };
}
return {
...buildPreviewOptions(form),
adapterOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
secretValues: Object.keys(form.secretValues).length > 0 ? form.secretValues : undefined,
};
},
[buildPreviewOptions],
);
const previewMutation = useMutation({
mutationFn: (form: TeamInstallFormState) =>
teamCatalogApi.preview(companyId, team.id, buildPreviewOptions(form)),
onSuccess: (result) => {
setPreviewResult(result);
setPreviewError(null);
},
onError: (error) => {
setPreviewError(error instanceof Error ? error.message : "Failed to load install preview.");
},
});
const installMutation = useMutation({
mutationFn: (form: TeamInstallFormState) =>
teamCatalogApi.install(companyId, team.id, buildInstallOptions(form)),
onMutate: () => {
setPhase("applying");
setApplyError(null);
},
onSuccess: (result) => {
setInstallResult(result);
setPhase("done");
onInstalled?.(result);
},
onError: (error) => {
setPhase("error");
setApplyError(error instanceof Error ? error.message : "Install failed.");
},
});
const reset = useCallback(() => {
setPhase("form");
setPreviewResult(null);
setPreviewError(null);
setInstallResult(null);
setApplyError(null);
}, []);
return {
simplified,
steps,
phase,
setPhase,
previewResult,
previewError,
isPreviewing: previewMutation.isPending,
installResult,
applyError,
runPreview: previewMutation.mutate,
runInstall: installMutation.mutate,
buildPreviewOptions,
buildInstallOptions,
reset,
};
}
function TeamInstallerDialog({
team,
companyId,
@@ -847,6 +1033,8 @@ function TeamInstallerDialog({
const [nameOverrides, setNameOverrides] = useState<Record<string, string>>({});
// slug -> adapterType override (the install schema accepts adapterOverrides).
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
const [secretValues, setSecretValues] = useState<Record<string, string>>({});
const [visibleSecretKeys, setVisibleSecretKeys] = useState<Record<string, boolean>>({});
const [confirmScripts, setConfirmScripts] = useState(false);
const [previewResult, setPreviewResult] = useState<CatalogTeamImportPreviewResult | null>(null);
@@ -867,6 +1055,8 @@ function TeamInstallerDialog({
setCollisionStrategy("rename");
setNameOverrides({});
setAdapterOverrides({});
setSecretValues({});
setVisibleSecretKeys({});
setConfirmScripts(false);
setPreviewResult(null);
setPreviewError(null);
@@ -895,9 +1085,13 @@ function TeamInstallerDialog({
for (const [slug, adapterType] of Object.entries(adapterOverrides)) {
if (adapterType) overrides[slug] = { adapterType };
}
const enteredSecretValues = Object.fromEntries(
Object.entries(secretValues).filter(([, value]) => value.trim().length > 0),
);
return {
...buildPreviewOptions(),
adapterOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
secretValues: Object.keys(enteredSecretValues).length > 0 ? enteredSecretValues : undefined,
};
};
@@ -955,6 +1149,10 @@ function TeamInstallerDialog({
const hasErrors = (previewResult?.errors.length ?? 0) > 0;
const blockedCount = previewResult?.errors.length ?? 0;
const missingRequiredSecretInputs = (previewResult?.portabilityPreview.envInputs ?? [])
.filter((input) => input.requirement === "required" && (secretValues[envInputFormKey(input)] ?? "").trim().length === 0);
const missingRequiredSecretCount = missingRequiredSecretInputs.length;
const installBlocked = hasErrors || missingRequiredSecretCount > 0;
const needsScriptsConfirm = team.trustLevel === "scripts_executables";
function goNext() {
@@ -1047,6 +1245,10 @@ function TeamInstallerDialog({
onRename={(slug, name) => setNameOverrides((cur) => ({ ...cur, [slug]: name }))}
adapterOverrides={adapterOverrides}
onAdapterChange={(slug, adapterType) => setAdapterOverrides((cur) => ({ ...cur, [slug]: adapterType }))}
secretValues={secretValues}
visibleSecretKeys={visibleSecretKeys}
onSecretChange={(key, value) => setSecretValues((cur) => ({ ...cur, [key]: value }))}
onToggleSecretVisibility={(key) => setVisibleSecretKeys((cur) => ({ ...cur, [key]: !cur[key] }))}
onRetry={() => previewMutation.mutate()}
/>
)}
@@ -1088,14 +1290,19 @@ function TeamInstallerDialog({
Install blocked: {blockedCount} error{blockedCount === 1 ? "" : "s"}
</span>
)}
{currentStep === "preview" && !hasErrors && missingRequiredSecretCount > 0 && (
<span className="text-xs text-rose-600 dark:text-rose-300">
Required secrets missing: {missingRequiredSecretCount}
</span>
)}
{currentStep === "preview" ? (
needsScriptsConfirm && confirmScripts ? (
<Button variant="destructive" onClick={submitInstall} disabled={hasErrors || previewMutation.isPending}>
<Button variant="destructive" onClick={submitInstall} disabled={installBlocked || previewMutation.isPending}>
<AlertTriangle className="h-4 w-4" />
Confirm — install with executables
</Button>
) : (
<Button onClick={submitInstall} disabled={hasErrors || previewMutation.isPending || !previewResult}>
<Button onClick={submitInstall} disabled={installBlocked || previewMutation.isPending || !previewResult}>
{needsScriptsConfirm ? <AlertTriangle className="h-4 w-4" /> : <Download className="h-4 w-4" />}
{needsScriptsConfirm ? "Install with executables" : "Install team"}
</Button>
@@ -1456,6 +1663,10 @@ export function StepPreview({
onRename,
adapterOverrides,
onAdapterChange,
secretValues = {},
visibleSecretKeys = {},
onSecretChange = () => {},
onToggleSecretVisibility = () => {},
onRetry,
}: {
team: CatalogTeam;
@@ -1468,6 +1679,10 @@ export function StepPreview({
onRename: (slug: string, name: string) => void;
adapterOverrides: Record<string, string>;
onAdapterChange: (slug: string, adapterType: string) => void;
secretValues?: Record<string, string>;
visibleSecretKeys?: Record<string, boolean>;
onSecretChange?: (key: string, value: string) => void;
onToggleSecretVisibility?: (key: string) => void;
onRetry: () => void;
}) {
if (loading && !result) {
@@ -1616,33 +1831,58 @@ export function StepPreview({
</PreviewSection>
)}
{/* Env inputs (read-only — install schema does not yet accept secretValues) */}
{/* Env inputs */}
{envInputs.length > 0 && (
<PreviewSection title={`Secrets & env inputs · ${envInputs.length}`}>
{envInputs.map((input) => (
<li key={input.key} className="flex items-center gap-2 px-3 py-2 text-sm">
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-mono text-xs uppercase tracking-wide">{input.key}</span>
{input.description && <span className="text-xs text-muted-foreground">{input.description}</span>}
{input.requirement === "required" && (
<Badge variant="outline" className="text-[10px]">required</Badge>
)}
<Badge
variant="outline"
className={cn("ml-auto text-[10px]", input.kind === "secret" ? "text-rose-600 dark:text-rose-300 border-rose-500/30" : "text-muted-foreground")}
>
{input.kind}
</Badge>
</li>
))}
<li className="flex items-start gap-1.5 px-3 py-2 text-[11px] text-amber-700 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" />
<span>
The teams-catalog install API does not yet accept secret values, so these are listed
read-only and must be set on each imported agent after install. Inline entry is tracked
in the Phase E follow-up (see comment).
</span>
</li>
{envInputs.map((input) => {
const formKey = envInputFormKey(input);
const visible = Boolean(visibleSecretKeys[formKey]);
const missingRequired = input.requirement === "required" && (secretValues[formKey] ?? "").trim().length === 0;
return (
<li key={formKey} className="grid gap-2 px-3 py-2 text-sm sm:grid-cols-[minmax(0,1fr)_minmax(14rem,18rem)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<KeyRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="font-mono text-xs uppercase tracking-wide">{input.key}</span>
{input.description && <span className="truncate text-xs text-muted-foreground">{input.description}</span>}
{input.requirement === "required" && (
<Badge variant="outline" className="text-[10px]">required</Badge>
)}
<Badge
variant="outline"
className={cn("ml-auto text-[10px]", input.kind === "secret" ? "text-rose-600 dark:text-rose-300 border-rose-500/30" : "text-muted-foreground")}
>
{input.kind}
</Badge>
</div>
<div className="flex items-center gap-1.5">
<Input
type={visible ? "text" : "password"}
value={secretValues[formKey] ?? ""}
onChange={(event) => onSecretChange(formKey, event.target.value)}
placeholder={input.requirement === "required" ? "Required" : "Optional"}
aria-label={`${input.key} value`}
aria-invalid={missingRequired || undefined}
className={cn("h-8 min-w-0", missingRequired && "border-rose-500/60 focus-visible:ring-rose-500/30")}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-8 w-8"
onClick={() => onToggleSecretVisibility(formKey)}
aria-label={visible ? `Hide ${input.key}` : `Show ${input.key}`}
>
{visible ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</TooltipTrigger>
<TooltipContent>{visible ? "Hide value" : "Show value"}</TooltipContent>
</Tooltip>
</div>
</li>
);
})}
</PreviewSection>
)}
@@ -1800,6 +2040,65 @@ export function TeamRow({
);
}
// ---------------------------------------------------------------------------
// TeamCard — square tile for the onboarding "Pick a starter team" grid
// (design §6 + §12.5). Rendered in a 3-col grid of `defaultInstall` bundled
// teams. Selection is owned by the parent (the onboarding step) so the same
// tile works for the future live flow without rework.
// ---------------------------------------------------------------------------
export function TeamCard({
team,
selected = false,
onSelect,
}: {
team: CatalogTeam;
selected?: boolean;
onSelect?: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={cn(
"flex aspect-square w-full flex-col gap-2 rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected && "ring-2 ring-ring",
)}
>
<div className="flex items-start justify-between gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md border border-border bg-background">
<Users2 className="h-4 w-4 text-muted-foreground" />
</span>
{team.trustLevel !== "markdown_only" && <TrustChip level={team.trustLevel} iconOnly />}
</div>
<div className="space-y-0.5">
<h3 className="text-sm font-semibold leading-snug">{team.name}</h3>
<p className="text-xs text-muted-foreground">
{team.counts.agents} agent{team.counts.agents === 1 ? "" : "s"} ·{" "}
{team.counts.projects} project{team.counts.projects === 1 ? "" : "s"} ·{" "}
{team.counts.routines} routine{team.counts.routines === 1 ? "" : "s"}
</p>
</div>
{team.description && (
<p className="line-clamp-3 text-xs text-muted-foreground">{team.description}</p>
)}
{team.tags.length > 0 && (
<div className="mt-auto flex flex-wrap gap-1">
{team.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-[10px]">
{tag}
</Badge>
))}
</div>
)}
</button>
);
}
type KindFilter = "all" | "bundled" | "optional";
type RiskFilter = "any" | "safe" | "has_warnings" | "blocked";