mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 06:15:21 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Users connect accounts and choose an agent harness and model. > - The runtime change in #14970 supports custom providers on those connections. > - Normal setup must stay simple while advanced users can choose a compatible gateway. > - Shared connector rows and access controls keep these choices consistent. > - This pull request refines the agent setup UI and adds review stories and repeatable browser qualification. > - The qualification checks real tools and downloaded outputs, not only a successful run status. ## Linked Issues or Issue Description Refs #14970, #37, #13083, #14104, #14565, #12692. The core implementation in #14970 is merged. This branch incorporates its squash commit and targets `master`. Both PRs contain our implementation. #14016 is a reference only and is not a dependency. This PR has 96 changed files. ## What Changed - Complete model-provider connector presentation beside other connectors. Each row uses the existing Connect action and connection list. Tags are stored without category UI. The base PR includes the provider forms and routes. - Show persistent Subscription, API Key, and Advanced choices. Label Advanced as Custom Gateway. Reuse provider logos, connection lists, and permissions controls. Default access to the organization and all agents when permitted; keep narrowing controls under Advanced. - Keep Configure reachable before subscription sign-in, so users can select a supported environment when the default cannot sign in. Testing and saving still require a connection. Show the execution environment in Configure. Preserve the confirmed Connect choice. Editing a method, credential, saved account, or advanced choice requires that current choice to connect before testing or saving. Use matching model and thinking-effort dropdowns and retain connection icons in selected values. - Preserve the new harness model default when switching an existing OpenCode agent to Codex or Claude, and resolve user-selected model names with the effective harness. - Load popular OpenRouter models through the shared connection-model discovery path. Keep explicit model lists and manual model entry available. - Group onboarding, connection setup, agent runtime, management, recovery, and production-component stories under AI Connections / Provider routing. - Add an explicit-only provider-connections browser suite for managed local or existing local/staging targets. Use private browser profiles and credential handoffs. Support human-assisted subscription sign-in without sharing passwords or tokens in reports. - Verify persisted connection identity, runtime probes, tool execution, exact artifact bytes, completion, and context-dependent follow-up. Retain source/model provenance, cost bounds, closed error diagnostics, original failures, and cleanup evidence. - Add Gemini startup-model and skill-root fixes, Grok private-history detection, ACP filesystem regression fixtures, selected-workspace handling for local Hermes, and artifact-helper workspace fallback. - Keep managed Grok runtime homes disposable. Remove host-side transcript retention/restoration because private file modes do not isolate same-user agent processes. Ignore earlier development archives and use a fresh task handoff when history is unavailable. Verify the absence of restored transcripts with a separate same-user process. - Capture stopped-run diagnostics before deleting an attached-company fixture agent. Track creation and owned sign-in receipts; revoke only this attempt's accounts and never adopt a concurrent campaign's newly created account. Preserve failure signals and final status through cleanup. - Require the requested environment in the saved agent and every run, including follow-ups. Reject a forced incompatible target. Keep one cancellation state through startup, every cell, reporting, and teardown for SIGINT, SIGTERM, and SIGHUP. Stop further paid cells after interruption. Document qualification limits. ## Verification - Current head `b3bb3e94d577d43d9965a6b9daba039f599b2e49` includes master `d9f600043`. The security fix in `a758fde31` passes full workspace typecheck, production build, and 119 connection/Grok regressions. The unchanged UI passes all 126 configuration/model-discovery tests and token gates. The final published-guide correction passes Grok adapter typecheck. Earlier head `eebd8225c` passed the complete deterministic runner suite (1,404 Vitest tests and 128 Node tests) and all CI jobs. Current-head CI run `37520147514` passed all 47 jobs, including the full sharded Vitest and browser matrix, production build, and canary dry run. All 55 checks completed: 53 successes and two expected skips. The current-head security scan passed, Greptile is 5/5, and no review threads remain open. - A separate same-user process reproduced reading a restored Grok transcript before the security fix. The regression now finds no transcript. Existing fresh-session fallback and ordinary session metadata behavior pass. - The final account-choice and cleanup fixes pass 85 setup tests and 26 qualification-harness tests. Regressions verify that editing a connection invalidates confirmation, Configure remains reachable before sign-in, diagnostics are captured before fixture deletion, and concurrent campaigns cannot adopt or revoke each other's accounts. UI and E2E typechecks pass. - The Storybook build and actual Chromium production-component stories passed during this change. Review the neighboring AI Connections / Provider routing stories, regular connector rows, three connection modes, model discovery, and the single execution-environment control in Configure. - Cancellation smoke verified authenticated cleanup before browser close for SIGINT, SIGTERM, and SIGHUP. Regressions cover interruption during startup and reporting, missing-file ACP resource errors, and preserved permission denials. Both ACP runtime versions and 54 ACPX/Grok regressions passed. The deterministic connection-intent browser suite passed two tests. - Historical local qualification retained 43 passing API/gateway cells out of 46, with downloaded outputs and follow-up receipts. These attempts span earlier builds; they do not qualify this exact commit or staging. Subscription combinations, Gemini overloads, and the unresolved follow-up failure remain recorded rather than counted as passing. - Use `pnpm test:e2e:runner -- --list --suite provider-connections` to inspect the matrix. Follow `tests/runner-e2e/PROVIDER-CONNECTIONS.md` for credentials, target URL, sign-in assistance, budget, evidence, and cleanup. Paid live tests remain opt-in. ## Risks - The core implementation in #14970 is merged. This PR adds no database migration of its own. - Subscription login needs an interactive provider session. Dedicated accounts and staging qualification remain follow-up work; this PR does not certify every login combination for production. - Managed Grok transcript resume is deferred until provider history has an OS isolation or authorized broker solution. Follow-ups start fresh with Paperclip task context; earlier live Grok results do not qualify this behavior. - Gemini CLI 0.58.0 has an upstream ACP new-file error conversion defect. Live overloads and one unresolved follow-up timeout remain recorded. The stock CLI is unchanged, and those cases are not marked as passing. - Real-provider tests spend credits and use private credential/evidence directories. The launcher requires explicit selection and checks target ownership. It must not attach to a developer's database by accident. - OpenClaw Gateway, Hermes Gateway, Claude Managed, AWS AgentCore, Process, HTTP, and legacy ACPX local remain outside custom provider setup. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser testing. The exact deployment model ID and context window size were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
3956 lines
150 KiB
TypeScript
3956 lines
150 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, subtractPersistedOverlay, type AdapterLoginDescriptor } from "./AgentConfigForm";
|
|
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 { DEFAULT_CODEX_LOCAL_MODEL } from "@paperclipai/adapter-codex-local";
|
|
|
|
const mockAgentsApi = vi.hoisted(() => ({
|
|
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/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 }));
|
|
}
|
|
|
|
async function renderForm(
|
|
environments: Environment[],
|
|
agentOverrides: Partial<Agent> = {},
|
|
options: {
|
|
showAdapterTestEnvironmentButton?: 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 = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AgentConfigForm
|
|
mode="edit"
|
|
agent={makeAgent(agentOverrides)}
|
|
onSave={onSave}
|
|
hidePromptTemplate
|
|
content={options.content}
|
|
environmentVariablesPlacement={options.environmentVariablesPlacement}
|
|
hideInlineSave={options.hideInlineSave}
|
|
onDirtyChange={options.onDirtyChange}
|
|
onSaveActionChange={options.onSaveActionChange}
|
|
onCancelActionChange={options.onCancelActionChange}
|
|
showAdapterTypeField={false}
|
|
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
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 = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
});
|
|
|
|
const values = {
|
|
...defaultCreateValues,
|
|
adapterType: "codex_local",
|
|
...valueOverrides,
|
|
};
|
|
const onChange = vi.fn();
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AgentConfigForm
|
|
mode="create"
|
|
values={values}
|
|
onChange={onChange}
|
|
hidePromptTemplate
|
|
showAdapterTypeField={false}
|
|
showAdapterTestEnvironmentButton={options.showAdapterTestEnvironmentButton ?? false}
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
],
|
|
{ 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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
],
|
|
{ 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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
],
|
|
{ adapterType: "grok_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
|
|
{ showAdapterTestEnvironmentButton: true },
|
|
);
|
|
}
|
|
|
|
async function renderClaudeSandbox(agentOverrides: Partial<Agent> = {}) {
|
|
return renderForm(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
],
|
|
{ adapterType: "claude_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
|
|
{ showAdapterTestEnvironmentButton: true },
|
|
);
|
|
}
|
|
|
|
async function renderCreateClaudeSandbox(
|
|
valueOverrides: Partial<typeof defaultCreateValues> = {},
|
|
) {
|
|
return renderCreateForm(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
],
|
|
{ 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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
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 () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<Harness />
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
await flushReact();
|
|
return { container, root, valuesRef };
|
|
}
|
|
|
|
async function selectEnvironment(container: HTMLElement, environmentId: string) {
|
|
const select = container.querySelector("select");
|
|
await act(async () => {
|
|
if (select) {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
|
setter?.call(select, environmentId);
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
});
|
|
await flushReact();
|
|
}
|
|
|
|
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[] = [];
|
|
|
|
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("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", undefined, DEFAULT_CODEX_LOCAL_MODEL],
|
|
["ACP agents", "acpx", "claude", "claude-sonnet-5"],
|
|
["Claude Managed", "claude_managed", undefined, "claude-sonnet-5"],
|
|
])("saves the %s harness default without the previous OpenCode model prefix", async (label, provider, acpxAgent, model) => {
|
|
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" } },
|
|
});
|
|
roots.push(result.root);
|
|
await act(async () => {
|
|
result.container.querySelector('[aria-label="Harness"]')!
|
|
.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')]
|
|
.find(element => element.textContent === label)!;
|
|
expect(option).toBeTruthy();
|
|
await act(async () => {
|
|
option.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
await clickByText(result.container, "Save");
|
|
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({
|
|
adapterConfig: expect.objectContaining({ provider, model, ...(acpxAgent ? { acpxAgent } : {}) }),
|
|
}));
|
|
} 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("Environment override");
|
|
expect(result.container.querySelector("select")).toBeNull();
|
|
});
|
|
|
|
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("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)");
|
|
expect(created.container.textContent).toContain("Default (claude-opus-5)");
|
|
expect(existing.onSave).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps secret access out of the main Configuration content", 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");
|
|
});
|
|
|
|
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([
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "E2B",
|
|
driver: "sandbox",
|
|
config: { provider: "e2b" },
|
|
}),
|
|
]);
|
|
roots.push(result.root);
|
|
|
|
const text = result.container.textContent ?? "";
|
|
const selector = result.container.querySelector("select");
|
|
|
|
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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "E2B",
|
|
driver: "sandbox",
|
|
config: { provider: "e2b" },
|
|
}),
|
|
],
|
|
{ adapterType: "grok_local" },
|
|
);
|
|
roots.push(result.root);
|
|
|
|
const text = result.container.textContent ?? "";
|
|
const selector = result.container.querySelector("select");
|
|
|
|
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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "E2B",
|
|
driver: "sandbox",
|
|
config: { provider: "e2b" },
|
|
}),
|
|
],
|
|
{ adapterType: "kimi_local" },
|
|
);
|
|
roots.push(result.root);
|
|
|
|
const text = result.container.textContent ?? "";
|
|
const selector = result.container.querySelector("select");
|
|
|
|
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 = result.container.querySelector("select");
|
|
|
|
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 = result.container.querySelector("select");
|
|
|
|
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("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("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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "E2B",
|
|
driver: "sandbox",
|
|
config: { provider: "e2b" },
|
|
}),
|
|
],
|
|
{ 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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "E2B",
|
|
driver: "sandbox",
|
|
config: { provider: "e2b" },
|
|
}),
|
|
],
|
|
{ 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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
],
|
|
{ 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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "E2B",
|
|
driver: "sandbox",
|
|
config: { provider: "e2b" },
|
|
}),
|
|
],
|
|
{ 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(
|
|
[
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
],
|
|
{ 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([
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
]);
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
roots.push(root);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
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 () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<LiftedFeedbackHarness />
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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("starts a login session for the effective sandbox and shows the 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);
|
|
|
|
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");
|
|
});
|
|
|
|
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);
|
|
|
|
// 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 = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
|
await act(async () => {
|
|
root.render(<QueryClientProvider client={queryClient}><ToastProvider><TooltipProvider>
|
|
<AdapterLoginPanel companyId="company-1" adapterType="grok_local" environmentId="new-sandbox"
|
|
aiConnection={{ ...intent, agentIds: [] }} chrome="onboarding" autoStart />
|
|
</TooltipProvider></ToastProvider></QueryClientProvider>);
|
|
});
|
|
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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AdapterLoginPanel
|
|
companyId="company-1"
|
|
adapterType="codex_local"
|
|
environmentId="sandbox-1"
|
|
chrome="onboarding"
|
|
autoStart
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
// 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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AdapterLoginPanel
|
|
companyId="company-1"
|
|
adapterType="codex_local"
|
|
environmentId="sandbox-1"
|
|
autoStart
|
|
onAccountBinding={onAccountBinding}
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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);
|
|
});
|
|
|
|
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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AdapterLoginPanel
|
|
companyId="company-1"
|
|
adapterType="codex_local"
|
|
environmentId="sandbox-1"
|
|
autoStart
|
|
onAccountBinding={onAccountBinding}
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AdapterLoginPanel
|
|
companyId="company-1"
|
|
adapterType="codex_local"
|
|
environmentId="sandbox-1"
|
|
autoStart
|
|
onAccountBinding={onAccountBinding}
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AdapterLoginPanel
|
|
companyId="company-1"
|
|
adapterType="codex_local"
|
|
environmentId="sandbox-1"
|
|
autoStart
|
|
onAccountBinding={onAccountBinding}
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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 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([
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
]);
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
roots.push(root);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
// 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 () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<RefreshHarness />
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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();
|
|
|
|
const select = result.container.querySelector("select");
|
|
await act(async () => {
|
|
if (select) {
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")?.set;
|
|
setter?.call(select, "");
|
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
}
|
|
});
|
|
await flushReact();
|
|
|
|
expect(findButton(result.container, "Sign in")).toBeFalsy();
|
|
});
|
|
|
|
it("shows the authorization URL and a browser-code input for a Claude sandbox", 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");
|
|
});
|
|
|
|
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([
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
]);
|
|
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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AdapterLoginPanel
|
|
companyId="company-1"
|
|
adapterType="claude_local"
|
|
environmentId="sandbox-1"
|
|
onStored={onStored}
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AdapterLoginPanel
|
|
companyId="company-1"
|
|
adapterType="claude_local"
|
|
environmentId="sandbox-1"
|
|
chrome="onboarding"
|
|
autoStart
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
// 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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AdapterLoginPanel
|
|
companyId="company-1"
|
|
adapterType="claude_local"
|
|
environmentId="sandbox-1"
|
|
onApplyStored={onApplyStored}
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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 = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AdapterLoginPanel
|
|
companyId="company-1"
|
|
adapterType="claude_local"
|
|
environmentId="sandbox-1"
|
|
onApplyStored={onApplyStored}
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(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([
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
]);
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
roots.push(root);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AgentConfigForm
|
|
mode="edit"
|
|
agent={makeAgent({
|
|
adapterType: "claude_local",
|
|
defaultEnvironmentId: "sandbox-1",
|
|
})}
|
|
onSave={vi.fn()}
|
|
hidePromptTemplate
|
|
showAdapterTypeField={false}
|
|
showAdapterTestEnvironmentButton
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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([
|
|
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
|
|
makeEnvironment({
|
|
id: "sandbox-1",
|
|
name: "Daytona",
|
|
driver: "sandbox",
|
|
config: { provider: "daytona" },
|
|
}),
|
|
]);
|
|
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
|
|
await act(async () => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<ToastProvider>
|
|
<TooltipProvider>
|
|
<AgentConfigForm
|
|
mode="edit"
|
|
agent={makeAgent({
|
|
adapterType: "claude_local",
|
|
defaultEnvironmentId: "sandbox-1",
|
|
...agentOverrides,
|
|
})}
|
|
onSave={onSave}
|
|
hidePromptTemplate
|
|
showAdapterTypeField={false}
|
|
showAdapterTestEnvironmentButton
|
|
/>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
|
|
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).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 non-path ACP controls visible when the policy hides the engine choice", 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");
|
|
});
|
|
|
|
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"],
|
|
});
|
|
});
|
|
});
|