mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## 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>
305 lines
11 KiB
TypeScript
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();
|
|
});
|
|
});
|