From 7badae69810b3e46386ebfffe01a1648f0c63598 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 22 Sep 2026 16:39:11 -0700 Subject: [PATCH 01/11] feat(plugins): add an optional organization switcher slot (#13832) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Plugins can add UI surfaces to the board. > - Organization navigation is still fixed in the host sidebar. > - A distribution needs a supported way to supply its own organization menu. > - This pull request adds one optional React slot with host-owned navigation controls. > - The built-in menu stays available when the optional contribution cannot render. ## Linked Issues or Issue Description **Subsystem affected** Plugin SDK, server capability validation, and sidebar UI. **Problem or motivation** An installed plugin cannot replace the organization switcher without editing the host menu. Existing sidebar and overlay slots do not provide this replacement surface. **Proposed solution** Add an `organizationSwitcher` slot that requires `ui.sidebar.register`. Pass display state, an icon renderer, and navigation/logout callbacks. Keep the built-in menu for absent, ambiguous, missing, failed, or unsupported contributions. **Roadmap alignment** This keeps distribution UI in plugins and adds a small host contract. It does not add an account system or change company authorization. The maintainer requested this extension. Related prior menu changes: #12788, #10917, and #10850. No duplicate replacement-slot PR was found. ## What Changed - Add the slot to shared validation, SDK types, and server capability checks. - Wrap both sidebar menu variants with the optional replacement. - Resolve selection against the current account query before mounting, and reset replacement state on account or company changes. - Add host-specific component props and a fallback to `PluginSlotMount`. - Document the React-only contract and its trust boundary. - Report runtime-supervisor fixture startup details when CI readiness fails. ## Verification - `pnpm -r typecheck` and `pnpm build` passed. - `pnpm check:token-gates` passed. - `pnpm exec vitest run --project @paperclipai/ui`: 6,580 tests passed. - Targeted manifest, replacement, and built-in menu tests: 26 passed, including the incoming-account selection regression. - Installed a local test contribution into an isolated server. CLI inspection reported `ready`. Browser checks covered the loaded production UI, keyboard dismissal, current-organization selection, and an expired remote session. Remote account responses were fixtures. - `pnpm test:run` was attempted. Its first server phase passed 8,323 tests but failed in 36 files due to embedded PostgreSQL startup and filesystem permission errors on this Mac. Later phases did not run. The current Linux CI run is green: 54 checks passed and two were skipped, including build, typecheck, and browser gates. See https://github.com/paperclipai/paperclip/actions/runs/35796722770. - The earlier runtime-supervisor readiness failure did not reproduce locally. The complete affected shard passed locally: 58 files and 843 tests. The six supervisor tests also passed on Node 24.21.0 with CI flags. Added fixture startup diagnostics for the selected Node executable and listener port. The affected Linux shard then passed all 843 tests. The original root cause remains unconfirmed; no production runtime behavior or timeout was changed. ## Risks - Plugin UI remains trusted same-origin code. Display props do not authorize account requests. - A replacement can change navigation behavior. The host retains the built-in menu when discovery or rendering fails and keeps logout/session cleanup host-owned. - No database migration. Existing menus and portfolio behavior remain available. - Local full-suite verification is limited by the environment failures listed above. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository inspection, code execution, and browser testing. The runtime does not expose a more specific model ID or context-window size. ## 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 - [x] 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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/plugins/PLUGIN_AUTHORING_GUIDE.md | 16 ++++ packages/plugins/sdk/src/ui/index.ts | 1 + packages/plugins/sdk/src/ui/types.ts | 21 +++++ packages/shared/src/constants.ts | 1 + .../local-service-supervisor.test.ts | 2 +- .../plugin-organization-switcher.test.ts | 17 ++++ .../services/plugin-capability-validator.ts | 1 + ui/package.json | 1 + .../PluginOrganizationSwitcher.test.tsx | 89 +++++++++++++++++++ .../components/PluginOrganizationSwitcher.tsx | 64 +++++++++++++ .../SidebarCompanyMenu.production.tsx | 12 ++- ui/src/components/SidebarCompanyMenu.test.tsx | 2 + ui/src/components/SidebarCompanyMenu.tsx | 12 ++- ui/src/plugins/slots.tsx | 15 +++- 14 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 server/src/__tests__/plugin-organization-switcher.test.ts create mode 100644 ui/src/components/PluginOrganizationSwitcher.test.tsx create mode 100644 ui/src/components/PluginOrganizationSwitcher.tsx diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index 00d6cc01bff..091f90e81cf 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -369,6 +369,7 @@ Mount surfaces currently wired in the host include: - `projectSidebarItem` - `globalToolbarButton` - `appShellOverlay` (persistent, signed-in application shell) +- `organizationSwitcher` (one React contribution replacing the organization menu) - `toolbarButton` - `contextMenuItem` - `commentAnnotation` @@ -617,3 +618,18 @@ pnpm build For image-supplied plugins and the persistent shell lifecycle, see [Distribution plugins](DISTRIBUTION-PLUGINS.md). + +### Organization switcher + +Declare one `organizationSwitcher` slot with `ui.sidebar.register`. The host +passes `PluginOrganizationSwitcherProps`: current company display data, collapsed +and open state, navigation/logout callbacks, and an icon renderer. Use the host +logout callback; authenticate remote account requests at their owning service. +`currentCompany` describes the host-local company. A distribution plugin must +resolve its external account/organization label itself; the host does not fetch +that portfolio on the plugin's behalf. +The slot props and `useHostContext()` are display context, not proof of identity. +The host resets plugin state on account/company changes and keeps its built-in +menu when no unique contribution exists, discovery fails, the module is missing, +or rendering throws. The slot is a React-only contract; do not use a custom +element export. This replaces only the menu, not company policy or authorization. diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index cffdb233dc5..f7d1cee64a8 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -152,6 +152,7 @@ export type { PluginWidgetProps, PluginDetailTabProps, PluginSidebarProps, + PluginOrganizationSwitcherProps, PluginRouteSidebarProps, PluginProjectSidebarItemProps, PluginCommentAnnotationProps, diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index 10478216838..64a9ce2d061 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -16,6 +16,7 @@ import type { AnchorHTMLAttributes, + ReactNode, MouseEvent as ReactMouseEvent, } from "react"; import type { @@ -270,6 +271,26 @@ export interface PluginDetailTabProps { }; } +/** A single installed contribution replaces the organization menu. The host + * retains its built-in menu while loading, on ambiguity, or on render failure. + * These values/callbacks are presentation context, never authorization. */ +export interface PluginOrganizationSwitcherProps { + organizationSwitcher: { + /** Host-local company branding, not an external account/organization label. + * Plugins resolve external organization identity at their owning service. */ + currentCompany: { name: string; logoUrl: string | null } | null; + collapsed: boolean; + open: boolean; + onOpenChange(open: boolean): void; + /** Close mobile navigation before leaving the current organization. */ + onNavigate(): void; + /** Host-owned logout preserves its session/cache invalidation behavior. */ + onSignOut(): void; + signingOut: boolean; + renderIcon(name: string, logoUrl: string | null, inMenu: boolean): ReactNode; + }; +} + /** * Props passed to a plugin sidebar component. * diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 33d1e61e8ef..1c90a7b7a91 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1483,6 +1483,7 @@ export const PLUGIN_UI_SLOT_TYPES = [ "projectSidebarItem", "globalToolbarButton", "appShellOverlay", + "organizationSwitcher", "toolbarButton", "contextMenuItem", "commentAnnotation", diff --git a/server/src/__tests__/local-service-supervisor.test.ts b/server/src/__tests__/local-service-supervisor.test.ts index 99d49e4c1d8..bd891224793 100644 --- a/server/src/__tests__/local-service-supervisor.test.ts +++ b/server/src/__tests__/local-service-supervisor.test.ts @@ -54,7 +54,7 @@ describe("local service supervision", () => { workspaceRuntime: { services: [{ name: "web", - command: "node -e \"const http=require('node:http'); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1')\"", + command: "command -v node; node --version; node -e \"const http=require('node:http'); console.log('fixture starting', process.execPath, process.env.PORT); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1',()=>console.log('fixture listening', process.env.PORT))\"", port: { type: "auto" }, readiness: { type: "http", diff --git a/server/src/__tests__/plugin-organization-switcher.test.ts b/server/src/__tests__/plugin-organization-switcher.test.ts new file mode 100644 index 00000000000..f6f20b27f23 --- /dev/null +++ b/server/src/__tests__/plugin-organization-switcher.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { pluginManifestV1Schema } from "@paperclipai/shared"; +import { pluginCapabilityValidator } from "../services/plugin-capability-validator.js"; + +describe("organization switcher installation", () => { + it("accepts the slot and requires the sidebar capability", () => { + const manifest = pluginManifestV1Schema.parse({ + id: "example.account", apiVersion: 1, version: "1.0.0", displayName: "Account", categories: ["ui"], + description: "Organization navigation", author: "Example", + capabilities: ["ui.sidebar.register"], entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" }, + ui: { slots: [{ type: "organizationSwitcher", id: "organizations", displayName: "Organizations", exportName: "Organizations" }] }, + }); + const validator = pluginCapabilityValidator(); + expect(validator.validateManifestCapabilities(manifest).allowed).toBe(true); + expect(validator.validateManifestCapabilities({ ...manifest, capabilities: [] })).toMatchObject({ allowed: false, missing: ["ui.sidebar.register"] }); + }); +}); diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index eb0b83d3f94..e015a9a8b57 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -164,6 +164,7 @@ const UI_SLOT_CAPABILITIES: Record = { dashboardWidget: "ui.dashboardWidget.register", globalToolbarButton: "ui.action.register", appShellOverlay: "ui.action.register", + organizationSwitcher: "ui.sidebar.register", toolbarButton: "ui.action.register", contextMenuItem: "ui.action.register", commentAnnotation: "ui.commentAnnotation.register", diff --git a/ui/package.json b/ui/package.json index 7104d63d8fa..64b7a759e9f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -74,6 +74,7 @@ "yjs": "13.6.32" }, "devDependencies": { + "@paperclipai/plugin-sdk": "workspace:*", "@sentry/browser": "10.71.0", "@storybook/addon-a11y": "10.5.10", "@storybook/addon-docs": "10.5.10", diff --git a/ui/src/components/PluginOrganizationSwitcher.test.tsx b/ui/src/components/PluginOrganizationSwitcher.test.tsx new file mode 100644 index 00000000000..866b984b020 --- /dev/null +++ b/ui/src/components/PluginOrganizationSwitcher.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginOrganizationSwitcherProps } from "@paperclipai/plugin-sdk/ui"; +import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; +import { registerPluginReactComponent, registerPluginWebComponent, type ResolvedPluginSlot } from "@/plugins/slots"; + +const state = vi.hoisted(() => ({ userId: "alice", companyId: "company-a", settled: true, companyListReady: true, companyIds: ["company-a", "company-b"], slots: [] as ResolvedPluginSlot[], errorMessage: null as string | null, mobile: false, collapsed: false, close: vi.fn(), signOut: vi.fn(), props: null as PluginOrganizationSwitcherProps | null })); +vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => state, useCompanyListQuery: () => ({ isSuccess: state.companyListReady, data: { unauthorized: false, companies: state.companyIds.map(id => ({ id, name: "Acme", issuePrefix: "ACME", logoUrl: "/logo" })) } }) })); +vi.mock("@/api/auth", () => ({ authApi: { getSession: async () => ({ user: { id: state.userId } }) } })); +vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId, selectedCompany: { name: "Acme", issuePrefix: "ACME", logoUrl: "/logo" } }) })); +vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: state.mobile, setSidebarOpen: state.close, collapsed: state.collapsed, peeking: false }) })); +vi.mock("@/hooks/useSignOut", () => ({ useSignOut: () => ({ mutate: state.signOut, isPending: false }) })); +vi.mock("./CompanyPatternIcon", () => ({ CompanyPatternIcon: () => icon })); +vi.mock("@/plugins/slots", async (importOriginal) => ({ ...await importOriginal(), usePluginSlots: () => state })); +const slot: ResolvedPluginSlot = { type: "organizationSwitcher", id: "switcher", displayName: "Organizations", exportName: "Switcher", pluginId: "plugin-1", pluginKey: "fixture.switcher", pluginDisplayName: "Fixture", pluginVersion: "1" }; +let root: Root | undefined; +let container: HTMLDivElement; +const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); +function render() { + if (!root) { container = document.createElement("div"); document.body.append(container); root = createRoot(container); } + flushSync(() => root!.render()); +} +afterEach(() => { + if (root) flushSync(() => root!.unmount()); root = undefined; container?.remove(); client.clear(); vi.restoreAllMocks(); + Object.assign(state, { userId: "alice", companyId: "company-a", settled: true, companyListReady: true, companyIds: ["company-a", "company-b"], slots: [], errorMessage: null, mobile: false, collapsed: false, props: null }); + state.close.mockClear(); state.signOut.mockClear(); +}); +function register() { + registerPluginReactComponent(slot.pluginKey, slot.exportName, (props) => { + const host = (props as unknown as PluginOrganizationSwitcherProps).organizationSwitcher; + state.props = { organizationSwitcher: host }; + const [selected, setSelected] = useState(false); + return ; + }); + state.slots = [slot]; +} +describe("organization navigation replacement", () => { + it("keeps built-in navigation for absent, ambiguous, failed or unsettled discovery", () => { + render(); expect(container.textContent).toBe("Built-in organizations"); + register(); state.slots = [slot, { ...slot, id: "other" }]; render(); expect(container.textContent).toBe("Built-in organizations"); + state.slots = [slot]; state.errorMessage = "offline"; render(); expect(container.textContent).toBe("Built-in organizations"); + state.errorMessage = null; state.settled = false; render(); expect(container.textContent).toBe("Built-in organizations"); + }); + it("falls back when a declared module is missing or rendering fails", () => { + state.slots = [{ ...slot, exportName: "Missing" }]; render(); expect(container.textContent).toBe("Built-in organizations"); + vi.spyOn(console, "error").mockImplementation(() => {}); + registerPluginReactComponent(slot.pluginKey, "Broken", () => { throw new Error("render failed"); }); + state.slots = [{ ...slot, exportName: "Broken" }]; render(); expect(container.textContent).toBe("Built-in organizations"); + }); + it("does not mount a new account with the previous account's company selection", () => { + register(); render(); + state.userId = "bob"; + state.companyListReady = false; + state.props = null; + render(); + expect(container.textContent).toBe("Built-in organizations"); + expect(state.props).toBeNull(); + state.companyListReady = true; + state.companyIds = ["company-b"]; + render(); + expect(container.textContent).toBe("Built-in organizations"); + expect(state.props).toBeNull(); + state.companyId = "company-b"; + render(); + expect(container.textContent).toBe("Private organizations"); + }); + it("keeps required navigation when the replacement cannot accept React callbacks", () => { + registerPluginWebComponent(slot.pluginKey, "WebSwitcher", "fixture-switcher"); + state.slots = [{ ...slot, exportName: "WebSwitcher" }]; + render(); + expect(container.textContent).toBe("Built-in organizations"); + }); + it("renders one replacement, keeps host controls, and clears state across identity changes", () => { + register(); state.mobile = true; state.collapsed = true; render(); + expect(container.textContent).toBe("Private organizations"); + const host = state.props!.organizationSwitcher; + expect(host.currentCompany).toEqual({ name: "Acme", logoUrl: "/logo" }); expect(host.collapsed).toBe(true); + flushSync(() => host.onOpenChange(true)); expect(state.props!.organizationSwitcher.open).toBe(true); + flushSync(() => host.onNavigate()); expect(state.close).toHaveBeenCalledWith(false); expect(state.props!.organizationSwitcher.open).toBe(false); + host.onSignOut(); expect(state.signOut).toHaveBeenCalledOnce(); + flushSync(() => container.querySelector("button")!.click()); render(); expect(container.textContent).toBe("selected"); + state.userId = "bob"; render(); expect(container.textContent).toBe("Private organizations"); + flushSync(() => container.querySelector("button")!.click()); state.companyId = "company-b"; render(); expect(container.textContent).toBe("Private organizations"); + }); +}); diff --git a/ui/src/components/PluginOrganizationSwitcher.tsx b/ui/src/components/PluginOrganizationSwitcher.tsx new file mode 100644 index 00000000000..19028a0d5ab --- /dev/null +++ b/ui/src/components/PluginOrganizationSwitcher.tsx @@ -0,0 +1,64 @@ +import { useState, type ReactNode } from "react"; +import type { PluginOrganizationSwitcherProps } from "@paperclipai/plugin-sdk/ui"; +import { useAccountIdentity, useCompanyListQuery } from "@/api/companies-query"; +import { useCompany } from "@/context/CompanyContext"; +import { useSidebar } from "@/context/SidebarContext"; +import { useSignOut } from "@/hooks/useSignOut"; +import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; +import { CompanyPatternIcon } from "./CompanyPatternIcon"; + +/** Optional replacement; the built-in menu stays usable throughout rollout. */ +export function PluginOrganizationSwitcher({ children, open: controlledOpen, onOpenChange }: { + children: ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const { userId, settled } = useAccountIdentity(); + const { selectedCompanyId } = useCompany(); + const companyList = useCompanyListQuery(); + // Selection is component state and can outlive its account until an effect + // clears it. Resolve it against this account's query before mounting plugins. + const selectedCompany = companyList.data?.companies.find(company => company.id === selectedCompanyId) ?? null; + const contextSettled = settled && companyList.isSuccess && !companyList.data?.unauthorized + && (selectedCompanyId === null || selectedCompany !== null); + return ( + + {children} + + ); +} + +function OrganizationSwitcher({ children, settled, companyId, company, open: controlledOpen, onOpenChange }: { + children: ReactNode; settled: boolean; companyId: string | null; + company: { name: string; issuePrefix: string; logoUrl?: string | null } | null; + open?: boolean; onOpenChange?: (open: boolean) => void; +}) { + const [internalOpen, setInternalOpen] = useState(false); + const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); + const open = controlledOpen ?? internalOpen; + const setOpen = onOpenChange ?? setInternalOpen; + function closeNavigation() { + setOpen(false); + if (isMobile) setSidebarOpen(false); + } + const signOut = useSignOut({ onSignedOut: closeNavigation }); + const { slots, errorMessage } = usePluginSlots({ slotTypes: ["organizationSwitcher"], companyId, enabled: settled }); + // Never choose an arbitrary winner for a replacement surface. + if (!settled || errorMessage || slots.length !== 1) return children; + const props: PluginOrganizationSwitcherProps = { + organizationSwitcher: { + currentCompany: company ? { name: company.name, logoUrl: company.logoUrl ?? null } : null, + collapsed: collapsed && !peeking, open, onOpenChange: setOpen, + onNavigate: closeNavigation, onSignOut: () => signOut.mutate(), signingOut: signOut.isPending, + renderIcon: (name, logoUrl, inMenu) => , + }, + }; + return ; +} diff --git a/ui/src/components/SidebarCompanyMenu.production.tsx b/ui/src/components/SidebarCompanyMenu.production.tsx index af118f53280..20d26072389 100644 --- a/ui/src/components/SidebarCompanyMenu.production.tsx +++ b/ui/src/components/SidebarCompanyMenu.production.tsx @@ -45,6 +45,8 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils"; import { useSidebar } from "../context/SidebarContext"; import { CompanyPatternIcon } from "./CompanyPatternIcon"; +import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; + interface SidebarCompanyMenuProps { open?: boolean; onOpenChange?: (open: boolean) => void; @@ -212,7 +214,15 @@ function SortableCompanyItem({ ); } -export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) { +export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) { + return ( + + + + ); +} + +function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) { const [internalOpen, setInternalOpen] = useState(false); const [isEditingOrder, setIsEditingOrder] = useState(false); const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } = diff --git a/ui/src/components/SidebarCompanyMenu.test.tsx b/ui/src/components/SidebarCompanyMenu.test.tsx index 4c07dfb19f2..8280988fdda 100644 --- a/ui/src/components/SidebarCompanyMenu.test.tsx +++ b/ui/src/components/SidebarCompanyMenu.test.tsx @@ -9,6 +9,8 @@ import { queryKeys } from "@/lib/queryKeys"; import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; import { SidebarCompanyMenu as SidebarCompanyMenuProduction } from "./SidebarCompanyMenu.production"; +vi.mock("./PluginOrganizationSwitcher", () => ({ PluginOrganizationSwitcher: ({ children }: { children: ReactNode }) => children })); + const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), signInEmail: vi.fn(), diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index 0734bb057d0..04b285b3b5f 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -45,6 +45,8 @@ import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "@/lib/utils"; import { useSidebar } from "../context/SidebarContext"; import { CompanyPatternIcon } from "./CompanyPatternIcon"; +import { PluginOrganizationSwitcher } from "./PluginOrganizationSwitcher"; + interface SidebarCompanyMenuProps { open?: boolean; onOpenChange?: (open: boolean) => void; @@ -212,7 +214,15 @@ function SortableCompanyItem({ ); } -export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) { +export function SidebarCompanyMenu(props: SidebarCompanyMenuProps = {}) { + return ( + + + + ); +} + +function BuiltinCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps) { const [internalOpen, setInternalOpen] = useState(false); const [isEditingOrder, setIsEditingOrder] = useState(false); const { companies, selectedCompany, setSelectedCompanyId, companyListUnavailable, retryCompanies } = diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index f5bf01866d2..042acaf0b47 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -701,6 +701,7 @@ export function usePluginSlots(filters: SlotFilters): UsePluginSlotsResult { type PluginSlotErrorBoundaryProps = { slot: ResolvedPluginSlot; + fallback?: ReactNode; className?: string; children: ReactNode; }; @@ -728,6 +729,7 @@ class PluginSlotErrorBoundary extends Component {this.props.slot.pluginDisplayName}: failed to render @@ -770,6 +772,10 @@ type PluginSlotMountProps = { context: PluginSlotContext; className?: string; missingBehavior?: "hidden" | "placeholder"; + /** Host-specific props; slot/context cannot be overridden. */ + componentProps?: Record; + /** Preserve required host navigation if an optional component is unavailable. */ + fallback?: ReactNode; }; /** @@ -829,6 +835,8 @@ export function PluginSlotMount({ context, className, missingBehavior = "hidden", + componentProps, + fallback, }: PluginSlotMountProps) { usePluginRegistrySubscription(); const [, forceRerender] = useState(0); @@ -852,6 +860,7 @@ export function PluginSlotMount({ }, [component, slot.pluginId]); if (!component) { + if (fallback !== undefined) return fallback; if (missingBehavior === "hidden") return null; return (
@@ -861,9 +870,9 @@ export function PluginSlotMount({ } if (component.kind === "react") { - const node = createElement(component.component, { slot, context }); + const node = createElement(component.component, { ...componentProps, slot, context }); return ( - + {className ?
{node}
: node}
@@ -871,6 +880,8 @@ export function PluginSlotMount({ ); } + if (componentProps && fallback !== undefined) return fallback; + return ( Date: Tue, 22 Sep 2026 16:56:00 -0700 Subject: [PATCH 02/11] feat(adapters): refresh current coding models and reasoning controls (#13829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its adapters supply model catalogs and reasoning controls to agent setup. > - Several provider releases are missing from the fallback catalogs. > - Some newer models also have effort levels that the UI does not offer. > - Operators need the exact supported IDs and controls when discovery is unavailable. > - This pull request updates the existing adapters from current provider documentation. > - Operators can select current coding models without entering custom IDs. ## Linked Issues or Issue Description **What existing behavior does this improve?** Model selection and reasoning controls across the existing coding-agent adapters. **Subsystem affected** Claude, Codex, Grok, Gemini, Cursor, Kimi, and OpenCode adapters; model discovery tests; agent creation and editing. **Current behavior** The catalogs omit Opus 5.5, GPT-6 Sol/Luna, Grok 4.7/4.6/4.5, current Gemini Flash models, and several Cursor/Kimi choices. Bedrock has obsolete IDs. The UI omits supported effort levels and saves Grok effort under a key the runtime does not read. **Proposed behavior** Offer verified current model IDs and model-specific efforts. Remove retired Gemini 2.0 choices. Keep configured defaults and saved model IDs. Keep runtime discovery for account-specific choices. **Reason and benefit** Catch up with provider releases through September 22, 2026. Correct the picker and runtime controls together. **Breaking changes** No database or API change. Gemini 2.0 options leave the picker after their June 1 shutdown. Existing saved IDs remain unchanged. Corrected Bedrock catalog IDs do not rewrite saved configuration. **Additional context** Supersedes the separate GPT-6 Sol PR #13830. Fable 5.1 was already merged in #12730, and GPT-6 Astra in #12851. The Grok 4.6/4.5 proposal #11324 was closed and parked by its author. This change retains the default-sentinel fix from #12062. Related discovery proposals #13127 and #13565 do not supply these catalog and effort updates. Searches found no open PR for the additional model IDs. See [the dated audit](https://github.com/paperclipai/paperclip/blob/feat/claude-opus-5-5/doc/adapter-model-audit-2026-09-22.md) for exact scope, primary sources, runtime observations, and account-specific limits. This updates existing adapters and does not duplicate planned core work. ## What Changed - Add Opus 5.5 for direct Claude and Bedrock, with a Claude Code 2.1.280 gate. Correct and extend Bedrock model IDs. - Add GPT-6 Sol/Luna and Fast mode. Offer Ultra for Astra/Sol and GPT-5.6 Sol/Terra, and Max for both Luna generations. - Add Grok 4.7/4.6/4.5, expose supported Extra High effort, and save Grok edits under `reasoningEffort`. - Add Gemini Flash 3.8/3.7/3.6/3.5, Flash Lite 3.5/3.1, and 3 Flash Preview. Remove retired 2.0 choices. - Add the current documented Cursor fallback models, including Fable 5.1, Composer 2.5, and Muse Spark 1.3. - Refresh OpenCode fallback IDs used in remote environments from its installed provider registry. - Add Kimi K3 256K. Update the existing coding alias to K2.8 Preview and enable its CLI effort settings. - Use model-specific Claude/Grok efforts in creation and editing. Clear unsupported effort when switching models. - Add catalog, CLI/ACP forwarding, compatibility, and UI persistence coverage. Record the audit and sources. ## Verification - Latest head `6e63c9ef53b54ba869cd4fb431a8570bebe289f4`: 53 CI checks passed, 2 skipped. This includes full workspace typecheck, build, and all test shards. Greptile is 5/5 with zero unresolved threads. GitHub reports no merge conflicts. - 340 focused tests passed across adapter metadata, CLI/ACP arguments, Claude version checks, Kimi effort, Grok execution, server model discovery, and UI effort selection/persistence. - `pnpm --filter @paperclipai/adapter-claude-local --filter @paperclipai/adapter-codex-local --filter @paperclipai/adapter-grok-local --filter @paperclipai/adapter-gemini-local --filter @paperclipai/adapter-kimi-local --filter @paperclipai/adapter-cursor-local --filter @paperclipai/adapter-opencode-local typecheck` — passed. The same filters with `build` passed. - `pnpm check:token-gates` and `git diff --check` — passed. - Full workspace and UI typechecks were attempted locally. They stop on existing missing `three` dependencies in `packages/shared/src/cliplab`. - Full `pnpm test:run` and `pnpm build` were not run locally. Worktree creation exhausted disk space, so a clean dependency install is not feasible on this host. Focused checks reuse existing dependencies. CI supplies full workspace verification. - No provider inference was run. Account-specific runtime model lists were inspected where available. - Manual check: select the new models in agent setup and editing. Confirm Luna has Max but no Ultra, Grok 4.7 has Extra High, and Fable 5.1 has Extra High/Max. Save Grok effort and confirm `adapterConfig.reasoningEffort` contains the selection. ## Risks - Catalog presence does not grant account access. Older CLIs and restricted accounts can reject a model. Opus 5.5 has an explicit upgrade check. - Higher effort can increase cost and latency. Existing agent defaults are unchanged. - Cursor fallback IDs come from public model documentation; the local account exposed no live catalog. Runtime discovery still adds account-specific variants. - Kimi effort remains supported only on its explicit CLI engine. This does not add effort support to its default ACP engine. - Saved obsolete Bedrock or retired Gemini IDs are not migrated automatically. ## Model Used OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The exact deployment ID and context window are not exposed to this session. ## 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 - [x] 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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/adapter-model-audit-2026-09-22.md | 81 +++++++++++++++++++ .../adapters/claude-local/src/index.test.ts | 16 +++- packages/adapters/claude-local/src/index.ts | 14 +++- .../src/server/cli-capabilities.ts | 9 +-- .../src/server/execute.remote.test.ts | 23 +++--- .../claude-local/src/server/execute.ts | 2 +- .../claude-local/src/server/models.ts | 9 ++- .../src/server/test.probe.test.ts | 10 ++- .../src/server/test.remote.test.ts | 12 ++- .../adapters/claude-local/src/server/test.ts | 6 +- .../adapters/codex-local/src/index.test.ts | 19 ++++- packages/adapters/codex-local/src/index.ts | 34 ++++++-- .../codex-local/src/server/acp.test.ts | 10 +-- .../codex-local/src/server/codex-args.test.ts | 14 ++-- .../codex-local/src/ui/build-config.test.ts | 10 +-- packages/adapters/cursor-local/src/index.ts | 12 +++ packages/adapters/gemini-local/src/index.ts | 9 ++- packages/adapters/grok-local/src/index.ts | 11 ++- .../grok-local/src/server/execute.test.ts | 13 +++ packages/adapters/kimi-local/src/index.ts | 11 ++- .../kimi-local/src/server/execute.test.ts | 6 +- packages/adapters/opencode-local/src/index.ts | 12 +++ server/src/__tests__/adapter-models.test.ts | 49 +++++++++-- .../AgentConfigForm.render.test.tsx | 17 ++++ ui/src/components/AgentConfigForm.tsx | 15 +++- ui/src/components/agent-config-primitives.tsx | 2 +- ui/src/lib/agent-setup-fields.test.ts | 21 +++++ ui/src/lib/agent-setup-fields.ts | 6 +- ui/src/lib/codex-reasoning-effort.test.ts | 12 ++- 29 files changed, 379 insertions(+), 86 deletions(-) create mode 100644 doc/adapter-model-audit-2026-09-22.md create mode 100644 ui/src/lib/agent-setup-fields.test.ts diff --git a/doc/adapter-model-audit-2026-09-22.md b/doc/adapter-model-audit-2026-09-22.md new file mode 100644 index 00000000000..26fe550ce65 --- /dev/null +++ b/doc/adapter-model-audit-2026-09-22.md @@ -0,0 +1,81 @@ +# Adapter model audit: September 22, 2026 + +This audit covers model selection in the existing coding-agent adapters. Catalog +entries identify models; provider accounts and installed CLIs determine access. +No agent defaults or saved model selections are migrated. + +| Adapter | Changes from the audit | +| --- | --- | +| Claude Code | Add Opus 5.5 and require CLI 2.1.280. Fable 5.1, Fable 5, Sonnet 5, and Mythos 5 were already listed. Expose the documented model-specific effort levels in creation and editing. | +| Claude on Bedrock | Add Opus 5.5, Opus 5, Sonnet 5, Opus 4.7, and Sonnet 4.6. Correct the obsolete `-v1` suffix on Opus 4.8 and Fable 5. Apply the Opus 5.5 version check to Bedrock IDs too. | +| Codex and the Codex runner catalog | Add GPT-6 Sol and Luna, including Fast mode. Astra was already listed. Expose efforts through Ultra for Astra, Sol, and GPT-5.6 Sol/Terra; cap both Luna generations at Max. | +| Grok Build | Add Grok 4.7, 4.6, and 4.5. Offer Extra High for 4.7/4.6. Save edited effort as `reasoningEffort`, which the runtime consumes. Keep `grok-build` as the sentinel that lets the CLI choose its default. | +| Gemini CLI | Add Flash 3.8, 3.7, 3.6, 3.5, Flash Lite 3.5/3.1, and 3 Flash Preview. Remove the retired Gemini 2.0 choices. Keep Auto and the existing 3.1 Pro and 2.5 choices. | +| Cursor | Add the current documented fallback IDs for Composer 2.5, Opus 5.5, Fable 5.1, Sonnet 5, GPT-5.6 Sol/Terra/Luna, Gemini 3.8 Flash, Muse Spark 1.3, and Grok 4.7/4.6/4.5. Runtime model discovery remains available. | +| OpenCode | Refresh the static fallback used by remote environments with GPT-6 and GPT-5.6 families, current Claude models, Gemini 3.8 Flash, and Grok 4.7. | +| Kimi Code | Add K3 256K. Relabel `kimi-for-coding` as K2.8 Preview, which replaced K2.7 under the same ID. Forward CLI effort for K2.8 Preview and both K3 variants. | + +## Sources and verification + +- [Claude Code model configuration](https://code.claude.com/docs/en/model-config) + documents the Opus 5.5 CLI requirement and supported efforts. + [Opus 5.5 specifications](https://platform.claude.com/docs/en/models/opus-5-5/overview) + and [model ID conventions](https://platform.claude.com/docs/en/about-claude/models/model-ids-and-versions) + supply direct and Bedrock IDs. Bedrock entries use the catalog's existing US + inference-profile convention; regional availability remains account-dependent. +- [OpenAI's Codex model guide](https://learn.chatgpt.com/docs/models) lists GPT-6 + Sol and Luna. The installed Codex model metadata independently lists Astra, + Sol, Luna, and all three GPT-5.6 variants with the effort sets used here. + [GPT-6 Luna specifications](https://developers.openai.com/api/docs/models/gpt-6-luna) + also document Fast pricing. Codex's Ultra setting is a CLI capability; it is + not inferred from the API's effort enum. +- [Grok 4.7](https://docs.x.ai/developers/grok-4-7) and + [Grok 4.6](https://docs.x.ai/developers/models/grok-4.6) document their IDs and + four effort levels. The installed `grok models` command had no authenticated + account and returned only its default sentinel. No inference was run. +- [Google's model catalog](https://ai.google.dev/gemini-api/docs/models) supplies + the new Gemini IDs. [The retirement schedule](https://ai.google.dev/gemini-api/docs/deprecations) + records Gemini 2.0 shutdown on June 1, 2026. Gemini 2.5 remains available to + existing users. [Gemini CLI model selection](https://geminicli.com/docs/cli/model/) + accepts an explicit model; login method and entitlement determine availability. +- [Cursor's catalog](https://cursor.com/docs/models-and-pricing) links each + model's exact ID. `cursor-agent --list-models` returned no models for the local + account, so the additions use documented IDs rather than guessed variant + suffixes. Account-specific Fast/thinking variants remain discoverable. +- [Kimi's model configuration](https://www.kimi.com/code/docs/en/kimi-code/models.html) + documents all four current IDs, K2.8's in-place alias upgrade, and effort + support. Kimi effort remains limited to the existing explicit CLI engine; + the default ACP engine's effort mapping is outside this catalog update. + +## Other adapters and restricted models + +OpenCode discovers local models, but remote environment routes use its static +fallback catalog. All twelve added provider-qualified IDs were also present in +the installed OpenCode registry. [OpenCode model configuration](https://opencode.ai/docs/models/) +documents its `provider/model` format and provider registry. + +Pi already discovers models from its runtime or provider registry. +Hermes and OpenClaw accept provider configuration without a curated model list. +Cursor Cloud obtains its account's model list from Cursor. These paths do not +need a static entry per upstream release. Local OpenCode discovery returned +current models, including Muse Spark 1.3 and Nemotron 3.5 Lightning. + +Anthropic describes Mythos 5.1 as invitation-only in the +[Fable 5.1 documentation](https://platform.claude.com/docs/en/models/fable-5-1/overview). +The public current-model catalog does not publish a selectable ID for it. Keep +account discovery and custom IDs available instead of inventing an ID. Grok +4.7 Fast is available in Grok Build and Cursor, but its exact account-specific +CLI variant ID was not exposed locally. It is not a public xAI API model. +Image, video, audio, and embedding models are outside these coding-agent pickers. + +## Prior work checked + +Fable 5.1 was already merged in [#12730](https://github.com/paperclipai/paperclip/pull/12730). +Astra was already merged in [#12851](https://github.com/paperclipai/paperclip/pull/12851). +The Grok 4.6/4.5 proposal [#11324](https://github.com/paperclipai/paperclip/pull/11324) +was closed and parked by its author. This update preserves the newer CLI-default +sentinel behavior from [#12062](https://github.com/paperclipai/paperclip/pull/12062). +Open model-discovery work such as [#13127](https://github.com/paperclipai/paperclip/pull/13127) +and [#13565](https://github.com/paperclipai/paperclip/pull/13565) is separate from +these catalog and effort corrections. No open PR covering the newly added IDs +was found before implementation. diff --git a/packages/adapters/claude-local/src/index.test.ts b/packages/adapters/claude-local/src/index.test.ts index 8add6054b56..ce8bdf5a360 100644 --- a/packages/adapters/claude-local/src/index.test.ts +++ b/packages/adapters/claude-local/src/index.test.ts @@ -1,7 +1,21 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_CLAUDE_LOCAL_MODEL, resolveClaudeModel } from "./index.js"; +import { claudeLocalReasoningEffortsForModel, DEFAULT_CLAUDE_LOCAL_MODEL, resolveClaudeModel } from "./index.js"; +import { minimumClaudeCliVersionForModel } from "./server/cli-capabilities.js"; describe("Claude model defaults", () => { + it.each(["claude-opus-5-5", "us.anthropic.claude-opus-5-5", "global.anthropic.claude-opus-5-5[1m]"])("requires a current CLI and offers all efforts for %s", (model) => { + expect(minimumClaudeCliVersionForModel(model)).toBe("2.1.280"); + expect(claudeLocalReasoningEffortsForModel(model)).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); + + it("keeps model-specific Claude reasoning limits", () => { + expect(claudeLocalReasoningEffortsForModel("claude-fable-5-1")).toContain("xhigh"); + expect(claudeLocalReasoningEffortsForModel("claude-sonnet-5")).toContain("max"); + expect(claudeLocalReasoningEffortsForModel("claude-sonnet-4-6")).toEqual(["low", "medium", "high", "max"]); + expect(claudeLocalReasoningEffortsForModel("claude-haiku-4-5")).toEqual([]); + expect(claudeLocalReasoningEffortsForModel("custom-model")).toEqual(["low", "medium", "high"]); + }); + it.each([undefined, null, "", " "])("uses Opus 5 for an unset model (%j)", (model) => { expect(DEFAULT_CLAUDE_LOCAL_MODEL).toBe("claude-opus-5"); expect(resolveClaudeModel(model)).toBe("claude-opus-5"); diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index c18d78d1b59..61ba77cabe6 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -22,6 +22,16 @@ export function resolveClaudeModel( } export const type = "claude_local"; + +export function claudeLocalReasoningEffortsForModel(model: string): readonly string[] { + const id = model.trim().replace(/\[1m\]$/, "").replace(/^(?:(?:us|eu|apac|global)\.)?anthropic\./, ""); + if (/^claude-haiku-/.test(id)) return []; + if (/^claude-(?:opus-5(?:-5)?|opus-4-[78]|sonnet-5|fable-5(?:-1)?)$/.test(id)) { + return ["low", "medium", "high", "xhigh", "max"]; + } + if (/^claude-(?:opus|sonnet)-4-6(?:-v1)?$/.test(id)) return ["low", "medium", "high", "max"]; + return ["low", "medium", "high"]; +} export const label = "Claude Code"; export const SANDBOX_INSTALL_COMMAND = "npm install -g @anthropic-ai/claude-code"; @@ -32,6 +42,7 @@ export const models = [ { id: "claude-fable-5-1", label: "Claude Fable 5.1" }, { id: "claude-fable-5", label: "Claude Fable 5" }, { id: "claude-mythos-5", label: "Claude Mythos 5" }, + { id: "claude-opus-5-5", label: "Claude Opus 5.5" }, { id: "claude-opus-5", label: "Claude Opus 5" }, { id: "claude-opus-4-7", label: "Claude Opus 4.7" }, { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, @@ -49,7 +60,7 @@ Core fields: - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime - model (string, optional): Claude model id. Missing or blank defaults to ${DEFAULT_CLAUDE_LOCAL_MODEL} in both CLI and ACP, including existing agents. Explicit model IDs and ANTHROPIC_MODEL overrides are preserved. Bedrock/Vertex without an explicit model retain their provider default. -- effort (string, optional): reasoning effort passed via --effort (low|medium|high) +- effort (string, optional): model-specific reasoning effort passed via --effort (low|medium|high; current Opus, Sonnet 5, and Fable models also support xhigh|max) - chrome (boolean, optional): pass --chrome when running Claude - promptTemplate (string, optional): run prompt template - maxTurnsPerRun (number, optional): max turns for one run @@ -77,6 +88,7 @@ Operational fields: - graceSec (number, optional): SIGTERM grace period in seconds Notes: +- Claude Opus 5.5 uses model ID \`claude-opus-5-5\` and requires Claude Code v2.1.280 or later. - filesystemScope and networkScope are spawn-level confinement and are orthogonal to Claude permission flags. Both require Bubblewrap on the host and explicit engine="cli"; default or explicit ACP is rejected because ACP confinement is not yet supported. networkScope="allowlist" injects HTTP_PROXY/HTTPS_PROXY for the CLI while its private network namespace blocks direct sockets, so every required provider/API hostname must be listed explicitly. - The Claude ACP lane requires Node >=24.11.0 and @agentclientprotocol/claude-agent-acp to be installed with this adapter package. Missing prerequisites fail both default and explicit ACP runs with an actionable setup error; the adapter never switches engines automatically. - For ACP runs, model selection is passed through ANTHROPIC_MODEL at ACP server startup; Paperclip-managed Claude permissions and ephemeral skill materialization are handled by the shared ACP engine. diff --git a/packages/adapters/claude-local/src/server/cli-capabilities.ts b/packages/adapters/claude-local/src/server/cli-capabilities.ts index 5fa3ba7cb51..70da0cd3dcb 100644 --- a/packages/adapters/claude-local/src/server/cli-capabilities.ts +++ b/packages/adapters/claude-local/src/server/cli-capabilities.ts @@ -6,11 +6,6 @@ const effortFlagSupportCache = new Map>(); export const CLAUDE_FABLE_5_1_MIN_CLI_VERSION = "2.1.251"; -const CLAUDE_FABLE_5_1_MODEL_IDS = new Set([ - "claude-fable-5-1", - "us.anthropic.claude-fable-5-1", -]); - export function claudeCommandLooksLike(command: string, expected = "claude"): boolean { const base = path.basename(command).toLowerCase(); return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; @@ -41,7 +36,9 @@ function cacheKeyForTarget(command: string, target: AdapterExecutionTarget | nul } export function minimumClaudeCliVersionForModel(model: string): string | null { - return CLAUDE_FABLE_5_1_MODEL_IDS.has(model.trim()) + const modelId = model.trim().replace(/\[1m\]$/, "").replace(/^(?:(?:us|eu|apac|global)\.)?anthropic\./, ""); + if (modelId === "claude-opus-5-5") return "2.1.280"; + return modelId === "claude-fable-5-1" ? CLAUDE_FABLE_5_1_MIN_CLI_VERSION : null; } diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index c1e776d20c1..ba7728ab7e8 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -18,7 +18,7 @@ const { signal: null, timedOut: false, stdout: args.includes("--version") - ? "2.1.251 (Claude Code)\n" + ? "2.1.280 (Claude Code)\n" : [ JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" }), JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } }), @@ -466,14 +466,14 @@ describe("claude remote execution", () => { return { args: call?.[2] ?? [], result }; } - it("passes the exact configured Fable 5.1 ID as --model on the CLI lane", async () => { + it.each(["claude-fable-5-1", "claude-opus-5-5"])("passes %s as --model on the CLI lane", async (model) => { const { args } = await executeWithModel("paperclip-claude-model-direct-", { - model: "claude-fable-5-1", + model, }); const modelFlag = args.indexOf("--model"); expect(modelFlag).toBeGreaterThanOrEqual(0); - expect(args[modelFlag + 1]).toBe("claude-fable-5-1"); + expect(args[modelFlag + 1]).toBe(model); }); it("passes the Bedrock-native Fable 5.1 ID as --model under Bedrock auth", async () => { @@ -496,27 +496,30 @@ describe("claude remote execution", () => { expect(args).not.toContain("--model"); }); - it("rejects Fable 5.1 before launch when the CLI is older than 2.1.251", async () => { + it.each([ + ["claude-fable-5-1", "2.1.251", "2.1.247"], + ["claude-opus-5-5", "2.1.280", "2.1.279"], + ])("rejects %s before launch below CLI %s", async (model, minimumVersion, detectedVersion) => { runChildProcess.mockResolvedValueOnce({ exitCode: 0, signal: null, timedOut: false, - stdout: "2.1.247 (Claude Code)\n", + stdout: `${detectedVersion} (Claude Code)\n`, stderr: "", pid: 123, startedAt: new Date().toISOString(), }); const { args, result } = await executeWithModel("paperclip-claude-model-old-cli-", { - model: "claude-fable-5-1", + model, }); expect(args).toEqual([]); expect(result.errorCode).toBe("claude_cli_version_incompatible"); - expect(result.errorMessage).toContain("requires Claude Code 2.1.251 or newer"); + expect(result.errorMessage).toContain(`${model} requires Claude Code ${minimumVersion} or newer`); expect(result.resultJson).toMatchObject({ - requiredClaudeCodeVersion: "2.1.251", - detectedClaudeCodeVersion: "2.1.247", + requiredClaudeCodeVersion: minimumVersion, + detectedClaudeCodeVersion: detectedVersion, }); }); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 83209568c04..c9387b6e079 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -1286,7 +1286,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { expect(JSON.stringify(spawnedEnv)).not.toContain("caller-proxy"); }); - it("warns without executing when runtime PATH selects a different local Claude executable", async () => { + it.each([ + ["claude-fable-5-1", "2.1.251"], + ["claude-opus-5-5", "2.1.280"], + ])("warns without executing %s when runtime PATH selects a different executable", async (model, minimumVersion) => { const runtimeDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cli-runtime-path-")); const runtimeClaudePath = path.join(runtimeDir, "claude"); await writeFile(runtimeClaudePath, "#!/bin/sh\nexit 0\n"); await chmod(runtimeClaudePath, 0o755); try { - probeResult.value = { exitCode: 0, stdout: "2.1.251 (Claude Code)\n", stderr: "" }; + probeResult.value = { exitCode: 0, stdout: `${minimumVersion} (Claude Code)\n`, stderr: "" }; const result = await testEnvironment({ companyId: "company-1", @@ -654,7 +657,7 @@ describe("claude CLI local hello probe hardening", () => { config: { engine: "cli", command: "claude", - model: "claude-fable-5-1", + model, env: { PATH: runtimeDir }, }, executionTarget: null, @@ -664,6 +667,7 @@ describe("claude CLI local hello probe hardening", () => { expect(result.status).toBe("warn"); expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_cli_version_probe_mismatch", + hint: `Ensure the runtime-selected Claude Code is ${minimumVersion} or newer. Execution will verify that exact executable before launch.`, level: "warn", })); expect(runAdapterExecutionTargetProcess).not.toHaveBeenCalled(); diff --git a/packages/adapters/claude-local/src/server/test.remote.test.ts b/packages/adapters/claude-local/src/server/test.remote.test.ts index b8cc0b36d68..9062260e027 100644 --- a/packages/adapters/claude-local/src/server/test.remote.test.ts +++ b/packages/adapters/claude-local/src/server/test.remote.test.ts @@ -141,10 +141,13 @@ describe("claude sandbox auth-missing check", () => { }); describe("claude CLI model compatibility check", () => { - it("fails before the hello probe when Fable 5.1 is configured with an older CLI", async () => { + it.each([ + ["claude-fable-5-1", "2.1.251", "2.1.247"], + ["claude-opus-5-5", "2.1.280", "2.1.279"], + ])("fails before the hello probe for %s below CLI %s", async (model, minimumVersion, detectedVersion) => { probeResult.value = { exitCode: 0, - stdout: "2.1.247 (Claude Code)\n", + stdout: `${detectedVersion} (Claude Code)\n`, stderr: "", }; @@ -154,7 +157,7 @@ describe("claude CLI model compatibility check", () => { config: { engine: "cli", command: "claude", - model: "claude-fable-5-1", + model, }, executionTarget: sandboxTarget, environmentName: "Daytona", @@ -164,7 +167,8 @@ describe("claude CLI model compatibility check", () => { expect(result.checks).toContainEqual(expect.objectContaining({ code: "claude_cli_version_incompatible", level: "error", - detail: "Detected Claude Code 2.1.247.", + message: `${model} requires Claude Code ${minimumVersion} or newer on the CLI lane.`, + detail: `Detected Claude Code ${detectedVersion}.`, })); expect(runAdapterExecutionTargetProcess).toHaveBeenCalledTimes(1); const versionCall = runAdapterExecutionTargetProcess.mock.calls[0] as unknown as [ diff --git a/packages/adapters/claude-local/src/server/test.ts b/packages/adapters/claude-local/src/server/test.ts index 8677ad1761a..b7cf5001b4a 100644 --- a/packages/adapters/claude-local/src/server/test.ts +++ b/packages/adapters/claude-local/src/server/test.ts @@ -266,9 +266,9 @@ export async function testEnvironment( code: "claude_cli_version_probe_mismatch", level: "warn", message: - "Skipped Fable 5.1 readiness probing because the runtime PATH selects a different Claude executable than the trusted local Test probe.", + `Skipped ${configuredModel} readiness probing because the runtime PATH selects a different Claude executable than the trusted local Test probe.`, hint: - "Ensure the runtime-selected Claude Code is 2.1.251 or newer. Execution will verify that exact executable before launch.", + `Ensure the runtime-selected Claude Code is ${minimumCliVersion} or newer. Execution will verify that exact executable before launch.`, }); } else if (canRunProbe && minimumCliVersion && versionProbeCommand) { const versionProbeEnv = localProbe?.env ?? env; @@ -289,7 +289,7 @@ export async function testEnvironment( checks.push({ code: "claude_cli_version_incompatible", level: "error", - message: `Claude Fable 5.1 requires Claude Code ${minimumCliVersion} or newer on the CLI lane.`, + message: `${configuredModel} requires Claude Code ${minimumCliVersion} or newer on the CLI lane.`, detail: detectedCliVersion ? `Detected Claude Code ${detectedCliVersion}.` : "Could not determine the installed Claude Code version.", diff --git a/packages/adapters/codex-local/src/index.test.ts b/packages/adapters/codex-local/src/index.test.ts index c70529e6daf..855a0c06db4 100644 --- a/packages/adapters/codex-local/src/index.test.ts +++ b/packages/adapters/codex-local/src/index.test.ts @@ -14,21 +14,24 @@ describe("codex local adapter metadata", () => { // Default to the concrete gpt-5.6-sol slug — Codex ships no metadata for the bare gpt-5.6 // alias, so it must not be advertised or used as the default (it triggers a fallback warning). expect(DEFAULT_CODEX_LOCAL_MODEL).toBe("gpt-5.6-sol"); - expect(modelIds.slice(0, 4)).toEqual([ + expect(modelIds.slice(0, 6)).toEqual([ "gpt-5.6-sol", "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-terra", "gpt-5.6-luna", ]); expect(modelIds).not.toContain("gpt-5.6"); expect(isCodexLocalFastModeSupported(DEFAULT_CODEX_LOCAL_MODEL)).toBe(true); expect(isCodexLocalFastModeSupported("gpt-6-astra")).toBe(true); + expect(isCodexLocalFastModeSupported("gpt-6-sol")).toBe(true); expect(modelIds).not.toContain("gpt-5.3-codex"); expect(modelIds).not.toContain("gpt-5.3-codex-spark"); }); - it("uses the reasoning efforts advertised for GPT-6 Astra", () => { - expect(codexLocalReasoningEffortsForModel("gpt-6-astra")).toEqual([ + it.each(["gpt-6-astra", "gpt-6-sol", " gpt-6-sol ", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6"])("uses the reasoning efforts advertised for %s", (model) => { + expect(codexLocalReasoningEffortsForModel(model)).toEqual([ "low", "medium", "high", @@ -36,7 +39,10 @@ describe("codex local adapter metadata", () => { "max", "ultra", ]); - expect(codexLocalReasoningEffortsForModel("gpt-5.6-sol")).toEqual([ + }); + + it.each(["gpt-5.5", "custom-model"])("preserves legacy efforts for %s", (model) => { + expect(codexLocalReasoningEffortsForModel(model)).toEqual([ "minimal", "low", "medium", @@ -45,6 +51,11 @@ describe("codex local adapter metadata", () => { ]); }); + it.each(["gpt-6-luna", "gpt-5.6-luna"])("caps %s at max and supports Fast mode", (model) => { + expect(codexLocalReasoningEffortsForModel(model)).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(isCodexLocalFastModeSupported(model)).toBe(true); + }); + it("normalizes the legacy bare gpt-5.6 alias to the concrete gpt-5.6-sol slug", () => { expect(normalizeCodexModel("gpt-5.6")).toBe("gpt-5.6-sol"); expect(normalizeCodexModel(" gpt-5.6 ")).toBe("gpt-5.6-sol"); diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 4bd62ddeffc..3cb69dc4ce9 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -14,6 +14,8 @@ export const DEFAULT_CODEX_LOCAL_MODEL = PAPERCLIP_RUNNER_DEFAULT_MODELS.codex; export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true; export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = [ "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", @@ -41,18 +43,22 @@ const CODEX_LOCAL_DEFAULT_REASONING_EFFORTS = [ "xhigh", ] as const; -const CODEX_LOCAL_ASTRA_REASONING_EFFORTS = [ +const CODEX_LOCAL_MAX_REASONING_EFFORTS = [ "low", "medium", "high", "xhigh", "max", +] as const; + +const CODEX_LOCAL_ULTRA_REASONING_EFFORTS = [ + ...CODEX_LOCAL_MAX_REASONING_EFFORTS, "ultra", ] as const; export type CodexLocalReasoningEffort = | (typeof CODEX_LOCAL_DEFAULT_REASONING_EFFORTS)[number] - | (typeof CODEX_LOCAL_ASTRA_REASONING_EFFORTS)[number]; + | (typeof CODEX_LOCAL_ULTRA_REASONING_EFFORTS)[number]; export function normalizeCodexModel(model: string | null | undefined): string { const normalizedModel = normalizeModelId(model); @@ -62,9 +68,19 @@ export function normalizeCodexModel(model: string | null | undefined): string { export function codexLocalReasoningEffortsForModel( model: string | null | undefined, ): readonly CodexLocalReasoningEffort[] { - return normalizeCodexModel(model) === "gpt-6-astra" - ? CODEX_LOCAL_ASTRA_REASONING_EFFORTS - : CODEX_LOCAL_DEFAULT_REASONING_EFFORTS; + const normalizedModel = normalizeCodexModel(model); + switch (normalizedModel) { + case "gpt-6-astra": + case "gpt-6-sol": + case "gpt-5.6-sol": + case "gpt-5.6-terra": + return CODEX_LOCAL_ULTRA_REASONING_EFFORTS; + case "gpt-6-luna": + case "gpt-5.6-luna": + return CODEX_LOCAL_MAX_REASONING_EFFORTS; + default: + return CODEX_LOCAL_DEFAULT_REASONING_EFFORTS; + } } export function isCodexLocalKnownModel(model: string | null | undefined): boolean { @@ -94,6 +110,8 @@ export const models = [ // DEFAULT_CODEX_LOCAL_MODEL is gpt-5.6-sol, so it doubles as the first (default) 5.6 entry. { id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL }, { id: "gpt-6-astra", label: "gpt-6-astra" }, + { id: "gpt-6-sol", label: "gpt-6-sol" }, + { id: "gpt-6-luna", label: "gpt-6-luna" }, { id: "gpt-5.6-terra", label: "gpt-5.6-terra" }, { id: "gpt-5.6-luna", label: "gpt-5.6-luna" }, { id: "gpt-5.4", label: "gpt-5.4" }, @@ -116,10 +134,10 @@ Core fields: - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime - model (string, optional): Codex model id -- modelReasoningEffort (string, optional): reasoning effort override passed via -c model_reasoning_effort=...; GPT-6 Astra supports low|medium|high|xhigh|max|ultra +- modelReasoningEffort (string, optional): reasoning effort override passed via -c model_reasoning_effort=...; GPT-6 Astra/Sol and GPT-5.6 Sol/Terra support low|medium|high|xhigh|max|ultra; GPT-6 Luna and GPT-5.6 Luna support low|medium|high|xhigh|max - promptTemplate (string, optional): run prompt template - search (boolean, optional): run codex with --search -- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-6 Astra, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and passed through for manual model IDs +- fastMode (boolean, optional): enable Codex Fast mode; supported on GPT-6 (astra/sol/luna), GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and passed through for manual model IDs - dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag - command (string, optional): defaults to "codex" - extraArgs (string[], optional): additional CLI args @@ -150,7 +168,7 @@ Notes: - Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. For new and updated agents, Paperclip assigns an isolated managed home at ~/.paperclip/instances//companies//agents//codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead. - New and updated codex_local agents persist an empty OPENAI_API_KEY override by default so a host-level OPENAI_API_KEY cannot leak into Codex runs through process inheritance. Explicit CODEX_HOME overrides must not point at the shared company codex-home, $CODEX_HOME, or ~/.codex. - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). -- Fast mode is supported on GPT-6 Astra, GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. +- Fast mode is supported on GPT-6 (astra/sol/luna), GPT-5.6 (sol/terra/luna), GPT-5.5, GPT-5.4 and manual model IDs. When enabled for those models, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. - The ACP engine keeps its workspace sandbox and enables network access on each turn. Explicit sandbox_workspace_write.network_access overrides in extraArgs (or env.PAPERCLIP_CODEX_ACP_NETWORK_ACCESS="false") disable it; execution-target network denial wins. The bundled ACP patch is needed because upstream mode presets override Codex config.toml on every turn. - The CLI engine defaults to a writable workspace sandbox with network access for unattended work and Paperclip API calls. It does not enable the dangerous bypass flag. Explicit sandbox modes/profiles and network overrides in extraArgs retain their meaning. An execution-target network denial remains enforced. diff --git a/packages/adapters/codex-local/src/server/acp.test.ts b/packages/adapters/codex-local/src/server/acp.test.ts index e98a1881e4f..eaa8c3dfe2e 100644 --- a/packages/adapters/codex-local/src/server/acp.test.ts +++ b/packages/adapters/codex-local/src/server/acp.test.ts @@ -508,15 +508,15 @@ describe("codex_local ACP lane", () => { }); }); - it("forwards GPT-6 Astra controls to the ACPX Codex target", () => { + it.each([["gpt-6-astra", "ultra"], ["gpt-6-sol", "ultra"], ["gpt-6-luna", "max"], ["gpt-5.6-sol", "ultra"], ["gpt-5.6-terra", "ultra"], ["gpt-5.6-luna", "max"]])("forwards %s controls to the ACPX Codex target", (model, effort) => { expect(buildCodexAcpConfig({ engine: "acp", - model: "gpt-6-astra", - modelReasoningEffort: "ultra", + model, + modelReasoningEffort: effort, fastMode: true, })).toMatchObject({ - model: "gpt-6-astra", - modelReasoningEffort: "ultra", + model, + modelReasoningEffort: effort, fastMode: true, }); }); diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts index 0cdfa266db4..266f987dc86 100644 --- a/packages/adapters/codex-local/src/server/codex-args.test.ts +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -9,14 +9,14 @@ describe("buildCodexExecArgs", () => { if (resumeSessionId) expect(args.slice(-3)).toEqual(["resume", resumeSessionId, "-"]); }); - it("forwards GPT-6 Astra, its ultra reasoning effort, and fast mode", () => { + it.each([["gpt-6-astra", "ultra"], ["gpt-6-sol", "ultra"], ["gpt-6-luna", "max"], ["gpt-5.6-sol", "ultra"], ["gpt-5.6-terra", "ultra"], ["gpt-5.6-luna", "max"]])("forwards %s, its supported reasoning effort, and fast mode", (model, effort) => { const result = buildCodexExecArgs({ - model: "gpt-6-astra", - modelReasoningEffort: "ultra", + model, + modelReasoningEffort: effort, fastMode: true, }); - expect(result.model).toBe("gpt-6-astra"); + expect(result.model).toBe(model); expect(result.fastModeApplied).toBe(true); expect(result.fastModeIgnoredReason).toBeNull(); expect(result.args).toEqual([ @@ -24,9 +24,9 @@ describe("buildCodexExecArgs", () => { "--json", "--dangerously-bypass-approvals-and-sandbox", "--model", - "gpt-6-astra", + model, "-c", - 'model_reasoning_effort="ultra"', + `model_reasoning_effort="${effort}"`, "-c", 'service_tier="fast"', "-c", @@ -148,7 +148,7 @@ describe("buildCodexExecArgs", () => { expect(result.fastModeRequested).toBe(true); expect(result.fastModeApplied).toBe(false); expect(result.fastModeIgnoredReason).toContain( - "currently only supported on gpt-6-astra, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4 or manually configured model IDs", + "currently only supported on gpt-6-astra, gpt-6-sol, gpt-6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5, gpt-5.4 or manually configured model IDs", ); expect(result.args).toEqual([ "exec", diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts index d8736bd94d4..b9338a3e5cb 100644 --- a/packages/adapters/codex-local/src/ui/build-config.test.ts +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -63,18 +63,18 @@ describe("buildCodexLocalConfig", () => { }); }); - it("persists the exact GPT-6 Astra model and supported controls", () => { + it.each([["gpt-6-astra", "ultra"], ["gpt-6-sol", "ultra"], ["gpt-6-luna", "max"], ["gpt-5.6-sol", "ultra"], ["gpt-5.6-terra", "ultra"], ["gpt-5.6-luna", "max"]])("persists the exact %s model and supported controls", (model, effort) => { const config = buildCodexLocalConfig( makeValues({ - model: "gpt-6-astra", - thinkingEffort: "ultra", + model, + thinkingEffort: effort, fastMode: true, }), ); expect(config).toMatchObject({ - model: "gpt-6-astra", - modelReasoningEffort: "ultra", + model, + modelReasoningEffort: effort, fastMode: true, }); }); diff --git a/packages/adapters/cursor-local/src/index.ts b/packages/adapters/cursor-local/src/index.ts index 0a78423c4f1..a10618c6401 100644 --- a/packages/adapters/cursor-local/src/index.ts +++ b/packages/adapters/cursor-local/src/index.ts @@ -14,6 +14,18 @@ export const DEFAULT_CURSOR_LOCAL_MODEL = "auto"; const CURSOR_FALLBACK_MODEL_IDS = [ "auto", + "composer-2.5", + "claude-opus-5-5", + "claude-fable-5-1", + "claude-sonnet-5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gemini-3.8-flash", + "muse-spark-1.3", + "grok-4.7", + "grok-4.6", + "grok-4.5", "composer-1.5", "composer-1", "gpt-5.3-codex-low", diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts index 4ac6b8215da..d71ce497e73 100644 --- a/packages/adapters/gemini-local/src/index.ts +++ b/packages/adapters/gemini-local/src/index.ts @@ -13,11 +13,16 @@ export const models = [ { id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" }, { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" }, { id: "gemini-3.1-pro-preview-customtools", label: "Gemini 3.1 Pro Preview (Custom Tools)" }, + { id: "gemini-3.8-flash", label: "Gemini 3.8 Flash" }, + { id: "gemini-3.7-flash", label: "Gemini 3.7 Flash" }, + { id: "gemini-3.6-flash", label: "Gemini 3.6 Flash" }, + { id: "gemini-3.5-flash", label: "Gemini 3.5 Flash" }, + { id: "gemini-3.5-flash-lite", label: "Gemini 3.5 Flash Lite" }, + { id: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite" }, + { id: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" }, { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, { id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, - { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, - { id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, ]; export const agentConfigurationDoc = `# gemini_local agent configuration diff --git a/packages/adapters/grok-local/src/index.ts b/packages/adapters/grok-local/src/index.ts index 8c669ad8077..f296d574011 100644 --- a/packages/adapters/grok-local/src/index.ts +++ b/packages/adapters/grok-local/src/index.ts @@ -5,8 +5,17 @@ export const DEFAULT_GROK_LOCAL_MODEL = "grok-build"; export const models = [ { id: DEFAULT_GROK_LOCAL_MODEL, label: DEFAULT_GROK_LOCAL_MODEL }, + { id: "grok-4.7", label: "Grok 4.7" }, + { id: "grok-4.6", label: "Grok 4.6" }, + { id: "grok-4.5", label: "Grok 4.5" }, ]; +export function grokLocalReasoningEffortsForModel(model: string): readonly string[] { + return model.trim() === "grok-4.7" || model.trim() === "grok-4.6" + ? ["low", "medium", "high", "xhigh"] + : ["low", "medium", "high"]; +} + export const agentConfigurationDoc = `# grok_local agent configuration Adapter: grok_local @@ -27,7 +36,7 @@ Core fields: - promptTemplate (string, optional): run prompt template - model (string, optional): Grok model id. Defaults to grok-build. - permissionMode (string, optional): Grok permission mode passed via \`--permission-mode\`. Unset by default: Grok >= 1.0 enforces \`dontAsk\` as deny-by-default and it overrides \`--always-approve\`, so unattended runs rely on \`--always-approve\` alone unless you explicitly need a mode -- reasoningEffort (string, optional): Grok reasoning effort passed via \`--reasoning-effort\` +- reasoningEffort (string, optional): Grok reasoning effort (low|medium|high; grok-4.7 and grok-4.6 also accept xhigh) passed via \`--reasoning-effort\` - maxTurns (number, optional): maximum agent turns for the run - command (string, optional): defaults to "grok" - extraArgs (string[], optional): additional CLI args diff --git a/packages/adapters/grok-local/src/server/execute.test.ts b/packages/adapters/grok-local/src/server/execute.test.ts index 5b0356a7cbe..7cd6e07fb9d 100644 --- a/packages/adapters/grok-local/src/server/execute.test.ts +++ b/packages/adapters/grok-local/src/server/execute.test.ts @@ -160,6 +160,19 @@ async function makeCtx(runId: string, cwd: string): Promise { + it.each(["grok-4.7", "grok-4.6"])("forwards the explicit %s model and xhigh effort", async (model) => { + const root = await makeTempRoot(); + const ctx = await makeCtx("model-selection", root); + ctx.config = { cwd: root, model, reasoningEffort: "xhigh" }; + runProcessMock.mockResolvedValue(makeSuccessfulRunResult()); + + await execute(ctx); + + const args = runProcessMock.mock.calls[0][3] as string[]; + expect(args[args.indexOf("--model") + 1]).toBe(model); + expect(args[args.indexOf("--reasoning-effort") + 1]).toBe("xhigh"); + }); + beforeEach(() => { mocks.state.isRemote = false; mocks.state.prepareRuntimeResult = null; diff --git a/packages/adapters/kimi-local/src/index.ts b/packages/adapters/kimi-local/src/index.ts index 2ad83521aed..6a87c0b9d38 100644 --- a/packages/adapters/kimi-local/src/index.ts +++ b/packages/adapters/kimi-local/src/index.ts @@ -8,9 +8,10 @@ export const SANDBOX_INSTALL_COMMAND = buildSandboxNpmInstallCommand("@moonshot- export const DEFAULT_KIMI_LOCAL_MODEL = "kimi-code/kimi-for-coding"; export const models = [ - { id: DEFAULT_KIMI_LOCAL_MODEL, label: "K2.7 Coding" }, + { id: DEFAULT_KIMI_LOCAL_MODEL, label: "K2.8 Preview" }, { id: "kimi-code/kimi-for-coding-highspeed", label: "K2.7 Coding Highspeed" }, { id: "kimi-code/k3", label: "K3" }, + { id: "kimi-code/k3-256k", label: "K3 (256K)" }, ]; /** @@ -26,7 +27,11 @@ export type KimiEffort = (typeof KIMI_SUPPORTED_EFFORTS)[number]; * Models that advertise `support_efforts` in Kimi's model catalog. Keep in * sync with `models` above; only these accept KIMI_MODEL_THINKING_EFFORT. */ -export const EFFORT_CAPABLE_MODELS = new Set(["kimi-code/k3"]); +export const EFFORT_CAPABLE_MODELS = new Set([ + DEFAULT_KIMI_LOCAL_MODEL, + "kimi-code/k3", + "kimi-code/k3-256k", +]); export function modelSupportsEffort(model: string): boolean { return EFFORT_CAPABLE_MODELS.has(model.trim()); @@ -65,7 +70,7 @@ Core fields: - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt. Sibling files in the same directory (HEARTBEAT.md, SOUL.md, TOOLS.md) are made readable via --add-dir for local runs. - promptTemplate (string, optional): run prompt template - model (string, optional): Kimi model alias (provider/model). Defaults to kimi-code/kimi-for-coding. -- effort (string, optional): thinking effort (low | medium | high | max). CLI lane only (engine=cli): forwarded as KIMI_MODEL_THINKING_EFFORT for effort-capable models (currently kimi-code/k3); "medium" maps to "high" since Kimi has no medium tier. Ignored for models without support_efforts, and NOT forwarded on the default ACP engine lane (Kimi ACP exposes a separate "thinking" option that is not wired yet) — pin engine=cli when effort control matters. +- effort (string, optional): thinking effort (low | medium | high | max). CLI lane only (engine=cli): forwarded as KIMI_MODEL_THINKING_EFFORT for effort-capable models (K2.8 Preview, K3, and K3 256K); "medium" maps to "high" since Kimi has no medium tier. Ignored for models without support_efforts, and NOT forwarded on the default ACP engine lane (Kimi ACP exposes a separate "thinking" option that is not wired yet) — pin engine=cli when effort control matters. - command (string, optional): defaults to "kimi" - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables diff --git a/packages/adapters/kimi-local/src/server/execute.test.ts b/packages/adapters/kimi-local/src/server/execute.test.ts index f005916d8ab..7d4b5aff4e4 100644 --- a/packages/adapters/kimi-local/src/server/execute.test.ts +++ b/packages/adapters/kimi-local/src/server/execute.test.ts @@ -333,7 +333,7 @@ describe("kimi_local execute", () => { expect(seenEnv.TERM).toBe("xterm-256color"); }); - it("forwards configured effort as KIMI_MODEL_THINKING_EFFORT for effort-capable models", async () => { + it.each(["kimi-code/k3", "kimi-code/k3-256k", "kimi-code/kimi-for-coding"])("forwards configured effort for %s", async (model) => { const root = await makeTempRoot(); let seenEnv: Record = {}; runProcessMock.mockImplementation(async (_runId, _target, _command, _args, options) => { @@ -341,7 +341,7 @@ describe("kimi_local execute", () => { return { exitCode: 0, signal: null, timedOut: false, stdout: KIMI_STDOUT, stderr: "" }; }); - await execute(makeContext(root, { config: { cwd: root, model: "kimi-code/k3", effort: "high" } })); + await execute(makeContext(root, { config: { cwd: root, model, effort: "high" } })); expect(seenEnv.KIMI_MODEL_THINKING_EFFORT).toBe("high"); }); @@ -368,7 +368,7 @@ describe("kimi_local execute", () => { }); await execute(makeContext(root, { - config: { cwd: root, model: "kimi-code/kimi-for-coding", effort: "high" }, + config: { cwd: root, model: "kimi-code/kimi-for-coding-highspeed", effort: "high" }, })); expect(seenEnv.KIMI_MODEL_THINKING_EFFORT).toBeUndefined(); diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index ca121e91252..0c02ab9ba18 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -53,6 +53,18 @@ export function isValidOpenCodeModelId(value: unknown): value is string { export const models: Array<{ id: string; label: string }> = [ { id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL }, + { id: "openai/gpt-6-astra", label: "openai/gpt-6-astra" }, + { id: "openai/gpt-6-sol", label: "openai/gpt-6-sol" }, + { id: "openai/gpt-6-luna", label: "openai/gpt-6-luna" }, + { id: "openai/gpt-5.6-sol", label: "openai/gpt-5.6-sol" }, + { id: "openai/gpt-5.6-terra", label: "openai/gpt-5.6-terra" }, + { id: "openai/gpt-5.6-luna", label: "openai/gpt-5.6-luna" }, + { id: "anthropic/claude-opus-5-5", label: "anthropic/claude-opus-5-5" }, + { id: "anthropic/claude-opus-5", label: "anthropic/claude-opus-5" }, + { id: "anthropic/claude-fable-5-1", label: "anthropic/claude-fable-5-1" }, + { id: "anthropic/claude-sonnet-5", label: "anthropic/claude-sonnet-5" }, + { id: "google/gemini-3.8-flash", label: "google/gemini-3.8-flash" }, + { id: "xai/grok-4.7", label: "xai/grok-4.7" }, { id: "openai/gpt-5.5", label: "openai/gpt-5.5" }, { id: "openai/gpt-5.4", label: "openai/gpt-5.4" }, { id: "openai/gpt-5.4-mini", label: "openai/gpt-5.4-mini" }, diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index 3b84c652a67..e35ba1b4471 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -71,6 +71,7 @@ describe("adapter model listing", () => { expect(models.some((model) => model.id === "claude-mythos-5")).toBe(true); // Opus 5 is a current GA flagship and must be offered even when live discovery is unavailable. expect(models.some((model) => model.id === "claude-opus-5")).toBe(true); + expect(models).toContainEqual({ id: "claude-opus-5-5", label: "Claude Opus 5.5" }); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -93,6 +94,7 @@ describe("adapter model listing", () => { expect(first).toEqual(second); expect(first.some((model) => model.id === "claude-opus-4-8-20260529")).toBe(true); expect(first.some((model) => model.id === "claude-opus-4-8")).toBe(true); + expect(first.some((model) => model.id === "claude-opus-5-5")).toBe(true); }); it("refreshes cached claude models on demand", async () => { @@ -131,18 +133,21 @@ describe("adapter model listing", () => { expect(models).toEqual(claudeFallbackModels); }); - it("does not duplicate claude-fable-5-1 when discovery returns the identical ID", async () => { + it.each([ + ["claude-fable-5-1", "Claude Fable 5.1"], + ["claude-opus-5-5", "Claude Opus 5.5"], + ])("does not duplicate %s when discovery returns the identical ID", async (id, displayName) => { process.env.ANTHROPIC_API_KEY = "sk-ant-test"; vi.spyOn(globalThis, "fetch").mockResolvedValue({ ok: true, json: async () => ({ - data: [{ id: "claude-fable-5-1", display_name: "Claude Fable 5.1" }], + data: [{ id, display_name: displayName }], }), } as Response); const models = await listAdapterModels("claude_local"); - expect(models.filter((model) => model.id === "claude-fable-5-1")).toHaveLength(1); + expect(models.filter((model) => model.id === id)).toEqual([{ id, label: displayName }]); // Curated fallbacks discovery did not return are still merged in. expect(models.some((model) => model.id === "claude-fable-5")).toBe(true); expect(models.some((model) => model.id === "claude-opus-4-8")).toBe(true); @@ -154,13 +159,42 @@ describe("adapter model listing", () => { const models = await listAdapterModels("claude_local"); - // The Bedrock default (first entry) is unchanged. - expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8-v1"); - expect(models.some((model) => model.id === "us.anthropic.claude-fable-5-1")).toBe(true); + // Keep Opus 4.8 first, using its documented dateless Bedrock ID. + expect(models[0]?.id).toBe("us.anthropic.claude-opus-4-8"); + expect(models.map((model) => model.id)).toEqual(expect.arrayContaining([ + "us.anthropic.claude-opus-5-5", "us.anthropic.claude-opus-5", "us.anthropic.claude-sonnet-5", + "us.anthropic.claude-fable-5-1", "us.anthropic.claude-opus-4-7", "us.anthropic.claude-sonnet-4-6", + ])); + expect(models.map((model) => model.id)).not.toEqual(expect.arrayContaining(["us.anthropic.claude-opus-4-8-v1"])); expect(models.some((model) => model.id === "claude-fable-5-1")).toBe(false); expect(fetchSpy).not.toHaveBeenCalled(); }); + it.each([ + ["gemini_local", ["gemini-3.8-flash", "gemini-3.7-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-flash-lite", "gemini-3-flash-preview"]], + ["grok_local", ["grok-build", "grok-4.7", "grok-4.6", "grok-4.5"]], + ["kimi_local", ["kimi-code/kimi-for-coding", "kimi-code/k3", "kimi-code/k3-256k"]], + ])("lists current %s models without a provider login", async (adapter, expectedIds) => { + const models = await listAdapterModels(adapter as string); + expect(models.map((model) => model.id)).toEqual(expect.arrayContaining(expectedIds as string[])); + expect(new Set(models.map((model) => model.id)).size).toBe(models.length); + if (adapter === "gemini_local") { + expect(models.some((model) => model.id.startsWith("gemini-2.0-"))).toBe(false); + } + if (adapter === "kimi_local") { + expect(models).toContainEqual({ id: "kimi-code/kimi-for-coding", label: "K2.8 Preview" }); + } + }); + + it("includes current Cursor fallbacks when runtime discovery is unavailable", async () => { + setCursorModelsRunnerForTests(() => ({ status: 1, stdout: "", stderr: "", hasError: true })); + const models = await listAdapterModels("cursor"); + expect(models.map((model) => model.id)).toEqual(expect.arrayContaining([ + "composer-2.5", "claude-opus-5-5", "claude-fable-5-1", "claude-sonnet-5", + "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "grok-4.7", "gemini-3.8-flash", "muse-spark-1.3", + ])); + }); + it("loads codex models dynamically and merges fallback options", async () => { process.env.OPENAI_API_KEY = "sk-test"; const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ @@ -232,12 +266,13 @@ describe("adapter model listing", () => { expect(models).toEqual(cursorFallbackModels); }); - it("returns opencode fallback models including gpt-5.4", async () => { + it("returns current provider-qualified OpenCode models when discovery is unavailable", async () => { process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; const models = await listAdapterModels("opencode_local"); expect(models).toEqual(opencodeFallbackModels); + expect(models.map((model) => model.id)).toEqual(expect.arrayContaining(["openai/gpt-6-astra", "openai/gpt-6-sol", "openai/gpt-6-luna", "openai/gpt-5.6-sol", "openai/gpt-5.6-terra", "openai/gpt-5.6-luna", "anthropic/claude-opus-5-5", "anthropic/claude-opus-5", "anthropic/claude-fable-5-1", "anthropic/claude-sonnet-5", "google/gemini-3.8-flash", "xai/grok-4.7"])); }); it("loads cursor models dynamically and caches them", async () => { diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index fb3715d9e54..e31aee17ba2 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -784,6 +784,23 @@ describe("AgentConfigForm environment selector", () => { expect(result.onSave.mock.calls[0][0].adapterConfig.effort).toBeUndefined(); }); + it("saves Grok 4.7 reasoning effort using the runtime key", async () => { + const result = await renderForm([], { adapterType: "grok_local", adapterConfig: { model: "grok-4.7", reasoningEffort: "high" } }); + roots.push(result.root); + const effort = [...result.container.querySelectorAll("button")].find(button => button.textContent?.trim() === "High")!; + expect(effort).toBeTruthy(); + await act(async () => effort.click()); + await flushReact(); + const xhigh = [...document.querySelectorAll("button")].find(button => button.textContent?.trim() === "X-Highxhigh")!; + expect(xhigh).toBeTruthy(); + await act(async () => xhigh.click()); + await flushReact(); + const save = [...result.container.querySelectorAll("button")].find(button => button.textContent?.trim() === "Save")!; + await act(async () => save.click()); + expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ reasoningEffort: "xhigh" }) })); + expect(result.onSave.mock.calls[0][0].adapterConfig.effort).toBeUndefined(); + }); + it("hides the environment override when Local is the only configured environment", async () => { const result = await renderForm([ makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index fb4e0b4d122..8556e8d5146 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -1,6 +1,7 @@ import { AiConnectionField } from "./ai-connections/AiConnectionField"; import { aiConnectionBindingSchema } from "@paperclipai/shared"; import { testAgentSetup } from "@/lib/test-agent-setup"; +import { setupEfforts } from "../lib/agent-setup-fields"; import { RuntimeTestCard } from "./RuntimeTestCard"; import { useState, useEffect, useRef, useMemo, useCallback, Children, isValidElement, type ReactNode } from "react"; import type { AdapterConfigSection } from "../adapters/types"; @@ -1268,6 +1269,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "mode" : adapterType === "opencode_local" ? "variant" + : adapterType === "grok_local" ? "reasoningEffort" : adapterType === "pi_local" ? "thinking" : "effort"; const thinkingEffortOptions = adapterType === "codex_local" @@ -1283,7 +1285,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? kimiThinkingEffortOptions : adapterType === "pi_local" ? [{ id: "", label: "Auto" }, ...["off", "minimal", "low", "medium", "high", "xhigh"].map(id => ({ id, label: id }))] - : claudeThinkingEffortOptions; + : adapterType === "claude_local" || adapterType === "grok_local" + ? [{ id: "", label: "Auto" }, ...setupEfforts(adapterType, currentModelId).map((id) => ({ + id, + label: id === "xhigh" ? "X-High" : id[0].toUpperCase() + id.slice(1), + }))] + : claudeThinkingEffortOptions; const currentThinkingEffort = isCreate ? val!.thinkingEffort : adapterType === "codex_local" @@ -1713,10 +1720,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) { models={models} value={currentModelId} onChange={(v) => { - const supportedEfforts = codexReasoningEffortOptions(v, "Auto"); - const clearUnsupportedEffort = adapterType === "codex_local" + const supportedEfforts = setupEfforts(adapterType, v); + const clearUnsupportedEffort = ["codex_local", "claude_local", "grok_local"].includes(adapterType) && Boolean(currentThinkingEffort) - && !supportedEfforts.some((option) => option.value === currentThinkingEffort); + && !supportedEfforts.includes(String(currentThinkingEffort)); if (isCreate) { set!({ model: v, diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 118a53dd751..92ef43378ce 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -34,7 +34,7 @@ export const help: Record = { dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", search: "Enable Codex web search capability during runs.", - fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is supported on GPT-6 Astra, GPT-5.6, GPT-5.5, GPT-5.4, and manual Codex model IDs.", + fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is supported on GPT-6, GPT-5.6, GPT-5.5, GPT-5.4, and manual Codex model IDs.", workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.", workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.", workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.", diff --git a/ui/src/lib/agent-setup-fields.test.ts b/ui/src/lib/agent-setup-fields.test.ts new file mode 100644 index 00000000000..e0d14f7e7d8 --- /dev/null +++ b/ui/src/lib/agent-setup-fields.test.ts @@ -0,0 +1,21 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { setupEfforts } from "./agent-setup-fields"; + +describe("model-specific setup efforts", () => { + it("offers current Claude efforts without offering them on Haiku", () => { + expect(setupEfforts("claude_local", "claude-fable-5-1")).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(setupEfforts("claude_local", "claude-haiku-4-5")).toEqual([]); + }); + + it("offers xhigh on Grok 4.7 and 4.6, with the lower limit on 4.5", () => { + expect(setupEfforts("grok_local", "grok-4.7")).toEqual(["low", "medium", "high", "xhigh"]); + expect(setupEfforts("grok_local", "grok-4.6")).toContain("xhigh"); + expect(setupEfforts("grok_local", "grok-4.5")).toEqual(["low", "medium", "high"]); + }); + + it("caps Luna at max while exposing ultra on Sol", () => { + expect(setupEfforts("codex_local", "gpt-6-luna")).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(setupEfforts("codex_local", "gpt-6-sol")).toContain("ultra"); + }); +}); diff --git a/ui/src/lib/agent-setup-fields.ts b/ui/src/lib/agent-setup-fields.ts index e94adecd72c..e0c6b70b7ef 100644 --- a/ui/src/lib/agent-setup-fields.ts +++ b/ui/src/lib/agent-setup-fields.ts @@ -1,4 +1,6 @@ import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local"; +import { claudeLocalReasoningEffortsForModel, DEFAULT_CLAUDE_LOCAL_MODEL } from "@paperclipai/adapter-claude-local"; +import { grokLocalReasoningEffortsForModel } from "@paperclipai/adapter-grok-local"; import { codexReasoningEffortOptions } from "./codex-reasoning-effort"; import { PROVIDER_ENV_KEYS } from "./provider-credential"; @@ -27,7 +29,7 @@ export function setupProviderKeys(adapter: string) { export function setupEfforts(adapter: string, model = ""): string[] { switch (adapter) { case "claude_local": - return ["low", "medium", "high"]; + return [...claudeLocalReasoningEffortsForModel(model || DEFAULT_CLAUDE_LOCAL_MODEL)]; case "codex_local": return codexReasoningEffortOptions(model || DEFAULT_CODEX_LOCAL_MODEL) .map((option) => option.value) @@ -35,7 +37,7 @@ export function setupEfforts(adapter: string, model = ""): string[] { case "pi_local": return ["off", "minimal", "low", "medium", "high", "xhigh"]; case "grok_local": - return ["low", "medium", "high"]; + return [...grokLocalReasoningEffortsForModel(model)]; default: return []; } diff --git a/ui/src/lib/codex-reasoning-effort.test.ts b/ui/src/lib/codex-reasoning-effort.test.ts index 293d6fe5d37..0d7424e52bb 100644 --- a/ui/src/lib/codex-reasoning-effort.test.ts +++ b/ui/src/lib/codex-reasoning-effort.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it } from "vitest"; import { codexReasoningEffortOptions } from "./codex-reasoning-effort"; describe("codexReasoningEffortOptions", () => { - it("exposes only the supported GPT-6 Astra reasoning efforts", () => { - expect(codexReasoningEffortOptions("gpt-6-astra")).toEqual([ + it.each(["gpt-6-astra", "gpt-6-sol", "gpt-5.6-sol", "gpt-5.6-terra"])("exposes only the supported %s reasoning efforts", (model) => { + expect(codexReasoningEffortOptions(model)).toEqual([ { value: "", label: "Default" }, { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, @@ -16,8 +16,14 @@ describe("codexReasoningEffortOptions", () => { ]); }); + it.each(["gpt-6-luna", "gpt-5.6-luna"])("caps %s at Max", (model) => { + expect(codexReasoningEffortOptions(model).map((option) => option.value)).toEqual([ + "", "low", "medium", "high", "xhigh", "max", + ]); + }); + it("preserves the existing choices for other and manual models", () => { - expect(codexReasoningEffortOptions("gpt-5.6-sol").map((option) => option.value)).toEqual([ + expect(codexReasoningEffortOptions("custom-model").map((option) => option.value)).toEqual([ "", "minimal", "low", From be6f49a425bef0500a61901fd9e52ce421429665 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 22 Sep 2026 17:02:29 -0700 Subject: [PATCH 03/11] feat(runner): refresh shared coding harness runtimes (#13838) ## Thinking Path > - Paperclip runs agents through local adapters and the native runner. > - Both paths must use the same installed provider CLI. > - New models require current harness releases. > - The runner still pins Codex 0.153.4, Claude SDK 0.3.263, and OpenCode 1.18.29. > - Changing the image alone would fail the runner's exact version and executable checks. > - This pull request updates those dependencies, integrity checks, controller checks, and image pins together. > - Shared installations can then run the current models without a task-time download. ## Linked Issues or Issue Description Refs #13829, which updates model choices and reasoning controls. Searches found no open PR that updates these runtime pins. **Current behavior** The shared provider pack ships old CLIs. Claude Code 2.1.263 cannot run Opus 5.5, which requires 2.1.280. Remote controllers reject provider packs whose versions differ from their declared pins. **Proposed behavior** Use Codex 0.156.0, Claude Agent SDK 0.3.280 / Claude Code 2.1.280, and OpenCode 1.18.32 throughout the runner. Keep the reviewed ACP bridge patches and one shared CLI installation per provider. **Reason and benefit** Current harnesses support the new model IDs while preserving executable verification and remote provider-pack compatibility checks. ## What Changed - Update dependency overrides, the Codex ACP package patch, runtime profiles, and remote controller pins. - Verify the new Claude Linux x64 and macOS arm64/x64 executables and Codex Linux x64 executable against integrity-verified npm archives. - Refresh OpenCode version checks, fixtures, and the runner configuration label. - Refresh the eval image's Grok, Gemini, Kimi, Cursor, and GitHub CLI pins and archive hashes. Hermes remains current at 0.19.0. - Refresh the build-time lock digest from clean pnpm 9.15.4 resolution. Leave lockfile commits to repository automation. - Document model compatibility and the separation between CLI runtimes and patched ACP bridges. ## Verification - `pnpm -r typecheck` and `pnpm build` passed. - Rust workspace release tests passed. - Package/patch and OpenCode binary-materialization contract tests: 11 passed. - Real Codex 0.156.0 startup-ownership and paginated session-resume probes passed with isolated synthetic homes and no model turn. - Codex app-server `thread/start` preserved `gpt-6-sol` and `gpt-6-luna`; no `turn/start` was sent. An unauthenticated built-in catalog does not include those account-served entries. - Installed Claude integrity probes passed for `claude-opus-5-5` and `claude-fable-5-1`. - `pnpm --filter @paperclipai/paperclip-runner test:opencode:qualification` passed with the actual OpenCode 1.18.32 executable under Node 24 and Node 25. The loopback provider exercise covers health/version, session creation/read/delete, SSE, and a completed async prompt. - `pnpm check:token-gates` passed. - The targeted runner suite passed 130 tests. Three macOS failures in snapshot module lookup and OpenCode final-message selection also reproduce on the unchanged base; Linux CI will provide the platform check. - [Final Linux CI](https://github.com/paperclipai/paperclip/actions/runs/35798076399): all gates passed. Four jobs needed one retry after their CI workers received shutdown signals. The PR has 55 successful checks, two skipped checks, Greptile 5/5, and no unresolved review threads. - Changed runner configuration UI tests: 5 passed. - Full macOS `pnpm test:run` reached 13,094 passing server tests, 84 skipped, and 18 failures before the wrapper stopped. Failures involved skill-cache publication permissions, missing bundled connector skills in the worktree, and a conversation-reset timing case. The 10 cache permission failures reproduce on the unchanged base; both conversation-reset cases passed on a targeted retry. The wrapper did not reach its later workspace/serialized groups locally; Linux CI covers those groups. - The local Docker daemon did not respond, so no local Docker build was run. No billable model requests were made. ## Risks - Deploy the matching controller and provider pack together. Older controllers enforce their previous exact pins. - Current upstream CLIs can change behavior. Existing protocol tests and isolated real Codex probes cover the integration boundaries; authenticated model inference is not part of these checks. - ACP bridge package versions and executable digests stay unchanged because their executable bytes are unchanged. Only the underlying CLI/SDK dependencies move. - No schema migration. Revert the runtime and image pins together to roll back. ## Model Used OpenAI GPT-6 via Codex, with repository tools, code execution, and web research. The exact serving model ID and context window were not exposed by this session. ## 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 - [x] I have run tests locally and they pass for the changed surfaces and real-executable probes; full macOS-suite limitations are listed above - [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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- docker/daytona-runner/Dockerfile | 19 ++- docker/daytona-runner/README.md | 28 +++- docs/deploy/environment-variables.md | 2 +- package.json | 4 +- packages/paperclip-runner/package.json | 7 +- .../runner-core/src/acpx_provider_backend.rs | 8 +- .../crates/runner-core/src/codex_provider.rs | 2 +- .../runner-core/src/provider_backend.rs | 4 +- .../runner-core/tests/acpx_warm_attachment.rs | 2 +- .../tests/native_provider_backend.rs | 4 +- .../scripts/build-provider-pack.mjs | 4 +- .../scripts/materialize-opencode-binary.mjs | 2 +- .../materialize-opencode-binary.test.mjs | 12 +- .../scripts/qualify-opencode-runtime.test.mjs | 146 ++++++++++++++++++ .../runner-protocol-eval-campaign.test.mjs | 2 +- .../src/backends/codex-native-backend.ts | 2 +- .../backends/native-backend-factory.test.ts | 6 +- .../paperclip-runner/src/cli/eval-session.ts | 6 +- .../src/cli/opencode-app-server-proxy.ts | 2 +- .../acpx/installation-integrity.test.ts | 2 +- .../drivers/acpx/installation-integrity.ts | 24 +-- .../drivers/acpx/qualified-profiles.test.ts | 6 +- .../src/drivers/acpx/qualified-profiles.ts | 4 +- .../opencode/opencode-server-driver.test.ts | 4 +- .../opencode/opencode-server-driver.ts | 2 +- .../src/live/live-session.test.ts | 2 +- .../paperclip-runner/src/live/live-session.ts | 2 +- .../src/live/runnerd-codex-transport.ts | 2 +- .../test/acpx-codex-package-contract.test.mjs | 6 +- .../test/fixtures/fake-opencode-server.mjs | 2 +- ...agentclientprotocol__codex-acp@1.6.2.patch | 2 +- pnpm-workspace.yaml | 4 +- scripts/runner-api-eval-worker.ts | 2 +- .../tests/native-cleanup-paginated-codex.mjs | 8 +- .../tests/native-provider-startup-codex.mjs | 4 +- server/src/__tests__/adapter-registry.test.ts | 4 +- server/src/adapters/registry.ts | 2 +- .../native-session-executor.test.ts | 14 +- .../native-runtime/native-session-executor.ts | 4 +- .../native-runtime/provider-profile.ts | 2 +- .../codex-local/config-fields.test.tsx | 4 +- ui/src/adapters/codex-local/config-fields.tsx | 2 +- 42 files changed, 273 insertions(+), 97 deletions(-) create mode 100644 packages/paperclip-runner/scripts/qualify-opencode-runtime.test.mjs diff --git a/docker/daytona-runner/Dockerfile b/docker/daytona-runner/Dockerfile index cff873d3177..3f471f5f653 100644 --- a/docker/daytona-runner/Dockerfile +++ b/docker/daytona-runner/Dockerfile @@ -28,7 +28,7 @@ COPY cli/package.json ./cli/package.json # The complete resolved lock (including transitive integrity hashes) is reviewed. # Reject registry-time drift BEFORE installing packages or running lifecycle code. # Refresh this digest together with source/provider dependency changes. -ARG PAPERCLIP_RUNNER_LOCK_SHA256=4b796c312833ebf2be4c38228babc0292c76774fb40d43bd18b54bd6b205753d +ARG PAPERCLIP_RUNNER_LOCK_SHA256=57b298aceebc48bb94ea0593347348256475da7b2fddb77025d9e57cc8759420 RUN pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile \ && printf '%s pnpm-lock.yaml\n' "${PAPERCLIP_RUNNER_LOCK_SHA256}" > /tmp/provider-lock.sha256 \ && sha256sum -c /tmp/provider-lock.sha256 \ @@ -54,9 +54,9 @@ ENV PATH=${PAPERCLIP_RUNNER_PROVIDER_PACK_ROOT}/node_modules/.bin:/usr/local/sha # Never add a second CLI version to work around a runner compatibility pin. RUN npm uninstall -g @anthropic-ai/claude-code @openai/codex opencode-ai \ && npm install -g \ - @xai-official/grok@1.0.13 \ - @google/gemini-cli@0.58.0 \ - @moonshot-ai/kimi-code@0.41.0 \ + @xai-official/grok@1.0.41 \ + @google/gemini-cli@0.60.0 \ + @moonshot-ai/kimi-code@2.0.2 \ && npm cache clean --force # Hermes requires Python >=3.11,<3.14; the image's default Python is newer. @@ -67,8 +67,8 @@ RUN /usr/bin/python3 -m venv /opt/hermes \ && /opt/hermes/bin/pip install --no-cache-dir hermes-agent==0.19.0 \ && ln -sf /opt/hermes/bin/hermes /usr/local/bin/hermes -ARG CURSOR_VERSION=2026.09.02-c22c1a3 -ARG CURSOR_SHA256_AMD64=b73b59854762535c0fc20d7ccc51c3b5a356a851491088d60a362be48750f53c +ARG CURSOR_VERSION=2026.09.18-9a7762b +ARG CURSOR_SHA256_AMD64=b1308f5a2fc05458b9d8966752986bb23a971bbcc67c842c1df94c4b8132bad9 RUN set -eu; \ arch="$(dpkg --print-architecture)"; \ [ "$arch" = "amd64" ] || { echo "FATAL: cursor pin only covers amd64, not $arch" >&2; exit 1; }; \ @@ -82,8 +82,8 @@ RUN set -eu; \ ln -sf /usr/local/bin/cursor-agent /usr/local/bin/agent; \ chmod -R a+rX /opt/cursor -ARG GH_VERSION=2.100.0 -ARG GH_SHA256_AMD64=e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be +ARG GH_VERSION=2.101.0 +ARG GH_SHA256_AMD64=9bca2d1c16825f109907a23307628a2f0698fbf99662b73a5cf0b020293072b8 RUN set -eu; \ arch="$(dpkg --print-architecture)"; \ [ "$arch" = "amd64" ] || { echo "FATAL: gh pin only covers amd64, not $arch" >&2; exit 1; }; \ @@ -133,6 +133,9 @@ RUN /bin/sh -lc 'set -eu; \ for command_name in acpx claude-agent-acp codex-acp; do \ command -v "$command_name" >/dev/null || { echo "FATAL: $command_name not on PATH for daytona user" >&2; exit 1; }; \ done; \ + test "$(codex --version)" = "codex-cli 0.156.0"; \ + test "$(claude --version)" = "2.1.280 (Claude Code)"; \ + test "$(opencode --version)" = "1.18.32"; \ test "$(acpx --version)" = "0.13.1"; \ test "$(claude-agent-acp --version)" = "0.73.0"; \ test "$(codex-acp --version)" = "@agentclientprotocol/codex-acp 1.6.2"' diff --git a/docker/daytona-runner/README.md b/docker/daytona-runner/README.md index 229529b962a..dd3686a9c10 100644 --- a/docker/daytona-runner/README.md +++ b/docker/daytona-runner/README.md @@ -2,7 +2,7 @@ This image is the Paperclip Cloud fleet sandbox image plus a source-built `paperclip-runnerd` and immutable provider pack. The pack contains Node 24.11, -OpenCode 1.18.29, the compiled OpenCode proxy, ACPX 0.13.1 sidecar, qualified ACP +OpenCode 1.18.32, the compiled OpenCode proxy, ACPX 0.13.1 sidecar, qualified ACP agents, and the production lockfile. Its manifest digests each executable bridge and binds the pack to the runner source revision, avoiding artifact upload and npm installation on every fresh lease. @@ -12,8 +12,34 @@ The fleet pins are intentionally copied from Update both definitions together until the fleet base is published as a stable image that this Dockerfile can extend directly. +## Harness versions + +The September 22, 2026 refresh pins Codex 0.156.0, Claude Agent SDK +0.3.280 (Claude Code 2.1.280), and OpenCode 1.18.32 in the shared provider +pack. Claude Code 2.1.280 is the minimum for +[Opus 5.5](https://code.claude.com/docs/en/model-config); it also supports +Fable 5.1. Codex uses the current +[GPT-6 Sol and Luna model IDs](https://learn.chatgpt.com/docs/models). +Grok CLI 1.0.41 supports the current +[Grok 4.7](https://docs.x.ai/developers/grok-4-7) model family. + +Keep the patched ACP bridge versions separate from their CLI runtime pins. +Their executable digests do not change when only the runtime dependency +changes. Refresh the runtime executable digests from integrity-verified npm +release archives for every supported platform, and keep the native runner, +provider manifest, and remote controller version checks aligned. + ## Build and verify +Run `pnpm --filter @paperclipai/paperclip-runner test:opencode:qualification` +after installing dependencies to exercise the actual pinned OpenCode executable. +It checks health/version, session creation and retrieval, SSE messages, an async +prompt, and session deletion against a loopback mock provider. It uses an +isolated home, starts no paid model request, and retires its process group. +Set `PAPERCLIP_TEST_OPENCODE_BINARY` to the materialized Linux executable when +qualifying an assembled provider pack. + + The fleet image is currently amd64-only because the pinned Cursor and GitHub CLI checksums cover amd64. diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index b8ccadbdb1d..b2985bd7a95 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -24,7 +24,7 @@ All environment variables that Paperclip uses for server configuration. | `PAPERCLIP_RUNNER_CA_BUNDLE_PATH` | (unset) | Optional PEM CA bundle for direct runner WSS. Platform roots remain enabled. There is no insecure TLS bypass. | | `PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` | (host build) | Host-local path to a `paperclip-runnerd` artifact built for the remote target OS and architecture. Required when Paperclip and the remote sandbox do not share a compatible platform; build metadata and the required transport mode are verified before launch. | | `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH` | (unset) | Optional host-local path to a Codex executable built for the remote target OS and architecture. For remote Codex-backed runners, Paperclip stages and verifies this executable beside `paperclip-runnerd`. | -| `PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC` | (unset) | Optional pinned npm package spec (for example, `@openai/codex@0.153.4`) installed inside each fresh remote lease when its Codex harness is not baked into the sandbox image. Mutually exclusive with `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH`; Paperclip verifies the installed executable before starting `runnerd`. | +| `PAPERCLIP_RUNNER_REMOTE_CODEX_NPM_SPEC` | (unset) | Optional pinned npm package spec (for example, `@openai/codex@0.156.0`) installed inside each fresh remote lease when its Codex harness is not baked into the sandbox image. Mutually exclusive with `PAPERCLIP_RUNNER_REMOTE_CODEX_PATH`; Paperclip verifies the installed executable before starting `runnerd`. | | `PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` | (unset) | Host-local path to the immutable provider pack built by `pnpm --filter @paperclipai/paperclip-runner build:provider-pack`. The pack includes its target-built Node 24.11 runtime, locked production dependencies, OpenCode proxy/executable, and ACPX sidecar. Remote OpenCode and ACPX fail closed without it. A preinstalled pack is accepted only when its complete digested manifest matches this build-owned pack; otherwise Paperclip stages this pack into the sandbox. | | `PAPERCLIP_HIDDEN_SETTINGS` | (unset) | Comma-separated settings surfaces to hide from the UI and floor at the API, for operators hosting Paperclip for others (managed cloud, internal shared server). See [Hiding settings surfaces](#hiding-settings-surfaces). | | `PAPERCLIP_SETTING_DEFAULTS` | (unset) | JSON object replacing the schema default of selected instance settings, for hosting operators. See [Operator setting defaults](#operator-setting-defaults). | diff --git a/package.json b/package.json index 41feeb179df..fb4396efecd 100644 --- a/package.json +++ b/package.json @@ -116,8 +116,8 @@ "postgres@3.4.9": "patches/postgres@3.4.9.patch" }, "overrides": { - "@agentclientprotocol/codex-acp@1.6.2>@openai/codex": "0.153.4", - "@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk": "0.3.263", + "@agentclientprotocol/codex-acp@1.6.2>@openai/codex": "0.156.0", + "@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk": "0.3.280", "rollup": ">=4.59.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/packages/paperclip-runner/package.json b/packages/paperclip-runner/package.json index fbf51aa98b0..ebc0614e39e 100644 --- a/packages/paperclip-runner/package.json +++ b/packages/paperclip-runner/package.json @@ -159,16 +159,17 @@ "browser:dev": "pnpm run build:typescript && pnpm run build:runner-binaries && vite --config vite.config.ts", "browser:preview": "vite preview --config vite.config.ts", "verify": "pnpm run build && pnpm run typecheck && pnpm run check:replay-goldens && pnpm run test && pnpm run test:sdk && pnpm run test:scenarios && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:sdk && pnpm run test:browser:scenarios && pnpm run check:forbidden-imports && pnpm run check:tracked-imports && pnpm run check:numbered-milestones && pnpm run check:package-boundaries && pnpm run check:clean-consumers && pnpm run docs:validate && pnpm run check:conformance-parity && pnpm run check:replay-parity && pnpm run trace:conformance && pnpm run replay:fixture && pnpm run trace:local-runner -- --quiet", - "verify:rootless": "bash scripts/verify-rootless-linux.sh" + "verify:rootless": "bash scripts/verify-rootless-linux.sh", + "test:opencode:qualification": "PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs" }, "dependencies": { "@agentclientprotocol/claude-agent-acp": "0.73.0", "@agentclientprotocol/codex-acp": "1.6.2", - "@openai/codex": "0.153.4", + "@openai/codex": "0.156.0", "acpx": "0.13.1", "ajv": "^8.20.0", "json-schema-to-ts": "^3.1.1", - "opencode-ai": "1.18.29", + "opencode-ai": "1.18.32", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "smol-toml": "^1.4.2" diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index e4cf725f1fe..41aa785e585 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -151,7 +151,7 @@ impl AcpxProviderDescriptor { "@agentclientprotocol/claude-agent-acp", "0.73.0", Some("@anthropic-ai/claude-agent-sdk"), - Some("0.3.263"), + Some("0.3.280"), "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", ), "codex" => ( @@ -159,7 +159,7 @@ impl AcpxProviderDescriptor { "@agentclientprotocol/codex-acp", "1.6.2", Some("@openai/codex"), - Some("0.153.4"), + Some("0.156.0"), "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", ), "pi" => return Err(DurableRunnerError::invalid( @@ -1852,7 +1852,7 @@ mod tests { "@agentclientprotocol/claude-agent-acp", "0.73.0", json!("@anthropic-ai/claude-agent-sdk"), - json!("0.3.263"), + json!("0.3.280"), "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", ) } else { @@ -1861,7 +1861,7 @@ mod tests { "@agentclientprotocol/codex-acp", "1.6.2", json!("@openai/codex"), - json!("0.153.4"), + json!("0.156.0"), "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", ) }; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index 892999199f1..bfa72a01c71 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -24,7 +24,7 @@ use crate::qualified_launch::verify_launch_artifact; use crate::question_response::validate_question_response; pub const CODEX_APP_SERVER_MAX_FRAME_BYTES: usize = 4 * 1024 * 1024; -const QUALIFIED_OPENCODE_VERSION: &str = "1.18.29"; +const QUALIFIED_OPENCODE_VERSION: &str = "1.18.32"; const DEFAULT_PROVIDER_TRACE_MAX_BYTES: usize = 64 * 1024 * 1024; const MAX_BUFFERED_MESSAGES: usize = 1_024; const MAX_BUFFERED_MESSAGE_BYTES: usize = 16 * 1024 * 1024; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index cf7f5468dbf..cc86427b96d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -4887,7 +4887,7 @@ mod tests { CodexProviderConfig { provider: "opencode".to_owned(), driver: "opencode_server".to_owned(), - provider_version: "1.18.29".to_owned(), + provider_version: "1.18.32".to_owned(), command: PathBuf::from("node"), args: Vec::new(), cwd: std::env::current_dir() @@ -5248,7 +5248,7 @@ mod tests { CodexProviderConfig { provider: "opencode".to_owned(), driver: "opencode_server".to_owned(), - provider_version: "1.18.29".to_owned(), + provider_version: "1.18.32".to_owned(), command: PathBuf::from("node"), args: Vec::new(), cwd: std::env::current_dir() diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs index d9673780217..252953d7d6a 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/acpx_warm_attachment.rs @@ -83,7 +83,7 @@ fn checkpoints_the_sidecar_and_rebinds_consecutive_warm_runs_before_accepting_wo "providerVersion": "0.13.1", "agent": "codex", "model": "gpt-5.6-sol", "acpxVersion": "0.13.1", "agentServerPackage": "@agentclientprotocol/codex-acp", "agentServerVersion": "1.6.2", "agentRuntimePackage": "@openai/codex", - "agentRuntimeVersion": "0.153.4", "commandDigest": PROFILE_DIGEST, + "agentRuntimeVersion": "0.156.0", "commandDigest": PROFILE_DIGEST, "sidecarCommand": sidecar, "sidecarArgs": args, "runtimeDirectory": fixture.0, "normalizedSessionId": "session-1", "runId": "run-1", "cwd": fixture.0, "instructions": "Complete the supplied work.", diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index a781f430f8d..c23930eeffa 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -168,7 +168,7 @@ fn prepare_payload(directory: &Path, agent: &str) -> Value { fn prepare_payload_with_mode(directory: &Path, agent: &str, mode: &str) -> Value { let operations = Vec::new(); let (runtime_package, runtime_version) = if agent == "codex" { - (json!("@openai/codex"), json!("0.153.4")) + (json!("@openai/codex"), json!("0.156.0")) } else { (Value::Null, Value::Null) }; @@ -265,7 +265,7 @@ fn opencode_prepare_payload(directory: &Path) -> Value { "kind": "opencode", "provider": "opencode", "driver": "opencode_server", - "providerVersion": "1.18.29", + "providerVersion": "1.18.32", "command": directory.join("qualified-opencode-proxy-command"), "args": [directory.join("qualified-opencode-proxy-script")], "cwd": directory, diff --git a/packages/paperclip-runner/scripts/build-provider-pack.mjs b/packages/paperclip-runner/scripts/build-provider-pack.mjs index 3b5e3fb18ac..d9bac8ba490 100644 --- a/packages/paperclip-runner/scripts/build-provider-pack.mjs +++ b/packages/paperclip-runner/scripts/build-provider-pack.mjs @@ -290,8 +290,8 @@ try { const payload = { pins: { nodeMinimum: minimumNodeVersion.join("."), - codex: "0.153.4", - opencode: "1.18.29", + codex: "0.156.0", + opencode: "1.18.32", acpx: "0.13.1", claudeAcp: "0.73.0", codexAcp: "1.6.2", diff --git a/packages/paperclip-runner/scripts/materialize-opencode-binary.mjs b/packages/paperclip-runner/scripts/materialize-opencode-binary.mjs index f4be2ea8ab8..ef984a02924 100644 --- a/packages/paperclip-runner/scripts/materialize-opencode-binary.mjs +++ b/packages/paperclip-runner/scripts/materialize-opencode-binary.mjs @@ -13,7 +13,7 @@ import { import { dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; -const OPENCODE_VERSION = "1.18.29"; +const OPENCODE_VERSION = "1.18.32"; const BASELINE_PACKAGE = "opencode-linux-x64-baseline"; function readPackage(path) { diff --git a/packages/paperclip-runner/scripts/materialize-opencode-binary.test.mjs b/packages/paperclip-runner/scripts/materialize-opencode-binary.test.mjs index 4bae1e206eb..789fa62af7b 100644 --- a/packages/paperclip-runner/scripts/materialize-opencode-binary.test.mjs +++ b/packages/paperclip-runner/scripts/materialize-opencode-binary.test.mjs @@ -35,14 +35,14 @@ async function fixture(options = {}) { join(packageRoot, "package.json"), JSON.stringify({ name: "opencode-ai", - version: options.packageVersion ?? "1.18.29", + version: options.packageVersion ?? "1.18.32", }), ), writeFile( join(baselineRoot, "package.json"), JSON.stringify({ name: "opencode-linux-x64-baseline", - version: options.baselineVersion ?? "1.18.29", + version: options.baselineVersion ?? "1.18.32", }), ), writeFile(join(packageRoot, "bin", "opencode.exe"), "sentinel\n"), @@ -50,11 +50,11 @@ async function fixture(options = {}) { const source = join(baselineRoot, "bin", "opencode"); if (options.symlinkSource) { const realSource = join(root, "real-opencode"); - await writeFile(realSource, "#!/bin/sh\necho 1.18.29\n"); + await writeFile(realSource, "#!/bin/sh\necho 1.18.32\n"); await chmod(realSource, 0o755); await symlink(realSource, source); } else { - await writeFile(source, "#!/bin/sh\necho 1.18.29\n"); + await writeFile(source, "#!/bin/sh\necho 1.18.32\n"); await chmod(source, 0o755); } return packageRoot; @@ -67,7 +67,7 @@ test("materializes the pinned baseline executable with a verified version", asyn platform: "linux", architecture: "x64", }); - assert.equal(result.version, "1.18.29"); + assert.equal(result.version, "1.18.32"); assert.match(result.sourceDigest, /^[0-9a-f]{64}$/); }); @@ -80,7 +80,7 @@ test("refuses version, file-type, and platform drift", async () => { platform: "linux", architecture: "x64", }), - /Expected opencode-linux-x64-baseline@1\.18\.29/, + /Expected opencode-linux-x64-baseline@1\.18\.32/, ); const symlinkSource = await fixture({ symlinkSource: true }); diff --git a/packages/paperclip-runner/scripts/qualify-opencode-runtime.test.mjs b/packages/paperclip-runner/scripts/qualify-opencode-runtime.test.mjs new file mode 100644 index 00000000000..9505c8952c9 --- /dev/null +++ b/packages/paperclip-runner/scripts/qualify-opencode-runtime.test.mjs @@ -0,0 +1,146 @@ +// Opt-in qualification of the installed CLI, not the fake protocol server. +// PAPERCLIP_OPENCODE_QUALIFY=1 node --test scripts/qualify-opencode-runtime.test.mjs +import assert from "node:assert/strict"; +import { spawn, execFileSync } from "node:child_process"; +import { once } from "node:events"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import test from "node:test"; + +const enabled = process.env.PAPERCLIP_OPENCODE_QUALIFY === "1"; +const packageRoot = resolve(import.meta.dirname, ".."); +const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); + +test("the pinned OpenCode executable serves health, sessions, SSE, and a local-provider prompt", { + skip: !enabled, + timeout: 60_000, +}, async (t) => { + const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-qualification-")); + let provider; + let child; + let exited; + const streamAbort = new AbortController(); + const killGroup = (signal) => { + if (!child?.pid) return; + try { process.kill(-child.pid, signal); } catch (error) { if (error.code !== "ESRCH") throw error; } + }; + t.after(async () => { + streamAbort.abort(); + if (child?.pid) { + killGroup("SIGTERM"); + const kill = setTimeout(() => killGroup("SIGKILL"), 3000); + try { await exited; } finally { clearTimeout(kill); killGroup("SIGKILL"); } + } + if (provider) { provider.closeAllConnections(); await new Promise((done) => provider.close(done)); } + await rm(root, { recursive: true, force: true }); + }); + const command = resolve(process.env.PAPERCLIP_TEST_OPENCODE_BINARY ?? join(packageRoot, "node_modules/opencode-ai/bin/opencode.exe")); + assert.equal(execFileSync(command, ["--version"], { encoding: "utf8", timeout: 10_000 }).trim(), manifest.dependencies["opencode-ai"]); + const requests = []; + provider = createServer(async (request, response) => { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); + requests.push({ path: request.url, body }); + if (request.url !== "/v1/chat/completions") { + response.writeHead(404).end(); + return; + } + response.writeHead(200, { "Content-Type": "text/event-stream" }); + for (const [delta, finish_reason] of [ + [{ role: "assistant", content: "paperclip-opencode-qualified" }, null], + [{}, "stop"], + ]) { + response.write(`data: ${JSON.stringify({ id: "chatcmpl-qualification", object: "chat.completion.chunk", created: 1, model: "qualification", choices: [{ index: 0, delta, finish_reason }] })}\n\n`); + } + response.end("data: [DONE]\n\n"); + }); + provider.listen(0, "127.0.0.1"); + await once(provider, "listening"); + const providerUrl = `http://127.0.0.1:${provider.address().port}/v1`; + const config = join(root, "opencode.json"); + await writeFile(config, JSON.stringify({ + model: "openrouter/qualification", + small_model: "openrouter/qualification", + share: "disabled", autoupdate: false, plugin: [], + enabled_providers: ["openrouter"], + provider: { openrouter: { options: { baseURL: providerUrl, apiKey: "local-fixture-only" }, models: { qualification: { name: "qualification", limit: { context: 10000, output: 1000 } } } } }, + permission: { "*": "deny" }, + })); + const workspace = join(root, "workspace"); + await mkdir(workspace); + child = spawn(command, ["serve", "--hostname", "127.0.0.1", "--port", "0"], { + cwd: workspace, + env: { + PATH: `${dirname(process.execPath)}:${process.env.PATH ?? "/usr/bin:/bin"}`, + HOME: root, XDG_CONFIG_HOME: join(root, "config"), XDG_DATA_HOME: join(root, "data"), XDG_CACHE_HOME: join(root, "cache"), + OPENCODE_CONFIG: config, + OPENCODE_DISABLE_PROJECT_CONFIG: "true", OPENCODE_DISABLE_MODELS_FETCH: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + OPENCODE_SERVER_USERNAME: "paperclip", OPENCODE_SERVER_PASSWORD: "local-fixture-only", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + let output = ""; + for (const stream of [child.stdout, child.stderr]) stream.on("data", (chunk) => { output = `${output}${chunk}`.slice(-16384); }); + exited = once(child, "exit"); + let baseUrl; + for (let attempt = 0; attempt < 150; attempt++) { + baseUrl = output.match(/http:\/\/127\.0\.0\.1:\d+/)?.[0]; + if (baseUrl) break; + assert.equal(child.exitCode, null, output); + await delay(100); + } + assert.ok(baseUrl, `OpenCode did not start: ${output}`); + const headers = { Authorization: `Basic ${Buffer.from("paperclip:local-fixture-only").toString("base64")}`, "Content-Type": "application/json" }; + const api = async (path, options = {}) => { + const response = await fetch(`${baseUrl}${path}`, { headers, signal: AbortSignal.timeout(15_000), ...options }); + assert.ok(response.ok, `${path}: ${response.status} ${await response.clone().text()}`); + return response; + }; + const health = await (await api("/global/health")).json(); + assert.deepEqual(health, { healthy: true, version: manifest.dependencies["opencode-ai"] }); + const session = await (await api("/session", { method: "POST", body: JSON.stringify({ title: "Runtime qualification" }) })).json(); + assert.equal((await (await api(`/session/${session.id}`)).json()).id, session.id); + const stream = await fetch(`${baseUrl}/event`, { headers, signal: streamAbort.signal }); + assert.equal(stream.status, 200); + assert.match(stream.headers.get("content-type"), /text\/event-stream/); + const reader = stream.body.getReader(); + const events = []; + const readEvents = (async () => { + let buffer = ""; + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let boundary; + while ((boundary = buffer.indexOf("\n\n")) !== -1) { + const frame = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); + for (const line of frame.split("\n")) if (line.startsWith("data: ")) events.push(JSON.parse(line.slice(6))); + } + } + } catch (error) { if (!streamAbort.signal.aborted) throw error; } + })(); + const prompt = await api(`/session/${session.id}/prompt_async`, { + method: "POST", + body: JSON.stringify({ model: { providerID: "openrouter", modelID: "qualification" }, parts: [{ type: "text", text: "Reply with the qualification marker." }] }), + }); + assert.equal(prompt.status, 204); + let messages; + for (let attempt = 0; attempt < 150; attempt++) { + messages = await (await api(`/session/${session.id}/message`)).json(); + if (messages.some((message) => message.info.role === "assistant" && message.parts.some((part) => part.type === "text" && part.text === "paperclip-opencode-qualified"))) break; + await delay(100); + } + assert.ok(messages.some((message) => message.info.role === "assistant" && message.parts.some((part) => part.type === "text" && part.text === "paperclip-opencode-qualified")), JSON.stringify(messages)); + assert.ok(requests.some((request) => request.path === "/v1/chat/completions" && request.body.model === "qualification")); + assert.ok(events.some((event) => event.type === "message.part.updated" || event.type === "message.part.delta")); + streamAbort.abort(); + await readEvents; + assert.equal((await api(`/session/${session.id}`, { method: "DELETE" })).status, 200); +}); diff --git a/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.test.mjs b/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.test.mjs index ef1874faea0..a8717ef6ba7 100644 --- a/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.test.mjs +++ b/packages/paperclip-runner/scripts/runner-protocol-eval-campaign.test.mjs @@ -34,7 +34,7 @@ async function fixture() { provider: "opencode", driver: "opencode_server", model: "openrouter/example/model", - opencodeVersion: "1.18.29", + opencodeVersion: "1.18.32", }; const evalCase = { schema: "paperclip-runner/eval-case/v1", diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts index f1042a98543..93aaa7c92ac 100644 --- a/packages/paperclip-runner/src/backends/codex-native-backend.ts +++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts @@ -69,7 +69,7 @@ function transportDriverIdentity(input: NativeExecutionInput): { return { kind: "opencode_server", displayName: "OpenCode server", - version: "1.18.29", + version: "1.18.32", }; case "claude_managed": return { diff --git a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts index 7671cfc7f0c..cd8a46a621e 100644 --- a/packages/paperclip-runner/src/backends/native-backend-factory.test.ts +++ b/packages/paperclip-runner/src/backends/native-backend-factory.test.ts @@ -101,7 +101,7 @@ function acpxExecution( ? "@openai/codex" : "@anthropic-ai/claude-agent-sdk", agentRuntimeVersion: - agent === "pi" ? "0.84.2" : agent === "codex" ? "0.153.4" : "0.3.263", + agent === "pi" ? "0.84.2" : agent === "codex" ? "0.156.0" : "0.3.280", commandDigest: agent === "codex" ? "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3" @@ -266,7 +266,7 @@ describe("native backend factory", () => { await expect(backend.descriptor()).resolves.toMatchObject({ kind: "runner", name: "opencode_server", - version: "1.18.29", + version: "1.18.32", capabilities: { steering: false, resume: true, @@ -418,7 +418,7 @@ describe("native backend factory", () => { await expect(backend.descriptor()).resolves.toMatchObject({ kind: "runner", name: "opencode_server", - version: "1.18.29", + version: "1.18.32", capabilities: { resume: true, interruption: true, diff --git a/packages/paperclip-runner/src/cli/eval-session.ts b/packages/paperclip-runner/src/cli/eval-session.ts index c88c047920c..63b5d84d1a0 100644 --- a/packages/paperclip-runner/src/cli/eval-session.ts +++ b/packages/paperclip-runner/src/cli/eval-session.ts @@ -159,9 +159,9 @@ export function evalSessionProviderVersion( request: EvalSessionRequest, ): string | null { if (request.provider === "opencode") { - const version = request.opencodeVersion ?? "1.18.29"; - if (version !== "1.18.29") { - throw new Error(`OpenCode evals require exact version 1.18.29; received ${version}`); + const version = request.opencodeVersion ?? "1.18.32"; + if (version !== "1.18.32") { + throw new Error(`OpenCode evals require exact version 1.18.32; received ${version}`); } return version; } diff --git a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts index 1b1ba26ccd9..f02a71ef918 100644 --- a/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts +++ b/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts @@ -331,7 +331,7 @@ async function handle(message: RpcMessage): Promise { case "initialize": result = { user: { sessionId: "opencode" }, - serverInfo: { name: "opencode", version: "1.18.29" }, + serverInfo: { name: "opencode", version: "1.18.32" }, }; break; case "thread/start": diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts index 132b87403a9..7c7a9356a3b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.test.ts @@ -445,7 +445,7 @@ describe("ACPX installation integrity", () => { }, { name: "@anthropic-ai/claude-agent-sdk", - version: "0.3.263", + version: "0.3.280", directory: join(dependencyRoot, "claude-agent-sdk"), }, { diff --git a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts index e65d90c947e..ef101bdb793 100644 --- a/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts +++ b/packages/paperclip-runner/src/drivers/acpx/installation-integrity.ts @@ -45,13 +45,13 @@ const VERIFIED_PROVIDER_RUNTIME_TARGET_ENV = const QUALIFIED_CLAUDE_LINUX_X64_RUNTIME = Object.freeze({ runtimePackageName: "@anthropic-ai/claude-agent-sdk", - runtimePackageVersion: "0.3.263", + runtimePackageVersion: "0.3.280", packageName: "@anthropic-ai/claude-agent-sdk-linux-x64", - packageVersion: "0.3.263", - dependencyDeclaration: "0.3.263", + packageVersion: "0.3.280", + dependencyDeclaration: "0.3.280", relativeExecutable: "claude", executableDigest: - "sha256:26d020351e8112f4006790f3cfce43b4c9df0c1bb1d0e542364d64151b81d5ba", + "sha256:1e08503dbdf3c2cb0d706d32f3408277388d1c76ef108673e8fe42c1b322925b", environmentVariable: "CLAUDE_CODE_EXECUTABLE", }); @@ -59,24 +59,24 @@ const QUALIFIED_CLAUDE_DARWIN_RUNTIMES = { arm64: Object.freeze({ ...QUALIFIED_CLAUDE_LINUX_X64_RUNTIME, packageName: "@anthropic-ai/claude-agent-sdk-darwin-arm64", - executableDigest: "sha256:ef5d2909c8af49f31ab6d5487e90316777bc2fac170adfe8160716caa8aaf4f9", + executableDigest: "sha256:387a5c5dcdbb815085edf0baf79591f9d8894efe922bceaf3d75b1b08055229d", }), x64: Object.freeze({ ...QUALIFIED_CLAUDE_LINUX_X64_RUNTIME, packageName: "@anthropic-ai/claude-agent-sdk-darwin-x64", - executableDigest: "sha256:a94a8b229fa85c3a316c6b4a35e0aa22bec1aabbd3d1422826ce1d10ddc88751", + executableDigest: "sha256:c1d32d87630482250633208ab77855429b24010ae3086a7ff7539b57b93168d4", }), }; const QUALIFIED_CODEX_LINUX_X64_RUNTIME = Object.freeze({ runtimePackageName: "@openai/codex", - runtimePackageVersion: "0.153.4", + runtimePackageVersion: "0.156.0", packageName: "@openai/codex-linux-x64", - packageVersion: "0.153.4-linux-x64", - dependencyDeclaration: "npm:@openai/codex@0.153.4-linux-x64", + packageVersion: "0.156.0-linux-x64", + dependencyDeclaration: "npm:@openai/codex@0.156.0-linux-x64", relativeExecutable: "vendor/x86_64-unknown-linux-musl/bin/codex", executableDigest: - "sha256:56ef98ab4032d317ab26e9b5e5a175650717351edb16ed9cde0cb6d1734d62da", + "sha256:78a11f06e0a2dda42d13fba1d50dc62e8cbdb2d5f69789722f4d4d99b5cdbe30", environmentVariable: "CODEX_PATH", }); @@ -92,8 +92,8 @@ const QUALIFIED_CLAUDE_PROVIDER_DEPENDENCIES = Object.freeze([ }), Object.freeze({ packageName: "@anthropic-ai/claude-agent-sdk", - packageVersion: "0.3.263", - // The package's own package.json still declares 0.3.257 — 0.3.263 is + packageVersion: "0.3.280", + // The package's own package.json still declares 0.3.257 — 0.3.280 is // only what pnpm resolves, forced by the // "claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk" override in // the workspace root. This field binds the declared string, not the diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts index 6451a369f51..836e27c485b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.test.ts @@ -18,7 +18,7 @@ describe("qualified ACPX profiles", () => { } }); - it.each(["claude-opus-5", "custom-model-not-in-catalog"])("accepts the exact Claude model %s", (model) => { + it.each(["claude-opus-5-5", "claude-fable-5-1", "custom-model-not-in-catalog"])("accepts the exact Claude model %s", (model) => { expect(resolveQualifiedAcpxProfile("claude", model)).toMatchObject({ qualificationModel: model, reportedModelId: model, commandDigest: QUALIFIED_ACPX_PROFILES.claude.commandDigest, @@ -34,14 +34,14 @@ describe("qualified ACPX profiles", () => { it("binds Codex ACP to the CLI runtime it launches", () => { expect(QUALIFIED_ACPX_PROFILES.codex).toMatchObject({ agentRuntimePackage: "@openai/codex", - agentRuntimeVersion: "0.153.4", + agentRuntimeVersion: "0.156.0", }); }); it("binds Claude ACP to the SDK and native CLI runtime it launches", () => { expect(QUALIFIED_ACPX_PROFILES.claude).toMatchObject({ agentRuntimePackage: "@anthropic-ai/claude-agent-sdk", - agentRuntimeVersion: "0.3.263", + agentRuntimeVersion: "0.3.280", }); }); }); diff --git a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts index ee54598b11e..fd931b18081 100644 --- a/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts +++ b/packages/paperclip-runner/src/drivers/acpx/qualified-profiles.ts @@ -56,7 +56,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< agentServerPackage: "@agentclientprotocol/claude-agent-acp", agentServerVersion: "0.73.0", agentRuntimePackage: "@anthropic-ai/claude-agent-sdk", - agentRuntimeVersion: "0.3.263", + agentRuntimeVersion: "0.3.280", commandDigest: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a", qualificationModel: "claude-sonnet-5", @@ -72,7 +72,7 @@ export const QUALIFIED_ACPX_PROFILES: Readonly< agentServerPackage: "@agentclientprotocol/codex-acp", agentServerVersion: "1.6.2", agentRuntimePackage: "@openai/codex", - agentRuntimeVersion: "0.153.4", + agentRuntimeVersion: "0.156.0", commandDigest: "sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3", qualificationModel: "gpt-5.6-sol", diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index b08741aba64..130b34f53b3 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -309,7 +309,7 @@ describe("OpenCodeServerDriver", () => { headers: { "Content-Type": "application/json" }, }); if (url.pathname === "/global/health") - return json({ healthy: true, version: "1.18.29" }); + return json({ healthy: true, version: "1.18.32" }); if (url.pathname === "/event") { return new Response( new ReadableStream({ @@ -681,7 +681,7 @@ describe("OpenCodeServerDriver", () => { output: 2, costUsd: 0.001, provider: "openrouter", - driverVersion: "1.18.29", + driverVersion: "1.18.32", }); await session.interrupt?.({ turnId: turn.turnId }); const snapshot = await session.snapshot(); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index f9a1f29cb3d..91cc5809c8f 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -73,7 +73,7 @@ import { nativeMcpLaunchBinding } from "../native-mcp.js"; import { materializeNativeRuntimeSkills } from "../runtime-context-materializer.js"; export const OPENCODE_SERVER_DRIVER_KIND = "opencode_server" as const; -export const QUALIFIED_OPENCODE_VERSION = "1.18.29" as const; +export const QUALIFIED_OPENCODE_VERSION = "1.18.32" as const; export const QUALIFIED_OPENCODE_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731" as const; diff --git a/packages/paperclip-runner/src/live/live-session.test.ts b/packages/paperclip-runner/src/live/live-session.test.ts index 643949a9a08..00bdc571f24 100644 --- a/packages/paperclip-runner/src/live/live-session.test.ts +++ b/packages/paperclip-runner/src/live/live-session.test.ts @@ -873,7 +873,7 @@ describe("Capability live runnerd and Codex session", () => { expect(session.snapshot().config).toMatchObject({ provider: "opencode", driver: "opencode_server", - providerVersion: "1.18.29", + providerVersion: "1.18.32", requestedModel: "openrouter/deepseek/deepseek-v4-flash-0731", }); await service.shutdown(session.id); diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index 821352e71f2..73359bf267c 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -924,7 +924,7 @@ export class CapabilityLiveSessionService { ? "aws_agentcore_harness_api" : input.provider === "acpx" ? "acpx_runtime" : "codex_app_server", providerVersion: input.provider === "opencode" - ? "1.18.29" + ? "1.18.32" : input.provider === "claude_managed" ? input.managedProfile!.agentVersion : input.provider === "aws_agentcore" diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4b624890030..1997257ef21 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -4565,7 +4565,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ? "opencode_server" : "codex_app_server", providerVersion: - provider === "opencode" ? "1.18.29" : "codex-app-server-v1", + provider === "opencode" ? "1.18.32" : "codex-app-server-v1", command: provider === "opencode" ? providerNodeCommand diff --git a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs index ab9e5ad6f75..14e6018a8ba 100644 --- a/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs +++ b/packages/paperclip-runner/test/acpx-codex-package-contract.test.mjs @@ -57,10 +57,10 @@ const nativeSessionExecutor = await readFile( ); test("the runner pins every qualified ACPX production dependency", () => { - assert.equal(runnerPackage.dependencies["@openai/codex"], "0.153.4"); + assert.equal(runnerPackage.dependencies["@openai/codex"], "0.156.0"); assert.equal(runnerPackage.dependencies["@anthropic-ai/claude-agent-sdk"], undefined); assert.equal(rootPackage.pnpm.overrides["@agentclientprotocol/codex-acp@1.6.2>@openai/codex"], runnerPackage.dependencies["@openai/codex"]); - assert.equal(rootPackage.pnpm.overrides["@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk"], "0.3.263"); + assert.equal(rootPackage.pnpm.overrides["@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk"], "0.3.280"); assert.equal(runnerPackage.optionalDependencies, undefined); assert.equal(runnerPackage.dependencies.node, undefined); assert.equal(runnerPackage.dependencies.acpx, "0.13.1"); @@ -142,7 +142,7 @@ test("old and new pnpm configuration both apply the exact runtime patches", () = providerPackBuilder, /copyFileSync\(process\.execPath, stableNodeCommand\)/, ); - assert.match(codexPatch, /\+ "@openai\/codex": "0\.153\.4"/); + assert.match(codexPatch, /\+ "@openai\/codex": "0\.156\.0"/); }); test("the ACPX patch preserves launch-only state and verified spawning", () => { diff --git a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs index 3a7db00308c..c69a9c7d047 100644 --- a/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs +++ b/packages/paperclip-runner/test/fixtures/fake-opencode-server.mjs @@ -222,7 +222,7 @@ const server = createServer(async (request, response) => { if (request.headers.authorization !== expectedAuth) return json(response, 401, { error: "unauthorized" }); if (request.url === "/global/health") - return json(response, 200, { healthy: true, version: "1.18.29" }); + return json(response, 200, { healthy: true, version: "1.18.32" }); if (request.url === "/event") { eventConnections += 1; response.writeHead(200, { diff --git a/patches/@agentclientprotocol__codex-acp@1.6.2.patch b/patches/@agentclientprotocol__codex-acp@1.6.2.patch index 8f6c17911ef..36becec2b8b 100644 --- a/patches/@agentclientprotocol__codex-acp@1.6.2.patch +++ b/patches/@agentclientprotocol__codex-acp@1.6.2.patch @@ -6,7 +6,7 @@ diff --git a/package.json b/package.json "dependencies": { "@agentclientprotocol/sdk": "^1.3.0", - "@openai/codex": "^0.148.0", -+ "@openai/codex": "0.153.4", ++ "@openai/codex": "0.156.0", "diff": "^9.0.0", "open": "^11.0.0", "vscode-jsonrpc": "^9.0.1", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1650614afc3..19900634e4b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,8 +32,8 @@ patchedDependencies: # Agent CLIs share the current runtime used by the native provider pack. # pnpm patches change package files, but overrides control dependency resolution. overrides: - "@agentclientprotocol/codex-acp@1.6.2>@openai/codex": "0.153.4" - "@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk": "0.3.263" + "@agentclientprotocol/codex-acp@1.6.2>@openai/codex": "0.156.0" + "@agentclientprotocol/claude-agent-acp@0.73.0>@anthropic-ai/claude-agent-sdk": "0.3.280" rollup: ">=4.59.0" react: "^19.2.8" react-dom: "^19.2.8" diff --git a/scripts/runner-api-eval-worker.ts b/scripts/runner-api-eval-worker.ts index 8423caaf712..4a76036e203 100644 --- a/scripts/runner-api-eval-worker.ts +++ b/scripts/runner-api-eval-worker.ts @@ -100,7 +100,7 @@ try { if (!request.reservationId || request.maxCostUsd !== 0.5 || !["gpt-5.6-luna", "claude-sonnet-5", ...OPENROUTER_MODELS].includes(request.model)) throw new Error("Paid attempt requires ledger reservation and qualified model"); if (isOpenRouter) { providerVersion = execFileSync(resolve("packages/paperclip-runner/node_modules/opencode-ai/bin/opencode.exe"), ["--version"], { encoding: "utf8" }).trim(); - if (providerVersion !== "1.18.29") throw new Error("OpenCode profile requires version 1.18.29"); + if (providerVersion !== "1.18.32") throw new Error("OpenCode profile requires version 1.18.32"); } const providerEnvironment = isOpenRouter ? openRouterEnvironment() : request.model === "claude-sonnet-5" ? (() => { const token = process.env.CLAUDE_CODE_OAUTH_TOKEN; diff --git a/scripts/tests/native-cleanup-paginated-codex.mjs b/scripts/tests/native-cleanup-paginated-codex.mjs index 401ea22dc93..fab7baa7053 100644 --- a/scripts/tests/native-cleanup-paginated-codex.mjs +++ b/scripts/tests/native-cleanup-paginated-codex.mjs @@ -1,7 +1,7 @@ // Opt-in real Codex qualification; no model turn or live account data. // Run from the repository root: // node --import ./server/node_modules/tsx/dist/loader.mjs scripts/tests/native-cleanup-paginated-codex.mjs -// PAPERCLIP_TEST_CODEX_BINARY may select the exact installed Codex 0.153.4 binary. +// PAPERCLIP_TEST_CODEX_BINARY may select the exact installed Codex 0.156.0 binary. // Fresh synthetic fixture directories are retained for inspection; never reuse live homes. import { spawn, execFileSync } from "node:child_process"; import { @@ -26,10 +26,10 @@ if ( execFileSync(codexBinary, ["--version"], { encoding: "utf8", timeout: 10000, - }).trim() !== "codex-cli 0.153.4" + }).trim() !== "codex-cli 0.156.0" ) { throw new Error( - "This opt-in qualification requires Codex CLI 0.153.4. Requalify deliberately before changing the pin.", + "This opt-in qualification requires Codex CLI 0.156.0. Requalify deliberately before changing the pin.", ); } const home = await mkdtemp(join(tmpdir(), "paperclip-paginated-probe-")); @@ -132,7 +132,7 @@ const lines = timestamp, cwd: home, originator: "codex", - cli_version: "0.153.4", + cli_version: "0.156.0", source: "cli", model_provider: "openai", selected_capability_roots: [], diff --git a/scripts/tests/native-provider-startup-codex.mjs b/scripts/tests/native-provider-startup-codex.mjs index ec400e36954..2eca77066e5 100644 --- a/scripts/tests/native-provider-startup-codex.mjs +++ b/scripts/tests/native-provider-startup-codex.mjs @@ -88,7 +88,7 @@ if (args.length === 0 || (args.length === 1 && args[0] === "--help")) { }).trim(); assert.equal( version, - "codex-cli 0.153.4", + "codex-cli 0.156.0", "qualified_codex_version_required", ); const ledger = join(fixture, "provider-process-starts.txt"); @@ -309,7 +309,7 @@ if (args.length === 0 || (args.length === 1 && args[0] === "--help")) { kind: "codex", provider: "codex", driver: "codex_app_server", - providerVersion: "0.153.4", + providerVersion: "0.156.0", command: shim, args: [ "--cd", diff --git a/server/src/__tests__/adapter-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 1da4fcdc4d4..39688126530 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -336,8 +336,8 @@ describe("server adapter registry", () => { const expectedCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex")}; fi`; const expectedGeminiInstall = `if ! command -v 'gemini' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@google/gemini-cli")}; fi`; const expectedOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai")}; fi`; - const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.153.4")}; fi`; - const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.29")}; fi`; + const expectedRunnerCodexInstall = `if ! command -v 'codex' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("@openai/codex@0.156.0")}; fi`; + const expectedRunnerOpenCodeInstall = `if ! command -v 'opencode' >/dev/null 2>&1; then ${buildSandboxNpmInstallCommand("opencode-ai@1.18.32")}; fi`; expect(findActiveServerAdapter("claude_local")?.getRuntimeCommandSpec?.({})).toEqual({ command: "claude", diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 96f97b58857..6f70a9a7e06 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -508,7 +508,7 @@ const paperclipRunnerAdapter: ServerAdapterModule = { "opencode", `opencode-ai@${QUALIFIED_OPENCODE_RUNNER_VERSION}`, ) - : buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.153.4"), + : buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex@0.156.0"), agentConfigurationDoc: "# Paperclip Runner\n\nAdapter: paperclip_runner\n\nRuns Codex, OpenCode, Claude Managed, AWS AgentCore, or ACPX Claude through the Rust Paperclip runner and authenticated PRP transport. Pi is not available through the qualified ACPX profile. Managed providers use company-scoped qualified profiles, explicit retention acknowledgement, and spend limits.\n", getConfigSchema: () => ({ diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index a6e79f988f1..e11b057ac05 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -995,8 +995,8 @@ describe("remote provider pack manifest", () => { const payload = { pins: { nodeMinimum: "24.11.0", - codex: "0.153.4", - opencode: "1.18.29", + codex: "0.156.0", + opencode: "1.18.32", acpx: "0.13.1", claudeAcp: "0.73.0", codexAcp: "1.6.2", @@ -1053,7 +1053,7 @@ describe("remote provider pack manifest", () => { ); await writeManifest(); expect(readRemoteProviderPackManifest(root).payload.pins.opencode).toBe( - "1.18.29", + "1.18.32", ); for (const [artifactName, substituteName] of [ ["nodeCommand", "productionLock"], @@ -1983,7 +1983,7 @@ describe("remote preinstalled executable discovery", () => { const shim = '#!/bin/sh\ncat "$(dirname "$0")/version.txt"\nprintf "%s\\n" "$@"\n'; await writeFile(source, shim, { mode: 0o755 }); - await writeFile(join(installation, "version.txt"), "codex-cli 0.153.4\n"); + await writeFile(join(installation, "version.txt"), "codex-cli 0.156.0\n"); // Existing deployments may already have the old symlink. Never write // through it into the shared installation while upgrading the launcher. await symlink(source, target); @@ -1996,7 +1996,7 @@ describe("remote preinstalled executable discovery", () => { execFileSync(target, ["--version", "argument with 'quotes'"], { encoding: "utf8", }), - ).toBe("codex-cli 0.153.4\n--version\nargument with 'quotes'\n"); + ).toBe("codex-cli 0.156.0\n--version\nargument with 'quotes'\n"); expect(await readFile(source, "utf8")).toBe(shim); } expect(await readdir(join(root, "workspace", "bin"))).toEqual(["codex"]); @@ -10252,7 +10252,7 @@ describe("runnerd provider runtime wiring", () => { }), stderr: "", }; if (command.args?.[0] === "--version") return { - exitCode: 0, timedOut: false, stdout: "codex-cli 0.153.4", stderr: "", + exitCode: 0, timedOut: false, stdout: "codex-cli 0.156.0", stderr: "", }; if (command.args?.[2] === "paperclip-runner-launch") { throw new Error("fixture_stop_after_launch_staging"); @@ -10373,7 +10373,7 @@ describe("runnerd provider runtime wiring", () => { ) { throw new Error("reached-preinstalled-codex-verification"); } - stdout = "codex-cli 0.153.4"; + stdout = "codex-cli 0.156.0"; } else if (script === "uname -s; uname -m") { stdout = `${process.platform === "darwin" ? "Darwin" : "Linux"}\n${process.arch === "arm64" ? "arm64" : "x86_64"}\n`; } else if (script.includes("command -v paperclip-runnerd")) { diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 5f350aaa7bc..17715dafa95 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -9127,8 +9127,8 @@ const RUNNERD_BINARY_CONTRACT_VERSION = 2; const REMOTE_PROVIDER_PACK_SCHEMA = "paperclip-runner/remote-provider-pack/v1"; const REMOTE_PROVIDER_PACK_PINS = { nodeMinimum: "24.11.0", - codex: "0.153.4", - opencode: "1.18.29", + codex: "0.156.0", + opencode: "1.18.32", acpx: "0.13.1", claudeAcp: "0.73.0", codexAcp: "1.6.2", diff --git a/server/src/services/native-runtime/provider-profile.ts b/server/src/services/native-runtime/provider-profile.ts index 7cd125783d7..3cd80f49f19 100644 --- a/server/src/services/native-runtime/provider-profile.ts +++ b/server/src/services/native-runtime/provider-profile.ts @@ -9,7 +9,7 @@ import { CLAUDE_MANAGED_QUALIFIED_MODEL, } from "../provider-profile-qualification.js"; -export const QUALIFIED_OPENCODE_RUNNER_VERSION = "1.18.29" as const; +export const QUALIFIED_OPENCODE_RUNNER_VERSION = "1.18.32" as const; export const DEFAULT_OPENCODE_RUNNER_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731" as const; export const CLAUDE_MANAGED_BETA_VERSION = "managed-agents-2026-04-01" as const; diff --git a/ui/src/adapters/codex-local/config-fields.test.tsx b/ui/src/adapters/codex-local/config-fields.test.tsx index 45f5aad7cec..47ca43073d5 100644 --- a/ui/src/adapters/codex-local/config-fields.test.tsx +++ b/ui/src/adapters/codex-local/config-fields.test.tsx @@ -29,7 +29,7 @@ describe("Paperclip Runner Codex configuration", () => { const html = renderRunner({ provider: "codex" }); expect(html).toContain(''); - expect(html).toContain("OpenCode 1.18.29"); + expect(html).toContain("OpenCode 1.18.32"); expect(html).toContain("ACPX"); expect(html).not.toContain("Permission mode"); expect(html).not.toContain("Ask when requested"); @@ -46,7 +46,7 @@ describe("Paperclip Runner Codex configuration", () => { }); expect(html).toContain( - '', + '', ); expect(html).toContain("Full auto (allow)"); expect(html).toContain('aria-label="Permission mode"'); diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 5d569a13851..4c518314b80 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -229,7 +229,7 @@ export function CodexLocalConfigFields({ }} > - + From 8c6cc7dccf91523e0720bd86f95487e66b4b0e63 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 22 Sep 2026 17:49:15 -0700 Subject: [PATCH 04/11] feat(cloud): sync primary-company archive state with the Cloud control plane (#13837) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Cloud runs managed instances and controls their lifecycle from a control plane. > - Inside the app, a person can archive a company. On a managed instance, the Cloud-pinned primary company is the whole organization. > - Today that archive stays local. The control plane does not learn about it, so it keeps the instance running and shows the organization as live. > - This pull request notifies the control plane when the primary company crosses the archived boundary, and adds two control endpoints so the control plane can verify the state and undo the archive during a restore. > - The benefit is that an archived organization stops running and shows as archived, and a restore brings the company back without manual steps. ## Linked Issues or Issue Description No public issue exists. Description follows the enhancement template: **What existing behavior does this improve?** Company archive on Cloud-managed instances. Archiving the primary company pauses agents and cancels runs, but the hosting control plane never learns about it. **Subsystem affected** Server: company service, cloud-control middleware, instance routes. **Current behavior** A person archives the primary company. The instance keeps running. The Cloud portfolio still shows the organization as live. Unarchive after a Cloud restore requires manual steps inside the product. **Proposed behavior** The company service rings a Cloud lifecycle doorbell when the primary company is archived or unarchived. The control plane verifies the state through `GET /api/instance/lifecycle` before it acts. During a restore, the control plane calls `POST /api/instance/lifecycle/unarchive-primary` to bring the company back. Self-hosted instances are not affected. **Reason and benefit** An archived organization should not keep running, and its hosting console should show it as archived. The verified read-back keeps the doorbell a hint: a forged or duplicated ring cannot change state. ## What Changed - New `services/cloud-lifecycle-sync.ts`: fire-and-forget doorbell `POST {cloudOrigin}/v1/tenant/lifecycle-changed` with bounded retries. It runs only on cloud-managed instances, and only for the derived primary company id. It never blocks or fails the company mutation. - `services/companies.ts`: ring the doorbell after a committed archive or unarchive transition, in both the `update()` status-patch path and `archive()`. - New `GET /api/instance/lifecycle`: reports the primary company id, its status, and how many other companies are not archived. Bound to a `lifecycle:read` Cloud control assertion; board members can also read it. - New `POST /api/instance/lifecycle/unarchive-primary`: idempotent unarchive of the primary company as a system actor. Bound to `lifecycle:unarchive-primary`; requires instance admin otherwise. - `middleware/cloud-control.ts`: a closed endpoint→method→action table replaces the single hardcoded endpoint. Each assertion still authorizes exactly one action on one endpoint. - `services/cloud-instance.ts`: the primary-company id derivation moves here as the single definition; `middleware/auth.ts` delegates to it. The derivation itself is unchanged. ## Verification - `pnpm exec tsc --noEmit` in `server/` is clean. - `pnpm exec vitest run src/__tests__/cloud-lifecycle-sync.test.ts src/__tests__/cloud-control-task-drain.test.ts src/__tests__/instance-settings-routes.test.ts src/__tests__/companies-service.test.ts src/__tests__/cloud-tenant-company-provisioning.test.ts` — all pass. - New tests cover: doorbell env-gating, retry bounds, no-throw contract; the read-back status and sibling count; idempotent unarchive and the admin gate; non-cloud 404s; control-assertion action binding for the new endpoints, including cross-action and wrong-method rejection; and a companies-service test that proves the doorbell rings exactly on archived-boundary transitions. ## Risks - Self-hosted instances see no behavior change: without a Cloud signal the doorbell is a no-op and the endpoints answer 404. - The doorbell is advisory by design. The control plane verifies through the read-back before it acts, so a lost or duplicated ring cannot corrupt state. - The unarchive endpoint reuses the existing `companyService.update` path, so agent reactivation and activity logging behave exactly like an in-product unarchive. ## Model Used - Claude Fable 5 (`claude-fable-5`), via Claude Code CLI, extended thinking and tool use enabled. ## 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 - [x] 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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- .../cloud-control-task-drain.test.ts | 38 ++++ .../__tests__/cloud-lifecycle-sync.test.ts | 90 ++++++++++ .../src/__tests__/companies-service.test.ts | 43 ++++- .../instance-settings-routes.test.ts | 162 ++++++++++++++++++ server/src/middleware/auth.ts | 7 +- server/src/middleware/cloud-control.ts | 28 +-- server/src/routes/instance-settings.ts | 82 ++++++++- server/src/routes/openapi.ts | 18 ++ server/src/services/cloud-instance.ts | 16 ++ server/src/services/cloud-lifecycle-sync.ts | 103 +++++++++++ server/src/services/cloud-runtime-identity.ts | 2 + server/src/services/companies.ts | 14 ++ 12 files changed, 585 insertions(+), 18 deletions(-) create mode 100644 server/src/__tests__/cloud-lifecycle-sync.test.ts create mode 100644 server/src/services/cloud-lifecycle-sync.ts diff --git a/server/src/__tests__/cloud-control-task-drain.test.ts b/server/src/__tests__/cloud-control-task-drain.test.ts index 0e500157bbe..3f0ab8e1951 100644 --- a/server/src/__tests__/cloud-control-task-drain.test.ts +++ b/server/src/__tests__/cloud-control-task-drain.test.ts @@ -211,6 +211,12 @@ describe("cloudControlMiddleware", () => { app.all("/api/instance/task-drain", (req, res) => { res.json({ actor: req.actor }); }); + app.all("/api/instance/lifecycle", (req, res) => { + res.json({ actor: req.actor }); + }); + app.all("/api/instance/lifecycle/unarchive-primary", (req, res) => { + res.json({ actor: req.actor }); + }); app.get("/api/instance/settings", (req, res) => { res.json({ actor: req.actor }); }); @@ -263,6 +269,38 @@ describe("cloudControlMiddleware", () => { expect(res.body.error).toBe("invalid_cloud_control_assertion"); }); + it("authorizes the lifecycle endpoints, each bound to its own action", async () => { + const app = createApp(); + const read = await request(app) + .get("/api/instance/lifecycle") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read")); + expect(read.status).toBe(200); + expect(read.body.actor).toMatchObject({ type: "board", isInstanceAdmin: true, source: "cloud_control" }); + + const unarchive = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:unarchive-primary")); + expect(unarchive.status).toBe(200); + expect(unarchive.body.actor).toMatchObject({ source: "cloud_control" }); + + // Cross-binding: a task-drain assertion opens no lifecycle door, a + // read assertion cannot unarchive, and lifecycle endpoints accept no + // extra methods. + const crossAction = await request(app) + .get("/api/instance/lifecycle") + .set(CLOUD_CONTROL_HEADER, freshAssertion("task-drain:read")); + expect(crossAction.status).toBe(401); + const readAsWrite = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read")); + expect(readAsWrite.status).toBe(401); + const wrongMethod = await request(app) + .post("/api/instance/lifecycle") + .set(CLOUD_CONTROL_HEADER, freshAssertion("lifecycle:read")); + expect(wrongMethod.status).toBe(400); + expect(wrongMethod.body.error).toBe("cloud_control_wrong_endpoint"); + }); + it("accepts the conventional trailing-slash form of the endpoint", async () => { const app = createApp(); const res = await request(app) diff --git a/server/src/__tests__/cloud-lifecycle-sync.test.ts b/server/src/__tests__/cloud-lifecycle-sync.test.ts new file mode 100644 index 00000000000..9a9897f086c --- /dev/null +++ b/server/src/__tests__/cloud-lifecycle-sync.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { + isCloudPinnedPrimaryCompany, + notifyCloudOfPrimaryCompanyLifecycleChange, +} from "../services/cloud-lifecycle-sync.js"; +import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js"; + +const STACK_ID = "stack-lifecycle-sync"; +const PRIMARY_ID = cloudTenantPrimaryCompanyId(STACK_ID); + +const CLOUD_ENV = { + PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN: "tenant-token-test", + PAPERCLIP_CLOUD_STACK_ID: STACK_ID, + PAPERCLIP_CLOUD_API_ORIGIN: "https://cloud.example.test", +} as NodeJS.ProcessEnv; + +describe("isCloudPinnedPrimaryCompany", () => { + it("matches only the derived primary company of a cloud-managed instance", () => { + expect(isCloudPinnedPrimaryCompany(PRIMARY_ID, CLOUD_ENV)).toBe(true); + expect(isCloudPinnedPrimaryCompany("some-other-company", CLOUD_ENV)).toBe(false); + // Self-hosted: no cloud signal, never primary. + expect(isCloudPinnedPrimaryCompany(PRIMARY_ID, {} as NodeJS.ProcessEnv)).toBe(false); + }); +}); + +describe("notifyCloudOfPrimaryCompanyLifecycleChange", () => { + it("rings the harness doorbell with the tenant token and stack id", async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ outcome: "archived" }), { status: 200 })); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]! as unknown as [string, RequestInit]; + expect(url).toBe("https://cloud.example.test/v1/tenant/lifecycle-changed"); + expect(init.method).toBe("POST"); + const headers = new Headers(init.headers); + expect(headers.get("authorization")).toBe("Bearer tenant-token-test"); + expect(headers.get("x-paperclip-cloud-stack-id")).toBe(STACK_ID); + }); + + it("is a silent no-op for non-primary companies and incomplete cloud metadata", async () => { + const fetchImpl = vi.fn(); + await notifyCloudOfPrimaryCompanyLifecycleChange("some-other-company", { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: { ...CLOUD_ENV, PAPERCLIP_CLOUD_API_ORIGIN: undefined } as NodeJS.ProcessEnv, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: {} as NodeJS.ProcessEnv, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("retries transient failures a bounded number of times and never throws", async () => { + const fetchImpl = vi.fn(async () => { + throw new Error("connection refused"); + }); + const sleeps: number[] = []; + await expect( + notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleep: async (ms) => { + sleeps.push(ms); + }, + }), + ).resolves.toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(sleeps).toEqual([2_000, 10_000]); + }); + + it("does not retry once the harness answered, even non-2xx", async () => { + // Any answer means the harness heard the ring; it does its own + // verified read-back, so a 4xx outcome is final. + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ error: "stack_not_found" }), { status: 404 })); + await notifyCloudOfPrimaryCompanyLifecycleChange(PRIMARY_ID, { + env: CLOUD_ENV, + fetchImpl: fetchImpl as unknown as typeof fetch, + sleep: async () => { + throw new Error("must not sleep"); + }, + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/src/__tests__/companies-service.test.ts b/server/src/__tests__/companies-service.test.ts index 40ccfc6d181..aa79a5b93a6 100644 --- a/server/src/__tests__/companies-service.test.ts +++ b/server/src/__tests__/companies-service.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { and, eq } from "drizzle-orm"; import { activityLog, @@ -24,6 +24,15 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +// Observe the Cloud lifecycle doorbell without any network: the real +// implementation is env-gated (a no-op off Cloud), so these tests assert +// WHEN the service rings it, not what the ring does. +const notifyCloudSpy = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("../services/cloud-lifecycle-sync.js", () => ({ + isCloudPinnedPrimaryCompany: () => false, + notifyCloudOfPrimaryCompanyLifecycleChange: notifyCloudSpy, +})); + import { companyService } from "../services/companies.js"; import { deriveIssuePrefixBase } from "../services/issue-prefix.js"; import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js"; @@ -809,6 +818,38 @@ describeEmbeddedPostgres("companyService", () => { expect(archiveActivity[0]).toMatchObject({ details: { agentsPaused: 1, runsCancelled: 0 } }); }); + it("rings the Cloud lifecycle doorbell exactly on archived-boundary transitions", async () => { + notifyCloudSpy.mockClear(); + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Doorbell Test Co", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + const svc = companyService(db); + const actor = { actorType: "user" as const, actorId: "test-user", agentId: null, runId: null }; + + // A plain edit never rings. + await svc.update(companyId, { name: "Doorbell Test Co (renamed)" }, actor); + expect(notifyCloudSpy).not.toHaveBeenCalled(); + + // archive() rings once; re-archiving (no transition) does not. + await svc.archive(companyId, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(1); + expect(notifyCloudSpy).toHaveBeenLastCalledWith(companyId); + await svc.archive(companyId, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(1); + + // Unarchiving through update() rings again. + await svc.update(companyId, { status: "active" }, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(2); + + // update() into archived rings too (the status-patch archive path). + await svc.update(companyId, { status: "archived" }, actor); + expect(notifyCloudSpy).toHaveBeenCalledTimes(3); + }); + it("runs the archive cascade when update() transitions a paused company to archived", async () => { const companyId = randomUUID(); const idleAgentId = randomUUID(); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 7d58c1ac64b..2eb7a0e96a5 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -23,11 +23,16 @@ const mockEnvironmentService = vi.hoisted(() => ({ findManagedSandboxEnvironment: vi.fn(), update: vi.fn(), })); +const mockCompanyService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), +})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockPublishActivity = vi.hoisted(() => vi.fn()); function registerModuleMocks() { vi.doMock("../services/index.js", () => ({ + companyService: () => mockCompanyService, heartbeatService: () => mockHeartbeatService, instanceSettingsService: () => mockInstanceSettingsService, logActivity: mockLogActivity, @@ -52,8 +57,12 @@ function defaultTransactionImplementation(fn: (tx: unknown) => Promise) // Module-scoped (not rebuilt per createApp call) so a test can assert how // many times a request opened a transaction — the task-drain audit writes // for every company must share ONE transaction, not one each. +// Rows the lifecycle read-back's plain `db.select().from(companies)` sees; +// tests assign per case. +let mockCompanyRows: Array<{ id: string; status: string }> = []; const mockDb = { transaction: vi.fn(defaultTransactionImplementation), + select: vi.fn(() => ({ from: () => Promise.resolve(mockCompanyRows) })), }; describe("instance settings routes", () => { @@ -1289,4 +1298,157 @@ describe("instance settings routes", () => { expect(mockHeartbeatService.applyTaskDrain).not.toHaveBeenCalled(); }); }); + + describe("cloud lifecycle read-back and unarchive", () => { + const STACK_ID = "stack-lifecycle-routes"; + const adminActor = { + type: "board", + userId: "paperclip-cloud", + source: "cloud_control", + isInstanceAdmin: true, + companyIds: [], + }; + const memberActor = { + type: "board", + userId: "member-1", + source: "cloud_tenant", + isInstanceAdmin: false, + companyIds: ["company-1"], + }; + // The pinned primary company id the routes derive from the stack id. + let primaryId: string; + + beforeEach(async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "test-server-token"; + process.env.PAPERCLIP_CLOUD_STACK_ID = STACK_ID; + const { cloudTenantPrimaryCompanyId } = await vi.importActual< + typeof import("../services/cloud-instance.js") + >("../services/cloud-instance.js"); + primaryId = cloudTenantPrimaryCompanyId(STACK_ID); + mockCompanyRows = []; + }); + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_CLOUD_STACK_ID; + }); + + it("reports the primary company status and how many other companies are not archived", async () => { + mockCompanyRows = [ + { id: primaryId, status: "archived" }, + { id: "company-sibling-live", status: "active" }, + { id: "company-sibling-paused", status: "paused" }, + { id: "company-sibling-archived", status: "archived" }, + ]; + const app = await createApp(adminActor); + + const res = await request(app).get("/api/instance/lifecycle"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + primaryCompanyId: primaryId, + primaryCompanyStatus: "archived", + // paused counts: any non-archived sibling keeps the stack alive. + otherUnarchivedCompanyCount: 2, + }); + }); + + it("reports a missing primary company without failing", async () => { + mockCompanyRows = [{ id: "company-other", status: "active" }]; + const app = await createApp(adminActor); + + const res = await request(app).get("/api/instance/lifecycle"); + + expect(res.status).toBe(200); + expect(res.body.primaryCompanyStatus).toBe("missing"); + }); + + it("hides the cross-company lifecycle summary from non-admin board members", async () => { + mockCompanyRows = [{ id: primaryId, status: "archived" }]; + const app = await createApp(memberActor); + + const res = await request(app).get("/api/instance/lifecycle"); + + expect(res.status).toBe(403); + }); + + it("answers 404 when the instance is not cloud-managed", async () => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_CLOUD_STACK_ID; + const readRes = await request(await createApp(adminActor)).get("/api/instance/lifecycle"); + expect(readRes.status).toBe(404); + + // The admin gate runs first, so prove the 404 with an admin actor. + const unarchiveRes = await request(await createApp(adminActor)) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + expect(unarchiveRes.status).toBe(404); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + + it("unarchives an archived primary company as a system actor", async () => { + mockCompanyService.getById.mockResolvedValue({ id: "", status: "archived" }); + mockCompanyService.update.mockImplementation(async (id: string) => ({ id, status: "active" })); + const app = await createApp(adminActor); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "active", changed: true }); + expect(mockCompanyService.update).toHaveBeenCalledWith( + primaryId, + { status: "active" }, + { actorType: "system", actorId: "paperclip-cloud", agentId: null, runId: null }, + ); + }); + + it("attributes a human admin's unarchive to that admin, not to Cloud", async () => { + mockCompanyService.getById.mockResolvedValue({ id: "", status: "archived" }); + mockCompanyService.update.mockImplementation(async (id: string) => ({ id, status: "active" })); + const humanAdmin = { + type: "board", + userId: "human-admin", + source: "session", + isInstanceAdmin: true, + companyIds: ["company-1"], + }; + const app = await createApp(humanAdmin); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(200); + expect(mockCompanyService.update).toHaveBeenCalledWith( + primaryId, + { status: "active" }, + expect.objectContaining({ actorType: "user", actorId: "human-admin" }), + ); + }); + + it("is idempotent: an unarchived primary company reports changed:false", async () => { + mockCompanyService.getById.mockResolvedValue({ id: "", status: "active" }); + const app = await createApp(adminActor); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "active", changed: false }); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + + it("requires instance admin rights to unarchive", async () => { + const app = await createApp(memberActor); + + const res = await request(app) + .post("/api/instance/lifecycle/unarchive-primary") + .send({}); + + expect(res.status).toBe(403); + expect(mockCompanyService.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 22286b7b3f1..60d1afe0733 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -58,6 +58,7 @@ import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compa import { forbidden, unauthorized, unprocessable } from "../errors.js"; export { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { cloudTenantPrimaryCompanyId } from "../services/cloud-instance.js"; function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); @@ -797,11 +798,7 @@ function constantTimeStringEqual(left: string, right: string): boolean { } function cloudTenantCompanyId(stackId: string): string { - const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest(); - bytes[6] = (bytes[6] & 0x0f) | 0x50; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = bytes.subarray(0, 16).toString("hex"); - return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; + return cloudTenantPrimaryCompanyId(stackId); } export function humanizeCloudStackSlug(stackId: string): string { diff --git a/server/src/middleware/cloud-control.ts b/server/src/middleware/cloud-control.ts index e4e490dfb86..f0e44503e8b 100644 --- a/server/src/middleware/cloud-control.ts +++ b/server/src/middleware/cloud-control.ts @@ -6,17 +6,25 @@ import { type CloudControlAction, } from "../services/cloud-runtime-identity.js"; -/** Method → the one action a control assertion must name to take it. */ -const ACTION_BY_METHOD: Record = { - GET: "task-drain:read", - POST: "task-drain:start", - DELETE: "task-drain:stop", +/** Endpoint → method → the one action a control assertion must name to take it. */ +const ACTIONS_BY_ENDPOINT: Record> = { + "/api/instance/task-drain": { + GET: "task-drain:read", + POST: "task-drain:start", + DELETE: "task-drain:stop", + }, + "/api/instance/lifecycle": { + GET: "lifecycle:read", + }, + "/api/instance/lifecycle/unarchive-primary": { + POST: "lifecycle:unarchive-primary", + }, }; /** - * Accepts Cloud's signed control assertion only on the task-drain endpoint, - * so the Cloud control plane can hold new agent work and wait for quiescence - * before restarting the container for a deploy. The JWS is the entire + * Accepts Cloud's signed control assertion only on the closed endpoint set + * above — the task-drain hold the deploy path uses, and the lifecycle + * read-back/unarchive pair behind the Cloud archive sync. The JWS is the entire * authorization: a valid assertion installs a synthetic instance-admin board * actor (replacing whatever weaker actor the request carried), each assertion * is bound to exactly one method's action, and the header is rejected loudly @@ -32,11 +40,11 @@ export function cloudControlMiddleware(): RequestHandler { next(); return; } - const expectedAction = ACTION_BY_METHOD[req.method]; // Express's non-strict routing treats a trailing slash as the same // route; the endpoint check must agree with it. const normalizedPath = req.path.length > 1 && req.path.endsWith("/") ? req.path.slice(0, -1) : req.path; - if (normalizedPath !== "/api/instance/task-drain" || !expectedAction) { + const expectedAction = ACTIONS_BY_ENDPOINT[normalizedPath]?.[req.method]; + if (!expectedAction) { res.status(400).json({ error: "cloud_control_wrong_endpoint" }); return; } diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 1784d848c6d..9612555278c 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -1,5 +1,5 @@ import { Router, type Request } from "express"; -import type { Db } from "@paperclipai/db"; +import { companies, type Db } from "@paperclipai/db"; import { patchInstanceSettingsSchema, patchInstanceExperimentalSettingsSchema, @@ -7,11 +7,16 @@ import { startTaskDrainRequestSchema, } from "@paperclipai/shared"; import { forbidden } from "../errors.js"; -import { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { + cloudTenantPrimaryCompanyId, + getCloudStackContext, + isCloudManagedInstance, +} from "../services/cloud-instance.js"; import { getHiddenSettings } from "../services/settings-visibility.js"; import { validate } from "../middleware/validate.js"; import { logger } from "../middleware/logger.js"; import { + companyService, heartbeatService, instanceSettingsService, logActivity, @@ -403,5 +408,78 @@ export function instanceSettingsRoutes(db: Db) { res.json({ wasActive }); }); + // Cloud lifecycle read-back: the harness answers a tenant's archive + // doorbell by asking this instance what the Cloud-pinned primary + // company's status actually is, so the shared-token doorbell can stay a + // hint (see cloud-lifecycle-sync.ts). Reached by the harness with a + // `lifecycle:read` Cloud control assertion. Instance-admin only for + // humans: the response summarizes lifecycle state across EVERY company + // on the instance, which a board member scoped to one company must not + // be able to infer. + router.get("/instance/lifecycle", async (req, res) => { + assertCanManageInstanceSettings(req); + const stackId = getCloudStackContext()?.stackId; + if (!stackId) { + res.status(404).json({ error: "not_cloud_managed" }); + return; + } + const primaryCompanyId = cloudTenantPrimaryCompanyId(stackId); + const rows = await db + .select({ id: companies.id, status: companies.status }) + .from(companies); + const primary = rows.find((row) => row.id === primaryCompanyId); + res.json({ + primaryCompanyId, + primaryCompanyStatus: primary?.status ?? "missing", + otherUnarchivedCompanyCount: rows.filter( + (row) => row.id !== primaryCompanyId && row.status !== "archived", + ).length, + }); + }); + + // Cloud restore counterpart: "Resume organization" on a stack that was + // archived because its primary company was archived must bring the + // company back too, or the tenant's next doorbell would re-archive the + // stack the customer just resumed. Idempotent: a primary company that is + // not archived reports changed:false. Reached by the harness with a + // `lifecycle:unarchive-primary` control assertion; instance admins hold + // the same power through PATCH /api/companies/:id already. + router.post("/instance/lifecycle/unarchive-primary", async (req, res) => { + assertCanManageInstanceSettings(req); + const stackId = getCloudStackContext()?.stackId; + if (!stackId) { + res.status(404).json({ error: "not_cloud_managed" }); + return; + } + const primaryCompanyId = cloudTenantPrimaryCompanyId(stackId); + const companySvc = companyService(db); + const existing = await companySvc.getById(primaryCompanyId); + if (!existing) { + res.status(404).json({ error: "primary_company_not_found" }); + return; + } + if (existing.status !== "archived") { + res.json({ status: existing.status, changed: false }); + return; + } + // Attribution: only the harness's synthetic cloud_control actor logs + // as the Cloud system identity; a human instance admin calling this + // endpoint is recorded as themselves, exactly like an in-product + // unarchive. + const actor = req.actor.source === "cloud_control" + ? { actorType: "system" as const, actorId: "paperclip-cloud", agentId: null, runId: null } + : (() => { + const info = getActorInfo(req); + return { + actorType: info.actorType, + actorId: info.actorId, + agentId: info.agentId, + runId: info.runId, + }; + })(); + const updated = await companySvc.update(primaryCompanyId, { status: "active" }, actor); + res.json({ status: updated?.status ?? "active", changed: true }); + }); + return router; } diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 54e72f1d959..c7d44d0aa5d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6215,6 +6215,24 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, }); +registry.registerPath({ + method: "get", + path: "/api/instance/lifecycle", + tags: ["instance"], + summary: + "Read the Cloud-pinned primary company's lifecycle status and how many other companies are not archived; 404 when the instance is not Cloud-managed", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + +registry.registerPath({ + method: "post", + path: "/api/instance/lifecycle/unarchive-primary", + tags: ["instance"], + summary: + "Unarchive the Cloud-pinned primary company (idempotent); used by the Cloud control plane while restoring an archived stack", + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); + // ─── Board chat (Conference Room Chat, experimental) ────────────────────────── registry.registerPath({ diff --git a/server/src/services/cloud-instance.ts b/server/src/services/cloud-instance.ts index e6825dda777..cdd3636969d 100644 --- a/server/src/services/cloud-instance.ts +++ b/server/src/services/cloud-instance.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + export type CloudInstanceEnv = Record; export type CloudStackContext = { @@ -50,3 +52,17 @@ export function getCloudStackContext( cloudOrigin: normalizeOptionalEnvValue(env.PAPERCLIP_CLOUD_API_ORIGIN), }; } + +/** + * The Cloud-pinned primary company id for a stack: the deterministic + * v5-style UUID the trusted-header lane seeds and every Cloud surface pins. + * The derivation is frozen — seeded companies fleet-wide already carry + * these ids. middleware/auth.ts delegates here; this is the one definition. + */ +export function cloudTenantPrimaryCompanyId(stackId: string): string { + const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest(); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = bytes.subarray(0, 16).toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} diff --git a/server/src/services/cloud-lifecycle-sync.ts b/server/src/services/cloud-lifecycle-sync.ts new file mode 100644 index 00000000000..8eb3e6e24c4 --- /dev/null +++ b/server/src/services/cloud-lifecycle-sync.ts @@ -0,0 +1,103 @@ +import { logger } from "../middleware/logger.js"; +import { + cloudTenantPrimaryCompanyId, + getCloudStackContext, + type CloudInstanceEnv, +} from "./cloud-instance.js"; + +/** + * Cloud lifecycle doorbell: when the Cloud-pinned primary company is + * archived or unarchived on a managed instance, ring the harness + * (`POST /v1/tenant/lifecycle-changed`) so Paperclip Cloud can converge the + * stack — an org whose only company is archived should not keep running, + * and should read "Archived" on /orgs rather than "Live". + * + * The ring is a HINT by contract: the harness never trusts it. It reads + * the primary company's status back through `GET /api/instance/lifecycle` + * (Cloud control assertion) before changing anything, so the worst a lost, + * duplicated, or forged ring can do is trigger one verified read. + * + * Fire-and-forget with a bounded retry: archiving a company must never + * block or fail on Cloud availability, and a missed ring only delays the + * badge/suspend convergence, it cannot corrupt state. + */ + +const REQUEST_TIMEOUT_MS = 10_000; +const RETRY_DELAYS_MS = [2_000, 10_000]; + +export type CloudLifecycleSyncOptions = { + env?: CloudInstanceEnv; + fetchImpl?: typeof fetch; + sleep?: (ms: number) => Promise; +}; + +/** + * True when this company is the Cloud-pinned primary company of a managed + * instance — the only company whose archive state the harness mirrors. + */ +export function isCloudPinnedPrimaryCompany( + companyId: string, + env: CloudInstanceEnv = process.env, +): boolean { + const stackId = getCloudStackContext(env)?.stackId; + return Boolean(stackId) && cloudTenantPrimaryCompanyId(stackId!) === companyId; +} + +/** + * Ring the harness about a lifecycle change of the pinned primary company. + * No-op (resolved promise) on self-hosted instances, non-primary companies, + * or incomplete Cloud metadata. Never throws. + */ +export async function notifyCloudOfPrimaryCompanyLifecycleChange( + companyId: string, + options: CloudLifecycleSyncOptions = {}, +): Promise { + const env = options.env ?? process.env; + if (!isCloudPinnedPrimaryCompany(companyId, env)) return; + const context = getCloudStackContext(env); + const token = env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim(); + if (!context?.stackId || !context.cloudOrigin || !token) return; + + const fetchImpl = options.fetchImpl ?? fetch; + // Unreferenced timers: this detached retry loop must never hold the + // process open — a shutdown mid-retry just drops the ring, which the + // harness's verified read-back model tolerates by design. + const sleep = + options.sleep ?? + ((ms: number) => + new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + })); + const url = `${context.cloudOrigin}/v1/tenant/lifecycle-changed`; + + for (let attempt = 0; ; attempt += 1) { + try { + const response = await fetchImpl(url, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "x-paperclip-cloud-stack-id": context.stackId, + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + // Any response means the harness heard the ring — it does its own + // verified read-back, so even a non-2xx outcome is not retried + // beyond transient server errors. + if (response.ok || response.status < 500) return; + if (attempt >= RETRY_DELAYS_MS.length) { + logger.warn( + { status: response.status, url }, + "Cloud lifecycle doorbell got a server error; giving up", + ); + return; + } + } catch (err) { + if (attempt >= RETRY_DELAYS_MS.length) { + logger.warn({ err, url }, "Cloud lifecycle doorbell unreachable; giving up"); + return; + } + } + await sleep(RETRY_DELAYS_MS[attempt]!); + } +} diff --git a/server/src/services/cloud-runtime-identity.ts b/server/src/services/cloud-runtime-identity.ts index db8f5b77bd2..e6567f2cf9e 100644 --- a/server/src/services/cloud-runtime-identity.ts +++ b/server/src/services/cloud-runtime-identity.ts @@ -449,6 +449,8 @@ export const CLOUD_CONTROL_ACTIONS = [ "task-drain:read", "task-drain:start", "task-drain:stop", + "lifecycle:read", + "lifecycle:unarchive-primary", ] as const; export type CloudControlAction = (typeof CLOUD_CONTROL_ACTIONS)[number]; diff --git a/server/src/services/companies.ts b/server/src/services/companies.ts index c2b27e0ebc5..b0838f14aca 100644 --- a/server/src/services/companies.ts +++ b/server/src/services/companies.ts @@ -36,6 +36,7 @@ import { } from "@paperclipai/db"; import { notFound, unprocessable } from "../errors.js"; import { isCloudManagedInstance } from "./cloud-instance.js"; +import { notifyCloudOfPrimaryCompanyLifecycleChange } from "./cloud-lifecycle-sync.js"; import { MAX_ISSUE_PREFIX_ATTEMPTS, deriveIssuePrefixBase, @@ -442,10 +443,20 @@ export function companyService(db: Db) { company: enrichCompany(hydrated), reactivated: shouldLogReactivation ? { agentsRestored } : null, archiveCascade, + unarchived: willReactivate && existing.status === "archived", issuePrefixRederived, }; }); if (!result) return null; + // Post-commit, fire-and-forget, and BEFORE any finalization that + // could throw: a Cloud-pinned primary company that crossed the + // archived boundary (either direction) rings the harness so the + // stack itself can converge. The status transaction has already + // committed, so a later cascade or activity-log failure must not + // leave Cloud unaware of a company that is in fact archived. + if (result.archiveCascade || result.unarchived) { + void notifyCloudOfPrimaryCompanyLifecycleChange(id); + } if (result.issuePrefixRederived) { await logActivity(db, { companyId: id, @@ -514,7 +525,10 @@ export function companyService(db: Db) { }); if (!result) return null; + // Same doorbell rule as update(): the archive is committed, so ring + // before finalization, which can throw without undoing it. if (result.cascade) { + void notifyCloudOfPrimaryCompanyLifecycleChange(id); await finalizeArchive(id, actor, result.cascade); } From 950ccb8eef8ee09bfad49cebbe108fdfec887ee3 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:17:25 -0700 Subject: [PATCH 05/11] ci: enable Grok qualification in the trusted paid workflow (#13845) ## Thinking Path > - Paperclip manages AI agents and their work. > - Product E2E tests verify tasks through the browser, server, and runner. > - These paid tests use a trusted workflow from master and an isolated target commit. > - The Grok target branch selects XAI_API_KEY, but the trusted workflow does not deliver that credential. > - Its artifact test also needs the pinned Python verifier before execution. > - This pull request adds both bindings inside the existing paid boundary. > - The tests can then run on the configured EC2 fleet without laptop Docker. ## Linked Issues or Issue Description Related evaluation infrastructure: https://github.com/paperclipai/paperclip/pull/11297. No duplicate Grok paid-workflow change was found. **What existing behavior does this improve?** Branch-targeted Grok Product E2E qualification in the existing paid workflow. **Current behavior** Grok cells cannot receive their selected API credential. The Grok build-revise case also misses the artifact-verifier setup step. **Proposed behavior** Deliver XAI_API_KEY only when the selected matrix credential is XAI_API_KEY. Prepare the existing pinned verifier for the Grok qualification suite. **Reason and benefit** Run the controller, browser, runner and artifact checks on the EC2 fleet. Preserve default-branch workflow authorization and protected environment secret access. ## What Changed - Bind the selected XAI credential only in the paid test step. - Install the checksum-verified Grok binary for local cells before provider access. - Include Grok qualification in the existing pinned artifact-verifier preparation. - Add an optional max_parallel input that can only lower the configured campaign concurrency. Use 1 for the Grok test key. - Extend security assertions and document setup. ## Verification - Ran the Product E2E workflow-security tests: 11 passed. - Checked the diff for whitespace errors. - Reviewed credential selection, setup ordering, numeric actor gates, target commit pinning, and trusted report checkout. - Live Grok execution follows after this workflow is available on master. This PR does not claim completed Grok qualification. ## Risks The paid test step can use the selected XAI credential and incur provider charges. The credential remains in runner-e2e-paid and is absent from setup, build, and reporting jobs. The default-branch gate and existing environment restrictions remain in place. No database migration or product behavior changes. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code editing, and tool execution. The exact deployment model ID and context-window size are not exposed in this session. ## 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 - [x] 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 - [ ] 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 --------- Co-authored-by: Paperclip --- .github/workflows/runner-full-stack-e2e.yml | 19 ++++++++++++++++++- tests/runner-e2e/README.md | 14 ++++++++++++++ tests/runner-e2e/SECURITY.md | 4 ++-- tests/runner-e2e/workflow-security.test.ts | 15 +++++++++++++-- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 9016efa30c2..5556752cbc4 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -9,6 +9,10 @@ on: description: "Branch in paperclipai/paperclip to test; the trusted workflow still runs from master" type: string required: false + max_parallel: + description: "Optional lower concurrency for this campaign (cannot exceed the configured limit)" + type: string + required: false all: description: "Run the complete paid matrix when no narrower selector is supplied" type: boolean @@ -286,6 +290,7 @@ jobs: SELECT_ID: ${{ inputs.id }} MAX_PARALLEL: ${{ vars.RUNNER_E2E_MAX_PARALLEL || needs.authorize.outputs.max_parallel_default }} MAX_PARALLEL_LIMIT: ${{ needs.authorize.outputs.max_parallel_limit }} + REQUESTED_MAX_PARALLEL: ${{ inputs.max_parallel }} run: | set -euo pipefail args=(--matrix-json) @@ -346,6 +351,13 @@ jobs: echo "RUNNER_E2E_MAX_PARALLEL must be an integer from 1 through $MAX_PARALLEL_LIMIT for the selected runner." >&2 exit 1 fi + if [ -n "${REQUESTED_MAX_PARALLEL:-}" ]; then + if ! [[ "$REQUESTED_MAX_PARALLEL" =~ ^[1-9][0-9]{0,2}$ ]] || [ "$REQUESTED_MAX_PARALLEL" -gt "$MAX_PARALLEL" ]; then + echo "max_parallel must be an integer from 1 through the configured campaign limit." >&2 + exit 1 + fi + MAX_PARALLEL="$REQUESTED_MAX_PARALLEL" + fi echo "max_parallel=$MAX_PARALLEL" >> "$GITHUB_OUTPUT" daytona_image: @@ -874,6 +886,10 @@ jobs: if: matrix.environmentId == 'local' && (matrix.profileId == 'legacy-opencode' || matrix.profileId == 'runner-opencode' || matrix.suiteId == 'openrouter-model-breadth') run: node packages/paperclip-runner/scripts/materialize-opencode-binary.mjs + - name: Install checksum-verified Grok executable + if: matrix.environmentId == 'local' && matrix.profileId == 'runner-acpx-grok' + run: node packages/grok-acp/install.mjs + - name: Download immutable campaign outputs if: startsWith(matrix.profileId, 'runner-') || matrix.suiteId == 'openrouter-model-breadth' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 @@ -1024,7 +1040,7 @@ jobs: NODE - name: Prepare pinned Python artifact oracle image - if: matrix.suiteId == 'everyday-workflows' && (matrix.caseId == 'build-revise' || matrix.caseId == 'delegate-feedback' || matrix.caseId == 'agent-review-handoff' || matrix.caseId == 'hire-reuse' || matrix.caseId == 'recover-controller' || matrix.caseId == 'stop-redirect') + if: (matrix.suiteId == 'everyday-workflows' || matrix.suiteId == 'grok-qualification') && (matrix.caseId == 'build-revise' || matrix.caseId == 'delegate-feedback' || matrix.caseId == 'agent-review-handoff' || matrix.caseId == 'hire-reuse' || matrix.caseId == 'recover-controller' || matrix.caseId == 'stop-redirect') run: | set -euo pipefail oracle_image='python@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285' @@ -1037,6 +1053,7 @@ jobs: OPENAI_API_KEY: ${{ matrix.credentialName == 'OPENAI_API_KEY' && secrets.OPENAI_API_KEY || '' }} ANTHROPIC_API_KEY: ${{ matrix.credentialName == 'ANTHROPIC_API_KEY' && secrets.ANTHROPIC_API_KEY || '' }} OPENROUTER_API_KEY: ${{ matrix.credentialName == 'OPENROUTER_API_KEY' && secrets.OPENROUTER_API_KEY || '' }} + XAI_API_KEY: ${{ matrix.credentialName == 'XAI_API_KEY' && secrets.XAI_API_KEY || '' }} DAYTONA_API_KEY: ${{ matrix.environmentId == 'daytona' && secrets.DAYTONA_API_KEY || '' }} PAPERCLIP_E2E_DAYTONA_IMAGE: ${{ needs.daytona_image.outputs.image }} PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH: ${{ github.workspace }}/packages/paperclip-runner/provider-pack diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index d10684439ec..00c1ffc3d2e 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -1001,3 +1001,17 @@ requires the saved content and usable composer to remain visible. Run it with th standard `tests/e2e/playwright.config.ts`; no provider or Daytona credentials are needed. Browser-support tests separately exercise blank-root/pending-module failure evidence, so a future blank page is distinguishable from a loaded task. + +### Grok branch qualification on EC2 + +The trusted default-branch workflow can run the explicit `grok-qualification` +suite from a selected target branch. Store `XAI_API_KEY` only in the protected +`runner-e2e-paid` environment. The paid step delivers it only to a profile whose +credential name is `XAI_API_KEY`. The Grok `build-revise` cells prepare the same +pinned Python artifact verifier used by Everyday Workflows, before credentials +are exposed. Local Grok cells also run the checksum-verifying binary installer +before receiving credentials. With `RUNNER_E2E_AWS_ENABLED=true`, the controller, browser and +artifact verifier run on the existing EC2 fleet; no developer laptop Docker +service is required. Set the optional `max_parallel` dispatch input to `1` for +keys with low request limits. It can only lower the configured campaign limit. +Keep subscription qualification separate from API-key results. diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index 57ed632d78f..e781d54fc63 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -1,6 +1,6 @@ # Runner E2E security for a public repository -This suite can spend provider money, expose four API credentials to isolated +This suite can spend provider money, expose selected API credentials to isolated test processes, publish a container, retain private visual evidence, and write public structured evidence. Treat changes to the workflow, harness, fixture prompts, evidence packager, and publisher as security-sensitive production @@ -64,7 +64,7 @@ rejects mutable tag or branch references. ## Secrets and protected environments Create `runner-e2e-paid`, restrict deployments to the default branch, and put -only `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, and +only `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `XAI_API_KEY`, and `DAYTONA_API_KEY` in it. Do not duplicate these credentials as repository- or organization-level Actions secrets: environment scoping is the boundary that prevents branch or pull-request jobs from requesting them. Require approval diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 1a9c021dd0d..5e3fd4084f9 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -294,12 +294,18 @@ describe("public repository paid workflow security", () => { expect(paidExecution).toBeGreaterThan(awsFfmpegInstall); expect(paidExecution).toBeGreaterThan(daytonaPluginPreparation); expect(paidExecution).toBeGreaterThan(everydayOraclePreparation); + const grokPreparation = paidJob.indexOf("- name: Install checksum-verified Grok executable"); + expect(grokPreparation).toBeGreaterThan(paidInstall); + expect(paidExecution).toBeGreaterThan(grokPreparation); + expect(paidJob).toContain("if: matrix.environmentId == 'local' && matrix.profileId == 'runner-acpx-grok'"); + expect(paidJob).toContain("run: node packages/grok-acp/install.mjs"); + const everydayOracleStep = paidJob.slice( everydayOraclePreparation, paidExecution, ); expect(everydayOracleStep).toContain( - "if: matrix.suiteId == 'everyday-workflows' && (matrix.caseId == 'build-revise' || matrix.caseId == 'delegate-feedback' || matrix.caseId == 'agent-review-handoff' || matrix.caseId == 'hire-reuse' || matrix.caseId == 'recover-controller' || matrix.caseId == 'stop-redirect')", + "if: (matrix.suiteId == 'everyday-workflows' || matrix.suiteId == 'grok-qualification') && (matrix.caseId == 'build-revise' || matrix.caseId == 'delegate-feedback' || matrix.caseId == 'agent-review-handoff' || matrix.caseId == 'hire-reuse' || matrix.caseId == 'recover-controller' || matrix.caseId == 'stop-redirect')", ); expect(everydayOracleStep).toContain( `oracle_image='${everydayOracleImage}'`, @@ -367,6 +373,10 @@ describe("public repository paid workflow security", () => { ); expect(authorizeJob).toContain('echo "max_parallel_limit=100"'); expect(fullStack).toContain('[ "$MAX_PARALLEL_LIMIT" -gt 100 ]'); + expect(fullStack).toContain("REQUESTED_MAX_PARALLEL: ${{ inputs.max_parallel }}"); + expect(fullStack).toContain('[ "$REQUESTED_MAX_PARALLEL" -gt "$MAX_PARALLEL" ]'); + expect(fullStack).toContain('[[ "$REQUESTED_MAX_PARALLEL" =~ ^[1-9][0-9]{0,2}$ ]]'); + expect(fullStack).toContain( '[ "$MAX_PARALLEL" -gt "$MAX_PARALLEL_LIMIT" ]', ); @@ -511,6 +521,7 @@ describe("public repository paid workflow security", () => { OPENAI_API_KEY: "matrix.credentialName == 'OPENAI_API_KEY'", ANTHROPIC_API_KEY: "matrix.credentialName == 'ANTHROPIC_API_KEY'", OPENROUTER_API_KEY: "matrix.credentialName == 'OPENROUTER_API_KEY'", + XAI_API_KEY: "matrix.credentialName == 'XAI_API_KEY'", DAYTONA_API_KEY: "matrix.environmentId == 'daytona'", })) { expect(fullStack).toContain( @@ -538,7 +549,7 @@ describe("public repository paid workflow security", () => { ); const providerSecretReferences = [ ...contents.matchAll( - /secrets(?:\.(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENROUTER_API_KEY|DAYTONA_API_KEY)\b|\[['"](?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENROUTER_API_KEY|DAYTONA_API_KEY)['"]\])/g, + /secrets(?:\.(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENROUTER_API_KEY|XAI_API_KEY|DAYTONA_API_KEY)\b|\[['"](?:OPENAI_API_KEY|ANTHROPIC_API_KEY|OPENROUTER_API_KEY|XAI_API_KEY|DAYTONA_API_KEY)['"]\])/g, ), ]; if (providerSecretReferences.length > 0) { From ac854a9b59f9c0520cd91148c9acb3609ff65a6f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:30:09 -0700 Subject: [PATCH 06/11] ci: build Daytona eval images on the authorized fleet (#13847) ## Thinking Path > - Paperclip manages AI agents and their work. > - Product E2E campaigns test the browser, server, and runner together. > - The trusted workflow selects an authorized execution fleet. > - Daytona image builds still use a fixed GitHub-hosted runner. > - This pull request applies the existing fleet selection to image builds. > - Campaign builds and tests then use the configured EC2 fleet. ## Linked Issues or Issue Description Refs: https://github.com/paperclipai/paperclip/pull/13845 **What existing behavior does this improve?** The location of Daytona Product E2E image builds. **Current behavior** The image job uses ubuntu-latest even when RUNNER_E2E_AWS_ENABLED selects EC2 for the rest of the campaign. **Proposed behavior** Use the existing authorized runner output for the image job. Preserve the GitHub-hosted fallback when the EC2 switch is off. ## What Changed - Route Daytona image builds through the existing authorized runner selection. - Assert that the image job depends on authorization and receives no provider credentials. - Document the build routing and credential boundary. ## Verification - Product E2E workflow security: 11 tests passed. - git diff --check passed. - Reviewed actor checks, workflow triggers, target commit selection, signing, and package permissions. They are unchanged. - Required CI checks pass on the latest head. The first CI attempt had failures in unchanged chat tests; one diagnostic rerun passed. Both attempts remain in Actions. - Greptile reviewed the latest head at 5/5 with no inline findings. - Live EC2 image verification follows after this trusted workflow change is merged. No local Docker execution. ## Risks EC2 image builds depend on the fleet having working Docker and sufficient disk space. Existing authorization, image digest checks, signing, and the GitHub-hosted fallback remain in place. No provider secrets are added to the image job. No application or database changes. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code editing, and tool execution. The exact deployment model ID and context-window size are not exposed in this session. ## 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 - [x] 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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .github/workflows/runner-full-stack-e2e.yml | 3 ++- tests/runner-e2e/SECURITY.md | 7 ++++++- tests/runner-e2e/workflow-security.test.ts | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/runner-full-stack-e2e.yml b/.github/workflows/runner-full-stack-e2e.yml index 5556752cbc4..d689e073068 100644 --- a/.github/workflows/runner-full-stack-e2e.yml +++ b/.github/workflows/runner-full-stack-e2e.yml @@ -363,7 +363,8 @@ jobs: daytona_image: name: Publish verified Daytona image needs: [authorize, target_lock, catalog] - runs-on: ubuntu-latest + # Keep image builds on the same authorized fleet as campaign execution. + runs-on: ${{ needs.authorize.outputs.test_runner }} timeout-minutes: 45 permissions: contents: read diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index e781d54fc63..e499f638d1e 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -98,7 +98,8 @@ the actor gate, environment branch restriction, and protected default branch. ## Runner fleet isolation -When `RUNNER_E2E_AWS_ENABLED=true`, paid matrix cells use the exact RunsOn fleet +When `RUNNER_E2E_AWS_ENABLED=true`, paid matrix cells, reusable runner builds, +and Daytona image builds use the exact RunsOn fleet selector `runs-on/fleet=paperclip-public-pr-x64/env=public-ci`, matching the AWS fleet selected by `pr-trusted.yml` only after its stable numeric-ID trust gate. Any other or missing toggle value falls back to the GitHub-hosted @@ -106,6 +107,10 @@ Any other or missing toggle value falls back to the GitHub-hosted between those two reviewed literal labels; it never evaluates a configured runner label. +Daytona image builds depend on successful campaign authorization and keep the +existing GHCR publication and signing permissions. They receive no provider +credentials and do not enter the `runner-e2e-paid` environment. + Keep both runner targets restricted to `paperclipai/paperclip` and workflows that independently authorize trusted source revisions. Never let a fork or untrusted pull-request workflow target them. The RunsOn fleet must launch a diff --git a/tests/runner-e2e/workflow-security.test.ts b/tests/runner-e2e/workflow-security.test.ts index 5e3fd4084f9..dc0ef205f85 100644 --- a/tests/runner-e2e/workflow-security.test.ts +++ b/tests/runner-e2e/workflow-security.test.ts @@ -200,6 +200,14 @@ describe("public repository paid workflow security", () => { fullStack.indexOf(" daytona_image:"), fullStack.indexOf(" build_runner_artifacts:"), ); + expect(daytonaImageJob).toMatch(buildRunnerNeeds); + expect(daytonaImageJob).toContain( + "runs-on: ${{ needs.authorize.outputs.test_runner }}", + ); + expect(daytonaImageJob).not.toContain("name: runner-e2e-paid"); + expect(daytonaImageJob).not.toMatch( + /(?:OPENAI|ANTHROPIC|OPENROUTER|DAYTONA|XAI)_API_KEY/, + ); expect(authorizeJob).toContain( "aws_runner='runs-on/fleet=paperclip-public-pr-x64/env=public-ci'", ); From 106d89314feaa15c7bba259363b08e6ec7bb88d1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:31:39 -0700 Subject: [PATCH 07/11] fix(evals): keep raw diagnostics out of public history (#13850) ## Thinking Path > - Paperclip manages AI agents and their work. > - Product E2E campaigns retain evidence from live provider runs. > - The public publisher copied per-attempt files based on their extension. > - Credential redaction does not remove hidden reasoning or provider session IDs. > - This pull request keeps those raw diagnostics out of public evidence bundles. > - Public reports retain normalized grades and declared fixture screenshots. ## Linked Issues or Issue Description **What happened?** The Product history publisher accepted all JSON, log, Markdown, and text files under an attempt directory. API snapshots and process logs can contain provider reasoning even after credential redaction. **Expected behavior** Keep raw attempt diagnostics in retained Actions artifacts. Publish normalized results and declared fixture screenshots. Preserve the original grades and attempt evidence. **Steps to reproduce** Place a credential-redacted API snapshot with a reasoning event under an attempt's snapshots directory. The previous public path check admitted it because it ended in .json. ## What Changed - Remove extension-based admission for per-attempt text files in both S3 and Pages staging. - Require PNG paths to appear in the existing screenshot declaration allowlist. - Preserve root normalized results, grading, billing, provenance, and reviewed screenshot behavior. - Add seven denial cases for raw diagnostics, renamed files, malformed JSON, and false screenshot declarations. - Update report copy and the publication security contract. ## Verification - Focused history and report tests: 47 passed. - Replayed the filter against a copy of retained Grok evidence: six diagnostic files removed, one declared screenshot retained, zero provider calls. Original artifacts remain unchanged. - git diff --check passed. - Product typecheck in the reused local dependency tree reports an unrelated plugin type mismatch for organizationSwitcher in server/src/services/plugin-capability-validator.ts. CI will verify a fresh install. - No local browser or Docker execution. ## Risks Public reports no longer link raw per-attempt logs or API snapshots. Those files remain in the original Actions artifact. This change affects future publication only; it does not withdraw or rewrite existing immutable public campaigns. Normalized result content and marked screenshot review retain their existing boundaries. No workflow authorization, provider credentials, application behavior, or database changes. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code editing, and tool execution. The exact deployment model ID and context-window size are not exposed in this session. ## 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 - [x] 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 - [ ] 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 Co-authored-by: Paperclip --- tests/runner-e2e/SECURITY.md | 15 ++++++---- tests/runner-e2e/dashboard.ts | 4 +-- tests/runner-e2e/history-index.ts | 4 +-- tests/runner-e2e/history-publish.ts | 14 +++++---- tests/runner-e2e/history.test.ts | 44 +++++++++++++++++++++++------ tests/runner-e2e/report.test.ts | 4 +-- 6 files changed, 58 insertions(+), 27 deletions(-) diff --git a/tests/runner-e2e/SECURITY.md b/tests/runner-e2e/SECURITY.md index e499f638d1e..2653cec086f 100644 --- a/tests/runner-e2e/SECURITY.md +++ b/tests/runner-e2e/SECURITY.md @@ -197,8 +197,8 @@ latest pointers are mutable, and S3 versioning makes those updates recoverable. ## Public evidence boundary CloudFront and GitHub Pages are public. Fixture identifiers, timing, token -usage, costs, normalized results, allowlisted inert structured per-attempt -evidence, and trusted runner PNG screenshots are expected public data. Each +usage, costs, normalized results, and trusted runner PNG screenshots are +expected public data. Each public screenshot must carry the explicit `public-runner-fixture` marker in the normalized result. This includes a `failure.png` capture. Screenshot paths must be safe PNG basenames and must be tied to the exact normalized execution @@ -211,8 +211,10 @@ fixed catalog labels and sanitized numeric/status fields. Video, archives, generated Playwright/blob/HTML report trees, SVG or other active content, credentials, Paperclip homes, databases, workspaces, master keys, raw/unredacted logs, unmarked images, and unallowlisted files are not public. -Allowlisted `.log` copies must pass the existing exact-value/key-shape scan and -redaction boundary. +Per-attempt JSON snapshots, result copies, logs, Markdown, and text files stay +in the retained Actions artifact. Credential redaction does not remove hidden +reasoning or provider session identities, so a text extension never admits a +file to the public bundle. Graded results remain in `normalized-results.json`. The packaged evidence uploaded as a 30-day GitHub Actions artifact has a different, broader boundary. Text is exact-value and key-shape scanned and @@ -225,8 +227,9 @@ capture requires review of the visible page state. Videos remain access-controlled. Before permanent publication, the campaign publisher creates a separate S3 -stage and retains only allowlisted `.json`, `.log`, `.md`, and `.txt` evidence, -result PNGs with the explicit `public-runner-fixture` marker. +stage and retains only result PNGs with the explicit `public-runner-fixture` +marker under `evidence/`. This applies even to malformed JSON and renamed +process logs. Original packaged evidence and grades remain unchanged. It then launches publisher-only Chromium with networking blocked to render one `public-images/campaign-summary.png`. That fixed-path PNG is capped at 12 MiB and its signature is validated. Per-attempt XML is excluded because browsers diff --git a/tests/runner-e2e/dashboard.ts b/tests/runner-e2e/dashboard.ts index 5537e80c39f..4ad7912c25e 100644 --- a/tests/runner-e2e/dashboard.ts +++ b/tests/runner-e2e/dashboard.ts @@ -1104,7 +1104,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) {

Full-stack acceptance campaign

${html(input.title)}

-

A browser-verified matrix of runner profiles, execution environments, and deterministic task contracts. Declared PNG screenshots and sanitized structured evidence are retained with every published campaign; additional diagnostic evidence remains in the access-controlled workflow artifact.

+

A browser-verified matrix of runner profiles, execution environments, and deterministic task contracts. Declared PNG screenshots and normalized results are retained with every published campaign; additional diagnostic evidence remains in the access-controlled workflow artifact.

@@ -1145,7 +1145,7 @@ export function renderRunnerE2EDashboard(input: RunnerDashboardInput) { ${suiteSections} ${historySection} -
Generated ${html(input.generatedAt)}${catalog.length} catalog executions · Declared screenshots and sanitized structured evidence published
+
Generated ${html(input.generatedAt)}${catalog.length} catalog executions · Declared screenshots and normalized results published