Files
PaperClipAI/ui/src/lib/adapter-test-environment.test.ts
T
Nicky LeachandPaperclip 5bc6031f79 fix(server,ui,claude-local): verify auth on the adapter Test lane and enforce managed-sandbox tenant binding (#11810)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Adapter Test checks whether an agent adapter can run with its
configured environment, and every local-driver adapter (Claude, Codex,
Gemini, OpenCode, Pi, Cursor, etc.) shares this Test route and its UI
resolution logic
> - The Claude ACP Test lane could report pass without checking local or
remote authentication, and the shared Test route and UI had gaps in
environment binding, probe safety, and managed-sandbox resolution that
affect every adapter that uses the Test button, not only Claude
> - This pull request verifies authentication on every Claude ACP
target, and closes the shared Test-route/UI gaps: tenant-binding on the
route, a managed-sandbox-only redirect that matches the real run path,
and a three-tier environment resolution in the UI
> - The benefit is a truthful Test result with safer probe execution and
tenant isolation, for Claude specifically and for every other local
adapter that shares this Test surface

## Linked Issues or Issue Description

**What happened?**

The Claude ACP Test lane returned `status: "pass"` without checking
authentication for some local and non-sandbox targets. Separately, the
shared `/companies/:companyId/adapters/:type/test-environment` route —
used by every local-driver adapter, not only Claude — accepted a foreign
environment id, and its UI resolution did not mirror the server's
managed-sandbox-only redirect.

**Expected behavior**

The Test lane checks the resolved credential and hello probe for every
Claude ACP target. The shared adapter Test route rejects a foreign
environment before it reveals environment details or starts a lease, for
any adapter type. The Test's environment resolution (UI and server)
matches the real run's three-tier resolution, including the
managed-sandbox-only redirect.

**Steps to reproduce**

1. Run the Claude ACP Test lane against a local target without a valid
credential.
2. Run the adapter Test route with an environment id from another
company (any adapter type).
3. Observe the pass result on step 1, or the missing tenant-binding
rejection on step 2.

**Paperclip version or commit**

`933749e01f74e82ce5d315c071be534d04e01158`

**Deployment mode**

Local dev (`pnpm dev`) and server route tests.

**Agent adapter(s) involved**

Claude Code directly (the ACP auth-verification work). The
tenant-binding guard, managed-sandbox-only redirect, and UI three-tier
resolution apply to the shared adapter Test route and affect every
local-driver adapter (Codex, Gemini, OpenCode, Pi, Cursor, etc.), not
only Claude — see "What Changed" below for the split between Claude-only
and shared changes.

**Database mode**

Not database-related.

**Access context**

Both board and agent paths use the affected Test surface, for every
local-driver adapter.

**Additional context**

Two commits that were previously bundled into this PR — a
`plugin-worker-manager` duplex-channel frame-bound fix and a
`workspace-runtime` exit-persist crash fix — are unrelated to the
adapter Test lane and have been split out into their own PRs: #11860 and
#11861.

## What Changed

Claude-only (`packages/adapters/claude-local`):

- Verify `CLAUDE_CODE_OAUTH_TOKEN` and run the hello probe for every
Claude ACP target.
- Keep `adapter_auth_missing` sandbox-only and report missing
non-sandbox credentials as a warning.
- Add a deny-by-default probe environment builder for the ACP and CLI
local probes.
- Log only fixed probe context and allowlisted classifications.
- Seed the host OAuth token into the hello probe environment.

Shared, cross-adapter (`server/src/routes/agents.ts`,
`ui/src/lib/adapter-test-environment.ts`,
`ui/src/components/AgentConfigForm.tsx`,
`ui/src/components/OnboardingWizard.tsx`):

- Add a company-binding guard and a binding assertion for the generic
`/companies/:companyId/adapters/:type/test-environment` route, so a
foreign-company environment id is rejected before any secret resolution
or sandbox lease, for every adapter type.
- Resolve all three server environment tiers (agent default, instance
default, local default) in the UI, and add the managed-sandbox-only
redirect so the Test probes the same target a real run would use.
- Enforce onboarding Test results: block hire on a failed environment
test.

- Add regression tests for authentication, tenant binding, probe safety,
diagnostics, and UI resolution.

## Verification

- Adapter suites pass for the Claude local server probe, remote, ACP,
auth, probe environment, and config paths.
- Server route tests pass, including the five tenant-binding cases.
- UI adapter Test environment resolver tests pass for all three
resolution tiers.
- Adapter package `tsc --noEmit` exits 0.
- Full CI must pass on this pull request.

## Risks

The probe environment now denies caller variables by default. A required
variable that is not on the allowlist could stop a probe from starting.
The route now rejects foreign environment ids with a fixed 403 response.
The managed-sandbox-only redirect changes where the Test (and the login
affordance) probes for every local-driver adapter under that policy, not
only Claude — operators running other local adapters under
managed-sandbox-only will see their Test target move from local to the
managed sandbox, matching what real runs already do. The change limits
secret and diagnostic exposure.

## Model Used

Original implementation: OpenAI Codex, GPT-5; exact context window not
exposed in that run; tool use and code execution.

This revision (commit split and title/description correction): Claude,
Sonnet 5 (claude-sonnet-5). The original title and description described
this PR as Claude-only; review found it also changes the shared adapter
Test route and UI resolution used by every local-driver adapter, and
carried two unrelated server fixes. Claude split those two commits into
#11860 and #11861 via `git rebase --onto` (verified byte-identical to
the original tree minus those commits) and rewrote this description to
reflect the actual scope. No functional code in this PR was authored by
Claude.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-21 11:30:49 -07:00

305 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { Environment } from "@paperclipai/shared";
import {
ManagedSandboxUnavailableForTestError,
resolveAdapterTestEnvironmentId,
resolveLocalDefaultEnvironmentId,
resolveManagedSandboxEnvironmentId,
} from "./adapter-test-environment";
function makeEnvironment(overrides: Partial<Environment>): Environment {
return {
id: "env-id",
name: "Env",
description: null,
driver: "sandbox",
status: "active",
config: {},
envVars: {},
metadata: null,
createdAt: new Date(0),
updatedAt: new Date(0),
...overrides,
};
}
describe("resolveAdapterTestEnvironmentId", () => {
it("prefers the agent's own environment", () => {
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: "agent-env",
instanceDefaultEnvironmentId: "instance-env",
localDefaultEnvironmentId: "local-env",
}),
).toBe("agent-env");
});
it("falls back to the instance default when the agent has none", () => {
// The regression this pins: an agent relying on the instance default
// (e.g. a managed sandbox with extra CLIs baked into its image) must be
// tested inside that environment, not on the Paperclip host where the
// CLI does not exist.
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
instanceDefaultEnvironmentId: "instance-env",
localDefaultEnvironmentId: "local-env",
}),
).toBe("instance-env");
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: "",
instanceDefaultEnvironmentId: "instance-env",
localDefaultEnvironmentId: "local-env",
}),
).toBe("instance-env");
});
it("falls back to the local default when neither an agent nor an instance default is set", () => {
// The server resolves a run with no agent or instance default to the local
// default environment. The Test must probe the same environment, not the
// host, so a Test result matches a real run.
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
instanceDefaultEnvironmentId: null,
localDefaultEnvironmentId: "local-env",
}),
).toBe("local-env");
});
it("returns null (host probe) when no tier is set", () => {
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: undefined,
instanceDefaultEnvironmentId: undefined,
localDefaultEnvironmentId: undefined,
}),
).toBeNull();
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: "",
instanceDefaultEnvironmentId: null,
localDefaultEnvironmentId: null,
}),
).toBeNull();
});
it("redirects a local-default resolution to the managed sandbox under managed-sandbox-only", () => {
// The real run redirects a local resolution to the managed sandbox, so the
// Test must probe the managed sandbox, not the local default.
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
instanceDefaultEnvironmentId: null,
localDefaultEnvironmentId: "local-env",
managedSandboxOnly: true,
managedSandboxEnvironmentId: "managed-env",
}),
).toBe("managed-env");
});
it("redirects a no-tier resolution to the managed sandbox under managed-sandbox-only", () => {
// The policy hides the local tier, so the list holds no local row and the
// resolution lands on no tier. The real run still resolves to local and
// redirects to the managed sandbox, so the Test does the same.
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
instanceDefaultEnvironmentId: null,
localDefaultEnvironmentId: null,
managedSandboxOnly: true,
managedSandboxEnvironmentId: "managed-env",
}),
).toBe("managed-env");
});
it("fails closed when managed-sandbox-only is on but no managed sandbox exists", () => {
// The real run fails closed, so the Test must not fall back to a local host
// probe that reports a result for a target the run never uses.
expect(() =>
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
instanceDefaultEnvironmentId: null,
localDefaultEnvironmentId: "local-env",
managedSandboxOnly: true,
managedSandboxEnvironmentId: null,
}),
).toThrow(ManagedSandboxUnavailableForTestError);
});
it("leaves a non-local default untouched under managed-sandbox-only", () => {
// The policy hides local; it does not forbid an ssh or a user sandbox that
// an agent or instance default names, so the Test probes that environment.
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
instanceDefaultEnvironmentId: "ssh-env",
localDefaultEnvironmentId: "local-env",
managedSandboxOnly: true,
managedSandboxEnvironmentId: "managed-env",
}),
).toBe("ssh-env");
});
it("ignores the managed-sandbox redirect when the policy is off", () => {
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: null,
instanceDefaultEnvironmentId: null,
localDefaultEnvironmentId: "local-env",
managedSandboxOnly: false,
managedSandboxEnvironmentId: "managed-env",
}),
).toBe("local-env");
});
it("redirects an agent default that names the hidden local row to the managed sandbox", () => {
// The policy hides the local environment, so the client list holds no local
// row and the local-default lookup is null. An agent default that still
// names the hidden local row names no visible environment, so the resolver
// treats it as a local resolution and redirects to the managed sandbox.
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: "hidden-local-env",
instanceDefaultEnvironmentId: null,
localDefaultEnvironmentId: null,
managedSandboxOnly: true,
managedSandboxEnvironmentId: "managed-env",
visibleEnvironmentIds: ["managed-env", "ssh-env"],
}),
).toBe("managed-env");
});
it("fails closed when an agent default names the hidden local row and no managed sandbox exists", () => {
expect(() =>
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: "hidden-local-env",
instanceDefaultEnvironmentId: null,
localDefaultEnvironmentId: null,
managedSandboxOnly: true,
managedSandboxEnvironmentId: null,
visibleEnvironmentIds: ["ssh-env"],
}),
).toThrow(ManagedSandboxUnavailableForTestError);
});
it("leaves a visible non-local default untouched when a visibility list is supplied", () => {
expect(
resolveAdapterTestEnvironmentId({
agentDefaultEnvironmentId: "ssh-env",
instanceDefaultEnvironmentId: null,
localDefaultEnvironmentId: null,
managedSandboxOnly: true,
managedSandboxEnvironmentId: "managed-env",
visibleEnvironmentIds: ["ssh-env", "managed-env"],
}),
).toBe("ssh-env");
});
});
describe("resolveManagedSandboxEnvironmentId", () => {
it("finds the non-local platform-managed environment", () => {
const environments = [
makeEnvironment({
id: "local-1",
driver: "local",
metadata: { managedByPaperclip: true, defaultForInstance: true },
}),
makeEnvironment({
id: "sandbox-1",
driver: "sandbox",
metadata: { managedByPaperclip: true },
}),
];
// The local-default row also carries the managed stamp, so the resolver must
// exclude the local driver and return the sandbox row.
expect(resolveManagedSandboxEnvironmentId(environments)).toBe("sandbox-1");
});
it("returns null when no managed sandbox environment exists", () => {
const environments = [
makeEnvironment({ id: "sandbox-1", driver: "sandbox", metadata: null }),
makeEnvironment({
id: "local-1",
driver: "local",
metadata: { managedByPaperclip: true, defaultForInstance: true },
}),
];
expect(resolveManagedSandboxEnvironmentId(environments)).toBeNull();
expect(resolveManagedSandboxEnvironmentId([])).toBeNull();
expect(resolveManagedSandboxEnvironmentId(null)).toBeNull();
});
it("skips an archived managed sandbox", () => {
// The server run-time resolver requires an active managed sandbox, so the
// Test resolution must not select an archived one. An archived sandbox row
// still carries the managed stamp, so the status guard is the only filter
// that excludes it. A real run rejects an archived sandbox, so the Test must
// reject it too.
const environments = [
makeEnvironment({
id: "sandbox-archived",
driver: "sandbox",
status: "archived",
metadata: { managedByPaperclip: true },
}),
makeEnvironment({
id: "local-1",
driver: "local",
metadata: { managedByPaperclip: true, defaultForInstance: true },
}),
];
expect(resolveManagedSandboxEnvironmentId(environments)).toBeNull();
});
it("selects the active managed sandbox and skips an archived one", () => {
// When both an archived and an active managed sandbox exist, the resolver
// returns the active row, matching the server run-time resolver.
const environments = [
makeEnvironment({
id: "sandbox-archived",
driver: "sandbox",
status: "archived",
metadata: { managedByPaperclip: true },
}),
makeEnvironment({
id: "sandbox-active",
driver: "sandbox",
status: "active",
metadata: { managedByPaperclip: true },
}),
];
expect(resolveManagedSandboxEnvironmentId(environments)).toBe("sandbox-active");
});
});
describe("resolveLocalDefaultEnvironmentId", () => {
it("finds the local-driver instance-default environment", () => {
const environments = [
makeEnvironment({ id: "sandbox-1", driver: "sandbox" }),
makeEnvironment({
id: "local-1",
driver: "local",
metadata: { managedByPaperclip: true, defaultForInstance: true },
}),
];
expect(resolveLocalDefaultEnvironmentId(environments)).toBe("local-1");
});
it("ignores a local environment that is not the instance default", () => {
const environments = [
makeEnvironment({ id: "local-1", driver: "local", metadata: { defaultForInstance: false } }),
makeEnvironment({ id: "local-2", driver: "local", metadata: null }),
];
expect(resolveLocalDefaultEnvironmentId(environments)).toBeNull();
});
it("returns null for an empty or missing list", () => {
expect(resolveLocalDefaultEnvironmentId([])).toBeNull();
expect(resolveLocalDefaultEnvironmentId(null)).toBeNull();
expect(resolveLocalDefaultEnvironmentId(undefined)).toBeNull();
});
});