Files
PaperClipAI/ui/src/components/AgentConfigForm.render.test.tsx
T
DottaandPaperclip 0f15f82b19 feat: offer existing Claude and Codex agents a runner upgrade (#17122)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Each agent stores its harness, model, account and runtime
configuration.
> - New Claude and Codex agents now use Paperclip Runner on qualified
targets.
> - Existing agents keep their legacy runner and need a clear upgrade
action.
> - The existing configuration API already translates settings and
records revisions.
> - This pull request adds an explicit upgrade action that uses that
API.
> - Operators can retain supported settings and correct rejected
settings in place.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Existing Claude and Codex agent configuration. Related defaults: #15683
and #15993.

**Current behavior**

Existing agents retain their legacy runner. The operator must find the
runner selector in Advanced to change it.

**Proposed behavior**

Show Upgrade runner when Paperclip Runner is the qualified default for
the saved environment. One click saves pending edits and requests the
existing server conversion. Show any rejected settings without changing
saved configuration.

**Reason and benefit**

Operators can adopt the current runner while retaining their harness,
model, account bindings, instructions and history. Unsupported settings
remain visible and editable.

**Breaking changes**

None. Visiting configuration and saving unrelated changes do not upgrade
an agent. The server API, authorization and conversion rules are
unchanged.

## What Changed

- Add an upgrade notice, pending state, saved result and retry feedback
to the production configuration form.
- Include pending profile, runtime and environment-variable edits in the
upgrade request.
- Bind asynchronous upgrade feedback and save receipts to their original
agent. Navigating away cannot show success on another agent or redirect
back.
- Keep the notice absent for native agents, other harnesses, unavailable
targets and failed discovery.
- Add configuration/API regressions and database session-reset checks
for Claude and Codex.
- Add 13 production-page Storybook states with browser checks in both
themes and on mobile.
- Update the product contract and feature/access maps.

## Verification

- Configuration form and original-agent save receipts: 135 tests passed.
Existing agent route/rename helpers also pass.
- Runner selection, configuration API and database migration
regressions: 220 tests passed.
- Storybook browser checks: 26 passed. Interactive cases wait for
Storybook's visible Pass report. Static cases check the rendered
production form. These use simulated HTTP boundaries.
- Full typecheck, build, map checks and token gates passed on the
current head. Storybook build and public preview verification passed.
- Published fixture preview:
https://storybook.paperclip.page/storybook/branches/codex~2Fagent-runner-upgrade/?path=/story/agents-runner-upgrade--legacy-codex
- All 58 current-head PR checks passed, including the full test shards,
browser suites, build and aggregate gates. The duplicate local pnpm
test:run was stopped after 34 minutes once the same-head full CI suite
passed; it had not finished its general-server phase. No local
full-suite pass is claimed.
- Real disposable local test drive: Codex used gpt-5.4 and Claude used
claude-sonnet-4-6. Both completed one task on Legacy, upgraded through
the browser button, and completed another task on Paperclip Runner with
the same model and API credentials. Independent file checks confirmed
saved instructions and environment bindings before and after conversion.
- Reopened both configurations and compared identity, model, credential
references, instruction hashes, working directories, run policies and
configuration revisions.
- A Claude Chrome setting produced a field-specific rejection. The saved
agent stayed Legacy. Clearing Chrome in the form and clicking Upgrade
runner saved the pending correction and completed conversion.

## Risks

- The existing translator rejects settings that the new runner cannot
honor. The notice shows that error and leaves the form available for
correction.
- Converted agents start fresh sessions. Prior runs remain available.
- Real task execution was checked on local macOS with API credentials.
SSH, sandbox targets and subscription credentials were not exercised in
this change.
- Storybook responses simulate persistence. The real test drive and
database tests provide separate persistence evidence.

## Model Used

- OpenAI GPT-6 through Codex. The exact serving variant and
context-window size were not exposed in this session. Used reasoning,
code editing, shell tools and browser interaction. No subagents.

## 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 <noreply@paperclip.ing>
2026-10-21 15:13:42 -05:00

4129 lines
168 KiB
TypeScript

// @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, beforeEach, describe, expect, it, vi } from "vitest";
import type { Agent, Environment, UserSecretDefinition } from "@paperclipai/shared";
import { getEnvironmentCapabilities } from "@paperclipai/shared";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ToastProvider } from "../context/ToastContext";
import { AgentConfigForm, AdapterLoginPanel, ModelDropdown, subtractPersistedOverlay, type AdapterLoginDescriptor } from "./AgentConfigForm";
import { AgentLifecycleStatus, useAgentLifecycleStatus } from "./AgentLifecycleStatus";
import { queryKeys } from "../lib/queryKeys";
import { defaultCreateValues } from "./agent-config-defaults";
import { buildNewAgentHirePayload } from "../lib/new-agent-hire-payload";
import { ApiError } from "../api/client";
import { aiConnectionsApi } from "../api/ai-connections";
import { CodexLocalConfigFields } from "../adapters/codex-local/config-fields";
import type { AdapterConfigFieldsProps } from "../adapters/types";
import { buildClaudeLocalConfig } from "@paperclipai/adapter-claude-local/ui";
import { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
const mockAgentsApi = vi.hoisted(() => ({
get: vi.fn(),
adapterModels: vi.fn(),
detectModel: vi.fn(),
list: vi.fn(),
testEnvironment: vi.fn(),
startAdapterAuthLogin: vi.fn(),
getAdapterAuthLoginStatus: vi.fn(),
getActiveAdapterAuthLoginSession: vi.fn(),
cancelAdapterAuthLogin: vi.fn(),
startClaudeSetupTokenLogin: vi.fn(),
getClaudeSetupTokenLoginStatus: vi.fn(),
getActiveClaudeSetupTokenLoginSession: vi.fn(),
getClaudeSetupTokenLoginPrompt: vi.fn(),
submitClaudeSetupTokenBrowserCode: vi.fn(),
completeClaudeSetupTokenLogin: vi.fn(),
cancelClaudeSetupTokenLogin: vi.fn(),
getClaudeOAuthTokenStatus: vi.fn(),
}));
// The default resume read for a test that does not exercise resume: no active
// session for the caller.
function noActiveSession() {
return Promise.reject(
new ApiError("Adapter login session not found", 404, { error: "Adapter login session not found" }),
);
}
const mockClipboard = vi.hoisted(() => ({
copyTextToClipboard: vi.fn(),
}));
const mockEnvironmentsApi = vi.hoisted(() => ({
list: vi.fn(),
capabilities: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
get: vi.fn(),
getExperimental: vi.fn(),
getGeneral: vi.fn(),
}));
const mockSecretsApi = vi.hoisted(() => ({
list: vi.fn(),
listProposals: vi.fn(),
listUserSecretDefinitions: vi.fn(async () => [] as unknown[]),
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/grokBot", () => ({
grokBotApi: {
connection: async () => ({ enabled: true, canConfigureConnection: true, binding: null }),
},
}));
const mockAdaptersApi = vi.hoisted(() => ({ list: vi.fn(async () => [{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]) }));
vi.mock("../api/adapters", () => ({ adaptersApi: mockAdaptersApi }));
vi.mock("../api/environments", () => ({
environmentsApi: mockEnvironmentsApi,
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("../api/secrets", () => ({
secretsApi: mockSecretsApi,
}));
vi.mock("../lib/clipboard", () => ({
copyTextToClipboard: mockClipboard.copyTextToClipboard,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", name: "Paperclip" }],
selectedCompanyId: "company-1",
selectedCompany: { id: "company-1", name: "Paperclip" },
selectionSource: "bootstrap",
loading: false,
error: null,
setSelectedCompanyId: vi.fn(),
reloadCompanies: vi.fn(),
createCompany: vi.fn(),
}),
}));
vi.mock("../adapters", () => ({
getUIAdapter: (type: string) => ({
type,
label: type === "hermes_gateway" ? "Hermes Gateway" : "Codex",
// The stand-in also records the two gates the form resolves for every
// adapter, so a test can assert the plumbing without rendering a real
// adapter's fields.
ConfigFields: (props: AdapterConfigFieldsProps) => {
if (type === "paperclip_runner") return <CodexLocalConfigFields {...props} />;
const { adapterType, hideInstructionsFile, managedSandboxOnly } = props;
return adapterType === "hermes_gateway"
? <div data-testid="hermes-gateway-config-fields">Hermes Gateway fields</div>
: (
<div
data-testid="adapter-config-fields"
data-hide-instructions-file={String(hideInstructionsFile === true)}
data-managed-sandbox-only={String(managedSandboxOnly === true)}
/>
);
},
buildAdapterConfig: (values: { model?: string }) => ({
model: values.model || undefined,
}),
parseStdoutLine: () => [],
}),
}));
// The projected login capability per adapter type. The server projects these
// safe scalar fields. `codex_local` drives the displayed-code panel; `claude_local`
// drives the submitted-browser-code panel. A test overrides this map to add a
// third adapter with a projected login capability.
const mockLoginProjections = vi.hoisted(
() =>
new Map<string, { panelMode: string; timeoutPolicy: string }>([
["codex_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
["grok_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
["claude_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
// A third adapter, not a built-in, with a projected displayed-code login.
["vendor_local", { panelMode: "displayed_code", timeoutPolicy: "caller_bounded" }],
// A non-built-in adapter with a submitted-browser-code login. Every login
// runs on a real pseudo-terminal, so the gate requires the provider pty
// capability from the login capability, not the adapter name.
["pty_vendor_local", { panelMode: "submitted_browser_code", timeoutPolicy: "fixed" }],
]),
);
vi.mock("../adapters/use-adapter-capabilities", () => ({
useAdapterCapabilities: () => (adapterType: string) => {
const login = mockLoginProjections.get(adapterType);
return adapterType === "hermes_gateway"
? {
supportsInstructionsBundle: false,
supportsSkills: false,
supportsLocalAgentJwt: false,
requiresMaterializedRuntimeSkills: false,
supportsAcp: false,
}
: {
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
supportsAcp: true,
...(login ? { login } : {}),
};
},
}));
vi.mock("../adapters/use-disabled-adapters", () => ({
useDisabledAdaptersSync: () => [],
}));
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: ({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) => (
<textarea
aria-label={placeholder ?? "Markdown"}
value={value}
onChange={(event) => onChange(event.currentTarget.value)}
/>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
async function flushReact() {
await act(async () => {
for (let i = 0; i < 4; i += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
});
}
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "Cody",
role: "Engineer",
title: null,
icon: null,
status: "idle",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
defaultEnvironmentId: null,
contextMode: "thin",
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
permissions: {},
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...overrides,
} as Agent;
}
function makeEnvironment(overrides: Partial<Environment>): Environment {
return {
id: "env-1",
name: "Local",
description: null,
driver: "local",
status: "active",
config: {},
envVars: {},
metadata: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...overrides,
};
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function daytonaEnvironments() {
return [
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
];
}
function e2bEnvironments() {
return [
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
];
}
function renderFormView(root: Root, queryClient: QueryClient, children: Parameters<Root["render"]>[0]) {
root.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<TooltipProvider>{children}</TooltipProvider>
</ToastProvider>
</QueryClientProvider>,
);
}
function createFormQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
}
async function renderForm(
environments: Environment[],
agentOverrides: Partial<Agent> = {},
options: {
showAdapterTestEnvironmentButton?: boolean;
showAdapterTypeField?: boolean;
compactTestFeedback?: boolean;
content?: "configuration" | "secrets";
environmentVariablesPlacement?: "configuration" | "secrets";
hideInlineSave?: boolean;
onDirtyChange?: (dirty: boolean) => void;
onSaveActionChange?: (save: (() => void) | null) => void;
onCancelActionChange?: (cancel: (() => void) | null) => void;
} = {},
) {
mockEnvironmentsApi.list.mockResolvedValue(environments);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onSave = vi.fn();
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AgentConfigForm
mode="edit"
agent={makeAgent(agentOverrides)}
onSave={onSave}
hidePromptTemplate
compactTestFeedback={options.compactTestFeedback}
content={options.content}
environmentVariablesPlacement={options.environmentVariablesPlacement}
hideInlineSave={options.hideInlineSave}
onDirtyChange={options.onDirtyChange}
onSaveActionChange={options.onSaveActionChange}
onCancelActionChange={options.onCancelActionChange}
showAdapterTypeField={options.showAdapterTypeField ?? false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
);
});
await flushReact();
return { container, root, onSave };
}
async function renderCreateForm(
environments: Environment[],
valueOverrides: Partial<typeof defaultCreateValues> = {},
options: { showAdapterTestEnvironmentButton?: boolean } = {},
) {
mockEnvironmentsApi.list.mockResolvedValue(environments);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = createFormQueryClient();
const values = {
...defaultCreateValues,
adapterType: "codex_local",
...valueOverrides,
};
const onChange = vi.fn();
await act(async () => {
renderFormView(root, queryClient,
<AgentConfigForm
mode="create"
values={values}
onChange={onChange}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
/>
);
});
await flushReact();
return { container, root, onChange };
}
const AUTH_MISSING_RESULT = {
adapterType: "codex_local",
status: "fail",
checks: [
{
code: "adapter_auth_missing",
level: "error",
message: "The sandbox has no ready authentication.",
},
],
testedAt: new Date(0).toISOString(),
};
const VENDOR_AUTH_MISSING_RESULT = {
adapterType: "vendor_local",
status: "fail",
checks: [
{
code: "adapter_auth_missing",
level: "error",
message: "The sandbox has no ready authentication.",
},
],
testedAt: new Date(0).toISOString(),
};
const GROK_AUTH_MISSING_RESULT = {
adapterType: "grok_local",
status: "warn",
checks: [
{
code: "grok_hello_probe_auth_required",
level: "warn",
message: "Grok CLI could not answer the hello probe because authentication is missing.",
},
{
code: "adapter_auth_missing",
level: "warn",
message: "This environment has no ready authentication for this adapter.",
},
],
testedAt: new Date(0).toISOString(),
};
const PTY_VENDOR_AUTH_MISSING_RESULT = {
adapterType: "pty_vendor_local",
status: "fail",
checks: [
{
code: "adapter_auth_missing",
level: "error",
message: "The sandbox has no ready authentication.",
},
],
testedAt: new Date(0).toISOString(),
};
const CLAUDE_AUTH_MISSING_RESULT = {
adapterType: "claude_local",
status: "warn",
checks: [
{
code: "claude_hello_probe_auth_required",
level: "warn",
message: "Claude CLI is installed, but login is required.",
},
{
code: "adapter_auth_missing",
level: "warn",
message: "The sandbox has no ready authentication for this adapter.",
},
],
testedAt: new Date(0).toISOString(),
};
// The provider capabilities the form fetches. Daytona advertises the
// setup-token login capability; E2B does not. The Claude login panel shows only
// for a provider with the capability.
const SANDBOX_CAPABILITIES = getEnvironmentCapabilities(["claude_local", "codex_local"], {
sandboxProviders: {
daytona: { supportsLoginPty: true, displayName: "Daytona" },
e2b: { supportsLoginPty: false, displayName: "E2B" },
},
});
function findButton(container: HTMLElement, label: string) {
return Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === label,
);
}
function findByAriaLabel(container: HTMLElement, label: string) {
return container.querySelector<HTMLElement>(`[aria-label="${label}"]`);
}
async function renderCodexSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
daytonaEnvironments(),
{ defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
}
// A third adapter, not a built-in, in a sandbox environment. Its projected login
// capability drives the login affordance and the displayed-code panel. The
// provider advertises the login pseudo-terminal capability the login needs.
async function renderVendorSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
daytonaEnvironments(),
{ adapterType: "vendor_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
}
// A Grok agent in a sandbox environment. Its projected login capability
// drives the login affordance and the displayed-code panel, the same as
// Codex. The provider advertises the login pseudo-terminal capability the
// login needs.
async function renderGrokSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
daytonaEnvironments(),
{ adapterType: "grok_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
}
async function renderClaudeSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
daytonaEnvironments(),
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
}
async function renderCreateClaudeSandbox(
valueOverrides: Partial<typeof defaultCreateValues> = {},
) {
return renderCreateForm(
daytonaEnvironments(),
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", ...valueOverrides },
{ showAdapterTestEnvironmentButton: true },
);
}
// A create-mode harness that holds the form values in React state. A value
// patch from the form (a login claim, an environment change) updates the props,
// so the form re-runs its effects against the new state. The fixed-values
// `renderCreateForm` harness cannot show the environment-change reset, because
// its `values` prop never changes. `valuesRef` exposes the current merged
// values to the test.
async function renderStatefulCreateClaudeSandbox(environments: Environment[]) {
mockEnvironmentsApi.list.mockResolvedValue(environments);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = createFormQueryClient();
const valuesRef: { current: typeof defaultCreateValues } = {
current: {
...defaultCreateValues,
adapterType: "claude_local",
defaultEnvironmentId: "sandbox-1",
},
};
function Harness() {
const [values, setValues] = useState(valuesRef.current);
valuesRef.current = values;
return (
<AgentConfigForm
mode="create"
values={values}
onChange={(patch) => setValues((prev) => ({ ...prev, ...patch }))}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
);
}
await act(async () => {
renderFormView(root, queryClient, <Harness />);
});
await flushReact();
return { container, root, valuesRef };
}
async function environmentOptions(container: HTMLElement) {
const picker = container.querySelector('[aria-label="Environment override"]');
expect(picker).not.toBeNull();
await clickElement(picker);
const options = document.body.querySelector('[role="listbox"][aria-label="Environment override"]');
expect(options).not.toBeNull();
return options!;
}
async function selectEnvironment(container: HTMLElement, environmentId: string) {
const options = await environmentOptions(container);
const option = options.querySelector(`[role="option"][data-value="${environmentId}"]`);
expect(option).not.toBeNull();
await clickElement(option);
}
async function clickByText(container: HTMLElement, label: string) {
const button = findButton(container, label);
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
async function clickElement(element: Element | null | undefined) {
await act(async () => {
element?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
async function runTest(container: HTMLElement) {
await clickByText(container, "Test");
}
async function startLogin(container: HTMLElement) {
await clickByText(container, "Sign in");
await flushReact();
}
// Flush React effects and pending promises until a condition holds. The Claude
// login chains a start, a status poll, and a completion read, so a single flush
// does not settle every state transition.
async function flushUntil(check: () => boolean, timeoutMs = 4000) {
const start = Date.now();
while (!check()) {
if (Date.now() - start > timeoutMs) break;
await flushReact();
await new Promise((resolve) => setTimeout(resolve, 20));
}
await flushReact();
}
describe("AgentConfigForm environment selector", () => {
let roots: Root[] = [];
it.each(["paperclip", "legacy"] as const)("uses the resolved %s Claude form to preserve the correct turn limit", async defaultRunner => {
mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: defaultRunner === "paperclip" ? ["paperclip", "legacy"] : ["legacy"], defaultRunner }]);
try {
const values = { ...defaultCreateValues, adapterType: "claude_local", runner: "auto" as const };
const result = await renderCreateForm([], values);
roots.push(result.root);
const resolved = result.onChange.mock.calls.reduce((current, [patch]) => ({ ...current, ...patch }), values);
expect(resolved.resolvedRunner).toBe(defaultRunner);
const config = buildClaudeLocalConfig(resolved);
if (defaultRunner === "legacy") expect(config.maxTurnsPerRun).toBe(1000);
else expect(config).not.toHaveProperty("maxTurnsPerRun");
expect(config).not.toHaveProperty("resolvedRunner");
} finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); }
});
it("carries a selected native Claude permission through the ordinary creation builder", async () => {
mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", supportedRunners: ["paperclip", "legacy"], defaultRunner: "paperclip" }]);
try {
const values = { ...defaultCreateValues, adapterType: "claude_local", runner: "auto" as const };
const result = await renderCreateForm([], values);
roots.push(result.root);
await clickElement(result.container.querySelector('[aria-label="Permission mode"]'));
const deny = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(option => option.textContent === "Deny all");
expect(deny).toBeTruthy();
await clickElement(deny);
const resolved = result.onChange.mock.calls.reduce((current, [patch]) => ({ ...current, ...patch }), values);
expect(buildClaudeLocalConfig(resolved)).toMatchObject({ acpxPermissionMode: "deny-all" });
} finally { mockAdaptersApi.list.mockResolvedValue([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "legacy" }]); }
});
it("keeps an explicit Codex draft policy until a harness reset clears it", async () => {
mockEnvironmentsApi.list.mockResolvedValue([]);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
let current = { ...defaultCreateValues, adapterType: "codex_local", runner: "auto" as const,
dangerouslyBypassSandbox: false, codexLegacySandboxBypass: false };
function Fixture() {
const [values, setValues] = useState<typeof defaultCreateValues>(current);
current = values as typeof current;
return <AgentConfigForm mode="create" values={values}
onChange={patch => setValues(prev => ({ ...prev, ...patch }))} showAdapterTypeField hidePromptTemplate />;
}
try {
await act(async () => root.render(<QueryClientProvider client={queryClient}><ToastProvider><TooltipProvider>
<Fixture />
</TooltipProvider></ToastProvider></QueryClientProvider>));
await flushReact();
expect(current.codexLegacySandboxBypass).toBe(false);
for (const harness of ["claude_local", "codex_local"]) {
await clickElement(container.querySelector('[aria-label="Harness"]'));
await clickElement(document.querySelector(`[role="option"][data-value="${harness}"]`));
await flushReact();
expect(current.adapterType).toBe(harness);
expect(current).toHaveProperty("codexLegacySandboxBypass", undefined);
}
} finally {
await act(async () => root.unmount());
container.remove();
}
});
it.each(["codex_local", "paperclip_runner"])("keeps a saved %s runner during unrelated edits", async adapterType => {
const adapterConfig = { model: "gpt-5.6-sol", ...(adapterType === "paperclip_runner" ? { provider: "codex", reasoningEffort: "high" } : { modelReasoningEffort: "high" }) };
const result = await renderForm([], { adapterType, adapterConfig }, { showAdapterTypeField: true });
roots.push(result.root);
expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("Codex");
await clickByText(result.container, "Advanced");
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(adapterType === "paperclip_runner" ? "Paperclip Runner" : "Legacy runner");
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed Cody"));
await flushReact();
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Cody" });
});
it.each(["codex_local", "claude_local"])("upgrades a saved %s in one click with pending edits", async adapterType => {
mockAdaptersApi.list.mockResolvedValueOnce([{ type: adapterType, supportedRunners: ["legacy", "paperclip"], defaultRunner: "paperclip" }]);
const result = await renderForm([], { adapterType, adapterConfig: { model: "existing-model", effort: "high" } });
roots.push(result.root);
expect(result.container.textContent).toContain("Upgrade to Paperclip Runner");
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed agent"));
await flushReact();
await clickByText(result.container, "Upgrade runner");
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed agent", runner: "paperclip" });
});
it.each(["paperclip_runner", "gemini_local"])("does not offer an upgrade for %s", async adapterType => {
const result = await renderForm([], { adapterType });
roots.push(result.root);
expect(result.container.textContent).not.toContain("Upgrade runner");
});
it("includes an uncommitted environment variable draft in an upgrade", async () => {
mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "paperclip" }]);
const result = await renderForm([], { adapterConfig: { model: "gpt-5.4" } }, { environmentVariablesPlacement: "configuration" });
roots.push(result.root);
await clickByText(result.container, "Add variable");
await act(async () => {
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!, "MIGRATION_MARKER");
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!, "kept");
});
await flushReact();
await clickByText(result.container, "Upgrade runner");
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ runner: "paperclip", replaceAdapterConfig: true,
adapterConfig: { model: "gpt-5.4", env: { MIGRATION_MARKER: { type: "plain", value: "kept" } } },
}));
});
it("keeps failed upgrades editable and allows retry", async () => {
mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "paperclip" }]);
const result = await renderForm([]);
roots.push(result.root);
result.onSave.mockRejectedValueOnce(new Error("Paperclip Runner cannot honor these settings: extraArgs."));
await clickByText(result.container, "Upgrade runner");
expect(result.container.querySelector('[role="alert"]')?.textContent).toContain("extraArgs");
expect(result.onSave).toHaveBeenLastCalledWith({ runner: "paperclip" });
await clickByText(result.container, "Upgrade runner");
expect(result.onSave).toHaveBeenCalledTimes(2);
expect(result.container.querySelector('[role="alert"]')).toBeNull();
});
it("does not show an earlier agent's upgrade result after navigation", async () => {
mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy", "paperclip"], defaultRunner: "paperclip" }]);
let finishUpgrade!: () => void;
let changeAgent!: () => void;
const save = vi.fn(() => new Promise<void>(resolve => { finishUpgrade = resolve; }));
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
function Fixture() {
const [agent, setAgent] = useState(() => makeAgent());
changeAgent = () => setAgent(makeAgent({ id: "agent-2", name: "Another agent" }));
return <AgentConfigForm mode="edit" agent={agent} onSave={save} hidePromptTemplate />;
}
await act(async () => renderFormView(root, createFormQueryClient(), <Fixture />));
await flushReact();
await clickByText(container, "Upgrade runner");
await act(async () => changeAgent());
await act(async () => finishUpgrade());
await flushReact();
expect(container.textContent).not.toContain("Upgraded to Paperclip Runner.");
expect(findButton(container, "Upgrade runner")).toBeDefined();
});
it("does not offer an upgrade when the target only supports Legacy", async () => {
mockAdaptersApi.list.mockResolvedValueOnce([{ type: "codex_local", supportedRunners: ["legacy"], defaultRunner: "legacy" }]);
const result = await renderForm([]);
roots.push(result.root);
expect(result.container.textContent).not.toContain("Upgrade runner");
});
it.each(["codex", "claude"].flatMap(harness => ["modelReasoningEffort", "reasoningEffort", "effort"].map(key => ({ harness, key }))))("hydrates saved effort and clears explicit Auto for $harness ($key)", async ({ harness, key }) => {
const profile = harness === "codex" ? { provider: "codex", model: "gpt-5.6-sol" } : { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5" };
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { ...profile, [key]: "high" } });
roots.push(result.root);
const effort = result.container.querySelector('[aria-label="Thinking effort"]');
expect(effort?.textContent).toContain("High");
await clickElement(effort);
await clickElement(document.querySelector('[role="option"][data-value=""]'));
await clickByText(result.container, "Save");
const saved = result.onSave.mock.calls[0][0];
expect(saved.replaceAdapterConfig).toBe(true);
expect(saved.adapterConfig).toMatchObject(profile);
for (const alias of ["modelReasoningEffort", "reasoningEffort", "effort"]) expect(saved.adapterConfig).not.toHaveProperty(alias);
});
it.each(["codex_local", "paperclip_runner"])("changes and clears the environment without changing a saved %s runner or account", async adapterType => {
const adapterConfig = {
model: "gpt-5.6-sol",
...(adapterType === "paperclip_runner" ? { provider: "codex", reasoningEffort: "high" } : { modelReasoningEffort: "high" }),
env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } },
};
const result = await renderForm([
...daytonaEnvironments(),
makeEnvironment({ id: "sandbox-2", name: "Daytona Two", driver: "sandbox", config: { provider: "daytona" } }),
], { adapterType, adapterConfig, defaultEnvironmentId: "sandbox-1" }, { showAdapterTypeField: true });
roots.push(result.root);
await clickByText(result.container, "Advanced");
const runnerLabel = adapterType === "paperclip_runner" ? "Paperclip Runner" : "Legacy runner";
await selectEnvironment(result.container, "sandbox-2");
expect(result.container.querySelector('[aria-label="Environment override"]')?.textContent).toContain("Daytona Two");
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(runnerLabel);
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenLastCalledWith({ defaultEnvironmentId: "sandbox-2" });
await selectEnvironment(result.container, "");
expect(result.container.querySelector('[aria-label="Environment override"]')?.textContent).toContain("Default: Local");
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain(runnerLabel);
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenLastCalledWith({ defaultEnvironmentId: null });
});
it("clears a new agent's environment override to the empty draft value", async () => {
const result = await renderCreateForm(daytonaEnvironments(), { defaultEnvironmentId: "sandbox-1", runner: "legacy" });
roots.push(result.root);
await selectEnvironment(result.container, "");
expect(result.onChange).toHaveBeenLastCalledWith({ defaultEnvironmentId: "" });
});
it("submits only an explicit runner change and retains saved configuration for server translation", async () => {
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-sol", reasoningEffort: "high", env: { CODEX_HOME: { type: "secret_ref", secretId: "codex-account" } } } });
roots.push(result.root);
await clickByText(result.container, "Advanced");
await clickElement(result.container.querySelector('[aria-label="Runner"]'));
await clickElement([...document.querySelectorAll('[role="option"]')].find(option => option.textContent?.startsWith("Legacy runner")));
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith({ runner: "legacy" });
});
it.each([false, true])("keeps experimental native selection gated when enableNativeRunner is %s", async enabled => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: enabled });
const result = await renderForm([], { adapterType: "claude_local", adapterConfig: { model: "claude-sonnet-5" } }, { showAdapterTypeField: true });
roots.push(result.root);
await clickElement(result.container.querySelector('[aria-label="Harness"]'));
const option = [...document.querySelectorAll('[role="option"]')].find(element => element.getAttribute("data-value") === "paperclip_runner");
expect(Boolean(option)).toBe(enabled);
if (!enabled) return;
expect(document.body.textContent).toContain("Advanced");
await clickElement(option);
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({
adapterType: "paperclip_runner",
adapterConfig: expect.objectContaining({ provider: "acpx", acpxAgent: "claude" }),
}));
expect(result.onSave.mock.calls[0][0].runner).toBeUndefined();
});
it("shows Claude's saved runner without legacy command controls when the experimental gate is off", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false });
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-opus-5-5", modelReasoningEffort: "high" } }, { showAdapterTypeField: true });
roots.push(result.root);
expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("Claude");
await clickByText(result.container, "Advanced");
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain("Paperclip Runner");
const labels = Array.from(result.container.querySelectorAll("label")).map(label => label.textContent?.trim());
expect(labels).not.toContain("Command");
expect(labels).not.toContain("Extra args (comma-separated)");
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed Claude"));
await flushReact();
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed Claude" });
});
it.each([{ provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" }, { provider: "unknown-provider" }])("preserves saved native $provider configuration with the experimental gate off", async adapterConfig => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableNativeRunner: false });
const result = await renderForm([], { adapterType: "paperclip_runner", adapterConfig }, { showAdapterTypeField: true });
roots.push(result.root);
if (adapterConfig.provider === "opencode") {
expect(result.container.querySelector('[aria-label="Harness"]')?.textContent).toContain("OpenCode");
await clickByText(result.container, "Advanced");
expect(result.container.querySelector('[aria-label="Runner"]')?.textContent).toContain("Paperclip Runner");
} else expect(result.container.querySelector('[aria-label="Runner"]')).toBeNull();
await act(async () => setInputValue(result.container.querySelector<HTMLInputElement>('[placeholder="Agent name"]')!, "Renamed native agent"));
await flushReact();
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith({ name: "Renamed native agent" });
});
beforeEach(() => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.detectModel.mockResolvedValue(null);
mockAgentsApi.list.mockResolvedValue([]);
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "codex_local",
status: "pass",
checks: [],
testedAt: new Date(0).toISOString(),
});
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true });
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "any" });
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listProposals.mockResolvedValue([]);
// Default: the caller has no active session. A resume test overrides this
// with a resolved session body.
mockAgentsApi.getActiveAdapterAuthLoginSession.mockImplementation(noActiveSession);
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockImplementation(noActiveSession);
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "starting",
expiresAt: null,
failure: null,
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/device", code: "WXYZ-1234" },
});
mockAgentsApi.cancelAdapterAuthLogin.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "cancelled",
expiresAt: null,
failure: null,
prompt: null,
});
mockClipboard.copyTextToClipboard.mockResolvedValue(undefined);
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "starting",
expiresAt: null,
failure: null,
panelMode: "submitted_browser_code",
prompt: null,
});
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
});
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockResolvedValue({
authorizationUrl: "https://claude.example.test/authorize",
});
mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
});
mockAgentsApi.completeClaudeSetupTokenLogin.mockResolvedValue({
storedSessionId: "stored-session-1",
});
mockAgentsApi.cancelClaudeSetupTokenLogin.mockResolvedValue(undefined);
// Default: the owner has no stored Claude login. A test that needs a stored
// value overrides this with a status body.
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue(null);
});
afterEach(async () => {
for (const root of roots) {
await act(async () => {
root.unmount();
});
}
roots = [];
document.body.innerHTML = "";
vi.clearAllMocks();
});
it.each([false, true])("uses Grok connection verification without native controls (compact: %s)", async compactTestFeedback => {
const result = await renderForm([], {
adapterType: "paperclip_runner",
adapterConfig: { provider: "grok_bot", cwd: "/old/local/path", model: "old-model" },
}, { showAdapterTypeField: true, showAdapterTestEnvironmentButton: true, compactTestFeedback });
roots.push(result.root);
expect(result.container.querySelector('[aria-label="Grok Bot connection"]')).not.toBeNull();
expect(result.container.textContent).toContain("Reconnect agent access");
expect(result.container.querySelector('[data-config-section="adapter"]')?.textContent).toContain("Grok Bot");
expect(result.container.querySelector('[aria-label="Model"]')).toBeNull();
expect(result.container.textContent).not.toContain("AI connection");
expect(result.container.textContent).not.toContain("Working directory");
expect(result.container.textContent).not.toContain("Test your agent");
expect(findButton(result.container, "Test")).toBeUndefined();
expect(mockAgentsApi.adapterModels).not.toHaveBeenCalled();
expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled();
});
it("promotes environment drafts through the page Save action and discards them through the page Discard action", async () => {
const dirty = vi.fn();
let save: (() => void) | null = null;
let discard: (() => void) | null = null;
const result = await renderForm([], {}, {
content: "secrets", environmentVariablesPlacement: "secrets", hideInlineSave: true,
onDirtyChange: dirty,
onSaveActionChange: action => { save = action; },
onCancelActionChange: action => { discard = action; },
});
roots.push(result.root);
const add = [...result.container.querySelectorAll("button")].find(button => button.textContent?.trim() === "Add variable")!;
await act(async () => add.click());
await act(async () => {
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]')!, "ONBOARDING_SMOKE");
setInputValue(result.container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]')!, "true");
});
await flushReact();
expect(dirty).toHaveBeenLastCalledWith(true);
expect([...result.container.querySelectorAll("button")].some(button => button.textContent?.trim() === "Save")).toBe(false);
await act(async () => { await save?.(); });
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ env: { ONBOARDING_SMOKE: { type: "plain", value: "true" } } }) }));
await act(async () => discard?.());
await flushReact();
expect(dirty).toHaveBeenLastCalledWith(false);
expect(result.container.querySelector('input[aria-label="Variable name"]')).toBeNull();
});
it("reads and saves Pi thinking effort using the Pi runtime key", async () => {
const result = await renderForm([], { adapterType: "pi_local", adapterConfig: { model: "openrouter/anthropic/claude-sonnet-4.6", thinking: "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 low = [...document.querySelectorAll("button")].find(button => button.textContent?.trim() === "lowlow")!;
expect(low).toBeTruthy();
await act(async () => low.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({ thinking: "low" }) }));
expect(result.onSave.mock.calls[0][0].adapterConfig.effort).toBeUndefined();
});
it.each([
["Codex", "codex_local"],
["Claude Code", "claude_local"],
])("clears OpenCode model settings when choosing %s so the server can apply its default", async (_label, adapterType) => {
const accountList = vi.spyOn(aiConnectionsApi, "list").mockResolvedValue({
currentUserId: "you",
canManageConnections: true,
connections: [],
});
try {
const result = await renderForm([], {
adapterType: "paperclip_runner",
adapterConfig: { provider: "opencode", model: "openrouter/anthropic/claude-sonnet-4.6" },
runtimeConfig: { aiConnection: { mode: "responsible_user", provider: "openrouter", method: "api_key" } },
}, { showAdapterTypeField: true });
roots.push(result.root);
await clickElement(result.container.querySelector('[aria-label="Harness"]'));
await flushReact();
const option = document.querySelector<HTMLElement>(`[role="option"][data-value="${adapterType}"]`);
expect(option).toBeTruthy();
await clickElement(option);
await flushReact();
await clickByText(result.container, "Save");
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({
adapterType,
runner: "auto",
replaceAdapterConfig: true,
adapterConfig: expect.objectContaining({ model: "" }),
}));
} finally {
accountList.mockRestore();
}
});
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" }),
]);
roots.push(result.root);
expect(result.container.textContent).not.toContain("Secret access");
expect(result.container.textContent).not.toContain("Environment override");
expect(result.container.querySelector('[aria-label="Environment override"]')).toBeNull();
});
it("refreshes Copilot models after reconnecting the same responsible-user account", async () => {
const binding = { mode: "responsible_user", provider: "github", method: "api_key" } as const;
const account = { id: "copilot-account", grantId: "copilot-grant", companyId: "company-1", provider: "github", method: "api_key", name: "My Copilot", ownership: "personal", ownerUserId: "you", isDefault: true, status: "needs_attention" } as const;
const accountList = vi.spyOn(aiConnectionsApi, "list").mockResolvedValue({ currentUserId: "you", canManageConnections: true, connections: [account] });
const reconnect = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({ connectionId: account.id, grantId: account.grantId });
mockAgentsApi.adapterModels.mockResolvedValueOnce([{ id: "old-model", label: "Old token model" }])
.mockResolvedValue([{ id: "gpt-5.6-luna", label: "New token model" }]);
try {
const result = await renderForm([makeEnvironment({ id: "local-1", name: "Local", driver: "local" })], {
adapterType: "paperclip_runner", adapterConfig: { provider: "acpx", acpxAgent: "copilot", model: "gpt-5.6-luna" }, runtimeConfig: { aiConnection: binding },
});
roots.push(result.root);
await vi.waitFor(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(1));
await clickByText(result.container, "Reconnect account");
const token = document.querySelector<HTMLInputElement>('input[placeholder="github_pat_..."]');
expect(token).not.toBeNull();
await act(async () => setInputValue(token!, "github_pat_test_reconnect"));
await flushReact();
await clickByText(document.body, "Connect");
await vi.waitFor(() => expect(reconnect).toHaveBeenCalledWith("company-1", expect.objectContaining({ provider: "github", connectionId: account.id, apiKey: "github_pat_test_reconnect" })));
await vi.waitFor(() => expect(mockAgentsApi.adapterModels).toHaveBeenCalledTimes(2));
expect(mockAgentsApi.adapterModels).toHaveBeenLastCalledWith("company-1", "paperclip_runner", expect.objectContaining({ provider: "acpx", acpxAgent: "copilot", aiConnection: binding }));
await flushReact();
expect(result.container.textContent).toContain("New token model");
} finally { accountList.mockRestore(); reconnect.mockRestore(); }
});
it("renders GPT-6 Astra and its model-specific reasoning efforts", async () => {
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-5.6-sol", label: "gpt-5.6-sol" },
{ id: "gpt-6-astra", label: "gpt-6-astra" },
]);
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{
adapterConfig: {
model: "gpt-6-astra",
modelReasoningEffort: "ultra",
},
},
);
roots.push(result.root);
expect(result.container.textContent).toContain("gpt-6-astra");
const effortButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Ultra");
expect(effortButton).not.toBeUndefined();
await act(async () => {
effortButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const effortChoices = Array.from(document.body.querySelectorAll("button"))
.map((button) => button.textContent?.replace(/\s+/g, "").trim());
expect(effortChoices).toContain("Maxmax");
expect(effortChoices).toContain("Ultraultra");
expect(effortChoices).not.toContain("Minimalminimal");
});
it("removes a legacy incompatible effort when the model changes to Astra", async () => {
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "gpt-5.6-sol", label: "gpt-5.6-sol" },
{ id: "gpt-6-astra", label: "gpt-6-astra" },
]);
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{
adapterConfig: {
model: "gpt-5.6-sol",
reasoningEffort: "minimal",
},
},
);
roots.push(result.root);
const modelButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "gpt-5.6-sol");
expect(modelButton).not.toBeUndefined();
await act(async () => {
modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const astraOption = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "gpt-6-astra");
expect(astraOption).not.toBeUndefined();
await act(async () => {
astraOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const saveButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Save");
expect(saveButton).not.toBeUndefined();
await act(async () => {
saveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(result.onSave).toHaveBeenCalledWith({
adapterConfig: { model: "gpt-6-astra" },
replaceAdapterConfig: true,
});
});
it.each(["modelReasoningEffort", "reasoningEffort", "effort"])("clears native Claude %s when the model does not support it", async key => {
mockAgentsApi.adapterModels.mockResolvedValue([
{ id: "claude-opus-5-5", label: "claude-opus-5-5" },
{ id: "claude-haiku-4-5", label: "claude-haiku-4-5" },
]);
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{
adapterType: "paperclip_runner",
adapterConfig: {
provider: "acpx", acpxAgent: "claude",
model: "claude-opus-5-5",
[key]: "max",
},
},
);
roots.push(result.root);
const modelButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "claude-opus-5-5");
expect(modelButton).not.toBeUndefined();
await act(async () => {
modelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const astraOption = Array.from(document.body.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "claude-haiku-4-5");
expect(astraOption).not.toBeUndefined();
await act(async () => {
astraOption!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const saveButton = Array.from(result.container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Save");
expect(saveButton).not.toBeUndefined();
await act(async () => {
saveButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(result.onSave).toHaveBeenCalledWith({
adapterConfig: { provider: "acpx", acpxAgent: "claude", model: "claude-haiku-4-5" },
replaceAdapterConfig: true,
});
});
it("names the Claude default for new and existing agents without pinning it", async () => {
const environments = [makeEnvironment({ id: "local-1", name: "Local", driver: "local" })];
const existing = await renderForm(environments, { adapterType: "claude_local", adapterConfig: {} });
roots.push(existing.root);
const created = await renderCreateForm(environments, { adapterType: "claude_local", model: "" });
roots.push(created.root);
expect(existing.container.textContent).toContain("Default (claude-opus-5-5)");
expect(created.container.textContent).toContain("Default (claude-opus-5-5)");
expect(existing.onSave).not.toHaveBeenCalled();
});
it("renders secret access as dedicated form content", async () => {
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{},
{ content: "secrets" },
);
roots.push(result.root);
expect(result.container.textContent).toContain("Secret access");
expect(result.container.textContent).toContain("No secrets are bound to this agent yet.");
expect(result.container.textContent).not.toContain("Environment variables");
});
it("shows concise Environment copy when one runnable non-local environment exists", async () => {
const result = await renderForm(e2bEnvironments());
roots.push(result.root);
const text = result.container.textContent ?? "";
const selector = await environmentOptions(result.container);
expect(text).toContain("Environment");
expect(text).toContain("Environment override");
expect(selector?.textContent).toContain("Default: Local");
expect(selector?.textContent).toContain("E2B · sandbox");
expect(text).not.toContain("Execution");
expect(text).not.toContain("Leave this unset to inherit the instance default");
expect(text).not.toContain("Inherit instance default");
});
it("shows the environment override for Grok local agents", async () => {
const result = await renderForm(
e2bEnvironments(),
{ adapterType: "grok_local" },
);
roots.push(result.root);
const text = result.container.textContent ?? "";
const selector = await environmentOptions(result.container);
expect(text).toContain("Environment override");
expect(selector?.textContent).toContain("E2B · sandbox");
});
it("shows the environment override for Kimi local agents", async () => {
const result = await renderForm(
e2bEnvironments(),
{ adapterType: "kimi_local" },
);
roots.push(result.root);
const text = result.container.textContent ?? "";
const selector = await environmentOptions(result.container);
expect(text).toContain("Environment override");
expect(selector?.textContent).toContain("E2B · sandbox");
});
it("keeps an existing non-runnable override visible so it can be cleared", async () => {
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "fake-sandbox-1",
name: "Fake Sandbox",
driver: "sandbox",
config: { provider: "fake" },
}),
],
{ defaultEnvironmentId: "fake-sandbox-1" },
);
roots.push(result.root);
const text = result.container.textContent ?? "";
const selector = await environmentOptions(result.container);
expect(text).toContain("Environment override");
expect(selector?.textContent).toContain("Default: Local");
expect(selector?.textContent).toContain("Fake Sandbox · sandbox");
});
it("labels the platform-managed instance default by name, without the driver key", async () => {
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: "managed-1" });
const result = await renderForm([
makeEnvironment({
id: "managed-1",
name: "Paperclip Computer",
driver: "sandbox",
config: { provider: "daytona" },
metadata: { managedByPaperclip: true },
}),
]);
roots.push(result.root);
const selector = await environmentOptions(result.container);
expect(selector?.textContent).toContain("Default: Paperclip Computer");
expect(selector?.textContent).toContain("Paperclip Computer");
expect(selector?.textContent).not.toContain("(sandbox)");
expect(selector?.textContent).not.toContain("· sandbox");
});
it("renders non-local adapter config fields in the Adapter card", async () => {
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{
adapterType: "hermes_gateway",
adapterConfig: {
apiBaseUrl: "http://127.0.0.1:8642",
apiKey: { type: "secret_ref", secretId: "11111111-1111-4111-8111-111111111111" },
},
},
);
roots.push(result.root);
expect(result.container.querySelector('[data-testid="hermes-gateway-config-fields"]')).toBeTruthy();
expect(result.container.textContent).toContain("Hermes Gateway fields");
});
it("tests a Codex agent after clearing the primary model to the adapter default", async () => {
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
], {
adapterConfig: { model: "gpt-5.4" },
}, {
showAdapterTestEnvironmentButton: true,
});
roots.push(result.root);
const modelButton = Array.from(result.container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "gpt-5.4",
);
expect(modelButton).toBeTruthy();
await act(async () => {
modelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const defaultButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Default",
);
expect(defaultButton).toBeTruthy();
await act(async () => {
defaultButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
const testButton = Array.from(result.container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Test",
);
expect(testButton).toBeTruthy();
await act(async () => {
testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.testEnvironment.mock.calls[0]?.[2]).toMatchObject({
adapterConfig: {},
});
const adapterConfig = (mockAgentsApi.testEnvironment.mock.calls[0]?.[2] as {
adapterConfig: Record<string, unknown>;
}).adapterConfig;
expect(adapterConfig).not.toHaveProperty("model");
expect(result.container.textContent).not.toContain("Cannot read properties of undefined");
});
it("omits undefined adapter config entries when testing a create form with the default model", async () => {
const result = await renderCreateForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
], {
model: "",
}, {
showAdapterTestEnvironmentButton: true,
});
roots.push(result.root);
const testButton = Array.from(result.container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Test",
);
expect(testButton).toBeTruthy();
await act(async () => {
testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(mockAgentsApi.testEnvironment.mock.calls[0]?.[2]).toMatchObject({
adapterConfig: {},
});
const adapterConfig = (mockAgentsApi.testEnvironment.mock.calls[0]?.[2] as {
adapterConfig: Record<string, unknown>;
}).adapterConfig;
expect(adapterConfig).not.toHaveProperty("model");
});
it("flushes pending environment variable edits before testing adapter config", async () => {
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
], {
adapterConfig: {
model: "gpt-5.4",
env: { API_TOKEN: { type: "plain", value: "old-token" } },
},
}, {
showAdapterTestEnvironmentButton: true,
});
roots.push(result.root);
const valueInput = result.container.querySelector<HTMLInputElement>('input[aria-label="Variable value"]');
expect(valueInput).toBeTruthy();
await act(async () => {
setInputValue(valueInput!, "draft-token");
});
await flushReact();
const testButton = Array.from(result.container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Test",
);
expect(testButton).toBeTruthy();
await act(async () => {
testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalled();
for (const call of mockAgentsApi.testEnvironment.mock.calls) {
expect(call).toEqual([
"company-1",
"codex_local",
expect.objectContaining({
adapterConfig: expect.objectContaining({
env: { API_TOKEN: { type: "plain", value: "draft-token" } },
}),
}),
]);
}
});
it("surfaces request failures instead of converting them into model test checks", async () => {
mockAgentsApi.testEnvironment.mockRejectedValueOnce(new Error("Network unavailable"));
const result = await renderForm([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
], {
adapterConfig: { model: "gpt-5.4" },
}, {
showAdapterTestEnvironmentButton: true,
});
roots.push(result.root);
const testButton = Array.from(result.container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Test",
);
expect(testButton).toBeTruthy();
await act(async () => {
testButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1);
expect(result.container.textContent).toContain("Network unavailable");
});
it("moves through model options with the same keyboard controls as the harness picker", async () => {
const curated = [
{ id: "claude-fable-5-1", label: "Claude Fable 5.1" },
{ id: "claude-opus-5-5", label: "Claude Opus 5.5" },
{ id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
{ id: "claude-opus-4-8", label: "Claude Opus 4.8" },
];
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(() => root.render(<TooltipProvider>
<ModelDropdown models={curated} value="" onChange={() => {}} open onOpenChange={() => {}} allowDefault={false} required groupByProvider={false} preserveOrder />
</TooltipProvider>));
const search = document.body.querySelector("input")!;
await act(() => { search.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); });
expect(document.activeElement?.textContent).toBe(curated[0].label);
await act(() => { document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "End", bubbles: true })); });
expect(document.activeElement?.textContent).toBe(curated.at(-1)?.label);
await act(() => { document.activeElement?.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })); });
expect(document.activeElement?.textContent).toBe(curated[0].label);
} finally {
await act(() => root.unmount());
container.remove();
}
});
it("hides the Login button before Test and shows it after the adapter_auth_missing check for a Codex sandbox", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it.each([
["auth_required", false], ["auth_required", true], ["failed", false],
] as const)("gates native Codex sandbox login on the shared auth check (%s, %s)", async (diagnostic, authentication) => {
mockAgentsApi.testEnvironment.mockResolvedValue({
adapterType: "paperclip_runner", status: "fail", testedAt: new Date(0).toISOString(),
checks: [
{ code: `codex_hello_probe_${diagnostic}`, level: "error", message: "Native Codex setup failed." },
...(authentication ? [{ code: "adapter_auth_missing", level: "error", message: "The selected Codex account needs authentication." }] : []),
],
});
const result = await renderCodexSandbox({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } });
roots.push(result.root);
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(Boolean(findButton(result.container, "Sign in"))).toBe(authentication);
if (authentication) {
await startLogin(result.container);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "codex_local", { environmentId: "sandbox-1" });
} else {
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
}
});
it("hides the Codex login for a provider without the login pseudo-terminal capability", async () => {
// The Codex device login runs on a real pseudo-terminal, so it needs a
// provider that advertises the login pseudo-terminal capability. E2B reports
// no capability, so the panel stays hidden even after the auth-missing check.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderForm(
e2bEnvironments(),
{ adapterType: "codex_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows the login affordance and the displayed-code panel for a third adapter with a projected login capability", async () => {
// The adapter is not a built-in. Its projected login capability drives the
// login affordance and the panel, so the form reads the capability, not the
// adapter name. The displayed-code panel shows the server code.
mockAgentsApi.testEnvironment.mockResolvedValue(VENDOR_AUTH_MISSING_RESULT);
const result = await renderVendorSandbox();
roots.push(result.root);
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
// The projected capability gates the login affordance on for the third
// adapter.
expect(findButton(result.container, "Sign in")).toBeTruthy();
await startLogin(result.container);
// The displayed-code panel shows the one-time code and the authentication
// URL. It shows no browser-code input, so the dispatcher picked the panel
// from the projected `displayed_code` mode.
expect(result.container.textContent).toContain("WXYZ-1234");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
});
it("shows the login affordance and the displayed-code panel for a Grok sandbox with a projected login capability", async () => {
// The panel dispatcher reads the projected `displayed_code` mode from the
// capability, the same way it does for Codex. It shows the Grok code and
// URL.
mockAgentsApi.testEnvironment.mockResolvedValue(GROK_AUTH_MISSING_RESULT);
const result = await renderGrokSandbox();
roots.push(result.root);
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeTruthy();
await startLogin(result.container);
expect(result.container.textContent).toContain("WXYZ-1234");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
});
it("hides the Login button before Test and shows it after the adapter_auth_missing check for a Claude sandbox", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderClaudeSandbox();
roots.push(result.root);
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("hides the Login button for a Claude sandbox whose provider lacks the setup-token login capability", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderForm(
e2bEnvironments(),
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
// E2B does not advertise the setup-token login capability, so the panel
// stays hidden even after the auth-missing check.
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("hides the Login button for a Daytona sandbox while the capabilities report no setup-token support", async () => {
// Reproduces the reported defect: the Test carries the auth-missing check,
// but the capabilities endpoint reports `supportsLoginPty: false`
// for Daytona (a stale persisted plugin manifest). The gate hides the
// panel. The server-side fix refreshes the persisted manifest so the
// capability reports true and the panel shows (see the companion positive
// test above).
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockEnvironmentsApi.capabilities.mockResolvedValue(
getEnvironmentCapabilities(["claude_local", "codex_local"], {
sandboxProviders: {
daytona: { supportsLoginPty: false, displayName: "Daytona" },
},
}),
);
const result = await renderForm(
daytonaEnvironments(),
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("gates a pseudo-terminal login on the provider pty capability for a non-Claude adapter", async () => {
// The gate reads the adapter login transport, not the adapter name. This
// adapter is not `claude_local`, but its login runs on a pseudo-terminal.
// The E2B provider reports no pty capability, so the panel stays hidden even
// after the auth-missing check.
mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT);
const result = await renderForm(
e2bEnvironments(),
{ adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows a pseudo-terminal login for a non-Claude adapter when the provider advertises pty support", async () => {
// The same non-Claude pseudo-terminal adapter on Daytona. Daytona advertises
// the pty capability, so the panel shows. This confirms the gate follows the
// provider capability, not the adapter name.
mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT);
const result = await renderForm(
daytonaEnvironments(),
{ adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("shows the Login button when a parent lifts the test feedback and renders the panel from the descriptor", async () => {
// The create page hides the inline feedback branch and renders the test
// result and the login panel itself. This harness mirrors that parent: it
// lifts the feedback and renders `AdapterLoginPanel` from the lifted login
// descriptor. Without the descriptor the Login button never appears.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments());
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
function LiftedFeedbackHarness() {
const [login, setLogin] = useState<AdapterLoginDescriptor | null>(null);
return (
<>
<AgentConfigForm
mode="create"
values={{
...defaultCreateValues,
adapterType: "codex_local",
defaultEnvironmentId: "sandbox-1",
}}
onChange={() => {}}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
onTestFeedbackChange={(feedback) => setLogin(feedback.login)}
/>
{login && (
<AdapterLoginPanel
companyId={login.companyId}
adapterType={login.adapterType}
environmentId={login.environmentId}
/>
)}
</>
);
}
await act(async () => {
renderFormView(root, queryClient, <LiftedFeedbackHarness />);
});
await flushReact();
expect(findButton(container, "Sign in")).toBeFalsy();
await runTest(container);
expect(findButton(container, "Sign in")).toBeTruthy();
});
it("does not show the Login button when the Test result has no adapter_auth_missing check", async () => {
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("does not show the Login button when the effective environment is Local", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{},
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows the Login button for an agent with no own environment under the managed-sandbox-only policy", async () => {
// The agent has no own environment, so the login target resolves the same
// way as the adapter Test target. The managed-sandbox-only policy redirects
// that resolution from the hidden local environment to the managed sandbox.
// The login affordance must read the managed sandbox, so it shows after the
// auth-missing check. A login target that stayed local would hide the panel
// for the target the real run uses.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableEnvironments: true,
enableManagedSandboxOnly: true,
});
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderForm(
[
makeEnvironment({
id: "local-1",
name: "Local",
driver: "local",
metadata: { defaultForInstance: true },
}),
makeEnvironment({
id: "managed-1",
name: "Managed",
driver: "sandbox",
config: { provider: "daytona" },
metadata: { managedByPaperclip: true },
}),
],
{ adapterType: "claude_local", defaultEnvironmentId: null },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
expect(findButton(result.container, "Sign in")).toBeFalsy();
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeTruthy();
});
it("keeps the Login button hidden under the managed-sandbox-only policy when no managed sandbox is available", async () => {
// The policy is on, but no managed sandbox environment exists, so the login
// target resolution fails closed. The render catches that failure and
// resolves no login environment, so the affordance stays hidden. The Test
// surfaces the same case as a fail-closed error.
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableEnvironments: true,
enableManagedSandboxOnly: true,
});
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderForm(
[
makeEnvironment({
id: "local-1",
name: "Local",
driver: "local",
metadata: { defaultForInstance: true },
}),
],
{ adapterType: "claude_local", defaultEnvironmentId: null },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows the loading state while the session starts and the prompt is not ready", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "starting",
expiresAt: null,
failure: null,
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("Preparing...");
expect(result.container.textContent).not.toContain("https://");
});
it("copies the login code and the authentication URL", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await clickElement(findByAriaLabel(result.container, "Copy code"));
await clickElement(findByAriaLabel(result.container, "Copy URL"));
expect(mockClipboard.copyTextToClipboard).toHaveBeenCalledWith("WXYZ-1234");
expect(mockClipboard.copyTextToClipboard).toHaveBeenCalledWith("https://auth.example.test/device");
// Code above URL, and the numbering agrees. Opening the page is what leaves
// this screen for a form that wants the code from it, so the code is read
// while it is still in front of you. The panel used to run the other way.
const labels = [...result.container.querySelectorAll("div")]
.map((el) => el.textContent?.trim())
.filter((t) => t === "1. Code" || t === "2. Authentication URL");
expect(labels).toEqual(["1. Code", "2. Authentication URL"]);
const codeIndex = result.container.textContent!.indexOf("WXYZ-1234");
const urlIndex = result.container.textContent!.indexOf("https://auth.example.test/device");
expect(codeIndex).toBeGreaterThan(-1);
expect(urlIndex).toBeGreaterThan(codeIndex);
});
it("keeps the code and URL visible after a later poll returns no prompt", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
// The server delivers the one-time prompt on the first owner read only. The
// first status poll carries the prompt; every later poll carries a null one.
mockAgentsApi.getAdapterAuthLoginStatus
.mockResolvedValueOnce({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/device", code: "WXYZ-1234" },
})
.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("WXYZ-1234");
// Wait for the next status poll, which returns no prompt.
const start = Date.now();
while (mockAgentsApi.getAdapterAuthLoginStatus.mock.calls.length < 2) {
if (Date.now() - start > 6000) throw new Error("the status poll did not run a second time");
await flushReact();
await new Promise((resolve) => setTimeout(resolve, 50));
}
await flushReact();
// The panel latched the prompt, so the code and the URL stay visible.
expect(result.container.textContent).toContain("WXYZ-1234");
expect(result.container.textContent).toContain("https://auth.example.test/device");
});
it("shows a Cancel affordance while a login is active and cancels the session", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "codex_local", {
environmentId: "sandbox-1",
});
expect(result.container.textContent).toContain("WXYZ-1234");
expect(result.container.textContent).toContain("https://auth.example.test/device");
// The Cancel button appears while the session is active.
expect(findButton(result.container, "Cancel")).toBeTruthy();
await clickByText(result.container, "Cancel");
await flushReact();
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalledWith(
"company-1",
"codex_local",
"session-1",
);
// The panel resets: the Sign in button is available again and the code is gone.
const login = findButton(result.container, "Sign in");
expect(login?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("WXYZ-1234");
});
it("does not cancel an active login session when the panel unmounts", async () => {
// The owner-scoped active-session read and the manual Cancel button now
// take over the purpose the unmount cancel used to serve. The connect step
// unmounts the panel routinely — Cancel closes the canvas, switching source
// remounts it under a new key, closing the wizard drops it — and each of
// those unmounts must leave the session reachable by a later mount's resume
// read, not release it. Deliberately not pushed to `roots`: this test does
// the unmount itself, and that unmount is the thing under test.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
await runTest(result.container);
await startLogin(result.container);
expect(findButton(result.container, "Cancel"), "the login should be active").toBeTruthy();
mockAgentsApi.cancelAdapterAuthLogin.mockClear();
await act(async () => {
result.root.unmount();
});
expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled();
});
it("recovers a previous-environment sign-in only after explicit successful cancellation", async () => {
const intent = { provider: "xai", method: "subscription", name: "My Grok subscription", ownership: "personal", agentIds: [], allAgents: true } as const;
mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValueOnce({
sessionId: "previous-login", environmentId: "previous-sandbox", aiConnection: intent,
status: "waiting_for_user", prompt: null,
}).mockImplementation(noActiveSession);
mockAgentsApi.cancelAdapterAuthLogin.mockRejectedValueOnce(new Error("Network unavailable"));
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container); roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel companyId="company-1" adapterType="grok_local" environmentId="new-sandbox"
aiConnection={{ ...intent, agentIds: [] }} chrome="onboarding" autoStart />
);
});
await flushUntil(() => Boolean(findButton(container, "Cancel previous sign-in and retry")));
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled();
await act(async () => findButton(container, "Cancel previous sign-in and retry")!.click());
await flushUntil(() => container.textContent?.includes("Could not cancel") ?? false);
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
let release!: () => void;
mockAgentsApi.cancelAdapterAuthLogin.mockImplementationOnce(() => new Promise<void>((resolve) => { release = resolve; }));
await act(async () => findButton(container, "Cancel previous sign-in and retry")!.click());
await flushReact();
expect(findButton(container, "Cancel previous sign-in and retry")!.disabled).toBe(true);
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
await act(async () => release());
await flushUntil(() => mockAgentsApi.startAdapterAuthLogin.mock.calls.length === 1);
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenLastCalledWith("company-1", "grok_local", "previous-login");
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "grok_local", { environmentId: "new-sandbox", aiConnection: intent });
queryClient.clear();
});
it("offers no Cancel in the onboarding chrome", async () => {
// The card carried a Cancel beside its instruction, directly above the
// step's own Back. Two ways out of one screen is one too many, so the
// button went — and with it the only explicit release, since unmounting
// deliberately keeps the session alive for a later resume. An abandoned
// login is now collected by the server deadline, the same as one abandoned
// by closing the tab.
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
chrome="onboarding"
autoStart
/>
);
});
// Wait for the card itself, then assert it is actually there: an absence
// check over an empty render passes for the wrong reason.
await flushUntil(() => container.textContent?.includes("authorization code") ?? false);
expect(container.textContent).toContain("authorization code");
expect(findButton(container, "Cancel")).toBeFalsy();
expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled();
});
it("reports the account-binding claim upward exactly once when the session authenticates", async () => {
// The authenticated owner read can carry the non-secret Codex
// account-binding claim. The panel hands it to the caller once; the
// caller (the edit-mode form) decides whether a bind is warranted.
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "bind-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/bind", code: "BIND-1" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "bind-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-bind-1", companyIdentityDiffers: true },
});
const onAccountBinding = vi.fn();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
);
});
await flushUntil(() => onAccountBinding.mock.calls.length > 0);
expect(onAccountBinding).toHaveBeenCalledTimes(1);
expect(onAccountBinding).toHaveBeenCalledWith({
secretId: "secret-bind-1",
companyIdentityDiffers: true,
});
// The panel narrates the bind as its own state, separate from the login's
// success line — the bind is a second save that can still fail.
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
expect(container.textContent).toContain("Agent bound to the signed-in account");
});
it("a failed bind save renders an explicit Retry instead of silently latching the claim", async () => {
// The status poll stops at the terminal state, so a rejected save behind
// a fire-and-forget latch would leave nothing to re-fire the bind. The
// panel keeps the claim and offers Retry.
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "bind-session-2",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/bind", code: "BIND-2" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "bind-session-2",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-bind-2", companyIdentityDiffers: true },
});
const onAccountBinding = vi
.fn()
.mockRejectedValueOnce(new Error("save failed"))
.mockResolvedValueOnce(undefined);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
);
});
await flushUntil(() => container.textContent?.includes("Could not bind this agent") ?? false);
const retry = findButton(container, "Retry");
expect(retry).toBeTruthy();
await act(async () => {
retry!.click();
});
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
expect(onAccountBinding).toHaveBeenCalledTimes(2);
expect(onAccountBinding).toHaveBeenLastCalledWith({
secretId: "secret-bind-2",
companyIdentityDiffers: true,
});
});
it("a second login in the same mounted panel runs its own bind", async () => {
// The terminal state re-enables Sign in without unmounting the panel. The
// bind latch is scoped to the session, so a second cross-account login
// binds again with ITS claim instead of being silently skipped.
mockAgentsApi.startAdapterAuthLogin
.mockResolvedValueOnce({
sessionId: "rebind-s1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/rebind", code: "REBIND-1" },
})
.mockResolvedValueOnce({
sessionId: "rebind-s2",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/rebind", code: "REBIND-2" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockImplementation(
async (_companyId: string, _adapterType: string, sid: string) => ({
sessionId: sid,
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: {
secretId: sid === "rebind-s2" ? "secret-second" : "secret-first",
companyIdentityDiffers: true,
},
}),
);
const onAccountBinding = vi.fn().mockResolvedValue(undefined);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
);
});
await flushUntil(() => onAccountBinding.mock.calls.length === 1);
expect(onAccountBinding).toHaveBeenLastCalledWith({
secretId: "secret-first",
companyIdentityDiffers: true,
});
const signIn = findButton(container, "Sign in");
expect(signIn).toBeTruthy();
await act(async () => {
signIn!.click();
});
await flushUntil(() => onAccountBinding.mock.calls.length === 2);
expect(onAccountBinding).toHaveBeenLastCalledWith({
secretId: "secret-second",
companyIdentityDiffers: true,
});
});
it("a new Sign in stays disabled while the bind save is in flight", async () => {
// Two overlapping bind saves can land out of order — the older save
// finishing last would silently revert the agent to the previous account.
// The panel serializes at its only entry point: Sign in is disabled until
// the current bind settles.
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
sessionId: "serialize-s1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/serialize", code: "SER-1" },
});
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "serialize-s1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-serialize", companyIdentityDiffers: true },
});
let releaseSave: (() => void) | null = null;
const onAccountBinding = vi.fn(
() =>
new Promise<void>((resolve) => {
releaseSave = resolve;
}),
);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="codex_local"
environmentId="sandbox-1"
autoStart
onAccountBinding={onAccountBinding}
/>
);
});
await flushUntil(() => container.textContent?.includes("Binding this agent") ?? false);
const signInWhileSaving = findButton(container, "Sign in");
expect(signInWhileSaving).toBeTruthy();
expect(signInWhileSaving!.disabled).toBe(true);
await act(async () => {
releaseSave?.();
});
await flushUntil(() => container.textContent?.includes("Agent bound to the signed-in account") ?? false);
const signInAfterSave = findButton(container, "Sign in");
expect(signInAfterSave!.disabled).toBe(false);
});
it("keeps unsaved configuration while a lifecycle poll updates progress", async () => {
const saved = makeAgent({ lifecycleState: "preparing", lifecycleVersion: 1 });
mockAgentsApi.get.mockResolvedValue(saved);
mockEnvironmentsApi.list.mockResolvedValue([]);
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const onSave = vi.fn();
function Harness() {
const lifecycle = useAgentLifecycleStatus(saved);
return <>
<AgentLifecycleStatus agent={lifecycle.data ?? saved} refreshError={lifecycle.isError} onRetry={() => {}} retryPending={false} />
<AgentConfigForm mode="edit" agent={saved} onSave={onSave} hidePromptTemplate showAdapterTypeField={false} />
</>;
}
await act(async () => renderFormView(root, queryClient, <Harness />));
await flushReact();
const nameInput = container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]')!;
setInputValue(nameInput, "Unsaved name");
await flushReact();
let finishPoll!: (agent: Agent) => void;
mockAgentsApi.get.mockReturnValueOnce(new Promise<Agent>(resolve => { finishPoll = resolve; }));
let poll!: Promise<void>;
await act(async () => { poll = queryClient.refetchQueries({ queryKey: [...queryKeys.agents.detail(saved.id), "lifecycle"] }); });
// Editing can continue while the request is in flight.
setInputValue(nameInput, "Edited during poll");
await flushReact();
await act(async () => {
finishPoll({ ...saved, name: "Another saved name", lifecycleState: "verifying", lifecycleVersion: 2 });
await poll;
});
await flushReact();
expect(mockAgentsApi.get).toHaveBeenCalledWith(saved.id, saved.companyId);
expect(container.textContent).toContain("Verifying agent");
expect(nameInput.value).toBe("Edited during poll");
await act(async () => findButton(container, "Save")!.click());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: "Edited during poll" }));
mockAgentsApi.get.mockRejectedValueOnce(new Error("Status unavailable"));
await act(async () => { await queryClient.refetchQueries({ queryKey: [...queryKeys.agents.detail(saved.id), "lifecycle"] }); });
await flushReact();
expect(container.textContent).toContain("Could not refresh agent lifecycle status");
expect(nameInput.value).toBe("Edited during poll");
queryClient.clear();
});
it("keeps edits made while the bind save is pending after the agent refresh", async () => {
// The bind save runs in the background while the form stays editable. The
// agent refresh that follows the save must subtract only what the save
// persisted — an edit made during "Binding this agent…" survives as
// pending dirty state instead of being wiped with the rest of the overlay.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
codexAccountBinding: { secretId: "secret-keep-edits", companyIdentityDiffers: true },
});
const releaseSaves: Array<() => void> = [];
const onSave = vi.fn(
(_patch: Record<string, unknown>) =>
new Promise<void>((resolve) => {
releaseSaves.push(resolve);
}),
);
mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments());
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
// The harness swaps the agent object the way the page does after a save
// refresh: same mounted form, new agent identity.
let refreshAgent: (agent: Agent) => void = () => {};
function RefreshHarness() {
const [agent, setAgent] = useState(() => makeAgent({ defaultEnvironmentId: "sandbox-1" }));
refreshAgent = setAgent;
return (
<AgentConfigForm
mode="edit"
agent={agent}
onSave={onSave}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
);
}
await act(async () => {
renderFormView(root, queryClient, <RefreshHarness />);
});
await flushReact();
await runTest(container);
await startLogin(container);
await flushUntil(() => onSave.mock.calls.length > 0);
// Rename the agent while the bind save is still in flight.
const nameInput = container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]');
expect(nameInput).toBeTruthy();
setInputValue(nameInput!, "Renamed during bind");
await flushReact();
// An UNRELATED refresh lands while the save is still pending — a poll or
// another actor's save, so this agent does NOT carry the binding yet. It
// must not consume the persisted snapshot (the bind save's own refresh
// still needs it), and it must not subtract the snapshot from the overlay
// either: with the binding entry gone from both the overlay and the
// refreshed agent, an ordinary Save racing the binding refresh would
// replace the config without CODEX_HOME and undo the just-persisted bind.
await act(async () => {
refreshAgent(makeAgent({ defaultEnvironmentId: "sandbox-1" }));
});
await flushReact();
expect(
container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]')!.value,
).toBe("Renamed during bind");
// An ordinary Save in that window still carries the binding.
const persistedPatch = onSave.mock.calls[0]![0] as Record<string, unknown>;
const saveButton = findButton(container, "Save");
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.click();
});
await flushReact();
expect(onSave.mock.calls.length).toBeGreaterThan(1);
const racingPatch = onSave.mock.calls.at(-1)![0] as Record<string, unknown>;
const racingEnv = (racingPatch.adapterConfig as Record<string, unknown>).env as Record<
string,
unknown
>;
expect(racingEnv.CODEX_HOME).toEqual({
type: "secret_ref",
secretId: "secret-keep-edits",
version: "latest",
});
// Both saves land, and the page refreshes the agent with the persisted
// binding — the same adapter config the bind save sent.
await act(async () => {
for (const release of releaseSaves) release();
});
await flushReact();
await act(async () => {
refreshAgent(
makeAgent({
defaultEnvironmentId: "sandbox-1",
adapterConfig: persistedPatch.adapterConfig as Record<string, unknown>,
}),
);
});
await flushReact();
// The rename survives the refresh instead of reverting to the refreshed
// agent's name.
expect(
container.querySelector<HTMLInputElement>('input[placeholder="Agent name"]')!.value,
).toBe("Renamed during bind");
});
it("resumes an active login session on mount, adopting its session id and prompt", async () => {
// A page reload loses every piece of local state, so the panel must read
// the caller's active session and adopt it instead of starting a new one.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValue({
sessionId: "resumed-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/resumed", code: "RESUME-1" },
});
// The prompt survives every owner read while the session stays active, so
// the status poll for the resumed session agrees with the resumed read.
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "resumed-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: { url: "https://auth.example.test/resumed", code: "RESUME-1" },
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await flushUntil(() => (result.container.textContent ?? "").includes("RESUME-1"));
// The prompt from the resumed read shows without a fresh start.
expect(result.container.textContent).toContain("RESUME-1");
expect(result.container.textContent).toContain("https://auth.example.test/resumed");
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
// The panel polls the resumed session id, not a freshly started one.
expect(mockAgentsApi.getAdapterAuthLoginStatus).toHaveBeenCalledWith(
"company-1",
"codex_local",
"resumed-session-1",
);
expect(findButton(result.container, "Cancel")).toBeTruthy();
});
it("starts a new session when the active-session read finds none", async () => {
// The default mock already answers with no active session (a 404). This
// pins the fallback: the panel still waits for the caller's press.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await flushUntil(() => mockAgentsApi.getActiveAdapterAuthLoginSession.mock.calls.length > 0);
await flushReact();
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
await startLogin(result.container);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "codex_local", {
environmentId: "sandbox-1",
});
});
it("releases a resumed session after an unrecoverable resume error, waiting for the cancel response", async () => {
// The active-session read finds a session, but by the time the status poll
// reaches the server the session is already gone (a race between the two
// reads). The panel cannot resume it, so it releases the reservation
// explicitly instead of trusting the 404 alone, and it waits for that
// release before it returns to its start state.
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValue({
sessionId: "resumed-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
prompt: null,
});
mockAgentsApi.getAdapterAuthLoginStatus.mockRejectedValue(
new ApiError("Adapter login session not found", 404, {
error: "Adapter login session not found",
}),
);
let resolveCancel!: () => void;
mockAgentsApi.cancelAdapterAuthLogin.mockReturnValue(
new Promise((resolve) => {
resolveCancel = () => resolve({
sessionId: "resumed-session-1",
environmentId: "sandbox-1",
status: "cancelled",
expiresAt: null,
failure: null,
prompt: null,
});
}),
);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await flushUntil(() =>
mockAgentsApi.cancelAdapterAuthLogin.mock.calls.some((call) => call[2] === "resumed-session-1"),
);
// The cancel call fired, but the panel still shows the resumed login as
// active because it is waiting for the cancel response.
expect(findButton(result.container, "Cancel")).toBeTruthy();
resolveCancel();
await flushUntil(() => findButton(result.container, "Sign in")?.disabled === false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
});
it("announces the login state through a polite live region", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
const live = result.container.querySelector('[role="status"][aria-live="polite"]');
expect(live).toBeTruthy();
expect(live?.textContent).toContain("WXYZ-1234");
});
it("opens the authentication URL in a new tab with a safe rel", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
const link = result.container.querySelector('a[href="https://auth.example.test/device"]');
expect(link).toBeTruthy();
expect(link?.getAttribute("target")).toBe("_blank");
expect(link?.getAttribute("rel")).toBe("noreferrer noopener");
});
it("disables a second login start while a session is active", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
const startButton = findButton(result.container, "Sign in");
expect(startButton).toBeTruthy();
expect(startButton?.disabled).toBe(true);
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledTimes(1);
});
it("renders the authenticated terminal state", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("Authenticated");
});
it("renders the failed terminal state with the non-secret message", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "failed",
expiresAt: null,
failure: { reason: "device_rejected", message: "The device rejected the code." },
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("Login failed");
expect(result.container.textContent).toContain("The device rejected the code.");
});
it("renders the timed-out terminal state", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
sessionId: "session-1",
environmentId: "sandbox-1",
status: "timed_out",
expiresAt: null,
failure: null,
prompt: null,
});
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
expect(result.container.textContent).toContain("Login timed out");
});
it("hides the Login button when the effective environment changes after a Test", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
const result = await renderCodexSandbox();
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Sign in")).toBeTruthy();
await selectEnvironment(result.container, "");
expect(findButton(result.container, "Sign in")).toBeFalsy();
});
it("shows a non-blocking disclaimer when the transport advisory is present", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
// The guarded prompt read reports a non-confidential transport. The server
// does not block the login; it attaches the advisory. The panel shows a
// disclaimer and the login still proceeds.
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockResolvedValue({
authorizationUrl: "https://claude.example.test/authorize",
transportAdvisory: { code: "insecure_transport" },
});
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() => (result.container.textContent ?? "").includes("not encrypted"));
// The disclaimer states the transport risk in plain language.
expect(result.container.textContent).toContain("not encrypted");
expect(result.container.textContent).toContain("clear text");
// The login still proceeds: the panel shows the URL and the browser-code
// input, so the disclaimer never blocks the flow.
expect(result.container.textContent).toContain("https://claude.example.test/authorize");
expect(
result.container.querySelector('input[aria-label="Browser code"]'),
).toBeTruthy();
});
it("submits one browser code and clears the input after submit", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
Boolean(result.container.querySelector('input[aria-label="Browser code"]')),
);
const input = result.container.querySelector<HTMLInputElement>(
'input[aria-label="Browser code"]',
);
setInputValue(input!, "BROWSERCODE-9");
await flushReact();
await clickByText(result.container, "Submit");
await flushReact();
expect(mockAgentsApi.submitClaudeSetupTokenBrowserCode).toHaveBeenCalledWith(
"company-1",
"claude-session-1",
"BROWSERCODE-9",
);
// The panel clears the browser code after submit, so the secret does not
// linger in the input.
const clearedInput = result.container.querySelector<HTMLInputElement>(
'input[aria-label="Browser code"]',
);
expect(clearedInput?.value).toBe("");
});
it("treats the server stored state as success and captures the stored-session claim", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
});
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() => (result.container.textContent ?? "").includes("Authenticated"));
expect(mockAgentsApi.completeClaudeSetupTokenLogin).toHaveBeenCalledWith(
"company-1",
"claude-session-1",
);
expect(result.container.textContent).toContain("Authenticated");
});
it("clears the stored-session claim and the fixed binding when the effective environment changes", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
});
const result = await renderStatefulCreateClaudeSandbox([
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona One",
driver: "sandbox",
config: { provider: "daytona" },
}),
makeEnvironment({
id: "sandbox-2",
name: "Daytona Two",
driver: "sandbox",
config: { provider: "daytona" },
}),
]);
roots.push(result.root);
// Sign in on the first sandbox. The stored state adds the fixed
// `CLAUDE_CODE_OAUTH_TOKEN` binding and the non-secret claim to the form.
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() => result.valuesRef.current.claudeStoredSessionId != null);
expect(result.valuesRef.current.claudeStoredSessionId).toBe("stored-session-1");
expect("CLAUDE_CODE_OAUTH_TOKEN" in (result.valuesRef.current.envBindings ?? {})).toBe(true);
// Change the effective environment. The reset drops the claim and the
// binding, so the create request cannot send a claim for the old target.
await selectEnvironment(result.container, "sandbox-2");
const values = result.valuesRef.current;
expect(values.claudeStoredSessionId ?? null).toBeNull();
expect(values.claudeApplyStoredLogin ?? false).toBe(false);
expect("CLAUDE_CODE_OAUTH_TOKEN" in (values.envBindings ?? {})).toBe(false);
// The create request body carries no stale claim.
const payload = buildNewAgentHirePayload({
name: "Cody",
effectiveRole: "Engineer",
configValues: values,
adapterConfig: {},
});
expect("storedSessionId" in payload).toBe(false);
});
it("clears the false missing-definition error on the bound row after a login stores the token", async () => {
// Regression: the user-secret-definitions list read at page load does not
// yet contain the Claude token key, but it is not empty. A stale non-empty
// list makes the bound row show a false "no longer exists" error. The login
// must invalidate the list so the refetch clears the error.
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
});
const makeDefinition = (key: string): UserSecretDefinition => ({
id: `def-${key}`,
companyId: "company-1",
key,
name: key.toUpperCase(),
description: null,
status: "active",
provider: "local_encrypted",
managedMode: "paperclip_managed",
providerConfigId: null,
providerMetadata: null,
usageGuidance: null,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
deletedAt: null,
createdAt: new Date(0),
updatedAt: new Date(0),
});
const staleList = [makeDefinition("OTHER_SECRET")];
const freshList = [makeDefinition("OTHER_SECRET"), makeDefinition("CLAUDE_CODE_OAUTH_TOKEN")];
mockSecretsApi.listUserSecretDefinitions.mockReset();
mockSecretsApi.listUserSecretDefinitions
.mockResolvedValueOnce(staleList)
.mockResolvedValue(freshList);
const result = await renderStatefulCreateClaudeSandbox(daytonaEnvironments());
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() => result.valuesRef.current.claudeStoredSessionId != null);
// The login bound the fixed Claude token row.
expect("CLAUDE_CODE_OAUTH_TOKEN" in (result.valuesRef.current.envBindings ?? {})).toBe(true);
// The invalidation refetched the definitions, so the stale list no longer
// drives the row.
await flushUntil(() => mockSecretsApi.listUserSecretDefinitions.mock.calls.length >= 2);
await flushUntil(() => {
const inputs = Array.from(result.container.querySelectorAll("input"));
return inputs.some((input) => (input as HTMLInputElement).value === "CLAUDE_CODE_OAUTH_TOKEN");
});
// Vacuous-pass guard: the bound row rendered.
const inputs = Array.from(result.container.querySelectorAll("input"));
expect(inputs.some((input) => (input as HTMLInputElement).value === "CLAUDE_CODE_OAUTH_TOKEN")).toBe(true);
// The row shows no false missing-definition error.
expect(result.container.textContent ?? "").not.toContain("no longer exists");
});
it("reports the non-secret stored-session claim to the parent on success", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
});
const onStored = vi.fn();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
onStored={onStored}
/>
);
});
await startLogin(container);
await flushUntil(() => onStored.mock.calls.length > 0);
expect(onStored).toHaveBeenCalledWith("stored-session-1");
});
it("offers no Cancel in the onboarding chrome", async () => {
// The card carried a Cancel beside its instruction, directly above the
// step's own Back. Two ways out of one screen is one too many, so the
// button went — and with it the only explicit release, since unmounting
// deliberately keeps the session alive for a later resume. An abandoned
// login is now collected by the server deadline, the same as one abandoned
// by closing the tab.
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
chrome="onboarding"
autoStart
/>
);
});
// Wait for the card itself, then assert it is actually there: an absence
// check over an empty render passes for the wrong reason.
await flushUntil(() => container.textContent?.includes("authorization code") ?? false);
expect(container.textContent).toContain("authorization code");
expect(findButton(container, "Cancel")).toBeFalsy();
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).not.toHaveBeenCalled();
});
it("offers an apply-existing affordance when the status route reports a stored value", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
secretId: "secret-1",
latestVersion: 3,
});
const onApplyStored = vi.fn();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
onApplyStored={onApplyStored}
/>
);
});
await flushUntil(() => Boolean(findButton(container, "Use saved login")));
await clickByText(container, "Use saved login");
expect(onApplyStored).toHaveBeenCalledTimes(1);
// The panel shows the applied confirmation and hides the apply affordance.
await flushUntil(() =>
(container.textContent ?? "").includes("The saved Claude login is bound to this agent now."),
);
expect(findButton(container, "Use saved login")).toBeUndefined();
// The apply-existing path never starts a login round trip.
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
});
it("does not offer the apply-existing affordance when there is no stored value", async () => {
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue(null);
const onApplyStored = vi.fn();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AdapterLoginPanel
companyId="company-1"
adapterType="claude_local"
environmentId="sandbox-1"
onApplyStored={onApplyStored}
/>
);
});
await flushUntil(() => Boolean(findButton(container, "Sign in")));
expect(findButton(container, "Use saved login")).toBeUndefined();
expect(onApplyStored).not.toHaveBeenCalled();
});
it("returns to its start state with a fixed message on a server failed state", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "failed",
failure: { reason: "rejected", message: "the provider rejected the browser code" },
expiresAt: null,
});
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
(result.container.textContent ?? "").includes("The login did not finish"),
);
// The panel shows a fixed message and returns to its start state. The Log in
// button is available again.
expect(result.container.textContent).toContain("The login did not finish");
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
// The panel never shows the provider failure message, which could carry a
// secret.
expect(result.container.textContent).not.toContain("the provider rejected the browser code");
});
it("returns to its start state on a server timed_out state", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "timed_out",
failure: null,
expiresAt: null,
});
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
(result.container.textContent ?? "").includes("The login did not finish"),
);
expect(result.container.textContent).toContain("The login did not finish");
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
});
it("shows a terminal failure and stops polling on a status 404 from server cleanup", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
// The server removes the row and the in-memory session at once on a
// non-stored terminal state, so the status route returns 404 when the login
// fails and the cleanup wins the race against the next poll. The panel must
// fail loudly instead of holding stale data.
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockRejectedValue(
new ApiError("Setup-token login session not found.", 404, {
error: "Setup-token login session not found.",
}),
);
// The prompt route also returns 404, so no authorization URL ever surfaces.
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockRejectedValue(
new ApiError("Setup-token login session not found.", 404, {
error: "Setup-token login session not found.",
}),
);
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
(result.container.textContent ?? "").includes("The login did not finish"),
);
// The panel shows the fixed failure message and returns to its start state.
expect(result.container.textContent).toContain("The login did not finish");
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
// The panel shows no credential material: no authorization URL and no
// browser-code input.
expect(result.container.textContent).not.toContain("https://claude.example.test/authorize");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
// The stale polling stopped. The status call count stays fixed after the
// failure, so the panel does not poll a session the server removed.
const statusCallsAtFailure = mockAgentsApi.getClaudeSetupTokenLoginStatus.mock.calls.length;
await new Promise((resolve) => setTimeout(resolve, 2500));
await flushReact();
expect(mockAgentsApi.getClaudeSetupTokenLoginStatus.mock.calls.length).toBe(
statusCallsAtFailure,
);
});
it("cancels an active Claude login and returns to its start state", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
(result.container.textContent ?? "").includes("https://claude.example.test/authorize"),
);
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith("company-1", {
environmentId: "sandbox-1",
});
// The panel shows the authorization URL and a browser-code input. It never
// shows a server-displayed code.
expect(result.container.textContent).toContain("https://claude.example.test/authorize");
expect(
result.container.querySelector('input[aria-label="Browser code"]'),
).toBeTruthy();
// The default prompt carries no advisory: a confidential transport. So the
// panel shows no disclaimer.
expect(result.container.textContent).not.toContain("not encrypted");
expect(findButton(result.container, "Cancel")).toBeTruthy();
await clickByText(result.container, "Cancel");
await flushReact();
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledWith(
"company-1",
"claude-session-1",
);
// The panel resets: the Sign in button is available again, and the URL and the
// browser-code input are gone.
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("https://claude.example.test/authorize");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
});
it("treats a 404 cancel the same as success and returns to its start state", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
// The server removes a terminal session, so a cancel of an already-terminal
// or unknown session can return a 404. The panel must treat that 404 the
// same as a successful cancel: clear the session and stop the polls.
mockAgentsApi.cancelClaudeSetupTokenLogin.mockRejectedValue(
new ApiError("Setup-token login session not found.", 404, {
error: "Setup-token login session not found.",
}),
);
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
(result.container.textContent ?? "").includes("https://claude.example.test/authorize"),
);
await clickByText(result.container, "Cancel");
await flushReact();
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledWith(
"company-1",
"claude-session-1",
);
// The panel reset even though the cancel returned a 404: the Sign in button is
// available again, and the URL and the browser-code input are gone. No error
// message remains.
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(result.container.textContent).not.toContain("https://claude.example.test/authorize");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
expect(result.container.textContent).not.toContain("Could not cancel the login.");
});
it("does not cancel an active login session when the panel unmounts", async () => {
// The owner-scoped active-session read and the manual Cancel button now
// take over the purpose the unmount cancel used to serve, so an unmount
// must leave the session reachable by a later mount's resume read.
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderClaudeSandbox();
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
(result.container.textContent ?? "").includes("https://claude.example.test/authorize"),
);
// The login is active before the unmount.
expect(findButton(result.container, "Cancel")).toBeTruthy();
mockAgentsApi.cancelClaudeSetupTokenLogin.mockClear();
await act(async () => {
result.root.unmount();
});
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).not.toHaveBeenCalled();
});
it("does not cancel on unmount when no login is active", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderClaudeSandbox();
await runTest(result.container);
// The panel shows the Sign in button but no session started, so no active
// session exists to cancel.
expect(findButton(result.container, "Sign in")).toBeTruthy();
await act(async () => {
result.root.unmount();
});
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).not.toHaveBeenCalled();
});
it("resumes an active Claude login session on mount, adopting its session id and authorization URL", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue({
sessionId: "resumed-claude-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
panelMode: "submitted_browser_code",
prompt: { authorizationUrl: "https://claude.example.test/resumed" },
});
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await flushUntil(() =>
(result.container.textContent ?? "").includes("https://claude.example.test/resumed"),
);
expect(result.container.textContent).toContain("https://claude.example.test/resumed");
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(mockAgentsApi.getClaudeSetupTokenLoginStatus).toHaveBeenCalledWith(
"company-1",
"resumed-claude-session-1",
);
expect(findButton(result.container, "Cancel")).toBeTruthy();
});
it("starts a new Claude login when the active-session read finds none", async () => {
// The default mock already answers with no active session (a 404).
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await flushUntil(() => mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mock.calls.length > 0);
await flushReact();
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
await startLogin(result.container);
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalled();
});
it("cancels a resumed Claude login session with the manual Cancel button", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue({
sessionId: "resumed-claude-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
panelMode: "submitted_browser_code",
prompt: { authorizationUrl: "https://claude.example.test/resumed" },
});
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await flushUntil(() =>
(result.container.textContent ?? "").includes("https://claude.example.test/resumed"),
);
await clickByText(result.container, "Cancel");
await flushReact();
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledWith(
"company-1",
"resumed-claude-session-1",
);
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
});
it("releases a resumed Claude login after an unrecoverable resume error, waiting for the cancel response", async () => {
// The active-session read finds a session, but the status poll for that
// resumed session finds it already gone (a race between the two reads).
// The panel cannot resume it, so it releases the reservation explicitly
// and waits for that release before it returns to its start state.
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue({
sessionId: "resumed-claude-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: null,
failure: null,
panelMode: "submitted_browser_code",
prompt: null,
});
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockRejectedValue(
new ApiError("Setup-token login session not found.", 404, {
error: "Setup-token login session not found.",
}),
);
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockRejectedValue(
new ApiError("Setup-token login session not found.", 404, {
error: "Setup-token login session not found.",
}),
);
let resolveCancel!: () => void;
mockAgentsApi.cancelClaudeSetupTokenLogin.mockReturnValue(
new Promise((resolve) => {
resolveCancel = () => resolve(undefined);
}),
);
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await flushUntil(() =>
mockAgentsApi.cancelClaudeSetupTokenLogin.mock.calls.some(
(call) => call[1] === "resumed-claude-session-1",
),
);
// The cancel call fired, but the panel still shows the resumed login as
// active because it is waiting for the cancel response.
expect(findButton(result.container, "Sign in")?.disabled).toBe(true);
resolveCancel();
await flushUntil(() => findButton(result.container, "Sign in")?.disabled === false);
expect(findButton(result.container, "Cancel")).toBeFalsy();
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
});
it("stops both polls and shows the timed-out state at the server deadline", async () => {
vi.useFakeTimers();
// Pin the clock to a known base. The panel arms the timed-out timer from the
// status `expiresAt`, so the test clock and the server deadline share one
// base time.
const baseNowMs = 1_700_000_000_000;
vi.setSystemTime(baseNowMs);
// The server deadline for this session. It is far longer than the old fixed
// 60-second cutoff, so the test proves the panel now tracks `expiresAt`.
const serverDeadlineMs = 5 * 60_000;
const expiresAtIso = new Date(baseNowMs + serverDeadlineMs).toISOString();
try {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
// The status never reaches a terminal state, and the prompt route returns
// 404 forever, so the authorization URL never surfaces. The status route
// carries `expiresAt`, which drives the client cutoff. Without the client
// cap the panel would poll both routes forever.
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "waiting_for_user",
expiresAt: expiresAtIso,
failure: null,
});
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockRejectedValue(
new ApiError("Setup-token login session not found.", 404, {
error: "Setup-token login session not found.",
}),
);
// Flush React effects and pending promises while the fake clock advances by
// zero, so the mocked queries settle without real time.
const flushFake = async () => {
await act(async () => {
for (let index = 0; index < 6; index += 1) {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(0);
}
});
};
const clickFake = async (container: HTMLElement, label: string) => {
const button = findButton(container, label);
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushFake();
};
const advanceFake = async (ms: number) => {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
await flushFake();
};
mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments());
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = createFormQueryClient();
await act(async () => {
renderFormView(root, queryClient,
<AgentConfigForm
mode="edit"
agent={makeAgent({
adapterType: "claude_local",
defaultEnvironmentId: "sandbox-1",
})}
onSave={vi.fn()}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
);
});
await flushFake();
await clickFake(container, "Test");
await clickFake(container, "Sign in");
// The login is active: both polls have run at least once.
const statusCallsAtStart = mockAgentsApi.getClaudeSetupTokenLoginStatus.mock.calls.length;
const promptCallsAtStart = mockAgentsApi.getClaudeSetupTokenLoginPrompt.mock.calls.length;
expect(statusCallsAtStart).toBeGreaterThanOrEqual(1);
expect(promptCallsAtStart).toBeGreaterThanOrEqual(1);
// Advance six seconds: both polls run again, so polling is active. The panel
// has not timed out yet.
await advanceFake(6_000);
expect(mockAgentsApi.getClaudeSetupTokenLoginStatus.mock.calls.length).toBeGreaterThan(
statusCallsAtStart,
);
expect(mockAgentsApi.getClaudeSetupTokenLoginPrompt.mock.calls.length).toBeGreaterThan(
promptCallsAtStart,
);
expect(container.textContent).not.toContain("The login timed out");
// Advance past the old fixed sixty-second cutoff. The panel does NOT time
// out now, because the cutoff comes from the server `expiresAt`, which is
// far longer than sixty seconds. This proves the fixed 60-second cap is
// gone.
await advanceFake(60_000);
expect(container.textContent).not.toContain("The login timed out");
// Advance past the server deadline. The panel enters the timed-out state.
// Total elapsed after this step is 1 second past `serverDeadlineMs`.
await advanceFake(serverDeadlineMs - 66_000 + 1_000);
expect(container.textContent).toContain("The login timed out");
// The panel released the server session when the cutoff fired. The cancel
// frees the per-owner reservation now, so an immediate retry by the same
// owner starts a new session and does not hit the "too many active
// sessions" cap.
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledWith(
"company-1",
"claude-session-1",
);
// The Log in button is available again, and the Cancel button is gone.
expect(findButton(container, "Sign in")?.disabled).toBe(false);
expect(findButton(container, "Cancel")).toBeFalsy();
// Both polls stopped. A further ten seconds adds no new poll call.
const statusCallsAtTimeout = mockAgentsApi.getClaudeSetupTokenLoginStatus.mock.calls.length;
const promptCallsAtTimeout = mockAgentsApi.getClaudeSetupTokenLoginPrompt.mock.calls.length;
await advanceFake(10_000);
expect(mockAgentsApi.getClaudeSetupTokenLoginStatus.mock.calls.length).toBe(
statusCallsAtTimeout,
);
expect(mockAgentsApi.getClaudeSetupTokenLoginPrompt.mock.calls.length).toBe(
promptCallsAtTimeout,
);
} finally {
vi.useRealTimers();
}
});
it("opens the authorization URL in a new tab with a safe rel", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
Boolean(
result.container.querySelector('a[href="https://claude.example.test/authorize"]'),
),
);
const link = result.container.querySelector(
'a[href="https://claude.example.test/authorize"]',
);
expect(link).toBeTruthy();
expect(link?.getAttribute("target")).toBe("_blank");
expect(link?.getAttribute("rel")).toBe("noreferrer noopener");
});
it("never renders the OAuth token in the Document Object Model", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
// The mocks add a token field that the real response never carries. The
// panel must render neither the token nor any other unknown response field.
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
oauthToken: "sk-ant-SECRET-TOKEN",
});
mockAgentsApi.completeClaudeSetupTokenLogin.mockResolvedValue({
storedSessionId: "stored-session-1",
oauthToken: "sk-ant-SECRET-TOKEN",
});
const result = await renderClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() => (result.container.textContent ?? "").includes("Authenticated"));
expect(result.container.textContent).toContain("Authenticated");
expect(result.container.textContent).not.toContain("sk-ant-SECRET-TOKEN");
});
});
const FIXED_CLAUDE_OAUTH_BINDING = {
type: "user_secret_ref",
key: "CLAUDE_CODE_OAUTH_TOKEN",
version: "latest",
required: true,
};
// Read the merged create-mode values after one or more onChange patches. The
// create form emits a partial patch, so later assertions merge every patch onto
// the seed values, the same way the parent page keeps the controlled state.
function mergedCreateValues(
seed: Record<string, unknown>,
onChange: ReturnType<typeof vi.fn>,
): Record<string, unknown> {
return onChange.mock.calls.reduce(
(acc, [patch]) => ({ ...acc, ...(patch as Record<string, unknown>) }),
{ ...seed },
);
}
describe("AgentConfigForm create-mode Claude OAuth binding", () => {
let roots: Root[] = [];
beforeEach(() => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.detectModel.mockResolvedValue(null);
mockAgentsApi.list.mockResolvedValue([]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true });
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "any" });
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listProposals.mockResolvedValue([]);
mockAgentsApi.getActiveAdapterAuthLoginSession.mockImplementation(noActiveSession);
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockImplementation(noActiveSession);
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "starting",
expiresAt: null,
failure: null,
panelMode: "submitted_browser_code",
prompt: null,
});
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
});
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockResolvedValue({
authorizationUrl: "https://claude.example.test/authorize",
});
mockAgentsApi.completeClaudeSetupTokenLogin.mockResolvedValue({
storedSessionId: "stored-session-1",
});
mockAgentsApi.cancelClaudeSetupTokenLogin.mockResolvedValue(undefined);
// Default: the owner has no stored Claude login. A test that needs a stored
// value overrides this with a status body.
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue(null);
});
afterEach(async () => {
for (const root of roots) {
await act(async () => {
root.unmount();
});
}
roots = [];
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("adds the fixed CLAUDE_CODE_OAUTH_TOKEN binding after the server stored state", async () => {
const result = await renderCreateClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
result.onChange.mock.calls.some(
([patch]) => (patch as Record<string, unknown>).claudeStoredSessionId,
),
);
const values = mergedCreateValues(
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", envBindings: {} },
result.onChange,
);
expect(values.claudeStoredSessionId).toBe("stored-session-1");
const bindings = values.envBindings as Record<string, unknown>;
expect(bindings.CLAUDE_CODE_OAUTH_TOKEN).toEqual(FIXED_CLAUDE_OAUTH_BINDING);
});
it("preserves unrelated environment bindings when it adds the fixed binding", async () => {
const seedBindings = { EXISTING_VAR: { type: "plain", value: "keep-me" } };
const result = await renderCreateClaudeSandbox({ envBindings: seedBindings });
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
result.onChange.mock.calls.some(
([patch]) => (patch as Record<string, unknown>).claudeStoredSessionId,
),
);
const values = mergedCreateValues(
{ adapterType: "claude_local", envBindings: seedBindings },
result.onChange,
);
const bindings = values.envBindings as Record<string, unknown>;
expect(bindings.EXISTING_VAR).toEqual({ type: "plain", value: "keep-me" });
expect(bindings.CLAUDE_CODE_OAUTH_TOKEN).toEqual(FIXED_CLAUDE_OAUTH_BINDING);
});
it("never puts a token value in the fixed binding", async () => {
const result = await renderCreateClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
result.onChange.mock.calls.some(
([patch]) => (patch as Record<string, unknown>).claudeStoredSessionId,
),
);
const values = mergedCreateValues(
{ adapterType: "claude_local", envBindings: {} },
result.onChange,
);
const bindings = values.envBindings as Record<string, Record<string, unknown>>;
// The fixed binding is a reference. It carries no `value` field, so the
// adapter config never holds the token value.
expect(bindings.CLAUDE_CODE_OAUTH_TOKEN.type).toBe("user_secret_ref");
expect(bindings.CLAUDE_CODE_OAUTH_TOKEN).not.toHaveProperty("value");
expect(JSON.stringify(values.envBindings)).not.toContain("sk-ant");
});
it("returns to the login start state when the server claim write fails", async () => {
mockAgentsApi.completeClaudeSetupTokenLogin.mockRejectedValue(
new Error("the provider rejected the stored-session claim"),
);
const result = await renderCreateClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() =>
(result.container.textContent ?? "").includes("The login did not finish"),
);
// The panel shows a fixed, non-secret message and returns to its start state.
expect(result.container.textContent).toContain("The login did not finish");
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
expect(result.container.textContent).not.toContain(
"the provider rejected the stored-session claim",
);
// A failed claim adds no binding and holds no claim.
expect(
result.onChange.mock.calls.some(
([patch]) => (patch as Record<string, unknown>).claudeStoredSessionId,
),
).toBe(false);
});
});
// Render the edit-mode form for an existing Claude agent in a sandbox
// environment. The helper returns the `onSave` spy so a test can read the patch
// that a stored login sends to the agent-update path.
async function renderEditClaudeSandbox(agentOverrides: Partial<Agent> = {}) {
mockEnvironmentsApi.list.mockResolvedValue(daytonaEnvironments());
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const queryClient = createFormQueryClient();
const onSave = vi.fn().mockResolvedValue(undefined);
await act(async () => {
renderFormView(root, queryClient,
<AgentConfigForm
mode="edit"
agent={makeAgent({
adapterType: "claude_local",
defaultEnvironmentId: "sandbox-1",
...agentOverrides,
})}
onSave={onSave}
hidePromptTemplate
showAdapterTypeField={false}
showAdapterTestEnvironmentButton
/>
);
});
await flushReact();
return { container, root, onSave };
}
describe("AgentConfigForm edit-mode Claude OAuth binding", () => {
let roots: Root[] = [];
beforeEach(() => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.detectModel.mockResolvedValue(null);
mockAgentsApi.list.mockResolvedValue([]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true });
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "any" });
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listProposals.mockResolvedValue([]);
mockAgentsApi.getActiveAdapterAuthLoginSession.mockImplementation(noActiveSession);
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockImplementation(noActiveSession);
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "starting",
expiresAt: null,
failure: null,
panelMode: "submitted_browser_code",
prompt: null,
});
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
sessionId: "claude-session-1",
environmentId: "sandbox-1",
status: "authenticated",
expiresAt: null,
failure: null,
});
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockResolvedValue({
authorizationUrl: "https://claude.example.test/authorize",
});
mockAgentsApi.completeClaudeSetupTokenLogin.mockResolvedValue({
storedSessionId: "stored-session-1",
});
mockAgentsApi.cancelClaudeSetupTokenLogin.mockResolvedValue(undefined);
// Default: the owner has no stored Claude login. A test that needs a stored
// value overrides this with a status body.
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue(null);
});
afterEach(async () => {
for (const root of roots) {
await act(async () => {
root.unmount();
});
}
roots = [];
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("adds the fixed CLAUDE_CODE_OAUTH_TOKEN binding to the agent and persists it", async () => {
const result = await renderEditClaudeSandbox();
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() => result.onSave.mock.calls.length > 0);
// A stored login sends one agent-update patch. The patch replaces the adapter
// config, and its env holds the fixed binding, so the agent keeps the binding
// without a manual step.
const patch = result.onSave.mock.calls.at(-1)![0] as Record<string, unknown>;
expect(patch.replaceAdapterConfig).toBe(true);
const adapterConfig = patch.adapterConfig as Record<string, unknown>;
const bindings = adapterConfig.env as Record<string, unknown>;
expect(bindings.CLAUDE_CODE_OAUTH_TOKEN).toEqual(FIXED_CLAUDE_OAUTH_BINDING);
// The persisted patch never carries a token value.
expect(JSON.stringify(adapterConfig.env)).not.toContain("sk-ant");
// An update can never consume the fresh login's stored-session claim -- only
// create and hire can, per `enforceClaudeOAuthBindingClaim`. So a save that
// introduces the binding through an update must set `applyStoredClaudeLogin`,
// or the server rejects the patch with the fixed claim error even though the
// login already stored the token. Regression coverage for that gap.
expect(patch.applyStoredClaudeLogin).toBe(true);
});
it("keeps every unrelated existing binding when it adds the fixed binding", async () => {
const result = await renderEditClaudeSandbox({
adapterConfig: { env: { EXISTING_VAR: { type: "plain", value: "keep-me" } } },
});
roots.push(result.root);
await runTest(result.container);
await startLogin(result.container);
await flushUntil(() => result.onSave.mock.calls.length > 0);
const patch = result.onSave.mock.calls.at(-1)![0] as Record<string, unknown>;
const adapterConfig = patch.adapterConfig as Record<string, unknown>;
const bindings = adapterConfig.env as Record<string, unknown>;
expect(bindings.EXISTING_VAR).toEqual({ type: "plain", value: "keep-me" });
expect(bindings.CLAUDE_CODE_OAUTH_TOKEN).toEqual(FIXED_CLAUDE_OAUTH_BINDING);
});
});
describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
let roots: Root[] = [];
const MANAGED_AGENT_CONFIG = {
cwd: "/srv/agents/cody",
command: "claude",
engine: "acp",
agentCommand: "claude-agent-acp",
stateDir: "/srv/agents/cody/acp-state",
};
function setManagedSandboxOnly(enabled: boolean) {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableEnvironments: true,
enableManagedSandboxOnly: enabled,
});
}
/** Every `Field` renders its label in a `<label>`, so this reads the form. */
function fieldLabels(container: HTMLElement) {
return Array.from(container.querySelectorAll("label")).map((label) => label.textContent?.trim() ?? "");
}
function choosePathButtons(container: HTMLElement) {
return Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Choose",
);
}
beforeEach(() => {
mockAgentsApi.adapterModels.mockResolvedValue([]);
mockAgentsApi.detectModel.mockResolvedValue(null);
mockAgentsApi.list.mockResolvedValue([]);
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null });
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "any" });
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.listProposals.mockResolvedValue([]);
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue(null);
setManagedSandboxOnly(false);
});
afterEach(async () => {
for (const root of roots) {
await act(async () => {
root.unmount();
});
}
roots = [];
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("shows the host path and execution-engine fields when the policy is off", async () => {
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{ adapterType: "claude_local", adapterConfig: MANAGED_AGENT_CONFIG },
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
const labels = fieldLabels(result.container);
expect(labels).toContain("Working directory (deprecated)");
expect(labels).toContain("Command");
expect(labels).toContain("Execution engine");
expect(labels).toContain("ACP server command");
expect(labels).toContain("ACP state directory");
expect(choosePathButtons(result.container).length).toBeGreaterThan(0);
const adapterFields = result.container.querySelector('[data-testid="adapter-config-fields"]');
expect(adapterFields?.getAttribute("data-managed-sandbox-only")).toBe("false");
expect(adapterFields?.getAttribute("data-hide-instructions-file")).toBe("false");
});
it("hides the host path and execution-engine fields for claude_local when the policy is on", async () => {
setManagedSandboxOnly(true);
const result = await renderForm(
[makeEnvironment({ id: "managed-1", name: "Managed", driver: "sandbox", config: { provider: "daytona" } })],
{ adapterType: "claude_local", adapterConfig: MANAGED_AGENT_CONFIG },
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
const labels = fieldLabels(result.container);
expect(labels).toContain("ACP session mode");
expect(labels).toContain("ACP non-interactive permissions");
expect(labels).not.toContain("Working directory (deprecated)");
expect(labels).not.toContain("Command");
expect(labels).not.toContain("Execution engine");
expect(labels).not.toContain("ACP server command");
expect(labels).not.toContain("ACP state directory");
expect(choosePathButtons(result.container)).toHaveLength(0);
// The stored values stay untouched: hiding is presentation, and an import
// that carries adapter configuration from another instance must still save.
expect(result.container.textContent).not.toContain("/srv/agents/cody");
});
it("keeps the host-path fields hidden while the policy is still loading", async () => {
// A cold cache resolves the policy to false on the first render. The gate
// fails closed so a managed instance never flashes a stored host path.
mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {}));
const result = await renderForm(
[makeEnvironment({ id: "local-1", name: "Local", driver: "local" })],
{ adapterType: "claude_local", adapterConfig: MANAGED_AGENT_CONFIG },
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
const labels = fieldLabels(result.container);
expect(labels).not.toContain("Working directory (deprecated)");
expect(labels).not.toContain("Command");
expect(labels).not.toContain("Execution engine");
expect(choosePathButtons(result.container)).toHaveLength(0);
expect(result.container.textContent).not.toContain("/srv/agents/cody");
});
it("hides the command field and forces the instructions-file gate for codex_local when the policy is on", async () => {
setManagedSandboxOnly(true);
const result = await renderForm(
[makeEnvironment({ id: "managed-1", name: "Managed", driver: "sandbox", config: { provider: "daytona" } })],
{ adapterType: "codex_local", adapterConfig: MANAGED_AGENT_CONFIG },
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
const labels = fieldLabels(result.container);
expect(labels).not.toContain("Working directory (deprecated)");
expect(labels).not.toContain("Command");
expect(choosePathButtons(result.container)).toHaveLength(0);
const adapterFields = result.container.querySelector('[data-testid="adapter-config-fields"]');
expect(adapterFields?.getAttribute("data-managed-sandbox-only")).toBe("true");
expect(adapterFields?.getAttribute("data-hide-instructions-file")).toBe("true");
});
});
describe("subtractPersistedOverlay", () => {
const overlayWith = (adapterConfig: Record<string, unknown>) => ({
identity: {},
adapterConfig,
heartbeat: {},
debug: {},
runtime: {},
});
it("drops an entry whose value is structurally equal even with a new reference", () => {
// An edit-then-restore rebuilds the env object, so a reference compare
// would keep it falsely dirty after the refresh subtracts the snapshot —
// a false "Unsaved changes" state and a redundant full-config save.
const persisted = overlayWith({
env: { CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" } },
});
const current = overlayWith({
env: { CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" } },
});
expect(subtractPersistedOverlay(current, persisted).adapterConfig).toEqual({});
});
it("keeps an entry the user changed after the snapshot", () => {
const persisted = overlayWith({
env: { CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" } },
});
const current = overlayWith({
env: {
CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" },
EXTRA: { type: "plain", value: "added-during-save" },
},
});
expect(subtractPersistedOverlay(current, persisted).adapterConfig).toEqual({
env: {
CODEX_HOME: { type: "secret_ref", secretId: "s-1", version: "latest" },
EXTRA: { type: "plain", value: "added-during-save" },
},
});
});
it("keeps an entry the snapshot never carried", () => {
const persisted = overlayWith({});
const current = overlayWith({ model: "gpt-5.5" });
expect(subtractPersistedOverlay(current, persisted).adapterConfig).toEqual({
model: "gpt-5.5",
});
});
it("compares arrays structurally", () => {
const persisted = overlayWith({ args: ["--flag", "value"] });
const equal = overlayWith({ args: ["--flag", "value"] });
const changed = overlayWith({ args: ["--flag", "other"] });
expect(subtractPersistedOverlay(equal, persisted).adapterConfig).toEqual({});
expect(subtractPersistedOverlay(changed, persisted).adapterConfig).toEqual({
args: ["--flag", "other"],
});
});
});