Gate Paperclip Runner setup behind an experimental flag (#12656)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent adapters control how Paperclip starts and resumes an agent runtime. > - Paperclip Runner is an experimental Rust runtime and must stay opt-in. > - The server already rejected new runner selections when the flag was off. > - Some setup and onboarding views did not enforce the same boundary. > - This pull request exposes the existing flag and applies it to every new setup path. > - The benefit is a safe rollout with unchanged legacy onboarding and recoverable existing native runs. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves experimental adapter selection in Settings, onboarding, new-agent setup, invite setup, and company import. **Subsystem affected** Cross-cutting: the React UI and the server onboarding seed service. **Current behavior** The server defaulted Paperclip Runner to off, but Settings did not expose the flag. First-run onboarding could show the runner after opt-in. A direct new-agent URL and some setup pickers could also reveal native runner configuration before the availability check completed. **Proposed behavior** Settings has a default-off Paperclip Runner toggle. Explicit agent configuration shows the runner only after the server reports that the flag is enabled. First-run and invite onboarding always use legacy adapters. Existing native agents and runs remain readable and recoverable. **Reason and benefit** This keeps the experimental runtime out of normal onboarding. It also gives administrators one clear opt-in before users can create a native runner agent. **Breaking changes** None. Legacy adapter selection and execution stay unchanged. Existing native records remain available. ## What Changed - Added the Paperclip Runner opt-in to Experimental Settings. - Refreshed adapter availability after the setting changes. - Kept UI and server-seeded onboarding on legacy adapters. - Made native runner choices fail closed in new-agent, invite, and import setup. - Preserved edit and recovery behavior for existing native agents and runs. - Added focused regression tests for flag-off and flag-on behavior. ## Verification - GitHub Actions will run the repository test, typecheck, build, and policy gates. - Focused tests cover Settings, onboarding, agent creation, invite setup, import setup, and server-seeded onboarding. - No local test suite was run, per the maintainer request to use GitHub Actions for verification. - `git diff --check` passes. ## Risks Low risk. The change narrows new adapter selection only. The server remains the final enforcement point. Existing native records do not depend on the current flag value for read or recovery behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5, with reasoning, tool use, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
This commit is contained in:
parent
5458940a6e
commit
1955b0e2d8
|
|
@ -105,6 +105,33 @@ function createDbStub() {
|
|||
return { db, updateMock };
|
||||
}
|
||||
|
||||
function createAgentInviteDbStub() {
|
||||
const invite = {
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
tokenHash: "hash",
|
||||
defaultsPayload: null,
|
||||
expiresAt: new Date("2027-03-10T00:00:00.000Z"),
|
||||
invitedByUserId: "user-1",
|
||||
revokedAt: null,
|
||||
acceptedAt: null,
|
||||
createdAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-07T00:00:00.000Z"),
|
||||
};
|
||||
const insert = vi.fn();
|
||||
const update = vi.fn();
|
||||
const db = {
|
||||
select() {
|
||||
return createQuery([invite]);
|
||||
},
|
||||
insert,
|
||||
update,
|
||||
};
|
||||
return { db, insert, update };
|
||||
}
|
||||
|
||||
function createApp(db: Record<string, unknown>) {
|
||||
return createAppWithActor(db, {
|
||||
type: "board",
|
||||
|
|
@ -315,6 +342,26 @@ describe("POST /invites/:token/accept", () => {
|
|||
expect(updateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects Paperclip Runner before an agent invite creates a join request", async () => {
|
||||
const { db, insert, update } = createAgentInviteDbStub();
|
||||
const app = createApp(db);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/invites/pcp_invite_test/accept")
|
||||
.send({
|
||||
requestType: "agent",
|
||||
agentName: "Native Agent",
|
||||
adapterType: "paperclip_runner",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe(
|
||||
"Paperclip Runner is not available through agent invite onboarding.",
|
||||
);
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("grants company access immediately for a human invite", async () => {
|
||||
const { db, insertedValues, updateValues } = createDirectHumanInviteDbStub();
|
||||
const app = createAppWithActor(db, {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assertLegacyAgentInviteAdapterType,
|
||||
buildJoinDefaultsPayloadForAccept,
|
||||
canReplayOpenClawGatewayInviteAccept,
|
||||
mergeJoinDefaultsPayloadForReplay,
|
||||
} from "../routes/access.js";
|
||||
|
||||
describe("assertLegacyAgentInviteAdapterType", () => {
|
||||
it("rejects native runner for new and pending agent-invite onboarding", () => {
|
||||
expect(() => assertLegacyAgentInviteAdapterType("paperclip_runner")).toThrow(
|
||||
"Paperclip Runner is not available through agent invite onboarding.",
|
||||
);
|
||||
expect(() => assertLegacyAgentInviteAdapterType("claude_local")).not.toThrow();
|
||||
expect(() => assertLegacyAgentInviteAdapterType(null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canReplayOpenClawGatewayInviteAccept", () => {
|
||||
it("allows replay only for openclaw_gateway agent joins in pending or approved state", () => {
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -122,6 +122,27 @@ describeEmbeddedPostgres("POST /api/companies/:companyId/onboarding-seed", () =>
|
|||
expect(record[0]?.issueId).toBe(companyIssues[0]?.id);
|
||||
});
|
||||
|
||||
it("keeps server-seeded onboarding on a legacy adapter when native runner is requested", async () => {
|
||||
const previous = process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE;
|
||||
process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = "paperclip_runner";
|
||||
try {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
const response = await post(app, companyId, SEED);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const companyAgents = await ctx.db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
expect(companyAgents).toHaveLength(1);
|
||||
expect(companyAgents[0]?.adapterType).toBe("claude_local");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("is idempotent per revision — a replay creates no second agent or task", async () => {
|
||||
const { companyId, app } = await seedCompany();
|
||||
|
||||
|
|
|
|||
|
|
@ -668,6 +668,17 @@ export function canReplayOpenClawGatewayInviteAccept(input: {
|
|||
);
|
||||
}
|
||||
|
||||
export function assertLegacyAgentInviteAdapterType(
|
||||
adapterType: string | null | undefined,
|
||||
) {
|
||||
if (adapterType === "paperclip_runner") {
|
||||
throw badRequest(
|
||||
"Paperclip Runner is not available through agent invite onboarding.",
|
||||
{ code: "paperclip_runner_invite_onboarding_disabled" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeSecretForLog(
|
||||
value: unknown
|
||||
): { present: true; length: number; sha256Prefix: string } | null {
|
||||
|
|
@ -3749,6 +3760,11 @@ export function accessRoutes(
|
|||
})
|
||||
);
|
||||
const adapterType = req.body.adapterType ?? null;
|
||||
if (requestType === "agent") {
|
||||
assertLegacyAgentInviteAdapterType(
|
||||
adapterType ?? existingJoinRequestForInvite?.adapterType ?? null,
|
||||
);
|
||||
}
|
||||
if (
|
||||
inviteAlreadyAccepted &&
|
||||
!canReplayHumanInviteAccept &&
|
||||
|
|
@ -4228,6 +4244,7 @@ export function accessRoutes(
|
|||
req.actor.userId ?? null
|
||||
);
|
||||
} else {
|
||||
assertLegacyAgentInviteAdapterType(existing.adapterType);
|
||||
const existingAgents = await agents.list(companyId);
|
||||
const managerId = resolveJoinRequestAgentManagerId(existingAgents);
|
||||
if (!managerId) {
|
||||
|
|
|
|||
|
|
@ -34,9 +34,14 @@ const SEEDED_AGENT_ROLE = "ceo";
|
|||
const FALLBACK_SEEDED_AGENT_ADAPTER_TYPE = "claude_local";
|
||||
|
||||
function seededAgentAdapterType() {
|
||||
return process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE?.trim()
|
||||
const configured = process.env.PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE?.trim()
|
||||
|| process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE?.trim()
|
||||
|| FALLBACK_SEEDED_AGENT_ADAPTER_TYPE;
|
||||
// Server-seeded onboarding deliberately stays on a direct adapter. Native
|
||||
// runner rollout is an explicit post-onboarding configuration choice.
|
||||
return configured === "paperclip_runner"
|
||||
? FALLBACK_SEEDED_AGENT_ADAPTER_TYPE
|
||||
: configured;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -238,12 +243,13 @@ export function onboardingSeedService(db: Db) {
|
|||
if (agentId) {
|
||||
await agentSvc.update(agentId, { name: agentName, title: agentRole });
|
||||
} else {
|
||||
const adapterType = seededAgentAdapterType();
|
||||
const created = await agentSvc.create(companyId, {
|
||||
name: agentName,
|
||||
role: SEEDED_AGENT_ROLE,
|
||||
title: agentRole,
|
||||
adapterType: seededAgentAdapterType(),
|
||||
adapterConfig: seededAgentAdapterConfig(seededAgentAdapterType()),
|
||||
adapterType,
|
||||
adapterConfig: seededAgentAdapterConfig(adapterType),
|
||||
runtimeConfig: {},
|
||||
permissions: {},
|
||||
status: "idle",
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const queryClient = useQueryClient();
|
||||
const environmentVariablesEditorRef = useRef<EnvironmentVariablesEditorHandle | null>(null);
|
||||
|
||||
// Sync disabled adapter types from server so dropdown filters them out
|
||||
// Sync disabled adapter types from server so dropdown filters them out.
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
|
||||
const { data: availableSecrets = [] } = useQuery({
|
||||
|
|
@ -292,6 +292,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
retry: false,
|
||||
});
|
||||
const adapterPickerDisabledTypes = useMemo(() => {
|
||||
const next = new Set(disabledTypes);
|
||||
// Fail closed while settings load. Existing native agents still render
|
||||
// their current value in edit mode, but the picker does not offer a fresh
|
||||
// native selection until the explicit experimental opt-in is known true.
|
||||
if (experimentalSettings?.enableNativeRunner !== true) {
|
||||
next.add("paperclip_runner");
|
||||
}
|
||||
return next;
|
||||
}, [disabledTypes, experimentalSettings?.enableNativeRunner]);
|
||||
const environmentsEnabled = experimentalSettings?.enableEnvironments === true;
|
||||
// Managed-sandbox-only policy: every agent runs in the platform-managed
|
||||
// environment, so the form hides each host filesystem path and each
|
||||
|
|
@ -1544,7 +1554,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
<Field label="Adapter type" hint={help.adapterType}>
|
||||
<AdapterTypeDropdown
|
||||
value={adapterType}
|
||||
disabledTypes={disabledTypes}
|
||||
disabledTypes={adapterPickerDisabledTypes}
|
||||
onChange={(t) => {
|
||||
if (isCreate) {
|
||||
// Reset all adapter-specific fields to defaults when switching adapter type
|
||||
|
|
|
|||
|
|
@ -60,7 +60,11 @@ vi.mock("../api/adapters", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../adapters", () => ({
|
||||
listUIAdapters: () => [{ type: "claude_local" }, { type: "openclaw_gateway" }],
|
||||
listUIAdapters: () => [
|
||||
{ type: "claude_local" },
|
||||
{ type: "paperclip_runner" },
|
||||
{ type: "openclaw_gateway" },
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/metadata", () => ({
|
||||
|
|
@ -207,4 +211,73 @@ describe("NewAgentDialog", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides Paperclip Runner configuration until the server enables it", async () => {
|
||||
listAdaptersMock.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "paperclip_runner", disabled: true },
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NewAgentDialog />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const configureButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Configure a runtime manually"),
|
||||
);
|
||||
await act(async () => {
|
||||
configureButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Claude Code");
|
||||
expect(container.textContent).not.toContain("Paperclip Runner");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Paperclip Runner configuration after the server enables it", async () => {
|
||||
listAdaptersMock.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "paperclip_runner", disabled: false },
|
||||
]);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NewAgentDialog />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const configureButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Configure a runtime manually"),
|
||||
);
|
||||
await act(async () => {
|
||||
configureButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip Runner");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ export function NewAgentDialog() {
|
|||
queryFn: () => adaptersApi.list(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const nativeRunnerAvailable =
|
||||
serverAdapters?.some(
|
||||
(adapter) => adapter.type === "paperclip_runner" && !adapter.disabled,
|
||||
) === true;
|
||||
|
||||
// Fetch existing agents for the "Ask CEO" flow
|
||||
const { data: agents } = useQuery({
|
||||
|
|
@ -92,6 +96,7 @@ export function NewAgentDialog() {
|
|||
const registered = listUIAdapters()
|
||||
.filter((a) =>
|
||||
isAgentAdapterType(a.type) &&
|
||||
(a.type !== "paperclip_runner" || nativeRunnerAvailable) &&
|
||||
!disabledTypes.has(a.type) &&
|
||||
isVisualAdapterChoice(a.type)
|
||||
);
|
||||
|
|
@ -115,7 +120,7 @@ export function NewAgentDialog() {
|
|||
if (!a.recommended && b.recommended) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
}, [disabledTypes, serverAdapters]);
|
||||
}, [disabledTypes, nativeRunnerAvailable, serverAdapters]);
|
||||
|
||||
function handleAskCeo() {
|
||||
closeNewAgent();
|
||||
|
|
|
|||
|
|
@ -201,6 +201,71 @@ describe("OnboardingWizard adapter selection", () => {
|
|||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps onboarding on legacy adapters even when Paperclip Runner is enabled", async () => {
|
||||
mockAdapterRegistry.list = [
|
||||
{ type: "paperclip_runner" },
|
||||
{ type: "codex_local" },
|
||||
];
|
||||
window.localStorage.setItem(
|
||||
ONBOARDING_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
step: 0,
|
||||
adapterType: "paperclip_runner",
|
||||
model: "gpt-runner-only",
|
||||
command: "runnerd",
|
||||
args: "--native",
|
||||
url: "ws://runner",
|
||||
}),
|
||||
);
|
||||
|
||||
const { root } = await mount();
|
||||
|
||||
const saved = JSON.parse(
|
||||
window.localStorage.getItem(ONBOARDING_STORAGE_KEY) ?? "{}",
|
||||
);
|
||||
expect(saved.adapterType).toBe("codex_local");
|
||||
expect(saved.model).toBe("");
|
||||
expect(saved.command).toBe("");
|
||||
expect(saved.args).toBe("");
|
||||
expect(saved.url).toBe("");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes a saved Paperclip Runner draft before adapter discovery resolves", async () => {
|
||||
mockAdapterRegistry.loaded = false;
|
||||
mockAdapterRegistry.list = [{ type: "codex_local" }];
|
||||
window.localStorage.setItem(
|
||||
ONBOARDING_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
step: 0,
|
||||
adapterType: "paperclip_runner",
|
||||
model: "gpt-runner-only",
|
||||
command: "runnerd",
|
||||
args: "--native",
|
||||
url: "ws://runner",
|
||||
}),
|
||||
);
|
||||
|
||||
const { root } = await mount();
|
||||
|
||||
const saved = JSON.parse(
|
||||
window.localStorage.getItem(ONBOARDING_STORAGE_KEY) ?? "{}",
|
||||
);
|
||||
expect(saved.adapterType).toBe("claude_local");
|
||||
expect(saved.model).toBe("");
|
||||
expect(saved.command).toBe("");
|
||||
expect(saved.args).toBe("");
|
||||
expect(saved.url).toBe("");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replace a saved adapter before the registry has loaded", async () => {
|
||||
// External adapter types are only registered once the adapters query
|
||||
// resolves. Until then `listUIAdapters()` returns the built-ins alone, so
|
||||
|
|
|
|||
|
|
@ -114,6 +114,21 @@ const MISSION_PROMPT_CHIPS = [
|
|||
"Launch a marketplace"
|
||||
];
|
||||
|
||||
// First-run onboarding stays on the proven direct adapters even when an
|
||||
// instance administrator has opted into Paperclip Runner elsewhere. The
|
||||
// experimental flag only exposes the runner in explicit agent configuration.
|
||||
const ONBOARDING_EXCLUDED_ADAPTER_TYPES = new Set([
|
||||
"process",
|
||||
"http",
|
||||
"paperclip_runner",
|
||||
]);
|
||||
|
||||
function restoreOnboardingAdapterType(savedAdapterType: unknown): AdapterType {
|
||||
return typeof savedAdapterType === "string" && savedAdapterType !== "paperclip_runner"
|
||||
? savedAdapterType
|
||||
: "claude_local";
|
||||
}
|
||||
|
||||
function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: string): string {
|
||||
const parts: string[] = [];
|
||||
if (q1.trim()) parts.push(q1.trim());
|
||||
|
|
@ -473,12 +488,26 @@ function OnboardingWizardInner({
|
|||
// one.
|
||||
(saved?.agentRole as AgentRole) || DEFAULT_AGENT_ROLE,
|
||||
);
|
||||
const [adapterType, setAdapterType] = useState<AdapterType>((saved?.adapterType as AdapterType) ?? "claude_local");
|
||||
const [adapterType, setAdapterType] = useState<AdapterType>(() =>
|
||||
restoreOnboardingAdapterType(saved?.adapterType),
|
||||
);
|
||||
const savedNativeRunnerDraft = saved?.adapterType === "paperclip_runner";
|
||||
const [cwd, setCwd] = useState((saved?.cwd as string) ?? "");
|
||||
const [model, setModel] = useState((saved?.model as string) ?? "");
|
||||
const [command, setCommand] = useState((saved?.command as string) ?? "");
|
||||
const [args, setArgs] = useState((saved?.args as string) ?? "");
|
||||
const [url, setUrl] = useState((saved?.url as string) ?? "");
|
||||
// Native drafts may carry provider-specific configuration that is invalid
|
||||
// for the legacy adapter selected above. Keep the portable working
|
||||
// directory, but clear runner-specific execution fields while restoring.
|
||||
const [model, setModel] = useState(
|
||||
savedNativeRunnerDraft ? "" : (saved?.model as string) ?? "",
|
||||
);
|
||||
const [command, setCommand] = useState(
|
||||
savedNativeRunnerDraft ? "" : (saved?.command as string) ?? "",
|
||||
);
|
||||
const [args, setArgs] = useState(
|
||||
savedNativeRunnerDraft ? "" : (saved?.args as string) ?? "",
|
||||
);
|
||||
const [url, setUrl] = useState(
|
||||
savedNativeRunnerDraft ? "" : (saved?.url as string) ?? "",
|
||||
);
|
||||
const [adapterEnvResult, setAdapterEnvResult] =
|
||||
useState<AdapterEnvironmentTestResult | null>(null);
|
||||
const [adapterEnvError, setAdapterEnvError] = useState<string | null>(null);
|
||||
|
|
@ -881,10 +910,9 @@ function OnboardingWizardInner({
|
|||
// External/plugin adapters automatically appear with generic defaults, and
|
||||
// server-disabled types are filtered out.
|
||||
const { recommendedAdapters, moreAdapters } = useMemo(() => {
|
||||
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
|
||||
const all = listUIAdapters()
|
||||
.filter((a) =>
|
||||
!SYSTEM_ADAPTER_TYPES.has(a.type) &&
|
||||
!ONBOARDING_EXCLUDED_ADAPTER_TYPES.has(a.type) &&
|
||||
!disabledTypes.has(a.type) &&
|
||||
isVisualAdapterChoice(a.type)
|
||||
)
|
||||
|
|
@ -1432,6 +1460,15 @@ function OnboardingWizardInner({
|
|||
// doesn't hire a second agent.
|
||||
async function handleGiveHeartbeat() {
|
||||
if (!createdCompanyId) return;
|
||||
// The grid and restore path both exclude native runner. Keep this final
|
||||
// guard at the mutation boundary so a stale or modified client cannot use
|
||||
// first-run onboarding to create a native agent.
|
||||
if (adapterType === "paperclip_runner") {
|
||||
setAdapterType("claude_local");
|
||||
setModel("");
|
||||
setError("Paperclip Runner is not available during onboarding. Choose a legacy adapter.");
|
||||
return;
|
||||
}
|
||||
// Guarded at the button and the Enter path too; repeated here because this
|
||||
// seeds the agent's instructions from `companyGoal`, and hiring with an
|
||||
// unhydrated mission fails silently - the agent exists, and simply never
|
||||
|
|
|
|||
|
|
@ -1096,6 +1096,34 @@ describe("CompanyImport", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("hides Paperclip Runner import configuration while its experimental flag is off", async () => {
|
||||
mockAdaptersApi.list.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "codex_local", disabled: false },
|
||||
{ type: "paperclip_runner", disabled: true },
|
||||
]);
|
||||
await previewMixedAdapterPackage();
|
||||
|
||||
for (const select of findAdapterSelects()) {
|
||||
expect(Array.from(select.options).map((option) => option.value))
|
||||
.not.toContain("paperclip_runner");
|
||||
}
|
||||
});
|
||||
|
||||
it("offers Paperclip Runner import configuration after its experimental flag is enabled", async () => {
|
||||
mockAdaptersApi.list.mockResolvedValue([
|
||||
{ type: "claude_local", disabled: false },
|
||||
{ type: "codex_local", disabled: false },
|
||||
{ type: "paperclip_runner", disabled: false },
|
||||
]);
|
||||
await previewMixedAdapterPackage();
|
||||
|
||||
for (const select of findAdapterSelects()) {
|
||||
expect(Array.from(select.options).map((option) => option.value))
|
||||
.toContain("paperclip_runner");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the CEO adapter with a visible warning when a manifest adapter is not installed", async () => {
|
||||
// The destination has no codex_local adapter; the CEO fallback (an empty
|
||||
// agent list defaults to claude_local) takes over — never silently.
|
||||
|
|
|
|||
|
|
@ -556,6 +556,7 @@ function ConflictResolutionList({
|
|||
|
||||
// ── Adapter type options for import ───────────────────────────────────
|
||||
|
||||
const FALLBACK_IMPORT_ADAPTER_TYPE = "claude_local";
|
||||
const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = listUIAdapters().map((adapter) => ({
|
||||
value: adapter.type,
|
||||
label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type),
|
||||
|
|
@ -572,13 +573,14 @@ interface AdapterPickerItem {
|
|||
* Set when the manifest adapter is not installed on the destination: the
|
||||
* adapter type the agent falls back to unless the user picks another one.
|
||||
* Null when the manifest adapter is usable here (or availability is unknown,
|
||||
* which fails open to the manifest adapter).
|
||||
* which fails open to the manifest adapter except for native runner).
|
||||
*/
|
||||
fallbackAdapterType: string | null;
|
||||
}
|
||||
|
||||
function AdapterPickerList({
|
||||
agents,
|
||||
adapterOptions,
|
||||
adapterOverrides,
|
||||
expandedSlugs,
|
||||
configValues,
|
||||
|
|
@ -587,6 +589,7 @@ function AdapterPickerList({
|
|||
onChangeConfig,
|
||||
}: {
|
||||
agents: AdapterPickerItem[];
|
||||
adapterOptions: { value: string; label: string }[];
|
||||
adapterOverrides: Record<string, string>;
|
||||
expandedSlugs: Set<string>;
|
||||
configValues: Record<string, CreateConfigValues>;
|
||||
|
|
@ -630,7 +633,7 @@ function AdapterPickerList({
|
|||
value={selectedType}
|
||||
onChange={(e) => onChangeAdapter(agent.slug, e.target.value)}
|
||||
>
|
||||
{IMPORT_ADAPTER_OPTIONS.map((opt) => (
|
||||
{adapterOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
|
|
@ -1025,6 +1028,18 @@ export function CompanyImport() {
|
|||
if (!installedAdapters) return null;
|
||||
return new Set(installedAdapters.filter((a) => !a.disabled).map((a) => a.type));
|
||||
}, [installedAdapters]);
|
||||
// Native runner is the one adapter that fails closed in the importer. Other
|
||||
// adapter choices preserve the importer's existing fail-open behavior when
|
||||
// availability cannot be read, but Paperclip Runner only appears after the
|
||||
// server explicitly reports that its experimental flag is enabled.
|
||||
const nativeRunnerAvailable =
|
||||
availableAdapterTypes?.has("paperclip_runner") === true;
|
||||
const importAdapterOptions = useMemo(
|
||||
() => IMPORT_ADAPTER_OPTIONS.filter(
|
||||
(option) => option.value !== "paperclip_runner" || nativeRunnerAvailable,
|
||||
),
|
||||
[nativeRunnerAvailable],
|
||||
);
|
||||
|
||||
const localZipHelpText =
|
||||
"Upload a .zip exported directly from Paperclip. Re-zipped archives created by Finder, Explorer, or other zip tools may not import correctly.";
|
||||
|
|
@ -1573,21 +1588,34 @@ export function CompanyImport() {
|
|||
// CEO's adapter; while availability is unknown the manifest adapter stands.
|
||||
const adapterAgents = useMemo<AdapterPickerItem[]>(() => {
|
||||
if (!importPreview) return [];
|
||||
return importPreview.manifest.agents.map((a) => ({
|
||||
slug: a.slug,
|
||||
name: a.name,
|
||||
adapterType: a.adapterType,
|
||||
// The fallback must itself be installed: the CEO's adapter when it is,
|
||||
// else any installed adapter, else null so the manifest adapter stands
|
||||
// and the server's unknown-adapter rejection is the backstop.
|
||||
fallbackAdapterType:
|
||||
availableAdapterTypes && !availableAdapterTypes.has(a.adapterType)
|
||||
? availableAdapterTypes.has(ceoAdapterType)
|
||||
return importPreview.manifest.agents.map((a) => {
|
||||
let fallbackAdapterType: string | null = null;
|
||||
if (a.adapterType === "paperclip_runner" && !nativeRunnerAvailable) {
|
||||
const firstEnabledLegacyAdapter = availableAdapterTypes
|
||||
? [...availableAdapterTypes].find((type) => type !== "paperclip_runner") ?? null
|
||||
: null;
|
||||
fallbackAdapterType =
|
||||
ceoAdapterType !== "paperclip_runner" &&
|
||||
(!availableAdapterTypes || availableAdapterTypes.has(ceoAdapterType))
|
||||
? ceoAdapterType
|
||||
: [...availableAdapterTypes][0] ?? null
|
||||
: null,
|
||||
}));
|
||||
}, [importPreview, availableAdapterTypes, ceoAdapterType]);
|
||||
: firstEnabledLegacyAdapter ?? FALLBACK_IMPORT_ADAPTER_TYPE;
|
||||
} else if (availableAdapterTypes && !availableAdapterTypes.has(a.adapterType)) {
|
||||
// The fallback must itself be installed: the CEO's adapter when it is,
|
||||
// else any installed adapter, else null so the manifest adapter stands
|
||||
// and the server's unknown-adapter rejection is the backstop.
|
||||
fallbackAdapterType = availableAdapterTypes.has(ceoAdapterType)
|
||||
? ceoAdapterType
|
||||
: [...availableAdapterTypes][0] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
slug: a.slug,
|
||||
name: a.name,
|
||||
adapterType: a.adapterType,
|
||||
fallbackAdapterType,
|
||||
};
|
||||
});
|
||||
}, [importPreview, availableAdapterTypes, ceoAdapterType, nativeRunnerAvailable]);
|
||||
|
||||
/** The adapter type an imported agent will actually use: an explicit user pick, else the availability fallback, else the manifest adapter. */
|
||||
function effectiveAdapterType(agent: AdapterPickerItem): string {
|
||||
|
|
@ -2048,6 +2076,7 @@ export function CompanyImport() {
|
|||
{/* Adapter picker list */}
|
||||
<AdapterPickerList
|
||||
agents={adapterAgents}
|
||||
adapterOptions={importAdapterOptions}
|
||||
adapterOverrides={adapterOverrides}
|
||||
expandedSlugs={adapterExpandedSlugs}
|
||||
configValues={adapterConfigValues}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ const AUTO_RECOVERY_TOGGLE_SELECTOR =
|
|||
'button[aria-label="Toggle task graph liveness auto-recovery"]';
|
||||
const RUNNER_PREVIEW_INGRESS_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle runner preview ingress experimental setting"]';
|
||||
const PAPERCLIP_RUNNER_TOGGLE_SELECTOR =
|
||||
'button[aria-label="Toggle Paperclip Runner experimental setting"]';
|
||||
|
||||
function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
||||
return {
|
||||
|
|
@ -323,6 +325,27 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
|
|||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps Paperclip Runner default-off and exposes an explicit opt-in", async () => {
|
||||
await renderPage();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip Runner");
|
||||
expect(container.textContent).toContain("Onboarding continues to use legacy adapters");
|
||||
const toggle = container.querySelector<HTMLButtonElement>(
|
||||
PAPERCLIP_RUNNER_TOGGLE_SELECTOR,
|
||||
);
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
toggle?.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
|
||||
enableNativeRunner: true,
|
||||
});
|
||||
expect(toggle?.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders and patches the Classic Task Interface experimental toggle on and off", async () => {
|
||||
await renderPage();
|
||||
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ export function InstanceExperimentalSettings() {
|
|||
queryClient.setQueryData(queryKeys.instance.experimentalSettings, updatedSettings);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.adapters.all }),
|
||||
queryClient.invalidateQueries({ queryKey: ["built-in-agents"] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
|
||||
]);
|
||||
|
|
@ -362,6 +363,7 @@ export function InstanceExperimentalSettings() {
|
|||
getWorktreeInstanceId(),
|
||||
);
|
||||
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||
const enableNativeRunner = experimentalQuery.data?.enableNativeRunner === true;
|
||||
const enableRunnerPreviewIngress =
|
||||
experimentalQuery.data?.enableRunnerPreviewIngress === true;
|
||||
const enableManagedSandboxOnly = experimentalQuery.data?.enableManagedSandboxOnly === true;
|
||||
|
|
@ -732,6 +734,19 @@ export function InstanceExperimentalSettings() {
|
|||
ariaLabel="Toggle Paperclip developer mode experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Paperclip Runner"
|
||||
description="Allow new Codex agents to select the experimental Rust Paperclip Runner. Onboarding continues to use legacy adapters. Turning this off hides the choice without affecting existing native runs."
|
||||
checked={enableNativeRunner}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleMutation.mutate({ enableNativeRunner: checked })
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
settingKey="enableNativeRunner"
|
||||
managed={managedKeys.enableNativeRunner}
|
||||
ariaLabel="Toggle Paperclip Runner experimental setting"
|
||||
/>
|
||||
|
||||
<ExperimentalToggleCard
|
||||
title="Runner Preview Ingress"
|
||||
description="Let Paperclip Runner agents connect through an authenticated sandbox-provider WebSocket preview. Legacy adapters continue using their existing transports."
|
||||
|
|
|
|||
|
|
@ -129,6 +129,50 @@ describe("InviteLandingPage", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps agent-invite onboarding on legacy adapters", async () => {
|
||||
getInviteMock.mockResolvedValue({
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
companyName: "Acme Robotics",
|
||||
companyLogoUrl: null,
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "agent",
|
||||
humanRole: null,
|
||||
expiresAt: "2027-03-07T00:10:00.000Z",
|
||||
inviteMessage: null,
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const adapterSelect = Array.from(container.querySelectorAll("select")).find((select) =>
|
||||
Array.from(select.options).some((option) => option.value === "claude_local"),
|
||||
);
|
||||
expect(adapterSelect).toBeTruthy();
|
||||
expect(Array.from(adapterSelect!.options).map((option) => option.value))
|
||||
.not.toContain("paperclip_runner");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults invite auth to account creation and guides existing users back to sign in", async () => {
|
||||
signUpEmailMock.mockRejectedValue(
|
||||
Object.assign(new Error("User already exists. Use another email."), {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ import { formatDate } from "../lib/utils";
|
|||
type AuthMode = "sign_in" | "sign_up";
|
||||
type AuthFeedback = { tone: "error" | "info"; message: string };
|
||||
|
||||
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
|
||||
// Agent-invite onboarding remains a legacy-adapter flow. Native runner setup
|
||||
// belongs in authenticated agent configuration after an administrator enables
|
||||
// the experimental flag.
|
||||
const joinAdapterOptions: AgentAdapterType[] = AGENT_ADAPTER_TYPES.filter(
|
||||
(type) => type !== "paperclip_runner",
|
||||
);
|
||||
const ENABLED_INVITE_ADAPTERS = new Set([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
|
|
|
|||
|
|
@ -46,10 +46,15 @@ const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
|||
const mockAssetsApi = vi.hoisted(() => ({ uploadImage: vi.fn() }));
|
||||
const mockClipboard = vi.hoisted(() => ({ copyTextToClipboard: vi.fn() }));
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
const mockAdapterAvailability = vi.hoisted(() => ({
|
||||
disabled: new Set<string>(),
|
||||
loaded: true,
|
||||
}));
|
||||
const mockSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
useSearchParams: () => [mockSearchParams.value, vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
|
|
@ -73,7 +78,8 @@ vi.mock("../lib/clipboard", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("../adapters/use-disabled-adapters", () => ({
|
||||
useDisabledAdaptersSync: () => new Set<string>(),
|
||||
useDisabledAdaptersSync: () => mockAdapterAvailability.disabled,
|
||||
useAdapterRegistryLoaded: () => mockAdapterAvailability.loaded,
|
||||
}));
|
||||
|
||||
// The form reads the projected adapter login capability to pick the login flow
|
||||
|
|
@ -235,6 +241,9 @@ describe("NewAgent Claude subscription login", () => {
|
|||
let roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
mockAdapterAvailability.disabled = new Set<string>();
|
||||
mockAdapterAvailability.loaded = true;
|
||||
mockSearchParams.value = new URLSearchParams();
|
||||
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
|
||||
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
||||
mockAgentsApi.detectModel.mockResolvedValue(null);
|
||||
|
|
@ -404,4 +413,24 @@ describe("NewAgent Claude subscription login", () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a native-runner URL preset while the experimental adapter is disabled", async () => {
|
||||
mockSearchParams.value = new URLSearchParams("adapterType=paperclip_runner");
|
||||
mockAdapterAvailability.disabled = new Set(["paperclip_runner"]);
|
||||
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
expect(result.container.textContent).not.toContain("Paperclip Runner");
|
||||
expect(result.container.textContent).toContain("Claude Code");
|
||||
});
|
||||
|
||||
it("accepts a native-runner URL preset after the experimental adapter is enabled", async () => {
|
||||
mockSearchParams.value = new URLSearchParams("adapterType=paperclip_runner");
|
||||
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
expect(result.container.textContent).toContain("Paperclip Runner");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,8 +30,11 @@ import {
|
|||
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||
import { buildFixedClaudeOAuthBinding } from "../components/environment-variables-editor/model";
|
||||
import type { EnvBinding } from "@paperclipai/shared";
|
||||
import { getUIAdapter, listUIAdapters } from "../adapters";
|
||||
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
|
||||
import { getUIAdapter } from "../adapters";
|
||||
import {
|
||||
useAdapterRegistryLoaded,
|
||||
useDisabledAdaptersSync,
|
||||
} from "../adapters/use-disabled-adapters";
|
||||
import { isValidAdapterType } from "../adapters/metadata";
|
||||
import { ReportsToPicker } from "../components/ReportsToPicker";
|
||||
import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload";
|
||||
|
|
@ -93,6 +96,8 @@ export function NewAgent() {
|
|||
result: null,
|
||||
login: null,
|
||||
});
|
||||
const disabledTypes = useDisabledAdaptersSync();
|
||||
const adapterRegistryLoaded = useAdapterRegistryLoaded();
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
|
|
@ -142,12 +147,13 @@ export function NewAgent() {
|
|||
useEffect(() => {
|
||||
const requested = presetAdapterType;
|
||||
if (!requested) return;
|
||||
if (!adapterRegistryLoaded || disabledTypes.has(requested)) return;
|
||||
if (!isValidAdapterType(requested)) return;
|
||||
setConfigValues((prev) => {
|
||||
if (prev.adapterType === requested) return prev;
|
||||
return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]);
|
||||
});
|
||||
}, [presetAdapterType]);
|
||||
}, [adapterRegistryLoaded, disabledTypes, presetAdapterType]);
|
||||
|
||||
const createAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ import type {
|
|||
CatalogTeamImportPreviewResult,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TeamCatalog, parseTeamRoute, teamRoute } from "./TeamCatalog";
|
||||
import {
|
||||
TeamCatalog,
|
||||
listTeamInstallAdapterTypes,
|
||||
parseTeamRoute,
|
||||
resolveTeamInstallAdapterType,
|
||||
teamRoute,
|
||||
} from "./TeamCatalog";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
const mockTeamCatalogApi = vi.hoisted(() => ({
|
||||
|
|
@ -27,9 +33,17 @@ const mockAgentsApi = vi.hoisted(() => ({
|
|||
const mockPushToast = vi.hoisted(() => vi.fn());
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockAdapterAvailability = vi.hoisted(() => ({
|
||||
disabled: new Set<string>(["paperclip_runner"]),
|
||||
loaded: true,
|
||||
}));
|
||||
|
||||
vi.mock("../api/teamCatalog", () => ({ teamCatalogApi: mockTeamCatalogApi }));
|
||||
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
|
||||
vi.mock("../adapters/use-disabled-adapters", () => ({
|
||||
useDisabledAdaptersSync: () => mockAdapterAvailability.disabled,
|
||||
useAdapterRegistryLoaded: () => mockAdapterAvailability.loaded,
|
||||
}));
|
||||
|
||||
vi.mock("../components/MarkdownBody", () => ({
|
||||
MarkdownBody: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
|
|
@ -95,6 +109,21 @@ describe("TeamCatalog routes", () => {
|
|||
filePath: "agents/a~b/AGENTS.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for Paperclip Runner until adapter availability is loaded and enabled", () => {
|
||||
expect(listTeamInstallAdapterTypes(new Set(), false)).not.toContain("paperclip_runner");
|
||||
expect(listTeamInstallAdapterTypes(new Set(), false)).not.toContain("process");
|
||||
expect(listTeamInstallAdapterTypes(new Set(), false)).not.toContain("http");
|
||||
expect(listTeamInstallAdapterTypes(new Set(["paperclip_runner"]), true))
|
||||
.not.toContain("paperclip_runner");
|
||||
expect(listTeamInstallAdapterTypes(new Set(), true)).toContain("paperclip_runner");
|
||||
expect(resolveTeamInstallAdapterType("paperclip_runner", ["claude_local", "codex_local"]))
|
||||
.toBe("claude_local");
|
||||
expect(resolveTeamInstallAdapterType("paperclip_runner", ["codex_local"]))
|
||||
.toBe("codex_local");
|
||||
expect(resolveTeamInstallAdapterType("paperclip_runner", []))
|
||||
.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
|
|
@ -210,6 +239,8 @@ describe("TeamCatalog install preview path", () => {
|
|||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAdapterAvailability.disabled = new Set(["paperclip_runner"]);
|
||||
mockAdapterAvailability.loaded = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
currentRoute = "team-no-deps";
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
|
|||
import { teamCatalogApi } from "../api/teamCatalog";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import {
|
||||
useAdapterRegistryLoaded,
|
||||
useDisabledAdaptersSync,
|
||||
} from "../adapters/use-disabled-adapters";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
|
|
@ -112,6 +116,31 @@ import { GithubIcon } from "../components/icons/github-icon";
|
|||
// Matches design §11 breakpoints. Module-level so stories and the page agree.
|
||||
const DESKTOP_MIN = 1024;
|
||||
const MOBILE_MAX = 767;
|
||||
const TEAM_INSTALL_FALLBACK_ADAPTER_TYPE = "claude_local";
|
||||
const TEAM_INSTALL_FORBIDDEN_ADAPTER_TYPES = new Set(["process", "http"]);
|
||||
|
||||
export function listTeamInstallAdapterTypes(
|
||||
disabledTypes: Set<string>,
|
||||
adapterRegistryLoaded: boolean,
|
||||
) {
|
||||
return AGENT_ADAPTER_TYPES.filter(
|
||||
(type) =>
|
||||
!TEAM_INSTALL_FORBIDDEN_ADAPTER_TYPES.has(type) &&
|
||||
!disabledTypes.has(type) &&
|
||||
(type !== "paperclip_runner" || adapterRegistryLoaded),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTeamInstallAdapterType(
|
||||
requestedType: string,
|
||||
selectableAdapterTypes: readonly string[],
|
||||
) {
|
||||
if (selectableAdapterTypes.includes(requestedType)) return requestedType;
|
||||
if (selectableAdapterTypes.includes(TEAM_INSTALL_FALLBACK_ADAPTER_TYPE)) {
|
||||
return TEAM_INSTALL_FALLBACK_ADAPTER_TYPE;
|
||||
}
|
||||
return selectableAdapterTypes[0] ?? null;
|
||||
}
|
||||
|
||||
function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() =>
|
||||
|
|
@ -1051,6 +1080,12 @@ function TeamInstallerDialog({
|
|||
const steps = useMemo(() => computeSteps(team), [team]);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [phase, setPhase] = useState<ApplyPhase>("form");
|
||||
const disabledAdapterTypes = useDisabledAdaptersSync({ enabled: open });
|
||||
const adapterRegistryLoaded = useAdapterRegistryLoaded({ enabled: open });
|
||||
const selectableAdapterTypes = useMemo(
|
||||
() => listTeamInstallAdapterTypes(disabledAdapterTypes, adapterRegistryLoaded),
|
||||
[adapterRegistryLoaded, disabledAdapterTypes],
|
||||
);
|
||||
|
||||
// Step 1 — target manager
|
||||
const [targetManagerAgentId, setTargetManagerAgentId] = useState<string | null>(null);
|
||||
|
|
@ -1117,7 +1152,31 @@ function TeamInstallerDialog({
|
|||
const buildInstallOptions = () => {
|
||||
const overrides: Record<string, CompanyPortabilityAdapterOverride> = {};
|
||||
for (const [slug, adapterType] of Object.entries(adapterOverrides)) {
|
||||
if (adapterType) overrides[slug] = { adapterType };
|
||||
if (adapterType) {
|
||||
const resolvedAdapterType = resolveTeamInstallAdapterType(
|
||||
adapterType,
|
||||
selectableAdapterTypes,
|
||||
);
|
||||
if (!resolvedAdapterType) {
|
||||
throw new Error("Enable a legacy adapter before installing this team.");
|
||||
}
|
||||
overrides[slug] = {
|
||||
adapterType: resolvedAdapterType,
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const agent of previewResult?.portabilityPreview.manifest.agents ?? []) {
|
||||
if (overrides[agent.slug]) continue;
|
||||
const adapterType = resolveTeamInstallAdapterType(
|
||||
agent.adapterType,
|
||||
selectableAdapterTypes,
|
||||
);
|
||||
if (!adapterType) {
|
||||
throw new Error("Enable a legacy adapter before installing this team.");
|
||||
}
|
||||
if (adapterType !== agent.adapterType) {
|
||||
overrides[agent.slug] = { adapterType };
|
||||
}
|
||||
}
|
||||
const enteredSecretValues = Object.fromEntries(
|
||||
Object.entries(secretValues).filter(([, value]) => value.trim().length > 0),
|
||||
|
|
@ -1186,7 +1245,16 @@ function TeamInstallerDialog({
|
|||
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 missingEnabledAdapter = Boolean(
|
||||
previewResult?.portabilityPreview.manifest.agents.some(
|
||||
(agent) =>
|
||||
!resolveTeamInstallAdapterType(
|
||||
adapterOverrides[agent.slug] ?? agent.adapterType,
|
||||
selectableAdapterTypes,
|
||||
),
|
||||
),
|
||||
);
|
||||
const installBlocked = hasErrors || missingRequiredSecretCount > 0 || missingEnabledAdapter;
|
||||
const needsScriptsConfirm = team.trustLevel === "scripts_executables";
|
||||
|
||||
function goNext() {
|
||||
|
|
@ -1278,6 +1346,7 @@ function TeamInstallerDialog({
|
|||
nameOverrides={nameOverrides}
|
||||
onRename={(slug, name) => setNameOverrides((cur) => ({ ...cur, [slug]: name }))}
|
||||
adapterOverrides={adapterOverrides}
|
||||
selectableAdapterTypes={selectableAdapterTypes}
|
||||
onAdapterChange={(slug, adapterType) => setAdapterOverrides((cur) => ({ ...cur, [slug]: adapterType }))}
|
||||
secretValues={secretValues}
|
||||
visibleSecretKeys={visibleSecretKeys}
|
||||
|
|
@ -1329,6 +1398,11 @@ function TeamInstallerDialog({
|
|||
Required secrets missing: {missingRequiredSecretCount}
|
||||
</span>
|
||||
)}
|
||||
{currentStep === "preview" && !hasErrors && missingRequiredSecretCount === 0 && missingEnabledAdapter && (
|
||||
<span className="text-xs text-rose-600 dark:text-rose-300">
|
||||
Enable a legacy adapter to install this team
|
||||
</span>
|
||||
)}
|
||||
{currentStep === "preview" ? (
|
||||
needsScriptsConfirm && confirmScripts ? (
|
||||
<Button variant="destructive" onClick={submitInstall} disabled={installBlocked || previewMutation.isPending}>
|
||||
|
|
@ -1696,6 +1770,7 @@ export function StepPreview({
|
|||
nameOverrides,
|
||||
onRename,
|
||||
adapterOverrides,
|
||||
selectableAdapterTypes,
|
||||
onAdapterChange,
|
||||
secretValues = {},
|
||||
visibleSecretKeys = {},
|
||||
|
|
@ -1712,6 +1787,7 @@ export function StepPreview({
|
|||
nameOverrides: Record<string, string>;
|
||||
onRename: (slug: string, name: string) => void;
|
||||
adapterOverrides: Record<string, string>;
|
||||
selectableAdapterTypes: readonly string[];
|
||||
onAdapterChange: (slug: string, adapterType: string) => void;
|
||||
secretValues?: Record<string, string>;
|
||||
visibleSecretKeys?: Record<string, boolean>;
|
||||
|
|
@ -1839,22 +1915,31 @@ export function StepPreview({
|
|||
{manifestAgents.length > 0 && (
|
||||
<PreviewSection title={`Adapter selection · ${manifestAgents.length}`}>
|
||||
{manifestAgents.map((agent) => {
|
||||
const selected = adapterOverrides[agent.slug] ?? agent.adapterType;
|
||||
const selected = resolveTeamInstallAdapterType(
|
||||
adapterOverrides[agent.slug] ?? agent.adapterType,
|
||||
selectableAdapterTypes,
|
||||
);
|
||||
return (
|
||||
<li key={agent.slug} className="flex items-center gap-2 px-3 py-2 text-sm">
|
||||
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="min-w-0 truncate">{agent.name}</span>
|
||||
<span className="font-mono text-(length:--text-micro) text-muted-foreground">{agent.slug}</span>
|
||||
<Select value={selected} onValueChange={(v) => onAdapterChange(agent.slug, v)}>
|
||||
<SelectTrigger className="ml-auto h-8 w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AGENT_ADAPTER_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>{getAdapterLabel(type)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selected ? (
|
||||
<Select value={selected} onValueChange={(v) => onAdapterChange(agent.slug, v)}>
|
||||
<SelectTrigger className="ml-auto h-8 w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectableAdapterTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>{getAdapterLabel(type)}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="ml-auto text-xs text-rose-600 dark:text-rose-300">
|
||||
No enabled legacy adapter
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ export const InstallPreview: Story = {
|
|||
nameOverrides={names}
|
||||
onRename={(slug, name) => setNames((c) => ({ ...c, [slug]: name }))}
|
||||
adapterOverrides={adapters}
|
||||
selectableAdapterTypes={["claude_local", "codex_local"]}
|
||||
onAdapterChange={(slug, t) => setAdapters((c) => ({ ...c, [slug]: t }))}
|
||||
onRetry={noop}
|
||||
/>
|
||||
|
|
@ -286,6 +287,7 @@ export const InstallPreviewBlocked: Story = {
|
|||
nameOverrides={{}}
|
||||
onRename={noop}
|
||||
adapterOverrides={{}}
|
||||
selectableAdapterTypes={["claude_local", "codex_local"]}
|
||||
onAdapterChange={noop}
|
||||
onRetry={noop}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue